openseries 1.8.3__py3-none-any.whl → 1.9.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.
openseries/report.py ADDED
@@ -0,0 +1,479 @@
1
+ """Functions related to HTML reports.
2
+
3
+ Copyright (c) Captor Fund Management AB. This file is part of the openseries project.
4
+
5
+ Licensed under the BSD 3-Clause License. You may obtain a copy of the License at:
6
+ https://github.com/CaptorAB/openseries/blob/master/LICENSE.md
7
+ SPDX-License-Identifier: BSD-3-Clause
8
+ """
9
+
10
+ # mypy: disable-error-code="assignment,index,arg-type"
11
+ from __future__ import annotations
12
+
13
+ from inspect import stack
14
+ from logging import getLogger
15
+ from pathlib import Path
16
+ from secrets import choice
17
+ from string import ascii_letters
18
+ from typing import TYPE_CHECKING, cast
19
+ from warnings import catch_warnings, simplefilter
20
+
21
+ if TYPE_CHECKING: # pragma: no cover
22
+ from pandas import Series
23
+ from plotly.graph_objs import Figure
24
+
25
+ from .frame import OpenFrame
26
+ from .owntypes import LiteralPlotlyJSlib, LiteralPlotlyOutput
27
+
28
+
29
+ from pandas import DataFrame, Series, concat
30
+ from plotly.io import to_html
31
+ from plotly.offline import plot
32
+ from plotly.subplots import make_subplots
33
+
34
+ from .load_plotly import load_plotly_dict
35
+ from .owntypes import (
36
+ LiteralBizDayFreq,
37
+ ValueType,
38
+ )
39
+
40
+ logger = getLogger(__name__)
41
+
42
+
43
+ __all__ = ["report_html"]
44
+
45
+
46
+ def calendar_period_returns(
47
+ data: OpenFrame,
48
+ freq: LiteralBizDayFreq = "BYE",
49
+ *,
50
+ relabel: bool = True,
51
+ ) -> DataFrame:
52
+ """Generate a table of returns with appropriate table labels.
53
+
54
+ Parameters
55
+ ----------
56
+ data: OpenFrame
57
+ The timeseries data
58
+ freq: LiteralBizDayFreq
59
+ The date offset string that sets the resampled frequency
60
+ relabel: bool, default: True
61
+ Whether to set new appropriate labels
62
+
63
+ Returns:
64
+ -------
65
+ pandas.DataFrame
66
+ The resulting data
67
+
68
+ """
69
+ copied = data.from_deepcopy()
70
+ copied.resample_to_business_period_ends(freq=freq)
71
+ vtypes = [x == ValueType.RTRN for x in copied.tsdf.columns.get_level_values(1)]
72
+ if not any(vtypes):
73
+ copied.value_to_ret()
74
+ cldr = copied.tsdf.iloc[1:].copy()
75
+ if relabel:
76
+ if freq == "BYE":
77
+ cldr.index = [d.year for d in cldr.index]
78
+ else:
79
+ cldr.index = [d.strftime("%b %y") for d in cldr.index]
80
+
81
+ return cldr # type: ignore[no-any-return]
82
+
83
+
84
+ def report_html(
85
+ data: OpenFrame,
86
+ bar_freq: LiteralBizDayFreq = "BYE",
87
+ filename: str | None = None,
88
+ title: str | None = None,
89
+ directory: Path | None = None,
90
+ output_type: LiteralPlotlyOutput = "file",
91
+ include_plotlyjs: LiteralPlotlyJSlib = "cdn",
92
+ *,
93
+ auto_open: bool = False,
94
+ add_logo: bool = True,
95
+ vertical_legend: bool = True,
96
+ ) -> tuple[Figure, str]:
97
+ """Generate a HTML report page with line and bar plots and a table.
98
+
99
+ Parameters
100
+ ----------
101
+ data: OpenFrame
102
+ The timeseries data
103
+ bar_freq: LiteralBizDayFreq
104
+ The date offset string that sets the bar plot frequency
105
+ filename: str, optional
106
+ Name of the Plotly html file
107
+ title: str, optional
108
+ The report page title
109
+ directory: DirectoryPath, optional
110
+ Directory where Plotly html file is saved
111
+ output_type: LiteralPlotlyOutput, default: "file"
112
+ Determines output type
113
+ include_plotlyjs: LiteralPlotlyJSlib, default: "cdn"
114
+ Determines how the plotly.js library is included in the output
115
+ auto_open: bool, default: True
116
+ Determines whether to open a browser window with the plot
117
+ add_logo: bool, default: True
118
+ If True a Captor logo is added to the plot
119
+ vertical_legend: bool, default: True
120
+ Determines whether to vertically align the legend's labels
121
+
122
+ Returns:
123
+ -------
124
+ tuple[plotly.go.Figure, str]
125
+ Plotly Figure and a div section or a html filename with location
126
+
127
+ """
128
+ data.trunc_frame().value_nan_handle().to_cumret()
129
+
130
+ if data.yearfrac > 1.0:
131
+ properties = [
132
+ "geo_ret",
133
+ "vol",
134
+ "ret_vol_ratio",
135
+ "sortino_ratio",
136
+ "worst_month",
137
+ "first_indices",
138
+ "last_indices",
139
+ ]
140
+ labels_init = [
141
+ "Return (CAGR)",
142
+ "Volatility",
143
+ "Sharpe Ratio",
144
+ "Sortino Ratio",
145
+ "Worst Month",
146
+ "Comparison Start",
147
+ "Comparison End",
148
+ "Jensen's Alpha",
149
+ "Information Ratio",
150
+ "Tracking Error (weekly)",
151
+ "Capture Ratio (monthly)",
152
+ "Index Beta (weekly)",
153
+ ]
154
+ labels_final = [
155
+ "Return (CAGR)",
156
+ "Year-to-Date",
157
+ "Month-to-Date",
158
+ "Volatility",
159
+ "Sharpe Ratio",
160
+ "Sortino Ratio",
161
+ "Jensen's Alpha",
162
+ "Information Ratio",
163
+ "Tracking Error (weekly)",
164
+ "Index Beta (weekly)",
165
+ "Capture Ratio (monthly)",
166
+ "Worst Month",
167
+ "Comparison Start",
168
+ "Comparison End",
169
+ ]
170
+ else:
171
+ properties = [
172
+ "value_ret",
173
+ "vol",
174
+ "ret_vol_ratio",
175
+ "sortino_ratio",
176
+ "worst",
177
+ "first_indices",
178
+ "last_indices",
179
+ ]
180
+ labels_init = [
181
+ "Return (simple)",
182
+ "Volatility",
183
+ "Sharpe Ratio",
184
+ "Sortino Ratio",
185
+ "Worst Day",
186
+ "Comparison Start",
187
+ "Comparison End",
188
+ "Jensen's Alpha",
189
+ "Information Ratio",
190
+ "Tracking Error (weekly)",
191
+ "Index Beta (weekly)",
192
+ ]
193
+ labels_final = [
194
+ "Return (simple)",
195
+ "Year-to-Date",
196
+ "Month-to-Date",
197
+ "Volatility",
198
+ "Sharpe Ratio",
199
+ "Sortino Ratio",
200
+ "Jensen's Alpha",
201
+ "Information Ratio",
202
+ "Tracking Error (weekly)",
203
+ "Index Beta (weekly)",
204
+ "Worst Day",
205
+ "Comparison Start",
206
+ "Comparison End",
207
+ ]
208
+
209
+ figure = make_subplots(
210
+ rows=2,
211
+ cols=2,
212
+ specs=[
213
+ [{"type": "xy"}, {"rowspan": 2, "type": "table"}],
214
+ [{"type": "xy"}, None],
215
+ ],
216
+ )
217
+
218
+ for item, lbl in enumerate(data.columns_lvl_zero):
219
+ figure.add_scatter(
220
+ x=data.tsdf.index,
221
+ y=data.tsdf.iloc[:, item],
222
+ hovertemplate="%{y:.2%}<br>%{x|%Y-%m-%d}",
223
+ line={"width": 2.5, "dash": "solid"},
224
+ mode="lines",
225
+ name=lbl,
226
+ showlegend=True,
227
+ row=1,
228
+ col=1,
229
+ )
230
+
231
+ quarter_of_year = 0.25
232
+ if data.yearfrac < quarter_of_year:
233
+ tmp = data.from_deepcopy()
234
+ bdf = tmp.value_to_ret().tsdf.iloc[1:]
235
+ else:
236
+ bdf = calendar_period_returns(data, freq=bar_freq)
237
+
238
+ for item in range(data.item_count):
239
+ figure.add_bar(
240
+ x=bdf.index,
241
+ y=bdf.iloc[:, item],
242
+ hovertemplate="%{y:.2%}<br>%{x}",
243
+ name=bdf.iloc[:, item].name[0],
244
+ showlegend=False,
245
+ row=2,
246
+ col=1,
247
+ )
248
+
249
+ formats = [
250
+ "{:.2%}",
251
+ "{:.2%}",
252
+ "{:.2f}",
253
+ "{:.2f}",
254
+ "{:.2%}",
255
+ "{:%Y-%m-%d}",
256
+ "{:%Y-%m-%d}",
257
+ "{:.2%}",
258
+ "{:.2f}",
259
+ "{:.2%}",
260
+ "{:.2f}",
261
+ ]
262
+
263
+ # noinspection PyTypeChecker
264
+ rpt_df = data.all_properties(properties=properties)
265
+ alpha_frame = data.from_deepcopy()
266
+ alpha_frame.to_cumret()
267
+ with catch_warnings():
268
+ simplefilter("ignore")
269
+ alphas: list[str | float] = [
270
+ alpha_frame.jensen_alpha(
271
+ asset=(aname, ValueType.PRICE),
272
+ market=(alpha_frame.columns_lvl_zero[-1], ValueType.PRICE),
273
+ riskfree_rate=0.0,
274
+ )
275
+ for aname in alpha_frame.columns_lvl_zero[:-1]
276
+ ]
277
+ alphas.append("")
278
+ ar = DataFrame(data=alphas, index=data.tsdf.columns, columns=["Jensen's Alpha"]).T
279
+ rpt_df = concat([rpt_df, ar])
280
+ ir = data.info_ratio_func()
281
+ ir.name = "Information Ratio"
282
+ ir.iloc[-1] = None
283
+ ir = ir.to_frame().T
284
+ rpt_df = concat([rpt_df, ir])
285
+ te_frame = data.from_deepcopy()
286
+ te_frame.resample("7D")
287
+ with catch_warnings():
288
+ simplefilter("ignore")
289
+ te = te_frame.tracking_error_func()
290
+ if te.hasnans:
291
+ te = Series(
292
+ data=[""] * te_frame.item_count,
293
+ index=te_frame.tsdf.columns,
294
+ name="Tracking Error (weekly)",
295
+ )
296
+ else:
297
+ te.iloc[-1] = None
298
+ te.name = "Tracking Error (weekly)"
299
+ te = te.to_frame().T
300
+ rpt_df = concat([rpt_df, te])
301
+
302
+ if data.yearfrac > 1.0:
303
+ crm = data.from_deepcopy()
304
+ crm.resample("ME")
305
+ cru_save = Series(
306
+ data=[""] * crm.item_count,
307
+ index=crm.tsdf.columns,
308
+ name="Capture Ratio (monthly)",
309
+ )
310
+ with catch_warnings():
311
+ simplefilter("ignore")
312
+ try:
313
+ cru = crm.capture_ratio_func(ratio="both")
314
+ except ZeroDivisionError as exc: # pragma: no cover
315
+ msg = f"Capture ratio calculation error: {exc!s}" # pragma: no cover
316
+ logger.warning(msg=msg) # pragma: no cover
317
+ cru = cru_save # pragma: no cover
318
+ if cru.hasnans:
319
+ cru = cru_save
320
+ else:
321
+ cru.iloc[-1] = None
322
+ cru.name = "Capture Ratio (monthly)"
323
+ cru = cru.to_frame().T
324
+ rpt_df = concat([rpt_df, cru])
325
+ formats.append("{:.2f}")
326
+ beta_frame = data.from_deepcopy()
327
+ beta_frame.resample("7D").value_nan_handle("drop")
328
+ beta_frame.to_cumret()
329
+ betas: list[str | float] = [
330
+ beta_frame.beta(
331
+ asset=(bname, ValueType.PRICE),
332
+ market=(beta_frame.columns_lvl_zero[-1], ValueType.PRICE),
333
+ )
334
+ for bname in beta_frame.columns_lvl_zero[:-1]
335
+ ]
336
+ # noinspection PyTypeChecker
337
+ betas.append("")
338
+ br = DataFrame(
339
+ data=betas,
340
+ index=data.tsdf.columns,
341
+ columns=["Index Beta (weekly)"],
342
+ ).T
343
+ rpt_df = concat([rpt_df, br])
344
+
345
+ for item, f in zip(rpt_df.index, formats, strict=False):
346
+ rpt_df.loc[item] = rpt_df.loc[item].apply(
347
+ lambda x, fmt=f: x if (isinstance(x, str) or x is None) else fmt.format(x), # type: ignore[return-value]
348
+ )
349
+
350
+ rpt_df.index = labels_init
351
+
352
+ this_year = data.last_idx.year
353
+ this_month = data.last_idx.month
354
+ ytd = cast("Series[float]", data.value_ret_calendar_period(year=this_year)).map(
355
+ "{:.2%}".format
356
+ )
357
+ ytd.name = "Year-to-Date"
358
+ mtd = cast(
359
+ "Series[float]",
360
+ data.value_ret_calendar_period(year=this_year, month=this_month),
361
+ ).map(
362
+ "{:.2%}".format,
363
+ )
364
+ mtd.name = "Month-to-Date"
365
+ ytd = ytd.to_frame().T
366
+ mtd = mtd.to_frame().T
367
+ rpt_df = concat([rpt_df, ytd])
368
+ rpt_df = concat([rpt_df, mtd])
369
+ rpt_df = rpt_df.reindex(labels_final)
370
+
371
+ rpt_df.index = [f"<b>{x}</b>" for x in rpt_df.index]
372
+ rpt_df = rpt_df.reset_index()
373
+
374
+ colmns = ["", *data.columns_lvl_zero]
375
+ columns = [f"<b>{x}</b>" for x in colmns]
376
+ aligning = ["left"] + ["center"] * (len(columns) - 1)
377
+
378
+ col_even_color = "lightgrey"
379
+ col_odd_color = "white"
380
+ color_lst = ["grey"] + [col_odd_color] * (data.item_count - 1) + [col_even_color]
381
+
382
+ tablevalues = rpt_df.transpose().to_numpy().tolist()
383
+ cleanedtablevalues = list(tablevalues)[:-1]
384
+ cleanedcol = [
385
+ valu if valu not in ["nan", "nan%"] else "" for valu in tablevalues[-1]
386
+ ]
387
+ cleanedtablevalues.append(cleanedcol)
388
+
389
+ figure.add_table(
390
+ header={
391
+ "values": columns,
392
+ "align": "center",
393
+ "fill_color": "grey",
394
+ "font": {"color": "white"},
395
+ },
396
+ cells={
397
+ "values": cleanedtablevalues,
398
+ "align": aligning,
399
+ "height": 25,
400
+ "fill_color": color_lst,
401
+ "font": {"color": ["white"] + ["black"] * len(columns)},
402
+ },
403
+ row=1,
404
+ col=2,
405
+ )
406
+
407
+ if directory:
408
+ dirpath = Path(directory).resolve()
409
+ elif Path.home().joinpath("Documents").exists():
410
+ dirpath = Path.home() / "Documents"
411
+ else:
412
+ dirpath = Path(stack()[1].filename).parent
413
+
414
+ if not filename:
415
+ filename = "".join(choice(ascii_letters) for _ in range(6)) + ".html"
416
+
417
+ plotfile = dirpath / filename
418
+
419
+ fig, logo = load_plotly_dict()
420
+
421
+ if add_logo:
422
+ figure.add_layout_image(logo)
423
+
424
+ figure.update_layout(fig.get("layout"))
425
+ colorway: list[str] = cast("dict[str, list[str]]", fig["layout"]).get("colorway")
426
+
427
+ if vertical_legend:
428
+ legend = {
429
+ "yanchor": "bottom",
430
+ "y": -0.04,
431
+ "xanchor": "right",
432
+ "x": 0.98,
433
+ "orientation": "v",
434
+ }
435
+ else:
436
+ legend = {
437
+ "yanchor": "bottom",
438
+ "y": -0.2,
439
+ "xanchor": "right",
440
+ "x": 0.98,
441
+ "orientation": "h",
442
+ }
443
+
444
+ figure.update_layout(
445
+ legend=legend,
446
+ colorway=colorway[: data.item_count],
447
+ )
448
+ figure.update_xaxes(gridcolor="#EEEEEE", automargin=True, tickangle=-45)
449
+ figure.update_yaxes(tickformat=".2%", gridcolor="#EEEEEE", automargin=True)
450
+
451
+ if isinstance(title, str):
452
+ figure.update_layout(
453
+ {"title": {"text": f"<b>{title}</b><br>", "font": {"size": 36}}},
454
+ )
455
+
456
+ if output_type == "file":
457
+ plot(
458
+ figure_or_data=figure,
459
+ filename=str(plotfile),
460
+ auto_open=auto_open,
461
+ auto_play=False,
462
+ link_text="",
463
+ include_plotlyjs=cast("bool", include_plotlyjs),
464
+ output_type=output_type,
465
+ config=fig["config"],
466
+ )
467
+ string_output = str(plotfile)
468
+ else:
469
+ div_id = filename.split(sep=".")[0]
470
+ string_output = to_html(
471
+ fig=figure,
472
+ div_id=div_id,
473
+ auto_play=False,
474
+ full_html=False,
475
+ include_plotlyjs=cast("bool", include_plotlyjs),
476
+ config=fig["config"],
477
+ )
478
+
479
+ return figure, string_output
openseries/series.py CHANGED
@@ -1,4 +1,11 @@
1
- """Defining the OpenTimeSeries class."""
1
+ """Defining the OpenTimeSeries class.
2
+
3
+ Copyright (c) Captor Fund Management AB. This file is part of the openseries project.
4
+
5
+ Licensed under the BSD 3-Clause License. You may obtain a copy of the License at:
6
+ https://github.com/CaptorAB/openseries/blob/master/LICENSE.md
7
+ SPDX-License-Identifier: BSD-3-Clause
8
+ """
2
9
 
