feat: add portfolio phase — discretize alpha weights into tradable positions

Adds a fourth pipeline phase modeling A-share microstructure: lot sizes,
the 2023-08-10 Main Board increment change, STAR 200-share minimum/odd-lot
rules, limit-up/down, suspensions, volume caps, costs, and slippage.

Two layers: research (continuous weights → return/Sharpe/turnover/Fitness,
no IC per repo convention) and execution (state-dependent lot rounding +
two-stage greedy exposure repair + next-open reference simulator).

Wires `portfolio build/simulate/eval` into the CLI and adds the
POSITION/FILL/PNL schema contracts. Covered by tests/test_portfolio.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Yuxuan Yan
2026-06-10 11:23:04 +08:00
parent 7faeb77c50
commit 94ab679a75
12 changed files with 1734 additions and 3 deletions
+171
View File
@@ -0,0 +1,171 @@
"""Continuous portfolio construction and the date-ordered position driver.
Layer-1 (research) math lives in :func:`continuous_targets`: it turns a signed
weight vector into target weights, dollar exposures, and continuous shares.
:func:`construct_positions` is the Layer-2 driver — it threads ``prev_shares``
across dates (positions are stateful, unlike alphas/combos), discretizing and
repairing each day's target into a tradable integer book.
Return-convention note: weights here are *target allocations*. The research
evaluation in :mod:`pipeline.portfolio.research` marks them close-to-close on the
*next* period (no look-ahead); the execution simulator marks the actually-filled
book at the next open. See those modules for details.
"""
from __future__ import annotations
import logging
import numpy as np
import pandas as pd
from pipeline.common.schema import POSITION_COLUMNS
from pipeline.portfolio.discretize import repair_exposure, round_to_valid_lot
from pipeline.portfolio.market_rules import MarketRule
logger = logging.getLogger(__name__)
def continuous_targets(
alpha: np.ndarray, price: np.ndarray, booksize: float
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Continuous (research) portfolio targets from a signed weight vector.
``w = alpha / sum(|alpha|)`` so ``sum(|w|) = 1`` and, because the upstream
alpha is demeaned, ``sum(w) ≈ 0`` (dollar-neutral). Then
``v_target = booksize · w`` and ``q_target = v_target / price``.
NaN alphas and non-positive / NaN prices are treated as a 0 target.
Args:
alpha: Signed weight vector, length N.
price: Per-name price (yuan), length N.
booksize: Gross dollar exposure ``B``.
Returns:
``(w, v_target, q_target)``, each a float array of length N.
"""
alpha = np.asarray(alpha, dtype=np.float64)
price = np.asarray(price, dtype=np.float64)
a = np.where(np.isfinite(alpha), alpha, 0.0)
gross = np.abs(a).sum()
if gross <= 0:
zeros = np.zeros_like(a)
return zeros, zeros.copy(), zeros.copy()
w = a / gross
v_target = booksize * w
tradable = np.isfinite(price) & (price > 0)
q_target = np.where(tradable, v_target / np.where(tradable, price, 1.0), 0.0)
# Names without a tradable price get no target exposure.
w = np.where(tradable, w, 0.0)
v_target = np.where(tradable, v_target, 0.0)
return w, v_target, q_target
def _pivot(df: pd.DataFrame, value: str, weight_col: str | None = None) -> pd.DataFrame:
col = weight_col or value
return df.pivot_table(
index="date", columns="symbol_id", values=col, aggfunc="first"
).sort_index()
def construct_positions(
weights_df: pd.DataFrame,
data_df: pd.DataFrame,
booksize: float,
portfolio_name: str,
rule_engine: MarketRule | None = None,
price_field: str = "close",
net_tol: float = 0.02,
gross_tol: float = 0.02,
) -> pd.DataFrame:
"""Build a tradable position book from target weights, day by day.
Pivots weights and prices to a wide grid on a fixed symbol column order,
then iterates dates in ascending order carrying an integer ``prev_shares``
vector. Each date: continuous targets → state-dependent lot rounding →
two-stage exposure repair. Names absent on a date get weight 0 (which closes
any stale holding). An empty / zero-gross cross-section carries the book
unchanged.
Args:
weights_df: Long frame with ``symbol_id, date, weight`` (ALPHA/COMBO).
data_df: Long frame with DATA_COLUMNS (prices, tradestatus, isST).
booksize: Gross dollar exposure ``B``.
portfolio_name: Identifier stored in the ``portfolio_name`` column.
rule_engine: A :class:`MarketRule`; a default one is built if None.
price_field: Data column used as the construction price (default close).
net_tol: Net tolerance (fraction of B) passed to the repair.
gross_tol: Gross tolerance (fraction of B) passed to the repair.
Returns:
Long DataFrame with POSITION_COLUMNS, sorted by ``(symbol_id, date)``.
"""
rule_engine = rule_engine or MarketRule()
price_wide = _pivot(data_df, price_field)
w_wide = _pivot(weights_df, "weight")
st_wide = _pivot(data_df, "isST") if "isST" in data_df.columns else None
# Fixed, sorted symbol-column order shared across the whole run.
symbols = sorted(set(price_wide.columns) | set(w_wide.columns))
price_wide = price_wide.reindex(columns=symbols)
w_wide = w_wide.reindex(columns=symbols)
if st_wide is not None:
st_wide = st_wide.reindex(columns=symbols)
dates = sorted(set(price_wide.index) & set(w_wide.index))
if not dates:
logger.warning("No overlapping dates between weights and data; empty result.")
return pd.DataFrame(columns=POSITION_COLUMNS)
sym_arr = np.asarray(symbols, dtype=object)
n = len(symbols)
prev_shares = np.zeros(n, dtype=np.int64)
blocks: list[pd.DataFrame] = []
for d in dates:
price = price_wide.loc[d].to_numpy(dtype=np.float64)
alpha = w_wide.loc[d].to_numpy(dtype=np.float64)
is_st = (
st_wide.loc[d].fillna(0).to_numpy() if st_wide is not None
else np.zeros(n)
)
min_open, increment, odd_full, _ = rule_engine.get_rules_vectorized(
sym_arr, d, is_st
)
w, v_target, q_target = continuous_targets(alpha, price, booksize)
q_round = round_to_valid_lot(q_target, prev_shares, min_open, increment, odd_full)
pos = repair_exposure(
q_round, q_target, price, increment, min_open, prev_shares, odd_full,
booksize=booksize, net_tol=net_tol, gross_tol=gross_tol,
)
safe_price = np.where(np.isfinite(price), price, 0.0)
blocks.append(pd.DataFrame({
"symbol_id": symbols,
"date": d,
"portfolio_name": portfolio_name,
"target_weight": w,
"target_value": v_target,
"target_shares": q_target,
"position_shares": pos,
"position_value": pos.astype(np.float64) * safe_price,
"price": price,
}))
prev_shares = pos
result = pd.concat(blocks, ignore_index=True)
# Drop names that are flat AND have no target (keep the active universe tidy).
active = (result["position_shares"] != 0) | (result["target_weight"] != 0)
result = result[active]
result = result[POSITION_COLUMNS]
result = result.sort_values(["symbol_id", "date"]).reset_index(drop=True)
n_dates = result["date"].nunique()
logger.info(
"Portfolio '%s': %d symbols × %d dates, booksize %.0f",
portfolio_name, result["symbol_id"].nunique(), n_dates, booksize,
)
return result