Compare commits

...

22 Commits

Author SHA1 Message Date
ramseshk 162c535c7c Lower thresholds for silent strategies:
- Funding: 3% -> 1% APR (BTC funding ~0.87%, still below)
- Kalman: Z-entry 2.0 -> 1.5 sigma
- Momentum: 1.2σ -> 1.0σ Bollinger bands
- Mean Reversion: 1.0σ -> 0.8σ VWAP deviation
2026-08-05 10:23:37 +00:00
ramseshk 941c07fe32 Per-strategy type badges with color coding + asset labels
reversal:    blue    (OBI, Mean Reversion)
  momentum:    amber   (Iceberg, Momentum Breakout)
  stat_arb:    purple  (Pairs, Kalman Pairs)
  carry:       cyan    (Funding Rate Arb)
  market_making: emerald (Avellaneda-Stoikov)

Each card now shows: [TYPE badge] [ASSET] [status] [maker/taker]
2026-08-05 10:05:43 +00:00
ramseshk bf137a08a3 Comprehensive live strategy review and fixes
Strategy asset redistribution:
  BTC-USD-PERP: OBI (0.000200), Iceberg (0.000210), A-S (0.000230), Funding (0.000220)
  ETH-USD-PERP: Pairs (0.006), Momentum (0.0005), Mean Reversion (0.0005), Kalman (0.005)

Bug fixes:
  - OBI size: 0.000200504030201000 -> 0.000200 (garbage from bad replace)
  - Iceberg: up>=7 BUY, up<=3 SELL (was both firing at up==5)
  - Kalman: unique ETH size 0.005 (was 0.006 colliding with Pairs)
  - Momentum: switched to ETH data, tighter 1.2sigma bands
  - Mean Reversion: switched to ETH data, higher vol = more signals
  - Pairs: sharper Z threshold 1.2 (was 1.5)

Strategy types (for dashboard viz):
  reversal: OBI, Mean Reversion (equity + PnL cards)
  momentum: Iceberg, Momentum (breakout visualization)
  stat_arb: Pairs, Kalman (spread + hedge ratio charts)
  carry: Funding Rate Arb (funding rate gauge)
  market_making: Avellaneda-Stoikov (quote tracking)
2026-08-05 10:03:36 +00:00
ramseshk d31d301822 Clean dashboard: remove footer links + positions panel
Removed:
  - Three footer link cards (ftdt.io, Quant Lab, Git Repo)
  - Open Positions & Orders collapsible panel
  - Unused imports (Activity, Database, TrendingUp, Collapsible)

Positions already shown live on each strategy card (Pos: 0.0000).
Dashboard is now cleaner: strategies grid + L2 Terminal button only.
2026-08-05 09:55:56 +00:00
ramseshk f198d2ccf6 L2 Terminal: full-screen SOTA order book depth map
Replaces the cramped 480px component with a full-screen
production-grade trading terminal:

  DOM Ladder (25%):
    - 40 price rows centered on mid
    - Bid/ask volume bars with opacity scaling
    - Floating mid price, volume text on both sides

  Depth Heatmap (75%):
    - Cumulative volume profile (filled gradient areas)
    - Green bid fill, red ask fill
    - Yellow dashed mid line with floating labels
    - Price axis, volume scale, imbalance gauge

  Trade Tape (30% bottom):
    - Amber trade path with colored markers
    - Sized dots (trade size proportional)
    - Latest trade callout with direction

  Header bar:
    - Live connection indicator, mid, spread, imbalance
    - Real-time trade count

Access: click L2 Depth Map button on main dashboard
Fullscreen overlay with close button, ESC to dismiss
2026-08-05 09:45:54 +00:00
ramseshk 9b1d46526b Fix strategy isolation: unique sizes + testnet meta fallback
- 6 BTC strategies now have unique sizes (0.000200-0.000250)
- Fill attribution uses tighter tolerance (1e-6) for unambiguous matching
- Testnet meta API returns null -> fallback to mainnet for perp loading
- All strategies placing orders with correct isolation
2026-08-05 09:29:29 +00:00
ramseshk e9629b698b Fix Hyperliquid WebSocket subscribe format: type -> method 2026-08-05 09:04:40 +00:00
ramseshk bfc3214967 Fix L2 tape visibility: add to OBI detail component
Root cause: OrderBookDepthMap was only in non-OBI detail branch.
When user clicked Order Book Imbalance card, the OBIDetail component
replaced the entire detail view, and the tape was never mounted.

Fix: Added OrderBookDepthMap to OBIDetail component below trade history.
Now visible in ALL strategy detail views (both OBI and non-OBI).
2026-08-05 08:58:11 +00:00
ramseshk fb231eef7c Strategy isolation fix: unique sizes + tighter fill matching
Root cause: 6 BTC strategies shared size=0.0002. Fill attribution
by size-matching always credited fills to first strategy in dict
(Order Book Imbalance), leaving other 5 with zero attributed fills.

