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

107 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-06 04:52 +0000

1"""Portfolio analytics class for quant finance. 

2 

3This module provides `Portfolio`, a frozen dataclass that stores the 

4raw portfolio inputs (prices, cash positions, AUM) and exposes both the 

5derived data series and the full analytics / visualisation suite. 

6 

7The class is composed from focused mixin modules: 

8 

9- `PortfolioNavMixin` — NAV & returns chain 

10- `PortfolioAttributionMixin` — tilt/timing attribution 

11- `PortfolioTurnoverMixin` — turnover analytics 

12- `PortfolioCostMixin` — cost analysis 

13- `PortfolioTransformMixin` — range/lag/smoothing transforms & correlation 

14- `PortfolioConstructorMixin` — `from_risk_position` / `from_position` factories 

15 

16Public API is unchanged: 

17 

18- Derived data series — `profits`, `profit`, `nav_accumulated`, 

19 `returns`, `monthly`, `nav_compounded`, `highwater`, 

20 `drawdown`, `all` 

21- Lazy composition accessors — `stats`, `plots`, `report` 

22- Portfolio transforms — `truncate`, `lag`, `smoothed_holding` 

23- Attribution — `tilt`, `timing`, `tilt_timing_decomp` 

24- Turnover analysis — `turnover`, `turnover_weekly`, `turnover_summary` 

25- Cost analysis — `cost_adjusted_returns`, `trading_cost_impact`, `deduct_management_fee` 

26- Utility — `correlation` 

27""" 

28 

29import dataclasses 

30from datetime import date, datetime 

31from typing import Self, cast 

32 

33import polars as pl 

34 

35from ._cache import cached_in_slot 

36from ._cost_model import CostModel 

37from ._plots import PortfolioPlots 

38from ._portfolio_attribution import PortfolioAttributionMixin 

39from ._portfolio_constructors import PortfolioConstructorMixin, _evaluate_position_expr 

40from ._portfolio_cost import PortfolioCostMixin 

41from ._portfolio_nav import PortfolioNavMixin 

42from ._portfolio_transform import PortfolioTransformMixin 

43from ._portfolio_turnover import PortfolioTurnoverMixin 

44from ._reports import Report 

45from ._stats import Stats as Stats 

46from ._utils import PortfolioUtils as PortfolioUtils 

47from .data import Data as Data 

48from .exceptions import ( 

49 InvalidCashPositionTypeError, 

50 InvalidPricesTypeError, 

51 NonPositiveAumError, 

52 RowCountMismatchError, 

53 UncleanSeriesError, 

54) 

55 

56# Slot fields used as lazy caches; __post_init__ initialises each to None and 

57# `cached_in_slot` fills them on first property access. 

58_CACHE_SLOTS = ( 

59 "_data_bridge", 

60 "_stats_cache", 

61 "_plots_cache", 

62 "_report_cache", 

63 "_utils_cache", 

64 "_profits_cache", 

65 "_returns_cache", 

66 "_tilt_cache", 

67 "_turnover_cache", 

68) 

69 

70 

71@dataclasses.dataclass(frozen=True, slots=True) 

72class Portfolio( 

73 PortfolioNavMixin, 

74 PortfolioAttributionMixin, 

75 PortfolioTurnoverMixin, 

76 PortfolioCostMixin, 

77 PortfolioTransformMixin, 

78 PortfolioConstructorMixin, 

79): 

