fix(margin-call-handling): fail closed on unusable data and escalate on the broker's own cushion

The engine graded accounts solely on maintenance_margin / net_liquidation_value
and never read excess_liquidity or available_funds, though both were fields on
the snapshot. Interactive Brokers defines Excess Liquidity as Equity with Loan
Value minus maintenance margin, and ELV is not NLV: it excludes value that does
not count toward margin equity. An account holding non-marginable assets can
therefore show a 70% house ratio while the broker's cushion is already negative
and positions are being liquidated.

Reproduced against the previous code: with NLV 100k, maintenance margin 70k and
excess liquidity -10k, evaluate_margin_health returned NORMAL and
guard_new_order approved a new order.

Separately, every threshold comparison against NaN evaluates False, so a NaN
ratio fell through the breach, critical and warning tests into the healthy
branch. A NaN net liquidation value, a NaN maintenance margin, and a NaN
margin_impact each reported a healthy account or approved an order.

Risk evaluation:
- Escalate to BREACH whenever excess_liquidity is negative, regardless of the
  house ratio, and expose broker_deficiency on the evaluation.
- Validate every snapshot, position and order input is finite; raise the new
  MarginDataError rather than grading an account the engine cannot measure.
- Stop flooring NLV at 0.01. At non-positive equity the ratio is undefined, so
  report infinity and a distinct HALT_AND_ESCALATE action; the deficit is now
  computed from the true NLV, which the floor understated by exactly the amount
  of negative equity (150,000 reported as 99,999.99).
- Validate threshold configuration: unordered thresholds silently made the
  WARNING tier unreachable, and a de-leverage target at or above the breach
  threshold aimed the recovery at a state that is itself a breach.

Order gating:
- Add an initial-margin gate. Positions are opened against initial margin, which
  Reg T sets at 50% for a long margin equity purchase against FINRA 4210's 25%
  maintenance minimum, so a maintenance-only projection is the weaker constraint.
  guard_new_order now accepts initial_margin_impact and tests it against
  available_funds.
- is_deleveraging=True returned True unconditionally, so an order with a
  +999,999 margin impact bypassed every gate. A de-leveraging order must reduce
  margin; a positive impact is now refused.

De-leveraging:
- Validate positions and make NLV handling consistent with evaluate_margin_health;
  non-positive equity now targets a full unwind explicitly.
- average_daily_volume no longer defaults to 1,000,000. It caps how much of a
  position the plan will sell, and a default asserts liquidity the position may
  not have.
- Document that the linear per-position margin model does not hold under
  Portfolio Margin or SPAN, where closing a hedge leg can increase total margin.

Documentation:
- standards.md cited reqSecDefOptParams as IBKR's pre-trade margin check. That
  call returns option chain metadata; the mechanism is Order.whatIf=true, whose
  OrderState returns initMarginChange and maintMarginChange. Corrected, with the
  error called out.
- Correct the IBKR warning trigger (Equity with Loan Value <= maintenance margin
  x 110%, not excess liquidity below 10% of NLV) and the Zerodha row, whose real
  policy is MTM-loss and time based, including the ~15:20 IST intraday square-off
  and exchange shortfall penalties, not margin-utilisation based.
- Qualify the portfolio margin stress range: -8%/+6% high-cap broad-based, +/-10%
  other broad-based, +/-15% sector and individual equity.
- Note that IBKR does not make margin calls and liquidates without notice, so
  BREACH is a state to avoid reaching rather than one to plan to act from.
- workflows.md taught the 0.01 NLV floor as procedure; corrected.

