f/chart_widget.py
Greg Pomerantz d8703a7a63 Stock & Portfolio Analyzer: full UI rework
- single spec grammar for symbol and benchmark fields: commas join one
  portfolio (MSFT:0.6,V:0.4), spaces separate distinct symbols/portfolios;
  both fields accept one or many entries
- benchmarks simulated with the same scheme/cost/tax rules; per-benchmark
  beta/alpha columns; after-tax benchmark curves
- global Curve mode (pre/after/both) above the tabs; clean names in
  single-curve mode
- live updates: field commits on Enter/blur, page recomputes per rerun;
  portfolio+tax sims cached (st.cache_data); plotly.js from CDN (4.6MB ->
  browser-cached) with F_INLINE_PLOTLY=1 offline fallback
- chart: legend underneath, solid lines, pan sticks to data edges
  (width-preserving), zoom edge-clamped
- inputs persist in settings.json across reloads/restarts/devices
- tests: tests/test_app.py (AppTest) + tests/test_e2e_browser.py
  (Playwright) via ./run_tests.sh
2026-08-24 16:05:27 -04:00

236 lines
9.1 KiB
Python

"""Interactive equity chart with re-basing zoom.
Streamlit's st.plotly_chart cannot run JS on relayout (scripts are sanitized
and component iframes can't reach the parent chart), so this builds a
self-contained HTML page — plotly.js + inline data + zoom logic — and embeds
it with st.components.v1.html / st.iframe.
Semantics (the point of this chart):
* The x-axis is clamped to the data's first/last timestamp — you can never
view empty space before or after the data.
* On EVERY view change (zoom, pan, scroll, reset) each series is
RE-BASED: its leftmost visible point is scaled to exactly 1.0.
So whatever window you look at, every line starts at 1.0 on the left
and the chart shows the change in value from that starting point.
* The y-axis is fitted exactly to the re-based visible data — no blank
space top or bottom (1.0 is always in view since every line starts there).
"""
from __future__ import annotations
import json
from pathlib import Path
import pandas as pd
_PLOTLY_URL = "https://cdn.plot.ly/plotly-2.35.2.min.js"
_JS_CACHE = Path(__file__).parent / ".cache" / "plotly.min.js"
def _plotly_js_tag() -> str:
"""CDN by default: the browser caches the ~4.6 MB library after the first
load, so chart reloads (window/curve toggles) send a small HTML payload.
Set F_INLINE_PLOTLY=1 for fully offline use (inlines the local copy)."""
import os
if os.environ.get("F_INLINE_PLOTLY"):
if not _JS_CACHE.exists():
_JS_CACHE.parent.mkdir(parents=True, exist_ok=True)
try:
import urllib.request
urllib.request.urlretrieve(_PLOTLY_URL, _JS_CACHE)
except Exception:
pass
if _JS_CACHE.exists():
return f"<script>{_JS_CACHE.read_text()}</script>"
return f'<script src="{_PLOTLY_URL}"></script>'
_TEMPLATE = """<!DOCTYPE html>
<html><head><meta charset="utf-8">{plotly}
<style>html,body{{margin:0;padding:0;background:#fff}}</style>
</head><body>
<div id="c" style="width:100%;height:{height}px"></div>
<script>
// Raw data: growth ratios (1.0 at each series' own start date).
// Raw data: growth ratios (1.0 at each series' own start date), x as ISO dates.
const SERIES = {series_json};
const X0 = SERIES[0].x[0];
const X1 = SERIES.map(s => s.x[s.x.length - 1]).reduce((a, b) => (a > b ? a : b));
const INIT0 = "{init0}", INIT1 = "{init1}";
const ms = v => +new Date(v); // ISO/Date -> epoch ms
const iso = v => new Date(v).toISOString().slice(0, 10);
function lowerBound(a, v) {{ let lo = 0, hi = a.length;
while (lo < hi) {{ const m = (lo + hi) >> 1; if (a[m] < v) lo = m + 1; else hi = m; }}
return lo; }}
function upperBound(a, v) {{ let lo = 0, hi = a.length;
while (lo < hi) {{ const m = (lo + hi) >> 1; if (a[m] <= v) lo = m + 1; else hi = m; }}
return lo; }}
// Re-base every series so its leftmost visible point == 1.0, and compute
// the tight y-range (log10 units) of the visible, re-based data.
function applyView(t0, t1) {{
const norms = SERIES.map(s => {{
const bi = Math.min(lowerBound(s.x, t0), s.x.length - 1);
const base = (s.y[bi] > 0) ? s.y[bi] : 1;
return s.y.map(v => v / base);
}});
let lo = Infinity, hi = -Infinity;
SERIES.forEach((s, k) => {{
const n = norms[k];
for (let i = lowerBound(s.x, t0), j = upperBound(s.x, t1); i < j; i++) {{
if (n[i] < lo) lo = n[i];
if (n[i] > hi) hi = n[i];
}}
}});
if (!isFinite(lo)) {{ lo = 0.99; hi = 1.01; }}
let a = Math.log10(lo), b = Math.log10(hi);
if (b - a < 0.0005) {{ a -= 0.0003; b += 0.0003; }}
return {{ norms, range: [a, b] }};
}}
// First/last VISIBLE data point (ms) across all series within [t0, t1].
function dataBounds(t0, t1) {{
let first = Infinity, last = -Infinity;
for (const s of SERIES) {{
const i = lowerBound(s.x, t0);
if (i < s.x.length) first = Math.min(first, ms(s.x[i]));
const j = upperBound(s.x, t1);
if (j > 0) last = Math.max(last, ms(s.x[j - 1]));
}}
return [first, last];
}}
// X ticks: leftmost/rightmost ALWAYS labeled to the day (YYYY-MM-DD);
// middle ticks get YYYY-MM (or MM-DD for short windows).
function xticks(t0, t1) {{
const m0 = ms(t0), m1 = ms(t1);
const n = 6, vals = [], text = [];
const pad = v => String(v).padStart(2, '0');
const span = m1 - m0;
for (let i = 0; i < n; i++) {{
const d = new Date(m0 + span * i / (n - 1));
vals.push(iso(d));
if (i === 0 || i === n - 1) {{
text.push(d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate()));
}} else if (span > 62 * 86400000) {{
text.push(d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1));
}} else {{
text.push(pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate()));
}}
}}
return {{ vals, text }};
}}
const traces = SERIES.map(s => ({{
x: s.x, y: s.y, type: 'scatter', mode: 'lines', name: s.name, line: s.style
}}));
const gd = document.getElementById('c');
let updating = false;
let lastView = null;
let lastViewMs = null; // [t0, t1] of last rendered view (ms)
function render(t0, t1) {{
// snap the view edges onto the first/last actual data points, so the
// leftmost/rightmost data point sits exactly on the axis edge
const [d0, d1] = dataBounds(t0, t1);
if (isFinite(d0) && isFinite(d1)) {{ t0 = iso(d0); t1 = iso(d1); }}
const {{ norms, range }} = applyView(t0, t1);
const tx = xticks(t0, t1);
lastView = [t0, t1, range[0], range[1]];
lastViewMs = [ms(t0), ms(t1)];
Plotly.restyle(gd, {{ y: norms }}).then(() =>
Plotly.relayout(gd, {{ 'xaxis.range': [t0, t1], 'yaxis.range': range,
'xaxis.tickvals': tx.vals, 'xaxis.ticktext': tx.text }})
).then(() => {{ updating = false; }});
}}
function onRelayout(ev) {{
if (updating) return;
// box-zoom sends xaxis.range[0]/[1]; scroll-zoom/pan send xaxis.range
if (!Object.keys(ev).some(k => k.startsWith('xaxis.range'))) return;
updating = true;
const rA = ms(gd.layout.xaxis.range[0]);
const rB = ms(gd.layout.xaxis.range[1]);
const cA = lastViewMs ? lastViewMs[0] : ms(X0);
const cB = lastViewMs ? lastViewMs[1] : ms(X1);
const minSpan = 7 * 86400000; // 1 week at full zoom
const maxSpan = ms(X1) - ms(X0);
let a, b;
if (Math.abs((rB - rA) - (cB - cA)) < 1e-6 * Math.max(cB - cA, 1)) {{
// PAN: the window keeps its width and sticks to the data edges
// (clamping only one edge would shrink the window -> looked like zoom).
const W = Math.min(Math.max(rB - rA, minSpan), maxSpan);
a = rA; b = rA + W;
if (a < ms(X0)) {{ a = ms(X0); b = a + W; }}
if (b > ms(X1)) {{ b = ms(X1); a = b - W; }}
}} else {{
// ZOOM (scroll/box/reset): clamp the edges to the data span
a = Math.max(rA, ms(X0));
b = Math.min(rB, ms(X1));
if (b - a < minSpan) {{
if (b >= ms(X1)) {{ b = ms(X1); a = Math.max(ms(X0), ms(X1) - minSpan); }}
else if (a <= ms(X0)) {{ a = ms(X0); b = Math.min(ms(X1), ms(X0) + minSpan); }}
else {{ const c = (a + b) / 2; a = c - minSpan / 2; b = c + minSpan / 2; }}
}}
}}
const t0 = iso(a), t1 = iso(b);
// skip only if the *current* view already matches the clamped target
if (lastView && t0 === lastView[0] && t1 === lastView[1]
&& iso(gd.layout.xaxis.range[0]) === t0 && iso(gd.layout.xaxis.range[1]) === t1) {{
updating = false; return; // truly nothing to do
}}
render(t0, t1);
}}
Plotly.newPlot(gd, traces, {{
margin: {{ l: 60, r: 55, t: 10, b: 90 }}, // b: room for x labels + legend below
xaxis: {{ type: 'date', rangeslider: {{ visible: false }},
hoverformat: '%Y-%m-%d' }},
yaxis: {{ type: 'log', title: '{ytitle}' }},
legend: {{ orientation: 'h', y: -0.30 }}, // underneath the plot, not over it
dragmode: 'zoom',
hovermode: 'x unified'
}}, {{
responsive: true, scrollZoom: true, doubleClick: 'reset',
modeBarButtonsToRemove: ['autoScale2d', 'select2d', 'lasso2d', 'toImage']
}}).then(() => {{
gd.on('plotly_relayout', onRelayout);
updating = true;
render(INIT0, INIT1);
}});
</script></body></html>
"""
def equity_chart_html(series: dict[str, pd.Series], window: pd.DatetimeIndex,
height: int = 540, ytitle: str = "growth",
styles: dict[str, dict] | None = None) -> str:
"""Render growth-ratio series as a self-contained interactive HTML page.
series: {name: ratio Series} (each starts at 1.0 on its own first date).
window: initial visible DatetimeIndex (x is always clamped to this span).
"""
styles = styles or {}
payload = []
for name, s in series.items():
s = s.reindex(window).ffill().dropna()
if s.empty:
continue
payload.append({
"name": name,
"x": [t.date().isoformat() for t in s.index],
"y": [float(v) for v in s.to_numpy(dtype=float)],
"style": styles.get(name, {"width": 2}),
})
w = pd.DatetimeIndex(window)
return _TEMPLATE.format(
plotly=_plotly_js_tag(),
height=height,
series_json=json.dumps(payload),
init0=w[0].date().isoformat(),
init1=w[-1].date().isoformat(),
ytitle=ytitle,
)