mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +08:00
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:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 # 落回自动检测
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user