80 """Portfolio analytics class for quant finance. 

81 

82 Stores the three raw inputs — cash positions, prices, and AUM — and 

83 exposes the standard derived data series, analytics facades, transforms, 

84 and attribution tools. 

85 

86 Derived data series: 

87 

88 - `profits` — per-asset daily cash P&L 

89 - `profit` — aggregate daily portfolio profit 

90 - `nav_accumulated` — cumulative additive NAV 

91 - `nav_compounded` — compounded NAV 

92 - `returns` — daily returns (profit / AUM) 

93 - `monthly` — monthly compounded returns 

94 - `highwater` — running high-water mark 

95 - `drawdown` — drawdown from high-water mark 

96 - `all` — merged view of all derived series 

97 

98 - Lazy composition accessors: `stats`, `plots`, `report` 

99 - Portfolio transforms: `truncate`, `lag`, 

100 `smoothed_holding` 

101 - Attribution: `tilt`, `timing`, `tilt_timing_decomp` 

102 - Turnover: `turnover`, `turnover_weekly`, 

103 `turnover_summary` 

104 - Cost analysis: `cost_adjusted_returns`, 

105 `trading_cost_impact`, `deduct_management_fee` 

106 - Utility: `correlation` 

107 

108 Attributes: 

109 cashposition: Polars DataFrame of positions per asset over time 

110 (includes date column if present). 

111 prices: Polars DataFrame of prices per asset over time (includes date 

112 column if present). 

113 aum: Assets under management used as base NAV offset. 

114 

115 Analytics facades 

116 ----------------- 

117 - ``.stats`` : delegates to the legacy ``Stats`` pipeline via ``.data``; all 50+ metrics available. 

118 - ``.plots`` : portfolio-specific ``Plots``; NAV overlays, lead-lag IR, rolling Sharpe/vol, heatmaps. 

119 - ``.report`` : HTML ``Report``; self-contained portfolio performance report. 

120 - ``.data`` : bridge to the legacy ``Data`` / ``Stats`` / ``DataPlots`` pipeline. 

121 

122 ``.plots`` and ``.report`` are intentionally *not* delegated to the legacy path: the legacy 

123 path operates on a bare returns series, while the analytics path has access to raw prices, 

124 positions, and AUM for richer portfolio-specific visualisations. 

125 

126 Cost models 

127 ----------- 

128 Two independent cost models are provided. They are not interchangeable: 

129 

130 **Model A — position-delta (stateful, set at construction):** 

131 ``cost_per_unit: float`` — one-way cost per unit of position change (e.g. 0.01 per share). 

132 Used by ``.position_delta_costs`` and ``.net_cost_nav``. 

133 Best for: equity portfolios where cost scales with shares traded. 

134 

135 **Model B — turnover-bps (stateless, passed at call time):** 

136 ``cost_bps: float`` — one-way cost in basis points of AUM turnover (e.g. 5 bps). 

137 Used by ``.cost_adjusted_returns(cost_bps)`` and ``.trading_cost_impact(max_bps)``. 

138 Best for: macro / fund-of-funds portfolios where cost scales with notional traded. 

139 

140 **Management fee (flat annual, set at construction or passed at call time):** 

141 ``annual_fee: float`` — flat annual management fee as a fraction of AUM (e.g. 0.0085 for 85 bps p.a.). 

142 Used by ``.deduct_management_fee(annual_fee)``. 

143 The fee accrues pro-rata per calendar day (``annual_fee * days / 365``), so weekends and 

144 holidays are charged to the next trading day and the deduction sums to ``annual_fee`` over a 

145 full year. Cost and fee deductions compose linearly in any order. 

146 

147 To sweep a range of cost assumptions use ``trading_cost_impact(max_bps=20)`` (Model B). 

148 To compute a net-NAV curve set ``cost_per_unit`` at construction and read ``.net_cost_nav`` (Model A). 

149 

150 Date column requirement 

151 ----------------------- 

152 Most analytics work with or without a ``date`` column. The following features require a 

153 temporal ``date`` column (``pl.Date`` or ``pl.Datetime``): 

154 

155 - ``portfolio.plots.correlation_heatmap()`` 

156 - ``portfolio.plots.lead_lag_ir_plot()`` 

157 - ``stats.monthly_win_rate()`` — returns NaN per column when no date is present 

158 - ``stats.annual_breakdown()`` — raises ``ValueError`` when no date is present 

159 - ``stats.max_drawdown_duration()`` — returns period count (int) instead of days 

160 

161 Portfolios without a ``date`` column (integer-indexed) are fully supported for 

162 NAV, returns, Sharpe, drawdown, cost analytics, and most rolling metrics. 

163 

164 Examples: 

165 >>> import polars as pl 

166 >>> from datetime import date 

167 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]}) 

168 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]}) 

169 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6) 

170 >>> pf.assets 

171 ['A'] 

172 """ 

