First commit of the previously-untracked PINK-on-DITAv2 migration system (execution moves to the Rust kernel; policy stays on legacy DITA, so Alpha Engine algorithmic integrity is preserved). BLUE is untouched. Sprint 0 (safety snapshot + flaw-fix verification, MARKET single-leg scope): - Verified Rust FSM fixes (flaws 2,4,10,11,13) by source read of lib.rs. - Hardened 5 vacuous/guarded assertions in test_flaws.py so each flaw test genuinely exercises its fix. Most important: Flaw 5 now asserts capital moves by EXACTLY realized PnL (was entering/exiting at the same price). - Offline suites: 533 passed, 0 failed (35 flaws + 402 kernel/accounting/ bridge + 96 runtime/persistence/multi-exit/restart/seams). - GATE PASS: MARKET-path-critical flaws 1,2,5 confirmed fixed + green. - Added SPRINT0_FLAW_VERIFICATION.md report and _rust_kernel/.gitignore (excludes Rust target/ build artifacts). LIMIT/partial-fill remain explicitly out of scope (MARKET-only bring-up). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
704 lines
28 KiB
Python
704 lines
28 KiB
Python
"""Rust-backed DITAv2 execution kernel.
|
|
|
|
This module keeps the Python API shape stable while moving the kernel state
|
|
machine into a Rust shared library. Slot views write through to the backend on
|
|
assignment, then the Python side mirrors the resulting state into Zinc and the
|
|
existing projections/journals.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
|
import ctypes
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
from .account import AccountProjection
|
|
from .control import ControlPlane, ControlUpdate, KernelControlSnapshot, KernelVerbosity, build_control_plane
|
|
from .contracts import (
|
|
KernelCommandType,
|
|
KernelDiagnosticCode,
|
|
KernelEventKind,
|
|
KernelIntent,
|
|
KernelOutcome,
|
|
KernelSeverity,
|
|
KernelTransition,
|
|
TradeSide,
|
|
TradeSlot,
|
|
TradeStage,
|
|
VenueEvent,
|
|
VenueOrder,
|
|
VenueOrderStatus,
|
|
VenueEventStatus,
|
|
)
|
|
from .journal import KernelJournal, MemoryKernelJournal
|
|
from .mock_venue import MockVenueAdapter
|
|
from .projection import HazelcastProjection
|
|
from .projection import build_projection
|
|
from .utils import json_safe
|
|
from .venue import VenueAdapter
|
|
from .zinc_plane import InMemoryZincPlane, ZincPlane
|
|
|
|
|
|
def _repo_root() -> Path:
|
|
return Path(__file__).resolve().parents[3]
|
|
|
|
|
|
def _crate_dir() -> Path:
|
|
return Path(__file__).resolve().with_name("_rust_kernel")
|
|
|
|
|
|
def _library_path() -> Path:
|
|
if sys.platform == "darwin":
|
|
name = "libdita_v2_kernel.dylib"
|
|
elif os.name == "nt":
|
|
name = "dita_v2_kernel.dll"
|
|
else:
|
|
name = "libdita_v2_kernel.so"
|
|
return _crate_dir() / "target" / "release" / name
|
|
|
|
|
|
def _build_library() -> None:
|
|
crate_dir = _crate_dir()
|
|
if not crate_dir.exists():
|
|
raise FileNotFoundError(f"Missing Rust kernel crate: {crate_dir}")
|
|
subprocess.run(
|
|
["cargo", "build", "--release", "--manifest-path", str(crate_dir / "Cargo.toml")],
|
|
cwd=_repo_root(),
|
|
check=True,
|
|
)
|
|
|
|
|
|
def _ensure_library() -> Path:
|
|
path = _library_path()
|
|
if not path.exists():
|
|
_build_library()
|
|
return path
|
|
|
|
|
|
class _RustKernelLib:
|
|
def __init__(self) -> None:
|
|
path = _ensure_library()
|
|
self.lib = ctypes.CDLL(str(path))
|
|
self.lib.dita_kernel_create.argtypes = [ctypes.c_size_t]
|
|
self.lib.dita_kernel_create.restype = ctypes.c_void_p
|
|
self.lib.dita_kernel_destroy.argtypes = [ctypes.c_void_p]
|
|
self.lib.dita_kernel_destroy.restype = None
|
|
self.lib.dita_kernel_free_string.argtypes = [ctypes.c_void_p]
|
|
self.lib.dita_kernel_free_string.restype = None
|
|
self.lib.dita_kernel_get_slot_json.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
|
|
self.lib.dita_kernel_get_slot_json.restype = ctypes.c_void_p
|
|
self.lib.dita_kernel_set_slot_json.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_char_p]
|
|
self.lib.dita_kernel_set_slot_json.restype = ctypes.c_int
|
|
self.lib.dita_kernel_process_intent_json.argtypes = [
|
|
ctypes.c_void_p,
|
|
ctypes.c_char_p,
|
|
ctypes.c_char_p,
|
|
ctypes.c_char_p,
|
|
]
|
|
self.lib.dita_kernel_process_intent_json.restype = ctypes.c_void_p
|
|
self.lib.dita_kernel_on_venue_event_json.argtypes = [
|
|
ctypes.c_void_p,
|
|
ctypes.c_char_p,
|
|
ctypes.c_char_p,
|
|
ctypes.c_char_p,
|
|
]
|
|
self.lib.dita_kernel_on_venue_event_json.restype = ctypes.c_void_p
|
|
self.lib.dita_kernel_reconcile_slots_json.argtypes = [
|
|
ctypes.c_void_p,
|
|
ctypes.c_char_p,
|
|
ctypes.c_char_p,
|
|
ctypes.c_char_p,
|
|
]
|
|
self.lib.dita_kernel_reconcile_slots_json.restype = ctypes.c_void_p
|
|
self.lib.dita_kernel_snapshot_json.argtypes = [ctypes.c_void_p]
|
|
self.lib.dita_kernel_snapshot_json.restype = ctypes.c_void_p
|
|
|
|
def create(self, max_slots: int) -> ctypes.c_void_p:
|
|
handle = self.lib.dita_kernel_create(ctypes.c_size_t(max_slots))
|
|
if not handle:
|
|
raise RuntimeError("dita_kernel_create failed")
|
|
return ctypes.c_void_p(handle)
|
|
|
|
def destroy(self, handle: ctypes.c_void_p) -> None:
|
|
if handle and handle.value:
|
|
self.lib.dita_kernel_destroy(handle)
|
|
|
|
def _take_string(self, raw: ctypes.c_void_p) -> str:
|
|
if not raw:
|
|
raise RuntimeError("Rust kernel returned null string")
|
|
text = ctypes.cast(raw, ctypes.c_char_p).value
|
|
if text is None:
|
|
self.lib.dita_kernel_free_string(raw)
|
|
raise RuntimeError("Rust kernel returned empty string")
|
|
try:
|
|
return text.decode("utf-8")
|
|
finally:
|
|
self.lib.dita_kernel_free_string(raw)
|
|
|
|
def get_slot_json(self, handle: ctypes.c_void_p, slot_id: int) -> Dict[str, Any]:
|
|
raw = self.lib.dita_kernel_get_slot_json(handle, ctypes.c_size_t(slot_id))
|
|
if not raw:
|
|
raise IndexError(f"Invalid slot id: {slot_id}")
|
|
return json.loads(self._take_string(raw))
|
|
|
|
def set_slot_json(self, handle: ctypes.c_void_p, slot_id: int, payload: Dict[str, Any]) -> None:
|
|
encoded = json.dumps(json_safe(payload), separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
rc = self.lib.dita_kernel_set_slot_json(handle, ctypes.c_size_t(slot_id), ctypes.c_char_p(encoded))
|
|
if rc != 0:
|
|
raise RuntimeError(f"dita_kernel_set_slot_json failed rc={rc}")
|
|
|
|
def process_intent(
|
|
self,
|
|
handle: ctypes.c_void_p,
|
|
payload: Dict[str, Any],
|
|
*,
|
|
mode: str,
|
|
verbosity: str,
|
|
) -> Dict[str, Any]:
|
|
encoded = json.dumps(json_safe(payload), separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
raw = self.lib.dita_kernel_process_intent_json(
|
|
handle,
|
|
ctypes.c_char_p(encoded),
|
|
ctypes.c_char_p(mode.encode("utf-8")),
|
|
ctypes.c_char_p(verbosity.encode("utf-8")),
|
|
)
|
|
return json.loads(self._take_string(raw))
|
|
|
|
def on_venue_event(
|
|
self,
|
|
handle: ctypes.c_void_p,
|
|
payload: Dict[str, Any],
|
|
*,
|
|
mode: str,
|
|
verbosity: str,
|
|
) -> Dict[str, Any]:
|
|
encoded = json.dumps(json_safe(payload), separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
raw = self.lib.dita_kernel_on_venue_event_json(
|
|
handle,
|
|
ctypes.c_char_p(encoded),
|
|
ctypes.c_char_p(mode.encode("utf-8")),
|
|
ctypes.c_char_p(verbosity.encode("utf-8")),
|
|
)
|
|
return json.loads(self._take_string(raw))
|
|
|
|
def reconcile_slots(
|
|
self,
|
|
handle: ctypes.c_void_p,
|
|
payload: Sequence[Dict[str, Any]],
|
|
*,
|
|
mode: str,
|
|
verbosity: str,
|
|
) -> Dict[str, Any]:
|
|
encoded = json.dumps(json_safe(list(payload)), separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
raw = self.lib.dita_kernel_reconcile_slots_json(
|
|
handle,
|
|
ctypes.c_char_p(encoded),
|
|
ctypes.c_char_p(mode.encode("utf-8")),
|
|
ctypes.c_char_p(verbosity.encode("utf-8")),
|
|
)
|
|
return json.loads(self._take_string(raw))
|
|
|
|
def snapshot(self, handle: ctypes.c_void_p) -> Dict[str, Any]:
|
|
raw = self.lib.dita_kernel_snapshot_json(handle)
|
|
return json.loads(self._take_string(raw))
|
|
|
|
|
|
_RUST: _RustKernelLib | None = None # lazy init — avoids Rust build on import
|
|
|
|
|
|
def _get_rust() -> _RustKernelLib:
|
|
global _RUST
|
|
if _RUST is None:
|
|
_RUST = _RustKernelLib()
|
|
return _RUST
|
|
|
|
|
|
def _slot_to_payload(slot: TradeSlot) -> Dict[str, Any]:
|
|
return slot.to_dict()
|
|
|
|
|
|
def _order_to_payload(order: Optional[VenueOrder]) -> Optional[Dict[str, Any]]:
|
|
if order is None:
|
|
return None
|
|
return {
|
|
"internal_trade_id": order.internal_trade_id,
|
|
"venue_order_id": order.venue_order_id,
|
|
"venue_client_id": order.venue_client_id,
|
|
"side": order.side.value,
|
|
"intended_size": float(order.intended_size or 0.0),
|
|
"filled_size": float(order.filled_size or 0.0),
|
|
"average_fill_price": float(order.average_fill_price or 0.0),
|
|
"status": order.status.value,
|
|
"metadata": dict(order.metadata),
|
|
}
|
|
|
|
|
|
def _order_from_payload(payload: Optional[Dict[str, Any]], *, trade_id: str) -> Optional[VenueOrder]:
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
return VenueOrder(
|
|
internal_trade_id=trade_id,
|
|
venue_order_id=str(payload.get("venue_order_id", "")),
|
|
venue_client_id=str(payload.get("venue_client_id", "")),
|
|
side=TradeSide(str(payload.get("side", TradeSide.FLAT.value))),
|
|
intended_size=float(payload.get("intended_size", 0.0)),
|
|
filled_size=float(payload.get("filled_size", 0.0)),
|
|
average_fill_price=float(payload.get("average_fill_price", 0.0)),
|
|
status=VenueOrderStatus(str(payload.get("status", VenueOrderStatus.NEW.value))),
|
|
metadata=dict(payload.get("metadata", {})),
|
|
)
|
|
|
|
|
|
def _slot_from_payload(payload: Dict[str, Any]) -> TradeSlot:
|
|
return TradeSlot(
|
|
slot_id=int(payload.get("slot_id", 0)),
|
|
trade_id=str(payload.get("trade_id", "")),
|
|
asset=str(payload.get("asset", "")),
|
|
side=TradeSide(str(payload.get("side", TradeSide.FLAT.value))),
|
|
entry_price=float(payload.get("entry_price", 0.0)),
|
|
size=float(payload.get("size", 0.0)),
|
|
initial_size=float(payload.get("initial_size", 0.0)),
|
|
leverage=float(payload.get("leverage", 0.0)),
|
|
entry_time=datetime.fromisoformat(payload["entry_time"]) if payload.get("entry_time") else None,
|
|
unrealized_pnl=float(payload.get("unrealized_pnl", 0.0)),
|
|
realized_pnl=float(payload.get("realized_pnl", 0.0)),
|
|
closed=bool(payload.get("closed", False)),
|
|
exit_leg_ratios=tuple(float(r) for r in payload.get("exit_leg_ratios", (1.0,))),
|
|
active_leg_index=int(payload.get("active_leg_index", 0)),
|
|
active_exit_order=_order_from_payload(payload.get("active_exit_order"), trade_id=str(payload.get("trade_id", ""))),
|
|
active_entry_order=_order_from_payload(payload.get("active_entry_order"), trade_id=str(payload.get("trade_id", ""))),
|
|
fsm_state=TradeStage(str(payload.get("fsm_state", TradeStage.IDLE.value))),
|
|
close_reason=str(payload.get("close_reason", "")),
|
|
last_event_time=datetime.fromisoformat(payload["last_event_time"]) if payload.get("last_event_time") else None,
|
|
seen_event_ids=tuple(str(event_id) for event_id in payload.get("seen_event_ids", ())),
|
|
metadata=dict(payload.get("metadata", {})),
|
|
)
|
|
|
|
|
|
def _intent_to_payload(intent: KernelIntent) -> Dict[str, Any]:
|
|
return {
|
|
"timestamp": intent.timestamp.isoformat() if hasattr(intent.timestamp, "isoformat") else str(intent.timestamp),
|
|
"intent_id": intent.intent_id,
|
|
"trade_id": intent.trade_id,
|
|
"slot_id": intent.slot_id,
|
|
"asset": intent.asset,
|
|
"side": intent.side.value,
|
|
"action": intent.action.value,
|
|
"reference_price": float(intent.reference_price or 0.0),
|
|
"target_size": float(intent.target_size or 0.0),
|
|
"leverage": float(intent.leverage or 0.0),
|
|
"exit_leg_ratios": list(intent.exit_leg_ratios),
|
|
"reason": intent.reason,
|
|
"metadata": dict(intent.metadata),
|
|
"stage": intent.stage.value,
|
|
"order_type": getattr(intent, "order_type", "MARKET"),
|
|
"limit_price": float(getattr(intent, "limit_price", 0.0) or 0.0),
|
|
}
|
|
|
|
|
|
def _event_to_payload(event: VenueEvent) -> Dict[str, Any]:
|
|
return {
|
|
"timestamp": event.timestamp.isoformat() if hasattr(event.timestamp, "isoformat") else str(event.timestamp),
|
|
"event_id": event.event_id,
|
|
"trade_id": event.trade_id,
|
|
"slot_id": event.slot_id,
|
|
"kind": event.kind.value,
|
|
"status": event.status.value,
|
|
"venue_order_id": event.venue_order_id,
|
|
"venue_client_id": event.venue_client_id,
|
|
"side": event.side.value,
|
|
"asset": event.asset,
|
|
"price": float(event.price or 0.0),
|
|
"size": float(event.size or 0.0),
|
|
"filled_size": float(event.filled_size or 0.0),
|
|
"remaining_size": float(event.remaining_size or 0.0),
|
|
"reason": event.reason,
|
|
"raw_payload": dict(event.raw_payload),
|
|
"metadata": dict(event.metadata),
|
|
}
|
|
|
|
|
|
def _transition_from_payload(payload: Dict[str, Any]) -> KernelTransition:
|
|
return KernelTransition(
|
|
timestamp=datetime.fromisoformat(payload["timestamp"]),
|
|
trade_id=str(payload.get("trade_id", "")),
|
|
slot_id=int(payload.get("slot_id", 0)),
|
|
prev_state=TradeStage(str(payload.get("prev_state", TradeStage.IDLE.value))),
|
|
next_state=TradeStage(str(payload.get("next_state", TradeStage.IDLE.value))),
|
|
trigger=str(payload.get("trigger", "")),
|
|
intent_id=str(payload.get("intent_id", "")),
|
|
event_id=str(payload.get("event_id", "")),
|
|
control_mode=str(payload.get("control_mode", "")),
|
|
control_verbosity=str(payload.get("control_verbosity", "")),
|
|
details=dict(payload.get("details", {})),
|
|
)
|
|
|
|
|
|
def _outcome_from_payload(payload: Dict[str, Any]) -> KernelOutcome:
|
|
return KernelOutcome(
|
|
accepted=bool(payload.get("accepted", False)),
|
|
slot_id=int(payload.get("slot_id", 0)),
|
|
trade_id=str(payload.get("trade_id", "")),
|
|
state=TradeStage(str(payload.get("state", TradeStage.IDLE.value))),
|
|
diagnostic_code=KernelDiagnosticCode(str(payload.get("diagnostic_code", KernelDiagnosticCode.OK.value))),
|
|
severity=KernelSeverity(str(payload.get("severity", KernelSeverity.INFO.value))),
|
|
transitions=tuple(_transition_from_payload(row) for row in payload.get("transitions", [])),
|
|
emitted_events=tuple(
|
|
VenueEvent(
|
|
timestamp=datetime.fromisoformat(row["timestamp"]),
|
|
event_id=str(row.get("event_id", "")),
|
|
trade_id=str(row.get("trade_id", "")),
|
|
slot_id=int(row.get("slot_id", 0)),
|
|
kind=KernelEventKind(str(row.get("kind", KernelEventKind.ORDER_ACK.value))),
|
|
status=VenueEventStatus(str(row.get("status", VenueEventStatus.ACKED.value))),
|
|
venue_order_id=str(row.get("venue_order_id", "")),
|
|
venue_client_id=str(row.get("venue_client_id", "")),
|
|
side=TradeSide(str(row.get("side", TradeSide.FLAT.value))),
|
|
asset=str(row.get("asset", "")),
|
|
price=float(row.get("price", 0.0)),
|
|
size=float(row.get("size", 0.0)),
|
|
filled_size=float(row.get("filled_size", 0.0)),
|
|
remaining_size=float(row.get("remaining_size", 0.0)),
|
|
reason=str(row.get("reason", "")),
|
|
raw_payload=dict(row.get("raw_payload", {})),
|
|
metadata=dict(row.get("metadata", {})),
|
|
)
|
|
for row in payload.get("emitted_events", [])
|
|
),
|
|
details=dict(payload.get("details", {})),
|
|
)
|
|
|
|
|
|
def _enum_text(value: Any) -> str:
|
|
if hasattr(value, "value"):
|
|
return str(getattr(value, "value"))
|
|
return str(value)
|
|
|
|
|
|
class KernelSlotView:
|
|
"""Write-through view over a Rust-backed slot."""
|
|
|
|
def __init__(self, kernel: "ExecutionKernel", slot_id: int) -> None:
|
|
object.__setattr__(self, "_kernel", kernel)
|
|
object.__setattr__(self, "_slot_id", int(slot_id))
|
|
|
|
@property
|
|
def slot_id(self) -> int:
|
|
return object.__getattribute__(self, "_slot_id")
|
|
|
|
def _snapshot(self) -> TradeSlot:
|
|
return self._kernel._get_slot(self.slot_id)
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
slot = self._snapshot()
|
|
if hasattr(slot, name):
|
|
return getattr(slot, name)
|
|
raise AttributeError(name)
|
|
|
|
def __setattr__(self, name: str, value: Any) -> None:
|
|
if name in {"_kernel", "_slot_id"}:
|
|
object.__setattr__(self, name, value)
|
|
return
|
|
slot = self._snapshot()
|
|
if not hasattr(slot, name):
|
|
raise AttributeError(name)
|
|
setattr(slot, name, value)
|
|
self._kernel._set_slot(slot)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return self._snapshot().to_dict()
|
|
|
|
def is_free(self) -> bool:
|
|
return self._snapshot().is_free()
|
|
|
|
def is_open(self) -> bool:
|
|
return self._snapshot().is_open()
|
|
|
|
def mark_price(self, price: float) -> None:
|
|
slot = self._snapshot()
|
|
slot.mark_price(price)
|
|
self._kernel._set_slot(slot)
|
|
|
|
def next_exit_ratio(self) -> float:
|
|
return self._snapshot().next_exit_ratio()
|
|
|
|
def consume_exit_leg(self) -> float:
|
|
slot = self._snapshot()
|
|
ratio = slot.consume_exit_leg()
|
|
self._kernel._set_slot(slot)
|
|
return ratio
|
|
|
|
def attach_entry_order(self, order: VenueOrder) -> None:
|
|
slot = self._snapshot()
|
|
slot.active_entry_order = order
|
|
self._kernel._set_slot(slot)
|
|
|
|
def attach_exit_order(self, order: VenueOrder) -> None:
|
|
slot = self._snapshot()
|
|
slot.active_exit_order = order
|
|
self._kernel._set_slot(slot)
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debugging helper
|
|
return f"KernelSlotView(slot_id={self.slot_id}, state={self._snapshot().fsm_state.value})"
|
|
|
|
|
|
class KernelStateView:
|
|
def __init__(self, kernel: "ExecutionKernel") -> None:
|
|
self._kernel = kernel
|
|
self.slots = [KernelSlotView(kernel, slot_id) for slot_id in range(kernel.max_slots)]
|
|
self.active_trade_index: Dict[str, int] = {}
|
|
self.venue_order_index: Dict[str, int] = {}
|
|
self.client_order_index: Dict[str, int] = {}
|
|
self.refresh()
|
|
|
|
def refresh(self) -> None:
|
|
snapshot = self._kernel._snapshot_backend()
|
|
self.active_trade_index = dict(snapshot.get("active_trade_index", {}))
|
|
self.venue_order_index = dict(snapshot.get("venue_order_index", {}))
|
|
self.client_order_index = dict(snapshot.get("client_order_index", {}))
|
|
|
|
|
|
class ExecutionKernel:
|
|
"""Rust-backed multi-slot execution kernel."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
max_slots: int = 10,
|
|
control_plane: Optional[ControlPlane] = None,
|
|
venue: Optional[VenueAdapter] = None,
|
|
journal: Optional[KernelJournal] = None,
|
|
account: Optional[AccountProjection] = None,
|
|
projection: Optional[HazelcastProjection] = None,
|
|
projection_client: Optional[Any] = None,
|
|
zinc_plane: Optional[ZincPlane] = None,
|
|
) -> None:
|
|
self.max_slots = int(max_slots)
|
|
self.control_plane = control_plane or build_control_plane()
|
|
self.venue = venue or MockVenueAdapter()
|
|
self.journal = journal or MemoryKernelJournal()
|
|
self.account = account or AccountProjection()
|
|
self.projection = projection or build_projection(client=projection_client)
|
|
self.zinc_plane = zinc_plane or InMemoryZincPlane()
|
|
self._backend = _get_rust().create(self.max_slots)
|
|
self._control_snapshot = self.control_plane.read()
|
|
self._last_settled_pnl: Dict[int, float] = {}
|
|
self.projection.write_control(self._control_snapshot)
|
|
self.zinc_plane.update_control(self._control_snapshot)
|
|
self.state = KernelStateView(self)
|
|
self.account.observe_slots([self._get_slot(slot_id) for slot_id in range(self.max_slots)])
|
|
|
|
def __del__(self) -> None: # pragma: no cover - cleanup best effort
|
|
backend = getattr(self, "_backend", None)
|
|
if backend is not None:
|
|
try:
|
|
_get_rust().destroy(backend)
|
|
except Exception:
|
|
pass
|
|
|
|
@property
|
|
def control(self) -> KernelControlSnapshot:
|
|
return self.control_plane.read()
|
|
|
|
def update_control(self, update: ControlUpdate) -> KernelControlSnapshot:
|
|
snapshot = self.control_plane.update(update)
|
|
self._control_snapshot = snapshot
|
|
self.projection.write_control(snapshot)
|
|
self.zinc_plane.update_control(snapshot)
|
|
return snapshot
|
|
|
|
def _snapshot_backend(self) -> Dict[str, Any]:
|
|
return _get_rust().snapshot(self._backend)
|
|
|
|
def _get_slot(self, slot_id: int) -> TradeSlot:
|
|
return _slot_from_payload(_get_rust().get_slot_json(self._backend, slot_id))
|
|
|
|
def _set_slot(self, slot: TradeSlot, *, journal: bool = False) -> None:
|
|
payload = _slot_to_payload(slot)
|
|
_get_rust().set_slot_json(self._backend, slot.slot_id, payload)
|
|
self.state.refresh()
|
|
slots = [self._get_slot(slot_id) for slot_id in range(self.max_slots)]
|
|
self.account.observe_slots(slots)
|
|
current = self._get_slot(slot.slot_id)
|
|
self.projection.write_slot(current)
|
|
self.zinc_plane.write_slot(current)
|
|
|
|
def slot(self, slot_id: int) -> KernelSlotView:
|
|
if not (0 <= int(slot_id) < self.max_slots):
|
|
raise IndexError(slot_id)
|
|
return self.state.slots[int(slot_id)]
|
|
|
|
def free_slot(self) -> Optional[KernelSlotView]:
|
|
for slot in self.state.slots:
|
|
if slot.is_free():
|
|
return slot
|
|
return None
|
|
|
|
def _record_transitions(self, transitions: Iterable[KernelTransition], slot: TradeSlot, event: Optional[VenueEvent]) -> None:
|
|
if self.control.debug_clickhouse_enabled:
|
|
for transition in transitions:
|
|
self.journal.record_transition(
|
|
transition=transition,
|
|
slot=slot,
|
|
event=event,
|
|
control=self.control,
|
|
)
|
|
|
|
def process_intent(self, intent: KernelIntent) -> KernelOutcome:
|
|
self.zinc_plane.publish_intent(intent)
|
|
if not (0 <= int(intent.slot_id) < self.max_slots):
|
|
return KernelOutcome(
|
|
accepted=False,
|
|
slot_id=int(intent.slot_id),
|
|
trade_id=intent.trade_id,
|
|
state=TradeStage.IDLE,
|
|
diagnostic_code=KernelDiagnosticCode.INVALID_SLOT_ID,
|
|
details={"reason": "INVALID_SLOT_ID", "slot_id": int(intent.slot_id), "intent_id": intent.intent_id},
|
|
)
|
|
payload = _intent_to_payload(intent)
|
|
result = _get_rust().process_intent(
|
|
self._backend,
|
|
payload,
|
|
mode=_enum_text(self.control.mode),
|
|
verbosity=_enum_text(self.control.verbosity),
|
|
)
|
|
outcome = _outcome_from_payload(result["outcome"])
|
|
self.state.refresh()
|
|
if intent.action == KernelCommandType.ENTER and outcome.accepted:
|
|
self._last_settled_pnl[intent.slot_id] = 0.0
|
|
emitted_events = []
|
|
all_venue_transitions: List[KernelTransition] = []
|
|
if intent.action in {KernelCommandType.ENTER, KernelCommandType.EXIT}:
|
|
emitted_events = self.venue.submit(intent)
|
|
for event in emitted_events:
|
|
evt_outcome = self.on_venue_event(event)
|
|
all_venue_transitions.extend(evt_outcome.transitions)
|
|
elif intent.action == KernelCommandType.CANCEL:
|
|
slot_view = self.slot(intent.slot_id)
|
|
if slot_view.active_exit_order is not None:
|
|
emitted_events = self.venue.cancel(slot_view.active_exit_order, reason=intent.reason)
|
|
elif slot_view.active_entry_order is not None and slot_view.fsm_state in {
|
|
TradeStage.ENTRY_WORKING,
|
|
TradeStage.ORDER_REQUESTED,
|
|
TradeStage.ORDER_SENT,
|
|
TradeStage.IDLE,
|
|
}:
|
|
emitted_events = self.venue.cancel(slot_view.active_entry_order, reason=intent.reason)
|
|
else:
|
|
emitted_events = []
|
|
for event in emitted_events:
|
|
evt_outcome = self.on_venue_event(event)
|
|
all_venue_transitions.extend(evt_outcome.transitions)
|
|
|
|
final_slot = self._get_slot(outcome.slot_id)
|
|
rate_limit_event = next((event for event in emitted_events if event.kind == KernelEventKind.RATE_LIMITED), None)
|
|
if rate_limit_event is not None:
|
|
rate_limit_details = dict(outcome.details)
|
|
rate_limit_details.update(
|
|
{
|
|
"reason": rate_limit_event.reason or "RATE_LIMITED",
|
|
"retry_after_ms": int(rate_limit_event.metadata.get("retry_after_ms", 0) or 0),
|
|
"venue_event_kind": rate_limit_event.kind.value,
|
|
"severity": KernelSeverity.WARNING.value,
|
|
"release_eta": "few minutes",
|
|
"retryable": True,
|
|
}
|
|
)
|
|
outcome = KernelOutcome(
|
|
accepted=False,
|
|
slot_id=outcome.slot_id,
|
|
trade_id=outcome.trade_id,
|
|
state=final_slot.fsm_state,
|
|
diagnostic_code=KernelDiagnosticCode.RATE_LIMITED,
|
|
severity=KernelSeverity.WARNING,
|
|
transitions=outcome.transitions,
|
|
emitted_events=outcome.emitted_events,
|
|
details=rate_limit_details,
|
|
)
|
|
all_transitions = list(outcome.transitions) + all_venue_transitions
|
|
final_outcome = KernelOutcome(
|
|
accepted=outcome.accepted,
|
|
slot_id=outcome.slot_id,
|
|
trade_id=final_slot.trade_id,
|
|
state=final_slot.fsm_state,
|
|
diagnostic_code=outcome.diagnostic_code,
|
|
transitions=tuple(all_transitions),
|
|
emitted_events=tuple(emitted_events),
|
|
details=dict(outcome.details),
|
|
)
|
|
slots = [self._get_slot(i) for i in range(self.max_slots)]
|
|
self.account.observe_slots(slots)
|
|
current = self._get_slot(final_slot.slot_id)
|
|
self.projection.write_slot(current)
|
|
self.zinc_plane.write_slot(current)
|
|
self._record_transitions(outcome.transitions, final_slot, None)
|
|
return final_outcome
|
|
|
|
def on_venue_event(self, event: VenueEvent) -> KernelOutcome:
|
|
result = _get_rust().on_venue_event(
|
|
self._backend,
|
|
_event_to_payload(event),
|
|
mode=_enum_text(self.control.mode),
|
|
verbosity=_enum_text(self.control.verbosity),
|
|
)
|
|
outcome = _outcome_from_payload(result["outcome"])
|
|
slot = _slot_from_payload(result["slot"])
|
|
self.state.refresh()
|
|
incremental_pnl = slot.realized_pnl - self._last_settled_pnl.get(slot.slot_id, 0.0)
|
|
if abs(incremental_pnl) > 1e-12:
|
|
self.account.settle(incremental_pnl)
|
|
self._last_settled_pnl[slot.slot_id] = slot.realized_pnl
|
|
slots = [self._get_slot(i) for i in range(self.max_slots)]
|
|
self.account.observe_slots(slots)
|
|
current = self._get_slot(slot.slot_id)
|
|
self.projection.write_slot(current)
|
|
self.zinc_plane.write_slot(current)
|
|
self._record_transitions(outcome.transitions, slot, event)
|
|
return outcome
|
|
|
|
def mark_price(self, asset: str, price: float) -> None:
|
|
for slot in self.state.slots:
|
|
if slot.asset == asset and slot.is_open():
|
|
slot.mark_price(price)
|
|
self.account.observe_slots([self._get_slot(i) for i in range(self.max_slots)])
|
|
|
|
def reconcile_from_slots(self, slots: Sequence[TradeSlot]) -> KernelOutcome:
|
|
payload = [_slot_to_payload(slot) for slot in slots]
|
|
result = _get_rust().reconcile_slots(
|
|
self._backend,
|
|
payload,
|
|
mode=_enum_text(self.control.mode),
|
|
verbosity=_enum_text(self.control.verbosity),
|
|
)
|
|
outcome = _outcome_from_payload(result["outcome"])
|
|
if not outcome.accepted:
|
|
return outcome
|
|
self.state.refresh()
|
|
slots = [self._get_slot(i) for i in range(self.max_slots)]
|
|
self.account.observe_slots(slots)
|
|
for current in slots:
|
|
self.projection.write_slot(current)
|
|
self.zinc_plane.write_slot(current)
|
|
return outcome
|
|
|
|
def snapshot(self) -> Dict[str, Any]:
|
|
return {
|
|
"control": self.control.as_dict(),
|
|
"slots": [self._get_slot(slot.slot_id).to_dict() for slot in self.state.slots],
|
|
"account": {
|
|
"capital": self.account.snapshot.capital,
|
|
"equity": self.account.snapshot.equity,
|
|
"realized_pnl": self.account.snapshot.realized_pnl,
|
|
"unrealized_pnl": self.account.snapshot.unrealized_pnl,
|
|
"open_positions": self.account.snapshot.open_positions,
|
|
"open_notional": self.account.snapshot.open_notional,
|
|
"leverage": self.account.snapshot.leverage,
|
|
},
|
|
}
|