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
+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
}