diff --git a/prod/exec_unified/__init__.py b/prod/exec_unified/__init__.py new file mode 100644 index 0000000..2fd7ad0 --- /dev/null +++ b/prod/exec_unified/__init__.py @@ -0,0 +1,31 @@ +"""Unified Execution Layer — the "T" of DITAv2 (spec SPEC_UNIFIED_EXEC_LAYER_20260714.md). + +Standalone, pure-policy foundation. Contract (§2) + whether-maker Router (§6). Zero I/O, +zero venue knowledge, zero importers elsewhere yet — adopting it breaks nothing. + +Not yet wired: the PINK drive-loop port (§7), the venue dialect (§11), telemetry (§12). +Those bolt on above/below this pure core. Kill switch discipline (UV_SMART_EXEC=0, §2.3 +promise #5) lives at the wiring layer, not here. +""" +from __future__ import annotations + +from .contract import ( + ExecutionAdvice, + ExecutionRequest, + ProtectiveSpec, + Side, + UrgencyClass, +) +from .router import ExecutionMethod, RoutingDecision, TtlDiscipline, decide + +__all__ = [ + "ExecutionAdvice", + "ExecutionMethod", + "ExecutionRequest", + "ProtectiveSpec", + "RoutingDecision", + "Side", + "TtlDiscipline", + "UrgencyClass", + "decide", +] diff --git a/prod/exec_unified/_constants.py b/prod/exec_unified/_constants.py new file mode 100644 index 0000000..6c69906 --- /dev/null +++ b/prod/exec_unified/_constants.py @@ -0,0 +1,30 @@ +"""Policy constants — from data, never vibes (spec §6 closing note). + +Every constant cites its provenance. Where a number is not yet backed by an L8/L10 +characterization row it is marked PROVISIONAL and must be calibrated before the smart +path arms with real capital — a provisional constant is a known-unknown, not a vibe. + +Cadence is NEVER hardcoded here (no literal 1 s): the drive loop takes its clock +injected (spec §7, C1). These are policy magnitudes, not periods. +""" +from __future__ import annotations + +from decimal import Decimal + +# PROTECT: "maker at touch, one reprice, ≤2 s total, then cross" (spec §6). +PROTECT_REPRICE_LIMIT: int = 1 +PROTECT_MAX_MS: int = 2_000 + +# HARVEST: "maker at target ± offset, chase ≤ N; give up when regression eats X% +# of unrealized → cross" (spec §6). N and X are PROVISIONAL — no characterization +# row fixes them yet (§17 open question: chase bounds from bookDepth replay). +HARVEST_MAX_CHASES: int = 3 # PROVISIONAL — calibrate vs L8 fill_sim +HARVEST_GIVEUP_REGRESSION_FRAC: Decimal = Decimal("0.25") # PROVISIONAL — 25% of unrealized + +# ACQUIRE: "patient maker inside spread; abandon, never chase" (spec §6, §4-1: +# a missed entry is free, a chased entry is not). No chase budget by construction. +ACQUIRE_MAX_CHASES: int = 0 + +# Dead-man's stop default multiple of software SL distance (spec §10, §17 open +# question: 2× vs ATR-scaled). PROVISIONAL default. +PROTECTIVE_STOP_MULT_DEFAULT: Decimal = Decimal("2.0") diff --git a/prod/exec_unified/contract.py b/prod/exec_unified/contract.py new file mode 100644 index 0000000..d6e2273 --- /dev/null +++ b/prod/exec_unified/contract.py @@ -0,0 +1,138 @@ +"""The Unified Execution Layer contract — "an asset, a size, and a prayer". + +This is the WHOLE input surface (spec §2.1). Nothing about the caller's world may +leak in: not leverage math, not postures, not capital, not why-now. Every brain the +factory produces (BLUE, UV/BLUE-PRIME, VIOLET, VIBRASS children, MALKHUT) executes +through the same body by speaking only this vocabulary. + +Pure stdlib + Decimal. Zero I/O, zero venue knowledge. V-TYPES discipline: frozen, +validated at construction, illegal states unrepresentable. + +Provenance: prod/docs/SPEC_UNIFIED_EXEC_LAYER_20260714.md §2, §6. +Home-to-be: prod/clean_arch/dita_v2/exec/ (vendored). Lives standalone until the +vendor flow relocates it — do not import from the vendored dita_v2 tree yet. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from enum import Enum + + +class Side(Enum): + BUY = "BUY" + SELL = "SELL" + + +class UrgencyClass(Enum): + """The caller's declaration of *why* — the only "why" this layer ever learns (§6). + + Ordered most-urgent → least. The ordinal is meaningful: dispatch priority and the + "never delay a more-urgent order" invariant read it (spec §9 priority ladder). + """ + + CATASTROPHIC = 0 # SL / kill / liquidation — taker MARKET now, no cleverness ever + PROTECT = 1 # TP_FLOOR give-back, ADVSL retract — maker@touch, 1 reprice, then cross + HARVEST = 2 # FIXED_TP — maker at target ± offset, chase ≤N, give up on regression + ROTATE = 3 # MAX_HOLD / admin — patient maker, TTL from deadline_ms, cross at TTL + ACQUIRE = 4 # ENTER — patient maker inside spread, abandon (never chase) + + @property + def is_exit(self) -> bool: + """Exit-side urgencies decrease a position; entries (ACQUIRE) increase it. + + Exits may only ever tighten toward urgency, never reprice away (spec §4-2). + """ + return self in (UrgencyClass.CATASTROPHIC, UrgencyClass.PROTECT, + UrgencyClass.HARVEST, UrgencyClass.ROTATE) + + +@dataclass(frozen=True) +class ProtectiveSpec: + """Attach-a-dead-man's-stop geometry (spec §10). Mark-based, rides the entry order. + + Either an explicit ``stop_price`` OR a ``stop_mult`` multiple of the caller's + software SL distance — never both, never neither. The venue-attached STOP_MARKET + fires only when the client is dead or blind (HZ silent-death lineage): parity- + invisible, because it only acts once the software layer has already failed. + """ + + stop_price: Decimal | None = None + stop_mult: Decimal | None = None # × software-SL distance (e.g. 2.0) + working_type: str = "MARK_PRICE" # venue geometry truth = mark, never last + reduce_only: bool = True + + def __post_init__(self) -> None: + has_px = self.stop_price is not None + has_mult = self.stop_mult is not None + if has_px == has_mult: + raise ValueError("ProtectiveSpec needs exactly one of stop_price / stop_mult") + if has_px and self.stop_price <= 0: + raise ValueError(f"stop_price must be positive, got {self.stop_price}") + if has_mult and self.stop_mult <= 0: + raise ValueError(f"stop_mult must be positive, got {self.stop_mult}") + if self.working_type != "MARK_PRICE": + # Geometry truth is the mark (spec §9 three-price-truths). Guardrail, not law. + raise ValueError("protective working_type must be MARK_PRICE") + + +@dataclass(frozen=True) +class ExecutionAdvice: + """MALKHUT/other advisor hints — an advisor, never an authority (spec §5, §11-L11). + + The layer may take these hints or ignore them; CATASTROPHIC ignores them *by law*. + Advice can never change WHAT was asked (side/size/asset/reduce_only are sacred). + """ + + source: str = "unknown" # who advised (provenance, non-authoritative) + prefer_post_only: bool | None = None # hint: rest as maker if possible + qty_fraction: Decimal | None = None # hint: work this fraction per slice (slicer above) + note: str = "" + + def __post_init__(self) -> None: + if self.qty_fraction is not None and not (Decimal(0) < self.qty_fraction <= Decimal(1)): + raise ValueError(f"qty_fraction must be in (0,1], got {self.qty_fraction}") + + +@dataclass(frozen=True) +class ExecutionRequest: + """The whole surface. Nothing else may leak in (spec §2.1). + + ``guideline_price`` is a decision-time *reference*, NOT a limit — it resolves the + ADVSL stale-price concern: scan-clock exits pass the price that triggered them; the + drive loop executes against the live book, never against this frozen number. + """ + + request_id: str # caller-minted; idempotency key end-to-end + asset: str # canonical undashed ("BTCUSDT"); dialect dashes it + side: Side + size: Decimal # base quantity; sizing is the caller's solved problem + urgency: UrgencyClass # assigned by CALLER, NEVER inferred here + reduce_only: bool = False # position-decreasing intent (exits set True) + guideline_price: Decimal | None = None # reference, not a limit + protective: ProtectiveSpec | None = None + advice: ExecutionAdvice | None = None + deadline_ms: int | None = None # caller's patience budget (ROTATE/ACQUIRE) + + def __post_init__(self) -> None: + if not self.request_id: + raise ValueError("request_id is mandatory (idempotency key)") + if self.asset != self.asset.upper() or "-" in self.asset: + raise ValueError(f"asset must be canonical undashed upper, got {self.asset!r}") + if self.size <= 0: + raise ValueError(f"size must be positive, got {self.size}") + if self.guideline_price is not None and self.guideline_price <= 0: + raise ValueError(f"guideline_price must be positive, got {self.guideline_price}") + if self.deadline_ms is not None and self.deadline_ms <= 0: + raise ValueError(f"deadline_ms must be positive, got {self.deadline_ms}") + # Contract invariant: an entry (increases position) can't be reduce_only. + if self.reduce_only and self.urgency is UrgencyClass.ACQUIRE: + raise ValueError("ACQUIRE (entry) cannot be reduce_only") + + @property + def client_order_id_seed(self) -> str: + """Deterministic seed for the venue clientOrderId (promise #4: never lose an order). + + Prefix discipline lives in the dialect layer (u-/m-); this is the stable core. + """ + return self.request_id diff --git a/prod/exec_unified/router.py b/prod/exec_unified/router.py new file mode 100644 index 0000000..5095054 --- /dev/null +++ b/prod/exec_unified/router.py @@ -0,0 +1,136 @@ +"""The whether-maker policy machine (spec §6 urgency ladder). + +Adjudication verdict (spec §17-A1, ratified here in code): the Router and SmartPlacer +are **complementary, not competitors**. The Router decides *whether* to rest as maker +and how hard to work the order (reprice budget, TTL discipline, give-up rule). The +SmartPlacer decides *where in the book* the maker quote sits — it plugs into the +``pre_submit`` seam (``wants_placement`` below) and is free to return a replacement +plan. Neither subsumes the other; this module is the Router half and knows nothing +about order books, offsets, or venues. + +PURE: no I/O, no clock, no venue, no ambient state. ``decide()`` is a total function of +the request alone. Everything time/price-dependent is expressed as a *rule the drive +loop enforces* (§7), not as a value read here — that is what keeps this layer +parity-invisible and venue-side-portable (C1). + +Provenance: prod/docs/SPEC_UNIFIED_EXEC_LAYER_20260714.md §4, §6, §17-A1. +""" +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from enum import Enum + +from . import _constants as K +from .contract import ExecutionRequest, UrgencyClass + + +class ExecutionMethod(Enum): + MAKER = "MAKER" # rest post-only (GTX); the only certified fill-improvement path (§4-16) + TAKER = "TAKER" # cross the spread now (MARKET / marketable) + + +class TtlDiscipline(Enum): + IMMEDIATE = "immediate" # act now, no resting (CATASTROPHIC) + BOUNDED_MS = "bounded_ms" # rest up to a fixed wall-clock bound, then cross (PROTECT) + DEADLINE = "deadline" # rest until caller's deadline_ms, then cross (ROTATE) + UNBOUNDED = "unbounded" # rest patiently; do NOT cross on expiry (ACQUIRE abandons) + + +@dataclass(frozen=True) +class RoutingDecision: + """What the Router decided — the plan the drive loop executes (§7). + + Carries *rules*, not live values: ``max_ms`` is a bound the loop measures against + its injected clock; the Router itself never reads a clock. + """ + + method: ExecutionMethod + max_reprices: int # chase budget; 0 = place-once (no chase) + ttl: TtlDiscipline + cross_on_expiry: bool # on TTL/reprice exhaustion: cross (True) or abandon (False) + wants_placement: bool # invite SmartPlacer to set the offset (pre_submit seam) + ignore_advice: bool # advice is inadmissible (CATASTROPHIC only, by law) + max_ms: int | None = None # wall-clock bound for BOUNDED_MS; else None + giveup_regression_frac: Decimal | None = None # HARVEST: cross if regression eats this of unrealized + rationale: str = "" + + def __post_init__(self) -> None: + if self.max_reprices < 0: + raise ValueError("max_reprices cannot be negative") + if (self.ttl is TtlDiscipline.BOUNDED_MS) != (self.max_ms is not None): + raise ValueError("max_ms must be set iff ttl is BOUNDED_MS") + + +def decide(request: ExecutionRequest) -> RoutingDecision: + """Map urgency → routing policy. Total, pure, deterministic (spec §6). + + The five promises this enforces (spec §2.3): CATASTROPHIC is never delayed; WHAT is + never changed (this function reads urgency, never mutates side/size/asset); advice is + ignored for CATASTROPHIC by law. + """ + u = request.urgency + + if u is UrgencyClass.CATASTROPHIC: + # Taker MARKET immediately. No cleverness. Ever. Advice ignored by law (§6). + return RoutingDecision( + method=ExecutionMethod.TAKER, + max_reprices=0, + ttl=TtlDiscipline.IMMEDIATE, + cross_on_expiry=True, + wants_placement=False, + ignore_advice=True, + rationale="CATASTROPHIC: cross now, no resting, advice inadmissible", + ) + + if u is UrgencyClass.PROTECT: + # Maker at touch, one reprice, ≤2 s total, then cross (§6). + return RoutingDecision( + method=ExecutionMethod.MAKER, + max_reprices=K.PROTECT_REPRICE_LIMIT, + ttl=TtlDiscipline.BOUNDED_MS, + max_ms=K.PROTECT_MAX_MS, + cross_on_expiry=True, + wants_placement=False, # at touch, not an offset model + ignore_advice=False, + rationale="PROTECT: maker@touch, 1 reprice, cross at 2s", + ) + + if u is UrgencyClass.HARVEST: + # Maker at target ± SmartPlacer offset; chase ≤ N; give up on regression → cross (§6). + return RoutingDecision( + method=ExecutionMethod.MAKER, + max_reprices=K.HARVEST_MAX_CHASES, + ttl=TtlDiscipline.UNBOUNDED, # bounded by chases + regression, not wall-clock + cross_on_expiry=True, # give-up crosses (harvest still wants the fill) + wants_placement=True, + ignore_advice=False, + giveup_regression_frac=K.HARVEST_GIVEUP_REGRESSION_FRAC, + rationale="HARVEST: maker@target±offset, chase<=N, cross on regression give-up", + ) + + if u is UrgencyClass.ROTATE: + # Patient maker; TTL from caller's deadline_ms; cross at TTL (§6). + return RoutingDecision( + method=ExecutionMethod.MAKER, + max_reprices=K.HARVEST_MAX_CHASES, # patient chasing within the deadline + ttl=TtlDiscipline.DEADLINE, + cross_on_expiry=True, + wants_placement=True, + ignore_advice=False, + rationale="ROTATE: patient maker, cross at caller deadline", + ) + + if u is UrgencyClass.ACQUIRE: + # Patient maker inside spread; abandon, never chase (§4-1: a missed entry is free). + return RoutingDecision( + method=ExecutionMethod.MAKER, + max_reprices=K.ACQUIRE_MAX_CHASES, # == 0: no chase + ttl=TtlDiscipline.UNBOUNDED, + cross_on_expiry=False, # ABANDON on expiry — do NOT cross + wants_placement=True, + ignore_advice=False, + rationale="ACQUIRE: patient maker inside spread, abandon (never chase, never cross)", + ) + + raise ValueError(f"unhandled urgency {u!r}") # unreachable given the enum is closed diff --git a/prod/exec_unified/test_exec_unified.py b/prod/exec_unified/test_exec_unified.py new file mode 100644 index 0000000..d2d5e12 --- /dev/null +++ b/prod/exec_unified/test_exec_unified.py @@ -0,0 +1,200 @@ +"""Behaviour + mutation-litmus tests for the Unified Execution Layer core. + +Testing doctrine (prod/docs/TESTING_DOCTRINE.md): these assert the VALUE/SHAPE/INVARIANT, +not "it ran". Each router test is written so that breaking the implementation goes RED — +the mutation litmus is spelled out per test. Run: + + /home/dolphin/siloqy_env/bin/python3 -m pytest prod/exec_unified/test_exec_unified.py -q + +Mutation examples that MUST turn something red: + * CATASTROPHIC method MAKER→... -> test_catastrophic_crosses_now + * ACQUIRE cross_on_expiry False→True -> test_acquire_abandons_never_crosses + * PROTECT max_reprices 1→2 -> test_protect_one_reprice_then_cross + * drop a urgency branch in decide() -> test_decide_total_over_all_urgencies + * ignore_advice True on a non-CATASTROPHIC -> test_only_catastrophic_ignores_advice +""" +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from prod.exec_unified import ( + ExecutionAdvice, + ExecutionMethod, + ExecutionRequest, + ProtectiveSpec, + RoutingDecision, + Side, + TtlDiscipline, + UrgencyClass, + decide, +) +from prod.exec_unified import _constants as K + + +def _req(urgency: UrgencyClass, **kw) -> ExecutionRequest: + base = dict( + request_id="r-1", + asset="BTCUSDT", + side=Side.BUY, + size=Decimal("0.001"), + urgency=urgency, + ) + # ACQUIRE is the only non-reduce_only default that matters; keep entries clean. + if urgency is not UrgencyClass.ACQUIRE: + base["reduce_only"] = True + base.update(kw) + return ExecutionRequest(**base) + + +# ---------------------------------------------------------------- router: the ladder + +def test_catastrophic_crosses_now(): + d = decide(_req(UrgencyClass.CATASTROPHIC)) + assert d.method is ExecutionMethod.TAKER # never maker — this is the SL/kill path + assert d.max_reprices == 0 # no chase, no resting + assert d.ttl is TtlDiscipline.IMMEDIATE + assert d.ignore_advice is True # advice inadmissible by law + assert d.wants_placement is False # no book games on a catastrophe + + +def test_protect_one_reprice_then_cross(): + d = decide(_req(UrgencyClass.PROTECT)) + assert d.method is ExecutionMethod.MAKER + assert d.max_reprices == 1 # EXACTLY one (spec §6) + assert d.ttl is TtlDiscipline.BOUNDED_MS + assert d.max_ms == K.PROTECT_MAX_MS == 2_000 # ≤2 s total + assert d.cross_on_expiry is True # then cross — a protect must complete + + +def test_harvest_chases_then_gives_up_by_crossing(): + d = decide(_req(UrgencyClass.HARVEST)) + assert d.method is ExecutionMethod.MAKER + assert d.max_reprices == K.HARVEST_MAX_CHASES + assert d.wants_placement is True # SmartPlacer sets the target±offset + assert d.giveup_regression_frac == K.HARVEST_GIVEUP_REGRESSION_FRAC + assert d.cross_on_expiry is True # give-up still wants the harvest + + +def test_rotate_uses_caller_deadline(): + d = decide(_req(UrgencyClass.ROTATE, deadline_ms=60_000)) + assert d.method is ExecutionMethod.MAKER + assert d.ttl is TtlDiscipline.DEADLINE + assert d.cross_on_expiry is True + + +def test_acquire_abandons_never_crosses(): + # THE distinctive invariant: a missed entry is free, a chased/crossed entry is not (§4-1). + d = decide(_req(UrgencyClass.ACQUIRE)) + assert d.method is ExecutionMethod.MAKER + assert d.max_reprices == 0 # never chase + assert d.cross_on_expiry is False # never cross — abandon + assert d.ttl is TtlDiscipline.UNBOUNDED + + +def test_only_catastrophic_ignores_advice(): + for u in UrgencyClass: + d = decide(_req(u, deadline_ms=1000)) + assert d.ignore_advice is (u is UrgencyClass.CATASTROPHIC), u + + +def test_decide_total_over_all_urgencies(): + # No urgency may fall through unhandled (drop a branch -> this goes red). + for u in UrgencyClass: + d = decide(_req(u, deadline_ms=1000)) + assert isinstance(d, RoutingDecision) + assert d.rationale # every path explains itself + + +def test_decide_is_pure_and_deterministic(): + r = _req(UrgencyClass.HARVEST) + a, b = decide(r), decide(r) + assert a == b # same input -> same decision + # WHAT is never changed: decide reads urgency, never mutates the request (frozen). + assert r.side is Side.BUY and r.size == Decimal("0.001") and r.asset == "BTCUSDT" + + +def test_maker_urgencies_never_taker(): + for u in (UrgencyClass.PROTECT, UrgencyClass.HARVEST, + UrgencyClass.ROTATE, UrgencyClass.ACQUIRE): + assert decide(_req(u, deadline_ms=1000)).method is ExecutionMethod.MAKER, u + + +# ------------------------------------------------------- RoutingDecision self-guard + +def test_bounded_ms_requires_max_ms(): + with pytest.raises(ValueError): + RoutingDecision( + method=ExecutionMethod.MAKER, max_reprices=1, + ttl=TtlDiscipline.BOUNDED_MS, cross_on_expiry=True, + wants_placement=False, ignore_advice=False, max_ms=None, # contradiction + ) + + +def test_non_bounded_rejects_max_ms(): + with pytest.raises(ValueError): + RoutingDecision( + method=ExecutionMethod.MAKER, max_reprices=1, + ttl=TtlDiscipline.UNBOUNDED, cross_on_expiry=False, + wants_placement=True, ignore_advice=False, max_ms=1000, # contradiction + ) + + +# ---------------------------------------------------------- contract validation + +def test_urgency_exit_classification(): + assert UrgencyClass.ACQUIRE.is_exit is False # entry + for u in (UrgencyClass.CATASTROPHIC, UrgencyClass.PROTECT, + UrgencyClass.HARVEST, UrgencyClass.ROTATE): + assert u.is_exit is True + + +@pytest.mark.parametrize("bad", [ + dict(request_id=""), # missing idempotency key + dict(asset="BTC-USDT"), # dashed (dialect's job, not caller's) + dict(asset="btcusdt"), # lowercase + dict(size=Decimal("0")), # non-positive size + dict(size=Decimal("-1")), + dict(guideline_price=Decimal("0")), # non-positive reference + dict(deadline_ms=0), # non-positive patience +]) +def test_execution_request_rejects_bad_input(bad): + with pytest.raises(ValueError): + _req(UrgencyClass.HARVEST, **bad) + + +def test_acquire_cannot_be_reduce_only(): + with pytest.raises(ValueError): + ExecutionRequest( + request_id="r", asset="BTCUSDT", side=Side.BUY, + size=Decimal("1"), urgency=UrgencyClass.ACQUIRE, reduce_only=True, + ) + + +def test_protective_spec_needs_exactly_one_anchor(): + with pytest.raises(ValueError): + ProtectiveSpec() # neither + with pytest.raises(ValueError): + ProtectiveSpec(stop_price=Decimal("100"), stop_mult=Decimal("2")) # both + ok = ProtectiveSpec(stop_mult=Decimal("2")) + assert ok.working_type == "MARK_PRICE" and ok.reduce_only is True + + +def test_protective_spec_rejects_non_mark_working_type(): + with pytest.raises(ValueError): + ProtectiveSpec(stop_mult=Decimal("2"), working_type="LAST_PRICE") + + +@pytest.mark.parametrize("frac", [Decimal("0"), Decimal("-0.1"), Decimal("1.5")]) +def test_advice_qty_fraction_bounds(frac): + with pytest.raises(ValueError): + ExecutionAdvice(qty_fraction=frac) + + +def test_advice_is_ignorable_metadata_only(): + # Advice cannot change WHAT: it carries no side/size/asset. Structural proof. + adv = ExecutionAdvice(source="MALKHUT", prefer_post_only=True, note="rest deeper") + fields = adv.__dataclass_fields__ + for forbidden in ("side", "size", "asset", "reduce_only"): + assert forbidden not in fields