openseries 2.1.2__tar.gz → 2.1.4__tar.gz
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-2.1.2 → openseries-2.1.4}/PKG-INFO +1 -1
- openseries-2.1.4/openseries/report.py +670 -0
- {openseries-2.1.2 → openseries-2.1.4}/pyproject.toml +3 -3
- openseries-2.1.2/openseries/report.py +0 -1453
- {openseries-2.1.2 → openseries-2.1.4}/LICENSE.md +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/README.md +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/__init__.py +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/_common_model.py +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/_risk.py +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/datefixer.py +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/frame.py +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/load_plotly.py +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/owntypes.py +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/plotly_captor_logo.json +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/plotly_layouts.json +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/portfoliotools.py +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/py.typed +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/series.py +0 -0
- {openseries-2.1.2 → openseries-2.1.4}/openseries/simulation.py +0 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""Functions related to HTML reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from inspect import stack
|
|
6
|
+
from itertools import cycle
|
|
7
|
+
from json import dumps as json_dumps
|
|
8
|
+
from logging import getLogger
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from secrets import choice
|
|
11
|
+
from string import ascii_letters
|
|
12
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
13
|
+
from warnings import catch_warnings, simplefilter
|
|
14
|
+
from webbrowser import open as webbrowser_open
|
|
15
|
+
|
|
16
|
+
from pandas import DataFrame, Index, Series, Timestamp, concat, isna
|
|
17
|
+
from plotly.graph_objs import Bar, Figure, Scatter # type: ignore[import-untyped]
|
|
18
|
+
from plotly.utils import PlotlyJSONEncoder # type: ignore[import-untyped]
|
|
19
|
+
|
|
20
|
+
from .load_plotly import load_plotly_dict
|
|
21
|
+
from .owntypes import (
|
|
22
|
+
CaptorLogoType,
|
|
23
|
+
LiteralBizDayFreq,
|
|
24
|
+
LiteralFrameProps,
|
|
25
|
+
LiteralPlotlyJSlib,
|
|
26
|
+
LiteralPlotlyOutput,
|
|
27
|
+
ValueType,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
31
|
+
from .frame import OpenFrame
|
|
32
|
+
|
|
33
|
+
logger = getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
__all__ = ["report_html"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def calendar_period_returns(
|
|
39
|
+
data: OpenFrame,
|
|
40
|
+
freq: LiteralBizDayFreq = "BYE",
|
|
41
|
+
*,
|
|
42
|
+
relabel: bool = True,
|
|
43
|
+
) -> DataFrame:
|
|
44
|
+
"""Generate a table of returns with appropriate table labels."""
|
|
45
|
+
copied = data.from_deepcopy()
|
|
46
|
+
copied.resample_to_business_period_ends(freq=freq)
|
|
47
|
+
copied.value_to_ret()
|
|
48
|
+
cldr = copied.tsdf.iloc[1:].copy()
|
|
49
|
+
if relabel:
|
|
50
|
+
if freq.upper() == "BYE":
|
|
51
|
+
cldr.index = Index([d.year for d in cldr.index])
|
|
52
|
+
elif freq.upper() == "BQE":
|
|
53
|
+
cldr.index = Index(
|
|
54
|
+
[Timestamp(d).to_period("Q").strftime("Q%q %Y") for d in cldr.index],
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
cldr.index = Index([d.strftime("%b %y") for d in cldr.index])
|
|
58
|
+
return cldr
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _dumps_plotly(obj: object) -> str:
|
|
62
|
+
return json_dumps(obj, cls=PlotlyJSONEncoder)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fmt_dates(idx: Index) -> list[str]:
|
|
66
|
+
return [Timestamp(d).strftime("%Y-%m-%d") for d in idx]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _metrics_table_html(df: DataFrame) -> str:
|
|
70
|
+
return df.to_html(index=False, escape=False, classes=["metrics"], border=0)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_report_properties_and_labels(
|
|
74
|
+
yearfrac: float,
|
|
75
|
+
) -> tuple[list[str], list[str], list[str]]:
|
|
76
|
+
"""Get properties and labels based on yearfrac."""
|
|
77
|
+
if yearfrac > 1.0:
|
|
78
|
+
properties = [
|
|
79
|
+
"geo_ret",
|
|
80
|
+
"vol",
|
|
81
|
+
"ret_vol_ratio",
|
|
82
|
+
"sortino_ratio",
|
|
83
|
+
"worst_month",
|
|
84
|
+
"first_indices",
|
|
85
|
+
"last_indices",
|
|
86
|
+
]
|
|
87
|
+
labels_init = [
|
|
88
|
+
"Return (CAGR)",
|
|
89
|
+
"Volatility",
|
|
90
|
+
"Sharpe Ratio",
|
|
91
|
+
"Sortino Ratio",
|
|
92
|
+
"Worst Month",
|
|
93
|
+
"Comparison Start",
|
|
94
|
+
"Comparison End",
|
|
95
|
+
"Jensen's Alpha",
|
|
96
|
+
"Information Ratio",
|
|
97
|
+
"Tracking Error (weekly)",
|
|
98
|
+
"Capture Ratio (monthly)",
|
|
99
|
+
"Index Beta (weekly)",
|
|
100
|
+
]
|
|
101
|
+
labels_final = [
|
|
102
|
+
"Return (CAGR)",
|
|
103
|
+
"Year-to-Date",
|
|
104
|
+
"Month-to-Date",
|
|
105
|
+
"Volatility",
|
|
106
|
+
"Sharpe Ratio",
|
|
107
|
+
"Sortino Ratio",
|
|
108
|
+
"Jensen's Alpha",
|
|
109
|
+
"Information Ratio",
|
|
110
|
+
"Tracking Error (weekly)",
|
|
111
|
+
"Index Beta (weekly)",
|
|
112
|
+
"Capture Ratio (monthly)",
|
|
113
|
+
"Worst Month",
|
|
114
|
+
"Comparison Start",
|
|
115
|
+
"Comparison End",
|
|
116
|
+
]
|
|
117
|
+
else:
|
|
118
|
+
properties = [
|
|
119
|
+
"value_ret",
|
|
120
|
+
"vol",
|
|
121
|
+
"ret_vol_ratio",
|
|
122
|
+
"sortino_ratio",
|
|
123
|
+
"worst",
|
|
124
|
+
"first_indices",
|
|
125
|
+
"last_indices",
|
|
126
|
+
]
|
|
127
|
+
labels_init = [
|
|
128
|
+
"Return (simple)",
|
|
129
|
+
"Volatility",
|
|
130
|
+
"Sharpe Ratio",
|
|
131
|
+
"Sortino Ratio",
|
|
132
|
+
"Worst Day",
|
|
133
|
+
"Comparison Start",
|
|
134
|
+
"Comparison End",
|
|
135
|
+
"Jensen's Alpha",
|
|
136
|
+
"Information Ratio",
|
|
137
|
+
"Tracking Error (weekly)",
|
|
138
|
+
"Index Beta (weekly)",
|
|
139
|
+
]
|
|
140
|
+
labels_final = [
|
|
141
|
+
"Return (simple)",
|
|
142
|
+
"Year-to-Date",
|
|
143
|
+
"Month-to-Date",
|
|
144
|
+
"Volatility",
|
|
145
|
+
"Sharpe Ratio",
|
|
146
|
+
"Sortino Ratio",
|
|
147
|
+
"Jensen's Alpha",
|
|
148
|
+
"Information Ratio",
|
|
149
|
+
"Tracking Error (weekly)",
|
|
150
|
+
"Index Beta (weekly)",
|
|
151
|
+
"Worst Day",
|
|
152
|
+
"Comparison Start",
|
|
153
|
+
"Comparison End",
|
|
154
|
+
]
|
|
155
|
+
return properties, labels_init, labels_final
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _create_line_traces(data: OpenFrame) -> list[Scatter]:
|
|
159
|
+
"""Create line traces for the plot."""
|
|
160
|
+
x_line = _fmt_dates(data.tsdf.index)
|
|
161
|
+
line_traces: list[Scatter] = []
|
|
162
|
+
for item, lbl in enumerate(data.columns_lvl_zero):
|
|
163
|
+
line_traces.append(
|
|
164
|
+
Scatter(
|
|
165
|
+
x=x_line,
|
|
166
|
+
y=data.tsdf.iloc[:, item].tolist(),
|
|
167
|
+
hovertemplate=f"{lbl}<br>%{{y:.2%}}<br>%{{x}}<extra></extra>",
|
|
168
|
+
line={"width": 2.5, "dash": "solid"},
|
|
169
|
+
mode="lines",
|
|
170
|
+
name=lbl,
|
|
171
|
+
showlegend=True,
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
return line_traces
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _create_bar_traces(
|
|
178
|
+
data: OpenFrame,
|
|
179
|
+
bar_freq: LiteralBizDayFreq,
|
|
180
|
+
) -> list[Bar]:
|
|
181
|
+
"""Create bar traces for the plot."""
|
|
182
|
+
quarter_of_year = 0.25
|
|
183
|
+
if data.yearfrac < quarter_of_year:
|
|
184
|
+
tmp = data.from_deepcopy()
|
|
185
|
+
bdf = tmp.value_to_ret().tsdf.iloc[1:]
|
|
186
|
+
else:
|
|
187
|
+
bdf = calendar_period_returns(data=data, freq=bar_freq)
|
|
188
|
+
|
|
189
|
+
x_bar = [str(x) for x in bdf.index]
|
|
190
|
+
bar_traces: list[Bar] = []
|
|
191
|
+
for item in range(data.item_count):
|
|
192
|
+
col_name = cast("tuple[str, ValueType]", bdf.iloc[:, item].name)
|
|
193
|
+
bar_traces.append(
|
|
194
|
+
Bar(
|
|
195
|
+
x=x_bar,
|
|
196
|
+
y=bdf.iloc[:, item].tolist(),
|
|
197
|
+
hovertemplate=f"{col_name[0]}<br>%{{y:.2%}}<br>%{{x}}<extra></extra>",
|
|
198
|
+
name=col_name[0],
|
|
199
|
+
showlegend=False,
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
return bar_traces
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _add_jensen_alpha(
|
|
206
|
+
rpt_df: DataFrame,
|
|
207
|
+
data: OpenFrame,
|
|
208
|
+
) -> DataFrame:
|
|
209
|
+
"""Add Jensen's Alpha to the report dataframe."""
|
|
210
|
+
alpha_frame = data.from_deepcopy()
|
|
211
|
+
alpha_frame.to_cumret()
|
|
212
|
+
with catch_warnings():
|
|
213
|
+
simplefilter("ignore")
|
|
214
|
+
alphas: list[str | float] = [
|
|
215
|
+
alpha_frame.jensen_alpha(
|
|
216
|
+
asset=(aname, ValueType.PRICE),
|
|
217
|
+
market=(alpha_frame.columns_lvl_zero[-1], ValueType.PRICE),
|
|
218
|
+
riskfree_rate=0.0,
|
|
219
|
+
)
|
|
220
|
+
for aname in alpha_frame.columns_lvl_zero[:-1]
|
|
221
|
+
]
|
|
222
|
+
alphas.append("")
|
|
223
|
+
ar = DataFrame(
|
|
224
|
+
data=alphas,
|
|
225
|
+
index=data.tsdf.columns,
|
|
226
|
+
columns=["Jensen's Alpha"],
|
|
227
|
+
).T
|
|
228
|
+
return concat([rpt_df, ar])
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _add_information_ratio(
|
|
232
|
+
rpt_df: DataFrame,
|
|
233
|
+
data: OpenFrame,
|
|
234
|
+
) -> DataFrame:
|
|
235
|
+
"""Add Information Ratio to the report dataframe."""
|
|
236
|
+
ir = data.info_ratio_func()
|
|
237
|
+
ir.name = "Information Ratio"
|
|
238
|
+
ir.iloc[-1] = None
|
|
239
|
+
ir_df = ir.to_frame().T
|
|
240
|
+
return concat([rpt_df, ir_df])
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _add_tracking_error(
|
|
244
|
+
rpt_df: DataFrame,
|
|
245
|
+
data: OpenFrame,
|
|
246
|
+
) -> DataFrame:
|
|
247
|
+
"""Add Tracking Error to the report dataframe."""
|
|
248
|
+
te_frame = data.from_deepcopy()
|
|
249
|
+
te_frame.resample("7D")
|
|
250
|
+
with catch_warnings():
|
|
251
|
+
simplefilter("ignore")
|
|
252
|
+
te: Series[float] | Series[str] = te_frame.tracking_error_func()
|
|
253
|
+
if te.hasnans:
|
|
254
|
+
te = Series(
|
|
255
|
+
data=[""] * te_frame.item_count,
|
|
256
|
+
index=te_frame.tsdf.columns,
|
|
257
|
+
name="Tracking Error (weekly)",
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
te.iloc[-1] = None
|
|
261
|
+
te.name = "Tracking Error (weekly)"
|
|
262
|
+
te_df = te.to_frame().T
|
|
263
|
+
return concat([rpt_df, te_df])
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _add_capture_ratio(
|
|
267
|
+
rpt_df: DataFrame,
|
|
268
|
+
data: OpenFrame,
|
|
269
|
+
formats: list[str],
|
|
270
|
+
) -> tuple[DataFrame, list[str]]:
|
|
271
|
+
"""Add Capture Ratio to the report dataframe."""
|
|
272
|
+
crm = data.from_deepcopy()
|
|
273
|
+
crm.resample("ME")
|
|
274
|
+
cru_save = Series(
|
|
275
|
+
data=[""] * crm.item_count,
|
|
276
|
+
index=crm.tsdf.columns,
|
|
277
|
+
name="Capture Ratio (monthly)",
|
|
278
|
+
)
|
|
279
|
+
with catch_warnings():
|
|
280
|
+
simplefilter("ignore")
|
|
281
|
+
try:
|
|
282
|
+
cru: Series[float] | Series[str] = crm.capture_ratio_func(ratio="both")
|
|
283
|
+
except ZeroDivisionError as exc: # pragma: no cover
|
|
284
|
+
msg = f"Capture ratio calculation error: {exc!s}" # pragma: no cover
|
|
285
|
+
logger.warning(msg) # pragma: no cover
|
|
286
|
+
cru = cru_save # pragma: no cover
|
|
287
|
+
if cru.hasnans:
|
|
288
|
+
cru = cru_save
|
|
289
|
+
else:
|
|
290
|
+
cru.iloc[-1] = None
|
|
291
|
+
cru.name = "Capture Ratio (monthly)"
|
|
292
|
+
cru_df = cru.to_frame().T
|
|
293
|
+
return concat([rpt_df, cru_df]), formats
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _add_beta(
|
|
297
|
+
rpt_df: DataFrame,
|
|
298
|
+
data: OpenFrame,
|
|
299
|
+
) -> DataFrame:
|
|
300
|
+
"""Add Index Beta to the report dataframe."""
|
|
301
|
+
beta_frame = data.from_deepcopy()
|
|
302
|
+
beta_frame.resample("7D").value_nan_handle("drop")
|
|
303
|
+
beta_frame.to_cumret()
|
|
304
|
+
betas: list[str | float] = [
|
|
305
|
+
beta_frame.beta(
|
|
306
|
+
asset=(bname, ValueType.PRICE),
|
|
307
|
+
market=(beta_frame.columns_lvl_zero[-1], ValueType.PRICE),
|
|
308
|
+
)
|
|
309
|
+
for bname in beta_frame.columns_lvl_zero[:-1]
|
|
310
|
+
]
|
|
311
|
+
betas.append("")
|
|
312
|
+
br = DataFrame(
|
|
313
|
+
data=betas,
|
|
314
|
+
index=data.tsdf.columns,
|
|
315
|
+
columns=["Index Beta (weekly)"],
|
|
316
|
+
).T
|
|
317
|
+
return concat([rpt_df, br])
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _add_ytd_mtd(
|
|
321
|
+
rpt_df: DataFrame,
|
|
322
|
+
data: OpenFrame,
|
|
323
|
+
) -> DataFrame:
|
|
324
|
+
"""Add Year-to-Date and Month-to-Date to the report dataframe."""
|
|
325
|
+
this_year = data.last_idx.year
|
|
326
|
+
this_month = data.last_idx.month
|
|
327
|
+
ytd = data.value_ret_calendar_period(year=this_year).map("{:.2%}".format)
|
|
328
|
+
ytd.name = "Year-to-Date"
|
|
329
|
+
mtd = data.value_ret_calendar_period(year=this_year, month=this_month).map(
|
|
330
|
+
"{:.2%}".format,
|
|
331
|
+
)
|
|
332
|
+
mtd.name = "Month-to-Date"
|
|
333
|
+
ytd_df = ytd.to_frame().T
|
|
334
|
+
mtd_df = mtd.to_frame().T
|
|
335
|
+
return concat([rpt_df, ytd_df, mtd_df])
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _get_output_directory(directory: Path | None) -> Path:
|
|
339
|
+
"""Determine the output directory."""
|
|
340
|
+
if directory:
|
|
341
|
+
return Path(directory).resolve()
|
|
342
|
+
if Path.home().joinpath("Documents").exists():
|
|
343
|
+
return Path.home() / "Documents"
|
|
344
|
+
return Path(stack()[1].filename).parent
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _get_plotly_layouts(
|
|
348
|
+
layout_theme: dict[str, Any],
|
|
349
|
+
colorway: list[str],
|
|
350
|
+
item_count: int,
|
|
351
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
352
|
+
"""Get line and bar layouts for plotly."""
|
|
353
|
+
line_layout = dict(layout_theme)
|
|
354
|
+
line_layout.update(
|
|
355
|
+
{
|
|
356
|
+
"colorway": colorway[:item_count] if colorway else None,
|
|
357
|
+
"margin": {"l": 50, "r": 20, "t": 20, "b": 40},
|
|
358
|
+
"xaxis": {"gridcolor": "#EEEEEE", "automargin": True, "tickangle": -45},
|
|
359
|
+
"yaxis": {"tickformat": ".2%", "gridcolor": "#EEEEEE", "automargin": True},
|
|
360
|
+
"showlegend": False,
|
|
361
|
+
},
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
bar_layout = dict(layout_theme)
|
|
365
|
+
bar_layout.update(
|
|
366
|
+
{
|
|
367
|
+
"barmode": "group",
|
|
368
|
+
"margin": {"l": 50, "r": 20, "t": 10, "b": 80},
|
|
369
|
+
"xaxis": {"gridcolor": "#EEEEEE", "automargin": True, "tickangle": -45},
|
|
370
|
+
"yaxis": {"tickformat": ".2%", "gridcolor": "#EEEEEE", "automargin": True},
|
|
371
|
+
"showlegend": False,
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
return line_layout, bar_layout
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _get_logo_html(logo: CaptorLogoType, *, add_logo: bool) -> str:
|
|
379
|
+
"""Get logo HTML."""
|
|
380
|
+
if not add_logo:
|
|
381
|
+
return ""
|
|
382
|
+
try:
|
|
383
|
+
src = cast("dict[str, Any]", logo).get("source", "")
|
|
384
|
+
except (KeyError, AttributeError, TypeError):
|
|
385
|
+
src = ""
|
|
386
|
+
if src:
|
|
387
|
+
return f'<img src="{src}" alt="Captor" style="height:68px;" />'
|
|
388
|
+
return "CAPTOR"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _get_legend_html(line_traces: list[Scatter], colorway: list[str]) -> str:
|
|
392
|
+
"""Generate HTML for the legend at the bottom of the page."""
|
|
393
|
+
legend_items = []
|
|
394
|
+
color_cycle = cycle(colorway or ["#66725B"])
|
|
395
|
+
for trace in line_traces:
|
|
396
|
+
name = trace.name or ""
|
|
397
|
+
color = next(color_cycle)
|
|
398
|
+
legend_items.append(
|
|
399
|
+
f'<div class="legend-item">'
|
|
400
|
+
f'<div class="legend-color" style="background-color:{color};"></div>'
|
|
401
|
+
f"<span>{name}</span>"
|
|
402
|
+
f"</div>",
|
|
403
|
+
)
|
|
404
|
+
if legend_items:
|
|
405
|
+
return f'<div class="legend-container">{"".join(legend_items)}</div>'
|
|
406
|
+
return ""
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _get_css() -> str:
|
|
410
|
+
"""Get CSS styles for the HTML report."""
|
|
411
|
+
return """
|
|
412
|
+
:root{--ink:#1f2a44;--muted:#6b778c;--header:#4a4a4a;--header2:#6a6a6a;--cell:#f3f3f3;--cell2:#e6e6e6;--paper:#ffffff;}
|
|
413
|
+
html,body{margin:0;padding:0;background:var(--paper);color:var(--ink);
|
|
414
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;}
|
|
415
|
+
.page{max-width:calc(100% - 64px);margin:0 auto;padding:32px;
|
|
416
|
+
padding-bottom:48px;}
|
|
417
|
+
.header{display:grid;grid-template-columns:140px 1fr 140px;gap:12px;
|
|
418
|
+
align-items:start;}
|
|
419
|
+
h1{margin:0;text-align:center;font-size:45px;font-weight:800;}
|
|
420
|
+
.layout{display:grid;grid-template-columns:1.2fr .9fr;
|
|
421
|
+
grid-template-areas:"charts table";gap:22px;align-items:start;margin-top:12px;}
|
|
422
|
+
.charts{grid-area:charts;display:grid;grid-template-rows:auto auto;gap:18px;}
|
|
423
|
+
.table{grid-area:table;}
|
|
424
|
+
.plot{width:100%;height:380px;}
|
|
425
|
+
.plot.bar{height:300px;}
|
|
426
|
+
@media (max-width:980px){
|
|
427
|
+
.page{padding:24px;padding-bottom:24px;}
|
|
428
|
+
.header{grid-template-columns:120px 1fr;}
|
|
429
|
+
h1{font-size:36px;}
|
|
430
|
+
.layout{grid-template-columns:1fr;grid-template-areas:"table" "charts";gap:16px;}
|
|
431
|
+
.plot{height:380px;}
|
|
432
|
+
.plot.bar{height:300px;}
|
|
433
|
+
}
|
|
434
|
+
table.metrics{width:100%;border-collapse:separate;border-spacing:0;font-size:12px;
|
|
435
|
+
border-radius:4px;overflow:hidden;}
|
|
436
|
+
table.metrics thead th{background:var(--header);color:white;padding:8px 10px;
|
|
437
|
+
font-weight:700;text-align:center;white-space:nowrap;}
|
|
438
|
+
table.metrics thead th:first-child{background:var(--header2);text-align:left;}
|
|
439
|
+
table.metrics tbody td{padding:7px 10px;border-bottom:1px solid white;
|
|
440
|
+
border-right:1px solid white;text-align:center;background:var(--paper);}
|
|
441
|
+
table.metrics tbody td:first-child{text-align:left;font-weight:600;color:white;
|
|
442
|
+
background:var(--header);width:42%;}
|
|
443
|
+
table.metrics tbody td:last-child{background:var(--cell2);}
|
|
444
|
+
.legend-container{margin-top:24px;padding-top:20px;padding-bottom:16px;
|
|
445
|
+
display:flex;justify-content:center;flex-wrap:wrap;gap:24px;flex-shrink:0;}
|
|
446
|
+
.legend-item{display:flex;align-items:center;gap:8px;font-size:14px;}
|
|
447
|
+
.legend-color{width:20px;height:3px;border-radius:2px;}
|
|
448
|
+
@media (min-width:981px){
|
|
449
|
+
html,body{overflow-y:auto;}
|
|
450
|
+
}
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _get_plotly_script(include_plotlyjs: LiteralPlotlyJSlib) -> str:
|
|
455
|
+
"""Get plotly script tag."""
|
|
456
|
+
if include_plotlyjs == "cdn":
|
|
457
|
+
return '<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>'
|
|
458
|
+
return ""
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _write_html_file(
|
|
462
|
+
plotfile: Path,
|
|
463
|
+
html: str,
|
|
464
|
+
*,
|
|
465
|
+
auto_open: bool,
|
|
466
|
+
) -> str:
|
|
467
|
+
"""Write HTML file and optionally open it."""
|
|
468
|
+
plotfile.parent.mkdir(parents=True, exist_ok=True)
|
|
469
|
+
plotfile.write_text(html, encoding="utf-8")
|
|
470
|
+
if auto_open:
|
|
471
|
+
try:
|
|
472
|
+
webbrowser_open(plotfile.as_uri())
|
|
473
|
+
except OSError as exc:
|
|
474
|
+
logger.warning("Failed to open browser: %s", exc)
|
|
475
|
+
return str(plotfile)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _generate_html(
|
|
479
|
+
title: str | None,
|
|
480
|
+
css: str,
|
|
481
|
+
plotly_script: str,
|
|
482
|
+
logo_html: str,
|
|
483
|
+
table_html: str,
|
|
484
|
+
line_payload: dict[str, Any],
|
|
485
|
+
bar_payload: dict[str, Any],
|
|
486
|
+
legend_html: str,
|
|
487
|
+
) -> str:
|
|
488
|
+
"""Generate the HTML string."""
|
|
489
|
+
return f"""<!doctype html>
|
|
490
|
+
<html lang="sv">
|
|
491
|
+
<head>
|
|
492
|
+
<meta charset="utf-8" />
|
|
493
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
494
|
+
<title>{title or ""}</title>
|
|
495
|
+
<style>{css}</style>
|
|
496
|
+
{plotly_script}
|
|
497
|
+
</head>
|
|
498
|
+
<body>
|
|
499
|
+
<div class="page">
|
|
500
|
+
<div class="header">
|
|
501
|
+
<div>{logo_html}</div>
|
|
502
|
+
<div><h1>{title or ""}</h1></div>
|
|
503
|
+
<div></div>
|
|
504
|
+
</div>
|
|
505
|
+
<div class="layout">
|
|
506
|
+
<div class="charts">
|
|
507
|
+
<div id="lineplot" class="plot"></div>
|
|
508
|
+
<div id="barplot" class="plot bar"></div>
|
|
509
|
+
</div>
|
|
510
|
+
<div class="table">{table_html}</div>
|
|
511
|
+
</div>
|
|
512
|
+
{legend_html}
|
|
513
|
+
</div>
|
|
514
|
+
<script>
|
|
515
|
+
const line = {_dumps_plotly(line_payload)};
|
|
516
|
+
const bar = {_dumps_plotly(bar_payload)};
|
|
517
|
+
Plotly.newPlot("lineplot", line.data, line.layout, line.config);
|
|
518
|
+
Plotly.newPlot("barplot", bar.data, bar.layout, bar.config);
|
|
519
|
+
window.addEventListener("resize", () => {{
|
|
520
|
+
Plotly.Plots.resize("lineplot");
|
|
521
|
+
Plotly.Plots.resize("barplot");
|
|
522
|
+
}});
|
|
523
|
+
</script>
|
|
524
|
+
</body>
|
|
525
|
+
</html>
|
|
526
|
+
"""
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def report_html(
|
|
530
|
+
data: OpenFrame,
|
|
531
|
+
bar_freq: LiteralBizDayFreq = "BYE",
|
|
532
|
+
filename: str | None = None,
|
|
533
|
+
title: str | None = None,
|
|
534
|
+
directory: Path | None = None,
|
|
535
|
+
output_type: LiteralPlotlyOutput = "file",
|
|
536
|
+
include_plotlyjs: LiteralPlotlyJSlib = "cdn",
|
|
537
|
+
*,
|
|
538
|
+
auto_open: bool = False,
|
|
539
|
+
add_logo: bool = True,
|
|
540
|
+
vertical_legend: bool = True, # noqa: ARG001
|
|
541
|
+
) -> tuple[Figure, str]:
|
|
542
|
+
"""Generate a responsive HTML report page with line and bar plots and a table."""
|
|
543
|
+
copied = data.from_deepcopy()
|
|
544
|
+
copied.trunc_frame().value_nan_handle().to_cumret()
|
|
545
|
+
|
|
546
|
+
properties, labels_init, labels_final = _get_report_properties_and_labels(
|
|
547
|
+
copied.yearfrac,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
line_traces = _create_line_traces(copied)
|
|
551
|
+
bar_traces = _create_bar_traces(copied, bar_freq)
|
|
552
|
+
|
|
553
|
+
rpt_df = copied.all_properties(
|
|
554
|
+
properties=cast("list[LiteralFrameProps]", properties),
|
|
555
|
+
)
|
|
556
|
+
rpt_df = _add_jensen_alpha(rpt_df, copied)
|
|
557
|
+
rpt_df = _add_information_ratio(rpt_df, copied)
|
|
558
|
+
rpt_df = _add_tracking_error(rpt_df, copied)
|
|
559
|
+
|
|
560
|
+
if copied.yearfrac > 1.0:
|
|
561
|
+
rpt_df, _ = _add_capture_ratio(rpt_df, copied, [])
|
|
562
|
+
|
|
563
|
+
rpt_df = _add_beta(rpt_df, copied)
|
|
564
|
+
rpt_df.index = Index(labels_init)
|
|
565
|
+
rpt_df = _add_ytd_mtd(rpt_df, copied)
|
|
566
|
+
rpt_df = rpt_df.reindex(labels_final)
|
|
567
|
+
|
|
568
|
+
format_map = {
|
|
569
|
+
"Return (CAGR)": "{:.2%}",
|
|
570
|
+
"Return (simple)": "{:.2%}",
|
|
571
|
+
"Year-to-Date": "{:.2%}",
|
|
572
|
+
"Month-to-Date": "{:.2%}",
|
|
573
|
+
"Volatility": "{:.2%}",
|
|
574
|
+
"Sharpe Ratio": "{:.2f}",
|
|
575
|
+
"Sortino Ratio": "{:.2f}",
|
|
576
|
+
"Jensen's Alpha": "{:.2%}",
|
|
577
|
+
"Information Ratio": "{:.2f}",
|
|
578
|
+
"Tracking Error (weekly)": "{:.2%}",
|
|
579
|
+
"Index Beta (weekly)": "{:.2f}",
|
|
580
|
+
"Capture Ratio (monthly)": "{:.2f}",
|
|
581
|
+
"Worst Month": "{:.2%}",
|
|
582
|
+
"Worst Day": "{:.2%}",
|
|
583
|
+
"Comparison Start": "{:%Y-%m-%d}",
|
|
584
|
+
"Comparison End": "{:%Y-%m-%d}",
|
|
585
|
+
}
|
|
586
|
+
formats = [format_map.get(label, "{:.2f}") for label in labels_final]
|
|
587
|
+
|
|
588
|
+
for item, f in zip(rpt_df.index, formats, strict=False):
|
|
589
|
+
rpt_df.loc[item] = rpt_df.loc[item].apply(
|
|
590
|
+
lambda x, fmt=f: ""
|
|
591
|
+
if (
|
|
592
|
+
x is None
|
|
593
|
+
or (not isinstance(x, str) and isna(x))
|
|
594
|
+
or (isinstance(x, str) and x.lower() in ("nan", "nan%", ""))
|
|
595
|
+
)
|
|
596
|
+
else (
|
|
597
|
+
str(x)
|
|
598
|
+
if isinstance(x, str)
|
|
599
|
+
else (
|
|
600
|
+
Timestamp(x).strftime("%Y-%m-%d")
|
|
601
|
+
if "%Y-%m-%d" in fmt and not isinstance(x, str)
|
|
602
|
+
else fmt.format(x)
|
|
603
|
+
)
|
|
604
|
+
),
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
rpt_df.index = Index([f"<b>{x}</b>" for x in rpt_df.index])
|
|
608
|
+
rpt_df = rpt_df.reset_index()
|
|
609
|
+
|
|
610
|
+
colmns = ["", *copied.columns_lvl_zero]
|
|
611
|
+
rpt_df.columns = colmns
|
|
612
|
+
table_html = _metrics_table_html(rpt_df)
|
|
613
|
+
|
|
614
|
+
dirpath = _get_output_directory(directory)
|
|
615
|
+
|
|
616
|
+
if not filename:
|
|
617
|
+
filename = "".join(choice(ascii_letters) for _ in range(6)) + ".html"
|
|
618
|
+
|
|
619
|
+
plotfile = dirpath / filename
|
|
620
|
+
|
|
621
|
+
fig_theme, logo = load_plotly_dict()
|
|
622
|
+
layout_theme = cast("dict[str, Any]", fig_theme.get("layout", {}))
|
|
623
|
+
colorway: list[str] = cast("dict[str, list[str]]", layout_theme).get(
|
|
624
|
+
"colorway", []
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
line_layout, bar_layout = _get_plotly_layouts(
|
|
628
|
+
layout_theme,
|
|
629
|
+
colorway,
|
|
630
|
+
copied.item_count,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
config = cast("dict[str, Any]", fig_theme.get("config", {})) or {}
|
|
634
|
+
config = {**config, "responsive": True, "displayModeBar": False}
|
|
635
|
+
|
|
636
|
+
plotly_script = _get_plotly_script(include_plotlyjs)
|
|
637
|
+
logo_html = _get_logo_html(logo, add_logo=add_logo)
|
|
638
|
+
css = _get_css()
|
|
639
|
+
|
|
640
|
+
line_payload = {
|
|
641
|
+
"data": [t.to_plotly_json() for t in line_traces],
|
|
642
|
+
"layout": line_layout,
|
|
643
|
+
"config": config,
|
|
644
|
+
}
|
|
645
|
+
bar_payload = {
|
|
646
|
+
"data": [t.to_plotly_json() for t in bar_traces],
|
|
647
|
+
"layout": bar_layout,
|
|
648
|
+
"config": config,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
legend_html = _get_legend_html(line_traces, colorway)
|
|
652
|
+
|
|
653
|
+
html = _generate_html(
|
|
654
|
+
title,
|
|
655
|
+
css,
|
|
656
|
+
plotly_script,
|
|
657
|
+
logo_html,
|
|
658
|
+
table_html,
|
|
659
|
+
line_payload,
|
|
660
|
+
bar_payload,
|
|
661
|
+
legend_html,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
if output_type == "file":
|
|
665
|
+
output = _write_html_file(plotfile, html, auto_open=auto_open)
|
|
666
|
+
else:
|
|
667
|
+
output = html
|
|
668
|
+
|
|
669
|
+
fig_return = Figure(data=line_traces)
|
|
670
|
+
return fig_return, output
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "openseries"
|
|
3
|
-
version = "2.1.
|
|
3
|
+
version = "2.1.4"
|
|
4
4
|
description = "Tools for analyzing financial timeseries."
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Martin Karrin", email = "martin.karrin@captor.se" },
|
|
@@ -61,9 +61,9 @@ dependencies = [
|
|
|
61
61
|
"Release Notes" = "https://github.com/CaptorAB/openseries/releases"
|
|
62
62
|
|
|
63
63
|
[tool.poetry.group.dev.dependencies]
|
|
64
|
-
mypy = "1.19.
|
|
64
|
+
mypy = "1.19.1"
|
|
65
65
|
pandas-stubs = ">=2.1.2"
|
|
66
|
-
pre-commit = ">=4.5.
|
|
66
|
+
pre-commit = ">=4.5.1"
|
|
67
67
|
pytest = ">=9.0.2"
|
|
68
68
|
pytest-cov = ">=7.0.0"
|
|
69
69
|
pytest-xdist = ">=3.8.0"
|