Files
sentiment-engine/prod/exec_unified/placer.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

128 lines
4.7 KiB
Python

"""SmartPlacer — the where-in-book seam (spec §4-12, §4-15, §4-16).
Pure stdlib + Decimal. Zero I/O, zero venue knowledge. The Router's
`wants_placement=True` is the ONLY entry point into this module (spec §3, §17-A1).
Provenance: prod/docs/SPEC_UNIFIED_EXEC_LAYER_20260714.md §4-12, §4-15, §4-16.
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_EVEN
@dataclass(frozen=True)
class MarketSnapshot:
"""Live top-of-book snapshot — the ONLY market data this layer reads (spec §4-12).
Spread/depth gates MUST be fed live spread_bps. The old `_spread_allows_maker`
was dead code because it never received spread_bps; do not repeat that.
"""
best_bid: Decimal
best_ask: Decimal
spread_bps: Decimal
tick: Decimal
step: Decimal
def __post_init__(self) -> None:
if self.best_bid <= 0:
raise ValueError(f"best_bid must be > 0, got {self.best_bid}")
if self.best_ask <= 0:
raise ValueError(f"best_ask must be > 0, got {self.best_ask}")
if self.best_ask <= self.best_bid:
raise ValueError(f"best_ask ({self.best_ask}) must be > best_bid ({self.best_bid})")
if self.spread_bps < 0:
raise ValueError(f"spread_bps must be >= 0, got {self.spread_bps}")
if self.tick <= 0:
raise ValueError(f"tick must be > 0, got {self.tick}")
if self.step <= 0:
raise ValueError(f"step must be > 0, got {self.step}")
@property
def mid_price(self) -> Decimal:
return (self.best_bid + self.best_ask) / Decimal("2")
@property
def spread(self) -> Decimal:
return self.best_ask - self.best_bid
@dataclass(frozen=True)
class PlacementPlan:
"""Replacement plan for the order — quantized to venue tick (spec §4-15)."""
limit_price: Decimal
post_only: bool = True
def __post_init__(self) -> None:
if self.limit_price <= 0:
raise ValueError(f"limit_price must be > 0, got {self.limit_price}")
# Spread gate threshold — provisional, calibrated from L8/L10 characterization
# §4-12: "Spread/depth gates must be parameterized per venue"
MAX_SPREAD_BPS_FOR_MAKER: Decimal = Decimal("8.0") # PROVISIONAL — calibrate vs L8 fill_sim
def _quantize_to_tick(price: Decimal, tick: Decimal) -> Decimal:
"""Quantize price to venue tick size using banker's rounding (spec §4-15)."""
if tick <= 0:
raise ValueError(f"tick must be > 0, got {tick}")
# price / tick -> round -> * tick
ratio = price / tick
quantized_ratio = ratio.quantize(Decimal("1"), rounding=ROUND_HALF_EVEN)
return quantized_ratio * tick
def _quantize_to_step(qty: Decimal, step: Decimal) -> Decimal:
"""Quantize quantity to venue step size."""
if step <= 0:
raise ValueError(f"step must be > 0, got {step}")
ratio = qty / step
quantized_ratio = ratio.quantize(Decimal("1"), rounding=ROUND_HALF_EVEN)
return quantized_ratio * step
def pre_submit(
request: "ExecutionRequest",
decision: "RoutingDecision",
snapshot: MarketSnapshot,
) -> PlacementPlan | None:
"""
SmartPlacer pre_submit seam — invited ONLY when router.wants_placement is True.
Returns None when:
- decision.method is TAKER (spec §4: CATASTROPHIC crosses immediately)
- spread gate fails: live spread_bps > MAX_SPREAD_BPS_FOR_MAKER
(the old _spread_allows_maker never got spread_bps — wire it live, spec §4-12)
Otherwise returns a PlacementPlan with limit_price quantized to tick (spec §4-15).
"""
# Import here to avoid circular dependency
from .router import ExecutionMethod
# TAKER method → no placement (CATASTROPHIC, or any future TAKER decision)
if decision.method is ExecutionMethod.TAKER:
return None
# Spread gate — MUST be fed live spread_bps (§4-12)
if snapshot.spread_bps > MAX_SPREAD_BPS_FOR_MAKER:
return None
# Inside-spread placement for MAKER urgencies (PROTECT, HARVEST, ROTATE, ACQUIRE)
# BUY: place at best_bid (touch) — pays maker fee, captures spread if filled
# SELL: place at best_ask (touch) — pays maker fee, captures spread if filled
if request.side.name == "BUY":
limit_price = snapshot.best_bid
else: # SELL
limit_price = snapshot.best_ask
# Quantize to tick BEFORE returning (spec §4-15)
limit_price = _quantize_to_tick(limit_price, snapshot.tick)
# Validate quantized price is still valid (tick quantization could push it across)
if request.side.name == "BUY" and limit_price >= snapshot.best_ask:
return None
if request.side.name == "SELL" and limit_price <= snapshot.best_bid:
return None
return PlacementPlan(limit_price=limit_price, post_only=True)