From 29a42d7db74190e260e1491242a8489ef68b7a7d Mon Sep 17 00:00:00 2001 From: Ken Tseng Date: Sat, 14 Mar 2026 01:07:43 +0800 Subject: [PATCH] Fix OnAssignmentOrderEvent fired before portfolio has assigned shares EmitOptionNotificationEvents processed fills one at a time and fired HandlePositionAssigned immediately after the option fill, before the underlying delivery fill was processed. Split the single foreach into two passes: first process all fills to update the portfolio, then fire assignment events. Fixes #9321 --- .../BrokerageTransactionHandler.cs | 5 +- .../BrokerageTransactionHandlerTests.cs | 104 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs index b2d76f38b7c3..995c416f0282 100644 --- a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs +++ b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs @@ -1716,12 +1716,15 @@ private void EmitOptionNotificationEvents(Security security, OptionExerciseOrder { // generate the order events reusing the option exercise model var option = (Option)security; - var orderEvents = option.OptionExerciseModel.OptionExercise(option, order); + var orderEvents = option.OptionExerciseModel.OptionExercise(option, order).ToList(); foreach (var orderEvent in orderEvents) { HandleOrderEvent(orderEvent); + } + foreach (var orderEvent in orderEvents) + { if (orderEvent.IsAssignment) { if (!string.IsNullOrEmpty(order.Tag)) diff --git a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs index ffb9fc557da2..49bdf52de19c 100644 --- a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs +++ b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs @@ -1908,6 +1908,97 @@ string expectedMessage Assert.AreEqual(1, tickets.Count); } + // Short Call --> ITM (assigned), underlying should be delivered before OnAssignmentOrderEvent + [TestCase(-1, OptionRight.Call, 450, 100, 455, 0)] + // Short Put --> ITM (assigned), underlying should be delivered before OnAssignmentOrderEvent + [TestCase(-1, OptionRight.Put, 455, 100, 450, 200)] + public void OptionAssignmentEventFiredAfterPortfolioUpdate( + int initialOptionPosition, + OptionRight optionRight, + decimal strikePrice, + int initialUnderlyingPosition, + decimal underlyingPrice, + int expectedUnderlyingPositionOnAssignment + ) + { + var algorithm = new TestAlgorithm(); + var equity = algorithm.AddEquity("SPY"); + var optionSymbol = Symbol.CreateOption(equity.Symbol, equity.Symbol.ID.Market, OptionStyle.American, optionRight, strikePrice, + new DateTime(2021, 9, 8)); + var option = algorithm.AddOptionContract(optionSymbol); + + algorithm.Portfolio[equity.Symbol].SetHoldings(underlyingPrice, initialUnderlyingPosition); + algorithm.Portfolio[option.Symbol].SetHoldings(0.01m, initialOptionPosition); + + equity.SetMarketPrice(new Tick { Value = underlyingPrice }); + + using var brokerage = new NoSubmitTestBrokerage(algorithm); + _transactionHandler = new TestBrokerageTransactionHandler(); + _transactionHandler.Initialize(algorithm, brokerage, new BacktestingResultHandler()); + algorithm.Transactions.SetOrderProcessor(_transactionHandler); + + // 9 PM ET + _transactionHandler.TestCurrentTimeUtc = new DateTime(2021, 9, 9, 1, 0, 0); + + var parameters = new object[] { new OptionNotificationEventArgs(optionSymbol, 0) }; + _handleOptionNotification.Invoke(_transactionHandler, parameters); + + _transactionHandler.Exit(); + + Assert.AreEqual(1, algorithm.AssignmentEvents.Count); + Assert.IsTrue(algorithm.UnderlyingQuantityOnAssignment.ContainsKey(equity.Symbol)); + Assert.AreEqual(expectedUnderlyingPositionOnAssignment, algorithm.UnderlyingQuantityOnAssignment[equity.Symbol]); + Assert.IsTrue(algorithm.UnderlyingOrderEventReceivedBeforeAssignment); + } + + // Short Call --> ITM (early assignment - full) + [TestCase(-1, OptionRight.Call, 450, 100, 455, 0, 0)] + // Short Put --> ITM (early assignment - full) + [TestCase(-1, OptionRight.Put, 455, 100, 450, 0, 200)] + // Short Call --> ITM (early assignment - partial) + [TestCase(-3, OptionRight.Call, 450, 300, 455, -1, 100)] + // Short Put --> ITM (early assignment - partial) + [TestCase(-3, OptionRight.Put, 455, 100, 450, -1, 300)] + public void EarlyAssignmentEventFiredAfterPortfolioUpdate( + int initialOptionPosition, + OptionRight optionRight, + decimal strikePrice, + int initialUnderlyingPosition, + decimal underlyingPrice, + int expectedOptionPosition, + int expectedUnderlyingPositionOnAssignment + ) + { + var algorithm = new TestAlgorithm(); + var equity = algorithm.AddEquity("SPY"); + var optionSymbol = Symbol.CreateOption(equity.Symbol, equity.Symbol.ID.Market, OptionStyle.American, optionRight, strikePrice, + new DateTime(2021, 9, 8)); + var option = algorithm.AddOptionContract(optionSymbol); + + algorithm.Portfolio[equity.Symbol].SetHoldings(underlyingPrice, initialUnderlyingPosition); + algorithm.Portfolio[option.Symbol].SetHoldings(0.01m, initialOptionPosition); + + equity.SetMarketPrice(new Tick { Value = underlyingPrice }); + + using var brokerage = new NoSubmitTestBrokerage(algorithm); + _transactionHandler = new TestBrokerageTransactionHandler(); + _transactionHandler.Initialize(algorithm, brokerage, new BacktestingResultHandler()); + algorithm.Transactions.SetOrderProcessor(_transactionHandler); + + // 10 AM ET, before expiry + _transactionHandler.TestCurrentTimeUtc = new DateTime(2021, 9, 8, 14, 0, 0); + + var parameters = new object[] { new OptionNotificationEventArgs(optionSymbol, expectedOptionPosition) }; + _handleOptionNotification.Invoke(_transactionHandler, parameters); + + _transactionHandler.Exit(); + + Assert.AreEqual(1, algorithm.AssignmentEvents.Count); + Assert.IsTrue(algorithm.UnderlyingQuantityOnAssignment.ContainsKey(equity.Symbol)); + Assert.AreEqual(expectedUnderlyingPositionOnAssignment, algorithm.UnderlyingQuantityOnAssignment[equity.Symbol]); + Assert.IsTrue(algorithm.UnderlyingOrderEventReceivedBeforeAssignment); + } + // Long Call --> ITM (exercised early - full) [TestCase(1, OptionRight.Call, 450, 100, 455, 2, 0, 200, "Automatic Exercise")] // Long Put --> ITM (exercised early - full) @@ -2667,6 +2758,9 @@ public override bool CanUpdateOrder(Security security, Order order, UpdateOrderR internal class TestAlgorithm : QCAlgorithm { public List OrderEvents = new List(); + public List AssignmentEvents = new List(); + public Dictionary UnderlyingQuantityOnAssignment = new Dictionary(); + public bool UnderlyingOrderEventReceivedBeforeAssignment { get; private set; } public TestAlgorithm() { SubscriptionManager.SetDataManager(new DataManagerStub(this)); @@ -2676,6 +2770,16 @@ public override void OnOrderEvent(OrderEvent orderEvent) { OrderEvents.Add(orderEvent); } + public override void OnAssignmentOrderEvent(OrderEvent assignmentEvent) + { + AssignmentEvents.Add(assignmentEvent); + var underlying = assignmentEvent.Symbol.Underlying; + if (underlying != null) + { + UnderlyingQuantityOnAssignment[underlying] = Portfolio[underlying].Quantity; + UnderlyingOrderEventReceivedBeforeAssignment = OrderEvents.Any(e => e.Symbol == underlying); + } + } } // Implemented through an underlying BactestingBrokerage instead of directly inheriting from it for easy implementation