diff --git a/.streamlit/config.toml b/.streamlit/config.toml index 4aa29c4..1b934b5 100644 --- a/.streamlit/config.toml +++ b/.streamlit/config.toml @@ -1,6 +1,19 @@ [theme] -base = "light" -primaryColor = "#1f77b4" +# Dark "terminal" aesthetic: near-black canvas, muted gold accent, warm off-white +# text, teal as the secondary chart hue. Fonts/cards/pills are layered on via CSS +# injected in app_lib.inject_theme() (Streamlit theming alone can't express them). +base = "dark" +primaryColor = "#c9a24b" +backgroundColor = "#0b0c0e" +secondaryBackgroundColor = "#14161b" +textColor = "#e7e3d8" +borderColor = "#2a2d34" +linkColor = "#c9a24b" +baseRadius = "0.25rem" +# Accent palette for built-in charts (st.line_chart / st.bar_chart) and Plotly +# rendered with the Streamlit theme. +chartCategoricalColors = ["#c9a24b", "#3fb6a8", "#c97b4b", "#7a8aa0", "#9d6bb0", "#b04b4b"] +chartSequentialColors = ["#10221f", "#163a34", "#1d5249", "#2a7064", "#3fb6a8", "#86d8cd"] [server] # Streamlit Community Cloud manages the server; these are safe local defaults. diff --git a/app.py b/app.py index 83c4123..2c2cc1e 100644 --- a/app.py +++ b/app.py @@ -8,9 +8,10 @@ import streamlit as st -from app_lib import DISCLAIMER, require_login +from app_lib import DISCLAIMER, inject_theme, require_login st.set_page_config(page_title="Options Strategy Lab", layout="wide") +inject_theme() require_login() st.title("Options Strategy Lab") diff --git a/app_lib.py b/app_lib.py index 43a1951..dc61c31 100644 --- a/app_lib.py +++ b/app_lib.py @@ -37,7 +37,148 @@ from osl.volatility.skew import delta_skew_25 DISCLAIMER = "Research and education only — not investment advice." -BADGE_COLOR = {Freshness.GREEN: "green", Freshness.AMBER: "orange", Freshness.RED: "red"} +BADGE_PILL = {Freshness.GREEN: "green", Freshness.AMBER: "amber", Freshness.RED: "red"} + +# Dark "terminal" aesthetic. The Streamlit [theme] in .streamlit/config.toml sets +# the palette; this stylesheet layers on the things native theming can't express: +# the serif/mono font pairing, card tiles, uppercase letter-spaced labels, +# gold-outline buttons, styled tabs, a dark sidebar, and the freshness pills. +_THEME_CSS = """ +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Spectral:wght@400;500;600&display=swap'); + +:root { + --osl-gold: #c9a24b; + --osl-teal: #3fb6a8; + --osl-red: #c0564b; + --osl-ink: #0b0c0e; + --osl-card: #14161b; + --osl-line: #2a2d34; + --osl-muted:#8b8e97; + --osl-text: #e7e3d8; +} + +.stApp { background: var(--osl-ink); } +[data-testid="stHeader"] { background: transparent; } +.block-container { padding-top: 2.5rem; padding-bottom: 3rem; max-width: 1280px; } + +h1, h2, h3, h4, +[data-testid="stHeading"] h1, +[data-testid="stHeading"] h2, +[data-testid="stHeading"] h3 { + font-family: 'Spectral', Georgia, 'Times New Roman', serif !important; + font-weight: 500; + letter-spacing: 0.005em; + color: var(--osl-text); +} +h1 { font-size: 2.1rem; } + +[data-testid="stCaptionContainer"], .stCaption, small { + font-family: 'IBM Plex Mono', ui-monospace, monospace !important; + color: var(--osl-muted) !important; + letter-spacing: 0.02em; +} + +[data-testid="stWidgetLabel"] label, +[data-testid="stMetricLabel"] { + font-family: 'IBM Plex Mono', ui-monospace, monospace !important; + text-transform: uppercase; + letter-spacing: 0.09em; + font-size: 0.72rem !important; + color: var(--osl-muted) !important; +} + +[data-testid="stMetric"] { + background: var(--osl-card); + border: 1px solid var(--osl-line); + border-radius: 6px; + padding: 1rem 1.1rem; +} +[data-testid="stMetricValue"] { + font-family: 'IBM Plex Mono', ui-monospace, monospace !important; + color: var(--osl-gold); + font-weight: 600; +} + +[data-baseweb="tab-list"] { border-bottom: 1px solid var(--osl-line); gap: 1.5rem; } +[data-baseweb="tab"] { + font-family: 'IBM Plex Mono', ui-monospace, monospace !important; + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.74rem; + color: var(--osl-muted); + background: transparent; +} +[data-baseweb="tab"][aria-selected="true"] { color: var(--osl-gold); } +[data-baseweb="tab-highlight"] { background-color: var(--osl-gold) !important; } + +.stButton > button, .stDownloadButton > button, .stFormSubmitButton > button { + background: transparent; + border: 1px solid var(--osl-gold); + color: var(--osl-gold); + border-radius: 4px; + font-family: 'IBM Plex Mono', ui-monospace, monospace; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.78rem; + font-weight: 500; +} +.stButton > button:hover, .stDownloadButton > button:hover, .stFormSubmitButton > button:hover { + background: var(--osl-gold); + color: var(--osl-ink); + border-color: var(--osl-gold); +} + +[data-testid="stSidebar"] { + background: #0e1014; + border-right: 1px solid var(--osl-line); +} + +[data-testid="stExpander"], +[data-testid="stVerticalBlockBorderWrapper"] { + border: 1px solid var(--osl-line) !important; + border-radius: 6px; + background: rgba(255,255,255,0.012); +} + +hr { border-color: var(--osl-line); } + +.osl-pill { + display: inline-block; + font-family: 'IBM Plex Mono', ui-monospace, monospace; + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.7rem; + font-weight: 600; + padding: 0.12rem 0.55rem; + border-radius: 3px; + border: 1px solid currentColor; +} +.osl-pill--green { color: var(--osl-teal); } +.osl-pill--amber { color: var(--osl-gold); } +.osl-pill--red { color: var(--osl-red); } +.osl-badge-meta { + font-family: 'IBM Plex Mono', ui-monospace, monospace; + color: var(--osl-muted); + font-size: 0.78rem; + margin-left: 0.4rem; +} +""" + + +def inject_theme() -> None: + """Inject the dark/gold stylesheet. Call once per page run (idempotent per run).""" + st.markdown(f"", unsafe_allow_html=True) + + +def render_chart(fig: Any, **kwargs: Any) -> None: + """Render a Plotly figure using its own dark template instead of Streamlit's. + + Passing ``theme=None`` lets the template defined in ``osl.viz.charts`` (dark + background, gold/teal palette) drive the look; Streamlit's default + ``theme="streamlit"`` would otherwise override it. + """ + kwargs.setdefault("use_container_width", True) + st.plotly_chart(fig, theme=None, **kwargs) def _load_streamlit_secrets_into_env() -> None: @@ -64,16 +205,19 @@ def settings() -> Settings: def sidebar_controls(default_symbol: str = "SPY") -> tuple[str, str]: - """Render the shared sidebar; return (symbol, provider_name).""" + """Render the shared sidebar; return (symbol, provider_name). + + The symbol and provider are stored in ``st.session_state`` under stable + keys, so a ticker set on one page carries across the whole app for the + session. Seed defaults only on first use (don't clobber the user's choice). + """ cfg = get_settings() + st.session_state.setdefault("symbol", default_symbol) + st.session_state.setdefault("provider", cfg.default_provider) with st.sidebar: st.header("Data") - symbol = st.text_input("Symbol", value=default_symbol).strip().upper() - provider = st.radio( - "Provider", - options=["schwab", "yfinance"], - index=0 if cfg.default_provider == "schwab" else 1, - ) + symbol = st.text_input("Symbol", key="symbol").strip().upper() + provider = st.radio("Provider", options=["schwab", "yfinance"], key="provider") return symbol, provider @@ -115,10 +259,12 @@ def load_history(provider_name: str, symbol: str, lookback_days: int = 400) -> p def render_badge(provider_name: str, *, is_delayed: bool, quote_time: pd.Timestamp) -> None: badge = freshness_badge(provider_name, is_delayed=is_delayed, quote_time=quote_time) - color = BADGE_COLOR[badge] + cls = BADGE_PILL[badge] + ts = f"{pd.Timestamp(quote_time):%Y-%m-%d %H:%M:%S %Z}" st.markdown( - f":{color}[**{badge.value}**] — {provider_name} @ " - f"{pd.Timestamp(quote_time):%Y-%m-%d %H:%M:%S %Z}" + f'{badge.value}' + f'{provider_name} @ {ts}', + unsafe_allow_html=True, ) @@ -369,6 +515,7 @@ def build_playbook_data( def page_header(title: str) -> None: + inject_theme() st.title(title) st.caption(DISCLAIMER) diff --git a/osl/viz/charts.py b/osl/viz/charts.py index d3aa554..8984a6d 100644 --- a/osl/viz/charts.py +++ b/osl/viz/charts.py @@ -20,8 +20,62 @@ DISCLAIMER = "Research and education only — not investment advice." +# Dark "terminal" palette, kept in sync with .streamlit/config.toml and +# app_lib._THEME_CSS. Charts are rendered with ``theme=None`` (see +# app_lib.render_chart) so this template — not Streamlit's — drives their look. +_INK = "#0b0c0e" +_LINE = "#2a2d34" +_TEXT = "#e7e3d8" +_MUTED = "#8b8e97" +_GOLD = "#c9a24b" +_TEAL = "#3fb6a8" +_RED = "#c0564b" +_GRID = "rgba(255,255,255,0.06)" + +_SERIF = "Spectral, Georgia, 'Times New Roman', serif" +_MONO = "'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace" + +_COLORWAY = ["#c9a24b", "#3fb6a8", "#c97b4b", "#7a8aa0", "#9d6bb0", "#b04b4b"] +_SEQ = [ + [0.0, "#10221f"], + [0.2, "#163a34"], + [0.4, "#1d5249"], + [0.6, "#2a7064"], + [0.8, "#3fb6a8"], + [1.0, "#86d8cd"], +] + +_AXIS = {"gridcolor": _GRID, "zerolinecolor": _LINE, "linecolor": _LINE, "color": _MUTED} +_SCENE_AXIS = {"gridcolor": _GRID, "backgroundcolor": "rgba(0,0,0,0)", "color": _MUTED} + +_DARK = go.layout.Template( + layout=go.Layout( + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + colorway=_COLORWAY, + font={"family": _MONO, "color": _TEXT, "size": 12}, + title={"font": {"family": _SERIF, "color": _TEXT, "size": 18}}, + xaxis=_AXIS, + yaxis=_AXIS, + legend={"font": {"color": _TEXT}, "bgcolor": "rgba(0,0,0,0)"}, + hoverlabel={"font": {"family": _MONO}, "bgcolor": _INK, "bordercolor": _LINE}, + scene={"xaxis": _SCENE_AXIS, "yaxis": _SCENE_AXIS, "zaxis": _SCENE_AXIS}, + polar={ + "bgcolor": "rgba(0,0,0,0)", + "radialaxis": {"gridcolor": _GRID, "color": _MUTED}, + "angularaxis": {"gridcolor": _GRID, "color": _MUTED}, + }, + colorscale={"sequential": _SEQ}, + ), + data={ + "heatmap": [go.Heatmap(colorscale=_SEQ)], + "surface": [go.Surface(colorscale=_SEQ)], + }, +) + def _footer(fig: go.Figure, note: str = DISCLAIMER) -> go.Figure: + fig.update_layout(template=_DARK) fig.add_annotation( text=note, xref="paper", @@ -29,7 +83,7 @@ def _footer(fig: go.Figure, note: str = DISCLAIMER) -> go.Figure: x=0.0, y=-0.18, showarrow=False, - font={"size": 10, "color": "gray"}, + font={"size": 10, "color": _MUTED}, align="left", ) fig.update_layout(margin={"b": 80}) @@ -125,7 +179,7 @@ def vol_cone_chart(cone: pd.DataFrame, *, current: pd.Series | None = None) -> g y=cone["current"], mode="markers", name="current RV", - marker={"size": 9, "color": "black"}, + marker={"size": 9, "color": _GOLD}, ) ) if current is not None: @@ -158,7 +212,7 @@ def drawdown_chart(equity: pd.Series, *, title: str = "Drawdown") -> go.Figure: peak = np.maximum.accumulate(eq) if eq.size else eq dd = (eq - peak) / peak if eq.size else eq fig = go.Figure( - go.Scatter(x=equity.index, y=dd, mode="lines", fill="tozeroy", line={"color": "red"}) + go.Scatter(x=equity.index, y=dd, mode="lines", fill="tozeroy", line={"color": _RED}) ) fig.update_layout(title=title, xaxis_title="Date", yaxis_title="Drawdown") return _footer(fig) @@ -177,15 +231,13 @@ def payoff_chart( pnl_arr = np.asarray(pnl, dtype=float) profit = np.where(pnl_arr >= 0, pnl_arr, np.nan) loss = np.where(pnl_arr < 0, pnl_arr, np.nan) - fig.add_trace( - go.Scatter(x=spots, y=profit, mode="lines", line={"color": "green"}, name="Profit") - ) - fig.add_trace(go.Scatter(x=spots, y=loss, mode="lines", line={"color": "red"}, name="Loss")) - fig.add_hline(y=0, line={"color": "gray", "dash": "dot"}) + fig.add_trace(go.Scatter(x=spots, y=profit, mode="lines", line={"color": _TEAL}, name="Profit")) + fig.add_trace(go.Scatter(x=spots, y=loss, mode="lines", line={"color": _RED}, name="Loss")) + fig.add_hline(y=0, line={"color": _MUTED, "dash": "dot"}) for be in breakevens: - fig.add_vline(x=be, line={"color": "blue", "dash": "dash"}) + fig.add_vline(x=be, line={"color": _GOLD, "dash": "dash"}) if current_spot is not None: - fig.add_vline(x=current_spot, line={"color": "black"}, annotation_text="spot") + fig.add_vline(x=current_spot, line={"color": _TEXT}, annotation_text="spot") fig.update_layout(title=title, xaxis_title="Terminal spot", yaxis_title="P&L ($)") return _footer(fig) diff --git a/pages/11_Advanced_Models.py b/pages/11_Advanced_Models.py index 750c9c8..e73f13b 100644 --- a/pages/11_Advanced_Models.py +++ b/pages/11_Advanced_Models.py @@ -16,6 +16,7 @@ page_footer, page_header, rate_assumptions, + render_chart, settings, sidebar_controls, ) @@ -130,7 +131,7 @@ lv = dupire_local_vol( expiries, grid_strikes.tolist(), iv_grid, spot=spot, rate=rate, dividend_yield=div ) - st.plotly_chart( + render_chart( heatmap_chart( lv, x=[round(t, 3) for t in expiries], @@ -140,7 +141,6 @@ title="Dupire local volatility", colorbar_title="local vol", ), - use_container_width=True, ) with tab_pca: diff --git a/pages/1_Ticker_Overview.py b/pages/1_Ticker_Overview.py index 0e8a833..005045e 100644 --- a/pages/1_Ticker_Overview.py +++ b/pages/1_Ticker_Overview.py @@ -14,6 +14,7 @@ page_header, rate_assumptions, render_badge, + render_chart, sidebar_controls, ) from osl.surface.prepare import prepare_smiles @@ -59,7 +60,7 @@ ) if not history.empty: - st.plotly_chart(price_history_chart(history, title=f"{symbol} close"), use_container_width=True) + render_chart(price_history_chart(history, title=f"{symbol} close")) else: st.warning("No price history returned.") diff --git a/pages/3_IV_Surface.py b/pages/3_IV_Surface.py index 853ffb0..f8f5e2c 100644 --- a/pages/3_IV_Surface.py +++ b/pages/3_IV_Surface.py @@ -11,6 +11,7 @@ page_footer, page_header, rate_assumptions, + render_chart, sidebar_controls, ) from osl.surface.prepare import prepare_smiles @@ -68,11 +69,11 @@ view = st.radio("View", ["Skew (2D)", "Surface (3D)", "Heatmap"], horizontal=True) if view == "Skew (2D)": - st.plotly_chart(skew_chart(smiles, fits), use_container_width=True) + render_chart(skew_chart(smiles, fits)) elif view == "Surface (3D)": - st.plotly_chart(iv_surface_3d(smiles), use_container_width=True) + render_chart(iv_surface_3d(smiles)) else: - st.plotly_chart(iv_heatmap(smiles), use_container_width=True) + render_chart(iv_heatmap(smiles)) st.subheader("SVI parameters per expiry") params_df = pd.DataFrame(rows) diff --git a/pages/4_Vol_Diagnostics.py b/pages/4_Vol_Diagnostics.py index 5e3b9fd..a398dcf 100644 --- a/pages/4_Vol_Diagnostics.py +++ b/pages/4_Vol_Diagnostics.py @@ -14,6 +14,7 @@ page_footer, page_header, rate_assumptions, + render_chart, sidebar_controls, ) from osl.surface.prepare import prepare_smiles @@ -53,7 +54,7 @@ fits[str(s.expiration)] = fit_svi( s.k, s.total_variance, weights=s.weights, T=s.T ).params - st.plotly_chart(skew_chart(smiles, fits), use_container_width=True) + render_chart(skew_chart(smiles, fits)) else: st.warning("No smiles available.") @@ -63,7 +64,7 @@ for s in smiles: tenors.append(s.T) atm.append(float(s.iv[int(np.argmin(np.abs(s.k)))])) - st.plotly_chart(term_structure_chart(tenors, atm), use_container_width=True) + render_chart(term_structure_chart(tenors, atm)) shape = "contango" if len(atm) > 1 and atm[-1] > atm[0] else "backwardation" st.caption(f"Term-structure shape: {shape} (RN, model-implied ATM IV).") else: @@ -72,7 +73,7 @@ with tab_cone: if not history.empty: cone = vol_cone(history["close"]) - st.plotly_chart(vol_cone_chart(cone), use_container_width=True) + render_chart(vol_cone_chart(cone)) st.dataframe(cone, use_container_width=True) else: st.warning("No price history for the cone.") diff --git a/pages/5_Strategy_Generator.py b/pages/5_Strategy_Generator.py index 0a3ce2b..7136bcc 100644 --- a/pages/5_Strategy_Generator.py +++ b/pages/5_Strategy_Generator.py @@ -4,7 +4,14 @@ import streamlit as st -from app_lib import build_candidates, candidates_table, page_footer, page_header, sidebar_controls +from app_lib import ( + build_candidates, + candidates_table, + page_footer, + page_header, + render_chart, + sidebar_controls, +) from osl.strategy.optimizer import rank from osl.viz.charts import radar_chart @@ -68,11 +75,10 @@ best.liquidity.score, ] st.subheader(f"Profile — {best.strategy.name}") -st.plotly_chart( +render_chart( radar_chart( ["POP", "EV/risk", "Theta", "Vega", "Convexity", "Liquidity"], norm, name=best.strategy.name ), - use_container_width=True, ) with st.expander("Model assumptions"): diff --git a/pages/6_Strategy_Optimizer.py b/pages/6_Strategy_Optimizer.py index 5289e1b..4b37bd6 100644 --- a/pages/6_Strategy_Optimizer.py +++ b/pages/6_Strategy_Optimizer.py @@ -6,7 +6,14 @@ import streamlit as st -from app_lib import build_candidates, candidates_table, page_footer, page_header, sidebar_controls +from app_lib import ( + build_candidates, + candidates_table, + page_footer, + page_header, + render_chart, + sidebar_controls, +) from osl.strategy.optimizer import OBJECTIVES, rank from osl.viz.charts import scatter_chart @@ -59,7 +66,7 @@ col1, col2 = st.columns(2) with col1: - st.plotly_chart( + render_chart( scatter_chart( risk, ev, @@ -68,10 +75,9 @@ y_title="EV ($)", title="EV frontier", ), - use_container_width=True, ) with col2: - st.plotly_chart( + render_chart( scatter_chart( risk, pop, @@ -80,7 +86,6 @@ y_title="POP (RN)", title="POP vs max loss", ), - use_container_width=True, ) st.caption( diff --git a/pages/7_Payoff_and_Scenario.py b/pages/7_Payoff_and_Scenario.py index 1619fd3..6dc8505 100644 --- a/pages/7_Payoff_and_Scenario.py +++ b/pages/7_Payoff_and_Scenario.py @@ -6,7 +6,7 @@ import pandas as pd import streamlit as st -from app_lib import build_candidates, page_footer, page_header, sidebar_controls +from app_lib import build_candidates, page_footer, page_header, render_chart, sidebar_controls from osl.scenario.payoff import payoff_curve from osl.scenario.pnl_surface import pnl_grid from osl.scenario.stress import stress_scenarios @@ -69,14 +69,14 @@ def _label(i: int) -> str: x=spots, y=pnl_now, mode="lines", - line={"dash": "dash", "color": "purple"}, + line={"dash": "dash", "color": "#9d6bb0"}, name=f"+{shock_days}d, {vol_shock_pts:+d} vol pts", ) -st.plotly_chart(fig, use_container_width=True) +render_chart(fig) st.subheader("P&L surface (spot × time)") sg, dg, z = pnl_grid(strategy, spot_shocks=np.linspace(-0.3, 0.3, 31), vol_shock=vol_shock) -st.plotly_chart(pnl_surface_chart(sg, dg, z), use_container_width=True) +render_chart(pnl_surface_chart(sg, dg, z)) st.subheader("Stress scenarios") iv_crush = st.slider("Earnings IV crush (vol points)", -40, 0, -20) / 100.0 diff --git a/pages/8_Probability_Lab.py b/pages/8_Probability_Lab.py index f9ee126..9b0827c 100644 --- a/pages/8_Probability_Lab.py +++ b/pages/8_Probability_Lab.py @@ -13,6 +13,7 @@ page_footer, page_header, rate_assumptions, + render_chart, settings, sidebar_controls, ) @@ -130,9 +131,8 @@ def _label(i: int) -> str: k_max=1.5, n=800, ) - st.plotly_chart( + render_chart( rnd_chart(strikes, normalize(strikes, dens), lognormal=normalize(ln_strikes, ln_dens)), - use_container_width=True, ) else: st.info("Not enough liquid strikes to build a market-implied RND for this expiry.") diff --git a/pages/9_Backtester.py b/pages/9_Backtester.py index a2e0ebc..09fa2f0 100644 --- a/pages/9_Backtester.py +++ b/pages/9_Backtester.py @@ -9,6 +9,7 @@ BACKTEST_SYSTEMS, page_footer, page_header, + render_chart, run_system_backtest, sidebar_controls, ) @@ -95,8 +96,8 @@ st.caption("note: DSR ≤ PSR by construction when trials > 1.") st.caption(f"Win rate {s['win_rate']:.0%} · profit factor {s['profit_factor']:.2f}") -st.plotly_chart(equity_curve_chart(result.equity), use_container_width=True) -st.plotly_chart(drawdown_chart(result.equity), use_container_width=True) +render_chart(equity_curve_chart(result.equity)) +render_chart(drawdown_chart(result.equity)) st.subheader("Trades") if result.trades: