From 534b91aaa4a003c4d709e776f7d5c150a8fd4ede Mon Sep 17 00:00:00 2001 From: Yuxuan Yan Date: Wed, 10 Jun 2026 15:41:38 +0800 Subject: [PATCH] Document and abstract portfolio trading costs --- CLAUDE.md | 2 + README.md | 4 +- docs/portfolio_trading_cost_model.md | 133 +++++++++++++++++++++++++++ pipeline/portfolio/costs.py | 50 ++++++++++ pipeline/portfolio/simulator.py | 26 ++++-- tests/test_portfolio.py | 65 +++++++++++++ 6 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 docs/portfolio_trading_cost_model.md create mode 100644 pipeline/portfolio/costs.py diff --git a/CLAUDE.md b/CLAUDE.md index 990b09b..d1fca33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/README.md b/README.md index 0f428ef..298593f 100644 --- a/README.md +++ b/README.md @@ -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/.pq` and -`pnl/.pq`. +`pnl/.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 | | --- | --- | --- | diff --git a/docs/portfolio_trading_cost_model.md b/docs/portfolio_trading_cost_model.md new file mode 100644 index 0000000..51dbca2 --- /dev/null +++ b/docs/portfolio_trading_cost_model.md @@ -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. diff --git a/pipeline/portfolio/costs.py b/pipeline/portfolio/costs.py new file mode 100644 index 0000000..ec41e4f --- /dev/null +++ b/pipeline/portfolio/costs.py @@ -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 diff --git a/pipeline/portfolio/simulator.py b/pipeline/portfolio/simulator.py index 84934b8..9c56c4b 100644 --- a/pipeline/portfolio/simulator.py +++ b/pipeline/portfolio/simulator.py @@ -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( diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index c12337a..af25725 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -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():