Phase 9 — Risk Engine¶
Status: Shipped. Merged into main on (placeholder).
Spec: ../superpowers/specs/2026-05-06-trading-engine-phase9-design.md
Summary¶
Phase 9 turns the risk engine from "per-symbol position cap" into a real circuit breaker. Equity is tracked over time, drawdown computed online, daily P&L bounded by UTC days, halt rules evaluated continuously on tick/fill, halt state observable via events, halt blocks new submissions while leaving positions open. Strategies see their own filtered risk state via StrategyContext.risk. Per-strategy variants of every halt rule fall out for free from the per-strategy attribution Phase 8 shipped.
What's new¶
EquityTracker(com.qkt.risk) — total + per-strategy equity over time. Updated on tick (unrealized recompute) and fill (realized + unrealized). Tracks peak monotonically.DrawdownTracker— online fractional drawdown computation.globalDrawdown(),strategyDrawdown(id). Returns 0 when no positive peak exists.DailyPnLTracker— UTC midnight rollover.globalRealizedToday(),realizedToday(strategyId). Resets on first read or write that crosses midnight.RiskState— central component owning the trackers + halt flags. Mutators (halt,haltStrategy,resume,resumeStrategy) emitRiskEvent.Halted/RiskEvent.Resumedon transitions. Idempotent.RiskState.noOp()factory for tests that don't care.RiskEvent.Halted(reason, strategyId?)andRiskEvent.Resumed(strategyId?)— new event family incom.qkt.events. Sibling ofBrokerEvent. Subscribers opt-in for alerting.HaltRuleinterface +HaltDecisionsealed class.HaltDecision.Continue/HaltDecision.Halt(reason, strategyId?).MaxDrawdown(maxFraction)— global halt rule. Halts when global drawdown exceeds threshold.MaxDailyLoss(maxLoss)— global halt rule. Halts when daily realized below threshold (loss expressed as positive number).MaxStrategyDrawdown(strategyId, maxFraction)— per-strategy halt rule.MaxStrategyDailyLoss(strategyId, maxLoss)— per-strategy halt rule.KillSwitch(riskState)— submission rule. ReadsriskState.halted/haltedStrategies, rejects matching submissions.RiskViewinterface +RiskViewImpl(filtered to a strategyId) +NoOpRiskView(test default).StrategyContext.risk: RiskView— every strategy receives its own filter. Readctx.risk.halted,ctx.risk.drawdown,ctx.risk.realizedToday, etc.RiskEngine(rules, haltRules, positions, riskState)— new primary constructor. Backwards-compat shimRiskEngine(rules, positions)provided viaRiskState.noOp()for legacy call sites.RiskEngine.evaluateHaltRules()— invoked by pipeline on each tick + fill. Skips during warmup (riskState.warmupComplete). Per-rulerunCatchingisolates buggy rules.TradingPipeline(..., riskState: RiskState, ...)— new required constructor parameter. Pipeline subscribesTickEventandOrderFilledto driveriskState.onTick()/onFill()andevaluateHaltRules().- Application entry points (
Backtest,LiveSession,Main) constructRiskStateand pass to pipeline.BacktestandMainsetwarmupComplete = trueimmediately.LiveSessionsets it after the indicator warmer finishes.
Migration from previous phase¶
| 8 | 9 | Notes |
|---|---|---|
RiskEngine(rules, positions) |
RiskEngine(rules, haltRules, positions, riskState) |
Convenience ctor RiskEngine(rules, positions) provided for tests via RiskState.noOp(). Existing simple call sites compile unchanged. |
StrategyContext had 7 fields |
gains risk: RiskView (8th) |
testStrategyContext() provides NoOpRiskView() default. |
TradingPipeline had no risk state |
gains required riskState: RiskState parameter |
Application-level wiring. |
| (no halt event) | RiskEvent.Halted / RiskEvent.Resumed |
Subscribers opt-in. |
EventBus exhaustive when had 13 cases |
gains RiskEvent.Halted / RiskEvent.Resumed |
Mechanical addition. |
| (no equity tracking) | EquityTracker, DrawdownTracker, DailyPnLTracker |
New subsystem. |
Pipeline subscribed TickEvent only for price update |
adds riskState.onTick() + riskEngine.evaluateHaltRules() |
Same subscription, more work. |
Application setup change¶
// 8
val pipeline = TradingPipeline(
...,
riskEngine = RiskEngine(rules = listOf(MaxPositionSize(...)), positions = positions),
...,
)
// 9
val riskState = RiskState(pnl, strategyPnL, clock, bus)
riskState.warmupComplete = true
val haltRules = listOf<HaltRule>(
MaxDrawdown(BigDecimal("0.20")),
MaxDailyLoss(BigDecimal("5000")),
)
val submitRules = listOf<RiskRule>(
MaxPositionSize("BYBIT_LINEAR:BTCUSDT", BigDecimal("1.0")),
MaxOpenPositions(10),
KillSwitch(riskState),
)
val pipeline = TradingPipeline(
...,
riskEngine = RiskEngine(submitRules, haltRules, positions, riskState),
riskState = riskState, // NEW
...,
)
Usage cookbook¶
1. Configure halt rules at startup¶
val haltRules = listOf<HaltRule>(
MaxDrawdown(BigDecimal("0.20")), // 20% peak-to-trough triggers halt
MaxDailyLoss(BigDecimal("5000")), // $5k daily loss triggers halt
MaxStrategyDrawdown("ema-cross-btc", BigDecimal("0.30")), // strategy-only 30% cap
MaxStrategyDailyLoss("ema-cross-btc", BigDecimal("2000")),
)
The engine evaluates these on every tick + fill. The first rule to return Halt triggers the halt. Subsequent submissions are blocked at the gate.
2. Strategy reads its own drawdown via ctx.risk¶
class ConservativeStrategy(private val symbol: String) : Strategy {
override fun onTick(tick: Tick, ctx: StrategyContext, emit: (Signal) -> Unit) {
if (ctx.risk.drawdown > BigDecimal("0.10")) {
log.info("Backing off: drawdown {}", ctx.risk.drawdown)
return
}
if (ctx.risk.realizedToday < BigDecimal("-1000")) return
if (someEntryCondition(tick)) emit(Signal.Buy(symbol, BigDecimal("0.1")))
}
}
The strategy soft-throttles based on its own state. Hard caps still come from halt rules.
3. Subscribe to RiskEvent.Halted for alerting¶
bus.subscribe<RiskEvent.Halted> { event ->
val target = event.strategyId ?: "GLOBAL"
log.error("RISK HALT [{}]: {}", target, event.reason)
slackBot.alert("qkt halted: $target → ${event.reason}")
}
bus.subscribe<RiskEvent.Resumed> { event ->
val target = event.strategyId ?: "GLOBAL"
log.info("RISK RESUMED [{}]", target)
}
Halts fire only on transition (idempotent). No flap loops.
4. Operator manually halts and resumes¶
// Manually halt the entire system (e.g., before a planned outage)
riskState.halt("planned maintenance")
// Block all new submissions until cleared
// existing positions remain open
// After maintenance:
riskState.resume()
For a single strategy:
riskState.haltStrategy("ema-cross-btc", "investigating loss spike")
// other strategies continue trading
riskState.resumeStrategy("ema-cross-btc")
5. Per-strategy halt without affecting others¶
val haltRules = listOf<HaltRule>(
MaxStrategyDrawdown("aggressive-strat", BigDecimal("0.25")),
MaxStrategyDrawdown("conservative-strat", BigDecimal("0.10")),
)
When aggressive-strat hits 25% drawdown, only it halts. conservative-strat continues with its own threshold.
Testing patterns¶
RiskState.noOp()— constructs a realRiskStatewith internal trackers wired but no halt rules. Used byRiskEngine(rules, positions)shim. Tests that don't need risk state don't construct one.TestRighelper pattern — seeHaltRulesTest. Bundlesstate, positions, strategyPositions, pnl, strategyPnLand providesapplyAndRecord(event)that calls all fourapplyFill/recordRealizedpaths in the same order the pipeline does.testStrategyContext(risk = NoOpRiskView())— default for strategy tests. Overrideriskto testctx.riskreads.- Halt event capture — subscribe
RiskEvent.Halted/RiskEvent.Resumedto aMutableListand assert transitions. Idempotent halts produce one event each. - Drawdown setup — record realized via
applyAndRecordto lock in profit (sets peak), then realized losses to drop equity (drawdown emerges). Or push price up then down with positions held.
Known limitations¶
- No notional exposure caps. Requires symbol-level price lookups for unrealized notional. Phase 10 has the data infrastructure; deferred.
- No leverage caps. Derivatives margin math; Phase 10+ scope.
- No volatility-based circuit breakers. Requires vol indicator integration; Phase 10+.
- No automatic position flattening on halt. Industry default leaves positions open in fast markets — auto-close compounds losses. Operator decides; if you want auto-close, build it as a halt-event subscriber that fires cancel/flatten orders.
- No automatic resume on improving conditions. Risk transitions are one-way until human input. Auto-resume creates flap loops in volatile markets.
- No persistence of risk state across JVM restarts. Same as 7f-8.
- No per-broker risk gating. Risk applies at the engine layer, broker-agnostic. If you want venue-specific limits, build a wrapper broker.
- Peak equity is permanent within a
RiskStateinstance. Once a peak is set, it stays. To reset risk after a halt-and-resume cycle, rebuild the engine with a freshRiskState. Auto-reset on resume would mask the drawdown that triggered the halt — wrong. DailyPnLTrackerrollover at UTC midnight only. No timezone configurability yet. NY/London/Asia operators must adjust thresholds for their effective day.- Equity curve is in-memory current state, not history. No time series of
(timestamp, equity)pairs. Phase 10 backtest reporting consumes the live trackers; longer-term history needs persistence. - Halt rules evaluated synchronously on tick/fill. A slow rule (e.g., one doing I/O — don't do this) blocks the dispatch thread.
runCatchingisolates exceptions but not slowness. KillSwitchis one-shot per evaluation. A submission that arrives during a halt is rejected; the halt itself is permanent untilresume(). There's no "halt for 5 minutes then auto-resume."RiskEngine.evaluateHaltRules()skips during warmup. Manually setriskState.warmupComplete = trueafter warmup phase.BacktestandMainset it immediately;LiveSessionsets it afterIndicatorWarmer.warmup().- Rule order matters in
RiskEngine.approve(). FirstDecision.Rejectwins. PlaceKillSwitchfirst if you want it to short-circuit before slow per-position rules. HaltDecision.Haltwith non-existentstrategyIdworks silently. No validation. Halts a strategy nobody trades. Documented; rules are in user code.