malkhut(wire): 5 risk gate stubs implemented + 3 scenarios behavior-driven

Risk gate (risk/gate.py) — 5 stubs implemented:

1. _kill_switch_active(): operator-controlled emergency stop via set_kill_switch()
2. _cancel_rate_would_exceed(): tracks cancel timestamps per symbol in 60s
   sliding window, blocks if >= MAX_CANCELS_PER_SYMBOL_PER_MINUTE
3. _would_self_trade(): checks open orders for same symbol+side at same price
   (within tick_size), skipping the cancel_order_id for CANCEL_REPLACE
4. _would_exceed_symbol_notional(): sums current open order notional + new
   order notional, blocks if > equity * MAX_SYMBOL_NOTIONAL_FRACTION
5. _violates_venue_minima(): checks tick alignment, lot rounding, min_qty,
   and min_notional — all float-robust comparisons

ScenarioFactory — 3 remaining hardcoded scenarios converted:

1. _spread_tightening: spread_mult=0.3, depth_fraction=1.0 (was hardcoded BTC)
2. _cross_venue_arb: spread_mult=0.5, depth_fraction=0.5 (was hardcoded BTC)
3. _cross_exchange_arb_stress: spread_mult=0.8, depth_fraction=0.3 (was hardcoded BTC)

All 30 scenarios now use _behavior_state() — zero hardcoded prices remain.

675 tests pass. Zero regressions.
This commit is contained in:
Codex
2026-07-14 17:01:38 +02:00
parent 1b6d280d26
commit f6d8d13146
2 changed files with 86 additions and 8 deletions

View File

