Files
shy3130 3c6ed99922 feat(index): 指数收敛为固定核心四只 + fuyao 指数快照接入
- 新建 app/services/index_const.py 单一权威: 展示层固定四只核心指数
  (上证/深证成指/创业板/科创综指), quote_service/overview/
  market_overview_builder/sector_monitor 四处重复定义全部收敛
- fuyao 插件接入指数快照端点 (/api/a-share-index/prices/snapshot):
  client 增加 index_snapshot (批量上限 627 码), provider 实现可选协议
  get_realtime_indices — .BJ 后缀先行过滤 (未知代码整批 1002 连坐),
  volume 不做股转手; 修复 fuyao 路由下指数冻结在日K兜底的 bug
- quote_service 自定义源分支鸭子类型调用 get_realtime_indices 补拉指数,
  请求清单 = 核心四只 + 启用指数监控规则标的; 未实现的源走日K兜底
- TickFlow 分支指数固定按码显式拉取, 移除 CN_Index 全量 universe 与
  mode core/all 分支; 指数落盘固定 merge 不截断
- 指数偏好全套下线: realtime_index_symbols / sidebar_index_symbols /
  indices_nav_pinned / realtime_pull_index / realtime_index_mode
- /api/index/list 与 /search (全指数浏览搜索) 删除; daily/minute 保留
  供指数详情页, sync_instruments/sync_daily 保留供数据页
- sector_monitor 指数标的恒可监控, catalog 签名不再依赖指数偏好
- 新增 7 个用例: 指数快照映射/.BJ 过滤/软失败 + 自定义源指数补充链路
  (含监控规则并入/无协议源静默/指数失败软降级)
2026-08-31 16:50:21 +08:00

212 lines
7.5 KiB
Python

