Files
chinese-equity-quant/scripts/generate_reversal_5d_report.py
2026-06-12 22:58:22 +08:00

1157 lines
45 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Generate the end-to-end 5-day reversal pipeline report.
Covers three runs of the same 5-day reversal *signal* under this repo's
"alpha = signed position weight" convention (no IC/IR):
naive_full : reversal (z-score weighting), full ~5k all-universe
rank_full : reversal_rank (rank weighting), full ~5k all-universe
rank_liquid : reversal_rank (rank weighting), per-date liquid subset
For each run it checks artifact storage, recomputes no-lookahead open-to-open research
metrics, measures how close the constructed portfolio is to the alpha and how
close the simulated net PnL is to the alpha, and renders a markdown report plus
PNG visualizations under docs/.
"""
from __future__ import annotations
import json
import os
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib")
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from pipeline.common.schema import ALPHA_COLUMNS, COMBO_COLUMNS, POSITION_COLUMNS
BOOKSIZE = 10_000_000.0
DATA_PATH = ROOT / "data/daily_bars/all"
ASSET_DIR = ROOT / "docs/assets"
REPORT_PATH = ROOT / "docs/reversal_5d_all_universe_pipeline_report.md"
DIAGNOSTICS_PATH = ROOT / "reports/reversal_5d_report_diagnostics.json"
TIMINGS_PATH = ROOT / "reports/reversal_rank_timings.json"
COST_BPS = 5.0
SLIPPAGE_BPS = 5.0
@dataclass
class Run:
key: str
label: str
weighting: str
universe: str
alpha: Path
combo: Path
positions: Path
fills: Path
pnl: Path
RUNS = [
Run(
"naive_full", "naive z-score (full)", "z-score", "all ~5,200",
ROOT / "alphas/reversal_5d_all.pq",
ROOT / "combos/reversal_5d_all_combo.pq",
ROOT / "portfolio/reversal_5d_all_10m.pq",
ROOT / "portfolio/fills/reversal_5d_all_10m.pq",
ROOT / "portfolio/pnl/reversal_5d_all_10m.pq",
),
Run(
"rank_full", "rank (full)", "rank", "all ~5,200",
ROOT / "alphas/reversal_rank_all.pq",
ROOT / "combos/reversal_rank_all_combo.pq",
ROOT / "portfolio/reversal_rank_all_10m.pq",
ROOT / "portfolio/fills/reversal_rank_all_10m.pq",
ROOT / "portfolio/pnl/reversal_rank_all_10m.pq",
),
Run(
"rank_liquid", "rank (liquid subset)", "rank", "top-1000 liquid, non-ST, tradable",
ROOT / "alphas/reversal_rank_liq.pq",
ROOT / "combos/reversal_rank_liq_combo.pq",
ROOT / "portfolio/reversal_rank_liq_10m.pq",
ROOT / "portfolio/fills/reversal_rank_liq_10m.pq",
ROOT / "portfolio/pnl/reversal_rank_liq_10m.pq",
),
]
# Match the e2e script's json keys to phase labels for the timing table.
TIMING_PHASES = [
("alpha compute", "alpha_compute"),
("alpha eval", "alpha_eval"),
("combo combine", "combo"),
("portfolio build", "portfolio_build"),
("portfolio eval", "portfolio_eval"),
("portfolio simulate", "portfolio_simulate"),
]
# ---------- formatting helpers ----------
def _pct(x: float) -> str:
return f"{x:.2%}"
def _num(x: float) -> str:
return f"{x:,.4f}"
def _money(x: float) -> str:
return f"{x:,.0f}"
def _date(x) -> str:
return pd.Timestamp(x).strftime("%Y-%m-%d")
def _md_table(headers: list[str], rows: list[list[str]]) -> str:
header = "| " + " | ".join(headers) + " |"
sep = "| " + " | ".join(["---"] * len(headers)) + " |"
body = "\n".join("| " + " | ".join(r) + " |" for r in rows)
return "\n".join([header, sep, body])
# ---------- metric helpers ----------
def _return_metrics(daily: pd.Series) -> dict:
daily = daily.dropna()
if len(daily) < 2:
return {"cumulative_return": 0.0, "sharpe_annual": 0.0,
"max_drawdown": 0.0, "hit_rate": 0.0, "n_dates": int(len(daily))}
equity = (1.0 + daily).cumprod()
dd = (equity - equity.cummax()) / equity.cummax()
sigma = daily.std()
return {
"cumulative_return": float(equity.iloc[-1] - 1.0),
"sharpe_annual": float(np.sqrt(252) * daily.mean() / sigma) if sigma > 0 else 0.0,
"max_drawdown": float(dd.min()),
"hit_rate": float((daily > 0).mean()),
"n_dates": int(len(daily)),
}
def _additive_metrics(daily: pd.Series) -> dict:
"""For PnL-fraction series, which are additive in cash terms."""
daily = daily.dropna()
if len(daily) < 2:
return {"cumulative_return": 0.0, "sharpe_annual": 0.0,
"max_drawdown": 0.0, "hit_rate": 0.0, "n_dates": int(len(daily))}
equity = 1.0 + daily.cumsum()
dd = (equity - equity.cummax()) / equity.cummax()
sigma = daily.std()
return {
"cumulative_return": float(daily.sum()),
"sharpe_annual": float(np.sqrt(252) * daily.mean() / sigma) if sigma > 0 else 0.0,
"max_drawdown": float(dd.min()),
"hit_rate": float((daily > 0).mean()),
"n_dates": int(len(daily)),
}
def _research_returns(weights: pd.DataFrame, fwd: pd.DataFrame) -> pd.Series:
"""w_t · r_open[t+1→t+2] / sum|w_t| on the signal calendar."""
w = weights
f = fwd.reindex(index=w.index, columns=w.columns)
gross = w.abs().sum(axis=1)
daily = (w * f).sum(axis=1, min_count=1) / gross.replace(0.0, np.nan)
return daily.dropna()
def _research_turnover(weights: pd.DataFrame) -> float:
gross = weights.abs().sum(axis=1)
to = weights.diff().abs().sum(axis=1) / gross.shift(1).replace(0.0, np.nan)
return float(to.dropna().mean() * 252)
def _to_exec_date(series: pd.Series, data_dates: pd.DatetimeIndex) -> pd.Series:
"""Shift a signal-date series to its execution date (next data date)."""
pos = {d: i for i, d in enumerate(data_dates)}
out_idx, out_val = [], []
for d, v in series.items():
i = pos.get(d)
if i is None or i + 1 >= len(data_dates):
continue
out_idx.append(data_dates[i + 1])
out_val.append(v)
return pd.Series(out_val, index=pd.DatetimeIndex(out_idx), name=series.name)
# ---------- per-run analysis ----------
def analyze_run(run: Run, close: pd.DataFrame, fwd: pd.DataFrame,
data_dates: pd.DatetimeIndex) -> dict | None:
if not run.alpha.exists():
print(f" [skip] {run.key}: missing {run.alpha}")
return None
alpha = pd.read_parquet(run.alpha)
alpha["date"] = pd.to_datetime(alpha["date"])
storage = {
"columns_ok": list(alpha.columns) == ALPHA_COLUMNS,
"rows": int(len(alpha)),
"symbols": int(alpha["symbol_id"].nunique()),
"dates": int(alpha["date"].nunique()),
"start": _date(alpha["date"].min()),
"end": _date(alpha["date"].max()),
"null_weights": int(alpha["weight"].isna().sum()),
"nonfinite_weights": int((~np.isfinite(alpha["weight"])).sum()),
"dup_keys": int(alpha.duplicated(["symbol_id", "date"]).sum()),
"max_abs_daily_mean": float(alpha.groupby("date")["weight"].mean().abs().max()),
"weight_min": float(alpha["weight"].min()),
"weight_max": float(alpha["weight"].max()),
"weight_p01": float(alpha["weight"].quantile(0.01)),
"weight_p99": float(alpha["weight"].quantile(0.99)),
}
aw = alpha.pivot_table(index="date", columns="symbol_id", values="weight",
aggfunc="first").sort_index()
alpha_daily = _research_returns(aw, fwd)
alpha_metrics = _return_metrics(alpha_daily)
alpha_metrics["turnover_annual"] = _research_turnover(aw)
alpha_exec = _to_exec_date(alpha_daily.rename("alpha"), data_dates)
# combo identity check
combo_info = {"exists": run.combo.exists()}
if run.combo.exists():
combo = pd.read_parquet(run.combo)
combo["date"] = pd.to_datetime(combo["date"])
same_keys = alpha[["symbol_id", "date"]].reset_index(drop=True).equals(
combo[["symbol_id", "date"]].reset_index(drop=True))
if same_keys:
diff = float(np.max(np.abs(
alpha["weight"].to_numpy() - combo["weight"].to_numpy())))
else:
j = alpha[["symbol_id", "date", "weight"]].merge(
combo[["symbol_id", "date", "weight"]], on=["symbol_id", "date"],
how="outer", suffixes=("_a", "_c"))
diff = float((j["weight_a"] - j["weight_c"]).abs().max())
combo_info.update({
"columns_ok": list(combo.columns) == COMBO_COLUMNS,
"max_abs_weight_diff": diff,
})
# positions / portfolio closeness
pos_info: dict = {"exists": run.positions.exists()}
portfolio_metrics = None
portfolio_exec = None
per_date = None
if run.positions.exists():
positions = pd.read_parquet(run.positions)
positions["date"] = pd.to_datetime(positions["date"])
per_date = positions.groupby("date").agg(
target_gross=("target_value", lambda s: s.abs().sum()),
position_gross=("position_value", lambda s: s.abs().sum()),
position_net=("position_value", "sum"),
)
per_date["l1_tracking"] = (
positions.assign(d=(positions["position_value"] - positions["target_value"]).abs())
.groupby("date")["d"].sum()
)
# target_weight -> target_value identity
tv_diff = float((positions["target_value"]
- positions["target_weight"] * BOOKSIZE).abs().max())
# alpha-normalized weight vs target_weight
alpha_gross = alpha.groupby("date")["weight"].apply(lambda s: s.abs().sum())
valid = positions["date"] < pd.Timestamp(_date(close.index.max()))
tj = positions.loc[valid, ["symbol_id", "date", "target_weight"]].merge(
alpha[["symbol_id", "date", "weight"]], on=["symbol_id", "date"], how="left")
expected = tj["weight"] / tj["date"].map(alpha_gross)
adiff = (tj["target_weight"] - expected).abs()
pos_info.update({
"columns_ok": list(positions.columns) == POSITION_COLUMNS,
"rows": int(len(positions)),
"dates": int(positions["date"].nunique()),
"target_value_identity_max_abs": tv_diff,
"alpha_to_target_mean_abs": float(adiff.mean()),
"alpha_to_target_max_abs": float(adiff.max()),
"target_gross_mean": float(per_date["target_gross"].mean()),
"position_gross_mean": float(per_date["position_gross"].mean()),
"l1_tracking_mean": float(per_date["l1_tracking"].mean()),
"l1_tracking_p95": float(per_date["l1_tracking"].quantile(0.95)),
})
pw = positions.pivot_table(index="date", columns="symbol_id",
values="target_weight", aggfunc="first").sort_index()
portfolio_daily = _research_returns(pw, fwd)
portfolio_metrics = _return_metrics(portfolio_daily)
portfolio_metrics["turnover_annual"] = _research_turnover(pw)
portfolio_exec = _to_exec_date(portfolio_daily.rename("portfolio"), data_dates)
ra = pd.concat([alpha_daily, portfolio_daily], axis=1,
keys=["a", "p"]).dropna()
pos_info["research_corr_to_alpha"] = float(ra["a"].corr(ra["p"])) if len(ra) > 2 else 0.0
pos_info["research_mean_abs_diff_to_alpha"] = float((ra["a"] - ra["p"]).abs().mean())
# execution / pnl
exec_info: dict = {"exists": run.pnl.exists()}
if run.pnl.exists():
pnl = pd.read_parquet(run.pnl)
pnl["date"] = pd.to_datetime(pnl["date"])
net = (pnl.set_index("date")["pnl"] / BOOKSIZE)
before = ((pnl.set_index("date")["pnl"] + pnl.set_index("date")["cost"]) / BOOKSIZE)
exec_info["net"] = _additive_metrics(net)
exec_info["before_cost"] = _additive_metrics(before)
exec_info["total_pnl"] = float(pnl["pnl"].sum())
exec_info["total_cost"] = float(pnl["cost"].sum())
exec_info["total_pnl_before_cost"] = float((pnl["pnl"] + pnl["cost"]).sum())
exec_info["mean_daily_turnover"] = float(pnl["turnover"].mean())
if run.fills.exists():
fills = pd.read_parquet(run.fills, columns=["blocked", "trade_cost"])
exec_info["blocked_flags"] = int(fills["blocked"].sum())
exec_info["fill_cost_matches_pnl"] = bool(
abs(float(fills["trade_cost"].sum()) - exec_info["total_cost"]) < 1.0)
# alpha vs execution-net closeness on execution calendar
ea = pd.concat([alpha_exec, net.rename("net")], axis=1).dropna()
exec_info["alpha_vs_net_corr"] = float(ea["alpha"].corr(ea["net"])) if len(ea) > 2 else 0.0
exec_info["alpha_vs_net_mean_abs_diff"] = float((ea["alpha"] - ea["net"]).abs().mean())
exec_info["net_series"] = net # kept for plotting; stripped before json
return {
"run": run,
"storage": storage,
"combo": combo_info,
"positions": pos_info,
"execution": exec_info,
"alpha_metrics": alpha_metrics,
"portfolio_metrics": portfolio_metrics,
"alpha_daily": alpha_daily,
"per_date": per_date,
"alpha_weights_sample": alpha["weight"].sample(
min(200_000, len(alpha)), random_state=7).to_numpy(),
}
# ---------- plots ----------
def plot_weight_distributions(results: dict) -> Path:
path = ASSET_DIR / "reversal_5d_weight_distributions.png"
keys = [k for k in ("naive_full", "rank_full", "rank_liquid") if k in results]
fig, axes = plt.subplots(1, len(keys), figsize=(5 * len(keys), 4))
if len(keys) == 1:
axes = [axes]
for ax, k in zip(axes, keys):
r = results[k]
s = r["alpha_weights_sample"]
ax.hist(s, bins=120, color="#4c78a8", alpha=0.85)
ax.set_title(f"{r['run'].label}\nstored weights "
f"[{r['storage']['weight_min']:.1f}, {r['storage']['weight_max']:.1f}]")
ax.set_xlabel("weight")
ax.grid(True, axis="y", alpha=0.25)
fig.suptitle("Stored alpha weight distributions (same signal, different weighting)")
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
return path
def plot_research_equity(results: dict) -> Path:
path = ASSET_DIR / "reversal_5d_research_equity.png"
fig, ax = plt.subplots(figsize=(11, 6))
for k in ("naive_full", "rank_full", "rank_liquid"):
if k not in results:
continue
d = results[k]["alpha_daily"]
curve = (1.0 + d).cumprod()
ax.plot(curve.index, curve.values, linewidth=1.5,
label=f"{results[k]['run'].label} "
f"(Sharpe {results[k]['alpha_metrics']['sharpe_annual']:.2f})")
ax.axhline(1.0, color="#666", linewidth=0.8)
ax.set_yscale("log")
ax.set_title("Costless next-open-to-next-open alpha research equity (log scale)")
ax.set_ylabel("growth of 1.0")
ax.grid(True, which="both", alpha=0.25)
ax.legend(loc="best")
fig.autofmt_xdate()
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
return path
def plot_exec_vs_research(results: dict) -> Path:
path = ASSET_DIR / "reversal_5d_exec_vs_research.png"
keys = [k for k in ("rank_full", "rank_liquid", "naive_full")
if k in results and results[k]["execution"].get("exists")]
fig, axes = plt.subplots(1, len(keys), figsize=(5.5 * len(keys), 4.5), sharey=False)
if len(keys) == 1:
axes = [axes]
for ax, k in zip(axes, keys):
r = results[k]
d = r["alpha_daily"]
research = (1.0 + d).cumprod()
ax.plot(research.index, research.values, label="research (costless)",
color="#4c78a8", linewidth=1.4)
net = r["execution"]["net_series"]
exec_net_curve = 1.0 + net.cumsum()
ax.plot(exec_net_curve.index, exec_net_curve.values,
label="execution net (after cost)", color="#e45756", linewidth=1.4)
ax.axhline(1.0, color="#666", linewidth=0.8)
ax.set_title(r["run"].label)
ax.grid(True, alpha=0.25)
ax.legend(loc="best", fontsize=8)
fig.suptitle("Research vs simulated net execution (booksize-normalized)")
fig.autofmt_xdate()
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
return path
def plot_tracking(results: dict) -> Path | None:
key = "rank_liquid" if "rank_liquid" in results else next(iter(results), None)
if key is None or results[key]["per_date"] is None:
return None
path = ASSET_DIR / "reversal_5d_portfolio_tracking.png"
per_date = results[key]["per_date"]
monthly = per_date.resample("ME").mean(numeric_only=True)
fig, axes = plt.subplots(2, 1, figsize=(11, 7), sharex=True)
axes[0].plot(monthly.index, monthly["target_gross"] / BOOKSIZE, label="target gross")
axes[0].plot(monthly.index, monthly["position_gross"] / BOOKSIZE, label="integer book gross")
axes[0].set_ylabel("gross / booksize")
axes[0].grid(True, alpha=0.25)
axes[0].legend(loc="best")
axes[0].set_title(f"Integer-book tracking — {results[key]['run'].label}")
axes[1].plot(monthly.index, monthly["l1_tracking"] / BOOKSIZE, color="#f58518")
axes[1].set_ylabel("L1 tracking / booksize")
axes[1].grid(True, alpha=0.25)
fig.autofmt_xdate()
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
return path
def plot_timings(timings: dict) -> Path | None:
if not timings:
return None
path = ASSET_DIR / "reversal_5d_phase_timings.png"
phases = [lbl for lbl, _ in TIMING_PHASES]
full = [timings.get(f"full_{k}", 0.0) for _, k in TIMING_PHASES]
liq = [timings.get(f"liq_{k}", 0.0) for _, k in TIMING_PHASES]
x = np.arange(len(phases))
w = 0.38
fig, ax = plt.subplots(figsize=(11, 5))
ax.bar(x - w / 2, full, w, label="rank full", color="#4c78a8")
ax.bar(x + w / 2, liq, w, label="rank liquid", color="#54a24b")
ax.set_xticks(x)
ax.set_xticklabels(phases, rotation=20)
ax.set_ylabel("seconds")
ax.set_title("Pipeline wall-clock time by phase (reversal_rank runs)")
ax.grid(True, axis="y", alpha=0.25)
ax.legend(loc="best")
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
return path
# ---------- report ----------
def _strip_series(results: dict) -> dict:
out = {}
for k, r in results.items():
rr = {kk: vv for kk, vv in r.items()
if kk not in ("alpha_daily", "per_date", "alpha_weights_sample", "run")}
rr = json.loads(json.dumps(rr, default=lambda o: None))
ex = r["execution"]
if "net_series" in ex:
rr["execution"] = {kk: vv for kk, vv in ex.items() if kk != "net_series"}
rr["execution"] = json.loads(json.dumps(rr["execution"], default=lambda o: None))
rr["run_label"] = r["run"].label
out[k] = rr
return out
def render_report(results: dict, data_summary: dict, timings: dict,
plots: dict) -> str:
order = [k for k in ("naive_full", "rank_full", "rank_liquid") if k in results]
# headline metric table
headline_rows = []
for k in order:
r = results[k]
am = r["alpha_metrics"]
ex = r["execution"]
net = ex.get("net", {}) if ex.get("exists") else {}
before = ex.get("before_cost", {}) if ex.get("exists") else {}
headline_rows.append([
r["run"].label,
r["run"].weighting,
_pct(am["cumulative_return"]),
_num(am["sharpe_annual"]),
f"{am['turnover_annual']:.0f}×",
_pct(before.get("cumulative_return", 0.0)) if before else "n/a",
_pct(net.get("cumulative_return", 0.0)) if net else "n/a",
_num(net.get("sharpe_annual", 0.0)) if net else "n/a",
])
headline = _md_table(
["run", "weighting", "research cum", "research Sharpe", "research turn/yr",
"exec before cost", "exec net", "exec net Sharpe"],
headline_rows,
)
# artifacts
artifact_rows = [["data", str(DATA_PATH.relative_to(ROOT)),
f"{data_summary['rows']:,}", f"{data_summary['symbols']:,}",
f"{data_summary['dates']:,}",
f"{data_summary['start']} to {data_summary['end']}"]]
for k in order:
r = results[k]
s = r["storage"]
artifact_rows.append([
f"alpha · {r['run'].label}", str(r["run"].alpha.relative_to(ROOT)),
f"{s['rows']:,}", f"{s['symbols']:,}", f"{s['dates']:,}",
f"{s['start']} to {s['end']}"])
artifacts = _md_table(["artifact", "path", "rows", "symbols", "dates", "coverage"],
artifact_rows)
# storage checks
storage_rows = []
for k in order:
s = results[k]["storage"]
c = results[k]["combo"]
storage_rows.append([
results[k]["run"].label,
str(s["columns_ok"]),
f"{s['null_weights']:,}",
f"{s['nonfinite_weights']:,}",
f"{s['dup_keys']:,}",
f"{s['max_abs_daily_mean']:.2e}",
f"[{s['weight_min']:.1f}, {s['weight_max']:.1f}]",
f"{c.get('max_abs_weight_diff', float('nan')):.2e}" if c.get("exists") else "n/a",
])
storage = _md_table(
["run", "schema ok", "null w", "non-finite w", "dup keys",
"max \\|daily mean\\|", "weight range", "combo identity Δ"],
storage_rows,
)
# closeness: alpha->portfolio
close_rows = []
for k in order:
p = results[k]["positions"]
if not p.get("exists"):
close_rows.append([results[k]["run"].label] + ["n/a"] * 5)
continue
close_rows.append([
results[k]["run"].label,
_num(p["target_value_identity_max_abs"]),
f"{p['alpha_to_target_max_abs']:.2e}",
f"{p.get('research_corr_to_alpha', float('nan')):.6f}",
_money(p["position_gross_mean"]),
_money(p["l1_tracking_mean"]),
])
closeness = _md_table(
["run", "target_value identity max\\\\|", "alpha→target max\\\\|",
"research corr(alpha,portfolio)", "mean integer gross", "mean L1 tracking"],
close_rows,
)
# closeness: alpha -> execution net
exec_rows = []
for k in order:
ex = results[k]["execution"]
if not ex.get("exists"):
exec_rows.append([results[k]["run"].label] + ["n/a"] * 5)
continue
exec_rows.append([
results[k]["run"].label,
f"{ex.get('alpha_vs_net_corr', float('nan')):.4f}",
_money(ex["total_pnl_before_cost"]),
_money(ex["total_cost"]),
_money(ex["total_pnl"]),
f"{ex['mean_daily_turnover']:.4f}",
])
exec_close = _md_table(
["run", "corr(alpha, exec net)", "PnL before cost", "total cost",
"net PnL", "mean daily turnover"],
exec_rows,
)
# timings
timing_rows = []
for label, key in TIMING_PHASES:
full = timings.get(f"full_{key}")
liq = timings.get(f"liq_{key}")
timing_rows.append([
label,
f"{full:.1f}" if full is not None else "n/a",
f"{liq:.1f}" if liq is not None else "n/a",
])
if timings:
full_total = sum(timings.get(f"full_{k}", 0.0) for _, k in TIMING_PHASES)
liq_total = sum(timings.get(f"liq_{k}", 0.0) for _, k in TIMING_PHASES)
timing_rows.append(["total", f"{full_total:.1f}", f"{liq_total:.1f}"])
timing_tbl = _md_table(["phase", "rank full (s)", "rank liquid (s)"], timing_rows)
naive = results.get("naive_full")
rliq = results.get("rank_liquid")
rfull = results.get("rank_full")
def cum(run_key, kind="alpha"):
if run_key not in results:
return float("nan")
return results[run_key]["alpha_metrics"]["cumulative_return"]
return f"""# Tutorial: Testing a 5-Day Reversal Alpha
This document is a teaching walkthrough for someone who is new to this research
framework and only lightly familiar with quant research. We will use one
concrete experiment, a 5-day reversal alpha on the full downloaded Chinese
A-share universe, to learn how the framework defines an alpha, stores it, tests
it, turns it into a portfolio, and explains the gap between a research result
and simulated trading PnL.
This generated version was refreshed at {datetime.now().isoformat(timespec="seconds")}.
The important point is not the timestamp; it is the research method.
## The Research Question
A quant research project starts with a hypothesis:
> If a stock fell a lot over the last few trading days, it may rebound soon; if
> it rose a lot, it may cool off soon.
This is called **short-horizon reversal**. It is a simple idea: recent losers
are candidates to buy, and recent winners are candidates to sell or underweight.
In this repo, the tested version looks back 5 trading days.
The central research question is:
> Does this 5-day reversal rule create useful portfolio returns after the
> framework applies realistic storage, portfolio construction, execution
> constraints, and trading costs?
The answer from this run is nuanced:
- The naive built-in version is positive under the tradable
next-open-to-next-open research convention (**{_pct(cum('naive_full'))}**),
but its stored weights still show that raw z-score weighting is too sensitive
to A-share outliers.
- A rank-weighted version on a liquid, non-ST, tradable universe has a positive
costless research result: **{_pct(cum('rank_liquid')) if rliq else 'n/a'}**
at Sharpe **{results['rank_liquid']['alpha_metrics']['sharpe_annual']:.2f}**.
- The daily-traded implementation is still not tradable after costs because
turnover is too high.
That is a normal research outcome. Good research is not just asking "did the
backtest go up?" It is asking **which layer explains the result**: signal,
weighting, universe, construction, execution, or cost.
## How This Framework Defines An Alpha
In many quant textbooks, an alpha is described as a **prediction** of future
returns. This framework uses a stricter and more practical convention:
> An alpha is a signed cross-sectional position weight.
That sentence is the key to the whole repo.
- **Signed** means positive values are long exposure and negative values are
short exposure.
- **Cross-sectional** means the alpha compares stocks to other stocks on the
same date.
- **Position weight** means the output is already an instruction about what the
portfolio wants to own. It is not merely a score to correlate with future
returns.
The stored alpha file always has this schema:
| column | meaning |
| --- | --- |
| `symbol_id` | Stock identifier such as `sh600000` or `sz000001`. |
| `date` | The signal date. The alpha is formed using information known by this date's close. |
| `alpha_name` | A label for this particular run, such as `reversal_5d_all`. |
| `weight` | Signed desired exposure. Positive means long; negative means short. |
Because the framework treats alphas as position weights, it evaluates them with
portfolio metrics: return, Sharpe, turnover, drawdown, and hit rate. It does
**not** use IC/IR, because IC/IR would treat the alpha as a return predictor.
## The Pipeline In One Picture
Every phase reads parquet files and writes parquet files. That makes the system
easy to inspect and rerun one layer at a time.
```text
daily bars
-> alpha weights
-> combined weights
-> portfolio targets and integer positions
-> simulated fills and PnL
-> evaluation metrics
```
For this experiment, the important phases are:
| phase | command family | what it teaches you |
| --- | --- | --- |
| Data | `cli.py data download` | What market data is available. |
| Alpha compute | `cli.py alpha compute` | How a raw research idea becomes stored weights. |
| Alpha eval | `cli.py alpha eval` | How close-formed weights perform over the tradable next-open-to-next-open interval. |
| Combo | `cli.py combo combine` | How one or more alphas become one combined book. |
| Portfolio build | `cli.py portfolio build` | How weights become target values and integer shares. |
| Portfolio simulate | `cli.py portfolio simulate` | How the integer book trades at next open with constraints and costs. |
| Portfolio eval | `cli.py portfolio eval` | How the continuous target portfolio behaves over the same costless open-to-open research interval. |
In a real research workflow, you should learn to pause after every phase and
inspect the parquet output. Most mistakes are easier to find at the interface
between two phases than at the final PnL line.
## Step 1: Define The Raw Reversal Signal
The built-in 5-day reversal alpha is implemented as:
```python
signal = -close.pct_change(5, fill_method=None)
```
For stock `i` on date `t`, this is approximately:
```text
signal[i, t] = -(close[i, t] / close[i, t-5] - 1)
```
So:
- If a stock rose by 10% over the last 5 trading days, the raw signal is `-10%`.
It becomes a candidate short or underweight.
- If a stock fell by 10% over the last 5 trading days, the raw signal is `+10%`.
It becomes a candidate long or overweight.
Notice the timing. The signal uses prices through date `t`. It must not use the
return from `t` to `t+1`, because that is the future. The costless alpha
evaluator tests the weight formed on date `t` over the tradable interval from
`open[t+1]` to `open[t+2]`; the later execution simulator is the separate layer
that trades the constructed integer book at the next open.
The code lives in `pipeline/alpha/library/reversal.py`:
```python
class ReversalAlpha(BaseAlpha):
name = "reversal"
def __init__(self, lookback: int = 5):
self.lookback = lookback
def signal(self, close: pd.DataFrame) -> pd.DataFrame:
return -close.pct_change(self.lookback, fill_method=None)
```
The alpha class only defines the raw signal. The base class then turns that
signal into weights.
## Step 2: Turn A Signal Into Cross-Sectional Weights
By default, `BaseAlpha.to_weights()` does a cross-sectional z-score each date:
```text
weight[i, t] = (signal[i, t] - mean_signal[t]) / std_signal[t]
```
This means the framework asks:
> On this date, which stocks have stronger reversal scores than the rest of the
> market, and by how much?
That is useful, but it has a weakness. If a few stocks have extreme trailing
returns because they are newly listed, suspended, illiquid, or limit-constrained,
z-scoring can put a very large amount of relative exposure into exactly those
names.
That is visible in the naive full-universe run. Stored weights reached about
`{results['naive_full']['storage']['weight_min']:.0f}` standard deviations. The
result is positive under the open-to-open convention, but it is much weaker and
less robust than the rank-weighted versions:
| run | weighting | research cumulative return | research Sharpe | research turnover/year |
| --- | --- | --- | --- | --- |
| naive z-score, full universe | z-score | {_pct(cum('naive_full'))} | {results['naive_full']['alpha_metrics']['sharpe_annual']:.4f} | {results['naive_full']['alpha_metrics']['turnover_annual']:.0f}x |
The lesson is not "reversal is solved." The lesson is:
> The same raw signal can become a fragile portfolio if the weighting method
> reacts badly to outliers.
## Step 3: Make The Weighting More Robust
The repo also has a rank-weighted version, `reversal_rank`. It uses the same raw
5-day reversal signal, but converts the cross-section to ranks instead of
z-scores:
```python
ranks = signal.rank(axis=1)
weights = ranks.subtract(ranks.mean(axis=1), axis=0)
```
Rank weighting keeps the ordering of stocks but removes the importance of the
exact outlier magnitude. A stock can be "the worst recent loser" or "the best
recent winner," but it cannot become dozens of standard deviations important
just because its raw percentage move is unusual.
The full-universe rank version was much less pathological, but still not a
clean signal:
| run | weighting | research cumulative return | research Sharpe | research turnover/year |
| --- | --- | --- | --- | --- |
| rank, full universe | rank | {_pct(cum('rank_full')) if rfull else 'n/a'} | {results['rank_full']['alpha_metrics']['sharpe_annual']:.4f} | {results['rank_full']['alpha_metrics']['turnover_annual']:.0f}x |
That tells us the weighting fix helped, but the universe still contains many
names that are poor candidates for a daily reversal strategy.
## Step 4: Define The Investable Universe
An alpha should be tested on stocks that could plausibly be traded. The liquid
run applies a per-date mask before weights are created. A stock must be:
- seasoned, with at least 60 observed closes;
- currently tradable, using `tradestatus == 1`;
- not ST, using `isST == 0`;
- inside the top 1000 names by trailing 20-day average traded amount.
This mask is applied to the signal, not to the price history used to compute the
5-day return. That distinction matters. We still compute `pct_change(5)` on the
full contiguous price history, then decide which names are eligible to hold on
each signal date.
The liquid rank result is the cleanest research result:
| run | weighting | universe | research cumulative return | research Sharpe | hit rate |
| --- | --- | --- | --- | --- | --- |
| rank, liquid subset | rank | top 1000 liquid, tradable, non-ST | {_pct(cum('rank_liquid')) if rliq else 'n/a'} | {results['rank_liquid']['alpha_metrics']['sharpe_annual']:.4f} | {_pct(results['rank_liquid']['alpha_metrics']['hit_rate']) if rliq else 'n/a'} |
This is the first point where a researcher can say:
> There appears to be a real 5-day reversal effect in a cleaner A-share
> universe, before trading costs.
That last phrase, **before trading costs**, is essential.
![Research equity](assets/reversal_5d_research_equity.png)
When reading this chart, focus on the shape and relative behavior:
- The naive z-score line shows why outlier-sensitive weighting is fragile.
- The rank full-universe line shows that robust weighting helps, but the full
universe still contains noisy and hard-to-trade names.
- The liquid rank line shows the signal-level edge before execution costs.
## Step 5: Check That The Alpha File Is Sane
Before trusting any metric, inspect the stored alpha artifact. The run checked:
- The columns match `ALPHA_COLUMNS`.
- There are no null weights.
- There are no non-finite weights.
- There are no duplicate `(symbol_id, date)` rows.
- The daily cross-sectional mean is approximately zero.
- A one-alpha combo is an exact identity transform.
{storage}
The rank ranges look numerically large because rank weights scale with the
number of names. That is fine: later evaluation divides by gross exposure, and
portfolio construction normalizes by `sum(abs(weight))`. The important
difference is that rank weights are bounded by cross-sectional rank, not by the
raw size of an abnormal stock move.
![Weight distributions](assets/reversal_5d_weight_distributions.png)
This is a good habit: when a backtest looks strange, plot the weights before
debugging the PnL. A broken or concentrated weight distribution often explains
the result.
## Step 6: Understand The Alpha Evaluation Formula
The costless alpha evaluator now asks:
> If we compute alpha weights after close on date `t`, trade them at `open[t+1]`,
> and hold them until `open[t+2]`, what return would we earn before costs?
This is still a **research-layer approximation**, not the trading simulator. At
this stage the framework has only an alpha weight file. It has not yet rounded
shares, checked limits, clipped trades, or paid costs. The purpose is to answer
a clean signal question: "Do these close-formed weights line up with returns
over the interval we could actually own after next-open execution?"
The daily research return is:
```text
R[t] = sum_i(weight[i, t] * (open[i, t+2] / open[i, t+1] - 1)) / sum_i(abs(weight[i, t]))
```
This has three important consequences:
- The alpha is normalized by its gross exposure, so the scale of raw weights
does not by itself create a higher return.
- The new signal does not receive credit for the overnight gap from `close[t]`
to `open[t+1]`, because it cannot be traded until `open[t+1]`.
- The final two signal dates are dropped from performance metrics because they
do not have a complete next-open-to-next-open holding interval.
Turnover is still measured from the weights:
```text
turnover[t] = sum_i(abs(weight[i, t] - weight[i, t-1])) / sum_i(abs(weight[i, t-1]))
```
The annualized turnover numbers are a warning. Even a positive signal can be
hard to monetize if it asks the portfolio to trade too much every day.
## Step 7: Build A Portfolio From The Alpha
The alpha file is still an abstract research book. `portfolio build` turns it
into target exposures and integer shares.
The main normalization is:
```text
target_weight[i, t] = weight[i, t] / sum_i(abs(weight[i, t]))
target_value[i, t] = booksize * target_weight[i, t]
target_shares[i, t] = target_value[i, t] / construction_price[i, t]
```
Then the framework creates an integer A-share book using lot rules and repair
logic. This is where a research portfolio starts to become a tradable portfolio.
The continuous target portfolio matched the stored alpha almost exactly:
{closeness}
The integer book is not exact because small target positions can be rounded
away. The liquid subset has lower tracking error because it spreads the book
over fewer and more tradable names.
![Portfolio tracking](assets/reversal_5d_portfolio_tracking.png)
When you research a new alpha, ask two separate questions:
- Does the continuous target portfolio match the alpha? It should.
- Does the integer tradable portfolio still resemble the target? It may not,
especially for small books or very broad universes.
## Step 8: Simulate Execution And Costs
Research returns are not the same as tradable PnL. The simulator executes the
integer `position_shares` at the next available open and applies constraints:
- suspension;
- price limit;
- volume cap;
- proportional trading cost.
The cost model is:
```text
cost = abs(traded_shares * open) * (cost_bps + slippage_bps) / 10000
```
For this run, cost is 5 bps commission plus 5 bps slippage. Slippage is treated
as cash cost, not as an additional execution price adjustment.
The execution results explain the final research conclusion:
{exec_close}
For the liquid rank run, simulated PnL before cost is about
{_money(results['rank_liquid']['execution']['total_pnl_before_cost']) if rliq and results['rank_liquid']['execution'].get('exists') else 'n/a'}, but total cost is about
{_money(results['rank_liquid']['execution']['total_cost']) if rliq and results['rank_liquid']['execution'].get('exists') else 'n/a'}. That is why the final net PnL is
weak or negative.
This is not a contradiction. It is exactly what a research pipeline should show:
> The signal can exist in the costless layer, but the daily implementation can
> still trade too much to keep the edge.
![Execution vs research](assets/reversal_5d_exec_vs_research.png)
## Step 9: Read The Headline Metrics Like A Researcher
The complete summary is:
{headline}
*Research = costless, no-look-ahead weights over the next-open-to-next-open
holding interval. Execution = next-open fills on the discretized integer book
under suspension / price-limit / volume-cap constraints, 5 bps commission + 5
bps slippage.*
Here is the interpretation:
- **Naive z-score full universe**: positive under open-to-open research, but a
less reliable test of the reversal idea because the weighting scheme lets
outliers dominate parts of the book.
- **Rank full universe**: a better test of the same idea, but still noisy
because the universe includes too many problematic names.
- **Rank liquid subset**: the best signal-level test; it finds the cleanest
costless reversal effect.
- **Execution net**: daily rebalancing remains heavily constrained by cost.
A beginner might look only at the final net PnL and say "the alpha failed." A
researcher should be more precise:
> The raw 5-day reversal idea has signal value in a liquid universe, but the
> current daily trading rule has too much turnover for the assumed cost model.
## Step 10: Reproduce The Experiment
These commands reproduce the important artifacts, assuming the full daily-bar
dataset already exists at `data/daily_bars/all`.
```bash
# Naive z-score baseline: built-in reversal alpha, full universe.
uv run python cli.py alpha compute --data-path data/daily_bars/all \\
--alpha-name reversal_5d_all --alpha-type reversal --lookback 5 \\
--output-dir alphas
# Rank-weighted full and liquid runs.
bash scripts/run_reversal_rank_e2e.sh
# Regenerate figures, diagnostics, and this tutorial report.
uv run python scripts/generate_reversal_5d_report.py
```
If you are learning the framework, do not run the whole pipeline blindly. Run
one phase, inspect the output parquet, then continue.
## How To Research Your Own Alpha
Use this checklist for a new idea.
1. State the hypothesis in plain language.
Example: "Stocks with poor 5-day returns may rebound over the next day."
2. Write the raw signal.
Implement `signal(close) -> wide DataFrame` in an alpha class. Higher values
should mean stronger long preference.
3. Choose the weighting method.
The default z-score is useful, but it can be fragile. Consider rank weights,
caps, neutralization, or liquidity-aware filters if outliers dominate.
4. Define the investable universe before trusting results.
Make sure the strategy is not depending on suspended, ST, newly listed, or
illiquid names.
5. Evaluate the alpha as a portfolio, not as a prediction.
Check cumulative return, Sharpe, drawdown, hit rate, and turnover over the
next-open-to-next-open holding interval. Do not add IC/IR unless the
framework's alpha convention changes.
6. Build the portfolio and inspect tracking.
Confirm that target weights match the alpha, then check whether integer
shares still track the target book.
7. Simulate execution with costs.
The final research question is not only "is there a signal?" It is "is there
enough signal left after realistic trading?"
8. Diagnose the failure layer.
If results are bad, identify whether the problem is the raw signal, weighting,
universe, construction, execution constraints, turnover, or cost.
For this 5-day reversal study, the diagnosis is clear: **the signal-level result
is most promising after robust weighting and a liquid universe filter, but the
current implementation needs turnover control before it can be considered
tradable.**
## Next Research Directions
The natural next experiments are:
- Add turnover control: no-trade bands, slower rebalancing, or weight smoothing.
- Sweep the lookback window: compare 3-day, 5-day, 10-day, and 20-day reversal.
- Sweep liquidity filters: top 500, top 1000, top 1500 by traded amount.
- Add position caps so no single name can dominate after normalization.
- Compare rank weighting with volatility-scaled reversal.
The most important habit is to keep the layers separate. A good alpha research
workflow does not stop at a single performance number; it explains how the idea
travels from hypothesis, to signal, to weights, to portfolio, to executable PnL.
## Appendix: Phase Timings From This Rerun
{timing_tbl}
![Phase timings](assets/reversal_5d_phase_timings.png)
`portfolio build` usually dominates because it iterates per signal date and
repairs a multi-thousand-name integer book under lot rules. The liquid run is
faster because it carries fewer non-zero names per date.
"""
def main() -> None:
ASSET_DIR.mkdir(parents=True, exist_ok=True)
DIAGNOSTICS_PATH.parent.mkdir(parents=True, exist_ok=True)
print("loading data ...")
data = pd.read_parquet(DATA_PATH, columns=["symbol_id", "date", "open", "close"])
data["date"] = pd.to_datetime(data["date"])
data_dates = pd.DatetimeIndex(sorted(data["date"].unique()))
by_date = data.groupby("date")["symbol_id"].size()
close = data.pivot_table(index="date", columns="symbol_id", values="close",
aggfunc="first").sort_index()
open_ = data.pivot_table(index="date", columns="symbol_id", values="open",
aggfunc="first").sort_index()
fwd = open_.shift(-2).divide(open_.shift(-1)) - 1.0
data_summary = {
"rows": int(len(data)),
"symbols": int(data["symbol_id"].nunique()),
"dates": int(data["date"].nunique()),
"start": _date(data["date"].min()),
"end": _date(data["date"].max()),
"last_date_rows": int(by_date.iloc[-1]),
"full_date_rows": int(by_date.max()),
}
del data
timings = {}
if TIMINGS_PATH.exists():
try:
timings = {k: v for k, v in json.loads(TIMINGS_PATH.read_text()).items()
if isinstance(v, (int, float))}
except json.JSONDecodeError:
print(f" [warn] {TIMINGS_PATH} not valid JSON yet; timing table will be sparse")
results = {}
for run in RUNS:
print(f"analyzing {run.key} ...")
r = analyze_run(run, close, fwd, data_dates)
if r is not None:
results[run.key] = r
if not results:
raise SystemExit("No runs found — run scripts/run_reversal_rank_e2e.sh first.")
plots = {
"weights": plot_weight_distributions(results),
"research_equity": plot_research_equity(results),
"exec_vs_research": plot_exec_vs_research(results),
"tracking": plot_tracking(results),
"timings": plot_timings(timings),
}
report = render_report(results, data_summary, timings, plots)
REPORT_PATH.write_text(report)
diagnostics = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"booksize": BOOKSIZE,
"data": data_summary,
"timings_seconds": timings,
"runs": _strip_series(results),
}
DIAGNOSTICS_PATH.write_text(json.dumps(diagnostics, indent=2))
print(f"Wrote {REPORT_PATH}")
print(f"Wrote {DIAGNOSTICS_PATH}")
if __name__ == "__main__":
main()