94ab679a75
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>
142 lines
4.9 KiB
Python
142 lines
4.9 KiB
Python
"""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
|