564 lines
25 KiB
Python
564 lines
25 KiB
Python
|
|
"""DITAv2 Execution Router — the execution-style layer (SOR seed).
|
||
|
|
|
||
|
|
Decides HOW an intent reaches the venue (taker MARKET vs post-only maker
|
||
|
|
LIMIT, quote price, TTL, miss policy) — never WHETHER (that is the alpha
|
||
|
|
layer's job and is not touched here). This module is the abstraction the
|
||
|
|
S3 "Smart Order Router" TODO in ``adapters/bingx_direct.py`` calls for:
|
||
|
|
``submit`` paths stay thin; policy lives here; future improvements (OBF
|
||
|
|
depth gating, price-impact models, TWAP/iceberg) plug in via hooks.
|
||
|
|
|
||
|
|
Design rules (DO NOT WEAKEN):
|
||
|
|
|
||
|
|
1. Exits are NEVER skipped. A maker exit that misses its TTL is always
|
||
|
|
escalated to MARKET. Only entries may be skipped.
|
||
|
|
2. One working order per slot. While an entry (or exit) quote is
|
||
|
|
working, duplicate ENTER (or same-urgency EXIT) intents are
|
||
|
|
suppressed — this is the double-entry guard. An *urgent* exit
|
||
|
|
always preempts a working maker exit (cancel + MARKET).
|
||
|
|
3. Bounded retries. ``entry_retries`` re-quotes maximum, then the
|
||
|
|
configured exhaust action (skip|market). No unbounded loops.
|
||
|
|
4. Pure policy. This module does no I/O, no asyncio, no venue calls —
|
||
|
|
the runtime drives cancels/submits. That is what makes it testable
|
||
|
|
"to heavens and high back".
|
||
|
|
5. Default config == legacy behavior (pure taker). With
|
||
|
|
``DOLPHIN_PINK_EXEC_STYLE`` unset every plan is MARKET and the
|
||
|
|
registry stays empty.
|
||
|
|
|
||
|
|
Hook points (each receives ``(plan_or_event, ctx)`` and may return a
|
||
|
|
replacement ``ExecutionPlan`` from ``pre_submit``; exceptions are isolated
|
||
|
|
and logged, never propagated to the trading path):
|
||
|
|
|
||
|
|
- ``pre_plan`` — observe/adjust planning inputs
|
||
|
|
- ``pre_submit`` — last-look mutation of the plan (e.g. depth gate)
|
||
|
|
- ``on_working`` — a maker quote was registered as working
|
||
|
|
- ``on_fill`` — a working quote filled (or immediate fill)
|
||
|
|
- ``on_miss`` — a working entry expired and a miss action was taken
|
||
|
|
- ``on_escalate`` — a working exit expired / was preempted to MARKET
|
||
|
|
- ``on_cancel`` — a working quote was cancelled
|
||
|
|
|
||
|
|
Configuration (env, parsed once by ``ExecConfig.from_env()``):
|
||
|
|
|
||
|
|
DOLPHIN_PINK_EXEC_STYLE taker|maker_entry|maker_exit|maker_both [taker]
|
||
|
|
DOLPHIN_PINK_MAKER_ENTRY_TTL_S float seconds quote lifetime [8.0]
|
||
|
|
DOLPHIN_PINK_MAKER_EXIT_TTL_S float seconds quote lifetime [5.0]
|
||
|
|
DOLPHIN_PINK_MAKER_ENTRY_MISS skip|retry|market [skip]
|
||
|
|
DOLPHIN_PINK_MAKER_ENTRY_RETRIES int max re-quotes when MISS=retry [1]
|
||
|
|
DOLPHIN_PINK_MAKER_RETRY_EXHAUST skip|market after retries spent [skip]
|
||
|
|
DOLPHIN_PINK_MAKER_OFFSET_TICKS int quote distance from reference [1]
|
||
|
|
DOLPHIN_PINK_MAKER_MAX_SPREAD_BPS float; spread wider than this → taker [5.0]
|
||
|
|
DOLPHIN_PINK_POST_ONLY 0|1 send PostOnly TIF on maker quotes [1]
|
||
|
|
DOLPHIN_PINK_TICK_SIZE_<SYMBOL> per-symbol tick override (e.g. _BTCUSDT)
|
||
|
|
|
||
|
|
Maker-eligible exit reasons: TAKE_PROFIT only. CATASTROPHIC_LOSS,
|
||
|
|
MAX_HOLD, MEAN_REVERSION and anything unrecognised are urgent → MARKET.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
import os
|
||
|
|
import time
|
||
|
|
from dataclasses import dataclass, field, replace
|
||
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||
|
|
|
||
|
|
LOGGER = logging.getLogger("dita_v2.exec_router")
|
||
|
|
|
||
|
|
# Exit reasons that tolerate a resting reduce-only quote. Everything else
|
||
|
|
# (stops, max-hold, mean-reversion flips, reconcile-driven closes) demands
|
||
|
|
# immediacy and is executed as taker MARKET regardless of style.
|
||
|
|
MAKER_EXIT_REASONS = frozenset({"TAKE_PROFIT"})
|
||
|
|
|
||
|
|
VALID_STYLES = ("taker", "maker_entry", "maker_exit", "maker_both")
|
||
|
|
VALID_MISS = ("skip", "retry", "market")
|
||
|
|
VALID_EXHAUST = ("skip", "market")
|
||
|
|
|
||
|
|
HOOK_STAGES = (
|
||
|
|
"pre_plan", "pre_submit", "on_working", "on_fill",
|
||
|
|
"on_miss", "on_escalate", "on_cancel",
|
||
|
|
)
|
||
|
|
|
||
|
|
# Tick sizes from the BingX characterization sweep
|
||
|
|
# (prod/docs/BingX_FILL_CHARACTERIZATION_AND_ADVANTAGES.md §Precision).
|
||
|
|
DEFAULT_TICKS: Dict[str, float] = {
|
||
|
|
"BTCUSDT": 0.1,
|
||
|
|
"ETHUSDT": 0.01,
|
||
|
|
"AAVEUSDT": 0.01,
|
||
|
|
"SOLUSDT": 0.001,
|
||
|
|
"XRPUSDT": 0.0001,
|
||
|
|
"DOGEUSDT": 0.00001,
|
||
|
|
"SHIBUSDT": 1e-9,
|
||
|
|
"YFIUSDT": 0.01,
|
||
|
|
"XAUTUSDT": 0.1,
|
||
|
|
"ADAUSDT": 0.0001,
|
||
|
|
"TRXUSDT": 0.00001,
|
||
|
|
"ALGOUSDT": 0.0001,
|
||
|
|
}
|
||
|
|
_FALLBACK_TICK_FRACTION = 1e-5 # unknown symbol: ~0.1 bp of price
|
||
|
|
|
||
|
|
|
||
|
|
def _env_float(name: str, default: float, lo: float, hi: float) -> float:
|
||
|
|
raw = os.environ.get(name)
|
||
|
|
if raw is None or not str(raw).strip():
|
||
|
|
return default
|
||
|
|
try:
|
||
|
|
val = float(str(raw).strip())
|
||
|
|
except Exception:
|
||
|
|
LOGGER.warning("exec_router: bad %s=%r — using default %s", name, raw, default)
|
||
|
|
return default
|
||
|
|
if not (lo <= val <= hi):
|
||
|
|
clamped = min(max(val, lo), hi)
|
||
|
|
LOGGER.warning("exec_router: %s=%s outside [%s, %s] — clamped to %s",
|
||
|
|
name, val, lo, hi, clamped)
|
||
|
|
return clamped
|
||
|
|
return val
|
||
|
|
|
||
|
|
|
||
|
|
def _env_int(name: str, default: int, lo: int, hi: int) -> int:
|
||
|
|
return int(_env_float(name, float(default), float(lo), float(hi)))
|
||
|
|
|
||
|
|
|
||
|
|
def _env_choice(name: str, default: str, choices: Tuple[str, ...]) -> str:
|
||
|
|
raw = str(os.environ.get(name, default) or default).strip().lower()
|
||
|
|
if raw not in choices:
|
||
|
|
LOGGER.warning("exec_router: %s=%r not in %s — using %r", name, raw, choices, default)
|
||
|
|
return default
|
||
|
|
return raw
|
||
|
|
|
||
|
|
|
||
|
|
def _env_bool(name: str, default: bool) -> bool:
|
||
|
|
raw = os.environ.get(name)
|
||
|
|
if raw is None or not str(raw).strip():
|
||
|
|
return default
|
||
|
|
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class ExecConfig:
|
||
|
|
"""Validated execution-policy configuration. Frozen: build once at boot."""
|
||
|
|
|
||
|
|
style: str = "taker"
|
||
|
|
entry_ttl_s: float = 8.0
|
||
|
|
exit_ttl_s: float = 5.0
|
||
|
|
entry_miss: str = "skip"
|
||
|
|
entry_retries: int = 1
|
||
|
|
retry_exhaust: str = "skip"
|
||
|
|
offset_ticks: int = 1
|
||
|
|
max_spread_bps: float = 5.0
|
||
|
|
post_only: bool = True
|
||
|
|
tick_overrides: Dict[str, float] = field(default_factory=dict)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def maker_entry(self) -> bool:
|
||
|
|
return self.style in ("maker_entry", "maker_both")
|
||
|
|
|
||
|
|
@property
|
||
|
|
def maker_exit(self) -> bool:
|
||
|
|
return self.style in ("maker_exit", "maker_both")
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def from_env(cls) -> "ExecConfig":
|
||
|
|
ticks: Dict[str, float] = {}
|
||
|
|
for key, raw in os.environ.items():
|
||
|
|
if key.startswith("DOLPHIN_PINK_TICK_SIZE_"):
|
||
|
|
sym = key[len("DOLPHIN_PINK_TICK_SIZE_"):].upper()
|
||
|
|
try:
|
||
|
|
val = float(raw)
|
||
|
|
if val > 0:
|
||
|
|
ticks[sym] = val
|
||
|
|
except Exception:
|
||
|
|
LOGGER.warning("exec_router: bad tick override %s=%r", key, raw)
|
||
|
|
return cls(
|
||
|
|
style=_env_choice("DOLPHIN_PINK_EXEC_STYLE", "taker", VALID_STYLES),
|
||
|
|
entry_ttl_s=_env_float("DOLPHIN_PINK_MAKER_ENTRY_TTL_S", 8.0, 0.5, 300.0),
|
||
|
|
exit_ttl_s=_env_float("DOLPHIN_PINK_MAKER_EXIT_TTL_S", 5.0, 0.5, 300.0),
|
||
|
|
entry_miss=_env_choice("DOLPHIN_PINK_MAKER_ENTRY_MISS", "skip", VALID_MISS),
|
||
|
|
entry_retries=_env_int("DOLPHIN_PINK_MAKER_ENTRY_RETRIES", 1, 0, 10),
|
||
|
|
retry_exhaust=_env_choice("DOLPHIN_PINK_MAKER_RETRY_EXHAUST", "skip", VALID_EXHAUST),
|
||
|
|
offset_ticks=_env_int("DOLPHIN_PINK_MAKER_OFFSET_TICKS", 1, 0, 100),
|
||
|
|
max_spread_bps=_env_float("DOLPHIN_PINK_MAKER_MAX_SPREAD_BPS", 5.0, 0.0, 1000.0),
|
||
|
|
post_only=_env_bool("DOLPHIN_PINK_POST_ONLY", True),
|
||
|
|
tick_overrides=ticks,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class ExecutionPlan:
|
||
|
|
"""How one intent should be executed. Produced by the router, consumed
|
||
|
|
by the runtime, forwarded to the venue via KernelIntent fields/metadata."""
|
||
|
|
|
||
|
|
order_type: str = "MARKET" # "MARKET" | "LIMIT"
|
||
|
|
limit_price: float = 0.0
|
||
|
|
post_only: bool = False
|
||
|
|
ttl_s: float = 0.0 # 0 = no TTL management (taker)
|
||
|
|
is_maker: bool = False
|
||
|
|
action: str = "ENTER" # "ENTER" | "EXIT"
|
||
|
|
reason: str = "taker_default" # provenance for logs/persistence
|
||
|
|
suppress: bool = False # True → do not submit (dup guard)
|
||
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||
|
|
|
||
|
|
def sane(self) -> bool:
|
||
|
|
if self.order_type not in ("MARKET", "LIMIT"):
|
||
|
|
return False
|
||
|
|
if self.order_type == "LIMIT" and not (self.limit_price > 0.0):
|
||
|
|
return False
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class WorkingOrder:
|
||
|
|
"""Runtime-registered maker quote awaiting fill or TTL."""
|
||
|
|
|
||
|
|
trade_id: str
|
||
|
|
asset: str
|
||
|
|
side: str # "SHORT" | "LONG" (position side)
|
||
|
|
action: str # "ENTER" | "EXIT"
|
||
|
|
plan: ExecutionPlan
|
||
|
|
submitted_at: float # monotonic clock
|
||
|
|
deadline: float
|
||
|
|
retries_left: int
|
||
|
|
base_trade_id: str # original id before retry suffixes
|
||
|
|
retry_n: int = 0
|
||
|
|
|
||
|
|
|
||
|
|
class MissAction:
|
||
|
|
SKIP = "skip"
|
||
|
|
RETRY = "retry"
|
||
|
|
MARKET = "market"
|
||
|
|
|
||
|
|
|
||
|
|
class ExecutionRouter:
|
||
|
|
"""Pure-policy execution router with a working-order registry.
|
||
|
|
|
||
|
|
The runtime asks ``plan_entry``/``plan_exit`` before each kernel
|
||
|
|
submission, registers maker quotes via ``register_working``, polls
|
||
|
|
``expired`` from its TTL loop, and reports outcomes back via
|
||
|
|
``note_fill``/``note_cancel``. All venue I/O stays in the runtime.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, config: Optional[ExecConfig] = None, *,
|
||
|
|
logger: Any = LOGGER, clock: Callable[[], float] = time.monotonic):
|
||
|
|
self.config = config or ExecConfig()
|
||
|
|
self.logger = logger
|
||
|
|
self.clock = clock
|
||
|
|
self._working: Dict[str, WorkingOrder] = {} # trade_id → WorkingOrder
|
||
|
|
self._hooks: Dict[str, List[Callable]] = {s: [] for s in HOOK_STAGES}
|
||
|
|
self.counters: Dict[str, int] = {
|
||
|
|
"plans_entry": 0, "plans_exit": 0,
|
||
|
|
"maker_entries": 0, "maker_exits": 0,
|
||
|
|
"taker_entries": 0, "taker_exits": 0,
|
||
|
|
"suppressed_dup_enter": 0, "suppressed_dup_exit": 0,
|
||
|
|
"spread_gate_taker": 0,
|
||
|
|
"entry_miss_skip": 0, "entry_miss_retry": 0, "entry_miss_market": 0,
|
||
|
|
"exit_escalations": 0, "fills_working": 0, "cancels": 0,
|
||
|
|
"hook_errors": 0,
|
||
|
|
}
|
||
|
|
|
||
|
|
# ── hooks ────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def register_hook(self, stage: str, fn: Callable) -> Callable[[], None]:
|
||
|
|
"""Register ``fn`` at ``stage``; returns an unregister callable."""
|
||
|
|
if stage not in self._hooks:
|
||
|
|
raise ValueError(f"unknown hook stage {stage!r}; valid: {HOOK_STAGES}")
|
||
|
|
self._hooks[stage].append(fn)
|
||
|
|
|
||
|
|
def _unregister() -> None:
|
||
|
|
try:
|
||
|
|
self._hooks[stage].remove(fn)
|
||
|
|
except ValueError:
|
||
|
|
pass
|
||
|
|
return _unregister
|
||
|
|
|
||
|
|
def _run_hooks(self, stage: str, payload: Any, ctx: Dict[str, Any]) -> Any:
|
||
|
|
"""Run hooks; a ``pre_submit`` hook may return a replacement plan.
|
||
|
|
Hook exceptions are isolated — the trading path must never die in
|
||
|
|
a plugin."""
|
||
|
|
out = payload
|
||
|
|
for fn in list(self._hooks.get(stage, ())):
|
||
|
|
try:
|
||
|
|
ret = fn(out, dict(ctx))
|
||
|
|
if stage == "pre_submit" and isinstance(ret, ExecutionPlan):
|
||
|
|
if ret.sane():
|
||
|
|
out = ret
|
||
|
|
else:
|
||
|
|
self.logger.warning(
|
||
|
|
"exec_router: hook %r returned insane plan — ignored", fn)
|
||
|
|
except Exception as exc:
|
||
|
|
self.counters["hook_errors"] += 1
|
||
|
|
self.logger.warning("exec_router: hook %r failed at %s: %s", fn, stage, exc)
|
||
|
|
return out
|
||
|
|
|
||
|
|
# ── pricing ──────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def tick_size(self, asset: str) -> float:
|
||
|
|
sym = str(asset or "").upper()
|
||
|
|
if sym in self.config.tick_overrides:
|
||
|
|
return self.config.tick_overrides[sym]
|
||
|
|
return DEFAULT_TICKS.get(sym, 0.0)
|
||
|
|
|
||
|
|
def maker_price(self, *, asset: str, order_side: str, reference_price: float) -> float:
|
||
|
|
"""Quote price that rests on the book on our side of the touch.
|
||
|
|
|
||
|
|
``order_side`` is the ORDER side ("SELL"/"BUY"), not the position
|
||
|
|
side. SELL rests at/above reference; BUY rests at/below. Post-only
|
||
|
|
rejects any residual cross, so quoting at the touch is safe.
|
||
|
|
"""
|
||
|
|
ref = float(reference_price)
|
||
|
|
if not (ref > 0.0):
|
||
|
|
return 0.0
|
||
|
|
tick = self.tick_size(asset)
|
||
|
|
if tick <= 0.0:
|
||
|
|
tick = ref * _FALLBACK_TICK_FRACTION
|
||
|
|
off = self.config.offset_ticks * tick
|
||
|
|
if str(order_side).upper() == "SELL":
|
||
|
|
return ref + off
|
||
|
|
return max(tick, ref - off)
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def order_side(action: str, position_side: str) -> str:
|
||
|
|
"""Map (action, position side) → order side, mirroring the adapter."""
|
||
|
|
pos = str(position_side).upper()
|
||
|
|
if str(action).upper() == "EXIT":
|
||
|
|
return "SELL" if pos == "LONG" else "BUY"
|
||
|
|
return "BUY" if pos == "LONG" else "SELL"
|
||
|
|
|
||
|
|
# ── planning ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def _spread_allows_maker(self, spread_bps: Optional[float]) -> bool:
|
||
|
|
if spread_bps is None:
|
||
|
|
return True # no OBF data — quote anyway; post-only caps the risk
|
||
|
|
return float(spread_bps) <= self.config.max_spread_bps
|
||
|
|
|
||
|
|
def plan_entry(self, *, trade_id: str, asset: str, position_side: str,
|
||
|
|
reference_price: float,
|
||
|
|
spread_bps: Optional[float] = None) -> ExecutionPlan:
|
||
|
|
"""Plan an ENTER execution. Never raises; falls back to MARKET."""
|
||
|
|
self.counters["plans_entry"] += 1
|
||
|
|
ctx = {"trade_id": trade_id, "asset": asset, "side": position_side,
|
||
|
|
"reference_price": reference_price, "spread_bps": spread_bps,
|
||
|
|
"action": "ENTER"}
|
||
|
|
self._run_hooks("pre_plan", None, ctx)
|
||
|
|
|
||
|
|
# Double-entry guard: a working entry means the slot is spoken for.
|
||
|
|
for wo in self._working.values():
|
||
|
|
if wo.action == "ENTER":
|
||
|
|
self.counters["suppressed_dup_enter"] += 1
|
||
|
|
return ExecutionPlan(action="ENTER", suppress=True,
|
||
|
|
reason=f"working_entry_exists:{wo.trade_id}")
|
||
|
|
|
||
|
|
plan = ExecutionPlan(action="ENTER", reason="taker_default")
|
||
|
|
if self.config.maker_entry and reference_price > 0.0:
|
||
|
|
if not self._spread_allows_maker(spread_bps):
|
||
|
|
self.counters["spread_gate_taker"] += 1
|
||
|
|
plan = ExecutionPlan(action="ENTER",
|
||
|
|
reason=f"spread_gate:{spread_bps}bps")
|
||
|
|
else:
|
||
|
|
side = self.order_side("ENTER", position_side)
|
||
|
|
px = self.maker_price(asset=asset, order_side=side,
|
||
|
|
reference_price=reference_price)
|
||
|
|
if px > 0.0:
|
||
|
|
plan = ExecutionPlan(
|
||
|
|
order_type="LIMIT", limit_price=px,
|
||
|
|
post_only=self.config.post_only,
|
||
|
|
ttl_s=self.config.entry_ttl_s, is_maker=True,
|
||
|
|
action="ENTER", reason="maker_entry",
|
||
|
|
)
|
||
|
|
plan = self._run_hooks("pre_submit", plan, ctx)
|
||
|
|
if plan.is_maker:
|
||
|
|
self.counters["maker_entries"] += 1
|
||
|
|
elif not plan.suppress:
|
||
|
|
self.counters["taker_entries"] += 1
|
||
|
|
return plan
|
||
|
|
|
||
|
|
def plan_exit(self, *, trade_id: str, asset: str, position_side: str,
|
||
|
|
reference_price: float, reason: str,
|
||
|
|
spread_bps: Optional[float] = None) -> ExecutionPlan:
|
||
|
|
"""Plan an EXIT execution.
|
||
|
|
|
||
|
|
RULE 1: exits are never skipped. A non-maker-eligible reason, a bad
|
||
|
|
reference price, or a wide spread all degrade to MARKET — never to
|
||
|
|
suppression, except the duplicate-guard case where a maker exit for
|
||
|
|
the SAME trade is already working and the new reason is equally
|
||
|
|
non-urgent (the resting quote IS the exit in flight).
|
||
|
|
"""
|
||
|
|
self.counters["plans_exit"] += 1
|
||
|
|
urgent = str(reason or "").upper() not in MAKER_EXIT_REASONS
|
||
|
|
ctx = {"trade_id": trade_id, "asset": asset, "side": position_side,
|
||
|
|
"reference_price": reference_price, "spread_bps": spread_bps,
|
||
|
|
"action": "EXIT", "reason": reason, "urgent": urgent}
|
||
|
|
self._run_hooks("pre_plan", None, ctx)
|
||
|
|
|
||
|
|
wo = self._working.get(trade_id)
|
||
|
|
if wo is not None and wo.action == "EXIT":
|
||
|
|
if not urgent:
|
||
|
|
# Same-trade maker exit already resting → nothing to add.
|
||
|
|
self.counters["suppressed_dup_exit"] += 1
|
||
|
|
return ExecutionPlan(action="EXIT", suppress=True,
|
||
|
|
reason="working_exit_exists")
|
||
|
|
# Urgent reason preempts the resting quote: runtime must cancel
|
||
|
|
# the working order, then submit this MARKET plan.
|
||
|
|
self.counters["exit_escalations"] += 1
|
||
|
|
plan = ExecutionPlan(action="EXIT", reason=f"escalate:{reason}",
|
||
|
|
metadata={"preempt_working": True})
|
||
|
|
return self._run_hooks("pre_submit", plan, ctx)
|
||
|
|
|
||
|
|
plan = ExecutionPlan(action="EXIT", reason=f"taker_exit:{reason}")
|
||
|
|
if (self.config.maker_exit and not urgent and reference_price > 0.0
|
||
|
|
and self._spread_allows_maker(spread_bps)):
|
||
|
|
side = self.order_side("EXIT", position_side)
|
||
|
|
px = self.maker_price(asset=asset, order_side=side,
|
||
|
|
reference_price=reference_price)
|
||
|
|
if px > 0.0:
|
||
|
|
plan = ExecutionPlan(
|
||
|
|
order_type="LIMIT", limit_price=px,
|
||
|
|
post_only=self.config.post_only,
|
||
|
|
ttl_s=self.config.exit_ttl_s, is_maker=True,
|
||
|
|
action="EXIT", reason="maker_exit:TAKE_PROFIT",
|
||
|
|
)
|
||
|
|
plan = self._run_hooks("pre_submit", plan, ctx)
|
||
|
|
if plan.suppress:
|
||
|
|
# RULE 1 enforcement against plugins: only the dup-guard branches
|
||
|
|
# above may suppress an exit; a hook returning suppress is
|
||
|
|
# overridden to MARKET so a position can never be stranded.
|
||
|
|
plan = ExecutionPlan(action="EXIT", reason="hook_suppress_overridden_market")
|
||
|
|
if not plan.sane():
|
||
|
|
# Hard floor: an exit must reach the venue. Insane plan → MARKET.
|
||
|
|
plan = ExecutionPlan(action="EXIT", reason="sanity_fallback_market")
|
||
|
|
if plan.is_maker:
|
||
|
|
self.counters["maker_exits"] += 1
|
||
|
|
elif not plan.suppress:
|
||
|
|
self.counters["taker_exits"] += 1
|
||
|
|
return plan
|
||
|
|
|
||
|
|
# ── working-order registry ───────────────────────────────────────────────
|
||
|
|
|
||
|
|
def register_working(self, *, trade_id: str, asset: str, position_side: str,
|
||
|
|
plan: ExecutionPlan,
|
||
|
|
base_trade_id: Optional[str] = None,
|
||
|
|
retry_n: int = 0) -> WorkingOrder:
|
||
|
|
now = self.clock()
|
||
|
|
wo = WorkingOrder(
|
||
|
|
trade_id=trade_id, asset=asset, side=str(position_side).upper(),
|
||
|
|
action=plan.action, plan=plan, submitted_at=now,
|
||
|
|
deadline=now + max(0.5, plan.ttl_s),
|
||
|
|
retries_left=self.config.entry_retries if plan.action == "ENTER" else 0,
|
||
|
|
base_trade_id=base_trade_id or trade_id, retry_n=retry_n,
|
||
|
|
)
|
||
|
|
if retry_n > 0:
|
||
|
|
wo.retries_left = max(0, self.config.entry_retries - retry_n)
|
||
|
|
self._working[trade_id] = wo
|
||
|
|
self._run_hooks("on_working", wo, {"trade_id": trade_id})
|
||
|
|
return wo
|
||
|
|
|
||
|
|
def working(self, trade_id: str) -> Optional[WorkingOrder]:
|
||
|
|
return self._working.get(trade_id)
|
||
|
|
|
||
|
|
def working_orders(self) -> List[WorkingOrder]:
|
||
|
|
return list(self._working.values())
|
||
|
|
|
||
|
|
def has_working_entry(self) -> bool:
|
||
|
|
return any(wo.action == "ENTER" for wo in self._working.values())
|
||
|
|
|
||
|
|
def expired(self, now: Optional[float] = None) -> List[WorkingOrder]:
|
||
|
|
t = self.clock() if now is None else now
|
||
|
|
return [wo for wo in self._working.values() if t >= wo.deadline]
|
||
|
|
|
||
|
|
def note_fill(self, trade_id: str) -> None:
|
||
|
|
wo = self._working.pop(trade_id, None)
|
||
|
|
if wo is not None:
|
||
|
|
self.counters["fills_working"] += 1
|
||
|
|
self._run_hooks("on_fill", wo, {"trade_id": trade_id})
|
||
|
|
|
||
|
|
def note_cancel(self, trade_id: str) -> None:
|
||
|
|
wo = self._working.pop(trade_id, None)
|
||
|
|
if wo is not None:
|
||
|
|
self.counters["cancels"] += 1
|
||
|
|
self._run_hooks("on_cancel", wo, {"trade_id": trade_id})
|
||
|
|
|
||
|
|
def clear_working(self, trade_id: str) -> None:
|
||
|
|
self._working.pop(trade_id, None)
|
||
|
|
|
||
|
|
# ── miss / escalation policy ─────────────────────────────────────────────
|
||
|
|
|
||
|
|
def entry_miss_action(self, wo: WorkingOrder) -> str:
|
||
|
|
"""Decide what to do with an expired working ENTRY (after the runtime
|
||
|
|
has cancelled the quote). Returns a ``MissAction``.
|
||
|
|
|
||
|
|
retry policy: up to ``entry_retries`` fresh quotes, then
|
||
|
|
``retry_exhaust`` (skip|market). ``entry_miss`` skip|market apply
|
||
|
|
immediately with no re-quote.
|
||
|
|
"""
|
||
|
|
mode = self.config.entry_miss
|
||
|
|
if mode == "skip":
|
||
|
|
self.counters["entry_miss_skip"] += 1
|
||
|
|
action = MissAction.SKIP
|
||
|
|
elif mode == "market":
|
||
|
|
self.counters["entry_miss_market"] += 1
|
||
|
|
action = MissAction.MARKET
|
||
|
|
else: # retry
|
||
|
|
if wo.retries_left > 0:
|
||
|
|
self.counters["entry_miss_retry"] += 1
|
||
|
|
action = MissAction.RETRY
|
||
|
|
elif self.config.retry_exhaust == "market":
|
||
|
|
self.counters["entry_miss_market"] += 1
|
||
|
|
action = MissAction.MARKET
|
||
|
|
else:
|
||
|
|
self.counters["entry_miss_skip"] += 1
|
||
|
|
action = MissAction.SKIP
|
||
|
|
self._run_hooks("on_miss", wo, {"action": action})
|
||
|
|
return action
|
||
|
|
|
||
|
|
def retry_plan(self, wo: WorkingOrder, *, reference_price: float) -> Tuple[str, ExecutionPlan]:
|
||
|
|
"""Fresh quote for a retried entry. Returns (new_trade_id, plan).
|
||
|
|
New trade_id guarantees clientOrderId uniqueness on the venue and a
|
||
|
|
clean kernel FSM lifecycle for the re-quote."""
|
||
|
|
n = wo.retry_n + 1
|
||
|
|
new_tid = f"{wo.base_trade_id}-r{n}"
|
||
|
|
side = self.order_side("ENTER", wo.side)
|
||
|
|
px = self.maker_price(asset=wo.asset, order_side=side,
|
||
|
|
reference_price=reference_price)
|
||
|
|
plan = ExecutionPlan(
|
||
|
|
order_type="LIMIT", limit_price=px,
|
||
|
|
post_only=self.config.post_only,
|
||
|
|
ttl_s=self.config.entry_ttl_s, is_maker=True,
|
||
|
|
action="ENTER", reason=f"maker_entry_retry_{n}",
|
||
|
|
metadata={"retry_n": n, "base_trade_id": wo.base_trade_id},
|
||
|
|
)
|
||
|
|
if not plan.sane():
|
||
|
|
plan = ExecutionPlan(action="ENTER", reason="retry_price_insane_market",
|
||
|
|
metadata={"retry_n": n, "base_trade_id": wo.base_trade_id})
|
||
|
|
return new_tid, plan
|
||
|
|
|
||
|
|
def market_fallback_plan(self, wo: WorkingOrder) -> Tuple[str, ExecutionPlan]:
|
||
|
|
"""MARKET fallback after a missed/escalated quote.
|
||
|
|
|
||
|
|
ENTER: fresh trade_id (``-m`` suffix) — the cancelled quote's
|
||
|
|
lifecycle is closed; the fallback is a new order.
|
||
|
|
EXIT: SAME trade_id — the exit must stay attached to the open
|
||
|
|
position's lifecycle in the kernel FSM.
|
||
|
|
"""
|
||
|
|
if wo.action == "ENTER":
|
||
|
|
new_tid = f"{wo.base_trade_id}-m"
|
||
|
|
self._run_hooks("on_escalate", wo, {"to": "MARKET"})
|
||
|
|
return new_tid, ExecutionPlan(
|
||
|
|
action="ENTER", reason="entry_miss_market_fallback",
|
||
|
|
metadata={"base_trade_id": wo.base_trade_id})
|
||
|
|
self.counters["exit_escalations"] += 1
|
||
|
|
self._run_hooks("on_escalate", wo, {"to": "MARKET"})
|
||
|
|
return wo.trade_id, ExecutionPlan(
|
||
|
|
action="EXIT", reason="exit_ttl_market_fallback",
|
||
|
|
metadata={"base_trade_id": wo.base_trade_id})
|
||
|
|
|
||
|
|
# ── observability ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def snapshot(self) -> Dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"style": self.config.style,
|
||
|
|
"working": [
|
||
|
|
{"trade_id": w.trade_id, "action": w.action, "asset": w.asset,
|
||
|
|
"age_s": round(self.clock() - w.submitted_at, 3),
|
||
|
|
"retry_n": w.retry_n}
|
||
|
|
for w in self._working.values()
|
||
|
|
],
|
||
|
|
"counters": dict(self.counters),
|
||
|
|
}
|