diff --git a/backend/app/data_providers/capabilities.py b/backend/app/data_providers/capabilities.py index 548bbfa..c6bb1d1 100644 --- a/backend/app/data_providers/capabilities.py +++ b/backend/app/data_providers/capabilities.py @@ -1,7 +1,7 @@ """能力注册表与能力路由矩阵 — 数据集维度的单一权威定义。 能力 (capability) = 一个标准化数据集 (CONTRIBUTING「数据源插件化要求」): -realtime / daily / minute / depth5 / adj_factor / financial。注册表集中声明每个 +daily / adj_factor / realtime / minute / depth5 / financial (注册表顺序即设置页卡片顺序)。注册表集中声明每个 能力的展示元数据、路由偏好字段与 TickFlow 档位要求, 前端设置页不再各自硬编码。 depth5 目前仅 TickFlow 供 (插件数据集白名单未开放, 见 loader), 仍进矩阵是为了 可用性门控诚实: 五档不可用时连板梯队封单/看板封单缺数据应有提示。 @@ -26,14 +26,6 @@ from __future__ import annotations from app.data_providers import custom as custom_sources CAPABILITY_REGISTRY: list[dict] = [ - { - "id": "realtime", - "label": "实时行情", - "desc": "全市场实时快照", - "field": "realtime_data_provider", - "default": "tickflow", - "tf_tier": "starter", - }, { "id": "daily", "label": "日K", @@ -42,6 +34,24 @@ CAPABILITY_REGISTRY: list[dict] = [ "default": "tickflow", "tf_tier": "none", }, + { + "id": "adj_factor", + "label": "除权因子", + "desc": "前复权计算基准", + "field": "adj_factor_provider", + "default": "tickflow", + "tf_tier": "starter", + # 独立路由 (曾经的「跟随日K」特殊值已下线: 每个能力单独配置, + # 复权口径一致性改由未来的一致性警示保障, 不做路由耦合) + }, + { + "id": "realtime", + "label": "实时行情", + "desc": "全市场实时快照", + "field": "realtime_data_provider", + "default": "tickflow", + "tf_tier": "starter", + }, { "id": "minute", "label": "分钟K", @@ -59,16 +69,6 @@ CAPABILITY_REGISTRY: list[dict] = [ "tf_tier": "pro", # 插件契约暂未开放 depth5 数据集 (loader 白名单), 当前仅 TickFlow 供 }, - { - "id": "adj_factor", - "label": "除权因子", - "desc": "前复权计算基准", - "field": "adj_factor_provider", - "default": "tickflow", - "tf_tier": "starter", - # 独立路由 (曾经的「跟随日K」特殊值已下线: 每个能力单独配置, - # 复权口径一致性改由未来的一致性警示保障, 不做路由耦合) - }, { "id": "financial", "label": "财务数据", @@ -77,6 +77,16 @@ CAPABILITY_REGISTRY: list[dict] = [ "default": "tickflow", "tf_tier": "expert", }, + { + "id": "full_minute", + "label": "全量分钟", + "desc": "盘中全市场当日分钟落盘 (冷启动全天 + 标的池增量)", + "field": None, + "default": "tickflow", + "tf_tier": "expert", + # intraday.universe 能力 (TickFlow Expert 专有): 插件契约不开放此数据集, + # 生效源恒为 TickFlow — 不可路由, 无对应 provider 偏好字段 + }, ] _TICKFLOW_CANDIDATE = { @@ -152,7 +162,8 @@ def build_capability_matrix(current: dict[str, str], tickflow_tier: str = "none" capabilities = [] for cap in CAPABILITY_REGISTRY: - effective = current.get(cap["field"], cap["default"]) + # field=None → 不可路由能力 (仅 TickFlow 提供, 无路由偏好), 生效源恒为默认 + effective = current.get(cap["field"], cap["default"]) if cap["field"] else cap["default"] tf_available = tier_rank >= _TIER_RANK[cap["tf_tier"]] candidates: list[dict] = [] pending: list[dict] = [] diff --git a/backend/tests/test_capability_matrix.py b/backend/tests/test_capability_matrix.py index 4220521..351de72 100644 --- a/backend/tests/test_capability_matrix.py +++ b/backend/tests/test_capability_matrix.py @@ -33,13 +33,17 @@ def _by_id(matrix: dict) -> dict[str, dict]: def test_registry_covers_all_routing_fields(): - """注册表是能力的单一权威: 六个能力、字段名与偏好键一一对应、无重复。""" - fields = [c["field"] for c in CAPABILITY_REGISTRY] - assert sorted(fields) == sorted(DEFAULT_CURRENT) - assert len(set(fields)) == len(fields) + """注册表是能力的单一权威: 可路由能力与偏好键一一对应、无重复; + full_minute 为不可路由能力 (field=None, 仅 TickFlow Expert 提供)。""" + routable = [c["field"] for c in CAPABILITY_REGISTRY if c["field"] is not None] + assert sorted(routable) == sorted(DEFAULT_CURRENT) + assert len(set(routable)) == len(routable) assert {c["id"] for c in CAPABILITY_REGISTRY} == { - "realtime", "daily", "minute", "depth5", "adj_factor", "financial", + "realtime", "daily", "minute", "full_minute", "depth5", "adj_factor", "financial", } + full_minute = next(c for c in CAPABILITY_REGISTRY if c["id"] == "full_minute") + assert full_minute["field"] is None + assert full_minute["tf_tier"] == "expert" for cap in CAPABILITY_REGISTRY: assert cap["default"] == "tickflow" assert cap["tf_tier"] in ("none", "starter", "pro", "expert") @@ -51,7 +55,7 @@ def test_matrix_without_third_party_sources(monkeypatch): _fake_sources(monkeypatch, []) matrix = build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="expert") assert matrix["tickflow_tier"] == "expert" - assert len(matrix["capabilities"]) == 6 + assert len(matrix["capabilities"]) == 7 for cap in matrix["capabilities"]: names = [c["name"] for c in cap["candidates"]] assert names == ["tickflow"] @@ -231,3 +235,22 @@ def test_unknown_current_display_falls_back_to_name(monkeypatch): assert caps["realtime"]["current"] == "ghost" assert caps["realtime"]["current_display"] == "ghost" assert caps["realtime"]["effective_display"] == "ghost" + + +def test_full_minute_row_is_non_routable_expert_only(monkeypatch): + """全量分钟行: field=None 不可路由, 生效源恒为 TickFlow, 按 expert 档判定可用。""" + _fake_sources(monkeypatch, []) + # 即使有插件声明别的数据集也不会成为全量分钟候选 (契约不开放该数据集) + caps = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="expert")) + fm = caps["full_minute"] + assert fm["field"] is None + assert [c["name"] for c in fm["candidates"]] == ["tickflow"] + assert fm["usable"] is True + assert fm["tf_available"] is True + assert fm["effective"] == "tickflow" + + caps_pro = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="pro")) + fm_pro = caps_pro["full_minute"] + assert fm_pro["candidates"] == [] + assert fm_pro["usable"] is False + assert fm_pro["tf_available"] is False diff --git a/frontend/src/pages/settings/DataSources.tsx b/frontend/src/pages/settings/DataSources.tsx index a90be42..6eb5e48 100644 --- a/frontend/src/pages/settings/DataSources.tsx +++ b/frontend/src/pages/settings/DataSources.tsx @@ -50,7 +50,9 @@ const DATASET_LABEL: Record = { daily: '日K', minute: '分钟', adj_factor: '除权', + depth5: '五档', financial: '财务', + full_minute: '全量分钟', } /** 能力图标 (纯展示; 能力清单本身由后端注册表驱动) */ @@ -58,6 +60,7 @@ const CAP_ICON: Record> = { realtime: Radio, daily: CandlestickIcon, minute: Timer, + full_minute: Zap, adj_factor: Scale, financial: Landmark, } @@ -103,9 +106,10 @@ function AllTiersBadge({ size = 'text-[10px]' }: { size?: string }) { } /** 提供方标签样式: 当前项高亮(accent), 其余弱化可点; 未就绪源禁用置灰 */ -function tagCls(active: boolean, disabled = false) { +function tagCls(active: boolean, disabled = false, interactive = true) { const base = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] transition-colors select-none' if (disabled) return `${base} bg-elevated/40 text-muted/50 cursor-not-allowed` + if (!interactive) return `${base} cursor-default ${active ? 'bg-accent/15 text-accent font-medium' : 'bg-elevated/60 text-muted/70'}` return `${base} cursor-pointer disabled:opacity-50 ${ active ? 'bg-accent/15 text-accent font-medium' @@ -131,7 +135,7 @@ function patchMatrix( ): CapabilityMatrix { const caps = matrix.capabilities.map(c => ({ ...c })) for (const cap of caps) { - const value = changes[cap.field] + const value = cap.field != null ? changes[cap.field] : undefined if (value !== undefined) { cap.current = value cap.current_display = displayOfName({ capabilities: caps }, value) @@ -197,12 +201,26 @@ function CapabilityCard({ cap, pendingKey, onSelect }: {
{cap.candidates.map(c => { const active = cap.current === c.name + // field=null → 不可路由能力 (仅 TickFlow 提供): 渲染为非交互标签, + // 保持激活高亮但不可点击 (用 button+disabled 会被 opacity-50 冲淡成灰色) + const routable = cap.field != null + if (!routable) { + return ( + + {c.display} + + ) + } return ( - ) - ) : ( + {/* 独立状态行仅用于无 Key 配置区的插件; 有 Key 区时「状态」行已展示, 避免重复 */} + {!plugin.available && !plugin.api_key_env && ( +
{plugin.status} - )} -
+
+ )} {/* API Key 配置 (声明了 api_key_env 的插件) */} {plugin.api_key_env && ( @@ -1108,37 +1120,41 @@ function TickFlowDetail({ active, matrix }: { active: boolean; matrix?: Capabili > - {/* 可用功能: 收进悬停浮层, 不占版面 (能力清单 + 限频)。图标在标题行右侧, 浮层向左展开 */} + {/* 可用功能: 收进悬停浮层, 不占版面 (能力清单 + 限频)。图标在标题行右侧, 浮层向左展开。 + 外层 top-full + pt-1.5: 间隙用内边距做, hover 区与图标无缝衔接 (mt 间隙会断 hover 链); + 不设 pointer-events-none, 否则鼠标移不进浮层、列表无法滚动。 */}
-
-
- 可用功能 - {capEntries.length} 项 -
- {capEntries.length > 0 ? ( -
- {capEntries.map(([cap, lim]) => ( -
- - {CAP_LABELS[cap]?.name ?? cap} - - - {lim.rpm ? `${lim.rpm}/min` : lim.subscribe ? `${lim.subscribe} 订阅` : '—'} - {lim.batch ? ` · ${lim.batch}/次` : ''} - -
- ))} +
+
+
+ 可用功能 + {capEntries.length} 项
- ) : ( -
- 暂无 — 配置 API Key 后自动检测 + {capEntries.length > 0 ? ( +
+ {capEntries.map(([cap, lim]) => ( +
+ + {CAP_LABELS[cap]?.name ?? cap} + + + {lim.rpm ? `${lim.rpm}/min` : lim.subscribe ? `${lim.subscribe} 订阅` : '—'} + {lim.batch ? ` · ${lim.batch}/次` : ''} + +
+ ))} +
+ ) : ( +
+ 暂无 — 配置 API Key 后自动检测 +
+ )} +
+ 根据 API Key 自动检测
- )} -
- 根据 API Key 自动检测