openseries 1.9.2__py3-none-any.whl → 1.9.4__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.
@@ -17,7 +17,7 @@ from math import ceil
17
17
  from pathlib import Path
18
18
  from secrets import choice
19
19
  from string import ascii_letters
20
- from typing import TYPE_CHECKING, Any, SupportsFloat, cast
20
+ from typing import TYPE_CHECKING, Any, Literal, SupportsFloat, cast
21
21
 
22
22
  from numpy import float64, inf, isnan, log, maximum, sqrt
23
23
 
@@ -25,7 +25,9 @@ from .owntypes import (
25
25
  DateAlignmentError,
26
26
  InitialValueZeroError,
27
27
  NumberOfItemsAndLabelsNotSameError,
28
+ ResampleDataLossError,
28
29
  Self,
30
+ ValueType,
29
31
  )
30
32
 
31
33
  if TYPE_CHECKING: # pragma: no cover
@@ -47,7 +49,6 @@ if TYPE_CHECKING: # pragma: no cover
47
49
  LiteralPlotlyJSlib,
48
50
  LiteralPlotlyOutput,
49
51
  LiteralQuantileInterp,
50
- ValueType,
51
52
  )
52
53
  from openpyxl.utils.dataframe import dataframe_to_rows
53
54
  from openpyxl.workbook.workbook import Workbook
@@ -263,7 +264,10 @@ class _CommonModel(BaseModel): # type: ignore[misc]
263
264
 
264
265
  """
265
266
  min_accepted_return: float = 0.0
266
- return self.downside_deviation_func(min_accepted_return=min_accepted_return)
267
+ order: Literal[2, 3] = 2
268
+ return self.lower_partial_moment_func(
269
+ min_accepted_return=min_accepted_return, order=order
270
+ )
267
271
 
268
272
  @property
269
273
  def ret_vol_ratio(self: Self) -> float | Series[float]:
@@ -298,6 +302,32 @@ class _CommonModel(BaseModel): # type: ignore[misc]
298
302
  min_accepted_return=minimum_accepted_return,
299
303
  )
300
304
 
305
+ @property
306
+ def kappa3_ratio(self: Self) -> float | Series[float]:
307
+ """Kappa-3 ratio.
308
+
309
+ The Kappa-3 ratio is a generalized downside-risk ratio defined as
310
+ annualized arithmetic return divided by the cubic-root of the
311
+ lower partial moment of order 3 (with respect to a minimum acceptable
312
+ return, MAR). It penalizes larger downside outcomes more heavily than
313
+ the Sortino ratio (which uses order 2).
314
+
315
+ Returns:
316
+ -------
317
+ float | Pandas.Series[float]
318
+ Kappa-3 ratio calculation with the riskfree rate and
319
+ Minimum Acceptable Return (MAR) both set to zero.
320
+
321
+ """
322
+ riskfree_rate: float = 0.0
323
+ minimum_accepted_return: float = 0.0
324
+ order: Literal[2, 3] = 3
325
+ return self.sortino_ratio_func(
326
+ riskfree_rate=riskfree_rate,
327
+ min_accepted_return=minimum_accepted_return,
328
+ order=order,
329
+ )
330
+
301
331
  @property
302
332
  def omega_ratio(self: Self) -> float | Series[float]:
303
333
  """https://en.wikipedia.org/wiki/Omega_ratio.
@@ -403,6 +433,16 @@ class _CommonModel(BaseModel): # type: ignore[misc]
403
433
 
404
434
  wmdf = wmdf.reindex(index=[deyt.date() for deyt in dates], method=method)
405
435
  wmdf.index = DatetimeIndex(wmdf.index)
436
+
437
+ vtypes = [x == ValueType.RTRN for x in wmdf.columns.get_level_values(1)]
438
+ if any(vtypes):
439
+ msg = (
440
+ "Do not run worst_month on return series. The operation will "
441
+ "pick the last data point in the sparser series. It will not sum "
442
+ "returns and therefore data will be lost and result will be wrong."
443
+ )
444
+ raise ResampleDataLossError(msg)
445
+
406
446
  result = wmdf.ffill().pct_change().min()
407
447
 
408
448
  if self.tsdf.shape[1] == 1:
@@ -559,6 +599,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
559
599
  countries: CountriesType | None = None,
560
600
  markets: list[str] | str | None = None,
561
601
  custom_holidays: list[str] | str | None = None,
602
+ method: LiteralPandasReindexMethod = "nearest",
562
603
  ) -> Self:
