From b5a005a8ac2af998ea6afabe9a9d0e299a64ef59 Mon Sep 17 00:00:00 2001 From: 0112020179 Date: Sat, 5 Sep 2026 22:49:52 +0800 Subject: [PATCH] feat(data-source): support custom depth providers Closes #245 --- CONTRIBUTING.md | 7 +- backend/app/data_providers/base.py | 4 + backend/app/data_providers/capabilities.py | 5 +- .../app/data_providers/tickflow_provider.py | 7 + backend/app/main.py | 10 +- backend/app/services/depth_service.py | 44 +++++- backend/app/tickflow/policy.py | 6 +- backend/tests/test_capability_augment.py | 16 ++- backend/tests/test_capability_matrix.py | 20 ++- backend/tests/test_custom_depth_provider.py | 134 ++++++++++++++++++ docs/plugin-development.md | 22 ++- 11 files changed, 242 insertions(+), 33 deletions(-) create mode 100644 backend/tests/test_custom_depth_provider.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ccc4d3..2a47cd5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,6 +95,9 @@ 新增跨边界映射时必须增加单位测试。禁止用“数值小于 1 就乘 100”一类启发式转换,这会掩盖真实数据错误。 +五档盘口的 `bid_volumes` / `ask_volumes` 沿用现有封单量口径,单位为“手”;前端计算 +封单额时再乘 `100` 换算为股。provider 必须在数据边界完成单位转换。 + ### 3.2 价格与复权 - enriched 的 `open/high/low/close` 为前复权价格。 @@ -122,7 +125,7 @@ 数据源已经插件化。任何通用功能都必须通过 provider 能力和标准化数据集访问数据,不能把 TickFlow SDK 调用硬编码到策略、监控、回测、API 或前端流程中。 - 使用现有的 `get_provider()`、`provider_has_dataset()` 和 preferences 路由能力。 -- 支持的数据集包括但不限于 `daily`、`adj_factor`、`minute`、`full_minute`(盘中全市场分钟落盘)、`realtime`、`financial`;新增数据集应先定义清晰的输入输出契约。 +- 支持的数据集包括但不限于 `daily`、`adj_factor`、`minute`、`full_minute`(盘中全市场分钟落盘)、`realtime`、`depth5`、`financial`;新增数据集应先定义清晰的输入输出契约。 - provider 负责把供应商字段、单位、日期和代码格式转换为内部标准格式。 - 上层服务依赖标准字段和能力声明,不依赖供应商响应结构。 - 只有明确标注为 TickFlow 专属的功能才可以直接依赖 TickFlow,并且不得影响其他 provider。 @@ -138,7 +141,7 @@ - 各页面能力门控统一以矩阵的 `usable` 为准(生效源当前能否真正提供该能力),不是 TickFlow 套餐视角;缺能力提示统一引导到数据源配置。 - 能力层中立:通用界面(侧栏徽章、能力路由卡、各页门控提示)不得出现 TickFlow 档位/订阅词汇;档位信息只在 TickFlow 专属详情卡展示。provider 名称作为路由事实可以出现。 - 每个能力独立路由,禁止跟随/派生特殊值(`same_as_daily` 已下线);存量非法偏好值由 preferences getter 回退默认自愈,不做迁移。 -- 边界注记:分时监控由分钟能力兜底(`intraday_monitor_support`),不单设分时能力;`full_minute`(全量分钟)数据集已开放插件/自定义源声明;`depth5` 已进矩阵但插件数据集白名单暂未开放,当前仅 TickFlow 提供。 +- 边界注记:分时监控由分钟能力兜底(`intraday_monitor_support`),不单设分时能力;`full_minute`(全量分钟)和 `depth5` 数据集均已开放插件声明。`depth5` 独立路由且失败时不跨源回退。 - 实时指数为产品级固定契约,不走路由矩阵:展示层(侧栏指数条、市场总览)固定核心四只(`backend/app/services/index_const.py` 单一权威:上证/深成/创业板/科创综指),后端各消费方与前端 Layout 引用同一份定义不建副本;指数页保留但标的固定为核心四只(无全指数搜索/浏览,`/api/index/list`、`/api/index/search` 已下线);侧栏指数多选配置已下线,相关偏好(`realtime_index_symbols`/`sidebar_index_symbols`/`indices_nav_pinned`/`realtime_pull_index`/`realtime_index_mode`)已删除。监控规则的指数标的不受限——quote_service 把核心四只 + 启用规则的指数并入显式拉取。 - 自定义源指数补充协议:A 股快照普遍不含指数(fuyao 实测无指数,指数在其独立端点)。provider 可实现可选方法 `get_realtime_indices(symbols) -> list[dict] | None`(record 结构与 realtime 一致),quote_service 在自定义源分支鸭子类型调用补拉;`None` 表示请求失败,保留上轮有效指数缓存,`[]` 表示成功但无数据;未实现的源指数缓存为空,由本地日K兜底接管。fuyao 指数快照有连坐语义——请求混入未知代码整批失败,插件侧必须先行过滤不支持的后缀(如 `.BJ`)。 diff --git a/backend/app/data_providers/base.py b/backend/app/data_providers/base.py index 53b59f4..5c1bfaf 100644 --- a/backend/app/data_providers/base.py +++ b/backend/app/data_providers/base.py @@ -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.""" diff --git a/backend/app/data_providers/capabilities.py b/backend/app/data_providers/capabilities.py index f778eed..ccbb97c 100644 --- a/backend/app/data_providers/capabilities.py +++ b/backend/app/data_providers/capabilities.py @@ -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", diff --git a/backend/app/data_providers/tickflow_provider.py b/backend/app/data_providers/tickflow_provider.py index 100d649..66891c4 100644 --- a/backend/app/data_providers/tickflow_provider.py +++ b/backend/app/data_providers/tickflow_provider.py @@ -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 {} diff --git a/backend/app/main.py b/backend/app/main.py index 78d1b0d..5be8a94 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/services/depth_service.py b/backend/app/services/depth_service.py index 9e7abcf..ab96d3e 100644 --- a/backend/app/services/depth_service.py +++ b/backend/app/services/depth_service.py @@ -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 diff --git a/backend/app/tickflow/policy.py b/backend/app/tickflow/policy.py index 8c1b286..c69e211 100644 --- a/backend/app/tickflow/policy.py +++ b/backend/app/tickflow/policy.py @@ -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 diff --git a/backend/tests/test_capability_augment.py b/backend/tests/test_capability_augment.py index e2ca9e0..ee26d2d 100644 --- a/backend/tests/test_capability_augment.py +++ b/backend/tests/test_capability_augment.py @@ -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"}) diff --git a/backend/tests/test_capability_matrix.py b/backend/tests/test_capability_matrix.py index 3fc7b00..80ee7c1 100644 --- a/backend/tests/test_capability_matrix.py +++ b/backend/tests/test_capability_matrix.py @@ -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): diff --git a/backend/tests/test_custom_depth_provider.py b/backend/tests/test_custom_depth_provider.py new file mode 100644 index 0000000..1137255 --- /dev/null +++ b/backend/tests/test_custom_depth_provider.py @@ -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() diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 6178fa7..7bac229 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -28,7 +28,7 @@ display_name: "我的数据源" # 设置页显示名 runtime: none # 运行时类型: node | python | none entry: app.plugins.my_source.provider:MyProvider # provider 类的导入路径 check: app.plugins.my_source.bridge:availability # 可用性检测函数(可选) -datasets: [realtime] # 支持的数据集: daily/adj_factor/minute/realtime/financial +datasets: [realtime] # 支持: daily/adj_factor/minute/realtime/depth5/financial api_key_env: MY_SOURCE_API_KEY # (可选)声明后设置页提供 Key 输入框 hidden: false # (可选)true = 已加载但对设置页隐藏,不注册不展示 description: "数据源描述" @@ -166,6 +166,9 @@ class MyProvider: 强烈建议实现本方法, 否则指数行情冻结在本地日K兜底。失败返回 None, 成功但无数据返回 []。""" + def get_depth_batch(self, symbols: list[str]) -> dict[str, dict]: + """(声明 depth5 时实现)五档盘口, 返回以 symbol 为键的标准盘口字典。""" + def get_financials(self, table, symbols, latest_only=False) -> pl.DataFrame: """财务数据(声明 financial 数据集时实现, table 见 financial_sync 调用)。""" @@ -178,6 +181,22 @@ class MyProvider: 返回 error 字段说明会回退 TickFlow。""" ``` +`get_depth_batch` 返回结构如下。价格和数量数组均按一档到五档排列;数量单位为“手”, +`timestamp` 为毫秒 Unix 时间戳。服务层按 capability 的 `batch` / `rpm` 统一分片限速, +provider 不应自行切换或回退到其他数据源。 + +```python +{ + "600519.SH": { + "bid_prices": [1500.0, 1499.9, 1499.8, 1499.7, 1499.6], + "bid_volumes": [10, 20, 30, 40, 50], + "ask_prices": [1500.1, 1500.2, 1500.3, 1500.4, 1500.5], + "ask_volumes": [12, 22, 32, 42, 52], + "timestamp": 1788505200000, + }, +} +``` + ### get_minute 的 datetime 时区契约 `datetime` 必须是**北京时间墙钟**(naive,如 `2026-08-28 09:35:00`),与日K的 @@ -219,6 +238,7 @@ class MyProvider: | --- | --- | | `get_realtime` | **软失败**: 返回 `[]` + warning 日志, 保证轮询线程不中断 | | `get_realtime_indices` | **软失败**: 返回 `None` + warning 日志, 保留上轮有效缓存; 成功无数据返回 `[]` | +| `get_depth_batch` | 单批异常由服务隔离并保留其他批次; 不跨数据源回退 | | `get_minute` | 抛异常时调用方自动回退 TickFlow 重试 | | `get_daily` / `get_adj_factors` / `get_financials` | 异常由上层同步流程捕获记录; 无数据返回空 DataFrame |