Files
siloqy/prod/clean_arch/dita_v2/contracts.py

363 lines
12 KiB
Python
Raw Normal View History

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