openseries 1.9.1__py3-none-any.whl → 1.9.3__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
 
@@ -263,7 +263,10 @@ class _CommonModel(BaseModel): # type: ignore[misc]
263
263
 
264
264
  """
265
265
  min_accepted_return: float = 0.0
266
- return self.downside_deviation_func(min_accepted_return=min_accepted_return)
266
+ order: Literal[2, 3] = 2
267
+ return self.lower_partial_moment_func(
268
+ min_accepted_return=min_accepted_return, order=order
269
+ )
267
270
 
268
271
  @property
269
272
  def ret_vol_ratio(self: Self) -> float | Series[float]:
@@ -298,6 +301,32 @@ class _CommonModel(BaseModel): # type: ignore[misc]
298
301
  min_accepted_return=minimum_accepted_return,
299
302
  )
300
303
 
304
+ @property
305
+ def kappa3_ratio(self: Self) -> float | Series[float]:
306
+ """Kappa-3 ratio.
307
+
308
+ The Kappa-3 ratio is a generalized downside-risk ratio defined as
309
+ annualized arithmetic return divided by the cubic-root of the
310
+ lower partial moment of order 3 (with respect to a minimum acceptable
311
+ return, MAR). It penalizes larger downside outcomes more heavily than
312
+ the Sortino ratio (which uses order 2).
313
+
314
+ Returns:
315
+ -------
316
+ float | Pandas.Series[float]
317
+ Kappa-3 ratio calculation with the riskfree rate and
318
+ Minimum Acceptable Return (MAR) both set to zero.
319
+
320
+ """
321
+ riskfree_rate: float = 0.0
322
+ minimum_accepted_return: float = 0.0
323
+ order: Literal[2, 3] = 3
324
+ return self.sortino_ratio_func(
325
+ riskfree_rate=riskfree_rate,
326
+ min_accepted_return=minimum_accepted_return,
327
+ order=order,
328
+ )
329
+
301
330
  @property
302
331
  def omega_ratio(self: Self) -> float | Series[float]:
303
332
  """https://en.wikipedia.org/wiki/Omega_ratio.
@@ -403,7 +432,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
403
432
 
404
433
  wmdf = wmdf.reindex(index=[deyt.date() for deyt in dates], method=method)
405
434
  wmdf.index = DatetimeIndex(wmdf.index)
406
- result = wmdf.ffill().pct_change().min()
435
+ result = wmdf.pct_change().min()
407
436
 
408
437
  if self.tsdf.shape[1] == 1:
409
438
  return float(result.iloc[0])
@@ -559,6 +588,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
559
588
  countries: CountriesType | None = None,
560
589
  markets: list[str] | str | None = None,
561
590
  custom_holidays: list[str] | str | None = None,
591
+ method: LiteralPandasReindexMethod = "nearest",
562
592
  ) -> Self:
