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: