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)
+85
View File
@@ -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 # 落回自动检测
+18
View File
@@ -831,6 +831,24 @@ export async function clearLlmHistory(): Promise<number> {
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<LimitUpEcologyResp> {
+2
View File
@@ -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
+67 -2
View File
@@ -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<string | null>(null)
const vipdocResolved = ref<string | null>(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 {
<button class="manual-refresh" @click="load"> 刷新</button>
</div>
<div class="vipdoc-bar card">
<span class="dim">vipdoc 数据目录</span>
<input
v-model="vipdocInput"
class="vipdoc-input mono"
type="text"
:placeholder="vipdocResolved || '自动检测(TDX_HOME 或常见安装路径)'"
spellcheck="false"
/>
<button :disabled="vipdocBusy" @click="saveVipdoc">{{ vipdocBusy ? '保存中' : '保存' }}</button>
<button :disabled="vipdocBusy || !vipdocStored" title="清除已存路径,恢复自动检测" @click="vipdocInput = ''; saveVipdoc()">自动检测</button>
<span v-if="vipdocMsg" class="dim">{{ vipdocMsg }}</span>
<span v-else-if="vipdocResolved && !vipdocStored" class="dim">当前自动检测{{ vipdocResolved }}</span>
</div>
<div v-if="error" class="err card">
加载失败{{ error }}
<button @click="load">重试</button>
@@ -145,7 +196,7 @@ function pctClass(pct: number): string {
<template v-else-if="resp">
<div v-if="resp.total === 0" class="err card">
未检测到本地通达信 vipdoc 日线数据请确认本机已安装通达信
未检测到本地通达信 vipdoc 日线数据可在上方填写安装目录 D:\new_tdx\vipdoc后保存或确认通达信已安装
<code> vipdoc/{sh,sz}/lday/*.day </code> 存在自动检测失败时可在 CLI 侧指定路径
</div>
@@ -245,6 +296,20 @@ function pctClass(pct: number): string {
</template>
<style scoped>
.vipdoc-bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
padding: 8px 12px;
}
.vipdoc-input {
flex: 1;
min-width: 260px;
padding: 4px 10px;
font-size: 12px;
}
.limitup-view {
height: 100%;
overflow-y: auto;