Files
2026-06-12 18:41:18 +08:00

177 lines
6.9 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.
"""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 from next open to
the following open (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)
tradable = np.isfinite(price) & (price > 0)
a = np.where(np.isfinite(alpha) & tradable, 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
q_target = np.zeros_like(v_target)
np.divide(v_target, price, out=q_target, where=tradable)
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 in ``position_shares`` while leaving the target fields at 0.
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)
if np.abs(w).sum() <= 0:
logger.warning(
"Portfolio '%s': zero-gross target on %s; carrying previous positions.",
portfolio_name, d,
)
pos = prev_shares.copy()
else:
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), 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