From 3e6f9bcfb779840cd20d1a7902a8d5f609c48e01 Mon Sep 17 00:00:00 2001 From: intfoo Date: Sun, 26 Jul 2026 19:59:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(index):=20=E5=90=8E=E7=AB=AF=E5=88=86?= =?UTF-8?q?=E9=92=9FK=E9=9A=94=E7=A6=BB=E5=8A=A0=E5=9B=BA=E4=B8=8E?= =?UTF-8?q?=E8=A7=84=E5=88=99=E8=B5=84=E4=BA=A7=E7=B1=BB=E5=9E=8B=E7=BA=A0?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kline.sync_minute_single 对指数 symbol 显式 400 (防污染 kline_minute) - kline.sync_minute 全市场 universe 剔除指数 symbol - kline.get_minute 响应新增 asset_type 字段 (3 处 return) - monitor_rules._reconcile_index_asset_type 纠正误存为 stock 的指数规则 (应用于 save_rule/list_rules/_sync_engine, 混合池不动) - 文案资产中立化: "个股信号→信号" "指定股票→指定标的" (options label + 校验报错) - 测试: reconcile 5 断言 + sync_minute_single 拒指数 400 + 分时报错断言同步 --- backend/app/api/kline.py | 11 ++++++ backend/app/api/monitor_rules.py | 37 +++++++++++++++++-- backend/app/strategy/monitor_rules.py | 2 +- .../tests/test_intraday_monitor_signals.py | 2 +- backend/tests/test_minute_routing.py | 22 +++++++++++ backend/tests/test_monitor_index.py | 36 ++++++++++++++++++ 6 files changed, 104 insertions(+), 6 deletions(-) diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index c85547c..d1a1627 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -652,6 +652,7 @@ def get_minute( return { "symbol": symbol, "name": stock_name, "stock_info": stock_info, "date": str(trade_date), "rows": df.to_dicts(), "source": "live", + "asset_type": asset_type, "price_limit": price_limit, } @@ -683,6 +684,7 @@ def get_minute( return { "symbol": symbol, "name": stock_name, "stock_info": stock_info, "date": str(trade_date), "rows": df.to_dicts(), "source": "local", + "asset_type": asset_type, "price_limit": price_limit, } @@ -692,6 +694,7 @@ def get_minute( "symbol": symbol, "name": stock_name, "stock_info": stock_info, "date": str(trade_date), "rows": live_df.to_dicts(), "source": "live" if not live_df.is_empty() else "none", + "asset_type": asset_type, "price_limit": price_limit, } @@ -787,6 +790,9 @@ async def sync_minute(request: Request): universe = sorted(set(universe) | set(inst["symbol"].to_list())) except Exception: # noqa: BLE001 pass + # 剔除指数 symbol: 指数分钟K无本地存储, 落库会污染 kline_minute + index_set = repo.get_index_symbol_set() + universe = [s for s in universe if s not in index_set] progress("sync_minute", 10, f"标的池 {len(universe)} 只") days = override_days if override_days else get_minute_sync_days() @@ -841,6 +847,11 @@ async def sync_minute_single(request: Request, body: dict): repo = request.app.state.repo capset = request.app.state.capabilities + # 指数分钟K无本地存储, 落库会污染股票分钟表 kline_minute; + # 指数分钟数据走 /api/index/minute 实时读取, 此端点显式拒绝。 + if repo.resolve_asset_type(symbol) == "index": + raise HTTPException(status_code=400, detail="指数分钟K不支持落库同步 (指数分钟数据走 /api/index/minute 实时读取)") + if not _minute_allowed(capset): raise HTTPException(status_code=403, detail="需要 Pro+ 权限") diff --git a/backend/app/api/monitor_rules.py b/backend/app/api/monitor_rules.py index 26e614d..763e1d4 100644 --- a/backend/app/api/monitor_rules.py +++ b/backend/app/api/monitor_rules.py @@ -20,11 +20,35 @@ def _data_dir(request: Request) -> Path: return request.app.state.repo.store.data_dir +def _reconcile_index_asset_type(rule: dict, repo) -> dict: + """纠正误存为 stock 的指数规则 (asset_type → index)。 + + 个股弹窗加监控 / 点位提醒等入口未传 asset_type, 指数 symbol 的规则被存成 + stock, 导致监控中心显示「个股」、引擎在股票轮评估 (指数 symbol 永不命中)。 + 仅当规则全部 symbols 都 resolve 为指数时纠正 (股票+指数混合池不动)。 + """ + if rule.get("asset_type", "stock") != "stock" or rule.get("scope") != "symbols": + return rule + symbols = [s for s in rule.get("symbols", []) if s] + if not symbols: + return rule + try: + if all(repo.resolve_asset_type(s) == "index" for s in symbols): + rule["asset_type"] = "index" + except Exception: # noqa: BLE001 + pass + return rule + + def _sync_engine(request: Request) -> None: """保存/删除后,把最新规则集 reload 到引擎内存态。""" engine = getattr(request.app.state, "monitor_engine", None) if engine is not None: - rules = monitor_rules.load_all(_data_dir(request)) + repo = request.app.state.repo + rules = [ + _reconcile_index_asset_type(r, repo) + for r in monitor_rules.load_all(_data_dir(request)) + ] engine.set_rules(rules) @@ -100,13 +124,13 @@ def get_options(request: Request): "custom_signals": custom_sigs, "operators": [">", ">=", "<", "<=", "==", "!="], "types": [ - {"key": "signal", "label": "个股信号"}, + {"key": "signal", "label": "信号"}, {"key": "price", "label": "价格/涨跌"}, {"key": "market", "label": "市场异动"}, {"key": "strategy", "label": "策略监控"}, ], "scopes": [ - {"key": "symbols", "label": "指定股票"}, + {"key": "symbols", "label": "指定标的"}, {"key": "all", "label": "全市场"}, {"key": "sector", "label": "板块"}, ], @@ -133,7 +157,11 @@ def get_options(request: Request): # ── 列表 ─────────────────────────────────────────────── @router.get("") def list_rules(request: Request): - rules = monitor_rules.load_all(_data_dir(request)) + repo = request.app.state.repo + rules = [ + _reconcile_index_asset_type(r, repo) + for r in monitor_rules.load_all(_data_dir(request)) + ] from app.services.kline_sync import intraday_monitor_support support = intraday_monitor_support(getattr(request.app.state, "capabilities", None)) @@ -166,6 +194,7 @@ def list_rules(request: Request): @router.post("") def save_rule(req: RuleModel, request: Request): rule = monitor_rules.normalize(req.model_dump()) + rule = _reconcile_index_asset_type(rule, request.app.state.repo) # 连板梯队封单监控 (type=ladder) 依赖五档盘口数据, 需 Pro+ (DEPTH5_BATCH 能力)。 # 无能力时拒绝创建, 避免规则存了却永远无法触发。 if rule.get("type") == "ladder": diff --git a/backend/app/strategy/monitor_rules.py b/backend/app/strategy/monitor_rules.py index 30f129e..6a65ebb 100644 --- a/backend/app/strategy/monitor_rules.py +++ b/backend/app/strategy/monitor_rules.py @@ -166,7 +166,7 @@ def validate(rule: dict) -> None: if not isinstance(syms, list) or len(syms) == 0: raise ValueError("scope=symbols 时 symbols 不能为空") if uses_intraday_signals(rule) and rule.get("scope") != "symbols": - raise ValueError("分时穿越信号仅支持指定股票") + raise ValueError("分时穿越信号仅支持指定标的") # sector 作用域的板块 JOIN 尚未实现: _apply_scope 目前会退化为「全市场」, # 一条本意针对某板块的规则会对全市场每只命中都触发(告警风暴)。在板块 JOIN # 落地前, 拒绝创建 sector 规则(fail-closed), 避免用户建出会刷屏的规则。 diff --git a/backend/tests/test_intraday_monitor_signals.py b/backend/tests/test_intraday_monitor_signals.py index 5473227..efd9a11 100644 --- a/backend/tests/test_intraday_monitor_signals.py +++ b/backend/tests/test_intraday_monitor_signals.py @@ -144,7 +144,7 @@ def test_intraday_rule_pool_is_derived_from_enabled_rules(): def test_intraday_rule_rejects_non_symbol_scope(): - with pytest.raises(ValueError, match="仅支持指定股票"): + with pytest.raises(ValueError, match="仅支持指定标的"): monitor_rules.validate(_intraday_rule("all")) diff --git a/backend/tests/test_minute_routing.py b/backend/tests/test_minute_routing.py index 0aa1274..6792d35 100644 --- a/backend/tests/test_minute_routing.py +++ b/backend/tests/test_minute_routing.py @@ -680,3 +680,25 @@ def test_intraday_monitor_support_resolver_exception_falls_back(monkeypatch): assert support["available"] is True assert support["source"] == "minute_batch" + + +# ---------- 测试 20: sync_minute_single 拒绝指数 symbol (防污染 kline_minute) ---------- + +def test_sync_minute_single_rejects_index_symbol(): + """指数分钟K无本地存储, 落库会污染股票分钟表; 端点应显式 400 而非 500。""" + import asyncio + + import pytest + from fastapi import HTTPException + + from app.api import kline as kline_api + + mock_repo = MagicMock() + mock_repo.resolve_asset_type.return_value = "index" + mock_request = MagicMock() + mock_request.app.state.repo = mock_repo + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(kline_api.sync_minute_single(mock_request, {"symbol": "000001.SH"})) + assert exc_info.value.status_code == 400 + assert "指数" in str(exc_info.value.detail) diff --git a/backend/tests/test_monitor_index.py b/backend/tests/test_monitor_index.py index fa0db25..a812009 100644 --- a/backend/tests/test_monitor_index.py +++ b/backend/tests/test_monitor_index.py @@ -70,3 +70,39 @@ def test_evaluate_index_round_triggers_and_isolates(): assert all(e["rule_id"] != "r_stock" for e in events) assert events[0]["name"] == "上证指数" assert eng.latest_strategy_results() == {} # 策略结果缓存未被触碰 + + +# ---- 资产类型纠正: 误存为 stock 的指数规则 ---- + +class _FakeRepo: + def resolve_asset_type(self, symbol): + return {"000001.SH": "index"}.get(symbol, "stock") + + +def test_reconcile_index_asset_type_corrects_index_only_rule(): + from app.api.monitor_rules import _reconcile_index_asset_type + + rule = {"asset_type": "stock", "scope": "symbols", "symbols": ["000001.SH"]} + assert _reconcile_index_asset_type(rule, _FakeRepo())["asset_type"] == "index" + + +def test_reconcile_index_asset_type_keeps_stock_and_mixed(): + from app.api.monitor_rules import _reconcile_index_asset_type + + repo = _FakeRepo() + # 纯股票 → 不动 + assert _reconcile_index_asset_type( + {"asset_type": "stock", "scope": "symbols", "symbols": ["600000.SH"]}, repo, + )["asset_type"] == "stock" + # 股票+指数混合 → 不动 (asset_type 语义覆盖整条规则) + assert _reconcile_index_asset_type( + {"asset_type": "stock", "scope": "symbols", "symbols": ["000001.SH", "600000.SH"]}, repo, + )["asset_type"] == "stock" + # 已是 index → 不动 + assert _reconcile_index_asset_type( + {"asset_type": "index", "scope": "symbols", "symbols": ["000001.SH"]}, repo, + )["asset_type"] == "index" + # 非 symbols 范围 → 不动 + assert _reconcile_index_asset_type( + {"asset_type": "stock", "scope": "all", "symbols": []}, repo, + )["asset_type"] == "stock"