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

191 statements  

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

1"""Portfolio analytics class for quant finance. 

2 

3This module provides :class:`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 four focused mixin modules: 

8 

9- :class:`~jquantstats._portfolio_nav.PortfolioNavMixin` — NAV & returns chain 

10- :class:`~jquantstats._portfolio_attribution.PortfolioAttributionMixin` — tilt/timing attribution 

11- :class:`~jquantstats._portfolio_turnover.PortfolioTurnoverMixin` — turnover analytics 

12- :class:`~jquantstats._portfolio_cost.PortfolioCostMixin` — cost analysis 

13 

14Public API is unchanged: 

15 

16- Derived data series — :attr:`profits`, :attr:`profit`, :attr:`nav_accumulated`, 

17 :attr:`returns`, :attr:`monthly`, :attr:`nav_compounded`, :attr:`highwater`, 

18 :attr:`drawdown`, :attr:`all` 

19- Lazy composition accessors — :attr:`stats`, :attr:`plots`, :attr:`report` 

20- Portfolio transforms — :meth:`truncate`, :meth:`lag`, :meth:`smoothed_holding` 

21- Attribution — :attr:`tilt`, :attr:`timing`, :attr:`tilt_timing_decomp` 

22- Turnover analysis — :attr:`turnover`, :attr:`turnover_weekly`, :meth:`turnover_summary` 

23- Cost analysis — :meth:`cost_adjusted_returns`, :meth:`trading_cost_impact` 

24- Utility — :meth:`correlation` 

25""" 

26 

27import dataclasses 

28from datetime import date, datetime 

29from typing import TYPE_CHECKING, Self, cast 

30 

31if TYPE_CHECKING: 

32 from ._stats import Stats as Stats 

33 from ._utils import PortfolioUtils as PortfolioUtils 

34 from .data import Data as Data 

35 

36import polars as pl 

37import polars.selectors as cs 

38 

39from ._cost_model import CostModel 

40from ._plots import PortfolioPlots 

41from ._portfolio_attribution import PortfolioAttributionMixin 

42from ._portfolio_cost import PortfolioCostMixin 

43from ._portfolio_nav import PortfolioNavMixin 

44from ._portfolio_turnover import PortfolioTurnoverMixin 

45from ._reports import Report 

46from .exceptions import ( 

47 IntegerIndexBoundError, 

48 InvalidCashPositionTypeError, 

49 InvalidPricesTypeError, 

50 NonPositiveAumError, 

51 RowCountMismatchError, 

52) 

53 

54 

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

56class Portfolio( 

57 PortfolioNavMixin, 

58 PortfolioAttributionMixin, 

59 PortfolioTurnoverMixin, 

60 PortfolioCostMixin, 

61): 

62 """Portfolio analytics class for quant finance. 

63 

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

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

66 and attribution tools. 

67 

68 Derived data series: 

69 

70 - :attr:`profits` — per-asset daily cash P&L 

71 - :attr:`profit` — aggregate daily portfolio profit 

72 - :attr:`nav_accumulated` — cumulative additive NAV 

73 - :attr:`nav_compounded` — compounded NAV 

74 - :attr:`returns` — daily returns (profit / AUM) 

75 - :attr:`monthly` — monthly compounded returns 

76 - :attr:`highwater` — running high-water mark 

77 - :attr:`drawdown` — drawdown from high-water mark 

78 - :attr:`all` — merged view of all derived series 

79 

80 - Lazy composition accessors: :attr:`stats`, :attr:`plots`, :attr:`report` 

81 - Portfolio transforms: :meth:`truncate`, :meth:`lag`, 

82 :meth:`smoothed_holding` 

83 - Attribution: :attr:`tilt`, :attr:`timing`, :attr:`tilt_timing_decomp` 

84 - Turnover: :attr:`turnover`, :attr:`turnover_weekly`, 

85 :meth:`turnover_summary` 

86 - Cost analysis: :meth:`cost_adjusted_returns`, 

87 :meth:`trading_cost_impact` 

88 - Utility: :meth:`correlation` 

89 

90 Attributes: 

91 cashposition: Polars DataFrame of positions per asset over time 

92 (includes date column if present). 

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

94 column if present). 

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

96 

97 Analytics facades 

98 ----------------- 

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

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

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

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

103 

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

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

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

107 

108 Cost models 

109 ----------- 

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

111 

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

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

114 Used by ``.position_delta_costs`` and ``.net_cost_nav``. 

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

116 

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

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

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

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

121 

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

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

124 

125 Date column requirement 

126 ----------------------- 

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

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

129 

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

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

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

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

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

135 

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

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

138 

139 Examples: 

140 >>> import polars as pl 

141 >>> from datetime import date 

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

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

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

145 >>> pf.assets 

146 ['A'] 

147 """ 

