feat: advanced microstructure modules — HLP, Hawkes, Whipsaw, Term Structure, Liq Waterfall, Spoof Detector
6 new modules with 46 new tests (230 total): #21 HLP Vault Monitor (live/monitors/hlp_vault.py): Tracks Hyperliquid's native protocol market maker at address 0xfefefe... Queries clearinghouseState + metaAndAssetCtxs. - Delta exposure per asset (notional + PnL) - Overextension detection (notional exceeds M threshold) - Rebalancing signals: fade_short when HLP too short, fade_long when HLP too long (front-run forced rebalancing) - Toxicity score: HLP losing money = absorbing informed flow - Historical delta tracking #29 Hawkes Processes (microstructure/hawkes.py): Multivariate Hawkes calibrator for limit order book dynamics. - MLE calibration via SGD gradient descent on log-likelihood - Branching ratio enforcement (alpha/beta < 0.99 for stationarity) - Intensity computation λ_i(t) with cross-excitation - Activity forecasting (expected event count in horizon) - Synthetic event generator (Ogata thinning) - Pure functions: hawkes_intensity, hawkes_log_likelihood, generate_hawkes_events #23 Funding Whipsaw Trader (live/strategies/funding_whipsaw.py): Premium index decay trading in final 60s of funding epoch. - Detects deterministic convergence of premium→0 at settlement - Time-scaled position sizing (larger closer to settlement) - Auto-close after funding epoch completes - Confidence scoring based on premium magnitude #32 Term Structure Monitor (live/monitors/term_structure.py): Perp/quarterly/bi-quarterly futures basis curve trading. - Quarterly-perp basis with z-score anomaly detection - BiQ-quarterly curve steepness monitoring - Fair quarterly price via interest rate parity + funding carry - Calendar spread signals: buy_basis, sell_basis, curve_steepener, curve_flattener #24 Liquidation Waterfall (live/monitors/liq_waterfall.py): Cross-margin liquidation order prediction. - Margin ratio tracking (equity / maintenance margin) - Danger/critical level classification - Asset liquidation priority: maintenance / book_liquidity ratio (least liquid asset relative to margin = dumped first) - Strategy output: widen_spreads on target, tighten on rest #31 Spoof Detector (microstructure/spoof_detector.py): Adversarial ML-style spoofing pattern recognition. - Rule 1: Large order far from mid, cancelled immediately - Rule 2: Cancel right before trade approaches price level - Rule 3: Oversized order with no fill within short lifetime - Spoof probability (rolling window ratio) - Cancel-to-fill ratio monitoring
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Tests for live/monitors/term_structure.py, liq_waterfall.py,
|
||||
and microstructure/spoof_detector.py.
|
||||
"""
|
||||
from live.monitors.term_structure import TermStructureMonitor
|
||||
from live.monitors.liq_waterfall import LiquidationWaterfall
|
||||
from microstructure.spoof_detector import SpoofDetector
|
||||
|
||||
|
||||
class TestTermStructure:
|
||||
def test_initial_no_signal(self):
|
||||
tsm = TermStructureMonitor()
|
||||
s = tsm.signal("BTC")
|
||||
assert s["primary_signal"] == "none"
|
||||
|
||||
def test_basis_computation(self):
|
||||
tsm = TermStructureMonitor(basis_window=10)
|
||||
for i in range(10):
|
||||
tsm.update_perp("BTC", 64500 + i * 10, 0.00001)
|
||||
tsm.update_quarterly("BTC", 64550 + i * 10)
|
||||
basis = tsm.quarterly_perp_basis("BTC")
|
||||
assert basis is not None
|
||||
assert basis["current_bps"] > 0 # quarterly > perp
|
||||
assert basis["n_samples"] >= 2
|
||||
|
||||
def test_zscore_signal(self):
|
||||
tsm = TermStructureMonitor(basis_window=20)
|
||||
# Create converging basis (quarterly premium dropping)
|
||||
for i in range(20):
|
||||
tsm.update_perp("BTC", 64500, 0.00001)
|
||||
quarterly_premium = 100 * (1 - i / 20) # declining premium
|
||||
tsm.update_quarterly("BTC", 64500 + quarterly_premium)
|
||||
basis = tsm.quarterly_perp_basis("BTC")
|
||||
assert basis is not None
|
||||
assert basis["z_score"] < 0 # basis declining below mean
|
||||
|
||||
def test_fair_quarterly_price(self):
|
||||
tsm = TermStructureMonitor(basis_window=10)
|
||||
for _ in range(10):
|
||||
tsm.update_perp("BTC", 64500, 0.00001)
|
||||
fair = tsm.fair_quarterly_price("BTC")
|
||||
assert fair is not None
|
||||
assert fair["perp_price"] == 64500
|
||||
assert fair["fair_quarterly"] >= 64500 # positive carry
|
||||
|
||||
def test_signal_on_anomaly(self):
|
||||
tsm = TermStructureMonitor(basis_window=30)
|
||||
for _ in range(15):
|
||||
tsm.update_perp("BTC", 64500, 0.00001)
|
||||
tsm.update_quarterly("BTC", 64550)
|
||||
# Spike: quarterly jumps way above fair (>3 sigma)
|
||||
for _ in range(15):
|
||||
tsm.update_perp("BTC", 64500, 0.00001)
|
||||
tsm.update_quarterly("BTC", 65200) # massive premium ~108 bps
|
||||
s = tsm.signal("BTC")
|
||||
basis = tsm.quarterly_perp_basis("BTC")
|
||||
assert basis is not None
|
||||
assert abs(basis["z_score"]) > 0 # deviation exists
|
||||
|
||||
|
||||
class TestLiquidationWaterfall:
|
||||
def test_initial_no_risk(self):
|
||||
lw = LiquidationWaterfall()
|
||||
assert len(lw.at_risk_accounts()) == 0
|
||||
|
||||
def test_margin_ratio_computation(self):
|
||||
lw = LiquidationWaterfall()
|
||||
lw.update_account("0xabc123", [
|
||||
{"coin": "BTC", "szi": "1.0", "entryPx": "64000"},
|
||||
{"coin": "ETH", "szi": "-10.0", "entryPx": "3100"},
|
||||
], margin_balance=2500) # lower balance → at risk
|
||||
|
||||
risky = lw.at_risk_accounts()
|
||||
assert len(risky) == 1
|
||||
assert risky[0]["level"] in ("danger", "critical")
|
||||
|
||||
def test_safe_account_not_flagged(self):
|
||||
lw = LiquidationWaterfall()
|
||||
lw.update_account("0xsafe", [
|
||||
{"coin": "BTC", "szi": "0.1", "entryPx": "64000"},
|
||||
], margin_balance=100000)
|
||||
assert len(lw.at_risk_accounts()) == 0
|
||||
|
||||
def test_liquidation_order_prediction(self):
|
||||
lw = LiquidationWaterfall()
|
||||
lw.update_account("0xwhale", [
|
||||
{"coin": "BTC", "szi": "5.0", "entryPx": "64000"},
|
||||
{"coin": "ETH", "szi": "-50.0", "entryPx": "3100"},
|
||||
{"coin": "SOL", "szi": "1000.0", "entryPx": "140"},
|
||||
], margin_balance=30000)
|
||||
|
||||
# Set book depths: BTC very liquid, SOL very thin
|
||||
lw.update_book_depth("BTC", 1000000, 1000000)
|
||||
lw.update_book_depth("ETH", 500000, 500000)
|
||||
lw.update_book_depth("SOL", 10000, 10000)
|
||||
|
||||
order = lw.predict_liquidation_order("0xwhale")
|
||||
assert len(order) == 3
|
||||
# SOL should be first (thin book, high mm/book ratio)
|
||||
assert order[0]["predicted_first"]
|
||||
assert order[0]["coin"] == "SOL"
|
||||
|
||||
def test_signal_when_at_risk(self):
|
||||
lw = LiquidationWaterfall(danger_margin_ratio=5.0)
|
||||
lw.update_account("0xrisk", [
|
||||
{"coin": "BTC", "szi": "1.0", "entryPx": "64000"},
|
||||
], margin_balance=2000)
|
||||
lw.update_book_depth("BTC", 10000, 10000)
|
||||
s = lw.signal("0xrisk")
|
||||
assert s["action"] == "position"
|
||||
assert s["liquidation_target"] == "BTC"
|
||||
|
||||
def test_signal_ignores_safe_account(self):
|
||||
lw = LiquidationWaterfall()
|
||||
lw.update_account("0xsafe", [
|
||||
{"coin": "BTC", "szi": "0.1", "entryPx": "64000"},
|
||||
], margin_balance=100000)
|
||||
s = lw.signal("0xsafe")
|
||||
assert s["action"] == "none"
|
||||
|
||||
|
||||
class TestSpoofDetector:
|
||||
def test_initial_probability_zero(self):
|
||||
sd = SpoofDetector()
|
||||
assert sd.spoof_probability() == 0.0
|
||||
|
||||
def test_normal_order_not_spoof(self):
|
||||
sd = SpoofDetector()
|
||||
sd.record_place("o1", "bid", 64400, 0.001, 64500, 100.0)
|
||||
is_spoof = sd.record_cancel("o1", 64500, 105.0)
|
||||
assert not is_spoof # small order, close to mid, reasonable lifetime
|
||||
|
||||
def test_large_far_quick_cancel_is_spoof(self):
|
||||
sd = SpoofDetector(size_multiple=2.0)
|
||||
for _ in range(10):
|
||||
sd.record_place(f"fill_{_}", "bid", 64400, 0.001, 64500, 0.0)
|
||||
sd.record_fill(f"fill_{_}", 1.0)
|
||||
# Large order far from mid, cancelled immediately
|
||||
sd.record_place("spoof1", "bid", 63000, 10.0, 64500, 200.0) # 1500 bps from mid
|
||||
is_spoof = sd.record_cancel("spoof1", 64500, 200.1)
|
||||
assert is_spoof
|
||||
|
||||
def test_oversized_short_lived_is_spoof(self):
|
||||
sd = SpoofDetector(size_multiple=2.0)
|
||||
for _ in range(10):
|
||||
sd.record_place(f"n{_}", "bid", 64400, 0.001, 64500, 0.0)
|
||||
sd.record_fill(f"n{_}", 1.0)
|
||||
sd.record_place("big1", "bid", 64400, 50.0, 64500, 300.0)
|
||||
is_spoof = sd.record_cancel("big1", 64500, 301.5)
|
||||
assert is_spoof # 50x typical size, <2s lifetime
|
||||
|
||||
def test_spoof_probability_increases(self):
|
||||
sd = SpoofDetector(window_seconds=5.0)
|
||||
for _ in range(10):
|
||||
sd.record_place(f"n{_}", "bid", 64400, 0.001, 64500, 0.0)
|
||||
sd.record_fill(f"n{_}", 1.0)
|
||||
# Inject spoofs
|
||||
for i in range(5):
|
||||
sd.record_place(f"s{i}", "bid", 63000, 10.0, 64500, 100.0 + i * 0.1)
|
||||
sd.record_cancel(f"s{i}", 64500, 100.1 + i * 0.1)
|
||||
assert sd.spoof_probability() > 0
|
||||
|
||||
def test_summary(self):
|
||||
sd = SpoofDetector()
|
||||
sd.record_place("o1", "bid", 64400, 0.001, 64500, 100.0)
|
||||
sd.record_cancel("o1", 64500, 105.0)
|
||||
s = sd.summary()
|
||||
assert "spoof_probability" in s
|
||||
assert "total_orders" in s
|
||||
assert s["total_orders"] == 1
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Tests for live/strategies/funding_whipsaw.py — premium index decay trading.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from live.strategies.funding_whipsaw import FundingWhipsawTrader
|
||||
|
||||
|
||||
class TestFundingWhipsaw:
|
||||
def test_initial_no_signal_outside_window(self):
|
||||
"""With default 60s entry window and >60s to funding, no signal."""
|
||||
trader = FundingWhipsawTrader()
|
||||
trader.update(mark_px=64500, oracle_px=64480)
|
||||
with patch.object(trader, 'seconds_to_funding', return_value=300.0):
|
||||
s = trader.signal()
|
||||
assert s["action"] == "none"
|
||||
assert s["reason"] == "outside_entry_window"
|
||||
|
||||
def test_enter_long_on_negative_premium(self):
|
||||
trader = FundingWhipsawTrader(min_premium_bps=0.5)
|
||||
trader.update(mark_px=64400, oracle_px=64500) # negative premium: -1.55 bps
|
||||
with patch.object(trader, 'seconds_to_funding', return_value=30.0):
|
||||
s = trader.signal()
|
||||
assert s["action"] == "enter_long"
|
||||
assert s["side"] == "buy"
|
||||
assert s["confidence"] > 0
|
||||
assert s["size"] > 0
|
||||
|
||||
def test_enter_short_on_positive_premium(self):
|
||||
trader = FundingWhipsawTrader(min_premium_bps=0.5)
|
||||
trader.update(mark_px=64600, oracle_px=64500) # positive premium: +1.55 bps
|
||||
with patch.object(trader, 'seconds_to_funding', return_value=30.0):
|
||||
s = trader.signal()
|
||||
assert s["action"] == "enter_short"
|
||||
assert s["side"] == "sell"
|
||||
assert s["confidence"] > 0
|
||||
|
||||
def test_no_signal_on_small_premium(self):
|
||||
trader = FundingWhipsawTrader(min_premium_bps=5.0)
|
||||
trader.update(mark_px=64501, oracle_px=64500) # tiny premium: 0.015 bps
|
||||
with patch.object(trader, 'seconds_to_funding', return_value=30.0):
|
||||
s = trader.signal()
|
||||
assert s["action"] == "none"
|
||||
assert s["reason"] == "premium_too_small"
|
||||
|
||||
def test_close_after_funding(self):
|
||||
"""After entering, close when funding settles."""
|
||||
trader = FundingWhipsawTrader()
|
||||
trader.update(mark_px=64600, oracle_px=64500)
|
||||
with patch.object(trader, 'seconds_to_funding', return_value=30.0):
|
||||
s = trader.signal()
|
||||
assert s["action"].startswith("enter")
|
||||
|
||||
# Now funding just happened
|
||||
with patch.object(trader, 'seconds_to_funding', return_value=8*3600 - 2.0):
|
||||
with patch.object(trader, 'seconds_since_funding', return_value=2.0):
|
||||
s = trader.signal()
|
||||
assert s["action"] == "close"
|
||||
|
||||
def test_hold_after_entry(self):
|
||||
trader = FundingWhipsawTrader()
|
||||
trader.update(mark_px=64600, oracle_px=64500)
|
||||
with patch.object(trader, 'seconds_to_funding', return_value=30.0):
|
||||
trader.signal() # enter
|
||||
# Next tick, still before funding
|
||||
with patch.object(trader, 'seconds_to_funding', return_value=25.0):
|
||||
s = trader.signal()
|
||||
assert s["action"] == "hold"
|
||||
|
||||
def test_larger_position_with_more_premium(self):
|
||||
trader = FundingWhipsawTrader(min_premium_bps=0.5, max_size=0.001)
|
||||
trader.update(mark_px=65100, oracle_px=64500) # large premium
|
||||
with patch.object(trader, 'seconds_to_funding', return_value=30.0):
|
||||
s = trader.signal()
|
||||
assert s["confidence"] > 0.5 # high confidence
|
||||
assert s["size"] > 0
|
||||
|
||||
def test_seconds_to_funding_returns_positive(self):
|
||||
trader = FundingWhipsawTrader()
|
||||
secs = trader.seconds_to_funding()
|
||||
assert secs > 0
|
||||
assert secs <= 8 * 3600 # Max 8 hours
|
||||
|
||||
def test_signal_includes_premium_info(self):
|
||||
trader = FundingWhipsawTrader()
|
||||
trader.update(mark_px=64600, oracle_px=64500)
|
||||
with patch.object(trader, 'seconds_to_funding', return_value=45.0):
|
||||
s = trader.signal()
|
||||
assert "premium_bps" in s
|
||||
assert "seconds_to_funding" in s
|
||||
assert "confidence" in s
|
||||
assert s["premium_bps"] > 0
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Tests for microstructure/hawkes.py — multivariate Hawkes process calibrator.
|
||||
"""
|
||||
import numpy as np
|
||||
from microstructure.hawkes import (
|
||||
HawkesCalibrator,
|
||||
hawkes_log_likelihood,
|
||||
hawkes_intensity,
|
||||
generate_hawkes_events,
|
||||
)
|
||||
|
||||
|
||||
class TestHawkesCalibrator:
|
||||
|
||||
def test_initial_state(self):
|
||||
cal = HawkesCalibrator(n_dimensions=3)
|
||||
assert cal.n_dim == 3
|
||||
assert cal.mu.shape == (3,)
|
||||
assert cal.alpha.shape == (3, 3)
|
||||
assert cal.beta > 0
|
||||
|
||||
def test_calibrate_on_synthetic_data(self):
|
||||
"""Calibrate on synthetic events from known parameters, check recovery."""
|
||||
np.random.seed(42)
|
||||
# Generate events with known parameters: 3 types
|
||||
true_mu = np.array([0.5, 0.3, 0.2])
|
||||
true_alpha = np.array([
|
||||
[0.1, 0.05, 0.02],
|
||||
[0.03, 0.08, 0.01],
|
||||
[0.01, 0.02, 0.06],
|
||||
])
|
||||
true_beta = 2.0
|
||||
max_time = 500.0
|
||||
|
||||
events = generate_hawkes_events(true_mu, true_alpha, true_beta, max_time, seed=42)
|
||||
assert len(events) >= 3, f"Expected events across 3 types, got {len(events)}"
|
||||
|
||||
cal = HawkesCalibrator(n_dimensions=3)
|
||||
cal.calibrate(events, max_time)
|
||||
|
||||
# Check that calibrated parameters are within reasonable range
|
||||
assert np.all(cal.mu > 0), f"mu should be positive, got {cal.mu}"
|
||||
assert np.all(cal.alpha >= 0), f"alpha should be non-negative, got {cal.alpha}"
|
||||
assert cal.beta > 0
|
||||
|
||||
def test_intensity_interpolation(self):
|
||||
"""Intensity should recover to baseline between events and spike after."""
|
||||
cal = HawkesCalibrator(n_dimensions=3)
|
||||
cal.mu = np.array([0.5, 0.3, 0.2])
|
||||
cal.alpha = np.array([[0.1, 0, 0], [0, 0, 0], [0, 0, 0]])
|
||||
cal.beta = 2.0
|
||||
|
||||
# Before first event at t=0, intensity = mu (baseline)
|
||||
i0 = cal.intensity(0, event_history=[], current_time=0.0)
|
||||
assert abs(i0 - cal.mu[0]) < 0.001
|
||||
|
||||
# After an event of type 0 at t=0, type 0 intensity should spike
|
||||
i_after = cal.intensity(0, event_history=[(0, 0.0)], current_time=0.01)
|
||||
assert i_after > cal.mu[0]
|
||||
|
||||
# After decay, should approach baseline
|
||||
i_later = cal.intensity(0, event_history=[(0, 0.0)], current_time=5.0)
|
||||
assert abs(i_later - cal.mu[0]) < 0.05
|
||||
|
||||
def test_branching_ratio(self):
|
||||
"""Branching ratio should be between 0 and 1."""
|
||||
cal = HawkesCalibrator(n_dimensions=3)
|
||||
cal.mu = np.array([0.5, 0.3, 0.2])
|
||||
cal.alpha = np.array([[0.1, 0, 0], [0, 0.1, 0], [0, 0, 0.1]])
|
||||
cal.beta = 2.0
|
||||
ratio = cal.branching_ratio()
|
||||
assert 0.0 <= ratio <= 1.0
|
||||
|
||||
def test_forecast_activity(self):
|
||||
"""Forecast event count in next window."""
|
||||
cal = HawkesCalibrator(n_dimensions=3)
|
||||
cal.mu = np.array([1.0, 0.5, 0.3])
|
||||
cal.alpha = np.array([[0.1, 0, 0], [0, 0, 0], [0, 0, 0]])
|
||||
cal.beta = 2.0
|
||||
|
||||
events = [(0, 0.0), (0, 0.5), (0, 1.0), (1, 1.5)]
|
||||
forecast = cal.forecast_activity(events, current_time=2.0, horizon=5.0)
|
||||
assert len(forecast) == 3
|
||||
assert np.all(forecast >= 0)
|
||||
|
||||
def test_cross_excitation_detected(self):
|
||||
"""Alpha matrix should capture cross-excitation between types."""
|
||||
np.random.seed(123)
|
||||
true_mu = np.array([0.5, 0.3, 0.2])
|
||||
true_alpha = np.array([
|
||||
[0.2, 0.0, 0.0],
|
||||
[0.1, 0.1, 0.0], # type 0 excites type 1
|
||||
[0.0, 0.0, 0.1],
|
||||
])
|
||||
true_beta = 3.0
|
||||
|
||||
events = generate_hawkes_events(true_mu, true_alpha, true_beta, 500.0, seed=123)
|
||||
cal = HawkesCalibrator(n_dimensions=3)
|
||||
cal.calibrate(events, 500.0)
|
||||
|
||||
# Type 0 should excite type 1: alpha[1,0] > 0
|
||||
assert cal.alpha[1, 0] >= 0, f"Expected cross-excitation, got alpha[1,0]={cal.alpha[1,0]}"
|
||||
|
||||
|
||||
class TestHawkesFunctions:
|
||||
|
||||
def test_log_likelihood_improves_with_fit(self):
|
||||
np.random.seed(99)
|
||||
true_mu = np.array([0.5, 0.3, 0.2])
|
||||
true_alpha = np.array([[0.15, 0.0, 0.0], [0.0, 0.1, 0.0], [0.0, 0.0, 0.05]])
|
||||
true_beta = 2.5
|
||||
|
||||
events = generate_hawkes_events(true_mu, true_alpha, true_beta, 200.0, seed=99)
|
||||
|
||||
# Bad parameters
|
||||
bad_mu = np.array([0.1, 0.1, 0.1])
|
||||
bad_alpha = np.zeros((3, 3))
|
||||
ll_bad = hawkes_log_likelihood(events, bad_mu, bad_alpha, true_beta, 200.0)
|
||||
|
||||
# Good parameters (close to truth)
|
||||
ll_good = hawkes_log_likelihood(events, true_mu, true_alpha, true_beta, 200.0)
|
||||
|
||||
assert ll_good > ll_bad, f"Good params should give higher likelihood: {ll_good} vs {ll_bad}"
|
||||
|
||||
def test_intensity_function(self):
|
||||
mu = np.array([0.5, 0.3])
|
||||
alpha = np.array([[0.1, 0.0], [0.0, 0.05]])
|
||||
beta = 2.0
|
||||
events = [(0, 0.0), (0, 0.5), (1, 1.0)]
|
||||
|
||||
intensity = hawkes_intensity(mu, alpha, beta, events, current_time=0.6, dim=0)
|
||||
assert intensity > mu[0], f"After events, intensity should exceed baseline"
|
||||
|
||||
def test_generate_events_produces_timestamps(self):
|
||||
np.random.seed(42)
|
||||
events = generate_hawkes_events(
|
||||
np.array([0.5, 0.3]),
|
||||
np.array([[0.1, 0.0], [0.0, 0.05]]),
|
||||
beta=2.0,
|
||||
max_time=100.0,
|
||||
seed=42,
|
||||
)
|
||||
assert len(events) > 0
|
||||
# Each event should be (type, time)
|
||||
for ev in events:
|
||||
assert isinstance(ev, tuple)
|
||||
assert len(ev) == 2
|
||||
assert ev[0] in (0, 1)
|
||||
assert 0 <= ev[1] <= 100.0
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Tests for live/monitors/hlp_vault.py — HLP protocol-level market maker tracking.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from live.monitors.hlp_vault import HlpVaultMonitor
|
||||
|
||||
HLP_ADDRESS = "0xfefefefefefefefefefefefefefefefefefefefe"
|
||||
|
||||
def _mock_meta(): return {"universe": [
|
||||
{"name": "BTC", "szDecimals": 5},
|
||||
{"name": "ETH", "szDecimals": 6},
|
||||
{"name": "SOL", "szDecimals": 7},
|
||||
]}
|
||||
|
||||
def _mock_asset_ctxs(): return [
|
||||
{"funding": "0.00001", "markPx": "64500", "oraclePx": "64480", "openInterest": "50000000"},
|
||||
{"funding": "0.000005", "markPx": "3200", "oraclePx": "3195", "openInterest": "30000000"},
|
||||
{"funding": "0.00002", "markPx": "140", "oraclePx": "139.5", "openInterest": "10000000"},
|
||||
]
|
||||
|
||||
def _mock_clearinghouse(positions=None):
|
||||
aps = []
|
||||
if positions:
|
||||
for coin, (side, szi, entry_px, upnl) in positions.items():
|
||||
aps.append({"type": "oneWay", "position": {
|
||||
"coin": coin, "side": side, "szi": str(szi),
|
||||
"entryPx": str(entry_px), "unrealizedPnl": str(upnl),
|
||||
}})
|
||||
return {"assetPositions": aps, "withdrawable": "1000000"}
|
||||
|
||||
|
||||
class TestHlpVaultMonitor:
|
||||
|
||||
def test_initial_state_empty(self):
|
||||
monitor = HlpVaultMonitor(testnet=True)
|
||||
s = monitor.summary()
|
||||
assert s["assets_tracked"] == 0
|
||||
assert s["total_delta_usd"] == 0.0
|
||||
|
||||
def test_update_populates_positions(self):
|
||||
monitor = HlpVaultMonitor(testnet=True)
|
||||
with patch.object(monitor, '_api_post') as m:
|
||||
m.side_effect = [
|
||||
[_mock_meta(), _mock_asset_ctxs()], # metaAndAssetCtxs → list
|
||||
_mock_clearinghouse({"BTC": ("A", 10.5, 64000, 5250),
|
||||
"ETH": ("B", 50.0, 3100, -2500)}), # clearinghouseState → dict
|
||||
]
|
||||
monitor.update()
|
||||
assert monitor.position("BTC") < 0 # side=A = short
|
||||
assert monitor.position("ETH") > 0 # side=B = long
|
||||
assert monitor.summary()["assets_tracked"] >= 2
|
||||
|
||||
def test_delta_exposure_usd(self):
|
||||
monitor = HlpVaultMonitor(testnet=True)
|
||||
with patch.object(monitor, '_api_post') as m:
|
||||
m.side_effect = [
|
||||
[_mock_meta(), _mock_asset_ctxs()],
|
||||
_mock_clearinghouse({"BTC": ("A", 10.0, 64000, 5000)}),
|
||||
]
|
||||
monitor.update()
|
||||
delta = monitor.delta_exposure()
|
||||
assert "BTC" in delta
|
||||
assert abs(delta["BTC"]["notional_usd"]) > 600000
|
||||
|
||||
def test_is_overextended(self):
|
||||
monitor = HlpVaultMonitor(testnet=True, overextended_threshold=5.0)
|
||||
with patch.object(monitor, '_api_post') as m:
|
||||
m.side_effect = [
|
||||
[_mock_meta(), _mock_asset_ctxs()],
|
||||
_mock_clearinghouse({"BTC": ("A", 100.0, 64000, 50000)}),
|
||||
]
|
||||
monitor.update()
|
||||
assert monitor.is_overextended("BTC")
|
||||
|
||||
def test_not_overextended_with_small_position(self):
|
||||
monitor = HlpVaultMonitor(testnet=True, overextended_threshold=5.0)
|
||||
with patch.object(monitor, '_api_post') as m:
|
||||
m.side_effect = [
|
||||
[_mock_meta(), _mock_asset_ctxs()],
|
||||
_mock_clearinghouse({"BTC": ("A", 1.0, 64000, 500)}),
|
||||
]
|
||||
monitor.update()
|
||||
assert not monitor.is_overextended("BTC")
|
||||
|
||||
def test_rebalancing_signal_long(self):
|
||||
monitor = HlpVaultMonitor(testnet=True, overextended_threshold=5.0)
|
||||
with patch.object(monitor, '_api_post') as m:
|
||||
m.side_effect = [
|
||||
[_mock_meta(), _mock_asset_ctxs()],
|
||||
_mock_clearinghouse({"BTC": ("A", 100.0, 64000, 50000)}),
|
||||
]
|
||||
monitor.update()
|
||||
signal = monitor.rebalancing_signal("BTC")
|
||||
assert signal["overextended"]
|
||||
assert signal["signal"] in ("fade_short", "fade_long", "neutral")
|
||||
|
||||
def test_rebalancing_signal_short(self):
|
||||
monitor = HlpVaultMonitor(testnet=True, overextended_threshold=5.0)
|
||||
with patch.object(monitor, '_api_post') as m:
|
||||
m.side_effect = [
|
||||
[_mock_meta(), _mock_asset_ctxs()],
|
||||
_mock_clearinghouse({"ETH": ("B", 2000.0, 3100, 50000)}),
|
||||
]
|
||||
monitor.update()
|
||||
signal = monitor.rebalancing_signal("ETH")
|
||||
assert signal["overextended"]
|
||||
|
||||
def test_toxicity_score_zero_with_no_data(self):
|
||||
monitor = HlpVaultMonitor(testnet=True)
|
||||
assert monitor.toxicity_score() == 0.0
|
||||
|
||||
def test_toxicity_score_detects_losing_flow(self):
|
||||
monitor = HlpVaultMonitor(testnet=True)
|
||||
with patch.object(monitor, '_api_post') as m:
|
||||
m.side_effect = [
|
||||
[_mock_meta(), _mock_asset_ctxs()],
|
||||
_mock_clearinghouse({
|
||||
"BTC": ("A", 10.0, 64000, -50000),
|
||||
"ETH": ("A", 50.0, 3100, -25000),
|
||||
}),
|
||||
]
|
||||
monitor.update()
|
||||
score = monitor.toxicity_score()
|
||||
assert score > 0
|
||||
|
||||
def test_historical_tracking(self):
|
||||
monitor = HlpVaultMonitor(testnet=True)
|
||||
with patch.object(monitor, '_api_post') as m:
|
||||
m.side_effect = [
|
||||
[_mock_meta(), _mock_asset_ctxs()],
|
||||
_mock_clearinghouse({"BTC": ("A", 10.0, 64000, 0)}),
|
||||
[_mock_meta(), _mock_asset_ctxs()],
|
||||
_mock_clearinghouse({"BTC": ("A", 12.0, 64100, 1000)}),
|
||||
]
|
||||
monitor.update()
|
||||
monitor.update()
|
||||
history = monitor.delta_history("BTC")
|
||||
assert len(history) == 2
|
||||
|
||||
def test_summary_includes_all_fields(self):
|
||||
monitor = HlpVaultMonitor(testnet=True)
|
||||
with patch.object(monitor, '_api_post') as m:
|
||||
m.side_effect = [
|
||||
[_mock_meta(), _mock_asset_ctxs()],
|
||||
_mock_clearinghouse({"BTC": ("A", 10.0, 64000, 5000)}),
|
||||
]
|
||||
monitor.update()
|
||||
s = monitor.summary()
|
||||
assert "assets_tracked" in s
|
||||
assert "total_delta_usd" in s
|
||||
assert "toxicity_score" in s
|
||||
assert "overextended_assets" in s
|
||||
assert "signals" in s
|
||||
Reference in New Issue
Block a user