563
604
  """Align the index of .tsdf with local calendar business days.
564
605
 
@@ -570,6 +611,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
570
611
  (List of) markets code(s) according to pandas-market-calendars
571
612
  custom_holidays: list[str] | str, optional
572
613
  Argument where missing holidays can be added
614
+ method: LiteralPandasReindexMethod, default: "nearest"
573
615
 
574
616
  Returns:
575
617
  -------
@@ -620,7 +662,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
620
662
  freq=CustomBusinessDay(calendar=calendar),
621
663
  )
622
664
  ]
623
- self.tsdf = self.tsdf.reindex(d_range, method=None, copy=False)
665
+ self.tsdf = self.tsdf.reindex(labels=d_range, method=method, copy=False)
624
666
 
625
667
  return self
626
668
 
@@ -1016,7 +1058,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1016
1058
  )
1017
1059
  figure.update_layout(yaxis={"tickformat": tick_fmt})
1018
1060
 
1019
- if show_last is True:
1061
+ if show_last:
1020
1062
  txt = f"Last {{:{tick_fmt}}}" if tick_fmt else "Last {}"
1021
1063
 
1022
1064
  for item in range(self.tsdf.shape[1]):
@@ -1063,7 +1105,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1063
1105
  def plot_histogram(
1064
1106
  self: Self,
1065
1107
  plot_type: LiteralPlotlyHistogramPlotType = "bars",
1066
- histnorm: LiteralPlotlyHistogramHistNorm = "percent",
1108
+ histnorm: LiteralPlotlyHistogramHistNorm = "probability",
1067
1109
  barmode: LiteralPlotlyHistogramBarMode = "overlay",
1068
1110
  xbins_size: float | None = None,
1069
1111
  opacity: float = 0.75,
@@ -1167,6 +1209,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1167
1209
  figure.add_histogram(
1168
1210
  x=self.tsdf.iloc[:, item],
1169
1211
  cumulative={"enabled": cumulative},
1212
+ histfunc="count",
1170
1213
  histnorm=histnorm,
1171
1214
  name=labels[item],
1172
1215
  xbins={"size": xbins_size},
@@ -1640,24 +1683,28 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1640
1683
  dtype="float64",
1641
1684
  )
1642
1685
 
1643
- def downside_deviation_func(
1686
+ def lower_partial_moment_func(
1644
1687
  self: Self,
1645
1688
  min_accepted_return: float = 0.0,
1689
+ order: Literal[2, 3] = 2,
1646
1690
  months_from_last: int | None = None,
1647
1691
  from_date: dt.date | None = None,
1648
1692
  to_date: dt.date | None = None,
1649
1693
  periods_in_a_year_fixed: DaysInYearType | None = None,
1650
1694
  ) -> float | Series[float]:
1651
- """Downside Deviation.
1695
+ """Downside Deviation if order set to 2.
1652
1696
 
1653
- The standard deviation of returns that are below a Minimum Accepted
1654
- Return of zero. It is used to calculate the Sortino Ratio.
1655
- https://www.investopedia.com/terms/d/downside-deviation.asp.
1697
+ If order is set to 2 the function calculates the standard
1698
+ deviation of returns that are below a Minimum Accepted
1699
+ Return of zero. For general order p, it returns LPM_p^(1/p),
1700
+ i.e., the rooted lower partial moment of order p.
1656
1701
 
1657
1702
  Parameters
1658
1703
  ----------
1659
1704
  min_accepted_return : float, optional
1660
1705
  The annualized Minimum Accepted Return (MAR)
1706
+ order: int, default: 2
1707
+ Order of partial moment
1661
1708
  months_from_last : int, optional
1662
1709
  number of months offset as positive integer. Overrides use of from_date
1663
1710
  and to_date
@@ -1672,15 +1719,20 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1672
1719
  Returns:
1673
1720
  -------
1674
1721
  float | Pandas.Series[float]
1675
- Downside deviation
1722
+ Downside deviation if order set to 2
1676
1723
 
1677
1724
  """
1725
+ msg = f"'order' must be 2 or 3, got {order!r}."
1726
+ if order not in (2, 3):
1727
+ raise ValueError(msg)
1728
+
1678
1729
  zero: float = 0.0
1679
1730
  earlier, later = self.calc_range(
1680
1731
  months_offset=months_from_last,
1681
1732
  from_dt=from_date,
1682
1733
  to_dt=to_date,
1683
1734
  )
