feat: overhaul strategy — document quality > price momentum
Flip scoring weights so event/document quality is primary signal (55%) and market confirmation is secondary (35%). Add research mode with kill-switch cooldown/reset, veto gates for bad events, reduced portfolio risk, and 4 diagnostic analysis scripts. Phase A: Research mode kill-switch reset, risk reduction (0.5%/trade, max 4 positions), bullish-only direction for all event types. Phase B: 2 new sub-scorers (parse_confidence, direction_clarity), 4 veto gates (oneoff risk, parse confidence, unknown/bearish direction). Phase C: signal_quality, event_type_decomposition, kill_switch_impact, concurrent_position analysis scripts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>main
parent
cdf6ae3493
commit
4d0e773ba0
@ -0,0 +1,169 @@
|
|||||||
|
"""Concurrent Position Analysis: Position clustering and correlation.
|
||||||
|
|
||||||
|
Are simultaneous positions correlated? Analyzes trade blotter to find
|
||||||
|
overlapping position groups and their sector distribution.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m dev.analysis.concurrent_position_analysis \
|
||||||
|
--run-dir runs/run_20260313_xyz [--output-dir ./data/analysis]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import datetime as dt
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pyarrow.parquet as pq
|
||||||
|
|
||||||
|
|
||||||
|
def _load_blotter(run_dir: Path) -> list[dict[str, Any]]:
|
||||||
|
"""Load trade blotter from parquet."""
|
||||||
|
blotter_path = run_dir / "trade_blotter.parquet"
|
||||||
|
if not blotter_path.exists():
|
||||||
|
# Try CSV fallback
|
||||||
|
csv_path = run_dir / "trade_blotter.csv"
|
||||||
|
if csv_path.exists():
|
||||||
|
rows = []
|
||||||
|
with open(csv_path) as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for row in reader:
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
raise FileNotFoundError(f"No trade blotter found in {run_dir}")
|
||||||
|
|
||||||
|
table = pq.read_table(blotter_path)
|
||||||
|
return table.to_pylist()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(val: Any) -> dt.date | None:
|
||||||
|
"""Parse a date value from various formats."""
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, dt.date):
|
||||||
|
return val
|
||||||
|
if isinstance(val, dt.datetime):
|
||||||
|
return val.date()
|
||||||
|
try:
|
||||||
|
return dt.date.fromisoformat(str(val)[:10])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(trades: list[dict[str, Any]], output_dir: Path) -> None:
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Parse entry/exit dates
|
||||||
|
positions: list[dict[str, Any]] = []
|
||||||
|
for t in trades:
|
||||||
|
entry = _parse_date(t.get("entry_date"))
|
||||||
|
exit_ = _parse_date(t.get("exit_date"))
|
||||||
|
if entry and exit_:
|
||||||
|
positions.append({
|
||||||
|
"trade_id": t.get("trade_id", ""),
|
||||||
|
"symbol": t.get("symbol", ""),
|
||||||
|
"sector": t.get("sector", "UNKNOWN"),
|
||||||
|
"entry_date": entry,
|
||||||
|
"exit_date": exit_,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not positions:
|
||||||
|
print("No valid trades found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build daily position timeline
|
||||||
|
all_dates = set()
|
||||||
|
for p in positions:
|
||||||
|
d = p["entry_date"]
|
||||||
|
while d <= p["exit_date"]:
|
||||||
|
all_dates.add(d)
|
||||||
|
d += dt.timedelta(days=1)
|
||||||
|
|
||||||
|
daily_positions: dict[dt.date, list[dict[str, Any]]] = {}
|
||||||
|
for d in sorted(all_dates):
|
||||||
|
active = [p for p in positions if p["entry_date"] <= d <= p["exit_date"]]
|
||||||
|
if active:
|
||||||
|
daily_positions[d] = active
|
||||||
|
|
||||||
|
# Compute concurrent position stats
|
||||||
|
concurrent_counts = [len(v) for v in daily_positions.values()]
|
||||||
|
max_concurrent = max(concurrent_counts) if concurrent_counts else 0
|
||||||
|
avg_concurrent = sum(concurrent_counts) / len(concurrent_counts) if concurrent_counts else 0
|
||||||
|
|
||||||
|
# Find overlap groups (days with >1 position)
|
||||||
|
overlap_days = {d: ps for d, ps in daily_positions.items() if len(ps) > 1}
|
||||||
|
|
||||||
|
# Sector distribution in overlap groups
|
||||||
|
overlap_sector_counts: Counter[str] = Counter()
|
||||||
|
overlap_symbol_counts: Counter[str] = Counter()
|
||||||
|
for d, ps in overlap_days.items():
|
||||||
|
for p in ps:
|
||||||
|
overlap_sector_counts[p["sector"]] += 1
|
||||||
|
overlap_symbol_counts[p["symbol"]] += 1
|
||||||
|
|
||||||
|
# Peak overlap days
|
||||||
|
peak_days = sorted(overlap_days.items(), key=lambda x: len(x[1]), reverse=True)[:10]
|
||||||
|
|
||||||
|
# CSV: daily concurrent position counts
|
||||||
|
csv_rows: list[dict[str, Any]] = []
|
||||||
|
for d in sorted(daily_positions):
|
||||||
|
ps = daily_positions[d]
|
||||||
|
sectors = Counter(p["sector"] for p in ps)
|
||||||
|
csv_rows.append({
|
||||||
|
"date": str(d),
|
||||||
|
"concurrent_positions": len(ps),
|
||||||
|
"unique_sectors": len(sectors),
|
||||||
|
"sectors": "|".join(f"{s}:{c}" for s, c in sectors.most_common()),
|
||||||
|
"symbols": "|".join(p["symbol"] for p in ps),
|
||||||
|
})
|
||||||
|
|
||||||
|
csv_path = output_dir / "concurrent_position_analysis.csv"
|
||||||
|
if csv_rows:
|
||||||
|
fieldnames = list(csv_rows[0].keys())
|
||||||
|
with open(csv_path, "w", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(csv_rows)
|
||||||
|
|
||||||
|
# Console output
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"Concurrent Position Analysis — {len(positions)} trades")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
print(f"\n Total trading days with positions: {len(daily_positions)}")
|
||||||
|
print(f" Days with >1 concurrent position: {len(overlap_days)}")
|
||||||
|
print(f" Max concurrent positions: {max_concurrent}")
|
||||||
|
print(f" Avg concurrent positions: {avg_concurrent:.1f}")
|
||||||
|
|
||||||
|
if overlap_sector_counts:
|
||||||
|
print(f"\n Sector distribution in overlap periods:")
|
||||||
|
for sector, count in overlap_sector_counts.most_common():
|
||||||
|
print(f" {sector:<20} {count:>5} position-days")
|
||||||
|
|
||||||
|
if peak_days:
|
||||||
|
print(f"\n Top 10 peak overlap days:")
|
||||||
|
for d, ps in peak_days:
|
||||||
|
symbols = ", ".join(p["symbol"] for p in ps)
|
||||||
|
sectors = set(p["sector"] for p in ps)
|
||||||
|
print(f" {d} {len(ps)} positions sectors={len(sectors)} [{symbols}]")
|
||||||
|
|
||||||
|
print(f"\nCSV written to: {csv_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Concurrent Position Analysis")
|
||||||
|
parser.add_argument("--run-dir", required=True, help="Path to backtest run directory")
|
||||||
|
parser.add_argument("--output-dir", default="./data/analysis", help="Output directory")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
trades = _load_blotter(Path(args.run_dir))
|
||||||
|
if not trades:
|
||||||
|
print("No trades found in blotter.")
|
||||||
|
return
|
||||||
|
|
||||||
|
analyze(trades, Path(args.output_dir))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
"""Event Type Decomposition: Per-event-type performance analysis.
|
||||||
|
|
||||||
|
Which event types contribute alpha?
|
||||||
|
|
||||||
|
Groups snapshot data by event_type and computes forward return statistics,
|
||||||
|
win rates, and average composite scores per type.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m dev.analysis.event_type_decomposition \
|
||||||
|
--snapshot-dir data/datasets/snapshots/snapshot_2026_03_20 \
|
||||||
|
--split train [--output-dir ./data/analysis]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import statistics
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pyarrow.parquet as pq
|
||||||
|
|
||||||
|
from libs.backtest.scoring import compute_entry_score
|
||||||
|
|
||||||
|
|
||||||
|
def _load_rows(snapshot_dir: Path, split: str) -> list[dict[str, Any]]:
|
||||||
|
parquet_path = snapshot_dir / f"{split}.parquet"
|
||||||
|
table = pq.read_table(parquet_path)
|
||||||
|
return table.to_pylist()
|
||||||
|
|
||||||
|
|
||||||
|
FWD_HORIZONS = ["fwd_return_1d", "fwd_return_3d", "fwd_return_5d", "fwd_return_10d"]
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(rows: list[dict[str, Any]], output_dir: Path) -> None:
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Compute scores
|
||||||
|
for row in rows:
|
||||||
|
row["_composite_score"] = compute_entry_score(row)
|
||||||
|
|
||||||
|
# Group by event_type
|
||||||
|
by_type: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for row in rows:
|
||||||
|
et = row.get("event_type", "unknown")
|
||||||
|
by_type.setdefault(et, []).append(row)
|
||||||
|
|
||||||
|
csv_rows: list[dict[str, Any]] = []
|
||||||
|
for event_type in sorted(by_type):
|
||||||
|
items = by_type[event_type]
|
||||||
|
n = len(items)
|
||||||
|
row_out: dict[str, Any] = {"event_type": event_type, "count": n}
|
||||||
|
|
||||||
|
# Average composite score
|
||||||
|
scores = [r["_composite_score"] for r in items]
|
||||||
|
row_out["avg_score"] = statistics.mean(scores) if scores else None
|
||||||
|
|
||||||
|
for horizon in FWD_HORIZONS:
|
||||||
|
vals = [float(r[horizon]) for r in items if r.get(horizon) is not None]
|
||||||
|
if vals:
|
||||||
|
row_out[f"{horizon}_mean"] = statistics.mean(vals)
|
||||||
|
row_out[f"{horizon}_win_rate"] = sum(1 for v in vals if v > 0) / len(vals)
|
||||||
|
row_out[f"{horizon}_std"] = statistics.stdev(vals) if len(vals) > 1 else 0.0
|
||||||
|
else:
|
||||||
|
row_out[f"{horizon}_mean"] = None
|
||||||
|
row_out[f"{horizon}_win_rate"] = None
|
||||||
|
row_out[f"{horizon}_std"] = None
|
||||||
|
|
||||||
|
csv_rows.append(row_out)
|
||||||
|
|
||||||
|
# Write CSV
|
||||||
|
csv_path = output_dir / "event_type_decomposition.csv"
|
||||||
|
if csv_rows:
|
||||||
|
fieldnames = list(csv_rows[0].keys())
|
||||||
|
with open(csv_path, "w", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(csv_rows)
|
||||||
|
|
||||||
|
# Console output
|
||||||
|
print(f"\n{'='*90}")
|
||||||
|
print(f"Event Type Decomposition — {len(rows)} events, {len(by_type)} types")
|
||||||
|
print(f"{'='*90}")
|
||||||
|
print(f"\n{'Type':<22} {'N':>5} {'AvgScore':>8}", end="")
|
||||||
|
for h in FWD_HORIZONS:
|
||||||
|
label = h.replace("fwd_return_", "")
|
||||||
|
print(f" {label+'_mean':>9} {label+'_wr':>7}", end="")
|
||||||
|
print()
|
||||||
|
print("-" * 90)
|
||||||
|
|
||||||
|
for row_out in csv_rows:
|
||||||
|
avg_s = row_out["avg_score"]
|
||||||
|
print(f"{row_out['event_type']:<22} {row_out['count']:>5} {avg_s:.3f}" if avg_s else
|
||||||
|
f"{row_out['event_type']:<22} {row_out['count']:>5} N/A", end="")
|
||||||
|
for h in FWD_HORIZONS:
|
||||||
|
mean_val = row_out[f"{h}_mean"]
|
||||||
|
wr_val = row_out[f"{h}_win_rate"]
|
||||||
|
mean_str = f"{mean_val:+.4f}" if mean_val is not None else " N/A"
|
||||||
|
wr_str = f"{wr_val:.1%}" if wr_val is not None else " N/A"
|
||||||
|
print(f" {mean_str:>9} {wr_str:>7}", end="")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(f"\nCSV written to: {csv_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Event Type Decomposition Analysis")
|
||||||
|
parser.add_argument("--snapshot-dir", required=True, help="Path to snapshot directory")
|
||||||
|
parser.add_argument("--split", default="train", help="Split name (train/valid/test)")
|
||||||
|
parser.add_argument("--output-dir", default="./data/analysis", help="Output directory")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
rows = _load_rows(Path(args.snapshot_dir), args.split)
|
||||||
|
if not rows:
|
||||||
|
print("No data found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
analyze(rows, Path(args.output_dir))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
"""Kill Switch Impact Analysis: Compare live vs research mode.
|
||||||
|
|
||||||
|
How much does the permanent kill switch hide?
|
||||||
|
|
||||||
|
Runs the backtest twice (live mode and research mode) and compares
|
||||||
|
key metrics side-by-side.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m dev.analysis.kill_switch_impact_analysis \
|
||||||
|
--manifest configs/experiments/phase5_v1.json \
|
||||||
|
--snapshot-dir data/datasets/snapshots \
|
||||||
|
--split train [--output-root ./runs]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from libs.backtest.manifests import load_manifest, resolve_config
|
||||||
|
from libs.backtest.domain import MetricsBundle
|
||||||
|
|
||||||
|
|
||||||
|
def _run_backtest(
|
||||||
|
manifest_path: str,
|
||||||
|
snapshot_dir: str | None,
|
||||||
|
split: str,
|
||||||
|
mode: str,
|
||||||
|
output_root: str,
|
||||||
|
initial_equity: float,
|
||||||
|
config_root: str,
|
||||||
|
) -> tuple[str, MetricsBundle, int]:
|
||||||
|
"""Run a single backtest and return (run_id, metrics, trade_count)."""
|
||||||
|
from apps.backtester.run import BacktestRunner, _build_store
|
||||||
|
|
||||||
|
manifest = load_manifest(manifest_path)
|
||||||
|
config = resolve_config(manifest, config_root=config_root)
|
||||||
|
config.risk.backtest_mode = mode
|
||||||
|
|
||||||
|
store = _build_store(manifest, config, split, snapshot_dir_override=snapshot_dir)
|
||||||
|
runner = BacktestRunner(
|
||||||
|
manifest=manifest,
|
||||||
|
config=config,
|
||||||
|
store=store,
|
||||||
|
initial_equity=initial_equity,
|
||||||
|
)
|
||||||
|
result = runner.run(output_root=output_root)
|
||||||
|
return result.run_id, result.metrics, result.metrics.trade_count
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(val: float | None, fmt: str = ".2f") -> str:
|
||||||
|
if val is None:
|
||||||
|
return "N/A"
|
||||||
|
return f"{val:{fmt}}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_pct(val: float | None) -> str:
|
||||||
|
if val is None:
|
||||||
|
return "N/A"
|
||||||
|
return f"{val:.2f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Kill Switch Impact Analysis")
|
||||||
|
parser.add_argument("--manifest", required=True, help="Path to experiment manifest JSON")
|
||||||
|
parser.add_argument("--snapshot-dir", help="Override snapshot root directory")
|
||||||
|
parser.add_argument("--split", default="train", help="Split name (train/valid/test)")
|
||||||
|
parser.add_argument("--output-root", default="./runs", help="Output root directory")
|
||||||
|
parser.add_argument("--initial-equity", type=float, default=100_000.0)
|
||||||
|
parser.add_argument("--config-root", default=".", help="Root dir for resolving config paths")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print("Running LIVE mode backtest...")
|
||||||
|
live_id, live_m, live_trades = _run_backtest(
|
||||||
|
args.manifest, args.snapshot_dir, args.split, "live",
|
||||||
|
args.output_root, args.initial_equity, args.config_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Running RESEARCH mode backtest...")
|
||||||
|
research_id, research_m, research_trades = _run_backtest(
|
||||||
|
args.manifest, args.snapshot_dir, args.split, "research",
|
||||||
|
args.output_root, args.initial_equity, args.config_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Side-by-side comparison
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Kill Switch Impact Analysis — split={args.split}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f"\n{'Metric':<28} {'Live':>14} {'Research':>14}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
("Run ID", live_id[:16], research_id[:16]),
|
||||||
|
("Total Trades", str(live_trades), str(research_trades)),
|
||||||
|
("Win Rate", _fmt_pct(live_m.win_rate and live_m.win_rate * 100),
|
||||||
|
_fmt_pct(research_m.win_rate and research_m.win_rate * 100)),
|
||||||
|
("Total Return", _fmt_pct(live_m.total_return_pct),
|
||||||
|
_fmt_pct(research_m.total_return_pct)),
|
||||||
|
("Max Drawdown", _fmt_pct(live_m.max_drawdown_pct),
|
||||||
|
_fmt_pct(research_m.max_drawdown_pct)),
|
||||||
|
("Sharpe Ratio", _fmt(live_m.sharpe_ratio), _fmt(research_m.sharpe_ratio)),
|
||||||
|
("Sortino Ratio", _fmt(live_m.sortino_ratio), _fmt(research_m.sortino_ratio)),
|
||||||
|
("Profit Factor", _fmt(live_m.profit_factor), _fmt(research_m.profit_factor)),
|
||||||
|
("Expectancy (R)", _fmt(live_m.expectancy_r), _fmt(research_m.expectancy_r)),
|
||||||
|
("Avg Holding Days", _fmt(live_m.avg_holding_days, ".1f"),
|
||||||
|
_fmt(research_m.avg_holding_days, ".1f")),
|
||||||
|
("Avg Positions Held", _fmt(live_m.avg_positions_held, ".1f"),
|
||||||
|
_fmt(research_m.avg_positions_held, ".1f")),
|
||||||
|
]
|
||||||
|
|
||||||
|
for label, live_val, research_val in rows:
|
||||||
|
print(f"{label:<28} {live_val:>14} {research_val:>14}")
|
||||||
|
|
||||||
|
# Delta summary
|
||||||
|
if live_m.total_return_pct is not None and research_m.total_return_pct is not None:
|
||||||
|
delta_return = research_m.total_return_pct - live_m.total_return_pct
|
||||||
|
delta_trades = research_trades - live_trades
|
||||||
|
print(f"\n Delta trades: {delta_trades:+d}")
|
||||||
|
print(f" Delta return: {delta_return:+.2f}%")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
"""Signal Quality Analysis: Score-bucket monotonicity check.
|
||||||
|
|
||||||
|
Does higher composite score predict better forward returns?
|
||||||
|
|
||||||
|
Reads snapshot parquet, computes compute_entry_score for each row,
|
||||||
|
buckets into quintiles, and checks Spearman rank correlation between
|
||||||
|
score bucket and mean forward return.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m dev.analysis.signal_quality_analysis \
|
||||||
|
--snapshot-dir data/datasets/snapshots/snapshot_2026_03_20 \
|
||||||
|
--split train [--output-dir ./data/analysis]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import statistics
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pyarrow.parquet as pq
|
||||||
|
|
||||||
|
from libs.backtest.scoring import compute_entry_score
|
||||||
|
|
||||||
|
|
||||||
|
def _load_rows(snapshot_dir: Path, split: str) -> list[dict[str, Any]]:
|
||||||
|
"""Load parquet snapshot into list of dicts."""
|
||||||
|
parquet_path = snapshot_dir / f"{split}.parquet"
|
||||||
|
table = pq.read_table(parquet_path)
|
||||||
|
return table.to_pylist()
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_label(score: float) -> str:
|
||||||
|
"""Assign score to a quintile bucket."""
|
||||||
|
if score < 0.2:
|
||||||
|
return "0.0-0.2"
|
||||||
|
if score < 0.4:
|
||||||
|
return "0.2-0.4"
|
||||||
|
if score < 0.6:
|
||||||
|
return "0.4-0.6"
|
||||||
|
if score < 0.8:
|
||||||
|
return "0.6-0.8"
|
||||||
|
return "0.8-1.0"
|
||||||
|
|
||||||
|
|
||||||
|
BUCKET_ORDER = ["0.0-0.2", "0.2-0.4", "0.4-0.6", "0.6-0.8", "0.8-1.0"]
|
||||||
|
FWD_HORIZONS = ["fwd_return_1d", "fwd_return_3d", "fwd_return_5d", "fwd_return_10d"]
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(rows: list[dict[str, Any]], output_dir: Path) -> None:
|
||||||
|
"""Score all rows, bucket, compute statistics, write output."""
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Score each row and assign bucket
|
||||||
|
scored: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
score = compute_entry_score(row)
|
||||||
|
bucket = _bucket_label(score)
|
||||||
|
scored.append({"score": score, "bucket": bucket, **row})
|
||||||
|
|
||||||
|
# Group by bucket
|
||||||
|
by_bucket: dict[str, list[dict[str, Any]]] = {b: [] for b in BUCKET_ORDER}
|
||||||
|
for s in scored:
|
||||||
|
by_bucket[s["bucket"]].append(s)
|
||||||
|
|
||||||
|
# Compute per-bucket stats
|
||||||
|
csv_rows: list[dict[str, Any]] = []
|
||||||
|
for bucket in BUCKET_ORDER:
|
||||||
|
items = by_bucket[bucket]
|
||||||
|
n = len(items)
|
||||||
|
row_out: dict[str, Any] = {"bucket": bucket, "count": n}
|
||||||
|
|
||||||
|
for horizon in FWD_HORIZONS:
|
||||||
|
vals = [float(r[horizon]) for r in items if r.get(horizon) is not None]
|
||||||
|
if vals:
|
||||||
|
row_out[f"{horizon}_mean"] = statistics.mean(vals)
|
||||||
|
row_out[f"{horizon}_win_rate"] = sum(1 for v in vals if v > 0) / len(vals)
|
||||||
|
else:
|
||||||
|
row_out[f"{horizon}_mean"] = None
|
||||||
|
row_out[f"{horizon}_win_rate"] = None
|
||||||
|
|
||||||
|
csv_rows.append(row_out)
|
||||||
|
|
||||||
|
# Spearman rank correlation (bucket rank vs mean return)
|
||||||
|
spearman_results: dict[str, float | None] = {}
|
||||||
|
for horizon in FWD_HORIZONS:
|
||||||
|
means = [r[f"{horizon}_mean"] for r in csv_rows if r[f"{horizon}_mean"] is not None]
|
||||||
|
if len(means) >= 3:
|
||||||
|
# Rank correlation: do the means increase with bucket rank?
|
||||||
|
ranks_score = list(range(len(means)))
|
||||||
|
ranks_return = _rank(means)
|
||||||
|
spearman_results[horizon] = _spearman(ranks_score, ranks_return)
|
||||||
|
else:
|
||||||
|
spearman_results[horizon] = None
|
||||||
|
|
||||||
|
# Write CSV
|
||||||
|
csv_path = output_dir / "signal_quality_analysis.csv"
|
||||||
|
if csv_rows:
|
||||||
|
fieldnames = list(csv_rows[0].keys())
|
||||||
|
with open(csv_path, "w", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(csv_rows)
|
||||||
|
|
||||||
|
# Console output
|
||||||
|
print(f"\n{'='*80}")
|
||||||
|
print(f"Signal Quality Analysis — {len(scored)} events")
|
||||||
|
print(f"{'='*80}")
|
||||||
|
print(f"\n{'Bucket':<12} {'Count':>6}", end="")
|
||||||
|
for h in FWD_HORIZONS:
|
||||||
|
label = h.replace("fwd_return_", "")
|
||||||
|
print(f" {label+'_mean':>10} {label+'_wr':>8}", end="")
|
||||||
|
print()
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
for row_out in csv_rows:
|
||||||
|
print(f"{row_out['bucket']:<12} {row_out['count']:>6}", end="")
|
||||||
|
for h in FWD_HORIZONS:
|
||||||
|
mean_val = row_out[f"{h}_mean"]
|
||||||
|
wr_val = row_out[f"{h}_win_rate"]
|
||||||
|
mean_str = f"{mean_val:+.4f}" if mean_val is not None else " N/A"
|
||||||
|
wr_str = f"{wr_val:.1%}" if wr_val is not None else " N/A"
|
||||||
|
print(f" {mean_str:>10} {wr_str:>8}", end="")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(f"\nSpearman rank correlation (bucket vs mean return):")
|
||||||
|
for h in FWD_HORIZONS:
|
||||||
|
label = h.replace("fwd_return_", "")
|
||||||
|
val = spearman_results.get(h)
|
||||||
|
print(f" {label}: {val:+.3f}" if val is not None else f" {label}: N/A")
|
||||||
|
|
||||||
|
print(f"\nCSV written to: {csv_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _rank(values: list[float]) -> list[float]:
|
||||||
|
"""Compute ranks (0-indexed) for a list of values."""
|
||||||
|
indexed = sorted(enumerate(values), key=lambda x: x[1])
|
||||||
|
ranks = [0.0] * len(values)
|
||||||
|
for rank, (idx, _) in enumerate(indexed):
|
||||||
|
ranks[idx] = float(rank)
|
||||||
|
return ranks
|
||||||
|
|
||||||
|
|
||||||
|
def _spearman(x: list[float], y: list[float]) -> float:
|
||||||
|
"""Compute Spearman rank correlation coefficient."""
|
||||||
|
n = len(x)
|
||||||
|
if n < 2:
|
||||||
|
return 0.0
|
||||||
|
d_sq = sum((xi - yi) ** 2 for xi, yi in zip(x, y))
|
||||||
|
return 1 - (6 * d_sq) / (n * (n**2 - 1))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Signal Quality Analysis")
|
||||||
|
parser.add_argument("--snapshot-dir", required=True, help="Path to snapshot directory")
|
||||||
|
parser.add_argument("--split", default="train", help="Split name (train/valid/test)")
|
||||||
|
parser.add_argument("--output-dir", default="./data/analysis", help="Output directory")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
rows = _load_rows(Path(args.snapshot_dir), args.split)
|
||||||
|
if not rows:
|
||||||
|
print("No data found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
analyze(rows, Path(args.output_dir))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue