Risk management — sizing, portfolio caps, kill switch
Updated: June 2026. Implementation:
src/core/risk_manager.py.
Risk is managed on four independent levels: per-trade (sizing), per-portfolio (gross cap + max positions), per-day (kill switch) and per-position (never without a stop on the venue).
1. Per-trade sizing: dynamic 3-factor
RiskManager.calculate_dynamic_size() — used by the tactical strategies:
base_risk = equity × BASE_RISK_PCT (default 1%)
adj_risk = base_risk × vol_scalar × regime_scalar × kelly_scalar
quantity = adj_risk / |entry − SL|
| Factor | Values |
|---|---|
| Volatility (ATR percentile) | <30 → 1.0 · 30-70 → 0.75 · ≥70 → 0.50 |
| Regime | TREND 1.0 · RANGE 0.80 · EXPANSION 0.70 · COMPRESSION 0.60 · UNKNOWN 0.70 |
| Fractional Kelly | kelly = 2×WR−1, clipped [0.05, 0.25], normalized /0.25 |
Final caps applied in cascade: effective leverage ≤ 10×, then the aggregate gross exposure cap (below). The core position does not use the 3-factor model: its size is equity × exposure fraction × vol-target bucket, still subject to the gross cap.
2. Portfolio caps (multi-strategy anti-oversizing)
With 5 active instances, multiple strategies can open positions at the same time. Two limits:
MAX_GROSS_EXPOSURE=1.5: the sum of ABSOLUTE notional of all open futures positions ≤ 1.5 × equity. Every new size is capped to the remaining headroom; at the cap, new entries are blocked.MAX_OPEN_TRADES=3: maximum number of venue positions.
can_open_new_position() is the single gate: kill switch → max
positions → gross cap. Structural note: the macro gates make OPPOSITE
positions on the same instrument impossible — no netting conflicts on a
netted venue.
3. Daily kill switch
- Every closed trade's P&L is registered
- If the day's P&L ≤ −(equity ×
MAX_DAILY_LOSS_PCT, default 3%) → trading suspended until midnight (resets on date change) - A manual kill switch is also available from the dashboard (flag file honored by the RiskManager)
- Visible in the dashboard (Risk & Exposure page)
4. Order lifecycle: never naked positions
In OrderManager.execute_generic_trade:
- Market entry (an unfilled limit would leave rejected reduce-only SL/TP orders and, filling later, a position WITHOUT a stop — a real bug found and fixed in June 2026)
- Reduce-only stop_market SL with 3× retry; if all fail → EMERGENCY CLOSE of the just-opened position
- Reduce-only limit TP (best effort)
- SL/TP registered in the
OrderRegistry→ the management loop (30s) cancels orphaned orders after every close
Additionally: the FailureHandler closes everything if the API stays
down too long; the core position keeps a venue disaster stop for crashes
while the bot is offline.
5. Vol-targeting on the core position
expo = clip(vol_target / realized_vol, 0, 1)
quantized to discrete buckets (rebalance only on bucket change)
Result in the 4-year equity simulation: portfolio maxDD 29.6% → 21.5%, Calmar 2.43 → 2.61, worst year 0.0%. Adopted; tested and rejected: fractional Kelly and drawdown-triggered de-risking (both worsened Calmar).
6. Equity and testnet
Total equity = BTC + ETH equity converted to USD at index prices. On testnet, sizing equity is capped at $50,000 (faucet funds would inflate sizes beyond market limits).
Live guardrails (continuous monitoring)
| Condition | Action |
|---|---|
| Rolling PF < 0.8 after ≥ 30 live trades | disable the strategy and re-validate |
| Strategy DD > 1.5× backtest maxDD | disable IMMEDIATELY |
| Orphan order / SL-less position in the dashboard | investigate immediately |