1735
+
1684
1736
  how_many = (
1685
1737
  self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1686
1738
  .ffill()
@@ -1697,23 +1749,27 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1697
1749
  fraction = (later - earlier).days / 365.25
1698
1750
  time_factor = how_many.div(fraction)
1699
1751
 
1700
- dddf = (
1752
+ per_period_mar = min_accepted_return / time_factor
1753
+ diff = (
1701
1754
  self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1702
1755
  .ffill()
1703
1756
  .pct_change()
1704
- .sub(min_accepted_return / time_factor)
1757
+ .sub(per_period_mar)
1705
1758
  )
1706
1759
 
1707
- result = sqrt((dddf[dddf < zero] ** 2).sum() / how_many) * sqrt(
1708
- time_factor,
1709
- )
1760
+ shortfall = (-diff).clip(lower=zero)
1761
+ base = shortfall.pow(order).sum() / how_many
1762
+ result = base.pow(1.0 / float(order))
1763
+ result *= sqrt(time_factor)
1764
+
1765
+ dd_order = 2
1710
1766
 
1711
1767
  if self.tsdf.shape[1] == 1:
1712
1768
  return float(result.iloc[0])
1713
1769
  return Series(
1714
1770
  data=result,
1715
1771
  index=self.tsdf.columns,
1716
- name="Downside deviation",
1772
+ name="Downside deviation" if order == dd_order else f"LPM{order}",
1717
1773
  dtype="float64",
1718
1774
  )
1719
1775
 
@@ -2047,18 +2103,22 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2047
2103
  self: Self,
2048
2104
  riskfree_rate: float = 0.0,
2049
2105
  min_accepted_return: float = 0.0,
2106
+ order: Literal[2, 3] = 2,
2050
2107
  months_from_last: int | None = None,
2051
2108
  from_date: dt.date | None = None,
2052
2109
  to_date: dt.date | None = None,
2053
2110
  periods_in_a_year_fixed: DaysInYearType | None = None,
2054
2111
  ) -> float | Series[float]:
2055
- """Sortino Ratio.
2112
+ """Sortino Ratio or Kappa3 Ratio.
2056
2113
 
2057
2114
  The Sortino ratio calculated as ( return - risk free return )
2058
2115
  / downside deviation. The ratio implies that the riskfree asset has zero
2059
2116
  volatility, and a minimum acceptable return of zero. The ratio is
2060
2117
  calculated using the annualized arithmetic mean of returns.
2061
2118
  https://www.investopedia.com/terms/s/sortinoratio.asp.
2119
+ If order is set to 3 the ratio calculated becomes Kappa3 which
2120
+ penalizes larger downside outcomes more heavily than the Sortino
2121
+ ratio (which uses order 2).
2062
2122
 
2063
2123
  Parameters
2064
2124
  ----------
@@ -2066,6 +2126,8 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2066
2126
  The return of the zero volatility asset
2067
2127
  min_accepted_return : float, optional
2068
2128
  The annualized Minimum Accepted Return (MAR)
2129
+ order: int, default: 2
2130
+ Order of partial moment
2069
2131
  months_from_last : int, optional
2070
2132
  number of months offset as positive integer. Overrides use of from_date
2071
2133
  and to_date
@@ -2092,20 +2154,22 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2092
2154
  periods_in_a_year_fixed=periods_in_a_year_fixed,
2093
2155
  )
2094
2156
  - riskfree_rate,
2095
- ) / self.downside_deviation_func(
2157
+ ) / self.lower_partial_moment_func(
2096
2158
  min_accepted_return=min_accepted_return,
2159
+ order=order,
2097
2160
  months_from_last=months_from_last,
2098
2161
  from_date=from_date,
2099
2162
  to_date=to_date,
2100
2163
  periods_in_a_year_fixed=periods_in_a_year_fixed,
2101
2164
  )
2102
2165
 
2166
+ sortino_order = 2
2103
2167
  if self.tsdf.shape[1] == 1:
2104
2168
  return float(cast("float64", ratio.iloc[0]))
2105
2169
  return Series(
2106
2170
  data=ratio,
2107
2171
  index=self.tsdf.columns,
2108
- name="Sortino ratio",
2172
+ name="Sortino ratio" if order == sortino_order else "Kappa-3 ratio",
2109
2173
  dtype="float64",
2110
2174
  )
2111
2175
 
@@ -2513,6 +2577,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2513
2577
  column: int = 0,
2514
2578
  observations: int = 21,
2515
2579
  periods_in_a_year_fixed: DaysInYearType | None = None,
2580
+ dlta_degr_freedms: int = 1,
2516
2581
  ) -> DataFrame:
2517
2582
  """Calculate rolling annualised volatilities.
2518
2583
 
@@ -2525,6 +2590,8 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2525
2590
  periods_in_a_year_fixed : DaysInYearType, optional
2526
2591
  Allows locking the periods-in-a-year to simplify test cases and
2527
2592
  comparisons
2593
+ dlta_degr_freedms: int, default: 1
2594
+ Variance bias factor taking the value 0 or 1.
2528
2595
 
2529
2596
  Returns:
2530
2597
  -------
@@ -2536,15 +2603,16 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2536
2603
  time_factor = float(periods_in_a_year_fixed)
2537
2604
  else:
2538
2605
  time_factor = self.periods_in_a_year
2606
+
2539
2607
  vol_label = cast("tuple[str, ValueType]", self.tsdf.iloc[:, column].name)[0]
2540
- dframe = Series(self.tsdf.iloc[:, column]).ffill().pct_change()
2541
- volseries = dframe.rolling(
2542
- observations,
2543
- min_periods=observations,
2544
- ).std() * sqrt(
2545
- time_factor,
2546
- )
2608
+
2609
+ s = log(self.tsdf.iloc[:, column]).diff()
2610
+ volseries = s.rolling(window=observations, min_periods=observations).std(
2611
+ ddof=dlta_degr_freedms
2612
+ ) * sqrt(time_factor)
2613
+
2547
2614
  voldf = volseries.dropna().to_frame()
2615
+
2548
2616
  voldf.columns = MultiIndex.from_arrays(
2549
2617
  [
2550
2618
  [vol_label],