Files
sentiment-engine/prod/exec_unified/friction.py
Codex ec95bd6663 exec(unified): sync local build — kernel_port + dialect §11 + friction §12 + size threading
Lands the /root/dev local increments (share was ENOSPC; now writable) into the canonical repo:
- kernel_port.py: real ExecPort over the DITAv2 kernel (duck-typed; injected to_kernel_intent).
- dialect.py §11: BingX boundary — clientOrderId(H4)/dash/quantize/payload (PostOnly=timeInForce).
- friction.py §12: effective-bps + naive-baseline savings; side-lane journal that can't raise
  (b46ebd2); BingX commission-sign flip. DDL ships with code (register in applier verify-set).
- drive_loop/executor/working: Decimal size threaded through plan/working types (exit size cap).
All pure stdlib+Decimal, mutation-litmus RED on the two load-bearing asserts. 132 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:05:26 +02:00

213 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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