BotDeepSeek/dashboard.py
2026-08-12 01:46:19 -07:00

786 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python
"""Render the trade ledger as a self-contained interactive HTML dashboard.
.venv/bin/python dashboard.py # -> dashboard.html
.venv/bin/python dashboard.py -o /tmp/out.html --open
.venv/bin/python dashboard.py --since 2026-08-01
Round trips come from analytics.py. The page embeds them as JSON and does its own
filtering/aggregation client-side, so the strategy, symbol and date-range filters
recompute every chart without regenerating the file. No external assets, no CDN:
the output is one portable HTML file.
"""
from __future__ import annotations
import argparse
import json
import webbrowser
from pathlib import Path
import analytics
# Reference thresholds drawn on the return-distribution chart. Read from config
# when it is importable so the annotations follow the live parameters.
try:
from config import config as _cfg
DEFAULT_MIN_PROFIT = _cfg.short_term.min_profit_pct
DEFAULT_STOP_LOSS = _cfg.short_term.stop_loss_pct
DEFAULT_MAX_DAILY_LOSS = _cfg.max_daily_loss
except Exception:
DEFAULT_MIN_PROFIT, DEFAULT_STOP_LOSS, DEFAULT_MAX_DAILY_LOSS = 1.5, 2.5, 150.0
HTML = r"""<title>Bot Trade History</title>
<style>
:root {
color-scheme: light;
--page:#f9f9f7; --surface:#fcfcfb;
--text:#0b0b0b; --text-2:#52514e; --muted:#898781;
--grid:#e1e0d9; --axis:#c3c2b7; --border:rgba(11,11,11,0.10);
--pos:#2a78d6; --neg:#e34948;
--s1:#2a78d6; --s2:#eb6834; --s3:#1baf7a; --s4:#eda100;
--wash:rgba(11,11,11,0.04);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--page:#0d0d0d; --surface:#1a1a19;
--text:#ffffff; --text-2:#c3c2b7; --muted:#898781;
--grid:#2c2c2a; --axis:#383835; --border:rgba(255,255,255,0.10);
--pos:#3987e5; --neg:#e66767;
--s1:#3987e5; --s2:#d95926; --s3:#199e70; --s4:#c98500;
--wash:rgba(255,255,255,0.06);
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--page:#0d0d0d; --surface:#1a1a19;
--text:#ffffff; --text-2:#c3c2b7; --muted:#898781;
--grid:#2c2c2a; --axis:#383835; --border:rgba(255,255,255,0.10);
--pos:#3987e5; --neg:#e66767;
--s1:#3987e5; --s2:#d95926; --s3:#199e70; --s4:#c98500;
--wash:rgba(255,255,255,0.06);
}
body {
background:var(--page); color:var(--text);
font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;
margin:0; padding:24px 20px 64px;
}
.wrap { max-width:1120px; margin:0 auto; }
h1 { font-size:20px; font-weight:650; margin:0 0 4px; letter-spacing:-0.01em; }
.sub { color:var(--text-2); font-size:13px; margin-bottom:20px; }
.sub code { color:var(--muted); font-size:12px; }
.card {
background:var(--surface); border:1px solid var(--border); border-radius:10px;
padding:16px 18px; margin-bottom:16px;
}
.card h2 {
font-size:13px; font-weight:600; margin:0 0 2px; letter-spacing:0.01em;
}
.card .note { color:var(--muted); font-size:12px; margin:0 0 14px; }
/* filters */
.filters { display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end; }
.fgroup { display:flex; flex-direction:column; gap:6px; }
.flabel { font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:var(--muted); }
.chips { display:flex; flex-wrap:wrap; gap:6px; }
.chip {
border:1px solid var(--border); background:transparent; color:var(--text-2);
border-radius:999px; padding:4px 11px; font-size:12.5px; cursor:pointer;
font-family:inherit; display:inline-flex; align-items:center; gap:6px;
min-height:28px;
}
.chip:hover { background:var(--wash); }
.chip[aria-pressed="true"] { color:var(--text); border-color:var(--axis); background:var(--wash); }
.chip .dot { width:8px; height:8px; border-radius:2px; background:currentColor; opacity:.35; }
.chip[aria-pressed="true"] .dot { opacity:1; }
select, input[type=date] {
font:inherit; font-size:12.5px; color:var(--text); background:var(--surface);
border:1px solid var(--border); border-radius:6px; padding:5px 8px; min-height:30px;
}
/* stat tiles */
.tiles { display:grid; grid-template-columns:repeat(4,1fr); gap:1px;
background:var(--border); border:1px solid var(--border); border-radius:10px;
overflow:hidden; margin-bottom:16px; }
@media (max-width:820px) { .tiles { grid-template-columns:repeat(2,1fr); } }
.tile { background:var(--surface); padding:14px 16px; }
.tile .k { font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:var(--muted); }
.tile .v { font-size:23px; font-weight:600; margin-top:4px; letter-spacing:-0.02em; }
.tile .h { font-size:12px; color:var(--text-2); margin-top:2px; }
.up { color:var(--pos); } .down { color:var(--neg); }
.grid2 { display:grid; grid-template-columns:repeat(auto-fit,minmax(420px,1fr)); gap:16px; }
svg { display:block; width:100%; overflow:visible; }
svg text { fill:var(--muted); font-size:11px; }
svg text.lbl { fill:var(--text-2); font-size:11.5px; }
svg text.val { fill:var(--text-2); font-size:11px; font-variant-numeric:tabular-nums; }
.gridline { stroke:var(--grid); stroke-width:1; }
.baseline { stroke:var(--axis); stroke-width:1; }
.annot { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.8; }
.legend { display:flex; flex-wrap:wrap; gap:14px; margin:0 0 10px; font-size:12px; color:var(--text-2); }
.legend span { display:inline-flex; align-items:center; gap:6px; }
.legend i { width:10px; height:10px; border-radius:2px; display:inline-block; }
/* tables */
.scroll { overflow-x:auto; }
table { border-collapse:collapse; width:100%; font-size:12.5px; }
th, td { text-align:right; padding:6px 9px; white-space:nowrap; }
th:first-child, td:first-child, th.l, td.l { text-align:left; }
thead th {
color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase;
letter-spacing:0.04em; border-bottom:1px solid var(--axis); cursor:pointer;
position:sticky; top:0; background:var(--surface);
}
thead th:hover { color:var(--text-2); }
tbody tr { border-bottom:1px solid var(--grid); }
tbody tr:hover { background:var(--wash); }
td.num { font-variant-numeric:tabular-nums; }
.tag { font-size:11px; color:var(--text-2); border:1px solid var(--border);
border-radius:4px; padding:1px 6px; }
.est { color:var(--muted); font-size:11px; }
.tallwrap { max-height:520px; overflow-y:auto; }
#tip {
position:fixed; pointer-events:none; z-index:50; opacity:0; transition:opacity .08s;
background:var(--surface); color:var(--text); border:1px solid var(--axis);
border-radius:7px; padding:8px 10px; font-size:12px; line-height:1.45;
box-shadow:0 4px 14px rgba(0,0,0,0.13); max-width:260px;
}
#tip b { font-weight:600; }
#tip .r { color:var(--text-2); }
.empty { color:var(--muted); padding:22px 0; text-align:center; font-size:13px; }
</style>
<div class="wrap">
<h1>Bot Trade History &amp; Performance</h1>
<div class="sub" id="meta"></div>
<div class="card">
<div class="filters" id="filters"></div>
</div>
<div class="tiles" id="tiles"></div>
<div class="card">
<h2>Realized equity curve</h2>
<p class="note">Cumulative net P&amp;L by exit time. Shaded band is drawdown from the running peak.</p>
<div id="equity"></div>
</div>
<div class="card">
<h2>Daily realized P&amp;L</h2>
<p class="note">Net per calendar day. The dashed line marks the $<span id="dlLbl"></span> daily-loss circuit breaker.</p>
<div id="daily"></div>
</div>
<div class="grid2">
<div class="card">
<h2>By strategy</h2>
<p class="note">Net P&amp;L; label shows trade count and win rate.</p>
<div id="byStrategy"></div>
</div>
<div class="card">
<h2>By symbol</h2>
<p class="note">Net P&amp;L; label shows trade count and win rate.</p>
<div id="bySymbol"></div>
</div>
<div class="card">
<h2>By entry signal</h2>
<p class="note">Which signal actually pays. Needs <code>reason</code> in the ledger.</p>
<div id="byEntry"></div>
</div>
<div class="card">
<h2>By exit reason</h2>
<p class="note">How trades end. Needs <code>reason</code> in the ledger.</p>
<div id="byExit"></div>
</div>
</div>
<div class="card">
<h2>Return distribution</h2>
<p class="note" id="distNote"></p>
<div id="dist"></div>
</div>
<div class="card">
<h2>Hold time vs return</h2>
<p class="note">One dot per closed trade. Colour is the owning strategy.</p>
<div id="scatter"></div>
</div>
<div class="card">
<h2>Open lots</h2>
<p class="note">Unmatched buy lots at the end of the ledger — cost basis only, not live marks.</p>
<div class="scroll" id="openTbl"></div>
</div>
<div class="card">
<h2>Trade history</h2>
<p class="note">Every closed round trip in range, newest first. Click a header to sort. <span class="est">SELL* = exit price estimated (fill missed while the bot was offline).</span></p>
<div class="scroll tallwrap" id="tripTbl"></div>
</div>
</div>
<div id="tip" role="tooltip"></div>
<script>
const DATA = __DATA__;
const SERIES = ['--s1','--s2','--s3','--s4'];
const cssv = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
/* ---------- helpers ---------- */
const money = v => (v<0?'-':'+') + '$' + Math.abs(v).toFixed(2);
const money0 = v => (v<0?'-':'') + '$' + Math.abs(v).toFixed(0);
const pct = v => v.toFixed(1) + '%';
const el = (t,a={},kids=[]) => {
const n = document.createElementNS('http://www.w3.org/2000/svg', t);
for (const k in a) n.setAttribute(k, a[k]);
kids.forEach(c => n.appendChild(c));
return n;
};
const svg = (w,h) => el('svg', {viewBox:`0 0 ${w} ${h}`, height:h,
preserveAspectRatio:'xMinYMid meet', role:'img'});
const txt = (x,y,s,cls='',anchor='start') => {
const n = el('text',{x,y,'text-anchor':anchor}); if(cls) n.setAttribute('class',cls);
n.textContent = s; return n;
};
function niceTicks(lo, hi, want=5) {
if (lo === hi) { lo -= 1; hi += 1; }
const raw = (hi-lo)/want, mag = Math.pow(10, Math.floor(Math.log10(raw)));
const step = [1,2,2.5,5,10].map(m=>m*mag).find(s=>s>=raw) || 10*mag;
const out = []; for (let v=Math.ceil(lo/step)*step; v<=hi+1e-9; v+=step) out.push(v);
return out;
}
/* Ticks are rounded inward, so the axis domain must be widened back out to the
data - otherwise an extreme value (e.g. a gap-through-stop loss) plots
outside the plot area. */
function domain(lo, hi, ticks) {
return [Math.min(lo, ticks[0]), Math.max(hi, ticks[ticks.length-1])];
}
/* mirrors analytics.summarize() so client-side filters recompute identically */
function summarize(ts) {
const n = ts.length;
if (!n) return {n:0,pnl:0,win_rate:0,expectancy:0,profit_factor:null,avg_win:0,
avg_loss:0,payoff:null,be_wr:null,best:0,worst:0,commission:0,
n_wins:0,n_losses:0,avg_hold:null};
const p = ts.map(t=>t.pnl);
const w = p.filter(x=>x>0), l = p.filter(x=>x<=0);
const gp = w.reduce((a,b)=>a+b,0), gl = -l.reduce((a,b)=>a+b,0);
const aw = w.length ? gp/w.length : 0, al = l.length ? gl/l.length : 0;
const payoff = al ? aw/al : null;
const holds = ts.map(t=>t.hold_minutes).filter(h=>h!=null);
return {
n, pnl:p.reduce((a,b)=>a+b,0), win_rate:w.length/n*100,
expectancy:p.reduce((a,b)=>a+b,0)/n, profit_factor: gl ? gp/gl : null,
avg_win:aw, avg_loss:al, payoff, be_wr: payoff ? 100/(1+payoff) : null,
best:Math.max(...p), worst:Math.min(...p),
commission: ts.reduce((a,t)=>a+t.commission,0),
n_wins:w.length, n_losses:l.length,
avg_hold: holds.length ? holds.reduce((a,b)=>a+b,0)/holds.length : null,
};
}
function groupBy(ts, keyfn) {
const m = new Map();
ts.forEach(t => { const k = keyfn(t) || '(none)';
if(!m.has(k)) m.set(k,[]); m.get(k).push(t); });
return m;
}
/* ---------- tooltip ---------- */
const tip = document.getElementById('tip');
function bindTip(node, html) {
node.addEventListener('pointerenter', e => {
tip.innerHTML = html; tip.style.opacity = 1; move(e);
});
node.addEventListener('pointermove', move);
node.addEventListener('pointerleave', () => tip.style.opacity = 0);
function move(e) {
const r = tip.getBoundingClientRect();
let x = e.clientX + 14, y = e.clientY - r.height - 10;
if (x + r.width > innerWidth - 8) x = e.clientX - r.width - 14;
if (y < 8) y = e.clientY + 16;
tip.style.left = x + 'px'; tip.style.top = y + 'px';
}
}
/* ---------- state & filtering ---------- */
const strategies = [...new Set(DATA.trips.map(t=>t.strategy_short))].sort();
const symbols = [...new Set(DATA.trips.map(t=>t.symbol))].sort();
const stratColor = {};
strategies.forEach((s,i) => stratColor[s] = SERIES[i % SERIES.length]);
const state = { strat:new Set(strategies), sym:new Set(symbols), days:0 };
function filtered() {
let ts = DATA.trips.filter(t => state.strat.has(t.strategy_short) && state.sym.has(t.symbol));
if (state.days > 0) {
const all = DATA.trips.map(t=>t.exit_ts).sort();
if (all.length) {
const last = new Date(all[all.length-1]);
const cut = new Date(last.getTime() - state.days*86400000).toISOString().slice(0,10);
ts = ts.filter(t => t.exit_ts.slice(0,10) >= cut);
}
}
return ts;
}
/* ---------- filter UI ---------- */
function buildFilters() {
const f = document.getElementById('filters');
f.innerHTML = '';
f.appendChild(chipGroup('Strategy', strategies, state.strat, s=>cssv(stratColor[s])));
f.appendChild(chipGroup('Symbol', symbols, state.sym, ()=>null));
const g = document.createElement('div'); g.className = 'fgroup';
g.innerHTML = '<span class="flabel">Range</span>';
const sel = document.createElement('select');
[[0,'All time'],[7,'Last 7 days'],[30,'Last 30 days'],[90,'Last 90 days']]
.forEach(([v,l]) => { const o=document.createElement('option'); o.value=v; o.textContent=l; sel.appendChild(o); });
sel.value = state.days;
sel.onchange = () => { state.days = +sel.value; render(); };
g.appendChild(sel); f.appendChild(g);
function chipGroup(label, items, set, colorFn) {
const g = document.createElement('div'); g.className='fgroup';
g.innerHTML = `<span class="flabel">${label}</span>`;
const box = document.createElement('div'); box.className='chips';
items.forEach(it => {
const b = document.createElement('button');
b.className='chip'; b.type='button';
b.setAttribute('aria-pressed', set.has(it));
const c = colorFn(it);
b.innerHTML = (c ? `<i class="dot" style="background:${c}"></i>` : '') + it;
b.onclick = () => {
if (set.has(it)) { if (set.size>1) set.delete(it); } else set.add(it);
b.setAttribute('aria-pressed', set.has(it)); render();
};
box.appendChild(b);
});
g.appendChild(box); return g;
}
}
/* ---------- stat tiles ---------- */
function renderTiles(ts) {
const s = summarize(ts);
const dd = drawdownOf(ts);
const beat = s.be_wr != null ? s.win_rate - s.be_wr : null;
const tiles = [
['Net realized', money(s.pnl), s.pnl>=0?'up':'down',
`${s.n} trades · ${money0(s.commission)} commission`],
['Expectancy', money(s.expectancy), s.expectancy>=0?'up':'down', 'per trade'],
['Win rate', pct(s.win_rate), '',
s.be_wr!=null ? `needs ${pct(s.be_wr)} to break even` : `${s.n_wins}W / ${s.n_losses}L`],
['Edge vs breakeven', beat!=null ? (beat>=0?'+':'')+beat.toFixed(1)+'pp' : '',
beat!=null ? (beat>=0?'up':'down') : '',
'win rate minus breakeven'],
['Profit factor', s.profit_factor!=null ? s.profit_factor.toFixed(2) : '',
s.profit_factor!=null ? (s.profit_factor>=1?'up':'down') : '',
`gross ${money0(s.avg_win*s.n_wins)} / ${money0(-s.avg_loss*s.n_losses)}`],
['Payoff ratio', s.payoff!=null ? s.payoff.toFixed(2) : '',
s.payoff!=null ? (s.payoff>=1?'up':'down') : '',
`avg ${money0(s.avg_win)} win / ${money0(s.avg_loss)} loss`],
['Max drawdown', money(dd), dd<0?'down':'', 'realized, peak to trough'],
['Avg hold', s.avg_hold!=null ? fmtHold(s.avg_hold) : '', '', 'entry to exit'],
];
document.getElementById('tiles').innerHTML = tiles.map(([k,v,cls,h]) =>
`<div class="tile"><div class="k">${k}</div><div class="v ${cls}">${v}</div><div class="h">${h}</div></div>`
).join('');
}
const fmtHold = m => m < 90 ? Math.round(m)+' min'
: m < 1440 ? (m/60).toFixed(1)+' h' : (m/1440).toFixed(1)+' d';
function drawdownOf(ts) {
let cum=0, peak=0, dd=0;
[...ts].sort((a,b)=>a.exit_ts<b.exit_ts?-1:1).forEach(t=>{
cum+=t.pnl; peak=Math.max(peak,cum); dd=Math.min(dd,cum-peak);
});
return dd;
}
/* ---------- equity curve ---------- */
function renderEquity(ts) {
const host = document.getElementById('equity');
host.innerHTML = '';
if (!ts.length) return host.innerHTML = '<div class="empty">No closed trades in range.</div>';
const pts = [...ts].sort((a,b)=>a.exit_ts<b.exit_ts?-1:1);
let cum=0, peak=0;
const series = pts.map(t => { cum+=t.pnl; peak=Math.max(peak,cum);
return {x:new Date(t.exit_ts).getTime(), cum, peak, t}; });
const W = host.clientWidth || 860, H = 260;
const m = {t:12, r:16, b:26, l:56};
const iw = W-m.l-m.r, ih = H-m.t-m.b;
const x0 = series[0].x, x1 = series[series.length-1].x;
const sx = v => m.l + (x1===x0 ? iw/2 : (v-x0)/(x1-x0)*iw);
const lo = Math.min(0, ...series.map(p=>p.cum)), hi = Math.max(0, ...series.map(p=>p.peak));
const ticks = niceTicks(lo, hi);
const yLo = Math.min(lo, ticks[0]), yHi = Math.max(hi, ticks[ticks.length-1]);
const sy = v => m.t + ih - (v-yLo)/(yHi-yLo)*ih;
const s = svg(W,H);
ticks.forEach(v => {
s.appendChild(el('line',{class:'gridline',x1:m.l,x2:W-m.r,y1:sy(v),y2:sy(v)}));
s.appendChild(txt(m.l-9, sy(v)+4, money0(v), 'val', 'end'));
});
s.appendChild(el('line',{class:'baseline',x1:m.l,x2:W-m.r,y1:sy(0),y2:sy(0)}));
// drawdown band: between running peak and equity
const band = series.map(p=>`${sx(p.x)},${sy(p.peak)}`).join(' ') + ' ' +
[...series].reverse().map(p=>`${sx(p.x)},${sy(p.cum)}`).join(' ');
s.appendChild(el('polygon',{points:band, fill:cssv('--neg'), opacity:0.13}));
s.appendChild(el('polyline',{
points: series.map(p=>`${sx(p.x)},${sy(p.cum)}`).join(' '),
fill:'none', stroke:cssv('--pos'), 'stroke-width':2,
'stroke-linejoin':'round','stroke-linecap':'round'}));
// x labels: first / middle / last date
[0, Math.floor(series.length/2), series.length-1].filter((v,i,a)=>a.indexOf(v)===i)
.forEach((i,j,arr) => {
const p = series[i];
s.appendChild(txt(sx(p.x), H-8, new Date(p.x).toISOString().slice(5,10),
'', j===0?'start':(j===arr.length-1?'end':'middle')));
});
// hover markers (invisible wide hit targets)
series.forEach(p => {
const g = el('g');
g.appendChild(el('circle',{cx:sx(p.x),cy:sy(p.cum),r:8,fill:'transparent'}));
g.appendChild(el('circle',{cx:sx(p.x),cy:sy(p.cum),r:2.5,
fill:cssv('--pos'),opacity:0.55}));
bindTip(g, `<b>${p.t.symbol}</b> <span class="r">${p.t.strategy_short}</span><br>
<span class="r">${p.t.exit_ts.replace('T',' ')}</span><br>
trade ${money(p.t.pnl)} · cumulative <b>${money(p.cum)}</b>
${p.cum<p.peak ? `<br><span class="r">drawdown ${money(p.cum-p.peak)}</span>`:''}`);
s.appendChild(g);
});
host.appendChild(s);
}
/* ---------- daily bars ---------- */
function renderDaily(ts) {
const host = document.getElementById('daily');
host.innerHTML = '';
if (!ts.length) return host.innerHTML = '<div class="empty">No closed trades in range.</div>';
const m2 = new Map();
ts.forEach(t => { const d=t.exit_ts.slice(0,10); m2.set(d,(m2.get(d)||0)+t.pnl); });
const days = [...m2.entries()].sort();
const W = host.clientWidth || 860, H = 200;
const m = {t:10,r:16,b:30,l:56}, iw=W-m.l-m.r, ih=H-m.t-m.b;
const vals = days.map(d=>d[1]);
const dLo = Math.min(0,...vals,-DATA.max_daily_loss), dHi = Math.max(0,...vals);
const ticks = niceTicks(dLo, dHi);
const [yLo, yHi] = domain(dLo, dHi, ticks);
const sy = v => m.t + ih - (v-yLo)/(yHi-yLo)*ih;
const bw = Math.max(2, Math.min(26, iw/days.length - 2));
const s = svg(W,H);
ticks.forEach(v=>{
s.appendChild(el('line',{class:'gridline',x1:m.l,x2:W-m.r,y1:sy(v),y2:sy(v)}));
s.appendChild(txt(m.l-9,sy(v)+4,money0(v),'val','end'));
});
// circuit-breaker reference
if (-DATA.max_daily_loss >= yLo) {
s.appendChild(el('line',{class:'annot',x1:m.l,x2:W-m.r,
y1:sy(-DATA.max_daily_loss),y2:sy(-DATA.max_daily_loss)}));
}
days.forEach(([d,v],i) => {
const cx = m.l + (i+0.5)*(iw/days.length);
const y = v>=0 ? sy(v) : sy(0), h = Math.max(1, Math.abs(sy(v)-sy(0)));
const g = el('g');
g.appendChild(el('rect',{x:cx-bw/2, y:y, width:bw, height:h, rx:Math.min(4,bw/2),
fill:cssv(v>=0?'--pos':'--neg')}));
g.appendChild(el('rect',{x:cx-bw/2-3, y:m.t, width:bw+6, height:ih, fill:'transparent'}));
bindTip(g, `<b>${d}</b><br>net <b>${money(v)}</b>`);
s.appendChild(g);
});
s.appendChild(el('line',{class:'baseline',x1:m.l,x2:W-m.r,y1:sy(0),y2:sy(0)}));
[0, days.length-1].filter((v,i,a)=>a.indexOf(v)===i).forEach((i,j) => {
s.appendChild(txt(m.l+(i+0.5)*(iw/days.length), H-8, days[i][0].slice(5),
'', j===0?'start':'end'));
});
host.appendChild(s);
}
/* ---------- horizontal attribution bars ---------- */
function renderBars(hostId, ts, keyfn, colorByStrategy=false) {
const host = document.getElementById(hostId);
host.innerHTML = '';
const groups = [...groupBy(ts, keyfn).entries()]
.map(([k,v]) => [k, summarize(v)])
.sort((a,b) => b[1].pnl - a[1].pnl);
if (!groups.length || (groups.length===1 && groups[0][0]==='(none)' && !ts.length))
return host.innerHTML = '<div class="empty">No data in range.</div>';
if (groups.every(g => g[0]==='(none)'))
return host.innerHTML = '<div class="empty">Not recorded in this ledger yet — '
+ 'new trades will populate it.</div>';
const rowH = 30, W = host.clientWidth || 420, H = groups.length*rowH + 24;
const labelW = Math.min(120, Math.max(...groups.map(g=>g[0].length))*7 + 10);
const m = {t:6, r:64, l:labelW+8}, iw = W-m.l-m.r;
const mx = Math.max(1, ...groups.map(g=>Math.abs(g[1].pnl)));
const zero = m.l + iw/2, half = iw/2;
const s = svg(W,H);
s.appendChild(el('line',{class:'baseline',x1:zero,x2:zero,y1:m.t,y2:m.t+groups.length*rowH}));
groups.forEach(([k,st],i) => {
const cy = m.t + i*rowH + rowH/2;
const w = Math.abs(st.pnl)/mx*half;
const g = el('g');
const pos = st.pnl >= 0;
g.appendChild(el('rect',{
x: pos ? zero+1 : zero-w-1, y: cy-7, width: Math.max(1.5,w), height:14,
rx:4, fill: colorByStrategy ? cssv(stratColor[k]||'--s1') : cssv(pos?'--pos':'--neg')}));
s.appendChild(txt(m.l-8, cy+4, k, 'lbl', 'end'));
s.appendChild(txt(W-m.r+8, cy+4, money0(st.pnl), 'val'));
g.appendChild(el('rect',{x:m.l,y:cy-rowH/2,width:iw,height:rowH,fill:'transparent'}));
bindTip(g, `<b>${k}</b><br>net <b>${money(st.pnl)}</b> over ${st.n} trades<br>
<span class="r">win ${pct(st.win_rate)}`
+ (st.be_wr!=null ? ` · breakeven ${pct(st.be_wr)}` : '')
+ `<br>expectancy ${money(st.expectancy)} · payoff `
+ (st.payoff!=null?st.payoff.toFixed(2):'') + `</span>`);
s.appendChild(g);
});
host.appendChild(s);
}
/* ---------- return distribution ---------- */
function renderDist(ts) {
const host = document.getElementById('dist');
host.innerHTML = '';
document.getElementById('distNote').innerHTML =
`Net return per trade in 0.5% bins. Dashed lines mark the +${DATA.min_profit_pct}% `
+ `minimum-profit exit gate and the ${DATA.stop_loss_pct}% hard stop. `
+ `A healthy distribution has its right tail reaching further than its left.`;
if (!ts.length) return host.innerHTML = '<div class="empty">No closed trades in range.</div>';
const BIN = 0.5;
const vals = ts.map(t=>t.pnl_pct);
const lo = Math.floor(Math.min(...vals, -DATA.stop_loss_pct)/BIN)*BIN;
const hi = Math.ceil(Math.max(...vals, DATA.min_profit_pct)/BIN)*BIN;
const nb = Math.max(1, Math.round((hi-lo)/BIN));
const bins = Array.from({length:nb}, (_,i)=>({lo:lo+i*BIN, hi:lo+(i+1)*BIN, items:[]}));
vals.forEach((v,i) => {
let k = Math.floor((v-lo)/BIN); k = Math.max(0, Math.min(nb-1,k));
bins[k].items.push(ts[i]);
});
const W = host.clientWidth || 860, H = 236;
// extra top margin so the threshold labels sit above the plot, never on a bar
const m = {t:26,r:16,b:34,l:40}, iw=W-m.l-m.r, ih=H-m.t-m.b;
const mxc = Math.max(1, ...bins.map(b=>b.items.length));
const cTicks = niceTicks(0, mxc, 4);
const yHi = Math.max(mxc, cTicks[cTicks.length-1]);
const sy = c => m.t + ih - c/yHi*ih;
const sx = v => m.l + (v-lo)/(hi-lo)*iw;
const bw = Math.max(2, iw/nb - 2);
const s = svg(W,H);
cTicks.forEach(c=>{
s.appendChild(el('line',{class:'gridline',x1:m.l,x2:W-m.r,y1:sy(c),y2:sy(c)}));
s.appendChild(txt(m.l-8,sy(c)+4,c,'val','end'));
});
bins.forEach(b => {
if (!b.items.length) return;
const c = b.items.length, cx = sx((b.lo+b.hi)/2);
const g = el('g');
g.appendChild(el('rect',{x:cx-bw/2, y:sy(c), width:bw, height:ih-(sy(c)-m.t),
rx:Math.min(4,bw/2), fill:cssv(b.lo>=0?'--pos':'--neg')}));
g.appendChild(el('rect',{x:cx-bw/2-2,y:m.t,width:bw+4,height:ih,fill:'transparent'}));
const sum = b.items.reduce((a,t)=>a+t.pnl,0);
bindTip(g, `<b>${b.lo.toFixed(1)}% to ${b.hi.toFixed(1)}%</b><br>
${c} trade${c>1?'s':''} · net ${money(sum)}<br>
<span class="r">${[...new Set(b.items.map(t=>t.symbol))].join(', ')}</span>`);
s.appendChild(g);
});
[[DATA.min_profit_pct, 'min profit'], [-DATA.stop_loss_pct, 'stop']].forEach(([v,l])=>{
s.appendChild(el('line',{class:'annot',x1:sx(v),x2:sx(v),y1:m.t-4,y2:m.t+ih}));
s.appendChild(txt(sx(v), m.t-10, l, '', 'middle'));
});
s.appendChild(el('line',{class:'baseline',x1:m.l,x2:W-m.r,y1:m.t+ih,y2:m.t+ih}));
niceTicks(lo,hi,6).forEach(v=>{
if (v<lo-1e-9||v>hi+1e-9) return;
s.appendChild(txt(sx(v), H-10, v.toFixed(1)+'%', '', 'middle'));
});
host.appendChild(s);
}
/* ---------- hold vs return scatter ---------- */
function renderScatter(ts) {
const host = document.getElementById('scatter');
host.innerHTML = '';
const pts = ts.filter(t => t.hold_minutes != null);
if (!pts.length) return host.innerHTML =
'<div class="empty">No hold times available (entry timestamps missing for these lots).</div>';
const used = [...new Set(pts.map(p=>p.strategy_short))].sort();
host.innerHTML = '<div class="legend">' + used.map(s =>
`<span><i style="background:${cssv(stratColor[s])}"></i>${s}</span>`).join('') + '</div>';
const W = host.clientWidth || 860, H = 260;
const m = {t:12,r:16,b:34,l:48}, iw=W-m.l-m.r, ih=H-m.t-m.b;
const hMax = Math.max(...pts.map(p=>p.hold_minutes));
const rLo = Math.min(0,...pts.map(p=>p.pnl_pct)), rHi = Math.max(0,...pts.map(p=>p.pnl_pct));
const rTicks = niceTicks(rLo, rHi, 5);
const [yLo, yHi] = domain(rLo, rHi, rTicks);
const sy = v => m.t + ih - (v-yLo)/(yHi-yLo)*ih;
// sqrt x-scale: hold times span minutes to days
const sx = v => m.l + Math.sqrt(v/Math.max(1,hMax))*iw;
const s = svg(W,H);
rTicks.forEach(v=>{
s.appendChild(el('line',{class:'gridline',x1:m.l,x2:W-m.r,y1:sy(v),y2:sy(v)}));
s.appendChild(txt(m.l-8,sy(v)+4,v.toFixed(1)+'%','val','end'));
});
s.appendChild(el('line',{class:'baseline',x1:m.l,x2:W-m.r,y1:sy(0),y2:sy(0)}));
const xt = [15,60,240,1440,4320,10080].filter(v => v <= hMax*0.88);
xt.forEach(v => s.appendChild(txt(sx(v), H-10, fmtHold(v), '', 'middle')));
// always anchor the right end, otherwise the axis trails off unlabelled
s.appendChild(txt(sx(hMax), H-10, fmtHold(hMax), '', 'end'));
pts.forEach(p => {
const g = el('g');
g.appendChild(el('circle',{cx:sx(p.hold_minutes),cy:sy(p.pnl_pct),r:5,
fill:cssv(stratColor[p.strategy_short]||'--s1'), 'fill-opacity':0.8,
stroke:cssv('--surface'), 'stroke-width':2}));
g.appendChild(el('circle',{cx:sx(p.hold_minutes),cy:sy(p.pnl_pct),r:11,fill:'transparent'}));
bindTip(g, `<b>${p.symbol}</b> <span class="r">${p.strategy_short}</span><br>
${p.pnl_pct>=0?'+':''}${p.pnl_pct.toFixed(2)}% · ${money(p.pnl)}<br>
<span class="r">held ${fmtHold(p.hold_minutes)}`
+ (p.exit_reason?` · exit ${p.exit_reason}`:'') + `</span>`);
s.appendChild(g);
});
host.appendChild(s);
}
/* ---------- tables ---------- */
let sortKey='exit_ts', sortDir=-1;
function renderTrips(ts) {
const host = document.getElementById('tripTbl');
if (!ts.length) return host.innerHTML = '<div class="empty">No closed trades in range.</div>';
const cols = [
['exit_ts','Exit', t=>t.exit_ts.replace('T',' '), 'l'],
['strategy_short','Strategy', t=>t.strategy_short, 'l'],
['symbol','Symbol', t=>t.symbol, 'l'],
['qty','Qty', t=>(+t.qty).toLocaleString(), 'num'],
['entry_price','Entry', t=>t.entry_price.toFixed(2), 'num'],
['exit_price','Exit px', t=>t.exit_price.toFixed(2) + (t.estimated?' <span class="est">*</span>':''), 'num'],
['pnl','Net P&L', t=>`<span class="${t.pnl>=0?'up':'down'}">${money(t.pnl)}</span>`, 'num'],
['pnl_pct','Return', t=>`<span class="${t.pnl>=0?'up':'down'}">${(t.pnl_pct>=0?'+':'')+t.pnl_pct.toFixed(2)}%</span>`, 'num'],
['hold_minutes','Held', t=>t.hold_minutes!=null?fmtHold(t.hold_minutes):'', 'num'],
['entry_reason','Entry signal', t=>t.entry_reason?`<span class="tag">${t.entry_reason}</span>`:'', 'l'],
['exit_reason','Exit reason', t=>t.exit_reason?`<span class="tag">${t.exit_reason}</span>`:'', 'l'],
];
const rows = [...ts].sort((a,b) => {
const x=a[sortKey], y=b[sortKey];
if (x==null) return 1; if (y==null) return -1;
return (x<y?-1:x>y?1:0) * sortDir;
});
host.innerHTML = `<table><thead><tr>` +
cols.map(([k,l,,cls]) => `<th class="${cls==='l'?'l':''}" data-k="${k}">${l}`
+ (sortKey===k ? (sortDir<0?'':'') : '') + `</th>`).join('') +
`</tr></thead><tbody>` +
rows.map(t => '<tr>' + cols.map(([,,fn,cls]) =>
`<td class="${cls}">${fn(t)}</td>`).join('') + '</tr>').join('') +
`</tbody></table>`;
host.querySelectorAll('th').forEach(th => th.onclick = () => {
const k = th.dataset.k;
if (sortKey===k) sortDir*=-1; else { sortKey=k; sortDir=-1; }
renderTrips(filtered());
});
}
function renderOpen() {
const host = document.getElementById('openTbl');
const lots = DATA.open_lots;
if (!lots.length) return host.innerHTML = '<div class="empty">No open lots.</div>';
host.innerHTML = `<table><thead><tr>
<th class="l">Strategy</th><th class="l">Symbol</th><th>Qty</th>
<th>Entry</th><th>Cost basis</th><th class="l">Signal</th><th class="l">Opened</th>
</tr></thead><tbody>` +
lots.map(l => `<tr>
<td class="l">${l.strategy_short}</td><td class="l">${l.symbol}</td>
<td class="num">${(+l.qty).toLocaleString()}</td>
<td class="num">${l.price.toFixed(2)}</td>
<td class="num">$${l.cost.toFixed(2)}</td>
<td class="l">${l.reason?`<span class="tag">${l.reason}</span>`:''}</td>
<td class="l">${l.ts ? l.ts.replace('T',' ') : (l.seeded?'<span class="est">pre-ledger</span>':'')}</td>
</tr>`).join('') + `</tbody></table>`;
}
/* ---------- orchestration ---------- */
function render() {
const ts = filtered();
renderTiles(ts);
renderEquity(ts);
renderDaily(ts);
renderBars('byStrategy', ts, t=>t.strategy_short, true);
renderBars('bySymbol', ts, t=>t.symbol);
renderBars('byEntry', ts, t=>t.entry_reason);
renderBars('byExit', ts, t=>t.exit_reason);
renderDist(ts);
renderScatter(ts);
renderTrips(ts);
}
document.getElementById('meta').innerHTML =
`${DATA.overall.n} closed round trips from ${DATA.n_records} ledger records`
+ (DATA.unmatched_sell_qty ? ` · <span class="est">${DATA.unmatched_sell_qty} unit(s) sold with unknown cost basis, excluded</span>` : '')
+ `<br><code>${DATA.ledger}</code> · generated ${DATA.generated.replace('T',' ')}`;
document.getElementById('dlLbl').textContent = DATA.max_daily_loss.toFixed(0);
buildFilters();
renderOpen();
render();
let rt; addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(render, 140); });
</script>
"""
def build(data: dict, min_profit: float, stop_loss: float, max_daily_loss: float) -> str:
"""Inject the analysis bundle into the page template."""
payload = {
"ledger": data["ledger"],
"generated": data["generated"],
"n_records": data["n_records"],
"unmatched_sell_qty": data["unmatched_sell_qty"],
"overall": data["overall"],
"trips": data["trips"],
"open_lots": data["open_lots"],
"min_profit_pct": min_profit,
"stop_loss_pct": stop_loss,
"max_daily_loss": max_daily_loss,
}
return HTML.replace("__DATA__", json.dumps(payload, default=str))
def main():
ap = argparse.ArgumentParser(description="Render trades.jsonl as an HTML dashboard")
ap.add_argument("--ledger", default=str(analytics.LEDGER))
ap.add_argument("-o", "--out", default="dashboard.html")
ap.add_argument("--since", help="only include trades closed on/after YYYY-MM-DD")
ap.add_argument("--until", help="only include trades closed on/before YYYY-MM-DD")
ap.add_argument("--min-profit", type=float, default=DEFAULT_MIN_PROFIT,
help="min-profit annotation on the distribution chart")
ap.add_argument("--stop-loss", type=float, default=DEFAULT_STOP_LOSS,
help="stop-loss annotation on the distribution chart")
ap.add_argument("--max-daily-loss", type=float, default=DEFAULT_MAX_DAILY_LOSS,
help="daily circuit-breaker reference line")
ap.add_argument("--open", action="store_true", help="open the result in a browser")
args = ap.parse_args()
path = Path(args.ledger)
if not path.exists():
raise SystemExit(f"ledger not found: {path}")
data = analytics.analyze(path, args.since, args.until)
out = Path(args.out)
out.write_text(build(data, args.min_profit, args.stop_loss, args.max_daily_loss))
print(f"wrote {out} ({data['overall']['n']} closed trips, "
f"net {data['overall']['pnl']:+.2f})")
if args.open:
webbrowser.open(out.resolve().as_uri())
if __name__ == "__main__":
main()