ppar 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ppar/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """
2
+ Imports and publc exposure for the package.
3
+ """
4
+
5
+ # Explicitly import the specific members or modules.
6
+ # If they are defined below in __all__, then they must be imported here.
7
+ from ppar.analytics import Analytics
8
+ from ppar.attribution import Attribution, View
9
+ from ppar.frequency import Frequency
10
+ from ppar.riskstatistics import RiskStatistics
11
+
12
+ # Define the public API using __all__
13
+ __all__ = [
14
+ "Analytics",
15
+ "Attribution",
16
+ "Frequency",
17
+ "RiskStatistics",
18
+ "View",
19
+ ]
ppar/analytics.py ADDED
@@ -0,0 +1,626 @@
1
+ """
2
+ The Analytics class reads and validates portfolio and benchmark Performance data, and then
3
+ consolidates them into common time periods based on the provided dates and frequency.
4
+
5
+ The public methods to retrieve the analytical calculations are:
6
+ 1. get_attribution()
7
+ 2. get_riskstatistics()
8
+ """
9
+
10
+ # Python Imports
11
+ import bisect
12
+ from collections import defaultdict
13
+ import datetime as dt
14
+
15
+ # Third-Party Imports
16
+ import polars as pl
17
+
18
+ # Project Imports
19
+ from ppar.attribution import Attribution
20
+ import ppar.columns as cols
21
+ from ppar.columns import CON, RET, WGT
22
+ import ppar.errors as errs
23
+ from ppar.frequency import Frequency, date_matches_frequency
24
+ from ppar.mapping import Mapping
25
+ from ppar.performance import Performance
26
+ from ppar.riskstatistics import RiskStatistics
27
+ import ppar.utilities as util
28
+
29
+
30
+ class Analytics:
31
+ """
32
+ The Analytics class reads and validates portfolio and benchmark Performance data, and then
33
+ consolidates them into common time periods based on the provided dates and frequency.
34
+
35
+ The public methods to retrieve the analytical calculations are:
36
+ 1. get_attribution()
37
+ 2. get_riskstatistics()
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ # Portfolio and Benchmark parameters
43
+ portfolio_data_source: util.TypePerformanceDataSource,
44
+ benchmark_data_source: util.TypePerformanceDataSource = util.EMPTY,
45
+ portfolio_name: str = util.EMPTY,
46
+ benchmark_name: str = util.EMPTY,
47
+ portfolio_classification_name: str = util.EMPTY,
48
+ benchmark_classification_name: str = util.EMPTY,
49
+ # Date and frequency parameters
50
+ beginning_date: str | dt.date = dt.date.min,
51
+ ending_date: str | dt.date = dt.date.max,
52
+ frequency: Frequency = Frequency.AS_OFTEN_AS_POSSIBLE,
53
+ # RiskStatistics parameters
54
+ annual_minimum_acceptable_return: float = util.DEFAULT_ANNUAL_MINIMUM_ACCEPTABLE_RETURN,
55
+ annual_risk_free_rate: float = util.DEFAULT_ANNUAL_RISK_FREE_RATE,
56
+ confidence_level: float = util.DEFAULT_CONFIDENCE_LEVEL,
57
+ portfolio_value: tuple[float, str] = (
58
+ util.DEFAULT_PORTFOLIO_VALUE,
59
+ util.DEFAULT_CURRENCY_SYMBOL,
60
+ ),
61
+ ):
62
+ """
63
+ The constructor. Reads and validates portfolio and benchmark Performance data, and
64
+ cnsolidates them into common time periods based on the provided dates and frequency.
65
+
66
+ Args:
67
+ portfolio_data_source (TypePerformanceDataSource): One of the following:
68
+ 1. A csv file path containing the portfolio performance data.
69
+ 2. A pandas or polars DataFrame containing the portfolio performance data.
70
+ benchmark_data_source (TypePerformanceDataSource, optional): One of the following:
71
+ 1. A csv file path containing the benchmark performance data.
72
+ 2. A pandas or polars DataFrame containing the benchmark performance data.
73
+ Defaults to portfolio_data_source.
74
+ portfolio_name (str, optional): The portfolio name used in view titles.
75
+ benchmark_name (str, optional): The benchmark name used in view titles.
76
+ portfolio_classification_name (str, optional): The classification name that corresponds
77
+ to the portfolio_data. Defaults to util.EMPTY.
78
+ benchmark_classification_name (str, optional): The classification name that corresponds
79
+ to the benchmark_data. Defaults to util.EMPTY.
80
+ beginning_date (str | dt.date, optional): Beginning date as a python date or a date
81
+ string in the format yyyy-mm-dd. Defaults to dt.date.min.
82
+ ending_date (str | dt.date, optional): Ending date as a python date or a date string in
83
+ the format yyyy-mm-dd. Defaults to dt.date.max.
84
+ frequency (Frequency, optional): The periodic frequency for which to
85
+ produce Attribution instances. Can be either:
86
+ 1. Frequency.AS_OFTEN_AS_POSSIBLE, meaning "as often as the provided data allows".
87
+ If daily dates, weights and returns are provided, then daily analytics will be
88
+ created. If monthly dates, weights and returns are provided, then monthly
89
+ analytics will be created, etc.
90
+ 2. Frequency.MONTHLY
91
+ 3. Frequency.QUARTERLY
92
+ 4. Frequency.YEARLY
93
+ Defaults to Frequency.AS_OFTEN_AS_POSSIBLE.
94
+ annual_minimum_acceptable_return (float, optional): The minimum acceptable return used
95
+ for calculating "downside" satistics.
96
+ Defaults to util.DEFAULT_ANNUAL_MINIMUM_ACCEPTABLE_RETURN.
97
+ annual_risk_free_rate (float, optional): The annual risk-free rate used for
98
+ calculating statistics that involve a risk-free rates.
99
+ Defaults to util.DEFAULT_ANNUAL_RISK_FREE_RATE.
100
+ confidence_level (float, optional): The confidence level for calculating the
101
+ value-at-risk (VAR). Defaults to util.DEFAULT_CONFIDENCE_LEVEL.
102
+ portfolio_value (tuple[float, str], optional): A tuple of the portfolio value and it's
103
+ associated currency that will be used when calculating the value-at-risk (VaR).
104
+ Defaults to (util.DEFAULT_PORTFOLIO_VALUE, util.DEFAULT_CURRENCY_SYMBOL).
105
+
106
+ Data Parameters:
107
+ Sample input data for the "portfolio_data_source" & "benchmark_data_source" parameters
108
+ can be in either of the 2 below formats. The weights for each time period must
109
+ sum to 1.0. The equation SumOf(weight * return) == TotalReturn must be satisfied for
110
+ each period. The column names must conform to the below formats. The ordering of the
111
+ columns or rows does not matter.
112
+ 1. Narrow Format:
113
+ beginning_date, ending_date, identifier, return, weight
114
+ 2023-12-31, 2024-01-31, aapl, -0.0422272121, 0.4
115
+ 2023-12-31, 2024-01-31, msft, 0.0572811503, 0.6
116
+ 2024-01-31, 2024-02-29, aapl, -0.019793881, 0.7
117
+ 2024-01-31, 2024-02-29, msft, 0.0403944092, 0.3
118
+ 2. Wide Format:
119
+ beginning_date, ending_date, aapl.ret, msft.ret, aapl.wgt, msft.wgt
120
+ 2023-12-31, 2024-01-31, -0.0422272121, 0.0572811503, 0.4, 0.6
121
+ 2024-01-31, 2024-02-29, -0.019793881, 0.0403944092, 0.7, 0.3
122
+ """
123
+ # Default the benchmark to the portfolio. This will allow for "portfolio-only" analysis
124
+ # if they do not have a benchmark.
125
+ if util.is_empty(benchmark_data_source):
126
+ benchmark_data_source = portfolio_data_source
127
+
128
+ # Convert the dates to dt.date types.
129
+ beginning_date = util.convert_to_date(beginning_date)
130
+ ending_date = util.convert_to_date(ending_date)
131
+
132
+ # Set the simple class variables directly from the constructor parameters.
133
+ self._annual_minimum_acceptable_return = annual_minimum_acceptable_return
134
+ self._annual_risk_free_rate = annual_risk_free_rate
135
+ self._confidence_level = confidence_level
136
+ self._frequency = frequency
137
+ self._portfolio_value = portfolio_value
138
+
139
+ # Initialize the internal data structures.
140
+ self._attributions: dict[str, Attribution] = {} # key = classification_name
141
+ self._riskstatistics: RiskStatistics | None = None
142
+
143
+ # Get a tuple of the 2 Performance classes. portfolio == 0, benchmark == 1.
144
+ self._performances = (
145
+ # Portfolio
146
+ Performance(
147
+ portfolio_data_source,
148
+ name=portfolio_name,
149
+ classification_name=portfolio_classification_name,
150
+ beginning_date=beginning_date,
151
+ ending_date=ending_date,
152
+ ),
153
+ # Benchmark
154
+ Performance(
155
+ benchmark_data_source,
156
+ name=benchmark_name,
157
+ classification_name=benchmark_classification_name,
158
+ beginning_date=beginning_date,
159
+ ending_date=ending_date,
160
+ ),
161
+ )
162
+
163
+ # Get the beginning_dates and ending_dates for all subperiods that are common between the
164
+ # two Performances.
165
+ self._subperiod_dates = self._calculate_subperiod_dates(
166
+ f"from {util.date_str(beginning_date)} to {util.date_str(ending_date)}"
167
+ )
168
+
169
+ # Now that the dates have been firmly established, remove the extraneous rows (dates) from
170
+ # the Performances.
171
+ for perf in self._performances:
172
+ perf.df = (
173
+ perf.df.lazy()
174
+ .filter(
175
+ (
176
+ (self._beginning_date() <= pl.col(cols.BEGINNING_DATE))
177
+ & (pl.col(cols.ENDING_DATE) <= self._ending_date())
178
+ )
179
+ )
180
+ .collect()
181
+ )
182
+
183
+ # Consolidate multiple subperiods (e.g. daily) into single periods (e.g. monthly) based on
184
+ # self._frequency.
185
+ self._consolidate_all_subperiods()
186
+
187
+ def audit(self) -> None:
188
+ """Audit the Analytics (self)."""
189
+ # Audit the portfolio/benchmark pair of performances. These are the performances that
190
+ # were originally read in the constructor. Depending on their classifications, they may
191
+ # be differenct than the performances in the attributions.
192
+ Performance.audit_performances(
193
+ self._performances, self._beginning_date(), self._ending_date()
194
+ )
195
+
196
+ # Audit the attributions and their associated performances.
197
+ Attribution.audit_attributions(list(self._attributions.values()))
198
+
199
+ def _beginning_date(self) -> dt.date:
200
+ """
201
+ Get the overall beginning date.
202
+
203
+ Returns:
204
+ dt.date: The overall beginning date.
205
+ """
206
+ return self._subperiod_dates[0][0]
207
+
208
+ def _calculate_subperiod_dates(self, message_suffix: str) -> list[tuple[dt.date, dt.date]]:
209
+ """
210
+ Calculate the beginning_dates and ending_dates for all subperiods that are common between
211
+ the 2 self._performances. This will define the subperiods for which the Attribution and
212
+ RiskStatistics will be calculated.
213
+
214
+ Args:
215
+ message_suffix (str): Error message suffix.
216
+
217
+ Returns:
218
+ list[tuple[dt.date, dt.date]]: A list of the common beginning dates and ending dates
219
+ for each subperiod.
220
+ """
221
+
222
+ def _common_dates(dates1: pl.Series, dates2: pl.Series) -> pl.Series:
223
+ """Return the sorted dates common between dates1 and dates2."""
224
+ # Note that using set intersection is MUCH slower.
225
+ # return sorted(set(dates1) & set(dates2))
226
+ return dates1.filter(dates1.is_in(dates2)).sort()
227
+
228
+ def _filter_dates_on_frequency(dates: pl.Series | list[dt.date]) -> list[dt.date]:
229
+ """Filter the dates based on self._frequency."""
230
+ return [date for date in dates if date_matches_frequency(date, self._frequency)]
231
+
232
+ # Cache the performance DataFrames.
233
+ df0 = self._performances[0].df
234
+ df1 = self._performances[1].df
235
+
236
+ # Compute sorted common beginning and ending dates.
237
+ common_beginning_dates: pl.Series | list[dt.date] = _common_dates(
238
+ df0[cols.BEGINNING_DATE], df1[cols.BEGINNING_DATE]
239
+ )
240
+ common_ending_dates: pl.Series | list[dt.date] = _common_dates(
241
+ df0[cols.ENDING_DATE], df1[cols.ENDING_DATE]
242
+ )
243
+
244
+ # Filter the dates based on frequency.
245
+ if self._frequency != Frequency.AS_OFTEN_AS_POSSIBLE:
246
+ common_beginning_dates = _filter_dates_on_frequency(common_beginning_dates)
247
+ common_ending_dates = _filter_dates_on_frequency(common_ending_dates)
248
+
249
+ # For each beginning date, find the first ending date that is strictly greater.
250
+ subperiod_dates: list[tuple[dt.date, dt.date]] = []
251
+ idx = 0
252
+ len_common_end_dates = len(common_ending_dates)
253
+ for begin_date in common_beginning_dates:
254
+ if idx < len_common_end_dates and common_ending_dates[idx] <= begin_date:
255
+ # bisect_right returns the insertion point which is the index of the first ending
256
+ # date > b.
257
+ idx = bisect.bisect_right(common_ending_dates, begin_date, lo=idx + 1)
258
+ if idx < len_common_end_dates:
259
+ subperiod_dates.append((begin_date, common_ending_dates[idx]))
260
+ idx += 1
261
+
262
+ # Assert that there is at least one subperiod.
263
+ assert 0 < len(subperiod_dates), f"{errs.ERROR_202_NO_REPORTABLE_DATES}{message_suffix}"
264
+
265
+ # Return the common beginning and ending dates that define the subperiods.
266
+ return subperiod_dates
267
+
268
+ def classification_names(self) -> tuple[str, str]:
269
+ """
270
+ Get a tuple of the classification names: 0=Portfolio, 1=Benchmark
271
+
272
+ Returns:
273
+ tuple[str, str]: A tuple of the classification names: 0=Portfolio, 1=Benchmark
274
+ """
275
+ return (
276
+ self._performances[0].classification_name,
277
+ self._performances[1].classification_name,
278
+ )
279
+
280
+ def _consolidate_all_subperiods(self) -> None:
281
+ """
282
+ Consolidate multiple subperiods (e.g. daily) into single periods (e.g. monthly) based on
283
+ self._frequency. This is done for both the portfolio and benchmark Performances in
284
+ self._performances.
285
+ """
286
+ # Iterate through the portfolio and benchmark Performances.
287
+ for performance in self._performances:
288
+ # Assert that performance.df has at least the same quantity of rows as
289
+ # self._subperiod_dates.
290
+ assert len(self._subperiod_dates) <= performance.df.shape[0], (
291
+ f"{errs.ERROR_999_UNEXPECTED}"
292
+ f"{performance.error_message_context} from {util.date_str(self._beginning_date())}"
293
+ f" to {util.date_str(self._ending_date())}"
294
+ )
295
+
296
+ # If performance.df has more rows than self._subperiod_dates, then that means that
297
+ # performance.df has subperiod rows that need to be consolidated into the
298
+ # self._subperiod_dates periods.
299
+ if len(self._subperiod_dates) < performance.df.shape[0]:
300
+ # Consolidate the subperiods.
301
+ performance.reset_df(
302
+ df=self._consolidate_subperiods(performance).collect(),
303
+ do_reset_column_names=False,
304
+ )
305
+
306
+ def _consolidate_subperiods(self, performance: Performance) -> pl.LazyFrame:
307
+ """
308
+ Consolidate performance.df into one row for each subperiod in self._subperiod_dates.
309
+ For instance, consolidate daily to monthly, or monthly to quarterly.
310
+
311
+ Args:
312
+ performance (Performance): The Performance instance.
313
+
314
+ Returns:
315
+ LazyFrame: _type_: The consolidated Performance LazyFrame.
316
+ """
317
+ # Create a DataFrame, one row per subperiod.
318
+ df_subperiods = (
319
+ pl.DataFrame(
320
+ {
321
+ "beg_date": [bd for bd, _ in self._subperiod_dates],
322
+ "end_date": [ed for _, ed in self._subperiod_dates],
323
+ }
324
+ )
325
+ .with_row_index(name="subperiod_id")
326
+ .lazy()
327
+ )
328
+
329
+ # Create a LazyFrame that contains all of the performance.df columns as well as the
330
+ # subperiod_id and dates.
331
+ joined_lf = performance.df.lazy().join_asof(
332
+ df_subperiods,
333
+ left_on=cols.BEGINNING_DATE,
334
+ right_on="beg_date",
335
+ strategy="backward",
336
+ by=None,
337
+ )
338
+
339
+ # Create a LazyFrame with the subperiod_id and the subperiod_return.
340
+ subperiod_returns = joined_lf.group_by("subperiod_id").agg(
341
+ [pl.col(cols.TOTAL_RETURN).add(1).cum_prod().last().sub(1).alias("subperiod_return")]
342
+ )
343
+
344
+ # Join the subperiod_returns. Since LazyFrame columns cannot have arithmetic performed on
345
+ # themselves, you must collect() here.
346
+ joined_df = joined_lf.join(subperiod_returns, on="subperiod_id").collect()
347
+
348
+ # Append the day-weighting coefficients and the linking coefficients.
349
+ joined_lf = joined_df.lazy().with_columns(
350
+ # Append the day-weighting coefficient column.
351
+ (
352
+ joined_df[cols.QUANTITY_OF_DAYS]
353
+ / (joined_df["end_date"] - joined_df["beg_date"]).dt.total_days()
354
+ ).alias("weight_coefficient"),
355
+ # Append the linking coefficient column.
356
+ pl.struct(["subperiod_return", cols.TOTAL_RETURN])
357
+ .map_batches(
358
+ lambda x: util.logarithmic_linking_coefficient_series(
359
+ x.struct.field("subperiod_return"), x.struct.field(cols.TOTAL_RETURN)
360
+ ),
361
+ return_dtype=pl.Float64,
362
+ )
363
+ .alias("linking_coefficient"),
364
+ )
365
+
366
+ # Get the final consolidated subperiods by linking the returns, summing the day-weighted
367
+ # weights, and summing the contributions after applying the linking coefficients.
368
+ consolidated_subperiods_lf = (
369
+ joined_lf.group_by("subperiod_id")
370
+ .agg(
371
+ [
372
+ # Some of these expressions produce lists of either single values or identical
373
+ # values. So take the first().
374
+ # Dates and Days
375
+ pl.col("beg_date").first().alias(cols.BEGINNING_DATE),
376
+ pl.col("end_date").first().alias(cols.ENDING_DATE),
377
+ pl.col(cols.QUANTITY_OF_DAYS).sum(),
378
+ # Total Return
379
+ pl.col(cols.TOTAL_RETURN).add(1).cum_prod().last().sub(1),
380
+ # Returns
381
+ pl.col(performance.col_names(RET)).add(1).cum_prod().tail(1).sub(1).first(),
382
+ # Weights
383
+ pl.col(performance.col_names(WGT)).mul(pl.col("weight_coefficient")).sum(),
384
+ # Contributions
385
+ pl.col(performance.col_names(CON)).mul(pl.col("linking_coefficient")).sum(),
386
+ ]
387
+ )
388
+ .sort("subperiod_id")
389
+ )
390
+
391
+ # Mark the performance as being consolidated.
392
+ performance.subperiods_have_been_consolidated = True
393
+
394
+ # Collect and return the consolidated subperiods.
395
+ return consolidated_subperiods_lf
396
+
397
+ def _ending_date(self) -> dt.date:
398
+ """
399
+ Summary:
400
+ Get the overall ending date.
401
+ Returns:
402
+ dt.date: The overall ending date.
403
+ """
404
+ return self._subperiod_dates[-1][-1]
405
+
406
+ def get_attribution(
407
+ self,
408
+ classification_name: str = util.EMPTY,
409
+ classification_data_source: util.TypeClassificationDataSource = util.EMPTY,
410
+ mapping_data_sources: tuple[util.TypeMappingDataSource, util.TypeMappingDataSource] = (
411
+ util.EMPTY,
412
+ util.EMPTY,
413
+ ),
414
+ classification_label: str = util.EMPTY,
415
+ ) -> Attribution:
416
+ """
417
+ Get the Attribution instance associated with the classification_name.
418
+
419
+ Args:
420
+ classification_name (str, optional): The classification name for the desired
421
+ Attribution instance. Defaults to util.EMPTY.
422
+ classification_data_source (TypeClassificationDataSource): One of the following:
423
+ 1. A csv file path containing the Classification data.
424
+ 2. A dictionary containing the Classification data.
425
+ 3. A pandas or polars DataFrame containing the Classification data.
426
+ Defaults to util.EMPTY.
427
+ mapping_data_sources (TypeMappingDataSource, TypeMappingDataSource): A tuple of 2
428
+ mapping data sources: 0 = portfolio, 1 = benchmark. Each mapping data source can
429
+ be one of the following:
430
+ 1. A csv file path containing the Mapping data.
431
+ 2. A dictionary containing the Mapping data.
432
+ 3. A pandas or polars DataFrame containing the Mapping data.
433
+ Defaults to (util.EMPTY, util.EMPTY)
434
+ classification_label (str, optional): The classification label that will be displayed
435
+ in the tables and charts if the classification_name is empty. This will happen
436
+ when the performance.classification_items are used. Defaults to util.EMPTY.
437
+
438
+ Data Parameters:
439
+ Sample data for the "classification_data_source" param of a "Security" Classification:
440
+ AAPL, Apple Inc.
441
+ MSFT, Microsft
442
+ ...
443
+ Sample data for the "mapping_data_source" param for "Security" to "Economic Sector":
444
+ AAPL, IT
445
+ GOOG, CS
446
+ ...
447
+
448
+ Returns:
449
+ Attribution: The Attribution instance associated with the classification_name.
450
+ """
451
+ # If the classification_name is empty, and the portflio and benchmark have common
452
+ # non-empty classification_names, then set the classificcation_name to that common
453
+ # classification_name.
454
+ if (
455
+ util.is_empty(classification_name)
456
+ and not util.is_empty(self._performances[0].classification_name)
457
+ and self._performances[0].classification_name
458
+ == self._performances[1].classification_name
459
+ ):
460
+ classification_name = self._performances[0].classification_name
461
+
462
+ # If the classification_name is unknown, and either the portfolio or benchmark have known
463
+ # classificiation names, then mandate that the classification_name is specified. Note
464
+ # that this wll still allow for all 3 of the classifications to be unknown.
465
+ assert not (
466
+ util.is_empty(classification_name)
467
+ and (
468
+ (not util.is_empty(self._performances[0].classification_name))
469
+ or (not util.is_empty(self._performances[1].classification_name))
470
+ )
471
+ ), errs.ERROR_252_MUST_SPECIFY_CLASSIFICATION_NAME
472
+
473
+ # Return the attribution if it already exists in the cache.
474
+ if classification_name in self._attributions:
475
+ return self._attributions[classification_name]
476
+
477
+ # Get the performances for the common classification_name.
478
+ attribution_performances = [
479
+ (
480
+ perf
481
+ if perf.classification_name == classification_name
482
+ else self._map_performance(perf, classification_name, mapping_data_sources[idx])
483
+ )
484
+ for idx, perf in enumerate(self._performances)
485
+ ]
486
+
487
+ # Now that both attribution performances are of the same common Classification,
488
+ # calculate the Attribution.
489
+ self._attributions[classification_name] = Attribution(
490
+ (attribution_performances[0], attribution_performances[1]),
491
+ classification_name,
492
+ classification_data_source,
493
+ self._frequency,
494
+ classification_label,
495
+ )
496
+
497
+ # Return the Attribution coresponding to classification_name.
498
+ return self._attributions[classification_name]
499
+
500
+ def get_riskstatistics(self) -> RiskStatistics:
501
+ """
502
+ Calculates the risk statistics and puts them into the cache self._riskstatistics.
503
+
504
+ Returns:
505
+ pl.DataFrame: A DataFrame of the risk statistics.
506
+ """
507
+ # Calculate the risk statistics if they are not already cached.
508
+ if self._riskstatistics is None:
509
+ self._riskstatistics = RiskStatistics(
510
+ self._performances,
511
+ self._frequency,
512
+ self._annual_minimum_acceptable_return,
513
+ self._annual_risk_free_rate,
514
+ self._confidence_level,
515
+ self._portfolio_value,
516
+ )
517
+
518
+ # Return the DataFrame of the risk statistics.
519
+ return self._riskstatistics
520
+
521
+ def _map_columns(
522
+ self,
523
+ performance: Performance,
524
+ to_froms: defaultdict[str, list[str]],
525
+ suffix: str,
526
+ ) -> pl.LazyFrame:
527
+ """
528
+ Map (sum) columns using the mapping.
529
+
530
+ Args:
531
+ performance (Performance): The Performance to be mapped (summed).
532
+ to_froms: defaultdict[str, list[str]]: A reverse mapping from `to_column_name` to a
533
+ list of `from_column_names`.
534
+ suffix (str): The suffix for the column names that will be mapped
535
+ (e.g., '.con' or '.wgt').
536
+
537
+ Returns:
538
+ pl.LazyFrame: The resulting mapped columns.
539
+ """
540
+ # Create aggregated columns using Polars expressions
541
+ aggregated_columns = [
542
+ pl.sum_horizontal([pl.col(f"{col}{suffix}") for col in from_columns]).alias(
543
+ f"{to_value}{suffix}"
544
+ )
545
+ for to_value, from_columns in to_froms.items()
546
+ ]
547
+
548
+ # Perform the horizontal summations of the expressions. Note that typically there will
549
+ # only be 10 - 50 expressions (e.g. the qty of "to" columns, e.g. the qty of the reporting
550
+ # "to" classification items). But if they have 10,000 securities and incomplete mappings,
551
+ # then there could be close to 10,000 expressions, which polars struggles with. It can run
552
+ # into memory issues, even in lazy mode. So chunk them into batches.
553
+ batch_size = 1000
554
+ horizontally_summed_lfs: list[pl.LazyFrame] = []
555
+ performance_lf = performance.df.lazy()
556
+ for i in range(0, len(aggregated_columns), batch_size):
557
+ horizontally_summed_lfs.append(
558
+ performance_lf.select(aggregated_columns[i : i + batch_size])
559
+ )
560
+
561
+ # Concatenate and return the horizontally_summed_lfs.
562
+ return (
563
+ horizontally_summed_lfs[0]
564
+ if len(horizontally_summed_lfs) == 1
565
+ else pl.concat(horizontally_summed_lfs, how="horizontal")
566
+ )
567
+
568
+ def _map_performance(
569
+ self,
570
+ performance: Performance,
571
+ to_classification_name: str,
572
+ mapping_data_source: util.TypeMappingDataSource,
573
+ ) -> Performance:
574
+ """
575
+ Map from the Performance Classification to the to_classification. For instance, from
576
+ Security to Economic Sector.
577
+
578
+ Args:
579
+ performance (Performance): The existing Performance that will be mapped.
580
+ to_classification_name (str): The classification name that will be mapped to.
581
+ mapping_data_source (TypeMappingDataSource): The Mapping data source.
582
+
583
+ Data Parameters:
584
+ Sample input for the "mapping_data" parameter for "Security" to "Economic Sector":
585
+ AAPL, IT
586
+ GOOG, CO
587
+ ...
588
+
589
+ Returns:
590
+ Performance: The new mapped Performance.
591
+ """
592
+ # Create a reverse mapping from `to_column_name` to a list of `from_column_names`.
593
+ to_froms = Mapping(
594
+ performance.identifiers,
595
+ mapping_data_source,
596
+ ).to_froms
597
+
598
+ # Get DataFrames of the resulting mapped columns with the new mapped identifiers as the new
599
+ # column names. For instance if the roll-up is from security to Economic Sector, then the
600
+ # columns ['aapl.con', 'hpq.con'] will be horizontally summed into a single new column
601
+ # named 'IT'.
602
+ mapped_contribs_lf = self._map_columns(performance, to_froms, CON)
603
+ mapped_weights_lf = self._map_columns(performance, to_froms, WGT)
604
+
605
+ # Get the mapped_df. Note that LazyFrames cannot be divided by one-another, so collect().
606
+ mapped_contribs = mapped_contribs_lf.collect()
607
+ mapped_weights = mapped_weights_lf.collect()
608
+ mapped_lf = (
609
+ # Calulate the returns by dividing contribs / weights.
610
+ (
611
+ (mapped_contribs / mapped_weights)
612
+ .lazy()
613
+ .fill_nan(0.0)
614
+ .fill_null(0.0)
615
+ .rename(lambda column_name: f"{column_name[:-4]}.ret")
616
+ )
617
+ # Add the weights
618
+ .with_columns(mapped_weights)
619
+ # Add the dates
620
+ .with_columns(performance.df[cols.BEGINNING_DATE, cols.ENDING_DATE])
621
+ )
622
+
623
+ # Return the new mapped Performance.
624
+ return Performance(
625
+ mapped_lf.collect(), name=performance.name, classification_name=to_classification_name
626
+ )