148 

149 cashposition: pl.DataFrame 

150 prices: pl.DataFrame 

151 aum: float 

152 cost_per_unit: float = 0.0 

153 cost_bps: float = 0.0 

154 

155 # ── Internal cache fields ───────────────────────────────────────────────── 

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

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

158 # lazily on first property access. 

159 # 

160 # Lifecycle: 

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

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

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

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

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

166 # lifetime of the instance. 

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

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

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

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

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

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

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

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

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

176 

177 @staticmethod 

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

179 """Build a :class:`~jquantstats._data.Data` bridge from a returns frame. 

180 

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

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

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

184 

185 Args: 

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

187 

188 Returns: 

189 A :class:`~jquantstats._data.Data` instance backed by *ret*. 

190 """ 

191 from .data import Data 

192 

193 returns_only = ret.select("returns") 

194 if "date" in ret.columns: 

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

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

197 

198 def __post_init__(self) -> None: 

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

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

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

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

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

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

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

206 if self.aum <= 0.0: 

207 raise NonPositiveAumError(self.aum) 

208 object.__setattr__(self, "_data_bridge", None) 

209 object.__setattr__(self, "_stats_cache", None) 

210 object.__setattr__(self, "_plots_cache", None) 

211 object.__setattr__(self, "_report_cache", None) 

212 object.__setattr__(self, "_utils_cache", None) 

213 object.__setattr__(self, "_profits_cache", None) 

214 object.__setattr__(self, "_returns_cache", None) 

215 object.__setattr__(self, "_tilt_cache", None) 

216 object.__setattr__(self, "_turnover_cache", None) 

217 

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

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

220 

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

222 """ 

223 ret = self.returns 

224 rows = ret.height 

225 if "date" in ret.columns: 

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

227 return rows, None, None 

228 

229 @property 

230 def cost_model(self) -> CostModel: 

231 """Return the active cost model as a :class:`~jquantstats.CostModel` instance. 

232 

233 Returns: 

234 A :class:`CostModel` whose ``cost_per_unit`` and ``cost_bps`` fields 

235 reflect the values stored on this portfolio. 

236 """ 

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

238 

239 def __repr__(self) -> str: 

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

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

242 if start is not None: 

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

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

245 

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

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

248 

249 Returns: 

250 ------- 

251 pl.DataFrame 

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

253 

254 Examples: 

255 >>> import polars as pl 

256 >>> from datetime import date 

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

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

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

260 >>> df = pf.describe() 

261 >>> list(df.columns) 

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

263 """ 

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

265 return pl.DataFrame( 

266 { 

267 "asset": self.assets, 

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

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

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

271 } 

272 ) 

273 

274 # ── Factory classmethods ────────────────────────────────────────────────── 

275 

276 @classmethod 

277 def from_risk_position( 

278 cls, 

279 prices: pl.DataFrame, 

280 risk_position: pl.DataFrame, 

281 aum: float, 

282 vola: int | dict[str, int] = 32, 

283 vol_cap: float | None = None, 

284 cost_per_unit: float = 0.0, 

285 cost_bps: float = 0.0, 

286 cost_model: CostModel | None = None, 

287 ) -> Self: 

