Files
sentiment-engine/prod/exec_unified/friction.py

213 lines
9.8 KiB
Python
Raw Normal View History

"""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)
"""