GitHub Actions commited on
Commit
b16a944
·
1 Parent(s): 63f2dd9

Sync from GitHub: 7e71c6b91cb36cef701b24bb05297201488f3df3

Browse files
Files changed (32) hide show
  1. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/data/data/__init__.py +1 -0
  2. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/README.md +12 -0
  3. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/data/__init__.py +1 -0
  4. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/data/loader.py +215 -0
  5. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/README.md +110 -14
  6. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/app.py +273 -0
  7. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/.gitattributes +35 -0
  8. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/Dockerfile +20 -0
  9. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/README.md +19 -0
  10. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/requirements.txt +3 -0
  11. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/src/streamlit_app.py +40 -0
  12. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/requirements.txt +29 -3
  13. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/models/base.py +199 -0
  14. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/models/approach1_wavelet.py +167 -0
  15. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/models/approach2_regime.py +1 -0
  16. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/models/approach3_multiscale.py +150 -0
  17. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/strategy/backtest.py +193 -0
  18. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/signals/conviction.py +93 -0
  19. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/ui/components.py +229 -0
  20. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/ui/charts.py +144 -0
  21. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/utils/calendar.py +91 -0
  22. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/models/__init__.py +1 -0
  23. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/signals/__init__.py +1 -0
  24. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/strategy/__init__.py +1 -0
  25. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/ui/__init__.py +1 -0
  26. hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/utils/__init__.py +1 -0
  27. hf_space/hf_space/hf_space/hf_space/hf_space/models/models/__init__.py +1 -0
  28. hf_space/hf_space/hf_space/hf_space/signals/__init__.py +1 -1
  29. hf_space/hf_space/hf_space/strategy/__init__.py +1 -1
  30. hf_space/hf_space/signals/__init__.py +1 -1
  31. hf_space/ui/__init__.py +1 -1
  32. utils/__init__.py +1 -1
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/data/data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/README.md CHANGED
@@ -1,3 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  # P2-ETF-CNN-LSTM-ALTERNATIVE-APPROACHES
2
 
3
  Macro-driven ETF rotation using three augmented CNN-LSTM variants.
 
1
+ ---
2
+ title: P2-ETF-CNN-LSTM-ALTERNATIVE-APPROACHES
3
+ emoji: 🧠
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: streamlit
7
+ sdk_version: "1.32.0"
8
+ python_version: "3.10"
9
+ app_file: app.py
10
+ pinned: false
11
+ ---
12
+
13
  # P2-ETF-CNN-LSTM-ALTERNATIVE-APPROACHES
14
 
