256 lines
10 KiB
Python
256 lines
10 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.
|
|
|
|
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 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))
|
|
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
|