PINK: S1 leverage cache, S2 background refresh, Gap 1/2/3 fee+slippage logging
S1 — Leverage cache (bingx_direct.py):
_ensure_leverage(): per-symbol asyncio.Lock + cached value check; skips ~350ms
POST when exchange already has the requested leverage. Saves ~350ms/trade.
Cache updated ONLY on success; failed POST leaves cache stale → correct retry.
Persist: JSON sidecar /tmp/.bingx_leverage_cache_{env}.json; survives restarts.
connect(): _verify_leverage_drift() detects when another process changed leverage
at the exchange and updates cache to exchange truth (logs WARNING on drift).
Multi-runner contract: leverage is account-level on BingX; documented that
concurrent runners with different leverage desires for same symbol conflict.
20 mock tests: same-lev skip, change-triggers-POST, failure-no-cache-update,
concurrent-same-symbol (lock prevents race), drift-detect, persist/restore,
multi-runner known-limitation documentation test.
S2 — Background state refresh (bingx_direct.py):
MARKET fills: asyncio.create_task(_refresh_state_background) — does not block
submit path. WS FILL_SETTLED + ACCOUNT_UPDATE deliver capital truth anyway.
LIMIT fills: synchronous refresh retained (include_history=False, not True) —
needed to detect resting order state for next pump cycle.
Saves ~600–900ms/trade on MARKET exits. ENTER similarly improved.
Gap 1 — VenueEvent friction fields (contracts.py):
Added: fee, fee_asset, fee_source, is_maker, exchange_ts, slippage_bps,
mark_at_submit — all with defaults so existing callers are unaffected.
Detailed inline docs for sign conventions and provenance codes.
Gap 2 — Fee estimation + WS_SETTLED provenance (bingx_direct.py, pink_clickhouse.py):
submit_intent: estimates fee from fill_price × fill_qty × taker/maker rate;
annotates ack_row with _fee_estimated, _fee_source, _is_maker_est.
persist_fee_settled(): new method writes fee_settled_events row when WS
ORDER_TRADE_UPDATE delivers actual commission ("n" field); fee_source="WS_SETTLED".
pink_direct._run_account_stream: calls persist_fee_settled on FILL_SETTLED.
Gap 3 — Slippage measurement (bingx_direct.py, bingx_venue.py, pink_clickhouse.py):
Captures mark_at_submit before the order POST; computes slippage_bps signed
by side: positive = adverse (taker overpaid / maker undersold), negative =
price improvement. Measured for BOTH taker and maker fills for symmetry.
Flows through VenueEvent → trade_events.slippage_bps + trade_exit_legs.slippage_bps.
S3 / SOR — Maker order placement: comprehensive TODO block in submit_intent with:
SHORT/LONG-aware price offset design, OBF integration requirements,
TODO_ADD_PARAMSET_VIBRISS for spread_bps threshold, intelligent timeout_s
calibration requirements, price-impact awareness gap, SOR abstraction CRITICAL TODO.
REST/WS split: documented why BingX (and all retail venues) separate these
and why a unified VenueAdapter protocol is the long-term solution.
151/151 existing tests green + 20 new leverage cache tests = 171 total.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:25:12 +02:00
|
|
|
|
"""Canonical v2 contracts for the DITAv2 execution kernel."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from enum import Enum
|
|
|
|
|
|
from typing import Any, Dict, Mapping, Optional, Sequence, Tuple
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TradeSide(str, Enum):
|
|
|
|
|
|
"""Trade side."""
|
|
|
|
|
|
|
|
|
|
|
|
LONG = "LONG"
|
|
|
|
|
|
SHORT = "SHORT"
|
|
|
|
|
|
FLAT = "FLAT"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TradeStage(str, Enum):
|
|
|
|
|
|
"""Execution stage for a trade slot."""
|
|
|
|
|
|
|
|
|
|
|
|
IDLE = "IDLE"
|
|
|
|
|
|
DECISION_CREATED = "DECISION_CREATED"
|
|
|
|
|
|
INTENT_CREATED = "INTENT_CREATED"
|
|
|
|
|
|
ORDER_REQUESTED = "ORDER_REQUESTED"
|
|
|
|
|
|
ORDER_SENT = "ORDER_SENT"
|
|
|
|
|
|
ORDER_ACKED = "ORDER_ACKED"
|
|
|
|
|
|
ORDER_REJECTED = "ORDER_REJECTED"
|
|
|
|
|
|
ENTRY_WORKING = "ENTRY_WORKING"
|
|
|
|
|
|
PARTIAL_FILL = "PARTIAL_FILL"
|
|
|
|
|
|
POSITION_OPENED = "POSITION_OPENED"
|
|
|
|
|
|
POSITION_OPEN = "POSITION_OPEN"
|
|
|
|
|
|
EXIT_REQUESTED = "EXIT_REQUESTED"
|
|
|
|
|
|
EXIT_SENT = "EXIT_SENT"
|
|
|
|
|
|
EXIT_ACKED = "EXIT_ACKED"
|
|
|
|
|
|
EXIT_REJECTED = "EXIT_REJECTED"
|
|
|
|
|
|
EXIT_WORKING = "EXIT_WORKING"
|
|
|
|
|
|
POSITION_PARTIALLY_CLOSED = "POSITION_PARTIALLY_CLOSED"
|
|
|
|
|
|
POSITION_CLOSED = "POSITION_CLOSED"
|
|
|
|
|
|
CLOSED = "CLOSED"
|
|
|
|
|
|
TRADE_TERMINAL_WRITTEN = "TRADE_TERMINAL_WRITTEN"
|
|
|
|
|
|
STALE_STATE_RECONCILING = "STALE_STATE_RECONCILING"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class KernelCommandType(str, Enum):
|
|
|
|
|
|
"""Kernel command types."""
|
|
|
|
|
|
|
|
|
|
|
|
ENTER = "ENTER"
|
|
|
|
|
|
EXIT = "EXIT"
|
|
|
|
|
|
MARK_PRICE = "MARK_PRICE"
|
|
|
|
|
|
RECONCILE = "RECONCILE"
|
|
|
|
|
|
CONTROL = "CONTROL"
|
|
|
|
|
|
CANCEL = "CANCEL"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class KernelEventKind(str, Enum):
|
|
|
|
|
|
"""Normalized venue event kinds."""
|
|
|
|
|
|
|
|
|
|
|
|
ORDER_ACK = "ORDER_ACK"
|
|
|
|
|
|
ORDER_REJECT = "ORDER_REJECT"
|
|
|
|
|
|
RATE_LIMITED = "RATE_LIMITED"
|
|
|
|
|
|
PARTIAL_FILL = "PARTIAL_FILL"
|
|
|
|
|
|
FULL_FILL = "FULL_FILL"
|
|
|
|
|
|
CANCEL_ACK = "CANCEL_ACK"
|
|
|
|
|
|
CANCEL_REJECT = "CANCEL_REJECT"
|
|
|
|
|
|
MARK_PRICE = "MARK_PRICE"
|
|
|
|
|
|
RECONCILE = "RECONCILE"
|
|
|
|
|
|
CONTROL = "CONTROL"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class KernelDiagnosticCode(str, Enum):
|
|
|
|
|
|
"""Structured diagnostic codes emitted by the kernel."""
|
|
|
|
|
|
|
|
|
|
|
|
OK = "OK"
|
|
|
|
|
|
RATE_LIMITED = "RATE_LIMITED"
|
|
|
|
|
|
INVALID_SLOT_ID = "INVALID_SLOT_ID"
|
|
|
|
|
|
INVALID_INTENT = "INVALID_INTENT"
|
|
|
|
|
|
UNSUPPORTED_INTENT = "UNSUPPORTED_INTENT"
|
|
|
|
|
|
SLOT_BUSY = "SLOT_BUSY"
|
|
|
|
|
|
NO_OPEN_POSITION = "NO_OPEN_POSITION"
|
|
|
|
|
|
NO_ACTIVE_EXIT_ORDER = "NO_ACTIVE_EXIT_ORDER"
|
|
|
|
|
|
UNKNOWN_EVENT_KIND = "UNKNOWN_EVENT_KIND"
|
|
|
|
|
|
ORDER_REJECTED = "ORDER_REJECTED"
|
|
|
|
|
|
ENTRY_ORDER_REJECTED = "ENTRY_ORDER_REJECTED"
|
|
|
|
|
|
EXIT_ORDER_REJECTED = "EXIT_ORDER_REJECTED"
|
|
|
|
|
|
CANCEL_REJECTED = "CANCEL_REJECTED"
|
|
|
|
|
|
STALE_STATE_RECONCILE = "STALE_STATE_RECONCILE"
|
|
|
|
|
|
RECONCILED = "RECONCILED"
|
|
|
|
|
|
DUPLICATE_EVENT = "DUPLICATE_EVENT"
|
|
|
|
|
|
UNRESOLVED_SLOT = "UNRESOLVED_SLOT"
|
|
|
|
|
|
INVALID_TRANSITION = "INVALID_TRANSITION"
|
|
|
|
|
|
TERMINAL_STATE = "TERMINAL_STATE"
|
|
|
|
|
|
CAPITAL_FROZEN = "CAPITAL_FROZEN"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class KernelSeverity(str, Enum):
|
|
|
|
|
|
"""Severity classification for kernel outcomes."""
|
|
|
|
|
|
|
|
|
|
|
|
INFO = "INFO"
|
|
|
|
|
|
WARNING = "WARNING"
|
|
|
|
|
|
ERROR = "ERROR"
|
|
|
|
|
|
CRITICAL = "CRITICAL"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class VenueOrderStatus(str, Enum):
|
|
|
|
|
|
"""Order status surface mirrored from venue truth."""
|
|
|
|
|
|
|
|
|
|
|
|
NEW = "NEW"
|
|
|
|
|
|
ACKED = "ACKED"
|
|
|
|
|
|
PARTIALLY_FILLED = "PARTIALLY_FILLED"
|
|
|
|
|
|
FILLED = "FILLED"
|
|
|
|
|
|
CANCELED = "CANCELED"
|
|
|
|
|
|
REJECTED = "REJECTED"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class VenueEventStatus(str, Enum):
|
|
|
|
|
|
"""Status alias for normalized venue events."""
|
|
|
|
|
|
|
|
|
|
|
|
ACKED = "ACKED"
|
|
|
|
|
|
REJECTED = "REJECTED"
|
|
|
|
|
|
RATE_LIMITED = "RATE_LIMITED"
|
|
|
|
|
|
PARTIALLY_FILLED = "PARTIALLY_FILLED"
|
|
|
|
|
|
FILLED = "FILLED"
|
|
|
|
|
|
CANCELED = "CANCELED"
|
|
|
|
|
|
CANCELED_REJECTED = "CANCEL_REJECTED"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class VenueOrder:
|
|
|
|
|
|
"""Venue-specific order identity and fill state."""
|
|
|
|
|
|
|
|
|
|
|
|
internal_trade_id: str
|
|
|
|
|
|
venue_order_id: str
|
|
|
|
|
|
venue_client_id: str
|
|
|
|
|
|
side: TradeSide
|
|
|
|
|
|
intended_size: float
|
|
|
|
|
|
filled_size: float = 0.0
|
|
|
|
|
|
average_fill_price: float = 0.0
|
|
|
|
|
|
status: VenueOrderStatus = VenueOrderStatus.NEW
|
|
|
|
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def remaining_size(self) -> float:
|
|
|
|
|
|
return max(0.0, float(self.intended_size) - float(self.filled_size))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 00:42:20 +02:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class VenueTelemetrySnapshot:
|
|
|
|
|
|
"""Shared-memory surface for venue-side call boundaries and state."""
|
|
|
|
|
|
|
|
|
|
|
|
phase: str = "idle"
|
|
|
|
|
|
status: str = "IDLE"
|
|
|
|
|
|
venue: str = "bingx"
|
|
|
|
|
|
endpoint: str = ""
|
|
|
|
|
|
method: str = ""
|
|
|
|
|
|
intent_id: str = ""
|
|
|
|
|
|
trade_id: str = ""
|
|
|
|
|
|
slot_id: int = 0
|
|
|
|
|
|
asset: str = ""
|
|
|
|
|
|
side: TradeSide = TradeSide.FLAT
|
|
|
|
|
|
action: str = ""
|
|
|
|
|
|
order_id: str = ""
|
|
|
|
|
|
client_order_id: str = ""
|
|
|
|
|
|
venue_order_status: str = ""
|
|
|
|
|
|
venue_event_kind: str = ""
|
|
|
|
|
|
message: str = ""
|
|
|
|
|
|
retry_after_ms: int = 0
|
|
|
|
|
|
timestamp: Optional[datetime] = None
|
|
|
|
|
|
details: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
def as_dict(self) -> Dict[str, Any]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"phase": self.phase,
|
|
|
|
|
|
"status": self.status,
|
|
|
|
|
|
"venue": self.venue,
|
|
|
|
|
|
"endpoint": self.endpoint,
|
|
|
|
|
|
"method": self.method,
|
|
|
|
|
|
"intent_id": self.intent_id,
|
|
|
|
|
|
"trade_id": self.trade_id,
|
|
|
|
|
|
"slot_id": int(self.slot_id or 0),
|
|
|
|
|
|
"asset": self.asset,
|
|
|
|
|
|
"side": self.side.value if hasattr(self.side, "value") else str(self.side),
|
|
|
|
|
|
"action": self.action,
|
|
|
|
|
|
"order_id": self.order_id,
|
|
|
|
|
|
"client_order_id": self.client_order_id,
|
|
|
|
|
|
"venue_order_status": self.venue_order_status,
|
|
|
|
|
|
"venue_event_kind": self.venue_event_kind,
|
|
|
|
|
|
"message": self.message,
|
|
|
|
|
|
"retry_after_ms": int(self.retry_after_ms or 0),
|
|
|
|
|
|
"timestamp": self.timestamp.isoformat() if hasattr(self.timestamp, "isoformat") else None,
|
|
|
|
|
|
"details": dict(self.details),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
PINK: S1 leverage cache, S2 background refresh, Gap 1/2/3 fee+slippage logging
S1 — Leverage cache (bingx_direct.py):
_ensure_leverage(): per-symbol asyncio.Lock + cached value check; skips ~350ms
POST when exchange already has the requested leverage. Saves ~350ms/trade.
Cache updated ONLY on success; failed POST leaves cache stale → correct retry.
Persist: JSON sidecar /tmp/.bingx_leverage_cache_{env}.json; survives restarts.
connect(): _verify_leverage_drift() detects when another process changed leverage
at the exchange and updates cache to exchange truth (logs WARNING on drift).
Multi-runner contract: leverage is account-level on BingX; documented that
concurrent runners with different leverage desires for same symbol conflict.
20 mock tests: same-lev skip, change-triggers-POST, failure-no-cache-update,
concurrent-same-symbol (lock prevents race), drift-detect, persist/restore,
multi-runner known-limitation documentation test.
S2 — Background state refresh (bingx_direct.py):
MARKET fills: asyncio.create_task(_refresh_state_background) — does not block
submit path. WS FILL_SETTLED + ACCOUNT_UPDATE deliver capital truth anyway.
LIMIT fills: synchronous refresh retained (include_history=False, not True) —
needed to detect resting order state for next pump cycle.
Saves ~600–900ms/trade on MARKET exits. ENTER similarly improved.
Gap 1 — VenueEvent friction fields (contracts.py):
Added: fee, fee_asset, fee_source, is_maker, exchange_ts, slippage_bps,
mark_at_submit — all with defaults so existing callers are unaffected.
Detailed inline docs for sign conventions and provenance codes.
Gap 2 — Fee estimation + WS_SETTLED provenance (bingx_direct.py, pink_clickhouse.py):
submit_intent: estimates fee from fill_price × fill_qty × taker/maker rate;
annotates ack_row with _fee_estimated, _fee_source, _is_maker_est.
persist_fee_settled(): new method writes fee_settled_events row when WS
ORDER_TRADE_UPDATE delivers actual commission ("n" field); fee_source="WS_SETTLED".
pink_direct._run_account_stream: calls persist_fee_settled on FILL_SETTLED.
Gap 3 — Slippage measurement (bingx_direct.py, bingx_venue.py, pink_clickhouse.py):
Captures mark_at_submit before the order POST; computes slippage_bps signed
by side: positive = adverse (taker overpaid / maker undersold), negative =
price improvement. Measured for BOTH taker and maker fills for symmetry.
Flows through VenueEvent → trade_events.slippage_bps + trade_exit_legs.slippage_bps.
S3 / SOR — Maker order placement: comprehensive TODO block in submit_intent with:
SHORT/LONG-aware price offset design, OBF integration requirements,
TODO_ADD_PARAMSET_VIBRISS for spread_bps threshold, intelligent timeout_s
calibration requirements, price-impact awareness gap, SOR abstraction CRITICAL TODO.
REST/WS split: documented why BingX (and all retail venues) separate these
and why a unified VenueAdapter protocol is the long-term solution.
151/151 existing tests green + 20 new leverage cache tests = 171 total.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:25:12 +02:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class TradeSlot:
|
|
|
|
|
|
"""A single execution slot managed by the v2 kernel."""
|
|
|
|
|
|
|
|
|
|
|
|
slot_id: int
|
|
|
|
|
|
trade_id: str = ""
|
|
|
|
|
|
asset: str = ""
|
|
|
|
|
|
side: TradeSide = TradeSide.FLAT
|
|
|
|
|
|
entry_price: float = 0.0
|
|
|
|
|
|
size: float = 0.0
|
|
|
|
|
|
initial_size: float = 0.0
|
|
|
|
|
|
leverage: float = 0.0
|
|
|
|
|
|
entry_time: Optional[datetime] = None
|
|
|
|
|
|
unrealized_pnl: float = 0.0
|
|
|
|
|
|
realized_pnl: float = 0.0
|
|
|
|
|
|
closed: bool = False
|
|
|
|
|
|
exit_leg_ratios: Tuple[float, ...] = (1.0,)
|
|
|
|
|
|
active_leg_index: int = 0
|
|
|
|
|
|
active_exit_order: Optional[VenueOrder] = None
|
|
|
|
|
|
active_entry_order: Optional[VenueOrder] = None
|
|
|
|
|
|
fsm_state: TradeStage = TradeStage.IDLE
|
|
|
|
|
|
close_reason: str = ""
|
|
|
|
|
|
last_event_time: Optional[datetime] = None
|
|
|
|
|
|
seen_event_ids: Tuple[str, ...] = ()
|
|
|
|
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
def is_free(self) -> bool:
|
|
|
|
|
|
return self.fsm_state in {TradeStage.IDLE, TradeStage.CLOSED} and float(self.size or 0.0) <= 0.0 and not self.active_entry_order and not self.active_exit_order
|
|
|
|
|
|
|
|
|
|
|
|
def is_open(self) -> bool:
|
|
|
|
|
|
return self.fsm_state in {
|
|
|
|
|
|
TradeStage.ENTRY_WORKING,
|
|
|
|
|
|
TradeStage.POSITION_OPENED,
|
|
|
|
|
|
TradeStage.POSITION_OPEN,
|
|
|
|
|
|
TradeStage.EXIT_WORKING,
|
|
|
|
|
|
} and not self.closed
|
|
|
|
|
|
|
|
|
|
|
|
def mark_price(self, price: float) -> None:
|
|
|
|
|
|
if price is None or price != price or price <= 0:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.entry_price = self.entry_price or price
|
|
|
|
|
|
if self.entry_price <= 0 or self.size <= 0:
|
|
|
|
|
|
self.unrealized_pnl = 0.0
|
|
|
|
|
|
return
|
|
|
|
|
|
delta = (price - self.entry_price) / self.entry_price
|
|
|
|
|
|
if self.side == TradeSide.SHORT:
|
|
|
|
|
|
delta = -delta
|
|
|
|
|
|
self.unrealized_pnl = delta * self.size * self.entry_price * self.leverage
|
|
|
|
|
|
|
|
|
|
|
|
def next_exit_ratio(self) -> float:
|
|
|
|
|
|
if self.active_leg_index < len(self.exit_leg_ratios):
|
|
|
|
|
|
ratio = float(self.exit_leg_ratios[self.active_leg_index])
|
|
|
|
|
|
return max(0.0, min(1.0, ratio))
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
|
|
def consume_exit_leg(self) -> float:
|
|
|
|
|
|
ratio = self.next_exit_ratio()
|
|
|
|
|
|
self.active_leg_index = min(self.active_leg_index + 1, max(len(self.exit_leg_ratios), 1))
|
|
|
|
|
|
return ratio
|
|
|
|
|
|
|
|
|
|
|
|
def remaining_size(self) -> float:
|
|
|
|
|
|
return max(0.0, float(self.size))
|
|
|
|
|
|
|
|
|
|
|
|
def attach_entry_order(self, order: VenueOrder) -> None:
|
|
|
|
|
|
self.active_entry_order = order
|
|
|
|
|
|
|
|
|
|
|
|
def attach_exit_order(self, order: VenueOrder) -> None:
|
|
|
|
|
|
self.active_exit_order = order
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
|
def _order_dict(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),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"slot_id": self.slot_id,
|
|
|
|
|
|
"trade_id": self.trade_id,
|
|
|
|
|
|
"asset": self.asset,
|
|
|
|
|
|
"side": self.side.value,
|
|
|
|
|
|
"entry_price": float(self.entry_price or 0.0),
|
|
|
|
|
|
"size": float(self.size or 0.0),
|
|
|
|
|
|
"initial_size": float(self.initial_size or 0.0),
|
|
|
|
|
|
"leverage": float(self.leverage or 0.0),
|
|
|
|
|
|
"entry_time": self.entry_time.isoformat() if hasattr(self.entry_time, "isoformat") else None,
|
|
|
|
|
|
"unrealized_pnl": float(self.unrealized_pnl or 0.0),
|
|
|
|
|
|
"realized_pnl": float(self.realized_pnl or 0.0),
|
|
|
|
|
|
"closed": bool(self.closed),
|
|
|
|
|
|
"exit_leg_ratios": [float(r) for r in self.exit_leg_ratios],
|
|
|
|
|
|
"active_leg_index": int(self.active_leg_index or 0),
|
|
|
|
|
|
"active_exit_order": _order_dict(self.active_exit_order),
|
|
|
|
|
|
"active_entry_order": _order_dict(self.active_entry_order),
|
|
|
|
|
|
"fsm_state": self.fsm_state.value,
|
|
|
|
|
|
"close_reason": self.close_reason,
|
|
|
|
|
|
"last_event_time": self.last_event_time.isoformat() if hasattr(self.last_event_time, "isoformat") else None,
|
|
|
|
|
|
"seen_event_ids": list(self.seen_event_ids),
|
|
|
|
|
|
"metadata": dict(self.metadata),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class KernelIntent:
|
|
|
|
|
|
"""Command emitted by the algo and written to the hot-path intent region."""
|
|
|
|
|
|
|
|
|
|
|
|
timestamp: datetime
|
|
|
|
|
|
intent_id: str
|
|
|
|
|
|
trade_id: str
|
|
|
|
|
|
slot_id: int
|
|
|
|
|
|
asset: str
|
|
|
|
|
|
side: TradeSide
|
|
|
|
|
|
action: KernelCommandType
|
|
|
|
|
|
reference_price: float
|
|
|
|
|
|
target_size: float
|
|
|
|
|
|
leverage: float
|
|
|
|
|
|
exit_leg_ratios: Tuple[float, ...] = (1.0,)
|
|
|
|
|
|
reason: str = ""
|
|
|
|
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
stage: TradeStage = TradeStage.INTENT_CREATED
|
|
|
|
|
|
order_type: str = "MARKET"
|
|
|
|
|
|
limit_price: float = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class VenueEvent:
|
|
|
|
|
|
"""Normalized venue truth mapped into DITAv2 semantics."""
|
|
|
|
|
|
|
|
|
|
|
|
timestamp: datetime
|
|
|
|
|
|
event_id: str
|
|
|
|
|
|
trade_id: str
|
|
|
|
|
|
slot_id: int
|
|
|
|
|
|
kind: KernelEventKind
|
|
|
|
|
|
status: VenueEventStatus
|
|
|
|
|
|
venue_order_id: str = ""
|
|
|
|
|
|
venue_client_id: str = ""
|
|
|
|
|
|
side: TradeSide = TradeSide.FLAT
|
|
|
|
|
|
asset: str = ""
|
|
|
|
|
|
price: float = 0.0 # avg fill price
|
|
|
|
|
|
size: float = 0.0
|
|
|
|
|
|
filled_size: float = 0.0
|
|
|
|
|
|
remaining_size: float = 0.0
|
|
|
|
|
|
reason: str = ""
|
|
|
|
|
|
raw_payload: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
# ── Fee / friction fields ──────────────────────────────────────────────
|
|
|
|
|
|
# fee: exchange commission for this fill event.
|
|
|
|
|
|
# Positive = cost; negative = rebate (maker on some venues).
|
|
|
|
|
|
# Starts as ESTIMATED_TAKER from the REST ACK path (BingX ACK does not
|
|
|
|
|
|
# include commission — we estimate from fill_size × fill_price × rate).
|
|
|
|
|
|
# Updated to WS_SETTLED when the FILL_SETTLED event arrives from the
|
|
|
|
|
|
# account stream with the actual commission field "n".
|
|
|
|
|
|
fee: float = 0.0
|
|
|
|
|
|
fee_asset: str = "" # e.g. "USDT"
|
|
|
|
|
|
# fee_source provenance codes:
|
|
|
|
|
|
# "" — fee unknown (e.g. CANCEL_ACK, ORDER_ACK events)
|
|
|
|
|
|
# "ESTIMATED_TAKER" — REST path, MARKET order; estimated at taker rate
|
|
|
|
|
|
# "ESTIMATED_MAKER" — REST path, LIMIT order that may rest; estimated at maker rate
|
|
|
|
|
|
# "WS_SETTLED" — actual fee from WS ORDER_TRADE_UPDATE field "n"
|
|
|
|
|
|
# "REST_SETTLED" — actual fee from REST fill history (allFillOrders)
|
|
|
|
|
|
fee_source: str = ""
|
|
|
|
|
|
is_maker: bool = False # True when LIMIT order rested and filled as maker
|
|
|
|
|
|
# exchange_ts: exchange-assigned fill timestamp (ms epoch).
|
|
|
|
|
|
# 0 when not available (REST ACK path — use VenueEvent.timestamp as fallback).
|
|
|
|
|
|
# Non-zero from WS ORDER_TRADE_UPDATE (field "T" or "t").
|
|
|
|
|
|
exchange_ts: int = 0
|
|
|
|
|
|
# slippage_bps: signed fill-quality metric.
|
|
|
|
|
|
# For taker fills: (fill_price - mark_at_submit) / mark_at_submit × 10_000
|
|
|
|
|
|
# positive = worse than mark (usual for taker on moving markets)
|
|
|
|
|
|
# negative = better than mark (rare — mark moved in our favour between submit and fill)
|
|
|
|
|
|
# For maker fills: fill_price is better than mid by design; slippage is typically
|
|
|
|
|
|
# negative (price improvement vs what a taker would have paid).
|
|
|
|
|
|
# 0.0 when mark_at_submit is unavailable.
|
|
|
|
|
|
slippage_bps: float = 0.0
|
|
|
|
|
|
mark_at_submit: float = 0.0 # mark/mid price captured just before submit_intent POST
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class KernelTransition:
|
|
|
|
|
|
"""Durable kernel transition used for debug journaling."""
|
|
|
|
|
|
|
|
|
|
|
|
timestamp: datetime
|
|
|
|
|
|
trade_id: str
|
|
|
|
|
|
slot_id: int
|
|
|
|
|
|
prev_state: TradeStage
|
|
|
|
|
|
next_state: TradeStage
|
|
|
|
|
|
trigger: str
|
|
|
|
|
|
intent_id: str = ""
|
|
|
|
|
|
event_id: str = ""
|
|
|
|
|
|
control_mode: str = ""
|
|
|
|
|
|
control_verbosity: str = ""
|
|
|
|
|
|
details: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class KernelOutcome:
|
|
|
|
|
|
"""Result of applying a command or venue event."""
|
|
|
|
|
|
|
|
|
|
|
|
accepted: bool
|
|
|
|
|
|
slot_id: int
|
|
|
|
|
|
trade_id: str
|
|
|
|
|
|
state: TradeStage
|
|
|
|
|
|
diagnostic_code: KernelDiagnosticCode = KernelDiagnosticCode.OK
|
|
|
|
|
|
severity: KernelSeverity = KernelSeverity.INFO
|
|
|
|
|
|
transitions: Tuple[KernelTransition, ...] = ()
|
|
|
|
|
|
emitted_events: Tuple[VenueEvent, ...] = ()
|
|
|
|
|
|
details: Dict[str, Any] = field(default_factory=dict)
|