Files
tick-stock-panel/backend/tests/test_screener_etf.py
T
Jinfeng SunandClaude Opus 4.8 e5a94c42d5 feat: ETF 支持(选股 / 回测 / 监控) (#61)
* feat(screener): 选股引擎支持 ETF

- 12 个内置策略打 asset_types 白名单 + strategy_supports_asset;涨停类
  (连板/断板反包)仅股票,其余 10 个技术类对 ETF 开放
- ScreenerService(repo, asset_type) 分流取数,ETF 复用 kline_etf_enriched,
  跳过股票专用历史缓存与涨停信号;进程级 _history_cache key 含 asset_type
- API /run、/run_preset 透传 asset_type;/strategies 按资产过滤;
  股票专有策略在 ETF 下返回空
- 新增 enriched_dirname(asset_type) 共享 helper;get_enriched_latest_asset
  增 refresh 参数(供轮询线程避免冷缓存同步重算)
- 前端「策略」页加 股票/ETF 切换,ETF 走实时单跑(空日期→用 ETF 自身最新日);
  QK.screenerStrategies 按 asset_type keyed
- 测试:test_screener_etf.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(backtest): 回测支持 ETF(个股/因子/策略组合)

- 三条回测路径 + 共用 BacktestEngine 面板加载按 asset_type 路由到
  kline_etf_enriched(复用 enriched_dirname);PanelCache key 隔离资产;
  ETF 跳过股票专用 get_enriched_range 缓存
- 面板 compute_all/名称 JOIN 按 asset_type 取维表(get_instruments_asset),
  修复 ETF 策略回测用错股票维表致名称为空/涨停信号算错
- BacktestConfig/FactorConfig/StrategyBacktestConfig 增 asset_type
- 三个回测 API + SSE stream 透传 asset_type;_make_job_key 纳入 asset_type
  (修复 stream 与 cancel job_key 不对齐致取消失效的回归)
- 前端策略组合页/因子页加 股票/ETF 切换,标的搜索与策略列表跟随资产;
  assetType 持久化
- 测试:test_backtest_etf.py(含 job_key 一致性回归);既有回测测试替身同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(monitor): 监控规则支持 ETF

- engine.evaluate(df, asset_type) 按规则 asset_type 分轮评估;quote_service
  增开 ETF 评估轮(用 ETF enriched 快照),股票轮不受影响、不重置其策略结果
- ETF 评估轮独立 try(异常不丢弃已算出的股票告警)+ refresh=False(不在轮询
  线程触发 ETF 冷缓存同步重算)
- ETF 版历史加载器(main.py 注入)+ 按规则 asset_type 选加载器
- _strategy_pools 按 (sid, asset_type) 键,避免同策略股票/ETF 规则互相覆盖
- name_map 仅在有 ETF 规则时补 ETF 维表, setdefault 保股票名优先
- RuleModel/normalize 增 asset_type(默认 stock,持久化往返)
- 前端 RuleEditor 加 股票/ETF 选择,策略列表与标的搜索跟随资产
- 测试:test_monitor_etf.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(etf): 前端 API 绑定透传 asset_type + 文档

- api.ts: screener/backtest 绑定加 assetType 参数,MonitorRule 类型加 asset_type
- docs/features.md: 标注选股/回测/监控的 ETF 支持范围与前提

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reliability): 管道并发/原子写/能力探测/监控告警多处加固

后端可靠性专项修复(均带回归测试, backend 全套 64 passed):

并发与数据完整性:
- 盘后管道单飞: JobStore.create() 去重纳入 pending∨running, 关闭"两次快速点击"
  并发双跑窗口; 新增 _heavy_run_lock 执行槽挡住 reap 后僵尸线程并发写 parquet
- adj_factor/minute 全部改走原子写(tmp+replace), 消除 kill/断电致 all.parquet 损坏
- 分块拉取失败聚合 WARNING 可见化(不再静默当成功); 复权失败标的会保持旧价已提示

能力探测:
- 周期重探(60min)热更新 app.state.capabilities, 付费 Key 过期/续费无需重启即可见
- 瞬时探测失败(超时/连接/5xx, 按 _is_transient 判定)不降级、保留旧付费档;
  真 401/无权限仍正常降级回落 free-api

监控告警:
- 评估仅在连续竞价(9:30-11:30/13:00-15:00)+ 快照当日新鲜度下进行, 避开集合竞价/
  收盘后陈旧价与节假日误告警
- scope=sector fail-closed(validate 拒绝新建 + _apply_scope 返回空), 修复板块规则
  对全市场刷屏
- 飞书 webhook 加退避重试并移到独立线程池 fire-and-forget, 不再阻塞行情轮询线程

