From aead122478bf3e523e596111b05319d5a11200ed Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Sat, 5 Sep 2026 11:24:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B6=A8=E5=81=9C=E7=94=9F=E6=80=81=20?= =?UTF-8?q?vipdoc=20=E8=B7=AF=E5=BE=84=E5=8F=AF=E9=85=8D=E7=BD=AE=20?= =?UTF-8?q?=E2=80=94=20=E5=BA=94=E7=94=A8=E7=BA=A7=20KV=20=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=20+=20=E9=A1=B5=E9=9D=A2=E9=85=8D=E7=BD=AE=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AppSettingsStore(~/.easy_tdx/settings.db,KV 持久化,重启不丢) - GET/PUT /settings/vipdoc:读取/保存本地 vipdoc 目录(保存即校验目录 存在,并清空涨停生态/历史缓存,下次扫描立用新路径;空串=恢复自动检测) - vipdoc 解析优先级:显式参数 > 已存设置 > 自动检测(TDX_HOME/常见路径) - /limitup-ecology 响应携带 vipdoc_path 供页面展示生效目录 - 涨停生态页头部新增路径配置行(输入框+保存+恢复自动检测), 未检测到数据的空态文案引导填写 --- src/easy_tdx/web/app_settings_store.py | 86 ++++++++++++++++++++++++++ src/easy_tdx/web/routers/market.py | 70 ++++++++++++++++++++- tests/unit/test_vipdoc_settings.py | 85 +++++++++++++++++++++++++ web-ui/src/api.ts | 18 ++++++ web-ui/src/types.ts | 2 + web-ui/src/views/LimitUpView.vue | 69 ++++++++++++++++++++- 6 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 src/easy_tdx/web/app_settings_store.py create mode 100644 tests/unit/test_vipdoc_settings.py diff --git a/src/easy_tdx/web/app_settings_store.py b/src/easy_tdx/web/app_settings_store.py new file mode 100644 index 0000000..a7f02f3 --- /dev/null +++ b/src/easy_tdx/web/app_settings_store.py @@ -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 diff --git a/src/easy_tdx/web/routers/market.py b/src/easy_tdx/web/routers/market.py index 10f7e7a..431e2c5 100644 --- a/src/easy_tdx/web/routers/market.py +++ b/src/easy_tdx/web/routers/market.py @@ -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) diff --git a/tests/unit/test_vipdoc_settings.py b/tests/unit/test_vipdoc_settings.py new file mode 100644 index 0000000..ba14dc6 --- /dev/null +++ b/tests/unit/test_vipdoc_settings.py @@ -0,0 +1,85 @@ +"""vipdoc 路径设置(app_settings_store + /settings/vipdoc 端点)单测。 + +覆盖:KV 存取/删除、端点保存校验(不存在路径 400)、保存后清空涨停缓存、 +_effective_vipdoc 优先级(显式参数 > 已存设置 > 自动检测)。 +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def settings_env(tmp_path, monkeypatch): + """独立配置目录 + 全新单例。""" + from easy_tdx.web import app_settings_store as asm + + monkeypatch.setenv("EASY_TDX_CONFIG_DIR", str(tmp_path / "cfg")) + asm._store = None + yield asm + asm._store = None + + +def test_settings_kv_roundtrip(settings_env): + store = settings_env.get_app_settings_store() + assert store.get("vipdoc") is None # 缺省 None + store.set("vipdoc", r"D:\new_tdx\vipdoc") + assert store.get("vipdoc") == r"D:\new_tdx\vipdoc" + store.set("vipdoc", r"E:\tdx\vipdoc") # 覆盖 + assert store.get("vipdoc") == r"E:\tdx\vipdoc" + store.delete("vipdoc") + assert store.get("vipdoc") is None + + +def test_vipdoc_settings_endpoints(settings_env, tmp_path): + """GET/PUT 往返;PUT 校验目录存在;保存即清空涨停扫描缓存。""" + pytest.importorskip("fastapi") + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from easy_tdx.web.errors import register_exception_handlers + from easy_tdx.web.routers import market as market_mod + + real_dir = tmp_path / "vipdoc_real" + (real_dir / "sh" / "lday").mkdir(parents=True) + + app = FastAPI() + register_exception_handlers(app) + app.include_router(market_mod.router, prefix="/api/v1") + app.state.tdx_client = object() + + with TestClient(app) as client: + # 初始:无已存设置 + r = client.get("/api/v1/settings/vipdoc") + assert r.status_code == 200 + assert r.json()["stored"] is None + + # 不存在的路径 → 400 + bad = client.put("/api/v1/settings/vipdoc", json={"path": str(tmp_path / "nope")}) + assert bad.status_code == 400 + + # 保存有效目录 → 生效 + 涨停缓存清空 + r = client.put("/api/v1/settings/vipdoc", json={"path": str(real_dir)}) + assert r.status_code == 200 + assert r.json()["stored"] == str(real_dir) + assert market_mod._limitup_cache is None + + # GET 回读 + assert client.get("/api/v1/settings/vipdoc").json()["stored"] == str(real_dir) + + # 清除(空串)→ 恢复自动检测 + r = client.put("/api/v1/settings/vipdoc", json={"path": ""}) + assert r.status_code == 200 + assert client.get("/api/v1/settings/vipdoc").json()["stored"] is None + + +def test_effective_vipdoc_priority(settings_env, tmp_path, monkeypatch): + """显式参数 > 已存设置 > 自动检测(None)。""" + from easy_tdx.web.app_settings_store import get_app_settings_store + from easy_tdx.web.routers.market import _effective_vipdoc + + get_app_settings_store().set("vipdoc", str(tmp_path)) + assert _effective_vipdoc(str(tmp_path / "other")) == str(tmp_path / "other") # 显式优先 + assert _effective_vipdoc(None) == str(tmp_path) # 已存设置 + get_app_settings_store().delete("vipdoc") + assert _effective_vipdoc(None) is None # 落回自动检测 diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index 41a9702..25063a4 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -831,6 +831,24 @@ export async function clearLlmHistory(): Promise { return (await resp.json()).deleted as number } +/** 读取本地 vipdoc 路径设置(stored=已保存;resolved=当前自动检测生效值)。 */ +export async function fetchVipdocSetting(): Promise<{ stored: string | null; resolved: string | null }> { + const resp = await fetch(`${BASE}/settings/vipdoc`) + if (!resp.ok) await throwError(resp) + return (await resp.json()) as { stored: string | null; resolved: string | null } +} + +/** 保存/清除本地 vipdoc 路径设置(空串 = 恢复自动检测;服务端随之清空涨停缓存)。 */ +export async function saveVipdocSetting(path: string): Promise<{ stored: string; resolved: string | null }> { + const resp = await fetch(`${BASE}/settings/vipdoc`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }) + if (!resp.ok) await throwError(resp) + return (await resp.json()) as { stored: string; resolved: string | null } +} + /** 涨停生态(连板天梯/炸板/跌停,本地 vipdoc 离线回算,服务端缓存 60s)。 * data_date 为 vipdoc 数据日期;name 字段需前端经 fetchSymbolName 补齐。 */ export async function fetchLimitUpEcology(): Promise { diff --git a/web-ui/src/types.ts b/web-ui/src/types.ts index 6537286..7455f0f 100644 --- a/web-ui/src/types.ts +++ b/web-ui/src/types.ts @@ -672,6 +672,8 @@ export interface LimitUpEcologyResp { /** vipdoc 数据日期 YYYYMMDD —— 新鲜度取决于本机通达信客户端 */ data_date: number total: number + /** 实际生效的 vipdoc 目录(自动检测或已存设置),未检测到为 null */ + vipdoc_path?: string | null summary: { limit_up_count: number limit_down_count: number diff --git a/web-ui/src/views/LimitUpView.vue b/web-ui/src/views/LimitUpView.vue index 7f6137b..0880f63 100644 --- a/web-ui/src/views/LimitUpView.vue +++ b/web-ui/src/views/LimitUpView.vue @@ -4,7 +4,7 @@ // 单击任意股票打开 StockDialog。120s 轮询 + 手动刷新。 import { computed, onBeforeUnmount, onMounted, ref } from 'vue' -import { fetchLimitUpEcology, fetchSymbolName, formatError } from '../api' +import { fetchLimitUpEcology, fetchSymbolName, fetchVipdocSetting, formatError, saveVipdocSetting } from '../api' import StockDialog from '../components/StockDialog.vue' import type { LimitUpEntry } from '../types' @@ -13,11 +13,46 @@ const loading = ref(false) const error = ref('') const lastRefresh = ref('') +const vipdocStored = ref(null) +const vipdocResolved = ref(null) +const vipdocInput = ref('') +const vipdocBusy = ref(false) +const vipdocMsg = ref('') + +async function loadVipdocSetting() { + try { + const r = await fetchVipdocSetting() + vipdocStored.value = r.stored + vipdocResolved.value = r.resolved + vipdocInput.value = r.stored ?? '' + } catch { + // 设置读取失败不打扰主流程 + } +} + +async function saveVipdoc() { + vipdocBusy.value = true + vipdocMsg.value = '' + try { + const r = await saveVipdocSetting(vipdocInput.value.trim()) + vipdocStored.value = r.stored || null + vipdocResolved.value = r.resolved + vipdocMsg.value = '已保存,正在按新路径重新扫描…' + await load() + vipdocMsg.value = '已保存' + } catch (e) { + vipdocMsg.value = formatError(e) + } finally { + vipdocBusy.value = false + } +} + async function load() { loading.value = resp.value === null error.value = '' try { resp.value = await fetchLimitUpEcology() + vipdocResolved.value = resp.value.vipdoc_path ?? vipdocResolved.value lastRefresh.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }) fillNames(allEntries.value) } catch (e) { @@ -110,6 +145,7 @@ function tick() { } onMounted(() => { + loadVipdocSetting() load() timer = window.setInterval(tick, 120_000) }) @@ -137,6 +173,21 @@ function pctClass(pct: number): string { +
+ vipdoc 数据目录 + + + + {{ vipdocMsg }} + 当前自动检测:{{ vipdocResolved }} +
+
加载失败:{{ error }} @@ -145,7 +196,7 @@ function pctClass(pct: number): string {