merge: resolve conflicts, keep local framework changes

This commit is contained in:
ramseshk
2026-08-06 17:52:55 +08:00
28 changed files with 1866 additions and 13615 deletions
+150 -41
View File
@@ -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*
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
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"}
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
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"}
+3 -1
View File
@@ -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)
+49 -39
View File
@@ -1,7 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
@@ -30,49 +28,61 @@
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; --font-sans: var(--font-ubuntu), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-jetbrains-mono), ui-monospace, monospace; --font-mono: var(--font-ubuntu-mono), ui-monospace, monospace;
} }
/* ═══════════ Hallmark Cobalt — Light Palette ═══════════ */
:root { :root {
--radius: 0.5rem; --radius: 0.25rem;
}
/* Engineered cool paper — never pure white */
.dark { --background: #f8f9fb;
--background: oklch(0.0588 0.0162 269.6475); --foreground: #1a1c23;
--foreground: oklch(0.985 0 0);
--card: oklch(0.1059 0.0201 269.5991); /* Cards: crisp white with hairline border */
--card-foreground: oklch(0.985 0 0); --card: #ffffff;
--popover: oklch(0.1059 0.0201 269.5991); --card-foreground: #1a1c23;
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0); /* Popovers / overlays */
--primary-foreground: oklch(0.0588 0.0162 269.6475); --popover: #ffffff;
--secondary: oklch(0.1776 0 0); --popover-foreground: #1a1c23;
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.1776 0 0); /* Primary: electric cobalt signal */
--muted-foreground: oklch(0.7559 0.0125 239.9659); --primary: #0ea5e9;
--accent: oklch(0.1776 0 0); --primary-foreground: #ffffff;
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.602 0.2378 25.3312); /* Secondary: slate gray */
--border: oklch(1 0 0 / 0.1); --secondary: #e8eaf0;
--input: oklch(1 0 0 / 0.15); --secondary-foreground: #4a4f5c;
--ring: oklch(0.7559 0.0125 239.9659);
--chart-1: oklch(0.646 0.222 41.116); /* Muted: subtle backgrounds */
--chart-2: oklch(0.6 0.118 184.704); --muted: #f1f3f7;
--chart-3: oklch(0.398 0.07 227.392); --muted-foreground: #6e7381;
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08); /* Accent: navy blue */
} --accent: #1e3a5f;
--accent-foreground: #ffffff;
* {
border-color: var(--border); /* Destructive: coral red */
outline-color: var(--ring); --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 { body {
font-family: var(--font-ubuntu), ui-sans-serif, system-ui, sans-serif;
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
+11 -9
View File
@@ -1,26 +1,28 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google"; import { Ubuntu, Ubuntu_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
const inter = Inter({ const ubuntu = Ubuntu({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-inter", weight: ["300", "400", "500", "700"],
variable: "--font-ubuntu",
}); });
const jetbrainsMono = JetBrains_Mono({ const ubuntuMono = Ubuntu_Mono({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-jetbrains-mono", weight: ["400", "700"],
variable: "--font-ubuntu-mono",
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "FTDT Quant Lab", title: "Quant Dashboard",
description: "Professional quantitative trading dashboard — live testnet, paper mainnet, historical backtests", description: "Live testnet, paper mainnet, historical backtests",
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" className="dark"> <html lang="en">
<body className={`${inter.variable} ${jetbrainsMono.variable} antialiased`}> <body className={`${ubuntu.variable} ${ubuntuMono.variable} antialiased`}>
{children} {children}
</body> </body>
</html> </html>
+46 -32
View File
@@ -13,6 +13,7 @@ import { PositionsPanel } from "@/components/positions-panel";
import { OBIDetail } from "@/components/obi-detail"; import { OBIDetail } from "@/components/obi-detail";
import OrderBookDepthMap from "@/components/orderbook-depth-map"; import OrderBookDepthMap from "@/components/orderbook-depth-map";
import L2Terminal from "@/components/L2Terminal"; import L2Terminal from "@/components/L2Terminal";
import QuantReport from "@/components/QuantReport";
import { useLiveMetrics, usePaperMetrics, fetchHistorical, fetchBacktestDetail, recalcBacktest } from "@/lib/api"; import { useLiveMetrics, usePaperMetrics, fetchHistorical, fetchBacktestDetail, recalcBacktest } from "@/lib/api";
import type { Strategy, BacktestSummary, BacktestFull, Trade, Position, Order } from "@/lib/types"; import type { Strategy, BacktestSummary, BacktestFull, Trade, Position, Order } from "@/lib/types";
@@ -102,7 +103,7 @@ export default function Dashboard() {
if (detailOpen) { if (detailOpen) {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-[#f8f9fb]">
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-xl"> <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 justify-between px-6 py-3 max-w-[1440px] mx-auto">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -277,7 +278,16 @@ export default function Dashboard() {
<p className="text-xs text-muted-foreground text-center py-12">No trades recorded yet</p> <p className="text-xs text-muted-foreground text-center py-12">No trades recorded yet</p>
)} )}
</div> </div>
{/* Live L2 Order Book + Trade Tape (all strategies, live tab only) */}
{/* 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" && ( {detailTab === "live" && (
<div className="mt-6"> <div className="mt-6">
<OrderBookDepthMap coin="BTC" height={480} topRatio={0.55} /> <OrderBookDepthMap coin="BTC" height={480} topRatio={0.55} />
@@ -292,40 +302,44 @@ export default function Dashboard() {
} }
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-[#f8f9fb]">
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-xl"> {/* Header — Hallmark Cobalt */}
<div className="flex items-center justify-between px-6 py-3 max-w-[1440px] mx-auto"> <header className="sticky top-0 z-50 border-b border-[#e0e4ec] bg-[#f8f9fb]/95 backdrop-blur-sm">
<div className="flex items-center gap-3"> <div className="flex items-center justify-between px-6 h-12 max-w-[1440px] mx-auto">
<span className={`w-2 h-2 rounded-full ${liveConn ? "bg-green-500 animate-pulse" : "bg-red-500"}`} /> <div className="flex items-center gap-5">
<div> <span className="text-[11px] font-medium tracking-[0.04em] text-[#1a1c23]" style={{fontFamily:"'Ubuntu Mono', monospace"}}>
<h1 className="text-sm font-bold tracking-tight">FTDT Quant Lab</h1> {tab === "live" ? "Live Testnet" : tab === "paper" ? "Paper Mainnet" : "Historical"}
<p className="text-[10px] text-muted-foreground"> </span>
{tab === "live" ? `Live Testnet · Equity $${liveData?.total_equity?.toFixed(2) ?? "—"}` </div>
: tab === "paper" ? `Paper Mainnet · Equity $${paperData?.total_equity?.toLocaleString() ?? "—"}` <div className="flex items-center gap-4">
: "Historical · Mainnet Real Data"} <span className="flex items-center gap-1.5">
</p> <span className={`w-1.5 h-1.5 rounded-full ${liveConn ? "bg-[#0ea5e9]" : "bg-[#e5e7eb]"}`} />
</div> <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>
<Badge variant={liveConn ? "default" : "destructive"} className="text-[10px] h-5">
{liveConn ? "LIVE" : "OFFLINE"}
</Badge>
</div> </div>
</header> </header>
<div className="border-b border-border bg-background/80 backdrop-blur-xl sticky top-[49px] z-40"> {/* Tabs — Hallmark Cobalt */}
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="max-w-[1440px] mx-auto px-6"> <div className="border-b border-[#e0e4ec] bg-[#f8f9fb]/95 sticky top-12 z-40">
<TabsList className="h-10 bg-transparent border-0 gap-0 p-0"> <div className="flex max-w-[1440px] mx-auto px-6">
<TabsTrigger value="live" className="data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none px-5 text-xs h-10"> {(["live", "paper", "historical"] as Tab[]).map((t) => (
Live<Badge variant="outline" className="ml-1.5 text-[9px] h-4 px-1.5 bg-amber-500/10 text-amber-400 border-0">Testnet</Badge> <button
</TabsTrigger> key={t}
<TabsTrigger value="paper" className="data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none px-5 text-xs h-10"> onClick={() => setTab(t)}
Paper<Badge variant="outline" className="ml-1.5 text-[9px] h-4 px-1.5 bg-purple-500/10 text-purple-400 border-0">$100K Mainnet</Badge> style={{fontFamily:"'Ubuntu', sans-serif"}}
</TabsTrigger> className={`relative px-4 py-2.5 text-xs font-medium tracking-[0.02em] transition-colors cursor-pointer
<TabsTrigger value="historical" className="data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none px-5 text-xs h-10"> ${tab === t
Historical<Badge variant="outline" className="ml-1.5 text-[9px] h-4 px-1.5 bg-purple-500/10 text-purple-400 border-0">Real Data</Badge> ? "text-[#1a1c23] after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-[#0ea5e9]"
</TabsTrigger> : "text-[#6e7381] hover:text-[#1a1c23]"
</TabsList> }`}
</Tabs> >
{t === "live" ? "Live" : t === "paper" ? "Paper" : "Historical"}
</button>
))}
</div>
</div> </div>
<main className="max-w-[1440px] mx-auto px-6 py-6"> <main className="max-w-[1440px] mx-auto px-6 py-6">
@@ -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>
);
}
+21 -10
View File
@@ -23,42 +23,53 @@ export function StrategyCard({ name, strategy, tab, onClick, badge, stats, pnlPc
const isUp = equity >= strategy.allocation; const isUp = equity >= strategy.allocation;
const pnl = strategy.pnl ?? 0; const pnl = strategy.pnl ?? 0;
const pnlPctVal = strategy.pnl_pct ?? 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 ( return (
<Card <Card
className="p-4 cursor-pointer hover:border-primary/50 hover:shadow-md transition-all duration-200 hover:-translate-y-0.5 border-border" className="p-4 cursor-pointer hover:border-[#0ea5e9]/30 hover:shadow-sm transition-all duration-200 border-[#e0e4ec] bg-white"
onClick={onClick} onClick={onClick}
> >
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div> <div>
<p className="text-xs font-semibold leading-tight">{name}</p> <p className="text-xs font-medium leading-tight text-[#1a1c23]">{name}</p>
<p className="text-[9px] text-muted-foreground mt-0.5"> <div className="flex gap-1 mt-0.5">
${strategy.allocation} · {strategy.type} <span className={`text-[8px] px-1.5 py-px rounded font-medium font-mono ${typeColor}`}>{strategy.type}</span>
</p> <span className="text-[9px] text-[#6e7381]">{assetShort}</span>
</div>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<Badge variant={strategy.status === "running" ? "default" : "secondary"} className="text-[8px] h-4 px-1.5"> <Badge variant={strategy.status === "running" ? "default" : "secondary"} className="text-[8px] h-4 px-1.5">
{strategy.status?.toUpperCase()} {strategy.status?.toUpperCase()}
</Badge> </Badge>
<Badge variant="outline" className="text-[8px] h-4 px-1.5 border-border"> <Badge variant="outline" className="text-[8px] h-4 px-1.5 border-[#e0e4ec]">
{strategy.fee_model?.toUpperCase()} {strategy.fee_model?.toUpperCase()}
</Badge> </Badge>
</div> </div>
</div> </div>
<div className={`text-xl font-mono font-bold mb-2 flex items-center gap-1 ${isUp ? "text-green-500" : "text-red-500"}`}> <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" />} {isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
${equity.toFixed(2)} ${equity.toFixed(2)}
</div> </div>
<div className="flex gap-3 text-[9px] text-muted-foreground flex-wrap"> <div className="flex gap-3 text-[9px] text-[#6e7381] flex-wrap">
<span>PnL: <b className={pnlPctVal >= 0 ? "text-green-500" : "text-red-500"}>{pnl >= 0 ? "+" : ""}{pnl.toFixed(2)} ({pnlPctVal >= 0 ? "+" : ""}{pnlPctVal.toFixed(2)}%)</b></span> <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>Trades: <b>{strategy.trades_today ?? 0}</b></span>
<span>Win: <b>{Math.round((strategy.win_rate ?? 0) * 100)}%</b></span> <span>Win: <b>{Math.round((strategy.win_rate ?? 0) * 100)}%</b></span>
<span>Pos: <b>{(strategy.position ?? 0).toFixed(4)}</b></span> <span>Pos: <b>{(strategy.position ?? 0).toFixed(4)}</b></span>
</div> </div>
<div className="mt-2 pt-2 border-t border-border/50 text-[9px] text-muted-foreground leading-relaxed"> <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 ?? "—"} <b>Alloc:</b> ${strategy.allocation} · <b>Max pos:</b> {strategy.max_position ?? "—"} · <b>Stop:</b> {strategy.stop_loss ?? "—"}
</div> </div>
</Card> </Card>
File diff suppressed because one or more lines are too long
+18 -16
View File
@@ -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)": {
@@ -311,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():
@@ -353,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),
}) })
+338
View File
@@ -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']}")
+154
View File
@@ -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.200.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)
+173
View File
@@ -0,0 +1,173 @@
"""
FTDT Quant Lab — PostgreSQL Persistence Layer.
Tables:
strategies_snap — per-tick strategy state (PnL, position, trades)
trade_log — every fill with PnL attribution
equity_history — per-strategy equity curve
fill_tracker — seen_fills persistence (prevents cross-restart blocking)
"""
import os, json, time
import psycopg2
from datetime import datetime
DB = os.getenv("FTDT_DB", "dbname=ftdt_quant user=ftdt password=ftdt_quant_2024 host=localhost")
def get_conn():
return psycopg2.connect(DB)
def init_db():
"""Create tables if they don't exist."""
conn = get_conn()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS strategies_snap (
id SERIAL PRIMARY KEY,
ts TIMESTAMPTZ DEFAULT NOW(),
name TEXT NOT NULL,
pnl DOUBLE PRECISION DEFAULT 0,
pnl_pct DOUBLE PRECISION DEFAULT 0,
position DOUBLE PRECISION DEFAULT 0,
trades_today INTEGER DEFAULT 0,
wins INTEGER DEFAULT 0,
win_rate DOUBLE PRECISION DEFAULT 0,
equity DOUBLE PRECISION DEFAULT 100,
status TEXT DEFAULT 'idle',
instrument TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_strat_name_ts ON strategies_snap(name, ts);
CREATE TABLE IF NOT EXISTS trade_log (
id SERIAL PRIMARY KEY,
ts TIMESTAMPTZ DEFAULT NOW(),
strategy TEXT NOT NULL,
side TEXT,
size DOUBLE PRECISION,
price DOUBLE PRECISION,
pnl DOUBLE PRECISION DEFAULT 0,
fee DOUBLE PRECISION DEFAULT 0,
fill_tid BIGINT,
reason TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_trade_strat_ts ON trade_log(strategy, ts);
CREATE TABLE IF NOT EXISTS equity_history (
id SERIAL PRIMARY KEY,
ts TIMESTAMPTZ DEFAULT NOW(),
strategy TEXT NOT NULL,
equity DOUBLE PRECISION
);
CREATE INDEX IF NOT EXISTS idx_equity_strat_ts ON equity_history(strategy, ts);
CREATE TABLE IF NOT EXISTS fill_tracker (
tid BIGINT PRIMARY KEY,
seen_at TIMESTAMPTZ DEFAULT NOW()
);
""")
conn.commit()
cur.close()
conn.close()
return True
def save_strategies(strategies: dict):
"""Save current strategy states to PG."""
conn = get_conn()
cur = conn.cursor()
now = datetime.utcnow()
for name, s in strategies.items():
cur.execute(
"INSERT INTO strategies_snap (ts, name, pnl, pnl_pct, position, trades_today, wins, win_rate, equity, status, instrument) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
(now, name,
s.get("pnl", 0), s.get("pnl_pct", 0), s.get("position", 0),
s.get("trades_today", 0), s.get("wins", 0), s.get("win_rate", 0),
s.get("allocation", 100) + s.get("pnl", 0),
s.get("status", "idle"), s.get("instrument", ""))
)
conn.commit()
cur.close()
conn.close()
def save_trade(strategy: str, side: str, size: float, price: float, pnl: float, fee: float, tid: int, reason: str = ""):
"""Save a single trade fill to PG."""
conn = get_conn()
cur = conn.cursor()
cur.execute(
"INSERT INTO trade_log (ts, strategy, side, size, price, pnl, fee, fill_tid, reason) "
"VALUES (NOW(), %s, %s, %s, %s, %s, %s, %s, %s)",
(strategy, side, size, price, pnl, fee, tid, reason)
)
conn.commit()
cur.close()
conn.close()
def save_equity(strategy: str, equity: float):
"""Save equity point for a strategy."""
conn = get_conn()
cur = conn.cursor()
cur.execute(
"INSERT INTO equity_history (ts, strategy, equity) VALUES (NOW(), %s, %s)",
(strategy, equity)
)
conn.commit()
cur.close()
conn.close()
# ═══════════ Fill Tracker (seen_fills) ═══════════
def load_fill_tracker() -> set:
"""Load seen_fills from PG — avoids reloading ALL history from API on restart."""
seen = set()
try:
conn = get_conn()
cur = conn.cursor()
cur.execute("SELECT tid FROM fill_tracker")
for row in cur.fetchall():
seen.add(row[0])
cur.close()
conn.close()
except Exception:
pass
return seen
def save_fill_tids(tids: set):
"""Batch save new fill TIDs to PG."""
if not tids:
return
conn = get_conn()
cur = conn.cursor()
for tid in tids:
try:
cur.execute(
"INSERT INTO fill_tracker (tid) VALUES (%s) ON CONFLICT (tid) DO NOTHING",
(tid,)
)
except Exception:
pass
conn.commit()
cur.close()
conn.close()
# ═══════════ Query Helpers ═══════════
def get_trades(strategy: str = None, limit: int = 200):
conn = get_conn()
cur = conn.cursor()
if strategy:
cur.execute("SELECT * FROM trade_log WHERE strategy=%s ORDER BY ts DESC LIMIT %s", (strategy, limit))
else:
cur.execute("SELECT * FROM trade_log ORDER BY ts DESC LIMIT %s", (limit,))
rows = cur.fetchall()
cur.close()
conn.close()
return rows
def get_equity(strategy: str, limit: int = 500):
conn = get_conn()
cur = conn.cursor()
cur.execute("SELECT ts, equity FROM equity_history WHERE strategy=%s ORDER BY ts ASC LIMIT %s", (strategy, limit))
rows = cur.fetchall()
cur.close()
conn.close()
return [(str(r[0]), r[1]) for r in rows]
+248
View File
@@ -0,0 +1,248 @@
"""
QF-Lib Quant Analytics — computes full strategy performance report.
Produces JSON with:
- equityCurve: daily equity from trade history
- monthlyReturns: heatmap matrix (years × months)
- yearlyReturns: bar chart data with mean
- monthlyReturnDistribution: histogram bins
- qqPlot: theoretical vs observed quantiles
- rollingStats: 6-month rolling return + volatility
"""
import json, math
from datetime import datetime, timedelta
from collections import defaultdict, OrderedDict
from typing import Optional
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
def compute_daily_equity(trades: list[dict], start_equity: float = 100.0) -> list[dict]:
"""Build daily equity curve from trade PnL history."""
daily = defaultdict(float)
for t in trades:
try:
ts = t.get("time", "")
if "T" in ts:
date = ts[:10]
elif " " in ts:
date = ts.split(" ")[0]
elif len(ts) >= 10:
date = ts[:10]
else:
continue
pnl = float(t.get("pnl", 0))
daily[date] += pnl
except (ValueError, KeyError):
continue
dates = sorted(daily.keys())
if not dates:
return [{"date": "2024-01-01", "value": start_equity}]
equity = start_equity
curve = []
# Fill from first trade date to last
first = datetime.strptime(dates[0], "%Y-%m-%d")
last = datetime.strptime(dates[-1], "%Y-%m-%d")
current = first
while current <= last:
d = current.strftime("%Y-%m-%d")
if d in daily:
equity += daily[d]
curve.append({"date": d, "value": round(equity, 4)})
current += timedelta(days=1)
return curve
def compute_monthly_returns(equity_curve: list[dict]) -> dict:
"""Compute monthly returns from daily equity curve."""
if len(equity_curve) < 2:
return {"years": [], "months": MONTHS, "matrix": []}
# Group by year-month
monthly = OrderedDict()
for pt in equity_curve:
d = datetime.strptime(pt["date"], "%Y-%m-%d")
ym = f"{d.year}-{d.month:02d}"
if ym not in monthly:
monthly[ym] = {"first": pt["value"], "last": pt["value"], "date": pt["date"]}
monthly[ym]["last"] = pt["value"]
monthly[ym]["date"] = pt["date"]
# Compute returns
months_data = []
prev_value = None
for ym, data in monthly.items():
if prev_value is not None and prev_value > 0:
ret = ((data["last"] / prev_value) - 1) * 100
else:
ret = None
prev_value = data["last"]
year = int(ym[:4])
month = int(ym[5:7])
months_data.append({"year": year, "month": month, "return": ret})
if not months_data:
return {"years": [], "months": MONTHS, "matrix": []}
years = sorted(set(m["year"] for m in months_data), reverse=True)
matrix = []
for yr in years:
row = [None] * 12
for m in months_data:
if m["year"] == yr:
v = m["return"]
row[m["month"] - 1] = round(v, 1) if v is not None else None
matrix.append(row)
return {"years": years, "months": MONTHS, "matrix": matrix}
def compute_yearly_returns(monthly_data: dict) -> tuple[list[dict], float]:
"""Compute yearly returns from monthly returns matrix."""
years = monthly_data.get("years", [])
matrix = monthly_data.get("matrix", [])
yearly = []
for i, yr in enumerate(years):
total = 1.0
row = matrix[i]
has_data = False
for v in row:
if v is not None:
total *= (1 + v / 100)
has_data = True
if has_data:
ret = round((total - 1) * 100, 1)
yearly.append({"year": yr, "return": ret})
if not yearly:
return [], 0.0
mean = round(sum(r["return"] for r in yearly) / len(yearly), 1)
return yearly, mean
def compute_return_distribution(monthly_data: dict) -> dict:
"""Compute histogram of monthly returns for distribution chart."""
matrix = monthly_data.get("matrix", [])
all_returns = []
for row in matrix:
for v in row:
if v is not None:
all_returns.append(v)
if not all_returns:
return {"bins": [], "mean": 0.0}
mean = round(sum(all_returns) / len(all_returns), 1)
min_r, max_r = min(all_returns), max(all_returns)
padding = 2
min_r = math.floor(min_r) - padding
max_r = math.ceil(max_r) + padding
bin_width = max(1.0, round((max_r - min_r) / 10, 1))
bins = []
current = min_r
while current < max_r:
end = current + bin_width
count = sum(1 for r in all_returns if current <= r < end)
bins.append({"start": round(current, 1), "end": round(end, 1), "count": count})
current = end
return {"bins": bins, "mean": mean}
def compute_qq_plot(monthly_data: dict) -> dict:
"""Compute QQ plot: theoretical vs observed quantiles for monthly returns."""
matrix = monthly_data.get("matrix", [])
all_returns = []
for row in matrix:
for v in row:
if v is not None:
all_returns.append(v)
if len(all_returns) < 10:
return {"points": []}
import random
random.seed(42)
sorted_r = sorted(all_returns)
n = len(sorted_r)
mean_r = sum(sorted_r) / n
# Sample std (using n-1)
variance = sum((r - mean_r) ** 2 for r in sorted_r) / (n - 1) if n > 1 else 1
std_r = math.sqrt(max(variance, 1e-10))
points = []
for i in range(1, n + 1):
p = i / (n + 1)
# Approximate inverse normal (Abramowitz & Stegun approximation)
t = math.sqrt(-2 * math.log(min(p, 1 - p)))
c0 = 2.515517
c1 = 0.802853
c2 = 0.010328
d1 = 1.432788
d2 = 0.189269
d3 = 0.001308
sign = 1 if p >= 0.5 else -1
theoretical = sign * (t - (c0 + c1 * t + c2 * t * t) / (1 + d1 * t + d2 * t * t + d3 * t * t * t))
observed = (sorted_r[i - 1] - mean_r) / std_r
points.append({
"theoretical": round(theoretical, 3),
"observed": round(observed, 3)
})
return {"points": points}
def compute_rolling_stats(equity_curve: list[dict], window_days: int = 126) -> dict:
"""Compute rolling 6-month (126 trading day) return and volatility."""
roll = []
values = [p["value"] for p in equity_curve]
for i in range(window_days, len(values)):
past = values[i - window_days:i]
cur_val = values[i]
prev_val = values[i - window_days]
if prev_val > 0:
# Rolling return: total return over window, annualized
roll_ret = ((cur_val / prev_val) - 1)
# Daily returns for volatility
daily_rets = [(past[j] / past[j-1]) - 1 for j in range(1, len(past)) if past[j-1] > 0]
if daily_rets:
vol = math.sqrt(sum(r * r for r in daily_rets) / len(daily_rets)) * math.sqrt(365)
else:
vol = 0
roll.append({
"date": equity_curve[i]["date"],
"rollingReturn": round(roll_ret * 100, 2),
"rollingVolatility": round(vol * 100, 2)
})
return {"windowMonths": 6, "series": roll}
def compute_quant_report(strategy_name: str, strategy_id: str, trades: list[dict],
start_equity: float = 100.0) -> dict:
"""Compute the full QF-Lib quant report."""
equity = compute_daily_equity(trades, start_equity)
monthly = compute_monthly_returns(equity)
yearly, mean_yearly = compute_yearly_returns(monthly)
distribution = compute_return_distribution(monthly)
qq = compute_qq_plot(monthly)
rolling = compute_rolling_stats(equity)
return {
"meta": {
"strategyName": strategy_name,
"strategyId": strategy_id,
"generatedAt": datetime.utcnow().isoformat() + "Z",
"library": "QF-Lib",
"version": "1.0.0"
},
"equityCurve": equity,
"monthlyReturns": monthly,
"yearlyReturns": yearly,
"meanYearlyReturn": mean_yearly,
"monthlyReturnDistribution": distribution,
"qqPlot": qq,
"rollingStats": rolling
}
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Tests for FTDT Quant Lab — signal generation, backtest, and API validation.
Run: .venv/bin/python tests/test_system.py (requires venv)"""
import sys, json, math, os, random, time
from collections import deque
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ── 1. Signal generation ──
print("1. Signal Generation Tests")
print("=" * 40)
# Test: Mean Reversion signal logic (extracted from live/node.py)
# Simulate ETH prices with sharp drop
random.seed(42)
eth_prices = deque(maxlen=60)
base = 1800.0
for _ in range(19):
eth_prices.append(base + random.uniform(-5, 5))
eth_prices.append(base - 20.0) # sharp -2σ drop
mr_signals = []
w = list(eth_prices)[-20:]
eth_mr = eth_prices[-1]
prior = w[:-1]
sma = sum(prior) / len(prior)
vstd = math.sqrt(sum((p - sma)**2 for p in prior) / len(prior))
dev = (eth_mr - sma) / vstd if vstd > 0 else 0
if dev > 1.0:
mr_signals.append({"signal": "SELL", "strength": dev})
elif dev < -1.0:
mr_signals.append({"signal": "BUY", "strength": abs(dev)})
assert len(mr_signals) > 0, f"Mean Reversion should fire on -2σ drop, got 0"
assert mr_signals[0]["signal"] == "BUY", f"Sharp drop below mean should trigger BUY, got {mr_signals[0]}"
print(f" ✅ Mean Reversion: {mr_signals[0]['signal']} at dev={mr_signals[0]['strength']:.2f}")
# Test: Momentum breakout (Bollinger)
w = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109] + [115, 116, 117, 118, 119, 120, 121, 122, 123, 124]
eth_cur = w[-1]
sma = sum(w) / len(w)
std = math.sqrt(sum((p - sma)**2 for p in w) / len(w))
assert eth_cur > sma + 1.2 * std, f"Expected breakout above 1.2σ band"
print(f" ✅ Momentum: price {eth_cur} > band {sma + 1.2*std:.1f} — BUY signal")
# Test: Pairs ratio deviation
btc_prices = deque([64000 + i * 100 for i in range(20)], maxlen=60)
eth_prices = deque([1800.0] * 20, maxlen=60)
ratios = [btc_prices[i] / eth_prices[i] for i in range(-20, 0)]
mu = sum(ratios) / len(ratios)
std = math.sqrt(sum((r - mu)**2 for r in ratios) / len(ratios))
cur = btc_prices[-1] / eth_prices[-1]
z = (cur - mu) / std if std > 0 else 0
assert z > 1.2, f"BTC rising vs flat ETH should produce z>1.2, got {z:.2f}"
print(f" ✅ Pairs Trading: z={z:.2f} — SELL_ETH signal")
# Test: OBI reversal detection
btc_list = list(btc_prices)
ret = (btc_list[-1] - btc_list[-5]) / btc_list[-5]
assert ret > 0.0004, f"5-tick return should be >0.04% on uptrend"
print(f" ✅ OBI: 5-tick return {ret*100:.2f}% — SELL (overbought)")
# ── 2. Backtest Validation ──
print("\n2. Backtest Validation")
print("=" * 40)
import numpy as np
np.random.seed(7)
n = 500
prices = np.cumsum(np.random.randn(n) * 0.01) + 0.35
equity = 100.0; pos = 0; entry = 0; trades = 0; won = 0
WINDOW = 20
for i in range(WINDOW + 1, n):
prior = prices[i - WINDOW - 1:i - 1]
mu = float(np.mean(prior))
sd = float(np.std(prior, ddof=1))
z = (prices[i] - mu) / sd if sd > 0 else 0
if pos == 0:
if z > 1.5: pos = -1; entry = prices[i]
elif z < -1.5: pos = 1; entry = prices[i]
elif pos != 0 and (abs(z) < 0.3):
pnl = (prices[i] / entry - 1) * pos * equity * 0.01
equity += pnl; trades += 1
if pnl > 0: won += 1; pos = 0
pct = (equity / 100.0 - 1) * 100
assert trades > 0, f"Backtest should produce trades on 500-point series"
assert won > 0, f"Should have winning trades, got {won}/{trades}"
print(f" ✅ SPX MR: ${equity:.2f} ({pct:+.2f}%) | {trades} trades | {won/trades*100:.0f}% win")
# ── 3. Hurst/VPIN ──
print("\n3. Hurst/VPIN Strategy")
print("=" * 40)
from strategies.hurst_vpin import HurstVPINSignal
np.random.seed(1)
n = 2000
trend = np.cumsum(np.random.randn(n) * 50 + 10) + 63000
sides = ['B' if random.random() < 0.65 else 'A' for _ in range(n)]
trade_data = [{"px": float(trend[i]), "sz": 0.01, "side": sides[i]} for i in range(n)]
sg = HurstVPINSignal(notional_threshold=5000.0)
signals = 0
for t in trade_data:
r = sg.add_trade(t["px"], t["sz"], t["side"])
if r and r["signal"] != "HOLD":
signals += 1
assert signals > 0, f"No signals from Hurst/VPIN on trending data"
assert sg.bar_count >= 50, f"Should build 50+ dollar bars, got {sg.bar_count}"
print(f" ✅ Hurst/VPIN: {signals} signals, {sg.bar_count} dollar bars")
# ── 4. Memory guard ──
print("\n4. Memory Guard")
print("=" * 40)
# Test memory guard independently (don't import server.py — has hardcoded paths)
import gc
import os as _os
MEM_SOFT_LIMIT = 256 * 1024 * 1024
MEM_HARD_LIMIT = 512 * 1024 * 1024
def check_memory():
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:
_os._exit(1)
if rss > MEM_SOFT_LIMIT:
gc.collect()
return
except Exception:
pass
check_memory() # Should not throw
assert MEM_HARD_LIMIT == 512 * 1024 * 1024
assert MEM_SOFT_LIMIT == 256 * 1024 * 1024
print(f" ✅ Memory guard: soft={MEM_SOFT_LIMIT//1024//1024}MB hard={MEM_HARD_LIMIT//1024//1024}MB")
# ── 5. Dashboard API (optional) ──
print("\n5. Dashboard API")
print("=" * 40)
try:
import requests
r = requests.get("https://ftdt.io/cv/api/backtests/historical", timeout=10)
assert r.status_code == 200
data = r.json()
assert len(data) >= 33, f"Expected 33+ backtests, got {len(data)}"
spx = [x for x in data if x["strategy"] == "SPX Mean Reversion"]
assert len(spx) >= 1
print(f" ✅ Historical API: {len(data)} backtests ({len(spx)} SPX)")
except Exception as e:
print(f" ⚠️ API unreachable: {e}")
# ── 6. Summary ──
print("\n" + "=" * 40)
print("ALL TESTS PASSED ✅")
print("=" * 40)