data-backtest-live-common 0.2.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.
- data_backtest_live_common-0.2.0.dist-info/METADATA +143 -0
- data_backtest_live_common-0.2.0.dist-info/RECORD +22 -0
- data_backtest_live_common-0.2.0.dist-info/WHEEL +5 -0
- data_backtest_live_common-0.2.0.dist-info/licenses/LICENSE +21 -0
- data_backtest_live_common-0.2.0.dist-info/top_level.txt +1 -0
- dbl_common/__init__.py +93 -0
- dbl_common/adjust.py +74 -0
- dbl_common/boards.py +162 -0
- dbl_common/circuit_breaker.py +82 -0
- dbl_common/corporate_actions.py +177 -0
- dbl_common/fees.py +171 -0
- dbl_common/halts.py +182 -0
- dbl_common/io_utils.py +70 -0
- dbl_common/lots.py +110 -0
- dbl_common/price_limit.py +326 -0
- dbl_common/proc.py +130 -0
- dbl_common/settlement.py +108 -0
- dbl_common/ticks.py +57 -0
- dbl_common/trade_calendar.py +128 -0
- dbl_common/trading_calendar.py +202 -0
- dbl_common/trading_hours.py +159 -0
- dbl_common/vec.py +134 -0
dbl_common/ticks.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""ticks.py — 最小申报价格单位(tick size)(公共模块 · 纯函数)。
|
|
3
|
+
|
|
4
|
+
撮合价的合法性校验、回测成交价的取整、实盘限价单步进都要用同一个 tick。
|
|
5
|
+
|
|
6
|
+
制度事实表(2026 口径)
|
|
7
|
+
----------------------
|
|
8
|
+
· A 股股票(沪深主板/科创板/创业板/北交所):0.01 元。
|
|
9
|
+
· B 股:沪 0.001 美元 / 深 0.01 港元(本模块返回数值口径 0.001/0.01,
|
|
10
|
+
币种换算归调用方)。
|
|
11
|
+
· 场内基金(ETF/LOF):0.001 元。
|
|
12
|
+
· 可转债:0.001 元。
|
|
13
|
+
· 沪市国债逆回购:0.005 元;深市 0.01 元(以交易所现行细则为准)。
|
|
14
|
+
|
|
15
|
+
⚠️ 价格的历史变迁(如早年沪市 A 股曾按价位分档 0.01/0.001)不做回溯——
|
|
16
|
+
对回测撮合影响可忽略;有实证需求再扩展。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from .boards import SecurityType, exchange_of, normalize_code, security_type
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def tick_size(code: str, *, stype: Optional[SecurityType] = None) -> float:
|
|
26
|
+
"""最小申报价格单位(元)。未知品种保守返回 0.01 并在调用方核对。"""
|
|
27
|
+
st = security_type(code, stype)
|
|
28
|
+
if st in (SecurityType.ETF, SecurityType.LOF, SecurityType.FUND_OTHER,
|
|
29
|
+
SecurityType.CONVERTIBLE_BOND):
|
|
30
|
+
return 0.001
|
|
31
|
+
if st == SecurityType.REPO:
|
|
32
|
+
return 0.005 if exchange_of(code) == "SH" else 0.01
|
|
33
|
+
if st == SecurityType.B_SHARE:
|
|
34
|
+
return 0.001 if exchange_of(code) == "SH" else 0.01
|
|
35
|
+
return 0.01
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def snap_to_tick(price: float, code: str, *,
|
|
39
|
+
stype: Optional[SecurityType] = None) -> float:
|
|
40
|
+
"""把任意价格吸附到合法申报价(向下取整到 tick——**不四舍五入**:
|
|
41
|
+
买单向下吸保证不超预算;卖方向上吸请调用方显式选择方向)。"""
|
|
42
|
+
import math
|
|
43
|
+
|
|
44
|
+
t = tick_size(code, stype=stype)
|
|
45
|
+
return math.floor(round(float(price) / t, 9)) * t
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_valid_price(price: float, code: str, *,
|
|
49
|
+
stype: Optional[SecurityType] = None,
|
|
50
|
+
tol: float = 1e-9) -> bool:
|
|
51
|
+
"""价格是否恰好落在合法申报档位上。"""
|
|
52
|
+
t = tick_size(code, stype=stype)
|
|
53
|
+
v = float(price) / t
|
|
54
|
+
return abs(v - round(v)) <= tol / t
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["tick_size", "snap_to_tick", "is_valid_price", "normalize_code"]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""dbl_common.trade_calendar — 交易日历公共模块(数据 / 回测 / 实盘共用)
|
|
3
|
+
|
|
4
|
+
溯源:quant_data_center/common/trade_calendar.py → 2026-09-26 抽入本独立库。
|
|
5
|
+
|
|
6
|
+
所有交易系统都必须遵守同一套日历口径,集中在此维护:
|
|
7
|
+
· 日历日期归一化:Tushare cal_date 的 YYYYMMDD → 标准 YYYY-MM-DD;
|
|
8
|
+
· 开市判定:按 is_open 列过滤周末与节假日(兼容字符串/数值多种存储);
|
|
9
|
+
· Parquet schema 探测:兼容 DuckDB 版本差异(1.5.x 为 name 列,旧版 column_name);
|
|
10
|
+
· load_trade_dates:从日历 Parquet 取升序开市日,支持自有/外部连接。
|
|
11
|
+
|
|
12
|
+
与旧版(quant_data_center/common/trade_calendar.py)的关键差异:
|
|
13
|
+
本库**不绑定任何系统的日历路径** —— ``cal_glob`` 必传:
|
|
14
|
+
· 参数缺失 = 程序员错误 → ValueError(响亮报错,不静默);
|
|
15
|
+
· 日历文件缺失/不可读 = 数据状态 → 返回 [](调用方可据此降级,不抛异常)。
|
|
16
|
+
各系统自己维护路径常量(quant_data_center 在 common/paths.py 的 DEFAULT_CAL_GLOB)。
|
|
17
|
+
|
|
18
|
+
仅依赖 duckdb + 标准库。
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
import duckdb
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("dbl_common.trade_calendar")
|
|
30
|
+
|
|
31
|
+
_8DIGIT = re.compile(r"^\d{8}$")
|
|
32
|
+
_ISO = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
33
|
+
|
|
34
|
+
# is_open 真值集合(pandas/duckdb 落盘后可能出现字符串、浮点等形态)
|
|
35
|
+
_OPEN_TRUE = {"1", "1.0", "true", "t", "yes", "y"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def normalize_cal_date(value) -> str:
|
|
39
|
+
"""日历日期 → YYYY-MM-DD。已是 ISO 格式原样返回;非法值抛 ValueError。"""
|
|
40
|
+
s = str(value).strip()
|
|
41
|
+
if _8DIGIT.match(s):
|
|
42
|
+
return f"{s[:4]}-{s[4:6]}-{s[6:8]}"
|
|
43
|
+
if _ISO.match(s):
|
|
44
|
+
return s
|
|
45
|
+
raise ValueError(f"无法识别的日历日期:{value!r}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_open_true(value) -> bool:
|
|
49
|
+
"""is_open 字段是否为开市(宽容判定)。"""
|
|
50
|
+
return str(value).strip().lower() in _OPEN_TRUE
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parquet_columns(glob: str,
|
|
54
|
+
con: Optional[duckdb.DuckDBPyConnection] = None) -> list[str]:
|
|
55
|
+
"""Parquet 文件列名(兼容 DuckDB 版本差异);文件不存在/出错 → []。"""
|
|
56
|
+
own = con is None
|
|
57
|
+
con = con or duckdb.connect()
|
|
58
|
+
try:
|
|
59
|
+
for col in ("name", "column_name"):
|
|
60
|
+
try:
|
|
61
|
+
return [r[0] for r in con.execute(
|
|
62
|
+
f"SELECT {col} FROM parquet_schema(?)", [glob]).fetchall()]
|
|
63
|
+
except duckdb.BinderException:
|
|
64
|
+
continue
|
|
65
|
+
return []
|
|
66
|
+
except Exception:
|
|
67
|
+
return []
|
|
68
|
+
finally:
|
|
69
|
+
if own:
|
|
70
|
+
con.close()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def has_column(glob: str, column: str,
|
|
74
|
+
con: Optional[duckdb.DuckDBPyConnection] = None) -> bool:
|
|
75
|
+
return column in parquet_columns(glob, con)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def trade_days_between(dates, start: Optional[str] = None,
|
|
79
|
+
end: Optional[str] = None) -> int:
|
|
80
|
+
"""[start, end] 闭区间内的交易日数(dates 为 ISO 升序序列)。"""
|
|
81
|
+
return sum(1 for d in dates
|
|
82
|
+
if (start is None or d >= start) and (end is None or d <= end))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_trade_dates(con: Optional[duckdb.DuckDBPyConnection] = None,
|
|
86
|
+
cal_glob: Optional[str] = None,
|
|
87
|
+
end_date: Optional[str] = None) -> list[str]:
|
|
88
|
+
"""开市交易日(ISO 升序,≤ end_date,默认今天)。
|
|
89
|
+
|
|
90
|
+
``cal_glob`` **必传**(本库不绑定任何系统的日历路径);
|
|
91
|
+
con=None 时自建内存连接并关闭(回测/实盘一行调用即可);
|
|
92
|
+
日历文件缺失或读取失败 → [](调用方可据此降级,不抛异常)。
|
|
93
|
+
"""
|
|
94
|
+
if not cal_glob:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"cal_glob 必传:dbl_common 不绑定任何系统的日历路径"
|
|
97
|
+
"(quant_data_center 用 common/paths.py 的 DEFAULT_CAL_GLOB)")
|
|
98
|
+
end_date = end_date or datetime.now().strftime("%Y-%m-%d")
|
|
99
|
+
own = con is None
|
|
100
|
+
con = con or duckdb.connect()
|
|
101
|
+
try:
|
|
102
|
+
cols = parquet_columns(cal_glob, con)
|
|
103
|
+
if not cols:
|
|
104
|
+
logger.warning("交易日历不可读:%s(相关日历规则将被跳过)", cal_glob)
|
|
105
|
+
return []
|
|
106
|
+
use_open = "is_open" in cols
|
|
107
|
+
df = con.execute(
|
|
108
|
+
f"SELECT DISTINCT cal_date{', is_open' if use_open else ''} "
|
|
109
|
+
"FROM read_parquet(?, hive_partitioning=true)",
|
|
110
|
+
[cal_glob]).df()
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logger.warning("交易日历读取失败:%s", e)
|
|
113
|
+
return []
|
|
114
|
+
finally:
|
|
115
|
+
if own:
|
|
116
|
+
con.close()
|
|
117
|
+
|
|
118
|
+
if use_open:
|
|
119
|
+
df = df[df["is_open"].map(is_open_true)]
|
|
120
|
+
days: set[str] = set()
|
|
121
|
+
for v in df["cal_date"]:
|
|
122
|
+
try:
|
|
123
|
+
s = normalize_cal_date(v)
|
|
124
|
+
except ValueError:
|
|
125
|
+
continue
|
|
126
|
+
if s <= end_date:
|
|
127
|
+
days.add(s)
|
|
128
|
+
return sorted(days)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""trading_calendar.py — 交易日历契约(公共模块):规则归本模块,**数据归数据中心**。
|
|
3
|
+
|
|
4
|
+
(模块名不用 `calendar.py`:会遮蔽标准库 `calendar`——任何把本目录挂进
|
|
5
|
+
`sys.path` 的系统都会连带炸掉 `datetime` 内部实现,实测踩过。)
|
|
6
|
+
|
|
7
|
+
为什么只定契约不定数据:节假日是**数据**不是**规则**(每年国务院发布),
|
|
8
|
+
硬编码必然过期。正确分工(与用户架构一致):
|
|
9
|
+
数据中心(quant_data_center)→ 产出权威交易日历落盘(如 BaoStock
|
|
10
|
+
`trade_cal`:date,is_trading 全历史);
|
|
11
|
+
本模块 → 定义注入接口 + 三态诚实判定 + 只跳周末的显式降级。
|
|
12
|
+
|
|
13
|
+
三态诚实(移植自 GMD `trading_calendar.py` 的设计,实测修过 5 份日历
|
|
14
|
+
口径漂移的教训):
|
|
15
|
+
`is_trading_day()` → True / False / **None**。
|
|
16
|
+
None = 日历不可用或日期在覆盖区间**之外**——绝不拿「猜」冒充「知道」,
|
|
17
|
+
不许 forward-fill(2027 年的问题不许静默沿用 2026 年的判断)。
|
|
18
|
+
降级是调用方的事,但降级口径也只提供这一份(`weekend_*`,调用方必须
|
|
19
|
+
显式选择并对外标注「用了近似」)。
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import datetime as _dt
|
|
24
|
+
import csv
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Callable, Iterable, Optional, Protocol, Set
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CalendarSource(Protocol):
|
|
30
|
+
"""交易日历数据源契约(数据中心实现并注入)。"""
|
|
31
|
+
|
|
32
|
+
def is_trading_day(self, date) -> Optional[bool]: ...
|
|
33
|
+
|
|
34
|
+
def coverage(self) -> tuple: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InjectedCalendar:
|
|
38
|
+
"""由「交易日集合」构成的日历(数据中心把 trade_cal 的开市日注入)。"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, trading_days: Iterable[str],
|
|
41
|
+
coverage_start: Optional[str] = None,
|
|
42
|
+
coverage_end: Optional[str] = None):
|
|
43
|
+
self._days: Set[str] = {str(d)[:10] for d in trading_days}
|
|
44
|
+
ds = sorted(self._days)
|
|
45
|
+
self._cov = (coverage_start or (ds[0] if ds else None),
|
|
46
|
+
coverage_end or (ds[-1] if ds else None))
|
|
47
|
+
|
|
48
|
+
def is_trading_day(self, date) -> Optional[bool]:
|
|
49
|
+
d = _iso(date)
|
|
50
|
+
if d is None:
|
|
51
|
+
return None
|
|
52
|
+
lo, hi = self._cov
|
|
53
|
+
if lo is None or hi is None or d < lo or d > hi:
|
|
54
|
+
return None # 覆盖区间外:不知道,不猜
|
|
55
|
+
return d in self._days
|
|
56
|
+
|
|
57
|
+
def coverage(self) -> tuple:
|
|
58
|
+
return self._cov
|
|
59
|
+
|
|
60
|
+
def next_trading_day(self, date, max_walk: int = 30) -> Optional[str]:
|
|
61
|
+
"""下一交易日(区间尽头/走 30 天没找到 → None)。"""
|
|
62
|
+
cur = _iso(date)
|
|
63
|
+
if cur is None:
|
|
64
|
+
return None
|
|
65
|
+
for _ in range(max_walk):
|
|
66
|
+
cur = (_dt.date.fromisoformat(cur) + _dt.timedelta(days=1)).isoformat()
|
|
67
|
+
if self.is_trading_day(cur) is True:
|
|
68
|
+
return cur
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def prev_trading_day(self, date, max_walk: int = 30) -> Optional[str]:
|
|
72
|
+
cur = _iso(date)
|
|
73
|
+
if cur is None:
|
|
74
|
+
return None
|
|
75
|
+
for _ in range(max_walk):
|
|
76
|
+
cur = (_dt.date.fromisoformat(cur) - _dt.timedelta(days=1)).isoformat()
|
|
77
|
+
if self.is_trading_day(cur) is True:
|
|
78
|
+
return cur
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def trading_days_between(self, start, end) -> Optional[int]:
|
|
82
|
+
"""[start, end] 内的交易日数(含端点;区间越界 → None)。"""
|
|
83
|
+
a, b = _iso(start), _iso(end)
|
|
84
|
+
if a is None or b is None or a > b:
|
|
85
|
+
return None
|
|
86
|
+
if self.is_trading_day(a) is None or self.is_trading_day(b) is None:
|
|
87
|
+
return None
|
|
88
|
+
n, cur = 0, a
|
|
89
|
+
while cur <= b:
|
|
90
|
+
if self.is_trading_day(cur) is True:
|
|
91
|
+
n += 1
|
|
92
|
+
cur = (_dt.date.fromisoformat(cur) + _dt.timedelta(days=1)).isoformat()
|
|
93
|
+
return n
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def from_baostock_csv(path, *, is_trading_col: str = "is_trading") -> InjectedCalendar:
|
|
97
|
+
"""从 BaoStock trade_cal CSV(date,is_trading)构建日历。
|
|
98
|
+
|
|
99
|
+
这是与数据中心的**对接点**:quant_data_center 已把 BaoStock 权威交易日历
|
|
100
|
+
落盘(storage/raw/baostock/trade_cal),任何系统把该文件路径传进来即可,
|
|
101
|
+
不需要再各自联网拉日历(GMD 实测教训:5 份日历 5 个口径)。
|
|
102
|
+
"""
|
|
103
|
+
p = Path(path)
|
|
104
|
+
days = []
|
|
105
|
+
with p.open("r", encoding="utf-8-sig", newline="") as f:
|
|
106
|
+
for row in csv.DictReader(f):
|
|
107
|
+
d = str(row.get("date") or "")[:10]
|
|
108
|
+
v = str(row.get(is_trading_col) or "").strip()
|
|
109
|
+
if d and v == "1":
|
|
110
|
+
days.append(d)
|
|
111
|
+
return InjectedCalendar(days)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def from_parquet_dataset(dataset_dir, *, date_col: str = "date",
|
|
115
|
+
is_trading_col: Optional[str] = None) -> InjectedCalendar:
|
|
116
|
+
"""从数据中心 raw 层 parquet 数据集目录构建交易日历(标准对接点)。
|
|
117
|
+
|
|
118
|
+
数据中心落盘布局为 `storage/raw/<source>/<dataset>/year=YYYY/*.parquet`
|
|
119
|
+
(按年分区),本函数递归读取整目录。两种用法:
|
|
120
|
+
· 日历数据集(baostock/trade_cal):传 `is_trading_col="is_trading"`
|
|
121
|
+
(只取值为 1/`'1'` 的开市日);
|
|
122
|
+
· 无 `is_trading_col`:把该数据集出现过的**全部日期**视为交易日
|
|
123
|
+
(如 tencent/index_daily 的指数日线日期集——对账/兜底场景够用)。
|
|
124
|
+
|
|
125
|
+
需要 pandas(数据中心环境标配;本包核心仍零依赖,此函数按需 import)。
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
import pandas as pd
|
|
129
|
+
except ImportError as e: # pragma: no cover
|
|
130
|
+
raise ImportError(
|
|
131
|
+
"from_parquet_dataset 需要 pandas——数据中心/回测环境标配;"
|
|
132
|
+
"若在纯净环境用请改用 from_baostock_csv 或直接注入日期集合") from e
|
|
133
|
+
p = Path(dataset_dir)
|
|
134
|
+
if not p.is_dir():
|
|
135
|
+
raise FileNotFoundError(f"数据集目录不存在: {p}")
|
|
136
|
+
files = sorted(p.rglob("*.parquet"))
|
|
137
|
+
if not files:
|
|
138
|
+
raise FileNotFoundError(f"目录里没有 parquet: {p}")
|
|
139
|
+
frames = [pd.read_parquet(f, columns=None) for f in files]
|
|
140
|
+
df = pd.concat(frames, ignore_index=True) if len(frames) > 1 else frames[0]
|
|
141
|
+
if date_col not in df.columns:
|
|
142
|
+
raise ValueError(f"缺日期列 {date_col!r},实际列: {list(df.columns)[:10]}")
|
|
143
|
+
dates = df[date_col].astype(str).str[:10]
|
|
144
|
+
if is_trading_col:
|
|
145
|
+
if is_trading_col not in df.columns:
|
|
146
|
+
raise ValueError(f"缺开市标记列 {is_trading_col!r},"
|
|
147
|
+
f"实际列: {list(df.columns)[:10]}")
|
|
148
|
+
mask = df[is_trading_col].astype(str).str.strip().isin(("1", "1.0", "True", "true"))
|
|
149
|
+
dates = dates[mask]
|
|
150
|
+
return InjectedCalendar(sorted(set(dates)))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── 显式降级口径(唯一的近似实现,调用方须标注「近似」)─────────────────
|
|
154
|
+
def weekend_is_trading_day(date) -> Optional[bool]:
|
|
155
|
+
"""只跳周末的近似:周六日 False,工作日 **None**(不知道——节假日不认识)。"""
|
|
156
|
+
d = _to_date(date)
|
|
157
|
+
if d is None:
|
|
158
|
+
return None
|
|
159
|
+
return False if d.weekday() >= 5 else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def weekend_next_trading_day(date, max_walk: int = 10) -> Optional[str]:
|
|
163
|
+
cur = _to_date(date)
|
|
164
|
+
if cur is None:
|
|
165
|
+
return None
|
|
166
|
+
for _ in range(max_walk):
|
|
167
|
+
cur = cur + _dt.timedelta(days=1)
|
|
168
|
+
if cur.weekday() < 5:
|
|
169
|
+
return cur.isoformat()
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def weekend_prev_trading_day(date, max_walk: int = 10) -> Optional[str]:
|
|
174
|
+
cur = _to_date(date)
|
|
175
|
+
if cur is None:
|
|
176
|
+
return None
|
|
177
|
+
for _ in range(max_walk):
|
|
178
|
+
cur = cur - _dt.timedelta(days=1)
|
|
179
|
+
if cur.weekday() < 5:
|
|
180
|
+
return cur.isoformat()
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _iso(x) -> Optional[str]:
|
|
185
|
+
d = _to_date(x)
|
|
186
|
+
return d.isoformat() if d else None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _to_date(x) -> Optional[_dt.date]:
|
|
190
|
+
if isinstance(x, _dt.datetime):
|
|
191
|
+
return x.date()
|
|
192
|
+
if isinstance(x, _dt.date):
|
|
193
|
+
return x
|
|
194
|
+
try:
|
|
195
|
+
return _dt.date.fromisoformat(str(x)[:10])
|
|
196
|
+
except ValueError:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
__all__ = ["InjectedCalendar", "CalendarSource", "from_baostock_csv",
|
|
201
|
+
"weekend_is_trading_day", "weekend_next_trading_day",
|
|
202
|
+
"weekend_prev_trading_day"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""trading_hours.py — A 股交易时段与竞价阶段(公共模块 · 纯函数)。
|
|
3
|
+
|
|
4
|
+
回测的「撮合时点」、模拟盘的「委托受理窗口」、实盘的「撤单合法性」都问
|
|
5
|
+
同一个问题:「现在处于哪个竞价阶段」。本模块给出**现行制度**的精确判定
|
|
6
|
+
与必要的**历史变更**标注。
|
|
7
|
+
|
|
8
|
+
现行时段(沪深,2026 口径)
|
|
9
|
+
---------------------------
|
|
10
|
+
· 09:15-09:25 开盘集合竞价(09:20-09:25 沪深**不接受撤单**;09:15-09:20 可撤)。
|
|
11
|
+
· 09:25-09:30 申报静默期(接收申报、不撮合——严格说 9:30 前不成交)。
|
|
12
|
+
· 09:30-11:30 / 13:00-14:57 连续竞价。
|
|
13
|
+
· 14:57-15:00 收盘集合竞价:**深市/北交所有**;沪市主板无(连续竞价至 15:00)。
|
|
14
|
+
⚠️ 深市收盘集合竞价自 2018-08-20 前后引入(确切日期以深交所公告为准——
|
|
15
|
+
回测 2018 前后深市收盘价的撮合语义差异极小,但如实标注)。
|
|
16
|
+
· 科创板/创业板盘后固定价格交易:15:05-15:30(申报 9:30-11:30/13:00-15:30)。
|
|
17
|
+
· 北交所:9:15-9:25 集合竞价(9:20-9:25 不可撤),连续竞价同上,14:57-15:00
|
|
18
|
+
收盘集合竞价。
|
|
19
|
+
|
|
20
|
+
诚实边界
|
|
21
|
+
--------
|
|
22
|
+
本模块不判「今天是否交易日」(日历数据归数据中心供给,见 `calendar.py`);
|
|
23
|
+
只判「若为交易日,此刻处于什么阶段」。午休/盘后返回明确枚举,调用方自行裁决。
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import datetime as _dt
|
|
28
|
+
import enum
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from .boards import Board, board_of, normalize_code
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Phase(enum.Enum):
|
|
35
|
+
"""竞价阶段(判定撮合/撤单语义的最小粒度)。"""
|
|
36
|
+
|
|
37
|
+
PRE_OPEN = "pre_open" # 开盘集合竞价·可撤单(9:15-9:20)
|
|
38
|
+
OPEN_AUCTION_LOCKED = "open_locked" # 开盘集合竞价·不可撤单(9:20-9:25)
|
|
39
|
+
OPEN_AUCTION_IDLE = "open_idle" # 静默期(9:25-9:30:接收不撮合)
|
|
40
|
+
MORNING = "morning" # 上午连续竞价(9:30-11:30)
|
|
41
|
+
LUNCH = "lunch" # 午间休市(11:30-13:00)
|
|
42
|
+
AFTERNOON = "afternoon" # 下午连续竞价(13:00-14:57)
|
|
43
|
+
CLOSING_AUCTION = "closing_auction" # 收盘集合竞价(深/北 14:57-15:00)
|
|
44
|
+
SH_CLOSE_RUN = "sh_close_run" # 沪市尾段连续竞价(14:57-15:00)
|
|
45
|
+
POST_FIXED = "post_fixed" # 科创/创业盘后固定价格(15:05-15:30)
|
|
46
|
+
LUNCH_AUCTION_IDLE = "lunch_idle" # 中午申报静默(11:30-13:00 接收不撮合)
|
|
47
|
+
CLOSED = "closed" # 收市 / 非交易时段
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# 深市收盘集合竞价引入(2018-08-20,确切日以深交所公告为准——回测影响可忽略)
|
|
51
|
+
SZ_CLOSING_AUCTION_START = "2018-08-20"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def phase_at(code: str, when, *, date: Optional[str] = None) -> Phase:
|
|
55
|
+
"""判定 `code`(按其市场/板块)在 `when`(time 或 datetime)所处阶段。
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
code: 证券代码(用于区分沪/深/北交所;无交易所信息的按前缀 best-effort)。
|
|
59
|
+
when: `datetime.time` / `datetime.datetime` / "HH:MM"[:SS] 字符串。
|
|
60
|
+
date: "YYYY-MM-DD"(深市收盘集合竞价历史判定用;None = 按现行制度)。
|
|
61
|
+
|
|
62
|
+
Notes:
|
|
63
|
+
盘后固定价格交易仅科创/创业板(15:05-15:30)。
|
|
64
|
+
"""
|
|
65
|
+
t = _to_time(when)
|
|
66
|
+
hm = (t.hour, t.minute, t.second)
|
|
67
|
+
|
|
68
|
+
def _between(lo, hi): # [lo, hi) 半开区间,hm 为 (h, m, s)
|
|
69
|
+
a = _sec(lo)
|
|
70
|
+
b = _sec(hi)
|
|
71
|
+
cur = hm[0] * 3600 + hm[1] * 60 + hm[2]
|
|
72
|
+
return a <= cur < b
|
|
73
|
+
|
|
74
|
+
if _between("09:15", "09:20"):
|
|
75
|
+
return Phase.PRE_OPEN
|
|
76
|
+
if _between("09:20", "09:25"):
|
|
77
|
+
return Phase.OPEN_AUCTION_LOCKED
|
|
78
|
+
if _between("09:25", "09:30"):
|
|
79
|
+
return Phase.OPEN_AUCTION_IDLE
|
|
80
|
+
if _between("09:30", "11:30"):
|
|
81
|
+
return Phase.MORNING
|
|
82
|
+
if _between("11:30", "13:00"):
|
|
83
|
+
return Phase.LUNCH
|
|
84
|
+
if _between("13:00", "14:57"):
|
|
85
|
+
return Phase.AFTERNOON
|
|
86
|
+
if _between("14:57", "15:00"):
|
|
87
|
+
return _closing_phase(code, date)
|
|
88
|
+
if _between("15:00", "15:05"):
|
|
89
|
+
return Phase.CLOSED
|
|
90
|
+
if _between("15:05", "15:30"):
|
|
91
|
+
if _is_star_or_gem(code):
|
|
92
|
+
return Phase.POST_FIXED
|
|
93
|
+
return Phase.CLOSED
|
|
94
|
+
return Phase.CLOSED
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _closing_phase(code: str, date: Optional[str]) -> Phase:
|
|
98
|
+
"""14:57-15:00:深/北收盘集合竞价;沪主板连续竞价。"""
|
|
99
|
+
board = board_of(code)
|
|
100
|
+
if board in (Board.MAIN_SZ, Board.GEM, Board.BSE):
|
|
101
|
+
if date is not None and board == Board.GEM and date < "2018-08-20":
|
|
102
|
+
return Phase.AFTERNOON # 深市引入收盘集合竞价前:仍是连续竞价(近似标注)
|
|
103
|
+
return Phase.CLOSING_AUCTION
|
|
104
|
+
return Phase.SH_CLOSE_RUN
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_star_or_gem(code: str) -> bool:
|
|
108
|
+
return board_of(normalize_code(code)) in (Board.STAR, Board.GEM)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _sec(hm: str) -> int:
|
|
112
|
+
h, m = hm.split(":")
|
|
113
|
+
return int(h) * 3600 + int(m) * 60
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _to_time(when) -> _dt.time:
|
|
117
|
+
if isinstance(when, _dt.datetime):
|
|
118
|
+
return when.time()
|
|
119
|
+
if isinstance(when, _dt.time):
|
|
120
|
+
return when
|
|
121
|
+
s = str(when).strip()
|
|
122
|
+
if len(s) >= 5 and ":" in s:
|
|
123
|
+
parts = s.split(":")
|
|
124
|
+
return _dt.time(int(parts[0]), int(parts[1]),
|
|
125
|
+
int(parts[2][:2]) if len(parts) > 2 and parts[2][:2].isdigit() else 0)
|
|
126
|
+
raise ValueError(f"无法解析时间: {when!r}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_auction(when, code: str = "600000") -> bool:
|
|
130
|
+
"""此刻是否处于集合竞价(开盘锁定段或收盘集合竞价)。"""
|
|
131
|
+
return phase_at(code, when) in (Phase.OPEN_AUCTION_LOCKED, Phase.CLOSING_AUCTION)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_continuous(when, code: str = "600000") -> bool:
|
|
135
|
+
"""此刻是否处于连续竞价(含沪市尾段)。"""
|
|
136
|
+
return phase_at(code, when) in (Phase.MORNING, Phase.AFTERNOON, Phase.SH_CLOSE_RUN)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def can_cancel(when, code: str = "600000") -> bool:
|
|
140
|
+
"""此刻未成交委托能否撤单(9:20-9:25 集合竞价锁定段不可撤;收盘集合竞价可撤?)。
|
|
141
|
+
|
|
142
|
+
现行规则:开盘集合竞价 9:20-9:25 不可撤;收盘集合竞价(深/北)14:57-15:00
|
|
143
|
+
**不可撤**;盘后固定价格交易申报可撤(15:30 前)。静默期 9:25-9:30 可撤。
|
|
144
|
+
"""
|
|
145
|
+
p = phase_at(code, when)
|
|
146
|
+
return p not in (Phase.OPEN_AUCTION_LOCKED, Phase.CLOSING_AUCTION)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
PHASE_LABEL = {
|
|
150
|
+
Phase.PRE_OPEN: "开盘集合竞价(可撤)", Phase.OPEN_AUCTION_LOCKED: "开盘集合竞价(不可撤)",
|
|
151
|
+
Phase.OPEN_AUCTION_IDLE: "申报静默期", Phase.MORNING: "上午连续竞价",
|
|
152
|
+
Phase.LUNCH: "午间休市", Phase.AFTERNOON: "下午连续竞价",
|
|
153
|
+
Phase.CLOSING_AUCTION: "收盘集合竞价(不可撤)", Phase.SH_CLOSE_RUN: "沪市尾段连续竞价",
|
|
154
|
+
Phase.POST_FIXED: "盘后固定价格交易", Phase.LUNCH_AUCTION_IDLE: "午间静默",
|
|
155
|
+
Phase.CLOSED: "非交易时段",
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
__all__ = ["Phase", "PHASE_LABEL", "phase_at", "is_auction", "is_continuous",
|
|
159
|
+
"can_cancel", "SZ_CLOSING_AUCTION_START"]
|
dbl_common/vec.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""vec.py — numpy 向量化判定层(涨跌停引擎唯一实现)。
|
|
3
|
+
|
|
4
|
+
**2026-09-27 起(market_common 并入 dbl_common)本模块是涨跌停向量化判定的
|
|
5
|
+
唯一引擎**:原 dbl_common.price_limit 内嵌的同名引擎(candidate_pcts/
|
|
6
|
+
exact_pcts 元组签名)已并入此处统一为 spec 驱动签名,避免两套引擎口径漂移。
|
|
7
|
+
|
|
8
|
+
全市场回测(5000+ 只 × 数千日)必须向量化,逐股循环太慢。本层把
|
|
9
|
+
`price_limit` 的纯标量规则翻译成 numpy 面板操作(接口语义与 GMD
|
|
10
|
+
`app/datasources/price_limit.py` 的 `flags_from_bars`/`limit_prices`
|
|
11
|
+
同构——经 200+ 轮实盘/回测验证的算法,独立改写)。
|
|
12
|
+
|
|
13
|
+
核心算法(为什么能跨除权日算对):
|
|
14
|
+
调后前收盘 = round_half_up( raw_close[t] / (1 + hfq_ret[t]) , 2)
|
|
15
|
+
涨停价 = round_half_up( 调后前收盘 × (1 + pct) , 2)
|
|
16
|
+
`hfq_ret` 为后复权真实收益率(除权跳空已被复权消除)——不复权价的
|
|
17
|
+
「前收盘」在除权日会跳空,直接 close/prev_close 会把除权跳空误判成跌停。
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Dict, Optional, Sequence
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from .price_limit import (CENT_TOL, EPS, LIMIT_ERA_START, LimitSpec,
|
|
26
|
+
limit_spec, price_decimals)
|
|
27
|
+
|
|
28
|
+
FLAG_COLS = ("up_touch", "dn_touch", "up_close", "dn_close",
|
|
29
|
+
"up_open", "dn_open")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def round_half_up_arr(x: np.ndarray, decimals: int = 2) -> np.ndarray:
|
|
33
|
+
"""向量化 half-up 舍入(交易所算板价口径;不是 np.round 的银行家舍入)。"""
|
|
34
|
+
f = 10.0 ** int(decimals)
|
|
35
|
+
return np.floor(np.asarray(x, dtype=np.float64) * f + 0.5) / f
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def limit_prices(raw_close: np.ndarray, hfq_ret: np.ndarray,
|
|
39
|
+
limit_pct: float) -> tuple:
|
|
40
|
+
"""(涨停价, 跌停价) 数组——由不复权收盘 + 后复权收益率反推调后前收盘。"""
|
|
41
|
+
rc = np.asarray(raw_close, dtype=np.float64)
|
|
42
|
+
ret = np.asarray(hfq_ret, dtype=np.float64)
|
|
43
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
44
|
+
adj_prev = round_half_up_arr(
|
|
45
|
+
np.where(np.isfinite(ret) & (ret > -0.999), rc / (1.0 + ret), np.nan))
|
|
46
|
+
up = round_half_up_arr(adj_prev * (1.0 + float(limit_pct)))
|
|
47
|
+
dn = round_half_up_arr(adj_prev * (1.0 - float(limit_pct)))
|
|
48
|
+
return up, dn
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def flags_from_bars(dates: Sequence[str], raw_open, raw_high, raw_low,
|
|
52
|
+
raw_close, hfq_ret, spec: LimitSpec,
|
|
53
|
+
decimals: Optional[int] = None) -> Dict[str, np.ndarray]:
|
|
54
|
+
"""六态涨跌停标记(纯数组运算,无 IO)。
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
dates: "YYYY-MM-DD" 序列。
|
|
58
|
+
raw_*: 不复权 OHLC(判板唯一正确口径)。
|
|
59
|
+
hfq_ret: 后复权真实收益率(首日/缺值 NaN → 该日不判)。
|
|
60
|
+
spec: `limit_spec()` 的判定结果(候选幅度 + 精确档)。
|
|
61
|
+
decimals: 报价位数(None = 自动按 2 处理;ETF/转债请传 3)。
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
{col: bool数组}——up/dn × touch(盘中触及)/close(收盘封板)/open(开盘即板)。
|
|
65
|
+
"""
|
|
66
|
+
n = len(dates)
|
|
67
|
+
op = np.asarray(raw_open, dtype=np.float64)
|
|
68
|
+
hi = np.asarray(raw_high, dtype=np.float64)
|
|
69
|
+
lo = np.asarray(raw_low, dtype=np.float64)
|
|
70
|
+
cl = np.asarray(raw_close, dtype=np.float64)
|
|
71
|
+
out = {k: np.zeros(n, dtype=bool) for k in FLAG_COLS}
|
|
72
|
+
if spec.unlimited or not spec.candidates:
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
dec = int(decimals) if decimals is not None else 2
|
|
76
|
+
# 精确档容差按报价位数缩放(CENT_TOL 是「分」口径)
|
|
77
|
+
tol = CENT_TOL * (10.0 ** (2 - dec)) if dec <= 2 else CENT_TOL / (10.0 ** (dec - 2))
|
|
78
|
+
|
|
79
|
+
for up_pct, dn_pct, exact in spec.candidates:
|
|
80
|
+
up, _ = limit_prices(cl, hfq_ret, up_pct)
|
|
81
|
+
_, dn = limit_prices(cl, hfq_ret, dn_pct)
|
|
82
|
+
ok = (np.isfinite(up) & np.isfinite(dn) & np.isfinite(op)
|
|
83
|
+
& np.isfinite(hi) & np.isfinite(lo) & np.isfinite(cl))
|
|
84
|
+
if exact:
|
|
85
|
+
out["up_open"] |= ok & (np.abs(op - up) <= tol)
|
|
86
|
+
out["up_touch"] |= ok & (np.abs(hi - up) <= tol)
|
|
87
|
+
out["up_close"] |= ok & (np.abs(cl - up) <= tol)
|
|
88
|
+
out["dn_open"] |= ok & (np.abs(op - dn) <= tol)
|
|
89
|
+
out["dn_touch"] |= ok & (np.abs(lo - dn) <= tol)
|
|
90
|
+
out["dn_close"] |= ok & (np.abs(cl - dn) <= tol)
|
|
91
|
+
else:
|
|
92
|
+
out["up_open"] |= ok & (op >= up - EPS)
|
|
93
|
+
out["up_touch"] |= ok & (hi >= up - EPS)
|
|
94
|
+
out["up_close"] |= ok & (cl >= up - EPS)
|
|
95
|
+
out["dn_open"] |= ok & (op <= dn + EPS)
|
|
96
|
+
out["dn_touch"] |= ok & (lo <= dn + EPS)
|
|
97
|
+
out["dn_close"] |= ok & (cl <= dn + EPS)
|
|
98
|
+
|
|
99
|
+
for i, d in enumerate(dates): # 制度起点之前不判
|
|
100
|
+
if str(d) < LIMIT_ERA_START:
|
|
101
|
+
for k in FLAG_COLS:
|
|
102
|
+
out[k][i] = False
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def hfq_ret_on(hfq_close: np.ndarray) -> np.ndarray:
|
|
107
|
+
"""由(按日期对齐好的)后复权收盘算真实收益率;缺值处为 NaN(→ 该日不判)。
|
|
108
|
+
|
|
109
|
+
2026-09-27 随 market_common 并入时从原 dbl_common.price_limit 引擎迁入
|
|
110
|
+
(向量化判定统一在本模块,price_limit 只留标量规则)。
|
|
111
|
+
"""
|
|
112
|
+
hc = np.asarray(hfq_close, dtype=np.float64)
|
|
113
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
114
|
+
out = np.full(hc.size, np.nan)
|
|
115
|
+
out[1:] = hc[1:] / hc[:-1] - 1.0
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def board_flags(code: str, dates: Sequence[str], raw_open, raw_high,
|
|
120
|
+
raw_low, raw_close, hfq_ret, **kw) -> Dict[str, np.ndarray]:
|
|
121
|
+
"""单只标的一站式:`limit_spec` + `flags_from_bars`(常用便捷入口)。
|
|
122
|
+
|
|
123
|
+
⚠️ 一个 spec 对应**一个制度档位**——若 `dates` 跨越制度变更日(如创业板
|
|
124
|
+
2020-08-24 的 10%→20%、ST 状态变化),必须**分段调用再拼接**,否则整段
|
|
125
|
+
按单一档位判(GMD 的做法:逐段算完取并集/覆盖,别偷懒一次算完)。
|
|
126
|
+
"""
|
|
127
|
+
spec = kw.pop("spec", None) or limit_spec(
|
|
128
|
+
code, str(dates[0]) if dates else None, **kw)
|
|
129
|
+
return flags_from_bars(dates, raw_open, raw_high, raw_low, raw_close,
|
|
130
|
+
hfq_ret, spec, decimals=price_decimals(code))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
__all__ = ["FLAG_COLS", "round_half_up_arr", "limit_prices",
|
|
134
|
+
"flags_from_bars", "board_flags", "hfq_ret_on"]
|