From 66a4fb1037f2e9ce25af13c9650c3b4210abeaed Mon Sep 17 00:00:00 2001 From: mkzung <103102868+mkzung@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:41:49 +0000 Subject: [PATCH] Fix concurrent futures margin history lookups --- Common/Securities/Future/FutureMarginModel.cs | 15 ++- .../FutureMarginBuyingPowerModelTests.cs | 100 ++++++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/Common/Securities/Future/FutureMarginModel.cs b/Common/Securities/Future/FutureMarginModel.cs index d6b662585405..59459f61ceeb 100644 --- a/Common/Securities/Future/FutureMarginModel.cs +++ b/Common/Securities/Future/FutureMarginModel.cs @@ -213,13 +213,20 @@ private MarginRequirementsEntry GetCurrentMarginRequirements(Security security) var marginRequirementsHistory = LoadMarginRequirementsHistory(security.Symbol); var date = lastData.Time.Date; - while (_marginCurrentIndex + 1 < marginRequirementsHistory.Length && - marginRequirementsHistory[_marginCurrentIndex + 1].Date <= date) + // Each lookup owns its index; the shared value is only a search hint. + var index = Math.Min(_marginCurrentIndex, marginRequirementsHistory.Length - 1); + while (index > 0 && marginRequirementsHistory[index].Date > date) { - _marginCurrentIndex++; + index--; + } + while (index + 1 < marginRequirementsHistory.Length && + marginRequirementsHistory[index + 1].Date <= date) + { + index++; } - return marginRequirementsHistory[_marginCurrentIndex]; + _marginCurrentIndex = index; + return marginRequirementsHistory[index]; } /// diff --git a/Tests/Common/Securities/FutureMarginBuyingPowerModelTests.cs b/Tests/Common/Securities/FutureMarginBuyingPowerModelTests.cs index 67ae203500e8..42f017d2af56 100644 --- a/Tests/Common/Securities/FutureMarginBuyingPowerModelTests.cs +++ b/Tests/Common/Securities/FutureMarginBuyingPowerModelTests.cs @@ -18,6 +18,9 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; using NUnit.Framework; using QuantConnect.Algorithm; using QuantConnect.Data; @@ -38,6 +41,103 @@ public class FutureMarginBuyingPowerModelTests // Test class to enable calling protected methods private FutureMarginModel _futureMarginModel; + [Test] + public void ConcurrentMarginLookupsKeepTheHistoryIndexInRange() + { + var symbol = Symbol.CreateFuture("MARGIN-RACE", Market.USA, new DateTime(2030, 1, 1)); + var date = new DateTime(2026, 1, 1); + var security = MarginSecurity(symbol, date); + var history = Enumerable.Range(0, 5000).Select(i => new MarginRequirementsEntry + { + Date = date.AddDays(i - 5000), + InitialOvernight = i + 1, + MaintenanceOvernight = i + 1 + }).ToArray(); + using var cache = new MarginHistoryCache(symbol, history); + for (var attempt = 0; attempt < 20; attempt++) + { + var model = new FutureMarginModel(security: security); + using var start = new Barrier(16); + var tasks = Enumerable.Range(0, 16).Select(_ => Task.Factory.StartNew(() => + { + start.SignalAndWait(); + return model.GetInitialMarginRequirement(new InitialMarginParameters(security, 1)).Value; + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)).ToArray(); + Task.WaitAll(tasks); + Assert.IsTrue(tasks.All(t => t.Result == 5000)); + Assert.AreEqual(5000, model.InitialOvernightMarginRequirement); + } + } + + [TestCase(false)] + [TestCase(true)] + public void MarginLookupsRecoverAfterTheCachedHistoryIsReplaced(bool futureRows) + { + var symbol = Symbol.CreateFuture("MARGIN-RELOAD", Market.USA, new DateTime(2030, 1, 1)); + var date = new DateTime(2026, 1, 5); + var security = MarginSecurity(symbol, date); + var history = Enumerable.Range(1, 5).Select(i => new MarginRequirementsEntry + { + Date = new DateTime(2026, 1, i), + InitialOvernight = 100 * i + }).ToArray(); + using var cache = new MarginHistoryCache(symbol, history); + var model = new FutureMarginModel(security: security); + Assert.AreEqual(500, model.InitialOvernightMarginRequirement); + cache.Replace(new[] { new MarginRequirementsEntry { Date = date, InitialOvernight = 750 } } + .Concat(futureRows ? history.Select(e => new MarginRequirementsEntry + { + Date = e.Date.AddDays(10), + InitialOvernight = e.InitialOvernight + }) : Array.Empty()).ToArray()); + Assert.AreEqual(750, model.InitialOvernightMarginRequirement); + } + + private static Future MarginSecurity(Symbol symbol, DateTime date) + { + var tz = TimeZones.NewYork; + var security = new Future(SecurityExchangeHours.AlwaysOpen(tz), + new SubscriptionDataConfig(typeof(TradeBar), symbol, Resolution.Minute, tz, tz, true, false, false), + new Cash(Currencies.USD, 0, 1m), + new OptionSymbolProperties(SymbolProperties.GetDefault(Currencies.USD)), + ErrorCurrencyConverter.Instance, RegisteredSecurityDataTypesProvider.Null); + security.SetMarketPrice(new Tick { Symbol = symbol, Value = 100, Time = date }); + return security; + } + + private sealed class MarginHistoryCache : IDisposable + { + private static readonly FieldInfo Cache = typeof(FutureMarginModel).GetField("_marginRequirementsCache", BindingFlags.Static | BindingFlags.NonPublic); + private static readonly object Locker = typeof(FutureMarginModel).GetField("_locker", BindingFlags.Static | BindingFlags.NonPublic).GetValue(null); + private readonly string _key; + + public MarginHistoryCache(Symbol symbol, MarginRequirementsEntry[] history) + { + _key = symbol.ID.Symbol; + Replace(history); + } + + public void Replace(MarginRequirementsEntry[] history) + { + lock (Locker) + { + var entries = new Dictionary((Dictionary)Cache.GetValue(null)); + entries[_key] = history; + Cache.SetValue(null, entries); + } + } + + public void Dispose() + { + lock (Locker) + { + var entries = new Dictionary((Dictionary)Cache.GetValue(null)); + entries.Remove(_key); + Cache.SetValue(null, entries); + } + } + } + [Test] public void TestMarginForSymbolWithOneLinerHistory() {