QF-Lib Quant Report: full strategy performance analytics
Backend: strategies/quant_report.py
- equityCurve: daily PnL from trade history
- monthlyReturns: heatmap matrix (years x months)
- yearlyReturns: bar chart data with mean
- monthlyReturnDistribution: histogram bins
- qqPlot: theoretical vs observed quantiles
- rollingStats: 6-month rolling return + volatility
API: /api/quant-report/{name}
Computes full report from any backtest JSON file
Frontend: QuantReport.tsx
- Strategy Performance chart (equity curve, blue line)
- Monthly Returns heatmap (blue saturation)
- Yearly Returns bar chart with mean line
- Distribution histogram
- Normal QQ plot with diagonal reference
- Rolling Statistics (6-month, dual line)
- QF-Lib header with logo and metadata
- Access via QF-Lib Report button in detail view
This commit is contained in:
@@ -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<Tab>("live");
|
||||
const [filter, setFilter] = useState("ALL");
|
||||
@@ -228,9 +230,17 @@ export default function Dashboard() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-3 pb-2 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-3 pb-2 border-b border-border">
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Trade History {detailTrades.length > 0 ? `(${detailTrades.length})` : ""}
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setQuantReportOpen(true)}
|
||||
className="text-[9px] px-2 py-0.5 bg-blue-50 text-blue-700 rounded font-mono hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
QF-Lib Report
|
||||
</button>
|
||||
</div>
|
||||
{detailTrades.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<Table>
|
||||
@@ -379,6 +389,25 @@ export default function Dashboard() {
|
||||
<L2Terminal coin="BTC" className="w-full h-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fullscreen Quant Report */}
|
||||
{quantReportOpen && (
|
||||
<div className="fixed inset-0 z-[200] bg-white overflow-auto">
|
||||
<div className="sticky top-0 z-[201] bg-white border-b border-gray-200 px-4 py-2 flex justify-between items-center">
|
||||
<span className="text-xs text-gray-600">QF-Lib Quant Report</span>
|
||||
<button
|
||||
onClick={() => setQuantReportOpen(false)}
|
||||
className="text-xs text-gray-500 hover:text-black font-mono px-3 py-1 border border-gray-300 rounded"
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
<QuantReport
|
||||
strategyName={detailName}
|
||||
backtestId={btFull ? `${detailName.replace(/\s+/g, "_").toLowerCase()}.json` : "live"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// ═══════════ Colors ═══════════
|
||||
const BLUE = "#1E5AA8";
|
||||
const BLUE_FILL = "rgba(30,90,168,0.15)";
|
||||
const GRAY = "#888888";
|
||||
const BLACK = "#111111";
|
||||
const GRID = "rgba(0,0,0,0.06)";
|
||||
const BG = "#FFFFFF";
|
||||
|
||||
interface QuantData {
|
||||
meta: { strategyName: string; strategyId: string; generatedAt: string };
|
||||
equityCurve: { date: string; value: number }[];
|
||||
monthlyReturns: { years: number[]; months: string[]; matrix: (number | null)[][] };
|
||||
yearlyReturns: { year: number; return: number }[];
|
||||
meanYearlyReturn: number;
|
||||
monthlyReturnDistribution: { bins: { start: number; end: number; count: number }[]; mean: number };
|
||||
qqPlot: { points: { theoretical: number; observed: number }[] };
|
||||
rollingStats: { windowMonths: number; series: { date: string; rollingReturn: number; rollingVolatility: number }[] };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
strategyName: string;
|
||||
backtestId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function QuantReport({ strategyName, backtestId, className = "" }: Props) {
|
||||
const [data, setData] = useState<QuantData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Canvas refs
|
||||
const equityCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const monthlyCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const yearlyCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const distCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const qqCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const rollingCanvas = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`/cv/api/quant-report/${backtestId}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { setData(d); setLoading(false); })
|
||||
.catch(e => { setError(e.message); setLoading(false); });
|
||||
}, [backtestId]);
|
||||
|
||||
// ═══════ Equity Curve ═══════
|
||||
useEffect(() => {
|
||||
if (!data?.equityCurve?.length) return;
|
||||
const canvas = equityCanvas.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth;
|
||||
const H = canvas.clientHeight;
|
||||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = BG; ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const curve = data.equityCurve;
|
||||
const M = { top: 30, bot: 35, left: 45, right: 15 };
|
||||
const pW = W - M.left - M.right, pH = H - M.top - M.bot;
|
||||
const vals = curve.map(c => c.value);
|
||||
const minV = Math.min(...vals) * 0.95;
|
||||
const maxV = Math.max(...vals) * 1.05;
|
||||
const range = maxV - minV || 1;
|
||||
|
||||
const toX = (i: number) => M.left + (i / (curve.length - 1)) * pW;
|
||||
const toY = (v: number) => M.top + pH - ((v - minV) / range) * pH;
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = BLACK; ctx.font = "bold 13px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText("Strategy Performance", 8, 18);
|
||||
|
||||
// Legend
|
||||
ctx.fillStyle = BLUE; ctx.font = "11px sans-serif";
|
||||
ctx.fillText(data.meta.strategyName, 8, M.top + pH + 18);
|
||||
|
||||
// Grid
|
||||
ctx.strokeStyle = GRID; ctx.lineWidth = 0.5;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = M.top + (i / 5) * pH;
|
||||
ctx.beginPath(); ctx.moveTo(M.left, y); ctx.lineTo(M.left + pW, y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Line
|
||||
ctx.strokeStyle = BLUE; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < curve.length; i++) {
|
||||
const x = toX(i), y = toY(curve[i].value);
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Y axis labels
|
||||
ctx.fillStyle = GRAY; ctx.font = "9px sans-serif";
|
||||
ctx.textAlign = "right";
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const v = minV + (i / 4) * range;
|
||||
ctx.fillText(v.toFixed(1), M.left - 4, toY(v) + 3);
|
||||
}
|
||||
|
||||
// X axis: years
|
||||
ctx.textAlign = "center";
|
||||
const years = [...new Set(curve.map(c => c.date.slice(0, 4)))];
|
||||
for (const yr of years.slice(0, 6)) {
|
||||
const pts = curve.filter(c => c.date.startsWith(yr));
|
||||
if (pts.length) {
|
||||
const idx = curve.indexOf(pts[Math.floor(pts.length / 2)]);
|
||||
ctx.fillText(yr, toX(idx), M.top + pH + 14);
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// ═══════ Monthly Returns Heatmap ═══════
|
||||
useEffect(() => {
|
||||
if (!data?.monthlyReturns?.matrix?.length) return;
|
||||
const canvas = monthlyCanvas.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth, H = 340;
|
||||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = BG; ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const mr = data.monthlyReturns;
|
||||
const M = { top: 25, bot: 5, left: 35, right: 5 };
|
||||
const nRows = mr.years.length, nCols = 12;
|
||||
const cellW = (W - M.left - M.right) / nCols;
|
||||
const cellH = (H - M.top - M.bot) / nRows;
|
||||
|
||||
ctx.fillStyle = BLACK; ctx.font = "bold 12px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText("Monthly Returns", 8, 16);
|
||||
|
||||
// Month headers
|
||||
ctx.font = "9px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
for (let c = 0; c < 12; c++) {
|
||||
ctx.fillText(mr.months[c].slice(0, 3), M.left + c * cellW + cellW / 2, M.top - 5);
|
||||
}
|
||||
|
||||
// Heatmap cells
|
||||
const allVals = mr.matrix.flat().filter(v => v !== null) as number[];
|
||||
const maxAbs = Math.max(Math.abs(Math.max(...allVals)), Math.abs(Math.min(...allVals)), 1);
|
||||
|
||||
for (let r = 0; r < nRows; r++) {
|
||||
// Year label
|
||||
ctx.fillStyle = BLACK; ctx.font = "10px sans-serif";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(String(mr.years[r]), M.left - 4, M.top + r * cellH + cellH * 0.65);
|
||||
|
||||
for (let c = 0; c < nCols; c++) {
|
||||
const v = mr.matrix[r][c];
|
||||
const x = M.left + c * cellW, y = M.top + r * cellH;
|
||||
if (v !== null && v !== undefined) {
|
||||
// Color: blue saturation proportional to value
|
||||
const alpha = Math.min(1, Math.abs(v) / maxAbs * 0.9 + 0.1);
|
||||
ctx.fillStyle = `rgba(30,90,168,${alpha})`;
|
||||
ctx.fillRect(x, y, cellW - 1, cellH - 1);
|
||||
// Value text
|
||||
ctx.fillStyle = Math.abs(v) > maxAbs * 0.4 ? "#FFFFFF" : "#111111";
|
||||
ctx.font = "9px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(v.toFixed(1), x + cellW / 2, y + cellH * 0.65);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// ═══════ Yearly Returns Bar Chart ═══════
|
||||
useEffect(() => {
|
||||
if (!data?.yearlyReturns?.length) return;
|
||||
const canvas = yearlyCanvas.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth, H = 340;
|
||||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = BG; ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const yr = data.yearlyReturns;
|
||||
const M = { top: 25, bot: 5, left: 8, right: 40 };
|
||||
const pH = (H - M.top - M.bot) / yr.length;
|
||||
const minR = Math.min(0, ...yr.map(y => y.return));
|
||||
const maxR = Math.max(...yr.map(y => y.return));
|
||||
const range = Math.max(maxR - minR, 1);
|
||||
const zeroX = M.left + ((-minR) / range) * (W - M.left - M.right);
|
||||
|
||||
ctx.fillStyle = BLACK; ctx.font = "bold 12px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText("Yearly Returns", 8, 16);
|
||||
|
||||
// Mean line
|
||||
ctx.strokeStyle = BLACK; ctx.lineWidth = 0.8;
|
||||
ctx.setLineDash([3, 3]);
|
||||
const meanX = M.left + ((data.meanYearlyReturn - minR) / range) * (W - M.left - M.right);
|
||||
ctx.beginPath(); ctx.moveTo(meanX, M.top); ctx.lineTo(meanX, M.top + yr.length * pH); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = BLACK; ctx.font = "8px sans-serif";
|
||||
ctx.fillText("Mean", meanX + 2, M.top + 10);
|
||||
|
||||
// Bars
|
||||
for (let i = 0; i < yr.length; i++) {
|
||||
const y = M.top + i * pH;
|
||||
const barW = ((yr[i].return - 0) / range) * (W - M.left - M.right) * (yr[i].return >= 0 ? 1 : -1);
|
||||
const bx = yr[i].return >= 0 ? zeroX : zeroX - Math.abs(barW);
|
||||
ctx.fillStyle = BLUE;
|
||||
ctx.fillRect(bx, y + 2, Math.abs(barW), pH - 4);
|
||||
|
||||
// Year label
|
||||
ctx.fillStyle = BLACK; ctx.font = "10px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(String(yr[i].year), 8, y + pH * 0.5 + 3);
|
||||
|
||||
// Return label
|
||||
ctx.textAlign = yr[i].return >= 0 ? "left" : "right";
|
||||
const lx = yr[i].return >= 0 ? bx + Math.abs(barW) + 2 : bx - 2;
|
||||
ctx.fillText(`${yr[i].return}%`, lx, y + pH * 0.5 + 3);
|
||||
}
|
||||
|
||||
// X axis
|
||||
ctx.fillStyle = GRAY; ctx.font = "8px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Returns", W / 2, H - 2);
|
||||
ctx.fillText(`${minR}%`, M.left, H - 2);
|
||||
ctx.fillText(`${maxR}%`, M.left + (W - M.left - M.right), H - 2);
|
||||
}, [data]);
|
||||
|
||||
// ═══════ Distribution Histogram ═══════
|
||||
useEffect(() => {
|
||||
if (!data?.monthlyReturnDistribution?.bins?.length) return;
|
||||
const canvas = distCanvas.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth, H = 280;
|
||||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = BG; ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const dist = data.monthlyReturnDistribution;
|
||||
const M = { top: 25, bot: 30, left: 35, right: 10 };
|
||||
const pW = W - M.left - M.right, pH = H - M.top - M.bot;
|
||||
const maxCount = Math.max(...dist.bins.map(b => b.count));
|
||||
|
||||
ctx.fillStyle = BLACK; ctx.font = "bold 12px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText("Distribution of Monthly Returns", 8, 16);
|
||||
|
||||
// Mean line
|
||||
const allStarts = dist.bins.map(b => b.start);
|
||||
const allEnds = dist.bins.map(b => b.end);
|
||||
const gMin = Math.min(...allStarts), gMax = Math.max(...allEnds);
|
||||
const gRange = gMax - gMin || 1;
|
||||
const toX = (v: number) => M.left + ((v - gMin) / gRange) * pW;
|
||||
const meanLine = toX(dist.mean);
|
||||
ctx.strokeStyle = BLACK; ctx.lineWidth = 0.8;
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.beginPath(); ctx.moveTo(meanLine, M.top); ctx.lineTo(meanLine, M.top + pH); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Bars
|
||||
for (const bin of dist.bins) {
|
||||
const x = toX(bin.start);
|
||||
const w = toX(bin.end) - toX(bin.start);
|
||||
const h = (bin.count / maxCount) * pH;
|
||||
ctx.fillStyle = bin.count > 0 ? BLUE : "rgba(30,90,168,0.1)";
|
||||
ctx.fillRect(x, M.top + pH - h, Math.max(w - 1, 2), h);
|
||||
}
|
||||
|
||||
// Axes
|
||||
ctx.fillStyle = GRAY; ctx.font = "8px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Returns", M.left + pW / 2, H - 2);
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText("Occurrences", 2, M.top + pH / 2);
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const v = Math.round(i * maxCount / 4);
|
||||
ctx.fillText(String(v), 2, M.top + pH - (i / 4) * pH + 3);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// ═══════ QQ Plot ═══════
|
||||
useEffect(() => {
|
||||
if (!data?.qqPlot?.points?.length) return;
|
||||
const canvas = qqCanvas.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth, H = 280;
|
||||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = BG; ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const pts = data.qqPlot.points;
|
||||
const M = { top: 25, bot: 30, left: 40, right: 10 };
|
||||
const pW = W - M.left - M.right, pH = H - M.top - M.bot;
|
||||
const tVals = pts.map(p => p.theoretical);
|
||||
const oVals = pts.map(p => p.observed);
|
||||
const tMin = -5, tMax = 5, oMin = -5, oMax = 5;
|
||||
|
||||
const toX = (t: number) => M.left + ((t - tMin) / (tMax - tMin)) * pW;
|
||||
const toY = (o: number) => M.top + pH - ((o - oMin) / (oMax - oMin)) * pH;
|
||||
|
||||
ctx.fillStyle = BLACK; ctx.font = "bold 12px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText("Normal Distribution Q-Q", 8, 16);
|
||||
|
||||
// Grid
|
||||
ctx.strokeStyle = GRID; ctx.lineWidth = 0.5;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = M.top + (i / 4) * pH;
|
||||
ctx.beginPath(); ctx.moveTo(M.left, y); ctx.lineTo(M.left + pW, y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Diagonal line
|
||||
ctx.strokeStyle = BLACK; ctx.lineWidth = 0.8;
|
||||
ctx.beginPath(); ctx.moveTo(M.left, M.top + pH); ctx.lineTo(M.left + pW, M.top); ctx.stroke();
|
||||
|
||||
// Points
|
||||
for (const p of pts) {
|
||||
ctx.fillStyle = BLUE;
|
||||
ctx.beginPath();
|
||||
ctx.arc(toX(p.theoretical), toY(p.observed), 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Axes
|
||||
ctx.fillStyle = GRAY; ctx.font = "8px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Normal Distribution Quantile", M.left + pW / 2, H - 2);
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText("Observed", M.left + pW + 2, M.top + pH / 2 + 10);
|
||||
}, [data]);
|
||||
|
||||
// ═══════ Rolling Stats ═══════
|
||||
useEffect(() => {
|
||||
if (!data?.rollingStats?.series?.length) return;
|
||||
const canvas = rollingCanvas.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth, H = 300;
|
||||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = BG; ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const rs = data.rollingStats;
|
||||
const M = { top: 30, bot: 30, left: 45, right: 15 };
|
||||
const pW = W - M.left - M.right, pH = H - M.top - M.bot;
|
||||
const allVals = rs.series.map(s => s.rollingReturn).concat(rs.series.map(s => s.rollingVolatility));
|
||||
const minV = Math.min(...allVals) * 1.1, maxV = Math.max(...allVals) * 1.1;
|
||||
const range = maxV - minV || 1;
|
||||
const toX = (i: number) => M.left + (i / (rs.series.length - 1)) * pW;
|
||||
const toY = (v: number) => M.top + pH - ((v - minV) / range) * pH;
|
||||
|
||||
ctx.fillStyle = BLACK; ctx.font = "bold 12px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(`Rolling Statistics [${rs.windowMonths} Months]`, 8, 18);
|
||||
|
||||
// Legend
|
||||
ctx.fillStyle = BLUE; ctx.font = "10px sans-serif";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText("Rolling Return", W - 8, 14);
|
||||
ctx.fillStyle = GRAY;
|
||||
ctx.fillText("Rolling Volatility", W - 8, 28);
|
||||
|
||||
// Grid
|
||||
ctx.strokeStyle = GRID; ctx.lineWidth = 0.5;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = M.top + (i / 4) * pH;
|
||||
ctx.beginPath(); ctx.moveTo(M.left, y); ctx.lineTo(M.left + pW, y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Volatility line (draw first, behind)
|
||||
ctx.strokeStyle = GRAY; ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < rs.series.length; i++) {
|
||||
const x = toX(i), y = toY(rs.series[i].rollingVolatility);
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Return line
|
||||
ctx.strokeStyle = BLUE; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < rs.series.length; i++) {
|
||||
const x = toX(i), y = toY(rs.series[i].rollingReturn);
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Y axis
|
||||
ctx.fillStyle = GRAY; ctx.font = "8px sans-serif"; ctx.textAlign = "right";
|
||||
for (let i = 0; i <= 3; i++) {
|
||||
const v = Math.round(minV + (i / 3) * range);
|
||||
ctx.fillText(`${v}%`, M.left - 4, toY(v) + 3);
|
||||
}
|
||||
|
||||
// X axis: years
|
||||
ctx.textAlign = "center";
|
||||
const years = [...new Set(rs.series.map(s => s.date.slice(0, 4)))];
|
||||
for (const yr of years.slice(0, 8)) {
|
||||
const pts = rs.series.filter(s => s.date.startsWith(yr));
|
||||
if (pts.length) {
|
||||
const idx = rs.series.indexOf(pts[Math.floor(pts.length / 2)]);
|
||||
ctx.fillText(yr, toX(idx), M.top + pH + 14);
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (loading) return <div className="p-8 text-center text-gray-500">Loading quant report...</div>;
|
||||
if (error) return <div className="p-8 text-center text-red-500">Error: {error}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className={`bg-white text-black p-4 max-w-5xl mx-auto ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-full bg-blue-700 flex items-center justify-center">
|
||||
<span className="text-[7px] text-white font-bold">QF</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-500">QF-Lib technology</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Generated with QF-Lib</p>
|
||||
<h1 className="text-base font-bold mt-1">{data.meta.strategyName}</h1>
|
||||
<p className="text-[10px] text-gray-400">{new Date(data.meta.generatedAt).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 mb-4" />
|
||||
|
||||
{/* Row 1: Equity Curve */}
|
||||
<div className="mb-4 border border-gray-100 rounded-sm overflow-hidden">
|
||||
<canvas ref={equityCanvas} className="w-full" style={{ height: 320 }} />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Monthly Returns + Yearly Returns */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="border border-gray-100 rounded-sm overflow-hidden">
|
||||
<canvas ref={monthlyCanvas} className="w-full" style={{ height: 340 }} />
|
||||
</div>
|
||||
<div className="border border-gray-100 rounded-sm overflow-hidden">
|
||||
<canvas ref={yearlyCanvas} className="w-full" style={{ height: 340 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Distribution + QQ Plot */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="border border-gray-100 rounded-sm overflow-hidden">
|
||||
<canvas ref={distCanvas} className="w-full" style={{ height: 280 }} />
|
||||
</div>
|
||||
<div className="border border-gray-100 rounded-sm overflow-hidden">
|
||||
<canvas ref={qqCanvas} className="w-full" style={{ height: 280 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Rolling Stats */}
|
||||
<div className="border border-gray-100 rounded-sm overflow-hidden mb-2">
|
||||
<canvas ref={rollingCanvas} className="w-full" style={{ height: 300 }} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-right text-[9px] text-gray-400">Page 1 of 2</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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