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
+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