mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
@@ -23,6 +23,7 @@ class ProviderCapabilities:
|
||||
adj_factor: bool = False
|
||||
minute: bool = False
|
||||
realtime: bool = False
|
||||
depth5: bool = False
|
||||
financial: bool = False
|
||||
|
||||
|
||||
@@ -73,3 +74,6 @@ class MarketDataProvider(Protocol):
|
||||
symbols: list[str] | None = None,
|
||||
) -> pl.DataFrame:
|
||||
"""Return normalized realtime quotes. Implementations may return empty."""
|
||||
|
||||
def get_depth_batch(self, symbols: list[str]) -> dict[str, dict]:
|
||||
"""Return five-level order books keyed by symbol."""
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
能力 (capability) = 一个标准化数据集 (CONTRIBUTING「数据源插件化要求」):
|
||||
daily / adj_factor / realtime / minute / depth5 / financial (注册表顺序即设置页卡片顺序)。注册表集中声明每个
|
||||
能力的展示元数据、路由偏好字段与 TickFlow 档位要求, 前端设置页不再各自硬编码。
|
||||
depth5 目前仅 TickFlow 供 (插件数据集白名单未开放, 见 loader), 仍进矩阵是为了
|
||||
可用性门控诚实: 五档不可用时连板梯队封单/看板封单缺数据应有提示。
|
||||
depth5 与其他数据集一样可由插件声明并独立路由; 五档不可用时连板梯队封单/
|
||||
看板封单通过 usable 给出缺数据提示。
|
||||
|
||||
build_capability_matrix 把注册表、插件/自定义源的能力声明 (datasets) 和当前
|
||||
路由偏好合并为一个矩阵, 供设置页一次拉全。当前偏好由 API 层注入
|
||||
@@ -67,7 +67,6 @@ CAPABILITY_REGISTRY: list[dict] = [
|
||||
"field": "depth5_data_provider",
|
||||
"default": "tickflow",
|
||||
"tf_tier": "pro",
|
||||
# 插件契约暂未开放 depth5 数据集 (loader 白名单), 当前仅 TickFlow 供
|
||||
},
|
||||
{
|
||||
"id": "financial",
|
||||
|
||||
@@ -24,6 +24,7 @@ class TickFlowProvider:
|
||||
adj_factor=True,
|
||||
minute=True,
|
||||
realtime=True,
|
||||
depth5=True,
|
||||
financial=True,
|
||||
)
|
||||
|
||||
@@ -119,3 +120,9 @@ class TickFlowProvider:
|
||||
else:
|
||||
return pl.DataFrame()
|
||||
return pl.DataFrame(resp or [])
|
||||
|
||||
def get_depth_batch(self, symbols: list[str]) -> dict[str, dict]:
|
||||
if not symbols:
|
||||
return {}
|
||||
data = get_client().depth.batch(symbols)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
+5
-5
@@ -135,11 +135,6 @@ async def _application_lifespan(app: FastAPI):
|
||||
# instruments/index/ETF 仍同步 (毫秒级)。应用立即 ready, 指标算完后自动替换。
|
||||
repo.refresh_cache(background=True)
|
||||
|
||||
# 能力探测
|
||||
capset = detect_capabilities()
|
||||
app.state.capabilities = capset
|
||||
logger.info("ready; %d capabilities active", len(capset.all()))
|
||||
|
||||
# 自定义数据源配置(可选): 失败只记录错误, 不影响 TickFlow 基准路径。
|
||||
try:
|
||||
from app.data_providers import custom as custom_sources
|
||||
@@ -148,6 +143,11 @@ async def _application_lifespan(app: FastAPI):
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("custom data sources init failed: %s", e)
|
||||
|
||||
# 自定义源必须先注册,能力探测才能补充其数据集能力。
|
||||
capset = detect_capabilities()
|
||||
app.state.capabilities = capset
|
||||
logger.info("ready; %d capabilities active", len(capset.all()))
|
||||
|
||||
# 全局行情服务
|
||||
qs = QuoteService()
|
||||
app.state.quote_service = qs
|
||||
|
||||
@@ -292,9 +292,30 @@ class DepthService:
|
||||
self._persist(enriched_date)
|
||||
|
||||
def _call_depth_batch(self, symbols: list[str]) -> dict:
|
||||
"""调 tf.depth.batch, 按 capset 的 batch 切片 + 节流。返回 {symbol: MarketDepth}。"""
|
||||
from app.tickflow.client import get_client
|
||||
tf = get_client()
|
||||
"""按独立五档路由取数; 所有 provider 共用分片限速且失败不跨源回退。"""
|
||||
from app.services import preferences
|
||||
|
||||
provider_name = preferences.get_depth5_data_provider()
|
||||
if provider_name == "tickflow":
|
||||
from app.data_providers.registry import get_provider
|
||||
|
||||
provider = get_provider("tickflow")
|
||||
else:
|
||||
from app.data_providers import custom as custom_sources
|
||||
|
||||
try:
|
||||
if not custom_sources.provider_has_dataset(provider_name, "depth5"):
|
||||
logger.warning("depth provider %s 未声明 depth5, 跳过本轮", provider_name)
|
||||
return {}
|
||||
provider = custom_sources.get_provider(provider_name)
|
||||
except Exception as e:
|
||||
logger.warning("depth provider %s 解析失败, 跳过本轮: %s", provider_name, e)
|
||||
return {}
|
||||
|
||||
fetch_depth = getattr(provider, "get_depth_batch", None)
|
||||
if not callable(fetch_depth):
|
||||
logger.warning("depth provider %s 未实现 get_depth_batch, 跳过本轮", provider_name)
|
||||
return {}
|
||||
|
||||
capset = self._get_capset()
|
||||
limit = resolve_limit(capset, Cap.DEPTH5_BATCH, default_batch=100, default_rpm=30)
|
||||
@@ -304,12 +325,23 @@ class DepthService:
|
||||
for i, chunk in enumerate(chunks):
|
||||
sleep_between_batches(i, limit.rpm, default_interval=2.0)
|
||||
try:
|
||||
# SDK 的 batch 内部已按 batch_size 切, 这里再切一层防单请求过大
|
||||
data = tf.depth.batch(chunk)
|
||||
data = fetch_depth(chunk)
|
||||
if isinstance(data, dict):
|
||||
result.update(data)
|
||||
else:
|
||||
logger.warning(
|
||||
"depth provider %s 第 %d 批返回非 dict, 已跳过",
|
||||
provider_name,
|
||||
i + 1,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("depth.batch 第 %d 批失败(%d 只): %s", i + 1, len(chunk), e)
|
||||
logger.warning(
|
||||
"depth provider %s 第 %d 批失败(%d 只): %s",
|
||||
provider_name,
|
||||
i + 1,
|
||||
len(chunk),
|
||||
e,
|
||||
)
|
||||
# 单批失败不影响其他批
|
||||
return result
|
||||
|
||||
|
||||
@@ -305,11 +305,12 @@ def detect_capabilities(force: bool = False) -> CapabilitySet:
|
||||
|
||||
# 数据集 → 能力映射: 第三方源声明某数据集且被选为当前 provider 时补授的能力。
|
||||
# 实时行情无对应能力键 (权限由 QuoteService.is_realtime_allowed 判定);
|
||||
# 五档盘口/WebSocket 暂无第三方数据集契约, 不增广。
|
||||
# WebSocket 暂无第三方数据集契约, 不增广。
|
||||
_DATASET_CAP_MAP: tuple[tuple[str, Cap], ...] = (
|
||||
("daily", Cap.KLINE_DAILY_BATCH),
|
||||
("adj_factor", Cap.ADJ_FACTOR),
|
||||
("minute", Cap.KLINE_MINUTE_BATCH),
|
||||
("depth5", Cap.DEPTH5_BATCH),
|
||||
("financial", Cap.FINANCIAL),
|
||||
("full_minute", Cap.INTRADAY_UNIVERSE),
|
||||
)
|
||||
@@ -327,6 +328,7 @@ def _augment_custom_sources(capset: CapabilitySet) -> None:
|
||||
"daily": daily_provider,
|
||||
"adj_factor": adj_provider,
|
||||
"minute": preferences.get_minute_data_provider(),
|
||||
"depth5": preferences.get_depth5_data_provider(),
|
||||
"financial": preferences.get_financial_provider(),
|
||||
"full_minute": preferences.get_full_minute_data_provider(),
|
||||
}
|
||||
@@ -548,8 +550,6 @@ def _compute_label_and_missing(
|
||||
|
||||
base_caps = _tier_caps_set(tiers, base)
|
||||
missing = sorted(c.value for c in (base_caps - held))
|
||||
extras = base_caps and (held - base_caps) or set() # extras 是超出该档的部分
|
||||
|
||||
# 实际超出 = held 中"既不属于本档、也不属于本档下方任何档"的 cap
|
||||
# 简化:extras = held - base_caps
|
||||
extras_set = held - base_caps
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""能力标准统一: 自定义/插件数据源能力增广回归测试。
|
||||
|
||||
对应 _augment_custom_sources 的数据集→能力映射 (daily/adj_factor/minute/financial/full_minute):
|
||||
对应 _augment_custom_sources 的数据集→能力映射
|
||||
(daily/adj_factor/minute/depth5/financial/full_minute):
|
||||
某数据集的当前 provider 非 tickflow 且声明了该数据集 → grant 对应能力;
|
||||
取数路由仍按 preferences 分流, 不会误调 TickFlow。
|
||||
"""
|
||||
@@ -13,12 +14,14 @@ from app.tickflow.policy import _augment_custom_sources
|
||||
|
||||
|
||||
def _set_providers(monkeypatch, *, daily="tickflow", adj="tickflow",
|
||||
minute="tickflow", financial="tickflow", full_minute="tickflow") -> None:
|
||||
minute="tickflow", depth5="tickflow", financial="tickflow",
|
||||
full_minute="tickflow") -> None:
|
||||
"""mock preferences 各数据集 provider getter。"""
|
||||
from app.services import preferences
|
||||
monkeypatch.setattr(preferences, "get_daily_data_provider", lambda: daily)
|
||||
monkeypatch.setattr(preferences, "get_adj_factor_provider", lambda: adj)
|
||||
monkeypatch.setattr(preferences, "get_minute_data_provider", lambda: minute)
|
||||
monkeypatch.setattr(preferences, "get_depth5_data_provider", lambda: depth5)
|
||||
monkeypatch.setattr(preferences, "get_financial_provider", lambda: financial)
|
||||
monkeypatch.setattr(preferences, "get_full_minute_data_provider", lambda: full_minute)
|
||||
|
||||
@@ -82,6 +85,15 @@ def test_minute_custom_source_grants_minute_batch(monkeypatch):
|
||||
assert capset.has(Cap.KLINE_MINUTE_BATCH)
|
||||
|
||||
|
||||
def test_depth5_custom_source_grants_depth_batch(monkeypatch):
|
||||
"""五档独立路由到声明 depth5 的自定义源时补授批量五档能力。"""
|
||||
_set_providers(monkeypatch, depth5="mock_src")
|
||||
_set_datasets(monkeypatch, {"depth5"})
|
||||
capset = CapabilitySet()
|
||||
_augment_custom_sources(capset)
|
||||
assert capset.has(Cap.DEPTH5_BATCH)
|
||||
|
||||
|
||||
def test_financial_custom_source_grants_financial(monkeypatch):
|
||||
_set_providers(monkeypatch, financial="mock_src")
|
||||
_set_datasets(monkeypatch, {"financial"})
|
||||
|
||||
@@ -203,26 +203,24 @@ def test_adj_factor_routes_independently(monkeypatch):
|
||||
|
||||
|
||||
def test_depth5_capability_semantics(monkeypatch):
|
||||
"""五档: pro+ 档 TickFlow 可供 (usable); 档位不足时不可用且无候选。
|
||||
|
||||
插件数据集白名单未开放 depth5, 假插件即使声明其他数据集也不进五档候选;
|
||||
未来契约开放后声明 depth5 的源会自然成为候选 (candidates 按 datasets 过滤)。
|
||||
"""
|
||||
"""五档可独立路由到声明 depth5 的插件, 不受 TickFlow 档位限制。"""
|
||||
_fake_sources(
|
||||
monkeypatch,
|
||||
[{"name": "fuyao", "display_name": "fuyao", "datasets": ["realtime"],
|
||||
[{"name": "depth_src", "display_name": "Depth", "datasets": ["depth5"],
|
||||
"available": True, "status": "ok"}],
|
||||
)
|
||||
# pro 档: TickFlow 进候选, 默认路由 tickflow → usable
|
||||
cap = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="pro"))["depth5"]
|
||||
assert cap["tf_available"] is True
|
||||
assert [c["name"] for c in cap["candidates"]] == ["tickflow"]
|
||||
assert [c["name"] for c in cap["candidates"]] == ["tickflow", "depth_src"]
|
||||
assert cap["usable"] is True
|
||||
# starter 档: 档位不足 → 无候选, usable False (连板梯队封单缺数据)
|
||||
cap = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="starter"))["depth5"]
|
||||
# starter 档: TickFlow 不可供, 但显式路由到插件后仍可用
|
||||
current = dict(DEFAULT_CURRENT, depth5_data_provider="depth_src")
|
||||
cap = _by_id(build_capability_matrix(current, tickflow_tier="starter"))["depth5"]
|
||||
assert cap["tf_available"] is False
|
||||
assert cap["candidates"] == []
|
||||
assert cap["usable"] is False
|
||||
assert [c["name"] for c in cap["candidates"]] == ["depth_src"]
|
||||
assert cap["effective"] == "depth_src"
|
||||
assert cap["usable"] is True
|
||||
|
||||
|
||||
def test_unknown_current_display_falls_back_to_name(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Custom depth provider routing and failure-isolation tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
from app.services import depth_service as depth_module
|
||||
from app.services.depth_service import DepthService
|
||||
from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet
|
||||
|
||||
|
||||
def _service(*, batch: int = 2, rpm: int = 30) -> DepthService:
|
||||
service = DepthService()
|
||||
service._app_state = SimpleNamespace(
|
||||
capabilities=CapabilitySet({
|
||||
Cap.DEPTH5_BATCH: CapabilityLimits(batch=batch, rpm=rpm),
|
||||
}),
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
def test_custom_depth_uses_shared_batching_and_rate_limit(monkeypatch):
|
||||
provider = SimpleNamespace(
|
||||
get_depth_batch=MagicMock(
|
||||
side_effect=lambda symbols: {symbol: {"ask_volumes": [0]} for symbol in symbols}
|
||||
)
|
||||
)
|
||||
sleep = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.preferences.get_depth5_data_provider",
|
||||
lambda: "custom_depth",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.data_providers.custom.provider_has_dataset",
|
||||
lambda name, dataset: name == "custom_depth" and dataset == "depth5",
|
||||
)
|
||||
monkeypatch.setattr("app.data_providers.custom.get_provider", lambda name: provider)
|
||||
monkeypatch.setattr(depth_module, "sleep_between_batches", sleep)
|
||||
|
||||
result = _service()._call_depth_batch(["A", "B", "C", "D", "E"])
|
||||
|
||||
assert set(result) == {"A", "B", "C", "D", "E"}
|
||||
assert provider.get_depth_batch.call_args_list == [
|
||||
call(["A", "B"]),
|
||||
call(["C", "D"]),
|
||||
call(["E"]),
|
||||
]
|
||||
assert sleep.call_args_list == [
|
||||
call(0, 24, default_interval=2.0),
|
||||
call(1, 24, default_interval=2.0),
|
||||
call(2, 24, default_interval=2.0),
|
||||
]
|
||||
|
||||
|
||||
def test_custom_depth_failure_does_not_fall_back_to_tickflow(monkeypatch):
|
||||
provider = SimpleNamespace(
|
||||
get_depth_batch=MagicMock(side_effect=RuntimeError("custom source down"))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.preferences.get_depth5_data_provider",
|
||||
lambda: "custom_depth",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.data_providers.custom.provider_has_dataset",
|
||||
lambda name, dataset: True,
|
||||
)
|
||||
monkeypatch.setattr("app.data_providers.custom.get_provider", lambda name: provider)
|
||||
monkeypatch.setattr(
|
||||
"app.tickflow.client.get_client",
|
||||
lambda: (_ for _ in ()).throw(AssertionError("must not fall back to TickFlow")),
|
||||
)
|
||||
|
||||
assert _service()._call_depth_batch(["A"]) == {}
|
||||
provider.get_depth_batch.assert_called_once_with(["A"])
|
||||
|
||||
|
||||
def test_custom_depth_failure_isolated_per_batch(monkeypatch):
|
||||
provider = SimpleNamespace(
|
||||
get_depth_batch=MagicMock(
|
||||
side_effect=[
|
||||
RuntimeError("first batch down"),
|
||||
{"C": {"ask_volumes": [0]}},
|
||||
]
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.preferences.get_depth5_data_provider",
|
||||
lambda: "custom_depth",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.data_providers.custom.provider_has_dataset",
|
||||
lambda name, dataset: True,
|
||||
)
|
||||
monkeypatch.setattr("app.data_providers.custom.get_provider", lambda name: provider)
|
||||
monkeypatch.setattr(depth_module, "sleep_between_batches", MagicMock())
|
||||
|
||||
assert _service()._call_depth_batch(["A", "B", "C"]) == {
|
||||
"C": {"ask_volumes": [0]},
|
||||
}
|
||||
assert provider.get_depth_batch.call_count == 2
|
||||
|
||||
|
||||
def test_tickflow_depth_uses_provider_contract_and_shared_batching(monkeypatch):
|
||||
batch = MagicMock(
|
||||
side_effect=lambda symbols: {symbol: {"ask_volumes": [0]} for symbol in symbols}
|
||||
)
|
||||
tickflow = SimpleNamespace(depth=SimpleNamespace(batch=batch))
|
||||
monkeypatch.setattr(
|
||||
"app.services.preferences.get_depth5_data_provider",
|
||||
lambda: "tickflow",
|
||||
)
|
||||
monkeypatch.setattr("app.data_providers.tickflow_provider.get_client", lambda: tickflow)
|
||||
monkeypatch.setattr(depth_module, "sleep_between_batches", MagicMock())
|
||||
|
||||
result = _service()._call_depth_batch(["A", "B", "C"])
|
||||
|
||||
assert set(result) == {"A", "B", "C"}
|
||||
assert batch.call_args_list == [call(["A", "B"]), call(["C"])]
|
||||
|
||||
|
||||
def test_invalid_custom_depth_contract_fails_closed(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.services.preferences.get_depth5_data_provider",
|
||||
lambda: "broken_depth",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.data_providers.custom.provider_has_dataset",
|
||||
lambda name, dataset: False,
|
||||
)
|
||||
get_provider = MagicMock()
|
||||
monkeypatch.setattr("app.data_providers.custom.get_provider", get_provider)
|
||||
|
||||
assert _service()._call_depth_batch(["A"]) == {}
|
||||
get_provider.assert_not_called()
|
||||
Reference in New Issue
Block a user