Skip to content

Commit 7ea0df1

Browse files
authored
Merge pull request #2 from HanSun103/live-forward-test-2026-07-17
Update project implementation
2 parents 7f90aa9 + cedcb4d commit 7ea0df1

4 files changed

Lines changed: 1069 additions & 81 deletions

File tree

README.md

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -196,20 +196,74 @@ python -m src.cli evaluate-rank-model \
196196
--top-k 40
197197
```
198198

199-
Create a frozen live-forward paper snapshot from the latest yfinance close:
199+
Install the optional tuning dependency, then create a frozen live-forward paper snapshot from the
200+
latest yfinance close:
200201

201202
```bash
202-
python -m src.cli run-live-forward-snapshot --as-of 2026-07-17
203+
pip install -e ".[tuning]"
204+
205+
python -m src.cli run-live-forward-snapshot \
206+
--as-of 2026-07-20 \
207+
--optuna-trials 25 \
208+
--tuning-folds 3 \
209+
--validation-days 42 \
210+
--purge-days 1 \
211+
--optuna-timeout-seconds 600
203212
```
204213

205-
By default this rebuilds a rolling daily-proxy training panel from recent yfinance prices, trains
206-
candidate Ridge and XGBoost rankers on all known one-session forward residual-rank labels, selects
207-
the winner on a trailing temporal validation window, refits the selected model, scores the active
208-
membership list using the latest close, and saves a local paper portfolio plus
209-
`model_selection.csv` under `reports/live_forward/YYYY-MM-DD/`. Do not edit a saved snapshot
210-
before measuring realized next-session returns; otherwise the forward test stops being clean. This
211-
daily proxy is designed to collect evidence faster than waiting ten calendar weeks for ten weekly
212-
rebalance observations.
214+
This rebuilds a rolling daily-proxy training panel from recent yfinance prices, builds only labels
215+
whose future one-session returns are already known, and uses Optuna to choose between Ridge and
216+
XGBoost rankers on purged rolling temporal validation folds. After selection, the winning model is
217+
refit on all data available before the decision date, the active membership list is scored using
218+
the latest close, and a frozen paper portfolio plus `model_selection.csv` are saved under
219+
`reports/live_forward/YYYY-MM-DD/`. Do not edit a saved snapshot before measuring realized
220+
next-session returns; otherwise the forward test stops being clean. This daily proxy is designed to
221+
collect evidence faster than waiting ten calendar weeks for ten weekly rebalance observations.
222+
223+
The July 20, 2026 production-style run requested 25 Optuna trials with a 600-second timeout. Ten
224+
trials completed, using three 42-session validation folds separated from training by a one-session
225+
purge. The selected model was Ridge with `alpha=6156.9973`, aggregate validation Rank IC of
226+
approximately `0.0121`, and average top-40 raw excess return of approximately `0.067%` per
227+
validation session. Treat that as model-selection evidence only; the true forward result comes
228+
from evaluating the frozen snapshot after the next session is realized.
229+
230+
Generate an hourly live-monitoring chart versus SPY for a saved snapshot:
231+
232+
```bash
233+
python -m src.cli report-live-intraday \
234+
--as-of 2026-07-17 \
235+
--monitor-date 2026-07-20 \
236+
--benchmark SPY \
237+
--interval 60m \
238+
--baseline previous-close
239+
```
240+
241+
This writes `intraday_vs_benchmark.csv`, `intraday_vs_benchmark_summary.json`, and a lightweight
242+
SVG chart under the snapshot folder. The default baseline is the monitored day's previous close,
243+
matching common one-day market charts. It is a monitoring view, not a replacement for the frozen
244+
next-session evaluation.
245+
246+
Evaluate a snapshot at stock and portfolio level for a named horizon:
247+
248+
```bash
249+
python -m src.cli evaluate-live-forward \
250+
--as-of 2026-07-17 \
251+
--eval-date 2026-07-20 \
252+
--benchmark SPY \
253+
--interval 60m \
254+
--baseline previous-close \
255+
--horizon-label monday_intraday
256+
```
257+
258+
Build the static monitoring dashboard:
259+
260+
```bash
261+
python -m src.cli build-live-dashboard
262+
```
263+
264+
The dashboard reads every saved snapshot under `reports/live_forward/`, supports changing the
265+
snapshot and evaluation horizon, and shows portfolio return, benchmark return, active return,
266+
stock-level winners/losers versus SPY, model-selection evidence, and experiment history.
213267

214268
Build the earlier conservative final-reference package:
215269

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ portfolio = "src.cli:main"
1515

1616
[project.optional-dependencies]
1717
wrds = ["wrds>=3.2"]
18+
tuning = ["optuna>=4.9"]
1819

1920
[tool.setuptools.packages.find]
2021
where = ["."]
@@ -30,6 +31,8 @@ addopts = "-v --tb=short"
3031
[tool.ruff]
3132
line-length = 100
3233
target-version = "py311"
34+
35+
[tool.ruff.lint]
3336
select = ["E", "F", "I", "N", "W"]
3437

3538
[tool.mypy]

src/cli.py

Lines changed: 140 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def main(log_level: str, log_file: str | None) -> None:
5353
@click.option("--seed", default=42, show_default=True, type=int)
5454
def generate_demo(output: str, start: str, end: str, seed: int) -> None:
5555
"""Generate synthetic demo prices and NLP data (no API keys needed)."""
56-
from .utils.demo_data import save_demo_prices, save_demo_nlp
56+
from .utils.demo_data import save_demo_nlp, save_demo_prices
5757
save_demo_prices(output, start=start, end=end, seed=seed)
5858
save_demo_nlp(output, start=start, end=end, seed=seed)
5959
click.echo(f"Demo data written to {output}")
@@ -79,9 +79,9 @@ def build_dataset(
7979
validate_experiment_config(cfg)
8080
logger.info("Building dataset (demo=%s)", use_demo)
8181

82-
from .data.market_data import MarketDataLoader
83-
from .data.macro_data import MacroDataLoader
8482
from .data.feature_store import FeatureStore
83+
from .data.macro_data import MacroDataLoader
84+
from .data.market_data import MarketDataLoader
8585

8686
mdl = MarketDataLoader(cfg, use_demo=use_demo)
8787
prices = mdl.load()
@@ -121,13 +121,11 @@ def build_nlp_features(
121121
if experiment_config:
122122
paths.append(experiment_config)
123123
cfg = load_config(*paths)
124-
nlp_cfg = cfg.extra.get("sources", {})
125-
126-
from .nlp.source_connectors import ConnectorRegistry
124+
from .features.nlp_features import NLPFeatureBuilder
127125
from .nlp.entity_linking import EntityLinker
128-
from .nlp.sentiment_model import SentimentModel
129126
from .nlp.event_detector import EventDetector
130-
from .features.nlp_features import NLPFeatureBuilder
127+
from .nlp.sentiment_model import SentimentModel
128+
from .nlp.source_connectors import ConnectorRegistry
131129

132130
registry = ConnectorRegistry(cfg, use_demo=use_demo)
133131
posts = registry.fetch_all()
@@ -169,6 +167,7 @@ def build_nlp_features(
169167
feature_store_path = Path(cfg.paths.data_processed) / "features.parquet"
170168
if feature_store_path.exists() and not features.empty:
171169
import pandas as pd
170+
172171
from .data.feature_store import FeatureStore
173172

174173
feature_store = pd.read_parquet(feature_store_path)
@@ -265,7 +264,11 @@ def collect_news(config: str, base_config: str) -> None:
265264
@main.command("import-fnspid")
266265
@click.option("--config", default="configs/base.yaml", show_default=True)
267266
@click.option("--nlp-config", default="configs/nlp.yaml", show_default=True)
268-
@click.option("--tickers", default=None, help="Comma-separated symbols; defaults to configured universe")
267+
@click.option(
268+
"--tickers",
269+
default=None,
270+
help="Comma-separated symbols; defaults to configured universe",
271+
)
269272
@click.option("--start", default=None, help="Inclusive date; defaults to configured start")
270273
@click.option("--end", default=None, help="Exclusive date; defaults to configured end")
271274
@click.option(
@@ -426,8 +429,8 @@ def train_model(
426429
from .experiments.forecast_evaluation import (
427430
development_slice,
428431
persist_forecast_evaluation,
429-
validate_feature_store_metadata,
430432
validate_experiment_config,
433+
validate_feature_store_metadata,
431434
)
432435
validate_experiment_config(cfg)
433436
model_name = model or cfg.extra.get("active_model", "xgboost")
@@ -440,18 +443,19 @@ def train_model(
440443
validate_feature_store_metadata(features_path, cfg)
441444

442445
import pandas as pd
443-
from .models.ensemble import build_model
446+
444447
from .backtest.walk_forward import WalkForwardSplitter
448+
from .models.ensemble import build_model
445449

446-
X = development_slice(pd.read_parquet(features_path), cfg, unlock_lockbox)
450+
features = development_slice(pd.read_parquet(features_path), cfg, unlock_lockbox)
447451
splitter = WalkForwardSplitter(cfg)
448452

449453
results = []
450-
for fold, (train_idx, val_idx, test_idx) in enumerate(splitter.split(X)):
454+
for fold, (train_idx, val_idx, test_idx) in enumerate(splitter.split(features)):
451455
mdl = build_model(model_name, cfg)
452-
train = X.iloc[train_idx]
453-
val = X.iloc[val_idx]
454-
test = X.iloc[test_idx]
456+
train = features.iloc[train_idx]
457+
val = features.iloc[val_idx]
458+
test = features.iloc[test_idx]
455459
mdl.fit(train, train["target"], val, val["target"])
456460
preds = mdl.predict(test)
457461
fold_result = test[["target"]].copy()
@@ -617,7 +621,9 @@ def prepare_fnspid_v2_inputs(
617621
coverage = len(covered_stocks) / len(stock_tickers)
618622
covered_membership = relevant.loc[relevant["ticker"].isin(covered_stocks)].copy()
619623
covered_membership.to_csv(out / "membership_intervals_covered.csv", index=False)
620-
pd.DataFrame({"ticker": missing_stocks}).to_csv(out / "missing_membership_prices.csv", index=False)
624+
pd.DataFrame({"ticker": missing_stocks}).to_csv(
625+
out / "missing_membership_prices.csv", index=False
626+
)
621627
prices.to_parquet(out / "prices_with_sector_etfs.parquet")
622628
repair_audit.to_csv(out / "price_adjustment_repairs.csv", index=False)
623629
sectors = infer_sector_etf_history(prices, covered_stocks)
@@ -879,6 +885,10 @@ def run_exit_prediction_v8_command(
879885
help="Train fixed config parameters only; intended for debugging, not evidence runs.",
880886
)
881887
@click.option("--validation-days", default=42, show_default=True, type=int)
888+
@click.option("--tuning-folds", default=3, show_default=True, type=int)
889+
@click.option("--purge-days", default=None, type=int)
890+
@click.option("--optuna-trials", default=50, show_default=True, type=int)
891+
@click.option("--optuna-timeout-seconds", default=None, type=int)
882892
@click.option("--selection-metric", default="top40_raw_excess", show_default=True)
883893
def run_live_forward_snapshot_command(
884894
base_config: str,
@@ -895,6 +905,10 @@ def run_live_forward_snapshot_command(
895905
use_static_training_dataset: bool,
896906
skip_hyperparameter_tuning: bool,
897907
validation_days: int,
908+
tuning_folds: int,
909+
purge_days: int | None,
910+
optuna_trials: int,
911+
optuna_timeout_seconds: int | None,
898912
selection_metric: str,
899913
) -> None:
900914
"""Create a frozen paper-trading snapshot from latest yfinance data."""
@@ -917,6 +931,10 @@ def run_live_forward_snapshot_command(
917931
use_static_training_dataset=use_static_training_dataset,
918932
tune_hyperparameters=not skip_hyperparameter_tuning,
919933
validation_days=validation_days,
934+
tuning_folds=tuning_folds,
935+
purge_days=purge_days,
936+
optuna_trials=optuna_trials,
937+
optuna_timeout_seconds=optuna_timeout_seconds,
920938
selection_metric=selection_metric,
921939
)
922940
click.echo(json.dumps({
@@ -934,6 +952,110 @@ def run_live_forward_snapshot_command(
934952
}, indent=2))
935953

936954

955+
@main.command("report-live-intraday")
956+
@click.option(
957+
"--snapshot-dir",
958+
default=None,
959+
help="Live snapshot directory; defaults to reports/live_forward/<as-of>.",
960+
)
961+
@click.option("--as-of", default=None, help="Snapshot date used when snapshot-dir is omitted.")
962+
@click.option("--benchmark", default="SPY", show_default=True)
963+
@click.option("--interval", default="60m", show_default=True)
964+
@click.option("--monitor-date", default=None, help="Intraday date to plot; defaults to today.")
965+
@click.option(
966+
"--baseline",
967+
default="previous-close",
968+
show_default=True,
969+
type=click.Choice(["previous-close", "first-bar"]),
970+
)
971+
def report_live_intraday_command(
972+
snapshot_dir: str | None,
973+
as_of: str | None,
974+
benchmark: str,
975+
interval: str,
976+
monitor_date: str | None,
977+
baseline: str,
978+
) -> None:
979+
"""Generate intraday portfolio-vs-benchmark CSV and SVG for a live snapshot."""
980+
import json
981+
982+
import pandas as pd
983+
984+
from .experiments.live_forward_test import generate_intraday_benchmark_report
985+
986+
if snapshot_dir is None:
987+
date = as_of or pd.Timestamp.today().strftime("%Y-%m-%d")
988+
snapshot_dir = str(Path("reports/live_forward") / date)
989+
result = generate_intraday_benchmark_report(
990+
snapshot_dir,
991+
benchmark=benchmark,
992+
interval=interval,
993+
monitor_date=monitor_date,
994+
baseline=baseline,
995+
)
996+
click.echo(json.dumps(result, indent=2))
997+
998+
999+
@main.command("evaluate-live-forward")
1000+
@click.option(
1001+
"--snapshot-dir",
1002+
default=None,
1003+
help="Live snapshot directory; defaults to reports/live_forward/<as-of>.",
1004+
)
1005+
@click.option("--as-of", default=None, help="Snapshot date used when snapshot-dir is omitted.")
1006+
@click.option("--eval-date", required=True, help="Evaluation date.")
1007+
@click.option("--benchmark", default="SPY", show_default=True)
1008+
@click.option("--interval", default="60m", show_default=True)
1009+
@click.option(
1010+
"--baseline",
1011+
default="previous-close",
1012+
show_default=True,
1013+
type=click.Choice(["previous-close", "first-bar"]),
1014+
)
1015+
@click.option("--horizon-label", default=None, help="Display/storage label for this horizon.")
1016+
def evaluate_live_forward_command(
1017+
snapshot_dir: str | None,
1018+
as_of: str | None,
1019+
eval_date: str,
1020+
benchmark: str,
1021+
interval: str,
1022+
baseline: str,
1023+
horizon_label: str | None,
1024+
) -> None:
1025+
"""Evaluate a frozen live-forward snapshot versus a benchmark."""
1026+
import json
1027+
1028+
import pandas as pd
1029+
1030+
from .experiments.live_forward_test import evaluate_live_forward_snapshot
1031+
1032+
if snapshot_dir is None:
1033+
date = as_of or pd.Timestamp.today().strftime("%Y-%m-%d")
1034+
snapshot_dir = str(Path("reports/live_forward") / date)
1035+
result = evaluate_live_forward_snapshot(
1036+
snapshot_dir,
1037+
eval_date=eval_date,
1038+
benchmark=benchmark,
1039+
interval=interval,
1040+
baseline=baseline,
1041+
horizon_label=horizon_label,
1042+
)
1043+
click.echo(json.dumps(result, indent=2))
1044+
1045+
1046+
@main.command("build-live-dashboard")
1047+
@click.option("--live-root", default="reports/live_forward", show_default=True)
1048+
@click.option("--output-file", default="dashboard.html", show_default=True)
1049+
def build_live_dashboard_command(live_root: str, output_file: str) -> None:
1050+
"""Build a static dashboard for live-forward snapshots and evaluations."""
1051+
import json
1052+
1053+
from .experiments.live_forward_test import build_live_dashboard
1054+
1055+
result = build_live_dashboard(live_root, output_file=output_file)
1056+
click.echo(json.dumps(result, indent=2))
1057+
1058+
9371059
@main.command("run-sparse-sentiment-ablation")
9381060
@click.option(
9391061
"--v2-base-config", default="configs/experiments/residual_rank_v2.yaml",
@@ -1193,6 +1315,7 @@ def select_pairs(
11931315
cfg = load_config(*paths)
11941316

11951317
import pandas as pd
1318+
11961319
from .data.market_data import MarketDataLoader
11971320
from .statarb.pair_selection import PairSelector
11981321

@@ -1274,7 +1397,6 @@ def run_ablation(config: str, base_config: str, output: str) -> None:
12741397
return
12751398

12761399
from .backtest.engine import BacktestEngine
1277-
from .utils.config import _deep_merge
12781400

12791401
out = Path(output)
12801402
out.mkdir(parents=True, exist_ok=True)

0 commit comments

Comments
 (0)