Skip to content

Commit 4348a23

Browse files
Fix duplicate holdings (#187)
- Fix duplicate holdings, adding unit test
1 parent 5ff8565 commit 4348a23

File tree

4 files changed

+77
-57
lines changed

4 files changed

+77
-57
lines changed

QuantConnect.InteractiveBrokersBrokerage.Tests/InteractiveBrokersBrokerageAdditionalTests.cs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
using Order = QuantConnect.Orders.Order;
4545
using IB = QuantConnect.Brokerages.InteractiveBrokers.Client;
4646
using QuantConnect.Securities.IndexOption;
47+
using static QuantConnect.Brokerages.InteractiveBrokers.InteractiveBrokersAccountData;
4748

4849
namespace QuantConnect.Tests.Brokerages.InteractiveBrokers
4950
{
@@ -57,19 +58,53 @@ public class InteractiveBrokersBrokerageAdditionalTests
5758

5859
private InteractiveBrokersSymbolMapper _symbolMapper = new InteractiveBrokersSymbolMapper(Composer.Instance.GetPart<IMapFileProvider>());
5960

60-
[SetUp]
61+
[OneTimeSetUp]
6162
public void Setup()
6263
{
6364
Log.LogHandler = new NUnitLogHandler();
6465
PythonInitializer.Initialize();
6566
}
6667

67-
[TearDown]
68+
[OneTimeTearDown]
6869
public void TearDown()
6970
{
7071
PythonInitializer.Shutdown();
7172
}
7273

74+
[TestCase(-500, 623.794, 100, 622.181, -400, 624.19725, "B")]
75+
[TestCase(100, 210.101, -200, 210.044, -100, 209.987, "B")]
76+
[TestCase(-500, 623.794, -500, 623.794, -500, 623.794, "A")] // double A is ignored
77+
public void MergeHoldingMergesOppositeSignedAAPLPositions(decimal holdingPositionQuantity, decimal holdingAvgPrice, decimal incomePositionQuantity,
78+
decimal incomeAvgPrice, decimal expectedNewPositionQuantity, decimal expectedAvgPrice, string incomingAccount)
79+
{
80+
var aapl = Symbols.AAPL;
81+
82+
var holdings = new Dictionary<Symbol, MergedHoldings>();
83+
holdings[aapl] = new();
84+
85+
holdings[aapl].Merge(new Holding
86+
{
87+
Symbol = aapl,
88+
Quantity = holdingPositionQuantity,
89+
AveragePrice = holdingAvgPrice
90+
}, "A");
91+
92+
var incoming = new Holding
93+
{
94+
Symbol = aapl,
95+
Quantity = incomePositionQuantity,
96+
AveragePrice = incomeAvgPrice
97+
};
98+
99+
InteractiveBrokersBrokerage.MergeHolding(holdings, incoming, incomingAccount);
100+
101+
var merged = holdings[aapl];
102+
103+
Assert.AreEqual(expectedNewPositionQuantity, merged.Holding.Quantity);
104+
Assert.AreEqual(expectedAvgPrice, merged.Holding.AveragePrice);
105+
Assert.AreEqual(aapl, merged.Holding.Symbol);
106+
}
107+
73108
[Test]
74109
public void LoginFailOnInvalidUserName()
75110
{

QuantConnect.InteractiveBrokersBrokerage.Tests/InteractiveBrokersBrokerageTests.cs

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
using QuantConnect.Configuration;
2020
using QuantConnect.Data;
2121
using QuantConnect.Data.Market;
22-
using QuantConnect.Lean.Engine.DataFeeds;
2322
using QuantConnect.Logging;
2423
using QuantConnect.Orders;
2524
using QuantConnect.Securities;
@@ -733,37 +732,6 @@ public void PlaceMarketOrderWithDifferentFAGroupFilter()
733732
Assert.IsFalse(holdingsGroup2.Any(h => h.Symbol == Symbols.AAPL), "TestGroup2 should not hold AAPL.");
734733
}
735734

736-
[TestCase(-500, 623.794, 100, 622.181, -400, 624.19725)]
737-
[TestCase(100, 210.101, -200, 210.044, -100, 209.987)]
738-
public void MergeHoldingMergesOppositeSignedAAPLPositions(decimal holdingPositionQuantity, decimal holdingAvgPrice, decimal incomePositionQuantity, decimal incomeAvgPrice, decimal expectedNewPositionQuantity, decimal expectedAvgPrice)
739-
{
740-
var aapl = Symbols.AAPL;
741-
742-
var holdings = new Dictionary<string, Holding>
743-
{
744-
["AAPL"] = new Holding
745-
{
746-
Symbol = aapl,
747-
Quantity = holdingPositionQuantity,
748-
AveragePrice = holdingAvgPrice
749-
}
750-
};
751-
752-
var incoming = new Holding
753-
{
754-
Symbol = aapl,
755-
Quantity = incomePositionQuantity,
756-
AveragePrice = incomeAvgPrice
757-
};
758-
759-
InteractiveBrokersBrokerage.MergeHolding(holdings, incoming);
760-
761-
var merged = holdings["AAPL"];
762-
763-
Assert.AreEqual(expectedNewPositionQuantity, merged.Quantity);
764-
Assert.AreEqual(expectedAvgPrice, merged.AveragePrice);
765-
}
766-
767735
private List<Holding> ExecuteMarketOrderForGroup(string faGroup, Symbol symbol, int quantity)
768736
{
769737
Config.Set("ib-financial-advisors-group-filter", faGroup);

QuantConnect.InteractiveBrokersBrokerage/InteractiveBrokersAccountData.cs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* limitations under the License.
1414
*/
1515

16+
using System.Collections.Generic;
1617
using System.Collections.Concurrent;
1718

1819
namespace QuantConnect.Brokerages.InteractiveBrokers
@@ -35,7 +36,7 @@ public class InteractiveBrokersAccountData
3536
/// <summary>
3637
/// The account holdings indexed by symbol
3738
/// </summary>
38-
public ConcurrentDictionary<string, Holding> AccountHoldings { get; } = new ConcurrentDictionary<string, Holding>();
39+
public ConcurrentDictionary<Symbol, MergedHoldings> AccountHoldings { get; } = new ();
3940

4041
/// <summary>
4142
/// Clears this instance of <see cref="InteractiveBrokersAccountData"/>
@@ -46,5 +47,36 @@ public void Clear()
4647
CashBalances.Clear();
4748
AccountHoldings.Clear();
4849
}
50+
51+
public class MergedHoldings
52+
{
53+
private readonly HashSet<string> _accounts = new();
54+
public Holding Holding { get; set; } = new();
55+
public void Merge(Holding incoming, string account)
56+
{
57+
// only merge in holdings of multiple accounts, IB can send the same holdings for the same account multiple times
58+
if (_accounts.Add(account))
59+
{
60+
if (_accounts.Count == 1)
61+
{
62+
// shortcut
63+
Holding = incoming;
64+
return;
65+
}
66+
67+
// Merge with existing holding: add quantities and recalculate average price
68+
// This happens when holdings are reported separately (e.g., across FA group accounts)
69+
var totalCost = (Holding.AveragePrice * Holding.Quantity) + (incoming.AveragePrice * incoming.Quantity);
70+
71+
Holding.Quantity += incoming.Quantity;
72+
73+
// Avoid division by zero when position is fully closed
74+
if (Holding.Quantity != 0)
75+
{
76+
Holding.AveragePrice = totalCost / Holding.Quantity;
77+
}
78+
}
79+
}
80+
}
4981
}
5082
}