from __future__ import annotations
from types import SimpleNamespace
import polars as pl
import pytest
from app.services import sector_monitor
from app.services.ext_data import ExtConfig, ExtConfigStore, ExtField
from app.services.sector_monitor import SectorMonitorService
from app.strategy import monitor_rules
from app.strategy.monitor import MonitorRuleEngine
class _Repo:
def __init__(self, data_dir, indices: pl.DataFrame | None = None):
self.store = SimpleNamespace(data_dir=data_dir)
self._indices = indices if indices is not None else pl.DataFrame()
def get_index_instruments(self) -> pl.DataFrame:
return self._indices
def _index_target(symbol: str, name: str) -> dict:
return {
"key": f"index:{symbol}",
"kind": "index",
"name": name,
"symbol": symbol,
}
def _sector_rule(targets: list[dict], **overrides) -> dict:
rule = {
"id": "r_sector",
"name": "板块监控",
"enabled": True,
"type": "sector",
"scope": "all",
"sector_kind": targets[0]["kind"],
"sector_targets": targets,
"sector_trigger": "change_pct",
"direction": "up",
"threshold_pct": 1.0,
"window_minutes": 5,
"cooldown_seconds": 0,
"severity": "info",
}
rule.update(overrides)
return monitor_rules.normalize(rule)
def test_validate_accepts_sector_rule_and_rejects_mixed_target_kinds():
rule = _sector_rule([_index_target("000001.SH", "上证指数")])
monitor_rules.validate(rule)
mixed = _sector_rule([
_index_target("000001.SH", "上证指数"),
{
"key": "concept:test:field:人工智能",
"kind": "concept",
"name": "人工智能",
"source_id": "test",
"field": "field",
"value": "人工智能",
},
])
try:
monitor_rules.validate(mixed)
except ValueError as exc:
assert "类型" in str(exc)
else:
raise AssertionError("混合板块类型必须被拒绝")
def test_dimension_values_preserve_names_with_spaces_and_filter_nulls(tmp_path):
service = SectorMonitorService(_Repo(tmp_path))
assert service._dimension_values("中国AI 50;6G概念") == ["中国AI 50", "6G概念"]
assert service._dimension_values("nan") == []
assert service._dimension_values(float("nan")) == []
assert service._industry_paths("电子-半导体-数字芯片设计")[-1] == (
"电子-半导体-数字芯片设计", 3, "电子 / 半导体 / 数字芯片设计",
)
def test_index_targets_are_evaluated_independently(tmp_path):
repo = _Repo(tmp_path)
service = SectorMonitorService(repo)
engine = MonitorRuleEngine()
engine.set_sector_monitor_service(service)
sh = _index_target("000001.SH", "上证指数")
cyb = _index_target("399006.SZ", "创业板指")
engine.set_rules([_sector_rule([sh, cyb])])
first = pl.DataFrame({
"symbol": ["000001.SH", "399006.SZ"],
"name": ["上证指数", "创业板指"],
"close": [3000.0, 2000.0],
"change_pct": [0.8, 0.9],
})
assert engine.evaluate_sectors(pl.DataFrame(), first, now=1000.0) == []
second = first.with_columns(
pl.Series("change_pct", [1.2, 0.95]),
)
events = engine.evaluate_sectors(pl.DataFrame(), second, now=1006.0)
assert [event["sector_name"] for event in events] == ["上证指数"]
assert events[0]["change_pct"] == 0.012
def test_index_targets_always_available(tmp_path):
"""指数标的恒可实时监控: quote_service 对核心四只 + 启用规则标的显式拉取,
不再有按偏好池翻转 available 的行为。"""
service = SectorMonitorService(_Repo(tmp_path))
targets = {target["symbol"]: target for target in service.list_targets()["index"]}
assert targets["000001.SH"]["available"] is True
assert targets["399006.SZ"]["available"] is True
first = targets["000001.SH"]
initial_quote = pl.DataFrame({"symbol": ["000001.SH"], "change_pct": [0.2]})
service.build_snapshots(pl.DataFrame(), initial_quote, [first], {5}, now=1000.0)
changed_quote = pl.DataFrame({"symbol": ["000001.SH"], "change_pct": [1.3]})
snapshot = service.build_snapshots(
pl.DataFrame(), changed_quote, [first], {5}, now=1300.0,
)
assert snapshot["index:000001.SH"]["window_changes"][5] is not None
def test_concept_snapshot_uses_member_average_and_full_window(tmp_path):
config = ExtConfig(
id="concept_test",
label="概念测试",
mode="snapshot",
fields=[
ExtField("symbol", "string", "标的代码"),
ExtField("concept", "string", "所属概念"),
],
)
ExtConfigStore(tmp_path).upsert(config)
ext_dir = tmp_path / "ext_data" / config.id
pl.DataFrame({
"symbol": ["A", "B", "C", "D", "E"],
"concept": ["人工智能", "人工智能", "人工智能", "人工智能", "人工智能"],
}).write_parquet(ext_dir / "part.parquet")
service = SectorMonitorService(_Repo(tmp_path))
target = next(
target for target in service.list_targets()["concept"]
if target["name"] == "人工智能"
)
first = pl.DataFrame({
"symbol": ["A", "B", "C", "D", "E"],
"name": ["甲", "乙", "丙", "丁", "戊"],
"close": [10.0] * 5,
"change_pct": [0.01, 0.02, 0.03, -0.01, 0.0],
})
snapshots = service.build_snapshots(first, pl.DataFrame(), [target], {5}, now=1000.0)
assert snapshots[target["key"]]["change_pct"] == pytest.approx(0.01)
assert snapshots[target["key"]]["coverage_ratio"] == 1.0
assert snapshots[target["key"]]["window_changes"][5] is None
second = first.with_columns((pl.col("change_pct") + 0.01).alias("change_pct"))
too_early = service.build_snapshots(second, pl.DataFrame(), [target], {5}, now=1240.0)
assert too_early[target["key"]]["window_changes"][5] is None
unrelated = ExtConfig(
id="hot_test",
label="热度测试",
mode="snapshot",
fields=[
ExtField("symbol", "string", "标的代码"),
ExtField("heat", "float", "市场热度"),
],
)
ExtConfigStore(tmp_path).upsert(unrelated)
unrelated_dir = tmp_path / "ext_data" / unrelated.id
pl.DataFrame({"symbol": ["A"], "heat": [1.0]}).write_parquet(unrelated_dir / "part.parquet")
complete = service.build_snapshots(second, pl.DataFrame(), [target], {5}, now=1300.0)
assert complete[target["key"]]["window_changes"][5] == pytest.approx(0.01)
def test_momentum_rule_triggers_after_complete_window(tmp_path):
service = SectorMonitorService(_Repo(tmp_path))
engine = MonitorRuleEngine()
engine.set_sector_monitor_service(service)
target = _index_target("000001.SH", "上证指数")
engine.set_rules([_sector_rule(
[target],
sector_trigger="momentum",
threshold_pct=1.0,
window_minutes=5,
)])
start = pl.DataFrame({
"symbol": ["000001.SH"],
"name": ["上证指数"],
"close": [3000.0],
"change_pct": [0.2],
})
assert engine.evaluate_sectors(pl.DataFrame(), start, now=1000.0) == []
early = start.with_columns(pl.lit(1.3).alias("change_pct"))
assert engine.evaluate_sectors(pl.DataFrame(), early, now=1240.0) == []
events = engine.evaluate_sectors(pl.DataFrame(), early, now=1300.0)
assert len(events) == 1
assert events[0]["type"] == "sector_momentum_up"
assert events[0]["window_change_pct"] == pytest.approx(0.011)