单标的新鲜度: 新增 repo.symbols_lagging() 检测掉队标的并 WARNING + 计入 job 结果

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:12:29 +08:00

120 lines
4.2 KiB
Python

from app.services.screener import (
PRESET_STRATEGIES,
strategy_supports_asset,
)
def test_all_presets_have_asset_types():
for sid, strat in PRESET_STRATEGIES.items():
assert "asset_types" in strat, f"{sid} 缺 asset_types"
assert "stock" in strat["asset_types"], f"{sid} 必须支持 stock"
def test_limit_up_strategies_are_stock_only():
for sid in ("broken_board_recovery", "consecutive_limit_ups"):
assert PRESET_STRATEGIES[sid]["asset_types"] == ["stock"]
def test_pure_technical_strategies_support_etf():
for sid in (
"trend_breakout", "ma_golden_cross", "macd_golden",
"volume_price_surge", "low_volatility_leader", "oversold_bounce",
"boll_breakout", "bullish_alignment", "pullback_to_support",
"n_day_low_reversal",
):
assert "etf" in PRESET_STRATEGIES[sid]["asset_types"], sid
def test_strategy_supports_asset_defaults_to_stock():
assert strategy_supports_asset({}, "stock") is True
assert strategy_supports_asset({}, "etf") is False
assert strategy_supports_asset({"asset_types": ["stock", "etf"]}, "etf") is True
import types
from datetime import date
import polars as pl
from app.services.screener import ScreenerService
class _FakeRepo:
"""最小 repo 桩:只实现 screener 用到的 _asset 取数接口。"""
def __init__(self, data_dir, enriched=None, instruments=None, latest=None):
self.store = types.SimpleNamespace(data_dir=data_dir)
self._enriched = enriched if enriched is not None else pl.DataFrame()
self._instruments = instruments if instruments is not None else pl.DataFrame()
self._latest = latest
def get_enriched_latest_asset(self, asset_type):
return self._enriched, self._latest
def get_instruments_asset(self, asset_type):
return self._instruments
def get_enriched_history(self, target_date, lookback_days):
return None # stock 缓存;ETF 分支不应调用它
def test_service_defaults_to_stock_dir(tmp_path):
svc = ScreenerService(_FakeRepo(tmp_path))
assert svc.asset_type == "stock"
assert svc._enriched_dirname == "kline_daily_enriched"
def test_service_etf_uses_etf_dir(tmp_path):
svc = ScreenerService(_FakeRepo(tmp_path), asset_type="etf")
assert svc.asset_type == "etf"
assert svc._enriched_dirname == "kline_etf_enriched"
def test_etf_run_preset_empty_data_degrades(tmp_path):
"""ETF enriched 为空时,run_preset 返回空结果而非抛错。"""
svc = ScreenerService(_FakeRepo(tmp_path), asset_type="etf")
result = svc.run_preset("trend_breakout", as_of=date(2026, 1, 2))
assert result.total == 0
assert result.rows == []
def test_etf_run_preset_filters_rows(tmp_path):
"""给一份含技术列的 ETF enriched,趋势突破策略能选出命中行。"""
enriched = pl.DataFrame({
"symbol": ["510300", "159915"],
"name": ["沪深300ETF", "创业板ETF"],
"date": [date(2026, 1, 2), date(2026, 1, 2)],
"close": [4.0, 2.0],
"open": [3.9, 2.1],
"ma60": [3.5, 2.5],
"signal_n_day_high": [True, False],
"vol_ratio_5d": [2.5, 0.5],
"momentum_60d": [0.2, -0.1],
})
repo = _FakeRepo(tmp_path, enriched=enriched, latest=date(2026, 1, 2))
svc = ScreenerService(repo, asset_type="etf")
result = svc.run_preset("trend_breakout", as_of=date(2026, 1, 2))
assert result.total == 1
assert result.rows[0]["symbol"] == "510300"
def test_strategies_filtered_for_etf():
etf_ids = [sid for sid, s in PRESET_STRATEGIES.items()
if strategy_supports_asset(s, "etf")]
assert "trend_breakout" in etf_ids
assert "consecutive_limit_ups" not in etf_ids
assert len(etf_ids) == 10
def test_run_preset_stock_only_strategy_on_etf_returns_empty(tmp_path):
"""对 ETF 跑股票专有策略(连板)应返回空结果,而非误命中或抛错。"""
enriched = pl.DataFrame({
"symbol": ["510300"],
"date": [date(2026, 1, 2)],
"close": [4.0],
})
repo = _FakeRepo(tmp_path, enriched=enriched, latest=date(2026, 1, 2))
svc = ScreenerService(repo, asset_type="etf")
result = svc.run_preset("consecutive_limit_ups", as_of=date(2026, 1, 2))
assert result.total == 0