"""用户偏好设置持久化。 存储位置: data/user_data/preferences.json 沿用 secrets_store 的 merge-write 模式,但不做 chmod 0600 (非敏感数据)。 """ from __future__ import annotations import json import logging from pathlib import Path logger = logging.getLogger(__name__) def _path() -> Path: from app.config import settings p = settings.data_dir / "user_data" / "preferences.json" p.parent.mkdir(parents=True, exist_ok=True) return p def load() -> dict: p = _path() if p.exists(): try: return json.loads(p.read_text(encoding="utf-8")) except Exception as e: # noqa: BLE001 logger.warning("preferences.json malformed: %s", e) return {} def save(updates: dict) -> dict: """合并写入。返回新内容。""" current = load() current.update(updates) _path().write_text( json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8", ) return current def get_realtime_quotes_enabled() -> bool: return load().get("realtime_quotes_enabled", False) def get_indices_nav_pinned() -> bool: """侧栏指数报价卡片是否固定显示。默认 True(常驻)。 关闭后,卡片跟随实时行情开关(仅实时开时显示)。""" return load().get("indices_nav_pinned", True) def get_realtime_quote_interval() -> float: return load().get("realtime_quote_interval", 10.0) def set_realtime_quote_interval(interval: float) -> float: """保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。""" current = load() current["realtime_quote_interval"] = interval _path().write_text( json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8", ) return interval def get_minute_sync_enabled() -> bool: return load().get("minute_sync_enabled", False) def get_minute_sync_days() -> int: return max(1, min(30, load().get("minute_sync_days", 5))) def get_pipeline_schedule() -> dict: """返回盘后管道调度时间 {"hour": 15, "minute": 30}。""" d = load().get("pipeline_schedule", {"hour": 15, "minute": 30}) return {"hour": d.get("hour", 15), "minute": d.get("minute", 30)} def set_pipeline_schedule(hour: int, minute: int) -> dict: h = max(0, min(23, hour)) m = max(0, min(59, minute)) # 盘后不早于 15:00 if h * 60 + m < 15 * 60: h, m = 15, 0 save({"pipeline_schedule": {"hour": h, "minute": m}}) return {"hour": h, "minute": m} def get_instruments_schedule() -> dict: """返回盘前标的维表调度时间 {"hour": 9, "minute": 10}。""" d = load().get("instruments_schedule", {"hour": 9, "minute": 10}) return {"hour": d.get("hour", 9), "minute": d.get("minute", 10)} def set_instruments_schedule(hour: int, minute: int) -> dict: h = max(0, min(23, hour)) m = max(0, min(59, minute)) # 盘前不晚于 09:15 if h * 60 + m > 9 * 60 + 15: h, m = 9, 15 save({"instruments_schedule": {"hour": h, "minute": m}}) return {"hour": h, "minute": m} def get_enriched_batch_size() -> int: """返回 enriched 全量计算每批 symbol 数量。""" return max(1, min(10000, load().get("enriched_batch_size", 1000))) def set_enriched_batch_size(size: int) -> int: """保存 enriched 全量计算批次大小。""" size = max(10, min(6000, size)) save({"enriched_batch_size": size}) return size def get_index_daily_batch_size() -> int: """返回指数日 K 同步每批 symbol 数量。""" return max(1, min(10000, load().get("index_daily_batch_size", 100))) def set_index_daily_batch_size(size: int) -> int: """保存指数日 K 同步批次大小。""" size = max(1, min(10000, size)) save({"index_daily_batch_size": size}) return size # ── 五档盘口 sealed(真假涨停) 配置 ────────────────────── def get_limit_ladder_monitor_enabled() -> bool: """连板梯队 5 档监控开关。关闭时 depth 不轮询(连板梯队降级显示)。""" return load().get("limit_ladder_monitor_enabled", False) def get_depth_polling_interval() -> float: """depth 盘中轮询间隔(秒)。默认 20(Pro/Expert 都适用)。""" return float(load().get("depth_polling_interval", 20.0)) def set_depth_polling_interval(interval: float) -> float: """保存 depth 轮询间隔。套餐范围 clamp 由 depth_service 按档位做。""" interval = max(1.0, min(600.0, float(interval))) save({"depth_polling_interval": interval}) return interval def get_depth_finalize_time() -> dict: """盘后 sealed 定版时间 {"hour": 15, "minute": 2}。范围 15:01~18:00。""" d = load().get("depth_finalize_time", {"hour": 15, "minute": 2}) return {"hour": d.get("hour", 15), "minute": d.get("minute", 2)} def set_depth_finalize_time(hour: int, minute: int) -> dict: """保存盘后 sealed 定版时间,强制范围 15:01~18:00。""" h = max(0, min(23, hour)) m = max(0, min(59, minute)) # 下限 15:01, 上限 18:00 if h * 60 + m < 15 * 60 + 1: h, m = 15, 1 if h * 60 + m > 18 * 60: h, m = 18, 0 save({"depth_finalize_time": {"hour": h, "minute": m}}) return {"hour": h, "minute": m} # ===== 实时监控 ===== # 页面 SSE 刷新配置: { "watchlist": true, "monitor": true, ... } # 可刷新的页面列表及其默认值 SSE_REFRESH_PAGES_DEFAULT = { "watchlist": True, "limit-ladder": False, } SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"] def get_sse_refresh_pages() -> dict[str, bool]: """返回每个页面的 SSE 刷新开关。""" stored = load().get("sse_refresh_pages", {}) # 合并默认值 (新增页面自动出现) result = dict(SSE_REFRESH_PAGES_DEFAULT) result.update(stored) return result def set_sse_refresh_pages(pages: dict[str, bool]) -> dict[str, bool]: """保存页面 SSE 刷新配置。""" save({"sse_refresh_pages": pages}) return get_sse_refresh_pages() def get_sidebar_index_symbols() -> list[str]: """返回左侧菜单显示的指数代码。""" stored = load().get("sidebar_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT) allowed = set(SIDEBAR_INDEX_SYMBOLS_DEFAULT) return [s for s in stored if s in allowed] def get_strategy_monitor_enabled() -> bool: """策略告警评估总开关。""" return load().get("strategy_monitor_enabled", False) def get_system_notify_enabled() -> bool: """系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。""" return load().get("system_notify_enabled", False) def set_system_notify_enabled(enabled: bool) -> bool: """保存系统通知开关。""" save({"system_notify_enabled": bool(enabled)}) return bool(enabled) def get_screener_auto_run() -> bool: """选股页进入时是否自动运行所有策略 (获取命中数)。默认开。""" return load().get("screener_auto_run", True) def get_strategy_monitor_ids() -> list[str]: """返回监控池中的策略 ID。""" return load().get("strategy_monitor_ids", []) def set_realtime_monitor_config(cfg: dict) -> dict: """批量更新实时监控配置。""" updates = {} if "sse_refresh_pages" in cfg: updates["sse_refresh_pages"] = cfg["sse_refresh_pages"] if "strategy_monitor_enabled" in cfg: updates["strategy_monitor_enabled"] = cfg["strategy_monitor_enabled"] if "strategy_monitor_ids" in cfg: updates["strategy_monitor_ids"] = cfg["strategy_monitor_ids"] if "sidebar_index_symbols" in cfg: allowed = set(SIDEBAR_INDEX_SYMBOLS_DEFAULT) updates["sidebar_index_symbols"] = [s for s in cfg["sidebar_index_symbols"] if s in allowed] if "screener_auto_run" in cfg: updates["screener_auto_run"] = bool(cfg["screener_auto_run"]) if updates: save(updates) return get_realtime_monitor_config() def get_realtime_monitor_config() -> dict: """返回完整的实时监控配置。""" return { "sse_refresh_pages": get_sse_refresh_pages(), "strategy_monitor_enabled": get_strategy_monitor_enabled(), "strategy_monitor_ids": get_strategy_monitor_ids(), "sidebar_index_symbols": get_sidebar_index_symbols(), "screener_auto_run": get_screener_auto_run(), } def get_nav_order() -> list[str]: """返回左侧菜单的自定义排序(内置页面 path + 扩展分析菜单 id)。""" return load().get("nav_order", []) def set_nav_order(order: list[str]) -> list[str]: """保存左侧菜单排序。""" save({"nav_order": order}) return get_nav_order() def get_nav_hidden() -> list[str]: """返回左侧菜单中隐藏的项 id 列表。""" return load().get("nav_hidden", []) def set_nav_hidden(hidden: list[str]) -> list[str]: """保存左侧菜单隐藏项。""" save({"nav_hidden": hidden}) return get_nav_hidden() def get_watchlist_columns() -> list[dict] | None: """返回自选列表列配置。""" return load().get("watchlist_columns") def set_watchlist_columns(columns: list[dict]) -> list[dict]: """保存自选列表列配置。""" save({"watchlist_columns": columns}) return columns def get_screener_result_columns() -> list[dict] | None: """返回策略结果列表列配置。""" return load().get("screener_result_columns") def set_screener_result_columns(columns: list[dict]) -> list[dict]: """保存策略结果列表列配置。""" save({"screener_result_columns": columns}) return columns # ===== 首次使用引导 ===== def get_onboarding_completed() -> bool: """是否已完成首次使用向导。默认 False(新用户)。""" return bool(load().get("onboarding_completed", False)) def set_onboarding_completed(done: bool = True) -> bool: """标记首次使用向导完成状态。""" save({"onboarding_completed": bool(done)}) return bool(done)