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,74 @@
|
|
|
1
|
+
"""Calculate the first day we are interested in a given FuturesContract."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import date
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
|
|
7
|
+
from mxm.refdata.models import Period, PeriodType
|
|
8
|
+
from mxm.refdata.services.period_factory import PeriodFactory
|
|
9
|
+
from mxm.refdata.trading_calendars.last_trading_day import calculate_last_trading_day
|
|
10
|
+
from mxm.refdata.trading_calendars.trading_calendar import TradingCalendar
|
|
11
|
+
|
|
12
|
+
# Load first_day_of_interest rules from JSON file
|
|
13
|
+
FIRST_DAY_OF_INTEREST_RULES = json.loads(
|
|
14
|
+
files("mxm.refdata")
|
|
15
|
+
.joinpath("data/first_day_of_interest_rule.json")
|
|
16
|
+
.read_text(encoding="utf-8")
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def calculate_first_day_of_interest(
|
|
21
|
+
product_id: str, period: Period, trading_calendar: TradingCalendar
|
|
22
|
+
) -> date:
|
|
23
|
+
"""
|
|
24
|
+
Calculate the first_day_of_interest for a given futures contract.
|
|
25
|
+
|
|
26
|
+
Parameters:
|
|
27
|
+
- product_id (str): The product identifier for the futures contract.
|
|
28
|
+
- period (Period): The period representing the contract's expiration/delivery period.
|
|
29
|
+
- trading_calendar (TradingCalendar): The trading calendar to use for business day calculations.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
- date: The first business day we are interested in a given FuturesContract.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
- ValueError: If the product_id is not found in the JSON rules.
|
|
36
|
+
- ValueError: If period_type is not MONTH.
|
|
37
|
+
"""
|
|
38
|
+
if product_id not in FIRST_DAY_OF_INTEREST_RULES:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"No first_day_of_interest rule found for product_id: {product_id}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if period.period_type != PeriodType.MONTH:
|
|
44
|
+
raise ValueError(f"Unsupported period_type: {period.period_type}")
|
|
45
|
+
|
|
46
|
+
product_rules = FIRST_DAY_OF_INTEREST_RULES[product_id]
|
|
47
|
+
month_str = period.first_date.strftime("%b")
|
|
48
|
+
try:
|
|
49
|
+
shift_n = product_rules["shift_rule"]["n_shift"][month_str]
|
|
50
|
+
except KeyError as e:
|
|
51
|
+
raise KeyError(
|
|
52
|
+
f"No shift value found for month '{month_str}' in product_id: {product_id}"
|
|
53
|
+
) from e
|
|
54
|
+
|
|
55
|
+
shifted_period = PeriodFactory().shift_period_by_n(period, n=-shift_n)
|
|
56
|
+
|
|
57
|
+
# Determine reference date based on reference rule
|
|
58
|
+
reference_rule = product_rules["reference_rule"]
|
|
59
|
+
|
|
60
|
+
if reference_rule == "next_b_day_after_last_trading_day_of_december":
|
|
61
|
+
december_period = PeriodFactory.get_period(
|
|
62
|
+
date_obj=date(shifted_period.last_date.year, 12, 1),
|
|
63
|
+
period_type=PeriodType.MONTH,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
reference_date = calculate_last_trading_day(
|
|
67
|
+
product_id, december_period, trading_calendar
|
|
68
|
+
)
|
|
69
|
+
elif reference_rule == "next_b_day_after_period":
|
|
70
|
+
reference_date = shifted_period.last_date
|
|
71
|
+
else:
|
|
72
|
+
raise ValueError(f"Unsupported reference_rule: {reference_rule}")
|
|
73
|
+
|
|
74
|
+
return trading_calendar.get_nth_business_day_relative_to_date(reference_date, n=1)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
from typing import NotRequired, TypedDict, cast
|
|
5
|
+
|
|
6
|
+
from mxm.refdata.models.periods import Period
|
|
7
|
+
from mxm.refdata.models.reference_events import ReferenceEvent
|
|
8
|
+
from mxm.refdata.models.weekdays import Weekday
|
|
9
|
+
from mxm.refdata.services.period_factory import PeriodFactory
|
|
10
|
+
from mxm.refdata.trading_calendars.nth_business_day import (
|
|
11
|
+
get_nth_business_day_of_period,
|
|
12
|
+
)
|
|
13
|
+
from mxm.refdata.trading_calendars.nth_calendar_day_of_period import (
|
|
14
|
+
get_nth_calendar_day_of_period,
|
|
15
|
+
)
|
|
16
|
+
from mxm.refdata.trading_calendars.nth_weekday_of_period import (
|
|
17
|
+
get_nth_weekday_of_period,
|
|
18
|
+
)
|
|
19
|
+
from mxm.refdata.trading_calendars.trading_calendar import TradingCalendar
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LastTradingRule(TypedDict):
|
|
23
|
+
period_offset: int
|
|
24
|
+
reference_event: str
|
|
25
|
+
n_reference: int
|
|
26
|
+
business_day_offset: int
|
|
27
|
+
weekday: NotRequired[str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
type LastTradingRules = dict[str, LastTradingRule]
|
|
31
|
+
|
|
32
|
+
TRADING_RULES: LastTradingRules = cast(
|
|
33
|
+
LastTradingRules,
|
|
34
|
+
json.loads(
|
|
35
|
+
files("mxm.refdata")
|
|
36
|
+
.joinpath("data/last_trading_rule.json")
|
|
37
|
+
.read_text(encoding="utf-8")
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_reference_date(
|
|
43
|
+
*,
|
|
44
|
+
product_id: str,
|
|
45
|
+
adjusted_period: Period,
|
|
46
|
+
rule: LastTradingRule,
|
|
47
|
+
trading_calendar: TradingCalendar,
|
|
48
|
+
) -> datetime.date:
|
|
49
|
+
"""Resolve the reference date specified by a product last-trading-day rule."""
|
|
50
|
+
reference_event = ReferenceEvent(rule["reference_event"])
|
|
51
|
+
n_reference = int(rule["n_reference"])
|
|
52
|
+
|
|
53
|
+
match reference_event:
|
|
54
|
+
case ReferenceEvent.BUSINESS_DAY_OF_PERIOD:
|
|
55
|
+
return get_nth_business_day_of_period(
|
|
56
|
+
adjusted_period,
|
|
57
|
+
n_reference,
|
|
58
|
+
trading_calendar,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
case ReferenceEvent.CALENDAR_DAY_OF_PERIOD:
|
|
62
|
+
return get_nth_calendar_day_of_period(
|
|
63
|
+
adjusted_period,
|
|
64
|
+
n_reference,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
case ReferenceEvent.WEEKDAY_OF_PERIOD:
|
|
68
|
+
weekday_raw = rule.get("weekday")
|
|
69
|
+
if not isinstance(weekday_raw, str):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"Missing or invalid 'weekday' key in trading rule for {product_id}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return get_nth_weekday_of_period(
|
|
75
|
+
adjusted_period,
|
|
76
|
+
Weekday.from_str(weekday_raw).as_int,
|
|
77
|
+
n_reference,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
case _:
|
|
81
|
+
raise ValueError(f"Unsupported reference_event: {reference_event!r}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def calculate_last_trading_day(
|
|
85
|
+
product_id: str,
|
|
86
|
+
period: Period,
|
|
87
|
+
trading_calendar: TradingCalendar,
|
|
88
|
+
) -> datetime.date:
|
|
89
|
+
"""
|
|
90
|
+
Calculate the last trading day for a futures contract.
|
|
91
|
+
|
|
92
|
+
The calculation is based on the product-specific last-trading-day rule,
|
|
93
|
+
the contract period, and the relevant trading calendar.
|
|
94
|
+
"""
|
|
95
|
+
if product_id not in TRADING_RULES:
|
|
96
|
+
raise KeyError(f"No trading rule found for product: {product_id}")
|
|
97
|
+
|
|
98
|
+
rule = TRADING_RULES[product_id]
|
|
99
|
+
|
|
100
|
+
adjusted_period = PeriodFactory.shift_period_by_n(
|
|
101
|
+
period,
|
|
102
|
+
int(rule["period_offset"]),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
reference_date = get_reference_date(
|
|
106
|
+
product_id=product_id,
|
|
107
|
+
adjusted_period=adjusted_period,
|
|
108
|
+
rule=rule,
|
|
109
|
+
trading_calendar=trading_calendar,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
business_day_offset = int(rule.get("business_day_offset", 0))
|
|
113
|
+
|
|
114
|
+
return trading_calendar.get_nth_business_day_relative_to_date(
|
|
115
|
+
reference_date,
|
|
116
|
+
business_day_offset,
|
|
117
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Calculate n-th business day of a period."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
|
|
5
|
+
from mxm.refdata.models.periods import Period
|
|
6
|
+
from mxm.refdata.trading_calendars.trading_calendar import TradingCalendar
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_nth_business_day_of_period(
|
|
10
|
+
period: Period, n: int, trading_calendar: TradingCalendar
|
|
11
|
+
) -> datetime.date:
|
|
12
|
+
"""
|
|
13
|
+
Returns the N-th business day of the given period.
|
|
14
|
+
|
|
15
|
+
Supports:
|
|
16
|
+
- **Positive `n` values** (e.g., `n=1` → First business day).
|
|
17
|
+
- **Negative `n` values** (e.g., `n=-1` → Last business day).
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
period (Period): The period (e.g., contract month, quarter).
|
|
21
|
+
n (int): The N-th business day (1-based index for forward search, -1-based for reverse search).
|
|
22
|
+
trading_calendar (TradingCalendar): The trading calendar to determine business days.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
datetime.date: The date of the N-th business day.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ValueError: If the N-th business day does not exist in the period.
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
- **Contract Month (June 2025)**:
|
|
32
|
+
- `get_nth_business_day_of_period(Period(2025, 6), 1, trading_calendar)` → First business day of June 2025.
|
|
33
|
+
- `get_nth_business_day_of_period(Period(2025, 6), -1, trading_calendar)` → Last business day of June 2025.
|
|
34
|
+
|
|
35
|
+
- **Quarter (Q3 2025)**:
|
|
36
|
+
- `get_nth_business_day_of_period(Period(2025, 7, 9), 5, trading_calendar)` → Fifth business day of Q3 2025.
|
|
37
|
+
"""
|
|
38
|
+
all_dates = period.to_daterange()
|
|
39
|
+
|
|
40
|
+
# Get valid business days within the period
|
|
41
|
+
business_days = [
|
|
42
|
+
date
|
|
43
|
+
for date in trading_calendar.get_sessions_in_range(all_dates[0], all_dates[-1])
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Ensure we return a date object, not a timestamp
|
|
47
|
+
try:
|
|
48
|
+
return business_days[n - 1].date() if n > 0 else business_days[n].date()
|
|
49
|
+
except IndexError as err:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"The {n}-th business day does not exist in {period.period_id}."
|
|
52
|
+
) from err
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from mxm.refdata.models.periods import Period
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_nth_calendar_day_of_period(period: Period, n: int) -> datetime.date:
|
|
7
|
+
"""
|
|
8
|
+
Returns the N-th calendar day of the given period.
|
|
9
|
+
|
|
10
|
+
Supports:
|
|
11
|
+
- **Positive `n` values** (e.g., `n=1` → First day of the period).
|
|
12
|
+
- **Negative `n` values** (e.g., `n=-1` → Last day of the period).
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
period (Period): The period (e.g., contract month, quarter).
|
|
16
|
+
n (int): The N-th calendar day (1-based index for forward search, -1-based for reverse search).
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
datetime.date: The date of the N-th calendar day.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ValueError: If the N-th calendar day does not exist in the period.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
- **Contract Month (June 2025)**:
|
|
26
|
+
- `get_nth_calendar_day_of_period(Period(2025, 6), 1)` → June 1, 2025.
|
|
27
|
+
- `get_nth_calendar_day_of_period(Period(2025, 6), -1)` → June 30, 2025.
|
|
28
|
+
|
|
29
|
+
- **Quarter (Q3 2025)**:
|
|
30
|
+
- `get_nth_calendar_day_of_period(Period(2025, 7, 9), 5)` → July 5, 2025.
|
|
31
|
+
- `get_nth_calendar_day_of_period(Period(2025, 7, 9), -5)` → September 26, 2025.
|
|
32
|
+
"""
|
|
33
|
+
all_days = period.to_daterange()
|
|
34
|
+
|
|
35
|
+
# Ensure valid index and return the correct day
|
|
36
|
+
try:
|
|
37
|
+
return (
|
|
38
|
+
all_days[n - 1].date() if n > 0 else all_days[n].date()
|
|
39
|
+
) # Adjust for 1-based indexing
|
|
40
|
+
except IndexError as err:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"The {n}-th calendar day does not exist in {period.period_id}."
|
|
43
|
+
) from err
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Utility functions for working with calendars."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from mxm.refdata.models.periods import Period
|
|
8
|
+
from mxm.refdata.models.weekdays import Weekday
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_nth_weekday_of_period(
|
|
12
|
+
period: Period, weekday: int | str, n: int
|
|
13
|
+
) -> datetime.date:
|
|
14
|
+
"""
|
|
15
|
+
Returns the N-th occurrence of a given weekday in the specified period.
|
|
16
|
+
|
|
17
|
+
Supports:
|
|
18
|
+
- `weekday` as an **integer** (0=Monday, 6=Sunday).
|
|
19
|
+
- `weekday` as a **full name** ("Tuesday", "Friday").
|
|
20
|
+
- `weekday` as an **abbreviation** ("Tue", "Fri").
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
period (Period): The period (e.g., contract month, quarter).
|
|
24
|
+
weekday (int | str): The weekday (0=Monday, 6=Sunday or "Tuesday", "Tue", etc.).
|
|
25
|
+
n (int): The N-th occurrence (1-based index for forward search, -1-based for reverse search).
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
datetime.date: The date of the N-th occurrence of the given weekday.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: If the N-th occurrence does not exist in the period.
|
|
32
|
+
"""
|
|
33
|
+
# Convert weekday string to integer if needed
|
|
34
|
+
if isinstance(weekday, str):
|
|
35
|
+
weekday = Weekday.from_str(weekday).as_int
|
|
36
|
+
period_range = period.to_daterange()
|
|
37
|
+
# Get all dates in the period and filter for matching weekdays
|
|
38
|
+
dates: list[pd.Timestamp] = [
|
|
39
|
+
date for date in period_range if date.weekday() == weekday
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Handle both forward (n > 0) and reverse (n < 0) indexing
|
|
43
|
+
try:
|
|
44
|
+
return (
|
|
45
|
+
dates[n - 1].date() if n > 0 else dates[n].date()
|
|
46
|
+
) # Python negative index works naturally
|
|
47
|
+
except IndexError as err:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"The {n}-th occurrence of weekday {weekday} does not exist in {period.period_id}."
|
|
50
|
+
) from err
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from typing import cast
|
|
3
|
+
|
|
4
|
+
import exchange_calendars as xcals
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from exchange_calendars.errors import InvalidCalendarName
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TradingCalendar:
|
|
10
|
+
"""
|
|
11
|
+
TradingCalendar manages exchange trading sessions and provides session-based utilities.
|
|
12
|
+
Sessions are represented by UTC Timestamps, following `exchange_calendars` conventions.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
calendar_name: str,
|
|
18
|
+
start: datetime.date | None = None,
|
|
19
|
+
end: datetime.date | None = None,
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Initializes a TradingCalendar based on an exchange calendar.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
calendar_name (str): The name of the exchange calendar (e.g., "CMES", "NYSE").
|
|
26
|
+
extend_to (datetime.date, optional): Extend the calendar to this date if needed.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: If the calendar name is not recognized by `exchange_calendars`.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
if start and not end:
|
|
33
|
+
self.calendar = xcals.get_calendar(calendar_name, start=start)
|
|
34
|
+
elif end and not start:
|
|
35
|
+
self.calendar = xcals.get_calendar(calendar_name, end=end)
|
|
36
|
+
elif start and end:
|
|
37
|
+
self.calendar = xcals.get_calendar(calendar_name, start=start, end=end)
|
|
38
|
+
else:
|
|
39
|
+
self.calendar = xcals.get_calendar(calendar_name)
|
|
40
|
+
|
|
41
|
+
except InvalidCalendarName as e:
|
|
42
|
+
raise ValueError(f"Unknown trading calendar: {calendar_name}") from e
|
|
43
|
+
|
|
44
|
+
def get_sessions_in_range(
|
|
45
|
+
self, start: datetime.date, end: datetime.date
|
|
46
|
+
) -> pd.DatetimeIndex:
|
|
47
|
+
"""
|
|
48
|
+
Returns trading sessions in a given range as a DatetimeIndex in UTC.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
start (datetime.date): The start of the range.
|
|
52
|
+
end (datetime.date): The end of the range.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
pd.DatetimeIndex: A list of session timestamps in UTC.
|
|
56
|
+
"""
|
|
57
|
+
return self.calendar.sessions_in_range(start, end)
|
|
58
|
+
|
|
59
|
+
def shift_sessions(self, session: pd.Timestamp, offset: int) -> pd.Timestamp:
|
|
60
|
+
"""
|
|
61
|
+
Moves a given session forward or backward by a specified number of sessions.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
session (pd.Timestamp): The session timestamp in UTC.
|
|
65
|
+
offset (int): Number of sessions to shift (+ for forward, - for backward).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
pd.Timestamp: The shifted session timestamp in UTC.
|
|
69
|
+
"""
|
|
70
|
+
return self.calendar.session_offset(session, offset)
|
|
71
|
+
|
|
72
|
+
def get_session_dates_in_range(
|
|
73
|
+
self, start: datetime.date, end: datetime.date
|
|
74
|
+
) -> list[datetime.date]:
|
|
75
|
+
"""
|
|
76
|
+
Returns trading session dates in a given range.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
start (datetime.date): The start date.
|
|
80
|
+
end (datetime.date): The end date.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
List[datetime.date]: A list of session dates.
|
|
84
|
+
"""
|
|
85
|
+
return [ts.date() for ts in self.get_sessions_in_range(start, end)]
|
|
86
|
+
|
|
87
|
+
def shift_session_date(self, date: datetime.date, offset: int) -> datetime.date:
|
|
88
|
+
"""
|
|
89
|
+
Moves a given date forward or backward by a specified number of trading sessions.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
date (datetime.date): The reference date.
|
|
93
|
+
offset (int): Number of sessions to shift (+ for forward, - for backward).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
datetime.date: The shifted session's date.
|
|
97
|
+
"""
|
|
98
|
+
return self.shift_sessions(pd.Timestamp(date), offset).date()
|
|
99
|
+
|
|
100
|
+
def get_session_open(self, session: pd.Timestamp) -> pd.Timestamp:
|
|
101
|
+
"""
|
|
102
|
+
Returns the opening time of a given session.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
session (pd.Timestamp): The session timestamp in UTC.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
pd.Timestamp: The market open time for the session (UTC).
|
|
109
|
+
"""
|
|
110
|
+
return cast(pd.Timestamp, self.calendar.schedule.loc[session, "open"])
|
|
111
|
+
|
|
112
|
+
def get_session_close(self, session: pd.Timestamp) -> pd.Timestamp:
|
|
113
|
+
"""
|
|
114
|
+
Returns the closing time of a given session.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
session (pd.Timestamp): The session timestamp in UTC.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
pd.Timestamp: The market close time for the session (UTC).
|
|
121
|
+
"""
|
|
122
|
+
return cast(pd.Timestamp, self.calendar.schedule.loc[session, "close"])
|
|
123
|
+
|
|
124
|
+
def is_trading_day(self, date: datetime.date) -> bool:
|
|
125
|
+
"""
|
|
126
|
+
Checks if a given date is a valid trading day.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
date (datetime.date): The date to check.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
bool: True if the date is a trading day, False otherwise.
|
|
133
|
+
"""
|
|
134
|
+
return self.calendar.is_session(date)
|
|
135
|
+
|
|
136
|
+
def get_last_prior_session_date(self, date: datetime.date) -> datetime.date:
|
|
137
|
+
"""
|
|
138
|
+
Finds the last valid session date prior to or on the given date.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
date (datetime.date): The date to check.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
datetime.date: The last valid trading session date.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: If the date is before the first available session in the trading calendar.
|
|
148
|
+
"""
|
|
149
|
+
# If it's already a trading day, return it as is
|
|
150
|
+
if self.is_trading_day(date):
|
|
151
|
+
return date
|
|
152
|
+
|
|
153
|
+
# Start from the given date and move backward until we find a valid session
|
|
154
|
+
while not self.is_trading_day(date):
|
|
155
|
+
date -= datetime.timedelta(days=1)
|
|
156
|
+
if date < self.calendar.first_session.date():
|
|
157
|
+
raise ValueError(f"No prior session found for date {date}")
|
|
158
|
+
|
|
159
|
+
return date
|
|
160
|
+
|
|
161
|
+
def get_nth_business_day_relative_to_date(
|
|
162
|
+
self, date: datetime.date, n: int
|
|
163
|
+
) -> datetime.date:
|
|
164
|
+
"""
|
|
165
|
+
Finds the N-th business day relative to a given date, even if the date is not a business day.
|
|
166
|
+
|
|
167
|
+
If the date is not a business day:
|
|
168
|
+
- When `n < 0` (backward shift), we already moved back once, so shift by `n + 1`.
|
|
169
|
+
- When `n > 0` (forward shift), shift by `n` as usual.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
date (datetime.date): The starting date (can be a business or non-business day).
|
|
173
|
+
n (int): The number of business days to move (+ for forward, - for backward).
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
datetime.date: The computed business day.
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
# If the date is not a business day, find the last valid business day before it
|
|
180
|
+
if not self.is_trading_day(date):
|
|
181
|
+
date = self.get_last_prior_session_date(date)
|
|
182
|
+
if n < 0:
|
|
183
|
+
n += 1 # If moving backward, adjust by 1 because we already moved back
|
|
184
|
+
|
|
185
|
+
# Now shift by `n` business days from a valid business day
|
|
186
|
+
return self.shift_session_date(date, n)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility functions for the refData package."""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""A cache manager for the reference data service."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
from cachetools import LRUCache
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CacheManager[V]:
|
|
9
|
+
"""Thread-safe cache manager using an LRU cache."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, maxsize: int = 1000) -> None:
|
|
12
|
+
self._cache: LRUCache[str, V] = LRUCache(maxsize=maxsize)
|
|
13
|
+
self._lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
def get(self, key: str) -> V | None:
|
|
16
|
+
"""Thread-safe retrieval from the cache."""
|
|
17
|
+
with self._lock:
|
|
18
|
+
return self._cache.get(key)
|
|
19
|
+
|
|
20
|
+
def set(self, key: str, value: V) -> None:
|
|
21
|
+
"""Thread-safe insertion into the cache."""
|
|
22
|
+
with self._lock:
|
|
23
|
+
self._cache[key] = value
|
|
24
|
+
|
|
25
|
+
def invalidate(self, key: str) -> None:
|
|
26
|
+
"""Thread-safe removal from the cache."""
|
|
27
|
+
with self._lock:
|
|
28
|
+
self._cache.pop(key, None)
|
|
29
|
+
|
|
30
|
+
def clear(self) -> None:
|
|
31
|
+
"""Thread-safe clearing of the cache."""
|
|
32
|
+
with self._lock:
|
|
33
|
+
self._cache.clear()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Configuration details for the refData application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _default_sqlite_db_url() -> str:
|
|
12
|
+
# Stable, user-writable default (avoid process-relative paths)
|
|
13
|
+
db_path = Path.home() / ".mxm" / "refdata" / "refdata.db"
|
|
14
|
+
return f"sqlite:///{db_path}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Config(BaseSettings):
|
|
18
|
+
# Overrideable via env; default is safe when imported as a dependency
|
|
19
|
+
SQL_DB_URL: str = _default_sqlite_db_url()
|
|
20
|
+
|
|
21
|
+
REFDATA_DB_MODE: str = "buildable" # "buildable" | "managed"
|
|
22
|
+
|
|
23
|
+
# None means "use packaged CSV resource"
|
|
24
|
+
REFDATA_FUTURES_PRODUCTS_CSV_PATH: str | None = None
|
|
25
|
+
|
|
26
|
+
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
|
27
|
+
env_file=".env", env_file_encoding="utf-8"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_config() -> Config:
|
|
32
|
+
return Config()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mxm.refdata.models.periods import PeriodType
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def encode_period_types(values: tuple[PeriodType, ...]) -> str:
|
|
7
|
+
if not values:
|
|
8
|
+
raise ValueError("period_types must be non-empty")
|
|
9
|
+
return ",".join(v.name for v in values)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def decode_period_types(value: str) -> tuple[PeriodType, ...]:
|
|
13
|
+
parts = [p.strip() for p in value.replace("|", ",").split(",") if p.strip()]
|
|
14
|
+
if not parts:
|
|
15
|
+
raise ValueError(f"invalid period_types string: {value!r}")
|
|
16
|
+
return tuple(PeriodType[p] for p in parts)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Pre-computed regex patterns for use in the refData package."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
|
|
6
|
+
from mxm.refdata.models.periods import PeriodType
|
|
7
|
+
|
|
8
|
+
PERIOD_TYPE_PARSING_MAP = MappingProxyType(
|
|
9
|
+
{
|
|
10
|
+
PeriodType.YEAR: re.compile(r"^\d{4}$"),
|
|
11
|
+
PeriodType.MONTH: re.compile(
|
|
12
|
+
r"^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{4}$"
|
|
13
|
+
),
|
|
14
|
+
PeriodType.QUARTER: re.compile(r"^\d{4}-Q[1-4]$"),
|
|
15
|
+
PeriodType.WEEK: re.compile(r"^\d{4}-W\d{1,2}$"),
|
|
16
|
+
PeriodType.DAY: re.compile(r"^\d{4}\d{2}\d{2}$"),
|
|
17
|
+
}
|
|
18
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from importlib.resources import as_file, files
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from mxm.refdata.utils.config import Config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@contextmanager
|
|
12
|
+
def futures_products_csv_path(cfg: Config) -> Iterator[Path]:
|
|
13
|
+
"""
|
|
14
|
+
Yield a filesystem Path to the futures products CSV.
|
|
15
|
+
|
|
16
|
+
If cfg.REFDATA_FUTURES_PRODUCTS_CSV_PATH is set, yield that path.
|
|
17
|
+
Otherwise, yield a materialised path to the packaged CSV resource.
|
|
18
|
+
|
|
19
|
+
The path may be backed by a temporary file; callers must consume it
|
|
20
|
+
within the context manager.
|
|
21
|
+
"""
|
|
22
|
+
override = cfg.REFDATA_FUTURES_PRODUCTS_CSV_PATH
|
|
23
|
+
if override:
|
|
24
|
+
yield Path(override)
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
resource = files("mxm.refdata").joinpath("data/futures_products.csv")
|
|
28
|
+
with as_file(resource) as p:
|
|
29
|
+
yield Path(p)
|