merge: resolve conflicts, keep local framework changes
This commit is contained in:
@@ -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,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]
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user