288 """Create a Portfolio from per-asset risk positions. 

289 

290 De-volatizes each risk position using an EWMA volatility estimate 

291 derived from the corresponding price series. 

292 

293 Args: 

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

295 risk_position: Risk units per asset aligned with prices. 

296 vola: EWMA lookback (span-equivalent) used to estimate volatility. 

297 Pass an ``int`` to apply the same span to every asset, or a 

298 ``dict[str, int]`` to set a per-asset span (assets absent from 

299 the dict default to ``32``). Every span value must be a 

300 positive integer; a ``ValueError`` is raised otherwise. Dict 

301 keys that do not correspond to any numeric column in *prices* 

302 also raise a ``ValueError``. 

303 vol_cap: Optional lower bound for the EWMA volatility estimate. 

304 When provided, the vol series is clipped from below at this 

305 value before dividing the risk position, preventing 

306 position blow-up in calm, low-volatility regimes. For 

307 example, ``vol_cap=0.05`` ensures annualised vol is never 

308 estimated below 5%. Must be positive when not ``None``. 

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

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

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

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

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

314 cost_model: Optional :class:`~jquantstats.CostModel` 

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

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

317 parameters above. 

318 

319 Returns: 

320 A Portfolio instance whose cash positions are risk_position 

321 divided by EWMA volatility. 

322 

323 Raises: 

324 ValueError: If any span value in *vola* is ≤ 0, or if a key in a 

325 *vola* dict does not match any numeric column in *prices*, or 

326 if *vol_cap* is provided but is not positive. 

327 """ 

328 if cost_model is not None: 

329 cost_per_unit = cost_model.cost_per_unit 

330 cost_bps = cost_model.cost_bps 

331 assets = [col for col, dtype in prices.schema.items() if dtype.is_numeric()] 

332 

333 # ── Validate vol_cap ────────────────────────────────────────────────── 

334 if vol_cap is not None and vol_cap <= 0: 

335 raise ValueError(f"vol_cap must be a positive number when provided, got {vol_cap!r}") # noqa: TRY003 

336 

337 # ── Validate vola ───────────────────────────────────────────────────── 

338 if isinstance(vola, dict): 

339 unknown = set(vola.keys()) - set(assets) 

340 if unknown: 

341 raise ValueError( # noqa: TRY003 

342 f"vola dict contains keys that do not match any numeric column in prices: {sorted(unknown)}" 

343 ) 

344 for asset, span in vola.items(): 

345 if int(span) <= 0: 

346 raise ValueError(f"vola span for '{asset}' must be a positive integer, got {span!r}") # noqa: TRY003 

347 else: 

348 if int(vola) <= 0: 

349 raise ValueError(f"vola span must be a positive integer, got {vola!r}") # noqa: TRY003 

350 

351 def _span(asset: str) -> int: 

352 """Return the EWMA span for *asset*, falling back to 32 if not specified.""" 

353 if isinstance(vola, dict): 

354 return int(vola.get(asset, 32)) 

355 return int(vola) 

356 

357 def _vol(asset: str) -> pl.Series: 

358 """Return the EWMA volatility series for *asset*, optionally clipped from below.""" 

359 vol = prices[asset].pct_change().ewm_std(com=_span(asset) - 1, adjust=True, min_samples=_span(asset)) 

360 if vol_cap is not None: 

361 vol = vol.clip(lower_bound=vol_cap) 

362 return vol 

363 

364 cash_position = risk_position.with_columns((pl.col(asset) / _vol(asset)).alias(asset) for asset in assets) 

365 return cls(prices=prices, cashposition=cash_position, aum=aum, cost_per_unit=cost_per_unit, cost_bps=cost_bps) 

366 

367 @classmethod 

368 def from_position( 

369 cls, 

370 prices: pl.DataFrame, 

371 position: pl.DataFrame, 

372 aum: float, 

373 cost_per_unit: float = 0.0, 

374 cost_bps: float = 0.0, 

375 cost_model: CostModel | None = None, 

376 ) -> Self: 

