Add offline workflow and coverage tests
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
"""Verbose offline checks for the daily research workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from pipeline.alpha.compute import compute_alpha
|
||||
from pipeline.combo.combine import combine_alphas
|
||||
from pipeline.common.schema import (
|
||||
ALPHA_COLUMNS,
|
||||
COMBO_COLUMNS,
|
||||
FILL_COLUMNS,
|
||||
PNL_COLUMNS,
|
||||
POSITION_COLUMNS,
|
||||
)
|
||||
from pipeline.portfolio.constraints import (
|
||||
PriceLimitConstraint,
|
||||
SuspensionConstraint,
|
||||
VolumeCapConstraint,
|
||||
)
|
||||
from pipeline.portfolio.construct import construct_positions
|
||||
from pipeline.portfolio.research import evaluate_portfolio
|
||||
from pipeline.portfolio.simulator import ReferenceSimulator
|
||||
from tests.helpers import (
|
||||
GENERATED_SYMBOLS,
|
||||
generated_sessions,
|
||||
make_generated_alpha_weights,
|
||||
make_generated_combo_weights,
|
||||
make_generated_daily_bars,
|
||||
)
|
||||
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent / "fixtures" / "daily_bars_real_2024_01_sample.pq"
|
||||
|
||||
|
||||
def _assert_sorted_by_symbol_date(frame: pd.DataFrame) -> None:
|
||||
expected = frame.sort_values(["symbol_id", "date"]).reset_index(drop=True)
|
||||
pd.testing.assert_frame_equal(frame.reset_index(drop=True), expected)
|
||||
|
||||
|
||||
def _assert_metric_dict_is_finite(metrics: dict[str, float]) -> None:
|
||||
for key in (
|
||||
"cumulative_return",
|
||||
"sharpe_annual",
|
||||
"turnover_annual",
|
||||
"max_drawdown",
|
||||
"hit_rate",
|
||||
"n_dates",
|
||||
):
|
||||
assert key in metrics
|
||||
assert np.isfinite(metrics[key])
|
||||
assert "ic" not in metrics
|
||||
assert "rank_ic" not in metrics
|
||||
assert "ir" not in metrics
|
||||
|
||||
|
||||
def test_tiny_workflow_golden_outputs_are_stable(tmp_path):
|
||||
dates = pd.to_datetime(["2024-01-02", "2024-01-03", "2024-01-04"])
|
||||
daily_bars = pd.DataFrame([
|
||||
{
|
||||
"symbol_id": "sh600000",
|
||||
"symbol_name": "A",
|
||||
"date": dates[0],
|
||||
"open": 10.0,
|
||||
"high": 10.0,
|
||||
"low": 10.0,
|
||||
"close": 10.0,
|
||||
"preclose": 10.0,
|
||||
"volume": 1_000_000.0,
|
||||
"amount": 10_000_000.0,
|
||||
"vwap": 10.0,
|
||||
"turn": 1.0,
|
||||
"pctChg": 0.0,
|
||||
"tradestatus": 1,
|
||||
"isST": 0,
|
||||
"peTTM": 1.0,
|
||||
"pbMRQ": 1.0,
|
||||
"psTTM": 1.0,
|
||||
"pcfNcfTTM": 1.0,
|
||||
},
|
||||
{
|
||||
"symbol_id": "sz000001",
|
||||
"symbol_name": "B",
|
||||
"date": dates[0],
|
||||
"open": 20.0,
|
||||
"high": 20.0,
|
||||
"low": 20.0,
|
||||
"close": 20.0,
|
||||
"preclose": 20.0,
|
||||
"volume": 1_000_000.0,
|
||||
"amount": 20_000_000.0,
|
||||
"vwap": 20.0,
|
||||
"turn": 1.0,
|
||||
"pctChg": 0.0,
|
||||
"tradestatus": 1,
|
||||
"isST": 0,
|
||||
"peTTM": 1.0,
|
||||
"pbMRQ": 1.0,
|
||||
"psTTM": 1.0,
|
||||
"pcfNcfTTM": 1.0,
|
||||
},
|
||||
{
|
||||
"symbol_id": "sh600000",
|
||||
"symbol_name": "A",
|
||||
"date": dates[1],
|
||||
"open": 10.0,
|
||||
"high": 12.0,
|
||||
"low": 10.0,
|
||||
"close": 12.0,
|
||||
"preclose": 10.0,
|
||||
"volume": 1_000_000.0,
|
||||
"amount": 10_000_000.0,
|
||||
"vwap": 10.0,
|
||||
"turn": 1.0,
|
||||
"pctChg": 20.0,
|
||||
"tradestatus": 1,
|
||||
"isST": 0,
|
||||
"peTTM": 1.0,
|
||||
"pbMRQ": 1.0,
|
||||
"psTTM": 1.0,
|
||||
"pcfNcfTTM": 1.0,
|
||||
},
|
||||
{
|
||||
"symbol_id": "sz000001",
|
||||
"symbol_name": "B",
|
||||
"date": dates[1],
|
||||
"open": 20.0,
|
||||
"high": 20.0,
|
||||
"low": 18.0,
|
||||
"close": 18.0,
|
||||
"preclose": 20.0,
|
||||
"volume": 1_000_000.0,
|
||||
"amount": 20_000_000.0,
|
||||
"vwap": 20.0,
|
||||
"turn": 1.0,
|
||||
"pctChg": -10.0,
|
||||
"tradestatus": 1,
|
||||
"isST": 0,
|
||||
"peTTM": 1.0,
|
||||
"pbMRQ": 1.0,
|
||||
"psTTM": 1.0,
|
||||
"pcfNcfTTM": 1.0,
|
||||
},
|
||||
{
|
||||
"symbol_id": "sh600000",
|
||||
"symbol_name": "A",
|
||||
"date": dates[2],
|
||||
"open": 12.0,
|
||||
"high": 13.0,
|
||||
"low": 12.0,
|
||||
"close": 13.0,
|
||||
"preclose": 12.0,
|
||||
"volume": 1_000_000.0,
|
||||
"amount": 12_000_000.0,
|
||||
"vwap": 12.0,
|
||||
"turn": 1.0,
|
||||
"pctChg": 8.33,
|
||||
"tradestatus": 1,
|
||||
"isST": 0,
|
||||
"peTTM": 1.0,
|
||||
"pbMRQ": 1.0,
|
||||
"psTTM": 1.0,
|
||||
"pcfNcfTTM": 1.0,
|
||||
},
|
||||
{
|
||||
"symbol_id": "sz000001",
|
||||
"symbol_name": "B",
|
||||
"date": dates[2],
|
||||
"open": 18.0,
|
||||
"high": 21.0,
|
||||
"low": 18.0,
|
||||
"close": 21.0,
|
||||
"preclose": 18.0,
|
||||
"volume": 1_000_000.0,
|
||||
"amount": 18_000_000.0,
|
||||
"vwap": 18.0,
|
||||
"turn": 1.0,
|
||||
"pctChg": 16.67,
|
||||
"tradestatus": 1,
|
||||
"isST": 0,
|
||||
"peTTM": 1.0,
|
||||
"pbMRQ": 1.0,
|
||||
"psTTM": 1.0,
|
||||
"pcfNcfTTM": 1.0,
|
||||
},
|
||||
])
|
||||
alpha = pd.DataFrame({
|
||||
"symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"],
|
||||
"date": [dates[0], dates[0], dates[1], dates[1]],
|
||||
"alpha_name": ["gold_alpha"] * 4,
|
||||
"weight": [1.0, -1.0, -1.0, 1.0],
|
||||
})
|
||||
alpha_path = tmp_path / "gold_alpha.pq"
|
||||
alpha.to_parquet(alpha_path, index=False)
|
||||
|
||||
combo = combine_alphas([str(alpha_path)], "gold_combo")
|
||||
positions = construct_positions(combo, daily_bars, booksize=20_000.0, portfolio_name="gold_port")
|
||||
fills, pnl = ReferenceSimulator().run(positions, daily_bars)
|
||||
|
||||
expected_combo = pd.DataFrame({
|
||||
"symbol_id": ["sh600000", "sh600000", "sz000001", "sz000001"],
|
||||
"date": [dates[0], dates[1], dates[0], dates[1]],
|
||||
"combo_name": ["gold_combo"] * 4,
|
||||
"weight": [1.0, -1.0, -1.0, 1.0],
|
||||
})
|
||||
expected_positions = pd.DataFrame({
|
||||
"symbol_id": ["sh600000", "sh600000", "sz000001", "sz000001"],
|
||||
"date": [dates[0], dates[1], dates[0], dates[1]],
|
||||
"portfolio_name": ["gold_port"] * 4,
|
||||
"target_weight": [0.5, -0.5, -0.5, 0.5],
|
||||
"target_value": [10000.0, -10000.0, -10000.0, 10000.0],
|
||||
"target_shares": [1000.0, -10000.0 / 12.0, -500.0, 10000.0 / 18.0],
|
||||
"position_shares": [1000, -833, -500, 556],
|
||||
"position_value": [10000.0, -9996.0, -10000.0, 10008.0],
|
||||
"price": [10.0, 12.0, 20.0, 18.0],
|
||||
})
|
||||
expected_fills = pd.DataFrame({
|
||||
"symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"],
|
||||
"date": [dates[1], dates[1], dates[2], dates[2]],
|
||||
"portfolio_name": ["gold_port"] * 4,
|
||||
"prev_shares": [0, 0, 1000, -500],
|
||||
"target_shares": [1000, -500, -833, 556],
|
||||
"traded_shares": [1000, -500, -1833, 1056],
|
||||
"realized_shares": [1000, -500, -833, 556],
|
||||
"blocked": [0, 0, 0, 0],
|
||||
"trade_cost": [0.0, 0.0, 0.0, 0.0],
|
||||
})
|
||||
expected_pnl = pd.DataFrame({
|
||||
"date": [dates[1], dates[2]],
|
||||
"portfolio_name": ["gold_port", "gold_port"],
|
||||
"gross_exposure": [21000.0, 22505.0],
|
||||
"net_exposure": [3000.0, 847.0],
|
||||
"pnl": [3000.0, 835.0],
|
||||
"cost": [0.0, 0.0],
|
||||
"turnover": [1.0, 2.0502],
|
||||
"n_positions": [2, 2],
|
||||
})
|
||||
|
||||
pd.testing.assert_frame_equal(combo, expected_combo)
|
||||
pd.testing.assert_frame_equal(positions, expected_positions)
|
||||
pd.testing.assert_frame_equal(fills, expected_fills)
|
||||
pd.testing.assert_frame_equal(pnl, expected_pnl)
|
||||
|
||||
|
||||
def test_generated_alpha_combo_portfolio_execution_workflow(tmp_path):
|
||||
daily_bars = make_generated_daily_bars()
|
||||
|
||||
computed_alpha = compute_alpha(
|
||||
data=daily_bars,
|
||||
alpha_name="generated_reversal_3d",
|
||||
alpha_type="reversal",
|
||||
lookback=3,
|
||||
)
|
||||
|
||||
assert list(computed_alpha.columns) == ALPHA_COLUMNS
|
||||
assert not computed_alpha.empty
|
||||
assert set(computed_alpha["symbol_id"]).issubset(set(GENERATED_SYMBOLS))
|
||||
assert computed_alpha["date"].min() > daily_bars["date"].min()
|
||||
assert computed_alpha["weight"].notna().all()
|
||||
assert computed_alpha["weight"].abs().sum() > 0.0
|
||||
assert {"ic", "rank_ic", "ir"}.isdisjoint(computed_alpha.columns)
|
||||
_assert_sorted_by_symbol_date(computed_alpha)
|
||||
|
||||
alpha_a = make_generated_alpha_weights("alpha_a", zero_date_index=2)
|
||||
alpha_b = make_generated_alpha_weights(
|
||||
"alpha_b",
|
||||
scale=0.5,
|
||||
offset=0.25,
|
||||
zero_date_index=2,
|
||||
)
|
||||
alpha_a_path = tmp_path / "alpha_a.pq"
|
||||
alpha_b_path = tmp_path / "alpha_b.pq"
|
||||
alpha_a.to_parquet(alpha_a_path, index=False)
|
||||
alpha_b.to_parquet(alpha_b_path, index=False)
|
||||
|
||||
identity_combo = combine_alphas([str(alpha_a_path)], "identity_combo")
|
||||
assert list(identity_combo.columns) == COMBO_COLUMNS
|
||||
assert (identity_combo["combo_name"] == "identity_combo").all()
|
||||
pd.testing.assert_frame_equal(
|
||||
identity_combo[["symbol_id", "date", "weight"]],
|
||||
alpha_a[["symbol_id", "date", "weight"]],
|
||||
)
|
||||
|
||||
equal_combo = combine_alphas([str(alpha_a_path), str(alpha_b_path)], "equal_combo")
|
||||
expected_equal_weights = (
|
||||
pd.concat([alpha_a, alpha_b], ignore_index=True)
|
||||
.groupby(["symbol_id", "date"], as_index=False)["weight"]
|
||||
.mean()
|
||||
.sort_values(["symbol_id", "date"])
|
||||
.reset_index(drop=True)
|
||||
)
|
||||
pd.testing.assert_frame_equal(
|
||||
equal_combo[["symbol_id", "date", "weight"]],
|
||||
expected_equal_weights,
|
||||
)
|
||||
|
||||
portfolio_weights = make_generated_combo_weights("workflow_combo", zero_date_index=2)
|
||||
positions = construct_positions(
|
||||
weights_df=portfolio_weights,
|
||||
data_df=daily_bars,
|
||||
booksize=2_000_000.0,
|
||||
portfolio_name="workflow_portfolio",
|
||||
)
|
||||
|
||||
assert list(positions.columns) == POSITION_COLUMNS
|
||||
assert not positions.empty
|
||||
assert (positions["portfolio_name"] == "workflow_portfolio").all()
|
||||
assert pd.api.types.is_integer_dtype(positions["position_shares"])
|
||||
assert np.allclose(
|
||||
positions["position_value"],
|
||||
positions["position_shares"].astype(float) * positions["price"].fillna(0.0),
|
||||
)
|
||||
|
||||
target_gross_by_date = positions.groupby("date")["target_weight"].apply(lambda s: s.abs().sum())
|
||||
nonzero_target_dates = target_gross_by_date[target_gross_by_date > 0.0]
|
||||
assert np.allclose(nonzero_target_dates, 1.0)
|
||||
|
||||
nonzero_share_counts = positions.loc[positions["position_shares"] != 0, "position_shares"].abs()
|
||||
assert (nonzero_share_counts >= 100).all()
|
||||
|
||||
zero_gross_date = generated_sessions(10)[2]
|
||||
previous_date = generated_sessions(10)[1]
|
||||
zero_gross_positions = positions[positions["date"] == zero_gross_date].set_index("symbol_id")
|
||||
previous_positions = positions[positions["date"] == previous_date].set_index("symbol_id")
|
||||
common_symbols = zero_gross_positions.index.intersection(previous_positions.index)
|
||||
assert not common_symbols.empty
|
||||
assert (zero_gross_positions.loc[common_symbols, "target_weight"] == 0.0).all()
|
||||
pd.testing.assert_series_equal(
|
||||
zero_gross_positions.loc[common_symbols, "position_shares"],
|
||||
previous_positions.loc[common_symbols, "position_shares"],
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
simulator = ReferenceSimulator(
|
||||
constraints=[
|
||||
SuspensionConstraint(),
|
||||
PriceLimitConstraint(),
|
||||
VolumeCapConstraint(max_frac=0.02),
|
||||
],
|
||||
cost_bps=5,
|
||||
slippage_bps=5,
|
||||
)
|
||||
fills, pnl = simulator.run(positions, daily_bars)
|
||||
|
||||
assert list(fills.columns) == FILL_COLUMNS
|
||||
assert list(pnl.columns) == PNL_COLUMNS
|
||||
assert not fills.empty
|
||||
assert not pnl.empty
|
||||
assert (fills["realized_shares"] == fills["prev_shares"] + fills["traded_shares"]).all()
|
||||
assert fills["blocked"].sum() > 0
|
||||
|
||||
fill_prices = fills.merge(
|
||||
daily_bars[["symbol_id", "date", "open"]],
|
||||
on=["symbol_id", "date"],
|
||||
how="left",
|
||||
validate="many_to_one",
|
||||
)
|
||||
expected_trade_cost = (
|
||||
fill_prices["traded_shares"].abs()
|
||||
* fill_prices["open"].fillna(0.0)
|
||||
* 10
|
||||
/ 10_000
|
||||
)
|
||||
assert np.allclose(fill_prices["trade_cost"], expected_trade_cost)
|
||||
|
||||
cost_by_date = fills.groupby("date")["trade_cost"].sum()
|
||||
assert np.allclose(
|
||||
pnl.set_index("date")["cost"],
|
||||
cost_by_date.reindex(pnl["date"], fill_value=0.0),
|
||||
)
|
||||
|
||||
booksize_used_by_simulator = positions.groupby("date")["target_value"].apply(lambda s: s.abs().sum()).max()
|
||||
traded_value_by_date = (
|
||||
fill_prices.assign(traded_value=fill_prices["traded_shares"].abs() * fill_prices["open"])
|
||||
.groupby("date")["traded_value"]
|
||||
.sum()
|
||||
)
|
||||
assert np.allclose(
|
||||
pnl.set_index("date")["turnover"],
|
||||
traded_value_by_date.reindex(pnl["date"], fill_value=0.0) / booksize_used_by_simulator,
|
||||
)
|
||||
|
||||
metrics = evaluate_portfolio(positions, daily_bars)
|
||||
_assert_metric_dict_is_finite(metrics)
|
||||
|
||||
|
||||
def test_generated_workflow_outputs_keep_parquet_schema_contracts(tmp_path):
|
||||
daily_bars = make_generated_daily_bars(n_sessions=10, include_missing=False)
|
||||
alpha = compute_alpha(
|
||||
data=daily_bars,
|
||||
alpha_name="schema_reversal",
|
||||
alpha_type="reversal",
|
||||
lookback=3,
|
||||
)
|
||||
alpha_path = tmp_path / "schema_reversal.pq"
|
||||
alpha.to_parquet(alpha_path, index=False)
|
||||
|
||||
combo = combine_alphas([str(alpha_path)], "schema_combo")
|
||||
positions = construct_positions(
|
||||
weights_df=combo,
|
||||
data_df=daily_bars,
|
||||
booksize=1_000_000.0,
|
||||
portfolio_name="schema_portfolio",
|
||||
)
|
||||
fills, pnl = ReferenceSimulator(cost_bps=5, slippage_bps=5).run(positions, daily_bars)
|
||||
|
||||
assert list(alpha.columns) == ALPHA_COLUMNS
|
||||
assert pd.api.types.is_object_dtype(alpha["symbol_id"])
|
||||
assert pd.api.types.is_datetime64_any_dtype(alpha["date"])
|
||||
assert pd.api.types.is_object_dtype(alpha["alpha_name"])
|
||||
assert pd.api.types.is_float_dtype(alpha["weight"])
|
||||
assert not alpha.isna().any().any()
|
||||
assert np.isfinite(alpha["weight"]).all()
|
||||
|
||||
assert list(combo.columns) == COMBO_COLUMNS
|
||||
assert pd.api.types.is_object_dtype(combo["symbol_id"])
|
||||
assert pd.api.types.is_datetime64_any_dtype(combo["date"])
|
||||
assert pd.api.types.is_object_dtype(combo["combo_name"])
|
||||
assert pd.api.types.is_float_dtype(combo["weight"])
|
||||
assert not combo.isna().any().any()
|
||||
assert np.isfinite(combo["weight"]).all()
|
||||
|
||||
assert list(positions.columns) == POSITION_COLUMNS
|
||||
assert pd.api.types.is_integer_dtype(positions["position_shares"])
|
||||
assert pd.api.types.is_datetime64_any_dtype(positions["date"])
|
||||
assert not positions.isna().any().any()
|
||||
position_numeric_columns = [
|
||||
"target_weight",
|
||||
"target_value",
|
||||
"target_shares",
|
||||
"position_value",
|
||||
"price",
|
||||
]
|
||||
assert np.isfinite(positions[position_numeric_columns]).all().all()
|
||||
|
||||
assert list(fills.columns) == FILL_COLUMNS
|
||||
assert pd.api.types.is_integer_dtype(fills["prev_shares"])
|
||||
assert pd.api.types.is_integer_dtype(fills["target_shares"])
|
||||
assert pd.api.types.is_integer_dtype(fills["traded_shares"])
|
||||
assert pd.api.types.is_integer_dtype(fills["realized_shares"])
|
||||
assert pd.api.types.is_integer_dtype(fills["blocked"])
|
||||
assert not fills.isna().any().any()
|
||||
assert np.isfinite(fills["trade_cost"]).all()
|
||||
|
||||
assert list(pnl.columns) == PNL_COLUMNS
|
||||
assert pd.api.types.is_integer_dtype(pnl["n_positions"])
|
||||
assert not pnl.isna().any().any()
|
||||
pnl_numeric_columns = [
|
||||
"gross_exposure",
|
||||
"net_exposure",
|
||||
"pnl",
|
||||
"cost",
|
||||
"turnover",
|
||||
]
|
||||
assert np.isfinite(pnl[pnl_numeric_columns]).all().all()
|
||||
|
||||
|
||||
def test_frozen_real_fixture_runs_high_level_workflow(tmp_path):
|
||||
real_daily_bars = pd.read_parquet(FIXTURE_PATH)
|
||||
|
||||
assert real_daily_bars.shape == (36, 19)
|
||||
assert set(real_daily_bars["symbol_id"]) == set(GENERATED_SYMBOLS)
|
||||
assert real_daily_bars["date"].min() == pd.Timestamp("2024-01-02")
|
||||
assert real_daily_bars["date"].max() == pd.Timestamp("2024-01-12")
|
||||
assert real_daily_bars.groupby("date")["symbol_id"].nunique().eq(4).all()
|
||||
|
||||
reversal_alpha = compute_alpha(
|
||||
data=real_daily_bars,
|
||||
alpha_name="real_reversal_3d",
|
||||
alpha_type="reversal",
|
||||
lookback=3,
|
||||
)
|
||||
reversal_vol_alpha = compute_alpha(
|
||||
data=real_daily_bars,
|
||||
alpha_name="real_reversal_vol_3d",
|
||||
alpha_type="reversal_vol",
|
||||
lookback=3,
|
||||
vol_window=3,
|
||||
)
|
||||
|
||||
reversal_path = tmp_path / "real_reversal.pq"
|
||||
reversal_vol_path = tmp_path / "real_reversal_vol.pq"
|
||||
reversal_alpha.to_parquet(reversal_path, index=False)
|
||||
reversal_vol_alpha.to_parquet(reversal_vol_path, index=False)
|
||||
|
||||
combo = combine_alphas([str(reversal_path), str(reversal_vol_path)], "real_equal_combo")
|
||||
positions = construct_positions(
|
||||
weights_df=combo,
|
||||
data_df=real_daily_bars,
|
||||
booksize=1_000_000.0,
|
||||
portfolio_name="real_fixture_portfolio",
|
||||
)
|
||||
fills, pnl = ReferenceSimulator(cost_bps=5, slippage_bps=5).run(positions, real_daily_bars)
|
||||
metrics = evaluate_portfolio(positions, real_daily_bars)
|
||||
|
||||
assert not reversal_alpha.empty
|
||||
assert not reversal_vol_alpha.empty
|
||||
assert not combo.empty
|
||||
assert not positions.empty
|
||||
assert not fills.empty
|
||||
assert not pnl.empty
|
||||
assert np.isfinite(combo["weight"]).all()
|
||||
assert np.isfinite(positions["target_weight"]).all()
|
||||
assert np.isfinite(pnl[["gross_exposure", "net_exposure", "pnl", "cost", "turnover"]]).all().all()
|
||||
_assert_metric_dict_is_finite(metrics)
|
||||
Reference in New Issue
Block a user