Add pipeline invariant checks for look-ahead, execution, PnL, and lot rules

Ten network-free correctness tests mapping 1:1 to the review checks:
reversal look-ahead, next-open execution date, PnL decomposition,
realized-not-target threading, blocked-trade zero cost, causal universe
mask, one-way cost bps, raw-price accounting, adjustment-invariant alpha,
and lot-lattice repair.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Yuxuan Yan
2026-06-11 21:46:29 +08:00
parent b7dd94b032
commit 2ceac82325
+305
View File
@@ -0,0 +1,305 @@
"""End-to-end correctness invariants for the reversal_5d pipeline (no network).
Each test maps 1:1 to one of the ten review checks. Naming convention: the
*execution date* ``d`` is the market session on which a target is actually
filled at the open; the *signal date* ``t`` is the session whose close formed
that target. The documented convention is ``d = next(t)`` (see
``docs/portfolio_trading_cost_model.md``), so ``close[d-1] == close[t]``.
"""
import numpy as np
import pandas as pd
from pipeline.alpha.compute import compute_alpha, investable_universe_mask
from pipeline.portfolio.construct import construct_positions
from pipeline.portfolio.discretize import repair_exposure, round_to_valid_lot
from pipeline.portfolio.market_rules import MarketRule
from pipeline.portfolio.costs import SimpleProportionalCostModel
from pipeline.portfolio.constraints import (
SuspensionConstraint,
VolumeCapConstraint,
)
from pipeline.portfolio.simulator import ReferenceSimulator
_SYMBOLS = ("sh600000", "sz000001", "sh688981", "sz300750")
def _panel(n_days=12, symbols=_SYMBOLS, start="2024-01-01", seed=0,
distinct_open=True):
"""Contiguous long-format DATA frame with all columns the pipeline needs.
Open and close differ (so overnight vs intraday PnL terms are separable),
and the calendar is gap-free so each session is the next session's ``d-1``.
"""
dates = pd.date_range(start, periods=n_days)
rng = np.random.default_rng(seed)
frames = []
for i, sym in enumerate(symbols):
close = np.abs(50.0 + i * 10 + np.cumsum(rng.standard_normal(n_days))) + 5.0
open_ = close * (0.99 + 0.02 * rng.random(n_days)) if distinct_open else close.copy()
preclose = np.concatenate([[close[0]], close[:-1]])
frames.append(pd.DataFrame({
"symbol_id": sym,
"symbol_name": sym,
"date": dates,
"open": open_,
"high": np.maximum(open_, close),
"low": np.minimum(open_, close),
"close": close,
"preclose": preclose,
"volume": 1_000_000.0,
"amount": 1_000_000.0 * close,
"tradestatus": 1,
"isST": 0,
}))
return pd.concat(frames, ignore_index=True)
# --- 1. reversal signal uses only close[d-1] and earlier ---------------------
def test_reversal_signal_does_not_peek_at_future_closes():
data = _panel(n_days=12)
base = compute_alpha(data, "rev", "reversal_rank", lookback=5)
# Perturb every close strictly AFTER an interior signal date t; the weight
# dated t (executed at d = t+1) must be unchanged — it may use close[t]
# (== close[d-1]) and earlier only.
t = sorted(data["date"].unique())[6]
future = data.copy()
mask = future["date"] > t
future.loc[mask, ["open", "high", "low", "close"]] *= 1.5
perturbed = compute_alpha(future, "rev", "reversal_rank", lookback=5)
b = base[base["date"] <= t].set_index(["symbol_id", "date"])["weight"]
p = perturbed[perturbed["date"] <= t].set_index(["symbol_id", "date"])["weight"]
pd.testing.assert_series_equal(b.sort_index(), p.sort_index())
# --- 2. the executed (fill/PnL) date is the open-execution date d = next(t) --
def test_fill_date_is_next_session_open_execution_date():
data = _panel(n_days=8)
weights = compute_alpha(data, "c", "reversal_rank", lookback=3)
weights = weights.rename(columns={"alpha_name": "combo_name"})
weights["combo_name"] = "c"
pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1")
fills, pnl = ReferenceSimulator(cost_bps=5, slippage_bps=5).run(pos, data)
sessions = sorted(data["date"].unique())
nxt = {s: sessions[i + 1] for i, s in enumerate(sessions[:-1])}
# Every executed date equals the session AFTER some position (signal) date.
pos_dates = set(pos["date"].unique())
exec_dates = set(pnl["date"].unique())
assert exec_dates == {nxt[t] for t in pos_dates if t in nxt}
# Execution price is the open of the execution date, not the signal close.
opn = data.pivot_table(index="date", columns="symbol_id", values="open",
aggfunc="first").sort_index()
d = sorted(exec_dates)[1]
row = fills[(fills["date"] == d) & (fills["traded_shares"] != 0)].iloc[0]
sym = row["symbol_id"]
expected_cost = abs(row["traded_shares"]) * opn.loc[d, sym] * (5 + 5) / 1e4
assert np.isclose(row["trade_cost"], expected_cost)
# --- 3. PnL identity: overnight(old book) + intraday(new book) - cost --------
def test_daily_pnl_matches_overnight_plus_intraday_minus_cost():
data = _panel(n_days=8)
weights = compute_alpha(data, "c", "reversal_rank", lookback=3)
weights = weights.rename(columns={"alpha_name": "combo_name"})
weights["combo_name"] = "c"
pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1")
fills, pnl = ReferenceSimulator(cost_bps=5, slippage_bps=5).run(pos, data)
opn = data.pivot_table(index="date", columns="symbol_id", values="open", aggfunc="first").sort_index()
cls = data.pivot_table(index="date", columns="symbol_id", values="close", aggfunc="first").sort_index()
sessions = list(cls.index)
prev_close_of = {sessions[i]: sessions[i - 1] for i in range(1, len(sessions))}
for d in sorted(pnl["date"].unique()):
day = fills[fills["date"] == d]
prev = day.set_index("symbol_id")["prev_shares"]
realized = day.set_index("symbol_id")["realized_shares"]
cost = day["trade_cost"].sum()
intraday = float((realized * (cls.loc[d] - opn.loc[d]).reindex(realized.index)).sum())
# Overnight gap on the OLD book is taken from the previous *executed*
# date's close. With a gap-free calendar and daily execution that is the
# immediately preceding session; the first executed date has no prior
# book so the term is naturally zero (prev_shares == 0 there).
pc = prev_close_of.get(d)
if pc is not None and (prev != 0).any():
overnight = float((prev * (opn.loc[d] - cls.loc[pc]).reindex(prev.index)).sum())
else:
overnight = 0.0
expected = overnight + intraday - cost
got = float(pnl[pnl["date"] == d]["pnl"].iloc[0])
assert np.isclose(got, expected, rtol=1e-6, atol=1e-3), (d, got, expected)
# --- 4. realized shares (not target shares) are threaded into the next day ---
def test_realized_not_target_threaded_forward():
data = _panel(n_days=6)
weights = compute_alpha(data, "c", "reversal_rank", lookback=2)
weights = weights.rename(columns={"alpha_name": "combo_name"})
weights["combo_name"] = "c"
pos = construct_positions(weights, data, booksize=1e8, portfolio_name="run1")
# A tight volume cap forces partial fills, so realized != target on most
# names — exactly the case where threading target vs realized diverges.
fills, _ = ReferenceSimulator(
constraints=[VolumeCapConstraint(max_frac=1e-6)]
).run(pos, data)
wide_prev = fills.pivot_table(index="date", columns="symbol_id", values="prev_shares", aggfunc="first")
wide_real = fills.pivot_table(index="date", columns="symbol_id", values="realized_shares", aggfunc="first")
exec_dates = list(wide_prev.index)
assert len(exec_dates) >= 2
# Today's prev_shares == yesterday's realized_shares for every name.
for a, b in zip(exec_dates[:-1], exec_dates[1:]):
prev_today = wide_prev.loc[b].dropna()
real_yest = wide_real.loc[a].reindex(prev_today.index).fillna(0.0)
pd.testing.assert_series_equal(
prev_today.astype(float), real_yest.astype(float), check_names=False
)
# And realized actually diverged from target (cap bit), so the test is real.
assert (fills["realized_shares"] != fills["target_shares"]).any()
# --- 5. blocked trades create zero traded_shares and zero trade_cost ---------
def test_blocked_trade_has_zero_shares_and_zero_cost():
data = _panel(n_days=6)
# Suspend one name on every session so any attempt to trade it is blocked.
data.loc[data["symbol_id"] == "sz000001", "tradestatus"] = 0
weights = compute_alpha(data, "c", "reversal_rank", lookback=2)
weights = weights.rename(columns={"alpha_name": "combo_name"})
weights["combo_name"] = "c"
pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1")
fills, _ = ReferenceSimulator(
constraints=[SuspensionConstraint()], cost_bps=5, slippage_bps=5
).run(pos, data)
blocked = fills[fills["blocked"] == 1]
assert (blocked["traded_shares"] == 0).all()
assert (blocked["trade_cost"] == 0.0).all()
# The suspended name never trades and never accrues cost.
susp = fills[fills["symbol_id"] == "sz000001"]
assert (susp["traded_shares"] == 0).all()
assert (susp["trade_cost"] == 0.0).all()
# --- 6. liquid universe uses only information known before open[d] -----------
def test_investable_universe_mask_is_causal():
data = _panel(n_days=14)
close = data.pivot_table(index="date", columns="symbol_id", values="close", aggfunc="first").sort_index()
full = investable_universe_mask(data, close, top_n=10, min_history=3)
t = sorted(data["date"].unique())[8]
# Recompute the mask from data truncated at the signal date t: the mask row
# for t must be identical, proving it never reads dates > t (i.e. nothing
# from open[d=t+1] onward).
trunc = data[data["date"] <= t]
close_t = close.loc[:t]
mask_t = investable_universe_mask(trunc, close_t, top_n=10, min_history=3)
pd.testing.assert_series_equal(
full.loc[t].sort_index(), mask_t.loc[t].sort_index(), check_names=False
)
# --- 7. cost bps is one-way per-trade (a round trip is charged twice) --------
def test_cost_bps_is_one_way_per_trade():
model = SimpleProportionalCostModel(cost_bps=5, slippage_bps=5)
price = np.array([20.0])
buy = model.compute(np.array([1000]), price, np.array([1]), date=None)
sell = model.compute(np.array([-1000]), price, np.array([-1]), date=None)
one_way = 1000 * 20 * (5 + 5) / 1e4
assert np.isclose(buy[0], one_way) # charged once on the buy leg
assert np.isclose(sell[0], one_way) # charged again on the sell leg
# A full round trip (enter then exit) therefore costs ~2x the one-way rate.
assert np.isclose(buy[0] + sell[0], 2 * one_way)
# --- 8. execution & PnL use raw tradable prices on the same scale as shares --
def test_position_value_is_shares_times_raw_price():
data = _panel(n_days=10)
weights = compute_alpha(data, "c", "reversal_rank", lookback=3)
weights = weights.rename(columns={"alpha_name": "combo_name"})
weights["combo_name"] = "c"
pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1")
finite = pos["price"] > 0
# The stored value is exactly integer shares × the raw construction price —
# no adjusted-price factor is mixed into the share→value accounting.
expected = pos.loc[finite, "position_shares"] * pos.loc[finite, "price"]
pd.testing.assert_series_equal(
pos.loc[finite, "position_value"].astype(float),
expected.astype(float),
check_names=False,
)
# --- 9. alpha is scale-free (adjusted prices ok); accounting uses raw units --
def test_alpha_weights_invariant_to_per_symbol_price_scaling():
data = _panel(n_days=12)
base = compute_alpha(data, "rev", "reversal_rank", lookback=5)
# A qfq/hfq adjustment is (per symbol) a multiplicative rescaling of the
# price series; pct_change is scale-free, so the alpha weights must not move.
scaled = data.copy()
factor = {"sh600000": 2.0, "sz000001": 0.5, "sh688981": 3.0, "sz300750": 1.25}
for sym, f in factor.items():
m = scaled["symbol_id"] == sym
scaled.loc[m, ["open", "high", "low", "close"]] *= f
scaled_alpha = compute_alpha(scaled, "rev", "reversal_rank", lookback=5)
b = base.set_index(["symbol_id", "date"])["weight"].sort_index()
s = scaled_alpha.set_index(["symbol_id", "date"])["weight"].sort_index()
pd.testing.assert_series_equal(b, s)
# --- 10. repaired book stays on valid A-share lot lattices -------------------
def _on_lattice(q, min_open, increment):
q = np.abs(np.asarray(q, dtype=np.int64))
on = (q == 0) | ((q >= min_open) & ((q - min_open) % increment == 0))
return bool(on.all())
def test_repair_output_stays_on_lot_lattice():
# Pre-2023 main board has a 100-share increment (the strongest lattice
# constraint); STAR uses min 200 / increment 1.
symbols = np.array(["sh600000", "sz000001", "sh688981", "sz300750"], dtype=object)
rule = MarketRule()
on = "2022-06-01" # pre 2023-08-10 → main-board increment is 100
min_open, increment, odd_full, _ = rule.get_rules_vectorized(
symbols, on, np.zeros(len(symbols), dtype=bool)
)
assert min_open[0] == 100 and increment[0] == 100 # main board pre-2023
price = np.array([12.3, 8.7, 45.0, 230.0])
prev = np.zeros(len(symbols), dtype=np.int64)
q_target = np.array([3251.0, -7777.0, 640.0, -415.0])
q_round = round_to_valid_lot(q_target, prev, min_open, increment, odd_full)
assert _on_lattice(q_round, min_open, increment)
repaired = repair_exposure(
q_round, q_target, price, increment, min_open, prev, odd_full,
booksize=float(np.abs(q_target * price).sum()),
)
assert _on_lattice(repaired, min_open, increment)
# Repair never flips a name's sign relative to the rounded book.
nz = q_round != 0
assert np.all(np.sign(repaired[nz]) * np.sign(q_round[nz]) >= 0)