Coverage for src / jquantstats / _portfolio_cost.py: 100%

49 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-07 14:36 +0000

1"""Cost analysis mixin for Portfolio.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING 

6 

7import polars as pl 

8 

9if TYPE_CHECKING: 

10 from .data import Data 

11 

12 

13class PortfolioCostMixin: 

14 """Mixin providing cost analysis methods for Portfolio.""" 

15 

16 if TYPE_CHECKING: 

17 cashposition: pl.DataFrame 

18 aum: float 

19 cost_per_unit: float 

20 cost_bps: float 

21 returns: pl.DataFrame 

22 turnover: pl.DataFrame 

23 profit: pl.DataFrame 

24 data: Data 

25 

26 @property 

27 def position_delta_costs(self) -> pl.DataFrame: 

28 """Daily trading cost using the position-delta model. 

29 

30 Computes the per-period cost as:: 

31 

32 cost_t = sum_i( |x_{i,t} - x_{i,t-1}| ) * cost_per_unit 

33 

34 where ``x_{i,t}`` is the cash position in asset *i* at time *t* and 

35 ``cost_per_unit`` is the one-way cost per unit of traded notional. 

36 The first row is always zero because there is no prior position to 

37 form a difference against. 

38 

39 Returns: 

40 pl.DataFrame: Frame with an optional ``'date'`` column and a 

41 ``'cost'`` column (absolute cash cost per period). 

42 

43 Examples: 

44 >>> from jquantstats.portfolio import Portfolio 

45 >>> import polars as pl 

46 >>> from datetime import date 

47 >>> _d = [date(2020, 1, 1), date(2020, 1, 2), date(2020, 1, 3)] 

48 >>> prices = pl.DataFrame({"date": _d, "A": [100.0, 110.0, 121.0]}) 

49 >>> pos = pl.DataFrame({"date": _d, "A": [1000.0, 1200.0, 900.0]}) 

50 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e5, cost_per_unit=0.01) 

51 >>> pf.position_delta_costs["cost"].to_list() 

52 [0.0, 2.0, 3.0] 

53 """ 

54 assets = [c for c in self.cashposition.columns if c != "date" and self.cashposition[c].dtype.is_numeric()] 

55 abs_position_changes = pl.sum_horizontal(pl.col(c).diff().abs().fill_null(0.0).fill_nan(0.0) for c in assets) 

56 daily_cost = (abs_position_changes * self.cost_per_unit).alias("cost") 

57 cols: list[str | pl.Expr] = [] 

58 if "date" in self.cashposition.columns: 

59 cols.append("date") 

60 cols.append(daily_cost) 

61 return self.cashposition.select(cols) 

62 

63 @property 

64 def net_cost_nav(self) -> pl.DataFrame: 

65 """Net-of-cost cumulative additive NAV using the position-delta cost model. 

66 

67 Deducts :attr:`position_delta_costs` from daily portfolio profit and 

68 computes the running cumulative sum offset by AUM. The result 

69 represents the realised NAV path a strategy would achieve after paying 

70 ``cost_per_unit`` on every unit of position change. 

71 

72 When ``cost_per_unit`` is zero the result equals :attr:`nav_accumulated`. 

73 

74 Returns: 

75 pl.DataFrame: Frame with an optional ``'date'`` column, 

76 ``'profit'``, ``'cost'``, and ``'NAV_accumulated_net'`` columns. 

77 

78 Examples: 

79 >>> from jquantstats.portfolio import Portfolio 

80 >>> import polars as pl 

81 >>> from datetime import date 

82 >>> _d = [date(2020, 1, 1), date(2020, 1, 2), date(2020, 1, 3)] 

83 >>> prices = pl.DataFrame({"date": _d, "A": [100.0, 110.0, 121.0]}) 

84 >>> pos = pl.DataFrame({"date": _d, "A": [1000.0, 1200.0, 900.0]}) 

85 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e5, cost_per_unit=0.0) 

86 >>> net = pf.net_cost_nav 

87 >>> list(net.columns) 

88 ['date', 'profit', 'cost', 'NAV_accumulated_net'] 

89 """ 

90 profit_df = self.profit 

91 cost_df = self.position_delta_costs 

92 if "date" in profit_df.columns: 

93 df = profit_df.join(cost_df, on="date", how="left") 

94 else: 

95 df = profit_df.hstack(cost_df.select(["cost"])) 

96 return df.with_columns(((pl.col("profit") - pl.col("cost")).cum_sum() + self.aum).alias("NAV_accumulated_net")) 

97 

98 def cost_adjusted_returns(self, cost_bps: float | None = None) -> pl.DataFrame: 

99 """Return daily portfolio returns net of estimated one-way trading costs. 

100 

101 Trading costs are modelled as a linear function of daily one-way 

102 turnover: for every unit of AUM traded, the strategy incurs 

103 ``cost_bps`` basis points (i.e. ``cost_bps / 10_000`` fractional 

104 cost). The daily cost deduction is therefore:: 

105 

106 daily_cost = turnover * (cost_bps / 10_000) 

107 

108 where ``turnover`` is the fraction-of-AUM one-way turnover already 

109 computed by :attr:`turnover`. The deduction is applied to the 

