From f6d8d13146df16f8066f733c8911679d66cddfed Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 17:01:38 +0200 Subject: [PATCH] malkhut(wire): 5 risk gate stubs implemented + 3 scenarios behavior-driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- MALKHUT/malkhut/risk/gate.py | 88 +++++++++++++++++++++++-- MALKHUT/malkhut/training/cma_trainer.py | 6 +- 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/MALKHUT/malkhut/risk/gate.py b/MALKHUT/malkhut/risk/gate.py index 1f4dfcf..102d190 100644 --- a/MALKHUT/malkhut/risk/gate.py +++ b/MALKHUT/malkhut/risk/gate.py @@ -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 diff --git a/MALKHUT/malkhut/training/cma_trainer.py b/MALKHUT/malkhut/training/cma_trainer.py index 205e829..8dd4566 100644 --- a/MALKHUT/malkhut/training/cma_trainer.py +++ b/MALKHUT/malkhut/training/cma_trainer.py @@ -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"),