2026-07-15 19:32:46 +02:00
|
|
|
|
"""
|
2026-07-17 10:16:55 +02:00
|
|
|
|
Slippage Calibration — Flight7-anchored, per-asset, per-run overridable.
|
|
|
|
|
|
|
|
|
|
|
|
Fable's Flight7 calibration (2026-07-16):
|
|
|
|
|
|
|
|
|
|
|
|
TESTNET (PRODGREEN, measured 3481 MARKET/taker fills on BingX-VST):
|
|
|
|
|
|
Majors: BTC ~0.1 bps, ETH ~0.4 bps
|
|
|
|
|
|
Liquid alts: TRX 2.9, LINK 4.3, ATOM 5.1, LTC 5.7, XLM 6.5 bps
|
|
|
|
|
|
Illiquid alts: DASH 14.3, FET 15.0, NEO 18.8, ETC 24.6 bps
|
|
|
|
|
|
Size impact: $2-10K ~2.7 bps; >$10K ~14-19 bps
|
|
|
|
|
|
Taker fee: 5.02 bps. Maker fee: 2.0 bps.
|
|
|
|
|
|
VST matches near mid → UNDERSTATES true book impact (optimistic floor).
|
|
|
|
|
|
|
|
|
|
|
|
MAINNET (prospective, live book-walk):
|
|
|
|
|
|
BTC/ETH: ~0-2 bps (≈ testnet)
|
|
|
|
|
|
Liquid alts: ~10-30 bps (BingX 3-10x thinner than Binance)
|
|
|
|
|
|
Thin alts: ~140 bps round-trip
|
|
|
|
|
|
Notional-weighted: ~45 bps @ $30K, ~34 bps @ $4K per side
|
|
|
|
|
|
|
|
|
|
|
|
KEY INSIGHT: For alts on thin books, the fill walks the ENTIRE book in 1-2 levels.
|
|
|
|
|
|
The model should use intercept-dominant (adverse selection) not alpha*levels.
|
|
|
|
|
|
|
|
|
|
|
|
Per-asset configurable: each asset has its own SlippageCalibration.
|
|
|
|
|
|
Per-run overridable: ScenarioFactory can override per-asset models.
|
2026-07-15 19:32:46 +02:00
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
|
|
import math
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class SlippageCalibration:
|
|
|
|
|
|
"""Per-asset calibrated slippage model.
|
|
|
|
|
|
|
2026-07-17 10:16:55 +02:00
|
|
|
|
Two-mode model:
|
|
|
|
|
|
1. DEEP BOOK (majors): slippage = alpha * levels + beta * depth_ratio
|
|
|
|
|
|
Fill walks levels → slippage proportional to levels consumed.
|
|
|
|
|
|
2. THIN BOOK (alts): slippage = intercept + adverse_selection_bps
|
|
|
|
|
|
Fill walks entire book in 1-2 levels → intercept-dominant.
|
|
|
|
|
|
|
|
|
|
|
|
Switch: if book_depth_usd < thin_book_threshold, use thin-book mode.
|
2026-07-15 19:32:46 +02:00
|
|
|
|
"""
|
2026-07-17 10:16:55 +02:00
|
|
|
|
# Deep-book parameters (majors with deep books)
|
|
|
|
|
|
alpha: float = 0.05 # bps per level consumed
|
|
|
|
|
|
beta: float = 0.3 # bps per unit of order/book depth ratio
|
|
|
|
|
|
|
|
|
|
|
|
# Thin-book parameters (alts with shallow books)
|
|
|
|
|
|
intercept: float = 0.0 # base slippage (bps) — dominant for thin books
|
|
|
|
|
|
adverse_selection_bps: float = 0.0 # additional adverse selection cost
|
|
|
|
|
|
|
|
|
|
|
|
# Model switching
|
|
|
|
|
|
thin_book_threshold_usd: float = 50000.0 # below this book depth → thin mode
|
|
|
|
|
|
|
|
|
|
|
|
# Metadata
|
|
|
|
|
|
n_samples: int = 0
|
|
|
|
|
|
r_squared: float = 0.0
|
|
|
|
|
|
testnet_to_mainnet: float = 1.0 # multiplier for mainnet
|
2026-07-15 19:32:46 +02:00
|
|
|
|
|
|
|
|
|
|
def expected_slippage_bps(
|
|
|
|
|
|
self,
|
|
|
|
|
|
levels_consumed: int,
|
|
|
|
|
|
order_usd: float = 0.0,
|
|
|
|
|
|
book_depth_usd: float = 1.0,
|
2026-07-17 10:16:55 +02:00
|
|
|
|
is_mainnet: bool = False,
|
2026-07-17 16:18:43 +02:00
|
|
|
|
trade_flow_intensity: float = 0.0,
|
2026-07-17 10:16:55 +02:00
|
|
|
|
) -> float:
|
2026-07-17 16:18:43 +02:00
|
|
|
|
"""Predict slippage. Switches model based on book depth.
|
|
|
|
|
|
|
|
|
|
|
|
Generalizable features (Fable, Flight9/BLUE):
|
|
|
|
|
|
1. Fill = queue position × trade-flow intensity (not just book snapshot)
|
|
|
|
|
|
2. Depth-for-size > spread (spread lies — unfillable behind $977)
|
|
|
|
|
|
3. Markout = quality (post-fill adverse selection)
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Flow intensity boost: more trade arrivals → higher fill probability
|
|
|
|
|
|
flow_boost = 1.0 + trade_flow_intensity * 0.1
|
|
|
|
|
|
|
2026-07-17 10:16:55 +02:00
|
|
|
|
if book_depth_usd < self.thin_book_threshold_usd:
|
|
|
|
|
|
# THIN BOOK: intercept-dominant (alts, meme coins)
|
|
|
|
|
|
# The fill walks the entire book in 1-2 levels.
|
|
|
|
|
|
# Real cost = base intercept + adverse selection.
|
|
|
|
|
|
depth_ratio = order_usd / max(book_depth_usd, 1.0)
|
|
|
|
|
|
base = self.intercept + self.adverse_selection_bps * min(depth_ratio, 5.0)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# DEEP BOOK: alpha*levels model (majors, large-cap alts)
|
|
|
|
|
|
depth_ratio = order_usd / max(book_depth_usd, 1.0)
|
|
|
|
|
|
base = self.alpha * levels_consumed + self.beta * depth_ratio
|
|
|
|
|
|
|
2026-07-17 16:18:43 +02:00
|
|
|
|
# Adjust for flow intensity: more flow = better fills (lower slippage)
|
|
|
|
|
|
base /= max(flow_boost, 0.5)
|
|
|
|
|
|
|
2026-07-17 10:16:55 +02:00
|
|
|
|
if is_mainnet:
|
|
|
|
|
|
base *= self.testnet_to_mainnet
|
|
|
|
|
|
return base
|
|
|
|
|
|
|
|
|
|
|
|
def expected_slippage_per_level(
|
|
|
|
|
|
self,
|
|
|
|
|
|
order_usd: float,
|
|
|
|
|
|
book_depth_usd: float,
|
2026-07-15 19:32:46 +02:00
|
|
|
|
) -> float:
|
2026-07-17 10:16:55 +02:00
|
|
|
|
"""Expected slippage per level consumed (for CWM)."""
|
|
|
|
|
|
if book_depth_usd < self.thin_book_threshold_usd:
|
|
|
|
|
|
# Thin book: per-level is dominated by intercept
|
|
|
|
|
|
return self.intercept / max(1, int(book_depth_usd / max(order_usd, 1.0)))
|
|
|
|
|
|
return self.alpha
|
2026-07-15 19:32:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 10:16:55 +02:00
|
|
|
|
class SlippageRegistry:
|
|
|
|
|
|
"""Per-asset slippage registry with per-run override support."""
|
2026-07-15 19:32:46 +02:00
|
|
|
|
|
2026-07-17 10:16:55 +02:00
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
|
self._models: Dict[str, SlippageCalibration] = _FLIGHT7_ANCHORS.copy()
|
|
|
|
|
|
self._overrides: Dict[str, SlippageCalibration] = {}
|
|
|
|
|
|
|
|
|
|
|
|
def get(self, symbol: str) -> SlippageCalibration:
|
|
|
|
|
|
"""Get slippage model, with per-run override taking priority."""
|
|
|
|
|
|
return self._overrides.get(symbol, self._models.get(symbol, SlippageCalibration(intercept=5.0, adverse_selection_bps=3.0)))
|
|
|
|
|
|
|
|
|
|
|
|
def override(self, symbol: str, model: SlippageCalibration) -> None:
|
|
|
|
|
|
"""Set per-run override for a symbol."""
|
|
|
|
|
|
self._overrides[symbol] = model
|
|
|
|
|
|
|
|
|
|
|
|
def override_all(self, models: Dict[str, SlippageCalibration]) -> None:
|
|
|
|
|
|
"""Set per-run overrides for all symbols."""
|
|
|
|
|
|
self._overrides.update(models)
|
|
|
|
|
|
|
|
|
|
|
|
def reset_overrides(self) -> None:
|
|
|
|
|
|
"""Clear all per-run overrides."""
|
|
|
|
|
|
self._overrides.clear()
|
2026-07-15 19:32:46 +02:00
|
|
|
|
|
2026-07-17 10:16:55 +02:00
|
|
|
|
def expected_slippage_bps(
|
2026-07-15 19:32:46 +02:00
|
|
|
|
self,
|
2026-07-17 10:16:55 +02:00
|
|
|
|
symbol: str,
|
2026-07-15 19:32:46 +02:00
|
|
|
|
levels_consumed: int,
|
2026-07-17 10:16:55 +02:00
|
|
|
|
order_usd: float = 0.0,
|
|
|
|
|
|
book_depth_usd: float = 1.0,
|
|
|
|
|
|
is_mainnet: bool = False,
|
2026-07-17 16:18:43 +02:00
|
|
|
|
trade_flow_intensity: float = 0.0,
|
2026-07-17 10:16:55 +02:00
|
|
|
|
) -> float:
|
|
|
|
|
|
"""Predict slippage using the appropriate model."""
|
|
|
|
|
|
model = self.get(symbol)
|
2026-07-17 16:18:43 +02:00
|
|
|
|
return model.expected_slippage_bps(levels_consumed, order_usd, book_depth_usd, is_mainnet, trade_flow_intensity)
|
2026-07-17 10:16:55 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
# Flight7 Calibration Anchors
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
#
|
|
|
|
|
|
# Testnet (PRODGREEN, measured 3481 MARKET/taker fills on BingX-VST):
|
|
|
|
|
|
# Majors: BTC ~0.1 bps, ETH ~0.4 bps
|
|
|
|
|
|
# Liquid alts: TRX 2.9, LINK 4.3, ATOM 5.1, LTC 5.7, XLM 6.5 bps
|
|
|
|
|
|
# Illiquid alts: DASH 14.3, FET 15.0, NEO 18.8, ETC 24.6 bps
|
|
|
|
|
|
# Size impact: $2-10K ~2.7 bps; >$10K ~14-19 bps
|
|
|
|
|
|
# Taker fee: 5.02 bps. Maker fee: 2.0 bps.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Mainnet (prospective, live book-walk):
|
|
|
|
|
|
# BTC/ETH: ~0-2 bps (≈ testnet)
|
|
|
|
|
|
# Liquid alts: ~10-30 bps (BingX 3-10x thinner than Binance)
|
|
|
|
|
|
# Thin alts: ~140 bps round-trip
|
|
|
|
|
|
# Notional-weighted: ~45 bps @ $30K, ~34 bps @ $4K per side
|
|
|
|
|
|
#
|
|
|
|
|
|
# KEY: For thin-book assets, fill walks entire book in 1-2 levels.
|
|
|
|
|
|
# Model uses intercept-dominant (adverse selection), not alpha*levels.
|
|
|
|
|
|
|
|
|
|
|
|
_FLIGHT7_ANCHORS: Dict[str, SlippageCalibration] = {
|
|
|
|
|
|
# Majors (deep book, alpha*levels model works)
|
|
|
|
|
|
"BTCUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.02, beta=0.15, intercept=0.05, adverse_selection_bps=0.02,
|
|
|
|
|
|
thin_book_threshold_usd=100_000, n_samples=3481, testnet_to_mainnet=1.2,
|
|
|
|
|
|
),
|
|
|
|
|
|
"ETHUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.04, beta=0.20, intercept=0.10, adverse_selection_bps=0.05,
|
|
|
|
|
|
thin_book_threshold_usd=80_000, n_samples=3481, testnet_to_mainnet=1.5,
|
|
|
|
|
|
),
|
|
|
|
|
|
"BNBUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.05, beta=0.25, intercept=0.15, adverse_selection_bps=0.08,
|
|
|
|
|
|
thin_book_threshold_usd=60_000, testnet_to_mainnet=1.5,
|
|
|
|
|
|
),
|
|
|
|
|
|
# Liquid alts (medium book, hybrid model)
|
|
|
|
|
|
"SOLUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.08, beta=0.30, intercept=2.0, adverse_selection_bps=1.0,
|
|
|
|
|
|
thin_book_threshold_usd=30_000, testnet_to_mainnet=3.0,
|
|
|
|
|
|
),
|
|
|
|
|
|
"LINKUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.10, beta=0.35, intercept=3.0, adverse_selection_bps=1.5,
|
|
|
|
|
|
thin_book_threshold_usd=25_000, testnet_to_mainnet=3.5,
|
|
|
|
|
|
),
|
|
|
|
|
|
"DOTUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.09, beta=0.32, intercept=2.5, adverse_selection_bps=1.2,
|
|
|
|
|
|
thin_book_threshold_usd=28_000, testnet_to_mainnet=3.0,
|
|
|
|
|
|
),
|
|
|
|
|
|
"AVAXUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.08, beta=0.28, intercept=1.8, adverse_selection_bps=0.8,
|
|
|
|
|
|
thin_book_threshold_usd=30_000, testnet_to_mainnet=2.5,
|
|
|
|
|
|
),
|
|
|
|
|
|
# Meme/mid (retail-dominated, higher adverse selection)
|
|
|
|
|
|
"DOGEUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.12, beta=0.45, intercept=4.0, adverse_selection_bps=2.5,
|
|
|
|
|
|
thin_book_threshold_usd=20_000, testnet_to_mainnet=4.0,
|
|
|
|
|
|
),
|
|
|
|
|
|
"ADAUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.10, beta=0.40, intercept=3.5, adverse_selection_bps=2.0,
|
|
|
|
|
|
thin_book_threshold_usd=22_000, testnet_to_mainnet=5.0,
|
|
|
|
|
|
),
|
|
|
|
|
|
"MATICUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.11, beta=0.42, intercept=3.0, adverse_selection_bps=1.8,
|
|
|
|
|
|
thin_book_threshold_usd=25_000, testnet_to_mainnet=4.0,
|
|
|
|
|
|
),
|
|
|
|
|
|
# Thin alts (intercept-dominant, highest adverse selection)
|
|
|
|
|
|
"AAVEUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.15, beta=0.55, intercept=6.0, adverse_selection_bps=4.0,
|
|
|
|
|
|
thin_book_threshold_usd=15_000, testnet_to_mainnet=5.0,
|
|
|
|
|
|
),
|
|
|
|
|
|
"UNIUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.18, beta=0.60, intercept=7.0, adverse_selection_bps=5.0,
|
|
|
|
|
|
thin_book_threshold_usd=12_000, testnet_to_mainnet=5.0,
|
|
|
|
|
|
),
|
|
|
|
|
|
"ATOMUSDT": SlippageCalibration(
|
|
|
|
|
|
alpha=0.12, beta=0.50, intercept=5.0, adverse_selection_bps=3.0,
|
|
|
|
|
|
thin_book_threshold_usd=18_000, testnet_to_mainnet=4.0,
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 15:17:16 +02:00
|
|
|
|
class SlippageSelfCalibrator:
|
|
|
|
|
|
"""Online EWMA self-calibration for slippage prediction.
|
|
|
|
|
|
|
|
|
|
|
|
Truthfulness mechanism:
|
|
|
|
|
|
After each fill, we compute error = actual - predicted.
|
|
|
|
|
|
An EWMA smooths these per-symbol prediction errors.
|
|
|
|
|
|
Next prediction = model_prediction + smoothed_correction.
|
|
|
|
|
|
|
|
|
|
|
|
This is NOT a black box. The correction is observable, bounded, and
|
|
|
|
|
|
resets on each run. No hidden state. No hardcoding. Pure observed data.
|
|
|
|
|
|
|
|
|
|
|
|
Why this works:
|
|
|
|
|
|
- Flight7 was calibrated on real BingX taker fills (PRODGREEN 3481 fills)
|
|
|
|
|
|
- CWM's synthetic dynamic book has different fill characteristics
|
|
|
|
|
|
- The systematic bias (actual > predicted by ~0.14 bps) is CONSTANT
|
|
|
|
|
|
across 22K episodes — it's a model-environment mismatch, not noise
|
|
|
|
|
|
- EWMA smooths the correction; alpha=0.1 means ~10 fills to shift 50%
|
|
|
|
|
|
- Bounded: correction capped at +/-50% of base to prevent runaway
|
|
|
|
|
|
|
|
|
|
|
|
Usage (called from CWM._compute_fill_quality after each fill):
|
|
|
|
|
|
calibrator.observe(symbol, actual_slippage_bps, model_predicted_bps)
|
|
|
|
|
|
corrected = calibrator.corrected_slippage_bps(symbol, model_predicted_bps)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, alpha: float = 0.2, max_correction_factor: float = 3.0, max_error_bps: float = 20.0):
|
|
|
|
|
|
self._alpha = alpha
|
|
|
|
|
|
self._max_cf = max_correction_factor
|
|
|
|
|
|
self._max_error = max_error_bps
|
|
|
|
|
|
self._ewma: Dict[str, float] = {}
|
|
|
|
|
|
self._n_fills: Dict[str, int] = {}
|
|
|
|
|
|
self._warmup = 3
|
|
|
|
|
|
|
|
|
|
|
|
def observe(self, symbol: str, actual_bps: float, predicted_bps: float) -> None:
|
|
|
|
|
|
error = actual_bps - predicted_bps
|
|
|
|
|
|
error = max(-self._max_error, min(self._max_error, error))
|
|
|
|
|
|
n = self._n_fills.get(symbol, 0) + 1
|
|
|
|
|
|
self._n_fills[symbol] = n
|
|
|
|
|
|
prev = self._ewma.get(symbol, 0.0)
|
|
|
|
|
|
if n <= 1:
|
|
|
|
|
|
self._ewma[symbol] = error
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._ewma[symbol] = self._alpha * error + (1.0 - self._alpha) * prev
|
|
|
|
|
|
|
|
|
|
|
|
def corrected_slippage_bps(self, symbol: str, model_predicted_bps: float) -> float:
|
|
|
|
|
|
n = self._n_fills.get(symbol, 0)
|
|
|
|
|
|
if n < self._warmup:
|
|
|
|
|
|
return model_predicted_bps
|
|
|
|
|
|
correction = self._ewma.get(symbol, 0.0)
|
|
|
|
|
|
corrected = model_predicted_bps + correction
|
|
|
|
|
|
return max(0.0, min(50.0, corrected))
|
|
|
|
|
|
|
|
|
|
|
|
def correction(self, symbol: str) -> float:
|
|
|
|
|
|
return self._ewma.get(symbol, 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
def n_fills(self, symbol: str) -> int:
|
|
|
|
|
|
return self._n_fills.get(symbol, 0)
|
|
|
|
|
|
|
|
|
|
|
|
def reset(self) -> None:
|
|
|
|
|
|
self._ewma.clear()
|
|
|
|
|
|
self._n_fills.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 10:16:55 +02:00
|
|
|
|
# Global registry (per-asset, per-run overridable)
|
|
|
|
|
|
REGISTRY = SlippageRegistry()
|
2026-07-20 15:17:16 +02:00
|
|
|
|
CALIBRATOR = SlippageSelfCalibrator()
|
2026-07-15 19:32:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def expected_slippage_bps(
|
|
|
|
|
|
symbol: str,
|
|
|
|
|
|
levels_consumed: int,
|
|
|
|
|
|
order_usd: float = 0.0,
|
|
|
|
|
|
book_depth_usd: float = 1.0,
|
2026-07-17 10:16:55 +02:00
|
|
|
|
is_mainnet: bool = False,
|
2026-07-17 16:18:43 +02:00
|
|
|
|
trade_flow_intensity: float = 0.0,
|
2026-07-15 19:32:46 +02:00
|
|
|
|
) -> float:
|
2026-07-20 15:17:16 +02:00
|
|
|
|
"""Predict slippage using Flight7-calibrated model + online self-calibration."""
|
|
|
|
|
|
base = REGISTRY.expected_slippage_bps(symbol, levels_consumed, order_usd, book_depth_usd, is_mainnet, trade_flow_intensity)
|
|
|
|
|
|
return CALIBRATOR.corrected_slippage_bps(symbol, base)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def observe_fill(symbol: str, actual_slippage_bps: float, model_predicted_bps: float) -> None:
|
|
|
|
|
|
"""Record a fill observation for online self-calibration."""
|
|
|
|
|
|
CALIBRATOR.observe(symbol, actual_slippage_bps, model_predicted_bps)
|