From 733b9c12681463c53a6b2ab371918fb135fff735 Mon Sep 17 00:00:00 2001 From: seathatflowsinourveins <234074349+seathatflowsinourveins@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:04:57 -0400 Subject: [PATCH] Add the pre-registered leverage schedule v1 (G-e, SYN, opt-in) leverage-schedule-v1-20260922 per the user's decision record: a fixed ceiling keyed by session x regime x drawdown ladder, <= 4x intraday and <= 2x overnight, 0 in risk_off/unavailable and on the kill switch, clamped by the broker account multiplier and config max_leverage <= 4, enforced at the policy entry budget and as a ledger backstop that counts pending buys. Opt-in only: the shipped configs carry no leverage_policy and behave byte-identically (golden fixture unchanged). Rung configs 1x/2x/4x scale per-order sizing and loss caps; each run records the achieved peak gross-to-equity leverage and time above the next-lower rung (a rung establishes the envelope at its cap, not a target exposure). Reviewed by three Claude lenses and one read-only Codex cross-family review (gpt-6-astra, high); all findings verified resolved. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../adaptive-paper/README-safety.md | 102 +++ .../us-equities/adaptive-paper/README.md | 9 +- .../adaptive-paper/config-leverage-1x.json | 132 ++++ .../adaptive-paper/config-leverage-2x.json | 132 ++++ .../adaptive-paper/config-leverage-4x.json | 132 ++++ .../us-equities/adaptive-paper/leverage.py | 301 ++++++++ .../adaptive-paper/native_strategy.py | 58 +- .../us-equities/adaptive-paper/runner.py | 270 ++++++- .../us-equities/adaptive-paper/safety.py | 84 ++- .../adaptive-paper/source-hashes.json | 26 +- .../us-equities/adaptive-paper/strategies.py | 44 +- .../adaptive-paper/strategies_v1.py | 70 +- .../us-equities/adaptive-paper/transport.py | 25 +- tests/test_adaptive_paper_leverage.py | 356 +++++++++ tests/test_adaptive_paper_native.py | 127 ++++ tests/test_adaptive_paper_runner.py | 684 +++++++++++++++++- tests/test_adaptive_paper_safety.py | 185 +++++ tests/test_adaptive_paper_strategies.py | 215 ++++++ tests/test_adaptive_paper_transport.py | 34 + 19 files changed, 2935 insertions(+), 51 deletions(-) create mode 100644 blueprints/us-equities/adaptive-paper/config-leverage-1x.json create mode 100644 blueprints/us-equities/adaptive-paper/config-leverage-2x.json create mode 100644 blueprints/us-equities/adaptive-paper/config-leverage-4x.json create mode 100644 blueprints/us-equities/adaptive-paper/leverage.py create mode 100644 tests/test_adaptive_paper_leverage.py diff --git a/blueprints/us-equities/adaptive-paper/README-safety.md b/blueprints/us-equities/adaptive-paper/README-safety.md index 7eab4b64a..02c6a7d1f 100644 --- a/blueprints/us-equities/adaptive-paper/README-safety.md +++ b/blueprints/us-equities/adaptive-paper/README-safety.md @@ -134,6 +134,108 @@ a fresh trial or reset request history to hide unresolved state. Identify the actual recovery adapter: a direct SDK fractional exit does not establish that a whole-share native engine adapter supports fractional execution. +## Leverage schedule (opt-in, `leverage-schedule-v1-20260922`) + +The default lane stays 1x: `runner.load_config` refuses `max_leverage > 1` +unless a config's top-level `leverage_policy` block validates against +`leverage.py`'s `validate_leverage_policy`. With no such block (both shipped +configs, `config.json`/`config-sip.json`), every path in this document is +unchanged -- `RiskLimits.leverage` is `None`, the persisted `meta.limits` +bytes are byte-identical to before this policy existed, and the buy-side +gross-exposure check is the original `min(effective_gross_cap, equity)`. + +Under a validated policy (`config-leverage-1x/2x/4x.json`), the effective +ceiling for new entries is `min(schedule[session][regime], +drawdown_ladder(drawdown_fraction), overnight_max_leverage if the session +policy allows overnight holds, the broker-proven account multiplier, +config `max_leverage` <= 4)`. Every existing hard ceiling in this document +(`max_order_notional_usd`, `max_gross_exposure_usd`, `max_gross_loss_usd`, +`max_drawdown_usd`, held-symbol and outstanding-order caps, STOP and +`halted_reason`) is unchanged and independent of it. The leverage ceiling +gates entries only: sells and exits are never refused by it, and it never +raises a risk halt or forces liquidation on its own -- a ladder step to 0 +(from accumulated drawdown) blocks new buys with `leverage_ceiling_zero` +while leaving existing holdings to the unchanged stop/take-profit/trailing/ +time-decay/portfolio-rotation exit chain. `Ledger._leverage_envelope` is the +independent, regime-unaware ledger-layer ceiling `reserve_intent`/ +`validate_pending` enforce even if the policy layer (`strategies.py`) were +somehow bypassed; it fails closed (0) for a timestamp outside the frozen +session calendar. See `leverage.py`'s module docstring for the full +schedule/ladder contract and +`agent-lab/docs/decisions/2026-09-22-leverage-schedule-and-entitlement.md` +for the design record. Leverage above 1x is paper-only, requires a +preflight-proven account multiplier at least equal to the requested +leverage (`runner._check_margin_entitlement`), and remains unqualified +until each rung's `leverage-ladder-1x/2x/4x` gate row shows +`needs_attention == 0`. + +F2 (2026-09-22 residual review, reachability): a rung's `max_leverage`, +schedule cells and drawdown ladder establish the safety **envelope** in +force at that cap -- the most a run is permitted to reach -- not a target a +run is guaranteed to hit. `strategies_v1._decide_core`'s entry budget +(`min(gross_cap, capital*leverage) - max_order_notional`) and its inverse- +volatility allocation do not by construction force achieved exposure up to +the configured ceiling, so a "4x" run can complete, and its gate row can +show `needs_attention == 0`, without its gross-to-equity exposure ever +having exceeded the 2x rung's own ceiling; the `README.md`/config-note +claim that every rung "gets the same proportional room to run" described +only the drawdown/loss caps' dollar-fraction scaling, not achieved +exposure, and has been corrected in the rung configs' `notes` accordingly. +Every leveraged run's `outcome`/receipt (`leverage_policy is not None`) +now separately records what was actually achieved: `outcome["leverage"]` +gains `peak_achieved_leverage` (peak gross exposure / mark-to-market +equity, distinct from the existing fixed-capital-denominated +`peak_effective_leverage`), `ceiling_at_peak_achieved_leverage` (the +policy-layer ceiling in force at that peak), `next_lower_rung_ceiling` +(`leverage.next_lower_rung_ceiling(config max_leverage)`; `None` for the +1x rung) and `seconds_above_next_lower_rung_ceiling` (cumulative wall-clock +time the run's achieved leverage spent above the next-lower rung's own +ceiling, computed per-tick by `runner._leverage_achievement_step`). These +fields are absent from every default-path (no `leverage_policy` block, or +`leverage_policy is None`) outcome, exactly like the rest of +`outcome["leverage"]`; they do not change `leverage.py`'s +`CANONICAL_V1_BLOCK` schedule/ladder or `strategies_v1._decide_core`'s +sizing math, which stay a ceiling, not a target, per +`agent-lab/docs/decisions/2026-09-22-leverage-schedule-and-entitlement.md`. +A rung's gate row is not, by itself, evidence that the rung's exposure was +ever achieved -- that evidence is this recorded achieved-leverage receipt. +No gate row in `catalogs/us-equities/gates-20260922.json` currently reads +`peak_achieved_leverage`/`seconds_above_next_lower_rung_ceiling`, so a gate +row can still flip to established on a receipt whose achieved exposure never +exceeded a lower rung's own ceiling; the rung configs' `notes` have been +corrected to say so instead of claiming the opposite. + +2026-09-22 leverage fix round 1 (LEV-RI-A/CX-P1/EH-1/CX-P2, all `major`/ +`blocker` findings against G-e/F2): `strategies_v1._decide_core`'s +leveraged-rung entry budget (`leverage_ceiling is not None`) now counts +every current holding's notional regardless of this tick's own exit +decision (a same-tick take-profit/stop/trailing/portfolio-rotation exit +removes a symbol from `targets` immediately, even though the position is +still physically held until its sell order actually fills -- CX-P1), plus +the caller-supplied notional of any still-resting, unfilled buy order +(`LeverageInputs.pending_buy_notional_usd`, sourced from the ledger's own +`AccountState.pending_buy_notional_usd` and threaded through +`native_strategy._leverage_inputs` -> `strategies.AdaptivePolicy.decide` -- +LEV-RI-A); previously `used` only summed this tick's own `targets`, so a +symbol still awaiting a fill or a sale from an earlier or the same tick was +invisible to the leveraged budget, letting a rung's aggregate exposure climb +past its regime ceiling (`leverage.LeveragePolicy.envelope` is deliberately +regime-independent -- see its docstring -- so this was the only enforcement +of the regime-specific part). The non-leveraged path (`leverage_ceiling is +None`, e.g. both default configs) is unchanged (`used` stays `targets`-only) +to preserve the golden-equivalence contract. `outcome["leverage"]`'s +`peak_gross_exposure_usd`/`peak_achieved_leverage`/`broker_margin_used` now +measure filled-position exposure only (`AccountState.gross_exposure_usd` +minus its own `pending_buy_notional_usd`, which used to be folded in +uncredited -- EH-1); the peak pending-buy notional observed is recorded +separately as `peak_pending_buy_notional_usd`. `runner.load_config`'s +`PolicyConfig` construction now also passes `max_order_notional` from the +config's own `max_order_notional_usd` instead of silently keeping +`PolicyConfig`'s 1000 default regardless of a rung's configured 2000/4000 +(CX-P2), so entries can actually size up to what each rung's own +`max_order_notional_usd`/ledger-side cap allows. + Run `python3 -m unittest discover -s tests -p test_adaptive_paper_safety.py -v` for the synthetic local failure cases. These checks establish local state invariants, not native broker throughput, fault behavior, order fills, or strategy quality. +`tests/test_adaptive_paper_leverage.py` covers `leverage.py` itself. diff --git a/blueprints/us-equities/adaptive-paper/README.md b/blueprints/us-equities/adaptive-paper/README.md index 13df575f5..493bb83dd 100644 --- a/blueprints/us-equities/adaptive-paper/README.md +++ b/blueprints/us-equities/adaptive-paper/README.md @@ -25,8 +25,13 @@ actual order result until a subsequent open-session receipt says otherwise. SPY,QQQ,IWM,DIA represent tradable US index proxies. The explicit24-name universe includes liquid stocks from the requested watchlist, subject to fresh native asset/quote qualification. The user-provided market table is not an executable -price feed. Foreign indices, penny-stock extremes, shorting, predictive catalyst -pre-positioning and leverage above1x remain unqualified. +price feed. Foreign indices, penny-stock extremes, shorting and predictive catalyst +pre-positioning remain unqualified. Leverage above 1x is unqualified until a +validated `leverage-schedule-v1-20260922` policy (see README-safety.md) is +present in config, a preflight-proven account multiplier at least equal to the +requested leverage has been confirmed, and this ladder's gate rows +(`leverage-ladder-1x/2x/4x` in `catalogs/us-equities/gates-20260922.json`) each +show `needs_attention == 0`. The allocator uses one portfolio owner across all families, explicit cost hurdles, an exposure reserve below the ledger cap, a cooldown and minimum hold. diff --git a/blueprints/us-equities/adaptive-paper/config-leverage-1x.json b/blueprints/us-equities/adaptive-paper/config-leverage-1x.json new file mode 100644 index 000000000..a65080d44 --- /dev/null +++ b/blueprints/us-equities/adaptive-paper/config-leverage-1x.json @@ -0,0 +1,132 @@ +{ + "schema_version": 1, + "mode": "adaptive_paper", + "endpoint": "https://paper-api.alpaca.markets", + "feed": "sip", + "regular_session_only": true, + "duration_seconds": 300, + "cleanup_seconds": 120, + "max_api_requests_per_minute": 200, + "max_submit_requests_per_minute": 180, + "strategy_scope": [ + "trend_momentum", + "range_breakout", + "mean_reversion", + "relative_strength", + "defensive_cash" + ], + "symbols": [ + "SPY", + "QQQ", + "IWM", + "DIA", + "NVDA", + "AAPL", + "AMZN", + "GOOGL", + "TSLA", + "MSFT", + "JNJ", + "JPM", + "PG", + "AMD", + "ARM", + "ASTS", + "AVGO", + "COIN", + "CRWD", + "CRWV", + "META", + "MU", + "INTC", + "SNDK" + ], + "benchmarks": [ + "SPY", + "QQQ", + "IWM", + "DIA" + ], + "capital_usd": "10000", + "max_gross_exposure_usd": "10000", + "max_order_notional_usd": "1000", + "max_order_quantity": 100, + "max_held_symbols": 10, + "max_outstanding_orders": 20, + "max_gross_loss_usd": "25", + "max_drawdown_usd": "25", + "max_leverage": "1", + "quote_max_age_seconds": 3, + "max_spread_bps": "15", + "warmup_samples": 32, + "warmup_seconds": 30, + "minimum_edge_bps": 4, + "min_entry_close_seconds": 300, + "order_timeout_seconds": 10, + "min_hold_seconds": 5, + "max_hold_seconds": 90, + "stop_bps": 25, + "take_profit_bps": 40, + "trailing_bps": 15, + "catalyst_orders_enabled": false, + "extended_hours_enabled": false, + "sessions": { + "extended_hours": false, + "overnight_holds": false, + "overnight_gross_multiple": "1.0" + }, + "rotation": { + "enabled": false + }, + "notes": "G-e leverage-schedule-v1-20260922, rung 1x. Gate row 'leverage-ladder-1x' in catalogs/us-equities/gates-20260922.json flips on needs_attention == 0 at blueprints/us-equities/adaptive-paper/ladder/1x/receipt.json; see agent-lab/docs/decisions/2026-09-22-leverage-schedule-and-entitlement.md for the design record. leverage_policy is the canonical leverage-schedule-v1-20260922 block (identical across every rung; see leverage.CANONICAL_V1_BLOCK), never widened per rung -- only the top-level max_leverage dial and the per-order sizing below differ from config-sip.json. max_order_quantity and max_order_qty_mode='notional' are widened together with max_gross_exposure_usd/max_order_notional_usd (=capital_usd*1 / gross/max_held_symbols) so the schedule's ceiling is more reachable than with the config-sip.json shipped per-order sizing (gross there is bounded at about 10 x min(price,$1000), well under even the 1x rung). F2 (2026-09-22 leverage fix round 1): max_order_quantity is 100, not 10 -- PolicyConfig.max_shares (strategies_v1.py) and RiskLimits.max_order_qty (safety.py) share the same 100-share hard ceiling, and a max_order_quantity of 10 silently undercut max_order_qty_mode='notional' for most of the symbol universe (floor(max_order_notional_usd/price) exceeds 10 shares for anything cheaper than about $100-400/share at this rung's notional), so entries never reached the sizing this config's max_order_notional_usd/max_gross_exposure_usd imply. F2 (2026-09-22 leverage fix round 2): max_gross_loss_usd/max_drawdown_usd now scale with this rung's leverage multiple, config-sip.json's base $25 x 1 = $25 at 1x (unchanged; see config-leverage-2x.json/config-leverage-4x.json's notes for the $50/$100 scaled values at those rungs) so that the fraction of the cap a given entry-spread mark-to-market loss consumes is identical across every rung -- ordinary entry spread cost alone (max_spread_bps=15 marking to the bid) no longer trips the hard halt at a smaller fraction of the ramp for a higher-leverage rung than it does here, and the drawdown ladder's own step-down (4x->2x at 25% drawdown, ->1x at 50%, ->0 at 75%, all fractions of max_drawdown_usd) gets an equal dollar-fraction of headroom before drawdown_cap_reached/gross_loss trips at every rung. F2 (2026-09-22 leverage fix round 3, residual review finding F2 -- reachability): this scales the drawdown/loss caps to the rung's configured ceiling, not to leverage actually achieved -- a rung establishes the safety envelope at that cap (the max_leverage ceiling and the drawdown ladder's own step-downs bound what a run is PERMITTED to reach); it does not by itself guarantee a run's actual gross-to-equity exposure reaches that cap, and achieved exposure may stay below it. Every leveraged run's outcome/receipt now records the achieved peak gross-to-equity leverage, the ceiling in force at that moment, and (at rungs above 1x) the time spent above the next-lower rung's own ceiling (outcome['leverage'].peak_achieved_leverage/ceiling_at_peak_achieved_leverage/seconds_above_next_lower_rung_ceiling; see leverage.next_lower_rung_ceiling and runner._leverage_achievement_step), so this rung's gate row ('leverage-ladder-1x' in catalogs/us-equities/gates-20260922.json) is not, by itself, evidence the rung's exposure was ever achieved -- that evidence is this recorded receipt (README-safety.md's Leverage schedule section); the 1x rung has no lower rung to compare against (leverage.next_lower_rung_ceiling(D(\"1\")) is None, so seconds_above_next_lower_rung_ceiling never accumulates here). tests/test_adaptive_paper_runner.py's test_rung_numeric_relationships now asserts max_gross_loss_usd == max_drawdown_usd == config-sip.json's $25 x this rung's multiple instead of locking every rung to the unscaled $25 flat value. Leverage above 1x is unqualified until this gate row's needs_attention count is 0 and a preflight-proven account multiplier >= 1 is confirmed (runner.py's validate_preflight, under the policy). Request ceilings are not fill targets. Predictive catalysts, Smart Router algorithms and live trading are unqualified. rotation.enabled stays false: registry.json currently ships only evidence_class SYN (synthetic, not accepted; see blueprints/us-equities/acceptance-wave/research-protocol.json's regime_selector field), so flipping this on today would run the selector framework against the same single inline policy with no real alternative to rotate to.", + "max_order_qty_mode": "notional", + "leverage_policy": { + "version": "leverage-schedule-v1-20260922", + "decision_record": "agent-lab/docs/decisions/2026-09-22-leverage-schedule-and-entitlement.md", + "schedule": { + "RTH": { + "trend": "4", + "range": "1", + "risk_off": "0", + "unavailable": "0" + }, + "PRE": { + "trend": "1", + "range": "1", + "risk_off": "0", + "unavailable": "0" + }, + "POST": { + "trend": "1", + "range": "1", + "risk_off": "0", + "unavailable": "0" + }, + "CLOSED": { + "trend": "0", + "range": "0", + "risk_off": "0", + "unavailable": "0" + } + }, + "overnight_max_leverage": "2", + "drawdown_ladder": [ + { + "from_drawdown_fraction": "0", + "max_leverage": "4" + }, + { + "from_drawdown_fraction": "0.25", + "max_leverage": "2" + }, + { + "from_drawdown_fraction": "0.5", + "max_leverage": "1" + }, + { + "from_drawdown_fraction": "0.75", + "max_leverage": "0" + } + ] + } +} diff --git a/blueprints/us-equities/adaptive-paper/config-leverage-2x.json b/blueprints/us-equities/adaptive-paper/config-leverage-2x.json new file mode 100644 index 000000000..69f08ae10 --- /dev/null +++ b/blueprints/us-equities/adaptive-paper/config-leverage-2x.json @@ -0,0 +1,132 @@ +{ + "schema_version": 1, + "mode": "adaptive_paper", + "endpoint": "https://paper-api.alpaca.markets", + "feed": "sip", + "regular_session_only": true, + "duration_seconds": 300, + "cleanup_seconds": 120, + "max_api_requests_per_minute": 200, + "max_submit_requests_per_minute": 180, + "strategy_scope": [ + "trend_momentum", + "range_breakout", + "mean_reversion", + "relative_strength", + "defensive_cash" + ], + "symbols": [ + "SPY", + "QQQ", + "IWM", + "DIA", + "NVDA", + "AAPL", + "AMZN", + "GOOGL", + "TSLA", + "MSFT", + "JNJ", + "JPM", + "PG", + "AMD", + "ARM", + "ASTS", + "AVGO", + "COIN", + "CRWD", + "CRWV", + "META", + "MU", + "INTC", + "SNDK" + ], + "benchmarks": [ + "SPY", + "QQQ", + "IWM", + "DIA" + ], + "capital_usd": "10000", + "max_gross_exposure_usd": "20000", + "max_order_notional_usd": "2000", + "max_order_quantity": 100, + "max_held_symbols": 10, + "max_outstanding_orders": 20, + "max_gross_loss_usd": "50", + "max_drawdown_usd": "50", + "max_leverage": "2", + "quote_max_age_seconds": 3, + "max_spread_bps": "15", + "warmup_samples": 32, + "warmup_seconds": 30, + "minimum_edge_bps": 4, + "min_entry_close_seconds": 300, + "order_timeout_seconds": 10, + "min_hold_seconds": 5, + "max_hold_seconds": 90, + "stop_bps": 25, + "take_profit_bps": 40, + "trailing_bps": 15, + "catalyst_orders_enabled": false, + "extended_hours_enabled": false, + "sessions": { + "extended_hours": false, + "overnight_holds": false, + "overnight_gross_multiple": "1.0" + }, + "rotation": { + "enabled": false + }, + "notes": "G-e leverage-schedule-v1-20260922, rung 2x. Gate row 'leverage-ladder-2x' in catalogs/us-equities/gates-20260922.json flips on needs_attention == 0 at blueprints/us-equities/adaptive-paper/ladder/2x/receipt.json; see agent-lab/docs/decisions/2026-09-22-leverage-schedule-and-entitlement.md for the design record. leverage_policy is the canonical leverage-schedule-v1-20260922 block (identical across every rung; see leverage.CANONICAL_V1_BLOCK), never widened per rung -- only the top-level max_leverage dial and the per-order sizing below differ from config-sip.json. max_order_quantity and max_order_qty_mode='notional' are widened together with max_gross_exposure_usd/max_order_notional_usd (=capital_usd*2 / gross/max_held_symbols) so the schedule's ceiling is more reachable than with the config-sip.json shipped per-order sizing (gross there is bounded at about 10 x min(price,$1000), well under even the 1x rung). F2 (2026-09-22 leverage fix round 1): max_order_quantity is 100, not 10 -- PolicyConfig.max_shares (strategies_v1.py) and RiskLimits.max_order_qty (safety.py) share the same 100-share hard ceiling, and a max_order_quantity of 10 silently undercut max_order_qty_mode='notional' for most of the symbol universe (floor(max_order_notional_usd/price) exceeds 10 shares for anything cheaper than about $100-400/share at this rung's notional), so entries never reached the sizing this config's max_order_notional_usd/max_gross_exposure_usd imply. F2 (2026-09-22 leverage fix round 2): max_gross_loss_usd/max_drawdown_usd are now $50/$50, config-sip.json's base $25 x this rung's 2x multiple (see config-leverage-1x.json's notes for the scaling rationale and config-leverage-4x.json's for the $100 4x value). At this rung's $20k gross ceiling, 15bps entry spread cost alone is up to $30 -- 60% of the scaled $50 cap, the same fraction the unscaled $25 cap absorbed at config-leverage-1x.json's $10k gross ceiling -- so the drawdown ladder's step-down (2x->1x at 25% drawdown, ->0 at 75%, fractions of max_drawdown_usd) gets an equal dollar-fraction of headroom to run here as at every other rung before drawdown_cap_reached/gross_loss trips, instead of the pre-round-2 flat $25 cap giving a higher-leverage rung less relative runway than a lower one. F2 (2026-09-22 leverage fix round 3, residual review finding F2 -- reachability): this scales the drawdown/loss caps to the rung's configured ceiling, not to leverage actually achieved -- this rung establishes the 2x safety envelope (the max_leverage ceiling and the drawdown ladder's own step-downs bound what a run is PERMITTED to reach); it does not by itself guarantee a run's actual gross-to-equity exposure reaches 2x, and achieved exposure may stay below it. Every leveraged run's outcome/receipt now records the achieved peak gross-to-equity leverage, the ceiling in force at that moment, and the time spent above the next-lower (1x) rung's own ceiling (outcome['leverage'].peak_achieved_leverage/ceiling_at_peak_achieved_leverage/seconds_above_next_lower_rung_ceiling; see leverage.next_lower_rung_ceiling and runner._leverage_achievement_step), so this gate row ('leverage-ladder-2x') is not, by itself, evidence the rung's exposure was ever achieved -- that evidence is this recorded receipt (README-safety.md's Leverage schedule section); no gate row in catalogs/us-equities/gates-20260922.json currently reads peak_achieved_leverage/seconds_above_next_lower_rung_ceiling, so a gate row can still flip to established on a receipt whose achieved exposure never exceeded 1x. tests/test_adaptive_paper_runner.py's test_rung_numeric_relationships now asserts max_gross_loss_usd == max_drawdown_usd == config-sip.json's $25 x this rung's multiple. Leverage above 1x is unqualified until this gate row's needs_attention count is 0 and a preflight-proven account multiplier >= 2 is confirmed (runner.py's validate_preflight, under the policy). Request ceilings are not fill targets. Predictive catalysts, Smart Router algorithms and live trading are unqualified. rotation.enabled stays false: registry.json currently ships only evidence_class SYN (synthetic, not accepted; see blueprints/us-equities/acceptance-wave/research-protocol.json's regime_selector field), so flipping this on today would run the selector framework against the same single inline policy with no real alternative to rotate to.", + "max_order_qty_mode": "notional", + "leverage_policy": { + "version": "leverage-schedule-v1-20260922", + "decision_record": "agent-lab/docs/decisions/2026-09-22-leverage-schedule-and-entitlement.md", + "schedule": { + "RTH": { + "trend": "4", + "range": "1", + "risk_off": "0", + "unavailable": "0" + }, + "PRE": { + "trend": "1", + "range": "1", + "risk_off": "0", + "unavailable": "0" + }, + "POST": { + "trend": "1", + "range": "1", + "risk_off": "0", + "unavailable": "0" + }, + "CLOSED": { + "trend": "0", + "range": "0", + "risk_off": "0", + "unavailable": "0" + } + }, + "overnight_max_leverage": "2", + "drawdown_ladder": [ + { + "from_drawdown_fraction": "0", + "max_leverage": "4" + }, + { + "from_drawdown_fraction": "0.25", + "max_leverage": "2" + }, + { + "from_drawdown_fraction": "0.5", + "max_leverage": "1" + }, + { + "from_drawdown_fraction": "0.75", + "max_leverage": "0" + } + ] + } +} diff --git a/blueprints/us-equities/adaptive-paper/config-leverage-4x.json b/blueprints/us-equities/adaptive-paper/config-leverage-4x.json new file mode 100644 index 000000000..2d8503862 --- /dev/null +++ b/blueprints/us-equities/adaptive-paper/config-leverage-4x.json @@ -0,0 +1,132 @@ +{ + "schema_version": 1, + "mode": "adaptive_paper", + "endpoint": "https://paper-api.alpaca.markets", + "feed": "sip", + "regular_session_only": true, + "duration_seconds": 300, + "cleanup_seconds": 120, + "max_api_requests_per_minute": 200, + "max_submit_requests_per_minute": 180, + "strategy_scope": [ + "trend_momentum", + "range_breakout", + "mean_reversion", + "relative_strength", + "defensive_cash" + ], + "symbols": [ + "SPY", + "QQQ", + "IWM", + "DIA", + "NVDA", + "AAPL", + "AMZN", + "GOOGL", + "TSLA", + "MSFT", + "JNJ", + "JPM", + "PG", + "AMD", + "ARM", + "ASTS", + "AVGO", + "COIN", + "CRWD", + "CRWV", + "META", + "MU", + "INTC", + "SNDK" + ], + "benchmarks": [ + "SPY", + "QQQ", + "IWM", + "DIA" + ], + "capital_usd": "10000", + "max_gross_exposure_usd": "40000", + "max_order_notional_usd": "4000", + "max_order_quantity": 100, + "max_held_symbols": 10, + "max_outstanding_orders": 20, + "max_gross_loss_usd": "100", + "max_drawdown_usd": "100", + "max_leverage": "4", + "quote_max_age_seconds": 3, + "max_spread_bps": "15", + "warmup_samples": 32, + "warmup_seconds": 30, + "minimum_edge_bps": 4, + "min_entry_close_seconds": 300, + "order_timeout_seconds": 10, + "min_hold_seconds": 5, + "max_hold_seconds": 90, + "stop_bps": 25, + "take_profit_bps": 40, + "trailing_bps": 15, + "catalyst_orders_enabled": false, + "extended_hours_enabled": false, + "sessions": { + "extended_hours": false, + "overnight_holds": false, + "overnight_gross_multiple": "1.0" + }, + "rotation": { + "enabled": false + }, + "notes": "G-e leverage-schedule-v1-20260922, rung 4x. Gate row 'leverage-ladder-4x' in catalogs/us-equities/gates-20260922.json flips on needs_attention == 0 at blueprints/us-equities/adaptive-paper/ladder/4x/receipt.json; see agent-lab/docs/decisions/2026-09-22-leverage-schedule-and-entitlement.md for the design record. leverage_policy is the canonical leverage-schedule-v1-20260922 block (identical across every rung; see leverage.CANONICAL_V1_BLOCK), never widened per rung -- only the top-level max_leverage dial and the per-order sizing below differ from config-sip.json. max_order_quantity and max_order_qty_mode='notional' are widened together with max_gross_exposure_usd/max_order_notional_usd (=capital_usd*4 / gross/max_held_symbols) so the schedule's ceiling is more reachable than with the config-sip.json shipped per-order sizing (gross there is bounded at about 10 x min(price,$1000), well under even the 1x rung). F2 (2026-09-22 leverage fix round 1): max_order_quantity is 100, not 10 -- PolicyConfig.max_shares (strategies_v1.py) and RiskLimits.max_order_qty (safety.py) share the same 100-share hard ceiling, and a max_order_quantity of 10 silently undercut max_order_qty_mode='notional' for most of the symbol universe (floor(max_order_notional_usd/price) exceeds 10 shares for anything cheaper than about $100-400/share at this rung's notional), so entries never reached the sizing this config's max_order_notional_usd/max_gross_exposure_usd imply. F2 (2026-09-22 leverage fix round 2): max_gross_loss_usd/max_drawdown_usd are now $100/$100, config-sip.json's base $25 x this rung's 4x multiple (see config-leverage-1x.json's notes for the scaling rationale and config-leverage-2x.json's for the $50 2x value). At this rung's $40k gross ceiling, 15bps entry spread cost alone is up to $60 -- 60% of the scaled $100 cap, the same fraction the unscaled $25 cap absorbed at config-leverage-1x.json's $10k gross ceiling, not the >100%-of-cap breach the flat $25 value produced here before round 2 -- so the drawdown ladder's step-down (4x->2x at 25% drawdown, ->1x at 50%, ->0 at 75%, fractions of max_drawdown_usd) gets an equal dollar-fraction of headroom to run here as at every other rung before drawdown_cap_reached/gross_loss trips, instead of tripping the hard halt before 4x exposure is ever shown. F2 (2026-09-22 leverage fix round 3, residual review finding F2 -- reachability): this scales the drawdown/loss caps to the rung's configured ceiling, not to leverage actually achieved -- this rung establishes the 4x safety envelope (the max_leverage ceiling and the drawdown ladder's own step-downs bound what a run is PERMITTED to reach, and strategies_v1._decide_core's budget/inverse-volatility allocation math does not by construction force a run to reach it); it does not by itself guarantee a run's actual gross-to-equity exposure reaches 4x, and achieved exposure may stay below it. Every leveraged run's outcome/receipt now records the achieved peak gross-to-equity leverage, the ceiling in force at that moment, and the time spent above the next-lower (2x) rung's own ceiling (outcome['leverage'].peak_achieved_leverage/ceiling_at_peak_achieved_leverage/seconds_above_next_lower_rung_ceiling; see leverage.next_lower_rung_ceiling and runner._leverage_achievement_step), so this gate row ('leverage-ladder-4x') is not, by itself, evidence the rung's exposure was ever achieved -- that evidence is this recorded receipt (README-safety.md's Leverage schedule section); no gate row in catalogs/us-equities/gates-20260922.json currently reads peak_achieved_leverage/seconds_above_next_lower_rung_ceiling, so a gate row can still flip to established on a receipt whose achieved exposure never exceeded 2x. tests/test_adaptive_paper_runner.py's test_rung_numeric_relationships now asserts max_gross_loss_usd == max_drawdown_usd == config-sip.json's $25 x this rung's multiple. Leverage above 1x is unqualified until this gate row's needs_attention count is 0 and a preflight-proven account multiplier >= 4 is confirmed (runner.py's validate_preflight, under the policy). Request ceilings are not fill targets. Predictive catalysts, Smart Router algorithms and live trading are unqualified. rotation.enabled stays false: registry.json currently ships only evidence_class SYN (synthetic, not accepted; see blueprints/us-equities/acceptance-wave/research-protocol.json's regime_selector field), so flipping this on today would run the selector framework against the same single inline policy with no real alternative to rotate to.", + "max_order_qty_mode": "notional", + "leverage_policy": { + "version": "leverage-schedule-v1-20260922", + "decision_record": "agent-lab/docs/decisions/2026-09-22-leverage-schedule-and-entitlement.md", + "schedule": { + "RTH": { + "trend": "4", + "range": "1", + "risk_off": "0", + "unavailable": "0" + }, + "PRE": { + "trend": "1", + "range": "1", + "risk_off": "0", + "unavailable": "0" + }, + "POST": { + "trend": "1", + "range": "1", + "risk_off": "0", + "unavailable": "0" + }, + "CLOSED": { + "trend": "0", + "range": "0", + "risk_off": "0", + "unavailable": "0" + } + }, + "overnight_max_leverage": "2", + "drawdown_ladder": [ + { + "from_drawdown_fraction": "0", + "max_leverage": "4" + }, + { + "from_drawdown_fraction": "0.25", + "max_leverage": "2" + }, + { + "from_drawdown_fraction": "0.5", + "max_leverage": "1" + }, + { + "from_drawdown_fraction": "0.75", + "max_leverage": "0" + } + ] + } +} diff --git a/blueprints/us-equities/adaptive-paper/leverage.py b/blueprints/us-equities/adaptive-paper/leverage.py new file mode 100644 index 000000000..9f45d061d --- /dev/null +++ b/blueprints/us-equities/adaptive-paper/leverage.py @@ -0,0 +1,301 @@ +"""G-e: the opt-in leverage-schedule policy layer (`leverage-schedule-v1-20260922`). + +Pure and side-effect free: no I/O, no broker/transport calls, no mutation of +caller state. Every function here is a deterministic mapping from validated +config/inputs to a bounded decision. This module is only ever consulted when +a config's optional top-level `leverage_policy` block is present and has +already passed `validate_leverage_policy` below; with that block absent +(every shipped config as of `bdd04ca`), nothing in this module is imported +into the decision path at all -- see runner.py's `load_config` and +strategies.py's `AdaptivePolicy.decide` for the opt-in gate. + +No optimiser, learned leverage or per-trade discretion lives here: the +session/regime schedule and the drawdown ladder are constants frozen in +config (see `CANONICAL_V1_BLOCK`), identical across every leverage rung. +This module only evaluates the frozen policy against the caller's already- +observed session/regime/drawdown/kill-switch/account-multiplier inputs. + +See agent-lab's `docs/decisions/2026-09-22-leverage-schedule-and-entitlement.md` +(referenced by `decision_record` below; that record lives in the agent-lab +repository, not this one) for the design rationale, and +`blueprints/us-equities/adaptive-paper/README-safety.md` for the enforcement +points this policy layer sits in front of (safety.py's independent ledger +ceiling is authoritative regardless of what this module returns). +""" +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from itertools import pairwise + +D = Decimal +ZERO = D(0) + +LEVERAGE_POLICY_VERSION = "leverage-schedule-v1-20260922" +INTRADAY_MAX = D("4") +OVERNIGHT_MAX = D("2") # Reg T +SESSIONS = ("PRE", "RTH", "POST", "CLOSED") +REGIMES = ("trend", "range", "risk_off", "unavailable") +ZERO_REGIMES = ("risk_off", "unavailable") +# strategies_v1._decide_core caps a "range" regime's leverage at 1 +# regardless of the configured max_leverage; a schedule cell above that for +# "range" would authorize an entry budget the engine can never actually use, +# so it is refused at validation time rather than silently ignored. +ENGINE_RANGE_CAP = D("1") + +# The single schedule/ladder every rung config must use verbatim (see +# blueprints/us-equities/adaptive-paper/config-leverage-{1,2,4}x.json). +# DP-2: enforced by `validate_leverage_policy` itself -- a shape-valid but +# non-canonical block (e.g. a hand-edited schedule/ladder that still passes +# every generic bound) must not reach the pre-registered >1x lane; the +# equality check below is the actual gate, not merely a config-authoring +# invariant. tests/test_adaptive_paper_runner.py additionally asserts every +# shipped rung config's block equals this constant byte-for-byte. +CANONICAL_V1_BLOCK = { + "version": LEVERAGE_POLICY_VERSION, + "decision_record": "agent-lab/docs/decisions/2026-09-22-leverage-schedule-and-entitlement.md", + "schedule": { + "RTH": {"trend": "4", "range": "1", "risk_off": "0", "unavailable": "0"}, + "PRE": {"trend": "1", "range": "1", "risk_off": "0", "unavailable": "0"}, + "POST": {"trend": "1", "range": "1", "risk_off": "0", "unavailable": "0"}, + "CLOSED": {"trend": "0", "range": "0", "risk_off": "0", "unavailable": "0"}, + }, + "overnight_max_leverage": "2", + "drawdown_ladder": [ + {"from_drawdown_fraction": "0", "max_leverage": "4"}, + {"from_drawdown_fraction": "0.25", "max_leverage": "2"}, + {"from_drawdown_fraction": "0.5", "max_leverage": "1"}, + {"from_drawdown_fraction": "0.75", "max_leverage": "0"}, + ], +} + + +# The discrete leverage rungs this schedule ships pre-registered configs for +# (config-leverage-1x/2x/4x.json). Pure/static; used only by the runner's +# achieved-leverage receipt (F2, 2026-09-22 residual review finding: +# reachability) -- this receipt exists so a rung's gate row's flip to +# established can eventually be checked against evidence that achieved +# exposure actually exceeded the next-lower rung's own ceiling, since +# strategies_v1._decide_core's budget/allocation math means a higher +# max_leverage does not by itself force higher achieved exposure. As of +# 2026-09-22 leverage fix round 2 (N1-1) no gate row in +# catalogs/us-equities/gates-20260922.json reads this receipt yet, so a +# rung's gate row can still flip on a receipt that never showed it -- see +# README-safety.md's Leverage schedule section. Not consulted by +# ceiling()/envelope() and does not change the schedule/ladder +# CANONICAL_V1_BLOCK enforces. +RUNGS = (D("1"), D("2"), D("4")) + + +def next_lower_rung_ceiling(max_leverage: Decimal) -> Decimal | None: + """The largest pre-registered rung strictly below `max_leverage`, or + None when there is none (the 1x rung itself, or any value <= the lowest + rung). Used to compute how long a run spent with achieved (gross + exposure / equity) leverage above the ceiling the next rung down would + have imposed -- evidence the run actually needed this rung's higher + ceiling rather than merely staying inside a lower one's proportional + room the whole time.""" + lower = [r for r in RUNGS if r < max_leverage] + return max(lower) if lower else None + + +class LeveragePolicyError(ValueError): + """Bounded reason code; safe to retain, mirrors safety.SafetyError.""" + + +def _require(condition, reason): + if not condition: + raise LeveragePolicyError(reason) + + +def _dec_str(value): + """A finite, non-negative Decimal parsed strictly from `str`, at most 2 + decimal places -- the contract every leverage-policy number (config + max_leverage, schedule cells, ladder fractions/steps, overnight cap) + shares. Rejects float/int/bool (a bool is not `str`), NaN/Infinity, + negative values and anything with more than 2 fractional digits.""" + if type(value) is not str: + raise LeveragePolicyError("leverage_value_invalid") + try: + result = D(value) + except InvalidOperation: + raise LeveragePolicyError("leverage_value_invalid") from None + if not result.is_finite() or result < 0: + raise LeveragePolicyError("leverage_value_invalid") + exponent = result.as_tuple().exponent + if isinstance(exponent, str) or exponent < -2: + raise LeveragePolicyError("leverage_value_invalid") + return result + + +def _dec_config(value): + """A finite Decimal parsed from an existing config number (already + validated elsewhere as a `str` by safety.RiskLimits/sessions.py); no + 2dp restriction, matching that wider existing contract.""" + try: + result = D(str(value)) + except InvalidOperation: + raise LeveragePolicyError("leverage_value_invalid") from None + if not result.is_finite(): + raise LeveragePolicyError("leverage_value_invalid") + return result + + +@dataclass(frozen=True) +class LeverageInputs: + """Per-tick, caller-observed inputs the policy layer's `ceiling()` + consults. `session` is a `sessions.SessionKind` value (or None when the + current wall-clock time is outside the frozen session calendar -- + unclassifiable, so `ceiling()`/`envelope()` fail closed to 0). + + `pending_buy_notional_usd` (LEV-RI-A, 2026-09-22 leverage fix round 1): + the ledger's own `AccountState.pending_buy_notional_usd` -- the notional + of every not-yet-filled buy intent (reserved/submitted/partially filled) + -- so strategies_v1._decide_core's leveraged-rung budget can net out + capital already committed to a resting order the policy layer's own + `self.holdings` (filled positions only) cannot see. Defaults to 0 so a + caller that does not observe the ledger (e.g. a direct unit-test + construction) reproduces the pre-fix behavior exactly.""" + session: str | None + drawdown_fraction: Decimal + kill_switch: bool + account_multiplier: Decimal | None = None + pending_buy_notional_usd: Decimal = D("0") + + +@dataclass(frozen=True) +class LeveragePolicy: + version: str + max_leverage: Decimal + # ((session, ((regime, value), ...)), ...) -- hashable/asdict-able, + # ordered by SESSIONS/REGIMES for a deterministic, comparable value. + schedule: tuple[tuple[str, tuple[tuple[str, Decimal], ...]], ...] + overnight_max_leverage: Decimal + ladder: tuple[tuple[Decimal, Decimal], ...] # (from_fraction, max_leverage), ascending fraction + apply_overnight_cap: bool + + def _cell(self, session: str, regime: str) -> Decimal: + for s, row in self.schedule: + if s == session: + for r, v in row: + if r == regime: + return v + # Unreachable once constructed only via validate_leverage_policy + # (which requires every SESSIONS x REGIMES cell to be present). + raise LeveragePolicyError("leverage_schedule_shape") + + def ladder_step(self, fraction: Decimal) -> Decimal: + """Fail closed (0) for a non-finite or negative fraction; otherwise + the ladder's last step whose threshold `fraction` has reached or + passed. Monotone non-increasing in `fraction` by construction + (validate_leverage_policy refuses a non-monotone ladder).""" + if not fraction.is_finite() or fraction < 0: + return ZERO + step = ZERO + for start, lev in self.ladder: + if fraction >= start: + step = lev + return step + + def ceiling(self, *, session, regime, drawdown_fraction, kill_switch, account_multiplier=None) -> Decimal: + """Policy-layer ceiling: the full session x regime x drawdown key, + plus the kill switch, the overnight cap (when applicable) and the + broker-proven account multiplier. Invariant: `ceiling(...) <= + envelope(session=..., drawdown_fraction=...)` for every regime.""" + if kill_switch or session not in SESSIONS or regime not in REGIMES: + return ZERO + c = min(self.max_leverage, self._cell(session, regime), self.ladder_step(drawdown_fraction)) + if self.apply_overnight_cap: + c = min(c, self.overnight_max_leverage) + if account_multiplier is not None: + c = min(c, account_multiplier) + return max(c, ZERO) + + def envelope(self, *, session, drawdown_fraction) -> Decimal: + """Ledger-layer ceiling: a regime-independent upper bound (the max + over every regime's cell), since the ledger (safety.py) has no + regime input of its own -- the policy layer (`ceiling()` above) + enforces the regime-specific part independently, and every entry + must pass both.""" + if session not in SESSIONS: + return ZERO + cell = max(self._cell(session, r) for r in REGIMES) + c = min(self.max_leverage, cell, self.ladder_step(drawdown_fraction)) + if self.apply_overnight_cap: + c = min(c, self.overnight_max_leverage) + return c + + +def validate_leverage_policy(config: dict, session_policy: dict) -> LeveragePolicy: + """Validate `config["leverage_policy"]` (already known to be present by + the caller) against `config["max_leverage"]`/`capital_usd`/ + `max_gross_exposure_usd` and the already-validated `session_policy` + (`sessions.validate_session_policy`'s return value). Raises + `LeveragePolicyError` with a bounded reason code on any defect.""" + block = config.get("leverage_policy") + _require(isinstance(block, dict), "leverage_policy_keys") + allowed_keys = {"version", "decision_record", "schedule", "overnight_max_leverage", "drawdown_ladder"} + required_keys = {"version", "schedule", "overnight_max_leverage", "drawdown_ladder"} + _require(set(block) <= allowed_keys and required_keys <= set(block), "leverage_policy_keys") + _require(block["version"] == LEVERAGE_POLICY_VERSION, "leverage_policy_version_mismatch") + if "decision_record" in block: + _require(isinstance(block["decision_record"], str) and block["decision_record"], "leverage_policy_keys") + + L = _dec_str(config["max_leverage"]) + _require(D("1") <= L <= INTRADAY_MAX, "leverage_requested_out_of_bounds") + + schedule_raw = block["schedule"] + _require(isinstance(schedule_raw, dict) and set(schedule_raw) == set(SESSIONS), "leverage_schedule_shape") + cells = {} + for session in SESSIONS: + row = schedule_raw[session] + _require(isinstance(row, dict) and set(row) == set(REGIMES), "leverage_schedule_shape") + for regime in REGIMES: + value = _dec_str(row[regime]) + _require(value <= INTRADAY_MAX, "leverage_value_invalid") + cells[(session, regime)] = value + _require(all(cells[(s, r)] == ZERO for s in SESSIONS for r in ZERO_REGIMES), "leverage_nonzero_in_risk_off") + _require(all(cells[("CLOSED", r)] == ZERO for r in REGIMES), "leverage_nonzero_when_closed") + _require(all(cells[(s, "range")] <= ENGINE_RANGE_CAP for s in SESSIONS), "leverage_range_exceeds_engine_cap") + + O = _dec_str(block["overnight_max_leverage"]) + _require(ZERO < O <= OVERNIGHT_MAX, "leverage_overnight_out_of_bounds") + + ladder_raw = block["drawdown_ladder"] + _require(isinstance(ladder_raw, list), "leverage_ladder_thresholds") + steps = [] + for item in ladder_raw: + _require(isinstance(item, dict) and set(item) == {"from_drawdown_fraction", "max_leverage"}, + "leverage_ladder_thresholds") + fraction = _dec_str(item["from_drawdown_fraction"]) + level = _dec_str(item["max_leverage"]) + _require(level <= INTRADAY_MAX, "leverage_value_invalid") + steps.append((fraction, level)) + _require(len(steps) >= 2 and steps[0][0] == ZERO, "leverage_ladder_must_start_at_zero") + _require(all(a[0] < b[0] for a, b in pairwise(steps)) and steps[-1][0] < D("1"), "leverage_ladder_thresholds") + _require(all(a[1] >= b[1] for a, b in pairwise(steps)), "leverage_ladder_not_monotone") + _require(steps[-1][1] == ZERO, "leverage_ladder_must_end_at_zero") + _require(steps[0][1] <= INTRADAY_MAX, "leverage_value_invalid") + + cap = _dec_config(config["capital_usd"]) + gross = _dec_config(config["max_gross_exposure_usd"]) + _require(gross <= cap * L, "leverage_gross_cap_exceeds_capital_times_leverage") + if session_policy["overnight_holds"]: + multiple = D(str(session_policy["overnight_gross_multiple"])) + _require(gross * multiple <= cap * O, "leverage_overnight_gross_exceeds_regt") + + # DP-2: the block is identical in every rung config and is compared to + # this canonical constant. Every field-level check above already refuses + # a malformed/out-of-bounds block with its own specific reason code; a + # block that instead passes every one of those checks but still differs + # from CANONICAL_V1_BLOCK (e.g. a hand-edited schedule/ladder within + # bounds) must not reach the pre-registered >1x lane, so it is refused + # here as the last check. + canonical = CANONICAL_V1_BLOCK if "decision_record" in block else { + k: v for k, v in CANONICAL_V1_BLOCK.items() if k != "decision_record"} + _require(block == canonical, "leverage_policy_block_not_canonical") + + schedule = tuple((s, tuple((r, cells[(s, r)]) for r in REGIMES)) for s in SESSIONS) + return LeveragePolicy(version=block["version"], max_leverage=L, schedule=schedule, + overnight_max_leverage=O, ladder=tuple(steps), + apply_overnight_cap=bool(session_policy["overnight_holds"])) diff --git a/blueprints/us-equities/adaptive-paper/native_strategy.py b/blueprints/us-equities/adaptive-paper/native_strategy.py index 0e052db85..cab399fc0 100644 --- a/blueprints/us-equities/adaptive-paper/native_strategy.py +++ b/blueprints/us-equities/adaptive-paper/native_strategy.py @@ -10,6 +10,7 @@ from nautilus_trader.model import (ClientOrderId, InstrumentId, OrderSide, Price, Quantity, StrategyId, TimeInForce) from exits import REASON_PRICE_RULE +from leverage import LeverageInputs from safety import DEFAULT_STOP, SafetyError, evaluate_gap_risk from sessions import SessionKind, previous_trading_day, session_at from strategies import AdaptivePolicy, OperationalStatus, QUOTE_FUTURE_TOLERANCE_SECONDS, limit_price @@ -21,13 +22,20 @@ def __new__(cls, policy, ledger, trial_id, **kwargs): order_id_tag="A", log_events=False, log_commands=False, manage_stop=False)) def __init__(self, policy: AdaptivePolicy, ledger, trial_id: str, *, event_sink=None, - transport=None, stop_file=None, clock=time.time): + transport=None, stop_file=None, clock=time.time, account_multiplier=None): self.policy = policy self.ledger = ledger self.trial_id = trial_id self.event_sink = event_sink or (lambda event: None) self.transport = transport self.stop_file = stop_file + # G-e: the broker-proven margin multiplier (runner.py's config + # "_account_multiplier", only ever set after preflight has checked + # multiplier >= requested leverage -- see runner._check_margin_ + # entitlement). None outside the leverage policy; consulted only by + # _leverage_inputs below, and only when self.policy.leverage_policy + # is not None (rebalance() only builds leverage_inputs then). + self.account_multiplier = account_multiplier # D2 (round 6): a single clock, mirroring runner.Controller's own # `clock=time.time` constructor convention, so rebalance()'s # default `now` and on_order_canceled's ack-time `now` (which has @@ -634,6 +642,29 @@ def _operational_status(self, now) -> OperationalStatus: reconciled=reason != "recovery_only", state_fresh=state_fresh) + def _leverage_inputs(self, now) -> LeverageInputs: + """G-e: only called from rebalance() when self.policy.leverage_policy + is not None. `session` is None for a timestamp outside the frozen + session calendar (LeverageInputs consumers fail closed to 0 on an + unclassifiable session, the same contract as safety.Ledger's own + independent envelope check). `kill_switch` is deliberately broader + than OperationalStatus.kill_switch above: it also trips on ANY + halted_reason (not just a non-recovery_only one) and on a frozen or + not-ready transport, since a leverage entry must stop on strictly + more conditions than the ordinary rotation kill switch. + """ + try: + session = session_at(datetime.fromtimestamp(now, timezone.utc)).kind.value + except ValueError: + session = None + acct = self.ledger.accounting() + reason = self.ledger.halted_reason() + health = getattr(self.transport, "health", {}) if self.transport is not None else {} + kill = (Path(self.stop_file or DEFAULT_STOP).exists() or reason is not None + or bool(health.get("frozen")) or health.get("ready") is False) + return LeverageInputs(session, acct.drawdown_usd / self.ledger.limits.max_drawdown_usd, kill, + self.account_multiplier, acct.pending_buy_notional_usd) + def rebalance(self, now=None, *, force_exit=False): """Called on the native owner loop, never a socket thread.""" now = self._clock() if now is None else now @@ -641,8 +672,16 @@ def rebalance(self, now=None, *, force_exit=False): for client_id in list(self.pending): self._finish_if_terminal(client_id) operational = self._operational_status(now) if self.policy.selector is not None else None + decide_kwargs = {"operational": operational} + # getattr, not a direct attribute access: self.policy is not always + # a real strategies.AdaptivePolicy instance -- a duck-typed test + # double (predating G-e) providing only the narrower pre-G-e + # interface (config/latest/selector/decide/...) must still work + # unchanged, with leverage treated as absent. + if getattr(self.policy, "leverage_policy", None) is not None: + decide_kwargs["leverage_inputs"] = self._leverage_inputs(now) decision = self.policy.decide(now, allow_entries=self.enabled, force_exit=force_exit, - operational=operational) + **decide_kwargs) if decision is None or not self.started: return None # If this tick's selector decision liquidates (FLATTEN_BEFORE_SWITCH), @@ -668,12 +707,15 @@ def rebalance(self, now=None, *, force_exit=False): # or not -- can be included in that same event below, instead of # needing a separate event type. gap_stop_symbols = self._gap_risk_stop_symbols(now, held) - self.event_sink({"type": "decision", "timestamp": now, "regime": decision.regime, - "targets": decision.targets, "exits": decision.exits, - "effective_leverage": decision.effective_leverage, - "signals": [{"symbol": s.symbol, "family": s.family, - "edge_bps": s.edge_bps, "score": s.score} for s in decision.signals], - "gap_bps": {symbol: str(bps) for symbol, bps in self._gap_bps.items()}}) + decision_event = {"type": "decision", "timestamp": now, "regime": decision.regime, + "targets": decision.targets, "exits": decision.exits, + "effective_leverage": decision.effective_leverage, + "signals": [{"symbol": s.symbol, "family": s.family, + "edge_bps": s.edge_bps, "score": s.score} for s in decision.signals], + "gap_bps": {symbol: str(bps) for symbol, bps in self._gap_bps.items()}} + if getattr(self.policy, "leverage_policy", None) is not None: + decision_event["leverage_ceiling"] = self.policy.last_leverage_ceiling + self.event_sink(decision_event) # Exits consume capacity before fresh entries; one outstanding order per # symbol also prevents sell-before-entry-terminal and oversell races. # A D5 gap-risk stop forces a full exit even if the ordinary decision diff --git a/blueprints/us-equities/adaptive-paper/runner.py b/blueprints/us-equities/adaptive-paper/runner.py index 21a90a09c..1cb00de13 100644 --- a/blueprints/us-equities/adaptive-paper/runner.py +++ b/blueprints/us-equities/adaptive-paper/runner.py @@ -20,6 +20,7 @@ import signal import time +from leverage import LeveragePolicyError, next_lower_rung_ceiling, validate_leverage_policy from safety import Ledger, Quote, RiskLimits, SafetyError, account_lock_fingerprint, DEFAULT_STOP from sessions import (DEFAULT_SESSION_POLICY, SessionKind, boundary_receipt, extended_session_close, must_end_flat, session_at, validate_session_policy) @@ -275,14 +276,33 @@ def load_config(path, *, registry_path=None): c = json.loads(Path(path).read_text()) if c["feed"] not in DATA_FEEDS: raise ValueError("unqualified_data_feed") + # G-e: leverage above 1x is refused exactly as before UNLESS a + # "leverage_policy" block is present, in which case it is deferred to + # validate_leverage_policy below (which raises its own, more specific + # LeveragePolicyError reason codes) instead of the blanket refusal + # here. Every shipped config as of bdd04ca has no "leverage_policy" + # key, so `lev_block_present` is False and this condition is + # byte-identical to before G-e for both of them. + lev_block_present = "leverage_policy" in c if (c["endpoint"] != "https://paper-api.alpaca.markets" - or c["catalyst_orders_enabled"] is not False or Decimal(c["max_leverage"]) > 1): + or c["catalyst_orders_enabled"] is not False + or (Decimal(c["max_leverage"]) > 1 and not lev_block_present)): raise ValueError("unqualified_lane_configuration") # Single consolidated session-policy gate (replaces the previous ad-hoc # regular_session_only/extended_hours_enabled check in place here); with # the default config (no "sessions" block, or one matching today's # defaults) this enforces exactly the same invariant as before. session_policy = validate_session_policy(c) + # G-e: opt-in leverage-schedule validation. Absent (every shipped + # config) -> leverage stays None and every downstream private key/kwarg + # below is skipped entirely, so the rest of load_config's output is + # byte-identical to before G-e. Present -> validate_leverage_policy + # raises its own LeveragePolicyError (a ValueError) reason code on any + # defect; a passing block is threaded through as "_leverage_policy" and + # into RiskLimits.leverage / PolicyConfig.leverage_policy_id below. + leverage = validate_leverage_policy(c, session_policy) if lev_block_present else None + if leverage is not None: + c["_leverage_policy"] = leverage # G-f round 5: the entire gap-risk hook is opt-in. "sessions.gap_stop" # is optional and, when absent (every shipped config), "enabled" # defaults to False -- validate_session_policy's own "sessions" block @@ -329,16 +349,28 @@ def load_config(path, *, registry_path=None): max_submits_per_minute=c["max_submit_requests_per_minute"], quote_max_age_seconds=c["quote_max_age_seconds"], trial_seconds=c["duration_seconds"], cleanup_seconds=c["cleanup_seconds"], min_entry_close_seconds=c["min_entry_close_seconds"], - overnight_gross_multiple=session_policy["overnight_gross_multiple"]) + overnight_gross_multiple=session_policy["overnight_gross_multiple"], + **({"leverage": leverage} if leverage is not None else {})) policy = PolicyConfig(symbols=tuple(c["symbols"]), benchmarks=tuple(c["benchmarks"]), max_positions=c["max_held_symbols"], capital=float(c["capital_usd"]), gross_cap=float(c["max_gross_exposure_usd"]), max_leverage=float(c["max_leverage"]), max_shares=c["max_order_quantity"], max_spread_bps=float(c["max_spread_bps"]), + # CX-P2 (2026-09-22 leverage fix round 1): PolicyConfig's + # own order-notional cap used to silently keep its + # 1000 default regardless of this config's + # max_order_notional_usd (RiskLimits already + # receives it above) -- a leveraged rung's larger + # configured per-order sizing (2000/4000) never + # reached strategies_v1._decide_core's allocation + # clamp, so entries could never actually size up + # to what the rung's ledger-side cap would allow. + max_order_notional=float(c["max_order_notional_usd"]), warmup_samples=c["warmup_samples"], warmup_seconds=c["warmup_seconds"], minimum_edge_bps=c["minimum_edge_bps"], quote_age_seconds=c["quote_max_age_seconds"], min_hold_seconds=c["min_hold_seconds"], max_hold_seconds=c["max_hold_seconds"], stop_bps=c["stop_bps"], take_profit_bps=c["take_profit_bps"], trailing_bps=c["trailing_bps"], - gap_stop_enabled=gap_stop_enabled, exit_replace_enabled=exit_replace_enabled) + gap_stop_enabled=gap_stop_enabled, exit_replace_enabled=exit_replace_enabled, + **({"leverage_policy_id": leverage.version} if leverage is not None else {})) if set(c["strategy_scope"]) != set(AdaptivePolicy.families): raise ValueError("unsupported_strategy_scope") return c, risk, policy @@ -422,6 +454,31 @@ def _check_promotion_gate(gate_result_path, snapshot_path): raise SafetyError("promotion_gate_mismatch") +def _check_margin_entitlement(account, config, lev, session_policy): + """G-e: only called (from validate_preflight, below) when + ``config.get("_leverage_policy")`` is set -- i.e. `account` was fetched + with ``include_margin=True`` (see transport.normalize_account/preflight). + Raises ``SafetyError`` with a bounded reason code on any missing field + or insufficient broker entitlement; never raises for the default, + non-leverage path (validate_preflight never calls this function then). + """ + need = lev.max_leverage + if any(key not in account for key in ("multiplier", "daytrading_buying_power", "regt_buying_power")): + raise SafetyError("account_margin_fields_missing") + if account.get("pattern_day_trader") is True and Decimal(account["equity"]) < 25000: + raise SafetyError("account_restricted") + mult = Decimal(account["multiplier"]) + if not mult.is_finite() or mult < need: + raise SafetyError("account_multiplier_below_requested_leverage") + if Decimal(account["daytrading_buying_power"]) < Decimal(config["max_gross_exposure_usd"]): + raise SafetyError("account_daytrading_buying_power_insufficient") + if session_policy["overnight_holds"]: + overnight_cap = Decimal(config["capital_usd"]) * lev.overnight_max_leverage + if Decimal(account["regt_buying_power"]) < overnight_cap: + raise SafetyError("account_regt_buying_power_insufficient") + return mult + + def validate_preflight(observation, config, *, require_open, allow_existing=False, allow_existing_positions=None, session_policy=None, mode=None, gate_result_path=None, snapshot_path=None): @@ -450,6 +507,26 @@ def validate_preflight(observation, config, *, require_open, allow_existing=Fals or (not allow_existing and (Decimal(account["cash"]) < Decimal(config["capital_usd"]) or Decimal(account["equity"]) < 25000))): raise SafetyError("account_not_ready") + # G-e: margin-entitlement preflight, only under a validated leverage + # policy (config["_leverage_policy"], set by load_config). Runs after + # the ordinary account-restriction checks above (which apply + # unconditionally, leverage or not) and before every other guard below, + # so a preflight that later fails a session/window/universe check under + # the policy still surfaces the more actionable margin-entitlement + # reason first when both would fail. config is mutated in place with + # the broker-proven multiplier ("_account_multiplier", a private key, + # same pattern as load_config's "_registry_entries") so main() can + # thread it into AdaptiveStrategy/the "margin" preflight summary + # without a second, duplicate broker-account check. + leverage_policy = config.get("_leverage_policy") + if leverage_policy is not None and not allow_existing: + # ``allow_existing`` (--command recover) only ever submits sells + # (recovery.recover, recovery.py:168, "buy_submissions": 0) -- + # broker margin/DTBP/multiplier entitlement gates new buys, so it + # must not block a sell-only recovery from flattening an + # over-leveraged position. README-safety.md: sells and exits are + # never refused by the leverage ceiling. + config["_account_multiplier"] = _check_margin_entitlement(account, config, leverage_policy, session_policy) server = clock["timestamp_ns"] / 1e9 close = clock["next_close_ns"] / 1e9 if abs(clock["received_at_ns"] / 1e9 - server) > .25: @@ -685,6 +762,48 @@ async def observed_submit(order): return port +def _leverage_achievement_step(state, *, dt_seconds, achieved_leverage, ceiling, next_lower_ceiling): + """F2 (residual review, reachability): one pure state-update step of the + per-run achieved-leverage receipt, factored out (D1 pattern) so it is + directly testable without driving run_native's full tick loop. + + `state` is the previous return value of this function (or + `_INITIAL_LEVERAGE_ACHIEVEMENT_STATE`); `achieved_leverage` is this + tick's gross exposure / equity (mark-to-market, not the fixed-capital + denominator `peak_effective_leverage` already in `outcome["leverage"]` + uses); `ceiling` is the policy layer's ceiling in force this tick; + `next_lower_ceiling` is `leverage.next_lower_rung_ceiling(config's + max_leverage)` (None for the 1x rung, which has no lower rung). + `dt_seconds` is the wall-clock gap since the previous tick this function + was called for (0 on the first call). + + Records: the peak achieved leverage seen so far, the ceiling that was in + force at the tick that peak was recorded (a receipt with a high peak but + a ceiling that never actually allowed it would itself be suspicious), + and the cumulative time spent with achieved leverage above the + next-lower rung's own ceiling -- evidence a '4x' run actually needed 4x + and did not merely stay inside 2x's proportional room the whole time. + Attributes the whole `dt_seconds` gap to "above" when THIS tick's + achieved leverage exceeds the threshold (a per-tick approximation, not a + continuous integral); ticks are ~0.1s apart in practice so the error is + bounded by the tick cadence. + """ + peak = state["peak_achieved_leverage"] + ceiling_at_peak = state["ceiling_at_peak"] + if achieved_leverage > peak: + peak = achieved_leverage + ceiling_at_peak = ceiling + seconds_above = state["seconds_above_next_lower_rung_ceiling"] + if next_lower_ceiling is not None and dt_seconds > 0 and achieved_leverage > next_lower_ceiling: + seconds_above += dt_seconds + return {"peak_achieved_leverage": peak, "ceiling_at_peak": ceiling_at_peak, + "seconds_above_next_lower_rung_ceiling": seconds_above} + + +_INITIAL_LEVERAGE_ACHIEVEMENT_STATE = {"peak_achieved_leverage": Decimal("0"), "ceiling_at_peak": Decimal("0"), + "seconds_above_next_lower_rung_ceiling": 0.0} + + async def run_native(controller, policy_config, assets, trial_id, config, baseline_cash, *, account_fingerprint="simulation"): from native_adapter import build_node from native_strategy import AdaptiveStrategy @@ -698,9 +817,11 @@ async def run_native(controller, policy_config, assets, trial_id, config, baseli if registry_entries is None: registry_entries = load_registry(Path(config.get("_registry_path", SOURCE / "registry.json"))) pool, selector = strategy_pool_and_selector(config, registry_entries) - policy = AdaptivePolicy(policy_config, strategy_pool=pool, selector=selector) + leverage_policy = config.get("_leverage_policy") + policy = AdaptivePolicy(policy_config, strategy_pool=pool, selector=selector, leverage_policy=leverage_policy) strategy = AdaptiveStrategy(policy, controller.ledger, trial_id, - event_sink=controller.events.append, transport=controller.port) + event_sink=controller.events.append, transport=controller.port, + account_multiplier=config.get("_account_multiplier")) # Capture actual streaming quotes before native conversion. Every native # strategy event still arrives through the data engine's ordinary path. port = controller.port @@ -741,6 +862,30 @@ async def quote_sink(q): # session calendar to compute it at all. last_session_kind = (session_at(datetime.fromtimestamp(time.time(), timezone.utc)).kind if (session_policy["overnight_holds"] or session_policy["extended_hours"]) else None) + # G-e receipt tracking (only meaningful/consulted when leverage_policy is + # not None; harmless, unread overhead otherwise). peak_gross_exposure_usd + # observes the ledger's own accounted FILLED-position gross exposure + # each tick -- EH-1 (2026-09-22 leverage fix round 1): the ledger's raw + # `accounting().gross_exposure_usd` is `gross + pending` + # (safety.Ledger._state), i.e. it also counts every still-resting, + # unfilled buy intent, so using it directly here used to let a run's + # "achieved" leverage/margin figures reflect capital that was never + # actually deployed into a filled position. peak_pending_buy_notional_usd + # records that unfilled-order notional separately instead of folding it + # into the achieved-exposure figures. + # ceiling_changes records the policy layer's last_leverage_ceiling only + # on an actual change, not every tick. + peak_gross_exposure_usd = Decimal("0") + peak_pending_buy_notional_usd = Decimal("0") + ceiling_changes = [] + last_recorded_ceiling = None + # F2 (residual review, reachability): the achieved-leverage receipt. + # next_lower_ceiling is fixed for the whole run (the rung the loaded + # config's own max_leverage sits at does not change mid-run); None for + # the 1x rung, which has no lower rung to compare against. + achievement_state = _INITIAL_LEVERAGE_ACHIEVEMENT_STATE + next_lower_ceiling = next_lower_rung_ceiling(leverage_policy.max_leverage) if leverage_policy is not None else None + last_achievement_tick = None try: while time.monotonic() - started < config["duration_seconds"] + config["cleanup_seconds"]: now = time.time() @@ -807,6 +952,52 @@ async def quote_sink(q): if strategy.started: strategy.cancel_expired(now, config["order_timeout_seconds"], all_entries=force_exit) strategy.rebalance(now, force_exit=force_exit) + if leverage_policy is not None: + acct_now = controller.ledger.accounting() + # EH-1: filled_gross_exposure_usd excludes every + # not-yet-filled buy intent's notional (acct_now.gross_ + # exposure_usd includes it; acct_now.pending_buy_ + # notional_usd is exactly that included amount -- see + # safety.Ledger._state) so peak_gross_exposure_usd, + # achieved_leverage_now and broker_margin_used below only + # ever reflect capital actually deployed into a filled + # position, never a resting or partially-filled order. + filled_gross_exposure_usd = acct_now.gross_exposure_usd - acct_now.pending_buy_notional_usd + peak_gross_exposure_usd = max(peak_gross_exposure_usd, filled_gross_exposure_usd) + peak_pending_buy_notional_usd = max(peak_pending_buy_notional_usd, + acct_now.pending_buy_notional_usd) + current_ceiling = policy.last_leverage_ceiling + # F2: gross-to-equity, mark-to-market (equity moves with + # realized+unrealized pnl each tick) -- distinct from + # peak_gross_exposure_usd/capital below, which is fixed + # to starting capital and cannot fall from paper losses. + equity_now = (controller.ledger.limits.capital_usd + acct_now.realized_pnl_usd + + acct_now.unrealized_pnl_usd) + achieved_leverage_now = (filled_gross_exposure_usd / equity_now + if equity_now > 0 else Decimal("0")) + dt = 0.0 if last_achievement_tick is None else max(0.0, now - last_achievement_tick) + achievement_state = _leverage_achievement_step( + achievement_state, dt_seconds=dt, achieved_leverage=achieved_leverage_now, + ceiling=current_ceiling if current_ceiling is not None else Decimal("0"), + next_lower_ceiling=next_lower_ceiling) + last_achievement_tick = now + if current_ceiling != last_recorded_ceiling: + # G-e receipt (S6): regime is read back from this + # tick's own decision event (controller.events), + # rather than re-deriving it, so a throttled tick + # (no fresh decision -> no event appended) simply + # keeps the last known ceiling change unlogged for + # regime rather than guessing. + last_event = controller.events[-1] if controller.events else {} + try: + observed_session = session_at(datetime.fromtimestamp(now, timezone.utc)).kind.value + except ValueError: + observed_session = None + ceiling_changes.append({ + "t": now, "session": observed_session, "regime": last_event.get("regime"), + "drawdown_fraction": str(acct_now.drawdown_usd / controller.ledger.limits.max_drawdown_usd), + "ceiling": str(current_ceiling)}) + last_recorded_ceiling = current_ceiling if force_exit and not controller.ledger.unresolved() and not controller.ledger.positions() and not strategy.pending: break await asyncio.sleep(.1) @@ -838,6 +1029,43 @@ async def quote_sink(q): "overnight_holds": session_policy["overnight_holds"]}, "boundary_receipts": boundary_receipts, "elapsed_seconds": time.monotonic() - started} + if leverage_policy is not None: + capital = Decimal(config["capital_usd"]) + peak_effective_leverage = (peak_gross_exposure_usd / capital) if capital else Decimal("0") + outcome["leverage"] = { + "policy_version": leverage_policy.version, + "config_max_leverage": str(leverage_policy.max_leverage), + "account_multiplier": (str(config["_account_multiplier"]) + if config.get("_account_multiplier") is not None else None), + "peak_gross_exposure_usd": str(peak_gross_exposure_usd), + # EH-1: the peak notional of not-yet-filled buy intents seen + # this run, tracked separately from peak_gross_exposure_usd + # above (which is now filled-position-only) rather than folded + # into it. + "peak_pending_buy_notional_usd": str(peak_pending_buy_notional_usd), + "peak_effective_leverage": str(peak_effective_leverage), + "ceiling_changes": ceiling_changes, + "broker_cash_at_start": str(baseline_cash), + "broker_margin_used": peak_gross_exposure_usd > Decimal(str(baseline_cash)), + # F2 (residual review, 2026-09-22, reachability): the achieved + # (not merely permitted) leverage this run actually reached, + # gross-to-equity and mark-to-market -- unlike + # peak_effective_leverage above (fixed-capital-denominated), this + # falls if paper losses shrink equity. This is a receipt only: + # no gate row in catalogs/us-equities/gates-20260922.json + # currently reads peak_achieved_leverage or + # seconds_above_next_lower_rung_ceiling, so a rung's gate row + # CAN still flip to established on a receipt whose achieved + # exposure never exceeded a lower rung's own ceiling -- see + # README-safety.md's Leverage schedule section (2026-09-22 + # leverage fix round 2, N1-1) and the rung configs' notes for + # what this field establishes and does not establish (achieved + # exposure may stay below the cap; a rung sets the safety + # envelope, not a target). + "next_lower_rung_ceiling": (str(next_lower_ceiling) if next_lower_ceiling is not None else None), + "peak_achieved_leverage": str(achievement_state["peak_achieved_leverage"]), + "ceiling_at_peak_achieved_leverage": str(achievement_state["ceiling_at_peak"]), + "seconds_above_next_lower_rung_ceiling": achievement_state["seconds_above_next_lower_rung_ceiling"]} outcome["status"] = _run_native_status(reconciliation, session.errors, strategy.native_fills, outcome, session_policy, time.time()) return outcome @@ -1063,9 +1291,14 @@ def observe_request(kind, **kwargs): attempts.append({"timestamp": time.time(), "kind": kind}) if len(attempts) > 50: raise SafetyError("preflight_request_bound") + # G-e: only a validated leverage policy widens the preflight account + # read to include margin fields (transport.normalize_account); the + # default (no "leverage_policy" block) preflight is unchanged. + include_margin = config.get("_leverage_policy") is not None try: observation = preflight(key, secret, config["symbols"], feed=config["feed"], - before_request=observe_request, request_observer=responses.append) + before_request=observe_request, request_observer=responses.append, + include_margin=include_margin) except TransportError as exc: result = {"status": "not_started", "stage": "preflight", "reason": str(exc), "feed": config["feed"], "orders_submitted": 0, "http": responses, @@ -1080,6 +1313,16 @@ def observe_request(kind, **kwargs): try: validate_preflight(observation, config, require_open=True, session_policy=session_policy) summary["status"] = "ready" + # G-e: only set once validate_preflight's margin-entitlement + # check (above) has already passed under the policy. + leverage_policy = config.get("_leverage_policy") + if leverage_policy is not None: + summary["margin"] = { + "multiplier": str(config["_account_multiplier"]), + "daytrading_buying_power": observation["account"]["daytrading_buying_power"], + "regt_buying_power": observation["account"]["regt_buying_power"], + "requested_max_leverage": str(leverage_policy.max_leverage), + "policy_version": leverage_policy.version} except SafetyError as exc: summary.update(status="not_ready", reason=str(exc)) save(args.output, summary) @@ -1116,6 +1359,18 @@ def observe_request(kind, **kwargs): save(args.output, summary) print(json.dumps({"status": "not_started", "reason": str(exc), "orders_submitted": 0})) return 2 + leverage_policy = config.get("_leverage_policy") + # G-e/LEV-RI-1: --command recover skips _check_margin_entitlement (it is + # sell-only, see validate_preflight above), so config["_account_multiplier"] + # is never set on that path -- report the "margin" summary only when the + # broker-proven multiplier actually exists. + if leverage_policy is not None and config.get("_account_multiplier") is not None: + summary["margin"] = { + "multiplier": str(config["_account_multiplier"]), + "daytrading_buying_power": observation["account"]["daytrading_buying_power"], + "regt_buying_power": observation["account"]["regt_buying_power"], + "requested_max_leverage": str(leverage_policy.max_leverage), + "policy_version": leverage_policy.version} fingerprint = observation["account_identity_sha256"] with account_lock_fingerprint(fingerprint): state_dir = args.state_root / fingerprint / "adaptive" @@ -1196,7 +1451,8 @@ def fresh_port(recovering=False): quote_timeout=config["quote_max_age_seconds"], feed=config["feed"], required_quote_symbols=needed if recovering and needed else config["benchmarks"], history_start=datetime.fromtimestamp(metadata["started_at"], timezone.utc), - extended_hours_allowed=session_policy["extended_hours"])) + extended_hours_allowed=session_policy["extended_hours"], + include_margin=leverage_policy is not None)) for sig in (signal.SIGINT, signal.SIGTERM): signal.signal(sig, lambda *_: setattr(controller, "stop", True)) async def execute(): diff --git a/blueprints/us-equities/adaptive-paper/safety.py b/blueprints/us-equities/adaptive-paper/safety.py index 86fd003cc..bfe51505b 100644 --- a/blueprints/us-equities/adaptive-paper/safety.py +++ b/blueprints/us-equities/adaptive-paper/safety.py @@ -27,6 +27,7 @@ if _HERE not in sys.path: sys.path.insert(0, _HERE) from sessions import SessionKind, session_at as _session_at # noqa: E402 +from leverage import LeveragePolicy # noqa: E402 D = Decimal ZERO = D(0) @@ -105,13 +106,28 @@ class RiskLimits: cleanup_seconds: int = 120 min_entry_close_seconds: int = 300 overnight_gross_multiple: Decimal = D("1.0") + # G-e: the opt-in leverage-schedule policy (leverage.py). None (every + # shipped default) keeps every check below byte-identical to before + # G-e: the gross-exposure bound is capital_usd (1x), exactly as it was. + # A validated LeveragePolicy widens that bound to + # capital_usd * leverage.max_leverage (<=4x) and, when + # apply_overnight_cap, additionally bounds the overnight gross cap by + # capital_usd * leverage.overnight_max_leverage (<=2x, Reg T). This + # field is never part of the generic numeric-bounds loop above/below + # (it is neither Decimal/int nor a caller-supplied string) and is + # dropped from frozen_json() when None, so the persisted meta.limits + # bytes for the default path are unchanged (see Ledger.__init__). + leverage: LeveragePolicy | None = None def __post_init__(self): for field in ("capital_usd", "max_gross_exposure_usd", "max_order_notional_usd", "max_order_qty", "max_gross_loss_usd", "max_drawdown_usd", "max_spread_bps", "overnight_gross_multiple"): object.__setattr__(self, field, decimal(getattr(self, field))) - if (self.capital_usd > D("1000000") or self.max_gross_exposure_usd > self.capital_usd + if self.leverage is not None and not isinstance(self.leverage, LeveragePolicy): + raise SafetyError("risk_limit_out_of_bounds") + leverage_multiple = self.leverage.max_leverage if self.leverage is not None else D("1") + if (self.capital_usd > D("1000000") or self.max_gross_exposure_usd > self.capital_usd * leverage_multiple or self.max_order_notional_usd > min(self.max_gross_exposure_usd, D("10000")) or self.max_order_qty > 100 or self.max_order_qty != self.max_order_qty.to_integral_value() or self.max_gross_loss_usd > self.capital_usd / 10 @@ -121,7 +137,10 @@ def __post_init__(self): # above (zero=False, the default) already refuses a # non-positive overnight_gross_multiple before this # __post_init__ body ever reaches this check. - or self.overnight_gross_multiple > D("2.0")): + or self.overnight_gross_multiple > D("2.0") + or (self.leverage is not None and self.leverage.max_leverage > D("4")) + or (self.leverage is not None and self.leverage.apply_overnight_cap + and self.overnight_gross_exposure_cap_usd() > self.capital_usd * self.leverage.overnight_max_leverage)): raise SafetyError("risk_limit_out_of_bounds") caps = {"max_held_symbols": 50, "max_outstanding_orders": 100, "max_rest_per_minute": 200, "max_submits_per_minute": 180, @@ -185,6 +204,18 @@ def overnight_gross_exposure_cap_usd(self): the default multiple of 1.0 this equals the ordinary intraday cap.""" return self.max_gross_exposure_usd * self.overnight_gross_multiple + def frozen_json(self): + """The exact bytes persisted to `meta.limits` (see Ledger.__init__) + and compared byte-for-byte against a mismatched reopen. `leverage` + (None on every default/pre-G-e path) is dropped from the dict + entirely rather than serialized as null, so the persisted bytes for + every existing on-disk ledger, and for every config without a + `leverage_policy` block, are unchanged from before G-e.""" + d = asdict(self) + if d["leverage"] is None: + del d["leverage"] + return json.dumps(d, default=str, sort_keys=True) + def evaluate_gap_risk(prior_close, session_open, stop_bps): """Gap-risk rule (deliverable 4): evaluated at the first RTH bar after an @@ -352,7 +383,7 @@ def __init__(self, db_path, limits=None): client_id TEXT, payload TEXT NOT NULL); CREATE TABLE IF NOT EXISTS trials(trial_id TEXT PRIMARY KEY, started_at REAL NOT NULL); """) - frozen = json.dumps(asdict(self.limits), default=str, sort_keys=True) + frozen = self.limits.frozen_json() with self._transaction(): previous = self._get("limits") if previous is not None and previous != frozen: @@ -637,6 +668,25 @@ def _effective_gross_cap(self, now): return self.limits.overnight_gross_exposure_cap_usd() return self.limits.max_gross_exposure_usd + def _leverage_envelope(self, now, state): + """The ledger-layer leverage ceiling at `now` (only ever consulted + when `self.limits.leverage is not None`): the regime-independent + upper bound over every session's schedule row, further reduced by + the drawdown ladder and (when applicable) the overnight cap. Fails + closed (0) for a timestamp outside the frozen session calendar, + the same fail-closed contract as an unclassifiable session in + leverage.LeveragePolicy.envelope/ceiling -- the policy layer + (strategies.py) never even observes an unclassifiable session + (native_strategy._leverage_inputs already sets session=None for + one, which also ceilings to 0), so this is deliberately at least + as strict as, never looser than, the policy layer.""" + try: + kind = _session_at(datetime.fromtimestamp(now, tz=timezone.utc)).kind.value + except ValueError: + return ZERO + drawdown_fraction = state.drawdown_usd / self.limits.max_drawdown_usd + return self.limits.leverage.envelope(session=kind, drawdown_fraction=drawdown_fraction) + def _refresh_risk(self, now=None): # now=None (e.g. record_order's optional timestamp) falls back to the # ordinary intraday cap, the same behaviour as before D2; when now is @@ -714,9 +764,17 @@ def reserve_intent(self, client_id, symbol, side, qty, limit_price, *, quote, mark = self.db.execute("SELECT at FROM marks WHERE symbol=?", (held,)).fetchone() if mark is None or not -0.25 <= now - mark[0] <= self.limits.quote_max_age_seconds: raise SafetyError("held_position_mark_stale") - equity = self.limits.capital_usd + state.realized_pnl_usd + state.unrealized_pnl_usd - if state.gross_exposure_usd + qty * price > min(self._effective_gross_cap(now), equity): - raise SafetyError("aggregate_exposure_cap_exceeded") + if self.limits.leverage is not None: + envelope = self._leverage_envelope(now, state) + if envelope <= 0: + raise SafetyError("leverage_ceiling_zero") + equity = self.limits.capital_usd + state.realized_pnl_usd + state.unrealized_pnl_usd + if state.gross_exposure_usd + qty * price > min(self._effective_gross_cap(now), equity * envelope): + raise SafetyError("aggregate_exposure_cap_exceeded") + else: + equity = self.limits.capital_usd + state.realized_pnl_usd + state.unrealized_pnl_usd + if state.gross_exposure_usd + qty * price > min(self._effective_gross_cap(now), equity): + raise SafetyError("aggregate_exposure_cap_exceeded") exposed = set(self._positions()) | {r[0] for r in self.db.execute( "SELECT symbol FROM intents WHERE side='buy' AND status NOT IN ('filled','canceled','expired','rejected','not_sent','broker_refused')")} if len(exposed | {symbol}) > self.limits.max_held_symbols: @@ -767,9 +825,17 @@ def validate_pending(self, client_id, *, quote, now, market_open, session_close, mark = self.db.execute("SELECT at FROM marks WHERE symbol=?", (symbol,)).fetchone() if mark is None or not -0.25 <= now - mark[0] <= self.limits.quote_max_age_seconds: raise SafetyError("held_position_mark_stale") - equity = self.limits.capital_usd + state.realized_pnl_usd + state.unrealized_pnl_usd - if state.gross_exposure_usd > min(self._effective_gross_cap(now), equity): - raise SafetyError("aggregate_exposure_cap_exceeded") + if self.limits.leverage is not None: + envelope = self._leverage_envelope(now, state) + if envelope <= 0: + raise SafetyError("leverage_ceiling_zero") + equity = self.limits.capital_usd + state.realized_pnl_usd + state.unrealized_pnl_usd + if state.gross_exposure_usd > min(self._effective_gross_cap(now), equity * envelope): + raise SafetyError("aggregate_exposure_cap_exceeded") + else: + equity = self.limits.capital_usd + state.realized_pnl_usd + state.unrealized_pnl_usd + if state.gross_exposure_usd > min(self._effective_gross_cap(now), equity): + raise SafetyError("aggregate_exposure_cap_exceeded") if state.held_symbols > self.limits.max_held_symbols: raise SafetyError("held_symbol_cap_reached") else: diff --git a/blueprints/us-equities/adaptive-paper/source-hashes.json b/blueprints/us-equities/adaptive-paper/source-hashes.json index c1fd12d6a..7d5b9eab4 100644 --- a/blueprints/us-equities/adaptive-paper/source-hashes.json +++ b/blueprints/us-equities/adaptive-paper/source-hashes.json @@ -1,35 +1,39 @@ { "blueprints/us-equities/adaptive-paper/adaptive_policy_v1.receipt.json": "0e6e93a74364cb0cc297c576e6b476a2548d07b25773d2b5e62a8ee8bd36fdf2", "blueprints/us-equities/adaptive-paper/benchmark.py": "d2aad24aaaa5bfcbf123e117497a0c53dfbba5fcce82fe985376157c9abddae6", + "blueprints/us-equities/adaptive-paper/config-leverage-1x.json": "ee56dff36a5b64c91f7735e86e58f018adb09d72061e05e58c671722d0ad5de7", + "blueprints/us-equities/adaptive-paper/config-leverage-2x.json": "d03ca9396437e9d4e4176b31e7aba4304d2b6324ff9ef1b69462483c8ac4807e", + "blueprints/us-equities/adaptive-paper/config-leverage-4x.json": "2bcedf606602db3ae73e73df5cffe3143bdb00a2f4e83fc456b232783946efd4", "blueprints/us-equities/adaptive-paper/config-sip.json": "3587653104e68fdfc9bbb9170825c611abcf7e2e1be1004327a056031c34555a", "blueprints/us-equities/adaptive-paper/config.json": "2b555982034af02b9d0d2e1d20e17d22c1e83f2b0c4d60b61d91307ebec63562", "blueprints/us-equities/adaptive-paper/exits.py": "189133e6df99bda201d73d36a15f45ccbfb38979a3319ae6f75d077f5db5b3df", "blueprints/us-equities/adaptive-paper/feeds.py": "0db3009a724ae2433572c5070066d6806d7365031f9db4e81c4b69ad1cbebacb", + "blueprints/us-equities/adaptive-paper/leverage.py": "4ad673522a5b3d76b6058da8fff8e7d8d27c43f2d243b87a5ef979db9a4c2803", "blueprints/us-equities/adaptive-paper/market_research.py": "692f0f16f56709204002d587d2e4da8045f0ffe8249294055cf36b51e29abe68", "blueprints/us-equities/adaptive-paper/metrics.py": "7d6ef2dbdaaed3de1a0c1f65257d834c92b9b1027a4f8a206c5ed148c4cf0e6f", "blueprints/us-equities/adaptive-paper/native_adapter.py": "049b1eaeee67010e25b553726e201ce9be951c1a0bc67097908a7f9d86ce86c8", - "blueprints/us-equities/adaptive-paper/native_strategy.py": "03eb8ea62bb60b7aa2799fa2f3e78908d30846cce77292148d4617031fc75b4d", + "blueprints/us-equities/adaptive-paper/native_strategy.py": "7dbf1037b5909d0ea40d49f06740d74bee364ef4608e654350e18ee67b297e2b", "blueprints/us-equities/adaptive-paper/receipts/adaptive_policy_v1.golden.json": "5cb4b21c7d2159e496093c67b3b358810db4e225d9c5ee1c633258050973556e", "blueprints/us-equities/adaptive-paper/recovery.py": "52a4c428bde16bd8f7ab758dc5f868cfd25b2ba482e8c1f26176f30d016036bc", "blueprints/us-equities/adaptive-paper/registry.json": "60da73b985e718bd1dece7a3350f34636bda0fb38e766a44ae71c5a8c532282a", "blueprints/us-equities/adaptive-paper/requirements.txt": "518afe4b77f901e87abd3a5d6faa126aa7e211db77eecec80cfe7ef42d82e07a", - "blueprints/us-equities/adaptive-paper/runner.py": "71b06d57135affc8a39e1ed10b10b174ddfbcbdc82e627896963285e7d8eab7c", - "blueprints/us-equities/adaptive-paper/safety.py": "e521cbf18f44e24cb3bd67a2eab7481fe39680c1bf748fe12374f63c926657e8", + "blueprints/us-equities/adaptive-paper/runner.py": "d264464fcf9c03bea0ce5be3a41937e32da0f880c6d35243bde0eef2414d7a16", + "blueprints/us-equities/adaptive-paper/safety.py": "8cc67464ab55794b232cedb9988c264b86c8ec11ebe1734a94fb71480a269fa0", "blueprints/us-equities/adaptive-paper/selector.py": "28de8ebf66b6e7135979554f73692ede772167682007d8c6ba41da6fb6fb39b3", "blueprints/us-equities/adaptive-paper/sessions.py": "1920952ac8219caf09e622fe517179e2766652fc7f987f7a11258481aa881998", "blueprints/us-equities/adaptive-paper/simulation.py": "085e578e9fabe03ccefdc68b4796b9cf5012aca8d832d65107e4b95e0a80621b", - "blueprints/us-equities/adaptive-paper/strategies.py": "56e9f639f61111282de3d0795908ea11719a22058899cc59f8839e10529f86cf", - "blueprints/us-equities/adaptive-paper/strategies_v1.py": "5721d077d321347d769ad0b35ccd75ffb805076f3d6fc11d9a59168334e98a8c", - "blueprints/us-equities/adaptive-paper/transport.py": "7287de7b96ecfb4b87776c63d7cf1de247065c751ee23646d441fdb651193b9a", + "blueprints/us-equities/adaptive-paper/strategies.py": "2d488a4fdd21ad323ab07cf7fb147b3623fd6e38951abcf2fc59cf8094a1383e", + "blueprints/us-equities/adaptive-paper/strategies_v1.py": "1952be269694485e12e4d927f124adca0ee29667588d6ac6651caede454c92f7", + "blueprints/us-equities/adaptive-paper/transport.py": "c75f7db3025200d55a166b8ec2811939d32518861118490ffc1a57eb7ba389fa", "tests/test_adaptive_market_research.py": "dfd7a09380a7b1b10633c2edf05daf5a35bfcb48498fff130a2d84ff7190e395", "tests/test_adaptive_paper_exits.py": "13a82d65ad1b2e72a8ca0fbb8bbb178a01ded1301399604c9e304ae213456764", "tests/test_adaptive_paper_metrics.py": "8b0627f752ddf11c87005200e76d0ad4873261905ba073163476c66659d564f9", - "tests/test_adaptive_paper_native.py": "8612ffe4f91e7f52d38fd9b98adbf74b95a115433b6b36bbff57fc24b150e86f", + "tests/test_adaptive_paper_native.py": "82aaac99388368d86af62d839de6503368bcee6ec76896f7496ff861112a05f6", "tests/test_adaptive_paper_recovery.py": "d10af6928bf3ba946c82e632e545ee9eb53a1feea275b2e20a818c283eb1b6aa", - "tests/test_adaptive_paper_runner.py": "859ef8541e3723b5296be51b5f723ff34e34fd707eb34afde493e0b1a6a0e54a", - "tests/test_adaptive_paper_safety.py": "4bb1c10347b21fc08f27a3f33e02172f5d570ae9bdced2dd8c16e1eb06b92555", + "tests/test_adaptive_paper_runner.py": "0d04b047bc2aba3ae5c30c4a1471d79c21183c6b99365f9f638e3110d7072cbe", + "tests/test_adaptive_paper_safety.py": "7d0cc648452d0c832ebee67b759018ff64a851f4386112c79577e9758b9f6cc2", "tests/test_adaptive_paper_selector.py": "27b431ac7911372d050f25d32ac24a3df9d2cf3c5e7dafdebfe51db6cab55936", "tests/test_adaptive_paper_sessions.py": "6fb5f03cdbdb7fd4cf0105cfd4002a41ae0adb1ef18770f0bdaaae054661e92b", - "tests/test_adaptive_paper_strategies.py": "76319884c13693ef82f976a06ddfb69973029d4386e88d667b84bf9b382ccffa", - "tests/test_adaptive_paper_transport.py": "b0a5e05bb7a6122e28267ae72a3be6e0e710881cf1b97e0758229021460bbab5" + "tests/test_adaptive_paper_strategies.py": "f0814b36909f2fc6b4c4213b4142ffc13339740f0397c99ad9c7c81982e2033a", + "tests/test_adaptive_paper_transport.py": "b4aee7d58fe7ae44cab81d2f4ff5db8afce7960980c6e02172ee0ce103f08262" } diff --git a/blueprints/us-equities/adaptive-paper/strategies.py b/blueprints/us-equities/adaptive-paper/strategies.py index 45a093b23..85c84f13d 100644 --- a/blueprints/us-equities/adaptive-paper/strategies.py +++ b/blueprints/us-equities/adaptive-paper/strategies.py @@ -33,11 +33,13 @@ SelectorConfig, StrategySpec, ) +from leverage import LeverageInputs, LeveragePolicy __all__ = [ "AdaptivePolicy", "AdaptivePolicyV1", "PolicyConfig", "Sample", "Signal", "Decision", "Holding", "limit_price", "Decimal", "QUOTE_FUTURE_TOLERANCE_SECONDS", "DecisionInputs", "OperationalStatus", "RegimeSelector", "SelectorConfig", "StrategySpec", + "LeverageInputs", "LeveragePolicy", ] # Kept importable under this name too, for callers/tests that want the @@ -59,7 +61,7 @@ class only adds a synthetic (evidence class SYN), not-yet-accepted """ def __init__(self, config: PolicyConfig, strategy_pool: tuple[StrategySpec, ...] = (), - selector: RegimeSelector | None = None): + selector: RegimeSelector | None = None, leverage_policy: LeveragePolicy | None = None): strategy_pool = tuple(strategy_pool) if selector is not None and not strategy_pool: # A selector with nothing to select would gate allow_entries on @@ -67,10 +69,20 @@ def __init__(self, config: PolicyConfig, strategy_pool: tuple[StrategySpec, ...] # silently blocking all entries. Fail loudly at construction # instead of producing a policy that never trades. raise ValueError("selector requires a non-empty strategy_pool") + # G-e: leverage_policy and config.leverage_policy_id must agree -- + # neither "a validated policy with no id recorded on the config" + # nor "an id recorded with no policy object supplied" is a state + # runner.py's load_config (the only production constructor path) + # can produce; catching the mismatch here fails loudly for a + # malformed direct construction instead. + if (leverage_policy is None) != (config.leverage_policy_id is None): + raise ValueError("leverage_policy_config_mismatch") super().__init__(config) self.strategy_pool = strategy_pool self.selector = selector self.last_selector_decision = None + self.leverage_policy = leverage_policy + self.last_leverage_ceiling = None def _evaluate_pool(self, regime: str, risk_off: bool, signals: tuple[Signal, ...]): """Synthetic, deterministic candidate scoring for the shipped @@ -91,9 +103,10 @@ def _evaluate_pool(self, regime: str, risk_off: bool, signals: tuple[Signal, ... return candidate.id, confidence, 0.0 def decide(self, now: float, *, allow_entries: bool = True, force_exit: bool = False, - operational: OperationalStatus | None = None) -> Decision | None: + operational: OperationalStatus | None = None, + leverage_inputs: LeverageInputs | None = None) -> Decision | None: c = self.config - if not self.strategy_pool and self.selector is None: + if not self.strategy_pool and self.selector is None and self.leverage_policy is None: # Exact original path: no wrapper computation, no extra reads. return super().decide(now, allow_entries=allow_entries, force_exit=force_exit) @@ -139,5 +152,28 @@ def decide(self, now: float, *, allow_entries: bool = True, force_exit: bool = F else: force_exit_reason = "trial_end" + ceiling = None + pending_buy_notional_usd = 0.0 + if self.leverage_policy is not None: + if leverage_inputs is None: + # Fail closed: with no observed session/drawdown/kill-switch + # inputs this tick, authorize no new risk rather than reuse + # a stale ceiling or fall back to the unbounded default. + ceiling = 0.0 + else: + ceiling = float(self.leverage_policy.ceiling( + session=leverage_inputs.session, regime=regime, + drawdown_fraction=leverage_inputs.drawdown_fraction, + kill_switch=leverage_inputs.kill_switch, + account_multiplier=leverage_inputs.account_multiplier)) + # LEV-RI-A (2026-09-22 leverage fix round 1): net out the + # notional of every still-resting, unfilled buy order from + # this tick's leveraged budget -- see + # strategies_v1._decide_core's own comment at the + # leverage_ceiling-gated `used` computation. + pending_buy_notional_usd = float(leverage_inputs.pending_buy_notional_usd) + self.last_leverage_ceiling = ceiling + return self._decide_core(now, allow_entries=entries_allowed, force_exit=force_exit, - force_exit_reason=force_exit_reason) + force_exit_reason=force_exit_reason, leverage_ceiling=ceiling, + pending_buy_notional_usd=pending_buy_notional_usd) diff --git a/blueprints/us-equities/adaptive-paper/strategies_v1.py b/blueprints/us-equities/adaptive-paper/strategies_v1.py index db77892c9..0c6b698ab 100644 --- a/blueprints/us-equities/adaptive-paper/strategies_v1.py +++ b/blueprints/us-equities/adaptive-paper/strategies_v1.py @@ -24,6 +24,7 @@ import statistics from exits import DEFAULT_PLAN, ExitContext +from leverage import LEVERAGE_POLICY_VERSION # Quotes may arrive up to this far ahead of the caller's `now` (clock skew # across processes/threads) and still count as fresh; anything further ahead @@ -135,6 +136,14 @@ class PolicyConfig: # cancel/resubmit), relying entirely on that resting order's own # eventual fill/expire/recovery path. exit_replace_enabled: bool = False + # G-e: opt-in leverage-schedule id (leverage.LEVERAGE_POLICY_VERSION), + # threaded through by strategies.AdaptivePolicy/runner.load_config only + # when a validated leverage_policy config block is present. None (every + # shipped default) keeps max_leverage bounded at <=2 below, exactly as + # before G-e; a string is never consulted by the generic numeric-bounds + # check further down (isinstance(v, (float, int)) excludes str), so its + # presence changes nothing about that loop. + leverage_policy_id: str | None = None def __post_init__(self): if (not self.symbols or len(set(self.symbols)) != len(self.symbols) @@ -169,9 +178,39 @@ def __post_init__(self): or self.exit_replace_min_interval_seconds < 0 or self.exit_replace_min_interval_seconds > 3600): raise ValueError("invalid_policy_number") + if self.leverage_policy_id is not None and self.leverage_policy_id != LEVERAGE_POLICY_VERSION: + raise ValueError("invalid_policy_bounds") + _max_leverage_bound = 4 if self.leverage_policy_id == LEVERAGE_POLICY_VERSION else 2 + # N1-0 (2026-09-22 leverage fix round 2): F2 (round 1) widened this + # bound to 100 for every config, not only under the opt-in leverage + # policy, which loosened the default (no leverage_policy_id) path + # a config with no leverage_policy block and max_order_quantity + # between 11 and 100 used to be refused here (runner.py's load_config + # passes max_order_quantity straight through as max_shares) and, + # after F2, was silently accepted instead. Gate the widened ceiling + # the same way as _max_leverage_bound just above: 100 only under the + # canonical leverage policy, 10 (the original, pre-F2 bound) + # otherwise. Every shipped default config (config.json, + # config-sip.json) uses max_order_quantity=1, so this does not + # change their runtime behavior; only a hypothetical no-policy + # config with max_order_quantity in [11, 100] is affected. + _max_shares_bound = 100 if self.leverage_policy_id == LEVERAGE_POLICY_VERSION else 10 if (self.warmup_samples < 4 or self.history < self.warmup_samples - or self.max_leverage > 2 or self.gross_cap > self.capital * self.max_leverage - or self.max_positions > len(self.symbols) or self.max_shares > 10 + or self.max_leverage > _max_leverage_bound or self.gross_cap > self.capital * self.max_leverage + # F2: matches safety.RiskLimits' own max_order_qty<=100 bound + # and effective_max_order_qty's hard 100-share ceiling + # (safety.py:132,198) -- this cap was 10 (well below that + # ceiling) until F2, which silently undercut + # max_order_qty_mode="notional" for any symbol priced so + # that max_order_notional_usd/price > 10 (e.g. a rung config + # whose max_order_notional_usd assumes a >10-share fill at + # that price never reaches it; see config-leverage-*.json's + # notes and tests/test_adaptive_paper_runner.py's + # LeverageRungConfigConsistencyTests for the corresponding + # per-rung sizing). Only reachable at 100 under the leverage + # policy (see _max_shares_bound above, N1-0); the default + # (no leverage_policy_id) path keeps the original 10 bound. + or self.max_positions > len(self.symbols) or self.max_shares > _max_shares_bound or self.minimum_price >= self.maximum_price or self.min_hold_seconds >= self.max_hold_seconds or self.stop_min_bps > self.stop_max_bps or self.trailing_min_bps > self.trailing_max_bps @@ -364,7 +403,8 @@ def _regime_and_signals(self, now: float): return features, complete, risk_off, regime, signals def _decide_core(self, now: float, *, allow_entries: bool, force_exit: bool, - force_exit_reason: str = "trial_end") -> Decision: + force_exit_reason: str = "trial_end", leverage_ceiling: float | None = None, + pending_buy_notional_usd: float = 0.0) -> Decision: c = self.config features, complete, risk_off, regime, signals = self._regime_and_signals(now) targets, exits = {}, {} @@ -392,8 +432,30 @@ def _decide_core(self, now: float, *, allow_entries: bool, force_exit: bool, # Only a fully warmed trending basket can use the configured ceiling; # other regimes reduce it. The independent ledger remains authoritative. leverage = min(c.max_leverage, 1 if regime == "range" else c.max_leverage) if regime not in ("risk_off", "unavailable") else 0 + if leverage_ceiling is not None: + leverage = min(leverage, leverage_ceiling) budget = max(0, min(c.gross_cap, c.capital * leverage) - c.max_order_notional) - used = sum(self.latest[s].ask * float(qty) for s, qty in targets.items() if s in self.latest) + # LEV-RI-A / CX-P1 (2026-09-22 leverage fix round 1): `targets` only + # reflects symbols this tick kept unchanged -- a same-tick exit + # decision removes a symbol from `targets` even though the position + # is still physically held until the sell fills, and an earlier + # tick's still-resting, unfilled buy order never reaches + # `self.holdings` at all (sync_positions syncs only from filled + # broker positions). Both are capital already committed, so a + # leveraged rung's budget must count every current holding + # regardless of this tick's exit decision, plus the caller-supplied + # notional of any still-resting unfilled buy order + # (native_strategy._leverage_inputs' ledger-sourced + # pending_buy_notional_usd, threaded through strategies.AdaptivePolicy + # .decide). Left byte-identical (targets-only, no pending term) on + # the non-leveraged path to preserve the golden-equivalence contract + # documented in strategies.py's module docstring. + if leverage_ceiling is not None: + used = sum(self.latest[s].ask * float(holding.quantity) + for s, holding in self.holdings.items() if s in self.latest) + used += pending_buy_notional_usd + else: + used = sum(self.latest[s].ask * float(qty) for s, qty in targets.items() if s in self.latest) if allow_entries and complete and not force_exit and not risk_off: for signal in signals: s = signal.symbol diff --git a/blueprints/us-equities/adaptive-paper/transport.py b/blueprints/us-equities/adaptive-paper/transport.py index 758bc2a82..bc951e590 100644 --- a/blueprints/us-equities/adaptive-paper/transport.py +++ b/blueprints/us-equities/adaptive-paper/transport.py @@ -161,12 +161,22 @@ def normalize_trading_status(raw): "ts_ns": timestamp_ns(raw.get("t", raw.get("timestamp")))} -def normalize_account(raw): +def normalize_account(raw, *, include_margin=False): result = {key: decimal_string(raw[key]) for key in ("cash", "equity", "buying_power")} for key in ("status", "currency", "trading_blocked", "account_blocked", "trade_suspended_by_user", "shorting_enabled", "pattern_day_trader"): if key in raw: result[key] = raw[key] + # G-e: only read for a leverage-policy preflight/reconciliation call + # (runner.py sets include_margin=True exactly when config has a + # validated _leverage_policy). False (every default call site) keeps + # this function's return keys byte-identical to before G-e. + if include_margin: + for key in ("multiplier", "daytrading_buying_power", "regt_buying_power"): + if key in raw: + result[key] = decimal_string(raw[key]) + if "daytrade_count" in raw: + result["daytrade_count"] = raw["daytrade_count"] return result @@ -293,7 +303,8 @@ def _sdk_client(api_key, secret_key, before_request, observer=None, *, data=Fals return client -def preflight(api_key, secret_key, symbols, *, feed="iex", before_request, request_observer=None): +def preflight(api_key, secret_key, symbols, *, feed="iex", before_request, request_observer=None, + include_margin=False): """Read-only native SDK preflight; callback is synchronous in this helper.""" from alpaca.data.enums import DataFeed from alpaca.data.requests import StockLatestQuoteRequest @@ -304,7 +315,7 @@ def preflight(api_key, secret_key, symbols, *, feed="iex", before_request, reque data = _sdk_client(api_key, secret_key, before_request, request_observer, data=True, read_only=True, lock=lock) try: raw_account = trading.get_account() - account = normalize_account(raw_account) + account = normalize_account(raw_account, include_margin=include_margin) import hashlib identity = hashlib.sha256(str(raw_account["id"]).encode()).hexdigest() clock = trading.get_clock() @@ -425,8 +436,12 @@ def __init__(self, api_key, secret_key, symbols, *, before_request, before_submi sink_observation, queue_size=1024, quote_timeout=5.0, start_timeout=15.0, order_update_timeout=10.0, request_observer=None, history_start=None, max_snapshot_pages=20, required_quote_symbols=None, feed="iex", - extended_hours_allowed=False): + extended_hours_allowed=False, include_margin=False): self.extended_hours_allowed = bool(extended_hours_allowed) + # G-e: forwarded to normalize_account on every snapshot() call. + # False (every default construction) keeps snapshot()'s account + # keys byte-identical to before G-e. + self.include_margin = bool(include_margin) self.feed = data_feed(feed) self.data_ws = data_stream_url(self.feed) self.symbols = _symbols(symbols) @@ -815,7 +830,7 @@ def _pages(self, status, *, after=None): async def snapshot(self): async with self._operation_lock: def collect(): - account = normalize_account(self._client.get_account()) + account = normalize_account(self._client.get_account(), include_margin=self.include_margin) positions = [{"symbol": p["symbol"], "qty": decimal_string(p["qty"]), "avg_entry_price": decimal_string(p["avg_entry_price"])} for p in self._client.get_all_positions()] diff --git a/tests/test_adaptive_paper_leverage.py b/tests/test_adaptive_paper_leverage.py new file mode 100644 index 000000000..2c26905e1 --- /dev/null +++ b/tests/test_adaptive_paper_leverage.py @@ -0,0 +1,356 @@ +"""Pure-module tests for the G-e leverage-schedule policy (leverage.py). + +No I/O, no broker/transport, no strategy/ledger fixtures here -- see +test_adaptive_paper_safety.py, test_adaptive_paper_strategies.py and +test_adaptive_paper_runner.py for the enforcement-point integration tests. +""" +from decimal import Decimal as D +import importlib.util +from itertools import product +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +FILE = ROOT / "blueprints/us-equities/adaptive-paper/leverage.py" +SPEC = importlib.util.spec_from_file_location("adaptive_paper_leverage", FILE) +L = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = L +SPEC.loader.exec_module(L) + + +def base_config(*, max_leverage="4", capital_usd="10000", max_gross_exposure_usd="40000", block=None): + return {"max_leverage": max_leverage, "capital_usd": capital_usd, + "max_gross_exposure_usd": max_gross_exposure_usd, + "leverage_policy": L.CANONICAL_V1_BLOCK if block is None else block} + + +def base_session_policy(*, overnight_holds=False, overnight_gross_multiple="1.0"): + return {"overnight_holds": overnight_holds, "overnight_gross_multiple": D(overnight_gross_multiple)} + + +def block_with(**changes): + import copy + block = copy.deepcopy(L.CANONICAL_V1_BLOCK) + block.update(changes) + return block + + +class CanonicalValidationTests(unittest.TestCase): + def test_canonical_block_validates_for_1_2_and_4x(self): + for lev in ("1", "2", "4"): + gross = str(int(D("10000") * D(lev))) + config = base_config(max_leverage=lev, max_gross_exposure_usd=gross) + policy = L.validate_leverage_policy(config, base_session_policy()) + self.assertEqual(policy.max_leverage, D(lev)) + self.assertFalse(policy.apply_overnight_cap) + + def test_canonical_block_validates_with_overnight_holds_true(self): + # capital*overnight_max_leverage(2) = 20000; gross*multiple must fit. + config = base_config(max_leverage="2", max_gross_exposure_usd="20000") + policy = L.validate_leverage_policy(config, base_session_policy(overnight_holds=True)) + self.assertTrue(policy.apply_overnight_cap) + + +class RefusalTests(unittest.TestCase): + def assertRefused(self, config, reason, session_policy=None): + with self.assertRaises(L.LeveragePolicyError) as ctx: + L.validate_leverage_policy(config, session_policy or base_session_policy()) + self.assertEqual(str(ctx.exception), reason) + + def test_version_mismatch(self): + self.assertRefused(base_config(block=block_with(version="other")), "leverage_policy_version_mismatch") + + def test_unknown_key(self): + block = block_with() + block["extra"] = "x" + self.assertRefused(base_config(block=block), "leverage_policy_keys") + + def test_missing_required_key(self): + block = block_with() + del block["overnight_max_leverage"] + self.assertRefused(base_config(block=block), "leverage_policy_keys") + + def test_schedule_missing_a_session(self): + block = block_with() + del block["schedule"]["CLOSED"] + self.assertRefused(base_config(block=block), "leverage_schedule_shape") + + def test_schedule_missing_a_regime(self): + block = block_with() + del block["schedule"]["RTH"]["range"] + self.assertRefused(base_config(block=block), "leverage_schedule_shape") + + def test_schedule_extra_regime(self): + block = block_with() + block["schedule"]["RTH"]["extra"] = "1" + self.assertRefused(base_config(block=block), "leverage_schedule_shape") + + def test_cell_value_float_refused(self): + block = block_with() + block["schedule"]["RTH"]["trend"] = 4.0 + self.assertRefused(base_config(block=block), "leverage_value_invalid") + + def test_cell_value_int_refused(self): + block = block_with() + block["schedule"]["RTH"]["trend"] = 4 + self.assertRefused(base_config(block=block), "leverage_value_invalid") + + def test_cell_value_bool_refused(self): + block = block_with() + block["schedule"]["RTH"]["trend"] = True + self.assertRefused(base_config(block=block), "leverage_value_invalid") + + def test_cell_value_nan_refused(self): + block = block_with() + block["schedule"]["RTH"]["trend"] = "NaN" + self.assertRefused(base_config(block=block), "leverage_value_invalid") + + def test_cell_value_negative_refused(self): + block = block_with() + block["schedule"]["RTH"]["trend"] = "-1" + self.assertRefused(base_config(block=block), "leverage_value_invalid") + + def test_cell_value_more_than_2dp_refused(self): + block = block_with() + block["schedule"]["RTH"]["trend"] = "1.001" + self.assertRefused(base_config(block=block), "leverage_value_invalid") + + def test_cell_value_above_4_refused(self): + block = block_with() + block["schedule"]["RTH"]["trend"] = "4.01" + self.assertRefused(base_config(block=block), "leverage_value_invalid") + + def test_risk_off_nonzero_refused(self): + block = block_with() + block["schedule"]["RTH"]["risk_off"] = "1" + self.assertRefused(base_config(block=block), "leverage_nonzero_in_risk_off") + + def test_unavailable_nonzero_refused(self): + block = block_with() + block["schedule"]["PRE"]["unavailable"] = "1" + self.assertRefused(base_config(block=block), "leverage_nonzero_in_risk_off") + + def test_closed_nonzero_refused(self): + block = block_with() + block["schedule"]["CLOSED"]["trend"] = "1" + self.assertRefused(base_config(block=block), "leverage_nonzero_when_closed") + + def test_range_above_engine_cap_refused(self): + block = block_with() + block["schedule"]["RTH"]["range"] = "2" + self.assertRefused(base_config(block=block), "leverage_range_exceeds_engine_cap") + + def test_overnight_max_leverage_zero_refused(self): + self.assertRefused(base_config(block=block_with(overnight_max_leverage="0")), + "leverage_overnight_out_of_bounds") + + def test_overnight_max_leverage_above_2_refused(self): + self.assertRefused(base_config(block=block_with(overnight_max_leverage="2.01")), + "leverage_overnight_out_of_bounds") + + def test_config_max_leverage_below_1_refused(self): + self.assertRefused(base_config(max_leverage="0.5"), "leverage_requested_out_of_bounds") + + def test_config_max_leverage_above_4_refused(self): + self.assertRefused(base_config(max_leverage="4.01", max_gross_exposure_usd="40000"), + "leverage_requested_out_of_bounds") + + def test_config_max_leverage_5_refused(self): + self.assertRefused(base_config(max_leverage="5", max_gross_exposure_usd="40000"), + "leverage_requested_out_of_bounds") + + # -- drawdown ladder refusals ------------------------------------------ + + def test_ladder_non_monotone_refused(self): + block = block_with(drawdown_ladder=[ + {"from_drawdown_fraction": "0", "max_leverage": "2"}, + {"from_drawdown_fraction": "0.5", "max_leverage": "4"}, + {"from_drawdown_fraction": "0.9", "max_leverage": "0"}]) + self.assertRefused(base_config(block=block), "leverage_ladder_not_monotone") + + def test_ladder_not_starting_at_zero_refused(self): + block = block_with(drawdown_ladder=[ + {"from_drawdown_fraction": "0.1", "max_leverage": "4"}, + {"from_drawdown_fraction": "0.9", "max_leverage": "0"}]) + self.assertRefused(base_config(block=block), "leverage_ladder_must_start_at_zero") + + def test_ladder_last_step_not_zero_refused(self): + block = block_with(drawdown_ladder=[ + {"from_drawdown_fraction": "0", "max_leverage": "4"}, + {"from_drawdown_fraction": "0.5", "max_leverage": "1"}]) + self.assertRefused(base_config(block=block), "leverage_ladder_must_end_at_zero") + + def test_ladder_thresholds_not_strictly_increasing_refused(self): + block = block_with(drawdown_ladder=[ + {"from_drawdown_fraction": "0", "max_leverage": "4"}, + {"from_drawdown_fraction": "0.5", "max_leverage": "2"}, + {"from_drawdown_fraction": "0.5", "max_leverage": "0"}]) + self.assertRefused(base_config(block=block), "leverage_ladder_thresholds") + + def test_ladder_threshold_at_or_above_1_refused(self): + block = block_with(drawdown_ladder=[ + {"from_drawdown_fraction": "0", "max_leverage": "4"}, + {"from_drawdown_fraction": "1", "max_leverage": "0"}]) + self.assertRefused(base_config(block=block), "leverage_ladder_thresholds") + + def test_ladder_first_step_above_4_refused(self): + block = block_with(drawdown_ladder=[ + {"from_drawdown_fraction": "0", "max_leverage": "4.5"}, + {"from_drawdown_fraction": "0.5", "max_leverage": "0"}]) + self.assertRefused(base_config(block=block), "leverage_value_invalid") + + def test_ladder_fewer_than_2_steps_refused(self): + block = block_with(drawdown_ladder=[{"from_drawdown_fraction": "0", "max_leverage": "4"}]) + self.assertRefused(base_config(block=block), "leverage_ladder_must_start_at_zero") + + # -- absolute notional consistency refusals ------------------------------ + + def test_gross_exceeds_capital_times_max_leverage_refused(self): + self.assertRefused(base_config(max_leverage="2", max_gross_exposure_usd="20000.01"), + "leverage_gross_cap_exceeds_capital_times_leverage") + + def test_overnight_gross_exceeds_regt_refused(self): + # capital 10000 * overnight_max_leverage 2 = 20000; gross*multiple must not exceed it. + config = base_config(max_leverage="2", max_gross_exposure_usd="20000") + session_policy = base_session_policy(overnight_holds=True, overnight_gross_multiple="1.5") + self.assertRefused(config, "leverage_overnight_gross_exceeds_regt", session_policy) + + # -- DP-2: shape/bounds-valid but non-canonical block refusal ----------- + + def test_shape_valid_non_canonical_block_refused(self): + """DP-2 finding scenario: a schedule/ladder that individually passes + every generic shape/bounds check above (RTH trend stays 4x, PRE/POST + trend also 4x, ladder steps straight to 0 at 0.99 drawdown instead of + the canonical 0.25/0.5/0.75 steps) must still be refused because it + is not the pre-registered CANONICAL_V1_BLOCK.""" + block = block_with( + schedule={ + "RTH": {"trend": "4", "range": "1", "risk_off": "0", "unavailable": "0"}, + "PRE": {"trend": "4", "range": "1", "risk_off": "0", "unavailable": "0"}, + "POST": {"trend": "4", "range": "1", "risk_off": "0", "unavailable": "0"}, + "CLOSED": {"trend": "0", "range": "0", "risk_off": "0", "unavailable": "0"}, + }, + drawdown_ladder=[ + {"from_drawdown_fraction": "0", "max_leverage": "4"}, + {"from_drawdown_fraction": "0.99", "max_leverage": "0"}, + ]) + self.assertRefused(base_config(block=block), "leverage_policy_block_not_canonical") + + def test_block_without_decision_record_still_compared_to_canonical(self): + import copy + block = copy.deepcopy(L.CANONICAL_V1_BLOCK) + del block["decision_record"] + # Still shape/bounds-valid (decision_record is optional) and equal to + # the canonical block modulo the dropped optional key -> accepted. + policy = L.validate_leverage_policy(base_config(block=block), base_session_policy()) + self.assertEqual(policy.max_leverage, D("4")) + # But a further, otherwise-valid deviation is still refused. + block["schedule"]["PRE"]["trend"] = "4" + self.assertRefused(base_config(block=block), "leverage_policy_block_not_canonical") + + +class PropertyGridTests(unittest.TestCase): + """session x regime x drawdown_fraction x max_leverage x overnight x kill.""" + + def policy(self, max_leverage, overnight_holds=False): + # With overnight_holds, gross is also bounded by capital*overnight_max_leverage(2). + bound = min(D(str(max_leverage)), D("2")) if overnight_holds else D(str(max_leverage)) + gross = str(int(D("10000") * bound)) + config = base_config(max_leverage=str(max_leverage), max_gross_exposure_usd=gross) + return L.validate_leverage_policy(config, base_session_policy(overnight_holds=overnight_holds)) + + def test_ceiling_equals_min_of_components(self): + for lev, overnight in product((1, 2, 4), (False, True)): + policy = self.policy(lev, overnight_holds=overnight) + for session, regime, frac in product(L.SESSIONS, L.REGIMES, (D("0"), D("0.1"), D("0.3"), D("0.6"), D("0.9"))): + expected = min(policy.max_leverage, policy._cell(session, regime), policy.ladder_step(frac)) + if overnight: + expected = min(expected, policy.overnight_max_leverage) + expected = max(expected, L.ZERO) + got = policy.ceiling(session=session, regime=regime, drawdown_fraction=frac, kill_switch=False) + self.assertEqual(got, expected, (lev, overnight, session, regime, frac)) + + def test_ceiling_never_exceeds_envelope(self): + for lev, overnight in product((1, 2, 4), (False, True)): + policy = self.policy(lev, overnight_holds=overnight) + for session, regime, frac in product(L.SESSIONS, L.REGIMES, (D("0"), D("0.2"), D("0.5"), D("0.8"))): + ceiling = policy.ceiling(session=session, regime=regime, drawdown_fraction=frac, kill_switch=False) + envelope = policy.envelope(session=session, drawdown_fraction=frac) + self.assertLessEqual(ceiling, envelope) + + def test_ceiling_non_increasing_in_drawdown(self): + policy = self.policy(4) + fractions = [D("0"), D("0.1"), D("0.24"), D("0.25"), D("0.4"), D("0.5"), D("0.6"), D("0.74"), D("0.75"), D("0.9")] + values = [policy.ceiling(session="RTH", regime="trend", drawdown_fraction=f, kill_switch=False) for f in fractions] + self.assertEqual(values, sorted(values, reverse=True)) + + def test_kill_switch_zeroes_ceiling(self): + policy = self.policy(4) + self.assertEqual(policy.ceiling(session="RTH", regime="trend", drawdown_fraction=D("0"), kill_switch=True), L.ZERO) + + def test_risk_off_and_unavailable_zero(self): + policy = self.policy(4) + for regime in ("risk_off", "unavailable"): + self.assertEqual(policy.ceiling(session="RTH", regime=regime, drawdown_fraction=D("0"), kill_switch=False), L.ZERO) + + def test_closed_zero(self): + policy = self.policy(4) + for regime in L.REGIMES: + self.assertEqual(policy.ceiling(session="CLOSED", regime=regime, drawdown_fraction=D("0"), kill_switch=False), L.ZERO) + + def test_unknown_session_zero(self): + policy = self.policy(4) + self.assertEqual(policy.ceiling(session="XYZ", regime="trend", drawdown_fraction=D("0"), kill_switch=False), L.ZERO) + self.assertEqual(policy.envelope(session="XYZ", drawdown_fraction=D("0")), L.ZERO) + self.assertEqual(policy.ceiling(session=None, regime="trend", drawdown_fraction=D("0"), kill_switch=False), L.ZERO) + + def test_nan_or_negative_drawdown_zero(self): + policy = self.policy(4) + self.assertEqual(policy.ladder_step(D("NaN")), L.ZERO) + self.assertEqual(policy.ladder_step(D("-0.01")), L.ZERO) + + def test_overnight_cap_bounds_ceiling_at_2_in_every_session(self): + policy = self.policy(4, overnight_holds=True) + for session, regime in product(L.SESSIONS, L.REGIMES): + ceiling = policy.ceiling(session=session, regime=regime, drawdown_fraction=D("0"), kill_switch=False) + self.assertLessEqual(ceiling, D("2")) + + def test_account_multiplier_clamps_ceiling(self): + policy = self.policy(4) + full = policy.ceiling(session="RTH", regime="trend", drawdown_fraction=D("0"), kill_switch=False) + clamped = policy.ceiling(session="RTH", regime="trend", drawdown_fraction=D("0"), kill_switch=False, + account_multiplier=D("2")) + self.assertEqual(full, D("4")) + self.assertEqual(clamped, D("2")) + self.assertLess(clamped, full) + + +class NextLowerRungCeilingTests(unittest.TestCase): + """F2 (2026-09-22 residual review, reachability): leverage. + next_lower_rung_ceiling -- the pure rung-ladder lookup the runner's + achieved-leverage receipt uses to know what "the next rung down" means + for a given config's max_leverage.""" + + def test_1x_has_no_lower_rung(self): + self.assertIsNone(L.next_lower_rung_ceiling(D("1"))) + + def test_2x_next_lower_is_1x(self): + self.assertEqual(L.next_lower_rung_ceiling(D("2")), D("1")) + + def test_4x_next_lower_is_2x(self): + self.assertEqual(L.next_lower_rung_ceiling(D("4")), D("2")) + + def test_below_lowest_rung_has_no_lower_rung(self): + self.assertIsNone(L.next_lower_rung_ceiling(D("0.5"))) + self.assertIsNone(L.next_lower_rung_ceiling(D("0"))) + + def test_a_value_between_rungs_returns_the_rung_strictly_below_it(self): + # Not itself one of the pre-registered rungs, but the mapping stays + # total (every shipped config's max_leverage is 1, 2 or 4 in + # practice; this just documents the lookup does not require that). + self.assertEqual(L.next_lower_rung_ceiling(D("3")), D("2")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_adaptive_paper_native.py b/tests/test_adaptive_paper_native.py index b233ce754..a5d8fa2b5 100644 --- a/tests/test_adaptive_paper_native.py +++ b/tests/test_adaptive_paper_native.py @@ -3,6 +3,7 @@ from decimal import Decimal import importlib.util from pathlib import Path +import tempfile import time import unittest @@ -235,5 +236,131 @@ def test_native_equity_model_does_not_claim_fractional_precision(self): ADAPTER.shares("0.5") +if NATIVE: + ENGINE = Path(__file__).resolve().parents[1] / "blueprints/us-equities/adaptive-paper" + import sys as _sys + if str(ENGINE) not in _sys.path: + _sys.path.insert(0, str(ENGINE)) + from native_strategy import AdaptiveStrategy + from strategies import AdaptivePolicy, PolicyConfig + from safety import Ledger, RiskLimits, Quote, DEFAULT_STOP + import leverage as lev + + class _LeverageFixtureStrategy(AdaptiveStrategy): + """positions() needs a live nautilus cache/trader wiring this test + never sets up (no LiveNode is started); override with a plain dict + so the rest of AdaptiveStrategy's own (non-nautilus) logic -- + _leverage_inputs, rebalance()'s decide()/event_sink wiring -- can be + exercised directly and cheaply. Defined at module level (not nested + inside LeverageStrategyTests) so this module still imports cleanly + without nautilus_trader installed -- a class body referencing + AdaptiveStrategy would otherwise raise NameError at import time, + before @unittest.skipUnless ever gets a chance to skip anything.""" + def __init__(self, *a, held=None, **kw): + super().__init__(*a, **kw) + self._held = held or {} + + def positions(self): + return self._held + + +@unittest.skipUnless(NATIVE, "requires pinned combined native runtime") +class LeverageStrategyTests(unittest.TestCase): + """G-e E5: AdaptiveStrategy._leverage_inputs / rebalance() leverage-inputs + wiring, and the decision event's opt-in "leverage_ceiling" key.""" + + RTH_NOW = 1772550000.0 # 2026-03-03 15:00 UTC = 10:00 ET (RTH), 2026 calendar + + def leverage_policy(self, max_leverage="4"): + config = {"max_leverage": max_leverage, "capital_usd": "10000", + "max_gross_exposure_usd": str(10000 * int(max_leverage)), "leverage_policy": lev.CANONICAL_V1_BLOCK} + session_policy = {"overnight_holds": False, "overnight_gross_multiple": Decimal("1.0")} + return lev.validate_leverage_policy(config, session_policy) + + def make_ledger(self, leverage_policy=None, tmp=None): + limits = RiskLimits(capital_usd=Decimal("10000"), + max_gross_exposure_usd=Decimal("40000") if leverage_policy else Decimal("5000"), + leverage=leverage_policy) + ledger = Ledger(Path(tmp) / "lev.sqlite3", limits) + ledger.start_trial(self.RTH_NOW) + return ledger + + def make_strategy(self, *, with_policy, tmp, held=None, account_multiplier=None): + leverage_policy = self.leverage_policy() if with_policy else None + ledger = self.make_ledger(leverage_policy, tmp) + config = PolicyConfig(symbols=("SPY", "QQQ", "IWM", "DIA", "AAPL", "MSFT"), max_positions=6, + capital=10000, gross_cap=40000 if with_policy else 5000, + max_leverage=4.0 if with_policy else 1.0, + leverage_policy_id=lev.LEVERAGE_POLICY_VERSION if with_policy else None) + policy = AdaptivePolicy(config, leverage_policy=leverage_policy) + events = [] + strategy = _LeverageFixtureStrategy(policy, ledger, "t1", event_sink=events.append, + account_multiplier=account_multiplier, held=held, + clock=lambda: self.RTH_NOW) + strategy.started = True + strategy.enabled = True + return strategy, ledger, events + + def test_leverage_inputs_reports_rth_session_and_zero_drawdown(self): + with tempfile.TemporaryDirectory() as tmp: + strategy, ledger, _ = self.make_strategy(with_policy=True, tmp=tmp) + inputs = strategy._leverage_inputs(self.RTH_NOW) + self.assertEqual(inputs.session, "RTH") + self.assertEqual(inputs.drawdown_fraction, Decimal("0")) + self.assertFalse(inputs.kill_switch) + ledger.close() + + def test_leverage_inputs_kill_switch_from_stop_file(self): + with tempfile.TemporaryDirectory() as tmp: + strategy, ledger, _ = self.make_strategy(with_policy=True, tmp=tmp) + stop = Path(tmp) / "STOP" + stop.write_text("halt") + strategy.stop_file = stop + self.assertTrue(strategy._leverage_inputs(self.RTH_NOW).kill_switch) + ledger.close() + + def test_leverage_inputs_kill_switch_from_halted_reason(self): + with tempfile.TemporaryDirectory() as tmp: + strategy, ledger, _ = self.make_strategy(with_policy=True, tmp=tmp) + ledger.freeze("manual_halt_for_test") + self.assertTrue(strategy._leverage_inputs(self.RTH_NOW).kill_switch) + ledger.close() + + def test_leverage_inputs_carries_account_multiplier(self): + with tempfile.TemporaryDirectory() as tmp: + strategy, ledger, _ = self.make_strategy(with_policy=True, tmp=tmp, account_multiplier=Decimal("2")) + self.assertEqual(strategy._leverage_inputs(self.RTH_NOW).account_multiplier, Decimal("2")) + ledger.close() + + def test_rebalance_decision_event_has_no_leverage_key_by_default(self): + with tempfile.TemporaryDirectory() as tmp: + strategy, ledger, events = self.make_strategy(with_policy=False, tmp=tmp) + strategy.rebalance(self.RTH_NOW) + decision_events = [e for e in events if e.get("type") == "decision"] + self.assertTrue(decision_events) + self.assertNotIn("leverage_ceiling", decision_events[-1]) + ledger.close() + + def test_rebalance_decision_event_has_leverage_ceiling_under_policy(self): + with tempfile.TemporaryDirectory() as tmp: + strategy, ledger, events = self.make_strategy(with_policy=True, tmp=tmp) + strategy.rebalance(self.RTH_NOW) + decision_events = [e for e in events if e.get("type") == "decision"] + self.assertTrue(decision_events) + self.assertIn("leverage_ceiling", decision_events[-1]) + ledger.close() + + def test_stop_file_gives_decided_ceiling_of_zero(self): + with tempfile.TemporaryDirectory() as tmp: + strategy, ledger, events = self.make_strategy(with_policy=True, tmp=tmp) + stop = Path(tmp) / "STOP" + stop.write_text("halt") + strategy.stop_file = stop + strategy.rebalance(self.RTH_NOW) + decision_events = [e for e in events if e.get("type") == "decision"] + self.assertEqual(decision_events[-1]["leverage_ceiling"], 0.0) + ledger.close() + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_adaptive_paper_runner.py b/tests/test_adaptive_paper_runner.py index 2b6044bec..ca240b57c 100644 --- a/tests/test_adaptive_paper_runner.py +++ b/tests/test_adaptive_paper_runner.py @@ -23,6 +23,15 @@ from safety import Ledger, Quote, RiskLimits, SafetyError from strategies import PolicyConfig from simulation import SimulatedPort + # EH-3/N1-0 (2026-09-22 leverage fix round 2): module references (not just + # the names run_native/Ledger already pull in) needed to pin every + # session_at() lookup the achieved-leverage integration tests exercise -- + # native_strategy._leverage_inputs and safety.Ledger._leverage_envelope + # each hold their own `session_at`/`_session_at` binding, imported + # separately from the sessions module, so patching runner_module alone + # does not reach them. native_strategy imports nautilus_trader at module + # scope, so it is only importable inside this NATIVE-gated block. + import native_strategy as native_strategy_module NATIVE = True except ImportError: NATIVE = False @@ -31,6 +40,7 @@ # (those are only imported lazily inside run_native/recovery paths), so the # promotion-gate and credential-permission tests below run unconditionally. import runner as runner_module +import safety as safety_module from runner import credentials, validate_preflight as validate_preflight_always from safety import SafetyError as SafetyErrorAlways @@ -38,7 +48,7 @@ @unittest.skipUnless(NATIVE, "requires pinned combined native runtime") class IntegratedRunner(unittest.TestCase): def test_default_config_is_bounded(self): - config, risk, policy = load_config(SOURCE / "config.json") + config, risk, policy = runner_module.load_config(SOURCE / "config.json") self.assertEqual(risk.max_submits_per_minute, 180) self.assertEqual(policy.max_leverage, 1) self.assertEqual(len(policy.symbols), 24) @@ -1353,5 +1363,677 @@ def test_error_never_includes_file_contents(self): self.assertNotIn("fixture-secret", str(ctx.exception)) +def _scratch_registry(root): + """A minimal, self-contained registry.json + source-hashes.json whose + single (disabled) entry references a trivial scratch module -- avoids + depending on the real strategies_v1.py's pinned hash (a separate, + coordinator-owned repin step; see leverage.py's module docstring and + this task's own file-editing scope), the same pattern already used by + test_run_native_uses_the_load_config_validated_registry_not_the_default + above. load_config only needs *a* validly hash-pinned, syntactically + valid registry to pass strategy_pool_and_selector's fail-fast check; + it does not need the shipped strategies_v1.py module specifically.""" + root = Path(root) + module_bytes = b"x = 1\n" + (root / "mod.py").write_bytes(module_bytes) + (root / "source-hashes.json").write_text( + json.dumps({"mod.py": hashlib.sha256(module_bytes).hexdigest()})) + receipt_bytes = b"{}" + (root / "receipt.json").write_bytes(receipt_bytes) + entry = {"id": "scratch", "module": "mod", "receipt_path": "receipt.json", + "receipt_sha256": hashlib.sha256(receipt_bytes).hexdigest(), + "evidence_class": "SYN", "sessions": ["regular"], "enabled": False} + registry_path = root / "registry.json" + registry_path.write_text(json.dumps({"schema_version": 1, "strategies": [entry]})) + return registry_path + + +class LeverageLoadConfigTests(unittest.TestCase): + """G-e: load_config's opt-in leverage_policy gate (runner.py E1).""" + + def test_default_configs_unaffected(self): + with tempfile.TemporaryDirectory() as root: + registry_path = _scratch_registry(root) + for name in ("config.json", "config-sip.json"): + config, risk, policy = runner_module.load_config(SOURCE / name, registry_path=registry_path) + self.assertIsNone(risk.leverage) + self.assertIsNone(policy.leverage_policy_id) + self.assertNotIn("_leverage_policy", config) + + def test_max_leverage_2_without_block_still_refused(self): + with tempfile.TemporaryDirectory() as root: + registry_path = _scratch_registry(root) + raw = json.loads((SOURCE / "config.json").read_text()) + raw["max_leverage"] = "2" + path = Path(root) / "config.json" + path.write_text(json.dumps(raw)) + with self.assertRaisesRegex(ValueError, "unqualified_lane_configuration"): + runner_module.load_config(path, registry_path=registry_path) + + def test_valid_2x_and_4x_blocks_load(self): + with tempfile.TemporaryDirectory() as root: + registry_path = _scratch_registry(root) + for name, lev in (("config-leverage-2x.json", "2"), ("config-leverage-4x.json", "4")): + config, risk, policy = runner_module.load_config(SOURCE / name, registry_path=registry_path) + self.assertEqual(str(risk.leverage.max_leverage), lev) + self.assertEqual(policy.leverage_policy_id, risk.leverage.version) + self.assertIs(config["_leverage_policy"], risk.leverage) + + def test_invalid_block_raises_specific_leverage_policy_error_code(self): + import leverage as lev + with tempfile.TemporaryDirectory() as root: + registry_path = _scratch_registry(root) + raw = json.loads((SOURCE / "config-leverage-2x.json").read_text()) + raw["leverage_policy"]["version"] = "wrong" + path = Path(root) / "config.json" + path.write_text(json.dumps(raw)) + with self.assertRaises(lev.LeveragePolicyError) as ctx: + runner_module.load_config(path, registry_path=registry_path) + self.assertEqual(str(ctx.exception), "leverage_policy_version_mismatch") + + +class LeverageRungConfigConsistencyTests(unittest.TestCase): + """G-e Sec.5: rung configs differ from config-sip.json only in the + documented keys, share one identical canonical leverage_policy block, + and every widened numeric key satisfies its documented relationship.""" + + # F2 (round 2): max_gross_loss_usd/max_drawdown_usd now scale with each + # rung's leverage multiple (see config-leverage-*.json's notes), so they + # join the other per-rung-widened keys here. + ALLOWED_DIFF_KEYS = {"max_leverage", "max_gross_exposure_usd", "max_order_notional_usd", + "max_order_quantity", "max_order_qty_mode", "leverage_policy", "notes", + "max_gross_loss_usd", "max_drawdown_usd"} + + def test_rungs_differ_only_in_allowed_keys(self): + import leverage as lev + base = json.loads((SOURCE / "config-sip.json").read_text()) + for name in ("config-leverage-1x.json", "config-leverage-2x.json", "config-leverage-4x.json"): + c = json.loads((SOURCE / name).read_text()) + diff = {k for k in set(base) | set(c) if base.get(k) != c.get(k)} + self.assertLessEqual(diff, self.ALLOWED_DIFF_KEYS, name) + self.assertEqual(c["leverage_policy"], lev.CANONICAL_V1_BLOCK, name) + + def test_leverage_policy_blocks_identical_across_rungs(self): + blocks = [json.loads((SOURCE / n).read_text())["leverage_policy"] + for n in ("config-leverage-1x.json", "config-leverage-2x.json", "config-leverage-4x.json")] + self.assertEqual(blocks[0], blocks[1]) + self.assertEqual(blocks[1], blocks[2]) + + def test_rung_numeric_relationships(self): + base = json.loads((SOURCE / "config-sip.json").read_text()) + for name, multiple in (("config-leverage-1x.json", 1), ("config-leverage-2x.json", 2), + ("config-leverage-4x.json", 4)): + c = json.loads((SOURCE / name).read_text()) + capital = Decimal(c["capital_usd"]) + self.assertEqual(Decimal(c["max_gross_exposure_usd"]), capital * multiple) + self.assertEqual(Decimal(c["max_order_notional_usd"]), + Decimal(c["max_gross_exposure_usd"]) / c["max_held_symbols"]) + # F2 (round 2): max_gross_loss_usd/max_drawdown_usd scale with + # the rung's leverage multiple (base config-sip.json's $25 x + # multiple) so the fraction of the cap a given entry-spread + # mark-to-market loss consumes -- and thus the drawdown + # ladder's runway before the hard halt -- is the same at every + # rung; see config-leverage-*.json's notes. + self.assertEqual(Decimal(c["max_gross_loss_usd"]), Decimal(base["max_gross_loss_usd"]) * multiple) + self.assertEqual(Decimal(c["max_drawdown_usd"]), Decimal(base["max_drawdown_usd"]) * multiple) + self.assertEqual(c["max_order_qty_mode"], "notional") + + def test_rung_loss_cap_absorbs_full_gross_spread_cost_with_ladder_room(self): + """F2 (round 2): the scaled max_drawdown_usd must leave the + drawdown ladder room to step down (not an immediate hard halt) when + the rung's full gross exposure is marked at the worst allowed entry + spread -- the exact scenario the round-1 finding named (entry + spread on ~$40k gross could trip the $25 hard halt before 4x + exposure was ever shown).""" + for name, multiple in (("config-leverage-1x.json", 1), ("config-leverage-2x.json", 2), + ("config-leverage-4x.json", 4)): + c = json.loads((SOURCE / name).read_text()) + gross = Decimal(c["max_gross_exposure_usd"]) + spread_cost = gross * Decimal(c["max_spread_bps"]) / Decimal("10000") + max_drawdown = Decimal(c["max_drawdown_usd"]) + self.assertLess(spread_cost, max_drawdown, name) + drawdown_fraction = spread_cost / max_drawdown + # Still inside the ladder (below the 75% -> 0x step, and well + # below the 100% hard halt), so de-leveraging, not an immediate + # halt, is what the worst-case entry spread alone can trigger. + self.assertLess(drawdown_fraction, Decimal("0.75"), name) + + def test_rung_max_order_quantity_matches_engine_share_ceiling(self): + """F2: max_order_quantity must not undercut max_order_qty_mode + ='notional' below its own designed ceiling -- PolicyConfig.max_shares + (strategies_v1.py) and RiskLimits.max_order_qty (safety.py) both cap + an order at 100 shares; a rung's max_order_quantity of 10 silently + capped effective_max_order_qty's min(max_order_qty, notional/price, + 100) at 10 for any symbol priced above max_order_notional_usd/10, + making the schedule's notional ceiling unreachable for most of the + symbol universe (see config-leverage-4x.json's notes).""" + for name in ("config-leverage-1x.json", "config-leverage-2x.json", "config-leverage-4x.json"): + c = json.loads((SOURCE / name).read_text()) + self.assertEqual(c["max_order_quantity"], 100, name) + # A $50 stock at this rung's max_order_notional_usd should be + # sizeable to (near) the full notional, not truncated to 10 + # shares ($500) the way max_order_quantity=10 would have forced. + notional = Decimal(c["max_order_notional_usd"]) + implied_shares_at_50 = int(notional / Decimal("50")) + self.assertGreater(min(c["max_order_quantity"], implied_shares_at_50, 100), 10, name) + + def test_rung_policy_config_max_order_notional_matches_configured_value(self): + """CX-P2 (2026-09-22 leverage fix round 1): PolicyConfig.max_order_notional + (strategies_v1.py's own entry-allocation clamp) used to silently keep + its 1000 default regardless of each rung's larger configured + max_order_notional_usd (RiskLimits already received it) -- entries + could then never size up to what the ledger-side cap would allow. + load_config's returned PolicyConfig must carry the exact configured + value for every shipped config, not just the default-1000 ones.""" + with tempfile.TemporaryDirectory() as root: + registry_path = _scratch_registry(root) + for name in ("config.json", "config-sip.json", "config-leverage-1x.json", + "config-leverage-2x.json", "config-leverage-4x.json"): + config, _, policy = runner_module.load_config(SOURCE / name, registry_path=registry_path) + self.assertEqual(policy.max_order_notional, float(config["max_order_notional_usd"]), name) + + def test_policy_config_accepts_the_widened_100_share_ceiling_under_leverage_policy(self): + """F2: strategies_v1.PolicyConfig must allow max_shares up to 100 + (matching safety.RiskLimits.max_order_qty's own <=100 bound and + effective_max_order_qty's hard 100-share ceiling), not the old + (undocumented, tighter) 10-share validation ceiling -- but only + under the opt-in canonical leverage policy (N1-0, round 2): the + widened ceiling must not loosen the default (no leverage_policy_id) + path, which keeps the original 10-share bound below.""" + import strategies_v1 + symbols = ("SPY", "QQQ", "IWM", "DIA") + config = strategies_v1.PolicyConfig(symbols=symbols, max_positions=len(symbols), max_shares=100, + leverage_policy_id=strategies_v1.LEVERAGE_POLICY_VERSION) + self.assertEqual(config.max_shares, 100) + with self.assertRaises(ValueError): + strategies_v1.PolicyConfig(symbols=symbols, max_positions=len(symbols), max_shares=101, + leverage_policy_id=strategies_v1.LEVERAGE_POLICY_VERSION) + + def test_policy_config_keeps_the_10_share_bound_without_leverage_policy(self): + """N1-0 (round 2): a config with no leverage_policy block (the + default, no leverage_policy_id) must keep the original 10-share + max_shares ceiling -- F2 (round 1) widened this to 100 + unconditionally, which silently accepted a no-policy + max_order_quantity in [11, 100] that the pre-F2 validation refused + with invalid_policy_bounds (runner.py's load_config passes + max_order_quantity straight through as max_shares).""" + import strategies_v1 + symbols = ("SPY", "QQQ", "IWM", "DIA") + config = strategies_v1.PolicyConfig(symbols=symbols, max_positions=len(symbols), max_shares=10) + self.assertEqual(config.max_shares, 10) + with self.assertRaises(ValueError): + strategies_v1.PolicyConfig(symbols=symbols, max_positions=len(symbols), max_shares=11) + + +class AchievedLeverageReceiptStepTests(unittest.TestCase): + """F2 (2026-09-22 residual review, reachability): pure unit tests for + runner._leverage_achievement_step, the per-tick state update behind + outcome["leverage"]'s peak_achieved_leverage/ceiling_at_peak_achieved_leverage/ + seconds_above_next_lower_rung_ceiling fields. `strategies_v1._decide_core`'s + budget/inverse-volatility allocation means a rung's higher max_leverage + does not by construction force higher achieved exposure, so a "4x" + receipt could previously pass (needs_attention == 0) without ever + showing exposure above the 2x rung's own ceiling; this step function is + what now records whether it actually did.""" + + def _initial(self): + return dict(runner_module._INITIAL_LEVERAGE_ACHIEVEMENT_STATE) + + def test_first_tick_records_peak_and_ceiling_with_no_time_above_yet(self): + # dt_seconds=0 on the very first tick this run observed -- there is + # no preceding tick to measure an elapsed interval against, so + # nothing is attributed to seconds_above even though 3.5 already + # exceeds the next-lower (2x) rung's ceiling. + state = runner_module._leverage_achievement_step( + self._initial(), dt_seconds=0.0, achieved_leverage=Decimal("3.5"), + ceiling=Decimal("4"), next_lower_ceiling=Decimal("2")) + self.assertEqual(state["peak_achieved_leverage"], Decimal("3.5")) + self.assertEqual(state["ceiling_at_peak"], Decimal("4")) + self.assertEqual(state["seconds_above_next_lower_rung_ceiling"], 0.0) + + def test_time_accumulates_only_while_above_next_lower_ceiling(self): + state = self._initial() + state = runner_module._leverage_achievement_step( + state, dt_seconds=1.0, achieved_leverage=Decimal("1.5"), + ceiling=Decimal("4"), next_lower_ceiling=Decimal("2")) + self.assertEqual(state["seconds_above_next_lower_rung_ceiling"], 0.0) + state = runner_module._leverage_achievement_step( + state, dt_seconds=1.0, achieved_leverage=Decimal("2.5"), + ceiling=Decimal("4"), next_lower_ceiling=Decimal("2")) + self.assertEqual(state["seconds_above_next_lower_rung_ceiling"], 1.0) + state = runner_module._leverage_achievement_step( + state, dt_seconds=0.5, achieved_leverage=Decimal("3.0"), + ceiling=Decimal("4"), next_lower_ceiling=Decimal("2")) + self.assertEqual(state["seconds_above_next_lower_rung_ceiling"], 1.5) + # Dropping back below the threshold stops further accumulation but + # does not erase what was already recorded. + state = runner_module._leverage_achievement_step( + state, dt_seconds=2.0, achieved_leverage=Decimal("1.0"), + ceiling=Decimal("4"), next_lower_ceiling=Decimal("2")) + self.assertEqual(state["seconds_above_next_lower_rung_ceiling"], 1.5) + + def test_exactly_at_next_lower_ceiling_does_not_count_as_above(self): + state = runner_module._leverage_achievement_step( + self._initial(), dt_seconds=1.0, achieved_leverage=Decimal("2"), + ceiling=Decimal("4"), next_lower_ceiling=Decimal("2")) + self.assertEqual(state["seconds_above_next_lower_rung_ceiling"], 0.0) + + def test_no_next_lower_ceiling_never_accumulates(self): + """The 1x rung: leverage.next_lower_rung_ceiling(1) is None, so no + amount of dt/achieved leverage at this rung ever accumulates -- + there is no lower rung to have shown 1x exceeded.""" + state = self._initial() + for _ in range(5): + state = runner_module._leverage_achievement_step( + state, dt_seconds=1.0, achieved_leverage=Decimal("1.0"), + ceiling=Decimal("1"), next_lower_ceiling=None) + self.assertEqual(state["seconds_above_next_lower_rung_ceiling"], 0.0) + + def test_peak_and_its_ceiling_snapshot_never_move_backward(self): + state = runner_module._leverage_achievement_step( + self._initial(), dt_seconds=0.0, achieved_leverage=Decimal("2.0"), + ceiling=Decimal("2"), next_lower_ceiling=Decimal("1")) + self.assertEqual(state["peak_achieved_leverage"], Decimal("2.0")) + self.assertEqual(state["ceiling_at_peak"], Decimal("2")) + # A later, lower-achieved tick (even under a higher ceiling) must + # not move the recorded peak or the ceiling snapshot taken with it. + state = runner_module._leverage_achievement_step( + state, dt_seconds=1.0, achieved_leverage=Decimal("1.0"), + ceiling=Decimal("4"), next_lower_ceiling=Decimal("1")) + self.assertEqual(state["peak_achieved_leverage"], Decimal("2.0")) + self.assertEqual(state["ceiling_at_peak"], Decimal("2")) + + def test_nonpositive_dt_never_accumulates(self): + state = runner_module._leverage_achievement_step( + self._initial(), dt_seconds=1.0, achieved_leverage=Decimal("3"), + ceiling=Decimal("4"), next_lower_ceiling=Decimal("2")) + before = state["seconds_above_next_lower_rung_ceiling"] + self.assertGreater(before, 0.0) + for bad_dt in (0.0, -0.3): + state = runner_module._leverage_achievement_step( + state, dt_seconds=bad_dt, achieved_leverage=Decimal("3"), + ceiling=Decimal("4"), next_lower_ceiling=Decimal("2")) + self.assertEqual(state["seconds_above_next_lower_rung_ceiling"], before) + + +@unittest.skipUnless(NATIVE, "requires pinned combined native runtime") +class AchievedLeverageReceiptIntegrationTests(unittest.TestCase): + """F2 (2026-09-22 residual review, reachability): outcome["leverage"]'s + achieved-leverage fields, driven through run_native end-to-end (not + just the pure step function above) against the shipped rung configs.""" + + def _pinned_rth_session(self): + """EH-3/N1-0 (2026-09-22 leverage fix round 2): these achieved- + leverage assertions only hold while the run actually executes + during a session the canonical schedule (leverage.py's + CANONICAL_V1_BLOCK) gives a >1x trend ceiling to -- RTH. Nothing + upstream pinned the wall clock for this class (the only session_at + pins in this file were _held_ledger-adjacent, near lines 140 and + 1285), so left unpinned this class only passed while it happened to + run during a 2026 RTH session on the real clock, and failed closed + to a <=1x ceiling (PRE/POST) or 0x (CLOSED, or any date outside the + 2026 calendar) the rest of the time: native_strategy._leverage_ + inputs and safety.Ledger._leverage_envelope both classify + time.time() through their own session_at/_session_at import, and + leverage.py's ceiling()/envelope() fail closed to 0 on + session=None. + + Mirrors the pinning pattern used near line 1285 + (test_main_refuses_held_resume_when_broker_holds_what_the_ledger_ + does_not): the `ts` argument is ignored entirely and every + session_at() lookup this run's three call sites can make -- + runner.py's own loop, native_strategy._leverage_inputs and + safety.Ledger._leverage_envelope, each importing session_at + separately -- resolves against one fixed real RTH instant + regardless of when this test actually runs. Real elapsed time + (time.monotonic() loop control, the time.time()-based dt the + achieved-leverage accumulator uses) is left untouched, since only + the session classification is intercepted here, not the clock + itself. + """ + from contextlib import ExitStack + from datetime import datetime as _dt, timezone as _tz + from zoneinfo import ZoneInfo + frozen = _dt(2026, 3, 10, 11, 0, tzinfo=ZoneInfo("America/New_York")).astimezone(_tz.utc) + real_session_at = runner_module.session_at + stack = ExitStack() + stack.enter_context(patch.object(runner_module, "session_at", lambda _ts: real_session_at(frozen))) + stack.enter_context(patch.object(native_strategy_module, "session_at", + lambda _ts: real_session_at(frozen))) + stack.enter_context(patch.object(safety_module, "_session_at", lambda _ts: real_session_at(frozen))) + return stack + + def _fast_leveraged_policy(self, name, root): + registry_path = _scratch_registry(root) + config, risk, policy = runner_module.load_config(SOURCE / name, registry_path=registry_path) + config.update(duration_seconds=2, cleanup_seconds=2, order_timeout_seconds=1) + # EH-3 (2026-09-22 leverage fix round 1): use load_config's own + # `risk` (it carries the rung's actual RiskLimits.leverage, gross + # cap and notional-mode order sizing) instead of a bare + # `RiskLimits(trial_seconds=2, cleanup_seconds=2)`, which kept every + # other field at its class default -- leverage=None (so + # safety.Ledger._leverage_envelope was never even consulted), + # max_gross_exposure_usd=$5000 and max_order_qty=1 share fixed -- + # under which every rung's ledger behaved identically to the + # unleveraged default and could never show achieved exposure above + # about 0.5x of capital, so this test could not have caught a + # regression in the achieved-leverage tracking it exists to cover. + risk = replace(risk, trial_seconds=2, cleanup_seconds=2) + policy = replace(policy, warmup_samples=4, warmup_seconds=.06, sample_seconds=.02, + rebalance_seconds=.02, min_hold_seconds=.05, max_hold_seconds=.4, + cooldown_seconds=.05) + return config, risk, policy + + def test_leveraged_run_records_the_achieved_leverage_receipt_fields(self): + with self._pinned_rth_session(), tempfile.TemporaryDirectory() as root: + config, limits, policy = self._fast_leveraged_policy("config-leverage-1x.json", root) + ledger = Ledger(Path(root) / "journal.db", limits) + ledger.start_trial(time.time()) + controller = Controller(ledger, time.time() + 3600, market_open=True) + def price(symbol, tick): + slope = Decimal(".03") if symbol in policy.benchmarks else Decimal(".10") + return Decimal("100") + min(tick, 70) * slope + port = SimulatedPort(controller, policy.symbols, price=price) + controller.port = port + result = asyncio.run(run_native(controller, policy, [{"symbol": s} for s in policy.symbols], + "fixture", config, "100000")) + self.assertIn("leverage", result, result) + lev = result["leverage"] + # Every field this task adds is present alongside the + # already-shipped ones. + for key in ("next_lower_rung_ceiling", "peak_achieved_leverage", + "ceiling_at_peak_achieved_leverage", "seconds_above_next_lower_rung_ceiling"): + self.assertIn(key, lev, lev) + # The 1x rung has no lower rung to compare against (F2 design: + # leverage.next_lower_rung_ceiling(D("1")) is None), so this + # metric can never accumulate at this rung regardless of what + # was achieved. + self.assertIsNone(lev["next_lower_rung_ceiling"]) + self.assertEqual(lev["seconds_above_next_lower_rung_ceiling"], 0.0) + peak_achieved = Decimal(lev["peak_achieved_leverage"]) + ceiling_at_peak = Decimal(lev["ceiling_at_peak_achieved_leverage"]) + self.assertTrue(peak_achieved.is_finite()) + self.assertGreaterEqual(peak_achieved, Decimal("0")) + # The 1x rung's policy-layer ceiling never exceeds 1. + self.assertLessEqual(ceiling_at_peak, Decimal("1")) + ledger.close() + + def test_2x_rung_next_lower_ceiling_is_the_1x_rung(self): + with self._pinned_rth_session(), tempfile.TemporaryDirectory() as root: + config, limits, policy = self._fast_leveraged_policy("config-leverage-2x.json", root) + ledger = Ledger(Path(root) / "journal.db", limits) + ledger.start_trial(time.time()) + controller = Controller(ledger, time.time() + 3600, market_open=True) + def price(symbol, tick): + slope = Decimal(".03") if symbol in policy.benchmarks else Decimal(".10") + return Decimal("100") + min(tick, 70) * slope + port = SimulatedPort(controller, policy.symbols, price=price) + controller.port = port + result = asyncio.run(run_native(controller, policy, [{"symbol": s} for s in policy.symbols], + "fixture", config, "100000")) + lev = result["leverage"] + self.assertEqual(lev["next_lower_rung_ceiling"], "1") + self.assertGreaterEqual(lev["seconds_above_next_lower_rung_ceiling"], 0.0) + # EH-3: with the ledger actually wired to this rung's leverage + # policy (see _fast_leveraged_policy), SimulatedPort's immediate + # fills let the strategy build filled positions past 1x of + # capital inside this short trial -- exercising the real + # achieved-leverage path this receipt exists to evidence, + # instead of a ledger whose $5000 default gross cap could never + # let achieved exposure clear about 0.5x. N1-0/EH-3 (round 2): + # _pinned_rth_session above pins this at a real RTH instant so + # the schedule's RTH/trend ceiling (4) -- not PRE/POST/CLOSED's + # <=1x -- is actually in force, independent of when this test + # happens to run. + peak_achieved = Decimal(lev["peak_achieved_leverage"]) + self.assertGreater(peak_achieved, Decimal("1"), lev) + self.assertGreater(lev["seconds_above_next_lower_rung_ceiling"], 0.0, lev) + ledger.close() + + def test_resting_unfilled_buy_orders_do_not_count_as_achieved_leverage(self): + """EH-3 (round 2, second half): the guarantee runner.py's EH-1 fix + establishes -- filled_gross_exposure_usd excludes every not-yet- + filled buy intent's notional (runner.py's `filled_gross_exposure_ + usd = acct_now.gross_exposure_usd - acct_now.pending_buy_notional_ + usd`) -- was previously untested at the run_native level: nothing + in this file ever reads peak_pending_buy_notional_usd, and + SimulatedPort.submit fills every order synchronously and + immediately, so no existing test could tell this apart from the + pre-EH-1 code (reverting to the raw gross_exposure_usd, which + folds a resting buy's notional straight into achieved exposure, + passed every other integration test here). + + This test replaces SimulatedPort.submit with a variant that walks + the exact same admissions/observation path as the real submit() + (Controller.before_submit -> ledger.reserve_intent, + Controller.observe -> ledger.record_order, and the port's own + on_order callback -> native_adapter's nautilus-event generation -- + so this is a broker acknowledgment, not a dropped/unmodeled + submission) except the order is acknowledged at broker status + "new" with filled_qty "0" and self.cash/self.positions are never + updated. The intent's ledger status advances reserved -> new + (still not in safety.TERMINAL, per safety.RANK/record_order) and + stays there for the run's whole duration: a genuinely resting, + unfilled buy order, not a synchronously-filled one. + """ + from simulation import invoke as _invoke + with self._pinned_rth_session(), tempfile.TemporaryDirectory() as root: + config, limits, policy = self._fast_leveraged_policy("config-leverage-2x.json", root) + ledger = Ledger(Path(root) / "journal.db", limits) + ledger.start_trial(time.time()) + controller = Controller(ledger, time.time() + 3600, market_open=True) + def price(symbol, tick): + slope = Decimal(".03") if symbol in policy.benchmarks else Decimal(".10") + return Decimal("100") + min(tick, 70) * slope + port = SimulatedPort(controller, policy.symbols, price=price) + + async def submit_resting(payload): + """Mirrors SimulatedPort.submit exactly except it + acknowledges (status "new", filled_qty "0") instead of + filling, and never touches self.cash/self.positions.""" + port.controller.before_submit(payload) + await port.controller.before_request("submit", client_id=payload["client_order_id"]) + port.submitted_at.append(time.time()) + order = dict(payload, id=f"sim-{len(port.orders) + 1}", status="new", + filled_qty="0", filled_avg_price=None, updated_at_ns=time.time_ns()) + port.orders[payload["client_order_id"]] = order + port.controller.observe(order) + await _invoke(port.on_order, dict(order)) + return dict(order) + + port.submit = submit_resting + controller.port = port + result = asyncio.run(run_native(controller, policy, [{"symbol": s} for s in policy.symbols], + "fixture", config, "100000")) + self.assertIn("leverage", result, result) + lev = result["leverage"] + # A buy was actually attempted and reserved (otherwise this + # test proves nothing): some resting notional was seen. + self.assertGreater(Decimal(lev["peak_pending_buy_notional_usd"]), Decimal("0"), lev) + # None of it ever filled, so achieved leverage/exposure must + # stay at exactly zero -- this is the assertion that catches a + # revert of runner.py's EH-1 fix back to the raw (fill-blind) + # gross_exposure_usd. + self.assertEqual(lev["peak_gross_exposure_usd"], "0") + self.assertEqual(Decimal(lev["peak_achieved_leverage"]), Decimal("0"), lev) + ledger.close() + + def test_default_path_outcome_has_no_leverage_key(self): + """The absence half of this task's acceptance: a run with no + leverage_policy (both shipped default configs) must not gain this + section at all -- outcome/receipt shape is otherwise unchanged + (D1/F2 default-path-byte-identical requirement).""" + config, _, _ = load_config(SOURCE / "config.json") + config.update(duration_seconds=2, cleanup_seconds=2, order_timeout_seconds=1) + with tempfile.TemporaryDirectory() as root: + limits = RiskLimits(trial_seconds=2, cleanup_seconds=2) + ledger = Ledger(Path(root) / "journal.db", limits) + ledger.start_trial(time.time()) + controller = Controller(ledger, time.time() + 3600, market_open=True) + policy = PolicyConfig(symbols=("SPY", "QQQ", "IWM", "DIA", "AAPL", "MSFT"), max_positions=6, + warmup_samples=4, warmup_seconds=.06, sample_seconds=.02, + rebalance_seconds=.02, min_hold_seconds=.05, max_hold_seconds=.4, + cooldown_seconds=.05) + port = SimulatedPort(controller, policy.symbols) + controller.port = port + result = asyncio.run(run_native(controller, policy, [{"symbol": s} for s in policy.symbols], + "fixture", config, "100000")) + self.assertNotIn("leverage", result, result) + ledger.close() + + +class LeverageMarginEntitlementTests(unittest.TestCase): + """G-e E8: runner._check_margin_entitlement (pure, no network).""" + + def leverage_policy(self, max_leverage="4"): + import leverage as lev + config = {"max_leverage": max_leverage, "capital_usd": "10000", + "max_gross_exposure_usd": str(10000 * int(max_leverage)), + "leverage_policy": lev.CANONICAL_V1_BLOCK} + session_policy = {"overnight_holds": False, "overnight_gross_multiple": Decimal("1.0")} + return lev.validate_leverage_policy(config, session_policy) + + def config(self, max_leverage="4"): + return {"max_gross_exposure_usd": str(10000 * int(max_leverage)), "capital_usd": "10000"} + + def account(self, **overrides): + account = {"multiplier": "4", "daytrading_buying_power": "40000", + "regt_buying_power": "20000", "equity": "10000", "pattern_day_trader": False} + account.update(overrides) + return account + + def test_missing_margin_fields_refused(self): + with self.assertRaisesRegex(SafetyErrorAlways, "account_margin_fields_missing"): + runner_module._check_margin_entitlement({"equity": "10000"}, self.config(), + self.leverage_policy(), {"overnight_holds": False}) + + def test_multiplier_below_requested_refused(self): + with self.assertRaisesRegex(SafetyErrorAlways, "account_multiplier_below_requested_leverage"): + runner_module._check_margin_entitlement(self.account(multiplier="2"), self.config(), + self.leverage_policy("4"), {"overnight_holds": False}) + + def test_daytrading_buying_power_insufficient_refused(self): + with self.assertRaisesRegex(SafetyErrorAlways, "account_daytrading_buying_power_insufficient"): + runner_module._check_margin_entitlement(self.account(daytrading_buying_power="100"), self.config(), + self.leverage_policy("4"), {"overnight_holds": False}) + + def test_regt_buying_power_insufficient_refused_when_overnight_holds(self): + with self.assertRaisesRegex(SafetyErrorAlways, "account_regt_buying_power_insufficient"): + runner_module._check_margin_entitlement(self.account(regt_buying_power="100"), self.config(), + self.leverage_policy("2"), {"overnight_holds": True}) + + def test_pdt_with_low_equity_refused(self): + with self.assertRaisesRegex(SafetyErrorAlways, "account_restricted"): + runner_module._check_margin_entitlement( + self.account(pattern_day_trader=True, equity="10000"), self.config(), + self.leverage_policy("4"), {"overnight_holds": False}) + + def test_sufficient_account_returns_multiplier(self): + mult = runner_module._check_margin_entitlement(self.account(), self.config(), + self.leverage_policy("4"), {"overnight_holds": False}) + self.assertEqual(mult, Decimal("4")) + + +class LeveragePreflightIntegrationTests(unittest.TestCase): + """G-e: validate_preflight's margin-entitlement gate is opt-in on + config["_leverage_policy"] and mutates config with "_account_multiplier" + only then; the default path (no policy) is unaffected even when the + observed account lacks every margin field.""" + + def observation(self, account_overrides=None): + account = {"status": "ACTIVE", "currency": "USD", "trading_blocked": False, + "account_blocked": False, "trade_suspended_by_user": False, + "cash": "10000", "equity": "30000"} + if account_overrides: + account.update(account_overrides) + now_ns = int(time.time() * 1e9) + return {"account": account, + "clock": {"is_open": True, "timestamp_ns": now_ns, "received_at_ns": now_ns, + "next_close_ns": now_ns + 3600 * 10**9}, + "positions": [], "orders": [], + "assets": [{"symbol": s, "status": "active", "tradable": True} + for s in ("SPY", "QQQ", "IWM", "DIA")], + "quotes": [{"symbol": s, "ts_ns": now_ns} for s in ("SPY", "QQQ", "IWM", "DIA")]} + + def test_default_config_with_margin_less_account_still_ready(self): + config = {"capital_usd": "10000", "duration_seconds": 10, "cleanup_seconds": 5, + "symbols": ["SPY", "QQQ", "IWM", "DIA"], "benchmarks": ["SPY", "QQQ", "IWM", "DIA"], + "quote_max_age_seconds": 5} # no "_leverage_policy" key + validate_preflight_always(self.observation(), config, require_open=True) + self.assertNotIn("_account_multiplier", config) + + def test_under_policy_missing_margin_fields_refused_before_other_config_state(self): + import leverage as lev + block = lev.CANONICAL_V1_BLOCK + leverage_config = {"max_leverage": "4", "capital_usd": "10000", + "max_gross_exposure_usd": "40000", "leverage_policy": block} + policy = lev.validate_leverage_policy(leverage_config, {"overnight_holds": False, + "overnight_gross_multiple": Decimal("1.0")}) + config = {"capital_usd": "10000", "duration_seconds": 10, "cleanup_seconds": 5, + "symbols": ["SPY", "QQQ", "IWM", "DIA"], "benchmarks": ["SPY", "QQQ", "IWM", "DIA"], + "quote_max_age_seconds": 5, "max_gross_exposure_usd": "40000", "_leverage_policy": policy} + with self.assertRaisesRegex(SafetyErrorAlways, "account_margin_fields_missing"): + validate_preflight_always(self.observation(), config, require_open=True) + self.assertNotIn("_account_multiplier", config) + + def test_under_policy_sufficient_account_sets_account_multiplier(self): + import leverage as lev + block = lev.CANONICAL_V1_BLOCK + leverage_config = {"max_leverage": "4", "capital_usd": "10000", + "max_gross_exposure_usd": "40000", "leverage_policy": block} + policy = lev.validate_leverage_policy(leverage_config, {"overnight_holds": False, + "overnight_gross_multiple": Decimal("1.0")}) + config = {"capital_usd": "10000", "duration_seconds": 10, "cleanup_seconds": 5, + "symbols": ["SPY", "QQQ", "IWM", "DIA"], "benchmarks": ["SPY", "QQQ", "IWM", "DIA"], + "quote_max_age_seconds": 5, "max_gross_exposure_usd": "40000", "_leverage_policy": policy} + account_overrides = {"multiplier": "4", "daytrading_buying_power": "40000", + "regt_buying_power": "20000", "equity": "50000"} + validate_preflight_always(self.observation(account_overrides), config, require_open=True) + self.assertEqual(config["_account_multiplier"], Decimal("4")) + + def test_allow_existing_recovery_skips_margin_entitlement_even_when_missing(self): + """LEV-RI-1: --command recover only ever submits sells + (recovery.recover, recovery.py:168, "buy_submissions": 0), so a + broker that reports insufficient multiplier/DTBP/regT or a missing + margin field must not block sell-only recovery from flattening an + over-leveraged position (README-safety.md: sells/exits are never + refused by the leverage ceiling).""" + import leverage as lev + block = lev.CANONICAL_V1_BLOCK + leverage_config = {"max_leverage": "4", "capital_usd": "10000", + "max_gross_exposure_usd": "40000", "leverage_policy": block} + policy = lev.validate_leverage_policy(leverage_config, {"overnight_holds": False, + "overnight_gross_multiple": Decimal("1.0")}) + config = {"capital_usd": "10000", "duration_seconds": 10, "cleanup_seconds": 5, + "symbols": ["SPY", "QQQ", "IWM", "DIA"], "benchmarks": ["SPY", "QQQ", "IWM", "DIA"], + "quote_max_age_seconds": 5, "max_gross_exposure_usd": "40000", "_leverage_policy": policy} + observation = self.observation() + observation["positions"] = [{"symbol": "SPY"}] + validate_preflight_always(observation, config, require_open=True, allow_existing=True) + self.assertNotIn("_account_multiplier", config) + + def test_allow_existing_recovery_still_enforces_pdt_equity_floor_removal(self): + """The ordinary account-restriction check (status/blocked flags, + cash/equity floor) above the leverage gate already drops the equity + floor under allow_existing (runner.py ~497-498); confirm a + margin-restricted PDT account with low equity is not refused solely + by _check_margin_entitlement's own (now skipped) pattern_day_trader + branch during recovery.""" + import leverage as lev + block = lev.CANONICAL_V1_BLOCK + leverage_config = {"max_leverage": "4", "capital_usd": "10000", + "max_gross_exposure_usd": "40000", "leverage_policy": block} + policy = lev.validate_leverage_policy(leverage_config, {"overnight_holds": False, + "overnight_gross_multiple": Decimal("1.0")}) + config = {"capital_usd": "10000", "duration_seconds": 10, "cleanup_seconds": 5, + "symbols": ["SPY", "QQQ", "IWM", "DIA"], "benchmarks": ["SPY", "QQQ", "IWM", "DIA"], + "quote_max_age_seconds": 5, "max_gross_exposure_usd": "40000", "_leverage_policy": policy} + account_overrides = {"pattern_day_trader": True, "equity": "10000", "cash": "500"} + observation = self.observation(account_overrides) + observation["positions"] = [{"symbol": "SPY"}] + validate_preflight_always(observation, config, require_open=True, allow_existing=True) + self.assertNotIn("_account_multiplier", config) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_adaptive_paper_safety.py b/tests/test_adaptive_paper_safety.py index 6c47dd2c2..fe9570845 100644 --- a/tests/test_adaptive_paper_safety.py +++ b/tests/test_adaptive_paper_safety.py @@ -671,5 +671,190 @@ def test_next_trial_clears_only_completed_recovery_only_state(self): self.reserve() +class LeverageRiskLimitsTests(unittest.TestCase): + """G-e: RiskLimits.leverage / frozen_json() byte-identity and bounds.""" + + PINNED_DEFAULT_FROZEN_JSON = ( + '{"capital_usd": "10000", "cleanup_seconds": 120, "max_drawdown_usd": "25", ' + '"max_gross_exposure_usd": "5000", "max_gross_loss_usd": "25", "max_held_symbols": 10, ' + '"max_order_notional_usd": "1000", "max_order_qty": "1", "max_order_qty_mode": "fixed", ' + '"max_outstanding_orders": 20, "max_rest_per_minute": 200, "max_spread_bps": "15", ' + '"max_submits_per_minute": 180, "min_entry_close_seconds": 300, "overnight_gross_multiple": "1", ' + '"quote_max_age_seconds": 3, "trial_seconds": 300}') + + def leverage_policy(self, max_leverage="4", overnight_holds=False): + import leverage as lev + block = lev.CANONICAL_V1_BLOCK + capital, gross_multiple = D("10000"), min(D(max_leverage), D("2")) if overnight_holds else D(max_leverage) + config = {"max_leverage": max_leverage, "capital_usd": "10000", + "max_gross_exposure_usd": str(int(capital * gross_multiple)), "leverage_policy": block} + session_policy = {"overnight_holds": overnight_holds, "overnight_gross_multiple": D("1.0")} + return lev.validate_leverage_policy(config, session_policy) + + def test_default_frozen_json_matches_pinned_bdd04ca_literal(self): + self.assertEqual(s.RiskLimits().frozen_json(), self.PINNED_DEFAULT_FROZEN_JSON) + + def test_ledger_opens_with_pre_change_meta_limits_bytes(self): + db = Path(tempfile.mkdtemp()) / "pre.sqlite3" + ledger = s.Ledger(db) # writes the current (== pre-G-e for the default) frozen bytes + ledger.close() + reopened = s.Ledger(db) # must not raise persisted_risk_limits_differ + reopened.close() + + def test_risklimits_refuses_gross_above_capital_without_leverage_unchanged(self): + with self.assertRaises(s.SafetyError): + s.RiskLimits(max_gross_exposure_usd=D("10001")) # capital_usd default is 10000 + + def test_risklimits_with_2x_policy_accepts_exactly_2x_refuses_above(self): + policy = self.leverage_policy("2") + ok = s.RiskLimits(max_gross_exposure_usd=D("20000"), max_order_notional_usd=D("2000"), + leverage=policy) + self.assertEqual(ok.max_gross_exposure_usd, D("20000")) + with self.assertRaises(s.SafetyError): + s.RiskLimits(max_gross_exposure_usd=D("20000.01"), max_order_notional_usd=D("2000"), leverage=policy) + + def test_risklimits_refuses_leverage_above_4(self): + import leverage as lev + bad = replace(self.leverage_policy("4"), max_leverage=D("4.5")) + with self.assertRaises(s.SafetyError): + s.RiskLimits(max_gross_exposure_usd=D("20000"), max_order_notional_usd=D("2000"), leverage=bad) + + +class LeverageLedgerTests(unittest.TestCase): + """G-e: reserve_intent/validate_pending under a validated leverage policy. + + RTH timestamp 1772550000.0 = 2026-03-03 15:00 UTC (10:00 ET), a + classified RTH tick in sessions.py's frozen 2026 calendar. + """ + RTH_NOW = 1772550000.0 + + def tearDown(self): + tmp = getattr(self, "tmp", None) + if tmp is not None: + tmp.cleanup() + + def leverage_policy(self, max_leverage="4"): + import leverage as lev + capital = D("10000") + config = {"max_leverage": max_leverage, "capital_usd": "10000", + "max_gross_exposure_usd": str(int(capital * D(max_leverage))), + "leverage_policy": lev.CANONICAL_V1_BLOCK} + session_policy = {"overnight_holds": False, "overnight_gross_multiple": D("1.0")} + return lev.validate_leverage_policy(config, session_policy) + + def make_ledger(self, max_leverage="4", **overrides): + policy = self.leverage_policy(max_leverage) + capital = D("10000") + kwargs = dict(capital_usd=capital, max_gross_exposure_usd=capital * D(max_leverage), + max_order_notional_usd=D("10000"), max_order_qty=D("100"), + max_drawdown_usd=D("100"), max_gross_loss_usd=D("100"), leverage=policy) + kwargs.update(overrides) + limits = s.RiskLimits(**kwargs) + self.tmp = tempfile.TemporaryDirectory() + ledger = s.Ledger(Path(self.tmp.name) / "lev.sqlite3", limits) + ledger.start_trial(self.RTH_NOW) + return ledger + + def quote(self, symbol="SPY", bid="50", ask="50.02", at=None): + return s.Quote(symbol, bid, ask, self.RTH_NOW if at is None else at) + + def test_reserve_intent_under_2x_admits_to_equity_times_2_refuses_beyond(self): + ledger = self.make_ledger("2") + # equity == capital == 10000; envelope == 2 -> cap 20000. + ledger.reserve_intent("b1", "SPY", "buy", "100", "50.02", quote=self.quote(), + now=self.RTH_NOW, market_open=True, session_close=self.RTH_NOW + 3600) + ledger.reserve_intent("b2", "QQQ", "buy", "100", "50.02", quote=self.quote("QQQ"), + now=self.RTH_NOW, market_open=True, session_close=self.RTH_NOW + 3600) + # 2 x 5002 = 10004; a third same-size order would push gross past 20000. + ledger.reserve_intent("b3", "DIA", "buy", "100", "50.02", quote=self.quote("DIA"), + now=self.RTH_NOW, market_open=True, session_close=self.RTH_NOW + 3600) + # 3 x 5002 = 15006, still under 20000; a 4th pushes to 20008 > 20000. + with self.assertRaisesRegex(s.SafetyError, "aggregate_exposure_cap_exceeded"): + ledger.reserve_intent("b4", "IWM", "buy", "100", "50.02", quote=self.quote("IWM"), + now=self.RTH_NOW, market_open=True, session_close=self.RTH_NOW + 3600) + ledger.close() + + def test_order_notional_cap_still_binds_tighter_than_envelope(self): + ledger = self.make_ledger("4", max_order_notional_usd=D("500")) + with self.assertRaisesRegex(s.SafetyError, "order_size_cap_exceeded"): + ledger.reserve_intent("b1", "SPY", "buy", "100", "50.02", quote=self.quote(), + now=self.RTH_NOW, market_open=True, session_close=self.RTH_NOW + 3600) + ledger.close() + + def test_ladder_zero_blocks_buy_but_sells_and_no_risk_halt(self): + ledger = self.make_ledger("4") + ledger.reserve_intent("b1", "SPY", "buy", "2", "50.02", quote=self.quote(), + now=self.RTH_NOW, market_open=True, session_close=self.RTH_NOW + 3600) + ledger.record_order("b1", "broker-b1", "filled", "2", "50.02") + # Peak at bid=60 (unrealized ~+20), then mark down to bid=15 + # (unrealized ~-70) -> drawdown ~90 (fraction 0.9 of + # max_drawdown_usd=100), inside the ladder's zero step + # (>=0.75, canonical) but still below the hard 1.0 drawdown_cap_reached halt. + ledger.mark_to_market([s.Quote("SPY", "60", "60.02", self.RTH_NOW + 1)], self.RTH_NOW + 1) + low = ledger.mark_to_market([s.Quote("SPY", "15", "15.02", self.RTH_NOW + 2)], self.RTH_NOW + 2) + self.assertGreaterEqual(low.drawdown_usd, D("75")) + self.assertLess(low.drawdown_usd, D("100")) + self.assertIsNone(low.halted_reason) # below the hard drawdown_cap_reached halt + with self.assertRaisesRegex(s.SafetyError, "leverage_ceiling_zero"): + ledger.reserve_intent("b2", "QQQ", "buy", "1", "50.02", quote=self.quote("QQQ"), + now=self.RTH_NOW + 2, market_open=True, session_close=self.RTH_NOW + 3600) + # A sell of the held position is still admitted (never gated by leverage). + sold = ledger.reserve_intent("s1", "SPY", "sell", "1", "15.00", + quote=s.Quote("SPY", "15", "15.02", self.RTH_NOW + 2), + now=self.RTH_NOW + 2, market_open=True, session_close=self.RTH_NOW + 3600) + self.assertEqual(sold.side, "sell") + self.assertIsNone(ledger.accounting().halted_reason) + ledger.close() + + def test_validate_pending_refuses_after_ladder_stepdown(self): + ledger = self.make_ledger("4") + ledger.reserve_intent("b1", "SPY", "buy", "2", "50.02", quote=self.quote(), + now=self.RTH_NOW, market_open=True, session_close=self.RTH_NOW + 3600) + ledger.record_order("b1", "broker-b1", "filled", "2", "50.02") + # Reserve a second buy while the ladder is still fully open. + ledger.reserve_intent("b2", "QQQ", "buy", "1", "50.02", quote=self.quote("QQQ"), + now=self.RTH_NOW + 1, market_open=True, session_close=self.RTH_NOW + 3600) + # Drive drawdown to the ladder's zero step (>=0.75, <1.0) before validate_pending. + ledger.mark_to_market([s.Quote("SPY", "60", "60.02", self.RTH_NOW + 2)], self.RTH_NOW + 2) + ledger.mark_to_market([s.Quote("SPY", "15", "15.02", self.RTH_NOW + 3), + s.Quote("QQQ", "50", "50.02", self.RTH_NOW + 3)], self.RTH_NOW + 3) + with self.assertRaisesRegex(s.SafetyError, "leverage_ceiling_zero"): + ledger.validate_pending("b2", quote=self.quote("QQQ", at=self.RTH_NOW + 3), now=self.RTH_NOW + 3, + market_open=True, session_close=self.RTH_NOW + 3600) + ledger.close() + + def test_stop_file_and_halts_still_bind_under_policy(self): + ledger = self.make_ledger("4") + stop = Path(self.tmp.name) / "STOP" + stop.write_text("halt") + with self.assertRaisesRegex(s.SafetyError, "stop_blocks_entry"): + ledger.reserve_intent("b1", "SPY", "buy", "1", "50.02", quote=self.quote(), + now=self.RTH_NOW, market_open=True, session_close=self.RTH_NOW + 3600, + stop_file=stop) + stop.unlink() + ledger.close() + + def test_timestamp_outside_calendar_fails_closed_under_policy_default_unaffected(self): + ledger = self.make_ledger("4") + outside = 1_800_000_000.0 # year 2027, outside the frozen 2026 calendar + ledger2_root = Path(self.tmp.name) / "outside.sqlite3" + outside_ledger = s.Ledger(ledger2_root, replace(s.RiskLimits(), leverage=self.leverage_policy("4"), + max_gross_exposure_usd=D("40000"))) + outside_ledger.start_trial(outside - 10) + with self.assertRaisesRegex(s.SafetyError, "leverage_ceiling_zero"): + outside_ledger.reserve_intent("b1", "SPY", "buy", "1", "50.02", + quote=s.Quote("SPY", "50", "50.02", outside), + now=outside, market_open=True, session_close=outside + 3600) + outside_ledger.close() + # The default (no leverage) path falls back to the ordinary cap and is unaffected. + default_ledger = s.Ledger(Path(self.tmp.name) / "outside_default.sqlite3") + default_ledger.start_trial(outside - 10) + default_ledger.reserve_intent("b1", "SPY", "buy", "1", "50.02", + quote=s.Quote("SPY", "50", "50.02", outside), + now=outside, market_open=True, session_close=outside + 3600) + default_ledger.close() + ledger.close() + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_adaptive_paper_strategies.py b/tests/test_adaptive_paper_strategies.py index 3d5372ea9..6a0af3a2f 100644 --- a/tests/test_adaptive_paper_strategies.py +++ b/tests/test_adaptive_paper_strategies.py @@ -333,5 +333,220 @@ def test_caller_force_exit_wins_over_same_tick_liquidation(self): self.assertEqual(d.exits.get("AAPL"), "trial_end") +import importlib.util as _ilu # noqa: E402 +_LEV_FILE = ENGINE / "leverage.py" +_LEV_SPEC = _ilu.spec_from_file_location("adaptive_policy_leverage", _LEV_FILE) +LEV = _ilu.module_from_spec(_LEV_SPEC) +sys.modules[_LEV_SPEC.name] = LEV +_LEV_SPEC.loader.exec_module(LEV) + + +class LeveragePolicyConfigBoundsTests(unittest.TestCase): + """G-e: PolicyConfig.leverage_policy_id / __post_init__ max_leverage bound.""" + + def config(self, max_leverage, leverage_policy_id=None, gross_cap=None): + # PolicyConfig.capital defaults to 10000; gross_cap here matches + # that default (10000 * max_leverage) unless overridden, so the + # "gross_cap <= capital*max_leverage" bound is exercised precisely. + return M.PolicyConfig(symbols=("SPY", "QQQ", "IWM", "DIA", "AAPL", "MSFT"), max_positions=6, + max_leverage=max_leverage, + gross_cap=gross_cap if gross_cap is not None else 10000 * max_leverage, + leverage_policy_id=leverage_policy_id) + + def test_3x_and_4x_refused_without_id_existing_bound_kept(self): + with self.assertRaises(ValueError): + self.config(3) + with self.assertRaises(ValueError): + self.config(4) + + def test_4x_accepted_with_v1_id(self): + c = self.config(4, leverage_policy_id=LEV.LEVERAGE_POLICY_VERSION) + self.assertEqual(c.max_leverage, 4) + + def test_4_5x_refused_with_id(self): + with self.assertRaises(ValueError): + self.config(4.5, leverage_policy_id=LEV.LEVERAGE_POLICY_VERSION) + + def test_unknown_id_refused(self): + with self.assertRaises(ValueError): + self.config(2, leverage_policy_id="some-other-version") + + def test_gross_cap_exceeding_capital_times_max_leverage_still_refused(self): + with self.assertRaises(ValueError): + self.config(4, leverage_policy_id=LEV.LEVERAGE_POLICY_VERSION, gross_cap=10000 * 4 + 1) + + +class LeveragePolicyWrapperTests(unittest.TestCase): + """G-e: strategies.AdaptivePolicy leverage-ceiling entry-budget gating.""" + + def leverage_policy(self, max_leverage="4"): + from decimal import Decimal as D + capital = D("1000") # matches this class's PolicyConfig.capital default (see policy()) + config = {"max_leverage": max_leverage, "capital_usd": str(capital), "leverage_policy": LEV.CANONICAL_V1_BLOCK, + "max_gross_exposure_usd": str(capital * D(max_leverage))} + session_policy = {"overnight_holds": False, "overnight_gross_multiple": D("1.0")} + return LEV.validate_leverage_policy(config, session_policy) + + def policy(self, max_leverage="4"): + lev = self.leverage_policy(max_leverage) + config = M.PolicyConfig(symbols=("SPY", "QQQ", "IWM", "DIA", "AAPL", "MSFT"), max_positions=6, + capital=1000, gross_cap=1000 * float(max_leverage), max_leverage=float(max_leverage), + leverage_policy_id=LEV.LEVERAGE_POLICY_VERSION) + return M.AdaptivePolicy(config, leverage_policy=lev) + + def feed(self, policy, stock_prices, *, benchmark_slope=0, start=1000): + for i, price in enumerate(stock_prices): + for s in policy.config.symbols: + p = 100 + benchmark_slope * i if s in policy.config.benchmarks else price + policy.observe(s, p - .005, p + .005, start + i) + return start + len(stock_prices) - 1 + + def test_ceiling_zero_yields_no_new_entries(self): + p = self.policy() + now = self.feed(p, [100 + i * .04 for i in range(40)], benchmark_slope=.015) + p.sync_positions({"AAPL": {"qty": 1, "avg_entry_price": "101"}}, now) + d = p.decide(now, leverage_inputs=M.LeverageInputs(session="RTH", + drawdown_fraction=__import__("decimal").Decimal("0"), kill_switch=True)) + self.assertEqual(p.last_leverage_ceiling, 0.0) + # A ceiling of 0 authorizes zero new entry budget; nothing (including + # the already-held AAPL, which is not re-signaled at 0 budget) is + # newly targeted. No leverage-specific liquidation mechanism exists: + # a holding that falls out of targets is handled entirely by the + # unchanged, pre-existing exit chain (portfolio_rotation fallback, + # exactly as any other budget-exhaustion cause already produces -- + # see strategies_v1.AdaptivePolicy._decide_core's trailing "for + # symbol in self.holdings: exits.setdefault(...)" fallback). + self.assertEqual(d.targets, {}) + # Whatever reason the unchanged exit chain assigns (take_profit here, + # given this fixture's entry price/trend -- or portfolio_rotation's + # fallback otherwise), it is unaffected by the leverage ceiling: no + # new "leverage_ceiling_zero"-style exit reason is introduced. + self.assertIn(d.exits.get("AAPL"), ("take_profit", "portfolio_rotation")) + + def test_ceiling_2_vs_4_gives_expected_budget_difference(self): + from decimal import Decimal as D + p4 = self.policy("4") + now4 = self.feed(p4, [100 + i * .04 for i in range(40)], benchmark_slope=.015) + d4 = p4.decide(now4, leverage_inputs=M.LeverageInputs(session="RTH", drawdown_fraction=D("0"), kill_switch=False)) + + p2 = self.policy("4") # same config; only the observed ceiling differs + now2 = self.feed(p2, [100 + i * .04 for i in range(40)], benchmark_slope=.015) + d2 = p2.decide(now2, leverage_inputs=M.LeverageInputs(session="RTH", drawdown_fraction=D("0"), kill_switch=False, + account_multiplier=D("2"))) + used4 = sum(p4.latest[s].ask * q for s, q in d4.targets.items()) + used2 = sum(p2.latest[s].ask * q for s, q in d2.targets.items()) + self.assertGreaterEqual(used4, used2) + + def test_range_regime_still_capped_at_1_regardless_of_schedule(self): + from decimal import Decimal as D + p = self.policy() + now = self.feed(p, [100] * 39 + [100.20]) # breakout -> "range" regime + d = p.decide(now, leverage_inputs=M.LeverageInputs(session="RTH", drawdown_fraction=D("0"), kill_switch=False)) + self.assertEqual(d.regime, "range") + self.assertLessEqual(sum(p.latest[s].ask * q for s, q in d.targets.items()), p.config.capital * 1) + + def test_decide_without_leverage_inputs_fails_closed_to_zero(self): + p = self.policy() + now = self.feed(p, [100 + i * .04 for i in range(40)], benchmark_slope=.015) + d = p.decide(now) + self.assertEqual(p.last_leverage_ceiling, 0.0) + self.assertEqual(d.targets, {}) + + def test_policy_without_id_raises_config_mismatch(self): + lev = self.leverage_policy() + # max_leverage=2.0 (valid without an id) isolates AdaptivePolicy's + # own leverage_policy/leverage_policy_id agreement check from + # PolicyConfig's separate max_leverage bound (already covered by + # LeveragePolicyConfigBoundsTests above). + config = M.PolicyConfig(symbols=("SPY", "QQQ", "IWM", "DIA", "AAPL", "MSFT"), max_positions=6, + capital=1000, gross_cap=2000, max_leverage=2.0) # no leverage_policy_id + with self.assertRaisesRegex(ValueError, "leverage_policy_config_mismatch"): + M.AdaptivePolicy(config, leverage_policy=lev) + + def test_id_without_policy_raises_config_mismatch(self): + config = M.PolicyConfig(symbols=("SPY", "QQQ", "IWM", "DIA", "AAPL", "MSFT"), max_positions=6, + capital=1000, gross_cap=4000, max_leverage=4.0, + leverage_policy_id=LEV.LEVERAGE_POLICY_VERSION) + with self.assertRaises(ValueError): + M.AdaptivePolicy(config, leverage_policy=None) + + def test_pending_buy_notional_usd_consumes_leveraged_entry_budget(self): + """LEV-RI-A (2026-09-22 leverage fix round 1): a still-resting, + unfilled buy order's notional is invisible to `p.holdings` (only + filled positions sync there -- see sync_positions) but is still + capital this tick's new entries must not re-spend. Without the + fix, `pending_buy_notional_usd` was not even an accepted keyword; + with it wired through LeverageInputs -> AdaptivePolicy.decide -> + _decide_core, a pending notional at least as large as the whole + leveraged budget must authorize zero new entries.""" + from decimal import Decimal as D + p_baseline = self.policy("4") + now1 = self.feed(p_baseline, [100 + i * .04 for i in range(40)], benchmark_slope=.015) + d_baseline = p_baseline.decide(now1, leverage_inputs=M.LeverageInputs( + session="RTH", drawdown_fraction=D("0"), kill_switch=False)) + self.assertGreater(len(d_baseline.targets), 0, d_baseline) + + p_pending = self.policy("4") + now2 = self.feed(p_pending, [100 + i * .04 for i in range(40)], benchmark_slope=.015) + d_pending = p_pending.decide(now2, leverage_inputs=M.LeverageInputs( + session="RTH", drawdown_fraction=D("0"), kill_switch=False, + pending_buy_notional_usd=D("100000"))) + self.assertEqual(d_pending.targets, {}) + + def test_leveraged_budget_counts_a_same_tick_exit_position_as_still_committed(self): + """CX-P1 (2026-09-22 leverage fix round 1): a symbol whose exit + this tick's plan decides (take_profit/stop/trailing/...) leaves + `targets` immediately even though the position is still physically + held until its sell order actually fills. The leveraged budget must + still count it as committed capital -- exposed via + Decision.effective_leverage (`used / capital`, computed after the + same-tick entry loop) -- not treat it as already-freed room for a + fresh entry.""" + from decimal import Decimal as D + p = self.policy("4") + now = self.feed(p, [100 + i * .04 for i in range(40)], benchmark_slope=.015) + # Mirrors test_ceiling_zero_yields_no_new_entries's fixture: this + # trend/entry-price combination triggers AAPL's take_profit exit. + p.sync_positions({"AAPL": {"qty": 27, "avg_entry_price": "101"}}, now) + d = p.decide(now, leverage_inputs=M.LeverageInputs(session="RTH", drawdown_fraction=D("0"), kill_switch=False)) + self.assertIn("AAPL", d.exits, d) + self.assertNotIn("AAPL", d.targets, d) + committed = float(p.latest["AAPL"].ask) * 27 + self.assertGreaterEqual(d.effective_leverage * p.config.capital, committed - 1e-6) + + def test_non_leveraged_path_used_stays_targets_only(self): + """The non-leveraged path (leverage_ceiling is None, e.g. every + default config) must stay byte-identical to the pre-fix targets-only + `used` computation -- a same-tick exit must NOT be folded into + Decision.effective_leverage there, preserving the golden-equivalence + contract documented in strategies.py's module docstring.""" + # Constructing without a leverage_policy exercises AdaptivePolicyV1's + # own decide() (no leverage_ceiling ever computed/passed). + import strategies_v1 as v1 + p = v1.AdaptivePolicy(M.PolicyConfig(symbols=("SPY", "QQQ", "IWM", "DIA", "AAPL", "MSFT"), + max_positions=6, capital=1000, gross_cap=1000)) + now = self.feed(p, [100 + i * .04 for i in range(40)], benchmark_slope=.015) + p.sync_positions({"AAPL": {"qty": 27, "avg_entry_price": "101"}}, now) + d = p.decide(now) + self.assertIn("AAPL", d.exits, d) + self.assertNotIn("AAPL", d.targets, d) + # AAPL's committed notional must NOT appear in effective_leverage + # here -- only this tick's `targets` (fresh entries plus any kept + # holding) do, exactly as before this task's leveraged-path fix. + entered = sum(p.latest[s].ask * q for s, q in d.targets.items()) + self.assertAlmostEqual(d.effective_leverage * p.config.capital, entered, places=6) + + def test_default_wrapper_still_uses_super_decide_fast_path(self): + # No pool, no selector, no leverage_policy -- unchanged fast path; + # _decide_core must not observe a leverage_ceiling kwarg (verified + # indirectly: golden-equivalence tests already assert byte-identical + # decisions for exactly this construction). + p = M.AdaptivePolicy(M.PolicyConfig(symbols=("SPY", "QQQ", "IWM", "DIA", "AAPL", "MSFT"), max_positions=6)) + self.assertIsNone(p.leverage_policy) + now = self.feed(p, [100] * 10) + d = p.decide(now) + self.assertEqual(d.targets, {}) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_adaptive_paper_transport.py b/tests/test_adaptive_paper_transport.py index 035709ae4..71d7274a6 100644 --- a/tests/test_adaptive_paper_transport.py +++ b/tests/test_adaptive_paper_transport.py @@ -622,5 +622,39 @@ def test_sdk_enum_member_is_refused_by_the_transport_and_by_preflight(self): client.assert_not_called() +class LeverageNormalizeAccountTests(unittest.TestCase): + """G-e E8: normalize_account's opt-in include_margin flag (pure, no SDK).""" + + def raw_account(self, **overrides): + raw = {"cash": "10000", "equity": "30000", "buying_power": "10000", + "status": "ACTIVE", "currency": "USD", "trading_blocked": False, + "account_blocked": False, "trade_suspended_by_user": False, + "shorting_enabled": True, "pattern_day_trader": False, + "multiplier": "4", "daytrading_buying_power": "40000", + "regt_buying_power": "20000", "daytrade_count": 0} + raw.update(overrides) + return raw + + def test_default_returns_exact_pre_change_key_set(self): + result = t.normalize_account(self.raw_account()) + expected_keys = {"cash", "equity", "buying_power", "status", "currency", "trading_blocked", + "account_blocked", "trade_suspended_by_user", "shorting_enabled", "pattern_day_trader"} + self.assertEqual(set(result), expected_keys) + + def test_include_margin_true_adds_margin_keys(self): + result = t.normalize_account(self.raw_account(), include_margin=True) + self.assertEqual(result["multiplier"], "4") + self.assertEqual(result["daytrading_buying_power"], "40000") + self.assertEqual(result["regt_buying_power"], "20000") + self.assertEqual(result["daytrade_count"], 0) + + def test_include_margin_true_missing_fields_omitted_not_erroring(self): + raw = self.raw_account() + del raw["multiplier"] + result = t.normalize_account(raw, include_margin=True) + self.assertNotIn("multiplier", result) + self.assertEqual(result["daytrading_buying_power"], "40000") + + if __name__ == "__main__": unittest.main()