You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fithia2/apps/tools/peer_relative_idle_alpha_pr...

391 lines
15 KiB
Python

"""Probe additivity from peer-relative surprise features on top of idle-alpha.
This is a standalone research tool. It does not modify shared runtime code.
The probe compares a baseline experiment config against variants that add
peer-relative surprise filters to the existing post-allocation idle-alpha
earnings engines.
Example:
python -m apps.tools.peer_relative_idle_alpha_probe \
--config configs/experiments/return_max_long_v17.1_idle_alpha_extended.json \
--start 2022-01-01 \
--end 2026-03-31 \
--period-start y2026=2026-01-01
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from apps.backtester.run import BacktestRunner, _build_merged_snapshot_store, load_manifest, resolve_config
from libs.backtest.artifacts import _resolve_trade_sleeve
from libs.common.logging import configure_logging
DEFAULT_CONFIG = "configs/experiments/return_max_long_v17.1_idle_alpha_extended.json"
TARGET_EVENT_TYPE = "earnings_release"
TARGET_SCOPE_BULLISH_ENGINE_ID = "next_open_long_bullish_raised_strong"
@dataclass(frozen=True)
class VariantSpec:
name: str
scope: str
peer_sector_event_count_365d_min: float | None = None
peer_relative_surprise_pct_365d_min: float | None = None
peer_relative_sue_hist_mean_4q_365d_min: float | None = None
def _parse_date(value: str, *, is_end: bool = False) -> dt.date:
parts = value.split("-")
if len(parts) == 1 and len(value) == 4 and value.isdigit():
year = int(value)
return dt.date(year, 12, 31) if is_end else dt.date(year, 1, 1)
if len(parts) == 2 and all(part.isdigit() for part in parts):
year = int(parts[0])
month = int(parts[1])
if is_end:
next_month = dt.date(year + (month // 12), (month % 12) + 1, 1)
return next_month - dt.timedelta(days=1)
return dt.date(year, month, 1)
return dt.date.fromisoformat(value)
def _parse_period_start(value: str) -> tuple[str, dt.date]:
if "=" in value:
label, raw_date = value.split("=", 1)
else:
label, raw_date = value, value
return label, _parse_date(raw_date)
def _period_stats(curve: list[Any], start_date: dt.date) -> dict[str, Any] | None:
points = [(state.date, float(state.equity)) for state in curve if state.date >= start_date]
if not points:
return None
start_equity = points[0][1]
peak = start_equity
max_dd_pct = 0.0
for _, equity in points:
peak = max(peak, equity)
if peak > 0:
max_dd_pct = max(max_dd_pct, (peak - equity) / peak * 100.0)
end_equity = points[-1][1]
return {
"start_date": points[0][0].isoformat(),
"end_date": points[-1][0].isoformat(),
"return_pct": (end_equity / start_equity - 1.0) * 100.0 if start_equity else 0.0,
"max_dd_pct": max_dd_pct,
}
def _clear_peer_filters(engine: Any) -> None:
engine.peer_sector_event_count_365d_min = None
engine.peer_sector_event_count_365d_max = None
engine.peer_relative_surprise_pct_365d_min = None
engine.peer_relative_surprise_pct_365d_max = None
engine.peer_relative_sue_hist_mean_4q_365d_min = None
engine.peer_relative_sue_hist_mean_4q_365d_max = None
def _is_idle_earnings_engine(engine: Any) -> bool:
event_types = {str(event_type) for event_type in (engine.event_types or [])}
return bool(getattr(engine, "post_allocation_idle_only", False)) and TARGET_EVENT_TYPE in event_types
def _target_engine_ids(config: Any, scope: str) -> set[str]:
idle_earnings = [engine.engine_id for engine in config.strategy_engines if _is_idle_earnings_engine(engine)]
if scope == "bullish_only":
return {engine_id for engine_id in idle_earnings if engine_id == TARGET_SCOPE_BULLISH_ENGINE_ID}
return set(idle_earnings)
def _apply_variant(config: Any, variant: VariantSpec) -> None:
target_engine_ids = _target_engine_ids(config, variant.scope)
for engine in config.strategy_engines:
_clear_peer_filters(engine)
if engine.engine_id not in target_engine_ids:
continue
if variant.peer_sector_event_count_365d_min is not None:
engine.peer_sector_event_count_365d_min = variant.peer_sector_event_count_365d_min
if variant.peer_relative_surprise_pct_365d_min is not None:
engine.peer_relative_surprise_pct_365d_min = variant.peer_relative_surprise_pct_365d_min
if variant.peer_relative_sue_hist_mean_4q_365d_min is not None:
engine.peer_relative_sue_hist_mean_4q_365d_min = variant.peer_relative_sue_hist_mean_4q_365d_min
def _build_variants() -> list[VariantSpec]:
variants = [VariantSpec(name="baseline", scope="all_idle_earnings")]
# Scope 1: apply to both idle earnings engines.
variants.extend(
[
VariantSpec(name="all_count3", scope="all_idle_earnings", peer_sector_event_count_365d_min=3.0),
VariantSpec(name="all_count4", scope="all_idle_earnings", peer_sector_event_count_365d_min=4.0),
VariantSpec(name="all_surprise8", scope="all_idle_earnings", peer_relative_surprise_pct_365d_min=8.0),
VariantSpec(name="all_surprise10", scope="all_idle_earnings", peer_relative_surprise_pct_365d_min=10.0),
VariantSpec(name="all_surprise12", scope="all_idle_earnings", peer_relative_surprise_pct_365d_min=12.0),
VariantSpec(
name="all_count3_surprise8_sue0",
scope="all_idle_earnings",
peer_sector_event_count_365d_min=3.0,
peer_relative_surprise_pct_365d_min=8.0,
peer_relative_sue_hist_mean_4q_365d_min=0.0,
),
VariantSpec(
name="all_count4_surprise10_sue0",
scope="all_idle_earnings",
peer_sector_event_count_365d_min=4.0,
peer_relative_surprise_pct_365d_min=10.0,
peer_relative_sue_hist_mean_4q_365d_min=0.0,
),
]
)
# Scope 2: only the bullish idle earnings engine.
variants.extend(
[
VariantSpec(name="bullish_count3", scope="bullish_only", peer_sector_event_count_365d_min=3.0),
VariantSpec(name="bullish_count4", scope="bullish_only", peer_sector_event_count_365d_min=4.0),
VariantSpec(name="bullish_surprise8", scope="bullish_only", peer_relative_surprise_pct_365d_min=8.0),
VariantSpec(name="bullish_surprise10", scope="bullish_only", peer_relative_surprise_pct_365d_min=10.0),
VariantSpec(name="bullish_surprise12", scope="bullish_only", peer_relative_surprise_pct_365d_min=12.0),
VariantSpec(
name="bullish_count3_surprise8_sue0",
scope="bullish_only",
peer_sector_event_count_365d_min=3.0,
peer_relative_surprise_pct_365d_min=8.0,
peer_relative_sue_hist_mean_4q_365d_min=0.0,
),
VariantSpec(
name="bullish_count4_surprise10_sue0",
scope="bullish_only",
peer_sector_event_count_365d_min=4.0,
peer_relative_surprise_pct_365d_min=10.0,
peer_relative_sue_hist_mean_4q_365d_min=0.0,
),
]
)
return variants
def _trade_sleeve_summary(trades: list[Any], candidate_map: dict[str, Any]) -> dict[str, Any]:
by_sleeve: Counter[str] = Counter()
by_engine: Counter[str] = Counter()
sleeve_pnl: defaultdict[str, float] = defaultdict(float)
idle_trades = 0
idle_pnl = 0.0
idle_wins = 0
for trade in trades:
sleeve = _resolve_trade_sleeve(trade, candidate_map)
by_sleeve[sleeve] += 1
by_engine[str(trade.engine_id)] += 1
sleeve_pnl[sleeve] += float(trade.net_pnl)
if sleeve == "idle_alpha":
idle_trades += 1
idle_pnl += float(trade.net_pnl)
if float(trade.net_pnl) > 0:
idle_wins += 1
return {
"by_sleeve": dict(sorted(by_sleeve.items(), key=lambda kv: (-kv[1], kv[0]))),
"by_engine": dict(sorted(by_engine.items(), key=lambda kv: (-kv[1], kv[0]))),
"sleeve_pnl": {k: round(v, 2) for k, v in sleeve_pnl.items()},
"idle_alpha_trade_count": idle_trades,
"idle_alpha_net_pnl": round(idle_pnl, 2),
"idle_alpha_win_rate": round(idle_wins / idle_trades, 4) if idle_trades else None,
}
def main() -> None:
parser = argparse.ArgumentParser(description="Probe peer-relative surprise additivity for idle-alpha")
parser.add_argument(
"--config",
default=DEFAULT_CONFIG,
help=f"Experiment manifest JSON path (default: {DEFAULT_CONFIG})",
)
parser.add_argument("--start", required=True, help="Start date (YYYY, YYYY-MM, YYYY-MM-DD)")
parser.add_argument("--end", required=True, help="End date (YYYY, YYYY-MM, YYYY-MM-DD)")
parser.add_argument(
"--period-start",
action="append",
default=[],
help="Sub-period start. Format: label=YYYY-MM-DD (repeatable).",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON only.",
)
parser.add_argument(
"--capital",
type=float,
default=10_000.0,
help="Initial equity for the probe (default: 10000).",
)
args = parser.parse_args()
configure_logging("WARNING")
start_date = _parse_date(args.start)
end_date = _parse_date(args.end, is_end=True)
period_starts = [_parse_period_start(value) for value in args.period_start]
variants = _build_variants()
manifest = load_manifest(args.config)
base_config = resolve_config(manifest)
store = _build_merged_snapshot_store(
manifest,
base_config,
snapshot_dir_override=None,
).slice_by_date_range(start_date, end_date)
rows: list[dict[str, Any]] = []
for variant in variants:
config = resolve_config(manifest)
_apply_variant(config, variant)
runner = BacktestRunner(
manifest=manifest,
config=config,
store=store,
initial_equity=args.capital,
split_name="peer_relative_surprise_probe",
)
result = runner.run(output_root=None)
sleeve_summary = _trade_sleeve_summary(runner._closed_trades, runner._candidate_map)
row: dict[str, Any] = {
"variant": variant.name,
"scope": variant.scope,
"peer_sector_event_count_365d_min": variant.peer_sector_event_count_365d_min,
"peer_relative_surprise_pct_365d_min": variant.peer_relative_surprise_pct_365d_min,
"peer_relative_sue_hist_mean_4q_365d_min": variant.peer_relative_sue_hist_mean_4q_365d_min,
"full_return_pct": round(result.metrics.total_return_pct or 0.0, 2),
"full_dd_pct": round(result.metrics.max_drawdown_pct or 0.0, 2),
"full_sharpe": round(result.metrics.sharpe_ratio or 0.0, 3),
"trade_count": result.metrics.trade_count,
**sleeve_summary,
"last_date": runner._equity_curve[-1].date.isoformat() if runner._equity_curve else None,
}
for label, period_start in period_starts:
stats = _period_stats(runner._equity_curve, period_start)
row[label] = {
"return_pct": round(stats["return_pct"], 2) if stats else None,
"max_dd_pct": round(stats["max_dd_pct"], 2) if stats else None,
"start_date": stats["start_date"] if stats else None,
"end_date": stats["end_date"] if stats else None,
}
rows.append(row)
baseline = next((row for row in rows if row["variant"] == "baseline"), None)
if baseline is not None:
for row in rows:
row["delta_return_vs_baseline"] = round(
row["full_return_pct"] - baseline["full_return_pct"],
2,
)
row["delta_dd_vs_baseline"] = round(
row["full_dd_pct"] - baseline["full_dd_pct"],
2,
)
row["delta_idle_alpha_trade_count_vs_baseline"] = (
row["idle_alpha_trade_count"] - baseline["idle_alpha_trade_count"]
)
row["delta_idle_alpha_net_pnl_vs_baseline"] = round(
row["idle_alpha_net_pnl"] - baseline["idle_alpha_net_pnl"],
2,
)
if args.json:
print(json.dumps(rows, ensure_ascii=False, indent=2))
return
period_labels = [label for label, _ in period_starts]
header = [
"variant",
"scope",
"full_return",
"full_dd",
"full_sharpe",
"trades",
"idle_trades",
"idle_pnl",
"delta_return",
"delta_dd",
"delta_idle_trades",
"delta_idle_pnl",
"last_date",
]
for label in period_labels:
header.extend([f"{label}_return", f"{label}_dd"])
print("\t".join(header))
for row in rows:
fields = [
row["variant"],
row["scope"],
f"{row['full_return_pct']:.2f}",
f"{row['full_dd_pct']:.2f}",
f"{row['full_sharpe']:.3f}",
str(row["trade_count"]),
str(row["idle_alpha_trade_count"]),
f"{row['idle_alpha_net_pnl']:.2f}",
f"{row['delta_return_vs_baseline']:.2f}" if baseline else "-",
f"{row['delta_dd_vs_baseline']:.2f}" if baseline else "-",
str(row["delta_idle_alpha_trade_count_vs_baseline"]) if baseline else "-",
f"{row['delta_idle_alpha_net_pnl_vs_baseline']:.2f}" if baseline else "-",
str(row["last_date"] or "-"),
]
for label in period_labels:
stats = row.get(label) or {}
fields.extend(
[
"-" if stats.get("return_pct") is None else f"{stats['return_pct']:.2f}",
"-" if stats.get("max_dd_pct") is None else f"{stats['max_dd_pct']:.2f}",
]
)
print("\t".join(fields))
if baseline is not None:
best = max(rows, key=lambda row: (row["delta_return_vs_baseline"], -row["delta_dd_vs_baseline"]))
best_additive = [
row
for row in rows
if row["variant"] != "baseline"
and row["delta_return_vs_baseline"] > 0
and row["delta_dd_vs_baseline"] <= 0
]
if best_additive:
best_additive_row = max(
best_additive,
key=lambda row: (row["delta_return_vs_baseline"], -row["delta_dd_vs_baseline"]),
)
print()
print(
"BEST_ADDITIVE",
best_additive_row["variant"],
f"return_delta={best_additive_row['delta_return_vs_baseline']:.2f}",
f"dd_delta={best_additive_row['delta_dd_vs_baseline']:.2f}",
f"idle_delta={best_additive_row['delta_idle_alpha_net_pnl_vs_baseline']:.2f}",
)
else:
print()
print(
"BEST_OVERALL",
best["variant"],
f"return_delta={best['delta_return_vs_baseline']:.2f}",
f"dd_delta={best['delta_dd_vs_baseline']:.2f}",
f"idle_delta={best['delta_idle_alpha_net_pnl_vs_baseline']:.2f}",
)
if __name__ == "__main__":
main()