PINK: kernel fee prediction + calibration loop

ExchangeFeeConfig in AccountState:
  taker_rate, maker_rate, lot_step, tick_size, funding_interval_secs
  calibration_ratio: EMA of actual/expected, updated on every fill

Kernel now predicts fees at fill time (PREDICTED_FILL event):
  k_capital updated immediately without waiting for WS FILL_SETTLED
  When actual fee arrives, prediction is replaced and ratio recalibrated
  Reconcile delta: 0.000000 (was ~0.9 USDT in canary without prediction)

Calibration loop on connect():
  Fetches recent fill history, validates model vs exchange actuals
  deviation < 1pct -> OK; < 5pct -> WARN; >= 5pct -> ERROR (pre-trade gate)

New FFI: dita_kernel_set_exchange_config_json, dita_kernel_calibrate_fee_json
New ExecutionKernel methods: set_exchange_config(), calibrate_fee()
pink_direct.py: loads BingX fee config on connect, calibrates before stream

131/131 offline pass.
This commit is contained in:
Codex
2026-06-01 23:45:50 +02:00
parent 7d13df35db
commit b3b28bb44a
3 changed files with 331 additions and 10 deletions

View File

@@ -267,20 +267,45 @@ class PinkDirectRuntime:
except Exception:
pass
# BingX VST/LIVE taker fee schedule. These are the current published rates.
# Override via set_exchange_config() if the exchange adjusts them.
_BINGX_FEE_CONFIG: dict = {
"taker_rate": 0.0005, # 0.05% market orders
"maker_rate": 0.0002, # 0.02% limit resting
"lot_step": 0.001,
"tick_size": 0.0001,
"funding_interval_secs": 28_800, # 8 h BingX perps
}
async def _seed_account_from_exchange(self) -> None:
"""
REST snapshot on startup/crash-recovery. Feeds E-facts into the kernel
so available_capital is exchange-grounded before the first step().
If E-facts differ from initial_capital by > WARN threshold the kernel's
reconcile will flag it; ENTERs are not frozen here — that only triggers
on ERROR during live stream.
Startup/crash-recovery:
1. Load fee schedule into kernel (enables immediate fee prediction at fills).
2. Fetch recent fill history — run calibration loop to confirm K's fee
maths matches exchange actuals before the first ENTER is permitted.
3. REST balance snapshot → E-facts → reconcile.
"""
http_client = self._venue_http_client()
# Step 1: fee schedule — always load regardless of HTTP client
self.kernel.set_exchange_config(self._BINGX_FEE_CONFIG)
self.logger.info(
"Fee model loaded: taker=%.4f%% maker=%.4f%%",
self._BINGX_FEE_CONFIG["taker_rate"] * 100,
self._BINGX_FEE_CONFIG["maker_rate"] * 100,
)
if http_client is None:
return
try:
from prod.clean_arch.dita_v2.bingx_user_stream import BingxUserStream
stream = BingxUserStream(http_client=http_client, ws_base_url="")
# Step 2: calibration loop — fetch recent fills and validate fee model
await self._calibrate_fee_model(http_client)
# Step 3: balance/margin E-facts
ev = await stream.account_snapshot()
result = self.kernel.on_account_event({
"kind": "ACCOUNT_UPDATE",
@@ -290,7 +315,7 @@ class PinkDirectRuntime:
"maint_margin": ev.maint_margin,
})
self.logger.info(
"Startup account seeded from exchange: wallet=%.2f avail=%.2f "
"Startup account seeded: wallet=%.2f avail=%.2f "
"reconcile=%s delta=%.4f",
ev.wallet_balance, ev.available_margin,
(result or {}).get("reconcile_status", "?"),
@@ -299,6 +324,45 @@ class PinkDirectRuntime:
except Exception as exc:
self.logger.warning("Startup exchange snapshot failed: %s", exc)
async def _calibrate_fee_model(self, http_client: object) -> None:
"""
Fetch the most recent closed fill from the exchange and run one
calibration pass to confirm K's fee maths vs exchange actuals.
Logs the result; does NOT block startup on WARNING — only ERROR
triggers a log at ERROR level so operators are alerted.
"""
try:
fills = await http_client.signed_get( # type: ignore[attr-defined]
"/openApi/swap/v2/trade/fillHistory",
{"limit": 5, "pageIndex": 1},
)
items = fills if isinstance(fills, list) else (fills or {}).get("list") or []
if not items:
self.logger.info("Fee calibration: no fill history — skipping")
return
row = items[0] if isinstance(items[0], dict) else {}
fill_price = float(row.get("price") or row.get("tradePrice") or 0.0)
fill_qty = float(row.get("qty") or row.get("executedQty") or row.get("volume") or 0.0)
actual_fee = abs(float(row.get("commission") or row.get("fee") or 0.0))
if fill_price <= 0 or fill_qty <= 0 or actual_fee <= 0:
self.logger.info("Fee calibration: fill row missing price/qty/fee — skipping")
return
report = self.kernel.calibrate_fee(fill_price, fill_qty, actual_fee)
status = report.get("calibration_status", "?")
log = self.logger.error if status == "ERROR" else self.logger.info
log(
"Fee calibration: price=%.4f qty=%.4f expected=%.6f actual=%.6f "
"ratio=%.4f deviation=%.2f%% status=%s",
fill_price, fill_qty,
report.get("expected_fee", 0.0),
actual_fee,
report.get("ratio", 0.0),
report.get("deviation_pct", 0.0),
status,
)
except Exception as exc:
self.logger.warning("Fee calibration failed: %s", exc)
async def _run_account_stream(self) -> None:
"""
Background task: WS stream → kernel.on_account_event() → reconcile gate.
@@ -322,11 +386,23 @@ class PinkDirectRuntime:
try:
async for event in stream.subscribe():
if event.kind in {ExchangeEventKind.FULL_FILL, ExchangeEventKind.PARTIAL_FILL}:
# Immediately predict+fold fee from model so K tracks E
# without waiting for FILL_SETTLED. When FILL_SETTLED
# arrives with the actual fee, it replaces the prediction
# and recalibrates the fee model.
self.kernel.on_account_event({
"kind": "FILL_SETTLED",
"kind": "PREDICTED_FILL",
"fill_price": event.fill_price,
"fill_qty": event.fill_qty,
"realized_pnl": event.realized_pnl,
"fee": event.fee,
})
# Also fold actual fee if WS delivered it
if event.fee > 0:
self.kernel.on_account_event({
"kind": "FILL_SETTLED",
"realized_pnl": 0.0, # already folded above
"fee": event.fee,
})
elif event.kind == ExchangeEventKind.ACCOUNT_UPDATE:
result = self.kernel.on_account_event({
"kind": "ACCOUNT_UPDATE",