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