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
I Luk Kim 5 months ago
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,336 @@
"""2-week paper trading simulation using real pipeline DB + MockBroker.
Simulates what would have happened during March 8-21, 2026 for sessions v236 and v4.41.
Uses real EventDetector (connects to PostgreSQL pipeline DB) but replaces Alpaca with
a MockBroker that fills orders using Oracle historical price data.
Usage:
python -m apps.tools.simulate_2week
"""
from __future__ import annotations
import asyncio
import datetime as dt
import os
import sys
import tempfile
from pathlib import Path
from typing import Any
# ── Load .env ─────────────────────────────────────────────────────────────────
_ENV_FILE = Path(__file__).parent.parent.parent / ".env"
if _ENV_FILE.exists():
for _line in _ENV_FILE.read_text().splitlines():
_line = _line.strip()
if _line and not _line.startswith("#") and "=" in _line:
_k, _, _v = _line.partition("=")
os.environ.setdefault(_k.strip(), _v.strip())
# ── Verify Oracle is reachable before heavy imports ───────────────────────────
_ORACLE_URL = os.environ.get("STOCK_ORACLE_URL", "http://localhost:18001")
_POSTGRES_DSN = os.environ.get("POSTGRES_DSN", "")
from rich.console import Console
from rich.table import Table
from rich import box as rbox
console = Console(width=120)
# ─────────────────────────────────────────────────────────────────────────────
# MockBroker + helpers (imported from mock_broker module)
# ─────────────────────────────────────────────────────────────────────────────
from apps.paper_trader.mock_broker import MockBroker, get_event_symbols_from_db, prefetch_bars as _prefetch_bars
async def prefetch_bars(
symbols: list[str],
start: dt.date,
end: dt.date,
oracle_url: str,
) -> dict[str, dict[dt.date, dict]]:
return await _prefetch_bars(symbols, start, end, oracle_url, console=console)
# ─────────────────────────────────────────────────────────────────────────────
# Simulation runner
# ─────────────────────────────────────────────────────────────────────────────
async def run_simulation(
session_name: str,
config_path: str,
initial_equity: float,
trading_days: list[dt.date],
bars_cache: dict[str, dict[dt.date, dict]],
db_dsn: str,
oracle_url: str,
) -> dict[str, Any]:
"""Run phased simulation for one session. Returns summary."""
import tempfile
from apps.paper_trader.state import StateManager, SessionRow
from apps.paper_trader.event_detector import EventDetector
from apps.paper_trader.engine import PaperTradingEngine
# Temp SQLite DB (won't touch production DB)
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
tmp.close()
tmp_db = tmp.name
state = StateManager(tmp_db)
session_id = state.create_session(
session_name=session_name,
config_path=config_path,
initial_equity=initial_equity,
)
session = state.get_session(session_id)
broker = MockBroker(initial_equity=initial_equity, bars_cache=bars_cache)
detector = EventDetector(db_dsn=db_dsn, oracle_url=oracle_url)
engine = PaperTradingEngine(
session=session,
broker=broker,
state=state,
event_detector=detector,
)
all_entries: list[dict] = []
all_exits: list[dict] = []
daily_summaries: list[dict] = []
console.print(f"\n[bold cyan]Session: {session_name}[/] config: {config_path}")
console.print(f" Equity: ${initial_equity:,.0f} | Days: {trading_days[0]}{trading_days[-1]}\n")
for day in trading_days:
# ── run-open: exits + after-close entries ──────────────────────────
broker.set_sim_context(day, "open")
open_result = await engine.run_next_open(target_date=day, force=True)
# ── run-close: same-day entries ────────────────────────────────────
broker.set_sim_context(day, "close")
close_result = await engine.run_reaction_close(target_date=day, force=True)
acct = broker.get_account()
exits_today = open_result.get("exits", [])
entries_open = open_result.get("entries", [])
entries_close = close_result.get("entries", [])
candidates_open = open_result.get("candidates_detected", 0)
candidates_close = close_result.get("candidates_detected", 0)
rejected_open = open_result.get("rejected", [])
rejected_close = close_result.get("rejected", [])
for e in exits_today:
e["exit_date"] = day.isoformat()
for e in entries_open:
e["entry_date"] = day.isoformat()
e["convention"] = "next_open"
for e in entries_close:
e["entry_date"] = day.isoformat()
e["convention"] = "reaction_close"
all_exits.extend(exits_today)
all_entries.extend(entries_open + entries_close)
summary = {
"date": day,
"equity": acct.equity,
"cash": acct.cash,
"market_value": acct.long_market_value,
"positions": len(broker.list_positions()),
"exits": len(exits_today),
"entries": len(entries_open) + len(entries_close),
"candidates": candidates_open + candidates_close,
"rejected": len(rejected_open) + len(rejected_close),
}
daily_summaries.append(summary)
pnl_today = sum(e.get("pnl", 0) for e in exits_today)
day_str = f"[bold]{day.strftime('%a %m/%d')}[/]"
eq_str = f"${acct.equity:,.2f}"
entries_str = f"{len(entries_open)+len(entries_close)} entries"
exits_str = f"{len(exits_today)} exits"
cand_str = f"{candidates_open + candidates_close} candidates"
pos_str = f"{len(broker.list_positions())} pos"
details = []
for e in entries_open + entries_close:
details.append(f" [green] ↳ ENTER {e['symbol']} ({e.get('event_type','?')}, score={e.get('score', 0):.2f}) "
f"× {e['shares']} shares [{e['convention']}][/]")
for e in exits_today:
pnl_col = "green" if e.get("pnl", 0) >= 0 else "red"
details.append(f" [{pnl_col}] ↳ EXIT {e['symbol']} ({e['reason']}) "
f"P&L=${e.get('pnl', 0):+,.2f}[/{pnl_col}]")
for r in (rejected_open + rejected_close)[:3]: # show first 3 rejections
details.append(f" [dim] ↳ skip {r['symbol']}{r['reason']}[/]")
console.print(f" {day_str} {eq_str} {entries_str} {exits_str} {cand_str} {pos_str}")
for d in details:
console.print(d)
# ── Final summary ──────────────────────────────────────────────────────
acct_final = broker.get_account()
total_pnl = acct_final.equity - initial_equity
pct = total_pnl / initial_equity * 100
console.print(f"\n [bold]Final:[/] equity=${acct_final.equity:,.2f} "
f"P&L=${total_pnl:+,.2f} ({pct:+.2f}%) "
f"trades={len(all_entries)} entries / {len(all_exits)} exits")
# Print trade table
if all_entries or all_exits:
tbl = Table(box=rbox.SIMPLE, show_header=True, header_style="bold yellow", padding=(0, 1))
tbl.add_column("Date", style="dim")
tbl.add_column("Action", style="bold")
tbl.add_column("Symbol")
tbl.add_column("Event Type")
tbl.add_column("Shares", justify="right")
tbl.add_column("Score", justify="right")
tbl.add_column("Convention / Reason")
for e in sorted(all_entries, key=lambda x: x["entry_date"]):
tbl.add_row(
e["entry_date"], "[green]ENTER[/]", e["symbol"],
e.get("event_type", "?"),
str(e.get("shares", "?")),
f"{e.get('score', 0):.2f}",
e.get("convention", "?"),
)
for e in sorted(all_exits, key=lambda x: x.get("exit_date", "")):
pnl = e.get("pnl", 0)
pnl_col = "green" if pnl >= 0 else "red"
tbl.add_row(
e.get("exit_date", "?"), f"[{pnl_col}]EXIT[/{pnl_col}]", e["symbol"],
"",
str(abs(int(e.get("shares", 0)))),
f"[{pnl_col}]{pnl:+,.2f}[/{pnl_col}]",
e.get("reason", "?"),
)
console.print(tbl)
# Clean up temp DB
try:
Path(tmp_db).unlink()
except Exception:
pass
return {
"session": session_name,
"total_pnl": total_pnl,
"total_pnl_pct": pct,
"entries": len(all_entries),
"exits": len(all_exits),
"final_equity": acct_final.equity,
"daily": daily_summaries,
}
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
SESSIONS = [
{
"name": "v236",
"config": "configs/experiments/return_max_long_v1.236.json",
"equity": 10000.0,
},
{
"name": "v4.41",
"config": "configs/experiments/return_max_long_v4.41.json",
"equity": 10000.0,
},
]
SIM_START = dt.date(2026, 2, 22)
SIM_END = dt.date(2026, 3, 21)
async def main() -> None:
from libs.common.time_utils import is_trading_day
console.print("[bold cyan]═══ 1-Month Paper Trading Simulation (Feb 22 Mar 21, 2026) ═══[/]")
console.print(f"Oracle URL : {_ORACLE_URL}")
console.print(f"Postgres : {_POSTGRES_DSN[:50]}..." if len(_POSTGRES_DSN) > 50 else f"Postgres : {_POSTGRES_DSN}")
# ── Trading days ───────────────────────────────────────────────────────
all_days = [
SIM_START + dt.timedelta(days=i)
for i in range((SIM_END - SIM_START).days + 1)
]
trading_days = [d for d in all_days if is_trading_day(d)]
console.print(f"Trading days: {[d.isoformat() for d in trading_days]}\n")
# ── Get symbols from pipeline DB ───────────────────────────────────────
console.print("[bold]Fetching event symbols from pipeline DB...[/]")
if not _POSTGRES_DSN:
console.print("[red]ERROR: POSTGRES_DSN not set! Cannot query pipeline DB.[/]")
sys.exit(1)
event_symbols = await get_event_symbols_from_db(
_POSTGRES_DSN,
start_date=SIM_START - dt.timedelta(days=7), # buffer for label lag
end_date=SIM_END,
console=console,
)
console.print(f" Found {len(event_symbols)} unique symbols in events")
# ── Pre-fetch bars ─────────────────────────────────────────────────────
all_symbols = sorted(set(event_symbols + ["SPY", "QQQ"]))
bar_start = SIM_START - dt.timedelta(days=90) # enough history for exits + macro SMA
console.print(f"\n[bold]Pre-fetching bars for {len(all_symbols)} symbols ({bar_start}{SIM_END})...[/]")
bars_cache = await prefetch_bars(all_symbols, bar_start, SIM_END, _ORACLE_URL)
fetched = sum(1 for v in bars_cache.values() if v)
console.print(f" Fetched bars for {fetched}/{len(all_symbols)} symbols")
if fetched == 0:
console.print("[red]ERROR: No bars fetched. Is Oracle running at {_ORACLE_URL}?[/]")
sys.exit(1)
# ── Suppress verbose logs during simulation ────────────────────────────
import logging
logging.getLogger("apps.paper_trader").setLevel(logging.WARNING)
logging.getLogger("libs.backtest").setLevel(logging.WARNING)
# ── Run simulations ────────────────────────────────────────────────────
console.print("\n[bold]Running simulations...[/]\n" + "" * 80)
results = []
for sess in SESSIONS:
result = await run_simulation(
session_name=sess["name"],
config_path=sess["config"],
initial_equity=sess["equity"],
trading_days=trading_days,
bars_cache=bars_cache,
db_dsn=_POSTGRES_DSN,
oracle_url=_ORACLE_URL,
)
results.append(result)
console.print("" * 80)
# ── Final comparison table ─────────────────────────────────────────────
console.print("\n[bold cyan]═══ Final Results ═══[/]")
tbl = Table(box=rbox.SIMPLE, show_header=True, header_style="bold yellow", padding=(0, 2))
tbl.add_column("Session")
tbl.add_column("Final Equity", justify="right")
tbl.add_column("P&L", justify="right")
tbl.add_column("P&L %", justify="right")
tbl.add_column("Entries", justify="right")
tbl.add_column("Exits", justify="right")
for r in results:
pnl_col = "green" if r["total_pnl"] >= 0 else "red"
tbl.add_row(
r["session"],
f"${r['final_equity']:,.2f}",
f"[{pnl_col}]${r['total_pnl']:+,.2f}[/{pnl_col}]",
f"[{pnl_col}]{r['total_pnl_pct']:+.2f}%[/{pnl_col}]",
str(r["entries"]),
str(r["exits"]),
)
console.print(tbl)
if __name__ == "__main__":
asyncio.run(main())

