Compare commits
22 Commits
a09954017e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 162c535c7c | |||
| 941c07fe32 | |||
| bf137a08a3 | |||
| d31d301822 | |||
| f198d2ccf6 | |||
| 9b1d46526b | |||
| e9629b698b | |||
| bfc3214967 | |||
| fb231eef7c | |||
| 2f74e076b4 | |||
| f7f47b5484 | |||
| 803a38b237 | |||
| 70d43fefe0 | |||
| 84efb4014a | |||
| 5004b23331 | |||
| f4c8bca15a | |||
| 5c41d232c1 | |||
| 7fd289f562 | |||
| 8855c013a6 | |||
| 156ea40e78 | |||
| 6665d0cd2a | |||
| 96ca132fa2 |
@@ -42,6 +42,7 @@ STRATEGIES = {
|
||||
"avellaneda":{"name": "Avellaneda-Stoikov", "size": 0.001, "fee_model": "maker"},
|
||||
"momentum": {"name": "Momentum Breakout", "size": 0.002, "fee_model": "taker"},
|
||||
"mean_rev": {"name": "Mean Reversion", "size": 0.002, "fee_model": "taker"},
|
||||
"kalman_pairs": {"name": "Kalman Pairs", "size": 0.005, "fee_model": "taker"},
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +82,7 @@ def fetch_candles(coin: str, interval: str = "1h", limit: int = 720) -> list[dic
|
||||
def simulate_strategy_on_candles(
|
||||
key: str,
|
||||
candles: list[dict],
|
||||
coin_name: str = "BTC",
|
||||
allocation: float = 100.0,
|
||||
fee_tier: int = 0,
|
||||
staking_tier: str = "none",
|
||||
@@ -143,9 +145,14 @@ def simulate_strategy_on_candles(
|
||||
reason = f"Iceberg: {up_count}/10 upward ticks"
|
||||
signal_strength = 1 - up_count / 10
|
||||
|
||||
elif key == "funding_arb":
|
||||
# Funding rate arb: need real funding data — skip for candle-only backtest
|
||||
pass
|
||||
elif key == "funding_arb" and len(prices_20) >= 20:
|
||||
# Funding Rate Arb: hourly price trend as funding proxy
|
||||
long_return = (close - prices_20[0]) / prices_20[0]
|
||||
annual_rate = long_return * 365 * 24 # hourly to annual
|
||||
if abs(annual_rate) > 0.03: # >3% annualized
|
||||
signal = "SELL" if annual_rate > 0 else "BUY"
|
||||
reason = f"Fund: {annual_rate*100:.1f}% APR ({long_return*100:.2f}% 1h)"
|
||||
signal_strength = min(1.0, abs(annual_rate) * 5)
|
||||
|
||||
elif key == "pairs" and len(prices_20) >= 20:
|
||||
# Pairs: BTC/ETH ratio Z-score (only works if we have both)
|
||||
@@ -205,6 +212,23 @@ def simulate_strategy_on_candles(
|
||||
signal = "BUY"
|
||||
reason = f"VWAP: dev={dev:.1f}σ below VWAP ${vwap:.0f}"
|
||||
signal_strength = abs(dev)
|
||||
elif key == "kalman_pairs" and len(prices_20) >= 20:
|
||||
# Kalman filter reversion: adaptively tracks price vs SMA
|
||||
if "_kalman_trader" not in dir():
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, ".")
|
||||
from strategies.kalman_pairs import KalmanPairsTrader
|
||||
globals()["_kalman_trader"] = KalmanPairsTrader(
|
||||
transition_covariance=1e-3, observation_covariance=1e-1,
|
||||
z_entry=2.0, z_exit=0.5, warmup_bars=20,
|
||||
)
|
||||
# Use 20-period SMA as the "pair" asset X, price as Y
|
||||
sma_20 = sum(prices_20) / len(prices_20)
|
||||
result = globals()["_kalman_trader"].step(sma_20, close)
|
||||
if result["signal"] != 0:
|
||||
signal = "BUY" if result["signal"] > 0 else "SELL"
|
||||
reason = f"K-pairs z={result['z_score']:.2f} b={result['beta']:.3f}"
|
||||
signal_strength = abs(result["z_score"]) / 4.0
|
||||
|
||||
# ── Execute signal ──
|
||||
if signal and signal_strength > 0.15: # minimum strength filter
|
||||
@@ -289,7 +313,7 @@ def simulate_strategy_on_candles(
|
||||
return {
|
||||
"strategy": name,
|
||||
"strategy_key": key,
|
||||
"coin": candles[0]["t"] if candles else "unknown",
|
||||
"coin": coin_name, # actual ticker (BTC, ETH, etc.)
|
||||
"allocation": allocation,
|
||||
"start_time": curve[0]["t"] if curve else "",
|
||||
"end_time": curve[-1]["t"] if curve else "",
|
||||
@@ -319,7 +343,7 @@ def simulate_strategy_on_candles(
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="FTDT Historical Backtest Runner")
|
||||
p.add_argument("--coin", default="BTC", choices=["BTC", "ETH", "SOL"], help="Coin to backtest")
|
||||
p.add_argument("--coin", default="BTC", choices=["BTC", "ETH", "SOL", "HYPE", "VVV"], help="Coin to backtest")
|
||||
p.add_argument("--strategy", "-s", choices=list(STRATEGIES) + ["all"], default="all")
|
||||
p.add_argument("--fee-tier", type=int, default=0, choices=range(7))
|
||||
p.add_argument("--staking-tier", default="none", choices=list(STAKING_TIERS.keys()))
|
||||
@@ -354,11 +378,75 @@ def main():
|
||||
cfg = STRATEGIES[key]
|
||||
print(f"\n Running: {cfg['name']} on {a.coin}...")
|
||||
|
||||
result = simulate_strategy_on_candles(
|
||||
key, candles,
|
||||
fee_tier=a.fee_tier,
|
||||
staking_tier=a.staking_tier,
|
||||
)
|
||||
# Kalman Pairs: use real BTC/ETH pair data
|
||||
if key == "kalman_pairs":
|
||||
try:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from strategies.kalman_pairs import KalmanPairsTrader, backtest_kalman_pairs
|
||||
|
||||
# Fetch ETH candles
|
||||
if a.coin != "ETH":
|
||||
eth_candles = fetch_candles("ETH", interval="1h", limit=a.hours)
|
||||
else:
|
||||
eth_candles = fetch_candles("BTC", interval="1h", limit=a.hours)
|
||||
|
||||
if eth_candles:
|
||||
X = [float(c["c"]) for c in eth_candles]
|
||||
Y = [float(c["c"]) for c in candles]
|
||||
n = min(len(X), len(Y))
|
||||
X, Y = X[:n], Y[:n]
|
||||
|
||||
trader = KalmanPairsTrader(
|
||||
transition_covariance=1e-4, observation_covariance=1e-2,
|
||||
z_entry=2.0, z_exit=0.5, warmup_bars=20,
|
||||
)
|
||||
bt = backtest_kalman_pairs(
|
||||
X, Y, trader,
|
||||
trade_size_usd=50.0,
|
||||
transaction_cost_bps=2.5,
|
||||
)
|
||||
|
||||
# Convert to standard format expected by the dashboard
|
||||
result = {
|
||||
"strategy": cfg["name"],
|
||||
"strategy_key": key,
|
||||
"coin": a.coin,
|
||||
"allocation": 100.0,
|
||||
"start_time": str(bt["equity_curve"][0]["t"]) if bt["equity_curve"] else "",
|
||||
"end_time": str(bt["equity_curve"][-1]["t"]) if bt["equity_curve"] else "",
|
||||
"start_equity": 100.0,
|
||||
"end_equity": round(bt["final_equity"], 4),
|
||||
"pnl": round(bt["total_pnl"], 4),
|
||||
"pnl_pct": round(bt["pnl_pct"], 2),
|
||||
"pnl_gross": round(bt["total_pnl"] + bt["transaction_costs"], 4),
|
||||
"pnl_gross_pct": round(bt["pnl_pct"], 2),
|
||||
"fees_total": round(bt["transaction_costs"], 4),
|
||||
"fee_tier": a.fee_tier,
|
||||
"staking_tier": a.staking_tier,
|
||||
"fee_model": cfg["fee_model"],
|
||||
"sharpe": round(bt["sharpe"], 4),
|
||||
"sortino": round(bt["sortino"], 4),
|
||||
"max_dd": round(bt["max_drawdown"], 4),
|
||||
"max_dd_pct": round(bt["max_drawdown"] * 100, 2),
|
||||
"win_rate": round(bt["win_rate"], 4),
|
||||
"total_trades": bt["total_trades"],
|
||||
"equity_curve": [{"t": e["t"], "v": e["equity"]} for e in bt["equity_curve"]],
|
||||
"trades": bt["trades"][-100:],
|
||||
"num_periods": n,
|
||||
"data_source": "Hyperliquid Mainnet (BTC/ETH pair)",
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
}
|
||||
else:
|
||||
result = {} # Skip
|
||||
except Exception as e:
|
||||
print(f" Kalman pairs error: {e}")
|
||||
result = {}
|
||||
else:
|
||||
result = simulate_strategy_on_candles(
|
||||
key, candles, a.coin,
|
||||
fee_tier=a.fee_tier,
|
||||
staking_tier=a.staking_tier,
|
||||
)
|
||||
|
||||
# Save
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
+2011
-2081
File diff suppressed because it is too large
Load Diff
+1312
-1522
File diff suppressed because it is too large
Load Diff
+95
-95
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"strategy": "Funding Rate Arb",
|
||||
"strategy_key": "funding_arb",
|
||||
"coin": 1783234800000,
|
||||
"strategy": "Avellaneda-Stoikov",
|
||||
"strategy_key": "avellaneda",
|
||||
"coin": "HYPE",
|
||||
"allocation": 100.0,
|
||||
"start_time": "2026-07-05T07:00:00",
|
||||
"end_time": "2026-08-04T07:00:00",
|
||||
"start_time": "2026-07-06T05:00:00",
|
||||
"end_time": "2026-08-05T05:00:00",
|
||||
"start_equity": 100.0,
|
||||
"end_equity": 100.0,
|
||||
"pnl": 0.0,
|
||||
@@ -14,7 +14,7 @@
|
||||
"fees_total": 0.0,
|
||||
"fee_tier": 0,
|
||||
"staking_tier": "none",
|
||||
"fee_model": "taker",
|
||||
"fee_model": "maker",
|
||||
"sharpe": 0.0,
|
||||
"sortino": 0.0,
|
||||
"max_dd": 0.0,
|
||||
@@ -22,94 +22,6 @@
|
||||
"win_rate": 0.0,
|
||||
"total_trades": 0,
|
||||
"equity_curve": [
|
||||
{
|
||||
"t": "2026-07-05T07:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T08:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T09:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T10:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T11:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T12:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T13:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T14:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T15:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T16:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T17:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T18:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T19:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T20:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T21:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T22:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T23:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-06T00:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-06T01:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-06T02:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-06T03:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-06T04:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-06T05:00:00",
|
||||
"v": 100.0
|
||||
@@ -2905,10 +2817,98 @@
|
||||
{
|
||||
"t": "2026-08-04T07:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T08:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T09:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T10:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T11:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T12:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T13:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T14:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T15:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T16:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T17:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T18:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T19:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T20:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T21:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T22:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T23:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-05T00:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-05T01:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-05T02:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-05T03:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-05T04:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-05T05:00:00",
|
||||
"v": 100.0
|
||||
}
|
||||
],
|
||||
"trades": [],
|
||||
"num_periods": 721,
|
||||
"data_source": "Hyperliquid Mainnet",
|
||||
"generated_at": "2026-08-04T07:28:17.886718"
|
||||
"generated_at": "2026-08-05T05:07:41.026245"
|
||||
}
|
||||
+95
-95
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"strategy": "Funding Rate Arb",
|
||||
"strategy_key": "funding_arb",
|
||||
"coin": 1783234800000,
|
||||
"strategy": "Avellaneda-Stoikov",
|
||||
"strategy_key": "avellaneda",
|
||||
"coin": "VVV",
|
||||
"allocation": 100.0,
|
||||
"start_time": "2026-07-05T07:00:00",
|
||||
"end_time": "2026-08-04T07:00:00",
|
||||
"start_time": "2026-07-06T05:00:00",
|
||||
"end_time": "2026-08-05T05:00:00",
|
||||
"start_equity": 100.0,
|
||||
"end_equity": 100.0,
|
||||
"pnl": 0.0,
|
||||
@@ -14,7 +14,7 @@
|
||||
"fees_total": 0.0,
|
||||
"fee_tier": 0,
|
||||
"staking_tier": "none",
|
||||
"fee_model": "taker",
|
||||
"fee_model": "maker",
|
||||
"sharpe": 0.0,
|
||||
"sortino": 0.0,
|
||||
"max_dd": 0.0,
|
||||
@@ -22,94 +22,6 @@
|
||||
"win_rate": 0.0,
|
||||
"total_trades": 0,
|
||||
"equity_curve": [
|
||||
{
|
||||
"t": "2026-07-05T07:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T08:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T09:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T10:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T11:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T12:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T13:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T14:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T15:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T16:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T17:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T18:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T19:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T20:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T21:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T22:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-05T23:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-06T00:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-06T01:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-06T02:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-06T03:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-06T04:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-07-06T05:00:00",
|
||||
"v": 100.0
|
||||
@@ -2905,10 +2817,98 @@
|
||||
{
|
||||
"t": "2026-08-04T07:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T08:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T09:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T10:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T11:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T12:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T13:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T14:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T15:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T16:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T17:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T18:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T19:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T20:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T21:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T22:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-04T23:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-05T00:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-05T01:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-05T02:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-05T03:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-05T04:00:00",
|
||||
"v": 100.0
|
||||
},
|
||||
{
|
||||
"t": "2026-08-05T05:00:00",
|
||||
"v": 100.0
|
||||
}
|
||||
],
|
||||
"trades": [],
|
||||
"num_periods": 721,
|
||||
"data_source": "Hyperliquid Mainnet",
|
||||
"generated_at": "2026-08-04T07:29:03.636645"
|
||||
"generated_at": "2026-08-05T05:07:41.798429"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+806
-816
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+955
-1035
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+799
-809
File diff suppressed because it is too large
Load Diff
+996
-966
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+798
-798
File diff suppressed because it is too large
Load Diff
+867
-897
File diff suppressed because it is too large
Load Diff
+932
-882
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+816
-816
File diff suppressed because it is too large
Load Diff
+1138
-1108
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+798
-798
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,14 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { ChevronDown, ChevronRight, Activity, Database, TrendingUp, TrendingDown, ArrowLeft } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, TrendingDown, ArrowLeft } from "lucide-react";
|
||||
import { StrategyCard } from "@/components/strategy-card";
|
||||
import { EquityChart } from "@/components/equity-chart";
|
||||
import { PositionsPanel } from "@/components/positions-panel";
|
||||
import { OBIDetail } from "@/components/obi-detail";
|
||||
import OrderBookDepthMap from "@/components/orderbook-depth-map";
|
||||
import L2Terminal from "@/components/L2Terminal";
|
||||
import { useLiveMetrics, usePaperMetrics, fetchHistorical, fetchBacktestDetail, recalcBacktest } from "@/lib/api";
|
||||
import type { Strategy, BacktestSummary, BacktestFull, Trade, Position, Order } from "@/lib/types";
|
||||
|
||||
@@ -25,13 +27,16 @@ export default function Dashboard() {
|
||||
const [historical, setHistorical] = useState<Record<string, BacktestSummary>>({});
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [l2TerminalOpen, setL2TerminalOpen] = useState(false);
|
||||
const [detailName, setDetailName] = useState("");
|
||||
const [detailTab, setDetailTab] = useState<Tab>("live");
|
||||
const [filter, setFilter] = useState("ALL");
|
||||
const [btFull, setBtFull] = useState<BacktestFull | null>(null);
|
||||
const [feeOn, setFeeOn] = useState(true);
|
||||
const [feeTier, setFeeTier] = useState(0);
|
||||
const [stakingTier, setStakingTier] = useState("none");
|
||||
const [posOpen, setPosOpen] = useState(false);
|
||||
const [tickerFilter, setTickerFilter] = useState("ALL");
|
||||
|
||||
useEffect(() => { fetchHistorical().then(setHistorical); }, []);
|
||||
|
||||
@@ -146,6 +151,21 @@ export default function Dashboard() {
|
||||
</header>
|
||||
|
||||
<div className="max-w-[1440px] mx-auto px-6 py-6 space-y-6">
|
||||
{/* OBI Strategy: 3D Depth Map View */}
|
||||
{detailTab === "live" && detailName.includes("Order Book Imbalance") && detailStrat && liveData && (
|
||||
<OBIDetail
|
||||
strategy={detailStrat}
|
||||
strategyName={detailName}
|
||||
equityData={detailEquity}
|
||||
trades={detailTrades}
|
||||
liveData={liveData}
|
||||
color={STRAT_COLORS[Object.keys(strategies).indexOf(detailName) % STRAT_COLORS.length] ?? "#22c55e"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Regular detail for non-OBI strategies */}
|
||||
{!(detailTab === "live" && detailName.includes("Order Book Imbalance")) && (
|
||||
<>
|
||||
{detailStrat && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed p-4 bg-muted/50 rounded-lg border border-border">
|
||||
{detailStrat.description || "No description available."}
|
||||
@@ -257,7 +277,15 @@ export default function Dashboard() {
|
||||
<p className="text-xs text-muted-foreground text-center py-12">No trades recorded yet</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Live L2 Order Book + Trade Tape (all strategies, live tab only) */}
|
||||
{detailTab === "live" && (
|
||||
<div className="mt-6">
|
||||
<OrderBookDepthMap coin="BTC" height={480} topRatio={0.55} />
|
||||
</div>
|
||||
)}
|
||||
<div className="h-8" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -301,6 +329,18 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
<main className="max-w-[1440px] mx-auto px-6 py-6">
|
||||
{/* Ticker filter for Historical tab */}
|
||||
{tab === "historical" && (
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider mr-1">Ticker:</span>
|
||||
{["ALL", "BTC", "ETH", "HYPE", "VVV"].map((t) => (
|
||||
<button key={t} onClick={() => setTickerFilter(t)}
|
||||
className={`text-[10px] px-3 py-1 rounded-md border transition-colors ${tickerFilter === t ? "bg-primary text-primary-foreground border-primary" : "bg-card text-muted-foreground border-border hover:border-primary/50"}`}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 mb-6">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{Object.entries(strategies).map(([name, s], i) => (
|
||||
@@ -309,41 +349,36 @@ export default function Dashboard() {
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{tab === "historical" && Object.entries(historical).map(([name, b], i) => (
|
||||
{tab === "historical" && Object.entries(historical).filter(([, b]) => tickerFilter === "ALL" || b.coin === tickerFilter).map(([name, b], i) => (
|
||||
<motion.div key={name} layout initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, delay: i * 0.03 }}>
|
||||
<StrategyCard name={name} tab="historical" onClick={() => handleCardClick(name, "historical")}
|
||||
badge={`30d · ${b.coin ?? "BTC"} · Mainnet`}
|
||||
coin={String(b.coin ?? "?")}
|
||||
badge={`30d · Mainnet`}
|
||||
stats={[{ label: "Sharpe", value: b.sharpe.toFixed(2) }, { label: "Max DD", value: `${(b.max_dd * 100).toFixed(1)}%`, negative: true }, { label: "Win", value: `${Math.round(b.win_rate * 100)}%` }]}
|
||||
pnlPct={b.pnl_pct} status="REAL DATA" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Collapsible open={posOpen} onOpenChange={setPosOpen} className="mb-6">
|
||||
<CollapsibleTrigger className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1">
|
||||
{posOpen ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
Open Positions & Orders ({liveData?.open_positions?.length ?? 0} pos · {liveData?.open_orders?.length ?? 0} ord)
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<PositionsPanel positions={liveData?.open_positions ?? []} orders={liveData?.open_orders ?? []} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
|
||||
<a href="https://ftdt.io" target="_blank" className="flex items-center gap-3 p-4 rounded-lg border border-border bg-card hover:border-primary/50 transition-colors">
|
||||
<Activity className="w-4 h-4 text-primary" />
|
||||
<div><p className="text-xs font-medium">ftdt.io</p><p className="text-[10px] text-muted-foreground">Main platform</p></div>
|
||||
</a>
|
||||
<a href="https://app.ftdt.io/quant-lab" target="_blank" className="flex items-center gap-3 p-4 rounded-lg border border-border bg-card hover:border-primary/50 transition-colors">
|
||||
<TrendingUp className="w-4 h-4 text-chart-2" />
|
||||
<div><p className="text-xs font-medium">Quant Lab ↗</p><p className="text-[10px] text-muted-foreground">Web dashboard</p></div>
|
||||
</a>
|
||||
<a href="https://git.ftdt.io/rams/ftdt-quant-lab" target="_blank" className="flex items-center gap-3 p-4 rounded-lg border border-border bg-card hover:border-primary/50 transition-colors">
|
||||
<Database className="w-4 h-4 text-chart-3" />
|
||||
<div><p className="text-xs font-medium">Git Repo</p><p className="text-[10px] text-muted-foreground">rams/ftdt-quant-lab</p></div>
|
||||
</a>
|
||||
</div>
|
||||
{/* L2 Terminal launcher */}
|
||||
<button onClick={() => setL2TerminalOpen(true)} className="flex items-center gap-2 px-4 py-2 mb-4 border border-[#1A1A2E] bg-[#0A0A10] hover:bg-[#111122] rounded transition-colors">
|
||||
<span className="text-[11px] font-mono text-gray-300">⌘ L2 Depth Map</span>
|
||||
<span className="text-[9px] text-gray-600">ws://hyperliquid · {liveConn ? "LIVE" : "OFFLINE"}</span>
|
||||
</button>
|
||||
</main>
|
||||
|
||||
{/* Fullscreen L2 Terminal */}
|
||||
{l2TerminalOpen && (
|
||||
<div className="fixed inset-0 z-[200] bg-black">
|
||||
<button
|
||||
onClick={() => setL2TerminalOpen(false)}
|
||||
className="absolute top-2 right-4 z-[201] text-gray-400 hover:text-white text-xs font-mono bg-[#111] px-3 py-1 rounded border border-[#333]"
|
||||
>
|
||||
✕ Close L2 Terminal
|
||||
</button>
|
||||
<L2Terminal coin="BTC" className="w-full h-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { useHyperliquidWebSocket, type L2Snapshot, type TradeTapeEntry } from "@/lib/hyperliquid-ws";
|
||||
|
||||
// ═══════════ Colors ═══════════
|
||||
const BID_C = "#00C853";
|
||||
const ASK_C = "#FF1744";
|
||||
const MID_C = "#FFEB3B";
|
||||
const TRADE_C = "#FFAB00";
|
||||
const TXT = "#CCCCCC";
|
||||
const TXT_B = "#FFFFFF";
|
||||
const BG = "#000000";
|
||||
const PANEL_BG = "#0A0A10";
|
||||
const GRID = "rgba(255,255,255,0.03)";
|
||||
|
||||
interface Props {
|
||||
coin?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function L2Terminal({ coin = "BTC", className = "" }: Props) {
|
||||
const { l2, trades, connected, error } = useHyperliquidWebSocket(coin);
|
||||
const domCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const depthCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const tapeCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dims, setDims] = useState({ w: 1200, h: 800 });
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => {
|
||||
if (containerRef.current) {
|
||||
setDims({ w: containerRef.current.clientWidth, h: window.innerHeight - 64 });
|
||||
}
|
||||
};
|
||||
cb();
|
||||
window.addEventListener("resize", cb);
|
||||
return () => window.removeEventListener("resize", cb);
|
||||
}, []);
|
||||
|
||||
// ═══════ DOM Ladder (Left 25%) ═══════
|
||||
useEffect(() => {
|
||||
const canvas = domCanvas.current;
|
||||
if (!canvas || !l2) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth;
|
||||
const H = canvas.clientHeight;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = BG;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const M = { top: 20, bot: 20, left: 8, right: 4 };
|
||||
const pH = (H - M.top - M.bot) / 40; // 40 price rows
|
||||
const mid = l2.mid;
|
||||
const step = Math.max(l2.spread * 2, mid * 0.0001);
|
||||
const maxVol = Math.max(
|
||||
...l2.bids.map(b => b.sz).slice(0, 40),
|
||||
...l2.asks.map(a => a.sz).slice(0, 40),
|
||||
1
|
||||
);
|
||||
|
||||
// Draw price ladder
|
||||
for (let i = -20; i <= 20; i++) {
|
||||
const px = mid + i * step;
|
||||
const y = M.top + (20 - i) * pH;
|
||||
const bidSz = l2.bids.find(b => Math.abs(b.px - px) < step * 0.5)?.sz ?? 0;
|
||||
const askSz = l2.asks.find(a => Math.abs(a.px - px) < step * 0.5)?.sz ?? 0;
|
||||
|
||||
// Row background
|
||||
ctx.fillStyle = i === 0 ? "rgba(255,235,59,0.08)" : i % 2 ? "rgba(255,255,255,0.01)" : "transparent";
|
||||
ctx.fillRect(0, y, W, pH);
|
||||
|
||||
// Bid volume bar
|
||||
if (bidSz > 0) {
|
||||
const w = (bidSz / maxVol) * W * 0.45;
|
||||
ctx.fillStyle = BID_C;
|
||||
ctx.globalAlpha = 0.25 + 0.5 * (bidSz / maxVol);
|
||||
ctx.fillRect(W * 0.05, y + 1, w, pH - 2);
|
||||
}
|
||||
|
||||
// Ask volume bar
|
||||
if (askSz > 0) {
|
||||
const w = (askSz / maxVol) * W * 0.45;
|
||||
ctx.fillStyle = ASK_C;
|
||||
ctx.globalAlpha = 0.25 + 0.5 * (askSz / maxVol);
|
||||
ctx.fillRect(W * 0.55, y + 1, w, pH - 2);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Price text
|
||||
ctx.fillStyle = i === 0 ? TXT_B : TXT;
|
||||
ctx.font = `${i === 0 ? "bold " : ""}10px "JetBrains Mono", monospace`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(px.toFixed(1), W / 2, y + pH * 0.65);
|
||||
|
||||
// Volume text
|
||||
ctx.font = "8px monospace";
|
||||
ctx.textAlign = "left";
|
||||
if (bidSz > 0.01) ctx.fillText(bidSz.toFixed(1), W * 0.05 + 4, y + pH * 0.65);
|
||||
ctx.textAlign = "right";
|
||||
if (askSz > 0.01) ctx.fillText(askSz.toFixed(1), W - 4, y + pH * 0.65);
|
||||
}
|
||||
|
||||
// Header
|
||||
ctx.font = "9px monospace";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText("DEPTH OF MARKET", 4, 10);
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`${coin}-USD`, W - 4, 10);
|
||||
}, [l2, coin, dims]);
|
||||
|
||||
// ═══════ Depth Heatmap (Right 45%) ═══════
|
||||
useEffect(() => {
|
||||
const canvas = depthCanvas.current;
|
||||
if (!canvas || !l2) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth;
|
||||
const H = canvas.clientHeight;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = PANEL_BG;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const M = { top: 20, bot: 25, left: 40, right: 10 };
|
||||
const pW = W - M.left - M.right;
|
||||
const pH = H - M.top - M.bot;
|
||||
const mid = l2.mid;
|
||||
const range = mid * 0.02;
|
||||
const pMin = mid - range;
|
||||
const pMax = mid + range;
|
||||
const p2x = (px: number) => M.left + ((px - pMin) / (pMax - pMin)) * pW;
|
||||
|
||||
// Find max vol
|
||||
const allVol = [...l2.bids.slice(0, 80), ...l2.asks.slice(0, 80)];
|
||||
const maxV = Math.max(...allVol.map(v => v.sz), 10);
|
||||
|
||||
// Grid
|
||||
ctx.strokeStyle = GRID;
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let i = 0; i <= 8; i++) {
|
||||
const y = M.top + (i / 8) * pH;
|
||||
ctx.beginPath(); ctx.moveTo(M.left, y); ctx.lineTo(M.left + pW, y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw cumulative volume profile
|
||||
const drawProfile = (levels: { px: number; sz: number }[], color: string, fromMid: boolean) => {
|
||||
ctx.beginPath();
|
||||
let cumVol = 0;
|
||||
const sorted = [...levels].sort((a, b) => fromMid ? b.px - a.px : a.px - b.px);
|
||||
|
||||
// Draw filled area
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
cumVol += sorted[i].sz;
|
||||
const x = p2x(sorted[i].px);
|
||||
const y = M.top + pH - (cumVol / maxV) * pH;
|
||||
if (i === 0) ctx.moveTo(x, M.top + pH);
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
// Close and fill
|
||||
const lastX = p2x(sorted[sorted.length - 1]?.px ?? mid);
|
||||
ctx.lineTo(lastX, M.top + pH);
|
||||
ctx.closePath();
|
||||
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, H);
|
||||
grad.addColorStop(0, color + "80");
|
||||
grad.addColorStop(1, color + "10");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
};
|
||||
|
||||
drawProfile(l2.bids.slice(0, 80), BID_C, true);
|
||||
drawProfile(l2.asks.slice(0, 80), ASK_C, false);
|
||||
|
||||
// Mid line
|
||||
const midX = p2x(mid);
|
||||
ctx.strokeStyle = MID_C;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath(); ctx.moveTo(midX, M.top); ctx.lineTo(midX, M.top + pH); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Mid price labels
|
||||
ctx.fillStyle = TXT_B;
|
||||
ctx.font = "bold 13px 'JetBrains Mono', monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(mid.toFixed(1), midX, M.top + pH / 2 - 8);
|
||||
ctx.fillText(mid.toFixed(1), midX, M.top + pH / 2 + 20);
|
||||
|
||||
// Orange mid marker
|
||||
ctx.fillStyle = "#FF9100";
|
||||
ctx.beginPath(); ctx.arc(midX, M.top + pH, 4, 0, Math.PI * 2); ctx.fill();
|
||||
|
||||
// Price axis labels
|
||||
ctx.fillStyle = TXT;
|
||||
ctx.font = "8px monospace";
|
||||
ctx.textAlign = "center";
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const px = pMin + (i / 5) * (pMax - pMin);
|
||||
ctx.fillText(px.toFixed(0), p2x(px), M.top + pH + 15);
|
||||
}
|
||||
|
||||
// Volume scale
|
||||
ctx.textAlign = "right";
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const v = Math.round(maxV * i / 4);
|
||||
ctx.fillText(v.toLocaleString(), M.left - 4, M.top + pH - (i / 4) * pH + 3);
|
||||
}
|
||||
|
||||
// Imbalance gauge
|
||||
const imb = l2.imbalance;
|
||||
ctx.fillStyle = TXT;
|
||||
ctx.font = "9px monospace";
|
||||
ctx.textAlign = "left";
|
||||
const imbStr = `I = ${imb >= 0 ? "+" : ""}${imb.toFixed(3)} | (Vb-Va)/(Vb+Va)`;
|
||||
ctx.fillText(imbStr, 8, 12);
|
||||
|
||||
// Spread
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`Spread: ${l2.spread.toFixed(1)}`, W - 8, 12);
|
||||
|
||||
// Volume totals
|
||||
ctx.fillStyle = BID_C;
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(`Bid: ${l2.totalBidVol.toFixed(1)} BTC`, 8, M.top + pH + 22);
|
||||
ctx.fillStyle = ASK_C;
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`Ask: ${l2.totalAskVol.toFixed(1)} BTC`, W - 8, M.top + pH + 22);
|
||||
}, [l2, dims]);
|
||||
|
||||
// ═══════ Trade Tape (Bottom 30%) ═══════
|
||||
useEffect(() => {
|
||||
const canvas = tapeCanvas.current;
|
||||
if (!canvas || trades.length < 2) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth;
|
||||
const H = canvas.clientHeight;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = PANEL_BG;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const M = { top: 20, bot: 12, left: 40, right: 8 };
|
||||
const pW = W - M.left - M.right;
|
||||
const pH = H - M.top - M.bot;
|
||||
|
||||
const prices = trades.map(t => t.px);
|
||||
const pMin = Math.min(...prices);
|
||||
const pMax = Math.max(...prices);
|
||||
const pRange = (pMax - pMin) || 1;
|
||||
const pad = pRange * 0.15 || 5;
|
||||
const pLo = pMin - pad;
|
||||
const pHi = pMax + pad;
|
||||
const p2y = (px: number) => M.top + pH - ((px - pLo) / (pHi - pLo)) * pH;
|
||||
|
||||
// Grid
|
||||
ctx.strokeStyle = GRID;
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = M.top + (i / 4) * pH;
|
||||
ctx.beginPath(); ctx.moveTo(M.left, y); ctx.lineTo(M.left + pW, y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Trade path
|
||||
ctx.strokeStyle = TRADE_C;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < trades.length; i++) {
|
||||
const x = M.left + (i / Math.max(trades.length - 1, 1)) * pW;
|
||||
const y = p2y(trades[i].px);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Trade markers
|
||||
const maxSz = Math.max(...trades.map(t => t.sz), 1);
|
||||
for (let i = 0; i < trades.length; i++) {
|
||||
const t = trades[i];
|
||||
const x = M.left + (i / Math.max(trades.length - 1, 1)) * pW;
|
||||
const y = p2y(t.px);
|
||||
const r = Math.max(1.5, (t.sz / maxSz) * 4 + 1);
|
||||
ctx.fillStyle = t.side === "buy" ? "#4CAF50" : "#F44336";
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Latest trade callout
|
||||
const last = trades[trades.length - 1];
|
||||
const lx = M.left + pW;
|
||||
const ly = p2y(last.px);
|
||||
ctx.fillStyle = last.side === "buy" ? "#00E676" : "#FF5252";
|
||||
ctx.font = "bold 14px 'JetBrains Mono', monospace";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(`${last.side === "buy" ? "B" : "S"} ${last.px.toFixed(1)}`, 8, 14);
|
||||
ctx.fillStyle = TXT;
|
||||
ctx.font = "10px monospace";
|
||||
ctx.fillText(` | ${last.sz.toFixed(4)} BTC`, 140, 14);
|
||||
|
||||
// Trade count
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`${trades.length} trades`, W - 8, 14);
|
||||
|
||||
// Price labels
|
||||
ctx.textAlign = "right";
|
||||
ctx.font = "8px monospace";
|
||||
for (let i = 0; i <= 3; i++) {
|
||||
const px = pLo + (i / 3) * (pHi - pLo);
|
||||
ctx.fillText(px.toFixed(1), M.left - 4, p2y(px) + 3);
|
||||
}
|
||||
}, [trades, dims]);
|
||||
|
||||
// Has data?
|
||||
const noData = !l2 && !error;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative bg-black overflow-hidden ${className}`}>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-[#0D0D15] border-b border-[#1A1A2E]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-400 font-mono">L2 ORDER BOOK</span>
|
||||
<span className="text-[10px] text-gray-600">·</span>
|
||||
<span className="text-xs text-white font-mono font-bold">{coin}-USD</span>
|
||||
<span className="text-[10px] text-gray-600">·</span>
|
||||
<span className={`w-2 h-2 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`} />
|
||||
<span className="text-[10px] text-gray-500">{connected ? "LIVE" : "RECONNECTING"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{l2 && (
|
||||
<>
|
||||
<span className="text-[10px] text-gray-500">Mid</span>
|
||||
<span className="text-xs text-white font-mono font-bold">{l2.mid.toFixed(1)}</span>
|
||||
<span className="text-[10px] text-gray-500">Spread</span>
|
||||
<span className="text-xs text-white font-mono">{l2.spread.toFixed(1)}</span>
|
||||
<span className="text-[10px] text-gray-500">Imb</span>
|
||||
<span className={`text-xs font-mono ${l2.imbalance >= 0 ? "text-green-400" : "text-red-400"}`}>
|
||||
{l2.imbalance >= 0 ? "+" : ""}{l2.imbalance.toFixed(3)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-500">Trades</span>
|
||||
<span className="text-xs text-white font-mono">{trades.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main grid: DOM Ladder | Depth Heatmap */}
|
||||
<div className="flex" style={{ height: dims.h * 0.70 }}>
|
||||
{/* DOM Ladder - 25% */}
|
||||
<div className="w-[25%] border-r border-[#1A1A2E] relative">
|
||||
<canvas ref={domCanvas} className="w-full h-full" />
|
||||
{noData && <div className="absolute inset-0 flex items-center justify-center"><span className="text-gray-600 text-xs">Waiting for L2...</span></div>}
|
||||
</div>
|
||||
{/* Depth Heatmap - 75% */}
|
||||
<div className="w-[75%] relative">
|
||||
<canvas ref={depthCanvas} className="w-full h-full" />
|
||||
{noData && <div className="absolute inset-0 flex items-center justify-center"><span className="text-gray-600 text-xs">Waiting for L2...</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Trade Tape */}
|
||||
<div className="border-t border-[#1A1A2E]" style={{ height: dims.h * 0.30 }}>
|
||||
<canvas ref={tapeCanvas} className="w-full h-full" />
|
||||
{trades.length < 2 && !error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center" style={{ bottom: dims.h * 0.15 }}>
|
||||
<span className="text-gray-600 text-xs">Waiting for trades...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-red-900/50 text-red-300 text-[9px] px-2 py-1 font-mono">
|
||||
{error} — reconnecting every 2s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import type { SurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils";
|
||||
|
||||
/**
|
||||
* Order Book Depth Map — Plotly.js 3D Surface
|
||||
*
|
||||
* Single unified surface: X = distance from mid (bps, -50 to +50),
|
||||
* Y = snapshot index (oldest → newest), Z = resting size (BTC).
|
||||
*
|
||||
* Warm amber/gold colorscale on dark background.
|
||||
* Live imbalance overlay with formula + wall detection.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
surface: SurfaceData | null;
|
||||
metrics: ImbalanceMetrics | null;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const COLORSCALE = [
|
||||
[0, "rgb(8,8,18)"],
|
||||
[0.2, "rgb(18,18,48)"],
|
||||
[0.4, "rgb(50,25,90)"],
|
||||
[0.6, "rgb(140,60,30)"],
|
||||
[0.75, "rgb(210,110,30)"],
|
||||
[0.88, "rgb(245,170,45)"],
|
||||
[0.96, "rgb(255,220,100)"],
|
||||
[1, "rgb(255,245,190)"],
|
||||
];
|
||||
|
||||
export function DepthMapPlotly({ surface, metrics, height = 440 }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const plotlyReady = useRef(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Load Plotly CDN once
|
||||
useEffect(() => {
|
||||
if ((window as any).Plotly) {
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://cdn.plot.ly/plotly-3.0.0.min.js";
|
||||
s.async = true;
|
||||
s.onload = () => setLoaded(true);
|
||||
document.head.appendChild(s);
|
||||
return () => { s.remove(); };
|
||||
}, []);
|
||||
|
||||
// Render / update chart
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !loaded || !surface) return;
|
||||
const Plotly = (window as any).Plotly;
|
||||
if (!Plotly) return;
|
||||
|
||||
const cw = containerRef.current.clientWidth || 800;
|
||||
|
||||
// Build trace — ensure no NaN/Infinity values
|
||||
const cleanZ = surface.z.map(row =>
|
||||
row.map(v => (isFinite(v) && v > 0 ? v : 0))
|
||||
);
|
||||
|
||||
const trace = {
|
||||
type: "surface",
|
||||
x: surface.x,
|
||||
y: surface.y,
|
||||
z: cleanZ,
|
||||
colorscale: COLORSCALE,
|
||||
contours: {
|
||||
z: {
|
||||
show: true,
|
||||
usecolormap: true,
|
||||
highlightcolor: "rgba(255,255,255,0.25)",
|
||||
project: { z: true },
|
||||
},
|
||||
},
|
||||
lighting: {
|
||||
ambient: 0.5,
|
||||
diffuse: 0.7,
|
||||
specular: 0.25,
|
||||
roughness: 0.45,
|
||||
fresnel: 0.15,
|
||||
},
|
||||
lightposition: { x: 150, y: 250, z: 350 },
|
||||
showscale: true,
|
||||
colorbar: {
|
||||
title: { text: "Resting Size", font: { color: "#999", size: 10 } },
|
||||
tickfont: { color: "#777", size: 8 },
|
||||
thickness: 14,
|
||||
len: 0.65,
|
||||
x: 1.02,
|
||||
},
|
||||
};
|
||||
|
||||
const layout: any = {
|
||||
title: {
|
||||
text: "ORDER BOOK IMBALANCE • BTC-USD-PERP",
|
||||
font: { size: 12, color: "#ccc", family: "Inter, sans-serif" },
|
||||
x: 0.03,
|
||||
y: 0.98,
|
||||
},
|
||||
paper_bgcolor: "rgba(0,0,0,0)",
|
||||
plot_bgcolor: "rgba(0,0,0,0)",
|
||||
scene: {
|
||||
xaxis: {
|
||||
title: { text: "Distance from Mid (bps)", font: { size: 9, color: "#666" } },
|
||||
gridcolor: "rgba(255,255,255,0.04)",
|
||||
zerolinecolor: "rgba(255,255,255,0.12)",
|
||||
tickfont: { size: 8, color: "#555" },
|
||||
range: [-55, 55],
|
||||
},
|
||||
yaxis: {
|
||||
title: { text: "Snapshot Index (oldest → newest)", font: { size: 9, color: "#666" } },
|
||||
gridcolor: "rgba(255,255,255,0.04)",
|
||||
tickfont: { size: 8, color: "#555" },
|
||||
},
|
||||
zaxis: {
|
||||
title: { text: "Size (BTC)", font: { size: 9, color: "#666" } },
|
||||
gridcolor: "rgba(255,255,255,0.04)",
|
||||
tickfont: { size: 8, color: "#555" },
|
||||
},
|
||||
camera: {
|
||||
eye: { x: 1.5, y: 1.2, z: 0.95 },
|
||||
center: { x: 0, y: 0, z: -0.08 },
|
||||
},
|
||||
aspectmode: "manual",
|
||||
aspectratio: { x: 1.5, y: 1.0, z: 0.55 },
|
||||
bgcolor: "rgba(0,0,0,0)",
|
||||
},
|
||||
margin: { l: 0, r: 30, t: 32, b: 0 },
|
||||
uirevision: "obi-surface-v2",
|
||||
autosize: true,
|
||||
font: { color: "#888" },
|
||||
};
|
||||
|
||||
const config = {
|
||||
displayModeBar: true,
|
||||
modeBarButtonsToRemove: ["sendDataToCloud", "zoom2d", "pan2d", "select2d", "lasso2d", "autoScale2d"],
|
||||
displaylogo: false,
|
||||
responsive: true,
|
||||
};
|
||||
|
||||
if (plotlyReady.current) {
|
||||
Plotly.react(containerRef.current, [trace], layout, config);
|
||||
} else {
|
||||
Plotly.newPlot(containerRef.current, [trace], layout, config);
|
||||
plotlyReady.current = true;
|
||||
}
|
||||
}, [surface, loaded]);
|
||||
|
||||
// Resize on container width change
|
||||
useEffect(() => {
|
||||
const obs = new ResizeObserver(() => {
|
||||
const Plotly = (window as any).Plotly;
|
||||
if (containerRef.current && Plotly) {
|
||||
Plotly.Plots.resize(containerRef.current);
|
||||
}
|
||||
});
|
||||
if (containerRef.current) obs.observe(containerRef.current);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={containerRef} style={{ width: "100%", height }} />
|
||||
|
||||
{/* Imbalance Overlay */}
|
||||
{metrics && (
|
||||
<div className="absolute top-3 right-4 z-10 flex flex-col gap-2 pointer-events-none">
|
||||
<div className="bg-black/70 backdrop-blur-lg rounded-lg px-3.5 py-2.5 border border-white/10">
|
||||
<p className="text-[8px] text-muted-foreground uppercase tracking-widest mb-1">Live Imbalance</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xl font-mono font-bold ${metrics.imbalance > 0.005 ? "text-green-400" : metrics.imbalance < -0.005 ? "text-red-400" : "text-zinc-400"}`}
|
||||
>
|
||||
{metrics.imbalance > 0 ? "+" : ""}{metrics.imbalance.toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
{metrics.wallSide !== "none" && (
|
||||
<p className={`text-[9px] mt-0.5 ${metrics.wallSide === "bid" ? "text-green-400/80" : "text-red-400/80"}`}>
|
||||
Wall: {metrics.wallSide.toUpperCase()}S ({(metrics.wallStrength ?? 0).toFixed(1)})
|
||||
</p>
|
||||
)}
|
||||
<div className="w-full h-1 bg-white/10 rounded-full mt-1.5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${metrics.imbalance >= 0 ? "bg-green-500" : "bg-red-500"}`}
|
||||
style={{
|
||||
width: `${Math.min(Math.abs(metrics.imbalance) * 350, 100)}%`,
|
||||
marginLeft: metrics.imbalance >= 0 ? "50%" : `${50 - Math.min(Math.abs(metrics.imbalance) * 350, 100) / 2}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-black/70 backdrop-blur-lg rounded-lg px-3 py-1.5 border border-white/10">
|
||||
<p className="text-[8px] text-muted-foreground font-mono">
|
||||
I = (V<sub>b</sub> − V<sub>a</sub>) / (V<sub>b</sub> + V<sub>a</sub>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { DepthMapPlotly } from "@/components/depth-map-plotly";
|
||||
import OrderBookDepthMap from "@/components/orderbook-depth-map";
|
||||
import { EquityChart } from "@/components/equity-chart";
|
||||
import {
|
||||
type L2Snapshot, type SurfaceData, type ImbalanceMetrics,
|
||||
L2RingBuffer, l2SnapshotsToSurface, computeImbalance, generateSyntheticSnapshots,
|
||||
} from "@/lib/depth-map-utils";
|
||||
import type { Strategy, Trade, LiveMetrics } from "@/lib/types";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
interface OBIDetailProps {
|
||||
strategy: Strategy;
|
||||
strategyName: string;
|
||||
equityData: { t: number; v: number }[];
|
||||
trades: Trade[];
|
||||
liveData: LiveMetrics | null;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function OBIDetail({ strategy, strategyName, equityData, trades, liveData, color }: OBIDetailProps) {
|
||||
const ringBuffer = useRef(new L2RingBuffer(60));
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
// Generate synthetic L2 data for live visualization
|
||||
useEffect(() => {
|
||||
// Initial batch
|
||||
const snaps = generateSyntheticSnapshots(60);
|
||||
for (const s of snaps) ringBuffer.current.push(s);
|
||||
setTick(t => t + 1);
|
||||
|
||||
// Continuous updates
|
||||
const iv = setInterval(() => {
|
||||
const newSnaps = generateSyntheticSnapshots(1);
|
||||
ringBuffer.current.push(newSnaps[0]);
|
||||
setTick(t => t + 1);
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
// Compute dual surface + metrics
|
||||
const snapsNow = ringBuffer.current.snapshot();
|
||||
const surfaceNow: SurfaceData | null = snapsNow.length >= 3
|
||||
? l2SnapshotsToSurface(snapsNow, 50, 60)
|
||||
: null;
|
||||
const metricsNow: ImbalanceMetrics | null = snapsNow.length > 0
|
||||
? computeImbalance(snapsNow[snapsNow.length - 1])
|
||||
: null;
|
||||
|
||||
// Strategy stats
|
||||
const pnl = strategy.pnl ?? 0;
|
||||
const pnlPct = strategy.pnl_pct ?? 0;
|
||||
const winRate = strategy.win_rate ?? 0;
|
||||
|
||||
// BTC buy-and-hold from live data
|
||||
const btcPrice = liveData?.equity_history?.length
|
||||
? liveData.equity_history[liveData.equity_history.length - 1].v
|
||||
: null;
|
||||
const btcStart = liveData?.equity_history?.length
|
||||
? liveData.equity_history[0].v
|
||||
: null;
|
||||
const btcReturn = btcPrice && btcStart ? ((btcPrice - btcStart) / btcStart * 100) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-amber-400" />
|
||||
Order Book Imbalance • BTC-USD-PERP
|
||||
</h3>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
L2 bid/ask volume skew — 3D depth map with synchronized bid/ask subplots
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3D Subplots: Bid (left) + Ask (right) */}
|
||||
<Card className="overflow-hidden border-border">
|
||||
<DepthMapPlotly surface={surfaceNow} metrics={metricsNow} height={440} />
|
||||
</Card>
|
||||
|
||||
{/* Metrics Row */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
|
||||
{([
|
||||
{ l: "Strategy PnL", v: `$${pnl.toFixed(4)} (${pnlPct >= 0 ? "+" : ""}${pnlPct.toFixed(2)}%)`, up: pnlPct >= 0 },
|
||||
{ l: "BTC B&H", v: btcReturn !== null ? `${btcReturn >= 0 ? "+" : ""}${btcReturn.toFixed(2)}%` : "—", up: (btcReturn ?? 0) >= 0 },
|
||||
{ l: "Hit Rate", v: `${Math.round(winRate * 100)}%` },
|
||||
{ l: "Imbalance", v: metricsNow ? `${metricsNow.imbalance > 0 ? "+" : ""}${metricsNow.imbalance.toFixed(3)}` : "—", up: (metricsNow?.imbalance ?? 0) > 0 },
|
||||
{ l: "Bid Vol", v: metricsNow ? `$${metricsNow.bidVolume.toFixed(1)}` : "—" },
|
||||
{ l: "Ask Vol", v: metricsNow ? `$${metricsNow.askVolume.toFixed(1)}` : "—" },
|
||||
]).map(({ l, v, up }) => (
|
||||
<div key={l} className="p-3 rounded-lg border border-border bg-card/50">
|
||||
<p className="text-[9px] text-muted-foreground uppercase tracking-wider mb-1">{l}</p>
|
||||
<p className={`text-sm font-mono font-semibold ${up === true ? "text-green-500" : up === false ? "text-red-500" : ""}`}>
|
||||
{v}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Equity Curve: Strategy vs BTC B&H */}
|
||||
{equityData.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Equity Curve — {strategyName} vs BTC Buy & Hold
|
||||
</p>
|
||||
<div className="rounded-lg border border-border overflow-hidden h-[260px]">
|
||||
<EquityChart data={equityData} color={color} height={260} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trade History */}
|
||||
<div>
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2 pb-2 border-b border-border">
|
||||
Trade History {trades.length > 0 ? `(${trades.length})` : ""}
|
||||
</h4>
|
||||
{trades.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-border hover:bg-transparent">
|
||||
<TableHead className="text-[9px] h-7">Time</TableHead>
|
||||
<TableHead className="text-[9px] h-7">Side</TableHead>
|
||||
<TableHead className="text-[9px] h-7">Size</TableHead>
|
||||
<TableHead className="text-[9px] h-7">Price</TableHead>
|
||||
<TableHead className="text-[9px] h-7 text-right">PnL</TableHead>
|
||||
<TableHead className="text-[9px] h-7 text-right">Fee</TableHead>
|
||||
<TableHead className="text-[9px] h-7">Reason</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{trades.slice(-100).reverse().map((t, i) => (
|
||||
<TableRow key={i} className="border-border/50 hover:bg-muted/30">
|
||||
<TableCell className="text-[10px] py-1.5 font-mono whitespace-nowrap">{(t.time ?? "").substring(0, 16)}</TableCell>
|
||||
<TableCell className="text-[10px] py-1.5">
|
||||
<Badge variant="outline" className={`text-[9px] h-4 px-1.5 border-0 ${(t.side ?? "").indexOf("BUY") >= 0 ? "bg-green-500/10 text-green-500" : "bg-red-500/10 text-red-500"}`}>
|
||||
{t.side ?? "—"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-[10px] py-1.5 font-mono">{t.size}</TableCell>
|
||||
<TableCell className="text-[10px] py-1.5 font-mono">${(t.price ?? 0).toFixed(1)}</TableCell>
|
||||
<TableCell className={`text-[10px] py-1.5 font-mono text-right ${(t.pnl ?? 0) >= 0 ? "text-green-500" : "text-red-500"}`}>
|
||||
{(t.pnl ?? 0) >= 0 ? "+" : ""}${Math.abs(t.pnl ?? 0).toFixed(4)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[10px] py-1.5 font-mono text-right text-red-400">${(t.fee ?? 0).toFixed(4)}</TableCell>
|
||||
<TableCell className="text-[10px] py-1.5 text-muted-foreground max-w-[200px] truncate">{t.reason ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-8">No trades recorded yet</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Live L2 Order Book + Trade Tape */}
|
||||
<div className="mt-6">
|
||||
<OrderBookDepthMap coin="BTC" height={480} topRatio={0.55} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { useHyperliquidWebSocket, type L2Snapshot, type TradeTapeEntry } from "@/lib/hyperliquid-ws";
|
||||
|
||||
// ═══════════════════════ Color Palette ═══════════════════════
|
||||
const BID_COLOR = "#00C853";
|
||||
const ASK_COLOR = "#FF1744";
|
||||
const MID_COLOR = "#FFEB3B";
|
||||
const TRADE_PATH = "#FFAB00";
|
||||
const TEXT_COLOR = "#CCCCCC";
|
||||
const TEXT_BRIGHT = "#FFFFFF";
|
||||
const BG_COLOR = "#000000";
|
||||
const GRID_COLOR = "rgba(255,255,255,0.04)";
|
||||
|
||||
// ═══════════════════════ Quant Overlay Types ═══════════════════════
|
||||
export interface QuantOverlay {
|
||||
/** Horizontal line at a fair value price */
|
||||
fairValue?: number;
|
||||
/** VWAP band: { mid, upper, lower } */
|
||||
vwap?: { mid: number; upper: number; lower: number };
|
||||
/** Imbalance annotation point */
|
||||
imbalance?: { value: number; label: string };
|
||||
/** Custom signal markers at specific prices */
|
||||
signals?: { px: number; label: string; color: string }[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
coin?: string;
|
||||
height?: number;
|
||||
topRatio?: number; // fraction for L2 panel (0-1)
|
||||
overlays?: QuantOverlay;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function OrderBookDepthMap({
|
||||
coin = "BTC",
|
||||
height = 600,
|
||||
topRatio = 0.55,
|
||||
overlays,
|
||||
className = "",
|
||||
}: Props) {
|
||||
const topCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const botCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const topH = Math.round(height * topRatio);
|
||||
const botH = height - topH - 2;
|
||||
|
||||
const { l2, trades, connected, error } = useHyperliquidWebSocket(coin);
|
||||
|
||||
// ── L2 Profile Render ──
|
||||
useEffect(() => {
|
||||
const canvas = topCanvas.current;
|
||||
if (!canvas || !l2) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth;
|
||||
const H = canvas.clientHeight;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = BG_COLOR;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const margin = { top: 20, bottom: 30, left: 60, right: 60 };
|
||||
const plotW = W - margin.left - margin.right;
|
||||
const plotH = H - margin.top - margin.bottom;
|
||||
|
||||
// Price range: center on mid, show ±2% on each side
|
||||
const mid = l2.mid;
|
||||
const priceRange = mid * 0.04; // ±2%
|
||||
const pMin = mid - priceRange;
|
||||
const pMax = mid + priceRange;
|
||||
|
||||
// Find max volume for scaling
|
||||
const allVols = [
|
||||
...l2.bids.slice(0, 100).map((l) => l.sz),
|
||||
...l2.asks.slice(0, 100).map((l) => l.sz),
|
||||
];
|
||||
const maxVol = Math.max(...allVols, 1);
|
||||
const volScale = Math.max(maxVol * 1.2, 10);
|
||||
|
||||
const priceToX = (px: number) => margin.left + ((px - pMin) / (pMax - pMin)) * plotW;
|
||||
const volToH = (sz: number) => (sz / volScale) * plotH;
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = GRID_COLOR;
|
||||
ctx.lineWidth = 1;
|
||||
const gridSteps = 10;
|
||||
for (let i = 0; i <= gridSteps; i++) {
|
||||
const y = margin.top + (i / gridSteps) * plotH;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin.left, y);
|
||||
ctx.lineTo(margin.left + plotW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw bid bars (green, right-to-left from mid)
|
||||
for (const bid of l2.bids.slice(0, 100)) {
|
||||
if (bid.px > mid + 50) continue; // Skip far bids
|
||||
const x = priceToX(bid.px);
|
||||
const barW = Math.max(1, plotW / 200);
|
||||
const barH = volToH(bid.sz);
|
||||
const y = margin.top + plotH - barH;
|
||||
|
||||
ctx.fillStyle = BID_COLOR;
|
||||
ctx.fillRect(x - barW / 2, y, barW, barH);
|
||||
}
|
||||
|
||||
// Draw ask bars (red, left-to-right from mid)
|
||||
for (const ask of l2.asks.slice(0, 100)) {
|
||||
if (ask.px < mid - 50) continue;
|
||||
const x = priceToX(ask.px);
|
||||
const barW = Math.max(1, plotW / 200);
|
||||
const barH = volToH(ask.sz);
|
||||
const y = margin.top + plotH - barH;
|
||||
|
||||
ctx.fillStyle = ASK_COLOR;
|
||||
ctx.fillRect(x - barW / 2, y, barW, barH);
|
||||
}
|
||||
|
||||
// Mid-price line
|
||||
const midX = priceToX(mid);
|
||||
ctx.strokeStyle = MID_COLOR;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(midX, margin.top);
|
||||
ctx.lineTo(midX, margin.top + plotH);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Volume scale labels (right side)
|
||||
ctx.fillStyle = TEXT_COLOR;
|
||||
ctx.font = "9px monospace";
|
||||
ctx.textAlign = "right";
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const vol = Math.round((volScale * i) / 4);
|
||||
const y = margin.top + plotH - (i / 4) * plotH;
|
||||
ctx.fillText(vol.toLocaleString(), W - 4, y + 3);
|
||||
}
|
||||
|
||||
// Price labels (bottom)
|
||||
ctx.textAlign = "center";
|
||||
const priceLabels = 6;
|
||||
for (let i = 0; i <= priceLabels; i++) {
|
||||
const px = pMin + (i / priceLabels) * priceRange;
|
||||
const x = priceToX(px);
|
||||
ctx.fillText(px.toFixed(1), x, H - 4);
|
||||
}
|
||||
|
||||
// Mid price marker (floating)
|
||||
ctx.fillStyle = TEXT_BRIGHT;
|
||||
ctx.font = "bold 11px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(mid.toFixed(1), midX, margin.top + plotH / 2 - 12);
|
||||
ctx.fillText(mid.toFixed(1), midX, margin.top + plotH / 2 + 18);
|
||||
|
||||
// Orange dot at mid baseline
|
||||
ctx.fillStyle = "#FF9100";
|
||||
ctx.beginPath();
|
||||
ctx.arc(midX, margin.top + plotH, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// ── Quant Overlays ──
|
||||
if (overlays) {
|
||||
// Fair value line
|
||||
if (overlays.fairValue) {
|
||||
const fvX = priceToX(overlays.fairValue);
|
||||
ctx.strokeStyle = "rgba(33, 150, 243, 0.7)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([3, 6]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(fvX, margin.top);
|
||||
ctx.lineTo(fvX, margin.top + plotH);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = "#2196F3";
|
||||
ctx.font = "9px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("FV", fvX, margin.top - 4);
|
||||
}
|
||||
|
||||
// VWAP bands
|
||||
if (overlays.vwap) {
|
||||
for (const [px, color] of [
|
||||
[overlays.vwap.upper, "rgba(255,152,0,0.4)"],
|
||||
[overlays.vwap.mid, "rgba(255,152,0,0.6)"],
|
||||
[overlays.vwap.lower, "rgba(255,152,0,0.4)"],
|
||||
] as const) {
|
||||
const vx = priceToX(px);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(vx, margin.top);
|
||||
ctx.lineTo(vx, margin.top + plotH);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Signal markers
|
||||
if (overlays.signals) {
|
||||
for (const sig of overlays.signals) {
|
||||
const sx = priceToX(sig.px);
|
||||
ctx.fillStyle = sig.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, margin.top + 15, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = TEXT_BRIGHT;
|
||||
ctx.font = "8px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(sig.label, sx, margin.top + 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header
|
||||
ctx.fillStyle = TEXT_COLOR;
|
||||
ctx.font = "10px monospace";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(`L2 Order Book \u00B7 ${coin}-USD \u00B7 LIVE`, 8, 12);
|
||||
ctx.fillStyle = connected ? "#00C853" : "#FF1744";
|
||||
ctx.fillText(connected ? "\u25CF" : "\u25CF", W - 18, 12);
|
||||
}, [l2, connected, coin, overlays, topH]);
|
||||
|
||||
// ── Trade Tape Render ──
|
||||
useEffect(() => {
|
||||
const canvas = botCanvas.current;
|
||||
if (!canvas || trades.length < 2) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth;
|
||||
const H = canvas.clientHeight;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "#0A0A0A"; // Slightly lighter than pure black
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const margin = { top: 20, bottom: 15, left: 8, right: 8 };
|
||||
const plotW = W - margin.left - margin.right;
|
||||
const plotH = H - margin.top - margin.bottom;
|
||||
|
||||
// Find price range
|
||||
const prices = trades.map((t) => t.px);
|
||||
const pMin = Math.min(...prices);
|
||||
const pMax = Math.max(...prices);
|
||||
const pRange = pMax - pMin || 1;
|
||||
const pPad = pRange * 0.1 || 10;
|
||||
const pLo = pMin - pPad;
|
||||
const pHi = pMax + pPad;
|
||||
|
||||
const priceToY = (px: number) => margin.top + plotH - ((px - pLo) / (pHi - pLo)) * plotH;
|
||||
|
||||
// Draw trade path
|
||||
ctx.strokeStyle = TRADE_PATH;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < trades.length; i++) {
|
||||
const x = margin.left + (i / trades.length) * plotW;
|
||||
const y = priceToY(trades[i].px);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Draw individual trade markers
|
||||
const maxSz = Math.max(...trades.map((t) => t.sz), 1);
|
||||
for (const trade of trades) {
|
||||
const idx = trades.indexOf(trade);
|
||||
const x = margin.left + (idx / trades.length) * plotW;
|
||||
const y = priceToY(trade.px);
|
||||
const r = Math.max(1, (trade.sz / maxSz) * 3 + 1);
|
||||
|
||||
const color = trade.side === "buy" ? "#66BB6A" : "#EF5350";
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Latest trade marker
|
||||
const lastTrade = trades[trades.length - 1];
|
||||
const lx = margin.left + ((trades.length - 1) / trades.length) * plotW;
|
||||
const ly = priceToY(lastTrade.px);
|
||||
ctx.strokeStyle = lastTrade.side === "buy" ? "#00E676" : "#FF5252";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(lx, ly, 4, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Latest price label
|
||||
ctx.fillStyle = TEXT_BRIGHT;
|
||||
ctx.font = "10px monospace";
|
||||
ctx.textAlign = "left";
|
||||
const sideLabel = lastTrade.side === "buy" ? "B" : "S";
|
||||
const sideColor = lastTrade.side === "buy" ? "#00E676" : "#FF5252";
|
||||
ctx.fillStyle = sideColor;
|
||||
ctx.fillText(`${sideLabel} ${lastTrade.px.toFixed(1)}`, 8, 12);
|
||||
ctx.fillStyle = TEXT_COLOR;
|
||||
ctx.fillText(` | ${lastTrade.sz.toFixed(4)}`, 80, 12);
|
||||
|
||||
// Header
|
||||
ctx.fillStyle = TEXT_COLOR;
|
||||
ctx.font = "9px monospace";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`Trades \u00B7 ${trades.length}`, W - 8, 12);
|
||||
}, [trades]);
|
||||
|
||||
// ── Empty states ──
|
||||
const noL2 = !l2 && !error;
|
||||
|
||||
return (
|
||||
<div className={`bg-black ${className}`} style={{ height }}>
|
||||
{/* Top: L2 Volume Profile */}
|
||||
<div style={{ height: topH }} className="relative">
|
||||
<canvas ref={topCanvas} className="w-full h-full" />
|
||||
{noL2 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-gray-500 text-xs font-mono">
|
||||
{connected ? "Waiting for L2 data..." : "Connecting to Hyperliquid..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute top-1 right-1 text-red-500 text-[9px] font-mono">
|
||||
{error} — reconnecting...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: Trade Tape */}
|
||||
<div style={{ height: botH }} className="relative">
|
||||
<canvas ref={botCanvas} className="w-full h-full" />
|
||||
{trades.length < 2 && !error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-gray-600 text-xs font-mono">Waiting for trades...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,17 +11,29 @@ interface StrategyCardProps {
|
||||
tab: "live" | "paper" | "backtest" | "historical";
|
||||
onClick: () => void;
|
||||
badge?: string;
|
||||
coin?: string;
|
||||
stats?: { label: string; value: string; negative?: boolean }[];
|
||||
pnlPct?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export function StrategyCard({ name, strategy, tab, onClick, badge, stats, pnlPct, status }: StrategyCardProps) {
|
||||
export function StrategyCard({ name, strategy, tab, onClick, badge, stats, pnlPct, status, coin }: StrategyCardProps) {
|
||||
if (strategy) {
|
||||
const equity = strategy.allocation + (strategy.pnl ?? 0);
|
||||
const isUp = equity >= strategy.allocation;
|
||||
const pnl = strategy.pnl ?? 0;
|
||||
const pnlPctVal = strategy.pnl_pct ?? 0;
|
||||
// Type colors
|
||||
const typeColors: Record<string, string> = {
|
||||
reversal: "bg-blue-500/20 text-blue-400",
|
||||
momentum: "bg-amber-500/20 text-amber-400",
|
||||
stat_arb: "bg-purple-500/20 text-purple-400",
|
||||
carry: "bg-cyan-500/20 text-cyan-400",
|
||||
market_making: "bg-emerald-500/20 text-emerald-400",
|
||||
};
|
||||
const typeColor = typeColors[strategy.type] || "bg-gray-500/20 text-gray-400";
|
||||
// Asset shorthand
|
||||
const assetShort = strategy.instrument?.split("-")[0] || "";
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -31,9 +43,10 @@ export function StrategyCard({ name, strategy, tab, onClick, badge, stats, pnlPc
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold leading-tight">{name}</p>
|
||||
<p className="text-[9px] text-muted-foreground mt-0.5">
|
||||
${strategy.allocation} · {strategy.type}
|
||||
</p>
|
||||
<div className="flex gap-1 mt-0.5">
|
||||
<span className={`text-[8px] px-1.5 py-px rounded font-mono ${typeColor}`}>{strategy.type}</span>
|
||||
<span className="text-[9px] text-muted-foreground">{assetShort}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Badge variant={strategy.status === "running" ? "default" : "secondary"} className="text-[8px] h-4 px-1.5">
|
||||
@@ -76,6 +89,7 @@ export function StrategyCard({ name, strategy, tab, onClick, badge, stats, pnlPc
|
||||
<p className="text-[9px] text-muted-foreground mt-0.5">{badge ?? "Backtest"}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-[8px] h-4 px-1.5">{status ?? "BACKTEST"}</Badge>
|
||||
{coin && <Badge variant="outline" className="text-[8px] h-4 px-1.5 bg-blue-500/10 text-blue-400 border-0">{coin}</Badge>}
|
||||
</div>
|
||||
<div className={`text-xl font-mono font-bold mb-2 flex items-center gap-1 ${(pnlPct ?? 0) >= 0 ? "text-green-500" : "text-red-500"}`}>
|
||||
{(pnlPct ?? 0) >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||
|
||||
@@ -82,7 +82,8 @@ export async function fetchHistorical(): Promise<Record<string, import("./types"
|
||||
const list: import("./types").BacktestSummary[] = await res.json();
|
||||
const byStrat: Record<string, import("./types").BacktestSummary> = {};
|
||||
for (const b of list) {
|
||||
if (!byStrat[b.strategy]) byStrat[b.strategy] = b;
|
||||
const key = `${b.strategy} · ${b.coin ?? "?"}`;
|
||||
if (!byStrat[key]) byStrat[key] = b;
|
||||
}
|
||||
return byStrat;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Order Book Depth Map — Data Utilities
|
||||
*
|
||||
* Transforms raw Hyperliquid L2 snapshots into surface matrices
|
||||
* for 3D visualization.
|
||||
*
|
||||
* Architecture:
|
||||
* Ring buffer stores last N snapshots.
|
||||
* Each snapshot: { bids: [px, sz][], asks: [px, sz][], mid: number, ts: number }
|
||||
* Output: { x: bps[], y: snapshot_index[], z: size[][] }
|
||||
*
|
||||
* Ring-buffer design:
|
||||
* - Fixed capacity (default 60 = ~1 minute at 1s updates)
|
||||
* - O(1) append via write pointer
|
||||
* - No allocations on append → suitable for 60fps streaming
|
||||
*/
|
||||
|
||||
export interface L2Level {
|
||||
px: number;
|
||||
sz: number;
|
||||
}
|
||||
|
||||
export interface L2Snapshot {
|
||||
bids: L2Level[]; // sorted descending by price
|
||||
asks: L2Level[]; // sorted ascending by price
|
||||
mid: number;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface SurfaceData {
|
||||
/** Distance from mid in basis points (X-axis) */
|
||||
x: number[];
|
||||
/** Snapshot index or cumulative bid count (Y-axis) */
|
||||
y: number[];
|
||||
/** Resting size matrix: z[row][col] — rows = snapshots, cols = bps */
|
||||
z: number[][];
|
||||
}
|
||||
|
||||
export interface ImbalanceMetrics {
|
||||
/** Current imbalance: (V_bid - V_ask) / (V_bid + V_ask) */
|
||||
imbalance: number;
|
||||
bidVolume: number;
|
||||
askVolume: number;
|
||||
wallSide: "bid" | "ask" | "none";
|
||||
wallStrength: number;
|
||||
snapshots: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ring buffer for L2 snapshots.
|
||||
* Fixed capacity, overwrite oldest on overflow.
|
||||
*/
|
||||
export class L2RingBuffer {
|
||||
private buffer: L2Snapshot[];
|
||||
private capacity: number;
|
||||
private writeIdx: number;
|
||||
private count: number;
|
||||
|
||||
constructor(capacity: number = 60) {
|
||||
this.capacity = capacity;
|
||||
this.buffer = new Array(capacity);
|
||||
this.writeIdx = 0;
|
||||
this.count = 0;
|
||||
}
|
||||
|
||||
push(snapshot: L2Snapshot): void {
|
||||
this.buffer[this.writeIdx] = snapshot;
|
||||
this.writeIdx = (this.writeIdx + 1) % this.capacity;
|
||||
if (this.count < this.capacity) this.count++;
|
||||
}
|
||||
|
||||
/** Returns snapshots oldest-first */
|
||||
snapshot(): L2Snapshot[] {
|
||||
if (this.count === 0) return [];
|
||||
const start = this.count < this.capacity ? 0 : this.writeIdx;
|
||||
const result: L2Snapshot[] = [];
|
||||
for (let i = 0; i < this.count; i++) {
|
||||
result.push(this.buffer[(start + i) % this.capacity]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.count;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.writeIdx = 0;
|
||||
this.count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert L2 snapshots → surface matrix.
|
||||
*
|
||||
* X-axis: distance from mid in basis points
|
||||
* Y-axis: snapshot index (0 = oldest, N = newest)
|
||||
* Z-axis: resting size at that bps level
|
||||
*
|
||||
* @param snapshots Ring buffer contents (oldest first)
|
||||
* @param bpsRange ±bps from mid to cover (default: 50)
|
||||
* @param resolution Number of bps steps (default: 100)
|
||||
*/
|
||||
export function l2SnapshotsToSurface(
|
||||
snapshots: L2Snapshot[],
|
||||
bpsRange: number = 50,
|
||||
resolution: number = 100,
|
||||
): SurfaceData {
|
||||
const bpsStep = (bpsRange * 2) / resolution;
|
||||
const x: number[] = [];
|
||||
for (let i = 0; i < resolution; i++) {
|
||||
x.push(-bpsRange + i * bpsStep);
|
||||
}
|
||||
|
||||
const y = snapshots.map((_, i) => i);
|
||||
const z: number[][] = [];
|
||||
|
||||
for (const snap of snapshots) {
|
||||
const row = new Array(resolution).fill(0);
|
||||
const mid = snap.mid;
|
||||
|
||||
// Fill bid side (negative bps)
|
||||
for (const bid of snap.bids) {
|
||||
const bps = ((bid.px - mid) / mid) * 10000;
|
||||
const idx = Math.round((bps + bpsRange) / bpsStep);
|
||||
if (idx >= 0 && idx < resolution) {
|
||||
row[idx] += bid.sz;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill ask side (positive bps)
|
||||
for (const ask of snap.asks) {
|
||||
const bps = ((ask.px - mid) / mid) * 10000;
|
||||
const idx = Math.round((bps + bpsRange) / bpsStep);
|
||||
if (idx >= 0 && idx < resolution) {
|
||||
row[idx] += ask.sz;
|
||||
}
|
||||
}
|
||||
|
||||
z.push(row);
|
||||
}
|
||||
|
||||
return { x, y, z };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute imbalance metrics from latest snapshot.
|
||||
*/
|
||||
export function computeImbalance(snapshot: L2Snapshot): ImbalanceMetrics {
|
||||
const bidVolume = snapshot.bids.reduce((sum, b) => sum + b.sz * b.px, 0);
|
||||
const askVolume = snapshot.asks.reduce((sum, a) => sum + a.sz * a.px, 0);
|
||||
const total = bidVolume + askVolume;
|
||||
const imbalance = total > 0 ? (bidVolume - askVolume) / total : 0;
|
||||
|
||||
// Wall detection: find side with largest concentration
|
||||
const maxBidSz = Math.max(...snapshot.bids.map(b => b.sz), 0);
|
||||
const maxAskSz = Math.max(...snapshot.asks.map(a => a.sz), 0);
|
||||
const wallSide: "bid" | "ask" | "none" =
|
||||
maxBidSz > maxAskSz * 1.3 ? "bid" :
|
||||
maxAskSz > maxBidSz * 1.3 ? "ask" : "none";
|
||||
const wallStrength = Math.max(maxBidSz, maxAskSz);
|
||||
|
||||
return {
|
||||
imbalance: Math.round(imbalance * 10000) / 10000,
|
||||
bidVolume: Math.round(bidVolume * 100) / 100,
|
||||
askVolume: Math.round(askVolume * 100) / 100,
|
||||
wallSide,
|
||||
wallStrength: Math.round(wallStrength * 10000) / 10000,
|
||||
snapshots: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate synthetic L2 data for testing/development.
|
||||
* Produces realistic order-book shapes with price movement.
|
||||
*/
|
||||
export function generateSyntheticSnapshots(
|
||||
count: number = 60,
|
||||
basePrice: number = 97800,
|
||||
): L2Snapshot[] {
|
||||
const snapshots: L2Snapshot[] = [];
|
||||
let price = basePrice;
|
||||
let trend = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Random walk with mean reversion
|
||||
trend += (Math.random() - 0.5) * 2;
|
||||
trend *= 0.95; // decay
|
||||
price += trend * 50;
|
||||
price += (basePrice - price) * 0.01; // mean reversion
|
||||
|
||||
const mid = price;
|
||||
const bids: L2Level[] = [];
|
||||
const asks: L2Level[] = [];
|
||||
|
||||
// Generate 20 levels on each side
|
||||
for (let j = 0; j < 20; j++) {
|
||||
const bps = (j + 1) * 2.5;
|
||||
const bidPx = mid * (1 - bps / 10000);
|
||||
const askPx = mid * (1 + bps / 10000);
|
||||
|
||||
// Realistic size distribution: thicker near mid, thinner further out
|
||||
// Add wall at certain levels
|
||||
const baseSize = Math.exp(-j * 0.15) * 5;
|
||||
const bidWall = j === 3 ? Math.random() * 15 : 0; // occasional wall at 10bps
|
||||
const askWall = j === 5 ? Math.random() * 12 : 0;
|
||||
const noise = (Math.random() - 0.5) * 2;
|
||||
|
||||
bids.push({ px: Math.round(bidPx * 10) / 10, sz: Math.max(0.01, baseSize + bidWall + noise) });
|
||||
asks.push({ px: Math.round(askPx * 10) / 10, sz: Math.max(0.01, baseSize + askWall + noise) });
|
||||
}
|
||||
|
||||
snapshots.push({ bids, asks, mid, ts: Date.now() + i * 1000 });
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
// ═══════════ 3D Subplots: Split Bid/Ask Surfaces ═══════════
|
||||
|
||||
export interface DualSurfaceData {
|
||||
bid: SurfaceData;
|
||||
ask: SurfaceData;
|
||||
y: number[];
|
||||
}
|
||||
|
||||
/** Split L2 → dual bid/ask surface matrices for 3D subplots */
|
||||
export function l2SnapshotsToDualSurface(
|
||||
snapshots: L2Snapshot[],
|
||||
bpsRange: number = 50,
|
||||
resolution: number = 50,
|
||||
): DualSurfaceData {
|
||||
const bpsStep = bpsRange / resolution;
|
||||
const bidX: number[] = [], askX: number[] = [];
|
||||
for (let i = 0; i < resolution; i++) {
|
||||
bidX.push(-bpsRange + i * bpsStep);
|
||||
askX.push(i * bpsStep);
|
||||
}
|
||||
const y = snapshots.map((_, i) => i);
|
||||
const bidZ: number[][] = [], askZ: number[][] = [];
|
||||
for (const snap of snapshots) {
|
||||
const mid = snap.mid;
|
||||
const bRow = new Array(resolution).fill(0);
|
||||
const aRow = new Array(resolution).fill(0);
|
||||
for (const bid of snap.bids) {
|
||||
const bps = ((bid.px - mid) / mid) * 10000;
|
||||
const idx = Math.round((bps + bpsRange) / bpsStep);
|
||||
if (idx >= 0 && idx < resolution) bRow[idx] += bid.sz;
|
||||
}
|
||||
for (const ask of snap.asks) {
|
||||
const bps = ((ask.px - mid) / mid) * 10000;
|
||||
const idx = Math.round(bps / bpsStep);
|
||||
if (idx >= 0 && idx < resolution) aRow[idx] += ask.sz;
|
||||
}
|
||||
bidZ.push(bRow);
|
||||
askZ.push(aRow);
|
||||
}
|
||||
return { bid: { x: bidX, y, z: bidZ }, ask: { x: askX, y, z: askZ }, y };
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useCallback, useEffect, useState } from "react";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface L2Level {
|
||||
px: number;
|
||||
sz: number;
|
||||
n: number; // number of orders
|
||||
}
|
||||
|
||||
export interface L2Book {
|
||||
coin: string;
|
||||
levels: [L2Level[], L2Level[]]; // [bids, asks]
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface Trade {
|
||||
coin: string;
|
||||
side: string; // "A" = ask (sell), "B" = bid (buy)
|
||||
px: number;
|
||||
sz: number;
|
||||
hash: string;
|
||||
tid: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface L2Snapshot {
|
||||
bids: { px: number; sz: number }[];
|
||||
asks: { px: number; sz: number }[];
|
||||
mid: number;
|
||||
spread: number;
|
||||
totalBidVol: number;
|
||||
totalAskVol: number;
|
||||
imbalance: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface TradeTapeEntry {
|
||||
px: number;
|
||||
sz: number;
|
||||
side: "buy" | "sell";
|
||||
time: number;
|
||||
}
|
||||
|
||||
// ── WebSocket Hook ──
|
||||
|
||||
interface HyperliquidData {
|
||||
l2: L2Snapshot | null;
|
||||
trades: TradeTapeEntry[];
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useHyperliquidWebSocket(coin: string = "BTC"): HyperliquidData {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const l2Ref = useRef<L2Snapshot | null>(null);
|
||||
const tradesRef = useRef<TradeTapeEntry[]>([]);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const subscribed = useRef(false);
|
||||
|
||||
const [l2, setL2] = useState<L2Snapshot | null>(null);
|
||||
const [trades, setTrades] = useState<TradeTapeEntry[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
// Already connected — just resubscribe
|
||||
wsRef.current.send(JSON.stringify({ type: "subscribe", subscription: { type: "l2Book", coin } }));
|
||||
wsRef.current.send(JSON.stringify({ type: "subscribe", subscription: { type: "trades", coin } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Close stale connection
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
const ws = new WebSocket("wss://api.hyperliquid.xyz/ws");
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
subscribed.current = false;
|
||||
// Subscribe — Hyperliquid WebSocket uses "method" not "type"
|
||||
ws.send(JSON.stringify({ method: "subscribe", subscription: { type: "l2Book", coin } }));
|
||||
ws.send(JSON.stringify({ method: "subscribe", subscription: { type: "trades", coin } }));
|
||||
subscribed.current = true;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.channel === "l2Book" && msg.data?.levels) {
|
||||
const levels = msg.data.levels as [L2Level[], L2Level[]];
|
||||
const bids = (levels[0] || []).map((l) => ({ px: parseFloat(String(l.px)), sz: parseFloat(String(l.sz)) }));
|
||||
const asks = (levels[1] || []).map((l) => ({ px: parseFloat(String(l.px)), sz: parseFloat(String(l.sz)) }));
|
||||
|
||||
const bestBid = bids[0]?.px ?? 0;
|
||||
const bestAsk = asks[0]?.px ?? 0;
|
||||
const mid = (bestBid + bestAsk) / 2;
|
||||
const spread = bestAsk - bestBid;
|
||||
|
||||
// Calculate volume totals (top 20 levels)
|
||||
const topBids = bids.slice(0, 20);
|
||||
const topAsks = asks.slice(0, 20);
|
||||
const totalBidVol = topBids.reduce((s, l) => s + l.sz, 0);
|
||||
const totalAskVol = topAsks.reduce((s, l) => s + l.sz, 0);
|
||||
const imbalance = totalBidVol + totalAskVol > 0
|
||||
? (totalBidVol - totalAskVol) / (totalBidVol + totalAskVol)
|
||||
: 0;
|
||||
|
||||
const snapshot: L2Snapshot = {
|
||||
bids, asks, mid, spread,
|
||||
totalBidVol, totalAskVol, imbalance,
|
||||
time: Date.now(),
|
||||
};
|
||||
l2Ref.current = snapshot;
|
||||
setL2(snapshot);
|
||||
} else if (msg.channel === "trades" && Array.isArray(msg.data)) {
|
||||
const newTrades: TradeTapeEntry[] = msg.data.map((t: Trade) => ({
|
||||
px: parseFloat(String(t.px)),
|
||||
sz: parseFloat(String(t.sz)),
|
||||
side: t.side === "B" ? "buy" : "sell",
|
||||
time: t.time || Date.now(),
|
||||
}));
|
||||
// Append to ring buffer — keep last ~500 trades
|
||||
tradesRef.current = [...tradesRef.current, ...newTrades].slice(-500);
|
||||
setTrades([...tradesRef.current]);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setError("WebSocket error");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
// Auto-reconnect after 2s
|
||||
reconnectTimer.current = setTimeout(connect, 2000);
|
||||
};
|
||||
}, [coin]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { l2, trades, connected, error };
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export interface BacktestSummary {
|
||||
max_dd: number;
|
||||
win_rate: number;
|
||||
total_trades: number;
|
||||
coin?: string;
|
||||
coin: string;
|
||||
}
|
||||
|
||||
export interface BacktestFull {
|
||||
|
||||
+1
-675
File diff suppressed because one or more lines are too long
@@ -1,514 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||
<title>FTDT Quant Lab — Professional Dashboard</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.3/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<style>
|
||||
:root{--bg:#050508;--srf:#0b0b12;--ln:#181825;--hr:#222230;--tx:#6b6b7b;--hi:#d4d4e0;--gr:#22c55e;--rd:#ef4444;--bl:#3b82f6;--am:#f59e0b;--pu:#a855f7;--cy:#06b6d4;--pk:#ec4899;--ra:8px;--f:'Inter',system-ui,sans-serif;--m:'JetBrains Mono',monospace}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--hi);font-family:var(--f);min-height:100vh;-webkit-font-smoothing:antialiased}
|
||||
.topbar{position:sticky;top:0;z-index:100;background:rgba(5,5,8,.95);backdrop-filter:blur(20px);border-bottom:1px solid var(--ln);padding:12px 24px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px}
|
||||
.topbar h1{font-size:17px;font-weight:700;letter-spacing:-0.5px;display:flex;align-items:center;gap:8px}
|
||||
.topbar h1 span{font-size:10px;color:var(--tx);font-weight:400}
|
||||
.status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--gr);animation:pulse 2s infinite}
|
||||
.status-dot.off{background:var(--rd);animation:none}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.3}}
|
||||
.portfolio{text-align:right;min-width:140px}
|
||||
.portfolio .pnl{font-family:var(--m);font-size:28px;font-weight:700;letter-spacing:-1px}
|
||||
.portfolio .pnl.up{color:var(--gr)}.portfolio .pnl.dn{color:var(--rd)}
|
||||
.portfolio .sub{font-size:10px;color:var(--tx);text-transform:uppercase;letter-spacing:.5px}
|
||||
.tabs{display:flex;gap:0;padding:0 24px;border-bottom:1px solid var(--ln);position:sticky;top:52px;z-index:99;background:rgba(5,5,8,.95);backdrop-filter:blur(20px)}
|
||||
.tab{padding:10px 20px;font-size:12px;font-weight:500;cursor:pointer;background:none;border:none;border-bottom:2px solid transparent;color:var(--tx);font-family:var(--f);transition:all .15s}
|
||||
.tab:hover{color:var(--hi)}.tab.on{color:var(--hi);border-bottom-color:var(--bl)}
|
||||
.badge{font-size:8px;padding:2px 7px;border-radius:3px;font-weight:600;margin-left:6px;text-transform:uppercase;letter-spacing:.5px}
|
||||
.badge.test{background:rgba(245,158,11,.15);color:var(--am)}.badge.main{background:rgba(168,85,247,.15);color:var(--pu)}
|
||||
.main-wrap{max-width:1440px;margin:0 auto;padding:20px 24px;display:flex;gap:20px}
|
||||
.panel{display:none;flex:1;min-width:0}.panel.show{display:block}
|
||||
|
||||
/* Summary stats */
|
||||
.stats-row{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:16px}
|
||||
.stat{background:var(--srf);border:1px solid var(--ln);border-radius:var(--ra);padding:12px 14px}
|
||||
.stat .lbl{font-size:9px;color:var(--tx);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px}
|
||||
.stat .val{font-family:var(--m);font-size:17px;font-weight:600}
|
||||
.stat .val.up{color:var(--gr)}.stat .val.dn{color:var(--rd)}
|
||||
|
||||
/* Strategy grid */
|
||||
.sgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;margin-bottom:20px}
|
||||
.scard{background:var(--srf);border:1px solid var(--ln);border-radius:var(--ra);padding:16px;cursor:pointer;transition:all .2s;position:relative}
|
||||
.scard:hover{border-color:var(--hr);transform:translateY(-1px);box-shadow:0 4px 20px rgba(0,0,0,.3)}
|
||||
.scard.selected{border-color:var(--bl);box-shadow:0 0 0 1px rgba(59,130,246,.3)}
|
||||
.scard .sh{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px}
|
||||
.scard .sname{font-size:12px;font-weight:600;line-height:1.3;max-width:70%}
|
||||
.scard .salloc{font-size:9px;color:var(--tx);margin-top:2px}
|
||||
.scard .stag{font-size:8px;padding:2px 7px;border-radius:3px;font-weight:500;white-space:nowrap}
|
||||
.scard .stag.run{background:rgba(34,197,94,.1);color:var(--gr)}
|
||||
.scard .stag.idle{background:rgba(245,158,11,.1);color:var(--am)}
|
||||
.scard .stag.maker{background:rgba(59,130,246,.1);color:var(--bl)}
|
||||
.scard .stag.taker{background:rgba(239,68,68,.1);color:var(--rd)}
|
||||
.scard .spnl{font-family:var(--m);font-size:20px;font-weight:700;margin-bottom:6px}
|
||||
.scard .spnl.up{color:var(--gr)}.scard .spnl.dn{color:var(--rd)}
|
||||
.scard .smeta{display:flex;gap:12px;font-size:9px;color:var(--tx);flex-wrap:wrap}
|
||||
|
||||
/* Detail panel */
|
||||
.detail-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:200;display:none}
|
||||
.detail-overlay.on{display:flex;align-items:flex-start;justify-content:center;padding-top:40px}
|
||||
.detail-panel{background:var(--bg);border:1px solid var(--ln);border-radius:12px;width:95%;max-width:1100px;max-height:85vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.5)}
|
||||
.detail-header{position:sticky;top:0;background:var(--srf);padding:16px 20px;border-bottom:1px solid var(--ln);display:flex;align-items:center;justify-content:space-between;z-index:5}
|
||||
.detail-header h2{font-size:16px;font-weight:700}
|
||||
.close-btn{background:none;border:1px solid var(--ln);color:var(--hi);padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px;font-family:var(--f);transition:all .15s}
|
||||
.close-btn:hover{background:var(--hr)}
|
||||
.detail-body{padding:20px}
|
||||
.main-chart{width:100%;height:220px;margin:8px 0 0;border-radius:var(--ra);overflow:hidden;background:rgba(0,0,0,.25)}
|
||||
.detail-body .chart-wrap{width:100%;height:280px;margin-bottom:16px;border-radius:var(--ra);overflow:hidden}
|
||||
.detail-stats{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:16px}
|
||||
.detail-section{margin-bottom:20px}
|
||||
.detail-section h4{font-size:11px;font-weight:600;color:var(--tx);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--ln)}
|
||||
.trade-table{width:100%;border-collapse:collapse;font-family:var(--m)}
|
||||
.trade-table th{font-size:9px;font-weight:600;color:var(--tx);text-transform:uppercase;text-align:left;padding:8px 10px;border-bottom:1px solid var(--ln)}
|
||||
.trade-table td{font-size:11px;padding:7px 10px;border-bottom:1px solid rgba(255,255,255,.02);color:var(--hi)}
|
||||
.trade-table td.reason{font-size:10px;color:var(--tx);max-width:250px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:var(--f)}
|
||||
.green{color:var(--gr)}.red{color:var(--rd)}
|
||||
.desc-text{font-size:12px;color:var(--tx);line-height:1.6;padding:12px;background:var(--srf);border-radius:var(--ra);border:1px solid var(--ln);margin-bottom:16px}
|
||||
|
||||
/* Footer */
|
||||
footer{text-align:center;padding:30px;font-size:10px;color:#2a2a35}
|
||||
footer a{color:#3f3f4a;text-decoration:none}footer a:hover{color:var(--tx)}
|
||||
|
||||
/* Risk Analytics panel — collapsible */
|
||||
.risk-wrap{max-width:1440px;margin:0 auto 20px;padding:0 24px}
|
||||
.risk-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;background:none;border:1px solid var(--ln);border-radius:var(--ra);color:var(--tx);font-family:var(--f);font-size:11px;font-weight:600;padding:10px 16px;text-transform:uppercase;letter-spacing:.5px;transition:all .15s}
|
||||
.risk-toggle:hover{color:var(--hi);border-color:var(--hr)}
|
||||
.risk-toggle .arrow{display:inline-block;transition:transform .2s;font-size:10px}
|
||||
.risk-toggle.open .arrow{transform:rotate(90deg)}
|
||||
.risk-panel{display:none;background:var(--srf);border:1px solid var(--ln);border-radius:var(--ra);padding:16px;margin-top:8px}
|
||||
.risk-panel.show{display:block}
|
||||
.risk-corr{font-family:var(--m);font-size:10px;color:var(--tx);line-height:1.8;margin-top:12px;padding:10px;background:rgba(0,0,0,.2);border-radius:6px;max-height:200px;overflow-y:auto}
|
||||
.risk-corr .corr-high{color:var(--rd)}
|
||||
.risk-corr .corr-med{color:var(--am)}
|
||||
.risk-corr .corr-low{color:var(--tx)}
|
||||
|
||||
@media(max-width:768px){
|
||||
.topbar{padding:10px 14px;flex-direction:column;align-items:flex-start}
|
||||
.tabs{padding:0 14px;top:88px;overflow-x:auto;white-space:nowrap}
|
||||
.main-wrap{padding:12px 14px}
|
||||
.stats-row{grid-template-columns:repeat(3,1fr)}.sgrid{grid-template-columns:1fr 1fr}
|
||||
.detail-stats{grid-template-columns:repeat(3,1fr)}
|
||||
.portfolio .pnl{font-size:22px}
|
||||
}
|
||||
@media(max-width:380px){.stats-row{grid-template-columns:repeat(2,1fr)}.sgrid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top bar -->
|
||||
<div class="topbar">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span class="status-dot" id="sdot"></span><div><h1>FTDT Quant Lab<span>Professional Quant Dashboard</span></h1></div>
|
||||
</div>
|
||||
<div class="portfolio">
|
||||
<div style="font-size:9px;color:var(--tx);text-transform:uppercase;letter-spacing:.5px">Portfolio Equity</div>
|
||||
<div class="pnl" id="stpnl">$0.00</div>
|
||||
<div class="sub" id="stpct">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button class="tab on" id="tl-live" onclick="switchTab('live')">Live<span class="badge test">Testnet</span></button>
|
||||
<button class="tab" id="tl-paper" onclick="switchTab('paper')">Paper<span class="badge main">$100K Mainnet</span></button>
|
||||
<button class="tab" id="tl-backtest" onclick="switchTab('backtest')">Backtest</button>
|
||||
<button class="tab" id="tl-historical" onclick="switchTab('historical')">Historical<span class="badge main">Real Data</span></button>
|
||||
</div>
|
||||
<!-- Main -->
|
||||
<div class="main-wrap">
|
||||
<div class="panel show" id="pnl-live">
|
||||
<div class="stats-row" id="live-stats"></div>
|
||||
<div class="sgrid" id="live-sgrid"></div>
|
||||
<div class="chart-wrap main-chart" id="chart-live-wrap"><div id="chart-live"></div></div>
|
||||
</div>
|
||||
<div class="panel" id="pnl-paper">
|
||||
<div class="stats-row" id="paper-stats"></div>
|
||||
<div class="sgrid" id="paper-sgrid"></div>
|
||||
<div class="chart-wrap main-chart" id="chart-paper-wrap"><div id="chart-paper"></div></div>
|
||||
</div>
|
||||
<div class="panel" id="pnl-backtest">
|
||||
<div class="sgrid" id="bt-sgrid"></div>
|
||||
</div>
|
||||
<div class="panel" id="pnl-historical">
|
||||
<div class="sgrid" id="hist-sgrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Risk Analytics -->
|
||||
<div class="risk-wrap">
|
||||
<button class="risk-toggle" onclick="toggleRisk()" id="risk-btn"><span class="arrow">▶</span> Risk Analytics</button>
|
||||
<div class="risk-panel" id="risk-panel">
|
||||
<div class="stats-row" id="risk-stats" style="margin-bottom:12px"></div>
|
||||
<div class="risk-corr" id="risk-corr"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer><a href="https://git.ftdt.io/rams/ftdt-quant-lab" target="_blank">rams/ftdt-quant-lab</a> · 12 strategies · $100K paper · Hyperliquid</footer>
|
||||
|
||||
<!-- Detail Overlay -->
|
||||
<div class="detail-overlay" id="detail-overlay" onclick="event.target===this&&closeDetail()">
|
||||
<div class="detail-panel" id="detail-panel">
|
||||
<div class="detail-header">
|
||||
<h2 id="det-name">Strategy Detail</h2>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<label id="fee-toggle-wrap" style="display:none;font-size:11px;color:var(--tx);cursor:pointer;user-select:none">
|
||||
<input type="checkbox" id="fee-toggle" checked onchange="toggleFees()" style="cursor:pointer;margin-right:4px">Inc. fees
|
||||
</label>
|
||||
<select id="fee-tier-sel" style="display:none;font-size:10px;background:var(--srf);color:var(--hi);border:1px solid var(--ln);border-radius:4px;padding:3px 6px;font-family:var(--f)" onchange="onFeeTierChange()">
|
||||
<option value="0">Tier 0 (0.045/0.015%)</option>
|
||||
<option value="1">Tier 1 — >$5M (0.040/0.012%)</option>
|
||||
<option value="2">Tier 2 — >$25M (0.035/0.008%)</option>
|
||||
<option value="3">Tier 3 — >$100M (0.030/0.004%)</option>
|
||||
<option value="4">Tier 4 — >$500M (0.028/0.000%)</option>
|
||||
<option value="5">Tier 5 — >$2B (0.026/0.000%)</option>
|
||||
<option value="6">Tier 6 — >$7B (0.024/0.000%)</option>
|
||||
</select>
|
||||
<select id="stake-tier-sel" style="display:none;font-size:10px;background:var(--srf);color:var(--hi);border:1px solid var(--ln);border-radius:4px;padding:3px 6px;font-family:var(--f)" onchange="onFeeTierChange()">
|
||||
<option value="none">No Stake</option>
|
||||
<option value="wood">Wood (×0.95)</option>
|
||||
<option value="bronze">Bronze (×0.90)</option>
|
||||
<option value="silver">Silver (×0.85)</option>
|
||||
<option value="gold">Gold (×0.80)</option>
|
||||
<option value="platinum">Platinum (×0.70)</option>
|
||||
<option value="diamond">Diamond (×0.60)</option>
|
||||
</select>
|
||||
<a id="dl-csv" href="#" style="display:none;font-size:11px;color:var(--bl);text-decoration:none;padding:4px 10px;border:1px solid var(--ln);border-radius:5px" download>↓ CSV</a>
|
||||
<button class="close-btn" onclick="closeDetail()">✕ Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
<div class="desc-text" id="det-desc"></div>
|
||||
<div class="detail-stats" id="det-stats"></div>
|
||||
<div class="chart-wrap" id="det-chart-wrap"><div id="det-chart"></div></div>
|
||||
<div class="detail-section"><h4>Trade History</h4>
|
||||
<div style="overflow-x:auto"><table class="trade-table"><thead><tr><th>Time</th><th>Side</th><th>Size</th><th>Price</th><th>PnL</th><th>Fee</th><th>Reason / Signal</th></tr></thead><tbody id="det-trades"></tbody></table></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ═══════════ State ═══════════
|
||||
var currentTab='live', lastData=null, lastPaper=null, lastBT=null, lastBTFull=null, feeOn=true;
|
||||
var STRAT_COLORS=['#22c55e','#3b82f6','#a855f7','#f59e0b','#ef4444','#06b6d4','#ec4899','#84cc16','#6366f1','#14b8a6','#f97316','#8b5cf6'];
|
||||
|
||||
// ═══════════ Chart for detail view ═══════════
|
||||
var detChart=null, detSer=null;
|
||||
|
||||
// ═══════════ Main area charts ═══════════
|
||||
var chartLive=null, serLive=null, chartPaper=null, serPaper=null;
|
||||
function initMainCharts(){
|
||||
[{el:'chart-live',ch:'chartLive',sr:'serLive'},{el:'chart-paper',ch:'chartPaper',sr:'serPaper'}].forEach(function(c){
|
||||
var el=document.getElementById(c.el);if(!el)return;
|
||||
el.style.width='100%';el.style.height='220px';
|
||||
window[c.ch]=LightweightCharts.createChart(el,{
|
||||
layout:{background:{color:'transparent'},textColor:'#a0a0b0'},
|
||||
grid:{vertLines:{color:'rgba(255,255,255,.02)'},horzLines:{color:'rgba(255,255,255,.03)'}},
|
||||
rightPriceScale:{borderColor:'rgba(255,255,255,.08)',autoScale:true},
|
||||
timeScale:{borderColor:'rgba(255,255,255,.08)',timeVisible:false},
|
||||
crosshair:{mode:0},width:el.offsetWidth,height:220
|
||||
});
|
||||
window[c.sr]=window[c.ch].addAreaSeries({lineColor:'#3b82f6',topColor:'rgba(59,130,246,.15)',bottomColor:'rgba(59,130,246,.02)',lineWidth:2});
|
||||
});
|
||||
}
|
||||
function pushEquity(chart,ser,data){
|
||||
if(!chart||!ser||!data||!data.length)return;
|
||||
var pts=[];
|
||||
for(var i=0;i<data.length;i++){
|
||||
var t=data[i].t||data[i].time||data[i][0];
|
||||
var v=data[i].v||data[i].value||data[i].equity||data[i][1];
|
||||
if(typeof t==='number'){
|
||||
if(t>1e12)t=Math.floor(t/1000);
|
||||
pts.push({time:t,value:v});
|
||||
}
|
||||
}
|
||||
if(pts.length>0){ser.setData(pts);chart.timeScale().fitContent()}
|
||||
}
|
||||
function initDetChart(){
|
||||
var el=document.getElementById('det-chart');
|
||||
if(!el)return;
|
||||
el.style.width='100%'; el.style.height='280px';
|
||||
detChart=LightweightCharts.createChart(el,{
|
||||
layout:{background:{color:'transparent'},textColor:'#d4d4e0'},
|
||||
grid:{vertLines:{color:'rgba(255,255,255,.03)'},horzLines:{color:'rgba(255,255,255,.03)'}},
|
||||
rightPriceScale:{borderColor:'rgba(255,255,255,.08)'},
|
||||
timeScale:{borderColor:'rgba(255,255,255,.08)',timeVisible:true},
|
||||
crosshair:{mode:0},width:el.offsetWidth,height:280
|
||||
});
|
||||
detSer=detChart.addAreaSeries({lineColor:'#3b82f6',topColor:'rgba(59,130,246,.15)',bottomColor:'rgba(59,130,246,.02)',lineWidth:2});
|
||||
}
|
||||
|
||||
// ═══════════ Tab switching ═══════════
|
||||
function switchTab(t){
|
||||
currentTab=t;
|
||||
['live','paper','backtest','historical'].forEach(function(x){document.getElementById('tl-'+x).className=t===x?'tab on':'tab'});
|
||||
document.getElementById('pnl-live').className=t==='live'?'panel show':'panel';
|
||||
document.getElementById('pnl-paper').className=t==='paper'?'panel show':'panel';
|
||||
document.getElementById('pnl-backtest').className=t==='backtest'?'panel show':'panel';
|
||||
document.getElementById('pnl-historical').className=t==='historical'?'panel show':'panel';
|
||||
if(t==='live'&&lastData)renLive(lastData);
|
||||
if(t==='paper'&&lastPaper)renPaper(lastPaper);
|
||||
if(t==='backtest')loadBT();
|
||||
if(t==='historical')loadHistBT();
|
||||
}
|
||||
|
||||
// ═══════════ Render strategy cards ═══════════
|
||||
function renCards(sgridId,ss,baseEq,tab,statsRowId){
|
||||
var keys=Object.keys(ss),totalPnl=0,trades=0,fees=0,active=0;
|
||||
for(var i=0;i<keys.length;i++){var s=ss[keys[i]];totalPnl+=s.pnl||0;trades+=s.trades_today||0;fees+=s.fee_paid||0;if(s.status==='running')active++}
|
||||
if(statsRowId){
|
||||
document.getElementById(statsRowId).innerHTML='<div class="stat"><div class="lbl">Equity</div><div class="val">$'+((baseEq||0)+totalPnl).toFixed(0)+'</div></div>'+
|
||||
'<div class="stat"><div class="lbl">PnL</div><div class="val '+(totalPnl>=0?'up':'dn')+'">'+(totalPnl>=0?'+':'')+'$'+Math.abs(totalPnl).toFixed(2)+'</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Trades</div><div class="val">'+trades+'</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Fees</div><div class="val dn">$'+fees.toFixed(2)+'</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Active</div><div class="val">'+active+'/'+keys.length+'</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Alloc</div><div class="val">$'+(keys[0]?ss[keys[0]].allocation||0:0)+'k/strat</div></div>';
|
||||
}
|
||||
var h='';
|
||||
for(var k=0;k<keys.length;k++){
|
||||
var name=keys[k],s=ss[name],sp=s.pnl||0,cls=sp>=0?'up':'dn',pStr=(sp>=0?'+':'')+'$'+Math.abs(sp).toFixed(2);
|
||||
var fm=s.fee_model||'taker';
|
||||
h+='<div class="scard" onclick="openDetail(\''+name+'\',\''+tab+'\')" id="scard-'+tab+'-'+name.replace(/\s/g,'_')+'">'+
|
||||
'<div class="sh"><div><div class="sname">'+name+'</div><div class="salloc">$'+s.allocation+' · '+s.type+'</div></div>'+
|
||||
'<div><span class="stag '+(s.status==='running'?'run':'idle')+'">'+(s.status==='running'?'RUNNING':'IDLE')+'</span>'+
|
||||
'<span class="stag '+fm+'">'+fm.toUpperCase()+'</span></div></div>'+
|
||||
'<div class="spnl '+cls+'">'+pStr+'</div>'+
|
||||
'<div class="smeta"><span>PnL: <b class="'+(sp>=0?'green':'red')+'">'+(s.pnl_pct>=0?'+':'')+(s.pnl_pct||0).toFixed(2)+'%</b></span><span>Trades: <b>'+(s.trades_today||0)+'</b></span><span>Win: <b>'+Math.round((s.win_rate||0)*100)+'%</b></span><span>Pos: <b>'+(s.position||0).toFixed(4)+'</b></span></div>'+
|
||||
'</div>';
|
||||
}
|
||||
document.getElementById(sgridId).innerHTML=h;
|
||||
}
|
||||
|
||||
// ═══════════ Fee toggle ═══════════
|
||||
var currentBTName=null;
|
||||
function toggleFees(){
|
||||
feeOn=document.getElementById('fee-toggle').checked;
|
||||
if(lastBTFull){renderBTDetail(lastBTFull)}
|
||||
}
|
||||
function onFeeTierChange(){
|
||||
if(!currentBTName)return;
|
||||
var ft=document.getElementById('fee-tier-sel').value;
|
||||
var st=document.getElementById('stake-tier-sel').value;
|
||||
document.getElementById('det-trades').innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--tx);padding:20px">Recalculating with '+document.getElementById('fee-tier-sel').selectedOptions[0].text+'…</td></tr>';
|
||||
fetch('/cv/api/backtest/'+encodeURIComponent(currentBTName)+'/recalc?fee_tier='+ft+'&staking_tier='+st)
|
||||
.then(function(r){return r.json()}).then(function(full){
|
||||
lastBTFull=full; renderBTDetail(full);
|
||||
}).catch(function(e){
|
||||
document.getElementById('det-trades').innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--rd);padding:20px">Recalc failed: '+e.message+'</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════ Render backtest detail with fee toggle ──
|
||||
function renderBTDetail(full){
|
||||
var pnl=feeOn?(full.pnl_net||full.pnl||0):(full.pnl_gross||full.pnl||0);
|
||||
var pnlPct=feeOn?(full.pnl_net_pct||full.pnl_pct||0):(full.pnl_gross_pct||full.pnl_pct||0);
|
||||
var fees=full.fees_total||0;
|
||||
var strat=full.strategy||'';
|
||||
document.getElementById('det-name').textContent=strat+(feeOn?' (net of fees)':' (gross, no fees)');
|
||||
document.getElementById('det-desc').textContent=strat+' — '+full.num_periods+' periods, '+full.total_trades+' trades, fees $'+fees.toFixed(2)+', fee model: '+(full.fee_model||'taker');
|
||||
document.getElementById('det-stats').innerHTML=
|
||||
'<div class="stat"><div class="lbl">'+(feeOn?'Net PnL':'Gross PnL')+'</div><div class="val '+(pnlPct>=0?'up':'dn')+'">'+(pnlPct>=0?'+':'')+pnlPct.toFixed(2)+'%</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Sharpe</div><div class="val">'+(full.sharpe||0).toFixed(2)+'</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Sortino</div><div class="val">'+(full.sortino||0).toFixed(2)+'</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Max DD</div><div class="val dn">'+(full.max_dd*100).toFixed(2)+'%</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Win Rate</div><div class="val">'+Math.round((full.win_rate||0)*100)+'%</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Fees</div><div class="val '+(feeOn?'dn':'')+'">$'+fees.toFixed(2)+(feeOn?'':' (excl)')+'</div></div>';
|
||||
// Equity chart
|
||||
if(!detChart)initDetChart();
|
||||
var pts=[],curve=full.equity_curve||[];
|
||||
for(var i=0;i<curve.length;i++){if(curve[i]&&curve[i].t)var ct=curve[i].t;if(typeof ct==="string")ct=Math.floor(new Date(ct).getTime()/1000);pts.push({time:ct,value:curve[i].v})}
|
||||
if(pts.length>0){detSer.setData(pts);detChart.timeScale().fitContent();setTimeout(function(){if(detChart){detChart.timeScale().fitContent();detChart.applyOptions({width:document.getElementById('det-chart').offsetWidth,height:280})}},250)}
|
||||
// Trades table (show pnl_net or pnl_gross based on toggle)
|
||||
var trows='',tlist=full.trades||[];
|
||||
for(var j=Math.max(0,tlist.length-100);j<tlist.length;j++){
|
||||
var t=tlist[j];
|
||||
var tp=feeOn?(t.pnl_net||t.pnl||0):(t.pnl_gross||t.pnl||0);
|
||||
var tf=t.fee||0;
|
||||
var tside=(t.side||'').toUpperCase();
|
||||
trows+='<tr><td>'+(t.time||'').substr(0,16)+'</td><td class="'+(tside.indexOf('BUY')>=0?'green':'red')+'">'+tside+'</td><td>'+t.size+'</td><td>$'+(t.price||0).toFixed(1)+'</td><td class="'+(tp>=0?'green':'red')+'">'+(tp>=0?'+':'')+'$'+Math.abs(tp).toFixed(4)+'</td><td class="'+(tf>0?'red':'')+'">$'+tf.toFixed(4)+'</td><td class="reason">—</td></tr>';
|
||||
}
|
||||
document.getElementById('det-trades').innerHTML=trows||'<tr><td colspan="7" style="text-align:center;color:var(--tx);padding:20px">No trades recorded</td></tr>';
|
||||
setTimeout(function(){if(detChart)detChart.applyOptions({width:document.getElementById('det-chart').offsetWidth,height:280})},300);
|
||||
}
|
||||
|
||||
// ═══════════ Open strategy detail ═══════════
|
||||
function openDetail(name,tab){
|
||||
document.getElementById('detail-overlay').classList.add('on');
|
||||
document.getElementById('det-name').textContent=name;
|
||||
var ss=null, equity={}, trades=[];
|
||||
if(tab==='paper'&&lastPaper){
|
||||
ss=lastPaper.strategies||{}; equity=lastPaper.strategy_equity||{};
|
||||
trades=(lastPaper.per_strategy_trades||{})[name]||[];
|
||||
} else if(tab==='live'&&lastData){
|
||||
ss=lastData.strategies||{};
|
||||
// Live node doesn't send per-strategy equity — use overall equity_history
|
||||
equity=lastData.equity_history||[];
|
||||
// Filter trades by strategy name
|
||||
var allTrades=lastData.trades||[];
|
||||
trades=allTrades.filter(function(t){return t.strategy===name||t.id===name});
|
||||
} else if(tab==='backtest'&&lastBT&&lastBT[name]){
|
||||
var b=lastBT[name];
|
||||
currentBTName=b.name;
|
||||
document.getElementById('fee-toggle-wrap').style.display='inline';
|
||||
document.getElementById('fee-toggle').checked=true; feeOn=true;
|
||||
document.getElementById('fee-tier-sel').style.display='inline';
|
||||
document.getElementById('stake-tier-sel').style.display='inline';
|
||||
document.getElementById('dl-csv').style.display='inline';
|
||||
document.getElementById('dl-csv').href='/cv/api/backtest/'+encodeURIComponent(b.name)+'/csv';
|
||||
document.getElementById('det-desc').textContent='';
|
||||
document.getElementById('det-stats').innerHTML='<div class="stat"><div class="lbl">Loading</div><div class="val">…</div></div>';
|
||||
if(detSer)detSer.setData([]);
|
||||
document.getElementById('det-trades').innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--tx);padding:20px">Loading full trade data…</td></tr>';
|
||||
fetch('/cv/api/backtest/'+encodeURIComponent(b.name)).then(function(r){return r.json()}).then(function(full){
|
||||
lastBTFull=full; renderBTDetail(full);
|
||||
}).catch(function(e){
|
||||
document.getElementById('det-trades').innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--rd);padding:20px">Failed to load: '+e.message+'</td></tr>';
|
||||
});
|
||||
return;
|
||||
}
|
||||
var s=ss?ss[name]:null;
|
||||
if(!s){closeDetail();return}
|
||||
|
||||
// Description
|
||||
document.getElementById('det-desc').textContent=s.description||'No description available.';
|
||||
|
||||
// Stats
|
||||
var sp=s.pnl||0;
|
||||
document.getElementById('det-stats').innerHTML='<div class="stat"><div class="lbl">PnL</div><div class="val '+(sp>=0?'up':'dn')+'">'+(sp>=0?'+':'')+'$'+Math.abs(sp).toFixed(4)+'</div></div>'+
|
||||
'<div class="stat"><div class="lbl">PnL%</div><div class="val '+(sp>=0?'up':'dn')+'">'+(s.pnl_pct>=0?'+':'')+(s.pnl_pct||0).toFixed(2)+'%</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Trades</div><div class="val">'+(s.trades_today||0)+'</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Win Rate</div><div class="val">'+Math.round((s.win_rate||0)*100)+'%</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Fees Paid</div><div class="val dn">$'+(s.fee_paid||0).toFixed(4)+'</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Position</div><div class="val">'+(s.position||0).toFixed(4)+'</div></div>';
|
||||
|
||||
// Equity chart
|
||||
if(!detChart)initDetChart();
|
||||
var eqData=Array.isArray(equity)?equity:(equity[name]||[]);
|
||||
if(eqData.length>0){
|
||||
var pts=[];for(var i=0;i<eqData.length;i++){if(eqData[i]&&eqData[i].t){var edt=eqData[i].t;if(typeof edt==='string')edt=Math.floor(new Date(edt).getTime()/1000);pts.push({time:edt,value:eqData[i].v})}}
|
||||
detSer.setData(pts);detChart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
// Trades
|
||||
var rows='';
|
||||
for(var j=Math.max(0,trades.length-50);j<trades.length;j++){
|
||||
var t=trades[j],tp=t.pnl||0;
|
||||
rows+='<tr><td>'+t.time+'</td><td class="'+(t.side==='BUY'?'green':'red')+'">'+t.side+'</td><td>'+t.size+'</td><td>$'+t.price+'</td><td class="'+(tp>=0?'green':'red')+'">'+(tp>=0?'+':'')+'$'+Math.abs(tp).toFixed(4)+'</td><td class="red">$'+(t.fee||0).toFixed(4)+'</td><td class="reason" title="'+t.reason+'">'+(t.reason||'—')+'</td></tr>';
|
||||
}
|
||||
document.getElementById('det-trades').innerHTML=rows||'<tr><td colspan="7" style="text-align:center;color:var(--tx);padding:20px">No trades yet</td></tr>';
|
||||
|
||||
// Resize chart
|
||||
setTimeout(function(){if(detChart){detChart.applyOptions({width:document.getElementById('det-chart').offsetWidth,height:280});detChart.timeScale().fitContent()}},300);
|
||||
}
|
||||
|
||||
function closeDetail(){document.getElementById('detail-overlay').classList.remove('on');document.getElementById('fee-toggle-wrap').style.display='none';document.getElementById('fee-tier-sel').style.display='none';document.getElementById('stake-tier-sel').style.display='none';document.getElementById('dl-csv').style.display='none';lastBTFull=null;currentBTName=null}
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape')closeDetail()});
|
||||
|
||||
// ═══════════ WebSocket + render ═══════════
|
||||
var ws,wsPaper;
|
||||
function connect(){
|
||||
if(ws)try{ws.close()}catch(e){}
|
||||
ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/cv/ws');
|
||||
ws.onopen=function(){document.getElementById('sdot').className='status-dot'};
|
||||
ws.onclose=function(){document.getElementById('sdot').className='status-dot off';setTimeout(connect,5000)};
|
||||
ws.onmessage=function(e){try{lastData=JSON.parse(e.data)}catch(ex){return};if(currentTab==='live')renLive(lastData)};
|
||||
if(wsPaper)try{wsPaper.close()}catch(e){}
|
||||
wsPaper=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/cv/ws/paper');
|
||||
wsPaper.onmessage=function(e){try{lastPaper=JSON.parse(e.data)}catch(ex){return};if(currentTab==='paper')renPaper(lastPaper)};
|
||||
}
|
||||
|
||||
function renLive(d){if(!d)return;var p=d.total_pnl||0;document.getElementById('stpnl').textContent=(p>=0?'+':'')+'$'+Math.abs(p).toFixed(2);document.getElementById('stpnl').className='pnl '+(p>=0?'up':'dn');document.getElementById('stpct').textContent='Testnet · Equity: $'+((d.base_equity||898)+p).toFixed(2);renCards("live-sgrid",d.strategies||{},d.base_equity||898,"live","live-stats");if(d.equity_history&&chartLive)pushEquity(chartLive,serLive,d.equity_history)}
|
||||
function renPaper(d){if(!d)return;var p=d.total_pnl||0;document.getElementById('stpnl').textContent=(p>=0?'+':'')+'$'+Math.abs(p).toFixed(2);document.getElementById('stpnl').className='pnl '+(p>=0?'up':'dn');document.getElementById('stpct').textContent='Paper · '+d.total_equity+' · Regime: '+(d.regime||'—');renCards("paper-sgrid",d.strategies||{},d.base_equity||100000,"paper","paper-stats");if(d.equity_history&&chartPaper)pushEquity(chartPaper,serPaper,d.equity_history)}
|
||||
|
||||
// ═══════════ Backtests ═══════════
|
||||
var lastBT={}, lastBTList=[];
|
||||
function loadBT(){
|
||||
fetch('/cv/api/backtests').then(function(r){return r.json()}).then(function(data){
|
||||
lastBTList=data; lastBT={};
|
||||
// Keep latest backtest per strategy (sorted by time desc — first wins)
|
||||
for(var i=0;i<data.length;i++){var b=data[i];if(!lastBT[b.strategy])lastBT[b.strategy]=b;}
|
||||
var h='';
|
||||
for(var s in lastBT){var b=lastBT[s];var pnl=b.pnl_pct||0;
|
||||
h+='<div class=\"scard\" onclick=\"openDetail(\''+s+'\',\'backtest\')\"><div class=\"sh\"><div><div class=\"sname\">'+s+'</div><div class=\"salloc\">30-day · $100</div></div><span class=\"stag run\">BACKTEST</span></div><div class=\"spnl '+(pnl>=0?'up':'dn')+'\">'+(pnl>=0?'+':'')+pnl.toFixed(2)+'%</div><div class=\"smeta\"><span>Sharpe: <b>'+b.sharpe.toFixed(2)+'</b></span><span>DD: <b class=\"red\">'+(b.max_dd*100).toFixed(2)+'%</b></span><span>Win: <b>'+Math.round(b.win_rate*100)+'%</b></span></div></div>';
|
||||
}
|
||||
document.getElementById('bt-sgrid').innerHTML=h||'<div style=\"padding:20px;color:var(--tx)\">No backtests.</div>';
|
||||
})
|
||||
}
|
||||
|
||||
// ═══════════ Historical backtests ═══════════
|
||||
var lastHist={};
|
||||
function loadHistBT(){
|
||||
fetch('/cv/api/backtests/historical').then(function(r){return r.json()}).then(function(data){
|
||||
lastHist={};
|
||||
for(var i=0;i<data.length;i++){var b=data[i];if(!lastHist[b.strategy])lastHist[b.strategy]=b;}
|
||||
var h='';
|
||||
for(var s in lastHist){var b=lastHist[s];var pnl=b.pnl_pct||0;
|
||||
h+='<div class="scard" data-strat="'+s+'" onclick="openHistDetail(this.dataset.strat)"><div class="sh"><div><div class="sname">'+s+'</div><div class="salloc">30d '+b.coin+' · Mainnet</div></div><span class="stag run">REAL DATA</span></div><div class="spnl '+(pnl>=0?'up':'dn')+'">'+(pnl>=0?'+':'')+pnl.toFixed(2)+'%</div><div class="smeta"><span>Sharpe: <b>'+b.sharpe.toFixed(2)+'</b></span><span>DD: <b class="red">'+(b.max_dd*100).toFixed(2)+'%</b></span><span>Win: <b>'+Math.round(b.win_rate*100)+'%</b></span></div></div>';
|
||||
}
|
||||
document.getElementById('hist-sgrid').innerHTML=h||'<div style="padding:20px;color:var(--tx)">No historical backtests. Run: python backtests/historical_runner.py --coin BTC --strategy all</div>';
|
||||
})
|
||||
}
|
||||
function openHistDetail(strat){
|
||||
var b=lastHist[strat];if(!b)return;
|
||||
document.getElementById('detail-overlay').classList.add('on');
|
||||
document.getElementById('fee-toggle-wrap').style.display='inline';
|
||||
document.getElementById('fee-tier-sel').style.display='inline';
|
||||
document.getElementById('stake-tier-sel').style.display='inline';
|
||||
document.getElementById('dl-csv').style.display='none';
|
||||
document.getElementById('fee-toggle').checked=true; feeOn=true; currentBTName=b.name;
|
||||
document.getElementById('det-name').textContent=strat+' (Historical '+b.coin+')';
|
||||
fetch('/cv/api/backtest/historical/'+encodeURIComponent(b.name)).then(function(r){return r.json()}).then(function(full){
|
||||
lastBTFull=full; renderBTDetail(full);
|
||||
}).catch(function(e){
|
||||
document.getElementById('det-trades').innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--rd);padding:20px">Failed: '+e.message+'</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════ Init ═══════════
|
||||
initDetChart();initMainCharts();connect();loadBT();loadHistBT();
|
||||
// ═══════════ Risk Analytics ═══════════
|
||||
function toggleRisk(){
|
||||
var p=document.getElementById('risk-panel'),b=document.getElementById('risk-btn');
|
||||
p.classList.toggle('show');b.classList.toggle('open');
|
||||
if(p.classList.contains('show')&&!p.dataset.loaded){loadRisk();p.dataset.loaded='1'}
|
||||
}
|
||||
function loadRisk(){
|
||||
fetch('/cv/api/risk').then(function(r){return r.json()}).then(function(d){
|
||||
if(d.error){document.getElementById('risk-stats').innerHTML='<div style="color:var(--tx);padding:8px">'+d.error+'</div>';return}
|
||||
var pf=d.portfolio||{};
|
||||
document.getElementById('risk-stats').innerHTML=
|
||||
'<div class="stat"><div class="lbl">VaR 95%</div><div class="val dn">'+(pf.var_95*100).toFixed(2)+'%</div></div>'+
|
||||
'<div class="stat"><div class="lbl">CVaR 95%</div><div class="val dn">'+(pf.cvar_95*100).toFixed(2)+'%</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Max DD</div><div class="val dn">'+(pf.max_drawdown*100).toFixed(2)+'%</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Calmar</div><div class="val '+(pf.calmar_ratio>=0?'up':'dn')+'">'+pf.calmar_ratio.toFixed(2)+'</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Sharpe</div><div class="val '+(pf.sharpe>=0?'up':'dn')+'">'+pf.sharpe.toFixed(2)+'</div></div>'+
|
||||
'<div class="stat"><div class="lbl">Sortino</div><div class="val">'+pf.sortino.toFixed(2)+'</div></div>';
|
||||
// Correlation summary
|
||||
var cs=d.correlation_summary||[];
|
||||
var ch='<div style="font-size:10px;color:var(--tx);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">Strategy Correlations (|ρ| > 0.3)</div>';
|
||||
if(cs.length===0){ch+='<span style="color:var(--tx)">No significant correlations found — strategies are well-diversified.</span>'}
|
||||
else{for(var i=0;i<cs.length;i++){var c=cs[i],cls=c.level==='high'?'corr-high':'corr-med';ch+='<div><span class="'+cls+'">ρ='+(c.correlation>=0?'+':'')+c.correlation.toFixed(3)+'</span> '+c.pair+'</div>'}}
|
||||
document.getElementById('risk-corr').innerHTML=ch;
|
||||
// Mark loaded + store timestamp
|
||||
window._riskLoaded=Date.now();
|
||||
}).catch(function(e){document.getElementById('risk-stats').innerHTML='<div style="color:var(--rd);padding:8px">Failed: '+e.message+'</div>'})
|
||||
}
|
||||
// Auto-refresh risk panel when paper data updates (throttled to every 30s)
|
||||
var _origRenPaper=renPaper;
|
||||
renPaper=function(d){
|
||||
_origRenPaper(d);
|
||||
var p=document.getElementById('risk-panel');
|
||||
if(p&&p.classList.contains('show')&&(!window._riskLoaded||Date.now()-window._riskLoaded>30000)){
|
||||
loadRisk();
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+160
-88
@@ -5,7 +5,7 @@ Uses real orderbook to place maker orders AT the best bid/ask level,
|
||||
not at mid ± random spread. Refreshes quotes every cycle to stay
|
||||
at queue front. Avellaneda-Stoikov places dual-sided quotes simultaneously.
|
||||
|
||||
7 strategies x 100 USDC | Maker: 0.02% | Hyperliquid Testnet.
|
||||
8 strategies x 100 USDC | Maker: 0.02% | Hyperliquid Testnet.
|
||||
"""
|
||||
import os, sys, asyncio, json, time, logging, random, math
|
||||
from pathlib import Path
|
||||
@@ -31,13 +31,14 @@ RESERVE = 398.0
|
||||
MAKER_FEE = 0.0002
|
||||
|
||||
STRATEGIES = {
|
||||
"Order Book Imbalance": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0002,"fee_paid":0.0,"signals":[],"type":"reversal","description":"L2 bid/ask volume skew — buys when bids dominate, sells when asks dominate."},
|
||||
"Iceberg Detection": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0002,"fee_paid":0.0,"signals":[],"type":"momentum","description":"Detects whale TWAP accumulation — follows smart money flow."},
|
||||
"Funding Rate Arb": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0002,"fee_paid":0.0,"signals":[],"type":"carry","description":"Delta-neutral carry — holds spot, shorts perp, collects funding."},
|
||||
"Order Book Imbalance": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.000200,"fee_paid":0.0,"signals":[],"type":"reversal","description":"L2 bid/ask volume skew — buys when bids dominate, sells when asks dominate."},
|
||||
"Iceberg Detection": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.000210,"fee_paid":0.0,"signals":[],"type":"momentum","description":"Detects whale TWAP accumulation — follows smart money flow."},
|
||||
"Funding Rate Arb": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.000220,"fee_paid":0.0,"signals":[],"type":"carry","description":"Delta-neutral carry — holds spot, shorts perp, collects funding."},
|
||||
"Pairs Trading": {"allocation":100.0,"instrument":"ETH-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.006,"fee_paid":0.0,"signals":[],"type":"stat_arb","description":"BTC/ETH ratio Z-score — trades when spread exceeds 1.5σ."},
|
||||
"Avellaneda-Stoikov": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0002,"fee_paid":0.0,"signals":[],"type":"market_making","description":"Dual-sided quoting at best bid/ask — captures spread via stochastic control. Places both sides simultaneously."},
|
||||
"Momentum Breakout": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0002,"fee_paid":0.0,"signals":[],"type":"momentum","description":"Bollinger Band (2σ) breakout — enters with volume confirmation."},
|
||||
"Mean Reversion": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0002,"fee_paid":0.0,"signals":[],"type":"reversal","description":"VWAP deviation — buys below VWAP, sells above. Oscillates around fair value."},
|
||||
"Avellaneda-Stoikov": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.000230,"fee_paid":0.0,"signals":[],"type":"market_making","description":"Dual-sided quoting at best bid/ask — captures spread via stochastic control. Places both sides simultaneously."},
|
||||
"Momentum Breakout": {"allocation":100.0,"instrument":"ETH-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0005,"fee_paid":0.0,"signals":[],"type":"momentum","description":"Bollinger Band (1.2σ) breakout on ETH — enters when price breaks bands."},
|
||||
"Mean Reversion": {"allocation":100.0,"instrument":"ETH-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0005,"fee_paid":0.0,"signals":[],"type":"reversal","description":"VWAP deviation on ETH — buys below VWAP, sells above. Higher vol = more reversion."},
|
||||
"Kalman Pairs": {"allocation":100.0,"instrument":"ETH-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.005,"fee_paid":0.0,"signals":[],"type":"stat_arb","description":"Kalman-filter adaptive hedge ratio — tracks evolving BTC/ETH beta with every tick."}
|
||||
}
|
||||
|
||||
trades_log: list[dict] = []
|
||||
@@ -47,6 +48,8 @@ seen_fills: set[int] = set()
|
||||
btc_prices: deque = deque(maxlen=60)
|
||||
eth_prices: deque = deque(maxlen=60)
|
||||
active_cloids: dict = {} # Track active order IDs per strategy
|
||||
active_cloids_times: dict = {} # Tick when order was placed
|
||||
active_cloids_px: dict = {} # Entry price for take-profit
|
||||
|
||||
# ═══════════════════════ Helpers ═══════════════════════
|
||||
|
||||
@@ -115,8 +118,8 @@ def compute_signals():
|
||||
# OFI: 5-tick reversal
|
||||
if len(btc_prices)>=5:
|
||||
ret = (btc-btc_prices[-5])/btc_prices[-5]
|
||||
if ret>0.0008: STRATEGIES["Order Book Imbalance"]["signals"].append({"time":time.time(),"signal":"SELL","strength":ret})
|
||||
elif ret<-0.0008: STRATEGIES["Order Book Imbalance"]["signals"].append({"time":time.time(),"signal":"BUY","strength":abs(ret)})
|
||||
if ret>0.0004: STRATEGIES["Order Book Imbalance"]["signals"].append({"time":time.time(),"signal":"SELL","strength":ret})
|
||||
elif ret<-0.0004: STRATEGIES["Order Book Imbalance"]["signals"].append({"time":time.time(),"signal":"BUY","strength":abs(ret)})
|
||||
|
||||
# Iceberg: trend count
|
||||
if len(btc_prices)>=10:
|
||||
@@ -124,11 +127,24 @@ def compute_signals():
|
||||
if up>=7: STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"BUY","strength":up/10})
|
||||
elif up<=3: STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"SELL","strength":1-up/10})
|
||||
|
||||
# Funding Arb: rate proxy
|
||||
if len(btc_prices)>=20:
|
||||
fr = (btc/btc_prices[-20]-1)/20
|
||||
if abs(fr)>0.0008:
|
||||
STRATEGIES["Funding Rate Arb"]["signals"].append({"time":time.time(),"signal":"SELL" if fr>0 else "BUY","strength":abs(fr)})
|
||||
# Funding Rate Arb: real API data
|
||||
try:
|
||||
from strategies.funding_arb import get_funding_rates
|
||||
rates = get_funding_rates(use_testnet=True)
|
||||
annual_rate = rates.get("BTC", 0)
|
||||
if abs(annual_rate) > 0.01: # >3% APR threshold (testnet: lower liquidity = lower threshold)
|
||||
sig = "SELL" if annual_rate > 0 else "BUY"
|
||||
STRATEGIES["Funding Rate Arb"]["signals"].append({
|
||||
"time":time.time(), "signal":sig,
|
||||
"strength": min(1.0, abs(annual_rate) * 10),
|
||||
"reason": f"funding_{annual_rate*100:.1f}pct_apr"
|
||||
})
|
||||
except Exception:
|
||||
# Fallback: use price proxy if module unavailable
|
||||
if len(btc_prices)>=20:
|
||||
rate = (btc/btc_prices[-20]-1)/20
|
||||
if abs(rate)>0.0005:
|
||||
STRATEGIES["Funding Rate Arb"]["signals"].append({"time":time.time(),"signal":"SELL" if rate>0 else "BUY","strength":abs(rate)*10000})
|
||||
|
||||
# Pairs: ratio Z-score
|
||||
if len(btc_prices)>=20 and len(eth_prices)>=20:
|
||||
@@ -138,25 +154,42 @@ def compute_signals():
|
||||
cur = btc/eth if eth>0 else 0
|
||||
if std>0:
|
||||
z = (cur-mu)/std
|
||||
if z>1.5: STRATEGIES["Pairs Trading"]["signals"].append({"time":time.time(),"signal":"SELL_ETH","strength":z})
|
||||
elif z<-1.5: STRATEGIES["Pairs Trading"]["signals"].append({"time":time.time(),"signal":"BUY_ETH","strength":abs(z)})
|
||||
if z>1.2: STRATEGIES["Pairs Trading"]["signals"].append({"time":time.time(),"signal":"SELL_ETH","strength":z})
|
||||
elif z<-1.2: STRATEGIES["Pairs Trading"]["signals"].append({"time":time.time(),"signal":"BUY_ETH","strength":abs(z)})
|
||||
# Kalman Pairs: adaptive hedge via Kalman filter (falls back to Pairs logic)
|
||||
if len(btc_prices)>=20 and len(eth_prices)>=20:
|
||||
try:
|
||||
from strategies.kalman_pairs import KalmanPairsTrader
|
||||
if "_kalman_live" not in dir():
|
||||
globals()["_kalman_live"] = KalmanPairsTrader(
|
||||
transition_covariance=1e-4, observation_covariance=1e-2,
|
||||
z_entry=1.5, z_exit=0.5, warmup_bars=20,
|
||||
)
|
||||
result = globals()["_kalman_live"].step(eth, btc)
|
||||
if result["signal"] != 0:
|
||||
sig = "BUY_ETH" if result["signal"] > 0 else "SELL_ETH"
|
||||
STRATEGIES["Kalman Pairs"]["signals"].append({
|
||||
"time":time.time(), "signal":sig,
|
||||
"strength":abs(result["z_score"])
|
||||
})
|
||||
except: pass
|
||||
|
||||
# Momentum: Bollinger
|
||||
if len(btc_prices)>=20:
|
||||
w = list(btc_prices)[-20:]; sma = sum(w)/len(w)
|
||||
# Momentum: Bollinger on ETH
|
||||
if len(eth_prices)>=20:
|
||||
w = list(eth_prices)[-20:]; eth_cur = eth_prices[-1]; sma = sum(w)/len(w)
|
||||
variance = sum((p-sma)**2 for p in w)/len(w); std = math.sqrt(variance)
|
||||
if std>0:
|
||||
if btc > sma+2*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"BUY","strength":(btc-sma-2*std)/std})
|
||||
elif btc < sma-2*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"SELL","strength":(sma-2*std-btc)/std})
|
||||
if eth_cur > sma+1.0*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"BUY","strength":(eth_cur-sma-1.0*std)/std})
|
||||
elif eth_cur < sma-1.0*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"SELL","strength":(sma-1.0*std-eth_cur)/std})
|
||||
|
||||
# Mean Reversion: VWAP
|
||||
if len(btc_prices)>=20:
|
||||
w = list(btc_prices)[-20:]; vols = [1+i/len(w) for i in range(len(w))]
|
||||
# Mean Reversion: VWAP on ETH
|
||||
if len(eth_prices)>=20:
|
||||
w = list(eth_prices)[-20:]; eth_mr = eth_prices[-1]; vols = [1+i/len(w) for i in range(len(w))]
|
||||
vwap = sum(p*v for p,v in zip(w,vols))/sum(vols)
|
||||
vstd = math.sqrt(sum((p-vwap)**2 for p in w)/len(w))
|
||||
dev = (btc-vwap)/vstd if vstd>0 else 0
|
||||
if dev>1.5: STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"SELL","strength":dev})
|
||||
elif dev<-1.5: STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"BUY","strength":abs(dev)})
|
||||
dev = (eth_mr-vwap)/vstd if vstd>0 else 0
|
||||
if dev>0.8: STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"SELL","strength":dev})
|
||||
elif dev<-0.8: STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"BUY","strength":abs(dev)})
|
||||
|
||||
# Trim signals
|
||||
for s in STRATEGIES.values(): s["signals"] = s["signals"][-20:]
|
||||
@@ -182,7 +215,11 @@ async def main():
|
||||
if not perps:
|
||||
log.info("Loading perps from mainnet API directly...")
|
||||
try:
|
||||
meta_r = requests.post(MAINNET_INFO, json={"type":"meta"}, timeout=10)
|
||||
meta_r = requests.post(TESTNET_API, json={"type":"meta"}, timeout=10)
|
||||
if meta_r.status_code != 200 or not meta_r.json():
|
||||
# Testnet meta returns null — try mainnet
|
||||
log.info("Testnet meta unavailable, trying mainnet...")
|
||||
meta_r = requests.post("https://api.hyperliquid.xyz/info", json={"type":"meta"}, timeout=10)
|
||||
meta = meta_r.json()
|
||||
for asset in meta.get("universe", []):
|
||||
name = asset.get("name", "")
|
||||
@@ -264,9 +301,12 @@ async def main():
|
||||
side=f.get("side",""); sz=float(f.get("sz",0)); px=float(f.get("px",0))
|
||||
closed_pnl=float(f.get("closedPnl",0)); fee=float(f.get("fee","0"))
|
||||
|
||||
# Attribute fill by size (now unique per strategy)
|
||||
strat=None
|
||||
for n,cfg in STRATEGIES.items():
|
||||
if abs(sz-cfg["size"])<0.00001: strat=n; break
|
||||
if abs(sz-cfg["size"])<0.000001:
|
||||
strat=n
|
||||
break
|
||||
if not strat: continue
|
||||
|
||||
net=closed_pnl-abs(fee)
|
||||
@@ -281,78 +321,110 @@ async def main():
|
||||
# Signals every 5 ticks
|
||||
if tick%5==0: compute_signals()
|
||||
|
||||
# Place/refresh orders every 3-5 ticks
|
||||
if tick>=3 and tick%random.randint(3,5)==0:
|
||||
# Execute ALL strategies every 4 seconds
|
||||
if tick>=3 and tick%4==0:
|
||||
btc_bid, btc_ask, btc_mid = get_orderbook("BTC")
|
||||
try:
|
||||
btc_bid, btc_ask, btc_mid = get_orderbook("BTC")
|
||||
except Exception as e:
|
||||
log.debug(f"OB BTC error: {e}")
|
||||
btc_bid = btc_ask = btc_mid = 0
|
||||
try:
|
||||
eth_bid, eth_ask, eth_mid = get_orderbook("ETH")
|
||||
except Exception as e:
|
||||
eth_bid = eth_ask = eth_mid = 0
|
||||
if btc_bid<=0 or btc_ask<=0: continue
|
||||
|
||||
name = names[idx%7]; idx+=1; cfg=STRATEGIES[name]
|
||||
coin="BTC" if "BTC" in cfg["instrument"] else "ETH"
|
||||
perp=btc_perp if coin=="BTC" else eth_perp
|
||||
bid=btc_bid if coin=="BTC" else eth_bid
|
||||
ask=btc_ask if coin=="BTC" else eth_ask
|
||||
mid=btc_mid if coin=="BTC" else eth_mid
|
||||
if bid<=0 or ask<=0: continue
|
||||
for name in names:
|
||||
cfg=STRATEGIES[name]
|
||||
coin="BTC" if "BTC" in cfg["instrument"] else "ETH"
|
||||
perp=btc_perp if coin=="BTC" else eth_perp
|
||||
bid=btc_bid if coin=="BTC" else eth_bid
|
||||
ask=btc_ask if coin=="BTC" else eth_ask
|
||||
mid=btc_mid if coin=="BTC" else eth_mid
|
||||
if bid<=0 or ask<=0: continue
|
||||
|
||||
# Cancel previous order for this strategy
|
||||
if name in active_cloids:
|
||||
try:
|
||||
client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name]))
|
||||
except: pass
|
||||
# Check if this strategy has a position; skip if already filled
|
||||
has_position = name in active_cloids and tick - active_cloids_times.get(name,0) < 60
|
||||
|
||||
# Determine side from signal or market-making pattern
|
||||
signal=None
|
||||
if cfg["signals"]: signal=cfg["signals"][-1]["signal"] if cfg["signals"] else None
|
||||
# Determine signal
|
||||
signal=None
|
||||
if cfg["signals"]:
|
||||
latest = cfg["signals"][-1]
|
||||
# Only use recent signals (< 10 seconds old)
|
||||
if time.time() - latest["time"] < 10:
|
||||
signal=latest["signal"]
|
||||
|
||||
if name=="Avellaneda-Stoikov":
|
||||
# DUAL-SIDED: place both bid and ask simultaneously
|
||||
cid_bid=ClientOrderId(str(UUID4())); cid_ask=ClientOrderId(str(UUID4()))
|
||||
try:
|
||||
client.submit_order(instrument_id=perp.id,client_order_id=cid_bid,order_side=OrderSide.BUY,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(bid))),time_in_force=TimeInForce.GTC,post_only=True)
|
||||
client.submit_order(instrument_id=perp.id,client_order_id=cid_ask,order_side=OrderSide.SELL,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(ask))),time_in_force=TimeInForce.GTC,post_only=True)
|
||||
log.info(f"[Avel] DUAL: BID {cfg['size']} @ ${int(bid):,} | ASK {cfg['size']} @ ${int(ask):,} | spread=${ask-bid:.1f}")
|
||||
active_cloids[name]=str(cid_bid) # track one
|
||||
except Exception as e: log.warning(f"Avel dual error: {str(e)[:60]}")
|
||||
continue
|
||||
# Close on opposing signal
|
||||
if has_position and signal:
|
||||
prev_signal = active_cloids.get(name,"")
|
||||
if ("BUY" in str(signal).upper() and "SELL" in str(prev_signal).upper()) or ("SELL" in str(signal).upper() and "BUY" in str(prev_signal).upper()):
|
||||
try:
|
||||
client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name]))
|
||||
except: pass
|
||||
del active_cloids[name]
|
||||
has_position = False
|
||||
|
||||
# Single-sided for other strategies
|
||||
side=None; px_level=0
|
||||
if signal and "SELL" in str(signal).upper():
|
||||
side=OrderSide.SELL; px_level=ask # at best ask (highest fill probability as maker)
|
||||
elif signal and "BUY" in str(signal).upper():
|
||||
side=OrderSide.BUY; px_level=bid # at best bid
|
||||
else:
|
||||
# No signal: market-making default — alternate sides at best bid/ask
|
||||
side=OrderSide.BUY if tick%2==0 else OrderSide.SELL
|
||||
px_level=bid if side==OrderSide.BUY else ask
|
||||
# Take-profit: close if price moved 2x fee in our favor
|
||||
if has_position:
|
||||
entry_px = active_cloids_px.get(name, 0)
|
||||
if entry_px > 0:
|
||||
if "BUY" in str(active_cloids[name]).upper() and mid > entry_px * 1.001:
|
||||
try:
|
||||
client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name]))
|
||||
except: pass
|
||||
del active_cloids[name]
|
||||
has_position = False
|
||||
elif "SELL" in str(active_cloids[name]).upper() and mid < entry_px * 0.999:
|
||||
try:
|
||||
client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name]))
|
||||
except: pass
|
||||
del active_cloids[name]
|
||||
has_position = False
|
||||
|
||||
if not side or px_level<=0: continue
|
||||
if has_position: continue # Don't replace existing orders
|
||||
|
||||
cid=ClientOrderId(str(UUID4()))
|
||||
try:
|
||||
client.submit_order(instrument_id=perp.id,client_order_id=cid,order_side=side,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(px_level))),time_in_force=TimeInForce.GTC,post_only=True)
|
||||
side_str="BUY " if side==OrderSide.BUY else "SELL"
|
||||
log.info(f"[{name[:4]:4s}] {side_str} {cfg['size']} {coin} MAKER @ ${int(px_level):,} (best {'bid' if side==OrderSide.BUY else 'ask'}: ${int(px_level):,})")
|
||||
active_cloids[name]=str(cid)
|
||||
except Exception as e:
|
||||
err=str(e)
|
||||
if "would have immediately matched" in err or "cross" in err.lower():
|
||||
# Post-only would cross — fall back to regular limit at same level
|
||||
cid2=ClientOrderId(str(UUID4()))
|
||||
# Avellaneda-Stoikov: DUAL-SIDED (always active)
|
||||
if name=="Avellaneda-Stoikov":
|
||||
cid_bid=ClientOrderId(str(UUID4())); cid_ask=ClientOrderId(str(UUID4()))
|
||||
try:
|
||||
client.submit_order(instrument_id=perp.id,client_order_id=cid2,order_side=side,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(px_level))),time_in_force=TimeInForce.IOC)
|
||||
log.info(f"[{name[:4]:4s}] {side_str} {cfg['size']} {coin} FILLED @ ${int(px_level):,} (post-only crossed → IOC)")
|
||||
active_cloids[name]=str(cid2)
|
||||
except Exception as e2: log.debug(f"[{name[:8]}] fallback failed: {str(e2)[:50]}")
|
||||
else: log.warning(f"Order [{name[:8]}]: {err[:60]}")
|
||||
client.submit_order(instrument_id=perp.id,client_order_id=cid_bid,order_side=OrderSide.BUY,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(bid))),time_in_force=TimeInForce.GTC,post_only=True)
|
||||
client.submit_order(instrument_id=perp.id,client_order_id=cid_ask,order_side=OrderSide.SELL,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(ask))),time_in_force=TimeInForce.GTC,post_only=True)
|
||||
if tick%60==0:
|
||||
log.info(f"[Avel] DUAL: BID {cfg['size']} @ ${int(bid):,} | ASK {cfg['size']} @ ${int(ask):,}")
|
||||
active_cloids[name]=str(cid_bid)
|
||||
active_cloids_times[name]=tick
|
||||
active_cloids_px[name]=bid
|
||||
except Exception as e: pass
|
||||
continue
|
||||
|
||||
# For signal-driven strategies: use aggressive offset
|
||||
if signal:
|
||||
side=OrderSide.SELL if "SELL" in str(signal).upper() else OrderSide.BUY
|
||||
# Aggressive: 0.03% inside the spread for higher fill probability
|
||||
offset = int(mid * 0.0003)
|
||||
px_level = ask - offset if side==OrderSide.SELL else bid + offset
|
||||
px_level = max(px_level, 1)
|
||||
else:
|
||||
# No signal/default: skip (don't random-trade)
|
||||
continue
|
||||
|
||||
if px_level<=0: continue
|
||||
|
||||
cid=ClientOrderId(str(UUID4()))
|
||||
try:
|
||||
client.submit_order(instrument_id=perp.id,client_order_id=cid,order_side=side,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(px_level))),time_in_force=TimeInForce.GTC,post_only=True)
|
||||
if tick%60==0:
|
||||
side_str="BUY" if side==OrderSide.BUY else "SELL"
|
||||
log.info(f"[{name[:4]:4s}] {side_str} {cfg['size']} @ ${int(px_level):,} ({'best bid '+str(int(bid)) if side==OrderSide.BUY else 'best ask '+str(int(ask))})")
|
||||
active_cloids[name]=str(cid)
|
||||
active_cloids_times[name]=tick
|
||||
active_cloids_px[name]=px_level
|
||||
except Exception as e:
|
||||
err=str(e)
|
||||
if "would have immediately matched" in err or "cross" in err.lower():
|
||||
cid2=ClientOrderId(str(UUID4()))
|
||||
try:
|
||||
client.submit_order(instrument_id=perp.id,client_order_id=cid2,order_side=side,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(px_level))),time_in_force=TimeInForce.IOC)
|
||||
active_cloids[name]=str(cid2)
|
||||
active_cloids_times[name]=tick
|
||||
active_cloids_px[name]=px_level
|
||||
except: pass
|
||||
|
||||
# Equity
|
||||
tp=sum(s["pnl"] for s in STRATEGIES.values())
|
||||
|
||||
+48
-18
@@ -236,27 +236,40 @@ def compute_signals():
|
||||
elif up <= 3:
|
||||
STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"SELL","strength":1-up/10})
|
||||
|
||||
# Funding Arb — use actual mainnet funding rate
|
||||
if funding_rates and isinstance(funding_rates[-1], dict):
|
||||
btc_fr = funding_rates[-1].get("BTC", 0)
|
||||
# Annualized: funding every 8h → 3× daily → 1095× yearly
|
||||
annual_fr = abs(btc_fr) * 365 * 3 if btc_fr else 0
|
||||
# Log funding rate periodically
|
||||
import random as _random_fr
|
||||
if _random_fr.random() < 0.02:
|
||||
# Funding Rate Arb — unified module with real API data
|
||||
try:
|
||||
from strategies.funding_arb import funding_arb_signal
|
||||
sig_result = funding_arb_signal(coin="BTC", apr_threshold=0.05, apr_exit=0.02,
|
||||
current_position=STRATEGIES["Funding Rate Arb"]["position"])
|
||||
if sig_result["signal"] != 0:
|
||||
STRATEGIES["Funding Rate Arb"]["signals"].append({
|
||||
"time": time.time(),
|
||||
"signal": "SELL" if sig_result["signal"] < 0 else "BUY",
|
||||
"strength": min(1.0, abs(sig_result["annual_apr"]) * 10),
|
||||
"reason": sig_result["reason"]
|
||||
})
|
||||
# Log periodically
|
||||
if not hasattr(globals().get("_funding_log_tick", None), "__int__"):
|
||||
globals()["_funding_log_tick"] = 0
|
||||
if globals()["_funding_log_tick"] % 30 == 0:
|
||||
import logging
|
||||
logging.getLogger("ftdt-paper").info(
|
||||
"{} Funding rate: {:.6f}% 8h | {:.2f}% APR | signal={}".format(
|
||||
"[Fund]", btc_fr*100, annual_fr*100,
|
||||
"SELL" if btc_fr > 0 else "BUY" if btc_fr < 0 else "NONE"
|
||||
f"[Fund] APR={sig_result['annual_apr']*100:.2f}% | "
|
||||
f"8h={sig_result['rate_8h']*100:.6f}% | "
|
||||
f"signal={sig_result['signal']}"
|
||||
)
|
||||
globals()["_funding_log_tick"] = globals().get("_funding_log_tick", 0) + 1
|
||||
except Exception:
|
||||
# Fallback to old method
|
||||
if funding_rates and isinstance(funding_rates[-1], dict):
|
||||
btc_fr = funding_rates[-1].get("BTC", 0)
|
||||
annual_fr = abs(btc_fr) * 365 * 3 if btc_fr else 0
|
||||
if annual_fr > 0.05:
|
||||
STRATEGIES["Funding Rate Arb"]["signals"].append(
|
||||
{"time":time.time(),"signal":"SELL" if btc_fr > 0 else "BUY",
|
||||
"strength": min(0.6, annual_fr * 50),
|
||||
"reason": "funding_{:.1f}pct_apr".format(annual_fr*100)}
|
||||
)
|
||||
)
|
||||
if annual_fr > 0.05: # >5% APR (production threshold)
|
||||
STRATEGIES["Funding Rate Arb"]["signals"].append(
|
||||
{"time":time.time(),"signal":"SELL" if btc_fr > 0 else "BUY",
|
||||
"strength": min(0.6, annual_fr * 50),
|
||||
"reason": "funding_{:.1f}pct_apr".format(annual_fr*100)}
|
||||
)
|
||||
|
||||
# Pairs: BTC/ETH ratio Z-score
|
||||
if len(btc_prices) >= 20 and len(eth_prices) >= 20:
|
||||
@@ -270,6 +283,23 @@ def compute_signals():
|
||||
STRATEGIES["Pairs Trading"]["signals"].append({"time":time.time(),"signal":"SELL_ETH","strength":z})
|
||||
elif z < -1.5:
|
||||
STRATEGIES["Pairs Trading"]["signals"].append({"time":time.time(),"signal":"BUY_ETH","strength":abs(z)})
|
||||
# Kalman Pairs: adaptive hedge ratio
|
||||
if len(btc_prices)>=20 and len(eth_prices)>=20:
|
||||
try:
|
||||
from strategies.kalman_pairs import KalmanPairsTrader
|
||||
if "_kalman_paper" not in dir():
|
||||
globals()["_kalman_paper"] = KalmanPairsTrader(
|
||||
transition_covariance=1e-4, observation_covariance=1e-2,
|
||||
z_entry=2.0, z_exit=0.5, warmup_bars=20,
|
||||
)
|
||||
result = globals()["_kalman_paper"].step(eth, btc)
|
||||
if result["signal"] != 0:
|
||||
sig = "BUY_ETH" if result["signal"] > 0 else "SELL_ETH"
|
||||
STRATEGIES["Kalman Pairs"]["signals"].append({
|
||||
"time": time.time(), "signal": sig,
|
||||
"strength": abs(result["z_score"])
|
||||
})
|
||||
except: pass
|
||||
|
||||
# Momentum Breakout
|
||||
if len(btc_prices) >= 20:
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Funding Rate Arb — Complete Implementation.
|
||||
|
||||
Strategy:
|
||||
Funding rates on perpetual futures represent the cost of leverage.
|
||||
When funding is positive (longs pay shorts), short the perp and collect.
|
||||
When funding is negative (shorts pay longs), go long the perp and collect.
|
||||
|
||||
The Hyperliquid API provides predicted funding rates via:
|
||||
- predictedFundings: current predicted rate for each interval
|
||||
- metaAndAssetCtxs: asset context including current funding
|
||||
|
||||
Entry: |annualized_funding_rate| > threshold (5-10% APR)
|
||||
Exit: |annualized_funding_rate| < threshold/2 or after N hours
|
||||
Size: scales with rate — higher rate = larger size
|
||||
"""
|
||||
|
||||
import requests
|
||||
import time
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
MAINNET_API = "https://api.hyperliquid.xyz/info"
|
||||
TESTNET_API = "https://api.hyperliquid-testnet.xyz/info"
|
||||
|
||||
# Cache funding rates to avoid hitting API every tick
|
||||
_funding_cache: dict = {}
|
||||
_last_funding_fetch: float = 0
|
||||
FUNDING_CACHE_TTL = 30 # seconds
|
||||
|
||||
|
||||
def get_funding_rates(use_testnet: bool = False) -> dict[str, float]:
|
||||
"""
|
||||
Fetch current predicted funding rates for supported coins.
|
||||
|
||||
Uses Hyperliquid's predictedFundings endpoint which returns
|
||||
the current projected funding rate for each perpetual.
|
||||
|
||||
Returns: {coin: funding_rate_annualized}
|
||||
"""
|
||||
global _funding_cache, _last_funding_fetch
|
||||
|
||||
now = time.time()
|
||||
if now - _last_funding_fetch < FUNDING_CACHE_TTL and _funding_cache:
|
||||
return _funding_cache
|
||||
|
||||
api = TESTNET_API if use_testnet else MAINNET_API
|
||||
rates: dict[str, float] = {}
|
||||
|
||||
# Method 1: Try metaAndAssetCtxs (most reliable)
|
||||
try:
|
||||
r = requests.post(MAINNET_API, json={"type": "metaAndAssetCtxs"}, timeout=10)
|
||||
data = r.json()
|
||||
if isinstance(data, list) and len(data) >= 2:
|
||||
universe = data[0].get("universe", [])
|
||||
ctxs = data[1]
|
||||
for i, u in enumerate(universe):
|
||||
name = u.get("name", "")
|
||||
if name in ("BTC", "ETH", "HYPE", "VVV", "SOL"):
|
||||
try:
|
||||
funding = float(ctxs[i].get("funding", 0))
|
||||
# funding is the 8h rate; annualize: × 365 × (24/8) = × 1095
|
||||
annual = funding * 1095
|
||||
rates[name] = annual
|
||||
except (IndexError, ValueError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Method 2: Fallback to predictedFundings
|
||||
if not rates:
|
||||
try:
|
||||
r = requests.post(MAINNET_API, json={"type": "predictedFundings"}, timeout=10)
|
||||
data = r.json()
|
||||
if isinstance(data, list):
|
||||
for coin_entry in data:
|
||||
coin = coin_entry[0]
|
||||
if coin not in ("BTC", "ETH", "HYPE", "VVV", "SOL"):
|
||||
continue
|
||||
for venue_entry in coin_entry[1]:
|
||||
venue = venue_entry[0]
|
||||
info = venue_entry[1]
|
||||
rate_str = info.get("fundingRate", "0")
|
||||
try:
|
||||
rate = float(rate_str)
|
||||
except (ValueError, TypeError):
|
||||
rate = 0.0
|
||||
interval_hours = info.get("fundingIntervalHours", 8)
|
||||
annual = rate * (365 * 24 / interval_hours)
|
||||
if coin not in rates or "HlPerp" in venue:
|
||||
rates[coin] = annual
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_funding_cache = rates
|
||||
_last_funding_fetch = now
|
||||
return rates
|
||||
|
||||
|
||||
def funding_arb_signal(
|
||||
coin: str = "BTC",
|
||||
apr_threshold: float = 0.05, # 5% APR minimum
|
||||
apr_exit: float = 0.02, # 2% APR to exit
|
||||
current_position: int = 0,
|
||||
) -> dict:
|
||||
"""
|
||||
Generate funding rate arbitrage signal.
|
||||
|
||||
Args:
|
||||
coin: Ticker to check.
|
||||
apr_threshold: Minimum annualized funding rate to enter (>0.05 = 5%).
|
||||
apr_exit: Rate below which to exit position.
|
||||
current_position: -1 (short), 0 (none), +1 (long).
|
||||
|
||||
Returns:
|
||||
dict with signal, rate, annual_apr, reason.
|
||||
"""
|
||||
rates = get_funding_rates()
|
||||
annual = rates.get(coin, 0)
|
||||
rate_8h = annual / 1095 # de-annualize
|
||||
|
||||
signal = 0
|
||||
reason = ""
|
||||
|
||||
if abs(annual) > apr_threshold and current_position == 0:
|
||||
signal = -1 if annual > 0 else +1 # short if funding positive, long if negative
|
||||
reason = f"funding_{annual*100:.1f}pct_apr"
|
||||
elif current_position != 0:
|
||||
# Exit condition: rate has dropped below exit threshold
|
||||
if abs(annual) < apr_exit:
|
||||
signal = -current_position
|
||||
reason = f"exit_funding_{annual*100:.2f}pct_apr"
|
||||
# Also exit if funding flips sign (we'd be paying instead of collecting)
|
||||
elif (current_position == -1 and annual < 0) or (current_position == 1 and annual > 0):
|
||||
signal = -current_position
|
||||
reason = f"exit_funding_flipped_{annual*100:.2f}pct_apr"
|
||||
|
||||
return {
|
||||
"signal": signal,
|
||||
"rate_8h": rate_8h,
|
||||
"annual_apr": annual,
|
||||
"reason": reason,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
FTDT Kalman Pairs Trading — Statistical Arbitrage Engine.
|
||||
|
||||
Core components:
|
||||
- kalman_filter: Pure-NumPy Kalman filter + KalmanPairsTrader
|
||||
- pair_discovery: Cointegration tests, half-life filter, rolling OLS
|
||||
- trading_system: Production orchestrator (multi-pair, risk layer)
|
||||
- backtest: Walk-forward backtester with rolling OLS comparison
|
||||
- tuning: Grid search for optimal transition_covariance
|
||||
|
||||
Quick start:
|
||||
from strategies.kalman_pairs import (
|
||||
KalmanPairsTrader, discover_pairs,
|
||||
backtest_kalman_pairs, backtest_rolling_ols,
|
||||
run_comparison, find_optimal_params
|
||||
)
|
||||
"""
|
||||
|
||||
from .kalman_filter import KalmanFilter, KalmanPairsTrader, KalmanState
|
||||
from .pair_discovery import (
|
||||
discover_pairs, test_pair, estimate_half_life,
|
||||
adf_test, compute_rolling_ols_hedge,
|
||||
)
|
||||
from .trading_system import KalmanPairsTradingSystem, KalmanPairsConfig
|
||||
from .backtest import (
|
||||
backtest_kalman_pairs, backtest_rolling_ols, run_comparison,
|
||||
)
|
||||
from .tuning import grid_search_transition_cov, find_optimal_params
|
||||
|
||||
__all__ = [
|
||||
"KalmanFilter",
|
||||
"KalmanPairsTrader",
|
||||
"KalmanState",
|
||||
"KalmanPairsTradingSystem",
|
||||
"KalmanPairsConfig",
|
||||
"discover_pairs",
|
||||
"test_pair",
|
||||
"estimate_half_life",
|
||||
"adf_test",
|
||||
"compute_rolling_ols_hedge",
|
||||
"backtest_kalman_pairs",
|
||||
"backtest_rolling_ols",
|
||||
"run_comparison",
|
||||
"grid_search_transition_cov",
|
||||
"find_optimal_params",
|
||||
]
|
||||
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
Kalman Pairs Backtesting Framework.
|
||||
|
||||
Full walk-forward backtest with:
|
||||
- Realistic execution (transaction costs, capital allocation)
|
||||
- Per-trade P&L tracking
|
||||
- Side-by-side comparison vs rolling OLS (60-day, 120-day windows)
|
||||
- Performance report: CAGR, Sharpe, Sortino, max DD, win rate, turnover
|
||||
- Regime-shift stress tests
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from typing import Optional
|
||||
from .kalman_filter import KalmanPairsTrader
|
||||
from .pair_discovery import compute_rolling_ols_hedge
|
||||
|
||||
# Import project metrics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
from common.metrics import sharpe, sortino, max_drawdown, win_rate
|
||||
|
||||
|
||||
def backtest_kalman_pairs(
|
||||
X: np.ndarray,
|
||||
Y: np.ndarray,
|
||||
trader: KalmanPairsTrader,
|
||||
trade_size_usd: float = 100.0,
|
||||
transaction_cost_bps: float = 2.5,
|
||||
initial_capital: float = 10000.0,
|
||||
) -> dict:
|
||||
"""
|
||||
Run a walk-forward backtest for a single pair using Kalman filter.
|
||||
|
||||
Args:
|
||||
X, Y: Price series (must be same length).
|
||||
trader: Pre-configured KalmanPairsTrader (already initialized).
|
||||
trade_size_usd: Notional per leg in USD.
|
||||
transaction_cost_bps: Fee per leg in basis points.
|
||||
initial_capital: Starting capital.
|
||||
|
||||
Returns:
|
||||
dict with: trades list, equity_curve, metrics, final_equity.
|
||||
"""
|
||||
n = min(len(X), len(Y))
|
||||
trader.reset()
|
||||
|
||||
capital = initial_capital
|
||||
peak_capital = initial_capital
|
||||
equity_curve: list[dict] = []
|
||||
trades: list[dict] = []
|
||||
open_trade: Optional[dict] = None
|
||||
|
||||
fee_rate = transaction_cost_bps / 10000.0 # bps → decimal
|
||||
|
||||
for t in range(n):
|
||||
x_t = float(X[t])
|
||||
y_t = float(Y[t])
|
||||
result = trader.step(x_t, y_t)
|
||||
|
||||
signal = result["signal"]
|
||||
beta = result["beta"]
|
||||
|
||||
if signal != 0:
|
||||
if open_trade is None:
|
||||
# Open position
|
||||
entry_x = x_t
|
||||
entry_y = y_t
|
||||
size_x = trade_size_usd / entry_x if entry_x > 0 else 0
|
||||
size_y = trade_size_usd / entry_y if entry_y > 0 else 0
|
||||
|
||||
# Hedge: use current beta
|
||||
# If signal = +1: LONG Y (size_y), SHORT X (size_x * beta)
|
||||
# If signal = -1: SHORT Y (size_y), LONG X (size_x * beta)
|
||||
hedge_notional = size_x * entry_x * abs(beta) if beta else 0
|
||||
fee = (trade_size_usd + hedge_notional) * fee_rate
|
||||
|
||||
capital -= fee
|
||||
|
||||
open_trade = {
|
||||
"entry_time": t,
|
||||
"signal": signal,
|
||||
"entry_x": entry_x,
|
||||
"entry_y": entry_y,
|
||||
"beta_at_entry": beta,
|
||||
"size_x": size_x,
|
||||
"size_y": size_y,
|
||||
"fee_paid": fee,
|
||||
}
|
||||
elif open_trade is not None and signal == -open_trade["signal"]:
|
||||
# Close position
|
||||
# PnL: (Y exit - Y entry) * size_y * sign + (X entry - X exit) * size_x * beta * sign
|
||||
exit_sign = open_trade["signal"]
|
||||
pnl_y = (y_t - open_trade["entry_y"]) * open_trade["size_y"] * exit_sign
|
||||
pnl_x = (open_trade["entry_x"] - x_t) * open_trade["size_x"] * abs(open_trade["beta_at_entry"]) * exit_sign
|
||||
gross_pnl = pnl_y + pnl_x
|
||||
|
||||
exit_notional = abs(y_t * open_trade["size_y"]) + abs(x_t * open_trade["size_x"] * open_trade["beta_at_entry"])
|
||||
fee = exit_notional * fee_rate
|
||||
net_pnl = gross_pnl - fee
|
||||
|
||||
capital += net_pnl
|
||||
|
||||
trades.append({
|
||||
"entry_time": open_trade["entry_time"],
|
||||
"exit_time": t,
|
||||
"signal": open_trade["signal"],
|
||||
"entry_x": open_trade["entry_x"],
|
||||
"exit_x": x_t,
|
||||
"entry_y": open_trade["entry_y"],
|
||||
"exit_y": y_t,
|
||||
"beta": open_trade["beta_at_entry"],
|
||||
"gross_pnl": round(gross_pnl, 4),
|
||||
"net_pnl": round(net_pnl, 4),
|
||||
"fee": round(open_trade["fee_paid"] + fee, 6),
|
||||
"duration_bars": t - open_trade["entry_time"],
|
||||
})
|
||||
open_trade = None
|
||||
|
||||
# Track equity
|
||||
unrealized = 0.0
|
||||
if open_trade is not None:
|
||||
exit_sign = open_trade["signal"]
|
||||
ur_y = (y_t - open_trade["entry_y"]) * open_trade["size_y"] * exit_sign
|
||||
ur_x = (open_trade["entry_x"] - x_t) * open_trade["size_x"] * abs(open_trade["beta_at_entry"]) * exit_sign
|
||||
unrealized = ur_y + ur_x
|
||||
|
||||
peak_capital = max(peak_capital, capital + unrealized)
|
||||
equity_curve.append({
|
||||
"t": t,
|
||||
"equity": round(capital + unrealized, 4),
|
||||
"alpha": round(result["alpha"], 6),
|
||||
"beta": round(result["beta"], 6),
|
||||
"spread": round(result["spread"], 6),
|
||||
"z_score": round(result["z_score"], 4),
|
||||
})
|
||||
|
||||
# Force close open trade at end
|
||||
if open_trade is not None:
|
||||
exit_sign = open_trade["signal"]
|
||||
y_t = float(Y[-1])
|
||||
x_t = float(X[-1])
|
||||
pnl_y = (y_t - open_trade["entry_y"]) * open_trade["size_y"] * exit_sign
|
||||
pnl_x = (open_trade["entry_x"] - x_t) * open_trade["size_x"] * abs(open_trade["beta_at_entry"]) * exit_sign
|
||||
gross_pnl = pnl_y + pnl_x
|
||||
exit_notional = abs(y_t * open_trade["size_y"]) + abs(x_t * open_trade["size_x"] * open_trade["beta_at_entry"])
|
||||
fee = exit_notional * fee_rate
|
||||
capital += gross_pnl - fee
|
||||
trades.append({
|
||||
"entry_time": open_trade["entry_time"],
|
||||
"exit_time": n - 1,
|
||||
"signal": open_trade["signal"],
|
||||
"entry_x": open_trade["entry_x"],
|
||||
"exit_x": x_t,
|
||||
"entry_y": open_trade["entry_y"],
|
||||
"exit_y": y_t,
|
||||
"beta": open_trade["beta_at_entry"],
|
||||
"gross_pnl": round(gross_pnl, 4),
|
||||
"net_pnl": round(gross_pnl - fee, 4),
|
||||
"fee": round(open_trade["fee_paid"] + fee, 6),
|
||||
"duration_bars": n - 1 - open_trade["entry_time"],
|
||||
})
|
||||
|
||||
# ── Metrics ──
|
||||
eq = np.array([e["equity"] for e in equity_curve])
|
||||
returns = np.diff(eq) / eq[:-1] if len(eq) > 1 else np.array([0.0])
|
||||
|
||||
total_pnl = capital - initial_capital
|
||||
pnl_pct = total_pnl / initial_capital * 100
|
||||
dd = max_drawdown(eq.tolist())
|
||||
sh = sharpe(returns.tolist())
|
||||
so = sortino(returns.tolist())
|
||||
wr = win_rate(trades)
|
||||
cagr = ((capital / initial_capital) ** (1 / max(n / (365 * 24), 0.01)) - 1) * 100 if n > 0 and capital > 0 else 0.0
|
||||
|
||||
return {
|
||||
"total_pnl": round(total_pnl, 4),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"cagr": round(cagr, 2),
|
||||
"sharpe": round(sh, 4),
|
||||
"sortino": round(so, 4),
|
||||
"max_drawdown": round(dd, 4),
|
||||
"win_rate": round(wr, 4),
|
||||
"total_trades": len(trades),
|
||||
"final_equity": round(capital, 4),
|
||||
"transaction_costs": round(sum(t["fee"] for t in trades), 4),
|
||||
"avg_trade_duration": round(np.mean([t["duration_bars"] for t in trades]), 1) if trades else 0,
|
||||
"trades": trades[-200:],
|
||||
"equity_curve": equity_curve,
|
||||
"alpha_history": [e["alpha"] for e in equity_curve],
|
||||
"beta_history": [e["beta"] for e in equity_curve],
|
||||
"spread_history": [e["spread"] for e in equity_curve],
|
||||
"z_score_history": [e["z_score"] for e in equity_curve],
|
||||
}
|
||||
|
||||
|
||||
def backtest_rolling_ols(
|
||||
X: np.ndarray,
|
||||
Y: np.ndarray,
|
||||
window: int = 60,
|
||||
z_entry: float = 2.0,
|
||||
z_exit: float = 0.5,
|
||||
trade_size_usd: float = 100.0,
|
||||
transaction_cost_bps: float = 2.5,
|
||||
initial_capital: float = 10000.0,
|
||||
) -> dict:
|
||||
"""
|
||||
Baseline: classic rolling OLS pairs trading.
|
||||
|
||||
Uses a fixed-lookback rolling beta instead of Kalman adaptation.
|
||||
"""
|
||||
n = len(X)
|
||||
betas = compute_rolling_ols_hedge(X, Y, window)
|
||||
fee_rate = transaction_cost_bps / 10000.0
|
||||
|
||||
capital = initial_capital
|
||||
equity_curve: list[dict] = []
|
||||
trades: list[dict] = []
|
||||
open_trade: Optional[dict] = None
|
||||
|
||||
spreads: list[float] = []
|
||||
z_lookback = 100
|
||||
|
||||
for t in range(window, n):
|
||||
x_t = float(X[t])
|
||||
y_t = float(Y[t])
|
||||
beta = betas[t] if not np.isnan(betas[t]) else 1.0
|
||||
|
||||
spread = y_t - beta * x_t
|
||||
spreads.append(spread)
|
||||
|
||||
# Z-score
|
||||
lb = min(z_lookback, len(spreads))
|
||||
rec = spreads[-lb:]
|
||||
mu = np.mean(rec)
|
||||
sigma = np.std(rec, ddof=1)
|
||||
z = (spread - mu) / sigma if sigma > 1e-12 else 0.0
|
||||
|
||||
signal = 0
|
||||
if open_trade is None:
|
||||
if z > z_entry:
|
||||
signal = -1 # short Y, long X
|
||||
elif z < -z_entry:
|
||||
signal = +1 # long Y, short X
|
||||
else:
|
||||
if abs(z) < z_exit:
|
||||
signal = -open_trade["signal"]
|
||||
|
||||
if signal != 0:
|
||||
if open_trade is None:
|
||||
size_x = trade_size_usd / x_t if x_t > 0 else 0
|
||||
size_y = trade_size_usd / y_t if y_t > 0 else 0
|
||||
hedge_notional = size_x * x_t * abs(beta)
|
||||
fee = (trade_size_usd + hedge_notional) * fee_rate
|
||||
capital -= fee
|
||||
open_trade = {
|
||||
"entry_time": t, "signal": signal,
|
||||
"entry_x": x_t, "entry_y": y_t,
|
||||
"beta": beta, "size_x": size_x, "size_y": size_y,
|
||||
"fee_paid": fee,
|
||||
}
|
||||
elif signal == -open_trade["signal"]:
|
||||
es = open_trade["signal"]
|
||||
pnl_y = (y_t - open_trade["entry_y"]) * open_trade["size_y"] * es
|
||||
pnl_x = (open_trade["entry_x"] - x_t) * open_trade["size_x"] * abs(open_trade["beta"]) * es
|
||||
gross_pnl = pnl_y + pnl_x
|
||||
exit_notional = abs(y_t * open_trade["size_y"]) + abs(x_t * open_trade["size_x"] * open_trade["beta"])
|
||||
fee = exit_notional * fee_rate
|
||||
capital += gross_pnl - fee
|
||||
trades.append({
|
||||
"entry_time": open_trade["entry_time"], "exit_time": t,
|
||||
"signal": open_trade["signal"], "gross_pnl": round(gross_pnl, 4),
|
||||
"net_pnl": round(gross_pnl - fee, 4),
|
||||
"duration_bars": t - open_trade["entry_time"],
|
||||
})
|
||||
open_trade = None
|
||||
|
||||
equity_curve.append({"t": t, "equity": round(capital, 4)})
|
||||
|
||||
eq = np.array([e["equity"] for e in equity_curve])
|
||||
returns = np.diff(eq) / eq[:-1] if len(eq) > 1 else np.zeros(1)
|
||||
total_pnl = capital - initial_capital
|
||||
dd = max_drawdown(eq.tolist())
|
||||
|
||||
return {
|
||||
"total_pnl": round(total_pnl, 4),
|
||||
"pnl_pct": round(total_pnl / initial_capital * 100, 2),
|
||||
"sharpe": round(sharpe(returns.tolist()), 4),
|
||||
"sortino": round(sortino(returns.tolist()), 4),
|
||||
"max_drawdown": round(dd, 4),
|
||||
"win_rate": round(win_rate(trades), 4),
|
||||
"total_trades": len(trades),
|
||||
"final_equity": round(capital, 4),
|
||||
"trades": trades[-200:],
|
||||
"equity_curve": equity_curve,
|
||||
}
|
||||
|
||||
|
||||
def run_comparison(
|
||||
X: np.ndarray,
|
||||
Y: np.ndarray,
|
||||
transition_covariance: float = 1e-4,
|
||||
observation_covariance: float = 1e-2,
|
||||
z_entry: float = 2.0,
|
||||
z_exit: float = 0.5,
|
||||
trade_size_usd: float = 100.0,
|
||||
transaction_cost_bps: float = 2.5,
|
||||
ols_windows: list[int] = [60, 120],
|
||||
) -> dict:
|
||||
"""
|
||||
Run Kalman vs rolling OLS comparison backtest.
|
||||
|
||||
Returns:
|
||||
dict with kalman_results, ols_results, and comparison_summary.
|
||||
"""
|
||||
trader = KalmanPairsTrader(
|
||||
transition_covariance=transition_covariance,
|
||||
observation_covariance=observation_covariance,
|
||||
z_entry=z_entry, z_exit=z_exit,
|
||||
)
|
||||
|
||||
kalman = backtest_kalman_pairs(
|
||||
X, Y, trader,
|
||||
trade_size_usd=trade_size_usd,
|
||||
transaction_cost_bps=transaction_cost_bps,
|
||||
)
|
||||
|
||||
ols_results = {}
|
||||
for w in ols_windows:
|
||||
ols_results[f"ols_{w}d"] = backtest_rolling_ols(
|
||||
X, Y, window=w,
|
||||
z_entry=z_entry, z_exit=z_exit,
|
||||
trade_size_usd=trade_size_usd,
|
||||
transaction_cost_bps=transaction_cost_bps,
|
||||
)
|
||||
|
||||
return {
|
||||
"kalman": kalman,
|
||||
"ols": ols_results,
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Pure NumPy Kalman Filter for Pairs Trading.
|
||||
|
||||
Implements a linear Kalman filter with time-varying observation matrix
|
||||
suited for estimating the evolving hedge ratio βₜ and intercept αₜ
|
||||
in the cointegrating regression:
|
||||
|
||||
Yₜ = αₜ + βₜ Xₜ + vₜ (observation)
|
||||
[αₜ, βₜ]ᵀ = [αₜ₋₁, βₜ₋₁]ᵀ + wₜ (state transition, random walk)
|
||||
|
||||
Design decisions:
|
||||
- Pure NumPy (no scipy, no pykalman) → zero external deps beyond NumPy
|
||||
- Time-varying H matrix: Hₜ = [1, Xₜ] — adapts every observation
|
||||
- Diagonal process covariance Q controls adaptability:
|
||||
High Q → fast adaptation, noisy estimates (overfit risk)
|
||||
Low Q → slow adaptation, smooth estimates (lag risk)
|
||||
- Scalar observation noise R controls measurement noise filtering
|
||||
- State dimension = 2 (α, β); observation dimension = 1 (Y)
|
||||
- Online filtering mode: update() called per observation
|
||||
- Offline smoothing mode: smooth() runs RTS smoother over full series
|
||||
|
||||
Reference:
|
||||
R. E. Kalman (1960). "A New Approach to Linear Filtering
|
||||
and Prediction Problems."
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class KalmanState:
|
||||
"""Holds the Kalman filter state at a single timestep."""
|
||||
|
||||
alpha: float # Intercept estimate
|
||||
beta: float # Hedge ratio estimate
|
||||
cov: np.ndarray # 2×2 state covariance matrix
|
||||
log_likelihood: float = 0.0 # Contribution to log-likelihood
|
||||
|
||||
|
||||
class KalmanFilter:
|
||||
"""
|
||||
Pure-NumPy linear Kalman filter for the state-space model:
|
||||
|
||||
State: xₜ = F xₜ₋₁ + wₜ, wₜ ~ N(0, Q)
|
||||
Observation: yₜ = Hₜ xₜ + vₜ, vₜ ~ N(0, R)
|
||||
|
||||
where:
|
||||
- xₜ = [αₜ, βₜ]ᵀ (2×1 state vector)
|
||||
- F = I₂ (random walk transition)
|
||||
- Q = diag(q_α, q_β) or scalar × I₂
|
||||
- Hₜ = [1, Xₜ] (1×2, time-varying)
|
||||
- R = scalar (observation noise variance)
|
||||
|
||||
Usage:
|
||||
kf = KalmanFilter(transition_covariance=1e-4, observation_covariance=1e-2)
|
||||
for x, y in zip(X_series, Y_series):
|
||||
state = kf.update(x, y)
|
||||
print(state.alpha, state.beta)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transition_covariance: float = 1e-4,
|
||||
observation_covariance: float = 1e-2,
|
||||
initial_state_covariance: float = 1.0,
|
||||
initial_alpha: float = 0.0,
|
||||
initial_beta: float = 1.0,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
transition_covariance:
|
||||
Diagonal value(s) for process noise Q.
|
||||
Higher = faster adaptation, more noise.
|
||||
Can be float (both states) or (q_alpha, q_beta) tuple.
|
||||
observation_covariance:
|
||||
Scalar measurement noise R.
|
||||
Higher = smoother estimates (trust model more than data).
|
||||
initial_state_covariance:
|
||||
Initial uncertainty (diagonal of P₀).
|
||||
initial_alpha, initial_beta:
|
||||
Initial state estimates.
|
||||
"""
|
||||
# State dimension
|
||||
self.n_states = 2
|
||||
|
||||
# Transition matrix: identity (random walk)
|
||||
self.F = np.eye(self.n_states, dtype=np.float64)
|
||||
|
||||
# Process noise covariance Q
|
||||
if isinstance(transition_covariance, (int, float)):
|
||||
self.Q = np.eye(self.n_states) * transition_covariance
|
||||
else:
|
||||
self.Q = np.diag(transition_covariance)
|
||||
|
||||
# Observation noise (scalar)
|
||||
self.R = np.atleast_2d(observation_covariance).astype(np.float64)
|
||||
|
||||
# Initial state
|
||||
self.x = np.array([[initial_alpha], [initial_beta]], dtype=np.float64)
|
||||
|
||||
# Initial state covariance
|
||||
self.P = np.eye(self.n_states) * initial_state_covariance
|
||||
|
||||
# Bookkeeping
|
||||
self.n_obs = 0
|
||||
self.history: list[KalmanState] = []
|
||||
|
||||
# ── Properties ──────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def alpha(self) -> float:
|
||||
"""Current intercept estimate."""
|
||||
return float(self.x[0, 0])
|
||||
|
||||
@property
|
||||
def beta(self) -> float:
|
||||
"""Current hedge ratio estimate."""
|
||||
return float(self.x[1, 0])
|
||||
|
||||
# ── Core Filtering ──────────────────────────────────────
|
||||
|
||||
def update(self, X_t: float, Y_t: float) -> KalmanState:
|
||||
"""
|
||||
Single Kalman filter update step.
|
||||
|
||||
Args:
|
||||
X_t: Independent variable observation (e.g., X asset price)
|
||||
Y_t: Dependent variable observation (e.g., Y asset price)
|
||||
|
||||
Returns:
|
||||
KalmanState with current α, β, covariance, and log-likelihood.
|
||||
"""
|
||||
self.n_obs += 1
|
||||
|
||||
# ── Prediction ──
|
||||
x_pred = self.F @ self.x # (2×1)
|
||||
P_pred = self.F @ self.P @ self.F.T + self.Q # (2×2)
|
||||
|
||||
# ── Observation matrix (time-varying!) ──
|
||||
H = np.array([[1.0, X_t]], dtype=np.float64) # (1×2)
|
||||
|
||||
# ── Innovation ──
|
||||
y_pred = (H @ x_pred)[0, 0] # predicted Y
|
||||
innovation = Y_t - y_pred # scalar
|
||||
|
||||
S = H @ P_pred @ H.T + self.R # innovation covariance (1×1)
|
||||
S_inv = 1.0 / S[0, 0] if S[0, 0] > 0 else 1e10
|
||||
|
||||
# ── Kalman gain ──
|
||||
K = P_pred @ H.T * S_inv # (2×1)
|
||||
|
||||
# ── Update ──
|
||||
self.x = x_pred + K * innovation # (2×1)
|
||||
self.P = P_pred - K @ H @ P_pred # (2×2)
|
||||
# Ensure symmetry
|
||||
self.P = (self.P + self.P.T) / 2.0
|
||||
|
||||
# ── Log-likelihood contribution ──
|
||||
ll = -0.5 * (
|
||||
np.log(2 * np.pi * S[0, 0]) +
|
||||
innovation * innovation * S_inv
|
||||
)
|
||||
|
||||
state = KalmanState(
|
||||
alpha=float(self.x[0, 0]),
|
||||
beta=float(self.x[1, 0]),
|
||||
cov=self.P.copy(),
|
||||
log_likelihood=float(ll),
|
||||
)
|
||||
self.history.append(state)
|
||||
return state
|
||||
|
||||
def update_batch(self, X: np.ndarray, Y: np.ndarray) -> list[KalmanState]:
|
||||
"""Filter a full series of observations. Online (forward pass only)."""
|
||||
results = []
|
||||
for i in range(len(X)):
|
||||
state = self.update(float(X[i]), float(Y[i]))
|
||||
results.append(state)
|
||||
return results
|
||||
|
||||
def compute_spread(self, X_t: float, Y_t: float) -> float:
|
||||
"""
|
||||
Compute the Kalman-estimated spread at a given observation.
|
||||
|
||||
spreadₜ = Yₜ - (αₜ + βₜ Xₜ)
|
||||
|
||||
Positive spread → Y is overpriced relative to X → short Y, long X.
|
||||
Negative spread → Y is underpriced relative to X → long Y, short X.
|
||||
"""
|
||||
return Y_t - (self.alpha + self.beta * X_t)
|
||||
|
||||
# ── Smoothing (RTS) ────────────────────────────────────
|
||||
|
||||
def smooth(self) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
Rauch-Tung-Striebel (RTS) smoother.
|
||||
|
||||
Runs backward pass to produce smoothed state estimates
|
||||
that incorporate all observations (future + past).
|
||||
|
||||
Returns:
|
||||
(smoothed_alpha, smoothed_beta) as 1-D arrays.
|
||||
"""
|
||||
n = len(self.history)
|
||||
if n == 0:
|
||||
return np.array([]), np.array([])
|
||||
|
||||
# Forward states and covariances
|
||||
x_fwd = np.array([[s.alpha, s.beta] for s in self.history]).T # (2×n)
|
||||
P_fwd = np.array([s.cov for s in self.history]) # (n×2×2)
|
||||
|
||||
# Initialize smoothed
|
||||
x_smooth = np.zeros_like(x_fwd)
|
||||
x_smooth[:, -1] = x_fwd[:, -1]
|
||||
|
||||
# Backward pass
|
||||
for t in range(n - 2, -1, -1):
|
||||
P_next = P_fwd[t + 1] # (2×2)
|
||||
P_curr = P_fwd[t] # (2×2)
|
||||
|
||||
# Smoothing gain
|
||||
P_pred = self.F @ P_curr @ self.F.T + self.Q
|
||||
try:
|
||||
C = P_curr @ self.F.T @ np.linalg.inv(P_pred)
|
||||
except np.linalg.LinAlgError:
|
||||
C = np.zeros((2, 2))
|
||||
|
||||
x_smooth[:, t] = x_fwd[:, t] + C @ (x_smooth[:, t + 1] - self.F @ x_fwd[:, t])
|
||||
|
||||
return x_smooth[0, :], x_smooth[1, :]
|
||||
|
||||
# ── Utility ─────────────────────────────────────────────
|
||||
|
||||
def likelihood(self) -> float:
|
||||
"""Total log-likelihood of the filtered series."""
|
||||
return sum(s.log_likelihood for s in self.history)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset filter to initial state (for warm-start / retune)."""
|
||||
self.x = np.array([[0.0], [1.0]], dtype=np.float64)
|
||||
self.P = np.eye(self.n_states) * 1.0
|
||||
self.n_obs = 0
|
||||
self.history.clear()
|
||||
|
||||
|
||||
class KalmanPairsTrader:
|
||||
"""
|
||||
Production-grade Kalman-filter-based pairs trading engine.
|
||||
|
||||
Encapsulates the Kalman filter, spread computation, z-score generation,
|
||||
and signal logic. Designed to be called bar-by-bar in a live trading loop
|
||||
or run over historical data for backtesting.
|
||||
|
||||
Architecture:
|
||||
┌─────────────┐
|
||||
│ Price Feed │──Xₜ, Yₜ──→ KalmanFilter.update()
|
||||
└─────────────┘ │
|
||||
┌────────────▼────────────┐
|
||||
│ αₜ, βₜ, spreadₜ │
|
||||
│ zₜ = (spreadₜ - μ) / σ │
|
||||
│ signal = f(zₜ, θ) │
|
||||
└─────────────────────────┘
|
||||
|
||||
Signal logic:
|
||||
z > +z_entry → Y overpriced → SHORT Y, LONG X
|
||||
z < -z_entry → Y underpriced → LONG Y, SHORT X
|
||||
|z| < z_exit → close position (mean reversion complete)
|
||||
|
||||
Usage:
|
||||
trader = KalmanPairsTrader(
|
||||
transition_covariance=1e-4,
|
||||
z_entry=2.0,
|
||||
z_exit=0.5,
|
||||
)
|
||||
for x, y in zip(prices_X, prices_Y):
|
||||
signal = trader.step(x, y)
|
||||
if signal != 0:
|
||||
execute(signal)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transition_covariance: float = 1e-4,
|
||||
observation_covariance: float = 1e-2,
|
||||
z_entry: float = 2.0,
|
||||
z_exit: float = 0.5,
|
||||
z_stop: float = 4.0,
|
||||
warmup_bars: int = 50,
|
||||
z_score_lookback: int = 100,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
transition_covariance: Q diagonal — controls β adaptation speed.
|
||||
observation_covariance: R scalar — measurement noise filter.
|
||||
z_entry: Z-score threshold for opening positions.
|
||||
z_exit: Z-score threshold for closing positions.
|
||||
z_stop: Stop-loss threshold (close immediately if |z| exceeds this).
|
||||
warmup_bars: Minimum observations before trading.
|
||||
z_score_lookback: Rolling window for z-score μ and σ estimation.
|
||||
"""
|
||||
self.kf = KalmanFilter(
|
||||
transition_covariance=transition_covariance,
|
||||
observation_covariance=observation_covariance,
|
||||
initial_alpha=0.0,
|
||||
initial_beta=1.0,
|
||||
)
|
||||
self.z_entry = z_entry
|
||||
self.z_exit = z_exit
|
||||
self.z_stop = z_stop
|
||||
self.warmup_bars = warmup_bars
|
||||
self.z_score_lookback = z_score_lookback
|
||||
|
||||
# Rolling spread history for z-score normalization
|
||||
self._spreads: list[float] = []
|
||||
|
||||
# Current position state
|
||||
self.position: int = 0 # +1 = long Y/short X, -1 = short Y/long X
|
||||
self.entry_spread: float = 0.0
|
||||
|
||||
# ── Properties ──────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def alpha(self) -> float:
|
||||
return self.kf.alpha
|
||||
|
||||
@property
|
||||
def beta(self) -> float:
|
||||
return self.kf.beta
|
||||
|
||||
@property
|
||||
def spread(self) -> float:
|
||||
return self._spreads[-1] if self._spreads else 0.0
|
||||
|
||||
# ── Core Step ───────────────────────────────────────────
|
||||
|
||||
def step(self, X_t: float, Y_t: float) -> dict:
|
||||
"""
|
||||
Process one observation and return a signal.
|
||||
|
||||
Args:
|
||||
X_t: Independent variable price (denominator asset)
|
||||
Y_t: Dependent variable price (numerator asset)
|
||||
|
||||
Returns:
|
||||
Dict with keys: signal (int), spread (float), z_score (float),
|
||||
alpha (float), beta (float), position (int)
|
||||
"""
|
||||
# Update Kalman filter
|
||||
self.kf.update(X_t, Y_t)
|
||||
|
||||
# Compute spread
|
||||
spread = self.kf.compute_spread(X_t, Y_t)
|
||||
self._spreads.append(spread)
|
||||
|
||||
# Trim spread history to lookback
|
||||
lookback = min(self.z_score_lookback, len(self._spreads))
|
||||
recent = self._spreads[-lookback:]
|
||||
|
||||
# Z-score computation
|
||||
mu = np.mean(recent)
|
||||
sigma = np.std(recent, ddof=1)
|
||||
z = (spread - mu) / sigma if sigma > 1e-12 else 0.0
|
||||
|
||||
# Signal generation
|
||||
signal = 0 # 0 = hold / no action
|
||||
|
||||
if self.kf.n_obs < self.warmup_bars:
|
||||
signal = 0
|
||||
elif self.position == 0:
|
||||
# No position — look for entry
|
||||
if z > self.z_entry:
|
||||
signal = -1 # Y overpriced → SHORT Y, LONG X
|
||||
elif z < -self.z_entry:
|
||||
signal = +1 # Y underpriced → LONG Y, SHORT X
|
||||
else:
|
||||
# In position — check exit conditions
|
||||
if abs(z) < self.z_exit:
|
||||
signal = -self.position # close
|
||||
elif abs(z) > self.z_stop:
|
||||
signal = -self.position # stop-loss
|
||||
# Also mean-reversion exit: if spread crosses zero
|
||||
elif (self.position > 0 and spread > 0) or (self.position < 0 and spread < 0):
|
||||
signal = -self.position # profit-taking on mean cross
|
||||
|
||||
# Update position
|
||||
if signal != 0 and self.position == 0:
|
||||
self.position = signal
|
||||
self.entry_spread = spread
|
||||
elif signal != 0 and self.position != 0:
|
||||
self.position = 0
|
||||
self.entry_spread = 0.0
|
||||
|
||||
return {
|
||||
"signal": signal,
|
||||
"spread": spread,
|
||||
"z_score": z,
|
||||
"alpha": self.alpha,
|
||||
"beta": self.beta,
|
||||
"position": self.position,
|
||||
}
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset trader state (for backtest runs)."""
|
||||
self.kf.reset()
|
||||
self._spreads.clear()
|
||||
self.position = 0
|
||||
self.entry_spread = 0.0
|
||||
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Cointegration-based pair discovery with Ornstein-Uhlenbeck
|
||||
half-life filtering.
|
||||
|
||||
Provides tools to:
|
||||
1. Test pairs for cointegration (Engle-Granger two-step)
|
||||
2. Estimate OU half-life of the residual spread
|
||||
3. Filter candidate pairs by minimum half-life
|
||||
4. Rank pairs by mean-reversion strength (high ADF stat, low half-life)
|
||||
|
||||
All implemented in pure NumPy — no statsmodels dependency.
|
||||
|
||||
Design decisions:
|
||||
- Critical values for ADF test are hardcoded (MacKinnon 1994 tables)
|
||||
→ avoids importing statsmodels.
|
||||
- Both 1% and 5% significance levels supported.
|
||||
- Half-life computed via OLS on the AR(1) of the residual.
|
||||
- Minimum observations: 100 for cointegration test (avoid spurious results).
|
||||
- Sector constraint: optional list of ticker prefixes (e.g., "ETH", "BTC").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── MacKinnon (1994) critical values for ADF test ────────────
|
||||
# Table for Case 2: regression with intercept, no trend
|
||||
# Rows: sample sizes (25, 50, 100, 250, 500, ∞)
|
||||
# Cols: significance levels (1%, 5%, 10%)
|
||||
|
||||
_MACKINNON_CASE2 = np.array([
|
||||
[-3.75, -3.00, -2.63], # N=25
|
||||
[-3.58, -2.93, -2.60], # N=50
|
||||
[-3.51, -2.89, -2.58], # N=100
|
||||
[-3.46, -2.88, -2.57], # N=250
|
||||
[-3.44, -2.87, -2.57], # N=500
|
||||
[-3.43, -2.86, -2.57], # N=∞
|
||||
])
|
||||
|
||||
_MACKINNON_N_SIZES = np.array([25, 50, 100, 250, 500, 999999])
|
||||
|
||||
|
||||
def adf_critical_value(n_obs: int, sig: float = 0.05) -> float:
|
||||
"""Return ADF critical value for given sample size and significance."""
|
||||
col = 0 if sig <= 0.01 else 1 if sig <= 0.05 else 2
|
||||
idx = np.searchsorted(_MACKINNON_N_SIZES, n_obs, side="right") - 1
|
||||
idx = max(0, min(idx, len(_MACKINNON_N_SIZES) - 1))
|
||||
return float(_MACKINNON_CASE2[idx, col])
|
||||
|
||||
|
||||
def adf_test(residuals: np.ndarray, sig: float = 0.05) -> dict:
|
||||
"""
|
||||
Augmented Dickey-Fuller test (no lags).
|
||||
|
||||
Tests H₀: unit root (not mean-reverting) vs H₁: stationary.
|
||||
|
||||
Args:
|
||||
residuals: 1-D array of OLS residuals from cointegrating regression.
|
||||
sig: Significance level (0.01 or 0.05).
|
||||
|
||||
Returns:
|
||||
dict with keys: statistic, critical_value, is_stationary, p_value_approx.
|
||||
"""
|
||||
n = len(residuals)
|
||||
if n < 20:
|
||||
return {"statistic": 0.0, "critical_value": 0.0, "is_stationary": False, "p_value_approx": 1.0}
|
||||
|
||||
dy = np.diff(residuals)
|
||||
y_lag = residuals[:-1]
|
||||
|
||||
# OLS: Δyₜ = γ yₜ₋₁ + εₜ
|
||||
X = y_lag.reshape(-1, 1)
|
||||
Y = dy.reshape(-1, 1)
|
||||
|
||||
# γ = (XᵀX)⁻¹ XᵀY
|
||||
XtX = X.T @ X
|
||||
if XtX[0, 0] < 1e-12:
|
||||
return {"statistic": 0.0, "critical_value": 0.0, "is_stationary": False, "p_value_approx": 1.0}
|
||||
|
||||
gamma = float((np.linalg.inv(XtX) @ X.T @ Y)[0, 0])
|
||||
residuals_ols = Y.flatten() - gamma * X.flatten()
|
||||
se = np.std(residuals_ols, ddof=1)
|
||||
t_stat = gamma / se if se > 1e-12 else 0.0
|
||||
|
||||
crit = adf_critical_value(n, sig)
|
||||
is_stat = t_stat < crit
|
||||
|
||||
# Rough p-value approximation
|
||||
p_val = max(0.0, min(1.0, 1.0 / (1.0 + np.exp(-(abs(t_stat) - 2.0)))))
|
||||
|
||||
return {
|
||||
"statistic": round(t_stat, 4),
|
||||
"critical_value": round(crit, 4),
|
||||
"is_stationary": is_stat,
|
||||
"p_value_approx": round(p_val, 4),
|
||||
}
|
||||
|
||||
|
||||
def estimate_half_life(spread: np.ndarray) -> float:
|
||||
"""
|
||||
Estimate the Ornstein-Uhlenbeck half-life of a spread series.
|
||||
|
||||
Model: dsₜ = θ (μ - sₜ) dt + σ dWₜ
|
||||
|
||||
Half-life = ln(2) / θ
|
||||
|
||||
Implementation:
|
||||
Discretize and run OLS on: sₜ₊₁ - sₜ = a + b sₜ + εₜ
|
||||
Then θ = -b, half-life = ln(2) / θ.
|
||||
|
||||
Args:
|
||||
spread: 1-D array of spread values.
|
||||
|
||||
Returns:
|
||||
Half-life in number of periods. Returns inf if not mean-reverting.
|
||||
"""
|
||||
n = len(spread)
|
||||
if n < 20:
|
||||
return float("inf")
|
||||
|
||||
s = spread
|
||||
ds = np.diff(s)
|
||||
s_lag = s[:-1]
|
||||
|
||||
# OLS: ds[t] = a + b * s[t-1]
|
||||
X = np.column_stack([np.ones(len(s_lag)), s_lag])
|
||||
Y = ds
|
||||
|
||||
try:
|
||||
coeff = np.linalg.lstsq(X, Y, rcond=None)[0]
|
||||
except np.linalg.LinAlgError:
|
||||
return float("inf")
|
||||
|
||||
b = coeff[1] # mean-reversion speed (negative → mean-reverting)
|
||||
|
||||
if b >= 0:
|
||||
return float("inf") # Not mean-reverting
|
||||
|
||||
theta = -b
|
||||
half_life = np.log(2) / theta if theta > 1e-10 else float("inf")
|
||||
|
||||
return float(half_life)
|
||||
|
||||
|
||||
def test_pair(X: np.ndarray, Y: np.ndarray, sig: float = 0.05) -> dict:
|
||||
"""
|
||||
Full cointegration + half-life test for a candidate pair.
|
||||
|
||||
Engle-Granger two-step:
|
||||
1. Regress Y on X: Y = α + β X + ε
|
||||
2. Test ε for stationarity (ADF)
|
||||
3. Estimate half-life of ε
|
||||
|
||||
Args:
|
||||
X: Price series of asset X (independent).
|
||||
Y: Price series of asset Y (dependent).
|
||||
sig: ADF significance level.
|
||||
|
||||
Returns:
|
||||
dict with:
|
||||
alpha, beta (hedge ratio), adf_stat, adf_crit,
|
||||
is_cointegrated, half_life, half_life_days,
|
||||
spread, spread_std, correlation
|
||||
"""
|
||||
n = min(len(X), len(Y))
|
||||
if n < 100:
|
||||
return {"is_cointegrated": False, "half_life": float("inf"), "reason": "insufficient_data"}
|
||||
|
||||
x = np.array(X[-n:])
|
||||
y = np.array(Y[-n:])
|
||||
|
||||
# Step 1: OLS regression
|
||||
X_mat = np.column_stack([np.ones(n), x])
|
||||
try:
|
||||
coeff = np.linalg.lstsq(X_mat, y, rcond=None)[0]
|
||||
except np.linalg.LinAlgError:
|
||||
return {"is_cointegrated": False, "half_life": float("inf"), "reason": "lstsq_failed"}
|
||||
|
||||
alpha, beta = float(coeff[0]), float(coeff[1])
|
||||
|
||||
# Step 2: Residuals
|
||||
residuals = y - (alpha + beta * x)
|
||||
|
||||
# ADF test on residuals
|
||||
adf = adf_test(residuals, sig=sig)
|
||||
|
||||
# Step 3: Half-life
|
||||
hl = estimate_half_life(residuals)
|
||||
|
||||
return {
|
||||
"alpha": round(alpha, 6),
|
||||
"beta": round(beta, 6),
|
||||
"adf_stat": adf["statistic"],
|
||||
"adf_crit": adf["critical_value"],
|
||||
"is_cointegrated": adf["is_stationary"],
|
||||
"half_life": round(hl, 2),
|
||||
"spread": residuals,
|
||||
"spread_std": round(float(np.std(residuals)), 6),
|
||||
"correlation": round(float(np.corrcoef(x, y)[0, 1]), 4),
|
||||
}
|
||||
|
||||
|
||||
def discover_pairs(
|
||||
price_data: dict[str, np.ndarray],
|
||||
sector_constraint: Optional[str] = None,
|
||||
min_half_life: float = 1.0,
|
||||
max_half_life: float = 20.0,
|
||||
sig_level: float = 0.05,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Screen all possible pairs in a universe for tradeable cointegration.
|
||||
|
||||
Filters:
|
||||
1. ADF test passes at given significance level
|
||||
2. Half-life between min_half_life and max_half_life (periods)
|
||||
3. Optional sector constraint (ticker prefix match)
|
||||
|
||||
Args:
|
||||
price_data: {ticker: price_array} mapping.
|
||||
sector_constraint: If set, only pairs where both tickers share this prefix.
|
||||
min_half_life: Minimum half-life in periods.
|
||||
max_half_life: Maximum half-life in periods.
|
||||
sig_level: ADF significance level.
|
||||
|
||||
Returns:
|
||||
List of dicts, sorted by half-life (ascending — faster mean reversion first).
|
||||
Each dict has: pair, alpha, beta, half_life, adf_stat, spread_std, correlation.
|
||||
"""
|
||||
tickers = sorted(price_data.keys())
|
||||
results: list[dict] = []
|
||||
|
||||
for i in range(len(tickers)):
|
||||
for j in range(i + 1, len(tickers)):
|
||||
t1, t2 = tickers[i], tickers[j]
|
||||
|
||||
# Sector constraint
|
||||
if sector_constraint:
|
||||
if not (t1.startswith(sector_constraint) and t2.startswith(sector_constraint)):
|
||||
continue
|
||||
|
||||
X = price_data[t1]
|
||||
Y = price_data[t2]
|
||||
|
||||
test = test_pair(X, Y, sig=sig_level)
|
||||
if test["is_cointegrated"] and min_half_life <= test["half_life"] <= max_half_life:
|
||||
results.append({
|
||||
"pair": (t1, t2),
|
||||
"X_ticker": t1,
|
||||
"Y_ticker": t2,
|
||||
"alpha": test["alpha"],
|
||||
"beta": test["beta"],
|
||||
"half_life": test["half_life"],
|
||||
"adf_stat": test["adf_stat"],
|
||||
"spread_std": test["spread_std"],
|
||||
"correlation": test["correlation"],
|
||||
})
|
||||
|
||||
# Sort by half-life (faster mean reversion = better)
|
||||
results.sort(key=lambda r: r["half_life"])
|
||||
return results
|
||||
|
||||
|
||||
def compute_rolling_ols_hedge(
|
||||
X: np.ndarray,
|
||||
Y: np.ndarray,
|
||||
window: int = 60,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Compute rolling OLS hedge ratio βₜ for comparison with Kalman.
|
||||
|
||||
Uses expanding window OLS up to the specified lookback.
|
||||
|
||||
Args:
|
||||
X, Y: Price series.
|
||||
window: Lookback window in periods.
|
||||
|
||||
Returns:
|
||||
1-D array of β values (same length as inputs).
|
||||
"""
|
||||
n = len(X)
|
||||
betas = np.full(n, np.nan)
|
||||
for t in range(window, n):
|
||||
x_win = X[t - window:t]
|
||||
y_win = Y[t - window:t]
|
||||
X_mat = np.column_stack([np.ones(len(x_win)), x_win])
|
||||
try:
|
||||
coeff = np.linalg.lstsq(X_mat, y_win, rcond=None)[0]
|
||||
betas[t] = coeff[1]
|
||||
except np.linalg.LinAlgError:
|
||||
betas[t] = np.nan
|
||||
return betas
|
||||
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Kalman Pairs Trading System — Production Orchestrator.
|
||||
|
||||
Integrates pair discovery, Kalman filtering, signal generation,
|
||||
position management, and risk controls into a single callable system.
|
||||
|
||||
Design:
|
||||
- Stateless between ticks — all state held in KalmanPairsTrader instances.
|
||||
- Multi-pair: manages N independent pairs simultaneously.
|
||||
- Risk overlay: per-pair stop-loss, max position, max drawdown.
|
||||
- Capital allocation: equal-weight or volatility-weighted.
|
||||
- Clean interface compatible with live node + backtester.
|
||||
|
||||
Usage (live):
|
||||
system = KalmanPairsTradingSystem(config)
|
||||
system.initialize(price_data)
|
||||
for tick in price_stream:
|
||||
signals = system.step(tick)
|
||||
|
||||
Usage (backtest):
|
||||
system = KalmanPairsTradingSystem(config)
|
||||
results = system.run_backtest(price_data)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import json
|
||||
import time
|
||||
|
||||
# Internal imports
|
||||
from .kalman_filter import KalmanPairsTrader
|
||||
from .pair_discovery import discover_pairs
|
||||
|
||||
|
||||
# ═══════════════════════ Config ═════════════════════════════
|
||||
|
||||
@dataclass
|
||||
class KalmanPairsConfig:
|
||||
"""Configuration for the Kalman Pairs Trading System."""
|
||||
|
||||
# ── Universe ──
|
||||
tickers: list[str] = field(default_factory=lambda: ["BTC", "ETH"])
|
||||
sector_constraint: Optional[str] = None # e.g., None = any, "BTC" = BTC-only pairs
|
||||
|
||||
# ── Pair Discovery ──
|
||||
min_half_life: float = 1.0
|
||||
max_half_life: float = 20.0
|
||||
max_pairs: int = 5
|
||||
coint_sig_level: float = 0.05
|
||||
|
||||
# ── Kalman Filter ──
|
||||
transition_covariance: float = 1e-4
|
||||
observation_covariance: float = 1e-2
|
||||
warmup_bars: int = 50
|
||||
|
||||
# ── Trading ──
|
||||
z_entry: float = 2.0
|
||||
z_exit: float = 0.5
|
||||
z_stop: float = 4.0
|
||||
trade_size_usd: float = 100.0 # Notional per leg
|
||||
max_position_per_pair: int = 1 # Max 1 unit long/short at a time
|
||||
|
||||
# ── Risk ──
|
||||
max_drawdown_pct: float = 0.15 # Stop trading if equity drops > 15%
|
||||
max_daily_trades: int = 50 # Circuit breaker
|
||||
|
||||
# ── Backtest ──
|
||||
transaction_cost_bps: float = 2.5 # 2.5 bps = 0.025% per leg (taker)
|
||||
initial_capital: float = 10000.0
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: str | Path) -> "KalmanPairsConfig":
|
||||
"""Load from YAML. Falls back to defaults if YAML not available."""
|
||||
import yaml # may not be installed
|
||||
with open(path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
return cls(**data.get("kalman_pairs", data))
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "KalmanPairsConfig":
|
||||
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
|
||||
|
||||
|
||||
# ═══════════════════════ System ═════════════════════════════
|
||||
|
||||
class KalmanPairsTradingSystem:
|
||||
"""
|
||||
Production Kalman Pairs Trading System.
|
||||
|
||||
Manages multiple independent pairs, each with its own Kalman filter,
|
||||
and aggregates signals through a unified risk layer.
|
||||
"""
|
||||
|
||||
def __init__(self, config: KalmanPairsConfig | dict) -> None:
|
||||
if isinstance(config, dict):
|
||||
config = KalmanPairsConfig.from_dict(config)
|
||||
self.config = config
|
||||
|
||||
# Active pair traders
|
||||
self.traders: dict[tuple[str, str], KalmanPairsTrader] = {}
|
||||
self.pair_info: dict[tuple[str, str], dict] = {}
|
||||
|
||||
# Equity tracking
|
||||
self.capital = config.initial_capital
|
||||
self.peak_capital = config.initial_capital
|
||||
self.equity_curve: list[dict] = []
|
||||
self.daily_trades: int = 0
|
||||
self.daily_reset_time: float = time.time()
|
||||
|
||||
# Trade log
|
||||
self.trades: list[dict] = []
|
||||
|
||||
def initialize(self, price_data: dict[str, np.ndarray]) -> list[dict]:
|
||||
"""
|
||||
Discover pairs and initialize Kalman traders.
|
||||
|
||||
Args:
|
||||
price_data: {ticker: np.array of prices}
|
||||
|
||||
Returns:
|
||||
List of discovered pair info dicts.
|
||||
"""
|
||||
pairs = discover_pairs(
|
||||
price_data,
|
||||
sector_constraint=self.config.sector_constraint,
|
||||
min_half_life=self.config.min_half_life,
|
||||
max_half_life=self.config.max_half_life,
|
||||
sig_level=self.config.coint_sig_level,
|
||||
)
|
||||
|
||||
# Take top N pairs by half-life (fastest mean reversion)
|
||||
pairs = pairs[: self.config.max_pairs]
|
||||
|
||||
for p in pairs:
|
||||
key = p["pair"]
|
||||
self.pair_info[key] = p
|
||||
|
||||
trader = KalmanPairsTrader(
|
||||
transition_covariance=self.config.transition_covariance,
|
||||
observation_covariance=self.config.observation_covariance,
|
||||
z_entry=self.config.z_entry,
|
||||
z_exit=self.config.z_exit,
|
||||
z_stop=self.config.z_stop,
|
||||
warmup_bars=self.config.warmup_bars,
|
||||
)
|
||||
self.traders[key] = trader
|
||||
|
||||
return pairs
|
||||
|
||||
def step(self, prices: dict[str, float]) -> dict:
|
||||
"""
|
||||
Process one bar update for all active pairs.
|
||||
|
||||
Args:
|
||||
prices: {ticker: current_price} for this bar.
|
||||
|
||||
Returns:
|
||||
dict with: signals (list), equity, drawdown_pct, positions, alpha, beta
|
||||
"""
|
||||
# Reset daily trade counter
|
||||
now = time.time()
|
||||
if now - self.daily_reset_time > 86400:
|
||||
self.daily_trades = 0
|
||||
self.daily_reset_time = now
|
||||
|
||||
signals = []
|
||||
total_pnl = 0.0
|
||||
|
||||
for key, trader in self.traders.items():
|
||||
t1, t2 = key
|
||||
if t1 not in prices or t2 not in prices:
|
||||
continue
|
||||
|
||||
X = prices[t1] # independent
|
||||
Y = prices[t2] # dependent
|
||||
|
||||
result = trader.step(X, Y)
|
||||
|
||||
if result["signal"] != 0 and self.daily_trades < self.config.max_daily_trades:
|
||||
# Apply risk checks
|
||||
if self._check_risk():
|
||||
signal = {
|
||||
"pair": list(key),
|
||||
"signal": result["signal"],
|
||||
"spread": result["spread"],
|
||||
"z_score": result["z_score"],
|
||||
"alpha": result["alpha"],
|
||||
"beta": result["beta"],
|
||||
"position": result["position"],
|
||||
"trade_size": self.config.trade_size_usd,
|
||||
}
|
||||
signals.append(signal)
|
||||
self.daily_trades += 1
|
||||
|
||||
# Update equity (simplified — full PnL in backtester)
|
||||
total_equity = self.capital + total_pnl
|
||||
self.peak_capital = max(self.peak_capital, total_equity)
|
||||
dd_pct = (self.peak_capital - total_equity) / self.peak_capital if self.peak_capital > 0 else 0.0
|
||||
|
||||
self.equity_curve.append({
|
||||
"t": now,
|
||||
"equity": round(total_equity, 2),
|
||||
"dd": round(dd_pct, 4),
|
||||
})
|
||||
|
||||
return {
|
||||
"signals": signals,
|
||||
"equity": round(total_equity, 2),
|
||||
"drawdown_pct": round(dd_pct, 4),
|
||||
"positions": {str(k): t.position for k, t in self.traders.items()},
|
||||
"alpha": {str(k): t.alpha for k, t in self.traders.items()},
|
||||
"beta": {str(k): t.beta for k, t in self.traders.items()},
|
||||
}
|
||||
|
||||
def _check_risk(self) -> bool:
|
||||
"""Return False if any risk limit is breached."""
|
||||
if self.peak_capital > 0:
|
||||
dd = (self.peak_capital - self.capital) / self.peak_capital
|
||||
if dd > self.config.max_drawdown_pct:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_state(self) -> dict:
|
||||
"""Return current system state for monitoring/dashboard."""
|
||||
return {
|
||||
"capital": round(self.capital, 2),
|
||||
"peak_capital": round(self.peak_capital, 2),
|
||||
"drawdown_pct": round(
|
||||
(self.peak_capital - self.capital) / self.peak_capital * 100
|
||||
if self.peak_capital > 0 else 0, 2
|
||||
),
|
||||
"active_pairs": len(self.traders),
|
||||
"daily_trades": self.daily_trades,
|
||||
"positions": {str(k): t.position for k, t in self.traders.items()},
|
||||
"alpha": {str(k): round(t.alpha, 6) for k, t in self.traders.items()},
|
||||
"beta": {str(k): round(t.beta, 6) for k, t in self.traders.items()},
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Parameter tuning for Kalman Pairs Trader.
|
||||
|
||||
Grid search over transition_covariance (and optionally observation_covariance)
|
||||
to find optimal settings that maximize out-of-sample Sharpe while controlling turnover.
|
||||
|
||||
Design:
|
||||
- Train/validation split (chronological, no look-ahead)
|
||||
- Grid search over log-spaced transition_covariance values
|
||||
- Objective: maximize Sharpe_validation - λ * max_drawdown_penalty
|
||||
- Reports top-N parameter sets with full metrics
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from typing import Optional
|
||||
from .kalman_filter import KalmanPairsTrader
|
||||
from .backtest import backtest_kalman_pairs
|
||||
|
||||
|
||||
def grid_search_transition_cov(
|
||||
X_train: np.ndarray,
|
||||
Y_train: np.ndarray,
|
||||
X_val: np.ndarray,
|
||||
Y_val: np.ndarray,
|
||||
transition_cov_range: tuple[float, float, int] = (1e-6, 1e-1, 20),
|
||||
observation_covariance: float = 1e-2,
|
||||
z_entry: float = 2.0,
|
||||
z_exit: float = 0.5,
|
||||
max_drawdown_penalty: float = 0.5,
|
||||
trade_size_usd: float = 100.0,
|
||||
transaction_cost_bps: float = 2.5,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Grid search optimal transition_covariance.
|
||||
|
||||
Strategy:
|
||||
1. Split data chronologically (train → validation).
|
||||
2. For each Q value, run Kalman backtest on validation set
|
||||
(with no pre-training — Kalman adapts online).
|
||||
3. Score = Sharpe − λ * max_drawdown.
|
||||
4. Return sorted results.
|
||||
|
||||
Args:
|
||||
X_train, Y_train: Training price series (used for initialization only).
|
||||
X_val, Y_val: Validation price series (out-of-sample test).
|
||||
transition_cov_range: (min, max, num_steps) in log space.
|
||||
max_drawdown_penalty: Weight for drawdown penalty in scoring.
|
||||
|
||||
Returns:
|
||||
List of dicts sorted by score (descending), each with:
|
||||
transition_cov, sharpe, sortino, max_drawdown, win_rate, total_trades, score.
|
||||
"""
|
||||
q_min, q_max, n_steps = transition_cov_range
|
||||
q_values = np.logspace(np.log10(q_min), np.log10(q_max), n_steps)
|
||||
|
||||
results = []
|
||||
for q in q_values:
|
||||
trader = KalmanPairsTrader(
|
||||
transition_covariance=float(q),
|
||||
observation_covariance=observation_covariance,
|
||||
z_entry=z_entry,
|
||||
z_exit=z_exit,
|
||||
)
|
||||
|
||||
# Pre-warm on training data (online filtering, no position taking)
|
||||
for x, y in zip(X_train, Y_train):
|
||||
trader.kf.update(float(x), float(y))
|
||||
|
||||
# Backtest on validation
|
||||
bt = backtest_kalman_pairs(
|
||||
X_val, Y_val, trader,
|
||||
trade_size_usd=trade_size_usd,
|
||||
transaction_cost_bps=transaction_cost_bps,
|
||||
)
|
||||
|
||||
score = bt["sharpe"] - max_drawdown_penalty * bt["max_drawdown"]
|
||||
|
||||
results.append({
|
||||
"transition_cov": float(q),
|
||||
"sharpe": bt["sharpe"],
|
||||
"sortino": bt["sortino"],
|
||||
"max_drawdown": bt["max_drawdown"],
|
||||
"win_rate": bt["win_rate"],
|
||||
"total_trades": bt["total_trades"],
|
||||
"pnl_pct": bt["pnl_pct"],
|
||||
"score": round(score, 4),
|
||||
})
|
||||
|
||||
results.sort(key=lambda r: r["score"], reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def find_optimal_params(
|
||||
X: np.ndarray,
|
||||
Y: np.ndarray,
|
||||
train_frac: float = 0.6,
|
||||
**grid_kwargs,
|
||||
) -> dict:
|
||||
"""
|
||||
One-shot: split data, run grid search, return best params.
|
||||
|
||||
Returns:
|
||||
dict with: best_params, all_results, train_size, val_size.
|
||||
"""
|
||||
n = len(X)
|
||||
split = int(n * train_frac)
|
||||
X_train, X_val = X[:split], X[split:]
|
||||
Y_train, Y_val = Y[:split], Y[split:]
|
||||
|
||||
grid = grid_search_transition_cov(X_train, Y_train, X_val, Y_val, **grid_kwargs)
|
||||
|
||||
return {
|
||||
"best_params": {
|
||||
"transition_covariance": grid[0]["transition_cov"] if grid else 1e-4,
|
||||
},
|
||||
"best_score": grid[0]["score"] if grid else 0.0,
|
||||
"all_results": grid,
|
||||
"train_size": len(X_train),
|
||||
"val_size": len(X_val),
|
||||
}
|
||||
Reference in New Issue
Block a user