Skip to content

Greedy option-strategy grouping charges naked-call margin on fully-covered debit spread books; inconsistent combo order buying power checks and silent cash insolvency in backtests #9638

Description

@AlexCatarino

Expected Behavior

A portfolio that holds only long call debit spreads (every short call covered 1:1 by a long call at a lower strike, same expiry) should require no maintenance margin beyond the premium already paid. OptionStrategyPositionGroupBuyingPowerModel gets this right for a single group: Bull Call Spread margin is Max(longStrike - shortStrike, 0) = 0 plus the net debit as initial margin.

Consequently, opening one more small debit spread against such a portfolio should only require its net debit (~$250 for a $5-wide ATM SPY spread), through both ComboMarketOrder and two individual MarketOrder legs, and both order routes should be charged the same buying power.

Actual Behavior

When several overlapping debit spreads accumulate on the same underlying/expiry (e.g. a new ATM spread opened daily, so strikes overlap between days), OptionStrategyPositionGroupResolver re-resolves the whole per-underlying book on every order check and frequently carves it into higher-leg-count strategies instead of the minimal-margin pairing. Economically-covered shorts then land in groups that charge naked-call margin:

  • A $247 net-debit 1-contract spread order is rejected with Insufficient buying power to complete orders (Value:[528,-281]), Reason: ..., Maintenance Margin Delta: 12059.6, Free Margin: 8418.3 — ~24-49x the true risk of the trade (12,059.6 ≈ 20% × underlying value, i.e. one naked short SPY call).
  • The same structure, same sizing, is sometimes accepted and sometimes rejected on different days (the greedy match outcome depends on which strikes overlap that day), so the check is inconsistent.
  • Portfolio.TotalMarginUsed also adopts the mis-grouped value (tens of thousands of dollars for a book whose optimal grouping needs $0), which later triggers spurious margin-call liquidations.
  • Because on many days the phantom margin does not appear, the account can keep buying spreads while Portfolio.Cash goes deeply negative (buying power is gated on MarginRemaining = TPV-based, never on cash), ending with insolvent backtests (e.g. -111% drawdown on a $30k account) instead of clean rejections.

Onset: correct while spreads are identical, wrong from the first overlapping pair

