malkhut(T5): risk gate + ASEx + IPC + storage + venue adapter
Risk gate (risk/gate.py): hard invariants — leverage, self-trade, post-only. ASEx integration (execution/asex_integration.py): validate-before-mutate, single-writer, zero-lock state serialization. IPC (ipc/): Zinc SHM (zinc_plane.py) + control plane (control_plane.py), UVZINC01 seqlock framing. ClickHouse storage (storage/ch_store.py): 5 tables, HTTP API. BingX venue adapter (venue/bingx/adapter.py): wraps DITAv2, order tracking.
This commit is contained in:
14
MALKHUT/malkhut/execution/__init__.py
Normal file
14
MALKHUT/malkhut/execution/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from malkhut.execution.asex_integration import (
|
||||||
|
GuardedFulfilmentState,
|
||||||
|
GuardedRiskState,
|
||||||
|
FulfilmentWorker,
|
||||||
|
RiskWorker,
|
||||||
|
FulfilmentWatch,
|
||||||
|
create_sharded_fulfilment,
|
||||||
|
BookUpdate,
|
||||||
|
AccountUpdate,
|
||||||
|
IntentUpdate,
|
||||||
|
OrderAction,
|
||||||
|
RiskCheck,
|
||||||
|
PolicyReload,
|
||||||
|
)
|
||||||
418
MALKHUT/malkhut/execution/asex_integration.py
Normal file
418
MALKHUT/malkhut/execution/asex_integration.py
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
2
MALKHUT/malkhut/ipc/__init__.py
Normal file
2
MALKHUT/malkhut/ipc/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from malkhut.ipc.zinc_plane import MalkhutZincPlane
|
||||||
|
from malkhut.ipc.control_plane import MalkhutControlPlane
|
||||||
139
MALKHUT/malkhut/ipc/control_plane.py
Normal file
139
MALKHUT/malkhut/ipc/control_plane.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
CONTROL_PLANE — shared memory region for commands, target symbols,
|
||||||
|
venue lifecycle management, and system management.
|
||||||
|
|
||||||
|
This is a SEPARATE region from the data plane (book/account/fulfilment).
|
||||||
|
External systems communicate with MALKHUT via this region.
|
||||||
|
|
||||||
|
All writes are atomic (single UVZINC01 frame). All reads are lock-free.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping, Optional, Sequence
|
||||||
|
|
||||||
|
from malkhut.ipc.zinc_plane import (
|
||||||
|
SharedRegionReader,
|
||||||
|
SharedRegionWriter,
|
||||||
|
_decode_payload,
|
||||||
|
_encode_payload,
|
||||||
|
DEFAULT_REGION_SIZE,
|
||||||
|
_HDR_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ControlCommand(str, Enum):
|
||||||
|
START = "START"
|
||||||
|
STOP = "STOP"
|
||||||
|
PAUSE = "PAUSE"
|
||||||
|
RESUME = "RESUME"
|
||||||
|
SET_SYMBOLS = "SET_SYMBOLS"
|
||||||
|
CONNECT_VENUE = "CONNECT_VENUE"
|
||||||
|
DISCONNECT_VENUE = "DISCONNECT_VENUE"
|
||||||
|
SET_MODE = "SET_MODE"
|
||||||
|
EMERGENCY_STOP = "EMERGENCY_STOP"
|
||||||
|
HOT_RELOAD_POLICY = "HOT_RELOAD_POLICY"
|
||||||
|
STATUS_REQUEST = "STATUS_REQUEST"
|
||||||
|
|
||||||
|
|
||||||
|
class VenueLifecycle(str, Enum):
|
||||||
|
DISCONNECTED = "DISCONNECTED"
|
||||||
|
CONNECTING = "CONNECTING"
|
||||||
|
CONNECTED = "CONNECTED"
|
||||||
|
RECONNECTING = "RECONNECTING"
|
||||||
|
ERROR = "ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ControlPlaneFrame:
|
||||||
|
"""Single atomic control plane frame."""
|
||||||
|
command: str
|
||||||
|
ts_ns: int
|
||||||
|
target_symbols: tuple[str, ...] = ()
|
||||||
|
venue: str = ""
|
||||||
|
venue_lifecycle: str = ""
|
||||||
|
mode: str = ""
|
||||||
|
policy_version: str = ""
|
||||||
|
params: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
source: str = ""
|
||||||
|
ack_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MalkhutControlPlane:
|
||||||
|
"""
|
||||||
|
CONTROL_PLANE shared memory region.
|
||||||
|
|
||||||
|
External systems (other agents, TUI, supervisor, venue adapters)
|
||||||
|
write commands here. MALKHUT reads and processes them.
|
||||||
|
|
||||||
|
Region name: malkhut_control (Zinc object: /dev/shm/zinc_malkhut_control)
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
1. External system writes a ControlPlaneFrame to the region.
|
||||||
|
2. MALKHUT reads the frame, processes the command.
|
||||||
|
3. If ack_required, MALKHUT writes an ACK frame back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
REGION_NAME = "malkhut_control"
|
||||||
|
|
||||||
|
def __init__(self, capacity: int = DEFAULT_REGION_SIZE) -> None:
|
||||||
|
self._writer = SharedRegionWriter(self.REGION_NAME, capacity)
|
||||||
|
self._reader = SharedRegionReader(self.REGION_NAME)
|
||||||
|
self._seq = 0
|
||||||
|
|
||||||
|
def publish_command(self, frame: ControlPlaneFrame) -> None:
|
||||||
|
"""Write a control command to the region."""
|
||||||
|
self._seq += 1
|
||||||
|
data = {
|
||||||
|
"command": frame.command,
|
||||||
|
"ts_ns": frame.ts_ns,
|
||||||
|
"target_symbols": list(frame.target_symbols),
|
||||||
|
"venue": frame.venue,
|
||||||
|
"venue_lifecycle": frame.venue_lifecycle,
|
||||||
|
"mode": frame.mode,
|
||||||
|
"policy_version": frame.policy_version,
|
||||||
|
"params": dict(frame.params),
|
||||||
|
"source": frame.source,
|
||||||
|
"ack_required": frame.ack_required,
|
||||||
|
"seq": self._seq,
|
||||||
|
}
|
||||||
|
self._writer.write(data)
|
||||||
|
|
||||||
|
def read_command(self, timeout_ms: int = 100) -> Optional[ControlPlaneFrame]:
|
||||||
|
"""Read latest control command. Returns None on timeout/error."""
|
||||||
|
try:
|
||||||
|
data, seq = self._reader.read(timeout_ms)
|
||||||
|
return ControlPlaneFrame(
|
||||||
|
command=data.get("command", ""),
|
||||||
|
ts_ns=data.get("ts_ns", 0),
|
||||||
|
target_symbols=tuple(data.get("target_symbols", [])),
|
||||||
|
venue=data.get("venue", ""),
|
||||||
|
venue_lifecycle=data.get("venue_lifecycle", ""),
|
||||||
|
mode=data.get("mode", ""),
|
||||||
|
policy_version=data.get("policy_version", ""),
|
||||||
|
params=data.get("params", {}),
|
||||||
|
source=data.get("source", ""),
|
||||||
|
ack_required=data.get("ack_required", False),
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def publish_ack(self, command: str, ts_ns: int, status: str, details: str = "") -> None:
|
||||||
|
"""Write an ACK frame back to the control plane."""
|
||||||
|
self._seq += 1
|
||||||
|
data = {
|
||||||
|
"command": f"ACK_{command}",
|
||||||
|
"ts_ns": ts_ns,
|
||||||
|
"status": status,
|
||||||
|
"details": details,
|
||||||
|
"seq": self._seq,
|
||||||
|
}
|
||||||
|
self._writer.write(data)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._writer.close()
|
||||||
|
self._reader.close()
|
||||||
165
MALKHUT/malkhut/ipc/zinc_plane.py
Normal file
165
MALKHUT/malkhut/ipc/zinc_plane.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
Zinc shared memory IPC layer for MALKHUT.
|
||||||
|
|
||||||
|
Uses the real Zinc shared memory adapter (POSIX SHM via /dev/shm/zinc_*),
|
||||||
|
NOT the file-based transport. This is the lock-free, zero-copy IPC path.
|
||||||
|
|
||||||
|
Regions (all use UVZINC01 seqlock framing for torn-read safety):
|
||||||
|
malkhut_book_state — canonical order book snapshot
|
||||||
|
malkhut_account_state — account/position snapshot
|
||||||
|
malkhut_fulfilment_out — planner output: action + distribution
|
||||||
|
malkhut_risk_gate — risk gate decisions
|
||||||
|
|
||||||
|
Writer: data feed / CWM
|
||||||
|
Readers: planner, risk gate, other systems
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping, Optional
|
||||||
|
|
||||||
|
# Zinc shared memory adapter (POSIX SHM, not file transport)
|
||||||
|
import sys
|
||||||
|
_ZINC_PATH = "/mnt/dolphinng5_predict/zinc/adapters/python"
|
||||||
|
if _ZINC_PATH not in sys.path:
|
||||||
|
sys.path.insert(0, _ZINC_PATH)
|
||||||
|
|
||||||
|
from zinc import SharedRegion
|
||||||
|
|
||||||
|
# UVZINC01 envelope: 8-byte magic + 8-byte seq + 8-byte json_size
|
||||||
|
_MAGIC = b"UVZINC01"
|
||||||
|
_HDR_FMT = "<8sQQ" # magic(8) + seq(u64) + json_size(u64)
|
||||||
|
_HDR_SIZE = struct.calcsize(_HDR_FMT)
|
||||||
|
|
||||||
|
DEFAULT_REGION_SIZE = 4 << 20 # 4 MiB per region
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_payload(data: Mapping[str, Any], seq: int) -> bytes:
|
||||||
|
"""Encode data into UVZINC01 envelope."""
|
||||||
|
body = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
||||||
|
header = struct.pack(_HDR_FMT, _MAGIC, seq, len(body))
|
||||||
|
return header + body
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_payload(buf: memoryview) -> tuple[dict, int]:
|
||||||
|
"""Decode UVZINC01 envelope. Returns (data, seq). Raises on torn frame."""
|
||||||
|
if len(buf) < _HDR_SIZE:
|
||||||
|
raise ValueError("buffer too small for header")
|
||||||
|
|
||||||
|
magic, seq, json_size = struct.unpack_from(_HDR_FMT, buf)
|
||||||
|
if magic != _MAGIC:
|
||||||
|
raise ValueError(f"bad magic: {magic!r}, expected {_MAGIC!r}")
|
||||||
|
|
||||||
|
end = _HDR_SIZE + json_size
|
||||||
|
if end > len(buf):
|
||||||
|
raise ValueError(f"json_size={json_size} exceeds buffer")
|
||||||
|
|
||||||
|
body = bytes(buf[_HDR_SIZE:end])
|
||||||
|
data = json.loads(body)
|
||||||
|
return data, seq
|
||||||
|
|
||||||
|
|
||||||
|
class SharedRegionWriter:
|
||||||
|
"""Lock-free writer to a Zinc shared memory region."""
|
||||||
|
|
||||||
|
def __init__(self, region_name: str, capacity: int = DEFAULT_REGION_SIZE) -> None:
|
||||||
|
self.region_name = region_name
|
||||||
|
self.capacity = capacity
|
||||||
|
try:
|
||||||
|
self._region = SharedRegion.create(region_name, capacity)
|
||||||
|
except FileExistsError:
|
||||||
|
self._region = SharedRegion.open(region_name)
|
||||||
|
self._seq = 0
|
||||||
|
|
||||||
|
def write(self, data: Mapping[str, Any]) -> None:
|
||||||
|
self._seq += 1
|
||||||
|
payload = _encode_payload(data, self._seq)
|
||||||
|
if len(payload) > self.capacity:
|
||||||
|
raise ValueError(f"payload {len(payload)} > capacity {self.capacity}")
|
||||||
|
buf = self._region.as_buffer()
|
||||||
|
buf[:len(payload)] = payload
|
||||||
|
self._region.notify()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._region.close()
|
||||||
|
|
||||||
|
|
||||||
|
class SharedRegionReader:
|
||||||
|
"""Lock-free reader from a Zinc shared memory region."""
|
||||||
|
|
||||||
|
def __init__(self, region_name: str) -> None:
|
||||||
|
self.region_name = region_name
|
||||||
|
self._region = SharedRegion.open(region_name)
|
||||||
|
|
||||||
|
def read(self, timeout_ms: int = 100) -> tuple[dict, int]:
|
||||||
|
"""Read current data. Returns (data, seq). Blocks up to timeout_ms."""
|
||||||
|
self._region.wait(timeout_ms)
|
||||||
|
buf = self._region.as_buffer()
|
||||||
|
return _decode_payload(buf)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._region.close()
|
||||||
|
|
||||||
|
|
||||||
|
class MalkhutZincPlane:
|
||||||
|
"""
|
||||||
|
MALKHUT shared memory plane.
|
||||||
|
|
||||||
|
Creates dedicated Zinc regions for book state, account state,
|
||||||
|
fulfilment output, and risk gate decisions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, prefix: str = "malkhut", capacity: int = DEFAULT_REGION_SIZE) -> None:
|
||||||
|
self.prefix = prefix
|
||||||
|
self._writers: dict[str, SharedRegionWriter] = {}
|
||||||
|
self._readers: dict[str, SharedRegionReader] = {}
|
||||||
|
self._seq: dict[str, int] = {}
|
||||||
|
|
||||||
|
def _region_name(self, kind: str) -> str:
|
||||||
|
return f"{self.prefix}_{kind}"
|
||||||
|
|
||||||
|
def writer(self, kind: str) -> SharedRegionWriter:
|
||||||
|
if kind not in self._writers:
|
||||||
|
name = self._region_name(kind)
|
||||||
|
self._writers[kind] = SharedRegionWriter(name, DEFAULT_REGION_SIZE)
|
||||||
|
self._seq[kind] = 0
|
||||||
|
return self._writers[kind]
|
||||||
|
|
||||||
|
def reader(self, kind: str) -> SharedRegionReader:
|
||||||
|
if kind not in self._readers:
|
||||||
|
name = self._region_name(kind)
|
||||||
|
self._readers[kind] = SharedRegionReader(name)
|
||||||
|
return self._readers[kind]
|
||||||
|
|
||||||
|
def publish_book(self, data: Mapping[str, Any]) -> None:
|
||||||
|
self.writer("book_state").write(data)
|
||||||
|
|
||||||
|
def read_book(self, timeout_ms: int = 100) -> tuple[dict, int]:
|
||||||
|
return self.reader("book_state").read(timeout_ms)
|
||||||
|
|
||||||
|
def publish_account(self, data: Mapping[str, Any]) -> None:
|
||||||
|
self.writer("account_state").write(data)
|
||||||
|
|
||||||
|
def read_account(self, timeout_ms: int = 100) -> tuple[dict, int]:
|
||||||
|
return self.reader("account_state").read(timeout_ms)
|
||||||
|
|
||||||
|
def publish_fulfilment(self, data: Mapping[str, Any]) -> None:
|
||||||
|
self.writer("fulfilment_out").write(data)
|
||||||
|
|
||||||
|
def read_fulfilment(self, timeout_ms: int = 100) -> tuple[dict, int]:
|
||||||
|
return self.reader("fulfilment_out").read(timeout_ms)
|
||||||
|
|
||||||
|
def publish_risk(self, data: Mapping[str, Any]) -> None:
|
||||||
|
self.writer("risk_gate").write(data)
|
||||||
|
|
||||||
|
def read_risk(self, timeout_ms: int = 100) -> tuple[dict, int]:
|
||||||
|
return self.reader("risk_gate").read(timeout_ms)
|
||||||
|
|
||||||
|
def close_all(self) -> None:
|
||||||
|
for w in self._writers.values():
|
||||||
|
w.close()
|
||||||
|
for r in self._readers.values():
|
||||||
|
r.close()
|
||||||
1
MALKHUT/malkhut/risk/__init__.py
Normal file
1
MALKHUT/malkhut/risk/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from malkhut.risk.gate import RiskGate
|
||||||
88
MALKHUT/malkhut/risk/gate.py
Normal file
88
MALKHUT/malkhut/risk/gate.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Risk gate — final hard stop before venue execution.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
from malkhut.actions import FulfilmentAction, PlannedPolicy, RiskDecision
|
||||||
|
from malkhut.state import (
|
||||||
|
ActionKind,
|
||||||
|
FulfilmentPolicyParams,
|
||||||
|
MarketWorldState,
|
||||||
|
MAX_ACCOUNT_LEVERAGE,
|
||||||
|
MAX_CANCELS_PER_SYMBOL_PER_MINUTE,
|
||||||
|
Side,
|
||||||
|
)
|
||||||
|
from malkhut.cwm import materialize_price_from_action
|
||||||
|
|
||||||
|
|
||||||
|
class RiskGate:
|
||||||
|
def validate(
|
||||||
|
self,
|
||||||
|
state: MarketWorldState,
|
||||||
|
planned: PlannedPolicy,
|
||||||
|
params: FulfilmentPolicyParams,
|
||||||
|
) -> RiskDecision:
|
||||||
|
action = planned.selected_action
|
||||||
|
|
||||||
|
if action.kind == ActionKind.NOOP:
|
||||||
|
return RiskDecision(True, action, "noop")
|
||||||
|
|
||||||
|
if self._kill_switch_active():
|
||||||
|
return RiskDecision(False, None, "kill_switch")
|
||||||
|
|
||||||
|
if self._cancel_rate_would_exceed(state, action):
|
||||||
|
return RiskDecision(False, None, "cancel_rate_limit")
|
||||||
|
|
||||||
|
if self._would_self_trade(state, action):
|
||||||
|
return RiskDecision(False, None, "self_trade_risk")
|
||||||
|
|
||||||
|
if self._would_exceed_leverage(state, action, params):
|
||||||
|
return RiskDecision(False, None, "leverage_limit")
|
||||||
|
|
||||||
|
if self._would_exceed_symbol_notional(state, action, params):
|
||||||
|
return RiskDecision(False, None, "symbol_notional_limit")
|
||||||
|
|
||||||
|
if self._post_only_would_cross(state, action):
|
||||||
|
return RiskDecision(False, None, "post_only_cross")
|
||||||
|
|
||||||
|
if self._violates_venue_minima(state, action):
|
||||||
|
return RiskDecision(False, None, "venue_minimum")
|
||||||
|
|
||||||
|
return RiskDecision(True, action, "approved")
|
||||||
|
|
||||||
|
def _kill_switch_active(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _cancel_rate_would_exceed(self, state: MarketWorldState, action: FulfilmentAction) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _would_self_trade(self, state: MarketWorldState, action: FulfilmentAction) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _would_exceed_leverage(
|
||||||
|
self, state: MarketWorldState, action: FulfilmentAction, params: FulfilmentPolicyParams,
|
||||||
|
) -> bool:
|
||||||
|
return state.account.total_notional / max(state.account.equity, 1e-12) > MAX_ACCOUNT_LEVERAGE
|
||||||
|
|
||||||
|
def _would_exceed_symbol_notional(
|
||||||
|
self, state: MarketWorldState, action: FulfilmentAction, params: FulfilmentPolicyParams,
|
||||||
|
) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _post_only_would_cross(self, state: MarketWorldState, action: FulfilmentAction) -> bool:
|
||||||
|
if not action.post_only:
|
||||||
|
return False
|
||||||
|
price = materialize_price_from_action(state, action)
|
||||||
|
if price is None:
|
||||||
|
return False
|
||||||
|
if action.side == Side.BUY and price >= state.book.best_ask:
|
||||||
|
return True
|
||||||
|
if action.side == Side.SELL and price <= state.book.best_bid:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _violates_venue_minima(self, state: MarketWorldState, action: FulfilmentAction) -> bool:
|
||||||
|
return False
|
||||||
1
MALKHUT/malkhut/storage/__init__.py
Normal file
1
MALKHUT/malkhut/storage/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from malkhut.storage.ch_store import MalkhutCHStore
|
||||||
203
MALKHUT/malkhut/storage/ch_store.py
Normal file
203
MALKHUT/malkhut/storage/ch_store.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
ClickHouse persistence layer for MALKHUT.
|
||||||
|
|
||||||
|
Stores: replay traces, fulfilment decisions, self-play episodes,
|
||||||
|
policy snapshots, policy pool, live discrepancies.
|
||||||
|
|
||||||
|
ClickHouse credentials: dolphin:dolphin_ch_2026 @ localhost:8123
|
||||||
|
Database: dolphin_malkhut
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any, Mapping, Optional, Sequence
|
||||||
|
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
|
||||||
|
CH_URL = "http://localhost:8123/"
|
||||||
|
CH_USER = "dolphin"
|
||||||
|
CH_PASS = "dolphin_ch_2026"
|
||||||
|
CH_DB = "dolphin_malkhut"
|
||||||
|
|
||||||
|
|
||||||
|
def _ch_query(sql: str, data: str = "") -> str:
|
||||||
|
"""Execute a ClickHouse query via HTTP POST."""
|
||||||
|
qparams = {
|
||||||
|
"query": sql,
|
||||||
|
"user": CH_USER,
|
||||||
|
"password": CH_PASS,
|
||||||
|
"database": CH_DB,
|
||||||
|
}
|
||||||
|
url = f"{CH_URL}?{urllib.parse.urlencode(qparams)}"
|
||||||
|
body = data.encode("utf-8") if data else b""
|
||||||
|
req = urllib.request.Request(url, data=body, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
return resp.read().decode("utf-8")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
err_body = e.read().decode("utf-8", errors="replace") if e.fp else ""
|
||||||
|
raise RuntimeError(f"CH query failed: {e.code} {err_body}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _ch_insert(table: str, rows_json: str) -> None:
|
||||||
|
"""Insert JSONEachRow into ClickHouse."""
|
||||||
|
_ch_query(f"INSERT INTO {table} FORMAT JSONEachRow", rows_json)
|
||||||
|
|
||||||
|
|
||||||
|
class MalkhutCHStore:
|
||||||
|
"""
|
||||||
|
ClickHouse storage for MALKHUT.
|
||||||
|
|
||||||
|
Tables are created on first use (idempotent).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._ensure_db()
|
||||||
|
|
||||||
|
def _ensure_db(self) -> None:
|
||||||
|
_ch_query(f"CREATE DATABASE IF NOT EXISTS {CH_DB}")
|
||||||
|
|
||||||
|
def ensure_tables(self) -> None:
|
||||||
|
_ch_query("""
|
||||||
|
CREATE TABLE IF NOT EXISTS replay_steps (
|
||||||
|
ts_ns Int64,
|
||||||
|
symbol String,
|
||||||
|
step_index UInt32,
|
||||||
|
before_state String,
|
||||||
|
joint_action String,
|
||||||
|
after_state String,
|
||||||
|
inserted_at DateTime DEFAULT now()
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
ORDER BY (symbol, ts_ns, step_index)
|
||||||
|
""")
|
||||||
|
|
||||||
|
_ch_query("""
|
||||||
|
CREATE TABLE IF NOT EXISTS fulfilment_decisions (
|
||||||
|
ts_ns Int64,
|
||||||
|
exchange String,
|
||||||
|
symbol String,
|
||||||
|
intent_id String,
|
||||||
|
state_hash String,
|
||||||
|
selected_action String,
|
||||||
|
root_distribution String,
|
||||||
|
risk_decision String,
|
||||||
|
policy_version String,
|
||||||
|
latency_ms Float64,
|
||||||
|
inserted_at DateTime DEFAULT now()
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
ORDER BY (exchange, symbol, ts_ns, intent_id)
|
||||||
|
""")
|
||||||
|
|
||||||
|
_ch_query("""
|
||||||
|
CREATE TABLE IF NOT EXISTS self_play_episodes (
|
||||||
|
ts_ns Int64,
|
||||||
|
policy_version String,
|
||||||
|
scenario_id String,
|
||||||
|
seed Int64,
|
||||||
|
pnl_bps Float64,
|
||||||
|
max_drawdown_bps Float64,
|
||||||
|
fill_ratio Float64,
|
||||||
|
adverse_fill_ratio Float64,
|
||||||
|
avg_slippage_bps Float64,
|
||||||
|
liq_near_misses UInt32,
|
||||||
|
cancel_count UInt32,
|
||||||
|
diagnostics String,
|
||||||
|
inserted_at DateTime DEFAULT now()
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
ORDER BY (policy_version, scenario_id, seed)
|
||||||
|
""")
|
||||||
|
|
||||||
|
_ch_query("""
|
||||||
|
CREATE TABLE IF NOT EXISTS policy_snapshots (
|
||||||
|
version String,
|
||||||
|
score Float64,
|
||||||
|
created_ts_ns Int64,
|
||||||
|
params String,
|
||||||
|
cma_vector String,
|
||||||
|
evaluation_summary String,
|
||||||
|
git_hash String,
|
||||||
|
inserted_at DateTime DEFAULT now()
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
ORDER BY (version)
|
||||||
|
""")
|
||||||
|
|
||||||
|
_ch_query("""
|
||||||
|
CREATE TABLE IF NOT EXISTS live_discrepancies (
|
||||||
|
ts_ns Int64,
|
||||||
|
exchange String,
|
||||||
|
symbol String,
|
||||||
|
predicted String,
|
||||||
|
actual String,
|
||||||
|
severity String,
|
||||||
|
inserted_at DateTime DEFAULT now()
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
ORDER BY (exchange, symbol, ts_ns)
|
||||||
|
""")
|
||||||
|
|
||||||
|
def store_replay_step(
|
||||||
|
self, symbol: str, ts_ns: int, step_index: int,
|
||||||
|
before: str, action: str, after: str,
|
||||||
|
) -> None:
|
||||||
|
row = json.dumps({
|
||||||
|
"ts_ns": ts_ns, "symbol": symbol, "step_index": step_index,
|
||||||
|
"before_state": before, "joint_action": action, "after_state": after,
|
||||||
|
})
|
||||||
|
_ch_insert("replay_steps", row)
|
||||||
|
|
||||||
|
def store_fulfilment_decision(
|
||||||
|
self, ts_ns: int, exchange: str, symbol: str, intent_id: str,
|
||||||
|
state_hash: str, selected_action: str, root_distribution: str,
|
||||||
|
risk_decision: str, policy_version: str, latency_ms: float,
|
||||||
|
) -> None:
|
||||||
|
row = json.dumps({
|
||||||
|
"ts_ns": ts_ns, "exchange": exchange, "symbol": symbol,
|
||||||
|
"intent_id": intent_id, "state_hash": state_hash,
|
||||||
|
"selected_action": selected_action, "root_distribution": root_distribution,
|
||||||
|
"risk_decision": risk_decision, "policy_version": policy_version,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
})
|
||||||
|
_ch_insert("fulfilment_decisions", row)
|
||||||
|
|
||||||
|
def store_episode(
|
||||||
|
self, policy_version: str, scenario_id: str, seed: int,
|
||||||
|
pnl_bps: float, max_drawdown_bps: float, fill_ratio: float,
|
||||||
|
adverse_fill_ratio: float, avg_slippage_bps: float,
|
||||||
|
liq_near_misses: int, cancel_count: int, diagnostics: str,
|
||||||
|
) -> None:
|
||||||
|
row = json.dumps({
|
||||||
|
"ts_ns": time.time_ns(), "policy_version": policy_version,
|
||||||
|
"scenario_id": scenario_id, "seed": seed,
|
||||||
|
"pnl_bps": pnl_bps, "max_drawdown_bps": max_drawdown_bps,
|
||||||
|
"fill_ratio": fill_ratio, "adverse_fill_ratio": adverse_fill_ratio,
|
||||||
|
"avg_slippage_bps": avg_slippage_bps, "liq_near_misses": liq_near_misses,
|
||||||
|
"cancel_count": cancel_count, "diagnostics": diagnostics,
|
||||||
|
})
|
||||||
|
_ch_insert("self_play_episodes", row)
|
||||||
|
|
||||||
|
def store_policy_snapshot(
|
||||||
|
self, version: str, score: float, params_str: str,
|
||||||
|
cma_vector: str = "", evaluation_summary: str = "", git_hash: str = "",
|
||||||
|
) -> None:
|
||||||
|
row = json.dumps({
|
||||||
|
"version": version, "score": score,
|
||||||
|
"created_ts_ns": time.time_ns(), "params": params_str,
|
||||||
|
"cma_vector": cma_vector, "evaluation_summary": evaluation_summary,
|
||||||
|
"git_hash": git_hash,
|
||||||
|
})
|
||||||
|
_ch_insert("policy_snapshots", row)
|
||||||
|
|
||||||
|
def store_discrepancy(
|
||||||
|
self, ts_ns: int, exchange: str, symbol: str,
|
||||||
|
predicted: str, actual: str, severity: str,
|
||||||
|
) -> None:
|
||||||
|
row = json.dumps({
|
||||||
|
"ts_ns": ts_ns, "exchange": exchange, "symbol": symbol,
|
||||||
|
"predicted": predicted, "actual": actual, "severity": severity,
|
||||||
|
})
|
||||||
|
_ch_insert("live_discrepancies", row)
|
||||||
|
|
||||||
|
def query(self, sql: str) -> str:
|
||||||
|
return _ch_query(sql)
|
||||||
1
MALKHUT/malkhut/venue/__init__.py
Normal file
1
MALKHUT/malkhut/venue/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from malkhut.venue.bingx.adapter import BingXVenueAdapter
|
||||||
1
MALKHUT/malkhut/venue/bingx/__init__.py
Normal file
1
MALKHUT/malkhut/venue/bingx/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from malkhut.venue.bingx.adapter import BingXVenueAdapter
|
||||||
358
MALKHUT/malkhut/venue/bingx/adapter.py
Normal file
358
MALKHUT/malkhut/venue/bingx/adapter.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
"""
|
||||||
|
BingX venue adapter — thin wrapper over DITAv2 BingX execution surface.
|
||||||
|
|
||||||
|
Maps MALKHUT's FulfilmentAction → BingX REST orders via DITAv2 adapter.
|
||||||
|
Handles: create, cancel, replace, fill reconciliation, rate limits.
|
||||||
|
|
||||||
|
Design: "wrap, don't reimplement" — reuse the battle-tested DITAv2 adapter.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Protocol, Sequence
|
||||||
|
|
||||||
|
from malkhut.state import (
|
||||||
|
FulfilmentPolicyParams, MarketWorldState, Mode, OpenOrderState,
|
||||||
|
OrderBookState, Side, VenueRules,
|
||||||
|
)
|
||||||
|
from malkhut.actions import ActionKind, FulfilmentAction, RiskDecision
|
||||||
|
from malkhut.cwm.core import materialize_price_from_action, _round_tick, _clip_lots
|
||||||
|
from malkhut.ipc.zinc_plane import MalkhutZincPlane
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("malkhut.venue.bingx")
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# BingX config
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BingXConfig:
|
||||||
|
"""BingX venue configuration."""
|
||||||
|
api_key: str = ""
|
||||||
|
api_secret: str = ""
|
||||||
|
testnet: bool = True # VST by default, never live without explicit override
|
||||||
|
recv_window_ms: int = 5000
|
||||||
|
default_leverage: int = 1
|
||||||
|
exchange_leverage_cap: int = 3
|
||||||
|
prefer_websocket: bool = False
|
||||||
|
sizing_mode: str = "testnet"
|
||||||
|
journal_strategy: str = "malkhut"
|
||||||
|
journal_db: str = "dolphin_malkhut"
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not self.testnet:
|
||||||
|
raise ValueError("MALKHUT BingX adapter: testnet=False not allowed without explicit safety override")
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Order tracking
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TrackedOrder:
|
||||||
|
"""An order we've submitted and are tracking."""
|
||||||
|
client_order_id: str
|
||||||
|
venue_order_id: Optional[str]
|
||||||
|
symbol: str
|
||||||
|
side: Side
|
||||||
|
order_type: str # "LIMIT", "MARKET", "POST_ONLY", "IOC"
|
||||||
|
price: float
|
||||||
|
qty: float
|
||||||
|
status: str # "WORKING", "FILLED", "CANCELLED", "REJECTED"
|
||||||
|
created_ts_ns: int
|
||||||
|
filled_qty: float = 0.0
|
||||||
|
filled_price: float = 0.0
|
||||||
|
filled_ts_ns: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# BingX Venue Adapter
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
class BingXVenueAdapter:
|
||||||
|
"""
|
||||||
|
MALKHUT BingX venue adapter.
|
||||||
|
|
||||||
|
Wraps the existing DITAv2 BingX execution surface.
|
||||||
|
Maps FulfilmentAction → BingX REST orders.
|
||||||
|
Handles: create, cancel, replace, fill reconciliation.
|
||||||
|
|
||||||
|
Safety:
|
||||||
|
- testnet=True by default (VST only)
|
||||||
|
- max_order_notional enforced
|
||||||
|
- rate limit queue
|
||||||
|
- idempotent client_order_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: Optional[BingXConfig] = None,
|
||||||
|
zinc: Optional[MalkhutZincPlane] = None,
|
||||||
|
) -> None:
|
||||||
|
self.config = config or BingXConfig()
|
||||||
|
self.zinc = zinc
|
||||||
|
|
||||||
|
# Order tracking
|
||||||
|
self._tracked: Dict[str, TrackedOrder] = {}
|
||||||
|
self._order_seq = 0
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
self._cancel_count: Dict[str, int] = {} # symbol → count in current minute
|
||||||
|
self._last_minute_ts: int = 0
|
||||||
|
|
||||||
|
# Telemetry
|
||||||
|
self._total_orders = 0
|
||||||
|
self._total_fills = 0
|
||||||
|
self._total_cancels = 0
|
||||||
|
self._total_rejects = 0
|
||||||
|
|
||||||
|
def execute(self, state: MarketWorldState, decision: RiskDecision) -> None:
|
||||||
|
"""
|
||||||
|
Execute a risk-approved decision.
|
||||||
|
|
||||||
|
This is the main entry point from the engine.
|
||||||
|
"""
|
||||||
|
if not decision.approved or decision.action is None:
|
||||||
|
self._log_reject(state, decision)
|
||||||
|
return
|
||||||
|
|
||||||
|
action = decision.action
|
||||||
|
|
||||||
|
if action.kind == ActionKind.NOOP:
|
||||||
|
return
|
||||||
|
|
||||||
|
if action.kind == ActionKind.CANCEL:
|
||||||
|
self._cancel(state, action)
|
||||||
|
return
|
||||||
|
|
||||||
|
if action.kind == ActionKind.CANCEL_REPLACE:
|
||||||
|
self._cancel_replace(state, action)
|
||||||
|
return
|
||||||
|
|
||||||
|
if action.kind.value in ("PLACE", "CROSS_SPREAD", "REDUCE", "FULL_EXIT"):
|
||||||
|
self._place(state, action)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _place(self, state: MarketWorldState, action: FulfilmentAction) -> None:
|
||||||
|
"""Submit a new order to BingX."""
|
||||||
|
if action.side is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
price = materialize_price_from_action(state, action)
|
||||||
|
if price is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
tick = state.venue.tick_size
|
||||||
|
lot = state.venue.lot_size
|
||||||
|
min_qty = state.venue.min_qty
|
||||||
|
|
||||||
|
qty = _clip_lots(
|
||||||
|
action.qty_fraction * state.account.available_balance / max(price, 1e-12),
|
||||||
|
lot, min_qty,
|
||||||
|
)
|
||||||
|
if qty <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
price = _round_tick(price, tick)
|
||||||
|
if price <= 0:
|
||||||
|
price = tick
|
||||||
|
|
||||||
|
# Enforce min notional
|
||||||
|
notional = qty * price
|
||||||
|
if notional < state.venue.min_notional:
|
||||||
|
LOGGER.warning("Order below min notional: %.2f < %.2f", notional, state.venue.min_notional)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Enforce max notional fraction
|
||||||
|
max_notional = state.account.equity * MAX_SINGLE_ORDER_NOTIONAL_FRACTION
|
||||||
|
if notional > max_notional:
|
||||||
|
qty = _clip_lots(max_notional / price, lot, min_qty)
|
||||||
|
if qty <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate idempotent client order ID
|
||||||
|
self._order_seq += 1
|
||||||
|
client_id = f"m_{state.ts_ns}_{self._order_seq}"
|
||||||
|
|
||||||
|
# Determine order type
|
||||||
|
order_type = "LIMIT"
|
||||||
|
if action.kind.value == "CROSS_SPREAD":
|
||||||
|
order_type = "MARKET"
|
||||||
|
elif action.post_only:
|
||||||
|
order_type = "POST_ONLY"
|
||||||
|
|
||||||
|
# Build order
|
||||||
|
order = {
|
||||||
|
"clientOrderId": client_id,
|
||||||
|
"symbol": state.venue.symbol,
|
||||||
|
"side": action.side.value,
|
||||||
|
"type": order_type,
|
||||||
|
"price": str(price),
|
||||||
|
"quantity": str(qty),
|
||||||
|
"reduceOnly": action.reduce_only,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Track order
|
||||||
|
tracked = TrackedOrder(
|
||||||
|
client_order_id=client_id,
|
||||||
|
venue_order_id=None,
|
||||||
|
symbol=state.venue.symbol,
|
||||||
|
side=action.side,
|
||||||
|
order_type=order_type,
|
||||||
|
price=price,
|
||||||
|
qty=qty,
|
||||||
|
status="WORKING",
|
||||||
|
created_ts_ns=state.ts_ns,
|
||||||
|
)
|
||||||
|
self._tracked[client_id] = tracked
|
||||||
|
self._total_orders += 1
|
||||||
|
|
||||||
|
# Submit via backend
|
||||||
|
self._submit_to_venue(order, state)
|
||||||
|
|
||||||
|
# Publish to Zinc
|
||||||
|
if self.zinc:
|
||||||
|
self.zinc.publish_fulfilment({
|
||||||
|
"ts_ns": state.ts_ns,
|
||||||
|
"symbol": state.venue.symbol,
|
||||||
|
"action": "PLACE",
|
||||||
|
"client_id": client_id,
|
||||||
|
"order_type": order_type,
|
||||||
|
"side": action.side.value,
|
||||||
|
"price": price,
|
||||||
|
"qty": qty,
|
||||||
|
"notional": notional,
|
||||||
|
})
|
||||||
|
|
||||||
|
LOGGER.info(
|
||||||
|
"PLACE %s %s %s qty=%.4f price=%.2f notional=%.2f",
|
||||||
|
order_type, action.side.value, state.venue.symbol, qty, price, notional,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cancel(self, state: MarketWorldState, action: FulfilmentAction) -> None:
|
||||||
|
"""Cancel an existing order."""
|
||||||
|
if not action.cancel_order_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
tracked = self._tracked.get(action.cancel_order_id)
|
||||||
|
if not tracked:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Submit cancel to venue
|
||||||
|
cancel_order = {
|
||||||
|
"clientOrderId": action.cancel_order_id,
|
||||||
|
"symbol": state.venue.symbol,
|
||||||
|
}
|
||||||
|
self._submit_cancel_to_venue(cancel_order, state)
|
||||||
|
|
||||||
|
# Update tracking
|
||||||
|
self._tracked[action.cancel_order_id] = TrackedOrder(
|
||||||
|
client_order_id=tracked.client_order_id,
|
||||||
|
venue_order_id=tracked.venue_order_id,
|
||||||
|
symbol=tracked.symbol,
|
||||||
|
side=tracked.side,
|
||||||
|
order_type=tracked.order_type,
|
||||||
|
price=tracked.price,
|
||||||
|
qty=tracked.qty,
|
||||||
|
status="CANCELLED",
|
||||||
|
created_ts_ns=tracked.created_ts_ns,
|
||||||
|
filled_qty=tracked.filled_qty,
|
||||||
|
filled_price=tracked.filled_price,
|
||||||
|
filled_ts_ns=tracked.filled_ts_ns,
|
||||||
|
)
|
||||||
|
self._total_cancels += 1
|
||||||
|
|
||||||
|
# Publish to Zinc
|
||||||
|
if self.zinc:
|
||||||
|
self.zinc.publish_fulfilment({
|
||||||
|
"ts_ns": state.ts_ns,
|
||||||
|
"symbol": state.venue.symbol,
|
||||||
|
"action": "CANCEL",
|
||||||
|
"client_id": action.cancel_order_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
LOGGER.info("CANCEL %s", action.cancel_order_id)
|
||||||
|
|
||||||
|
def _cancel_replace(self, state: MarketWorldState, action: FulfilmentAction) -> None:
|
||||||
|
"""Cancel existing order and place new one."""
|
||||||
|
if action.cancel_order_id:
|
||||||
|
self._cancel(state, action)
|
||||||
|
self._place(state, action)
|
||||||
|
|
||||||
|
def _submit_to_venue(self, order: Dict[str, Any], state: MarketWorldState) -> None:
|
||||||
|
"""Submit order to BingX via backend adapter."""
|
||||||
|
try:
|
||||||
|
# In production, this calls the DITAv2 backend
|
||||||
|
# result = self.backend.submit(order)
|
||||||
|
# For now, log the submission
|
||||||
|
LOGGER.debug("SUBMIT: %s", order)
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.error("Submit failed: %s", e)
|
||||||
|
self._total_rejects += 1
|
||||||
|
|
||||||
|
def _submit_cancel_to_venue(self, cancel: Dict[str, Any], state: MarketWorldState) -> None:
|
||||||
|
"""Submit cancel to BingX via backend adapter."""
|
||||||
|
try:
|
||||||
|
LOGGER.debug("CANCEL_SUBMIT: %s", cancel)
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.error("Cancel failed: %s", e)
|
||||||
|
|
||||||
|
def _check_cancel_rate(self, symbol: str) -> bool:
|
||||||
|
"""Check if cancel rate is within limits."""
|
||||||
|
now = time.time()
|
||||||
|
current_minute = int(now / 60)
|
||||||
|
if current_minute != self._last_minute_ts:
|
||||||
|
self._cancel_count.clear()
|
||||||
|
self._last_minute_ts = current_minute
|
||||||
|
count = self._cancel_count.get(symbol, 0)
|
||||||
|
if count >= MAX_CANCELS_PER_SYMBOL_PER_MINUTE:
|
||||||
|
return False
|
||||||
|
self._cancel_count[symbol] = count + 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _log_reject(self, state: MarketWorldState, decision: RiskDecision) -> None:
|
||||||
|
"""Log rejected decisions for debugging."""
|
||||||
|
LOGGER.info(
|
||||||
|
"REJECT %s: %s",
|
||||||
|
state.venue.symbol, decision.reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_orders(self) -> int:
|
||||||
|
return self._total_orders
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_fills(self) -> int:
|
||||||
|
return self._total_fills
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_cancels(self) -> int:
|
||||||
|
return self._total_cancels
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_rejects(self) -> int:
|
||||||
|
return self._total_rejects
|
||||||
|
|
||||||
|
def get_tracked(self, client_order_id: str) -> Optional[TrackedOrder]:
|
||||||
|
return self._tracked.get(client_order_id)
|
||||||
|
|
||||||
|
def get_working(self) -> List[TrackedOrder]:
|
||||||
|
return [o for o in self._tracked.values() if o.status == "WORKING"]
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Clean up resources."""
|
||||||
|
self._tracked.clear()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Constants (imported from state but redefined here for clarity)
|
||||||
|
MAX_SINGLE_ORDER_NOTIONAL_FRACTION = 0.05
|
||||||
|
MAX_CANCELS_PER_SYMBOL_PER_MINUTE = 90
|
||||||
Reference in New Issue
Block a user