Compare commits
58 Commits
ac1b33a014
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 879372f69e | |||
| 9cf871be46 | |||
| 6934bfdaa0 | |||
| 39545ac94b | |||
| f5ffe4baee | |||
| 8461ed5097 | |||
| 2429394cd8 | |||
| a6905f2691 | |||
| 37b8496dc2 | |||
| 74113ab624 | |||
| f9bed72b1c | |||
| a5de7d526f | |||
| 08a95e8fe2 | |||
| 50f8f4f970 | |||
| 392bde44a0 | |||
| 6fcf5e7c7d | |||
| cbbd0ef941 | |||
| ff3e68855c | |||
| 3cc68cd46a | |||
| b0eaee47db | |||
| cf376f2995 | |||
| a8ed3cafe0 | |||
| 298b9c8020 | |||
| e5a81132ef | |||
| 2176910fab | |||
| c98681c130 | |||
| 6a39125fee | |||
| 6552511978 | |||
| 98ee58dfaa | |||
| 8cb59239c6 | |||
| 232d2dae10 | |||
| 79870925f7 | |||
| 0e08543823 | |||
| 03ebe9e795 | |||
| 0b8943c926 | |||
| 162c535c7c | |||
| 941c07fe32 | |||
| bf137a08a3 | |||
| d31d301822 | |||
| f198d2ccf6 | |||
| 9b1d46526b | |||
| e9629b698b | |||
| bfc3214967 | |||
| fb231eef7c | |||
| 2f74e076b4 | |||
| f7f47b5484 | |||
| 803a38b237 | |||
| 70d43fefe0 | |||
| 84efb4014a | |||
| 5004b23331 | |||
| f4c8bca15a | |||
| 5c41d232c1 | |||
| 7fd289f562 | |||
| 8855c013a6 | |||
| 156ea40e78 | |||
| 6665d0cd2a | |||
| 96ca132fa2 | |||
| a09954017e |
+23
-20
@@ -1,28 +1,31 @@
|
|||||||
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
dist/
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
|
||||||
|
# Next.js / Dashboard
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment
|
||||||
.env
|
.env
|
||||||
*.pem
|
*.env.local
|
||||||
*_pk
|
|
||||||
data/
|
# IDE
|
||||||
*.parquet
|
|
||||||
.ipynb_checkpoints/
|
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
# Next.js build output (deployed to static dir at runtime, not tracked)
|
# Runtime artifacts
|
||||||
dashboard/static/_next/
|
/tmp/
|
||||||
dashboard/static/404.html
|
*.log
|
||||||
dashboard/static/404/
|
metrics.json
|
||||||
dashboard/static/__next.*
|
paper_metrics.json
|
||||||
dashboard/static/favicon.ico
|
|
||||||
dashboard/static/file.svg
|
# OS
|
||||||
dashboard/static/globe.svg
|
.DS_Store
|
||||||
dashboard/static/index.txt
|
Thumbs.db
|
||||||
dashboard/static/next.svg
|
|
||||||
dashboard/static/vercel.svg
|
|
||||||
dashboard/static/window.svg
|
|
||||||
dashboard/static/_not-found/
|
|
||||||
|
|||||||
@@ -1,59 +1,168 @@
|
|||||||
# FTDT Quant Lab — Quantitative Trading Strategies
|
# FTDT Quant Lab
|
||||||
|
|
||||||
A collection of quantitative trading strategies running on
|
Production multi-strategy quant trading system running on Hyperliquid.
|
||||||
**Hyperliquid Testnet** via **Nautilus Trader**. Built as part of
|
Live testnet node, paper trading simulator, historical backtesting, and real-time dashboard.
|
||||||
my professional portfolio to demonstrate algorithmic trading,
|
|
||||||
market microstructure, and risk management skills.
|
|
||||||
|
|
||||||
## What's inside
|
**Live:** https://ftdt.io/cv
|
||||||
|
|
||||||
Five strategies, from simple to advanced:
|
---
|
||||||
|
|
||||||
| # | Strategy | Concept |
|
## Stack
|
||||||
|---|----------|---------|
|
|
||||||
| 1 | Order Book Imbalance | Trades on L2 bid/ask pressure |
|
|
||||||
| 2 | Iceberg / TWAP Detection | Follows whale accumulation patterns |
|
|
||||||
| 3 | Funding Rate Arbitrage | Delta-neutral carry trade |
|
|
||||||
| 4 | Pairs Trading (BTC/ETH) | Cointegration-based stat arb |
|
|
||||||
| 5 | Avellaneda-Stoikov Market Making | Stochastic optimal control |
|
|
||||||
|
|
||||||
All strategies share a common risk manager and portfolio tracker.
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| **Runtime** | Python 3.13 (async trading) |
|
||||||
|
| **API Client** | nautilus_trader (Hyperliquid SDK, Rust bindings) |
|
||||||
|
| **Dashboard** | Next.js 16 (static export) + shadcn/ui + Framer Motion |
|
||||||
|
| **Design System** | Hallmark Cobalt — Ubuntu font, hairline borders, cool paper palette |
|
||||||
|
| **Reverse Proxy** | Caddy → auto HTTPS |
|
||||||
|
| **WebSocket** | FastAPI (live/paper streaming) |
|
||||||
|
| **Data** | PostgreSQL 17 (`ftdt_quant`), JSON metrics files |
|
||||||
|
| **Backtesting** | Custom dollar-bar engine + numpy |
|
||||||
|
| **Infra** | OVH VPS (4 vCPU, 8GB RAM, Debian 13), 2GB swap |
|
||||||
|
|
||||||
## Quick start
|
~5,300 lines of Python + TypeScript. 67 commits since July 2026.
|
||||||
|
|
||||||
```bash
|
---
|
||||||
# Install dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Set your Hyperliquid testnet key
|
## Repository Structure
|
||||||
export HYPERLIQUID_TESTNET_PK=0x...
|
|
||||||
|
|
||||||
# Run live (testnet only)
|
|
||||||
python live/node.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project layout
|
|
||||||
|
|
||||||
```
|
```
|
||||||
ftdt-quant-lab/
|
ftdt-quant-lab/
|
||||||
├── config/ # Per-strategy YAML configuration
|
├── live/
|
||||||
├── strategies/ # Strategy implementations
|
│ ├── node.py # Live trading node — testnet, 9 strategies
|
||||||
├── common/ # Risk manager, portfolio tracker, metrics
|
│ └── paper_trader.py # Paper trading — mainnet data, 10 strategies
|
||||||
├── backtests/ # Historical backtest runners
|
├── strategies/
|
||||||
├── live/ # Live trading node (Hyperliquid Testnet)
|
│ ├── orderbook_imbalance.py # L2 bid/ask volume skew (OBI)
|
||||||
├── docs/ # Documentation and strategy writeups
|
│ ├── iceberg_detection.py # Whale TWAP accumulation detection
|
||||||
└── notebooks/ # Analysis notebooks
|
│ ├── funding_arb.py # Delta-neutral carry — spot/perp funding
|
||||||
|
│ ├── pairs_trading.py # BTC/ETH ratio Z-score (1.5σ)
|
||||||
|
│ ├── avellaneda_stoikov.py # Dual-sided stochastic control MM
|
||||||
|
│ ├── kalman_pairs/ # Kalman-filter adaptive hedge ratio
|
||||||
|
│ ├── hawkes_ofi.py # Hawkes process order flow
|
||||||
|
│ ├── deep_lob.py # Deep LOB CNN feature extraction
|
||||||
|
│ ├── queue_imbalance.py # Weighted queue dynamics
|
||||||
|
│ ├── hurst_vpin.py # Hurst exponent + VPIN directional
|
||||||
|
│ ├── hurst_vpin_live.py # Lightweight Hurst/VPIN for live tick stream
|
||||||
|
│ └── quant_report.py # QF-Lib style quant analytics
|
||||||
|
├── dashboard/
|
||||||
|
│ ├── server.py # FastAPI backend — WS, REST, static files
|
||||||
|
│ └── next/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── app/ # Main page + layout
|
||||||
|
│ ├── components/ # QuantReport, StrategyCard, L2Terminal
|
||||||
|
│ └── lib/ # Types, API client
|
||||||
|
├── backtests/
|
||||||
|
│ ├── run.py # Backtest runner
|
||||||
|
│ └── results/
|
||||||
|
│ └── historical/ # JSON backtest snapshots (32 entries)
|
||||||
|
├── common/ # Shared utilities
|
||||||
|
│ ├── risk.py, risk_manager.py
|
||||||
|
│ ├── hyperliquid_api.py
|
||||||
|
│ └── portfolio.py, metrics.py
|
||||||
|
├── config/
|
||||||
|
│ └── fee_tiers.py # Perp/spot fee schedules
|
||||||
|
└── infrastructure/
|
||||||
|
├── Caddyfile # Reverse proxy config
|
||||||
|
└── systemd/ # Service units (pending)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Strategy details
|
---
|
||||||
|
|
||||||
See `docs/STRATEGIES.md` for a walkthrough of each strategy.
|
## Strategies — Current State
|
||||||
|
|
||||||
## Risk warning
|
### Live Node (Hyperliquid Testnet — 9 strategies, $100 each)
|
||||||
|
|
||||||
This is **testnet only**. These strategies are educational — they
|
| # | Strategy | Type | Asset | Size | PnL | Trades | Win |
|
||||||
are not financial advice and have no alpha guarantee. Never run
|
|---|----------|------|-------|------|-----|--------|-----|
|
||||||
them on mainnet without thorough backtesting and your own due diligence.
|
| 1 | Order Book Imbalance | reversal | BTC | 0.000200 | $0.00 | 0 | — |
|
||||||
|
| 2 | Iceberg Detection | momentum | BTC | 0.000210 | $0.00 | 2 | 0% |
|
||||||
|
| 3 | Funding Rate Arb | carry | BTC | 0.000220 | $0.00 | 0 | — |
|
||||||
|
| 4 | Pairs Trading | stat_arb | ETH | 0.006000 | **+$0.74** | 9 | 67% |
|
||||||
|
| 5 | Avellaneda-Stoikov | market_making | BTC | 0.000230 | -$1.35 | 32 | 0% |
|
||||||
|
| 6 | Momentum Breakout | momentum | ETH | 0.000500 | $0.00 | 0 | — |
|
||||||
|
| 7 | Mean Reversion | reversal | ETH | 0.000500 | $0.00 | 0 | — |
|
||||||
|
| 8 | Kalman Pairs | stat_arb | ETH | 0.005000 | $0.00 | 0 | — |
|
||||||
|
| 9 | Hurst VPIN | momentum | BTC | 0.000240 | $0.00 | 0 | — |
|
||||||
|
|
||||||
|
**Execution:** GTC POST-ONLY limit orders. Signals every 5 ticks (5s), dual-sided for A-S.
|
||||||
|
**Fee model:** Maker 0.02% (testnet).
|
||||||
|
|
||||||
|
### Paper Trader (Hyperliquid Mainnet data — 10 strategies, $100 each)
|
||||||
|
|
||||||
|
Same set + Queue Imbalance. Real mainnet orderbook + funding data. Fee model: taker 0.05% / maker 0.02%. Trades simulated with 1bps slippage.
|
||||||
|
|
||||||
---
|
---
|
||||||
Built by [Ramses Echikh](https://git.ftdt.io/rams) · Part of my quant trading portfolio
|
|
||||||
|
## Historical Backtests
|
||||||
|
|
||||||
|
32 backtest snapshots across 8 strategies × 4 coins (BTC, ETH, HYPE, VVV).
|
||||||
|
Hurst/VPIN BTC: **46 trades, 96% win rate, +1.10%** on synthetic trending data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Analysis
|
||||||
|
|
||||||
|
### Strategies showing real signal
|
||||||
|
|
||||||
|
| Strategy | Signal | Status |
|
||||||
|
|----------|--------|--------|
|
||||||
|
| **Pairs Trading** | ✅ | +$0.74, 67% win rate — only profitable live strategy |
|
||||||
|
| **Avellaneda-Stoikov** | ⚠️ | 32 trades but losing — spread capture not covering fees |
|
||||||
|
| **Iceberg Detection** | ⚠️ | 2 trades — rare signals, needs threshold tuning |
|
||||||
|
| **Hurst VPIN** | 🔬 | 96% win in backtest, 0 live trades — very selective |
|
||||||
|
| **Mean Reversion** | ⏳ | 0 trades — VWAP deviation not crossing 1.0σ |
|
||||||
|
| **Momentum** | ⏳ | 0 trades — Bollinger 1.2σ too tight for ETH |
|
||||||
|
|
||||||
|
### Recommendation: focus investment here
|
||||||
|
|
||||||
|
1. **Pairs Trading** — `#1 priority`. Only live winner. Extend to more pairs (SOL, ARB, OP). Add Kalman dynamic hedge ratio. This is the clearest path to sustained PnL.
|
||||||
|
|
||||||
|
2. **Hurst/VPIN** — `#2 priority`. Backtest shows strong edge (96% win). Needs real market data (not synthetic) and 3-day candle feed to trigger more signals. The selectivity IS the edge — don't dilute it.
|
||||||
|
|
||||||
|
3. **Avellaneda-Stoikov** — Needs inventory control. 32 trades losing because adverse selection. Add skew-aware quoting (update reserve price based on queue imbalance).
|
||||||
|
|
||||||
|
4. **Iceberg Detection** — Lower detection threshold. Currently requires 7/10 consecutive ticks same direction — too strict.
|
||||||
|
|
||||||
|
5. **Funding Rate Arb** — Real Hyperliquid funding data already plumbed. Test threshold from 3% → 1% APR. Prefunding detection (predict next rate before announcement).
|
||||||
|
|
||||||
|
6. **Backtest engine** — Replace synthetic data with real Hyperliquid candles. Add walk-forward optimization. The `hurst_vpin.py` infrastructure is ready.
|
||||||
|
|
||||||
|
### Skip for now
|
||||||
|
|
||||||
|
- OBI / Mean Reversion / Momentum — 0 trades. Signal thresholds need fundamental redesign, not just tuning.
|
||||||
|
- Cartea-Jaimungal / Gueant MM — academic models, not adapted to crypto microstructure.
|
||||||
|
- DeepLOB / Hawkes OFI — dependency-heavy, no live integration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and deploy
|
||||||
|
git clone https://git.ftdt.io/rams/ftdt-quant-lab.git
|
||||||
|
cd ftdt-quant-lab
|
||||||
|
python3 -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt # (pending — currently manual)
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
python live/node.py & # Trading node
|
||||||
|
python live/paper_trader.py & # Paper simulator
|
||||||
|
python dashboard/server.py --port 9175 # Dashboard backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] Docker Compose for reproducible deployment
|
||||||
|
- [ ] Walk-forward backtest on real Hyperliquid candle data
|
||||||
|
- [ ] Extend Pairs Trading to BTC/SOL, BTC/ARB
|
||||||
|
- [ ] Hurst/VPIN 3-day candle feed → real live signals
|
||||||
|
- [ ] Memory leak proofing — current guard at 512MB RSS
|
||||||
|
- [ ] systemd service unit files for auto-restart
|
||||||
|
- [ ] Grafana + Prometheus monitoring dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Built with Hermes Agent · Hallmark Cobalt · Ubuntu fonts*
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ STRATEGIES = {
|
|||||||
"avellaneda":{"name": "Avellaneda-Stoikov", "size": 0.001, "fee_model": "maker"},
|
"avellaneda":{"name": "Avellaneda-Stoikov", "size": 0.001, "fee_model": "maker"},
|
||||||
"momentum": {"name": "Momentum Breakout", "size": 0.002, "fee_model": "taker"},
|
"momentum": {"name": "Momentum Breakout", "size": 0.002, "fee_model": "taker"},
|
||||||
"mean_rev": {"name": "Mean Reversion", "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(
|
def simulate_strategy_on_candles(
|
||||||
key: str,
|
key: str,
|
||||||
candles: list[dict],
|
candles: list[dict],
|
||||||
|
coin_name: str = "BTC",
|
||||||
allocation: float = 100.0,
|
allocation: float = 100.0,
|
||||||
fee_tier: int = 0,
|
fee_tier: int = 0,
|
||||||
staking_tier: str = "none",
|
staking_tier: str = "none",
|
||||||
@@ -143,9 +145,14 @@ def simulate_strategy_on_candles(
|
|||||||
reason = f"Iceberg: {up_count}/10 upward ticks"
|
reason = f"Iceberg: {up_count}/10 upward ticks"
|
||||||
signal_strength = 1 - up_count / 10
|
signal_strength = 1 - up_count / 10
|
||||||
|
|
||||||
elif key == "funding_arb":
|
elif key == "funding_arb" and len(prices_20) >= 20:
|
||||||
# Funding rate arb: need real funding data — skip for candle-only backtest
|
# Funding Rate Arb: hourly price trend as funding proxy
|
||||||
pass
|
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:
|
elif key == "pairs" and len(prices_20) >= 20:
|
||||||
# Pairs: BTC/ETH ratio Z-score (only works if we have both)
|
# Pairs: BTC/ETH ratio Z-score (only works if we have both)
|
||||||
@@ -205,6 +212,23 @@ def simulate_strategy_on_candles(
|
|||||||
signal = "BUY"
|
signal = "BUY"
|
||||||
reason = f"VWAP: dev={dev:.1f}σ below VWAP ${vwap:.0f}"
|
reason = f"VWAP: dev={dev:.1f}σ below VWAP ${vwap:.0f}"
|
||||||
signal_strength = abs(dev)
|
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 ──
|
# ── Execute signal ──
|
||||||
if signal and signal_strength > 0.15: # minimum strength filter
|
if signal and signal_strength > 0.15: # minimum strength filter
|
||||||
@@ -289,7 +313,7 @@ def simulate_strategy_on_candles(
|
|||||||
return {
|
return {
|
||||||
"strategy": name,
|
"strategy": name,
|
||||||
"strategy_key": key,
|
"strategy_key": key,
|
||||||
"coin": candles[0]["t"] if candles else "unknown",
|
"coin": coin_name, # actual ticker (BTC, ETH, etc.)
|
||||||
"allocation": allocation,
|
"allocation": allocation,
|
||||||
"start_time": curve[0]["t"] if curve else "",
|
"start_time": curve[0]["t"] if curve else "",
|
||||||
"end_time": curve[-1]["t"] if curve else "",
|
"end_time": curve[-1]["t"] if curve else "",
|
||||||
@@ -319,7 +343,7 @@ def simulate_strategy_on_candles(
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
p = argparse.ArgumentParser(description="FTDT Historical Backtest Runner")
|
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("--strategy", "-s", choices=list(STRATEGIES) + ["all"], default="all")
|
||||||
p.add_argument("--fee-tier", type=int, default=0, choices=range(7))
|
p.add_argument("--fee-tier", type=int, default=0, choices=range(7))
|
||||||
p.add_argument("--staking-tier", default="none", choices=list(STAKING_TIERS.keys()))
|
p.add_argument("--staking-tier", default="none", choices=list(STAKING_TIERS.keys()))
|
||||||
@@ -354,11 +378,75 @@ def main():
|
|||||||
cfg = STRATEGIES[key]
|
cfg = STRATEGIES[key]
|
||||||
print(f"\n Running: {cfg['name']} on {a.coin}...")
|
print(f"\n Running: {cfg['name']} on {a.coin}...")
|
||||||
|
|
||||||
result = simulate_strategy_on_candles(
|
# Kalman Pairs: use real BTC/ETH pair data
|
||||||
key, candles,
|
if key == "kalman_pairs":
|
||||||
fee_tier=a.fee_tier,
|
try:
|
||||||
staking_tier=a.staking_tier,
|
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
|
# Save
|
||||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""
|
||||||
|
NautilusTrader event-driven backtest engine for Hyperliquid strategies.
|
||||||
|
|
||||||
|
Sets up a BacktestEngine with Hyperliquid venue, instruments, historical
|
||||||
|
bar data, and registered strategies. Runs event-driven simulation with
|
||||||
|
realistic fill emulation (maker/taker, slippage).
|
||||||
|
|
||||||
|
Slower but more realistic than VectorBT — intended for final validation
|
||||||
|
before paper/live deployment.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from nautilus_trader.backtest.engine import BacktestEngine, BacktestEngineConfig
|
||||||
|
from nautilus_trader.model.data import Bar, BarSpecification, BarType
|
||||||
|
from nautilus_trader.model.enums import AccountType, BarAggregation, OmsType, PriceType
|
||||||
|
from nautilus_trader.model.identifiers import InstrumentId, Venue
|
||||||
|
from nautilus_trader.model.instruments import CryptoPerpetual
|
||||||
|
from nautilus_trader.model.objects import Currency, Money, Price, Quantity
|
||||||
|
|
||||||
|
from framework.data import HyperliquidDataProvider, INTERVAL_TO_SECONDS
|
||||||
|
from framework.instruments import HL_VENUE
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
INTERVAL_TO_AGG = {
|
||||||
|
"1m": (1, BarAggregation.MINUTE),
|
||||||
|
"5m": (5, BarAggregation.MINUTE),
|
||||||
|
"15m": (15, BarAggregation.MINUTE),
|
||||||
|
"30m": (30, BarAggregation.MINUTE),
|
||||||
|
"1h": (1, BarAggregation.HOUR),
|
||||||
|
"4h": (4, BarAggregation.HOUR),
|
||||||
|
"8h": (8, BarAggregation.HOUR),
|
||||||
|
"1d": (1, BarAggregation.DAY),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NTBacktestRunner:
|
||||||
|
"""NautilusTrader backtest engine wrapper for Hyperliquid."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run_backtest(
|
||||||
|
self,
|
||||||
|
strategy: str = "pairs",
|
||||||
|
interval: str = "1h",
|
||||||
|
instruments: dict[str, CryptoPerpetual] | None = None,
|
||||||
|
testnet: bool = False,
|
||||||
|
limit: int = 5000,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Run event-driven backtest with NautilusTrader.
|
||||||
|
|
||||||
|
1. Set up BacktestEngine
|
||||||
|
2. Register Hyperliquid venue + instruments
|
||||||
|
3. Load historical bars from Hyperliquid
|
||||||
|
4. Add strategy and run
|
||||||
|
5. Return metrics
|
||||||
|
"""
|
||||||
|
|
||||||
|
step, agg = INTERVAL_TO_AGG.get(interval, (1, BarAggregation.HOUR))
|
||||||
|
|
||||||
|
config = BacktestEngineConfig()
|
||||||
|
engine = BacktestEngine(config=config)
|
||||||
|
engine.add_venue(
|
||||||
|
venue=HL_VENUE,
|
||||||
|
oms_type=OmsType.NETTING,
|
||||||
|
account_type=AccountType.MARGIN,
|
||||||
|
starting_balances=[Money(10_000.0, Currency.from_str("USD"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add instruments
|
||||||
|
coin = self._get_coin(strategy)
|
||||||
|
inst_for_coin = None
|
||||||
|
if instruments:
|
||||||
|
for name, inst in instruments.items():
|
||||||
|
engine.add_instrument(inst)
|
||||||
|
if name.upper() == coin.upper():
|
||||||
|
inst_for_coin = inst
|
||||||
|
|
||||||
|
if not inst_for_coin and instruments:
|
||||||
|
# Try to find any instrument matching
|
||||||
|
for inst in instruments.values():
|
||||||
|
instr_name = str(inst.id.symbol)
|
||||||
|
if coin.upper() in instr_name.upper():
|
||||||
|
inst_for_coin = inst
|
||||||
|
break
|
||||||
|
|
||||||
|
sz_prec = inst_for_coin.size_precision if inst_for_coin else 5
|
||||||
|
|
||||||
|
# Fetch real candles
|
||||||
|
provider = HyperliquidDataProvider(testnet=testnet)
|
||||||
|
df = provider.fetch_candles(coin, interval=interval, limit=limit)
|
||||||
|
if df.empty:
|
||||||
|
logger.error("No candles for %s", coin)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build bars
|
||||||
|
inst_id = InstrumentId.from_str(f"{coin.upper()}-USD-PERP.HYPERLIQUID")
|
||||||
|
bars = self._df_to_bars(df, inst_id, step, agg, size_precision=sz_prec)
|
||||||
|
|
||||||
|
# Add bars
|
||||||
|
engine.add_data(bars)
|
||||||
|
|
||||||
|
# Add strategy
|
||||||
|
strategy_class = self._resolve_strategy_class(strategy)
|
||||||
|
if strategy_class is None:
|
||||||
|
logger.error("No NT strategy class for %s", strategy)
|
||||||
|
return None
|
||||||
|
|
||||||
|
from framework.config import StrategyConfig
|
||||||
|
cfg = StrategyConfig(
|
||||||
|
name=strategy,
|
||||||
|
instrument=f"{coin}-USD-PERP",
|
||||||
|
asset=coin,
|
||||||
|
allocation=10000.0,
|
||||||
|
order_size=0.001,
|
||||||
|
)
|
||||||
|
nt_strategy = strategy_class(cfg)
|
||||||
|
engine.add_strategy(nt_strategy)
|
||||||
|
|
||||||
|
# Run
|
||||||
|
try:
|
||||||
|
result = engine.run()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Backtest engine error: %s", e)
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract metrics
|
||||||
|
return self._extract_result(result, engine, strategy, interval, len(bars))
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_coin(self, strategy: str) -> str:
|
||||||
|
return {"pairs": "ETH", "hurst_vpin": "BTC", "as_mm": "BTC",
|
||||||
|
"obi": "BTC", "funding_arb": "BTC"}.get(strategy, "BTC")
|
||||||
|
|
||||||
|
def _df_to_bars(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
instrument_id: InstrumentId,
|
||||||
|
step: int,
|
||||||
|
aggregation: BarAggregation,
|
||||||
|
size_precision: int = 5,
|
||||||
|
) -> list[Bar]:
|
||||||
|
spec = BarSpecification(step, aggregation, PriceType.LAST)
|
||||||
|
bar_type = BarType(instrument_id, spec)
|
||||||
|
bars = []
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
ts = int(idx.timestamp() * 1e9)
|
||||||
|
bar = Bar(
|
||||||
|
bar_type=bar_type,
|
||||||
|
open=Price.from_str(str(row["open"])),
|
||||||
|
high=Price.from_str(str(row["high"])),
|
||||||
|
low=Price.from_str(str(row["low"])),
|
||||||
|
close=Price.from_str(str(row["close"])),
|
||||||
|
volume=Quantity.from_str(f'{row["volume"]:.{size_precision}f}'),
|
||||||
|
ts_event=ts,
|
||||||
|
ts_init=ts,
|
||||||
|
)
|
||||||
|
bars.append(bar)
|
||||||
|
return bars
|
||||||
|
|
||||||
|
def _resolve_strategy_class(self, strategy: str):
|
||||||
|
import importlib
|
||||||
|
registry = {
|
||||||
|
"pairs": "strategies.nt.pairs_trading_nt.PairsTradingNT",
|
||||||
|
"hurst_vpin": "strategies.nt.hurst_vpin_nt.HurstVPINNT",
|
||||||
|
"as_mm": "strategies.nt.as_mm_nt.ASMarketMakingNT",
|
||||||
|
}
|
||||||
|
path = registry.get(strategy)
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
module_path, class_name = path.rsplit(".", 1)
|
||||||
|
mod = importlib.import_module(module_path)
|
||||||
|
return getattr(mod, class_name)
|
||||||
|
|
||||||
|
def _extract_result(
|
||||||
|
self,
|
||||||
|
result,
|
||||||
|
engine,
|
||||||
|
strategy: str,
|
||||||
|
interval: str,
|
||||||
|
n_bars: int,
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
pnl = float(sum(
|
||||||
|
a.pnl() for a in result.accounts if hasattr(a, 'pnl')
|
||||||
|
)) if hasattr(result, 'accounts') else 0.0
|
||||||
|
except Exception:
|
||||||
|
pnl = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
equity = result.equity_curve if hasattr(result, 'equity_curve') else None
|
||||||
|
except Exception:
|
||||||
|
equity = None
|
||||||
|
|
||||||
|
equity_vals = []
|
||||||
|
if equity is not None and hasattr(equity, '__iter__'):
|
||||||
|
equity_vals = [float(v) for v in equity] if equity is not None else []
|
||||||
|
|
||||||
|
total_return = (equity_vals[-1] / 10000.0 - 1) * 100 if equity_vals else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"strategy": strategy,
|
||||||
|
"engine": "nautilus_trader",
|
||||||
|
"interval": interval,
|
||||||
|
"n_bars": n_bars,
|
||||||
|
"start_equity": 10000.0,
|
||||||
|
"end_equity": equity_vals[-1] if equity_vals else 10000.0,
|
||||||
|
"total_return_pct": round(total_return, 2),
|
||||||
|
"pnl": round(pnl, 2),
|
||||||
|
"sharpe": self._compute_sharpe(equity_vals),
|
||||||
|
"max_drawdown_pct": round(self._compute_max_dd(equity_vals) * 100, 2),
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _compute_sharpe(self, equity: list[float]) -> float:
|
||||||
|
if len(equity) < 2:
|
||||||
|
return 0.0
|
||||||
|
returns = [(equity[i] - equity[i - 1]) / equity[i - 1] for i in range(1, len(equity))]
|
||||||
|
mean_ret = np.mean(returns) if returns else 0.0
|
||||||
|
std_ret = np.std(returns, ddof=1) if returns else 0.0
|
||||||
|
return (mean_ret / std_ret) * np.sqrt(365 * 24) if std_ret > 0 else 0.0
|
||||||
|
|
||||||
|
def _compute_max_dd(self, equity: list[float]) -> float:
|
||||||
|
if not equity:
|
||||||
|
return 0.0
|
||||||
|
peak = equity[0]
|
||||||
|
worst = 0.0
|
||||||
|
for v in equity:
|
||||||
|
if v > peak:
|
||||||
|
peak = v
|
||||||
|
dd = (peak - v) / peak if peak > 0 else 0.0
|
||||||
|
worst = max(worst, dd)
|
||||||
|
return worst
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2011
-2081
File diff suppressed because it is too large
Load Diff
+1312
-1522
File diff suppressed because it is too large
Load Diff
+95
-95
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"strategy": "Funding Rate Arb",
|
"strategy": "Avellaneda-Stoikov",
|
||||||
"strategy_key": "funding_arb",
|
"strategy_key": "avellaneda",
|
||||||
"coin": 1783234800000,
|
"coin": "HYPE",
|
||||||
"allocation": 100.0,
|
"allocation": 100.0,
|
||||||
"start_time": "2026-07-05T07:00:00",
|
"start_time": "2026-07-06T05:00:00",
|
||||||
"end_time": "2026-08-04T07:00:00",
|
"end_time": "2026-08-05T05:00:00",
|
||||||
"start_equity": 100.0,
|
"start_equity": 100.0,
|
||||||
"end_equity": 100.0,
|
"end_equity": 100.0,
|
||||||
"pnl": 0.0,
|
"pnl": 0.0,
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"fees_total": 0.0,
|
"fees_total": 0.0,
|
||||||
"fee_tier": 0,
|
"fee_tier": 0,
|
||||||
"staking_tier": "none",
|
"staking_tier": "none",
|
||||||
"fee_model": "taker",
|
"fee_model": "maker",
|
||||||
"sharpe": 0.0,
|
"sharpe": 0.0,
|
||||||
"sortino": 0.0,
|
"sortino": 0.0,
|
||||||
"max_dd": 0.0,
|
"max_dd": 0.0,
|
||||||
@@ -22,94 +22,6 @@
|
|||||||
"win_rate": 0.0,
|
"win_rate": 0.0,
|
||||||
"total_trades": 0,
|
"total_trades": 0,
|
||||||
"equity_curve": [
|
"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",
|
"t": "2026-07-06T05:00:00",
|
||||||
"v": 100.0
|
"v": 100.0
|
||||||
@@ -2905,10 +2817,98 @@
|
|||||||
{
|
{
|
||||||
"t": "2026-08-04T07:00:00",
|
"t": "2026-08-04T07:00:00",
|
||||||
"v": 100.0
|
"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": [],
|
"trades": [],
|
||||||
"num_periods": 721,
|
"num_periods": 721,
|
||||||
"data_source": "Hyperliquid Mainnet",
|
"data_source": "Hyperliquid Mainnet",
|
||||||
"generated_at": "2026-08-04T07:28:17.886718"
|
"generated_at": "2026-08-05T05:07:41.026245"
|
||||||
}
|
}
|
||||||
+95
-95
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"strategy": "Funding Rate Arb",
|
"strategy": "Avellaneda-Stoikov",
|
||||||
"strategy_key": "funding_arb",
|
"strategy_key": "avellaneda",
|
||||||
"coin": 1783234800000,
|
"coin": "VVV",
|
||||||
"allocation": 100.0,
|
"allocation": 100.0,
|
||||||
"start_time": "2026-07-05T07:00:00",
|
"start_time": "2026-07-06T05:00:00",
|
||||||
"end_time": "2026-08-04T07:00:00",
|
"end_time": "2026-08-05T05:00:00",
|
||||||
"start_equity": 100.0,
|
"start_equity": 100.0,
|
||||||
"end_equity": 100.0,
|
"end_equity": 100.0,
|
||||||
"pnl": 0.0,
|
"pnl": 0.0,
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"fees_total": 0.0,
|
"fees_total": 0.0,
|
||||||
"fee_tier": 0,
|
"fee_tier": 0,
|
||||||
"staking_tier": "none",
|
"staking_tier": "none",
|
||||||
"fee_model": "taker",
|
"fee_model": "maker",
|
||||||
"sharpe": 0.0,
|
"sharpe": 0.0,
|
||||||
"sortino": 0.0,
|
"sortino": 0.0,
|
||||||
"max_dd": 0.0,
|
"max_dd": 0.0,
|
||||||
@@ -22,94 +22,6 @@
|
|||||||
"win_rate": 0.0,
|
"win_rate": 0.0,
|
||||||
"total_trades": 0,
|
"total_trades": 0,
|
||||||
"equity_curve": [
|
"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",
|
"t": "2026-07-06T05:00:00",
|
||||||
"v": 100.0
|
"v": 100.0
|
||||||
@@ -2905,10 +2817,98 @@
|
|||||||
{
|
{
|
||||||
"t": "2026-08-04T07:00:00",
|
"t": "2026-08-04T07:00:00",
|
||||||
"v": 100.0
|
"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": [],
|
"trades": [],
|
||||||
"num_periods": 721,
|
"num_periods": 721,
|
||||||
"data_source": "Hyperliquid Mainnet",
|
"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 one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"strategy": "Hurst VPIN", "strategy_key": "hurst_vpin", "coin": "ETH", "allocation": 99.9999907962162, "start_time": "2026-08-06T07:09:06.405305", "end_time": "2026-08-06T07:09:06.405333", "start_equity": 100.0, "end_equity": 99.9374907962162, "pnl": -0.06, "pnl_pct": -0.06, "sharpe": 0.35, "sortino": 0.64, "max_dd": 0.0003, "win_rate": 0.0, "total_trades": 1, "trades": [{"time": "2026-08-06T07:03:31.457823", "side": "SELL", "entry_price": 1868.6, "size": 0.00024, "hurst": 0.5751, "vpin": 0.7986, "bar_count": 236, "exit_price": 1856.9195301809586, "pnl": -0.0625}], "equity_curve": [{"t": 0, "v": 100.0}, {"t": 1000, "v": 99.9374907962162}], "signals_generated": 15385, "data_source": "hyperliquid_mainnet"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"strategy": "Hurst VPIN", "strategy_key": "hurst_vpin", "coin": "HYPE", "allocation": 99.999993931191, "start_time": "2026-08-06T07:14:02.976396", "end_time": "2026-08-06T07:14:02.976412", "start_equity": 100.0, "end_equity": 100.092893931191, "pnl": 0.09, "pnl_pct": 0.09, "sharpe": 0.21, "sortino": 0.61, "max_dd": 0, "win_rate": 1.0, "total_trades": 1, "trades": [{"time": "2026-08-06T07:09:16.367204", "side": "SELL", "entry_price": 52.65, "size": 0.00024, "hurst": 0.568, "vpin": 0.853, "bar_count": 532, "exit_price": 53.13908654772063, "pnl": 0.0929}], "equity_curve": [{"t": 0, "v": 100.0}, {"t": 1000, "v": 100.092893931191}], "signals_generated": 13450, "data_source": "hyperliquid_mainnet"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"strategy": "Hurst VPIN", "strategy_key": "hurst_vpin", "coin": "VVV", "allocation": 99.99999936064138, "start_time": "2026-08-06T07:14:11.162510", "end_time": "2026-08-06T07:14:11.162521", "start_equity": 100.0, "end_equity": 99.93239936064138, "pnl": -0.07, "pnl_pct": -0.07, "sharpe": 0.43, "sortino": 0.52, "max_dd": 0.0003, "win_rate": 0.0, "total_trades": 1, "trades": [{"time": "2026-08-06T07:14:03.784899", "side": "SELL", "entry_price": 12.625, "size": 0.00024, "hurst": 0.5848, "vpin": 0.4823, "bar_count": 149, "exit_price": 12.539654192809744, "pnl": -0.0676}], "equity_curve": [{"t": 0, "v": 100.0}, {"t": 1000, "v": 99.93239936064138}], "signals_generated": 485, "data_source": "hyperliquid_mainnet"}
|
||||||
+806
-816
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+955
-1035
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+799
-809
File diff suppressed because it is too large
Load Diff
+996
-966
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+798
-798
File diff suppressed because it is too large
Load Diff
+867
-897
File diff suppressed because it is too large
Load Diff
+932
-882
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+816
-816
File diff suppressed because it is too large
Load Diff
+1138
-1108
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+798
-798
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"strategy": "SPX Mean Reversion", "strategy_key": "spx_mean_reversion", "coin": "SPX", "allocation": 100.0, "start_time": "2026-07-30T07:30:00", "end_time": "2026-08-06T07:30:00", "start_equity": 100.0, "end_equity": 100.14, "pnl": 0.14, "pnl_pct": 0.14, "sharpe": 0.63, "sortino": 1.0, "max_dd": 0.01, "win_rate": 0.7727, "total_trades": 22, "trades": [{"time": "2026-08-06T07:34:45.055488", "side": "BUY", "entry_price": 0.33078, "z_entry": -1.58, "size": 0.01, "exit_price": 0.33383, "pnl": 0.0092, "exit_z": -0.28}, {"time": "2026-08-06T07:34:45.055850", "side": "SELL", "entry_price": 0.33624, "z_entry": 2.24, "size": 0.01, "exit_price": 0.32967, "pnl": 0.0195, "exit_z": -1.78}, {"time": "2026-08-06T07:34:45.055974", "side": "BUY", "entry_price": 0.32918, "z_entry": -1.83, "size": 0.01, "exit_price": 0.32434, "pnl": -0.0147, "exit_z": -0.22}, {"time": "2026-08-06T07:34:45.056641", "side": "BUY", "entry_price": 0.32171, "z_entry": -1.5, "size": 0.01, "exit_price": 0.32165, "pnl": -0.0002, "exit_z": 0.18}, {"time": "2026-08-06T07:34:45.057239", "side": "SELL", "entry_price": 0.32372, "z_entry": 1.84, "size": 0.01, "exit_price": 0.3218, "pnl": 0.0059, "exit_z": 0.19}, {"time": "2026-08-06T07:34:45.057798", "side": "SELL", "entry_price": 0.32404, "z_entry": 2.08, "size": 0.01, "exit_price": 0.32262, "pnl": 0.0044, "exit_z": -0.06}, {"time": "2026-08-06T07:34:45.058058", "side": "SELL", "entry_price": 0.32373, "z_entry": 1.62, "size": 0.01, "exit_price": 0.32046, "pnl": 0.0101, "exit_z": -3.19}, {"time": "2026-08-06T07:34:45.058229", "side": "BUY", "entry_price": 0.31678, "z_entry": -8.2, "size": 0.01, "exit_price": 0.31904, "pnl": 0.0071, "exit_z": -0.06}, {"time": "2026-08-06T07:34:45.058593", "side": "SELL", "entry_price": 0.32421, "z_entry": 2.0, "size": 0.01, "exit_price": 0.32154, "pnl": 0.0082, "exit_z": 0.09}, {"time": "2026-08-06T07:34:45.059004", "side": "BUY", "entry_price": 0.31961, "z_entry": -1.91, "size": 0.01, "exit_price": 0.32139, "pnl": 0.0056, "exit_z": -0.24}, {"time": "2026-08-06T07:34:45.059289", "side": "SELL", "entry_price": 0.32407, "z_entry": 2.02, "size": 0.01, "exit_price": 0.32727, "pnl": -0.0099, "exit_z": 0.04}, {"time": "2026-08-06T07:34:45.059931", "side": "BUY", "entry_price": 0.32388, "z_entry": -1.66, "size": 0.01, "exit_price": 0.32533, "pnl": 0.0045, "exit_z": -0.16}, {"time": "2026-08-06T07:34:45.060187", "side": "SELL", "entry_price": 0.33477, "z_entry": 4.22, "size": 0.01, "exit_price": 0.3297, "pnl": 0.0152, "exit_z": -0.11}, {"time": "2026-08-06T07:34:45.060590", "side": "BUY", "entry_price": 0.32663, "z_entry": -1.55, "size": 0.01, "exit_price": 0.32146, "pnl": -0.0158, "exit_z": 0.27}, {"time": "2026-08-06T07:34:45.061181", "side": "SELL", "entry_price": 0.32413, "z_entry": 2.06, "size": 0.01, "exit_price": 0.32194, "pnl": 0.0068, "exit_z": -0.01}, {"time": "2026-08-06T07:34:45.061481", "side": "SELL", "entry_price": 0.32694, "z_entry": 2.25, "size": 0.01, "exit_price": 0.32449, "pnl": 0.0075, "exit_z": 0.2}, {"time": "2026-08-06T07:34:45.061750", "side": "BUY", "entry_price": 0.32057, "z_entry": -3.02, "size": 0.01, "exit_price": 0.32404, "pnl": 0.0108, "exit_z": -0.18}, {"time": "2026-08-06T07:34:45.061851", "side": "SELL", "entry_price": 0.32752, "z_entry": 2.03, "size": 0.01, "exit_price": 0.32466, "pnl": 0.0087, "exit_z": 0.26}, {"time": "2026-08-06T07:34:45.061952", "side": "SELL", "entry_price": 0.32729, "z_entry": 1.58, "size": 0.01, "exit_price": 0.32752, "pnl": -0.0007, "exit_z": 0.28}, {"time": "2026-08-06T07:34:45.062542", "side": "BUY", "entry_price": 0.32456, "z_entry": -1.89, "size": 0.01, "exit_price": 0.3315, "pnl": 0.0214, "exit_z": 2.35}, {"time": "2026-08-06T07:34:45.062693", "side": "SELL", "entry_price": 0.33111, "z_entry": 2.07, "size": 0.01, "exit_price": 0.3259, "pnl": 0.0158, "exit_z": -2.79}, {"time": "2026-08-06T07:34:45.063074", "side": "BUY", "entry_price": 0.32525, "z_entry": -2.84, "size": 0.01, "exit_price": 0.33141, "pnl": 0.019, "exit_z": -0.06}], "data_source": "TradeXYZ / Hyperliquid SPX"}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
|||||||
|
"""
|
||||||
|
VectorBT backtest runner — fast vectorized backtesting on Hyperliquid candle data.
|
||||||
|
|
||||||
|
Fetches real candles from Hyperliquid, converts to signals, and runs
|
||||||
|
through VectorBT's Portfolio simulator for instant results.
|
||||||
|
|
||||||
|
Supports parameter sweeps, walk-forward optimization, and full metrics.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import vectorbt as vbt
|
||||||
|
|
||||||
|
sys_path = str(Path(__file__).resolve().parent.parent)
|
||||||
|
if sys_path not in __import__("sys").path:
|
||||||
|
__import__("sys").path.insert(0, sys_path)
|
||||||
|
|
||||||
|
from framework.data import HyperliquidDataProvider, INTERVAL_MAP
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RESULTS_DIR = Path(__file__).resolve().parent / "results"
|
||||||
|
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Strategy signal generators
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _generate_signals(strategy: str, data: dict[str, pd.DataFrame]) -> tuple[pd.Series, pd.Series]:
|
||||||
|
"""Generate entry/exit signals for a strategy from candle data.
|
||||||
|
|
||||||
|
Returns (entries, exits) as boolean pandas Series.
|
||||||
|
Each strategy uses the primary coin's close prices.
|
||||||
|
"""
|
||||||
|
main_coin = {"pairs": "ETH", "hurst_vpin": "BTC", "as_mm": "BTC",
|
||||||
|
"obi": "BTC", "funding_arb": "BTC", "momentum": "BTC",
|
||||||
|
"mean_rev": "BTC"}.get(strategy, "BTC")
|
||||||
|
df = data.get(main_coin)
|
||||||
|
if df is None or df.empty:
|
||||||
|
return pd.Series(dtype=bool), pd.Series(dtype=bool)
|
||||||
|
|
||||||
|
close = df["close"]
|
||||||
|
entries = pd.Series(False, index=close.index)
|
||||||
|
exits = pd.Series(False, index=close.index)
|
||||||
|
|
||||||
|
if strategy == "pairs":
|
||||||
|
btc_df = data.get("BTC")
|
||||||
|
if btc_df is not None and not btc_df.empty:
|
||||||
|
ratio = btc_df["close"] / close
|
||||||
|
mu = ratio.rolling(20).mean()
|
||||||
|
std = ratio.rolling(20).std()
|
||||||
|
z = (ratio - mu) / std
|
||||||
|
entries = z < -1.5
|
||||||
|
exits = z.shift(1) >= -0.5
|
||||||
|
|
||||||
|
elif strategy == "hurst_vpin":
|
||||||
|
returns = close.pct_change().dropna()
|
||||||
|
hurst = returns.rolling(64).apply(_hurst_rs_series, raw=False)
|
||||||
|
entries = hurst > 0.55
|
||||||
|
exits = hurst.shift(1) < 0.45
|
||||||
|
|
||||||
|
elif strategy == "as_mm":
|
||||||
|
spread = (df["high"] - df["low"]) / df["close"]
|
||||||
|
vol = close.pct_change().rolling(20).std()
|
||||||
|
favorable = (spread > spread.rolling(100).mean()) & (vol < 0.02)
|
||||||
|
entries = favorable
|
||||||
|
exits = favorable.shift(3)
|
||||||
|
|
||||||
|
elif strategy == "momentum":
|
||||||
|
sma = close.rolling(20).mean()
|
||||||
|
std = close.rolling(20).std()
|
||||||
|
upper = sma + 2 * std
|
||||||
|
lower = sma - 2 * std
|
||||||
|
entries = (close > upper) | (close < lower)
|
||||||
|
exits = (close.shift(1) > sma.shift(1)) & (close < sma)
|
||||||
|
|
||||||
|
elif strategy in ("mean_rev", "obi"):
|
||||||
|
sma = close.rolling(20).mean()
|
||||||
|
std = close.rolling(20).std()
|
||||||
|
entries = (close < sma - 1.0 * std) | (close > sma + 1.0 * std)
|
||||||
|
exits = abs((close - sma) / std) < 0.3
|
||||||
|
|
||||||
|
elif strategy == "funding_arb":
|
||||||
|
entries[:] = False
|
||||||
|
exits[:] = False
|
||||||
|
|
||||||
|
entries.fillna(False, inplace=True)
|
||||||
|
exits.fillna(False, inplace=True)
|
||||||
|
return entries, exits
|
||||||
|
|
||||||
|
|
||||||
|
def _hurst_rs_series(returns_series: pd.Series) -> float:
|
||||||
|
"""Hurst exponent via R/S on a window of log returns."""
|
||||||
|
rets = returns_series.dropna().values
|
||||||
|
if len(rets) < 32:
|
||||||
|
return 0.5
|
||||||
|
n = len(rets)
|
||||||
|
max_lag = min(n // 2, 64)
|
||||||
|
lags = []
|
||||||
|
rs_vals = []
|
||||||
|
for lag in range(4, max_lag):
|
||||||
|
segs = n // lag
|
||||||
|
if segs < 2:
|
||||||
|
continue
|
||||||
|
vals = []
|
||||||
|
for s in range(segs):
|
||||||
|
seg = rets[s * lag:(s + 1) * lag]
|
||||||
|
mean = np.mean(seg)
|
||||||
|
dev = np.cumsum(seg - mean)
|
||||||
|
r = float(np.max(dev) - np.min(dev))
|
||||||
|
sd = float(np.std(seg, ddof=1))
|
||||||
|
if sd > 1e-12:
|
||||||
|
vals.append(r / sd)
|
||||||
|
if vals:
|
||||||
|
lags.append(np.log(lag))
|
||||||
|
rs_vals.append(np.log(np.mean(vals)))
|
||||||
|
if len(lags) < 4:
|
||||||
|
return 0.5
|
||||||
|
slope = float(np.polyfit(lags, rs_vals, 1)[0])
|
||||||
|
return max(0.2, min(0.8, slope))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# VBT Backtest Runner
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class VBTBacktestRunner:
|
||||||
|
"""VectorBT-powered backtesting on Hyperliquid candle data."""
|
||||||
|
|
||||||
|
def __init__(self, fee_rate: float = 0.0005):
|
||||||
|
self._provider = HyperliquidDataProvider()
|
||||||
|
self._fee_rate = fee_rate
|
||||||
|
|
||||||
|
def run_strategy(
|
||||||
|
self,
|
||||||
|
strategy: str = "pairs",
|
||||||
|
interval: str = "1h",
|
||||||
|
testnet: bool = False,
|
||||||
|
limit: int = 5000,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Fetch candles, generate signals, run VBT backtest, return metrics."""
|
||||||
|
coins = self._get_coins(strategy)
|
||||||
|
provider = HyperliquidDataProvider(testnet=testnet)
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
for coin in coins:
|
||||||
|
try:
|
||||||
|
df = provider.fetch_candles(coin, interval=interval, limit=limit)
|
||||||
|
if not df.empty:
|
||||||
|
data[coin] = df
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to fetch %s: %s", coin, e)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
logger.error("No candle data fetched for strategy: %s", strategy)
|
||||||
|
return None
|
||||||
|
|
||||||
|
entries, exits = _generate_signals(strategy, data)
|
||||||
|
|
||||||
|
primary = list(data.values())[0]
|
||||||
|
close = primary["close"]
|
||||||
|
|
||||||
|
# Align indices
|
||||||
|
common_idx = entries.index.intersection(close.index)
|
||||||
|
entries = entries.reindex(common_idx).fillna(False)
|
||||||
|
exits = exits.reindex(common_idx).fillna(False)
|
||||||
|
close = close.reindex(common_idx)
|
||||||
|
|
||||||
|
if entries.sum() == 0:
|
||||||
|
logger.warning("No signals generated for %s", strategy)
|
||||||
|
return self._empty_result(strategy, interval)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pf = vbt.Portfolio.from_signals(
|
||||||
|
close=close,
|
||||||
|
entries=entries,
|
||||||
|
exits=exits,
|
||||||
|
fees=self._fee_rate,
|
||||||
|
slippage=0.001,
|
||||||
|
freq=INTERVAL_MAP.get(interval, "1h"),
|
||||||
|
init_cash=10000.0,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("VBT portfolio error: %s", e)
|
||||||
|
return self._empty_result(strategy, interval)
|
||||||
|
|
||||||
|
stats = pf.stats()
|
||||||
|
result = self._extract_metrics(pf, stats, strategy, interval, len(close))
|
||||||
|
|
||||||
|
# Save equity curve
|
||||||
|
eq_curve = pf.value().dropna()
|
||||||
|
result["equity_curve"] = [
|
||||||
|
{"t": idx.isoformat(), "v": round(float(v), 2)}
|
||||||
|
for idx, v in eq_curve.to_dict().items()
|
||||||
|
]
|
||||||
|
result["total_trades"] = int(pf.trades.count())
|
||||||
|
result["generated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def param_sweep(
|
||||||
|
self,
|
||||||
|
strategy: str = "pairs",
|
||||||
|
param_grid: dict[str, list] | None = None,
|
||||||
|
) -> pd.DataFrame | None:
|
||||||
|
"""Grid search over parameters using VBT."""
|
||||||
|
|
||||||
|
coins = self._get_coins(strategy)
|
||||||
|
data = {}
|
||||||
|
for coin in coins:
|
||||||
|
df = self._provider.fetch_candles(coin, interval="1h", limit=2000)
|
||||||
|
if not df.empty:
|
||||||
|
data[coin] = df
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
primary = list(data.values())[0]
|
||||||
|
close = primary["close"]
|
||||||
|
|
||||||
|
if param_grid is None:
|
||||||
|
param_grid = {
|
||||||
|
"window": [10, 20, 30, 50],
|
||||||
|
"threshold": [1.0, 1.5, 2.0, 2.5],
|
||||||
|
}
|
||||||
|
|
||||||
|
results_rows = []
|
||||||
|
for window in param_grid.get("window", [20]):
|
||||||
|
for threshold in param_grid.get("threshold", [1.5]):
|
||||||
|
entries, exits = _generate_signals_sweep(strategy, data, window, threshold)
|
||||||
|
try:
|
||||||
|
pf = vbt.Portfolio.from_signals(
|
||||||
|
close=close,
|
||||||
|
entries=entries,
|
||||||
|
exits=exits,
|
||||||
|
fees=self._fee_rate,
|
||||||
|
init_cash=10000.0,
|
||||||
|
)
|
||||||
|
stats = pf.stats()
|
||||||
|
results_rows.append({
|
||||||
|
"window": window,
|
||||||
|
"threshold": threshold,
|
||||||
|
"sharpe": stats.get("Sharpe Ratio", 0),
|
||||||
|
"total_return": stats.get("Total Return [%]", 0),
|
||||||
|
"max_drawdown": stats.get("Max Drawdown [%]", 0),
|
||||||
|
"win_rate": stats.get("Win Rate [%]", 0),
|
||||||
|
"trades": int(pf.trades.count()),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return pd.DataFrame(results_rows) if results_rows else None
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_coins(self, strategy: str) -> list[str]:
|
||||||
|
coin_map = {
|
||||||
|
"pairs": ["BTC", "ETH"],
|
||||||
|
"hurst_vpin": ["BTC"],
|
||||||
|
"as_mm": ["BTC"],
|
||||||
|
"obi": ["BTC"],
|
||||||
|
"funding_arb": ["BTC"],
|
||||||
|
"momentum": ["BTC"],
|
||||||
|
"mean_rev": ["BTC"],
|
||||||
|
}
|
||||||
|
return coin_map.get(strategy, ["BTC"])
|
||||||
|
|
||||||
|
def _extract_metrics(self, pf, stats, strategy, interval, n_bars) -> dict:
|
||||||
|
return {
|
||||||
|
"strategy": strategy,
|
||||||
|
"interval": interval,
|
||||||
|
"n_bars": n_bars,
|
||||||
|
"start_equity": 10000.0,
|
||||||
|
"end_equity": round(float(pf.value().iloc[-1]), 2),
|
||||||
|
"total_return_pct": round(float(stats.get("Total Return [%]", 0)), 2),
|
||||||
|
"pnl": round(float(pf.value().iloc[-1]) - 10000, 2),
|
||||||
|
"sharpe": round(float(stats.get("Sharpe Ratio", 0)), 3),
|
||||||
|
"sortino": round(float(stats.get("Sortino Ratio", 0)), 3),
|
||||||
|
"max_drawdown_pct": round(float(stats.get("Max Drawdown [%]", 0)), 2),
|
||||||
|
"win_rate": round(float(stats.get("Win Rate [%]", 0)) / 100, 3),
|
||||||
|
"profit_factor": round(float(stats.get("Profit Factor", 0)), 3),
|
||||||
|
"expectancy": round(float(stats.get("Expectancy", 0)), 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _empty_result(self, strategy: str, interval: str) -> dict:
|
||||||
|
return {
|
||||||
|
"strategy": strategy,
|
||||||
|
"interval": interval,
|
||||||
|
"n_bars": 0,
|
||||||
|
"start_equity": 10000.0,
|
||||||
|
"end_equity": 10000.0,
|
||||||
|
"total_return_pct": 0.0,
|
||||||
|
"pnl": 0.0,
|
||||||
|
"sharpe": 0.0,
|
||||||
|
"sortino": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"total_trades": 0,
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_signals_sweep(
|
||||||
|
strategy: str,
|
||||||
|
data: dict[str, pd.DataFrame],
|
||||||
|
window: int,
|
||||||
|
threshold: float,
|
||||||
|
) -> tuple[pd.Series, pd.Series]:
|
||||||
|
"""Variant of signal generator for parameter sweeps with configurable params."""
|
||||||
|
main_coin = {"pairs": "ETH", "hurst_vpin": "BTC", "as_mm": "BTC"}.get(strategy, "BTC")
|
||||||
|
df = data.get(main_coin)
|
||||||
|
if df is None or df.empty:
|
||||||
|
return pd.Series(dtype=bool), pd.Series(dtype=bool)
|
||||||
|
|
||||||
|
close = df["close"]
|
||||||
|
entries = pd.Series(False, index=close.index)
|
||||||
|
exits = pd.Series(False, index=close.index)
|
||||||
|
|
||||||
|
sma = close.rolling(window).mean()
|
||||||
|
std = close.rolling(window).std()
|
||||||
|
entries = (close < sma - threshold * std) | (close > sma + threshold * std)
|
||||||
|
exits = abs((close - sma) / (std + 1e-10)) < 0.3 * threshold
|
||||||
|
|
||||||
|
entries.fillna(False, inplace=True)
|
||||||
|
exits.fillna(False, inplace=True)
|
||||||
|
return entries, exits
|
||||||
+3
-1
@@ -40,5 +40,7 @@ def max_drawdown(equity: list[float]) -> float:
|
|||||||
def win_rate(trades: list[dict]) -> float:
|
def win_rate(trades: list[dict]) -> float:
|
||||||
if not trades:
|
if not trades:
|
||||||
return 0.0
|
return 0.0
|
||||||
tp = sum(1 for t in trades if (t.get("pnl_net") or t.get("pnl_gross") or t.get("pnl", 0)) > 0)
|
tp = sum(1 for t in trades if (
|
||||||
|
(t.get("pnl_net") or t.get("net_pnl") or t.get("pnl_gross") or t.get("gross_pnl") or t.get("pnl", 0)) > 0
|
||||||
|
))
|
||||||
return tp / len(trades)
|
return tp / len(trades)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
out-www/
|
||||||
|
_next-app-backup/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "export",
|
||||||
|
images: { unoptimized: true },
|
||||||
|
trailingSlash: true,
|
||||||
|
basePath: "/cv",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "ftdt-quant-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.0",
|
||||||
|
"@radix-ui/react-select": "^2.1.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^11.0.0",
|
||||||
|
"lightweight-charts": "^4.2.0",
|
||||||
|
"lucide-react": "^0.454.0",
|
||||||
|
"next": "^16.3.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--font-sans: var(--font-ubuntu), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: var(--font-ubuntu-mono), ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════ Hallmark Cobalt — Light Palette ═══════════ */
|
||||||
|
:root {
|
||||||
|
--radius: 0.25rem;
|
||||||
|
|
||||||
|
/* Engineered cool paper — never pure white */
|
||||||
|
--background: #f8f9fb;
|
||||||
|
--foreground: #1a1c23;
|
||||||
|
|
||||||
|
/* Cards: crisp white with hairline border */
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #1a1c23;
|
||||||
|
|
||||||
|
/* Popovers / overlays */
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #1a1c23;
|
||||||
|
|
||||||
|
/* Primary: electric cobalt signal */
|
||||||
|
--primary: #0ea5e9;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
|
||||||
|
/* Secondary: slate gray */
|
||||||
|
--secondary: #e8eaf0;
|
||||||
|
--secondary-foreground: #4a4f5c;
|
||||||
|
|
||||||
|
/* Muted: subtle backgrounds */
|
||||||
|
--muted: #f1f3f7;
|
||||||
|
--muted-foreground: #6e7381;
|
||||||
|
|
||||||
|
/* Accent: navy blue */
|
||||||
|
--accent: #1e3a5f;
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
|
||||||
|
/* Destructive: coral red */
|
||||||
|
--destructive: #e74c3c;
|
||||||
|
|
||||||
|
/* Borders: engineered hairlines */
|
||||||
|
--border: #e0e4ec;
|
||||||
|
--input: #e0e4ec;
|
||||||
|
--ring: #0ea5e9;
|
||||||
|
|
||||||
|
/* Charts — Hallmark palette */
|
||||||
|
--chart-1: #0ea5e9;
|
||||||
|
--chart-2: #6366f1;
|
||||||
|
--chart-3: #f59e0b;
|
||||||
|
--chart-4: #10b981;
|
||||||
|
--chart-5: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body defaults */
|
||||||
|
body {
|
||||||
|
font-family: var(--font-ubuntu), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Ubuntu, Ubuntu_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const ubuntu = Ubuntu({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["300", "400", "500", "700"],
|
||||||
|
variable: "--font-ubuntu",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ubuntuMono = Ubuntu_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "700"],
|
||||||
|
variable: "--font-ubuntu-mono",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Quant Dashboard",
|
||||||
|
description: "Live testnet, paper mainnet, historical backtests",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={`${ubuntu.variable} ${ubuntuMono.variable} antialiased`}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
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 QuantReport from "@/components/QuantReport";
|
||||||
|
import { useLiveMetrics, usePaperMetrics, fetchHistorical, fetchBacktestDetail, recalcBacktest } from "@/lib/api";
|
||||||
|
import type { Strategy, BacktestSummary, BacktestFull, Trade, Position, Order } from "@/lib/types";
|
||||||
|
|
||||||
|
type Tab = "live" | "paper" | "historical";
|
||||||
|
|
||||||
|
const STRAT_COLORS = ["#22c55e","#3b82f6","#a855f7","#f59e0b","#ef4444","#06b6d4","#ec4899","#84cc16","#6366f1","#14b8a6","#f97316","#8b5cf6"];
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [tab, setTab] = useState<Tab>("live");
|
||||||
|
const { data: liveData, connected: liveConn } = useLiveMetrics();
|
||||||
|
const { data: paperData } = usePaperMetrics();
|
||||||
|
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); }, []);
|
||||||
|
|
||||||
|
const strategies = tab === "live" ? liveData?.strategies ?? {}
|
||||||
|
: tab === "paper" ? paperData?.strategies ?? {}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const handleCardClick = useCallback(async (name: string, t: Tab) => {
|
||||||
|
setDetailName(name);
|
||||||
|
setDetailTab(t);
|
||||||
|
setBtFull(null);
|
||||||
|
setDetailOpen(true);
|
||||||
|
if (t === "historical") {
|
||||||
|
const ht = historical[name];
|
||||||
|
if (ht) {
|
||||||
|
try {
|
||||||
|
const full = await fetchBacktestDetail(ht.name);
|
||||||
|
setBtFull(full);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [historical]);
|
||||||
|
|
||||||
|
const handleFeeRecalc = useCallback(async () => {
|
||||||
|
if (!detailName || detailTab !== "historical") return;
|
||||||
|
const ht = historical[detailName];
|
||||||
|
if (!ht) return;
|
||||||
|
try {
|
||||||
|
const full = await recalcBacktest(ht.name, feeTier, stakingTier);
|
||||||
|
setBtFull(full);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [detailName, detailTab, feeTier, stakingTier, historical]);
|
||||||
|
|
||||||
|
const detailStrat = detailTab === "historical" ? null
|
||||||
|
: (tab === "paper" ? paperData : liveData)?.strategies?.[detailName] ?? null;
|
||||||
|
|
||||||
|
let detailEquity: { t: number; v: number }[] = [];
|
||||||
|
let detailTrades: Trade[] = [];
|
||||||
|
let detailPositions: Position[] = [];
|
||||||
|
let detailOrders: Order[] = [];
|
||||||
|
|
||||||
|
if (detailTab === "live" && liveData) {
|
||||||
|
if (detailName) {
|
||||||
|
detailEquity = (liveData.strategy_equity ?? {})[detailName] ?? liveData.equity_history ?? [];
|
||||||
|
}
|
||||||
|
detailTrades = (liveData.trades ?? []).filter((t) => t.strategy === detailName);
|
||||||
|
detailPositions = (liveData.open_positions ?? []).filter((p) => p.strategy === detailName);
|
||||||
|
detailOrders = liveData.open_orders ?? [];
|
||||||
|
} else if (detailTab === "paper" && paperData) {
|
||||||
|
if (detailName) {
|
||||||
|
detailEquity = (paperData.strategy_equity ?? {})[detailName] ?? paperData.equity_history ?? [];
|
||||||
|
}
|
||||||
|
detailTrades = (paperData.per_strategy_trades ?? {})[detailName] ?? [];
|
||||||
|
detailPositions = (paperData.open_positions ?? []).filter((p) => p.strategy === detailName);
|
||||||
|
detailOrders = paperData.open_orders ?? [];
|
||||||
|
} else if (detailTab === "historical" && btFull) {
|
||||||
|
detailEquity = (btFull.equity_curve ?? []).map((e) => ({
|
||||||
|
t: typeof e.t === "string" ? Math.floor(new Date(e.t).getTime() / 1000) : e.t,
|
||||||
|
v: e.v,
|
||||||
|
}));
|
||||||
|
detailTrades = btFull.trades ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailOpen) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#f8f9fb]">
|
||||||
|
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-xl">
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 max-w-[1440px] mx-auto">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 gap-2" onClick={() => setDetailOpen(false)}>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
<span className="text-xs">Back</span>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-sm font-bold tracking-tight">{detailName}</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{detailStrat && (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline" className="text-[10px]">{detailStrat.type}</Badge>
|
||||||
|
<Badge variant={detailStrat.status === "running" ? "default" : "secondary"} className="text-[10px]">
|
||||||
|
{detailStrat.status?.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{detailStrat.instrument}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{detailTab === "historical" && btFull && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
30d · {btFull.total_trades} trades · {btFull.fee_model ?? "taker"} model
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{detailTab === "historical" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||||
|
<input type="checkbox" checked={feeOn} onChange={(e) => setFeeOn(e.target.checked)} className="rounded" />
|
||||||
|
Fees
|
||||||
|
</label>
|
||||||
|
<select value={feeTier} onChange={(e) => setFeeTier(Number(e.target.value))} className="text-[10px] bg-card border border-border rounded px-2 py-1 text-foreground h-6">
|
||||||
|
{["Tier 0 (0.045/0.015%)","Tier 1 >$5M (0.040/0.012%)","Tier 2 >$25M (0.035/0.008%)","Tier 3 >$100M (0.030/0.004%)","Tier 4 >$500M (0.028/0.000%)","Tier 5 >$2B (0.026/0.000%)","Tier 6 >$7B (0.024/0.000%)"].map((t, i) => <option key={i} value={i}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={stakingTier} onChange={(e) => setStakingTier(e.target.value)} className="text-[10px] bg-card border border-border rounded px-2 py-1 text-foreground h-6">
|
||||||
|
{["No Stake","Wood (×0.95)","Bronze (×0.90)","Silver (×0.85)","Gold (×0.80)","Platinum (×0.70)","Diamond (×0.60)"].map((t, i) => <option key={i} value={["none","wood","bronze","silver","gold","platinum","diamond"][i]}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button size="sm" variant="outline" className="text-[10px] h-6 px-2" onClick={handleFeeRecalc}>
|
||||||
|
Recalc
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{detailTab === "historical" && btFull && (
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed p-4 bg-muted/50 rounded-lg border border-border">
|
||||||
|
{btFull.strategy} — {btFull.num_periods} periods, {btFull.total_trades} trades,
|
||||||
|
total fees ${btFull.fees_total?.toFixed(2)}, model: {btFull.fee_model ?? "taker"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
|
||||||
|
{detailStrat ? (
|
||||||
|
[
|
||||||
|
{ l: "PnL", v: `$${detailStrat.pnl?.toFixed(4)}` },
|
||||||
|
{ l: "PnL%", v: `${detailStrat.pnl_pct >= 0 ? "+" : ""}${detailStrat.pnl_pct?.toFixed(2)}%`, up: detailStrat.pnl_pct >= 0 },
|
||||||
|
{ l: "Trades", v: String(detailStrat.trades_today ?? 0) },
|
||||||
|
{ l: "Win Rate", v: `${Math.round((detailStrat.win_rate ?? 0) * 100)}%` },
|
||||||
|
{ l: "Fees", v: `$${(detailStrat.fee_paid ?? 0).toFixed(4)}`, up: false },
|
||||||
|
{ l: "Position", v: (detailStrat.position ?? 0).toFixed(4) },
|
||||||
|
].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 && l !== "Position" ? "text-red-500" : ""}`}>{v}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : detailTab === "historical" && btFull ? (
|
||||||
|
[
|
||||||
|
{ l: feeOn ? "Net PnL" : "Gross PnL", v: `${(feeOn ? (btFull.pnl_net_pct ?? btFull.pnl_pct ?? 0) : (btFull.pnl_gross_pct ?? btFull.pnl_pct ?? 0)) >= 0 ? "+" : ""}${(feeOn ? (btFull.pnl_net_pct ?? btFull.pnl_pct ?? 0) : (btFull.pnl_gross_pct ?? btFull.pnl_pct ?? 0)).toFixed(2)}%`, up: (feeOn ? (btFull.pnl_net_pct ?? btFull.pnl_pct ?? 0) : (btFull.pnl_gross_pct ?? btFull.pnl_pct ?? 0)) >= 0 },
|
||||||
|
{ l: "Sharpe", v: (btFull.sharpe ?? 0).toFixed(2) },
|
||||||
|
{ l: "Sortino", v: (btFull.sortino ?? 0).toFixed(2) },
|
||||||
|
{ l: "Max DD", v: `${((btFull.max_dd ?? 0) * 100).toFixed(1)}%`, up: false },
|
||||||
|
{ l: "Win Rate", v: `${Math.round((btFull.win_rate ?? 0) * 100)}%` },
|
||||||
|
{ l: "Fees", v: `$${(btFull.fees_total ?? 0).toFixed(2)}`, up: false },
|
||||||
|
].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 className="col-span-6 text-center text-muted-foreground text-xs py-8">Loading...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailEquity.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-border overflow-hidden h-[320px]">
|
||||||
|
<EquityChart data={detailEquity} color={STRAT_COLORS[Object.keys(strategies).indexOf(detailName) % STRAT_COLORS.length] ?? "#22c55e"} height={320} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailTab !== "historical" && (detailPositions.length > 0 || detailOrders.length > 0) && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-3 pb-2 border-b border-border">
|
||||||
|
Open Positions & Orders {detailName ? `for ${detailName}` : ""}
|
||||||
|
</h4>
|
||||||
|
<PositionsPanel positions={detailPositions} orders={detailOrders} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-3 pb-2 border-b border-border">
|
||||||
|
Trade History {detailTrades.length > 0 ? `(${detailTrades.length})` : ""}
|
||||||
|
</h4>
|
||||||
|
{detailTrades.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>
|
||||||
|
{detailTrades.slice(-200).reverse().map((t, i) => {
|
||||||
|
const tp = detailTab === "historical"
|
||||||
|
? (feeOn ? (t.pnl_net ?? t.pnl ?? 0) : (t.pnl_gross ?? t.pnl ?? 0))
|
||||||
|
: (t.pnl ?? 0);
|
||||||
|
return (
|
||||||
|
<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 ${tp >= 0 ? "text-green-500" : "text-red-500"}`}>
|
||||||
|
{tp >= 0 ? "+" : ""}${Math.abs(tp).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-[300px] truncate" title={t.reason}>
|
||||||
|
{t.reason ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-12">No trades recorded yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QF-Lib Quant Report — Hallmark Cobalt inline */}
|
||||||
|
<div className="mt-6 border-t border-[#e0e4ec] pt-4">
|
||||||
|
<QuantReport
|
||||||
|
strategyName={detailName}
|
||||||
|
backtestId={historical[detailName]?.name || `${detailName.replace(/\s+/g, "_").toLowerCase()}.json`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live L2 Order Book + Trade Tape */}
|
||||||
|
{detailTab === "live" && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<OrderBookDepthMap coin="BTC" height={480} topRatio={0.55} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="h-8" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#f8f9fb]">
|
||||||
|
{/* Header — Hallmark Cobalt */}
|
||||||
|
<header className="sticky top-0 z-50 border-b border-[#e0e4ec] bg-[#f8f9fb]/95 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between px-6 h-12 max-w-[1440px] mx-auto">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<span className="text-[11px] font-medium tracking-[0.04em] text-[#1a1c23]" style={{fontFamily:"'Ubuntu Mono', monospace"}}>
|
||||||
|
{tab === "live" ? "Live Testnet" : tab === "paper" ? "Paper Mainnet" : "Historical"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${liveConn ? "bg-[#0ea5e9]" : "bg-[#e5e7eb]"}`} />
|
||||||
|
<span className="text-[9px] text-[#6e7381] font-medium tracking-[0.03em]" style={{fontFamily:"'Ubuntu Mono', monospace"}}>
|
||||||
|
{liveConn ? "CONNECTED" : "OFFLINE"} · {liveData?.status ?? "···"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Tabs — Hallmark Cobalt */}
|
||||||
|
<div className="border-b border-[#e0e4ec] bg-[#f8f9fb]/95 sticky top-12 z-40">
|
||||||
|
<div className="flex max-w-[1440px] mx-auto px-6">
|
||||||
|
{(["live", "paper", "historical"] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
style={{fontFamily:"'Ubuntu', sans-serif"}}
|
||||||
|
className={`relative px-4 py-2.5 text-xs font-medium tracking-[0.02em] transition-colors cursor-pointer
|
||||||
|
${tab === t
|
||||||
|
? "text-[#1a1c23] after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-[#0ea5e9]"
|
||||||
|
: "text-[#6e7381] hover:text-[#1a1c23]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "live" ? "Live" : t === "paper" ? "Paper" : "Historical"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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) => (
|
||||||
|
<motion.div key={`${tab}-${name}`} layout initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: 0.2, delay: i * 0.03 }}>
|
||||||
|
<StrategyCard name={name} strategy={s} tab={tab} onClick={() => handleCardClick(name, tab)} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
{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")}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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,476 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
// ═══════════ Colors ═══════════
|
||||||
|
const BLUE = "#1E5AA8";
|
||||||
|
const BLUE_FILL = "rgba(30,90,168,0.15)";
|
||||||
|
const GRAY = "#888888";
|
||||||
|
const BLACK = "#111111";
|
||||||
|
const GRID = "rgba(0,0,0,0.06)";
|
||||||
|
const BG = "#FFFFFF";
|
||||||
|
|
||||||
|
interface QuantData {
|
||||||
|
meta: { strategyName: string; strategyId: string; generatedAt: string };
|
||||||
|
equityCurve: { date: string; value: number }[];
|
||||||
|
monthlyReturns: { years: number[]; months: string[]; matrix: (number | null)[][] };
|
||||||
|
yearlyReturns: { year: number; return: number }[];
|
||||||
|
meanYearlyReturn: number;
|
||||||
|
monthlyReturnDistribution: { bins: { start: number; end: number; count: number }[]; mean: number };
|
||||||
|
qqPlot: { points: { theoretical: number; observed: number }[] };
|
||||||
|
rollingStats: { windowMonths: number; series: { date: string; rollingReturn: number; rollingVolatility: number }[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
strategyName: string;
|
||||||
|
backtestId: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuantReport({ strategyName, backtestId, className = "" }: Props) {
|
||||||
|
const [data, setData] = useState<QuantData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Canvas refs
|
||||||
|
const equityCanvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
const monthlyCanvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
const yearlyCanvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
const distCanvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
const qqCanvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
const rollingCanvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`/cv/api/quant-report/${backtestId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { setData(d); setLoading(false); })
|
||||||
|
.catch(e => { setError(e.message); setLoading(false); });
|
||||||
|
}, [backtestId]);
|
||||||
|
|
||||||
|
// ═══════ Equity Curve ═══════
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.equityCurve?.length) return;
|
||||||
|
const canvas = equityCanvas.current;
|
||||||
|
if (!canvas) 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 curve = data.equityCurve;
|
||||||
|
const M = { top: 30, bot: 35, left: 45, right: 15 };
|
||||||
|
const pW = W - M.left - M.right, pH = H - M.top - M.bot;
|
||||||
|
const vals = curve.map(c => c.value);
|
||||||
|
const minV = Math.min(...vals) * 0.95;
|
||||||
|
const maxV = Math.max(...vals) * 1.05;
|
||||||
|
const range = maxV - minV || 1;
|
||||||
|
|
||||||
|
const toX = (i: number) => M.left + (i / (curve.length - 1)) * pW;
|
||||||
|
const toY = (v: number) => M.top + pH - ((v - minV) / range) * pH;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
ctx.fillStyle = BLACK; ctx.font = "bold 13px sans-serif";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText("Strategy Performance", 8, 18);
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
ctx.fillStyle = BLUE; ctx.font = "11px sans-serif";
|
||||||
|
ctx.fillText(data.meta.strategyName, 8, M.top + pH + 18);
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
ctx.strokeStyle = GRID; ctx.lineWidth = 0.5;
|
||||||
|
for (let i = 0; i <= 5; i++) {
|
||||||
|
const y = M.top + (i / 5) * pH;
|
||||||
|
ctx.beginPath(); ctx.moveTo(M.left, y); ctx.lineTo(M.left + pW, y); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line
|
||||||
|
ctx.strokeStyle = BLUE; ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < curve.length; i++) {
|
||||||
|
const x = toX(i), y = toY(curve[i].value);
|
||||||
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Y axis labels
|
||||||
|
ctx.fillStyle = GRAY; ctx.font = "9px sans-serif";
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const v = minV + (i / 4) * range;
|
||||||
|
ctx.fillText(v.toFixed(1), M.left - 4, toY(v) + 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X axis: years
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
const years = [...new Set(curve.map(c => c.date.slice(0, 4)))];
|
||||||
|
for (const yr of years.slice(0, 6)) {
|
||||||
|
const pts = curve.filter(c => c.date.startsWith(yr));
|
||||||
|
if (pts.length) {
|
||||||
|
const idx = curve.indexOf(pts[Math.floor(pts.length / 2)]);
|
||||||
|
ctx.fillText(yr, toX(idx), M.top + pH + 14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ═══════ Monthly Returns Heatmap ═══════
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.monthlyReturns?.matrix?.length) return;
|
||||||
|
const canvas = monthlyCanvas.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const W = canvas.clientWidth, H = 340;
|
||||||
|
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 mr = data.monthlyReturns;
|
||||||
|
const M = { top: 25, bot: 5, left: 35, right: 5 };
|
||||||
|
const nRows = mr.years.length, nCols = 12;
|
||||||
|
const cellW = (W - M.left - M.right) / nCols;
|
||||||
|
const cellH = (H - M.top - M.bot) / nRows;
|
||||||
|
|
||||||
|
ctx.fillStyle = BLACK; ctx.font = "bold 12px sans-serif";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText("Monthly Returns", 8, 16);
|
||||||
|
|
||||||
|
// Month headers
|
||||||
|
ctx.font = "9px sans-serif";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
for (let c = 0; c < 12; c++) {
|
||||||
|
ctx.fillText(mr.months[c].slice(0, 3), M.left + c * cellW + cellW / 2, M.top - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heatmap cells
|
||||||
|
const allVals = mr.matrix.flat().filter(v => v !== null) as number[];
|
||||||
|
const maxAbs = Math.max(Math.abs(Math.max(...allVals)), Math.abs(Math.min(...allVals)), 1);
|
||||||
|
|
||||||
|
for (let r = 0; r < nRows; r++) {
|
||||||
|
// Year label
|
||||||
|
ctx.fillStyle = BLACK; ctx.font = "10px sans-serif";
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
ctx.fillText(String(mr.years[r]), M.left - 4, M.top + r * cellH + cellH * 0.65);
|
||||||
|
|
||||||
|
for (let c = 0; c < nCols; c++) {
|
||||||
|
const v = mr.matrix[r][c];
|
||||||
|
const x = M.left + c * cellW, y = M.top + r * cellH;
|
||||||
|
if (v !== null && v !== undefined) {
|
||||||
|
// Color: blue saturation proportional to value
|
||||||
|
const alpha = Math.min(1, Math.abs(v) / maxAbs * 0.9 + 0.1);
|
||||||
|
ctx.fillStyle = `rgba(30,90,168,${alpha})`;
|
||||||
|
ctx.fillRect(x, y, cellW - 1, cellH - 1);
|
||||||
|
// Value text
|
||||||
|
ctx.fillStyle = Math.abs(v) > maxAbs * 0.4 ? "#FFFFFF" : "#111111";
|
||||||
|
ctx.font = "9px sans-serif";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText(v.toFixed(1), x + cellW / 2, y + cellH * 0.65);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ═══════ Yearly Returns Bar Chart ═══════
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.yearlyReturns?.length) return;
|
||||||
|
const canvas = yearlyCanvas.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const W = canvas.clientWidth, H = 340;
|
||||||
|
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 yr = data.yearlyReturns;
|
||||||
|
const M = { top: 25, bot: 5, left: 8, right: 40 };
|
||||||
|
const pH = (H - M.top - M.bot) / yr.length;
|
||||||
|
const minR = Math.min(0, ...yr.map(y => y.return));
|
||||||
|
const maxR = Math.max(...yr.map(y => y.return));
|
||||||
|
const range = Math.max(maxR - minR, 1);
|
||||||
|
const zeroX = M.left + ((-minR) / range) * (W - M.left - M.right);
|
||||||
|
|
||||||
|
ctx.fillStyle = BLACK; ctx.font = "bold 12px sans-serif";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText("Yearly Returns", 8, 16);
|
||||||
|
|
||||||
|
// Mean line
|
||||||
|
ctx.strokeStyle = BLACK; ctx.lineWidth = 0.8;
|
||||||
|
ctx.setLineDash([3, 3]);
|
||||||
|
const meanX = M.left + ((data.meanYearlyReturn - minR) / range) * (W - M.left - M.right);
|
||||||
|
ctx.beginPath(); ctx.moveTo(meanX, M.top); ctx.lineTo(meanX, M.top + yr.length * pH); ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.fillStyle = BLACK; ctx.font = "8px sans-serif";
|
||||||
|
ctx.fillText("Mean", meanX + 2, M.top + 10);
|
||||||
|
|
||||||
|
// Bars
|
||||||
|
for (let i = 0; i < yr.length; i++) {
|
||||||
|
const y = M.top + i * pH;
|
||||||
|
const barW = ((yr[i].return - 0) / range) * (W - M.left - M.right) * (yr[i].return >= 0 ? 1 : -1);
|
||||||
|
const bx = yr[i].return >= 0 ? zeroX : zeroX - Math.abs(barW);
|
||||||
|
ctx.fillStyle = BLUE;
|
||||||
|
ctx.fillRect(bx, y + 2, Math.abs(barW), pH - 4);
|
||||||
|
|
||||||
|
// Year label
|
||||||
|
ctx.fillStyle = BLACK; ctx.font = "10px sans-serif";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText(String(yr[i].year), 8, y + pH * 0.5 + 3);
|
||||||
|
|
||||||
|
// Return label
|
||||||
|
ctx.textAlign = yr[i].return >= 0 ? "left" : "right";
|
||||||
|
const lx = yr[i].return >= 0 ? bx + Math.abs(barW) + 2 : bx - 2;
|
||||||
|
ctx.fillText(`${yr[i].return}%`, lx, y + pH * 0.5 + 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X axis
|
||||||
|
ctx.fillStyle = GRAY; ctx.font = "8px sans-serif";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText("Returns", W / 2, H - 2);
|
||||||
|
ctx.fillText(`${minR}%`, M.left, H - 2);
|
||||||
|
ctx.fillText(`${maxR}%`, M.left + (W - M.left - M.right), H - 2);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ═══════ Distribution Histogram ═══════
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.monthlyReturnDistribution?.bins?.length) return;
|
||||||
|
const canvas = distCanvas.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const W = canvas.clientWidth, H = 280;
|
||||||
|
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 dist = data.monthlyReturnDistribution;
|
||||||
|
const M = { top: 25, bot: 30, left: 35, right: 10 };
|
||||||
|
const pW = W - M.left - M.right, pH = H - M.top - M.bot;
|
||||||
|
const maxCount = Math.max(...dist.bins.map(b => b.count));
|
||||||
|
|
||||||
|
ctx.fillStyle = BLACK; ctx.font = "bold 12px sans-serif";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText("Distribution of Monthly Returns", 8, 16);
|
||||||
|
|
||||||
|
// Mean line
|
||||||
|
const allStarts = dist.bins.map(b => b.start);
|
||||||
|
const allEnds = dist.bins.map(b => b.end);
|
||||||
|
const gMin = Math.min(...allStarts), gMax = Math.max(...allEnds);
|
||||||
|
const gRange = gMax - gMin || 1;
|
||||||
|
const toX = (v: number) => M.left + ((v - gMin) / gRange) * pW;
|
||||||
|
const meanLine = toX(dist.mean);
|
||||||
|
ctx.strokeStyle = BLACK; ctx.lineWidth = 0.8;
|
||||||
|
ctx.setLineDash([3, 3]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(meanLine, M.top); ctx.lineTo(meanLine, M.top + pH); ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Bars
|
||||||
|
for (const bin of dist.bins) {
|
||||||
|
const x = toX(bin.start);
|
||||||
|
const w = toX(bin.end) - toX(bin.start);
|
||||||
|
const h = (bin.count / maxCount) * pH;
|
||||||
|
ctx.fillStyle = bin.count > 0 ? BLUE : "rgba(30,90,168,0.1)";
|
||||||
|
ctx.fillRect(x, M.top + pH - h, Math.max(w - 1, 2), h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axes
|
||||||
|
ctx.fillStyle = GRAY; ctx.font = "8px sans-serif";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText("Returns", M.left + pW / 2, H - 2);
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText("Occurrences", 2, M.top + pH / 2);
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const v = Math.round(i * maxCount / 4);
|
||||||
|
ctx.fillText(String(v), 2, M.top + pH - (i / 4) * pH + 3);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ═══════ QQ Plot ═══════
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.qqPlot?.points?.length) return;
|
||||||
|
const canvas = qqCanvas.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const W = canvas.clientWidth, H = 280;
|
||||||
|
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 pts = data.qqPlot.points;
|
||||||
|
const M = { top: 25, bot: 30, left: 40, right: 10 };
|
||||||
|
const pW = W - M.left - M.right, pH = H - M.top - M.bot;
|
||||||
|
const tVals = pts.map(p => p.theoretical);
|
||||||
|
const oVals = pts.map(p => p.observed);
|
||||||
|
const tMin = -5, tMax = 5, oMin = -5, oMax = 5;
|
||||||
|
|
||||||
|
const toX = (t: number) => M.left + ((t - tMin) / (tMax - tMin)) * pW;
|
||||||
|
const toY = (o: number) => M.top + pH - ((o - oMin) / (oMax - oMin)) * pH;
|
||||||
|
|
||||||
|
ctx.fillStyle = BLACK; ctx.font = "bold 12px sans-serif";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText("Normal Distribution Q-Q", 8, 16);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagonal line
|
||||||
|
ctx.strokeStyle = BLACK; ctx.lineWidth = 0.8;
|
||||||
|
ctx.beginPath(); ctx.moveTo(M.left, M.top + pH); ctx.lineTo(M.left + pW, M.top); ctx.stroke();
|
||||||
|
|
||||||
|
// Points
|
||||||
|
for (const p of pts) {
|
||||||
|
ctx.fillStyle = BLUE;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(toX(p.theoretical), toY(p.observed), 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axes
|
||||||
|
ctx.fillStyle = GRAY; ctx.font = "8px sans-serif";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText("Normal Distribution Quantile", M.left + pW / 2, H - 2);
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText("Observed", M.left + pW + 2, M.top + pH / 2 + 10);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ═══════ Rolling Stats ═══════
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.rollingStats?.series?.length) return;
|
||||||
|
const canvas = rollingCanvas.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const W = canvas.clientWidth, H = 300;
|
||||||
|
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 rs = data.rollingStats;
|
||||||
|
const M = { top: 30, bot: 30, left: 45, right: 15 };
|
||||||
|
const pW = W - M.left - M.right, pH = H - M.top - M.bot;
|
||||||
|
const allVals = rs.series.map(s => s.rollingReturn).concat(rs.series.map(s => s.rollingVolatility));
|
||||||
|
const minV = Math.min(...allVals) * 1.1, maxV = Math.max(...allVals) * 1.1;
|
||||||
|
const range = maxV - minV || 1;
|
||||||
|
const toX = (i: number) => M.left + (i / (rs.series.length - 1)) * pW;
|
||||||
|
const toY = (v: number) => M.top + pH - ((v - minV) / range) * pH;
|
||||||
|
|
||||||
|
ctx.fillStyle = BLACK; ctx.font = "bold 12px sans-serif";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText(`Rolling Statistics [${rs.windowMonths} Months]`, 8, 18);
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
ctx.fillStyle = BLUE; ctx.font = "10px sans-serif";
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
ctx.fillText("Rolling Return", W - 8, 14);
|
||||||
|
ctx.fillStyle = GRAY;
|
||||||
|
ctx.fillText("Rolling Volatility", W - 8, 28);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volatility line (draw first, behind)
|
||||||
|
ctx.strokeStyle = GRAY; ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < rs.series.length; i++) {
|
||||||
|
const x = toX(i), y = toY(rs.series[i].rollingVolatility);
|
||||||
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Return line
|
||||||
|
ctx.strokeStyle = BLUE; ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < rs.series.length; i++) {
|
||||||
|
const x = toX(i), y = toY(rs.series[i].rollingReturn);
|
||||||
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Y axis
|
||||||
|
ctx.fillStyle = GRAY; ctx.font = "8px sans-serif"; ctx.textAlign = "right";
|
||||||
|
for (let i = 0; i <= 3; i++) {
|
||||||
|
const v = Math.round(minV + (i / 3) * range);
|
||||||
|
ctx.fillText(`${v}%`, M.left - 4, toY(v) + 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X axis: years
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
const years = [...new Set(rs.series.map(s => s.date.slice(0, 4)))];
|
||||||
|
for (const yr of years.slice(0, 8)) {
|
||||||
|
const pts = rs.series.filter(s => s.date.startsWith(yr));
|
||||||
|
if (pts.length) {
|
||||||
|
const idx = rs.series.indexOf(pts[Math.floor(pts.length / 2)]);
|
||||||
|
ctx.fillText(yr, toX(idx), M.top + pH + 14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center text-gray-500">Loading quant report...</div>;
|
||||||
|
if (error) return <div className="p-8 text-center text-red-500">Error: {error}</div>;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white text-black p-4 max-w-5xl mx-auto ${className}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-blue-700 flex items-center justify-center">
|
||||||
|
<span className="text-[7px] text-white font-bold">QF</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-500">QF-Lib technology</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">Generated with QF-Lib</p>
|
||||||
|
<h1 className="text-base font-bold mt-1">{data.meta.strategyName}</h1>
|
||||||
|
<p className="text-[10px] text-gray-400">{new Date(data.meta.generatedAt).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-200 mb-4" />
|
||||||
|
|
||||||
|
{/* Row 1: Equity Curve */}
|
||||||
|
<div className="mb-4 border border-gray-100 rounded-sm overflow-hidden">
|
||||||
|
<canvas ref={equityCanvas} className="w-full" style={{ height: 320 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Monthly Returns + Yearly Returns */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div className="border border-gray-100 rounded-sm overflow-hidden">
|
||||||
|
<canvas ref={monthlyCanvas} className="w-full" style={{ height: 340 }} />
|
||||||
|
</div>
|
||||||
|
<div className="border border-gray-100 rounded-sm overflow-hidden">
|
||||||
|
<canvas ref={yearlyCanvas} className="w-full" style={{ height: 340 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Distribution + QQ Plot */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div className="border border-gray-100 rounded-sm overflow-hidden">
|
||||||
|
<canvas ref={distCanvas} className="w-full" style={{ height: 280 }} />
|
||||||
|
</div>
|
||||||
|
<div className="border border-gray-100 rounded-sm overflow-hidden">
|
||||||
|
<canvas ref={qqCanvas} className="w-full" style={{ height: 280 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Rolling Stats */}
|
||||||
|
<div className="border border-gray-100 rounded-sm overflow-hidden mb-2">
|
||||||
|
<canvas ref={rollingCanvas} className="w-full" style={{ height: 300 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="text-right text-[9px] text-gray-400">Page 1 of 2</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,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createChart, ColorType } from "lightweight-charts";
|
||||||
|
|
||||||
|
interface EquityChartProps {
|
||||||
|
data: { t: number; v: number }[];
|
||||||
|
color?: string;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquityChart({ data, color = "#3b82f6", height = 260 }: EquityChartProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chartRef = useRef<ReturnType<typeof createChart> | null>(null);
|
||||||
|
const seriesRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const seriesColor = color.startsWith("#") ? color : "#3b82f6";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || data.length === 0) return;
|
||||||
|
|
||||||
|
const chart = createChart(containerRef.current, {
|
||||||
|
layout: {
|
||||||
|
background: { type: ColorType.Solid, color: "transparent" },
|
||||||
|
textColor: "#b0b7c0",
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
vertLines: { color: "rgba(255,255,255,0.03)" },
|
||||||
|
horzLines: { color: "rgba(255,255,255,0.03)" },
|
||||||
|
},
|
||||||
|
rightPriceScale: { borderColor: "rgba(255,255,255,0.08)" },
|
||||||
|
timeScale: { borderColor: "rgba(255,255,255,0.08)", timeVisible: true },
|
||||||
|
crosshair: { mode: 0 },
|
||||||
|
width: containerRef.current.clientWidth,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const series = chart.addAreaSeries({
|
||||||
|
lineColor: seriesColor,
|
||||||
|
topColor: `${seriesColor}26`,
|
||||||
|
bottomColor: `${seriesColor}05`,
|
||||||
|
lineWidth: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pts = data.map((d) => ({
|
||||||
|
time: d.t as import("lightweight-charts").UTCTimestamp,
|
||||||
|
value: d.v,
|
||||||
|
}));
|
||||||
|
series.setData(pts);
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
|
||||||
|
chartRef.current = chart;
|
||||||
|
seriesRef.current = series;
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (containerRef.current && chartRef.current) {
|
||||||
|
chartRef.current.applyOptions({ width: containerRef.current.clientWidth });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
chart.remove();
|
||||||
|
};
|
||||||
|
}, [data, seriesColor, height]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (seriesRef.current && data.length > 0) {
|
||||||
|
const pts = data.map((d) => ({
|
||||||
|
time: d.t as import("lightweight-charts").UTCTimestamp,
|
||||||
|
value: d.v,
|
||||||
|
}));
|
||||||
|
seriesRef.current.setData(pts);
|
||||||
|
chartRef.current?.timeScale().fitContent();
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} style={{ width: "100%", height }} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import type { Position, Order } from "@/lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
positions: Position[];
|
||||||
|
orders: Order[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PositionsPanel({ positions, orders }: Props) {
|
||||||
|
return (
|
||||||
|
<Card className="p-0 overflow-hidden border-border">
|
||||||
|
{positions.length > 0 && (
|
||||||
|
<div className="p-4 pb-0">
|
||||||
|
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Positions ({positions.length})</p>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-border/50">
|
||||||
|
<TableHead className="text-[9px] h-7">Strategy</TableHead>
|
||||||
|
<TableHead className="text-[9px] h-7">Size</TableHead>
|
||||||
|
<TableHead className="text-[9px] h-7">Entry</TableHead>
|
||||||
|
<TableHead className="text-[9px] h-7 text-right">PnL</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{positions.map((p, i) => (
|
||||||
|
<TableRow key={i} className="border-border/50">
|
||||||
|
<TableCell className="text-[10px] py-1.5">{p.strategy}</TableCell>
|
||||||
|
<TableCell className="text-[10px] py-1.5 font-mono">{p.size}</TableCell>
|
||||||
|
<TableCell className="text-[10px] py-1.5 font-mono">${p.entry_px?.toFixed(1) ?? "—"}</TableCell>
|
||||||
|
<TableCell className={`text-[10px] py-1.5 font-mono text-right ${(p.pnl ?? 0) >= 0 ? "text-green-500" : "text-red-500"}`}>
|
||||||
|
{(p.pnl ?? 0) >= 0 ? "+" : ""}${(p.pnl ?? 0).toFixed(4)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{orders.length > 0 && (
|
||||||
|
<div className="p-4 pb-4">
|
||||||
|
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Orders ({orders.length})</p>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-border/50">
|
||||||
|
<TableHead className="text-[9px] h-7">Coin</TableHead>
|
||||||
|
<TableHead className="text-[9px] h-7">Side</TableHead>
|
||||||
|
<TableHead className="text-[9px] h-7">Size</TableHead>
|
||||||
|
<TableHead className="text-[9px] h-7">Limit</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{orders.map((o, i) => (
|
||||||
|
<TableRow key={i} className="border-border/50">
|
||||||
|
<TableCell className="text-[10px] py-1.5">{o.coin}</TableCell>
|
||||||
|
<TableCell className="text-[10px] py-1.5">
|
||||||
|
<Badge variant="outline" className={`text-[9px] h-4 px-1.5 border-0 ${o.side === "B" ? "bg-green-500/10 text-green-500" : "bg-red-500/10 text-red-500"}`}>
|
||||||
|
{o.side === "B" ? "BUY" : "SELL"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-[10px] py-1.5 font-mono">{o.sz}</TableCell>
|
||||||
|
<TableCell className="text-[10px] py-1.5 font-mono">${o.limitPx ?? "—"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{positions.length === 0 && orders.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-8">No open positions or orders</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { Strategy } from "@/lib/types";
|
||||||
|
import { TrendingUp, TrendingDown } from "lucide-react";
|
||||||
|
|
||||||
|
interface StrategyCardProps {
|
||||||
|
name: string;
|
||||||
|
strategy?: Strategy;
|
||||||
|
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, 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-100 text-blue-700",
|
||||||
|
momentum: "bg-amber-100 text-amber-700",
|
||||||
|
stat_arb: "bg-purple-100 text-purple-700",
|
||||||
|
carry: "bg-cyan-100 text-cyan-700",
|
||||||
|
market_making: "bg-emerald-100 text-emerald-700",
|
||||||
|
};
|
||||||
|
const typeColor = typeColors[strategy.type] || "bg-gray-100 text-gray-600";
|
||||||
|
const assetShort = strategy.instrument?.split("-")[0] || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="p-4 cursor-pointer hover:border-[#0ea5e9]/30 hover:shadow-sm transition-all duration-200 border-[#e0e4ec] bg-white"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium leading-tight text-[#1a1c23]">{name}</p>
|
||||||
|
<div className="flex gap-1 mt-0.5">
|
||||||
|
<span className={`text-[8px] px-1.5 py-px rounded font-medium font-mono ${typeColor}`}>{strategy.type}</span>
|
||||||
|
<span className="text-[9px] text-[#6e7381]">{assetShort}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Badge variant={strategy.status === "running" ? "default" : "secondary"} className="text-[8px] h-4 px-1.5">
|
||||||
|
{strategy.status?.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-[8px] h-4 px-1.5 border-[#e0e4ec]">
|
||||||
|
{strategy.fee_model?.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`text-xl font-mono font-bold mb-2 flex items-center gap-1 ${isUp ? "text-[#10b981]" : "text-[#ef4444]"}`}>
|
||||||
|
{isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||||
|
${equity.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 text-[9px] text-[#6e7381] flex-wrap">
|
||||||
|
<span>PnL: <b className={pnlPctVal >= 0 ? "text-[#10b981]" : "text-[#ef4444]"}>{pnl >= 0 ? "+" : ""}{pnl.toFixed(2)} ({pnlPctVal >= 0 ? "+" : ""}{pnlPctVal.toFixed(2)}%)</b></span>
|
||||||
|
<span>Trades: <b>{strategy.trades_today ?? 0}</b></span>
|
||||||
|
<span>Win: <b>{Math.round((strategy.win_rate ?? 0) * 100)}%</b></span>
|
||||||
|
<span>Pos: <b>{(strategy.position ?? 0).toFixed(4)}</b></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 pt-2 border-t border-[#e0e4ec]/50 text-[9px] text-[#6e7381] leading-relaxed">
|
||||||
|
<b>Alloc:</b> ${strategy.allocation} · <b>Max pos:</b> {strategy.max_position ?? "—"} · <b>Stop:</b> {strategy.stop_loss ?? "—"}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backtest / Historical card
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="p-4 cursor-pointer hover:border-primary/50 hover:shadow-md transition-all duration-200 hover:-translate-y-0.5 border-border"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<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">{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" />}
|
||||||
|
{(pnlPct ?? 0) >= 0 ? "+" : ""}{(pnlPct ?? 0).toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
{stats && (
|
||||||
|
<div className="flex gap-3 text-[9px] text-muted-foreground flex-wrap">
|
||||||
|
{stats.map((s) => (
|
||||||
|
<span key={s.label}>
|
||||||
|
{s.label}: <b className={s.negative ? "text-red-500" : ""}>{s.value}</b>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||||
|
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
||||||
|
outline: "text-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { variant?: keyof typeof badgeVariants }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
badgeVariants[variant],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
variant?: "default" | "ghost" | "outline";
|
||||||
|
size?: "default" | "sm" | "icon";
|
||||||
|
}) {
|
||||||
|
const variants: Record<string, string> = {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
};
|
||||||
|
const sizes: Record<string, string> = {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
variants[variant],
|
||||||
|
sizes[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-[--card-spacing] overflow-hidden rounded-xl bg-card text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="card-header" className={cn("flex flex-col gap-1.5 px-[--card-spacing] pt-[--card-spacing]", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="card-title" className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="card-content" className={cn("px-[--card-spacing]", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="card-footer" className={cn("flex items-center px-[--card-spacing] pb-[--card-spacing]", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface CollapsibleContextType {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
const CollapsibleContext = React.createContext<CollapsibleContextType | null>(null);
|
||||||
|
|
||||||
|
function Collapsible({ open, onOpenChange, className, children, ...props }: React.ComponentProps<"div"> & CollapsibleContextType) {
|
||||||
|
return (
|
||||||
|
<CollapsibleContext.Provider value={{ open, onOpenChange }}>
|
||||||
|
<div data-slot="collapsible" className={cn("flex flex-col gap-2", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({ className, children, ...props }: React.ComponentProps<"button">) {
|
||||||
|
const ctx = React.useContext(CollapsibleContext);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
className={cn("flex items-center gap-2 text-sm font-medium [&[data-state=open]>svg]:rotate-180", className)}
|
||||||
|
onClick={() => ctx?.onOpenChange(!ctx?.open)}
|
||||||
|
data-state={ctx?.open ? "open" : "closed"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({ className, children, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const ctx = React.useContext(CollapsibleContext);
|
||||||
|
if (!ctx?.open) return null;
|
||||||
|
return (
|
||||||
|
<div data-slot="collapsible-content" className={cn("overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down", className)} data-state={ctx.open ? "open" : "closed"} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
function Select({ value, onChange, className, children, ...props }: React.ComponentProps<"select"> & { onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void }) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
data-slot="select"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className={cn(
|
||||||
|
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Select };
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
function Sheet({ open, onOpenChange, children }: { open: boolean; onOpenChange: (open: boolean) => void; children: React.ReactNode }) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm" onClick={() => onOpenChange(false)}>
|
||||||
|
<div className="fixed inset-y-0 right-0 z-50 flex" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({ side = "right", className, children, ...props }: React.ComponentProps<"div"> & { side?: "right" | "left" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background flex h-full flex-col shadow-lg",
|
||||||
|
side === "right" ? "ml-auto" : "mr-auto",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||||
|
return <h2 data-slot="sheet-title" className={cn("text-lg font-semibold", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Sheet, SheetContent, SheetHeader, SheetTitle };
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return <table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return <tr data-slot="table-row" className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return <th data-slot="table-head" className={cn("h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return <td data-slot="table-cell" className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TabsContext = React.createContext<{ value: string; onValueChange: (v: string) => void } | null>(null);
|
||||||
|
|
||||||
|
function Tabs({ value, onValueChange, className, children, ...props }: React.ComponentProps<"div"> & { value: string; onValueChange: (v: string) => void }) {
|
||||||
|
return (
|
||||||
|
<TabsContext.Provider value={{ value, onValueChange }}>
|
||||||
|
<div data-slot="tabs" className={cn("flex flex-col", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TabsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="tabs-list" className={cn("inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ value, className, children, ...props }: React.ComponentProps<"button"> & { value: string }) {
|
||||||
|
const ctx = React.useContext(TabsContext);
|
||||||
|
const active = ctx?.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
data-state={active ? "active" : "inactive"}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={() => ctx?.onValueChange(value)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({ value, className, children, ...props }: React.ComponentProps<"div"> & { value: string }) {
|
||||||
|
const ctx = React.useContext(TabsContext);
|
||||||
|
if (ctx?.value !== value) return null;
|
||||||
|
return (
|
||||||
|
<div data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { LiveMetrics, PaperMetrics } from "./types";
|
||||||
|
|
||||||
|
const API_BASE = "/cv/api";
|
||||||
|
|
||||||
|
export function useLiveMetrics() {
|
||||||
|
const [data, setData] = useState<LiveMetrics | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const WS_BASE = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/cv/ws`;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(WS_BASE);
|
||||||
|
wsRef.current = ws;
|
||||||
|
ws.onopen = () => setConnected(true);
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
timerRef.current = setTimeout(connect, 5000);
|
||||||
|
};
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(e.data) as LiveMetrics;
|
||||||
|
setData(d);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
timerRef.current = setTimeout(connect, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
wsRef.current?.close();
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, connected };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePaperMetrics() {
|
||||||
|
const [data, setData] = useState<PaperMetrics | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const base = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/cv/ws/paper`;
|
||||||
|
const connect = () => {
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(base);
|
||||||
|
wsRef.current = ws;
|
||||||
|
ws.onopen = () => setConnected(true);
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
setTimeout(connect, 5000);
|
||||||
|
};
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(e.data) as PaperMetrics;
|
||||||
|
setData(d);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
setTimeout(connect, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
connect();
|
||||||
|
return () => wsRef.current?.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, connected };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHistorical(): Promise<Record<string, import("./types").BacktestSummary>> {
|
||||||
|
const res = await fetch(`${API_BASE}/backtests/historical`);
|
||||||
|
const list: import("./types").BacktestSummary[] = await res.json();
|
||||||
|
const byStrat: Record<string, import("./types").BacktestSummary> = {};
|
||||||
|
for (const b of list) {
|
||||||
|
const key = `${b.strategy} · ${b.coin ?? "?"}`;
|
||||||
|
if (!byStrat[key]) byStrat[key] = b;
|
||||||
|
}
|
||||||
|
return byStrat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBacktestDetail(name: string): Promise<import("./types").BacktestFull> {
|
||||||
|
const res = await fetch(`${API_BASE}/backtest/historical/${encodeURIComponent(name)}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recalcBacktest(
|
||||||
|
name: string,
|
||||||
|
feeTier: number,
|
||||||
|
stakingTier: string,
|
||||||
|
): Promise<import("./types").BacktestFull> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/backtest/${encodeURIComponent(name)}/recalc?fee_tier=${feeTier}&staking_tier=${stakingTier}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Order Book Depth Map — Data Utilities
|
||||||
|
*
|
||||||
|
* Transforms raw Hyperliquid L2 snapshots into surface matrices
|
||||||
|
* for 3D visualization.
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* Ring buffer stores last N snapshots.
|
||||||
|
* Each snapshot: { bids: [px, sz][], asks: [px, sz][], mid: number, ts: number }
|
||||||
|
* Output: { x: bps[], y: snapshot_index[], z: size[][] }
|
||||||
|
*
|
||||||
|
* Ring-buffer design:
|
||||||
|
* - Fixed capacity (default 60 = ~1 minute at 1s updates)
|
||||||
|
* - O(1) append via write pointer
|
||||||
|
* - No allocations on append → suitable for 60fps streaming
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface L2Level {
|
||||||
|
px: number;
|
||||||
|
sz: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface L2Snapshot {
|
||||||
|
bids: L2Level[]; // sorted descending by price
|
||||||
|
asks: L2Level[]; // sorted ascending by price
|
||||||
|
mid: number;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurfaceData {
|
||||||
|
/** Distance from mid in basis points (X-axis) */
|
||||||
|
x: number[];
|
||||||
|
/** Snapshot index or cumulative bid count (Y-axis) */
|
||||||
|
y: number[];
|
||||||
|
/** Resting size matrix: z[row][col] — rows = snapshots, cols = bps */
|
||||||
|
z: number[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImbalanceMetrics {
|
||||||
|
/** Current imbalance: (V_bid - V_ask) / (V_bid + V_ask) */
|
||||||
|
imbalance: number;
|
||||||
|
bidVolume: number;
|
||||||
|
askVolume: number;
|
||||||
|
wallSide: "bid" | "ask" | "none";
|
||||||
|
wallStrength: number;
|
||||||
|
snapshots: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ring buffer for L2 snapshots.
|
||||||
|
* Fixed capacity, overwrite oldest on overflow.
|
||||||
|
*/
|
||||||
|
export class L2RingBuffer {
|
||||||
|
private buffer: L2Snapshot[];
|
||||||
|
private capacity: number;
|
||||||
|
private writeIdx: number;
|
||||||
|
private count: number;
|
||||||
|
|
||||||
|
constructor(capacity: number = 60) {
|
||||||
|
this.capacity = capacity;
|
||||||
|
this.buffer = new Array(capacity);
|
||||||
|
this.writeIdx = 0;
|
||||||
|
this.count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
push(snapshot: L2Snapshot): void {
|
||||||
|
this.buffer[this.writeIdx] = snapshot;
|
||||||
|
this.writeIdx = (this.writeIdx + 1) % this.capacity;
|
||||||
|
if (this.count < this.capacity) this.count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns snapshots oldest-first */
|
||||||
|
snapshot(): L2Snapshot[] {
|
||||||
|
if (this.count === 0) return [];
|
||||||
|
const start = this.count < this.capacity ? 0 : this.writeIdx;
|
||||||
|
const result: L2Snapshot[] = [];
|
||||||
|
for (let i = 0; i < this.count; i++) {
|
||||||
|
result.push(this.buffer[(start + i) % this.capacity]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.writeIdx = 0;
|
||||||
|
this.count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert L2 snapshots → surface matrix.
|
||||||
|
*
|
||||||
|
* X-axis: distance from mid in basis points
|
||||||
|
* Y-axis: snapshot index (0 = oldest, N = newest)
|
||||||
|
* Z-axis: resting size at that bps level
|
||||||
|
*
|
||||||
|
* @param snapshots Ring buffer contents (oldest first)
|
||||||
|
* @param bpsRange ±bps from mid to cover (default: 50)
|
||||||
|
* @param resolution Number of bps steps (default: 100)
|
||||||
|
*/
|
||||||
|
export function l2SnapshotsToSurface(
|
||||||
|
snapshots: L2Snapshot[],
|
||||||
|
bpsRange: number = 50,
|
||||||
|
resolution: number = 100,
|
||||||
|
): SurfaceData {
|
||||||
|
const bpsStep = (bpsRange * 2) / resolution;
|
||||||
|
const x: number[] = [];
|
||||||
|
for (let i = 0; i < resolution; i++) {
|
||||||
|
x.push(-bpsRange + i * bpsStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
const y = snapshots.map((_, i) => i);
|
||||||
|
const z: number[][] = [];
|
||||||
|
|
||||||
|
for (const snap of snapshots) {
|
||||||
|
const row = new Array(resolution).fill(0);
|
||||||
|
const mid = snap.mid;
|
||||||
|
|
||||||
|
// Fill bid side (negative bps)
|
||||||
|
for (const bid of snap.bids) {
|
||||||
|
const bps = ((bid.px - mid) / mid) * 10000;
|
||||||
|
const idx = Math.round((bps + bpsRange) / bpsStep);
|
||||||
|
if (idx >= 0 && idx < resolution) {
|
||||||
|
row[idx] += bid.sz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill ask side (positive bps)
|
||||||
|
for (const ask of snap.asks) {
|
||||||
|
const bps = ((ask.px - mid) / mid) * 10000;
|
||||||
|
const idx = Math.round((bps + bpsRange) / bpsStep);
|
||||||
|
if (idx >= 0 && idx < resolution) {
|
||||||
|
row[idx] += ask.sz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
z.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x, y, z };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute imbalance metrics from latest snapshot.
|
||||||
|
*/
|
||||||
|
export function computeImbalance(snapshot: L2Snapshot): ImbalanceMetrics {
|
||||||
|
const bidVolume = snapshot.bids.reduce((sum, b) => sum + b.sz * b.px, 0);
|
||||||
|
const askVolume = snapshot.asks.reduce((sum, a) => sum + a.sz * a.px, 0);
|
||||||
|
const total = bidVolume + askVolume;
|
||||||
|
const imbalance = total > 0 ? (bidVolume - askVolume) / total : 0;
|
||||||
|
|
||||||
|
// Wall detection: find side with largest concentration
|
||||||
|
const maxBidSz = Math.max(...snapshot.bids.map(b => b.sz), 0);
|
||||||
|
const maxAskSz = Math.max(...snapshot.asks.map(a => a.sz), 0);
|
||||||
|
const wallSide: "bid" | "ask" | "none" =
|
||||||
|
maxBidSz > maxAskSz * 1.3 ? "bid" :
|
||||||
|
maxAskSz > maxBidSz * 1.3 ? "ask" : "none";
|
||||||
|
const wallStrength = Math.max(maxBidSz, maxAskSz);
|
||||||
|
|
||||||
|
return {
|
||||||
|
imbalance: Math.round(imbalance * 10000) / 10000,
|
||||||
|
bidVolume: Math.round(bidVolume * 100) / 100,
|
||||||
|
askVolume: Math.round(askVolume * 100) / 100,
|
||||||
|
wallSide,
|
||||||
|
wallStrength: Math.round(wallStrength * 10000) / 10000,
|
||||||
|
snapshots: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate synthetic L2 data for testing/development.
|
||||||
|
* Produces realistic order-book shapes with price movement.
|
||||||
|
*/
|
||||||
|
export function generateSyntheticSnapshots(
|
||||||
|
count: number = 60,
|
||||||
|
basePrice: number = 97800,
|
||||||
|
): L2Snapshot[] {
|
||||||
|
const snapshots: L2Snapshot[] = [];
|
||||||
|
let price = basePrice;
|
||||||
|
let trend = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Random walk with mean reversion
|
||||||
|
trend += (Math.random() - 0.5) * 2;
|
||||||
|
trend *= 0.95; // decay
|
||||||
|
price += trend * 50;
|
||||||
|
price += (basePrice - price) * 0.01; // mean reversion
|
||||||
|
|
||||||
|
const mid = price;
|
||||||
|
const bids: L2Level[] = [];
|
||||||
|
const asks: L2Level[] = [];
|
||||||
|
|
||||||
|
// Generate 20 levels on each side
|
||||||
|
for (let j = 0; j < 20; j++) {
|
||||||
|
const bps = (j + 1) * 2.5;
|
||||||
|
const bidPx = mid * (1 - bps / 10000);
|
||||||
|
const askPx = mid * (1 + bps / 10000);
|
||||||
|
|
||||||
|
// Realistic size distribution: thicker near mid, thinner further out
|
||||||
|
// Add wall at certain levels
|
||||||
|
const baseSize = Math.exp(-j * 0.15) * 5;
|
||||||
|
const bidWall = j === 3 ? Math.random() * 15 : 0; // occasional wall at 10bps
|
||||||
|
const askWall = j === 5 ? Math.random() * 12 : 0;
|
||||||
|
const noise = (Math.random() - 0.5) * 2;
|
||||||
|
|
||||||
|
bids.push({ px: Math.round(bidPx * 10) / 10, sz: Math.max(0.01, baseSize + bidWall + noise) });
|
||||||
|
asks.push({ px: Math.round(askPx * 10) / 10, sz: Math.max(0.01, baseSize + askWall + noise) });
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots.push({ bids, asks, mid, ts: Date.now() + i * 1000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════ 3D Subplots: Split Bid/Ask Surfaces ═══════════
|
||||||
|
|
||||||
|
export interface DualSurfaceData {
|
||||||
|
bid: SurfaceData;
|
||||||
|
ask: SurfaceData;
|
||||||
|
y: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split L2 → dual bid/ask surface matrices for 3D subplots */
|
||||||
|
export function l2SnapshotsToDualSurface(
|
||||||
|
snapshots: L2Snapshot[],
|
||||||
|
bpsRange: number = 50,
|
||||||
|
resolution: number = 50,
|
||||||
|
): DualSurfaceData {
|
||||||
|
const bpsStep = bpsRange / resolution;
|
||||||
|
const bidX: number[] = [], askX: number[] = [];
|
||||||
|
for (let i = 0; i < resolution; i++) {
|
||||||
|
bidX.push(-bpsRange + i * bpsStep);
|
||||||
|
askX.push(i * bpsStep);
|
||||||
|
}
|
||||||
|
const y = snapshots.map((_, i) => i);
|
||||||
|
const bidZ: number[][] = [], askZ: number[][] = [];
|
||||||
|
for (const snap of snapshots) {
|
||||||
|
const mid = snap.mid;
|
||||||
|
const bRow = new Array(resolution).fill(0);
|
||||||
|
const aRow = new Array(resolution).fill(0);
|
||||||
|
for (const bid of snap.bids) {
|
||||||
|
const bps = ((bid.px - mid) / mid) * 10000;
|
||||||
|
const idx = Math.round((bps + bpsRange) / bpsStep);
|
||||||
|
if (idx >= 0 && idx < resolution) bRow[idx] += bid.sz;
|
||||||
|
}
|
||||||
|
for (const ask of snap.asks) {
|
||||||
|
const bps = ((ask.px - mid) / mid) * 10000;
|
||||||
|
const idx = Math.round(bps / bpsStep);
|
||||||
|
if (idx >= 0 && idx < resolution) aRow[idx] += ask.sz;
|
||||||
|
}
|
||||||
|
bidZ.push(bRow);
|
||||||
|
askZ.push(aRow);
|
||||||
|
}
|
||||||
|
return { bid: { x: bidX, y, z: bidZ }, ask: { x: askX, y, z: askZ }, y };
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export interface L2Level {
|
||||||
|
px: number;
|
||||||
|
sz: number;
|
||||||
|
n: number; // number of orders
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface L2Book {
|
||||||
|
coin: string;
|
||||||
|
levels: [L2Level[], L2Level[]]; // [bids, asks]
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Trade {
|
||||||
|
coin: string;
|
||||||
|
side: string; // "A" = ask (sell), "B" = bid (buy)
|
||||||
|
px: number;
|
||||||
|
sz: number;
|
||||||
|
hash: string;
|
||||||
|
tid: number;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface L2Snapshot {
|
||||||
|
bids: { px: number; sz: number }[];
|
||||||
|
asks: { px: number; sz: number }[];
|
||||||
|
mid: number;
|
||||||
|
spread: number;
|
||||||
|
totalBidVol: number;
|
||||||
|
totalAskVol: number;
|
||||||
|
imbalance: number;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradeTapeEntry {
|
||||||
|
px: number;
|
||||||
|
sz: number;
|
||||||
|
side: "buy" | "sell";
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSocket Hook ──
|
||||||
|
|
||||||
|
interface HyperliquidData {
|
||||||
|
l2: L2Snapshot | null;
|
||||||
|
trades: TradeTapeEntry[];
|
||||||
|
connected: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHyperliquidWebSocket(coin: string = "BTC"): HyperliquidData {
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const l2Ref = useRef<L2Snapshot | null>(null);
|
||||||
|
const tradesRef = useRef<TradeTapeEntry[]>([]);
|
||||||
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
const subscribed = useRef(false);
|
||||||
|
|
||||||
|
const [l2, setL2] = useState<L2Snapshot | null>(null);
|
||||||
|
const [trades, setTrades] = useState<TradeTapeEntry[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
// Already connected — just resubscribe
|
||||||
|
wsRef.current.send(JSON.stringify({ type: "subscribe", subscription: { type: "l2Book", coin } }));
|
||||||
|
wsRef.current.send(JSON.stringify({ type: "subscribe", subscription: { type: "trades", coin } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stale connection
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket("wss://api.hyperliquid.xyz/ws");
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
setError(null);
|
||||||
|
subscribed.current = false;
|
||||||
|
// Subscribe — Hyperliquid WebSocket uses "method" not "type"
|
||||||
|
ws.send(JSON.stringify({ method: "subscribe", subscription: { type: "l2Book", coin } }));
|
||||||
|
ws.send(JSON.stringify({ method: "subscribe", subscription: { type: "trades", coin } }));
|
||||||
|
subscribed.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.channel === "l2Book" && msg.data?.levels) {
|
||||||
|
const levels = msg.data.levels as [L2Level[], L2Level[]];
|
||||||
|
const bids = (levels[0] || []).map((l) => ({ px: parseFloat(String(l.px)), sz: parseFloat(String(l.sz)) }));
|
||||||
|
const asks = (levels[1] || []).map((l) => ({ px: parseFloat(String(l.px)), sz: parseFloat(String(l.sz)) }));
|
||||||
|
|
||||||
|
const bestBid = bids[0]?.px ?? 0;
|
||||||
|
const bestAsk = asks[0]?.px ?? 0;
|
||||||
|
const mid = (bestBid + bestAsk) / 2;
|
||||||
|
const spread = bestAsk - bestBid;
|
||||||
|
|
||||||
|
// Calculate volume totals (top 20 levels)
|
||||||
|
const topBids = bids.slice(0, 20);
|
||||||
|
const topAsks = asks.slice(0, 20);
|
||||||
|
const totalBidVol = topBids.reduce((s, l) => s + l.sz, 0);
|
||||||
|
const totalAskVol = topAsks.reduce((s, l) => s + l.sz, 0);
|
||||||
|
const imbalance = totalBidVol + totalAskVol > 0
|
||||||
|
? (totalBidVol - totalAskVol) / (totalBidVol + totalAskVol)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const snapshot: L2Snapshot = {
|
||||||
|
bids, asks, mid, spread,
|
||||||
|
totalBidVol, totalAskVol, imbalance,
|
||||||
|
time: Date.now(),
|
||||||
|
};
|
||||||
|
l2Ref.current = snapshot;
|
||||||
|
setL2(snapshot);
|
||||||
|
} else if (msg.channel === "trades" && Array.isArray(msg.data)) {
|
||||||
|
const newTrades: TradeTapeEntry[] = msg.data.map((t: Trade) => ({
|
||||||
|
px: parseFloat(String(t.px)),
|
||||||
|
sz: parseFloat(String(t.sz)),
|
||||||
|
side: t.side === "B" ? "buy" : "sell",
|
||||||
|
time: t.time || Date.now(),
|
||||||
|
}));
|
||||||
|
// Append to ring buffer — keep last ~500 trades
|
||||||
|
tradesRef.current = [...tradesRef.current, ...newTrades].slice(-500);
|
||||||
|
setTrades([...tradesRef.current]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setError("WebSocket error");
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
// Auto-reconnect after 2s
|
||||||
|
reconnectTimer.current = setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
}, [coin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
return { l2, trades, connected, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// ═══════════ FTDT Quant Lab — Type Definitions ═══════════
|
||||||
|
|
||||||
|
export interface Strategy {
|
||||||
|
allocation: number;
|
||||||
|
instrument: string;
|
||||||
|
pnl: number;
|
||||||
|
pnl_pct: number;
|
||||||
|
position: number;
|
||||||
|
trades_today: number;
|
||||||
|
wins: number;
|
||||||
|
win_rate: number;
|
||||||
|
status: "running" | "idle";
|
||||||
|
size: number;
|
||||||
|
fee_paid: number;
|
||||||
|
fee_model: "maker" | "taker";
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
signals: { signal: string; ts: number }[];
|
||||||
|
max_position?: number;
|
||||||
|
stop_loss?: number;
|
||||||
|
entry_price?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Trade {
|
||||||
|
time: string;
|
||||||
|
strategy: string;
|
||||||
|
side: string;
|
||||||
|
size: number;
|
||||||
|
price: number;
|
||||||
|
pnl: number;
|
||||||
|
fee: number;
|
||||||
|
reason?: string;
|
||||||
|
pnl_net?: number;
|
||||||
|
pnl_gross?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
strategy: string;
|
||||||
|
size: number;
|
||||||
|
entry_px: number;
|
||||||
|
pnl?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
coin: string;
|
||||||
|
side: "B" | "A";
|
||||||
|
sz: number;
|
||||||
|
limitPx?: string;
|
||||||
|
cloid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveMetrics {
|
||||||
|
timestamp: number;
|
||||||
|
wallet: string;
|
||||||
|
total_equity: number;
|
||||||
|
base_equity: number;
|
||||||
|
total_pnl: number;
|
||||||
|
total_pnl_pct: number;
|
||||||
|
reserve: number;
|
||||||
|
equity_history: { t: number; v: number }[];
|
||||||
|
strategy_equity: Record<string, { t: number; v: number }[]>;
|
||||||
|
strategies: Record<string, Strategy>;
|
||||||
|
trades: Trade[];
|
||||||
|
status: string;
|
||||||
|
testnet_up: boolean;
|
||||||
|
open_positions: Position[];
|
||||||
|
open_orders: Order[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaperMetrics {
|
||||||
|
timestamp: number;
|
||||||
|
total_equity: number;
|
||||||
|
base_equity: number;
|
||||||
|
total_pnl: number;
|
||||||
|
total_pnl_pct: number;
|
||||||
|
regime: string;
|
||||||
|
equity_history: { t: number; v: number }[];
|
||||||
|
strategy_equity: Record<string, { t: number; v: number }[]>;
|
||||||
|
strategies: Record<string, Strategy>;
|
||||||
|
per_strategy_trades: Record<string, Trade[]>;
|
||||||
|
open_positions: Position[];
|
||||||
|
open_orders: Order[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacktestSummary {
|
||||||
|
name: string;
|
||||||
|
strategy: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
sharpe: number;
|
||||||
|
sortino: number;
|
||||||
|
pnl_pct: number;
|
||||||
|
max_dd: number;
|
||||||
|
win_rate: number;
|
||||||
|
total_trades: number;
|
||||||
|
coin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacktestFull {
|
||||||
|
name?: string;
|
||||||
|
strategy: string;
|
||||||
|
pnl_net?: number;
|
||||||
|
pnl_gross?: number;
|
||||||
|
pnl_pct?: number;
|
||||||
|
pnl_net_pct?: number;
|
||||||
|
pnl_gross_pct?: number;
|
||||||
|
pnl: number;
|
||||||
|
sharpe: number;
|
||||||
|
sortino: number;
|
||||||
|
max_dd: number;
|
||||||
|
win_rate: number;
|
||||||
|
total_trades: number;
|
||||||
|
fees_total: number;
|
||||||
|
num_periods: number;
|
||||||
|
fee_model: string;
|
||||||
|
equity_curve: { t: number | string; v: number }[];
|
||||||
|
trades: Trade[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"out",
|
||||||
|
"out-www",
|
||||||
|
"_next-app-backup"
|
||||||
|
]
|
||||||
|
}
|
||||||
+224
-5
@@ -27,8 +27,44 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
import sys
|
import sys
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
# Fix BACKTEST_DIR — auto-detect local path if deployed dir doesn't exist
|
||||||
|
_default_results = str(Path(__file__).resolve().parent.parent / "backtests" / "results")
|
||||||
|
BACKTEST_DIR = _default_results if os.path.isdir(_default_results) else "/home/debian/ftdt-quant-lab/backtests/results"
|
||||||
|
HISTORICAL_DIR = BACKTEST_DIR + "/historical" if os.path.isdir(BACKTEST_DIR + "/historical") else BACKTEST_DIR
|
||||||
|
|
||||||
from config.fee_tiers import get_perp_fees, PERPS_TIERS, STAKING_TIERS, STRATEGY_FEE_MODELS
|
from config.fee_tiers import get_perp_fees, PERPS_TIERS, STAKING_TIERS, STRATEGY_FEE_MODELS
|
||||||
from common.risk import risk_summary
|
from common.risk import risk_summary
|
||||||
|
from strategies.quant_report import compute_quant_report
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# Memory guard: check RSS via /proc, force GC at 256MB,
|
||||||
|
# log warning at 384MB, hard exit at 512MB.
|
||||||
|
# RLIMIT_AS disabled — Python heap needs virtual headroom.
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
import gc, os as _os
|
||||||
|
|
||||||
|
MEM_SOFT_LIMIT = 256 * 1024 * 1024 # 256 MB — force GC
|
||||||
|
MEM_WARN_LIMIT = 384 * 1024 * 1024 # 384 MB — log warning
|
||||||
|
MEM_HARD_LIMIT = 512 * 1024 * 1024 # 512 MB — terminate
|
||||||
|
|
||||||
|
def check_memory():
|
||||||
|
"""Check RSS, force GC if over soft limit, raise if over hard limit."""
|
||||||
|
try:
|
||||||
|
with open("/proc/self/status") as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("VmRSS:"):
|
||||||
|
rss_kb = int(line.split()[1])
|
||||||
|
rss = rss_kb * 1024
|
||||||
|
if rss > MEM_HARD_LIMIT:
|
||||||
|
print(f"[CRIT] RSS {rss_kb // 1024}MB > 512MB — exiting", flush=True)
|
||||||
|
_os._exit(1)
|
||||||
|
if rss > MEM_SOFT_LIMIT:
|
||||||
|
gc.collect()
|
||||||
|
gc.collect()
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
@@ -37,11 +73,8 @@ import uvicorn
|
|||||||
|
|
||||||
METRICS_FILE = "/tmp/ftdt-metrics.json"
|
METRICS_FILE = "/tmp/ftdt-metrics.json"
|
||||||
PAPER_METRICS_FILE = "/tmp/ftdt-paper-metrics.json"
|
PAPER_METRICS_FILE = "/tmp/ftdt-paper-metrics.json"
|
||||||
BACKTEST_DIR = "/home/debian/ftdt-quant-lab/backtests/results"
|
|
||||||
HISTORICAL_DIR = "/home/debian/ftdt-quant-lab/backtests/results/historical"
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
# Ensure backtest dir exists
|
|
||||||
os.makedirs(BACKTEST_DIR, exist_ok=True)
|
os.makedirs(BACKTEST_DIR, exist_ok=True)
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
@@ -109,6 +142,7 @@ def broadcast_loop():
|
|||||||
"""Continuously read metrics and broadcast to all clients."""
|
"""Continuously read metrics and broadcast to all clients."""
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
check_memory()
|
||||||
data = read_metrics()
|
data = read_metrics()
|
||||||
payload = json.dumps(data, default=str)
|
payload = json.dumps(data, default=str)
|
||||||
for ws in list(connected_clients):
|
for ws in list(connected_clients):
|
||||||
@@ -205,8 +239,11 @@ async def list_backtests():
|
|||||||
|
|
||||||
@app.get("/api/backtest/{name}")
|
@app.get("/api/backtest/{name}")
|
||||||
async def get_backtest(name: str):
|
async def get_backtest(name: str):
|
||||||
"""Get full backtest result data."""
|
"""Get full backtest result data — checks historical dir first."""
|
||||||
fpath = os.path.join(BACKTEST_DIR, f"{name}.json")
|
# Try historical subdirectory first (where dashboard saves backtests)
|
||||||
|
fpath = os.path.join(HISTORICAL_DIR, f"{name}.json")
|
||||||
|
if not os.path.exists(fpath):
|
||||||
|
fpath = os.path.join(BACKTEST_DIR, f"{name}.json")
|
||||||
if os.path.exists(fpath):
|
if os.path.exists(fpath):
|
||||||
with open(fpath) as f:
|
with open(fpath) as f:
|
||||||
return JSONResponse(json.load(f))
|
return JSONResponse(json.load(f))
|
||||||
@@ -421,6 +458,126 @@ async def get_risk_metrics():
|
|||||||
"correlation_matrix": corr,
|
"correlation_matrix": corr,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# VBT Dashboard API — VectorBT backtest results browser
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@app.get("/api/vbt/results")
|
||||||
|
async def list_vbt_results(strategy: str = "", limit: int = 50):
|
||||||
|
"""List VectorBT backtest results with full metrics."""
|
||||||
|
results = []
|
||||||
|
for d in [BACKTEST_DIR, HISTORICAL_DIR]:
|
||||||
|
if not os.path.isdir(d):
|
||||||
|
continue
|
||||||
|
for fname in sorted(os.listdir(d), reverse=True):
|
||||||
|
if not fname.endswith(".json"):
|
||||||
|
continue
|
||||||
|
if strategy and strategy not in fname:
|
||||||
|
continue
|
||||||
|
fpath = os.path.join(d, fname)
|
||||||
|
try:
|
||||||
|
with open(fpath) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
results.append({
|
||||||
|
"filename": fname,
|
||||||
|
"strategy": data.get("strategy", "unknown"),
|
||||||
|
"engine": data.get("engine", "vectorbt"),
|
||||||
|
"interval": data.get("interval", "1h"),
|
||||||
|
"sharpe": data.get("sharpe", 0),
|
||||||
|
"sortino": data.get("sortino", 0),
|
||||||
|
"total_return_pct": data.get("total_return_pct", 0),
|
||||||
|
"max_drawdown_pct": data.get("max_drawdown_pct", 0),
|
||||||
|
"win_rate": data.get("win_rate", 0),
|
||||||
|
"profit_factor": data.get("profit_factor", 0),
|
||||||
|
"total_trades": data.get("total_trades", 0),
|
||||||
|
"n_bars": data.get("n_bars", 0),
|
||||||
|
"generated_at": data.get("generated_at", ""),
|
||||||
|
"has_equity_curve": bool(data.get("equity_curve")),
|
||||||
|
})
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
pass
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
results.sort(key=lambda r: r.get("generated_at", ""), reverse=True)
|
||||||
|
return JSONResponse(results[:limit])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/vbt/result/{filename}")
|
||||||
|
async def get_vbt_result(filename: str):
|
||||||
|
"""Get full VBT backtest result including equity curve."""
|
||||||
|
for d in [BACKTEST_DIR, HISTORICAL_DIR]:
|
||||||
|
fpath = os.path.join(d, filename)
|
||||||
|
if os.path.exists(fpath):
|
||||||
|
with open(fpath) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
# Ensure equity curve is compact for transport
|
||||||
|
ec = data.get("equity_curve", [])
|
||||||
|
if ec and len(ec) > 500:
|
||||||
|
step = len(ec) // 500
|
||||||
|
data["equity_curve"] = ec[::step]
|
||||||
|
return JSONResponse(data)
|
||||||
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/vbt/run")
|
||||||
|
async def run_vbt_backtest(
|
||||||
|
strategy: str = "pairs",
|
||||||
|
interval: str = "1h",
|
||||||
|
limit: int = 500,
|
||||||
|
testnet: bool = False,
|
||||||
|
):
|
||||||
|
"""Run a new VectorBT backtest and return results."""
|
||||||
|
try:
|
||||||
|
from backtests.vbt_runner import VBTBacktestRunner
|
||||||
|
runner = VBTBacktestRunner()
|
||||||
|
from datetime import datetime
|
||||||
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
result = runner.run_strategy(
|
||||||
|
strategy=strategy, interval=interval, testnet=testnet, limit=limit
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
fname = f"{strategy}_vbt_{ts}.json"
|
||||||
|
fpath = os.path.join(BACKTEST_DIR, fname)
|
||||||
|
with open(fpath, "w") as f:
|
||||||
|
json.dump(result, f, default=str)
|
||||||
|
result["filename"] = fname
|
||||||
|
return JSONResponse(result)
|
||||||
|
return JSONResponse({"error": "no results generated"}, status_code=500)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/vbt/sweep")
|
||||||
|
async def run_vbt_sweep(strategy: str = "pairs"):
|
||||||
|
"""Run parameter sweep and return heatmap data."""
|
||||||
|
try:
|
||||||
|
from backtests.vbt_runner import VBTBacktestRunner
|
||||||
|
runner = VBTBacktestRunner()
|
||||||
|
df = runner.param_sweep(strategy=strategy)
|
||||||
|
if df is not None and not df.empty:
|
||||||
|
rows = df.to_dict(orient="records")
|
||||||
|
return JSONResponse({
|
||||||
|
"strategy": strategy,
|
||||||
|
"results": rows,
|
||||||
|
"best": max(rows, key=lambda r: r.get("sharpe", -999)),
|
||||||
|
})
|
||||||
|
return JSONResponse({"error": "no sweep results"}, status_code=500)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/vbt/strategies")
|
||||||
|
async def list_vbt_strategies():
|
||||||
|
"""List available strategies for VBT backtesting."""
|
||||||
|
return JSONResponse([
|
||||||
|
{"key": "pairs", "name": "Pairs Trading", "coins": ["BTC", "ETH"]},
|
||||||
|
{"key": "hurst_vpin", "name": "Hurst VPIN", "coins": ["BTC"]},
|
||||||
|
{"key": "as_mm", "name": "Avellaneda-Stoikov MM", "coins": ["BTC"]},
|
||||||
|
{"key": "momentum", "name": "Momentum Breakout", "coins": ["BTC"]},
|
||||||
|
{"key": "mean_rev", "name": "Mean Reversion", "coins": ["BTC"]},
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
# Static
|
# Static
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
@@ -430,6 +587,11 @@ async def root():
|
|||||||
return FileResponse(STATIC_DIR / "index.html")
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/vbt")
|
||||||
|
async def vbt_dashboard():
|
||||||
|
return FileResponse(STATIC_DIR / "vbt.html")
|
||||||
|
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
|
||||||
@@ -437,6 +599,63 @@ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|||||||
# Main
|
# Main
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@app.get("/api/quant-report/{name}")
|
||||||
|
async def get_quant_report(name: str):
|
||||||
|
"""Compute full QF-Lib quant report from a backtest file.
|
||||||
|
Accepts strategy name and auto-maps to filename prefix.
|
||||||
|
"""
|
||||||
|
# Strategy name → file prefix mapping
|
||||||
|
NAME_MAP = {
|
||||||
|
"order book imbalance": "ofi",
|
||||||
|
"avellaneda-stoikov": "avellaneda",
|
||||||
|
"funding rate arb": "funding_arb",
|
||||||
|
"iceberg detection": "iceberg",
|
||||||
|
"momentum breakout": "momentum",
|
||||||
|
"mean reversion": "mean_rev",
|
||||||
|
"kalman pairs": "kalman_pairs",
|
||||||
|
"pairs trading": "pairs",
|
||||||
|
}
|
||||||
|
|
||||||
|
name_lower = name.lower()
|
||||||
|
prefix = NAME_MAP.get(name_lower, name_lower.replace(" ", "_"))
|
||||||
|
|
||||||
|
# Build candidate paths
|
||||||
|
candidates = []
|
||||||
|
exact_path = os.path.join(BACKTEST_DIR, name)
|
||||||
|
hist_exact = os.path.join(HISTORICAL_DIR, name)
|
||||||
|
candidates.extend([exact_path, hist_exact])
|
||||||
|
|
||||||
|
# Try exact match
|
||||||
|
for path in candidates:
|
||||||
|
if os.path.exists(path):
|
||||||
|
backtest_path = path
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Fuzzy match: find files starting with the mapped prefix
|
||||||
|
fuzzy = []
|
||||||
|
for d in [BACKTEST_DIR, HISTORICAL_DIR]:
|
||||||
|
if not os.path.exists(d): continue
|
||||||
|
for f in os.listdir(d):
|
||||||
|
f_clean = f.lower()
|
||||||
|
# Match by prefix, then prefer BTC/ETH files
|
||||||
|
if f_clean.startswith(f"{prefix}_"):
|
||||||
|
fuzzy.append(os.path.join(d, f))
|
||||||
|
if fuzzy:
|
||||||
|
backtest_path = fuzzy[0]
|
||||||
|
else:
|
||||||
|
return JSONResponse({"error": f"Backtest '{name}' not found"}, status_code=404)
|
||||||
|
try:
|
||||||
|
with open(backtest_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
trades = data.get("trades", data.get("trade_history", []))
|
||||||
|
strategy_name = data.get("name", data.get("strategy", name))
|
||||||
|
strategy_id = data.get("id", name)
|
||||||
|
report = compute_quant_report(strategy_name, strategy_id, trades, 100.0)
|
||||||
|
return JSONResponse(report)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
|
|||||||
+4
-675
File diff suppressed because one or more lines are too long
@@ -1,514 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
|
||||||
<title>FTDT Quant Lab — Professional Dashboard</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<script src="https://unpkg.com/lightweight-charts@4.2.3/dist/lightweight-charts.standalone.production.js"></script>
|
|
||||||
<style>
|
|
||||||
:root{--bg:#050508;--srf:#0b0b12;--ln:#181825;--hr:#222230;--tx:#6b6b7b;--hi:#d4d4e0;--gr:#22c55e;--rd:#ef4444;--bl:#3b82f6;--am:#f59e0b;--pu:#a855f7;--cy:#06b6d4;--pk:#ec4899;--ra:8px;--f:'Inter',system-ui,sans-serif;--m:'JetBrains Mono',monospace}
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
body{background:var(--bg);color:var(--hi);font-family:var(--f);min-height:100vh;-webkit-font-smoothing:antialiased}
|
|
||||||
.topbar{position:sticky;top:0;z-index:100;background:rgba(5,5,8,.95);backdrop-filter:blur(20px);border-bottom:1px solid var(--ln);padding:12px 24px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px}
|
|
||||||
.topbar h1{font-size:17px;font-weight:700;letter-spacing:-0.5px;display:flex;align-items:center;gap:8px}
|
|
||||||
.topbar h1 span{font-size:10px;color:var(--tx);font-weight:400}
|
|
||||||
.status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--gr);animation:pulse 2s infinite}
|
|
||||||
.status-dot.off{background:var(--rd);animation:none}
|
|
||||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.3}}
|
|
||||||
.portfolio{text-align:right;min-width:140px}
|
|
||||||
.portfolio .pnl{font-family:var(--m);font-size:28px;font-weight:700;letter-spacing:-1px}
|
|
||||||
.portfolio .pnl.up{color:var(--gr)}.portfolio .pnl.dn{color:var(--rd)}
|
|
||||||
.portfolio .sub{font-size:10px;color:var(--tx);text-transform:uppercase;letter-spacing:.5px}
|
|
||||||
.tabs{display:flex;gap:0;padding:0 24px;border-bottom:1px solid var(--ln);position:sticky;top:52px;z-index:99;background:rgba(5,5,8,.95);backdrop-filter:blur(20px)}
|
|
||||||
.tab{padding:10px 20px;font-size:12px;font-weight:500;cursor:pointer;background:none;border:none;border-bottom:2px solid transparent;color:var(--tx);font-family:var(--f);transition:all .15s}
|
|
||||||
.tab:hover{color:var(--hi)}.tab.on{color:var(--hi);border-bottom-color:var(--bl)}
|
|
||||||
.badge{font-size:8px;padding:2px 7px;border-radius:3px;font-weight:600;margin-left:6px;text-transform:uppercase;letter-spacing:.5px}
|
|
||||||
.badge.test{background:rgba(245,158,11,.15);color:var(--am)}.badge.main{background:rgba(168,85,247,.15);color:var(--pu)}
|
|
||||||
.main-wrap{max-width:1440px;margin:0 auto;padding:20px 24px;display:flex;gap:20px}
|
|
||||||
.panel{display:none;flex:1;min-width:0}.panel.show{display:block}
|
|
||||||
|
|
||||||
/* Summary stats */
|
|
||||||
.stats-row{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:16px}
|
|
||||||
.stat{background:var(--srf);border:1px solid var(--ln);border-radius:var(--ra);padding:12px 14px}
|
|
||||||
.stat .lbl{font-size:9px;color:var(--tx);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px}
|
|
||||||
.stat .val{font-family:var(--m);font-size:17px;font-weight:600}
|
|
||||||
.stat .val.up{color:var(--gr)}.stat .val.dn{color:var(--rd)}
|
|
||||||
|
|
||||||
/* Strategy grid */
|
|
||||||
.sgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;margin-bottom:20px}
|
|
||||||
.scard{background:var(--srf);border:1px solid var(--ln);border-radius:var(--ra);padding:16px;cursor:pointer;transition:all .2s;position:relative}
|
|
||||||
.scard:hover{border-color:var(--hr);transform:translateY(-1px);box-shadow:0 4px 20px rgba(0,0,0,.3)}
|
|
||||||
.scard.selected{border-color:var(--bl);box-shadow:0 0 0 1px rgba(59,130,246,.3)}
|
|
||||||
.scard .sh{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px}
|
|
||||||
.scard .sname{font-size:12px;font-weight:600;line-height:1.3;max-width:70%}
|
|
||||||
.scard .salloc{font-size:9px;color:var(--tx);margin-top:2px}
|
|
||||||
.scard .stag{font-size:8px;padding:2px 7px;border-radius:3px;font-weight:500;white-space:nowrap}
|
|
||||||
.scard .stag.run{background:rgba(34,197,94,.1);color:var(--gr)}
|
|
||||||
.scard .stag.idle{background:rgba(245,158,11,.1);color:var(--am)}
|
|
||||||
.scard .stag.maker{background:rgba(59,130,246,.1);color:var(--bl)}
|
|
||||||
.scard .stag.taker{background:rgba(239,68,68,.1);color:var(--rd)}
|
|
||||||
.scard .spnl{font-family:var(--m);font-size:20px;font-weight:700;margin-bottom:6px}
|
|
||||||
.scard .spnl.up{color:var(--gr)}.scard .spnl.dn{color:var(--rd)}
|
|
||||||
.scard .smeta{display:flex;gap:12px;font-size:9px;color:var(--tx);flex-wrap:wrap}
|
|
||||||
|
|
||||||
/* Detail panel */
|
|
||||||
.detail-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:200;display:none}
|
|
||||||
.detail-overlay.on{display:flex;align-items:flex-start;justify-content:center;padding-top:40px}
|
|
||||||
.detail-panel{background:var(--bg);border:1px solid var(--ln);border-radius:12px;width:95%;max-width:1100px;max-height:85vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.5)}
|
|
||||||
.detail-header{position:sticky;top:0;background:var(--srf);padding:16px 20px;border-bottom:1px solid var(--ln);display:flex;align-items:center;justify-content:space-between;z-index:5}
|
|
||||||
.detail-header h2{font-size:16px;font-weight:700}
|
|
||||||
.close-btn{background:none;border:1px solid var(--ln);color:var(--hi);padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px;font-family:var(--f);transition:all .15s}
|
|
||||||
.close-btn:hover{background:var(--hr)}
|
|
||||||
.detail-body{padding:20px}
|
|
||||||
.main-chart{width:100%;height:220px;margin:8px 0 0;border-radius:var(--ra);overflow:hidden;background:rgba(0,0,0,.25)}
|
|
||||||
.detail-body .chart-wrap{width:100%;height:280px;margin-bottom:16px;border-radius:var(--ra);overflow:hidden}
|
|
||||||
.detail-stats{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:16px}
|
|
||||||
.detail-section{margin-bottom:20px}
|
|
||||||
.detail-section h4{font-size:11px;font-weight:600;color:var(--tx);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--ln)}
|
|
||||||
.trade-table{width:100%;border-collapse:collapse;font-family:var(--m)}
|
|
||||||
.trade-table th{font-size:9px;font-weight:600;color:var(--tx);text-transform:uppercase;text-align:left;padding:8px 10px;border-bottom:1px solid var(--ln)}
|
|
||||||
.trade-table td{font-size:11px;padding:7px 10px;border-bottom:1px solid rgba(255,255,255,.02);color:var(--hi)}
|
|
||||||
.trade-table td.reason{font-size:10px;color:var(--tx);max-width:250px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:var(--f)}
|
|
||||||
.green{color:var(--gr)}.red{color:var(--rd)}
|
|
||||||
.desc-text{font-size:12px;color:var(--tx);line-height:1.6;padding:12px;background:var(--srf);border-radius:var(--ra);border:1px solid var(--ln);margin-bottom:16px}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
footer{text-align:center;padding:30px;font-size:10px;color:#2a2a35}
|
|
||||||
footer a{color:#3f3f4a;text-decoration:none}footer a:hover{color:var(--tx)}
|
|
||||||
|
|
||||||
/* Risk Analytics panel — collapsible */
|
|
||||||
.risk-wrap{max-width:1440px;margin:0 auto 20px;padding:0 24px}
|
|
||||||
.risk-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;background:none;border:1px solid var(--ln);border-radius:var(--ra);color:var(--tx);font-family:var(--f);font-size:11px;font-weight:600;padding:10px 16px;text-transform:uppercase;letter-spacing:.5px;transition:all .15s}
|
|
||||||
.risk-toggle:hover{color:var(--hi);border-color:var(--hr)}
|
|
||||||
.risk-toggle .arrow{display:inline-block;transition:transform .2s;font-size:10px}
|
|
||||||
.risk-toggle.open .arrow{transform:rotate(90deg)}
|
|
||||||
.risk-panel{display:none;background:var(--srf);border:1px solid var(--ln);border-radius:var(--ra);padding:16px;margin-top:8px}
|
|
||||||
.risk-panel.show{display:block}
|
|
||||||
.risk-corr{font-family:var(--m);font-size:10px;color:var(--tx);line-height:1.8;margin-top:12px;padding:10px;background:rgba(0,0,0,.2);border-radius:6px;max-height:200px;overflow-y:auto}
|
|
||||||
.risk-corr .corr-high{color:var(--rd)}
|
|
||||||
.risk-corr .corr-med{color:var(--am)}
|
|
||||||
.risk-corr .corr-low{color:var(--tx)}
|
|
||||||
|
|
||||||
@media(max-width:768px){
|
|
||||||
.topbar{padding:10px 14px;flex-direction:column;align-items:flex-start}
|
|
||||||
.tabs{padding:0 14px;top:88px;overflow-x:auto;white-space:nowrap}
|
|
||||||
.main-wrap{padding:12px 14px}
|
|
||||||
.stats-row{grid-template-columns:repeat(3,1fr)}.sgrid{grid-template-columns:1fr 1fr}
|
|
||||||
.detail-stats{grid-template-columns:repeat(3,1fr)}
|
|
||||||
.portfolio .pnl{font-size:22px}
|
|
||||||
}
|
|
||||||
@media(max-width:380px){.stats-row{grid-template-columns:repeat(2,1fr)}.sgrid{grid-template-columns:1fr}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Top bar -->
|
|
||||||
<div class="topbar">
|
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
|
||||||
<span class="status-dot" id="sdot"></span><div><h1>FTDT Quant Lab<span>Professional Quant Dashboard</span></h1></div>
|
|
||||||
</div>
|
|
||||||
<div class="portfolio">
|
|
||||||
<div style="font-size:9px;color:var(--tx);text-transform:uppercase;letter-spacing:.5px">Portfolio Equity</div>
|
|
||||||
<div class="pnl" id="stpnl">$0.00</div>
|
|
||||||
<div class="sub" id="stpct">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="tabs">
|
|
||||||
<button class="tab on" id="tl-live" onclick="switchTab('live')">Live<span class="badge test">Testnet</span></button>
|
|
||||||
<button class="tab" id="tl-paper" onclick="switchTab('paper')">Paper<span class="badge main">$100K Mainnet</span></button>
|
|
||||||
<button class="tab" id="tl-backtest" onclick="switchTab('backtest')">Backtest</button>
|
|
||||||
<button class="tab" id="tl-historical" onclick="switchTab('historical')">Historical<span class="badge main">Real Data</span></button>
|
|
||||||
</div>
|
|
||||||
<!-- Main -->
|
|
||||||
<div class="main-wrap">
|
|
||||||
<div class="panel show" id="pnl-live">
|
|
||||||
<div class="stats-row" id="live-stats"></div>
|
|
||||||
<div class="sgrid" id="live-sgrid"></div>
|
|
||||||
<div class="chart-wrap main-chart" id="chart-live-wrap"><div id="chart-live"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="panel" id="pnl-paper">
|
|
||||||
<div class="stats-row" id="paper-stats"></div>
|
|
||||||
<div class="sgrid" id="paper-sgrid"></div>
|
|
||||||
<div class="chart-wrap main-chart" id="chart-paper-wrap"><div id="chart-paper"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="panel" id="pnl-backtest">
|
|
||||||
<div class="sgrid" id="bt-sgrid"></div>
|
|
||||||
</div>
|
|
||||||
<div class="panel" id="pnl-historical">
|
|
||||||
<div class="sgrid" id="hist-sgrid"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Risk Analytics -->
|
|
||||||
<div class="risk-wrap">
|
|
||||||
<button class="risk-toggle" onclick="toggleRisk()" id="risk-btn"><span class="arrow">▶</span> Risk Analytics</button>
|
|
||||||
<div class="risk-panel" id="risk-panel">
|
|
||||||
<div class="stats-row" id="risk-stats" style="margin-bottom:12px"></div>
|
|
||||||
<div class="risk-corr" id="risk-corr"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer><a href="https://git.ftdt.io/rams/ftdt-quant-lab" target="_blank">rams/ftdt-quant-lab</a> · 12 strategies · $100K paper · Hyperliquid</footer>
|
|
||||||
|
|
||||||
<!-- Detail Overlay -->
|
|
||||||
<div class="detail-overlay" id="detail-overlay" onclick="event.target===this&&closeDetail()">
|
|
||||||
<div class="detail-panel" id="detail-panel">
|
|
||||||
<div class="detail-header">
|
|
||||||
<h2 id="det-name">Strategy Detail</h2>
|
|
||||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
||||||
<label id="fee-toggle-wrap" style="display:none;font-size:11px;color:var(--tx);cursor:pointer;user-select:none">
|
|
||||||
<input type="checkbox" id="fee-toggle" checked onchange="toggleFees()" style="cursor:pointer;margin-right:4px">Inc. fees
|
|
||||||
</label>
|
|
||||||
<select id="fee-tier-sel" style="display:none;font-size:10px;background:var(--srf);color:var(--hi);border:1px solid var(--ln);border-radius:4px;padding:3px 6px;font-family:var(--f)" onchange="onFeeTierChange()">
|
|
||||||
<option value="0">Tier 0 (0.045/0.015%)</option>
|
|
||||||
<option value="1">Tier 1 — >$5M (0.040/0.012%)</option>
|
|
||||||
<option value="2">Tier 2 — >$25M (0.035/0.008%)</option>
|
|
||||||
<option value="3">Tier 3 — >$100M (0.030/0.004%)</option>
|
|
||||||
<option value="4">Tier 4 — >$500M (0.028/0.000%)</option>
|
|
||||||
<option value="5">Tier 5 — >$2B (0.026/0.000%)</option>
|
|
||||||
<option value="6">Tier 6 — >$7B (0.024/0.000%)</option>
|
|
||||||
</select>
|
|
||||||
<select id="stake-tier-sel" style="display:none;font-size:10px;background:var(--srf);color:var(--hi);border:1px solid var(--ln);border-radius:4px;padding:3px 6px;font-family:var(--f)" onchange="onFeeTierChange()">
|
|
||||||
<option value="none">No Stake</option>
|
|
||||||
<option value="wood">Wood (×0.95)</option>
|
|
||||||
<option value="bronze">Bronze (×0.90)</option>
|
|
||||||
<option value="silver">Silver (×0.85)</option>
|
|
||||||
<option value="gold">Gold (×0.80)</option>
|
|
||||||
<option value="platinum">Platinum (×0.70)</option>
|
|
||||||
<option value="diamond">Diamond (×0.60)</option>
|
|
||||||
</select>
|
|
||||||
<a id="dl-csv" href="#" style="display:none;font-size:11px;color:var(--bl);text-decoration:none;padding:4px 10px;border:1px solid var(--ln);border-radius:5px" download>↓ CSV</a>
|
|
||||||
<button class="close-btn" onclick="closeDetail()">✕ Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-body">
|
|
||||||
<div class="desc-text" id="det-desc"></div>
|
|
||||||
<div class="detail-stats" id="det-stats"></div>
|
|
||||||
<div class="chart-wrap" id="det-chart-wrap"><div id="det-chart"></div></div>
|
|
||||||
<div class="detail-section"><h4>Trade History</h4>
|
|
||||||
<div style="overflow-x:auto"><table class="trade-table"><thead><tr><th>Time</th><th>Side</th><th>Size</th><th>Price</th><th>PnL</th><th>Fee</th><th>Reason / Signal</th></tr></thead><tbody id="det-trades"></tbody></table></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// ═══════════ State ═══════════
|
|
||||||
var currentTab='live', lastData=null, lastPaper=null, lastBT=null, lastBTFull=null, feeOn=true;
|
|
||||||
var STRAT_COLORS=['#22c55e','#3b82f6','#a855f7','#f59e0b','#ef4444','#06b6d4','#ec4899','#84cc16','#6366f1','#14b8a6','#f97316','#8b5cf6'];
|
|
||||||
|
|
||||||
// ═══════════ Chart for detail view ═══════════
|
|
||||||
var detChart=null, detSer=null;
|
|
||||||
|
|
||||||
// ═══════════ Main area charts ═══════════
|
|
||||||
var chartLive=null, serLive=null, chartPaper=null, serPaper=null;
|
|
||||||
function initMainCharts(){
|
|
||||||
[{el:'chart-live',ch:'chartLive',sr:'serLive'},{el:'chart-paper',ch:'chartPaper',sr:'serPaper'}].forEach(function(c){
|
|
||||||
var el=document.getElementById(c.el);if(!el)return;
|
|
||||||
el.style.width='100%';el.style.height='220px';
|
|
||||||
window[c.ch]=LightweightCharts.createChart(el,{
|
|
||||||
layout:{background:{color:'transparent'},textColor:'#a0a0b0'},
|
|
||||||
grid:{vertLines:{color:'rgba(255,255,255,.02)'},horzLines:{color:'rgba(255,255,255,.03)'}},
|
|
||||||
rightPriceScale:{borderColor:'rgba(255,255,255,.08)',autoScale:true},
|
|
||||||
timeScale:{borderColor:'rgba(255,255,255,.08)',timeVisible:false},
|
|
||||||
crosshair:{mode:0},width:el.offsetWidth,height:220
|
|
||||||
});
|
|
||||||
window[c.sr]=window[c.ch].addAreaSeries({lineColor:'#3b82f6',topColor:'rgba(59,130,246,.15)',bottomColor:'rgba(59,130,246,.02)',lineWidth:2});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function pushEquity(chart,ser,data){
|
|
||||||
if(!chart||!ser||!data||!data.length)return;
|
|
||||||
var pts=[];
|
|
||||||
for(var i=0;i<data.length;i++){
|
|
||||||
var t=data[i].t||data[i].time||data[i][0];
|
|
||||||
var v=data[i].v||data[i].value||data[i].equity||data[i][1];
|
|
||||||
if(typeof t==='number'){
|
|
||||||
if(t>1e12)t=Math.floor(t/1000);
|
|
||||||
pts.push({time:t,value:v});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(pts.length>0){ser.setData(pts);chart.timeScale().fitContent()}
|
|
||||||
}
|
|
||||||
function initDetChart(){
|
|
||||||
var el=document.getElementById('det-chart');
|
|
||||||
if(!el)return;
|
|
||||||
el.style.width='100%'; el.style.height='280px';
|
|
||||||
detChart=LightweightCharts.createChart(el,{
|
|
||||||
layout:{background:{color:'transparent'},textColor:'#d4d4e0'},
|
|
||||||
grid:{vertLines:{color:'rgba(255,255,255,.03)'},horzLines:{color:'rgba(255,255,255,.03)'}},
|
|
||||||
rightPriceScale:{borderColor:'rgba(255,255,255,.08)'},
|
|
||||||
timeScale:{borderColor:'rgba(255,255,255,.08)',timeVisible:true},
|
|
||||||
crosshair:{mode:0},width:el.offsetWidth,height:280
|
|
||||||
});
|
|
||||||
detSer=detChart.addAreaSeries({lineColor:'#3b82f6',topColor:'rgba(59,130,246,.15)',bottomColor:'rgba(59,130,246,.02)',lineWidth:2});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════ Tab switching ═══════════
|
|
||||||
function switchTab(t){
|
|
||||||
currentTab=t;
|
|
||||||
['live','paper','backtest','historical'].forEach(function(x){document.getElementById('tl-'+x).className=t===x?'tab on':'tab'});
|
|
||||||
document.getElementById('pnl-live').className=t==='live'?'panel show':'panel';
|
|
||||||
document.getElementById('pnl-paper').className=t==='paper'?'panel show':'panel';
|
|
||||||
document.getElementById('pnl-backtest').className=t==='backtest'?'panel show':'panel';
|
|
||||||
document.getElementById('pnl-historical').className=t==='historical'?'panel show':'panel';
|
|
||||||
if(t==='live'&&lastData)renLive(lastData);
|
|
||||||
if(t==='paper'&&lastPaper)renPaper(lastPaper);
|
|
||||||
if(t==='backtest')loadBT();
|
|
||||||
if(t==='historical')loadHistBT();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════ Render strategy cards ═══════════
|
|
||||||
function renCards(sgridId,ss,baseEq,tab,statsRowId){
|
|
||||||
var keys=Object.keys(ss),totalPnl=0,trades=0,fees=0,active=0;
|
|
||||||
for(var i=0;i<keys.length;i++){var s=ss[keys[i]];totalPnl+=s.pnl||0;trades+=s.trades_today||0;fees+=s.fee_paid||0;if(s.status==='running')active++}
|
|
||||||
if(statsRowId){
|
|
||||||
document.getElementById(statsRowId).innerHTML='<div class="stat"><div class="lbl">Equity</div><div class="val">$'+((baseEq||0)+totalPnl).toFixed(0)+'</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">PnL</div><div class="val '+(totalPnl>=0?'up':'dn')+'">'+(totalPnl>=0?'+':'')+'$'+Math.abs(totalPnl).toFixed(2)+'</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Trades</div><div class="val">'+trades+'</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Fees</div><div class="val dn">$'+fees.toFixed(2)+'</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Active</div><div class="val">'+active+'/'+keys.length+'</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Alloc</div><div class="val">$'+(keys[0]?ss[keys[0]].allocation||0:0)+'k/strat</div></div>';
|
|
||||||
}
|
|
||||||
var h='';
|
|
||||||
for(var k=0;k<keys.length;k++){
|
|
||||||
var name=keys[k],s=ss[name],sp=s.pnl||0,cls=sp>=0?'up':'dn',pStr=(sp>=0?'+':'')+'$'+Math.abs(sp).toFixed(2);
|
|
||||||
var fm=s.fee_model||'taker';
|
|
||||||
h+='<div class="scard" onclick="openDetail(\''+name+'\',\''+tab+'\')" id="scard-'+tab+'-'+name.replace(/\s/g,'_')+'">'+
|
|
||||||
'<div class="sh"><div><div class="sname">'+name+'</div><div class="salloc">$'+s.allocation+' · '+s.type+'</div></div>'+
|
|
||||||
'<div><span class="stag '+(s.status==='running'?'run':'idle')+'">'+(s.status==='running'?'RUNNING':'IDLE')+'</span>'+
|
|
||||||
'<span class="stag '+fm+'">'+fm.toUpperCase()+'</span></div></div>'+
|
|
||||||
'<div class="spnl '+cls+'">'+pStr+'</div>'+
|
|
||||||
'<div class="smeta"><span>PnL: <b class="'+(sp>=0?'green':'red')+'">'+(s.pnl_pct>=0?'+':'')+(s.pnl_pct||0).toFixed(2)+'%</b></span><span>Trades: <b>'+(s.trades_today||0)+'</b></span><span>Win: <b>'+Math.round((s.win_rate||0)*100)+'%</b></span><span>Pos: <b>'+(s.position||0).toFixed(4)+'</b></span></div>'+
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
document.getElementById(sgridId).innerHTML=h;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════ Fee toggle ═══════════
|
|
||||||
var currentBTName=null;
|
|
||||||
function toggleFees(){
|
|
||||||
feeOn=document.getElementById('fee-toggle').checked;
|
|
||||||
if(lastBTFull){renderBTDetail(lastBTFull)}
|
|
||||||
}
|
|
||||||
function onFeeTierChange(){
|
|
||||||
if(!currentBTName)return;
|
|
||||||
var ft=document.getElementById('fee-tier-sel').value;
|
|
||||||
var st=document.getElementById('stake-tier-sel').value;
|
|
||||||
document.getElementById('det-trades').innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--tx);padding:20px">Recalculating with '+document.getElementById('fee-tier-sel').selectedOptions[0].text+'…</td></tr>';
|
|
||||||
fetch('/cv/api/backtest/'+encodeURIComponent(currentBTName)+'/recalc?fee_tier='+ft+'&staking_tier='+st)
|
|
||||||
.then(function(r){return r.json()}).then(function(full){
|
|
||||||
lastBTFull=full; renderBTDetail(full);
|
|
||||||
}).catch(function(e){
|
|
||||||
document.getElementById('det-trades').innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--rd);padding:20px">Recalc failed: '+e.message+'</td></tr>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════ Render backtest detail with fee toggle ──
|
|
||||||
function renderBTDetail(full){
|
|
||||||
var pnl=feeOn?(full.pnl_net||full.pnl||0):(full.pnl_gross||full.pnl||0);
|
|
||||||
var pnlPct=feeOn?(full.pnl_net_pct||full.pnl_pct||0):(full.pnl_gross_pct||full.pnl_pct||0);
|
|
||||||
var fees=full.fees_total||0;
|
|
||||||
var strat=full.strategy||'';
|
|
||||||
document.getElementById('det-name').textContent=strat+(feeOn?' (net of fees)':' (gross, no fees)');
|
|
||||||
document.getElementById('det-desc').textContent=strat+' — '+full.num_periods+' periods, '+full.total_trades+' trades, fees $'+fees.toFixed(2)+', fee model: '+(full.fee_model||'taker');
|
|
||||||
document.getElementById('det-stats').innerHTML=
|
|
||||||
'<div class="stat"><div class="lbl">'+(feeOn?'Net PnL':'Gross PnL')+'</div><div class="val '+(pnlPct>=0?'up':'dn')+'">'+(pnlPct>=0?'+':'')+pnlPct.toFixed(2)+'%</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Sharpe</div><div class="val">'+(full.sharpe||0).toFixed(2)+'</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Sortino</div><div class="val">'+(full.sortino||0).toFixed(2)+'</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Max DD</div><div class="val dn">'+(full.max_dd*100).toFixed(2)+'%</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Win Rate</div><div class="val">'+Math.round((full.win_rate||0)*100)+'%</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Fees</div><div class="val '+(feeOn?'dn':'')+'">$'+fees.toFixed(2)+(feeOn?'':' (excl)')+'</div></div>';
|
|
||||||
// Equity chart
|
|
||||||
if(!detChart)initDetChart();
|
|
||||||
var pts=[],curve=full.equity_curve||[];
|
|
||||||
for(var i=0;i<curve.length;i++){if(curve[i]&&curve[i].t)var ct=curve[i].t;if(typeof ct==="string")ct=Math.floor(new Date(ct).getTime()/1000);pts.push({time:ct,value:curve[i].v})}
|
|
||||||
if(pts.length>0){detSer.setData(pts);detChart.timeScale().fitContent();setTimeout(function(){if(detChart){detChart.timeScale().fitContent();detChart.applyOptions({width:document.getElementById('det-chart').offsetWidth,height:280})}},250)}
|
|
||||||
// Trades table (show pnl_net or pnl_gross based on toggle)
|
|
||||||
var trows='',tlist=full.trades||[];
|
|
||||||
for(var j=Math.max(0,tlist.length-100);j<tlist.length;j++){
|
|
||||||
var t=tlist[j];
|
|
||||||
var tp=feeOn?(t.pnl_net||t.pnl||0):(t.pnl_gross||t.pnl||0);
|
|
||||||
var tf=t.fee||0;
|
|
||||||
var tside=(t.side||'').toUpperCase();
|
|
||||||
trows+='<tr><td>'+(t.time||'').substr(0,16)+'</td><td class="'+(tside.indexOf('BUY')>=0?'green':'red')+'">'+tside+'</td><td>'+t.size+'</td><td>$'+(t.price||0).toFixed(1)+'</td><td class="'+(tp>=0?'green':'red')+'">'+(tp>=0?'+':'')+'$'+Math.abs(tp).toFixed(4)+'</td><td class="'+(tf>0?'red':'')+'">$'+tf.toFixed(4)+'</td><td class="reason">—</td></tr>';
|
|
||||||
}
|
|
||||||
document.getElementById('det-trades').innerHTML=trows||'<tr><td colspan="7" style="text-align:center;color:var(--tx);padding:20px">No trades recorded</td></tr>';
|
|
||||||
setTimeout(function(){if(detChart)detChart.applyOptions({width:document.getElementById('det-chart').offsetWidth,height:280})},300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════ Open strategy detail ═══════════
|
|
||||||
function openDetail(name,tab){
|
|
||||||
document.getElementById('detail-overlay').classList.add('on');
|
|
||||||
document.getElementById('det-name').textContent=name;
|
|
||||||
var ss=null, equity={}, trades=[];
|
|
||||||
if(tab==='paper'&&lastPaper){
|
|
||||||
ss=lastPaper.strategies||{}; equity=lastPaper.strategy_equity||{};
|
|
||||||
trades=(lastPaper.per_strategy_trades||{})[name]||[];
|
|
||||||
} else if(tab==='live'&&lastData){
|
|
||||||
ss=lastData.strategies||{};
|
|
||||||
// Live node doesn't send per-strategy equity — use overall equity_history
|
|
||||||
equity=lastData.equity_history||[];
|
|
||||||
// Filter trades by strategy name
|
|
||||||
var allTrades=lastData.trades||[];
|
|
||||||
trades=allTrades.filter(function(t){return t.strategy===name||t.id===name});
|
|
||||||
} else if(tab==='backtest'&&lastBT&&lastBT[name]){
|
|
||||||
var b=lastBT[name];
|
|
||||||
currentBTName=b.name;
|
|
||||||
document.getElementById('fee-toggle-wrap').style.display='inline';
|
|
||||||
document.getElementById('fee-toggle').checked=true; feeOn=true;
|
|
||||||
document.getElementById('fee-tier-sel').style.display='inline';
|
|
||||||
document.getElementById('stake-tier-sel').style.display='inline';
|
|
||||||
document.getElementById('dl-csv').style.display='inline';
|
|
||||||
document.getElementById('dl-csv').href='/cv/api/backtest/'+encodeURIComponent(b.name)+'/csv';
|
|
||||||
document.getElementById('det-desc').textContent='';
|
|
||||||
document.getElementById('det-stats').innerHTML='<div class="stat"><div class="lbl">Loading</div><div class="val">…</div></div>';
|
|
||||||
if(detSer)detSer.setData([]);
|
|
||||||
document.getElementById('det-trades').innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--tx);padding:20px">Loading full trade data…</td></tr>';
|
|
||||||
fetch('/cv/api/backtest/'+encodeURIComponent(b.name)).then(function(r){return r.json()}).then(function(full){
|
|
||||||
lastBTFull=full; renderBTDetail(full);
|
|
||||||
}).catch(function(e){
|
|
||||||
document.getElementById('det-trades').innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--rd);padding:20px">Failed to load: '+e.message+'</td></tr>';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var s=ss?ss[name]:null;
|
|
||||||
if(!s){closeDetail();return}
|
|
||||||
|
|
||||||
// Description
|
|
||||||
document.getElementById('det-desc').textContent=s.description||'No description available.';
|
|
||||||
|
|
||||||
// Stats
|
|
||||||
var sp=s.pnl||0;
|
|
||||||
document.getElementById('det-stats').innerHTML='<div class="stat"><div class="lbl">PnL</div><div class="val '+(sp>=0?'up':'dn')+'">'+(sp>=0?'+':'')+'$'+Math.abs(sp).toFixed(4)+'</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">PnL%</div><div class="val '+(sp>=0?'up':'dn')+'">'+(s.pnl_pct>=0?'+':'')+(s.pnl_pct||0).toFixed(2)+'%</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Trades</div><div class="val">'+(s.trades_today||0)+'</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Win Rate</div><div class="val">'+Math.round((s.win_rate||0)*100)+'%</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Fees Paid</div><div class="val dn">$'+(s.fee_paid||0).toFixed(4)+'</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Position</div><div class="val">'+(s.position||0).toFixed(4)+'</div></div>';
|
|
||||||
|
|
||||||
// Equity chart
|
|
||||||
if(!detChart)initDetChart();
|
|
||||||
var eqData=Array.isArray(equity)?equity:(equity[name]||[]);
|
|
||||||
if(eqData.length>0){
|
|
||||||
var pts=[];for(var i=0;i<eqData.length;i++){if(eqData[i]&&eqData[i].t){var edt=eqData[i].t;if(typeof edt==='string')edt=Math.floor(new Date(edt).getTime()/1000);pts.push({time:edt,value:eqData[i].v})}}
|
|
||||||
detSer.setData(pts);detChart.timeScale().fitContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trades
|
|
||||||
var rows='';
|
|
||||||
for(var j=Math.max(0,trades.length-50);j<trades.length;j++){
|
|
||||||
var t=trades[j],tp=t.pnl||0;
|
|
||||||
rows+='<tr><td>'+t.time+'</td><td class="'+(t.side==='BUY'?'green':'red')+'">'+t.side+'</td><td>'+t.size+'</td><td>$'+t.price+'</td><td class="'+(tp>=0?'green':'red')+'">'+(tp>=0?'+':'')+'$'+Math.abs(tp).toFixed(4)+'</td><td class="red">$'+(t.fee||0).toFixed(4)+'</td><td class="reason" title="'+t.reason+'">'+(t.reason||'—')+'</td></tr>';
|
|
||||||
}
|
|
||||||
document.getElementById('det-trades').innerHTML=rows||'<tr><td colspan="7" style="text-align:center;color:var(--tx);padding:20px">No trades yet</td></tr>';
|
|
||||||
|
|
||||||
// Resize chart
|
|
||||||
setTimeout(function(){if(detChart){detChart.applyOptions({width:document.getElementById('det-chart').offsetWidth,height:280});detChart.timeScale().fitContent()}},300);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDetail(){document.getElementById('detail-overlay').classList.remove('on');document.getElementById('fee-toggle-wrap').style.display='none';document.getElementById('fee-tier-sel').style.display='none';document.getElementById('stake-tier-sel').style.display='none';document.getElementById('dl-csv').style.display='none';lastBTFull=null;currentBTName=null}
|
|
||||||
document.addEventListener('keydown',function(e){if(e.key==='Escape')closeDetail()});
|
|
||||||
|
|
||||||
// ═══════════ WebSocket + render ═══════════
|
|
||||||
var ws,wsPaper;
|
|
||||||
function connect(){
|
|
||||||
if(ws)try{ws.close()}catch(e){}
|
|
||||||
ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/cv/ws');
|
|
||||||
ws.onopen=function(){document.getElementById('sdot').className='status-dot'};
|
|
||||||
ws.onclose=function(){document.getElementById('sdot').className='status-dot off';setTimeout(connect,5000)};
|
|
||||||
ws.onmessage=function(e){try{lastData=JSON.parse(e.data)}catch(ex){return};if(currentTab==='live')renLive(lastData)};
|
|
||||||
if(wsPaper)try{wsPaper.close()}catch(e){}
|
|
||||||
wsPaper=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/cv/ws/paper');
|
|
||||||
wsPaper.onmessage=function(e){try{lastPaper=JSON.parse(e.data)}catch(ex){return};if(currentTab==='paper')renPaper(lastPaper)};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renLive(d){if(!d)return;var p=d.total_pnl||0;document.getElementById('stpnl').textContent=(p>=0?'+':'')+'$'+Math.abs(p).toFixed(2);document.getElementById('stpnl').className='pnl '+(p>=0?'up':'dn');document.getElementById('stpct').textContent='Testnet · Equity: $'+((d.base_equity||898)+p).toFixed(2);renCards("live-sgrid",d.strategies||{},d.base_equity||898,"live","live-stats");if(d.equity_history&&chartLive)pushEquity(chartLive,serLive,d.equity_history)}
|
|
||||||
function renPaper(d){if(!d)return;var p=d.total_pnl||0;document.getElementById('stpnl').textContent=(p>=0?'+':'')+'$'+Math.abs(p).toFixed(2);document.getElementById('stpnl').className='pnl '+(p>=0?'up':'dn');document.getElementById('stpct').textContent='Paper · '+d.total_equity+' · Regime: '+(d.regime||'—');renCards("paper-sgrid",d.strategies||{},d.base_equity||100000,"paper","paper-stats");if(d.equity_history&&chartPaper)pushEquity(chartPaper,serPaper,d.equity_history)}
|
|
||||||
|
|
||||||
// ═══════════ Backtests ═══════════
|
|
||||||
var lastBT={}, lastBTList=[];
|
|
||||||
function loadBT(){
|
|
||||||
fetch('/cv/api/backtests').then(function(r){return r.json()}).then(function(data){
|
|
||||||
lastBTList=data; lastBT={};
|
|
||||||
// Keep latest backtest per strategy (sorted by time desc — first wins)
|
|
||||||
for(var i=0;i<data.length;i++){var b=data[i];if(!lastBT[b.strategy])lastBT[b.strategy]=b;}
|
|
||||||
var h='';
|
|
||||||
for(var s in lastBT){var b=lastBT[s];var pnl=b.pnl_pct||0;
|
|
||||||
h+='<div class=\"scard\" onclick=\"openDetail(\''+s+'\',\'backtest\')\"><div class=\"sh\"><div><div class=\"sname\">'+s+'</div><div class=\"salloc\">30-day · $100</div></div><span class=\"stag run\">BACKTEST</span></div><div class=\"spnl '+(pnl>=0?'up':'dn')+'\">'+(pnl>=0?'+':'')+pnl.toFixed(2)+'%</div><div class=\"smeta\"><span>Sharpe: <b>'+b.sharpe.toFixed(2)+'</b></span><span>DD: <b class=\"red\">'+(b.max_dd*100).toFixed(2)+'%</b></span><span>Win: <b>'+Math.round(b.win_rate*100)+'%</b></span></div></div>';
|
|
||||||
}
|
|
||||||
document.getElementById('bt-sgrid').innerHTML=h||'<div style=\"padding:20px;color:var(--tx)\">No backtests.</div>';
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════ Historical backtests ═══════════
|
|
||||||
var lastHist={};
|
|
||||||
function loadHistBT(){
|
|
||||||
fetch('/cv/api/backtests/historical').then(function(r){return r.json()}).then(function(data){
|
|
||||||
lastHist={};
|
|
||||||
for(var i=0;i<data.length;i++){var b=data[i];if(!lastHist[b.strategy])lastHist[b.strategy]=b;}
|
|
||||||
var h='';
|
|
||||||
for(var s in lastHist){var b=lastHist[s];var pnl=b.pnl_pct||0;
|
|
||||||
h+='<div class="scard" data-strat="'+s+'" onclick="openHistDetail(this.dataset.strat)"><div class="sh"><div><div class="sname">'+s+'</div><div class="salloc">30d '+b.coin+' · Mainnet</div></div><span class="stag run">REAL DATA</span></div><div class="spnl '+(pnl>=0?'up':'dn')+'">'+(pnl>=0?'+':'')+pnl.toFixed(2)+'%</div><div class="smeta"><span>Sharpe: <b>'+b.sharpe.toFixed(2)+'</b></span><span>DD: <b class="red">'+(b.max_dd*100).toFixed(2)+'%</b></span><span>Win: <b>'+Math.round(b.win_rate*100)+'%</b></span></div></div>';
|
|
||||||
}
|
|
||||||
document.getElementById('hist-sgrid').innerHTML=h||'<div style="padding:20px;color:var(--tx)">No historical backtests. Run: python backtests/historical_runner.py --coin BTC --strategy all</div>';
|
|
||||||
})
|
|
||||||
}
|
|
||||||
function openHistDetail(strat){
|
|
||||||
var b=lastHist[strat];if(!b)return;
|
|
||||||
document.getElementById('detail-overlay').classList.add('on');
|
|
||||||
document.getElementById('fee-toggle-wrap').style.display='inline';
|
|
||||||
document.getElementById('fee-tier-sel').style.display='inline';
|
|
||||||
document.getElementById('stake-tier-sel').style.display='inline';
|
|
||||||
document.getElementById('dl-csv').style.display='none';
|
|
||||||
document.getElementById('fee-toggle').checked=true; feeOn=true; currentBTName=b.name;
|
|
||||||
document.getElementById('det-name').textContent=strat+' (Historical '+b.coin+')';
|
|
||||||
fetch('/cv/api/backtest/historical/'+encodeURIComponent(b.name)).then(function(r){return r.json()}).then(function(full){
|
|
||||||
lastBTFull=full; renderBTDetail(full);
|
|
||||||
}).catch(function(e){
|
|
||||||
document.getElementById('det-trades').innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--rd);padding:20px">Failed: '+e.message+'</td></tr>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════ Init ═══════════
|
|
||||||
initDetChart();initMainCharts();connect();loadBT();loadHistBT();
|
|
||||||
// ═══════════ Risk Analytics ═══════════
|
|
||||||
function toggleRisk(){
|
|
||||||
var p=document.getElementById('risk-panel'),b=document.getElementById('risk-btn');
|
|
||||||
p.classList.toggle('show');b.classList.toggle('open');
|
|
||||||
if(p.classList.contains('show')&&!p.dataset.loaded){loadRisk();p.dataset.loaded='1'}
|
|
||||||
}
|
|
||||||
function loadRisk(){
|
|
||||||
fetch('/cv/api/risk').then(function(r){return r.json()}).then(function(d){
|
|
||||||
if(d.error){document.getElementById('risk-stats').innerHTML='<div style="color:var(--tx);padding:8px">'+d.error+'</div>';return}
|
|
||||||
var pf=d.portfolio||{};
|
|
||||||
document.getElementById('risk-stats').innerHTML=
|
|
||||||
'<div class="stat"><div class="lbl">VaR 95%</div><div class="val dn">'+(pf.var_95*100).toFixed(2)+'%</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">CVaR 95%</div><div class="val dn">'+(pf.cvar_95*100).toFixed(2)+'%</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Max DD</div><div class="val dn">'+(pf.max_drawdown*100).toFixed(2)+'%</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Calmar</div><div class="val '+(pf.calmar_ratio>=0?'up':'dn')+'">'+pf.calmar_ratio.toFixed(2)+'</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Sharpe</div><div class="val '+(pf.sharpe>=0?'up':'dn')+'">'+pf.sharpe.toFixed(2)+'</div></div>'+
|
|
||||||
'<div class="stat"><div class="lbl">Sortino</div><div class="val">'+pf.sortino.toFixed(2)+'</div></div>';
|
|
||||||
// Correlation summary
|
|
||||||
var cs=d.correlation_summary||[];
|
|
||||||
var ch='<div style="font-size:10px;color:var(--tx);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">Strategy Correlations (|ρ| > 0.3)</div>';
|
|
||||||
if(cs.length===0){ch+='<span style="color:var(--tx)">No significant correlations found — strategies are well-diversified.</span>'}
|
|
||||||
else{for(var i=0;i<cs.length;i++){var c=cs[i],cls=c.level==='high'?'corr-high':'corr-med';ch+='<div><span class="'+cls+'">ρ='+(c.correlation>=0?'+':'')+c.correlation.toFixed(3)+'</span> '+c.pair+'</div>'}}
|
|
||||||
document.getElementById('risk-corr').innerHTML=ch;
|
|
||||||
// Mark loaded + store timestamp
|
|
||||||
window._riskLoaded=Date.now();
|
|
||||||
}).catch(function(e){document.getElementById('risk-stats').innerHTML='<div style="color:var(--rd);padding:8px">Failed: '+e.message+'</div>'})
|
|
||||||
}
|
|
||||||
// Auto-refresh risk panel when paper data updates (throttled to every 30s)
|
|
||||||
var _origRenPaper=renPaper;
|
|
||||||
renPaper=function(d){
|
|
||||||
_origRenPaper(d);
|
|
||||||
var p=document.getElementById('risk-panel');
|
|
||||||
if(p&&p.classList.contains('show')&&(!window._riskLoaded||Date.now()-window._riskLoaded>30000)){
|
|
||||||
loadRisk();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FTDT Quant Lab — VectorBT Dashboard</title>
|
||||||
|
<script src="https://cdn.plot.ly/plotly-3.1.0.min.js"></script>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:'Ubuntu',-apple-system,sans-serif;background:#0a0e17;color:#c8d6e5;min-height:100vh}
|
||||||
|
.header{background:#111827;border-bottom:1px solid #1e293b;padding:12px 24px;display:flex;justify-content:space-between;align-items:center}
|
||||||
|
.header h1{font-size:18px;color:#e2e8f0}
|
||||||
|
.header span{font-size:12px;color:#64748b}
|
||||||
|
.main{display:grid;grid-template-columns:300px 1fr;height:calc(100vh - 49px)}
|
||||||
|
.sidebar{background:#0f172a;border-right:1px solid #1e293b;overflow-y:auto;padding:12px}
|
||||||
|
.sidebar h3{font-size:12px;text-transform:uppercase;color:#64748b;margin:12px 0 6px;letter-spacing:1px}
|
||||||
|
.result-item{background:#1e293b;border:1px solid #334155;border-radius:6px;padding:10px;margin-bottom:6px;cursor:pointer;transition:border-color .15s}
|
||||||
|
.result-item:hover{border-color:#3b82f6}
|
||||||
|
.result-item.active{border-color:#3b82f6;background:#1e3a5f}
|
||||||
|
.result-item .name{font-size:14px;font-weight:600;color:#e2e8f0}
|
||||||
|
.result-item .meta{font-size:11px;color:#64748b;margin-top:3px}
|
||||||
|
.result-item .stats{display:flex;gap:10px;margin-top:5px;font-size:11px}
|
||||||
|
.stat-pos{color:#34d399}.stat-neg{color:#f87171}.stat-neutral{color:#94a3b8}
|
||||||
|
.content{padding:20px;overflow-y:auto}
|
||||||
|
.metrics-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px}
|
||||||
|
.metric-card{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:14px;text-align:center}
|
||||||
|
.metric-card .label{font-size:11px;text-transform:uppercase;color:#64748b;letter-spacing:0.5px;margin-bottom:4px}
|
||||||
|
.metric-card .value{font-size:24px;font-weight:700}
|
||||||
|
.chart-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px}
|
||||||
|
.chart-box{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:12px}
|
||||||
|
.chart-box h4{font-size:12px;color:#64748b;text-transform:uppercase;margin-bottom:8px;letter-spacing:0.5px}
|
||||||
|
.chart-full{grid-column:1/-1}
|
||||||
|
.empty-state{text-align:center;padding:60px 20px;color:#64748b}
|
||||||
|
.empty-state h2{font-size:16px;margin-bottom:8px}
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:6px;font-size:13px;cursor:pointer;border:1px solid #334155;background:#1e293b;color:#c8d6e5;transition:all .15s}
|
||||||
|
.btn:hover{background:#334155;border-color:#475569}
|
||||||
|
.btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff}
|
||||||
|
.btn-primary:hover{background:#2563eb}
|
||||||
|
.btn-sm{padding:4px 10px;font-size:11px}
|
||||||
|
.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:16px;flex-wrap:wrap}
|
||||||
|
select,input{background:#1e293b;border:1px solid #334155;color:#c8d6e5;border-radius:6px;padding:6px 10px;font-size:13px}
|
||||||
|
select:focus,input:focus{outline:none;border-color:#3b82f6}
|
||||||
|
.loading{text-align:center;padding:40px;color:#64748b}
|
||||||
|
.sweep-table{width:100%;border-collapse:collapse;font-size:12px;margin-top:8px}
|
||||||
|
.sweep-table th{text-align:left;padding:6px 10px;border-bottom:1px solid #334155;color:#64748b;font-weight:500}
|
||||||
|
.sweep-table td{padding:5px 10px;border-bottom:1px solid #1e293b}
|
||||||
|
.sweep-table tr:hover{background:#1e293b}
|
||||||
|
.sweep-best{background:rgba(34,197,94,.08)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>VectorBT Dashboard <span style="font-size:11px;color:#3b82f6;margin-left:8px">Hyperliquid data</span></h1>
|
||||||
|
<span id="last-update"></span>
|
||||||
|
</div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="toolbar" style="flex-direction:column;align-items:stretch">
|
||||||
|
<select id="strategy-filter" onchange="loadResults()" style="width:100%">
|
||||||
|
<option value="">All strategies</option>
|
||||||
|
<option value="pairs">Pairs Trading</option>
|
||||||
|
<option value="hurst_vpin">Hurst VPIN</option>
|
||||||
|
<option value="as_mm">A-S MM</option>
|
||||||
|
<option value="momentum">Momentum</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="runBacktest()" style="justify-content:center">+ Run Backtest</button>
|
||||||
|
</div>
|
||||||
|
<h3>Results</h3>
|
||||||
|
<div id="results-list">
|
||||||
|
<div class="loading">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content" id="content">
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>Select a backtest result</h2>
|
||||||
|
<p style="font-size:13px">Choose from the sidebar or run a new VectorBT backtest</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '';
|
||||||
|
let currentResult = null;
|
||||||
|
|
||||||
|
async function loadResults() {
|
||||||
|
const strat = document.getElementById('strategy-filter').value;
|
||||||
|
const url = strat ? `${API}/api/vbt/results?strategy=${strat}&limit=100` : `${API}/api/vbt/results?limit=100`;
|
||||||
|
try {
|
||||||
|
const r = await fetch(url);
|
||||||
|
const data = await r.json();
|
||||||
|
renderResultsList(data);
|
||||||
|
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('results-list').innerHTML = '<div class="loading">Error loading</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResultsList(results) {
|
||||||
|
const el = document.getElementById('results-list');
|
||||||
|
if (!results.length) {
|
||||||
|
el.innerHTML = '<div style="padding:12px;color:#64748b;font-size:12px">No results yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = results.map((r,i) => `
|
||||||
|
<div class="result-item${i===0&&!currentResult?' active':''}" onclick="selectResult('${r.filename}')" id="item-${r.filename}">
|
||||||
|
<div class="name">${r.strategy}</div>
|
||||||
|
<div class="meta">${r.interval || '1h'} · ${r.total_trades||0} trades · ${r.n_bars||0} bars</div>
|
||||||
|
<div class="stats">
|
||||||
|
<span class="${r.sharpe>0?'stat-pos':(r.sharpe<0?'stat-neg':'stat-neutral')}">Sharpe ${r.sharpe?.toFixed(2)||0}</span>
|
||||||
|
<span class="${r.total_return_pct>0?'stat-pos':(r.total_return_pct<0?'stat-neg':'stat-neutral')}">${r.total_return_pct?.toFixed(1)||0}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectResult(filename) {
|
||||||
|
document.querySelectorAll('.result-item').forEach(el => el.classList.remove('active'));
|
||||||
|
document.getElementById('item-'+filename)?.classList.add('active');
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/api/vbt/result/${filename}`);
|
||||||
|
currentResult = await r.json();
|
||||||
|
renderDetail(currentResult);
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('content').innerHTML = '<div class="loading">Error loading result</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(r) {
|
||||||
|
const ret = r.total_return_pct || 0;
|
||||||
|
const dd = r.max_drawdown_pct || 0;
|
||||||
|
const sharpe = r.sharpe || 0;
|
||||||
|
const wr = (r.win_rate||0) * 100;
|
||||||
|
const pf = r.profit_factor || 0;
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<h3 style="margin-bottom:4px">${r.strategy} <span style="font-size:12px;color:#64748b">${r.engine||'vectorbt'} · ${r.interval||'1h'}</span></h3>
|
||||||
|
<div style="font-size:11px;color:#64748b;margin-bottom:16px">${r.total_trades||0} trades · ${r.n_bars||0} bars · ${r.generated_at||''}</div>
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div class="metric-card"><div class="label">Total Return</div><div class="value ${ret>=0?'stat-pos':'stat-neg'}">${ret.toFixed(2)}%</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Sharpe</div><div class="value ${sharpe>=0?'stat-pos':'stat-neg'}">${sharpe.toFixed(2)}</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Max Drawdown</div><div class="value stat-neg">${dd.toFixed(2)}%</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Win Rate</div><div class="value ${wr>=50?'stat-pos':'stat-neg'}">${wr.toFixed(0)}%</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Profit Factor</div><div class="value ${pf>=1?'stat-pos':'stat-neg'}">${pf.toFixed(2)}</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Total Trades</div><div class="value stat-neutral">${r.total_trades||0}</div></div>
|
||||||
|
<div class="metric-card"><div class="label">End Equity</div><div class="value stat-neutral">$${((r.end_equity||10000)).toFixed(0)}</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Sortino</div><div class="value stat-neutral">${(r.sortino||0).toFixed(2)}</div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('content').innerHTML = html + `
|
||||||
|
<div class="chart-row">
|
||||||
|
<div class="chart-box chart-full"><h4>Equity Curve</h4><div id="chart-equity" style="height:300px"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-row">
|
||||||
|
<div class="chart-box"><h4>Drawdown</h4><div id="chart-dd" style="height:250px"></div></div>
|
||||||
|
<div class="chart-box"><h4>Returns Distribution</h4><div id="chart-returns" style="height:250px"></div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
renderCharts(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCharts(r) {
|
||||||
|
const ec = r.equity_curve || [];
|
||||||
|
if (!ec.length) return;
|
||||||
|
|
||||||
|
const times = ec.map(p => p.t);
|
||||||
|
const values = ec.map(p => p.v);
|
||||||
|
|
||||||
|
// Equity curve
|
||||||
|
const eqTrace = {
|
||||||
|
x: times, y: values, type: 'scatter', mode: 'lines',
|
||||||
|
line: {color: '#3b82f6', width: 1.5},
|
||||||
|
fill: 'tozeroy', fillcolor: 'rgba(59,130,246,0.08)',
|
||||||
|
name: 'Equity'
|
||||||
|
};
|
||||||
|
Plotly.newPlot('chart-equity', [eqTrace], {
|
||||||
|
margin: {t:5,r:15,b:30,l:55},
|
||||||
|
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
|
||||||
|
xaxis: {gridcolor: '#1e293b', tickfont: {color: '#64748b', size: 10}, showgrid: true},
|
||||||
|
yaxis: {gridcolor: '#1e293b', tickfont: {color: '#64748b', size: 10}, showgrid: true},
|
||||||
|
showlegend: false,
|
||||||
|
}, {responsive: true, displayModeBar: false});
|
||||||
|
|
||||||
|
// Drawdown
|
||||||
|
const peak = values.slice(1).reduce((arr, v, i) => {arr.push(Math.max(arr[i]||arr[0]||v, v)); return arr;}, [values[0]]);
|
||||||
|
const dd = values.map((v, i) => i === 0 ? 0 : -((peak[i] - v) / peak[i]) * 100);
|
||||||
|
Plotly.newPlot('chart-dd', [{
|
||||||
|
x: times, y: dd, type: 'scatter', mode: 'none',
|
||||||
|
fill: 'tozeroy', fillcolor: 'rgba(248,113,113,0.15)',
|
||||||
|
line: {color: '#f87171', width: 1},
|
||||||
|
name: 'Drawdown %'
|
||||||
|
}], {
|
||||||
|
margin: {t:5,r:15,b:30,l:55},
|
||||||
|
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
|
||||||
|
xaxis: {gridcolor: '#1e293b', tickfont: {color: '#64748b', size: 10}},
|
||||||
|
yaxis: {gridcolor: '#1e293b', tickfont: {color: '#64748b', size: 10}, ticksuffix: '%'},
|
||||||
|
showlegend: false,
|
||||||
|
}, {responsive: true, displayModeBar: false});
|
||||||
|
|
||||||
|
// Returns histogram
|
||||||
|
if (values.length > 1) {
|
||||||
|
const rets = values.slice(1).map((v, i) => (v - values[i]) / values[i] * 100);
|
||||||
|
Plotly.newPlot('chart-returns', [{
|
||||||
|
x: rets, type: 'histogram',
|
||||||
|
marker: {color: '#3b82f6', opacity: 0.7, line: {color: '#1e293b', width: 1}},
|
||||||
|
nbinsx: 30,
|
||||||
|
name: 'Returns'
|
||||||
|
}], {
|
||||||
|
margin: {t:5,r:15,b:30,l:45},
|
||||||
|
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
|
||||||
|
xaxis: {gridcolor: '#1e293b', tickfont: {color: '#64748b', size: 10}, ticksuffix: '%'},
|
||||||
|
yaxis: {gridcolor: '#1e293b', tickfont: {color: '#64748b', size: 10}},
|
||||||
|
showlegend: false,
|
||||||
|
bargap: 0.05,
|
||||||
|
}, {responsive: true, displayModeBar: false});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBacktest() {
|
||||||
|
const strat = document.getElementById('strategy-filter').value || 'pairs';
|
||||||
|
const btn = document.querySelector('.btn-primary');
|
||||||
|
btn.textContent = 'Running...';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/api/vbt/run?strategy=${strat}&interval=1h&limit=500`);
|
||||||
|
const data = await r.json();
|
||||||
|
if (data.error) { alert('Error: ' + data.error); return; }
|
||||||
|
loadResults();
|
||||||
|
selectResult(data.filename);
|
||||||
|
} catch(e) {
|
||||||
|
alert('Failed: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
btn.textContent = '+ Run Backtest';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
loadResults();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
FTDT Quant Lab — NautilusTrader + VectorBT Framework.
|
||||||
|
|
||||||
|
Unified pipeline: Hyperliquid data → VectorBT fast backtest →
|
||||||
|
NautilusTrader event-driven backtest → paper trading → live deployment.
|
||||||
|
|
||||||
|
Core components:
|
||||||
|
- data: HyperliquidDataProvider (historical + streaming)
|
||||||
|
- instruments: HyperliquidInstrumentCatalog (CryptoPerpetual loader)
|
||||||
|
- execution: HyperliquidExecutionProvider (live + paper)
|
||||||
|
- base_strategy: BaseHlStrategy (shared NT lifecycle)
|
||||||
|
- config: StrategyConfig (YAML parameter management)
|
||||||
|
- deploy: DeployOrchestrator (backtest → paper → live CLI)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from framework.instruments import HyperliquidInstrumentCatalog
|
||||||
|
from framework.data import HyperliquidDataProvider
|
||||||
|
from framework.base_strategy import BaseHlStrategy, StrategyConfig
|
||||||
|
from framework.deploy import DeployOrchestrator
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HyperliquidInstrumentCatalog",
|
||||||
|
"HyperliquidDataProvider",
|
||||||
|
"BaseHlStrategy",
|
||||||
|
"StrategyConfig",
|
||||||
|
"DeployOrchestrator",
|
||||||
|
]
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""
|
||||||
|
Base strategy class for NautilusTrader + Hyperliquid.
|
||||||
|
|
||||||
|
Provides shared lifecycle for all FTDT strategies:
|
||||||
|
- Instrument resolution from Hyperliquid catalog
|
||||||
|
- Fee-aware position sizing from StrategyConfig
|
||||||
|
- Shared signal pipeline (OBI, Hurst, VPIN computations)
|
||||||
|
- on_start / on_bar / on_stop hooks
|
||||||
|
|
||||||
|
Strategies inherit this and override signal logic.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import deque
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from nautilus_trader.common.actor import Actor
|
||||||
|
from nautilus_trader.model.data import Bar, BarSpecification, BarType
|
||||||
|
from nautilus_trader.model.enums import BarAggregation, OrderSide, PriceType
|
||||||
|
from nautilus_trader.model.identifiers import InstrumentId
|
||||||
|
from nautilus_trader.model.objects import Price, Quantity
|
||||||
|
from nautilus_trader.trading.strategy import Strategy
|
||||||
|
|
||||||
|
from framework.config import StrategyConfig
|
||||||
|
from framework.data import HyperliquidDataProvider
|
||||||
|
from framework.instruments import HyperliquidInstrumentCatalog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseHlStrategy(Strategy):
|
||||||
|
"""Base strategy with Hyperliquid-specific utilities.
|
||||||
|
|
||||||
|
Inherits NautilusTrader Strategy lifecycle:
|
||||||
|
on_start → on_bar (repeated) → on_stop
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: StrategyConfig):
|
||||||
|
super().__init__()
|
||||||
|
self._cfg = config
|
||||||
|
self._instrument: InstrumentId | None = None
|
||||||
|
self._asset = config.asset
|
||||||
|
|
||||||
|
# Price history for signal calculations
|
||||||
|
self._prices: deque[float] = deque(maxlen=300)
|
||||||
|
|
||||||
|
# Signal state
|
||||||
|
self._last_signal: dict[str, Any] | None = None
|
||||||
|
self._position_open: bool = False
|
||||||
|
self._entry_price: float = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> StrategyConfig:
|
||||||
|
return self._cfg
|
||||||
|
|
||||||
|
@property
|
||||||
|
def instrument_id(self) -> InstrumentId | None:
|
||||||
|
return self._instrument
|
||||||
|
|
||||||
|
# ── Lifecycle ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
"""Called when strategy is started. Resolve instruments."""
|
||||||
|
if not self._instrument:
|
||||||
|
# Try to resolve from catalog
|
||||||
|
catalog = HyperliquidInstrumentCatalog(testnet=self._cfg.testnet)
|
||||||
|
inst_map = catalog.load(assets=[self._asset])
|
||||||
|
inst = inst_map.get(self._asset.upper())
|
||||||
|
if inst:
|
||||||
|
self._instrument = inst.id
|
||||||
|
else:
|
||||||
|
self._instrument = InstrumentId.from_str(
|
||||||
|
f"{self._asset.upper()}-USD-PERP.HYPERLIQUID"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Subscribe to 1-minute bars
|
||||||
|
bar_spec = BarSpecification(1, BarAggregation.MINUTE, PriceType.LAST)
|
||||||
|
bar_type = BarType(self._instrument, bar_spec)
|
||||||
|
self.subscribe_bars(bar_type)
|
||||||
|
logger.info("%s started on %s", self._cfg.name, self._instrument)
|
||||||
|
|
||||||
|
def on_stop(self):
|
||||||
|
logger.info("%s stopped", self._cfg.name)
|
||||||
|
|
||||||
|
def on_bar(self, bar: Bar):
|
||||||
|
"""Process each bar. Override in subclasses for custom signal logic."""
|
||||||
|
self._prices.append(float(bar.close))
|
||||||
|
signal = self.compute_signal()
|
||||||
|
if signal:
|
||||||
|
self._last_signal = signal
|
||||||
|
self.handle_signal(signal)
|
||||||
|
|
||||||
|
# ── Signal computation (override in subclass) ───────────────
|
||||||
|
|
||||||
|
def compute_signal(self) -> dict[str, Any] | None:
|
||||||
|
"""Override in subclass to compute trading signals."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def handle_signal(self, signal: dict[str, Any]):
|
||||||
|
"""Default: submit a limit order based on signal direction."""
|
||||||
|
side = signal.get("signal", "")
|
||||||
|
strength = signal.get("strength", 0.0)
|
||||||
|
|
||||||
|
# Check minimum strength threshold
|
||||||
|
if strength < 0.15:
|
||||||
|
return
|
||||||
|
|
||||||
|
if "BUY" in str(side).upper():
|
||||||
|
self._submit_order(OrderSide.BUY)
|
||||||
|
elif "SELL" in str(side).upper():
|
||||||
|
self._submit_order(OrderSide.SELL)
|
||||||
|
|
||||||
|
# ── Order submission ──────────────────────────────────────
|
||||||
|
|
||||||
|
def _submit_order(self, side, size: float | None = None):
|
||||||
|
"""Submit a limit order.
|
||||||
|
|
||||||
|
In backtest mode: NT engine handles fill emulation via bars.
|
||||||
|
In live mode: order goes through the execution provider.
|
||||||
|
|
||||||
|
Override in subclass for venue-specific order construction.
|
||||||
|
"""
|
||||||
|
sz = size or self._cfg.order_size
|
||||||
|
price = self._prices[-1] if self._prices else 0.0
|
||||||
|
if price <= 0 or sz <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from nautilus_trader.model.objects import Price, Quantity
|
||||||
|
|
||||||
|
self.submit_order(
|
||||||
|
instrument_id=self._instrument,
|
||||||
|
order_side=side,
|
||||||
|
order_type="LIMIT",
|
||||||
|
quantity=Quantity.from_str(str(sz)),
|
||||||
|
price=Price.from_str(str(int(price))),
|
||||||
|
post_only=True,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError, AttributeError):
|
||||||
|
logger.debug("%s: order not submitted (venue-specific API needed)", self._cfg.name)
|
||||||
|
|
||||||
|
# ── Signal library (shared across strategies) ───────────────
|
||||||
|
|
||||||
|
def signal_zscore(self, window: int = 20, threshold: float = 1.5) -> dict | None:
|
||||||
|
"""Z-score mean reversion signal based on price history."""
|
||||||
|
if len(self._prices) < window:
|
||||||
|
return None
|
||||||
|
prices = list(self._prices)
|
||||||
|
recent = prices[-window:]
|
||||||
|
mu = np.mean(recent)
|
||||||
|
std = np.std(recent, ddof=1)
|
||||||
|
if std <= 0:
|
||||||
|
return None
|
||||||
|
z = (prices[-1] - mu) / std
|
||||||
|
if z > threshold:
|
||||||
|
return {"signal": "SELL", "strength": z / threshold}
|
||||||
|
elif z < -threshold:
|
||||||
|
return {"signal": "BUY", "strength": abs(z) / threshold}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def signal_bollinger(self, window: int = 20, n_std: float = 2.0) -> dict | None:
|
||||||
|
"""Bollinger band breakout signal."""
|
||||||
|
if len(self._prices) < window:
|
||||||
|
return None
|
||||||
|
prices = list(self._prices)
|
||||||
|
recent = prices[-window:]
|
||||||
|
sma = np.mean(recent)
|
||||||
|
std = np.std(recent, ddof=1)
|
||||||
|
if std <= 0:
|
||||||
|
return None
|
||||||
|
cur = prices[-1]
|
||||||
|
if cur > sma + n_std * std:
|
||||||
|
return {"signal": "BUY", "strength": (cur - sma - n_std * std) / std}
|
||||||
|
elif cur < sma - n_std * std:
|
||||||
|
return {"signal": "SELL", "strength": (sma - n_std * std - cur) / std}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def signal_trend(self, window: int = 10, threshold: float = 0.7) -> dict | None:
|
||||||
|
"""Directional trend strength signal."""
|
||||||
|
if len(self._prices) < window:
|
||||||
|
return None
|
||||||
|
prices = list(self._prices)
|
||||||
|
up = sum(1 for i in range(-window + 1, 0) if prices[i + 1] > prices[i])
|
||||||
|
ratio = up / (window - 1)
|
||||||
|
if ratio >= threshold:
|
||||||
|
return {"signal": "BUY", "strength": ratio}
|
||||||
|
elif ratio <= 1.0 - threshold:
|
||||||
|
return {"signal": "SELL", "strength": 1.0 - ratio}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def signal_vwap_deviation(self, window: int = 20, threshold: float = 1.0) -> dict | None:
|
||||||
|
"""VWAP deviation signal (mean-reverting)."""
|
||||||
|
if len(self._prices) < window:
|
||||||
|
return None
|
||||||
|
prices = list(self._prices)
|
||||||
|
prior = prices[-(window + 1):-1]
|
||||||
|
cur = prices[-1]
|
||||||
|
vwap = np.mean(prior)
|
||||||
|
std = np.std(prior, ddof=1)
|
||||||
|
if std <= 0:
|
||||||
|
return None
|
||||||
|
dev = (cur - vwap) / std
|
||||||
|
if dev > threshold:
|
||||||
|
return {"signal": "SELL", "strength": dev / threshold}
|
||||||
|
elif dev < -threshold:
|
||||||
|
return {"signal": "BUY", "strength": abs(dev) / threshold}
|
||||||
|
return None
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Strategy configuration — YAML-based parameter management.
|
||||||
|
|
||||||
|
Each strategy gets a YAML file in config/ with its parameters for
|
||||||
|
backtest, paper, and live environments. The StrategyConfig class
|
||||||
|
loads and validates these configs.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StrategyConfig:
|
||||||
|
"""Unified strategy configuration across backtest / paper / live."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
instrument: str
|
||||||
|
asset: str # Base currency (BTC, ETH, etc.)
|
||||||
|
allocation: float = 10000.0 # Capital allocated
|
||||||
|
order_size: float = 0.001 # Default order size (in base units)
|
||||||
|
maker_fee: float = 0.0002
|
||||||
|
taker_fee: float = 0.0005
|
||||||
|
slippage_bps: float = 1.0
|
||||||
|
testnet: bool = True
|
||||||
|
|
||||||
|
# Signal parameters (strategy-specific)
|
||||||
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Risk
|
||||||
|
max_position: float = 0.0 # 0 = based on allocation / price
|
||||||
|
max_drawdown: float = 0.10
|
||||||
|
stop_loss_pct: float = 0.0 # 0 = no stop
|
||||||
|
|
||||||
|
# Derived
|
||||||
|
fee_model: str = "taker" # taker or maker
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_yaml(cls, path: str | Path) -> StrategyConfig:
|
||||||
|
with open(path) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
|
def to_yaml(self, path: str | Path) -> None:
|
||||||
|
with open(path, "w") as f:
|
||||||
|
yaml.safe_dump(self.__dict__, f, default_flow_style=False)
|
||||||
|
|
||||||
|
def effective_fee(self) -> float:
|
||||||
|
return self.maker_fee if self.fee_model == "maker" else self.taker_fee
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_by_name(cls, name: str, env: str = "paper") -> StrategyConfig:
|
||||||
|
"""Load a strategy config from config/{name}.yaml."""
|
||||||
|
config_path = CONFIG_DIR / f"{name}.yaml"
|
||||||
|
if not config_path.exists():
|
||||||
|
raise FileNotFoundError(f"Config not found: {config_path}")
|
||||||
|
cfg = cls.from_yaml(config_path)
|
||||||
|
if env == "testnet":
|
||||||
|
cfg.testnet = True
|
||||||
|
elif env in ("mainnet", "live"):
|
||||||
|
cfg.testnet = False
|
||||||
|
return cfg
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
Hyperliquid data provider — historical candles, orderbook snapshots, and WebSocket streams.
|
||||||
|
|
||||||
|
Fetches OHLCV candles from Hyperliquid info API (candleSnapshot) and
|
||||||
|
provides them as pandas DataFrames (for VectorBT) and NT Bar objects
|
||||||
|
(for NautilusTrader backtesting).
|
||||||
|
|
||||||
|
WebSocket support: real-time orderbook, trades, mark prices via Hyperliquid WS.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import AsyncIterator, Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from nautilus_trader.model.data import Bar, BarSpecification, BarType
|
||||||
|
from nautilus_trader.model.enums import BarAggregation, PriceType
|
||||||
|
from nautilus_trader.model.identifiers import InstrumentId
|
||||||
|
from nautilus_trader.model.objects import Price, Quantity
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TESTNET_API = "https://api.hyperliquid-testnet.xyz/info"
|
||||||
|
MAINNET_API = "https://api.hyperliquid.xyz/info"
|
||||||
|
|
||||||
|
WS_TESTNET = "wss://api.hyperliquid-testnet.xyz/ws"
|
||||||
|
WS_MAINNET = "wss://api.hyperliquid.xyz/ws"
|
||||||
|
|
||||||
|
INTERVAL_MAP: dict[str, str] = {
|
||||||
|
"1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m",
|
||||||
|
"1h": "1h", "4h": "4h", "8h": "8h", "1d": "1d",
|
||||||
|
"1w": "1w",
|
||||||
|
}
|
||||||
|
|
||||||
|
INTERVAL_TO_SECONDS: dict[str, int] = {
|
||||||
|
"1m": 60, "5m": 300, "15m": 900, "30m": 1800,
|
||||||
|
"1h": 3600, "4h": 14400, "8h": 28800, "1d": 86400,
|
||||||
|
"1w": 604800,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HyperliquidDataProvider:
|
||||||
|
"""Fetches and manages Hyperliquid market data."""
|
||||||
|
|
||||||
|
def __init__(self, testnet: bool = True):
|
||||||
|
self._api_url = TESTNET_API if testnet else MAINNET_API
|
||||||
|
self._ws_url = WS_TESTNET if testnet else WS_MAINNET
|
||||||
|
self._testnet = testnet
|
||||||
|
|
||||||
|
# ── Historical candles ──────────────────────────────────────
|
||||||
|
|
||||||
|
def fetch_candles(
|
||||||
|
self,
|
||||||
|
coin: str,
|
||||||
|
interval: str = "1h",
|
||||||
|
start_ms: int | None = None,
|
||||||
|
end_ms: int | None = None,
|
||||||
|
limit: int = 5000,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Fetch OHLCV candles from Hyperliquid info API.
|
||||||
|
|
||||||
|
Returns DataFrame with columns: open, high, low, close, volume, timestamp.
|
||||||
|
Timestamp is UTC datetime index.
|
||||||
|
"""
|
||||||
|
hl_interval = INTERVAL_MAP.get(interval, interval)
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
payload = {
|
||||||
|
"type": "candleSnapshot",
|
||||||
|
"req": {
|
||||||
|
"coin": coin.upper(),
|
||||||
|
"interval": hl_interval,
|
||||||
|
"startTime": start_ms or (now - limit * INTERVAL_TO_SECONDS.get(interval, 3600) * 1000),
|
||||||
|
"endTime": end_ms or now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp = requests.post(self._api_url, json=payload, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
candles = resp.json()
|
||||||
|
|
||||||
|
if not candles:
|
||||||
|
return pd.DataFrame(columns=["open", "high", "low", "close", "volume", "timestamp"])
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for c in candles:
|
||||||
|
rows.append({
|
||||||
|
"open": float(c["o"]),
|
||||||
|
"high": float(c["h"]),
|
||||||
|
"low": float(c["l"]),
|
||||||
|
"close": float(c["c"]),
|
||||||
|
"volume": float(c["v"]),
|
||||||
|
"timestamp": datetime.fromtimestamp(c["t"] / 1000, tz=timezone.utc),
|
||||||
|
})
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
df.set_index("timestamp", inplace=True)
|
||||||
|
df.sort_index(inplace=True)
|
||||||
|
return df
|
||||||
|
|
||||||
|
def fetch_multi_candles(
|
||||||
|
self,
|
||||||
|
coins: list[str],
|
||||||
|
interval: str = "1h",
|
||||||
|
limit: int = 5000,
|
||||||
|
) -> dict[str, pd.DataFrame]:
|
||||||
|
"""Fetch candles for multiple coins in parallel."""
|
||||||
|
results = {}
|
||||||
|
for coin in coins:
|
||||||
|
try:
|
||||||
|
results[coin] = self.fetch_candles(coin, interval=interval, limit=limit)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to fetch %s candles: %s", coin, e)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def to_nt_bars(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
instrument_id: InstrumentId,
|
||||||
|
step: int = 1,
|
||||||
|
bar_aggregation: BarAggregation = BarAggregation.MINUTE,
|
||||||
|
price_type: PriceType = PriceType.LAST,
|
||||||
|
) -> list[Bar]:
|
||||||
|
"""Convert a pandas DataFrame of candles to NautilusTrader Bar objects."""
|
||||||
|
spec = BarSpecification(step, bar_aggregation, price_type)
|
||||||
|
bar_type = BarType(instrument_id, spec)
|
||||||
|
bars = []
|
||||||
|
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
ts_event = int(idx.timestamp() * 1e9)
|
||||||
|
ts_init = ts_event
|
||||||
|
bar = Bar(
|
||||||
|
bar_type=bar_type,
|
||||||
|
open=Price(row["open"], instrument_id.venue.precision or 2),
|
||||||
|
high=Price(row["high"], instrument_id.venue.precision or 2),
|
||||||
|
low=Price(row["low"], instrument_id.venue.precision or 2),
|
||||||
|
close=Price(row["close"], instrument_id.venue.precision or 2),
|
||||||
|
volume=Quantity(row["volume"], 0),
|
||||||
|
ts_event=ts_event,
|
||||||
|
ts_init=ts_init,
|
||||||
|
)
|
||||||
|
bars.append(bar)
|
||||||
|
|
||||||
|
return bars
|
||||||
|
|
||||||
|
# ── Orderbook snapshots ─────────────────────────────────────
|
||||||
|
|
||||||
|
def fetch_orderbook(self, coin: str) -> dict:
|
||||||
|
"""Get current L2 orderbook snapshot."""
|
||||||
|
resp = requests.post(self._api_url, json={"type": "l2Book", "coin": coin.upper()}, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
bids = [[float(l["px"]), float(l["sz"])] for l in data["levels"][0]]
|
||||||
|
asks = [[float(l["px"]), float(l["sz"])] for l in data["levels"][1]]
|
||||||
|
return {
|
||||||
|
"bids": bids,
|
||||||
|
"asks": asks,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def fetch_orderbook_df(self, coin: str) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||||
|
"""Get orderbook as bid/ask DataFrames."""
|
||||||
|
ob = self.fetch_orderbook(coin)
|
||||||
|
bids_df = pd.DataFrame(ob["bids"], columns=["price", "size"])
|
||||||
|
asks_df = pd.DataFrame(ob["asks"], columns=["price", "size"])
|
||||||
|
return bids_df, asks_df
|
||||||
|
|
||||||
|
# ── Mark prices ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fetch_mark_prices(self) -> dict[str, float]:
|
||||||
|
"""Get current mark prices for all assets."""
|
||||||
|
resp = requests.post(self._api_url, json={"type": "metaAndAssetCtxs"}, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if not isinstance(data, list) or len(data) < 2:
|
||||||
|
return {}
|
||||||
|
universe = data[0].get("universe", [])
|
||||||
|
ctxs = data[1]
|
||||||
|
prices = {}
|
||||||
|
for i, u in enumerate(universe):
|
||||||
|
if i < len(ctxs):
|
||||||
|
prices[u["name"]] = float(ctxs[i].get("markPx", 0))
|
||||||
|
return prices
|
||||||
|
|
||||||
|
# ── WebSocket streaming ─────────────────────────────────────
|
||||||
|
|
||||||
|
async def stream_orderbook(self, coin: str) -> AsyncIterator[dict]:
|
||||||
|
"""Stream L2 orderbook updates via Hyperliquid WebSocket."""
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError:
|
||||||
|
logger.error("websockets not installed; pip install websockets")
|
||||||
|
return
|
||||||
|
|
||||||
|
subscribe_msg = json.dumps({"method": "subscribe", "subscription": {"type": "l2Book", "coin": coin.upper()}})
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with websockets.connect(self._ws_url) as ws:
|
||||||
|
await ws.send(subscribe_msg)
|
||||||
|
async for msg in ws:
|
||||||
|
yield json.loads(msg)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("WebSocket error: %s (reconnecting)", e)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
async def stream_prices(self, coins: list[str]) -> AsyncIterator[dict[str, float]]:
|
||||||
|
"""Stream mark prices via polling fallback (1s interval).
|
||||||
|
|
||||||
|
Hyperliquid WebSocket doesn't have a simple 'mark prices' stream,
|
||||||
|
so we poll the REST API with async sleep.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
prices = self.fetch_mark_prices()
|
||||||
|
yield {c: prices.get(c, 0) for c in coins}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Price poll error: %s", e)
|
||||||
|
await asyncio.sleep(1)
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""
|
||||||
|
Deploy orchestrator — unified CLI for backtest → paper → live pipeline.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
backtest --strategy <name> [--fast|--full] [--interval 1h]
|
||||||
|
paper --strategy <name> [--duration 3600]
|
||||||
|
live --strategy <name> [--testnet|--mainnet]
|
||||||
|
list List all registered strategies and backtest results.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [deploy] %(message)s", datefmt="%H:%M:%S")
|
||||||
|
logger = logging.getLogger("ftdt-deploy")
|
||||||
|
|
||||||
|
RESULTS_DIR = Path(__file__).resolve().parent.parent / "backtests" / "results"
|
||||||
|
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
STRATEGY_REGISTRY = {
|
||||||
|
"pairs": {
|
||||||
|
"name": "Pairs Trading",
|
||||||
|
"description": "BTC/ETH ratio Z-score mean reversion",
|
||||||
|
"class": "strategies.nt.pairs_trading_nt.PairsTradingNT",
|
||||||
|
},
|
||||||
|
"hurst_vpin": {
|
||||||
|
"name": "Hurst VPIN",
|
||||||
|
"description": "Hurst exponent regime filter + VPIN flow imbalance",
|
||||||
|
"class": "strategies.nt.hurst_vpin_nt.HurstVPINNT",
|
||||||
|
},
|
||||||
|
"as_mm": {
|
||||||
|
"name": "Avellaneda-Stoikov",
|
||||||
|
"description": "Stochastic control market making with inventory risk",
|
||||||
|
"class": "strategies.nt.as_mm_nt.ASMarketMakingNT",
|
||||||
|
},
|
||||||
|
"obi": {
|
||||||
|
"name": "Order Book Imbalance",
|
||||||
|
"description": "L2 bid/ask volume skew reversal",
|
||||||
|
"class": None, # Not yet ported
|
||||||
|
},
|
||||||
|
"funding_arb": {
|
||||||
|
"name": "Funding Rate Arb",
|
||||||
|
"description": "Delta-neutral carry — collect funding payments",
|
||||||
|
"class": None,
|
||||||
|
},
|
||||||
|
"momentum": {
|
||||||
|
"name": "Momentum Breakout",
|
||||||
|
"description": "Bollinger band breakout on trending instruments",
|
||||||
|
"class": None,
|
||||||
|
},
|
||||||
|
"mean_rev": {
|
||||||
|
"name": "Mean Reversion",
|
||||||
|
"description": "VWAP deviation oscillator",
|
||||||
|
"class": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DeployOrchestrator:
|
||||||
|
"""Unified deployment pipeline."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmd_backtest(args):
|
||||||
|
from backtests.vbt_runner import VBTBacktestRunner
|
||||||
|
from backtests.nt_runner import NTBacktestRunner
|
||||||
|
from framework.instruments import HyperliquidInstrumentCatalog
|
||||||
|
|
||||||
|
strategy_key = args.strategy
|
||||||
|
strategy_info = STRATEGY_REGISTRY.get(strategy_key)
|
||||||
|
if not strategy_info:
|
||||||
|
print(f"Unknown strategy: {strategy_key}")
|
||||||
|
print(f"Available: {list(STRATEGY_REGISTRY.keys())}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Quick VectorBT backtest
|
||||||
|
if not args.nt_only:
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" VectorBT Backtest: {strategy_info['name']}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
runner = VBTBacktestRunner()
|
||||||
|
result = runner.run_strategy(
|
||||||
|
strategy=strategy_key,
|
||||||
|
interval=args.interval,
|
||||||
|
testnet=args.testnet,
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
_save_result(strategy_key, "vbt", result)
|
||||||
|
|
||||||
|
# Full NautilusTrader backtest
|
||||||
|
if not args.vbt_only:
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" NautilusTrader Backtest: {strategy_info['name']}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
catalog = HyperliquidInstrumentCatalog(testnet=args.testnet)
|
||||||
|
runner = NTBacktestRunner()
|
||||||
|
result = runner.run_backtest(
|
||||||
|
strategy=strategy_key,
|
||||||
|
interval=args.interval,
|
||||||
|
instruments=catalog.load(),
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
_save_result(strategy_key, "nt", result)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmd_paper(args):
|
||||||
|
from framework.data import HyperliquidDataProvider
|
||||||
|
from framework.execution import PaperExecutionProvider
|
||||||
|
from framework.config import StrategyConfig
|
||||||
|
|
||||||
|
strategy_key = args.strategy
|
||||||
|
strategy_info = STRATEGY_REGISTRY.get(strategy_key)
|
||||||
|
if not strategy_info:
|
||||||
|
print(f"Unknown strategy: {strategy_key}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" Paper Trading: {strategy_info['name']}")
|
||||||
|
print(f" Duration: {args.duration}s | Mainnet data")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
provider = HyperliquidDataProvider(testnet=False)
|
||||||
|
execution = PaperExecutionProvider()
|
||||||
|
|
||||||
|
# Determine coin from strategy
|
||||||
|
coin_map = {"pairs": "ETH", "hurst_vpin": "BTC", "as_mm": "BTC",
|
||||||
|
"obi": "BTC", "funding_arb": "BTC", "momentum": "ETH"}
|
||||||
|
coin = args.coin or coin_map.get(strategy_key, "BTC")
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
start = asyncio.get_event_loop().time()
|
||||||
|
while asyncio.get_event_loop().time() - start < args.duration:
|
||||||
|
try:
|
||||||
|
prices = provider.fetch_mark_prices()
|
||||||
|
mark = prices.get(coin, 0)
|
||||||
|
if mark > 0:
|
||||||
|
# Simulate a signal check each tick
|
||||||
|
_tick(strategy_key, coin, mark, provider, execution)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Paper loop error: %s", e)
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmd_live(args):
|
||||||
|
from framework.execution import HyperliquidExecutionProvider
|
||||||
|
|
||||||
|
strategy_key = args.strategy
|
||||||
|
strategy_info = STRATEGY_REGISTRY.get(strategy_key)
|
||||||
|
if not strategy_info:
|
||||||
|
print(f"Unknown strategy: {strategy_key}")
|
||||||
|
return
|
||||||
|
|
||||||
|
use_testnet = not args.mainnet
|
||||||
|
env = "testnet" if use_testnet else "mainnet"
|
||||||
|
|
||||||
|
private_key = os.environ.get(f"HYPERLIQUID_{env.upper()}_PK")
|
||||||
|
if not private_key:
|
||||||
|
env_file = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
if env_file.exists():
|
||||||
|
for line in env_file.read_text().splitlines():
|
||||||
|
key = f"HYPERLIQUID_{env.upper()}_PK"
|
||||||
|
if line.startswith(f"{key}="):
|
||||||
|
private_key = line.split("=", 1)[1].strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
if not private_key:
|
||||||
|
print(f"ERROR: HYPERLIQUID_{env.upper()}_PK not set in .env or environment")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not use_testnet:
|
||||||
|
resp = input(f"\n⚠️ LIVE MAINNET for {strategy_key}. Confirm? (yes/no): ")
|
||||||
|
if resp.lower() != "yes":
|
||||||
|
print("Aborted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
provider = HyperliquidExecutionProvider(private_key=private_key, testnet=use_testnet)
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" LIVE {env.upper()}: {strategy_info['name']}")
|
||||||
|
print(f" Wallet: {provider.address}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# Cancel existing orders
|
||||||
|
provider.cancel_all()
|
||||||
|
print("Run with Ctrl+C to stop. Existing node.py/paper_trader.py unaffected.")
|
||||||
|
print("This is a standalone execution — for prod monitoring use the existing live node.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmd_list(args):
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(" Registered Strategies")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
for key, info in STRATEGY_REGISTRY.items():
|
||||||
|
ported = "✅" if info["class"] else "⏳"
|
||||||
|
print(f" {ported} {key:15s} {info['name']:30s} {info['description']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# List backtest results
|
||||||
|
results = sorted(RESULTS_DIR.glob("*.json"), key=os.path.getmtime, reverse=True)
|
||||||
|
if results:
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(" Backtest Results")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
for r in results[:10]:
|
||||||
|
mtime = datetime.fromtimestamp(os.path.getmtime(r)).strftime("%Y-%m-%d %H:%M")
|
||||||
|
size_kb = os.path.getsize(r) / 1024
|
||||||
|
print(f" {r.name:50s} {size_kb:6.1f}KB {mtime}")
|
||||||
|
if len(results) > 10:
|
||||||
|
print(f" ... and {len(results) - 10} more")
|
||||||
|
|
||||||
|
|
||||||
|
def _save_result(strategy_key: str, engine: str, result: dict):
|
||||||
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
path = RESULTS_DIR / f"{strategy_key}_{engine}_{ts}.json"
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(result, f, indent=2, default=str)
|
||||||
|
print(f" Saved: {path.name}")
|
||||||
|
if "sharpe" in result:
|
||||||
|
print(f" Sharpe: {result['sharpe']:.2f} | DD: {result.get('max_drawdown_pct', 0):.1f}% | Win: {result.get('win_rate', 0):.0%}")
|
||||||
|
|
||||||
|
|
||||||
|
def _tick(strategy_key: str, coin: str, mark: float, provider, execution):
|
||||||
|
"""Single tick of paper trading logic — placeholder for full strategy logic."""
|
||||||
|
# Load strategy module dynamically
|
||||||
|
strategy_class_path = STRATEGY_REGISTRY.get(strategy_key, {}).get("class")
|
||||||
|
if not strategy_class_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
module_path, class_name = strategy_class_path.rsplit(".", 1)
|
||||||
|
import importlib
|
||||||
|
try:
|
||||||
|
mod = importlib.import_module(module_path)
|
||||||
|
strategy_cls = getattr(mod, class_name)
|
||||||
|
|
||||||
|
# Instantiate if not already cached
|
||||||
|
if not hasattr(_tick, "_instances"):
|
||||||
|
_tick._instances = {}
|
||||||
|
if strategy_key not in _tick._instances:
|
||||||
|
from framework.config import StrategyConfig
|
||||||
|
cfg = StrategyConfig(
|
||||||
|
name=STRATEGY_REGISTRY[strategy_key]["name"],
|
||||||
|
instrument=f"{coin}-USD-PERP",
|
||||||
|
asset=coin,
|
||||||
|
allocation=10000.0,
|
||||||
|
order_size=0.001,
|
||||||
|
testnet=False, # paper uses mainnet data
|
||||||
|
)
|
||||||
|
_tick._instances[strategy_key] = strategy_cls(cfg)
|
||||||
|
|
||||||
|
strat = _tick._instances[strategy_key]
|
||||||
|
sig = strat.compute_signal(price=mark)
|
||||||
|
if sig:
|
||||||
|
# Paper execution
|
||||||
|
from framework.execution import PaperExecutionProvider as Pep
|
||||||
|
pep = Pep()
|
||||||
|
cloid = pep.submit(
|
||||||
|
coin=coin,
|
||||||
|
side="BUY" if "BUY" in sig.get("signal", "").upper() else "SELL",
|
||||||
|
size=cfg.order_size,
|
||||||
|
price=mark,
|
||||||
|
fee_model=cfg.fee_model,
|
||||||
|
mark_price=mark,
|
||||||
|
)
|
||||||
|
logger.info("Paper signal: %s → %s | fill=%s", sig["signal"], cloid, mark)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Tick error for %s: %s", strategy_key, e)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="FTDT Quant Lab — Deploy Orchestrator")
|
||||||
|
sub = parser.add_subparsers(dest="command", help="Command")
|
||||||
|
|
||||||
|
# backtest
|
||||||
|
bt = sub.add_parser("backtest", help="Run backtest (VectorBT + NautilusTrader)")
|
||||||
|
bt.add_argument("--strategy", "-s", required=True, help="Strategy key (pairs, hurst_vpin, as_mm, etc.)")
|
||||||
|
bt.add_argument("--fast", dest="vbt_only", action="store_true", help="VectorBT quick backtest only")
|
||||||
|
bt.add_argument("--full", dest="nt_only", action="store_true", help="NautilusTrader full backtest only")
|
||||||
|
bt.add_argument("--interval", default="1h", help="Candle interval (1m, 5m, 15m, 1h, 4h, 1d)")
|
||||||
|
bt.add_argument("--testnet", action="store_true", default=False, help="Use testnet data")
|
||||||
|
|
||||||
|
# paper
|
||||||
|
pp = sub.add_parser("paper", help="Run paper trading simulation")
|
||||||
|
pp.add_argument("--strategy", "-s", required=True, help="Strategy key")
|
||||||
|
pp.add_argument("--duration", type=int, default=3600, help="Duration in seconds (default: 3600)")
|
||||||
|
pp.add_argument("--coin", help="Override trading coin (default: strategy default)")
|
||||||
|
|
||||||
|
# live
|
||||||
|
ll = sub.add_parser("live", help="Run live trading")
|
||||||
|
ll.add_argument("--strategy", "-s", required=True, help="Strategy key")
|
||||||
|
ll.add_argument("--testnet", action="store_true", default=True, help="Use testnet (default)")
|
||||||
|
ll.add_argument("--mainnet", action="store_true", help="Use mainnet")
|
||||||
|
|
||||||
|
# list
|
||||||
|
sub.add_parser("list", help="List registered strategies and results")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
orch = DeployOrchestrator()
|
||||||
|
getattr(orch, f"cmd_{args.command}")(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"""
|
||||||
|
Hyperliquid execution provider — live and paper trading via NautilusTrader.
|
||||||
|
|
||||||
|
Live mode: Submits real orders to Hyperliquid testnet/mainnet via REST.
|
||||||
|
Paper mode: Tracks virtual positions, simulates fills with realistic slippage.
|
||||||
|
|
||||||
|
Uses the hyperliquid-python-sdk for signed order submission.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from nautilus_trader.model.enums import OrderSide, OrderType, TimeInForce
|
||||||
|
from nautilus_trader.model.identifiers import ClientOrderId, InstrumentId, VenueOrderId
|
||||||
|
from nautilus_trader.model.objects import Price, Quantity
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TESTNET_API = "https://api.hyperliquid-testnet.xyz/info"
|
||||||
|
MAINNET_API = "https://api.hyperliquid.xyz/info"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SimulatedPosition:
|
||||||
|
coin: str
|
||||||
|
quantity: float
|
||||||
|
entry_price: float
|
||||||
|
side: str # BUY or SELL
|
||||||
|
fee_paid: float = 0.0
|
||||||
|
pnl: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SimulatedOrder:
|
||||||
|
cloid: str
|
||||||
|
coin: str
|
||||||
|
side: str
|
||||||
|
quantity: float
|
||||||
|
price: float
|
||||||
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
filled: bool = False
|
||||||
|
fill_price: float = 0.0
|
||||||
|
fee: float = 0.0
|
||||||
|
pnl: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class HyperliquidExecutionProvider:
|
||||||
|
"""Live trading via Hyperliquid SDK + REST API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
private_key: str,
|
||||||
|
testnet: bool = True,
|
||||||
|
vault_address: str | None = None,
|
||||||
|
):
|
||||||
|
self._pk = private_key
|
||||||
|
self._vault = vault_address
|
||||||
|
self._testnet = testnet
|
||||||
|
self._api_url = TESTNET_API if testnet else MAINNET_API
|
||||||
|
self._exchange = None
|
||||||
|
self._info = None
|
||||||
|
self._address: str | None = None
|
||||||
|
|
||||||
|
def _ensure_sdk(self):
|
||||||
|
if self._exchange is None:
|
||||||
|
from hyperliquid.exchange import Exchange
|
||||||
|
from hyperliquid.info import Info
|
||||||
|
|
||||||
|
self._info = Info(self._api_url, skip_ws=True)
|
||||||
|
self._exchange = Exchange(
|
||||||
|
wallet=self._info,
|
||||||
|
private_key=self._pk,
|
||||||
|
vault_address=self._vault,
|
||||||
|
account_address=None,
|
||||||
|
is_testnet=self._testnet,
|
||||||
|
)
|
||||||
|
meta = self._info.meta()
|
||||||
|
if meta and "universe" in meta:
|
||||||
|
logger.info("HL SDK initialized: %d assets", len(meta.get("universe", [])))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def address(self) -> str | None:
|
||||||
|
if not self._address:
|
||||||
|
self._ensure_sdk()
|
||||||
|
if self._exchange:
|
||||||
|
self._address = self._exchange.wallet.address
|
||||||
|
return self._address
|
||||||
|
|
||||||
|
def submit_limit_order(
|
||||||
|
self,
|
||||||
|
coin: str,
|
||||||
|
side: str, # "BUY" or "SELL"
|
||||||
|
size: float,
|
||||||
|
price: float,
|
||||||
|
post_only: bool = True,
|
||||||
|
reduce_only: bool = False,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Submit a limit order. Returns order response or None on failure."""
|
||||||
|
self._ensure_sdk()
|
||||||
|
try:
|
||||||
|
is_buy = side.upper() == "BUY"
|
||||||
|
result = self._exchange.order(
|
||||||
|
name=coin,
|
||||||
|
is_buy=is_buy,
|
||||||
|
sz=size,
|
||||||
|
limit_px=price,
|
||||||
|
order_type={"limit": {"tif": "Gtc" if post_only else "Ioc"}},
|
||||||
|
reduce_only=reduce_only,
|
||||||
|
)
|
||||||
|
logger.info("Order submitted: %s %s %.6f @ %.1f → %s",
|
||||||
|
side, coin, size, price, result)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Order failed: %s %s: %s", side, coin, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cancel_order(self, coin: str, cloid: str) -> bool:
|
||||||
|
"""Cancel an order by client order ID."""
|
||||||
|
self._ensure_sdk()
|
||||||
|
try:
|
||||||
|
self._exchange.cancel(coin, cloid)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Cancel failed for %s/%s: %s", coin, cloid, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cancel_all(self, coin: str | None = None):
|
||||||
|
"""Cancel all open orders, optionally filtered by coin."""
|
||||||
|
self._ensure_sdk()
|
||||||
|
try:
|
||||||
|
self._exchange.cancel_all(coin)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Cancel all failed: %s", e)
|
||||||
|
|
||||||
|
def get_positions(self) -> list[dict]:
|
||||||
|
"""Get open positions for the wallet."""
|
||||||
|
if not self.address:
|
||||||
|
return []
|
||||||
|
resp = requests.post(
|
||||||
|
self._api_url,
|
||||||
|
json={"type": "clearinghouseState", "user": self.address},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return []
|
||||||
|
data = resp.json()
|
||||||
|
positions = []
|
||||||
|
for pos in data.get("assetPositions", []):
|
||||||
|
pos_type = pos.get("position", {})
|
||||||
|
if pos_type:
|
||||||
|
coin = pos_type.get("coin", "")
|
||||||
|
szi = float(pos_type.get("szi", 0))
|
||||||
|
if coin and abs(szi) > 0:
|
||||||
|
positions.append({
|
||||||
|
"coin": coin,
|
||||||
|
"size": szi,
|
||||||
|
"entry_px": float(pos_type.get("entryPx", 0)),
|
||||||
|
"unrealized_pnl": float(pos_type.get("unrealizedPnl", 0)),
|
||||||
|
})
|
||||||
|
return positions
|
||||||
|
|
||||||
|
def get_open_orders(self) -> list[dict]:
|
||||||
|
if not self.address:
|
||||||
|
return []
|
||||||
|
resp = requests.post(
|
||||||
|
self._api_url,
|
||||||
|
json={"type": "openOrders", "user": self.address},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return []
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
class PaperExecutionProvider:
|
||||||
|
"""Paper trading — simulated fills against real Hyperliquid mark prices."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
maker_fee: float = 0.0002,
|
||||||
|
taker_fee: float = 0.0005,
|
||||||
|
slippage_bps: float = 1.0,
|
||||||
|
):
|
||||||
|
self.maker_fee = maker_fee
|
||||||
|
self.taker_fee = taker_fee
|
||||||
|
self.slippage_bps = slippage_bps
|
||||||
|
|
||||||
|
self.positions: dict[str, SimulatedPosition] = {}
|
||||||
|
self.orders: dict[str, SimulatedOrder] = {}
|
||||||
|
self.trades: list[dict] = []
|
||||||
|
self._counter = 0
|
||||||
|
|
||||||
|
def submit(
|
||||||
|
self,
|
||||||
|
coin: str,
|
||||||
|
side: str,
|
||||||
|
size: float,
|
||||||
|
price: float,
|
||||||
|
fee_model: str = "taker",
|
||||||
|
mark_price: float | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Submit a simulated order. Returns client order ID."""
|
||||||
|
self._counter += 1
|
||||||
|
cloid = f"paper-{self._counter}"
|
||||||
|
|
||||||
|
order = SimulatedOrder(cloid=cloid, coin=coin, side=side, quantity=size, price=price)
|
||||||
|
self.orders[cloid] = order
|
||||||
|
|
||||||
|
# Simulate immediate fill at mark price or limit price
|
||||||
|
fill_price = mark_price if mark_price and mark_price > 0 else price
|
||||||
|
fee_rate = self.maker_fee if fee_model == "maker" else self.taker_fee
|
||||||
|
|
||||||
|
# Apply slippage
|
||||||
|
slip = fill_price * self.slippage_bps / 10000
|
||||||
|
effective_px = fill_price + slip if side.upper() == "BUY" else fill_price - slip
|
||||||
|
|
||||||
|
fee = size * effective_px * fee_rate
|
||||||
|
order.filled = True
|
||||||
|
order.fill_price = effective_px
|
||||||
|
order.fee = fee
|
||||||
|
|
||||||
|
# Update position
|
||||||
|
pos = self.positions.get(coin)
|
||||||
|
if pos and pos.side != side:
|
||||||
|
# Closing trade — calculate PnL
|
||||||
|
pnl = (effective_px - pos.entry_price) * min(size, abs(pos.quantity))
|
||||||
|
if pos.side == "SELL":
|
||||||
|
pnl = -pnl
|
||||||
|
order.pnl = pnl
|
||||||
|
pos.quantity -= size
|
||||||
|
pos.fee_paid += fee
|
||||||
|
pos.pnl += pnl
|
||||||
|
if abs(pos.quantity) < 1e-8:
|
||||||
|
del self.positions[coin]
|
||||||
|
else:
|
||||||
|
# Opening or adding to position
|
||||||
|
if coin not in self.positions:
|
||||||
|
self.positions[coin] = SimulatedPosition(
|
||||||
|
coin=coin, quantity=size, entry_price=effective_px, side=side
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pos.quantity += size
|
||||||
|
pos.entry_price = (pos.entry_price * (pos.quantity - size) + effective_px * size) / pos.quantity
|
||||||
|
|
||||||
|
trade = {
|
||||||
|
"cloid": cloid,
|
||||||
|
"coin": coin,
|
||||||
|
"side": side,
|
||||||
|
"size": size,
|
||||||
|
"price": effective_px,
|
||||||
|
"fee": round(fee, 6),
|
||||||
|
"pnl": round(order.pnl, 4),
|
||||||
|
"timestamp": time.time(),
|
||||||
|
}
|
||||||
|
self.trades.append(trade)
|
||||||
|
logger.debug("Paper fill: %s %s %.6f @ %.1f | pnl=%.4f fee=%.6f",
|
||||||
|
side, coin, size, effective_px, order.pnl, fee)
|
||||||
|
return cloid
|
||||||
|
|
||||||
|
def cancel(self, cloid: str) -> bool:
|
||||||
|
if cloid in self.orders and not self.orders[cloid].filled:
|
||||||
|
del self.orders[cloid]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_pnl(self) -> float:
|
||||||
|
return sum(p.pnl for p in self.positions.values()) + sum(
|
||||||
|
t.get("pnl", 0) for t in self.trades if t.get("pnl", 0) > 0
|
||||||
|
)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
Hyperliquid instrument catalog — loads perpetual contracts as NT CryptoPerpetual.
|
||||||
|
|
||||||
|
Fetches exchange metadata (universe + asset contexts) from Hyperliquid info API
|
||||||
|
and builds NautilusTrader CryptoPerpetual instrument definitions.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from nautilus_trader.model.identifiers import InstrumentId, Symbol, Venue
|
||||||
|
from nautilus_trader.model.instruments import CryptoPerpetual
|
||||||
|
from nautilus_trader.model.objects import Currency, Price, Quantity
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HL_VENUE = Venue("HYPERLIQUID")
|
||||||
|
TESTNET_API = "https://api.hyperliquid-testnet.xyz/info"
|
||||||
|
MAINNET_API = "https://api.hyperliquid.xyz/info"
|
||||||
|
|
||||||
|
|
||||||
|
def _hl_meta(testnet: bool = True) -> dict:
|
||||||
|
url = TESTNET_API if testnet else MAINNET_API
|
||||||
|
resp = requests.post(url, json={"type": "metaAndAssetCtxs"}, timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if not isinstance(data, list) or len(data) < 2:
|
||||||
|
raise ValueError("Invalid metaAndAssetCtxs response")
|
||||||
|
return {"universe": data[0].get("universe", []), "contexts": data[1]}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_instrument(asset: dict, ctx: dict | None) -> CryptoPerpetual | None:
|
||||||
|
name = asset.get("name", "")
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
symbol_str = f"{name}-USD-PERP"
|
||||||
|
inst_id = InstrumentId(Symbol(symbol_str), HL_VENUE)
|
||||||
|
|
||||||
|
px_ctx = ctx if ctx else {}
|
||||||
|
mark_px = float(px_ctx.get("markPx", 0) or 0)
|
||||||
|
|
||||||
|
step_size = asset.get("szDecimals", 5)
|
||||||
|
size_increment_val = 10 ** -step_size
|
||||||
|
tick_size = asset.get("pxDecimals", 1)
|
||||||
|
price_increment_val = 10 ** -tick_size
|
||||||
|
|
||||||
|
now_ns = int(datetime.now(timezone.utc).timestamp() * 1e9)
|
||||||
|
|
||||||
|
return CryptoPerpetual(
|
||||||
|
instrument_id=inst_id,
|
||||||
|
raw_symbol=Symbol(symbol_str),
|
||||||
|
base_currency=Currency.from_str(name),
|
||||||
|
quote_currency=Currency.from_str("USD"),
|
||||||
|
settlement_currency=Currency.from_str("USD"),
|
||||||
|
is_inverse=False,
|
||||||
|
price_precision=tick_size,
|
||||||
|
size_precision=step_size,
|
||||||
|
price_increment=Price.from_str(str(price_increment_val)),
|
||||||
|
size_increment=Quantity.from_str(str(size_increment_val)),
|
||||||
|
multiplier=Quantity.from_str("1.0"),
|
||||||
|
maker_fee=Decimal("0.0002"),
|
||||||
|
taker_fee=Decimal("0.0005"),
|
||||||
|
max_quantity=Quantity.from_str("10000.0"),
|
||||||
|
min_quantity=Quantity.from_str(str(size_increment_val)),
|
||||||
|
max_notional=None,
|
||||||
|
min_notional=None,
|
||||||
|
max_price=Price.from_str(str(int(mark_px * 10)) if mark_px > 0 else "10000000.0"),
|
||||||
|
min_price=Price.from_str("0.01"),
|
||||||
|
margin_init=Decimal("0.02"),
|
||||||
|
margin_maint=Decimal("0.01"),
|
||||||
|
ts_event=now_ns,
|
||||||
|
ts_init=now_ns,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HyperliquidInstrumentCatalog:
|
||||||
|
"""Fetches and caches Hyperliquid perpetual instrument definitions."""
|
||||||
|
|
||||||
|
def __init__(self, testnet: bool = True):
|
||||||
|
self._testnet = testnet
|
||||||
|
self._instruments: dict[str, CryptoPerpetual] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def venue(self) -> Venue:
|
||||||
|
return HL_VENUE
|
||||||
|
|
||||||
|
def load(self, assets: list[str] | None = None) -> dict[str, CryptoPerpetual]:
|
||||||
|
"""Fetch all perps, returning dict keyed by base currency name."""
|
||||||
|
meta = _hl_meta(testnet=self._testnet)
|
||||||
|
universe = meta["universe"]
|
||||||
|
contexts = meta["contexts"]
|
||||||
|
|
||||||
|
for i, asset_info in enumerate(universe):
|
||||||
|
name = asset_info.get("name", "")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if assets and name.upper() not in [a.upper() for a in assets]:
|
||||||
|
continue
|
||||||
|
ctx = contexts[i] if i < len(contexts) else None
|
||||||
|
try:
|
||||||
|
inst = _to_instrument(asset_info, ctx)
|
||||||
|
if inst:
|
||||||
|
self._instruments[name] = inst
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Skipped instrument %s: %s", name, e)
|
||||||
|
|
||||||
|
logger.info("Loaded %d Hyperliquid instruments", len(self._instruments))
|
||||||
|
return self._instruments
|
||||||
|
|
||||||
|
def get(self, name: str) -> CryptoPerpetual | None:
|
||||||
|
return self._instruments.get(name.upper())
|
||||||
|
|
||||||
|
def all_ids(self) -> list[InstrumentId]:
|
||||||
|
return [inst.id for inst in self._instruments.values()]
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._instruments)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self._instruments.values())
|
||||||
+301
-134
@@ -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
|
not at mid ± random spread. Refreshes quotes every cycle to stay
|
||||||
at queue front. Avellaneda-Stoikov places dual-sided quotes simultaneously.
|
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
|
import os, sys, asyncio, json, time, logging, random, math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -31,13 +31,15 @@ RESERVE = 398.0
|
|||||||
MAKER_FEE = 0.0002
|
MAKER_FEE = 0.0002
|
||||||
|
|
||||||
STRATEGIES = {
|
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."},
|
"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.0002,"fee_paid":0.0,"signals":[],"type":"momentum","description":"Detects whale TWAP accumulation — follows smart money flow."},
|
"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.0002,"fee_paid":0.0,"signals":[],"type":"carry","description":"Delta-neutral carry — holds spot, shorts perp, collects funding."},
|
"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σ."},
|
"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."},
|
"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":"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."},
|
"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":"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."},
|
"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."},
|
||||||
|
"Hurst VPIN": {"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.000240,"fee_paid":0.0,"signals":[],"type":"momentum","description":"Hurst exponent regime filter + VPIN informed flow — enters when both align trending + high flow imbalance."}
|
||||||
}
|
}
|
||||||
|
|
||||||
trades_log: list[dict] = []
|
trades_log: list[dict] = []
|
||||||
@@ -47,6 +49,8 @@ seen_fills: set[int] = set()
|
|||||||
btc_prices: deque = deque(maxlen=60)
|
btc_prices: deque = deque(maxlen=60)
|
||||||
eth_prices: deque = deque(maxlen=60)
|
eth_prices: deque = deque(maxlen=60)
|
||||||
active_cloids: dict = {} # Track active order IDs per strategy
|
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 ═══════════════════════
|
# ═══════════════════════ Helpers ═══════════════════════
|
||||||
|
|
||||||
@@ -115,8 +119,8 @@ def compute_signals():
|
|||||||
# OFI: 5-tick reversal
|
# OFI: 5-tick reversal
|
||||||
if len(btc_prices)>=5:
|
if len(btc_prices)>=5:
|
||||||
ret = (btc-btc_prices[-5])/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})
|
if ret>0.0004: 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)})
|
elif ret<-0.0004: STRATEGIES["Order Book Imbalance"]["signals"].append({"time":time.time(),"signal":"BUY","strength":abs(ret)})
|
||||||
|
|
||||||
# Iceberg: trend count
|
# Iceberg: trend count
|
||||||
if len(btc_prices)>=10:
|
if len(btc_prices)>=10:
|
||||||
@@ -124,11 +128,24 @@ def compute_signals():
|
|||||||
if up>=7: STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"BUY","strength":up/10})
|
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})
|
elif up<=3: STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"SELL","strength":1-up/10})
|
||||||
|
|
||||||
# Funding Arb: rate proxy
|
# Funding Rate Arb: real API data
|
||||||
if len(btc_prices)>=20:
|
try:
|
||||||
fr = (btc/btc_prices[-20]-1)/20
|
from strategies.funding_arb import get_funding_rates
|
||||||
if abs(fr)>0.0008:
|
rates = get_funding_rates(use_testnet=True)
|
||||||
STRATEGIES["Funding Rate Arb"]["signals"].append({"time":time.time(),"signal":"SELL" if fr>0 else "BUY","strength":abs(fr)})
|
annual_rate = rates.get("BTC", 0)
|
||||||
|
if abs(annual_rate) > 0.03: # >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
|
# Pairs: ratio Z-score
|
||||||
if len(btc_prices)>=20 and len(eth_prices)>=20:
|
if len(btc_prices)>=20 and len(eth_prices)>=20:
|
||||||
@@ -138,25 +155,60 @@ def compute_signals():
|
|||||||
cur = btc/eth if eth>0 else 0
|
cur = btc/eth if eth>0 else 0
|
||||||
if std>0:
|
if std>0:
|
||||||
z = (cur-mu)/std
|
z = (cur-mu)/std
|
||||||
if z>1.5: STRATEGIES["Pairs Trading"]["signals"].append({"time":time.time(),"signal":"SELL_ETH","strength":z})
|
if z>1.2: 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)})
|
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=2.0, 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
|
# Momentum: Bollinger on ETH
|
||||||
if len(btc_prices)>=20:
|
if len(eth_prices)>=20:
|
||||||
w = list(btc_prices)[-20:]; sma = sum(w)/len(w)
|
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)
|
variance = sum((p-sma)**2 for p in w)/len(w); std = math.sqrt(variance)
|
||||||
if std>0:
|
if std>0:
|
||||||
if btc > sma+2*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"BUY","strength":(btc-sma-2*std)/std})
|
if eth_cur > sma+1.2*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"BUY","strength":(eth_cur-sma-1.2*std)/std})
|
||||||
elif btc < sma-2*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"SELL","strength":(sma-2*std-btc)/std})
|
elif eth_cur < sma-1.2*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"SELL","strength":(sma-1.2*std-eth_cur)/std})
|
||||||
|
|
||||||
# Mean Reversion: VWAP
|
# Mean Reversion: VWAP on ETH (exclude current price from VWAP)
|
||||||
if len(btc_prices)>=20:
|
if len(eth_prices)>=20:
|
||||||
w = list(btc_prices)[-20:]; vols = [1+i/len(w) for i in range(len(w))]
|
w = list(eth_prices)[-20:]; eth_mr = eth_prices[-1]
|
||||||
vwap = sum(p*v for p,v in zip(w,vols))/sum(vols)
|
# VWAP on prior 19 prices, equal volume weights
|
||||||
vstd = math.sqrt(sum((p-vwap)**2 for p in w)/len(w))
|
prior = w[:-1]
|
||||||
dev = (btc-vwap)/vstd if vstd>0 else 0
|
sma = sum(prior)/len(prior)
|
||||||
if dev>1.5: STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"SELL","strength":dev})
|
vstd = math.sqrt(sum((p-sma)**2 for p in prior)/len(prior))
|
||||||
elif dev<-1.5: STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"BUY","strength":abs(dev)})
|
dev = (eth_mr-sma)/vstd if vstd>0 else 0
|
||||||
|
if dev>1.0: STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"SELL","strength":dev})
|
||||||
|
elif dev<-1.0: STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"BUY","strength":abs(dev)})
|
||||||
|
|
||||||
|
# Hurst/VPIN: feed BTC price into dollar bars
|
||||||
|
if len(btc_prices)>=3:
|
||||||
|
try:
|
||||||
|
from strategies.hurst_vpin_live import HurstVPINLive
|
||||||
|
if "_hv_live" not in dir():
|
||||||
|
globals()["_hv_live"] = HurstVPINLive()
|
||||||
|
hv_signal = globals()["_hv_live"].feed_price(btc)
|
||||||
|
if hv_signal:
|
||||||
|
STRATEGIES["Hurst VPIN"]["signals"].append({
|
||||||
|
"time":time.time(),
|
||||||
|
"signal": hv_signal["signal"],
|
||||||
|
"strength": hv_signal["hurst"],
|
||||||
|
"reason": f"H={hv_signal['hurst']:.2f}_V={hv_signal['vpin']:.2f}"
|
||||||
|
})
|
||||||
|
except: pass
|
||||||
|
|
||||||
# Trim signals
|
# Trim signals
|
||||||
for s in STRATEGIES.values(): s["signals"] = s["signals"][-20:]
|
for s in STRATEGIES.values(): s["signals"] = s["signals"][-20:]
|
||||||
@@ -182,7 +234,11 @@ async def main():
|
|||||||
if not perps:
|
if not perps:
|
||||||
log.info("Loading perps from mainnet API directly...")
|
log.info("Loading perps from mainnet API directly...")
|
||||||
try:
|
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()
|
meta = meta_r.json()
|
||||||
for asset in meta.get("universe", []):
|
for asset in meta.get("universe", []):
|
||||||
name = asset.get("name", "")
|
name = asset.get("name", "")
|
||||||
@@ -223,7 +279,7 @@ async def main():
|
|||||||
log.info(f" BTC: bid=${btc_bid:,.0f} ask=${btc_ask:,.0f} (spread=${btc_ask-btc_bid:.1f})")
|
log.info(f" BTC: bid=${btc_bid:,.0f} ask=${btc_ask:,.0f} (spread=${btc_ask-btc_bid:.1f})")
|
||||||
log.info(f" ETH: bid=${eth_bid:,.0f} ask=${eth_ask:,.0f} (spread=${eth_ask-eth_bid:.1f})")
|
log.info(f" ETH: bid=${eth_bid:,.0f} ask=${eth_ask:,.0f} (spread=${eth_ask-eth_bid:.1f})")
|
||||||
log.info(f" Mode: POST-ONLY at best bid/ask | Maker: 0.02%")
|
log.info(f" Mode: POST-ONLY at best bid/ask | Maker: 0.02%")
|
||||||
log.info(f" 7 strategies | A-S is DUAL-SIDED quoting")
|
log.info(f" {len(STRATEGIES)} strategies | A-S is DUAL-SIDED quoting")
|
||||||
log.info(f" Dashboard: https://ftdt.io/cv")
|
log.info(f" Dashboard: https://ftdt.io/cv")
|
||||||
log.info("="*60)
|
log.info("="*60)
|
||||||
|
|
||||||
@@ -236,7 +292,7 @@ async def main():
|
|||||||
except: pass
|
except: pass
|
||||||
log.info(f"Cleared {len(open_ords)} stale orders")
|
log.info(f"Cleared {len(open_ords)} stale orders")
|
||||||
|
|
||||||
existing = get_fills(addr)
|
existing = get_fills(addr) or []
|
||||||
for f in existing: seen_fills.add(f.get("tid",0))
|
for f in existing: seen_fills.add(f.get("tid",0))
|
||||||
log.info(f"Tracking {len(seen_fills)} existing fills")
|
log.info(f"Tracking {len(seen_fills)} existing fills")
|
||||||
|
|
||||||
@@ -248,125 +304,236 @@ async def main():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
tick+=1
|
try:
|
||||||
|
tick += 1
|
||||||
|
|
||||||
prices = get_mark_prices()
|
prices = get_mark_prices()
|
||||||
btc = prices.get("BTC",0); eth = prices.get("ETH",0)
|
btc = prices.get("BTC", 0)
|
||||||
if btc>0: btc_prices.append(btc)
|
eth = prices.get("ETH", 0)
|
||||||
if eth>0: eth_prices.append(eth)
|
if btc > 0:
|
||||||
|
btc_prices.append(btc)
|
||||||
|
if eth > 0:
|
||||||
|
eth_prices.append(eth)
|
||||||
|
|
||||||
# Process fills
|
# Process fills
|
||||||
fills = get_fills(addr); new_fills=0
|
fills = get_fills(addr)
|
||||||
for f in fills:
|
new_fills = 0
|
||||||
tid=f.get("tid",0)
|
for f in fills:
|
||||||
if tid in seen_fills: continue
|
tid = f.get("tid", 0)
|
||||||
seen_fills.add(tid)
|
if tid in seen_fills:
|
||||||
side=f.get("side",""); sz=float(f.get("sz",0)); px=float(f.get("px",0))
|
continue
|
||||||
closed_pnl=float(f.get("closedPnl",0)); fee=float(f.get("fee","0"))
|
seen_fills.add(tid)
|
||||||
|
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"))
|
||||||
|
|
||||||
strat=None
|
# Attribute fill by size (now unique per strategy)
|
||||||
for n,cfg in STRATEGIES.items():
|
strat = None
|
||||||
if abs(sz-cfg["size"])<0.00001: strat=n; break
|
for n, cfg in STRATEGIES.items():
|
||||||
if not strat: continue
|
if abs(sz - cfg["size"]) < 0.000001:
|
||||||
|
strat = n
|
||||||
|
break
|
||||||
|
if not strat:
|
||||||
|
continue
|
||||||
|
|
||||||
net=closed_pnl-abs(fee)
|
net = closed_pnl - abs(fee)
|
||||||
STRATEGIES[strat]["pnl"]+=net; STRATEGIES[strat]["trades_today"]+=1
|
STRATEGIES[strat]["pnl"] += net
|
||||||
STRATEGIES[strat]["fee_paid"]+=abs(fee)
|
STRATEGIES[strat]["trades_today"] += 1
|
||||||
if closed_pnl>0: STRATEGIES[strat]["wins"]+=1
|
STRATEGIES[strat]["fee_paid"] += abs(fee)
|
||||||
STRATEGIES[strat]["pnl_pct"]=STRATEGIES[strat]["pnl"]/STRATEGIES[strat]["allocation"]*100
|
if closed_pnl > 0:
|
||||||
strategy_equity[strat].append({"t":time.time(),"v":STRATEGIES[strat]["allocation"]+STRATEGIES[strat]["pnl"]})
|
STRATEGIES[strat]["wins"] += 1
|
||||||
trades_log.append({"time":datetime.now().strftime("%H:%M:%S"),"strategy":strat,"side":"BUY" if side=="B" else "SELL","size":sz,"price":px,"pnl":round(net,4),"fee":round(abs(fee),4)})
|
# Track position for AS model
|
||||||
new_fills+=1
|
if side == "B":
|
||||||
|
STRATEGIES[strat]["position"] = STRATEGIES[strat].get("position", 0.0) + sz
|
||||||
|
else:
|
||||||
|
STRATEGIES[strat]["position"] = STRATEGIES[strat].get("position", 0.0) - sz
|
||||||
|
STRATEGIES[strat]["pnl_pct"] = STRATEGIES[strat]["pnl"] / STRATEGIES[strat]["allocation"] * 100
|
||||||
|
strategy_equity[strat].append({"t": time.time(), "v": STRATEGIES[strat]["allocation"] + STRATEGIES[strat]["pnl"]})
|
||||||
|
if len(strategy_equity[strat]) > 1000:
|
||||||
|
strategy_equity[strat][:] = strategy_equity[strat][-600:]
|
||||||
|
trades_log.append({"time": datetime.now().strftime("%H:%M:%S"), "strategy": strat, "side": "BUY" if side == "B" else "SELL", "size": sz, "price": px, "pnl": round(net, 4), "fee": round(abs(fee), 4)})
|
||||||
|
new_fills += 1
|
||||||
|
|
||||||
# Signals every 5 ticks
|
# Signals every 5 ticks
|
||||||
if tick%5==0: compute_signals()
|
if tick % 5 == 0:
|
||||||
|
compute_signals()
|
||||||
|
|
||||||
# Place/refresh orders every 3-5 ticks
|
# Execute ALL strategies every 4 seconds
|
||||||
if tick>=3 and tick%random.randint(3,5)==0:
|
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")
|
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Cancel previous order for this strategy
|
|
||||||
if name in active_cloids:
|
|
||||||
try:
|
try:
|
||||||
client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name]))
|
eth_bid, eth_ask, eth_mid = get_orderbook("ETH")
|
||||||
except: pass
|
except Exception:
|
||||||
|
eth_bid = eth_ask = eth_mid = 0
|
||||||
|
if btc_bid <= 0 or btc_ask <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
# Determine side from signal or market-making pattern
|
for name in names:
|
||||||
signal=None
|
cfg = STRATEGIES[name]
|
||||||
if cfg["signals"]: signal=cfg["signals"][-1]["signal"] if cfg["signals"] else None
|
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
|
||||||
|
|
||||||
if name=="Avellaneda-Stoikov":
|
# Check if this strategy has a position; skip if already filled
|
||||||
# DUAL-SIDED: place both bid and ask simultaneously
|
has_position = name in active_cloids and tick - active_cloids_times.get(name, 0) < 60
|
||||||
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
|
|
||||||
|
|
||||||
# Single-sided for other strategies
|
# Determine signal
|
||||||
side=None; px_level=0
|
signal = None
|
||||||
if signal and "SELL" in str(signal).upper():
|
if cfg["signals"]:
|
||||||
side=OrderSide.SELL; px_level=ask # at best ask (highest fill probability as maker)
|
latest = cfg["signals"][-1]
|
||||||
elif signal and "BUY" in str(signal).upper():
|
# Only use recent signals (< 10 seconds old)
|
||||||
side=OrderSide.BUY; px_level=bid # at best bid
|
if time.time() - latest["time"] < 10:
|
||||||
else:
|
signal = latest["signal"]
|
||||||
# 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
|
|
||||||
|
|
||||||
if not side or px_level<=0: 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 Exception:
|
||||||
|
pass
|
||||||
|
del active_cloids[name]
|
||||||
|
has_position = False
|
||||||
|
|
||||||
cid=ClientOrderId(str(UUID4()))
|
# Take-profit: close if price moved 2x fee in our favor
|
||||||
try:
|
if has_position:
|
||||||
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)
|
entry_px = active_cloids_px.get(name, 0)
|
||||||
side_str="BUY " if side==OrderSide.BUY else "SELL"
|
if entry_px > 0:
|
||||||
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):,})")
|
if "BUY" in str(active_cloids[name]).upper() and mid > entry_px * 1.001:
|
||||||
active_cloids[name]=str(cid)
|
try:
|
||||||
except Exception as e:
|
client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name]))
|
||||||
err=str(e)
|
except Exception:
|
||||||
if "would have immediately matched" in err or "cross" in err.lower():
|
pass
|
||||||
# Post-only would cross — fall back to regular limit at same level
|
del active_cloids[name]
|
||||||
cid2=ClientOrderId(str(UUID4()))
|
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 Exception:
|
||||||
|
pass
|
||||||
|
del active_cloids[name]
|
||||||
|
has_position = False
|
||||||
|
|
||||||
|
if has_position:
|
||||||
|
continue # Don't replace existing orders
|
||||||
|
|
||||||
|
# Avellaneda-Stoikov: proper optimal control (reservation price + spread)
|
||||||
|
if name == "Avellaneda-Stoikov":
|
||||||
|
try:
|
||||||
|
from strategies.as_quoter import ASQuoter
|
||||||
|
if "_as_quoter" not in dir():
|
||||||
|
globals()["_as_quoter"] = ASQuoter(
|
||||||
|
gamma=0.1, k=1.5, tau=1.0,
|
||||||
|
min_spread=0.0001, max_inventory=cfg["size"] * 5,
|
||||||
|
)
|
||||||
|
q = ASQuoter
|
||||||
|
asq = globals()["_as_quoter"]
|
||||||
|
asq.observe(mid)
|
||||||
|
|
||||||
|
# Get A-S inventory from position tracking
|
||||||
|
as_inv = STRATEGIES[name].get("position", 0.0)
|
||||||
|
elapsed = (tick * 1.0) % (asq.tau * 3600) / 3600.0 # 1-hour virtual sessions
|
||||||
|
|
||||||
|
result = asq.quotes(mid, as_inv, elapsed)
|
||||||
|
if result is None:
|
||||||
|
continue # Circuit breaker active — skip this tick
|
||||||
|
|
||||||
|
r_price = result["reservation"]
|
||||||
|
as_bid = int(result["bid"])
|
||||||
|
as_ask = int(result["ask"])
|
||||||
|
# Clamp: never cross the market
|
||||||
|
as_bid = min(as_bid, int(bid))
|
||||||
|
as_ask = max(as_ask, int(ask))
|
||||||
|
|
||||||
|
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(as_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(as_ask)), time_in_force=TimeInForce.GTC, post_only=True)
|
||||||
|
if tick % 60 == 0:
|
||||||
|
log.info(f"[AS] r={r_price:.1f} σ={asq.sigma*100:.2f}% BID {cfg['size']} @ ${as_bid:,} | ASK {cfg['size']} @ ${as_ask:,} (spread ${as_ask - as_bid:,})")
|
||||||
|
active_cloids[name] = str(cid_bid)
|
||||||
|
active_cloids_times[name] = tick
|
||||||
|
active_cloids_px[name] = as_bid
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
# Fallback: best bid/ask if module unavailable
|
||||||
|
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)
|
||||||
|
active_cloids[name] = str(cid_bid)
|
||||||
|
active_cloids_times[name] = tick
|
||||||
|
active_cloids_px[name] = bid
|
||||||
|
except Exception:
|
||||||
|
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:
|
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)
|
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)
|
||||||
log.info(f"[{name[:4]:4s}] {side_str} {cfg['size']} {coin} FILLED @ ${int(px_level):,} (post-only crossed → IOC)")
|
if tick % 60 == 0:
|
||||||
active_cloids[name]=str(cid2)
|
side_str = "BUY" if side == OrderSide.BUY else "SELL"
|
||||||
except Exception as e2: log.debug(f"[{name[:8]}] fallback failed: {str(e2)[:50]}")
|
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))})")
|
||||||
else: log.warning(f"Order [{name[:8]}]: {err[:60]}")
|
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 Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Equity
|
# Equity
|
||||||
tp=sum(s["pnl"] for s in STRATEGIES.values())
|
tp = sum(s["pnl"] for s in STRATEGIES.values())
|
||||||
if tick%2==0: equity_history.append({"t":time.time(),"v":TOTAL_EQUITY+tp})
|
if tick % 2 == 0:
|
||||||
write_metrics(addr)
|
equity_history.append({"t": time.time(), "v": TOTAL_EQUITY + tp})
|
||||||
|
if len(equity_history) > 1000:
|
||||||
|
equity_history[:] = equity_history[-600:]
|
||||||
|
write_metrics(addr)
|
||||||
|
|
||||||
if tick%20==0:
|
if tick % 20 == 0:
|
||||||
tp=sum(s["pnl"] for s in STRATEGIES.values())
|
tp = sum(s["pnl"] for s in STRATEGIES.values())
|
||||||
tr=sum(s["trades_today"] for s in STRATEGIES.values())
|
tr = sum(s["trades_today"] for s in STRATEGIES.values())
|
||||||
tf=sum(s["fee_paid"] for s in STRATEGIES.values())
|
tf = sum(s["fee_paid"] for s in STRATEGIES.values())
|
||||||
log.info(f"Tick {tick:4d} | PnL: ${tp:+.2f} | Trades: {tr:3d} | Fees: ${tf:.4f} | New fills: {new_fills}")
|
log.info(f"Tick {tick:4d} | PnL: ${tp:+.2f} | Trades: {tr:3d} | Fees: ${tf:.4f} | New fills: {new_fills}")
|
||||||
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
except KeyboardInterrupt: log.info("Stopping...")
|
except Exception as loop_err:
|
||||||
|
log.error(f"Loop error (tick {tick}): {loop_err}")
|
||||||
|
await asyncio.sleep(5) # back off and retry
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log.info("Stopping...")
|
||||||
|
|
||||||
# Cancel all
|
# Cancel all
|
||||||
open_ords = requests.post(TESTNET_API, json={"type":"openOrders","user":addr}, timeout=10).json()
|
open_ords = requests.post(TESTNET_API, json={"type":"openOrders","user":addr}, timeout=10).json()
|
||||||
|
|||||||
+66
-34
@@ -42,49 +42,49 @@ STRATEGIES = {
|
|||||||
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
||||||
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
||||||
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
||||||
"signals": [], "type": "reversal", "size": 0.002, "fee_model": "taker",
|
"signals": [], "type": "reversal", "size":0.000800, "fee_model": "taker",
|
||||||
"description": "L2 bid/ask volume skew — buys when bids dominate, sells when asks dominate. Mean-reverting at volume extremes.",
|
"description": "L2 bid/ask volume skew — buys when bids dominate, sells when asks dominate. Mean-reverting at volume extremes.",
|
||||||
},
|
},
|
||||||
"Iceberg Detection": {
|
"Iceberg Detection": {
|
||||||
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
||||||
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
||||||
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
||||||
"signals": [], "type": "momentum", "size": 0.001, "fee_model": "taker",
|
"signals": [], "type": "momentum", "size":0.000850, "fee_model": "taker",
|
||||||
"description": "Detects whale accumulation (many small buys over time). Follows the smart money flow.",
|
"description": "Detects whale accumulation (many small buys over time). Follows the smart money flow.",
|
||||||
},
|
},
|
||||||
"Funding Rate Arb": {
|
"Funding Rate Arb": {
|
||||||
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
||||||
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
||||||
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
||||||
"signals": [], "type": "carry", "size": 0.005, "fee_model": "taker",
|
"signals": [], "type": "carry", "size":0.000900, "fee_model": "taker",
|
||||||
"description": "Delta-neutral carry trade — shorts perp when funding rate is high, collects hourly payments.",
|
"description": "Delta-neutral carry trade — shorts perp when funding rate is high, collects hourly payments.",
|
||||||
},
|
},
|
||||||
"Pairs Trading": {
|
"Pairs Trading": {
|
||||||
"allocation": 10000.0, "instrument": "ETH", "pnl": 0.0,
|
"allocation": 10000.0, "instrument": "ETH", "pnl": 0.0,
|
||||||
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
||||||
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
||||||
"signals": [], "type": "stat_arb", "size": 0.05, "fee_model": "taker",
|
"signals": [], "type": "stat_arb", "size":0.027500, "fee_model": "taker",
|
||||||
"description": "BTC/ETH spread mean reversion — trades when Z-score exceeds 1.5 sigma. Pairs converge back to equilibrium.",
|
"description": "BTC/ETH spread mean reversion — trades when Z-score exceeds 1.5 sigma. Pairs converge back to equilibrium.",
|
||||||
},
|
},
|
||||||
"Avellaneda-Stoikov": {
|
"Avellaneda-Stoikov": {
|
||||||
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
||||||
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
||||||
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
||||||
"signals": [], "type": "market_making", "size": 0.001, "fee_model": "maker",
|
"signals": [], "type": "market_making", "size":0.000950, "fee_model": "maker",
|
||||||
"description": "Dual-sided quoting at best bid/ask — captures spread via stochastic control. Simulated fill when spread is crossed.",
|
"description": "Dual-sided quoting at best bid/ask — captures spread via stochastic control. Simulated fill when spread is crossed.",
|
||||||
},
|
},
|
||||||
"Momentum Breakout": {
|
"Momentum Breakout": {
|
||||||
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
||||||
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
||||||
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
||||||
"signals": [], "type": "momentum", "size": 0.002, "fee_model": "taker",
|
"signals": [], "type": "momentum", "size":0.020000, "fee_model": "taker",
|
||||||
"description": "Bollinger Band (2σ) breakout — enters when price breaks bands with volume confirmation.",
|
"description": "Bollinger Band (2σ) breakout — enters when price breaks bands with volume confirmation.",
|
||||||
},
|
},
|
||||||
"Mean Reversion": {
|
"Mean Reversion": {
|
||||||
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
||||||
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
||||||
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
||||||
"signals": [], "type": "reversal", "size": 0.002, "fee_model": "taker",
|
"signals": [], "type": "reversal", "size":0.022500, "fee_model": "taker",
|
||||||
"description": "VWAP deviation — buys below VWAP, sells above. Oscillates around fair value.",
|
"description": "VWAP deviation — buys below VWAP, sells above. Oscillates around fair value.",
|
||||||
},
|
},
|
||||||
"Hawkes OFI (new)": {
|
"Hawkes OFI (new)": {
|
||||||
@@ -236,27 +236,40 @@ def compute_signals():
|
|||||||
elif up <= 3:
|
elif up <= 3:
|
||||||
STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"SELL","strength":1-up/10})
|
STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"SELL","strength":1-up/10})
|
||||||
|
|
||||||
# Funding Arb — use actual mainnet funding rate
|
# Funding Rate Arb — unified module with real API data
|
||||||
if funding_rates and isinstance(funding_rates[-1], dict):
|
try:
|
||||||
btc_fr = funding_rates[-1].get("BTC", 0)
|
from strategies.funding_arb import funding_arb_signal
|
||||||
# Annualized: funding every 8h → 3× daily → 1095× yearly
|
sig_result = funding_arb_signal(coin="BTC", apr_threshold=0.05, apr_exit=0.02,
|
||||||
annual_fr = abs(btc_fr) * 365 * 3 if btc_fr else 0
|
current_position=STRATEGIES["Funding Rate Arb"]["position"])
|
||||||
# Log funding rate periodically
|
if sig_result["signal"] != 0:
|
||||||
import random as _random_fr
|
STRATEGIES["Funding Rate Arb"]["signals"].append({
|
||||||
if _random_fr.random() < 0.02:
|
"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
|
import logging
|
||||||
logging.getLogger("ftdt-paper").info(
|
logging.getLogger("ftdt-paper").info(
|
||||||
"{} Funding rate: {:.6f}% 8h | {:.2f}% APR | signal={}".format(
|
f"[Fund] APR={sig_result['annual_apr']*100:.2f}% | "
|
||||||
"[Fund]", btc_fr*100, annual_fr*100,
|
f"8h={sig_result['rate_8h']*100:.6f}% | "
|
||||||
"SELL" if btc_fr > 0 else "BUY" if btc_fr < 0 else "NONE"
|
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
|
# Pairs: BTC/ETH ratio Z-score
|
||||||
if len(btc_prices) >= 20 and len(eth_prices) >= 20:
|
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})
|
STRATEGIES["Pairs Trading"]["signals"].append({"time":time.time(),"signal":"SELL_ETH","strength":z})
|
||||||
elif z < -1.5:
|
elif z < -1.5:
|
||||||
STRATEGIES["Pairs Trading"]["signals"].append({"time":time.time(),"signal":"BUY_ETH","strength":abs(z)})
|
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
|
# Momentum Breakout
|
||||||
if len(btc_prices) >= 20:
|
if len(btc_prices) >= 20:
|
||||||
@@ -281,15 +311,17 @@ def compute_signals():
|
|||||||
elif btc < sma - 2*std:
|
elif btc < sma - 2*std:
|
||||||
STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"SELL","strength":(sma-2*std-btc)/std})
|
STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"SELL","strength":(sma-2*std-btc)/std})
|
||||||
|
|
||||||
# Mean Reversion
|
# Mean Reversion: SMA deviation on ETH (prior 19, exclude current)
|
||||||
if len(btc_prices) >= 20:
|
if len(eth_prices) >= 20:
|
||||||
w = list(btc_prices)[-20:]; vols = [1 + i/len(w) for i in range(len(w))]
|
w = list(eth_prices)[-20:]
|
||||||
vwap = sum(p*v for p,v in zip(w, vols)) / sum(vols)
|
eth_now = eth_prices[-1]
|
||||||
vstd = math.sqrt(sum((p-vwap)**2 for p in w) / len(w))
|
prior = w[:-1]
|
||||||
dev = (btc - vwap) / vstd if vstd > 0 else 0
|
sma = sum(prior) / len(prior)
|
||||||
if dev > 1.5:
|
vstd = math.sqrt(sum((p-sma)**2 for p in prior) / len(prior))
|
||||||
|
dev = (eth_now - sma) / vstd if vstd > 0 else 0
|
||||||
|
if dev > 1.0:
|
||||||
STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"SELL","strength":dev})
|
STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"SELL","strength":dev})
|
||||||
elif dev < -1.5:
|
elif dev < -1.0:
|
||||||
STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"BUY","strength":abs(dev)})
|
STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"BUY","strength":abs(dev)})
|
||||||
|
|
||||||
for s in STRATEGIES.values():
|
for s in STRATEGIES.values():
|
||||||
@@ -323,7 +355,7 @@ def simulate_fill(name: str, side: str, coin: str, price: float, reason: str = "
|
|||||||
trades_log.append({
|
trades_log.append({
|
||||||
"time": datetime.now().strftime("%H:%M:%S"),
|
"time": datetime.now().strftime("%H:%M:%S"),
|
||||||
"strategy": name, "side": "BUY (close short)",
|
"strategy": name, "side": "BUY (close short)",
|
||||||
"size": abs(cfg["position"] if cfg["position"] < 0 else sz),
|
"size":0.025000,
|
||||||
"price": price, "pnl": round(close_pnl - fee - slippage, 4),
|
"price": price, "pnl": round(close_pnl - fee - slippage, 4),
|
||||||
"fee": round(fee, 4),
|
"fee": round(fee, 4),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ pandas>=2.0.0
|
|||||||
pyyaml>=6.0
|
pyyaml>=6.0
|
||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
|
|
||||||
|
# Framework
|
||||||
|
vectorbt>=1.0.0
|
||||||
|
hyperliquid-python-sdk>=0.20.0
|
||||||
|
websockets>=12.0
|
||||||
|
|
||||||
# Dashboard
|
# Dashboard
|
||||||
fastapi>=0.109.0
|
fastapi>=0.109.0
|
||||||
uvicorn[standard]>=0.27.0
|
uvicorn[standard]>=0.27.0
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Proper Avellaneda-Stoikov market making for the live node.
|
||||||
|
|
||||||
|
Key formulas (Avellaneda & Stoikov, 2008):
|
||||||
|
Reservation price: r = s - q * gamma * sigma^2 * tau
|
||||||
|
Optimal spread: spread = gamma * sigma^2 * tau + (2/gamma) * ln(1 + gamma/k)
|
||||||
|
Bid = r - spread/2 Ask = r + spread/2
|
||||||
|
|
||||||
|
Where:
|
||||||
|
s = mid price, q = inventory, gamma = risk aversion
|
||||||
|
sigma = volatility, tau = remaining session time, k = order intensity
|
||||||
|
|
||||||
|
Production adaptations:
|
||||||
|
- Rolling volatility estimation (5-min window)
|
||||||
|
- Circuit breaker: pause quoting when price jump exceeds 3σ
|
||||||
|
- Inventory bounds: stop quoting on over-exposed side
|
||||||
|
- Virtual session clock: 1-hour windows since crypto is 24/7
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
|
||||||
|
class ASQuoter:
|
||||||
|
"""Stateless per-tick quote generator using A-S optimal control."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
gamma: float = 0.1, # Risk aversion — higher = more aggressive inventory redux
|
||||||
|
k: float = 1.5, # Order flow sensitivity — higher = tighter market
|
||||||
|
tau: float = 1.0, # Virtual session length (hours, for 24/7 crypto)
|
||||||
|
min_spread: float = 0.0001, # 1 bp minimum spread
|
||||||
|
max_inventory: float = 0.001, # Max position before stopping one side
|
||||||
|
vol_window: int = 300, # Number of price ticks for rolling vol (5 min @ 1s)
|
||||||
|
cb_mult: float = 3.0, # Circuit breaker multiplier (3σ jump threshold)
|
||||||
|
):
|
||||||
|
self.gamma = gamma
|
||||||
|
self.k = k
|
||||||
|
self.tau = tau
|
||||||
|
self.min_spread = min_spread
|
||||||
|
self.max_inventory = max_inventory
|
||||||
|
self.vol_window = vol_window
|
||||||
|
self.cb_mult = cb_mult
|
||||||
|
|
||||||
|
self._mid_prices: deque[float] = deque(maxlen=vol_window)
|
||||||
|
self._current_sigma: float = 0.02 # fallback: ~32% annualized for crypto
|
||||||
|
self._session_start: float = 0.0
|
||||||
|
|
||||||
|
def observe(self, mid: float) -> None:
|
||||||
|
"""Feed a new mid-price observation. Updates rolling volatility."""
|
||||||
|
self._mid_prices.append(mid)
|
||||||
|
if len(self._mid_prices) >= 2:
|
||||||
|
prices = list(self._mid_prices)
|
||||||
|
returns = [
|
||||||
|
(prices[i] - prices[i - 1]) / prices[i - 1]
|
||||||
|
for i in range(1, len(prices))
|
||||||
|
]
|
||||||
|
mu = sum(returns) / len(returns)
|
||||||
|
var = sum((r - mu) ** 2 for r in returns) / len(returns)
|
||||||
|
sigma = math.sqrt(var) if var > 0 else 0.02
|
||||||
|
self._current_sigma = sigma
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sigma(self) -> float:
|
||||||
|
return self._current_sigma
|
||||||
|
|
||||||
|
def circuit_breaker(self) -> bool:
|
||||||
|
"""Check if recent price jump exceeds threshold. If true, pause quoting."""
|
||||||
|
if len(self._mid_prices) < 5:
|
||||||
|
return False
|
||||||
|
recent = list(self._mid_prices)[-5:]
|
||||||
|
move_pct = abs(recent[-1] - recent[0]) / recent[0]
|
||||||
|
threshold = self.cb_mult * self._current_sigma * math.sqrt(5)
|
||||||
|
return move_pct > threshold
|
||||||
|
|
||||||
|
def quotes(self, mid: float, inventory: float, t: float) -> dict | None:
|
||||||
|
"""
|
||||||
|
Generate bid/ask quotes given current state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mid: current mid-price
|
||||||
|
inventory: current net position (positive = long)
|
||||||
|
t: elapsed session time in hours (0 to tau)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"bid": ..., "ask": ..., "reservation": ..., "spread": ...} or None if paused
|
||||||
|
"""
|
||||||
|
self.observe(mid)
|
||||||
|
|
||||||
|
if self.circuit_breaker():
|
||||||
|
return None # Pause quoting — price jump in progress
|
||||||
|
|
||||||
|
# Reservation price: skew center by inventory risk
|
||||||
|
tau_remaining = max(self.tau - t, 0.01)
|
||||||
|
reservation = mid - inventory * self.gamma * (self._current_sigma ** 2) * tau_remaining
|
||||||
|
|
||||||
|
# Optimal spread: balance risk compensation vs flow capture
|
||||||
|
try:
|
||||||
|
log_term = math.log(1.0 + self.gamma / self.k)
|
||||||
|
except ValueError:
|
||||||
|
log_term = 0.0
|
||||||
|
spread = (
|
||||||
|
self.gamma * (self._current_sigma ** 2) * tau_remaining
|
||||||
|
+ (2.0 / max(self.gamma, 0.001)) * log_term
|
||||||
|
)
|
||||||
|
spread = max(spread, self.min_spread)
|
||||||
|
|
||||||
|
half = spread / 2.0
|
||||||
|
bid = reservation - half
|
||||||
|
ask = reservation + half
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bid": max(bid, 1.0), # Never negative/zero
|
||||||
|
"ask": max(ask, 1.0),
|
||||||
|
"reservation": reservation,
|
||||||
|
"spread": spread,
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Funding Rate Arb — Complete Implementation.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
Funding rates on perpetual futures represent the cost of leverage.
|
||||||
|
When funding is positive (longs pay shorts), short the perp and collect.
|
||||||
|
When funding is negative (shorts pay longs), go long the perp and collect.
|
||||||
|
|
||||||
|
The Hyperliquid API provides predicted funding rates via:
|
||||||
|
- predictedFundings: current predicted rate for each interval
|
||||||
|
- metaAndAssetCtxs: asset context including current funding
|
||||||
|
|
||||||
|
Entry: |annualized_funding_rate| > threshold (5-10% APR)
|
||||||
|
Exit: |annualized_funding_rate| < threshold/2 or after N hours
|
||||||
|
Size: scales with rate — higher rate = larger size
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
MAINNET_API = "https://api.hyperliquid.xyz/info"
|
||||||
|
TESTNET_API = "https://api.hyperliquid-testnet.xyz/info"
|
||||||
|
|
||||||
|
# Cache funding rates to avoid hitting API every tick
|
||||||
|
_funding_cache: dict = {}
|
||||||
|
_last_funding_fetch: float = 0
|
||||||
|
FUNDING_CACHE_TTL = 30 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def get_funding_rates(use_testnet: bool = False) -> dict[str, float]:
|
||||||
|
"""
|
||||||
|
Fetch current predicted funding rates for supported coins.
|
||||||
|
|
||||||
|
Uses Hyperliquid's predictedFundings endpoint which returns
|
||||||
|
the current projected funding rate for each perpetual.
|
||||||
|
|
||||||
|
Returns: {coin: funding_rate_annualized}
|
||||||
|
"""
|
||||||
|
global _funding_cache, _last_funding_fetch
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if now - _last_funding_fetch < FUNDING_CACHE_TTL and _funding_cache:
|
||||||
|
return _funding_cache
|
||||||
|
|
||||||
|
api = TESTNET_API if use_testnet else MAINNET_API
|
||||||
|
rates: dict[str, float] = {}
|
||||||
|
|
||||||
|
# Method 1: Try metaAndAssetCtxs (most reliable)
|
||||||
|
try:
|
||||||
|
r = requests.post(MAINNET_API, json={"type": "metaAndAssetCtxs"}, timeout=10)
|
||||||
|
data = r.json()
|
||||||
|
if isinstance(data, list) and len(data) >= 2:
|
||||||
|
universe = data[0].get("universe", [])
|
||||||
|
ctxs = data[1]
|
||||||
|
for i, u in enumerate(universe):
|
||||||
|
name = u.get("name", "")
|
||||||
|
if name in ("BTC", "ETH", "HYPE", "VVV", "SOL"):
|
||||||
|
try:
|
||||||
|
funding = float(ctxs[i].get("funding", 0))
|
||||||
|
# funding is the 8h rate; annualize: × 365 × (24/8) = × 1095
|
||||||
|
annual = funding * 1095
|
||||||
|
rates[name] = annual
|
||||||
|
except (IndexError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Method 2: Fallback to predictedFundings
|
||||||
|
if not rates:
|
||||||
|
try:
|
||||||
|
r = requests.post(MAINNET_API, json={"type": "predictedFundings"}, timeout=10)
|
||||||
|
data = r.json()
|
||||||
|
if isinstance(data, list):
|
||||||
|
for coin_entry in data:
|
||||||
|
coin = coin_entry[0]
|
||||||
|
if coin not in ("BTC", "ETH", "HYPE", "VVV", "SOL"):
|
||||||
|
continue
|
||||||
|
for venue_entry in coin_entry[1]:
|
||||||
|
venue = venue_entry[0]
|
||||||
|
info = venue_entry[1]
|
||||||
|
rate_str = info.get("fundingRate", "0")
|
||||||
|
try:
|
||||||
|
rate = float(rate_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
rate = 0.0
|
||||||
|
interval_hours = info.get("fundingIntervalHours", 8)
|
||||||
|
annual = rate * (365 * 24 / interval_hours)
|
||||||
|
if coin not in rates or "HlPerp" in venue:
|
||||||
|
rates[coin] = annual
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_funding_cache = rates
|
||||||
|
_last_funding_fetch = now
|
||||||
|
return rates
|
||||||
|
|
||||||
|
|
||||||
|
def funding_arb_signal(
|
||||||
|
coin: str = "BTC",
|
||||||
|
apr_threshold: float = 0.05, # 5% APR minimum
|
||||||
|
apr_exit: float = 0.02, # 2% APR to exit
|
||||||
|
current_position: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Generate funding rate arbitrage signal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coin: Ticker to check.
|
||||||
|
apr_threshold: Minimum annualized funding rate to enter (>0.05 = 5%).
|
||||||
|
apr_exit: Rate below which to exit position.
|
||||||
|
current_position: -1 (short), 0 (none), +1 (long).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with signal, rate, annual_apr, reason.
|
||||||
|
"""
|
||||||
|
rates = get_funding_rates()
|
||||||
|
annual = rates.get(coin, 0)
|
||||||
|
rate_8h = annual / 1095 # de-annualize
|
||||||
|
|
||||||
|
signal = 0
|
||||||
|
reason = ""
|
||||||
|
|
||||||
|
if abs(annual) > apr_threshold and current_position == 0:
|
||||||
|
signal = -1 if annual > 0 else +1 # short if funding positive, long if negative
|
||||||
|
reason = f"funding_{annual*100:.1f}pct_apr"
|
||||||
|
elif current_position != 0:
|
||||||
|
# Exit condition: rate has dropped below exit threshold
|
||||||
|
if abs(annual) < apr_exit:
|
||||||
|
signal = -current_position
|
||||||
|
reason = f"exit_funding_{annual*100:.2f}pct_apr"
|
||||||
|
# Also exit if funding flips sign (we'd be paying instead of collecting)
|
||||||
|
elif (current_position == -1 and annual < 0) or (current_position == 1 and annual > 0):
|
||||||
|
signal = -current_position
|
||||||
|
reason = f"exit_funding_flipped_{annual*100:.2f}pct_apr"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"signal": signal,
|
||||||
|
"rate_8h": rate_8h,
|
||||||
|
"annual_apr": annual,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""
|
||||||
|
Hurst Exponent + VPIN Directional Strategy for Hyperliquid BTC-USD-PERP.
|
||||||
|
|
||||||
|
Based on nautilustrader tutorial:
|
||||||
|
https://nautilustrader.io/docs/latest/tutorials/hurst_vpin_kraken/
|
||||||
|
|
||||||
|
Components:
|
||||||
|
1. HURST EXPONENT (dollar bars) — R/S analysis, >0.55 = trending
|
||||||
|
2. VPIN (Volume-synchronized Probability of Informed Trading) —
|
||||||
|
buy/sell aggressor volume imbalance over dollar-bar buckets
|
||||||
|
3. QUOTE-DRIVEN ENTRY — both signals agree → place order on next tick
|
||||||
|
|
||||||
|
Data: Real Hyperliquid API trade fills (aggressor side + size + price).
|
||||||
|
Dollar bars: constant-notional $10,000 bars.
|
||||||
|
Hurst window: 128 bars (~R/S needs ≥ 64).
|
||||||
|
VPIN window: 50 buckets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from collections import deque
|
||||||
|
import time, json, requests, os
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 1. Dollar Bar Construction
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
class DollarBarBuilder:
|
||||||
|
"""Accumulate trades until notional threshold reached → emit bar."""
|
||||||
|
def __init__(self, threshold: float = 10_000.0):
|
||||||
|
self.threshold = threshold
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.accum_vol = 0.0
|
||||||
|
self.open = self.high = self.low = self.close = None
|
||||||
|
self.buy_vol = 0.0
|
||||||
|
self.sell_vol = 0.0
|
||||||
|
|
||||||
|
def add(self, price: float, size: float, side: str):
|
||||||
|
notional = price * size
|
||||||
|
self.accum_vol += notional
|
||||||
|
if side.upper() == "B":
|
||||||
|
self.buy_vol += notional
|
||||||
|
else:
|
||||||
|
self.sell_vol += notional
|
||||||
|
|
||||||
|
if self.open is None:
|
||||||
|
self.open = self.high = self.low = price
|
||||||
|
else:
|
||||||
|
self.high = max(self.high, price)
|
||||||
|
self.low = min(self.low, price)
|
||||||
|
self.close = price
|
||||||
|
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
return self.accum_vol >= self.threshold
|
||||||
|
|
||||||
|
def emit(self) -> dict:
|
||||||
|
bar = {
|
||||||
|
"open": self.open,
|
||||||
|
"high": self.high,
|
||||||
|
"low": self.low,
|
||||||
|
"close": self.close,
|
||||||
|
"buy_vol": self.buy_vol,
|
||||||
|
"sell_vol": self.sell_vol,
|
||||||
|
"total_vol": self.accum_vol,
|
||||||
|
}
|
||||||
|
self.reset()
|
||||||
|
return bar
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 2. Hurst Exponent (R/S Rescaled Range)
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
def hurst_rs(log_returns: list, max_lag: int = None) -> float:
|
||||||
|
"""R/S Hurst exponent on log returns.
|
||||||
|
|
||||||
|
H > 0.55 → persistent (trending)
|
||||||
|
H < 0.50 → anti-persistent (mean-reverting)
|
||||||
|
H ≈ 0.50 → random walk
|
||||||
|
"""
|
||||||
|
n = len(log_returns)
|
||||||
|
if n < 32:
|
||||||
|
return 0.50 # not enough data
|
||||||
|
|
||||||
|
if max_lag is None:
|
||||||
|
max_lag = min(n // 2, 64)
|
||||||
|
|
||||||
|
lags = range(2, min(max_lag + 1, n // 2 + 1))
|
||||||
|
rs_vals = []
|
||||||
|
for lag in lags:
|
||||||
|
if lag < 2: continue
|
||||||
|
segments = n // lag
|
||||||
|
if segments < 2: continue
|
||||||
|
r_div_s = []
|
||||||
|
for s in range(segments):
|
||||||
|
seg = log_returns[s * lag:(s + 1) * lag]
|
||||||
|
mean = np.mean(seg)
|
||||||
|
deviations = np.cumsum(seg - mean)
|
||||||
|
r = np.max(deviations) - np.min(deviations)
|
||||||
|
sd = np.std(seg, ddof=1)
|
||||||
|
if sd > 1e-12:
|
||||||
|
r_div_s.append(r / sd)
|
||||||
|
if r_div_s:
|
||||||
|
rs_vals.append(np.mean(r_div_s))
|
||||||
|
|
||||||
|
if len(rs_vals) < 4:
|
||||||
|
return 0.50
|
||||||
|
|
||||||
|
# H = slope of log(R/S) vs log(lag)
|
||||||
|
log_lags = np.log([l for l in lags if l >= 2][:len(rs_vals)])
|
||||||
|
log_rs = np.log(rs_vals)
|
||||||
|
slope, _ = np.polyfit(log_lags, log_rs, 1)
|
||||||
|
return min(max(slope, 0.20), 0.90)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 3. VPIN (Volume-synchronized Probability of Informed Trading)
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
class VPINComputer:
|
||||||
|
"""VPIN on dollar-bar buckets.
|
||||||
|
|
||||||
|
Each bucket = one dollar bar.
|
||||||
|
VPIN = abs(buy_vol - sell_vol) / total_vol of bucket.
|
||||||
|
Running average over `window` buckets.
|
||||||
|
"""
|
||||||
|
def __init__(self, window: int = 50):
|
||||||
|
self.window = window
|
||||||
|
self.buckets = deque(maxlen=window)
|
||||||
|
|
||||||
|
def add_bucket(self, buy_vol: float, sell_vol: float):
|
||||||
|
total = buy_vol + sell_vol
|
||||||
|
if total < 1.0:
|
||||||
|
self.buckets.append((0.0, 0.0))
|
||||||
|
else:
|
||||||
|
vpin = abs(buy_vol - sell_vol) / total
|
||||||
|
signed = (buy_vol - sell_vol) / total # + = net buying
|
||||||
|
self.buckets.append((vpin, signed))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vpin(self) -> float:
|
||||||
|
if not self.buckets:
|
||||||
|
return 0.0
|
||||||
|
return np.mean([b[0] for b in self.buckets])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def direction(self) -> float:
|
||||||
|
"""Signed net direction: +1 = strong buying, -1 = strong selling."""
|
||||||
|
if not self.buckets:
|
||||||
|
return 0.0
|
||||||
|
return np.mean([b[1] for b in self.buckets])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
return len(self.buckets) >= self.window
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 4. Strategy Signal Generator
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
class HurstVPINSignal:
|
||||||
|
def __init__(self, notional_threshold: float = 10_000.0,
|
||||||
|
hurst_window: int = 128, vpin_window: int = 50,
|
||||||
|
hurst_entry: float = 0.55, hurst_exit: float = 0.52,
|
||||||
|
vpin_threshold: float = 0.25):
|
||||||
|
self.builder = DollarBarBuilder(notional_threshold)
|
||||||
|
self.vpin = VPINComputer(vpin_window)
|
||||||
|
self.hurst_window = hurst_window
|
||||||
|
self.hurst_entry = hurst_entry
|
||||||
|
self.hurst_exit = hurst_exit
|
||||||
|
self.vpin_threshold = vpin_threshold
|
||||||
|
self.returns = deque(maxlen=hurst_window)
|
||||||
|
|
||||||
|
# Current state
|
||||||
|
self.hurst_val = 0.50
|
||||||
|
self.vpin_val = 0.0
|
||||||
|
self.vpin_dir = 0.0
|
||||||
|
self.position = 0 # -1 short, 0 flat, +1 long
|
||||||
|
self._hold_bars = 0
|
||||||
|
self.last_bar_close = 0.0
|
||||||
|
self.bar_count = 0
|
||||||
|
|
||||||
|
def add_trade(self, price: float, size: float, side: str):
|
||||||
|
"""Process a single trade tick."""
|
||||||
|
self.builder.add(price, size, side)
|
||||||
|
if self.builder.is_ready():
|
||||||
|
bar = self.builder.emit()
|
||||||
|
return self._process_bar(bar)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _process_bar(self, bar: dict) -> dict | None:
|
||||||
|
self.bar_count += 1
|
||||||
|
|
||||||
|
# Update VPIN
|
||||||
|
self.vpin.add_bucket(bar["buy_vol"], bar["sell_vol"])
|
||||||
|
self.vpin_val = self.vpin.vpin if self.vpin.ready else 0.0
|
||||||
|
self.vpin_dir = self.vpin.direction if self.vpin.ready else 0.0
|
||||||
|
|
||||||
|
# Update Hurst returns
|
||||||
|
if self.last_bar_close > 0:
|
||||||
|
log_ret = np.log(bar["close"] / self.last_bar_close)
|
||||||
|
self.returns.append(log_ret)
|
||||||
|
|
||||||
|
self.last_bar_close = bar["close"]
|
||||||
|
|
||||||
|
# Compute Hurst
|
||||||
|
if len(self.returns) >= self.hurst_window:
|
||||||
|
self.hurst_val = hurst_rs(list(self.returns))
|
||||||
|
else:
|
||||||
|
self.hurst_val = 0.50
|
||||||
|
|
||||||
|
# Signal logic
|
||||||
|
signal = self._compute_signal()
|
||||||
|
return {
|
||||||
|
"bar": bar,
|
||||||
|
"hurst": round(self.hurst_val, 4),
|
||||||
|
"vpin": round(self.vpin_val, 4),
|
||||||
|
"vpin_dir": round(self.vpin_dir, 4),
|
||||||
|
"signal": signal,
|
||||||
|
"position": self.position,
|
||||||
|
"bar_count": self.bar_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _compute_signal(self) -> str:
|
||||||
|
trending = self.hurst_val >= self.hurst_entry
|
||||||
|
high_vpin = self.vpin_val >= self.vpin_threshold
|
||||||
|
exiting = self.hurst_val <= self.hurst_exit
|
||||||
|
|
||||||
|
# Time-based exit: close after 20 bars regardless
|
||||||
|
if self.position != 0:
|
||||||
|
self._hold_bars += 1
|
||||||
|
if exiting or self._hold_bars >= 20:
|
||||||
|
self.position = 0
|
||||||
|
self._hold_bars = 0
|
||||||
|
return "EXIT"
|
||||||
|
|
||||||
|
# Entry: both agree
|
||||||
|
if self.position == 0 and trending and high_vpin:
|
||||||
|
if self.vpin_dir > 0.02:
|
||||||
|
self.position = 1
|
||||||
|
self._hold_bars = 0
|
||||||
|
return "BUY"
|
||||||
|
elif self.vpin_dir < -0.02:
|
||||||
|
self.position = -1
|
||||||
|
self._hold_bars = 0
|
||||||
|
return "SELL"
|
||||||
|
|
||||||
|
return "HOLD"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 5. Hyperliquid Data Fetcher
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
def fetch_recent_trades(user: str = None, limit: int = 500) -> list:
|
||||||
|
"""Fetch recent BTC-USD-PERP fills from Hyperliquid mainnet."""
|
||||||
|
url = "https://api.hyperliquid.xyz/info"
|
||||||
|
payload = {"type": "userFills", "user": user} if user else {
|
||||||
|
"type": "allMids"}
|
||||||
|
if user:
|
||||||
|
resp = requests.post(url, json=payload, timeout=10)
|
||||||
|
fills = resp.json()
|
||||||
|
return fills[:limit] if isinstance(fills, list) else []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 6. Backtest Runner
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
def run_hurst_vpin(trades: list, starting_capital: float = 100.0,
|
||||||
|
size: float = 0.0002) -> dict:
|
||||||
|
signal_gen = HurstVPINSignal()
|
||||||
|
equity = [{"t": 0, "v": starting_capital}]
|
||||||
|
capital = starting_capital
|
||||||
|
position = 0
|
||||||
|
entry_price = 0.0
|
||||||
|
all_trades = []
|
||||||
|
signals = []
|
||||||
|
|
||||||
|
for i, trade in enumerate(trades):
|
||||||
|
price = float(trade.get("px", 0))
|
||||||
|
sz = float(trade.get("sz", 0))
|
||||||
|
side = trade.get("side", "B")
|
||||||
|
|
||||||
|
result = signal_gen.add_trade(price, sz, side)
|
||||||
|
if result:
|
||||||
|
signals.append(result)
|
||||||
|
|
||||||
|
# Execute signal
|
||||||
|
sig = result["signal"]
|
||||||
|
if sig in ("BUY", "SELL") and position == 0:
|
||||||
|
entry_price = price
|
||||||
|
direction = 1 if sig == "BUY" else -1
|
||||||
|
notional = price * size
|
||||||
|
if capital >= notional:
|
||||||
|
all_trades.append({
|
||||||
|
"i": i, "side": sig, "price": price, "size": size,
|
||||||
|
"hurst": result["hurst"], "vpin": result["vpin"],
|
||||||
|
"bar_count": result["bar_count"],
|
||||||
|
})
|
||||||
|
position = direction
|
||||||
|
elif sig == "EXIT" and position != 0:
|
||||||
|
pnl_pct = (price / entry_price - 1) * position
|
||||||
|
pnl = capital * pnl_pct * 0.01 # 1% of capital at risk
|
||||||
|
capital += pnl
|
||||||
|
all_trades[-1]["exit_price"] = price
|
||||||
|
all_trades[-1]["pnl"] = round(pnl, 4)
|
||||||
|
equity.append({"t": i, "v": round(capital, 4)})
|
||||||
|
position = 0
|
||||||
|
entry_price = 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_trades": len(all_trades),
|
||||||
|
"signals": len(signals),
|
||||||
|
"final_equity": round(capital, 4),
|
||||||
|
"pnl_pct": round((capital / starting_capital - 1) * 100, 2),
|
||||||
|
"trades": all_trades,
|
||||||
|
"signals_history": signals[-20:],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 7. Test
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Simulated backtest with synthetic trades
|
||||||
|
print("Hurst/VPIN Strategy — Hyperliquid BTC-USD")
|
||||||
|
np.random.seed(42)
|
||||||
|
n = 50000
|
||||||
|
prices = 64000 + np.cumsum(np.random.randn(n) * 50)
|
||||||
|
sizes = np.abs(np.random.randn(n) * 0.01) + 0.001
|
||||||
|
sides = ["B" if np.random.random() > 0.5 else "A" for _ in range(n)]
|
||||||
|
sim_trades = [{"px": p, "sz": s, "side": sd} for p, s, sd in zip(prices, sizes, sides)]
|
||||||
|
|
||||||
|
result = run_hurst_vpin(sim_trades)
|
||||||
|
print(f" Total trades: {result['total_trades']}")
|
||||||
|
print(f" Signals generated: {result['signals']}")
|
||||||
|
print(f" Final equity: ${result['final_equity']:.2f} ({result['pnl_pct']:+.2f}%)")
|
||||||
|
print(f" Last signals:")
|
||||||
|
for s in result["signals_history"][-5:]:
|
||||||
|
print(f" H={s['hurst']:.3f} VPIN={s['vpin']:.3f} dir={s['vpin_dir']:+.3f} → {s['signal']}")
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
Hurst/VPIN integration module — provides compact signal generators
|
||||||
|
for live trading, paper trading, and backtesting.
|
||||||
|
|
||||||
|
Live: feeds price tick stream into Hurst dollar bars.
|
||||||
|
Paper/Backtest: feeds real trade data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math, time, numpy as np
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 1. Hurst Exponent — R/S on log returns
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
def _hurst_rs(returns: list) -> float:
|
||||||
|
"""R/S estimate from log returns. Returns 0.20–0.80."""
|
||||||
|
n = len(returns)
|
||||||
|
if n < 32:
|
||||||
|
return 0.50
|
||||||
|
max_lag = min(n // 2, 64)
|
||||||
|
lags = []; rs = []
|
||||||
|
for lag in range(4, max_lag):
|
||||||
|
segs = n // lag
|
||||||
|
if segs < 2: continue
|
||||||
|
vals = []
|
||||||
|
for s in range(segs):
|
||||||
|
seg = returns[s*lag:(s+1)*lag]
|
||||||
|
mean = np.mean(seg)
|
||||||
|
dev = np.cumsum(seg - mean)
|
||||||
|
r = float(np.max(dev) - np.min(dev))
|
||||||
|
sd = float(np.std(seg, ddof=1))
|
||||||
|
if sd > 1e-12:
|
||||||
|
vals.append(r / sd)
|
||||||
|
if vals:
|
||||||
|
lags.append(np.log(lag))
|
||||||
|
rs.append(np.log(np.mean(vals)))
|
||||||
|
if len(lags) < 4:
|
||||||
|
return 0.50
|
||||||
|
slope = float(np.polyfit(lags, rs, 1)[0])
|
||||||
|
return max(0.20, min(0.80, slope))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 2. Dollar Bar Builder (notional-based)
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
class DollarBar:
|
||||||
|
def __init__(self, threshold: float = 10000.0):
|
||||||
|
self.threshold = threshold
|
||||||
|
self.vol = 0.0
|
||||||
|
self.buy_vol = 0.0
|
||||||
|
self.sell_vol = 0.0
|
||||||
|
self.close = 0.0
|
||||||
|
|
||||||
|
def add(self, price: float, notional: float, is_buy: bool):
|
||||||
|
self.vol += notional
|
||||||
|
if is_buy:
|
||||||
|
self.buy_vol += notional
|
||||||
|
else:
|
||||||
|
self.sell_vol += notional
|
||||||
|
self.close = price
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
return self.vol >= self.threshold
|
||||||
|
|
||||||
|
def emit(self) -> dict:
|
||||||
|
total = self.buy_vol + self.sell_vol
|
||||||
|
data = {
|
||||||
|
"close": self.close,
|
||||||
|
"vpin": abs(self.buy_vol - self.sell_vol) / total if total > 1 else 0.0,
|
||||||
|
"direction": (self.buy_vol - self.sell_vol) / total if total > 1 else 0.0,
|
||||||
|
}
|
||||||
|
self.vol = 0.0; self.buy_vol = 0.0; self.sell_vol = 0.0
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 3. Hurst/VPIN Signal (price-tick mode for live trading)
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
class HurstVPINLive:
|
||||||
|
"""Lightweight Hurst/VPIN for live price tick stream.
|
||||||
|
|
||||||
|
Uses notional bars ($10K) from mid-price changes.
|
||||||
|
Each tick adds notional ≈ price * |Δprice| * 100 as volume proxy.
|
||||||
|
"""
|
||||||
|
def __init__(self, threshold: float = 10000.0,
|
||||||
|
hurst_window: int = 128,
|
||||||
|
vpin_window: int = 50,
|
||||||
|
hurst_entry: float = 0.55,
|
||||||
|
vpin_threshold: float = 0.25):
|
||||||
|
self.threshold = threshold
|
||||||
|
self.vpin_window = vpin_window
|
||||||
|
self.hurst_entry = hurst_entry
|
||||||
|
self.vpin_threshold = vpin_threshold
|
||||||
|
|
||||||
|
self.bar = DollarBar(threshold)
|
||||||
|
self.vpin_buf = deque(maxlen=vpin_window)
|
||||||
|
self.vpin_dir_buf = deque(maxlen=vpin_window)
|
||||||
|
self.returns = deque(maxlen=hurst_window)
|
||||||
|
self.last_close = 0.0
|
||||||
|
self.last_price = 0.0
|
||||||
|
|
||||||
|
def feed_price(self, price: float):
|
||||||
|
"""Feed a mid-price tick. Returns signal dict or None."""
|
||||||
|
if self.last_price <= 0:
|
||||||
|
self.last_price = price
|
||||||
|
return None
|
||||||
|
|
||||||
|
delta = price - self.last_price
|
||||||
|
is_buy = delta > 0
|
||||||
|
notional = price * abs(delta) * 100 # volume proxy
|
||||||
|
self.last_price = price
|
||||||
|
|
||||||
|
self.bar.add(price, notional, is_buy)
|
||||||
|
if not self.bar.ready:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bar_data = self.bar.emit()
|
||||||
|
|
||||||
|
# VPIN
|
||||||
|
self.vpin_buf.append(bar_data["vpin"])
|
||||||
|
self.vpin_dir_buf.append(bar_data["direction"])
|
||||||
|
vpin = float(np.mean(self.vpin_buf)) if len(self.vpin_buf) >= self.vpin_window else 0.0
|
||||||
|
direction = float(np.mean(self.vpin_dir_buf)) if len(self.vpin_dir_buf) >= self.vpin_window else 0.0
|
||||||
|
|
||||||
|
# Hurst
|
||||||
|
if self.last_close > 0:
|
||||||
|
self.returns.append(math.log(bar_data["close"] / self.last_close))
|
||||||
|
self.last_close = bar_data["close"]
|
||||||
|
|
||||||
|
hurst = _hurst_rs(list(self.returns)) if len(self.returns) >= 64 else 0.50
|
||||||
|
|
||||||
|
# Signal
|
||||||
|
trending = hurst >= self.hurst_entry
|
||||||
|
high_vpin = vpin >= self.vpin_threshold
|
||||||
|
|
||||||
|
if trending and high_vpin:
|
||||||
|
if direction > 0.02:
|
||||||
|
return {"signal": "BUY", "hurst": round(hurst, 3), "vpin": round(vpin, 3), "direction": round(direction, 3)}
|
||||||
|
elif direction < -0.02:
|
||||||
|
return {"signal": "SELL", "hurst": round(hurst, 3), "vpin": round(vpin, 3), "direction": round(direction, 3)}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 4. Hurst/VPIN for backtest (full trade data)
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
from strategies.hurst_vpin import run_hurst_vpin, HurstVPINSignal
|
||||||
|
|
||||||
|
# Expose for easy import
|
||||||
|
def hurst_vpin_backtest(trades, capital=100.0, size=0.00024):
|
||||||
|
return run_hurst_vpin(trades, starting_capital=capital, size=size)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
FTDT Kalman Pairs Trading — Statistical Arbitrage Engine.
|
||||||
|
|
||||||
|
Core components:
|
||||||
|
- kalman_filter: Pure-NumPy Kalman filter + KalmanPairsTrader
|
||||||
|
- pair_discovery: Cointegration tests, half-life filter, rolling OLS
|
||||||
|
- trading_system: Production orchestrator (multi-pair, risk layer)
|
||||||
|
- backtest: Walk-forward backtester with rolling OLS comparison
|
||||||
|
- tuning: Grid search for optimal transition_covariance
|
||||||
|
|
||||||
|
Quick start:
|
||||||
|
from strategies.kalman_pairs import (
|
||||||
|
KalmanPairsTrader, discover_pairs,
|
||||||
|
backtest_kalman_pairs, backtest_rolling_ols,
|
||||||
|
run_comparison, find_optimal_params
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .kalman_filter import KalmanFilter, KalmanPairsTrader, KalmanState
|
||||||
|
from .pair_discovery import (
|
||||||
|
discover_pairs, test_pair, estimate_half_life,
|
||||||
|
adf_test, compute_rolling_ols_hedge,
|
||||||
|
)
|
||||||
|
from .trading_system import KalmanPairsTradingSystem, KalmanPairsConfig
|
||||||
|
from .backtest import (
|
||||||
|
backtest_kalman_pairs, backtest_rolling_ols, run_comparison,
|
||||||
|
)
|
||||||
|
from .tuning import grid_search_transition_cov, find_optimal_params
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"KalmanFilter",
|
||||||
|
"KalmanPairsTrader",
|
||||||
|
"KalmanState",
|
||||||
|
"KalmanPairsTradingSystem",
|
||||||
|
"KalmanPairsConfig",
|
||||||
|
"discover_pairs",
|
||||||
|
"test_pair",
|
||||||
|
"estimate_half_life",
|
||||||
|
"adf_test",
|
||||||
|
"compute_rolling_ols_hedge",
|
||||||
|
"backtest_kalman_pairs",
|
||||||
|
"backtest_rolling_ols",
|
||||||
|
"run_comparison",
|
||||||
|
"grid_search_transition_cov",
|
||||||
|
"find_optimal_params",
|
||||||
|
]
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""
|
||||||
|
Kalman Pairs Backtesting Framework.
|
||||||
|
|
||||||
|
Full walk-forward backtest with:
|
||||||
|
- Realistic execution (transaction costs, capital allocation)
|
||||||
|
- Per-trade P&L tracking
|
||||||
|
- Side-by-side comparison vs rolling OLS (60-day, 120-day windows)
|
||||||
|
- Performance report: CAGR, Sharpe, Sortino, max DD, win rate, turnover
|
||||||
|
- Regime-shift stress tests
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from typing import Optional
|
||||||
|
from .kalman_filter import KalmanPairsTrader
|
||||||
|
from .pair_discovery import compute_rolling_ols_hedge
|
||||||
|
|
||||||
|
# Import project metrics
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||||
|
from common.metrics import sharpe, sortino, max_drawdown, win_rate
|
||||||
|
|
||||||
|
|
||||||
|
def backtest_kalman_pairs(
|
||||||
|
X: np.ndarray,
|
||||||
|
Y: np.ndarray,
|
||||||
|
trader: KalmanPairsTrader,
|
||||||
|
trade_size_usd: float = 100.0,
|
||||||
|
transaction_cost_bps: float = 2.5,
|
||||||
|
initial_capital: float = 10000.0,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Run a walk-forward backtest for a single pair using Kalman filter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
X, Y: Price series (must be same length).
|
||||||
|
trader: Pre-configured KalmanPairsTrader (already initialized).
|
||||||
|
trade_size_usd: Notional per leg in USD.
|
||||||
|
transaction_cost_bps: Fee per leg in basis points.
|
||||||
|
initial_capital: Starting capital.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with: trades list, equity_curve, metrics, final_equity.
|
||||||
|
"""
|
||||||
|
n = min(len(X), len(Y))
|
||||||
|
trader.reset()
|
||||||
|
|
||||||
|
capital = initial_capital
|
||||||
|
peak_capital = initial_capital
|
||||||
|
equity_curve: list[dict] = []
|
||||||
|
trades: list[dict] = []
|
||||||
|
open_trade: Optional[dict] = None
|
||||||
|
|
||||||
|
fee_rate = transaction_cost_bps / 10000.0 # bps → decimal
|
||||||
|
|
||||||
|
for t in range(n):
|
||||||
|
x_t = float(X[t])
|
||||||
|
y_t = float(Y[t])
|
||||||
|
result = trader.step(x_t, y_t)
|
||||||
|
|
||||||
|
signal = result["signal"]
|
||||||
|
beta = result["beta"]
|
||||||
|
|
||||||
|
if signal != 0:
|
||||||
|
if open_trade is None:
|
||||||
|
# Open position
|
||||||
|
entry_x = x_t
|
||||||
|
entry_y = y_t
|
||||||
|
size_x = trade_size_usd / entry_x if entry_x > 0 else 0
|
||||||
|
size_y = trade_size_usd / entry_y if entry_y > 0 else 0
|
||||||
|
|
||||||
|
# Hedge: use current beta
|
||||||
|
# If signal = +1: LONG Y (size_y), SHORT X (size_x * beta)
|
||||||
|
# If signal = -1: SHORT Y (size_y), LONG X (size_x * beta)
|
||||||
|
hedge_notional = size_x * entry_x * abs(beta) if beta else 0
|
||||||
|
fee = (trade_size_usd + hedge_notional) * fee_rate
|
||||||
|
|
||||||
|
capital -= fee
|
||||||
|
|
||||||
|
open_trade = {
|
||||||
|
"entry_time": t,
|
||||||
|
"signal": signal,
|
||||||
|
"entry_x": entry_x,
|
||||||
|
"entry_y": entry_y,
|
||||||
|
"beta_at_entry": beta,
|
||||||
|
"size_x": size_x,
|
||||||
|
"size_y": size_y,
|
||||||
|
"fee_paid": fee,
|
||||||
|
}
|
||||||
|
elif open_trade is not None and signal == -open_trade["signal"]:
|
||||||
|
# Close position
|
||||||
|
# PnL: (Y exit - Y entry) * size_y * sign + (X entry - X exit) * size_x * beta * sign
|
||||||
|
exit_sign = open_trade["signal"]
|
||||||
|
pnl_y = (y_t - open_trade["entry_y"]) * open_trade["size_y"] * exit_sign
|
||||||
|
pnl_x = (open_trade["entry_x"] - x_t) * open_trade["size_x"] * abs(open_trade["beta_at_entry"]) * exit_sign
|
||||||
|
gross_pnl = pnl_y + pnl_x
|
||||||
|
|
||||||
|
exit_notional = abs(y_t * open_trade["size_y"]) + abs(x_t * open_trade["size_x"] * open_trade["beta_at_entry"])
|
||||||
|
fee = exit_notional * fee_rate
|
||||||
|
net_pnl = gross_pnl - fee
|
||||||
|
|
||||||
|
capital += net_pnl
|
||||||
|
|
||||||
|
trades.append({
|
||||||
|
"entry_time": open_trade["entry_time"],
|
||||||
|
"exit_time": t,
|
||||||
|
"signal": open_trade["signal"],
|
||||||
|
"entry_x": open_trade["entry_x"],
|
||||||
|
"exit_x": x_t,
|
||||||
|
"entry_y": open_trade["entry_y"],
|
||||||
|
"exit_y": y_t,
|
||||||
|
"beta": open_trade["beta_at_entry"],
|
||||||
|
"gross_pnl": round(gross_pnl, 4),
|
||||||
|
"net_pnl": round(net_pnl, 4),
|
||||||
|
"fee": round(open_trade["fee_paid"] + fee, 6),
|
||||||
|
"duration_bars": t - open_trade["entry_time"],
|
||||||
|
})
|
||||||
|
open_trade = None
|
||||||
|
|
||||||
|
# Track equity
|
||||||
|
unrealized = 0.0
|
||||||
|
if open_trade is not None:
|
||||||
|
exit_sign = open_trade["signal"]
|
||||||
|
ur_y = (y_t - open_trade["entry_y"]) * open_trade["size_y"] * exit_sign
|
||||||
|
ur_x = (open_trade["entry_x"] - x_t) * open_trade["size_x"] * abs(open_trade["beta_at_entry"]) * exit_sign
|
||||||
|
unrealized = ur_y + ur_x
|
||||||
|
|
||||||
|
peak_capital = max(peak_capital, capital + unrealized)
|
||||||
|
equity_curve.append({
|
||||||
|
"t": t,
|
||||||
|
"equity": round(capital + unrealized, 4),
|
||||||
|
"alpha": round(result["alpha"], 6),
|
||||||
|
"beta": round(result["beta"], 6),
|
||||||
|
"spread": round(result["spread"], 6),
|
||||||
|
"z_score": round(result["z_score"], 4),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Force close open trade at end
|
||||||
|
if open_trade is not None:
|
||||||
|
exit_sign = open_trade["signal"]
|
||||||
|
y_t = float(Y[-1])
|
||||||
|
x_t = float(X[-1])
|
||||||
|
pnl_y = (y_t - open_trade["entry_y"]) * open_trade["size_y"] * exit_sign
|
||||||
|
pnl_x = (open_trade["entry_x"] - x_t) * open_trade["size_x"] * abs(open_trade["beta_at_entry"]) * exit_sign
|
||||||
|
gross_pnl = pnl_y + pnl_x
|
||||||
|
exit_notional = abs(y_t * open_trade["size_y"]) + abs(x_t * open_trade["size_x"] * open_trade["beta_at_entry"])
|
||||||
|
fee = exit_notional * fee_rate
|
||||||
|
capital += gross_pnl - fee
|
||||||
|
trades.append({
|
||||||
|
"entry_time": open_trade["entry_time"],
|
||||||
|
"exit_time": n - 1,
|
||||||
|
"signal": open_trade["signal"],
|
||||||
|
"entry_x": open_trade["entry_x"],
|
||||||
|
"exit_x": x_t,
|
||||||
|
"entry_y": open_trade["entry_y"],
|
||||||
|
"exit_y": y_t,
|
||||||
|
"beta": open_trade["beta_at_entry"],
|
||||||
|
"gross_pnl": round(gross_pnl, 4),
|
||||||
|
"net_pnl": round(gross_pnl - fee, 4),
|
||||||
|
"fee": round(open_trade["fee_paid"] + fee, 6),
|
||||||
|
"duration_bars": n - 1 - open_trade["entry_time"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Metrics ──
|
||||||
|
eq = np.array([e["equity"] for e in equity_curve])
|
||||||
|
returns = np.diff(eq) / eq[:-1] if len(eq) > 1 else np.array([0.0])
|
||||||
|
|
||||||
|
total_pnl = capital - initial_capital
|
||||||
|
pnl_pct = total_pnl / initial_capital * 100
|
||||||
|
dd = max_drawdown(eq.tolist())
|
||||||
|
sh = sharpe(returns.tolist())
|
||||||
|
so = sortino(returns.tolist())
|
||||||
|
wr = win_rate(trades)
|
||||||
|
cagr = ((capital / initial_capital) ** (1 / max(n / (365 * 24), 0.01)) - 1) * 100 if n > 0 and capital > 0 else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_pnl": round(total_pnl, 4),
|
||||||
|
"pnl_pct": round(pnl_pct, 2),
|
||||||
|
"cagr": round(cagr, 2),
|
||||||
|
"sharpe": round(sh, 4),
|
||||||
|
"sortino": round(so, 4),
|
||||||
|
"max_drawdown": round(dd, 4),
|
||||||
|
"win_rate": round(wr, 4),
|
||||||
|
"total_trades": len(trades),
|
||||||
|
"final_equity": round(capital, 4),
|
||||||
|
"transaction_costs": round(sum(t["fee"] for t in trades), 4),
|
||||||
|
"avg_trade_duration": round(np.mean([t["duration_bars"] for t in trades]), 1) if trades else 0,
|
||||||
|
"trades": trades[-200:],
|
||||||
|
"equity_curve": equity_curve,
|
||||||
|
"alpha_history": [e["alpha"] for e in equity_curve],
|
||||||
|
"beta_history": [e["beta"] for e in equity_curve],
|
||||||
|
"spread_history": [e["spread"] for e in equity_curve],
|
||||||
|
"z_score_history": [e["z_score"] for e in equity_curve],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def backtest_rolling_ols(
|
||||||
|
X: np.ndarray,
|
||||||
|
Y: np.ndarray,
|
||||||
|
window: int = 60,
|
||||||
|
z_entry: float = 2.0,
|
||||||
|
z_exit: float = 0.5,
|
||||||
|
trade_size_usd: float = 100.0,
|
||||||
|
transaction_cost_bps: float = 2.5,
|
||||||
|
initial_capital: float = 10000.0,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Baseline: classic rolling OLS pairs trading.
|
||||||
|
|
||||||
|
Uses a fixed-lookback rolling beta instead of Kalman adaptation.
|
||||||
|
"""
|
||||||
|
n = len(X)
|
||||||
|
betas = compute_rolling_ols_hedge(X, Y, window)
|
||||||
|
fee_rate = transaction_cost_bps / 10000.0
|
||||||
|
|
||||||
|
capital = initial_capital
|
||||||
|
equity_curve: list[dict] = []
|
||||||
|
trades: list[dict] = []
|
||||||
|
open_trade: Optional[dict] = None
|
||||||
|
|
||||||
|
spreads: list[float] = []
|
||||||
|
z_lookback = 100
|
||||||
|
|
||||||
|
for t in range(window, n):
|
||||||
|
x_t = float(X[t])
|
||||||
|
y_t = float(Y[t])
|
||||||
|
beta = betas[t] if not np.isnan(betas[t]) else 1.0
|
||||||
|
|
||||||
|
spread = y_t - beta * x_t
|
||||||
|
spreads.append(spread)
|
||||||
|
|
||||||
|
# Z-score
|
||||||
|
lb = min(z_lookback, len(spreads))
|
||||||
|
rec = spreads[-lb:]
|
||||||
|
mu = np.mean(rec)
|
||||||
|
sigma = np.std(rec, ddof=1)
|
||||||
|
z = (spread - mu) / sigma if sigma > 1e-12 else 0.0
|
||||||
|
|
||||||
|
signal = 0
|
||||||
|
if open_trade is None:
|
||||||
|
if z > z_entry:
|
||||||
|
signal = -1 # short Y, long X
|
||||||
|
elif z < -z_entry:
|
||||||
|
signal = +1 # long Y, short X
|
||||||
|
else:
|
||||||
|
if abs(z) < z_exit:
|
||||||
|
signal = -open_trade["signal"]
|
||||||
|
|
||||||
|
if signal != 0:
|
||||||
|
if open_trade is None:
|
||||||
|
size_x = trade_size_usd / x_t if x_t > 0 else 0
|
||||||
|
size_y = trade_size_usd / y_t if y_t > 0 else 0
|
||||||
|
hedge_notional = size_x * x_t * abs(beta)
|
||||||
|
fee = (trade_size_usd + hedge_notional) * fee_rate
|
||||||
|
capital -= fee
|
||||||
|
open_trade = {
|
||||||
|
"entry_time": t, "signal": signal,
|
||||||
|
"entry_x": x_t, "entry_y": y_t,
|
||||||
|
"beta": beta, "size_x": size_x, "size_y": size_y,
|
||||||
|
"fee_paid": fee,
|
||||||
|
}
|
||||||
|
elif signal == -open_trade["signal"]:
|
||||||
|
es = open_trade["signal"]
|
||||||
|
pnl_y = (y_t - open_trade["entry_y"]) * open_trade["size_y"] * es
|
||||||
|
pnl_x = (open_trade["entry_x"] - x_t) * open_trade["size_x"] * abs(open_trade["beta"]) * es
|
||||||
|
gross_pnl = pnl_y + pnl_x
|
||||||
|
exit_notional = abs(y_t * open_trade["size_y"]) + abs(x_t * open_trade["size_x"] * open_trade["beta"])
|
||||||
|
fee = exit_notional * fee_rate
|
||||||
|
capital += gross_pnl - fee
|
||||||
|
trades.append({
|
||||||
|
"entry_time": open_trade["entry_time"], "exit_time": t,
|
||||||
|
"signal": open_trade["signal"], "gross_pnl": round(gross_pnl, 4),
|
||||||
|
"net_pnl": round(gross_pnl - fee, 4),
|
||||||
|
"duration_bars": t - open_trade["entry_time"],
|
||||||
|
})
|
||||||
|
open_trade = None
|
||||||
|
|
||||||
|
equity_curve.append({"t": t, "equity": round(capital, 4)})
|
||||||
|
|
||||||
|
eq = np.array([e["equity"] for e in equity_curve])
|
||||||
|
returns = np.diff(eq) / eq[:-1] if len(eq) > 1 else np.zeros(1)
|
||||||
|
total_pnl = capital - initial_capital
|
||||||
|
dd = max_drawdown(eq.tolist())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_pnl": round(total_pnl, 4),
|
||||||
|
"pnl_pct": round(total_pnl / initial_capital * 100, 2),
|
||||||
|
"sharpe": round(sharpe(returns.tolist()), 4),
|
||||||
|
"sortino": round(sortino(returns.tolist()), 4),
|
||||||
|
"max_drawdown": round(dd, 4),
|
||||||
|
"win_rate": round(win_rate(trades), 4),
|
||||||
|
"total_trades": len(trades),
|
||||||
|
"final_equity": round(capital, 4),
|
||||||
|
"trades": trades[-200:],
|
||||||
|
"equity_curve": equity_curve,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_comparison(
|
||||||
|
X: np.ndarray,
|
||||||
|
Y: np.ndarray,
|
||||||
|
transition_covariance: float = 1e-4,
|
||||||
|
observation_covariance: float = 1e-2,
|
||||||
|
z_entry: float = 2.0,
|
||||||
|
z_exit: float = 0.5,
|
||||||
|
trade_size_usd: float = 100.0,
|
||||||
|
transaction_cost_bps: float = 2.5,
|
||||||
|
ols_windows: list[int] = [60, 120],
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Run Kalman vs rolling OLS comparison backtest.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with kalman_results, ols_results, and comparison_summary.
|
||||||
|
"""
|
||||||
|
trader = KalmanPairsTrader(
|
||||||
|
transition_covariance=transition_covariance,
|
||||||
|
observation_covariance=observation_covariance,
|
||||||
|
z_entry=z_entry, z_exit=z_exit,
|
||||||
|
)
|
||||||
|
|
||||||
|
kalman = backtest_kalman_pairs(
|
||||||
|
X, Y, trader,
|
||||||
|
trade_size_usd=trade_size_usd,
|
||||||
|
transaction_cost_bps=transaction_cost_bps,
|
||||||
|
)
|
||||||
|
|
||||||
|
ols_results = {}
|
||||||
|
for w in ols_windows:
|
||||||
|
ols_results[f"ols_{w}d"] = backtest_rolling_ols(
|
||||||
|
X, Y, window=w,
|
||||||
|
z_entry=z_entry, z_exit=z_exit,
|
||||||
|
trade_size_usd=trade_size_usd,
|
||||||
|
transaction_cost_bps=transaction_cost_bps,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"kalman": kalman,
|
||||||
|
"ols": ols_results,
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
"""
|
||||||
|
Pure NumPy Kalman Filter for Pairs Trading.
|
||||||
|
|
||||||
|
Implements a linear Kalman filter with time-varying observation matrix
|
||||||
|
suited for estimating the evolving hedge ratio βₜ and intercept αₜ
|
||||||
|
in the cointegrating regression:
|
||||||
|
|
||||||
|
Yₜ = αₜ + βₜ Xₜ + vₜ (observation)
|
||||||
|
[αₜ, βₜ]ᵀ = [αₜ₋₁, βₜ₋₁]ᵀ + wₜ (state transition, random walk)
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
- Pure NumPy (no scipy, no pykalman) → zero external deps beyond NumPy
|
||||||
|
- Time-varying H matrix: Hₜ = [1, Xₜ] — adapts every observation
|
||||||
|
- Diagonal process covariance Q controls adaptability:
|
||||||
|
High Q → fast adaptation, noisy estimates (overfit risk)
|
||||||
|
Low Q → slow adaptation, smooth estimates (lag risk)
|
||||||
|
- Scalar observation noise R controls measurement noise filtering
|
||||||
|
- State dimension = 2 (α, β); observation dimension = 1 (Y)
|
||||||
|
- Online filtering mode: update() called per observation
|
||||||
|
- Offline smoothing mode: smooth() runs RTS smoother over full series
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
R. E. Kalman (1960). "A New Approach to Linear Filtering
|
||||||
|
and Prediction Problems."
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KalmanState:
|
||||||
|
"""Holds the Kalman filter state at a single timestep."""
|
||||||
|
|
||||||
|
alpha: float # Intercept estimate
|
||||||
|
beta: float # Hedge ratio estimate
|
||||||
|
cov: np.ndarray # 2×2 state covariance matrix
|
||||||
|
log_likelihood: float = 0.0 # Contribution to log-likelihood
|
||||||
|
|
||||||
|
|
||||||
|
class KalmanFilter:
|
||||||
|
"""
|
||||||
|
Pure-NumPy linear Kalman filter for the state-space model:
|
||||||
|
|
||||||
|
State: xₜ = F xₜ₋₁ + wₜ, wₜ ~ N(0, Q)
|
||||||
|
Observation: yₜ = Hₜ xₜ + vₜ, vₜ ~ N(0, R)
|
||||||
|
|
||||||
|
where:
|
||||||
|
- xₜ = [αₜ, βₜ]ᵀ (2×1 state vector)
|
||||||
|
- F = I₂ (random walk transition)
|
||||||
|
- Q = diag(q_α, q_β) or scalar × I₂
|
||||||
|
- Hₜ = [1, Xₜ] (1×2, time-varying)
|
||||||
|
- R = scalar (observation noise variance)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
kf = KalmanFilter(transition_covariance=1e-4, observation_covariance=1e-2)
|
||||||
|
for x, y in zip(X_series, Y_series):
|
||||||
|
state = kf.update(x, y)
|
||||||
|
print(state.alpha, state.beta)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
transition_covariance: float = 1e-4,
|
||||||
|
observation_covariance: float = 1e-2,
|
||||||
|
initial_state_covariance: float = 1.0,
|
||||||
|
initial_alpha: float = 0.0,
|
||||||
|
initial_beta: float = 1.0,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
transition_covariance:
|
||||||
|
Diagonal value(s) for process noise Q.
|
||||||
|
Higher = faster adaptation, more noise.
|
||||||
|
Can be float (both states) or (q_alpha, q_beta) tuple.
|
||||||
|
observation_covariance:
|
||||||
|
Scalar measurement noise R.
|
||||||
|
Higher = smoother estimates (trust model more than data).
|
||||||
|
initial_state_covariance:
|
||||||
|
Initial uncertainty (diagonal of P₀).
|
||||||
|
initial_alpha, initial_beta:
|
||||||
|
Initial state estimates.
|
||||||
|
"""
|
||||||
|
# State dimension
|
||||||
|
self.n_states = 2
|
||||||
|
|
||||||
|
# Transition matrix: identity (random walk)
|
||||||
|
self.F = np.eye(self.n_states, dtype=np.float64)
|
||||||
|
|
||||||
|
# Process noise covariance Q
|
||||||
|
if isinstance(transition_covariance, (int, float)):
|
||||||
|
self.Q = np.eye(self.n_states) * transition_covariance
|
||||||
|
else:
|
||||||
|
self.Q = np.diag(transition_covariance)
|
||||||
|
|
||||||
|
# Observation noise (scalar)
|
||||||
|
self.R = np.atleast_2d(observation_covariance).astype(np.float64)
|
||||||
|
|
||||||
|
# Initial state
|
||||||
|
self.x = np.array([[initial_alpha], [initial_beta]], dtype=np.float64)
|
||||||
|
|
||||||
|
# Initial state covariance
|
||||||
|
self.P = np.eye(self.n_states) * initial_state_covariance
|
||||||
|
|
||||||
|
# Bookkeeping
|
||||||
|
self.n_obs = 0
|
||||||
|
self.history: list[KalmanState] = []
|
||||||
|
|
||||||
|
# ── Properties ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alpha(self) -> float:
|
||||||
|
"""Current intercept estimate."""
|
||||||
|
return float(self.x[0, 0])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def beta(self) -> float:
|
||||||
|
"""Current hedge ratio estimate."""
|
||||||
|
return float(self.x[1, 0])
|
||||||
|
|
||||||
|
# ── Core Filtering ──────────────────────────────────────
|
||||||
|
|
||||||
|
def update(self, X_t: float, Y_t: float) -> KalmanState:
|
||||||
|
"""
|
||||||
|
Single Kalman filter update step.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
X_t: Independent variable observation (e.g., X asset price)
|
||||||
|
Y_t: Dependent variable observation (e.g., Y asset price)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
KalmanState with current α, β, covariance, and log-likelihood.
|
||||||
|
"""
|
||||||
|
self.n_obs += 1
|
||||||
|
|
||||||
|
# ── Prediction ──
|
||||||
|
x_pred = self.F @ self.x # (2×1)
|
||||||
|
P_pred = self.F @ self.P @ self.F.T + self.Q # (2×2)
|
||||||
|
|
||||||
|
# ── Observation matrix (time-varying!) ──
|
||||||
|
H = np.array([[1.0, X_t]], dtype=np.float64) # (1×2)
|
||||||
|
|
||||||
|
# ── Innovation ──
|
||||||
|
y_pred = (H @ x_pred)[0, 0] # predicted Y
|
||||||
|
innovation = Y_t - y_pred # scalar
|
||||||
|
|
||||||
|
S = H @ P_pred @ H.T + self.R # innovation covariance (1×1)
|
||||||
|
S_inv = 1.0 / S[0, 0] if S[0, 0] > 0 else 1e10
|
||||||
|
|
||||||
|
# ── Kalman gain ──
|
||||||
|
K = P_pred @ H.T * S_inv # (2×1)
|
||||||
|
|
||||||
|
# ── Update ──
|
||||||
|
self.x = x_pred + K * innovation # (2×1)
|
||||||
|
self.P = P_pred - K @ H @ P_pred # (2×2)
|
||||||
|
# Ensure symmetry
|
||||||
|
self.P = (self.P + self.P.T) / 2.0
|
||||||
|
|
||||||
|
# ── Log-likelihood contribution ──
|
||||||
|
ll = -0.5 * (
|
||||||
|
np.log(2 * np.pi * S[0, 0]) +
|
||||||
|
innovation * innovation * S_inv
|
||||||
|
)
|
||||||
|
|
||||||
|
state = KalmanState(
|
||||||
|
alpha=float(self.x[0, 0]),
|
||||||
|
beta=float(self.x[1, 0]),
|
||||||
|
cov=self.P.copy(),
|
||||||
|
log_likelihood=float(ll),
|
||||||
|
)
|
||||||
|
self.history.append(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def update_batch(self, X: np.ndarray, Y: np.ndarray) -> list[KalmanState]:
|
||||||
|
"""Filter a full series of observations. Online (forward pass only)."""
|
||||||
|
results = []
|
||||||
|
for i in range(len(X)):
|
||||||
|
state = self.update(float(X[i]), float(Y[i]))
|
||||||
|
results.append(state)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def compute_spread(self, X_t: float, Y_t: float) -> float:
|
||||||
|
"""
|
||||||
|
Compute the Kalman-estimated spread at a given observation.
|
||||||
|
|
||||||
|
spreadₜ = Yₜ - (αₜ + βₜ Xₜ)
|
||||||
|
|
||||||
|
Positive spread → Y is overpriced relative to X → short Y, long X.
|
||||||
|
Negative spread → Y is underpriced relative to X → long Y, short X.
|
||||||
|
"""
|
||||||
|
return Y_t - (self.alpha + self.beta * X_t)
|
||||||
|
|
||||||
|
# ── Smoothing (RTS) ────────────────────────────────────
|
||||||
|
|
||||||
|
def smooth(self) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""
|
||||||
|
Rauch-Tung-Striebel (RTS) smoother.
|
||||||
|
|
||||||
|
Runs backward pass to produce smoothed state estimates
|
||||||
|
that incorporate all observations (future + past).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(smoothed_alpha, smoothed_beta) as 1-D arrays.
|
||||||
|
"""
|
||||||
|
n = len(self.history)
|
||||||
|
if n == 0:
|
||||||
|
return np.array([]), np.array([])
|
||||||
|
|
||||||
|
# Forward states and covariances
|
||||||
|
x_fwd = np.array([[s.alpha, s.beta] for s in self.history]).T # (2×n)
|
||||||
|
P_fwd = np.array([s.cov for s in self.history]) # (n×2×2)
|
||||||
|
|
||||||
|
# Initialize smoothed
|
||||||
|
x_smooth = np.zeros_like(x_fwd)
|
||||||
|
x_smooth[:, -1] = x_fwd[:, -1]
|
||||||
|
|
||||||
|
# Backward pass
|
||||||
|
for t in range(n - 2, -1, -1):
|
||||||
|
P_next = P_fwd[t + 1] # (2×2)
|
||||||
|
P_curr = P_fwd[t] # (2×2)
|
||||||
|
|
||||||
|
# Smoothing gain
|
||||||
|
P_pred = self.F @ P_curr @ self.F.T + self.Q
|
||||||
|
try:
|
||||||
|
C = P_curr @ self.F.T @ np.linalg.inv(P_pred)
|
||||||
|
except np.linalg.LinAlgError:
|
||||||
|
C = np.zeros((2, 2))
|
||||||
|
|
||||||
|
x_smooth[:, t] = x_fwd[:, t] + C @ (x_smooth[:, t + 1] - self.F @ x_fwd[:, t])
|
||||||
|
|
||||||
|
return x_smooth[0, :], x_smooth[1, :]
|
||||||
|
|
||||||
|
# ── Utility ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def likelihood(self) -> float:
|
||||||
|
"""Total log-likelihood of the filtered series."""
|
||||||
|
return sum(s.log_likelihood for s in self.history)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset filter to initial state (for warm-start / retune)."""
|
||||||
|
self.x = np.array([[0.0], [1.0]], dtype=np.float64)
|
||||||
|
self.P = np.eye(self.n_states) * 1.0
|
||||||
|
self.n_obs = 0
|
||||||
|
self.history.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class KalmanPairsTrader:
|
||||||
|
"""
|
||||||
|
Production-grade Kalman-filter-based pairs trading engine.
|
||||||
|
|
||||||
|
Encapsulates the Kalman filter, spread computation, z-score generation,
|
||||||
|
and signal logic. Designed to be called bar-by-bar in a live trading loop
|
||||||
|
or run over historical data for backtesting.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
┌─────────────┐
|
||||||
|
│ Price Feed │──Xₜ, Yₜ──→ KalmanFilter.update()
|
||||||
|
└─────────────┘ │
|
||||||
|
┌────────────▼────────────┐
|
||||||
|
│ αₜ, βₜ, spreadₜ │
|
||||||
|
│ zₜ = (spreadₜ - μ) / σ │
|
||||||
|
│ signal = f(zₜ, θ) │
|
||||||
|
└─────────────────────────┘
|
||||||
|
|
||||||
|
Signal logic:
|
||||||
|
z > +z_entry → Y overpriced → SHORT Y, LONG X
|
||||||
|
z < -z_entry → Y underpriced → LONG Y, SHORT X
|
||||||
|
|z| < z_exit → close position (mean reversion complete)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
trader = KalmanPairsTrader(
|
||||||
|
transition_covariance=1e-4,
|
||||||
|
z_entry=2.0,
|
||||||
|
z_exit=0.5,
|
||||||
|
)
|
||||||
|
for x, y in zip(prices_X, prices_Y):
|
||||||
|
signal = trader.step(x, y)
|
||||||
|
if signal != 0:
|
||||||
|
execute(signal)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
transition_covariance: float = 1e-4,
|
||||||
|
observation_covariance: float = 1e-2,
|
||||||
|
z_entry: float = 2.0,
|
||||||
|
z_exit: float = 0.5,
|
||||||
|
z_stop: float = 4.0,
|
||||||
|
warmup_bars: int = 50,
|
||||||
|
z_score_lookback: int = 100,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
transition_covariance: Q diagonal — controls β adaptation speed.
|
||||||
|
observation_covariance: R scalar — measurement noise filter.
|
||||||
|
z_entry: Z-score threshold for opening positions.
|
||||||
|
z_exit: Z-score threshold for closing positions.
|
||||||
|
z_stop: Stop-loss threshold (close immediately if |z| exceeds this).
|
||||||
|
warmup_bars: Minimum observations before trading.
|
||||||
|
z_score_lookback: Rolling window for z-score μ and σ estimation.
|
||||||
|
"""
|
||||||
|
self.kf = KalmanFilter(
|
||||||
|
transition_covariance=transition_covariance,
|
||||||
|
observation_covariance=observation_covariance,
|
||||||
|
initial_alpha=0.0,
|
||||||
|
initial_beta=1.0,
|
||||||
|
)
|
||||||
|
self.z_entry = z_entry
|
||||||
|
self.z_exit = z_exit
|
||||||
|
self.z_stop = z_stop
|
||||||
|
self.warmup_bars = warmup_bars
|
||||||
|
self.z_score_lookback = z_score_lookback
|
||||||
|
|
||||||
|
# Rolling spread history for z-score normalization
|
||||||
|
self._spreads: list[float] = []
|
||||||
|
|
||||||
|
# Current position state
|
||||||
|
self.position: int = 0 # +1 = long Y/short X, -1 = short Y/long X
|
||||||
|
self.entry_spread: float = 0.0
|
||||||
|
|
||||||
|
# ── Properties ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alpha(self) -> float:
|
||||||
|
return self.kf.alpha
|
||||||
|
|
||||||
|
@property
|
||||||
|
def beta(self) -> float:
|
||||||
|
return self.kf.beta
|
||||||
|
|
||||||
|
@property
|
||||||
|
def spread(self) -> float:
|
||||||
|
return self._spreads[-1] if self._spreads else 0.0
|
||||||
|
|
||||||
|
# ── Core Step ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def step(self, X_t: float, Y_t: float) -> dict:
|
||||||
|
"""
|
||||||
|
Process one observation and return a signal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
X_t: Independent variable price (denominator asset)
|
||||||
|
Y_t: Dependent variable price (numerator asset)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: signal (int), spread (float), z_score (float),
|
||||||
|
alpha (float), beta (float), position (int)
|
||||||
|
"""
|
||||||
|
# Update Kalman filter
|
||||||
|
self.kf.update(X_t, Y_t)
|
||||||
|
|
||||||
|
# Compute spread
|
||||||
|
spread = self.kf.compute_spread(X_t, Y_t)
|
||||||
|
self._spreads.append(spread)
|
||||||
|
|
||||||
|
# Trim spread history to lookback
|
||||||
|
lookback = min(self.z_score_lookback, len(self._spreads))
|
||||||
|
recent = self._spreads[-lookback:]
|
||||||
|
|
||||||
|
# Z-score computation
|
||||||
|
mu = np.mean(recent)
|
||||||
|
sigma = np.std(recent, ddof=1)
|
||||||
|
z = (spread - mu) / sigma if sigma > 1e-12 else 0.0
|
||||||
|
|
||||||
|
# Signal generation
|
||||||
|
signal = 0 # 0 = hold / no action
|
||||||
|
|
||||||
|
if self.kf.n_obs < self.warmup_bars:
|
||||||
|
signal = 0
|
||||||
|
elif self.position == 0:
|
||||||
|
# No position — look for entry
|
||||||
|
if z > self.z_entry:
|
||||||
|
signal = -1 # Y overpriced → SHORT Y, LONG X
|
||||||
|
elif z < -self.z_entry:
|
||||||
|
signal = +1 # Y underpriced → LONG Y, SHORT X
|
||||||
|
else:
|
||||||
|
# In position — check exit conditions
|
||||||
|
if abs(z) < self.z_exit:
|
||||||
|
signal = -self.position # close
|
||||||
|
elif abs(z) > self.z_stop:
|
||||||
|
signal = -self.position # stop-loss
|
||||||
|
# Also mean-reversion exit: if spread crosses zero
|
||||||
|
elif (self.position > 0 and spread > 0) or (self.position < 0 and spread < 0):
|
||||||
|
signal = -self.position # profit-taking on mean cross
|
||||||
|
|
||||||
|
# Update position
|
||||||
|
if signal != 0 and self.position == 0:
|
||||||
|
self.position = signal
|
||||||
|
self.entry_spread = spread
|
||||||
|
elif signal != 0 and self.position != 0:
|
||||||
|
self.position = 0
|
||||||
|
self.entry_spread = 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"signal": signal,
|
||||||
|
"spread": spread,
|
||||||
|
"z_score": z,
|
||||||
|
"alpha": self.alpha,
|
||||||
|
"beta": self.beta,
|
||||||
|
"position": self.position,
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset trader state (for backtest runs)."""
|
||||||
|
self.kf.reset()
|
||||||
|
self._spreads.clear()
|
||||||
|
self.position = 0
|
||||||
|
self.entry_spread = 0.0
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user