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,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
|
||||
Reference in New Issue
Block a user