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 +19 -0
- ppar/analytics.py +626 -0
- ppar/attribution.py +1125 -0
- ppar/classification.py +107 -0
- ppar/columns.py +175 -0
- ppar/demo_data/classifications/Economic Sector.csv +11 -0
- ppar/demo_data/classifications/Security.csv +503 -0
- ppar/demo_data/mappings/Security--to--Economic Sector.csv +503 -0
- ppar/demo_data/performance/Large-Cap Alpha Portfolio.csv +11843 -0
- ppar/demo_data/performance/Large-Cap Benchmark.csv +17573 -0
- ppar/demo_data_sources.py +92 -0
- ppar/errors.py +73 -0
- ppar/format_chart.py +550 -0
- ppar/format_table.py +293 -0
- ppar/frequency.py +83 -0
- ppar/mapping.py +65 -0
- ppar/performance.py +583 -0
- ppar/py.typed +0 -0
- ppar/riskstatistics.py +656 -0
- ppar/utilities.py +361 -0
- ppar-0.1.0.dist-info/LICENSE +19 -0
- ppar-0.1.0.dist-info/METADATA +153 -0
- ppar-0.1.0.dist-info/RECORD +25 -0
- ppar-0.1.0.dist-info/WHEEL +5 -0
- ppar-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|