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/lots.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""lots.py — 申报数量规则(整手/零股/递增单位)(公共模块 · 纯函数)。
|
|
3
|
+
|
|
4
|
+
回测下单撮合、模拟盘委托校验、实盘报单风控共用。规则错了会导致
|
|
5
|
+
「回测能成交、实盘被拒单」的假象。
|
|
6
|
+
|
|
7
|
+
制度事实表(交易所竞价交易细则,2026 口径)
|
|
8
|
+
------------------------------------------
|
|
9
|
+
· 沪深主板 / 创业板股票:
|
|
10
|
+
买入 ≥ 100 股且为 100 股整数倍;卖出可为零股(不足 100 股部分
|
|
11
|
+
**必须一次性申报卖出**,且一次性卖出后不得再有该股持仓申报零股)。
|
|
12
|
+
· 科创板:≥ 200 股,以 **1 股为单位递增**(限价与市价申报同);卖出时
|
|
13
|
+
余额不足 200 股的部分一次性申报卖出。
|
|
14
|
+
· 北交所:≥ 100 股,以 **1 股为单位递增**;卖出余额不足 100 股部分一次性卖出。
|
|
15
|
+
· 场内基金(ETF/LOF):≥ 100 份且 100 份整数倍(细则以交易所现行规定为准)。
|
|
16
|
+
· 可转债:≥ 10 张(1 手),以 1 张为单位递增。
|
|
17
|
+
· 大宗交易/申赎另有规则(不在竞价口径内,本模块不管)。
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Optional, Tuple
|
|
22
|
+
|
|
23
|
+
from .boards import Board, SecurityType, board_of, security_type
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def lot_rule(code: str, *, stype: Optional[SecurityType] = None) -> dict:
|
|
27
|
+
"""申报数量规则档位。
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
{"min_buy": int, "increment": int, "unit": "股"/"份"/"张",
|
|
31
|
+
"sell_lot_free": bool, "reason": str}
|
|
32
|
+
`sell_lot_free` = 卖出是否允许零股(不足一手部分须一次性卖出)。
|
|
33
|
+
"""
|
|
34
|
+
st = security_type(code, stype)
|
|
35
|
+
if st == SecurityType.STOCK:
|
|
36
|
+
board = board_of(code, st)
|
|
37
|
+
if board == Board.STAR:
|
|
38
|
+
return {"min_buy": 200, "increment": 1, "unit": "股",
|
|
39
|
+
"sell_lot_free": True,
|
|
40
|
+
"reason": "科创板 ≥200 股、1 股递增;卖出零股一次性"}
|
|
41
|
+
if board == Board.BSE:
|
|
42
|
+
return {"min_buy": 100, "increment": 1, "unit": "股",
|
|
43
|
+
"sell_lot_free": True,
|
|
44
|
+
"reason": "北交所 ≥100 股、1 股递增;卖出零股一次性"}
|
|
45
|
+
return {"min_buy": 100, "increment": 100, "unit": "股",
|
|
46
|
+
"sell_lot_free": True,
|
|
47
|
+
"reason": "主板/创业板 买入 100 股整数倍;卖出零股一次性"}
|
|
48
|
+
if st in (SecurityType.ETF, SecurityType.LOF, SecurityType.FUND_OTHER):
|
|
49
|
+
return {"min_buy": 100, "increment": 100, "unit": "份",
|
|
50
|
+
"sell_lot_free": False,
|
|
51
|
+
"reason": "场内基金 100 份整数倍"}
|
|
52
|
+
if st == SecurityType.CONVERTIBLE_BOND:
|
|
53
|
+
return {"min_buy": 10, "increment": 1, "unit": "张",
|
|
54
|
+
"sell_lot_free": True,
|
|
55
|
+
"reason": "可转债 10 张(1手)起、1 张递增"}
|
|
56
|
+
if st == SecurityType.B_SHARE:
|
|
57
|
+
return {"min_buy": 100, "increment": 100, "unit": "股",
|
|
58
|
+
"sell_lot_free": True,
|
|
59
|
+
"reason": "B 股按 100 股整数倍(以交易所 B 股细则为准)"}
|
|
60
|
+
return {"min_buy": 100, "increment": 100, "unit": "股",
|
|
61
|
+
"sell_lot_free": True,
|
|
62
|
+
"reason": f"未知品种保守按 100 整数倍({code})"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def validate_qty(code: str, qty: int, side: str, *,
|
|
66
|
+
position: Optional[int] = None,
|
|
67
|
+
stype: Optional[SecurityType] = None) -> Tuple[bool, str]:
|
|
68
|
+
"""校验一笔竞价委托的数量合法性。
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
qty: 申报数量(正整数)。
|
|
72
|
+
side: 'buy' / 'sell'。
|
|
73
|
+
position: 卖出时的当前持仓(允许「全部零股一次性卖出」校验);
|
|
74
|
+
None = 不校验持仓约束,仅校验数量档位。
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
(ok, reason)——reason 进拒单留痕,不许静默截断数量。
|
|
78
|
+
"""
|
|
79
|
+
rule = lot_rule(code, stype=stype)
|
|
80
|
+
q = int(qty)
|
|
81
|
+
if q <= 0:
|
|
82
|
+
return False, f"申报数量必须为正整数(收到 {qty})"
|
|
83
|
+
min_buy, inc = rule["min_buy"], rule["increment"]
|
|
84
|
+
if side == "buy":
|
|
85
|
+
if q < min_buy:
|
|
86
|
+
return False, f"买入不得少于 {min_buy} {rule['unit']}"
|
|
87
|
+
if (q - min_buy) % inc != 0 and inc > 1:
|
|
88
|
+
return False, (f"买入须为 {min_buy}+{inc}×n 的整数倍"
|
|
89
|
+
f"(收到 {q} {rule['unit']})")
|
|
90
|
+
if inc == 1 and q < min_buy:
|
|
91
|
+
return False, f"买入不得少于 {min_buy} {rule['unit']}"
|
|
92
|
+
return True, rule["reason"]
|
|
93
|
+
# 卖出
|
|
94
|
+
if rule["sell_lot_free"]:
|
|
95
|
+
if position is not None:
|
|
96
|
+
if q > position:
|
|
97
|
+
return False, f"卖出数量超过持仓(want {q} > have {position})"
|
|
98
|
+
if q < position and q < min_buy:
|
|
99
|
+
return False, (f"零股须一次性卖出:持仓 {position},"
|
|
100
|
+
f"申报 {q} < {min_buy} 且未覆盖全部持仓")
|
|
101
|
+
if q == position:
|
|
102
|
+
return True, rule["reason"] # 整仓卖出永远合法(含零股整仓)
|
|
103
|
+
return True, rule["reason"] # 不校验持仓时只档位判
|
|
104
|
+
# 基金类:整百份
|
|
105
|
+
if q % 100 != 0:
|
|
106
|
+
return False, f"申报须为 100 {rule['unit']} 整数倍(收到 {q})"
|
|
107
|
+
return True, rule["reason"]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["lot_rule", "validate_qty"]
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""price_limit.py — A 股涨跌幅规则(公共模块核心)—— 唯一真相源。
|
|
3
|
+
|
|
4
|
+
溯源:global_market_dashboard `app/datasources/price_limit.py`(200+ 轮实盘/回测
|
|
5
|
+
验证)→ 独立改写为 market_common 规则库全集 → **2026-09-27 随 market_common
|
|
6
|
+
整体并入 dbl_common**(两库合一,本模块为超集版;原 dbl_common 简化版
|
|
7
|
+
`limit_spec`(返回候选元组)与向量化引擎已统一:规则在本模块,引擎在
|
|
8
|
+
`dbl_common.vec`)。回测系统、实盘系统、模拟盘系统直接 import 本模块,
|
|
9
|
+
不许各写一份贴板逻辑(分叉 = 口径漂移 = 回测作弊)。
|
|
10
|
+
|
|
11
|
+
制度时间线(交易所公开规则,**含历史变更,不许一律按 10% 算**)
|
|
12
|
+
----------------------------------------------------------------
|
|
13
|
+
· 1996-12-16 之前:A 股**无**涨跌停制度(回测跨这段必须 unlimited)。
|
|
14
|
+
· 1996-12-16 起:主板 ±10%(沪深统一)。
|
|
15
|
+
· 1998-04 起:ST/*ST 股 ±5%(特别处理制度实施;主板口径)。
|
|
16
|
+
⚠️ ST 是**交易状态**不是代码段——无逐日历史状态时用「候选集 + 精确贴板」
|
|
17
|
+
(见下),绝不全史硬套 5%(会把 ST 之前的普通大涨日误判成涨停)。
|
|
18
|
+
· 2009-10-30 创业板开市:±10%;2020-08-24 注册制改革:±20%(存量同步切换)。
|
|
19
|
+
· 2019-07-22 科创板开市:±20%(开市即 20%),新股首 5 个交易日不设涨跌幅。
|
|
20
|
+
· 2020-08-24 起创业板新股:首 5 个交易日不设涨跌幅。
|
|
21
|
+
· 2021-11-15 北交所开市:±30%,新股上市首日不设涨跌幅。
|
|
22
|
+
· 2023-02-17 全面注册制(主板):主板新股首 5 个交易日不设涨跌幅;
|
|
23
|
+
此前(2014-01-01 ~ 2023-02-16)主板新股首日有效申报范围为发行价的
|
|
24
|
+
64%~144%(即 +44% / -36%,**非对称**)。
|
|
25
|
+
· 可转债:2022-08-01 起沪深统一 ±20%(上市首日 +57.3% / -43.3%);
|
|
26
|
+
此前无涨跌幅限制(仅临时停牌机制)。
|
|
27
|
+
· ETF:股票 ETF ±10%;创业板/科创板 ETF ±20%;跨境 ETF ±10%;
|
|
28
|
+
债券/货币 ETF 无涨跌幅;商品(黄金等)ETF ±10%(细分品类以交易所细则为准)。
|
|
29
|
+
· 指数:无涨跌幅。国债逆回购:无涨跌幅。
|
|
30
|
+
· 创业板注册制后其 ST 股同为 ±20%(不再是 5%);科创板不设 ST。
|
|
31
|
+
· B 股:±10%(ST B 股 ±5%)。
|
|
32
|
+
|
|
33
|
+
涨跌停价计算(交易所官方口径)
|
|
34
|
+
------------------------------
|
|
35
|
+
板价 = round_half_up( 前收盘 × (1 ± 幅度) , 2 ) ← 四舍五入到分
|
|
36
|
+
⚠️ **不是** Python `round()`(银行家舍入,2.5→2):边界日会差 1 分钱,
|
|
37
|
+
把「收盘封板」判成「没封板」(GMD 实测:福耀 2015-07-09 涨停价 13.59,
|
|
38
|
+
`round(13.585, 2)` 给 13.58 → 判错)。ETF/可转债报价到 0.001,decimals=3。
|
|
39
|
+
|
|
40
|
+
候选集设计(GMD 血泪经验,务必保留语义)
|
|
41
|
+
----------------------------------------
|
|
42
|
+
`limit_spec()` 返回 `LimitSpec`(candidates 可能多对,**up/dn 允许非对称**,
|
|
43
|
+
如主板旧新股首日 +44%/-36%)。ST 历史状态拿不准时同时给 (10%,10%) 与
|
|
44
|
+
(5%,5%) 两档,5% 档要求「恰好贴板」(`exact=True`)才成立——真 ST 日必然
|
|
45
|
+
精确贴在 5% 板价上,普通大涨日几乎不会恰好落在 1 分钱窗口里。
|
|
46
|
+
宁可多识别(保守),不让回测凭空多赚。
|
|
47
|
+
|
|
48
|
+
新股「首 N 日不设涨跌幅」需要**交易日序号**(非自然日);调用方有日历时传
|
|
49
|
+
`days_listed`(1=上市首日)最准;只有上市日 `listing_date` 时按自然日近似
|
|
50
|
+
并在 `reason` 里如实标注「近似」。
|
|
51
|
+
"""
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import math
|
|
55
|
+
from dataclasses import dataclass, field
|
|
56
|
+
from typing import Optional, Sequence, Tuple
|
|
57
|
+
|
|
58
|
+
from .boards import Board, SecurityType, board_of, normalize_code, security_type
|
|
59
|
+
|
|
60
|
+
# ── 制度时间线常量(字符串 ISO 日期,可直接比较)─────────────────────────
|
|
61
|
+
LIMIT_ERA_START = "1996-12-16" # A 股涨跌停制度起点(此前 unlimited)
|
|
62
|
+
ST_5PCT_START = "1998-04-27" # 特别处理(ST)制度实施(精确日以交易所公告为准,量级无误)
|
|
63
|
+
GEM_20PCT_START = "2020-08-24" # 创业板注册制:10% → 20%
|
|
64
|
+
MAIN_REG_START = "2023-02-17" # 主板全面注册制:新股首5日无涨跌幅
|
|
65
|
+
MAIN_IPO_4436_START = "2014-01-01" # 主板新股首日 64%~144% 规则起点
|
|
66
|
+
CB_20PCT_START = "2022-08-01" # 可转债 ±20%(首日 +57.3%/-43.3%)
|
|
67
|
+
|
|
68
|
+
EPS = 1e-6
|
|
69
|
+
CENT_TOL = 0.011 # 「恰好贴板」容差:报价到分,留 1 分 + 抖动
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class LimitSpec:
|
|
74
|
+
"""某代码某日的涨跌幅规则判定结果(值对象,可安全缓存/比较)。
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
candidates: [(up_pct, dn_pct, exact), ...]——up/dn 允许非对称
|
|
78
|
+
(主板旧新股首日 +44%/-36%)。`exact=True` 的档位要求「恰好
|
|
79
|
+
贴板」(±1 分钱窗口)才算命中(ST 5% 档专用,防历史状态
|
|
80
|
+
拿不准造成的成片假阳性)。
|
|
81
|
+
unlimited: True = 该日不设涨跌幅(制度前/新股初期/转债旧规/
|
|
82
|
+
债券货基 ETF/指数/回购)。unlimited 时 candidates 为空。
|
|
83
|
+
reason: 规则出处(含生效日期),进日志/报告,不许调用方各自抄文案。
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
candidates: Tuple[Tuple[float, float, bool], ...] = ()
|
|
87
|
+
unlimited: bool = False
|
|
88
|
+
reason: str = ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_NO_LIMIT = "不设涨跌幅"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def round_half_up(x: float, decimals: int = 2) -> float:
|
|
95
|
+
"""四舍五入(half-up)到 `decimals` 位——交易所算板价的官方口径。
|
|
96
|
+
|
|
97
|
+
股票 `decimals=2`(到分);ETF/可转债 `decimals=3`(到 0.001)。
|
|
98
|
+
**不是** Python 的 `round()`(银行家舍入,2.5→2)。浮点精度修正:
|
|
99
|
+
2.675×100 实际是 267.49999999999997,`floor(+0.5)` 会给 267 而非 268
|
|
100
|
+
—— 加极小 epsilon(1e-9)把二进制浮点的「刚好差一点」推回正确侧
|
|
101
|
+
(原 dbl_common 版实测修正,2026-09-27 合并时保留)。
|
|
102
|
+
"""
|
|
103
|
+
f = 10.0 ** int(decimals)
|
|
104
|
+
return math.floor(float(x) * f + 0.5 + 1e-9) / f
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def limit_up_price(prev_close: float, pct: float, decimals: int = 2) -> float:
|
|
108
|
+
"""涨停价(四舍五入到最小价位)。`pct` 如 0.10。"""
|
|
109
|
+
return round_half_up(float(prev_close) * (1.0 + float(pct)), decimals)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def limit_down_price(prev_close: float, pct: float, decimals: int = 2) -> float:
|
|
113
|
+
"""跌停价。`pct` 如 0.10(跌停 = 前收 × (1 - pct))。"""
|
|
114
|
+
return round_half_up(float(prev_close) * (1.0 - float(pct)), decimals)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _days_listed(date: str, listing_date: Optional[str],
|
|
118
|
+
days_listed: Optional[int]) -> Optional[int]:
|
|
119
|
+
"""优先用调用方给的交易日序号;只有上市日时按自然日近似(差 ±1 天,保守取大)。"""
|
|
120
|
+
if days_listed is not None:
|
|
121
|
+
return int(days_listed)
|
|
122
|
+
if listing_date is None or date is None:
|
|
123
|
+
return None
|
|
124
|
+
from datetime import date as _d
|
|
125
|
+
|
|
126
|
+
a = _d.fromisoformat(str(date)[:10])
|
|
127
|
+
b = _d.fromisoformat(str(listing_date)[:10])
|
|
128
|
+
return max(1, (a - b).days + 1) # 近似:自然日含非交易日,只多不少 → 更早解除无限制
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def limit_spec(code: str, date: Optional[str] = None, *,
|
|
132
|
+
is_st: Optional[bool] = None,
|
|
133
|
+
stype: Optional[SecurityType] = None,
|
|
134
|
+
etf_category: Optional[str] = None,
|
|
135
|
+
listing_date: Optional[str] = None,
|
|
136
|
+
days_listed: Optional[int] = None) -> LimitSpec:
|
|
137
|
+
"""判定 `code` 在 `date` 的涨跌幅规则(详见模块 docstring 的时间线)。
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
code: 任意代码形态(会 normalize)。
|
|
141
|
+
date: "YYYY-MM-DD"(None = 按现行制度;历史回测必须传)。
|
|
142
|
+
is_st: 当前 ST 名单可得时传;None = 未知(候选集两档都试,5% 精确贴板)。
|
|
143
|
+
stype: 品种(None = 前缀推断;跟踪指数必须显式传 INDEX)。
|
|
144
|
+
etf_category: ETF 细分 'stock'/'gem'/'star'/'cross_border'/'bond'/
|
|
145
|
+
'money'/'commodity';None = 按代码前缀 best-effort。
|
|
146
|
+
listing_date / days_listed: 新股初期无涨跌幅判定用(见 docstring)。
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
LimitSpec(candidates 可能为空多档;unlimited=True 表示不设涨跌幅)。
|
|
150
|
+
"""
|
|
151
|
+
c = normalize_code(code)
|
|
152
|
+
st = security_type(c, stype)
|
|
153
|
+
d = str(date)[:10] if date else None
|
|
154
|
+
|
|
155
|
+
# ---- 指数 / 回购:无涨跌幅 ----
|
|
156
|
+
if st in (SecurityType.INDEX, SecurityType.REPO):
|
|
157
|
+
return LimitSpec((), True, f"{st.value} 无涨跌幅限制")
|
|
158
|
+
|
|
159
|
+
# ---- 可转债 ----
|
|
160
|
+
if st == SecurityType.CONVERTIBLE_BOND:
|
|
161
|
+
if d is None or d >= CB_20PCT_START:
|
|
162
|
+
if _is_first_day(d, listing_date, days_listed):
|
|
163
|
+
return LimitSpec((), True,
|
|
164
|
+
"可转债 2022-08-01 起首日 +57.3%/-43.3%"
|
|
165
|
+
"(不对称,暂按 unlimited 处理,调用方需自查)")
|
|
166
|
+
return LimitSpec(((0.20, 0.20, False),), False,
|
|
167
|
+
"可转债 2022-08-01 起统一 ±20%")
|
|
168
|
+
return LimitSpec((), True, "可转债 2022-08-01 前无涨跌幅(仅临停)")
|
|
169
|
+
|
|
170
|
+
# ---- ETF ----
|
|
171
|
+
if st == SecurityType.ETF:
|
|
172
|
+
return _etf_limit_spec(c, d, etf_category)
|
|
173
|
+
|
|
174
|
+
# ---- B 股 ----
|
|
175
|
+
if st == SecurityType.B_SHARE:
|
|
176
|
+
if d is not None and d < LIMIT_ERA_START:
|
|
177
|
+
return LimitSpec((), True, "1996-12-16 前无涨跌停制度")
|
|
178
|
+
return _main_board_spec(d, is_st)
|
|
179
|
+
|
|
180
|
+
# ---- A 股股票 ----
|
|
181
|
+
if st != SecurityType.STOCK:
|
|
182
|
+
return LimitSpec((), True, f"品种 {st.value} 无涨跌幅规则(按 unlimited)")
|
|
183
|
+
|
|
184
|
+
if d is not None and d < LIMIT_ERA_START:
|
|
185
|
+
return LimitSpec((), True, "1996-12-16 前无涨跌停制度(制度起点前不判)")
|
|
186
|
+
|
|
187
|
+
board = board_of(c, st)
|
|
188
|
+
dl = _days_listed(d, listing_date, days_listed)
|
|
189
|
+
|
|
190
|
+
# ---- 科创板:±20%,新股首 5 交易日无涨跌幅 ----
|
|
191
|
+
if board == Board.STAR:
|
|
192
|
+
if dl is not None and dl <= 5:
|
|
193
|
+
return LimitSpec((), True, "科创板新股首 5 个交易日不设涨跌幅")
|
|
194
|
+
return LimitSpec(((0.20, 0.20, False),), False, "科创板 ±20%(2019-07-22 开市即 20%)")
|
|
195
|
+
|
|
196
|
+
# ---- 创业板:注册制前与主板同口径(含新股首日规则);2020-08-24 后
|
|
197
|
+
# ±20% + 新股首 5 日无限制 ----
|
|
198
|
+
if board == Board.GEM:
|
|
199
|
+
if d is not None and d < GEM_20PCT_START:
|
|
200
|
+
ipo = _ipo_era_spec(d, dl) # 注册制前创业板新股首日与主板同规则
|
|
201
|
+
return ipo if ipo is not None else _main_board_spec(d, is_st)
|
|
202
|
+
if dl is not None and dl <= 5:
|
|
203
|
+
return LimitSpec((), True, "创业板注册制新股首 5 个交易日不设涨跌幅")
|
|
204
|
+
# 注册制后创业板 ST 同为 ±20%(不再是 5%)
|
|
205
|
+
return LimitSpec(((0.20, 0.20, False),), False,
|
|
206
|
+
"创业板 2020-08-24 起 ±20%(含 ST)")
|
|
207
|
+
|
|
208
|
+
# ---- 北交所:±30%,新股首日无涨跌幅 ----
|
|
209
|
+
if board == Board.BSE:
|
|
210
|
+
if dl is not None and dl <= 1:
|
|
211
|
+
return LimitSpec((), True, "北交所新股上市首日不设涨跌幅")
|
|
212
|
+
return LimitSpec(((0.30, 0.30, False),), False, "北交所 ±30%(2021-11-15 开市)")
|
|
213
|
+
|
|
214
|
+
# ---- 主板 ----
|
|
215
|
+
ipo = _ipo_era_spec(d, dl)
|
|
216
|
+
if ipo is not None:
|
|
217
|
+
return ipo
|
|
218
|
+
return _main_board_spec(d, is_st)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _ipo_era_spec(date: Optional[str], dl: Optional[int]) -> Optional[LimitSpec]:
|
|
222
|
+
"""主板系(主板 + 注册制前创业板)新股首日/初期规则(None = 非新股初期)。
|
|
223
|
+
|
|
224
|
+
· 2014-01-01 前:新股上市首日**不设涨跌幅**(dl==1);
|
|
225
|
+
· 2014-01-01 ~ 2023-02-16:主板(及同期创业板)新股首日有效申报范围
|
|
226
|
+
发行价 64%~144%(+44%/-36% 非对称);
|
|
227
|
+
· 2023-02-17 起(主板全面注册制):新股首 5 个交易日不设涨跌幅。
|
|
228
|
+
"""
|
|
229
|
+
if dl is None or date is None:
|
|
230
|
+
return None
|
|
231
|
+
if dl == 1:
|
|
232
|
+
if date < MAIN_IPO_4436_START:
|
|
233
|
+
return LimitSpec((), True, "2014 年前新股上市首日不设涨跌幅")
|
|
234
|
+
if date < MAIN_REG_START:
|
|
235
|
+
return LimitSpec(((0.44, 0.36, False),), False,
|
|
236
|
+
"主板旧新股首日有效申报范围发行价 64%~144%(+44%/-36%)")
|
|
237
|
+
if dl <= 5 and date >= MAIN_REG_START:
|
|
238
|
+
return LimitSpec((), True, "主板注册制新股首 5 个交易日不设涨跌幅(2023-02-17 起)")
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_first_day(date: Optional[str], listing_date: Optional[str],
|
|
243
|
+
days_listed: Optional[int]) -> bool:
|
|
244
|
+
return _days_listed(date, listing_date, days_listed) == 1
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _main_board_spec(date: Optional[str], is_st: Optional[bool]) -> LimitSpec:
|
|
248
|
+
"""主板(及注册制前创业板/B股):±10%,ST ±5%。
|
|
249
|
+
|
|
250
|
+
is_st 三态:
|
|
251
|
+
· False(确知非 ST)→ 纯 10%;
|
|
252
|
+
· True(按**当前**名单标注)→ 10% 与 5% 两档候选 + 5% 精确贴板
|
|
253
|
+
(该股 ST 之前的历史年份是 10% 限幅,两档保守防漏判);
|
|
254
|
+
· None(未知)→ 同 True 的两档候选(宁可多识别,不让回测凭空多赚)。
|
|
255
|
+
1998-04 前 ST 制度未实施 → 一律 10%。
|
|
256
|
+
"""
|
|
257
|
+
if date is not None and date < ST_5PCT_START:
|
|
258
|
+
return LimitSpec(((0.10, 0.10, False),), False,
|
|
259
|
+
"主板 ±10%(ST 5% 档制度 1998-04 前未实施)")
|
|
260
|
+
if is_st is False:
|
|
261
|
+
return LimitSpec(((0.10, 0.10, False),), False, "主板 ±10%(非 ST)")
|
|
262
|
+
label = "当前 ST" if is_st else "ST 状态未知"
|
|
263
|
+
return LimitSpec(((0.10, 0.05, False), (0.05, 0.05, True)), False,
|
|
264
|
+
f"{label}:±10% 与 ±5% 两档候选,5% 档精确贴板(防假阳性)")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ── ETF 细分 ────────────────────────────────────────────────────────────
|
|
268
|
+
_ETF_GEM_PREFIX = ("1599",) # 创业板 ETF(深市 159 下的创业板跟踪类,best-effort)
|
|
269
|
+
_ETF_STAR_PREFIX = ("588", "589") # 科创板 ETF(沪市)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _etf_limit_spec(code: Optional[str], date: Optional[str],
|
|
273
|
+
category: Optional[str]) -> LimitSpec:
|
|
274
|
+
"""ETF 涨跌幅:细分品类决定 10%/20%/无限制(跨境 10%,债券货币无)。"""
|
|
275
|
+
cat = (category or "").lower()
|
|
276
|
+
if not cat:
|
|
277
|
+
c = normalize_code(code or "")
|
|
278
|
+
if c[:3] in _ETF_STAR_PREFIX:
|
|
279
|
+
cat = "star"
|
|
280
|
+
elif c[:4] in _ETF_GEM_PREFIX:
|
|
281
|
+
cat = "gem"
|
|
282
|
+
else:
|
|
283
|
+
cat = "stock" # best-effort:多数股票 ETF 10%,明确品类请传参
|
|
284
|
+
if cat in ("bond", "money"):
|
|
285
|
+
return LimitSpec((), True, f"{'债券' if cat == 'bond' else '货币'}ETF 无涨跌幅限制")
|
|
286
|
+
if cat == "gem":
|
|
287
|
+
if date is not None and date < GEM_20PCT_START:
|
|
288
|
+
return LimitSpec(((0.10, 0.10, False),), False,
|
|
289
|
+
"创业板 ETF 注册制前 ±10%")
|
|
290
|
+
return LimitSpec(((0.20, 0.20, False),), False, "创业板 ETF ±20%")
|
|
291
|
+
if cat == "star":
|
|
292
|
+
return LimitSpec(((0.20, 0.20, False),), False, "科创板 ETF ±20%")
|
|
293
|
+
if cat in ("stock", "cross_border", "commodity"):
|
|
294
|
+
return LimitSpec(((0.10, 0.10, False),), False,
|
|
295
|
+
f"{'跨境' if cat == 'cross_border' else ('商品' if cat == 'commodity' else '股票')}ETF ±10%")
|
|
296
|
+
return LimitSpec(((0.10, 0.10, False),), False,
|
|
297
|
+
f"ETF 品类未识别({category!r}),按股票 ETF ±10% 保守处理")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def price_decimals(code: str, stype: Optional[SecurityType] = None) -> int:
|
|
301
|
+
"""该品种报价最小位数:股票/B股 2(到分);基金/转债 3(到 0.001)。"""
|
|
302
|
+
st = security_type(code, stype)
|
|
303
|
+
if st in (SecurityType.ETF, SecurityType.LOF, SecurityType.FUND_OTHER,
|
|
304
|
+
SecurityType.CONVERTIBLE_BOND):
|
|
305
|
+
return 3
|
|
306
|
+
return 2
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def candidates_of(spec: LimitSpec) -> Sequence[float]:
|
|
310
|
+
"""便捷取全部候选幅度(含 up/dn 合并去重;unlimited 返回空)。"""
|
|
311
|
+
if spec.unlimited:
|
|
312
|
+
return ()
|
|
313
|
+
out = []
|
|
314
|
+
for up, dn, _ in spec.candidates:
|
|
315
|
+
for p in (up, dn):
|
|
316
|
+
if p not in out:
|
|
317
|
+
out.append(p)
|
|
318
|
+
return tuple(out)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
__all__ = ["LimitSpec", "limit_spec", "round_half_up", "limit_up_price",
|
|
322
|
+
"limit_down_price", "price_decimals", "candidates_of",
|
|
323
|
+
"normalize_code",
|
|
324
|
+
"LIMIT_ERA_START", "ST_5PCT_START", "GEM_20PCT_START",
|
|
325
|
+
"MAIN_REG_START", "MAIN_IPO_4436_START", "CB_20PCT_START",
|
|
326
|
+
"EPS", "CENT_TOL"]
|
dbl_common/proc.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
dbl_common.proc — 子进程「不弹控制台黑窗」的唯一实现(数据/回测/实盘共用)
|
|
4
|
+
==========================================================================
|
|
5
|
+
|
|
6
|
+
溯源:global_market_dashboard app/shared/proc.py →
|
|
7
|
+
quant_data_center/common/proc.py → 2026-09-26 抽入本独立库(内容不变)。
|
|
8
|
+
|
|
9
|
+
**要解决的真实问题**:Windows 上,只要**父进程没有控制台**(pythonw / 计划任务 /
|
|
10
|
+
服务(NSSM、WinSW)/ GUI 宿主 / 某些 IDE 的后台宿主),它用 ``subprocess`` 起的
|
|
11
|
+
子进程就会**自己新建一个控制台窗口并显示出来** —— 用户会看到莫名弹出的 cmd 黑窗;
|
|
12
|
+
批量任务连跑时(如 scheduler 逐个跑采集/清洗/审核脚本)就是**反复闪窗**。
|
|
13
|
+
|
|
14
|
+
这不是「代码 bug」,而是 Windows 的默认行为,**必须在创建进程时**用
|
|
15
|
+
``CREATE_NO_WINDOW`` 显式关掉;漏掉也不会报错,只是用户又看到弹窗(静默复发)。
|
|
16
|
+
|
|
17
|
+
**为什么单独一个模块**:所有 ``subprocess`` 调用点都必须走这里,
|
|
18
|
+
不许多处各写一份 ``creationflags`` 计算 —— 「同一件事两个实现」正是要避免的
|
|
19
|
+
(口径分叉、漏改一处就复发)。quant_data_center 的守卫
|
|
20
|
+
tests/test_proc_guard.py 静态扫描 scheduler/ api/ scripts/ ingestion/ 下的
|
|
21
|
+
subprocess 调用点,必须 import 本模块。
|
|
22
|
+
|
|
23
|
+
使用边界(两条,别用错):
|
|
24
|
+
|
|
25
|
+
1. **子进程输出走管道 / 重定向**(``capture_output=True`` / ``stdout=PIPE`` /
|
|
26
|
+
``stdout=文件``):用 :func:`no_window`。调度任务全是这种做法,
|
|
27
|
+
``CREATE_NO_WINDOW`` **不影响管道读取**,日志一条不少。
|
|
28
|
+
2. **子进程输出继承父控制台、用户要实时看日志**的长驻进程:用
|
|
29
|
+
:func:`no_window_if_detached`。它**只在父进程本来就没有控制台时**才加标记 ——
|
|
30
|
+
那种情况下用户根本看不到日志,加标记零损失;父进程有控制台时保持原样。
|
|
31
|
+
|
|
32
|
+
判定「有没有控制台」用 ``kernel32.GetConsoleWindow()``:返回 0 即当前进程没有可见
|
|
33
|
+
控制台(服务 / 计划任务 / pythonw 的典型情形),此时子进程默认会**弹窗**。
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
import subprocess
|
|
39
|
+
from typing import Any, Dict
|
|
40
|
+
|
|
41
|
+
# CREATE_NO_WINDOW:让子进程在「没有窗口的控制台」里运行(值 = 0x08000000)。
|
|
42
|
+
# 用 getattr 兜底,保证任何平台上都能 import 本模块(不因常量缺失而炸)。
|
|
43
|
+
CREATE_NO_WINDOW: int = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def has_console() -> bool:
|
|
47
|
+
"""当前进程是否附着在一个**可见控制台**上。
|
|
48
|
+
|
|
49
|
+
- 非 Windows:一律 True(POSIX 没有「控制台窗口」这个概念,也就不存在这个问题);
|
|
50
|
+
- Windows:``GetConsoleWindow()`` 返回 0 表示没有控制台;
|
|
51
|
+
- 检测本身失败时**保守返回 True**(= 保持既有行为,不做激进改动)。
|
|
52
|
+
"""
|
|
53
|
+
if os.name != "nt":
|
|
54
|
+
return True
|
|
55
|
+
try:
|
|
56
|
+
import ctypes
|
|
57
|
+
|
|
58
|
+
return bool(ctypes.windll.kernel32.GetConsoleWindow())
|
|
59
|
+
except Exception: # pragma: no cover - 极端环境(无 ctypes / 受限 sandbox)
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def no_window() -> Dict[str, Any]:
|
|
64
|
+
"""给 ``subprocess.run/Popen`` 用的 kwargs:子进程**永不弹控制台黑窗**。
|
|
65
|
+
|
|
66
|
+
适用:输出走管道或重定向的调用(``capture_output=True`` / ``stdout=PIPE`` /
|
|
67
|
+
``stdout=文件``)。非 Windows 返回空 dict,调用点无需写平台分支。
|
|
68
|
+
|
|
69
|
+
用法::
|
|
70
|
+
|
|
71
|
+
subprocess.run(cmd, capture_output=True, **no_window())
|
|
72
|
+
subprocess.Popen(cmd, stdout=subprocess.PIPE, **no_window())
|
|
73
|
+
"""
|
|
74
|
+
if os.name == "nt":
|
|
75
|
+
return {"creationflags": CREATE_NO_WINDOW}
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def no_window_if_detached() -> Dict[str, Any]:
|
|
80
|
+
"""给「输出继承父控制台、用户要看日志」的进程用:**只在父进程没有控制台时**隐藏窗口。
|
|
81
|
+
|
|
82
|
+
用法::
|
|
83
|
+
|
|
84
|
+
subprocess.Popen(cmd, **no_window_if_detached())
|
|
85
|
+
|
|
86
|
+
⚠️ 不要给「用户要看滚动日志」的进程无条件用 :func:`no_window`:``CREATE_NO_WINDOW``
|
|
87
|
+
会把子进程从父控制台摘出去,用户可能就看不到日志了。本函数正是为避免这个副作用而存在。
|
|
88
|
+
"""
|
|
89
|
+
if os.name == "nt" and not has_console():
|
|
90
|
+
return {"creationflags": CREATE_NO_WINDOW}
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def pid_alive(pid: int) -> bool:
|
|
95
|
+
"""指定 PID 的进程**是否还活着**(跨平台;只探测,绝不干扰目标)。
|
|
96
|
+
|
|
97
|
+
⚠️⚠️ **绝对不要**用 `os.kill(pid, 0)` 在 Windows 上做这件事:
|
|
98
|
+
Windows 上 `os.kill` 走的是 `TerminateProcess`,语义**完全相反** ——
|
|
99
|
+
你以为在「检查它还在不在」,实际是在**静默杀掉它**。
|
|
100
|
+
Windows 走 `OpenProcess(SYNCHRONIZE)` + `WaitForSingleObject(handle, 0)`:
|
|
101
|
+
句柄还在(`WAIT_TIMEOUT = 0x102`)说明进程没退出;`WAIT_OBJECT_0 = 0` 说明已退出。
|
|
102
|
+
|
|
103
|
+
拿不到句柄(进程不存在 / 权限不足)一律返回 False。
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
pid = int(pid)
|
|
107
|
+
except (TypeError, ValueError):
|
|
108
|
+
return False
|
|
109
|
+
if pid <= 0:
|
|
110
|
+
return False
|
|
111
|
+
if os.name == "nt":
|
|
112
|
+
try:
|
|
113
|
+
import ctypes
|
|
114
|
+
|
|
115
|
+
k32 = ctypes.windll.kernel32
|
|
116
|
+
handle = k32.OpenProcess(0x00100000, False, pid) # SYNCHRONIZE
|
|
117
|
+
if not handle:
|
|
118
|
+
return False
|
|
119
|
+
try:
|
|
120
|
+
rc = k32.WaitForSingleObject(handle, 0)
|
|
121
|
+
finally:
|
|
122
|
+
k32.CloseHandle(handle)
|
|
123
|
+
return rc == 0x00000102 # WAIT_TIMEOUT = 句柄还在 ⇒ 进程没退出
|
|
124
|
+
except Exception: # pragma: no cover - 极端受限环境
|
|
125
|
+
return False
|
|
126
|
+
try:
|
|
127
|
+
os.kill(pid, 0) # POSIX 上这才是真正的「只探测」
|
|
128
|
+
return True
|
|
129
|
+
except OSError:
|
|
130
|
+
return False
|
dbl_common/settlement.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""settlement.py — 交收/回转交易制度(T+1、T+0、T+3)(公共模块 · 纯函数)。
|
|
3
|
+
|
|
4
|
+
「今天买的今天能不能卖」与「今天卖的今天钱能不能用」是回测撮合、模拟盘
|
|
5
|
+
持仓管理、实盘资金风控共用的判定。各写一份必漂移(GMD 实测教训)。
|
|
6
|
+
|
|
7
|
+
制度事实表(公开规则)
|
|
8
|
+
----------------------
|
|
9
|
+
· A 股股票:**T+1**(当日买入次日方可卖出;当日卖出资金当日可用不可取)。
|
|
10
|
+
历史:1992-1994 沪深先后实行过 T+0 回转,1995-01-01 起恢复 T+1至今。
|
|
11
|
+
· B 股:T+0 回转交易 + **T+3 资金交收**(交易当日可回转,款券三级交收)。
|
|
12
|
+
· ETF:跟踪境内股票指数的 ETF 为 T+1;**跨境/债券/货币/商品(黄金)ETF
|
|
13
|
+
为 T+0**(当日买入当日可卖)。
|
|
14
|
+
· 可转债:T+0(当日回转)。
|
|
15
|
+
· 国债逆回购:T+0 交易;资金 T+1 可用(实际占款天数按品种期限计,
|
|
16
|
+
细则归费用/现金流模块,这里只回答「能否回转」)。
|
|
17
|
+
|
|
18
|
+
诚实边界:T+N 的「N 个交易日」需要交易日历(数据中心供给,见 calendar.py);
|
|
19
|
+
本模块回答制度档位与「当日能否回转」,跨日推算由调用方配日历完成。
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from .boards import Board, SecurityType, board_of, normalize_code, security_type
|
|
26
|
+
|
|
27
|
+
STOCK_T1_START = "1995-01-01" # A 股统一 T+1 起点(此前为 T+0 回转期)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def settle_rule(code: str, *, stype: Optional[SecurityType] = None,
|
|
31
|
+
etf_category: Optional[str] = None,
|
|
32
|
+
date: Optional[str] = None) -> dict:
|
|
33
|
+
"""交收制度档位。
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
{"trade_turn": "T+0"/"T+1", "funds_settle": "T+0"/"T+1"/"T+3",
|
|
37
|
+
"reason": str}
|
|
38
|
+
`trade_turn` = 当日买入当日可否卖出;`funds_settle` = 卖出资金可取日。
|
|
39
|
+
"""
|
|
40
|
+
st = security_type(code, stype)
|
|
41
|
+
c = normalize_code(code)
|
|
42
|
+
|
|
43
|
+
if st == SecurityType.STOCK:
|
|
44
|
+
if date is not None and date < STOCK_T1_START:
|
|
45
|
+
return {"trade_turn": "T+0", "funds_settle": "T+0",
|
|
46
|
+
"reason": "1995-01-01 前 A 股为 T+0 回转交易(历史区间)"}
|
|
47
|
+
return {"trade_turn": "T+1", "funds_settle": "T+1",
|
|
48
|
+
"reason": "A 股股票 T+1(1995-01-01 起);卖出资金当日可用、次日可取"}
|
|
49
|
+
|
|
50
|
+
if st == SecurityType.B_SHARE:
|
|
51
|
+
return {"trade_turn": "T+0", "funds_settle": "T+3",
|
|
52
|
+
"reason": "B 股 T+0 回转交易、T+3 资金交收"}
|
|
53
|
+
|
|
54
|
+
if st == SecurityType.CONVERTIBLE_BOND:
|
|
55
|
+
return {"trade_turn": "T+0", "funds_settle": "T+0",
|
|
56
|
+
"reason": "可转债当日回转(T+0)"}
|
|
57
|
+
|
|
58
|
+
if st == SecurityType.REPO:
|
|
59
|
+
return {"trade_turn": "T+0", "funds_settle": "T+1",
|
|
60
|
+
"reason": "国债逆回购 T+0 交易、资金 T+1 可用(占款天数随品种期限)"}
|
|
61
|
+
|
|
62
|
+
if st == SecurityType.ETF:
|
|
63
|
+
cat = (etf_category or "").lower()
|
|
64
|
+
if not cat:
|
|
65
|
+
# best-effort:513/518 等跨境/商品类 T+0;其余保守按 T+1
|
|
66
|
+
if c[:3] in ("513", "518"):
|
|
67
|
+
cat = "cross_border"
|
|
68
|
+
else:
|
|
69
|
+
cat = "stock"
|
|
70
|
+
if cat in ("cross_border", "bond", "money", "commodity"):
|
|
71
|
+
return {"trade_turn": "T+0", "funds_settle": "T+0",
|
|
72
|
+
"reason": f"{cat} ETF 当日回转(T+0)"}
|
|
73
|
+
return {"trade_turn": "T+1", "funds_settle": "T+1",
|
|
74
|
+
"reason": "境内股票 ETF T+1(跨境/债券/货币/商品 ETF 为 T+0,"
|
|
75
|
+
"如持有请显式传 etf_category)"}
|
|
76
|
+
|
|
77
|
+
if st in (SecurityType.LOF, SecurityType.FUND_OTHER):
|
|
78
|
+
return {"trade_turn": "T+1", "funds_settle": "T+1",
|
|
79
|
+
"reason": "场内基金 T+1(跨境 LOF 有 T+0 品种,需显式传参区分)"}
|
|
80
|
+
|
|
81
|
+
return {"trade_turn": "T+1", "funds_settle": "T+1",
|
|
82
|
+
"reason": f"未知品种按最保守 T+1 处理(code={code})"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def can_sell_today(code: str, *, buy_date: str, today: str,
|
|
86
|
+
trading_days_between=None,
|
|
87
|
+
stype: Optional[SecurityType] = None,
|
|
88
|
+
etf_category: Optional[str] = None) -> bool:
|
|
89
|
+
"""当日买入的持仓今天能否卖出(T+1 判定)。
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
buy_date/today: "YYYY-MM-DD"(同日=当日买入)。
|
|
93
|
+
trading_days_between: 调用方(有日历时)提供的 buy→today 间隔交易日数;
|
|
94
|
+
None 时按「today > buy_date 即隔了至少 1 个交易日」近似——
|
|
95
|
+
若 today 是买入当日后的首个交易日该近似正确;跨停牌长假期时
|
|
96
|
+
请务必传真实交易日数。
|
|
97
|
+
"""
|
|
98
|
+
rule = settle_rule(code, stype=stype, etf_category=etf_category, date=buy_date)
|
|
99
|
+
if rule["trade_turn"] == "T+0":
|
|
100
|
+
return True
|
|
101
|
+
if buy_date >= today:
|
|
102
|
+
return False
|
|
103
|
+
if trading_days_between is None:
|
|
104
|
+
return True # 近似:次日及以后(见 docstring)
|
|
105
|
+
return trading_days_between >= 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = ["settle_rule", "can_sell_today", "STOCK_T1_START"]
|