15
  Macro-driven ETF rotation using three augmented CNN-LSTM variants.
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/data/loader.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ data/loader.py
3
+ Loads master_data.parquet from HF Dataset.
4
+ Validates freshness against the last NYSE trading day.
5
+ No external pings — all data comes from HF Dataset only.
6
+ """
7
+
8
+ import pandas as pd
9
+ import numpy as np
10
+ import streamlit as st
11
+ from huggingface_hub import hf_hub_download
12
+ from datetime import datetime, timedelta
13
+ import pytz
14
+ import os
15
+
16
+ try:
17
+ import pandas_market_calendars as mcal
18
+ NYSE_CAL_AVAILABLE = True
19
+ except ImportError:
20
+ NYSE_CAL_AVAILABLE = False
21
+
22
+ DATASET_REPO = "P2SAMAPA/fi-etf-macro-signal-master-data"
23
+ PARQUET_FILE = "master_data.parquet"
24
+
25
+ # Columns expected in the dataset
26
+ REQUIRED_ETF_COLS = ["TLT_Ret", "TBT_Ret", "VNQ_Ret", "SLV_Ret", "GLD_Ret"]
27
+ BENCHMARK_COLS = ["SPY_Ret", "AGG_Ret"]
28
+ TBILL_COL = "DTB3" # 3m T-bill column in HF dataset
29
+ TARGET_ETFS = REQUIRED_ETF_COLS # 5 targets (no CASH in returns, CASH handled in strategy)
30
+
31
+
32
+ # ── NYSE calendar helpers ─────────────────────────────────────────────────────
33
+
34
+ def get_last_nyse_trading_day(as_of: datetime = None) -> datetime.date:
35
+ """Return the most recent NYSE trading day before or on as_of (default: today EST)."""
36
+ est = pytz.timezone("US/Eastern")
37
+ if as_of is None:
38
+ as_of = datetime.now(est)
39
+
40
+ today = as_of.date()
41
+
42
+ if NYSE_CAL_AVAILABLE:
43
+ try:
44
+ nyse = mcal.get_calendar("NYSE")
45
+ # Look back up to 10 days to find last trading day
46
+ start = today - timedelta(days=10)
47
+ schedule = nyse.schedule(start_date=start, end_date=today)
48
+ if len(schedule) > 0:
49
+ return schedule.index[-1].date()
50
+ except Exception:
51
+ pass
52
+
53
+ # Fallback: skip weekends
54
+ candidate = today
55
+ while candidate.weekday() >= 5:
56
+ candidate -= timedelta(days=1)
57
+ return candidate
58
+
59
+
60
+ def is_nyse_trading_day(date) -> bool:
61
+ """Return True if date is a NYSE trading day."""
62
+ if NYSE_CAL_AVAILABLE:
63
+ try:
64
+ nyse = mcal.get_calendar("NYSE")
65
+ schedule = nyse.schedule(start_date=date, end_date=date)
66
+ return len(schedule) > 0
67
+ except Exception:
68
+ pass
69
+ return date.weekday() < 5
70
+
71
+
72
+ # ── Data loading ──────────────────────────────────────────────────────────────
73
+
74
+ @st.cache_data(ttl=3600, show_spinner=False)
75
+ def load_dataset(hf_token: str) -> pd.DataFrame:
76
+ """
77
+ Download master_data.parquet from HF Dataset and return as DataFrame.
78
+ Cached for 1 hour. Index is parsed as DatetimeIndex.
79
+ """
80
+ try:
81
+ path = hf_hub_download(
82
+ repo_id=DATASET_REPO,
83
+ filename=PARQUET_FILE,
84
+ repo_type="dataset",
85
+ token=hf_token,
86
+ )
87
+ df = pd.read_parquet(path)
88
+
89
+ # Ensure DatetimeIndex
90
+ if not isinstance(df.index, pd.DatetimeIndex):
91
+ if "Date" in df.columns:
92
+ df = df.set_index("Date")
93
+ elif "date" in df.columns:
94
+ df = df.set_index("date")
95
+ df.index = pd.to_datetime(df.index)
96
+
97
+ df = df.sort_index()
98
+ return df
99
+
100
+ except Exception as e:
101
+ st.error(f"❌ Failed to load dataset from HuggingFace: {e}")
102
+ return pd.DataFrame()
103
+
104
+
105
+ # ── Freshness check ───────────────────────────────────────────────────────────
106
+
107
+ def check_data_freshness(df: pd.DataFrame) -> dict:
108
+ """
109
+ Check whether the dataset contains data for the last NYSE trading day.
110
+
111
+ Returns a dict:
112
+ {
113
+ "fresh": bool,
114
+ "last_date_in_data": date,
115
+ "expected_date": date,
116
+ "message": str
117
+ }
118
+ """
119
+ if df.empty:
120
+ return {
121
+ "fresh": False,
122
+ "last_date_in_data": None,
123
+ "expected_date": None,
124
+ "message": "Dataset is empty.",
125
+ }
126
+
127
+ last_date_in_data = df.index[-1].date()
128
+ expected_date = get_last_nyse_trading_day()
129
+
130
+ fresh = last_date_in_data >= expected_date
131
+
132
+ if fresh:
133
+ message = f"✅ Dataset is up to date through **{last_date_in_data}**."
134
+ else:
135
+ message = (
136
+ f"⚠️ **{expected_date}** data not yet updated in dataset. "
137
+ f"Latest available: **{last_date_in_data}**. "
138
+ f"Please check back later — the dataset updates daily after market close."
139
+ )
140
+
141
+ return {
142
+ "fresh": fresh,
143
+ "last_date_in_data": last_date_in_data,
144
+ "expected_date": expected_date,
145
+ "message": message,
146
+ }
147
+
148
+
149
+ # ── Feature / target extraction ───────────────────────────────────────��───────
150
+
151
+ def get_features_and_targets(df: pd.DataFrame):
152
+ """
153
+ Extract input feature columns and target ETF return columns from the dataset.
154
+
155
+ Returns:
156
+ input_features : list of column names
157
+ target_etfs : list of ETF return column names (e.g. TLT_Ret)
158
+ tbill_rate : latest 3m T-bill rate as a float (annualised, e.g. 0.045)
159
+ """
160
+ # Target ETF return columns
161
+ target_etfs = [c for c in REQUIRED_ETF_COLS if c in df.columns]
162
+
163
+ if not target_etfs:
164
+ raise ValueError(
165
+ f"No target ETF columns found. Expected: {REQUIRED_ETF_COLS}. "
166
+ f"Found in dataset: {list(df.columns)}"
167
+ )
168
+
169
+ # Input features: Z-scores, vol, regime, yield curve, credit, rates, VIX terms
170
+ exclude = set(target_etfs + BENCHMARK_COLS + [TBILL_COL])
171
+ input_features = [
172
+ c for c in df.columns
173
+ if c not in exclude
174
+ and (
175
+ c.endswith("_Z")
176
+ or c.endswith("_Vol")
177
+ or "Regime" in c
178
+ or "YC_" in c
179
+ or "Credit_" in c
180
+ or "Rates_" in c
181
+ or "VIX_" in c
182
+ or "Spread" in c
183
+ or "DXY" in c
184
+ or "VIX" in c
185
+ or "T10Y" in c
186
+ )
187
+ ]
188
+
189
+ # 3m T-bill rate (for CASH return & Sharpe)
190
+ tbill_rate = 0.045 # default fallback
191
+ if TBILL_COL in df.columns:
192
+ raw = df[TBILL_COL].dropna()
193
+ if len(raw) > 0:
194
+ last_val = raw.iloc[-1]
195
+ # DTB3 is typically in percent (e.g. 5.25 means 5.25%)
196
+ tbill_rate = float(last_val) / 100 if last_val > 1 else float(last_val)
197
+
198
+ return input_features, target_etfs, tbill_rate
199
+
200
+
201
+ # ── Column info helper (for sidebar display) ──────────────────────────────────
202
+
203
+ def dataset_summary(df: pd.DataFrame) -> dict:
204
+ """Return a brief summary dict for sidebar display."""
205
+ if df.empty:
206
+ return {}
207
+ return {
208
+ "rows": len(df),
209
+ "columns": len(df.columns),
210
+ "start_date": df.index[0].strftime("%Y-%m-%d"),
211
+ "end_date": df.index[-1].strftime("%Y-%m-%d"),
212
+ "etfs_found": [c for c in REQUIRED_ETF_COLS if c in df.columns],
213
+ "benchmarks": [c for c in BENCHMARK_COLS if c in df.columns],
214
+ "tbill_found": TBILL_COL in df.columns,
215
+ }
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/README.md CHANGED
@@ -1,19 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: P2 ETF CNN LSTM ALTERNATIVE APPROACHES
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
- pinned: false
11
- short_description: Streamlit template space
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  ---
13
 
14
- # Welcome to Streamlit!
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
17
 
18
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
19
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
1
+ # P2-ETF-CNN-LSTM-ALTERNATIVE-APPROACHES
2
+
3
+ Macro-driven ETF rotation using three augmented CNN-LSTM variants.
4
+ Winner selected by **highest raw annualised return** on the out-of-sample test set.
5
+
6
+ ---
7
+
8
+ ## Architecture Overview
9
+
10
+ | Approach | Core Idea | Key Addition |
11
+ |---|---|---|
12
+ | **1 — Wavelet** | DWT decomposes each macro signal into frequency subbands before the CNN | Separates trend / cycle / noise |
13
+ | **2 — Regime-Conditioned** | HMM detects macro regimes; one-hot regime label concatenated into the network | Removes non-stationarity |
14
+ | **3 — Multi-Scale Parallel** | Three CNN towers (kernels 3, 7, 21 days) run in parallel before the LSTM | Captures momentum + cycle + trend simultaneously |
15
+
16
  ---
17
+
18
+ ## ETF Universe
19
+
20
+ | Ticker | Description |
21
+ |---|---|
22
+ | TLT | 20+ Year Treasury Bond |
23
+ | TBT | 20+ Year Treasury Short (2×) |
24
+ | VNQ | Real Estate (REIT) |
25
+ | SLV | Silver |
26
+ | GLD | Gold |
27
+ | CASH | 3m T-bill rate (from HF dataset) |
28
+
29
+ Benchmarks (chart only, not traded): **SPY**, **AGG**
30
+
31
+ ---
32
+
33
+ ## Data
34
+
35
+ All data sourced exclusively from:
36
+ **`P2SAMAPA/fi-etf-macro-signal-master-data`** (HuggingFace Dataset)
37
+ File: `master_data.parquet`
38
+
39
+ No external API calls (no yfinance, no FRED).
40
+ The app checks daily whether the prior NYSE trading day's data is present in the dataset.
41
+
42
  ---
43
 
44
+ ## Project Structure
45
+
46
+ ```
47
+ ├── .github/
48
+ │ └── workflows/
49
+ │ └── sync.yml # Auto-sync GitHub → HF Space on push to main
50
+
51
+ ├── app.py # Streamlit orchestrator (UI wiring only)
52
+
53
+ ├── data/
54
+ │ └── loader.py # HF dataset load, freshness check, column validation
55
+
56
+ ├── models/
57
+ │ ├── base.py # Shared: sequences, splits, scaling, callbacks
58
+ │ ├── approach1_wavelet.py # Wavelet CNN-LSTM
59
+ │ ├── approach2_regime.py # Regime-Conditioned CNN-LSTM
60
+ │ └── approach3_multiscale.py # Multi-Scale Parallel CNN-LSTM
61
+
62
+ ├── strategy/
63
+ │ └── backtest.py # execute_strategy, metrics, winner selection
64
+
65
+ ├── signals/
66
+ │ └── conviction.py # Z-score conviction scoring
67
+
68
+ ├── ui/
69
+ │ ├── components.py # Banner, conviction panel, metrics, audit trail
70
+ │ └── charts.py # Plotly equity curve + comparison bar chart
71
+
72
+ ├── utils/
73
+ │ └── calendar.py # NYSE calendar, next trading day, EST time
74
+
75
+ ├── requirements.txt
76
+ └── README.md
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Secrets Required
82
+
83
+ | Secret | Where | Purpose |
84
+ |---|---|---|
85
+ | `HF_TOKEN` | GitHub + HF Space | Read HF dataset · Sync HF Space |
86
+
87
+ Set in:
88
+ - GitHub: `Settings → Secrets → Actions → New repository secret`
89
+ - HF Space: `Settings → Repository secrets`
90
+
91
+ ---
92
+
93
+ ## Deployment
94
+
95
+ Push to `main` → GitHub Actions (`sync.yml`) automatically syncs to HF Space.
96
+
97
+ ### Local development
98
+
99
+ ```bash
100
+ pip install -r requirements.txt
101
+ export HF_TOKEN=your_token
102
+ streamlit run app.py
103
+ ```
104
+
105
+ ---
106
 
107
+ ## Output UI
108
 
109
+ 1. **Data freshness warning** alerts if prior NYSE trading day data is missing
110
+ 2. **Next Trading Day Signal** — date + ETF from the winning approach
111
+ 3. **Signal Conviction** — Z-score gauge + per-ETF probability bars
112
+ 4. **Performance Metrics** — Annualised Return, Sharpe, Hit Ratio, Max DD
113
+ 5. **Approach Comparison Table** — all three approaches side by side
114
+ 6. **Equity Curves** — all three approaches + SPY + AGG benchmarks
115
+ 7. **Audit Trail** — last 20 trading days for the winning approach
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/app.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py
3
+ P2-ETF-CNN-LSTM-ALTERNATIVE-APPROACHES
4
+ Streamlit orchestrator — UI wiring only, no business logic here.
5
+ """
6
+
7
+ import os
8
+ import streamlit as st
9
+ import pandas as pd
10
+ import numpy as np
11
+
12
+ # ── Module imports ────────────────────────────────────────────────────────────
13
+ from data.loader import load_dataset, check_data_freshness, get_features_and_targets, dataset_summary
14
+ from utils.calendar import get_est_time, is_sync_window, get_next_signal_date
15
+ from models.base import build_sequences, train_val_test_split, scale_features, returns_to_labels
16
+ from models.approach1_wavelet import train_approach1, predict_approach1
17
+ from models.approach2_regime import train_approach2, predict_approach2
18
+ from models.approach3_multiscale import train_approach3, predict_approach3
19
+ from strategy.backtest import execute_strategy, select_winner, build_comparison_table
20
+ from signals.conviction import compute_conviction
21
+ from ui.components import (
22
+ show_freshness_status, show_signal_banner, show_conviction_panel,
23
+ show_metrics_row, show_comparison_table, show_audit_trail,
24
+ )
25
+ from ui.charts import equity_curve_chart, comparison_bar_chart
26
+
27
+ # ── Page config ───────────────────────────────────────────────────────────────
28
+ st.set_page_config(
29
+ page_title="P2-ETF-CNN-LSTM",
30
+ page_icon="🧠",
31
+ layout="wide",
32
+ )
33
+
34
+ # ── Secrets ───────────────────────────────────────────────────────────────────
35
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
36
+
37
+ # ── Sidebar ───────────────────────────────────────────────────────────────────
38
+ with st.sidebar:
39
+ st.header("⚙️ Configuration")
40
+
41
+ now_est = get_est_time()
42
+ st.write(f"🕒 **EST:** {now_est.strftime('%H:%M:%S')}")
43
+ if is_sync_window():
44
+ st.success("✅ Sync Window Active")
45
+ else:
46
+ st.info("⏸️ Sync Window Inactive")
47
+
48
+ st.divider()
49
+
50
+ start_yr = st.slider("📅 Start Year", 2010, 2024, 2016)
51
+ fee_bps = st.slider("💰 Fee (bps)", 0, 50, 10)
52
+ lookback = st.slider("📐 Lookback (days)", 20, 60, 30, step=5)
53
+ epochs = st.number_input("🔁 Max Epochs", 20, 300, 100, step=10)
54
+
55
+ st.divider()
56
+
57
+ split_option = st.selectbox("📊 Train/Val/Test Split", ["70/15/15", "80/10/10"], index=0)
58
+ split_map = {"70/15/15": (0.70, 0.15), "80/10/10": (0.80, 0.10)}
59
+ train_pct, val_pct = split_map[split_option]
60
+
61
+ include_cash = st.checkbox("💵 Include CASH class", value=True,
62
+ help="Model can select CASH (earns T-bill rate) as an alternative to any ETF")
63
+
64
+ st.divider()
65
+
66
+ run_button = st.button("🚀 Run All 3 Approaches", type="primary", use_container_width=True)
67
+
68
+ # ── Title ─────────────────────────────────────────────────────────────────────
69
+ st.title("🧠 P2-ETF-CNN-LSTM")
70
+ st.caption("Approach 1: Wavelet · Approach 2: Regime-Conditioned · Approach 3: Multi-Scale Parallel")
71
+ st.caption("Winner selected by highest raw annualised return on out-of-sample test set.")
72
+
73
+ # ── Load data (always, to check freshness) ────────────────────────────────────
74
+ if not HF_TOKEN:
75
+ st.error("❌ HF_TOKEN secret not found. Please add it to your HF Space / GitHub secrets.")
76
+ st.stop()
77
+
78
+ with st.spinner("📡 Loading dataset from HuggingFace..."):
79
+ df = load_dataset(HF_TOKEN)
80
+
81
+ if df.empty:
82
+ st.stop()
83
+
84
+ # ── Freshness check ───────────────────────────────────────────────────────────
85
+ freshness = check_data_freshness(df)
86
+ show_freshness_status(freshness)
87
+
88
+ # ── Dataset summary in sidebar ────────────────────────────────────────────────
89
+ with st.sidebar:
90
+ st.divider()
91
+ st.subheader("📦 Dataset Info")
92
+ summary = dataset_summary(df)
93
+ if summary:
94
+ st.write(f"**Rows:** {summary['rows']:,}")
95
+ st.write(f"**Range:** {summary['start_date']} → {summary['end_date']}")
96
+ st.write(f"**ETFs:** {', '.join([e.replace('_Ret','') for e in summary['etfs_found']])}")
97
+ st.write(f"**Benchmarks:** {', '.join([b.replace('_Ret','') for b in summary['benchmarks']])}")
98
+ st.write(f"**T-bill col:** {'✅' if summary['tbill_found'] else '❌'}")
99
+
100
+ # ── Main execution ────────────────────────────────────────────────────────────
101
+ if not run_button:
102
+ st.info("👈 Configure parameters in the sidebar and click **🚀 Run All 3 Approaches** to begin.")
103
+ st.stop()
104
+
105
+ # ── Filter by start year ──────────────────────────────────────────────────────
106
+ df = df[df.index.year >= start_yr].copy()
107
+ st.write(f"📅 **Data:** {df.index[0].strftime('%Y-%m-%d')} → {df.index[-1].strftime('%Y-%m-%d')} "
108
+ f"({df.index[-1].year - df.index[0].year + 1} years)")
109
+
110
+ # ── Feature / target extraction ───────────────────────────────────────────────
111
+ try:
112
+ input_features, target_etfs, tbill_rate = get_features_and_targets(df)
113
+ except ValueError as e:
114
+ st.error(str(e))
115
+ st.stop()
116
+
117
+ st.info(f"🎯 **Targets:** {len(target_etfs)} ETFs · **Features:** {len(input_features)} signals · "
118
+ f"**T-bill rate:** {tbill_rate*100:.2f}%")
119
+
120
+ # ── Prepare sequences ─────────────────────────────────────────────────────────
121
+ X_raw = df[input_features].values.astype(np.float32)
122
+ y_raw = df[target_etfs].values.astype(np.float32)
123
+ n_etfs = len(target_etfs)
124
+ n_classes = n_etfs + (1 if include_cash else 0) # +1 for CASH
125
+
126
+ # Fill NaNs with column means
127
+ col_means = np.nanmean(X_raw, axis=0)
128
+ for j in range(X_raw.shape[1]):
129
+ mask = np.isnan(X_raw[:, j])
130
+ X_raw[mask, j] = col_means[j]
131
+
132
+ X_seq, y_seq = build_sequences(X_raw, y_raw, lookback)
133
+ y_labels = returns_to_labels(y_seq, include_cash=include_cash)
134
+
135
+ X_train, y_train_r, X_val, y_val_r, X_test, y_test_r = train_val_test_split(X_seq, y_seq, train_pct, val_pct)
136
+ _, y_train_l, _, y_val_l, _, y_test_l = train_val_test_split(X_seq, y_labels, train_pct, val_pct)
137
+
138
+ X_train_s, X_val_s, X_test_s, _ = scale_features(X_train, X_val, X_test)
139
+
140
+ train_size = len(X_train)
141
+ val_size = len(X_val)
142
+
143
+ # Test dates (aligned with y_test)
144
+ test_start = lookback + train_size + val_size
145
+ test_dates = df.index[test_start: test_start + len(X_test)]
146
+ test_slice = slice(test_start, test_start + len(X_test))
147
+
148
+ st.success(f"✅ Sequences — Train: {train_size} · Val: {val_size} · Test: {len(X_test)}")
149
+
150
+ # ── Train all three approaches ────────────────────────────────────────────────
151
+ results = {}
152
+ trained_info = {} # store extra info needed for conviction
153
+
154
+ progress = st.progress(0, text="Starting training...")
155
+
156
+ # ── Approach 1: Wavelet ───────────────────────────────────────────────────────
157
+ with st.spinner("🌊 Training Approach 1 — Wavelet CNN-LSTM..."):
158
+ try:
159
+ model1, hist1, _ = train_approach1(
160
+ X_train_s, y_train_l,
161
+ X_val_s, y_val_l,
162
+ n_classes=n_classes, epochs=int(epochs),
163
+ )
164
+ preds1, proba1 = predict_approach1(model1, X_test_s)
165
+ results["Approach 1"] = execute_strategy(
166
+ preds1, proba1, y_test_r, test_dates, target_etfs, fee_bps, tbill_rate, include_cash,
167
+ )
168
+ trained_info["Approach 1"] = {"proba": proba1}
169
+ st.success("✅ Approach 1 complete")
170
+ except Exception as e:
171
+ st.warning(f"⚠️ Approach 1 failed: {e}")
172
+ results["Approach 1"] = None
173
+
174
+ progress.progress(33, text="Approach 1 done...")
175
+
176
+ # ── Approach 2: Regime-Conditioned ───────────────────────────────────────────
177
+ with st.spinner("🔀 Training Approach 2 — Regime-Conditioned CNN-LSTM..."):
178
+ try:
179
+ model2, hist2, hmm2, regime_cols2 = train_approach2(
180
+ X_train_s, y_train_l,
181
+ X_val_s, y_val_l,
182
+ X_flat_all=X_raw,
183
+ feature_names=input_features,
184
+ lookback=lookback,
185
+ train_size=train_size,
186
+ val_size=val_size,
187
+ n_classes=n_classes, epochs=int(epochs),
188
+ )
189
+ preds2, proba2 = predict_approach2(
190
+ model2, X_test_s, X_raw, regime_cols2, hmm2,
191
+ lookback, train_size, val_size,
192
+ )
193
+ results["Approach 2"] = execute_strategy(
194
+ preds2, proba2, y_test_r, test_dates, target_etfs, fee_bps, tbill_rate, include_cash,
195
+ )
196
+ trained_info["Approach 2"] = {"proba": proba2}
197
+ st.success("✅ Approach 2 complete")
198
+ except Exception as e:
199
+ st.warning(f"⚠️ Approach 2 failed: {e}")
200
+ results["Approach 2"] = None
201
+
202
+ progress.progress(66, text="Approach 2 done...")
203
+
204
+ # ── Approach 3: Multi-Scale ───────────────────────────────────────────────────
205
+ with st.spinner("📡 Training Approach 3 — Multi-Scale CNN-LSTM..."):
206
+ try:
207
+ model3, hist3 = train_approach3(
208
+ X_train_s, y_train_l,
209
+ X_val_s, y_val_l,
210
+ n_classes=n_classes, epochs=int(epochs),
211
+ )
212
+ preds3, proba3 = predict_approach3(model3, X_test_s)
213
+ results["Approach 3"] = execute_strategy(
214
+ preds3, proba3, y_test_r, test_dates, target_etfs, fee_bps, tbill_rate, include_cash,
215
+ )
216
+ trained_info["Approach 3"] = {"proba": proba3}
217
+ st.success("✅ Approach 3 complete")
218
+ except Exception as e:
219
+ st.warning(f"⚠️ Approach 3 failed: {e}")
220
+ results["Approach 3"] = None
221
+
222
+ progress.progress(100, text="All approaches complete!")
223
+ progress.empty()
224
+
225
+ # ── Select winner ─────────────────────────────────────────────────────────────
226
+ winner_name = select_winner(results)
227
+ winner_res = results.get(winner_name)
228
+
229
+ if winner_res is None:
230
+ st.error("❌ All approaches failed. Please check your data and configuration.")
231
+ st.stop()
232
+
233
+ # ── Next trading date ─────────────────────────────────────────────────────────
234
+ next_date = get_next_signal_date()
235
+
236
+ st.divider()
237
+
238
+ # ── Signal banner (winner) ────────────────────────────────────────────────────
239
+ show_signal_banner(winner_res["next_signal"], next_date, winner_name)
240
+
241
+ # ── Conviction panel ──────────────────────────────────────────────────────────
242
+ winner_proba = trained_info[winner_name]["proba"]
243
+ conviction = compute_conviction(winner_proba[-1], target_etfs, include_cash)
244
+ show_conviction_panel(conviction)
245
+
246
+ st.divider()
247
+
248
+ # ── Winner metrics ────────────────────────────────────────────────────────────
249
+ st.subheader(f"📊 {winner_name} — Performance Metrics")
250
+ show_metrics_row(winner_res, tbill_rate)
251
+
252
+ st.divider()
253
+
254
+ # ── Comparison table ──────────────────────────────────────────────────────────
255
+ st.subheader("🏆 Approach Comparison (Winner = Highest Raw Annualised Return)")
256
+ comparison_df = build_comparison_table(results, winner_name)
257
+ show_comparison_table(comparison_df)
258
+
259
+ # ── Comparison bar chart ──────────────────────────────────────────────────────
260
+ st.plotly_chart(comparison_bar_chart(results, winner_name), use_container_width=True)
261
+
262
+ st.divider()
263
+
264
+ # ── Equity curves ─────────────────────────────────────────────────────────────
265
+ st.subheader("📈 Out-of-Sample Equity Curves — All Approaches vs Benchmarks")
266
+ fig = equity_curve_chart(results, winner_name, test_dates, df, test_slice, tbill_rate)
267
+ st.plotly_chart(fig, use_container_width=True)
268
+
269
+ st.divider()
270
+
271
+ # ── Audit trail (winner) ──────────────────────────────────────────────────────
272
+ st.subheader(f"📋 Audit Trail — {winner_name} (Last 20 Trading Days)")
273
+ show_audit_trail(winner_res["audit_trail"])
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13.5-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y \
6
+ build-essential \
7
+ curl \
8
+ git \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ COPY requirements.txt ./
12
+ COPY src/ ./src/
13
+
14
+ RUN pip3 install -r requirements.txt
15
+
16
+ EXPOSE 8501
17
+
18
+ HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
19
+
20
+ ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/README.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: P2 ETF CNN LSTM ALTERNATIVE APPROACHES
3
+ emoji: 🚀
4
+ colorFrom: red
5
+ colorTo: red
6
+ sdk: docker
7
+ app_port: 8501
8
+ tags:
9
+ - streamlit
10
+ pinned: false
11
+ short_description: Streamlit template space
12
+ ---
13
+
14
+ # Welcome to Streamlit!
15
+
16
+ Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
17
+
18
+ If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
19
+ forums](https://discuss.streamlit.io).
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ altair
2
+ pandas
3
+ streamlit
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/src/streamlit_app.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import altair as alt
2
+ import numpy as np
3
+ import pandas as pd
4
+ import streamlit as st
5
+
6
+ """
7
+ # Welcome to Streamlit!
8
+
9
+ Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
+ If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
+ forums](https://discuss.streamlit.io).
12
+
13
+ In the meantime, below is an example of what you can do with just a few lines of code:
14
+ """
15
+
16
+ num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
+ num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
+
19
+ indices = np.linspace(0, 1, num_points)
20
+ theta = 2 * np.pi * num_turns * indices
21
+ radius = indices
22
+
23
+ x = radius * np.cos(theta)
24
+ y = radius * np.sin(theta)
25
+
26
+ df = pd.DataFrame({
27
+ "x": x,
28
+ "y": y,
29
+ "idx": indices,
30
+ "rand": np.random.randn(num_points),
31
+ })
32
+
33
+ st.altair_chart(alt.Chart(df, height=700, width=700)
34
+ .mark_point(filled=True)
35
+ .encode(
36
+ x=alt.X("x", axis=None),
37
+ y=alt.Y("y", axis=None),
38
+ color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
+ size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
+ ))
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/requirements.txt CHANGED
@@ -1,3 +1,29 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core
2
+ streamlit>=1.32.0
3
+ pandas>=2.0.0
4
+ numpy>=1.24.0
5
+
6
+ # Hugging Face
7
+ huggingface_hub>=0.21.0
8
+ datasets>=2.18.0
9
+
10
+ # Machine Learning
11
+ tensorflow>=2.14.0
12
+ scikit-learn>=1.3.0
13
+ xgboost>=2.0.0
14
+
15
+ # Wavelet (Approach 1)
16
+ PyWavelets>=1.5.0
17
+
18
+ # Regime detection (Approach 2)
19
+ hmmlearn>=0.3.0
20
+
21
+ # Visualisation
22
+ plotly>=5.18.0
23
+
24
+ # NYSE Calendar
25
+ pandas_market_calendars>=4.3.0
26
+ pytz>=2024.1
27
+
28
+ # Parquet
29
+ pyarrow>=14.0.0
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/models/base.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models/base.py
3
+ Shared utilities for all three CNN-LSTM variants:
4
+ - Data preparation (sequences, train/val/test split)
5
+ - Common Keras layers / callbacks
6
+ - Predict + evaluate helpers
7
+ """
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from sklearn.preprocessing import RobustScaler
12
+ import tensorflow as tf
13
+ from tensorflow import keras
14
+
15
+ # ── Reproducibility ───────────────────────────────────────────────────────────
16
+ SEED = 42
17
+ tf.random.set_seed(SEED)
18
+ np.random.seed(SEED)
19
+
20
+
21
+ # ── Sequence builder ──────────────────────────────────────────────────────────
22
+
23
+ def build_sequences(features: np.ndarray, targets: np.ndarray, lookback: int):
24
+ """
25
+ Build supervised sequences for CNN-LSTM input.
26
+
27
+ Args:
28
+ features : 2-D array [n_days, n_features]
29
+ targets : 2-D array [n_days, n_etfs] (raw returns)
30
+ lookback : number of past days per sample
31
+
32
+ Returns:
33
+ X : [n_samples, lookback, n_features]
34
+ y : [n_samples, n_etfs] (raw returns for the next day)
35
+ """
36
+ X, y = [], []
37
+ for i in range(lookback, len(features)):
38
+ X.append(features[i - lookback: i])
39
+ y.append(targets[i])
40
+ return np.array(X, dtype=np.float32), np.array(y, dtype=np.float32)
41
+
42
+
43
+ # ── Train / val / test split ──────────────────────────────────────────────────
44
+
45
+ def train_val_test_split(X, y, train_pct=0.70, val_pct=0.15):
46
+ """Split sequences into train / val / test preserving temporal order."""
47
+ n = len(X)
48
+ t1 = int(n * train_pct)
49
+ t2 = int(n * (train_pct + val_pct))
50
+
51
+ return (
52
+ X[:t1], y[:t1],
53
+ X[t1:t2], y[t1:t2],
54
+ X[t2:], y[t2:],
55
+ )
56
+
57
+
58
+ # ── Feature scaling ───────────────────────────────────────────────────────────
59
+
60
+ def scale_features(X_train, X_val, X_test):
61
+ """
62
+ Fit RobustScaler on training data only, apply to val and test.
63
+ Operates on the flattened feature dimension.
64
+
65
+ Returns scaled arrays with same shape as inputs.
66
+ """
67
+ n_train, lb, n_feat = X_train.shape
68
+ scaler = RobustScaler()
69
+
70
+ # Fit on train
71
+ scaler.fit(X_train.reshape(-1, n_feat))
72
+
73
+ def _transform(X):
74
+ shape = X.shape
75
+ return scaler.transform(X.reshape(-1, n_feat)).reshape(shape)
76
+
77
+ return _transform(X_train), _transform(X_val), _transform(X_test), scaler
78
+
79
+
80
+ # ── Label builder (classification: argmax of returns) ────────────────────────
81
+
82
+ def returns_to_labels(y_raw, include_cash=True, cash_threshold=0.0):
83
+ """
84
+ Convert raw return matrix to integer class labels.
85
+
86
+ If include_cash=True, adds a CASH class (index = n_etfs) when
87
+ the best ETF return is below cash_threshold.
88
+
89
+ Args:
90
+ y_raw : [n_samples, n_etfs]
91
+ include_cash : whether to allow CASH class
92
+ cash_threshold : minimum ETF return to prefer over CASH
93
+
94
+ Returns:
95
+ labels : [n_samples] integer class indices
96
+ """
97
+ best = np.argmax(y_raw, axis=1)
98
+ if include_cash:
99
+ best_return = y_raw[np.arange(len(y_raw)), best]
100
+ cash_idx = y_raw.shape[1]
101
+ labels = np.where(best_return < cash_threshold, cash_idx, best)
102
+ else:
103
+ labels = best
104
+ return labels.astype(np.int32)
105
+
106
+
107
+ # ── Common Keras callbacks ────────────────────────────────────────────────────
108
+
109
+ def get_callbacks(patience_es=15, patience_lr=8, min_lr=1e-6):
110
+ """Standard early stopping + reduce-LR callbacks shared by all models."""
111
+ return [
112
+ keras.callbacks.EarlyStopping(
113
+ monitor="val_loss",
114
+ patience=patience_es,
115
+ restore_best_weights=True,
116
+ verbose=0,
117
+ ),
118
+ keras.callbacks.ReduceLROnPlateau(
119
+ monitor="val_loss",
120
+ factor=0.5,
121
+ patience=patience_lr,
122
+ min_lr=min_lr,
123
+ verbose=0,
124
+ ),
125
+ ]
126
+
127
+
128
+ # ── Common output head ────────────────────────────────────────────────────────
129
+
130
+ def classification_head(x, n_classes: int, dropout: float = 0.3):
131
+ """
132
+ Shared dense output head for all three CNN-LSTM variants.
133
+
134
+ Args:
135
+ x : input tensor
136
+ n_classes : number of ETF classes (+ 1 for CASH if applicable)
137
+ dropout : dropout rate
138
+
139
+ Returns:
140
+ output tensor with softmax activation
141
+ """
142
+ x = keras.layers.Dense(64, activation="relu")(x)
143
+ x = keras.layers.Dropout(dropout)(x)
144
+ x = keras.layers.Dense(n_classes, activation="softmax")(x)
145
+ return x
146
+
147
+
148
+ # ── Prediction helper ─────────────────────────────────────────────────────────
149
+
150
+ def predict_classes(model, X_test: np.ndarray) -> np.ndarray:
151
+ """Return integer class predictions from a Keras model."""
152
+ proba = model.predict(X_test, verbose=0)
153
+ return np.argmax(proba, axis=1), proba
154
+
155
+
156
+ # ── Metrics helper ────────────────────────────────────────────────────────────
157
+
158
+ def evaluate_returns(
159
+ preds: np.ndarray,
160
+ proba: np.ndarray,
161
+ y_raw_test: np.ndarray,
162
+ target_etfs: list,
163
+ tbill_rate: float,
164
+ fee_bps: int,
165
+ include_cash: bool = True,
166
+ ):
167
+ """
168
+ Given integer class predictions and raw return matrix,
169
+ compute strategy returns and summary metrics.
170
+
171
+ Returns:
172
+ strat_rets : np.ndarray of daily net returns
173
+ ann_return : annualised return (float)
174
+ cum_returns : cumulative return series
175
+ last_proba : probability vector for the last prediction
176
+ next_etf : name of ETF predicted for next session
177
+ """
178
+ n_etfs = len(target_etfs)
179
+ strat_rets = []
180
+
181
+ for i, cls in enumerate(preds):
182
+ if include_cash and cls == n_etfs:
183
+ # CASH: earn daily T-bill rate
184
+ daily_tbill = tbill_rate / 252
185
+ net = daily_tbill - (fee_bps / 10000)
186
+ else:
187
+ ret = y_raw_test[i][cls]
188
+ net = ret - (fee_bps / 10000)
189
+ strat_rets.append(net)
190
+
191
+ strat_rets = np.array(strat_rets)
192
+ cum_returns = np.cumprod(1 + strat_rets)
193
+ ann_return = (cum_returns[-1] ** (252 / len(strat_rets))) - 1
194
+
195
+ last_proba = proba[-1]
196
+ next_cls = int(np.argmax(last_proba))
197
+ next_etf = "CASH" if (include_cash and next_cls == n_etfs) else target_etfs[next_cls].replace("_Ret", "")
198
+
199
+ return strat_rets, ann_return, cum_returns, last_proba, next_etf
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/models/approach1_wavelet.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models/approach1_wavelet.py
3
+ Approach 1: Wavelet Decomposition CNN-LSTM
4
+
5
+ Pipeline:
6
+ Raw macro signals
7
+ → DWT (db4, level=3) per signal → multi-band channel stack
8
+ → 1D CNN (64 filters, k=3) → MaxPool → (32 filters, k=3)
9
+ → LSTM (128 units)
10
+ → Dense 64 → Softmax (n_etfs + 1 CASH)
11
+ """
12
+
13
+ import numpy as np
14
+ import pywt
15
+ import tensorflow as tf
16
+ from tensorflow import keras
17
+ from models.base import classification_head, get_callbacks
18
+
19
+ WAVELET = "db4"
20
+ LEVEL = 3
21
+
22
+
23
+ # ── Wavelet feature engineering ───────────────────────────────────────────────
24
+
25
+ def _wavelet_decompose_signal(signal: np.ndarray, wavelet: str, level: int) -> np.ndarray:
26
+ """
27
+ Decompose a 1-D signal into DWT subbands and return them stacked.
28
+
29
+ For a signal of length T:
30
+ coeffs = [cA_n, cD_n, cD_{n-1}, ..., cD_1]
31
+ We interpolate each subband back to length T so we can stack them.
32
+
33
+ Returns: array of shape [T, level+1]
34
+ """
35
+ T = len(signal)
36
+ coeffs = pywt.wavedec(signal, wavelet, level=level)
37
+ bands = []
38
+ for c in coeffs:
39
+ # Interpolate back to original length
40
+ band = np.interp(
41
+ np.linspace(0, len(c) - 1, T),
42
+ np.arange(len(c)),
43
+ c,
44
+ )
45
+ bands.append(band)
46
+ return np.stack(bands, axis=-1) # [T, level+1]
47
+
48
+
49
+ def apply_wavelet_transform(X: np.ndarray, wavelet: str = WAVELET, level: int = LEVEL) -> np.ndarray:
50
+ """
51
+ Apply DWT to every feature channel across all samples.
52
+
53
+ Args:
54
+ X : [n_samples, lookback, n_features]
55
+
56
+ Returns:
57
+ X_wt : [n_samples, lookback, n_features * (level+1)]
58
+ """
59
+ n_samples, lookback, n_features = X.shape
60
+ n_bands = level + 1
61
+ X_wt = np.zeros((n_samples, lookback, n_features * n_bands), dtype=np.float32)
62
+
63
+ for s in range(n_samples):
64
+ for f in range(n_features):
65
+ decomposed = _wavelet_decompose_signal(X[s, :, f], wavelet, level) # [T, n_bands]
66
+ start = f * n_bands
67
+ X_wt[s, :, start: start + n_bands] = decomposed
68
+
69
+ return X_wt
70
+
71
+
72
+ # ── Model builder ─────────────────────────────────────────────────────────────
73
+
74
+ def build_wavelet_cnn_lstm(
75
+ input_shape: tuple,
76
+ n_classes: int,
77
+ dropout: float = 0.3,
78
+ lstm_units: int = 128,
79
+ ) -> keras.Model:
80
+ """
81
+ Build Wavelet CNN-LSTM model.
82
+
83
+ Args:
84
+ input_shape : (lookback, n_features * n_bands) — post-DWT shape
85
+ n_classes : number of output classes (ETFs + CASH)
86
+ dropout : dropout rate
87
+ lstm_units : LSTM hidden size
88
+
89
+ Returns:
90
+ Compiled Keras model
91
+ """
92
+ inputs = keras.Input(shape=input_shape, name="wavelet_input")
93
+
94
+ # CNN block 1
95
+ x = keras.layers.Conv1D(64, kernel_size=3, padding="causal", activation="relu")(inputs)
96
+ x = keras.layers.BatchNormalization()(x)
97
+ x = keras.layers.MaxPooling1D(pool_size=2)(x)
98
+
99
+ # CNN block 2
100
+ x = keras.layers.Conv1D(32, kernel_size=3, padding="causal", activation="relu")(x)
101
+ x = keras.layers.BatchNormalization()(x)
102
+ x = keras.layers.Dropout(dropout)(x)
103
+
104
+ # LSTM
105
+ x = keras.layers.LSTM(lstm_units, dropout=dropout, recurrent_dropout=0.1)(x)
106
+
107
+ # Output head
108
+ outputs = classification_head(x, n_classes, dropout)
109
+
110
+ model = keras.Model(inputs, outputs, name="Approach1_Wavelet_CNN_LSTM")
111
+ model.compile(
112
+ optimizer=keras.optimizers.Adam(learning_rate=1e-3),
113
+ loss="sparse_categorical_crossentropy",
114
+ metrics=["accuracy"],
115
+ )
116
+ return model
117
+
118
+
119
+ # ── Full train pipeline ───────────────────────────────────────────────────────
120
+
121
+ def train_approach1(
122
+ X_train, y_train,
123
+ X_val, y_val,
124
+ n_classes: int,
125
+ epochs: int = 100,
126
+ batch_size: int = 32,
127
+ dropout: float = 0.3,
128
+ lstm_units: int = 128,
129
+ ):
130
+ """
131
+ Apply wavelet transform then train the CNN-LSTM.
132
+
133
+ Args:
134
+ X_train/val : [n, lookback, n_features] (scaled, pre-wavelet)
135
+ y_train/val : [n] integer class labels
136
+ n_classes : total output classes
137
+
138
+ Returns:
139
+ model : trained Keras model
140
+ history : training history
141
+ wt_shape : post-DWT input shape (for inference)
142
+ """
143
+ # Apply DWT
144
+ X_train_wt = apply_wavelet_transform(X_train)
145
+ X_val_wt = apply_wavelet_transform(X_val)
146
+
147
+ input_shape = X_train_wt.shape[1:] # (lookback, n_features * n_bands)
148
+ model = build_wavelet_cnn_lstm(input_shape, n_classes, dropout, lstm_units)
149
+
150
+ history = model.fit(
151
+ X_train_wt, y_train,
152
+ validation_data=(X_val_wt, y_val),
153
+ epochs=epochs,
154
+ batch_size=batch_size,
155
+ callbacks=get_callbacks(),
156
+ verbose=0,
157
+ )
158
+
159
+ return model, history, input_shape
160
+
161
+
162
+ def predict_approach1(model, X_test: np.ndarray) -> tuple:
163
+ """Apply DWT to test set then predict. Returns (class_preds, proba)."""
164
+ X_test_wt = apply_wavelet_transform(X_test)
165
+ proba = model.predict(X_test_wt, verbose=0)
166
+ preds = np.argmax(proba, axis=1)
167
+ return preds, proba
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/models/approach2_regime.py ADDED
@@ -0,0 +1 @@
 
 
1
+
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/models/approach3_multiscale.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models/approach3_multiscale.py
3
+ Approach 3: Multi-Scale Parallel CNN-LSTM
4
+
5
+ Pipeline:
6
+ Raw macro signals
7
+ → 3 parallel CNN towers: kernel 3 (short), 7 (medium), 21 (long)
8
+ → Concatenate [96 features]
9
+ → LSTM (128 units)
10
+ → Dense 64 → Softmax (n_etfs + 1 CASH)
11
+ """
12
+
13
+ import numpy as np
14
+ import tensorflow as tf
15
+ from tensorflow import keras
16
+ from models.base import classification_head, get_callbacks
17
+
18
+ # Kernel sizes represent: momentum (3d), weekly cycle (7d), monthly trend (21d)
19
+ KERNEL_SIZES = [3, 7, 21]
20
+ FILTERS_EACH = 32 # 32 × 3 towers = 96 concatenated features
21
+
22
+
23
+ # ── Model builder ─────────────────────────────────────────────────────────────
24
+
25
+ def build_multiscale_cnn_lstm(
26
+ input_shape: tuple,
27
+ n_classes: int,
28
+ kernel_sizes: list = None,
29
+ filters: int = FILTERS_EACH,
30
+ dropout: float = 0.3,
31
+ lstm_units: int = 128,
32
+ ) -> keras.Model:
33
+ """
34
+ Multi-scale parallel CNN-LSTM.
35
+
36
+ Three CNN towers with different kernel sizes run in parallel on the
37
+ same input, capturing momentum, weekly cycle, and monthly trend
38
+ simultaneously. Their outputs are concatenated before the LSTM.
39
+
40
+ Args:
41
+ input_shape : (lookback, n_features)
42
+ n_classes : number of output classes (ETFs + CASH)
43
+ kernel_sizes : list of kernel sizes for each tower
44
+ filters : number of Conv1D filters per tower
45
+ dropout : dropout rate
46
+ lstm_units : LSTM hidden size
47
+
48
+ Returns:
49
+ Compiled Keras model
50
+ """
51
+ if kernel_sizes is None:
52
+ kernel_sizes = KERNEL_SIZES
53
+
54
+ inputs = keras.Input(shape=input_shape, name="multiscale_input")
55
+
56
+ towers = []
57
+ for k in kernel_sizes:
58
+ # Each tower: Conv → BN → Conv → BN → GlobalAvgPool
59
+ t = keras.layers.Conv1D(
60
+ filters, kernel_size=k, padding="causal", activation="relu",
61
+ name=f"conv1_k{k}"
62
+ )(inputs)
63
+ t = keras.layers.BatchNormalization(name=f"bn1_k{k}")(t)
64
+ t = keras.layers.Conv1D(
65
+ filters, kernel_size=k, padding="causal", activation="relu",
66
+ name=f"conv2_k{k}"
67
+ )(t)
68
+ t = keras.layers.BatchNormalization(name=f"bn2_k{k}")(t)
69
+ t = keras.layers.Dropout(dropout, name=f"drop_k{k}")(t)
70
+ towers.append(t)
71
+
72
+ # Concatenate along the feature dimension — keeps temporal axis intact for LSTM
73
+ if len(towers) > 1:
74
+ merged = keras.layers.Concatenate(axis=-1, name="tower_concat")(towers)
75
+ else:
76
+ merged = towers[0]
77
+
78
+ # LSTM integrates multi-scale temporal features
79
+ x = keras.layers.LSTM(lstm_units, dropout=dropout, recurrent_dropout=0.1, name="lstm")(merged)
80
+
81
+ # Output head
82
+ outputs = classification_head(x, n_classes, dropout)
83
+
84
+ model = keras.Model(inputs, outputs, name="Approach3_MultiScale_CNN_LSTM")
85
+ model.compile(
86
+ optimizer=keras.optimizers.Adam(learning_rate=1e-3),
87
+ loss="sparse_categorical_crossentropy",
88
+ metrics=["accuracy"],
89
+ )
90
+ return model
91
+
92
+
93
+ # ── Full train pipeline ───────────────────────────────────────────────────────
94
+
95
+ def train_approach3(
96
+ X_train, y_train,
97
+ X_val, y_val,
98
+ n_classes: int,
99
+ epochs: int = 100,
100
+ batch_size: int = 32,
101
+ dropout: float = 0.3,
102
+ lstm_units: int = 128,
103
+ kernel_sizes: list = None,
104
+ ):
105
+ """
106
+ Build and train the multi-scale CNN-LSTM.
107
+
108
+ Args:
109
+ X_train/val : [n, lookback, n_features]
110
+ y_train/val : [n] integer class labels
111
+ n_classes : total output classes
112
+
113
+ Returns:
114
+ model : trained Keras model
115
+ history : training history
116
+ """
117
+ if kernel_sizes is None:
118
+ kernel_sizes = KERNEL_SIZES
119
+
120
+ # Guard: lookback must be >= largest kernel
121
+ lookback = X_train.shape[1]
122
+ valid_kernels = [k for k in kernel_sizes if k <= lookback]
123
+ if not valid_kernels:
124
+ valid_kernels = [min(3, lookback)]
125
+
126
+ model = build_multiscale_cnn_lstm(
127
+ input_shape=X_train.shape[1:],
128
+ n_classes=n_classes,
129
+ kernel_sizes=valid_kernels,
130
+ dropout=dropout,
131
+ lstm_units=lstm_units,
132
+ )
133
+
134
+ history = model.fit(
135
+ X_train, y_train,
136
+ validation_data=(X_val, y_val),
137
+ epochs=epochs,
138
+ batch_size=batch_size,
139
+ callbacks=get_callbacks(),
140
+ verbose=0,
141
+ )
142
+
143
+ return model, history
144
+
145
+
146
+ def predict_approach3(model, X_test: np.ndarray) -> tuple:
147
+ """Predict on test set. Returns (class_preds, proba)."""
148
+ proba = model.predict(X_test, verbose=0)
149
+ preds = np.argmax(proba, axis=1)
150
+ return preds, proba
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/strategy/backtest.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ strategy/backtest.py
3
+ Strategy execution, performance metrics, and benchmark calculations.
4
+ Supports CASH as a class (earns T-bill rate when selected).
5
+ """
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from datetime import datetime
10
+
11
+
12
+ # ── Strategy execution ────────────────────────────────────────────────────────
13
+
14
+ def execute_strategy(
15
+ preds: np.ndarray,
16
+ proba: np.ndarray,
17
+ y_raw_test: np.ndarray,
18
+ test_dates: pd.DatetimeIndex,
19
+ target_etfs: list,
20
+ fee_bps: int,
21
+ tbill_rate: float,
22
+ include_cash: bool = True,
23
+ ) -> dict:
24
+ """
25
+ Execute strategy from model predictions.
26
+
27
+ Args:
28
+ preds : [n] integer class predictions
29
+ proba : [n, n_classes] softmax probabilities
30
+ y_raw_test : [n, n_etfs] actual next-day ETF returns
31
+ test_dates : DatetimeIndex aligned with y_raw_test
32
+ target_etfs : list of ETF return column names e.g. ["TLT_Ret", ...]
33
+ fee_bps : transaction fee in basis points
34
+ tbill_rate : annualised 3m T-bill rate (e.g. 0.045)
35
+ include_cash: whether CASH is a valid class (index = n_etfs)
36
+
37
+ Returns:
38
+ dict with keys:
39
+ strat_rets, cum_returns, ann_return, sharpe,
40
+ hit_ratio, max_dd, max_daily_dd, cum_max,
41
+ audit_trail, next_signal, next_proba
42
+ """
43
+ n_etfs = len(target_etfs)
44
+ daily_tbill = tbill_rate / 252
45
+ today = datetime.now().date()
46
+
47
+ strat_rets = []
48
+ audit_trail = []
49
+
50
+ for i, cls in enumerate(preds):
51
+ if include_cash and cls == n_etfs:
52
+ signal_etf = "CASH"
53
+ realized_ret = daily_tbill
54
+ else:
55
+ cls = min(cls, n_etfs - 1)
56
+ signal_etf = target_etfs[cls].replace("_Ret", "")
57
+ realized_ret = float(y_raw_test[i][cls])
58
+
59
+ net_ret = realized_ret - (fee_bps / 10000)
60
+ strat_rets.append(net_ret)
61
+
62
+ trade_date = test_dates[i]
63
+ if trade_date.date() < today:
64
+ audit_trail.append({
65
+ "Date": trade_date.strftime("%Y-%m-%d"),
66
+ "Signal": signal_etf,
67
+ "Realized": realized_ret,
68
+ "Net_Return": net_ret,
69
+ })
70
+
71
+ strat_rets = np.array(strat_rets, dtype=np.float64)
72
+
73
+ # Next signal (last prediction)
74
+ last_cls = int(preds[-1])
75
+ next_proba = proba[-1]
76
+
77
+ if include_cash and last_cls == n_etfs:
78
+ next_signal = "CASH"
79
+ else:
80
+ last_cls = min(last_cls, n_etfs - 1)
81
+ next_signal = target_etfs[last_cls].replace("_Ret", "")
82
+
83
+ metrics = _compute_metrics(strat_rets, tbill_rate)
84
+
85
+ return {
86
+ **metrics,
87
+ "strat_rets": strat_rets,
88
+ "audit_trail": audit_trail,
89
+ "next_signal": next_signal,
90
+ "next_proba": next_proba,
91
+ }
92
+
93
+
94
+ # ── Performance metrics ───────────────────────────────────────────────────────
95
+
96
+ def _compute_metrics(strat_rets: np.ndarray, tbill_rate: float) -> dict:
97
+ if len(strat_rets) == 0:
98
+ return {}
99
+
100
+ cum_returns = np.cumprod(1 + strat_rets)
101
+ n = len(strat_rets)
102
+ ann_return = float(cum_returns[-1] ** (252 / n) - 1)
103
+
104
+ excess = strat_rets - tbill_rate / 252
105
+ sharpe = float(np.mean(excess) / (np.std(strat_rets) + 1e-9) * np.sqrt(252))
106
+
107
+ recent = strat_rets[-15:]
108
+ hit_ratio = float(np.mean(recent > 0))
109
+
110
+ cum_max = np.maximum.accumulate(cum_returns)
111
+ drawdown = (cum_returns - cum_max) / cum_max
112
+ max_dd = float(np.min(drawdown))
113
+ max_daily = float(np.min(strat_rets))
114
+
115
+ return {
116
+ "cum_returns": cum_returns,
117
+ "ann_return": ann_return,
118
+ "sharpe": sharpe,
119
+ "hit_ratio": hit_ratio,
120
+ "max_dd": max_dd,
121
+ "max_daily_dd":max_daily,
122
+ "cum_max": cum_max,
123
+ }
124
+
125
+
126
+ def compute_benchmark_metrics(returns: np.ndarray, tbill_rate: float) -> dict:
127
+ """Compute metrics for a benchmark return series."""
128
+ return _compute_metrics(returns, tbill_rate)
129
+
130
+
131
+ # ── Winner selection ──────────────────────────────────────────────────────────
132
+
133
+ def select_winner(results: dict) -> str:
134
+ """
135
+ Given a dict of {approach_name: result_dict}, return the approach name
136
+ with the highest annualised return (raw, not risk-adjusted).
137
+
138
+ Args:
139
+ results : {"Approach 1": {...}, "Approach 2": {...}, "Approach 3": {...}}
140
+
141
+ Returns:
142
+ winner_name : str
143
+ """
144
+ best_name = None
145
+ best_return = -np.inf
146
+
147
+ for name, res in results.items():
148
+ if res is None:
149
+ continue
150
+ ret = res.get("ann_return", -np.inf)
151
+ if ret > best_return:
152
+ best_return = ret
153
+ best_name = name
154
+
155
+ return best_name
156
+
157
+
158
+ # ── Comparison table ──────────────────────────────────────────────────────────
159
+
160
+ def build_comparison_table(results: dict, winner_name: str) -> pd.DataFrame:
161
+ """
162
+ Build a summary DataFrame comparing all three approaches.
163
+
164
+ Args:
165
+ results : {name: result_dict}
166
+ winner_name : name of the winner
167
+
168
+ Returns:
169
+ pd.DataFrame with one row per approach
170
+ """
171
+ rows = []
172
+ for name, res in results.items():
173
+ if res is None:
174
+ rows.append({
175
+ "Approach": name,
176
+ "Ann. Return": "N/A",
177
+ "Sharpe": "N/A",
178
+ "Hit Ratio (15d)":"N/A",
179
+ "Max Drawdown": "N/A",
180
+ "Winner": "",
181
+ })
182
+ continue
183
+
184
+ rows.append({
185
+ "Approach": name,
186
+ "Ann. Return": f"{res['ann_return']*100:.2f}%",
187
+ "Sharpe": f"{res['sharpe']:.2f}",
188
+ "Hit Ratio (15d)": f"{res['hit_ratio']*100:.0f}%",
189
+ "Max Drawdown": f"{res['max_dd']*100:.2f}%",
190
+ "Winner": "⭐ WINNER" if name == winner_name else "",
191
+ })
192
+
193
+ return pd.DataFrame(rows)
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/signals/conviction.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ signals/conviction.py
3
+ Signal conviction scoring via Z-score of model probabilities.
4
+ """
5
+
6
+ import numpy as np
7
+
8
+
9
+ CONVICTION_THRESHOLDS = {
10
+ "Very High": 2.0,
11
+ "High": 1.0,
12
+ "Moderate": 0.0,
13
+ # Below 0.0 → "Low"
14
+ }
15
+
16
+
17
+ def compute_conviction(proba: np.ndarray, target_etfs: list, include_cash: bool = True) -> dict:
18
+ """
19
+ Compute Z-score conviction for the selected signal.
20
+
21
+ Args:
22
+ proba : 1-D softmax probability vector [n_classes]
23
+ target_etfs : list of ETF return column names (e.g. ["TLT_Ret", ...])
24
+ include_cash: whether CASH is the last class
25
+
26
+ Returns:
27
+ dict with keys:
28
+ best_idx : int
29
+ best_name : str (ETF ticker or "CASH")
30
+ z_score : float
31
+ label : str ("Very High" / "High" / "Moderate" / "Low")
32
+ scores : np.ndarray (raw proba)
33
+ etf_names : list of display names
34
+ sorted_pairs : list of (name, score) sorted high→low
35
+ """
36
+ scores = np.array(proba, dtype=float)
37
+ best_idx = int(np.argmax(scores))
38
+ n_etfs = len(target_etfs)
39
+
40
+ # Display names
41
+ etf_names = [e.replace("_Ret", "") for e in target_etfs]
42
+ if include_cash:
43
+ etf_names = etf_names + ["CASH"]
44
+
45
+ best_name = etf_names[best_idx] if best_idx < len(etf_names) else "CASH"
46
+
47
+ # Z-score
48
+ mean = np.mean(scores)
49
+ std = np.std(scores)
50
+ z = float((scores[best_idx] - mean) / std) if std > 1e-9 else 0.0
51
+
52
+ # Label
53
+ label = "Low"
54
+ for lbl, threshold in CONVICTION_THRESHOLDS.items():
55
+ if z >= threshold:
56
+ label = lbl
57
+ break
58
+
59
+ # Sorted pairs for UI bar chart
60
+ sorted_pairs = sorted(
61
+ zip(etf_names, scores),
62
+ key=lambda x: x[1],
63
+ reverse=True,
64
+ )
65
+
66
+ return {
67
+ "best_idx": best_idx,
68
+ "best_name": best_name,
69
+ "z_score": z,
70
+ "label": label,
71
+ "scores": scores,
72
+ "etf_names": etf_names,
73
+ "sorted_pairs": sorted_pairs,
74
+ }
75
+
76
+
77
+ def conviction_color(label: str) -> str:
78
+ """Return hex accent colour for a conviction label."""
79
+ return {
80
+ "Very High": "#00b894",
81
+ "High": "#00cec9",
82
+ "Moderate": "#fdcb6e",
83
+ "Low": "#d63031",
84
+ }.get(label, "#888888")
85
+
86
+
87
+ def conviction_icon(label: str) -> str:
88
+ return {
89
+ "Very High": "🟢",
90
+ "High": "🟢",
91
+ "Moderate": "🟡",
92
+ "Low": "🔴",
93
+ }.get(label, "⚪")
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/ui/components.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ui/components.py
3
+ Reusable Streamlit UI blocks:
4
+ - Freshness warning banner
5
+ - Next trading day signal banner
6
+ - Signal conviction panel
7
+ - Metrics row
8
+ - Audit trail table
9
+ - Comparison summary table
10
+ """
11
+
12
+ import streamlit as st
13
+ import pandas as pd
14
+ import numpy as np
15
+
16
+ from signals.conviction import conviction_color, conviction_icon
17
+
18
+
19
+ # ── Freshness warning ─────────────────────────────────────────────────────────
20
+
21
+ def show_freshness_status(freshness: dict):
22
+ """Display data freshness status. Stops app if data is stale."""
23
+ if freshness.get("fresh"):
24
+ st.success(freshness["message"])
25
+ else:
26
+ st.warning(freshness["message"])
27
+
28
+
29
+ # ── Next trading day banner ───────────────────────────────────────────────────
30
+
31
+ def show_signal_banner(next_signal: str, next_date, approach_name: str):
32
+ """Large coloured banner showing the winning approach's next signal."""
33
+ is_cash = next_signal == "CASH"
34
+ bg = "linear-gradient(135deg, #2d3436 0%, #1a1a2e 100%)" if is_cash else \
35
+ "linear-gradient(135deg, #00d1b2 0%, #00a896 100%)"
36
+
37
+ st.markdown(f"""
38
+ <div style="background:{bg}; padding:25px; border-radius:15px;
39
+ text-align:center; box-shadow:0 8px 16px rgba(0,0,0,0.3);
40
+ margin:16px 0;">
41
+ <div style="color:rgba(255,255,255,0.7); font-size:12px;
42
+ letter-spacing:3px; margin-bottom:6px;">
43
+ {approach_name.upper()} · NEXT TRADING DAY SIGNAL
44
+ </div>
45
+ <h1 style="color:white; font-size:44px; margin:0 0 8px 0;
46
+ font-weight:800; text-shadow:2px 2px 4px rgba(0,0,0,0.3);">
47
+ 🎯 {next_date.strftime('%Y-%m-%d')} → {next_signal}
48
+ </h1>
49
+ </div>
50
+ """, unsafe_allow_html=True)
51
+
52
+
53
+ # ── Signal conviction panel ───────────────────────────────────────────────────
54
+
55
+ def show_conviction_panel(conviction: dict):
56
+ """
57
+ White-background conviction panel with Z-score gauge and per-ETF bars.
58
+ Uses separate st.markdown calls per ETF row to avoid Streamlit HTML escaping.
59
+ """
60
+ label = conviction["label"]
61
+ z_score = conviction["z_score"]
62
+ best_name = conviction["best_name"]
63
+ sorted_pairs = conviction["sorted_pairs"]
64
+
65
+ color = conviction_color(label)
66
+ icon = conviction_icon(label)
67
+
68
+ z_clipped = max(-3.0, min(3.0, z_score))
69
+ bar_pct = int((z_clipped + 3) / 6 * 100)
70
+
71
+ max_score = max(s for _, s in sorted_pairs) if sorted_pairs else 1.0
72
+ if max_score <= 0:
73
+ max_score = 1.0
74
+
75
+ # ── Header + gauge ────────────────────────────────────────────────────────
76
+ st.markdown(f"""
77
+ <div style="background:#ffffff; border:1px solid #ddd;
78
+ border-left:5px solid {color}; border-radius:12px 12px 0 0;
79
+ padding:18px 24px 12px 24px; margin:12px 0 0 0;
80
+ box-shadow:0 2px 8px rgba(0,0,0,0.07);">
81
+
82
+ <div style="display:flex; align-items:center; gap:12px;
83
+ margin-bottom:14px; flex-wrap:wrap;">
84
+ <span style="font-size:20px;">{icon}</span>
85
+ <span style="font-size:18px; font-weight:700; color:#1a1a1a;">Signal Conviction</span>
86
+ <span style="background:#f0f0f0; border:1px solid {color};
87
+ color:{color}; font-weight:700; font-size:14px;
88
+ padding:3px 12px; border-radius:8px;">
89
+ Z = {z_score:.2f} &sigma;
90
+ </span>
91
+ <span style="margin-left:auto; background:{color}; color:#fff;
92
+ font-weight:700; padding:4px 16px;
93
+ border-radius:20px; font-size:13px;">
94
+ {label}
95
+ </span>
96
+ </div>
97
+
98
+ <div style="display:flex; justify-content:space-between;
99
+ font-size:11px; color:#999; margin-bottom:4px;">
100
+ <span>Weak &minus;3&sigma;</span>
101
+ <span>Neutral 0&sigma;</span>
102
+ <span>Strong +3&sigma;</span>
103
+ </div>
104
+ <div style="background:#f0f0f0; border-radius:8px; height:14px;
105
+ overflow:hidden; position:relative; border:1px solid #e0e0e0;
106
+ margin-bottom:14px;">
107
+ <div style="position:absolute; left:50%; top:0; width:2px;
108
+ height:100%; background:#ccc;"></div>
109
+ <div style="width:{bar_pct}%; height:100%;
110
+ background:linear-gradient(90deg,#fab1a0,{color});
111
+ border-radius:8px;"></div>
112
+ </div>
113
+
114
+ <div style="font-size:12px; color:#999; margin-bottom:2px;">
115
+ Model probability by ETF (ranked high &rarr; low):
116
+ </div>
117
+ </div>
118
+ """, unsafe_allow_html=True)
119
+
120
+ # ── Per-ETF rows ──────────────────────────────────────────────────────────
121
+ for i, (name, score) in enumerate(sorted_pairs):
122
+ is_winner = (name == best_name)
123
+ is_last = (i == len(sorted_pairs) - 1)
124
+ bar_w = int(score / max_score * 100)
125
+ name_style = "font-weight:700; color:#00897b;" if is_winner else "color:#444;"
126
+ bar_color = color if is_winner else "#b2dfdb" if score > max_score * 0.5 else "#e0e0e0"
127
+ star = " ★" if is_winner else ""
128
+ bottom_r = "0 0 12px 12px" if is_last else "0"
129
+ border_bot = "border-bottom:1px solid #f0f0f0;" if not is_last else ""
130
+
131
+ st.markdown(f"""
132
+ <div style="background:#ffffff; border:1px solid #ddd; border-top:none;
133
+ border-radius:{bottom_r}; padding:7px 24px; {border_bot}
134
+ box-shadow:0 2px 8px rgba(0,0,0,0.07);">
135
+ <div style="display:flex; align-items:center; gap:12px;">
136
+ <span style="width:44px; text-align:right; font-size:13px; {name_style}">{name}{star}</span>
137
+ <div style="flex:1; background:#f5f5f5; border-radius:4px;
138
+ height:14px; overflow:hidden; border:1px solid #e8e8e8;">
139
+ <div style="width:{bar_w}%; height:100%;
140
+ background:{bar_color}; border-radius:4px;"></div>
141
+ </div>
142
+ <span style="width:56px; font-size:12px; color:#888; text-align:right;">{score:.4f}</span>
143
+ </div>
144
+ </div>
145
+ """, unsafe_allow_html=True)
146
+
147
+ st.caption(
148
+ "Z-score = std deviations the top ETF's probability sits above the mean of all ETF probabilities. "
149
+ "Higher → model is more decisive."
150
+ )
151
+
152
+
153
+ # ── Metrics row ───────────────────────────────────────────────────────────────
154
+
155
+ def show_metrics_row(result: dict, tbill_rate: float):
156
+ """Five-column metric display."""
157
+ col1, col2, col3, col4, col5 = st.columns(5)
158
+
159
+ col1.metric(
160
+ "📈 Annualised Return",
161
+ f"{result['ann_return']*100:.2f}%",
162
+ delta=f"vs T-bill: {(result['ann_return'] - tbill_rate)*100:.2f}%",
163
+ )
164
+ col2.metric(
165
+ "📊 Sharpe Ratio",
166
+ f"{result['sharpe']:.2f}",
167
+ delta="Risk-Adjusted" if result['sharpe'] > 1 else "Below Threshold",
168
+ )
169
+ col3.metric(
170
+ "🎯 Hit Ratio (15d)",
171
+ f"{result['hit_ratio']*100:.0f}%",
172
+ delta="Strong" if result['hit_ratio'] > 0.6 else "Weak",
173
+ )
174
+ col4.metric(
175
+ "📉 Max Drawdown",
176
+ f"{result['max_dd']*100:.2f}%",
177
+ delta="Peak to Trough",
178
+ )
179
+ col5.metric(
180
+ "⚠️ Max Daily DD",
181
+ f"{result['max_daily_dd']*100:.2f}%",
182
+ delta="Worst Day",
183
+ )
184
+
185
+
186
+ # ── Comparison table ──────────────────────────────────────────────────────────
187
+
188
+ def show_comparison_table(comparison_df: pd.DataFrame):
189
+ """Styled comparison table for all three approaches."""
190
+ def highlight_winner(row):
191
+ if "WINNER" in str(row.get("Winner", "")):
192
+ return ["background-color: rgba(0,200,150,0.15); font-weight:bold"] * len(row)
193
+ return [""] * len(row)
194
+
195
+ styled = comparison_df.style.apply(highlight_winner, axis=1).set_properties(**{
196
+ "text-align": "center",
197
+ "font-size": "14px",
198
+ }).set_table_styles([
199
+ {"selector": "th", "props": [("font-size", "14px"), ("font-weight", "bold"),
200
+ ("text-align", "center")]},
201
+ {"selector": "td", "props": [("padding", "10px")]},
202
+ ])
203
+ st.dataframe(styled, use_container_width=True)
204
+
205
+
206
+ # ── Audit trail ───────────────────────────────────────────────────────────────
207
+
208
+ def show_audit_trail(audit_trail: list):
209
+ """Last 20 days styled audit trail."""
210
+ if not audit_trail:
211
+ st.info("No audit trail data available.")
212
+ return
213
+
214
+ df = pd.DataFrame(audit_trail).tail(20)[["Date", "Signal", "Net_Return"]]
215
+
216
+ def color_return(val):
217
+ return "color: #00c896; font-weight:bold" if val > 0 else "color: #ff4b4b; font-weight:bold"
218
+
219
+ styled = df.style.applymap(color_return, subset=["Net_Return"]).format(
220
+ {"Net_Return": "{:.2%}"}
221
+ ).set_properties(**{
222
+ "font-size": "16px",
223
+ "text-align": "center",
224
+ }).set_table_styles([
225
+ {"selector": "th", "props": [("font-size", "16px"), ("font-weight", "bold"),
226
+ ("text-align", "center")]},
227
+ {"selector": "td", "props": [("padding", "10px")]},
228
+ ])
229
+ st.dataframe(styled, use_container_width=True, height=500)
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/ui/charts.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ui/charts.py
3
+ All Plotly chart builders for the Streamlit UI.
4
+ """
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ import plotly.graph_objects as go
9
+
10
+
11
+ APPROACH_COLOURS = {
12
+ "Approach 1": "#00ffc8",
13
+ "Approach 2": "#7c6aff",
14
+ "Approach 3": "#ff6b6b",
15
+ }
16
+ BENCHMARK_COLOURS = {
17
+ "SPY": "#ff4b4b",
18
+ "AGG": "#ffa500",
19
+ }
20
+
21
+
22
+ def equity_curve_chart(
23
+ results: dict,
24
+ winner_name: str,
25
+ plot_dates: pd.DatetimeIndex,
26
+ df: pd.DataFrame,
27
+ test_slice: slice,
28
+ tbill_rate: float,
29
+ ) -> go.Figure:
30
+ """
31
+ Equity curve chart showing all three approaches + SPY + AGG benchmarks.
32
+
33
+ Args:
34
+ results : {approach_name: result_dict}
35
+ winner_name : highlighted approach
36
+ plot_dates : DatetimeIndex for x-axis
37
+ df : full DataFrame (for benchmark columns)
38
+ test_slice : slice object to extract test-period benchmark returns
39
+ tbill_rate : for benchmark metric calculation
40
+ """
41
+ from strategy.backtest import compute_benchmark_metrics
42
+
43
+ fig = go.Figure()
44
+
45
+ # ── Strategy lines ────────────────────────────────────────────────────────
46
+ for name, res in results.items():
47
+ if res is None:
48
+ continue
49
+ colour = APPROACH_COLOURS.get(name, "#aaaaaa")
50
+ width = 3 if name == winner_name else 1.5
51
+ dash = "solid" if name == winner_name else "dot"
52
+
53
+ n = min(len(res["cum_returns"]), len(plot_dates))
54
+
55
+ fig.add_trace(go.Scatter(
56
+ x=plot_dates[:n],
57
+ y=res["cum_returns"][:n],
58
+ mode="lines",
59
+ name=f"{name} {'★' if name == winner_name else ''}",
60
+ line=dict(color=colour, width=width, dash=dash),
61
+ fill="tozeroy" if name == winner_name else None,
62
+ fillcolor=f"rgba({_hex_to_rgb(colour)},0.07)" if name == winner_name else None,
63
+ ))
64
+
65
+ # ── Benchmark: SPY ────────────────────────────────────────────────────────
66
+ if "SPY_Ret" in df.columns:
67
+ spy_rets = df["SPY_Ret"].iloc[test_slice].values
68
+ n = min(len(spy_rets), len(plot_dates))
69
+ spy_m = compute_benchmark_metrics(spy_rets[:n], tbill_rate)
70
+ fig.add_trace(go.Scatter(
71
+ x=plot_dates[:n],
72
+ y=spy_m["cum_returns"],
73
+ mode="lines",
74
+ name="SPY (Equity BM)",
75
+ line=dict(color=BENCHMARK_COLOURS["SPY"], width=1.5, dash="dot"),
76
+ ))
77
+
78
+ # ── Benchmark: AGG ────────────────────────────────────────────────────────
79
+ if "AGG_Ret" in df.columns:
80
+ agg_rets = df["AGG_Ret"].iloc[test_slice].values
81
+ n = min(len(agg_rets), len(plot_dates))
82
+ agg_m = compute_benchmark_metrics(agg_rets[:n], tbill_rate)
83
+ fig.add_trace(go.Scatter(
84
+ x=plot_dates[:n],
85
+ y=agg_m["cum_returns"],
86
+ mode="lines",
87
+ name="AGG (Bond BM)",
88
+ line=dict(color=BENCHMARK_COLOURS["AGG"], width=1.5, dash="dot"),
89
+ ))
90
+
91
+ fig.update_layout(
92
+ template="plotly_dark",
93
+ height=460,
94
+ hovermode="x unified",
95
+ showlegend=True,
96
+ legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01, font=dict(size=11)),
97
+ xaxis_title="Date",
98
+ yaxis_title="Cumulative Return (×)",
99
+ margin=dict(l=50, r=30, t=20, b=50),
100
+ )
101
+ return fig
102
+
103
+
104
+ def comparison_bar_chart(results: dict, winner_name: str) -> go.Figure:
105
+ """
106
+ Horizontal bar chart comparing annualised returns across all three approaches.
107
+ """
108
+ names = []
109
+ returns = []
110
+ colours = []
111
+
112
+ for name, res in results.items():
113
+ if res is None:
114
+ continue
115
+ names.append(name)
116
+ returns.append(res["ann_return"] * 100)
117
+ colours.append(APPROACH_COLOURS.get(name, "#aaaaaa"))
118
+
119
+ fig = go.Figure(go.Bar(
120
+ x=returns,
121
+ y=names,
122
+ orientation="h",
123
+ marker_color=colours,
124
+ text=[f"{r:.1f}%" for r in returns],
125
+ textposition="auto",
126
+ ))
127
+
128
+ fig.update_layout(
129
+ template="plotly_dark",
130
+ height=200,
131
+ xaxis_title="Annualised Return (%)",
132
+ margin=dict(l=100, r=30, t=10, b=40),
133
+ showlegend=False,
134
+ )
135
+ return fig
136
+
137
+
138
+ # ── Helper ────────────────────────────────────────────────────────────────────
139
+
140
+ def _hex_to_rgb(hex_color: str) -> str:
141
+ """Convert #rrggbb to 'r,g,b' string for rgba()."""
142
+ h = hex_color.lstrip("#")
143
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
144
+ return f"{r},{g},{b}"
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/utils/calendar.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ utils/calendar.py
3
+ NYSE calendar utilities:
4
+ - Next trading day for signal display
5
+ - Market open check
6
+ - EST time helper
7
+ """
8
+
9
+ from datetime import datetime, timedelta
10
+ import pytz
11
+
12
+ try:
13
+ import pandas_market_calendars as mcal
14
+ NYSE_CAL_AVAILABLE = True
15
+ except ImportError:
16
+ NYSE_CAL_AVAILABLE = False
17
+
18
+
19
+ def get_est_time() -> datetime:
20
+ """Return current datetime in US/Eastern timezone."""
21
+ return datetime.now(pytz.timezone("US/Eastern"))
22
+
23
+
24
+ def is_market_open_today() -> bool:
25
+ """Return True if today is a NYSE trading day."""
26
+ today = get_est_time().date()
27
+ if NYSE_CAL_AVAILABLE:
28
+ try:
29
+ nyse = mcal.get_calendar("NYSE")
30
+ schedule = nyse.schedule(start_date=today, end_date=today)
31
+ return len(schedule) > 0
32
+ except Exception:
33
+ pass
34
+ return today.weekday() < 5
35
+
36
+
37
+ def get_next_signal_date() -> datetime.date:
38
+ """
39
+ Determine the date for which the model's signal applies.
40
+
41
+ Rules:
42
+ - If today is a NYSE trading day AND it is before 09:30 EST
43
+ → signal applies to TODAY (market hasn't opened yet)
44
+ - Otherwise
45
+ → signal applies to the NEXT NYSE trading day
46
+ """
47
+ now_est = get_est_time()
48
+ today = now_est.date()
49
+
50
+ market_not_open_yet = (
51
+ now_est.hour < 9 or
52
+ (now_est.hour == 9 and now_est.minute < 30)
53
+ )
54
+
55
+ if NYSE_CAL_AVAILABLE:
56
+ try:
57
+ nyse = mcal.get_calendar("NYSE")
58
+ schedule = nyse.schedule(
59
+ start_date=today,
60
+ end_date=today + timedelta(days=10),
61
+ )
62
+ if len(schedule) == 0:
63
+ return today # fallback
64
+
65
+ first_day = schedule.index[0].date()
66
+
67
+ # Today is a trading day and market hasn't opened → today
68
+ if first_day == today and market_not_open_yet:
69
+ return today
70
+
71
+ # Otherwise find first trading day strictly after today
72
+ for ts in schedule.index:
73
+ d = ts.date()
74
+ if d > today:
75
+ return d
76
+
77
+ return schedule.index[-1].date()
78
+ except Exception:
79
+ pass
80
+
81
+ # Fallback: simple weekend skip
82
+ candidate = today if market_not_open_yet else today + timedelta(days=1)
83
+ while candidate.weekday() >= 5:
84
+ candidate += timedelta(days=1)
85
+ return candidate
86
+
87
+
88
+ def is_sync_window() -> bool:
89
+ """True if current EST time is in the 07:00-08:00 or 19:00-20:00 window."""
90
+ now = get_est_time()
91
+ return (7 <= now.hour < 8) or (19 <= now.hour < 20)
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/signals/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/strategy/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/ui/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
hf_space/hf_space/hf_space/hf_space/hf_space/models/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # models package
hf_space/hf_space/hf_space/hf_space/signals/__init__.py CHANGED
@@ -1 +1 @@
1
-
 
1
+ # strategy package
hf_space/hf_space/hf_space/strategy/__init__.py CHANGED
@@ -1 +1 @@
1
-
 
1
+ # strategy package
hf_space/hf_space/signals/__init__.py CHANGED
@@ -1 +1 @@
1
- # strategy package
 
1
+ # signals package
hf_space/ui/__init__.py CHANGED
@@ -1 +1 @@
1
-
 
1
+ # ui package
utils/__init__.py CHANGED
@@ -1 +1 @@
1
-
 
1
+ # utils package