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:
+4
-4
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user