malkhut: urgency-driven maker/taker + calibrated slippage + chase + docs
This commit is contained in:
@@ -543,6 +543,51 @@ The CMA-ES optimizer now receives fill quality metrics in each `EpisodeResult`:
|
||||
|
||||
The CMA-ES objective is: maximize fill quality (primary) while maintaining positive PnL.
|
||||
|
||||
#### Urgency-Driven Maker/Taker Decision
|
||||
|
||||
The system learns WHEN to use maker (passive) vs taker (aggressive) as a function of urgency:
|
||||
|
||||
| Urgency | Behavior | Penalty |
|
||||
|---------|----------|---------|
|
||||
| 0.0 – 0.3 | Passive only (maker). No taker actions generated. | — |
|
||||
| 0.3 – 0.65 | IOC partial taker allowed. Small sizes, partial fills. | Low |
|
||||
| 0.65 – 1.0 | Full taker allowed. Aggressive crossing at full size. | None |
|
||||
|
||||
Two CMA-ES optimizable parameters:
|
||||
- `urgency_taker_threshold` (default 0.65): crosses from passive to aggressive
|
||||
- `urgency_taker_penalty_bps` (default 2.0): penalty for taker at low urgency
|
||||
|
||||
The system learns: "For ADAUSDT in stress regime, threshold=0.4 (thin book → try maker,
|
||||
but resort to taker when liquidity appears). For BTCUSDT in normal regime, threshold=0.8
|
||||
(deep book → maker always works)."
|
||||
|
||||
#### Calibrated Slippage (Flight7 VST + Mainnet Anchors)
|
||||
|
||||
Slippage model per-asset, configurable, overridable per-run:
|
||||
|
||||
| Book Type | Model | Assets |
|
||||
|-----------|-------|--------|
|
||||
| **Deep book** | `slippage = alpha * levels + beta * depth_ratio` | BTC, ETH, BNB |
|
||||
| **Thin book** | `slippage = intercept + adverse_selection` | DOGE, ADA, UNI, AAVE, ATOM |
|
||||
|
||||
Switch: `if book_depth_usd < thin_book_threshold_usd → thin mode`
|
||||
|
||||
Fable's Flight7 calibration anchors (testnet + mainnet):
|
||||
- Majors: BTC ~0.1 bps, ETH ~0.4 bps (testnet ≈ mainnet)
|
||||
- Liquid alts: 3-6 bps testnet, 10-30 bps mainnet (3-6× gap)
|
||||
- Thin alts: 14-25 bps testnet, ~140 bps round-trip mainnet
|
||||
|
||||
#### Chase Mechanics
|
||||
|
||||
Cancel → wait → retry with configurable parameters (CMA-ES optimizable):
|
||||
- `wait_to_retry_ms`: delay before re-quoting after cancel (0-2000ms)
|
||||
- `chase_enabled`: enable chase-follow behavior
|
||||
- `chase_offset_ticks`: ticks from target price to chase (0-10)
|
||||
- `chase_max_retries`: max cancel-retry cycles (0-5)
|
||||
|
||||
CHASE in the DSL places a limit order at target offset with TTL=wait_to_retry_ms.
|
||||
If not filled, the order expires and next step re-places at a new offset.
|
||||
|
||||
### Performance Benchmarks
|
||||
|
||||
| Metric | Value |
|
||||
|
||||
@@ -394,3 +394,44 @@ reward = w_fill_probability * fill_value_score ← PRIMARY
|
||||
PerformanceMatrix stores `avg_fill_rate`, `avg_slippage_bps`, `avg_price_improvement_bps`,
|
||||
`avg_fill_value_score` per (regime, strategy, venue) — enabling:
|
||||
"Which strategy achieves the best fill quality in regime X on venue Y?"
|
||||
|
||||
## Urgency-Driven Maker/Taker Decision
|
||||
|
||||
Two CMA-ES optimizable parameters control the maker/taker boundary:
|
||||
- `urgency_taker_threshold` (default 0.65): urgency level to switch from passive to aggressive
|
||||
- `urgency_taker_penalty_bps` (default 2.0): penalty for taker fills at low urgency
|
||||
|
||||
Action menu generates three urgency bands:
|
||||
1. urgency < threshold×0.5: passive only (no CROSS_SPREAD actions)
|
||||
2. threshold×0.5 < urgency < threshold: IOC partial taker (small sizes)
|
||||
3. urgency > threshold: full taker (aggressive crossing)
|
||||
|
||||
Reward function adds urgency penalty:
|
||||
```python
|
||||
if is_cross and urgency < threshold:
|
||||
penalty = urgency_taker_penalty_bps * (1 - urgency / threshold)
|
||||
fill_quality_reward -= penalty
|
||||
```
|
||||
|
||||
The CMA-ES learns the optimal threshold per (asset, regime, venue).
|
||||
|
||||
## Calibrated Slippage
|
||||
|
||||
SlippageCalibration uses Flight7 VST + mainnet anchors:
|
||||
- **Deep book** (BTC/ETH): `alpha * levels + beta * depth_ratio` (walks book)
|
||||
- **Thin book** (alts): `intercept + adverse_selection` (fills entire book in 1-2 levels)
|
||||
|
||||
Switch: `book_depth_usd < thin_book_threshold_usd → thin mode`
|
||||
|
||||
Per-asset configurable, per-run overridable via `SlippageRegistry.override()`.
|
||||
|
||||
## Chase Mechanics
|
||||
|
||||
CHASE in DSL: cancel → wait_to_retry_ms → retry at new offset.
|
||||
Parameters in FulfilmentPolicyParams:
|
||||
- `wait_to_retry_ms` (0-2000): delay before re-quoting
|
||||
- `chase_enabled`: enable chase-follow behavior
|
||||
- `chase_offset_ticks` (0-10): ticks from target price to chase
|
||||
- `chase_max_retries` (0-5): max cancel-retry cycles
|
||||
|
||||
All three parameters are in the CMA-ES optimization cycle.
|
||||
|
||||
@@ -661,6 +661,13 @@ class HftBacktestCWM:
|
||||
if fq.filled and fq.post_fill_adverse_bps < 0:
|
||||
fill_quality_reward += params.w_adverse_selection * fq.post_fill_adverse_bps
|
||||
|
||||
# ── URGENCY PENALTY: penalize taker at low urgency ──────────────
|
||||
# The system learns: at low urgency, prefer maker. At high urgency, taker is OK.
|
||||
urgency = prev_state.intent.urgency if prev_state.intent else 0.5
|
||||
if is_cross and urgency < params.urgency_taker_threshold:
|
||||
urgency_penalty = params.urgency_taker_penalty_bps * (1.0 - urgency / params.urgency_taker_threshold)
|
||||
fill_quality_reward -= urgency_penalty
|
||||
|
||||
return base_reward + fill_quality_reward
|
||||
|
||||
def terminal(self, state: MarketWorldState, depth: int) -> bool:
|
||||
|
||||
@@ -23,6 +23,10 @@ from malkhut.state import (
|
||||
from malkhut.actions import FulfilmentAction
|
||||
|
||||
|
||||
CHASE_MIN_OFFSET = 0 # minimum offset ticks for chase
|
||||
CHASE_MAX_OFFSET = 5 # maximum offset ticks for chase
|
||||
|
||||
|
||||
def _side_for_intent(intent_kind: IntentKind) -> Side:
|
||||
if intent_kind in (IntentKind.ENTER_LONG, IntentKind.ADD_LONG, IntentKind.REDUCE_SHORT, IntentKind.EXIT_SHORT):
|
||||
return Side.BUY
|
||||
@@ -107,8 +111,11 @@ def build_our_actions(
|
||||
post_only=intent.prefer_maker, reduce_only=intent.reduce_only,
|
||||
))
|
||||
|
||||
# Aggressive crossing if urgency allows
|
||||
if intent.urgency > 0.65:
|
||||
# Aggressive crossing — urgency-driven maker/taker decision
|
||||
# Below threshold: passive only (maker). Above: aggressive (taker).
|
||||
# The system learns the cheapest/fastest path as a function of urgency.
|
||||
if intent.urgency > params.urgency_taker_threshold:
|
||||
# High urgency: aggressive taker fills
|
||||
for frac in (0.05, 0.10, 0.25):
|
||||
actions.append(FulfilmentAction(
|
||||
kind=ActionKind.CROSS_SPREAD, side=side,
|
||||
@@ -116,7 +123,20 @@ def build_our_actions(
|
||||
qty_fraction=frac, ttl_ms=params.aggressive_ttl_ms,
|
||||
time_in_force="IOC",
|
||||
reduce_only=intent.reduce_only,
|
||||
metadata={"urgency": intent.urgency, "mode": "taker"},
|
||||
))
|
||||
elif intent.urgency > params.urgency_taker_threshold * 0.5:
|
||||
# Medium urgency: aggressive but with IOC (partial fill OK)
|
||||
for frac in (0.03, 0.06):
|
||||
actions.append(FulfilmentAction(
|
||||
kind=ActionKind.CROSS_SPREAD, side=side,
|
||||
order_type=OrderType.LIMIT, price_ticks_from_best=0,
|
||||
qty_fraction=frac, ttl_ms=params.aggressive_ttl_ms,
|
||||
time_in_force="IOC",
|
||||
reduce_only=intent.reduce_only,
|
||||
metadata={"urgency": intent.urgency, "mode": "taker_partial"},
|
||||
))
|
||||
# Low urgency: passive only (maker) — no taker actions added
|
||||
|
||||
# Path-risk exits
|
||||
if _path_risk_says_exit(state, params):
|
||||
@@ -127,4 +147,19 @@ def build_our_actions(
|
||||
metadata={"reason": "path_risk_exit"},
|
||||
))
|
||||
|
||||
# Chase actions (cancel → wait → retry with configurable wait_to_retry_ms)
|
||||
if params.chase_enabled and params.wait_to_retry_ms > 0:
|
||||
chase_ttl = params.wait_to_retry_ms
|
||||
for offset in range(CHASE_MIN_OFFSET, min(CHASE_MAX_OFFSET + 1, params.chase_offset_ticks + 1)):
|
||||
for frac in params.quote_size_fractions:
|
||||
actions.append(FulfilmentAction(
|
||||
kind=ActionKind.PLACE, side=side,
|
||||
order_type=OrderType.LIMIT,
|
||||
price_ticks_from_best=offset, qty_fraction=frac,
|
||||
ttl_ms=chase_ttl,
|
||||
post_only=True,
|
||||
metadata={"chase": True, "chase_offset": offset,
|
||||
"chase_max_retries": params.chase_max_retries},
|
||||
))
|
||||
|
||||
return tuple(actions)
|
||||
|
||||
@@ -386,3 +386,13 @@ class FulfilmentPolicyParams:
|
||||
toxic_counterparty_weight: float
|
||||
low_liquidity_weight: float
|
||||
latency_stress_weight: float
|
||||
|
||||
# Chase mechanics (cancel → wait → retry)
|
||||
wait_to_retry_ms: int = 0 # ms to wait before re-quoting after cancel
|
||||
chase_enabled: bool = False # enable chase-follow behavior
|
||||
chase_offset_ticks: int = 1 # ticks from target price to chase
|
||||
chase_max_retries: int = 3 # max cancel-retry cycles
|
||||
|
||||
# Urgency-driven maker/taker decision (CMA-ES optimizable)
|
||||
urgency_taker_threshold: float = 0.65 # above this urgency, prefer taker
|
||||
urgency_taker_penalty_bps: float = 2.0 # penalty for taker at low urgency
|
||||
|
||||
@@ -105,6 +105,8 @@ def _params() -> FulfilmentPolicyParams:
|
||||
w_fee_quality=0.5, w_time_decay=0.3, w_policy_entropy=0.5,
|
||||
robust_tail_weight=2.0, toxic_counterparty_weight=3.0,
|
||||
low_liquidity_weight=2.0, latency_stress_weight=1.0,
|
||||
wait_to_retry_ms=0, chase_enabled=False, chase_offset_ticks=1, chase_max_retries=0,
|
||||
urgency_taker_threshold=0.65, urgency_taker_penalty_bps=2.0,
|
||||
)
|
||||
|
||||
def _place(side: Side = Side.BUY, price_ticks: int = 0, qty: float = 0.10,
|
||||
|
||||
@@ -90,6 +90,11 @@ class CMAParameterCodec:
|
||||
ParamSpec("toxic_counterparty_weight", "float", 0.0, 10.0),
|
||||
ParamSpec("low_liquidity_weight", "float", 0.0, 10.0),
|
||||
ParamSpec("latency_stress_weight", "float", 0.0, 10.0),
|
||||
ParamSpec("wait_to_retry_ms", "int", 0, 2000),
|
||||
ParamSpec("chase_offset_ticks", "int", 0, 10),
|
||||
ParamSpec("chase_max_retries", "int", 0, 5),
|
||||
ParamSpec("urgency_taker_threshold", "float", 0.1, 0.9),
|
||||
ParamSpec("urgency_taker_penalty_bps", "float", 0.0, 10.0),
|
||||
)
|
||||
|
||||
def initial_vector(self, baseline: FulfilmentPolicyParams) -> list[float]:
|
||||
@@ -146,6 +151,12 @@ class CMAParameterCodec:
|
||||
toxic_counterparty_weight=vals.get("toxic_counterparty_weight", 3.0),
|
||||
low_liquidity_weight=vals.get("low_liquidity_weight", 2.0),
|
||||
latency_stress_weight=vals.get("latency_stress_weight", 1.0),
|
||||
wait_to_retry_ms=vals.get("wait_to_retry_ms", 0),
|
||||
chase_enabled=vals.get("chase_max_retries", 0) > 0,
|
||||
chase_offset_ticks=vals.get("chase_offset_ticks", 1),
|
||||
chase_max_retries=vals.get("chase_max_retries", 0),
|
||||
urgency_taker_threshold=vals.get("urgency_taker_threshold", 0.65),
|
||||
urgency_taker_penalty_bps=vals.get("urgency_taker_penalty_bps", 2.0),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class ActionType(str, Enum):
|
||||
CROSS = "CROSS"
|
||||
SNIPER = "SNIPER"
|
||||
PING = "PING"
|
||||
CHASE = "CHASE" # Chase: follow target price with cancel-retry loop
|
||||
|
||||
# Cancellation
|
||||
CANCEL = "CANCEL"
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
"""
|
||||
Slippage Calibration — replace 0.1bps-per-level heuristic with VST-calibrated model.
|
||||
Slippage Calibration — Flight7-anchored, per-asset, per-run overridable.
|
||||
|
||||
The current CWM uses: expected_slippage_bps = levels_consumed * 0.1
|
||||
Fable's Flight7 calibration (2026-07-16):
|
||||
|
||||
This is a constant heuristic. Real slippage is CONDITIONAL on:
|
||||
- Book depth at fill time
|
||||
- Order size relative to book depth
|
||||
- Market regime (trending vs choppy)
|
||||
- Asset-specific depth profile (alpha from power-law decay)
|
||||
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).
|
||||
|
||||
Calibration process:
|
||||
1. Collect VST fill data: (levels_consumed, slippage_bps, order_usd, book_depth_usd)
|
||||
2. Fit per-asset model: slippage_bps = alpha * levels + beta * (order_usd / book_depth_usd) + gamma
|
||||
3. Store calibrated params in AssetBehavior
|
||||
4. CWM uses calibrated params instead of 0.1bps constant
|
||||
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
|
||||
|
||||
The key insight from the OB study:
|
||||
- BTC alpha=0.70: deep book → ~0.05 bps/level
|
||||
- ETH alpha=0.75: ~0.08 bps/level
|
||||
- SOL alpha=0.85: ~0.15 bps/level
|
||||
- DOGE alpha=1.00: thin book → ~0.3 bps/level
|
||||
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.
|
||||
|
||||
VST tape format (from Flight7):
|
||||
Each row: timestamp, symbol, side, fill_price, fill_qty, best_bid, best_ask,
|
||||
bid_depth_levels, ask_depth_levels, bid_depth_usd, ask_depth_usd,
|
||||
spread_bps, order_latency_ms
|
||||
Per-asset configurable: each asset has its own SlippageCalibration.
|
||||
Per-run overridable: ScenarioFactory can override per-asset models.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -37,142 +34,183 @@ import math
|
||||
class SlippageCalibration:
|
||||
"""Per-asset calibrated slippage model.
|
||||
|
||||
Model: slippage_bps = alpha * levels_consumed + beta * (order_usd / book_depth_usd) + intercept
|
||||
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.
|
||||
"""
|
||||
alpha: float = 0.1 # bps per level consumed (default: 0.1)
|
||||
beta: float = 0.5 # bps per unit of order/book depth ratio
|
||||
intercept: float = 0.0 # base slippage (bps)
|
||||
n_samples: int = 0 # how many fills used for calibration
|
||||
r_squared: float = 0.0 # model fit quality
|
||||
# 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
|
||||
|
||||
def expected_slippage_bps(
|
||||
self,
|
||||
levels_consumed: int,
|
||||
order_usd: float = 0.0,
|
||||
book_depth_usd: float = 1.0,
|
||||
is_mainnet: bool = False,
|
||||
) -> float:
|
||||
"""Predict slippage based on fill parameters."""
|
||||
depth_ratio = order_usd / max(book_depth_usd, 1.0)
|
||||
return self.alpha * levels_consumed + self.beta * depth_ratio + self.intercept
|
||||
"""Predict slippage. Switches model based on book depth."""
|
||||
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
|
||||
|
||||
if is_mainnet:
|
||||
base *= self.testnet_to_mainnet
|
||||
return base
|
||||
|
||||
@dataclass
|
||||
class SlippageCalibrator:
|
||||
"""Calibrate slippage model from VST fill data.
|
||||
|
||||
Usage:
|
||||
calibrator = SlippageCalibrator()
|
||||
for fill in vst_fills:
|
||||
calibrator.record(fill)
|
||||
model = calibrator.calibrate()
|
||||
"""
|
||||
_records: List[Dict[str, float]] = field(default_factory=list)
|
||||
|
||||
def record(
|
||||
def expected_slippage_per_level(
|
||||
self,
|
||||
levels_consumed: int,
|
||||
slippage_bps: float,
|
||||
order_usd: float,
|
||||
book_depth_usd: float,
|
||||
is_maker: bool = False,
|
||||
spread_bps: float = 0.0,
|
||||
) -> None:
|
||||
"""Record a VST fill for calibration."""
|
||||
self._records.append({
|
||||
"levels": levels_consumed,
|
||||
"slippage": slippage_bps,
|
||||
"order_usd": order_usd,
|
||||
"book_depth_usd": book_depth_usd,
|
||||
"is_maker": float(is_maker),
|
||||
"spread_bps": spread_bps,
|
||||
})
|
||||
|
||||
def calibrate(self) -> SlippageCalibration:
|
||||
"""Fit slippage model using least-squares regression.
|
||||
|
||||
Model: slippage_bps = alpha * levels + beta * (order_usd / book_depth_usd) + intercept
|
||||
"""
|
||||
if len(self._records) < 10:
|
||||
return SlippageCalibration()
|
||||
|
||||
# Build feature matrix: [levels, depth_ratio]
|
||||
X = []
|
||||
y = []
|
||||
for r in self._records:
|
||||
depth_ratio = r["order_usd"] / max(r["book_depth_usd"], 1.0)
|
||||
X.append([r["levels"], depth_ratio])
|
||||
y.append(r["slippage"])
|
||||
|
||||
n = len(X)
|
||||
# Simple least-squares: y = alpha*x1 + beta*x2 + intercept
|
||||
# Using normal equations: (X^T X)^-1 X^T y
|
||||
sum_x1 = sum(row[0] for row in X)
|
||||
sum_x2 = sum(row[1] for row in X)
|
||||
sum_y = sum(y)
|
||||
sum_x1sq = sum(row[0]**2 for row in X)
|
||||
sum_x2sq = sum(row[1]**2 for row in X)
|
||||
sum_x1x2 = sum(row[0]*row[1] for row in X)
|
||||
sum_x1y = sum(row[0]*y[i] for i, row in enumerate(X))
|
||||
sum_x2y = sum(row[1]*y[i] for i, row in enumerate(X))
|
||||
|
||||
# Normal equations matrix
|
||||
det = (n * sum_x1sq * sum_x2sq +
|
||||
2 * sum_x1 * sum_x2 * sum_x1x2 -
|
||||
sum_x1sq * sum_x2**2 -
|
||||
sum_x2sq * sum_x1**2 -
|
||||
n * sum_x1x2**2)
|
||||
|
||||
if abs(det) < 1e-12:
|
||||
return SlippageCalibration()
|
||||
|
||||
alpha = (sum_x2sq * sum_x1y - sum_x1x2 * sum_x2y +
|
||||
sum_x1x2 * sum_y - sum_x1 * sum_x2 * sum_x1y / n) / det * n
|
||||
beta = (sum_x1sq * sum_x2y - sum_x1x2 * sum_x1y +
|
||||
sum_x1x2 * sum_y - sum_x2 * sum_x1 * sum_x1y / n) / det * n
|
||||
intercept = (sum_y - alpha * sum_x1 - beta * sum_x2) / n
|
||||
|
||||
# R-squared
|
||||
y_mean = sum_y / n
|
||||
ss_res = sum((y[i] - (alpha * X[i][0] + beta * X[i][1] + intercept))**2 for i in range(n))
|
||||
ss_tot = sum((y[i] - y_mean)**2 for i in range(n))
|
||||
r_squared = 1.0 - ss_res / max(ss_tot, 1e-12)
|
||||
|
||||
return SlippageCalibration(
|
||||
alpha=alpha, beta=beta, intercept=intercept,
|
||||
n_samples=n, r_squared=r_squared,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def default_per_asset() -> Dict[str, SlippageCalibration]:
|
||||
"""Default calibration from OB study research (Bouchaud/Cont/Stoikov).
|
||||
|
||||
These are PRIOR values from the power-law depth model:
|
||||
alpha = estimated bps per level consumed
|
||||
"""
|
||||
return {
|
||||
"BTCUSDT": SlippageCalibration(alpha=0.05, beta=0.3, intercept=0.02, n_samples=0),
|
||||
"ETHUSDT": SlippageCalibration(alpha=0.08, beta=0.4, intercept=0.03, n_samples=0),
|
||||
"SOLUSDT": SlippageCalibration(alpha=0.15, beta=0.6, intercept=0.05, n_samples=0),
|
||||
"DOGEUSDT": SlippageCalibration(alpha=0.30, beta=1.0, intercept=0.10, n_samples=0),
|
||||
"ADAUSDT": SlippageCalibration(alpha=0.25, beta=0.8, intercept=0.08, n_samples=0),
|
||||
"AVAXUSDT": SlippageCalibration(alpha=0.20, beta=0.7, intercept=0.06, n_samples=0),
|
||||
"UNIUSDT": SlippageCalibration(alpha=0.35, beta=1.2, intercept=0.12, n_samples=0),
|
||||
"LINKUSDT": SlippageCalibration(alpha=0.18, beta=0.6, intercept=0.05, n_samples=0),
|
||||
"BNBUSDT": SlippageCalibration(alpha=0.06, beta=0.35, intercept=0.02, n_samples=0),
|
||||
"MATICUSDT": SlippageCalibration(alpha=0.22, beta=0.75, intercept=0.07, n_samples=0),
|
||||
"AAVEUSDT": SlippageCalibration(alpha=0.30, beta=1.0, intercept=0.10, n_samples=0),
|
||||
"DOTUSDT": SlippageCalibration(alpha=0.18, beta=0.6, intercept=0.05, n_samples=0),
|
||||
"ATOMUSDT": SlippageCalibration(alpha=0.25, beta=0.8, intercept=0.08, n_samples=0),
|
||||
}
|
||||
) -> float:
|
||||
"""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
|
||||
|
||||
|
||||
# Global registry
|
||||
SLIPPAGE_MODELS: Dict[str, SlippageCalibration] = SlippageCalibrator.default_per_asset()
|
||||
class SlippageRegistry:
|
||||
"""Per-asset slippage registry with per-run override support."""
|
||||
|
||||
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()
|
||||
|
||||
def expected_slippage_bps(
|
||||
self,
|
||||
symbol: str,
|
||||
levels_consumed: int,
|
||||
order_usd: float = 0.0,
|
||||
book_depth_usd: float = 1.0,
|
||||
is_mainnet: bool = False,
|
||||
) -> float:
|
||||
"""Predict slippage using the appropriate model."""
|
||||
model = self.get(symbol)
|
||||
return model.expected_slippage_bps(levels_consumed, order_usd, book_depth_usd, is_mainnet)
|
||||
|
||||
|
||||
def get_slippage_model(symbol: str) -> SlippageCalibration:
|
||||
"""Get calibrated slippage model for a symbol."""
|
||||
return SLIPPAGE_MODELS.get(symbol, SlippageCalibration(alpha=0.1, beta=0.5))
|
||||
# ==============================================================================
|
||||
# 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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Global registry (per-asset, per-run overridable)
|
||||
REGISTRY = SlippageRegistry()
|
||||
|
||||
|
||||
def expected_slippage_bps(
|
||||
@@ -180,7 +218,7 @@ def expected_slippage_bps(
|
||||
levels_consumed: int,
|
||||
order_usd: float = 0.0,
|
||||
book_depth_usd: float = 1.0,
|
||||
is_mainnet: bool = False,
|
||||
) -> float:
|
||||
"""Predict slippage for a given fill parameters, using calibrated model."""
|
||||
model = get_slippage_model(symbol)
|
||||
return model.expected_slippage_bps(levels_consumed, order_usd, book_depth_usd)
|
||||
"""Predict slippage using Flight7-calibrated model."""
|
||||
return REGISTRY.expected_slippage_bps(symbol, levels_consumed, order_usd, book_depth_usd, is_mainnet)
|
||||
|
||||
Reference in New Issue
Block a user