diff --git a/prod/clean_arch/persistence/pink_clickhouse.py b/prod/clean_arch/persistence/pink_clickhouse.py index 102fe88..33ea2da 100644 --- a/prod/clean_arch/persistence/pink_clickhouse.py +++ b/prod/clean_arch/persistence/pink_clickhouse.py @@ -624,6 +624,7 @@ class PinkClickHousePersistence: self, *, trade_id: str, + venue_order_id: str = "", fee: float, fee_asset: str = "USDT", is_maker: bool = False, @@ -637,6 +638,9 @@ class PinkClickHousePersistence: When the WS ORDER_TRADE_UPDATE frame arrives with field "n" (actual 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. Downstream queries can reconcile using: 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", { "ts": ts_val.isoformat() if hasattr(ts_val, "isoformat") else str(ts_val), "trade_id": trade_id, + "venue_order_id": venue_order_id, "fee": float(fee), "fee_asset": fee_asset, "fee_source": "WS_SETTLED", diff --git a/prod/clean_arch/runtime/pink_direct.py b/prod/clean_arch/runtime/pink_direct.py index ce335aa..f3cd103 100644 --- a/prod/clean_arch/runtime/pink_direct.py +++ b/prod/clean_arch/runtime/pink_direct.py @@ -473,6 +473,23 @@ class PinkDirectRuntime: return order_type = str(row.get("orderType") or row.get("type") or "MARKET").upper() 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) status = report.get("calibration_status", "?") 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. if self.persistence is not None: try: - # trade_id: best-effort from the client_order_id field ("c") - # or order_id ("i") — WS may not carry our trade_id directly. - ws_trade_id = str(event.client_order_id or event.order_id or "") + # BingX WS does not echo back our clientOrderId ("c" field + # is empty). Read trade_id from the kernel slot instead — + # 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( - trade_id=ws_trade_id, + trade_id=_our_trade_id, + venue_order_id=_venue_order_id, fee=event.fee, fee_asset=event.fee_asset or "USDT", is_maker=event.is_maker,