Document and abstract portfolio trading costs
This commit is contained in:
@@ -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.
|
`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
|
## 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).
|
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).
|
||||||
|
|||||||
@@ -210,7 +210,9 @@ uv run python cli.py portfolio build \
|
|||||||
|
|
||||||
Executes the constructed `position_shares` book at the next available open,
|
Executes the constructed `position_shares` book at the next available open,
|
||||||
clipping trades through repeatable constraints. It writes `fills/<name>.pq` and
|
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 |
|
| Option | Default | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
information available on date ``t`` is executed at ``open[t+1]``. Trades that
|
||||||
violate a :class:`~pipeline.portfolio.constraints.TradeConstraint` (suspension,
|
violate a :class:`~pipeline.portfolio.constraints.TradeConstraint` (suspension,
|
||||||
price limit, volume cap, …) are clipped; a fully blocked buy leaves the position
|
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
|
The simulator is an ABC + a :class:`ReferenceSimulator`; constraints compose by
|
||||||
intersecting their per-name signed delta bounds.
|
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.common.schema import FILL_COLUMNS, PNL_COLUMNS
|
||||||
from pipeline.portfolio.constraints import TradeConstraint
|
from pipeline.portfolio.constraints import TradeConstraint
|
||||||
|
from pipeline.portfolio.costs import CostModel, SimpleProportionalCostModel
|
||||||
from pipeline.portfolio.market_rules import MarketRule, compute_limit_status
|
from pipeline.portfolio.market_rules import MarketRule, compute_limit_status
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -65,10 +67,12 @@ class ExecutionSimulator(ABC):
|
|||||||
"""Abstract execution layer. Subclasses define how a target gets filled."""
|
"""Abstract execution layer. Subclasses define how a target gets filled."""
|
||||||
|
|
||||||
def __init__(self, constraints: list[TradeConstraint] | None = None,
|
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.constraints = constraints or []
|
||||||
self.cost_bps = cost_bps
|
self.cost_model = cost_model or SimpleProportionalCostModel(
|
||||||
self.slippage_bps = slippage_bps
|
cost_bps=cost_bps, slippage_bps=slippage_bps
|
||||||
|
)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def fill(self, ctx: TradeContext) -> FillResult:
|
def fill(self, ctx: TradeContext) -> FillResult:
|
||||||
@@ -104,9 +108,17 @@ class ReferenceSimulator(ExecutionSimulator):
|
|||||||
blocked = (traded != desired).astype(np.int64)
|
blocked = (traded != desired).astype(np.int64)
|
||||||
|
|
||||||
realized = prev + traded
|
realized = prev + traded
|
||||||
open_px = np.where(np.isfinite(ctx.slice.price), ctx.slice.price, 0.0)
|
cost = self.cost_model.compute(
|
||||||
trade_value = np.abs(traded.astype(np.float64) * open_px)
|
traded_shares=traded,
|
||||||
cost = trade_value * (self.cost_bps + self.slippage_bps) / 1e4
|
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)
|
return FillResult(realized, traded, cost, blocked)
|
||||||
|
|
||||||
def run(
|
def run(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from pipeline.portfolio.constraints import (
|
|||||||
SuspensionConstraint,
|
SuspensionConstraint,
|
||||||
VolumeCapConstraint,
|
VolumeCapConstraint,
|
||||||
)
|
)
|
||||||
|
from pipeline.portfolio.costs import SimpleProportionalCostModel
|
||||||
from pipeline.portfolio.simulator import (
|
from pipeline.portfolio.simulator import (
|
||||||
MarketSlice,
|
MarketSlice,
|
||||||
ReferenceSimulator,
|
ReferenceSimulator,
|
||||||
@@ -445,6 +446,7 @@ def test_simulator_blocked_buy_when_suspended():
|
|||||||
assert res.traded_shares[0] == 0
|
assert res.traded_shares[0] == 0
|
||||||
assert res.realized_shares[0] == 0
|
assert res.realized_shares[0] == 0
|
||||||
assert res.blocked[0] == 1
|
assert res.blocked[0] == 1
|
||||||
|
assert res.cost[0] == 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_simulator_cost_is_positive_when_trading():
|
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)
|
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 ------------------------------------------------------
|
# --- evaluate_portfolio ------------------------------------------------------
|
||||||
|
|
||||||
def test_evaluate_portfolio_keys_no_ic():
|
def test_evaluate_portfolio_keys_no_ic():
|
||||||
|
|||||||
Reference in New Issue
Block a user