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