173 

174 cashposition: pl.DataFrame 

175 prices: pl.DataFrame 

176 aum: float 

177 cost_per_unit: float = 0.0 

178 cost_bps: float = 0.0 

179 annual_fee: float = 0.0 

180 

181 # ── Internal cache fields ───────────────────────────────────────────────── 

182 # All cache fields are initialised to ``None`` in ``__post_init__`` via 

183 # ``object.__setattr__`` (required for frozen dataclasses) and populated 

184 # lazily on first property access. 

185 # 

186 # Lifecycle: 

187 # - Initialised: ``__post_init__`` sets every field to ``None``. 

188 # - Populated: each property computes its value on the first call and 

189 # writes it back via ``object.__setattr__``. 

190 # - Invalidation: not required — ``Portfolio`` is a *frozen* dataclass, 

191 # so its inputs never change and all derived values remain valid for the 

192 # lifetime of the instance. 

193 _data_bridge: "Data | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False) 

194 _stats_cache: "Stats | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False) 

195 _plots_cache: "PortfolioPlots | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False) 

196 _report_cache: "Report | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False) 

197 _utils_cache: "PortfolioUtils | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False) 

198 _profits_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False) 

199 _returns_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False) 

200 _tilt_cache: "Portfolio | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False) 

201 _turnover_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False) 

202 

203 @staticmethod 

204 def _build_data_bridge(ret: pl.DataFrame) -> "Data": 

205 """Build a `Data` bridge from a returns frame. 

206 

207 Splits out the ``'date'`` column (if present) into an index and passes 

208 the remaining numeric columns as returns. Used internally to populate 

209 ``_data_bridge`` at construction time so the ``data`` property is O(1). 

210 

211 Args: 

212 ret: Returns DataFrame, optionally with a leading ``'date'`` column. 

213 

214 Returns: 

215 A `Data` instance backed by *ret*. 

216 """ 

217 returns_only = ret.select("returns") 

218 if "date" in ret.columns: 

219 return Data(returns=returns_only, index=ret.select("date")) 

220 return Data(returns=returns_only, index=pl.DataFrame({"index": list(range(ret.height))})) 

221 

222 def __post_init__(self) -> None: 

223 """Validate input types, shapes, and parameters post-initialization.""" 

224 if not isinstance(self.prices, pl.DataFrame): 

225 raise InvalidPricesTypeError(type(self.prices).__name__) 

226 if not isinstance(self.cashposition, pl.DataFrame): 

227 raise InvalidCashPositionTypeError(type(self.cashposition).__name__) 

228 if self.cashposition.shape[0] != self.prices.shape[0]: 

229 raise RowCountMismatchError(self.prices.shape[0], self.cashposition.shape[0]) 

230 if self.aum <= 0.0: 

231 raise NonPositiveAumError(self.aum) 

232 for slot in _CACHE_SLOTS: 

233 object.__setattr__(self, slot, None) 

234 

235 def _date_range(self) -> tuple[int, date | datetime | None, date | datetime | None]: 

236 """Return (rows, start, end) for the portfolio's returns series. 

237 

238 ``start`` and ``end`` are ``None`` when there is no ``'date'`` column. 

239 """ 

240 ret = self.returns 

241 rows = ret.height 

242 if "date" in ret.columns: 

243 return rows, cast(date | None, ret["date"].min()), cast(date | None, ret["date"].max()) 

244 return rows, None, None 

245 

246 @property 

247 def cost_model(self) -> CostModel: 

248 """Return the active cost model as a `CostModel` instance. 

249 

250 Returns: 

251 A `CostModel` whose ``cost_per_unit`` and ``cost_bps`` fields 

252 reflect the values stored on this portfolio. 

253 """ 

