From 9da0d60393571498389cc86d0e4d35481f112072 Mon Sep 17 00:00:00 2001 From: Stenvro Date: Sat, 19 Sep 2026 08:35:30 +0000 Subject: [PATCH 1/2] =?UTF-8?q?ui:=20backtest=20card=20=E2=80=94=20analyti?= =?UTF-8?q?cs=20on=20row=201,=20verify=20status=20+=20lock=20on=20row=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/BotManagerUI.jsx | 114 ++++++++++++----------- 1 file changed, 61 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/BotManagerUI.jsx b/frontend/src/components/BotManagerUI.jsx index f7f8f97..4f350b1 100644 --- a/frontend/src/components/BotManagerUI.jsx +++ b/frontend/src/components/BotManagerUI.jsx @@ -155,81 +155,89 @@ const fmtDay = (iso) => { return Number.isNaN(d.getTime()) ? '?' : d.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: '2-digit' }); }; -/* One slim line: how many configurations of this strategy have been +/* Two slim rows: (1) how many configurations of this strategy have been backtested (on this slice of data / ever), the range the last one walked, - whether that range is pinned, and a jump to Analytics — the performance - numbers themselves live there, not on the card */ + whether it is locked, and a jump to Analytics — the performance numbers + themselves live there, not on the card; (2) whether that range has been + verified against the exchange, with the verify/lock actions */ function BacktestResult({ bot, updateBotConfig, verifyData, verifying }) { const sm = bot.last_backtest_summary ?? bot.settings?.last_backtest_summary; if (!sm || typeof sm.trades !== 'number') return null; - const restated = Number(sm.restated_candles) || 0; - const verifiedAt = sm.verified_at ? new Date(sm.verified_at) : null; - const canVerify = !!sm.data_from && !!sm.data_to && !verifying; const total = Number(sm.variants) || 0; const onSlice = Number(sm.variants_on_slice) || 0; - const pinned = !!(bot.settings?.backtest_from && bot.settings?.backtest_to); - const canPin = !bot.is_active && !!sm.data_from && !!sm.data_to; + const locked = !!(bot.settings?.backtest_from && bot.settings?.backtest_to); + const verified = !!sm.verified_at; + const restated = Number(sm.restated_candles) || 0; + const hasRange = !!sm.data_from && !!sm.data_to; + // Lock only after a verify: the snapshot you freeze should be one you have + // compared with the exchange. Unlocking is always allowed. + const canLock = !bot.is_active && hasRange && (locked || verified); const counterTitle = onSlice > 0 ? `Variant #${onSlice} on this slice of data (same pairs, timeframe and range) — ${total} distinct configuration${total === 1 ? '' : 's'} of this strategy backtested in total. Switching pairs or range starts a new slice; the total keeps counting. Reset the bot to start over.` : (total > 0 ? `${total} distinct configuration${total === 1 ? '' : 's'} of this strategy have been backtested. Reset the bot to start counting again.` : undefined); - const pin = () => updateBotConfig(bot.id, bot, { settings: { backtest_from: sm.data_from, backtest_to: sm.data_to } }); - const unpin = () => updateBotConfig(bot.id, bot, { settings: { backtest_from: null, backtest_to: null } }); - const action = "text-3xs font-bold uppercase tracking-wider transition-colors"; + const lock = () => updateBotConfig(bot.id, bot, { settings: { backtest_from: sm.data_from, backtest_to: sm.data_to } }); + const unlock = () => updateBotConfig(bot.id, bot, { settings: { backtest_from: null, backtest_to: null } }); + const action = "text-3xs font-bold uppercase tracking-wider transition-colors shrink-0"; + const exchange = (bot.settings?.data_exchange || 'exchange').toString(); + const verifiedTime = verified ? new Date(sm.verified_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; return ( -
- {/* Row 1: what ran — counter, range, pin state */} -
- - Backtest{onSlice > 0 && #{onSlice}} - - {total > onSlice && · {total} total} - {sm.data_from && sm.data_to && ( - {fmtDay(sm.data_from)} → {fmtDay(sm.data_to)} - )} - {pinned && ( - - Pinned +
+ {/* Row 1: what ran */} +
+
+ + Backtest{onSlice > 0 && #{onSlice}} - )} -
- {/* Row 2: actions — wrap instead of squeezing the range on narrow cards */} -
- {canPin && (pinned - ? - : )} - {canVerify && ( - - )} - {verifying && verifying…} + {total > onSlice && · {total} total} + {hasRange && {fmtDay(sm.data_from)} → {fmtDay(sm.data_to)}} + {locked && ( + + Locked + + )} +
+ {/* Row 2: is the data trustworthy, and freeze it */} + {hasRange && ( +
+ 0 ? 'text-warn' : 'text-faint'}`} + title={!verified + ? 'The stored candles have not been compared with the exchange yet. Exchanges (Binance most of all) silently restate history.' + : restated > 0 + ? 'The exchange now reports different values for these candles. The backtest keeps using the local snapshot (reproducible); verify again and accept the exchange data to overwrite it.' + : 'The stored candles in this range match what the exchange reports.'}> + {verifying ? 'Verifying against exchange…' + : !verified ? 'Not verified against exchange' + : restated > 0 ? `${exchange} restated ${restated} candle${restated === 1 ? '' : 's'} · local snapshot kept · ${verifiedTime}` + : `Matches ${exchange} · verified ${verifiedTime}`} + +
+ {!verifying && ( + + )} + {canLock && (locked + ? + : )} +
+
+ )} {sm.data_changed === true && ( -

+

Data changed historical candles differ from the previous run on this slice

)} - {verifiedAt && (restated > 0 ? ( -

- Exchange restated {restated} candle{restated === 1 ? '' : 's'} - local snapshot kept · verified {fmtDay(sm.verified_at)} {verifiedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -

- ) : ( -

- Matches exchange · verified {fmtDay(sm.verified_at)} {verifiedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -

- ))}
); } From 297550a4c3b36e22b3ece50f498aa413e22529c1 Mon Sep 17 00:00:00 2001 From: Stenvro Date: Mon, 21 Sep 2026 09:03:37 +0000 Subject: [PATCH 2/2] feat: Sharpe column in performance breakdown; bump version to 2.2.1 --- .gitignore | 2 +- backend/main.py | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- frontend/src/components/TradeManager.jsx | 16 +++++++++++++++- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 4e0ec16..883256b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ data/ .cert .cert/ CLAUDE.md -brag-output/ +brag-output*/ # --- DATABASE --- *.sqlite diff --git a/backend/main.py b/backend/main.py index 7d2ff89..12e426c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -63,7 +63,7 @@ async def lifespan(app: FastAPI): enable_docs = os.getenv("ENABLE_DOCS", "0") == "1" app = FastAPI( title="ApexAlgo Engine API", - version="2.2.0", + version="2.2.1", swagger_ui_init_oauth={"clientId": "test"}, lifespan=lifespan, docs_url="/docs" if enable_docs else None, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b03f88c..ae797f9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "2.2.0", + "version": "2.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "2.2.0", + "version": "2.2.1", "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", diff --git a/frontend/package.json b/frontend/package.json index e7afa9e..91e65a7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.2.0", + "version": "2.2.1", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/frontend/src/components/TradeManager.jsx b/frontend/src/components/TradeManager.jsx index b98c177..07106bb 100644 --- a/frontend/src/components/TradeManager.jsx +++ b/frontend/src/components/TradeManager.jsx @@ -758,7 +758,7 @@ export default function TradeManager({ setError, bots = [], request = null }) { const groups = new Map(); for (const p of closedPositions) { const key = keyFn(p); - if (!groups.has(key)) groups.set(key, { key, label: labelFn(p), trades: 0, wins: 0, gross: 0, loss: 0, net: 0, modes: new Set(), fees: 0, holdMs: 0, holdN: 0, best: -Infinity, worst: Infinity, spans: [], botNames: new Set() }); + if (!groups.has(key)) groups.set(key, { key, label: labelFn(p), trades: 0, wins: 0, gross: 0, loss: 0, net: 0, modes: new Set(), fees: 0, holdMs: 0, holdN: 0, best: -Infinity, worst: Infinity, spans: [], botNames: new Set(), returns: [] }); const g = groups.get(key); const pnl = p.profit_abs || 0; g.trades += 1; @@ -768,6 +768,7 @@ export default function TradeManager({ setError, bots = [], request = null }) { g.fees += feesByPosId[p.id] || 0; g.best = Math.max(g.best, pnl); g.worst = Math.min(g.worst, pnl); + g.returns.push(p.profit_pct || 0); if (p.closed_at && p.created_at) { const h = new Date(p.closed_at) - new Date(p.created_at); if (h > 0) { g.holdMs += h; g.holdN += 1; } @@ -800,11 +801,19 @@ export default function TradeManager({ setError, bots = [], request = null }) { if (!Number.isNaN(t) && (dataTo === null || t > dataTo)) dataTo = t; } const longestFlat = longestFlatGap(g.spans, dateFrom ?? dataFrom, dateTo ?? dataTo); + // Same per-trade Sharpe as the Return / Risk tile: mean trade + // return ÷ sample std dev, not annualised + const n = g.returns.length; + const mean = n ? g.returns.reduce((a, b) => a + b, 0) / n : 0; + const sd = n > 1 ? Math.sqrt(g.returns.reduce((a, r) => a + (r - mean) ** 2, 0) / (n - 1)) : 0; + const sharpe = n > 1 && sd > 0 ? mean / sd : null; return { ...g, spans: undefined, botNames: undefined, + returns: undefined, longestFlat, + sharpe, modes: [...g.modes], winRate: g.trades ? (g.wins / g.trades) * 100 : 0, profitFactor: g.loss > 0 ? g.gross / g.loss : (g.gross > 0 ? Infinity : 0), @@ -1475,6 +1484,7 @@ export default function TradeManager({ setError, bots = [], request = null }) { Net PNL {breakdownView === 'bot' && Return} PF + Sharpe {breakdownView === 'bot' && Max DD} Best / Worst Avg hold @@ -1503,6 +1513,10 @@ export default function TradeManager({ setError, bots = [], request = null }) { )} {r.profitFactor === Infinity ? '∞' : safeNum(r.profitFactor)} + 1 ? 'text-success' : r.sharpe > 0 ? 'text-accent' : 'text-danger'}`} + title={r.sharpe === null ? 'needs at least two closed trades with different returns' : undefined}> + {r.sharpe === null ? '—' : safeNum(r.sharpe)} + {breakdownView === 'bot' && ( {r.engineDD !== null ? `-${safeNum(r.engineDD, 1)}%` : '—'} )}