diff --git a/backend/app/__init__.py b/backend/app/__init__.py index e2f0df1..41ddb81 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,3 +1,15 @@ """TickFlow Stock Panel backend.""" +import sys + __version__ = "0.1.38" + +# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的 +# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。 +# 进程加载最早阶段强制 UTF-8,根治此类编码崩溃。 +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: # noqa: BLE001 + pass diff --git a/backend/app/api/data.py b/backend/app/api/data.py index 2afe935..d5a725d 100644 --- a/backend/app/api/data.py +++ b/backend/app/api/data.py @@ -564,9 +564,31 @@ def clear_data(request: Request): fp.unlink() deleted += 1 + # 清除监控运行数据 (user_data 下仅清运行产物, 不动 monitor_rules/preferences/secrets 等用户配置) + # - 触发记录 alerts.jsonl + from app.services import alert_store + alert_store.clear(data_dir) + # - 待推送的实时通知队列 (进程内存) + qs = getattr(request.app.state, "quote_service", None) + if qs is not None: + with qs._lock: + qs._pending_alerts.clear() + # 清除 Polars 缓存 + # 先 clear_cache 无条件清空内存 (refresh_cache 在磁盘无数据时会提前 return, + # 导致 _enriched_cache 等旧数据残留 —— 清数据后看板仍显示旧数据的根因), + # 再 refresh_cache 尝试重载 (磁盘有数据则重建缓存)。 + repo.clear_cache() repo.refresh_cache() + # 清除 Screener 进程级 _history_cache (TTL 缓存) + from app.services.screener import ScreenerService + ScreenerService.clear_history_cache() + + # 清除 Overview 总览聚合结果缓存 (5s TTL) + from app.api.overview import invalidate_overview_cache + invalidate_overview_cache() + # 刷新 DuckDB 视图(空 parquet 目录也需要重新挂载) d = data_dir.as_posix() for name, path in { @@ -703,23 +725,19 @@ def table_schema(request: Request, table: str) -> list[dict]: def get_version(request: Request) -> dict: """返回当前项目版本号。 - 优先从 pyproject.toml 读取 (项目权威版本源), - 回退到 VERSION 文件, 最后兜底 v0.0.0。 + 优先读 app.__version__ (与 /health 接口同源, 唯一权威版本), + 回退到项目根 VERSION 文件, 最后兜底 v0.0.0。 """ + from app import __version__ + + # 1. 优先用 app.__version__ (开发期 bump_version.py 写入, 打包期由 PyInstaller 注入) + if __version__: + v = __version__.strip() + return {"version": v if v.startswith("v") else f"v{v}"} + + # 2. 回退到项目根 VERSION 文件 from app.config import settings project_root = Path(settings.data_dir).parent - - # 1. 优先读 pyproject.toml - pyproject = project_root / "pyproject.toml" - if pyproject.exists(): - for line in pyproject.read_text(encoding="utf-8").splitlines(): - if line.strip().startswith("version"): - # version = "0.1.28" - v = line.split("=", 1)[1].strip().strip('"').strip("'") - if v: - return {"version": f"v{v}" if not v.startswith("v") else v} - - # 2. 回退到 VERSION 文件 version_file = project_root / "VERSION" if version_file.exists(): v = version_file.read_text(encoding="utf-8").strip() diff --git a/backend/app/api/overview.py b/backend/app/api/overview.py index fa1eebb..c6367c1 100644 --- a/backend/app/api/overview.py +++ b/backend/app/api/overview.py @@ -20,6 +20,18 @@ _cache: dict[str, Any] | None = None _cache_key: str | None = None _cache_ts: float = 0.0 + +def invalidate_overview_cache() -> None: + """清空总览聚合结果缓存。 + + 清除数据后调用, 避免看板在 TTL 窗口内继续返回旧的聚合结果。 + """ + global _cache, _cache_key, _cache_ts + _cache = None + _cache_key = None + _cache_ts = 0.0 + + CORE_INDEX_NAMES = { "000001.SH": "上证指数", "399001.SZ": "深证成指", diff --git a/backend/app/services/screener.py b/backend/app/services/screener.py index 8adfa1e..b4c46b0 100644 --- a/backend/app/services/screener.py +++ b/backend/app/services/screener.py @@ -185,6 +185,14 @@ class ScreenerService: def __init__(self, repo: KlineRepository) -> None: self.repo = repo + @staticmethod + def clear_history_cache() -> None: + """清空进程级 _history_cache (TTL 缓存)。 + + 清除数据后调用, 避免内存里的旧历史窗口残留导致策略/看板仍命中旧数据。 + """ + _history_cache.clear() + def _load_enriched_for_date(self, target_date: date) -> pl.DataFrame: """从 enriched parquet 读取指定日期的基础数据并即时计算完整指标+信号。 diff --git a/backend/app/tickflow/repository.py b/backend/app/tickflow/repository.py index 32853d4..a247316 100644 --- a/backend/app/tickflow/repository.py +++ b/backend/app/tickflow/repository.py @@ -152,6 +152,22 @@ class KlineRepository: self._refresh_index_instruments() self._refresh_enriched() + def clear_cache(self) -> None: + """清空所有 Polars 内存缓存。 + + 与 refresh_cache 的区别: refresh_cache 在磁盘无数据时会提前 return, + 导致内存里的旧缓存残留 (clear 数据后看板仍显示旧数据的根因)。 + 本方法无条件清空, 供清除数据/重置场景调用。 + """ + self._enriched_cache = None + self._enriched_cache_date = None + self._enriched_history_cache = None + self._enriched_history_start = None + self._live_agg_cache = None + self._live_agg_cache_date = None + self._instruments_cache = None + self._index_instruments_cache = None + def _refresh_enriched(self) -> None: """从 parquet 加载 enriched 最新日到内存 + 构建聚合表。 @@ -163,6 +179,9 @@ class KlineRepository: try: latest = self._latest_enriched_date_duckdb() if not latest: + # 磁盘已无数据: 必须清空内存缓存, 否则旧数据会残留 + # (清数据后看板仍显示旧数据的根因) + self.clear_cache() return # Step 1: 直接读最新日期的分区文件 (仅 14 列)