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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""corporate_actions.py — 除权除息参考价与复权因子理论跳变(公共模块 · 纯函数)。
|
|
3
|
+
|
|
4
|
+
分红送转配股会让价格「凭空跳变」——不是涨跌。所有区分「真涨跌」与「除权跳变」
|
|
5
|
+
的系统(回测收益率、涨跌停判定、数据中心核验复权因子)都需要交易所的
|
|
6
|
+
**除权除息参考价公式**:
|
|
7
|
+
|
|
8
|
+
除权除息参考价 = (前收盘价 - 每股派息 + 配股价 × 每股配股比)
|
|
9
|
+
÷ (1 + 每股送股比 + 每股转增比 + 每股配股比)
|
|
10
|
+
|
|
11
|
+
(沪深交易所交易细则官方口径;每股派息按分红派息公告的**含税**金额折算。)
|
|
12
|
+
|
|
13
|
+
数据中心核验复权因子的用法
|
|
14
|
+
--------------------------
|
|
15
|
+
后复权价 = 不复权价 × adj_factor 在除权日保持连续 ⟺
|
|
16
|
+
|
|
17
|
+
adj_factor(新) / adj_factor(旧) ≈ 前收盘价 / 除权除息参考价
|
|
18
|
+
|
|
19
|
+
即 `factor_jump()` 给出理论跳变比,与落盘的 adj_factor 实际跳变对账
|
|
20
|
+
(容差建议 0.5%——官方因子含登记结算的取整与股本变动时点细节)。
|
|
21
|
+
⚠️ 真实分红方案对账锚点尚未接入:QDC 的 `dividend` 数据集在采集计划内,
|
|
22
|
+
落地后应补「方案 → 理论跳变 vs tushare adj_factor 实际跳变」的真实守卫;
|
|
23
|
+
在那之前本模块守卫为官方公式的数学恒等式案例,不冒充实盘对账。
|
|
24
|
+
|
|
25
|
+
诚实边界
|
|
26
|
+
--------
|
|
27
|
+
· B 股除权除息价涉及**派息币种折算**(按最后交易日的中国外汇交易中心
|
|
28
|
+
汇率折美元/港币)——汇率是运行期数据不是规则,本模块拒答(ValueError),
|
|
29
|
+
调用方自行处理;
|
|
30
|
+
· 可转债无送股/配股、派息极小额,发行人不除权——本模块对其拒答;
|
|
31
|
+
· 分红方案数据(送转派配比例)是**数据**不是规则,归数据中心供,
|
|
32
|
+
本模块只做「方案 → 价格」的纯计算。
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import math
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Union
|
|
39
|
+
|
|
40
|
+
from .boards import SecurityType, normalize_code, security_type
|
|
41
|
+
from .price_limit import round_half_up
|
|
42
|
+
|
|
43
|
+
Number = Union[int, float]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class CorporateAction:
|
|
48
|
+
"""一次分红送配事件(每股口径)。
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
cash_div: 每股派息(元,含税口径,公告每10股派X元 → X/10)。
|
|
52
|
+
bonus_per_share: 每股送股(红股)比例,10送3 → 0.3。
|
|
53
|
+
transfer_per_share: 每股资本公积转增比例,10转3 → 0.3。
|
|
54
|
+
rights_per_share: 每股配股比例,10配3 → 0.3。
|
|
55
|
+
rights_price: 配股价(元/股);有配股时必填 > 0。
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
cash_div: float = 0.0
|
|
59
|
+
bonus_per_share: float = 0.0
|
|
60
|
+
transfer_per_share: float = 0.0
|
|
61
|
+
rights_per_share: float = 0.0
|
|
62
|
+
rights_price: float = 0.0
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
bad = [f for f in ("cash_div", "bonus_per_share", "transfer_per_share",
|
|
66
|
+
"rights_per_share", "rights_price")
|
|
67
|
+
if getattr(self, f) < 0]
|
|
68
|
+
if bad:
|
|
69
|
+
raise ValueError(f"分红送配比例不能为负:{bad}")
|
|
70
|
+
if self.rights_per_share > 0 and not (self.rights_price > 0):
|
|
71
|
+
raise ValueError("有配股时 rights_price 必须 > 0")
|
|
72
|
+
|
|
73
|
+
def is_ex_event(self) -> bool:
|
|
74
|
+
"""是否构成除权除息事件(全零 = 普通日,无除权价)。"""
|
|
75
|
+
return (self.cash_div > 0 or self.bonus_per_share > 0
|
|
76
|
+
or self.transfer_per_share > 0 or self.rights_per_share > 0)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def denominator(self) -> float:
|
|
80
|
+
"""公式分母 1 + 送 + 转 + 配。"""
|
|
81
|
+
return 1.0 + self.bonus_per_share + self.transfer_per_share \
|
|
82
|
+
+ self.rights_per_share
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def from_per_ten(cash_per_10: Number = 0, bonus_per_10: Number = 0,
|
|
86
|
+
transfer_per_10: Number = 0, rights_per_10: Number = 0,
|
|
87
|
+
rights_price: Number = 0) -> CorporateAction:
|
|
88
|
+
"""按公告口径(每 10 股)构造方案——上市公司公告的惯用表达。
|
|
89
|
+
|
|
90
|
+
例:公告「10送3转2派1.5元,配3股配股价8元」→
|
|
91
|
+
from_per_ten(1.5, 3, 2, 3, 8)
|
|
92
|
+
"""
|
|
93
|
+
return CorporateAction(
|
|
94
|
+
cash_div=float(cash_per_10) / 10.0,
|
|
95
|
+
bonus_per_share=float(bonus_per_10) / 10.0,
|
|
96
|
+
transfer_per_share=float(transfer_per_10) / 10.0,
|
|
97
|
+
rights_per_share=float(rights_per_10) / 10.0,
|
|
98
|
+
rights_price=float(rights_price),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ex_right_raw(prev_close: float, action: CorporateAction) -> float:
|
|
103
|
+
"""除权除息参考价(不舍入)——公式本体。"""
|
|
104
|
+
pc = float(prev_close)
|
|
105
|
+
if not (pc > 0):
|
|
106
|
+
raise ValueError(f"前收盘价必须 > 0,收到 {pc}")
|
|
107
|
+
if action.denominator <= 0:
|
|
108
|
+
raise ValueError("分母 1+送+转+配 必须 > 0")
|
|
109
|
+
return (pc - action.cash_div
|
|
110
|
+
+ action.rights_price * action.rights_per_share) / action.denominator
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def ex_right_price(prev_close: float, action: CorporateAction, *,
|
|
114
|
+
decimals: int = 2, round_: bool = True) -> float:
|
|
115
|
+
"""除权除息参考价。
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
prev_close: 除权除息前一交易日收盘价(不复权口径)。
|
|
119
|
+
action: 分红送配方案。
|
|
120
|
+
decimals: 舍入位数——股票 2(到分,交易所口径);ETF 场内分红 3。
|
|
121
|
+
round_: False 时返回未舍入值(供理论计算)。
|
|
122
|
+
|
|
123
|
+
全零方案返回原价(无除权除息事件,调用方不必特判)。
|
|
124
|
+
"""
|
|
125
|
+
if not action.is_ex_event():
|
|
126
|
+
return float(prev_close)
|
|
127
|
+
raw = _ex_right_raw(prev_close, action)
|
|
128
|
+
return round_half_up(raw, decimals) if round_ else raw
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def factor_jump(prev_close: float, action: CorporateAction) -> float:
|
|
132
|
+
"""理论复权因子跳变比 = 前收盘价 ÷ 未舍入除权除息参考价。
|
|
133
|
+
|
|
134
|
+
用于数据中心核验:除权日 adj_factor(新)/adj_factor(旧) 应 ≈ 本值
|
|
135
|
+
(建议容差 0.5%)。未舍入参考价口径是因为官方因子按登记结算精确值
|
|
136
|
+
计算后再离散化,先舍入会引入额外误差。
|
|
137
|
+
"""
|
|
138
|
+
return float(prev_close) / _ex_right_raw(prev_close, action)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def validate_against_factor(prev_close: float, close_on_ex: float,
|
|
142
|
+
adj_prev: float, adj_new: float,
|
|
143
|
+
action: CorporateAction,
|
|
144
|
+
tol: float = 0.005) -> tuple[bool, float]:
|
|
145
|
+
"""核验落盘复权因子与分红方案是否一致(数据中心对账入口)。
|
|
146
|
+
|
|
147
|
+
逻辑:后复权价连续 ⟺ adj_new/adj_prev == prev_close/参考价(理论跳变)。
|
|
148
|
+
残差按「理论跳变 ÷ 实际跳变 - 1」计。
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
close_on_ex: 除权日不复权收盘价(仅用于日志/留痕口径,不参与判定)。
|
|
152
|
+
adj_prev / adj_new: 除权日前/后的复权因子。
|
|
153
|
+
tol: 相对容差(默认 0.5%)。
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
(是否一致, 相对残差)。实际跳变非正数时返回 (False, nan)——因子
|
|
157
|
+
非法不是「不一致」,是脏数据。
|
|
158
|
+
"""
|
|
159
|
+
actual = float(adj_new) / float(adj_prev)
|
|
160
|
+
if not (actual > 0) or math.isnan(actual):
|
|
161
|
+
return False, float("nan")
|
|
162
|
+
resid = factor_jump(prev_close, action) / actual - 1.0
|
|
163
|
+
return abs(resid) <= tol, resid
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def check_applicable(code: str) -> None:
|
|
167
|
+
"""校验品种适用性:B 股(汇率折算)与可转债(不除权)拒答。"""
|
|
168
|
+
st = security_type(normalize_code(code))
|
|
169
|
+
if st == SecurityType.B_SHARE:
|
|
170
|
+
raise ValueError("B 股除权除息价需按最后交易日汇率折算外币派息,"
|
|
171
|
+
"属运行期数据非规则,本模块拒答——调用方自行处理")
|
|
172
|
+
if st == SecurityType.CONVERTIBLE_BOND:
|
|
173
|
+
raise ValueError("可转债无送股配股、不除权,本模块不适用")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = ["CorporateAction", "from_per_ten", "ex_right_price", "factor_jump",
|
|
177
|
+
"validate_against_factor", "check_applicable"]
|
dbl_common/fees.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""fees.py — A 股交易费用:制度时间线 + 费用计算器(公共模块 · 纯函数)。
|
|
3
|
+
|
|
4
|
+
回测扣费与实盘对账必须同一套费率——各算各的会让「回测赚钱、实盘亏钱」
|
|
5
|
+
的缺口无法解释。本模块把**随政策变化的费率**做成时间线(date → rate),
|
|
6
|
+
把**随券商变化的佣金**做成显式参数(不藏在默认值里)。
|
|
7
|
+
|
|
8
|
+
制度时间线(财政部/中登/交易所公告,锚点事件为高置信度)
|
|
9
|
+
--------------------------------------------------------
|
|
10
|
+
印花税(对卖方征收的证券交易印花税):
|
|
11
|
+
· 2001-11-16 起 0.2%(双边改单边? — 此前多次调整,**2001 年前历史请查公告**,
|
|
12
|
+
本模块 2001-11-16 之前的查询返回 None 拒答);
|
|
13
|
+
· 2005-01-24 起 0.1%;2007-05-30 起 0.3%;2008-04-24 起 0.1%;
|
|
14
|
+
· 2008-09-19 起 改**单边**(仅卖出)0.1%;
|
|
15
|
+
· 2023-08-28 起 减半为**单边** 0.05%。
|
|
16
|
+
免征:ETF/LOF 基金份额交易、债券(含可转债)、回购。
|
|
17
|
+
|
|
18
|
+
过户费(中国结算,双边):
|
|
19
|
+
· 0.002% → **2022-04-29 起下调 50% 至 0.001%**(中登公告口径);
|
|
20
|
+
· 历史上仅沪市股票收取、深市 2015 年前后纳入(精确日以中登公告为准——
|
|
21
|
+
回测 2015 年前后成本差异 < 万分之二,影响可忽略但如实标注);
|
|
22
|
+
· 仅股票/B 股收;基金、转债不收。
|
|
23
|
+
|
|
24
|
+
佣金(券商):双边、成交金额的 0.03% 封顶(2015 年 8 月起沪深统一从
|
|
25
|
+
0.3% 降至 0.03%——**注意封顶值本身也是政策变量**)、每笔最低 5 元
|
|
26
|
+
(各券商可下浮)。本模块不设「默认佣金」——调用方必须显式传,
|
|
27
|
+
模拟盘/实盘按实际开户费率,回测建议做敏感性区间(万1/万2.5/万3)。
|
|
28
|
+
|
|
29
|
+
经手费/证管费/风险金:合计约万分之几、随交易所公告微调,本模块不硬编码
|
|
30
|
+
(计算器留 `extra_rate` 参数),避免拿不确定的数冒充确定。
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Optional, Sequence, Tuple
|
|
36
|
+
|
|
37
|
+
from .boards import SecurityType, security_type
|
|
38
|
+
|
|
39
|
+
# ── 印花税时间线((生效日, 边, 税率);边 'both'/'sell';None=拒答区间)────
|
|
40
|
+
_STAMP_TAX_TIMELINE: Tuple[Tuple[str, str, float], ...] = (
|
|
41
|
+
("2001-11-16", "both", 0.002),
|
|
42
|
+
("2005-01-24", "both", 0.001),
|
|
43
|
+
("2007-05-30", "both", 0.003),
|
|
44
|
+
("2008-04-24", "both", 0.001),
|
|
45
|
+
("2008-09-19", "sell", 0.001),
|
|
46
|
+
("2023-08-28", "sell", 0.0005),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# ── 过户费时间线(双边费率)────────────────────────────────────────────
|
|
50
|
+
_TRANSFER_FEE_TIMELINE: Tuple[Tuple[str, float], ...] = (
|
|
51
|
+
("1990-01-01", 0.00002),
|
|
52
|
+
("2022-04-29", 0.00001),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
_COMMISSION_CAP = 0.0003 # 2015-08-01 起佣金封顶 0.03%(此前 0.3%)
|
|
56
|
+
_COMMISSION_CAP_EARLY = 0.003 # 2015-08-01 前
|
|
57
|
+
_COMMISSION_CAP_START = "2015-08-01"
|
|
58
|
+
_COMMISSION_MIN_DEFAULT = 5.0 # 每笔最低佣金(常见 5 元,以券商协议为准)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stamp_tax(date: Optional[str]) -> Optional[Tuple[str, float]]:
|
|
62
|
+
"""某日的印花税档位 → (征收边, 税率);2001-11-16 前返回 None(拒答,不猜)。
|
|
63
|
+
|
|
64
|
+
date=None 按现行(单边卖出 0.05%)。
|
|
65
|
+
"""
|
|
66
|
+
d = str(date)[:10] if date else "9999-12-31"
|
|
67
|
+
cur: Optional[Tuple[str, float]] = None
|
|
68
|
+
for start, side, rate in _STAMP_TAX_TIMELINE:
|
|
69
|
+
if d >= start:
|
|
70
|
+
cur = (side, rate)
|
|
71
|
+
return cur
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def transfer_fee_rate(date: Optional[str]) -> float:
|
|
75
|
+
"""某日过户费双边费率(股票/B股适用;基金转债不收——见 calc_fees)。"""
|
|
76
|
+
d = str(date)[:10] if date else "9999-12-31"
|
|
77
|
+
rate = _TRANSFER_FEE_TIMELINE[0][1]
|
|
78
|
+
for start, r in _TRANSFER_FEE_TIMELINE:
|
|
79
|
+
if d >= start:
|
|
80
|
+
rate = r
|
|
81
|
+
return rate
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def commission_cap(date: Optional[str]) -> float:
|
|
85
|
+
"""佣金法定封顶(回测里给「夸张佣金」做合法性钳制用)。"""
|
|
86
|
+
d = str(date)[:10] if date else "9999-12-31"
|
|
87
|
+
return _COMMISSION_CAP if d >= _COMMISSION_CAP_START else _COMMISSION_CAP_EARLY
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class FeeResult:
|
|
92
|
+
"""一笔成交的费用明细(金额单位:元;逐项拆开以便对账)。"""
|
|
93
|
+
|
|
94
|
+
amount: float # 成交金额(股数 × 价格)
|
|
95
|
+
commission: float # 佣金(双边各收一次——调用方按 buy/sell 各算一笔)
|
|
96
|
+
stamp_tax: float # 印花税(只对征收边非零)
|
|
97
|
+
transfer_fee: float # 过户费
|
|
98
|
+
extra_fee: float # 经手费/证管费等(调用方显式传比率才有)
|
|
99
|
+
total: float
|
|
100
|
+
breakdown: str # 人读得懂的分项(进留痕,不许只给总数)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def total_rate(self) -> float:
|
|
104
|
+
return self.total / self.amount if self.amount else 0.0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def calc_fees(code: str, qty: float, price: float, side: str, *,
|
|
108
|
+
date: Optional[str] = None,
|
|
109
|
+
commission_rate: Optional[float] = None,
|
|
110
|
+
commission_min: float = _COMMISSION_MIN_DEFAULT,
|
|
111
|
+
extra_rate: float = 0.0,
|
|
112
|
+
stype: Optional[SecurityType] = None) -> FeeResult:
|
|
113
|
+
"""计算一笔竞价成交的费用。
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
qty/price: 成交数量与单价(数量单位随品种:股/份/张)。
|
|
117
|
+
side: 'buy' / 'sell'。
|
|
118
|
+
date: 成交日(费率时间线判定;None = 现行)。
|
|
119
|
+
commission_rate: 佣金率(**必传**,如 0.00025=万2.5;None 抛错——
|
|
120
|
+
不设默认是故意的:回测/实盘费率不同,藏默认值就是埋雷)。
|
|
121
|
+
extra_rate: 经手+证管+风险金等附加费率合计(不确定就传 0 并披露)。
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
FeeResult(逐项 + 总额 + 可读明细)。
|
|
125
|
+
"""
|
|
126
|
+
if commission_rate is None:
|
|
127
|
+
raise ValueError("commission_rate 必须显式传(万2.5 → 0.00025);"
|
|
128
|
+
"不设默认是防止回测/实盘费率混用")
|
|
129
|
+
if commission_rate > commission_cap(date):
|
|
130
|
+
raise ValueError(f"佣金率 {commission_rate} 超过封顶 {commission_cap(date)}")
|
|
131
|
+
st = security_type(code, stype)
|
|
132
|
+
amount = float(qty) * float(price)
|
|
133
|
+
if amount <= 0:
|
|
134
|
+
raise ValueError(f"成交金额必须为正(qty={qty}, price={price})")
|
|
135
|
+
|
|
136
|
+
commission = max(amount * commission_rate, commission_min)
|
|
137
|
+
# 基金/债券/回购免印花税
|
|
138
|
+
stamp = 0.0
|
|
139
|
+
if st in (SecurityType.STOCK, SecurityType.B_SHARE):
|
|
140
|
+
tier = stamp_tax(date)
|
|
141
|
+
if tier is None:
|
|
142
|
+
raise ValueError(f"{date} 早于 2001-11-16:印花税历史档位未编码,"
|
|
143
|
+
"请查财政部公告后显式计算(不猜)")
|
|
144
|
+
tside, rate = tier
|
|
145
|
+
if (tside == "both") or (tside == "sell" and side == "sell"):
|
|
146
|
+
stamp = amount * rate
|
|
147
|
+
# 过户费:股票/B股双边
|
|
148
|
+
transfer = amount * transfer_fee_rate(date) \
|
|
149
|
+
if st in (SecurityType.STOCK, SecurityType.B_SHARE) else 0.0
|
|
150
|
+
extra = amount * extra_rate
|
|
151
|
+
|
|
152
|
+
total = commission + stamp + transfer + extra
|
|
153
|
+
parts = [f"佣金 {commission:.2f}(率{commission_rate:.5f},最低{commission_min:.0f})"]
|
|
154
|
+
if stamp:
|
|
155
|
+
parts.append(f"印花税 {stamp:.2f}({side}边)")
|
|
156
|
+
if transfer:
|
|
157
|
+
parts.append(f"过户费 {transfer:.2f}")
|
|
158
|
+
if extra:
|
|
159
|
+
parts.append(f"附加 {extra:.2f}(率{extra_rate:.5f})")
|
|
160
|
+
return FeeResult(amount=amount, commission=commission, stamp_tax=stamp,
|
|
161
|
+
transfer_fee=transfer, extra_fee=extra, total=total,
|
|
162
|
+
breakdown=";".join(parts))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def stamp_tax_timeline() -> Sequence[Tuple[str, str, float]]:
|
|
166
|
+
"""公开时间线(给报告/前端渲染,不许调用方各抄一份)。"""
|
|
167
|
+
return _STAMP_TAX_TIMELINE
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = ["FeeResult", "calc_fees", "stamp_tax", "transfer_fee_rate",
|
|
171
|
+
"commission_cap", "stamp_tax_timeline"]
|
dbl_common/halts.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""halts.py — 盘中临时停牌规则(公共模块 · 纯函数)。
|
|
3
|
+
|
|
4
|
+
回测撮合与实盘风控都必须回答:「这只票今天会不会停牌、停多久、几点回来」。
|
|
5
|
+
停牌时段内**价格不存在**——按逐笔/分钟数据撮合时跳过停牌窗口,否则回测
|
|
6
|
+
会「成交在真实世界里不可能成交的价位」(与 2016 熔断同一性质的坑)。
|
|
7
|
+
|
|
8
|
+
制度事实表(交易所交易细则,2026 口径)
|
|
9
|
+
--------------------------------------
|
|
10
|
+
· 科创板(2019-07-22 起)新股上市初期(无涨跌幅交易日):
|
|
11
|
+
盘中成交价较**当日开盘价**首次上涨/下跌达 30%、60% → 各停牌 10 分钟;
|
|
12
|
+
停牌时段跨越 14:57 的于 14:57 复牌(进入收盘集合竞价/尾段)。
|
|
13
|
+
· 创业板(2020-08-24 注册制起,同口径):新股首 5 个交易日 30%/60% → 各 10 分钟。
|
|
14
|
+
· 北交所(2021-11-15 起)新股上市首日:较开盘价 30%/60% → 各 10 分钟。
|
|
15
|
+
· 主板新股首日(2014-01-01 ~ 2023-02-16 的 +44%/-36% 时代):
|
|
16
|
+
较开盘价首次涨/跌超 10% → 停 30 分钟;首次涨/跌超 20% → 停牌至 14:57。
|
|
17
|
+
(2023-02-17 全面注册制后主板新股首 5 日无涨跌幅、**无盘中临停**,
|
|
18
|
+
改为申报价格范围管理——细则以交易所规定为准。)
|
|
19
|
+
· 可转债(2022-08-01 沪深统一):盘中成交价较**前收盘**(上市首日较发行价)
|
|
20
|
+
首次涨/跌达 20% → 停 30 分钟;达 30% → 停牌至 14:57,复牌进入收盘集合竞价。
|
|
21
|
+
⚠️ 2022-08-01 之前的转债临停细则各地所多次修订(2020 年深市 20%/30% 档
|
|
22
|
+
等口径)——历史回测请按当期交易所公告核对,本模块对早期**拒答**(None)。
|
|
23
|
+
|
|
24
|
+
诚实边界
|
|
25
|
+
--------
|
|
26
|
+
· 日常(非新股初期)股票在涨跌幅制度内**无盘中临停**——返回 None 表示
|
|
27
|
+
「无此规则」,与「规则未知」区分开(reason 里写清)。
|
|
28
|
+
· 停牌窗口的「10 分钟」按交易所钟面时间计(跨午休顺延,见 `resume_time`)。
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import datetime as _dt
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import Optional, Sequence, Tuple
|
|
35
|
+
|
|
36
|
+
from .boards import Board, SecurityType, board_of, normalize_code, security_type
|
|
37
|
+
from .price_limit import GEM_20PCT_START, MAIN_IPO_4436_START, MAIN_REG_START, \
|
|
38
|
+
_days_listed
|
|
39
|
+
|
|
40
|
+
_CB_UNIFIED_START = "2022-08-01" # 可转债沪深统一临停规则
|
|
41
|
+
_LUNCH_START = _dt.time(11, 30)
|
|
42
|
+
_LUNCH_END = _dt.time(13, 0)
|
|
43
|
+
_CLOSE_CAP = _dt.time(14, 57)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class HaltRule:
|
|
48
|
+
"""盘中临时停牌规则(值对象)。
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
stops: [(阈值, 停牌分钟数 or None), ...]——阈值=较基准价涨跌幅
|
|
52
|
+
(涨跌对称,如 0.30 表示 ±30%);分钟数 None = 停牌至 14:57。
|
|
53
|
+
basis: 触发基准 'open'(当日开盘价)/ 'prev_close'(前收盘)/
|
|
54
|
+
'issue_price'(发行价,转债首日)。
|
|
55
|
+
reason: 规则出处(含生效日期),进留痕。
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
stops: Tuple[Tuple[float, Optional[int]], ...]
|
|
59
|
+
basis: str
|
|
60
|
+
reason: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def intraday_halt_rule(code: str, date: Optional[str], *,
|
|
64
|
+
days_listed: Optional[int] = None,
|
|
65
|
+
listing_date: Optional[str] = None,
|
|
66
|
+
stype: Optional[SecurityType] = None) -> Optional[HaltRule]:
|
|
67
|
+
"""判定 `code` 在 `date` 的盘中临时停牌规则(None = 无/未知,见 reason 语义)。
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
days_listed: 交易日序号(1=上市首日);只有上市日时传 listing_date
|
|
71
|
+
按自然日近似(与 price_limit._days_listed 同口径)。
|
|
72
|
+
"""
|
|
73
|
+
c = normalize_code(code)
|
|
74
|
+
st = security_type(c, stype)
|
|
75
|
+
d = str(date)[:10] if date else None
|
|
76
|
+
dl = _days_listed(d, listing_date, days_listed)
|
|
77
|
+
|
|
78
|
+
# ---- 可转债:2022-08-01 沪深统一(首日较发行价,其余较前收盘) ----
|
|
79
|
+
if st == SecurityType.CONVERTIBLE_BOND:
|
|
80
|
+
if d is None or d >= _CB_UNIFIED_START:
|
|
81
|
+
basis = "issue_price" if dl == 1 else "prev_close"
|
|
82
|
+
return HaltRule(((0.20, 30), (0.30, None)), basis,
|
|
83
|
+
f"可转债 2022-08-01 起盘中较{('发行价' if dl == 1 else '前收盘')}"
|
|
84
|
+
"首次±20%停30分钟、±30%停至14:57")
|
|
85
|
+
return None # 早期细则各地所多次修订,拒答(见模块 docstring)
|
|
86
|
+
|
|
87
|
+
if st != SecurityType.STOCK:
|
|
88
|
+
return None # 基金/ETF/指数/回购:无盘中临停制度
|
|
89
|
+
|
|
90
|
+
board = board_of(c, st)
|
|
91
|
+
|
|
92
|
+
# ---- 科创板:新股无涨跌幅期(首 5 交易日)30%/60% 各 10 分钟 ----
|
|
93
|
+
if board == Board.STAR:
|
|
94
|
+
if dl is not None and dl <= 5:
|
|
95
|
+
return HaltRule(((0.30, 10), (0.60, 10)), "open",
|
|
96
|
+
"科创板新股首5个交易日:较开盘价±30%/±60%各停牌10分钟")
|
|
97
|
+
return None # 日常 ±20% 涨跌幅内无盘中临停
|
|
98
|
+
|
|
99
|
+
# ---- 北交所:新股首日 30%/60% 各 10 分钟 ----
|
|
100
|
+
if board == Board.BSE:
|
|
101
|
+
if dl is not None and dl == 1:
|
|
102
|
+
return HaltRule(((0.30, 10), (0.60, 10)), "open",
|
|
103
|
+
"北交所新股上市首日:较开盘价±30%/±60%各停牌10分钟")
|
|
104
|
+
return None # 日常 ±30% 涨跌幅内无盘中临停
|
|
105
|
+
|
|
106
|
+
# ---- 创业板:注册制后新股首 5 交易日 30%/60% 各 10 分钟 ----
|
|
107
|
+
if board == Board.GEM:
|
|
108
|
+
if d is not None and d >= GEM_20PCT_START and dl is not None and dl <= 5:
|
|
109
|
+
return HaltRule(((0.30, 10), (0.60, 10)), "open",
|
|
110
|
+
"创业板注册制新股首5个交易日:较开盘价±30%/±60%各停牌10分钟")
|
|
111
|
+
if d is not None and d < GEM_20PCT_START:
|
|
112
|
+
return _main_ipo_halt(d, dl) # 注册制前与主板同口径
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
# ---- 主板 ----
|
|
116
|
+
if board in (Board.MAIN_SH, Board.MAIN_SZ):
|
|
117
|
+
return _main_ipo_halt(d, dl)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _main_ipo_halt(date: Optional[str], dl: Optional[int]) -> Optional[HaltRule]:
|
|
122
|
+
"""主板(及注册制前创业板)新股首日临停。
|
|
123
|
+
|
|
124
|
+
· 2014-01-01 ~ 2023-02-16(+44%/-36% 时代):较开盘价首次±10% 停 30 分钟、
|
|
125
|
+
首次±20% 停牌至 14:57;
|
|
126
|
+
· 2014 之前:早期临停细则多次修订,拒答(None);
|
|
127
|
+
· 2023-02-17(全面注册制)后:新股首 5 日无涨跌幅**也无盘中临停**。
|
|
128
|
+
· 非新股:无盘中临停。
|
|
129
|
+
"""
|
|
130
|
+
if dl is None or date is None or dl != 1:
|
|
131
|
+
return None
|
|
132
|
+
if date < MAIN_IPO_4436_START:
|
|
133
|
+
return None # 早期细则请按当期公告核对,不猜
|
|
134
|
+
if date >= MAIN_REG_START:
|
|
135
|
+
return None # 注册制后无盘中临停(申报价格范围管理)
|
|
136
|
+
return HaltRule(((0.10, 30), (0.20, None)), "open",
|
|
137
|
+
"主板新股首日(2014-01-01~2023-02-16):较开盘价首次"
|
|
138
|
+
"±10%停30分钟、±20%停牌至14:57")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── 停牌窗口推算 ────────────────────────────────────────────────────────
|
|
142
|
+
def resume_time(trigger_time, minutes: Optional[int]) -> Optional[str]:
|
|
143
|
+
"""停牌触发时刻 + 时长 → 复牌时刻 "HH:MM"。
|
|
144
|
+
|
|
145
|
+
· 时长 None → 直接 14:57(停牌至收盘集合竞价前的固定复牌点);
|
|
146
|
+
· 钟面 10/30 分钟**跨午休顺延 90 分钟**(11:30-13:00 不是交易时间);
|
|
147
|
+
· 跨越 14:57 一律截到 14:57(交易所口径:临停跨越 14:57 的于 14:57 复牌)。
|
|
148
|
+
"""
|
|
149
|
+
t = _to_time(trigger_time)
|
|
150
|
+
cur = _dt.datetime(2000, 1, 1, t.hour, t.minute)
|
|
151
|
+
if minutes is None:
|
|
152
|
+
return "14:57"
|
|
153
|
+
end = cur + _dt.timedelta(minutes=int(minutes))
|
|
154
|
+
# 跨午休:停牌终点落在 11:30~13:00 → 顺延到午后同偏移
|
|
155
|
+
if _LUNCH_START <= end.time() < _LUNCH_END or end.time() == _LUNCH_START:
|
|
156
|
+
end = _dt.datetime.combine(end.date(), _LUNCH_END) + \
|
|
157
|
+
_dt.timedelta(minutes=(end - _dt.datetime.combine(
|
|
158
|
+
end.date(), _LUNCH_START)).total_seconds() // 60)
|
|
159
|
+
if end.time() >= _CLOSE_CAP:
|
|
160
|
+
return "14:57"
|
|
161
|
+
return end.strftime("%H:%M")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def halt_price(trigger_basis_price: float, threshold: float,
|
|
165
|
+
direction: str) -> float:
|
|
166
|
+
"""触发临停的价位(供撮合引擎比对盘中价)。direction='up'/'down'。"""
|
|
167
|
+
base = float(trigger_basis_price)
|
|
168
|
+
return base * (1.0 + float(threshold)) if direction == "up" \
|
|
169
|
+
else base * (1.0 - float(threshold))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _to_time(x) -> _dt.time:
|
|
173
|
+
if isinstance(x, _dt.datetime):
|
|
174
|
+
return x.time()
|
|
175
|
+
if isinstance(x, _dt.time):
|
|
176
|
+
return x
|
|
177
|
+
s = str(x).strip()
|
|
178
|
+
hh, mm = s.split(":")[:2]
|
|
179
|
+
return _dt.time(int(hh), int(mm))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = ["HaltRule", "intraday_halt_rule", "resume_time", "halt_price"]
|
dbl_common/io_utils.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
dbl_common.io_utils — 原子写文件工具(数据/回测/实盘共用)
|
|
4
|
+
===========================================================
|
|
5
|
+
|
|
6
|
+
溯源:global_market_dashboard app/shared/io_utils.py →
|
|
7
|
+
quant_data_center/common/io_utils.py → 2026-09-26 抽入本独立库(内容不变)。
|
|
8
|
+
|
|
9
|
+
为什么需要它:凡是「后台任务/调度器会重写、且可能被 API 直接读取或 serve」的
|
|
10
|
+
数据文件(JSON / JSONL / CSV / 状态文件等),如果用裸 ``path.write_text(...)``
|
|
11
|
+
写目标文件,**写入过程中**(尤其大文件)文件已存在但内容截断 —— 此刻任何一个
|
|
12
|
+
HTTP 请求进来都会读到半截内容,前端 ``JSON.parse`` 抛 SyntaxError,整页数据挂掉。
|
|
13
|
+
|
|
14
|
+
解法:先写**同目录**临时文件(同分区才能 rename),写完 ``os.replace``
|
|
15
|
+
原子替换(Windows 的 os.replace 底层是 MoveFileEx MOVEFILE_REPLACE_EXISTING,
|
|
16
|
+
对目标读者而言要么看到旧完整文件、要么看到新完整文件,永无截断中间态)。
|
|
17
|
+
|
|
18
|
+
使用口径:凡是「后台重写 + 会被并发读取」的文件一律用本模块,不许裸 write_text。
|
|
19
|
+
(写「全新唯一文件名」的场景——如 Parquet part 文件——没有读者冲突,
|
|
20
|
+
自身 tmp+replace 或直接写都可以,无需用本模块。)
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
# Windows 上目标文件正被其他句柄打开(如 API 正在读)时,os.replace 抛
|
|
29
|
+
# WinError 5(拒绝访问)。读者只打开几十毫秒,短重试即可成功。
|
|
30
|
+
_REPLACE_RETRIES = 10
|
|
31
|
+
_REPLACE_BACKOFF = 0.3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _atomic_write(path: Path, write_fn) -> None:
|
|
35
|
+
"""tmp 同目录 → write_fn(tmp) → os.replace;失败清理 tmp 并抛原异常。"""
|
|
36
|
+
p = Path(path)
|
|
37
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
tmp = p.with_name(p.name + ".tmp")
|
|
39
|
+
try:
|
|
40
|
+
write_fn(tmp)
|
|
41
|
+
for i in range(_REPLACE_RETRIES):
|
|
42
|
+
try:
|
|
43
|
+
os.replace(tmp, p)
|
|
44
|
+
return
|
|
45
|
+
except PermissionError:
|
|
46
|
+
if i == _REPLACE_RETRIES - 1:
|
|
47
|
+
raise
|
|
48
|
+
time.sleep(_REPLACE_BACKOFF)
|
|
49
|
+
except Exception:
|
|
50
|
+
try:
|
|
51
|
+
tmp.unlink(missing_ok=True)
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def atomic_write_text(path: Path | str, text: str, *, encoding: str = "utf-8") -> None:
|
|
58
|
+
"""原子写文本文件:读者永不见截断中间态。
|
|
59
|
+
|
|
60
|
+
失败语义:tmp 写失败 / 替换重试耗尽 → 抛原异常(目标文件保持旧内容不动,
|
|
61
|
+
绝不留下半截文件);tmp 会被尽力清理。
|
|
62
|
+
Windows 语义:替换撞上「读者正打开目标文件」会 PermissionError,自动短重试
|
|
63
|
+
(_REPLACE_RETRIES 次 × _REPLACE_BACKOFF 秒)。
|
|
64
|
+
"""
|
|
65
|
+
_atomic_write(Path(path), lambda t: t.write_text(text, encoding=encoding))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def atomic_write_bytes(path: Path | str, data: bytes) -> None:
|
|
69
|
+
"""原子写二进制文件,语义同 :func:`atomic_write_text`。"""
|
|
70
|
+
_atomic_write(Path(path), lambda t: t.write_bytes(data))
|