a45aa5b033
Co-Authored-By: Claude <noreply@anthropic.com>
336 lines
11 KiB
Python
336 lines
11 KiB
Python
"""用户偏好设置持久化。
|
|
|
|
存储位置: 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})
|