From 8a8cd746dcfebcbf842c68a5e657511033cd2deb Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sun, 28 Jun 2026 18:08:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=80=94=20=E6=95=B0=E6=8D=AE=E7=9B=AE?= =?UTF-8?q?=E5=BD=95/=E5=AE=9E=E6=97=B6=E7=9B=91=E6=8E=A7/=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E5=A4=8D=E7=9B=98/=E8=80=81CPU=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 桌面客户端体验优化,Polars 兼容内核 (老 CPU 兼容) 2. 自选页实时监控优化 3. 个股监控逻辑优化 4. 新增定时复盘 6. 其他: 修复 settings.py 中 HTTPException 未 import 的既有 bug --- .github/workflows/release.yml | 11 +- README.md | 2 +- backend/app/api/settings.py | 48 ++++++- backend/app/config.py | 20 +-- backend/app/jobs/daily_pipeline.py | 73 ++++++++++ backend/app/services/preferences.py | 28 ++++ backend/app/tickflow/repository.py | 63 ++++++++ .../src/components/monitor/RuleEditor.tsx | 35 ++++- frontend/src/lib/api.ts | 6 + frontend/src/pages/ConceptAnalysis.tsx | 10 ++ frontend/src/pages/Review.tsx | 136 +++++++++++++++++- frontend/src/pages/Watchlist.tsx | 41 ++++++ packaging/tickflow.iss | 53 +++++-- packaging/tickflow.spec | 13 ++ 14 files changed, 512 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b95b763..8608511 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,11 +96,15 @@ jobs: - name: 设置 Python run: uv python install 3.12 - - name: 安装后端依赖 (含 desktop, 不含 backtest) + - name: 安装后端依赖 (含 desktop + legacy-cpu, 不含 backtest) working-directory: backend # --no-dev 排除 pytest/ruff/mypy; --extra desktop 装 pywebview + # --extra legacy-cpu 装 polars[rtcompat] 运行时兼容内核: + # 让安装包同时兼容 AVX2 新 CPU 和无 AVX2 的老 CPU (NAS/老服务器/国产 CPU)。 + # rtcompat 会在运行时自动探测 CPU 能力 —— 有 AVX2 用 AVX2, 没有则回退, + # 对新 CPU 性能几乎无损 (Polars 1.x 官方推荐方案)。 # 不加 --extra backtest, 主包不含 vectorbt/numba/llvmlite - run: uv sync --no-dev --extra desktop + run: uv sync --no-dev --extra desktop --extra legacy-cpu - name: 安装 PyInstaller # 必须装进 backend 的 uv 环境 (pywebview/polars 等依赖都在那里), @@ -209,7 +213,8 @@ jobs: - **Windows**: 下载后双击运行, 按安装向导操作(无需管理员权限), 自动创建桌面/开始菜单快捷方式 - **macOS**: 双击 dmg 打开, 将 TickFlowStockPanel 拖入 Applications 文件夹; 首次启动需右键→打开(绕过 Gatekeeper) - **Linux**: 解压后运行可执行文件 - - 数据存储在用户目录 (`%LOCALAPPDATA%`), 卸载重装不丢数据 + - 数据存储在安装目录下的 `data/` 子文件夹, 卸载重装不丢数据 + - 内置 Polars 兼容内核, 新老 CPU (无 AVX2) 均可运行 - 含纯 Polars 回测引擎; 不含 vectorbt 回测(为控制体积) - 系统通知: 设置 → 实时监控 → 系统通知 - 检查更新: 设置 → 系统设置 → 关于 diff --git a/README.md b/README.md index 5a9b98e..51dbc3c 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ docker compose up --build
环境适配与高级选项(老 CPU · 手动启动 · 回测依赖) -**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`。 +**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:桌面客户端安装包已内置兼容内核(新老 CPU 通吃)。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`。 **手动分别启动:** diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 1735940..9af897a 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -7,7 +7,7 @@ from __future__ import annotations import logging import time -from fastapi import APIRouter, Request +from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from app import secrets_store @@ -314,6 +314,7 @@ def get_preferences() -> dict: "limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(), "depth_polling_interval": preferences.get_depth_polling_interval(), "depth_finalize_time": preferences.get_depth_finalize_time(), + "review_schedule": preferences.get_review_schedule(), } @@ -928,3 +929,48 @@ def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> di return sched + +class ReviewScheduleIn(BaseModel): + enabled: bool + hour: int + minute: int + + +@router.put("/preferences/review-schedule") +def update_review_schedule(req: ReviewScheduleIn, request: Request) -> dict: + """保存定时复盘调度并立即更新 APScheduler job。 + + - enabled=True: 注册/更新 job(工作日定时生成复盘报告) + - enabled=False: 移除 job(停止定时复盘) + - 校验: 开启时若 AI Key 未配置则拒绝(复盘依赖 AI), 提示用户先配置。 + - 时间下限 15:30(盘后数据就绪), 由 preferences 层强制。 + """ + from app.services import preferences + + if req.enabled: + # 复盘必须有 AI Key, 否则每日报错刷日志 + from app import secrets_store + if not secrets_store.get_ai_key(): + raise HTTPException( + status_code=400, + detail="复盘依赖 AI,请先在「设置 → AI」配置 API Key 后再开启定时复盘", + ) + + sched = preferences.set_review_schedule(req.enabled, req.hour, req.minute) + + # 动态操作 APScheduler job + from app.jobs.daily_pipeline import _register_review_job, REVIEW_JOB_ID + scheduler = getattr(request.app.state, "scheduler", None) + if scheduler: + if sched["enabled"]: + _register_review_job(scheduler, request.app.state.repo, sched["hour"], sched["minute"]) + logger.info("scheduled_review enabled @%02d:%02d mon-fri", sched["hour"], sched["minute"]) + else: + try: + scheduler.remove_job(REVIEW_JOB_ID) + logger.info("scheduled_review disabled (job removed)") + except Exception: + pass # job 本就不存在(从未开过), 无需处理 + + return sched + diff --git a/backend/app/config.py b/backend/app/config.py index e0542d9..c39ac2c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -21,22 +21,24 @@ def _user_data_root() -> Path: 定位策略 (按优先级): 1. 环境变量 DATA_DIR (pydantic-settings 自动注入到 settings.data_dir, 不在此处理) - 2. exe 同级的独立数据目录 <安装目录>/../TickFlowStockPanel_Data/ - —— 在 {app} 外, Inno Setup 覆盖安装/卸载都碰不到; 与 exe 同盘符, - 用户可直接看到, 不占 C 盘。 + 2. 打包桌面版: exe 同级的 data/ 子目录 (<安装目录>/data/) + —— 与程序同处一个总目录 (用户选择的安装目录), 视觉直观, 便于备份/迁移。 3. 非 frozen (开发模式): 项目根 data/ 为什么不用 platformdirs 默认 (%LOCALAPPDATA%) 作为主路径: - 落在 C 盘系统目录, 用户不易察觉, 占系统盘空间 - 用户期望「数据跟随程序」(便于备份/迁移) - 为什么不直接放 {app}/data (exe 旁的 data/): - - {app} 在安装目录内, Inno Setup 覆盖安装会覆盖、卸载会清空 → 数据丢失 - 折中: 放 {app} 的同级目录 (兄弟文件夹), 既跟随 exe 又不在 {app} 内。 + 为什么放 {app}/data (exe 旁的 data/) 而非 {app} 外的兄弟目录: + - 用户体验: 用户选了安装目录, 自然期望「程序和数据都在这」, 单一总目录更直观。 + - 数据安全: Inno Setup 覆盖安装(升级)时只往 {app} 写新程序文件, 不会清空 + 目录里不在安装清单上的运行时文件 (data/ 即此类), 故覆盖安装不丢数据。 + (注意: 卸载时需在 .iss 中豁免 data/, 见 packaging/tickflow.iss 的 [UninstallDelete]。) + 旧版本数据迁移: 见 DataStore._migrate_legacy_data_dir(), 老用户首次启动自动搬迁。 """ - # 打包桌面版: exe 同级的独立数据目录 (../TickFlowStockPanel_Data/) + # 打包桌面版: exe 同级的 data/ 子目录 (与程序同一总目录, 覆盖安装不丢数据) if _IS_FROZEN: exe_dir = Path(sys.executable).resolve().parent - return exe_dir.parent / "TickFlowStockPanel_Data" + return exe_dir / "data" # 开发模式: 项目根 data/ return _PROJECT_ROOT / "data" @@ -92,7 +94,7 @@ class Settings(BaseSettings): log_level: str = "INFO" backtest_range_guard: bool = False - # Data — frozen: exe 同级 TickFlowStockPanel_Data/; 非 frozen: 项目根 data/ + # Data — frozen: exe 同级 data/ 子目录; 非 frozen: 项目根 data/ # (均可被环境变量 DATA_DIR 覆盖, pydantic-settings 自动注入) data_dir: Path = _user_data_root() diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index d08a56e..59a829a 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -538,6 +538,69 @@ def _run_tracked(fn, job_label: str) -> None: job_store.fail(job_id, f"scheduled {job_label} failed") +# ================================================================ +# 定时复盘 (AI 大盘复盘报告) +# ================================================================ + +REVIEW_JOB_ID = "scheduled_review" + + +async def _run_scheduled_review(repo) -> None: + """定时复盘 job: 调用非流式复盘生成 → 落盘归档(与手动生成同格式)。 + + 静默执行, 不推送 SSE/系统通知 —— 用户下次打开复盘页即可看到新报告。 + 任何异常都吞掉只记日志, 绝不影响调度器主循环。 + """ + try: + from app.services.market_recap import recap_market_once + from app.services import market_recap_reports + from app import secrets_store as ss + + # AI Key 未配置时跳过(避免每日报错刷日志) + if not ss.get_ai_key(): + logger.info("scheduled review skipped: AI key not configured") + return + + app_state = _get_app_state() + quote_service = getattr(app_state, "quote_service", None) if app_state else None + depth_service = getattr(app_state, "depth_service", None) if app_state else None + + content, meta = await recap_market_once(repo, quote_service, depth_service) + if not content: + logger.warning("scheduled review produced no content (meta=%s)", meta) + return + + # 落盘: 与手动生成完全相同的归档格式 + market_recap_reports.save_report({ + "as_of": meta.get("as_of"), + "focus": "", + "content": content, + "summary": meta.get("summary", ""), + "emotion_score": meta.get("emotion_score"), + "emotion_label": meta.get("emotion_label", ""), + }) + logger.info("scheduled review saved: as_of=%s", meta.get("as_of")) + except Exception as e: # noqa: BLE001 + logger.exception("scheduled review failed: %s", e) + + +def _register_review_job(scheduler, repo, hour: int, minute: int) -> None: + """注册/更新定时复盘 job(工作日 mon-fri, Asia/Shanghai)。 + + 供 start_scheduler(启动时) 和 settings API(改时间时) 共用。 + 用 replace_existing=True, 重复注册只更新 trigger。 + """ + scheduler.add_job( + lambda: _run_scheduled_review(repo), + trigger=CronTrigger(day_of_week="mon-fri", + hour=hour, minute=minute, + timezone="Asia/Shanghai"), + id=REVIEW_JOB_ID, + misfire_grace_time=7200, # 复盘非关键, 允许 2 小时内补跑 + replace_existing=True, + ) + + def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOScheduler: """启动调度器。 @@ -600,6 +663,16 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche replace_existing=True, ) + # 定时复盘 (AI 大盘复盘报告): 工作日到点自动生成并归档。 + # 默认关闭 —— 仅当用户在复盘页开启时才注册 job。 + # 复用 recap_market_once(非流式) + market_recap_reports.save_report(落盘)。 + # quote_service / depth_service 通过 _get_app_state() 延迟取用。 + review_sched = preferences.get_review_schedule() + if review_sched["enabled"]: + _register_review_job(scheduler, repo, review_sched["hour"], review_sched["minute"]) + logger.info("scheduled_review enabled @%02d:%02d mon-fri", + review_sched["hour"], review_sched["minute"]) + scheduler.start() logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d, depth@%02d:%02d mon-fri", inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"], diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 8fe2890..7853dfe 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -265,6 +265,34 @@ def set_depth_finalize_time(hour: int, minute: int) -> dict: return {"hour": h, "minute": m} +def get_review_schedule() -> dict: + """定时复盘调度 {"enabled": False, "hour": 16, "minute": 30}。默认关闭。 + + 复盘依赖盘后数据(日K/enriched, 盘后管道默认 15:30 跑完), + 故默认时间设为 16:30(数据就绪后), 强制下限 15:30。 + """ + d = load().get("review_schedule", {"enabled": False, "hour": 16, "minute": 30}) + return { + "enabled": bool(d.get("enabled", False)), + "hour": d.get("hour", 16), + "minute": d.get("minute", 30), + } + + +def set_review_schedule(enabled: bool, hour: int, minute: int) -> dict: + """保存定时复盘调度。强制时间下限 15:30(盘后数据就绪)。 + + enabled=False 时时间仍保存(下次开启可沿用), 但调度器不会注册 job。 + """ + h = max(0, min(23, hour)) + m = max(0, min(59, minute)) + # 下限 15:30: 盘后管道(默认 15:30)完成后才有完整数据复盘 + if h * 60 + m < 15 * 60 + 30: + h, m = 15, 30 + save({"review_schedule": {"enabled": bool(enabled), "hour": h, "minute": m}}) + return {"enabled": bool(enabled), "hour": h, "minute": m} + + # ===== 实时监控 ===== diff --git a/backend/app/tickflow/repository.py b/backend/app/tickflow/repository.py index 953b291..f916c40 100644 --- a/backend/app/tickflow/repository.py +++ b/backend/app/tickflow/repository.py @@ -13,6 +13,7 @@ from __future__ import annotations import logging +import sys import threading from datetime import date from pathlib import Path @@ -32,6 +33,10 @@ class DataStore: self.data_dir = Path(data_dir or settings.data_dir) self.data_dir.mkdir(parents=True, exist_ok=True) + # 一次性数据迁移: 旧桌面版把数据放在 exe 同级的兄弟目录 TickFlowStockPanel_Data/, + # 新版改为 {app}/data/。老用户首次启动时自动把旧数据搬过来, 无感升级。 + self._migrate_legacy_data_dir() + # 关键子目录(§7.2) for sub in ( "kline_daily", @@ -67,6 +72,64 @@ class DataStore: self.db = duckdb.connect(database=":memory:") self._register_views() + def _migrate_legacy_data_dir(self) -> None: + """把旧桌面版数据目录 (<安装目录>/../TickFlowStockPanel_Data/) 迁移到新位置 (<安装目录>/data/)。 + + 背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录), + 新版改为 exe_dir / "data" (子目录)。老用户首次升级时旧数据在兄弟目录, + 若不迁移会导致历史行情/策略/回测/监控全部"丢失"(实际还在旧位置)。 + + 策略 (仅打包桌面版触发, 开发/Docker 不受影响): + 1. 旧目录存在且新 data/ 还基本为空 → 整目录搬迁 (shutil.move, 跨盘符安全)。 + 2. 新旧目录都已有数据 (用户在两套路径都跑过) → 不自动搬, 仅记日志, 避免覆盖。 + 3. 旧目录不存在 → 新装用户, 无需迁移。 + 所有异常都吞掉只记警告 —— 数据迁移失败绝不能阻塞应用启动。 + """ + # 仅打包桌面版需要迁移; 开发/Docker 模式 _PROJECT_ROOT/data 本就是唯一路径 + if not getattr(sys, "frozen", False): + return + + import shutil + + try: + legacy_dir = self.data_dir.parent / "TickFlowStockPanel_Data" + if not legacy_dir.exists(): + return # 新装用户, 无旧数据 + + # 新 data/ 目录里已有实质性内容 → 用户已在新路径跑过, 不覆盖 + # (用 .parquet 作为"有真实数据"的判据, 避免空子目录误判) + has_new_data = any(self.data_dir.rglob("*.parquet")) or any( + self.data_dir.rglob("*.jsonl") + ) + if has_new_data: + logger.info( + "legacy data dir %s exists but new %s already has data, skip migration", + legacy_dir, self.data_dir, + ) + return + + logger.info("migrating legacy data %s -> %s", legacy_dir, self.data_dir) + # 逐项 move 而非整目录 move: data/ 可能已被 __init__ 创建了空子目录, + # 直接 shutil.move(legacy, data) 会因目标已存在失败。 + for item in legacy_dir.iterdir(): + dest = self.data_dir / item.name + if dest.exists(): + # 同名子目录 (如 kline_daily): 合并内容 + if dest.is_dir(): + shutil.move(str(item), str(dest / item.name)) + else: + item.unlink() # 同名文件, 以新路径为准, 删旧 + else: + shutil.move(str(item), str(dest)) + # 搬完后清理空的旧目录 + try: + shutil.rmtree(legacy_dir) + except OSError: + logger.warning("legacy dir %s not empty, kept", legacy_dir) + logger.info("legacy data migration done") + except Exception as e: # noqa: BLE001 + logger.warning("legacy data migration failed (startup continues): %s", e) + def _register_views(self) -> None: """把 Parquet 目录挂载为 DuckDB 视图(§7.3)。""" d = self.data_dir.as_posix() diff --git a/frontend/src/components/monitor/RuleEditor.tsx b/frontend/src/components/monitor/RuleEditor.tsx index a6dffdc..59c5782 100644 --- a/frontend/src/components/monitor/RuleEditor.tsx +++ b/frontend/src/components/monitor/RuleEditor.tsx @@ -92,7 +92,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { ...d, conditions: [...d.conditions, op === 'truth' ? { field: 'signal_volume_surge', op: 'truth' } - : { field: 'rsi_14', op: '<', value: 30 }], + // simple 模式(个股弹窗)默认现价; 完整模式默认 RSI 超卖 + : { field: simple ? 'close' : 'rsi_14', op: '<', value: simple ? 0 : 30 }], })) const removeCond = (idx: number) => setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) })) @@ -139,6 +140,38 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { + {/* 价位条件 (阈值) — 与信号共存, 可选添加 */} +
+
+ 价位条件 (可选) + +
+ {thresholdConds.length > 0 && ( +
+ {thresholdConds.map((c, i) => { + const realIdx = draft.conditions.indexOf(c) + return ( +
+ {i === 0 && selectedSignals.length === 0 ? '当' : draft.logic === 'and' ? '且' : '或'} + + + updateCond(realIdx, { value: parseFloat(e.target.value) })} step="any" className="w-24 h-7 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" /> + +
+ ) + })} +
+ )} +
+