Files
chinese-equity-quant/pipeline/portfolio/market_rules.py
T
Yuxuan Yan 94ab679a75 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>
2026-06-10 11:23:04 +08:00

236 lines
8.6 KiB
Python

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