feat: 涨停生态 vipdoc 路径可配置 — 应用级 KV 设置 + 页面配置行

- 新增 AppSettingsStore(~/.easy_tdx/settings.db,KV 持久化,重启不丢)
- GET/PUT /settings/vipdoc:读取/保存本地 vipdoc 目录(保存即校验目录
  存在,并清空涨停生态/历史缓存,下次扫描立用新路径;空串=恢复自动检测)
- vipdoc 解析优先级:显式参数 > 已存设置 > 自动检测(TDX_HOME/常见路径)
- /limitup-ecology 响应携带 vipdoc_path 供页面展示生效目录
- 涨停生态页头部新增路径配置行(输入框+保存+恢复自动检测),
  未检测到数据的空态文案引导填写
This commit is contained in:
Justin Gu
2026-09-05 11:24:48 +08:00
parent a36043f962
commit aead122478
6 changed files with 326 additions and 4 deletions
+86
View File
@@ -0,0 +1,86 @@
"""应用级轻量设置(KV,SQLite 持久化,重启不丢)。
与 watchlist/llm_history 同款存储约定:单文件 SQLite 落统一配置目录
``~/.easy_tdx/settings.db``,随 ``EASY_TDX_CONFIG_DIR`` 走),短连接 +
写锁串行。存放跨重启的服务端偏好,如本地 vipdoc 路径(涨停生态扫描用)。
"""
from __future__ import annotations
import os
import sqlite3
import threading
from pathlib import Path
__all__ = ["AppSettingsStore", "get_app_settings_store"]
_write_lock = threading.Lock()
def _config_dir() -> Path:
return Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx")))
class AppSettingsStore:
"""KV 设置存储(值为任意 JSON 可序列化对象,实际以字符串存取)。"""
def __init__(self, db_path: str | Path | None = None):
self._path = Path(db_path) if db_path else _config_dir() / "settings.db"
self._path.parent.mkdir(parents=True, exist_ok=True)
with _write_lock:
conn = self._connect()
try:
conn.execute(
"CREATE TABLE IF NOT EXISTS settings ("
"key TEXT PRIMARY KEY, value TEXT NOT NULL)"
)
conn.commit()
finally:
conn.close()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._path, timeout=10)
conn.row_factory = sqlite3.Row
return conn
def get(self, key: str, default: str | None = None) -> str | None:
conn = self._connect()
try:
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
return str(row["value"]) if row is not None else default
finally:
conn.close()
def set(self, key: str, value: str) -> None:
with _write_lock:
conn = self._connect()
try:
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, value),
)
conn.commit()
finally:
conn.close()
def delete(self, key: str) -> None:
with _write_lock:
conn = self._connect()
try:
conn.execute("DELETE FROM settings WHERE key = ?", (key,))
conn.commit()
finally:
conn.close()
_store: AppSettingsStore | None = None
_store_lock = threading.Lock()
def get_app_settings_store() -> AppSettingsStore:
"""进程级单例(测试可通过 ``app_settings_store._store = None`` 重置)。"""
global _store
with _store_lock:
if _store is None:
_store = AppSettingsStore()
return _store
+68 -2
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, Query
@@ -98,6 +99,61 @@ async def market_session() -> dict[str, Any]:
return session_info()
# 本地 vipdoc 路径设置(应用级 KV,重启不丢;涨停生态/涨停历史共用)
_VIPDOC_KEY = "vipdoc"
def _effective_vipdoc(explicit: str | None) -> str | None:
"""vipdoc 解析优先级:显式参数 > 已存设置 > 自动检测(None)。"""
if explicit:
return explicit
from easy_tdx.web.app_settings_store import get_app_settings_store
return get_app_settings_store().get(_VIPDOC_KEY)
@router.get("/settings/vipdoc")
async def get_vipdoc_setting() -> dict[str, Any]:
"""读取本地 vipdoc 路径设置(含自动检测的当前生效值,供涨停生态页配置)。"""
from easy_tdx.web.app_settings_store import get_app_settings_store
stored = get_app_settings_store().get(_VIPDOC_KEY)
effective = _effective_vipdoc(None)
try:
from easy_tdx.offline.paths import resolve_vipdoc
resolved = str(resolve_vipdoc(effective))
except Exception: # noqa: BLE001 — 未检测到/路径无效
resolved = None
return {"stored": stored, "resolved": resolved}
@router.put("/settings/vipdoc")
async def set_vipdoc_setting(req: dict[str, Any]) -> dict[str, Any]:
"""保存/清除本地 vipdoc 路径设置(空串 = 恢复自动检测)。
保存即校验目录存在;成功后清空涨停生态/历史缓存,下次扫描立用新路径。
"""
from easy_tdx.offline.paths import resolve_vipdoc
from easy_tdx.web.app_settings_store import get_app_settings_store
path = str(req.get("path") or "").strip()
if path:
p = Path(path)
if not p.is_dir():
raise ValueError(f"路径不存在或不是目录: {p}")
get_app_settings_store().set(_VIPDOC_KEY, str(p))
resolved = str(resolve_vipdoc(str(p)))
else:
get_app_settings_store().delete(_VIPDOC_KEY)
resolved = None
# 路径变更后旧扫描结果作废
global _limitup_cache, _limitup_history_cache
_limitup_cache = None
_limitup_history_cache.clear()
return {"stored": path, "resolved": resolved}
@router.get("/limitup-ecology", response_model=DictResponse)
async def limitup_ecology(
vipdoc: str | None = Query(None, description="离线数据目录(默认自动检测)"),
@@ -114,13 +170,23 @@ async def limitup_ecology(
if _limitup_cache is not None and now - _limitup_cache[0] < _LIMITUP_TTL:
return DictResponse.from_dict(_limitup_cache[1])
effective = _effective_vipdoc(vipdoc)
def _scan() -> dict[str, Any]:
from easy_tdx.screen.limitup import compute_limitup_ecology
eco = compute_limitup_ecology(vipdoc)
eco = compute_limitup_ecology(effective)
vipdoc_path = None
try:
from easy_tdx.offline.paths import resolve_vipdoc
vipdoc_path = str(resolve_vipdoc(effective))
except Exception: # noqa: BLE001 — 未检测到时前端展示配置入口
pass
return {
"data_date": eco.data_date,
"total": eco.total,
"vipdoc_path": vipdoc_path,
"summary": eco.summary(),
"limit_up": [asdict(e) for e in eco.limit_up],
"limit_down": [asdict(e) for e in eco.limit_down],
@@ -187,7 +253,7 @@ async def limitup_history(
def _scan() -> dict[str, Any]:
from easy_tdx.screen.limitup import compute_limitup_history
rows = compute_limitup_history(vipdoc, days=days)
rows = compute_limitup_history(_effective_vipdoc(vipdoc), days=days)
return {"count": len(rows), "days": rows}
payload = await asyncio.to_thread(_scan)