将项目文件整理到 refer 目录
This commit is contained in:
@@ -0,0 +1,854 @@
|
||||
"""设置 API — Key 配置 / 模式切换。
|
||||
|
||||
提供面向非开发者的 UI 配置入口,避免逼用户改 .env。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import secrets_store
|
||||
from app.services.financial_sync import financial_scheduler
|
||||
from app.tickflow import client as tf_client
|
||||
from app.tickflow.policy import (
|
||||
detect_capabilities,
|
||||
extras_caps,
|
||||
missing_caps,
|
||||
probe_log,
|
||||
tier_label,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
# 默认端点 —— endpoints.json 列表第一项,UI"当前使用"始终对齐此项。
|
||||
# 注意:Free 模式 SDK 实际走 free-api(免费数据通道),但 UI 显示统一用默认节点。
|
||||
DEFAULT_PAID_ENDPOINT = "https://api.tickflow.org"
|
||||
|
||||
|
||||
class TickflowKeyIn(BaseModel):
|
||||
api_key: str
|
||||
|
||||
|
||||
def _sync_financial_scheduler(request: Request, capset) -> None:
|
||||
"""Key 变更后同步财务调度器状态,无需重启服务。"""
|
||||
try:
|
||||
financial_scheduler.update(request.app.state.repo.store.data_dir, capset)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("financial_scheduler update failed: %s", e)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_settings() -> dict:
|
||||
"""返回当前配置概况(Key 脱敏)。"""
|
||||
from app.config import settings
|
||||
from app.services import preferences
|
||||
|
||||
key = secrets_store.get_tickflow_key()
|
||||
return {
|
||||
"mode": tf_client.current_mode(),
|
||||
"tickflow_api_key_masked": secrets_store.mask(key),
|
||||
"has_tickflow_key": bool(key),
|
||||
"tier_label": tier_label(),
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
"probe_log": probe_log(),
|
||||
"missing_caps": missing_caps(),
|
||||
"extras_caps": extras_caps(),
|
||||
# 首次使用引导
|
||||
"onboarding_completed": preferences.get_onboarding_completed(),
|
||||
# AI 配置
|
||||
"ai_provider": secrets_store.get_ai_config("ai_provider", settings.ai_provider),
|
||||
"ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url),
|
||||
"ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()),
|
||||
"has_ai_key": bool(secrets_store.get_ai_key()),
|
||||
"ai_model": secrets_store.get_ai_config("ai_model", settings.ai_model),
|
||||
"ai_daily_token_budget": int(secrets_store.get_ai_config("ai_daily_token_budget", str(settings.ai_daily_token_budget)) or settings.ai_daily_token_budget),
|
||||
}
|
||||
|
||||
|
||||
class SwitchEndpointIn(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
@router.post("/switch_endpoint")
|
||||
def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
|
||||
"""切换数据源端点并立即生效。
|
||||
|
||||
端点切换仅对付费档(starter+,走付费 API 节点)有意义;
|
||||
none/free 档运行在 free-api 服务器,无付费端点权限,禁止切换。
|
||||
"""
|
||||
# none/free 档没有付费端点权限,禁止切换
|
||||
if tf_client.current_mode() != "api_key":
|
||||
return {"ok": False, "error": "当前档位无法切换端点,仅付费套餐(Starter+)支持"}
|
||||
|
||||
url = req.url.strip().rstrip("/")
|
||||
if not url.startswith("https://"):
|
||||
return {"ok": False, "error": "仅支持 HTTPS 端点"}
|
||||
|
||||
# 持久化到 secrets.json
|
||||
secrets_store.save({"tickflow_base_url": url})
|
||||
# 重置客户端,下次调用自动用新端点
|
||||
tf_client.reset_clients()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tickflow-key")
|
||||
def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
"""保存数据源 API Key 并立即重新探测能力。
|
||||
|
||||
先探后存(关键改动,修复乱填 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 端点不可用,
|
||||
故自动切到默认付费端点;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"}
|
||||
|
||||
# ===== 1) 临时存 key + 重置客户端,让探测走付费端点 =====
|
||||
secrets_store.save({"tickflow_api_key": key})
|
||||
tf_client.reset_clients()
|
||||
|
||||
# 立即重新探测(此时 client 已按档位判定,但首次探测必然走付费端点验证)
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, 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
|
||||
_sync_financial_scheduler(request, 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": [],
|
||||
"capabilities_count": len(capset.all()),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/tickflow-key")
|
||||
def clear_tickflow_key(request: Request) -> dict:
|
||||
"""清除 Key,退回无档(none)。
|
||||
|
||||
同时清除 tickflow_base_url(测速切换的自定义端点),使客户端走 free-api
|
||||
服务器取历史日K;档位标签为 None(无档)。
|
||||
"""
|
||||
secrets_store.clear("tickflow_api_key", "tickflow_base_url")
|
||||
tf_client.reset_clients()
|
||||
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "none",
|
||||
"tier_label": tier_label(),
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
"capabilities_count": len(capset.all()),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/onboarding/complete")
|
||||
def complete_onboarding() -> dict:
|
||||
"""标记首次使用向导完成。
|
||||
|
||||
写入 preferences.json,前端守卫据此判断是否需要再次展示向导。
|
||||
跨设备/清缓存安全 —— 状态落在后端文件,不依赖浏览器本地存储。
|
||||
"""
|
||||
from app.services import preferences
|
||||
done = preferences.set_onboarding_completed(True)
|
||||
return {"ok": True, "onboarding_completed": done}
|
||||
|
||||
|
||||
class AiSettingsIn(BaseModel):
|
||||
provider: str = "openai_compat"
|
||||
base_url: str = ""
|
||||
api_key: str | None = None
|
||||
model: str = ""
|
||||
daily_token_budget: int = 500_000
|
||||
|
||||
|
||||
@router.post("/ai")
|
||||
def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
"""保存 AI 配置(全部持久化到 secrets.json)"""
|
||||
from app.config import settings
|
||||
|
||||
updates: dict = {}
|
||||
if req.provider:
|
||||
updates["ai_provider"] = req.provider
|
||||
settings.ai_provider = req.provider
|
||||
if req.base_url:
|
||||
updates["ai_base_url"] = req.base_url
|
||||
settings.ai_base_url = req.base_url
|
||||
if req.api_key is not None:
|
||||
if req.api_key:
|
||||
updates["ai_api_key"] = req.api_key
|
||||
settings.ai_api_key = req.api_key
|
||||
else:
|
||||
secrets_store.clear("ai_api_key")
|
||||
settings.ai_api_key = ""
|
||||
if req.model:
|
||||
updates["ai_model"] = req.model
|
||||
settings.ai_model = req.model
|
||||
updates["ai_daily_token_budget"] = req.daily_token_budget
|
||||
settings.ai_daily_token_budget = req.daily_token_budget
|
||||
|
||||
if updates:
|
||||
secrets_store.save(updates)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ===== 偏好设置 =====
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.get("/preferences")
|
||||
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(),
|
||||
"pipeline_schedule": preferences.get_pipeline_schedule(),
|
||||
"instruments_schedule": preferences.get_instruments_schedule(),
|
||||
"enriched_batch_size": preferences.get_enriched_batch_size(),
|
||||
"index_daily_batch_size": preferences.get_index_daily_batch_size(),
|
||||
"watchlist_columns": preferences.get_watchlist_columns(),
|
||||
"screener_result_columns": preferences.get_screener_result_columns(),
|
||||
"sse_refresh_pages": preferences.get_sse_refresh_pages(),
|
||||
"strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(),
|
||||
"strategy_monitor_ids": preferences.get_strategy_monitor_ids(),
|
||||
"system_notify_enabled": preferences.get_system_notify_enabled(),
|
||||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||||
"nav_order": preferences.get_nav_order(),
|
||||
"nav_hidden": preferences.get_nav_hidden(),
|
||||
"screener_auto_run": preferences.get_screener_auto_run(),
|
||||
"limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(),
|
||||
"depth_polling_interval": preferences.get_depth_polling_interval(),
|
||||
"depth_finalize_time": preferences.get_depth_finalize_time(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/preferences/watchlist-columns")
|
||||
def get_watchlist_columns() -> dict:
|
||||
"""返回自选列表列配置。"""
|
||||
from app.services import preferences
|
||||
cols = preferences.get_watchlist_columns()
|
||||
return {"columns": cols}
|
||||
|
||||
|
||||
class NavOrderIn(BaseModel):
|
||||
nav_order: list[str]
|
||||
|
||||
|
||||
class NavHiddenIn(BaseModel):
|
||||
nav_hidden: list[str]
|
||||
|
||||
|
||||
@router.put("/preferences/nav-order")
|
||||
def update_nav_order(req: NavOrderIn) -> dict:
|
||||
"""保存左侧菜单排序(内置页面 path + 扩展分析菜单 id 的有序列表)。"""
|
||||
from app.services import preferences
|
||||
saved = preferences.set_nav_order(req.nav_order)
|
||||
return {"nav_order": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/nav-hidden")
|
||||
def update_nav_hidden(req: NavHiddenIn) -> dict:
|
||||
"""保存左侧菜单隐藏项。"""
|
||||
from app.services import preferences
|
||||
saved = preferences.set_nav_hidden(req.nav_hidden)
|
||||
return {"nav_hidden": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/watchlist-columns")
|
||||
def update_watchlist_columns(req: dict) -> dict:
|
||||
"""保存自选列表列配置。"""
|
||||
from app.services import preferences
|
||||
columns = req.get("columns", [])
|
||||
saved = preferences.set_watchlist_columns(columns)
|
||||
return {"columns": saved}
|
||||
|
||||
|
||||
@router.get("/preferences/screener-result-columns")
|
||||
def get_screener_result_columns() -> dict:
|
||||
"""返回策略结果列表列配置。"""
|
||||
from app.services import preferences
|
||||
cols = preferences.get_screener_result_columns()
|
||||
return {"columns": cols}
|
||||
|
||||
|
||||
@router.put("/preferences/screener-result-columns")
|
||||
def update_screener_result_columns(req: dict) -> dict:
|
||||
"""保存策略结果列表列配置。"""
|
||||
from app.services import preferences
|
||||
columns = req.get("columns", [])
|
||||
saved = preferences.set_screener_result_columns(columns)
|
||||
return {"columns": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/minute-sync")
|
||||
def update_minute_sync(req: MinuteSyncPrefs) -> dict:
|
||||
"""保存分钟 K 同步偏好。"""
|
||||
from app.services import preferences
|
||||
days = max(1, min(30, req.minute_sync_days))
|
||||
preferences.save({
|
||||
"minute_sync_enabled": req.minute_sync_enabled,
|
||||
"minute_sync_days": days,
|
||||
})
|
||||
return {
|
||||
"minute_sync_enabled": req.minute_sync_enabled,
|
||||
"minute_sync_days": days,
|
||||
}
|
||||
|
||||
|
||||
class RealtimeQuotesPrefs(BaseModel):
|
||||
realtime_quotes_enabled: bool
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-quotes")
|
||||
def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
"""保存全局实时行情开关。
|
||||
|
||||
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, "realtime_allowed": allowed}
|
||||
|
||||
|
||||
class IndicesNavPinnedPrefs(BaseModel):
|
||||
indices_nav_pinned: bool
|
||||
|
||||
|
||||
@router.put("/preferences/indices-nav-pinned")
|
||||
def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict:
|
||||
"""保存侧栏指数报价卡片固定显示开关。
|
||||
ON=常驻显示;OFF=跟随实时行情开关(仅实时开时显示)。"""
|
||||
from app.services import preferences
|
||||
preferences.save({"indices_nav_pinned": req.indices_nav_pinned})
|
||||
return {"indices_nav_pinned": req.indices_nav_pinned}
|
||||
|
||||
|
||||
class RealtimeMonitorConfigIn(BaseModel):
|
||||
sse_refresh_pages: dict[str, bool] | None = None
|
||||
strategy_monitor_enabled: bool | None = None
|
||||
strategy_monitor_ids: list[str] | None = None
|
||||
sidebar_index_symbols: list[str] | None = None
|
||||
screener_auto_run: bool | None = None
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-monitor")
|
||||
def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Request) -> dict:
|
||||
"""更新实时监控配置。策略监控统一迁移为 MonitorRule,由监控引擎评估。"""
|
||||
from app.services import preferences
|
||||
|
||||
cfg = req.model_dump(exclude_none=True)
|
||||
result = preferences.set_realtime_monitor_config(cfg)
|
||||
|
||||
# 策略监控开关/池变化 → 同步迁移为 type=strategy 规则 + reload 引擎
|
||||
if req.strategy_monitor_ids is not None or req.strategy_monitor_enabled is not None:
|
||||
monitor_engine = getattr(request.app.state, "monitor_engine", None)
|
||||
strategy_engine = getattr(request.app.state, "strategy_engine", None)
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
if monitor_engine is not None and strategy_engine is not None:
|
||||
from app.strategy import monitor_rules as mr_store
|
||||
try:
|
||||
if preferences.get_strategy_monitor_enabled():
|
||||
ids = preferences.get_strategy_monitor_ids()
|
||||
names = {s.id: s.name for s in strategy_engine.list_strategies()}
|
||||
mr_store.migrate_strategy_monitors(data_dir, ids, names)
|
||||
else:
|
||||
# 关闭策略监控: 停用所有策略规则
|
||||
mr_store.migrate_strategy_monitors(data_dir, [], {})
|
||||
# reload 规则到引擎
|
||||
monitor_engine.set_rules(mr_store.load_all(data_dir))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class QuoteIntervalIn(BaseModel):
|
||||
interval: float
|
||||
|
||||
|
||||
class SystemNotifyPrefsIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.put("/preferences/system-notify")
|
||||
def update_system_notify(req: SystemNotifyPrefsIn) -> dict:
|
||||
"""系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。
|
||||
|
||||
纯偏好, 无副作用 (不像策略监控要迁移规则), 直接落盘即可。
|
||||
quote_service 在每轮告警评估时读此开关决定是否发系统通知。
|
||||
"""
|
||||
from app.services import preferences
|
||||
saved = preferences.set_system_notify_enabled(req.enabled)
|
||||
return {"system_notify_enabled": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/quote-interval")
|
||||
def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict:
|
||||
"""更新行情轮询间隔。按档位自动 clamp。"""
|
||||
qs = getattr(request.app.state, "quote_service", None)
|
||||
if not qs:
|
||||
return {"interval": req.interval, "min_interval": qs.get_min_interval(), "max_interval": 60.0}
|
||||
clamped = qs.set_interval(req.interval)
|
||||
return {
|
||||
"interval": clamped,
|
||||
"min_interval": qs.get_min_interval(),
|
||||
"max_interval": qs.MAX_INTERVAL,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/preferences/quote-interval")
|
||||
def get_quote_interval(request: Request) -> dict:
|
||||
"""获取当前行情轮询间隔和档位限制。"""
|
||||
qs = getattr(request.app.state, "quote_service", None)
|
||||
if not qs:
|
||||
return {"interval": 10.0, "min_interval": 5.0, "max_interval": 60.0}
|
||||
return {
|
||||
"interval": qs._interval,
|
||||
"min_interval": qs.get_min_interval(),
|
||||
"max_interval": qs.MAX_INTERVAL,
|
||||
}
|
||||
|
||||
|
||||
class TestEndpointIn(BaseModel):
|
||||
url: str
|
||||
# 测试轮数;不传时取 endpoints.json 的 testRounds(默认 5)
|
||||
rounds: int | None = None
|
||||
|
||||
|
||||
# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取数据源官网 /endpoints.json
|
||||
# (无 CORS 头),因此由后端代理。缓存 5 分钟,失败时回退到内置列表。
|
||||
ENDPOINTS_URL = "https://tickflow.org/endpoints.json"
|
||||
ENDPOINTS_TTL = 300.0 # 秒
|
||||
|
||||
# 回退列表 —— 与官方 endpoints.json 的 endpoints[] 字段对齐。
|
||||
# 当远程拉取失败时使用,保证 UI 永远有内容可显示。
|
||||
_FALLBACK_ENDPOINTS: list[dict] = [
|
||||
{
|
||||
"id": "default",
|
||||
"url": "https://api.tickflow.org",
|
||||
"label": "默认端点",
|
||||
"region": "auto",
|
||||
"description": "默认端点",
|
||||
"premium": False,
|
||||
},
|
||||
{
|
||||
"id": "hk",
|
||||
"url": "https://hk-api.tickflow.org",
|
||||
"label": "香港端点",
|
||||
"region": "ap-east-1",
|
||||
"description": "备用端点,部分地区访问更稳定",
|
||||
"premium": False,
|
||||
},
|
||||
{
|
||||
"id": "sg",
|
||||
"url": "https://sg-api.tickflow.org",
|
||||
"label": "新加坡端点",
|
||||
"region": "ap-southeast-1",
|
||||
"description": "备用端点,亚太地区访问更稳定",
|
||||
"premium": False,
|
||||
},
|
||||
{
|
||||
"id": "us",
|
||||
"url": "https://us-api.tickflow.org",
|
||||
"label": "美国端点",
|
||||
"region": "us-east-1",
|
||||
"description": "备用端点,欧美地区访问更稳定",
|
||||
"premium": False,
|
||||
},
|
||||
{
|
||||
"id": "cn",
|
||||
"url": "https://139.196.55.234:50443",
|
||||
"label": "中国大陆端点(Beta)",
|
||||
"region": "cn-east-1",
|
||||
"description": "备用端点,中国大陆地区访问更稳定,目前处于测试阶段,谨慎使用",
|
||||
"premium": False,
|
||||
},
|
||||
{
|
||||
"id": "cn-premium",
|
||||
"url": "https://106.15.238.72:50443",
|
||||
"label": "中国大陆专线端点",
|
||||
"region": "cn-east-1",
|
||||
"description": "专线加速端点,需要专线加速权限(该权限包含在 Expert 及以上套餐中,也可通过自定义组合单独开通)",
|
||||
"premium": True,
|
||||
},
|
||||
]
|
||||
|
||||
# 进程内缓存:{ "ts": float, "data": dict }
|
||||
_endpoints_cache: dict = {"ts": 0.0, "data": None}
|
||||
|
||||
|
||||
@router.get("/endpoints")
|
||||
def list_endpoints() -> dict:
|
||||
"""代理拉取数据源官网 /endpoints.json 并返回规范化端点列表。
|
||||
|
||||
前端无法跨域直连该 URL(无 CORS 头),故由本接口代理。带 8s 超时、
|
||||
5 分钟内存缓存,远程失败时回退到内置列表,保证 UI 始终有内容。
|
||||
返回结构与原始 endpoints.json 一致(透传 schema/version 等元信息)。
|
||||
"""
|
||||
import httpx
|
||||
|
||||
now = time.monotonic()
|
||||
cached = _endpoints_cache.get("data")
|
||||
if cached is not None and (now - _endpoints_cache["ts"]) < ENDPOINTS_TTL:
|
||||
return cached
|
||||
|
||||
source = "remote"
|
||||
data: dict | None = None
|
||||
try:
|
||||
resp = httpx.get(ENDPOINTS_URL, timeout=8.0, follow_redirects=True)
|
||||
if resp.status_code == 200:
|
||||
parsed = resp.json()
|
||||
eps = parsed.get("endpoints")
|
||||
# 校验:必须是列表且每项含必要字段,否则视为无效
|
||||
if isinstance(eps, list) and all(
|
||||
isinstance(e, dict) and "url" in e for e in eps
|
||||
):
|
||||
data = {
|
||||
"version": parsed.get("version", 1),
|
||||
"description": parsed.get(
|
||||
"description", "API 端点配置"
|
||||
),
|
||||
"healthPath": parsed.get("healthPath", "/health"),
|
||||
"testRounds": parsed.get("testRounds", 5),
|
||||
"endpoints": eps,
|
||||
}
|
||||
except (httpx.HTTPError, ValueError):
|
||||
logger.warning("拉取 endpoints.json 失败,使用内置回退列表", exc_info=True)
|
||||
|
||||
if data is None:
|
||||
source = "fallback"
|
||||
data = {
|
||||
"version": 1,
|
||||
"description": "API 端点配置",
|
||||
"healthPath": "/health",
|
||||
"testRounds": 5,
|
||||
"endpoints": _FALLBACK_ENDPOINTS,
|
||||
}
|
||||
|
||||
# 标记数据来源,便于前端提示(回退时显示"内置列表")。
|
||||
data["source"] = source
|
||||
_endpoints_cache["ts"] = now
|
||||
_endpoints_cache["data"] = data
|
||||
return data
|
||||
|
||||
|
||||
async def _http_ping(url: str, timeout: float = 10.0) -> float | None:
|
||||
"""单次异步 GET 请求并返回延迟(ms),失败返回 None。
|
||||
|
||||
对齐官方 latency_test.py:用 /health 轻量端点测真实网络延迟,
|
||||
不携带 API Key(/health 公开)。异步实现,保证多端点并行测速不阻塞。
|
||||
"""
|
||||
import httpx
|
||||
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = await client.get(url)
|
||||
dt = (time.perf_counter() - t0) * 1000
|
||||
# 只把 <400 视为成功;4xx/5xx 也算"不可达"
|
||||
if resp.status_code < 400:
|
||||
return round(dt, 2)
|
||||
return None
|
||||
except (httpx.TimeoutException, httpx.ConnectError, httpx.HTTPError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/test_endpoint")
|
||||
async def test_endpoint(req: TestEndpointIn) -> dict:
|
||||
"""测试端点网络延迟:对 /health 多轮探测取中位数。
|
||||
|
||||
参考官方 latency_test.py:
|
||||
- 路径用 /health(公开、轻量),反映真实网络延迟而非业务接口耗时
|
||||
- 多轮探测(默认 5 轮,取自 endpoints.json 的 testRounds),间隔 0.3s
|
||||
- 返回 median/min/max/success,前端显示中位数
|
||||
- 异步实现,保证"全部测速"时多端点真正并行
|
||||
"""
|
||||
import asyncio
|
||||
import statistics
|
||||
|
||||
base = req.url.rstrip("/")
|
||||
rounds = max(1, min(10, req.rounds or _endpoints_cache.get("data", {}).get("testRounds", 5)))
|
||||
health_url = base + "/health"
|
||||
|
||||
latencies: list[float] = []
|
||||
for _ in range(rounds):
|
||||
ms = await _http_ping(health_url)
|
||||
if ms is not None:
|
||||
latencies.append(ms)
|
||||
# 官方脚本间隔 0.3s;末轮无需等待
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
success = len(latencies)
|
||||
if success == 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "不可达",
|
||||
"url": req.url,
|
||||
"rounds": rounds,
|
||||
"success": 0,
|
||||
"median_ms": None,
|
||||
"min_ms": None,
|
||||
"max_ms": None,
|
||||
}
|
||||
|
||||
median = round(statistics.median(latencies), 2)
|
||||
return {
|
||||
"ok": True,
|
||||
"url": req.url,
|
||||
"rounds": rounds,
|
||||
"success": success,
|
||||
"median_ms": median,
|
||||
"min_ms": round(min(latencies), 2),
|
||||
"max_ms": round(max(latencies), 2),
|
||||
# 兼容旧字段:取中位数作为代表延迟
|
||||
"latency_ms": median,
|
||||
}
|
||||
|
||||
|
||||
class PipelineScheduleIn(BaseModel):
|
||||
hour: int
|
||||
minute: int
|
||||
|
||||
|
||||
@router.put("/preferences/pipeline-schedule")
|
||||
def update_pipeline_schedule(req: PipelineScheduleIn, request: Request) -> dict:
|
||||
"""保存盘后管道调度时间并立即 reschedule。"""
|
||||
from app.services import preferences
|
||||
sched = preferences.set_pipeline_schedule(req.hour, req.minute)
|
||||
|
||||
# 动态 reschedule
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
scheduler = getattr(request.app.state, "scheduler", None)
|
||||
if scheduler:
|
||||
scheduler.reschedule_job(
|
||||
"daily_pipeline",
|
||||
trigger=CronTrigger(
|
||||
day_of_week="mon-fri",
|
||||
hour=sched["hour"],
|
||||
minute=sched["minute"],
|
||||
timezone="Asia/Shanghai",
|
||||
),
|
||||
)
|
||||
logger.info("pipeline rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"])
|
||||
|
||||
return sched
|
||||
|
||||
|
||||
@router.put("/preferences/instruments-schedule")
|
||||
def update_instruments_schedule(req: PipelineScheduleIn, request: Request) -> dict:
|
||||
"""保存盘前标的维表调度时间并立即 reschedule。"""
|
||||
from app.services import preferences
|
||||
sched = preferences.set_instruments_schedule(req.hour, req.minute)
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
scheduler = getattr(request.app.state, "scheduler", None)
|
||||
if scheduler:
|
||||
scheduler.reschedule_job(
|
||||
"pre_market_instruments",
|
||||
trigger=CronTrigger(
|
||||
day_of_week="mon-fri",
|
||||
hour=sched["hour"],
|
||||
minute=sched["minute"],
|
||||
timezone="Asia/Shanghai",
|
||||
),
|
||||
)
|
||||
return sched
|
||||
|
||||
|
||||
class EnrichedBatchSizeIn(BaseModel):
|
||||
size: int
|
||||
|
||||
|
||||
@router.put("/preferences/enriched-batch-size")
|
||||
def update_enriched_batch_size(req: EnrichedBatchSizeIn) -> dict:
|
||||
"""保存 enriched 全量计算批次大小。"""
|
||||
from app.services import preferences
|
||||
size = preferences.set_enriched_batch_size(req.size)
|
||||
return {"enriched_batch_size": size}
|
||||
|
||||
|
||||
class IndexDailyBatchSizeIn(BaseModel):
|
||||
size: int
|
||||
|
||||
|
||||
@router.put("/preferences/index-daily-batch-size")
|
||||
def update_index_daily_batch_size(req: IndexDailyBatchSizeIn) -> dict:
|
||||
"""保存指数日 K 同步批次大小。"""
|
||||
from app.services import preferences
|
||||
size = preferences.set_index_daily_batch_size(req.size)
|
||||
return {"index_daily_batch_size": size}
|
||||
|
||||
|
||||
# ── 五档盘口 sealed 配置 ──────────────────────────────
|
||||
|
||||
class LimitLadderMonitorIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.put("/preferences/limit-ladder-monitor")
|
||||
def update_limit_ladder_monitor(req: LimitLadderMonitorIn, request: Request) -> dict:
|
||||
"""连板梯队 5 档监控开关。开启→启动 depth 轮询, 关闭→停止。"""
|
||||
from app.services import preferences
|
||||
preferences.save({"limit_ladder_monitor_enabled": req.enabled})
|
||||
|
||||
# 立即应用: 启停 depth 轮询线程
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
if depth_svc:
|
||||
depth_svc.apply_monitor_toggle(req.enabled)
|
||||
|
||||
return {"limit_ladder_monitor_enabled": req.enabled}
|
||||
|
||||
|
||||
@router.post("/preferences/limit-ladder-monitor/run")
|
||||
def run_limit_ladder_fix(request: Request) -> dict:
|
||||
"""立即手动修正一次真假板(拉取五档盘口 + 更新缓存)。需 Pro+。"""
|
||||
from app.tickflow.capabilities import Cap
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.DEPTH5_BATCH) # 无能力抛 CapabilityDenied(403)
|
||||
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
if not depth_svc:
|
||||
raise HTTPException(status_code=503, detail="depth 服务未初始化")
|
||||
return depth_svc.run_once()
|
||||
|
||||
|
||||
class DepthPollingIntervalIn(BaseModel):
|
||||
interval: float
|
||||
|
||||
|
||||
@router.put("/preferences/depth-polling-interval")
|
||||
def update_depth_polling_interval(req: DepthPollingIntervalIn, request: Request) -> dict:
|
||||
"""保存五档盘口盘中轮询间隔(秒)。需 Pro+。"""
|
||||
from app.tickflow.capabilities import Cap
|
||||
request.app.state.capabilities.require(Cap.DEPTH5_BATCH)
|
||||
|
||||
from app.services import preferences
|
||||
interval = preferences.set_depth_polling_interval(req.interval)
|
||||
return {"depth_polling_interval": interval}
|
||||
|
||||
|
||||
class DepthFinalizeTimeIn(BaseModel):
|
||||
hour: int
|
||||
minute: int
|
||||
|
||||
|
||||
@router.put("/preferences/depth-finalize-time")
|
||||
def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> dict:
|
||||
"""保存盘后 sealed 定版时间(范围15:01~18:00)并立即 reschedule。需 Pro+。"""
|
||||
from app.tickflow.capabilities import Cap
|
||||
request.app.state.capabilities.require(Cap.DEPTH5_BATCH)
|
||||
|
||||
from app.services import preferences
|
||||
sched = preferences.set_depth_finalize_time(req.hour, req.minute)
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
scheduler = getattr(request.app.state, "scheduler", None)
|
||||
if scheduler:
|
||||
scheduler.reschedule_job(
|
||||
"depth_finalize",
|
||||
trigger=CronTrigger(
|
||||
day_of_week="mon-fri",
|
||||
hour=sched["hour"],
|
||||
minute=sched["minute"],
|
||||
timezone="Asia/Shanghai",
|
||||
),
|
||||
)
|
||||
logger.info("depth_finalize rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"])
|
||||
|
||||
return sched
|
||||
|
||||
Reference in New Issue
Block a user