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:
ramseshk
2026-08-07 14:47:08 +08:00
parent 639dd4fb6d
commit 4f66ef36a9
13 changed files with 1229 additions and 0 deletions
View File
+116
View File
@@ -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
View File
+218
View File
@@ -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
View File
+120
View File
@@ -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
+99
View File
@@ -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}
+109
View File
@@ -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
),
}
+255
View File
@@ -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),
}