1157 lines
45 KiB
Python
1157 lines
45 KiB
Python
"""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.
|
||
|
||

|
||
|
||
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.
|
||
|
||

|
||
|
||
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.
|
||
|
||

|
||
|
||
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.
|
||
|
||

|
||
|
||
## 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}
|
||
|
||

|
||
|
||
`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()
|