377 """Create a Portfolio from share/unit positions. 

378 

379 Converts *position* (number of units held per asset) to cash exposure 

380 by multiplying element-wise with *prices*, then delegates to 

381 :py:meth:`from_cash_position`. 

382 

383 Args: 

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

385 position: Number of units held per asset over time, aligned with 

386 *prices*. Non-numeric columns (e.g. ``'date'``) are passed 

387 through unchanged. 

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

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

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

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

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

393 cost_model: Optional :class:`~jquantstats.CostModel` instance. 

394 When supplied, its ``cost_per_unit`` and ``cost_bps`` values 

395 take precedence over the individual parameters above. 

396 

397 Returns: 

398 A Portfolio instance whose cash positions equal *position* x *prices*. 

399 

400 Examples: 

401 >>> import polars as pl 

402 >>> prices = pl.DataFrame({"A": [100.0, 110.0, 105.0]}) 

403 >>> pos = pl.DataFrame({"A": [10.0, 10.0, 10.0]}) 

404 >>> pf = Portfolio.from_position(prices=prices, position=pos, aum=1e6) 

405 >>> pf.cashposition["A"].to_list() 

406 [1000.0, 1100.0, 1050.0] 

407 """ 

408 assets = [col for col, dtype in prices.schema.items() if dtype.is_numeric()] 

409 cash_position = position.with_columns((pl.col(asset) * prices[asset]).alias(asset) for asset in assets) 

410 return cls.from_cash_position( 

411 prices=prices, 

412 cash_position=cash_position, 

413 aum=aum, 

414 cost_per_unit=cost_per_unit, 

415 cost_bps=cost_bps, 

416 cost_model=cost_model, 

417 ) 

418 

419 @classmethod 

420 def from_cash_position( 

421 cls, 

422 prices: pl.DataFrame, 

423 cash_position: pl.DataFrame, 

424 aum: float, 

425 cost_per_unit: float = 0.0, 

426 cost_bps: float = 0.0, 

427 cost_model: CostModel | None = None, 

428 ) -> Self: 

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

430 

431 Args: 

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

433 cash_position: Cash exposure per asset over time. 

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

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

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

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

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

439 cost_model: Optional :class:`~jquantstats.CostModel` 

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

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

442 parameters above. 

443 

444 Returns: 

445 A Portfolio instance with the provided cash positions. 

446 """ 

447 if cost_model is not None: 

448 cost_per_unit = cost_model.cost_per_unit 

449 cost_bps = cost_model.cost_bps 

450 return cls(prices=prices, cashposition=cash_position, aum=aum, cost_per_unit=cost_per_unit, cost_bps=cost_bps) 

451 

452 # ── Internal helpers ─────────────────────────────────────────────────────── 

453 

454 @staticmethod 

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

456 """Raise ValueError if *series* contains nulls or non-finite values.""" 

457 if series.null_count() != 0: 

458 raise ValueError 

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

460 raise ValueError 

461 

462 # ── Core data properties ─────────────────────────────────────────────────── 

463 

464 @property 

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

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

467 

468 Returns: 

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

470 ``'date'``. 

471 """ 

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

473 

474 # ── Lazy composition accessors ───────────────────────────────────────────── 

475 

476 @property 

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

478 """Build a legacy :class:`~jquantstats._data.Data` object from this portfolio's returns. 

479 

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

481 prices and positions; the returned :class:`~jquantstats._data.Data` object 

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

483 ``data.plots``, ``data.reports``). 

484 

485 Returns: 

486 :class:`~jquantstats._data.Data`: A Data object whose ``returns`` column 

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

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

489 

490 Examples: 

491 >>> import polars as pl 

492 >>> from datetime import date 

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

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

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

496 >>> d = pf.data 

497 >>> "returns" in d.returns.columns 

498 True 

499 """ 

500 if self._data_bridge is not None: 

501 return self._data_bridge 

502 bridge = Portfolio._build_data_bridge(self.returns) 

503 object.__setattr__(self, "_data_bridge", bridge) 

504 return bridge 

505 

506 @property 

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

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

509 

510 Delegates to the legacy :class:`~jquantstats._stats.Stats` pipeline via 

511 :attr:`data`, so all analytics (Sharpe, drawdown, summary, etc.) are 

