nlalpha-data 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.
- nlalpha_data/__init__.py +30 -0
- nlalpha_data/bars_aligner.py +82 -0
- nlalpha_data/base.py +90 -0
- nlalpha_data/calendar/__init__.py +15 -0
- nlalpha_data/calendar/cn_equity.py +27 -0
- nlalpha_data/calendar/futures.py +54 -0
- nlalpha_data/calendar/us_equity.py +24 -0
- nlalpha_data/calendar_base.py +201 -0
- nlalpha_data/calendar_tools.py +133 -0
- nlalpha_data/discovery.py +129 -0
- nlalpha_data/knowledge/loading.md +17 -0
- nlalpha_data/knowledge/local-provider.md +17 -0
- nlalpha_data/loader.py +210 -0
- nlalpha_data/local_provider.py +407 -0
- nlalpha_data/mock_provider.py +123 -0
- nlalpha_data/parquet_store.py +134 -0
- nlalpha_data/server.py +392 -0
- nlalpha_data/sqlite_store.py +174 -0
- nlalpha_data/store.py +316 -0
- nlalpha_data-0.3.0.dist-info/METADATA +149 -0
- nlalpha_data-0.3.0.dist-info/RECORD +24 -0
- nlalpha_data-0.3.0.dist-info/WHEEL +4 -0
- nlalpha_data-0.3.0.dist-info/entry_points.txt +2 -0
- nlalpha_data-0.3.0.dist-info/licenses/LICENSE +661 -0
nlalpha_data/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Financial data providers, explicit loading, storage, and trading calendars."""
|
|
2
|
+
|
|
3
|
+
from nlalpha_data.base import DataProvider
|
|
4
|
+
from nlalpha_data.store import BaseBarStore, BarOverview
|
|
5
|
+
from nlalpha_data.calendar_base import TradingCalendar, WeekendOnlyCalendar
|
|
6
|
+
from nlalpha_data.calendar import CNEquityCalendar, USEquityCalendar, FuturesCalendar
|
|
7
|
+
from nlalpha_data.loader import DataLoader, DataProviderRecord, LoadedBars
|
|
8
|
+
from nlalpha_data.local_provider import LocalFileDataProvider
|
|
9
|
+
from nlalpha_data.parquet_store import ParquetBarStore
|
|
10
|
+
from nlalpha_data.sqlite_store import SQLiteBarStore
|
|
11
|
+
from nlalpha_data.discovery import discover_data_providers, get_default_data_provider
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DataProvider",
|
|
15
|
+
"BaseBarStore",
|
|
16
|
+
"BarOverview",
|
|
17
|
+
"TradingCalendar",
|
|
18
|
+
"WeekendOnlyCalendar",
|
|
19
|
+
"CNEquityCalendar",
|
|
20
|
+
"USEquityCalendar",
|
|
21
|
+
"FuturesCalendar",
|
|
22
|
+
"DataLoader",
|
|
23
|
+
"DataProviderRecord",
|
|
24
|
+
"LoadedBars",
|
|
25
|
+
"LocalFileDataProvider",
|
|
26
|
+
"ParquetBarStore",
|
|
27
|
+
"SQLiteBarStore",
|
|
28
|
+
"discover_data_providers",
|
|
29
|
+
"get_default_data_provider",
|
|
30
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""多品种 Bar 数据时间对齐工具。
|
|
2
|
+
|
|
3
|
+
参考 vnpy_portfoliostrategy 的数据切片机制:
|
|
4
|
+
- 所有品种按统一时间轴对齐
|
|
5
|
+
- 缺失品种用前一根 Bar 前向填充(ffill)
|
|
6
|
+
- 输出:[(datetime, {symbol: Bar})] 按时间升序排列
|
|
7
|
+
|
|
8
|
+
设计约束:
|
|
9
|
+
- 不修改原始 Bar 数据
|
|
10
|
+
- 前向填充只在当前品种缺失当前时间点的数据时使用
|
|
11
|
+
- 如果某个品种在整个回测期间都没有数据,它不会出现在任何切片中
|
|
12
|
+
|
|
13
|
+
用法:
|
|
14
|
+
from nlalpha_data.bars_aligner import align_bars
|
|
15
|
+
|
|
16
|
+
data = {
|
|
17
|
+
"000300.SH": [Bar(datetime=dt1, ...), Bar(datetime=dt2, ...)],
|
|
18
|
+
"000905.SH": [Bar(datetime=dt2, ...), Bar(datetime=dt3, ...)],
|
|
19
|
+
}
|
|
20
|
+
slices = align_bars(data)
|
|
21
|
+
# slices = [(dt1, {"000300.SH": bar1}), (dt2, {"000300.SH": bar2, "000905.SH": bar3}), ...]
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
|
|
26
|
+
from nlalpha.core.contracts.market import Bar
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def align_bars(bars_dict: dict[str, list[Bar]]) -> list[tuple[datetime, dict[str, Bar]]]:
|
|
30
|
+
"""将多品种的 Bar 列表按时间对齐。
|
|
31
|
+
|
|
32
|
+
算法:
|
|
33
|
+
1. 收集所有品种的所有时间点,去重排序
|
|
34
|
+
2. 为每个品种建立 datetime -> Bar 的映射
|
|
35
|
+
3. 逐时间点切片:对每个时间点,取各品种在该时间或之前的最近一根 Bar(前向填充)
|
|
36
|
+
4. 返回按时间升序排列的切片列表
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
bars_dict: {symbol: [Bar, ...]},每个品种的 Bar 列表已按时间升序排列
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
[(datetime, {symbol: Bar})] 按时间升序排列
|
|
43
|
+
"""
|
|
44
|
+
if not bars_dict:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
# 1. 收集所有时间点
|
|
48
|
+
all_times: set[datetime] = set()
|
|
49
|
+
for symbol, bars in bars_dict.items():
|
|
50
|
+
for bar in bars:
|
|
51
|
+
all_times.add(bar.datetime)
|
|
52
|
+
|
|
53
|
+
if not all_times:
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
# 2. 排序时间点
|
|
57
|
+
sorted_times = sorted(all_times)
|
|
58
|
+
|
|
59
|
+
# 3. 为每个品种建立 datetime -> Bar 映射(快速查找)
|
|
60
|
+
# 同时保留排序后的 Bar 列表用于前向填充
|
|
61
|
+
bar_by_time: dict[str, dict[datetime, Bar]] = {}
|
|
62
|
+
for symbol, bars in bars_dict.items():
|
|
63
|
+
bar_by_time[symbol] = {bar.datetime: bar for bar in bars}
|
|
64
|
+
|
|
65
|
+
# 4. 逐时间切片,前向填充
|
|
66
|
+
result: list[tuple[datetime, dict[str, Bar]]] = []
|
|
67
|
+
last_bar: dict[str, Bar] = {} # 前向填充缓存
|
|
68
|
+
|
|
69
|
+
for dt in sorted_times:
|
|
70
|
+
slice_bars: dict[str, Bar] = {}
|
|
71
|
+
for symbol in bars_dict.keys():
|
|
72
|
+
# 如果当前时间点有该品种的 Bar,更新缓存
|
|
73
|
+
if dt in bar_by_time.get(symbol, {}):
|
|
74
|
+
last_bar[symbol] = bar_by_time[symbol][dt]
|
|
75
|
+
# 如果缓存中有该品种的数据,放入当前切片
|
|
76
|
+
if symbol in last_bar:
|
|
77
|
+
slice_bars[symbol] = last_bar[symbol]
|
|
78
|
+
|
|
79
|
+
if slice_bars:
|
|
80
|
+
result.append((dt, slice_bars))
|
|
81
|
+
|
|
82
|
+
return result
|
nlalpha_data/base.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""DataProvider 抽象接口。
|
|
2
|
+
|
|
3
|
+
所有数据源(Tushare、CSV、Parquet、akshare、rqdata 等)
|
|
4
|
+
都必须实现此接口,这样 family 子系统和回测运行时
|
|
5
|
+
不需要关心数据来自哪里。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
from nlalpha.core.contracts.market import Bar
|
|
14
|
+
from nlalpha.core.contracts.market_source import validate_request_description
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DataProvider(Protocol):
|
|
18
|
+
"""历史数据提供者接口。"""
|
|
19
|
+
|
|
20
|
+
async def load_bars(self, symbol: str, interval: str, start: str, end: str, *,
|
|
21
|
+
price_adjustment: str | None = None) -> list[Bar]:
|
|
22
|
+
"""Load bars for one symbol, interval, and inclusive date range.
|
|
23
|
+
|
|
24
|
+
price_adjustment selects "unadjusted", "forward", or "backward"; None leaves the source default.
|
|
25
|
+
Raises DataError when the source fails or the range has no data.
|
|
26
|
+
"""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
async def describe_request(self, symbol: str, interval: str,
|
|
30
|
+
price_adjustment: str | None = None) -> dict:
|
|
31
|
+
"""Declare the price adjustment the selected source would return for this request.
|
|
32
|
+
|
|
33
|
+
The declaration includes evidence and must match an explicit request before loading.
|
|
34
|
+
"""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def cache_identity(self) -> dict:
|
|
38
|
+
"""Declare the stable configuration that determines cached data."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
def is_available(self) -> bool:
|
|
42
|
+
"""检查当前数据源是否可用(例如 token 是否有效、网络是否通畅)。"""
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_INTERVAL = re.compile(
|
|
47
|
+
r"\s*(\d+(?:\.\d+)?)\s*"
|
|
48
|
+
r"(ns|us|µs|ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes|"
|
|
49
|
+
r"h|hr|hrs|hour|hours|d|day|days|w|week|weeks)\s*"
|
|
50
|
+
)
|
|
51
|
+
_UNIT_MICROSECONDS = {
|
|
52
|
+
"ns": Decimal("0.001"),
|
|
53
|
+
"us": Decimal(1),
|
|
54
|
+
"µs": Decimal(1),
|
|
55
|
+
"ms": Decimal(1_000),
|
|
56
|
+
"s": Decimal(1_000_000),
|
|
57
|
+
"sec": Decimal(1_000_000),
|
|
58
|
+
"secs": Decimal(1_000_000),
|
|
59
|
+
"second": Decimal(1_000_000),
|
|
60
|
+
"seconds": Decimal(1_000_000),
|
|
61
|
+
"m": Decimal(60_000_000),
|
|
62
|
+
"min": Decimal(60_000_000),
|
|
63
|
+
"mins": Decimal(60_000_000),
|
|
64
|
+
"minute": Decimal(60_000_000),
|
|
65
|
+
"minutes": Decimal(60_000_000),
|
|
66
|
+
"h": Decimal(3_600_000_000),
|
|
67
|
+
"hr": Decimal(3_600_000_000),
|
|
68
|
+
"hrs": Decimal(3_600_000_000),
|
|
69
|
+
"hour": Decimal(3_600_000_000),
|
|
70
|
+
"hours": Decimal(3_600_000_000),
|
|
71
|
+
"d": Decimal(86_400_000_000),
|
|
72
|
+
"day": Decimal(86_400_000_000),
|
|
73
|
+
"days": Decimal(86_400_000_000),
|
|
74
|
+
"w": Decimal(604_800_000_000),
|
|
75
|
+
"week": Decimal(604_800_000_000),
|
|
76
|
+
"weeks": Decimal(604_800_000_000),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _interval_delta(value: object) -> timedelta:
|
|
81
|
+
"""Resolve an explicit fixed interval into exact whole microseconds."""
|
|
82
|
+
if not isinstance(value, str):
|
|
83
|
+
raise ValueError("data.interval must be a positive fixed interval")
|
|
84
|
+
match = _INTERVAL.fullmatch(value)
|
|
85
|
+
if match is None:
|
|
86
|
+
raise ValueError("data.interval must be a positive fixed interval")
|
|
87
|
+
microseconds = Decimal(match.group(1)) * _UNIT_MICROSECONDS[match.group(2)]
|
|
88
|
+
if microseconds <= 0 or microseconds != microseconds.to_integral_value():
|
|
89
|
+
raise ValueError("data.interval must resolve to positive whole microseconds")
|
|
90
|
+
return timedelta(microseconds=int(microseconds))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""交易日历子包。
|
|
2
|
+
|
|
3
|
+
提供各市场的交易日历实现:
|
|
4
|
+
- USEquityCalendar:美股(NYSE / NASDAQ)
|
|
5
|
+
- CNEquityCalendar:中国 A 股(沪深)
|
|
6
|
+
- FuturesCalendar:国内期货(四大交易所)
|
|
7
|
+
|
|
8
|
+
作者:kimi(2026-05-07)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .cn_equity import CNEquityCalendar
|
|
12
|
+
from .futures import FuturesCalendar
|
|
13
|
+
from .us_equity import USEquityCalendar
|
|
14
|
+
|
|
15
|
+
__all__ = ["CNEquityCalendar", "FuturesCalendar", "USEquityCalendar"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Resolve Chinese equity sessions with an explicitly selected exchange calendar."""
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from nlalpha.core.errors import DataError
|
|
5
|
+
from nlalpha_data.calendar_base import TradingCalendar, exchange_calendar, session_bounds
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CNEquityCalendar(TradingCalendar):
|
|
9
|
+
"""Provide exact XSHG or XBSE sessions for domestic equities."""
|
|
10
|
+
|
|
11
|
+
market = "CN_EQUITY"
|
|
12
|
+
timezone = "Asia/Shanghai"
|
|
13
|
+
|
|
14
|
+
def __init__(self, calendar_id: str = "XSHG"):
|
|
15
|
+
if not isinstance(calendar_id, str) or calendar_id not in {"XSHG", "XBSE"}:
|
|
16
|
+
raise DataError("CNEquityCalendar supports only XSHG and XBSE.", details={"time_contract": True})
|
|
17
|
+
self.calendar_id = calendar_id
|
|
18
|
+
self._ec = exchange_calendar(calendar_id)
|
|
19
|
+
|
|
20
|
+
def is_trading_day(self, d: date) -> bool:
|
|
21
|
+
"""Check the declared exchange's actual session label."""
|
|
22
|
+
from pandas import Timestamp
|
|
23
|
+
return self._ec.is_session(Timestamp(d))
|
|
24
|
+
|
|
25
|
+
def session_bounds(self, d: date) -> tuple[datetime, datetime]:
|
|
26
|
+
"""Return the declared exchange's UTC open and close."""
|
|
27
|
+
return session_bounds(self.calendar_id, d)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""期货交易日历。
|
|
2
|
+
|
|
3
|
+
国内期货市场交易日历:
|
|
4
|
+
- 上期所 (SHFE)
|
|
5
|
+
- 大商所 (DCE)
|
|
6
|
+
- 郑商所 (CZCE)
|
|
7
|
+
- 中金所 (CFFEX)
|
|
8
|
+
|
|
9
|
+
特点:
|
|
10
|
+
- 周六、周日休市
|
|
11
|
+
- 法定节假日休市(与 A 股基本一致)
|
|
12
|
+
- 无盘前盘后(与美股不同)
|
|
13
|
+
|
|
14
|
+
依赖:
|
|
15
|
+
- 优先使用 exchange-calendars(如果已安装)
|
|
16
|
+
- 未安装时退化为 WeekendOnlyCalendar(仅排除周末)
|
|
17
|
+
|
|
18
|
+
作者:kimi(2026-05-06)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from datetime import date
|
|
22
|
+
|
|
23
|
+
from nlalpha_data.calendar_base import WeekendOnlyCalendar
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FuturesCalendar(WeekendOnlyCalendar):
|
|
27
|
+
"""国内期货交易日历。
|
|
28
|
+
|
|
29
|
+
覆盖四大期货交易所的公共交易日历。
|
|
30
|
+
各交易所个别差异(如品种特定交割日)不在此处理,由 Broker 层负责。
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
market = "CN_FUTURES"
|
|
34
|
+
timezone = "Asia/Shanghai"
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self._ec = None
|
|
38
|
+
try:
|
|
39
|
+
import exchange_calendars as ec
|
|
40
|
+
# 中金所日历最接近国内期货整体日历
|
|
41
|
+
self._ec = ec.get_calendar("XSHG") # 上交所(A股日历 ≈ 期货日历)
|
|
42
|
+
except ImportError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
# 不再自建节假日表,完全依赖 exchange-calendars
|
|
46
|
+
# 若 exchange-calendars 未安装,fallback 到 WeekendOnlyCalendar(仅排除周末)
|
|
47
|
+
super().__init__()
|
|
48
|
+
|
|
49
|
+
def is_trading_day(self, d: date) -> bool:
|
|
50
|
+
if self._ec is not None:
|
|
51
|
+
from pandas import Timestamp
|
|
52
|
+
ts = Timestamp(d)
|
|
53
|
+
return self._ec.is_session(ts)
|
|
54
|
+
return super().is_trading_day(d)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Resolve United States equity sessions including DST and shortened days."""
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from nlalpha_data.calendar_base import TradingCalendar, exchange_calendar, session_bounds
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class USEquityCalendar(TradingCalendar):
|
|
8
|
+
"""Provide exact XNYS session labels and endpoints."""
|
|
9
|
+
|
|
10
|
+
market = "US_EQUITY"
|
|
11
|
+
timezone = "America/New_York"
|
|
12
|
+
calendar_id = "XNYS"
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self._ec = exchange_calendar(self.calendar_id)
|
|
16
|
+
|
|
17
|
+
def is_trading_day(self, d: date) -> bool:
|
|
18
|
+
"""Check whether NYSE has a session with this local date label."""
|
|
19
|
+
from pandas import Timestamp
|
|
20
|
+
return self._ec.is_session(Timestamp(d))
|
|
21
|
+
|
|
22
|
+
def session_bounds(self, d: date) -> tuple[datetime, datetime]:
|
|
23
|
+
"""Return NYSE's actual UTC open and close."""
|
|
24
|
+
return session_bounds(self.calendar_id, d)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""交易日历抽象。
|
|
2
|
+
|
|
3
|
+
T3.1(2026-05-06)新增:支撑多市场回测的交易日历基础设施。
|
|
4
|
+
|
|
5
|
+
职责:
|
|
6
|
+
- 判断某日是否为交易日
|
|
7
|
+
- 计算下一个/前 N 个交易日
|
|
8
|
+
- 计算结算日(T+0 / T+1 / T+2)
|
|
9
|
+
|
|
10
|
+
设计原则:
|
|
11
|
+
- 最小接口,不依赖外部库(exchange-calendars 为可选增强)
|
|
12
|
+
- 默认实现仅判断周末,各市场子类可覆盖节假日
|
|
13
|
+
- 所有日期操作使用 datetime.date,不含时区
|
|
14
|
+
|
|
15
|
+
作者:kimi(2026-05-06)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from datetime import date, datetime, timedelta
|
|
20
|
+
from functools import lru_cache
|
|
21
|
+
|
|
22
|
+
from nlalpha.core.contracts.time import normalize_utc
|
|
23
|
+
from nlalpha.core.errors import DataError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TradingCalendar(ABC):
|
|
27
|
+
"""交易日历抽象基类。
|
|
28
|
+
|
|
29
|
+
用法:
|
|
30
|
+
cal = USEquityCalendar()
|
|
31
|
+
cal.is_trading_day(date(2024, 1, 1)) # False (New Year's Day)
|
|
32
|
+
cal.add_trading_days(date(2024, 1, 1), 2) # date(2024, 1, 3)
|
|
33
|
+
cal.settlement_date(date(2024, 1, 2), rule="t+2") # date(2024, 1, 4)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def market(self) -> str:
|
|
39
|
+
"""市场标识,如 "US_EQUITY", "CN_EQUITY", "FUTURES"。"""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def timezone(self) -> str:
|
|
45
|
+
"""市场时区,如 "America/New_York", "Asia/Shanghai"。"""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
# 核心查询
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def is_trading_day(self, d: date) -> bool:
|
|
54
|
+
"""判断某日是否为交易日。"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def session_bounds(self, d: date) -> tuple[datetime, datetime]:
|
|
58
|
+
"""Resolve a labeled session into its authoritative UTC endpoints."""
|
|
59
|
+
raise DataError(
|
|
60
|
+
f"Calendar {self.market!r} has no authoritative session bounds; configure a symbol-specific session calendar.",
|
|
61
|
+
details={"time_contract": True},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def next_trading_day(self, d: date) -> date:
|
|
65
|
+
"""返回 d 的下一个交易日(若 d 本身是交易日,则返回 d 的下一个交易日)。"""
|
|
66
|
+
nxt = d + timedelta(days=1)
|
|
67
|
+
while not self.is_trading_day(nxt):
|
|
68
|
+
nxt += timedelta(days=1)
|
|
69
|
+
return nxt
|
|
70
|
+
|
|
71
|
+
def prev_trading_day(self, d: date) -> date:
|
|
72
|
+
"""返回 d 的上一个交易日。"""
|
|
73
|
+
prev = d - timedelta(days=1)
|
|
74
|
+
while not self.is_trading_day(prev):
|
|
75
|
+
prev -= timedelta(days=1)
|
|
76
|
+
return prev
|
|
77
|
+
|
|
78
|
+
def add_trading_days(self, d: date, n: int) -> date:
|
|
79
|
+
"""从 d 起加减 n 个交易日。
|
|
80
|
+
|
|
81
|
+
n 为正:向后数;n 为负:向前数;n 为 0:返回 d 本身(不检查是否为交易日)。
|
|
82
|
+
"""
|
|
83
|
+
result = d
|
|
84
|
+
step = 1 if n >= 0 else -1
|
|
85
|
+
count = 0
|
|
86
|
+
while count < abs(n):
|
|
87
|
+
result += timedelta(days=step)
|
|
88
|
+
if self.is_trading_day(result):
|
|
89
|
+
count += 1
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# 结算日计算
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def settlement_date(self, trade_date: date, rule: str = "t+1") -> date:
|
|
97
|
+
"""根据交收规则计算结算日。
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
trade_date: 交易日
|
|
101
|
+
rule: "t+0" / "t+1" / "t+2" / "t+3"
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
结算日(考虑节假日,只算交易日)
|
|
105
|
+
"""
|
|
106
|
+
days = self._parse_settlement_rule(rule)
|
|
107
|
+
return self.add_trading_days(trade_date, days)
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _parse_settlement_rule(rule: str) -> int:
|
|
111
|
+
"""解析结算规则字符串为交易日数。"""
|
|
112
|
+
mapping = {
|
|
113
|
+
"t+0": 0,
|
|
114
|
+
"t+1": 1,
|
|
115
|
+
"t+2": 2,
|
|
116
|
+
"t+3": 3,
|
|
117
|
+
}
|
|
118
|
+
return mapping.get(rule.lower(), 1)
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# 交易日计数
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def trading_days_between(self, start: date, end: date) -> int:
|
|
125
|
+
"""计算 [start, end] 区间内的交易日数量(含两端)。"""
|
|
126
|
+
if start > end:
|
|
127
|
+
return 0
|
|
128
|
+
count = 0
|
|
129
|
+
current = start
|
|
130
|
+
while current <= end:
|
|
131
|
+
if self.is_trading_day(current):
|
|
132
|
+
count += 1
|
|
133
|
+
current += timedelta(days=1)
|
|
134
|
+
return count
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class WeekendOnlyCalendar(TradingCalendar):
|
|
138
|
+
"""仅判断周末的简化日历(无节假日)。
|
|
139
|
+
|
|
140
|
+
作为默认 fallback:当 exchange-calendars 未安装时,
|
|
141
|
+
各市场子类可继承此类并只覆盖节假日列表。
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
market: str = "generic"
|
|
145
|
+
timezone: str = "UTC"
|
|
146
|
+
|
|
147
|
+
def __init__(self, holidays: set[date] | None = None):
|
|
148
|
+
self._holidays = holidays or set()
|
|
149
|
+
|
|
150
|
+
def is_trading_day(self, d: date) -> bool:
|
|
151
|
+
"""周六、周日和节假日为非交易日。"""
|
|
152
|
+
if d.weekday() >= 5: # 5=Saturday, 6=Sunday
|
|
153
|
+
return False
|
|
154
|
+
if d in self._holidays:
|
|
155
|
+
return False
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
def add_holiday(self, d: date) -> None:
|
|
159
|
+
"""动态添加节假日(用于测试或自定义)。"""
|
|
160
|
+
self._holidays.add(d)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def exchange_calendar(calendar_id: str):
|
|
164
|
+
"""Resolve an explicitly named exchange calendar without substitution."""
|
|
165
|
+
if not isinstance(calendar_id, str) or not calendar_id or calendar_id != calendar_id.strip():
|
|
166
|
+
raise DataError("A nonempty exchange calendar_id is required.", details={"time_contract": True})
|
|
167
|
+
return _exchange_calendar(calendar_id)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@lru_cache(maxsize=64)
|
|
171
|
+
def _exchange_calendar(calendar_id: str):
|
|
172
|
+
"""Cache validated exchange-calendar identifiers."""
|
|
173
|
+
import exchange_calendars as ec
|
|
174
|
+
if calendar_id not in ec.get_calendar_names():
|
|
175
|
+
raise DataError(f"Unknown exchange calendar_id: {calendar_id!r}.", details={"time_contract": True})
|
|
176
|
+
try:
|
|
177
|
+
return ec.get_calendar(calendar_id)
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
raise DataError(f"Cannot resolve calendar {calendar_id!r}: {exc}", details={"time_contract": True}) from exc
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def session_bounds(calendar_id: str, session_date: date) -> tuple[datetime, datetime]:
|
|
183
|
+
"""Return actual session endpoints including holidays and shortened sessions."""
|
|
184
|
+
from pandas import Timestamp
|
|
185
|
+
calendar = exchange_calendar(calendar_id)
|
|
186
|
+
try:
|
|
187
|
+
label = Timestamp(session_date)
|
|
188
|
+
if not calendar.is_session(label):
|
|
189
|
+
raise ValueError("date is not a trading session")
|
|
190
|
+
source_zone = str(calendar.tz)
|
|
191
|
+
begin_local = calendar.session_open(label).tz_convert(calendar.tz).tz_localize(None)
|
|
192
|
+
finish_local = calendar.session_close(label).tz_convert(calendar.tz).tz_localize(None)
|
|
193
|
+
begin = normalize_utc(begin_local, source_timezone=source_zone)
|
|
194
|
+
finish = normalize_utc(finish_local, source_timezone=source_zone)
|
|
195
|
+
if not begin < finish:
|
|
196
|
+
raise ValueError("session open must precede close")
|
|
197
|
+
return begin, finish
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
if isinstance(exc, DataError):
|
|
200
|
+
raise
|
|
201
|
+
raise DataError(f"Cannot resolve {calendar_id} session {session_date}: {exc}", details={"time_contract": True}) from exc
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""交易日历工具 —— 直接暴露 exchange_calendars 原生能力。
|
|
2
|
+
|
|
3
|
+
系统不维护任何节假日表,不自建推断逻辑。
|
|
4
|
+
Agent 自行选择交易所、自行计算交易日、自行评估数据质量。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import date
|
|
11
|
+
|
|
12
|
+
from nlalpha.core.result_contract import failure_response
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("nlalpha-data.calendar")
|
|
15
|
+
|
|
16
|
+
# 日历 ID 别名归一:exchange_calendars 4.13 已移除 XSHE(深交所),A 股沪深统一休市,映射到 XSHG
|
|
17
|
+
_CALENDAR_ALIASES = {
|
|
18
|
+
"XSHE": "XSHG",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_calendar_id(calendar_id: str) -> str:
|
|
23
|
+
"""Normalize legacy calendar IDs to IDs present in the installed version."""
|
|
24
|
+
return _CALENDAR_ALIASES.get(calendar_id, calendar_id)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_ec_calendar(calendar_id: str):
|
|
28
|
+
"""Get an exchange_calendars calendar by ID."""
|
|
29
|
+
import exchange_calendars as ec
|
|
30
|
+
return ec.get_calendar(_normalize_calendar_id(calendar_id))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_trading_calendars() -> list[dict]:
|
|
34
|
+
"""List all available trading calendars from exchange_calendars.
|
|
35
|
+
|
|
36
|
+
列出 exchange_calendars 支持的所有交易所日历。
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
List of {"id": str, "name": str}.
|
|
40
|
+
"""
|
|
41
|
+
import exchange_calendars as ec
|
|
42
|
+
|
|
43
|
+
names = ec.get_calendar_names()
|
|
44
|
+
result = []
|
|
45
|
+
for cid in sorted(names):
|
|
46
|
+
try:
|
|
47
|
+
cal = ec.get_calendar(cid)
|
|
48
|
+
result.append({
|
|
49
|
+
"id": cid,
|
|
50
|
+
"name": getattr(cal, "name", cid),
|
|
51
|
+
"timezone": str(getattr(cal, "tz", "")),
|
|
52
|
+
})
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.debug("skip calendar | id=%s | error=%s", cid, e)
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_trading_days(calendar_id: str, start: str, end: str) -> dict:
|
|
59
|
+
"""Get the number of trading days between start and end for a given calendar.
|
|
60
|
+
|
|
61
|
+
返回指定交易所日历在区间内的交易日数量。
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
calendar_id: Exchange calendar ID, e.g. "XSHG" (上交所), "XNYS" (纽交所)
|
|
65
|
+
start: Start date "YYYY-MM-DD"
|
|
66
|
+
end: End date "YYYY-MM-DD"
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
{"calendar_id": str, "start": str, "end": str, "trading_days": int}
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
cal = _get_ec_calendar(calendar_id)
|
|
73
|
+
sessions = cal.sessions_in_range(start, end)
|
|
74
|
+
trading_days = len(sessions)
|
|
75
|
+
return {
|
|
76
|
+
"calendar_id": calendar_id,
|
|
77
|
+
"start": start,
|
|
78
|
+
"end": end,
|
|
79
|
+
"trading_days": trading_days,
|
|
80
|
+
}
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.warning("get_trading_days failed | %s %s~%s | error=%s", calendar_id, start, end, e)
|
|
83
|
+
return {
|
|
84
|
+
**failure_response(e),
|
|
85
|
+
"calendar_id": calendar_id,
|
|
86
|
+
"start": start,
|
|
87
|
+
"end": end,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_trading_day(calendar_id: str, date_str: str) -> dict:
|
|
92
|
+
"""Check whether a specific date is a trading day on the given calendar.
|
|
93
|
+
|
|
94
|
+
判断指定日期是否为给定交易所的交易日。
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
calendar_id: Exchange calendar ID, e.g. "XSHG"
|
|
98
|
+
date_str: Date "YYYY-MM-DD"
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
{"calendar_id": str, "date": str, "is_trading_day": bool}
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
cal = _get_ec_calendar(calendar_id)
|
|
105
|
+
from pandas import Timestamp
|
|
106
|
+
ts = Timestamp(date_str)
|
|
107
|
+
is_session = cal.is_session(ts)
|
|
108
|
+
return {
|
|
109
|
+
"calendar_id": calendar_id,
|
|
110
|
+
"date": date_str,
|
|
111
|
+
"is_trading_day": bool(is_session),
|
|
112
|
+
}
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.warning("is_trading_day failed | %s %s | error=%s", calendar_id, date_str, e)
|
|
115
|
+
return {
|
|
116
|
+
**failure_response(e),
|
|
117
|
+
"calendar_id": calendar_id,
|
|
118
|
+
"date": date_str,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def confess() -> dict:
|
|
123
|
+
"""Describe how to discover and interpret the available exchange calendars."""
|
|
124
|
+
guide = """# Trading calendar guide
|
|
125
|
+
|
|
126
|
+
`list_trading_calendars()` discovers calendar IDs from the installed `exchange_calendars` library. Call it first and use an ID returned by that call; the available catalog can vary with the installed library version. `get_trading_days(calendar_id, start, end)` returns the number of sessions in the requested date range, and `is_trading_day(calendar_id, date)` checks one date. Preserve any returned error as unavailable calendar evidence.
|
|
127
|
+
|
|
128
|
+
This module normalizes the legacy ID `XSHE` to `XSHG` for calendar lookup. That single compatibility alias does not establish that every market calendar is identical or that `XSHG` is appropriate for another instrument.
|
|
129
|
+
|
|
130
|
+
Trading-session counts are not observed-bar counts or missing-bar counts. Do not calculate missing sessions as `trading_days - bar_count`: duplicate bars, intraday intervals, partial sessions, or different bar coverage can make that subtraction wrong. For an exact missing-session result, compare observed per-day session dates with the sessions and applicable schedule for the requested instrument. If those observations or a suitable calendar/session definition are unavailable, report the exact missing count as unknown.
|
|
131
|
+
|
|
132
|
+
Do not assume a calendar for futures or another instrument class. Use only a calendar explicitly supported by the current source contract or the instrument's verified session schedule; an unverified calendar leaves the result unknown."""
|
|
133
|
+
return {"guide": guide}
|