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

123 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 04:11 +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`, `data`, `as_data` 

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 ._portfolio_units import PortfolioUnitsMixin 

45from ._reports import Report 

46from ._stats import Stats as Stats 

47from ._utils import PortfolioUtils as PortfolioUtils 

48from .data import Data as Data 

49from .exceptions import ( 

50 InvalidCashPositionTypeError, 

51 InvalidPricesTypeError, 

52 MissingReturnsColumnError, 

53 NonPositiveAumError, 

54 RowCountMismatchError, 

55 UncleanSeriesError, 

56) 

57 

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

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

60_CACHE_SLOTS = ( 

61 "_data_bridge", 

62 "_stats_cache", 

63 "_plots_cache", 

64 "_report_cache", 

65 "_utils_cache", 

66 "_profits_cache", 

67 "_returns_cache", 

68 "_tilt_cache", 

69 "_turnover_cache", 

70) 

71 

72# The canonical name of the date column throughout the Portfolio internals. Every 

73# _portfolio_* mixin tests for this exact name to decide whether it has a temporal 

74# axis, so inputs are normalised to it once at construction rather than each site 

75# having to cope with an arbitrary caller-chosen name. 

76_DATE_COLUMN = "date" 

77 

78 

79def _normalise_date_column(frame: pl.DataFrame) -> pl.DataFrame: 

80 """Rename *frame*'s temporal column to ``'date'``. 

81 

82 The Portfolio internals identify the date axis by name, so a frame whose 

83 dates live under any other label (``'Date'``, ``'timestamp'``, …) would be 

84 treated as having no temporal axis at all — silently falling back to a 

85 positional index and a default ``periods_per_year``. Renaming here makes 

86 that impossible. 

87 

88 A frame that already has a ``'date'`` column is returned untouched, so this is 

89 idempotent and cheap on the internal rebuild paths (`lag`, `truncate`, 

90 `smoothed_holding`) where the input is already canonical. When several 

91 temporal columns are present the first one wins, matching the 

92 leading-date-column convention used throughout. 

93 

94 Args: 

95 frame: A price or cash-position frame, with or without dates. 

96 

97 Returns: 

98 *frame* with its date column named ``'date'``, or *frame* unchanged 

99 when it already has one or has no temporal column to rename. 

100 """ 

101 if _DATE_COLUMN in frame.columns: 

102 return frame 

103 temporal = [name for name, dtype in frame.schema.items() if dtype.is_temporal()] 

104 if not temporal: 

105 return frame 

106 return frame.rename({temporal[0]: _DATE_COLUMN}) 

107 

108 

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

110class Portfolio( 

111 PortfolioNavMixin, 

112 PortfolioAttributionMixin, 

113 PortfolioTurnoverMixin, 

114 PortfolioCostMixin, 

115 PortfolioTransformMixin, 

116 PortfolioUnitsMixin, 

117 PortfolioConstructorMixin, 

118): 

119 """Portfolio analytics class for quant finance. 

120 

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

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

123 and attribution tools. 

124 

125 Derived data series: 

126 

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

128 - `profit` — aggregate daily portfolio profit 

129 - `nav_accumulated` — cumulative additive NAV 

130 - `nav_compounded` — compounded NAV 

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

132 - `monthly` — monthly compounded returns 

133 - `highwater` — running high-water mark 

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

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

136 

137 - Lazy composition accessors: `stats`, `plots`, `report`, `data`, 

138 `as_data` (the same bridge over a derived returns frame) 

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

140 `smoothed_holding` 

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

142 - Turnover: `turnover`, `turnover_weekly`, 

143 `turnover_summary` 

144 - Share-count view: `units`, `equity`, `trades_units`, 

145 `trades_currency`, `weights` — all derived from cash positions and 

146 prices, so they are available however the portfolio was constructed 

147 - Cost analysis: `cost_adjusted_returns`, 

148 `trading_cost_impact`, `deduct_management_fee` 

149 - Utility: `correlation` 

150 

151 Attributes: 

152 cashposition: Polars DataFrame of positions per asset over time. Any 

153 temporal column is present as ``'date'`` — see *Date column* below. 

154 prices: Polars DataFrame of prices per asset over time. Any temporal 

155 column is present as ``'date'`` — see *Date column* below. 

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

157 

158 Analytics facades 

159 ----------------- 

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

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

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

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

164 - ``.as_data(frame)`` : the same bridge over a *derived* returns frame — cost-adjusted, 

165 fee-deducted, or hand-built. Prefer it over ``Data.from_returns(frame)``, which would 

166 also treat the ``'profit'`` and ``'NAV_accumulated'`` columns as assets. 

167 

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

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

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

171 

172 Cost models 

173 ----------- 

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

175 

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

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

178 Used by ``.position_delta_costs`` and ``.net_cost_nav``. 

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

180 

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

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

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

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

185 

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

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

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

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

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

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

192 

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

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

195 

196 Date column 

197 ----------- 

198 The date axis is identified internally by the name ``date``, so a temporal column 

199 (``pl.Date`` or ``pl.Datetime``) under any other label — ``'Date'``, ``'timestamp'`` — 

200 is **renamed to ``date`` at construction**. ``prices`` and ``cashposition`` therefore 

201 report ``date`` rather than the caller's original name, and every date-dependent 

202 feature works regardless of what the input column was called. 

203 

204 Only genuinely temporal columns are normalised: a date column still held as strings 

205 is left as-is and the portfolio is treated as integer-indexed. Parse it first 

206 (``pl.col("Date").str.to_date()``) to get a temporal axis. When a frame contains 

207 several temporal columns and none is named ``date``, the first is renamed. 

208 

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

210 temporal ``date`` column: 

211 

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

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

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

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

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

217 

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

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

220 

221 Examples: 

222 >>> import polars as pl 

223 >>> from datetime import date 

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

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

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

227 >>> pf.assets 

228 ['A'] 

229 """ 

230 

231 cashposition: pl.DataFrame 

232 prices: pl.DataFrame 

233 aum: float 

234 cost_per_unit: float = 0.0 

235 cost_bps: float = 0.0 

236 annual_fee: float = 0.0 

237 

238 # ── Internal cache fields ───────────────────────────────────────────────── 

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

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

241 # lazily on first property access. 

242 # 

243 # Lifecycle: 

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

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

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

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

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

249 # lifetime of the instance. 

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

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

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

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

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

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

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

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

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

259 

260 @staticmethod 

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

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

263 

264 Narrows *ret* to its ``'returns'`` column and splits out ``'date'`` (if 

265 present) into the index. The narrowing is the whole point: the 

266 portfolio's returns-shaped frames also carry ``'profit'`` and 

267 ``'NAV_accumulated'``, and passing those to `Data` would have it treat a 

268 cash P&L series and a NAV level as two further "assets". 

269 

270 The date column is matched literally after a 

271 `_normalise_date_column` pass, so a caller-supplied frame whose dates 

272 arrived as ``'Date'`` is handled too; the positional-index fallback is 

273 reached only by a frame that genuinely has no temporal column. 

274 

275 Args: 

276 ret: Returns DataFrame with a ``'returns'`` column, optionally with 

277 a date column and any number of columns to ignore. 

278 

279 Returns: 

280 A `Data` instance backed by the ``'returns'`` column of *ret*. 

281 

282 Raises: 

283 MissingReturnsColumnError: If *ret* has no ``'returns'`` column. 

284 """ 

285 if "returns" not in ret.columns: 

286 raise MissingReturnsColumnError(ret.columns) 

287 ret = _normalise_date_column(ret) 

288 returns_only = ret.select("returns") 

289 if _DATE_COLUMN in ret.columns: 

290 return Data(returns=returns_only, index=ret.select(_DATE_COLUMN)) 

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

292 

293 def __post_init__(self) -> None: 

294 """Validate input types, shapes, and parameters, and normalise the date column.""" 

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

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

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

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

299 # Canonicalise the date axis before anything downstream looks for it; the 

300 # mixins match ``'date'`` by name, so this must happen on every 

301 # construction path, including a direct ``Portfolio(...)`` call. 

302 object.__setattr__(self, "prices", _normalise_date_column(self.prices)) 

303 object.__setattr__(self, "cashposition", _normalise_date_column(self.cashposition)) 

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

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

306 if self.aum <= 0.0: 

307 raise NonPositiveAumError(self.aum) 

308 for slot in _CACHE_SLOTS: 

309 object.__setattr__(self, slot, None) 

310 

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

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

313 

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

315 """ 

316 ret = self.returns 

317 rows = ret.height 

318 if "date" in ret.columns: 

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

320 return rows, None, None 

321 

322 @property 

323 def cost_model(self) -> CostModel: 

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

325 

326 Returns: 

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

328 reflect the values stored on this portfolio. 

329 """ 

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

331 

332 def __repr__(self) -> str: 

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

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

335 if start is not None: 

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

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

338 

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

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

341 

342 Returns: 

343 ------- 

344 pl.DataFrame 

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

346 

347 Examples: 

348 >>> import polars as pl 

349 >>> from datetime import date 

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

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

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

353 >>> df = pf.describe() 

354 >>> list(df.columns) 

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

356 """ 

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

358 return pl.DataFrame( 

359 { 

360 "asset": self.assets, 

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

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

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

364 } 

365 ) 

366 

367 # ── Factory classmethods ────────────────────────────────────────────────── 

368 

369 @classmethod 

370 def from_cash_position( 

371 cls, 

372 prices: pl.DataFrame, 

373 cash_position: pl.DataFrame | pl.Expr, 

374 aum: float, 

375 cost_per_unit: float = 0.0, 

376 cost_bps: float = 0.0, 

377 cost_model: CostModel | None = None, 

378 annual_fee: float = 0.0, 

379 ) -> Self: 

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

381 

382 Args: 

383 prices: Price levels per asset over time. A temporal column is 

384 normalised to ``'date'`` at construction, whatever it was named. 

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

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

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

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

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

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

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

392 cost_model: Optional `CostModel` 

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

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

395 parameters above. 

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

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

398 Used as the default by `deduct_management_fee`. 

399 

400 Returns: 

401 A Portfolio instance with the provided cash positions. 

402 

403 Raises: 

404 PositionExprColumnError: If *cash_position* is an expression that 

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

406 such expressions leave the original asset columns untouched, 

407 silently treating raw prices as positions. 

408 """ 

409 if isinstance(cash_position, pl.Expr): 

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

411 if cost_model is not None: 

412 cost_per_unit = cost_model.cost_per_unit 

413 cost_bps = cost_model.cost_bps 

414 return cls( 

415 prices=prices, 

416 cashposition=cash_position, 

417 aum=aum, 

418 cost_per_unit=cost_per_unit, 

419 cost_bps=cost_bps, 

420 annual_fee=annual_fee, 

421 ) 

422 

423 # ── Internal helpers ─────────────────────────────────────────────────────── 

424 

425 @staticmethod 

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

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

428 

429 Args: 

430 series: The series to validate. 

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

432 

433 Raises: 

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

435 """ 

436 if series.null_count() != 0: 

437 raise UncleanSeriesError(name, "null") 

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

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

440 

441 # ── Core data properties ─────────────────────────────────────────────────── 

442 

443 @property 

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

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

446 

447 Returns: 

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

449 ``'date'``. 

450 """ 

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

452 

453 # ── Lazy composition accessors ───────────────────────────────────────────── 

454 

455 @property 

456 @cached_in_slot("_data_bridge") 

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

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

459 

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

461 prices and positions; the returned `Data` object 

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

463 ``data.plots``, ``data.reports``). 

464 

465 Returns: 

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

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

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

469 

470 Examples: 

471 >>> import polars as pl 

472 >>> from datetime import date 

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

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

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

476 >>> d = pf.data 

477 >>> "returns" in d.returns.columns 

478 True 

479 """ 

480 return self.as_data() 

481 

482 def as_data(self, returns: pl.DataFrame | None = None) -> "Data": 

483 """Bridge a returns-shaped frame into a `Data` object. 

484 

485 `data` is the same bridge hardwired to `returns`. Use this 

486 method when the series you want analysed is a *derived* one — the output 

487 of `cost_adjusted_returns`, `deduct_management_fee`, or any frame you 

488 built yourself — so it reaches `Stats` through the same narrowing the 

489 `data` property uses. 

490 

491 Building the `Data` by hand is the trap this exists to close: 

492 the portfolio's returns-shaped frames carry ``'profit'`` and 

493 ``'NAV_accumulated'`` alongside ``'returns'``, and 

494 ``Data.from_returns(pf.returns)`` therefore reports a Sharpe ratio for 

495 the cash P&L series and the NAV level as if they were two more assets. 

496 This method keeps only ``'returns'``. 

497 

498 Args: 

499 returns: Frame with a ``'returns'`` column, optionally a date 

500 column, and any number of other columns (which are ignored). 

501 Defaults to `returns`. 

502 

503 Returns: 

504 `Data`: A Data object over the single return series, indexed by the 

505 frame's date column when it has one and by row position otherwise. 

506 

507 Raises: 

508 MissingReturnsColumnError: If *returns* has no ``'returns'`` column. 

509 

510 Examples: 

511 >>> import polars as pl 

512 >>> from datetime import date 

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

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

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

516 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6, cost_bps=5.0) 

517 >>> net = pf.as_data(pf.cost_adjusted_returns()) 

518 >>> net.returns.columns 

519 ['returns'] 

520 """ 

521 return Portfolio._build_data_bridge(self.returns if returns is None else returns) 

522 

523 @property 

524 @cached_in_slot("_stats_cache") 

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

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

527 

528 Delegates to the legacy `Stats` pipeline via 

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

530 available through the shared implementation. 

531 

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

533 """ 

534 return self.data.stats 

535 

536 @property 

537 @cached_in_slot("_plots_cache") 

538 def plots(self) -> PortfolioPlots: 

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

540 

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

542 performance curves, and lead/lag IR charts. 

543 

544 Returns: 

545 `PortfolioPlots`: Helper object with 

546 plotting methods. 

547 

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

549 """ 

550 return PortfolioPlots(self) 

551 

552 @property 

553 @cached_in_slot("_report_cache") 

554 def report(self) -> Report: 

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

556 

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

558 containing statistics tables and interactive charts. 

559 

560 Returns: 

561 `Report`: Helper object with 

562 report methods. 

563 

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

565 """ 

566 return Report(self) 

567 

568 @property 

569 @cached_in_slot("_utils_cache") 

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

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

572 

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

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

575 computing exponential standard deviation. 

576 

577 Returns: 

578 `PortfolioUtils`: Helper object with 

579 utility transform methods. 

580 

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

582 """ 

583 return PortfolioUtils(self)