213 lines
9.8 KiB
Python
213 lines
9.8 KiB
Python
|
|
"""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)
|
|||
|
|
"""
|