Merge feat/tier-5level-none: API 检测重构为 5 档(新增「无」档)

This commit is contained in:
shy3130
2026-06-24 17:46:17 +08:00
20 changed files with 440 additions and 102 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""TickFlow Stock Panel backend."""
__version__ = "0.1.35"
__version__ = "0.1.36"
+3 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from fastapi import APIRouter
from app import __version__
from app.config import settings
from app.tickflow import client as tf_client
from app.tickflow.policy import detect_capabilities, tier_label
router = APIRouter()
@@ -15,7 +15,8 @@ def health() -> dict:
return {
"status": "ok",
"version": __version__,
"mode": "free" if settings.use_free_mode else "api_key",
# 三态: none(无key/无效) / free(免费key) / api_key(付费档)
"mode": tf_client.current_mode(),
}
+89 -26
View File
@@ -69,12 +69,12 @@ class SwitchEndpointIn(BaseModel):
def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
"""切换 TickFlow 端点并立即生效。
endpoints.json 里的端点都是 Starter+ 付费端点,Free 模式无 key
无法使用,故 Free 模式下禁止切换。
端点切换仅对付费档(starter+,走 api.tickflow.org)有意义;
none/free 档运行在 free-api 服务器,无付费端点权限,禁止切换。
"""
# Free 模式没有付费端点权限,禁止切换
if tf_client.current_mode() == "free":
return {"ok": False, "error": "Free 模式无法切换端点,请先配置 API Key"}
# none/free 没有付费端点权限,禁止切换
if tf_client.current_mode() != "api_key":
return {"ok": False, "error": "当前档位无法切换端点,仅付费套餐(Starter+)支持"}
url = req.url.strip().rstrip("/")
if not url.startswith("https://"):
@@ -95,44 +95,89 @@ def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
"""保存 TickFlow API Key 并立即重新探测能力。
端点联动:Free → Starter+ 时,Free 模式残留的 free-api 端点不可用于
付费 Key,故自动切到默认付费端点(api.tickflow.org)。
先探后存(关键改动,修复乱填 key 也会被持久化的问题):
1. 临时用新 key 探测(付费端点),判定档位
2. 判定为 none(连单只日K都拿不到)→ key 无效:不存,清除已存的,
返回 {ok: false, reason: "invalid"},前端提示「Key 无效」
3. 判定为 free(免费有效 key)→ 存 key,客户端切到 free-api 服务器
4. 判定为 starter+ → 存 key,切到付费端点(现有逻辑)
端点联动:从无 key 升级到付费 key 时,残留的 free-api 端点不可用,
故自动切到默认付费端点(api.tickflow.org);free 档则清除自定义端点。
"""
from app.tickflow.policy import (
base_tier_name, is_invalid_key,
)
key = req.api_key.strip()
if not key:
return {"ok": False, "error": "key empty"}
# 判断是否 Free → Starter+ 转换(此前无 key)
was_free = tf_client.current_mode() == "free"
updates: dict = {"tickflow_api_key": key}
if was_free:
# 自动切到默认端点;之前的残留自定义 URL 不再适用
updates["tickflow_base_url"] = DEFAULT_PAID_ENDPOINT
secrets_store.save(updates)
# ===== 1) 临时存 key + 重置客户端,让探测走付费端点 =====
secrets_store.save({"tickflow_api_key": key})
tf_client.reset_clients()
# 立即重新探测
# 立即重新探测(此时 client 已按档位判定,但首次探测必然走付费端点验证)
capset = detect_capabilities(force=True)
request.app.state.capabilities = capset
# ===== 2) 判定为无效 key(连单只日K都拿不到)→ 不存,清除 =====
if is_invalid_key() or base_tier_name() == "none":
# 无效 key:清除刚存的,避免乱填被持久化;退回 none 档
secrets_store.clear("tickflow_api_key", "tickflow_base_url")
tf_client.reset_clients()
capset = detect_capabilities(force=True)
request.app.state.capabilities = capset
return {
"ok": False,
"reason": "invalid",
"error": "Key 无效或已过期,请检查后重试",
"mode": "none",
"tier_label": tier_label(),
"current_endpoint": tf_client.current_endpoint(),
"probe_log": [],
"capabilities_count": len(capset.all()),
}
# ===== 3) free 档(免费有效 key)→ 存 key,切到 free-api 服务器 =====
if base_tier_name() == "free":
# 免费档运行时走 free-api 服务器,清除付费端点的自定义配置
secrets_store.clear("tickflow_base_url")
tf_client.reset_clients()
return {
"ok": True,
"tickflow_api_key_masked": secrets_store.mask(key),
"mode": "free",
"tier_label": tier_label(),
"current_endpoint": tf_client.current_endpoint(),
"probe_log": [],
"capabilities_count": len(capset.all()),
}
# ===== 4) starter+ 付费档 → 确保走付费端点(现有逻辑) =====
# 若之前是 none/free(无自定义付费端点),切到默认付费端点
base = secrets_store.load().get("tickflow_base_url")
if not base:
secrets_store.save({"tickflow_base_url": DEFAULT_PAID_ENDPOINT})
tf_client.reset_clients()
return {
"ok": True,
"tickflow_api_key_masked": secrets_store.mask(key),
"mode": "api_key",
"tier_label": tier_label(),
"current_endpoint": tf_client.current_endpoint(),
"probe_log": probe_log(),
"probe_log": [],
"capabilities_count": len(capset.all()),
}
@router.delete("/tickflow-key")
def clear_tickflow_key(request: Request) -> dict:
"""清除 Key,退回 Free 模式
"""清除 Key,退回无档(none)
同时清除 tickflow_base_url(测速切换的自定义端点),使"当前使用"
回到默认节点 api.tickflow.org;SDK 则自动用 free() 取免费数据
同时清除 tickflow_base_url(测速切换的自定义端点),使客户端走 free-api
服务器取历史日K;档位标签为 None(无档)
"""
secrets_store.clear("tickflow_api_key", "tickflow_base_url")
tf_client.reset_clients()
@@ -142,7 +187,7 @@ def clear_tickflow_key(request: Request) -> dict:
return {
"ok": True,
"mode": "free",
"mode": "none",
"tier_label": tier_label(),
"current_endpoint": tf_client.current_endpoint(),
"capabilities_count": len(capset.all()),
@@ -202,6 +247,12 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
# ===== 偏好设置 =====
def _realtime_allowed() -> bool:
"""当前档位是否允许实时行情(none/free 不允许)。"""
from app.services.quote_service import QuoteService
return QuoteService.is_realtime_allowed()
class MinuteSyncPrefs(BaseModel):
minute_sync_enabled: bool
minute_sync_days: int = 5
@@ -213,6 +264,7 @@ def get_preferences() -> dict:
from app.services import preferences
return {
"realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(),
"realtime_allowed": _realtime_allowed(),
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
"minute_sync_days": preferences.get_minute_sync_days(),
@@ -315,19 +367,30 @@ class RealtimeQuotesPrefs(BaseModel):
@router.put("/preferences/realtime-quotes")
def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
"""保存全局实时行情开关。"""
from app.services import preferences
preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
"""保存全局实时行情开关。
# 动态启停行情服务
none/free 档无实时行情权限:拒绝开启,persist 为关闭并返回 allowed=False,
前端据此把开关置灰 / 回弹。
"""
from app.services import preferences
qs = getattr(request.app.state, "quote_service", None)
allowed = qs.is_realtime_allowed() if qs else True
if req.realtime_quotes_enabled and not allowed:
# 当前档位不允许开启实时行情 — 强制关闭
preferences.save({"realtime_quotes_enabled": False})
if qs:
qs.disable()
return {"realtime_quotes_enabled": False, "realtime_allowed": False}
preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
if qs:
if req.realtime_quotes_enabled:
qs.enable()
else:
qs.disable()
return {"realtime_quotes_enabled": req.realtime_quotes_enabled}
return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed}
class IndicesNavPinnedPrefs(BaseModel):
+18 -1
View File
@@ -16,6 +16,7 @@ from app.api.routes import router as core_router
from app.config import settings
from app.jobs import daily_pipeline
from app.services.quote_service import QuoteService
from app.tickflow import client as tf_client
from app.tickflow.policy import detect_capabilities
from app.tickflow.repository import DataStore, KlineRepository
@@ -30,7 +31,7 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI):
logger.info(
"TickFlow Stock Panel v%s starting (mode=%s)",
__version__, "free" if settings.use_free_mode else "api_key",
__version__, tf_client.current_mode(),
)
# 数据层
@@ -197,6 +198,22 @@ app.include_router(signals.router)
app.include_router(monitor_rules.router)
app.include_router(alerts.router)
# 能力门控异常 → 403(而非默认 500)
# 业务代码用 capset.require(Cap.X) 断言能力,缺失时抛 CapabilityDenied;
# 若不注册 handler 会冒泡成 500 Internal Server Error,对前端不友好且语义错误。
from fastapi import Request
from fastapi.responses import JSONResponse
from app.tickflow.capabilities import CapabilityDenied
@app.exception_handler(CapabilityDenied)
async def capability_denied_handler(request: Request, exc: CapabilityDenied) -> JSONResponse:
return JSONResponse(
status_code=403,
content={"detail": str(exc), "suggestion": exc.suggestion},
)
# 生产期静态文件(前端 dist)
_static = Path(settings.static_dir)
if _static.exists():
+28 -3
View File
@@ -99,8 +99,15 @@ class QuoteService:
self._save_enabled(False)
logger.info("行情服务已停止")
def enable(self) -> None:
"""开启自动行情 (不立即启动线程,等下一个交易时段)。"""
def enable(self) -> bool:
"""开启自动行情 (不立即启动线程,等下一个交易时段)。
none/free 档无实时行情权限,拒绝开启并返回 False;
starter+ 正常启动。返回值表示是否真正开启。
"""
if not self.is_realtime_allowed():
logger.warning("实时行情开启被拒:当前档位(none/free)无实时行情权限")
return False
self._enabled = True
self._save_enabled(True)
if not self._running:
@@ -117,8 +124,17 @@ class QuoteService:
logger.info("行情服务已关闭")
def boot_check(self) -> None:
"""启动时检查 preferences,若 enabled 则自动启动。"""
"""启动时检查 preferences,若 enabled 则自动启动。
none/free 档无实时行情权限:即使 preferences 标记为 enabled,
也不启动,并同步 preferences 为关闭(避免 UI 误显示已开启)。
"""
from app.services import preferences
if not self.is_realtime_allowed():
if preferences.get_realtime_quotes_enabled():
self._save_enabled(False)
logger.info("实时行情未启动:当前档位(none/free)无实时行情权限")
return
if preferences.get_realtime_quotes_enabled():
self.start()
@@ -182,6 +198,15 @@ class QuoteService:
from app.tickflow.policy import tier_label
return tier_label().split()[0].split("+")[0].strip().lower()
@classmethod
def is_realtime_allowed(cls) -> bool:
"""当前档位是否允许使用实时行情。
none/free 档走 free-api 服务器,无实时行情权限 → 不允许;
starter+ 付费档走付费端点,有实时行情 → 允许。
"""
return cls._current_tier() not in ("none", "free")
@classmethod
def _tier_min_interval(cls) -> float:
tier = cls._current_tier()
+4
View File
@@ -97,6 +97,10 @@ def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8
elif capset.has(Cap.QUOTE_BY_SYMBOL):
lim = capset.limits(Cap.QUOTE_BY_SYMBOL)
batch_size = lim.batch if lim and lim.batch else 5
else:
# 无任何实时行情能力(none/free 档走 free-api 服务器,不提供实时行情)
# 提前返回空,避免发起注定失败的请求
return []
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
+47 -10
View File
@@ -2,6 +2,11 @@
进程内单例;Key 来源(优先级):secrets.json > .env。
用户改 Key 后需要 `reset_clients()`,然后 `get_client()` 会拿新的。
5 档体系下服务器归属:
- none 档(无 key / 无效 key) → TickFlow.free()(free-api 服务器)
- free 档(免费有效 key) → TickFlow.free()(key 被 SDK 忽略,运行时走 free-api)
- starter/pro/expert(付费 key) → TickFlow(api_key=key, base_url)
"""
from __future__ import annotations
@@ -15,6 +20,27 @@ _sync_client: TickFlow | None = None
_async_client: AsyncTickFlow | None = None
# ===== 服务器归属判定 =====
# free-api 服务器默认节点(SDK 默认值),none/free 档运行时走这里。
FREE_ENDPOINT = "https://free-api.tickflow.org"
# 付费端点默认节点(starter+ 运行时走这里,也是端点切换的默认值)。
PAID_ENDPOINT = "https://api.tickflow.org"
def _should_use_free_server() -> bool:
"""是否应走 free-api 服务器。
判定依据:无 key,或当前档位为 none/free。
付费档(starter+)走付费端点。
"""
if not secrets_store.get_tickflow_key():
return True
# 有 key 时按探测出的档位判定(避免读 capabilities.json 在首次启动前未生成的边界)
from app.tickflow.policy import base_tier_name
return base_tier_name() in ("none", "free")
def _base_url() -> str | None:
"""从 secrets.json 读取用户自定义端点,没有则返回 None(用 SDK 默认)。"""
return secrets_store.load().get("tickflow_base_url") or None
@@ -25,8 +51,8 @@ def get_client() -> TickFlow:
global _sync_client
if _sync_client is None:
key = secrets_store.get_tickflow_key()
if not key:
# Free 模式:付费端点 URL 不可用,忽略 base_url 走 SDK 默认 free-api
if _should_use_free_server():
# none/free 档:走 free-api 服务器(无 key 或免费 key 被 SDK 忽略)
_sync_client = TickFlow.free()
else:
_sync_client = TickFlow(api_key=key, base_url=_base_url())
@@ -38,8 +64,7 @@ def get_async_client() -> AsyncTickFlow:
global _async_client
if _async_client is None:
key = secrets_store.get_tickflow_key()
if not key:
# Free 模式:付费端点 URL 不可用,忽略 base_url 走 SDK 默认 free-api
if _should_use_free_server():
_async_client = AsyncTickFlow.free()
else:
_async_client = AsyncTickFlow(api_key=key, base_url=_base_url())
@@ -54,19 +79,31 @@ def reset_clients() -> None:
def current_mode() -> str:
"""供 UI 显示当前模式。"""
return "api_key" if secrets_store.get_tickflow_key() else "free"
"""供 UI 显示当前模式。三态:
- "none" : 无 key / 无效 key(走 free-api,仅历史日K)
- "free" : 免费有效 key(走 free-api,仅历史日K)
- "api_key" : 付费 key(starter+,走付费端点,有实时行情)
"""
if not secrets_store.get_tickflow_key():
return "none"
from app.tickflow.policy import base_tier_name
tier = base_tier_name()
if tier in ("none", "free"):
return "free" if tier == "free" else "none"
return "api_key"
def current_endpoint() -> str:
"""返回当前显示用的端点 URL(对应 endpoints.json 列表项)。
注:SDK 的 TickFlow.free() 内部实际走 free-api,但 UI 显示统一用默认
节点(api.tickflow.org),使"当前使用"始终对得上端点列表里的某一项。
- none/free 档:显示 free-api 服务器节点
- 付费档:显示用户自定义端点(测速切换后)或默认付费节点 api.tickflow.org
"""
if _should_use_free_server():
return FREE_ENDPOINT
# 自定义端点(付费模式测速切换后):优先返回
base = _base_url()
if base:
return base.rstrip("/")
# Free 模式或未自定义:统一显示默认节点
return "https://api.tickflow.org"
return PAID_ENDPOINT
+93 -12
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -29,7 +30,9 @@ _CAPSET_CACHE_FILE = "capabilities.json"
# 旧缓存(无此字段或版本更低)会被判定过期,触发重新探测。
# v2: 拆分 depth5 → depth5(单只) + depth5.batch(批量)
# v3: 探测补全 quote.batch(此前 tiers.yaml 声明了但 _probe_real 漏探测)
_CACHE_SCHEMA_VERSION = 3
# v4: 5 档重构 —— 新增 none 档(无key/无效key),free 档重定义(走 free-api 服务器,
# 仅历史日K)。判定改为复权因子分水岭:_classify_tier 接管档位判定。
_CACHE_SCHEMA_VERSION = 4
# 探测用最小代价请求:挑流通性最好的 1 只标的试
_PROBE_SYMBOL = "600000.SH" # 浦发银行,长期不会退市
@@ -247,27 +250,36 @@ def detect_capabilities(force: bool = False) -> CapabilitySet:
tiers = _load_tiers_yaml()
if settings.use_free_mode:
capset = _tier_to_capset(tiers["free"])
label, missing, extras = _compute_label_and_missing(capset, tiers)
_persist(capset, label, log=["Free 模式(无 API Key)"], missing=missing, extras=extras)
# 无 key —— 归 none 档(走 free-api 服务器,仅历史日K)
capset = _tier_to_capset(tiers["none"])
_persist(capset, "None", log=["无 API Key(无档 · free-api 服务器)"], missing=[], extras=[])
return capset
# 有 API key — 真实探测
try:
capset, probe_log = _probe_real(tiers)
if not capset.all():
logger.warning("probe returned no caps; falling back to free baseline")
# 判定档位:无效 key → none,免费 key → free,付费 → starter/pro/expert
classified = _classify_tier(capset, tiers)
if classified.is_invalid:
# 无效 key(连单只日K都拿不到):归 none 档,标记要求清除 key
capset = _tier_to_capset(tiers["none"])
probe_log.append("⚠ Key 无效(单只日K也无法获取),判定为无档")
_persist(capset, "None", log=probe_log, missing=[], extras=[], invalid_key=True)
return capset
if classified.is_free:
# 免费有效 key:能力按 free 档(= none 档能力,走 free-api 服务器)
capset = _tier_to_capset(tiers["free"])
probe_log.append("⚠ 所有探测均失败,降级为 Free 占位")
_persist(capset, "Free", log=probe_log + ["✓ 免费有效 key(运行时走 free-api 服务器)"], missing=[], extras=[])
return capset
# 付费档(starter+) — 探测出的能力即为真实可用
label, missing, extras = _compute_label_and_missing(capset, tiers)
# 探测时 limits 用了"任意档默认值",现在判档完成,用真实档位的 limits 覆盖
capset = _override_limits_with_detected_tier(capset, label, tiers)
_persist(capset, label, log=probe_log, missing=missing, extras=extras)
return capset
except Exception as e:
logger.exception("detect_capabilities failed; using free baseline: %s", e)
capset = _tier_to_capset(tiers["free"])
_persist(capset, "Free(探测失败)", log=[f"探测失败:{e}"], missing=[], extras=[])
logger.exception("detect_capabilities failed; using none baseline: %s", e)
capset = _tier_to_capset(tiers["none"])
_persist(capset, "None(探测失败)", log=[f"探测失败:{e}"], missing=[], extras=[])
return capset
@@ -280,9 +292,55 @@ TIER_SIGNATURES: dict[str, set[Cap]] = {
Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH},
"starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH,
Cap.ADJ_FACTOR, Cap.QUOTE_POOL},
# free 不需 signature — 默认兜底
# free / none 不需 signature — 由 _classify_tier 的分水岭逻辑判定
}
@dataclass(slots=True, frozen=True)
class TierClassification:
"""档位判定结果。
判定依据是"复权因子分水岭":
- 连单只日K都没有 → 无效 key(is_invalid),归 none 档
- 有单只日K、无复权因子 → 免费 key(is_free)
- 有复权因子 → 付费档(starter+),具体档位由 signature 决定
"""
tier: str # "none" / "free" / "starter" / "pro" / "expert"
is_invalid: bool # 无效 key(连单只日K都拿不到)
is_free: bool # 免费有效 key(有日K、无复权因子)
def _classify_tier(capset: CapabilitySet, tiers: dict) -> TierClassification:
"""根据探测出的能力集判定档位。
分水岭是 KLINE_DAILY_BY_SYMBOL(单只日K)与 ADJ_FACTOR(复权因子):
- 无单只日K → none(无效 key)
- 有日K无复权 → free(免费 key)
- 有复权因子 → 走 signature 判定 starter/pro/expert
"""
held = set(capset.all().keys())
# 1) 连单只日K都没有 → 无效 key
if Cap.KLINE_DAILY_BY_SYMBOL not in held:
return TierClassification(tier="none", is_invalid=True, is_free=False)
# 2) 有日K但无复权因子 → 免费 key
if Cap.ADJ_FACTOR not in held:
return TierClassification(tier="free", is_invalid=False, is_free=True)
# 3) 有复权因子 → 付费档,按 signature 自上而下判定
if held & TIER_SIGNATURES["expert"]:
base = "expert"
elif held & TIER_SIGNATURES["pro"]:
base = "pro"
elif held & TIER_SIGNATURES["starter"]:
base = "starter"
else:
# 有复权因子但无任何代表能力 — 兜底为 starter(复权本身是 starter 特征)
base = "starter"
return TierClassification(tier=base, is_invalid=False, is_free=False)
# 补丁友好命名(label 后缀用)
_CAP_ALIASES: dict[Cap, str] = {
Cap.KLINE_MINUTE_BATCH: "分钟K",
@@ -395,6 +453,7 @@ def _persist(
log: list[str] | None = None,
missing: list[str] | None = None,
extras: list[str] | None = None,
invalid_key: bool = False,
) -> None:
settings.data_dir.mkdir(parents=True, exist_ok=True)
cache_path = settings.data_dir / _CAPSET_CACHE_FILE
@@ -405,6 +464,7 @@ def _persist(
"probe_log": log or [],
"missing_caps": missing or [], # 本档应有但未探测到
"extras_caps": extras or [], # 超出本档的额外能力
"invalid_key": invalid_key, # 探测出的 key 无效(连单只日K都拿不到)
}
with cache_path.open("w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
@@ -456,3 +516,24 @@ def extras_caps() -> list[str]:
with cache_path.open(encoding="utf-8") as f:
return json.load(f).get("extras_caps", [])
return []
def is_invalid_key() -> bool:
"""最近一次探测是否判定 key 无效(连单只日K都拿不到)。
settings 层据此清除已存的 key,避免乱填的 key 被持久化。
"""
cache_path = settings.data_dir / _CAPSET_CACHE_FILE
if cache_path.exists():
with cache_path.open(encoding="utf-8") as f:
return bool(json.load(f).get("invalid_key", False))
return False
def base_tier_name() -> str:
"""当前档位的基础名(小写): none / free / starter / pro / expert。
供 client 层判断"是否走 free-api 服务器"(none/free → free 服务器)。
"""
label = tier_label()
return label.split()[0].split("+")[0].strip().lower()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tickflow-stock-panel-frontend",
"private": true,
"version": "0.1.35",
"version": "0.1.36",
"type": "module",
"scripts": {
"dev": "vite",
+17 -7
View File
@@ -16,6 +16,7 @@ import {
useToggleRealtimeQuotes,
} from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import {
Star,
ScanSearch,
@@ -134,7 +135,7 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i
// ===== 档位卡片 =====
function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
const base = label.split(' ')[0].split('+')[0].toLowerCase()
const isFree = base === 'free' || !label
const isNone = base === 'none'
const tierConfig: Record<string, {
desc: string
@@ -142,6 +143,12 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
dotStyle: React.CSSProperties
labelTextStyle: React.CSSProperties
}> = {
none: {
desc: '未配置 Key · 仅历史日K',
tagBg: { background: 'rgba(113,113,122,0.15)' },
dotStyle: { background: '#52525b' },
labelTextStyle: { color: '#71717a' },
},
free: {
desc: '基础日K · 单股查询',
tagBg: { background: 'rgba(113,113,122,0.3)' },
@@ -168,7 +175,9 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
},
}
const t = tierConfig[base] || tierConfig.free
const t = tierConfig[base] || tierConfig.none
// none 档显示中文「无」,无 label 时显示「无档」
const displayLabel = isNone ? '无' : (label || '无')
return (
<NavLink
@@ -191,14 +200,14 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
/>
</div>
<div className="mt-0.5 truncate text-[10px] leading-tight text-muted">
{isFree && !hasKey ? '配置 Key 解锁更多能力' : t.desc}
{isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc}
</div>
</div>
<span
className="inline-flex h-[18px] max-w-[68px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none"
style={t.tagBg}
>
<span className="truncate" style={t.labelTextStyle}>{label || 'Free'}</span>
<span className="truncate" style={t.labelTextStyle}>{displayLabel}</span>
</span>
<Settings className="h-3 w-3 shrink-0 text-muted group-hover:text-blue-300 transition-colors" />
</div>
@@ -271,7 +280,8 @@ export function Layout() {
const toggleQuote = useToggleRealtimeQuotes()
const isRunning = quoteStatus?.running ?? false
const isTrading = quoteStatus?.is_trading_hours ?? false
const isFreeTier = (caps?.label ?? '').toLowerCase().startsWith('free')
// none/free 档(无实时行情权限)→ rank < starter(1)
const isFreeTier = tierRank(caps?.label ?? '') < 1
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
const alertsTotalQuery = useQuery({
@@ -316,7 +326,7 @@ export function Layout() {
queryKey: QK.capabilities,
queryFn: api.capabilities,
})
if ((fresh.label ?? '').toLowerCase().startsWith('free')) return
if (tierRank(fresh.label ?? '') < 1) return
}
await toggleQuote.mutateAsync(enabled)
// 仅在交易时段立即获取一次行情
@@ -356,7 +366,7 @@ export function Layout() {
<TierBadge
label={caps?.label ?? ''}
hasKey={settingsState?.mode === 'api_key'}
hasKey={settingsState?.mode !== 'none'}
/>
<AIConfigBadge
configured={settingsState?.has_ai_key}
+4 -2
View File
@@ -5,11 +5,13 @@ import { fmtDate } from '@/lib/format'
import { Skeleton } from './Skeleton'
// 卡片能力定义:capKey → 查 capability limitstierReq → 无权限时显示的档位要求
// capKey 为空串表示该数据在 free-api 服务器(无档/免费档)即可获取,无需付费能力门控。
export const CARD_META: Record<string, {
capKey: string // 对应的 capability key,空串表示本地计算
capKey: string // 对应的 capability key,空串表示本地计算 / free 服务器可用
tierReq: string // 最低档位要求(无权限时显示)
}> = {
instruments: { capKey: 'quote.by_symbol', tierReq: 'Free' },
// 标的维表走 exchanges 端点,free-api 服务器即可获取,无需付费能力
instruments: { capKey: '', tierReq: '' },
daily: { capKey: 'kline.daily.batch', tierReq: 'Starter+' },
adj_factor: { capKey: 'adj_factor', tierReq: 'Starter+' },
enriched: { capKey: '', tierReq: '' },
+15 -2
View File
@@ -549,7 +549,7 @@ export interface EndpointManifest {
}
export interface SettingsState {
mode: 'free' | 'api_key'
mode: 'none' | 'free' | 'api_key'
tickflow_api_key_masked: string
has_tickflow_key: boolean
tier_label: string
@@ -568,6 +568,19 @@ export interface SettingsState {
ai_daily_token_budget: number
}
/** 保存 TickFlow Key 的响应(先探后存) */
export interface SaveTickflowKeyResult {
ok: boolean
/** ok=false 且 key 无效时的原因标识,前端据此提示「Key 无效」 */
reason?: 'invalid'
error?: string
mode?: 'none' | 'free' | 'api_key'
tier_label?: string
current_endpoint?: string
tickflow_api_key_masked?: string
capabilities_count?: number
}
export interface Preferences {
realtime_quotes_enabled: boolean
indices_nav_pinned: boolean
@@ -609,7 +622,7 @@ export const api = {
settings: () => request<SettingsState>('/api/settings'),
saveTickflowKey: (api_key: string) =>
request<any>('/api/settings/tickflow-key', {
request<SaveTickflowKeyResult>('/api/settings/tickflow-key', {
method: 'POST',
body: JSON.stringify({ api_key }),
}),
+12 -3
View File
@@ -16,7 +16,8 @@ export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。
// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。
export const TIER_RANK: Record<string, number> = { free: 0, starter: 1, pro: 2, expert: 3 }
// none = 无档(无 key / 无效 key),低于 free,仅历史日K无实时行情。
export const TIER_RANK: Record<string, number> = { none: -1, free: 0, starter: 1, pro: 2, expert: 3 }
export const EXPERT_RANK = TIER_RANK.expert
export function tierRank(label: string): number {
@@ -37,6 +38,12 @@ export interface TierStyle {
}
const TIER_STYLE: Record<string, TierStyle> = {
none: {
desc: '未配置 Key · 仅历史日K',
tagBg: { background: 'rgba(113,113,122,0.15)' },
dotStyle: { background: '#52525b' },
labelTextStyle: { color: '#71717a' },
},
free: {
desc: '基础日K · 单股查询',
tagBg: { background: 'rgba(113,113,122,0.3)' },
@@ -74,7 +81,7 @@ export function tierStyle(label: string): TierStyle {
}
/** 所有档位(有序, 供档位列表渲染) */
export const ALL_TIERS = ['free', 'starter', 'pro', 'expert'] as const
export const ALL_TIERS = ['none', 'free', 'starter', 'pro', 'expert'] as const
/** 返回档位标签的渐变文字样式(用于大字显示, 如 Keys 页档位) */
export function tierTextStyle(label: string): { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string } {
@@ -85,12 +92,14 @@ export function tierTextStyle(label: string): { color?: string; background?: str
export function TierTag({ label, className = '' }: { label: string; className?: string }) {
const t = tierStyle(label)
const base = tierBaseName(label)
// none 档显示中文「无」,其余档显示英文档名
const display = base === 'none' ? '无' : base
return (
<span
className={`inline-flex h-[18px] max-w-[80px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none ${className}`}
style={t.tagBg}
>
<span className="truncate capitalize" style={t.labelTextStyle}>{base}</span>
<span className="truncate capitalize" style={t.labelTextStyle}>{display}</span>
</span>
)
}
+26 -2
View File
@@ -2,12 +2,12 @@ import { useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Flame, Gauge, LineChart, Loader2, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Flame, Gauge, LineChart, Loader2, RefreshCw, Sparkles, Target, Timer, ExternalLink, Gift } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct } from '@/lib/format'
import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries'
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { cn } from '@/lib/cn'
@@ -480,9 +480,12 @@ export function Dashboard() {
})
const data = overview.data
const caps = useCapabilities()
const settings = useSettings()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const sealedReady = !!data?.limit?.sealed_ready
const isSealedDegrade = !hasDepth || !sealedReady
// none 档(无 key / 无效 key)→ 显示升级提示横幅
const isNoKey = settings.data?.mode === 'none'
// 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感
const handleRefresh = () => {
@@ -520,6 +523,27 @@ export function Dashboard() {
return (
<div className="min-h-full bg-base p-3">
{/* none 档(无 key)提示横幅 —— 引导用户领取免费 Key 解锁完整能力 */}
{isNoKey && (
<div className="mb-3 flex items-center gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2 text-xs">
<Gift className="h-4 w-4 shrink-0 text-warning" />
<span className="text-secondary leading-relaxed">
API Key,
<a
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
target="_blank"
rel="noreferrer"
className="mx-1 inline-flex items-baseline gap-0.5 font-medium text-warning hover:underline"
>
TickFlow
<ExternalLink className="h-3 w-3 self-center" />
</a>
({' '}
<span className="font-mono font-semibold text-warning">V3KDKGXPEA</span>
) API Key,
</span>
</div>
)}
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-card border border-border bg-surface/85 px-3 py-2">
<div className="flex items-center gap-2">
<Gauge className="h-4 w-4 text-accent" />
+1 -1
View File
@@ -732,7 +732,7 @@ export function Data() {
{showEndpointTest && (
<EndpointTestDialog
hasKey={settings.data?.has_tickflow_key ?? false}
hasKey={settings.data?.mode === 'api_key'}
tierLabel={settings.data?.tier_label ?? ''}
currentEndpoint={settings.data?.current_endpoint ?? ''}
onClose={() => setShowEndpointTest(false)}
+27 -12
View File
@@ -230,16 +230,20 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
const save = useMutation({
mutationFn: () => api.saveTickflowKey(keyInput.trim()),
onSuccess: () => {
setSaved(true)
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: QK.settings })
qc.invalidateQueries({ queryKey: QK.capabilities })
// 保存成功后自动进入下一步看探测结果
setTimeout(() => onNext(), 600)
if (data.ok) {
// 仅当 key 有效(被存储)时才进入下一步看探测结果
setSaved(true)
setTimeout(() => onNext(), 600)
}
// ok=false(key 无效):不进入下一步,错误提示由 save.error / save.data 渲染
},
})
const alreadyHasKey = settings.data?.has_tickflow_key
// 已配置 key —— 免费档或付费档都算(只要不是无档 none)
const alreadyHasKey = settings.data?.mode !== 'none' && settings.data?.mode !== undefined
return (
<div>
@@ -250,8 +254,8 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
<h2 className="text-xl font-bold text-foreground"> TickFlow API Key</h2>
</div>
<p className="mt-2.5 text-sm text-secondary leading-relaxed">
Key 使 Key <span className="font-medium text-foreground">Free</span> ;
Key 使 Key <span className="font-medium text-foreground"> </span>
使K; Key
</p>
{/* 注册引导 */}
@@ -339,6 +343,17 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
{save.isError && (
<div className="text-xs text-danger">:{String((save.error as any).message)}</div>
)}
{/* 无效 key —— 探测失败(key 无效/乱填)未存储,提示用户 */}
{save.data && !save.data.ok && (
<div className="flex items-start gap-1.5 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2 text-[11px] leading-snug text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>
{save.data.reason === 'invalid'
? 'Key 无效或已过期,请检查后重试(未保存该 Key)。'
: save.data.error ?? '保存失败'}
</span>
</div>
)}
</form>
{/* 底部操作 */}
@@ -384,7 +399,8 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
const settings = useSettings()
const caps = useCapabilities()
const hasKey = settings.data?.has_tickflow_key
// 是否配置成功 —— 免费档(free)或付费档(api_key)都算;无档(none)算未配置
const hasKey = settings.data?.mode === 'free' || settings.data?.mode === 'api_key'
const capList = caps.data ? Object.entries(caps.data.capabilities) : []
return (
@@ -442,11 +458,10 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
<div className="mx-auto w-fit rounded-xl bg-elevated p-3">
<Zap className="h-6 w-6 text-warning" />
</div>
<div className="mt-3 text-sm font-medium text-foreground"> Free </div>
<div className="mt-3 text-sm font-medium text-foreground"></div>
<p className="mt-2 text-xs text-muted leading-relaxed max-w-sm mx-auto">
,
<span className="text-foreground font-medium"> </span>
Key
Key,使K数据 Key ,
<span className="text-foreground font-medium"> </span>
</p>
</div>
)}
@@ -10,6 +10,7 @@ import {
type StrategyParamDef,
} from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import { storage } from '@/lib/storage'
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
import { boardTag } from '@/lib/board'
@@ -620,7 +621,7 @@ export function StrategyBacktest() {
// 高颗粒回测(分钟K精确回测)— 开发中,Starter+ 功能
const [highGranularity, setHighGranularity] = useState(false)
const { data: caps } = useCapabilities()
const isFreeTier = (caps?.label ?? '').toLowerCase().startsWith('free')
const isFreeTier = tierRank(caps?.label ?? '') < 1
const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false)
const [quickRanges, setQuickRanges] = useState(loadQuickRanges)
const [settingsTab, setSettingsTab] = useState<AdvancedSettingsTab>('params')
+32 -13
View File
@@ -38,12 +38,15 @@ export function SettingsKeysPanel() {
const save = useMutation({
mutationFn: () => api.saveTickflowKey(keyInput.trim()),
onSuccess: () => {
onSuccess: (data) => {
setKeyInput('')
setSaved(true)
qc.invalidateQueries({ queryKey: QK.settings })
qc.invalidateQueries({ queryKey: QK.capabilities })
setTimeout(() => setSaved(false), 2000)
if (data.ok) {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
// ok=false 由 save.data 在下方渲染提示(reason=invalid),无需额外处理
},
})
@@ -119,15 +122,21 @@ export function SettingsKeysPanel() {
<span className="text-sm font-medium shrink-0"></span>
<span className="font-mono text-xs text-secondary truncate">{masked}</span>
</>
) : mode === 'free' ? (
<>
<CheckCircle2 className="h-4 w-4 text-bear shrink-0" />
<span className="text-sm font-medium shrink-0"> Key</span>
<span className="font-mono text-xs text-secondary truncate">{masked}</span>
</>
) : (
<>
<AlertCircle className="h-4 w-4 text-warning shrink-0" />
<span className="text-sm font-medium text-warning">Free </span>
<AlertCircle className="h-4 w-4 text-muted shrink-0" />
<span className="text-sm font-medium text-muted"> · Free </span>
</>
)}
</div>
</div>
{mode === 'api_key' && (
{(mode === 'api_key' || mode === 'free') && (
<button
onClick={() => setConfirmClear(true)}
disabled={clear.isPending}
@@ -150,7 +159,7 @@ export function SettingsKeysPanel() {
<div className="relative">
<input
type={revealing ? 'text' : 'password'}
placeholder={mode === 'api_key' ? '粘贴新 Key 替换当前' : '粘贴 TickFlow API Key'}
placeholder={mode === 'none' ? '粘贴 TickFlow API Key' : '粘贴新 Key 替换当前'}
value={keyInput}
onChange={(e) => { setKeyInput(e.target.value); if (saved) setSaved(false) }}
className="w-full px-3 py-2 pr-9 rounded-input bg-base border border-border text-sm font-mono focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth"
@@ -198,10 +207,20 @@ export function SettingsKeysPanel() {
:{String((save.error as any).message)}
</div>
)}
{/* 无效 key —— 先探后存:探测失败(key 无效/乱填)时不存储,提示用户 */}
{save.data && !save.data.ok && (
<div className="mt-3 text-xs text-danger flex items-center gap-1.5">
<AlertCircle className="h-3 w-3 shrink-0" />
{save.data.reason === 'invalid'
? 'Key 无效或已过期,请检查后重试(未保存该 Key)'
: save.data.error ?? '保存失败'}
</div>
)}
{save.data?.ok && (
<div className="mt-3 text-xs text-bear flex items-center gap-1.5">
<CheckCircle2 className="h-3 w-3" />
{save.data.capabilities_count} , {save.data.tier_label}
{save.data.tier_label}
{save.data.mode === 'free' && '(免费档 · 历史日K)'}
</div>
)}
</Card>
@@ -322,7 +341,7 @@ export function SettingsKeysPanel() {
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
<h3 className="text-sm font-medium text-foreground mb-2"> API Key</h3>
<p className="text-xs text-secondary mb-5">
退 Free , Key
退(K), Key
</p>
<div className="flex items-center justify-end gap-2">
<button
@@ -372,7 +391,7 @@ function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
className="absolute top-full left-0 mt-1 z-50 w-72 bg-surface border border-border rounded-lg shadow-xl p-3.5 text-[11px] leading-relaxed"
onClick={e => e.stopPropagation()}
>
{/* 4 档位 tag 横排 */}
{/* 档位 tag 横排 */}
<div className="flex items-center gap-1.5 mb-3">
{ALL_TIERS.map(t => (
<div key={t} className={`flex flex-col items-center gap-1 ${t === currentBase ? '' : 'opacity-60'}`}>
@@ -388,7 +407,7 @@ function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
return (
<div key={t} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={s.dotStyle} />
<span className="capitalize font-mono font-bold w-12 shrink-0" style={s.labelTextStyle}>{t}</span>
<span className="font-mono font-bold w-12 shrink-0" style={s.labelTextStyle}>{t === 'none' ? '无' : t}</span>
<span className="text-secondary">{s.desc}</span>
</div>
)
@@ -398,8 +417,8 @@ function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
{/* 检测说明 */}
<div className="text-secondary space-y-1.5">
<div className="font-medium text-foreground"></div>
<p> API Key ,"代表性能力"( Expert )</p>
<p className="text-muted">"代表性能力",( Pro + K) + </p>
<p> Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为( Key);K但无复权因子则判为Free; Starter/Pro/Expert</p>
<p className="text-muted">(K), Key,</p>
</div>
</motion.div>
</>
+3 -1
View File
@@ -18,6 +18,7 @@ import {
import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import { toast } from '@/components/Toast'
import { DepthConfigContent } from '@/components/data/DepthConfigCard'
@@ -45,7 +46,8 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const { data: intervalData } = useQuoteInterval()
const updateInterval = useUpdateQuoteInterval()
const toggleQuote = useToggleRealtimeQuotes()
const isFreeTier = (caps?.label ?? '').toLowerCase().startsWith('free')
// none/free 档(无实时行情权限)→ rank < starter(1)
const isFreeTier = tierRank(caps?.label ?? '') < 1
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
const refreshPages = prefs?.sse_refresh_pages ?? {}
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
+17 -2
View File
@@ -14,10 +14,25 @@
# 下表数值除明确标注「推断」的条目外,均沿用 2026-05-21 旧版抓取。
# FAQ 印证的能力归属:免费=行情+日K;Starter+=标的池+批量;Pro+=分钟K;Expert+=财务+WS。
# 标注「推断」的条目按 tiers.yaml 既有 batch 类接口规律推得,建议后续用真实套餐核对。
#
# 说明(2026-06-24 5 档重构):
# none = 无 key / 乱填 / 无效 key。运行时走 free-api 服务器(free 通道),
# 仅历史日K(含批量),无实时行情。不存 API Key。
# free = 免费有效 key(付费端点验证:有单只日K、无复权因子)。
# 运行时同样走 free-api 服务器(key 被忽略),能力与 none 等价;
# 存 key 仅作档位标记,便于将来 SDK 支持免费 key 时自动升级。
# 两者运行时服务器相同(free-api),区别只在是否存 key。
# starter+ 才走付费端点 api.tickflow.org,有实时行情。
none:
# 无 key / 无效 key —— 走 free-api 服务器,仅历史日K(含批量),无实时行情
kline.daily.by_symbol: { rpm: 60, batch: 1 }
kline.daily.batch: { rpm: 60, batch: 50 } # free-api 服务器批量日K
free:
quote.by_symbol: { rpm: 10, batch: 5 }
kline.daily.by_symbol: { rpm: 10, batch: 1 }
# 免费有效 key —— 运行时同样走 free-api 服务器,能力与 none 等价
kline.daily.by_symbol: { rpm: 60, batch: 1 }
kline.daily.batch: { rpm: 60, batch: 50 } # [补] 批量日K(此前缺失)
starter:
quote.by_symbol: { rpm: 60, batch: 50 }