563
593
  """Align the index of .tsdf with local calendar business days.
564
594
 
@@ -570,6 +600,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
570
600
  (List of) markets code(s) according to pandas-market-calendars
571
601
  custom_holidays: list[str] | str, optional
572
602
  Argument where missing holidays can be added
603
+ method: LiteralPandasReindexMethod, default: "nearest"
573
604
 
574
605
  Returns:
575
606
  -------
@@ -620,7 +651,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
620
651
  freq=CustomBusinessDay(calendar=calendar),
621
652
  )
622
653
  ]
623
- self.tsdf = self.tsdf.reindex(d_range, method=None, copy=False)
654
+ self.tsdf = self.tsdf.reindex(labels=d_range, method=method, copy=False)
624
655
 
625
656
  return self
626
657
 
@@ -1016,7 +1047,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1016
1047
  )
1017
1048
  figure.update_layout(yaxis={"tickformat": tick_fmt})
1018
1049
 
1019
- if show_last is True:
1050
+ if show_last:
1020
1051
  txt = f"Last {{:{tick_fmt}}}" if tick_fmt else "Last {}"
1021
1052
 
1022
1053
  for item in range(self.tsdf.shape[1]):
@@ -1063,7 +1094,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1063
1094
  def plot_histogram(
1064
1095
  self: Self,
1065
1096
  plot_type: LiteralPlotlyHistogramPlotType = "bars",
1066
- histnorm: LiteralPlotlyHistogramHistNorm = "percent",
1097
+ histnorm: LiteralPlotlyHistogramHistNorm = "probability",
1067
1098
  barmode: LiteralPlotlyHistogramBarMode = "overlay",
1068
1099
  xbins_size: float | None = None,
1069
1100
  opacity: float = 0.75,
@@ -1167,6 +1198,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1167
1198
  figure.add_histogram(
1168
1199
  x=self.tsdf.iloc[:, item],
1169
1200
  cumulative={"enabled": cumulative},
1201
+ histfunc="count",
1170
1202
  histnorm=histnorm,
1171
1203
  name=labels[item],
1172
1204
  xbins={"size": xbins_size},
@@ -1273,7 +1305,6 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1273
1305
 
1274
1306
  result = (
1275
1307
  self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1276
- .ffill()
1277
1308
  .pct_change()
1278
1309
  .mean()
1279
1310
  * time_factor
@@ -1335,7 +1366,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1335
1366
  time_factor = how_many / fraction
1336
1367
 
1337
1368
  data = self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1338
- result = data.ffill().pct_change().std().mul(sqrt(time_factor))
1369
+ result = data.pct_change().std().mul(sqrt(time_factor))
1339
1370
 
1340
1371
  if self.tsdf.shape[1] == 1:
1341
1372
  return float(cast("SupportsFloat", result.iloc[0]))
@@ -1533,24 +1564,21 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1533
1564
  if drift_adjust:
1534
1565
  imp_vol = (-sqrt(time_factor) / norm.ppf(level)) * (
1535
1566
  self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1536
- .ffill()
1537
1567
  .pct_change()
1538
1568
  .quantile(1 - level, interpolation=interpolation)
1539
1569
  - self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1540
- .ffill()
1541
1570
  .pct_change()
1542
1571
  .sum()
1543
1572
  / len(
1544
- self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1545
- .ffill()
1546
- .pct_change(),
1573
+ self.tsdf.loc[
1574
+ cast("int", earlier) : cast("int", later)
1575
+ ].pct_change(),
1547
1576
  )
1548
1577
  )
1549
1578
  else:
1550
1579
  imp_vol = (
1551
1580
  -sqrt(time_factor)
1552
1581
  * self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1553
- .ffill()
1554
1582
  .pct_change()
1555
1583
  .quantile(1 - level, interpolation=interpolation)
1556
1584
  / norm.ppf(level)
@@ -1616,14 +1644,12 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1616
1644
  )
1617
1645
  result = [
1618
1646
  cvar_df.loc[:, x] # type: ignore[call-overload,index]
1619
- .ffill()
1620
1647
  .pct_change()
1621
1648
  .sort_values()
1622
1649
  .iloc[
1623
1650
  : ceil(
1624
1651
  (1 - level)
1625
1652
  * cvar_df.loc[:, x] # type: ignore[index]
1626
- .ffill()
1627
1653
  .pct_change()
1628
1654
  .count(),
1629
1655
  ),
@@ -1640,24 +1666,28 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1640
1666
  dtype="float64",
1641
1667
  )
1642
1668
 
1643
- def downside_deviation_func(
1669
+ def lower_partial_moment_func(
1644
1670
  self: Self,
1645
1671
  min_accepted_return: float = 0.0,
1672
+ order: Literal[2, 3] = 2,
1646
1673
  months_from_last: int | None = None,
1647
1674
  from_date: dt.date | None = None,
1648
1675
  to_date: dt.date | None = None,
1649
1676
  periods_in_a_year_fixed: DaysInYearType | None = None,
1650
1677
  ) -> float | Series[float]:
1651
- """Downside Deviation.
1678
+ """Downside Deviation if order set to 2.
1652
1679
 
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.
1680
+ If order is set to 2 the function calculates the standard
1681
+ deviation of returns that are below a Minimum Accepted
1682
+ Return of zero. For general order p, it returns LPM_p^(1/p),
1683
+ i.e., the rooted lower partial moment of order p.
1656
1684
 
1657
1685
  Parameters
1658
1686
  ----------
1659
1687
  min_accepted_return : float, optional
1660
1688
  The annualized Minimum Accepted Return (MAR)
1689
+ order: int, default: 2
1690
+ Order of partial moment
1661
1691
  months_from_last : int, optional
1662
1692
  number of months offset as positive integer. Overrides use of from_date
1663
1693
  and to_date
@@ -1672,18 +1702,22 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1672
1702
  Returns:
1673
1703
  -------
1674
1704
  float | Pandas.Series[float]
1675
- Downside deviation
1705
+ Downside deviation if order set to 2
1676
1706
 
1677
1707
  """
