feat(data-source): support custom depth providers

Closes #245
This commit is contained in:
0112020179
2026-09-05 22:49:52 +08:00
parent e9359c4fa7
commit b5a005a8ac
11 changed files with 242 additions and 33 deletions
+4
View File
@@ -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."""
+2 -3
View File
@@ -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
View File
@@ -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
+38 -6
View File
@@ -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
+3 -3
View File
@@ -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
+14 -2
View File
@@ -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"})
+9 -11
View File
@@ -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):
+134
View File
@@ -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()