GitHub Actions commited on
Commit ·
b16a944
1
Parent(s): 63f2dd9
Sync from GitHub: 7e71c6b91cb36cef701b24bb05297201488f3df3
Browse files- hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/data/data/__init__.py +1 -0
- hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/README.md +12 -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/data/__init__.py +1 -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/data/loader.py +215 -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/README.md +110 -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/hf_space/hf_space/hf_space/app.py +273 -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/.gitattributes +35 -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/Dockerfile +20 -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 +19 -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/requirements.txt +3 -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/src/streamlit_app.py +40 -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/requirements.txt +29 -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/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/hf_space/models/base.py +199 -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/models/approach1_wavelet.py +167 -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/models/approach2_regime.py +1 -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/models/approach3_multiscale.py +150 -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/strategy/backtest.py +193 -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/signals/conviction.py +93 -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/ui/components.py +229 -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/ui/charts.py +144 -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/utils/calendar.py +91 -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/models/__init__.py +1 -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/signals/__init__.py +1 -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/strategy/__init__.py +1 -0
- 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
- 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
- hf_space/hf_space/hf_space/hf_space/hf_space/models/models/__init__.py +1 -0
- hf_space/hf_space/hf_space/hf_space/signals/__init__.py +1 -1
- hf_space/hf_space/hf_space/strategy/__init__.py +1 -1
- hf_space/hf_space/signals/__init__.py +1 -1
- hf_space/ui/__init__.py +1 -1
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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} σ
|
| 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 −3σ</span>
|
| 101 |
+
<span>Neutral 0σ</span>
|
| 102 |
+
<span>Strong +3σ</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 → 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 |
-
#
|
|
|
|
| 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
|