1708
+ msg = f"'order' must be 2 or 3, got {order!r}."
1709
+ if order not in (2, 3):
1710
+ raise ValueError(msg)
1711
+
1678
1712
  zero: float = 0.0
1679
1713
  earlier, later = self.calc_range(
1680
1714
  months_offset=months_from_last,
1681
1715
  from_dt=from_date,
1682
1716
  to_dt=to_date,
1683
1717
  )
1718
+
1684
1719
  how_many = (
1685
1720
  self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1686
- .ffill()
1687
1721
  .pct_change()
1688
1722
  .count(numeric_only=True)
1689
1723
  )
@@ -1697,23 +1731,26 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1697
1731
  fraction = (later - earlier).days / 365.25
1698
1732
  time_factor = how_many.div(fraction)
1699
1733
 
1700
- dddf = (
1734
+ per_period_mar = min_accepted_return / time_factor
1735
+ diff = (
1701
1736
  self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1702
- .ffill()
1703
1737
  .pct_change()
1704
- .sub(min_accepted_return / time_factor)
1738
+ .sub(per_period_mar)
1705
1739
  )
1706
1740
 
1707
- result = sqrt((dddf[dddf < zero] ** 2).sum() / how_many) * sqrt(
1708
- time_factor,
1709
- )
1741
+ shortfall = (-diff).clip(lower=zero)
1742
+ base = shortfall.pow(order).sum() / how_many
1743
+ result = base.pow(1.0 / float(order))
1744
+ result *= sqrt(time_factor)
1745
+
1746
+ dd_order = 2
1710
1747
 
1711
1748
  if self.tsdf.shape[1] == 1:
1712
1749
  return float(result.iloc[0])
1713
1750
  return Series(
1714
1751
  data=result,
1715
1752
  index=self.tsdf.columns,
1716
- name="Downside deviation",
1753
+ name="Downside deviation" if order == dd_order else f"LPM{order}",
1717
1754
  dtype="float64",
1718
1755
  )
1719
1756
 
@@ -1808,7 +1845,6 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1808
1845
  )
1809
1846
  result: NDArray[float64] = skew(
1810
1847
  a=self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1811
- .ffill()
1812
1848
  .pct_change()
1813
1849
  .to_numpy(),
1814
1850
  bias=True,
@@ -1856,11 +1892,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1856
1892
  to_dt=to_date,
1857
1893
  )
1858
1894
  result: NDArray[float64] = kurtosis(
1859
- a=(
1860
- self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1861
- .ffill()
1862
- .pct_change()
1863
- ),
1895
+ a=(self.tsdf.loc[cast("int", earlier) : cast("int", later)].pct_change()),
1864
1896
  fisher=True,
1865
1897
  bias=True,
1866
1898
  nan_policy="omit",
@@ -1956,18 +1988,16 @@ class _CommonModel(BaseModel): # type: ignore[misc]
1956
1988
  )
