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:
@@ -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.
|
||||
"""
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user