mxm-refdata 0.3.0__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.
- mxm/refdata/__init__.py +1 -0
- mxm/refdata/api/__init__.py +1 -0
- mxm/refdata/api/ref_data_api.py +607 -0
- mxm/refdata/cli.py +218 -0
- mxm/refdata/data/first_day_of_interest_rule.json +88 -0
- mxm/refdata/data/futures_products.csv +6 -0
- mxm/refdata/data/last_trading_rule.json +34 -0
- mxm/refdata/database/__init__.py +1 -0
- mxm/refdata/database/sql_session_manager.py +135 -0
- mxm/refdata/mappings/__init__.py +31 -0
- mxm/refdata/mappings/futures_contract_vs_orm.py +51 -0
- mxm/refdata/mappings/futures_product_vs_orm.py +56 -0
- mxm/refdata/mappings/period_cycles_vs_orm.py +102 -0
- mxm/refdata/mappings/period_vs_orm.py +40 -0
- mxm/refdata/models/__init__.py +31 -0
- mxm/refdata/models/contracts/__init__.py +0 -0
- mxm/refdata/models/contracts/futures_contract.py +22 -0
- mxm/refdata/models/currencies.py +24 -0
- mxm/refdata/models/months.py +72 -0
- mxm/refdata/models/orm/__init__.py +16 -0
- mxm/refdata/models/orm/base.py +3 -0
- mxm/refdata/models/orm/futures_contracts.py +53 -0
- mxm/refdata/models/orm/futures_products.py +61 -0
- mxm/refdata/models/orm/period_cycles.py +84 -0
- mxm/refdata/models/orm/periods.py +30 -0
- mxm/refdata/models/period_cycles.py +73 -0
- mxm/refdata/models/periods.py +64 -0
- mxm/refdata/models/products/__init__.py +1 -0
- mxm/refdata/models/products/futures_product.py +38 -0
- mxm/refdata/models/products/settlement.py +10 -0
- mxm/refdata/models/reference_events.py +9 -0
- mxm/refdata/models/units.py +28 -0
- mxm/refdata/models/weekdays.py +66 -0
- mxm/refdata/parsing/__init__.py +1 -0
- mxm/refdata/parsing/futures_products_from_csv.py +76 -0
- mxm/refdata/py.typed +0 -0
- mxm/refdata/scripts/__init__.py +1 -0
- mxm/refdata/scripts/db_utils.py +61 -0
- mxm/refdata/scripts/manage_static_ref_data.py +73 -0
- mxm/refdata/services/__init__.py +0 -0
- mxm/refdata/services/bootstrap.py +84 -0
- mxm/refdata/services/futures_contract_factory.py +127 -0
- mxm/refdata/services/futures_product_factory.py +132 -0
- mxm/refdata/services/period_factory.py +315 -0
- mxm/refdata/services/ref_data_service.py +322 -0
- mxm/refdata/services/smokecheck.py +326 -0
- mxm/refdata/trading_calendars/__init__.py +0 -0
- mxm/refdata/trading_calendars/first_day_of_interest.py +74 -0
- mxm/refdata/trading_calendars/last_trading_day.py +117 -0
- mxm/refdata/trading_calendars/nth_business_day.py +52 -0
- mxm/refdata/trading_calendars/nth_calendar_day_of_period.py +43 -0
- mxm/refdata/trading_calendars/nth_weekday_of_period.py +50 -0
- mxm/refdata/trading_calendars/trading_calendar.py +186 -0
- mxm/refdata/utils/__init__.py +1 -0
- mxm/refdata/utils/cache_manager.py +33 -0
- mxm/refdata/utils/config.py +32 -0
- mxm/refdata/utils/period_types_codec.py +16 -0
- mxm/refdata/utils/regex_patterns.py +18 -0
- mxm/refdata/utils/resources.py +29 -0
- mxm_refdata-0.3.0.dist-info/METADATA +228 -0
- mxm_refdata-0.3.0.dist-info/RECORD +64 -0
- mxm_refdata-0.3.0.dist-info/WHEEL +4 -0
- mxm_refdata-0.3.0.dist-info/entry_points.txt +3 -0
- mxm_refdata-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Factory for FuturesProduct instances.
|
|
3
|
+
|
|
4
|
+
Semantics
|
|
5
|
+
---------
|
|
6
|
+
- This factory provides *interning*: at most one FuturesProduct instance per
|
|
7
|
+
product_id within this process.
|
|
8
|
+
- It may be initialised from CSV for convenience.
|
|
9
|
+
- It also supports construction from a typed dict payload (useful for tests),
|
|
10
|
+
but the preferred path is to parse into FuturesProduct and then intern.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
from typing import Any, TypedDict, cast
|
|
17
|
+
|
|
18
|
+
from mxm.refdata.models.currencies import Currency
|
|
19
|
+
from mxm.refdata.models.periods import PeriodType
|
|
20
|
+
from mxm.refdata.models.products.futures_product import FuturesProduct, SettlementMethod
|
|
21
|
+
from mxm.refdata.models.units import ProductUnit
|
|
22
|
+
from mxm.refdata.parsing.futures_products_from_csv import parse_futures_products_csv
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FuturesProductSpec(TypedDict, total=False):
|
|
26
|
+
"""
|
|
27
|
+
Typed input payload for creating FuturesProduct instances.
|
|
28
|
+
|
|
29
|
+
Intended primarily for tests/fixtures and controlled programmatic creation.
|
|
30
|
+
|
|
31
|
+
Notes:
|
|
32
|
+
- This is *not* persisted format.
|
|
33
|
+
- period_types must already be canonical: tuple[PeriodType, ...].
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
product_id: str
|
|
37
|
+
venue: str
|
|
38
|
+
description: str
|
|
39
|
+
currency: Currency
|
|
40
|
+
unit: ProductUnit
|
|
41
|
+
contract_size: float
|
|
42
|
+
valid_period_rule: str
|
|
43
|
+
listing_rule: str
|
|
44
|
+
period_types: tuple[PeriodType, ...]
|
|
45
|
+
settlement: SettlementMethod
|
|
46
|
+
last_trading_rule: str
|
|
47
|
+
expiry_rule: str
|
|
48
|
+
trading_calendar: str
|
|
49
|
+
trading_hours: str | None
|
|
50
|
+
tick_size: float | None
|
|
51
|
+
tick_value: float | None
|
|
52
|
+
initial_margin: float | None
|
|
53
|
+
maintenance_margin: float | None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FuturesProductFactory:
|
|
57
|
+
"""Factory / interning cache for FuturesProduct instances."""
|
|
58
|
+
|
|
59
|
+
_instance: FuturesProductFactory | None = None
|
|
60
|
+
_lock = threading.Lock()
|
|
61
|
+
_cache: dict[str, FuturesProduct]
|
|
62
|
+
|
|
63
|
+
def __new__(cls) -> FuturesProductFactory:
|
|
64
|
+
with cls._lock:
|
|
65
|
+
if cls._instance is None:
|
|
66
|
+
cls._instance = super().__new__(cls)
|
|
67
|
+
cls._instance._cache = {} # instance-owned cache
|
|
68
|
+
return cls._instance
|
|
69
|
+
|
|
70
|
+
# -------------------------
|
|
71
|
+
# Core cache operations
|
|
72
|
+
# -------------------------
|
|
73
|
+
|
|
74
|
+
def intern(self, product: FuturesProduct) -> FuturesProduct:
|
|
75
|
+
"""
|
|
76
|
+
Return the canonical FuturesProduct instance for product.product_id.
|
|
77
|
+
"""
|
|
78
|
+
cached = self._cache.get(product.product_id)
|
|
79
|
+
if cached is None:
|
|
80
|
+
self._cache[product.product_id] = product
|
|
81
|
+
return product
|
|
82
|
+
return cached
|
|
83
|
+
|
|
84
|
+
def get(self, product_id: str) -> FuturesProduct | None:
|
|
85
|
+
"""Return product if present, else None."""
|
|
86
|
+
return self._cache.get(product_id)
|
|
87
|
+
|
|
88
|
+
def require(self, product_id: str) -> FuturesProduct:
|
|
89
|
+
"""Return product if present, else raise."""
|
|
90
|
+
p = self._cache.get(product_id)
|
|
91
|
+
if p is None:
|
|
92
|
+
raise KeyError(f"Unknown product_id: {product_id!r}")
|
|
93
|
+
return p
|
|
94
|
+
|
|
95
|
+
def all(self) -> list[FuturesProduct]:
|
|
96
|
+
"""Return all cached products (order not guaranteed)."""
|
|
97
|
+
return list(self._cache.values())
|
|
98
|
+
|
|
99
|
+
def clear(self) -> None:
|
|
100
|
+
"""Clear the cache (useful in tests)."""
|
|
101
|
+
self._cache.clear()
|
|
102
|
+
|
|
103
|
+
# -------------------------
|
|
104
|
+
# Construction helpers
|
|
105
|
+
# -------------------------
|
|
106
|
+
|
|
107
|
+
def create_from_spec(self, spec: FuturesProductSpec) -> FuturesProduct:
|
|
108
|
+
"""
|
|
109
|
+
Create (and intern) a FuturesProduct from a typed spec payload.
|
|
110
|
+
|
|
111
|
+
This is useful for tests, fixtures, and programmatic creation.
|
|
112
|
+
|
|
113
|
+
Requirements:
|
|
114
|
+
- spec must include product_id and all required FuturesProduct fields.
|
|
115
|
+
- period_types must be a tuple[PeriodType, ...] (canonical).
|
|
116
|
+
"""
|
|
117
|
+
if "product_id" not in spec or not spec["product_id"]:
|
|
118
|
+
raise ValueError("FuturesProductSpec requires non-empty 'product_id'")
|
|
119
|
+
|
|
120
|
+
# TypedDict is total=False, so we need to trust the caller for required keys.
|
|
121
|
+
# This is intended for controlled/test usage.
|
|
122
|
+
product = FuturesProduct(**cast(dict[str, Any], spec)) # safe boundary
|
|
123
|
+
return self.intern(product)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def initialise_from_csv(cls, csv_file_path: str) -> list[FuturesProduct]:
|
|
127
|
+
"""
|
|
128
|
+
Load products from CSV and intern them into the factory cache.
|
|
129
|
+
"""
|
|
130
|
+
products = parse_futures_products_csv(csv_file_path)
|
|
131
|
+
factory = cls()
|
|
132
|
+
return [factory.intern(p) for p in products]
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Module to manage the creation of Period objects with flyweight pattern."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
|
+
from datetime import date, timedelta
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
from mxm.refdata.models.months import Month
|
|
11
|
+
from mxm.refdata.models.periods import Period, PeriodType
|
|
12
|
+
from mxm.refdata.utils.regex_patterns import PERIOD_TYPE_PARSING_MAP
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HandleCurrentPeriod(Enum):
|
|
16
|
+
"""Options for handling the current period when generating a list of periods."""
|
|
17
|
+
|
|
18
|
+
INCLUDE = "include"
|
|
19
|
+
EXCLUDE = "exclude"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HandlePartialEndPeriod(Enum):
|
|
23
|
+
"""Options for handling a partial end period when generating a list of periods."""
|
|
24
|
+
|
|
25
|
+
INCLUDE = "include"
|
|
26
|
+
EXCLUDE = "exclude"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PeriodFactory:
|
|
30
|
+
"""Factory class to manage the creation of Period objects with flyweight pattern."""
|
|
31
|
+
|
|
32
|
+
_instance = None # Singleton instance
|
|
33
|
+
_period_cache: ClassVar[dict[str, Period]] = {}
|
|
34
|
+
_lock = threading.Lock() # Lock for thread safety
|
|
35
|
+
|
|
36
|
+
date_to_period_id_map: Mapping[PeriodType, Callable[[date], str]] = (
|
|
37
|
+
MappingProxyType(
|
|
38
|
+
{
|
|
39
|
+
PeriodType.YEAR: lambda d: f"{d.year}",
|
|
40
|
+
PeriodType.MONTH: lambda d: d.strftime("%b-%Y"),
|
|
41
|
+
PeriodType.QUARTER: lambda d: f"{d.year}-Q{(d.month - 1) // 3 + 1}",
|
|
42
|
+
PeriodType.WEEK: lambda d: f"{d.year}-W{d.isocalendar()[1]}",
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def __new__(cls) -> "PeriodFactory":
|
|
48
|
+
"""Ensures that only one instance of PeriodFactory is created (thread-safe singleton)."""
|
|
49
|
+
with cls._lock:
|
|
50
|
+
if cls._instance is None:
|
|
51
|
+
cls._instance = super().__new__(cls)
|
|
52
|
+
return cls._instance
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def get_period_by_id(cls, period_id: str) -> Period:
|
|
56
|
+
"""Get a Period object by canonical period_id."""
|
|
57
|
+
if period_id not in cls._period_cache:
|
|
58
|
+
period_type = cls._period_type_from_period_id(period_id)
|
|
59
|
+
first_day, last_day = cls.calculate_period_dates(period_id, period_type)
|
|
60
|
+
cls._period_cache[period_id] = Period(
|
|
61
|
+
period_id=period_id,
|
|
62
|
+
period_type=period_type,
|
|
63
|
+
first_date=first_day,
|
|
64
|
+
last_date=last_day,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return cls._period_cache[period_id]
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def get_period_by_date(
|
|
71
|
+
cls,
|
|
72
|
+
date_obj: date,
|
|
73
|
+
period_type: PeriodType,
|
|
74
|
+
) -> Period:
|
|
75
|
+
"""Get a Period object containing a date for the given period type."""
|
|
76
|
+
period = cls._create_from_date(date_obj, period_type)
|
|
77
|
+
|
|
78
|
+
if period.period_id not in cls._period_cache:
|
|
79
|
+
cls._period_cache[period.period_id] = period
|
|
80
|
+
|
|
81
|
+
return cls._period_cache[period.period_id]
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def get_period(
|
|
85
|
+
cls,
|
|
86
|
+
period_id: str | None = None,
|
|
87
|
+
date_obj: date | None = None,
|
|
88
|
+
period_type: PeriodType | None = None,
|
|
89
|
+
) -> Period:
|
|
90
|
+
"""Get a Period object by id or by date and period type.
|
|
91
|
+
|
|
92
|
+
Prefer get_period_by_id or get_period_by_date in new code.
|
|
93
|
+
"""
|
|
94
|
+
if period_id is not None:
|
|
95
|
+
return cls.get_period_by_id(period_id)
|
|
96
|
+
|
|
97
|
+
if date_obj is not None and period_type is not None:
|
|
98
|
+
return cls.get_period_by_date(date_obj, period_type)
|
|
99
|
+
|
|
100
|
+
raise ValueError("Must provide either period_id or date_obj with period_type")
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def _period_type_from_period_id(cls, period_id: str) -> PeriodType:
|
|
104
|
+
"""Parse a period_id string and return the corresponding PeriodType."""
|
|
105
|
+
for period_type, pattern in PERIOD_TYPE_PARSING_MAP.items():
|
|
106
|
+
if pattern.match(period_id):
|
|
107
|
+
return period_type
|
|
108
|
+
raise ValueError(f"Unrecognized period_id format: {period_id}")
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def _create_from_date(cls, date_obj: date, period_type: PeriodType) -> Period:
|
|
112
|
+
"""Create a Period from a specific date and period type."""
|
|
113
|
+
if period_type not in cls.date_to_period_id_map:
|
|
114
|
+
message = f"Unsupported period_type: {period_type}"
|
|
115
|
+
raise ValueError(message)
|
|
116
|
+
|
|
117
|
+
period_id = cls.date_to_period_id_map[period_type](date_obj)
|
|
118
|
+
first_day, last_day = cls.calculate_period_dates(period_id, period_type)
|
|
119
|
+
return Period(
|
|
120
|
+
period_id=period_id,
|
|
121
|
+
period_type=period_type,
|
|
122
|
+
first_date=first_day,
|
|
123
|
+
last_date=last_day,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def calculate_period_dates(
|
|
128
|
+
cls, period_id: str, period_type: PeriodType
|
|
129
|
+
) -> tuple[date, date]:
|
|
130
|
+
"""Calculate the first and last date for the given period based on its type."""
|
|
131
|
+
if period_type == PeriodType.YEAR:
|
|
132
|
+
return cls._calculate_year_dates(period_id)
|
|
133
|
+
elif period_type == PeriodType.MONTH:
|
|
134
|
+
return cls._calculate_month_dates(period_id)
|
|
135
|
+
elif period_type == PeriodType.QUARTER:
|
|
136
|
+
return cls._calculate_quarter_dates(period_id)
|
|
137
|
+
elif period_type == PeriodType.WEEK:
|
|
138
|
+
return cls._calculate_week_dates(period_id)
|
|
139
|
+
else:
|
|
140
|
+
raise ValueError(f"Unsupported period type: {period_type}")
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _calculate_year_dates(period_id: str) -> tuple[date, date]:
|
|
144
|
+
"""Calculate the first and last date for a year."""
|
|
145
|
+
year = int(period_id)
|
|
146
|
+
return date(year, 1, 1), date(year, 12, 31)
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _calculate_month_dates(period_id: str) -> tuple[date, date]:
|
|
150
|
+
"""Calculate the first and last date for a month."""
|
|
151
|
+
month_str, year = period_id.split("-")
|
|
152
|
+
month = Month.from_str(month_str)
|
|
153
|
+
first_date = date(int(year), month.as_int, 1)
|
|
154
|
+
last_date = PeriodFactory._last_day_of_month(first_date)
|
|
155
|
+
return first_date, last_date
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _calculate_quarter_dates(period_id: str) -> tuple[date, date]:
|
|
159
|
+
"""Calculate the first and last date for a quarter."""
|
|
160
|
+
year, quarter = period_id.split("-Q")
|
|
161
|
+
start_month = (int(quarter) - 1) * 3 + 1
|
|
162
|
+
first_date = date(int(year), start_month, 1)
|
|
163
|
+
last_date = PeriodFactory._last_day_of_month(
|
|
164
|
+
date(int(year), start_month + 2, 1) # Third month of the quarter
|
|
165
|
+
)
|
|
166
|
+
return first_date, last_date
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _calculate_week_dates(period_id: str) -> tuple[date, date]:
|
|
170
|
+
"""Calculate the first and last date for a week."""
|
|
171
|
+
year, week = period_id.split("-W")
|
|
172
|
+
first_date = date.fromisocalendar(int(year), int(week), 1)
|
|
173
|
+
last_date = first_date + timedelta(days=6)
|
|
174
|
+
return first_date, last_date
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _last_day_of_month(first_date: date) -> date:
|
|
178
|
+
"""Calculate the last day of the month."""
|
|
179
|
+
next_month = first_date.replace(day=28) + timedelta(
|
|
180
|
+
days=4
|
|
181
|
+
) # Move to next month
|
|
182
|
+
return next_month - timedelta(days=next_month.day)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def get_next_n_periods(
|
|
186
|
+
cls,
|
|
187
|
+
start_date: date,
|
|
188
|
+
period_type: PeriodType,
|
|
189
|
+
n: int,
|
|
190
|
+
handle_current_period: HandleCurrentPeriod = HandleCurrentPeriod.INCLUDE,
|
|
191
|
+
) -> list[Period]:
|
|
192
|
+
"""Generate the next n Period objects from a given start_date.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
start_date (date): The date to start from.
|
|
196
|
+
period_type (PeriodType): The type of period (e.g., month, quarter).
|
|
197
|
+
n (int): Number of periods to generate.
|
|
198
|
+
handle_current_period (HandleCurrentPeriod): Whether to include the period containing start_date.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
list[Period]: A list of Period objects representing the next n periods.
|
|
202
|
+
"""
|
|
203
|
+
periods: list[Period] = []
|
|
204
|
+
|
|
205
|
+
# Handle the current period
|
|
206
|
+
if handle_current_period == HandleCurrentPeriod.INCLUDE:
|
|
207
|
+
period = cls.get_period(date_obj=start_date, period_type=period_type)
|
|
208
|
+
periods.append(period)
|
|
209
|
+
|
|
210
|
+
current_date = cls.shift_date_by_n_periods(start_date, period_type, 1)
|
|
211
|
+
|
|
212
|
+
# Generate the remaining n - 1 periods
|
|
213
|
+
while len(periods) < n:
|
|
214
|
+
period = cls.get_period(date_obj=current_date, period_type=period_type)
|
|
215
|
+
periods.append(period)
|
|
216
|
+
current_date = cls.shift_date_by_n_periods(current_date, period_type, 1)
|
|
217
|
+
|
|
218
|
+
return periods
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def shift_date_by_n_periods(
|
|
222
|
+
cls, start_date: date, period_type: "PeriodType", steps: int
|
|
223
|
+
) -> date:
|
|
224
|
+
"""Shift a date by a given number of periods in the specified PeriodType."""
|
|
225
|
+
if period_type == PeriodType.YEAR:
|
|
226
|
+
return cls._shift_years(start_date, steps)
|
|
227
|
+
elif period_type == PeriodType.MONTH:
|
|
228
|
+
return cls._shift_months(start_date, steps)
|
|
229
|
+
elif period_type == PeriodType.QUARTER:
|
|
230
|
+
return cls._shift_quarters(start_date, steps)
|
|
231
|
+
elif period_type == PeriodType.WEEK:
|
|
232
|
+
return cls._shift_weeks(start_date, steps)
|
|
233
|
+
else:
|
|
234
|
+
raise ValueError(f"Unsupported period type: {period_type}")
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def _shift_years(start_date: date, steps: int) -> date:
|
|
238
|
+
"""Shift a date by a given number of years."""
|
|
239
|
+
return date(start_date.year + steps, start_date.month, start_date.day)
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _shift_months(start_date: date, steps: int) -> date:
|
|
243
|
+
"""Shift a date by a given number of months, preserving the day when possible."""
|
|
244
|
+
new_month = (start_date.month - 1 + steps) % 12 + 1
|
|
245
|
+
new_year = start_date.year + ((start_date.month - 1 + steps) // 12)
|
|
246
|
+
return date(new_year, new_month, 1)
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _shift_quarters(start_date: date, steps: int) -> date:
|
|
250
|
+
"""Shift a date by a given number of quarters."""
|
|
251
|
+
start_month = (start_date.month - 1) // 3 * 3 + 1
|
|
252
|
+
new_quarter_start_month = (start_month - 1 + steps * 3) % 12 + 1
|
|
253
|
+
new_year = start_date.year + ((start_month - 1 + steps * 3) // 12)
|
|
254
|
+
return date(new_year, new_quarter_start_month, 1)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _shift_weeks(start_date: date, steps: int) -> date:
|
|
258
|
+
"""Shift a date by a given number of weeks."""
|
|
259
|
+
return start_date + timedelta(weeks=steps)
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def shift_period_by_n(cls, period: Period, n: int) -> Period:
|
|
263
|
+
"""
|
|
264
|
+
Shifts a given period by `n` instances of its period type.
|
|
265
|
+
|
|
266
|
+
Supports:
|
|
267
|
+
- **Months** (e.g., "Jun-2025" → "Jul-2025" for `n=1`).
|
|
268
|
+
- **Quarters** (e.g., "2025-Q2" → "2025-Q3" for `n=1`).
|
|
269
|
+
- **Years** (e.g., "2025" → "2026" for `n=1`).
|
|
270
|
+
- **Weeks** (e.g., "2025-W10" → "2025-W11" for `n=1`).
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
period (Period): The period instance to shift.
|
|
274
|
+
n (int): The number of periods to shift (can be negative for backward shifts).
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Period: The new shifted period instance.
|
|
278
|
+
"""
|
|
279
|
+
# Shift the first date by `n` periods using the existing method
|
|
280
|
+
new_start_date = cls.shift_date_by_n_periods(
|
|
281
|
+
period.first_date, period.period_type, n
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Retrieve the new period instance based on the shifted date
|
|
285
|
+
return cls.get_period(date_obj=new_start_date, period_type=period.period_type)
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def get_periods_in_range(
|
|
289
|
+
cls,
|
|
290
|
+
start_date: date,
|
|
291
|
+
end_date: date,
|
|
292
|
+
period_type: PeriodType,
|
|
293
|
+
handle_partial_end_period: HandlePartialEndPeriod = HandlePartialEndPeriod.EXCLUDE,
|
|
294
|
+
) -> list[Period]:
|
|
295
|
+
"""Generate a list of Period objects within a specified date range."""
|
|
296
|
+
if start_date > end_date:
|
|
297
|
+
message = "start_date must be earlier than end_date"
|
|
298
|
+
raise ValueError(message)
|
|
299
|
+
|
|
300
|
+
periods: list[Period] = []
|
|
301
|
+
current_date = start_date
|
|
302
|
+
|
|
303
|
+
while current_date <= end_date:
|
|
304
|
+
period = cls.get_period(date_obj=current_date, period_type=period_type)
|
|
305
|
+
if (
|
|
306
|
+
period.last_date > end_date
|
|
307
|
+
and handle_partial_end_period == HandlePartialEndPeriod.EXCLUDE
|
|
308
|
+
):
|
|
309
|
+
break
|
|
310
|
+
periods.append(period)
|
|
311
|
+
current_date = cls.shift_date_by_n_periods(
|
|
312
|
+
period.first_date, period_type, 1
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return periods
|