"""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"" return f'' _TEMPLATE = """ {plotly}
""" 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, )