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