mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
feat: — 数据目录/实时监控/定时复盘/老CPU兼容
1. 桌面客户端体验优化,Polars 兼容内核 (老 CPU 兼容) 2. 自选页实时监控优化 3. 个股监控逻辑优化 4. 新增定时复盘 6. 其他: 修复 settings.py 中 HTTPException 未 import 的既有 bug
This commit is contained in:
@@ -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 回测(为控制体积)
|
||||
- 系统通知: 设置 → 实时监控 → 系统通知
|
||||
- 检查更新: 设置 → 系统设置 → 关于
|
||||
|
||||
@@ -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`。
|
||||
|
||||
**手动分别启动:**
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
# ===== 实时监控 =====
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user