Files
sentiment-engine/prod/exec_unified/router.py
Codex d2e6d78abd exec_unified: add placer.py (SmartPlacer pre_submit seam) + mutation-litmus tests
- MarketSnapshot: best_bid, best_ask, spread_bps, tick, step (Decimal, frozen, validated)
- PlacementPlan: limit_price (quantized to tick), post_only=True
- pre_submit: returns None for TAKER, spread gate failure, or quantize-cross; otherwise PlacementPlan
- Spread gate uses live spread_bps (fixes dead _spread_allows_maker)
- Quantize to tick BEFORE returning (ROUND_HALF_EVEN per §4-15)
- Cross-after-quantize guard rejects if tick quantization crosses book
- 33 mutation-litmus tests: spread-gate flip, TAKER gate, cross-after-quantize all go RED
- Router.PROTECT wants_placement=True (at touch)
- Pure stdlib+Decimal, zero I/O/venue
2026-07-14 23:14:07 +02:00

137 lines
6.0 KiB
Python

"""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=True, # at touch 2014 SmartPlacer places at best_bid/best_ask
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