From e092fc486a35ae3af810849e00f7c13849b88b1e Mon Sep 17 00:00:00 2001 From: Romazes Date: Wed, 4 Nov 2026 19:52:38 +0200 Subject: [PATCH 1/2] fix: run the option holdings sync in the brokerage behind an opt-out flag - the opt-out has to be a protected virtual on the base brokerage, which the transaction handler cannot read; the sync moves next to the cash sync, where the brokerage already receives the algorithm once a day, and raises the option notification the handler already turns into an exercise or an assignment - IB reports every option position change from its portfolio stream and its holdings read is empty after a reconnect, so it sets the flag to false once a NuGet with it ships; every other brokerage keeps the sync as today --- Brokerages/Brokerage.cs | 79 ++++++++++++++++++- .../BrokerageTransactionHandler.cs | 71 ----------------- .../BrokerageTransactionHandlerTests.cs | 2 +- 3 files changed, 78 insertions(+), 74 deletions(-) diff --git a/Brokerages/Brokerage.cs b/Brokerages/Brokerage.cs index 0d19a4d8be50..86e04cc45192 100644 --- a/Brokerages/Brokerage.cs +++ b/Brokerages/Brokerage.cs @@ -23,6 +23,7 @@ using System.Threading.Tasks; using QuantConnect.Interfaces; using QuantConnect.Securities; +using QuantConnect.Securities.Option; using QuantConnect.Orders.Fees; using System.Collections.Generic; using System.Collections.Concurrent; @@ -514,7 +515,7 @@ protected virtual List GetCashBalance(Dictionary bro /// nothing when a contract expires or is assigned. A brokerage that reports every option position change /// itself returns false /// - public virtual bool HoldingsSyncEnabled => true; + protected virtual bool HoldingsSyncEnabled => true; /// /// Cache holding the legs of a combo order until all of them have been placed, so the brokerage can submit them together @@ -589,7 +590,7 @@ public virtual bool ShouldPerformCashSync(DateTime currentTimeUtc) } /// - /// Synchronizes the cashbook with the brokerage account + /// Synchronizes the cashbook, and the option holdings, with the brokerage account /// /// The algorithm instance /// The current time (UTC) @@ -606,6 +607,8 @@ public virtual bool PerformCashSync(IAlgorithm algorithm, DateTime currentTimeUt return false; } + SyncOptionHoldings(algorithm, currentTimeUtc); + Log.Trace("Brokerage.PerformCashSync(): Sync cash balance"); List balances = null; @@ -698,6 +701,78 @@ public virtual bool PerformCashSync(IAlgorithm algorithm, DateTime currentTimeUt return true; } + /// + /// Syncs the option holdings with the brokerage once a day, with the cash sync, for the brokerages that report + /// nothing when a contract expires or is assigned: a contract the brokerage no longer lists is reported as position 0 + /// + /// The algorithm instance + /// The current time (UTC) + private void SyncOptionHoldings(IAlgorithm algorithm, DateTime currentTimeUtc) + { + if (!HoldingsSyncEnabled) + { + return; + } + + try + { + var brokerageHoldings = default(Dictionary); + foreach (var security in algorithm.Securities.Values) + { + if (!security.Invested || !security.Symbol.SecurityType.IsOption()) + { + continue; + } + + if (brokerageHoldings == null) + { + Log.Trace("Brokerage.SyncOptionHoldings(): Start syncing option holdings..."); + brokerageHoldings = []; + foreach (var holding in GetAccountHoldings()) + { + if (holding.Symbol.SecurityType.IsOption()) + { + brokerageHoldings.Add(holding.Symbol, holding.Quantity); + } + } + } + + var position = 0m; + if (!OptionSymbol.IsOptionContractExpired(security.Symbol, currentTimeUtc)) + { + brokerageHoldings.TryGetValue(security.Symbol, out position); + } + + if (position != security.Holdings.Quantity) + { + var underlying = (security as Option).Underlying; + if (underlying.Price == 0) + { + var lastKnownPrice = algorithm.GetLastKnownPrice(underlying.Symbol); + underlying.SetMarketPrice(lastKnownPrice); + if (underlying.Price == 0) + { + Log.Error($"Brokerage.SyncOptionHoldings(): no price for {underlying.Symbol.Value}, {security.Symbol.Value} not synced."); + continue; + } + } + + Log.Trace($"Brokerage.SyncOptionHoldings(): syncing option holding: Symbol: {security.Symbol.Value}, Holdings: {security.Holdings.Quantity}, Position: {position}"); + OnOptionNotification(new OptionNotificationEventArgs(security.Symbol, position)); + } + } + + if (brokerageHoldings != null) + { + Log.Trace($"Brokerage.SyncOptionHoldings(): Brokerage option holdings count {brokerageHoldings.Count}. Exit"); + } + } + catch (Exception err) + { + Log.Error(err, "Brokerage.SyncOptionHoldings(): failed to sync the option holdings."); + } + } + #endregion #region CrossZeroOrder implementation diff --git a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs index 48e006b2973f..4e923bd59a28 100644 --- a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs +++ b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs @@ -748,7 +748,6 @@ public virtual void ProcessSynchronousEvents() // only perform cash syncs if we haven't had a fill for at least 10 seconds if (TimeSinceLastFill > TimeSpan.FromSeconds(10)) { - SyncOptionHoldings(); if (!_brokerage.PerformCashSync(_algorithm, CurrentTimeUtc, () => TimeSinceLastFill)) { if (++_failedCashSyncAttempts >= MaxCashSyncAttempts) @@ -1734,76 +1733,6 @@ private void HandleDelistingNotification(DelistingNotificationEventArgs e) } } - /// - /// Syncs the option holdings with the brokerage once a day, with the cash sync, for the brokerages that report - /// nothing when a contract expires or is assigned - /// - private void SyncOptionHoldings() - { - if (_brokerage is Brokerage { HoldingsSyncEnabled: false }) - { - return; - } - - try - { - var brokerageHoldings = default(Dictionary); - foreach (var security in _algorithm.Securities.Values) - { - if (!security.Invested || !security.Symbol.SecurityType.IsOption()) - { - continue; - } - - if (brokerageHoldings == null) - { - Log.Trace("BrokerageTransactionHandler.SyncOptionHoldings(): Start syncing option holdings..."); - brokerageHoldings = []; - foreach (var holding in _brokerage.GetAccountHoldings()) - { - if (holding.Symbol.SecurityType.IsOption()) - { - brokerageHoldings.Add(holding.Symbol, holding.Quantity); - } - } - } - - var position = 0m; - if (!OptionSymbol.IsOptionContractExpired(security.Symbol, CurrentTimeUtc)) - { - brokerageHoldings.TryGetValue(security.Symbol, out position); - } - - if (position != security.Holdings.Quantity) - { - var underlying = (security as Option).Underlying; - if (underlying.Price == 0) - { - var lastKnownPrice = _algorithm.GetLastKnownPrice(underlying.Symbol); - underlying.SetMarketPrice(lastKnownPrice); - if (underlying.Price == 0) - { - Log.Error($"BrokerageTransactionHandler.SyncOptionHoldings(): no price for {underlying.Symbol.Value}, {security.Symbol.Value} not synced."); - continue; - } - } - - Log.Trace($"BrokerageTransactionHandler.SyncOptionHoldings(): syncing option holding: Symbol: {security.Symbol.Value}, Holdings: {security.Holdings.Quantity}, Position: {position}"); - HandleOptionNotification(new OptionNotificationEventArgs(security.Symbol, position)); - } - } - - if (brokerageHoldings != null) - { - Log.Trace($"BrokerageTransactionHandler.SyncOptionHoldings(): Brokerage option holdings count {brokerageHoldings.Count}. Exit"); - } - } - catch (Exception err) - { - Log.Error(err, "BrokerageTransactionHandler.SyncOptionHoldings(): failed to sync the option holdings."); - } - } - /// /// Option notification event is received and new order events are generated /// diff --git a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs index 0cf7897249d5..45646d83a332 100644 --- a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs +++ b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs @@ -3498,7 +3498,7 @@ internal class NoSubmitTestBrokerage : Brokerage public override bool IsConnected => _underlyingBrokerage.IsConnected; - public override bool HoldingsSyncEnabled { get; } + protected override bool HoldingsSyncEnabled { get; } public NoSubmitTestBrokerage(IAlgorithm algorithm, bool holdingsSyncEnabled = true) : base("NoSubmitTestBrokerage") { From 6ff5aaba0a654be62171a30bfe3519e53886eae9 Mon Sep 17 00:00:00 2001 From: Romazes Date: Wed, 4 Nov 2026 20:53:03 +0200 Subject: [PATCH 2/2] refactor: option holdings sync as a public virtual method, no flag - a protected flag next to the sync was one switch too many; a brokerage that reports every option position change itself now overrides PerformOptionHoldingsSync and returns true, the same shape as PerformCashSync - PerformCashSync ignores the result, so a failed holdings read is retried the next day and never counts as a failed cash sync, as before --- Brokerages/Brokerage.cs | 36 ++++++++----------- .../BrokerageTransactionHandlerTests.cs | 25 +++++++++---- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/Brokerages/Brokerage.cs b/Brokerages/Brokerage.cs index 86e04cc45192..29e9c33f066a 100644 --- a/Brokerages/Brokerage.cs +++ b/Brokerages/Brokerage.cs @@ -510,13 +510,6 @@ protected virtual List GetCashBalance(Dictionary bro /// public virtual bool AccountInstantlyUpdated => false; - /// - /// True when Lean syncs the option holdings with the brokerage once a day, for the brokerages that report - /// nothing when a contract expires or is assigned. A brokerage that reports every option position change - /// itself returns false - /// - protected virtual bool HoldingsSyncEnabled => true; - /// /// Cache holding the legs of a combo order until all of them have been placed, so the brokerage can submit them together /// @@ -607,7 +600,7 @@ public virtual bool PerformCashSync(IAlgorithm algorithm, DateTime currentTimeUt return false; } - SyncOptionHoldings(algorithm, currentTimeUtc); + PerformOptionHoldingsSync(algorithm, currentTimeUtc); Log.Trace("Brokerage.PerformCashSync(): Sync cash balance"); @@ -702,18 +695,16 @@ public virtual bool PerformCashSync(IAlgorithm algorithm, DateTime currentTimeUt } /// - /// Syncs the option holdings with the brokerage once a day, with the cash sync, for the brokerages that report - /// nothing when a contract expires or is assigned: a contract the brokerage no longer lists is reported as position 0 + /// Synchronizes the option holdings with the brokerage account, once a day with the cash sync, for the brokerages + /// that report nothing when a contract expires or is assigned: a contract the brokerage no longer lists is raised as + /// an option notification with position 0. A brokerage that reports every option position change itself overrides + /// this method and returns true /// /// The algorithm instance /// The current time (UTC) - private void SyncOptionHoldings(IAlgorithm algorithm, DateTime currentTimeUtc) + /// True if the option holdings sync was performed successfully + public virtual bool PerformOptionHoldingsSync(IAlgorithm algorithm, DateTime currentTimeUtc) { - if (!HoldingsSyncEnabled) - { - return; - } - try { var brokerageHoldings = default(Dictionary); @@ -726,7 +717,7 @@ private void SyncOptionHoldings(IAlgorithm algorithm, DateTime currentTimeUtc) if (brokerageHoldings == null) { - Log.Trace("Brokerage.SyncOptionHoldings(): Start syncing option holdings..."); + Log.Trace("Brokerage.PerformOptionHoldingsSync(): Start syncing option holdings..."); brokerageHoldings = []; foreach (var holding in GetAccountHoldings()) { @@ -752,24 +743,27 @@ private void SyncOptionHoldings(IAlgorithm algorithm, DateTime currentTimeUtc) underlying.SetMarketPrice(lastKnownPrice); if (underlying.Price == 0) { - Log.Error($"Brokerage.SyncOptionHoldings(): no price for {underlying.Symbol.Value}, {security.Symbol.Value} not synced."); + Log.Error($"Brokerage.PerformOptionHoldingsSync(): no price for {underlying.Symbol.Value}, {security.Symbol.Value} not synced."); continue; } } - Log.Trace($"Brokerage.SyncOptionHoldings(): syncing option holding: Symbol: {security.Symbol.Value}, Holdings: {security.Holdings.Quantity}, Position: {position}"); + Log.Trace($"Brokerage.PerformOptionHoldingsSync(): syncing option holding: Symbol: {security.Symbol.Value}, Holdings: {security.Holdings.Quantity}, Position: {position}"); OnOptionNotification(new OptionNotificationEventArgs(security.Symbol, position)); } } if (brokerageHoldings != null) { - Log.Trace($"Brokerage.SyncOptionHoldings(): Brokerage option holdings count {brokerageHoldings.Count}. Exit"); + Log.Trace($"Brokerage.PerformOptionHoldingsSync(): Brokerage option holdings count {brokerageHoldings.Count}. Exit"); } + + return true; } catch (Exception err) { - Log.Error(err, "Brokerage.SyncOptionHoldings(): failed to sync the option holdings."); + Log.Error(err, "Brokerage.PerformOptionHoldingsSync(): failed to sync the option holdings."); + return false; } } diff --git a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs index 45646d83a332..52657918e0fa 100644 --- a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs +++ b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs @@ -2298,9 +2298,9 @@ public void ProcessSynchronousEventsSkipsTheOptionHoldingsSyncForTheDayWhenTheBr } [TestCase(true, 1, 0, -600, Description = "Short Call -> ITM, not listed at the brokerage, the sync books the assignment")] - [TestCase(false, 0, -7, 100, Description = "Short Call -> ITM, not listed at the brokerage, the brokerage reports option position changes itself, nothing booked")] - public void ProcessSynchronousEventsSyncsTheOptionHoldingsOnlyWhenTheBrokerageEnablesIt( - bool holdingsSyncEnabled, + [TestCase(false, 0, -7, 100, Description = "Short Call -> ITM, not listed at the brokerage, the brokerage overrides the sync away, nothing booked")] + public void ProcessSynchronousEventsSkipsTheOptionHoldingsSyncWhenTheBrokerageOverridesIt( + bool performOptionHoldingsSync, int expectedTickets, int expectedOptionPosition, int expectedUnderlyingPosition @@ -2316,7 +2316,7 @@ int expectedUnderlyingPosition equity.SetMarketPrice(new Tick { Value = 455m }); algorithm.SetLiveMode(true); - using var brokerage = new NoSubmitTestBrokerage(algorithm, holdingsSyncEnabled); + using var brokerage = new NoSubmitTestBrokerage(algorithm, performOptionHoldingsSync); // the brokerage does not list the contract, as a stale holdings read does after a reconnect brokerage.AccountHoldings = new List { new Holding { Symbol = equity.Symbol, Quantity = 100 } }; _transactionHandler = new TestBrokerageTransactionHandler(); @@ -3498,12 +3498,23 @@ internal class NoSubmitTestBrokerage : Brokerage public override bool IsConnected => _underlyingBrokerage.IsConnected; - protected override bool HoldingsSyncEnabled { get; } + // false stands for a brokerage that reports every option position change itself and overrides the sync away + private readonly bool _performOptionHoldingsSync; - public NoSubmitTestBrokerage(IAlgorithm algorithm, bool holdingsSyncEnabled = true) : base("NoSubmitTestBrokerage") + public NoSubmitTestBrokerage(IAlgorithm algorithm, bool performOptionHoldingsSync = true) : base("NoSubmitTestBrokerage") { _underlyingBrokerage = new BacktestingBrokerage(algorithm); - HoldingsSyncEnabled = holdingsSyncEnabled; + _performOptionHoldingsSync = performOptionHoldingsSync; + } + + public override bool PerformOptionHoldingsSync(IAlgorithm algorithm, DateTime currentTimeUtc) + { + if (!_performOptionHoldingsSync) + { + return true; + } + + return base.PerformOptionHoldingsSync(algorithm, currentTimeUtc); } public override bool PlaceOrder(Order order) {