mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 20:14:16 +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 个阻断项核心场景
124 lines
4.6 KiB
Python
124 lines
4.6 KiB
Python
"""回归测试: Free 档自选实时 symbols 超过 capability batch 上限时分批请求 (PR #46 问题 4)。"""
|
|
from contextlib import ExitStack
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import polars as pl
|
|
|
|
from app.services.quote_service import QuoteService
|
|
from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet
|
|
|
|
|
|
def _make_svc(engine_rules: dict) -> QuoteService:
|
|
"""创建最小可用的 QuoteService 实例 (跳过 __init__)。"""
|
|
svc = QuoteService.__new__(QuoteService)
|
|
svc._app_state = MagicMock()
|
|
svc._repo = MagicMock()
|
|
svc._lock = MagicMock()
|
|
|
|
engine = MagicMock()
|
|
engine.rules = engine_rules
|
|
svc._app_state.monitor_engine = engine
|
|
svc._app_state.repo = svc._repo
|
|
|
|
svc._repo.get_index_symbol_set.return_value = {"000001.SH"}
|
|
svc._repo.get_etf_symbol_set.return_value = set()
|
|
return svc
|
|
|
|
|
|
def _run_fetch(svc, tf, watchlist: list[str], capset: CapabilitySet):
|
|
"""在完整 patch 环境下执行 _fetch_watchlist_quotes。"""
|
|
with ExitStack() as stack:
|
|
stack.enter_context(patch(
|
|
"app.services.preferences.get_realtime_watchlist_symbols",
|
|
return_value=watchlist,
|
|
))
|
|
stack.enter_context(patch(
|
|
"app.tickflow.client.get_paid_realtime_client", return_value=tf,
|
|
))
|
|
stack.enter_context(patch(
|
|
"app.tickflow.policy.detect_capabilities", return_value=capset,
|
|
))
|
|
stack.enter_context(patch("app.tickflow.rate_limits.sleep_between_batches"))
|
|
# patch 分批之后的下游处理
|
|
stack.enter_context(patch.object(
|
|
QuoteService, "_build_daily", return_value=pl.DataFrame(),
|
|
))
|
|
stack.enter_context(patch.object(
|
|
QuoteService, "_build_quote_extra", return_value=pl.DataFrame(),
|
|
))
|
|
stack.enter_context(patch.object(
|
|
QuoteService, "_build_index_quotes", return_value=pl.DataFrame(),
|
|
))
|
|
stack.enter_context(patch.object(QuoteService, "_broadcast_quote_updated"))
|
|
stack.enter_context(patch.object(QuoteService, "_evaluate_monitors"))
|
|
stack.enter_context(patch("app.services.quote_service._persist_last_fetch"))
|
|
svc._fetch_watchlist_quotes()
|
|
|
|
|
|
def test_watchlist_batch_respects_capability_limit():
|
|
"""6 symbols / batch 5 → 分 2 批请求, 不整轮失败。"""
|
|
engine_rules = {
|
|
"r_idx": {"enabled": True, "asset_type": "index", "scope": "symbols",
|
|
"symbols": ["000001.SH"]},
|
|
}
|
|
svc = _make_svc(engine_rules)
|
|
|
|
tf = MagicMock()
|
|
tf.quotes.get.return_value = [
|
|
{"symbol": "600000.SH", "last_price": 10.0, "prev_close": 9.9, "ext": {}},
|
|
]
|
|
capset = CapabilitySet({Cap.QUOTE_BY_SYMBOL: CapabilityLimits(batch=5, rpm=60)})
|
|
|
|
_run_fetch(svc, tf,
|
|
["600000.SH", "600001.SH", "600002.SH", "600003.SH", "600004.SH"],
|
|
capset)
|
|
|
|
# 5 股票 + 1 指数 = 6 symbols, batch 5 → 2 批
|
|
assert tf.quotes.get.call_count == 2
|
|
first_batch = tf.quotes.get.call_args_list[0][1]["symbols"]
|
|
second_batch = tf.quotes.get.call_args_list[1][1]["symbols"]
|
|
assert len(first_batch) == 5
|
|
assert len(second_batch) == 1
|
|
assert "000001.SH" in second_batch
|
|
|
|
|
|
def test_watchlist_batch_partial_failure_keeps_other_batches():
|
|
"""某一批拉取失败不影响其他批次 (已有股票实时刷新不丢失)。"""
|
|
engine_rules = {
|
|
"r_idx": {"enabled": True, "asset_type": "index", "scope": "symbols",
|
|
"symbols": ["000001.SH"]},
|
|
}
|
|
svc = _make_svc(engine_rules)
|
|
|
|
tf = MagicMock()
|
|
# 第一批 (股票) 成功, 第二批 (指数) 失败
|
|
tf.quotes.get.side_effect = [
|
|
[{"symbol": "600000.SH", "last_price": 10.0, "prev_close": 9.9, "ext": {}}],
|
|
ConnectionError("timeout"),
|
|
]
|
|
capset = CapabilitySet({Cap.QUOTE_BY_SYMBOL: CapabilityLimits(batch=5, rpm=60)})
|
|
|
|
_run_fetch(svc, tf,
|
|
["600000.SH", "600001.SH", "600002.SH", "600003.SH", "600004.SH"],
|
|
capset)
|
|
|
|
# 两批都被尝试 (第二批失败不阻断)
|
|
assert tf.quotes.get.call_count == 2
|
|
|
|
|
|
def test_watchlist_no_index_rules_no_extra_symbols():
|
|
"""无指数监控规则时, symbols 不追加指数标的。"""
|
|
svc = _make_svc({}) # 无规则
|
|
|
|
tf = MagicMock()
|
|
tf.quotes.get.return_value = [
|
|
{"symbol": "600000.SH", "last_price": 10.0, "prev_close": 9.9, "ext": {}},
|
|
]
|
|
capset = CapabilitySet({Cap.QUOTE_BY_SYMBOL: CapabilityLimits(batch=5, rpm=60)})
|
|
|
|
_run_fetch(svc, tf, ["600000.SH", "600001.SH"], capset)
|
|
|
|
# 2 symbols / batch 5 → 1 批
|
|
assert tf.quotes.get.call_count == 1
|
|
assert tf.quotes.get.call_args_list[0][1]["symbols"] == ["600000.SH", "600001.SH"]
|