1957
1989
  pos = (
1958
1990
  self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1959
- .ffill()
1960
1991
  .pct_change()[1:][
1961
- self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1962
- .ffill()
1963
- .pct_change()[1:]
1992
+ self.tsdf.loc[cast("int", earlier) : cast("int", later)].pct_change()[
1993
+ 1:
1994
+ ]
1964
1995
  > zero
1965
1996
  ]
1966
1997
  .count()
1967
1998
  )
1968
1999
  tot = (
1969
2000
  self.tsdf.loc[cast("int", earlier) : cast("int", later)]
1970
- .ffill()
1971
2001
  .pct_change()
1972
2002
  .count()
1973
2003
  )
@@ -2047,18 +2077,22 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2047
2077
  self: Self,
2048
2078
  riskfree_rate: float = 0.0,
2049
2079
  min_accepted_return: float = 0.0,
2080
+ order: Literal[2, 3] = 2,
2050
2081
  months_from_last: int | None = None,
2051
2082
  from_date: dt.date | None = None,
2052
2083
  to_date: dt.date | None = None,
2053
2084
  periods_in_a_year_fixed: DaysInYearType | None = None,
2054
2085
  ) -> float | Series[float]:
2055
- """Sortino Ratio.
2086
+ """Sortino Ratio or Kappa3 Ratio.
2056
2087
 
2057
2088
  The Sortino ratio calculated as ( return - risk free return )
2058
2089
  / downside deviation. The ratio implies that the riskfree asset has zero
2059
2090
  volatility, and a minimum acceptable return of zero. The ratio is
2060
2091
  calculated using the annualized arithmetic mean of returns.
2061
2092
  https://www.investopedia.com/terms/s/sortinoratio.asp.
2093
+ If order is set to 3 the ratio calculated becomes Kappa3 which
2094
+ penalizes larger downside outcomes more heavily than the Sortino
2095
+ ratio (which uses order 2).
2062
2096
 
2063
2097
  Parameters
2064
2098
  ----------
@@ -2066,6 +2100,8 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2066
2100
  The return of the zero volatility asset
2067
2101
  min_accepted_return : float, optional
2068
2102
  The annualized Minimum Accepted Return (MAR)
2103
+ order: int, default: 2
2104
+ Order of partial moment
2069
2105
  months_from_last : int, optional
2070
2106
  number of months offset as positive integer. Overrides use of from_date
2071
2107
  and to_date
@@ -2092,20 +2128,22 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2092
2128
  periods_in_a_year_fixed=periods_in_a_year_fixed,
2093
2129
  )
2094
2130
  - riskfree_rate,
2095
- ) / self.downside_deviation_func(
2131
+ ) / self.lower_partial_moment_func(
2096
2132
  min_accepted_return=min_accepted_return,
2133
+ order=order,
2097
2134
  months_from_last=months_from_last,
2098
2135
  from_date=from_date,
2099
2136
  to_date=to_date,
2100
2137
  periods_in_a_year_fixed=periods_in_a_year_fixed,
2101
2138
  )
2102
2139
 
2140
+ sortino_order = 2
2103
2141
  if self.tsdf.shape[1] == 1:
2104
2142
  return float(cast("float64", ratio.iloc[0]))
2105
2143
  return Series(
2106
2144
  data=ratio,
2107
2145
  index=self.tsdf.columns,
2108
- name="Sortino ratio",
2146
+ name="Sortino ratio" if order == sortino_order else "Kappa-3 ratio",
2109
2147
  dtype="float64",
2110
2148
  )
2111
2149
 
@@ -2146,11 +2184,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2146
2184
  from_dt=from_date,
2147
2185
  to_dt=to_date,
2148
2186
  )
2149
- retdf = (
2150
- self.tsdf.loc[cast("int", earlier) : cast("int", later)]
2151
- .ffill()
2152
- .pct_change()
2153
- )
2187
+ retdf = self.tsdf.loc[cast("int", earlier) : cast("int", later)].pct_change()
2154
2188
  pos = retdf[retdf > min_accepted_return].sub(min_accepted_return).sum()
2155
2189
  neg = retdf[retdf < min_accepted_return].sub(min_accepted_return).sum()