254 return CostModel(cost_per_unit=self.cost_per_unit, cost_bps=self.cost_bps) 

255 

256 def __repr__(self) -> str: 

257 """Return a string representation of the Portfolio object.""" 

258 rows, start, end = self._date_range() 

259 if start is not None: 

260 return f"Portfolio(assets={self.assets}, rows={rows}, start={start}, end={end})" 

261 return f"Portfolio(assets={self.assets}, rows={rows})" 

262 

263 def describe(self) -> pl.DataFrame: 

264 """Return a tidy summary of shape, date range and asset names. 

265 

266 Returns: 

267 ------- 

268 pl.DataFrame 

269 One row per asset with columns: asset, start, end, rows. 

270 

271 Examples: 

272 >>> import polars as pl 

273 >>> from datetime import date 

274 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]}) 

275 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]}) 

276 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6) 

277 >>> df = pf.describe() 

278 >>> list(df.columns) 

279 ['asset', 'start', 'end', 'rows'] 

280 """ 

281 rows, start, end = self._date_range() 

282 return pl.DataFrame( 

283 { 

284 "asset": self.assets, 

285 "start": [start] * len(self.assets), 

286 "end": [end] * len(self.assets), 

287 "rows": [rows] * len(self.assets), 

288 } 

289 ) 

290 

291 # ── Factory classmethods ────────────────────────────────────────────────── 

292 

293 @classmethod 

294 def from_cash_position( 

295 cls, 

296 prices: pl.DataFrame, 

297 cash_position: pl.DataFrame | pl.Expr, 

298 aum: float, 

299 cost_per_unit: float = 0.0, 

300 cost_bps: float = 0.0, 

301 cost_model: CostModel | None = None, 

302 annual_fee: float = 0.0, 

303 ) -> Self: 

304 """Create a Portfolio directly from cash positions aligned with prices. 

305 

306 Args: 

307 prices: Price levels per asset over time (may include a date column). 

308 cash_position: Cash exposure per asset over time, either as a 

309 DataFrame or as a Polars expression evaluated against *prices*. 

310 aum: Assets under management used as the base NAV offset. 

311 cost_per_unit: One-way trading cost per unit of position change. 

312 Defaults to 0.0 (no cost). Ignored when *cost_model* is given. 

313 cost_bps: One-way trading cost in basis points of AUM turnover. 

314 Defaults to 0.0 (no cost). Ignored when *cost_model* is given. 

315 cost_model: Optional `CostModel` 

316 instance. When supplied, its ``cost_per_unit`` and 

317 ``cost_bps`` values take precedence over the individual 

318 parameters above. 

319 annual_fee: Flat annual management fee as a fraction of AUM 

320 (e.g. 0.0085 for 85 bps p.a.). Defaults to 0.0 (no fee). 

321 Used as the default by `deduct_management_fee`. 

322 

323 Returns: 

324 A Portfolio instance with the provided cash positions. 

325 

326 Raises: 

327 PositionExprColumnError: If *cash_position* is an expression that 

328 creates columns not present in *prices* (e.g. via ``.alias``); 

329 such expressions leave the original asset columns untouched, 

330 silently treating raw prices as positions. 

331 """ 

332 if isinstance(cash_position, pl.Expr): 

333 cash_position = _evaluate_position_expr(prices, cash_position, "cash_position") 

334 if cost_model is not None: 

335 cost_per_unit = cost_model.cost_per_unit 

336 cost_bps = cost_model.cost_bps 

337 return cls( 

338 prices=prices, 

339 cashposition=cash_position, 

340 aum=aum, 

341 cost_per_unit=cost_per_unit, 

342 cost_bps=cost_bps, 

343 annual_fee=annual_fee, 

344 ) 

345 

346 # ── Internal helpers ─────────────────────────────────────────────────────── 

347 

348 @staticmethod 

349 def _assert_clean_series(series: pl.Series, name: str = "") -> None: 

