diff --git a/dashboard-next/src/app/page.tsx b/dashboard-next/src/app/page.tsx index 7aae249..d5469b7 100644 --- a/dashboard-next/src/app/page.tsx +++ b/dashboard-next/src/app/page.tsx @@ -13,6 +13,7 @@ import { PositionsPanel } from "@/components/positions-panel"; import { OBIDetail } from "@/components/obi-detail"; import OrderBookDepthMap from "@/components/orderbook-depth-map"; import L2Terminal from "@/components/L2Terminal"; +import QuantReport from "@/components/QuantReport"; import { useLiveMetrics, usePaperMetrics, fetchHistorical, fetchBacktestDetail, recalcBacktest } from "@/lib/api"; import type { Strategy, BacktestSummary, BacktestFull, Trade, Position, Order } from "@/lib/types"; @@ -28,6 +29,7 @@ export default function Dashboard() { const [detailOpen, setDetailOpen] = useState(false); const [l2TerminalOpen, setL2TerminalOpen] = useState(false); + const [quantReportOpen, setQuantReportOpen] = useState(false); const [detailName, setDetailName] = useState(""); const [detailTab, setDetailTab] = useState("live"); const [filter, setFilter] = useState("ALL"); @@ -228,9 +230,17 @@ export default function Dashboard() { )}
-

- Trade History {detailTrades.length > 0 ? `(${detailTrades.length})` : ""} -

+
+

+ Trade History {detailTrades.length > 0 ? `(${detailTrades.length})` : ""} +

+ +
{detailTrades.length > 0 ? (
@@ -379,6 +389,25 @@ export default function Dashboard() { )} + + {/* Fullscreen Quant Report */} + {quantReportOpen && ( +
+
+ QF-Lib Quant Report + +
+ +
+ )} ); } diff --git a/dashboard-next/src/components/QuantReport.tsx b/dashboard-next/src/components/QuantReport.tsx new file mode 100644 index 0000000..702071f --- /dev/null +++ b/dashboard-next/src/components/QuantReport.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Canvas refs + const equityCanvas = useRef(null); + const monthlyCanvas = useRef(null); + const yearlyCanvas = useRef(null); + const distCanvas = useRef(null); + const qqCanvas = useRef(null); + const rollingCanvas = useRef(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
Loading quant report...
; + if (error) return
Error: {error}
; + if (!data) return null; + + return ( +
+ {/* Header */} +
+
+
+
+ QF +
+ QF-Lib technology +
+

Generated with QF-Lib

+

{data.meta.strategyName}

+

{new Date(data.meta.generatedAt).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}

+
+
+
+ + {/* Row 1: Equity Curve */} +
+ +
+ + {/* Row 2: Monthly Returns + Yearly Returns */} +
+
+ +
+
+ +
+
+ + {/* Row 3: Distribution + QQ Plot */} +
+
+ +
+
+ +
+
+ + {/* Row 4: Rolling Stats */} +
+ +
+ + {/* Footer */} +
Page 1 of 2
+
+ ); +} diff --git a/dashboard/server.py b/dashboard/server.py index c672a4c..78b21ee 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -29,6 +29,7 @@ import sys sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from config.fee_tiers import get_perp_fees, PERPS_TIERS, STAKING_TIERS, STRATEGY_FEE_MODELS from common.risk import risk_summary +from strategies.quant_report import compute_quant_report import uvicorn # ═══════════════════════════════════════════════════════════ @@ -437,6 +438,29 @@ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") # Main # ═══════════════════════════════════════════════════════════ +@app.get("/api/quant-report/{name}") +async def get_quant_report(name: str): + """Compute full QF-Lib quant report from a backtest file.""" + backtest_path = os.path.join(BACKTEST_DIR, name) + if not os.path.exists(backtest_path): + # Try historical + hist_path = os.path.join(HISTORICAL_DIR, name) + if os.path.exists(hist_path): + backtest_path = hist_path + else: + return JSONResponse({"error": f"Backtest '{name}' not found"}, status_code=404) + try: + with open(backtest_path) as f: + data = json.load(f) + trades = data.get("trades", data.get("trade_history", [])) + strategy_name = data.get("name", data.get("strategy", name)) + strategy_id = data.get("id", name) + report = compute_quant_report(strategy_name, strategy_id, trades, 100.0) + return JSONResponse(report) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + def main(): import argparse parser = argparse.ArgumentParser() diff --git a/dashboard/static/index.html b/dashboard/static/index.html index 85bf9ec..b6672b2 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -1 +1 @@ -FTDT Quant Lab

FTDT Quant Lab

Live Testnet · Equity $—

OFFLINE
\ No newline at end of file +FTDT Quant Lab

FTDT Quant Lab

Live Testnet · Equity $—

OFFLINE
\ No newline at end of file diff --git a/strategies/quant_report.py b/strategies/quant_report.py new file mode 100644 index 0000000..5ee1c89 --- /dev/null +++ b/strategies/quant_report.py @@ -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 + }