"""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_FLOOR, ROUND_CEILING @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_conservative(price: Decimal, tick: Decimal, side: str) -> Decimal: """ Quantize price to venue tick size CONSERVATIVELY by side (spec §4-15). BUY (post-only bid): ROUND_FLOOR — never round UP into the ask. SELL (post-only ask): ROUND_CEILING — never round DOWN into the bid. This keeps the quote resting-maker and lifts fill rate. ROUND_HALF_EVEN can push a MAKER quote across the touch and cause needless None returns. """ if tick <= 0: raise ValueError(f"tick must be > 0, got {tick}") if side not in ("BUY", "SELL"): raise ValueError(f"side must be BUY or SELL, got {side}") ratio = price / tick if side == "BUY": # Floor: round down toward zero (never up into ask) quantized_ratio = ratio.quantize(Decimal("1"), rounding=ROUND_FLOOR) else: # SELL # Ceiling: round up away from zero (never down into bid) quantized_ratio = ratio.quantize(Decimal("1"), rounding=ROUND_CEILING) return quantized_ratio * tick # Size/step quantization happens at the venue-dialect submit (prod/bingx/execution.py), # not here. Placer only sets the limit price. _quantize_to_step is NOT used. # def _quantize_to_step(qty: Decimal, step: Decimal) -> Decimal: # """Quantize quantity to venue step size — NOT called here.""" # ... 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 CONSERVATIVELY by side BEFORE returning (spec §4-15) # BUY → ROUND_FLOOR (never up into ask) # SELL → ROUND_CEILING (never down into bid) limit_price = _quantize_to_tick_conservative(limit_price, snapshot.tick, request.side.name) # 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)