- _quantize_to_tick_conservative: BUY→ROUND_FLOOR (never up into ask), SELL→ROUND_CEILING (never down into bid)
- Removed unused _quantize_to_step (size/step quantization at venue-dialect submit, not placer)
- Fixed cross-quantize tests: with conservative rounding, BUY floors down, SELL ceilings up → never crosses
- Added TestQuantizeToTickConservative with 8 tests for side-aware rounding
- Mutation-litmus: spread gate, TAKER gate, quantize-cross all RED on inversion
- 33 placer tests + 30 router + 18 drive_loop = 81 total green
- Drive_loop.py (commit 670b739a) now consumes pre_submit via wants_placement
144 lines
5.5 KiB
Python
144 lines
5.5 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_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) |