512 available through the shared implementation. 

513 

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

515 """ 

516 if self._stats_cache is None: 

517 object.__setattr__(self, "_stats_cache", self.data.stats) 

518 return self._stats_cache # type: ignore[return-value] 

519 

520 @property 

521 def plots(self) -> PortfolioPlots: 

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

523 

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

525 performance curves, and lead/lag IR charts. 

526 

527 Returns: 

528 :class:`~jquantstats._plots.PortfolioPlots`: Helper object with 

529 plotting methods. 

530 

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

532 """ 

533 if self._plots_cache is None: 

534 object.__setattr__(self, "_plots_cache", PortfolioPlots(self)) 

535 return self._plots_cache # type: ignore[return-value] 

536 

537 @property 

538 def report(self) -> Report: 

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

540 

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

542 containing statistics tables and interactive charts. 

543 

544 Returns: 

545 :class:`~jquantstats._reports.Report`: Helper object with 

546 report methods. 

547 

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

549 """ 

550 if self._report_cache is None: 

551 object.__setattr__(self, "_report_cache", Report(self)) 

552 return self._report_cache # type: ignore[return-value] 

553 

554 @property 

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

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

557 

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

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

560 computing exponential standard deviation. 

561 

562 Returns: 

563 :class:`~jquantstats._utils.PortfolioUtils`: Helper object with 

564 utility transform methods. 

565 

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

567 """ 

568 if self._utils_cache is None: 

569 from ._utils import PortfolioUtils 

570 

571 object.__setattr__(self, "_utils_cache", PortfolioUtils(self)) 

572 return self._utils_cache # type: ignore[return-value] 

573 

574 # ── Portfolio transforms ─────────────────────────────────────────────────── 

575 

576 def truncate( 

577 self, 

578 start: date | datetime | str | int | None = None, 

579 end: date | datetime | str | int | None = None, 

580 ) -> "Portfolio": 

581 """Return a new Portfolio truncated to the inclusive [start, end] range. 

582 

583 When a ``'date'`` column is present in both prices and cash positions, 

584 truncation is performed by comparing the ``'date'`` column against 

585 ``start`` and ``end`` (which should be date/datetime values or strings 

586 parseable by Polars). 

587 

588 When the ``'date'`` column is absent, integer-based row slicing is 

589 used instead. In this case ``start`` and ``end`` must be non-negative 

590 integers representing 0-based row indices. Passing non-integer bounds 

591 to an integer-indexed portfolio raises :exc:`TypeError`. 

592 

593 In all cases the ``aum`` value is preserved. 

594 

595 Args: 

596 start: Optional lower bound (inclusive). A date/datetime or 

597 Polars-parseable string when a ``'date'`` column exists; a 

598 non-negative int row index when the data has no ``'date'`` 

599 column. 

600 end: Optional upper bound (inclusive). Same type rules as 

601 ``start``. 

602 

603 Returns: 

604 A new Portfolio instance with prices and cash positions filtered 

605 to the specified range. 

606 

607 Raises: 

608 TypeError: When the portfolio has no ``'date'`` column and a 

609 non-integer bound is supplied. 

610 """ 

611 has_date = "date" in self.prices.columns 

612 if has_date: 

613 cond = pl.lit(True) 

614 if start is not None: 

615 cond = cond & (pl.col("date") >= pl.lit(start)) 

616 if end is not None: 

617 cond = cond & (pl.col("date") <= pl.lit(end)) 

618 pr = self.prices.filter(cond) 

619 cp = self.cashposition.filter(cond) 

620 else: 

621 if start is not None and not isinstance(start, int): 

622 raise IntegerIndexBoundError("start", type(start).__name__) 

623 if end is not None and not isinstance(end, int): 

624 raise IntegerIndexBoundError("end", type(end).__name__) 

625 row_start = int(start) if start is not None else 0 

626 row_end = int(end) + 1 if end is not None else self.prices.height 

627 length = max(0, row_end - row_start) 

628 pr = self.prices.slice(row_start, length) 

629 cp = self.cashposition.slice(row_start, length) 

