1215 lines
43 KiB
Python
1215 lines
43 KiB
Python
"""Point-in-time fundamentals rank-overlay research.
|
|
|
|
The production qualification gate is unchanged. The runner first measures
|
|
30-session factor IC, then reorders already-qualified candidates with quality,
|
|
growth, or balanced fundamental ranks. It supports the original registered
|
|
matrix and a split-safe follow-up sensitivity. It writes JSON, Markdown, CSV,
|
|
and a portable ZIP bundle.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import csv
|
|
import hashlib
|
|
import json
|
|
import multiprocessing
|
|
import os
|
|
import pickle
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
from collections import defaultdict
|
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
from datetime import date, datetime, time, timezone
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
CACHE_VERSION = "fundamentals-overlay-v1"
|
|
NY = ZoneInfo("America/New_York")
|
|
COMPOSITES = ("quality", "growth", "balanced")
|
|
ORIGINAL_PROTOCOL = "original"
|
|
SPLIT_SAFE_PROTOCOL = "split-safe"
|
|
WEIGHTS = (0.10, 0.20, 0.30, 0.40)
|
|
SPLIT_SAFE_WEIGHTS = (0.05, 0.10, 0.15)
|
|
|
|
|
|
def _arm_matrix(weights: tuple[float, ...]) -> tuple[dict[str, Any], ...]:
|
|
"""Build the control plus three bounded overlay families."""
|
|
return (
|
|
{
|
|
"id": "control_w00",
|
|
"label": "Production 80/20 momentum-volatility rank",
|
|
"composite": None,
|
|
"weight": 0.0,
|
|
},
|
|
*tuple(
|
|
{
|
|
"id": f"{composite}_w{round(weight * 100):02d}",
|
|
"label": f"{composite.title()} overlay {round(weight * 100)}%",
|
|
"composite": composite,
|
|
"weight": weight,
|
|
}
|
|
for composite in COMPOSITES
|
|
for weight in weights
|
|
),
|
|
)
|
|
|
|
|
|
ARMS = _arm_matrix(WEIGHTS)
|
|
N_TRIALS = len(ARMS)
|
|
SPLIT_SAFE_ARMS = _arm_matrix(SPLIT_SAFE_WEIGHTS)
|
|
SPLIT_SAFE_N_TRIALS = len(SPLIT_SAFE_ARMS)
|
|
|
|
|
|
def _parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("snapshot")
|
|
parser.add_argument(
|
|
"--workers", type=int, default=max(1, multiprocessing.cpu_count() - 1)
|
|
)
|
|
parser.add_argument("--out", default=None)
|
|
parser.add_argument(
|
|
"--candidate-cache", default="reports/.cache/fundamentals-candidates.pkl"
|
|
)
|
|
parser.add_argument(
|
|
"--fundamentals-cache", default="reports/.cache/fundamentals-scores.pkl"
|
|
)
|
|
parser.add_argument("--train-end", default="2024-01-01")
|
|
parser.add_argument("--test-start", default="2025-01-01")
|
|
parser.add_argument(
|
|
"--protocol",
|
|
choices=(ORIGINAL_PROTOCOL, SPLIT_SAFE_PROTOCOL),
|
|
default=ORIGINAL_PROTOCOL,
|
|
)
|
|
parser.add_argument("--allow-spawn", action="store_true")
|
|
parser.add_argument("--quiet", action="store_true")
|
|
return parser.parse_args()
|
|
|
|
|
|
def _sqlite_url(path: Path) -> str:
|
|
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
|
|
|
|
|
|
def _default_out(protocol: str = ORIGINAL_PROTOCOL) -> Path:
|
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
label = (
|
|
"fundamentals-splitsafe"
|
|
if protocol == SPLIT_SAFE_PROTOCOL
|
|
else "fundamentals-overlay"
|
|
)
|
|
return Path("reports") / f"{label}-{stamp}.json"
|
|
|
|
|
|
def _snapshot_hash(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _cache_key(snapshot: Path, suffix: dict[str, Any]) -> dict[str, Any]:
|
|
stat = snapshot.stat()
|
|
return {
|
|
"version": CACHE_VERSION,
|
|
"snapshot": str(snapshot.resolve()),
|
|
"size": stat.st_size,
|
|
"mtime_ns": stat.st_mtime_ns,
|
|
**suffix,
|
|
}
|
|
|
|
|
|
def _load_cache(path: Path, key: dict[str, Any]) -> Any | None:
|
|
if not path.exists():
|
|
return None
|
|
with path.open("rb") as handle:
|
|
payload = pickle.load(handle) # noqa: S301 - trusted local cache
|
|
return payload.get("value") if payload.get("key") == key else None
|
|
|
|
|
|
def _save_cache(path: Path, key: dict[str, Any], value: Any) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("wb") as handle:
|
|
pickle.dump(
|
|
{"key": key, "value": value}, handle, protocol=pickle.HIGHEST_PROTOCOL
|
|
)
|
|
|
|
|
|
def _git_commit() -> str | None:
|
|
try:
|
|
return subprocess.run(
|
|
["git", "rev-parse", "HEAD"],
|
|
cwd=ROOT,
|
|
capture_output=True,
|
|
check=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
except (OSError, subprocess.CalledProcessError):
|
|
return None
|
|
|
|
|
|
async def _load_snapshot(snapshot: Path, quiet: bool) -> dict[str, Any]:
|
|
from app.models.earnings_event import EarningsEvent
|
|
from app.models.fundamental_snapshot import FundamentalSnapshot
|
|
from app.models.ohlcv import OHLCVRecord
|
|
from app.models.ticker import Ticker
|
|
from app.services import backtest_service as bt
|
|
from app.services.admin_service import get_activation_config
|
|
from app.services.paper_trade_service import get_exit_policy
|
|
from app.services.recommendation_service import get_recommendation_config
|
|
|
|
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
|
session_factory = async_sessionmaker(
|
|
engine, class_=AsyncSession, expire_on_commit=False
|
|
)
|
|
try:
|
|
async with session_factory() as db:
|
|
config = await get_recommendation_config(db)
|
|
activation = await get_activation_config(db)
|
|
exit_config = await get_exit_policy(db)
|
|
benchmark = await bt._load_benchmark_closes_for_backtest(
|
|
db, days=None, refresh=False
|
|
)
|
|
tickers = list(
|
|
(await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars()
|
|
)
|
|
snapshots = list(
|
|
(
|
|
await db.execute(
|
|
select(FundamentalSnapshot).order_by(
|
|
FundamentalSnapshot.cik,
|
|
FundamentalSnapshot.accepted_at,
|
|
)
|
|
)
|
|
).scalars()
|
|
)
|
|
earnings_count = int(
|
|
(
|
|
await db.execute(select(func.count()).select_from(EarningsEvent))
|
|
).scalar_one()
|
|
)
|
|
price_bounds = (
|
|
await db.execute(
|
|
select(
|
|
func.min(OHLCVRecord.date),
|
|
func.max(OHLCVRecord.date),
|
|
func.count(),
|
|
)
|
|
)
|
|
).one()
|
|
prices: dict[str, tuple] = {}
|
|
for index, ticker in enumerate(tickers, 1):
|
|
columns = await bt._fetch_columns(db, ticker.symbol)
|
|
if columns is not None:
|
|
prices[ticker.symbol] = columns
|
|
if not quiet and index % 50 == 0:
|
|
print(f"loaded prices {index}/{len(tickers)}", flush=True)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
by_cik: dict[str, list[Any]] = defaultdict(list)
|
|
for row in snapshots:
|
|
by_cik[str(row.cik)].append(row)
|
|
ticker_rows = [
|
|
{
|
|
"symbol": ticker.symbol,
|
|
"cik": str(ticker.cik) if ticker.cik else None,
|
|
"sic": ticker.sic,
|
|
}
|
|
for ticker in tickers
|
|
]
|
|
audit = {
|
|
"tickers": len(tickers),
|
|
"tickers_with_prices": len(prices),
|
|
"tickers_with_cik": sum(row["cik"] is not None for row in ticker_rows),
|
|
"unique_ciks": len({row["cik"] for row in ticker_rows if row["cik"]}),
|
|
"fundamental_rows": len(snapshots),
|
|
"fundamental_ciks": len(by_cik),
|
|
"accepted_at_min": min((row.accepted_at for row in snapshots), default=None),
|
|
"accepted_at_max": max((row.accepted_at for row in snapshots), default=None),
|
|
"earnings_rows": earnings_count,
|
|
"price_date_min": price_bounds[0],
|
|
"price_date_max": price_bounds[1],
|
|
"price_rows": int(price_bounds[2] or 0),
|
|
}
|
|
return {
|
|
"config": config,
|
|
"activation": activation,
|
|
"exit_config": exit_config,
|
|
"benchmark": benchmark,
|
|
"ticker_rows": ticker_rows,
|
|
"prices": prices,
|
|
"snapshots_by_cik": dict(by_cik),
|
|
"audit": audit,
|
|
}
|
|
|
|
|
|
def _representatives(
|
|
ticker_rows: list[dict], prices: dict[str, tuple]
|
|
) -> dict[str, str]:
|
|
reps: dict[str, str] = {}
|
|
for row in ticker_rows:
|
|
cik = row.get("cik")
|
|
symbol = str(row["symbol"])
|
|
if not cik or symbol not in prices:
|
|
continue
|
|
if cik not in reps or symbol < reps[cik]:
|
|
reps[cik] = symbol
|
|
return reps
|
|
|
|
|
|
def _build_candidates(
|
|
snapshot: Path,
|
|
data: dict[str, Any],
|
|
args: argparse.Namespace,
|
|
) -> tuple[list[dict], int]:
|
|
from app.services import backtest_service as bt
|
|
from scripts import run_research_matrix as shared
|
|
|
|
cache_path = Path(args.candidate_cache)
|
|
key = _cache_key(snapshot, {"kind": "daily-production-candidates"})
|
|
cached = _load_cache(cache_path, key)
|
|
if cached is not None:
|
|
if not args.quiet:
|
|
print(f"loaded candidate cache {cache_path}", flush=True)
|
|
return list(cached["qualified"]), int(cached["entry_candidate_count"])
|
|
|
|
prices = data["prices"]
|
|
workers = max(1, min(args.workers, max(1, multiprocessing.cpu_count() - 1)))
|
|
replay_rows: list[dict] = []
|
|
replay_start = date(1900, 1, 1)
|
|
|
|
def replay_one(symbol: str, columns: tuple) -> list[dict]:
|
|
return bt._replay_candidates_for_period(
|
|
symbol,
|
|
columns,
|
|
data["config"],
|
|
data["activation"],
|
|
data["benchmark"],
|
|
replay_start,
|
|
"daily",
|
|
True,
|
|
True,
|
|
)
|
|
|
|
if workers == 1:
|
|
for index, (symbol, columns) in enumerate(prices.items(), 1):
|
|
replay_rows.extend(replay_one(symbol, columns))
|
|
if not args.quiet and index % 25 == 0:
|
|
print(f"replay {index}/{len(prices)}", flush=True)
|
|
else:
|
|
context = bt._mp_context() or multiprocessing.get_context("spawn")
|
|
with ProcessPoolExecutor(max_workers=workers, mp_context=context) as pool:
|
|
futures = {
|
|
pool.submit(
|
|
bt._replay_candidates_for_period,
|
|
symbol,
|
|
columns,
|
|
data["config"],
|
|
data["activation"],
|
|
data["benchmark"],
|
|
replay_start,
|
|
"daily",
|
|
True,
|
|
True,
|
|
): symbol
|
|
for symbol, columns in prices.items()
|
|
}
|
|
for index, future in enumerate(as_completed(futures), 1):
|
|
replay_rows.extend(future.result())
|
|
if not args.quiet and index % 25 == 0:
|
|
print(f"replay {index}/{len(futures)}", flush=True)
|
|
|
|
setups = [row for row in replay_rows if not row.get("_rank_only")]
|
|
observations = [row for row in replay_rows if row.get("_universe_rank_observation")]
|
|
ranks = shared._live_universe_rank_map(
|
|
observations,
|
|
data["benchmark"],
|
|
bt.STRATEGY_RANK_MOMENTUM_WEIGHT,
|
|
)
|
|
cutoff = float(data["activation"].get("min_momentum_percentile", 80.0))
|
|
qualified: list[dict] = []
|
|
for setup in setups:
|
|
if setup.get("direction") != "long":
|
|
continue
|
|
identity = (str(setup["symbol"]), str(setup["date"]))
|
|
rank = ranks.get(identity)
|
|
if rank is None:
|
|
continue
|
|
candidate = {
|
|
key: value
|
|
for key, value in setup.items()
|
|
if not key.startswith("_universe_")
|
|
}
|
|
candidate[bt.PRODUCTION_PERCENTILE_KEY] = rank["momentum_percentile"]
|
|
candidate[bt.VOL_PERCENTILE_KEY] = rank["volatility_percentile"]
|
|
candidate[bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY] = rank["strategy_rank"]
|
|
candidate["qualified"] = bt._momentum_qualifies(candidate, cutoff)
|
|
if candidate["qualified"]:
|
|
qualified.append(candidate)
|
|
|
|
if not qualified:
|
|
raise RuntimeError("no qualified long candidates after replay")
|
|
value = {"qualified": qualified, "entry_candidate_count": len(setups)}
|
|
_save_cache(cache_path, key, value)
|
|
if not args.quiet:
|
|
print(f"wrote candidate cache {cache_path}", flush=True)
|
|
return qualified, len(setups)
|
|
|
|
|
|
def _weekly_factor_dates(
|
|
representatives: dict[str, str], prices: dict[str, tuple]
|
|
) -> set[date]:
|
|
from app.services import backtest_service as bt
|
|
|
|
dates: set[date] = set()
|
|
for symbol in representatives.values():
|
|
columns = prices[symbol]
|
|
records = [
|
|
SimpleNamespace(date=date.fromordinal(int(value))) for value in columns[0]
|
|
]
|
|
for index in bt._weekly_asof_indices(records):
|
|
if index >= bt.MIN_LOOKBACK - 1 and index + bt.HORIZON < len(records):
|
|
dates.add(records[index].date)
|
|
return dates
|
|
|
|
|
|
def _utc(value: datetime) -> datetime:
|
|
if value.tzinfo is None:
|
|
return value.replace(tzinfo=timezone.utc)
|
|
return value.astimezone(timezone.utc)
|
|
|
|
|
|
def _coverage_summary(rows: list[dict[str, int]]) -> dict[str, Any]:
|
|
if not rows:
|
|
return {}
|
|
keys = sorted(rows[0])
|
|
result: dict[str, Any] = {"dates": len(rows)}
|
|
for key in keys:
|
|
values = sorted(row[key] for row in rows)
|
|
middle = len(values) // 2
|
|
median = (
|
|
values[middle]
|
|
if len(values) % 2
|
|
else (values[middle - 1] + values[middle]) / 2
|
|
)
|
|
result[key] = {
|
|
"min": values[0],
|
|
"median": median,
|
|
"max": values[-1],
|
|
}
|
|
return result
|
|
|
|
|
|
def _build_scores(
|
|
snapshot: Path,
|
|
dates: set[date],
|
|
representatives: dict[str, str],
|
|
snapshots_by_cik: dict[str, list[Any]],
|
|
split_safe: bool,
|
|
args: argparse.Namespace,
|
|
) -> tuple[dict[str, dict[str, dict[str, float | None]]], dict[str, Any]]:
|
|
from app.services import fundamentals_derivation as derivation
|
|
from app.services import fundamentals_research as research
|
|
|
|
factor_polarity = (
|
|
research.SPLIT_SAFE_FACTOR_POLARITY if split_safe else research.FACTOR_POLARITY
|
|
)
|
|
ordered_dates = sorted(dates)
|
|
date_fingerprint = hashlib.sha256(
|
|
"|".join(value.isoformat() for value in ordered_dates).encode()
|
|
).hexdigest()
|
|
cache_path = Path(args.fundamentals_cache)
|
|
key = _cache_key(
|
|
snapshot,
|
|
{
|
|
"kind": "point-in-time-scores",
|
|
"score_profile": SPLIT_SAFE_PROTOCOL if split_safe else ORIGINAL_PROTOCOL,
|
|
"date_fingerprint": date_fingerprint,
|
|
"availability": "accepted before signal-date midnight America/New_York",
|
|
},
|
|
)
|
|
cached = _load_cache(cache_path, key)
|
|
if cached is not None:
|
|
if not args.quiet:
|
|
print(f"loaded fundamentals cache {cache_path}", flush=True)
|
|
return cached["scores"], cached["coverage"]
|
|
|
|
eligible_ciks = sorted(set(representatives) & set(snapshots_by_cik))
|
|
rows_by_cik = {
|
|
cik: sorted(snapshots_by_cik[cik], key=lambda row: _utc(row.accepted_at))
|
|
for cik in eligible_ciks
|
|
}
|
|
positions = {cik: 0 for cik in eligible_ciks}
|
|
visible = {cik: [] for cik in eligible_ciks}
|
|
current_features: dict[str, dict[str, float | None]] = {}
|
|
scores_by_date: dict[str, dict[str, dict[str, float | None]]] = {}
|
|
coverage_rows: list[dict[str, int]] = []
|
|
|
|
for date_index, signal_date in enumerate(ordered_dates, 1):
|
|
cutoff = datetime.combine(signal_date, time.min, tzinfo=NY).astimezone(
|
|
timezone.utc
|
|
)
|
|
for cik in eligible_ciks:
|
|
rows = rows_by_cik[cik]
|
|
position = positions[cik]
|
|
changed = False
|
|
while position < len(rows) and _utc(rows[position].accepted_at) <= cutoff:
|
|
visible[cik].append(rows[position])
|
|
position += 1
|
|
changed = True
|
|
positions[cik] = position
|
|
if changed:
|
|
current_features[cik] = research.raw_features(
|
|
derivation.derive(visible[cik])
|
|
)
|
|
scores = research.cross_section_scores(
|
|
current_features,
|
|
split_safe=split_safe,
|
|
)
|
|
scores_by_date[signal_date.isoformat()] = scores
|
|
coverage_rows.append(
|
|
{
|
|
key: sum(row.get(key) is not None for row in scores.values())
|
|
for key in (*factor_polarity, *research.COMPOSITE_KEYS)
|
|
}
|
|
)
|
|
if not args.quiet and date_index % 100 == 0:
|
|
print(f"fundamentals dates {date_index}/{len(ordered_dates)}", flush=True)
|
|
|
|
coverage = _coverage_summary(coverage_rows)
|
|
value = {"scores": scores_by_date, "coverage": coverage}
|
|
_save_cache(cache_path, key, value)
|
|
if not args.quiet:
|
|
print(f"wrote fundamentals cache {cache_path}", flush=True)
|
|
return scores_by_date, coverage
|
|
|
|
|
|
def _factor_diagnostics(
|
|
representatives: dict[str, str],
|
|
prices: dict[str, tuple],
|
|
scores_by_date: dict[str, dict[str, dict[str, float | None]]],
|
|
factor_keys: tuple[str, ...],
|
|
train_end: date,
|
|
test_start: date,
|
|
) -> dict[str, list[dict]]:
|
|
from app.services import backtest_service as bt
|
|
|
|
signal_keys = (*factor_keys, *COMPOSITES)
|
|
observations: list[dict[str, Any]] = []
|
|
for cik, symbol in representatives.items():
|
|
columns = prices[symbol]
|
|
ordinals, _opens, _highs, _lows, closes, _volumes = columns
|
|
records = [
|
|
SimpleNamespace(date=date.fromordinal(int(value))) for value in ordinals
|
|
]
|
|
for index in bt._weekly_asof_indices(records):
|
|
forward_index = index + bt.HORIZON
|
|
if index < bt.MIN_LOOKBACK - 1 or forward_index >= len(records):
|
|
continue
|
|
if closes[index] <= 0:
|
|
continue
|
|
signal_date = records[index].date
|
|
score = scores_by_date.get(signal_date.isoformat(), {}).get(cik, {})
|
|
forward = closes[forward_index] / closes[index] - 1.0
|
|
iso = signal_date.isocalendar()
|
|
for key in signal_keys:
|
|
value = score.get(key)
|
|
if value is not None:
|
|
observations.append(
|
|
{
|
|
"signal": key,
|
|
"date": signal_date,
|
|
"week": (iso.year, iso.week),
|
|
"value": float(value),
|
|
"forward": float(forward),
|
|
"symbol": symbol,
|
|
}
|
|
)
|
|
|
|
windows = {
|
|
"train": lambda value: value < train_end,
|
|
"validation": lambda value: train_end <= value < test_start,
|
|
"test": lambda value: value >= test_start,
|
|
"full": lambda _value: True,
|
|
}
|
|
result: dict[str, list[dict]] = {}
|
|
for window, predicate in windows.items():
|
|
collected: dict = defaultdict(lambda: defaultdict(list))
|
|
for row in observations:
|
|
if predicate(row["date"]):
|
|
collected[row["signal"]][row["week"]].append(
|
|
{
|
|
"val": row["value"],
|
|
"fwd": row["forward"],
|
|
"symbol": row["symbol"],
|
|
}
|
|
)
|
|
result[window] = bt._signal_evaluation(collected)
|
|
return result
|
|
|
|
|
|
def _attach_overlay_ranks(
|
|
candidates: list[dict],
|
|
ticker_rows: list[dict],
|
|
scores_by_date: dict[str, dict[str, dict[str, float | None]]],
|
|
arms: tuple[dict[str, Any], ...],
|
|
) -> dict[str, Any]:
|
|
from app.services import backtest_service as bt
|
|
from app.services import fundamentals_research as research
|
|
|
|
symbol_to_cik = {
|
|
str(row["symbol"]): row.get("cik") for row in ticker_rows if row.get("cik")
|
|
}
|
|
covered = {composite: 0 for composite in COMPOSITES}
|
|
for candidate in candidates:
|
|
cik = symbol_to_cik.get(str(candidate["symbol"]))
|
|
score = scores_by_date.get(str(candidate["date"]), {}).get(cik, {})
|
|
for composite in COMPOSITES:
|
|
value = score.get(composite)
|
|
candidate[f"fund_{composite}"] = value
|
|
if value is not None:
|
|
covered[composite] += 1
|
|
base = candidate.get(bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY)
|
|
for arm in arms:
|
|
if arm["composite"] is None:
|
|
continue
|
|
candidate[_ranking_key(arm)] = research.overlay_rank(
|
|
base,
|
|
score.get(str(arm["composite"])),
|
|
float(arm["weight"]),
|
|
)
|
|
total = len(candidates)
|
|
return {
|
|
composite: {
|
|
"candidates": count,
|
|
"pct": round(count / total * 100.0, 2) if total else 0.0,
|
|
}
|
|
for composite, count in covered.items()
|
|
}
|
|
|
|
|
|
def _ranking_key(arm: dict[str, Any]) -> str:
|
|
from app.services import backtest_service as bt
|
|
|
|
if arm["composite"] is None:
|
|
return bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY
|
|
return "fund_overlay_{}_{:02d}".format(
|
|
arm["composite"], round(float(arm["weight"]) * 100)
|
|
)
|
|
|
|
|
|
def _window(arm: dict[str, Any], name: str) -> dict[str, Any] | None:
|
|
return next(
|
|
(row for row in arm.get("windows", []) if row.get("window") == name),
|
|
None,
|
|
)
|
|
|
|
|
|
def _trade_overlap(subject: set[str], control: set[str]) -> dict[str, Any]:
|
|
union = subject | control
|
|
return {
|
|
"overlap_pct": round(len(subject & control) / len(union) * 100.0, 2)
|
|
if union
|
|
else 100.0,
|
|
"added": len(subject - control),
|
|
"removed": len(control - subject),
|
|
}
|
|
|
|
|
|
def _winner_concentration(details: list[dict[str, Any]]) -> dict[str, Any]:
|
|
pnls = sorted(
|
|
(float(row["pnl"]) for row in details if row.get("pnl") is not None),
|
|
reverse=True,
|
|
)
|
|
rs = sorted(
|
|
(float(row["r"]) for row in details if row.get("r") is not None),
|
|
reverse=True,
|
|
)
|
|
top_pnl = sum(pnls[:5])
|
|
total_pnl = sum(pnls)
|
|
remaining_rs = rs[5:]
|
|
return {
|
|
"top5_pnl": round(top_pnl, 2) if pnls else None,
|
|
"net_pnl_ex_top5": round(total_pnl - top_pnl, 2) if pnls else None,
|
|
"top5_share_of_positive_net_pct": (
|
|
round(top_pnl / total_pnl * 100.0, 2) if total_pnl > 0 else None
|
|
),
|
|
"avg_r_ex_top5": (
|
|
round(sum(remaining_rs) / len(remaining_rs), 4) if remaining_rs else None
|
|
),
|
|
}
|
|
|
|
|
|
def _run_arm(
|
|
arm: dict[str, Any],
|
|
candidates: list[dict],
|
|
data: dict[str, Any],
|
|
train_end: date,
|
|
test_start: date,
|
|
n_trials: int,
|
|
) -> tuple[dict[str, Any], dict[str, set[str]]]:
|
|
from app.services import backtest_service as bt
|
|
from scripts import run_research_matrix as shared
|
|
|
|
strategy = next(
|
|
row for row in bt.PORTFOLIO_MONITOR_STRATEGIES if row.get("is_production")
|
|
)
|
|
entry = bt._entry_variant_config(str(strategy["entry_variant"]))
|
|
if entry is None:
|
|
raise RuntimeError("production entry configuration missing")
|
|
exit_config = data["exit_config"]
|
|
exit_policy = bt.LIVE_EXIT_MODE_TO_SIM.get(
|
|
str(exit_config.get("mode", "atr_trailing")), "atr_trail3"
|
|
)
|
|
hold_days = int(exit_config.get("hold_days", 30))
|
|
trail = float(exit_config.get("atr_multiplier", bt.ATR_TRAIL_MULTIPLIER))
|
|
ranking_key = _ranking_key(arm)
|
|
reentry = bt._make_gate_reset_reentry_fn(
|
|
candidates,
|
|
data["prices"],
|
|
cadence="daily",
|
|
ranking_key=ranking_key,
|
|
)
|
|
windows: list[dict[str, Any]] = []
|
|
trades_by_window: dict[str, set[str]] = {}
|
|
full_trade_details: list[dict[str, Any]] = []
|
|
for name, start, end in (
|
|
("train", None, train_end),
|
|
("validation", train_end, test_start),
|
|
("test", test_start, None),
|
|
("full", None, None),
|
|
):
|
|
sim = bt._simulate_portfolio(
|
|
candidates,
|
|
data["prices"],
|
|
data["benchmark"],
|
|
exit_policy,
|
|
hold_days,
|
|
ranking_key=ranking_key,
|
|
max_positions=int(entry["max_positions"]),
|
|
risk_per_trade=float(entry["risk_per_trade"]),
|
|
atr_trail_multiplier=trail,
|
|
post_stop_reentry_fn=reentry,
|
|
start_date=start,
|
|
end_date=end,
|
|
fill_mode=bt.FILL_MODE_CLOSE,
|
|
include_trades=True,
|
|
)
|
|
if sim is None:
|
|
windows.append({"window": name, "error": "no trades"})
|
|
trades_by_window[name] = set()
|
|
continue
|
|
shared._assert_calendar_truncation(sim, hold_days, bt.FILL_MODE_CLOSE)
|
|
details = sim.pop("trade_details", [])
|
|
sim["winner_concentration"] = _winner_concentration(details)
|
|
if name == "full":
|
|
full_trade_details = details
|
|
trades_by_window[name] = {
|
|
"{}:{}".format(row.get("symbol"), row.get("entry_date")) for row in details
|
|
}
|
|
for heavy in ("equity_curve", "benchmark_curve", "reentry_events"):
|
|
sim.pop(heavy, None)
|
|
dsr = bt.deflated_sharpe_ratio(
|
|
sim.get("sharpe"),
|
|
sim.get("sharpe_se"),
|
|
n_trials,
|
|
n_returns=sim.get("n_returns"),
|
|
return_skew=sim.get("return_skew"),
|
|
return_kurtosis=sim.get("return_kurtosis"),
|
|
)
|
|
windows.append({"window": name, "dsr": dsr, **sim})
|
|
return (
|
|
{
|
|
"id": arm["id"],
|
|
"label": arm["label"],
|
|
"composite": arm["composite"],
|
|
"weight": arm["weight"],
|
|
"ranking_key": ranking_key,
|
|
"windows": windows,
|
|
"full_trade_details": full_trade_details,
|
|
},
|
|
trades_by_window,
|
|
)
|
|
|
|
|
|
def _development_grade(control: dict, arm: dict) -> dict[str, Any]:
|
|
control_train = _window(control, "train") or {}
|
|
control_validation = _window(control, "validation") or {}
|
|
arm_train = _window(arm, "train") or {}
|
|
arm_validation = _window(arm, "validation") or {}
|
|
required = (
|
|
control_train.get("sharpe"),
|
|
control_validation.get("sharpe"),
|
|
control_validation.get("max_drawdown_pct"),
|
|
arm_train.get("sharpe"),
|
|
arm_validation.get("sharpe"),
|
|
arm_validation.get("max_drawdown_pct"),
|
|
)
|
|
if any(value is None for value in required):
|
|
return {"pass": False, "reason": "missing train or validation statistic"}
|
|
checks = {
|
|
"train_sharpe_not_worse": arm_train["sharpe"] >= control_train["sharpe"],
|
|
"validation_sharpe_not_worse": (
|
|
arm_validation["sharpe"] >= control_validation["sharpe"]
|
|
),
|
|
"validation_drawdown_within_2pp": (
|
|
arm_validation["max_drawdown_pct"]
|
|
<= control_validation["max_drawdown_pct"] + 2.0
|
|
),
|
|
}
|
|
return {
|
|
"pass": all(checks.values()),
|
|
"checks": checks,
|
|
"train_sharpe_delta": round(arm_train["sharpe"] - control_train["sharpe"], 4),
|
|
"validation_sharpe_delta": round(
|
|
arm_validation["sharpe"] - control_validation["sharpe"], 4
|
|
),
|
|
}
|
|
|
|
|
|
def _final_check(control: dict, selected: dict | None) -> dict[str, Any] | None:
|
|
if selected is None:
|
|
return None
|
|
control_test = _window(control, "test") or {}
|
|
selected_test = _window(selected, "test") or {}
|
|
values = (
|
|
control_test.get("sharpe"),
|
|
control_test.get("max_drawdown_pct"),
|
|
selected_test.get("sharpe"),
|
|
selected_test.get("max_drawdown_pct"),
|
|
)
|
|
if any(value is None for value in values):
|
|
return {"pass": False, "reason": "missing test statistic"}
|
|
checks = {
|
|
"test_sharpe_not_worse": selected_test["sharpe"] >= control_test["sharpe"],
|
|
"test_drawdown_within_2pp": (
|
|
selected_test["max_drawdown_pct"] <= control_test["max_drawdown_pct"] + 2.0
|
|
),
|
|
}
|
|
return {
|
|
"arm_id": selected["id"],
|
|
"pass": all(checks.values()),
|
|
"checks": checks,
|
|
"test_sharpe_delta": round(selected_test["sharpe"] - control_test["sharpe"], 4),
|
|
"note": "Research evidence only; passing does not change production.",
|
|
}
|
|
|
|
|
|
def _fmt(value: Any) -> str:
|
|
if value is None:
|
|
return "—"
|
|
return f"{value:.4g}" if isinstance(value, float) else str(value)
|
|
|
|
|
|
def _markdown(report: dict[str, Any]) -> str:
|
|
protocol_id = report.get("research_protocol", ORIGINAL_PROTOCOL)
|
|
title = (
|
|
"Split-safe fundamentals overlay sensitivity"
|
|
if protocol_id == SPLIT_SAFE_PROTOCOL
|
|
else "Point-in-time fundamentals overlay research"
|
|
)
|
|
lines = [
|
|
f"# {title}",
|
|
"",
|
|
"Generated: {}".format(report.get("generated_at")),
|
|
"",
|
|
"## Protocol",
|
|
"",
|
|
"- Research protocol: **{}**.".format(protocol_id),
|
|
"- Train ends before **{}**.".format(report["splits"]["train_end"]),
|
|
"- Validation runs until **{}**.".format(report["splits"]["test_start"]),
|
|
"- Test starts at that date and is not used to select the arm.",
|
|
"- Qualification is unchanged; fundamentals only reorder qualified longs.",
|
|
"- SEC filings become visible at midnight New York time after acceptance.",
|
|
"- Pre-registered portfolio trials for DSR: **{}**.".format(report["n_trials"]),
|
|
"",
|
|
"## Data warnings",
|
|
"",
|
|
]
|
|
lines.extend(f"- {warning}" for warning in report.get("warnings", []))
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"## Factor IC",
|
|
"",
|
|
"| window | signal | IC | t | positive | quintile spread | weeks | N |",
|
|
"|---|---|---:|---:|---:|---:|---:|---:|",
|
|
]
|
|
)
|
|
for window, rows in report.get("factor_ic", {}).items():
|
|
for row in rows:
|
|
lines.append(
|
|
"| {} | {} | {} | {} | {} | {} | {} | {} |".format(
|
|
window,
|
|
row.get("signal"),
|
|
_fmt(row.get("mean_ic")),
|
|
_fmt(row.get("ic_t_stat")),
|
|
_fmt(row.get("ic_positive_pct")),
|
|
_fmt(row.get("mean_quintile_spread")),
|
|
row.get("weeks"),
|
|
_fmt(row.get("avg_cross_section")),
|
|
)
|
|
)
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"## Portfolio arms",
|
|
"",
|
|
"| arm | window | Sharpe | SE | DSR | CAGR | MaxDD | Calmar | trades | overlap |",
|
|
"|---|---|---:|---:|---:|---:|---:|---:|---:|---:|",
|
|
]
|
|
)
|
|
for arm in report.get("arms", []):
|
|
for row in arm.get("windows", []):
|
|
overlap = row.get("selection_vs_control", {}).get("overlap_pct")
|
|
lines.append(
|
|
"| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |".format(
|
|
arm["id"],
|
|
row.get("window"),
|
|
_fmt(row.get("sharpe")),
|
|
_fmt(row.get("sharpe_se")),
|
|
_fmt(row.get("dsr")),
|
|
_fmt(row.get("cagr_pct")),
|
|
_fmt(row.get("max_drawdown_pct")),
|
|
_fmt(row.get("calmar")),
|
|
_fmt(row.get("trades")),
|
|
_fmt(overlap),
|
|
)
|
|
)
|
|
selection = report.get("development_selection")
|
|
lines.extend(["", "## Mechanical selection", ""])
|
|
if selection:
|
|
lines.append(
|
|
"- Development-selected arm: **{}**. ".format(selection["arm_id"])
|
|
+ "The test result is reported only as a final check."
|
|
)
|
|
lines.append("- Final check: `{}`".format(report.get("final_check")))
|
|
else:
|
|
lines.append("- No overlay passed the train + validation requirements.")
|
|
lines.extend(["", "Production remains unchanged pending human review.", ""])
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _write_outputs(report: dict[str, Any], out: Path, *, bundle: bool) -> None:
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
|
|
markdown_path = out.with_suffix(".md")
|
|
markdown_path.write_text(_markdown(report), encoding="utf-8")
|
|
|
|
arms_csv = out.with_name(f"{out.stem}-arms.csv")
|
|
with arms_csv.open("w", newline="", encoding="utf-8") as handle:
|
|
writer = csv.writer(handle)
|
|
writer.writerow(
|
|
[
|
|
"arm",
|
|
"composite",
|
|
"weight",
|
|
"window",
|
|
"sharpe",
|
|
"sharpe_se",
|
|
"dsr",
|
|
"cagr_pct",
|
|
"max_drawdown_pct",
|
|
"calmar",
|
|
"trades",
|
|
"overlap_pct",
|
|
"top5_pnl_share_pct",
|
|
"avg_r_ex_top5",
|
|
]
|
|
)
|
|
for arm in report.get("arms", []):
|
|
for row in arm.get("windows", []):
|
|
writer.writerow(
|
|
[
|
|
arm["id"],
|
|
arm["composite"],
|
|
arm["weight"],
|
|
row.get("window"),
|
|
row.get("sharpe"),
|
|
row.get("sharpe_se"),
|
|
row.get("dsr"),
|
|
row.get("cagr_pct"),
|
|
row.get("max_drawdown_pct"),
|
|
row.get("calmar"),
|
|
row.get("trades"),
|
|
row.get("selection_vs_control", {}).get("overlap_pct"),
|
|
row.get("winner_concentration", {}).get(
|
|
"top5_share_of_positive_net_pct"
|
|
),
|
|
row.get("winner_concentration", {}).get("avg_r_ex_top5"),
|
|
]
|
|
)
|
|
|
|
factor_csv = out.with_name(f"{out.stem}-factor-ic.csv")
|
|
with factor_csv.open("w", newline="", encoding="utf-8") as handle:
|
|
writer = csv.writer(handle)
|
|
writer.writerow(
|
|
[
|
|
"window",
|
|
"signal",
|
|
"mean_ic",
|
|
"ic_t_stat",
|
|
"ic_positive_pct",
|
|
"mean_quintile_spread",
|
|
"weeks",
|
|
"avg_cross_section",
|
|
"reliable",
|
|
]
|
|
)
|
|
for window, rows in report.get("factor_ic", {}).items():
|
|
for row in rows:
|
|
writer.writerow(
|
|
[
|
|
window,
|
|
row.get("signal"),
|
|
row.get("mean_ic"),
|
|
row.get("ic_t_stat"),
|
|
row.get("ic_positive_pct"),
|
|
row.get("mean_quintile_spread"),
|
|
row.get("weeks"),
|
|
row.get("avg_cross_section"),
|
|
row.get("reliable"),
|
|
]
|
|
)
|
|
|
|
trades_csv = out.with_name(f"{out.stem}-trades.csv")
|
|
trade_columns = [
|
|
"arm",
|
|
"symbol",
|
|
"entry_date",
|
|
"exit_date",
|
|
"r",
|
|
"pnl",
|
|
"reason",
|
|
"hold",
|
|
"entry",
|
|
"exit",
|
|
"risk_dollars",
|
|
]
|
|
with trades_csv.open("w", newline="", encoding="utf-8") as handle:
|
|
writer = csv.DictWriter(handle, fieldnames=trade_columns, extrasaction="ignore")
|
|
writer.writeheader()
|
|
for arm in report.get("arms", []):
|
|
for trade in arm.get("full_trade_details", []):
|
|
writer.writerow({"arm": arm["id"], **trade})
|
|
|
|
if bundle:
|
|
bundle_path = out.with_suffix(".zip")
|
|
with zipfile.ZipFile(bundle_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
for path in (out, markdown_path, arms_csv, factor_csv, trades_csv):
|
|
archive.write(path, arcname=path.name)
|
|
protocol = ROOT / "docs" / "research" / "fundamentals-weight-backtest.md"
|
|
if protocol.exists():
|
|
archive.write(protocol, arcname=protocol.name)
|
|
|
|
|
|
async def _main() -> None:
|
|
from app.services import fundamentals_research as research
|
|
|
|
args = _parse_args()
|
|
split_safe = args.protocol == SPLIT_SAFE_PROTOCOL
|
|
arms = SPLIT_SAFE_ARMS if split_safe else ARMS
|
|
n_trials = len(arms)
|
|
factor_keys = tuple(
|
|
(
|
|
research.SPLIT_SAFE_FACTOR_POLARITY
|
|
if split_safe
|
|
else research.FACTOR_POLARITY
|
|
).keys()
|
|
)
|
|
snapshot = Path(args.snapshot)
|
|
out = Path(args.out) if args.out else _default_out(args.protocol)
|
|
if not snapshot.exists():
|
|
raise SystemExit(f"snapshot not found: {snapshot}")
|
|
if args.workers < 1:
|
|
raise SystemExit("--workers must be positive")
|
|
train_end = date.fromisoformat(args.train_end)
|
|
test_start = date.fromisoformat(args.test_start)
|
|
if train_end >= test_start:
|
|
raise SystemExit("--train-end must be earlier than --test-start")
|
|
|
|
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
|
if args.allow_spawn:
|
|
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
|
|
|
data = await _load_snapshot(snapshot, args.quiet)
|
|
audit = data["audit"]
|
|
if audit["fundamental_rows"] == 0 or audit["fundamental_ciks"] < 5:
|
|
raise SystemExit(
|
|
"snapshot lacks usable fundamental_snapshots; create a fresh export "
|
|
"with scripts/create_backtest_snapshot.py"
|
|
)
|
|
representatives = _representatives(data["ticker_rows"], data["prices"])
|
|
candidates, entry_candidate_count = _build_candidates(snapshot, data, args)
|
|
factor_dates = _weekly_factor_dates(representatives, data["prices"])
|
|
all_dates = factor_dates | {
|
|
date.fromisoformat(str(candidate["date"])) for candidate in candidates
|
|
}
|
|
scores_by_date, score_coverage = _build_scores(
|
|
snapshot,
|
|
all_dates,
|
|
representatives,
|
|
data["snapshots_by_cik"],
|
|
split_safe,
|
|
args,
|
|
)
|
|
candidate_coverage = _attach_overlay_ranks(
|
|
candidates, data["ticker_rows"], scores_by_date, arms
|
|
)
|
|
factor_ic = _factor_diagnostics(
|
|
representatives,
|
|
data["prices"],
|
|
scores_by_date,
|
|
factor_keys,
|
|
train_end,
|
|
test_start,
|
|
)
|
|
|
|
warnings = [
|
|
"Current tracked universe only: historical constituent membership and "
|
|
"delisted names are unavailable, so absolute results have survivorship bias.",
|
|
"Earnings surprise is excluded because the completed SUE study already "
|
|
"failed its promotion bar for this strategy.",
|
|
"The test window has already been observed; this follow-up is sensitivity "
|
|
"evidence and live paper performance remains the final out-of-sample check.",
|
|
]
|
|
if split_safe:
|
|
warnings.insert(
|
|
1,
|
|
"Diluted-EPS growth and share-count change are excluded because filing-time "
|
|
"values are not split-comparable without point-in-time split factors.",
|
|
)
|
|
else:
|
|
warnings.insert(
|
|
1,
|
|
"Historical valuation is excluded because split-adjusted bars cannot be "
|
|
"safely combined with filing-time EPS and shares without split factors.",
|
|
)
|
|
|
|
report: dict[str, Any] = {
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"research_protocol": args.protocol,
|
|
"git_commit": _git_commit(),
|
|
"snapshot": str(snapshot.resolve()),
|
|
"snapshot_sha256": _snapshot_hash(snapshot),
|
|
"splits": {
|
|
"train_end": train_end.isoformat(),
|
|
"test_start": test_start.isoformat(),
|
|
"windows": {
|
|
"train": f"entry date < {train_end.isoformat()}",
|
|
"validation": (
|
|
f"{train_end.isoformat()} <= entry date < {test_start.isoformat()}"
|
|
),
|
|
"test": f"entry date >= {test_start.isoformat()}",
|
|
},
|
|
},
|
|
"n_trials": n_trials,
|
|
"pre_registered_arms": list(arms),
|
|
"protocol": {
|
|
"id": args.protocol,
|
|
"qualification": "unchanged production gate; rank overlay only",
|
|
"cadence": "daily",
|
|
"fill_mode": "close; production near-close proxy",
|
|
"horizon_sessions": 30,
|
|
"filing_availability": (
|
|
"accepted_at before signal-date midnight America/New_York; "
|
|
"conservative match for the daily pre-market SEC import"
|
|
),
|
|
"missing_fundamental_score": 50.0,
|
|
"factor_keys": list(factor_keys),
|
|
"split_sensitive_metrics_excluded": (
|
|
["eps_growth_yoy", "share_count_change_yoy"] if split_safe else []
|
|
),
|
|
"selection": (
|
|
"highest validation Sharpe among arms with train and validation "
|
|
"Sharpe not below control and validation drawdown within 2pp"
|
|
),
|
|
"production_mutation": False,
|
|
},
|
|
"warnings": warnings,
|
|
"data_audit": audit,
|
|
"strategy_config": {
|
|
"recommendation": data["config"],
|
|
"activation": data["activation"],
|
|
"exit": data["exit_config"],
|
|
},
|
|
"score_cross_section_coverage": score_coverage,
|
|
"qualified_candidate_coverage": candidate_coverage,
|
|
"entry_candidate_count": entry_candidate_count,
|
|
"qualified_candidates": len(candidates),
|
|
"factor_ic": factor_ic,
|
|
"arms": [],
|
|
"development_grades": {},
|
|
"development_selection": None,
|
|
"final_check": None,
|
|
}
|
|
_write_outputs(report, out, bundle=False)
|
|
|
|
trade_sets: dict[str, dict[str, set[str]]] = {}
|
|
control: dict[str, Any] | None = None
|
|
for arm in arms:
|
|
if not args.quiet:
|
|
print("running {}".format(arm["id"]), flush=True)
|
|
result, arm_trades = _run_arm(
|
|
arm, candidates, data, train_end, test_start, n_trials
|
|
)
|
|
trade_sets[str(arm["id"])] = arm_trades
|
|
if control is None:
|
|
control = result
|
|
else:
|
|
for row in result["windows"]:
|
|
name = str(row["window"])
|
|
row["selection_vs_control"] = _trade_overlap(
|
|
arm_trades.get(name, set()),
|
|
trade_sets["control_w00"].get(name, set()),
|
|
)
|
|
report["development_grades"][str(arm["id"])] = _development_grade(
|
|
control, result
|
|
)
|
|
report["arms"].append(result)
|
|
_write_outputs(report, out, bundle=False)
|
|
|
|
if control is None:
|
|
raise RuntimeError("control arm did not run")
|
|
eligible = [
|
|
arm
|
|
for arm in report["arms"][1:]
|
|
if report["development_grades"].get(arm["id"], {}).get("pass")
|
|
]
|
|
selected = max(
|
|
eligible,
|
|
key=lambda arm: (
|
|
float((_window(arm, "validation") or {}).get("sharpe") or -999.0),
|
|
-float(arm["weight"]),
|
|
),
|
|
default=None,
|
|
)
|
|
if selected is not None:
|
|
report["development_selection"] = {
|
|
"arm_id": selected["id"],
|
|
"chosen_without_test": True,
|
|
"validation_sharpe": (_window(selected, "validation") or {}).get("sharpe"),
|
|
}
|
|
report["final_check"] = _final_check(control, selected)
|
|
report["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
_write_outputs(report, out, bundle=True)
|
|
print(f"wrote {out}", flush=True)
|
|
bundle_path = out.with_suffix(".zip")
|
|
print("wrote {}".format(bundle_path), flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(_main())
|