@ -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

@ -20,6 +20,8 @@ from libs.backtest.domain import (
MetricsBundle,
OpenPosition,
)
from libs.common.config import get_settings
from libs.common.ids import sha256_checksum_str
from libs.common.logging import get_logger
logger = get_logger(__name__)
@ -70,6 +72,67 @@ def write_metrics_summary(run_dir: Path, metrics: MetricsBundle) -> Path:
return out
def write_snapshot_provenance(
run_dir: Path,
config: BacktestConfig,
) -> dict[str, str]:
"""Persist the snapshot manifest and a stable fingerprint for reproducibility checks."""
snapshot_id = config.dataset_snapshot_id
settings = get_settings()
candidate_paths = [
Path(settings.parquet_dir) / snapshot_id / "manifest.json",
Path("data/datasets/snapshots") / snapshot_id / "manifest.json",
]
manifest_path = next((path for path in candidate_paths if path.exists()), None)
if manifest_path is None:
return {}
manifest_text = manifest_path.read_text()
manifest_data = json.loads(manifest_text)
manifest_snapshot_id = manifest_data.get("snapshot_id")
manifest_output_dir = manifest_data.get("output_dir")
snapshot_dir_name = manifest_path.parent.name
snapshot_id_matches_manifest = manifest_snapshot_id == snapshot_dir_name
output_dir_matches_manifest = (
True if not manifest_output_dir else Path(str(manifest_output_dir)).name == snapshot_dir_name
)
if not snapshot_id_matches_manifest or not output_dir_matches_manifest:
logger.warning(
"snapshot_manifest_metadata_mismatch",
dataset_snapshot_id=snapshot_id,
source_manifest_path=str(manifest_path.resolve()),
manifest_snapshot_id=manifest_snapshot_id,
manifest_output_dir=manifest_output_dir,
snapshot_dir_name=snapshot_dir_name,
)
manifest_copy = run_dir / "snapshot_manifest.json"
manifest_copy.write_text(json.dumps(manifest_data, indent=2))
fingerprint = {
"dataset_snapshot_id": snapshot_id,
"source_manifest_path": str(manifest_path.resolve()),
"snapshot_dir_name": snapshot_dir_name,
"manifest_snapshot_id": manifest_snapshot_id,
"manifest_output_dir": manifest_output_dir,
"snapshot_id_matches_manifest": snapshot_id_matches_manifest,
"output_dir_matches_manifest": output_dir_matches_manifest,
"manifest_sha256": sha256_checksum_str(manifest_text),
"snapshot_created_at_utc": manifest_data.get("created_at_utc"),
"code_commit_hash": manifest_data.get("code_commit_hash"),
"feature_version": manifest_data.get("feature_version"),
"parser_version": manifest_data.get("parser_version"),
"label_version": manifest_data.get("label_version"),
"row_counts": manifest_data.get("row_counts"),
"total_rows": manifest_data.get("total_rows"),
}
fingerprint_path = run_dir / "snapshot_fingerprint.json"
fingerprint_path.write_text(json.dumps(fingerprint, indent=2))
return {
"snapshot_manifest": str(manifest_copy),
"snapshot_fingerprint": str(fingerprint_path),
}
def write_trade_blotter(
run_dir: Path,
trades: list[FilledTrade],
@ -431,6 +494,7 @@ def write_all_artifacts(
# Core files
paths["manifest"] = str(save_manifest(manifest, run_dir))
paths["resolved_config"] = str(save_resolved_config(config, run_dir))
paths.update(write_snapshot_provenance(run_dir, config))
paths["metadata"] = str(
write_metadata(
run_dir, run_id, started_at, finished_at, git_hash,

@ -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…
Cancel
Save