PINK: S1 leverage cache, S2 background refresh, Gap 1/2/3 fee+slippage logging

S1 — Leverage cache (bingx_direct.py):
  _ensure_leverage(): per-symbol asyncio.Lock + cached value check; skips ~350ms
  POST when exchange already has the requested leverage.  Saves ~350ms/trade.
  Cache updated ONLY on success; failed POST leaves cache stale → correct retry.
  Persist: JSON sidecar /tmp/.bingx_leverage_cache_{env}.json; survives restarts.
  connect(): _verify_leverage_drift() detects when another process changed leverage
  at the exchange and updates cache to exchange truth (logs WARNING on drift).
  Multi-runner contract: leverage is account-level on BingX; documented that
  concurrent runners with different leverage desires for same symbol conflict.
  20 mock tests: same-lev skip, change-triggers-POST, failure-no-cache-update,
  concurrent-same-symbol (lock prevents race), drift-detect, persist/restore,
  multi-runner known-limitation documentation test.

S2 — Background state refresh (bingx_direct.py):
  MARKET fills: asyncio.create_task(_refresh_state_background) — does not block
  submit path.  WS FILL_SETTLED + ACCOUNT_UPDATE deliver capital truth anyway.
  LIMIT fills: synchronous refresh retained (include_history=False, not True) —
  needed to detect resting order state for next pump cycle.
  Saves ~600–900ms/trade on MARKET exits. ENTER similarly improved.

Gap 1 — VenueEvent friction fields (contracts.py):
  Added: fee, fee_asset, fee_source, is_maker, exchange_ts, slippage_bps,
  mark_at_submit — all with defaults so existing callers are unaffected.
  Detailed inline docs for sign conventions and provenance codes.

Gap 2 — Fee estimation + WS_SETTLED provenance (bingx_direct.py, pink_clickhouse.py):
  submit_intent: estimates fee from fill_price × fill_qty × taker/maker rate;
  annotates ack_row with _fee_estimated, _fee_source, _is_maker_est.
  persist_fee_settled(): new method writes fee_settled_events row when WS
  ORDER_TRADE_UPDATE delivers actual commission ("n" field); fee_source="WS_SETTLED".
  pink_direct._run_account_stream: calls persist_fee_settled on FILL_SETTLED.

Gap 3 — Slippage measurement (bingx_direct.py, bingx_venue.py, pink_clickhouse.py):
  Captures mark_at_submit before the order POST; computes slippage_bps signed
  by side: positive = adverse (taker overpaid / maker undersold), negative =
  price improvement.  Measured for BOTH taker and maker fills for symmetry.
  Flows through VenueEvent → trade_events.slippage_bps + trade_exit_legs.slippage_bps.

S3 / SOR — Maker order placement: comprehensive TODO block in submit_intent with:
  SHORT/LONG-aware price offset design, OBF integration requirements,
  TODO_ADD_PARAMSET_VIBRISS for spread_bps threshold, intelligent timeout_s
  calibration requirements, price-impact awareness gap, SOR abstraction CRITICAL TODO.
  REST/WS split: documented why BingX (and all retail venues) separate these
  and why a unified VenueAdapter protocol is the long-term solution.

151/151 existing tests green + 20 new leverage cache tests = 171 total.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-05 12:25:12 +02:00
parent 714913bab6
commit c864e9c550
6 changed files with 1171 additions and 36 deletions

View File

