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.
171 lines
5.7 KiB
Python
171 lines
5.7 KiB
Python
"""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()
|