Add overlay engine, ranking models, snapshot pipelines, and research tools
New libs: overlay curve builder, ranking models, continuation/merged snapshot export, intraday features. New tools: overlay evaluator, ranking model builder, deep evaluation, fullsplit batch runner. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>main
parent
9b92ab6589
commit
2aba6418e6
@ -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()
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
@ -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,
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
Loading…
Reference in New Issue