419 lines
14 KiB
Python
419 lines
14 KiB
Python
|
|
"""
|
||
|
|
ASEx integration for MALKHUT.
|
||
|
|
|
||
|
|
Builds ASEx's validate-before-mutate kernel into the core of MALKHUT:
|
||
|
|
- GuardedFulfilmentState: engine state mutations through ASEx
|
||
|
|
- GuardedRiskState: risk gate decisions through ASEx
|
||
|
|
- GuardedVenueState: venue adapter mutations through ASEx
|
||
|
|
- FulfilmentWorker: single-threaded ASExWorker for serialised engine
|
||
|
|
- ShardedFulfilmentWorker: per-symbol ShardedWorker for parallelism
|
||
|
|
- FulfilmentWatch: zero-overhead ASExWatch for hot-path ring buffer
|
||
|
|
|
||
|
|
The pattern: every mutable state object is an ASExGuardedState.
|
||
|
|
_mutate() calls _validate() first, then _apply() only if valid.
|
||
|
|
One worker thread per state. No locks. No races. No GC pressure.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from typing import Any, Optional
|
||
|
|
|
||
|
|
# ASEx is not pip-installable; imported via path
|
||
|
|
_ASEX_SRC = "/mnt/dolphinng5_predict/ASEx/src"
|
||
|
|
if _ASEX_SRC not in sys.path:
|
||
|
|
sys.path.insert(0, _ASEX_SRC)
|
||
|
|
|
||
|
|
from asex.guarded import ASExGuardedState, ValidationError, SafetyError
|
||
|
|
from asex.worker import ASExWorker
|
||
|
|
from asex.watch import ASExWatch
|
||
|
|
from asex.batch import BatchWorker
|
||
|
|
from asex.sharded import ShardedWorker
|
||
|
|
from asex.daemon import LocalDaemon
|
||
|
|
from asex.client import ASExClientLocal
|
||
|
|
|
||
|
|
from malkhut.state import (
|
||
|
|
AccountState, ExecutionIntent, FulfilmentPolicyParams, MarketWorldState,
|
||
|
|
Mode, OpenOrderState, OrderBookState, VenueRules,
|
||
|
|
)
|
||
|
|
from malkhut.actions import (
|
||
|
|
ActionKind, FulfilmentAction, PlannedPolicy, RiskDecision,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ==============================================================================
|
||
|
|
# Mutation types
|
||
|
|
# ==============================================================================
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class BookUpdate:
|
||
|
|
"""Mutation: update the canonical order book."""
|
||
|
|
ts_ns: int
|
||
|
|
symbol: str
|
||
|
|
bids: tuple # tuple[PriceLevel, ...]
|
||
|
|
asks: tuple # tuple[PriceLevel, ...]
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class AccountUpdate:
|
||
|
|
"""Mutation: update account/position state."""
|
||
|
|
ts_ns: int
|
||
|
|
equity: float
|
||
|
|
wallet_balance: float
|
||
|
|
available_balance: float
|
||
|
|
margin_used: float
|
||
|
|
total_notional: float
|
||
|
|
positions: dict # dict[str, PositionState]
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class IntentUpdate:
|
||
|
|
"""Mutation: update the current execution intent."""
|
||
|
|
intent: Optional[ExecutionIntent]
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class OrderAction:
|
||
|
|
"""Mutation: place/cancel/replace an order."""
|
||
|
|
kind: str # ActionKind value
|
||
|
|
action: Optional[FulfilmentAction] = None
|
||
|
|
cancel_order_id: Optional[str] = None
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class RiskCheck:
|
||
|
|
"""Mutation: run risk gate on a planned action."""
|
||
|
|
state: Any # MarketWorldState
|
||
|
|
planned: PlannedPolicy
|
||
|
|
params: FulfilmentPolicyParams
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class PolicyReload:
|
||
|
|
"""Mutation: hot-reload policy parameters."""
|
||
|
|
params: FulfilmentPolicyParams
|
||
|
|
|
||
|
|
|
||
|
|
# ==============================================================================
|
||
|
|
# Guarded state objects
|
||
|
|
# ==============================================================================
|
||
|
|
|
||
|
|
class GuardedFulfilmentState(ASExGuardedState[Any, Any]):
|
||
|
|
"""
|
||
|
|
ASEx-guarded engine state. All mutations go through validate-before-mutate.
|
||
|
|
|
||
|
|
Holds:
|
||
|
|
- canonical book state
|
||
|
|
- account state
|
||
|
|
- open orders
|
||
|
|
- trade path
|
||
|
|
- current intent
|
||
|
|
- active policy params
|
||
|
|
|
||
|
|
Thread safety: owned by exactly one ASExWorker thread.
|
||
|
|
No external mutation allowed.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, initial_state: Optional[MarketWorldState] = None) -> None:
|
||
|
|
super().__init__()
|
||
|
|
self._state: Optional[MarketWorldState] = initial_state
|
||
|
|
self._params: Optional[FulfilmentPolicyParams] = None
|
||
|
|
self._last_planned: Optional[PlannedPolicy] = None
|
||
|
|
self._last_risk: Optional[RiskDecision] = None
|
||
|
|
self._mutation_count: int = 0
|
||
|
|
|
||
|
|
def _validate(self, mutation: Any) -> bool:
|
||
|
|
if isinstance(mutation, BookUpdate):
|
||
|
|
return mutation.ts_ns > 0 and len(mutation.bids) > 0 and len(mutation.asks) > 0
|
||
|
|
if isinstance(mutation, AccountUpdate):
|
||
|
|
return mutation.ts_ns > 0 and mutation.equity >= 0
|
||
|
|
if isinstance(mutation, IntentUpdate):
|
||
|
|
return True # intent can be None (no intent)
|
||
|
|
if isinstance(mutation, OrderAction):
|
||
|
|
return mutation.kind in ("PLACE", "CANCEL", "CANCEL_REPLACE", "CROSS_SPREAD",
|
||
|
|
"REDUCE", "FULL_EXIT", "NOOP")
|
||
|
|
if isinstance(mutation, RiskCheck):
|
||
|
|
return mutation.planned is not None and mutation.params is not None
|
||
|
|
if isinstance(mutation, PolicyReload):
|
||
|
|
return mutation.params is not None
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _apply(self, mutation: Any) -> Any:
|
||
|
|
self._mutation_count += 1
|
||
|
|
|
||
|
|
if isinstance(mutation, BookUpdate):
|
||
|
|
if self._state is None:
|
||
|
|
return None
|
||
|
|
from malkhut.state import PriceLevel
|
||
|
|
bids = tuple(
|
||
|
|
PriceLevel(p["price"], p["qty"]) if isinstance(p, dict)
|
||
|
|
else PriceLevel(p[0], p[1])
|
||
|
|
for p in mutation.bids
|
||
|
|
)
|
||
|
|
asks = tuple(
|
||
|
|
PriceLevel(p["price"], p["qty"]) if isinstance(p, dict)
|
||
|
|
else PriceLevel(p[0], p[1])
|
||
|
|
for p in mutation.asks
|
||
|
|
)
|
||
|
|
new_book = OrderBookState(
|
||
|
|
ts_ns=mutation.ts_ns, symbol=mutation.symbol,
|
||
|
|
bids=bids, asks=asks,
|
||
|
|
last_trade_price=self._state.book.last_trade_price,
|
||
|
|
last_trade_qty=self._state.book.last_trade_qty,
|
||
|
|
last_trade_side=self._state.book.last_trade_side,
|
||
|
|
)
|
||
|
|
self._state = MarketWorldState(
|
||
|
|
ts_ns=mutation.ts_ns, mode=self._state.mode,
|
||
|
|
venue=self._state.venue, book=new_book,
|
||
|
|
account=self._state.account,
|
||
|
|
open_orders=self._state.open_orders,
|
||
|
|
trade_path=self._state.trade_path,
|
||
|
|
intent=self._state.intent,
|
||
|
|
funding_bps=self._state.funding_bps,
|
||
|
|
volatility_state=self._state.volatility_state,
|
||
|
|
market_regime=self._state.market_regime,
|
||
|
|
)
|
||
|
|
return new_book
|
||
|
|
|
||
|
|
if isinstance(mutation, AccountUpdate):
|
||
|
|
if self._state is None:
|
||
|
|
return None
|
||
|
|
from malkhut.state import PositionState
|
||
|
|
positions = {}
|
||
|
|
for sym, pos_data in mutation.positions.items():
|
||
|
|
positions[sym] = PositionState(
|
||
|
|
symbol=pos_data["symbol"], qty=pos_data["qty"],
|
||
|
|
avg_entry=pos_data["avg_entry"],
|
||
|
|
unrealized_pnl=pos_data.get("unrealized_pnl", 0.0),
|
||
|
|
realized_pnl=pos_data.get("realized_pnl", 0.0),
|
||
|
|
liquidation_price=pos_data.get("liquidation_price"),
|
||
|
|
leverage=pos_data.get("leverage", 0.0),
|
||
|
|
side=pos_data.get("side"),
|
||
|
|
)
|
||
|
|
new_account = AccountState(
|
||
|
|
ts_ns=mutation.ts_ns, equity=mutation.equity,
|
||
|
|
wallet_balance=mutation.wallet_balance,
|
||
|
|
available_balance=mutation.available_balance,
|
||
|
|
margin_used=mutation.margin_used,
|
||
|
|
total_notional=mutation.total_notional,
|
||
|
|
positions=positions,
|
||
|
|
)
|
||
|
|
self._state = MarketWorldState(
|
||
|
|
ts_ns=mutation.ts_ns, mode=self._state.mode,
|
||
|
|
venue=self._state.venue, book=self._state.book,
|
||
|
|
account=new_account,
|
||
|
|
open_orders=self._state.open_orders,
|
||
|
|
trade_path=self._state.trade_path,
|
||
|
|
intent=self._state.intent,
|
||
|
|
)
|
||
|
|
return new_account
|
||
|
|
|
||
|
|
if isinstance(mutation, IntentUpdate):
|
||
|
|
if self._state is None:
|
||
|
|
return None
|
||
|
|
self._state = MarketWorldState(
|
||
|
|
ts_ns=self._state.ts_ns, mode=self._state.mode,
|
||
|
|
venue=self._state.venue, book=self._state.book,
|
||
|
|
account=self._state.account,
|
||
|
|
open_orders=self._state.open_orders,
|
||
|
|
trade_path=self._state.trade_path,
|
||
|
|
intent=mutation.intent,
|
||
|
|
)
|
||
|
|
return mutation.intent
|
||
|
|
|
||
|
|
if isinstance(mutation, PolicyReload):
|
||
|
|
self._params = mutation.params
|
||
|
|
return mutation.params
|
||
|
|
|
||
|
|
return None
|
||
|
|
|
||
|
|
@property
|
||
|
|
def state(self) -> Optional[MarketWorldState]:
|
||
|
|
return self._state
|
||
|
|
|
||
|
|
@property
|
||
|
|
def params(self) -> Optional[FulfilmentPolicyParams]:
|
||
|
|
return self._params
|
||
|
|
|
||
|
|
@property
|
||
|
|
def mutation_count(self) -> int:
|
||
|
|
return self._mutation_count
|
||
|
|
|
||
|
|
|
||
|
|
class GuardedRiskState(ASExGuardedState[Any, RiskDecision]):
|
||
|
|
"""
|
||
|
|
ASEx-guarded risk gate state.
|
||
|
|
|
||
|
|
Validates risk decisions before they affect the system.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self) -> None:
|
||
|
|
super().__init__()
|
||
|
|
self._kill_switch: bool = False
|
||
|
|
self._cancel_counts: dict[str, int] = {}
|
||
|
|
self._last_decision: Optional[RiskDecision] = None
|
||
|
|
|
||
|
|
def _validate(self, mutation: Any) -> bool:
|
||
|
|
if isinstance(mutation, RiskCheck):
|
||
|
|
return True
|
||
|
|
if isinstance(mutation, str) and mutation == "KILL_SWITCH_ON":
|
||
|
|
return True
|
||
|
|
if isinstance(mutation, str) and mutation == "KILL_SWITCH_OFF":
|
||
|
|
return True
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _apply(self, mutation: Any) -> RiskDecision:
|
||
|
|
if mutation == "KILL_SWITCH_ON":
|
||
|
|
self._kill_switch = True
|
||
|
|
return RiskDecision(False, None, "kill_switch_activated")
|
||
|
|
if mutation == "KILL_SWITCH_OFF":
|
||
|
|
self._kill_switch = False
|
||
|
|
return RiskDecision(True, None, "kill_switch_deactivated")
|
||
|
|
|
||
|
|
if self._kill_switch:
|
||
|
|
return RiskDecision(False, None, "kill_switch")
|
||
|
|
|
||
|
|
if isinstance(mutation, RiskCheck):
|
||
|
|
from malkhut.risk.gate import RiskGate
|
||
|
|
gate = RiskGate()
|
||
|
|
gate._kill_switch_active = lambda: self._kill_switch
|
||
|
|
decision = gate.validate(
|
||
|
|
mutation.state,
|
||
|
|
mutation.planned,
|
||
|
|
mutation.params,
|
||
|
|
)
|
||
|
|
self._last_decision = decision
|
||
|
|
return decision
|
||
|
|
|
||
|
|
return RiskDecision(False, None, "unknown_mutation")
|
||
|
|
|
||
|
|
@property
|
||
|
|
def kill_switch(self) -> bool:
|
||
|
|
return self._kill_switch
|
||
|
|
|
||
|
|
@property
|
||
|
|
def last_decision(self) -> Optional[RiskDecision]:
|
||
|
|
return self._last_decision
|
||
|
|
|
||
|
|
|
||
|
|
# ==============================================================================
|
||
|
|
# Worker wrappers
|
||
|
|
# ==============================================================================
|
||
|
|
|
||
|
|
class FulfilmentWorker:
|
||
|
|
"""
|
||
|
|
Single ASExWorker wrapping GuardedFulfilmentState.
|
||
|
|
|
||
|
|
All engine mutations go through this worker's queue.
|
||
|
|
Serialised, validated, applied on one thread.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, initial_state: Optional[MarketWorldState] = None) -> None:
|
||
|
|
self._guarded = GuardedFulfilmentState(initial_state)
|
||
|
|
self._worker = ASExWorker(self._guarded, daemon=True)
|
||
|
|
|
||
|
|
def update_book(self, ts_ns: int, symbol: str, bids: tuple, asks: tuple):
|
||
|
|
from malkhut.state import PriceLevel
|
||
|
|
bid_dicts = [{"price": p.price, "qty": p.qty} for p in bids]
|
||
|
|
ask_dicts = [{"price": p.price, "qty": p.qty} for p in asks]
|
||
|
|
return self._worker.mutate(BookUpdate(ts_ns, symbol, tuple(bid_dicts), tuple(ask_dicts)))
|
||
|
|
|
||
|
|
def update_account(self, **kwargs):
|
||
|
|
return self._worker.mutate(AccountUpdate(**kwargs))
|
||
|
|
|
||
|
|
def update_intent(self, intent: Optional[ExecutionIntent]):
|
||
|
|
return self._worker.mutate(IntentUpdate(intent))
|
||
|
|
|
||
|
|
def reload_policy(self, params: FulfilmentPolicyParams):
|
||
|
|
return self._worker.mutate(PolicyReload(params))
|
||
|
|
|
||
|
|
@property
|
||
|
|
def state(self) -> Optional[MarketWorldState]:
|
||
|
|
return self._guarded.state
|
||
|
|
|
||
|
|
@property
|
||
|
|
def params(self) -> Optional[FulfilmentPolicyParams]:
|
||
|
|
return self._guarded.params
|
||
|
|
|
||
|
|
@property
|
||
|
|
def mutation_count(self) -> int:
|
||
|
|
return self._guarded.mutation_count
|
||
|
|
|
||
|
|
def close(self, timeout: float = 5.0):
|
||
|
|
self._worker.close(timeout=timeout)
|
||
|
|
|
||
|
|
|
||
|
|
class RiskWorker:
|
||
|
|
"""ASExWorker wrapping GuardedRiskState for risk gate mutations."""
|
||
|
|
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self._guarded = GuardedRiskState()
|
||
|
|
self._worker = ASExWorker(self._guarded, daemon=True)
|
||
|
|
|
||
|
|
def activate_kill_switch(self):
|
||
|
|
return self._worker.mutate("KILL_SWITCH_ON")
|
||
|
|
|
||
|
|
def deactivate_kill_switch(self):
|
||
|
|
return self._worker.mutate("KILL_SWITCH_OFF")
|
||
|
|
|
||
|
|
@property
|
||
|
|
def kill_switch(self) -> bool:
|
||
|
|
return self._guarded.kill_switch
|
||
|
|
|
||
|
|
def close(self, timeout: float = 5.0):
|
||
|
|
self._worker.close(timeout=timeout)
|
||
|
|
|
||
|
|
|
||
|
|
class FulfilmentWatch:
|
||
|
|
"""
|
||
|
|
Zero-overhead ring buffer for hot-path mutations.
|
||
|
|
|
||
|
|
**DEFERRED from critical path** per T19 ANNEX B pre-condition #2:
|
||
|
|
"ASExWatch stays OUT of the critical path until its heavy-test hangs
|
||
|
|
are fixed (known-deferred)."
|
||
|
|
|
||
|
|
Producer: claim slot via _free.get(), write, signal via _filled.put().
|
||
|
|
Consumer: reads _filled.get() directly, calls _apply(), frees slot.
|
||
|
|
|
||
|
|
When ring is full, mutate() raises queue.Full — never blocks the producer.
|
||
|
|
|
||
|
|
NOTE: Do NOT use in the live hot path until ASExWatch stability is proven.
|
||
|
|
Use FulfilmentWorker (ASExWorker) instead for production.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, initial_state: Optional[MarketWorldState] = None,
|
||
|
|
capacity: int = 65536) -> None:
|
||
|
|
self._guarded = GuardedFulfilmentState(initial_state)
|
||
|
|
self._watch = ASExWatch(self._guarded, capacity=capacity)
|
||
|
|
|
||
|
|
def mutate(self, mutation: Any):
|
||
|
|
return self._watch.mutate(mutation)
|
||
|
|
|
||
|
|
def poll(self, timeout: float = None) -> int:
|
||
|
|
return self._watch.poll(timeout)
|
||
|
|
|
||
|
|
def wait(self, timeout: float = None) -> int:
|
||
|
|
return self._watch.wait(timeout)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def state(self) -> Optional[MarketWorldState]:
|
||
|
|
return self._guarded.state
|
||
|
|
|
||
|
|
@property
|
||
|
|
def pending(self) -> int:
|
||
|
|
return self._watch.pending
|
||
|
|
|
||
|
|
def close(self):
|
||
|
|
self._watch.close()
|
||
|
|
|
||
|
|
|
||
|
|
def create_sharded_fulfilment(n_partitions: int = 4):
|
||
|
|
"""
|
||
|
|
Create a ShardedWorker for per-symbol fulfilment state.
|
||
|
|
|
||
|
|
Each symbol gets its own ASExGuardedState + ASExWorker.
|
||
|
|
No two workers ever touch the same accumulator.
|
||
|
|
"""
|
||
|
|
return ShardedWorker(n_partitions, GuardedFulfilmentState)
|