openseries 2.1.5__tar.gz → 2.1.7__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.5
3
+ Version: 2.1.7
4
4
  Summary: Tools for analyzing financial timeseries.
5
5
  License-Expression: BSD-3-Clause
6
6
  License-File: LICENSE.md
@@ -31,6 +31,7 @@ Requires-Dist: python-dateutil (>=2.8.2)
31
31
  Requires-Dist: requests (>=2.20.0)
32
32
  Requires-Dist: scikit-learn (>=1.4.0)
33
33
  Requires-Dist: scipy (>=1.14.1)
34
+ Requires-Dist: tzdata (>=2025.3)
34
35
  Project-URL: Documentation, https://openseries.readthedocs.io/
35
36
  Project-URL: Homepage, https://captorab.github.io/openseries/
36
37
  Project-URL: Issue Tracker, https://github.com/CaptorAB/openseries/issues
@@ -50,7 +51,7 @@ Description-Content-Type: text/markdown
50
51
  [![Python version](https://img.shields.io/pypi/pyversions/openseries.svg)](https://www.python.org/)
51
52
  [![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
53
  [![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)
54
+ [![Documentation Status](https://readthedocs.org/projects/openseries/badge/?version=latest)](https://captorab.github.io/openseries/)
54
55
  [![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/)
55
56
  [![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
57
  [![GitHub License](https://img.shields.io/github/license/CaptorAB/openseries)](https://github.com/CaptorAB/openseries/blob/master/LICENSE.md)
@@ -60,7 +61,7 @@ Tools for analyzing financial timeseries of a single asset or a group of assets.
60
61
 
61
62
  ## Documentation
62
63
 
63
- Complete documentation is available at: [https://openseries.readthedocs.io](https://openseries.readthedocs.io/)
64
+ Complete documentation is available at: [https://captorab.github.io/openseries/](https://captorab.github.io/openseries/)
64
65
 
65
66
  The documentation includes:
66
67
 
@@ -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
 
@@ -162,6 +162,30 @@ def _get_base_column_data(
162
162
  return data, item, label
163
163
 
164
164
 
165
+ def _demeaned_returns_for_autocorr(
166
+ series: Series[float], valuetype: ValueType, *, squared: bool = False
167
+ ) -> Series[float]:
168
+ """Return demeaned return series for autocorrelation analysis.
169
+
170
+ Args:
171
+ series: Input series (prices or returns).
172
+ valuetype: ValueType.PRICE for price data (pct_change applied),
173
+ else use as returns.
174
+ squared: If True, square the demeaned returns.
175
+
176
+ Returns:
177
+ Demeaned return series (optionally squared).
178
+ """
179
+ if valuetype == ValueType.PRICE:
180
+ rets = series.ffill().pct_change().dropna()
181
+ else:
182
+ rets = series.ffill().dropna()
183
+ rets = rets - rets.mean()
184
+ if squared:
185
+ rets = rets**2
186
+ return rets
187
+
188
+
165
189
  def _calculate_time_factor(
166
190
  data: Series[float],
167
191
  earlier: dt.date,
@@ -282,8 +306,9 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
282
306
  result = (
283
307
  self.tsdf.groupby(years)
284
308
  .apply(
285
- lambda prices: (prices / prices.expanding(min_periods=1).max()).min()
286
- - 1,
309
+ lambda prices: (
310
+ (prices / prices.expanding(min_periods=1).max()).min() - 1
311
+ ),
287
312
  )
288
313
  .min()
289
314
  )
@@ -353,6 +378,23 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
353
378
  """
354
379
  return self.vol_func()
355
380
 
381
+ @property
382
+ def autocorr(self: Self) -> SeriesOrFloat_co:
383
+ """Autocorrelation at lag 1.
384
+
385
+ Shorthand for ``autocorr_func(lag=1)``. Returns the lag-1 autocorrelation
386
+ of demeaned returns. For price series, returns are computed via
387
+ ``pct_change``; for return series, raw values are used after demeaning.
388
+
389
+ Returns:
390
+ --------
391
+ SeriesOrFloat_co
392
+ Autocorrelation at lag 1.
393
+ Returns float for OpenTimeSeries, Series[float] for OpenFrame.
394
+
395
+ """
396
+ return self.autocorr_func()
397
+
356
398
  @property
357
399
  def downside_deviation(self: Self) -> SeriesOrFloat_co:
358
400
  """Downside Deviation.
@@ -823,7 +865,7 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
823
865
  else None,
824
866
  )
825
867
  ]
826
- self.tsdf = self.tsdf.reindex(labels=d_range, method=method, copy=False)
868
+ self.tsdf = self.tsdf.reindex(labels=d_range, method=method)
827
869
 
828
870
  return self
829
871
 
@@ -1481,6 +1523,50 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
1481
1523
 
1482
1524
  return self._coerce_result(result=result, name="Volatility")
1483
1525
 
1526
+ def autocorr_func(
1527
+ self: Self,
1528
+ lag: int = 1,
1529
+ *,
1530
+ squared: bool = False,
1531
+ ) -> SeriesOrFloat_co:
1532
+ """Calculate autocorrelation at a given lag.
1533
+
1534
+ Computes the autocorrelation of demeaned returns at the specified lag.
1535
+ For price series (ValueType.PRICE), returns are derived via ``pct_change``;
1536
+ for return series (ValueType.RTRN), raw values are demeaned. Use
1537
+ ``squared=True`` for squared-return autocorrelation (e.g. volatility
1538
+ clustering). Returns ``nan`` when the series has too few observations.
1539
+
1540
+ Args:
1541
+ lag: The lag at which to compute autocorrelation. Defaults to 1.
1542
+ squared: If True, compute autocorrelation of squared returns.
1543
+ Defaults to False.
1544
+
1545
+ Returns:
1546
+ Autocorrelation at the specified lag. Float for OpenTimeSeries,
1547
+ ``Series[float]`` for OpenFrame.
1548
+ """
1549
+ values: list[float] = []
1550
+ vtypes = self.tsdf.columns.get_level_values(1)
1551
+ for col_idx, col in enumerate(self.tsdf.columns):
1552
+ valuetype = cast("ValueType", vtypes[col_idx])
1553
+ rets = _demeaned_returns_for_autocorr(
1554
+ series=self.tsdf[col],
1555
+ valuetype=valuetype,
1556
+ squared=squared,
1557
+ )
1558
+ if len(rets) > lag:
1559
+ values.append(float(rets.autocorr(lag=lag)))
1560
+ else:
1561
+ values.append(float("nan"))
1562
+ result = Series(
1563
+ data=values,
1564
+ index=self.tsdf.columns,
1565
+ name="Autocorrelation",
1566
+ dtype="float64",
1567
+ )
1568
+ return self._coerce_result(result=result, name="Autocorrelation")
1569
+
1484
1570
  def vol_from_var_func(
1485
1571
  self: Self,
1486
1572
  level: float = 0.95,
@@ -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
  )
@@ -663,13 +663,12 @@ class OpenFrame(_CommonModel[SeriesFloat]):
663
663
  if not end_cut and where in ["after", "both"]:
664
664
  end_cut = self.last_indices.min()
665
665
  self.tsdf = self.tsdf.sort_index()
666
- self.tsdf = self.tsdf.truncate(before=start_cut, after=end_cut, copy=False)
666
+ self.tsdf = self.tsdf.truncate(before=start_cut, after=end_cut)
667
667
 
668
668
  for xerie in self.constituents:
669
669
  xerie.tsdf = xerie.tsdf.truncate(
670
670
  before=start_cut,
671
671
  after=end_cut,
672
- copy=False,
673
672
  )
674
673
  if len(set(self.first_indices)) != 1:
675
674
  msg = (
@@ -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,
@@ -184,11 +184,14 @@ LiteralSeriesProps = Literal[
184
184
  "span_of_days",
185
185
  "yearfrac",
186
186
  "periods_in_a_year",
187
+ "autocorr",
188
+ "partial_autocorr",
187
189
  ]
188
190
  LiteralFrameProps = Literal[
189
191
  "value_ret",
190
192
  "geo_ret",
191
193
  "arithmetic_ret",
194
+ "autocorr",
192
195
  "vol",
193
196
  "downside_deviation",
194
197
  "ret_vol_ratio",
@@ -273,6 +276,8 @@ class OpenTimeSeriesPropertiesList(PropertiesList):
273
276
  "span_of_days",
274
277
  "yearfrac",
275
278
  "periods_in_a_year",
279
+ "autocorr",
280
+ "partial_autocorr",
276
281
  }
277
282
 
278
283
  def __init__(
@@ -288,6 +293,7 @@ class OpenFramePropertiesList(PropertiesList):
288
293
  """Allowed property arguments for the OpenFrame class."""
289
294
 
290
295
  allowed_strings: ClassVar[set[str]] = PropertiesList.allowed_strings | {
296
+ "autocorr",
291
297
  "first_indices",
292
298
  "last_indices",
293
299
  "lengths_of_items",
@@ -300,10 +306,11 @@ class OpenFramePropertiesList(PropertiesList):
300
306
  self._validate()
301
307
 
302
308
 
303
- class ValueType(str, Enum):
309
+ class ValueType(StrEnum):
304
310
  """Enum types of OpenTimeSeries to identify the output."""
305
311
 
306
- EWMA = "EWMA"
312
+ EWMA_VOL = "EWMA volatility"
313
+ EWMA_VAR = "EWMA VaR"
307
314
  PRICE = "Price(Close)"
308
315
  RTRN = "Return(Total)"
309
316
  RELRTRN = "Relative return"
@@ -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
 
@@ -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(
@@ -586,19 +586,21 @@ def report_html(
586
586
 
587
587
  for item, f in zip(rpt_df.index, formats, strict=False):
588
588
  rpt_df.loc[item] = rpt_df.loc[item].apply(
589
- lambda x, fmt=f: ""
590
- if (
591
- x is None
592
- or (not isinstance(x, str) and isna(x))
593
- or (isinstance(x, str) and x.lower() in ("nan", "nan%", ""))
594
- )
595
- else (
596
- str(x)
597
- 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
+ )
598
596
  else (
599
- Timestamp(x).strftime("%Y-%m-%d")
600
- if "%Y-%m-%d" in fmt and not isinstance(x, str)
601
- 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
+ )
602
604
  )
603
605
  ),
604
606
  )
@@ -32,8 +32,13 @@ from pandas import (
32
32
  date_range,
33
33
  )
34
34
  from pydantic import field_validator, model_validator
35
+ from scipy.stats import chi2, norm
35
36
 
36
- from ._common_model import _calculate_time_factor, _CommonModel
37
+ from ._common_model import (
38
+ _calculate_time_factor,
39
+ _CommonModel,
40
+ _demeaned_returns_for_autocorr,
41
+ )
37
42
  from .datefixer import _do_resample_to_business_period_ends, date_fix
38
43
  from .owntypes import (
39
44
  Countries,
@@ -415,7 +420,18 @@ class OpenTimeSeries(_CommonModel[float]):
415
420
  )
416
421
 
417
422
  props = OpenTimeSeriesPropertiesList(*properties)
418
- pdf = DataFrame.from_dict({x: getattr(self, x) for x in props}, orient="index")
423
+
424
+ def _prop_value(name: str) -> float | int | dt.date | Series[float]:
425
+ attr = getattr(self, name)
426
+ return cast(
427
+ "float | int | dt.date | Series[float]",
428
+ attr() if callable(attr) else attr,
429
+ )
430
+
431
+ pdf = DataFrame.from_dict(
432
+ {x: _prop_value(x) for x in props},
433
+ orient="index",
434
+ )
419
435
  pdf.columns = self.tsdf.columns
420
436
  return pdf
421
437
 
@@ -647,7 +663,83 @@ class OpenTimeSeries(_CommonModel[float]):
647
663
  return Series(
648
664
  data=rawdata,
649
665
  index=data.index,
650
- name=(self.label, ValueType.EWMA),
666
+ name=(self.label, ValueType.EWMA_VOL),
667
+ dtype="float64",
668
+ )
669
+
670
+ def ewma_var_func(
671
+ self: Self,
672
+ lmbda: float = 0.94,
673
+ day_chunk: int = 11,
674
+ level: float = 0.95,
675
+ dlta_degr_freedms: int = 0,
676
+ months_from_last: int | None = None,
677
+ from_date: dt.date | None = None,
678
+ to_date: dt.date | None = None,
679
+ periods_in_a_year_fixed: DaysInYearType | None = None,
680
+ ) -> Series[float]:
681
+ """Exponentially Weighted Moving Average Model for Value At Risk (VaR).
682
+
683
+ Reference: https://www.investopedia.com/articles/07/ewma.asp.
684
+
685
+ Args:
686
+ lmbda: Scaling factor to determine weighting. Defaults to 0.94.
687
+ day_chunk: Sampling the data which is assumed to be daily.
688
+ Defaults to 11.
689
+ level: The sought VaR level. Defaults to 0.95.
690
+ dlta_degr_freedms: Variance bias factor taking the value 0 or 1.
691
+ Defaults to 0.
692
+ months_from_last: Number of months offset as positive integer.
693
+ Overrides use of from_date and to_date. Optional.
694
+ from_date: Specific from date. Optional.
695
+ to_date: Specific to date. Optional.
696
+ periods_in_a_year_fixed: Allows locking the periods-in-a-year to simplify
697
+ test cases and comparisons. Optional.
698
+
699
+ Returns:
700
+ Series EWMA VaR.
701
+ """
702
+ earlier, later = self.calc_range(
703
+ months_offset=months_from_last,
704
+ from_dt=from_date,
705
+ to_dt=to_date,
706
+ )
707
+ time_factor = _calculate_time_factor(
708
+ data=self.tsdf.loc[
709
+ cast("Timestamp", earlier) : cast("Timestamp", later)
710
+ ].iloc[:, 0],
711
+ earlier=earlier,
712
+ later=later,
713
+ periods_in_a_year_fixed=periods_in_a_year_fixed,
714
+ )
715
+
716
+ data = self.tsdf.loc[
717
+ cast("Timestamp", earlier) : cast("Timestamp", later)
718
+ ].copy()
719
+
720
+ data.loc[:, (self.label, ValueType.RTRN)] = log(
721
+ data.loc[:, self.tsdf.columns.to_numpy()[0]],
722
+ ).diff()
723
+
724
+ rawdata = [
725
+ data[(self.label, ValueType.RTRN)]
726
+ .iloc[1:day_chunk]
727
+ .std(ddof=dlta_degr_freedms)
728
+ * sqrt(time_factor),
729
+ ]
730
+
731
+ for item in data[(self.label, ValueType.RTRN)].iloc[1:]:
732
+ prev = rawdata[-1]
733
+ rawdata.append(
734
+ sqrt(
735
+ square(item) * time_factor * (1 - lmbda) + square(prev) * lmbda,
736
+ ),
737
+ )
738
+
739
+ return Series(
740
+ data=array(rawdata) * norm.ppf(1 - level),
741
+ index=data.index,
742
+ name=(self.label, ValueType.EWMA_VAR),
651
743
  dtype="float64",
652
744
  )
653
745
 
@@ -747,6 +839,147 @@ class OpenTimeSeries(_CommonModel[float]):
747
839
  self.tsdf.columns = self.tsdf.columns.droplevel(level=1)
748
840
  return self
749
841
 
842
+ def _returns_series(self: Self, *, squared: bool = False) -> Series[float]:
843
+ """Return demeaned return series for autocorrelation analysis."""
844
+ data: Series[float] = self.tsdf.iloc[:, 0]
845
+ return _demeaned_returns_for_autocorr(
846
+ series=data, valuetype=self.valuetype, squared=squared
847
+ )
848
+
849
+ def acf(
850
+ self: Self,
851
+ lags: int | list[int],
852
+ *,
853
+ squared: bool = False,
854
+ ) -> Series[float]:
855
+ """Calculate autocorrelation function for specified lags.
856
+
857
+ Args:
858
+ lags: If int, compute ACF from lag 0 to this value (inclusive).
859
+ If list, compute ACF at lag 0 plus each lag in the list.
860
+ squared: If True, compute ACF of squared returns. Defaults to False.
861
+
862
+ Returns:
863
+ Series of autocorrelations indexed by lag.
864
+ """
865
+ rets = self._returns_series(squared=squared)
866
+ if isinstance(lags, int):
867
+ lag_list = list(range(lags + 1))
868
+ else:
869
+ lag_list = sorted({0} | set(lags))
870
+ values: list[float] = []
871
+ for lag in lag_list:
872
+ if lag == 0:
873
+ values.append(1.0)
874
+ else:
875
+ values.append(float(rets.autocorr(lag=lag)))
876
+ return Series(
877
+ data=values,
878
+ index=lag_list,
879
+ name="ACF",
880
+ dtype="float64",
881
+ )
882
+
883
+ def partial_autocorr(self: Self, lag: int = 1, *, squared: bool = False) -> float:
884
+ """Calculate partial autocorrelation at a given lag.
885
+
886
+ Args:
887
+ lag: The lag at which to compute partial autocorrelation. Defaults to 1.
888
+ squared: If True, compute partial autocorrelation of squared returns.
889
+ Defaults to False.
890
+
891
+ Returns:
892
+ Partial autocorrelation at the specified lag.
893
+ """
894
+ pacf_series = self.pacf(lags=lag, squared=squared)
895
+ return float(pacf_series.loc[lag])
896
+
897
+ def pacf(
898
+ self: Self,
899
+ lags: int | list[int],
900
+ *,
901
+ squared: bool = False,
902
+ ) -> Series[float]:
903
+ """Calculate partial autocorrelation function for specified lags.
904
+
905
+ Args:
906
+ lags: If int, compute PACF from lag 0 to this value (inclusive).
907
+ If list, compute PACF at lag 0 plus each lag in the list.
908
+ squared: If True, compute PACF of squared returns. Defaults to False.
909
+
910
+ Returns:
911
+ Series of partial autocorrelations indexed by lag.
912
+ """
913
+ if isinstance(lags, int):
914
+ lag_list = list(range(lags + 1))
915
+ else:
916
+ lag_list = sorted({0} | set(lags))
917
+ max_lag = max(lag_list) if lag_list else 0
918
+ acf_vals = self.acf(lags=max_lag, squared=squared)
919
+ acf_arr = array([acf_vals.loc[k] for k in range(max_lag + 1)])
920
+ pacf_values: list[float] = [1.0]
921
+ phi: list[list[float]] = []
922
+ for k in range(1, max_lag + 1):
923
+ if k == 1:
924
+ phi_kk = acf_arr[1]
925
+ else:
926
+ numer = acf_arr[k]
927
+ denom = 1.0
928
+ for j in range(k - 1):
929
+ numer -= phi[k - 2][j] * acf_arr[k - 1 - j]
930
+ denom -= phi[k - 2][j] * acf_arr[j + 1]
931
+ phi_kk = numer / denom
932
+ phi_row = [0.0] * k
933
+ for j in range(k - 1):
934
+ phi_row[j] = phi[k - 2][j] - phi_kk * phi[k - 2][k - 2 - j]
935
+ phi_row[k - 1] = phi_kk
936
+ phi.append(phi_row)
937
+ pacf_values.append(phi_kk)
938
+ result = {lag: pacf_values[lag] for lag in lag_list}
939
+ return Series(
940
+ data=[result[lag] for lag in lag_list],
941
+ index=lag_list,
942
+ name="PACF",
943
+ dtype="float64",
944
+ )
945
+
946
+ def ljung_box(
947
+ self: Self,
948
+ lags: int | list[int],
949
+ *,
950
+ squared: bool = False,
951
+ ) -> tuple[float, float, list[int]]:
952
+ """Compute Ljung-Box test for autocorrelation.
953
+
954
+ Args:
955
+ lags: If int, use lags 1 through this value. If list, use the given
956
+ lags (lag 0 excluded from test).
957
+ squared: If True, test autocorrelation of squared returns.
958
+ Defaults to False.
959
+
960
+ Returns:
961
+ Tuple of (statistic, pvalue, lags) where statistic is the Ljung-Box
962
+ Q statistic, pvalue is the chi-squared p-value, and lags is the
963
+ list of lags used.
964
+ """
965
+ rets = self._returns_series(squared=squared)
966
+ n = len(rets)
967
+ if isinstance(lags, int):
968
+ lag_list = list(range(1, lags + 1))
969
+ else:
970
+ lag_list = sorted({k for k in lags if k > 0})
971
+ if not lag_list:
972
+ return 0.0, 1.0, []
973
+ r_k_sq_sum = 0.0
974
+ for k in lag_list:
975
+ if k < n:
976
+ r_k = float(rets.autocorr(lag=k))
977
+ r_k_sq_sum += r_k**2 / (n - k)
978
+ q_stat = n * (n + 2) * r_k_sq_sum
979
+ df = len(lag_list)
980
+ pval = float(1.0 - chi2.cdf(q_stat, df))
981
+ return q_stat, pval, lag_list
982
+
750
983
 
751
984
  def timeseries_chain(
752
985
  front: TypeOpenTimeSeries,
@@ -47,6 +47,35 @@ class _JumpParams(TypedDict, total=False):
47
47
  jumps_mu: float
48
48
 
49
49
 
50
+ def _validate_ar1_coef(ar1_coef: float) -> None:
51
+ """Validate ar1_coef is in (-1, 1) for stationarity."""
52
+ if not -1.0 < ar1_coef < 1.0:
53
+ msg = f"ar1_coef must be in (-1, 1) for stationarity, got {ar1_coef}"
54
+ raise ValueError(msg)
55
+
56
+
57
+ def _apply_ar1_filter(returns: DataFrame, ar1_coef: float) -> DataFrame:
58
+ """Apply AR(1) filter to returns to introduce lag-1 autocorrelation.
59
+
60
+ r_t = ar1_coef * r_{t-1} + sqrt(1 - ar1_coef**2) * innovation_t
61
+ Preserves mean and variance of the base process.
62
+
63
+ Args:
64
+ returns: DataFrame of shape (number_of_sims, trading_days).
65
+ ar1_coef: Lag-1 autocorrelation coefficient in (-1, 1).
66
+
67
+ Returns:
68
+ Filtered returns.
69
+ """
70
+ if ar1_coef == 0.0:
71
+ return returns
72
+ arr = returns.to_numpy(copy=True)
73
+ scale = sqrt(1.0 - ar1_coef * ar1_coef)
74
+ for t in range(1, arr.shape[1]):
75
+ arr[:, t] = ar1_coef * arr[:, t - 1] + scale * arr[:, t]
76
+ return DataFrame(data=arr, dtype="float64")
77
+
78
+
50
79
  def _random_generator(seed: int | None) -> Generator:
51
80
  """Make a Numpy Random Generator object.
52
81
 
@@ -183,6 +212,7 @@ class ReturnSimulation(BaseModel):
183
212
  trading_days_in_year: DaysInYearType = 252,
184
213
  seed: int | None = None,
185
214
  randomizer: Generator | None = None,
215
+ ar1_coef: float = 0.0,
186
216
  ) -> ReturnSimulation:
187
217
  """Create a Normal distribution simulation.
188
218
 
@@ -195,22 +225,29 @@ class ReturnSimulation(BaseModel):
195
225
  Defaults to 252.
196
226
  seed: Seed for random process initiation.
197
227
  randomizer: Random process generator.
228
+ ar1_coef: Lag-1 autoregressive coefficient in (-1, 1) to induce
229
+ autocorrelation. Defaults to 0.0 (i.i.d. returns).
198
230
 
199
231
  Returns:
200
232
  Normal distribution simulation.
201
233
  """
234
+ _validate_ar1_coef(ar1_coef)
202
235
  if not randomizer:
203
236
  randomizer = _random_generator(seed=seed)
204
237
 
205
- returns = randomizer.normal(
206
- loc=mean_annual_return / trading_days_in_year,
207
- scale=mean_annual_vol / sqrt(trading_days_in_year),
208
- size=(number_of_sims, trading_days),
238
+ returns_df = DataFrame(
239
+ data=randomizer.normal(
240
+ loc=mean_annual_return / trading_days_in_year,
241
+ scale=mean_annual_vol / sqrt(trading_days_in_year),
242
+ size=(number_of_sims, trading_days),
243
+ ),
244
+ dtype="float64",
209
245
  )
246
+ returns = _apply_ar1_filter(returns_df, ar1_coef)
210
247
 
211
248
  return _create_base_simulation(
212
249
  cls=cls,
213
- returns=DataFrame(data=returns, dtype="float64"),
250
+ returns=returns,
214
251
  number_of_sims=number_of_sims,
215
252
  trading_days=trading_days,
216
253
  trading_days_in_year=trading_days_in_year,
@@ -229,6 +266,7 @@ class ReturnSimulation(BaseModel):
229
266
  trading_days_in_year: DaysInYearType = 252,
230
267
  seed: int | None = None,
231
268
  randomizer: Generator | None = None,
269
+ ar1_coef: float = 0.0,
232
270
  ) -> ReturnSimulation:
233
271
  """Create a Lognormal distribution simulation.
234
272
 
@@ -241,25 +279,32 @@ class ReturnSimulation(BaseModel):
241
279
  Defaults to 252.
242
280
  seed: Seed for random process initiation.
243
281
  randomizer: Random process generator.
282
+ ar1_coef: Lag-1 autoregressive coefficient in (-1, 1) to induce
283
+ autocorrelation. Defaults to 0.0 (i.i.d. returns).
244
284
 
245
285
  Returns:
246
286
  Lognormal distribution simulation.
247
287
  """
288
+ _validate_ar1_coef(ar1_coef)
248
289
  if not randomizer:
249
290
  randomizer = _random_generator(seed=seed)
250
291
 
251
- returns = (
252
- randomizer.lognormal(
253
- mean=mean_annual_return / trading_days_in_year,
254
- sigma=mean_annual_vol / sqrt(trading_days_in_year),
255
- size=(number_of_sims, trading_days),
256
- )
257
- - 1
292
+ returns_df = DataFrame(
293
+ data=(
294
+ randomizer.lognormal(
295
+ mean=mean_annual_return / trading_days_in_year,
296
+ sigma=mean_annual_vol / sqrt(trading_days_in_year),
297
+ size=(number_of_sims, trading_days),
298
+ )
299
+ - 1
300
+ ),
301
+ dtype="float64",
258
302
  )
303
+ returns = _apply_ar1_filter(returns_df, ar1_coef)
259
304
 
260
305
  return _create_base_simulation(
261
306
  cls=cls,
262
- returns=DataFrame(data=returns, dtype="float64"),
307
+ returns=returns,
263
308
  number_of_sims=number_of_sims,
264
309
  trading_days=trading_days,
265
310
  trading_days_in_year=trading_days_in_year,
@@ -278,6 +323,7 @@ class ReturnSimulation(BaseModel):
278
323
  trading_days_in_year: DaysInYearType = 252,
279
324
  seed: int | None = None,
280
325
  randomizer: Generator | None = None,
326
+ ar1_coef: float = 0.0,
281
327
  ) -> ReturnSimulation:
282
328
  """Create a Geometric Brownian Motion simulation.
283
329
 
@@ -290,10 +336,13 @@ class ReturnSimulation(BaseModel):
290
336
  Defaults to 252.
291
337
  seed: Seed for random process initiation.
292
338
  randomizer: Random process generator.
339
+ ar1_coef: Lag-1 autoregressive coefficient in (-1, 1) to induce
340
+ autocorrelation. Defaults to 0.0 (i.i.d. returns).
293
341
 
294
342
  Returns:
295
343
  Geometric Brownian Motion simulation.
296
344
  """
345
+ _validate_ar1_coef(ar1_coef)
297
346
  if not randomizer:
298
347
  randomizer = _random_generator(seed=seed)
299
348
 
@@ -308,11 +357,12 @@ class ReturnSimulation(BaseModel):
308
357
  size=(number_of_sims, trading_days),
309
358
  )
310
359
 
311
- returns = drift + wiener
360
+ returns_df = DataFrame(data=drift + wiener, dtype="float64")
361
+ returns = _apply_ar1_filter(returns_df, ar1_coef)
312
362
 
313
363
  return _create_base_simulation(
314
364
  cls=cls,
315
- returns=DataFrame(data=returns, dtype="float64"),
365
+ returns=returns,
316
366
  number_of_sims=number_of_sims,
317
367
  trading_days=trading_days,
318
368
  trading_days_in_year=trading_days_in_year,
@@ -334,6 +384,7 @@ class ReturnSimulation(BaseModel):
334
384
  trading_days_in_year: DaysInYearType = 252,
335
385
  seed: int | None = None,
336
386
  randomizer: Generator | None = None,
387
+ ar1_coef: float = 0.0,
337
388
  ) -> ReturnSimulation:
338
389
  """Create a Merton Jump-Diffusion model simulation.
339
390
 
@@ -350,10 +401,13 @@ class ReturnSimulation(BaseModel):
350
401
  Defaults to 252.
351
402
  seed: Seed for random process initiation.
352
403
  randomizer: Random process generator.
404
+ ar1_coef: Lag-1 autoregressive coefficient in (-1, 1) to induce
405
+ autocorrelation. Defaults to 0.0 (i.i.d. returns).
353
406
 
354
407
  Returns:
355
408
  Merton Jump-Diffusion model simulation.
356
409
  """
410
+ _validate_ar1_coef(ar1_coef)
357
411
  if not randomizer:
358
412
  randomizer = _random_generator(seed=seed)
359
413
 
@@ -382,13 +436,15 @@ class ReturnSimulation(BaseModel):
382
436
  - jumps_lamda * (jumps_mu + jumps_sigma**2.0)
383
437
  ) * (1.0 / trading_days_in_year)
384
438
 
385
- returns = poisson_jumps + drift + wiener
439
+ raw_returns = poisson_jumps + drift + wiener
440
+ raw_returns[:, 0] = 0.0
386
441
 
387
- returns[:, 0] = 0.0
442
+ returns_df = DataFrame(data=raw_returns, dtype="float64")
443
+ returns = _apply_ar1_filter(returns_df, ar1_coef)
388
444
 
389
445
  return _create_base_simulation(
390
446
  cls=cls,
391
- returns=DataFrame(data=returns, dtype="float64"),
447
+ returns=returns,
392
448
  number_of_sims=number_of_sims,
393
449
  trading_days=trading_days,
394
450
  trading_days_in_year=trading_days_in_year,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openseries"
3
- version = "2.1.5"
3
+ version = "2.1.7"
4
4
  description = "Tools for analyzing financial timeseries."
5
5
  authors = [
6
6
  { name = "Martin Karrin", email = "martin.karrin@captor.se" },
@@ -50,7 +50,8 @@ dependencies = [
50
50
  "python-dateutil>=2.8.2",
51
51
  "requests>=2.20.0",
52
52
  "scipy>=1.14.1",
53
- "scikit-learn>=1.4.0"
53
+ "scikit-learn>=1.4.0",
54
+ "tzdata (>=2025.3)"
54
55
  ]
55
56
 
56
57
  [project.urls]
@@ -67,7 +68,7 @@ pre-commit = ">=4.5.1"
67
68
  pytest = ">=9.0.2"
68
69
  pytest-cov = ">=7.0.0"
69
70
  pytest-xdist = ">=3.8.0"
70
- ruff = "0.14.10"
71
+ ruff = "0.15.6"
71
72
  types-openpyxl = ">=3.1.2"
72
73
  scipy-stubs = ">=1.14.1.0"
73
74
  types-python-dateutil = ">=2.8.2"
@@ -80,7 +81,7 @@ sphinx-autodoc-typehints = ">=3.6.0"
80
81
  sphinx-rtd-theme = ">=3.1.0rc1"
81
82
 
82
83
  [build-system]
83
- requires = ["poetry-core>=2.2.1"]
84
+ requires = ["poetry-core>=2.3.1"]
84
85
  build-backend = "poetry.core.masonry.api"
85
86
 
86
87
  [tool.setuptools.package-data]
File without changes