Skip to content

Add margin-aware option strategy match selection - #9639

Open
AlexCatarino wants to merge 6 commits into
QuantConnect:masterfrom
AlexCatarino:support/215475229332236-margin-aware-option-grouping
Open

Add margin-aware option strategy match selection#9639
AlexCatarino wants to merge 6 commits into
QuantConnect:masterfrom
AlexCatarino:support/215475229332236-margin-aware-option-grouping

Conversation

@AlexCatarino

@AlexCatarino AlexCatarino commented Jul 27, 2026

Copy link
Copy Markdown
Member

Description

OptionStrategyMatcher.MatchOnce greedily matched strategy definitions in descending leg-count order and never consulted the IOptionStrategyMatchObjectiveFunction hook (the long-standing TODO at the top of the class). Because every buying power check re-resolves the whole per-underlying option book through this greedy matching, a portfolio holding only fully-covered debit spreads could get carved into groups containing uncovered short legs, which are charged naked option margin.

Minimal case (from our reproduction, daily 1-contract ATM $5-wide SPY bull call spreads via ComboMarketOrder): holding 598C +1, 603C -1 and ordering 600C +1, 605C -1 (same expiry, SPY ≈ 600) re-groups the combined book into a Bull Call Ladder (600, 603, 605) plus an orphan 598C long. The ladder's second short is charged naked call margin ≈ premium + 20% × underlying ≈ $12k, so a ~$295 net-debit, defined-risk order is rejected with Maintenance Margin Delta ≈ $12.5k. The correct grouping — two Bull Call Spreads — requires no margin beyond the premium, and LEAN's own spread margin formulas agree.

Consequences observed: inconsistent accept/reject on identical trades, day-to-day TotalMarginUsed churn ($0 → $12,758 → $100 → $12,306 on consecutive days) as re-resolution flips between carvings, spurious margin calls, and even long-only option buys (max risk = premium) rejected with ~$12k deltas that were purely re-grouping artifacts.