Tests: 5 -> 29, with regression coverage for each defect above and exact-boundary
tier checks. Thresholds remain house policy, not regulation, and are labelled so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoqCTfNGdU91RQWxC3Jwyv
This commit is contained in:
Claude
2026-08-13 17:08:19 +00:00
parent ffee373e42
commit b1b375e8b0
7 changed files with 882 additions and 87 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"generated_at": "2026-08-13T16:52:09.627524+00:00",
"generated_at": "2026-08-13T17:06:53.595088+00:00",
"repository": "https://github.com/HimanshuJ16/Algo-Trading-Skills",
"domain": "algorithmic-trading",
"total_skills": 504,
@@ -1130,7 +1130,7 @@
},
{
"name": "broker-account-margin-call-handling",
"description": "Use when monitoring margin account health to calculate real-time maintenance margin ratios, enforce multi-tiered margin warning thresholds, evaluate predictive order impacts, and trigger automated liquidity-aware de-leveraging before broker forced liquidation.",
"description": "Use when monitoring margin account health to calculate real-time maintenance margin ratios, cross-check them against the broker's own excess-liquidity cushion, enforce multi-tiered margin warning thresholds, gate new orders on initial margin, and trigger liquidity-aware de-leveraging before broker forced liquidation.",
"domain": "algorithmic-trading",
"subdomain": "broker-integration",
"tags": [
@@ -1147,8 +1147,8 @@
"Alpaca Margin API",
"CME SPAN"
],
"version": "2.0.0",
"author": "quantitative-systems-engineer",
"version": "3.0.0",
"author": "algo-trading-skills-contributors",
"license": "Apache-2.0",
"path": "skills/broker-account-margin-call-handling",
"skill_md": "skills/broker-account-margin-call-handling/SKILL.md"
@@ -1,9 +1,9 @@
---
name: broker-account-margin-call-handling
description: Use when monitoring margin account health to calculate real-time maintenance
margin ratios, enforce multi-tiered margin warning thresholds, evaluate predictive
order impacts, and trigger automated liquidity-aware de-leveraging before broker
forced liquidation.
margin ratios, cross-check them against the broker's own excess-liquidity cushion,
enforce multi-tiered margin warning thresholds, gate new orders on initial margin, and
trigger liquidity-aware de-leveraging before broker forced liquidation.
domain: algorithmic-trading
subdomain: broker-integration
tags:
@@ -18,63 +18,146 @@ brokers_frameworks:
- Zerodha RMS
- Alpaca Margin API
- CME SPAN
version: "2.0.0"
author: quantitative-systems-engineer
version: "3.0.0"
author: algo-trading-skills-contributors
license: Apache-2.0
---
## When to Use
Invoke this whenever an algorithmic trading bot operates on a margin account (Reg T, Portfolio Margin, or Futures margin). If adverse market movements push maintenance margin requirements above Net Liquidation Value (NLV), brokers issue margin calls and automatically liquidate positions at market prices, incurring severe slippage and realization losses.
Invoke this whenever an algorithmic trading bot operates on a margin account (Reg T,
Portfolio Margin, or futures margin). When adverse market moves push maintenance margin
requirements toward or past account equity, brokers liquidate positions at market prices,
and they choose which ones. Acting first — cancelling resting orders, blocking new
leverage, and unwinding on your own schedule with liquidity limits — is the difference
between a controlled reduction and a forced one.
Implementing real-time margin ratio monitoring, multi-tiered risk gates (`WARNING` at 85%, `CANCEL_ORDERS` at 95%, `DE_LEVERAGE` at 100%), predictive pre-trade margin impact checks, and systematic, liquidity-aware order vetoing prevents broker-forced liquidation.
Use it to run three gates: a **tiered house ratio** as early warning, a **broker-cushion
check** that catches deficiency the house ratio can miss, and a **pre-trade veto** on
orders that would consume margin you do not have.
## When NOT to Use
- **As a substitute for the broker's own numbers.** The engine grades a snapshot you
supply. If your feed is stale, the grade is stale. Poll `excess_liquidity` and
`available_funds` from the broker; do not derive them from NLV.
- **As a guaranteed way to beat the broker to liquidation.** IBKR does not make margin
calls — it liquidates in real time, without prior notice, and may do so without the
account ever displaying a margin warning. If you are already at BREACH you may have no
window at all. The pre-breach tiers are where this skill earns its keep.
- **To size liquidations under Portfolio Margin or SPAN without re-pricing.** The planner
assumes margin is separable per position. Under portfolio-level regimes, closing one leg
of a hedge can *raise* total margin — see Workflow step 5.
- **On a cash account**, or for exchange margin-shortfall *penalty* accounting, which is a
separate settlement-side concern.
## Prerequisites
- Access to broker real-time account data: `net_liquidation_value`, `maintenance_margin`, `initial_margin`, `excess_liquidity`.
- Open order cancellation interface.
- Position de-leveraging prioritization logic (highest-margin, short options tail-risk, or liquidity-capped slicing).
- Broker real-time account data: `net_liquidation_value`, `initial_margin`,
`maintenance_margin`, and critically `excess_liquidity` and `available_funds`.
- A pre-trade margin impact source. Estimating it is the weak link — get it from the
broker. At IBKR that is an `Order.whatIf = true` submission, whose `OrderState` returns
`initMarginChange` and `maintMarginChange`.
- An open-order cancellation interface.
- Per-position `average_daily_volume`. It is a required field, not an optional one: it
caps how much of a position the plan will sell, and a guessed value defeats the cap.
- An execution path for liquidation slices (TWAP/VWAP or equivalent).
## Workflow
1. **Calculate Margin Utilization Metrics**:
- Compute Maintenance Margin Utilization Ratio: $M_{\text{ratio}} = \frac{\text{Maintenance Margin}}{\text{Net Liquidation Value}}$.
- Compute Predictive Initial Margin Impact before order placement.
1. **Evaluate account health** with `evaluate_margin_health(snapshot)`. It escalates on
the *worse* of two signals:
- the house ratio $M = \text{maintenance margin} / \text{NLV}$, against your configured
tiers, and
- **`excess_liquidity < 0`**, the broker's own cushion, which is authoritative.
Unusable input (NaN, infinity, negative margin) raises `MarginDataError` rather than
grading the account. A margin engine that reports NORMAL because its feed broke is
worse than one that stops.
2. **Evaluate Multi-Tiered Margin Thresholds**:
- **NORMAL** ($M_{\text{ratio}} < 0.85$): Normal trading operations.
- **WARNING** ($0.85 \le M_{\text{ratio}} < 0.95$): Block new leverage-increasing orders; issue alert to risk operators.
- **CRITICAL** ($0.95 \le M_{\text{ratio}} < 1.0$): Cancel all open pending orders immediately to release reserved margin buffers.
- **BREACH** ($M_{\text{ratio}} \ge 1.0$): Execute automated de-leveraging to restore $M_{\text{ratio}} \le 0.75$ (target buffer) before broker forced liquidation.
2. **Act on the tier.** Defaults are house policy, not regulation — set them for your book:
- **NORMAL** ($M < 0.85$): trade normally.
- **WARNING** ($0.85 \le M < 0.95$): block new leverage-increasing orders; alert.
- **CRITICAL** ($0.95 \le M < 1.0$): cancel all resting orders to release reserved margin.
- **BREACH** ($M \ge 1.0$, or negative excess liquidity): de-leverage now.
For calibration, IBKR's own warning fires at roughly a 90.9% maintenance/ELV ratio,
between the 85% and 95% defaults.
3. **Execute De-Leveraging Order Slicing**:
- Score and sort existing positions prioritizing unhedged short options (tail risk), high margin density, and highly liquid assets.
- Apply a volume participation cap (e.g., max 10% of ADV) to prevent the de-leveraging algorithm from causing flash crashes in illiquid names.
- Liquidate position slices systematically until the target margin buffer is restored.
3. **Handle non-positive equity as a different problem.** If NLV $\le 0$ the ratio is
undefined — the engine returns `math.inf` and the action `HALT_AND_ESCALATE` rather than
`DE_LEVERAGE_IMMEDIATELY`. There is no target margin to de-leverage toward against
non-positive equity; stop trading and involve a human.
4. **Predictive New Order Veto Guard**:
- Intercept outbound order requests via `guard_new_order(margin_impact)`. Veto any order that *would* increase the projected margin ratio $\ge 0.85$.
4. **Gate every new order** through `guard_new_order(snapshot, margin_impact,
initial_margin_impact=...)`. Pass `initial_margin_impact` whenever you have it: new
positions are opened against **initial** margin (Reg T requires 50% on a long margin
equity purchase) not maintenance margin (FINRA 4210's 25% minimum), so a
maintenance-only projection is the weaker constraint and can pass an order the broker
refuses. The guard returns `True` or raises — it never returns `False`, so treat any
exception as a hard veto.
5. **Plan de-leveraging** with `plan_deleveraging(snapshot, positions)`, which orders by
short-option tail risk, then margin density, then liquidity, and caps each slice at
`max_participation_rate` of ADV. **Then re-price the plan before acting on it if you
are on Portfolio Margin or SPAN** — the planner's linear "units × margin per unit" model
does not hold when margin is computed on portfolio-level stressed loss, and unwinding a
hedge leg can increase the requirement. Send each slice through the broker's own
pre-trade check first.
6. **Expect the plan to be incomplete under illiquidity.** The ADV cap can leave the plan
short of the required reduction. That is the intended trade-off — dumping the position
would crush its price, lower NLV and trigger a second deficiency — but it means you must
check whether the plan actually clears the deficit and escalate when it does not.
> Full step-by-step procedure with broker-specific detail: see `references/workflows.md`.
> Broker/framework coverage table for this skill: see `references/standards.md`.
> Broker triggers, cushion definitions and regulatory reference points: see `references/standards.md`.
> Printable pre-flight checklist: see `assets/checklist.md`.
## Common Pitfalls
- **Passive Waiting for Broker Liquidation**: Waiting for the broker's automated RMS engine to liquidate positions, leading to market-order slippage and bad fills.
- **Ignoring Predictive Margin Impact**: Approving an order when current margin is 80%, but the order itself pushes margin to 110%.
- **Illiquidity Spirals**: Dumping an illiquid asset all at once, crushing the bid price, lowering the NLV further, and triggering a secondary margin call.
- **Ignoring Tail Risk**: De-leveraging long equity positions first while leaving naked short options open during a volatility spike.
- **Trusting the house ratio alone.** `maintenance_margin / NLV` uses NLV; the broker's
cushion uses Equity with Loan Value, which excludes non-margin-eligible value and can be
much lower. An account can read 70% and healthy while `excess_liquidity` is already
negative and liquidation is underway.
- **Letting NaN through a threshold chain.** Every comparison against NaN is False, so a
NaN ratio falls past `>= breach`, `>= critical` and `>= warning` into the healthy branch.
A broken feed must fail closed.
- **Checking new orders against maintenance margin.** Positions are opened against initial
margin, which is roughly double under Reg T.
- **Flooring NLV to avoid dividing by zero.** It manufactures a finite ratio from an
undefined one and, if the deficit is computed from the floored value, understates the
deficit by exactly the amount of negative equity.
- **Exempting "de-leveraging" orders without checking they de-leverage.** A bypass flag
that is trusted unconditionally lets a margin-increasing order through every gate.
- **Unordered thresholds.** Setting warning above critical makes the WARNING tier
unreachable — the account jumps straight to CRITICAL and the early gate never fires.
- **Passive waiting for broker liquidation**, taking market-order slippage on positions
someone else chose.
- **Illiquidity spirals**: dumping an illiquid asset at once, crushing the bid, lowering
NLV, and triggering a secondary call.
- **Ignoring tail risk**: unwinding long equity first while naked short options stay open
through a volatility spike.
- **Ignoring the clock.** Where the broker force-closes intraday products on a schedule
(Zerodha squares off MIS positions around 15:20 IST), being margin-healthy is not enough.
## Verification
- Ensure `evaluate_margin_health` correctly applies logic across initial and maintenance margins.
- Submit account metrics with $M_{\text{ratio}} = 0.88$ and confirm state transitions to `WARNING`.
- Simulate a high-impact order using `guard_new_order` and verify predictive rejection.
- Run unit test suite `python scripts/test_margin_call_engine.py` and confirm 100% pass rate.
- Run the unit suite and confirm every test passes:
`python -m unittest discover -s skills/broker-account-margin-call-handling/scripts`
- Feed a snapshot with a healthy ratio but negative `excess_liquidity` and confirm the
state is `MARGIN_CALL_BREACH` with `broker_deficiency` set — this is the failure mode
most likely to be missed in production.
- Feed NaN in each snapshot field in turn and confirm `MarginDataError` every time; a
`NORMAL` result from any of them is a fail-open bug.
- Check each tier at its exact boundary (85.0%, 95.0%, 100.0%), not just mid-band.
- Submit a snapshot with $M = 0.88$ and confirm `WARNING`.
- Submit an order whose `initial_margin_impact` exceeds `available_funds` and confirm the
veto, even when the maintenance projection alone would have passed.
- Replay a historical drawdown that actually breached, and confirm the tiers would have
fired early enough to matter given your broker's liquidation behaviour.
## Related Skills
- `kill-switch-and-drawdown-circuit-breakers`
- `options-margin-span-calculation-global`
- `correlation-aware-exposure-limits`
- `margin-utilization-circuit-breaker`
@@ -3,11 +3,35 @@
Use this before considering the skill's implementation complete.
- [ ] **Ratio Evaluation:** Confirm `evaluate_margin_health()` calculates both initial and maintenance margin ratios accurately.
- [ ] **Multi-Tier Gates:** Confirm state transitions to `WARNING` (85%), `CRITICAL` (95%), and `BREACH` (100%).
- [ ] **Predictive Order Veto Gate:** Confirm `guard_new_order()` calculates projected margin impact and blocks leverage-increasing orders under margin stress.
- [ ] **Broker Cushion Cross-Check:** Confirm a snapshot with a *healthy* house ratio but
**negative `excess_liquidity`** escalates to `MARGIN_CALL_BREACH` with
`broker_deficiency` set. Equity with Loan Value is not NLV, and it is the broker's
cushion that triggers liquidation.
- [ ] **Fail-Closed on Bad Data:** Feed NaN and infinity into each snapshot field in turn
and confirm `MarginDataError` every time. Any `NORMAL` result here is a fail-open bug.
- [ ] **Non-Positive Equity:** Confirm NLV $\le 0$ yields `HALT_AND_ESCALATE` (not
`DE_LEVERAGE_IMMEDIATELY`) and that the reported deficit uses the true NLV, not a floor.
- [ ] **Multi-Tier Gates:** Confirm state transitions at the **exact** boundaries — 85.0%,
95.0%, 100.0% — not just mid-band values.
- [ ] **Threshold Config Validation:** Confirm unordered thresholds and a de-leverage target
at or above the breach threshold are rejected at construction.
- [ ] **Predictive Order Veto Gate:** Confirm `guard_new_order()` blocks leverage-increasing
orders under margin stress, and that it **vetoes on initial margin** when
`initial_margin_impact > available_funds` even if the maintenance projection passes.
- [ ] **Bypass Integrity:** Confirm an order flagged `is_deleveraging=True` with a *positive*
margin impact is refused rather than exempted.
- [ ] **Margin Impact Source:** Confirm impacts come from the broker (IBKR: `Order.whatIf`
→ `OrderState.initMarginChange` / `maintMarginChange`), not from a local estimate.
- [ ] **Tail-Risk Prioritization:** Confirm `plan_deleveraging()` sorts unhedged short options first for liquidation.
- [ ] **Liquidity Capping:** Confirm `plan_deleveraging()` applies an ADV (Average Daily Volume) maximum participation rate to prevent crashing illiquid assets.
- [ ] **Automated Testing:** Run `python -m unittest test_margin_call_engine.py` and confirm 100% test pass rate.
- [ ] **Liquidity Capping:** Confirm `plan_deleveraging()` applies an ADV (Average Daily Volume) maximum participation rate to prevent crashing illiquid assets, and that `average_daily_volume` is supplied per position rather than defaulted.
- [ ] **Deficit Actually Cleared:** Confirm the capped plan is checked against the required
reduction, and that a shortfall escalates instead of being assumed handled.
- [ ] **Portfolio Margin / SPAN Re-Pricing:** If on portfolio-level margining, confirm each
slice is re-priced through the broker before sending — closing a hedge leg can
*increase* the requirement.
- [ ] **Scheduled Square-Off:** Confirm the bot models broker time-based square-offs
(e.g. Zerodha MIS around 15:20 IST), not only margin thresholds.
- [ ] **Automated Testing:** Run `python -m unittest discover -s skills/broker-account-margin-call-handling/scripts` and confirm 100% test pass rate.
## Sign-off
@@ -1,20 +1,101 @@
# Broker & Framework Coverage — broker-account-margin-call-handling
| Broker / Margin System | Margin Warning Trigger | Forced Liquidation Threshold | Pre-Trade Margin Impact |
## How to read this document
Broker policies below are quoted from each broker's own published documentation and
were correct as at the sources linked. **They change, and they differ by account type,
jurisdiction and product.** Verify against your own broker's current terms before
encoding any number here into a live risk control. Nothing in this skill's default
thresholds (85% / 95% / 100%) comes from a regulator or a broker — they are house
policy you choose.
## 1. Broker triggers
| Broker / Margin System | Margin warning trigger | Forced liquidation trigger | Pre-trade margin impact |
|---|---|---|---|
| Interactive Brokers (Reg T / PM) | Excess Liquidity $< 10\%$ of NLV | Maintenance Margin $>$ Net Liquidation Value | Check Impact API `reqsecdefoptparams` |
| Zerodha RMS | Margin Utilization $\ge 85\%$ | Square-off triggered at 100% margin breach | Margin Calculator API |
| Alpaca Margin API | Equity $<$ Maintenance Margin | Day trade / maintenance margin call | Predictive Initial Margin check |
| CME SPAN | Configurable warning buffer | Intraday Initial Margin breach | SPAN array calculations |
| **Interactive Brokers** (Reg T / Portfolio Margin) | Margin warning when **Equity with Loan Value ≤ Maintenance Margin Requirement × 110%** — a 10% cushion above the *requirement*, not above NLV | **IBKR does not make margin calls.** Real-time liquidation occurs when the account has a margin deficiency (Excess Liquidity < 0), without prior notice and without letting you choose the positions, timing or order | `Order.whatIf = true` passed to `placeOrder`; the `openOrder` callback returns an `OrderState` carrying `initMarginChange`, `maintMarginChange` and `equityWithLoanChange` |
| **Zerodha** (India) | Margin shortfall reported against exchange-mandated margins; exchange **penalty of 0.5%** of the shortfall below ₹1 lakh and **1%** at or above it | Policy is **MTM-loss and time based, not margin-utilisation based**: positions may be squared off when MTM losses exceed ~50% of account funds; MTF positions at ~20% of the funded amount; and **all intraday (MIS) positions are auto squared off around 15:20 IST** regardless of margin health | Margin calculator / basket margin API |
| **Alpaca** | Equity approaching maintenance margin requirement | Equity below maintenance margin requirement; separate day-trade (PDT) margin call rules | Pre-trade estimation from account buying power |
| **CME SPAN** | A *margin methodology*, not a broker policy — SPAN produces the requirement; the warning and liquidation policy is set by your clearing FCM | Intraday variation/initial margin calls issued by the FCM | SPAN risk arrays evaluated against the portfolio |
## Category
**Sources:** IBKR Campus glossary (Margin Warning, Excess Liquidity, Current Available
Funds); [IBKR TWS API — Checking Margin Changes](https://interactivebrokers.github.io/tws-api/margin.html);
IBKR *Disclosure of Risks of Margin Trading*; Zerodha support articles on RMS square-off
and [margin penalties](https://zerodha.com/z-connect/general/margins-margin-penalties-when-trading-with-leverage).
`broker-integration` — see the top-level `mappings/` directory for how this category rolls up
across the full skill library.
> **Correction of a common error:** `reqSecDefOptParams` is *not* a margin call. It
> returns option chain metadata — trading class, exchange, expiries and strikes. The
> pre-trade margin mechanism at IBKR is the `whatIf` order above.
## Regulatory & Operational Notes
## 2. The two cushions, and why they disagree
- **Reg T vs Portfolio Margin**: Portfolio margin calculates risk based on maximum theoretical loss within a predefined range (e.g., +/- 15%). Reg T uses fixed percentages. The engine abstracts this via `maintenance_margin`.
- **FINRA Rule 4210**: Requires firms to collect margin. Failure to act proactively results in broker violations and possible account freezes.
- **Liquidity Spiral Risk**: Liquidating large illiquid positions at market can trigger a collapse in NLV, creating a secondary margin call. Therefore, `plan_deleveraging` must use an ADV cap (Average Daily Volume participation rate limit).
- **Tail Risk**: Short unhedged options carry the highest tail risk and are the first priority for liquidation in a margin stress scenario, regardless of PnL impact.
Interactive Brokers defines:
- **Available Funds** = Equity with Loan Value − **Initial** Margin
- **Excess Liquidity** = Equity with Loan Value − **Maintenance** Margin
Both are computed from **Equity with Loan Value (ELV)**, not from Net Liquidation Value.
ELV excludes value that does not count toward margin equity, so ELV ≤ NLV, sometimes
materially.
This is the trap the engine exists to close. An account can show
`maintenance_margin / NLV = 0.70` — comfortably inside any house "NORMAL" band — while
Excess Liquidity is already negative and the broker is liquidating. The house ratio is a
useful *early warning* because it moves before the breach; it is not authoritative about
whether a breach has occurred. **`excess_liquidity < 0` is the condition that matters**,
and `evaluate_margin_health` escalates on it regardless of the ratio.
For calibration: IBKR's warning fires at ELV ≤ 1.1 × maintenance margin, i.e. a
maintenance/ELV ratio of about **90.9%** — which sits between this skill's default 85%
warning and 95% critical tiers.
## 3. Regulatory reference points (US)
Jurisdiction: United States. These bind the **broker-dealer**; they constrain what your
account will be permitted to do, and they do not apply to non-US venues or to
non-securities margin.
- **Federal Reserve Regulation T** (12 CFR 220) sets *initial* margin: a broker may lend
up to **50%** of the purchase price of a margin equity security, so 50% must be
deposited. Short sales of non-exempted securities require **150%** of current market
value (the 100% proceeds plus 50%).
- **FINRA Rule 4210** sets *maintenance* margin: at least **25%** of current market value
for long positions, with higher requirements for shorts and for leveraged ETPs (the
rule text gives 2× the base figures for 2× leveraged products — 50% long, 60% short).
Rule 4210 also sets initial requirements where Reg T is silent.
- **Portfolio margin** (FINRA Rule 4210(g)) replaces fixed percentages with a stress test
of the portfolio's maximum theoretical loss across a defined range. The range is **not**
a single number: roughly −8%/+6% for high-capitalisation broad-based indices, ±10% for
other broad-based indices, and **±15% for sector indices and individual equities**.
Consequence for this skill: a **pre-trade** check must be made against **initial** margin,
not maintenance margin. At Reg T levels the initial requirement is double the maintenance
minimum, so an order can pass a maintenance-margin projection and still be refused, or be
accepted and leave the account with far less room than the projection implied. That is why
`guard_new_order` accepts an `initial_margin_impact` and tests it against `available_funds`.
**Sources:** [FINRA Rule 4210](https://www.finra.org/rules-guidance/rulebooks/finra-rules/4210),
[FINRA Margin Regulation](https://www.finra.org/rules-guidance/key-topics/margin-accounts),
[FINRA Portfolio Margin FAQ](https://www.finra.org/rules-guidance/key-topics/portfolio-margin/faq).
## 4. Operational notes
- **Margin is not separable per position under Portfolio Margin or SPAN.** Both compute the
requirement on the portfolio's stressed loss, so closing one leg of a hedge can *increase*
total margin. `plan_deleveraging` uses a linear per-position model; under these regimes
treat its output as a candidate ordering and re-price each slice through the broker
(an IBKR `whatIf` order, or a SPAN evaluation) before sending it.
- **Liquidity spiral risk.** Liquidating a large illiquid position at market can collapse
its price, lowering NLV and creating a secondary deficiency. Hence the ADV participation
cap. The cap also means a plan may not clear the deficit in a single pass — that is the
intended trade-off, not a bug.
- **Tail risk.** Unhedged short options carry the largest adverse convexity and are
prioritised for liquidation regardless of PnL. Note this is a *risk* ordering: it will
often mean realising losses on the worst positions first.
- **Time-based square-off.** In markets where the broker force-closes intraday products on
a schedule (Zerodha's ~15:20 IST MIS square-off), margin health is not the only trigger.
A bot that is margin-healthy at 15:19 will still be flattened. Model the clock as well as
the ratio.
- **You may not get a window.** Where the broker liquidates in real time without notice, the
BREACH tier is not a state you plan to act from — it is a state you plan never to reach.
The pre-breach tiers carry the actionable decisions.
@@ -6,14 +6,36 @@ actually implementing the skill, not just when deciding whether it applies.
## Full Procedure
1. **Margin Utilization Calculation:**
- Ingest `net_liquidation_value` (NLV) and `maintenance_margin`.
- Ensure NLV is floored at `0.01` to prevent division by zero.
- Ingest `net_liquidation_value` (NLV), `maintenance_margin`, and — equally important —
`excess_liquidity` and `available_funds` from the broker.
- **Validate every value is finite first.** Every comparison against NaN evaluates
False, so a NaN ratio walks past the breach, critical and warning tests and lands in
the healthy branch. Unusable input must raise, not grade.
- Calculate Maintenance Margin Utilization Ratio: $M_{\text{ratio}} = \frac{\text{Maintenance Margin}}{\text{Net Liquidation Value}}$.
- **Do not floor NLV to avoid dividing by zero.** At NLV $\le 0$ the ratio is undefined;
represent it as infinity and branch to a distinct `HALT_AND_ESCALATE` action. Compute
the deficit from the *true* NLV — computing it from a floored value understates it by
exactly the amount of negative equity.
- **Cross-check the broker's cushion.** `excess_liquidity` is Equity with Loan Value
minus maintenance margin, and ELV is not NLV. When it is negative the account is in
deficiency and may be liquidated *even if* $M_{\text{ratio}}$ still looks healthy.
Escalate to BREACH on that signal alone.
2. **Predictive New Order Veto Guard:**
- Intercept outbound order requests with `guard_new_order(margin_impact)`.
- Calculate projected margin: `projected_maint_margin = maintenance_margin + margin_impact`.
- Calculate projected ratio. If projected ratio $\ge 0.85$, veto order.
- Intercept outbound order requests with
`guard_new_order(snapshot, margin_impact, initial_margin_impact=...)`.
- **Gate on initial margin first.** New positions are opened against initial margin
(Reg T: 50% on a long margin equity purchase), not maintenance margin (FINRA 4210:
25% minimum). Veto when `initial_margin_impact > available_funds` — `available_funds`
is the broker's own "room for new positions" figure (ELV − initial margin).
- Then the maintenance projection: `projected_maint_margin = maintenance_margin + margin_impact`.
If the projected ratio $\ge 0.85$, veto.
- Source both impacts from the broker rather than estimating. At IBKR, submit the order
with `Order.whatIf = true` and read `initMarginChange` / `maintMarginChange` off the
`OrderState` returned to `openOrder`.
- Risk-reducing orders may bypass the gates, but **verify they reduce risk**: an order
flagged as de-leveraging with a positive margin impact is a contradiction and must be
refused, not exempted.
3. **Evaluate Multi-Tier Risk Gates:**
- **NORMAL** ($M_{\text{ratio}} < 0.85$): Proceed with strategy execution.
@@ -28,6 +50,14 @@ actually implementing the skill, not just when deciding whether it applies.
- *Margin Density*: Maintenance margin requirement divided by notional value (+100 * density).
- *Liquidity*: High ADV assets receive higher priority, as they can be exited with minimal slippage (+ADV scaled).
- Cap the liquidation units per position to a percentage of its Average Daily Volume (e.g. 10%) to prevent localized flash crashes.
- **Check whether the capped plan actually clears the deficit.** Under illiquidity it
often will not, and that is the intended trade-off — but an unmet reduction must
escalate rather than be assumed handled.
- **Re-price the plan under Portfolio Margin or SPAN before acting.** The scoring model
assumes margin is separable per position (`reduction = units × margin per unit`).
Portfolio-level regimes margin the stressed loss of the whole book, so unwinding one
leg of a hedge can *increase* the requirement. Treat the output as a candidate
ordering and validate each slice through the broker's own pre-trade check.
- Generate execution slices to send to the execution algorithms (e.g., TWAP/VWAP).
## Failure Modes Observed in Production
@@ -36,6 +66,19 @@ actually implementing the skill, not just when deciding whether it applies.
- **Uncanceled Pending Orders:** Leaving active limit orders in the market that consume margin during adverse price moves.
- **Illiquidity Liquidation Trap:** Dumping an illiquid micro-cap stock to cover margin, causing the stock to drop 20%, which further drops NLV, triggering a secondary margin call. ADV caps solve this.
- **Selling the Winners:** Naively selling highly liquid, low-margin-density winners first, leaving the portfolio concentrated in high-risk, illiquid losers.
- **Healthy Ratio, Liquidating Broker:** Grading only on `maintenance_margin / NLV` while
the broker's `excess_liquidity` (computed off Equity with Loan Value) is already
negative. The account reads NORMAL and keeps accepting orders while positions are being
liquidated.
- **Silent NaN:** A dropped or corrupted account feed yields NaN, every threshold test
returns False, and the engine reports a healthy account through the entire drawdown.
- **Trusted Bypass Flag:** An order tagged as de-leveraging is exempted from the gates
without anyone checking that it actually reduces margin.
- **Waiting For a Window That Does Not Exist:** Designing the response around acting at
BREACH when the broker liquidates in real time without notice. IBKR states positions may
be liquidated without the account ever showing a margin warning.
- **Clock-Driven Square-Off:** Ignoring scheduled broker square-offs (Zerodha closes
intraday MIS positions around 15:20 IST) because the margin ratio looks fine.
## Production Implementation Reference
@@ -1,16 +1,91 @@
"""
broker-account-margin-call-handling:
Margin utilisation monitoring, pre-trade veto gating and liquidity-aware
de-leveraging planning for algorithmic trading on margin accounts.
Two metrics matter and they are not interchangeable:
- **The house ratio** ``maintenance_margin / net_liquidation_value``, which
drives the configurable WARNING / CRITICAL / BREACH tiers. This is an early
warning signal you choose the thresholds for; no regulator or broker defines
it.
- **The broker's own cushion**, ``excess_liquidity``. At Interactive Brokers
this is ``Equity with Loan Value - Maintenance Margin``, and when it goes
negative the account no longer meets maintenance requirements and positions
may be liquidated. It is authoritative in a way the house ratio is not.
They can disagree, and the direction of the disagreement is dangerous: Equity
with Loan Value excludes assets that do not count toward margin equity, so it
can be materially lower than NLV. An account holding non-marginable stock can
show ``maintenance_margin / NLV = 0.70`` — comfortably NORMAL on the house ratio
— while ``excess_liquidity`` is already negative and the broker is liquidating.
``evaluate_margin_health`` therefore treats negative excess liquidity as a breach
regardless of what the house ratio says.
Scope limits the caller must respect:
- **The de-leveraging planner assumes margin is separable per position**
(``reduction = units x margin_per_unit``). That holds approximately under
Reg T style fixed-percentage margining. It does **not** hold under Portfolio
Margin or SPAN, where margin is computed on the portfolio's stressed loss:
closing one leg of a hedge can *increase* total margin. Under those regimes
treat the plan as a candidate ordering only, and re-price each slice through
the broker before acting on it.
- **Timing is broker-specific.** IBKR does not make margin calls; it liquidates
in real time, without prior notice, and may do so without the account ever
displaying a margin warning. Reaching the BREACH tier does not mean you have
time to act — the pre-breach tiers are where the useful decisions happen.
- The engine plans; it does not place, cancel or reconcile orders.
See ``references/standards.md`` for per-broker triggers with sources and the
jurisdictional limits of the regulatory figures quoted.
"""
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import List, Dict, Tuple, Optional
import math
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
class MarginCallError(RuntimeError):
"""Raised when a margin breach occurs or new orders are vetoed under margin stress."""
pass
class MarginDataError(ValueError):
"""
Raised when account or order inputs are unusable.
Kept distinct from ``MarginCallError`` so a caller can tell "the risk engine
refused this order" from "the data feeding the risk engine is broken". Both
must stop the order; only the second means escalate to whoever owns the feed.
"""
pass
def _require_finite(name: str, value: float, *, allow_negative: bool = True) -> float:
"""
Validate a numeric margin input.
NaN is the specific hazard: every comparison against NaN is False, so a NaN
ratio walks past every threshold test and lands in the healthy branch. A
margin engine that reports NORMAL because the feed returned NaN is worse than
one that fails loudly, so unusable input raises rather than defaulting.
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise MarginDataError(f"{name} must be a number, got {value!r}")
if not math.isfinite(value):
raise MarginDataError(
f"{name} must be finite, got {value!r}. Refusing to evaluate margin health on "
f"an unusable feed value; treat this as a data outage, not a healthy account."
)
if not allow_negative and value < 0:
raise MarginDataError(f"{name} must be >= 0, got {value!r}")
return float(value)
class MarginState(str, Enum):
NORMAL = "NORMAL"
WARNING = "WARNING" # Margin Ratio >= warning_threshold
@@ -20,7 +95,16 @@ class MarginState(str, Enum):
@dataclass
class AccountMarginSnapshot:
"""Represents a real-time snapshot of account margin metrics."""
"""
Real-time snapshot of account margin metrics, as reported by the broker.
``excess_liquidity`` and ``available_funds`` are the broker's own cushions and
are treated as authoritative here. At Interactive Brokers:
``Excess Liquidity = Equity with Loan Value - Maintenance Margin`` and
``Available Funds = Equity with Loan Value - Initial Margin``. Populate them
from the broker rather than deriving them from NLV, because Equity with Loan
Value is not NLV.
"""
net_liquidation_value: float
initial_margin: float
maintenance_margin: float
@@ -29,16 +113,37 @@ class AccountMarginSnapshot:
buying_power: float
currency: str = "USD"
def validate(self) -> None:
"""Reject unusable values before any threshold comparison is made."""
_require_finite("net_liquidation_value", self.net_liquidation_value)
_require_finite("initial_margin", self.initial_margin, allow_negative=False)
_require_finite("maintenance_margin", self.maintenance_margin, allow_negative=False)
_require_finite("excess_liquidity", self.excess_liquidity)
_require_finite("available_funds", self.available_funds)
_require_finite("buying_power", self.buying_power)
@dataclass
class MarginCallEvaluation:
"""Outcome of a margin health check."""
"""
Outcome of a margin health check.
``maintenance_margin_ratio`` is ``math.inf`` when net liquidation value is
zero or negative: the ratio is genuinely undefined there, and infinity is the
honest encoding that also sorts correctly against every threshold.
``broker_deficiency`` is True when the broker's own cushion
(``excess_liquidity``) is negative. That is the condition under which
positions may actually be liquidated, and it can be True while the house
ratio still looks healthy.
"""
state: MarginState
maintenance_margin_ratio: float
initial_margin_ratio: float
maintenance_deficit: float
action_required: str
message: str
broker_deficiency: bool = False
@dataclass
@@ -49,10 +154,28 @@ class PositionMarginInfo:
quantity: float
current_price: float
maintenance_margin_requirement: float
average_daily_volume: float = 1_000_000.0
# Required, deliberately: this caps how much of the position the plan is
# willing to sell into the market. A default would silently assert liquidity
# the position may not have, and the whole point of the cap is to stop a
# forced sale from crushing the price of a name that cannot absorb it.
average_daily_volume: float
is_short_option: bool = False
beta_to_benchmark: float = 1.0
def validate(self) -> None:
_require_finite(f"{self.symbol}.quantity", self.quantity)
_require_finite(f"{self.symbol}.current_price", self.current_price, allow_negative=False)
_require_finite(
f"{self.symbol}.maintenance_margin_requirement",
self.maintenance_margin_requirement,
allow_negative=False,
)
_require_finite(
f"{self.symbol}.average_daily_volume",
self.average_daily_volume,
allow_negative=False,
)
class BrokerMarginCallEngine:
"""
@@ -73,7 +196,46 @@ class BrokerMarginCallEngine:
:param target_post_deleverage_ratio: Target maintenance margin ratio after forced liquidations.
:param max_participation_rate: Max % of ADV to trade during a liquidation slice.
:param liquidation_buffer_multiplier: Multiplier on required reduction to account for slippage.
Thresholds are house policy, not regulation, but they must be internally
consistent: unordered thresholds silently make a tier unreachable (with
warning above critical, an account jumps straight to CRITICAL and the
WARNING gate never fires), and a de-leverage target at or above the breach
threshold aims the recovery at a state that is itself a breach.
"""
for name, value in (
("warning_threshold", warning_threshold),
("critical_threshold", critical_threshold),
("breach_threshold", breach_threshold),
("target_post_deleverage_ratio", target_post_deleverage_ratio),
("max_participation_rate", max_participation_rate),
("liquidation_buffer_multiplier", liquidation_buffer_multiplier),
):
_require_finite(name, value, allow_negative=False)
if not warning_threshold < critical_threshold <= breach_threshold:
raise MarginDataError(
f"Thresholds must satisfy warning < critical <= breach, got "
f"warning={warning_threshold}, critical={critical_threshold}, "
f"breach={breach_threshold}. Unordered thresholds make a tier unreachable."
)
if not 0 < target_post_deleverage_ratio < breach_threshold:
raise MarginDataError(
f"target_post_deleverage_ratio must be > 0 and < breach_threshold "
f"({breach_threshold}), got {target_post_deleverage_ratio}. De-leveraging "
f"toward a ratio at or above the breach threshold cannot clear the breach."
)
if not 0 < max_participation_rate <= 1.0:
raise MarginDataError(
f"max_participation_rate must be in (0, 1], got {max_participation_rate}"
)
if liquidation_buffer_multiplier < 1.0:
raise MarginDataError(
f"liquidation_buffer_multiplier must be >= 1.0, got "
f"{liquidation_buffer_multiplier}; a multiplier below 1 under-liquidates "
f"relative to the computed requirement."
)
self.warning_threshold = warning_threshold
self.critical_threshold = critical_threshold
self.breach_threshold = breach_threshold
@@ -82,11 +244,74 @@ class BrokerMarginCallEngine:
self.liquidation_buffer_multiplier = liquidation_buffer_multiplier
def evaluate_margin_health(self, snapshot: AccountMarginSnapshot) -> MarginCallEvaluation:
"""Evaluates margin utilization ratios (Maintenance and Initial) and determines risk state."""
nlv = max(snapshot.net_liquidation_value, 0.01)
maint_ratio = snapshot.maintenance_margin / nlv
init_ratio = snapshot.initial_margin / nlv
"""
Evaluates margin utilisation and determines risk state.
Escalates on the worse of two signals: the configurable house ratio
``maintenance_margin / NLV``, and the broker's own ``excess_liquidity``
cushion. The second exists because the first can look healthy while the
account is already in deficiency at the broker — Equity with Loan Value
is not NLV, and it is the broker's number that decides whether positions
get liquidated.
Raises:
MarginDataError: any snapshot value is non-finite or negative where it
must not be. The engine refuses to grade an account it cannot
measure rather than reporting it healthy.
"""
snapshot.validate()
nlv = snapshot.net_liquidation_value
# The ratio is undefined at or below zero equity. Infinity is the honest
# encoding and compares correctly against every threshold; the previous
# 0.01 floor produced a finite ratio and, worse, a deficit understated by
# exactly the amount of negative equity.
if nlv > 0:
maint_ratio = snapshot.maintenance_margin / nlv
init_ratio = snapshot.initial_margin / nlv
else:
maint_ratio = math.inf
init_ratio = math.inf
deficit = max(0.0, snapshot.maintenance_margin - nlv)
broker_deficiency = snapshot.excess_liquidity < 0
if nlv <= 0:
msg = (
f"NEGATIVE/ZERO EQUITY! NLV is ${nlv:,.2f} with maintenance margin "
f"${snapshot.maintenance_margin:,.2f}. Deficit: ${deficit:,.2f}. De-leveraging "
f"cannot restore a ratio against non-positive equity — halt trading and escalate."
)
logger.critical(msg)
return MarginCallEvaluation(
state=MarginState.MARGIN_CALL_BREACH,
maintenance_margin_ratio=maint_ratio,
initial_margin_ratio=init_ratio,
maintenance_deficit=deficit,
action_required="HALT_AND_ESCALATE",
message=msg,
broker_deficiency=broker_deficiency,
)
if broker_deficiency and maint_ratio < self.breach_threshold:
# The broker's cushion is the one that triggers liquidation. Reporting
# NORMAL here because the house ratio looks fine is the failure this
# check exists to prevent.
msg = (
f"BROKER DEFICIENCY! Excess liquidity is ${snapshot.excess_liquidity:,.2f} "
f"(negative) even though the house ratio is {maint_ratio:.1%}. The account no "
f"longer meets maintenance requirements and positions may be liquidated — Equity "
f"with Loan Value is below NLV. Treating as BREACH."
)
logger.critical(msg)
return MarginCallEvaluation(
state=MarginState.MARGIN_CALL_BREACH,
maintenance_margin_ratio=maint_ratio,
initial_margin_ratio=init_ratio,
maintenance_deficit=deficit,
action_required="DE_LEVERAGE_IMMEDIATELY",
message=msg,
broker_deficiency=True,
)
if maint_ratio >= self.breach_threshold:
state = MarginState.MARGIN_CALL_BREACH
@@ -115,33 +340,86 @@ class BrokerMarginCallEngine:
maintenance_deficit=deficit,
action_required=action,
message=msg,
broker_deficiency=broker_deficiency,
)
def guard_new_order(self, snapshot: AccountMarginSnapshot, margin_impact: float, is_deleveraging: bool = False) -> bool:
def guard_new_order(
self,
snapshot: AccountMarginSnapshot,
margin_impact: float,
is_deleveraging: bool = False,
initial_margin_impact: Optional[float] = None,
) -> bool:
"""
Vetoes new orders under margin stress, accounting for the order's estimated margin impact.
:param margin_impact: Expected increase in maintenance margin from this order.
Returns True if the order may proceed, and raises otherwise — it never
returns False. Callers must treat any exception as a hard veto rather
than testing a boolean.
:param margin_impact: Expected increase in **maintenance** margin from this
order. Obtain it from the broker rather than estimating: at IBKR that
is a ``whatIf`` order, whose ``OrderState`` returns ``maintMarginChange``
and ``initMarginChange``.
:param is_deleveraging: Exempts risk-reducing orders from the gates, since
blocking them would trap the account in breach. The order must actually
reduce margin; a positive ``margin_impact`` contradicts the flag and is
refused.
:param initial_margin_impact: Expected increase in **initial** margin. New
positions are opened against initial margin, which is materially higher
than maintenance margin (Reg T requires 50% initial on a long margin
equity purchase against FINRA 4210's 25% maintenance minimum), so a
maintenance-only projection is the weaker of the two constraints and
can pass an order the broker will not accept. When supplied it is
checked against ``available_funds``, which is the broker's own
"room for new positions" figure.
Raises:
MarginDataError: unusable inputs, or ``is_deleveraging`` on an order
that increases margin.
MarginCallError: the order is vetoed on risk grounds.
"""
snapshot.validate()
_require_finite("margin_impact", margin_impact)
if initial_margin_impact is not None:
_require_finite("initial_margin_impact", initial_margin_impact)
if is_deleveraging:
if margin_impact > 0:
raise MarginDataError(
f"Order flagged is_deleveraging=True but has a positive margin impact of "
f"${margin_impact:,.2f}. A de-leveraging order must reduce margin; refusing "
f"to bypass the risk gates for an order that increases it."
)
return True
eval_res = self.evaluate_margin_health(snapshot)
# Predictive check
projected_maint_margin = snapshot.maintenance_margin + margin_impact
projected_ratio = projected_maint_margin / max(snapshot.net_liquidation_value, 0.01)
if projected_ratio >= self.warning_threshold:
# Initial-margin gate. Checked before the maintenance projection because
# it is the binding constraint on opening a position.
if initial_margin_impact is not None and initial_margin_impact > snapshot.available_funds:
raise MarginCallError(
f"Order vetoed! Projected margin ratio {projected_ratio:.1%} exceeds warning threshold {self.warning_threshold:.1%}."
f"Order vetoed! Initial margin impact ${initial_margin_impact:,.2f} exceeds "
f"available funds ${snapshot.available_funds:,.2f}. New positions are opened "
f"against initial margin, not maintenance margin."
)
if eval_res.state != MarginState.NORMAL:
raise MarginCallError(
f"Order vetoed under margin stress! Account state is '{eval_res.state.value}'. {eval_res.message}"
)
return True
# Predictive maintenance check. NLV is known positive here: a non-positive
# NLV returns BREACH from evaluate_margin_health and is caught below.
if eval_res.state == MarginState.NORMAL:
projected_maint_margin = snapshot.maintenance_margin + margin_impact
projected_ratio = projected_maint_margin / snapshot.net_liquidation_value
if projected_ratio >= self.warning_threshold:
raise MarginCallError(
f"Order vetoed! Projected margin ratio {projected_ratio:.1%} exceeds warning "
f"threshold {self.warning_threshold:.1%}."
)
return True
raise MarginCallError(
f"Order vetoed under margin stress! Account state is '{eval_res.state.value}'. "
f"{eval_res.message}"
)
def plan_deleveraging(
self, snapshot: AccountMarginSnapshot, positions: List[PositionMarginInfo]
@@ -152,18 +430,43 @@ class BrokerMarginCallEngine:
1. Short options (unlimited tail risk)
2. High beta / high margin requirement relative to notional
3. Highly liquid assets (to minimize slippage)
**This assumes margin is separable per position** — that closing N units of
a position releases ``N x (position margin / position size)``. That is
approximately true under Reg T style fixed-percentage margining. It is
false under Portfolio Margin and SPAN, which margin the portfolio's
stressed loss: unwinding one leg of a hedge can *raise* total margin. Under
those regimes use this output as a candidate ordering and re-price every
slice through the broker (an IBKR ``whatIf`` order, a SPAN evaluation)
before sending it.
Returns an empty plan when the account is not in breach, or when the
maintenance margin is already at or below target.
"""
eval_res = self.evaluate_margin_health(snapshot)
if eval_res.state != MarginState.MARGIN_CALL_BREACH:
return []
for pos in positions:
pos.validate()
nlv = snapshot.net_liquidation_value
target_margin = nlv * self.target_ratio
if nlv <= 0:
# No positive equity to size a target against: any positive target
# margin is unreachable, so the only coherent plan is to unwind
# everything, subject to the liquidity caps.
logger.critical(
f"De-leveraging with non-positive NLV (${nlv:,.2f}): targeting full unwind. "
f"Liquidity caps still apply, so the plan may not clear the deficit in one pass."
)
target_margin = 0.0
else:
target_margin = nlv * self.target_ratio
base_required_reduction = snapshot.maintenance_margin - target_margin
if base_required_reduction <= 0:
return []
# Add buffer to account for adverse slippage during liquidation
required_reduction = base_required_reduction * self.liquidation_buffer_multiplier
@@ -1,13 +1,28 @@
import math
import unittest
from margin_call_engine import (
AccountMarginSnapshot,
BrokerMarginCallEngine,
MarginCallError,
MarginDataError,
MarginState,
PositionMarginInfo,
)
def _snapshot(nlv, maintenance, initial=None, excess=None, available=0.0):
"""Snapshot helper; excess liquidity defaults to a healthy positive cushion."""
return AccountMarginSnapshot(
net_liquidation_value=nlv,
initial_margin=maintenance if initial is None else initial,
maintenance_margin=maintenance,
excess_liquidity=(nlv - maintenance) if excess is None else excess,
available_funds=available,
buying_power=0.0,
)
class TestBrokerMarginCallEngine(unittest.TestCase):
def setUp(self):
@@ -126,5 +141,251 @@ class TestBrokerMarginCallEngine(unittest.TestCase):
plan = self.engine.plan_deleveraging(snap, positions)
self.assertEqual(plan[0][1], 10.0)
class TestUnusableInputFailsClosed(unittest.TestCase):
"""
Every threshold comparison against NaN is False, so a NaN ratio falls through
the whole if/elif chain into the healthy branch. A margin engine must not
report NORMAL because its feed broke.
"""
def setUp(self):
self.engine = BrokerMarginCallEngine()
def test_nan_nlv_raises_instead_of_reporting_healthy(self):
with self.assertRaises(MarginDataError):
self.engine.evaluate_margin_health(_snapshot(float("nan"), 100_000.0))
def test_nan_maintenance_margin_raises_instead_of_reporting_healthy(self):
with self.assertRaises(MarginDataError):
self.engine.evaluate_margin_health(_snapshot(100_000.0, float("nan")))
def test_infinite_values_raise(self):
with self.assertRaises(MarginDataError):
self.engine.evaluate_margin_health(_snapshot(float("inf"), 100_000.0))
def test_negative_maintenance_margin_raises(self):
with self.assertRaises(MarginDataError):
self.engine.evaluate_margin_health(_snapshot(100_000.0, -1.0))
def test_nan_margin_impact_does_not_approve_the_order(self):
"""Regression: a NaN projected ratio passed every veto test and returned True."""
snap = _snapshot(100_000.0, 50_000.0)
with self.assertRaises(MarginDataError):
self.engine.guard_new_order(snap, margin_impact=float("nan"))
class TestNonPositiveEquity(unittest.TestCase):
def setUp(self):
self.engine = BrokerMarginCallEngine()
def test_negative_nlv_reports_the_true_deficit(self):
"""
Regression: NLV was floored at 0.01 and the deficit computed from the
floored value, understating it by exactly the negative equity. With
NLV = -50,000 and maintenance margin 100,000 the true deficit is 150,000;
the old code reported 99,999.99.
"""
res = self.engine.evaluate_margin_health(
_snapshot(-50_000.0, 100_000.0, excess=-150_000.0)
)
self.assertEqual(res.maintenance_deficit, 150_000.0)
self.assertEqual(res.state, MarginState.MARGIN_CALL_BREACH)
self.assertEqual(res.action_required, "HALT_AND_ESCALATE")
self.assertEqual(res.maintenance_margin_ratio, math.inf)
def test_zero_nlv_is_a_breach_not_normal(self):
"""Regression: NLV=0 with zero margin produced ratio 0.0 and state NORMAL."""
res = self.engine.evaluate_margin_health(_snapshot(0.0, 0.0, excess=0.0))
self.assertEqual(res.state, MarginState.MARGIN_CALL_BREACH)
self.assertEqual(res.action_required, "HALT_AND_ESCALATE")
def test_deleveraging_with_non_positive_nlv_targets_full_unwind(self):
snap = _snapshot(-10_000.0, 80_000.0, excess=-90_000.0)
positions = [
PositionMarginInfo(
symbol="ABC", asset_class="STK", quantity=1000, current_price=10.0,
maintenance_margin_requirement=80_000.0, average_daily_volume=1_000_000,
)
]
plan = self.engine.plan_deleveraging(snap, positions)
self.assertEqual(len(plan), 1)
self.assertEqual(plan[0][1], 1000.0) # entire position
class TestBrokerCushionOverridesHouseRatio(unittest.TestCase):
"""
Excess Liquidity is Equity with Loan Value minus maintenance margin, and ELV
is not NLV. An account holding non-marginable assets can look healthy on
maintenance/NLV while the broker's own cushion is already negative — which is
the condition under which positions actually get liquidated.
"""
def setUp(self):
self.engine = BrokerMarginCallEngine()
def test_negative_excess_liquidity_is_a_breach_despite_a_healthy_ratio(self):
# maintenance/NLV = 70k/100k = 0.70, comfortably NORMAL on the house ratio.
# But ELV is only 60k, so excess liquidity is -10k and the broker is in
# deficiency. Regression: this reported NORMAL.
snap = _snapshot(100_000.0, 70_000.0, excess=-10_000.0)
res = self.engine.evaluate_margin_health(snap)
self.assertEqual(res.state, MarginState.MARGIN_CALL_BREACH)
self.assertTrue(res.broker_deficiency)
self.assertAlmostEqual(res.maintenance_margin_ratio, 0.70)
def test_positive_excess_liquidity_leaves_the_house_ratio_in_charge(self):
res = self.engine.evaluate_margin_health(_snapshot(100_000.0, 50_000.0))
self.assertEqual(res.state, MarginState.NORMAL)
self.assertFalse(res.broker_deficiency)
def test_order_is_vetoed_when_the_broker_cushion_is_negative(self):
snap = _snapshot(100_000.0, 70_000.0, excess=-10_000.0)
with self.assertRaises(MarginCallError):
self.engine.guard_new_order(snap, margin_impact=0.0)
class TestThresholdConfiguration(unittest.TestCase):
def test_unordered_thresholds_rejected(self):
"""Regression: warning above critical silently made the WARNING tier unreachable."""
with self.assertRaises(MarginDataError):
BrokerMarginCallEngine(warning_threshold=0.95, critical_threshold=0.85)
def test_deleverage_target_at_or_above_breach_rejected(self):
"""Regression: a target of 1.5 aimed the recovery at a state that is itself a breach."""
for bad in (1.0, 1.5):
with self.assertRaises(MarginDataError):
BrokerMarginCallEngine(target_post_deleverage_ratio=bad)
def test_invalid_participation_rate_rejected(self):
for bad in (0.0, -0.1, 1.5):
with self.assertRaises(MarginDataError):
BrokerMarginCallEngine(max_participation_rate=bad)
def test_buffer_multiplier_below_one_rejected(self):
with self.assertRaises(MarginDataError):
BrokerMarginCallEngine(liquidation_buffer_multiplier=0.9)
class TestTierBoundaries(unittest.TestCase):
"""Thresholds are inclusive lower bounds; verify each exact boundary."""
def setUp(self):
self.engine = BrokerMarginCallEngine()
def test_exact_boundaries(self):
cases = [
(84_999.0, MarginState.NORMAL),
(85_000.0, MarginState.WARNING),
(94_999.0, MarginState.WARNING),
(95_000.0, MarginState.CRITICAL),
(99_999.0, MarginState.CRITICAL),
(100_000.0, MarginState.MARGIN_CALL_BREACH),
]
for maintenance, expected in cases:
with self.subTest(maintenance=maintenance):
# Excess liquidity held positive so the house ratio is the only driver.
res = self.engine.evaluate_margin_health(
_snapshot(100_000.0, maintenance, excess=1.0)
)
self.assertEqual(res.state, expected)
class TestDeleveragingOrderGuard(unittest.TestCase):
def setUp(self):
self.engine = BrokerMarginCallEngine()
def test_deleveraging_flag_cannot_smuggle_a_margin_increasing_order(self):
"""
Regression: is_deleveraging=True returned True unconditionally, so an
order with a +999,999 margin impact bypassed every gate.
"""
snap = _snapshot(100_000.0, 50_000.0)
with self.assertRaises(MarginDataError):
self.engine.guard_new_order(snap, margin_impact=999_999.0, is_deleveraging=True)
def test_genuine_deleveraging_order_is_allowed_even_in_breach(self):
snap = _snapshot(100_000.0, 120_000.0, excess=-20_000.0)
self.assertTrue(
self.engine.guard_new_order(snap, margin_impact=-30_000.0, is_deleveraging=True)
)
class TestInitialMarginGate(unittest.TestCase):
"""
New positions are opened against initial margin (Reg T: 50% on a long margin
equity purchase) not maintenance margin (FINRA 4210: 25% minimum), so a
maintenance-only projection is the weaker constraint.
"""
def setUp(self):
self.engine = BrokerMarginCallEngine()
def test_order_exceeding_available_funds_is_vetoed(self):
# Maintenance projection alone would pass: 50k + 10k = 60k on 100k NLV = 60%.
snap = _snapshot(100_000.0, 50_000.0, available=8_000.0)
with self.assertRaises(MarginCallError) as ctx:
self.engine.guard_new_order(
snap, margin_impact=10_000.0, initial_margin_impact=20_000.0
)
self.assertIn("Initial margin impact", str(ctx.exception))
def test_order_within_available_funds_passes(self):
snap = _snapshot(100_000.0, 50_000.0, available=40_000.0)
self.assertTrue(
self.engine.guard_new_order(
snap, margin_impact=10_000.0, initial_margin_impact=20_000.0
)
)
class TestDeleveragingArithmetic(unittest.TestCase):
def test_required_reduction_includes_the_slippage_buffer(self):
"""Independently computed: buffer multiplier must scale the reduction."""
engine = BrokerMarginCallEngine(liquidation_buffer_multiplier=1.05)
# ratio = 220k/200k = 1.10 -> BREACH. target = 200k * 0.75 = 150k.
# base reduction = 220k - 150k = 70k; with buffer = 70k * 1.05 = 73.5k.
# margin per unit = 220k / 1000 = 220. units = 73500 / 220 = 334.0909...
snap = _snapshot(200_000.0, 220_000.0, excess=-20_000.0)
positions = [
PositionMarginInfo(
symbol="XYZ", asset_class="STK", quantity=1000, current_price=100.0,
maintenance_margin_requirement=220_000.0, average_daily_volume=1_000_000,
)
]
plan = engine.plan_deleveraging(snap, positions)
self.assertAlmostEqual(plan[0][1], 73_500.0 / 220.0, places=6)
def test_no_plan_when_not_in_breach(self):
engine = BrokerMarginCallEngine()
snap = _snapshot(100_000.0, 50_000.0)
self.assertEqual(engine.plan_deleveraging(snap, []), [])
def test_position_with_unusable_values_raises(self):
engine = BrokerMarginCallEngine()
snap = _snapshot(100_000.0, 120_000.0, excess=-20_000.0)
bad = [
PositionMarginInfo(
symbol="BAD", asset_class="STK", quantity=float("nan"), current_price=10.0,
maintenance_margin_requirement=120_000.0, average_daily_volume=1_000.0,
)
]
with self.assertRaises(MarginDataError):
engine.plan_deleveraging(snap, bad)
def test_negative_adv_raises_rather_than_sizing_against_it(self):
engine = BrokerMarginCallEngine()
snap = _snapshot(100_000.0, 120_000.0, excess=-20_000.0)
bad = [
PositionMarginInfo(
symbol="BAD", asset_class="STK", quantity=100, current_price=10.0,
maintenance_margin_requirement=120_000.0, average_daily_volume=-5.0,
)
]
with self.assertRaises(MarginDataError):
engine.plan_deleveraging(snap, bad)
if __name__ == "__main__":
unittest.main()