mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
fix(index): 后端分钟K隔离加固与规则资产类型纠正
- 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 + 分时报错断言同步
This commit is contained in:
@@ -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+ 权限")
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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), 避免用户建出会刷屏的规则。
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user