350 """Raise `UncleanSeriesError` if *series* contains nulls or non-finite values. 

351 

352 Args: 

353 series: The series to validate. 

354 name: Optional series name included in the error message. 

355 

356 Raises: 

357 UncleanSeriesError: If the series contains null or non-finite values. 

358 """ 

359 if series.null_count() != 0: 

360 raise UncleanSeriesError(name, "null") 

361 if not series.is_finite().all(): 

362 raise UncleanSeriesError(name, "non-finite") 

363 

364 # ── Core data properties ─────────────────────────────────────────────────── 

365 

366 @property 

367 def assets(self) -> list[str]: 

368 """List the asset column names from prices (numeric columns). 

369 

370 Returns: 

371 list[str]: Names of numeric columns in prices; typically excludes 

372 ``'date'``. 

373 """ 

374 return [c for c in self.prices.columns if self.prices[c].dtype.is_numeric()] 

375 

376 # ── Lazy composition accessors ───────────────────────────────────────────── 

377 

378 @property 

379 @cached_in_slot("_data_bridge") 

380 def data(self) -> "Data": 

381 """Build a legacy `Data` object from this portfolio's returns. 

382 

383 This bridges the two entry points: ``Portfolio`` compiles the NAV curve from 

384 prices and positions; the returned `Data` object 

385 gives access to the full legacy analytics pipeline (``data.stats``, 

386 ``data.plots``, ``data.reports``). 

387 

388 Returns: 

389 `Data`: A Data object whose ``returns`` column 

390 is the portfolio's daily return series and whose ``index`` holds the date 

391 column (or a synthetic integer index for date-free portfolios). 

392 

393 Examples: 

394 >>> import polars as pl 

395 >>> from datetime import date 

396 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]}) 

397 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]}) 

398 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6) 

399 >>> d = pf.data 

400 >>> "returns" in d.returns.columns 

401 True 

402 """ 

403 return Portfolio._build_data_bridge(self.returns) 

404 

405 @property 

406 @cached_in_slot("_stats_cache") 

407 def stats(self) -> "Stats": 

408 """Return a Stats object built from the portfolio's daily returns. 

409 

410 Delegates to the legacy `Stats` pipeline via 

411 `data`, so all analytics (Sharpe, drawdown, summary, etc.) are 

412 available through the shared implementation. 

413 

414 The result is cached after first access so repeated calls are O(1). 

415 """ 

416 return self.data.stats 

417 

418 @property 

419 @cached_in_slot("_plots_cache") 

420 def plots(self) -> PortfolioPlots: 

421 """Convenience accessor returning a PortfolioPlots facade for this portfolio. 

422 

423 Use this to create Plotly visualizations such as snapshots, lagged 

424 performance curves, and lead/lag IR charts. 

425 

426 Returns: 

427 `PortfolioPlots`: Helper object with 

428 plotting methods. 

429 

430 The result is cached after first access so repeated calls are O(1). 

431 """ 

432 return PortfolioPlots(self) 

433 

434 @property 

435 @cached_in_slot("_report_cache") 

436 def report(self) -> Report: 

437 """Convenience accessor returning a Report facade for this portfolio. 

438 

439 Use this to generate a self-contained HTML performance report 

440 containing statistics tables and interactive charts. 

441 

442 Returns: 

443 `Report`: Helper object with 

444 report methods. 

445 

446 The result is cached after first access so repeated calls are O(1). 

447 """ 

448 return Report(self) 

449 

450 @property 

451 @cached_in_slot("_utils_cache") 

452 def utils(self) -> "PortfolioUtils": 

453 """Convenience accessor returning a PortfolioUtils facade for this portfolio. 

454 

455 Use this for common data transformations such as converting returns to 

456 prices, computing log returns, rebasing, aggregating by period, and 

457 computing exponential standard deviation. 

458 

459 Returns: 

460 `PortfolioUtils`: Helper object with 

461 utility transform methods. 

462 

463 The result is cached after first access so repeated calls are O(1). 

464 """ 

465 return PortfolioUtils(self)