feat: — 数据目录/实时监控/定时复盘/老CPU兼容

1. 桌面客户端体验优化,Polars 兼容内核 (老 CPU 兼容)
2. 自选页实时监控优化
3. 个股监控逻辑优化
4. 新增定时复盘
6. 其他: 修复 settings.py 中 HTTPException 未 import 的既有 bug
This commit is contained in:
shy3130
2026-06-28 18:08:52 +08:00
parent b79fe60a56
commit 8a8cd746dc
14 changed files with 512 additions and 27 deletions
+8 -3
View File
@@ -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 回测(为控制体积)
- 系统通知: 设置 → 实时监控 → 系统通知
- 检查更新: 设置 → 系统设置 → 关于
+1 -1
View File
@@ -111,7 +111,7 @@ docker compose up --build
<details>
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
**老 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`
**手动分别启动:**
+47 -1
View File
@@ -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
+11 -9
View File
@@ -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()
+73
View File
@@ -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"],
+28
View File
@@ -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}
# ===== 实时监控 =====
+63
View File
@@ -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()
+34 -1
View File
@@ -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) {
<SignalPicker signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" />
</div>
{/* 价位条件 (阈值) — 与信号共存, 可选添加 */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted"> ()</span>
<button onClick={() => addCond('threshold')} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
</div>
{thresholdConds.length > 0 && (
<div className="space-y-1.5">
{thresholdConds.map((c, i) => {
const realIdx = draft.conditions.indexOf(c)
return (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted/60 w-6 text-right shrink-0">{i === 0 && selectedSignals.length === 0 ? '当' : draft.logic === 'and' ? '且' : '或'}</span>
<select value={c.field} onChange={e => updateCond(realIdx, { field: e.target.value })} className="flex-1 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
{thresholdFields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<select value={c.op} onChange={e => updateCond(realIdx, { op: e.target.value })} className="w-12 h-7 px-1 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50">
{operators.map(op => <option key={op} value={op}>{op}</option>)}
</select>
<input type="number" value={c.value ?? 0} onChange={e => 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" />
<button onClick={() => removeCond(realIdx)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
<X className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
)}
</div>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"> ()</span>
<input value={draft.message} onChange={e => setDraft(d => ({ ...d, message: e.target.value }))} placeholder="给这条监控加个备注" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
+6
View File
@@ -676,6 +676,7 @@ export interface Preferences {
limit_ladder_monitor_enabled: boolean
depth_polling_interval: number
depth_finalize_time: { hour: number; minute: number }
review_schedule: { enabled: boolean; hour: number; minute: number }
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
@@ -820,6 +821,11 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ hour, minute }),
}),
updateReviewSchedule: (enabled: boolean, hour: number, minute: number) =>
request<{ enabled: boolean; hour: number; minute: number }>('/api/settings/preferences/review-schedule', {
method: 'PUT',
body: JSON.stringify({ enabled, hour, minute }),
}),
updateDepthPollingInterval: (interval: number) =>
request<{ depth_polling_interval: number }>('/api/settings/preferences/depth-polling-interval', {
method: 'PUT',
+10
View File
@@ -6,6 +6,7 @@ import {
Crown,
Layers3,
RefreshCw,
Repeat,
Search,
Settings2,
TrendingDown,
@@ -20,6 +21,7 @@ import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtBigNum, fmtPct, priceColorClass } from '@/lib/format'
import { cn } from '@/lib/cn'
import { toast } from '@/components/Toast'
import { resolveDimension, type DimensionGroup, type StockRow } from '@/lib/analysis-adapter'
const KEYWORDS = ['concept', '概念', 'theme', '题材', '板块']
@@ -358,6 +360,14 @@ export function ConceptAnalysis() {
subtitle={`${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个概念 · ${totalSymbols} 只标的`}
right={
<div className="flex items-center gap-1">
{/* RPS 轮动计算(占位, 功能开发中) */}
<button
onClick={() => toast('涨幅RPS轮动功能开发中,敬请期待')}
className="inline-flex items-center gap-1 rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[11px] text-secondary transition-colors hover:border-accent/40 hover:text-accent"
title="涨幅RPS轮动(开发中)"
>
<Repeat className="h-3.5 w-3.5" />RPS轮动
</button>
<button
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
disabled={rowsQuery.isFetching || marketQuery.isFetching}
+134 -2
View File
@@ -9,10 +9,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { motion, AnimatePresence } from 'framer-motion'
import {
BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle,
Database, Wand2, Copy, Download,
Database, Wand2, Copy, Download, Clock, X,
} from 'lucide-react'
import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api'
@@ -22,6 +22,7 @@ import { fmtBigNum } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader'
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
import { toast } from '@/components/Toast'
import { usePreferences } from '@/lib/useSharedQueries'
import { useReviewState } from '@/lib/useReviewStore'
import {
startReviewGeneration, resetReview, isReviewGenerating,
@@ -98,6 +99,27 @@ export function Review() {
onError: () => { /* request() 已 toast */ },
})
// ===== 定时复盘 =====
const [showSchedule, setShowSchedule] = useState(false)
const prefs = usePreferences()
const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 16, minute: 30 }
// 弹窗内的本地草稿: 开关和时间都在本地改, 点「保存」才真正提交(避免开关一拨就关弹窗)
const [draft, setDraft] = useState(reviewSched)
const openSchedule = useCallback(() => {
setDraft(reviewSched) // 每次打开同步最新服务端值
setShowSchedule(true)
}, [reviewSched])
const reviewMut = useMutation({
mutationFn: ({ enabled, hour, minute }: { enabled: boolean; hour: number; minute: number }) =>
api.updateReviewSchedule(enabled, hour, minute),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: QK.preferences })
setShowSchedule(false)
toast(vars.enabled ? '已开启定时复盘' : '已关闭定时复盘', 'success')
},
onError: () => { /* request() 已 toast */ },
})
// 自动滚动到报告底部(streaming 时)
useEffect(() => {
if (phase === 'streaming') {
@@ -186,6 +208,18 @@ export function Review() {
>
<RefreshCw className={cn('h-3 w-3', marketQuery.isFetching && 'animate-spin')} />
</button>
<button
onClick={openSchedule}
className={cn(
'inline-flex items-center gap-1 rounded-btn border px-2 py-1 text-[11px] transition-colors',
reviewSched.enabled
? 'border-accent/40 bg-accent/10 text-accent hover:bg-accent/20'
: 'border-border bg-elevated text-secondary hover:text-foreground',
)}
title={reviewSched.enabled ? `定时复盘已开启 · 每日 ${String(reviewSched.hour).padStart(2,'0')}:${String(reviewSched.minute).padStart(2,'0')}` : '定时复盘'}
>
<Clock className="h-3 w-3" />
</button>
<button
onClick={generate}
disabled={isGenerating}
@@ -281,6 +315,104 @@ export function Review() {
)}
</div>
</div>
{/* ===== 定时复盘设置弹窗 ===== */}
<AnimatePresence>
{showSchedule && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => setShowSchedule(false)}
>
<motion.div
initial={{ scale: 0.96, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.96, opacity: 0 }}
className="w-full max-w-md rounded-card border border-border bg-surface p-5 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<button
onClick={() => setShowSchedule(false)}
className="rounded p-1 text-muted transition-colors hover:bg-elevated hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="mb-4 text-[11px] leading-relaxed text-muted">
,,()
</p>
{/* 开关(只改本地草稿, 不提交) */}
<label className="flex items-center justify-between rounded-btn bg-elevated/40 px-3 py-2.5">
<span className="text-xs text-foreground"></span>
<button
type="button"
role="switch"
aria-checked={draft.enabled}
onClick={() => setDraft(d => ({ ...d, enabled: !d.enabled }))}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors',
draft.enabled ? 'bg-accent' : 'bg-border',
)}
>
<span className={cn('inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform', draft.enabled ? 'translate-x-[18px]' : 'translate-x-1')} />
</button>
</label>
{/* 时间设置(仅开启时可编辑, 本地草稿) */}
{draft.enabled && (
<div className="mt-3 flex items-center gap-2 rounded-btn bg-elevated/40 px-3 py-2.5">
<span className="text-[11px] text-muted"></span>
<input
type="number" min={0} max={23} value={draft.hour}
onChange={e => setDraft(d => ({ ...d, hour: Math.max(0, Math.min(23, Number(e.target.value))) }))}
className="w-12 px-1.5 py-1 rounded-btn bg-base border border-border text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
<span className="text-xs text-muted">:</span>
<input
type="number" min={0} max={59} value={draft.minute}
onChange={e => setDraft(d => ({ ...d, minute: Math.max(0, Math.min(59, Number(e.target.value))) }))}
className="w-12 px-1.5 py-1 rounded-btn bg-base border border-border text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
<span className="text-[10px] text-muted/70"> 15:30 · </span>
</div>
)}
{!draft.enabled && (
<p className="mt-3 text-[10px] text-muted/70">
当前: 已关闭
</p>
)}
{/* 操作区: 取消 + 保存(统一提交开关+时间) */}
<div className="mt-5 flex justify-end gap-2">
<button
onClick={() => setShowSchedule(false)}
className="rounded-btn bg-elevated px-4 py-1.5 text-xs text-secondary transition-colors hover:text-foreground"
>
</button>
<button
onClick={() => reviewMut.mutate({ enabled: draft.enabled, hour: draft.hour, minute: draft.minute })}
disabled={reviewMut.isPending}
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
>
{reviewMut.isPending ? '保存中…' : '保存'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}
+41
View File
@@ -16,6 +16,7 @@ import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick'
import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives'
import { getSignals, signalCls, getSortValue, UNSORTABLE_KEYS } from '@/lib/stock-table'
import { resolveCandleConfig } from '@/lib/list-columns'
import { useQuoteStatus } from '@/lib/useSharedQueries'
import {
type ColumnConfig,
BUILTIN_COLUMNS,
@@ -295,6 +296,27 @@ function StockSearchBox({
)
}
// ===== 实时监控圆点 =====
// 自选页 symbol 列代码后的小圆点, 标识该标的正在被实时行情监控 (Free/低档按自选监控模式)。
// 视觉: 内圈实心点 + 外圈 animate-ping 扩散晕, 语义=「在线/活动」。
// 配色用 accent (电光蓝) 而非绿/红: 项目设计规范规定红绿仅用于价格/K线,
// UI 状态用 accent, 避免与 A 股涨跌色混淆。
// 全市场模式 (Starter+) 不显示 —— 全部都在监控, 标记无信息量。
function RealtimeDot({ title = '实时监控中' }: { title?: string }) {
return (
<span
title={title}
className="relative inline-flex h-2 w-2 shrink-0"
aria-label={title}
>
{/* 外圈: 扩散晕 (ping 动画) */}
<span className="absolute inline-flex h-full w-full rounded-full bg-accent/60 animate-ping motion-reduce:hidden" />
{/* 内圈: 实心点 + 微辉光 */}
<span className="relative inline-flex rounded-full h-2 w-2 bg-accent shadow-[0_0_5px_rgba(61,214,140,0.6)]" />
</span>
)
}
// ===== 卡片组件 =====
function StockCard({
@@ -309,6 +331,7 @@ function StockCard({
extCols,
expandedCells,
onToggleExpand,
isMonitored,
}: {
r: any
candleRows: KlineRow[]
@@ -321,6 +344,7 @@ function StockCard({
extCols: ColumnConfig[]
expandedCells: Set<string>
onToggleExpand: (key: string) => void
isMonitored?: boolean
}) {
const board = boardTag(r.symbol)
const price = r.rt_price ?? r.close
@@ -394,6 +418,7 @@ function StockCard({
{r.consecutive_limit_ups === 1 ? '首板' : `${r.consecutive_limit_ups}`}
</span>
)}
{isMonitored && <span className="ml-auto"><RealtimeDot /></span>}
</div>
{/* 第二行: 大价格 + 涨跌幅胶囊 */}
@@ -622,6 +647,20 @@ export function Watchlist() {
const allSymbols = list.data?.symbols?.map(s => s.symbol) ?? []
const rows = enriched.data?.rows ?? []
// 实时监控圆点: 仅 Free/低档 "按自选股实时监控" 模式 (mode === 'watchlist') 下显示;
// Starter+ 全市场模式 (mode === 'full_market') 全部标的都在监控, 标圆点无意义, 故不显示。
// 后端 Free 档实际只监控自选页前 N 个 (N = watchlist_symbol_count), 顺序与 allSymbols 一致。
const quoteStatus = useQuoteStatus()
const realtimeRunning = quoteStatus.data?.running ?? false
const realtimeMode = quoteStatus.data?.mode
const watchlistMonitoredCount = quoteStatus.data?.watchlist_symbol_count ?? 0
const showRealtimeDot = realtimeRunning && realtimeMode === 'watchlist'
// 真正被监控的标的集合 (自选列表前 watchlistMonitoredCount 个)
const monitoredSymbols = useMemo(
() => showRealtimeDot ? new Set(allSymbols.slice(0, watchlistMonitoredCount)) : new Set<string>(),
[showRealtimeDot, allSymbols, watchlistMonitoredCount],
)
// ===== 筛选 =====
const [filterOpen, setFilterOpen] = useState(false)
const [filters, setFilters] = useState<Record<string, { min?: string; max?: string; text?: string }>>({})
@@ -946,6 +985,7 @@ export function Watchlist() {
{board.label}
</span>
) : null}
{monitoredSymbols.has(r.symbol) && <span className="ml-2"><RealtimeDot /></span>}
</button>
{/* 删除入口:默认减号图标,二次确认时替换为确定按钮 */}
<div className="ml-auto pl-1 shrink-0">
@@ -1056,6 +1096,7 @@ export function Watchlist() {
extCols={visibleExtCols}
expandedCells={expandedCells}
onToggleExpand={handleToggleExpand}
isMonitored={monitoredSymbols.has(r.symbol)}
/>
))}
</div>
+43 -10
View File
@@ -11,7 +11,9 @@
;
; 设计决策:
; - 装到用户目录 {localappdata}\Programs\ (不弹 UAC, 不需管理员)
; - 卸载时询问是否删除用户数据 (%LOCALAPPDATA%\TickFlowStockPanel\)
; - 用户数据存在 {app}\data\ (与程序同处一个总目录, 视觉直观)
; - 卸载时询问是否删除用户数据 ({app}\data\)
; - 覆盖安装(升级)不动 data\: Inno Setup 只写程序文件, data 不在安装清单
; - 桌面 + 开始菜单快捷方式
; - 卸载入口 (控制面板可见)
; ===========================================================================
@@ -93,11 +95,36 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}
; 卸载前先关闭正在运行的应用 (否则 exe 被占用删不掉)
Filename: "{cmd}"; Parameters: "/C taskkill /F /IM {#MyAppExeName}"; Flags: runhidden; RunOnceId: "KillApp"
[UninstallDelete]
; 清理安装目录下的残留 (日志等)
Type: filesandordirs; Name: "{app}"
; [UninstallDelete] 故意不删 {app}:
; 用户数据在 {app}\data\, 若这里写 Type: filesandordirs; Name: "{app}" 会连数据一起删。
; 卸载默认行为已足够 —— Inno Setup 会删除它安装清单内的所有程序文件, 只留下运行时
; 生成的 data\ 目录。是否清理 data\ 由下方 [Code] 的卸载询问逻辑决定。
[Code]
// ── 辅助函数: 判断目录是否为空 ─────────────────────────────────
// Inno Setup 内置无 IsDirEmpty, 用 FindFirst/FindNext 自行实现。
// 用于卸载后清理空的 {app} 壳目录。
function IsDirEmpty(const Dir: String): Boolean;
var
FindRec: TFindRec;
begin
Result := True;
if FindFirst(AddBackslash(Dir) + '*', FindRec) then
begin
try
repeat
if (FindRec.Name <> '.') and (FindRec.Name <> '..') then
begin
Result := False;
Break;
end;
until not FindNext(FindRec);
finally
FindClose(FindRec);
end;
end;
end;
// ── 启动时: 若 D 盘不存在, 回退默认路径到用户目录 ───────────────
// 避免默认 D:\... 但系统没 D 盘时向导显示无效路径
function InitializeSetup(): Boolean;
@@ -118,17 +145,17 @@ begin
end;
// ── 卸载时询问是否删除用户数据 ─────────────────────────────────
// 用户数据在 {app} 同级的 TickFlowStockPanel_Data\ (策略/选股/回测/监控/行情)
// 注意: 该目录在 {app} 外, 覆盖安装常规卸载都不会动它, 重装后数据自动恢复
// 这里仅在用户明确「彻底卸载」时才清理
// 用户数据在 {app}\data\ (策略/选股/回测/监控/行情), 与程序同处 {app} 总目录。
// Inno Setup 卸载默认只删它装过的程序文件, data\ 会被保留 (覆盖安装/常规卸载都不丢)
// 这里仅在用户明确「彻底卸载」时, 才询问是否清理 data\ + {app} 空壳
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
DataDir: String;
DataDir, AppDir: String;
begin
if CurUninstallStep = usPostUninstall then
begin
// {app}\..\TickFlowStockPanel_Data = 与安装目录同级的独立数据目录
DataDir := ExpandConstant('{app}\..\TickFlowStockPanel_Data');
// {app}\data = 用户数据目录 (与程序同总目录, 子文件夹)
DataDir := ExpandConstant('{app}\data');
if DirExists(DataDir) then
begin
if SuppressibleMsgBox(
@@ -141,5 +168,11 @@ begin
DelTree(DataDir, True, True, True);
end;
end;
// 清理可能残留的空 {app} 壳目录 (程序文件已被 Inno Setup 删除)
AppDir := ExpandConstant('{app}');
if DirExists(AppDir) and IsDirEmpty(AppDir) then
begin
DelTree(AppDir, True, True, True);
end;
end;
end;
+13
View File
@@ -48,6 +48,19 @@ for pkg in ("polars", "pyarrow", "duckdb", "fastexcel"):
binaries += b
hiddenimports += h
# polars-runtime-32 (rtcompat 兼容内核): release.yml 用 --extra legacy-cpu 安装。
# 它是独立的伴侣二进制包 (含 .pyd/.so), 与 polars 主包分开发布,
# collect_all("polars") 抓不到它的目录 —— 必须显式收集, 否则老 CPU 用户
# 运行时 rtcompat 加载器找不到兼容库仍会崩 (Illegal instruction)。
# 不存在时 (未装 legacy-cpu) collect_all 返回空, 不影响普通构建。
try:
rt_d, rt_b, rt_h = collect_all("polars_runtime_32")
datas += rt_d
binaries += rt_b
hiddenimports += rt_h
except Exception:
pass
# polars 新 ABI 运行时目录 (_polars_runtime_32) 需显式收集子模块
hiddenimports += collect_submodules("polars")