Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ data/
.cert
.cert/
CLAUDE.md
brag-output/
brag-output*/

# --- DATABASE ---
*.sqlite
Expand Down
2 changes: 1 addition & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.2.0",
"version": "2.2.1",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
Expand Down
114 changes: 61 additions & 53 deletions frontend/src/components/BotManagerUI.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="px-4 py-2 border-b border-border bg-bg/40 flex flex-col gap-1">
{/* Row 1: what ran — counter, range, pin state */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-3xs font-num text-muted" title={counterTitle}>
<span className="font-bold uppercase tracking-widest">
Backtest{onSlice > 0 && <span className="text-faint"> #{onSlice}</span>}
</span>
{total > onSlice && <span className="text-faint">· {total} total</span>}
{sm.data_from && sm.data_to && (
<span>{fmtDay(sm.data_from)} → {fmtDay(sm.data_to)}</span>
)}
{pinned && (
<span className="rounded-sm border border-info/40 bg-info/10 px-1 py-px font-bold uppercase tracking-wider text-info"
title="Every start replays exactly this range of candles, so the result stays reproducible. Use 'Rerun latest' to slide the window to the newest data.">
Pinned
<div className="px-4 py-2 border-b border-border bg-bg/40 flex flex-col gap-1 text-3xs">
{/* Row 1: what ran */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 min-w-0 font-num text-muted" title={counterTitle}>
<span className="font-bold uppercase tracking-widest shrink-0">
Backtest{onSlice > 0 && <span className="text-faint"> #{onSlice}</span>}
</span>
)}
</div>
{/* Row 2: actions — wrap instead of squeezing the range on narrow cards */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5">
{canPin && (pinned
? <button type="button" onClick={unpin} title="Drop the pin: the next start walks the newest candles again"
className={`${action} text-muted hover:text-text`}>Rerun latest</button>
: <button type="button" onClick={pin} title="Pin this range: every next start replays exactly these candles (reproducible result)"
className={`${action} text-muted hover:text-text`}>Pin range</button>)}
{canVerify && (
<button type="button" onClick={() => verifyData(bot)}
title="Re-fetch this range from the exchange and compare it with the stored candles. Exchanges (Binance most of all) silently restate history; the local snapshot is never changed unless you accept the exchange data."
className={`${action} text-muted hover:text-text`}>Verify data</button>
)}
{verifying && <span className="text-3xs text-faint">verifying…</span>}
{total > onSlice && <span className="text-faint shrink-0">· {total} total</span>}
{hasRange && <span className="truncate">{fmtDay(sm.data_from)} → {fmtDay(sm.data_to)}</span>}
{locked && (
<span className="shrink-0 rounded-sm border border-info/40 bg-info/10 px-1 py-px font-bold uppercase tracking-wider text-info"
title="Every start replays exactly this range of candles, so the result stays reproducible. Unlock to slide the window to the newest data.">
Locked
</span>
)}
</div>
<button type="button"
onClick={() => window.dispatchEvent(new CustomEvent('open-analytics', { detail: { bot: bot.name, mode: 'backtest' } }))}
className={`${action} text-info hover:text-text ml-auto`}>
View in Analytics →
</button>
</div>
{/* Row 2: is the data trustworthy, and freeze it */}
{hasRange && (
<div className="flex items-center gap-3">
<span className={`min-w-0 truncate ${restated > 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}`}
</span>
<div className="flex items-center gap-3 ml-auto shrink-0">
{!verifying && (
<button type="button" onClick={() => verifyData(bot)}
title="Re-fetch this range from the exchange and compare it with the stored candles. Nothing is changed unless you accept the exchange data."
className={`${action} text-muted hover:text-text`}>Verify data</button>
)}
{canLock && (locked
? <button type="button" onClick={unlock} title="Unlock: the next start walks the newest candles again"
className={`${action} text-muted hover:text-text`}>Unlock</button>
: <button type="button" onClick={lock} title="Lock this range: every next start replays exactly these candles (reproducible result)"
className={`${action} text-muted hover:text-text`}>Lock range</button>)}
</div>
</div>
)}
{sm.data_changed === true && (
<p className="text-3xs text-warn leading-snug"
title="The raw candles in this range hash differently than in the previous run on the same slice — a re-download, gap repair or an exchange restatement changed them. The two results are not directly comparable.">
<p className="text-warn leading-snug"
title="The raw candles in this range hash differently than in the previous run on the same slice — a re-download, gap repair or an accepted exchange restatement changed them. The two results are not directly comparable.">
<span className="font-bold uppercase tracking-wider mr-1">Data changed</span>
historical candles differ from the previous run on this slice
</p>
)}
{verifiedAt && (restated > 0 ? (
<p className="text-3xs text-warn leading-snug"
title="The exchange now reports different OHLCV values for these candles than the local snapshot. The backtest still uses the local snapshot (reproducible); run 'Verify data' again and accept the exchange data to overwrite them.">
<span className="font-bold uppercase tracking-wider mr-1">Exchange restated {restated} candle{restated === 1 ? '' : 's'}</span>
local snapshot kept · verified {fmtDay(sm.verified_at)} {verifiedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
) : (
<p className="text-3xs text-faint leading-snug" title="The stored candles in this range match what the exchange reports today.">
Matches exchange · verified {fmtDay(sm.verified_at)} {verifiedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
))}
</div>
);
}
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/components/TradeManager.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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; }
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1475,6 +1484,7 @@ export default function TradeManager({ setError, bots = [], request = null }) {
<th className={`${thClass} text-right`}>Net PNL</th>
{breakdownView === 'bot' && <th className={`${thClass} text-right`}>Return</th>}
<th className={`${thClass} text-right`}>PF</th>
<th className={`${thClass} text-right`} title="Return / Risk: mean trade return ÷ std dev of trade returns (per-trade Sharpe, not annualised)">Sharpe</th>
{breakdownView === 'bot' && <th className={`${thClass} text-right`}>Max DD</th>}
<th className={`${thClass} text-right`}>Best / Worst</th>
<th className={`${thClass} text-right`}>Avg hold</th>
Expand Down Expand Up @@ -1503,6 +1513,10 @@ export default function TradeManager({ setError, bots = [], request = null }) {
</td>
)}
<td className="px-3 py-1.5 text-right font-num text-muted">{r.profitFactor === Infinity ? '∞' : safeNum(r.profitFactor)}</td>
<td className={`px-3 py-1.5 text-right font-num ${r.sharpe === null ? 'text-faint' : r.sharpe > 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)}
</td>
{breakdownView === 'bot' && (
<td className="px-3 py-1.5 text-right font-num text-danger">{r.engineDD !== null ? `-${safeNum(r.engineDD, 1)}%` : '—'}</td>
)}
Expand Down
Loading