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

129 statements  

« prev     ^ index     » next       coverage.py v7.17.1, created at 2026-11-03 15: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` / `from_weights` 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, or / previous NAV when ``compounding``) 

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 compounding: When False (the default), `returns` divides each period's 

158 profit by the constant ``aum`` — the convention for a book traded at 

159 a fixed notional. When True, it divides by the previous period's 

160 NAV, the convention for a book whose positions are sized off its 

161 own, compounding NAV (see `from_weights`). Turnover and the 

162 investment ratio follow the same denominator. 

163 realized_costs: Optional per-asset trading costs actually paid on each 

164 row, in currency and aligned with ``prices``. When present they 

165 are deducted from `profits`, so NAV and returns are net of them. 

166 `from_weights` fills this from its simulation; transforms that 

167 change the positions (`lag`, `smoothed_holding`, `tilt`, 

168 `timing`) drop it, because those costs belong to the original 

169 trades. 

170 

171 Analytics facades 

172 ----------------- 

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

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

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

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

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

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

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

180 

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

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

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

184 

185 Cost models 

186 ----------- 

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

188 

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

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

191 Used by ``.position_delta_costs`` and ``.net_cost_nav``. 

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

193 

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

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

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

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

198 

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

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

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

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

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

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

205 

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

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

208 

209 Date column 

210 ----------- 

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

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

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

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

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

216 

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

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

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

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

221 

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

223 temporal ``date`` column: 

224 

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

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

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

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

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

230 

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

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

233 

234 Examples: 

235 >>> import polars as pl 

236 >>> from datetime import date 

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

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

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

240 >>> pf.assets 

241 ['A'] 

242 """ 

243 

244 cashposition: pl.DataFrame 

245 prices: pl.DataFrame 

246 aum: float 

247 cost_per_unit: float = 0.0 

248 cost_bps: float = 0.0 

249 annual_fee: float = 0.0 

250 compounding: bool = False 

251 realized_costs: pl.DataFrame | None = None 

252 

253 # ── Internal cache fields ───────────────────────────────────────────────── 

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

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

256 # lazily on first property access. 

257 # 

258 # Lifecycle: 

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

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

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

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

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

264 # lifetime of the instance. 

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

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

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

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

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

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

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

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

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

274 

275 @staticmethod 

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

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

278 

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

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

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

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

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

284 

285 The date column is matched literally after a 

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

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

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

289 

290 Args: 

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

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

293 

294 Returns: 

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

296 

297 Raises: 

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

299 """ 

300 if "returns" not in ret.columns: 

301 raise MissingReturnsColumnError(ret.columns) 

302 ret = _normalise_date_column(ret) 

303 returns_only = ret.select("returns") 

304 if _DATE_COLUMN in ret.columns: 

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

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

307 

308 def __post_init__(self) -> None: 

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

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

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

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

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

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

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

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

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

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

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

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

321 if self.realized_costs is not None: 

322 object.__setattr__(self, "realized_costs", _normalise_date_column(self.realized_costs)) 

323 if self.realized_costs.shape[0] != self.prices.shape[0]: 

324 raise RowCountMismatchError(self.prices.shape[0], self.realized_costs.shape[0], "realized_costs") 

325 if self.aum <= 0.0: 

326 raise NonPositiveAumError(self.aum) 

327 for slot in _CACHE_SLOTS: 

328 object.__setattr__(self, slot, None) 

329 

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

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

332 

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

334 """ 

335 ret = self.returns 

336 rows = ret.height 

337 if "date" in ret.columns: 

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

339 return rows, None, None 

340 

341 @property 

342 def cost_model(self) -> CostModel: 

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

344 

345 Returns: 

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

347 reflect the values stored on this portfolio. 

348 """ 

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

350 

351 def __repr__(self) -> str: 

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

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

354 if start is not None: 

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

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

357 

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

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

360 

361 Returns: 

362 ------- 

363 pl.DataFrame 

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

365 

366 Examples: 

367 >>> import polars as pl 

368 >>> from datetime import date 

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

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

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

372 >>> df = pf.describe() 

373 >>> list(df.columns) 

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

375 """ 

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

377 return pl.DataFrame( 

378 { 

379 "asset": self.assets, 

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

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

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

383 } 

384 ) 

385 

386 # ── Factory classmethods ────────────────────────────────────────────────── 

387 

388 @classmethod 

389 def from_cash_position( 

390 cls, 

391 prices: pl.DataFrame, 

392 cash_position: pl.DataFrame | pl.Expr, 

393 aum: float, 

394 cost_per_unit: float = 0.0, 

395 cost_bps: float = 0.0, 

396 cost_model: CostModel | None = None, 

397 annual_fee: float = 0.0, 

398 compounding: bool = False, 

399 realized_costs: pl.DataFrame | None = None, 

400 ) -> Self: 

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

402 

403 Args: 

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

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

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

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

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

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

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

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

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

413 cost_model: Optional `CostModel` 

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

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

416 parameters above. 

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

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

419 Used as the default by `deduct_management_fee`. 

420 compounding: Measure returns against the previous period's NAV 

421 instead of the constant *aum*. Defaults to False. 

422 realized_costs: Optional per-asset trading costs actually paid on 

423 each row, in currency, deducted from `profits`. Defaults to 

424 None (no realised costs). 

425 

426 Returns: 

427 A Portfolio instance with the provided cash positions. 

428 

429 Raises: 

430 PositionExprColumnError: If *cash_position* is an expression that 

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

432 such expressions leave the original asset columns untouched, 

433 silently treating raw prices as positions. 

434 """ 

435 if isinstance(cash_position, pl.Expr): 

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

437 if cost_model is not None: 

438 cost_per_unit = cost_model.cost_per_unit 

439 cost_bps = cost_model.cost_bps 

440 return cls( 

441 prices=prices, 

442 cashposition=cash_position, 

443 aum=aum, 

444 cost_per_unit=cost_per_unit, 

445 cost_bps=cost_bps, 

446 annual_fee=annual_fee, 

447 compounding=compounding, 

448 realized_costs=realized_costs, 

449 ) 

450 

451 # ── Internal helpers ─────────────────────────────────────────────────────── 

452 

453 @staticmethod 

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

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

456 

457 Args: 

458 series: The series to validate. 

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

460 

461 Raises: 

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

463 """ 

464 if series.null_count() != 0: 

465 raise UncleanSeriesError(name, "null") 

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

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

468 

469 # ── Core data properties ─────────────────────────────────────────────────── 

470 

471 @property 

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

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

474 

475 Returns: 

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

477 ``'date'``. 

478 """ 

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

480 

481 # ── Lazy composition accessors ───────────────────────────────────────────── 

482 

483 @property 

484 @cached_in_slot("_data_bridge") 

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

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

487 

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

489 prices and positions; the returned `Data` object 

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

491 ``data.plots``, ``data.reports``). 

492 

493 Returns: 

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

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

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

497 

498 Examples: 

499 >>> import polars as pl 

500 >>> from datetime import date 

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

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

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

504 >>> d = pf.data 

505 >>> "returns" in d.returns.columns 

506 True 

507 """ 

508 return self.as_data() 

509 

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

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

512 

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

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

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

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

517 `data` property uses. 

518 

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

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

521 ``'NAV_accumulated'`` alongside ``'returns'``, and 

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

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

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

525 

526 Args: 

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

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

529 Defaults to `returns`. 

530 

531 Returns: 

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

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

534 

535 Raises: 

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

537 

538 Examples: 

539 >>> import polars as pl 

540 >>> from datetime import date 

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

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

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

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

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

546 >>> net.returns.columns 

547 ['returns'] 

548 """ 

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

550 

551 @property 

552 @cached_in_slot("_stats_cache") 

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

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

555 

556 Delegates to the legacy `Stats` pipeline via 

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

558 available through the shared implementation. 

559 

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

561 """ 

562 return self.data.stats 

563 

564 @property 

565 @cached_in_slot("_plots_cache") 

566 def plots(self) -> PortfolioPlots: 

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

568 

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

570 performance curves, and lead/lag IR charts. 

571 

572 Returns: 

573 `PortfolioPlots`: Helper object with 

574 plotting methods. 

575 

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

577 """ 

578 return PortfolioPlots(self) 

579 

580 @property 

581 @cached_in_slot("_report_cache") 

582 def report(self) -> Report: 

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

584 

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

586 containing statistics tables and interactive charts. 

587 

588 Returns: 

589 `Report`: Helper object with 

590 report methods. 

591 

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

593 """ 

594 return Report(self) 

595 

596 @property 

597 @cached_in_slot("_utils_cache") 

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

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

600 

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

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

603 computing exponential standard deviation. 

604 

605 Returns: 

606 `PortfolioUtils`: Helper object with 

607 utility transform methods. 

608 

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

610 """ 

611 return PortfolioUtils(self)