2156
2190
  ratio = pos / -neg
@@ -2238,7 +2272,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2238
2272
  period = "-".join([str(year), str(month).zfill(2)])
2239
2273
  vrdf = self.tsdf.copy()
2240
2274
  vrdf.index = DatetimeIndex(vrdf.index)
2241
- resultdf = DataFrame(vrdf.ffill().pct_change())
2275
+ resultdf = DataFrame(vrdf.pct_change())
2242
2276
  result = resultdf.loc[period] + 1
2243
2277
  cal_period = result.cumprod(axis="index").iloc[-1] - 1
2244
2278
  if self.tsdf.shape[1] == 1:
@@ -2290,7 +2324,6 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2290
2324
  )
2291
2325
  result = (
2292
2326
  self.tsdf.loc[cast("int", earlier) : cast("int", later)]
2293
- .ffill()
2294
2327
  .pct_change()
2295
2328
  .quantile(1 - level, interpolation=interpolation)
2296
2329
  )
@@ -2339,7 +2372,6 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2339
2372
  )
2340
2373
  result = (
2341
2374
  self.tsdf.loc[cast("int", earlier) : cast("int", later)]
2342
- .ffill()
2343
2375
  .pct_change()
2344
2376
  .rolling(observations, min_periods=observations)
2345
2377
  .sum()
@@ -2386,11 +2418,9 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2386
2418
  from_dt=from_date,
2387
2419
  to_dt=to_date,
2388
2420
  )
2389
- zscframe = (
2390
- self.tsdf.loc[cast("int", earlier) : cast("int", later)]
2391
- .ffill()
2392
- .pct_change()
2393
- )
2421
+ zscframe = self.tsdf.loc[
2422
+ cast("int", earlier) : cast("int", later)
2423
+ ].pct_change()
2394
2424
  result = (zscframe.iloc[-1] - zscframe.mean()) / zscframe.std()
2395
2425
 
2396
2426
  if self.tsdf.shape[1] == 1:
@@ -2459,7 +2489,6 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2459
2489
  ret_label = cast("tuple[str]", self.tsdf.iloc[:, column].name)[0]
2460
2490
  retseries = (
2461
2491
  Series(self.tsdf.iloc[:, column])
2462
- .ffill()
2463
2492
  .pct_change()
2464
2493
  .rolling(observations, min_periods=observations)
2465
2494
  .sum()
@@ -2513,6 +2542,7 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2513
2542
  column: int = 0,
2514
2543
  observations: int = 21,
2515
2544
  periods_in_a_year_fixed: DaysInYearType | None = None,
2545
+ dlta_degr_freedms: int = 1,
2516
2546
  ) -> DataFrame:
2517
2547
  """Calculate rolling annualised volatilities.
2518
2548
 
@@ -2525,6 +2555,8 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2525
2555
  periods_in_a_year_fixed : DaysInYearType, optional
2526
2556
  Allows locking the periods-in-a-year to simplify test cases and
2527
2557
  comparisons
2558
+ dlta_degr_freedms: int, default: 1
2559
+ Variance bias factor taking the value 0 or 1.
2528
2560
 
2529
2561
  Returns:
2530
2562
  -------
@@ -2536,15 +2568,16 @@ class _CommonModel(BaseModel): # type: ignore[misc]
2536
2568
  time_factor = float(periods_in_a_year_fixed)
2537
2569
  else:
2538
2570
  time_factor = self.periods_in_a_year
2571
+
2539
2572
  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
- )
2573
+
2574
+ s = log(self.tsdf.iloc[:, column]).diff()
2575
+ volseries = s.rolling(window=observations, min_periods=observations).std(
2576
+ ddof=dlta_degr_freedms
2577
+ ) * sqrt(time_factor)
2578
+
2547
2579
  voldf = volseries.dropna().to_frame()
2580
+
2548
2581
  voldf.columns = MultiIndex.from_arrays(
2549
2582
  [
2550
2583
  [vol_label],