786 lines
34 KiB
Python
786 lines
34 KiB
Python
#!/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 & 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&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&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&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&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()
|