feat: Phase 4 — controlled strategy deployment module + 38 tests
New live/ sub-modules for production-ready market making:
live/filters/toxicity.py (ToxicityFilter):
VPIN-based pre-trade filter. Accumulates buy/sell volume, computes
VPIN via microstructure module, produces quoting decision:
- allow_quoting: bool
- size_multiplier: 0.0–1.0 (graduated reduction approaching alarm)
- granular thresholds (threshold vs alarm) with smooth reduction
live/treasury.py (Treasury):
Central capital/risk management — single source of truth:
- Position tracking per coin (opening, closing, average entry)
- Realized + unrealized PnL computation
- Pre-trade constraint checks (inventory limits, fee estimates)
- Circuit breaker (drawdown, trade count, toxic fill rate, API errors)
- Liquidation distance monitoring
- Automatic cooldown reset after trip expiry
live/makers/hl_btc_eth.py:
HlMaker — per-coin market maker integrating:
- AvellanedaStoikovMaker (Phase 3) for optimal quotes
- ToxicityFilter for pre-trade gating
- Treasury for position/risk checks
HlMakerPool — manages multiple HlMaker instances with shared treasury
and coordinated observe_all()/quote_all()
live/monitors/cross_venue.py (CrossVenueMonitor):
Cross-exchange lead-lag detection via cross-correlation at multiple
lags. Spot premium (basis proxy) computation. Multi-venue summary.
live/monitors/funding_basis.py (FundingBasisMonitor):
Funding regime classification, momentum detection, carry PnL
estimation, basis spread analysis. Uses microstructure/funding.py.
live/monitors/liq_risk.py (LiquidationRiskOverlay):
Per-position liquidation distance monitoring with tiered warnings
(safe/warning/danger/critical). Recommended position reduction.
38 tests across 4 files (all pass):
test_live_filters.py (5)
test_live_maker.py (9)
test_live_monitors.py (11)
test_live_treasury.py (13)
Total test suite: 172 tests, all passing.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Pre-trade toxicity filter — blocks quoting when market is adversarially informed.
|
||||
|
||||
Uses VPIN and flow toxicity metrics from microstructure module to
|
||||
determine whether to quote, reduce size, or go flat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ToxicityFilter:
|
||||
"""VPIN-based flow toxicity filter for market-making.
|
||||
|
||||
Monitors trade flow and order book to detect informed trading.
|
||||
Blocks or reduces quoting when toxicity crosses thresholds.
|
||||
|
||||
Usage:
|
||||
tf = ToxicityFilter()
|
||||
tf.update_trade(buy_vol=0.1, sell_vol=0.05)
|
||||
decision = tf.check()
|
||||
if decision["allow_quoting"]:
|
||||
size_mult = decision["size_multiplier"]
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vpin_threshold: float = 0.3,
|
||||
vpin_alarm: float = 0.5,
|
||||
vpin_window: int = 50,
|
||||
volume_bucket_size: float = 1.0,
|
||||
size_reduction_steps: int = 5,
|
||||
):
|
||||
self._threshold = vpin_threshold
|
||||
self._alarm = vpin_alarm
|
||||
self._window = vpin_window
|
||||
self._bucket_size = volume_bucket_size
|
||||
self._steps = size_reduction_steps
|
||||
|
||||
self._buy_vol: deque[float] = deque(maxlen=5000)
|
||||
self._sell_vol: deque[float] = deque(maxlen=5000)
|
||||
self._current_vpin: float = 0.0
|
||||
self._last_obi: float = 0.0
|
||||
self._trade_count: int = 0
|
||||
|
||||
def update_trade(self, buy_vol: float, sell_vol: float):
|
||||
"""Feed a classified trade volume observation."""
|
||||
self._buy_vol.append(buy_vol)
|
||||
self._sell_vol.append(sell_vol)
|
||||
self._trade_count += 1
|
||||
|
||||
def update_book_imbalance(self, obi: float):
|
||||
"""Feed current order-book imbalance."""
|
||||
self._last_obi = obi
|
||||
|
||||
def compute(self):
|
||||
"""Recompute VPIN from accumulated volume data."""
|
||||
from microstructure.toxicity import compute_vpin
|
||||
result = compute_vpin(
|
||||
list(self._buy_vol),
|
||||
list(self._sell_vol),
|
||||
volume_bucket_size=self._bucket_size,
|
||||
n_buckets=self._window,
|
||||
)
|
||||
self._current_vpin = result.get("vpin_value", 0.0)
|
||||
|
||||
def check(self) -> dict:
|
||||
"""Evaluate current toxicity state.
|
||||
|
||||
Returns:
|
||||
allow_quoting: whether to quote at all
|
||||
size_multiplier: fraction of base size to quote (1.0 = full, 0.0 = none)
|
||||
vpin: current VPIN
|
||||
obi: current OBI
|
||||
reason: explanation if blocked/reduced
|
||||
"""
|
||||
self.compute()
|
||||
v = self._current_vpin
|
||||
|
||||
if v >= self._alarm:
|
||||
return {
|
||||
"allow_quoting": False,
|
||||
"size_multiplier": 0.0,
|
||||
"vpin": round(v, 4),
|
||||
"obi": round(self._last_obi, 4),
|
||||
"reason": f"VPIN {v:.3f} >= alarm {self._alarm}",
|
||||
}
|
||||
|
||||
if v >= self._threshold:
|
||||
reduction = (v - self._threshold) / (self._alarm - self._threshold)
|
||||
size = max(0.0, 1.0 - reduction)
|
||||
return {
|
||||
"allow_quoting": size > 0.0,
|
||||
"size_multiplier": round(size, 2),
|
||||
"vpin": round(v, 4),
|
||||
"obi": round(self._last_obi, 4),
|
||||
"reason": f"VPIN {v:.3f} >= threshold {self._threshold}",
|
||||
}
|
||||
|
||||
return {
|
||||
"allow_quoting": True,
|
||||
"size_multiplier": 1.0,
|
||||
"vpin": round(v, 4),
|
||||
"obi": round(self._last_obi, 4),
|
||||
"reason": "ok",
|
||||
}
|
||||
|
||||
@property
|
||||
def vpin(self) -> float:
|
||||
return self._current_vpin
|
||||
|
||||
@property
|
||||
def trade_count(self) -> int:
|
||||
return self._trade_count
|
||||
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
Hyperliquid BTC/ETH maker strategy with tight inventory limits.
|
||||
|
||||
Uses Avellaneda-Stoikov optimal control from sim/maker.py,
|
||||
toxicity filter from live/filters/toxicity.py, and treasury
|
||||
from live/treasury.py for position/risk management.
|
||||
|
||||
Designed for Phase 4 controlled deployment: post-only quotes,
|
||||
tight inventory caps, toxicity gating.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sim.maker import AvellanedaStoikovMaker, MakerConfig, Quote
|
||||
from sim.queue import QueueModel
|
||||
from live.filters.toxicity import ToxicityFilter
|
||||
from live.treasury import Treasury
|
||||
|
||||
|
||||
class HlMaker:
|
||||
"""Hyperliquid market maker for a single coin.
|
||||
|
||||
Lifecycle per tick:
|
||||
1. observe(mid_price) — feed mid price for vol estimation
|
||||
2. update_flow(buy_vol, sell_vol, obi) — feed trade flow for toxicity
|
||||
3. quote() — get bid/ask quotes (or None if blocked)
|
||||
4. record_fill(side, size, price, fee) — after exchange confirms fill
|
||||
|
||||
Usage:
|
||||
maker = HlMaker("BTC", treasury=treasury, max_inventory=0.003)
|
||||
maker.observe(50000.0)
|
||||
maker.update_flow(buy_vol=0.1, sell_vol=0.05, obi=0.2)
|
||||
quote = maker.quote()
|
||||
if quote:
|
||||
# place bid at quote.bid, ask at quote.ask on exchange
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coin: str,
|
||||
treasury: Treasury,
|
||||
max_inventory: float = 0.003,
|
||||
base_size: float = 0.0002,
|
||||
gamma: float = 0.1,
|
||||
k: float = 1.5,
|
||||
tau_hours: float = 1.0,
|
||||
min_spread_bps: float = 1.0,
|
||||
max_spread_bps: float = 15.0,
|
||||
vpin_threshold: float = 0.3,
|
||||
vpin_alarm: float = 0.5,
|
||||
skew_factor: float = 0.3,
|
||||
):
|
||||
self.coin = coin.upper()
|
||||
self._treasury = treasury
|
||||
self._max_inventory = max_inventory
|
||||
self._base_size = base_size
|
||||
self._skew_factor = skew_factor
|
||||
|
||||
self._maker = AvellanedaStoikovMaker(
|
||||
MakerConfig(
|
||||
gamma=gamma,
|
||||
k=k,
|
||||
tau=tau_hours,
|
||||
min_spread_bps=min_spread_bps,
|
||||
max_spread_bps=max_spread_bps,
|
||||
base_size=base_size,
|
||||
max_inventory=max_inventory,
|
||||
skew_factor=skew_factor,
|
||||
)
|
||||
)
|
||||
|
||||
self._toxicity = ToxicityFilter(
|
||||
vpin_threshold=vpin_threshold,
|
||||
vpin_alarm=vpin_alarm,
|
||||
)
|
||||
|
||||
self._mid_price: float = 0.0
|
||||
self._best_bid: float = 0.0
|
||||
self._best_ask: float = 0.0
|
||||
self._elapsed_hours: float = 0.0
|
||||
self._start_time: float = time.time()
|
||||
self._last_quote: Optional[Quote] = None
|
||||
|
||||
def observe(self, mid_price: float):
|
||||
"""Feed a new mid price observation."""
|
||||
self._mid_price = mid_price
|
||||
self._elapsed_hours = (time.time() - self._start_time) / 3600.0
|
||||
self._maker.observe(mid_price)
|
||||
self._treasury.update_mark_price(self.coin, mid_price)
|
||||
|
||||
def update_book(self, best_bid: float, best_ask: float):
|
||||
self._best_bid = best_bid
|
||||
self._best_ask = best_ask
|
||||
|
||||
def update_flow(self, buy_vol: float, sell_vol: float, obi: float = 0.0):
|
||||
"""Feed trade flow and order-book imbalance for toxicity tracking."""
|
||||
self._toxicity.update_trade(buy_vol, sell_vol)
|
||||
self._toxicity.update_book_imbalance(obi)
|
||||
|
||||
def quote(self) -> Optional[Quote]:
|
||||
"""Generate the next set of quotes, or None if blocked."""
|
||||
if self._treasury.is_halted():
|
||||
return None
|
||||
if self._mid_price <= 0:
|
||||
return None
|
||||
|
||||
tox = self._toxicity.check()
|
||||
if not tox["allow_quoting"]:
|
||||
return None
|
||||
|
||||
size_mult = tox["size_multiplier"]
|
||||
|
||||
position = self._treasury.position(self.coin)
|
||||
target_inv = 0.0 # neutral target
|
||||
|
||||
q = self._maker.quote_with_skew(
|
||||
mid_price=self._mid_price,
|
||||
inventory=position,
|
||||
elapsed_hours=self._elapsed_hours,
|
||||
target_inventory=target_inv,
|
||||
)
|
||||
|
||||
# Scale sizes by toxicity multiplier
|
||||
q.bid_size *= size_mult
|
||||
q.ask_size *= size_mult
|
||||
|
||||
# Never cross the market
|
||||
if self._best_bid > 0:
|
||||
q.bid = round(min(q.bid, self._best_bid * 0.999), 2)
|
||||
if self._best_ask > 0:
|
||||
q.ask = round(max(q.ask, self._best_ask * 1.001), 2)
|
||||
|
||||
self._last_quote = q
|
||||
return q
|
||||
|
||||
def record_fill(self, side: str, size: float, price: float, fee: float):
|
||||
"""Record a fill after exchange confirmation."""
|
||||
pnl = 0.0
|
||||
position = self._treasury.position(self.coin)
|
||||
if (side == "sell" and position > 0) or (side == "buy" and position < 0):
|
||||
pnl = size * (price - (self._mid_price))
|
||||
self._treasury.record_fill(self.coin, side, size, price, fee, pnl)
|
||||
|
||||
def should_skip(self) -> bool:
|
||||
"""Check if we should skip quoting this tick."""
|
||||
if self._treasury.is_halted():
|
||||
return True
|
||||
if self._treasury.position_size(self.coin) >= self._max_inventory:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def last_quote(self) -> Optional[Quote]:
|
||||
return self._last_quote
|
||||
|
||||
@property
|
||||
def current_vpin(self) -> float:
|
||||
return self._toxicity.vpin
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"coin": self.coin,
|
||||
"mid": self._mid_price,
|
||||
"position": self._treasury.position(self.coin),
|
||||
"vpin": self._toxicity.vpin,
|
||||
"sigma": self._maker.sigma,
|
||||
"last_quote": {
|
||||
"bid": self._last_quote.bid,
|
||||
"ask": self._last_quote.ask,
|
||||
"spread_bps": self._last_quote.spread_bps,
|
||||
} if self._last_quote else None,
|
||||
}
|
||||
|
||||
|
||||
class HlMakerPool:
|
||||
"""Manage multiple HlMaker instances across coins.
|
||||
|
||||
Provides unified interface for multi-coin market making with
|
||||
shared treasury and coordinated quoting.
|
||||
"""
|
||||
|
||||
def __init__(self, treasury: Treasury, maker_config: dict | None = None):
|
||||
self._treasury = treasury
|
||||
self._maker_config = maker_config or {}
|
||||
self._makers: dict[str, HlMaker] = {}
|
||||
|
||||
def add_maker(self, coin: str, max_inventory: float = 0.003, **kwargs) -> HlMaker:
|
||||
cfg = dict(self._maker_config)
|
||||
cfg.update(kwargs)
|
||||
maker = HlMaker(coin=coin, treasury=self._treasury, max_inventory=max_inventory, **cfg)
|
||||
self._makers[coin.upper()] = maker
|
||||
return maker
|
||||
|
||||
def get(self, coin: str) -> Optional[HlMaker]:
|
||||
return self._makers.get(coin.upper())
|
||||
|
||||
def observe_all(self, mid_prices: dict[str, float]):
|
||||
for coin, price in mid_prices.items():
|
||||
maker = self._makers.get(coin.upper())
|
||||
if maker:
|
||||
maker.observe(price)
|
||||
|
||||
def quote_all(self) -> dict[str, Optional[Quote]]:
|
||||
return {coin: maker.quote() for coin, maker in self._makers.items()}
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
coin: maker.summary()
|
||||
for coin, maker in self._makers.items()
|
||||
}
|
||||
|
||||
@property
|
||||
def makers(self) -> dict[str, HlMaker]:
|
||||
return self._makers
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Cross-venue lead-lag monitor.
|
||||
|
||||
Detects when one exchange leads another in price discovery.
|
||||
Used for informational purposes only in Phase 4 — no auto-trading.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
class CrossVenueMonitor:
|
||||
"""Monitor price lead-lag relationships between exchanges.
|
||||
|
||||
Tracks mid prices across venues and computes cross-correlation
|
||||
and lead-lag structure. Can detect when Hyperliquid follows
|
||||
Binance or vice versa.
|
||||
|
||||
Usage:
|
||||
monitor = CrossVenueMonitor(pairs=[("hl", "binance")], window=100)
|
||||
monitor.update("hl", "BTC", 50000.0)
|
||||
monitor.update("binance", "BTC", 50000.5)
|
||||
result = monitor.lead_lag("BTC")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pairs: list[tuple[str, str]] | None = None,
|
||||
window: int = 100,
|
||||
max_lag: int = 10,
|
||||
):
|
||||
self._window = window
|
||||
self._max_lag = max_lag
|
||||
self._pairs = pairs or [("hl", "binance"), ("hl", "bybit"), ("hl", "okx")]
|
||||
|
||||
# venue → coin → deque of mid prices
|
||||
self._prices: dict[str, dict[str, deque[float]]] = {}
|
||||
self._timestamps: dict[str, dict[str, deque[float]]] = {}
|
||||
|
||||
def update(self, venue: str, coin: str, price: float, timestamp: float):
|
||||
"""Record a mid price observation from a venue."""
|
||||
v = venue.lower()
|
||||
c = coin.upper()
|
||||
self._prices.setdefault(v, {}).setdefault(c, deque(maxlen=self._window)).append(price)
|
||||
self._timestamps.setdefault(v, {}).setdefault(c, deque(maxlen=self._window)).append(timestamp)
|
||||
|
||||
def lead_lag(self, coin: str, venue_a: str = "hl", venue_b: str = "binance") -> dict | None:
|
||||
"""Determine which venue leads by cross-correlation at various lags.
|
||||
|
||||
Returns {leading_venue: str, max_correlation: float, lag: int}
|
||||
Negative lag = venue_a leads, positive lag = venue_b leads.
|
||||
"""
|
||||
prices_a = list(self._prices.get(venue_a.lower(), {}).get(coin.upper(), []))
|
||||
prices_b = list(self._prices.get(venue_b.lower(), {}).get(coin.upper(), []))
|
||||
|
||||
min_len = min(len(prices_a), len(prices_b))
|
||||
if min_len < self._max_lag + 2:
|
||||
return None
|
||||
|
||||
a = np.array(prices_a[-min_len:])
|
||||
b = np.array(prices_b[-min_len:])
|
||||
|
||||
best_corr = -1.0
|
||||
best_lag = 0
|
||||
|
||||
for lag in range(-self._max_lag, self._max_lag + 1):
|
||||
if lag < 0:
|
||||
corr = np.corrcoef(a[-lag:], b[:lag])[0, 1] if lag < 0 else 0
|
||||
elif lag > 0:
|
||||
corr = np.corrcoef(a[:min_len - lag], b[lag:])[0, 1]
|
||||
else:
|
||||
corr = np.corrcoef(a, b)[0, 1]
|
||||
|
||||
if not np.isnan(corr) and abs(corr) > abs(best_corr):
|
||||
best_corr = float(corr)
|
||||
best_lag = lag
|
||||
|
||||
return {
|
||||
"leading_venue": venue_a if best_lag < 0 else venue_b if best_lag > 0 else "none",
|
||||
"correlation": round(best_corr, 4),
|
||||
"lag": best_lag,
|
||||
"samples": min_len,
|
||||
}
|
||||
|
||||
def spot_premium(self, coin: str, venue: str = "hl", spot_venue: str = "binance") -> dict | None:
|
||||
"""Compute the premium of venue over spot (basis proxy)."""
|
||||
v = self._prices.get(venue.lower(), {}).get(coin.upper())
|
||||
sv = self._prices.get(spot_venue.lower(), {}).get(coin.upper())
|
||||
if not v or not sv:
|
||||
return None
|
||||
|
||||
perp = v[-1]
|
||||
spot = sv[-1]
|
||||
basis_bps = (perp - spot) / spot * 10000 if spot > 0 else 0
|
||||
|
||||
return {
|
||||
"venue": venue,
|
||||
"spot_venue": spot_venue,
|
||||
"perp_price": perp,
|
||||
"spot_price": spot,
|
||||
"basis_bps": round(basis_bps, 2),
|
||||
}
|
||||
|
||||
def summary(self, coin: str) -> dict:
|
||||
"""Summary for a given coin across all venues."""
|
||||
result = {}
|
||||
for va, vb in self._pairs:
|
||||
ll = self.lead_lag(coin, va, vb)
|
||||
if ll:
|
||||
result[f"{va}_{vb}"] = ll
|
||||
|
||||
premium = self.spot_premium(coin)
|
||||
if premium:
|
||||
result["premium"] = premium
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Funding and basis carry monitor.
|
||||
|
||||
Tracks funding rates across Hyperliquid and estimates carry
|
||||
trade profitability. Signals when funding arbitrage is attractive.
|
||||
|
||||
Phase 4: informational only — no automated trading.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
from microstructure.funding import (
|
||||
funding_regime,
|
||||
funding_predictability,
|
||||
funding_carry_pnl,
|
||||
)
|
||||
|
||||
|
||||
class FundingBasisMonitor:
|
||||
"""Monitor funding rates and carry trade opportunities.
|
||||
|
||||
Usage:
|
||||
monitor = FundingBasisMonitor()
|
||||
monitor.update_funding("BTC", 0.0001)
|
||||
monitor.update_spot("BTC", 50000.0)
|
||||
monitor.update_perp("BTC", 50005.0)
|
||||
result = monitor.signal("BTC")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
funding_window: int = 1440, # 24h at 1 sample/min
|
||||
samples_per_hour: int = 60,
|
||||
):
|
||||
self._samples_per_hour = samples_per_hour
|
||||
self._funding: dict[str, deque[float]] = {}
|
||||
self._perp_prices: dict[str, deque[float]] = {}
|
||||
self._spot_prices: dict[str, deque[float]] = {}
|
||||
self._window = funding_window
|
||||
|
||||
def update_funding(self, coin: str, rate: float):
|
||||
"""Record an hourly funding rate."""
|
||||
self._funding.setdefault(coin.upper(), deque(maxlen=self._window)).append(rate)
|
||||
|
||||
def update_perp(self, coin: str, price: float):
|
||||
self._perp_prices.setdefault(coin.upper(), deque(maxlen=self._window)).append(price)
|
||||
|
||||
def update_spot(self, coin: str, price: float):
|
||||
self._spot_prices.setdefault(coin.upper(), deque(maxlen=self._window)).append(price)
|
||||
|
||||
def signal(self, coin: str) -> dict:
|
||||
"""Generate funding/basis signal for a coin."""
|
||||
c = coin.upper()
|
||||
funding_list = list(self._funding.get(c, []))
|
||||
perp_list = list(self._perp_prices.get(c, []))
|
||||
spot_list = list(self._spot_prices.get(c, []))
|
||||
|
||||
if not funding_list:
|
||||
return {"signal": "insufficient_data", "action": "none"}
|
||||
|
||||
regime = funding_regime(funding_list, window_hours=min(24, len(funding_list) // self._samples_per_hour),
|
||||
n_samples_per_hour=self._samples_per_hour)
|
||||
predictability = funding_predictability(funding_list)
|
||||
|
||||
basis = None
|
||||
if perp_list and spot_list:
|
||||
from microstructure.funding import basis_spread
|
||||
basis = basis_spread(perp_list, spot_list)
|
||||
|
||||
carry = funding_carry_pnl(
|
||||
funding_list,
|
||||
position_size=1.0,
|
||||
mark_prices=perp_list if perp_list else None,
|
||||
n_samples_per_hour=self._samples_per_hour,
|
||||
)
|
||||
|
||||
regime_name = regime.get("regime", "unknown")
|
||||
action = "none"
|
||||
|
||||
if regime_name in ("high_positive",) and predictability.get("is_momentum"):
|
||||
action = "consider_short" # shorts earn positive funding
|
||||
elif regime_name in ("high_negative",) and predictability.get("is_momentum"):
|
||||
action = "consider_long"
|
||||
|
||||
return {
|
||||
"signal": regime_name,
|
||||
"action": action,
|
||||
"funding_mean_annual_pct": regime.get("mean_annual_pct", 0),
|
||||
"momentum": predictability.get("momentum_strength", 0),
|
||||
"carry_cumulative_pnl": carry.get("cumulative_pnl", 0),
|
||||
"basis_current_bps": basis.get("current_basis_bps", 0) if basis else 0,
|
||||
"basis_mean_bps": basis.get("mean_basis_bps", 0) if basis else 0,
|
||||
}
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {coin: self.signal(coin) for coin in self._funding}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Liquidation risk overlay.
|
||||
|
||||
Monitors current positions, mark prices, and computes liquidation
|
||||
distance. Warns when positions approach liquidation threshold.
|
||||
|
||||
Integrates with live/treasury.py for position tracking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from live.treasury import Treasury
|
||||
|
||||
|
||||
class LiquidationRiskOverlay:
|
||||
"""Liquidation risk monitor for open positions.
|
||||
|
||||
Usage:
|
||||
overlay = LiquidationRiskOverlay(treasury=treasury)
|
||||
status = overlay.check("BTC")
|
||||
if status["warning"]:
|
||||
# reduce position or add margin
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
treasury: Treasury,
|
||||
warning_threshold_pct: float = 10.0,
|
||||
danger_threshold_pct: float = 5.0,
|
||||
critical_threshold_pct: float = 2.5,
|
||||
):
|
||||
self._treasury = treasury
|
||||
self._warning = warning_threshold_pct
|
||||
self._danger = danger_threshold_pct
|
||||
self._critical = critical_threshold_pct
|
||||
|
||||
def check(self, coin: str) -> dict:
|
||||
"""Check liquidation safety for a specific coin."""
|
||||
distance = self._treasury.liquidation_distance(coin.upper())
|
||||
|
||||
if distance >= self._warning or distance >= 1e9:
|
||||
level = "safe"
|
||||
elif distance >= self._danger:
|
||||
level = "warning"
|
||||
elif distance >= self._critical:
|
||||
level = "danger"
|
||||
else:
|
||||
level = "critical"
|
||||
|
||||
return {
|
||||
"coin": coin.upper(),
|
||||
"level": level,
|
||||
"distance_pct": round(min(distance, 999999), 2),
|
||||
"position": self._treasury.position(coin.upper()),
|
||||
"warning": level in ("warning", "danger", "critical"),
|
||||
"needs_action": level == "critical",
|
||||
}
|
||||
|
||||
def check_all(self) -> dict[str, dict]:
|
||||
positions = self._treasury.all_positions
|
||||
return {coin: self.check(coin) for coin, pos in positions.items() if abs(pos) > 0}
|
||||
|
||||
def pnl_at_liquidation(self, coin: str) -> float:
|
||||
"""Estimate realized PnL if position reaches liquidation price."""
|
||||
pos = self._treasury._positions.get(coin.upper())
|
||||
if not pos:
|
||||
return 0.0
|
||||
|
||||
entry = pos["entry_px"]
|
||||
size = pos["size"]
|
||||
side = pos["side"]
|
||||
|
||||
liq_price = self._treasury._liquidation.liquidation_price(
|
||||
entry, size, side, self._treasury.equity
|
||||
)
|
||||
|
||||
if side == "buy":
|
||||
return size * (liq_price - entry)
|
||||
else:
|
||||
return size * (entry - liq_price)
|
||||
|
||||
def recommended_action(self, coin: str) -> str:
|
||||
"""Recommend action based on liquidation distance."""
|
||||
status = self.check(coin)
|
||||
level = status["level"]
|
||||
|
||||
if level == "safe":
|
||||
return "none"
|
||||
elif level == "warning":
|
||||
return "reduce_position_25pct"
|
||||
elif level == "danger":
|
||||
return "reduce_position_50pct"
|
||||
else:
|
||||
return "close_all"
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"positions": self.check_all(),
|
||||
"worst_case": min(
|
||||
(self.check(c)["distance_pct"] for c in self._treasury.all_positions),
|
||||
default=float("inf")
|
||||
),
|
||||
"any_critical": any(
|
||||
self.check(c)["level"] == "critical"
|
||||
for c in self._treasury.all_positions
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Central treasury — position/capital limits, circuit breakers, PnL stops.
|
||||
|
||||
Single source of truth for all risk constraints in live trading.
|
||||
Integrates with sim/constraints.py for the constraint logic and adds
|
||||
live-specific bookkeeping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sim.constraints import (
|
||||
InventoryConstraint,
|
||||
FundingConstraint,
|
||||
FeeSchedule,
|
||||
LiquidationRisk,
|
||||
CircuitBreaker,
|
||||
)
|
||||
|
||||
|
||||
class Treasury:
|
||||
"""Central risk and capital management for live trading.
|
||||
|
||||
Tracks:
|
||||
- Current positions per asset
|
||||
- Realized and unrealized PnL
|
||||
- Daily trade counts
|
||||
- Circuit breaker state
|
||||
- Fee budget consumption
|
||||
|
||||
Usage:
|
||||
treasury = Treasury(initial_equity=10000.0)
|
||||
ok = treasury.can_open("BTC", side="buy", size=0.001, mark_price=50000.0)
|
||||
treasury.record_fill("BTC", side="buy", size=0.001, price=50000.0, fee=10.0)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial_equity: float = 10000.0,
|
||||
max_position_per_asset: float = 0.005,
|
||||
max_net_exposure: float = 0.01,
|
||||
max_daily_trades: int = 500,
|
||||
max_drawdown_pct: float = -10.0,
|
||||
max_toxic_rate: float = 0.4,
|
||||
cooldown_seconds: float = 300.0,
|
||||
maker_fee_pct: float = 0.0002,
|
||||
taker_fee_pct: float = 0.0005,
|
||||
maintenance_margin_pct: float = 0.03,
|
||||
):
|
||||
self._initial_equity = initial_equity
|
||||
self._realized_pnl: float = 0.0
|
||||
self._fees_paid: float = 0.0
|
||||
self._daily_trades: int = 0
|
||||
self._toxic_fills: int = 0
|
||||
self._api_errors: int = 0
|
||||
|
||||
# Positions tracked as {coin: {"side": "long"|"short", "size": float, "entry_px": float}}
|
||||
self._positions: dict[str, dict] = {}
|
||||
self._mark_prices: dict[str, float] = {}
|
||||
|
||||
self._circuit_breaker = CircuitBreaker(
|
||||
max_drawdown_pct=max_drawdown_pct,
|
||||
max_daily_trades=max_daily_trades,
|
||||
max_toxic_rate=max_toxic_rate,
|
||||
cooldown_seconds=cooldown_seconds,
|
||||
)
|
||||
|
||||
self._inventory = InventoryConstraint(
|
||||
max_long=max_position_per_asset,
|
||||
max_short=max_position_per_asset,
|
||||
max_net_exposure=max_net_exposure,
|
||||
)
|
||||
|
||||
self._fees = FeeSchedule(maker_fee_pct=maker_fee_pct, taker_fee_pct=taker_fee_pct)
|
||||
self._funding = FundingConstraint()
|
||||
self._liquidation = LiquidationRisk(maintenance_margin_pct=maintenance_margin_pct)
|
||||
|
||||
self._halted: bool = False
|
||||
self._halt_reason: str = ""
|
||||
self._halted_at: float = 0.0
|
||||
self._session_start: float = time.time()
|
||||
|
||||
# ── Position management ──────────────────────────────────
|
||||
|
||||
def can_open(self, coin: str, side: str, size: float, mark_price: float) -> dict:
|
||||
"""Check whether a new position can be opened.
|
||||
|
||||
Returns {allowed: bool, reason: str, fee_estimate: float}
|
||||
"""
|
||||
if self._halted:
|
||||
return {"allowed": False, "reason": self._halt_reason, "fee_estimate": 0.0}
|
||||
|
||||
pos = self._positions.get(coin.upper(), {})
|
||||
current_size = pos.get("size", 0.0) if pos.get("side") == side else -(pos.get("size", 0.0))
|
||||
new_size = current_size + size
|
||||
|
||||
limits = self._inventory.check(
|
||||
max(0.0, new_size) if side == "buy" else max(0.0, current_size),
|
||||
max(0.0, -new_size) if side == "sell" else max(0.0, -current_size),
|
||||
)
|
||||
|
||||
if not limits["long_ok"]:
|
||||
return {"allowed": False, "reason": "long limit exceeded", "fee_estimate": 0.0}
|
||||
if not limits["short_ok"]:
|
||||
return {"allowed": False, "reason": "short limit exceeded", "fee_estimate": 0.0}
|
||||
|
||||
fee = self._fees.maker_fee(size * mark_price)
|
||||
return {"allowed": True, "reason": "ok", "fee_estimate": round(fee, 6)}
|
||||
|
||||
def record_fill(self, coin: str, side: str, size: float, price: float, fee: float, pnl: float = 0.0):
|
||||
"""Record a filled trade."""
|
||||
c = coin.upper()
|
||||
pos = self._positions.get(c)
|
||||
|
||||
is_close = pos and pos.get("side") != side
|
||||
if is_close:
|
||||
self._realized_pnl += pnl
|
||||
pos["size"] -= size
|
||||
if pos["size"] <= 1e-10:
|
||||
del self._positions[c]
|
||||
else:
|
||||
if not pos:
|
||||
self._positions[c] = {"side": side, "size": size, "entry_px": price}
|
||||
else:
|
||||
total = pos["size"] + size
|
||||
pos["entry_px"] = (pos["entry_px"] * pos["size"] + price * size) / total if total > 0 else price
|
||||
pos["size"] = total
|
||||
|
||||
self._fees_paid += fee
|
||||
self._daily_trades += 1
|
||||
|
||||
self._check_breakers()
|
||||
|
||||
def record_toxic_fill(self):
|
||||
self._toxic_fills += 1
|
||||
|
||||
def record_api_error(self):
|
||||
self._api_errors += 1
|
||||
|
||||
def update_mark_price(self, coin: str, price: float):
|
||||
self._mark_prices[coin.upper()] = price
|
||||
|
||||
# ── Position queries ─────────────────────────────────────
|
||||
|
||||
def position(self, coin: str) -> float:
|
||||
"""Signed position (positive = long)."""
|
||||
pos = self._positions.get(coin.upper(), {})
|
||||
raw = pos.get("size", 0.0)
|
||||
return raw if pos.get("side") == "buy" else -raw
|
||||
|
||||
def position_size(self, coin: str) -> float:
|
||||
"""Absolute position size."""
|
||||
return abs(self.position(coin))
|
||||
|
||||
@property
|
||||
def all_positions(self) -> dict[str, float]:
|
||||
return {c: self.position(c) for c in self._positions}
|
||||
|
||||
@property
|
||||
def net_exposure(self) -> float:
|
||||
return sum(abs(p) for p in self.all_positions.values())
|
||||
|
||||
# ── PnL ──────────────────────────────────────────────────
|
||||
|
||||
def unrealized_pnl(self) -> float:
|
||||
pnl = 0.0
|
||||
for coin, pos in self._positions.items():
|
||||
mark = self._mark_prices.get(coin, pos.get("entry_px", 0))
|
||||
if pos["side"] == "buy":
|
||||
pnl += pos["size"] * (mark - pos["entry_px"])
|
||||
else:
|
||||
pnl += pos["size"] * (pos["entry_px"] - mark)
|
||||
return round(pnl, 4)
|
||||
|
||||
def total_pnl(self) -> float:
|
||||
return self._realized_pnl + self.unrealized_pnl() - self._fees_paid
|
||||
|
||||
def pnl_pct(self) -> float:
|
||||
return self.total_pnl() / self._initial_equity * 100 if self._initial_equity > 0 else 0
|
||||
|
||||
@property
|
||||
def equity(self) -> float:
|
||||
return self._initial_equity + self.total_pnl()
|
||||
|
||||
# ── Liquidation risk ─────────────────────────────────────
|
||||
|
||||
def liquidation_distance(self, coin: str) -> float:
|
||||
"""Percentage distance to liquidation."""
|
||||
pos = self._positions.get(coin.upper())
|
||||
if not pos:
|
||||
return float("inf")
|
||||
|
||||
mark = self._mark_prices.get(coin.upper(), pos["entry_px"])
|
||||
liq = self._liquidation.liquidation_price(
|
||||
entry_price=pos["entry_px"],
|
||||
size=pos["size"],
|
||||
position_side=pos["side"],
|
||||
wallet_balance=self.equity,
|
||||
)
|
||||
return self._liquidation.distance_to_liquidation_pct(mark, liq, pos["side"])
|
||||
|
||||
def is_liquidation_safe(self, coin: str, threshold_pct: float = 5.0) -> bool:
|
||||
return self.liquidation_distance(coin) >= threshold_pct
|
||||
|
||||
# ── Circuit breaker ──────────────────────────────────────
|
||||
|
||||
def _check_breakers(self):
|
||||
if self._halted:
|
||||
return
|
||||
toxic_rate = self._toxic_fills / max(self._daily_trades, 1)
|
||||
result = self._circuit_breaker.evaluate({
|
||||
"pnl_pct": round(self.pnl_pct(), 2),
|
||||
"daily_trades": self._daily_trades,
|
||||
"toxic_rate": toxic_rate,
|
||||
"api_errors": self._api_errors,
|
||||
})
|
||||
if result.get("tripped"):
|
||||
self._halted = True
|
||||
self._halt_reason = result.get("reason", "unknown")
|
||||
self._halted_at = time.time()
|
||||
|
||||
def is_halted(self) -> bool:
|
||||
if self._halted:
|
||||
elapsed = time.time() - self._halted_at
|
||||
if elapsed > self._circuit_breaker.cooldown_seconds:
|
||||
self._halted = False
|
||||
self._halt_reason = ""
|
||||
self._daily_trades = 0
|
||||
self._toxic_fills = 0
|
||||
return self._halted
|
||||
|
||||
@property
|
||||
def halt_reason(self) -> str:
|
||||
return self._halt_reason
|
||||
|
||||
# ── Stats ────────────────────────────────────────────────
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"equity": round(self.equity, 2),
|
||||
"realized_pnl": round(self._realized_pnl, 4),
|
||||
"unrealized_pnl": self.unrealized_pnl(),
|
||||
"total_pnl": self.total_pnl(),
|
||||
"pnl_pct": round(self.pnl_pct(), 2),
|
||||
"fees_paid": round(self._fees_paid, 4),
|
||||
"daily_trades": self._daily_trades,
|
||||
"toxic_fills": self._toxic_fills,
|
||||
"api_errors": self._api_errors,
|
||||
"positions": {c: round(v, 6) for c, v in self.all_positions.items()},
|
||||
"net_exposure": round(self.net_exposure, 6),
|
||||
"halted": self._halted,
|
||||
"uptime_hours": round((time.time() - self._session_start) / 3600, 1),
|
||||
}
|
||||
Reference in New Issue
Block a user