Files
2026-06-16 17:37:16 +08:00

269 lines
11 KiB
Python

"""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. Trading
cost defaults to a simplified open-execution proportional cash-cost model.
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.costs import CostModel, SimpleProportionalCostModel
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,
cost_model: CostModel | None = None):
self.constraints = constraints or []
self.cost_model = cost_model or SimpleProportionalCostModel(
cost_bps=cost_bps, 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
cost = self.cost_model.compute(
traded_shares=traded,
execution_price=ctx.slice.price,
side=np.sign(traded),
date=ctx.slice.date,
metadata={
"symbol_ids": ctx.slice.symbol_ids,
"booksize": ctx.booksize,
"market_slice": ctx.slice,
},
)
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 constructed ``position_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, "position_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))
data_index = close.index
tgt = tgt.reindex(columns=symbols)
opn = opn.reindex(index=data_index, columns=symbols)
close = close.reindex(columns=symbols)
preclose = preclose.reindex(index=data_index, columns=symbols)
amount = amount.reindex(index=data_index, columns=symbols)
tstat = tstat.reindex(index=data_index, columns=symbols)
st = st.reindex(index=data_index, 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