Document and abstract portfolio trading costs

This commit is contained in:
Yuxuan Yan
2026-06-10 15:41:38 +08:00
parent 4a477b8f75
commit 534b91aaa4
6 changed files with 272 additions and 8 deletions
+2
View File
@@ -69,6 +69,8 @@ Data is stored **long/tidy**, not wide, as a Hive-partitioned dataset keyed by `
`portfolio simulate` must execute `position_shares`, not continuous `target_shares`. It fills at the next available open and clips desired deltas through repeatable constraints (`suspension`, `price_limit`, `volume_cap`). `portfolio eval` uses `target_weight` for a continuous research view, so zero-gross carry dates remain flat there. Keep IC/IR out of portfolio metrics too.
Trading cost uses the simplified open-execution proportional cash-cost model in `docs/portfolio_trading_cost_model.md`: `abs(traded_shares * open) * (cost_bps + slippage_bps) / 10000`. Slippage is cash cost only; do not also adjust execution prices for slippage.
## Alphas: factory + plugin pattern
Each alpha is a class subclassing `BaseAlpha` (`pipeline/alpha/base.py`), living in its own module. It implements `signal(close) -> wide DataFrame` (the raw score); the base class's `to_weights` cross-sectionally z-scores that into position weights (override for custom normalization). Subclasses declare their own typed `__init__` params (e.g. `lookback`, `vol_window`, or anything custom).
+3 -1
View File
@@ -210,7 +210,9 @@ uv run python cli.py portfolio build \
Executes the constructed `position_shares` book at the next available open,
clipping trades through repeatable constraints. It writes `fills/<name>.pq` and
`pnl/<name>.pq`.
`pnl/<name>.pq`. Trading costs use the simplified open-execution proportional
cash-cost model documented in
[`docs/portfolio_trading_cost_model.md`](docs/portfolio_trading_cost_model.md).
| Option | Default | Description |
| --- | --- | --- |
+133
View File
@@ -0,0 +1,133 @@
# Portfolio Trading Cost Model
This document describes the trading cost model used by `portfolio simulate`.
The current implementation is a simplified open-execution proportional cost
model. It is intentionally small, explicit, and easy to audit.
## Open-Execution Timeline
The simulator runs once per trading day:
1. A constructed portfolio row provides the target book for an execution date.
In the current file layout, a target dated `t` is executed at the next
available market date `d = next(t)`.
2. Trades are executed at `open[d]`.
3. Realized positions are held during the trading day.
4. Daily PnL is marked from `open[d]` to `close[d]` on the newly realized book,
plus any overnight gap from the previous realized holdings.
5. Trading cost is charged only on actually realized `traded_shares`, after all
constraints have clipped the desired trade.
This means a fully blocked order has `traded_shares = 0` and therefore zero
trading cost.
## Current Formula
For each symbol:
```text
trade_value_i = abs(traded_shares_i * execution_price_i)
trade_cost_i = trade_value_i * (cost_bps + slippage_bps) / 10000
```
where:
```text
execution_price_i = open_price_i
```
`cost_bps` is the proportional explicit trading-cost rate in basis points.
`slippage_bps` is modeled as an additional cash cost in basis points. The two
rates are added linearly. The CLI options `--cost-bps` and `--slippage-bps`
both default to `0.0`.
Example:
```text
traded_shares = 1000
execution_price = 20 yuan
cost_bps = 10
slippage_bps = 5
abs(1000 * 20) * 15 / 10000 = 30 yuan
```
## Slippage Convention
Slippage is not applied by changing the execution price. It is charged only as
a cash cost through `trade_cost`.
Do not double-count slippage by doing both:
```text
execution_price = open * (1 +/- slippage_bps / 10000)
trade_cost += trade_value * slippage_bps / 10000
```
The simulator should execute at the open price and subtract the slippage cash
cost from PnL.
## Relationship To The Simulator
`ReferenceSimulator.fill()` clips desired trades through constraints first, then
passes the actual `traded_shares` to the cost model. The per-name result is
stored in the fills parquet as `trade_cost`.
`ReferenceSimulator.run()` sums per-name `trade_cost` into the daily PnL row's
`cost` column and subtracts that total from daily PnL:
```text
pnl = overnight + intraday - cost_total
```
## What This Model Does Not Cover
The current model intentionally does not model:
- Minimum commissions.
- Buy/sell asymmetric fees.
- Sell-side stamp duty.
- Exchange handling fees.
- Regulatory fees.
- Transfer fees.
- Date-aware fee schedule changes.
- Nonlinear price impact.
- Auction liquidity / queue effects.
- Partial fills caused by open auction depth.
These omissions are deliberate. The current model is the default reference
model, not a detailed brokerage fee simulator.
## Future Extension
The simulator is structured around a cost model abstraction:
```python
class CostModel:
def compute(
self,
traded_shares,
execution_price,
side,
date,
metadata,
):
...
```
The current implementation is `SimpleProportionalCostModel`.
A future `AShareDetailedCostModel` can add:
- Commission, optionally subject to minimum commission.
- Sell-side stamp duty.
- Transfer fee.
- Exchange handling fee.
- Regulatory fee.
- Date-aware fee rates.
- Separate buy-side and sell-side rates.
- Optional nonlinear slippage / market-impact model.
Any future model must preserve the same high-level simulator contract: costs
are computed from realized trades after constraints, and slippage must not be
counted both through execution-price adjustment and cash cost.
+50
View File
@@ -0,0 +1,50 @@
"""Trading cost models for portfolio execution simulation."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Mapping
import numpy as np
class CostModel(ABC):
"""Interface for per-name execution cost models."""
@abstractmethod
def compute(
self,
traded_shares: np.ndarray,
execution_price: np.ndarray,
side: np.ndarray,
date,
metadata: Mapping[str, object] | None = None,
) -> np.ndarray:
"""Return per-name trading cost in yuan."""
@dataclass(frozen=True)
class SimpleProportionalCostModel(CostModel):
"""Simplified open-execution proportional cost model.
Slippage is represented as an additional cash cost. The execution price is
not adjusted by slippage, which avoids double-counting.
"""
cost_bps: float = 0.0
slippage_bps: float = 0.0
def compute(
self,
traded_shares: np.ndarray,
execution_price: np.ndarray,
side: np.ndarray,
date,
metadata: Mapping[str, object] | None = None,
) -> np.ndarray:
shares = np.asarray(traded_shares, dtype=np.float64)
price = np.asarray(execution_price, dtype=np.float64)
open_price = np.where(np.isfinite(price), price, 0.0)
trade_value = np.abs(shares * open_price)
return trade_value * (self.cost_bps + self.slippage_bps) / 1e4
+19 -7
View File
@@ -4,7 +4,8 @@ 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.
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.
@@ -21,6 +22,7 @@ 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__)
@@ -65,10 +67,12 @@ 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_bps: float = 0.0, slippage_bps: float = 0.0,
cost_model: CostModel | None = None):
self.constraints = constraints or []
self.cost_bps = cost_bps
self.slippage_bps = slippage_bps
self.cost_model = cost_model or SimpleProportionalCostModel(
cost_bps=cost_bps, slippage_bps=slippage_bps
)
@abstractmethod
def fill(self, ctx: TradeContext) -> FillResult:
@@ -104,9 +108,17 @@ class ReferenceSimulator(ExecutionSimulator):
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
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(
+65
View File
@@ -22,6 +22,7 @@ from pipeline.portfolio.constraints import (
SuspensionConstraint,
VolumeCapConstraint,
)
from pipeline.portfolio.costs import SimpleProportionalCostModel
from pipeline.portfolio.simulator import (
MarketSlice,
ReferenceSimulator,
@@ -445,6 +446,7 @@ def test_simulator_blocked_buy_when_suspended():
assert res.traded_shares[0] == 0
assert res.realized_shares[0] == 0
assert res.blocked[0] == 1
assert res.cost[0] == 0.0
def test_simulator_cost_is_positive_when_trading():
@@ -458,6 +460,69 @@ def test_simulator_cost_is_positive_when_trading():
assert np.isclose(res.cost[0], 1000 * 20 * 15 / 1e4)
def test_simulator_cost_only_on_nonzero_realized_trades():
n = 2
sim = ReferenceSimulator(constraints=[], cost_bps=10)
sl = _slice(n, price=np.array([10.0, 20.0]))
ctx = TradeContext(np.array([100, 100], np.int64),
np.array([100, 150], np.int64), sl, 1e6)
res = sim.fill(ctx)
assert res.traded_shares.tolist() == [0, 50]
assert res.cost[0] == 0.0
assert np.isclose(res.cost[1], 50 * 20 * 10 / 1e4)
def test_simple_cost_model_adds_cost_and_slippage_without_price_adjustment():
model = SimpleProportionalCostModel(cost_bps=10, slippage_bps=5)
cost = model.compute(
traded_shares=np.array([1000, -1000]),
execution_price=np.array([20.0, 20.0]),
side=np.array([1, -1]),
date=dt.date(2024, 1, 2),
)
assert np.allclose(cost, np.array([30.0, 30.0]))
def test_daily_pnl_cost_matches_fill_trade_cost_sum():
dates = pd.to_datetime(["2024-01-01", "2024-01-02"])
positions = pd.DataFrame({
"symbol_id": ["sh600000", "sz000001"],
"date": [dates[0], dates[0]],
"portfolio_name": ["run1", "run1"],
"target_weight": [0.5, -0.5],
"target_value": [1000.0, -1000.0],
"target_shares": [100.0, -50.0],
"position_shares": [100, -50],
"position_value": [1000.0, -1000.0],
"price": [10.0, 20.0],
})
data = pd.DataFrame([
{
"symbol_id": sym,
"date": d,
"open": price,
"close": price,
"preclose": price,
"amount": 1e9,
"tradestatus": 1,
"isST": 0,
}
for d in dates
for sym, price in (("sh600000", 10.0), ("sz000001", 20.0))
])
fills, pnl = ReferenceSimulator(cost_bps=10, slippage_bps=5).run(positions, data)
total_fill_cost = fills["trade_cost"].sum()
assert np.isclose(total_fill_cost, 3.0)
assert np.isclose(pnl["cost"].iloc[0], total_fill_cost)
assert np.isclose(pnl["pnl"].iloc[0], -total_fill_cost)
# --- evaluate_portfolio ------------------------------------------------------
def test_evaluate_portfolio_keys_no_ic():