feat(portfolio): get_limit_status_batch 回测涨跌停/停牌批量接口
修 filter_limitup/limitdown/paused 回测失效(get_current_tick 无 last_price/paused
字段→恒不过滤→03/02 回测算出假收益)。
get_limit_status_batch(codes, date) → {code: {is_limit_up,is_limit_down,is_paused}|None}:
- dbbardata 无 high_limit 列 → high_limit=round(prev_close×(1+幅度),2) 精确算
(pctChg 阈值高价股边界失真故不用); 窗口 ROW_NUMBER 取 T+T-1 两根日线。
- 幅度板块感知: 主板10/创业·科创20/北交30 + 历史 ST5%(valuation_baostock.isST)。
- 停牌=当日 volume==0; 方案A(返回判断好的状态); 缺失股 None。
Mac TDD 6 用例(涨停/跌停/停牌/创业板20%/正常/缺失)全绿; 45 回归通过。
策略层 filter 接入归策略 session(替 get_current_tick 逐只)。
This commit is contained in:
@@ -838,6 +838,100 @@ class LocalUnifiedProvider(DataProvider): # type: ignore[misc]
|
||||
}
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _limit_pct(sym: str, is_st: bool) -> float:
|
||||
"""涨跌停幅度(%): ST5 / 北交30 / 科创·创业20 / 主板10。"""
|
||||
if is_st:
|
||||
return 5.0
|
||||
if sym.startswith("920") or sym[:1] in ("4", "8"): # 北交所
|
||||
return 30.0
|
||||
if sym.startswith("68") or sym.startswith("30"): # 科创 / 创业
|
||||
return 20.0
|
||||
return 10.0 # 主板
|
||||
|
||||
def _isst_batch(self, syms, year: int, date_str: str) -> set:
|
||||
"""valuation_baostock[year] 取各 sym 最新(date<=T)的 isST=1 集合(历史 ST 感知)。"""
|
||||
vbs = self._read_valuation_baostock(year)
|
||||
if vbs.empty or "isST" not in vbs.columns:
|
||||
return set()
|
||||
try:
|
||||
sub = vbs[
|
||||
vbs["symbol"].astype(str).isin(syms)
|
||||
& (vbs["date"].astype(str) <= date_str)
|
||||
]
|
||||
sub = sub.sort_values("date").drop_duplicates("symbol", keep="last")
|
||||
return set(sub.loc[sub["isST"].astype(int) == 1, "symbol"].astype(str))
|
||||
except Exception as exc:
|
||||
logger.debug("_isst_batch 失败: %s", exc)
|
||||
return set()
|
||||
|
||||
def get_limit_status_batch(
|
||||
self,
|
||||
codes: List[str],
|
||||
date: Union[str, datetime],
|
||||
) -> Dict[str, Optional[Dict[str, bool]]]:
|
||||
"""批量回测当日涨跌停/停牌状态(修 filter_limitup/limitdown/paused 回测失效)。
|
||||
|
||||
dbbardata 无 high_limit 列 → 用 ``high_limit=round(prev_close×(1+幅度),2)`` 精确算
|
||||
(pctChg 阈值在高价股边界失真, 故不用)。幅度板块感知(主板10/创业·科创20/北交30)
|
||||
+ 历史 ST5%(valuation_baostock.isST, 非当前名)。停牌=当日 volume==0。
|
||||
返回 {code: {is_limit_up, is_limit_down, is_paused} | None(无 bar)}。
|
||||
"""
|
||||
if not codes:
|
||||
return {}
|
||||
date_str = self._to_date_str(date)
|
||||
if not date_str:
|
||||
return {c: None for c in codes}
|
||||
t_end = date_str + " 23:59:59"
|
||||
pairs = [(c, jq_to_dbbardata(c)) for c in codes]
|
||||
syms = list({sym for sym, _ in (p[1] for p in pairs)})
|
||||
conn = self._connect()
|
||||
|
||||
# 1. 最近 2 根日线(T + T-1)per (sym, exc); 窗口 ROW_NUMBER 取 top2
|
||||
bars: Dict[tuple, list] = {}
|
||||
for i in range(0, len(syms), 400):
|
||||
chunk = syms[i:i + 400]
|
||||
ph = ",".join("?" * len(chunk))
|
||||
sql = (
|
||||
"SELECT symbol, exchange, close_price, high_price, low_price, volume, rn FROM ("
|
||||
" SELECT symbol, exchange, close_price, high_price, low_price, volume, datetime,"
|
||||
" ROW_NUMBER() OVER (PARTITION BY symbol, exchange ORDER BY datetime DESC) AS rn"
|
||||
f" FROM dbbardata WHERE interval='d' AND symbol IN ({ph}) AND datetime <= ?"
|
||||
") WHERE rn <= 2"
|
||||
)
|
||||
for r in conn.execute(sql, chunk + [t_end]):
|
||||
bars.setdefault((r[0], r[1]), []).append(r)
|
||||
for k in bars: # rn 升序: rn=1(当日 T)在前, rn=2(T-1)在后
|
||||
bars[k].sort(key=lambda x: x[6])
|
||||
|
||||
# 2. 历史 ST 集合 → 5% 幅度
|
||||
st_set = self._isst_batch(syms, int(date_str[:4]), date_str)
|
||||
|
||||
out: Dict[str, Optional[Dict[str, bool]]] = {}
|
||||
for jq_code, (sym, exc) in pairs:
|
||||
blist = bars.get((sym, exc))
|
||||
if not blist:
|
||||
out[jq_code] = None
|
||||
continue
|
||||
t_bar = blist[0] # rn=1 = 当日
|
||||
close_t = t_bar[2]
|
||||
vol_t = t_bar[5]
|
||||
is_paused = (vol_t is None or vol_t == 0)
|
||||
prev_close = blist[1][2] if len(blist) >= 2 else None
|
||||
is_up = is_down = False
|
||||
if prev_close and close_t is not None:
|
||||
pct = self._limit_pct(sym, sym in st_set)
|
||||
high_limit = round(prev_close * (1 + pct / 100), 2)
|
||||
low_limit = round(prev_close * (1 - pct / 100), 2)
|
||||
is_up = close_t >= high_limit
|
||||
is_down = close_t <= low_limit
|
||||
out[jq_code] = {
|
||||
"is_limit_up": is_up,
|
||||
"is_limit_down": is_down,
|
||||
"is_paused": is_paused,
|
||||
}
|
||||
return out
|
||||
|
||||
def get_current_tick(self, security: str) -> Optional[Dict[str, Any]]:
|
||||
"""dbbardata 最近 close + 高低涨停 ±10%(简化,ST/创业/科创精确规则 v2)。"""
|
||||
sym, exc = jq_to_dbbardata(security)
|
||||
|
||||
@@ -623,6 +623,70 @@ class TestGetValueMetricsBatch:
|
||||
), code
|
||||
|
||||
|
||||
# ======================== Task 3d: get_limit_status_batch (涨跌停/停牌回测修正) ========================
|
||||
def _make_limit_fixture(tmp_path):
|
||||
"""dbbardata 2 日线(T-1=06-19, T=06-20)覆盖涨停/跌停/停牌/创业板20%/正常/缺失。
|
||||
|
||||
symbol, exchange, datetime, interval, close_price, high_price, low_price, volume
|
||||
"""
|
||||
db = tmp_path / "lim.db"
|
||||
c = sqlite3.connect(str(db))
|
||||
c.execute(
|
||||
"CREATE TABLE dbbardata(symbol TEXT, exchange TEXT, datetime TEXT, "
|
||||
"interval TEXT, close_price REAL, high_price REAL, low_price REAL, volume REAL)"
|
||||
)
|
||||
rows = [
|
||||
# 600001 主板: prev=10, T close=11.0(=round(10*1.1,2)) → 涨停
|
||||
("600001", "SSE", "2024-06-19 00:00:00", "d", 10.0, 10.0, 10.0, 1000),
|
||||
("600001", "SSE", "2024-06-20 00:00:00", "d", 11.0, 11.0, 11.0, 1000),
|
||||
# 600002 主板: prev=10, T close=9.0(=round(10*0.9,2)) → 跌停
|
||||
("600002", "SSE", "2024-06-19 00:00:00", "d", 10.0, 10.0, 10.0, 1000),
|
||||
("600002", "SSE", "2024-06-20 00:00:00", "d", 9.0, 9.0, 9.0, 1000),
|
||||
# 600003 主板: T vol=0 → 停牌
|
||||
("600003", "SSE", "2024-06-19 00:00:00", "d", 10.0, 10.0, 10.0, 1000),
|
||||
("600003", "SSE", "2024-06-20 00:00:00", "d", 10.0, 10.0, 10.0, 0),
|
||||
# 300001 创业板: prev=10, T close=12.0(=round(10*1.2,2)) → 涨停(20%)
|
||||
("300001", "SZSE", "2024-06-19 00:00:00", "d", 10.0, 10.0, 10.0, 1000),
|
||||
("300001", "SZSE", "2024-06-20 00:00:00", "d", 12.0, 12.0, 12.0, 1000),
|
||||
# 600004 主板: prev=10, T close=10.5 → 正常(非涨跌停)
|
||||
("600004", "SSE", "2024-06-19 00:00:00", "d", 10.0, 10.0, 10.0, 1000),
|
||||
("600004", "SSE", "2024-06-20 00:00:00", "d", 10.5, 10.6, 10.4, 1000),
|
||||
]
|
||||
c.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?)", rows)
|
||||
c.commit()
|
||||
c.close()
|
||||
return db
|
||||
|
||||
|
||||
class TestGetLimitStatusBatch:
|
||||
"""get_limit_status_batch: 回测当日涨跌停/停牌(修 filter 失效致假收益)。
|
||||
|
||||
口径: high_limit=round(prev_close×(1+幅度),2); close>=high_limit→涨停;
|
||||
volume==0→停牌; 幅度=主板10/创业·科创20/北交30/ST5。
|
||||
"""
|
||||
|
||||
def test_limit_status(self, tmp_path):
|
||||
db = _make_limit_fixture(tmp_path)
|
||||
p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
|
||||
codes = [
|
||||
"600001.XSHG", "600002.XSHG", "600003.XSHG",
|
||||
"300001.XSHE", "600004.XSHG", "999999.XSHG",
|
||||
]
|
||||
out = p.get_limit_status_batch(codes, date="2024-06-20")
|
||||
assert set(out.keys()) == set(codes)
|
||||
assert out["600001.XSHG"] == {"is_limit_up": True, "is_limit_down": False, "is_paused": False}
|
||||
assert out["600002.XSHG"] == {"is_limit_up": False, "is_limit_down": True, "is_paused": False}
|
||||
assert out["600003.XSHG"]["is_paused"] is True # vol=0
|
||||
assert out["300001.XSHE"] == {"is_limit_up": True, "is_limit_down": False, "is_paused": False} # 创业板20%
|
||||
assert out["600004.XSHG"] == {"is_limit_up": False, "is_limit_down": False, "is_paused": False}
|
||||
assert out["999999.XSHG"] is None # 无 bar
|
||||
|
||||
def test_empty(self, tmp_path):
|
||||
db = _make_limit_fixture(tmp_path)
|
||||
p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
|
||||
assert p.get_limit_status_batch([], date="2024-06-20") == {}
|
||||
|
||||
|
||||
# ======================== Task 4: 辅助方法 ========================
|
||||
class TestAuxMethods:
|
||||
def test_get_trade_days_from_dbbardata(self, unified_provider):
|
||||
|
||||
Reference in New Issue
Block a user