"""设置 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