diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 9cf8098..131d35c 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -397,6 +397,7 @@ class DataProvidersIn(BaseModel): daily_data_provider: str | None = None adj_factor_provider: str | None = None minute_data_provider: str | None = None + depth5_data_provider: str | None = None realtime_data_provider: str | None = None financial_data_provider: str | None = None @@ -483,6 +484,7 @@ def get_preferences() -> dict: "daily_data_provider": preferences.get_daily_data_provider(), "adj_factor_provider": preferences.get_adj_factor_provider(), "minute_data_provider": preferences.get_minute_data_provider(), + "depth5_data_provider": preferences.get_depth5_data_provider(), "realtime_data_provider": preferences.get_realtime_data_provider(), "financial_data_provider": preferences.get_financial_provider(), "data_source_job_timeout_s": preferences.get_data_source_job_timeout_s(), @@ -543,6 +545,31 @@ def list_data_sources() -> dict: } +@router.get("/capability-matrix") +def get_capability_matrix() -> dict: + """能力 x 源路由矩阵: 能力注册表 + 各源能力声明 + 当前路由偏好, 设置页一次拉全。 + + 偏好值经 preferences getters 注入 (自带合法源校验, 非法值回退默认), + TickFlow 当前档位由 tickflow policy 注入 (候选按档位过滤), + 组装逻辑在 data_providers.capabilities, 本层保持薄。 + """ + from app.data_providers.capabilities import build_capability_matrix + from app.services import preferences + from app.tickflow import policy + + return build_capability_matrix( + { + "realtime_data_provider": preferences.get_realtime_data_provider(), + "daily_data_provider": preferences.get_daily_data_provider(), + "minute_data_provider": preferences.get_minute_data_provider(), + "depth5_data_provider": preferences.get_depth5_data_provider(), + "adj_factor_provider": preferences.get_adj_factor_provider(), + "financial_data_provider": preferences.get_financial_provider(), + }, + tickflow_tier=policy.base_tier_name(), + ) + + @router.post("/plugin-key") def save_plugin_key(req: PluginKeyIn) -> dict: """保存插件 API Key(先探后存, 对齐 /tickflow-key 语义)。 @@ -689,9 +716,8 @@ def delete_data_source(name: str, request: Request) -> dict: updates["realtime_data_provider"] = "tickflow" if preferences.get_financial_provider() == name: updates["financial_data_provider"] = "tickflow" - adj = preferences.get_adj_factor_provider() - if adj == name: - updates["adj_factor_provider"] = "same_as_daily" + if preferences.get_adj_factor_provider() == name: + updates["adj_factor_provider"] = "tickflow" if updates: preferences.save(updates) # 删除源可能触发偏好回退 tickflow, 同步刷新能力快照 @@ -737,6 +763,7 @@ def update_data_providers(req: DataProvidersIn, request: Request) -> dict: "daily_data_provider": preferences.get_daily_data_provider(), "adj_factor_provider": preferences.get_adj_factor_provider(), "minute_data_provider": preferences.get_minute_data_provider(), + "depth5_data_provider": preferences.get_depth5_data_provider(), "realtime_data_provider": preferences.get_realtime_data_provider(), "financial_data_provider": preferences.get_financial_provider(), } diff --git a/backend/app/data_providers/capabilities.py b/backend/app/data_providers/capabilities.py new file mode 100644 index 0000000..548bbfa --- /dev/null +++ b/backend/app/data_providers/capabilities.py @@ -0,0 +1,190 @@ +"""能力注册表与能力路由矩阵 — 数据集维度的单一权威定义。 + +能力 (capability) = 一个标准化数据集 (CONTRIBUTING「数据源插件化要求」): +realtime / daily / minute / depth5 / adj_factor / financial。注册表集中声明每个 +能力的展示元数据、路由偏好字段与 TickFlow 档位要求, 前端设置页不再各自硬编码。 +depth5 目前仅 TickFlow 供 (插件数据集白名单未开放, 见 loader), 仍进矩阵是为了 +可用性门控诚实: 五档不可用时连板梯队封单/看板封单缺数据应有提示。 + +build_capability_matrix 把注册表、插件/自定义源的能力声明 (datasets) 和当前 +路由偏好合并为一个矩阵, 供设置页一次拉全。当前偏好由 API 层注入 +(preferences getters 自带合法源校验), 本模块不反向依赖 services 层。 + +候选契约: 每个能力的 candidates 只包含「当前确实可提供该能力」的源 — +TickFlow 按当前订阅档位过滤 (日K全档位, 其余按注册表 tf_tier 门槛), +未就绪的插件/自定义源 (依赖未装/Key 未配) 放入 pending 并携带原因, +供前端置灰提示。其他页面可以把 candidates 直接当作可用提供方名单。 + +usable 契约: 每个能力额外给出 usable = 生效源当前能否真正提供该能力 +(生效源在 candidates 中)。各页面的能力门控 (缺能力提示 → 数据源配置) +统一以 usable 为准, 而不是 TickFlow 套餐视角 — 路由到可用插件时同样可用, +路由到 TickFlow 但档位不足时同样不可用。 +""" + +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", + "desc": "历史K线与实时覆写", + "field": "daily_data_provider", + "default": "tickflow", + "tf_tier": "none", + }, + { + "id": "minute", + "label": "分钟K", + "desc": "分时图与分钟回测", + "field": "minute_data_provider", + "default": "tickflow", + "tf_tier": "pro", + }, + { + "id": "depth5", + "label": "五档盘口", + "desc": "连板梯队封单与盘口深度", + "field": "depth5_data_provider", + "default": "tickflow", + "tf_tier": "pro", + # 插件契约暂未开放 depth5 数据集 (loader 白名单), 当前仅 TickFlow 供 + }, + { + "id": "adj_factor", + "label": "除权因子", + "desc": "前复权计算基准", + "field": "adj_factor_provider", + "default": "tickflow", + "tf_tier": "starter", + # 独立路由 (曾经的「跟随日K」特殊值已下线: 每个能力单独配置, + # 复权口径一致性改由未来的一致性警示保障, 不做路由耦合) + }, + { + "id": "financial", + "label": "财务数据", + "desc": "财务指标与三大报表", + "field": "financial_data_provider", + "default": "tickflow", + "tf_tier": "expert", + }, +] + +_TICKFLOW_CANDIDATE = { + "name": "tickflow", + "display": "TickFlow", + "kind": "builtin", + "available": True, + "status": "ok", + "note": None, +} + +# 档位排序: none 最低 (无 Key/无效 Key, 仅免费通道历史日K), 未知档按 none 处理 (fail-closed) +_TIER_RANK = {"none": -1, "free": 0, "starter": 1, "pro": 2, "expert": 3} + + +def _tier_base(tier: str) -> str: + """归一化档位输入为基础名: "Pro +" -> "pro"; 空值归为 none。""" + text = str(tier or "").strip().lower() + if not text: + return "none" + return text.split()[0].split("+")[0] + + +def _declared_sources() -> list[dict]: + """插件 + 自定义源 → 统一能力声明视图。未注册 (hidden/加载失败) 的源不会出现。""" + rows: list[dict] = [] + for plugin in custom_sources.list_plugins(): + rows.append({ + "name": plugin["name"], + "display": plugin.get("display_name") or plugin["name"], + "datasets": set(plugin.get("datasets") or []), + "available": bool(plugin.get("available")), + "status": str(plugin.get("status") or ""), + "kind": "plugin", + }) + for source in custom_sources.list_sources(): + rows.append({ + "name": source["name"], + "display": source.get("display_name") or source["name"], + "datasets": set(source.get("datasets") or []), + # 自定义源注册即已通过加载校验, 视为可用 + "available": True, + "status": "ok", + "kind": "custom", + }) + return rows + + +def _display_of(sources: list[dict], name: str) -> str: + if name == "tickflow": + return "TickFlow" + for s in sources: + if s["name"] == name: + return s["display"] + return name + + +def build_capability_matrix(current: dict[str, str], tickflow_tier: str = "none") -> dict: + """注册表 + 源能力声明 + 当前偏好 → 能力路由矩阵。 + + current 为 {偏好字段: 当前值}, 由 API 层经 preferences getters 注入; + getters 已把非法值 (未注册源) 回退为默认, 这里直接信任。effective + 即当前值本身 (每个能力独立路由, 无跟随/派生特殊值)。 + + tickflow_tier 为 TickFlow 当前档位基础名 (none/free/starter/pro/expert), + 由 API 层从 tickflow policy 注入。当前档位不提供的能力里 TickFlow + 不进候选, 但偏好仍指向 tickflow 时以 tf_available=False 标记, + 供前端提示「档位不足」。未知档按 none 处理。 + """ + tier_base = _tier_base(tickflow_tier) + tier_rank = _TIER_RANK.get(tier_base, -1) + sources = _declared_sources() + + capabilities = [] + for cap in CAPABILITY_REGISTRY: + effective = current.get(cap["field"], cap["default"]) + tf_available = tier_rank >= _TIER_RANK[cap["tf_tier"]] + candidates: list[dict] = [] + pending: list[dict] = [] + if tf_available: + candidates.append(dict(_TICKFLOW_CANDIDATE)) + for s in sources: + if cap["id"] not in s["datasets"]: + continue + entry = { + "name": s["name"], + "display": s["display"], + "kind": s["kind"], + "available": s["available"], + "status": s["status"], + "note": None if s["available"] else (s["status"] or "不可用"), + } + (candidates if s["available"] else pending).append(entry) + usable = any(c["name"] == effective for c in candidates) + capabilities.append({ + "id": cap["id"], + "label": cap["label"], + "desc": cap["desc"], + "field": cap["field"], + "default": cap["default"], + "tf_tier": cap["tf_tier"], + "tf_available": tf_available, + "usable": usable, + "current": effective, + "current_display": _display_of(sources, effective), + "effective": effective, + "effective_display": _display_of(sources, effective), + "candidates": candidates, + "pending": pending, + }) + return {"tickflow_tier": tier_base, "capabilities": capabilities} diff --git a/backend/app/data_providers/custom/loader.py b/backend/app/data_providers/custom/loader.py index 2a5985b..6f71d83 100644 --- a/backend/app/data_providers/custom/loader.py +++ b/backend/app/data_providers/custom/loader.py @@ -11,6 +11,7 @@ from pathlib import Path import yaml +from app import secrets_store from app.config import settings from app.data_providers.custom.config import ( DEFAULT_TIMEOUT, @@ -89,6 +90,19 @@ def list_plugins() -> list[dict]: return list(_PLUGIN_STATUS.values()) +def _plugin_key_masked(name: str, api_key_env: str) -> str: + """插件当前生效 Key 的脱敏串 (secrets.json 优先, .env 兜底), 未配置返回空。 + + 与 TickFlow Key 的展示契约一致 (settings API 的 tickflow_api_key_masked): + 完整 Key 永不出后端, 只出 mask() 结果, 供设置页常驻显示。 + """ + env = str(api_key_env or "").strip() + if not env: + return "" + key = secrets_store.get_env_backed_secret(f"{name.lower()}_api_key", env) + return secrets_store.mask(key) if key else "" + + def plugin_manifest(name: str) -> dict | None: """读取指定插件的 plugin.yaml 清单。""" plugin_dir = plugins_dir() / (name or "") @@ -560,7 +574,9 @@ def _register_one_plugin(manifest: dict) -> None: "status": reason, "description": manifest.get("description", ""), "install_hint": manifest.get("install_hint", ""), + "homepage": manifest.get("homepage", ""), "api_key_env": manifest.get("api_key_env", ""), + "api_key_masked": _plugin_key_masked(name, manifest.get("api_key_env", "")), } if not available: return # 依赖没装: 不注册, 但状态已记录供 UI 显示 diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index 840f860..3dc47f5 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -303,8 +303,6 @@ def run_now( written_adj = 0 affected_symbols: list[str] = [] adj_provider = _prefs.get_adj_factor_provider() - if adj_provider == "same_as_daily": - adj_provider = _prefs.get_daily_data_provider() can_sync_adj = capset.has(Cap.ADJ_FACTOR) or adj_provider != "tickflow" if can_sync_adj: from datetime import datetime, timedelta diff --git a/backend/app/plugins/fuyao/plugin.yaml b/backend/app/plugins/fuyao/plugin.yaml index aef54dd..7e11613 100644 --- a/backend/app/plugins/fuyao/plugin.yaml +++ b/backend/app/plugins/fuyao/plugin.yaml @@ -9,6 +9,6 @@ entry: app.plugins.fuyao.provider:FuyaoProvider check: app.plugins.fuyao.provider:availability datasets: [realtime] api_key_env: FUYAO_API_KEY # 声明后设置页提供 Key 输入框(先探后存, secrets.json 优先) -hidden: true # 优化完成前不在数据源页展示; 删除此行即可恢复 description: "同花顺官方 REST 数据 API。当前提供 A 股全市场实时快照(分页拉取);日K/分钟/财务未接入,自动回退 TickFlow。" install_hint: "点击卡片中的输入框配置 API Key(https://fuyao.aicubes.cn 申请),或在 .env 中配置 FUYAO_API_KEY" +homepage: "https://fuyao.aicubes.cn" diff --git a/backend/app/services/extend_history.py b/backend/app/services/extend_history.py index dc0037a..0249ad4 100644 --- a/backend/app/services/extend_history.py +++ b/backend/app/services/extend_history.py @@ -167,8 +167,6 @@ def run_extend_history( from app.services import preferences as _prefs adj_provider = _prefs.get_adj_factor_provider() - if adj_provider == "same_as_daily": - adj_provider = _prefs.get_daily_data_provider() can_sync_adj = capset.has(Cap.ADJ_FACTOR) or adj_provider != "tickflow" if can_sync_adj: emit("extend_history", 48, f"获取除权因子 [{adj_start_str} ~ {adj_end_str}]…") diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py index 2350141..2fc1a88 100644 --- a/backend/app/services/kline_sync.py +++ b/backend/app/services/kline_sync.py @@ -335,8 +335,6 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository, return 0, [] provider_name = preferences.get_adj_factor_provider() - if provider_name == "same_as_daily": - provider_name = preferences.get_daily_data_provider() if provider_name != "tickflow": from app.data_providers import custom as custom_sources if custom_sources.provider_has_dataset(provider_name, "adj_factor"): diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 906789d..7da2bf0 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -279,10 +279,9 @@ def get_daily_data_provider() -> str: def get_adj_factor_provider() -> str: - provider = str(load().get("adj_factor_provider", "same_as_daily") or "same_as_daily").lower() - if provider == "same_as_daily": - return provider - return provider if provider in _allowed_data_providers() else "same_as_daily" + # 「跟随日K」(same_as_daily) 特殊值已下线: 存量配置里的旧值按非法值回退 tickflow + provider = str(load().get("adj_factor_provider", "tickflow") or "tickflow").lower() + return provider if provider in _allowed_data_providers() else "tickflow" def get_minute_data_provider() -> str: @@ -290,6 +289,11 @@ def get_minute_data_provider() -> str: return provider if provider in _allowed_data_providers() else "tickflow" +def get_depth5_data_provider() -> str: + provider = str(load().get("depth5_data_provider", "tickflow") or "tickflow").lower() + return provider if provider in _allowed_data_providers() else "tickflow" + + def get_realtime_data_provider() -> str: provider = str(load().get("realtime_data_provider", "tickflow") or "tickflow").lower() return provider if provider in _allowed_data_providers() else "tickflow" diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 9844a5c..d3d1e5d 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -167,13 +167,15 @@ class QuoteService: CORE_INDEX_SYMBOLS = ("000001.SH", "399001.SZ", "399006.SZ", "000680.SH") - # 档位 → 最小轮询间隔 (秒) + # 档位 → 最小轮询间隔 (秒) — TickFlow 档位限速保护, 仅实时源为 tickflow 时适用 TIER_MIN_INTERVAL = { "expert": 1.0, "pro": 3.0, "starter": 6.0, "free": 6.0, } + # 插件/自定义源: 不受 TickFlow 档位保护约束, 通用下限 1s (默认间隔仍为 DEFAULT_INTERVAL) + CUSTOM_PROVIDER_MIN_INTERVAL = 1.0 DEFAULT_INTERVAL = 6.0 MAX_INTERVAL = 60.0 @@ -441,6 +443,11 @@ class QuoteService: @classmethod def _tier_min_interval(cls) -> float: + # 实时源路由到插件/自定义源时, TickFlow 档位限速不适用 (中立能力原则): + # 下限放宽到通用 1s, 默认/已保存间隔不变 + from app.services import preferences + if preferences.get_realtime_data_provider() != "tickflow": + return cls.CUSTOM_PROVIDER_MIN_INTERVAL tier = cls._current_tier() return cls.TIER_MIN_INTERVAL.get(tier, cls.DEFAULT_INTERVAL) diff --git a/backend/app/tickflow/policy.py b/backend/app/tickflow/policy.py index 54b475a..8111981 100644 --- a/backend/app/tickflow/policy.py +++ b/backend/app/tickflow/policy.py @@ -316,8 +316,6 @@ def _augment_custom_sources(capset: CapabilitySet) -> None: daily_provider = preferences.get_daily_data_provider() adj_provider = preferences.get_adj_factor_provider() - if adj_provider == "same_as_daily": - adj_provider = daily_provider active_providers = { "daily": daily_provider, "adj_factor": adj_provider, diff --git a/backend/tests/test_capability_augment.py b/backend/tests/test_capability_augment.py index 5543be6..0044649 100644 --- a/backend/tests/test_capability_augment.py +++ b/backend/tests/test_capability_augment.py @@ -12,7 +12,7 @@ from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet from app.tickflow.policy import _augment_custom_sources -def _set_providers(monkeypatch, *, daily="tickflow", adj="same_as_daily", +def _set_providers(monkeypatch, *, daily="tickflow", adj="tickflow", minute="tickflow", financial="tickflow") -> None: """mock preferences 各数据集 provider getter。""" from app.services import preferences @@ -42,9 +42,9 @@ def test_daily_custom_source_grants_daily_batch(monkeypatch): assert not capset.has(Cap.FINANCIAL) -def test_adj_same_as_daily_resolves_to_daily_provider(monkeypatch): - """adj_factor_provider=same_as_daily → 跟随 daily provider 判定。""" - _set_providers(monkeypatch, daily="mock_src", adj="same_as_daily") +def test_adj_custom_source_grants_adj_factor(monkeypatch): + """adj 显式路由到声明除权的自定义源 → 补授能力 (跟随日K已下线, 独立判定)。""" + _set_providers(monkeypatch, adj="mock_src") _set_datasets(monkeypatch, {"adj_factor"}) capset = CapabilitySet() _augment_custom_sources(capset) diff --git a/backend/tests/test_capability_matrix.py b/backend/tests/test_capability_matrix.py new file mode 100644 index 0000000..4220521 --- /dev/null +++ b/backend/tests/test_capability_matrix.py @@ -0,0 +1,233 @@ +"""能力路由矩阵契约测试。 + +覆盖: 注册表与路由偏好字段一一对应、候选按各源 datasets 声明过滤、 +候选只含当前可用源 (未就绪插件进 pending 并携带原因)、TickFlow 候选 +按当前订阅档位过滤、偏好指向 tickflow 但档位不足时的 tf_available +标记、usable 跟随生效源 (各页能力门控的统一判定)、每能力独立路由 +(「跟随日K」特殊值已下线, 存量旧值由 preferences getter 自愈回退)、 +偏好指向未知源时的回退形态。全部用假插件/自定义源, +不依赖真实网络与本地 data/ 目录。 +""" +from __future__ import annotations + +from app.data_providers import custom as custom_sources +from app.data_providers.capabilities import CAPABILITY_REGISTRY, build_capability_matrix + +DEFAULT_CURRENT = { + "daily_data_provider": "tickflow", + "adj_factor_provider": "tickflow", + "minute_data_provider": "tickflow", + "depth5_data_provider": "tickflow", + "realtime_data_provider": "tickflow", + "financial_data_provider": "tickflow", +} + + +def _fake_sources(monkeypatch, plugins: list[dict], customs: list[dict] | None = None) -> None: + monkeypatch.setattr(custom_sources, "list_plugins", lambda: plugins) + monkeypatch.setattr(custom_sources, "list_sources", lambda: customs or []) + + +def _by_id(matrix: dict) -> dict[str, dict]: + return {c["id"]: c for c in matrix["capabilities"]} + + +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) + assert {c["id"] for c in CAPABILITY_REGISTRY} == { + "realtime", "daily", "minute", "depth5", "adj_factor", "financial", + } + for cap in CAPABILITY_REGISTRY: + assert cap["default"] == "tickflow" + assert cap["tf_tier"] in ("none", "starter", "pro", "expert") + assert "follow" not in cap + + +def test_matrix_without_third_party_sources(monkeypatch): + """无插件无自定义源: 每个能力只剩 TickFlow 候选, 默认路由全部生效。""" + _fake_sources(monkeypatch, []) + matrix = build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="expert") + assert matrix["tickflow_tier"] == "expert" + assert len(matrix["capabilities"]) == 6 + for cap in matrix["capabilities"]: + names = [c["name"] for c in cap["candidates"]] + assert names == ["tickflow"] + assert cap["candidates"][0]["kind"] == "builtin" + assert cap["tf_available"] is True + assert cap["usable"] is True + assert cap["pending"] == [] + assert cap["current"] == cap["effective"] == "tickflow" + + +def test_candidates_only_available_unready_goes_pending(monkeypatch): + """候选只包含声明了该能力且可用的源; 未就绪插件进 pending 并携带原因。""" + _fake_sources( + monkeypatch, + [ + {"name": "fuyao", "display_name": "fuyao", "datasets": ["realtime"], + "available": True, "status": "ok"}, + {"name": "sdk", "display_name": "SDK", "datasets": ["daily", "minute"], + "available": False, "status": "依赖未安装"}, + ], + [{"name": "myhttp", "display_name": "MyHTTP", "datasets": ["financial", "realtime"]}], + ) + caps = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="expert")) + assert [c["name"] for c in caps["realtime"]["candidates"]] == ["tickflow", "fuyao", "myhttp"] + assert [c["name"] for c in caps["daily"]["candidates"]] == ["tickflow"] + assert [c["name"] for c in caps["daily"]["pending"]] == ["sdk"] + assert caps["daily"]["pending"][0]["available"] is False + assert caps["daily"]["pending"][0]["note"] == "依赖未安装" + assert [c["name"] for c in caps["financial"]["candidates"]] == ["tickflow", "myhttp"] + assert caps["financial"]["candidates"][1]["kind"] == "custom" + assert [c["name"] for c in caps["adj_factor"]["candidates"]] == ["tickflow"] + + +def test_tickflow_candidates_filtered_by_tier(monkeypatch): + """TickFlow 只出现在当前档位确实提供的能力候选里 (free: 仅日K)。""" + _fake_sources(monkeypatch, []) + caps = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="free")) + assert caps["daily"]["tf_available"] is True + assert [c["name"] for c in caps["daily"]["candidates"]] == ["tickflow"] + for cap_id in ("realtime", "minute", "depth5", "adj_factor", "financial"): + assert caps[cap_id]["tf_available"] is False + assert [c["name"] for c in caps[cap_id]["candidates"]] == [] + # starter 解锁实时与除权, 分钟/五档/财务仍锁 + caps = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="starter")) + assert caps["realtime"]["tf_available"] is True + assert caps["adj_factor"]["tf_available"] is True + assert caps["minute"]["tf_available"] is False + assert caps["depth5"]["tf_available"] is False + assert caps["financial"]["tf_available"] is False + + +def test_current_tickflow_unmet_tier_flagged(monkeypatch): + """偏好仍指向 tickflow 但档位不足: current 不动, tf_available=False 供前端警示。""" + _fake_sources(monkeypatch, []) + cap = _by_id( + build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="free"), + )["realtime"] + assert cap["current"] == cap["effective"] == "tickflow" + assert cap["tf_available"] is False + assert cap["usable"] is False + assert [c["name"] for c in cap["candidates"]] == [] + + +def test_usable_follows_effective_provider(monkeypatch): + """usable 跟随生效源而非 TickFlow 套餐: 各页能力门控的统一判定。 + + - 生效源是可用插件 → usable (即使 TickFlow 档位不足); + - 生效源是未就绪插件 → 不可用 (即使有其他可用候选); + - 除权独立路由, 档位门槛/插件可用性同样生效。 + """ + _fake_sources( + monkeypatch, + [ + {"name": "fuyao", "display_name": "fuyao", "datasets": ["realtime", "minute"], + "available": True, "status": "ok"}, + {"name": "sdk", "display_name": "SDK", "datasets": ["daily", "adj_factor", "minute"], + "available": False, "status": "依赖未安装"}, + ], + ) + caps = _by_id( + build_capability_matrix( + dict(DEFAULT_CURRENT, realtime_data_provider="fuyao", minute_data_provider="sdk"), + tickflow_tier="free", + ), + ) + # 路由到可用插件: TickFlow 档位不足不影响 usable + assert caps["realtime"]["tf_available"] is False + assert caps["realtime"]["usable"] is True + # 路由到未就绪插件: 有可用候选 (fuyao) 也不算 usable + minute = caps["minute"] + assert [c["name"] for c in minute["candidates"]] == ["fuyao"] + assert minute["usable"] is False + # 除权默认路由 tickflow: 自身档位门槛 (starter+) 生效, free 档不可用 + # (独立路由, 不再随日K联动) + assert caps["adj_factor"]["effective"] == "tickflow" + assert caps["adj_factor"]["tf_available"] is False + assert caps["adj_factor"]["usable"] is False + # 除权显式路由到未就绪 sdk → 不可用 (有 tickflow 候选也不算, 日K不受影响) + caps = _by_id( + build_capability_matrix( + dict(DEFAULT_CURRENT, adj_factor_provider="sdk"), tickflow_tier="expert", + ), + ) + assert [c["name"] for c in caps["adj_factor"]["candidates"]] == ["tickflow"] + assert caps["adj_factor"]["effective"] == "sdk" + assert caps["adj_factor"]["usable"] is False + assert caps["daily"]["usable"] is True + + +def test_unknown_or_empty_tier_fails_closed(monkeypatch): + """未知档 (探测缺失) 与空档按 none 处理: 仅全档位能力 (日K) 保留 TickFlow。""" + _fake_sources(monkeypatch, []) + for tier in ("", "unknown", None): + matrix = build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier=tier) + caps = _by_id(matrix) + assert [c["name"] for c in caps["daily"]["candidates"]] == ["tickflow"] + assert caps["realtime"]["tf_available"] is False + assert caps["realtime"]["candidates"] == [] + assert caps["minute"]["tf_available"] is False + + +def test_adj_factor_routes_independently(monkeypatch): + """除权独立路由 (跟随日K已下线): 显式切到声明除权的插件即生效, 与日K当前源无关。 + + 历史遗留值 same_as_daily 不再被矩阵特判 — 存量配置经 preferences getter + 按非法源回退 tickflow (getter 层自愈), 矩阵只信任注入值。 + """ + _fake_sources( + monkeypatch, + [{"name": "sdk", "display_name": "SDK", "datasets": ["adj_factor"], + "available": True, "status": "ok"}], + ) + # 日K走 tickflow, 除权显式走 sdk → 互不影响 (TickFlow none 档下插件照常可用) + caps = _by_id( + build_capability_matrix( + dict(DEFAULT_CURRENT, adj_factor_provider="sdk"), tickflow_tier="none", + ), + ) + adj = caps["adj_factor"] + assert adj["current"] == adj["effective"] == "sdk" + assert adj["usable"] is True + assert caps["daily"]["effective"] == "tickflow" + assert caps["daily"]["usable"] is True + + +def test_depth5_capability_semantics(monkeypatch): + """五档: pro+ 档 TickFlow 可供 (usable); 档位不足时不可用且无候选。 + + 插件数据集白名单未开放 depth5, 假插件即使声明其他数据集也不进五档候选; + 未来契约开放后声明 depth5 的源会自然成为候选 (candidates 按 datasets 过滤)。 + """ + _fake_sources( + monkeypatch, + [{"name": "fuyao", "display_name": "fuyao", "datasets": ["realtime"], + "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 cap["usable"] is True + # starter 档: 档位不足 → 无候选, usable False (连板梯队封单缺数据) + cap = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="starter"))["depth5"] + assert cap["tf_available"] is False + assert cap["candidates"] == [] + assert cap["usable"] is False + + +def test_unknown_current_display_falls_back_to_name(monkeypatch): + """偏好指向未注册源 (正常经 getters 校验不会发生): 展示回退为原始名, 不抛异常。""" + _fake_sources(monkeypatch, []) + caps = _by_id( + build_capability_matrix( + dict(DEFAULT_CURRENT, realtime_data_provider="ghost"), tickflow_tier="expert", + ), + ) + assert caps["realtime"]["current"] == "ghost" + assert caps["realtime"]["current_display"] == "ghost" + assert caps["realtime"]["effective_display"] == "ghost" diff --git a/backend/tests/test_fuyao_provider.py b/backend/tests/test_fuyao_provider.py index 6bfdfed..7e158d0 100644 --- a/backend/tests/test_fuyao_provider.py +++ b/backend/tests/test_fuyao_provider.py @@ -369,13 +369,52 @@ def test_manifest_declares_realtime_dataset(): def test_hidden_plugin_not_registered(): - """hidden: true 的插件不注册、不在数据源页展示 (优化完成前隐藏 fuyao)。""" + """fuyao 已取消隐藏 (plugin.yaml 不再声明 hidden); hidden 机制本身仍生效。 + + 用合成清单验证: hidden: true 的插件不注册、不在数据源页展示。 + """ from app.data_providers.custom import loader manifest = loader.plugin_manifest("fuyao") - assert manifest.get("hidden") is True + assert manifest is not None and not manifest.get("hidden"), ( + "fuyao 应保持可见; 如需重新隐藏请在 plugin.yaml 声明 hidden 并更新本测试" + ) loader._register_one_plugin(manifest) - assert "fuyao" not in loader._PLUGIN_STATUS - assert "fuyao" not in loader._PROVIDERS + assert "fuyao" in loader._PLUGIN_STATUS + + hidden_manifest = dict(manifest, name="hidden_probe", hidden=True) + loader._register_one_plugin(hidden_manifest) + assert "hidden_probe" not in loader._PLUGIN_STATUS + assert "hidden_probe" not in loader._PROVIDERS + + +# ---- 插件 Key 脱敏展示 (与 TickFlow Key 契约一致) ---- + +def test_plugin_key_masked_from_secrets_then_env(monkeypatch): + """api_key_masked 随插件状态返回: secrets.json 优先, .env 兜底, 未配置为空。 + + 完整 Key 不出后端, 只出 mask() 结果 — 与 settings API 的 + tickflow_api_key_masked 同一展示契约。 + """ + from app import secrets_store + from app.data_providers.custom import loader + + # 未声明 api_key_env / 未配置 Key → 空 + assert loader._plugin_key_masked("x", "") == "" + monkeypatch.setattr(secrets_store, "load", lambda: {}) + monkeypatch.delenv(fp.API_KEY_ENV, raising=False) + assert loader._plugin_key_masked("fuyao", fp.API_KEY_ENV) == "" + + # .env 兜底 + monkeypatch.setenv(fp.API_KEY_ENV, "env-secret-key-123456") + assert loader._plugin_key_masked("fuyao", fp.API_KEY_ENV) == "env-••••••3456" + + # secrets.json 优先于 .env + monkeypatch.setattr(secrets_store, "load", lambda: {"fuyao_api_key": "stored-secret-key-999"}) + assert loader._plugin_key_masked("fuyao", fp.API_KEY_ENV) == "stor••••••-999" + + # 注册进插件状态: 数据源列表接口据此常驻展示 (而非仅保存后瞬时显示) + loader._register_one_plugin(loader.plugin_manifest("fuyao")) + assert loader._PLUGIN_STATUS["fuyao"]["api_key_masked"] == "stor••••••-999" # ---- 设置页试拉 ---- diff --git a/backend/tests/test_quote_interval_min.py b/backend/tests/test_quote_interval_min.py new file mode 100644 index 0000000..99efac3 --- /dev/null +++ b/backend/tests/test_quote_interval_min.py @@ -0,0 +1,48 @@ +"""实时行情轮询间隔下限契约测试。 + +下限语义 (中立能力原则): +- 实时源路由到插件/自定义源时, TickFlow 档位限速保护不适用 → 通用下限 1s; +- 实时源为 tickflow 时, 仍按当前订阅档位查表 (none/free=6s, starter=6s, pro=3s, expert=1s); +- 默认间隔 6s 不因路由变化而改变 (只放宽下限, 不动存量偏好)。 +""" +from __future__ import annotations + +from app.services.quote_service import QuoteService + + +def _route(monkeypatch, provider: str, tier: str) -> None: + from app.services import preferences + monkeypatch.setattr(preferences, "get_realtime_data_provider", lambda: provider) + monkeypatch.setattr(QuoteService, "_current_tier", classmethod(lambda cls: tier)) + + +def _bare() -> QuoteService: + """绕过 __init__ (单例/线程副作用), 只用无状态方法。""" + return QuoteService.__new__(QuoteService) + + +def test_custom_provider_min_interval_1s(monkeypatch): + """插件/自定义源: 不受 TickFlow 档位保护, 下限放宽到 1s。""" + _route(monkeypatch, "fuyao", "none") + assert _bare().get_min_interval() == 1.0 + + +def test_custom_provider_clamp_allows_1s(monkeypatch): + """fuyao 路由下设置 1s 不再被抬到 6s; 超过上限仍被压回。""" + _route(monkeypatch, "fuyao", "none") + qs = _bare() + assert qs._clamp_interval(1.0) == 1.0 + assert qs._clamp_interval(0.5) == 1.0 + assert qs._clamp_interval(120.0) == QuoteService.MAX_INTERVAL + + +def test_tickflow_tier_floor_unchanged(monkeypatch): + """TickFlow 路由: 档位查表行为不变 (none/free/starter=6s, pro=3s, expert=1s)。""" + for tier, expect in (("none", 6.0), ("free", 6.0), ("starter", 6.0), ("pro", 3.0), ("expert", 1.0)): + _route(monkeypatch, "tickflow", tier) + assert _bare().get_min_interval() == expect, tier + + +def test_default_interval_unchanged(): + """默认间隔仍是 6s — 放宽的只是下限, 不是默认值。""" + assert QuoteService.DEFAULT_INTERVAL == 6.0