openseries 2.1.4__tar.gz → 2.1.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openseries
3
- Version: 2.1.4
3
+ Version: 2.1.6
4
4
  Summary: Tools for analyzing financial timeseries.
5
5
  License-Expression: BSD-3-Clause
6
6
  License-File: LICENSE.md
@@ -50,7 +50,7 @@ Description-Content-Type: text/markdown
50
50
  [![Python version](https://img.shields.io/pypi/pyversions/openseries.svg)](https://www.python.org/)
51
51
  [![GitHub Action Test Suite](https://github.com/CaptorAB/openseries/actions/workflows/test.yml/badge.svg)](https://github.com/CaptorAB/openseries/actions/workflows/test.yml)
52
52
  [![codecov](https://img.shields.io/codecov/c/gh/CaptorAB/openseries?logo=codecov)](https://codecov.io/gh/CaptorAB/openseries/branch/master)
53
- ![Documentation Status](https://readthedocs.org/projects/openseries/badge/?version=latest)
53
+ [![Documentation Status](https://readthedocs.org/projects/openseries/badge/?version=latest)](https://captorab.github.io/openseries/)
54
54
  [![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/)
55
55
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://beta.ruff.rs/docs/)
56
56
  [![GitHub License](https://img.shields.io/github/license/CaptorAB/openseries)](https://github.com/CaptorAB/openseries/blob/master/LICENSE.md)
@@ -60,7 +60,7 @@ Tools for analyzing financial timeseries of a single asset or a group of assets.
60
60
 
61
61
  ## Documentation
62
62
 
63
- Complete documentation is available at: [https://openseries.readthedocs.io](https://openseries.readthedocs.io/)
63
+ Complete documentation is available at: [https://captorab.github.io/openseries/](https://captorab.github.io/openseries/)
64
64
 
65
65
  The documentation includes:
66
66
 
@@ -10,7 +10,7 @@
10
10
  [![Python version](https://img.shields.io/pypi/pyversions/openseries.svg)](https://www.python.org/)
11
11
  [![GitHub Action Test Suite](https://github.com/CaptorAB/openseries/actions/workflows/test.yml/badge.svg)](https://github.com/CaptorAB/openseries/actions/workflows/test.yml)
12
12
  [![codecov](https://img.shields.io/codecov/c/gh/CaptorAB/openseries?logo=codecov)](https://codecov.io/gh/CaptorAB/openseries/branch/master)
13
- ![Documentation Status](https://readthedocs.org/projects/openseries/badge/?version=latest)
13
+ [![Documentation Status](https://readthedocs.org/projects/openseries/badge/?version=latest)](https://captorab.github.io/openseries/)
14
14
  [![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/)
15
15
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://beta.ruff.rs/docs/)
16
16
  [![GitHub License](https://img.shields.io/github/license/CaptorAB/openseries)](https://github.com/CaptorAB/openseries/blob/master/LICENSE.md)
@@ -20,7 +20,7 @@ Tools for analyzing financial timeseries of a single asset or a group of assets.
20
20
 
21
21
  ## Documentation
22
22
 
23
- Complete documentation is available at: [https://openseries.readthedocs.io](https://openseries.readthedocs.io/)
23
+ Complete documentation is available at: [https://captorab.github.io/openseries/](https://captorab.github.io/openseries/)
24
24
 
25
25
  The documentation includes:
26
26
 
@@ -11,6 +11,7 @@ from .datefixer import (
11
11
  offset_business_days,
12
12
  )
13
13
  from .frame import OpenFrame
14
+ from .html_utils import export_plotly_figure
14
15
  from .load_plotly import load_plotly_dict
15
16
  from .owntypes import ValueType
16
17
  from .portfoliotools import (
@@ -33,6 +34,7 @@ __all__ = [
33
34
  "date_fix",
34
35
  "date_offset_foll",
35
36
  "efficient_frontier",
37
+ "export_plotly_figure",
36
38
  "generate_calendar_date_range",
37
39
  "get_previous_business_day_before_today",
38
40
  "holiday_calendar",
@@ -21,7 +21,6 @@ from .owntypes import (
21
21
  DateAlignmentError,
22
22
  InitialValueZeroError,
23
23
  NumberOfItemsAndLabelsNotSameError,
24
- PlotlyConfigType,
25
24
  ResampleDataLossError,
26
25
  SeriesOrFloat_co,
27
26
  ValueType,
@@ -61,9 +60,7 @@ from pandas import (
61
60
  from pandas.tseries.offsets import CustomBusinessDay
62
61
  from plotly.figure_factory import create_distplot # type: ignore[import-untyped]
63
62
  from plotly.graph_objs import Figure # type: ignore[import-untyped]
64
- from plotly.io import to_html # type: ignore[import-untyped]
65
- from plotly.offline import plot # type: ignore[import-untyped]
66
- from pydantic import BaseModel, ConfigDict, DirectoryPath, ValidationError
63
+ from pydantic import BaseModel, ConfigDict, DirectoryPath
67
64
  from scipy.stats import (
68
65
  kurtosis,
69
66
  norm,
@@ -79,6 +76,7 @@ from .datefixer import (
79
76
  date_offset_foll,
80
77
  holiday_calendar,
81
78
  )
79
+ from .html_utils import export_plotly_figure
82
80
  from .load_plotly import load_plotly_dict
83
81
 
84
82
 
@@ -284,8 +282,9 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
284
282
  result = (
285
283
  self.tsdf.groupby(years)
286
284
  .apply(
287
- lambda prices: (prices / prices.expanding(min_periods=1).max()).min()
288
- - 1,
285
+ lambda prices: (
286
+ (prices / prices.expanding(min_periods=1).max()).min() - 1
287
+ ),
289
288
  )
290
289
  .min()
291
290
  )
@@ -506,17 +505,19 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
506
505
  """
507
506
  mdddf = self.tsdf.copy()
508
507
  mdddf.index = DatetimeIndex(mdddf.index)
509
- result = (mdddf / mdddf.expanding(min_periods=1).max()).idxmin().dt.date # type: ignore[attr-defined,arg-type]
508
+ idxmin_result = cast(
509
+ "Series[Timestamp]",
510
+ (mdddf / mdddf.expanding(min_periods=1).max()).idxmin(),
511
+ )
512
+ result = idxmin_result.dt.date
510
513
 
511
514
  if self.tsdf.shape[1] == 1:
512
- return cast("dt.date", result.iloc[0])
513
- date_series = Series(
515
+ return result.iloc[0]
516
+ return Series(
514
517
  data=result,
515
518
  index=self.tsdf.columns,
516
519
  name="Max drawdown date",
517
- dtype="datetime64[ns]",
518
- ).dt.date # type: ignore[attr-defined]
519
- return cast("Series[dt.date]", date_series)
520
+ )
520
521
 
521
522
  @property
522
523
  def worst(self: Self) -> SeriesOrFloat_co:
@@ -547,12 +548,12 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
547
548
  """
548
549
  method: LiteralPandasReindexMethod = "nearest"
549
550
 
550
- try:
551
+ if hasattr(self, "constituents"):
552
+ countries = self.constituents[0].countries
553
+ markets = self.constituents[0].markets
554
+ else:
551
555
  countries = self.countries
552
556
  markets = self.markets
553
- except AttributeError:
554
- countries = self.constituents[0].countries # type: ignore[attr-defined]
555
- markets = self.constituents[0].markets # type: ignore[attr-defined]
556
557
 
557
558
  wmdf = self.tsdf.copy()
558
559
 
@@ -733,6 +734,54 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
733
734
 
734
735
  return earlier, later
735
736
 
737
+ def _get_or_set_countries(
738
+ self: Self, countries: CountriesType | None
739
+ ) -> CountriesType | None:
740
+ """Get or set countries attribute, handling both OpenTimeSeries and OpenFrame.
741
+
742
+ Args:
743
+ countries: Country code(s) to set, or None to get existing value.
744
+
745
+ Returns:
746
+ The countries value after getting or setting.
747
+ """
748
+ if countries:
749
+ if hasattr(self, "countries"):
750
+ self.countries = countries
751
+ else:
752
+ for serie in self.constituents: # type: ignore[attr-defined]
753
+ serie.countries = countries
754
+ elif hasattr(self, "countries"):
755
+ countries = self.countries
756
+ else:
757
+ countries = self.constituents[0].countries # type: ignore[attr-defined]
758
+
759
+ return countries
760
+
761
+ def _get_or_set_markets(
762
+ self: Self, markets: list[str] | str | None
763
+ ) -> list[str] | str | None:
764
+ """Get or set markets attribute, handling both OpenTimeSeries and OpenFrame.
765
+
766
+ Args:
767
+ markets: Market code(s) to set, or None to get existing value.
768
+
769
+ Returns:
770
+ The markets value after getting or setting.
771
+ """
772
+ if markets:
773
+ if hasattr(self, "markets"):
774
+ self.markets = markets
775
+ else:
776
+ for serie in self.constituents: # type: ignore[attr-defined]
777
+ serie.markets = markets
778
+ elif hasattr(self, "markets"):
779
+ markets = self.markets
780
+ else:
781
+ markets = self.constituents[0].markets # type: ignore[attr-defined]
782
+
783
+ return markets
784
+
736
785
  def align_index_to_local_cdays(
737
786
  self: Self,
738
787
  countries: CountriesType | None = None,
@@ -754,29 +803,8 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
754
803
  startyear = cast("int", to_datetime(self.tsdf.index[0]).year)
755
804
  endyear = cast("int", to_datetime(self.tsdf.index[-1]).year)
756
805
 
757
- if countries:
758
- try:
759
- self.countries = countries
760
- except ValidationError:
761
- for serie in self.constituents: # type: ignore[attr-defined]
762
- serie.countries = countries
763
- else:
764
- try:
765
- countries = self.countries
766
- except AttributeError:
767
- countries = self.constituents[0].countries # type: ignore[attr-defined]
768
-
769
- if markets:
770
- try:
771
- self.markets = markets
772
- except ValidationError:
773
- for serie in self.constituents: # type: ignore[attr-defined]
774
- serie.markets = markets
775
- else:
776
- try:
777
- markets = self.markets
778
- except AttributeError:
779
- markets = self.constituents[0].markets # type: ignore[attr-defined]
806
+ countries = self._get_or_set_countries(countries)
807
+ markets = self._get_or_set_markets(markets)
780
808
 
781
809
  calendar = holiday_calendar(
782
810
  startyear=startyear,
@@ -1046,76 +1074,44 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
1046
1074
  def _apply_title_logo(
1047
1075
  figure: Figure,
1048
1076
  logo: CaptorLogoType,
1049
- title: str | None,
1050
1077
  *,
1051
1078
  add_logo: bool,
1052
- ) -> None:
1053
- """Apply optional title and logo to a Plotly Figure.
1079
+ ) -> str | None:
1080
+ """Apply optional logo to a Plotly Figure.
1054
1081
 
1055
1082
  Args:
1056
1083
  figure: Plotly figure to update.
1057
1084
  logo: Plotly layout image dict.
1058
- title: Optional plot title.
1059
1085
  add_logo: Whether to add the logo to the figure.
1086
+
1087
+ Returns:
1088
+ Logo source URL if logo should be displayed, None otherwise.
1060
1089
  """
1090
+ logo_url: str | None = None
1061
1091
  if add_logo:
1062
- figure.add_layout_image(logo)
1063
- if title:
1064
- figure.update_layout(
1065
- {"title": {"text": f"<b>{title}</b><br>", "font": {"size": 36}}},
1092
+ source = logo.get("source", "")
1093
+ logo_url = str(source) if source else None
1094
+ figure.add_layout_image(
1095
+ {
1096
+ "source": "",
1097
+ "x": 0,
1098
+ "y": 1,
1099
+ "xanchor": "left",
1100
+ "yanchor": "top",
1101
+ "xref": "paper",
1102
+ "yref": "paper",
1103
+ "sizex": 0,
1104
+ "sizey": 0,
1105
+ "opacity": 0,
1106
+ }
1066
1107
  )
1067
-
1068
- @staticmethod
1069
- def _emit_output(
1070
- figure: Figure,
1071
- fig_config: PlotlyConfigType,
1072
- output_type: LiteralPlotlyOutput,
1073
- plotfile: Path,
1074
- filename: str,
1075
- *,
1076
- include_plotlyjs_bool: LiteralPlotlyJSlib,
1077
- auto_open: bool,
1078
- ) -> str:
1079
- """Write a file or return inline HTML string from a Plotly Figure.
1080
-
1081
- Args:
1082
- figure: Plotly figure to render.
1083
- fig_config: Plotly config dict.
1084
- output_type: Output type: ``"file"`` or ``"div"``.
1085
- plotfile: Full path to the output html file.
1086
- filename: Output filename used for the ``div_id`` when inline.
1087
- include_plotlyjs_bool: How plotly.js is included.
1088
- auto_open: Whether to auto-open the file in a browser.
1089
-
1090
- Returns:
1091
- If ``output_type`` is ``"file"``, the path to the file; otherwise an
1092
- inline HTML string (div).
1093
- """
1094
- if output_type == "file":
1095
- plot(
1096
- figure_or_data=figure,
1097
- filename=str(plotfile),
1098
- auto_open=auto_open,
1099
- auto_play=False,
1100
- link_text="",
1101
- include_plotlyjs=include_plotlyjs_bool,
1102
- config=fig_config,
1103
- output_type=output_type,
1104
- )
1105
- return str(plotfile)
1106
-
1107
- div_id = filename.rsplit(".", 1)[0]
1108
- return cast(
1109
- "str",
1110
- to_html(
1111
- fig=figure,
1112
- config=fig_config,
1113
- auto_play=False,
1114
- include_plotlyjs=include_plotlyjs_bool,
1115
- full_html=False,
1116
- div_id=div_id,
1117
- ),
1108
+ figure.update_layout(
1109
+ {
1110
+ "margin": {"t": 20, "b": 60, "l": 60, "r": 60, "pad": 4},
1111
+ "autosize": True,
1112
+ },
1118
1113
  )
1114
+ return logo_url
1119
1115
 
1120
1116
  def plot_bars(
1121
1117
  self: Self,
@@ -1176,21 +1172,22 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
1176
1172
  )
1177
1173
  figure.update_layout(barmode=mode, yaxis={"tickformat": tick_fmt})
1178
1174
 
1179
- self._apply_title_logo(
1175
+ logo_url = self._apply_title_logo(
1180
1176
  figure=figure,
1181
- title=title,
1182
1177
  add_logo=add_logo,
1183
1178
  logo=logo,
1184
1179
  )
1185
1180
 
1186
- string_output = self._emit_output(
1181
+ string_output = export_plotly_figure(
1187
1182
  figure=figure,
1188
1183
  fig_config=fig["config"],
1189
- include_plotlyjs_bool=include_plotlyjs,
1184
+ include_plotlyjs=include_plotlyjs,
1190
1185
  output_type=output_type,
1191
1186
  auto_open=auto_open,
1192
1187
  plotfile=plotfile,
1193
1188
  filename=filename,
1189
+ title=title,
1190
+ logo_url=logo_url,
1194
1191
  )
1195
1192
 
1196
1193
  return figure, string_output
@@ -1271,21 +1268,22 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
1271
1268
  textposition="top center",
1272
1269
  )
1273
1270
 
1274
- self._apply_title_logo(
1271
+ logo_url = self._apply_title_logo(
1275
1272
  figure=figure,
1276
- title=title,
1277
1273
  add_logo=add_logo,
1278
1274
  logo=logo,
1279
1275
  )
1280
1276
 
1281
- string_output = self._emit_output(
1277
+ string_output = export_plotly_figure(
1282
1278
  figure=figure,
1283
1279
  fig_config=fig["config"],
1284
- include_plotlyjs_bool=include_plotlyjs,
1280
+ include_plotlyjs=include_plotlyjs,
1285
1281
  output_type=output_type,
1286
1282
  auto_open=auto_open,
1287
1283
  plotfile=plotfile,
1288
1284
  filename=filename,
1285
+ title=title,
1286
+ logo_url=logo_url,
1289
1287
  )
1290
1288
 
1291
1289
  return figure, string_output
@@ -1393,21 +1391,22 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
1393
1391
  figure.update_xaxes(zeroline=True, zerolinewidth=2, zerolinecolor="lightgrey")
1394
1392
  figure.update_yaxes(zeroline=True, zerolinewidth=2, zerolinecolor="lightgrey")
1395
1393
 
1396
- self._apply_title_logo(
1394
+ logo_url = self._apply_title_logo(
1397
1395
  figure=figure,
1398
- title=title,
1399
1396
  add_logo=add_logo,
1400
1397
  logo=logo,
1401
1398
  )
1402
1399
 
1403
- string_output = self._emit_output(
1400
+ string_output = export_plotly_figure(
1404
1401
  figure=figure,
1405
1402
  fig_config=fig_dict["config"],
1406
- include_plotlyjs_bool=include_plotlyjs,
1403
+ include_plotlyjs=include_plotlyjs,
1407
1404
  output_type=output_type,
1408
1405
  auto_open=auto_open,
1409
1406
  plotfile=plotfile,
1410
1407
  filename=filename,
1408
+ title=title,
1409
+ logo_url=logo_url,
1411
1410
  )
1412
1411
 
1413
1412
  return figure, string_output
@@ -374,13 +374,22 @@ def generate_calendar_date_range(
374
374
  raise TradingDaysNotAboveZeroError(msg)
375
375
 
376
376
  if start and not end:
377
+ adjusted_start = date_offset_foll(
378
+ raw_date=start,
379
+ months_offset=0,
380
+ countries=countries,
381
+ markets=markets,
382
+ custom_holidays=custom_holidays,
383
+ adjust=True,
384
+ following=True,
385
+ )
377
386
  tmp_range = date_range(
378
- start=start,
387
+ start=adjusted_start,
379
388
  periods=trading_days * 365 // 252,
380
389
  freq="D",
381
390
  )
382
391
  calendar = holiday_calendar(
383
- startyear=start.year,
392
+ startyear=adjusted_start.year,
384
393
  endyear=date_fix(tmp_range.tolist()[-1]).year,
385
394
  countries=countries,
386
395
  markets=markets,
@@ -389,17 +398,30 @@ def generate_calendar_date_range(
389
398
  return [
390
399
  d.date()
391
400
  for d in date_range(
392
- start=start,
401
+ start=adjusted_start,
393
402
  periods=trading_days,
394
403
  freq=CustomBusinessDay(calendar=calendar),
395
404
  )
396
405
  ]
397
406
 
398
407
  if end and not start:
399
- tmp_range = date_range(end=end, periods=trading_days * 365 // 252, freq="D")
408
+ adjusted_end = date_offset_foll(
409
+ raw_date=end,
410
+ months_offset=0,
411
+ countries=countries,
412
+ markets=markets,
413
+ custom_holidays=custom_holidays,
414
+ adjust=True,
415
+ following=False,
416
+ )
417
+ tmp_range = date_range(
418
+ end=adjusted_end,
419
+ periods=trading_days * 365 // 252,
420
+ freq="D",
421
+ )
400
422
  calendar = holiday_calendar(
401
423
  startyear=date_fix(tmp_range.tolist()[0]).year,
402
- endyear=end.year,
424
+ endyear=adjusted_end.year,
403
425
  countries=countries,
404
426
  markets=markets,
405
427
  custom_holidays=custom_holidays,
@@ -407,7 +429,7 @@ def generate_calendar_date_range(
407
429
  return [
408
430
  d.date()
409
431
  for d in date_range(
410
- end=end,
432
+ end=adjusted_end,
411
433
  periods=trading_days,
412
434
  freq=CustomBusinessDay(calendar=calendar),
413
435
  )
@@ -0,0 +1,314 @@
1
+ """Shared HTML utilities for responsive Plotly outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, cast
7
+ from webbrowser import open as webbrowser_open
8
+
9
+ from plotly.io import to_html # type: ignore[import-untyped]
10
+
11
+ if TYPE_CHECKING: # pragma: no cover
12
+ from plotly.graph_objs import Figure # type: ignore[import-untyped]
13
+
14
+ from .owntypes import LiteralPlotlyJSlib, LiteralPlotlyOutput, PlotlyConfigType
15
+
16
+ __all__ = [
17
+ "_generate_responsive_plot_html",
18
+ "_get_base_css",
19
+ "_get_plot_css",
20
+ "_get_plotly_script",
21
+ "export_plotly_figure",
22
+ ]
23
+
24
+
25
+ def _get_base_css() -> str:
26
+ """Get base CSS styles for responsive HTML reports."""
27
+ return """
28
+ :root{--ink:#1f2a44;--muted:#6b778c;--header:#4a4a4a;--header2:#6a6a6a;--cell:#f3f3f3;--cell2:#e6e6e6;--paper:#ffffff;}
29
+ html,body{margin:0;padding:0;background:var(--paper);color:var(--ink);
30
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;}
31
+ .page{max-width:calc(100% - 64px);margin:0 auto;padding:32px;
32
+ padding-bottom:48px;}
33
+ .header{display:grid;grid-template-columns:140px 1fr 140px;gap:12px;
34
+ align-items:start;}
35
+ h1{margin:0;text-align:center;font-size:45px;font-weight:800;}
36
+ .layout{display:grid;grid-template-columns:1.2fr .9fr;
37
+ grid-template-areas:"charts table";gap:22px;align-items:start;margin-top:12px;}
38
+ .charts{grid-area:charts;display:grid;grid-template-rows:auto auto;gap:18px;}
39
+ .table{grid-area:table;}
40
+ .plot{width:100%;height:380px;}
41
+ .plot.bar{height:300px;}
42
+ @media (max-width:980px){
43
+ .page{padding:24px;padding-bottom:24px;}
44
+ .header{grid-template-columns:120px 1fr;}
45
+ h1{font-size:36px;}
46
+ .layout{grid-template-columns:1fr;grid-template-areas:"table" "charts";gap:16px;}
47
+ .plot{height:380px;}
48
+ .plot.bar{height:300px;}
49
+ .table{overflow-x:auto;}
50
+ table.metrics{table-layout:auto;font-size:14px;}
51
+ }
52
+ table.metrics{width:100%;border-collapse:separate;border-spacing:0;font-size:12px;
53
+ border-radius:4px;overflow:hidden;table-layout:fixed;}
54
+ table.metrics thead th{background:var(--header);color:white;padding:8px 10px;
55
+ font-weight:700;text-align:center;word-wrap:break-word;word-break:break-word;}
56
+ table.metrics thead th:first-child{background:var(--header2);text-align:left;
57
+ width:180px;}
58
+ table.metrics tbody td{padding:7px 10px;border-bottom:1px solid white;
59
+ border-right:1px solid white;text-align:center;background:var(--paper);}
60
+ table.metrics tbody td:first-child{text-align:left;font-weight:600;color:white;
61
+ background:var(--header);width:180px;}
62
+ table.metrics tbody td:last-child{background:var(--cell2);}
63
+ .legend-container{margin-top:24px;padding-top:20px;padding-bottom:16px;
64
+ display:flex;justify-content:center;flex-wrap:wrap;gap:24px;flex-shrink:0;}
65
+ .legend-item{display:flex;align-items:center;gap:8px;font-size:14px;}
66
+ .legend-color{width:20px;height:3px;border-radius:2px;}
67
+ @media (min-width:981px){
68
+ html,body{overflow-y:auto;}
69
+ }
70
+ """
71
+
72
+
73
+ def _get_plot_css() -> str:
74
+ """Get CSS styles for full-screen responsive plots.
75
+
76
+ Returns:
77
+ CSS string for responsive plots.
78
+ """
79
+ return """
80
+ *{box-sizing:border-box;}
81
+ html,body{margin:0;padding:0;width:100%;height:100%;
82
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;
83
+ overflow-x:hidden;}
84
+ .page{width:100%;height:100vh;display:flex;flex-direction:column;}
85
+ .title-container{width:100%;flex-shrink:0;padding:15px 20px 10px 20px;
86
+ display:flex;align-items:center;justify-content:center;background:white;z-index:10;
87
+ gap:15px;}
88
+ .title-logo{height:60px;width:auto;flex-shrink:0;}
89
+ .title-container h1{margin:0;padding:0;font-size:36px;font-weight:bold;
90
+ color:#253551;word-wrap:break-word;word-break:break-word;
91
+ white-space:normal;line-height:1.2;flex:1;text-align:center;}
92
+ .plot-container{width:100%;flex:1;position:relative;overflow:hidden;
93
+ min-height:0;}
94
+ .plot{width:100%;height:100%;display:block;position:absolute;top:0;left:0;}
95
+ .plot > div{width:100% !important;height:100% !important;}
96
+ .plot .js-plotly-plot{width:100% !important;height:100% !important;}
97
+ .plot .js-plotly-plot .plotly{width:100% !important;height:100% !important;}
98
+ @media (max-width: 980px) {
99
+ .title-container{padding:12px 15px 8px 15px;gap:12px;}
100
+ .title-container h1{font-size:24px;line-height:1.3;}
101
+ .title-logo{height:32px;}
102
+ }
103
+ @media (max-width: 480px) {
104
+ .title-container{padding:10px 12px 6px 12px;gap:10px;}
105
+ .title-container h1{font-size:18px;line-height:1.2;}
106
+ .title-logo{height:24px;}
107
+ }
108
+ """
109
+
110
+
111
+ def _get_plotly_script(include_plotlyjs: LiteralPlotlyJSlib) -> str:
112
+ """Get plotly script tag."""
113
+ if include_plotlyjs == "cdn":
114
+ return '<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>'
115
+ return ""
116
+
117
+
118
+ def _generate_responsive_plot_html(
119
+ title: str | None,
120
+ plot_div: str,
121
+ include_plotlyjs: LiteralPlotlyJSlib,
122
+ plot_id: str,
123
+ logo_url: str | None = None,
124
+ ) -> str:
125
+ """Generate responsive HTML for a single Plotly plot."""
126
+ css = _get_plot_css()
127
+ plotly_script = _get_plotly_script(include_plotlyjs)
128
+
129
+ plot_div = plot_div.replace(
130
+ f'<div id="{plot_id}"', f'<div id="{plot_id}" class="plot"'
131
+ )
132
+
133
+ title_html = ""
134
+ if (title is not None and title) or logo_url:
135
+ logo_html = ""
136
+ if logo_url:
137
+ logo_html = f'<img src="{logo_url}" alt="Logo" class="title-logo" />'
138
+ title_text = f"<h1><b>{title}</b></h1>" if title else ""
139
+ title_html = f'<div class="title-container">{logo_html}{title_text}</div>'
140
+
141
+ return f"""<!doctype html>
142
+ <html lang="en">
143
+ <head>
144
+ <meta charset="utf-8" />
145
+ <meta name="viewport" content="width=device-width,initial-scale=1,
146
+ maximum-scale=5,user-scalable=yes" />
147
+ <title>{title or ""}</title>
148
+ <style>{css}</style>
149
+ {plotly_script}
150
+ </head>
151
+ <body>
152
+ <div class="page">
153
+ {title_html}
154
+ <div class="plot-container">
155
+ {plot_div}
156
+ </div>
157
+ </div>
158
+ <script>
159
+ (function() {{
160
+ var plotDiv = document.getElementById("{plot_id}");
161
+ var container = plotDiv ? plotDiv.closest('.plot-container') : null;
162
+
163
+ function getContainerSize() {{
164
+ if (!container) return {{width: window.innerWidth, height: window.innerHeight}};
165
+ var rect = container.getBoundingClientRect();
166
+ return {{width: rect.width, height: rect.height}};
167
+ }}
168
+
169
+ function resizePlot() {{
170
+ if (typeof Plotly === 'undefined' || !Plotly.Plots || !plotDiv) return;
171
+
172
+ var size = getContainerSize();
173
+ if (size.width > 0 && size.height > 0) {{
174
+ Plotly.Plots.resize(plotDiv);
175
+ }}
176
+ }}
177
+
178
+ function debounceResize() {{
179
+ clearTimeout(window._resizeTimeout);
180
+ window._resizeTimeout = setTimeout(resizePlot, 150);
181
+ }}
182
+
183
+ window.addEventListener("resize", debounceResize);
184
+
185
+ window.addEventListener("orientationchange", function() {{
186
+ setTimeout(function() {{
187
+ resizePlot();
188
+ }}, 300);
189
+ }});
190
+
191
+ function initResize() {{
192
+ if (typeof Plotly !== 'undefined' && plotDiv) {{
193
+ setTimeout(resizePlot, 100);
194
+ setTimeout(resizePlot, 500);
195
+ setTimeout(resizePlot, 1000);
196
+ }} else {{
197
+ setTimeout(initResize, 100);
198
+ }}
199
+ }}
200
+
201
+ if (document.readyState === 'loading') {{
202
+ document.addEventListener('DOMContentLoaded', initResize);
203
+ }} else {{
204
+ initResize();
205
+ }}
206
+
207
+ window.addEventListener('load', function() {{
208
+ setTimeout(resizePlot, 200);
209
+ }});
210
+ }})();
211
+ </script>
212
+ </body>
213
+ </html>
214
+ """
215
+
216
+
217
+ def export_plotly_figure(
218
+ figure: Figure,
219
+ fig_config: PlotlyConfigType,
220
+ output_type: LiteralPlotlyOutput,
221
+ filename: str,
222
+ *,
223
+ include_plotlyjs: LiteralPlotlyJSlib,
224
+ auto_open: bool = False,
225
+ plotfile: Path | str,
226
+ title: str | None = None,
227
+ logo_url: str | None = None,
228
+ ) -> str:
229
+ """Export a Plotly figure to a mobile-responsive HTML file or inline div.
230
+
231
+ This function converts a Plotly figure to HTML output, with support for
232
+ mobile-responsive standalone HTML files or inline HTML divs. When exporting
233
+ to a file, the output includes proper viewport settings, responsive CSS,
234
+ and JavaScript for handling window resizing and orientation changes.
235
+
236
+ Args:
237
+ figure: Plotly figure to render.
238
+ fig_config: Plotly config dictionary.
239
+ output_type: Output type: ``"file"`` or ``"div"``.
240
+ filename: Output filename used for the ``div_id`` when inline, or to
241
+ derive the div_id when exporting to file.
242
+ include_plotlyjs: How plotly.js is included. Can be ``True`` (inline),
243
+ ``False`` (not included), or ``"cdn"`` (CDN link).
244
+ auto_open: Whether to auto-open the file in a browser (only used when
245
+ ``output_type="file"``). Defaults to ``False``.
246
+ plotfile: Full path to the output HTML file. Required when
247
+ ``output_type="file"``, ignored when ``output_type="div"``.
248
+ title: Title for the HTML page (used for file output). Defaults to
249
+ ``None``.
250
+ logo_url: Optional logo URL to display in the title container.
251
+ Defaults to ``None``.
252
+
253
+ Returns:
254
+ If ``output_type`` is ``"file"``, the path to the file as a string;
255
+ otherwise an inline HTML string (div).
256
+
257
+
258
+ Example:
259
+ Export a Plotly figure to a responsive HTML file:
260
+
261
+ >>> import plotly.graph_objects as go
262
+ >>> from openseries.html_utils import export_plotly_figure
263
+ >>> from pathlib import Path
264
+ >>>
265
+ >>> fig = go.Figure(data=go.Scatter(x=[1, 2, 3], y=[4, 5, 6]))
266
+ >>> output_path = export_plotly_figure(
267
+ ... figure=fig,
268
+ ... fig_config={},
269
+ ... output_type="file",
270
+ ... filename="my_plot.html",
271
+ ... include_plotlyjs="cdn",
272
+ ... plotfile=Path("output/my_plot.html"),
273
+ ... title="My Plot",
274
+ ... auto_open=True,
275
+ ... )
276
+ """
277
+ if output_type == "file":
278
+ plotfile_path = Path(plotfile) if isinstance(plotfile, str) else plotfile
279
+ div_id = filename.rsplit(".", 1)[0]
280
+ plot_div = cast(
281
+ "str",
282
+ to_html(
283
+ fig=figure,
284
+ config=fig_config,
285
+ auto_play=False,
286
+ include_plotlyjs=False,
287
+ full_html=False,
288
+ div_id=div_id,
289
+ ),
290
+ )
291
+ html_content = _generate_responsive_plot_html(
292
+ title=title,
293
+ plot_div=plot_div,
294
+ include_plotlyjs=include_plotlyjs,
295
+ plot_id=div_id,
296
+ logo_url=logo_url,
297
+ )
298
+ plotfile_path.write_text(html_content, encoding="utf-8")
299
+ if auto_open:
300
+ webbrowser_open(plotfile_path.as_uri())
301
+ return str(plotfile_path)
302
+
303
+ div_id = filename.rsplit(".", 1)[0]
304
+ return cast(
305
+ "str",
306
+ to_html(
307
+ fig=figure,
308
+ config=fig_config,
309
+ auto_play=False,
310
+ include_plotlyjs=include_plotlyjs,
311
+ full_html=False,
312
+ div_id=div_id,
313
+ ),
314
+ )
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import datetime as dt
6
- from enum import Enum
6
+ from enum import StrEnum
7
7
  from pprint import pformat
8
8
  from typing import (
9
9
  TYPE_CHECKING,
@@ -300,10 +300,11 @@ class OpenFramePropertiesList(PropertiesList):
300
300
  self._validate()
301
301
 
302
302
 
303
- class ValueType(str, Enum):
303
+ class ValueType(StrEnum):
304
304
  """Enum types of OpenTimeSeries to identify the output."""
305
305
 
306
- EWMA = "EWMA"
306
+ EWMA_VOL = "EWMA volatility"
307
+ EWMA_VAR = "EWMA VaR"
307
308
  PRICE = "Price(Close)"
308
309
  RTRN = "Return(Total)"
309
310
  RELRTRN = "Relative return"
@@ -41,22 +41,26 @@
41
41
  "legend": {
42
42
  "bgcolor": "rgba(0,0,0,0)",
43
43
  "orientation": "h",
44
- "x": 0.98,
45
- "xanchor": "right",
46
- "y": -0.15,
47
- "yanchor": "bottom"
44
+ "x": 0.5,
45
+ "xanchor": "center",
46
+ "xref": "container",
47
+ "y": 1.05,
48
+ "yanchor": "bottom",
49
+ "yref": "container"
48
50
  },
49
51
  "paper_bgcolor": "rgba(255,255,255,1)",
50
52
  "plot_bgcolor": "rgba(255,255,255,1)",
51
53
  "showlegend": true,
52
54
  "title": {
53
55
  "font": {
54
- "size": 32
56
+ "size": 36
55
57
  },
56
58
  "x": 0.5,
57
59
  "xanchor": "center",
58
- "y": 0.95,
59
- "yanchor": "top"
60
+ "xref": "container",
61
+ "y": 0.93,
62
+ "yanchor": "bottom",
63
+ "yref": "container"
60
64
  },
61
65
  "xaxis": {
62
66
  "gridcolor": "#EEEEEE",
@@ -296,8 +296,10 @@ def _build_frontier_dataframe(
296
296
  weight_cols = columns_lvl_zero
297
297
  weight_header = "<br><br>Weights:<br>"
298
298
  line_df["text"] = line_df[weight_cols].apply(
299
- lambda row: weight_header
300
- + "<br>".join([f"{row[col]:.1%} {col}" for col in weight_cols]),
299
+ lambda row: (
300
+ weight_header
301
+ + "<br>".join([f"{row[col]:.1%} {col}" for col in weight_cols])
302
+ ),
301
303
  axis=1,
302
304
  )
303
305
 
@@ -738,7 +740,7 @@ def _configure_figure_layout(
738
740
  if title:
739
741
  if titletext is None:
740
742
  titletext = "<b>Risk and Return</b><br>"
741
- figure.update_layout(title={"text": titletext, "font": {"size": 32}})
743
+ figure.update_layout(title={"text": titletext, "font": {"size": 36}})
742
744
 
743
745
  if add_logo:
744
746
  figure.add_layout_image(logo)
@@ -781,7 +783,7 @@ def _generate_sharpeplot_output(
781
783
  )
782
784
  return str(plotfile)
783
785
 
784
- div_id = filename.split(sep=".")[0]
786
+ div_id = filename.split(maxsplit=1, sep=".")[0]
785
787
  return cast(
786
788
  "str",
787
789
  to_html(
@@ -17,6 +17,7 @@ from pandas import DataFrame, Index, Series, Timestamp, concat, isna
17
17
  from plotly.graph_objs import Bar, Figure, Scatter # type: ignore[import-untyped]
18
18
  from plotly.utils import PlotlyJSONEncoder # type: ignore[import-untyped]
19
19
 
20
+ from .html_utils import _get_base_css, _get_plotly_script
20
21
  from .load_plotly import load_plotly_dict
21
22
  from .owntypes import (
22
23
  CaptorLogoType,
@@ -408,12 +409,10 @@ def _get_legend_html(line_traces: list[Scatter], colorway: list[str]) -> str:
408
409
 
409
410
  def _get_css() -> str:
410
411
  """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;}
412
+ base_css = _get_base_css()
413
+ return (
414
+ base_css
415
+ + """
417
416
  .header{display:grid;grid-template-columns:140px 1fr 140px;gap:12px;
418
417
  align-items:start;}
419
418
  h1{margin:0;text-align:center;font-size:45px;font-weight:800;}
@@ -430,16 +429,22 @@ def _get_css() -> str:
430
429
  .layout{grid-template-columns:1fr;grid-template-areas:"table" "charts";gap:16px;}
431
430
  .plot{height:380px;}
432
431
  .plot.bar{height:300px;}
432
+ table.metrics{table-layout:fixed;width:auto;}
433
+ table.metrics thead th{min-width:120px;width:120px;white-space:nowrap;}
434
+ table.metrics thead th:first-child{width:180px;}
435
+ table.metrics tbody td{min-width:120px;width:120px;}
436
+ table.metrics tbody td:first-child{width:180px;}
433
437
  }
434
438
  table.metrics{width:100%;border-collapse:separate;border-spacing:0;font-size:12px;
435
- border-radius:4px;overflow:hidden;}
439
+ border-radius:4px;overflow:hidden;table-layout:fixed;}
436
440
  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;}
441
+ font-weight:700;text-align:center;word-wrap:break-word;word-break:break-word;}
442
+ table.metrics thead th:first-child{background:var(--header2);text-align:left;
443
+ width:180px;}
439
444
  table.metrics tbody td{padding:7px 10px;border-bottom:1px solid white;
440
445
  border-right:1px solid white;text-align:center;background:var(--paper);}
441
446
  table.metrics tbody td:first-child{text-align:left;font-weight:600;color:white;
442
- background:var(--header);width:42%;}
447
+ background:var(--header);width:180px;}
443
448
  table.metrics tbody td:last-child{background:var(--cell2);}
444
449
  .legend-container{margin-top:24px;padding-top:20px;padding-bottom:16px;
445
450
  display:flex;justify-content:center;flex-wrap:wrap;gap:24px;flex-shrink:0;}
@@ -449,13 +454,7 @@ def _get_css() -> str:
449
454
  html,body{overflow-y:auto;}
450
455
  }
451
456
  """
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 ""
457
+ )
459
458
 
460
459
 
461
460
  def _write_html_file(
@@ -587,19 +586,21 @@ def report_html(
587
586
 
588
587
  for item, f in zip(rpt_df.index, formats, strict=False):
589
588
  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)
589
+ lambda x, fmt=f: (
590
+ ""
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
+ )
599
596
  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)
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
+ )
603
604
  )
604
605
  ),
605
606
  )
@@ -611,7 +612,7 @@ def report_html(
611
612
  rpt_df.columns = colmns
612
613
  table_html = _metrics_table_html(rpt_df)
613
614
 
614
- dirpath = _get_output_directory(directory)
615
+ dirpath = _get_output_directory(directory=directory)
615
616
 
616
617
  if not filename:
617
618
  filename = "".join(choice(ascii_letters) for _ in range(6)) + ".html"
@@ -625,16 +626,16 @@ def report_html(
625
626
  )
626
627
 
627
628
  line_layout, bar_layout = _get_plotly_layouts(
628
- layout_theme,
629
- colorway,
630
- copied.item_count,
629
+ layout_theme=layout_theme,
630
+ colorway=colorway,
631
+ item_count=copied.item_count,
631
632
  )
632
633
 
633
634
  config = cast("dict[str, Any]", fig_theme.get("config", {})) or {}
634
635
  config = {**config, "responsive": True, "displayModeBar": False}
635
636
 
636
- plotly_script = _get_plotly_script(include_plotlyjs)
637
- logo_html = _get_logo_html(logo, add_logo=add_logo)
637
+ plotly_script = _get_plotly_script(include_plotlyjs=include_plotlyjs)
638
+ logo_html = _get_logo_html(logo=logo, add_logo=add_logo)
638
639
  css = _get_css()
639
640
 
640
641
  line_payload = {
@@ -648,21 +649,21 @@ def report_html(
648
649
  "config": config,
649
650
  }
650
651
 
651
- legend_html = _get_legend_html(line_traces, colorway)
652
+ legend_html = _get_legend_html(line_traces=line_traces, colorway=colorway)
652
653
 
653
654
  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,
655
+ title=title,
656
+ css=css,
657
+ plotly_script=plotly_script,
658
+ logo_html=logo_html,
659
+ table_html=table_html,
660
+ line_payload=line_payload,
661
+ bar_payload=bar_payload,
662
+ legend_html=legend_html,
662
663
  )
663
664
 
664
665
  if output_type == "file":
665
- output = _write_html_file(plotfile, html, auto_open=auto_open)
666
+ output = _write_html_file(plotfile=plotfile, html=html, auto_open=auto_open)
666
667
  else:
667
668
  output = html
668
669
 
@@ -32,6 +32,7 @@ from pandas import (
32
32
  date_range,
33
33
  )
34
34
  from pydantic import field_validator, model_validator
35
+ from scipy.stats import norm
35
36
 
36
37
  from ._common_model import _calculate_time_factor, _CommonModel
37
38
  from .datefixer import _do_resample_to_business_period_ends, date_fix
@@ -625,7 +626,7 @@ class OpenTimeSeries(_CommonModel[float]):
625
626
  cast("Timestamp", earlier) : cast("Timestamp", later)
626
627
  ].copy()
627
628
 
628
- data.loc[:, (self.label, ValueType.RTRN)] = log( # type: ignore[index]
629
+ data.loc[:, (self.label, ValueType.RTRN)] = log(
629
630
  data.loc[:, self.tsdf.columns.to_numpy()[0]],
630
631
  ).diff()
631
632
 
@@ -647,7 +648,83 @@ class OpenTimeSeries(_CommonModel[float]):
647
648
  return Series(
648
649
  data=rawdata,
649
650
  index=data.index,
650
- name=(self.label, ValueType.EWMA),
651
+ name=(self.label, ValueType.EWMA_VOL),
652
+ dtype="float64",
653
+ )
654
+
655
+ def ewma_var_func(
656
+ self: Self,
657
+ lmbda: float = 0.94,
658
+ day_chunk: int = 11,
659
+ level: float = 0.95,
660
+ dlta_degr_freedms: int = 0,
661
+ months_from_last: int | None = None,
662
+ from_date: dt.date | None = None,
663
+ to_date: dt.date | None = None,
664
+ periods_in_a_year_fixed: DaysInYearType | None = None,
665
+ ) -> Series[float]:
666
+ """Exponentially Weighted Moving Average Model for Value At Risk (VaR).
667
+
668
+ Reference: https://www.investopedia.com/articles/07/ewma.asp.
669
+
670
+ Args:
671
+ lmbda: Scaling factor to determine weighting. Defaults to 0.94.
672
+ day_chunk: Sampling the data which is assumed to be daily.
673
+ Defaults to 11.
674
+ level: The sought VaR level. Defaults to 0.95.
675
+ dlta_degr_freedms: Variance bias factor taking the value 0 or 1.
676
+ Defaults to 0.
677
+ months_from_last: Number of months offset as positive integer.
678
+ Overrides use of from_date and to_date. Optional.
679
+ from_date: Specific from date. Optional.
680
+ to_date: Specific to date. Optional.
681
+ periods_in_a_year_fixed: Allows locking the periods-in-a-year to simplify
682
+ test cases and comparisons. Optional.
683
+
684
+ Returns:
685
+ Series EWMA VaR.
686
+ """
687
+ earlier, later = self.calc_range(
688
+ months_offset=months_from_last,
689
+ from_dt=from_date,
690
+ to_dt=to_date,
691
+ )
692
+ time_factor = _calculate_time_factor(
693
+ data=self.tsdf.loc[
694
+ cast("Timestamp", earlier) : cast("Timestamp", later)
695
+ ].iloc[:, 0],
696
+ earlier=earlier,
697
+ later=later,
698
+ periods_in_a_year_fixed=periods_in_a_year_fixed,
699
+ )
700
+
701
+ data = self.tsdf.loc[
702
+ cast("Timestamp", earlier) : cast("Timestamp", later)
703
+ ].copy()
704
+
705
+ data.loc[:, (self.label, ValueType.RTRN)] = log(
706
+ data.loc[:, self.tsdf.columns.to_numpy()[0]],
707
+ ).diff()
708
+
709
+ rawdata = [
710
+ data[(self.label, ValueType.RTRN)]
711
+ .iloc[1:day_chunk]
712
+ .std(ddof=dlta_degr_freedms)
713
+ * sqrt(time_factor),
714
+ ]
715
+
716
+ for item in data[(self.label, ValueType.RTRN)].iloc[1:]:
717
+ prev = rawdata[-1]
718
+ rawdata.append(
719
+ sqrt(
720
+ square(item) * time_factor * (1 - lmbda) + square(prev) * lmbda,
721
+ ),
722
+ )
723
+
724
+ return Series(
725
+ data=array(rawdata) * norm.ppf(1 - level),
726
+ index=data.index,
727
+ name=(self.label, ValueType.EWMA_VAR),
651
728
  dtype="float64",
652
729
  )
653
730
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openseries"
3
- version = "2.1.4"
3
+ version = "2.1.6"
4
4
  description = "Tools for analyzing financial timeseries."
5
5
  authors = [
6
6
  { name = "Martin Karrin", email = "martin.karrin@captor.se" },
@@ -67,7 +67,7 @@ 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"
70
- ruff = "0.14.9"
70
+ ruff = "0.15.0"
71
71
  types-openpyxl = ">=3.1.2"
72
72
  scipy-stubs = ">=1.14.1.0"
73
73
  types-python-dateutil = ">=2.8.2"
@@ -80,7 +80,7 @@ sphinx-autodoc-typehints = ">=3.6.0"
80
80
  sphinx-rtd-theme = ">=3.1.0rc1"
81
81
 
82
82
  [build-system]
83
- requires = ["poetry-core>=2.2.1"]
83
+ requires = ["poetry-core>=2.3.1"]
84
84
  build-backend = "poetry.core.masonry.api"
85
85
 
86
86
  [tool.setuptools.package-data]
File without changes