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:
- 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).
- 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.
- 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.
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.
OptionStrategyPositionGroupBuyingPowerModelgets this right for a single group: Bull Call Spread margin isMax(longStrike - shortStrike, 0) = 0plus 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
ComboMarketOrderand two individualMarketOrderlegs, 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),
OptionStrategyPositionGroupResolverre-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: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).Portfolio.TotalMarginUsedalso 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.Portfolio.Cashgoes deeply negative (buying power is gated onMarginRemaining= 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
GetReservedBuyingPowerImpactcontemplation inside the algorithm before each order) show the defect activating at a precise, minimal point — identically in both order routes:Bull Call Ladder (600L, 603S, 605S)= $12,257 + orphan long 598 → delta 12,552. The order still fills (free margin was ~$32k), butTotalMarginUsedjumps to ~$12.8k — the book's margin accounting is corrupted from the first overlapping pair onward, two spreads being sufficient to trigger it.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:
ComboMarketOrderrejects both legs atomically, while with individualMarketOrderlegs 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):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:
OptionStrategyPositionGroupResolver.GetPositionGroupscallsOptionStrategyMatcher.MatchOnce(Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs:47-72), which walks the definitions inDescendingByLegCountOptionStrategyDefinitionEnumeratororder (theForDefinitionsdefault,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 theIOptionStrategyMatchObjectiveFunctionscoring 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,GetCallLadderMarginchargesOptionMarginModelnaked margin ≈ 20% of the underlying for its uncovered short (Common/Securities/Option/OptionStrategyPositionGroupBuyingPowerModel.cs:628).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 ofTotalMarginUsed.PositionGroupBuyingPowerModel.HasSufficientBuyingPowerForOrder(Common/Securities/Positions/PositionGroupBuyingPowerModel.cs:184-194) only compares the margin delta withMarginRemaining; debit premiums are never checked against cash, so on the days the phantom margin does not appear the account runsPortfolio.Cashto 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-56comment sketches. The scoring hook for candidate evaluation already exists (IOptionStrategyMatchObjectiveFunction.ComputeScore,Common/Securities/Option/StrategyMatcher/IOptionStrategyMatchObjectiveFunction.cs:28), butMatchOnce— the path the position-group resolver uses — bypasses it. Practical caveats:maximumDurationis 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.ComputeScoreonly seesOptionPositionCollections; computing margin needs security prices/models, so a margin-aware objective (orMatchOnceitself) 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-figureMaintenance Margin Deltavalues while equivalent trades on other days fill; by late February cash is ~-$30k and margin-call liquidations of auto-exercised SPY shares begin.Support ref: Intercom conversation 215475229332236.