The change (implements the matcher's TODO):

  • MatchOnce evaluates a fixed set of two candidate solutions and selects via the configured objective function:
    1. The legacy greedy match over the configured definition order — evaluated first and kept on ties, so every currently-correct grouping is unchanged.
    2. The same greedy match with definitions that leave a short leg uncovered within the strategy (naked calls/puts, ladders, short backspreads/straddles/strangles) deprioritized, so shorts are matched into covered strategies whenever the positions allow it.
  • New default objective function UncoveredShortQuantityOptionStrategyMatchObjectiveFunction: scores a solution by the negated quantity of short option contracts left uncovered, either inside their strategy or unmatched. Uncovered shorts are charged naked option margin, typically an order of magnitude above any risk-defined strategy margin, which makes this a cheap proxy for total margin that doesn't require security prices inside the matcher. The previous default, UnmatchedPositionCountOptionStrategyMatchObjectiveFunction, was never consulted anywhere and remains available.
  • Coverage is strike- and expiry-aware, so the selection can only ever lower margin, mirroring what the margin models actually honor:
    • A long on the debit side of the short strike (lower for calls, higher for puts) covers for free.
    • A long on the credit side caps the risk at the strike width instead, which for a distant long can exceed the naked charge — short 600P covered by a long 100P is a $50k spread versus ~$12k naked. Credit-side coverage therefore only counts within 10% of the short strike, the price-free stand-in for the naked margin floor of 10% of underlying value in OptionMarginModel.
    • A covering long must also outlive the short. A long expiring first leaves the short naked for the rest of its life, which is exactly why OptionStrategyPositionGroupBuyingPowerModel charges short calendar spreads the stand-alone naked short margin while ordinary calendar spreads require none. Without this the scorer read a short calendar's zero strike width as full coverage.
    • Where coverage isn't credited, the candidates tie and the previous grouping is kept.

Why not just reorder definitions (spreads before ladders): a true butterfly book (+1 low, −2 mid, +1 high) would then decompose into a bull call spread plus a bear call spread, charging the bear spread's strike width where the butterfly requires none. Descending-leg-count is sometimes the cheaper carve, so the fix compares solutions instead of hard-coding an order; a butterfly regression test covers this.

Intentionally untouched (root-cause items 2 and 3 of the issue, kept out to focus this PR):

  • Premiums are still not gated on cash in PositionGroupBuyingPowerModel.HasSufficientBuyingPowerForOrder (compares only against MarginRemaining).
  • OptionStrategyPositionGroupResolver.GetImpactedGroups still matches by underlying; it amplified this bug but is semantically defensible, and with margin-aware selection it becomes harmless.

Pre-existing behavior worth flagging (not introduced here, not addressed here): the matcher's result is not stable across processes. Greedy matching picks the first of several equally valid matches, and that order depends on enumeration over the symbol-keyed immutable collections in OptionPositionCollection, which varies with .NET's per-process randomized string hashing. Digesting the pre-change single-pass grouping of 15,600 multi-expiry books across four processes produced three different digests, so this predates the change; the two-candidate selection inherits it rather than causing it. It shows up mainly in books admitting several valid carves. Tracked separately in #9648, to be fixed after this merges.

Related Issue

Fixes #9638

Motivation and Context

Defined-risk option spread books were charged phantom naked-call margin, causing rejected orders, inconsistent margin usage, and spurious margin-call liquidations in backtesting and live. See the issue for the full analysis.

Requires Documentation Change

None.

How Has This Been Tested?

Behavioral change surface. We swept 4,320 synthetic books (calls-only, puts-only, mixed, and with underlying lots at ±1/±2), running the previous algorithm and the new one on each and diffing the resulting groupings. 210 books change (4.9%), across ~100 distinct transitions, and every transition drops a Bull Call Ladder or a Bear Put Ladder — those two are the only strategies that ever disappear, always replaced by covered or risk-defined groups (bull/bear call and put spreads, covered calls, protective calls). Butterflies, iron condors, iron butterflies, straddles, strangles, backspreads, box spreads, calendar spreads, jelly rolls, conversions and protective collars are never affected; neither are Bear Call Ladder and Bull Put Ladder, whose net option position isn't short. (The exact transition count varies by ±1 between runs because of the pre-existing non-determinism noted above; the changed-book count and the ladders-only property are stable.)

A second sweep over 15,600 multi-expiry books, scoring every candidate with an expiry-aware uncovered count, found no book where the second candidate wins yet requires more real margin than the previous behavior.

Performance. Benchmarked against master per MatchOnce call (interleaved rounds, min of 5, alternating order; ±10% between processes):

book master this PR ratio
single spread 4.1ms 3.9ms 0.93x
naked shorts 7.0ms 6.8ms 0.96x
butterfly 5.5ms 5.1ms 0.93x
iron condor 9.7ms 9.3ms 0.96x
ladder 14.9ms 14.5ms 0.98x
overlapping spreads 27.3ms 57.2ms 2.10x
multi-expiry book 29.7ms 71.8ms 2.42x
wide 8-leg book 137.3ms 271.8ms 1.98x

Books where the second pass doesn't run are slightly faster than master, since the definition ordering is now cached instead of being re-sorted on every call. The cost is confined to books where the second pass genuinely runs and changes the answer — exactly the books that were producing wrong margin before — and is bounded by the second pass itself, hence ~2x. Two optimizations keep that surface small: matching again is skipped when the first solution already leaves no more shorts uncovered than the positions can possibly cover (a long covers at most its own quantity of same-right shorts, and so does an underlying lot), which takes a plain ladder from 2.2x down to parity; and scoring takes an allocation-free fast path for every strategy with a single short leg, which is every spread, butterfly, condor, backspread and covered call. That bound reads the score as a quantity of uncovered contracts, which only the default objective function guarantees, so a custom one always gets both candidates evaluated.

Tests. New unit tests in Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs:

  • {598C +1, 600C +1, 603C -1, 605C -1} (same expiry) matches as two Bull Call Spreads, no ladder, no leftovers.
  • An interleaved book (longs 598×3, 600×2, 604×3, 608×2; shorts 603×3, 605×2, 609×2, 613×1) leaves no short contract uncovered.
  • A true butterfly book still matches as Butterfly Call (guards against naive definition reordering).
  • A real ladder book still matches as Bull Call Ladder (ties preserve previous behavior).
  • A short call with only a distant long available stays a ladder rather than becoming a wide bear call spread, and the put-side equivalent.
  • A short calendar spread's short leg scores as uncovered, and an ordinary calendar spread's does not, both rights.
  • Underlying lots cover a covered call's short leg.
  • A custom objective function is offered every candidate rather than being short-circuited by the default's bound.

New in Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs: holding two overlapping bull call spreads resolves as two Bull Call Spread groups with TotalMarginUsed == 0; ordering an overlapping spread with cash covering only its net debit passes HasSufficientBuyingPowerForOrder (used to require ~$12k); a long-only call order against that book passes with cash covering only its premium.

New regression algorithm OptionEquityOverlappingBullCallSpreadsRegressionAlgorithm opens two overlapping debit spreads with interleaved strikes and asserts two Bull Call Spread groups, zero margin used, and margin-remaining accounting.

Every test above was verified to fail against the pre-change code (the regression algorithm ends in RuntimeError: Expected two Bull Call Spread groups, found 0: Bull Call Ladder, Naked Call), except the ones asserting behavior is preserved; those were verified by mutation instead — removing the credit-side width bound or the expiry guard makes them fail.

dotnet build QuantConnect.Lean.sln -c Release builds clean. Green suites: ~3,300 tests matching StrategyMatcher|OptionEquity|PositionGroup|OptionStrategyPositionGroupBuyingPowerModel, plus a 3,688-test run over Strateg|MarginCall|ComboOrder. A full Tests run was not executed locally.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • Performance (see the benchmark table above)

Checklist:

  • My code follows the code style of this project.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • My branch follows the naming convention bug-<issue#>-<description>

🤖 Generated with Claude Code

AlexCatarino and others added 2 commits July 27, 2026 16:55
OptionStrategyMatcher.MatchOnce greedily matched definitions in
descending leg-count order, never consulting the objective function
hook. Books of overlapping debit spreads were carved into ladders
whose uncovered short leg is charged naked option margin, producing
phantom margin deltas, inconsistent accept/reject decisions and
TotalMarginUsed churn on fully covered, defined-risk books.

MatchOnce now evaluates a second candidate solution that deprioritizes
definitions leaving a short leg uncovered, and selects the best
solution via the objective function. The new default objective
function minimizes the quantity of uncovered short contracts, a
deterministic proxy for the margin required to hold the positions.
Ties preserve the previous grouping, so behavior only changes where
the greedy carve left a short uncovered that another grouping of the
same positions covers.

Fixes QuantConnect#9638

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Materialize the definition enumerations once per matcher options instead of
re-sorting them on every MatchOnce call, and only evaluate the second candidate
solution when some short contract can actually be covered by a long of the same
right or by the underlying lots held. A book of naked shorts, by far the most
common one reaching that point, now runs a single matching pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AlexCatarino and others added 4 commits August 1, 2026 22:39
The uncovered short proxy treated any same-right long as covering a
short leg. A long on the credit side (higher strike for calls, lower
for puts) caps the risk at the strike width, which for a distant long
can exceed the naked short margin, so preferring it could raise the
margin required instead of lowering it.

Coverage from the debit side stays free, while credit-side coverage
only counts within 10% of the short strike, the price-free stand-in
for the naked short margin floor of the option margin model. Beyond
that width the short counts as uncovered, the candidate solutions tie
and the previous grouping is preserved, so the selection can only ever
lower the margin required to hold the positions.

Also adds a regression algorithm for the reported defect: two
overlapping bull call debit spreads with interleaved strikes resolve
into two margin free spreads instead of a bull call ladder charging
naked call margin plus an unmatched long.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Matching again cannot help once the first solution already leaves no
more shorts uncovered than the positions can possibly cover, since a
long contract covers at most its own quantity of shorts of the same
right, and so does an underlying lot. Checking that bound generalizes
the naked shorts precondition it replaces and removes the second pass
from books holding fewer longs than shorts, such as a plain ladder,
which measured 2.2x slower than a single pass before and is now level
with it.

The credit side width test also subsumes the debit side one, whose
width is never positive, so coverage collapses into a single predicate
and one pass over the legs. Strategies with a single short leg, which
is every spread, butterfly, condor, backspread and covered call, now
take a fast path that needs neither ordering nor allocation, and the
remaining ladders and short butterflies sort a small array in place
instead of allocating lists, objects and sort closures per score.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The coverage proxy compared strikes only, so a short calendar spread,
long the near expiration and short the far one at the same strike, read
as fully covered on a zero strike width. The margin models disagree:
once the long expires the short is naked for the rest of its life, and
short calendar spreads are charged the stand-alone naked short margin
while ordinary calendar spreads, whose long outlives the short, require
none. Requiring the covering long to expire no earlier than the short
makes the proxy mirror that distinction exactly, and leaves same expiry
books untouched.

The skip added for provably useless second passes reads the score as a
quantity of uncovered contracts, which only the default objective
function guarantees, so a custom one now always gets both candidates.

Also documents that the definition ordering is cached, freezing the
first output of a user supplied enumerator, and drops the stale claim
that nothing in the options type is consulted by the matcher.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A function deriving from the default one is free to score by different
rules, so taking its score for a quantity of uncovered contracts could
skip a second candidate it would have preferred. Match the type exactly
instead, which leaves derived functions always evaluating both.

Also documents that the legacy objective function scores are not
bounded above by zero, so configuring it ends candidate evaluation and
preserves the single matching pass, and describes the regression
algorithm strikes by their order in the chain rather than as the
highest ones, which only held for a chain of exactly four strikes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AlexCatarino

Copy link
Copy Markdown
Member Author

The matcher non-determinism noted in the description is now filed separately as #9648, with a minimal reproduction: a same-expiration book of +1 595C, +1 600C, -1 605C pairs the short with either long depending on the process, because TryMatchOnce takes the first of several valid matches and that order comes from hashing symbols, which .NET randomizes per process.

It predates this PR — digesting the pre-change single greedy pass over 15,600 books across four processes gave three different digests — so nothing here needs to change for it. Keeping it out of this PR to leave the diff focused on the margin defect; happy to pick it up once this one merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants