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
+3
View File
@@ -10,3 +10,6 @@ data/daily_bars/
alphas/ alphas/
combos/ combos/
reports/ reports/
/portfolio/
/fills/
/pnl/
+3
View File
@@ -5,6 +5,7 @@ Phases:
data — Download daily bars to parquet data — Download daily bars to parquet
alpha — Compute alpha weights from data alpha — Compute alpha weights from data
combo — Combine alphas into a single weight combo — Combine alphas into a single weight
portfolio — Build tradable positions and simulate execution
""" """
import logging import logging
@@ -14,6 +15,7 @@ import click
from pipeline.data.cli import data from pipeline.data.cli import data
from pipeline.alpha.cli import alpha from pipeline.alpha.cli import alpha
from pipeline.combo.cli import combo from pipeline.combo.cli import combo
from pipeline.portfolio.cli import portfolio
from tools.pqcat import pqcat from tools.pqcat import pqcat
from tools.alphaview import alphaview from tools.alphaview import alphaview
@@ -39,6 +41,7 @@ def cli(log_level):
cli.add_command(data) cli.add_command(data)
cli.add_command(alpha) cli.add_command(alpha)
cli.add_command(combo) cli.add_command(combo)
cli.add_command(portfolio)
cli.add_command(pqcat) cli.add_command(pqcat)
cli.add_command(alphaview) cli.add_command(alphaview)
+40
View File
@@ -42,3 +42,43 @@ COMBO_COLUMNS: Final[list[str]] = [
"combo_name", # str: identifies which combo (e.g. 'equal_weight') "combo_name", # str: identifies which combo (e.g. 'equal_weight')
"weight", # float64: combined weight, signed "weight", # float64: combined weight, signed
] ]
# Required columns for portfolio (position) parquet files.
# A position is a tradable integer holding derived from a target weight under
# A-share lot/board rules. Produced by the `portfolio build` phase.
POSITION_COLUMNS: Final[list[str]] = [
"symbol_id", # str
"date", # date
"portfolio_name", # str: identifies this construction run
"target_weight", # float64: w = alpha / sum(|alpha|); signed, sum(|w|)=1
"target_value", # float64: v_target = booksize * w (signed dollar exposure)
"target_shares", # float64: q_target = v_target / price (continuous, signed)
"position_shares", # int64: discretized + repaired integer shares (signed)
"position_value", # float64: position_shares * price (signed)
"price", # float64: construction price (close by default)
]
# Required columns for execution-simulator fill parquet files.
FILL_COLUMNS: Final[list[str]] = [
"symbol_id", # str
"date", # date: the EXECUTION date (open[t+1] of the target date)
"portfolio_name", # str
"prev_shares", # int64: realized position carried in
"target_shares", # int64: requested target for this execution
"traded_shares", # int64: signed delta actually executed
"realized_shares", # int64: resulting position (blocked trades revert to prev)
"blocked", # int: 1 if the trade was (fully or partially) blocked
"trade_cost", # float64: commission + slippage in yuan
]
# Required columns for execution-simulator per-day PnL parquet files.
PNL_COLUMNS: Final[list[str]] = [
"date", # date
"portfolio_name", # str
"gross_exposure", # float64: sum(|position_value|)
"net_exposure", # float64: sum(signed position_value)
"pnl", # float64: daily mark-to-market PnL (yuan), net of cost
"cost", # float64: total trade cost that day (yuan)
"turnover", # float64: sum(|traded_value|) / booksize
"n_positions", # int: count of nonzero holdings
]
+6
View File
@@ -0,0 +1,6 @@
"""Portfolio construction phase.
Turns continuous alpha/combo weights into tradable A-share positions under
date-aware lot/board rules, and simulates execution with market constraints,
costs, and slippage.
"""
+100
View File
@@ -0,0 +1,100 @@
"""CLI for the portfolio construction and execution-simulation phase."""
import os
import click
import pandas as pd
from pipeline.portfolio.constraints import available_constraints, get_constraint
from pipeline.portfolio.construct import construct_positions
from pipeline.portfolio.research import evaluate_portfolio
from pipeline.portfolio.simulator import ReferenceSimulator
@click.group(name="portfolio")
def portfolio():
"""Construct tradable positions from weights and simulate execution."""
@portfolio.command("build")
@click.option("--weights-path", required=True, help="Alpha or combo parquet (signed weights)")
@click.option("--data-path", required=True, help="Data parquet file or dataset directory")
@click.option("--booksize", type=float, required=True, help="Gross dollar exposure B")
@click.option("--portfolio-name", required=True, help="Name for this portfolio run")
@click.option("--price-field", default="close", help="Data column used as construction price")
@click.option("--output-dir", default="portfolio", help="Directory to save the positions parquet")
def build(weights_path, data_path, booksize, portfolio_name, price_field, output_dir):
"""Discretize target weights into a tradable integer position book."""
weights = pd.read_parquet(weights_path)
data = pd.read_parquet(data_path)
result = construct_positions(
weights_df=weights, data_df=data, booksize=booksize,
portfolio_name=portfolio_name, price_field=price_field,
)
os.makedirs(output_dir, exist_ok=True)
out_path = f"{output_dir}/{portfolio_name}.pq"
result.to_parquet(out_path, index=False)
click.echo(f"Saved positions: {out_path} ({len(result):,} rows)")
per_date = result.groupby("date").agg(
gross=("position_value", lambda s: s.abs().sum()),
net=("position_value", "sum"),
)
click.echo(
f"Gross exposure — mean: {per_date['gross'].mean():,.0f} "
f"(target {booksize:,.0f}); |net| mean: {per_date['net'].abs().mean():,.0f}"
)
@portfolio.command("simulate")
@click.option("--positions-path", required=True, help="Positions parquet from `portfolio build`")
@click.option("--data-path", required=True, help="Data parquet file or dataset directory")
@click.option("--constraint", "constraints", multiple=True,
help=f"Trade constraint to apply (repeatable). Options: {available_constraints()}")
@click.option("--cost-bps", type=float, default=0.0, help="Commission in basis points")
@click.option("--slippage-bps", type=float, default=0.0, help="Slippage in basis points")
@click.option("--volume-frac", type=float, default=0.10,
help="Max traded value as a fraction of daily turnover (volume_cap)")
@click.option("--output-dir", default=".", help="Base dir; writes fills/ and pnl/ subdirs")
def simulate(positions_path, data_path, constraints, cost_bps, slippage_bps,
volume_frac, output_dir):
"""Simulate next-open execution under A-share constraints, costs, slippage."""
positions = pd.read_parquet(positions_path)
data = pd.read_parquet(data_path)
name = positions["portfolio_name"].iloc[0] if len(positions) else "portfolio"
built = []
for c in constraints:
params = {"max_frac": volume_frac} if c == "volume_cap" else {}
built.append(get_constraint(c, **params))
sim = ReferenceSimulator(constraints=built, cost_bps=cost_bps, slippage_bps=slippage_bps)
fills, pnl = sim.run(positions, data)
fills_dir = os.path.join(output_dir, "fills")
pnl_dir = os.path.join(output_dir, "pnl")
os.makedirs(fills_dir, exist_ok=True)
os.makedirs(pnl_dir, exist_ok=True)
fills.to_parquet(f"{fills_dir}/{name}.pq", index=False)
pnl.to_parquet(f"{pnl_dir}/{name}.pq", index=False)
click.echo(f"Saved fills: {fills_dir}/{name}.pq ({len(fills):,} rows)")
click.echo(f"Saved pnl: {pnl_dir}/{name}.pq ({len(pnl):,} rows)")
if len(pnl):
click.echo(
f"Total PnL: {pnl['pnl'].sum():,.0f} | total cost: {pnl['cost'].sum():,.0f} "
f"| blocked trades: {int(fills['blocked'].sum()):,}"
)
@portfolio.command("eval")
@click.option("--positions-path", required=True, help="Positions parquet from `portfolio build`")
@click.option("--data-path", required=True, help="Data parquet file or dataset directory")
def eval_(positions_path, data_path):
"""Print Layer-1 research metrics (return/Sharpe/turnover/max-dd/Fitness; no IC)."""
positions = pd.read_parquet(positions_path)
data = pd.read_parquet(data_path)
metrics = evaluate_portfolio(positions, data)
click.echo("Research-portfolio metrics:")
for key, value in metrics.items():
click.echo(f" {key:18s}: {value}")
+141
View File
@@ -0,0 +1,141 @@
"""Trade constraints for the execution simulator.
A constraint answers: *given today's market state, how much may each name be
traded?* Each returns per-name signed delta bounds ``(low, high)`` in shares;
the simulator intersects them (``low = max(lows)``, ``high = min(highs)``) and
clips the desired trade. ``-inf/inf`` mean uncapped, ``0`` blocks that direction.
Constraints self-register via :func:`register_constraint` (mirroring the alpha
registry) so the CLI can select them by name. They consume the abstracted
:class:`~pipeline.portfolio.market_rules.LimitStatus` rather than raw prices,
leaving room for richer fill models (一字板, queues, partial fills) later.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Type
import numpy as np
from pipeline.portfolio.market_rules import LimitStatus
if TYPE_CHECKING:
from pipeline.portfolio.simulator import TradeContext
class TradeConstraint(ABC):
"""Per-name tradeability rule producing signed share-delta bounds."""
name: str = ""
@abstractmethod
def delta_bounds(self, ctx: "TradeContext") -> tuple[np.ndarray, np.ndarray]:
"""Return ``(low, high)`` arrays: the min/max signed share delta allowed."""
def adjust_targets(self, ctx: "TradeContext") -> np.ndarray | None:
"""Optional portfolio-level retargeting hook (e.g. neutrality).
Returns a new target-shares array, or None to leave targets unchanged.
Default: no adjustment. Future industry/beta neutrality can implement a
cheap numpy least-squares projection here — no MIP needed.
"""
return None
_CONSTRAINTS: dict[str, Type[TradeConstraint]] = {}
def register_constraint(cls: Type[TradeConstraint]) -> Type[TradeConstraint]:
"""Class decorator registering a constraint under its ``name``."""
if not (isinstance(cls, type) and issubclass(cls, TradeConstraint)):
raise TypeError(f"{cls!r} is not a TradeConstraint subclass")
key = getattr(cls, "name", "")
if not key:
raise ValueError(f"{cls.__name__} must set a non-empty class attribute `name`")
existing = _CONSTRAINTS.get(key)
if existing is not None and existing is not cls:
raise ValueError(f"Constraint name '{key}' already registered by {existing.__name__}")
_CONSTRAINTS[key] = cls
return cls
def available_constraints() -> list[str]:
"""Sorted names of all registered constraints."""
return sorted(_CONSTRAINTS)
def get_constraint(name: str, **params) -> TradeConstraint:
"""Instantiate a registered constraint by name.
Raises:
KeyError: If ``name`` is not registered.
"""
if name not in _CONSTRAINTS:
raise KeyError(f"Unknown constraint '{name}'. Available: {sorted(_CONSTRAINTS)}")
return _CONSTRAINTS[name](**params)
def _unbounded(n: int) -> tuple[np.ndarray, np.ndarray]:
return np.full(n, -np.inf), np.full(n, np.inf)
@register_constraint
class SuspensionConstraint(TradeConstraint):
"""Suspended names (``tradestatus == 0``) cannot trade at all."""
name = "suspension"
def delta_bounds(self, ctx):
n = len(ctx.target_shares)
low, high = _unbounded(n)
suspended = ctx.slice.tradestatus == 0
low = np.where(suspended, 0.0, low)
high = np.where(suspended, 0.0, high)
return low, high
@register_constraint
class PriceLimitConstraint(TradeConstraint):
"""Limit-up blocks buys; limit-down blocks sells.
Consumes ``ctx.slice.limit_status`` (NORMAL/UP_LIMIT/DOWN_LIMIT), not raw
prices, so future states (e.g. limit-locked 一字板 with zero fill) plug in
by extending the status set.
"""
name = "price_limit"
def delta_bounds(self, ctx):
n = len(ctx.target_shares)
low, high = _unbounded(n)
status = ctx.slice.limit_status
at_up = status == LimitStatus.UP_LIMIT.value
at_down = status == LimitStatus.DOWN_LIMIT.value
high = np.where(at_up, 0.0, high) # cannot buy at the up limit
low = np.where(at_down, 0.0, low) # cannot sell at the down limit
return low, high
@register_constraint
class VolumeCapConstraint(TradeConstraint):
"""Cap traded **value** at a fraction of the day's turnover value.
``|trade_value| ≤ max_frac · amount`` (amount = daily turnover in yuan),
converted to a share cap via the execution price. Value-based, not
share-count based.
"""
name = "volume_cap"
def __init__(self, max_frac: float = 0.10):
self.max_frac = max_frac
def delta_bounds(self, ctx):
amount = np.asarray(ctx.slice.amount, dtype=np.float64)
price = np.asarray(ctx.slice.price, dtype=np.float64)
cap_value = self.max_frac * np.where(np.isfinite(amount), amount, 0.0)
with np.errstate(divide="ignore", invalid="ignore"):
cap_shares = np.where(price > 0, cap_value / price, 0.0)
cap_shares = np.floor(cap_shares)
return -cap_shares, cap_shares
+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
+294
View File
@@ -0,0 +1,294 @@
"""Continuous → tradable position discretization and exposure repair.
Pure-numpy, no I/O. Two steps:
1. :func:`round_to_valid_lot` — snap continuous target shares to the nearest
*valid resting position* given the per-name lot rule AND the current holding
(``prev_shares``). Rounding is state-dependent: a target below the board
minimum cannot *open* a fresh lot, but an existing holding may always be
reduced to 0, and a 科创 odd-lot residual may be sold whole.
2. :func:`repair_exposure` — a two-stage greedy that drives net exposure to ~0
(Stage A) and gross exposure to the booksize (Stage B) while minimizing the
dollar-space tracking error ``sum((v_i - v_target_i)**2)``. Splitting the two
stages avoids the oscillation a single mixed loop suffers (gross repair
breaking net neutrality and vice versa). O(N log N) via lazy heaps.
A "move" adjusts one name by ±``increment`` shares (or closes it to 0 at the
lattice boundary); names are never opened during repair and never flip sign.
"""
from __future__ import annotations
import heapq
import itertools
import numpy as np
def round_to_valid_lot(
target: np.ndarray,
prev_shares: np.ndarray,
min_open: np.ndarray,
increment: np.ndarray,
sell_is_odd_full: np.ndarray | None = None,
) -> np.ndarray:
"""Snap continuous target shares to valid integer resting positions.
The valid resting lattice for a name is ``{0} {min_open + k·increment :
k ≥ 0}`` on each side. Rounding depends on the current holding:
* **Opening** (no holding on the target side) a magnitude below ``min_open``
is not allowed → snaps to 0.
* **Holding** the same side, a sub-minimum target snaps to the nearer of
``{0, min_open}`` (so a reduction can rest at the minimum lot or close out).
* A full liquidation to 0 is always valid (covers the 科创 odd-lot sell:
a residual ``< min_open`` can only be sold whole, i.e. to 0).
* Sign is never flipped unless ``target`` itself flipped sign.
Args:
target: Continuous signed target shares, length N.
prev_shares: Current signed integer holding, length N.
min_open: Per-name minimum open size, length N.
increment: Per-name share increment (> 0), length N.
sell_is_odd_full: Unused for resting validity (odd-lot sells already
resolve to 0); accepted for API symmetry and documentation.
Returns:
``int64`` array of valid resting positions, length N.
"""
target = np.asarray(target, dtype=np.float64)
prev = np.asarray(prev_shares, dtype=np.int64)
min_open = np.asarray(min_open, dtype=np.float64)
increment = np.asarray(increment, dtype=np.float64)
sign = np.sign(target)
mag = np.abs(target)
# Lattice magnitude for mag >= min_open: min_open + round((mag-min_open)/inc)*inc
k = np.maximum(np.round((mag - min_open) / increment), 0.0)
lattice_mag = min_open + k * increment
holding_same_side = (prev != 0) & (np.sign(prev) == sign) & (sign != 0)
# Sub-minimum handling: opening -> 0; holding same side -> nearer of {0, min_open}.
sub_min = mag < min_open
sub_min_mag = np.where(
holding_same_side & (mag >= 0.5 * min_open), min_open, 0.0
)
final_mag = np.where(sub_min, sub_min_mag, lattice_mag)
rounded = sign * final_mag
return rounded.astype(np.int64)
def _exposures(q: np.ndarray, price: np.ndarray) -> tuple[np.ndarray, float, float]:
v = q.astype(np.float64) * price
return v, float(v.sum()), float(np.abs(v).sum())
def repair_exposure(
q_round: np.ndarray,
q_target: np.ndarray,
price: np.ndarray,
increment: np.ndarray,
min_open: np.ndarray,
prev_shares: np.ndarray,
sell_is_odd_full: np.ndarray | None = None,
booksize: float = 1.0,
net_tol: float = 0.02,
gross_tol: float = 0.02,
max_iters: int | None = None,
) -> np.ndarray:
"""Two-stage greedy exposure repair in dollar space.
Stage A drives ``net = sum(v_i)`` toward 0; Stage B drives ``gross =
sum(|v_i|)`` toward ``booksize`` using only moves that keep ``|net|`` within
its tolerance band, so Stage B cannot undo Stage A. Both stages pick, at each
step, the admissible ±``increment`` move with the lowest tracking-error cost
per dollar moved (``ΔTE/|Δv|`` where ``ΔTE = 2·Δv·(v_i - v_target_i) +
Δv²``). Names that round to 0 are never re-opened here.
Tolerances are fractions of ``booksize`` but floored to the lot granularity:
with coarse lots (e.g. pre-2023 100-share main-board lots) exact neutrality
is unreachable, so the floor prevents a deadlock / infinite loop.
Args:
q_round: Integer positions from :func:`round_to_valid_lot`, length N.
q_target: Continuous target shares (the tracking anchor), length N.
price: Per-name price (yuan), length N.
increment: Per-name share increment (> 0), length N.
min_open: Per-name minimum open size, length N.
prev_shares: Current holding (unused directly; reserved for borrow caps).
sell_is_odd_full: Reserved; accepted for API symmetry.
booksize: Target gross exposure ``B``.
net_tol: Net tolerance as a fraction of ``B``.
gross_tol: Gross tolerance as a fraction of ``B``.
max_iters: Hard cap on repair moves (default ``8·N``).
Returns:
``int64`` repaired positions, length N.
"""
q = np.asarray(q_round, dtype=np.int64).copy()
price = np.asarray(price, dtype=np.float64)
increment = np.asarray(increment, dtype=np.int64).astype(np.float64)
min_open = np.asarray(min_open, dtype=np.int64).astype(np.float64)
qt = np.asarray(q_target, dtype=np.float64)
n = len(q)
if n == 0:
return q
vt = np.where(np.isfinite(qt), qt, 0.0) * price # v_target, NaN-safe
tradable = np.isfinite(price) & (price > 0)
step = np.where(tradable, increment * price, np.inf) # dollar per increment
if max_iters is None:
max_iters = 8 * n
# Adaptive absolute tolerances: never finer than the lot granularity.
active_step = step[(q != 0) & tradable]
max_step = float(active_step.max()) if active_step.size else 0.0
min_step = float(active_step.min()) if active_step.size else 0.0
net_tol_abs = max(net_tol * booksize, max_step)
gross_tol_abs = max(gross_tol * booksize, min_step)
net_band = net_tol_abs # Stage B keeps |net| within this band
v, net, gross = _exposures(q, price)
def _move(i: int, grow: bool):
"""Return (dshares, dv, dte) for a grow/shrink move on name i, or None."""
if q[i] == 0 or not tradable[i]:
return None
s = 1 if q[i] > 0 else -1
if grow:
dshares = s * int(increment[i])
else:
mag = abs(int(q[i]))
if mag - increment[i] >= min_open[i]:
dshares = -s * int(increment[i])
else:
dshares = -int(q[i]) # close to 0 (lattice boundary / odd lot)
if dshares == 0:
return None
dv = dshares * price[i]
dte = 2.0 * dv * (v[i] - vt[i]) + dv * dv
return dshares, dv, dte
def _apply(i: int, dshares: int, dv: float):
nonlocal net, gross
old_abs = abs(v[i])
q[i] += dshares
v[i] += dv
net += dv
gross += abs(v[i]) - old_abs
counter = itertools.count()
active_idx = np.nonzero((q != 0) & tradable)[0]
# ---- Stage A: net repair -------------------------------------------------
def _stageA_dir() -> int:
return -1 if net > 0 else 1 # desired sign of dv
iters = 0
while abs(net) > net_tol_abs and iters < max_iters:
want = _stageA_dir() # dv sign we need
heap: list = []
best_key: dict[int, float] = {}
for i in active_idx:
i = int(i)
# For net>0 (want dv<0): shrink longs, grow shorts. Mirror otherwise.
grow = (q[i] < 0) if want < 0 else (q[i] > 0)
mv = _move(i, grow)
if mv is None:
continue
_, dv, dte = mv
if np.sign(dv) != want:
continue
key = dte / abs(dv)
best_key[i] = key
heapq.heappush(heap, (key, next(counter), i, grow))
if not heap:
break
progressed = False
while heap and abs(net) > net_tol_abs and iters < max_iters:
key, _, i, grow = heapq.heappop(heap)
if best_key.get(i) != key:
continue # stale
mv = _move(i, grow)
if mv is None:
best_key.pop(i, None)
continue
dshares, dv, dte = mv
if np.sign(dv) != want:
best_key.pop(i, None)
continue
# Don't overshoot net through 0 by more than the tolerance band.
if abs(net + dv) > abs(net) and abs(net + dv) > net_tol_abs:
best_key.pop(i, None)
continue
_apply(i, dshares, dv)
iters += 1
progressed = True
if q[i] != 0:
nk = _move(i, grow)
if nk is not None:
_, ndv, ndte = nk
if np.sign(ndv) == want:
k2 = ndte / abs(ndv)
best_key[i] = k2
heapq.heappush(heap, (k2, next(counter), i, grow))
continue
best_key.pop(i, None)
if not progressed:
break
# ---- Stage B: gross repair (net-preserving) -----------------------------
iters = 0
active_idx = np.nonzero((q != 0) & tradable)[0]
while abs(gross - booksize) > gross_tol_abs and iters < max_iters:
grow = gross < booksize # need more gross → grow magnitudes; else shrink
heap = []
best_key = {}
for i in active_idx:
i = int(i)
mv = _move(i, grow)
if mv is None:
continue
_, dv, dte = mv
# Net-band filter: never push |net| past the band.
if abs(net + dv) > net_band and abs(net + dv) >= abs(net):
continue
key = dte / abs(dv)
best_key[i] = key
heapq.heappush(heap, (key, next(counter), i, grow))
if not heap:
break
progressed = False
while heap and abs(gross - booksize) > gross_tol_abs and iters < max_iters:
key, _, i, g = heapq.heappop(heap)
if best_key.get(i) != key:
continue
mv = _move(i, g)
if mv is None:
best_key.pop(i, None)
continue
dshares, dv, dte = mv
if abs(net + dv) > net_band and abs(net + dv) >= abs(net):
best_key.pop(i, None)
continue
_apply(i, dshares, dv)
iters += 1
progressed = True
if q[i] != 0:
nk = _move(i, g)
if nk is not None:
_, ndv, ndte = nk
if not (abs(net + ndv) > net_band and abs(net + ndv) >= abs(net)):
k2 = ndte / abs(ndv)
best_key[i] = k2
heapq.heappush(heap, (k2, next(counter), i, g))
continue
best_key.pop(i, None)
if not progressed:
break
return q
+235
View File
@@ -0,0 +1,235 @@
"""Date-aware A-share market rule engine.
The engine is deliberately separated from alpha/portfolio logic: it answers a
single question — *what lot, increment, sell, and price-limit rules apply to a
given symbol on a given date* — from a data-driven table. New rule changes or
new boards are added by appending rows to :data:`RULE_TABLE`; no branching logic
needs editing.
Boards are detected from the internal ``symbol_id`` prefix (``sh600000`` /
``sz000001`` / ``sh688981`` / ``sz300750``).
"""
from __future__ import annotations
import datetime as _dt
from dataclasses import dataclass
from enum import Enum
import numpy as np
class Board(str, Enum):
"""A-share trading board."""
MAIN = "main" # sh60xxxx, sz000/001/002xxx (沪深主板, incl. former SME)
STAR = "star" # sh688xxx (科创板 / STAR Market)
CHINEXT = "chinext" # sz300xxx (创业板 / ChiNext)
UNKNOWN = "unknown"
class LimitStatus(int, Enum):
"""Daily price-limit state of a name on a given date.
Constraints consume this status rather than comparing raw prices, so future
refinements (一字板 / limit-locked, queue priority, partial fills) only add
states or richer fill logic without rewriting constraints.
"""
NORMAL = 0
UP_LIMIT = 1
DOWN_LIMIT = -1
@dataclass(frozen=True)
class Rule:
"""Lot/sell/price-limit rule that applies to one (board, date) cell."""
minimum_open_size: int # min shares to OPEN (buy) a position
share_increment: int # lot granularity above the minimum
sell_rule: str # "lot" | "odd_lot_full" (odd residual sellable whole)
price_limit_pct: float # daily up/down band as a fraction (e.g. 0.10)
@dataclass(frozen=True)
class RuleSpan:
"""A rule that is valid for ``[valid_from, valid_to)`` on a board."""
board: Board
valid_from: _dt.date
valid_to: _dt.date
rule: Rule
# --- The rule table ----------------------------------------------------------
# Append rows here for future rule changes or new boards. Order does not matter;
# get_rule selects by board + [valid_from, valid_to) membership.
_MAIN_INCREMENT_CHANGE = _dt.date(2023, 8, 10)
_DATE_MIN = _dt.date(1990, 1, 1)
_DATE_MAX = _dt.date(2999, 12, 31)
#: Price-limit band applied to ST names, overriding the board band.
ST_PRICE_LIMIT_PCT = 0.05
RULE_TABLE: list[RuleSpan] = [
# 沪深主板: before 2023-08-10 orders must be whole multiples of 100;
# on/after 2023-08-10 the minimum is still 100 but the increment is 1 share.
RuleSpan(Board.MAIN, _DATE_MIN, _MAIN_INCREMENT_CHANGE,
Rule(100, 100, "lot", 0.10)),
RuleSpan(Board.MAIN, _MAIN_INCREMENT_CHANGE, _DATE_MAX,
Rule(100, 1, "lot", 0.10)),
# 科创板 (STAR): from launch, min buy 200, increment 1; a residual holding
# below 200 (an odd lot) may be sold in full.
RuleSpan(Board.STAR, _DATE_MIN, _DATE_MAX,
Rule(200, 1, "odd_lot_full", 0.20)),
# 创业板 (ChiNext): APPROXIMATION — modeled as post-2023 main-board lots
# (min 100, increment 1) with a 20% band. Real ChiNext history (e.g. the
# 2020-08-24 registration-system 20% band, earlier 10% band, 100-share lots)
# can be added as extra rows here WITHOUT touching get_rule's logic.
RuleSpan(Board.CHINEXT, _DATE_MIN, _DATE_MAX,
Rule(100, 1, "lot", 0.20)),
]
#: Fallback for UNKNOWN boards — conservative main-board-like lots, 10% band.
_DEFAULT_RULE = Rule(100, 100, "lot", 0.10)
def detect_board(symbol_id: str) -> Board:
"""Classify a symbol into its trading board from the ``symbol_id`` prefix.
Args:
symbol_id: Internal code like ``sh600000`` / ``sz300750``.
Returns:
The :class:`Board`; :attr:`Board.UNKNOWN` if no rule matches.
"""
if len(symbol_id) < 5:
return Board.UNKNOWN
exchange, code = symbol_id[:2], symbol_id[2:]
if exchange == "sh":
if code.startswith("688"):
return Board.STAR
if code.startswith("60"):
return Board.MAIN
elif exchange == "sz":
if code.startswith("300"):
return Board.CHINEXT
if code[:3] in ("000", "001", "002"):
return Board.MAIN
return Board.UNKNOWN
def _to_date(value) -> _dt.date:
"""Coerce a date / datetime / pandas Timestamp / ISO string to ``date``."""
if isinstance(value, _dt.datetime):
return value.date()
if isinstance(value, _dt.date):
return value
# numpy datetime64 / pandas Timestamp / str all accept str() round-trip.
return _dt.date.fromisoformat(str(value)[:10])
class MarketRule:
"""Resolve lot/sell/price-limit rules for a symbol on a date.
The engine is stateless; instantiate once and reuse across the run.
"""
def __init__(self, table: list[RuleSpan] | None = None,
default_rule: Rule = _DEFAULT_RULE,
st_price_limit_pct: float = ST_PRICE_LIMIT_PCT) -> None:
self._table = table if table is not None else RULE_TABLE
self._default = default_rule
self._st_limit = st_price_limit_pct
def get_rule(self, symbol_id: str, on, is_st: bool = False) -> Rule:
"""Return the :class:`Rule` for ``symbol_id`` on date ``on``.
Args:
symbol_id: Internal symbol code.
on: Trading date (``date``, ``datetime``, ``Timestamp``, or ISO str).
is_st: If True, override the price-limit band with the ST band.
Returns:
The matching :class:`Rule`, with ST band applied if ``is_st``.
"""
day = _to_date(on)
board = detect_board(symbol_id)
rule = self._default
for span in self._table:
if span.board is board and span.valid_from <= day < span.valid_to:
rule = span.rule
break
if is_st:
rule = Rule(rule.minimum_open_size, rule.share_increment,
rule.sell_rule, self._st_limit)
return rule
def get_rules_vectorized(
self, symbol_ids, on, is_st,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Vectorized rule lookup for a whole cross-section on one date.
Args:
symbol_ids: Sequence of ``symbol_id`` strings (length N).
on: The trading date (shared by all names).
is_st: Boolean array (length N), 1/True where the name is ST.
Returns:
Tuple of four numpy arrays, each length N:
``(min_open, increment, sell_is_odd_full, limit_pct)`` with dtypes
``int64, int64, bool, float64``.
"""
symbol_ids = np.asarray(symbol_ids, dtype=object)
is_st = np.asarray(is_st).astype(bool)
n = len(symbol_ids)
min_open = np.empty(n, dtype=np.int64)
increment = np.empty(n, dtype=np.int64)
odd_full = np.empty(n, dtype=bool)
limit_pct = np.empty(n, dtype=np.float64)
# Resolve once per distinct symbol (board only depends on the prefix).
cache: dict[str, Rule] = {}
for i, sym in enumerate(symbol_ids):
rule = cache.get(sym)
if rule is None:
rule = self.get_rule(sym, on, is_st=False)
cache[sym] = rule
min_open[i] = rule.minimum_open_size
increment[i] = rule.share_increment
odd_full[i] = rule.sell_rule == "odd_lot_full"
limit_pct[i] = self._st_limit if is_st[i] else rule.price_limit_pct
return min_open, increment, odd_full, limit_pct
def compute_limit_status(
price, preclose, limit_pct, *, tol: float = 1e-6,
) -> np.ndarray:
"""Classify each name's daily price-limit state.
A name is at the up (down) limit when its price reaches
``preclose * (1 ± limit_pct)`` within ``tol`` relative tolerance.
Args:
price: Reference price array (e.g. the open at which we trade).
preclose: Previous close array.
limit_pct: Per-name daily band fraction.
tol: Relative tolerance for the limit comparison.
Returns:
``int8`` array of :class:`LimitStatus` values (length N). Names with
non-positive or NaN preclose are treated as ``NORMAL``.
"""
price = np.asarray(price, dtype=np.float64)
preclose = np.asarray(preclose, dtype=np.float64)
limit_pct = np.asarray(limit_pct, dtype=np.float64)
status = np.zeros(price.shape, dtype=np.int8)
valid = np.isfinite(price) & np.isfinite(preclose) & (preclose > 0)
up = preclose * (1.0 + limit_pct)
down = preclose * (1.0 - limit_pct)
at_up = valid & (price >= up * (1.0 - tol))
at_down = valid & (price <= down * (1.0 + tol))
status[at_up] = LimitStatus.UP_LIMIT.value
status[at_down] = LimitStatus.DOWN_LIMIT.value
return status
+89
View File
@@ -0,0 +1,89 @@
"""Layer-1 (research) evaluation of a portfolio's target weights.
This is the WorldQuant-style research view: continuous target weights, no lot or
trading constraints. Metrics are return / Sharpe / turnover / max-drawdown /
**Fitness**. There is deliberately **no IC/IR** — consistent with the repo's
convention that an alpha is a position weight, not a return predictor.
Return convention (documented): the target weight formed from information at
date ``t`` earns the *next* period's close-to-close return, i.e. weights are
shifted one day relative to realized returns, so there is no look-ahead:
``R_t = sum_i w_{i,t} · r_{i,t+1}`` normalized by gross exposure.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
#: WorldQuant fitness floor on turnover (avoids dividing by ~0 turnover).
_TURNOVER_FLOOR = 0.125
def evaluate_portfolio(positions_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
"""Evaluate target weights as a continuous research portfolio.
Args:
positions_df: POSITION_COLUMNS (uses ``target_weight``).
data_df: DATA_COLUMNS (uses ``close`` for returns).
Returns:
Dict with ``cumulative_return, sharpe_annual, turnover_annual,
max_drawdown, fitness, hit_rate, n_dates``. No IC key.
"""
close = data_df.pivot_table(
index="date", columns="symbol_id", values="close", aggfunc="first"
).sort_index()
returns = close.pct_change()
weights = positions_df.pivot_table(
index="date", columns="symbol_id", values="target_weight", aggfunc="first"
).sort_index()
common = weights.index.intersection(returns.index)
weights = weights.loc[common]
returns = returns.loc[common]
empty = {
"cumulative_return": 0.0, "sharpe_annual": 0.0, "turnover_annual": 0.0,
"max_drawdown": 0.0, "fitness": 0.0, "hit_rate": 0.0,
"n_dates": len(common),
}
if len(common) < 3:
return empty
gross = weights.abs().sum(axis=1)
# Weights at t earn the return from t to t+1: shift returns back by one.
fwd = returns.shift(-1)
daily = (weights * fwd).sum(axis=1) / gross.replace(0.0, np.nan)
daily = daily.dropna()
if len(daily) < 2:
return empty
cumulative_return = float((1.0 + daily).prod() - 1.0)
mu, sigma = daily.mean(), daily.std()
sharpe_annual = float(np.sqrt(252) * mu / sigma) if sigma > 0 else 0.0
weight_change = weights.diff().abs().sum(axis=1)
prev_gross = gross.shift(1)
daily_turnover = (weight_change / prev_gross.replace(0.0, np.nan)).dropna()
turnover_annual = float(daily_turnover.mean() * 252)
equity = (1.0 + daily).cumprod()
drawdown = (equity - equity.cummax()) / equity.cummax()
max_drawdown = float(drawdown.min())
# Fitness = Sharpe · sqrt(|annualized return| / max(annual turnover, floor)).
ann_return = float(mu * 252)
denom = max(turnover_annual, _TURNOVER_FLOOR)
fitness = float(sharpe_annual * np.sqrt(abs(ann_return) / denom)) if denom > 0 else 0.0
return {
"cumulative_return": cumulative_return,
"sharpe_annual": sharpe_annual,
"turnover_annual": turnover_annual,
"max_drawdown": max_drawdown,
"fitness": fitness,
"hit_rate": float((daily > 0).mean()),
"n_dates": int(len(daily)),
}
+255
View File
@@ -0,0 +1,255 @@
"""Execution simulator: next-open fills under A-share trading constraints.
Execution model (documented convention): a position book targeted from
information available on date ``t`` is executed at ``open[t+1]``. Trades that
violate a :class:`~pipeline.portfolio.constraints.TradeConstraint` (suspension,
price limit, volume cap, …) are clipped; a fully blocked buy leaves the position
at its previous level. Realized PnL marks the *actually filled* book.
The simulator is an ABC + a :class:`ReferenceSimulator`; constraints compose by
intersecting their per-name signed delta bounds.
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
import numpy as np
import pandas as pd
from pipeline.common.schema import FILL_COLUMNS, PNL_COLUMNS
from pipeline.portfolio.constraints import TradeConstraint
from pipeline.portfolio.market_rules import MarketRule, compute_limit_status
logger = logging.getLogger(__name__)
@dataclass
class MarketSlice:
"""Per-name market arrays for one execution date (fixed symbol order)."""
symbol_ids: np.ndarray
date: object
price: np.ndarray # execution/reference price (the open)
preclose: np.ndarray
amount: np.ndarray # daily turnover value (yuan)
tradestatus: np.ndarray # 1 traded / 0 suspended
is_st: np.ndarray
limit_status: np.ndarray # LimitStatus values
close: np.ndarray # close, for marking
@dataclass
class TradeContext:
"""Inputs handed to constraints and the fill routine for one date."""
prev_shares: np.ndarray
target_shares: np.ndarray
slice: MarketSlice
booksize: float
@dataclass
class FillResult:
"""Outcome of executing one date's target against the constraints."""
realized_shares: np.ndarray
traded_shares: np.ndarray
cost: np.ndarray
blocked: np.ndarray
class ExecutionSimulator(ABC):
"""Abstract execution layer. Subclasses define how a target gets filled."""
def __init__(self, constraints: list[TradeConstraint] | None = None,
cost_bps: float = 0.0, slippage_bps: float = 0.0):
self.constraints = constraints or []
self.cost_bps = cost_bps
self.slippage_bps = slippage_bps
@abstractmethod
def fill(self, ctx: TradeContext) -> FillResult:
"""Execute ``ctx.target_shares`` from ``ctx.prev_shares``."""
class ReferenceSimulator(ExecutionSimulator):
"""Reference fill model: clip the desired trade to the composed bounds."""
def fill(self, ctx: TradeContext) -> FillResult:
prev = ctx.prev_shares.astype(np.int64)
target = ctx.target_shares.astype(np.int64)
# Portfolio-level retargeting hooks (e.g. neutrality), if any.
for c in self.constraints:
adjusted = c.adjust_targets(ctx)
if adjusted is not None:
target = np.asarray(adjusted, dtype=np.int64)
desired = target - prev
n = len(prev)
low = np.full(n, -np.inf)
high = np.full(n, np.inf)
for c in self.constraints:
lo, hi = c.delta_bounds(ctx)
low = np.maximum(low, lo)
high = np.minimum(high, hi)
# Clip desired delta into the feasible interval; round toward zero so a
# value/volume cap yields a conservative partial fill.
clipped = np.clip(desired.astype(np.float64), low, high)
traded = np.trunc(clipped).astype(np.int64)
blocked = (traded != desired).astype(np.int64)
realized = prev + traded
open_px = np.where(np.isfinite(ctx.slice.price), ctx.slice.price, 0.0)
trade_value = np.abs(traded.astype(np.float64) * open_px)
cost = trade_value * (self.cost_bps + self.slippage_bps) / 1e4
return FillResult(realized, traded, cost, blocked)
def run(
self,
positions_df: pd.DataFrame,
data_df: pd.DataFrame,
rule_engine: MarketRule | None = None,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Simulate the whole book date by date with next-open execution.
For each signal date ``t`` in ``positions_df`` the target is executed at
the *next* available data date's open. Returns ``(fills, pnl)`` with
FILL_COLUMNS / PNL_COLUMNS.
Args:
positions_df: POSITION_COLUMNS (uses ``target_shares``).
data_df: DATA_COLUMNS (open/close/preclose/amount/tradestatus/isST).
rule_engine: For per-name price-limit bands; default built if None.
Returns:
``(fills_df, pnl_df)``.
"""
rule_engine = rule_engine or MarketRule()
portfolio_name = (
positions_df["portfolio_name"].iloc[0] if len(positions_df) else ""
)
# Booksize ≈ the per-date gross dollar target (constant by construction).
if "target_value" in positions_df.columns and len(positions_df):
per_date_gross = (positions_df.groupby("date")["target_value"]
.apply(lambda s: s.abs().sum()))
booksize = float(per_date_gross.max()) or 1.0
else:
booksize = 1.0
def wide(df, col):
return df.pivot_table(index="date", columns="symbol_id",
values=col, aggfunc="first").sort_index()
tgt = wide(positions_df, "target_shares")
opn = wide(data_df, "open")
close = wide(data_df, "close")
preclose = wide(data_df, "preclose") if "preclose" in data_df.columns else close.shift(1)
amount = wide(data_df, "amount") if "amount" in data_df.columns else opn * np.inf
tstat = wide(data_df, "tradestatus") if "tradestatus" in data_df.columns else opn.notna().astype(float)
st = wide(data_df, "isST") if "isST" in data_df.columns else opn * 0.0
symbols = sorted(set(tgt.columns) | set(opn.columns))
tgt = tgt.reindex(columns=symbols)
opn = opn.reindex(columns=symbols)
close = close.reindex(columns=symbols)
preclose = preclose.reindex(columns=symbols)
amount = amount.reindex(columns=symbols)
tstat = tstat.reindex(columns=symbols)
st = st.reindex(columns=symbols)
sym_arr = np.asarray(symbols, dtype=object)
n = len(symbols)
data_dates = list(close.index)
date_pos = {d: i for i, d in enumerate(data_dates)}
prev_shares = np.zeros(n, dtype=np.int64)
mark_prev = None # last close at which the book was marked
fill_blocks: list[pd.DataFrame] = []
pnl_rows: list[dict] = []
for t in tgt.index:
# Execute at the next available data date after the signal date t.
i = date_pos.get(t)
if i is None or i + 1 >= len(data_dates):
continue
e = data_dates[i + 1]
open_e = opn.loc[e].to_numpy(dtype=np.float64)
close_e = close.loc[e].to_numpy(dtype=np.float64)
pre_e = preclose.loc[e].to_numpy(dtype=np.float64)
amt_e = amount.loc[e].to_numpy(dtype=np.float64)
tstat_e = np.nan_to_num(tstat.loc[e].to_numpy(dtype=np.float64), nan=0.0)
st_e = np.nan_to_num(st.loc[e].to_numpy(dtype=np.float64), nan=0.0)
target = np.nan_to_num(tgt.loc[t].to_numpy(dtype=np.float64), nan=0.0).astype(np.int64)
_, _, _, limit_pct = rule_engine.get_rules_vectorized(sym_arr, e, st_e)
limit_status = compute_limit_status(open_e, pre_e, limit_pct)
mslice = MarketSlice(
symbol_ids=sym_arr, date=e, price=open_e, preclose=pre_e,
amount=amt_e, tradestatus=tstat_e, is_st=st_e,
limit_status=limit_status, close=close_e,
)
ctx = TradeContext(prev_shares, target, mslice, booksize)
res = self.fill(ctx)
# PnL: overnight gap on the OLD book + intraday on the NEW book - cost.
if mark_prev is None:
overnight = 0.0
else:
gap = np.where(np.isfinite(open_e) & np.isfinite(mark_prev),
open_e - mark_prev, 0.0)
overnight = float(np.nansum(prev_shares * gap))
intraday_px = np.where(np.isfinite(close_e) & np.isfinite(open_e),
close_e - open_e, 0.0)
intraday = float(np.nansum(res.realized_shares * intraday_px))
cost_total = float(np.nansum(res.cost))
pnl = overnight + intraday - cost_total
mark_e = np.where(np.isfinite(close_e), close_e, open_e)
realized_value = res.realized_shares * np.where(np.isfinite(mark_e), mark_e, 0.0)
traded_value = np.abs(res.traded_shares * np.where(np.isfinite(open_e), open_e, 0.0))
nz = res.realized_shares != 0
fill_blocks.append(pd.DataFrame({
"symbol_id": symbols,
"date": e,
"portfolio_name": portfolio_name,
"prev_shares": prev_shares,
"target_shares": target,
"traded_shares": res.traded_shares,
"realized_shares": res.realized_shares,
"blocked": res.blocked,
"trade_cost": res.cost,
})[lambda d: (d["traded_shares"] != 0) | (d["realized_shares"] != 0)])
pnl_rows.append({
"date": e,
"portfolio_name": portfolio_name,
"gross_exposure": float(np.abs(realized_value).sum()),
"net_exposure": float(realized_value.sum()),
"pnl": pnl,
"cost": cost_total,
"turnover": float(traded_value.sum() / booksize) if booksize else 0.0,
"n_positions": int(nz.sum()),
})
prev_shares = res.realized_shares
mark_prev = mark_e
fills_df = (pd.concat(fill_blocks, ignore_index=True)[FILL_COLUMNS]
if fill_blocks else pd.DataFrame(columns=FILL_COLUMNS))
pnl_df = (pd.DataFrame(pnl_rows)[PNL_COLUMNS]
if pnl_rows else pd.DataFrame(columns=PNL_COLUMNS))
logger.info(
"Simulated '%s': %d exec days, final gross %.0f, total cost %.0f",
portfolio_name, len(pnl_df),
pnl_df["gross_exposure"].iloc[-1] if len(pnl_df) else 0.0,
pnl_df["cost"].sum() if len(pnl_df) else 0.0,
)
return fills_df, pnl_df
+394
View File
@@ -0,0 +1,394 @@
"""Tests for the portfolio construction & execution phase (no network)."""
import datetime as dt
import numpy as np
import pandas as pd
from pipeline.common.schema import FILL_COLUMNS, PNL_COLUMNS, POSITION_COLUMNS
from pipeline.portfolio.construct import construct_positions, continuous_targets
from pipeline.portfolio.discretize import repair_exposure, round_to_valid_lot
from pipeline.portfolio.market_rules import (
Board,
LimitStatus,
MarketRule,
compute_limit_status,
detect_board,
)
from pipeline.portfolio.research import evaluate_portfolio
from pipeline.portfolio.constraints import (
PriceLimitConstraint,
SuspensionConstraint,
VolumeCapConstraint,
)
from pipeline.portfolio.simulator import (
MarketSlice,
ReferenceSimulator,
TradeContext,
)
# --- fixtures ----------------------------------------------------------------
_SYMBOLS = ("sh600000", "sz000001", "sh688981", "sz300750")
def _make_data(n_days: int = 40, symbols=_SYMBOLS, start="2024-01-01",
st_symbol=None) -> pd.DataFrame:
"""Synthetic long-format DATA_COLUMNS frame, deterministic prices."""
dates = pd.date_range(start, periods=n_days)
rng = np.random.default_rng(0)
frames = []
for i, sym in enumerate(symbols):
close = 50.0 + i * 10 + np.cumsum(rng.standard_normal(n_days))
close = np.abs(close) + 5.0 # keep strictly positive
preclose = np.concatenate([[close[0]], close[:-1]])
frames.append(pd.DataFrame({
"symbol_id": sym,
"symbol_name": sym,
"date": dates,
"open": close,
"high": close,
"low": close,
"close": close,
"preclose": preclose,
"volume": 1_000_000.0,
"amount": 1_000_000.0 * close,
"tradestatus": 1,
"isST": 1 if sym == st_symbol else 0,
}))
return pd.concat(frames, ignore_index=True)
def _make_weights(data: pd.DataFrame, name="combo") -> pd.DataFrame:
"""Demeaned per-date signed weights so the cross-section is dollar-neutral."""
close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index()
raw = -close.pct_change(5)
demeaned = raw.sub(raw.mean(axis=1), axis=0)
long = demeaned.reset_index().melt(id_vars="date", var_name="symbol_id",
value_name="weight").dropna()
long["combo_name"] = name
return long[["symbol_id", "date", "combo_name", "weight"]]
# --- detect_board ------------------------------------------------------------
def test_detect_board():
assert detect_board("sh600000") == Board.MAIN
assert detect_board("sz000001") == Board.MAIN
assert detect_board("sz002594") == Board.MAIN
assert detect_board("sh688981") == Board.STAR
assert detect_board("sz300750") == Board.CHINEXT
assert detect_board("bj830000") == Board.UNKNOWN
# --- MarketRule date transitions ---------------------------------------------
def test_main_board_increment_transition():
rules = MarketRule()
before = rules.get_rule("sh600000", dt.date(2023, 8, 9))
after = rules.get_rule("sh600000", dt.date(2023, 8, 10))
assert (before.minimum_open_size, before.share_increment) == (100, 100)
assert (after.minimum_open_size, after.share_increment) == (100, 1)
assert before.price_limit_pct == 0.10
def test_star_rule_and_odd_lot():
rule = MarketRule().get_rule("sh688981", dt.date(2024, 1, 1))
assert rule.minimum_open_size == 200
assert rule.share_increment == 1
assert rule.sell_rule == "odd_lot_full"
assert rule.price_limit_pct == 0.20
def test_st_overrides_price_limit():
rule = MarketRule().get_rule("sh600000", dt.date(2024, 1, 1), is_st=True)
assert rule.price_limit_pct == 0.05
def test_get_rules_vectorized():
rules = MarketRule()
syms = np.array(["sh600000", "sh688981", "sz300750"], dtype=object)
min_open, inc, odd, limit = rules.get_rules_vectorized(
syms, dt.date(2024, 1, 1), np.array([0, 0, 0])
)
assert list(min_open) == [100, 200, 100]
assert list(inc) == [1, 1, 1]
assert list(odd) == [False, True, False]
assert list(limit) == [0.10, 0.20, 0.20]
def test_compute_limit_status():
price = np.array([110.0, 90.0, 100.0])
preclose = np.array([100.0, 100.0, 100.0])
limit_pct = np.array([0.10, 0.10, 0.10])
status = compute_limit_status(price, preclose, limit_pct)
assert status[0] == LimitStatus.UP_LIMIT.value
assert status[1] == LimitStatus.DOWN_LIMIT.value
assert status[2] == LimitStatus.NORMAL.value
# --- continuous targets ------------------------------------------------------
def test_continuous_targets_normalization():
alpha = np.array([2.0, -1.0, -1.0, 0.5])
price = np.array([10.0, 20.0, 5.0, 8.0])
w, v_target, q_target = continuous_targets(alpha, price, booksize=1e6)
assert np.isclose(np.abs(w).sum(), 1.0)
assert np.isclose(w.sum(), alpha.sum() / np.abs(alpha).sum())
assert np.allclose(v_target, 1e6 * w)
assert np.allclose(q_target, v_target / price)
def test_continuous_targets_demeaned_is_neutral():
alpha = np.array([2.0, -1.0, -1.0])
w, _, _ = continuous_targets(alpha, np.array([10.0, 10.0, 10.0]), 1e6)
assert abs(w.sum()) < 1e-12
def test_continuous_targets_guards_bad_price():
alpha = np.array([1.0, -1.0])
w, v, q = continuous_targets(alpha, np.array([np.nan, 10.0]), 1e6)
assert w[0] == 0.0 and q[0] == 0.0
# --- round_to_valid_lot (state-dependent) ------------------------------------
def test_round_main_board_pre2023_multiples_of_100():
target = np.array([250.0, -180.0, 40.0])
prev = np.zeros(3, dtype=np.int64)
min_open = np.array([100, 100, 100])
inc = np.array([100, 100, 100])
out = round_to_valid_lot(target, prev, min_open, inc)
# 250 -> 200 or 300 (nearest is 200? round(150/100)=2 ->300). 250/100 -> k=round(1.5)=2 ->300
assert out[0] in (200, 300)
assert out[1] in (-200, -100)
assert out[2] == 0 # sub-min, no holding
def test_round_post2023_increment_one():
target = np.array([153.4])
out = round_to_valid_lot(target, np.zeros(1, np.int64),
np.array([100]), np.array([1]))
assert out[0] == 153
def test_round_star_min_200():
target = np.array([150.0, 240.6])
prev = np.zeros(2, dtype=np.int64)
out = round_to_valid_lot(target, prev, np.array([200, 200]), np.array([1, 1]),
np.array([True, True]))
assert out[0] == 0 # below 200, no holding -> cannot open
assert out[1] == 241 # 200 + round(40.6)
def test_round_reduction_can_liquidate_below_min():
# Holding 300, target wants ~40 shares -> nearest valid resting is 0.
target = np.array([40.0])
prev = np.array([300], dtype=np.int64)
out = round_to_valid_lot(target, prev, np.array([100]), np.array([100]))
assert out[0] == 0
def test_round_star_odd_lot_residual_sells_to_zero():
# Holding 150 STAR shares (odd lot), target reduces -> must go to 0.
target = np.array([20.0])
prev = np.array([150], dtype=np.int64)
out = round_to_valid_lot(target, prev, np.array([200]), np.array([1]),
np.array([True]))
assert out[0] == 0
def test_round_no_sign_flip_when_target_same_sign():
target = np.array([500.0])
prev = np.array([-300], dtype=np.int64)
out = round_to_valid_lot(target, prev, np.array([100]), np.array([100]))
assert out[0] > 0 # follows target sign, not prev
# --- repair_exposure (two-stage) ---------------------------------------------
def _gross_net(q, price):
v = q.astype(float) * price
return float(np.abs(v).sum()), float(v.sum())
def test_repair_drives_net_and_gross():
rng = np.random.default_rng(1)
n = 200
price = rng.uniform(5, 100, n)
alpha = rng.standard_normal(n)
alpha -= alpha.mean()
B = 1e7
_, _, q_target = continuous_targets(alpha, price, B)
min_open = np.full(n, 100)
inc = np.full(n, 1)
prev = np.zeros(n, dtype=np.int64)
q_round = round_to_valid_lot(q_target, prev, min_open, inc)
pos = repair_exposure(q_round, q_target, price, inc, min_open, prev,
booksize=B, net_tol=0.01, gross_tol=0.01)
gross, net = _gross_net(pos, price)
assert abs(net) <= 0.02 * B + price.max() * 1 # within band + a step
assert abs(gross - B) <= 0.02 * B + price.max() * 1
def test_repair_does_not_worsen_tracking_error_grossly():
rng = np.random.default_rng(2)
n = 150
price = rng.uniform(5, 100, n)
alpha = rng.standard_normal(n)
alpha -= alpha.mean()
B = 5e6
_, v_target, q_target = continuous_targets(alpha, price, B)
inc = np.full(n, 1)
min_open = np.full(n, 100)
prev = np.zeros(n, dtype=np.int64)
q_round = round_to_valid_lot(q_target, prev, min_open, inc)
pos = repair_exposure(q_round, q_target, price, inc, min_open, prev,
booksize=B, net_tol=0.01, gross_tol=0.01)
te_round = np.sum((q_round * price - v_target) ** 2)
te_pos = np.sum((pos * price - v_target) ** 2)
# Repair should keep TE comparable (not blow it up by orders of magnitude).
assert te_pos <= 5.0 * te_round + B
def test_repair_scales_to_4000_names():
rng = np.random.default_rng(3)
n = 4000
price = rng.uniform(5, 100, n)
alpha = rng.standard_normal(n)
alpha -= alpha.mean()
B = 1e8
_, _, q_target = continuous_targets(alpha, price, B)
inc = np.full(n, 1)
min_open = np.full(n, 100)
prev = np.zeros(n, dtype=np.int64)
q_round = round_to_valid_lot(q_target, prev, min_open, inc)
pos = repair_exposure(q_round, q_target, price, inc, min_open, prev, booksize=B)
gross, net = _gross_net(pos, price)
assert abs(net) <= 0.03 * B
assert abs(gross - B) <= 0.03 * B
# --- construct_positions -----------------------------------------------------
def test_construct_positions_schema():
data = _make_data()
weights = _make_weights(data)
pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1")
assert list(pos.columns) == POSITION_COLUMNS
assert (pos["portfolio_name"] == "run1").all()
assert pos["position_shares"].dtype == np.int64
def test_construct_positions_threads_state_and_closes_absent():
data = _make_data()
weights = _make_weights(data)
# Drop the last 3 dates of one symbol so it goes "absent" → must be closed.
sym = "sz300750"
last_dates = sorted(weights["date"].unique())[-3:]
weights = weights[~((weights["symbol_id"] == sym) &
(weights["date"].isin(last_dates)))]
pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1")
final_date = pos["date"].max()
final = pos[(pos["symbol_id"] == sym) & (pos["date"] == final_date)]
# Either no row, or a zeroed position for the absent name on the final date.
assert final.empty or (final["position_shares"] == 0).all()
# --- constraints -------------------------------------------------------------
def _slice(n, **over):
base = dict(
symbol_ids=np.array([f"s{i}" for i in range(n)], dtype=object),
date=dt.date(2024, 1, 2),
price=np.full(n, 10.0),
preclose=np.full(n, 10.0),
amount=np.full(n, 1e6),
tradestatus=np.ones(n),
is_st=np.zeros(n),
limit_status=np.zeros(n, dtype=np.int8),
close=np.full(n, 10.0),
)
base.update(over)
return MarketSlice(**base)
def test_suspension_blocks_all_delta():
n = 2
sl = _slice(n, tradestatus=np.array([1.0, 0.0]))
ctx = TradeContext(np.zeros(n, np.int64), np.array([100, 100]), sl, 1e6)
low, high = SuspensionConstraint().delta_bounds(ctx)
assert low[1] == 0.0 and high[1] == 0.0
assert np.isinf(high[0])
def test_price_limit_blocks_directionally():
n = 2
sl = _slice(n, limit_status=np.array([LimitStatus.UP_LIMIT.value,
LimitStatus.DOWN_LIMIT.value], dtype=np.int8))
ctx = TradeContext(np.zeros(n, np.int64), np.array([100, -100]), sl, 1e6)
low, high = PriceLimitConstraint().delta_bounds(ctx)
assert high[0] == 0.0 # up-limit: cannot buy
assert low[1] == 0.0 # down-limit: cannot sell
def test_volume_cap_uses_traded_value():
n = 1
# amount=1e6, price=10, max_frac=0.1 -> cap value 1e5 -> cap 1e4 shares.
sl = _slice(n, amount=np.array([1e6]), price=np.array([10.0]))
ctx = TradeContext(np.zeros(n, np.int64), np.array([99999]), sl, 1e6)
low, high = VolumeCapConstraint(max_frac=0.1).delta_bounds(ctx)
assert high[0] == 10000.0
assert low[0] == -10000.0
# --- ReferenceSimulator ------------------------------------------------------
def test_simulator_next_open_and_blocked_buy_holds_prev():
data = _make_data(n_days=15)
weights = _make_weights(data)
pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1")
sim = ReferenceSimulator(constraints=[SuspensionConstraint()],
cost_bps=5, slippage_bps=5)
fills, pnl = sim.run(pos, data)
assert list(fills.columns) == FILL_COLUMNS
assert list(pnl.columns) == PNL_COLUMNS
# realized = prev + traded must always hold.
assert (fills["realized_shares"] == fills["prev_shares"] + fills["traded_shares"]).all()
def test_simulator_blocked_buy_when_suspended():
n = 1
sim = ReferenceSimulator(constraints=[SuspensionConstraint()])
sl = _slice(n, tradestatus=np.array([0.0]))
ctx = TradeContext(np.array([0], np.int64), np.array([500]), sl, 1e6)
res = sim.fill(ctx)
assert res.traded_shares[0] == 0
assert res.realized_shares[0] == 0
assert res.blocked[0] == 1
def test_simulator_cost_is_positive_when_trading():
n = 1
sim = ReferenceSimulator(constraints=[], cost_bps=10, slippage_bps=5)
sl = _slice(n, price=np.array([20.0]))
ctx = TradeContext(np.array([0], np.int64), np.array([1000]), sl, 1e6)
res = sim.fill(ctx)
assert res.traded_shares[0] == 1000
# 1000 * 20 * (15/1e4) = 30
assert np.isclose(res.cost[0], 1000 * 20 * 15 / 1e4)
# --- evaluate_portfolio ------------------------------------------------------
def test_evaluate_portfolio_keys_no_ic():
data = _make_data()
weights = _make_weights(data)
pos = construct_positions(weights, data, booksize=1e6, portfolio_name="run1")
metrics = evaluate_portfolio(pos, data)
for key in ("cumulative_return", "sharpe_annual", "turnover_annual",
"max_drawdown", "fitness", "hit_rate", "n_dates"):
assert key in metrics
assert "ic" not in metrics
assert "rank_ic" not in metrics