Instrumented runs (re-running the GetReservedBuyingPowerImpact contemplation inside the algorithm before each order) show the defect activating at a precise, minimal point — identically in both order routes:

  • 2025-01-02 → 01-27: every order is charged correctly. The first order sees an empty book (delta = its $290 premium); subsequent same-strike orders merge into one Bull Call Spread ×N group, margin 0, delta = premium only ($108–$301).
  • 2025-01-28 — first day two different-strike spreads coexist: held (598L/603S), new order (600L/605S). Optimal grouping is two spreads, $0 margin. The resolver instead produces Bull Call Ladder (600L, 603S, 605S) = $12,257 + orphan long 598 → delta 12,552. The order still fills (free margin was ~$32k), but TotalMarginUsed jumps to ~$12.8k — the book's margin accounting is corrupted from the first overlapping pair onward, two spreads being sufficient to trigger it.
  • 2025-01-28 → 01-31 churn: as one more spread is added each day, the re-grouping flip-flops and reserved margin oscillates $0 → $12,758 → $12,770 → $100 → $12,306 (a 2025-01-30 re-resolve happens to find Short Butterfly + two spreads = $100, then next day it's back to a ladder), producing alternating deltas of ±$11-12.5k for identical $250-debit trades.
  • 2025-02-07 — first rejection, once free margin has shrunk below the phantom delta. The full contemplated breakdown that day — the book held 10 long / 8 short Feb-21 calls, every short coverable by a strictly-lower long (optimal margin $0):
GROUP [Butterfly Call]        margin=0.0      (598@1  | 603@-2 | 608@1)
GROUP [Short Butterfly Call]  margin=100.0    (603@-1 | 604@2  | 605@-1)
GROUP [Bull Call Ladder]      margin=12403.6  (600@1  | 605@-1 | 609@-1)
GROUP [Bull Call Ladder]      margin=11812.6  (600@1  | 609@-1 | 613@-1)
GROUP [Bull Call Ladder]      margin=11812.6  (608@1  | 609@-1 | 613@-1)
GROUP [Naked Call]            margin=0.0      (598@2)
GROUP [Naked Call]            margin=0.0      (604@1)

contemplated = 36,128.8 vs current reserved = 24,316.2 → delta 12,059.6 → the $247 spread is rejected.

Combo vs individual legs

Both routes are charged identically (and correctly) until 2025-01-28, and both hit the same mis-grouping from then on. Their outcomes diverge at the first rejection: ComboMarketOrder rejects both legs atomically, while with individual MarketOrder legs the long has already filled when the short is rejected, leaving an unpaired long in the book. Over the same 9-month run (2025-01-01 → 2025-10-01, $30k):

ComboMarketOrder individual MarketOrder legs
total orders 262 247
rejected legs 74 101
margin-call liquidations 3 5
net profit 43.7% 48.9%

With individual legs there is also the purest symptom: a buy of a single long call (max risk = its $544 premium) rejected with Maintenance Margin Delta: 12135.5 — the delta is entirely a re-grouping artifact of positions already held, not the order's own risk.

Root Cause

The matcher never considers margin when choosing among valid groupings:

  1. The grouping is greedy by leg count and never looks at margin. OptionStrategyPositionGroupResolver.GetPositionGroups calls OptionStrategyMatcher.MatchOnce (Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs:47-72), which walks the definitions in DescendingByLegCountOptionStrategyDefinitionEnumerator order (the ForDefinitions default, OptionStrategyMatcherOptions.cs:91), matching one strategy at a time and removing its legs — 3-leg ladders/butterflies are therefore consumed before 2-leg spreads are ever tried, and the IOptionStrategyMatchObjectiveFunction scoring hook is not consulted on this path at all. The in-code comment already acknowledges the gap: "a better implementation would be to evaluate all possible matches and make decisions prioritizing positions that would require more margin if not matched" (OptionStrategyMatcher.cs:54-56). Once the ladder is matched, GetCallLadderMargin charges OptionMarginModel naked margin ≈ 20% of the underlying for its uncovered short (Common/Securities/Option/OptionStrategyPositionGroupBuyingPowerModel.cs:628).
  2. Every order re-groups the whole book. OptionStrategyPositionGroupResolver.GetImpactedGroups (Common/Securities/Positions/OptionStrategyPositionGroupResolver.cs:157-172) matches by underlying, so each order check re-resolves the entire per-underlying book; combined with (1), the buying-power result depends on which strikes happen to overlap that day — hence the inconsistency and the day-to-day churn of TotalMarginUsed.
  3. No cash gate. Independent of grouping, PositionGroupBuyingPowerModel.HasSufficientBuyingPowerForOrder (Common/Securities/Positions/PositionGroupBuyingPowerModel.cs:184-194) only compares the margin delta with MarginRemaining; debit premiums are never checked against cash, so on the days the phantom margin does not appear the account runs Portfolio.Cash to large negative values with no margin call — silent insolvency instead of a rejection.

Proposed Change

Make the grouping margin-aware: pick the partition of the book that minimizes total required margin (groups' margin plus per-security margin of the unmatched remainder), which is also what real brokers' margin-optimization does — exactly the direction the existing OptionStrategyMatcher.cs:54-56 comment sketches. The scoring hook for candidate evaluation already exists (IOptionStrategyMatchObjectiveFunction.ComputeScore, Common/Securities/Option/StrategyMatcher/IOptionStrategyMatchObjectiveFunction.cs:28), but MatchOnce — the path the position-group resolver uses — bypasses it. Practical caveats:

  • Cost: margin-per-candidate inside a combinatorial search bounded by maximumDuration is expensive. A cheap heuristic fixes this class of case without full optimization: match risk-defined 2-leg spreads before naked-short-bearing groups (ladders, short butterflies) when both can cover the same shorts — effectively reversing today's descending-leg-count preference for short-containing strategies.
  • Context: ComputeScore only sees OptionPositionCollections; computing margin needs security prices/models, so a margin-aware objective (or MatchOnce itself) needs portfolio context threaded in.

Separately, (3) above deserves its own guard: debit premiums should be payable from settled cash regardless of how the book is grouped.

Reproduction

Minimal algorithm (C#, cloud defaults, no custom models): from 2025-01-01 with $30,000, each trading day open one 1-contract ATM $5-wide SPY bull call spread via ComboMarketOrder (nearest expiry 20-45 DTE), never close. The first overlapping-strike day (~4 weeks in) triggers the mis-grouping; within ~5 weeks orders are rejected with 5-figure Maintenance Margin Delta values while equivalent trades on other days fill; by late February cash is ~-$30k and margin-call liquidations of auto-exercised SPY shares begin.

public class ComboSpreadMarginRepro : QCAlgorithm
{
    private Symbol _option;
    private DateTime _last;
    public override void Initialize()
    {
        SetStartDate(2025, 1, 1);
        SetEndDate(2025, 10, 1);
        SetCash(30000);
        AddEquity("SPY", Resolution.Minute);
        var option = AddOption("SPY", Resolution.Minute);
        _option = option.Symbol;
        option.SetFilter(u => u.Strikes(-10, 10).Expiration(20, 45));
    }
    public override void OnData(Slice slice)
    {
        if (Time.Date == _last.Date || !slice.OptionChains.TryGetValue(_option, out var chain)) return;
        var calls = chain.Where(c => c.Right == OptionRight.Call).ToList();
        if (calls.Count == 0) return;
        var expiry = calls.Min(c => c.Expiry);
        var atExpiry = calls.Where(c => c.Expiry == expiry).OrderBy(c => c.Strike).ToList();
        var longCall = atExpiry.OrderBy(c => Math.Abs(c.Strike - chain.Underlying.Price)).FirstOrDefault();
        var shortCall = atExpiry.FirstOrDefault(c => c.Strike == longCall.Strike + 5m);
        if (longCall == null || shortCall == null) return;
        _last = Time;
        ComboMarketOrder(new List<Leg> { Leg.Create(longCall.Symbol, 1), Leg.Create(shortCall.Symbol, -1) }, 1);
    }
}

Support ref: Intercom conversation 215475229332236.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions