P2SAMAPA commited on
Commit
78c05aa
Β·
unverified Β·
1 Parent(s): 4f2c5e2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +198 -79
app.py CHANGED
@@ -1,13 +1,11 @@
1
  """
2
- P2-ETF-PREDICTOR β€” TFT Edition (Display Mode)
3
- ===============================================
4
- Reads pre-computed model outputs pushed daily by GitHub Actions.
5
- Replays execute_strategy() live so all sliders work without retraining.
6
-
7
- Files read from HF Space repo root:
8
- - model_outputs.npz β€” proba, daily returns, dates, target_etfs
9
- - signals.json β€” next signal, conviction, metadata
10
- - training_meta.json β€” lookback, epochs, accuracy info
11
  """
12
 
13
  import streamlit as st
@@ -17,6 +15,7 @@ import plotly.graph_objects as go
17
  import json
18
  import os
19
  import time
 
20
 
21
  from utils import get_est_time, is_sync_window
22
  from data_manager import get_data, fetch_etf_data, fetch_macro_data_robust, smart_update_hf_dataset
@@ -24,12 +23,17 @@ from strategy import execute_strategy, calculate_metrics, calculate_benchmark_me
24
 
25
  st.set_page_config(page_title="P2-ETF-Predictor | TFT", layout="wide")
26
 
27
- HF_OUTPUT_REPO = "P2SAMAPA/p2-etf-tft-outputs" # dedicated dataset repo for model outputs
 
 
 
 
28
 
29
 
30
- @st.cache_data(ttl=1800)
 
 
31
  def load_model_outputs():
32
- """Load pre-computed model outputs from HF Dataset repo."""
33
  try:
34
  from huggingface_hub import hf_hub_download
35
  path = hf_hub_download(
@@ -39,15 +43,13 @@ def load_model_outputs():
39
  force_download=True,
40
  )
41
  npz = np.load(path, allow_pickle=True)
42
- data = {k: npz[k] for k in npz.files}
43
- return data, None
44
  except Exception as e:
45
  return {}, str(e)
46
 
47
 
48
- @st.cache_data(ttl=1800)
49
  def load_signals():
50
- """Load latest signals.json from HF Dataset repo."""
51
  try:
52
  from huggingface_hub import hf_hub_download
53
  path = hf_hub_download(
@@ -62,9 +64,8 @@ def load_signals():
62
  return None, str(e)
63
 
64
 
65
- @st.cache_data(ttl=1800)
66
  def load_training_meta():
67
- """Load training_meta.json from HF Dataset repo."""
68
  try:
69
  from huggingface_hub import hf_hub_download
70
  path = hf_hub_download(
@@ -79,6 +80,83 @@ def load_training_meta():
79
  return None
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  # ─────────────────────────────────────────────────────────────────────────────
83
  # SIDEBAR
84
  # ─────────────────────────────────────────────────────────────────────────────
@@ -87,23 +165,17 @@ with st.sidebar:
87
 
88
  current_time = get_est_time()
89
  st.write(f"πŸ•’ **EST:** {current_time.strftime('%H:%M:%S')}")
90
- if is_sync_window():
91
- st.success("βœ… Sync Window Active")
92
- else:
93
- st.info("⏸️ Sync Window Inactive")
94
 
95
  st.divider()
96
 
97
- st.subheader("πŸ“₯ Dataset")
98
- force_refresh = st.checkbox("Force Dataset Refresh", value=False)
99
- clean_dataset = st.checkbox("Clean HF Dataset (>30% NaN columns)", value=False)
100
- refresh_only_button = st.button("πŸ”„ Refresh Dataset Only",
101
- type="secondary", use_container_width=True)
102
 
103
  st.divider()
104
 
105
- start_yr = st.slider("πŸ“… Start Year (OOS display)", 2008, 2024, 2016)
106
- fee_bps = st.slider("πŸ’° Transaction Fee (bps)", 0, 100, 15)
107
 
108
  st.divider()
109
 
@@ -125,7 +197,36 @@ with st.sidebar:
125
  )
126
 
127
  st.divider()
128
- st.caption("πŸ€– Model retrained daily via GitHub Actions Β· Split: 80/10/10 (hardcoded)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  # ─────────────────────────────────────────────────────────────────────────────
131
  # HEADER
@@ -134,7 +235,23 @@ st.title("πŸ€– P2-ETF-PREDICTOR")
134
  st.caption("Temporal Fusion Transformer β€” Fixed Income ETF Rotation")
135
 
136
  # ─────────────────────────────────────────────────────────────────────────────
137
- # DATASET REFRESH ONLY (unchanged β€” still works exactly as before)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  # ─────────────────────────────────────────────────────────────────────────────
139
  if refresh_only_button:
140
  st.info("πŸ”„ Refreshing dataset...")
@@ -160,75 +277,76 @@ if refresh_only_button:
160
  st.stop()
161
 
162
  # ─────────────────────────────────────────────────────────────────────────────
163
- # LOAD PRE-COMPUTED OUTPUTS
 
 
 
 
 
 
 
 
 
 
164
  # ─────────────────────────────────────────────────────────────────────────────
165
- with st.spinner("πŸ“¦ Loading pre-computed model outputs..."):
166
- outputs, err = load_model_outputs()
167
- signals, sig_err = load_signals()
168
- meta = load_training_meta()
 
169
 
 
 
 
170
  if not outputs:
171
- st.error(f"❌ Could not load model outputs: {err}")
172
- st.info("πŸ’‘ The model may not have been trained yet. "
173
- "Trigger the GitHub Actions workflow manually to run the first training.")
174
  st.stop()
175
 
176
- if signals is None:
177
- st.warning(f"⚠️ Could not load signals.json: {sig_err}")
178
-
179
  # ── Extract arrays ────────────────────────────────────────────────────────────
180
- proba = outputs['proba'] # (N, 7)
181
- daily_ret_test = outputs['daily_ret_test'] # (N, 7)
182
- y_fwd_test = outputs['y_fwd_test'] # (N, 7)
183
- spy_ret_test = outputs['spy_ret_test'] # (N,)
184
- agg_ret_test = outputs['agg_ret_test'] # (N,)
185
  test_dates = pd.DatetimeIndex(outputs['test_dates'])
186
  target_etfs = list(outputs['target_etfs'])
187
  sofr = float(outputs['sofr'][0])
188
  etf_names = [e.replace('_Ret', '') for e in target_etfs]
189
 
190
- # ── Apply start year filter ───────────────────────────────────────────────────
191
- start_mask = test_dates.year >= start_yr
192
- if start_mask.sum() < 50:
193
- st.warning(f"⚠️ Less than 50 test days after {start_yr} filter. Showing all data.")
194
- start_mask = np.ones(len(test_dates), dtype=bool)
195
-
196
- proba_f = proba[start_mask]
197
- daily_ret_f = daily_ret_test[start_mask]
198
- y_fwd_f = y_fwd_test[start_mask]
199
- spy_ret_f = spy_ret_test[start_mask]
200
- agg_ret_f = agg_ret_test[start_mask]
201
- test_dates_f = test_dates[start_mask]
202
-
203
  # ── Show dataset info ─────────────────────────────────────────────────────────
204
  if signals:
205
- st.info(f"πŸ“… **Data:** {signals['data_start']} β†’ {signals['data_end']} | "
206
- f"**OOS Test:** {test_dates_f[0].date()} β†’ {test_dates_f[-1].date()} "
207
- f"({len(test_dates_f)} days) | "
208
- f"πŸ•’ Last trained: {signals['run_timestamp_utc'][:10]}")
209
- if meta:
210
- st.caption(f"πŸ“ Lookback: {meta['lookback_days']}d Β· "
211
- f"Features: {meta['n_features']} Β· "
212
- f"Split: {meta['split']} Β· "
213
- f"Targets: {', '.join(etf_names)}")
 
 
 
 
 
214
 
215
  # ─────────────────────────────────────────────────────────────────────────────
216
- # LIVE STRATEGY REPLAY (all sliders applied here β€” no retraining needed)
217
  # ─────────────────────────────────────────────────────────────────────────────
218
  (strat_rets, audit_trail, next_signal, next_trading_date,
219
  conviction_zscore, conviction_label, all_etf_scores) = execute_strategy(
220
- proba_f, y_fwd_f, test_dates_f, target_etfs,
221
  fee_bps,
222
  stop_loss_pct=stop_loss_pct,
223
  z_reentry=z_reentry,
224
  sofr=sofr,
225
  z_min_entry=z_min_entry,
226
- daily_ret_override=daily_ret_f
227
  )
228
 
229
  metrics = calculate_metrics(strat_rets, sofr)
230
 
231
- # ── Accuracy info from meta ───────────────────────────────────────────────────
232
  if meta and 'accuracy_per_etf' in meta:
233
  st.info(f"🎯 **Binary Accuracy per ETF:** {meta['accuracy_per_etf']} | "
234
  f"Random baseline: 50.0%")
@@ -353,7 +471,7 @@ c5.metric("⚠️ Max Daily DD", f"{metrics['max_daily_dd']*100:.2f}%",
353
  # ─────────────────────────────────────────────────────────────────────────────
354
  st.subheader("πŸ“ˆ Out-of-Sample Equity Curve (with Benchmarks)")
355
 
356
- plot_dates = test_dates_f[:len(metrics['cum_returns'])]
357
  fig = go.Figure()
358
  fig.add_trace(go.Scatter(
359
  x=plot_dates, y=metrics['cum_returns'], mode='lines',
@@ -366,11 +484,10 @@ fig.add_trace(go.Scatter(
366
  line=dict(color='rgba(255,255,255,0.3)', width=1, dash='dash')
367
  ))
368
 
369
- # Benchmarks
370
  spy_m = calculate_benchmark_metrics(
371
- np.nan_to_num(spy_ret_f[:len(strat_rets)], nan=0.0), sofr)
372
  agg_m = calculate_benchmark_metrics(
373
- np.nan_to_num(agg_ret_f[:len(strat_rets)], nan=0.0), sofr)
374
 
375
  fig.add_trace(go.Scatter(
376
  x=plot_dates, y=spy_m['cum_returns'], mode='lines',
@@ -422,6 +539,7 @@ st.divider()
422
  st.subheader("πŸ“– Methodology & Model Notes")
423
  lookback_display = meta['lookback_days'] if meta else "auto"
424
  rf_label_display = signals['rf_label'] if signals else "4.5% fallback"
 
425
 
426
  st.markdown(f"""
427
  <div style="background:#1a1a2e;border:1px solid #2d2d4e;border-radius:12px;
@@ -434,13 +552,14 @@ At inference time, ETFs are ranked by their confidence probability.</p>
434
 
435
  <h4 style="color:#00d1b2;margin-top:20px;">πŸ“Š Training Methodology</h4>
436
  <ul>
 
437
  <li><b>Split:</b> 80% train / 10% val / 10% test β€” strictly chronological</li>
438
  <li><b>Lookback auto-optimised:</b> Best window = <b>{lookback_display} days</b></li>
439
- <li><b>Retrained daily</b> via GitHub Actions on the latest data from HF Dataset</li>
440
  <li><b>Risk-free rate:</b> {sofr*100:.2f}% ({rf_label_display})</li>
441
  </ul>
442
 
443
- <h4 style="color:#00d1b2;margin-top:20px;">βš™οΈ Strategy Execution (live, applied to saved predictions)</h4>
444
  <ul>
445
  <li><b>Conviction gate (Οƒ={z_min_entry}):</b> Only enter if top ETF sits β‰₯ {z_min_entry}Οƒ above mean</li>
446
  <li><b>Trailing stop-loss ({stop_loss_pct*100:.0f}%):</b> Switch to CASH if 2-day cumulative ≀ threshold</li>
 
1
  """
2
+ P2-ETF-PREDICTOR β€” TFT Edition
3
+ ================================
4
+ - User picks start year β†’ clicks Run Model
5
+ - App triggers GitHub Actions via REST API with start_year param
6
+ - Shows last saved outputs while new training runs in background
7
+ - Daily auto-training at 7am EST with default start_year=2016
8
+ - Outputs stored in P2SAMAPA/p2-etf-tft-outputs HF Dataset repo
 
 
9
  """
10
 
11
  import streamlit as st
 
15
  import json
16
  import os
17
  import time
18
+ import requests as req
19
 
20
  from utils import get_est_time, is_sync_window
21
  from data_manager import get_data, fetch_etf_data, fetch_macro_data_robust, smart_update_hf_dataset
 
23
 
24
  st.set_page_config(page_title="P2-ETF-Predictor | TFT", layout="wide")
25
 
26
+ # ── Constants ─────────────────────────────────────────────────────────────────
27
+ HF_OUTPUT_REPO = "P2SAMAPA/p2-etf-tft-outputs"
28
+ GITHUB_REPO = "P2SAMAPA/P2-ETF-TFT-PREDICTOR-HF-DATASET"
29
+ GITHUB_WORKFLOW = "train_and_push.yml"
30
+ GITHUB_API_BASE = "https://api.github.com"
31
 
32
 
33
+ # ── Load outputs from HF Dataset repo ────────────────────────────────────────
34
+
35
+ @st.cache_data(ttl=300) # 5 min cache β€” refreshes frequently to catch new training
36
  def load_model_outputs():
 
37
  try:
38
  from huggingface_hub import hf_hub_download
39
  path = hf_hub_download(
 
43
  force_download=True,
44
  )
45
  npz = np.load(path, allow_pickle=True)
46
+ return {k: npz[k] for k in npz.files}, None
 
47
  except Exception as e:
48
  return {}, str(e)
49
 
50
 
51
+ @st.cache_data(ttl=300)
52
  def load_signals():
 
53
  try:
54
  from huggingface_hub import hf_hub_download
55
  path = hf_hub_download(
 
64
  return None, str(e)
65
 
66
 
67
+ @st.cache_data(ttl=300)
68
  def load_training_meta():
 
69
  try:
70
  from huggingface_hub import hf_hub_download
71
  path = hf_hub_download(
 
80
  return None
81
 
82
 
83
+ def trigger_github_training(start_year: int, force_refresh: bool = False) -> bool:
84
+ """
85
+ Trigger GitHub Actions workflow via REST API.
86
+ Passes start_year as workflow input.
87
+ Returns True if trigger was accepted (HTTP 204).
88
+ """
89
+ pat = os.getenv("GITHUB_PAT")
90
+ if not pat:
91
+ st.error("❌ GITHUB_PAT secret not found in HF Space secrets.")
92
+ return False
93
+
94
+ url = (f"{GITHUB_API_BASE}/repos/{GITHUB_REPO}/actions/workflows/"
95
+ f"{GITHUB_WORKFLOW}/dispatches")
96
+ payload = {
97
+ "ref": "main",
98
+ "inputs": {
99
+ "start_year": str(start_year),
100
+ "force_refresh": str(force_refresh).lower(),
101
+ }
102
+ }
103
+ headers = {
104
+ "Authorization": f"Bearer {pat}",
105
+ "Accept": "application/vnd.github+json",
106
+ "X-GitHub-Api-Version": "2022-11-28",
107
+ }
108
+ try:
109
+ r = req.post(url, json=payload, headers=headers, timeout=15)
110
+ if r.status_code == 204:
111
+ return True
112
+ else:
113
+ st.error(f"❌ GitHub API error {r.status_code}: {r.text[:200]}")
114
+ return False
115
+ except Exception as e:
116
+ st.error(f"❌ Failed to trigger GitHub Actions: {e}")
117
+ return False
118
+
119
+
120
+ def get_latest_workflow_run() -> dict:
121
+ """Get status of the latest GitHub Actions workflow run."""
122
+ pat = os.getenv("GITHUB_PAT")
123
+ if not pat:
124
+ return {}
125
+ url = (f"{GITHUB_API_BASE}/repos/{GITHUB_REPO}/actions/workflows/"
126
+ f"{GITHUB_WORKFLOW}/runs?per_page=1")
127
+ headers = {
128
+ "Authorization": f"Bearer {pat}",
129
+ "Accept": "application/vnd.github+json",
130
+ }
131
+ try:
132
+ r = req.get(url, headers=headers, timeout=10)
133
+ if r.status_code == 200:
134
+ runs = r.json().get("workflow_runs", [])
135
+ return runs[0] if runs else {}
136
+ except Exception:
137
+ pass
138
+ return {}
139
+
140
+
141
+ # ── Load outputs first (needed for sidebar slider range) ─────────────────────
142
+ with st.spinner("πŸ“¦ Loading model outputs..."):
143
+ outputs, load_err = load_model_outputs()
144
+ signals, sig_err = load_signals()
145
+ meta = load_training_meta()
146
+
147
+ # Derive test date range for slider
148
+ if outputs and 'test_dates' in outputs:
149
+ _test_dates = pd.DatetimeIndex(outputs['test_dates'])
150
+ _trained_start_yr = int(signals.get('start_year', 2016)) if signals else 2016
151
+ else:
152
+ _test_dates = None
153
+ _trained_start_yr = 2016
154
+
155
+ # ── Check workflow status ─────────────────────────────────────────────────────
156
+ latest_run = get_latest_workflow_run()
157
+ is_training = latest_run.get("status") in ("queued", "in_progress")
158
+ run_started = latest_run.get("created_at", "")[:16].replace("T", " ") if latest_run else ""
159
+
160
  # ─────────────────────────────────────────────────────────────────────────────
161
  # SIDEBAR
162
  # ─────────────────────────────────────────────────────────────────────────────
 
165
 
166
  current_time = get_est_time()
167
  st.write(f"πŸ•’ **EST:** {current_time.strftime('%H:%M:%S')}")
 
 
 
 
168
 
169
  st.divider()
170
 
171
+ st.subheader("πŸ“… Training Period")
172
+ start_yr = st.slider("Start Year", 2008, 2024, _trained_start_yr,
173
+ help="Model trains on data from this year to present (80/10/10 split)")
 
 
174
 
175
  st.divider()
176
 
177
+ st.subheader("πŸ’° Transaction Cost")
178
+ fee_bps = st.slider("Transaction Fee (bps)", 0, 100, 15)
179
 
180
  st.divider()
181
 
 
197
  )
198
 
199
  st.divider()
200
+
201
+ st.subheader("πŸ“₯ Dataset")
202
+ force_refresh = st.checkbox("Force Dataset Refresh", value=False)
203
+ clean_dataset = st.checkbox("Clean HF Dataset (>30% NaN columns)", value=False)
204
+
205
+ st.divider()
206
+
207
+ # ── Run Model button ──────────────────────────────────────────────────────
208
+ run_button = st.button(
209
+ "πŸš€ Run TFT Model",
210
+ type="primary",
211
+ use_container_width=True,
212
+ disabled=is_training,
213
+ help="Triggers GitHub Actions to train with selected start year (~1.5hrs)"
214
+ )
215
+ if is_training:
216
+ st.warning(f"⏳ Training in progress (started {run_started} UTC)...\n\n"
217
+ f"Results will auto-update when complete.")
218
+
219
+ refresh_only_button = st.button(
220
+ "πŸ”„ Refresh Dataset Only",
221
+ type="secondary",
222
+ use_container_width=True
223
+ )
224
+
225
+ st.divider()
226
+ st.caption("πŸ€– Training runs on GitHub Actions Β· Split: 80/10/10")
227
+ if signals:
228
+ st.caption(f"πŸ“… Current outputs: start_year={signals.get('start_year', '?')} Β· "
229
+ f"trained {signals.get('run_timestamp_utc', '')[:10]}")
230
 
231
  # ─────────────────────────────────────────────────────────────────────────────
232
  # HEADER
 
235
  st.caption("Temporal Fusion Transformer β€” Fixed Income ETF Rotation")
236
 
237
  # ─────────────────────────────────────────────────────────────────────────────
238
+ # HANDLE RUN BUTTON β€” trigger GitHub Actions
239
+ # ─────────────────────────────────────────────────────────────────────────────
240
+ if run_button:
241
+ with st.spinner(f"πŸš€ Triggering GitHub Actions training for start_year={start_yr}..."):
242
+ success = trigger_github_training(start_year=start_yr, force_refresh=force_refresh)
243
+ if success:
244
+ st.success(
245
+ f"βœ… Training triggered for **start_year={start_yr}**! "
246
+ f"GitHub Actions is now training (~1.5hrs). "
247
+ f"This page will show the previous results in the meantime β€” "
248
+ f"refresh in ~90 minutes to see updated outputs."
249
+ )
250
+ time.sleep(2)
251
+ st.rerun()
252
+
253
+ # ─────────────────────────────────────────────────────────────────────────────
254
+ # HANDLE REFRESH DATASET ONLY
255
  # ─────────────────────────────────────────────────────────────────────────────
256
  if refresh_only_button:
257
  st.info("πŸ”„ Refreshing dataset...")
 
277
  st.stop()
278
 
279
  # ─────────────────────────────────────────────────────────────────────────────
280
+ # TRAINING IN PROGRESS BANNER
281
+ # ─────────────────────────────────────────────────────────────────────────────
282
+ if is_training:
283
+ st.warning(
284
+ f"⏳ **Training in progress** (started {run_started} UTC) β€” "
285
+ f"showing previous results below. Refresh in ~90 minutes for updated outputs.",
286
+ icon="πŸ”„"
287
+ )
288
+
289
+ # ─────────────────────────────────────────────────────────────────────────────
290
+ # YEAR MISMATCH WARNING
291
  # ─────────────────────────────────────────────────────────────────────────────
292
+ if signals and signals.get('start_year') and int(signals.get('start_year')) != start_yr and not is_training:
293
+ st.info(
294
+ f"ℹ️ Showing results for **start_year={signals.get('start_year')}** "
295
+ f"(last trained). Click **πŸš€ Run TFT Model** to train for **{start_yr}**."
296
+ )
297
 
298
+ # ─────────────────────────────────────────────────────────────────────────────
299
+ # CHECK IF OUTPUTS AVAILABLE
300
+ # ─────────────────────────────────────────────────────────────────────────────
301
  if not outputs:
302
+ st.error(f"❌ No model outputs available yet: {load_err}")
303
+ st.info("πŸ‘ˆ Click **πŸš€ Run TFT Model** to trigger the first training run.")
 
304
  st.stop()
305
 
 
 
 
306
  # ── Extract arrays ────────────────────────────────────────────────────────────
307
+ proba = outputs['proba']
308
+ daily_ret_test = outputs['daily_ret_test']
309
+ y_fwd_test = outputs['y_fwd_test']
310
+ spy_ret_test = outputs['spy_ret_test']
311
+ agg_ret_test = outputs['agg_ret_test']
312
  test_dates = pd.DatetimeIndex(outputs['test_dates'])
313
  target_etfs = list(outputs['target_etfs'])
314
  sofr = float(outputs['sofr'][0])
315
  etf_names = [e.replace('_Ret', '') for e in target_etfs]
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  # ── Show dataset info ─────────────────────────────────────────────────────────
318
  if signals:
319
+ st.info(
320
+ f"πŸ“… **Trained from:** {signals.get('start_year', '?')} Β· "
321
+ f"**Data:** {signals['data_start']} β†’ {signals['data_end']} | "
322
+ f"**OOS Test:** {signals['test_start']} β†’ {signals['test_end']} "
323
+ f"({signals['n_test_days']} days) | "
324
+ f"πŸ•’ Trained: {signals['run_timestamp_utc'][:10]}"
325
+ )
326
+ if meta:
327
+ st.caption(
328
+ f"πŸ“ Lookback: {meta['lookback_days']}d Β· "
329
+ f"Features: {meta['n_features']} Β· "
330
+ f"Split: {meta['split']} Β· "
331
+ f"Targets: {', '.join(etf_names)}"
332
+ )
333
 
334
  # ─────────────────────────────────────────────────────────────────────────────
335
+ # LIVE STRATEGY REPLAY β€” all risk sliders applied here
336
  # ─────────────────────────────────────────────────────────────────────────────
337
  (strat_rets, audit_trail, next_signal, next_trading_date,
338
  conviction_zscore, conviction_label, all_etf_scores) = execute_strategy(
339
+ proba, y_fwd_test, test_dates, target_etfs,
340
  fee_bps,
341
  stop_loss_pct=stop_loss_pct,
342
  z_reentry=z_reentry,
343
  sofr=sofr,
344
  z_min_entry=z_min_entry,
345
+ daily_ret_override=daily_ret_test
346
  )
347
 
348
  metrics = calculate_metrics(strat_rets, sofr)
349
 
 
350
  if meta and 'accuracy_per_etf' in meta:
351
  st.info(f"🎯 **Binary Accuracy per ETF:** {meta['accuracy_per_etf']} | "
352
  f"Random baseline: 50.0%")
 
471
  # ─────────────────────────────────────────────────────────────────────────────
472
  st.subheader("πŸ“ˆ Out-of-Sample Equity Curve (with Benchmarks)")
473
 
474
+ plot_dates = test_dates[:len(metrics['cum_returns'])]
475
  fig = go.Figure()
476
  fig.add_trace(go.Scatter(
477
  x=plot_dates, y=metrics['cum_returns'], mode='lines',
 
484
  line=dict(color='rgba(255,255,255,0.3)', width=1, dash='dash')
485
  ))
486
 
 
487
  spy_m = calculate_benchmark_metrics(
488
+ np.nan_to_num(spy_ret_test[:len(strat_rets)], nan=0.0), sofr)
489
  agg_m = calculate_benchmark_metrics(
490
+ np.nan_to_num(agg_ret_test[:len(strat_rets)], nan=0.0), sofr)
491
 
492
  fig.add_trace(go.Scatter(
493
  x=plot_dates, y=spy_m['cum_returns'], mode='lines',
 
539
  st.subheader("πŸ“– Methodology & Model Notes")
540
  lookback_display = meta['lookback_days'] if meta else "auto"
541
  rf_label_display = signals['rf_label'] if signals else "4.5% fallback"
542
+ trained_start = signals.get('start_year', start_yr) if signals else start_yr
543
 
544
  st.markdown(f"""
545
  <div style="background:#1a1a2e;border:1px solid #2d2d4e;border-radius:12px;
 
552
 
553
  <h4 style="color:#00d1b2;margin-top:20px;">πŸ“Š Training Methodology</h4>
554
  <ul>
555
+ <li><b>Training period:</b> {trained_start} β†’ present (user-selectable via sidebar)</li>
556
  <li><b>Split:</b> 80% train / 10% val / 10% test β€” strictly chronological</li>
557
  <li><b>Lookback auto-optimised:</b> Best window = <b>{lookback_display} days</b></li>
558
+ <li><b>Runs on GitHub Actions</b> β€” triggered from sidebar, outputs saved to HF Dataset</li>
559
  <li><b>Risk-free rate:</b> {sofr*100:.2f}% ({rf_label_display})</li>
560
  </ul>
561
 
562
+ <h4 style="color:#00d1b2;margin-top:20px;">βš™οΈ Strategy Execution (live β€” applied to saved predictions)</h4>
563
  <ul>
564
  <li><b>Conviction gate (Οƒ={z_min_entry}):</b> Only enter if top ETF sits β‰₯ {z_min_entry}Οƒ above mean</li>
565
  <li><b>Trailing stop-loss ({stop_loss_pct*100:.0f}%):</b> Switch to CASH if 2-day cumulative ≀ threshold</li>