3
10
  # mypy: disable-error-code="no-any-return"
4
11
  from __future__ import annotations
@@ -45,6 +52,7 @@ from .owntypes import (
45
52
  LiteralBizDayFreq,
46
53
  LiteralPandasReindexMethod,
47
54
  LiteralSeriesProps,
55
+ MarketsNotStringNorListStrError,
48
56
  OpenTimeSeriesPropertiesList,
49
57
  Self,
50
58
  ValueListType,
@@ -91,6 +99,8 @@ class OpenTimeSeries(_CommonModel):
91
99
  ISO 4217 currency code of the user's home currency
92
100
  countries: CountriesType, default: "SE"
93
101
  (List of) country code(s) according to ISO 3166-1 alpha-2
102
+ markets: list[str] | str, optional
103
+ (List of) markets code(s) according to pandas-market-calendars
94
104
  isin : str, optional
95
105
  ISO 6166 identifier code of the associated instrument
96
106
  label : str, optional
@@ -109,6 +119,7 @@ class OpenTimeSeries(_CommonModel):
109
119
  currency: CurrencyStringType
110
120
  domestic: CurrencyStringType = "SEK"
111
121
  countries: CountriesType = "SE"
122
+ markets: list[str] | str | None = None # type: ignore[assignment]
112
123
  isin: str | None = None
113
124
  label: str | None = None
114
125
 
@@ -126,6 +137,25 @@ class OpenTimeSeries(_CommonModel):
126
137
  _ = Countries(countryinput=value)
127
138
  return value
128
139
 
140
+ @field_validator("markets", mode="before") # type: ignore[misc]
141
+ @classmethod
142
+ def _validate_markets(
143
+ cls, value: list[str] | str | None
144
+ ) -> list[str] | str | None:
145
+ """Pydantic validator to ensure markets field is validated."""
146
+ msg = (
147
+ "'markets' must be a string or list of strings, "
148
+ f"got {type(value).__name__!r}"
149
+ )
150
+ if value is None or isinstance(value, str):
151
+ return value
152
+ if isinstance(value, list):
153
+ if all(isinstance(item, str) for item in value) and len(value) != 0:
154
+ return value
155
+ item_msg = "All items in 'markets' must be strings."
156
+ raise MarketsNotStringNorListStrError(item_msg)
157
+ raise MarketsNotStringNorListStrError(msg)
158
+
129
159
  @model_validator(mode="after") # type: ignore[misc,unused-ignore]
130
160
  def _dates_and_values_validate(self: Self) -> Self:
131
161
  """Pydantic validator to ensure dates and values are validated."""
@@ -587,6 +617,7 @@ class OpenTimeSeries(_CommonModel):
587
617
  data=self.tsdf,
588
618
  freq=freq,
589
619
  countries=self.countries,
620
+ markets=self.markets,
590
621
  )
