- 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
236 lines
9.1 KiB
Python
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,
|
|
)
|