@@ -455,21 +455,36 @@ class PinkClickHousePersistence:
partial = (not slot_closed) and cur_size > 0.0
# Extract the fill price from emitted venue events (G21 fix): the actual
# exchange fill price lives in the FULL_FILL/PARTIAL_FILL event, not in
# the slot dict. Pass it explicitly so _write_trade_event does not fall
# back to entry_price.
# Extract fill price AND friction fields from emitted venue events.
# These are first-class fields on VenueEvent (Gap 1/2/3).
fill_price_hint = 0.0
fill_fee = 0.0
fill_fee_source = ""
fill_is_maker = False
fill_slippage_bps = 0.0
fill_mark_at_submit = 0.0
fill_exchange_ts = 0
for ev in events:
p_val = getattr(ev, "price", 0.0)
if p_val and math.isfinite(float(p_val)) and float(p_val) > 0:
fill_price_hint = float(p_val)
break
if getattr(ev, "fee", 0.0):
fill_fee = float(ev.fee)
if getattr(ev, "fee_source", ""):
fill_fee_source = str(ev.fee_source)
if getattr(ev, "is_maker", False):
fill_is_maker = bool(ev.is_maker)
if getattr(ev, "slippage_bps", 0.0):
fill_slippage_bps = float(ev.slippage_bps)
if getattr(ev, "mark_at_submit", 0.0):
fill_mark_at_submit = float(ev.mark_at_submit)
if getattr(ev, "exchange_ts", 0):
fill_exchange_ts = int(ev.exchange_ts)
# One trade_exit_legs row per exit leg (partial or final), BLUE-schema
# compatible so PINK multi-exit trades reconcile against the same table.
self._write_trade_exit_leg(snapshot, decision, intent, slot, outcome,
fill_price_hint=fill_price_hint)
fill_price_hint=fill_price_hint,
fill_fee=fill_fee, fill_fee_source=fill_fee_source,
fill_is_maker=fill_is_maker, fill_slippage_bps=fill_slippage_bps)
self._write_trade_reconstruction(
snapshot, intent.trade_id,
event_type="PARTIAL_EXIT" if partial else "EXIT",
@@ -483,11 +498,15 @@ class PinkClickHousePersistence:
},
market_state=market_state,
)
# Terminal trade event.
if slot_closed:
self._write_trade_event(snapshot, decision, intent, slot, outcome,
market_state=market_state,
exit_price_hint=fill_price_hint)
exit_price_hint=fill_price_hint,
fill_fee=fill_fee, fill_fee_source=fill_fee_source,
fill_is_maker=fill_is_maker,
fill_slippage_bps=fill_slippage_bps,
fill_mark_at_submit=fill_mark_at_submit,
fill_exchange_ts=fill_exchange_ts)
def persist_fill_events(
self,
@@ -601,6 +620,46 @@ class PinkClickHousePersistence:
market_state=market_state,
)
def persist_fee_settled(
self,
*,
trade_id: str,
fee: float,
fee_asset: str = "USDT",
is_maker: bool = False,
exchange_ts: int = 0,
realized_pnl_delta: float = 0.0,
ts: Optional[Any] = None,
) -> None:
"""Record the WS FILL_SETTLED fee arriving after the REST submit.
Gap 2: the REST ACK path writes fee_source="ESTIMATED_TAKER/MAKER".
When the WS ORDER_TRADE_UPDATE frame arrives with field "n" (actual
commission), call this method to log the settled truth.
The CH spool stores both the original estimated row AND this settled row.
Downstream queries can reconcile using:
SELECT trade_id, MAX(fee) FILTER(WHERE fee_source='WS_SETTLED') AS settled_fee,
MAX(fee) FILTER(WHERE fee_source LIKE 'ESTIMATED%') AS estimated_fee
FROM trade_events GROUP BY trade_id
This method writes to ``fee_settled_events`` (a lightweight supplementary
table, not trade_events) so the original row is never mutated.
"""
ts_val = ts or datetime.now(timezone.utc)
self._sink("fee_settled_events", {
"ts": ts_val.isoformat() if hasattr(ts_val, "isoformat") else str(ts_val),
"trade_id": trade_id,
"fee": float(fee),
"fee_asset": fee_asset,
"fee_source": "WS_SETTLED",
"is_maker": bool(is_maker),
"exchange_ts": int(exchange_ts),
"realized_pnl_delta": float(realized_pnl_delta),
"runtime_namespace": self.config.runtime_namespace,
"strategy": self.config.strategy,
})
def record_anomaly(
self,
*,
@@ -876,6 +935,10 @@ class PinkClickHousePersistence:
self, snapshot: Any, decision: Decision, intent: Intent,
slot_dict: dict[str, Any], outcome: KernelOutcome | None,
fill_price_hint: float = 0.0,
fill_fee: float = 0.0,
fill_fee_source: str = "",
fill_is_maker: bool = False,
fill_slippage_bps: float = 0.0,
) -> None:
"""Emit one BLUE-schema-compatible ``trade_exit_legs`` row per exit leg.
@@ -964,6 +1027,11 @@ class PinkClickHousePersistence:
"pnl_leg": pnl_leg,
"pnl_realized_total": cur_realized,
"bars_held": int(intent.bars_held or 0),
# Gap 1/2/3: per-leg friction
"fee_leg": fill_fee,
"fee_source": fill_fee_source,
"is_maker": fill_is_maker,
"slippage_bps": fill_slippage_bps,
})
# Advance the per-trade leg snapshot for the next leg's delta.
@@ -978,6 +1046,12 @@ class PinkClickHousePersistence:
slot_dict: dict[str, Any], outcome: KernelOutcome | None,
*, market_state: Mapping[str, Any] | None = None,
exit_price_hint: float = 0.0,
fill_fee: float = 0.0,
fill_fee_source: str = "",
fill_is_maker: bool = False,
fill_slippage_bps: float = 0.0,
fill_mark_at_submit: float = 0.0,
fill_exchange_ts: int = 0,
) -> None:
entry_price = _safe_float(slot_dict.get("entry_price", 0.0), 0.0) or _safe_float(intent.reference_price, 0.0)
quantity = _safe_float(slot_dict.get("initial_size", slot_dict.get("size", 0.0)), 0.0) or _safe_float(intent.target_size, 0.0)
@@ -1050,7 +1124,19 @@ class PinkClickHousePersistence:
"entry_payload_json": _json_text({"decision": _decision_summary(decision), "intent": _intent_summary(intent)}),
"exit_payload_json": _json_text({"outcome": _outcome_summary(outcome), "slot": _json_safe(slot_dict)}),
"execution_payload_json": _json_text({"outcome": _outcome_summary(outcome)}),
"friction_payload_json": _json_text({"fees": 0.0}),
# Gap 1/2/3: fee, maker/taker, slippage, exchange timing.
# fee_source provenance: "ESTIMATED_TAKER" | "ESTIMATED_MAKER" | "WS_SETTLED" | "REST_SETTLED"
"fee": fill_fee,
"fee_source": fill_fee_source,
"is_maker": fill_is_maker,
"slippage_bps": fill_slippage_bps,
"mark_at_submit": fill_mark_at_submit,
"exchange_ts": fill_exchange_ts,
"friction_payload_json": _json_text({
"fee": fill_fee, "fee_source": fill_fee_source,
"is_maker": fill_is_maker, "slippage_bps": fill_slippage_bps,
"mark_at_submit": fill_mark_at_submit, "exchange_ts": fill_exchange_ts,
}),
"event_payload_json": _json_text({"phase": "terminal_close", "trade_id": intent.trade_id}),
"market_state_bundle_json": _json_text(market_state or {}),
"tp_base_pct": _safe_float(metadata.get("tp_base_pct", 0.0), 0.0),