"""Friction telemetry — "before-and-after or it didn't happen" (spec §12). This is the instrument that turns "smart exec is better" from a claim into a number. It computes, per resolved order, the effective friction in basis points (adverse slippage vs the caller's guideline price + realized fee) and the savings vs the naive-MARKET baseline a T0 flight would have paid. Aggregated per urgency class, that IS the §15 rung-by-rung delta and simultaneously MALKHUT's reward signal (§5-3) — one instrument, two consumers. TWO LAWS this module exists to honor, both learned the hard way: * §13 / §4-5 — **telemetry is a lossless SIDE-LANE and is NEVER allowed on the exec path.** (b46ebd2: a telemetry write on the hot path is a latent stall/raise in the order loop.) So ``FrictionJournal.record`` catches everything and CANNOT raise. A dead sink loses a telemetry row; it never touches an order. The pure bps math *can* raise (bad reference price is a programming error) — but only ever inside record()'s guard, off the exec path. * BingX commission SIGN — a NEGATIVE commission is a COST/debit (opposite Binance); a positive one is a rebate. We paid for this: omp read −2.00 bps as a "rebate" when it was a +2.00 bps cost. ``fee_cost_from_commission`` encodes the flip so no caller repeats it. See [[bingx_maker_fee_and_commission_sign]]. Pure stdlib + Decimal. No I/O of its own; the CH sink is injected. """ from __future__ import annotations import logging from dataclasses import dataclass, field from decimal import Decimal from typing import Callable from .contract import Side, UrgencyClass LOGGER = logging.getLogger(__name__) # Measured venue rates (VST, 2026-07-14) — [[bingx_maker_fee_and_commission_sign]]. # Maker = 2.00 bps (refutes the 1 bp savings-table assumption); taker = 5.016 bps over 1,455 fills. MAKER_FEE_BPS = Decimal("2.00") TAKER_FEE_BPS = Decimal("5.016") _BPS = Decimal("10000") def fee_cost_from_commission(commission: Decimal) -> Decimal: """Venue commission → signed COST in quote currency (positive = we paid). BingX reports commission NEGATIVE for a debit (a cost) and positive for a rebate — the opposite of Binance. Cost is therefore ``-commission``: a −0.013 commission is a +0.013 cost; a +0.005 rebate is a −0.005 cost (we earned it). The one-line flip that omp's Q1 got backwards. THE mutation test: change this to ``return commission`` and the sign test goes RED. """ return -commission def adverse_slippage_bps(guideline_px: Decimal, fill_px: Decimal, side: Side) -> Decimal: """Signed slippage of the fill vs the caller's guideline, in bps. POSITIVE = adverse (we paid up on a BUY / sold down on a SELL); NEGATIVE = price improvement. The side flip is the whole point: a higher fill helps a seller but hurts a buyer, so the same raw delta is friction for one side and improvement for the other.""" if guideline_px <= 0: raise ValueError(f"guideline_px must be > 0 to reference slippage, got {guideline_px}") side_sign = Decimal(1) if side is Side.BUY else Decimal(-1) return side_sign * (fill_px - guideline_px) / guideline_px * _BPS def fee_bps(fee_cost: Decimal, notional: Decimal) -> Decimal: """Realized fee as bps of notional. ``fee_cost`` is the SIGNED cost (see fee_cost_from_commission) so a rebate lowers friction. POSITIVE = cost.""" if notional <= 0: raise ValueError(f"notional must be > 0 to reference fee bps, got {notional}") return fee_cost / notional * _BPS def effective_friction_bps(guideline_px: Decimal, fill_px: Decimal, side: Side, fee_cost: Decimal, size: Decimal) -> Decimal: """Total realized friction = adverse slippage + fee, in bps. The single number §12 asks per order. POSITIVE = the order cost us that many bps vs a free fill at guideline.""" notional = fill_px * size return adverse_slippage_bps(guideline_px, fill_px, side) + fee_bps(fee_cost, notional) def naive_baseline_bps(guideline_px: Decimal, touch_px: Decimal, side: Side, taker_fee_bps: Decimal = TAKER_FEE_BPS) -> Decimal: """What a T0 naive-MARKET cross at the touch would have cost, in bps: it crosses the spread (guideline→touch, adverse by construction) and pays the taker fee. This is the per-order baseline the smart fill is measured against (§12; the 2026-07-10 audit is the frozen AGGREGATE baseline, this is its per-order model).""" return adverse_slippage_bps(guideline_px, touch_px, side) + taker_fee_bps def savings_vs_naive_bps(effective_bps: Decimal, baseline_bps: Decimal) -> Decimal: """Smart-exec savings = baseline − effective. POSITIVE = we beat the naive MARKET; the number that makes "$91–231K" a measurement instead of a hope (§4-20).""" return baseline_bps - effective_bps @dataclass(frozen=True) class FrictionRecord: """One resolved order's §12 telemetry row. Every field the journal DDL carries; the derived bps are computed lazily so a malformed row can never poison construction.""" request_id: str asset: str side: Side urgency: UrgencyClass maker: bool # True = filled as maker (post-only rested), False = taker cross guideline_px: Decimal # the caller's reference price at request time placed_px: Decimal # where we actually rested/sent (0 for pure MARKET) fill_px: Decimal # realized average fill touch_px: Decimal # the touch a naive MARKET would have crossed (baseline ref) size: Decimal fee_cost: Decimal # SIGNED quote-currency cost (positive = paid; see fee_cost_from_commission) chases: int = 0 # reprice attempts spent queue_age_s: Decimal = Decimal(0) advisor_id: str = "" # MALKHUT advice provenance ("" = none) triage: str = "" # terminal disposition (filled/crossed/abandoned/…) @property def notional(self) -> Decimal: return self.fill_px * self.size def effective_bps(self) -> Decimal: return effective_friction_bps(self.guideline_px, self.fill_px, self.side, self.fee_cost, self.size) def baseline_bps(self) -> Decimal: return naive_baseline_bps(self.guideline_px, self.touch_px, self.side) def savings_bps(self) -> Decimal: return savings_vs_naive_bps(self.effective_bps(), self.baseline_bps()) FrictionSink = Callable[[FrictionRecord], None] class FrictionJournal: """Side-lane friction telemetry (§4-5). ``record`` is fire-and-forget and CANNOT raise — a failing sink loses the row and logs, never disturbs the order loop (§13, b46ebd2). The sink is injected: in prod it writes the §12 CH rows (``exec_smart_journal``); in tests it's a list. The journal keeps an in-memory copy only when ``retain`` so aggregate queries work without a database.""" def __init__(self, sink: FrictionSink | None = None, *, retain: bool = True, logger: logging.Logger = LOGGER) -> None: self._sink = sink self._retain = retain self.log = logger self._rows: list[FrictionRecord] = [] def record(self, rec: FrictionRecord) -> None: """Emit one row. Swallows ALL exceptions — telemetry never breaks execution.""" if self._retain: self._rows.append(rec) if self._sink is None: return try: self._sink(rec) except Exception as exc: # noqa: BLE001 — side-lane law: never propagate self.log.warning("friction.record: sink failed, row dropped: %r", exc) def rows(self) -> list[FrictionRecord]: return list(self._rows) def friction_by_urgency(self) -> dict[UrgencyClass, Decimal]: """Mean effective friction bps per urgency class — the §15 rung delta. Rows whose bps can't be computed (bad reference px) are skipped, not fatal (side-lane).""" buckets: dict[UrgencyClass, list[Decimal]] = {} for r in self._rows: try: buckets.setdefault(r.urgency, []).append(r.effective_bps()) except Exception: # noqa: BLE001 continue return {u: sum(v) / len(v) for u, v in buckets.items() if v} def savings_by_urgency(self) -> dict[UrgencyClass, Decimal]: """Mean savings vs naive-MARKET per urgency — the before/after claim, per class.""" buckets: dict[UrgencyClass, list[Decimal]] = {} for r in self._rows: try: buckets.setdefault(r.urgency, []).append(r.savings_bps()) except Exception: # noqa: BLE001 continue return {u: sum(v) / len(v) for u, v in buckets.items() if v} # ── DDL — ships WITH the code (§12: the 404-storm lesson — tables that don't exist journal # nothing). When this package syncs to /mnt, register EXEC_SMART_JOURNAL_DDL in the applier # verify-set (prod/clickhouse/uv/apply_uv_ddl.py) so a missing table fails LOUD, not silent. EXEC_SMART_JOURNAL_DDL = """\ CREATE TABLE IF NOT EXISTS {db}.exec_smart_journal ( ts DateTime64(3) DEFAULT now64(3), request_id String, asset String, side Enum8('BUY' = 1, 'SELL' = 2), urgency LowCardinality(String), maker UInt8, guideline_px Decimal(38, 12), placed_px Decimal(38, 12), fill_px Decimal(38, 12), touch_px Decimal(38, 12), size Decimal(38, 12), fee_cost Decimal(38, 12), effective_bps Decimal(18, 6), baseline_bps Decimal(18, 6), savings_bps Decimal(18, 6), chases UInt16, queue_age_s Decimal(18, 6), advisor_id String, triage LowCardinality(String) ) ENGINE = MergeTree ORDER BY (asset, ts) """