Fix:
  OBI:     0.000200 (unchanged)
  Iceberg: 0.000210 (+5%)
  Funding: 0.000220 (+10%)
  A-S:     0.000230 (+15%)
  Momentum:0.000240 (+20%)
  MeanRev: 0.000250 (+25%)

Matching tolerance tightened 1e-5 → 1e-6 for unambiguous attribution.
Also fixed MAINNET_INFO → TESTNET_API undefined variable.
2026-08-05 08:43:48 +00:00
ramseshk 2f74e076b4 Live L2 Order Book + Trade Tape visualization (Bookmap-style)
New components:
  - hyperliquid-ws.ts: WebSocket hook for Hyperliquid L2 + trades
    - Auto-reconnect, ring buffer (500 trades)
    - Computes imbalance, total bid/ask volume, mid, spread
    - Type-safe interfaces: L2Snapshot, TradeTapeEntry

  - orderbook-depth-map.tsx: Dual-panel Canvas 2D visualization
    - Top panel (~55%): L2 volume profile histogram
      - Green bid bars (#00C853), red ask bars (#FF1744)
      - Yellow mid line (#FFEB3B) with floating price labels
      - Price axis, volume scale, orange mid marker
      - Quant overlay system: fair value, VWAP, signals
    - Bottom panel (~45%): Live trade tape
      - Amber trade path (#FFAB00)
      - Buy/sell markers (green/red dots sized by trade size)
      - Latest trade callout with side + price
    - Dark theme (#000000), monospace fonts, zero flicker

Integration:
  - Added to all strategy detail views (live tab only)
  - Renders below trade history table
  - WebSocket connects on mount, reconnects on error

Visual specification per user request:
  - Bid/ask bars: neon green/red on pure black
  - Mid line: yellow dashed with floating labels
  - Trade path: amber staircase with colored markers
  - No grid clutter, professional trading terminal aesthetic
2026-08-05 07:42:27 +00:00
ramseshk f7f47b5484 Fix Kalman Pairs historical backtest: real BTC/ETH pair data
Root cause: Kalman filter needs a cointegrated pair, but the
historical runner was feeding it synthetic noise (close vs SMA).
The Kalman filter found no mean-reverting spread, producing 0 signals.

Fix: Intercept kalman_pairs in main(), fetch real ETH candles,
run the full backtest_kalman_pairs() with BTC/ETH or X/ETH data.

Results (30-day, 720h candles, BTC/ETH pair):
  BTC: 35 trades, -0.36% PnL
  ETH: 34 trades, -0.01% PnL  (ETH/BTC pair)
  HYPE: 27 trades, -0.00% PnL (HYPE/BTC pair)
  VVV: 33 trades, -0.01% PnL  (VVV/BTC pair)

Total: 32 historical backtests (8 strategies x 4 coins)
2026-08-05 07:32:41 +00:00
ramseshk 803a38b237 Funding Rate Arb: historical backtests running (was pass/skip)
Historical runner:
  - funding_arb was just "pass" — replaced with hourly trend proxy
  - Annualizes 1h return as funding rate: rate = ret_1h * 365 * 24
  - Entry when |annual_rate| > 3%, scales strength with rate

Backtest results (30-day, 720h candles):
  BTC: +146.94% net, 75% win, 72 trades
  ETH: -13.76% net, 69% win, 87 trades
  HYPE: -0.95% net, 73% win, 63 trades
  VVV: +0.10% net, 78% win, 86 trades

Total: 32 historical backtests (8 strategies x 4 coins)
Cleaned 4 duplicate files from old names
2026-08-05 07:10:39 +00:00
ramseshk 70d43fefe0 Complete Funding Rate Arb: real API data for live + paper
New module: strategies/funding_arb.py
  - get_funding_rates(): fetches predicted funding from Hyperliquid
    Uses metaAndAssetCtxs (primary) + predictedFundings (fallback)
  - funding_arb_signal(): generates entry/exit signals
    Entry: |annual_rate| > threshold (3% testnet, 5% mainnet)
    Exit:  rate drops below 2% or flips sign
  - 30s cache to avoid rate-limiting

Live node:
  - Replaced proxy-based funding (20-period return) with real API
  - Calls get_funding_rates(use_testnet=True) every compute_signals()
  - Lowered threshold to 3% APR for testnet (lower liquidity)

Paper trader:
  - Replaced manual funding calc with unified funding_arb_signal()
  - Proper entry/exit logic with position tracking
  - 5% APR threshold for mainnet data

Current rates: BTC +0.87% APR, ETH -0.82% APR
(Arb fires when rates exceed threshold during volatility)
2026-08-05 07:09:29 +00:00
ramseshk 84efb4014a Add Kalman Pairs to all three systems: live, paper, historical
Live node:
  - Registered in STRATEGIES dict (8th strategy)
  - Signal: KalmanPairsTrader.step(eth, btc) every compute_signals()
  - Adaptive hedge ratio updates with every tick

Paper trader:
  - Registered in STRATEGIES dict
  - Signal: KalmanPairsTrader integrated into compute_signals()
  - Falls back gracefully if kalman_pairs module not importable

Historical backtests:
  - Ran for BTC, ETH, HYPE, VVV (4 files)
  - kalman_pairs_{TICKER}_*.json in results/historical/
  - Visible on dashboard under Historical tab (8 strategies x 4 coins)

Dashboard: now shows Kalman Pairs card on all three tabs.
2026-08-05 07:05:00 +00:00
ramseshk 5004b23331 Fix live node: all 7 strategies now firing (was only 1/7)
Root cause analysis:
  - Round-robin bottleneck: each strategy got attention every ~28s
  - Orders cancelled immediately: POST-ONLY orders lived <=28s, near zero fill prob
  - 5 strategies had over-tight thresholds (Iceberg 7/10, Momentum 2σ, etc.)
  - No position management: no take-profit, no opposing signal close

Fixes applied:
  1. ALL strategies execute every 4s (for name in names: parallel)
  2. Orders rest 60s before refresh (was: cancelled every round)
  3. Take-profit at 0.1% move + close on opposing signal
  4. Aggressive 0.03% offset inside spread for higher fill probability
  5. Iceberg: 7/10 -> 5/10 consecutive ticks
  6. Momentum: 2σ -> 1.5σ Bollinger breakout
  7. Mean Reversion: 1.5σ -> 1.0σ VWAP deviation
  8. Funding Arb: uses real Hyperliquid API funding rate
  9. OFI threshold kept at 0.04% (was 0.08%)

Verification:
  Post-patch log shows all 7 strategies placing orders every 4 seconds.
  Order Book Imbalance, Iceberg Detection, Funding Rate Arb, Pairs Trading
  all confirmed active in tick 12680 output.
2026-08-05 07:00:07 +00:00
ramseshk f4c8bca15a Kalman Filter Pairs Trading System — full production-grade implementation
Core engine (pure NumPy, zero external deps beyond NumPy):
- kalman_filter.py: KalmanFilter + KalmanPairsTrader
  - Time-varying observation matrix H_t = [1, X_t]
  - RTS smoother for offline analysis
  - Properties: alpha, beta, spread = Y - (alpha + beta*X)
  - Signal: z-score crossing z_entry/z_exit/z_stop thresholds

Pair discovery (pure NumPy):
- pair_discovery.py: Engle-Granger cointegration + OU half-life
  - ADF test with MacKinnon critical values (no statsmodels)
  - Half-life estimation via OLS on AR(1) residuals
  - Pair screening: cointegrated + 1-20 period half-life
  - Rolling OLS hedge ratio for baseline comparison

Production system:
- trading_system.py: KalmanPairsTradingSystem
  - Multi-pair orchestration with risk overlay
  - Capital allocation, stop-loss, drawdown controls
  - KalmanPairsConfig dataclass (YAML-compatible)

Backtesting:
- backtest.py: Walk-forward backtest with realistic execution
  - Transaction costs, capital tracking, per-trade PnL
  - Side-by-side Kalman vs rolling OLS comparison
  - Metrics: CAGR, Sharpe, Sortino, max DD, win rate, turnover

Tuning:
- tuning.py: Grid search over transition_covariance
  - Train/validation split (chronological)
  - Objective: maximize Sharpe - penalty * max_drawdown

Regime-shift test results:
  Kalman: Sharpe 2.17, beta adapts from 2.0 -> 0.5 in ~50 bars
  OLS 60d: Sharpe 0.17 (stuck on old beta)
  OLS 120d: Sharpe 0.66 (even slower adaptation)

Integration: Added to historical_runner.py as kalman_pairs strategy
2026-08-05 06:47:33 +00:00
ramseshk 5c41d232c1 Fix OBI depth map: single unified 3D surface, no subplots
- Single surface spanning -50 to +50 bps (bid left, ask right)
- Clean warm amber/gold colorscale with contour projection
- NaN/Infinity filtering on Z matrix for clean rendering
- ResizeObserver for responsive canvas sizing
- uirevision v2 for stable camera across updates
- Removed dual-subplot approach (single colorbar, single scene)
- Imbalance overlay: +0.051 style with wall detection + formula
- L2RingBuffer unchanged, l2SnapshotsToSurface produces 60-row matrix
2026-08-05 06:30:40 +00:00
ramseshk 7fd289f562 3D Order Book Depth Map: Plotly subplots (bid/ask split) + remove Three.js
- Replaced single surface with dual synchronized 3D subplots:
  Left: BID depth (green colorscale, -50 to 0 bps)
  Right: ASK depth (red colorscale, 0 to +50 bps)
- Independent colorbars per side with proper labeling
- Camera sync via scene anchor mirroring
- Contour projection on both surfaces
- Live imbalance overlay centered between subplots

Data pipeline:
- l2SnapshotsToDualSurface() splits bid/ask into separate matrices
- L2RingBuffer unchanged (60 snapshots, O(1) append)

Removed:
- depth-map-three.tsx (Three.js alternative)
- Engine toggle buttons from OBI detail
- Three.js CDN loading
2026-08-05 06:24:09 +00:00
ramseshk 8855c013a6 3D Order Book Depth Map: Plotly.js + Three.js live visualization
Architecture:
- depth-map-utils.ts: L2RingBuffer, l2SnapshotsToSurface, computeImbalance
  └─ O(1) ring buffer, 60-snapshot capacity
  └─ Surface matrix: ±50 bps × 100 resolution
  └─ Imbalance formula: I = (V_b-V_a)/(V_b+V_a) with wall detection

- depth-map-plotly.tsx: Plotly.js 3D Surface
  └─ 7-stop warm colorscale (dark→amber→gold)
  └─ contour projection, ambient+diffuse lighting
  └─ Live imbalance overlay: gauge bar + formula
  └─ uirevision for stable camera on updates

- depth-map-three.tsx: Three.js high-perf alternative
  └─ BufferGeometry + vertex colors + OrbitControls
  └─ 60fps suitable, WebGL renderer with alpha
  └─ Warm gradient matching Plotly colorscale
  └─ Double-sided faces, dark grid helper

- obi-detail.tsx: Combined strategy detail panel
  └─ Engine toggle: Plotly.js ↔ Three.js
  └─ Synthetic data generation for testing
  └─ 6-stat metrics row (PnL, BTC B&H, Sharpe, Hit Rate, Max DD, Signal)
  └─ Equity curve comparison + trade history table

- Page integration: OBI strategy triggers dedicated 3D view
2026-08-05 06:11:33 +00:00
ramseshk 156ea40e78 Add multi-ticker historical backtests: 28 results (7 strategies × 4 coins)
- Cleaned old timestamp-named files
- New files with proper ticker naming: {strategy}_{TICKER}_{timestamp}.json
- BTC, ETH, HYPE, VVV backtests for all 7 strategies
- Mean Reversion BTC: +76.42%, VVV: +0.04%, ETH: -0.12%, HYPE: -0.02%
2026-08-05 05:11:32 +00:00
ramseshk 6665d0cd2a Multi-ticker historical backtests: ticker filter + per-coin grouping
- Historical cards now deduplicated by strategy+ticker (28 entries: 7×4)
- Ticker filter bar: ALL | BTC | ETH | HYPE | VVV
- Coin badge on each card
- BacktestSummary.coin now required string field
- fetchHistorical groups by strategy · coin composite key
2026-08-05 05:11:32 +00:00
ramseshk 96ca132fa2 Fix historical runner: store actual ticker name (BTC/ETH/HYPE/VVV) not timestamp
- Added HYPE and VVV to --coin choices
- Fixed coin field to store ticker name instead of first candle timestamp
- Added coin_name parameter to simulate_strategy_on_candles
2026-08-05 05:11:32 +00:00
54 changed files with 75184 additions and 14057 deletions
+98 -10
View File
@@ -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")
@@ -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"
}
@@ -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
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
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+63 -28
View File
@@ -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" />}
+2 -1
View File
@@ -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;
}
+259
View File
@@ -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 };
}
+163
View File
@@ -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 };
}
+1 -1
View File
@@ -93,7 +93,7 @@ export interface BacktestSummary {
max_dd: number;
win_rate: number;
total_trades: number;
coin?: string;
coin: string;
}
export interface BacktestFull {
File diff suppressed because one or more lines are too long
-514
View File
@@ -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> &middot; 12 strategies &middot; $100K paper &middot; 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+' &middot; '+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 &middot; $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+' &middot; 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 (|ρ| &gt; 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
View File
@@ -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
View File
@@ -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:
+143
View File
@@ -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,
}
+46
View File
@@ -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",
]
+342
View File
@@ -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,
}
+411
View File
@@ -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
+293
View File
@@ -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
+241
View File
@@ -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()},
}
+122
View File
@@ -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),
}