1+ """SMA heatmap optimisation page."""
2+ from __future__ import annotations
3+
14from datetime import date , timedelta
25
36import pandas as pd
47import streamlit as st
58
6- from quantboard .data import get_prices
9+ from quantboard .data import get_prices_cached
710from quantboard .optimize import grid_search_sma
811from quantboard .plots import heatmap_metric
12+ from quantboard .ui .state import get_param , set_param , shareable_link_button
913from quantboard .ui .theme import apply_global_theme
1014
1115st .set_page_config (page_title = "SMA Heatmap" , page_icon = "🔥" , layout = "wide" )
1216apply_global_theme ()
1317
1418
1519def _validate_prices (df : pd .DataFrame ) -> pd .Series | None :
20+ """Return a cleaned *close* series or ``None`` when empty."""
1621 if df .empty or "close" not in df .columns :
1722 st .error ("No data for the selected range/interval." )
1823 return None
24+
1925 close = pd .to_numeric (df ["close" ], errors = "coerce" ).dropna ()
2026 if close .empty :
2127 st .error ("No data for the selected range/interval." )
2228 return None
29+
2330 return close
2431
2532
33+ @st .cache_data (ttl = 60 )
34+ def _load_prices (ticker : str , start : date , end : date ) -> pd .DataFrame :
35+ return get_prices_cached (ticker , start = start , end = end , interval = "1d" )
36+
37+
2638def main () -> None :
2739 st .title ("🔥 SMA Heatmap" )
2840
41+ shareable_link_button ()
42+
43+ today = date .today ()
44+ default_start = today - timedelta (days = 365 * 2 )
45+
46+ ticker_default = str (get_param ("ticker" , "AAPL" )).strip ().upper () or "AAPL"
47+ end_default = get_param ("heat_end" , today )
48+ start_default = get_param ("heat_start" , default_start )
49+ fast_min_default = int (get_param ("heat_fast_min" , 10 ))
50+ fast_max_default = int (get_param ("heat_fast_max" , 25 ))
51+ slow_min_default = int (get_param ("heat_slow_min" , 50 ))
52+ slow_max_default = int (get_param ("heat_slow_max" , 120 ))
53+
54+ fast_min_default = max (5 , min (60 , fast_min_default ))
55+ fast_max_default = max (fast_min_default , min (60 , fast_max_default ))
56+ slow_min_default = max (20 , min (240 , slow_min_default ))
57+ slow_max_default = max (slow_min_default , min (240 , slow_max_default ))
58+
2959 with st .sidebar :
3060 st .header ("Parameters" )
31- ticker = st .text_input ("Ticker" , value = "AAPL" ).strip ().upper ()
32- end = st .date_input ("To" , value = date .today ())
33- start = st .date_input ("From" , value = date .today () - timedelta (days = 365 * 2 ))
34- fast_min , fast_max = st .slider ("Fast SMA range" , 5 , 60 , (10 , 25 ))
35- slow_min , slow_max = st .slider ("Slow SMA range" , 20 , 240 , (50 , 120 ))
36- run_btn = st .button ("Run search" , type = "primary" )
61+ with st .form ("heatmap_form" ):
62+ ticker = st .text_input ("Ticker" , value = ticker_default ).strip ().upper ()
63+ end = st .date_input ("To" , value = end_default )
64+ start = st .date_input ("From" , value = start_default )
65+ fast_min , fast_max = st .slider ("Fast SMA range" , 5 , 60 , (fast_min_default , fast_max_default ))
66+ slow_min , slow_max = st .slider ("Slow SMA range" , 20 , 240 , (slow_min_default , slow_max_default ))
67+ submitted = st .form_submit_button ("Run search" , type = "primary" )
68+
69+ if ticker != ticker_default :
70+ set_param ("ticker" , ticker or None )
71+ ticker_default = ticker or ticker_default
72+
73+ if end != end_default :
74+ set_param ("heat_end" , end )
75+ end_default = end
76+
77+ if start != start_default :
78+ set_param ("heat_start" , start )
79+ start_default = start
80+
81+ if int (fast_min ) != int (fast_min_default ) or int (fast_max ) != int (fast_max_default ):
82+ set_param ("heat_fast_min" , int (fast_min ))
83+ set_param ("heat_fast_max" , int (fast_max ))
84+ fast_min_default , fast_max_default = int (fast_min ), int (fast_max )
85+
86+ if int (slow_min ) != int (slow_min_default ) or int (slow_max ) != int (slow_max_default ):
87+ set_param ("heat_slow_min" , int (slow_min ))
88+ set_param ("heat_slow_max" , int (slow_max ))
89+ slow_min_default , slow_max_default = int (slow_min ), int (slow_max )
90+
91+ if not submitted :
92+ st .info ("Choose parameters and click **Run search**." )
93+ return
3794
3895 if fast_min >= slow_min :
3996 st .error ("Fast SMA range must stay below the Slow SMA range." )
4097 return
4198
42- if not run_btn :
43- st .info ("Choose parameters and click **Run search**." )
44- return
45-
4699 with st .spinner ("Fetching data..." ):
47- df = get_prices (ticker , start = start , end = end , interval = "1d" )
100+ df = _load_prices (ticker , start = start , end = end )
48101
49102 close = _validate_prices (df )
50103 if close is None :
@@ -57,28 +110,61 @@ def main() -> None:
57110 slow_range = range (int (slow_min ), int (slow_max ) + 1 ),
58111 metric = "Sharpe" ,
59112 )
60- # Invalidate combinations where fast >= slow
61- for f in z .index :
62- for s in z .columns :
63- if int (f ) >= int (s ):
64- z .loc [f , s ] = float ("nan" )
113+
114+ # Invalidate combinations where fast >= slow
115+ for fast_window in z .index :
116+ for slow_window in z .columns :
117+ if int (fast_window ) >= int (slow_window ):
118+ z .loc [fast_window , slow_window ] = float ("nan" )
65119
66120 st .subheader ("Heatmap (Sharpe)" )
67121 st .plotly_chart (heatmap_metric (z , title = "SMA grid — Sharpe" ), use_container_width = True )
68122
69- # Best combination ignoring NaNs
70- best = z .stack ().dropna ().astype (float ).idxmax () if z .stack ().dropna ().size else None
71- if best :
72- f_best , s_best = map (int , best )
73- st .success (f"Best combo: **Fast SMA { f_best } / Slow SMA { s_best } **" )
74- if st .button ("Use in Home" ):
75- st .experimental_set_query_params (ticker = ticker )
76- try :
77- st .switch_page ("streamlit_app.py" )
78- except Exception :
79- st .info ("Open Home from the menu; the ticker was set." )
80- else :
123+ stacked = z .stack ().dropna ().astype (float )
124+ if stacked .empty :
81125 st .warning ("No valid combination found in the selected range." )
126+ return
127+
128+ f_best , s_best = map (int , stacked .idxmax ())
129+ st .success (f"Best combo: **Fast SMA { f_best } / Slow SMA { s_best } **" )
130+
131+ top_df = (
132+ stacked .sort_values (ascending = False )
133+ .head (10 )
134+ .rename_axis (("fast" , "slow" ))
135+ .reset_index (name = "Sharpe" )
136+ )
137+
138+ st .subheader (f"Top { len (top_df )} combinations" )
139+ header_cols = st .columns ([1 , 1 , 1 , 1.5 ])
140+ header_cols [0 ].markdown ("**Fast**" )
141+ header_cols [1 ].markdown ("**Slow**" )
142+ header_cols [2 ].markdown ("**Sharpe**" )
143+ header_cols [3 ].markdown ("**Action**" )
144+
145+ for idx , row in top_df .iterrows ():
146+ fast_val = int (row ["fast" ])
147+ slow_val = int (row ["slow" ])
148+ sharpe_val = float (row ["Sharpe" ])
149+ cols = st .columns ([1 , 1 , 1 , 1.5 ])
150+ cols [0 ].write (fast_val )
151+ cols [1 ].write (slow_val )
152+ cols [2 ].write (f"{ sharpe_val :.2f} " )
153+ if cols [3 ].button ("Run Backtest" , key = f"run_backtest_{ idx } " ):
154+ set_param ("ticker" , ticker or None )
155+ set_param ("fast" , int (fast_val ))
156+ set_param ("slow" , int (slow_val ))
157+ try :
158+ st .switch_page ("pages/03_Backtest.py" )
159+ except Exception : # pragma: no cover - depends on Streamlit runtime
160+ st .info ("Open Backtest from the menu; the parameters were set." )
161+
162+ if st .button ("Use in Home" ):
163+ set_param ("ticker" , ticker or None )
164+ try :
165+ st .switch_page ("streamlit_app.py" )
166+ except Exception : # pragma: no cover - depends on Streamlit runtime
167+ st .info ("Open Home from the menu; the ticker was set." )
82168
83169
84170main ()
0 commit comments