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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user