diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 0000000..9e48a64 --- /dev/null +++ b/.Jules/palette.md @@ -0,0 +1,3 @@ +## 2024-05-24 - Add Explanatory Tooltips to Financial Parameter Inputs +**Learning:** Statistical and financial parameter inputs (e.g., basis points, quantiles) without concrete examples cause significant cognitive load for users and hurt accessibility. +**Action:** Always include a `help` tooltip with concrete examples (e.g., "10 bps = 0.10%") when adding complex parameter inputs to Streamlit interfaces. diff --git a/src/dashboard.py b/src/dashboard.py index 4156c1d..e3b6b5c 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -160,24 +160,53 @@ def get_cache_key(*args) -> str: st.subheader("3. Signal Parameters") if mode == "Single-Asset": sma_window = st.slider( - "Trend SMA Window", 10, 200, DEFAULT_SMA_WINDOW, 10, - help="Lookback days for Simple Moving Average trend signal." + "Trend SMA Window", + 10, + 200, + DEFAULT_SMA_WINDOW, + 10, + help="Lookback days for Simple Moving Average trend signal.", ) mom_window = st.slider( - "Momentum Lookback (Months)", 1, 24, DEFAULT_MOMENTUM_WINDOW, 1, - help="Lookback months for Momentum signal." + "Momentum Lookback (Months)", + 1, + 24, + DEFAULT_MOMENTUM_WINDOW, + 1, + help="Lookback months for Momentum signal.", ) else: - factor_window = st.slider("Factor Beta Window (days)", 20, 252, 63, 7) - vol_window = st.slider("Regime Vol Window (days)", 10, 60, 21, 5) - adv_pct = st.slider("ADV Participation %", 0.01, 0.30, float(DEFAULT_ADV_PCT), 0.01) + factor_window = st.slider( + "Factor Beta Window (days)", + 20, + 252, + 63, + 7, + help="Lookback days for Rolling OLS factor betas.", + ) + vol_window = st.slider( + "Regime Vol Window (days)", + 10, + 60, + 21, + 5, + help="Lookback days for realized volatility calculation.", + ) + adv_pct = st.slider( + "ADV Participation %", + 0.01, + 0.30, + float(DEFAULT_ADV_PCT), + 0.01, + help="Maximum participation rate of Average Daily Volume (e.g., 0.05 = 5%).", + ) st.markdown("---") st.subheader("4. Research Rigor") use_oos = st.toggle( "Out-of-Sample Mode", value=False, - help="Uses expanding-window quantiles for regime classification to avoid look-ahead bias. Enable for rigorous backtesting." + help="Uses expanding-window quantiles for regime classification to avoid look-ahead bias. Enable for rigorous backtesting.", ) if use_oos: st.success("✓ Look-ahead bias removed") @@ -650,9 +679,9 @@ def get_cache_key(*args) -> str: # --- Regime Detection --- # Using 21-day annualized vol with option for out-of-sample analysis df = signals.detect_volatility_regime( - df, - vol_col='Vol_21d', - quantile_high=vol_q_high, + df, + vol_col='Vol_21d', + quantile_high=vol_q_high, quantile_low=0.25, use_expanding=use_oos # Toggle between in-sample and out-of-sample ) @@ -729,7 +758,7 @@ def get_cache_key(*args) -> str: fig = go.Figure() fig.add_trace(go.Scatter(x=df.index, y=df['Close'], name='Close Price', line=dict(color='white', width=1))) fig.add_trace(go.Scatter(x=df.index, y=df[f'SMA_{sma_window}'], name=f'{sma_window}-Day SMA', line=dict(color='#ff9f43', width=1))) - + # Highlight High Volatility Regimes # Filter high vol periods high_vol_mask = df['Vol_Regime'] == 'High' @@ -737,7 +766,7 @@ def get_cache_key(*args) -> str: # Let's plot points high_vol_pts = df[high_vol_mask] fig.add_trace(go.Scatter(x=high_vol_pts.index, y=high_vol_pts['Close'], mode='markers', name='High Volatility', marker=dict(color='red', size=2))) - + fig.update_layout( title=f"{ticker} Price History & Regime Context", yaxis_title="Price ($)", @@ -751,21 +780,21 @@ def get_cache_key(*args) -> str: # --- TAB 2: REGIME ANALYSIS --- with tab_regime: st.subheader("Volatility Regime Classification") - + c1, c2 = st.columns(2) with c1: # Scatter: Vol vs Returns needed? Maybe just distribution fig_hist = px.histogram(df, x="Vol_21d", color="Vol_Regime", nbins=50, title="Volatility Distribution", template="plotly_dark", color_discrete_map={"High": "#ff4b4b", "Low": "#00ff00", "Normal": "#888888"}) st.plotly_chart(fig_hist, use_container_width=True) - + with c2: # Pie chart of time spent in regimes regime_counts = df['Vol_Regime'].value_counts() fig_pie = px.pie(values=regime_counts, names=regime_counts.index, title="Time Spent in Regimes", template="plotly_dark", color=regime_counts.index, color_discrete_map={"High": "#ff4b4b", "Low": "#00ff00", "Normal": "#888888"}) st.plotly_chart(fig_pie, use_container_width=True) - + st.markdown("### Regime Characteristics") stats = df.groupby('Vol_Regime')[['Daily_Return', 'Vol_21d']].mean() # Annualize return @@ -828,57 +857,57 @@ def get_cache_key(*args) -> str: # --- TAB 3: BACKTEST --- with tab_bt: st.subheader("Strategy Simulation") - + # Out-of-sample mode indicator if use_oos: st.success("🔬 **Out-of-Sample Mode Active** - Regime classification uses only past data at each point") if not res_df.empty: - + # 1. Global Metrics with Bootstrap CI strat_metrics = backtester.calculate_perf_metrics( - res_df['Equity_Strategy'], + res_df['Equity_Strategy'], include_bootstrap_ci=True, n_bootstrap=500 ) bench_metrics = backtester.calculate_perf_metrics(res_df['Equity_Benchmark']) - + col_m1, col_m2, col_m3, col_m4 = st.columns(4) col_m1.metric("Global CAGR", f"{strat_metrics['CAGR']:.2%}") - + # Show Sharpe with CI if available sharpe_display = f"{strat_metrics['Sharpe']:.2f}" if strat_metrics.get('Sharpe_CI_Lower') is not None: sharpe_display += f" [{strat_metrics['Sharpe_CI_Lower']:.2f}, {strat_metrics['Sharpe_CI_Upper']:.2f}]" col_m2.metric("Sharpe (95% CI)", sharpe_display) - + col_m3.metric("Max Drawdown", f"{strat_metrics['MaxDD']:.2%}") col_m4.metric("Max DD Duration", f"{strat_metrics.get('MaxDD_Duration', 0)} days") - + # Additional metrics row col_a1, col_a2, col_a3, col_a4 = st.columns(4) col_a1.metric("Sortino", f"{strat_metrics.get('Sortino', 0):.2f}") col_a2.metric("Calmar", f"{strat_metrics.get('Calmar', 0):.2f}") col_a3.metric("Win Rate", f"{strat_metrics.get('WinRate', 0):.1%}") col_a4.metric("Avg DD Duration", f"{strat_metrics.get('AvgDD_Duration', 0):.0f} days") - + # 2. Equity Curve fig_eq = go.Figure() fig_eq.add_trace(go.Scatter(x=res_df.index, y=res_df['Equity_Strategy'], name='Trend Strategy', line=dict(color='#00ff00'))) fig_eq.add_trace(go.Scatter(x=res_df.index, y=res_df['Equity_Benchmark'], name='Buy & Hold', line=dict(color='gray', dash='dot'))) fig_eq.update_layout(title="Equity Curve", template="plotly_dark", height=400) st.plotly_chart(fig_eq, use_container_width=True) - + # 3. Drawdown Chart with st.expander("📉 Drawdown Analysis", expanded=False): fig_dd = go.Figure() fig_dd.add_trace(go.Scatter( - x=res_df.index, y=res_df['DD_Strategy'] * 100, + x=res_df.index, y=res_df['DD_Strategy'] * 100, name='Strategy Drawdown', fill='tozeroy', line=dict(color='#ff4b4b') )) fig_dd.add_trace(go.Scatter( - x=res_df.index, y=res_df['DD_Benchmark'] * 100, + x=res_df.index, y=res_df['DD_Benchmark'] * 100, name='Benchmark Drawdown', line=dict(color='gray', dash='dot') )) @@ -889,36 +918,36 @@ def get_cache_key(*args) -> str: height=300 ) st.plotly_chart(fig_dd, use_container_width=True) - + # 4. Conditional Analysis st.markdown("### 🔬 Conditional Performance by Regime") st.info("Does the strategy outperform during High Volatility?") - + # Merge comparison = pd.concat([cond_stats.add_suffix('_Strat'), bench_cond.add_suffix('_Bench')], axis=1) - + # Reorder columns - handle missing columns gracefully available_cols = [] for col in ['Ann_Return_Strat', 'Ann_Return_Bench', 'Sharpe_Strat', 'Sharpe_Bench', 'WinRate_Strat']: if col in comparison.columns: available_cols.append(col) comparison = comparison[available_cols] - + st.dataframe(comparison.style.background_gradient(cmap='RdYlGn', subset=['Ann_Return_Strat', 'Sharpe_Strat']).format("{:.2f}")) - + st.markdown("**Key Insight:** Compare 'Sharpe_Strat' vs 'Sharpe_Bench' in the **High** volatility row.") - + # 5. Walk-Forward Validation (Advanced) with st.expander("🚀 Walk-Forward Validation (Advanced)", expanded=False): st.markdown(""" Walk-forward validation splits data into rolling train/test windows to evaluate out-of-sample performance. This is more rigorous than a single full-sample backtest. """) - + wf_col1, wf_col2 = st.columns(2) wf_train = wf_col1.number_input("Training Window (months)", value=24, min_value=6, max_value=60) wf_test = wf_col2.number_input("Test Window (months)", value=6, min_value=1, max_value=12) - + if st.button("Run Walk-Forward Analysis"): with st.spinner("Running walk-forward validation..."): wf_results = backtester.walk_forward_backtest( @@ -928,16 +957,16 @@ def get_cache_key(*args) -> str: cost_bps=bt_cost, rebalance_freq='M' ) - + if wf_results: st.success(f"✅ Completed {wf_results['n_periods']} walk-forward periods") - + wf_summary = wf_results['summary'] wf_c1, wf_c2, wf_c3 = st.columns(3) wf_c1.metric("OOS CAGR", f"{wf_summary.get('CAGR', 0):.2%}") wf_c2.metric("OOS Sharpe", f"{wf_summary.get('Sharpe', 0):.2f}") wf_c3.metric("OOS Max DD", f"{wf_summary.get('MaxDD', 0):.2%}") - + # Show per-period results st.markdown("#### Per-Period Results") period_data = [] @@ -959,11 +988,11 @@ def get_cache_key(*args) -> str: # --- TAB 4: REPORT --- with tab_rep: st.subheader("Research Note Generation") - + st.markdown("### Findings Summary") st.write(f"**Asset**: {ticker}") st.write(f"**Trend Model**: {sma_window}-Day SMA") - + if not res_df.empty: # Create text summary high_vol_perf = cond_stats.loc['High', 'Sharpe'] if 'High' in cond_stats.index else 0 @@ -978,13 +1007,13 @@ def get_cache_key(*args) -> str: sweep_std = sweep_df.groupby("Regime")["Sharpe"].std().dropna() if not sweep_std.empty: sweep_stability = ", ".join([f"{k}: {v:.2f}" for k, v in sweep_std.items()]) - + st.success(f"Strategy Sharpe in High Vol: **{high_vol_perf:.2f}**") st.info(f"Strategy Sharpe in Normal Vol: **{normal_vol_perf:.2f}**") st.write(f"**Regime Sensitivity (Sharpe High - Normal)**: {regime_sensitivity.get('Sharpe_Diff', np.nan):.2f}") st.write(f"**Top Transition Risk**: {transition_risk}") st.write(f"**Sweep Stability (Sharpe Std)**: {sweep_stability}") - + st.download_button( label="Download Full Research Data (CSV)", data=res_df.to_csv().encode('utf-8'),