QuantConnect.InteractiveBrokersBrokerage/InteractiveBrokersBrokerage.cs

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -694,9 +694,9 @@ public override List<Holding> GetAccountHoldings()
694694
var utcNow = DateTime.UtcNow;
695695
var holdings = new List<Holding>();
696696

697-
foreach (var kvp in _accountData.AccountHoldings)
697+
foreach (var kvp in _accountData.AccountHoldings.Values)
698698
{
699-
var holding = ObjectActivator.Clone(kvp.Value);
699+
var holding = ObjectActivator.Clone(kvp.Holding);
700700

701701
if (holding.Quantity != 0)
702702
{
@@ -2673,7 +2673,7 @@ private void HandlePortfolioUpdates(object sender, IB.UpdatePortfolioEventArgs e
26732673
if (_loadExistingHoldings)
26742674
{
26752675
var holding = CreateHolding(e);
2676-
MergeHolding(_accountData.AccountHoldings, holding);
2676+
MergeHolding(_accountData.AccountHoldings, holding, e.AccountName);
26772677
}
26782678
}
26792679
catch (Exception exception)
@@ -2718,30 +2718,15 @@ private void HandlePortfolioUpdates(object sender, IB.UpdatePortfolioEventArgs e
27182718
/// This method is used when multiple updates for the same symbol can be received,
27192719
/// such as when FA (Financial Advisor) group accounts return positions for multiple sub-accounts.
27202720
/// </remarks>
2721-
internal static void MergeHolding(IDictionary<string, Holding> holdings, Holding incoming)
2721+
internal static void MergeHolding(IDictionary<Symbol, InteractiveBrokersAccountData.MergedHoldings> holdings, Holding incoming, string accountName)
27222722
{
2723-
var key = incoming.Symbol.Value;
2724-
27252723
lock (holdings)
27262724
{
2727-
if (holdings.TryGetValue(key, out var existing))
2728-
{
2729-
// Merge with existing holding: add quantities and recalculate average price
2730-
// This happens when holdings are reported separately (e.g., across FA group accounts)
2731-
var totalCost = (existing.AveragePrice * existing.Quantity) + (incoming.AveragePrice * incoming.Quantity);
2732-
2733-
existing.Quantity += incoming.Quantity;
2734-
2735-
// Avoid division by zero when position is fully closed
2736-
if (existing.Quantity != 0)
2737-
{
2738-
existing.AveragePrice = totalCost / existing.Quantity;
2739-
}
2740-
}
2741-
else
2725+
if (!holdings.TryGetValue(incoming.Symbol, out var mergedHoldings))
27422726
{
2743-
holdings[key] = incoming;
2727+
holdings[incoming.Symbol] = mergedHoldings = new InteractiveBrokersAccountData.MergedHoldings();
27442728
}
2729+
mergedHoldings.Merge(incoming, accountName);
27452730
}
27462731
}
27472732

0 commit comments

Comments
 (0)