"""用户偏好设置持久化。 存储位置: 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_indices_nav_pinned() -> bool: """侧栏指数报价卡片是否固定显示。默认 True(常驻)。 关闭后,卡片跟随实时行情开关(仅实时开时显示)。""" return load().get("indices_nav_pinned", True) 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))) # ===== 数据源选择 (默认 TickFlow;第一阶段仅日K切换入口) ===== _ALLOWED_DATA_PROVIDERS = {"tickflow"} def get_daily_data_provider() -> str: provider = str(load().get("daily_data_provider", "tickflow") or "tickflow").lower() return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow" def get_adj_factor_provider() -> str: provider = str(load().get("adj_factor_provider", "same_as_daily") or "same_as_daily").lower() if provider == "same_as_daily": return provider return provider if provider in _ALLOWED_DATA_PROVIDERS else "same_as_daily" def get_minute_data_provider() -> str: provider = str(load().get("minute_data_provider", "tickflow") or "tickflow").lower() return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow" # ===== 盘后管道拉取内容开关 (A股 / ETF / 指数 独立控制) ===== def get_pipeline_pull_a_share() -> bool: """A 股日K固定拉取。""" return True def get_pipeline_pull_etf() -> bool: """是否拉取 ETF 日K。默认 False(标的多,首次较慢)。""" return load().get("pipeline_pull_etf", False) def get_pipeline_pull_index() -> bool: """是否拉取指数日K。默认 True。""" return load().get("pipeline_pull_index", True) _PIPELINE_PULL_KEYS = ("pipeline_pull_etf", "pipeline_pull_index") def get_pipeline_pull_types() -> dict: """返回三个拉取开关的当前值。""" return { "pipeline_pull_a_share": get_pipeline_pull_a_share(), "pipeline_pull_etf": get_pipeline_pull_etf(), "pipeline_pull_index": get_pipeline_pull_index(), } def set_pipeline_pull_types(cfg: dict) -> dict: """批量保存拉取开关。只接受白名单内的布尔字段。""" updates = { k: bool(v) for k, v in cfg.items() if k in _PIPELINE_PULL_KEYS and v is not None } save(updates) return get_pipeline_pull_types() def get_pipeline_index_symbols() -> str: """指数自定义拉取代码(逗号/换行/空格分隔)。空串表示全量。""" return str(load().get("pipeline_index_symbols", "") or "").strip() def set_pipeline_index_symbols(symbols: str) -> str: """保存指数自定义代码,返回规范化后的字符串。""" save({"pipeline_index_symbols": symbols}) return get_pipeline_index_symbols() 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 # 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位) # 多选: 不推送 = 空数组, 而非 'none' REVIEW_PUSH_CHANNELS = {"feishu"} def get_review_schedule() -> dict: """定时复盘调度 {"enabled": False, "hour": 15, "minute": 10}。默认关闭。 A股 15:00 收盘, 默认时间设为 15:10(收盘后即时复盘), 强制下限 15:00。 """ d = load().get("review_schedule", {"enabled": False, "hour": 15, "minute": 10}) return { "enabled": bool(d.get("enabled", False)), "hour": d.get("hour", 15), "minute": d.get("minute", 10), } def set_review_schedule(enabled: bool, hour: int, minute: int) -> dict: """保存定时复盘调度。强制时间下限 15:00(A股收盘)。 enabled=False 时时间仍保存(下次开启可沿用), 但调度器不会注册 job。 """ h = max(0, min(23, hour)) m = max(0, min(59, minute)) # 下限 15:00: A股 15:00 收盘, 收盘后才有当日完整数据复盘 if h * 60 + m < 15 * 60: h, m = 15, 0 save({"review_schedule": {"enabled": bool(enabled), "hour": h, "minute": m}}) return {"enabled": bool(enabled), "hour": h, "minute": m} def get_review_push_channels() -> list[str]: """复盘推送渠道(多选) — 选定的外部工具列表, 复盘归档后逐个推送。 与 review_schedule / 实时行情完全独立, 常驻可单独设置。 空列表 = 不推送; ['feishu'] = 推送到飞书(复用监控中心全局 feishu_webhook_url/secret)。 向后兼容: - 老多版本单选 review_push_channel=='feishu' → ['feishu'] - 更老布尔 review_push_enabled==True → ['feishu'] """ d = load() raw = d.get("review_push_channels") if isinstance(raw, list): return [c for c in raw if c in REVIEW_PUSH_CHANNELS] # 兼容老单选字符串 if d.get("review_push_channel") == "feishu": return ["feishu"] # 兼容更老布尔开关 if d.get("review_push_enabled") is True: return ["feishu"] return [] def set_review_push_channels(channels: list[str]) -> list[str]: """保存复盘推送渠道(多选)。过滤白名单外的值、去重、保序。空列表 = 不推送。""" seen: set[str] = set() cleaned: list[str] = [] for c in channels or []: if c in REVIEW_PUSH_CHANNELS and c not in seen: seen.add(c) cleaned.append(c) save({"review_push_channels": cleaned}) return cleaned SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"] 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_feishu_webhook_url() -> str: """飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。""" return load().get("feishu_webhook_url", "") def get_feishu_webhook_secret() -> str: """飞书自定义机器人签名密钥 — 机器人启用「签名校验」时必填, 留空表示不验签。""" return load().get("feishu_webhook_secret", "") def get_screener_auto_run() -> bool: """选股页进入时是否自动运行所有策略 (获取命中数)。默认开。""" return load().get("screener_auto_run", True) 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_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) # ===== 财务数据同步时间(持久化,重启不丢失) ===== # 结构: { "metrics": "2026-06-25T10:00:00+08:00", "income": ..., ... } def get_financial_sync_times() -> dict[str, str]: """返回各财务表的最后同步时间(ISO 字符串)。未同步过的表不在返回值中。""" return load().get("financial_sync_times", {}) or {} def set_financial_sync_time(table: str, iso_ts: str) -> None: """更新单张财务表的最后同步时间(合并写入,不清除其他表)。""" times = get_financial_sync_times() times[table] = iso_ts save({"financial_sync_times": times})