PINK: fix fee calibration poisoning + fee_settled trade_id linkage

Two related accounting fixes:

1. _calibrate_fee_model startup guard: before calling calibrate_fee,
   compute raw deviation from the published taker/maker rate (ignoring
   any stale calibration_ratio). If >15%, skip and log WARNING rather
   than letting a bad REST fill set calibration_ratio to ~0.8 and cause
   ESTIMATED fees to understate actuals by 20% for the entire session.

2. fee_settled_events trade_id: BingX WS does not echo back our
   clientOrderId in fill events (field "c" is empty). Was falling back
   to BingX's internal orderId (p-e-mq5.../p-x-mq5...) which can't
   be joined to trade_events. Now reads trade_id from kernel slot 0
   (which retains the trade_id until the next ENTER) so
   fee_settled_events.trade_id = BTCUSDT-T-N. Added venue_order_id
   field to persist_fee_settled for bidirectional reconciliation.

128/128 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-08 20:19:10 +02:00
parent c16b5aaaa4
commit 9e210b5a02
2 changed files with 40 additions and 4 deletions

View File

@@ -624,6 +624,7 @@ class PinkClickHousePersistence:
self, self,
*, *,
trade_id: str, trade_id: str,
venue_order_id: str = "",
fee: float, fee: float,
fee_asset: str = "USDT", fee_asset: str = "USDT",
is_maker: bool = False, is_maker: bool = False,
@@ -637,6 +638,9 @@ class PinkClickHousePersistence:
When the WS ORDER_TRADE_UPDATE frame arrives with field "n" (actual When the WS ORDER_TRADE_UPDATE frame arrives with field "n" (actual
commission), call this method to log the settled truth. commission), call this method to log the settled truth.
trade_id should be our BTCUSDT-T-N format (from kernel slot).
venue_order_id is BingX's own orderId for bidirectional lookup.
The CH spool stores both the original estimated row AND this settled row. The CH spool stores both the original estimated row AND this settled row.
Downstream queries can reconcile using: Downstream queries can reconcile using:
SELECT trade_id, MAX(fee) FILTER(WHERE fee_source='WS_SETTLED') AS settled_fee, SELECT trade_id, MAX(fee) FILTER(WHERE fee_source='WS_SETTLED') AS settled_fee,
@@ -650,6 +654,7 @@ class PinkClickHousePersistence:
self._sink("fee_settled_events", { self._sink("fee_settled_events", {
"ts": ts_val.isoformat() if hasattr(ts_val, "isoformat") else str(ts_val), "ts": ts_val.isoformat() if hasattr(ts_val, "isoformat") else str(ts_val),
"trade_id": trade_id, "trade_id": trade_id,
"venue_order_id": venue_order_id,
"fee": float(fee), "fee": float(fee),
"fee_asset": fee_asset, "fee_asset": fee_asset,
"fee_source": "WS_SETTLED", "fee_source": "WS_SETTLED",

View File

@@ -473,6 +473,23 @@ class PinkDirectRuntime:
return return
order_type = str(row.get("orderType") or row.get("type") or "MARKET").upper() order_type = str(row.get("orderType") or row.get("type") or "MARKET").upper()
is_maker = order_type == "LIMIT" is_maker = order_type == "LIMIT"
# Guard: check raw deviation (ignoring any stale calibration_ratio) before
# mutating kernel state. A REST fill with >15% deviation from published rate
# poisons calibration_ratio and causes ESTIMATED fees to drift from actuals.
raw_rate = (
self._BINGX_FEE_CONFIG.get("maker_rate", 0.0002) if is_maker
else self._BINGX_FEE_CONFIG.get("taker_rate", 0.0005)
)
raw_expected = fill_price * fill_qty * raw_rate
raw_deviation_pct = abs(actual_fee - raw_expected) / raw_expected * 100 if raw_expected > 0 else 0.0
if raw_deviation_pct > 15.0:
self.logger.warning(
"Fee calibration SKIPPED: REST fill shows %.2f%% deviation from "
"published %.4f%% rate (expected=%.6f actual=%.6f). "
"Holding calibration_ratio=1.0 to avoid poisoning kernel fee model.",
raw_deviation_pct, raw_rate * 100, raw_expected, actual_fee,
)
return
report = self.kernel.calibrate_fee(fill_price, fill_qty, actual_fee, is_maker=is_maker) report = self.kernel.calibrate_fee(fill_price, fill_qty, actual_fee, is_maker=is_maker)
status = report.get("calibration_status", "?") status = report.get("calibration_status", "?")
log = self.logger.error if status == "ERROR" else self.logger.info log = self.logger.error if status == "ERROR" else self.logger.info
@@ -536,11 +553,25 @@ class PinkDirectRuntime:
# downstream can reconcile against the ESTIMATED_TAKER row. # downstream can reconcile against the ESTIMATED_TAKER row.
if self.persistence is not None: if self.persistence is not None:
try: try:
# trade_id: best-effort from the client_order_id field ("c") # BingX WS does not echo back our clientOrderId ("c" field
# or order_id ("i") — WS may not carry our trade_id directly. # is empty). Read trade_id from the kernel slot instead —
ws_trade_id = str(event.client_order_id or event.order_id or "") # the slot retains its trade_id until the next ENTER.
# Store BingX's own orderId as venue_order_id for
# bidirectional reconciliation.
_venue_order_id = str(event.order_id or "")
try:
_slot_dict = self.kernel.slot(0).to_dict() if self.kernel.max_slots > 0 else {}
_our_trade_id = str(_slot_dict.get("trade_id") or "")
except Exception:
_our_trade_id = ""
# Fall back: if clientOrderId was echoed (future BingX change)
# parse our trade_id prefix from "BTCUSDT-T-N:intent_id"
if not _our_trade_id:
_c = str(event.client_order_id or "")
_our_trade_id = _c.split(":")[0] if ":" in _c else (_c or _venue_order_id)
self.persistence.persist_fee_settled( self.persistence.persist_fee_settled(
trade_id=ws_trade_id, trade_id=_our_trade_id,
venue_order_id=_venue_order_id,
fee=event.fee, fee=event.fee,
fee_asset=event.fee_asset or "USDT", fee_asset=event.fee_asset or "USDT",
is_maker=event.is_maker, is_maker=event.is_maker,