@@ -1,11 +1,16 @@
"""
Risk gate — final hard stop before venue execution.
The planner is not trusted. The optimiser is not trusted. The exchange adapter
The planner is not trusted. The optimiser is not trusted, the exchange adapter
is not trusted. This gate enforces hard invariants.
"""
from __future__ import annotations
import time
from collections import defaultdict
from typing import Deque
from collections import deque
from malkhut.actions import FulfilmentAction, PlannedPolicy, RiskDecision
from malkhut.state import (
ActionKind,
@@ -13,12 +18,27 @@ from malkhut.state import (
MarketWorldState,
MAX_ACCOUNT_LEVERAGE,
MAX_CANCELS_PER_SYMBOL_PER_MINUTE,
MAX_SYMBOL_NOTIONAL_FRACTION,
Side,
)
from malkhut.cwm import materialize_price_from_action
class RiskGate:
def __init__(self) -> None:
self._kill_switch: bool = False
self._cancel_timestamps: dict[str, Deque[float]] = defaultdict(
lambda: deque(maxlen=MAX_CANCELS_PER_SYMBOL_PER_MINUTE + 10)
)
def set_kill_switch(self, active: bool) -> None:
"""Operator-controlled emergency stop."""
self._kill_switch = active
def record_cancel(self, symbol: str) -> None:
"""Record a cancel event for rate-limit tracking."""
self._cancel_timestamps[symbol].append(time.time())
def validate(
self,
state: MarketWorldState,
@@ -35,7 +55,6 @@ class RiskGate:
"""
action = planned.selected_action
# DAAT OOD veto — highest priority
if daat_verdict == "OUT_OF_DISTRIBUTION":
return RiskDecision(True, None, "ood_veto_fall_back_to_doctrinal")
@@ -66,12 +85,38 @@ class RiskGate:
return RiskDecision(True, action, "approved")
def _kill_switch_active(self) -> bool:
return False
return self._kill_switch
def _cancel_rate_would_exceed(self, state: MarketWorldState, action: FulfilmentAction) -> bool:
return False
if action.kind != ActionKind.CANCEL and action.kind != ActionKind.CANCEL_REPLACE:
return False
symbol = state.venue.symbol
now = time.time()
window = self._cancel_timestamps[symbol]
cutoff = now - 60.0
while window and window[0] < cutoff:
window.popleft()
return len(window) >= MAX_CANCELS_PER_SYMBOL_PER_MINUTE
def _would_self_trade(self, state: MarketWorldState, action: FulfilmentAction) -> bool:
if action.kind not in (ActionKind.PLACE, ActionKind.CANCEL_REPLACE, ActionKind.CROSS_SPREAD):
return False
if action.side is None:
return False
for oo in state.open_orders:
if oo.symbol != state.venue.symbol:
continue
if oo.side != action.side:
continue
if oo.client_order_id == action.cancel_order_id:
continue
if oo.price is None or action.price_ticks_from_best is None:
continue
our_price = materialize_price_from_action(state, action)
if our_price is None:
continue
if abs(our_price - oo.price) < state.venue.tick_size:
return True
return False
def _would_exceed_leverage(
@@ -82,7 +127,19 @@ class RiskGate:
def _would_exceed_symbol_notional(
self, state: MarketWorldState, action: FulfilmentAction, params: FulfilmentPolicyParams,
) -> bool:
return False
if action.kind not in (ActionKind.PLACE, ActionKind.CANCEL_REPLACE, ActionKind.CROSS_SPREAD):
return False
price = materialize_price_from_action(state, action)
if price is None:
return False
qty = action.qty_fraction * state.account.available_balance / max(price, 1e-12)
order_notional = price * qty
max_notional = state.account.equity * MAX_SYMBOL_NOTIONAL_FRACTION
current_notional = 0.0
for oo in state.open_orders:
if oo.symbol == state.venue.symbol and oo.price is not None:
current_notional += oo.price * oo.remaining_qty
return (current_notional + order_notional) > max_notional
def _post_only_would_cross(self, state: MarketWorldState, action: FulfilmentAction) -> bool:
if not action.post_only:
@@ -97,4 +154,25 @@ class RiskGate:
return False
def _violates_venue_minima(self, state: MarketWorldState, action: FulfilmentAction) -> bool:
if action.kind not in (ActionKind.PLACE, ActionKind.CANCEL_REPLACE):
return False
price = materialize_price_from_action(state, action)
if price is None:
return False
if price <= 0:
return True
tick = state.venue.tick_size
if tick > 0:
remainder = price % tick
if remainder > 1e-9 and tick - remainder > 1e-9:
return True
qty = action.qty_fraction * state.account.available_balance / max(price, 1e-12)
lot = state.venue.lot_size
if lot > 0 and qty > 0:
rounded = round(qty / lot) * lot
if rounded < state.venue.min_qty:
return True
notional = price * qty
if notional < state.venue.min_notional:
return True
return False

View File

@@ -645,7 +645,7 @@ class ScenarioFactory:
return Scenario(
scenario_id=f"tighten_{symbol}_{seed}",
symbol=symbol,
initial_state=self._make_state(symbol, bid=49950.0, ask=50050.0, bid_qty=2.0, ask_qty=2.0, exchange_id=self.exchange_id),
initial_state=self._behavior_state(symbol, spread_mult=0.3, depth_fraction=1.0, exchange_id=self.exchange_id),
counterparties=self.counterparties,
max_steps=steps,
tags=("spread_tightening", "competition"),
@@ -726,7 +726,7 @@ class ScenarioFactory:
return Scenario(
scenario_id=f"arb_{symbol}_{seed}",
symbol=symbol,
initial_state=self._make_state(symbol, bid=49990.0, ask=50010.0, bid_qty=0.5, ask_qty=0.5, exchange_id=self.exchange_id),
initial_state=self._behavior_state(symbol, spread_mult=0.5, depth_fraction=0.5, exchange_id=self.exchange_id),
counterparties=(LatencyArbPolicy(lead_threshold=0.3), ToxicTakerPolicy(sensitivity=0.4)),
max_steps=steps,
tags=("arbitrage", "cross_venue", "price_discovery"),
@@ -834,7 +834,7 @@ class ScenarioFactory:
return Scenario(
scenario_id=f"arb_stress_{symbol}_{seed}",
symbol=symbol,
initial_state=self._make_state(symbol, bid=49980.0, ask=50020.0, bid_qty=0.3, ask_qty=0.3, exchange_id=self.exchange_id),
initial_state=self._behavior_state(symbol, spread_mult=0.8, depth_fraction=0.3, exchange_id=self.exchange_id),
counterparties=(LatencyArbPolicy(lead_threshold=0.2), ToxicTakerPolicy(sensitivity=0.4)),
max_steps=steps,
tags=("cross_exchange", "arb_stress", "price_discovery"),