630 return Portfolio( 

631 prices=pr, 

632 cashposition=cp, 

633 aum=self.aum, 

634 cost_per_unit=self.cost_per_unit, 

635 cost_bps=self.cost_bps, 

636 ) 

637 

638 def lag(self, n: int) -> "Portfolio": 

639 """Return a new Portfolio with cash positions lagged by ``n`` steps. 

640 

641 This method shifts the numeric asset columns in the cashposition 

642 DataFrame by ``n`` rows, preserving the ``'date'`` column and any 

643 non-numeric columns unchanged. Positive ``n`` delays weights (moves 

644 them down); negative ``n`` leads them (moves them up); ``n == 0`` 

645 returns the current portfolio unchanged. 

646 

647 Notes: 

648 Missing values introduced by the shift are left as nulls; 

649 downstream profit computation already guards and treats nulls as 

650 zero when multiplying by returns. 

651 

652 Args: 

653 n: Number of rows to shift (can be negative, zero, or positive). 

654 

655 Returns: 

656 A new Portfolio instance with lagged cash positions and the same 

657 prices/AUM as the original. 

658 """ 

659 if not isinstance(n, int): 

660 raise TypeError 

661 if n == 0: 

662 return self 

663 

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

665 cp_lagged = self.cashposition.with_columns(pl.col(c).shift(n) for c in assets) 

666 return Portfolio( 

667 prices=self.prices, 

668 cashposition=cp_lagged, 

669 aum=self.aum, 

670 cost_per_unit=self.cost_per_unit, 

671 cost_bps=self.cost_bps, 

672 ) 

673 

674 def smoothed_holding(self, n: int) -> "Portfolio": 

675 """Return a new Portfolio with cash positions smoothed by a rolling mean. 

676 

677 Applies a trailing window average over the last ``n`` steps for each 

678 numeric asset column (excluding ``'date'``). The window length is 

679 ``n + 1`` so that: 

680 

681 - n=0 returns the original weights (no smoothing), 

682 - n=1 averages the current and previous weights, 

683 - n=k averages the current and last k weights. 

684 

685 Args: 

686 n: Non-negative integer specifying how many previous steps to 

687 include. 

688 

689 Returns: 

690 A new Portfolio with smoothed cash positions and the same 

691 prices/AUM. 

692 """ 

693 if not isinstance(n, int): 

694 raise TypeError 

695 if n < 0: 

696 raise ValueError 

697 if n == 0: 

698 return self 

699 

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

701 window = n + 1 

702 cp_smoothed = self.cashposition.with_columns( 

703 pl.col(c).rolling_mean(window_size=window, min_samples=1).alias(c) for c in assets 

704 ) 

705 return Portfolio( 

706 prices=self.prices, 

707 cashposition=cp_smoothed, 

708 aum=self.aum, 

709 cost_per_unit=self.cost_per_unit, 

710 cost_bps=self.cost_bps, 

711 ) 

712 

713 # ── Utility ──────────────────────────────────────────────────────────────── 

714 

715 def correlation(self, frame: pl.DataFrame, name: str = "portfolio") -> pl.DataFrame: 

716 """Compute a correlation matrix of asset returns plus the portfolio. 

717 

718 Computes percentage changes for all numeric columns in ``frame``, 

719 appends the portfolio profit series under the provided ``name``, and 

720 returns the Pearson correlation matrix across all numeric columns. 

721 

722 Args: 

723 frame: A Polars DataFrame containing at least the asset price 

724 columns (and a date column which will be ignored if 

725 non-numeric). 

726 name: The column name to use when adding the portfolio profit 

727 series to the input frame. 

728 

729 Returns: 

730 A square Polars DataFrame where each cell is the correlation 

731 between a pair of series (values in [-1, 1]). 

732 """ 

733 p = frame.with_columns(cs.by_dtype(pl.Float32, pl.Float64).pct_change()) 

734 p = p.with_columns(pl.Series(name, self.profit["profit"])) 

735 corr_matrix = p.select(cs.numeric()).fill_null(0.0).corr() 

736 return corr_matrix