mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
- 策略: 引擎只加载 data/strategies/{custom,ai,composite} 与研究模板目录,
原 25 个内置策略一次性迁移为用户自定义文件(ID 不变, 引用零迁移);
研究模板移至 app/strategy/research, 打包 spec 同步; 新装环境策略库为空
- 信号: 管线不再硬编码任何信号表达式; 涨跌停判定价成为运行时列
limit_up_price/limit_down_price(生效价∓0.005 容差), 20 个信号转为
data/user_data/custom_signals 定义(显式 column 沿用 signal_* 原列名);
依赖解析/注入顺序/当日路径 prev-join 全部定义驱动; 语义统一为历史路径
口径(顺带修正当日路径 BOLL 比较符与翘板/炸板空值两处既有偏差)
- 前端: 信号库单列表(全部可编辑), SignalPicker/监控选项/信号标签全走
定义接口; 回测页新增免责声明(模拟性质/数据源准确性不作保证/不构成
投资建议)
- 测试: 25 个策略夹具与 20 个信号种子入库 tests/fixtures, 受影响测试
全部自包含化(不依赖运行目录用户数据); 77 万行 MA 死叉新旧表达式对拍
逐行一致; 回测实跑 45 笔卖点信号出场验证矩阵链路
- 同批包含工作区既有进度: 策略结果 ext 列同日投影修复(含测试)、数据源插件、fuyao/auth/api_gateway/data_integrity 调整、README 信号库
描述与 strategy.md 导入路径补充
验证: 后端 2592 passed(空数据目录 CI 模拟 2588 passed, 4 例为模拟环境
伪影复验排除); 前端 vitest 125 passed + tsc 0 错 + vite build 通过
234 lines
9.9 KiB
Python
234 lines
9.9 KiB
Python
"""维表涨停价为「无涨跌停限制」哨兵值 (>= 10000) 时, 盘后全量与实时路径同口径不判涨跌停。
|
|
|
|
注册制新股上市前 5 个交易日无涨跌幅限制, 数据源在维表 limit_up 填上万的占位值。
|
|
识别该哨兵后涨跌停判定价 (limit_up_price / limit_down_price) 为 null —— 引用判定价的
|
|
signal_limit_* 自定义信号条件一律不成立, 连板数归零; 否则盘后全量 (compute_limit_signals)
|
|
回退按 10%/20%/30% 理论价判断, 新股当日涨超 10% 就被记为涨停、连板数 +1, 收盘后看板
|
|
涨停家数、连板梯队与涨停类策略都会把它算进去, 与盘中结论相反。
|
|
|
|
listing_date 窗口判定 (price_limits.is_no_limit_day) 补齐哨兵覆盖不到的部分:
|
|
维表 as_of 只命中一个行情日, 窗口内其余交易日与 as_of 漂移后的历史重算由
|
|
listing_date 判定兜底 (601091.SH 2026-09-18: 昨收 20.8 收 57.77 被误记涨停+1 连板)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.indicators import pipeline
|
|
from app.price_limits import (
|
|
BJ_OPENING_DATE,
|
|
GEM_REGISTRATION_DATE,
|
|
MAIN_BOARD_REGISTRATION_DATE,
|
|
is_no_limit_day,
|
|
no_limit_window_days,
|
|
)
|
|
|
|
YESTERDAY = date(2026, 9, 16)
|
|
TODAY = date(2026, 9, 17)
|
|
|
|
|
|
def _rows(prev_close: float, *, open_: float, high: float, low: float, close: float) -> pl.DataFrame:
|
|
return pl.DataFrame({
|
|
"symbol": ["001234.SZ", "001234.SZ"],
|
|
"date": [YESTERDAY, TODAY],
|
|
"open": [prev_close, open_],
|
|
"high": [prev_close, high],
|
|
"low": [prev_close, low],
|
|
"close": [prev_close, close],
|
|
"raw_close": [prev_close, close],
|
|
"raw_high": [prev_close, high],
|
|
"raw_low": [prev_close, low],
|
|
"volume": [1000.0, 1000.0],
|
|
})
|
|
|
|
|
|
def _instruments(limit_up: float, limit_down: float, as_of: date = TODAY, symbol: str = "001234.SZ") -> pl.DataFrame:
|
|
return pl.DataFrame({
|
|
"symbol": [symbol],
|
|
"name": ["新股"],
|
|
"limit_up": [limit_up],
|
|
"limit_down": [limit_down],
|
|
"as_of": [as_of],
|
|
})
|
|
|
|
|
|
def _realtime(rows: pl.DataFrame, instruments: pl.DataFrame) -> dict:
|
|
today = rows.filter(pl.col("date") == TODAY).with_columns(
|
|
pl.lit(rows["close"][0]).alias("_prev_close_raw"),
|
|
)
|
|
return pipeline._compute_limit_signals_today(today, instruments).row(0, named=True)
|
|
|
|
|
|
# 昨收 20.00, 主板理论涨停 22.00 / 跌停 18.00; 判定价含 ∓0.005 容差
|
|
CASES = {
|
|
"up_close": dict(open_=21.0, high=23.5, low=20.5, close=23.4), # 涨超 10% 收盘
|
|
"up_touch_fade": dict(open_=21.0, high=22.5, low=20.5, close=21.5), # 冲过理论涨停价后回落
|
|
"down_close": dict(open_=19.0, high=19.5, low=17.5, close=17.6), # 跌超 10% 收盘
|
|
"down_touch_up": dict(open_=18.2, high=19.0, low=17.8, close=18.8), # 跌破理论跌停价后收阳
|
|
}
|
|
LIMIT_PRICES = ("limit_up_price", "limit_down_price")
|
|
|
|
|
|
@pytest.mark.parametrize("case", list(CASES))
|
|
def test_full_path_no_price_limit_sentinel_matches_realtime(case):
|
|
rows = _rows(20.0, **CASES[case])
|
|
instruments = _instruments(limit_up=100000.0, limit_down=0.0)
|
|
|
|
full = pipeline.compute_limit_signals(rows, instruments).row(-1, named=True)
|
|
realtime = _realtime(rows, instruments)
|
|
|
|
for price in LIMIT_PRICES:
|
|
assert realtime[price] is None, price
|
|
assert full[price] is None, price
|
|
assert full["consecutive_limit_ups"] == 0
|
|
assert full["consecutive_limit_downs"] == 0
|
|
|
|
|
|
def test_down_signals_alone_also_respect_sentinel():
|
|
rows = _rows(20.0, **CASES["down_close"])
|
|
instruments = _instruments(limit_up=100000.0, limit_down=0.0)
|
|
|
|
full = pipeline.compute_limit_signals(
|
|
rows, instruments, needed={"limit_down_price", "consecutive_limit_downs"},
|
|
).row(-1, named=True)
|
|
|
|
assert full["limit_down_price"] is None
|
|
assert full["consecutive_limit_downs"] == 0
|
|
|
|
|
|
def test_sentinel_only_applies_to_matching_instrument_date():
|
|
"""维表日期不是该行情日 (历史行) 时无法得知是否无限制, 保持理论价口径。"""
|
|
rows = _rows(20.0, **CASES["up_close"])
|
|
stale = _instruments(limit_up=100000.0, limit_down=0.0, as_of=YESTERDAY)
|
|
|
|
full = pipeline.compute_limit_signals(rows, stale).row(-1, named=True)
|
|
|
|
assert full["limit_up_price"] == pytest.approx(21.995)
|
|
assert full["consecutive_limit_ups"] == 1
|
|
|
|
|
|
def test_regular_stock_limit_up_unchanged():
|
|
rows = _rows(20.0, open_=21.0, high=22.0, low=20.5, close=22.0)
|
|
instruments = _instruments(limit_up=22.0, limit_down=18.0)
|
|
|
|
full = pipeline.compute_limit_signals(rows, instruments).row(-1, named=True)
|
|
|
|
assert full["limit_up_price"] == pytest.approx(21.995)
|
|
assert full["raw_close"] >= full["limit_up_price"]
|
|
assert full["consecutive_limit_ups"] == 1
|
|
|
|
|
|
# ================================================================
|
|
# listing_date 无涨跌幅窗口判定 (哨兵覆盖不到的历史行兜底)
|
|
# ================================================================
|
|
|
|
def test_window_days_by_board_and_listing_date():
|
|
listing = date(2026, 9, 17)
|
|
# 沪主板 / 创业板 / 科创板 / 北交所: 注册制后上市 → 窗口 5 / 5 / 5 / 1
|
|
assert no_limit_window_days("601091.SH", listing) == 5
|
|
assert no_limit_window_days("301234.SZ", listing) == 5
|
|
assert no_limit_window_days("688001.SH", listing) == 5
|
|
assert no_limit_window_days("920001.BJ", listing) == 1
|
|
# 注册制改革前上市的老股 → 不适用
|
|
assert no_limit_window_days("600001.SH", MAIN_BOARD_REGISTRATION_DATE) == 5
|
|
assert no_limit_window_days("600001.SH", date(2023, 2, 16)) == 0
|
|
assert no_limit_window_days("300001.SZ", GEM_REGISTRATION_DATE) == 5
|
|
assert no_limit_window_days("300001.SZ", date(2020, 8, 23)) == 0
|
|
assert no_limit_window_days("920001.BJ", BJ_OPENING_DATE) == 1
|
|
assert no_limit_window_days("600001.SH", None) == 0
|
|
|
|
|
|
def test_is_no_limit_day_with_day_rank_exact():
|
|
listing = date(2026, 9, 10)
|
|
# 序号精确门控: 前 5 个交易日内 True, 第 6 日起 False
|
|
assert is_no_limit_day("601091.SH", listing, date(2026, 9, 10), day_rank=1) is True
|
|
assert is_no_limit_day("601091.SH", listing, date(2026, 9, 16), day_rank=5) is True
|
|
assert is_no_limit_day("601091.SH", listing, date(2026, 9, 17), day_rank=6) is False
|
|
# 北交所仅首日
|
|
assert is_no_limit_day("920001.BJ", listing, listing, day_rank=1) is True
|
|
assert is_no_limit_day("920001.BJ", listing, date(2026, 9, 11), day_rank=2) is False
|
|
|
|
|
|
def test_full_path_listing_date_window_marks_no_limit_on_stale_as_of():
|
|
"""C沈鼓场景: 维表 as_of (09-20) 与行情日 (09-17) 不一致, 哨兵失效;
|
|
listing_date 窗口兜底, 涨超 10% 不再被误记涨停。"""
|
|
rows = _rows(20.0, **CASES["up_close"])
|
|
instruments = _instruments(limit_up=0.0, limit_down=0.0, as_of=date(2026, 9, 20))
|
|
instruments = instruments.with_columns(
|
|
pl.lit(TODAY).cast(pl.Date).alias("listing_date")
|
|
)
|
|
|
|
full = pipeline.compute_limit_signals(rows, instruments).row(-1, named=True)
|
|
|
|
for price in LIMIT_PRICES:
|
|
assert full[price] is None, price
|
|
assert full["consecutive_limit_ups"] == 0
|
|
assert full["consecutive_limit_downs"] == 0
|
|
|
|
|
|
def test_sixth_trading_day_after_window_resumes_limit_signals():
|
|
"""窗口第 6 个交易日起恢复正常涨跌停判定 (601091: 上市 09-10, 09-17 是第 6 日)。"""
|
|
dates = [date(2026, 9, 10), date(2026, 9, 11), date(2026, 9, 14),
|
|
date(2026, 9, 15), date(2026, 9, 16), date(2026, 9, 17)]
|
|
closes = [20.0, 20.0, 20.0, 20.0, 20.0, 22.0] # 第 6 日收盘 = 理论涨停 22.00
|
|
rows = pl.DataFrame({
|
|
"symbol": ["601091.SH"] * 6,
|
|
"date": dates,
|
|
"open": closes, "high": closes, "low": [19.8] * 5 + [21.0], "close": closes,
|
|
"raw_close": closes, "raw_high": closes, "raw_low": [19.8] * 5 + [21.0],
|
|
"volume": [1000.0] * 6,
|
|
})
|
|
instruments = _instruments(limit_up=0.0, limit_down=0.0, as_of=date(2026, 9, 20), symbol="601091.SH")
|
|
instruments = instruments.with_columns(
|
|
pl.lit(date(2026, 9, 10)).cast(pl.Date).alias("listing_date")
|
|
)
|
|
|
|
full = pipeline.compute_limit_signals(rows, instruments).row(-1, named=True)
|
|
|
|
assert full["limit_up_price"] == pytest.approx(21.995)
|
|
assert full["consecutive_limit_ups"] == 1
|
|
|
|
|
|
def test_pre_registration_listing_keeps_theoretical_limit():
|
|
"""注册制改革前上市的老股不受窗口判定影响, 理论价照判。"""
|
|
rows = _rows(20.0, **CASES["up_close"])
|
|
instruments = _instruments(limit_up=0.0, limit_down=0.0, as_of=date(2026, 9, 20))
|
|
instruments = instruments.with_columns(
|
|
pl.lit(date(2019, 6, 1)).cast(pl.Date).alias("listing_date")
|
|
)
|
|
|
|
full = pipeline.compute_limit_signals(rows, instruments).row(-1, named=True)
|
|
|
|
assert full["limit_up_price"] == pytest.approx(21.995)
|
|
assert full["consecutive_limit_ups"] == 1
|
|
|
|
|
|
def test_partial_local_history_old_stock_not_marked_no_limit():
|
|
"""listing 近但本地日K首行晚于窗口边界 (历史被裁剪) 时序号不可信, 不标记。"""
|
|
# 上市 2026-09-10, 本地历史从 09-16 开始 (首行在窗口外) → 回退理论价
|
|
rows = _rows(20.0, **CASES["up_close"]) # 行情日 09-16/09-17
|
|
instruments = _instruments(limit_up=0.0, limit_down=0.0, as_of=date(2026, 9, 20))
|
|
instruments = instruments.with_columns(
|
|
pl.lit(date(2026, 9, 1)).cast(pl.Date).alias("listing_date")
|
|
)
|
|
|
|
full = pipeline.compute_limit_signals(rows, instruments).row(-1, named=True)
|
|
|
|
assert full["limit_up_price"] == pytest.approx(21.995)
|
|
|
|
|
|
def test_realtime_path_listing_date_window_without_sentinel():
|
|
"""实时路径: 维表无哨兵 (limit_up=0) 但 listing_date 命中窗口 → 不判涨停。"""
|
|
rows = _rows(20.0, **CASES["up_close"])
|
|
instruments = _instruments(limit_up=0.0, limit_down=0.0).with_columns(
|
|
pl.lit(TODAY).cast(pl.Date).alias("listing_date")
|
|
)
|
|
|
|
realtime = _realtime(rows, instruments)
|
|
|
|
for price in LIMIT_PRICES:
|
|
assert realtime[price] is None, price
|