Earnings backfill sourced from the public DoltHub earnings repo at a pinned commit rather than the FMP API: reproducible for anyone re-running the study, and it burns no request quota. 12,414 events, 98.6% of symbols with >=8 announcements, 99.2% paired actual/estimate, no keyed duplicates. 2a earnings-gap diagnostic: INFORMATIONAL, no filter shipped. The pre-earnings cohort's right tail was better, so the registered avoid-earnings condition failed. Note the raw 23/266 vs 115/574 incidence gap is largely a duration confound -- severe losses stop out fast and have less time to span an announcement -- so it is not evidence that holding through earnings is safe. 2b SUE: FAIL against the pre-registered +0.03 bar (unconditional IC +0.0151 over 56 reliable windows, momentum-conditional +0.0213). Signs stable across eras, so this is a clean null rather than an ambiguous one, consistent with post-earnings drift having decayed in large caps. Closes the Tier-1 arc: Task 1 dead on deep evidence, Task 2 dead here, Task 3 complete as diagnostic. No in-sample research thread remains open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1550 lines
58 KiB
Python
1550 lines
58 KiB
Python
"""Run Task 2 earnings-gap (2a) and SUE/PEAD (2b) research.
|
|
|
|
Both experiments use the manifest-guarded research snapshot restricted to the
|
|
production symbol set. Earnings data remain in the real earnings_events table
|
|
on the production snapshot. This runner is research-only and never changes
|
|
production configuration or integrates a signal/filter.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import bisect
|
|
import json
|
|
import math
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
from collections import defaultdict
|
|
from concurrent.futures import ProcessPoolExecutor
|
|
from datetime import date, datetime, timezone
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
from sqlalchemy import create_engine, select, text
|
|
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))
|
|
|
|
from app.ssl_bootstrap import bootstrap_ssl # noqa: E402
|
|
|
|
bootstrap_ssl()
|
|
|
|
IRON_IC_BAR = 0.03
|
|
SUE_CARRY_DAYS = 63
|
|
SUE_TRAIL = 8
|
|
SUE_MIN_TRAIL = 4
|
|
COST_PER_SIDE = 0.001
|
|
MIN_PRE2021_DEPTH_PCT = 80.0
|
|
|
|
|
|
def _parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--snapshot", default="backtest_snapshots/research.sqlite")
|
|
parser.add_argument(
|
|
"--universe-snapshot", default="backtest_snapshots/prod.sqlite"
|
|
)
|
|
parser.add_argument(
|
|
"--earnings-snapshot", default="backtest_snapshots/prod.sqlite"
|
|
)
|
|
parser.add_argument("--workers", type=int, default=6)
|
|
parser.add_argument("--allow-spawn", action="store_true")
|
|
parser.add_argument("--quiet", action="store_true")
|
|
parser.add_argument("--stamp", default=None)
|
|
return parser.parse_args()
|
|
|
|
|
|
def _sqlite_url(path: Path) -> str:
|
|
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
|
|
|
|
|
|
def _read_symbols(snapshot: Path) -> list[str]:
|
|
engine = create_engine(f"sqlite:///{snapshot.resolve().as_posix()}", future=True)
|
|
try:
|
|
with engine.connect() as conn:
|
|
return [
|
|
str(row[0]).upper().replace(".", "-")
|
|
for row in conn.execute(text("SELECT symbol FROM tickers ORDER BY symbol"))
|
|
]
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def _snapshot_depth(snapshot: Path, symbols: set[str]) -> dict[str, Any]:
|
|
from scripts.research_snapshot_manifest import assert_research_snapshot_complete
|
|
|
|
manifest = assert_research_snapshot_complete(snapshot)
|
|
connection = sqlite3.connect(snapshot)
|
|
try:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT t.symbol, MIN(o.date), MAX(o.date), COUNT(o.id)
|
|
FROM tickers t
|
|
LEFT JOIN ohlcv_records o ON o.ticker_id=t.id
|
|
GROUP BY t.id, t.symbol
|
|
"""
|
|
).fetchall()
|
|
benchmark = connection.execute(
|
|
"SELECT COUNT(*), MIN(date), MAX(date) FROM benchmark_prices "
|
|
"WHERE symbol='SPY'"
|
|
).fetchone()
|
|
finally:
|
|
connection.close()
|
|
|
|
by_symbol = {str(row[0]).upper(): row for row in rows}
|
|
selected = [by_symbol[symbol] for symbol in sorted(symbols) if symbol in by_symbol]
|
|
missing = sorted(symbols - set(by_symbol))
|
|
usable = [row for row in selected if int(row[3] or 0) > 0]
|
|
zero_bar = sorted(str(row[0]) for row in selected if int(row[3] or 0) == 0)
|
|
counts = sorted(int(row[3]) for row in usable)
|
|
pre2021 = [row for row in usable if row[1] and str(row[1]) < "2021-01-01"]
|
|
pre_pct = round(len(pre2021) / max(1, len(usable)) * 100.0, 1)
|
|
shallow = [
|
|
{
|
|
"symbol": str(row[0]),
|
|
"first_bar": row[1],
|
|
"last_bar": row[2],
|
|
"bars": int(row[3] or 0),
|
|
}
|
|
for row in sorted(usable, key=lambda item: int(item[3] or 0))
|
|
if int(row[3] or 0) < 1000
|
|
]
|
|
gate_pass = (
|
|
len(usable) >= 505
|
|
and len(missing) + len(zero_bar) <= 1
|
|
and pre_pct >= MIN_PRE2021_DEPTH_PCT
|
|
and int(benchmark[0] or 0) >= 1000
|
|
and benchmark[1] is not None
|
|
and str(benchmark[1]) < "2021-01-01"
|
|
)
|
|
return {
|
|
"manifest": manifest,
|
|
"requested_symbols": len(symbols),
|
|
"tradable_symbols": len(usable),
|
|
"missing_symbols": missing,
|
|
"zero_bar_symbols": zero_bar,
|
|
"bar_count": {
|
|
"min": min(counts) if counts else 0,
|
|
"median": counts[len(counts) // 2] if counts else 0,
|
|
"max": max(counts) if counts else 0,
|
|
},
|
|
"symbols_with_pre2021_bars": len(pre2021),
|
|
"symbols_with_pre2021_bars_pct": pre_pct,
|
|
"shallow_symbols_lt_1000_bars": shallow,
|
|
"price_window": {
|
|
"min": min(str(row[1]) for row in usable if row[1]),
|
|
"max": max(str(row[2]) for row in usable if row[2]),
|
|
},
|
|
"benchmark_spy": {
|
|
"rows": int(benchmark[0] or 0),
|
|
"min": benchmark[1],
|
|
"max": benchmark[2],
|
|
},
|
|
"gate_threshold": {
|
|
"min_tradable_symbols": 505,
|
|
"max_missing_or_zero_bar": 1,
|
|
"min_symbols_with_pre2021_bars_pct": MIN_PRE2021_DEPTH_PCT,
|
|
"benchmark_min_rows": 1000,
|
|
"benchmark_must_begin_pre2021": True,
|
|
},
|
|
"gate_pass": gate_pass,
|
|
}
|
|
|
|
|
|
def _load_backfill_status() -> dict[str, Any]:
|
|
path = Path("reports/earnings-backfill-status.json")
|
|
if not path.exists():
|
|
raise SystemExit(f"Missing backfill status: {path}")
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _load_earnings(snapshot: Path, symbols: set[str]) -> list[dict[str, Any]]:
|
|
engine = create_engine(f"sqlite:///{snapshot.resolve().as_posix()}", future=True)
|
|
try:
|
|
with engine.connect() as conn:
|
|
tables = {
|
|
str(row[0])
|
|
for row in conn.execute(
|
|
text("SELECT name FROM sqlite_master WHERE type='table'")
|
|
)
|
|
}
|
|
if "earnings_events" not in tables:
|
|
raise SystemExit("earnings_events table missing")
|
|
event_columns = {
|
|
str(row[1])
|
|
for row in conn.execute(text("PRAGMA table_info(earnings_events)"))
|
|
}
|
|
period_expression = (
|
|
"period_end_date" if "period_end_date" in event_columns else "NULL"
|
|
)
|
|
rows = conn.execute(
|
|
text(
|
|
f"""
|
|
SELECT symbol, announce_date, announce_time, eps_estimate,
|
|
eps_actual, revenue_estimate, revenue_actual, source,
|
|
{period_expression} AS period_end_date
|
|
FROM earnings_events
|
|
ORDER BY symbol, announce_date
|
|
"""
|
|
)
|
|
).fetchall()
|
|
finally:
|
|
engine.dispose()
|
|
result = []
|
|
for row in rows:
|
|
symbol = str(row[0]).upper().replace(".", "-")
|
|
if symbol not in symbols:
|
|
continue
|
|
result.append(
|
|
{
|
|
"symbol": symbol,
|
|
"announce_date": date.fromisoformat(str(row[1])[:10]),
|
|
"announce_time": str(row[2]).lower() if row[2] else None,
|
|
"eps_estimate": row[3],
|
|
"eps_actual": row[4],
|
|
"revenue_estimate": row[5],
|
|
"revenue_actual": row[6],
|
|
"source": row[7],
|
|
"period_end_date": (
|
|
date.fromisoformat(str(row[8])[:10]) if row[8] else None
|
|
),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def _load_surprise_history(
|
|
snapshot: Path, symbols: set[str]
|
|
) -> dict[str, list[dict[str, Any]]]:
|
|
engine = create_engine(f"sqlite:///{snapshot.resolve().as_posix()}", future=True)
|
|
try:
|
|
with engine.connect() as conn:
|
|
tables = {
|
|
str(row[0])
|
|
for row in conn.execute(
|
|
text("SELECT name FROM sqlite_master WHERE type='table'")
|
|
)
|
|
}
|
|
if "earnings_surprise_history" not in tables:
|
|
return {}
|
|
rows = conn.execute(
|
|
text(
|
|
"""
|
|
SELECT symbol, period_end_date, eps_estimate, eps_actual
|
|
FROM earnings_surprise_history
|
|
ORDER BY symbol, period_end_date
|
|
"""
|
|
)
|
|
).fetchall()
|
|
finally:
|
|
engine.dispose()
|
|
result: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
for row in rows:
|
|
symbol = str(row[0]).upper().replace(".", "-")
|
|
if symbol not in symbols:
|
|
continue
|
|
result[symbol].append(
|
|
{
|
|
"period_end_date": date.fromisoformat(str(row[1])[:10]),
|
|
"eps_estimate": row[2],
|
|
"eps_actual": row[3],
|
|
}
|
|
)
|
|
return dict(result)
|
|
|
|
|
|
def _pct(numerator: int, denominator: int) -> float:
|
|
return round(numerator / max(1, denominator) * 100.0, 1)
|
|
|
|
|
|
def _data_quality(
|
|
events: list[dict[str, Any]],
|
|
symbols: set[str],
|
|
*,
|
|
window_start: date,
|
|
window_end: date,
|
|
backfill_status: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
in_window = [
|
|
event
|
|
for event in events
|
|
if window_start <= event["announce_date"] <= window_end
|
|
]
|
|
by_symbol: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
for event in in_window:
|
|
by_symbol[event["symbol"]].append(event)
|
|
paired = [
|
|
event
|
|
for event in in_window
|
|
if event.get("eps_estimate") is not None
|
|
and event.get("eps_actual") is not None
|
|
]
|
|
paired_by_symbol: dict[str, int] = defaultdict(int)
|
|
for event in paired:
|
|
paired_by_symbol[event["symbol"]] += 1
|
|
ge8 = sum(len(by_symbol.get(symbol, [])) >= 8 for symbol in symbols)
|
|
ge8_paired = sum(paired_by_symbol.get(symbol, 0) >= 8 for symbol in symbols)
|
|
|
|
recognised_sessions = {"bmo", "amc", "during"}
|
|
session_known = sum(
|
|
str(event.get("announce_time") or "").lower() in recognised_sessions
|
|
for event in in_window
|
|
)
|
|
session_pct = _pct(session_known, len(in_window))
|
|
session_reliable = session_pct >= 80.0
|
|
|
|
far_off = []
|
|
annual_rates: list[float] = []
|
|
for symbol in sorted(symbols):
|
|
symbol_events = sorted(
|
|
by_symbol.get(symbol, []), key=lambda event: event["announce_date"]
|
|
)
|
|
if not symbol_events:
|
|
far_off.append(
|
|
{"symbol": symbol, "events": 0, "events_per_year": 0.0}
|
|
)
|
|
continue
|
|
first = symbol_events[0]["announce_date"]
|
|
last = symbol_events[-1]["announce_date"]
|
|
active_years = max(1.0, (last - first).days / 365.25)
|
|
rate = len(symbol_events) / active_years
|
|
annual_rates.append(rate)
|
|
if rate < 2.0 or rate > 6.0:
|
|
far_off.append(
|
|
{
|
|
"symbol": symbol,
|
|
"events": len(symbol_events),
|
|
"first": first.isoformat(),
|
|
"last": last.isoformat(),
|
|
"events_per_year": round(rate, 2),
|
|
}
|
|
)
|
|
|
|
keys = [(event["symbol"], event["announce_date"]) for event in in_window]
|
|
duplicate_rows_in_table = len(keys) - len(set(keys))
|
|
paired_pct = _pct(len(paired), len(in_window))
|
|
ge8_paired_pct = _pct(ge8_paired, len(symbols))
|
|
fallback_needed = paired_pct < 50.0 or ge8_paired_pct < 50.0
|
|
return {
|
|
"window": {"from": window_start.isoformat(), "to": window_end.isoformat()},
|
|
"prod_symbols": len(symbols),
|
|
"events": len(in_window),
|
|
"symbols_with_any_event": len(by_symbol),
|
|
"symbols_with_ge8_announcements": ge8,
|
|
"symbols_with_ge8_announcements_pct": _pct(ge8, len(symbols)),
|
|
"symbols_with_ge8_paired_announcements": ge8_paired,
|
|
"symbols_with_ge8_paired_announcements_pct": ge8_paired_pct,
|
|
"events_with_actual_and_estimate": len(paired),
|
|
"events_with_actual_and_estimate_pct": paired_pct,
|
|
"duplicate_rows_in_table": duplicate_rows_in_table,
|
|
"duplicate_rows_fetched": backfill_status.get(
|
|
"duplicate_rows_logged_total", 0
|
|
),
|
|
"restated_rows_fetched": backfill_status.get(
|
|
"restated_rows_logged_total", 0
|
|
),
|
|
"dedupe_policy": backfill_status.get("dedupe_policy"),
|
|
"events_per_symbol_year": {
|
|
"mean_active_span_rate": (
|
|
round(sum(annual_rates) / len(annual_rates), 2)
|
|
if annual_rates
|
|
else None
|
|
),
|
|
"expected": "approximately 4",
|
|
"far_off_rule": "active-span rate <2 or >6, plus zero-event symbols",
|
|
"far_off_count": len(far_off),
|
|
"far_off_symbols": far_off,
|
|
},
|
|
"announcement_session": {
|
|
"recognised_bmo_amc_or_during": session_known,
|
|
"recognised_pct": session_pct,
|
|
"reliable": session_reliable,
|
|
"assessment": (
|
|
"usable"
|
|
if session_reliable
|
|
else "missing/unreliable; do not use same-day availability"
|
|
),
|
|
},
|
|
"point_in_time_policy": "announce_date_plus_1_trading_day_for_all_events",
|
|
"sue_scaling": {
|
|
"primary": "eps_surprise_over_stdev_of_prior_8_surprises_min_4",
|
|
"fallback_trigger": (
|
|
"paired event coverage <50% or symbols with >=8 paired events <50%"
|
|
),
|
|
"fallback_needed": fallback_needed,
|
|
"fallback_name": (
|
|
"eps_surprise_over_price"
|
|
if fallback_needed
|
|
else "not_used"
|
|
),
|
|
},
|
|
"backfill": backfill_status,
|
|
}
|
|
|
|
|
|
def _percentile(values: list[float], quantile: float) -> float | None:
|
|
if not values:
|
|
return None
|
|
ordered = sorted(values)
|
|
if len(ordered) == 1:
|
|
return ordered[0]
|
|
location = quantile * (len(ordered) - 1)
|
|
lower = int(math.floor(location))
|
|
upper = int(math.ceil(location))
|
|
if lower == upper:
|
|
return ordered[lower]
|
|
weight = location - lower
|
|
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
|
|
|
|
|
|
def _r_dist(values: list[float]) -> dict[str, Any]:
|
|
if not values:
|
|
return {"count": 0}
|
|
return {
|
|
"count": len(values),
|
|
"mean_r": round(sum(values) / len(values), 4),
|
|
"median_r": round(float(_percentile(values, 0.50)), 4),
|
|
"win_rate": round(sum(value > 0 for value in values) / len(values), 4),
|
|
"p05_r": round(float(_percentile(values, 0.05)), 4),
|
|
"p95_r": round(float(_percentile(values, 0.95)), 4),
|
|
"min_r": round(min(values), 4),
|
|
"max_r": round(max(values), 4),
|
|
}
|
|
|
|
|
|
def _first_session_after(announcement: date, calendar: list[date]) -> date | None:
|
|
index = bisect.bisect_right(calendar, announcement)
|
|
return calendar[index] if index < len(calendar) else None
|
|
|
|
|
|
def _entry_in_last_three_sessions(
|
|
entry: date, announcement: date, calendar: list[date]
|
|
) -> bool:
|
|
index = bisect.bisect_left(calendar, announcement)
|
|
return entry in calendar[max(0, index - 3) : index]
|
|
|
|
|
|
def _analyse_2a_trades(
|
|
details: list[dict[str, Any]],
|
|
events: list[dict[str, Any]],
|
|
calendar: list[date],
|
|
*,
|
|
cost_per_side: float,
|
|
) -> dict[str, Any]:
|
|
by_symbol: dict[str, list[date]] = defaultdict(list)
|
|
for event in events:
|
|
by_symbol[event["symbol"]].append(event["announce_date"])
|
|
for dates in by_symbol.values():
|
|
dates.sort()
|
|
|
|
parsed = []
|
|
for trade in details:
|
|
symbol = str(trade.get("symbol") or "").upper()
|
|
entry_raw = trade.get("entry_date")
|
|
exit_raw = trade.get("exit_date")
|
|
raw_r = trade.get("r")
|
|
if entry_raw is None or exit_raw is None or raw_r is None:
|
|
continue
|
|
entry_date = date.fromisoformat(str(entry_raw)[:10])
|
|
exit_date = date.fromisoformat(str(exit_raw)[:10])
|
|
entry_price = float(trade.get("entry") or 0.0)
|
|
initial_stop = float(trade.get("initial_stop") or 0.0)
|
|
exit_fill = float(trade.get("fill") or 0.0)
|
|
risk = entry_price - initial_stop
|
|
if risk <= 0:
|
|
continue
|
|
net_r = float(raw_r) - cost_per_side * (entry_price + exit_fill) / risk
|
|
announcements = by_symbol.get(symbol, [])
|
|
strict_hold = [
|
|
event_date
|
|
for event_date in announcements
|
|
if entry_date < event_date < exit_date
|
|
]
|
|
pre_entry = any(
|
|
_entry_in_last_three_sessions(entry_date, event_date, calendar)
|
|
for event_date in announcements
|
|
)
|
|
is_stop = str(trade.get("reason") or "") in {"stop", "trailing_stop"}
|
|
stop_after = is_stop and any(
|
|
_first_session_after(event_date, calendar) == exit_date
|
|
for event_date in announcements
|
|
)
|
|
parsed.append(
|
|
{
|
|
"symbol": symbol,
|
|
"entry": entry_date,
|
|
"exit": exit_date,
|
|
"net_r": net_r,
|
|
"earnings_strictly_in_hold": bool(strict_hold),
|
|
"pre_earnings_entry": pre_entry,
|
|
"is_stop": is_stop,
|
|
"stop_within_1d_after_earnings": stop_after,
|
|
}
|
|
)
|
|
|
|
losses = [trade for trade in parsed if trade["net_r"] <= -1.0]
|
|
losses_with = [trade for trade in losses if trade["earnings_strictly_in_hold"]]
|
|
all_with = [trade for trade in parsed if trade["earnings_strictly_in_hold"]]
|
|
pre = [trade["net_r"] for trade in parsed if trade["pre_earnings_entry"]]
|
|
other = [trade["net_r"] for trade in parsed if not trade["pre_earnings_entry"]]
|
|
stops = [trade for trade in parsed if trade["is_stop"]]
|
|
stops_after = [
|
|
trade["net_r"] for trade in stops if trade["stop_within_1d_after_earnings"]
|
|
]
|
|
other_stops = [
|
|
trade["net_r"] for trade in stops if not trade["stop_within_1d_after_earnings"]
|
|
]
|
|
all_other_exits = [
|
|
trade["net_r"]
|
|
for trade in parsed
|
|
if not trade["stop_within_1d_after_earnings"]
|
|
]
|
|
pre_dist = _r_dist(pre)
|
|
other_dist = _r_dist(other)
|
|
left_delta = None
|
|
right_delta = None
|
|
if pre and other:
|
|
left_delta = round(pre_dist["p05_r"] - other_dist["p05_r"], 4)
|
|
right_delta = round(pre_dist["p95_r"] - other_dist["p95_r"], 4)
|
|
tail_condition = (
|
|
left_delta is not None
|
|
and left_delta < 0
|
|
and right_delta is not None
|
|
and right_delta <= 0
|
|
)
|
|
return {
|
|
"verdict": "INFORMATIONAL",
|
|
"costs": {"per_side": cost_per_side, "r_is_net_of_round_trip_costs": True},
|
|
"closed_trades": len(parsed),
|
|
"q1_loss_concentration": {
|
|
"loss_definition": "realized_net_R <= -1.0",
|
|
"holding_period_definition": "announcement strictly after entry and before exit",
|
|
"losses_count": len(losses),
|
|
"losses_with_announcement_count": len(losses_with),
|
|
"losses_with_announcement_fraction": (
|
|
round(len(losses_with) / len(losses), 4) if losses else None
|
|
),
|
|
"all_trades_with_announcement_count": len(all_with),
|
|
"all_trades_with_announcement_fraction": (
|
|
round(len(all_with) / len(parsed), 4) if parsed else None
|
|
),
|
|
},
|
|
"q2_entries_within_3_trading_days_before_announcement": {
|
|
"pre_earnings": pre_dist,
|
|
"all_other_entries": other_dist,
|
|
"tail_deltas_pre_minus_other": {
|
|
"p05_r": left_delta,
|
|
"p95_r": right_delta,
|
|
},
|
|
"directional_tail_condition_present": tail_condition,
|
|
"tail_read": (
|
|
"Directional left-worse/right-not-better condition is present; "
|
|
"materiality and any filter design require separate human approval."
|
|
if tail_condition
|
|
else "Registered directional tail condition is not present."
|
|
),
|
|
},
|
|
"q3_stop_exits_within_1_trading_day_after_announcement": {
|
|
"stops_after_earnings": _r_dist(stops_after),
|
|
"all_other_stops": _r_dist(other_stops),
|
|
"all_other_exits": _r_dist(all_other_exits),
|
|
},
|
|
"implementation": "REPORT_ONLY_NO_FILTER_ARM_NO_FILTER_CHANGE",
|
|
}
|
|
|
|
|
|
async def _run_2a(
|
|
snapshot: Path,
|
|
events: list[dict[str, Any]],
|
|
symbols: set[str],
|
|
*,
|
|
analysis_start: date,
|
|
analysis_end: date,
|
|
workers: int,
|
|
quiet: bool,
|
|
) -> dict[str, Any]:
|
|
from app.config import settings
|
|
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.benchmark_service import load_benchmark_closes
|
|
from app.services.paper_trade_service import get_exit_policy
|
|
from app.services.recommendation_service import get_recommendation_config
|
|
|
|
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
|
settings.backtest_workers = workers
|
|
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)
|
|
tickers = list(
|
|
(
|
|
await db.execute(
|
|
select(Ticker)
|
|
.where(Ticker.symbol.in_(sorted(symbols)))
|
|
.order_by(Ticker.symbol)
|
|
)
|
|
).scalars()
|
|
)
|
|
spy = await load_benchmark_closes(db, "SPY")
|
|
prices: dict[str, tuple] = {}
|
|
replay_inputs: list[tuple[str, tuple]] = []
|
|
for index, ticker in enumerate(tickers):
|
|
if not quiet and index % 50 == 0:
|
|
print(f" 2a load {index}/{len(tickers)}", flush=True)
|
|
columns = await bt._fetch_columns(db, ticker.symbol)
|
|
if columns is None:
|
|
continue
|
|
prices[ticker.symbol] = columns
|
|
replay_inputs.append((ticker.symbol, columns))
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
candidates: list[dict] = []
|
|
process_count = bt._backtest_worker_count()
|
|
context = bt._mp_context() if process_count > 1 else None
|
|
if context is not None:
|
|
loop = asyncio.get_running_loop()
|
|
chunk_size = process_count * 2
|
|
with ProcessPoolExecutor(
|
|
max_workers=process_count, mp_context=context
|
|
) as pool:
|
|
for start in range(0, len(replay_inputs), chunk_size):
|
|
batch = replay_inputs[start : start + chunk_size]
|
|
futures = [
|
|
loop.run_in_executor(
|
|
pool,
|
|
bt._replay_candidates_for_period,
|
|
symbol,
|
|
columns,
|
|
config,
|
|
activation,
|
|
spy,
|
|
analysis_start,
|
|
"weekly",
|
|
True,
|
|
False,
|
|
)
|
|
for symbol, columns in batch
|
|
]
|
|
for ticker_candidates in await asyncio.gather(*futures):
|
|
candidates.extend(ticker_candidates)
|
|
if not quiet:
|
|
print(
|
|
f" 2a replay {min(start + len(batch), len(replay_inputs))}/"
|
|
f"{len(replay_inputs)} workers={process_count}",
|
|
flush=True,
|
|
)
|
|
else:
|
|
for index, (symbol, columns) in enumerate(replay_inputs):
|
|
if not quiet and index % 25 == 0:
|
|
print(f" 2a replay {index}/{len(replay_inputs)}", flush=True)
|
|
ticker_candidates = bt._replay_candidates_for_period(
|
|
symbol,
|
|
columns,
|
|
config,
|
|
activation,
|
|
spy,
|
|
analysis_start,
|
|
"weekly",
|
|
True,
|
|
False,
|
|
)
|
|
candidates.extend(ticker_candidates)
|
|
|
|
bt._assign_momentum_percentiles(candidates)
|
|
bt._assign_residual_momentum_percentiles(candidates)
|
|
bt._assign_low_volatility_percentiles(candidates)
|
|
bt._assign_activation_momentum_percentiles(candidates)
|
|
bt._assign_residual_high_vol_blend(candidates)
|
|
cutoff = float(activation.get("min_momentum_percentile", 80.0))
|
|
for candidate in candidates:
|
|
candidate["qualified"] = bt._momentum_qualifies(candidate, cutoff)
|
|
longs = [
|
|
candidate
|
|
for candidate in candidates
|
|
if candidate.get("qualified") and candidate.get("direction") == "long"
|
|
]
|
|
strategy = next(
|
|
row for row in bt.PORTFOLIO_MONITOR_STRATEGIES if row.get("is_production")
|
|
)
|
|
entry_config = bt._entry_variant_config(str(strategy["entry_variant"]))
|
|
if entry_config is None:
|
|
raise RuntimeError("Production entry configuration missing")
|
|
ranking_key = str(
|
|
entry_config.get("ranking_key") or entry_config["percentile_key"]
|
|
)
|
|
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))
|
|
reentry = bt._make_gate_reset_reentry_fn(
|
|
longs, prices, cadence="weekly", ranking_key=ranking_key
|
|
)
|
|
simulation = bt._simulate_portfolio(
|
|
longs,
|
|
prices,
|
|
spy,
|
|
exit_policy,
|
|
hold_days,
|
|
ranking_key=ranking_key,
|
|
max_positions=int(entry_config["max_positions"]),
|
|
risk_per_trade=float(entry_config["risk_per_trade"]),
|
|
atr_trail_multiplier=trail,
|
|
post_stop_reentry_fn=reentry,
|
|
fill_mode=bt.FILL_MODE_CLOSE,
|
|
cost_per_side=COST_PER_SIDE,
|
|
include_trades=True,
|
|
)
|
|
if simulation is None:
|
|
raise RuntimeError("Production-config simulation returned no result")
|
|
calendar = sorted(
|
|
{
|
|
date.fromordinal(int(ordinal))
|
|
for columns in prices.values()
|
|
for ordinal in columns[0]
|
|
}
|
|
)
|
|
all_trade_details = simulation.get("trade_details") or []
|
|
eligible_trade_details = []
|
|
for trade in all_trade_details:
|
|
entry_raw = trade.get("entry_date")
|
|
exit_raw = trade.get("exit_date")
|
|
if entry_raw is None or exit_raw is None:
|
|
continue
|
|
entry_date = date.fromisoformat(str(entry_raw)[:10])
|
|
exit_date = date.fromisoformat(str(exit_raw)[:10])
|
|
if analysis_start <= entry_date and exit_date <= analysis_end:
|
|
eligible_trade_details.append(trade)
|
|
analysis = _analyse_2a_trades(
|
|
eligible_trade_details,
|
|
events,
|
|
calendar,
|
|
cost_per_side=COST_PER_SIDE,
|
|
)
|
|
analysis["analysis_window"] = {
|
|
"from": analysis_start.isoformat(),
|
|
"to": analysis_end.isoformat(),
|
|
"rule": "entry_on_or_after_start_and_exit_on_or_before_end",
|
|
"simulation_trades_total": len(all_trade_details),
|
|
"trades_excluded_outside_earnings_coverage": (
|
|
len(all_trade_details) - len(eligible_trade_details)
|
|
),
|
|
}
|
|
analysis["run_config"] = {
|
|
"universe_symbols": len(tickers),
|
|
"fill_mode": "close",
|
|
"cost_per_side": COST_PER_SIDE,
|
|
"momentum_cutoff": cutoff,
|
|
"exit_policy": exit_policy,
|
|
"hold_days": hold_days,
|
|
"max_positions": int(entry_config["max_positions"]),
|
|
"risk_per_trade": float(entry_config["risk_per_trade"]),
|
|
}
|
|
analysis["sim_summary"] = {
|
|
key: simulation.get(key)
|
|
for key in (
|
|
"start_date",
|
|
"end_date",
|
|
"trades",
|
|
"sharpe",
|
|
"cagr_pct",
|
|
"max_drawdown_pct",
|
|
"total_return_pct",
|
|
)
|
|
}
|
|
return analysis
|
|
|
|
|
|
def _build_sue_series(
|
|
events_by_symbol: dict[str, list[dict[str, Any]]],
|
|
prices: dict[str, tuple],
|
|
*,
|
|
use_price_fallback: bool,
|
|
surprise_history_by_symbol: dict[str, list[dict[str, Any]]] | None = None,
|
|
) -> tuple[dict[str, dict[date, float]], dict[str, int]]:
|
|
result: dict[str, dict[date, float]] = {}
|
|
standard_values = 0
|
|
fallback_values = 0
|
|
history_scaled_values = 0
|
|
dropped_insufficient_history = 0
|
|
dropped_missing_period_alignment = 0
|
|
dropped_zero_stdev = 0
|
|
for symbol, columns in prices.items():
|
|
dates = [date.fromordinal(int(value)) for value in columns[0]]
|
|
closes = [float(value) for value in columns[4]]
|
|
if not dates:
|
|
continue
|
|
surprises = []
|
|
for event in events_by_symbol.get(symbol.upper(), []):
|
|
actual = event.get("eps_actual")
|
|
estimate = event.get("eps_estimate")
|
|
if actual is None or estimate is None:
|
|
continue
|
|
surprises.append(
|
|
{
|
|
"announce_date": event["announce_date"],
|
|
"period_end_date": event.get("period_end_date"),
|
|
"surprise": float(actual) - float(estimate),
|
|
}
|
|
)
|
|
surprises.sort(key=lambda item: item["announce_date"])
|
|
history = []
|
|
if surprise_history_by_symbol is not None:
|
|
for row in surprise_history_by_symbol.get(symbol.upper(), []):
|
|
actual = row.get("eps_actual")
|
|
estimate = row.get("eps_estimate")
|
|
if actual is None or estimate is None:
|
|
continue
|
|
history.append(
|
|
(
|
|
row["period_end_date"],
|
|
float(actual) - float(estimate),
|
|
)
|
|
)
|
|
history.sort(key=lambda item: item[0])
|
|
live: dict[date, float] = {}
|
|
for index, event in enumerate(surprises):
|
|
announcement = event["announce_date"]
|
|
surprise = float(event["surprise"])
|
|
period_end = event.get("period_end_date")
|
|
if surprise_history_by_symbol is not None:
|
|
if period_end is None:
|
|
dropped_missing_period_alignment += 1
|
|
continue
|
|
trailing = [
|
|
value for history_period, value in history if history_period < period_end
|
|
][-SUE_TRAIL:]
|
|
else:
|
|
trailing = [
|
|
float(prior["surprise"])
|
|
for prior in surprises[max(0, index - SUE_TRAIL) : index]
|
|
]
|
|
sue = None
|
|
if len(trailing) >= SUE_MIN_TRAIL:
|
|
mean = sum(trailing) / len(trailing)
|
|
variance = sum((value - mean) ** 2 for value in trailing) / (
|
|
len(trailing) - 1
|
|
)
|
|
stdev = math.sqrt(variance) if variance > 0 else 0.0
|
|
if stdev > 1e-12:
|
|
sue = surprise / stdev
|
|
standard_values += 1
|
|
if surprise_history_by_symbol is not None:
|
|
history_scaled_values += 1
|
|
else:
|
|
dropped_zero_stdev += 1
|
|
else:
|
|
dropped_insufficient_history += 1
|
|
if sue is None and use_price_fallback:
|
|
price_index = bisect.bisect_right(dates, announcement) - 1
|
|
if price_index >= 0 and closes[price_index] > 0:
|
|
sue = surprise / closes[price_index]
|
|
fallback_values += 1
|
|
if sue is None or not math.isfinite(sue):
|
|
continue
|
|
start_index = bisect.bisect_right(dates, announcement)
|
|
if start_index >= len(dates):
|
|
continue
|
|
end_index = min(len(dates), start_index + SUE_CARRY_DAYS)
|
|
for trading_index in range(start_index, end_index):
|
|
live[dates[trading_index]] = float(sue)
|
|
if live:
|
|
result[symbol.upper()] = live
|
|
return result, {
|
|
"standard_scaled_events": standard_values,
|
|
"events_scaled_from_period_history": history_scaled_values,
|
|
"price_fallback_events": fallback_values,
|
|
"dropped_insufficient_trailing_history": dropped_insufficient_history,
|
|
"dropped_missing_period_alignment": dropped_missing_period_alignment,
|
|
"dropped_zero_stdev": dropped_zero_stdev,
|
|
}
|
|
|
|
|
|
def _find_signal(rows: list[dict[str, Any]], signal: str) -> dict[str, Any] | None:
|
|
return next((row for row in rows if row.get("signal") == signal), None)
|
|
|
|
|
|
def _mechanical_sue_grade(
|
|
sue_row: dict[str, Any] | None,
|
|
pre_row: dict[str, Any] | None,
|
|
post_row: dict[str, Any] | None,
|
|
) -> tuple[bool, bool]:
|
|
sign_stable = bool(
|
|
pre_row
|
|
and post_row
|
|
and float(pre_row.get("mean_ic", 0.0)) > 0
|
|
and float(post_row.get("mean_ic", 0.0)) > 0
|
|
)
|
|
passed = bool(
|
|
sue_row
|
|
and float(sue_row.get("mean_ic", -999.0)) >= IRON_IC_BAR
|
|
and bool(sue_row.get("reliable"))
|
|
and sign_stable
|
|
)
|
|
return passed, sign_stable
|
|
|
|
|
|
def _conditional_ic(bt, sue_weeks: dict) -> dict[str, Any]:
|
|
usable = [
|
|
week
|
|
for week, records in sue_weeks.items()
|
|
if len(records) >= bt.MIN_CROSS_SECTION
|
|
]
|
|
kept = bt._nonoverlapping_weeks(
|
|
usable, max(1, round(bt.HORIZON / 5))
|
|
)
|
|
values = []
|
|
sizes = []
|
|
for week in kept:
|
|
records = [
|
|
record for record in sue_weeks[week] if record.get("mom_12_1") is not None
|
|
]
|
|
if len(records) < bt.MIN_CROSS_SECTION:
|
|
continue
|
|
ordered = sorted(records, key=lambda record: float(record["mom_12_1"]))
|
|
top = ordered[-max(1, len(ordered) // 5) :]
|
|
if len(top) < 5:
|
|
continue
|
|
ic = bt._spearman(
|
|
[float(record["val"]) for record in top],
|
|
[float(record["fwd"]) for record in top],
|
|
)
|
|
if ic is not None:
|
|
values.append(float(ic))
|
|
sizes.append(len(top))
|
|
if not values:
|
|
return {"mean_ic": None, "weeks": 0, "avg_cross_section": None}
|
|
mean = sum(values) / len(values)
|
|
if len(values) > 1:
|
|
stdev = math.sqrt(
|
|
sum((value - mean) ** 2 for value in values) / (len(values) - 1)
|
|
)
|
|
t_stat = mean / stdev * math.sqrt(len(values)) if stdev > 0 else None
|
|
else:
|
|
t_stat = None
|
|
return {
|
|
"mean_ic": round(mean, 4),
|
|
"ic_t_stat": round(t_stat, 2) if t_stat is not None else None,
|
|
"weeks": len(values),
|
|
"avg_cross_section": round(sum(sizes) / len(sizes), 1),
|
|
"population": "top_mom_12_1_quintile_only",
|
|
}
|
|
|
|
|
|
async def _run_2b(
|
|
snapshot: Path,
|
|
events: list[dict[str, Any]],
|
|
surprise_history: dict[str, list[dict[str, Any]]],
|
|
symbols: set[str],
|
|
*,
|
|
quality: dict[str, Any],
|
|
workers: int,
|
|
quiet: bool,
|
|
) -> dict[str, Any]:
|
|
from app.config import settings
|
|
from app.models.ticker import Ticker
|
|
from app.services import backtest_service as bt
|
|
from app.services.benchmark_service import load_benchmark_closes
|
|
|
|
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
|
os.environ["BACKTEST_SIGNAL_EVAL_ONLY"] = "1"
|
|
settings.backtest_workers = workers
|
|
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
|
session_factory = async_sessionmaker(
|
|
engine, class_=AsyncSession, expire_on_commit=False
|
|
)
|
|
prices: dict[str, tuple] = {}
|
|
try:
|
|
async with session_factory() as db:
|
|
tickers = list(
|
|
(
|
|
await db.execute(
|
|
select(Ticker)
|
|
.where(Ticker.symbol.in_(sorted(symbols)))
|
|
.order_by(Ticker.symbol)
|
|
)
|
|
).scalars()
|
|
)
|
|
spy = await load_benchmark_closes(db, "SPY")
|
|
for index, ticker in enumerate(tickers):
|
|
if not quiet and index % 25 == 0:
|
|
print(f" 2b signals {index}/{len(tickers)}", flush=True)
|
|
columns = await bt._fetch_columns(db, ticker.symbol)
|
|
if columns is not None:
|
|
prices[ticker.symbol] = columns
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
events_by_symbol: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
for event in events:
|
|
events_by_symbol[event["symbol"]].append(event)
|
|
fallback = bool(quality["sue_scaling"]["fallback_needed"])
|
|
sue_map, scaling_counts = _build_sue_series(
|
|
events_by_symbol,
|
|
prices,
|
|
use_price_fallback=fallback,
|
|
surprise_history_by_symbol=surprise_history,
|
|
)
|
|
|
|
sue_all: dict = defaultdict(list)
|
|
identical: dict = defaultdict(lambda: defaultdict(list))
|
|
for symbol, columns in prices.items():
|
|
dates = [date.fromordinal(int(value)) for value in columns[0]]
|
|
highs = [float(value) for value in columns[2]]
|
|
closes = [float(value) for value in columns[4]]
|
|
volumes = [float(value) for value in columns[5]]
|
|
records = [
|
|
SimpleNamespace(
|
|
date=dates[index],
|
|
close=closes[index],
|
|
high=highs[index],
|
|
volume=volumes[index],
|
|
)
|
|
for index in range(len(dates))
|
|
]
|
|
live_sue = sue_map.get(symbol.upper(), {})
|
|
if not live_sue:
|
|
continue
|
|
for index in bt._weekly_asof_indices(records):
|
|
forward_index = index + bt.HORIZON
|
|
if forward_index >= len(records) or closes[index] <= 0:
|
|
continue
|
|
as_of = dates[index]
|
|
sue_value = live_sue.get(as_of)
|
|
if sue_value is None:
|
|
continue
|
|
forward = closes[forward_index] / closes[index] - 1.0
|
|
signal_values = bt._signal_values(
|
|
dates, closes, highs, index, spy
|
|
)
|
|
momentum = signal_values.get("mom_12_1")
|
|
residual = signal_values.get("mom_12_1_resid")
|
|
iso = as_of.isocalendar()
|
|
week = (iso.year, iso.week)
|
|
sue_record = {
|
|
"val": float(sue_value),
|
|
"fwd": float(forward),
|
|
"symbol": symbol,
|
|
"mom_12_1": momentum,
|
|
}
|
|
sue_all[week].append(sue_record)
|
|
if momentum is not None and residual is not None:
|
|
identical["sue_latest"][week].append(sue_record)
|
|
identical["mom_12_1"][week].append(
|
|
{"val": float(momentum), "fwd": forward, "symbol": symbol}
|
|
)
|
|
identical["mom_12_1_resid"][week].append(
|
|
{"val": float(residual), "fwd": forward, "symbol": symbol}
|
|
)
|
|
|
|
full_eval = bt._signal_evaluation({"sue_latest": sue_all})
|
|
identical_eval = bt._signal_evaluation(identical)
|
|
pre_eval = bt._signal_evaluation(
|
|
{"sue_latest": {week: rows for week, rows in sue_all.items() if week[0] < 2021}}
|
|
)
|
|
post_eval = bt._signal_evaluation(
|
|
{"sue_latest": {week: rows for week, rows in sue_all.items() if week[0] >= 2021}}
|
|
)
|
|
sue_row = _find_signal(full_eval, "sue_latest")
|
|
pre_row = _find_signal(pre_eval, "sue_latest")
|
|
post_row = _find_signal(post_eval, "sue_latest")
|
|
pass_grade, sign_stable = _mechanical_sue_grade(sue_row, pre_row, post_row)
|
|
verdict = "PASS" if pass_grade else "FAIL"
|
|
verdict_detail = (
|
|
"SUE candidate confirmed - book-integration design (tilt vs second gate) "
|
|
"is PENDING_HUMAN. Do not integrate anything yourself."
|
|
if pass_grade
|
|
else "SUE DEAD for this stack"
|
|
)
|
|
weekly_sizes = [len(rows) for rows in sue_all.values()]
|
|
average_weekly_n = (
|
|
round(sum(weekly_sizes) / len(weekly_sizes), 1) if weekly_sizes else 0.0
|
|
)
|
|
average_scored_n = sue_row.get("avg_cross_section") if sue_row else None
|
|
thin = average_scored_n is None or float(average_scored_n) < 100.0
|
|
side_by_side = {
|
|
signal: _find_signal(identical_eval, signal)
|
|
for signal in ("sue_latest", "mom_12_1", "mom_12_1_resid")
|
|
}
|
|
return {
|
|
"verdict": verdict,
|
|
"verdict_detail": verdict_detail,
|
|
"grade_rule": {
|
|
"mean_ic_ge_0_03_positive": (
|
|
bool(sue_row and float(sue_row.get("mean_ic", -999.0)) >= IRON_IC_BAR)
|
|
),
|
|
"reliable_ge_12_windows": bool(sue_row and sue_row.get("reliable")),
|
|
"positive_sign_pre_and_post_2021": sign_stable,
|
|
"pass": pass_grade,
|
|
},
|
|
"sue_unconditional": sue_row,
|
|
"era_split": {"pre_2021": pre_row, "post_2021": post_row},
|
|
"signal_eval_identical_cross_sections": side_by_side,
|
|
"identical_cross_section_definition": (
|
|
"same week-symbol-forward-return cells where sue_latest, mom_12_1, "
|
|
"and mom_12_1_resid are all non-null"
|
|
),
|
|
"momentum_conditional_top_quintile": _conditional_ic(bt, sue_all),
|
|
"coverage": {
|
|
"symbols_with_live_sue": len(sue_map),
|
|
"avg_weekly_live_n_all_weeks": average_weekly_n,
|
|
"avg_cross_section_n_scored_nonoverlap": average_scored_n,
|
|
"thin_cross_section_lt_100": thin,
|
|
"warning": (
|
|
"THIN CROSS-SECTION: fewer than 100 live SUE names per scored week."
|
|
if thin
|
|
else None
|
|
),
|
|
},
|
|
"scaling": {
|
|
"method": quality["sue_scaling"]["primary"],
|
|
"fallback": quality["sue_scaling"]["fallback_name"],
|
|
"counts": scaling_counts,
|
|
"pre_coverage_history_policy": (
|
|
"period-end EPS surprises may scale later events but are never "
|
|
"treated as live signals without an announcement date"
|
|
),
|
|
"availability": "announce_date_plus_1_trading_day",
|
|
"carry_trading_days": SUE_CARRY_DAYS,
|
|
},
|
|
"universe_symbols": len(prices),
|
|
}
|
|
|
|
|
|
def _format(value: Any) -> str:
|
|
if value is None:
|
|
return "-"
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
return str(value)
|
|
|
|
|
|
def _quality_markdown(quality: dict[str, Any], depth: dict[str, Any]) -> str:
|
|
annual = quality["events_per_symbol_year"]
|
|
session = quality["announcement_session"]
|
|
backfill = quality["backfill"]
|
|
source = backfill.get("source") or {}
|
|
lines = [
|
|
"### Data quality gate",
|
|
"",
|
|
(
|
|
f"Approved earnings window: {quality['window']['from']} to "
|
|
f"{quality['window']['to']}. Source mode: {backfill.get('mode')}."
|
|
),
|
|
"",
|
|
"| check | result |",
|
|
"|---|---:|",
|
|
f"| Prod symbols requested / tradable | {depth['requested_symbols']} / {depth['tradable_symbols']} |",
|
|
f"| Manifest complete + live counts match | {depth['manifest'].get('complete')} |",
|
|
f"| Prod symbols with pre-2021 bars | {depth['symbols_with_pre2021_bars']} ({depth['symbols_with_pre2021_bars_pct']}%) |",
|
|
f"| SPY benchmark depth | {depth['benchmark_spy']['rows']} rows, {depth['benchmark_spy']['min']} to {depth['benchmark_spy']['max']} |",
|
|
f"| Snapshot depth gate | {depth['gate_pass']} |",
|
|
f"| Bulk source windows / requests logged | {backfill.get('bulk_windows_done')}/{backfill.get('bulk_windows_total')} / {backfill.get('bulk_requests_logged_total')} |",
|
|
f"| Source repository / pinned commit | {source.get('repository')} @ {source.get('commit')} |",
|
|
f"| Source license / upstream provider documented | {source.get('license')} / {source.get('upstream_provider_documented')} |",
|
|
f"| Existing-source conflicts preserved | {backfill.get('conflicting_existing_rows')} rows / {backfill.get('conflicting_existing_fields')} fields |",
|
|
f"| Symbols with >=8 announcements | {quality['symbols_with_ge8_announcements']} ({quality['symbols_with_ge8_announcements_pct']}%) |",
|
|
f"| Symbols with >=8 paired announcements | {quality['symbols_with_ge8_paired_announcements']} ({quality['symbols_with_ge8_paired_announcements_pct']}%) |",
|
|
f"| Events with estimate + actual | {quality['events_with_actual_and_estimate']}/{quality['events']} ({quality['events_with_actual_and_estimate_pct']}%) |",
|
|
f"| Duplicate rows in keyed table | {quality['duplicate_rows_in_table']} |",
|
|
f"| Duplicate / restated payload rows fetched | {quality['duplicate_rows_fetched']} / {quality['restated_rows_fetched']} |",
|
|
f"| Mean announcements per active symbol-year | {annual['mean_active_span_rate']} (expected about 4) |",
|
|
f"| Symbols far off (<2 or >6/year, incl. zero) | {annual['far_off_count']} |",
|
|
f"| Recognised BMO/AMC/during | {session['recognised_pct']}% (reliable={session['reliable']}) |",
|
|
f"| Point-in-time policy | {quality['point_in_time_policy']} |",
|
|
f"| SUE price fallback | {quality['sue_scaling']['fallback_name']} |",
|
|
"",
|
|
f"Deduplication: {quality['dedupe_policy']}",
|
|
"",
|
|
"Far-off announcement-rate symbols: "
|
|
+ (", ".join(row["symbol"] for row in annual["far_off_symbols"]) or "none"),
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _dist_markdown(label: str, row: dict[str, Any]) -> str:
|
|
return (
|
|
f"| {label} | {_format(row.get('count'))} | {_format(row.get('mean_r'))} | "
|
|
f"{_format(row.get('median_r'))} | {_format(row.get('win_rate'))} | "
|
|
f"{_format(row.get('p05_r'))} | {_format(row.get('p95_r'))} |"
|
|
)
|
|
|
|
|
|
def _two_a_markdown(result: dict[str, Any]) -> str:
|
|
q1 = result["q1_loss_concentration"]
|
|
q2 = result["q2_entries_within_3_trading_days_before_announcement"]
|
|
q3 = result["q3_stop_exits_within_1_trading_day_after_announcement"]
|
|
window = result["analysis_window"]
|
|
lines = [
|
|
"### Experiment 2a - earnings-gap risk diagnostic",
|
|
"",
|
|
"Verdict: **INFORMATIONAL**. Report-only; no filter arm or implementation.",
|
|
"",
|
|
(
|
|
f"Trade cohort is restricted to the approved earnings-coverage window "
|
|
f"{window['from']} to {window['to']}; "
|
|
f"{window['trades_excluded_outside_earnings_coverage']} simulated trades "
|
|
"outside that window were excluded."
|
|
),
|
|
"",
|
|
"| cohort | count | fraction |",
|
|
"|---|---:|---:|",
|
|
f"| Realized net R <= -1.0 | {q1['losses_count']} | - |",
|
|
f"| Losses with announcement strictly inside hold | {q1['losses_with_announcement_count']} | {q1['losses_with_announcement_fraction']} |",
|
|
f"| All trades with announcement strictly inside hold | {q1['all_trades_with_announcement_count']} | {q1['all_trades_with_announcement_fraction']} |",
|
|
"",
|
|
"| Entry cohort | count | mean R | median R | win rate | p05 R | p95 R |",
|
|
"|---|---:|---:|---:|---:|---:|---:|",
|
|
_dist_markdown("Within 3 sessions before earnings", q2["pre_earnings"]),
|
|
_dist_markdown("All other entries", q2["all_other_entries"]),
|
|
"",
|
|
f"Tail deltas (pre minus other): p05={q2['tail_deltas_pre_minus_other']['p05_r']}, p95={q2['tail_deltas_pre_minus_other']['p95_r']}.",
|
|
"",
|
|
q2["tail_read"],
|
|
"",
|
|
"| Exit cohort | count | mean R | median R | win rate | p05 R | p95 R |",
|
|
"|---|---:|---:|---:|---:|---:|---:|",
|
|
_dist_markdown("Stops within 1 session after earnings", q3["stops_after_earnings"]),
|
|
_dist_markdown("All other stops", q3["all_other_stops"]),
|
|
_dist_markdown("All other exits", q3["all_other_exits"]),
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _ic_row(label: str, row: dict[str, Any] | None) -> str:
|
|
row = row or {}
|
|
return (
|
|
f"| {label} | {_format(row.get('mean_ic'))} | {_format(row.get('ic_t_stat'))} | "
|
|
f"{_format(row.get('weeks'))} | {_format(row.get('avg_cross_section'))} | "
|
|
f"{_format(row.get('ic_positive_pct'))} | {_format(row.get('reliable'))} |"
|
|
)
|
|
|
|
|
|
def _two_b_markdown(result: dict[str, Any]) -> str:
|
|
side = result["signal_eval_identical_cross_sections"]
|
|
era = result["era_split"]
|
|
coverage = result["coverage"]
|
|
conditional = result["momentum_conditional_top_quintile"]
|
|
lines = [
|
|
"### Experiment 2b - SUE / post-earnings drift",
|
|
"",
|
|
f"Mechanical verdict: **{result['verdict']}** - {result['verdict_detail']}",
|
|
"",
|
|
"Identical cross-sections:",
|
|
"",
|
|
"| signal | mean IC | t | windows | avg N | IC positive % | reliable |",
|
|
"|---|---:|---:|---:|---:|---:|---|",
|
|
_ic_row("sue_latest", side.get("sue_latest")),
|
|
_ic_row("mom_12_1", side.get("mom_12_1")),
|
|
_ic_row("mom_12_1_resid", side.get("mom_12_1_resid")),
|
|
"",
|
|
"Unconditional SUE grade row:",
|
|
"",
|
|
"| signal | mean IC | t | windows | avg N | IC positive % | reliable |",
|
|
"|---|---:|---:|---:|---:|---:|---|",
|
|
_ic_row("sue_latest", result.get("sue_unconditional")),
|
|
"",
|
|
"Era stability:",
|
|
"",
|
|
"| era | mean IC | t | windows | avg N | IC positive % | reliable |",
|
|
"|---|---:|---:|---:|---:|---:|---|",
|
|
_ic_row("pre-2021", era.get("pre_2021")),
|
|
_ic_row("post-2021", era.get("post_2021")),
|
|
"",
|
|
f"Coverage: {coverage['symbols_with_live_sue']} symbols with live SUE; avg weekly N={coverage['avg_weekly_live_n_all_weeks']}; scored non-overlap avg N={coverage['avg_cross_section_n_scored_nonoverlap']}.",
|
|
"",
|
|
coverage.get("warning") or "Cross-section is not flagged thin at the registered <100-name read.",
|
|
"",
|
|
f"Momentum-conditional top-quintile SUE: mean IC={conditional.get('mean_ic')}, t={conditional.get('ic_t_stat')}, windows={conditional.get('weeks')}, avg N={conditional.get('avg_cross_section')}.",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _write_reports(
|
|
*,
|
|
stamp: str,
|
|
generated_at: str,
|
|
snapshot: Path,
|
|
depth: dict[str, Any],
|
|
quality: dict[str, Any],
|
|
result_2a: dict[str, Any],
|
|
result_2b: dict[str, Any],
|
|
) -> tuple[Path, Path]:
|
|
reports = Path("reports")
|
|
reports.mkdir(parents=True, exist_ok=True)
|
|
path_2a = reports / f"earnings-2a-gap-{stamp}.json"
|
|
path_2b = reports / f"earnings-2b-sue-{stamp}.json"
|
|
common = {
|
|
"generated_at": generated_at,
|
|
"snapshot": str(snapshot.resolve()),
|
|
"snapshot_depth": depth,
|
|
"data_quality": quality,
|
|
"production_impact": "none",
|
|
}
|
|
payload_2a = {**common, "experiment": "2a", "result": result_2a}
|
|
payload_2b = {**common, "experiment": "2b", "result": result_2b}
|
|
path_2a.write_text(
|
|
json.dumps(payload_2a, indent=2, default=str) + "\n", encoding="utf-8"
|
|
)
|
|
path_2b.write_text(
|
|
json.dumps(payload_2b, indent=2, default=str) + "\n", encoding="utf-8"
|
|
)
|
|
path_2a.with_suffix(".md").write_text(
|
|
"# Earnings Task 2a - gap diagnostic\n\n"
|
|
+ _quality_markdown(quality, depth)
|
|
+ "\n\n"
|
|
+ _two_a_markdown(result_2a)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
path_2b.with_suffix(".md").write_text(
|
|
"# Earnings Task 2b - SUE / PEAD\n\n"
|
|
+ _quality_markdown(quality, depth)
|
|
+ "\n\n"
|
|
+ _two_b_markdown(result_2b)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
return path_2a, path_2b
|
|
|
|
|
|
def _update_research_doc(
|
|
*,
|
|
depth: dict[str, Any],
|
|
quality: dict[str, Any],
|
|
result_2a: dict[str, Any],
|
|
result_2b: dict[str, Any],
|
|
path_2a: Path,
|
|
path_2b: Path,
|
|
) -> None:
|
|
path = Path("docs/research/earnings-gap-and-sue.md")
|
|
existing = path.read_text(encoding="utf-8") if path.exists() else "# Earnings gap and SUE"
|
|
marker = "## Results"
|
|
index = existing.find(marker)
|
|
preregistration = existing[:index].rstrip() if index >= 0 else existing.rstrip()
|
|
final_status = (
|
|
"Task 2 CLOSED (SUE PASS→PENDING_HUMAN)"
|
|
if result_2b["verdict"] == "PASS"
|
|
else "Task 2 CLOSED (SUE DEAD)"
|
|
)
|
|
body = [
|
|
preregistration,
|
|
"",
|
|
"## Results",
|
|
"",
|
|
_quality_markdown(quality, depth),
|
|
"",
|
|
_two_a_markdown(result_2a),
|
|
"",
|
|
_two_b_markdown(result_2b),
|
|
"",
|
|
"## Artifacts",
|
|
"",
|
|
f"- `{path_2a.as_posix()}` and companion Markdown",
|
|
f"- `{path_2b.as_posix()}` and companion Markdown",
|
|
"- `reports/earnings-backfill-status.json`",
|
|
"",
|
|
"Production changes: **none**. No earnings filter or SUE integration was implemented.",
|
|
"",
|
|
f"## Final status: **{final_status}**",
|
|
"",
|
|
]
|
|
path.write_text("\n".join(body), encoding="utf-8")
|
|
|
|
|
|
async def _main() -> None:
|
|
args = _parse_args()
|
|
snapshot = Path(args.snapshot)
|
|
universe_snapshot = Path(args.universe_snapshot)
|
|
earnings_snapshot = Path(args.earnings_snapshot)
|
|
for path in (snapshot, universe_snapshot, earnings_snapshot):
|
|
if not path.exists():
|
|
raise SystemExit(f"Missing snapshot: {path}")
|
|
if args.allow_spawn:
|
|
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
|
|
|
requested_symbols = set(_read_symbols(universe_snapshot))
|
|
depth = _snapshot_depth(snapshot, requested_symbols)
|
|
print(
|
|
"Snapshot guard:",
|
|
json.dumps(
|
|
{
|
|
"manifest_complete": depth["manifest"].get("complete"),
|
|
"tradable": depth["tradable_symbols"],
|
|
"pre2021_pct": depth["symbols_with_pre2021_bars_pct"],
|
|
"benchmark": depth["benchmark_spy"],
|
|
"gate_pass": depth["gate_pass"],
|
|
},
|
|
default=str,
|
|
),
|
|
)
|
|
if not depth["gate_pass"]:
|
|
raise SystemExit(
|
|
"Snapshot depth gate failed. Repair the production-universe depth and "
|
|
"SPY benchmark before running either experiment."
|
|
)
|
|
tradable_symbols = requested_symbols - set(depth["missing_symbols"]) - set(
|
|
depth["zero_bar_symbols"]
|
|
)
|
|
|
|
backfill_status = _load_backfill_status()
|
|
price_start = date.fromisoformat(depth["price_window"]["min"])
|
|
price_end = date.fromisoformat(depth["price_window"]["max"])
|
|
backfill_window = backfill_status.get("window") or {}
|
|
coverage_start = (
|
|
date.fromisoformat(backfill_window["from"])
|
|
if backfill_window.get("from")
|
|
else None
|
|
)
|
|
coverage_end = (
|
|
date.fromisoformat(backfill_window["to"])
|
|
if backfill_window.get("to")
|
|
else None
|
|
)
|
|
approved_shorter_window = bool(
|
|
backfill_status.get("mode") == "dolthub_public_bulk_clone"
|
|
and (backfill_status.get("coverage_amendment") or {}).get(
|
|
"approved_by_user"
|
|
)
|
|
)
|
|
backfill_covers_approved_window = bool(
|
|
coverage_start
|
|
and coverage_end
|
|
and coverage_end >= price_end
|
|
and (coverage_start <= price_start or approved_shorter_window)
|
|
)
|
|
allowed_modes = {"fmp_bulk_date_range_only", "dolthub_public_bulk_clone"}
|
|
if (
|
|
backfill_status.get("mode") not in allowed_modes
|
|
or not backfill_status.get("complete")
|
|
or not backfill_covers_approved_window
|
|
):
|
|
raise SystemExit(
|
|
"Bulk earnings backfill is incomplete or does not cover its approved "
|
|
"research window through the price snapshot end."
|
|
)
|
|
if coverage_start is None or coverage_end is None:
|
|
raise SystemExit("Backfill status is missing its approved coverage window.")
|
|
analysis_start = max(price_start, coverage_start)
|
|
analysis_end = min(price_end, coverage_end)
|
|
|
|
events = [
|
|
event
|
|
for event in _load_earnings(earnings_snapshot, tradable_symbols)
|
|
if analysis_start <= event["announce_date"] <= analysis_end
|
|
]
|
|
surprise_history = _load_surprise_history(
|
|
earnings_snapshot, tradable_symbols
|
|
)
|
|
quality = _data_quality(
|
|
events,
|
|
tradable_symbols,
|
|
window_start=analysis_start,
|
|
window_end=analysis_end,
|
|
backfill_status=backfill_status,
|
|
)
|
|
print(
|
|
"Earnings quality:",
|
|
json.dumps(
|
|
{
|
|
"events": quality["events"],
|
|
"symbols_ge8_pct": quality["symbols_with_ge8_announcements_pct"],
|
|
"paired_pct": quality["events_with_actual_and_estimate_pct"],
|
|
"session": quality["announcement_session"],
|
|
"fallback": quality["sue_scaling"]["fallback_name"],
|
|
}
|
|
),
|
|
)
|
|
|
|
stamp = args.stamp or datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
generated_at = datetime.now(timezone.utc).isoformat()
|
|
reports = Path("reports")
|
|
reports.mkdir(parents=True, exist_ok=True)
|
|
print("Running Experiment 2a...")
|
|
result_2a = await _run_2a(
|
|
snapshot,
|
|
events,
|
|
tradable_symbols,
|
|
analysis_start=analysis_start,
|
|
analysis_end=analysis_end,
|
|
workers=args.workers,
|
|
quiet=args.quiet,
|
|
)
|
|
checkpoint_2a = reports / f"earnings-2a-gap-{stamp}.checkpoint.json"
|
|
checkpoint_2a.write_text(
|
|
json.dumps(
|
|
{
|
|
"generated_at": generated_at,
|
|
"snapshot_depth": depth,
|
|
"data_quality": quality,
|
|
"result": result_2a,
|
|
},
|
|
indent=2,
|
|
default=str,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
print(f"Wrote 2a checkpoint: {checkpoint_2a}", flush=True)
|
|
print("Running Experiment 2b...")
|
|
result_2b = await _run_2b(
|
|
snapshot,
|
|
events,
|
|
surprise_history,
|
|
tradable_symbols,
|
|
quality=quality,
|
|
workers=args.workers,
|
|
quiet=args.quiet,
|
|
)
|
|
checkpoint_2b = reports / f"earnings-2b-sue-{stamp}.checkpoint.json"
|
|
checkpoint_2b.write_text(
|
|
json.dumps(
|
|
{
|
|
"generated_at": generated_at,
|
|
"snapshot_depth": depth,
|
|
"data_quality": quality,
|
|
"result": result_2b,
|
|
},
|
|
indent=2,
|
|
default=str,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
print(f"Wrote 2b checkpoint: {checkpoint_2b}", flush=True)
|
|
path_2a, path_2b = _write_reports(
|
|
stamp=stamp,
|
|
generated_at=generated_at,
|
|
snapshot=snapshot,
|
|
depth=depth,
|
|
quality=quality,
|
|
result_2a=result_2a,
|
|
result_2b=result_2b,
|
|
)
|
|
_update_research_doc(
|
|
depth=depth,
|
|
quality=quality,
|
|
result_2a=result_2a,
|
|
result_2b=result_2b,
|
|
path_2a=path_2a,
|
|
path_2b=path_2b,
|
|
)
|
|
print(f"2a verdict: {result_2a['verdict']}")
|
|
print(f"2b verdict: {result_2b['verdict']} - {result_2b['verdict_detail']}")
|
|
print(f"Wrote {path_2a} and {path_2b} (+ Markdown companions)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(_main())
|