Files
stock/backend/app/api/settings.py
T
2026-07-01 22:07:55 +08:00

855 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""设置 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