591
622
  self.tsdf = self.tsdf.reindex([deyt.date() for deyt in dates], method=method)
592
623
  return self
openseries/simulation.py CHANGED
@@ -1,4 +1,11 @@
1
- """Defining the ReturnSimulation class."""
1
+ """Defining the ReturnSimulation class.
2
+
3
+ Copyright (c) Captor Fund Management AB. This file is part of the openseries project.
4
+
5
+ Licensed under the BSD 3-Clause License. You may obtain a copy of the License at:
6
+ https://github.com/CaptorAB/openseries/blob/master/LICENSE.md
7
+ SPDX-License-Identifier: BSD-3-Clause
8
+ """
2
9
 
3
10
  # mypy: disable-error-code="no-any-return"
4
11
  from __future__ import annotations
@@ -417,6 +424,7 @@ class ReturnSimulation(BaseModel): # type: ignore[misc]
417
424
  start: dt.date | None = None,
418
425
  end: dt.date | None = None,
419
426
  countries: CountriesType = "SE",
427
+ markets: list[str] | str | None = None,
420
428
  ) -> DataFrame:
421
429
  """Create a pandas.DataFrame from simulation(s).
422
430
 
@@ -430,6 +438,8 @@ class ReturnSimulation(BaseModel): # type: ignore[misc]
430
438
  Date when the simulation ends
431
439
  countries: CountriesType, default: "SE"
432
440
  (List of) country code(s) according to ISO 3166-1 alpha-2
441
+ markets: list[str] | str, optional
442
+ (List of) markets code(s) according to pandas-market-calendars
433
443
 
434
444
  Returns:
435
445
  -------
@@ -442,6 +452,7 @@ class ReturnSimulation(BaseModel): # type: ignore[misc]
442
452
  start=start,
443
453
  end=end,
444
454
  countries=countries,
455
+ markets=markets,
445
456
  )
446
457
 
447
458
  if self.number_of_sims == 1: