Skip to content
102 changes: 102 additions & 0 deletions blueprints/us-equities/adaptive-paper/README-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 7 additions & 2 deletions blueprints/us-equities/adaptive-paper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
132 changes: 132 additions & 0 deletions blueprints/us-equities/adaptive-paper/config-leverage-1x.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
Loading