From bfbba7789677d0c5456c3ad80bcfbd0a11aab32c Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 16 Jul 2026 14:18:05 -0400 Subject: [PATCH 1/6] Force live results store once holdings changes settle Monitor the securities holdings quantity changes in the live trading result handler and force a store of the full results once the changes have settled for a configurable period (holdings-changed-store-delay, defaults to 10 seconds), so stored results reflect fills quickly instead of waiting for the next scheduled store. --- Engine/Results/LiveTradingResultHandler.cs | 60 ++++++++- .../Results/LiveTradingResultHandlerTests.cs | 115 ++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/Engine/Results/LiveTradingResultHandler.cs b/Engine/Results/LiveTradingResultHandler.cs index 9d7b1dbfec35..2745d8daebac 100644 --- a/Engine/Results/LiveTradingResultHandler.cs +++ b/Engine/Results/LiveTradingResultHandler.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Diagnostics; using System.IO; using System.Linq; @@ -58,6 +59,12 @@ public class LiveTradingResultHandler : BaseResultsHandler, IResultHandler private readonly TimeSpan _storeInsightPeriod; + // Holdings change monitoring: once holdings change and settle for the configured period, + // we force a store of the full results without waiting for the next scheduled store + private readonly TimeSpan _holdingsChangedStoreDelay; + private long _lastHoldingsChangedTicks; + private long _lastStoredHoldingsChangedTicks; + private DateTime _nextPortfolioMarginUpdate; private DateTime _previousPortfolioMarginUpdate; private readonly TimeSpan _samplePortfolioPeriod; @@ -93,6 +100,7 @@ public LiveTradingResultHandler() _samplePortfolioPeriod = _storeInsightPeriod = TimeSpan.FromMinutes(10); _streamedChartLimit = Config.GetInt("streamed-chart-limit", 12); _streamedChartGroupSize = Config.GetInt("streamed-chart-group-size", 3); + _holdingsChangedStoreDelay = TimeSpan.FromSeconds(Config.GetDouble("holdings-changed-store-delay", 10)); } /// @@ -212,6 +220,9 @@ private void Update() //Profit loss changes, get the banner statistics, summary information on the performance for the headers. var serverStatistics = GetServerStatistics(utcNow); + // read the last holdings change time before taking the snapshot so a change slipping in + // between the snapshot and the store is never marked as stored + var lastHoldingsChangedTicks = Interlocked.Read(ref _lastHoldingsChangedTicks); var holdings = GetHoldings(Algorithm.Securities.Values, Algorithm.SubscriptionManager.SubscriptionDataConfigService); //Add the algorithm statistics first. @@ -235,8 +246,12 @@ private void Update() MessagingHandler.Send(liveResultPacket); } - //Send full packet to storage. - if (utcNow > _nextChartsUpdate) + // Send full packet to storage. Holdings changes not yet persisted force a store once they + // have settled for the configured delay, so the stored results reflect fills quickly + // instead of waiting for the next scheduled store + var holdingsSettled = lastHoldingsChangedTicks > _lastStoredHoldingsChangedTicks + && utcNow.Ticks >= lastHoldingsChangedTicks + _holdingsChangedStoreDelay.Ticks; + if (utcNow > _nextChartsUpdate || holdingsSettled) { Log.Debug("LiveTradingResultHandler.Update(): Pre-store result"); var chartComplete = new Dictionary(); @@ -258,6 +273,7 @@ private void Update() Algorithm.Transactions.TransactionRecord, holdings, Algorithm.Portfolio.CashBook, deltaStatistics, runtimeStatistics, orderEvents, statistics.TotalPerformance, serverStatistics, state: GetAlgorithmState()))); StoreResult(complete); + _lastStoredHoldingsChangedTicks = lastHoldingsChangedTicks; _nextChartsUpdate = DateTime.UtcNow.Add(ChartUpdateInterval); Log.Debug("LiveTradingResultHandler.Update(): End-store result"); } @@ -769,6 +785,37 @@ public override void SetAlgorithm(IAlgorithm algorithm, decimal startingPortfoli base.SetAlgorithm(algorithm, startingPortfolioValue); Algorithm.SetStatisticsService(this); + // we must be notified each time our holdings change, so each time a security is added, we + // want to bind to its SecurityHolding.QuantityChanged event, to force a results store once they settle + foreach (var security in algorithm.Securities.Values) + { + security.Holdings.QuantityChanged += HoldingsOnQuantityChanged; + } + + algorithm.Securities.CollectionChanged += (sender, args) => + { + var items = args.NewItems ?? new List(); + if (args.OldItems != null) + { + foreach (var item in args.OldItems) + { + items.Add(item); + } + } + + foreach (Security security in items) + { + if (args.Action == NotifyCollectionChangedAction.Add) + { + security.Holdings.QuantityChanged += HoldingsOnQuantityChanged; + } + else if (args.Action == NotifyCollectionChangedAction.Remove) + { + security.Holdings.QuantityChanged -= HoldingsOnQuantityChanged; + } + } + }; + // we need to forward Console.Write messages to the algorithm's Debug function var debug = new FuncTextWriter(algorithm.Debug); var error = new FuncTextWriter(algorithm.Error); @@ -778,6 +825,15 @@ public override void SetAlgorithm(IAlgorithm algorithm, decimal startingPortfoli UpdateAlgorithmStatus(); } + /// + /// Keeps track of the last time any security holding quantity changed, so the update loop + /// can force a results store once holdings have settled + /// + private void HoldingsOnQuantityChanged(object sender, SecurityHoldingQuantityChangedEventArgs e) + { + Interlocked.Exchange(ref _lastHoldingsChangedTicks, DateTime.UtcNow.Ticks); + } + /// /// Send a algorithm status update to the user of the algorithms running state. /// diff --git a/Tests/Engine/Results/LiveTradingResultHandlerTests.cs b/Tests/Engine/Results/LiveTradingResultHandlerTests.cs index 251476905ca9..558d0e7b0fb1 100644 --- a/Tests/Engine/Results/LiveTradingResultHandlerTests.cs +++ b/Tests/Engine/Results/LiveTradingResultHandlerTests.cs @@ -16,14 +16,19 @@ using System; using System.Linq; +using System.Threading; +using System.Diagnostics; using NUnit.Framework; using QuantConnect.Packets; +using QuantConnect.Logging; using QuantConnect.Securities; using QuantConnect.Interfaces; +using QuantConnect.Configuration; using QuantConnect.Lean.Engine.Results; using QuantConnect.Lean.Engine.DataFeeds; using QuantConnect.Data.UniverseSelection; using QuantConnect.Tests.Engine.DataFeeds; +using QuantConnect.Brokerages.Backtesting; using QuantConnect.Lean.Engine.TransactionHandlers; using QuantConnect.Tests.Common.Data.UniverseSelection; using QuantConnect.Data.Custom.IconicTypes; @@ -313,11 +318,121 @@ public void TrimChartsKeepsDailySampleOfStatisticsSeries() .Select(v => (v.Time, v.Open, v.High, v.Low, v.Close)).ToList()); } + [Test] + public void HoldingsChangeForcesResultsStoreOnceSettled() + { + var settleDelay = TimeSpan.FromSeconds(2); + Config.Set("holdings-changed-store-delay", settleDelay.TotalSeconds.ToStringInvariant()); + using var api = new Api.Api(); + using var messaging = new QuantConnect.Messaging.Messaging(); + var resultHandler = new TestableForceStoreOnSettledHoldingsResultHandler(); + + try + { + var algorithm = new AlgorithmStub(createDataManager: false); + var dataManager = new DataManagerStub(new TestDataFeed(), algorithm); + algorithm.SubscriptionManager.SetDataManager(dataManager); + var spy = algorithm.AddEquity("SPY"); + algorithm.PostInitialize(); + + var transactionHandler = new BacktestingTransactionHandler(); + using var brokerage = new BacktestingBrokerage(algorithm); + transactionHandler.Initialize(algorithm, brokerage, resultHandler); + + resultHandler.Initialize(new(new LiveNodePacket(), messaging, api, transactionHandler, null)); + resultHandler.SetAlgorithm(algorithm, 100000); + algorithm.SetLocked(); + + var expectedStores = 1; + // the first update pass always stores because the scheduled store is due right away + Assert.IsTrue(resultHandler.WaitForStore(expectedStores, TimeSpan.FromSeconds(30)), "Initial scheduled store did not happen"); + + // without holdings changes, no store is forced even after the settle delay elapses + Assert.IsFalse(resultHandler.WaitForStore(expectedStores + 1, settleDelay + TimeSpan.FromSeconds(1)), "A store happened without holdings changes"); + + // a position is opened for an existing security + spy.Holdings.SetHoldings(100, 10); + var stopwatch = Stopwatch.StartNew(); + + // no store is forced before the holdings settle + Assert.IsFalse(resultHandler.WaitForStore(expectedStores + 1, TimeSpan.FromSeconds(1)), "A store was forced before the holdings settled"); + // but once they settle, a store is forced without waiting for the scheduled one + Assert.IsTrue(resultHandler.WaitForStore(++expectedStores, settleDelay + TimeSpan.FromSeconds(5)), "No store was forced after the opened position settled"); + Assert.GreaterOrEqual(stopwatch.Elapsed, settleDelay); + + // the existing position is increased + spy.Holdings.SetHoldings(100, 20); + Assert.IsTrue(resultHandler.WaitForStore(++expectedStores, settleDelay + TimeSpan.FromSeconds(5)), "No store was forced after the increased position settled"); + + // the existing position is reduced + spy.Holdings.SetHoldings(100, 5); + Assert.IsTrue(resultHandler.WaitForStore(++expectedStores, settleDelay + TimeSpan.FromSeconds(5)), "No store was forced after the reduced position settled"); + + // a security added after the algorithm started is monitored too + var aapl = algorithm.AddEquity("AAPL"); + aapl.Holdings.SetHoldings(200, 10); + Assert.IsTrue(resultHandler.WaitForStore(++expectedStores, settleDelay + TimeSpan.FromSeconds(5)), "No store was forced after the added security holdings settled"); + + // the added security position is liquidated before removing it, like QCAlgorithm.RemoveSecurity does + aapl.Holdings.SetHoldings(200, 0); + Assert.IsTrue(resultHandler.WaitForStore(++expectedStores, settleDelay + TimeSpan.FromSeconds(5)), "No store was forced after the liquidation settled"); + + // once the security is removed, its holdings are no longer monitored. + // this will also fail if any of the previous changes forced more than one store + algorithm.Securities.Remove(aapl.Symbol); + aapl.Holdings.SetHoldings(200, 10); + Assert.IsFalse(resultHandler.WaitForStore(expectedStores + 1, settleDelay + TimeSpan.FromSeconds(1)), "A store was forced for a removed security"); + } + finally + { + resultHandler.Exit(); + Config.Reset(); + } + } + private class TestableLiveTradingResultHandler : LiveTradingResultHandler { public void PublicTrimCharts(DateTime utcNow) => TrimCharts(utcNow); } + private class TestableForceStoreOnSettledHoldingsResultHandler : LiveTradingResultHandler + { + private int _storeCount; + + // speed up the update loop so the test can use short settle delays + protected override TimeSpan MainUpdateInterval => TimeSpan.FromMilliseconds(100); + + public TestableForceStoreOnSettledHoldingsResultHandler() + { + // keep the scheduled store out of the way so only forced stores happen after the initial one + ChartUpdateInterval = TimeSpan.FromMinutes(10); + } + + public bool WaitForStore(int count, TimeSpan timeout) + { + var start = DateTime.UtcNow; + while (DateTime.UtcNow - start < timeout) + { + if (Interlocked.CompareExchange(ref _storeCount, 0, 0) >= count) + { + return true; + } + Thread.Sleep(50); + } + return Interlocked.CompareExchange(ref _storeCount, 0, 0) >= count; + } + + protected override void StoreResult(Packet packet) + { + Interlocked.Increment(ref _storeCount); + } + + public override string SaveLogs(string id, List logs) + { + return string.Empty; + } + } + private class TestDataFeed : IDataFeed { public bool IsActive { get; } From a54f5d2892facf9a5201cd6fdf6870dd2dd57ffa Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 16 Jul 2026 14:22:50 -0400 Subject: [PATCH 2/6] Encapsulate holdings change monitoring in a private nested class --- Engine/Results/LiveTradingResultHandler.cs | 131 +++++++++++++-------- 1 file changed, 79 insertions(+), 52 deletions(-) diff --git a/Engine/Results/LiveTradingResultHandler.cs b/Engine/Results/LiveTradingResultHandler.cs index 2745d8daebac..6311a59851c2 100644 --- a/Engine/Results/LiveTradingResultHandler.cs +++ b/Engine/Results/LiveTradingResultHandler.cs @@ -59,11 +59,7 @@ public class LiveTradingResultHandler : BaseResultsHandler, IResultHandler private readonly TimeSpan _storeInsightPeriod; - // Holdings change monitoring: once holdings change and settle for the configured period, - // we force a store of the full results without waiting for the next scheduled store - private readonly TimeSpan _holdingsChangedStoreDelay; - private long _lastHoldingsChangedTicks; - private long _lastStoredHoldingsChangedTicks; + private readonly HoldingsChangeMonitor _holdingsChangeMonitor = new(); private DateTime _nextPortfolioMarginUpdate; private DateTime _previousPortfolioMarginUpdate; @@ -100,7 +96,6 @@ public LiveTradingResultHandler() _samplePortfolioPeriod = _storeInsightPeriod = TimeSpan.FromMinutes(10); _streamedChartLimit = Config.GetInt("streamed-chart-limit", 12); _streamedChartGroupSize = Config.GetInt("streamed-chart-group-size", 3); - _holdingsChangedStoreDelay = TimeSpan.FromSeconds(Config.GetDouble("holdings-changed-store-delay", 10)); } /// @@ -220,9 +215,7 @@ private void Update() //Profit loss changes, get the banner statistics, summary information on the performance for the headers. var serverStatistics = GetServerStatistics(utcNow); - // read the last holdings change time before taking the snapshot so a change slipping in - // between the snapshot and the store is never marked as stored - var lastHoldingsChangedTicks = Interlocked.Read(ref _lastHoldingsChangedTicks); + _holdingsChangeMonitor.Snapshot(); var holdings = GetHoldings(Algorithm.Securities.Values, Algorithm.SubscriptionManager.SubscriptionDataConfigService); //Add the algorithm statistics first. @@ -249,9 +242,7 @@ private void Update() // Send full packet to storage. Holdings changes not yet persisted force a store once they // have settled for the configured delay, so the stored results reflect fills quickly // instead of waiting for the next scheduled store - var holdingsSettled = lastHoldingsChangedTicks > _lastStoredHoldingsChangedTicks - && utcNow.Ticks >= lastHoldingsChangedTicks + _holdingsChangedStoreDelay.Ticks; - if (utcNow > _nextChartsUpdate || holdingsSettled) + if (utcNow > _nextChartsUpdate || _holdingsChangeMonitor.ShouldForceStore(utcNow)) { Log.Debug("LiveTradingResultHandler.Update(): Pre-store result"); var chartComplete = new Dictionary(); @@ -273,7 +264,7 @@ private void Update() Algorithm.Transactions.TransactionRecord, holdings, Algorithm.Portfolio.CashBook, deltaStatistics, runtimeStatistics, orderEvents, statistics.TotalPerformance, serverStatistics, state: GetAlgorithmState()))); StoreResult(complete); - _lastStoredHoldingsChangedTicks = lastHoldingsChangedTicks; + _holdingsChangeMonitor.MarkStored(); _nextChartsUpdate = DateTime.UtcNow.Add(ChartUpdateInterval); Log.Debug("LiveTradingResultHandler.Update(): End-store result"); } @@ -785,36 +776,7 @@ public override void SetAlgorithm(IAlgorithm algorithm, decimal startingPortfoli base.SetAlgorithm(algorithm, startingPortfolioValue); Algorithm.SetStatisticsService(this); - // we must be notified each time our holdings change, so each time a security is added, we - // want to bind to its SecurityHolding.QuantityChanged event, to force a results store once they settle - foreach (var security in algorithm.Securities.Values) - { - security.Holdings.QuantityChanged += HoldingsOnQuantityChanged; - } - - algorithm.Securities.CollectionChanged += (sender, args) => - { - var items = args.NewItems ?? new List(); - if (args.OldItems != null) - { - foreach (var item in args.OldItems) - { - items.Add(item); - } - } - - foreach (Security security in items) - { - if (args.Action == NotifyCollectionChangedAction.Add) - { - security.Holdings.QuantityChanged += HoldingsOnQuantityChanged; - } - else if (args.Action == NotifyCollectionChangedAction.Remove) - { - security.Holdings.QuantityChanged -= HoldingsOnQuantityChanged; - } - } - }; + _holdingsChangeMonitor.Monitor(algorithm.Securities); // we need to forward Console.Write messages to the algorithm's Debug function var debug = new FuncTextWriter(algorithm.Debug); @@ -825,15 +787,6 @@ public override void SetAlgorithm(IAlgorithm algorithm, decimal startingPortfoli UpdateAlgorithmStatus(); } - /// - /// Keeps track of the last time any security holding quantity changed, so the update loop - /// can force a results store once holdings have settled - /// - private void HoldingsOnQuantityChanged(object sender, SecurityHoldingQuantityChangedEventArgs e) - { - Interlocked.Exchange(ref _lastHoldingsChangedTicks, DateTime.UtcNow.Ticks); - } - /// /// Send a algorithm status update to the user of the algorithms running state. /// @@ -1396,5 +1349,79 @@ public void SetSummaryStatistic(string name, string value) { SummaryStatistic(name, value); } + + /// + /// Monitors the securities holdings for quantity changes so the update loop can force a store + /// of the full results once the changes settle, without waiting for the next scheduled store + /// + private class HoldingsChangeMonitor + { + private readonly TimeSpan _storeDelay = TimeSpan.FromSeconds(Config.GetDouble("holdings-changed-store-delay", 10)); + + private long _lastChangedTicks; + private long _snapshotTicks; + private long _lastStoredTicks; + + /// + /// Monitors each security holding for quantity changes, + /// including securities added and removed after the algorithm starts + /// + public void Monitor(SecurityManager securities) + { + foreach (var security in securities.Values) + { + security.Holdings.QuantityChanged += HoldingsOnQuantityChanged; + } + + securities.CollectionChanged += (sender, args) => + { + if (args.Action == NotifyCollectionChangedAction.Add) + { + foreach (var security in args.NewItems.OfType()) + { + security.Holdings.QuantityChanged += HoldingsOnQuantityChanged; + } + } + else if (args.Action == NotifyCollectionChangedAction.Remove) + { + foreach (var security in args.OldItems.OfType()) + { + security.Holdings.QuantityChanged -= HoldingsOnQuantityChanged; + } + } + }; + } + + /// + /// Captures the last time the holdings changed. To be called before fetching the holdings + /// to send and store, so a change slipping in after the holdings are fetched is never marked as stored + /// + public void Snapshot() + { + _snapshotTicks = Interlocked.Read(ref _lastChangedTicks); + } + + /// + /// Determines whether the captured holdings changes have not been stored yet and have + /// settled for the configured delay, in which case a results store should be forced + /// + public bool ShouldForceStore(DateTime utcNow) + { + return _snapshotTicks > _lastStoredTicks && utcNow.Ticks >= _snapshotTicks + _storeDelay.Ticks; + } + + /// + /// Marks the captured holdings changes as stored + /// + public void MarkStored() + { + _lastStoredTicks = _snapshotTicks; + } + + private void HoldingsOnQuantityChanged(object sender, SecurityHoldingQuantityChangedEventArgs e) + { + Interlocked.Exchange(ref _lastChangedTicks, DateTime.UtcNow.Ticks); + } + } } } From a7f95d6ff7bb94bc76ce3cce5bfff550fda3771c Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 16 Jul 2026 16:29:56 -0400 Subject: [PATCH 3/6] Monitor order events instead of holdings quantity changes The holdings change monitor now subscribes to the transaction handler's NewOrderEvent and tracks fills, instead of wiring into every security holding QuantityChanged event, which required monitoring the securities collection changes as well. --- Engine/Results/LiveTradingResultHandler.cs | 46 +++++------------ Launcher/config.json | 17 ++++--- .../Results/LiveTradingResultHandlerTests.cs | 51 ++++++++++++------- 3 files changed, 57 insertions(+), 57 deletions(-) diff --git a/Engine/Results/LiveTradingResultHandler.cs b/Engine/Results/LiveTradingResultHandler.cs index 6311a59851c2..d60b3b0ffad0 100644 --- a/Engine/Results/LiveTradingResultHandler.cs +++ b/Engine/Results/LiveTradingResultHandler.cs @@ -16,7 +16,6 @@ using System; using System.Collections.Generic; -using System.Collections.Specialized; using System.Diagnostics; using System.IO; using System.Linq; @@ -111,6 +110,9 @@ public override void Initialize(ResultHandlerInitializeParameters parameters) _currentUtcDate = utcNow.Date; _nextPortfolioMarginUpdate = utcNow.RoundDown(_samplePortfolioPeriod).Add(_samplePortfolioPeriod); + + _holdingsChangeMonitor.Monitor(parameters.TransactionHandler); + base.Initialize(parameters); } @@ -776,8 +778,6 @@ public override void SetAlgorithm(IAlgorithm algorithm, decimal startingPortfoli base.SetAlgorithm(algorithm, startingPortfolioValue); Algorithm.SetStatisticsService(this); - _holdingsChangeMonitor.Monitor(algorithm.Securities); - // we need to forward Console.Write messages to the algorithm's Debug function var debug = new FuncTextWriter(algorithm.Debug); var error = new FuncTextWriter(algorithm.Error); @@ -1351,8 +1351,9 @@ public void SetSummaryStatistic(string name, string value) } /// - /// Monitors the securities holdings for quantity changes so the update loop can force a store - /// of the full results once the changes settle, without waiting for the next scheduled store + /// Monitors the order events, keeping track of the last time the holdings changed by filtering fills, + /// so the update loop can force a store of the full results once the changes settle, + /// without waiting for the next scheduled store /// private class HoldingsChangeMonitor { @@ -1363,33 +1364,11 @@ private class HoldingsChangeMonitor private long _lastStoredTicks; /// - /// Monitors each security holding for quantity changes, - /// including securities added and removed after the algorithm starts + /// Monitors the order events to keep track of the last time the holdings changed /// - public void Monitor(SecurityManager securities) + public void Monitor(IOrderEventProvider orderEventProvider) { - foreach (var security in securities.Values) - { - security.Holdings.QuantityChanged += HoldingsOnQuantityChanged; - } - - securities.CollectionChanged += (sender, args) => - { - if (args.Action == NotifyCollectionChangedAction.Add) - { - foreach (var security in args.NewItems.OfType()) - { - security.Holdings.QuantityChanged += HoldingsOnQuantityChanged; - } - } - else if (args.Action == NotifyCollectionChangedAction.Remove) - { - foreach (var security in args.OldItems.OfType()) - { - security.Holdings.QuantityChanged -= HoldingsOnQuantityChanged; - } - } - }; + orderEventProvider.NewOrderEvent += OnNewOrderEvent; } /// @@ -1418,9 +1397,12 @@ public void MarkStored() _lastStoredTicks = _snapshotTicks; } - private void HoldingsOnQuantityChanged(object sender, SecurityHoldingQuantityChangedEventArgs e) + private void OnNewOrderEvent(object sender, OrderEvent orderEvent) { - Interlocked.Exchange(ref _lastChangedTicks, DateTime.UtcNow.Ticks); + if (orderEvent.Status.IsFill()) + { + Interlocked.Exchange(ref _lastChangedTicks, DateTime.UtcNow.Ticks); + } } } } diff --git a/Launcher/config.json b/Launcher/config.json index f51a4a826d13..fdca2768aa55 100644 --- a/Launcher/config.json +++ b/Launcher/config.json @@ -6,17 +6,20 @@ // two predefined environments, 'backtesting' and 'live', feel free // to add more! - "environment": "backtesting", // "live-paper", "backtesting", "live-interactive", "live-interactive-iqfeed" + "environment": "live-paper", // "live-paper", "backtesting", "live-interactive", "live-interactive-iqfeed" // algorithm class selector - "algorithm-type-name": "BasicTemplateFrameworkAlgorithm", + "algorithm-type-name": "CryptoHoldingsBatchUpdatesAlgorithm", // Algorithm language selector - options CSharp, Python - "algorithm-language": "CSharp", + "algorithm-language": "Python", //Physical DLL location - "algorithm-location": "QuantConnect.Algorithm.CSharp.dll", - //"algorithm-location": "../../../Algorithm.Python/BasicTemplateFrameworkAlgorithm.py", + //"algorithm-location": "QuantConnect.Algorithm.CSharp.dll", + "algorithm-location": "../../../Algorithm.Python/CryptoHoldingsBatchUpdatesAlgorithm.py", + + // show debug logs, including the result handler store logs + "debug-mode": true, //Research notebook //"composer-dll-directory": ".", @@ -92,7 +95,7 @@ "live-data-port": 8020, // live portfolio state - "live-cash-balance": "", + "live-cash-balance": "[{\"currency\":\"USD\",\"amount\":100000}]", "live-holdings": "[]", // interactive brokers configuration @@ -385,7 +388,7 @@ "setup-handler": "QuantConnect.Lean.Engine.Setup.BrokerageSetupHandler", "result-handler": "QuantConnect.Lean.Engine.Results.LiveTradingResultHandler", "data-feed-handler": "QuantConnect.Lean.Engine.DataFeeds.LiveTradingDataFeed", - "data-queue-handler": [ "QuantConnect.Lean.Engine.DataFeeds.Queues.LiveDataQueue" ], + "data-queue-handler": [ "QuantConnect.Lean.Engine.DataFeeds.Queues.FakeDataQueue" ], "real-time-handler": "QuantConnect.Lean.Engine.RealTime.LiveTradingRealTimeHandler", "transaction-handler": "QuantConnect.Lean.Engine.TransactionHandlers.BacktestingTransactionHandler" }, diff --git a/Tests/Engine/Results/LiveTradingResultHandlerTests.cs b/Tests/Engine/Results/LiveTradingResultHandlerTests.cs index 558d0e7b0fb1..3ced9a210f97 100644 --- a/Tests/Engine/Results/LiveTradingResultHandlerTests.cs +++ b/Tests/Engine/Results/LiveTradingResultHandlerTests.cs @@ -19,10 +19,13 @@ using System.Threading; using System.Diagnostics; using NUnit.Framework; +using QuantConnect.Orders; using QuantConnect.Packets; using QuantConnect.Logging; using QuantConnect.Securities; using QuantConnect.Interfaces; +using QuantConnect.Data.Market; +using QuantConnect.Orders.Fees; using QuantConnect.Configuration; using QuantConnect.Lean.Engine.Results; using QuantConnect.Lean.Engine.DataFeeds; @@ -332,17 +335,31 @@ public void HoldingsChangeForcesResultsStoreOnceSettled() var algorithm = new AlgorithmStub(createDataManager: false); var dataManager = new DataManagerStub(new TestDataFeed(), algorithm); algorithm.SubscriptionManager.SetDataManager(dataManager); - var spy = algorithm.AddEquity("SPY"); + // crypto markets are always open, so the market orders fill right away regardless of when the test runs + var btc = algorithm.AddCrypto("BTCUSD", Resolution.Minute, Market.Coinbase); + btc.SetFeeModel(new ConstantFeeModel(0)); + // the price time must be fresh relative to the order submission time (algorithm utc time) for the fills to happen + btc.SetMarketPrice(new TradeBar(algorithm.UtcTime.ConvertFromUtc(btc.Exchange.TimeZone), btc.Symbol, 100, 100, 100, 100, 100)); algorithm.PostInitialize(); var transactionHandler = new BacktestingTransactionHandler(); using var brokerage = new BacktestingBrokerage(algorithm); transactionHandler.Initialize(algorithm, brokerage, resultHandler); + algorithm.Transactions.SetOrderProcessor(transactionHandler); resultHandler.Initialize(new(new LiveNodePacket(), messaging, api, transactionHandler, null)); resultHandler.SetAlgorithm(algorithm, 100000); algorithm.SetLocked(); + // places an order for the given quantity, changing the holdings when it fills + OrderTicket Trade(decimal quantity, OrderType orderType = OrderType.Market, decimal limitPrice = 0) + { + var ticket = algorithm.Transactions.ProcessRequest(new SubmitOrderRequest(orderType, btc.Symbol.SecurityType, + btc.Symbol, quantity, 0, limitPrice, algorithm.UtcTime, string.Empty)); + brokerage.Scan(); + return ticket; + } + var expectedStores = 1; // the first update pass always stores because the scheduled store is due right away Assert.IsTrue(resultHandler.WaitForStore(expectedStores, TimeSpan.FromSeconds(30)), "Initial scheduled store did not happen"); @@ -350,8 +367,8 @@ public void HoldingsChangeForcesResultsStoreOnceSettled() // without holdings changes, no store is forced even after the settle delay elapses Assert.IsFalse(resultHandler.WaitForStore(expectedStores + 1, settleDelay + TimeSpan.FromSeconds(1)), "A store happened without holdings changes"); - // a position is opened for an existing security - spy.Holdings.SetHoldings(100, 10); + // a position is opened + Trade(10); var stopwatch = Stopwatch.StartNew(); // no store is forced before the holdings settle @@ -360,28 +377,26 @@ public void HoldingsChangeForcesResultsStoreOnceSettled() Assert.IsTrue(resultHandler.WaitForStore(++expectedStores, settleDelay + TimeSpan.FromSeconds(5)), "No store was forced after the opened position settled"); Assert.GreaterOrEqual(stopwatch.Elapsed, settleDelay); - // the existing position is increased - spy.Holdings.SetHoldings(100, 20); + // the position is increased + Trade(10); Assert.IsTrue(resultHandler.WaitForStore(++expectedStores, settleDelay + TimeSpan.FromSeconds(5)), "No store was forced after the increased position settled"); - // the existing position is reduced - spy.Holdings.SetHoldings(100, 5); + // the position is reduced + Trade(-5); Assert.IsTrue(resultHandler.WaitForStore(++expectedStores, settleDelay + TimeSpan.FromSeconds(5)), "No store was forced after the reduced position settled"); - // a security added after the algorithm started is monitored too - var aapl = algorithm.AddEquity("AAPL"); - aapl.Holdings.SetHoldings(200, 10); - Assert.IsTrue(resultHandler.WaitForStore(++expectedStores, settleDelay + TimeSpan.FromSeconds(5)), "No store was forced after the added security holdings settled"); + // order events without fills don't force stores: a limit order far from the market price + // is submitted and then canceled without ever changing the holdings + var ticket = Trade(1, OrderType.Limit, limitPrice: 1); + ticket.Cancel(); + Assert.IsFalse(resultHandler.WaitForStore(expectedStores + 1, settleDelay + TimeSpan.FromSeconds(1)), "A store was forced for order events without fills"); - // the added security position is liquidated before removing it, like QCAlgorithm.RemoveSecurity does - aapl.Holdings.SetHoldings(200, 0); + // the position is liquidated + Trade(-15); Assert.IsTrue(resultHandler.WaitForStore(++expectedStores, settleDelay + TimeSpan.FromSeconds(5)), "No store was forced after the liquidation settled"); - // once the security is removed, its holdings are no longer monitored. - // this will also fail if any of the previous changes forced more than one store - algorithm.Securities.Remove(aapl.Symbol); - aapl.Holdings.SetHoldings(200, 10); - Assert.IsFalse(resultHandler.WaitForStore(expectedStores + 1, settleDelay + TimeSpan.FromSeconds(1)), "A store was forced for a removed security"); + // a single settled change forces a single store + Assert.IsFalse(resultHandler.WaitForStore(expectedStores + 1, settleDelay + TimeSpan.FromSeconds(1)), "More than one store was forced for a single holdings change"); } finally { From 2c3f1da93f1d39e4bac081f3f65122596f46cd68 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 16 Jul 2026 16:30:31 -0400 Subject: [PATCH 4/6] Revert local test changes to Launcher config --- Launcher/config.json | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/Launcher/config.json b/Launcher/config.json index fdca2768aa55..f51a4a826d13 100644 --- a/Launcher/config.json +++ b/Launcher/config.json @@ -6,20 +6,17 @@ // two predefined environments, 'backtesting' and 'live', feel free // to add more! - "environment": "live-paper", // "live-paper", "backtesting", "live-interactive", "live-interactive-iqfeed" + "environment": "backtesting", // "live-paper", "backtesting", "live-interactive", "live-interactive-iqfeed" // algorithm class selector - "algorithm-type-name": "CryptoHoldingsBatchUpdatesAlgorithm", + "algorithm-type-name": "BasicTemplateFrameworkAlgorithm", // Algorithm language selector - options CSharp, Python - "algorithm-language": "Python", + "algorithm-language": "CSharp", //Physical DLL location - //"algorithm-location": "QuantConnect.Algorithm.CSharp.dll", - "algorithm-location": "../../../Algorithm.Python/CryptoHoldingsBatchUpdatesAlgorithm.py", - - // show debug logs, including the result handler store logs - "debug-mode": true, + "algorithm-location": "QuantConnect.Algorithm.CSharp.dll", + //"algorithm-location": "../../../Algorithm.Python/BasicTemplateFrameworkAlgorithm.py", //Research notebook //"composer-dll-directory": ".", @@ -95,7 +92,7 @@ "live-data-port": 8020, // live portfolio state - "live-cash-balance": "[{\"currency\":\"USD\",\"amount\":100000}]", + "live-cash-balance": "", "live-holdings": "[]", // interactive brokers configuration @@ -388,7 +385,7 @@ "setup-handler": "QuantConnect.Lean.Engine.Setup.BrokerageSetupHandler", "result-handler": "QuantConnect.Lean.Engine.Results.LiveTradingResultHandler", "data-feed-handler": "QuantConnect.Lean.Engine.DataFeeds.LiveTradingDataFeed", - "data-queue-handler": [ "QuantConnect.Lean.Engine.DataFeeds.Queues.FakeDataQueue" ], + "data-queue-handler": [ "QuantConnect.Lean.Engine.DataFeeds.Queues.LiveDataQueue" ], "real-time-handler": "QuantConnect.Lean.Engine.RealTime.LiveTradingRealTimeHandler", "transaction-handler": "QuantConnect.Lean.Engine.TransactionHandlers.BacktestingTransactionHandler" }, From 6d292dddc9defab693340aa6d34505f8752a4ac3 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 17 Jul 2026 09:54:47 -0400 Subject: [PATCH 5/6] Remove snapshot step from holdings change monitor --- Engine/Results/LiveTradingResultHandler.cs | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/Engine/Results/LiveTradingResultHandler.cs b/Engine/Results/LiveTradingResultHandler.cs index d60b3b0ffad0..27aba555b1cc 100644 --- a/Engine/Results/LiveTradingResultHandler.cs +++ b/Engine/Results/LiveTradingResultHandler.cs @@ -217,7 +217,6 @@ private void Update() //Profit loss changes, get the banner statistics, summary information on the performance for the headers. var serverStatistics = GetServerStatistics(utcNow); - _holdingsChangeMonitor.Snapshot(); var holdings = GetHoldings(Algorithm.Securities.Values, Algorithm.SubscriptionManager.SubscriptionDataConfigService); //Add the algorithm statistics first. @@ -1360,7 +1359,6 @@ private class HoldingsChangeMonitor private readonly TimeSpan _storeDelay = TimeSpan.FromSeconds(Config.GetDouble("holdings-changed-store-delay", 10)); private long _lastChangedTicks; - private long _snapshotTicks; private long _lastStoredTicks; /// @@ -1372,29 +1370,21 @@ public void Monitor(IOrderEventProvider orderEventProvider) } /// - /// Captures the last time the holdings changed. To be called before fetching the holdings - /// to send and store, so a change slipping in after the holdings are fetched is never marked as stored - /// - public void Snapshot() - { - _snapshotTicks = Interlocked.Read(ref _lastChangedTicks); - } - - /// - /// Determines whether the captured holdings changes have not been stored yet and have + /// Determines whether the latest holdings changes have not been stored yet and have /// settled for the configured delay, in which case a results store should be forced /// public bool ShouldForceStore(DateTime utcNow) { - return _snapshotTicks > _lastStoredTicks && utcNow.Ticks >= _snapshotTicks + _storeDelay.Ticks; + var lastChangedTicks = Interlocked.Read(ref _lastChangedTicks); + return lastChangedTicks > _lastStoredTicks && utcNow.Ticks >= lastChangedTicks + _storeDelay.Ticks; } /// - /// Marks the captured holdings changes as stored + /// Marks the latest holdings changes as stored /// public void MarkStored() { - _lastStoredTicks = _snapshotTicks; + _lastStoredTicks = Interlocked.Read(ref _lastChangedTicks); } private void OnNewOrderEvent(object sender, OrderEvent orderEvent) From 86ae86f4e3b9a3a77c7afeac27eb43af4497f682 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 17 Jul 2026 10:38:41 -0400 Subject: [PATCH 6/6] Stamp holdings changes with the order event time --- Engine/Results/LiveTradingResultHandler.cs | 2 +- .../Engine/Results/LiveTradingResultHandlerTests.cs | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Engine/Results/LiveTradingResultHandler.cs b/Engine/Results/LiveTradingResultHandler.cs index 27aba555b1cc..c57245ce6a5b 100644 --- a/Engine/Results/LiveTradingResultHandler.cs +++ b/Engine/Results/LiveTradingResultHandler.cs @@ -1391,7 +1391,7 @@ private void OnNewOrderEvent(object sender, OrderEvent orderEvent) { if (orderEvent.Status.IsFill()) { - Interlocked.Exchange(ref _lastChangedTicks, DateTime.UtcNow.Ticks); + Interlocked.Exchange(ref _lastChangedTicks, orderEvent.UtcTime.Ticks); } } } diff --git a/Tests/Engine/Results/LiveTradingResultHandlerTests.cs b/Tests/Engine/Results/LiveTradingResultHandlerTests.cs index 3ced9a210f97..156d1f89781d 100644 --- a/Tests/Engine/Results/LiveTradingResultHandlerTests.cs +++ b/Tests/Engine/Results/LiveTradingResultHandlerTests.cs @@ -335,6 +335,10 @@ public void HoldingsChangeForcesResultsStoreOnceSettled() var algorithm = new AlgorithmStub(createDataManager: false); var dataManager = new DataManagerStub(new TestDataFeed(), algorithm); algorithm.SubscriptionManager.SetDataManager(dataManager); + // live algorithms run on wall-clock time, which the order event times the monitor tracks are in sync with + algorithm.SetDateTime(DateTime.UtcNow); + // normally initialized by the setup handlers, required for statistics generation + algorithm.Settings.TradingDaysPerYear = 365; // crypto markets are always open, so the market orders fill right away regardless of when the test runs var btc = algorithm.AddCrypto("BTCUSD", Resolution.Minute, Market.Coinbase); btc.SetFeeModel(new ConstantFeeModel(0)); @@ -354,6 +358,9 @@ public void HoldingsChangeForcesResultsStoreOnceSettled() // places an order for the given quantity, changing the holdings when it fills OrderTicket Trade(decimal quantity, OrderType orderType = OrderType.Market, decimal limitPrice = 0) { + // keep the algorithm clock in sync with wall-clock time like in live trading, + // so the order events are stamped with current times + algorithm.SetDateTime(DateTime.UtcNow); var ticket = algorithm.Transactions.ProcessRequest(new SubmitOrderRequest(orderType, btc.Symbol.SecurityType, btc.Symbol, quantity, 0, limitPrice, algorithm.UtcTime, string.Empty)); brokerage.Scan(); @@ -367,9 +374,10 @@ OrderTicket Trade(decimal quantity, OrderType orderType = OrderType.Market, deci // without holdings changes, no store is forced even after the settle delay elapses Assert.IsFalse(resultHandler.WaitForStore(expectedStores + 1, settleDelay + TimeSpan.FromSeconds(1)), "A store happened without holdings changes"); - // a position is opened - Trade(10); + // a position is opened. The stopwatch is started before trading so the elapsed time + // is measured from no later than the order fill time var stopwatch = Stopwatch.StartNew(); + Trade(10); // no store is forced before the holdings settle Assert.IsFalse(resultHandler.WaitForStore(expectedStores + 1, TimeSpan.FromSeconds(1)), "A store was forced before the holdings settled");