diff --git a/apps/pipeline/cli.py b/apps/pipeline/cli.py new file mode 100644 index 0000000..db1cab2 --- /dev/null +++ b/apps/pipeline/cli.py @@ -0,0 +1,151 @@ +"""CLI for running the data pipeline. + +Usage: + fithia2 pipeline run # run all 5 steps + fithia2 pipeline run --step poller|fetcher|parser|features|labels + fithia2 pipeline run --start-date 2026-03-15 +""" +from __future__ import annotations + +import argparse +import asyncio +import sys + +from rich.console import Console + +_console = Console(width=120) + +STEPS = ["poller", "fetcher", "parser", "features", "labels"] + + +def _run_id() -> str: + from libs.common.ids import new_job_run_id + return new_job_run_id() + + +def _configure() -> None: + import logging + try: + from dotenv import load_dotenv + load_dotenv() + except ImportError: + pass + from libs.common.config import get_settings + from libs.common.logging import configure_logging + configure_logging(get_settings().log_level) + # Suppress noisy HTTP request logs from httpx + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + + +async def _run_steps_async( + steps: list[str], + start_date: str | None, + end_date: str | None, + continue_on_error: bool = False, +) -> dict[str, dict | str]: + """Run all pipeline steps in a single event loop to avoid asyncpg loop conflicts.""" + from apps.pipeline.filing_poller.main import poll_filings + from apps.pipeline.filing_fetcher.main import fetch_exhibits + from apps.pipeline.event_parser.main import run_event_parser + from apps.pipeline.feature_builder.main import run_feature_builder + from apps.pipeline.label_generator.main import run_label_generator + + results: dict[str, dict | str] = {} + for step in steps: + try: + if step == "poller": + results[step] = await poll_filings(_run_id(), start_date=start_date, end_date=end_date) + elif step == "fetcher": + results[step] = await fetch_exhibits(_run_id(), start_date=start_date, end_date=end_date) + elif step == "parser": + results[step] = await run_event_parser(_run_id(), start_date=start_date, end_date=end_date) + elif step == "features": + results[step] = await run_feature_builder(_run_id(), start_date=start_date, end_date=end_date) + elif step == "labels": + results[step] = await run_label_generator(_run_id(), start_date=start_date, end_date=end_date) + except Exception as exc: + results[step] = f"ERROR: {exc}" + if not continue_on_error: + raise + return results + + +_STEP_NAMES = { + "poller": "Filing Poller", + "fetcher": "Filing Fetcher", + "parser": "Event Parser", + "features": "Feature Builder", + "labels": "Label Generator", +} + + +def cmd_run(args: argparse.Namespace) -> None: + _configure() + + steps = [args.step] if args.step else STEPS + + for step in steps: + _console.print(f"\n[bold cyan]▶ {_STEP_NAMES[step]}[/]") + + try: + all_results = asyncio.run( + _run_steps_async(steps, args.start_date, args.end_date, args.continue_on_error) + ) + except Exception as exc: + _console.print(f"[red]FAILED: {exc}[/]") + sys.exit(1) + + _console.print() + failed = False + for step in steps: + result = all_results.get(step, {}) + if isinstance(result, str) and result.startswith("ERROR:"): + _console.print(f" [bold cyan]{_STEP_NAMES[step]}:[/] [red]{result}[/]") + failed = True + else: + parts = [f"{k}={v}" for k, v in (result.items() if isinstance(result, dict) else [])] + _console.print(f" [bold cyan]{_STEP_NAMES[step]}:[/] [green]done[/] {', '.join(parts)}") + + if failed: + sys.exit(1) + + _console.print("\n[bold green]Pipeline complete.[/]") + + +def main() -> None: + parser = argparse.ArgumentParser(description="fithia2 Pipeline Runner") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("run", help="Run pipeline steps") + p.add_argument( + "--step", "-s", + choices=STEPS, + default=None, + help="Run a single step (default: all steps in order)", + ) + p.add_argument( + "--start-date", + default=None, + metavar="YYYY-MM-DD", + help="Start date for filing poller (default: 7 days ago)", + ) + p.add_argument( + "--end-date", + default=None, + metavar="YYYY-MM-DD", + help="End date for filing poller (default: today)", + ) + p.add_argument( + "--continue-on-error", + action="store_true", + help="Continue to next step even if a step fails", + ) + + args = parser.parse_args() + if args.command == "run": + cmd_run(args) + + +if __name__ == "__main__": + main() diff --git a/apps/pipeline/continuation_snapshot/main.py b/apps/pipeline/continuation_snapshot/main.py new file mode 100644 index 0000000..4cd6b55 --- /dev/null +++ b/apps/pipeline/continuation_snapshot/main.py @@ -0,0 +1,58 @@ +"""Build continuation snapshots from an existing event-day snapshot.""" +from __future__ import annotations + +import argparse +import asyncio +import json + +from libs.common.config import get_settings +from libs.common.logging import configure_logging +from libs.export.continuation_snapshot import export_continuation_snapshot_from_base + + +async def run_continuation_snapshot( + *, + base_snapshot_dir: str, + output_dir: str, + snapshot_id: str, + lookback_days: int, +) -> dict: + return await export_continuation_snapshot_from_base( + base_snapshot_dir=base_snapshot_dir, + output_dir=output_dir, + snapshot_id=snapshot_id, + lookback_days=lookback_days, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build continuation snapshot from base snapshot") + parser.add_argument("--base-snapshot-dir", required=True, help="Base snapshot directory path") + parser.add_argument("--output-dir", default="./data/datasets/snapshots", help="Snapshot output root") + parser.add_argument("--snapshot-id", required=True, help="New snapshot id") + parser.add_argument("--lookback-days", type=int, default=3, help="Continuation signal lookback in trading days") + parser.add_argument("--json", action="store_true", help="Print manifest JSON") + args = parser.parse_args() + + configure_logging(get_settings().log_level) + manifest = asyncio.run( + run_continuation_snapshot( + base_snapshot_dir=args.base_snapshot_dir, + output_dir=args.output_dir, + snapshot_id=args.snapshot_id, + lookback_days=args.lookback_days, + ) + ) + if args.json: + print(json.dumps(manifest, indent=2)) + else: + print(f"Snapshot exported: {manifest['snapshot_id']}") + print(f" Output: {manifest['output_dir']}") + print(f" Total rows: {manifest['total_rows']}") + for split, count in manifest["row_counts"].items(): + print(f" {split}: {count} rows") + + +if __name__ == "__main__": + main() + diff --git a/apps/pipeline/merged_snapshot/main.py b/apps/pipeline/merged_snapshot/main.py new file mode 100644 index 0000000..d8bb797 --- /dev/null +++ b/apps/pipeline/merged_snapshot/main.py @@ -0,0 +1,39 @@ +"""Merge multiple snapshots and re-split them temporally.""" +from __future__ import annotations + +import argparse +import json + +from libs.common.config import get_settings +from libs.common.logging import configure_logging +from libs.export.merged_snapshot import export_merged_snapshot + + +def main() -> None: + parser = argparse.ArgumentParser(description="Merge snapshots into a single temporally-split snapshot") + parser.add_argument("--source-snapshot-dir", action="append", required=True, help="Source snapshot directory path; repeat for multiple inputs") + parser.add_argument("--output-dir", default="./data/datasets/snapshots", help="Snapshot output root") + parser.add_argument("--snapshot-id", required=True, help="New merged snapshot id") + parser.add_argument("--split-policy", default="temporal_70_15_15", help="Temporal split policy") + parser.add_argument("--json", action="store_true", help="Print manifest JSON") + args = parser.parse_args() + + configure_logging(get_settings().log_level) + manifest = export_merged_snapshot( + source_snapshot_dirs=args.source_snapshot_dir, + output_dir=args.output_dir, + snapshot_id=args.snapshot_id, + split_policy=args.split_policy, + ) + if args.json: + print(json.dumps(manifest, indent=2)) + else: + print(f"Snapshot exported: {manifest['snapshot_id']}") + print(f" Output: {manifest['output_dir']}") + print(f" Total rows: {manifest['total_rows']}") + for split, count in manifest["row_counts"].items(): + print(f" {split}: {count} rows") + + +if __name__ == "__main__": + main() diff --git a/apps/tools/backfill_market_cap.py b/apps/tools/backfill_market_cap.py new file mode 100644 index 0000000..4e8d943 --- /dev/null +++ b/apps/tools/backfill_market_cap.py @@ -0,0 +1,179 @@ +"""Backfill market_cap_proxy + exchange_proxy into existing FeatureSnapshot rows. + +Patches market_v1 FeatureSnapshots that are missing market_cap_proxy in +their feature_json. This is needed for paper-trading backsim on events +that were feature-built before the feature-builder started persisting +company metadata. + +Usage: + python -m apps.tools.backfill_market_cap + python -m apps.tools.backfill_market_cap --start 2026-01-01 --end 2026-03-31 + python -m apps.tools.backfill_market_cap --dry-run +""" +from __future__ import annotations + +import argparse +import asyncio +import datetime as dt +import os +from pathlib import Path + +_ENV_FILE = Path(__file__).parent.parent.parent / ".env" +if _ENV_FILE.exists(): + for _line in _ENV_FILE.read_text().splitlines(): + _line = _line.strip() + if _line and not _line.startswith("#") and "=" in _line: + _k, _, _v = _line.partition("=") + os.environ.setdefault(_k.strip(), _v.strip()) + +from rich.console import Console +from rich.progress import track + +console = Console(width=120) + + +async def run_backfill( + start_date: dt.date | None, + end_date: dt.date | None, + dry_run: bool, +) -> None: + from sqlalchemy import select, update + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + + from libs.db.models import Event, FeatureSnapshot, SymbolMaster + from libs.oracle_client import CompanyService, ScreenerService, make_oracle_client + + db_dsn = os.environ.get("POSTGRES_DSN", "") + oracle_url = os.environ.get("STOCK_ORACLE_URL", "http://localhost:18001") + + if not db_dsn: + console.print("[red]POSTGRES_DSN not set[/]") + return + + engine = create_async_engine(db_dsn, echo=False) + async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + # ── Step 1: find market_v1 snapshots without market_cap_proxy ────────── + async with async_session() as session: + stmt = ( + select(FeatureSnapshot, Event, SymbolMaster) + .join(Event, FeatureSnapshot.event_id == Event.event_id) + .outerjoin(SymbolMaster, Event.symbol_id == SymbolMaster.symbol_id) + .where(FeatureSnapshot.snapshot_name == "market_v1") + ) + if start_date: + stmt = stmt.where(Event.event_date >= start_date) + if end_date: + stmt = stmt.where(Event.event_date <= end_date) + + rows = (await session.execute(stmt)).all() + + # Filter to those missing market_cap_proxy + missing = [ + (snap, event, sym) + for snap, event, sym in rows + if not (snap.feature_json or {}).get("market_cap_proxy") + ] + console.print(f"Found [bold]{len(rows)}[/] market_v1 snapshots, " + f"[yellow]{len(missing)}[/] missing market_cap_proxy") + + if not missing or dry_run: + if dry_run: + console.print("[dim]Dry-run: no changes written.[/]") + return + + # ── Step 2: collect unique tickers ───────────────────────────────────── + ticker_to_snap: dict[str, list[FeatureSnapshot]] = {} + for snap, event, sym in missing: + if sym and sym.ticker: + ticker_to_snap.setdefault(sym.ticker, []).append(snap) + + tickers = sorted(ticker_to_snap) + console.print(f"Fetching company info for [bold]{len(tickers)}[/] unique tickers…") + + # ── Step 3: batch-fetch via screener first, then per-symbol CompanyService ─ + ticker_mcap: dict[str, float | None] = {t: None for t in tickers} + ticker_exchange: dict[str, str | None] = {t: None for t in tickers} + + async with make_oracle_client() as client: + # Try screener first (batch — faster, one call per page) + try: + svc = ScreenerService(client) + stocks = await svc.search_all_stocks( + market_cap_min=500_000_000, + exchange="NYSE,NASDAQ,AMEX", + exclude_types="ETF,FUND,ADR,SPAC", + ) + screener_lookup = {(s.symbol or "").upper(): s for s in stocks} + for t in tickers: + s = screener_lookup.get(t.upper()) + if s: + ticker_mcap[t] = s.market_cap + ticker_exchange[t] = s.exchange + resolved = sum(1 for t in tickers if ticker_mcap.get(t) is not None) + console.print(f" Screener resolved {resolved}/{len(tickers)}") + except Exception as exc: + console.print(f" [yellow]Screener failed ({exc}), falling back to CompanyService[/]") + + # Per-symbol CompanyService for anything still missing + unresolved = [t for t in tickers if ticker_mcap.get(t) is None] + if unresolved: + company_svc = CompanyService(client) + semaphore = asyncio.Semaphore(16) + + async def _fetch_one(ticker: str) -> None: + async with semaphore: + try: + info = await company_svc.get_company(ticker) + ticker_mcap[ticker] = info.market_cap + ticker_exchange[ticker] = info.exchange + except Exception: + pass + + await asyncio.gather(*(_fetch_one(t) for t in unresolved)) + resolved2 = sum(1 for t in unresolved if ticker_mcap.get(t) is not None) + console.print(f" CompanyService resolved {resolved2}/{len(unresolved)} remaining") + + # ── Step 4: update feature_json ───────────────────────────────────────── + updated = 0 + skipped = 0 + async with async_session() as session: + for ticker, snaps in track(ticker_to_snap.items(), description="Updating…"): + mcap = ticker_mcap.get(ticker) + exch = ticker_exchange.get(ticker) + if mcap is None and exch is None: + skipped += len(snaps) + continue + for snap in snaps: + fj = dict(snap.feature_json or {}) + if mcap is not None: + fj["market_cap_proxy"] = mcap + if exch is not None: + fj["exchange_proxy"] = exch + await session.execute( + update(FeatureSnapshot) + .where(FeatureSnapshot.feature_snapshot_id == snap.feature_snapshot_id) + .values(feature_json=fj) + ) + updated += 1 + + await session.commit() + + await engine.dispose() + console.print(f"\n[bold green]Done.[/] Updated {updated} snapshots, skipped {skipped} (no data).") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Backfill market_cap_proxy into FeatureSnapshots") + parser.add_argument("--start", default=None, metavar="YYYY-MM-DD") + parser.add_argument("--end", default=None, metavar="YYYY-MM-DD") + parser.add_argument("--dry-run", action="store_true", help="Show what would be done without writing") + args = parser.parse_args() + + start = dt.date.fromisoformat(args.start) if args.start else None + end = dt.date.fromisoformat(args.end) if args.end else None + asyncio.run(run_backfill(start, end, args.dry_run)) + + +if __name__ == "__main__": + main() diff --git a/apps/tools/build_ranking_model.py b/apps/tools/build_ranking_model.py new file mode 100644 index 0000000..2659acf --- /dev/null +++ b/apps/tools/build_ranking_model.py @@ -0,0 +1,96 @@ +"""Build a lightweight bucket-prior ranking model from snapshot labels.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import pandas as pd + +from libs.backtest.ranking_models import _resolve_feature_key + + +FEATURE_SPECS = [ + ("event_type", 0.08), + ("direction_guidance_combo", 0.27), + ("reaction_bucket", 0.22), + ("close_bucket", 0.15), + ("volume_bucket", 0.12), + ("gap_bucket", 0.08), + ("document_bucket", 0.05), + ("confidence_bucket", 0.03), +] + + +def build_model( + snapshot_path: Path, + cutoff_event_date: str, + min_bucket_count: int, +) -> dict: + df = pd.read_parquet(snapshot_path) + df["event_date"] = pd.to_datetime(df["event_date"]).dt.date + df["reaction_date"] = pd.to_datetime(df["reaction_date"], errors="coerce").dt.date + cutoff_date = pd.to_datetime(cutoff_event_date).date() + df = df[ + (df["event_date"] <= cutoff_date) + & (df["reaction_date"] == df["event_date"]) + & (df["reaction_day_return"] > 0) + & df["event_type"].isin(["earnings_release", "guidance_update"]) + & df["fwd_return_20d"].notna() + ].copy() + global_mean = float(df["fwd_return_20d"].mean()) if not df.empty else 0.0 + + features = [] + for name, weight in FEATURE_SPECS: + bucket_map: dict[str, list[float]] = {} + for row in df.to_dict("records"): + key = _resolve_feature_key(name, row) + if key is None: + continue + bucket_map.setdefault(key, []).append(float(row["fwd_return_20d"])) + values = { + key: sum(values) / len(values) + for key, values in bucket_map.items() + if len(values) >= min_bucket_count + } + features.append( + { + "name": name, + "weight": weight, + "values": values, + } + ) + + return { + "model_type": "bucket_blend_v1", + "target": "fwd_return_20d", + "snapshot_id": snapshot_path.parent.name, + "cutoff_event_date": cutoff_event_date, + "min_bucket_count": min_bucket_count, + "training_rows": int(len(df)), + "global_mean": global_mean, + "features": features, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build a bucket-prior ranking model") + parser.add_argument("--snapshot", required=True, help="Path to the parquet snapshot") + parser.add_argument("--output", required=True, help="Output JSON path") + parser.add_argument("--cutoff-event-date", default="2024-12-31") + parser.add_argument("--min-bucket-count", type=int, default=8) + args = parser.parse_args() + + model = build_model( + snapshot_path=Path(args.snapshot), + cutoff_event_date=args.cutoff_event_date, + min_bucket_count=args.min_bucket_count, + ) + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(model, indent=2)) + print(output) + + +if __name__ == "__main__": + main() diff --git a/apps/tools/deepeval_effective_n.py b/apps/tools/deepeval_effective_n.py new file mode 100644 index 0000000..f1844a6 --- /dev/null +++ b/apps/tools/deepeval_effective_n.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""DeepEval Phase 2.2: Effective Independent Sample Count calculator. + +Reads engine attribution and trade blotter data from a backtest run to compute +per-engine effective_n — the number of truly independent trade observations, +accounting for same-ticker repeated events. + +Usage: + python -m apps.tools.deepeval_effective_n \ + --run-dir runs/v1045_wfv/walk_forward/fold_07/train/bt_* + + Or for all folds: + python -m apps.tools.deepeval_effective_n \ + --wfv-dir runs/v1045_wfv +""" +from __future__ import annotations + +import argparse +import csv +import json +from collections import defaultdict +from datetime import date, timedelta +from pathlib import Path + +MIN_GAP_DAYS = 126 # ~6 months between events for independence +MAX_PER_TICKER = 3 # cap independent observations per ticker + + +def load_trade_blotter(run_dir: Path) -> list[dict]: + """Load trade blotter from parquet or CSV.""" + parquet_path = run_dir / "artifacts" / "trade_blotter.parquet" + if parquet_path.exists(): + try: + import pyarrow.parquet as pq + table = pq.read_table(parquet_path) + return table.to_pylist() + except ImportError: + pass + + csv_path = run_dir / "artifacts" / "trade_blotter.csv" + if csv_path.exists(): + with open(csv_path) as f: + return list(csv.DictReader(f)) + return [] + + +def load_attribution(run_dir: Path) -> dict[str, dict]: + """Load engine attribution CSV.""" + csv_path = run_dir / "metrics" / "attribution_by_engine.csv" + if not csv_path.exists(): + return {} + result = {} + with open(csv_path) as f: + for row in csv.DictReader(f): + result[row["engine_id"]] = row + return result + + +def compute_effective_n(trades: list[dict], min_gap_days: int = MIN_GAP_DAYS) -> dict: + """Compute effective independent sample count for trades grouped by engine. + + Returns dict of engine_id -> { + total_trades, unique_tickers, effective_n, + ticker_breakdown: {ticker: {total, independent}} + } + """ + # Group trades by engine_id + by_engine: dict[str, list[dict]] = defaultdict(list) + for t in trades: + eid = t.get("engine_id", "default") + by_engine[eid].append(t) + + results = {} + for engine_id, engine_trades in by_engine.items(): + # Group by ticker + by_ticker: dict[str, list[date]] = defaultdict(list) + for t in engine_trades: + symbol = t.get("symbol", "") + entry_str = t.get("entry_date", "") + if isinstance(entry_str, str) and entry_str: + try: + entry_date = date.fromisoformat(entry_str[:10]) + except ValueError: + continue + elif isinstance(entry_str, date): + entry_date = entry_str + else: + continue + by_ticker[symbol].append(entry_date) + + ticker_breakdown = {} + effective_n = 0 + for ticker, dates in by_ticker.items(): + sorted_dates = sorted(dates) + independent = 1 # first event always counts + last_counted = sorted_dates[0] + for d in sorted_dates[1:]: + if (d - last_counted).days >= min_gap_days: + independent += 1 + last_counted = d + capped = min(independent, MAX_PER_TICKER) + effective_n += capped + ticker_breakdown[ticker] = { + "total": len(dates), + "independent": independent, + "capped": capped, + } + + results[engine_id] = { + "total_trades": len(engine_trades), + "unique_tickers": len(by_ticker), + "effective_n": effective_n, + "ticker_breakdown": ticker_breakdown, + } + + return results + + +def classify(effective_n: int) -> str: + if effective_n < 3: + return "shadow_only" + elif effective_n < 8: + return "reduced_budget" + else: + return "full_budget" + + +def print_report(engine_stats: dict, attribution: dict) -> None: + """Print formatted report.""" + print(f"\n{'Engine':<50} {'Trades':>6} {'Tickers':>7} {'Eff-N':>5} {'Class':<15} {'PnL':>10}") + print("-" * 100) + + # Sort by effective_n ascending (worst first) + sorted_engines = sorted(engine_stats.items(), key=lambda x: x[1]["effective_n"]) + + shadow_count = 0 + reduced_count = 0 + full_count = 0 + + for eid, stats in sorted_engines: + cls = classify(stats["effective_n"]) + if cls == "shadow_only": + shadow_count += 1 + elif cls == "reduced_budget": + reduced_count += 1 + else: + full_count += 1 + + pnl = "" + if eid in attribution: + pnl = f"${float(attribution[eid].get('net_pnl', 0)):>9,.0f}" + + is_exact = "exact" in eid + marker = " *" if is_exact else "" + print( + f"{eid[:48] + marker:<50} " + f"{stats['total_trades']:>6} " + f"{stats['unique_tickers']:>7} " + f"{stats['effective_n']:>5} " + f"{cls:<15} " + f"{pnl:>10}" + ) + + print("-" * 100) + print(f"Shadow-only (eff_n < 3): {shadow_count}") + print(f"Reduced budget (3 <= eff_n < 8): {reduced_count}") + print(f"Full budget (eff_n >= 8): {full_count}") + print(f"Total engines with trades: {len(engine_stats)}") + print("(* = exact pocket engine)") + + +def process_run_dir(run_dir: Path) -> dict: + """Process a single run directory.""" + trades = load_trade_blotter(run_dir) + if not trades: + print(f"No trades found in {run_dir}") + return {} + attribution = load_attribution(run_dir) + return compute_effective_n(trades) + + +def main() -> None: + parser = argparse.ArgumentParser(description="DeepEval Phase 2.2: Effective-N Calculator") + parser.add_argument("--run-dir", help="Single backtest run directory") + parser.add_argument("--wfv-dir", help="Walk-forward validation directory (processes all folds)") + parser.add_argument("--output", help="Save JSON report to file") + args = parser.parse_args() + + if args.wfv_dir: + # Aggregate across all train folds + wfv_dir = Path(args.wfv_dir) + all_trades = [] + for fold_dir in sorted(wfv_dir.glob("walk_forward/fold_*/train/bt_*")): + trades = load_trade_blotter(fold_dir) + all_trades.extend(trades) + if not all_trades: + print("No trades found in WFV folds") + return + + print(f"Loaded {len(all_trades)} trades from WFV train folds") + engine_stats = compute_effective_n(all_trades) + + # Load attribution from latest fold for PnL reference + latest_fold = sorted(wfv_dir.glob("walk_forward/fold_*/train/bt_*"))[-1] if all_trades else None + attribution = load_attribution(latest_fold) if latest_fold else {} + + elif args.run_dir: + run_dir = Path(args.run_dir) + trades = load_trade_blotter(run_dir) + if not trades: + print(f"No trades found in {run_dir}") + return + engine_stats = compute_effective_n(trades) + attribution = load_attribution(run_dir) + else: + parser.error("Provide --run-dir or --wfv-dir") + return + + print_report(engine_stats, attribution) + + if args.output: + # Prepare serializable report + report = {} + for eid, stats in engine_stats.items(): + report[eid] = { + **stats, + "classification": classify(stats["effective_n"]), + "is_exact": "exact" in eid, + } + with open(args.output, "w") as f: + json.dump(report, f, indent=2) + print(f"\nJSON report saved to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/apps/tools/evaluate_book_overlay.py b/apps/tools/evaluate_book_overlay.py new file mode 100644 index 0000000..d7c7909 --- /dev/null +++ b/apps/tools/evaluate_book_overlay.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import json +from pathlib import Path +from typing import Any + +from apps.backtester.run import _build_merged_snapshot_store +from libs.backtest.allocator import _macro_regime_state +from libs.backtest.manifests import load_manifest, resolve_config +from libs.backtest.overlay import ( + build_overlay_curve, + load_equity_curve_csv, + load_merged_store_from_snapshot_dir, + summarize_overlay_curve, +) +from libs.common.config import get_settings + + +def _parse_date(value: str | None) -> dt.date | None: + if not value: + return None + return dt.date.fromisoformat(value) + + +def _compute_regimes( + *, + snapshot_dir: str | Path, + split: str, + config_path: str | Path, + start_date: dt.date | None, + end_date: dt.date | None, +) -> dict[dt.date, str]: + del split # overlay regimes should cover the full requested window, not a single split + manifest = load_manifest(config_path) + config = resolve_config(manifest, config_root=".") + + raw_snapshot_dir = Path(snapshot_dir) + if (raw_snapshot_dir / "train.parquet").exists() or (raw_snapshot_dir / "test.parquet").exists(): + settings = get_settings() + store = load_merged_store_from_snapshot_dir( + raw_snapshot_dir, + oracle_url=settings.stock_oracle_url, + db_dsn=settings.postgres_dsn, + ) + else: + try: + store = _build_merged_snapshot_store( + manifest, + config, + snapshot_dir_override=str(raw_snapshot_dir), + ) + except FileNotFoundError: + store = _build_merged_snapshot_store( + manifest, + config, + snapshot_dir_override=None, + ) + + if start_date or end_date: + lower = start_date or dt.date.min + upper = end_date or dt.date.max + store = store.slice_by_date_range(lower, upper) + + regimes: dict[dt.date, str] = {} + for date in store.all_trading_days(): + regimes[date] = _macro_regime_state(config, store.get_macro_for_date(date)) + return regimes + + +def _load_spec(path: str | Path) -> dict[str, Any]: + return json.loads(Path(path).read_text()) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Evaluate a regime-switched overlay from book equity curves") + parser.add_argument("--spec", required=True, help="Path to overlay spec JSON") + parser.add_argument("--output-dir", required=True, help="Directory to write overlay outputs") + args = parser.parse_args() + + spec = _load_spec(args.spec) + initial_equity = float(spec.get("initial_equity", 10_000.0)) + + curves = { + book["label"]: load_equity_curve_csv(book["equity_csv"]) + for book in spec["books"] + } + regime_source = spec["regime_source"] + start_date = _parse_date(spec.get("start_date")) + end_date = _parse_date(spec.get("end_date")) + regimes = _compute_regimes( + snapshot_dir=regime_source["snapshot_dir"], + split=regime_source.get("split", "train"), + config_path=regime_source["config_path"], + start_date=start_date, + end_date=end_date, + ) + + curve = build_overlay_curve( + curves=curves, + allocations=spec["allocations"], + regimes_by_date=regimes, + initial_equity=initial_equity, + ) + if start_date: + curve = curve[curve["date"] >= start_date] + if end_date: + curve = curve[curve["date"] <= end_date] + + summary = summarize_overlay_curve(curve, initial_equity=initial_equity) + summary["overlay_name"] = spec.get("overlay_name", Path(args.spec).stem) + summary["books"] = [book["label"] for book in spec["books"]] + summary["allocations"] = spec["allocations"] + + out_dir = Path(args.output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + curve.assign(date=curve["date"].astype(str)).to_csv(out_dir / "overlay_equity.csv", index=False) + (out_dir / "overlay_summary.json").write_text(json.dumps(summary, indent=2) + "\n") + print(json.dumps(summary, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/apps/tools/management_change_analysis.py b/apps/tools/management_change_analysis.py new file mode 100644 index 0000000..66a4d5d --- /dev/null +++ b/apps/tools/management_change_analysis.py @@ -0,0 +1,508 @@ +"""Management Change Alpha Analysis — Phase 1 validation. + +Empirically tests whether management_change events produce tradeable +PEAD-like signals (initial under-reaction followed by drift). + +Analyses: + 1. Directional follow-through decomposition by initial reaction sign + 2. Forward return profiles (mean, median, win rate) by holding period + 3. MAE/MFE profiles + 4. Filter effect simulation (document_quality, volume_ratio, market_cap) + 5. Temporal distribution (monthly, quarterly, earnings-season overlap) + +Usage: + python -m apps.tools.management_change_analysis \ + --snapshot-dir data/datasets/snapshots/midlarge-liquid-long-v1 +""" +from __future__ import annotations + +import argparse +import statistics +from pathlib import Path +from typing import Any + +import pyarrow.parquet as pq + + +def _load_rows(snapshot_dir: Path, split: str) -> list[dict[str, Any]]: + parquet_path = snapshot_dir / f"{split}.parquet" + table = pq.read_table(parquet_path) + return table.to_pylist() + + +def _safe_float(raw: Any) -> float | None: + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def _win_rate(values: list[float]) -> float: + if not values: + return 0.0 + return sum(1 for v in values if v > 0) / len(values) + + +def _pct(value: float) -> str: + return f"{value * 100:+.2f}%" + + +def _fmt_pct(value: float) -> str: + return f"{value:.1%}" + + +# ─────────────────────────────────────────────────────────────────── +# Analysis 1: Directional follow-through +# ─────────────────────────────────────────────────────────────────── + +def analyze_follow_through(mc_rows: list[dict]) -> None: + """Decompose follow-through by initial reaction direction.""" + print("\n" + "=" * 70) + print("1. DIRECTIONAL FOLLOW-THROUGH DECOMPOSITION") + print("=" * 70) + + horizons = [ + ("fwd_return_1d", "1d"), + ("fwd_return_3d", "3d"), + ("fwd_return_5d", "5d"), + ("fwd_return_10d", "10d"), + ("fwd_return_20d", "20d"), + ] + + groups = { + "bullish_reaction (ret > 0)": [r for r in mc_rows if (_safe_float(r.get("reaction_day_return")) or 0) > 0], + "bearish_reaction (ret < 0)": [r for r in mc_rows if (_safe_float(r.get("reaction_day_return")) or 0) < 0], + "flat_reaction (ret == 0)": [r for r in mc_rows if (_safe_float(r.get("reaction_day_return")) or 0) == 0], + } + + for group_name, rows in groups.items(): + if not rows: + continue + print(f"\n {group_name}: N={len(rows)}") + print(f" {'Horizon':<10} {'Mean':>10} {'Median':>10} {'WinRate':>10} {'StdDev':>10}") + print(f" {'-'*50}") + for col, label in horizons: + vals = [float(r[col]) for r in rows if r.get(col) is not None] + if not vals: + continue + mean = statistics.mean(vals) + median = statistics.median(vals) + wr = _win_rate(vals) + sd = statistics.stdev(vals) if len(vals) > 1 else 0.0 + print(f" {label:<10} {_pct(mean):>10} {_pct(median):>10} {_fmt_pct(wr):>10} {_pct(sd):>10}") + + +# ─────────────────────────────────────────────────────────────────── +# Analysis 2: Return profile +# ─────────────────────────────────────────────────────────────────── + +def analyze_return_profile(mc_rows: list[dict]) -> None: + """Forward return statistics at each horizon.""" + print("\n" + "=" * 70) + print("2. RETURN PROFILE (all management_change)") + print("=" * 70) + print(f" Total events: {len(mc_rows)}") + + horizons = [ + ("fwd_return_1d", "1d"), + ("fwd_return_3d", "3d"), + ("fwd_return_5d", "5d"), + ("fwd_return_10d", "10d"), + ("fwd_return_20d", "20d"), + ] + + print(f"\n {'Horizon':<10} {'Mean':>10} {'Median':>10} {'WinRate':>10} {'P25':>10} {'P75':>10}") + print(f" {'-'*60}") + for col, label in horizons: + vals = sorted([float(r[col]) for r in mc_rows if r.get(col) is not None]) + if not vals: + continue + mean = statistics.mean(vals) + median = statistics.median(vals) + wr = _win_rate(vals) + p25 = vals[len(vals) // 4] + p75 = vals[3 * len(vals) // 4] + print(f" {label:<10} {_pct(mean):>10} {_pct(median):>10} {_fmt_pct(wr):>10} {_pct(p25):>10} {_pct(p75):>10}") + + +# ─────────────────────────────────────────────────────────────────── +# Analysis 3: MAE/MFE profile +# ─────────────────────────────────────────────────────────────────── + +def analyze_mae_mfe(mc_rows: list[dict]) -> None: + """Maximum Adverse/Favorable Excursion by horizon.""" + print("\n" + "=" * 70) + print("3. MAE / MFE PROFILE") + print("=" * 70) + + windows = [("3d", "mae_3d", "mfe_3d"), ("5d", "mae_5d", "mfe_5d"), + ("10d", "mae_10d", "mfe_10d"), ("20d", "mae_20d", "mfe_20d")] + + print(f"\n {'Window':<8} {'MAE_mean':>10} {'MAE_med':>10} {'MFE_mean':>10} {'MFE_med':>10} {'MFE/MAE':>10}") + print(f" {'-'*58}") + for label, mae_col, mfe_col in windows: + mae_vals = [abs(float(r[mae_col])) for r in mc_rows if r.get(mae_col) is not None] + mfe_vals = [float(r[mfe_col]) for r in mc_rows if r.get(mfe_col) is not None] + if not mae_vals or not mfe_vals: + continue + mae_mean = statistics.mean(mae_vals) + mae_med = statistics.median(mae_vals) + mfe_mean = statistics.mean(mfe_vals) + mfe_med = statistics.median(mfe_vals) + ratio = mfe_mean / mae_mean if mae_mean > 0 else 0 + print(f" {label:<8} {_pct(mae_mean):>10} {_pct(mae_med):>10} {_pct(mfe_mean):>10} {_pct(mfe_med):>10} {ratio:>10.2f}") + + +# ─────────────────────────────────────────────────────────────────── +# Analysis 4: Filter effect simulation +# ─────────────────────────────────────────────────────────────────── + +def analyze_filter_effects(mc_rows: list[dict]) -> None: + """Test how filters affect count and follow-through quality.""" + print("\n" + "=" * 70) + print("4. FILTER EFFECT SIMULATION") + print("=" * 70) + + filters = [ + ("No filter (baseline)", lambda r: True), + ("reaction_day_return > 0 (bullish)", lambda r: (_safe_float(r.get("reaction_day_return")) or 0) > 0), + ("reaction_day_return > 0.02", lambda r: (_safe_float(r.get("reaction_day_return")) or 0) > 0.02), + ("reaction_day_return > 0.03", lambda r: (_safe_float(r.get("reaction_day_return")) or 0) > 0.03), + ("reaction_day_return > 0.05", lambda r: (_safe_float(r.get("reaction_day_return")) or 0) > 0.05), + ("document_quality >= 0.5", lambda r: (_safe_float(r.get("document_quality_score")) or 0) >= 0.5), + ("volume_ratio >= 1.5x", lambda r: (_safe_float(r.get("volume_ratio_20d")) or 0) >= 1.5), + ("volume_ratio >= 1.2x", lambda r: (_safe_float(r.get("volume_ratio_20d")) or 0) >= 1.2), + ("market_cap >= $2B", lambda r: (_safe_float(r.get("market_cap_proxy")) or 0) >= 2e9), + ("market_cap >= $5B", lambda r: (_safe_float(r.get("market_cap_proxy")) or 0) >= 5e9), + ("close_location >= 0.5", lambda r: (_safe_float(r.get("close_location")) or 0) >= 0.5), + ("close_location >= 0.6", lambda r: (_safe_float(r.get("close_location")) or 0) >= 0.6), + # Combined filters + ("bullish + doc>=0.5", lambda r: ( + (_safe_float(r.get("reaction_day_return")) or 0) > 0 + and (_safe_float(r.get("document_quality_score")) or 0) >= 0.5 + )), + ("bullish + vol>=1.2", lambda r: ( + (_safe_float(r.get("reaction_day_return")) or 0) > 0 + and (_safe_float(r.get("volume_ratio_20d")) or 0) >= 1.2 + )), + ("bullish + close>=0.5", lambda r: ( + (_safe_float(r.get("reaction_day_return")) or 0) > 0 + and (_safe_float(r.get("close_location")) or 0) >= 0.5 + )), + ("bullish + doc>=0.5 + close>=0.5", lambda r: ( + (_safe_float(r.get("reaction_day_return")) or 0) > 0 + and (_safe_float(r.get("document_quality_score")) or 0) >= 0.5 + and (_safe_float(r.get("close_location")) or 0) >= 0.5 + )), + ("bullish + doc>=0.5 + vol>=1.2", lambda r: ( + (_safe_float(r.get("reaction_day_return")) or 0) > 0 + and (_safe_float(r.get("document_quality_score")) or 0) >= 0.5 + and (_safe_float(r.get("volume_ratio_20d")) or 0) >= 1.2 + )), + ("ret>0.03 + doc>=0.5", lambda r: ( + (_safe_float(r.get("reaction_day_return")) or 0) > 0.03 + and (_safe_float(r.get("document_quality_score")) or 0) >= 0.5 + )), + ("ret>0.03 + close>=0.5", lambda r: ( + (_safe_float(r.get("reaction_day_return")) or 0) > 0.03 + and (_safe_float(r.get("close_location")) or 0) >= 0.5 + )), + ] + + horizons = ["fwd_return_5d", "fwd_return_10d", "fwd_return_20d"] + h_labels = ["5d", "10d", "20d"] + + header = f" {'Filter':<42} {'N':>5}" + for h in h_labels: + header += f" {'WR_' + h:>8} {'Mean_' + h:>10}" + header += f" {'AnnFreq':>8}" + print(f"\n{header}") + print(f" {'-' * (len(header) - 2)}") + + # Compute date range for annualization + dates = sorted(set(r.get("event_date", "") for r in mc_rows if r.get("event_date"))) + if len(dates) >= 2: + from datetime import datetime + d0 = datetime.strptime(str(dates[0])[:10], "%Y-%m-%d") + d1 = datetime.strptime(str(dates[-1])[:10], "%Y-%m-%d") + years = max((d1 - d0).days / 365.25, 0.5) + else: + years = 1.0 + + for name, fn in filters: + filtered = [r for r in mc_rows if fn(r)] + n = len(filtered) + ann_freq = n / years + + row_str = f" {name:<42} {n:>5}" + for h_col in horizons: + vals = [float(r[h_col]) for r in filtered if r.get(h_col) is not None] + if vals: + wr = _win_rate(vals) + mean = statistics.mean(vals) + row_str += f" {_fmt_pct(wr):>8} {_pct(mean):>10}" + else: + row_str += f" {'N/A':>8} {'N/A':>10}" + row_str += f" {ann_freq:>7.1f}" + print(row_str) + + +# ─────────────────────────────────────────────────────────────────── +# Analysis 5: Temporal distribution +# ─────────────────────────────────────────────────────────────────── + +def analyze_temporal_distribution(mc_rows: list[dict]) -> None: + """Monthly and quarterly frequency, earnings season overlap.""" + print("\n" + "=" * 70) + print("5. TEMPORAL DISTRIBUTION") + print("=" * 70) + + from datetime import datetime + + months: dict[str, int] = {} + quarters: dict[str, int] = {} + earnings_months = {1, 2, 4, 5, 7, 8, 10, 11} # typical earnings season months + + in_season = 0 + out_season = 0 + + for r in mc_rows: + ed = str(r.get("event_date", ""))[:10] + if len(ed) < 7: + continue + try: + dt = datetime.strptime(ed, "%Y-%m-%d") + except ValueError: + continue + + ym = f"{dt.year}-{dt.month:02d}" + yq = f"{dt.year}-Q{(dt.month - 1) // 3 + 1}" + + months[ym] = months.get(ym, 0) + 1 + quarters[yq] = quarters.get(yq, 0) + 1 + + if dt.month in earnings_months: + in_season += 1 + else: + out_season += 1 + + total = in_season + out_season + + print(f"\n Earnings season months (Jan/Feb/Apr/May/Jul/Aug/Oct/Nov): {in_season} ({in_season/total:.0%})") + print(f" Non-earnings months (Mar/Jun/Sep/Dec): {out_season} ({out_season/total:.0%})") + print(f" --> Temporal diversification: {'GOOD' if out_season / total >= 0.20 else 'POOR'}") + + print(f"\n Quarterly distribution:") + for q in sorted(quarters.keys()): + bar = "#" * quarters[q] + print(f" {q}: {quarters[q]:>3} {bar}") + + # Monthly avg + if months: + avg_per_month = statistics.mean(months.values()) + print(f"\n Average events per month: {avg_per_month:.1f}") + print(f" Total months with events: {len(months)}/{len(months)}") + print(f" Min/Max per month: {min(months.values())}/{max(months.values())}") + + +# ─────────────────────────────────────────────────────────────────── +# Analysis 6: Comparison vs earnings_release baseline +# ─────────────────────────────────────────────────────────────────── + +def analyze_vs_earnings(mc_rows: list[dict], all_rows: list[dict]) -> None: + """Compare MC follow-through to earnings_release baseline.""" + print("\n" + "=" * 70) + print("6. MANAGEMENT_CHANGE vs EARNINGS_RELEASE COMPARISON") + print("=" * 70) + + er_rows = [r for r in all_rows if r.get("event_type") == "earnings_release"] + + # Bullish subsets + mc_bull = [r for r in mc_rows if (_safe_float(r.get("reaction_day_return")) or 0) > 0] + er_bull = [r for r in er_rows if (_safe_float(r.get("reaction_day_return")) or 0) > 0] + + horizons = [ + ("fwd_return_5d", "5d"), + ("fwd_return_10d", "10d"), + ("fwd_return_20d", "20d"), + ] + + print(f"\n ALL events:") + print(f" {'Metric':<20} {'MC (N={len(mc_rows)})':>20} {'ER (N={len(er_rows)})':>20}") + print(f" {'-'*60}") + for col, label in horizons: + mc_vals = [float(r[col]) for r in mc_rows if r.get(col) is not None] + er_vals = [float(r[col]) for r in er_rows if r.get(col) is not None] + mc_wr = _win_rate(mc_vals) if mc_vals else 0 + er_wr = _win_rate(er_vals) if er_vals else 0 + mc_mean = statistics.mean(mc_vals) if mc_vals else 0 + er_mean = statistics.mean(er_vals) if er_vals else 0 + print(f" {label + ' WR':<20} {_fmt_pct(mc_wr):>20} {_fmt_pct(er_wr):>20}") + print(f" {label + ' Mean':<20} {_pct(mc_mean):>20} {_pct(er_mean):>20}") + + print(f"\n BULLISH reaction only (ret > 0):") + print(f" {'Metric':<20} {'MC (N={len(mc_bull)})':>20} {'ER (N={len(er_bull)})':>20}") + print(f" {'-'*60}") + for col, label in horizons: + mc_vals = [float(r[col]) for r in mc_bull if r.get(col) is not None] + er_vals = [float(r[col]) for r in er_bull if r.get(col) is not None] + mc_wr = _win_rate(mc_vals) if mc_vals else 0 + er_wr = _win_rate(er_vals) if er_vals else 0 + mc_mean = statistics.mean(mc_vals) if mc_vals else 0 + er_mean = statistics.mean(er_vals) if er_vals else 0 + print(f" {label + ' WR':<20} {_fmt_pct(mc_wr):>20} {_fmt_pct(er_wr):>20}") + print(f" {label + ' Mean':<20} {_pct(mc_mean):>20} {_pct(er_mean):>20}") + + +# ─────────────────────────────────────────────────────────────────── +# Analysis 7: Reaction magnitude buckets +# ─────────────────────────────────────────────────────────────────── + +def analyze_reaction_buckets(mc_rows: list[dict]) -> None: + """Break down follow-through by reaction magnitude bucket.""" + print("\n" + "=" * 70) + print("7. FOLLOW-THROUGH BY REACTION MAGNITUDE BUCKET") + print("=" * 70) + + buckets = [ + ("ret < -5%", lambda r: (_safe_float(r.get("reaction_day_return")) or 0) < -0.05), + ("-5% <= ret < -2%", lambda r: -0.05 <= (_safe_float(r.get("reaction_day_return")) or 0) < -0.02), + ("-2% <= ret < 0%", lambda r: -0.02 <= (_safe_float(r.get("reaction_day_return")) or 0) < 0), + ("0% <= ret < 2%", lambda r: 0 <= (_safe_float(r.get("reaction_day_return")) or 0) < 0.02), + ("2% <= ret < 5%", lambda r: 0.02 <= (_safe_float(r.get("reaction_day_return")) or 0) < 0.05), + ("5% <= ret < 10%", lambda r: 0.05 <= (_safe_float(r.get("reaction_day_return")) or 0) < 0.10), + ("ret >= 10%", lambda r: (_safe_float(r.get("reaction_day_return")) or 0) >= 0.10), + ] + + print(f"\n {'Bucket':<22} {'N':>5} {'5d_WR':>8} {'5d_Mean':>10} {'10d_WR':>8} {'10d_Mean':>10} {'20d_WR':>8} {'20d_Mean':>10}") + print(f" {'-'*90}") + + for name, fn in buckets: + filtered = [r for r in mc_rows if fn(r)] + n = len(filtered) + if n == 0: + continue + + parts = f" {name:<22} {n:>5}" + for col in ["fwd_return_5d", "fwd_return_10d", "fwd_return_20d"]: + vals = [float(r[col]) for r in filtered if r.get(col) is not None] + if vals: + wr = _win_rate(vals) + mean = statistics.mean(vals) + parts += f" {_fmt_pct(wr):>8} {_pct(mean):>10}" + else: + parts += f" {'N/A':>8} {'N/A':>10}" + print(parts) + + +# ─────────────────────────────────────────────────────────────────── +# Judgment summary +# ─────────────────────────────────────────────────────────────────── + +def print_judgment(mc_rows: list[dict], years: float) -> None: + """Evaluate against Phase 1 pass/fail criteria.""" + print("\n" + "=" * 70) + print("PHASE 1 JUDGMENT CRITERIA") + print("=" * 70) + + # Criterion 1: Bullish reaction -> 5d forward return positive >= 55% + bullish = [r for r in mc_rows if (_safe_float(r.get("reaction_day_return")) or 0) > 0] + bull_5d = [float(r["fwd_return_5d"]) for r in bullish if r.get("fwd_return_5d") is not None] + bull_wr = _win_rate(bull_5d) if bull_5d else 0 + + # Criterion 2: Filtered events >= 30/year + # Use "bullish + doc>=0.5" as a reasonable filter combo + filtered = [r for r in mc_rows + if (_safe_float(r.get("reaction_day_return")) or 0) > 0 + and (_safe_float(r.get("document_quality_score")) or 0) >= 0.5] + ann_freq = len(filtered) / years + + # Criterion 3: Temporal diversification (non-earnings-season >= 20%) + from datetime import datetime + earnings_months = {1, 2, 4, 5, 7, 8, 10, 11} + out_season = 0 + total = 0 + for r in mc_rows: + ed = str(r.get("event_date", ""))[:10] + try: + dt = datetime.strptime(ed, "%Y-%m-%d") + total += 1 + if dt.month not in earnings_months: + out_season += 1 + except ValueError: + pass + temporal_div = out_season / total if total > 0 else 0 + + # Criterion 4: Win rate >= 52% (all bullish MC at 5d) + overall_wr = bull_wr # same as criterion 1 + + # Print results + c1_pass = bull_wr >= 0.55 + c2_pass = ann_freq >= 30 + c3_pass = temporal_div >= 0.20 + c4_pass = overall_wr >= 0.52 + all_pass = c1_pass and c2_pass and c3_pass and c4_pass + + def _status(ok: bool) -> str: + return "PASS" if ok else "FAIL" + + print(f"\n 1. Bullish reaction -> 5d WR >= 55%: {_fmt_pct(bull_wr):>8} [{_status(c1_pass)}]") + print(f" 2. Filtered events >= 30/year: {ann_freq:>7.1f} [{_status(c2_pass)}]") + print(f" 3. Temporal diversification >= 20%: {_fmt_pct(temporal_div):>8} [{_status(c3_pass)}]") + print(f" 4. Win rate >= 52%: {_fmt_pct(overall_wr):>8} [{_status(c4_pass)}]") + print(f"\n OVERALL: {'>>> PROCEED TO PHASE 2 <<<' if all_pass else '>>> DOES NOT PASS — review filter combos above <<<'}") + + # Also check best filter combo that might pass + if not all_pass: + print(f"\n NOTE: Check Section 4 filter combos for alternative thresholds that may pass.") + + +# ─────────────────────────────────────────────────────────────────── +# Main +# ─────────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser(description="Management Change Alpha Analysis") + parser.add_argument("--snapshot-dir", type=str, + default="data/datasets/snapshots/midlarge-liquid-long-v1") + parser.add_argument("--split", type=str, default="train", + help="Which split to analyze (default: train)") + args = parser.parse_args() + + snapshot_dir = Path(args.snapshot_dir) + print(f"Loading {args.split} from {snapshot_dir} ...") + + all_rows = _load_rows(snapshot_dir, args.split) + mc_rows = [r for r in all_rows if r.get("event_type") == "management_change"] + + print(f"Total events: {len(all_rows)}") + print(f"management_change events: {len(mc_rows)}") + + if not mc_rows: + print("No management_change events found. Exiting.") + return + + # Compute years for annualization + from datetime import datetime + dates = sorted(set(str(r.get("event_date", ""))[:10] for r in mc_rows)) + dates = [d for d in dates if len(d) >= 10] + if len(dates) >= 2: + d0 = datetime.strptime(dates[0], "%Y-%m-%d") + d1 = datetime.strptime(dates[-1], "%Y-%m-%d") + years = max((d1 - d0).days / 365.25, 0.5) + else: + years = 1.0 + print(f"Date range: {dates[0]} to {dates[-1]} ({years:.1f} years)") + + analyze_follow_through(mc_rows) + analyze_return_profile(mc_rows) + analyze_mae_mfe(mc_rows) + analyze_filter_effects(mc_rows) + analyze_temporal_distribution(mc_rows) + analyze_vs_earnings(mc_rows, all_rows) + analyze_reaction_buckets(mc_rows) + print_judgment(mc_rows, years) + + +if __name__ == "__main__": + main() diff --git a/apps/tools/run_deepeval_phase1.py b/apps/tools/run_deepeval_phase1.py new file mode 100644 index 0000000..387b697 --- /dev/null +++ b/apps/tools/run_deepeval_phase1.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""DeepEval Phase 1: Run WFV comparison matrix (clean lineage). + +Usage: + python -m apps.tools.run_deepeval_phase1 [--output-root runs/deepeval_phase1] + +Runs walk-forward validation for: + 1.1 Clean lineage baseline + 1.2 Clean lineage replay of legacy v0.326 + +Then prints a comparison matrix. +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from concurrent.futures import ProcessPoolExecutor, as_completed + + +CONFIGS = { + "v1.1_clean_baseline": "configs/experiments/return_max_long_v1.1.json", + "v1.2_clean_replay": "configs/experiments/return_max_long_v1.2.json", +} + +WF_TRAIN_DAYS = 504 +WF_TEST_DAYS = 63 + + +def run_single_wfv(label: str, manifest_path: str, output_root: str) -> dict: + """Run a single WFV and return the summary dict.""" + out_dir = str(Path(output_root) / label) + cmd = [ + sys.executable, "-m", "apps.backtester.run", + "--manifest", manifest_path, + "--walk-forward", + "--wf-train-days", str(WF_TRAIN_DAYS), + "--wf-test-days", str(WF_TEST_DAYS), + "--output-root", out_dir, + "--initial-equity", "100000", + "--snapshot-dir", "data/datasets/snapshots", + ] + print(f"[{label}] Starting WFV...") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"[{label}] FAILED:\n{result.stderr[-500:]}") + return {"label": label, "error": result.stderr[-500:]} + + summary_path = Path(out_dir) / "walk_forward" / "walk_forward_summary.json" + if not summary_path.exists(): + print(f"[{label}] WARNING: summary not found at {summary_path}") + return {"label": label, "error": "summary not found"} + + with open(summary_path) as f: + summary = json.load(f) + print(f"[{label}] Done. Test mean return: {summary['test_aggregate']['mean_return_pct']:.1f}%") + return {"label": label, "summary": summary} + + +def print_comparison_matrix(results: list[dict]) -> None: + """Print a formatted comparison matrix.""" + print("\n" + "=" * 90) + print("DeepEval Phase 1 — WFV Comparison Matrix") + print("=" * 90) + + header = f"{'Config':<25} {'Test Mean%':>10} {'Test Med%':>10} {'Test Worst%':>11} {'Train-Test Gap':>14} {'Test Sharpe':>11}" + print(header) + print("-" * 90) + + for r in results: + label = r["label"] + if "error" in r: + print(f"{label:<25} {'ERROR':>10}") + continue + s = r["summary"] + ta = s["test_aggregate"] + gap = s["gap_stats"] + print( + f"{label:<25} " + f"{ta['mean_return_pct']:>10.1f} " + f"{ta['median_return_pct']:>10.1f} " + f"{ta['worst_return_pct']:>11.1f} " + f"{gap['mean_train_test_return_gap_pct']:>14.1f} " + f"{ta.get('mean_profit_factor', 0):>11.1f}" + ) + + print("=" * 90) + print("\nKey interpretation:") + print(" - v1.1 is the clean-lineage baseline") + print(" - v1.2 is the clean-lineage replay of legacy v0.326 non-exact core") + print(" - Train-Test Gap: lower is better (less overfitting)") + + +def main() -> None: + parser = argparse.ArgumentParser(description="DeepEval Phase 1: WFV Comparison Matrix") + parser.add_argument("--output-root", default="runs/deepeval_phase1", help="Output directory") + parser.add_argument("--parallel", type=int, default=2, help="Max parallel WFV runs") + args = parser.parse_args() + + results = [] + if args.parallel > 1: + with ProcessPoolExecutor(max_workers=args.parallel) as executor: + futures = { + executor.submit(run_single_wfv, label, path, args.output_root): label + for label, path in CONFIGS.items() + } + for future in as_completed(futures): + results.append(future.result()) + else: + for label, path in CONFIGS.items(): + results.append(run_single_wfv(label, path, args.output_root)) + + # Sort by label for consistent output + results.sort(key=lambda r: r["label"]) + print_comparison_matrix(results) + + # Save raw results + out_path = Path(args.output_root) / "phase1_comparison.json" + out_path.parent.mkdir(parents=True, exist_ok=True) + with open(out_path, "w") as f: + json.dump(results, f, indent=2, default=str) + print(f"\nRaw results saved to {out_path}") + + +if __name__ == "__main__": + main() diff --git a/apps/tools/run_fullsplit_batch.py b/apps/tools/run_fullsplit_batch.py new file mode 100644 index 0000000..50d2950 --- /dev/null +++ b/apps/tools/run_fullsplit_batch.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run train/valid/test backtests for multiple manifests.") + parser.add_argument("--manifest", action="append", required=True, help="Experiment manifest path. Repeat for multiple manifests.") + parser.add_argument("--snapshot-dir", default="data/datasets/snapshots", help="Snapshot root directory.") + parser.add_argument("--output-root", required=True, help="Root directory for run artifacts.") + parser.add_argument("--summary-path", required=True, help="Path to write summary JSON.") + return parser.parse_args() + + +def run_split(root: Path, manifest: str, split: str, output_root: Path, snapshot_dir: str) -> dict[str, object]: + cmd = [ + sys.executable, + "apps/backtester/run.py", + "--manifest", + manifest, + "--snapshot-dir", + snapshot_dir, + "--split", + split, + "--output-root", + str(output_root), + ] + result = subprocess.run( + cmd, + cwd=root, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + tail = result.stdout.splitlines()[-5:] if result.stdout else [] + for line in tail: + print(line) + if result.returncode != 0: + raise RuntimeError(f"{Path(manifest).stem} {split} failed:\n{result.stdout}") + + latest = max(output_root.glob("bt_*"), key=lambda path: path.stat().st_mtime) + summary = json.loads((latest / "metrics" / "metrics_summary.json").read_text()) + return { + "run_id": latest.name, + "ret": summary["total_return_pct"], + "dd": summary["max_drawdown_pct"], + "n": summary["trade_count"], + "pf": summary["profit_factor"], + } + + +def main() -> None: + args = parse_args() + root = Path(__file__).resolve().parents[2] + output_root = Path(args.output_root) + output_root.mkdir(parents=True, exist_ok=True) + + rows: list[dict[str, object]] = [] + for manifest in args.manifest: + exp_name = Path(manifest).stem + print(f"RUN {exp_name}", flush=True) + exp_root = output_root / exp_name + exp_root.mkdir(parents=True, exist_ok=True) + metrics = { + split: run_split(root, manifest, split, exp_root, args.snapshot_dir) + for split in ("train", "valid", "test") + } + rows.append({"experiment": exp_name, "metrics": metrics}) + + summary_path = Path(args.summary_path) + summary_path.parent.mkdir(parents=True, exist_ok=True) + summary_path.write_text(json.dumps(rows, indent=2) + "\n") + print(f"WROTE {summary_path}") + for row in rows: + metrics = row["metrics"] + print( + row["experiment"], + f"tr={metrics['train']['ret']:.2f} v={metrics['valid']['ret']:.2f} t={metrics['test']['ret']:.2f}", + ) + + +if __name__ == "__main__": + main() diff --git a/apps/tools/simulate_2week.py b/apps/tools/simulate_2week.py new file mode 100644 index 0000000..aed9a72 --- /dev/null +++ b/apps/tools/simulate_2week.py @@ -0,0 +1,336 @@ +"""2-week paper trading simulation using real pipeline DB + MockBroker. + +Simulates what would have happened during March 8-21, 2026 for sessions v236 and v4.41. +Uses real EventDetector (connects to PostgreSQL pipeline DB) but replaces Alpaca with +a MockBroker that fills orders using Oracle historical price data. + +Usage: + python -m apps.tools.simulate_2week +""" +from __future__ import annotations + +import asyncio +import datetime as dt +import os +import sys +import tempfile +from pathlib import Path +from typing import Any + +# ── Load .env ───────────────────────────────────────────────────────────────── +_ENV_FILE = Path(__file__).parent.parent.parent / ".env" +if _ENV_FILE.exists(): + for _line in _ENV_FILE.read_text().splitlines(): + _line = _line.strip() + if _line and not _line.startswith("#") and "=" in _line: + _k, _, _v = _line.partition("=") + os.environ.setdefault(_k.strip(), _v.strip()) + +# ── Verify Oracle is reachable before heavy imports ─────────────────────────── +_ORACLE_URL = os.environ.get("STOCK_ORACLE_URL", "http://localhost:18001") +_POSTGRES_DSN = os.environ.get("POSTGRES_DSN", "") + +from rich.console import Console +from rich.table import Table +from rich import box as rbox + +console = Console(width=120) + + +# ───────────────────────────────────────────────────────────────────────────── +# MockBroker + helpers (imported from mock_broker module) +# ───────────────────────────────────────────────────────────────────────────── + +from apps.paper_trader.mock_broker import MockBroker, get_event_symbols_from_db, prefetch_bars as _prefetch_bars + + +async def prefetch_bars( + symbols: list[str], + start: dt.date, + end: dt.date, + oracle_url: str, +) -> dict[str, dict[dt.date, dict]]: + return await _prefetch_bars(symbols, start, end, oracle_url, console=console) + + +# ───────────────────────────────────────────────────────────────────────────── +# Simulation runner +# ───────────────────────────────────────────────────────────────────────────── + +async def run_simulation( + session_name: str, + config_path: str, + initial_equity: float, + trading_days: list[dt.date], + bars_cache: dict[str, dict[dt.date, dict]], + db_dsn: str, + oracle_url: str, +) -> dict[str, Any]: + """Run phased simulation for one session. Returns summary.""" + import tempfile + from apps.paper_trader.state import StateManager, SessionRow + from apps.paper_trader.event_detector import EventDetector + from apps.paper_trader.engine import PaperTradingEngine + + # Temp SQLite DB (won't touch production DB) + tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + tmp.close() + tmp_db = tmp.name + + state = StateManager(tmp_db) + session_id = state.create_session( + session_name=session_name, + config_path=config_path, + initial_equity=initial_equity, + ) + session = state.get_session(session_id) + + broker = MockBroker(initial_equity=initial_equity, bars_cache=bars_cache) + detector = EventDetector(db_dsn=db_dsn, oracle_url=oracle_url) + engine = PaperTradingEngine( + session=session, + broker=broker, + state=state, + event_detector=detector, + ) + + all_entries: list[dict] = [] + all_exits: list[dict] = [] + daily_summaries: list[dict] = [] + + console.print(f"\n[bold cyan]Session: {session_name}[/] config: {config_path}") + console.print(f" Equity: ${initial_equity:,.0f} | Days: {trading_days[0]} → {trading_days[-1]}\n") + + for day in trading_days: + # ── run-open: exits + after-close entries ────────────────────────── + broker.set_sim_context(day, "open") + open_result = await engine.run_next_open(target_date=day, force=True) + + # ── run-close: same-day entries ──────────────────────────────────── + broker.set_sim_context(day, "close") + close_result = await engine.run_reaction_close(target_date=day, force=True) + + acct = broker.get_account() + exits_today = open_result.get("exits", []) + entries_open = open_result.get("entries", []) + entries_close = close_result.get("entries", []) + candidates_open = open_result.get("candidates_detected", 0) + candidates_close = close_result.get("candidates_detected", 0) + rejected_open = open_result.get("rejected", []) + rejected_close = close_result.get("rejected", []) + + for e in exits_today: + e["exit_date"] = day.isoformat() + for e in entries_open: + e["entry_date"] = day.isoformat() + e["convention"] = "next_open" + for e in entries_close: + e["entry_date"] = day.isoformat() + e["convention"] = "reaction_close" + + all_exits.extend(exits_today) + all_entries.extend(entries_open + entries_close) + + summary = { + "date": day, + "equity": acct.equity, + "cash": acct.cash, + "market_value": acct.long_market_value, + "positions": len(broker.list_positions()), + "exits": len(exits_today), + "entries": len(entries_open) + len(entries_close), + "candidates": candidates_open + candidates_close, + "rejected": len(rejected_open) + len(rejected_close), + } + daily_summaries.append(summary) + + pnl_today = sum(e.get("pnl", 0) for e in exits_today) + day_str = f"[bold]{day.strftime('%a %m/%d')}[/]" + eq_str = f"${acct.equity:,.2f}" + entries_str = f"{len(entries_open)+len(entries_close)} entries" + exits_str = f"{len(exits_today)} exits" + cand_str = f"{candidates_open + candidates_close} candidates" + pos_str = f"{len(broker.list_positions())} pos" + + details = [] + for e in entries_open + entries_close: + details.append(f" [green] ↳ ENTER {e['symbol']} ({e.get('event_type','?')}, score={e.get('score', 0):.2f}) " + f"× {e['shares']} shares [{e['convention']}][/]") + for e in exits_today: + pnl_col = "green" if e.get("pnl", 0) >= 0 else "red" + details.append(f" [{pnl_col}] ↳ EXIT {e['symbol']} ({e['reason']}) " + f"P&L=${e.get('pnl', 0):+,.2f}[/{pnl_col}]") + for r in (rejected_open + rejected_close)[:3]: # show first 3 rejections + details.append(f" [dim] ↳ skip {r['symbol']} → {r['reason']}[/]") + + console.print(f" {day_str} {eq_str} {entries_str} {exits_str} {cand_str} {pos_str}") + for d in details: + console.print(d) + + # ── Final summary ────────────────────────────────────────────────────── + acct_final = broker.get_account() + total_pnl = acct_final.equity - initial_equity + pct = total_pnl / initial_equity * 100 + + console.print(f"\n [bold]Final:[/] equity=${acct_final.equity:,.2f} " + f"P&L=${total_pnl:+,.2f} ({pct:+.2f}%) " + f"trades={len(all_entries)} entries / {len(all_exits)} exits") + + # Print trade table + if all_entries or all_exits: + tbl = Table(box=rbox.SIMPLE, show_header=True, header_style="bold yellow", padding=(0, 1)) + tbl.add_column("Date", style="dim") + tbl.add_column("Action", style="bold") + tbl.add_column("Symbol") + tbl.add_column("Event Type") + tbl.add_column("Shares", justify="right") + tbl.add_column("Score", justify="right") + tbl.add_column("Convention / Reason") + + for e in sorted(all_entries, key=lambda x: x["entry_date"]): + tbl.add_row( + e["entry_date"], "[green]ENTER[/]", e["symbol"], + e.get("event_type", "?"), + str(e.get("shares", "?")), + f"{e.get('score', 0):.2f}", + e.get("convention", "?"), + ) + for e in sorted(all_exits, key=lambda x: x.get("exit_date", "")): + pnl = e.get("pnl", 0) + pnl_col = "green" if pnl >= 0 else "red" + tbl.add_row( + e.get("exit_date", "?"), f"[{pnl_col}]EXIT[/{pnl_col}]", e["symbol"], + "", + str(abs(int(e.get("shares", 0)))), + f"[{pnl_col}]{pnl:+,.2f}[/{pnl_col}]", + e.get("reason", "?"), + ) + console.print(tbl) + + # Clean up temp DB + try: + Path(tmp_db).unlink() + except Exception: + pass + + return { + "session": session_name, + "total_pnl": total_pnl, + "total_pnl_pct": pct, + "entries": len(all_entries), + "exits": len(all_exits), + "final_equity": acct_final.equity, + "daily": daily_summaries, + } + + +# ───────────────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────────────── + +SESSIONS = [ + { + "name": "v236", + "config": "configs/experiments/return_max_long_v1.236.json", + "equity": 10000.0, + }, + { + "name": "v4.41", + "config": "configs/experiments/return_max_long_v4.41.json", + "equity": 10000.0, + }, +] + +SIM_START = dt.date(2026, 2, 22) +SIM_END = dt.date(2026, 3, 21) + + +async def main() -> None: + from libs.common.time_utils import is_trading_day + + console.print("[bold cyan]═══ 1-Month Paper Trading Simulation (Feb 22 – Mar 21, 2026) ═══[/]") + console.print(f"Oracle URL : {_ORACLE_URL}") + console.print(f"Postgres : {_POSTGRES_DSN[:50]}..." if len(_POSTGRES_DSN) > 50 else f"Postgres : {_POSTGRES_DSN}") + + # ── Trading days ─────────────────────────────────────────────────────── + all_days = [ + SIM_START + dt.timedelta(days=i) + for i in range((SIM_END - SIM_START).days + 1) + ] + trading_days = [d for d in all_days if is_trading_day(d)] + console.print(f"Trading days: {[d.isoformat() for d in trading_days]}\n") + + # ── Get symbols from pipeline DB ─────────────────────────────────────── + console.print("[bold]Fetching event symbols from pipeline DB...[/]") + if not _POSTGRES_DSN: + console.print("[red]ERROR: POSTGRES_DSN not set! Cannot query pipeline DB.[/]") + sys.exit(1) + + event_symbols = await get_event_symbols_from_db( + _POSTGRES_DSN, + start_date=SIM_START - dt.timedelta(days=7), # buffer for label lag + end_date=SIM_END, + console=console, + ) + console.print(f" Found {len(event_symbols)} unique symbols in events") + + # ── Pre-fetch bars ───────────────────────────────────────────────────── + all_symbols = sorted(set(event_symbols + ["SPY", "QQQ"])) + bar_start = SIM_START - dt.timedelta(days=90) # enough history for exits + macro SMA + + console.print(f"\n[bold]Pre-fetching bars for {len(all_symbols)} symbols ({bar_start} → {SIM_END})...[/]") + bars_cache = await prefetch_bars(all_symbols, bar_start, SIM_END, _ORACLE_URL) + fetched = sum(1 for v in bars_cache.values() if v) + console.print(f" Fetched bars for {fetched}/{len(all_symbols)} symbols") + + if fetched == 0: + console.print("[red]ERROR: No bars fetched. Is Oracle running at {_ORACLE_URL}?[/]") + sys.exit(1) + + # ── Suppress verbose logs during simulation ──────────────────────────── + import logging + logging.getLogger("apps.paper_trader").setLevel(logging.WARNING) + logging.getLogger("libs.backtest").setLevel(logging.WARNING) + + # ── Run simulations ──────────────────────────────────────────────────── + console.print("\n[bold]Running simulations...[/]\n" + "═" * 80) + + results = [] + for sess in SESSIONS: + result = await run_simulation( + session_name=sess["name"], + config_path=sess["config"], + initial_equity=sess["equity"], + trading_days=trading_days, + bars_cache=bars_cache, + db_dsn=_POSTGRES_DSN, + oracle_url=_ORACLE_URL, + ) + results.append(result) + console.print("─" * 80) + + # ── Final comparison table ───────────────────────────────────────────── + console.print("\n[bold cyan]═══ Final Results ═══[/]") + tbl = Table(box=rbox.SIMPLE, show_header=True, header_style="bold yellow", padding=(0, 2)) + tbl.add_column("Session") + tbl.add_column("Final Equity", justify="right") + tbl.add_column("P&L", justify="right") + tbl.add_column("P&L %", justify="right") + tbl.add_column("Entries", justify="right") + tbl.add_column("Exits", justify="right") + + for r in results: + pnl_col = "green" if r["total_pnl"] >= 0 else "red" + tbl.add_row( + r["session"], + f"${r['final_equity']:,.2f}", + f"[{pnl_col}]${r['total_pnl']:+,.2f}[/{pnl_col}]", + f"[{pnl_col}]{r['total_pnl_pct']:+.2f}%[/{pnl_col}]", + str(r["entries"]), + str(r["exits"]), + ) + console.print(tbl) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/configs/backtest/return_max_long_v1.json b/configs/backtest/return_max_long_v1.json new file mode 100644 index 0000000..060115b --- /dev/null +++ b/configs/backtest/return_max_long_v1.json @@ -0,0 +1,102 @@ +{ + "strategy_name": "return_max_long_v1", + "dataset_snapshot_id": "midlarge-liquid-long-v1", + "universe": { + "min_price": 15.0, + "min_avg_dollar_volume": 75000000.0, + "min_market_cap_proxy": 2000000000.0, + "exclude_asset_types": ["ETF", "FUND", "ADR", "SPAC"], + "allowed_exchanges": ["NYSE", "NASDAQ", "AMEX"] + }, + "signal": { + "score_threshold": 0.62, + "max_candidates_per_day": 6, + "execution_timing": "next_open", + "decision_timing": "reaction_close", + "ranking_fields": ["-score", "-avg_dollar_volume"], + "scoring_model": "return_max_long_v1", + "a_tier_score_threshold": 0.75 + }, + "risk": { + "per_trade_risk_pct": 0.004, + "per_trade_risk_pct_a_tier": 0.005, + "max_daily_new_risk_pct": 0.015, + "max_positions": 6, + "max_positions_per_sector": 2, + "max_position_value_pct": 0.15, + "max_adv_fraction": 0.02, + "cooldown_after_loss_streak": 0, + "cooldown_days": 0, + "macro_regime_enabled": true, + "macro_regime_mode": "spy_qqq_scaler", + "macro_regime_neutral_size_scaler": 0.6, + "macro_regime_risk_off_size_scaler": 0.35, + "macro_regime_risk_off_a_tier_only": true, + "macro_sma_period": 20, + "stop_atr_multiplier": 2.25, + "backtest_mode": "research", + "kill_switch_cooldown_days": 20, + "kill_switch_log_only": true, + "veto_oneoff_penalty": 0.4, + "veto_parse_confidence_min": 0.6, + "veto_unknown_direction": true, + "veto_bearish_direction": true + }, + "execution": { + "entry_fill_model": "next_open", + "exit_fill_model": "daily_bar_approximation", + "slippage_bps_base": 10.0, + "commission_per_share": 0.005, + "same_bar_priority": "stop_first_conservative", + "target_model": "fixed_r", + "target_1_r": 1.5, + "target_1_fraction": 0.5, + "use_tiered_targets": true, + "a_tier_target_1_r": 2.0, + "a_tier_target_1_fraction": 0.25, + "non_a_tier_target_1_r": 1.5, + "non_a_tier_target_1_fraction": 0.5, + "trailing_model": "pct_6", + "trailing_warmup_days": 3, + "max_holding_days": 15, + "no_follow_through_exit": false, + "early_failure_close_below_entry_and_reaction_close": true, + "early_failure_no_progress_days": 2, + "early_failure_no_progress_r": 0.5, + "early_failure_no_progress_fraction": 0.5 + }, + "reporting": { + "write_trade_blotter": true, + "write_equity_curve": true, + "write_metrics_summary": true, + "generate_plots": false, + "attribution_buckets": ["event_type", "sector", "score_bucket", "engine_id"] + }, + "event_type_profiles": { + "earnings_release": { + "enabled": true, + "max_holding_days_override": 20, + "direction_filter": "any" + }, + "guidance_update": { + "enabled": true, + "max_holding_days_override": 12, + "direction_filter": "any" + }, + "management_change": { + "enabled": true, + "max_holding_days_override": 20, + "direction_filter": "any" + }, + "material_contract": { + "enabled": false + }, + "unknown": { + "enabled": false + }, + "other_material_event": { + "enabled": false + } + }, + "strategy_engine_selection_mode": "interleave" +} diff --git a/configs/overlays/return_book_overlay_v1.json b/configs/overlays/return_book_overlay_v1.json new file mode 100644 index 0000000..7740a4e --- /dev/null +++ b/configs/overlays/return_book_overlay_v1.json @@ -0,0 +1,39 @@ +{ + "overlay_name": "return_book_overlay_v1", + "initial_equity": 10000, + "start_date": "2022-03-03", + "end_date": "2026-03-13", + "regime_source": { + "config_path": "configs/experiments/return_max_long_v6.221.json", + "snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2", + "split": "train" + }, + "books": [ + { + "label": "core", + "equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6.221_equity.csv" + }, + { + "label": "mom", + "equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv" + } + ], + "allocations": { + "risk_on": { + "core": 0.0, + "mom": 1.0 + }, + "neutral": { + "core": 0.0, + "mom": 1.0 + }, + "risk_off": { + "core": 1.0, + "mom": 0.0 + }, + "unknown": { + "core": 1.0, + "mom": 0.0 + } + } +} diff --git a/configs/overlays/return_book_overlay_v1b.json b/configs/overlays/return_book_overlay_v1b.json new file mode 100644 index 0000000..89c5779 --- /dev/null +++ b/configs/overlays/return_book_overlay_v1b.json @@ -0,0 +1,39 @@ +{ + "overlay_name": "return_book_overlay_v1b", + "initial_equity": 10000, + "start_date": "2022-03-03", + "end_date": "2026-03-13", + "regime_source": { + "config_path": "configs/experiments/return_max_long_v6.221.json", + "snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2", + "split": "train" + }, + "books": [ + { + "label": "core", + "equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6.221_equity.csv" + }, + { + "label": "mom", + "equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv" + } + ], + "allocations": { + "risk_on": { + "core": 0.0, + "mom": 1.0 + }, + "neutral": { + "core": 0.0, + "mom": 1.0 + }, + "risk_off": { + "core": 1.0, + "mom": 0.0 + }, + "unknown": { + "core": 0.0, + "mom": 1.0 + } + } +} diff --git a/configs/overlays/return_book_overlay_v1c.json b/configs/overlays/return_book_overlay_v1c.json new file mode 100644 index 0000000..51d5df8 --- /dev/null +++ b/configs/overlays/return_book_overlay_v1c.json @@ -0,0 +1,39 @@ +{ + "overlay_name": "return_book_overlay_v1c", + "initial_equity": 10000, + "start_date": "2022-03-03", + "end_date": "2026-03-13", + "regime_source": { + "config_path": "configs/experiments/return_max_long_v6.221.json", + "snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2", + "split": "train" + }, + "books": [ + { + "label": "core", + "equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6.221_equity.csv" + }, + { + "label": "mom", + "equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv" + } + ], + "allocations": { + "risk_on": { + "core": 0.0, + "mom": 1.0 + }, + "neutral": { + "core": 0.0, + "mom": 1.0 + }, + "risk_off": { + "core": 1.0, + "mom": 0.0 + }, + "unknown": { + "core": 0.5, + "mom": 0.5 + } + } +} diff --git a/configs/overlays/return_book_overlay_v2.json b/configs/overlays/return_book_overlay_v2.json new file mode 100644 index 0000000..c1a0bca --- /dev/null +++ b/configs/overlays/return_book_overlay_v2.json @@ -0,0 +1,39 @@ +{ + "initial_equity": 10000, + "start_date": "2022-03-03", + "end_date": "2026-03-13", + "regime_source": { + "config_path": "configs/experiments/return_max_long_v6.114.json", + "snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2", + "split": "train" + }, + "books": [ + { + "label": "core", + "equity_csv": "runs/fallback_book_compare/return_max_long_v6.114_equity.csv" + }, + { + "label": "mom", + "equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv" + } + ], + "overlay_name": "return_book_overlay_v2", + "allocations": { + "risk_on": { + "core": 0, + "mom": 1 + }, + "neutral": { + "core": 0, + "mom": 1 + }, + "risk_off": { + "core": 1, + "mom": 0 + }, + "unknown": { + "core": 1, + "mom": 0 + } + } +} diff --git a/configs/overlays/return_book_overlay_v2b.json b/configs/overlays/return_book_overlay_v2b.json new file mode 100644 index 0000000..d6137a9 --- /dev/null +++ b/configs/overlays/return_book_overlay_v2b.json @@ -0,0 +1,39 @@ +{ + "initial_equity": 10000, + "start_date": "2022-03-03", + "end_date": "2026-03-13", + "regime_source": { + "config_path": "configs/experiments/return_max_long_v6.114.json", + "snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2", + "split": "train" + }, + "books": [ + { + "label": "core", + "equity_csv": "runs/fallback_book_compare/return_max_long_v6.114_equity.csv" + }, + { + "label": "mom", + "equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv" + } + ], + "overlay_name": "return_book_overlay_v2b", + "allocations": { + "risk_on": { + "core": 0, + "mom": 1 + }, + "neutral": { + "core": 0, + "mom": 1 + }, + "risk_off": { + "core": 1, + "mom": 0 + }, + "unknown": { + "core": 0, + "mom": 1 + } + } +} diff --git a/configs/overlays/return_book_overlay_v3.json b/configs/overlays/return_book_overlay_v3.json new file mode 100644 index 0000000..fb30189 --- /dev/null +++ b/configs/overlays/return_book_overlay_v3.json @@ -0,0 +1,29 @@ +{ + "overlay_name": "return_book_overlay_v3", + "initial_equity": 10000, + "start_date": "2022-03-03", + "end_date": "2026-03-13", + "regime_source": { + "config_path": "configs/experiments/return_max_long_v6.221.json", + "snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2", + "split": "train" + }, + "books": [ + { + "label": "core", + "equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6.221_equity.csv", + "experiment_config": "configs/experiments/return_max_long_v6.221.json" + }, + { + "label": "mom", + "equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv", + "experiment_config": "configs/experiments/return_max_long_v6new.54.json" + } + ], + "allocations": { + "risk_on": {"core": 0.5, "mom": 0.5}, + "neutral": {"core": 0.0, "mom": 1.0}, + "risk_off": {"core": 1.0, "mom": 0.0}, + "unknown": {"core": 1.0, "mom": 0.0} + } +} diff --git a/configs/overlays/return_book_overlay_v3_oot.json b/configs/overlays/return_book_overlay_v3_oot.json new file mode 100644 index 0000000..0b5fd0d --- /dev/null +++ b/configs/overlays/return_book_overlay_v3_oot.json @@ -0,0 +1,39 @@ +{ + "overlay_name": "return_book_overlay_v3_oot", + "initial_equity": 10000, + "start_date": "2020-01-02", + "end_date": "2021-12-31", + "regime_source": { + "config_path": "configs/experiments/return_max_long_v6.221.json", + "snapshot_dir": "data/datasets/snapshots/midlarge-liquid-long-v1-oot-2020-2021", + "split": "train" + }, + "books": [ + { + "label": "core", + "equity_csv": "runs/book_overlay_oot_inputs/csv/core_equity.csv" + }, + { + "label": "mom", + "equity_csv": "runs/book_overlay_oot_inputs/csv/mom_equity.csv" + } + ], + "allocations": { + "risk_on": { + "core": 0.5, + "mom": 0.5 + }, + "neutral": { + "core": 0.0, + "mom": 1.0 + }, + "risk_off": { + "core": 1.0, + "mom": 0.0 + }, + "unknown": { + "core": 1.0, + "mom": 0.0 + } + } +} diff --git a/configs/overlays/return_book_overlay_v3b.json b/configs/overlays/return_book_overlay_v3b.json new file mode 100644 index 0000000..d3b33b2 --- /dev/null +++ b/configs/overlays/return_book_overlay_v3b.json @@ -0,0 +1,29 @@ +{ + "overlay_name": "return_book_overlay_v3b", + "initial_equity": 10000, + "start_date": "2022-03-03", + "end_date": "2026-03-13", + "regime_source": { + "config_path": "configs/experiments/return_max_long_v6.221.json", + "snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2", + "split": "train" + }, + "books": [ + { + "label": "core", + "equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6.221_equity.csv", + "experiment_config": "configs/experiments/return_max_long_v6.221.json" + }, + { + "label": "mom", + "equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv", + "experiment_config": "configs/experiments/return_max_long_v6new.54.json" + } + ], + "allocations": { + "risk_on": {"core": 0.5, "mom": 0.5}, + "neutral": {"core": 0.5, "mom": 0.5}, + "risk_off": {"core": 1.0, "mom": 0.0}, + "unknown": {"core": 1.0, "mom": 0.0} + } +} diff --git a/configs/overlays/return_book_overlay_v3b_oot.json b/configs/overlays/return_book_overlay_v3b_oot.json new file mode 100644 index 0000000..a731073 --- /dev/null +++ b/configs/overlays/return_book_overlay_v3b_oot.json @@ -0,0 +1,39 @@ +{ + "overlay_name": "return_book_overlay_v3b_oot", + "initial_equity": 10000, + "start_date": "2020-01-02", + "end_date": "2021-12-31", + "regime_source": { + "config_path": "configs/experiments/return_max_long_v6.221.json", + "snapshot_dir": "data/datasets/snapshots/midlarge-liquid-long-v1-oot-2020-2021", + "split": "train" + }, + "books": [ + { + "label": "core", + "equity_csv": "runs/book_overlay_oot_inputs/csv/core_equity.csv" + }, + { + "label": "mom", + "equity_csv": "runs/book_overlay_oot_inputs/csv/mom_equity.csv" + } + ], + "allocations": { + "risk_on": { + "core": 0.5, + "mom": 0.5 + }, + "neutral": { + "core": 0.5, + "mom": 0.5 + }, + "risk_off": { + "core": 1.0, + "mom": 0.0 + }, + "unknown": { + "core": 1.0, + "mom": 0.0 + } + } +} diff --git a/configs/ranking_models/return_max_long_same_day_bucket_ranker_v1.json b/configs/ranking_models/return_max_long_same_day_bucket_ranker_v1.json new file mode 100644 index 0000000..677a100 --- /dev/null +++ b/configs/ranking_models/return_max_long_same_day_bucket_ranker_v1.json @@ -0,0 +1,109 @@ +{ + "model_type": "bucket_blend_v1", + "target": "fwd_return_20d", + "snapshot_id": "midlarge-liquid-long-v1", + "cutoff_event_date": "2024-12-31", + "min_bucket_count": 8, + "training_rows": 763, + "global_mean": 0.013861784034629062, + "features": [ + { + "name": "event_type", + "weight": 0.08, + "values": { + "earnings_release": 0.013482702682923942, + "guidance_update": 0.014893624982197144 + } + }, + { + "name": "direction_guidance_combo", + "weight": 0.27, + "values": { + "unknown|not_provided": 0.016789790143916142, + "bullish|raised": 0.019916887018894172, + "bearish|lowered": 0.009396202128923085, + "mixed|not_provided": 0.011859585171721585, + "unknown|inline_or_maintained": 0.0321858937167025, + "mixed|raised": 0.013566486476964852, + "bullish|not_provided": -0.025325870206453084, + "mixed|inline_or_maintained": 0.0026180541246784124, + "bearish|not_provided": -0.008039359344034852, + "mixed|lowered": 0.017688995312151363 + } + }, + { + "name": "reaction_bucket", + "weight": 0.22, + "values": { + "0.0300:0.0500": 0.00774203925550324, + "lt:0.0300": 0.010927064762955756, + "0.0500:0.0800": 0.02576325744106944, + "0.1800:0.2500": 0.034001329902713935, + "0.0800:0.1200": 0.010397204860884156, + "0.1200:0.1800": 0.026897777303804844 + } + }, + { + "name": "close_bucket", + "weight": 0.15, + "values": { + "0.7500:0.8300": 0.017834323072406757, + "ge:0.8300": 0.010522669641600198, + "0.7000:0.7500": 0.016480791808342433, + "0.6500:0.7000": 0.018889228296455927, + "0.5500:0.6500": 0.016620583101258957, + "lt:0.4500": 0.017303539799914436, + "0.4500:0.5500": 0.002514089610550091 + } + }, + { + "name": "volume_bucket", + "weight": 0.12, + "values": { + "1.5000:2.0000": 0.021128635118496122, + "2.0000:3.0000": 0.014945498682820607, + "1.0000:1.5000": 0.0007699318750771301, + "3.0000:4.0000": 0.016891167249830953, + "lt:1.0000": 0.011469857799173266, + "4.0000:6.0000": 0.023075156200472874, + "ge:6.0000": -0.0032525631317377427 + } + }, + { + "name": "gap_bucket", + "weight": 0.08, + "values": { + "0.0000:0.0200": 0.008587188386656924, + "lt:0.0000": 0.01412242601580457, + "0.0500:0.0800": 0.02889925470529481, + "ge:0.1500": -0.001885467827172075, + "0.0200:0.0500": 0.020601190682999287, + "0.0800:0.1500": -0.010483907345619789 + } + }, + { + "name": "document_bucket", + "weight": 0.05, + "values": { + "lt:0.6000": 0.011313536567150808, + "0.7500:0.8000": 0.01432435968599387, + "0.6600:0.7000": 0.017171850148476657, + "0.7000:0.7500": 0.012283079248182768, + "0.8000:0.8500": 0.0331560535366728, + "0.6000:0.6600": 0.02189813112493116 + } + }, + { + "name": "confidence_bucket", + "weight": 0.03, + "values": { + "lt:0.6000": 0.013464462105512902, + "0.7500:0.8000": 0.010978891099686632, + "0.6400:0.7000": 0.010205404135075202, + "0.7000:0.7500": 0.021927902800045987, + "0.8000:0.9000": 0.03458923180770463, + "0.6000:0.6400": 0.0021363000664559304 + } + } + ] +} \ No newline at end of file diff --git a/configs/symbols_midlarge_snapshot_exact.yaml b/configs/symbols_midlarge_snapshot_exact.yaml new file mode 100644 index 0000000..8a0db41 --- /dev/null +++ b/configs/symbols_midlarge_snapshot_exact.yaml @@ -0,0 +1,972 @@ +symbols: +- A +- AA +- AAOI +- AAON +- AAP +- AAPL +- ABBV +- ABNB +- ABT +- ABVX +- ACGL +- ACHC +- ACI +- ACLS +- ACLX +- ACM +- ACMR +- ACN +- ADBE +- ADC +- ADI +- ADM +- ADMA +- ADP +- ADSK +- AEE +- AEIS +- AEM +- AEO +- AEP +- AER +- AFL +- AFRM +- AG +- AGCO +- AHR +- AIG +- AIT +- AJG +- AKAM +- AL +- ALAB +- ALB +- ALC +- ALGM +- ALGN +- ALH +- ALK +- ALKS +- ALL +- ALLY +- ALNY +- ALSN +- ALV +- AMAT +- AMCR +- AMD +- AME +- AMGN +- AMH +- AMPX +- AMT +- AMZN +- ANET +- ANF +- AON +- AOS +- APA +- APD +- APG +- APGE +- APH +- APLD +- APLS +- APO +- APP +- APPF +- APTV +- AR +- ARCC +- ARE +- ARES +- ARM +- ARMK +- ARW +- ARWR +- AS +- ASML +- ASO +- ASTS +- ATI +- ATO +- AUGO +- AVAV +- AVB +- AVGO +- AWI +- AWK +- AXON +- AXP +- AXS +- AXSM +- AXTA +- AXTI +- B +- BA +- BABA +- BAC +- BAH +- BALL +- BAM +- BANC +- BAX +- BBIO +- BBWI +- BBY +- BCE +- BDX +- BE +- BEKE +- BEN +- BF-B +- BFAM +- BFH +- BG +- BHF +- BIDU +- BIIB +- BILI +- BILL +- BIRK +- BJ +- BK +- BKH +- BKR +- BLDR +- BLK +- BLSH +- BMRN +- BMY +- BN +- BNS +- BNTX +- BOOT +- BOX +- BPOP +- BR +- BRK-B +- BRKR +- BRO +- BROS +- BRX +- BSX +- BSY +- BTI +- BTSG +- BTU +- BUD +- BWA +- BX +- BXP +- BYD +- C +- CACI +- CAG +- CAH +- CAKE +- CALM +- CAR +- CARR +- CART +- CAT +- CAVA +- CB +- CBRE +- CC +- CCI +- CCJ +- CCK +- CCL +- CDE +- CDNS +- CDW +- CE +- CEG +- CELC +- CELH +- CENX +- CF +- CFG +- CFLT +- CFR +- CG +- CGNX +- CHD +- CHDN +- CHKP +- CHRD +- CHRW +- CHTR +- CHWY +- CI +- CIEN +- CIFR +- CL +- CLS +- CLX +- CM +- CMC +- CMCSA +- CME +- CMG +- CMS +- CNC +- CNI +- CNK +- CNM +- CNP +- CNQ +- CNR +- CNX +- COF +- COGT +- COHR +- COIN +- COLB +- COO +- COP +- COR +- CORT +- CORZ +- COST +- CP +- CPB +- CPNG +- CPRI +- CPRT +- CPT +- CRBG +- CRDO +- CRH +- CRL +- CRM +- CRNX +- CROX +- CRSP +- CRUS +- CRWD +- CSCO +- CSGP +- CSX +- CTAS +- CTRA +- CTSH +- CTVA +- CUBE +- CVE +- CVLT +- CVNA +- CVS +- CVX +- CWAN +- CWST +- CYTK +- CZR +- D +- DAL +- DAR +- DASH +- DAVE +- DBRG +- DBX +- DCI +- DD +- DDOG +- DE +- DECK +- DELL +- DG +- DHI +- DHR +- DINO +- DIS +- DKNG +- DKS +- DLR +- DLTR +- DOC +- DOCN +- DOCS +- DOCU +- DOV +- DOW +- DOX +- DPZ +- DRI +- DT +- DTE +- DUK +- DUOL +- DVA +- DVN +- DXCM +- EA +- EAT +- EBAY +- EBC +- ECL +- ED +- EDU +- EFX +- EGO +- EIX +- EL +- ELAN +- ELF +- ELS +- ELV +- EMN +- EMR +- ENB +- ENPH +- ENSG +- ENTG +- EOG +- EPAM +- EPD +- EPRT +- EQH +- EQNR +- EQR +- EQT +- ERO +- ES +- ESI +- ESTC +- ET +- ETN +- ETOR +- ETSY +- EVRG +- EW +- EWBC +- EXAS +- EXC +- EXE +- EXEL +- EXLS +- EXP +- EXPD +- EXPE +- EXR +- FAF +- FANG +- FAST +- FBIN +- FCX +- FDS +- FDX +- FE +- FERG +- FHN +- FIGR +- FIS +- FISV +- FITB +- FIVE +- FLEX +- FLNC +- FLR +- FLS +- FLUT +- FNB +- FND +- FNF +- FORM +- FOUR +- FPS +- FR +- FROG +- FRPT +- FRT +- FSLR +- FSLY +- FTAI +- FTI +- FTNT +- FTV +- FUTU +- FWONK +- G +- GAP +- GD +- GDDY +- GDS +- GE +- GEHC +- GEN +- GEO +- GEV +- GFL +- GFS +- GGAL +- GGG +- GH +- GILD +- GKOS +- GLBE +- GLPI +- GLW +- GLXY +- GM +- GME +- GMED +- GNRC +- GOOGL +- GPC +- GPGI +- GPN +- GPOR +- GRMN +- GS +- GTES +- GTLB +- GTLS +- GVA +- GWRE +- GXO +- HAE +- HAL +- HALO +- HAS +- HBAN +- HBM +- HCA +- HCC +- HD +- HESM +- HIG +- HIMS +- HL +- HLI +- HLT +- HOLX +- HON +- HOOD +- HPE +- HPQ +- HQY +- HR +- HRB +- HRI +- HSAI +- HSIC +- HST +- HSY +- HTHT +- HUBS +- HUM +- HUT +- HWM +- HXL +- HYMC +- IAG +- IBKR +- IBM +- ICE +- ICLR +- IFF +- ILMN +- INCY +- INDV +- INGM +- INSM +- INTC +- INVH +- IONQ +- IONS +- IOT +- IP +- IPGP +- IQV +- IR +- IREN +- IRM +- IRTC +- ISRG +- IT +- ITGR +- ITRI +- ITW +- IVZ +- JAZZ +- JBHT +- JBL +- JBTM +- JCI +- JD +- JEF +- JHG +- JNJ +- JPM +- JXN +- KBH +- KBR +- KEX +- KGS +- KMX +- KNSL +- KNX +- KRC +- KTOS +- KVYO +- KYMR +- LASR +- LBRDK +- LBRT +- LEA +- LEU +- LITE +- LKQ +- LLY +- LMND +- LMT +- LNC +- LNT +- LNTH +- LOAR +- LOW +- LPX +- LRCX +- LRN +- LSCC +- LTH +- LULU +- LUNR +- LUV +- LVS +- LW +- LYB +- LYV +- M +- MA +- MAA +- MANH +- MAR +- MASI +- MAT +- MCD +- MCHP +- MCO +- MDB +- MDLZ +- MDT +- MDU +- MEDP +- MELI +- MET +- META +- MFC +- MGA +- MGM +- MHK +- MIDD +- MIR +- MKC +- MKSI +- MKTX +- MMED +- MMM +- MMS +- MMSI +- MMYT +- MNDY +- MNST +- MO +- MOD +- MOH +- MORN +- MOS +- MP +- MPC +- MPLX +- MRK +- MRNA +- MRP +- MRSH +- MRVL +- MS +- MSFT +- MSI +- MSM +- MSTR +- MT +- MTB +- MTCH +- MTDR +- MTH +- MTN +- MTSI +- MU +- MUR +- NBIS +- NBIX +- NCLH +- NDAQ +- NE +- NEM +- NET +- NFLX +- NI +- NICE +- NKE +- NKTR +- NLY +- NOV +- NOW +- NRG +- NSC +- NTAP +- NTNX +- NTR +- NTRA +- NTRS +- NUE +- NVDA +- NVST +- NVT +- NXPI +- NXT +- NYT +- O +- OC +- ODFL +- OKE +- OKLO +- OKTA +- OLED +- OLLI +- OLN +- OMC +- OMF +- ONB +- ONON +- ONTO +- ORA +- ORCL +- ORI +- ORLY +- OS +- OSK +- OTIS +- OVV +- OXY +- PAA +- PAAS +- PANW +- PAYC +- PAYX +- PB +- PBF +- PCAR +- PCG +- PCOR +- PCTY +- PCVX +- PDD +- PEG +- PEGA +- PEN +- PEP +- PFE +- PFG +- PFGC +- PFSI +- PG +- PHM +- PI +- PII +- PINS +- PL +- PLD +- PLNT +- PLTR +- PM +- PNC +- PNFP +- PNR +- PNW +- POOL +- POR +- POST +- POWI +- PPG +- PPL +- PPTA +- PR +- PRAX +- PRIM +- PRMB +- PRU +- PSA +- PSN +- PSTG +- PSX +- PTC +- PTCT +- PTGX +- PVH +- PWR +- PYPL +- QBTS +- QCOM +- QGEN +- QLYS +- QRVO +- QSR +- RAL +- RARE +- RBA +- RBLX +- RBRK +- RCL +- RDDT +- REG +- REXR +- RF +- RGEN +- RGTI +- RH +- RHI +- RIO +- RIVN +- RJF +- RKLB +- RMBS +- RMD +- RNG +- ROIV +- ROKU +- ROL +- ROP +- ROST +- RPM +- RPRX +- RRC +- RRX +- RSG +- RTX +- RVMD +- RVTY +- RY +- RYAN +- RYN +- RYTM +- SAIA +- SAIC +- SANM +- SAP +- SARO +- SBUX +- SCCO +- SCHW +- SCI +- SE +- SEDG +- SEE +- SEI +- SEIC +- SEZL +- SF +- SFM +- SGI +- SHAK +- SHEL +- SHOO +- SHOP +- SHW +- SIG +- SIMO +- SIRI +- SITE +- SITM +- SJM +- SLB +- SLG +- SLM +- SM +- SMCI +- SMG +- SMMT +- SMTC +- SN +- SNDK +- SNOW +- SNPS +- SNY +- SO +- SOC +- SOFI +- SOLV +- SPG +- SPGI +- SPOT +- SPXC +- SRE +- SSB +- SSNC +- SSRM +- ST +- STLD +- STNG +- STT +- STWD +- STX +- STZ +- SU +- SUI +- SW +- SWK +- SWKS +- SYF +- SYK +- SYM +- SYY +- T +- TAP +- TCOM +- TD +- TEAM +- TECH +- TECK +- TEL +- TEM +- TER +- TERN +- TEVA +- TEX +- TFC +- TFX +- TGTX +- THO +- TJX +- TKO +- TMDX +- TMHC +- TMO +- TMUS +- TOL +- TOST +- TPH +- TPR +- TREX +- TRGP +- TRI +- TRMB +- TROW +- TRP +- TRU +- TRV +- TSCO +- TSLA +- TSM +- TSN +- TT +- TTAN +- TTC +- TTD +- TTE +- TTEK +- TTMI +- TTWO +- TW +- TWLO +- TXRH +- TXT +- U +- UAL +- UBER +- UCTT +- UDR +- UGI +- UL +- UMBF +- UNH +- UNM +- UNP +- UPS +- UPST +- URBN +- URI +- USAR +- USB +- USFD +- V +- VAL +- VCTR +- VEEV +- VFC +- VIAV +- VICI +- VICR +- VIK +- VIPS +- VISN +- VKTX +- VLO +- VLTO +- VMC +- VRNS +- VRSK +- VRT +- VRTX +- VSAT +- VSCO +- VSEC +- VSNT +- VST +- VTR +- VVV +- VZ +- W +- WAL +- WAT +- WAY +- WBD +- WBS +- WCN +- WDAY +- WDC +- WEC +- WELL +- WFC +- WFRD +- WGS +- WH +- WHR +- WING +- WIX +- WLK +- WM +- WMB +- WMG +- WMT +- WPC +- WPM +- WRB +- WRBY +- WSC +- WSM +- WULF +- WY +- WYNN +- XOM +- XP +- XPEV +- XPO +- XYL +- XYZ +- YETI +- YUM +- YUMC +- Z +- ZBH +- ZETA +- ZIM +- ZION +- ZM +- ZS +- ZTO +- ZTS diff --git a/libs/backtest/artifacts.py b/libs/backtest/artifacts.py index 9b973eb..38dce8e 100644 --- a/libs/backtest/artifacts.py +++ b/libs/backtest/artifacts.py @@ -20,6 +20,8 @@ from libs.backtest.domain import ( MetricsBundle, OpenPosition, ) +from libs.common.config import get_settings +from libs.common.ids import sha256_checksum_str from libs.common.logging import get_logger logger = get_logger(__name__) @@ -70,6 +72,67 @@ def write_metrics_summary(run_dir: Path, metrics: MetricsBundle) -> Path: return out +def write_snapshot_provenance( + run_dir: Path, + config: BacktestConfig, +) -> dict[str, str]: + """Persist the snapshot manifest and a stable fingerprint for reproducibility checks.""" + snapshot_id = config.dataset_snapshot_id + settings = get_settings() + candidate_paths = [ + Path(settings.parquet_dir) / snapshot_id / "manifest.json", + Path("data/datasets/snapshots") / snapshot_id / "manifest.json", + ] + manifest_path = next((path for path in candidate_paths if path.exists()), None) + if manifest_path is None: + return {} + + manifest_text = manifest_path.read_text() + manifest_data = json.loads(manifest_text) + manifest_snapshot_id = manifest_data.get("snapshot_id") + manifest_output_dir = manifest_data.get("output_dir") + snapshot_dir_name = manifest_path.parent.name + snapshot_id_matches_manifest = manifest_snapshot_id == snapshot_dir_name + output_dir_matches_manifest = ( + True if not manifest_output_dir else Path(str(manifest_output_dir)).name == snapshot_dir_name + ) + if not snapshot_id_matches_manifest or not output_dir_matches_manifest: + logger.warning( + "snapshot_manifest_metadata_mismatch", + dataset_snapshot_id=snapshot_id, + source_manifest_path=str(manifest_path.resolve()), + manifest_snapshot_id=manifest_snapshot_id, + manifest_output_dir=manifest_output_dir, + snapshot_dir_name=snapshot_dir_name, + ) + manifest_copy = run_dir / "snapshot_manifest.json" + manifest_copy.write_text(json.dumps(manifest_data, indent=2)) + + fingerprint = { + "dataset_snapshot_id": snapshot_id, + "source_manifest_path": str(manifest_path.resolve()), + "snapshot_dir_name": snapshot_dir_name, + "manifest_snapshot_id": manifest_snapshot_id, + "manifest_output_dir": manifest_output_dir, + "snapshot_id_matches_manifest": snapshot_id_matches_manifest, + "output_dir_matches_manifest": output_dir_matches_manifest, + "manifest_sha256": sha256_checksum_str(manifest_text), + "snapshot_created_at_utc": manifest_data.get("created_at_utc"), + "code_commit_hash": manifest_data.get("code_commit_hash"), + "feature_version": manifest_data.get("feature_version"), + "parser_version": manifest_data.get("parser_version"), + "label_version": manifest_data.get("label_version"), + "row_counts": manifest_data.get("row_counts"), + "total_rows": manifest_data.get("total_rows"), + } + fingerprint_path = run_dir / "snapshot_fingerprint.json" + fingerprint_path.write_text(json.dumps(fingerprint, indent=2)) + return { + "snapshot_manifest": str(manifest_copy), + "snapshot_fingerprint": str(fingerprint_path), + } + + def write_trade_blotter( run_dir: Path, trades: list[FilledTrade], @@ -431,6 +494,7 @@ def write_all_artifacts( # Core files paths["manifest"] = str(save_manifest(manifest, run_dir)) paths["resolved_config"] = str(save_resolved_config(config, run_dir)) + paths.update(write_snapshot_provenance(run_dir, config)) paths["metadata"] = str( write_metadata( run_dir, run_id, started_at, finished_at, git_hash, diff --git a/libs/backtest/overlay.py b/libs/backtest/overlay.py new file mode 100644 index 0000000..830003f --- /dev/null +++ b/libs/backtest/overlay.py @@ -0,0 +1,193 @@ +"""Evaluate deterministic book overlays from daily equity curves.""" +from __future__ import annotations + +import datetime as dt +import math +from pathlib import Path +from typing import Any + +import pandas as pd + + +def load_merged_store_from_snapshot_dir( + snapshot_dir: str | Path, + *, + oracle_url: str, + db_dsn: str, +): + """Load and merge train/valid/test splits from an explicit snapshot directory.""" + from libs.backtest.snapshot_store import SnapshotStore + + base = Path(snapshot_dir) + stores = [] + for split in ("train", "valid", "test"): + if not (base / f"{split}.parquet").exists(): + continue + stores.append( + SnapshotStore.load( + snapshot_dir=base, + split_name=split, + oracle_url=oracle_url, + db_dsn=db_dsn, + ) + ) + + if not stores: + raise FileNotFoundError(f"No snapshot splits found under {base}") + + merged_candidates: dict[dt.date, dict[tuple[Any, ...], dict[str, Any]]] = {} + merged_bars: dict[str, dict[dt.date, dict[str, Any]]] = {} + merged_macro: dict[dt.date, dict[str, Any]] = {} + + for store in stores: + for exec_date in store.all_execution_dates(): + bucket = merged_candidates.setdefault(exec_date, {}) + for candidate in store.get_candidates_for_date(exec_date): + dedupe_key = ( + candidate.get("event_id"), + candidate.get("symbol"), + candidate.get("execution_date"), + candidate.get("reaction_date"), + ) + bucket.setdefault(dedupe_key, candidate) + for symbol, bars in store._bars.items(): + merged_bars.setdefault(symbol, {}).update(bars) + for macro_date, macro_values in store._macro.items(): + merged_macro.setdefault(macro_date, {}).update(macro_values) + + from libs.backtest.snapshot_store import SnapshotStore + + return SnapshotStore( + candidates_by_exec_date={ + date: list(rows.values()) + for date, rows in merged_candidates.items() + }, + bars_by_symbol_date=merged_bars, + macro_by_date=merged_macro, + ) + + +def load_equity_curve_csv(path: str | Path) -> pd.DataFrame: + """Load a paper backtest equity CSV into a normalized daily returns frame.""" + csv_path = Path(path) + df = pd.read_csv(csv_path, parse_dates=["date"]) + required = {"date", "equity"} + missing = required.difference(df.columns) + if missing: + raise ValueError(f"Missing columns in {csv_path}: {sorted(missing)}") + if df.empty: + raise ValueError(f"Equity CSV has no rows: {csv_path}") + + df = df.sort_values("date").copy() + df["date"] = pd.to_datetime(df["date"]).dt.date + df["equity"] = df["equity"].astype(float) + df["daily_return"] = df["equity"].pct_change().fillna(0.0) + return df[["date", "equity", "daily_return"]] + + +def validate_allocations( + allocations: dict[str, dict[str, float]], + labels: set[str], + *, + tolerance: float = 1e-6, +) -> None: + """Ensure each regime allocation references known labels and sums to 1.""" + if not allocations: + raise ValueError("allocations must not be empty") + if "unknown" not in allocations: + raise ValueError("allocations must include an 'unknown' regime") + + for regime, weights in allocations.items(): + unknown = set(weights).difference(labels) + if unknown: + raise ValueError( + f"Allocation for regime '{regime}' references unknown labels: {sorted(unknown)}" + ) + total = sum(float(weight) for weight in weights.values()) + if abs(total - 1.0) > tolerance: + raise ValueError( + f"Allocation for regime '{regime}' must sum to 1.0, got {total:.6f}" + ) + + +def build_overlay_curve( + *, + curves: dict[str, pd.DataFrame], + allocations: dict[str, dict[str, float]], + regimes_by_date: dict[dt.date, str], + initial_equity: float = 10_000.0, +) -> pd.DataFrame: + """Combine per-book daily returns into a single overlay equity curve.""" + labels = set(curves) + if not labels: + raise ValueError("curves must not be empty") + validate_allocations(allocations, labels) + + merged: pd.DataFrame | None = None + for label, df in curves.items(): + renamed = df.rename( + columns={ + "equity": f"equity_{label}", + "daily_return": f"daily_return_{label}", + } + ) + frame = renamed[["date", f"daily_return_{label}"]] + merged = frame if merged is None else merged.merge(frame, on="date", how="inner") + + if merged is None or merged.empty: + raise ValueError("No overlapping dates across curves") + + merged = merged.sort_values("date").copy() + merged["regime"] = merged["date"].map(regimes_by_date).fillna("unknown") + + overlay_returns: list[float] = [] + for row in merged.itertuples(index=False): + weights = allocations.get(row.regime, allocations["unknown"]) + ret = 0.0 + for label in labels: + ret += float(weights.get(label, 0.0)) * float(getattr(row, f"daily_return_{label}")) + overlay_returns.append(ret) + + merged["overlay_return"] = overlay_returns + equity = initial_equity + overlay_equity: list[float] = [] + for ret in overlay_returns: + equity *= 1.0 + float(ret) + overlay_equity.append(equity) + merged["overlay_equity"] = overlay_equity + return merged[["date", "regime", "overlay_return", "overlay_equity"]] + + +def summarize_overlay_curve(curve: pd.DataFrame, *, initial_equity: float) -> dict[str, Any]: + """Return total return, drawdown, Sharpe, and regime counts for an overlay.""" + if curve.empty: + raise ValueError("curve must not be empty") + + final_equity = float(curve["overlay_equity"].iloc[-1]) + total_return_pct = (final_equity / float(initial_equity) - 1.0) * 100.0 + + peak = float(initial_equity) + max_drawdown_pct = 0.0 + for equity in curve["overlay_equity"]: + peak = max(peak, float(equity)) + drawdown_pct = (peak - float(equity)) / peak * 100.0 if peak > 0 else 0.0 + max_drawdown_pct = max(max_drawdown_pct, drawdown_pct) + + rets = curve["overlay_return"].astype(float) + if len(rets) >= 2 and float(rets.std()) > 0: + sharpe = float(rets.mean() / rets.std() * math.sqrt(252.0)) + else: + sharpe = 0.0 + + regime_counts = { + str(regime): int(count) + for regime, count in curve["regime"].value_counts().sort_index().items() + } + return { + "return_pct": total_return_pct, + "max_dd_pct": max_drawdown_pct, + "sharpe": sharpe, + "final_equity": final_equity, + "day_count": int(len(curve)), + "regime_day_counts": regime_counts, + } diff --git a/libs/backtest/ranking_models.py b/libs/backtest/ranking_models.py new file mode 100644 index 0000000..7e13e2c --- /dev/null +++ b/libs/backtest/ranking_models.py @@ -0,0 +1,120 @@ +"""Lightweight learned ranking models for candidate prioritization.""" +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + + +def resolve_ranking_model_path(model_path: str) -> Path: + path = Path(model_path) + if path.is_absolute(): + return path + return Path.cwd() / path + + +@lru_cache(maxsize=16) +def load_ranking_model(model_path: str) -> dict[str, Any]: + path = resolve_ranking_model_path(model_path) + return json.loads(path.read_text()) + + +def compute_ranking_model_score( + row: dict[str, Any], + model_path: str | None, +) -> float | None: + if not model_path: + return None + model = load_ranking_model(model_path) + model_type = str(model.get("model_type", "")).lower() + if model_type != "bucket_blend_v1": + return None + + features = model.get("features", []) + total_weight = 0.0 + weighted_sum = 0.0 + for feature in features: + weight = float(feature.get("weight", 0.0)) + if weight <= 0: + continue + key = _resolve_feature_key(str(feature.get("name", "")), row) + if key is None: + continue + value = feature.get("values", {}).get(key) + if value is None: + continue + weighted_sum += weight * float(value) + total_weight += weight + + if total_weight <= 0: + fallback = model.get("global_mean") + return float(fallback) if fallback is not None else None + return weighted_sum / total_weight + + +def _resolve_feature_key(name: str, row: dict[str, Any]) -> str | None: + if name == "event_type": + return _safe_text(row.get("event_type")) + if name == "direction_guidance_combo": + direction = _safe_text(row.get("event_direction")) + guidance = _safe_text(row.get("guidance_status")) + if direction is None or guidance is None: + return None + return f"{direction}|{guidance}" + if name == "reaction_bucket": + return _bucketize( + _safe_float(row.get("reaction_day_return")), + [0.03, 0.05, 0.08, 0.12, 0.18, 0.25], + ) + if name == "close_bucket": + return _bucketize( + _safe_float(row.get("close_location")), + [0.45, 0.55, 0.65, 0.70, 0.75, 0.83], + ) + if name == "volume_bucket": + return _bucketize( + _safe_float(row.get("volume_ratio_20d")), + [1.0, 1.5, 2.0, 3.0, 4.0, 6.0], + ) + if name == "gap_bucket": + return _bucketize( + _safe_float(row.get("gap_size")), + [0.0, 0.02, 0.05, 0.08, 0.15], + ) + if name == "document_bucket": + return _bucketize( + _safe_float(row.get("document_quality_score")), + [0.60, 0.66, 0.70, 0.75, 0.80, 0.85], + ) + if name == "confidence_bucket": + return _bucketize( + _safe_float(row.get("parse_confidence_overall")), + [0.60, 0.64, 0.70, 0.75, 0.80, 0.90], + ) + return None + + +def _bucketize(value: float | None, edges: list[float]) -> str | None: + if value is None or not edges: + return None + if value < edges[0]: + return f"lt:{edges[0]:.4f}" + for low, high in zip(edges, edges[1:]): + if low <= value < high: + return f"{low:.4f}:{high:.4f}" + return f"ge:{edges[-1]:.4f}" + + +def _safe_float(raw: Any) -> float | None: + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def _safe_text(raw: Any) -> str | None: + if raw is None: + return None + text = str(raw).strip().lower() + return text or None diff --git a/libs/export/continuation_snapshot.py b/libs/export/continuation_snapshot.py new file mode 100644 index 0000000..c26c3cc --- /dev/null +++ b/libs/export/continuation_snapshot.py @@ -0,0 +1,210 @@ +"""Build continuation-focused snapshots from existing event-day snapshots.""" +from __future__ import annotations + +import datetime as dt +import json +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pyarrow as pa +import pyarrow.parquet as pq + +from libs.common.config import get_settings +from libs.common.time_utils import trading_days_between, utc_now +from libs.export.snapshot_export import _rows_to_table, _temporal_split +from libs.features.market_features import compute_market_features +from libs.labeler.label_generator import _compute_labels_from_bars, _pct_return +from libs.oracle_client.models import PriceBar + + +def _parse_date(raw: Any) -> dt.date | None: + if isinstance(raw, dt.date): + return raw + if isinstance(raw, str): + try: + return dt.date.fromisoformat(raw) + except ValueError: + return None + return None + + +def _iso_ts(date_value: dt.date) -> str: + return f"{date_value.isoformat()}T21:00:00+00:00" + + +def _to_price_bars(date_bars: dict[dt.date, dict[str, Any]]) -> list[PriceBar]: + rows: list[PriceBar] = [] + for date_value in sorted(date_bars): + bar = date_bars[date_value] + rows.append( + PriceBar( + date=date_value.isoformat(), + open=float(bar["open"]), + high=float(bar["high"]), + low=float(bar["low"]), + close=float(bar["close"]), + volume=float(bar["volume"]), + ) + ) + return rows + + +def _continuation_entry_window(exec_date: dt.date, lookback_days: int) -> tuple[dt.date, dt.date] | None: + trading_days = trading_days_between(exec_date, exec_date + dt.timedelta(days=14)) + signal_index = lookback_days + entry_index = lookback_days + 1 + if len(trading_days) <= entry_index: + return None + return trading_days[signal_index], trading_days[entry_index] + + +def _build_continuation_rows_from_bars( + base_rows: list[dict[str, Any]], + bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]], + *, + lookback_days: int = 3, +) -> list[dict[str, Any]]: + transformed: list[dict[str, Any]] = [] + price_bars_cache: dict[str, list[PriceBar]] = {} + + for row in base_rows: + ticker = str(row.get("ticker") or row.get("symbol") or "").upper() + exec_date = _parse_date(row.get("entry_date") or row.get("execution_date")) + source_close_raw = row.get("event_close") or row.get("entry_price_est") + if not ticker or exec_date is None or source_close_raw in (None, 0): + continue + source_close = float(source_close_raw) + + symbol_bars = bars_by_symbol.get(ticker) + if not symbol_bars or exec_date not in symbol_bars: + continue + + dates = _continuation_entry_window(exec_date, lookback_days) + if dates is None: + continue + signal_date, entry_date = dates + if signal_date not in symbol_bars or entry_date not in symbol_bars: + continue + + signal_bar = symbol_bars[signal_date] + signal_close = float(signal_bar["close"]) + if source_close <= 0 or signal_close <= 0: + continue + drift_pct = (signal_close - source_close) / source_close + + price_bars = price_bars_cache.get(ticker) + if price_bars is None: + price_bars = _to_price_bars(symbol_bars) + price_bars_cache[ticker] = price_bars + signal_features = compute_market_features(price_bars, signal_date.isoformat()) + + sorted_dates = sorted(symbol_bars) + entry_idx = sorted_dates.index(entry_date) + bars_from_entry = [symbol_bars[d] for d in sorted_dates[entry_idx:]] + if not bars_from_entry: + continue + entry_price = Decimal(str(bars_from_entry[0]["open"])) + forward_bars = bars_from_entry[1:] + + label_status = "ok" if len(forward_bars) >= 20 else "truncated" + fwd_1d = _pct_return(entry_price, Decimal(str(bars_from_entry[1]["close"]))) if len(bars_from_entry) > 1 else None + lbl_3d = _compute_labels_from_bars(entry_price, forward_bars, 3) + lbl_5d = _compute_labels_from_bars(entry_price, forward_bars, 5) + lbl_10d = _compute_labels_from_bars(entry_price, forward_bars, 10) + lbl_20d = _compute_labels_from_bars(entry_price, forward_bars, 20) + + new_row = dict(row) + original_event_id = str(row.get("event_id") or "") + new_row["event_id"] = f"{original_event_id}::cont_d{lookback_days}" + new_row["original_event_id"] = original_event_id + new_row["original_event_date"] = row.get("event_date") + new_row["original_reaction_date"] = row.get("reaction_date") + new_row["original_entry_date"] = row.get("entry_date") + new_row["original_event_close"] = source_close + new_row["event_date"] = signal_date.isoformat() + new_row["reaction_date"] = signal_date.isoformat() + new_row["entry_date"] = entry_date.isoformat() + new_row["entry_convention"] = "next_open_after_continuation_signal" + new_row["event_timestamp"] = _iso_ts(signal_date) + new_row["event_close"] = signal_close + new_row["entry_price"] = float(entry_price) + new_row["reaction_day_return"] = drift_pct + new_row["continuation_anchor_drift_pct"] = drift_pct + new_row["continuation_anchor_day_return"] = signal_features.get("reaction_day_return") + new_row["close_location"] = signal_features.get("close_location") + new_row["volume_ratio_20d"] = signal_features.get("volume_ratio_20d") + new_row["avg_dollar_volume_20d"] = signal_features.get("avg_dollar_volume_20d") + new_row["gap_size"] = signal_features.get("gap_size") + new_row["atr_14"] = signal_features.get("atr_14") + new_row["reaction_day_low"] = signal_features.get("reaction_day_low") + new_row["reaction_day_high"] = signal_features.get("reaction_day_high") + new_row["fwd_return_1d"] = float(fwd_1d) if fwd_1d is not None else None + new_row["fwd_return_3d"] = float(lbl_3d.get("fwd_return")) if lbl_3d.get("fwd_return") is not None else None + new_row["fwd_return_5d"] = float(lbl_5d.get("fwd_return")) if lbl_5d.get("fwd_return") is not None else None + new_row["fwd_return_10d"] = float(lbl_10d.get("fwd_return")) if lbl_10d.get("fwd_return") is not None else None + new_row["fwd_return_20d"] = float(lbl_20d.get("fwd_return")) if lbl_20d.get("fwd_return") is not None else None + new_row["mfe_3d"] = float(lbl_3d.get("mfe")) if lbl_3d.get("mfe") is not None else None + new_row["mae_3d"] = float(lbl_3d.get("mae")) if lbl_3d.get("mae") is not None else None + new_row["mfe_5d"] = float(lbl_5d.get("mfe")) if lbl_5d.get("mfe") is not None else None + new_row["mae_5d"] = float(lbl_5d.get("mae")) if lbl_5d.get("mae") is not None else None + new_row["mfe_10d"] = float(lbl_10d.get("mfe")) if lbl_10d.get("mfe") is not None else None + new_row["mae_10d"] = float(lbl_10d.get("mae")) if lbl_10d.get("mae") is not None else None + new_row["mfe_20d"] = float(lbl_20d.get("mfe")) if lbl_20d.get("mfe") is not None else None + new_row["mae_20d"] = float(lbl_20d.get("mae")) if lbl_20d.get("mae") is not None else None + new_row["label_status"] = label_status + transformed.append(new_row) + + return transformed + + +async def export_continuation_snapshot_from_base( + *, + base_snapshot_dir: str | Path, + output_dir: str | Path, + snapshot_id: str, + lookback_days: int = 3, +) -> dict[str, Any]: + from libs.backtest.snapshot_store import SnapshotStore + + base_path = Path(base_snapshot_dir) + rows: list[dict[str, Any]] = [] + for split in ("train", "valid", "test"): + table = pq.read_table(base_path / f"{split}.parquet") + rows.extend(table.to_pylist()) + + symbols = sorted({str(r.get("ticker") or r.get("symbol") or "").upper() for r in rows if r.get("ticker") or r.get("symbol")}) + dates = [_parse_date(r.get("entry_date")) for r in rows] + dates = [d for d in dates if d is not None] + if not symbols or not dates: + raise ValueError("Base snapshot has no symbols or dates") + + settings = get_settings() + bars_by_symbol, _ = await SnapshotStore._fetch_price_data( + symbols, + (min(dates) - dt.timedelta(days=45), max(dates) + dt.timedelta(days=45)), + settings.stock_oracle_url, + ) + transformed = _build_continuation_rows_from_bars(rows, bars_by_symbol, lookback_days=lookback_days) + splits = _temporal_split(transformed, "temporal_70_15_15") + + out_path = Path(output_dir) / snapshot_id + out_path.mkdir(parents=True, exist_ok=True) + row_counts: dict[str, int] = {} + for split_name, split_rows in splits.items(): + pq.write_table(_rows_to_table(split_rows), out_path / f"{split_name}.parquet") + row_counts[split_name] = len(split_rows) + + manifest = { + "snapshot_id": snapshot_id, + "created_at_utc": utc_now().isoformat(), + "base_snapshot_id": base_path.name, + "transform": f"continuation_d{lookback_days}", + "split_policy": "temporal_70_15_15", + "row_counts": row_counts, + "total_rows": sum(row_counts.values()), + "output_dir": str(out_path), + } + (out_path / "manifest.json").write_text(json.dumps(manifest, indent=2)) + return manifest + diff --git a/libs/export/merged_snapshot.py b/libs/export/merged_snapshot.py new file mode 100644 index 0000000..dfb5736 --- /dev/null +++ b/libs/export/merged_snapshot.py @@ -0,0 +1,55 @@ +"""Merge multiple snapshot directories and re-split them temporally.""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pyarrow.parquet as pq + +from libs.common.time_utils import utc_now +from libs.export.snapshot_export import _rows_to_table, _temporal_split + + +def export_merged_snapshot( + *, + source_snapshot_dirs: list[str | Path], + output_dir: str | Path, + snapshot_id: str, + split_policy: str = "temporal_70_15_15", +) -> dict[str, Any]: + rows: list[dict[str, Any]] = [] + source_ids: list[str] = [] + + for source_dir in source_snapshot_dirs: + source_path = Path(source_dir) + source_ids.append(source_path.name) + signal_origin = "continuation" if "cont_" in source_path.name else "event_day" + for split in ("train", "valid", "test"): + table = pq.read_table(source_path / f"{split}.parquet") + for row in table.to_pylist(): + merged_row = dict(row) + merged_row.setdefault("signal_origin", signal_origin) + rows.append(merged_row) + + splits = _temporal_split(rows, split_policy) + out_path = Path(output_dir) / snapshot_id + out_path.mkdir(parents=True, exist_ok=True) + + row_counts: dict[str, int] = {} + for split_name, split_rows in splits.items(): + pq.write_table(_rows_to_table(split_rows), out_path / f"{split_name}.parquet") + row_counts[split_name] = len(split_rows) + + manifest = { + "snapshot_id": snapshot_id, + "created_at_utc": utc_now().isoformat(), + "source_snapshot_ids": source_ids, + "transform": "merged_snapshot", + "split_policy": split_policy, + "row_counts": row_counts, + "total_rows": sum(row_counts.values()), + "output_dir": str(out_path), + } + (out_path / "manifest.json").write_text(json.dumps(manifest, indent=2)) + return manifest diff --git a/libs/features/intraday_features.py b/libs/features/intraday_features.py new file mode 100644 index 0000000..908f69b --- /dev/null +++ b/libs/features/intraday_features.py @@ -0,0 +1,135 @@ +"""Intraday volume profile features from 5-minute bars. + +Computes institutional conviction signals from reaction-day intraday data: + - first_half_volume_pct: fraction of volume in 9:30-12:00 + - volume_front_loading_ratio: first_half / second_half volume + - vwap_premium_pct: (close - vwap) / vwap + - institutional_conviction_score: composite [0, 1] +""" +from __future__ import annotations + +from typing import Any + +from libs.common.logging import get_logger + +logger = get_logger(__name__) + +# NYSE session: 9:30 ET - 16:00 ET; midpoint at 12:00 ET +_MIDPOINT_HOUR = 12 +_MIDPOINT_MINUTE = 0 + + +def compute_intraday_features(bars: list[dict[str, Any]]) -> dict[str, Any] | None: + """Compute intraday volume profile features from 5-minute bars. + + Args: + bars: list of dicts with keys: timestamp, open, high, low, close, volume. + timestamp format: "2024-01-15T09:30:00-05:00" or similar. + + Returns: + Feature dict or None if insufficient data. + """ + if not bars or len(bars) < 10: + return None + + first_half_vol = 0 + second_half_vol = 0 + total_volume = 0 + vwap_numerator = 0.0 + + for bar in bars: + vol = int(bar.get("volume", 0)) + if vol <= 0: + continue + + ts = bar.get("timestamp", "") + hour, minute = _extract_hour_minute(ts) + if hour is None: + continue + + total_volume += vol + typical_price = (float(bar["high"]) + float(bar["low"]) + float(bar["close"])) / 3.0 + vwap_numerator += typical_price * vol + + if hour < _MIDPOINT_HOUR or (hour == _MIDPOINT_HOUR and minute == 0): + first_half_vol += vol + else: + second_half_vol += vol + + if total_volume < 100: + return None + + first_half_pct = first_half_vol / total_volume if total_volume > 0 else 0.0 + front_loading = first_half_vol / max(1, second_half_vol) + vwap = vwap_numerator / total_volume if total_volume > 0 else 0.0 + + last_close = float(bars[-1].get("close", 0.0)) + vwap_premium = (last_close - vwap) / vwap if vwap > 0 else 0.0 + + conviction = _compute_conviction_score(first_half_pct, front_loading, vwap_premium) + + return { + "first_half_volume_pct": round(first_half_pct, 4), + "volume_front_loading_ratio": round(front_loading, 4), + "vwap_premium_pct": round(vwap_premium, 6), + "institutional_conviction_score": round(conviction, 4), + } + + +def _compute_conviction_score( + first_half_pct: float, + front_loading: float, + vwap_premium: float, +) -> float: + """Composite conviction score [0, 1]. + + High conviction = volume front-loaded (institutions acting early) + + closing above VWAP (sustained buying pressure). + + Components (equal weight): + - Front-loading: 1.4-2.0x first/second half ratio -> 0.5-1.0 + - VWAP premium: 0%-2%+ close above VWAP -> 0.5-1.0 + - Volume concentration: 55-70% in first half -> 0.5-1.0 + """ + # Front-loading ratio score + if front_loading < 1.0: + fl_score = 0.2 + elif front_loading < 1.4: + fl_score = 0.2 + (front_loading - 1.0) / 0.4 * 0.3 + elif front_loading <= 2.0: + fl_score = 0.5 + (front_loading - 1.4) / 0.6 * 0.5 + else: + fl_score = 1.0 + + # VWAP premium score + if vwap_premium < 0: + vwap_score = max(0.0, 0.3 + vwap_premium * 10) + elif vwap_premium < 0.01: + vwap_score = 0.3 + vwap_premium / 0.01 * 0.4 + elif vwap_premium <= 0.02: + vwap_score = 0.7 + (vwap_premium - 0.01) / 0.01 * 0.3 + else: + vwap_score = 1.0 + + # Volume concentration score + if first_half_pct < 0.45: + conc_score = 0.1 + elif first_half_pct < 0.55: + conc_score = 0.1 + (first_half_pct - 0.45) / 0.10 * 0.4 + elif first_half_pct <= 0.70: + conc_score = 0.5 + (first_half_pct - 0.55) / 0.15 * 0.5 + else: + conc_score = 1.0 + + return max(0.0, min(1.0, (fl_score + vwap_score + conc_score) / 3.0)) + + +def _extract_hour_minute(timestamp_str: str) -> tuple[int | None, int | None]: + """Extract hour and minute from an ISO timestamp string.""" + try: + # Handle formats like "2024-01-15T09:30:00-05:00" or "2024-01-15 09:30:00" + time_part = timestamp_str.split("T")[-1] if "T" in timestamp_str else timestamp_str.split(" ")[-1] + parts = time_part.split(":") + return int(parts[0]), int(parts[1]) + except (IndexError, ValueError): + return None, None diff --git a/models/ranking/return_max_long_v6_138_bucket_blend_v1.json b/models/ranking/return_max_long_v6_138_bucket_blend_v1.json new file mode 100644 index 0000000..3b5a747 --- /dev/null +++ b/models/ranking/return_max_long_v6_138_bucket_blend_v1.json @@ -0,0 +1,77 @@ +{ + "features": [ + { + "name": "event_type", + "values": { + "earnings_release": 0.009541954980765698, + "guidance_update": 0.0009482648915621687, + "management_change": 0.0037943892849668067, + "material_contract": -0.002583401943860385, + "other_material_event": 0.007274953564248645, + "unknown": 0.013718728472747108 + }, + "weight": 1.0 + }, + { + "name": "direction_guidance_combo", + "values": { + "bearish|inline_or_maintained": 0.014849691046284415, + "bearish|lowered": 0.013276250701250034, + "bearish|not_provided": 0.018595717479568417, + "bearish|withdrawn": 0.011369466183441736, + "bullish|inline_or_maintained": 0.02727479326576037, + "bullish|not_provided": 0.004903340259116119, + "bullish|raised": 0.011991806619724226, + "mixed|inline_or_maintained": 0.004622282169307713, + "mixed|lowered": 0.00588359030953816, + "mixed|not_provided": 0.00907448566353367, + "mixed|raised": 0.0024288776644139517, + "mixed|withdrawn": 0.005830410949122928, + "unknown|inline_or_maintained": 0.0037177429402563906, + "unknown|not_provided": 0.006394818363679808 + }, + "weight": 1.0 + }, + { + "name": "reaction_bucket", + "values": { + "0.0300:0.0500": 0.005804302904957638, + "0.0500:0.0800": 0.014028950406920149, + "0.0800:0.1200": 0.013390250099679488, + "0.1200:0.1800": 0.016429699012940856, + "0.1800:0.2500": 0.018091554496172925, + "ge:0.2500": 0.022703549563279107, + "lt:0.0300": 0.007463418336429027 + }, + "weight": 1.0 + }, + { + "name": "close_bucket", + "values": { + "0.4500:0.5500": 0.0037344958793790553, + "0.5500:0.6500": 0.0034088379270604146, + "0.6500:0.7000": 0.0077765367486839505, + "0.7000:0.7500": 0.01365069551914878, + "0.7500:0.8300": 0.005653113232508216, + "ge:0.8300": 0.012257990963233491, + "lt:0.4500": 0.009148577108281387 + }, + "weight": 1.0 + }, + { + "name": "volume_bucket", + "values": { + "1.0000:1.5000": 0.005788743156663131, + "1.5000:2.0000": 0.009552789598084569, + "2.0000:3.0000": 0.010935970477692514, + "3.0000:4.0000": 0.011978407936663975, + "4.0000:6.0000": 0.010133479958808835, + "ge:6.0000": 0.007055591853625428, + "lt:1.0000": 0.006421397824293606 + }, + "weight": 1.0 + } + ], + "global_mean": 0.008588314546783061, + "model_type": "bucket_blend_v1" +} \ No newline at end of file diff --git a/models/ranking/return_max_long_v6_138_bucket_blend_v1_shrunk.json b/models/ranking/return_max_long_v6_138_bucket_blend_v1_shrunk.json new file mode 100644 index 0000000..415915c --- /dev/null +++ b/models/ranking/return_max_long_v6_138_bucket_blend_v1_shrunk.json @@ -0,0 +1,89 @@ +{ + "features": [ + { + "name": "event_type", + "values": { + "earnings_release": 0.009534859441822375, + "guidance_update": 0.0010963278693765272, + "management_change": 0.004044724807777055, + "material_contract": -0.0007368372346631217, + "other_material_event": 0.007315489397042917, + "unknown": 0.013660128656345463 + }, + "weight": 1.0 + }, + { + "name": "direction_guidance_combo", + "values": { + "bearish|inline_or_maintained": 0.01239424928177408, + "bearish|lowered": 0.01289970964466634, + "bearish|not_provided": 0.01687030318081232, + "bearish|withdrawn": 0.010472320494197001, + "bullish|inline_or_maintained": 0.02060105086612562, + "bullish|not_provided": 0.005544205352623413, + "bullish|raised": 0.01194070313514553, + "mixed|inline_or_maintained": 0.0048338038961063985, + "mixed|lowered": 0.006114763321268493, + "mixed|not_provided": 0.009069604427421716, + "mixed|raised": 0.0030268812452264866, + "mixed|withdrawn": 0.006546749545917768, + "unknown|inline_or_maintained": 0.004394211218940651, + "unknown|not_provided": 0.0064175018816022816 + }, + "weight": 1.0 + }, + { + "name": "reaction_bucket", + "values": { + "0.0300:0.0500": 0.005909957995729001, + "0.0500:0.0800": 0.013773521493298689, + "0.0800:0.1200": 0.013052085624123402, + "0.1200:0.1800": 0.01565332431332127, + "0.1800:0.2500": 0.0166181839613838, + "ge:0.2500": 0.0197628756015091, + "lt:0.0300": 0.00746791882136743 + }, + "weight": 1.0 + }, + { + "name": "close_bucket", + "values": { + "0.4500:0.5500": 0.003896289834959189, + "0.5500:0.6500": 0.0035756487682753147, + "0.6500:0.7000": 0.007825292171993205, + "0.7000:0.7500": 0.013334296708375923, + "0.7500:0.8300": 0.005759077178871929, + "ge:0.8300": 0.012200109631743736, + "lt:0.4500": 0.009144800481645066 + }, + "weight": 1.0 + }, + { + "name": "volume_bucket", + "values": { + "1.0000:1.5000": 0.005833464744364727, + "1.5000:2.0000": 0.009533970572693319, + "2.0000:3.0000": 0.010902360514472622, + "3.0000:4.0000": 0.011873936645758554, + "4.0000:6.0000": 0.010074728422229909, + "ge:6.0000": 0.007158115110692828, + "lt:1.0000": 0.006450003985646603 + }, + "weight": 1.0 + }, + { + "name": "gap_bucket", + "values": { + "0.0000:0.0200": 0.008129876651421848, + "0.0200:0.0500": 0.006752810448790125, + "0.0500:0.0800": 0.021429731733563485, + "0.0800:0.1500": 0.005394540658745227, + "ge:0.1500": 0.02707372309534434, + "lt:0.0000": 0.006965288168527164 + }, + "weight": 1.0 + } + ], + "global_mean": 0.008588314546783061, + "model_type": "bucket_blend_v1" +} \ No newline at end of file