mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 19:04:15 +08:00
阻断项1: 前端 TypeScript 类型扩展 - MonitorRule.asset_type 加 'index' (api.ts:517) - screenerStrategies 参数加 'index' (api.ts:1412) - klineMinute 响应 asset_type 去重 (api.ts:1296) 阻断项2: 指数监控独立评估 - _evaluate_monitors 股票早期 return 降级为 stock_ready 标志 仅跳过股票轮, ETF/指数轮独立判断数据新鲜度 - 纯指数行情/自选场景下指数规则可正常触发 阻断项3: 核心指数模式不截断分区 - _process_full_market_records 按 index_mode 条件分支: mode=all (完整 CN_Index) → flush 覆盖; mode=core (部分标的) → merge 不截断 - merge_live_enriched_asset 对 index 正确更新 _index_enriched_cache 阻断项4: Free 档额度分批 - _fetch_watchlist_quotes 用 resolve_limit + chunked 按 capability batch 上限分批 - 失败批次跳过不整轮退出, 已有股票实时刷新不受影响 - 复用进程级共享限速器 sleep_between_batches 测试: +7 测试覆盖 4 个阻断项核心场景
203 lines
7.5 KiB
Python
203 lines
7.5 KiB
Python
"""指数监控规则校验测试。"""
|
|
import pytest
|
|
|
|
from app.strategy import monitor_rules
|
|
|
|
|
|
def _index_rule(rid="r_idx", **over):
|
|
rule = {
|
|
"id": rid, "name": rid, "type": "signal", "asset_type": "index",
|
|
"scope": "symbols", "symbols": ["000001.SH"], "logic": "and",
|
|
"conditions": [{"field": "rsi_14", "op": "<", "value": 30}],
|
|
"cooldown_seconds": 0, "enabled": True,
|
|
}
|
|
rule.update(over)
|
|
return rule
|
|
|
|
|
|
def test_index_signal_price_allowed():
|
|
monitor_rules.validate(_index_rule())
|
|
monitor_rules.validate(_index_rule(type="price"))
|
|
|
|
|
|
def test_index_strategy_rejected():
|
|
with pytest.raises(ValueError, match="指数"):
|
|
monitor_rules.validate(_index_rule(type="strategy", strategy_id="s1"))
|
|
|
|
|
|
def test_index_market_rejected():
|
|
with pytest.raises(ValueError, match="指数"):
|
|
monitor_rules.validate(_index_rule(type="market"))
|
|
|
|
|
|
def test_index_scope_all_rejected():
|
|
with pytest.raises(ValueError, match="指数"):
|
|
monitor_rules.validate(_index_rule(scope="all", symbols=[]))
|
|
|
|
|
|
def test_index_intraday_signal_rejected():
|
|
with pytest.raises(ValueError, match="分时"):
|
|
monitor_rules.validate(_index_rule(
|
|
conditions=[{"field": "signal_intraday_avg_cross_up", "op": "truth"}],
|
|
))
|
|
|
|
|
|
# ---- Task 7: B5 监控指数评估轮 ----
|
|
|
|
def _signal_rule(rid, asset_type, sym):
|
|
return {
|
|
"id": rid, "name": rid, "type": "signal", "asset_type": asset_type,
|
|
"scope": "symbols", "symbols": [sym], "logic": "and",
|
|
"conditions": [{"field": "rsi_14", "op": "<", "value": 100}],
|
|
"cooldown_seconds": 0, "enabled": True,
|
|
}
|
|
|
|
|
|
def test_evaluate_index_round_triggers_and_isolates():
|
|
"""指数轮只评估指数规则, 且不触碰策略结果缓存。"""
|
|
import polars as pl
|
|
from app.strategy.monitor import MonitorRuleEngine
|
|
|
|
eng = MonitorRuleEngine()
|
|
eng.set_rules([_signal_rule("r_idx", "index", "000001.SH"),
|
|
_signal_rule("r_stock", "stock", "000001.SH")])
|
|
eng.set_name_map({"000001.SH": "上证指数"})
|
|
df = pl.DataFrame({"symbol": ["000001.SH"], "close": [3000.0],
|
|
"change_pct": [0.01], "rsi_14": [40.0]})
|
|
|
|
events = eng.evaluate(df, asset_type="index", reset_strategy_results=False)
|
|
assert any(e["rule_id"] == "r_idx" for e in events)
|
|
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"
|
|
|
|
|
|
# ---- 股票快照为空时指数轮仍独立评估 (PR #46 问题 2) ----
|
|
|
|
def test_evaluate_monitors_index_round_survives_empty_stock_snapshot():
|
|
"""纯指数行情/自选场景: 股票 enriched 为空时, 指数监控轮仍独立评估。"""
|
|
from datetime import date
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import polars as pl
|
|
|
|
from app.services.quote_service import QuoteService
|
|
|
|
svc = QuoteService.__new__(QuoteService)
|
|
svc._repo = MagicMock()
|
|
|
|
engine = MagicMock()
|
|
engine.rule_count = 1
|
|
engine.has_asset_rules.side_effect = lambda at: at == "index"
|
|
engine.has_rule_type.return_value = False
|
|
engine.evaluate.return_value = [] # 无触发, 简化后续
|
|
|
|
svc._app_state = MagicMock()
|
|
svc._app_state.monitor_engine = engine
|
|
svc._app_state.repo = svc._repo
|
|
|
|
svc._repo.get_instruments.return_value = pl.DataFrame()
|
|
svc._repo.get_instruments_asset.return_value = pl.DataFrame()
|
|
svc._repo.get_enriched_latest_asset.return_value = (
|
|
pl.DataFrame({"symbol": ["000001.SH"], "close": [3000.0], "rsi_14": [40.0]}),
|
|
date(2026, 7, 28),
|
|
)
|
|
|
|
with (
|
|
patch.object(QuoteService, "_is_continuous_trading", return_value=True),
|
|
patch.object(QuoteService, "get_enriched_today",
|
|
return_value=(pl.DataFrame(), None)), # 股票快照为空
|
|
patch.object(QuoteService, "_inject_intraday_signals",
|
|
side_effect=lambda df, e, at: df),
|
|
patch("app.services.quote_service.cn_today", return_value=date(2026, 7, 28)),
|
|
):
|
|
svc._evaluate_monitors(pl.DataFrame(), None)
|
|
|
|
# 指数轮执行了 (asset_type="index")
|
|
index_calls = [c for c in engine.evaluate.call_args_list
|
|
if c[1].get("asset_type") == "index"]
|
|
assert len(index_calls) == 1, "股票快照为空时指数轮仍应评估"
|
|
# 股票轮被跳过 (stock_ready=False)
|
|
stock_calls = [c for c in engine.evaluate.call_args_list
|
|
if c[1].get("asset_type") == "stock"]
|
|
assert len(stock_calls) == 0, "股票快照为空时股票轮应跳过"
|
|
|
|
|
|
def test_evaluate_monitors_stock_round_runs_when_snapshot_ready():
|
|
"""股票快照就绪时, 股票轮正常执行 (回归确认未破坏原有行为)。"""
|
|
from datetime import date
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import polars as pl
|
|
|
|
from app.services.quote_service import QuoteService
|
|
|
|
svc = QuoteService.__new__(QuoteService)
|
|
svc._repo = MagicMock()
|
|
|
|
engine = MagicMock()
|
|
engine.rule_count = 1
|
|
engine.has_asset_rules.return_value = False
|
|
engine.has_rule_type.return_value = False
|
|
engine.evaluate.return_value = []
|
|
engine.consume_strategy_result_updates.return_value = False
|
|
|
|
svc._app_state = MagicMock()
|
|
svc._app_state.monitor_engine = engine
|
|
svc._app_state.repo = svc._repo
|
|
|
|
svc._repo.get_instruments.return_value = pl.DataFrame()
|
|
|
|
stock_df = pl.DataFrame({"symbol": ["600000.SH"], "close": [10.0], "rsi_14": [50.0]})
|
|
|
|
with (
|
|
patch.object(QuoteService, "_is_continuous_trading", return_value=True),
|
|
patch.object(QuoteService, "get_enriched_today",
|
|
return_value=(stock_df, date(2026, 7, 28))),
|
|
patch.object(QuoteService, "_inject_intraday_signals",
|
|
side_effect=lambda df, e, at: df),
|
|
patch("app.services.quote_service.cn_today", return_value=date(2026, 7, 28)),
|
|
):
|
|
svc._evaluate_monitors(pl.DataFrame(), None)
|
|
|
|
# 股票轮正常执行
|
|
stock_calls = [c for c in engine.evaluate.call_args_list
|
|
if c[1].get("asset_type") == "stock"]
|
|
assert len(stock_calls) == 1, "股票快照就绪时股票轮应正常执行"
|