Repository navigation
Conversation
- the delisting at expiry is processed only in backtesting, so in live an expired option stayed in the portfolio until restart unless the brokerage sent a position update, which only IB does - the transaction handler now runs every held option past its expiry through the option notification path each time step, the same path a brokerage position update with zero takes - Lean books the exercise or the expiry itself, so no brokerage call and no per-brokerage switch
- Securities.Values is a cached snapshot, so the handler can run inside the loop and the intermediate symbol list was an extra pass for nothing
Romazes
force-pushed
the
feature-clear-expired-option-holdings
branch
from
October 23, 2026 15:48
4057e20 to
27653ba
Compare
- a scan of every security on each live time step was too frequent for a contract that only expires at the close, so the check moved inside the daily cash sync window, one scan a day with no new state - it runs before the cash read, so the cash an expiry books is replaced by the brokerage figure right after
Romazes
force-pushed
the
feature-clear-expired-option-holdings
branch
from
October 23, 2026 15:49
27653ba to
d95c68d
Compare
- the expiry date only covers contracts held to expiry; an in the money American style short option can be assigned on any day before, and a brokerage without a position stream reports nothing, so Lean kept the full short and no shares - the daily pass now reads the brokerage holdings once, only when an option is held, and runs every option whose quantity differs through the same path a brokerage position update takes, which already books full and partial early assignment and moves the shares - a contract past expiry is cleared whatever the brokerage still reports, Charles Schwab keeps it until an overnight cleanup - a failed holdings read is logged and skipped for the day, same as a failed cash read, so one REST error does not stop a live algorithm - the test brokerage got a settable holdings list, the backtesting one mirrors Lean's own portfolio and never differs; the full assignment case leaves the contract out of the list, the way a brokerage reports a closed position
- after a redeploy between the close and 07:45 ET the underlying of a held option has no price: setup seeds only the held security and the live feed drops off-hours data, so the exercise model read 0 and called every put in the money and every call worthless - the sync now takes the last known price from history when the underlying has none, and skips the contract for the day when history has nothing, so no contract is settled at a zero price - the sync tests run their second pass on the next day; the cash sync flag of the same day had been skipping it, so the pass that finds no difference was never exercised
- the daily pass left nothing in the live log, and the first proof on Charles Schwab will be a log: a start line before the read and an exit line with the option holdings count, the pattern of the old-order cleanup in the same handler - the holdings read returns every security type while the lookups are by option symbol, so only option rows go into the dictionary and the count in the exit line means option holdings
- a failed holdings read only returned, so when the cash read then succeeded the sync window closed and the option sync was lost for the day; it came back the same day only when the cash read failed too - the read now counts as a failed sync attempt: retried at the next time step with the same five-attempt limit as the cash sync, so a failed cash read also repeats the holdings read on each retry
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
In live mode an expired option contract stays in the portfolio until a restart, and an early assignment on a short contract is never booked. Lean clears or reduces an option holding only in
BrokerageTransactionHandler.HandleOptionNotification, on a brokerageOptionNotificationwith the new position. The backtesting brokerage raises it from the delisting at expiry, and Interactive Brokers raises it from its portfolio updates. No other brokerage plugin raises it (Alpaca, Charles Schwab, Clear Street, Public, Tastytrade, Tradier, TradeStation, WeBull, Eze, Wolverine, Bloomberg FIX, TerminalLink, Samco, Zerodha), andAlgorithmManagertracks delistings only in non-live mode. Charles Schwab support and the Alpaca docs confirm those APIs send nothing when a contract expires, so the plugins have nothing to raise it from.Seen on a Charles Schwab deployment trading 0DTE SPXW (plugin issue 123): seven contracts stayed in
Portfolioafter the 16:00 ET expiry withInvested = true, every expiry added a set, and a restart before Schwab's overnight cleanup loaded them back:Now
ProcessSynchronousEventsin live mode syncs the option holdings with the brokerage once a day, inside the cash sync window (07:45 ET) and its no-fill guard, before the cash read. When Lean holds an option it readsGetAccountHoldingsonce and runs every invested option whose quantity differs throughHandleOptionNotificationwith the brokerage quantity: 0 for a contract past expiry whatever the brokerage still reports, otherwise the quantity the brokerage holds, 0 when it no longer lists the contract. That is the same path a brokerage position update takes, so expiry books an exercise, assignment or expired worthless through the option exercise model, and a smaller short position before expiry books an early assignment, full or partial, with the shares moved. The fills are applied synchronously, so the next pass finds no difference and books nothing more. One holdings read a day, only when an option is held, repeated once about 10 seconds later on a day with a booking because the cash sync's fill check asks for a resync. A failed read is logged and retried at the next time step like a failed cash read, with the same five attempts in a row before the sync throws. AStart syncing option holdings...trace line before the read and aBrokerage option holdings count N. Exitline after the pass show it in the live log. No per-brokerage switch, no new state, backtests keep the delisting path.After a redeploy between the close and 07:45 ET the underlying of a held option has no price yet:
BrokerageSetupHandlerseeds a price only for the held security, the live feed drops off-hours data, and the first bar arrives at 09:30 ET. The exercise model readsunderlying.Closeto decide in the money, and at 0 every put is in the money by the full strike and every call is worthless. So before the notification the sync seeds the underlying withGetLastKnownPricefrom the history provider when its price is 0; when history returns nothing the contract is skipped for the day with an error line instead of being settled at 0.The Interactive Brokers handler stays: its position updates book expiry and assignment the same day, and the daily sync then finds no difference. Not covered, as before: a long contract exercised outside Lean with no exercise order in Lean.
Related Issue
https://github.com/QuantConnect/Lean.Brokerages.CharlesSchwab/issues/123
Motivation and Context
An expired contract kept in the portfolio keeps
Investedtrue, so the algorithm's own position guards blocked trading the next session. Nothing could close it: the engine refuses orders on an expired contract and the brokerage rejects them.Requires Documentation Change
No
How Has This Been Tested?
BrokerageTransactionHandlerTests.ProcessSynchronousEventsClearsExpiredOptionHoldingsOnlyInLiveMode, six cases of a SPY option held past expiry with 100 shares of the underlying, two daily passes at 08:00 ET inside the cash sync window, the first the day after expiry, the second a day later and finding nothing to book. Live mode books one exercise order and the option quantity goes to 0: OTM leaves the shares at 100, an ITM long call or short put ends with 200, an ITM long put or short call ends with 0. Backtest mode books nothing and the quantity stays 7.BrokerageTransactionHandlerTests.ProcessSynchronousEventsBooksEarlyAssignmentFromBrokerageHoldings, three cases: a short SPY call ITM the day before expiry, the test brokerage no longer lists the contract, lists -3, or lists the same -7. Full assignment ends with 0 contracts and -600 shares, partial with -3 and -300, equal books nothing. The second daily pass, on expiry day before the close, books nothing more.BrokerageTransactionHandlerTests.ProcessSynchronousEventsSeedsUnderlyingPriceFromHistoryWhenMissing, three cases of a SPY 450 call whose underlying has no price, with a history provider fake that returns a 455 bar or nothing: a short call no longer listed at the brokerage is seeded and assigned in full (0 contracts, -600 shares); with no history it is skipped (-7 contracts, 100 shares); an expired long call is seeded and exercised (0 contracts, 200 shares). Without the seed a call at price 0 is worthless, so the first and third cases fail on the previous code.BrokerageTransactionHandlerTests.ProcessSynchronousEventsRetriesTheOptionHoldingsSyncWhenTheBrokerageReadFails, three cases of an expired SPY 450 call in the money with a brokerage fake whose holdings read throws a given number of times: one or four failed reads book the exercise on the next pass at the same clock time (0 contracts, 200 shares); five failed reads throw the cash sync attempts exception on the fifth pass and book nothing.OptionExpirationEmitsOrderEventsand early assignment cases cover the exercise outcomes and show a second notification for the same contract adds no order.Types of changes
Checklist:
bug-<issue#>-<description>orfeature-<issue#>-<description>🤖 Generated with Claude Code