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:
Codex
2026-07-11 10:31:31 +02:00
parent 863a4cc8c9
commit 6fb55dadcb
12 changed files with 1391 additions and 0 deletions

View 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,
)

View 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)

View File

@@ -0,0 +1,2 @@
from malkhut.ipc.zinc_plane import MalkhutZincPlane
from malkhut.ipc.control_plane import MalkhutControlPlane

View 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()

View 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()

View File

@@ -0,0 +1 @@
from malkhut.risk.gate import RiskGate

View 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

View File

@@ -0,0 +1 @@
from malkhut.storage.ch_store import MalkhutCHStore

View 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)

View File

@@ -0,0 +1 @@
from malkhut.venue.bingx.adapter import BingXVenueAdapter

View File

@@ -0,0 +1 @@
from malkhut.venue.bingx.adapter import BingXVenueAdapter

View 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