Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .streamlit/config.toml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
3 changes: 2 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
169 changes: 158 additions & 11 deletions app_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<style>{_THEME_CSS}</style>", 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:
Expand All @@ -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


Expand Down Expand Up @@ -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'<span class="osl-pill osl-pill--{cls}">{badge.value}</span>'
f'<span class="osl-badge-meta">{provider_name} @ {ts}</span>',
unsafe_allow_html=True,
)


Expand Down Expand Up @@ -369,6 +515,7 @@ def build_playbook_data(


def page_header(title: str) -> None:
inject_theme()
st.title(title)
st.caption(DISCLAIMER)

Expand Down
72 changes: 62 additions & 10 deletions osl/viz/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,70 @@

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",
yref="paper",
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})
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions pages/11_Advanced_Models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
page_footer,
page_header,
rate_assumptions,
render_chart,
settings,
sidebar_controls,
)
Expand Down Expand Up @@ -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],
Expand All @@ -140,7 +141,6 @@
title="Dupire local volatility",
colorbar_title="local vol",
),
use_container_width=True,
)

with tab_pca:
Expand Down
3 changes: 2 additions & 1 deletion pages/1_Ticker_Overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
page_header,
rate_assumptions,
render_badge,
render_chart,
sidebar_controls,
)
from osl.surface.prepare import prepare_smiles
Expand Down Expand Up @@ -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.")

Expand Down
Loading
Loading