110 ``returns`` column of :attr:`returns`, leaving all other columns 

111 (including ``date``) untouched. 

112 

113 Args: 

114 cost_bps: One-way trading cost in basis points per unit of AUM 

115 traded. Must be non-negative. Defaults to ``self.cost_bps`` 

116 set at construction time. 

117 

118 Returns: 

119 pl.DataFrame: Same schema as :attr:`returns` but with the 

120 ``returns`` column reduced by the per-period trading cost. 

121 

122 Raises: 

123 ValueError: If ``cost_bps`` is negative. 

124 

125 Examples: 

126 >>> from jquantstats.portfolio import Portfolio 

127 >>> import polars as pl 

128 >>> from datetime import date 

129 >>> _d = [date(2020, 1, 1), date(2020, 1, 2), date(2020, 1, 3)] 

130 >>> prices = pl.DataFrame({"date": _d, "A": [100.0, 110.0, 121.0]}) 

131 >>> pos = pl.DataFrame({"date": _d, "A": [1000.0, 1200.0, 900.0]}) 

132 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e5) 

133 >>> adj = pf.cost_adjusted_returns(0.0) 

134 >>> float(adj["returns"][1]) == float(pf.returns["returns"][1]) 

135 True 

136 """ 

137 effective_bps = cost_bps if cost_bps is not None else self.cost_bps 

138 if effective_bps < 0: 

139 raise ValueError 

140 base = self.returns 

141 daily_cost = self.turnover["turnover"] * (effective_bps / 10_000.0) 

142 return base.with_columns((pl.col("returns") - daily_cost).alias("returns")) 

143 

144 def trading_cost_impact(self, max_bps: int = 20) -> pl.DataFrame: 

145 """Estimate the impact of trading costs on the Sharpe ratio. 

146 

147 Computes the annualised Sharpe ratio of cost-adjusted returns for 

148 each integer cost level from 0 up to and including ``max_bps`` basis 

149 points (1 bp = 0.01 %). The result lets you quickly assess at what 

150 cost level the strategy's edge is eroded. 

151 

152 Args: 

153 max_bps: Maximum one-way trading cost to evaluate, in basis 

154 points. Defaults to 20 (i.e., evaluates 0, 1, 2, …, 20 

155 bps). Must be a positive integer. 

156 

157 Returns: 

158 pl.DataFrame: Frame with columns ``'cost_bps'`` (Int64) and 

159 ``'sharpe'`` (Float64), one row per cost level from 0 to 

160 ``max_bps`` inclusive. 

161 

162 Raises: 

163 ValueError: If ``max_bps`` is not a positive integer. 

164 

165 Examples: 

166 >>> from jquantstats.portfolio import Portfolio 

167 >>> import polars as pl 

168 >>> from datetime import date, timedelta 

169 >>> import numpy as np 

170 >>> start = date(2020, 1, 1) 

171 >>> dates = pl.date_range( 

172 ... start=start, end=start + timedelta(days=99), interval="1d", eager=True 

173 ... ) 

174 >>> rng = np.random.default_rng(0) 

175 >>> prices = pl.DataFrame({ 

176 ... "date": dates, 

177 ... "A": pl.Series(np.cumprod(1 + rng.normal(0.001, 0.01, 100)) * 100), 

178 ... }) 

179 >>> pos = pl.DataFrame({"date": dates, "A": pl.Series(np.ones(100) * 1000.0)}) 

180 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e5) 

181 >>> impact = pf.trading_cost_impact(max_bps=5) 

182 >>> list(impact["cost_bps"]) 

183 [0, 1, 2, 3, 4, 5] 

184 """ 

185 if not isinstance(max_bps, int) or max_bps < 1: 

186 raise ValueError 

187 import numpy as np 

188 

189 periods = self.data._periods_per_year # one Data object, outside the loop 

190 _eps = np.finfo(np.float64).eps 

191 sqrt_periods = float(np.sqrt(periods)) 

192 cost_levels = list(range(0, max_bps + 1)) 

193 

194 # Extract base returns and turnover once — O(1) allocations regardless of max_bps 

195 base_rets = self.returns["returns"] 

196 turnover_s = self.turnover["turnover"] 

197 

198 # Build all cost-adjusted return columns in one vectorised DataFrame construction, 

199 # then compute means and stds in a single aggregate pass (no per-iteration allocation). 

200 sweep = pl.DataFrame({str(bps): base_rets - turnover_s * (bps / 10_000.0) for bps in cost_levels}) 

201 means_row = sweep.mean().row(0) 

202 stds_row = sweep.std(ddof=1).row(0) 

203 

204 sharpe_values: list[float] = [] 

205 for mean_raw, std_raw in zip(means_row, stds_row, strict=False): 

206 mean_val = 0.0 if mean_raw is None else float(mean_raw) 

207 if std_raw is None or float(std_raw) <= _eps * max(abs(mean_val), _eps) * 10: 

208 sharpe_values.append(float("nan")) 

209 else: 

210 sharpe_values.append(mean_val / float(std_raw) * sqrt_periods) 

211 return pl.DataFrame({"cost_bps": pl.Series(cost_levels, dtype=pl.Int64), "sharpe": pl.Series(sharpe_values)})