mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat(data): 能力路由矩阵落地——五档入册、除权独立路由、插件源下限放宽
- 新增能力注册表 capabilities.py 与 /capability-matrix: 六能力(实时/日K/ 分钟/五档/除权/财务)统一 candidates/pending/usable, 各页门控以 usable 为准 - 五档盘口 (depth5) 进入矩阵: tf_tier=pro, 插件数据集白名单暂未开放 - 除权「跟随日K」(same_as_daily) 下线: 每能力独立路由, 四处跟随解析移除, 存量旧值经 preferences getter 回退 tickflow 自愈; 矩阵显示与实际拉取口径一致 - 实时轮询下限: 路由到插件/自定义源时不受 TickFlow 档位限速, 通用下限 1s (默认间隔仍 6s, TickFlow 路由档位查表不变) - 插件 manifest 新增 homepage 透传; loader 输出 api_key_masked (Key 脱敏 与 TickFlow 同一展示契约); fuyao 声明官网
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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 显示
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}]…")
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user