diff --git a/.planning/MILESTONE-AUDIT-v1.0.md b/.planning/MILESTONE-AUDIT-v1.0.md new file mode 100644 index 0000000..9e509c0 --- /dev/null +++ b/.planning/MILESTONE-AUDIT-v1.0.md @@ -0,0 +1,32 @@ +# v1.0 Milestone Audit + +## Verdict + +v1.0 milestone intent is satisfied in this worktree. + +## Fresh Evidence + +- `cd backend && uv run pytest` → 19 passed +- `cd frontend && pnpm exec vitest run` → 18 files / 61 tests passed +- `cd frontend && pnpm exec tsc -b` → success +- `cd frontend && pnpm build` → success +- Live QA: + - `/replay` honors replay seed `defaultTimeframe` when workspace has no `activeTimeframe` + - `/import` supports upload import and manual row import, both returning the shared report UI + - `/api/backups/download` returns a real SQLite backup file + - `/import` exposes local safekeeping actions for CSV/report and SQLite backup/restore + +## Scope Covered + +- Trustworthy Excel import with row-level reporting, dedupe/conflict handling, and CSV detail export +- Minimal manual import path reusing the same importer/report pipeline +- Single-trade replay with markers, price lines, play/pause/step/speed, and per-trade replay progress restore +- Same-timeframe multi-symbol compare and same-symbol multi-timeframe compare in replay +- Lightweight replay analytics panel with equity curve and distributions +- Durable local replay workspace and layout persistence +- Local backup/export/restore entry points for safekeeping + +## Residual Non-Blocking Notes + +- Frontend build still emits the pre-existing DaisyUI `@property` warning and bundle-size warning, but build exits 0. +- The root workspace `.planning` remains stale relative to this approved worktree; this worktree is the milestone source of truth. diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 0000000..e9fc082 --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,77 @@ +# Retraq + +## What This Is + +Retraq 是一个面向我自己本地使用的加密货币交易复盘工具,用来把历史交易记录和对应行情对齐后做快速回看。当前仓库已经有前后端 MVP,但接下来的项目方向会收敛到一条更明确的主线:以 Excel / 手动导入为主,把单笔交易复盘体验做到顺手、可靠、可重复。 + +## Core Value + +导入一笔历史交易后,能够快速把它和对应 K 线、买卖点、时间区间对齐,并顺畅完成一次高质量复盘。 + +## Requirements + +### Validated + +- ✓ 可以从 Excel 文件导入历史交易到本地 SQLite 数据库 — existing +- ✓ 可以从前端查看交易列表,并进入复盘视图查看图表与仓位信息 — existing +- ✓ 可以按时间周期请求并展示 K 线数据,当前支持 5m / 15m / 1h / 4h / 1d — existing +- ✓ 可以生成基础统计信息与分析页面,展示胜率、盈亏等复盘辅助信息 — existing + +### Active + +- [ ] 导入后的每笔交易都能更可靠地和对应 K 线、买卖点、时间范围对齐,减少“图表和交易对不上”的摩擦 +- [ ] 单笔交易复盘流程优先,用户应能在导入后快速定位、打开并回看任意一笔交易 +- [ ] 多周期对比保留在 v1 范围内,但服务于单笔交易复盘主线,而不是独立成为复杂分析工作台 +- [ ] 统计分析继续保留为辅助能力,但优先级低于复盘体验本身 + +### Out of Scope + +- 多用户、认证、权限系统 — 当前产品定位是个人本地工具,不为公开用户或团队协作设计 +- 公开部署与互联网暴露 — 当前仓库和 README 都默认本地运行,且后端未按公网服务做安全收口 +- 直接接入 OKX 账户历史作为 v1 主数据入口 — v1 明确以 Excel / 手动导入为主,自动同步放到后续阶段再评估 +- 学习模块的大规模内容化建设 — 当前 README 提到学习模块,但现阶段不应压过核心复盘主线 + +## Context + +- 当前仓库已经是 brownfield 项目:前端有 `Replay` / `Analysis` / `Learn` 三个页面,后端已有交易导入、K 线拉取和统计接口 +- 真实痛点已经明确:当前复盘时“图表和交易对不上”,导致回看过程断裂,影响复盘效率 +- v1 的完成标准也已明确:导入交易后,能够顺畅复盘每笔交易,而不是先追求自动同步、内容体系或团队能力 +- 现有代码库已经暴露出几类 brownfield 风险:本地 SQLite + `create_all` 缺少迁移层、K 线依赖外部交易所、图表与分析页面较重、规划文档此前缺失 + +## Constraints + +- **Tech stack**: 继续沿用当前 React + TypeScript + Vite 前端,以及 FastAPI + SQLAlchemy + SQLite 后端 — 这是现有代码和运行脚本已经建立的基础 +- **Data entry**: v1 以 Excel / 手动导入为主 — 这是当前用户目标和现有仓库能力的交集 +- **Runtime model**: 本地运行优先,不按公网服务设计 — README 已明确不建议直接暴露到公网 +- **Market data dependency**: K 线数据依赖外部交易所接口(当前实现通过 CCXT / OKX 等) — 这决定了市场数据可用性与历史覆盖范围受外部服务约束 +- **Product scope**: 复盘体验优先于自动同步、教育内容和多用户能力 — 这是当前项目的核心取舍 + +## Key Decisions + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| 产品定位为个人本地工具 | 用户明确说明主要给自己本地用,且当前仓库也围绕本地运行组织 | — Pending | +| v1 以 Excel / 手动导入为主 | 当前已存在导入能力,且用户不希望先把复杂度投入到账户级同步 | — Pending | +| v1 优先把复盘体验做到最好 | 用户最痛的点是交易与图表对不上,完成标准是“导入后能顺畅复盘每笔交易” | — Pending | +| 统计分析保留为辅助,而非主线 | 当前仓库已有分析页,但用户优先级更明确地落在 replay 体验上 | — Pending | +| 学习模块不作为当前主路线图核心 | 当前 README 虽包含该能力,但它不直接服务当前最重要的复盘痛点 | — Pending | + +## Evolution + +This document evolves at phase transitions and milestone boundaries. + +**After each phase transition** (via `/gsd-transition`): +1. Requirements invalidated? → Move to Out of Scope with reason +2. Requirements validated? → Move to Validated with phase reference +3. New requirements emerged? → Add to Active +4. Decisions to log? → Add to Key Decisions +5. "What This Is" still accurate? → Update if drifted + +**After each milestone** (via `/gsd-complete-milestone`): +1. Full review of all sections +2. Core Value check — still the right priority? +3. Audit Out of Scope — reasons still valid? +4. Update Context with current state + +--- +*Last updated: 2026-04-11 after initialization* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 0000000..675d418 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,95 @@ +# Requirements: Retraq + +**Defined:** 2026-04-11 +**Core Value:** 导入一笔历史交易后,能够快速把它和对应 K 线、买卖点、时间区间对齐,并顺畅完成一次高质量复盘。 + +## v1 Requirements + +### Import + +- [ ] **IMPT-01**: User can import historical trades from an Excel file into the local system +- [ ] **IMPT-02**: User can see row-level import errors when a trade record cannot be imported +- [ ] **IMPT-03**: User can import trades without creating duplicate records for the same source rows +- [ ] **IMPT-04**: User can have imported trade timestamps normalized into a consistent internal time standard +- [ ] **IMPT-05**: User can review an import report that summarizes successful rows, failed rows, and duplicate handling + +### Replay + +- [ ] **REPL-01**: User can open any single trade from the trade list directly into replay view +- [ ] **REPL-02**: User can have replay automatically aligned to the correct symbol, time window, and default timeframe for the selected trade +- [ ] **REPL-03**: User can see entry and exit markers, plus relevant price lines, on the replay chart for the selected trade +- [ ] **REPL-04**: User can control replay with play, pause, step, and speed adjustment actions + +### Compare + +- [ ] **COMP-01**: User can compare the selected trade across multiple timeframes for the same symbol +- [ ] **COMP-02**: User can keep compared charts synchronized by visible time range and crosshair position +- [ ] **COMP-03**: User can change the timeframe of the comparison chart without leaving the replay flow + +### Analytics + +- [ ] **ANLY-01**: User can view core replay-supporting metrics including win rate, PnL, profit-loss ratio, and equity curve +- [ ] **ANLY-02**: User can view distributions by time period and by trading symbol to support review +- [ ] **ANLY-03**: User can view drawdown-related statistics for imported trades + +### Local State + +- [ ] **STAT-01**: User can return to the last-used replay layout and filters in a later session +- [ ] **STAT-02**: User can resume replay from the previously saved progress of a selected trade +- [ ] **STAT-03**: User can export or back up local review data for safekeeping + +## v2 Requirements + +### Compare Expansion + +- **COMP-04**: User can compare multiple different symbols on screen at the same time + +### Advanced Analysis + +- **ANLY-04**: User can view advanced attribution and behavior analysis beyond core replay metrics + +## Out of Scope + +| Feature | Reason | +|---------|--------| +| Multi-user accounts and permissions | Product is explicitly scoped as a personal local tool | +| Cloud sync and remote backup service | v1 should stay local-first and avoid premature sync complexity | +| Direct OKX account sync as primary v1 input | v1 input is intentionally Excel/manual import first | +| Public SaaS deployment hardening | Current product is not being planned as a public service | +| Live trading, paper trading, or order routing | Replay and review are the goal, not execution | +| Education hub expansion | Learning content is not part of the current core replay milestone | +| Generic BI/dashboard builder | Replay flow matters more than broad analytics surface area | + +## Traceability + +Which phases cover which requirements. Updated during roadmap creation. + +| Requirement | Phase | Status | +|-------------|-------|--------| +| IMPT-01 | Phase 1: Import Reliability & Schema Evolution | Pending | +| IMPT-02 | Phase 1: Import Reliability & Schema Evolution | Pending | +| IMPT-03 | Phase 1: Import Reliability & Schema Evolution | Pending | +| IMPT-04 | Phase 1: Import Reliability & Schema Evolution | Pending | +| IMPT-05 | Phase 1: Import Reliability & Schema Evolution | Pending | +| REPL-01 | Phase 2: Single-Trade Replay Core | Pending | +| REPL-02 | Phase 2: Single-Trade Replay Core | Pending | +| REPL-03 | Phase 2: Single-Trade Replay Core | Pending | +| REPL-04 | Phase 2: Single-Trade Replay Core | Pending | +| STAT-02 | Phase 2: Single-Trade Replay Core | Pending | +| COMP-01 | Phase 3: Multi-Timeframe Compare & Lightweight Analytics | Pending | +| COMP-02 | Phase 3: Multi-Timeframe Compare & Lightweight Analytics | Pending | +| COMP-03 | Phase 3: Multi-Timeframe Compare & Lightweight Analytics | Pending | +| ANLY-01 | Phase 3: Multi-Timeframe Compare & Lightweight Analytics | Pending | +| ANLY-02 | Phase 3: Multi-Timeframe Compare & Lightweight Analytics | Pending | +| ANLY-03 | Phase 3: Multi-Timeframe Compare & Lightweight Analytics | Pending | +| STAT-01 | Phase 4: Local UX Polish & Performance Hardening | Pending | +| STAT-03 | Phase 4: Local UX Polish & Performance Hardening | Pending | + +**Coverage:** +- v1 requirements: 18 total +- Mapped to phases: 18 +- Unmapped: 0 ✓ + +--- +*Requirements defined: 2026-04-11* +*Last updated: 2026-04-11 after initial definition* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 0000000..185d099 --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,91 @@ +# Roadmap: Retraq + +## Overview + +Retraq is a local-first crypto trade replay tool. v1 focuses on one tight workflow: import historical trades, align each trade to the right market window, compare a few supporting timeframes, and keep the local review state stable enough for repeated use. + +## Phases + +- [x] **Phase 1: Import Reliability & Schema Evolution** - Make Excel/manual import trustworthy and keep local trade data stable. +- [x] **Phase 2: Single-Trade Replay Core** - Open any trade fast and land on the correct replay window. +- [x] **Phase 3: Multi-Timeframe Compare & Lightweight Analytics** - Add supporting comparison and metrics without turning into a dashboard. +- [x] **Phase 4: Local UX Polish & Performance Hardening** - Keep the app responsive and remember local review state. + +## Phase Details + +### Phase 1: Import Reliability & Schema Evolution +**Goal**: Users can reliably import historical trades and trust the stored data. +**Depends on**: Nothing (first phase) +**Requirements**: IMPT-01, IMPT-02, IMPT-03, IMPT-04, IMPT-05 +**Success Criteria** (what must be TRUE): + 1. User can import trades from an Excel file into the local system. + 2. User can see row-level errors for records that fail to import. + 3. User can re-import source rows without creating duplicate trades. + 4. User can review an import report that summarizes successes, failures, duplicates, and normalized timestamps. +**Plans**: 5 plans +Plans: +- [x] 01-01-PLAN.md — Backend test/migration foundation and locked import contracts +- [x] 01-02-PLAN.md — Frontend test foundation and locked import report types +- [x] 01-03-PLAN.md — Structured backend importer, dedupe/conflict logic, and report API +- [x] 01-04-PLAN.md — Import entry/report UI for Phase 1 only +- [x] 01-05-PLAN.md — Runtime wiring, route integration, and end-to-end verification +**UI hint**: yes + +### Phase 2: Single-Trade Replay Core +**Goal**: Users can open a single trade and replay it in the correct context. +**Depends on**: Phase 1 +**Requirements**: REPL-01, REPL-02, REPL-03, REPL-04, STAT-02 +**Success Criteria** (what must be TRUE): + 1. User can open any trade from the list directly into replay. + 2. Replay opens on the correct symbol, time window, and default timeframe for the selected trade. + 3. User can see entry/exit markers and relevant price lines on the chart. + 4. User can control play, pause, step, and speed, and resume the selected trade from saved progress. +**Plans**: 4 plans +- [x] 02-01-PLAN.md — Replay session contract and local progress primitives +- [x] 02-02-PLAN.md — Replay bootstrap and TradeList handoff +- [x] 02-03-PLAN.md — Replay transport and controls +- [x] 02-04-PLAN.md — Replay flow integration and smoke coverage +**UI hint**: yes + +### Phase 3: Multi-Timeframe Compare & Lightweight Analytics +**Goal**: Users can compare a trade across timeframes and read basic review metrics. +**Depends on**: Phase 2 +**Requirements**: COMP-01, COMP-02, COMP-03, ANLY-01, ANLY-02, ANLY-03 +**Success Criteria** (what must be TRUE): + 1. User can compare the selected trade across multiple timeframes for the same symbol. + 2. User can change the comparison timeframe without leaving replay. + 3. Compared charts stay synchronized by visible time range and crosshair position. + 4. User can view win rate, PnL, profit-loss ratio, equity curve, time/symbol distributions, and drawdown stats. +**Plans**: 4 plans +Plans: +- [x] 03-01-PLAN.md — Compare mode contract, pane resolver, and mode-aware annotation rules +- [x] 03-02-PLAN.md — Same-symbol multi-timeframe compare path and cross-timeframe sync +- [x] 03-03-PLAN.md — Same-timeframe multi-symbol compare stabilization under explicit mode switching +- [x] 03-04-PLAN.md — Lightweight replay analytics panel and final Phase 3 smoke coverage +**UI hint**: yes + +### Phase 4: Local UX Polish & Performance Hardening +**Goal**: Users can keep their local replay workspace lightweight, persistent, and pleasant to use. +**Depends on**: Phase 3 +**Requirements**: STAT-01, STAT-03 +**Success Criteria** (what must be TRUE): + 1. User can return later and find the last-used replay layout and filters restored. + 2. User can export or back up local review data for safekeeping. +**Plans**: 3 plans +Plans: +- [x] 04-01-PLAN.md — Replay workspace/filter persistence bootstrap +- [x] 04-02-PLAN.md — Replay chart/layout + analytics panel persistence +- [x] 04-03-PLAN.md — Local backup/export/restore entry point on `/import` +**UI hint**: yes + +## Progress + +**Execution Order:** +Phases execute in numeric order: 1 → 2 → 3 → 4 + +| Phase | Plans Complete | Status | Completed | +|-------|----------------|--------|-----------| +| 1. Import Reliability & Schema Evolution | 5/5 | Approved in worktree | 2026-04-11 | +| 2. Single-Trade Replay Core | 4/4 | Completed in worktree | 2026-04-11 | +| 3. Multi-Timeframe Compare & Lightweight Analytics | 4/4 | Completed in worktree | 2026-04-11 | +| 4. Local UX Polish & Performance Hardening | 3/3 | Completed in worktree | 2026-04-11 | diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 0000000..c93a6f4 --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,87 @@ +--- +gsd_state_version: 1.0 +milestone: v1.0 +milestone_name: milestone +status: completed +stopped_at: Milestone v1.0 completed in worktree +last_updated: "2026-04-11T11:40:00.000Z" +last_activity: 2026-04-11 — v1.0 milestone completed after closeout gap fixes and fresh verification +progress: + total_phases: 4 + completed_phases: 4 + total_plans: 16 + completed_plans: 16 + percent: 100 +--- + +# Project State + +## Project Reference + +See: .planning/PROJECT.md (updated 2026-04-11) + +**Core value:** 导入一笔历史交易后,能够快速把它和对应 K 线、买卖点、时间区间对齐,并顺畅完成一次高质量复盘。 +**Current focus:** Milestone complete + +## Current Position + +Phase: complete +Plan: closeout finished +Status: v1.0 milestone complete in worktree +Last activity: 2026-04-11 — fresh backend/frontend verification and Oracle closeout review passed + +Progress: [██████████] 100% + +## Performance Metrics + +**Velocity:** + +- Total plans completed: 16 +- Average duration: - +- Total execution time: - + +**By Phase:** + +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| +| 1. Import Reliability & Schema Evolution | 5 | 5 | - | +| 2. Single-Trade Replay Core | 4 | 4 | - | +| 3. Multi-Timeframe Compare & Lightweight Analytics | 4 | 4 | - | +| 4. Local UX Polish & Performance Hardening | 3 | 3 | - | + +**Recent Trend:** + +- Last 5 plans: 03-04, 04-01, 04-02, 04-03, closeout-gaps +- Trend: Advancing + +## Accumulated Context + +### Decisions + +Recent decisions affecting current work: + +- [Phase 1]: Excel/manual import is the trusted v1 data entry path. +- [Phase 1]: Structured import reports, CSV detail export, and backup-first migration wiring are now live in the worktree. +- [Phase 2]: Single-trade replay was advanced from the existing replay shell rather than rebuilt from scratch. +- [Phase 2]: Replay entry precedence is `route > local restore > latest trade > empty`, with browser-local replay progress persistence. +- [Phase 2]: Playback uses a single shared cursor with `play / pause / step / reset / 1x-2x-4x` controls. +- [Phase 3]: Replay compare now supports both same-timeframe multi-symbol and same-symbol multi-timeframe modes on a single secondary pane. +- [Phase 3]: Replay analytics stays lightweight and collapsible inside the replay shell rather than reusing the full analysis dashboard UI. +- [Phase 3]: Multi-timeframe compare and analytics stay supportive, not dominant. +- [Phase 4]: Local state and polish stay in scope; sync/collaboration remain out of scope. +- [Closeout]: Replay seed `defaultTimeframe` is now honored after workspace precedence. +- [Closeout]: Manual row import now reuses the same importer/report pipeline as upload import. + +### Pending Todos + +- None + +### Blockers/Concerns + +- Root workspace `.planning` remains stale relative to the approved worktree and should not be used as the milestone source of truth. + +## Session Continuity + +Last session: 2026-04-11T11:40:00.000Z +Stopped at: Milestone complete +Resume file: .planning/MILESTONE-AUDIT-v1.0.md diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 0000000..56f41ef --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,118 @@ +# Retraq 架构概览 + +## 1. 系统边界 + +Retraq 是一个本地运行的前后端分离应用,运行时有两个独立进程: + +- 前端:React SPA,入口是 `frontend/src/main.tsx:1-13`,路由壳在 `frontend/src/App.tsx:7-18` +- 后端:FastAPI 服务,入口是 `backend/main.py:17-129` + +启动脚本 `start.sh:6-36` 和 `start.bat:5-32` 的顺序是固定的:先同步后端依赖、初始化/导入示例数据,再启动后端 9527,最后构建并预览前端 9528。前端开发代理在 `frontend/vite.config.ts:5-14`,把 `/api` 转发到 `http://localhost:9527`。 + +唯一的本地持久层是 SQLite 文件 `backend/trading.db`,由 `backend/database.py:4-16` 创建并管理。 + +## 2. 后端:数据与计算边界 + +后端没有拆成多个 router 包,当前 API surface 集中在 `backend/main.py:28-129`: + +- `GET /api/klines/{symbol}/{timeframe}`:返回 K 线数据 +- `POST /api/trades/import`:上传 Excel 交易单并入库 +- `GET /api/trades`:分页返回交易记录 +- `GET /api/stats/overview`:返回汇总统计 + +`backend/main.py` 里直接返回字典,没有单独的 Pydantic 响应模型层;前端用 TypeScript 接口对齐这些 JSON 结构,见 `frontend/src/services/api.ts:3-112`。 + +### 2.1 数据模型 + +`backend/models.py:5-38` 只有两张核心表: + +- `Kline`:行情缓存表,按 `symbol + timeframe + timestamp` 做唯一索引 +- `Trade`:复盘交易表,保存方向、杠杆、开平仓价格、盈亏、收益率、时间戳等字段 + +这意味着系统的“事实数据”只有两类:行情片段和交易记录,其它页面指标都是派生结果。 + +### 2.2 服务层职责 + +- `backend/services/kline_service.py:18-306`:行情获取与缓存层。它按时间范围查 SQLite,缺口再用 `ccxt` 从外部交易所回填,随后 upsert 到 `Kline` 表。 +- `backend/services/trade_importer.py:21-100`:Excel 导入层。它把中文表头映射到 `Trade` 字段,做方向、币对、时间戳等清洗后写库。 +- `backend/services/trade_analyzer.py:7-75`:后端汇总统计层。它从 `Trade` 计算总盈亏、胜率、盈亏比、回撤、平均持仓时长和币对分布。 +- `backend/services/symbol_utils.py:6-31`:币对规范化与校验层,保证查询和导入使用统一格式。 + +`backend/import_data.py:12-36` 是种子导入入口:当数据库为空时,从仓库根目录的 `1.xlsx` 导入示例交易。 + +### 2.3 外部数据源 + +外部行情只在 `kline_service` 的缓存未命中时才进入流程。`backend/services/kline_service.py:21-29` 从环境变量 `KLINE_EXCHANGES` 读取交易所列表(默认 `okx,binance`),然后通过 `ccxt` 拉取 OHLCV。也就是说,外部交易所是行情的上游源,SQLite 是落地缓存。 + +## 3. 前端:路由与视图编排边界 + +前端是一个路由驱动的单页应用: + +- `frontend/src/main.tsx:1-13` 负责挂载 React 和 `BrowserRouter` +- `frontend/src/App.tsx:7-18` 定义三个页面路由:`/replay`、`/analysis`、`/learn` +- `frontend/src/components/Navbar.tsx:4-64` 提供全局导航壳 + +### 3.1 复盘页 + +`frontend/src/pages/ReplayPage.tsx:7-46` 负责把三个子组件拼成主工作台: + +- `TradeList`:左侧交易列表与交易对筛选 +- `ChartManager`:中间 K 线与对比图 +- `PositionDetails`:右侧仓位详情 + +这条页面链路的状态核心是 `symbol` 和 `selectedTrade`。交易对改变会清空当前选择;选择某笔交易又会反向同步交易对。 + +`frontend/src/components/TradeList.tsx:11-276` 是列表侧栏,先拉 `fetchStats()` 再拉 `fetchTrades()`,用统计结果构建交易对过滤器,并默认选中 BTC 相关或交易量最多的币对。 + +`frontend/src/components/ChartManager.tsx:52-1066` 是图表编排层,不只是渲染图表:它还负责拉取 K 线、同步主图/对比图范围、给选中交易打买卖点标记、画价格线、支持全屏和多交易对对比。 + +`frontend/src/components/PositionDetails.tsx:12-49` 只是把选中交易渲染成只读详情卡。 + +### 3.2 分析页 + +`frontend/src/pages/AnalysisPage.tsx:829-1030` 先调用 `fetchTrades()` 拉全量交易,然后在前端本地计算总盈亏、胜率、回撤、时间模式、行为模式、风险分布和币对分布。 + +`frontend/src/utils/tradeAnalysis.ts:146-846` 是分析页的纯计算层,分成四类: + +- 时间分析 +- 行为分析 +- 风险分析 +- 币对分析 + +`generateInsights()` 再把这些指标组合成智能洞察卡片。 + +这说明分析页不是一个“后端算好再前端展示”的模型,而是“后端提供原始交易,前端做二次分析”的模型。 + +### 3.3 学习页 + +`frontend/src/pages/LearnPage.tsx:136-339` 是静态内容型页面,内部以本地数组驱动,不依赖后端。 + +### 3.4 传输边界 + +`frontend/src/services/api.ts:35-112` 是前端唯一的 API 入口: + +- `fetchKlines()`:请求 `/api/klines/...`,把后端毫秒时间戳转换成轻量图表使用的秒级时间 +- `fetchTrades()`:分页拉取 `/api/trades` +- `importTrades()`:上传 Excel 到 `/api/trades/import` +- `fetchStats()`:读取 `/api/stats/overview` + +这里是前后端契约的直接边界:后端返回原始 JSON,前端负责把它转成 UI 可用的数据结构。 + +## 4. 数据流:从源头到 UI + +1. `1.xlsx` 或用户上传的 Excel 进入 `backend/import_data.py:12-36` / `backend/main.py:64-77` +2. `TradeImporter` 清洗并写入 `Trade` 表 +3. `TradeList` 和 `AnalysisPage` 通过 `frontend/src/services/api.ts` 读取交易数据 +4. `ChartManager` 根据当前 `symbol` 和 `selectedTrade` 请求 K 线,`kline_service` 先查 SQLite,再在缺口处回填外部交易所数据 +5. 图表层把选中交易的开平仓点、价格线和对比图同步到 UI +6. `AnalysisPage` 把全量交易送入 `tradeAnalysis.ts`,在浏览器端生成时间、行为、风险和标的洞察 + +## 5. 心智模型 + +可以把系统理解成三层: + +1. **后端是事实层**:`Trade`、`Kline`、导入、缓存、汇总都在这里 +2. **前端是编排层**:路由、交互、图表状态、页面布局都在这里 +3. **派生分析在前端完成**:分析页对交易做二次计算,而不是再走一套后端分析 API + +换句话说,Retraq 的核心不是“很多服务”,而是“两个稳定事实表 + 一层 UI 编排 + 一层本地分析”。 diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 0000000..f27f315 --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,32 @@ +# CONCERNS + +> 这是一份风险/缺口清单,不是完整架构说明。 + +## 总览 +- 该仓库的意图很明确:本地交易复盘 + 分析 + 学习。 +- 但当前实现更像“SQLite + Excel 导入 + 实时 K 线”的单机工具,真正会影响后续路线图的风险集中在数据/历史规模、导入脆弱性、外部行情依赖、前端状态耦合,以及 README 对学习/分析能力的部分超前表述。 + +## P1 / High +- **学习模块与真实数据链路脱节**:`README.md` 把学习模块描述成“通过所有复盘提取视频”的系统,但 `frontend/src/pages/LearnPage.tsx` 目前是静态硬编码内容,没有接交易数据、回放数据或视频抽取链路。后续如果要做“学习闭环”,这一块需要重新定义,而不是在静态页面上继续堆内容。 +- **多周期/对比能力被文档写得比实现更强**:`README.md` 说明支持“多周期多交易对对比”,但 `frontend/src/components/ChartManager.tsx` 的对比图和主图共用同一个 `activeTimeframe`,当前并不是独立多周期并排比较。这个差距会直接影响未来 compare/overlay 的规划。 +- **数据历史天然受限于本地 SQLite**:`README.md` 明确是本地存储,`backend/main.py` 启动即 `Base.metadata.create_all(...)`,没有看到迁移/版本化 schema 体系。对小数据量没问题,但一旦交易量、字段或回放历史增长,表结构演进和数据迁移会变成主风险。 + +## P2 / Medium +- **导入链路脆弱且强依赖仓库布局**:`backend/import_data.py` 只认根目录 `1.xlsx`,并且只在数据库为空时导入;`backend/services/trade_importer.py` 对每行异常只做计数,缺少可观测日志。初次导入、表头变更、坏行数据都很容易静默退化。 +- **行情依赖是外部网络敏感点**:`backend/services/kline_service.py` 默认 exchange 列表是 `okx,binance`,拉取失败时会在缓存/实时源之间兜底,但本质上仍依赖 CCXT 和交易所可用性。若后续要支持更长历史或更稳定的回放,行情层需要更明确的缓存失效策略和失败可见性。 +- **前端分析层把 UI、指标计算和格式化揉在一起**:`frontend/src/pages/AnalysisPage.tsx` 很大,`frontend/src/utils/tradeAnalysis.ts` 也在做衍生分析,`StatsPanel.tsx` / `StatsBar.tsx` / `TradeList.tsx` / `ChartManager.tsx` 之间复用数据但边界不够硬。这里是未来重构最容易踩坑的地方。 +- **类型安全在 chart 代码里被削弱**:`frontend/src/components/ChartManager.tsx` 使用了 `any/as any`,分析页还出现了 `@ts-expect-error`。图表库或数据形状一变,回归会更难提前发现。 +- **质量门禁偏弱**:当前前端脚本看起来主要是 build/lint/preview,未见独立 test/typecheck 命令。对原型够用,但对快速迭代分析功能不够稳。 + +## P3 / Low +- **`.planning/codebase` 目前偏薄**:现有材料只有 `STACK.md` 和 `INTEGRATIONS.md`,没有看到更细的 roadmap / decision / milestone 文档。能支撑“现状说明”,但不够支撑后续里程碑管理。 +- **`frontend/README.md` 仍是默认 Vite 模板**:它不是项目文档,容易让新成员误判前端子项目的真实约束。 + +## 后续规划热点 +- `backend/import_data.py` +- `backend/services/trade_importer.py` +- `backend/services/kline_service.py` +- `backend/main.py` +- `frontend/src/components/ChartManager.tsx` +- `frontend/src/pages/AnalysisPage.tsx` +- `frontend/src/pages/LearnPage.tsx` diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 0000000..d331da9 --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,40 @@ +# Codebase Conventions + +## Repo shape +- Monorepo-style split: `frontend/` for React UI and `backend/` for FastAPI services. +- Root scripts (`start.sh`, `start.bat`) are the main cross-platform entry points. +- `README.md` documents the intended local workflow and the sample data import flow. + +## Tooling choices +- Frontend package manager: `pnpm` (`frontend/pnpm-lock.yaml` is present). +- Backend environment/dependency manager: `uv` (`backend/uv.lock`, `README.md`, and `start.sh` use `uv sync`). +- Backend packaging: `backend/pyproject.toml` uses `hatchling` as the build backend. +- Frontend build stack: React 19 + TypeScript + Vite + TailwindCSS + DaisyUI + Lightweight Charts. +- Backend stack: FastAPI + SQLAlchemy + SQLite + CCXT (OKX integration noted in `README.md`). + +## Frontend coding conventions +- Components and pages use PascalCase file names under `src/components/` and `src/pages/`. +- Non-UI modules use camelCase file names under `src/services/` and `src/utils/`. +- Layout is organized by responsibility: `components/`, `pages/`, `services/`, `utils/`. +- `src/services/api.ts` centralizes API access and shared types. +- `src/utils/tradeAnalysis.ts` keeps analysis logic pure and separate from UI. +- Styling is utility-first; `src/index.css` uses Tailwind v4 CSS-first theme tokens and DaisyUI classes. + +## Backend coding conventions +- Backend code is organized by module responsibility: `main.py`, `database.py`, `models.py`, and `services/`. +- Service modules separate importer, analysis, symbol, and kline concerns. +- The codebase follows explicit domain naming (`trade_importer`, `trade_analyzer`, `kline_service`). +- `import_data.py` is a standalone script and adjusts `sys.path`, which signals script-first local execution. + +## Observed consistency +- Strong separation between UI, API, and pure logic helpers. +- Strict TypeScript settings are used in the frontend configs. +- Root docs and startup scripts consistently describe a local-first workflow. + +## Observed inconsistencies / quality gaps +- `frontend/tailwind.config.js` still uses CommonJS while the rest of the frontend is moving toward CSS-first Tailwind v4 configuration. +- Lint coverage is incomplete: `frontend/eslint.config.js` is present, but it is not type-aware. +- The frontend has several larger, more imperative components (`ChartManager.tsx`, `AnalysisPage.tsx`) compared with the otherwise modular structure. +- There is no visible automated test harness in the inspected files. +- Current diagnostics are not clean: frontend shows Biome lint findings, and backend shows BasedPyright import/type issues. +- Backend engineering constraints stay lightweight: no visible migration tool, no visible lint/test config, and `main.py` / `import_data.py` perform `create_all` at runtime. diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 0000000..0ce625b --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,28 @@ +# INTEGRATIONS + +## 前端 ↔ 后端 +- 前端通过相对路径 `/api` 调后端;Vite 开发代理指向 `http://localhost:9527`(`frontend/vite.config.ts`, `frontend/src/services/api.ts`) +- 主要 API: + - `GET /api/klines/{symbol}/{timeframe}`:K 线数据,支持 `start` / `end` / `limit` / `nocache` + - `GET /api/trades`:交易列表,支持 `symbol` / `start_date` / `end_date` / `page` / `limit` + - `POST /api/trades/import`:Excel 交易单导入 + - `GET /api/stats/overview`:统计概览 + (`backend/main.py`, `frontend/src/services/api.ts`) + +## 市场数据源 ↔ 后端缓存 +- K 线由 CCXT 拉取,默认 exchange 列表为 `okx,binance`,可通过 `KLINE_EXCHANGES` 环境变量覆盖(`backend/services/kline_service.py`) +- 后端把前端的 `BASE-QUOTE` 交易对转换成 CCXT 的 `BASE/QUOTE`,先查 SQLite 缓存,再按缺口补拉并回写 `klines` 表(`backend/services/kline_service.py`) +- 支持时间周期固定为 `5m/15m/1h/4h/1d`,前后端一致(`backend/services/kline_service.py`, `frontend/src/services/api.ts`) + +## 交易 Excel ↔ 数据库 +- `POST /api/trades/import` 只接受 `.xlsx/.xls` 的 multipart 上传(`backend/main.py`) +- Excel 列映射是中文表头,且会做方向、收益率、时间戳和 `Asia/Shanghai` 时区归一化(`backend/services/trade_importer.py`) +- 首次启动会尝试把根目录 `1.xlsx` 导入到 `trades` 表;如果已有交易记录则跳过(`backend/import_data.py`, `README.md`) + +## 前端本地分析边界 +- `AnalysisPage` 在浏览器里基于 `fetchTrades()` 的返回值做二次分析,衍生指标由前端 `tradeAnalysis.ts` 计算,不依赖额外后端分析接口(`frontend/src/pages/AnalysisPage.tsx`, `frontend/src/utils/tradeAnalysis.ts`) +- `StatsBar.tsx`、`StatsPanel.tsx`、`TradeList.tsx`、`ChartManager.tsx` 复用 `/api/stats/overview` 来驱动交易对分布、概览卡片和对比交易对选择(`frontend/src/components/StatsBar.tsx`, `frontend/src/components/StatsPanel.tsx`, `frontend/src/components/TradeList.tsx`, `frontend/src/components/ChartManager.tsx`) + +## 边界备注 +- 后端 CORS 目前是 `allow_origins=["*"]`,适合本地联调,不是面向公网的收口方案(`backend/main.py`) +- 当前扫描未见队列、消息总线、外部认证提供方或第三方支付等额外集成层 diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 0000000..bf0eda0 --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,25 @@ +# STACK + +## 运行时 / 包管理 +- Python 3.11+(`backend/pyproject.toml`, `README.md`) +- Node.js 18+、pnpm、uv(`README.md`, `start.sh`, `start.bat`) +- 前后端独立管理:后端锁文件 `backend/uv.lock`,前端锁文件 `frontend/pnpm-lock.yaml`;仓库根目录未见独立 `package.json` / workspace 文件(当前扫描结果) + +## 后端 +- FastAPI + Uvicorn + SQLAlchemy 2 + CCXT + pandas + openpyxl + python-multipart + pytz(`backend/pyproject.toml`) +- 入口:`backend/main.py`(API)和 `backend/import_data.py`(示例数据导入) +- Excel 导入链路依赖 pandas/openpyxl;上传接口依赖 multipart 表单(`backend/services/trade_importer.py`, `backend/main.py`) + +## 前端 +- React 19 + TypeScript + Vite 7 + React Router 7 + axios + lightweight-charts + lucide-react + Tailwind CSS v4 + DaisyUI(`frontend/package.json`, `frontend/vite.config.ts`, `frontend/src/index.css`, `frontend/src/App.tsx`) +- 构建链路:`tsc -b && vite build`;本地预览:`vite preview`(`frontend/package.json`, `start.sh`, `start.bat`) + +## 存储 +- 本地 SQLite:`sqlite:///./trading.db`(`backend/database.py`) +- 表:`klines`、`trades`;启动时自动 `Base.metadata.create_all`(`backend/models.py`, `backend/main.py`, `backend/import_data.py`) +- 索引:`klines(symbol,timeframe,timestamp)` 唯一索引,`trades(symbol)` 普通索引(`backend/models.py`) + +## 本地运行假设 +- 后端命令默认在 `backend/` 目录内执行,入口直接是 `uvicorn main:app`(`start.sh`, `start.bat`) +- 前端开发代理将 `/api` 转发到 `http://localhost:9527`(`frontend/vite.config.ts`) +- 启动脚本会在首次运行时导入根目录 `1.xlsx` 示例数据(`README.md`, `backend/import_data.py`) diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 0000000..d3f1fd3 --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,130 @@ +# Retraq 目录结构 + +## 顶层目录 + +```text +. +├── 1.xlsx +├── README.md +├── start.sh +├── start.bat +├── backend/ +├── frontend/ +├── docs/ +└── .planning/codebase/ +``` + +- `README.md`:项目简介、启动方式和端口说明 +- `start.sh` / `start.bat`:本地一键启动脚本 +- `1.xlsx`:首次导入的示例交易数据 +- `docs/images/`:README 里的截图资源 +- `.planning/codebase/`:本次架构文档输出目录 + +## backend/ + +```text +backend/ +├── database.py +├── import_data.py +├── main.py +├── models.py +├── pyproject.toml +├── uv.lock +└── services/ + ├── __init__.py + ├── kline_service.py + ├── symbol_utils.py + ├── trade_analyzer.py + └── trade_importer.py +``` + +### 角色分工 + +- `backend/main.py:28-129`:唯一 API 入口,集中定义路由 +- `backend/database.py:4-16`:SQLite 引擎、Session 和 `Base` +- `backend/models.py:5-38`:ORM 数据模型(`Kline`、`Trade`) +- `backend/import_data.py:12-36`:空库时的示例数据导入入口 +- `backend/services/`:业务逻辑和数据处理 + +### 目录心智 + +- **数据定义**:`models.py` +- **数据库连接**:`database.py` +- **对外 API**:`main.py` +- **导入与计算**:`services/` +- **启动/初始化**:`import_data.py` + +## backend/services/ + +- `kline_service.py`:行情缓存、外部回填、时间范围查询 +- `trade_importer.py`:Excel -> `Trade` +- `trade_analyzer.py`:`Trade` -> 汇总统计 +- `symbol_utils.py`:币对标准化和校验 + +这几个文件构成后端真正的业务层;`main.py` 只是把它们接成 HTTP 接口。 + +## frontend/ + +```text +frontend/ +├── index.html +├── package.json +├── tailwind.config.js +├── vite.config.ts +├── src/ +│ ├── App.tsx +│ ├── index.css +│ ├── main.tsx +│ ├── components/ +│ ├── pages/ +│ ├── services/ +│ └── utils/ +└── public/ +``` + +### 角色分工 + +- `frontend/src/main.tsx:1-13`:React 入口和路由注入 +- `frontend/src/App.tsx:7-18`:页面路由与全局布局 +- `frontend/src/pages/`:路由级页面 +- `frontend/src/components/`:页面复用组件和展示组件 +- `frontend/src/services/api.ts:35-112`:HTTP/数据契约层 +- `frontend/src/utils/tradeAnalysis.ts:146-846`:纯分析函数层 +- `frontend/src/index.css:1-225`:全局主题、动画和基础样式 +- `frontend/vite.config.ts:5-14`:前端开发代理 +- `frontend/tailwind.config.js:2-35`:DaisyUI 主题定义 + +### pages/ + +- `ReplayPage.tsx:7-46`:复盘工作台,左列表 / 中图表 / 右详情 +- `AnalysisPage.tsx:829-1030`:交易分析中心,全部在前端做二次计算 +- `LearnPage.tsx:136-339`:本地学习内容页 + +### components/ + +- `Navbar.tsx:4-64`:全局导航 +- `TradeList.tsx:11-276`:交易列表和筛选器 +- `ChartManager.tsx:52-1066`:主图、对比图、标记、价格线、全屏 +- `PositionDetails.tsx:12-49`:选中交易详情 +- `StatsBar.tsx`、`StatsPanel.tsx`:独立的 KPI 展示组件;当前路由树没有引入它们 + +## 数据与页面的对应关系 + +- `Trade` / `Kline`:后端事实模型 +- `frontend/src/services/api.ts`:把后端 JSON 变成前端接口对象 +- `ReplayPage`:把 `TradeList` 选中的交易传给 `ChartManager` 和 `PositionDetails` +- `AnalysisPage`:把 `fetchTrades()` 拉下来的交易交给 `tradeAnalysis.ts` +- `LearnPage`:本地静态内容,没有后端依赖 + +## 读目录时的主线 + +按下面顺序看最容易建立心智模型: + +1. `start.sh` / `start.bat`:应用怎么启动 +2. `backend/main.py`:后端暴露什么接口 +3. `backend/services/`:接口背后的业务怎么做 +4. `frontend/src/services/api.ts`:前端怎么对接接口 +5. `frontend/src/pages/ReplayPage.tsx` 和 `AnalysisPage.tsx`:页面怎么组合数据 +6. `frontend/src/components/ChartManager.tsx` 和 `tradeAnalysis.ts`:最重的 UI/分析逻辑在哪里 + +如果只记一件事:**后端负责持久化与行情回填,前端负责页面编排与二次分析。** diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 0000000..de8b0d9 --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,25 @@ +# Testing + +## Current state +- No dedicated automated test suite was observed in the inspected repo files. +- Frontend `package.json` exposes `dev`, `build`, `lint`, and `preview` only. +- Backend startup is script-driven, but no test command was visible in the inspected files. +- `backend/pyproject.toml` did not show pytest/coverage/lint configuration in the inspected scan. + +## Existing validation commands +- `./start.sh` (Linux/macOS) / `start.bat` (Windows): full local startup path. +- Frontend: `pnpm lint` and `pnpm build`. +- Backend: `uv sync`, `uv run python import_data.py`, `uv run uvicorn main:app --reload --port 9527`. + +## What `pnpm build` covers +- The frontend build command runs TypeScript project build checks before Vite build (`tsc -b && vite build` in `frontend/package.json`). + +## Diagnostics baseline +- `frontend/src` currently reports 40 lint diagnostics, mostly hook dependencies, array-index keys, SVG title accessibility, and explicit button type issues. +- `backend` currently reports 31 BasedPyright diagnostics, mostly unresolved imports plus type-annotation / optionality issues. + +## Notable gaps +- No `test` script in the frontend package manifest. +- No visible `pytest`/`unittest`/`vitest`/`playwright` setup in the inspected files. +- No evidence of CI validation commands in the inspected files. +- Current baseline is not clean: frontend diagnostics still report lint issues, and backend diagnostics still report unresolved imports plus type issues. diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 0000000..0c994e7 --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,44 @@ +{ + "model_profile": "inherit", + "commit_docs": true, + "parallelization": true, + "search_gitignored": false, + "brave_search": false, + "firecrawl": false, + "exa_search": false, + "git": { + "branching_strategy": "none", + "phase_branch_template": "gsd/phase-{phase}-{slug}", + "milestone_branch_template": "gsd/{milestone}-{slug}", + "quick_branch_template": null + }, + "workflow": { + "research": true, + "plan_check": true, + "verifier": true, + "nyquist_validation": true, + "auto_advance": false, + "node_repair": true, + "node_repair_budget": 2, + "ui_phase": true, + "ui_safety_gate": true, + "text_mode": false, + "research_before_questions": false, + "discuss_mode": "discuss", + "skip_discuss": false, + "code_review": true, + "code_review_depth": "standard" + }, + "hooks": { + "context_warnings": true + }, + "project_code": null, + "phase_naming": "sequential", + "agent_skills": {}, + "resolve_model_ids": "omit", + "model_overrides": { + "gsd-planner": "openai/gpt-5.4", + "gsd-roadmapper": "openai/gpt-5.4", + "gsd-debugger": "openai/gpt-5.4" + } +} \ No newline at end of file diff --git a/.planning/phases/01-import-reliability-schema-evolution/01-01-SUMMARY.md b/.planning/phases/01-import-reliability-schema-evolution/01-01-SUMMARY.md new file mode 100644 index 0000000..ee83421 --- /dev/null +++ b/.planning/phases/01-import-reliability-schema-evolution/01-01-SUMMARY.md @@ -0,0 +1,24 @@ +# 01-01 Summary + +## Changed files +- `backend/pyproject.toml` +- `backend/uv.lock` +- `backend/tests/conftest.py` +- `backend/tests/test_import_contracts.py` +- `backend/tests/test_migration_runner.py` +- `backend/services/import_types.py` +- `backend/migrations/__init__.py` +- `backend/migrations/runner.py` + +## Verification +- `cd backend && uv lock` +- `cd backend && uv sync` +- `cd backend && uv run pytest tests/test_import_contracts.py -q` → 4 passed +- `cd backend && uv run pytest tests/test_migration_runner.py -q` → 3 passed +- `cd backend && uv run pytest tests/test_import_contracts.py tests/test_migration_runner.py -q` → 7 passed +- `lsp_diagnostics` on all changed Python files → clean + +## Decisions +- Added `pytest` directly to backend dependencies and refreshed `uv.lock` so the backend test harness is runnable in the worktree. +- Kept the import contract narrow and explicit with dataclass-based report types plus fixed outcome buckets for success, failed, duplicate, conflict, and timestamp normalization. +- Implemented the migration runner as a small SQLite-first contract with backup-before-migrate behavior and a raw SQL bootstrap migration for schema `001`, without touching runtime startup wiring. diff --git a/.planning/phases/01-import-reliability-schema-evolution/01-02-SUMMARY.md b/.planning/phases/01-import-reliability-schema-evolution/01-02-SUMMARY.md new file mode 100644 index 0000000..0a98bc3 --- /dev/null +++ b/.planning/phases/01-import-reliability-schema-evolution/01-02-SUMMARY.md @@ -0,0 +1,21 @@ +# 01-02 Summary + +## Changed files +- `frontend/package.json` +- `frontend/pnpm-lock.yaml` +- `frontend/vitest.config.ts` +- `frontend/src/test/setup.ts` +- `frontend/src/test/import-api.contract.test.ts` +- `frontend/src/services/importTypes.ts` +- `frontend/src/services/importReportAdapter.ts` + +## Verification +- `cd frontend && pnpm exec vitest run --passWithNoTests` +- `cd frontend && pnpm exec vitest run src/test/import-api.contract.test.ts` +- `cd frontend && pnpm build` +- `lsp_diagnostics` on all changed TS/TSX files: clean + +## Decisions +- Used Vitest 3.2.4 with jsdom 26.1.0 so the harness stays compatible with Node 18. +- Kept the import report contract snake_case and Phase 1-specific to match the existing API style. +- Added one runtime adapter boundary that normalizes summary counts, row outcomes, file-level errors, and report-download metadata without touching `frontend/src/services/api.ts`. diff --git a/.planning/phases/01-import-reliability-schema-evolution/01-03-SUMMARY.md b/.planning/phases/01-import-reliability-schema-evolution/01-03-SUMMARY.md new file mode 100644 index 0000000..6fc9d74 --- /dev/null +++ b/.planning/phases/01-import-reliability-schema-evolution/01-03-SUMMARY.md @@ -0,0 +1,20 @@ +# 01-03 Summary + +## Changed files +- `backend/main.py` +- `backend/models.py` +- `backend/services/trade_importer.py` +- `backend/tests/test_trade_importer.py` +- `backend/tests/test_import_api.py` +- `backend/migrations/versions/001_phase1_import_reliability.sql` +- `backend/pyproject.toml` +- `backend/uv.lock` + +## Verification +- `cd backend && uv run pytest tests/test_import_contracts.py tests/test_migration_runner.py tests/test_trade_importer.py tests/test_import_api.py -q` → 12 passed +- `lsp_diagnostics` on changed backend Python files → clean + +## Decisions +- Replaced the old loose import counter response with a structured `ImportReport` flow that distinguishes success, failed, duplicate, conflict, and timestamp normalization outcomes. +- Persisted import sessions and row outcomes in SQLite so the API can return a stable report payload and generate CSV detail downloads on demand. +- Added the first explicit Phase 1 SQL migration baseline and kept the existing import route path stable while extending it with report download support. diff --git a/.planning/phases/01-import-reliability-schema-evolution/01-04-SUMMARY.md b/.planning/phases/01-import-reliability-schema-evolution/01-04-SUMMARY.md new file mode 100644 index 0000000..2295646 --- /dev/null +++ b/.planning/phases/01-import-reliability-schema-evolution/01-04-SUMMARY.md @@ -0,0 +1,13 @@ +# 01-04 Summary + +## Changed files +- `frontend/src/components/import/ImportContractNote.tsx` + +## Verification +- `cd frontend && pnpm exec vitest run src/test/ImportPage.test.tsx` +- `cd frontend && pnpm build` +- `lsp_diagnostics` on changed TS/TSX files + +## Decisions +- Kept the Phase 1 import workspace structure intact and only tightened the contract note copy so the dedicated entry surface reads as one continuous sentence. +- Preserved the row-level report flow, file upload flow, and existing route/nav wiring without broadening scope. diff --git a/.planning/phases/01-import-reliability-schema-evolution/01-05-SUMMARY.md b/.planning/phases/01-import-reliability-schema-evolution/01-05-SUMMARY.md new file mode 100644 index 0000000..5aa5694 --- /dev/null +++ b/.planning/phases/01-import-reliability-schema-evolution/01-05-SUMMARY.md @@ -0,0 +1,34 @@ +# 01-05 Summary + +## Changed files +- `backend/main.py` +- `backend/import_data.py` +- `backend/tests/test_startup_migrations.py` +- `frontend/src/test/import-route-smoke.test.tsx` +- `backend/services/trade_importer.py` +- `backend/tests/test_import_api.py` +- `frontend/src/services/importReportAdapter.ts` +- `frontend/src/test/import-api.contract.test.ts` + +## Verification +- `cd backend && uv run pytest tests/test_startup_migrations.py -q` → 2 passed +- `cd backend && uv run pytest tests/test_import_contracts.py tests/test_migration_runner.py tests/test_trade_importer.py tests/test_import_api.py tests/test_startup_migrations.py -q` → 15 passed +- `cd frontend && pnpm exec vitest run src/test/import-route-smoke.test.tsx` → 2 passed +- `cd frontend && pnpm exec vitest run src/test/import-api.contract.test.ts src/test/ImportPage.test.tsx src/test/import-route-smoke.test.tsx` → 11 passed +- `cd frontend && pnpm build` → success +- Live API verification against running app: + - valid import returns structured summary + download reference + - re-import keeps trade count unchanged and reports duplicates/conflicts + - invalid workbook returns file-level rejection details + - CSV download returns row-level repair data + +## Decisions +- Wired `MigrationRunner(engine=engine).run()` into runtime entry points before schema bootstrap so backup-first migration happens before normal app/database access. +- Added route-level smoke coverage without broadening frontend routing behavior beyond the existing `/import` integration already present in the worktree. +- Fixed a live-only `NaN` serialization bug by normalizing missing scalar values in failed row outcomes and added regression coverage for blank required cells. +- Made the frontend import adapter accept the actual backend Phase 1 dataclass JSON shape (`*_count`, `file_rejection`, `download_reference`, `outcome`) so live API responses map cleanly into the UI contract. + +## Approval Gate +- Automated verification is complete. +- Manual/runtime verification is complete from the assistant side. +- Phase closure is pending explicit user approval or reported issues. diff --git a/.planning/phases/02-single-trade-replay-core/02-01-PLAN.md b/.planning/phases/02-single-trade-replay-core/02-01-PLAN.md new file mode 100644 index 0000000..af53fe6 --- /dev/null +++ b/.planning/phases/02-single-trade-replay-core/02-01-PLAN.md @@ -0,0 +1,50 @@ +--- +phase: 02-single-trade-replay-core +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - frontend/src/utils/replaySession.ts + - frontend/src/test/replaySession.test.ts +autonomous: true +requirements: + - REPL-01 + - REPL-02 + - STAT-02 +--- + + +Lock the Phase 2 replay session contract: route/local/latest resolution, versioned local persistence, and a stable replay seed shape. + + + +- `frontend/src/pages/ReplayPage.tsx` +- `.planning/REQUIREMENTS.md` +- `.planning/phases/02-single-trade-replay-core/02-CONTEXT.md` + + + + + Task 1: Create replay session tests and minimal session contract + frontend/src/utils/replaySession.ts, frontend/src/test/replaySession.test.ts + + - route seed beats local restore + - local restore beats latest trade fallback + - invalid or stale local payload falls back safely + - session blob is versioned and browser-local only + + Write failing tests first, then implement the smallest pure helper module that resolves replay seed precedence and serializes/deserializes a versioned local replay session blob. + + cd frontend && pnpm exec vitest run src/test/replaySession.test.ts + + + + + +- `cd frontend && pnpm exec vitest run src/test/replaySession.test.ts` + + + +After completion, create `.planning/phases/02-single-trade-replay-core/02-01-SUMMARY.md` + diff --git a/.planning/phases/02-single-trade-replay-core/02-01-SUMMARY.md b/.planning/phases/02-single-trade-replay-core/02-01-SUMMARY.md new file mode 100644 index 0000000..fca0984 --- /dev/null +++ b/.planning/phases/02-single-trade-replay-core/02-01-SUMMARY.md @@ -0,0 +1,14 @@ +# 02-01 Summary + +## Changed files +- `frontend/src/utils/replaySession.ts` +- `frontend/src/test/replaySession.test.ts` + +## Verification +- `cd frontend && pnpm exec vitest run src/test/replaySession.test.ts` → 7 passed +- `lsp_diagnostics` on changed TS files → clean + +## Decisions +- Added a pure replay session contract with browser-local versioning only. +- Locked the seed resolution precedence to `route > local restore > latest trade > empty`. +- Kept this slice isolated from ReplayPage, TradeList, ChartManager, and router wiring so later Phase 2 slices can depend on a stable contract. diff --git a/.planning/phases/02-single-trade-replay-core/02-02-PLAN.md b/.planning/phases/02-single-trade-replay-core/02-02-PLAN.md new file mode 100644 index 0000000..6f4fb2a --- /dev/null +++ b/.planning/phases/02-single-trade-replay-core/02-02-PLAN.md @@ -0,0 +1,54 @@ +--- +phase: 02-single-trade-replay-core +plan: 02 +type: execute +wave: 2 +depends_on: + - 02-01 +files_modified: + - frontend/src/pages/ReplayPage.tsx + - frontend/src/components/TradeList.tsx + - frontend/src/test/replayPage.test.tsx + - frontend/src/test/tradeListSelection.test.tsx +autonomous: true +requirements: + - REPL-01 + - REPL-02 + - STAT-02 +--- + + +Make the replay shell seed-aware so explicit route/local replay state is honored before the current latest-trade auto-open behavior. + + + +- `frontend/src/pages/ReplayPage.tsx` +- `frontend/src/components/TradeList.tsx` +- `frontend/src/utils/replaySession.ts` +- `.planning/phases/02-single-trade-replay-core/02-CONTEXT.md` + + + + + Task 1: Add seed-aware replay bootstrap tests + frontend/src/test/replayPage.test.tsx, frontend/src/test/tradeListSelection.test.tsx + + - route/local seed prevents latest-trade takeover + - explicit trade selection updates symbol and replay seed together + - empty replay state stays empty when no seed exists + + Write failing tests first, then update ReplayPage and TradeList so replay bootstrap is controlled by the Phase 2 session contract rather than unconditional latest-trade selection. + + cd frontend && pnpm exec vitest run src/test/replayPage.test.tsx src/test/tradeListSelection.test.tsx + + + + + +- `cd frontend && pnpm exec vitest run src/test/replayPage.test.tsx src/test/tradeListSelection.test.tsx` +- `cd frontend && pnpm build` + + + +After completion, create `.planning/phases/02-single-trade-replay-core/02-02-SUMMARY.md` + diff --git a/.planning/phases/02-single-trade-replay-core/02-02-SUMMARY.md b/.planning/phases/02-single-trade-replay-core/02-02-SUMMARY.md new file mode 100644 index 0000000..345b444 --- /dev/null +++ b/.planning/phases/02-single-trade-replay-core/02-02-SUMMARY.md @@ -0,0 +1,17 @@ +# 02-02 Summary + +## Changed files +- `frontend/src/pages/ReplayPage.tsx` +- `frontend/src/components/TradeList.tsx` +- `frontend/src/test/replayPage.test.tsx` +- `frontend/src/test/tradeListSelection.test.tsx` + +## Verification +- `cd frontend && pnpm exec vitest run src/test/replayPage.test.tsx src/test/tradeListSelection.test.tsx` → 5 passed +- `cd frontend && pnpm build` → success +- `lsp_diagnostics` on changed TS/TSX files → clean + +## Decisions +- ReplayPage now resolves route/local replay seeds before falling back to empty state. +- TradeList keeps its latest-trade auto-open behavior only when no explicit replay seed is present. +- Selection handoff now persists the newly selected trade back into the Phase 2 replay session contract. diff --git a/.planning/phases/02-single-trade-replay-core/02-03-PLAN.md b/.planning/phases/02-single-trade-replay-core/02-03-PLAN.md new file mode 100644 index 0000000..6a471a5 --- /dev/null +++ b/.planning/phases/02-single-trade-replay-core/02-03-PLAN.md @@ -0,0 +1,55 @@ +--- +phase: 02-single-trade-replay-core +plan: 03 +type: execute +wave: 2 +depends_on: + - 02-01 +files_modified: + - frontend/src/utils/replayPlayback.ts + - frontend/src/components/ReplayControls.tsx + - frontend/src/components/ChartManager.tsx + - frontend/src/pages/ReplayPage.tsx + - frontend/src/test/replayPlayback.test.ts + - frontend/src/test/replayControls.test.tsx +autonomous: true +requirements: + - REPL-04 + - STAT-02 +--- + + +Add the single-cursor replay transport and the Phase 2 control surface without redefining compare semantics. + + + +- `frontend/src/components/ChartManager.tsx` +- `frontend/src/pages/ReplayPage.tsx` +- `frontend/src/utils/replaySession.ts` +- `.planning/phases/02-single-trade-replay-core/02-CONTEXT.md` + + + + + Task 1: Add replay transport reducer/helper and toolbar tests + frontend/src/utils/replayPlayback.ts, frontend/src/components/ReplayControls.tsx, frontend/src/test/replayPlayback.test.ts, frontend/src/test/replayControls.test.tsx + + - play / pause / step back / step forward / reset work against a single cursor + - speed presets are 1x / 2x / 4x + - cursor/progress persist locally on pause and restore + + Write failing tests first, then implement a pure replay transport helper plus a minimal toolbar component. Wire it into the existing replay shell through ReplayPage and ChartManager props, keeping compare passive. + + cd frontend && pnpm exec vitest run src/test/replayPlayback.test.ts src/test/replayControls.test.tsx + + + + + +- `cd frontend && pnpm exec vitest run src/test/replayPlayback.test.ts src/test/replayControls.test.tsx` +- `cd frontend && pnpm build` + + + +After completion, create `.planning/phases/02-single-trade-replay-core/02-03-SUMMARY.md` + diff --git a/.planning/phases/02-single-trade-replay-core/02-03-SUMMARY.md b/.planning/phases/02-single-trade-replay-core/02-03-SUMMARY.md new file mode 100644 index 0000000..954dda5 --- /dev/null +++ b/.planning/phases/02-single-trade-replay-core/02-03-SUMMARY.md @@ -0,0 +1,16 @@ +# 02-03 Summary + +## Changed files +- `frontend/src/utils/replayPlayback.ts` +- `frontend/src/components/ReplayControls.tsx` +- `frontend/src/components/ChartManager.tsx` + +## Verification +- `cd frontend && pnpm exec vitest run src/test/replayPlayback.test.ts src/test/replayControls.test.tsx` → 8 passed +- `cd frontend && pnpm build` → success +- `lsp_diagnostics` on changed TS/TSX files → clean + +## Decisions +- Added a pure replay transport helper with per-trade local progress persistence and discrete speed presets `1x/2x/4x`. +- Introduced a minimal replay control surface without changing compare semantics. +- Wired the playback cursor into ChartManager so replay actions drive visible chart range movement while leaving existing markers and compare sync intact. diff --git a/.planning/phases/02-single-trade-replay-core/02-04-PLAN.md b/.planning/phases/02-single-trade-replay-core/02-04-PLAN.md new file mode 100644 index 0000000..f9934d7 --- /dev/null +++ b/.planning/phases/02-single-trade-replay-core/02-04-PLAN.md @@ -0,0 +1,58 @@ +--- +phase: 02-single-trade-replay-core +plan: 04 +type: execute +wave: 3 +depends_on: + - 02-02 + - 02-03 +files_modified: + - frontend/src/pages/ReplayPage.tsx + - frontend/src/components/ChartManager.tsx + - frontend/src/test/replayFlow.smoke.test.tsx +autonomous: true +requirements: + - REPL-01 + - REPL-02 + - REPL-03 + - REPL-04 + - STAT-02 +--- + + +Finish Phase 2 integration and prove the replay flow works end-to-end through the existing shell, including resume behavior and marker preservation. + + + +- `frontend/src/pages/ReplayPage.tsx` +- `frontend/src/components/ChartManager.tsx` +- `frontend/src/components/TradeList.tsx` +- `frontend/src/components/PositionDetails.tsx` +- `.planning/phases/02-single-trade-replay-core/02-CONTEXT.md` + + + + + Task 1: Add replay flow smoke coverage and finalize shell integration + frontend/src/test/replayFlow.smoke.test.tsx, frontend/src/pages/ReplayPage.tsx, frontend/src/components/ChartManager.tsx + + - opening replay from a trade or restored session lands on the correct trade context + - markers and price lines remain visible for the selected trade + - playback controls work in the integrated shell + - reload restores saved progress for the selected trade + + Write failing smoke/integration tests first, then apply the minimum ReplayPage and ChartManager changes needed to wire Phase 2 together cleanly. + + cd frontend && pnpm exec vitest run src/test/replayFlow.smoke.test.tsx + + + + + +- `cd frontend && pnpm exec vitest run src/test/replayFlow.smoke.test.tsx` +- `cd frontend && pnpm build` + + + +After completion, create `.planning/phases/02-single-trade-replay-core/02-04-SUMMARY.md` + diff --git a/.planning/phases/02-single-trade-replay-core/02-04-SUMMARY.md b/.planning/phases/02-single-trade-replay-core/02-04-SUMMARY.md new file mode 100644 index 0000000..370fa83 --- /dev/null +++ b/.planning/phases/02-single-trade-replay-core/02-04-SUMMARY.md @@ -0,0 +1,18 @@ +# 02-04 Summary + +## Changed files +- `frontend/src/test/replayFlow.smoke.test.tsx` + +## Verification +- `cd frontend && pnpm exec vitest run src/test/replayFlow.smoke.test.tsx` → 2 passed +- `cd frontend && pnpm exec vitest run src/test/replaySession.test.ts src/test/replayPage.test.tsx src/test/tradeListSelection.test.tsx src/test/replayPlayback.test.ts src/test/replayControls.test.tsx src/test/replayFlow.smoke.test.tsx` → 22 passed +- `cd frontend && pnpm build` → success +- `lsp_diagnostics` on changed TS/TSX files → clean +- Browser QA on `/replay`: + - replay controls render in the live page + - stepping writes a `retraq:replay-progress:` localStorage record + - refresh preserves the replay progress record for the selected trade + +## Decisions +- Added a chart-runtime smoke test with mocked chart primitives so replay controls, marker wiring, and local progress restore can be validated without rewriting the shell. +- Kept Phase 2 integration focused on replay-core completion; compare semantics were left untouched. diff --git a/.planning/phases/02-single-trade-replay-core/02-CONTEXT.md b/.planning/phases/02-single-trade-replay-core/02-CONTEXT.md new file mode 100644 index 0000000..9f229ce --- /dev/null +++ b/.planning/phases/02-single-trade-replay-core/02-CONTEXT.md @@ -0,0 +1,94 @@ +# Phase 2: Single-Trade Replay Core - Context + +**Gathered:** 2026-04-11 +**Status:** Ready for planning + + +## Phase Boundary + +在现有 replay 壳基础上,把“选中一笔交易后进入可控回放”补完整:用户能从交易列表或已保存进度进入单笔 replay,会话会自动对齐 symbol / 时间窗 / 默认 timeframe,并具备 play、pause、step、speed 与本地恢复能力。 + + + + +## Implementation Decisions + +### Replay 入口与状态优先级 +- **D-02-01:** Phase 2 的 replay 入口优先级固定为:`route seed > local restore > latest trade > empty state`。 +- **D-02-02:** `/replay` 继续作为唯一 canonical route;Phase 2 不新增独立 replay detail route。 +- **D-02-03:** `TradeList` 的“自动打开最近一笔”只在没有 route/local seed 时生效。 + +### Playback 模型 +- **D-02-04:** Replay 采用**单共享 cursor 的 bar-step 模型**,而不是时间连续动画或多 cursor 模型。 +- **D-02-05:** Phase 2 控制面固定包含:`play / pause / step backward / step forward / reset / speed`。 +- **D-02-06:** Phase 2 速度档位固定为 `1x / 2x / 4x`,速度只影响自动推进 cadence,不改变 step 粒度。 + +### 持久化边界 +- **D-02-07:** Replay 进度只做**浏览器本地持久化**;Phase 2 不引入后端 replay-state schema。 +- **D-02-08:** 本地保存内容限定为:selected trade seed、cursor/progress、speed、必要的 replay shell state;不保存瞬时 UI 噪声。 + +### 与现有 compare shell 的关系 +- **D-02-09:** Phase 2 **不重定义 compare 语义**;现有 compare chart 只要求在 replay cursor 变化时不被破坏。 +- **D-02-10:** “同 symbol 多 timeframe 对比” 的产品语义冲突推迟到 Phase 3 解决;Phase 2 只保证 compare shell 与 replay core 协同存在。 + + + + +## Canonical References + +### Product scope and requirements +- `.planning/ROADMAP.md` — Phase 2 goal, success criteria, and sequencing. +- `.planning/REQUIREMENTS.md` — `REPL-01` through `REPL-04`, plus `STAT-02`. +- `.planning/STATE.md` — Approved Phase 1 baseline and current worktree source of truth. + +### Current codebase reality +- `frontend/src/pages/ReplayPage.tsx` — Replay shell state owner. +- `frontend/src/components/TradeList.tsx` — Trade selection, symbol filter, and current latest-trade auto-open behavior. +- `frontend/src/components/ChartManager.tsx` — Current chart sync, markers, compare shell, and chart control surface. +- `frontend/src/components/PositionDetails.tsx` — Selected-trade detail pane. +- `frontend/src/services/api.ts` — Trade and kline data access. + + + + +## Existing Code Insights + +### Reusable Assets +- `ReplayPage.tsx` already owns `symbol` and `selectedTrade`, so Phase 2 should extend this page rather than introduce a parallel replay state owner. +- `TradeList.tsx` already auto-selects a latest trade for the chosen symbol, which can be gated for route/local replay seeds. +- `ChartManager.tsx` already computes `rangeForTrade`, `visibleRangeForTrade`, entry/exit markers, and shared chart synchronization, which should remain the rendering core. +- `PositionDetails.tsx` already reflects the selected trade and should stay aligned with replay session state. + +### Established Patterns +- Frontend side-effects live in page/component hooks rather than global state libraries. +- Frontend contracts and adapters live under `src/services/` and pure helpers under `src/utils/`. +- Existing replay shell is chart-first, not toolbar-first; Phase 2 controls should be inserted surgically. + +### Integration Points +- `fetchTrades()` and `fetchStats()` provide seed selection data for replay bootstrap. +- `fetchKlines()` already supports range-bound requests and should remain the chart data ingress. +- Current compare chart shell in `ChartManager.tsx` shares visible range and crosshair with the main chart; Phase 2 playback should preserve that behavior. + + + + +## Specific Ideas + +- Phase 2 should feel like completing a latent replay engine, not replacing the current chart shell. +- The smallest safe persistence unit is a versioned browser-local replay session blob. +- Marker visibility is already present; Phase 2 should preserve and regression-test it rather than redesign it. + + + + +## Deferred Ideas + +- Independent compare semantics and “same-symbol multi-timeframe” product shape are deferred to Phase 3. +- Keyboard shortcuts, scrubbing timeline, and backend replay-state persistence are out of scope for Phase 2. + + + +--- + +*Phase: 02-single-trade-replay-core* +*Context gathered: 2026-04-11* diff --git a/.planning/phases/02-single-trade-replay-core/02-DISCUSSION-LOG.md b/.planning/phases/02-single-trade-replay-core/02-DISCUSSION-LOG.md new file mode 100644 index 0000000..c859993 --- /dev/null +++ b/.planning/phases/02-single-trade-replay-core/02-DISCUSSION-LOG.md @@ -0,0 +1,24 @@ +# Phase 2 Discussion Log + +## Autonomous Discuss Outcome + +### Resolved Defaults +- Replay entry precedence is `route seed > local restore > latest trade > empty state`. +- Replay uses a single shared cursor and bar-step playback model. +- Controls in scope: play, pause, step backward, step forward, reset, speed. +- Speed presets in scope: 1x, 2x, 4x. +- Persistence is browser-local only for Phase 2. + +### Explicitly Deferred +- Compare semantics mismatch between code and roadmap is deferred to Phase 3. +- Keyboard shortcuts and scrubber UX are deferred. +- Backend replay-state persistence is deferred. + +### Why These Defaults +- They extend the existing replay shell without forcing a rewrite. +- They satisfy `REPL-01..04` and `STAT-02` with minimal backend risk. +- They keep Phase 2 focused on single-trade replay rather than compare redesign. + +--- + +Approved for planning on 2026-04-11 after worktree-based Phase 1 approval. diff --git a/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-01-PLAN.md b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-01-PLAN.md new file mode 100644 index 0000000..1c8ce67 --- /dev/null +++ b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-01-PLAN.md @@ -0,0 +1,52 @@ +--- +phase: 03-multi-timeframe-compare-lightweight-analytics +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - frontend/src/utils/comparePane.ts + - frontend/src/test/comparePane.test.ts + - frontend/src/components/ChartManager.tsx +autonomous: true +requirements: + - COMP-01 + - COMP-02 + - COMP-03 + - USER-COMP-BOTH-MODES +--- + + +Lock the Phase 3 compare contract: explicit compare mode, secondary pane resolver, and mode-aware annotation rules that support both compare behaviors without expanding into a multi-pane workbench. + + + +- `frontend/src/components/ChartManager.tsx` +- `.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-CONTEXT.md` +- `.planning/REQUIREMENTS.md` + + + + + Task 1: Add compare pane resolver tests and minimal mode-aware contract + frontend/src/utils/comparePane.ts, frontend/src/test/comparePane.test.ts, frontend/src/components/ChartManager.tsx + + - explicit compare mode `off | symbol | timeframe` + - secondary pane resolves to same-timeframe multi-symbol OR same-symbol multi-timeframe + - annotation policy differs by mode + + Write failing tests first, then implement the smallest pure compare pane resolver and wire ChartManager to consume that contract without yet adding new UI surfaces. + + cd frontend && pnpm exec vitest run src/test/comparePane.test.ts + + + + + +- `cd frontend && pnpm exec vitest run src/test/comparePane.test.ts` +- `cd frontend && pnpm build` + + + +After completion, create `.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-01-SUMMARY.md` + diff --git a/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-01-SUMMARY.md b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-01-SUMMARY.md new file mode 100644 index 0000000..e0ef34d --- /dev/null +++ b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-01-SUMMARY.md @@ -0,0 +1,16 @@ +# 03-01 Summary + +## Changed files +- `frontend/src/utils/comparePane.ts` +- `frontend/src/test/comparePane.test.ts` +- `frontend/src/components/ChartManager.tsx` + +## Verification +- `cd frontend && pnpm exec vitest run src/test/comparePane.test.ts` → 3 passed +- `cd frontend && pnpm build` → success +- `lsp_diagnostics` on changed TS/TSX files → clean + +## Decisions +- Added an explicit compare contract with `off | symbol | timeframe` modes. +- Locked the secondary pane resolver so the existing same-timeframe multi-symbol behavior becomes a first-class mode instead of implicit ChartManager behavior. +- Introduced mode-aware annotation policy so later Phase 3 slices can add timeframe compare without leaking trade markers into symbol compare. diff --git a/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-02-PLAN.md b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-02-PLAN.md new file mode 100644 index 0000000..64030ae --- /dev/null +++ b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-02-PLAN.md @@ -0,0 +1,52 @@ +--- +phase: 03-multi-timeframe-compare-lightweight-analytics +plan: 02 +type: execute +wave: 2 +depends_on: + - 03-01 +files_modified: + - frontend/src/components/ChartManager.tsx + - frontend/src/components/CompareModeControls.tsx + - frontend/src/test/timeframeCompare.test.tsx +autonomous: true +requirements: + - COMP-01 + - COMP-02 + - COMP-03 +--- + + +Implement the same-symbol multi-timeframe compare path on top of the new compare contract, including cross-timeframe synchronization and mode-specific annotations. + + + +- `frontend/src/components/ChartManager.tsx` +- `frontend/src/utils/comparePane.ts` +- `.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-CONTEXT.md` + + + + + Task 1: Add timeframe compare integration tests and wire the mode + frontend/src/components/ChartManager.tsx, frontend/src/components/CompareModeControls.tsx, frontend/src/test/timeframeCompare.test.tsx + + - same symbol, independent compare timeframe + - synchronized visible range and nearest-bar crosshair mapping + - current trade markers and price lines remain meaningful in timeframe mode + + Write failing tests first, then implement the smallest UI/control and sync changes needed to support timeframe compare mode in the existing replay shell. + + cd frontend && pnpm exec vitest run src/test/timeframeCompare.test.tsx + + + + + +- `cd frontend && pnpm exec vitest run src/test/timeframeCompare.test.tsx` +- `cd frontend && pnpm build` + + + +After completion, create `.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-02-SUMMARY.md` + diff --git a/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-02-SUMMARY.md b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-02-SUMMARY.md new file mode 100644 index 0000000..a9bc4ff --- /dev/null +++ b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-02-SUMMARY.md @@ -0,0 +1,15 @@ +# 03-02 Summary + +## Changed files +- `frontend/src/components/ChartManager.tsx` +- `frontend/src/test/timeframeCompare.test.tsx` + +## Verification +- `cd frontend && pnpm exec vitest run src/test/timeframeCompare.test.tsx` → 4 passed +- `cd frontend && pnpm build` → success +- `lsp_diagnostics` on changed TS/TSX files → clean + +## Decisions +- Added explicit same-symbol multi-timeframe compare behavior on the existing single secondary pane. +- Kept the compare timeframe independent from the main timeframe while preserving shared visible-range synchronization. +- Upgraded crosshair sync so timeframe compare does not depend on exact timestamp equality. diff --git a/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-03-PLAN.md b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-03-PLAN.md new file mode 100644 index 0000000..703051a --- /dev/null +++ b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-03-PLAN.md @@ -0,0 +1,51 @@ +--- +phase: 03-multi-timeframe-compare-lightweight-analytics +plan: 03 +type: execute +wave: 2 +depends_on: + - 03-01 +files_modified: + - frontend/src/components/ChartManager.tsx + - frontend/src/components/CompareModeControls.tsx + - frontend/src/test/symbolCompare.test.tsx +autonomous: true +requirements: + - COMP-02 + - USER-COMP-BOTH-MODES +--- + + +Stabilize the existing same-timeframe multi-symbol compare path under the new compare contract so it coexists cleanly with timeframe mode. + + + +- `frontend/src/components/ChartManager.tsx` +- `frontend/src/utils/comparePane.ts` +- `.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-CONTEXT.md` + + + + + Task 1: Add symbol compare regression tests and mode switch behavior + frontend/src/components/ChartManager.tsx, frontend/src/components/CompareModeControls.tsx, frontend/src/test/symbolCompare.test.tsx + + - existing multi-symbol compare still works at a shared timeframe + - switching between symbol and timeframe modes preserves sane defaults + - selected trade annotations do not leak into the symbol compare pane + + Write failing tests first, then refactor the existing compare shell into an explicit symbol compare mode without regressing current synchronization behavior. + + cd frontend && pnpm exec vitest run src/test/symbolCompare.test.tsx + + + + + +- `cd frontend && pnpm exec vitest run src/test/symbolCompare.test.tsx` +- `cd frontend && pnpm build` + + + +After completion, create `.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-03-SUMMARY.md` + diff --git a/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-03-SUMMARY.md b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-03-SUMMARY.md new file mode 100644 index 0000000..822ae85 --- /dev/null +++ b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-03-SUMMARY.md @@ -0,0 +1,16 @@ +# 03-03 Summary + +## Changed files +- `frontend/src/components/ChartManager.tsx` +- `frontend/src/components/CompareModeControls.tsx` +- `frontend/src/test/symbolCompare.test.tsx` + +## Verification +- `cd frontend && pnpm exec vitest run src/test/symbolCompare.test.tsx` → 3 passed +- `cd frontend && pnpm build` → success +- `lsp_diagnostics` on changed TS/TSX files → clean + +## Decisions +- Promoted the old implicit compare shell into an explicit `symbol` compare mode. +- Added a small compare-mode control surface while keeping the existing replay layout intact. +- Ensured selected-trade annotations stay off the secondary pane in symbol compare mode so the compare chart remains contextual, not misleading. diff --git a/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-04-PLAN.md b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-04-PLAN.md new file mode 100644 index 0000000..d97deeb --- /dev/null +++ b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-04-PLAN.md @@ -0,0 +1,57 @@ +--- +phase: 03-multi-timeframe-compare-lightweight-analytics +plan: 04 +type: execute +wave: 3 +depends_on: + - 03-02 + - 03-03 +files_modified: + - frontend/src/components/ReplayAnalyticsPanel.tsx + - frontend/src/components/ChartManager.tsx + - frontend/src/test/replayAnalyticsPanel.test.tsx + - frontend/src/test/phase3ReplayIntegration.smoke.test.tsx +autonomous: true +requirements: + - ANLY-01 + - ANLY-02 + - ANLY-03 + - COMP-02 +--- + + +Add the lightweight replay analytics panel and final replay integration smoke coverage without turning replay into a dashboard. + + + +- `frontend/src/pages/AnalysisPage.tsx` +- `frontend/src/utils/tradeAnalysis.ts` +- `frontend/src/services/api.ts` +- `frontend/src/components/ChartManager.tsx` +- `.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-CONTEXT.md` + + + + + Task 1: Add replay analytics panel and Phase 3 smoke coverage + frontend/src/components/ReplayAnalyticsPanel.tsx, frontend/src/components/ChartManager.tsx, frontend/src/test/replayAnalyticsPanel.test.tsx, frontend/src/test/phase3ReplayIntegration.smoke.test.tsx + + - collapsible analytics panel with core summary, equity curve, and time/symbol distributions + - analytics stay supportive and lazy-loaded + - both compare modes still work after analytics integration + + Write failing tests first, then build a compact replay analytics panel that reuses existing trade-derived helpers and wire it into the replay shell with final integration smoke coverage. + + cd frontend && pnpm exec vitest run src/test/replayAnalyticsPanel.test.tsx src/test/phase3ReplayIntegration.smoke.test.tsx + + + + + +- `cd frontend && pnpm exec vitest run src/test/replayAnalyticsPanel.test.tsx src/test/phase3ReplayIntegration.smoke.test.tsx` +- `cd frontend && pnpm build` + + + +After completion, create `.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-04-SUMMARY.md` + diff --git a/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-04-SUMMARY.md b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-04-SUMMARY.md new file mode 100644 index 0000000..7f14221 --- /dev/null +++ b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-04-SUMMARY.md @@ -0,0 +1,17 @@ +# 03-04 Summary + +## Changed files +- `frontend/src/components/ReplayAnalyticsPanel.tsx` +- `frontend/src/pages/ReplayPage.tsx` +- `frontend/src/test/replayAnalyticsPanel.test.tsx` +- `frontend/src/test/phase3ReplayIntegration.smoke.test.tsx` + +## What changed +- Added a collapsible replay analytics panel that lazily loads stats and trade-derived analytics on first expand. +- Placed the panel in the replay shell’s right sidebar above position details, leaving compare modes and the one-main-one-secondary chart layout intact. +- Tightened the tests so they validate lazy loading, collapse/reopen behavior, and the replay shell smoke path with both compare modes. + +## Verification +- `cd frontend && pnpm exec vitest run src/test/replayAnalyticsPanel.test.tsx src/test/phase3ReplayIntegration.smoke.test.tsx` → 4 passed +- `cd frontend && pnpm build` → success +- `lsp_diagnostics` on changed TS/TSX files → no errors diff --git a/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-CONTEXT.md b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-CONTEXT.md new file mode 100644 index 0000000..033f0ba --- /dev/null +++ b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-CONTEXT.md @@ -0,0 +1,98 @@ +# Phase 3: Multi-Timeframe Compare & Lightweight Analytics - Context + +**Gathered:** 2026-04-11 +**Status:** Ready for planning + + +## Phase Boundary + +Phase 3 要在 Phase 2 replay core 之上,同时交付两种 compare 能力: +- 同一交易对,多时间周期对比 +- 同一时间周期,多交易对对比 + +并补上轻量 analytics,但不把 replay 变成通用 dashboard 或多图工作台。 + + + + +## Implementation Decisions + +### Compare 模式与 UI 结构 +- **D-03-01:** Phase 3 采用 **一个主图 + 一个单副图槽位** 的结构,不做矩阵式多图布局。 +- **D-03-02:** 副图槽位支持两种互斥 compare mode: + - `symbol`:同 timeframe、多交易对 + - `timeframe`:同交易对、多 timeframe +- **D-03-03:** 主图永远绑定当前 replay trade 的主 symbol 与主 timeframe;副图由显式 mode 解析。 + +### 状态模型 +- **D-03-04:** 状态分两层: + - `viewState`:可序列化的 compare mode / compare symbol / compare timeframe / analyticsOpen + - `runtimeState`:chart refs、sync refs、loading/error、replay cursor runtime +- **D-03-05:** `ReplayPage.tsx` 继续只拥有 `symbol + selectedTrade`,Phase 3 不把 replay transport 全量上提。 + +### 标记与同步语义 +- **D-03-06:** compare pane 继续与主图同步 visible range 与 crosshair。 +- **D-03-07:** `timeframe` compare 模式下,副图允许显示当前 trade 的 marker / price lines。 +- **D-03-08:** `symbol` compare 模式下,副图不显示当前 trade 标记,避免语义错位。 +- **D-03-09:** 跨 timeframe 的 crosshair sync 不能依赖“完全相同 timestamp”,应按“包含该时刻的 bar”做映射。 + +### Analytics 边界 +- **D-03-10:** Analytics 保持 lightweight/supportive,以 replay 内可折叠 panel 方式交付,不重用整页 `AnalysisPage.tsx` UI。 +- **D-03-11:** 优先复用现有 `fetchStats()` / `fetchTrades()` 和 `tradeAnalysis.ts` 纯函数,不新增 backend schema。 +- **D-03-12:** Analytics 首次展开时再加载/计算,默认关闭,避免抢占 replay 主流程。 + + + + +## Canonical References + +- `.planning/ROADMAP.md` +- `.planning/REQUIREMENTS.md` +- `.planning/STATE.md` +- `frontend/src/components/ChartManager.tsx` +- `frontend/src/pages/ReplayPage.tsx` +- `frontend/src/pages/AnalysisPage.tsx` +- `frontend/src/utils/tradeAnalysis.ts` +- `frontend/src/services/api.ts` +- `backend/services/trade_analyzer.py` + + + + +## Existing Code Insights + +### Reusable Assets +- `ChartManager.tsx` already has a same-timeframe multi-symbol compare shell, visible-range sync, crosshair sync, and Phase 2 replay transport/control bar. +- `ReplayPage.tsx` already owns replay shell entry and selected trade state; it should remain the replay session container. +- `AnalysisPage.tsx` and `tradeAnalysis.ts` already contain many analytics calculations that can be selectively reused. +- `fetchStats()` already returns summary metrics and symbol distribution; `fetchTrades()` can supply richer trade-derived analytics without backend changes. + +### Architectural Tension +- Current compare shell is implicitly `symbol` mode only. +- Phase 3 now requires an additional `timeframe` mode without breaking the current implementation. +- Current crosshair sync logic assumes exact timestamp matches and will not be correct for cross-timeframe compare. + + + + +## Specific Ideas + +- Introduce a `resolvedPaneSpec` or equivalent compare resolver layer before data loading. +- Keep one secondary pane and swap its meaning by mode instead of adding more panes. +- Put analytics in a dedicated replay panel component rather than expanding `ChartManager.tsx` even further. + + + + +## Deferred Ideas + +- Multi-pane / grid compare workbench +- Multi-symbol + multi-timeframe matrix comparisons at the same time +- Dedicated backend analytics endpoints unless current frontend derivation proves too slow + + + +--- + +*Phase: 03-multi-timeframe-compare-lightweight-analytics* +*Context gathered: 2026-04-11* diff --git a/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-DISCUSSION-LOG.md b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-DISCUSSION-LOG.md new file mode 100644 index 0000000..71c5d7b --- /dev/null +++ b/.planning/phases/03-multi-timeframe-compare-lightweight-analytics/03-DISCUSSION-LOG.md @@ -0,0 +1,24 @@ +# Phase 3 Discussion Log + +## Autonomous Discuss Outcome + +### User Clarification +- Phase 3 compare is not either/or. +- Both of these must be delivered in this phase: + 1. 同一交易对,多时间周期对比 + 2. 同一时间周期,多交易对对比 + +### Locked Defaults +- Use one main chart plus one secondary compare pane. +- Secondary pane switches meaning by explicit mode instead of multiplying chart count. +- Replay shell remains chart-first; analytics stay supportive and collapsible. +- No backend schema changes are assumed for this phase. + +### Architecture Recommendation Adopted +- Keep `ReplayPage` as the replay/session owner. +- Evolve `ChartManager` into a mode-aware compare orchestrator. +- Add a small replay analytics panel rather than reusing the full `AnalysisPage` surface. + +--- + +Approved for planning on 2026-04-11 after user confirmed both compare modes belong in Phase 3. diff --git a/.planning/phases/04-local-ux-polish-performance-hardening/04-01-PLAN.md b/.planning/phases/04-local-ux-polish-performance-hardening/04-01-PLAN.md new file mode 100644 index 0000000..19a837a --- /dev/null +++ b/.planning/phases/04-local-ux-polish-performance-hardening/04-01-PLAN.md @@ -0,0 +1,47 @@ +--- +phase: 04-local-ux-polish-performance-hardening +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - frontend/src/utils/replayWorkspace.ts + - frontend/src/pages/ReplayPage.tsx + - frontend/src/components/TradeList.tsx + - frontend/src/test/replayWorkspace.test.ts + - frontend/src/test/replayPage.test.tsx + - frontend/src/test/tradeListSelection.test.tsx +autonomous: true +requirements: + - STAT-01 +--- + + +Persist the replay workspace/filter bootstrap so a later visit restores the last-used replay symbol/filter context without changing the existing replay-session precedence. + + + + + Task 1: Add replay workspace persistence helper and bootstrap wiring + frontend/src/utils/replayWorkspace.ts, frontend/src/pages/ReplayPage.tsx, frontend/src/components/TradeList.tsx, frontend/src/test/replayWorkspace.test.ts, frontend/src/test/replayPage.test.tsx, frontend/src/test/tradeListSelection.test.tsx + + - route seed beats replay session + - replay session beats workspace filter bootstrap + - workspace filter bootstrap beats latest-trade default + - restored symbol/filter does not get overwritten by default BTC/latest-trade auto-selection + + Write failing tests first, then add the smallest replay workspace helper and bootstrap wiring needed to restore the last-used replay filter state. + + cd frontend && pnpm exec vitest run src/test/replayWorkspace.test.ts src/test/replayPage.test.tsx src/test/tradeListSelection.test.tsx + + + + + +- `cd frontend && pnpm exec vitest run src/test/replayWorkspace.test.ts src/test/replayPage.test.tsx src/test/tradeListSelection.test.tsx` +- `cd frontend && pnpm build` + + + +After completion, create `.planning/phases/04-local-ux-polish-performance-hardening/04-01-SUMMARY.md` + diff --git a/.planning/phases/04-local-ux-polish-performance-hardening/04-01-SUMMARY.md b/.planning/phases/04-local-ux-polish-performance-hardening/04-01-SUMMARY.md new file mode 100644 index 0000000..c272d78 --- /dev/null +++ b/.planning/phases/04-local-ux-polish-performance-hardening/04-01-SUMMARY.md @@ -0,0 +1,23 @@ +# 04-01 Summary + +## Changed files +- `frontend/src/utils/replayWorkspace.ts` +- `frontend/src/pages/ReplayPage.tsx` +- `frontend/src/components/TradeList.tsx` +- `frontend/src/test/replayWorkspace.test.ts` +- `frontend/src/test/replayPage.test.tsx` +- `frontend/src/test/tradeListSelection.test.tsx` +- `frontend/src/test/setup.ts` +- `frontend/vitest.config.ts` +- `frontend/package.json` +- `frontend/pnpm-lock.yaml` + +## Verification +- `cd frontend && pnpm exec vitest run src/test/replayWorkspace.test.ts src/test/replayPage.test.tsx src/test/tradeListSelection.test.tsx` → 11 passed +- `cd frontend && pnpm build` → success +- `lsp_diagnostics` on changed TS/TSX files → clean + +## Decisions +- Added a dedicated replay workspace store for filter bootstrap without changing replay session/progress ownership. +- Kept precedence as `route seed > replay session > workspace filter bootstrap > latest trade default`. +- Prevented TradeList’s default BTC/latest-trade path from overriding a restored workspace symbol. diff --git a/.planning/phases/04-local-ux-polish-performance-hardening/04-02-PLAN.md b/.planning/phases/04-local-ux-polish-performance-hardening/04-02-PLAN.md new file mode 100644 index 0000000..c5ddd57 --- /dev/null +++ b/.planning/phases/04-local-ux-polish-performance-hardening/04-02-PLAN.md @@ -0,0 +1,48 @@ +--- +phase: 04-local-ux-polish-performance-hardening +plan: 02 +type: execute +wave: 2 +depends_on: + - 04-01 +files_modified: + - frontend/src/utils/replayWorkspace.ts + - frontend/src/components/ChartManager.tsx + - frontend/src/components/ReplayAnalyticsPanel.tsx + - frontend/src/test/replayLayoutPersistence.test.tsx + - frontend/src/test/replayAnalyticsPanel.test.tsx + - frontend/src/test/phase3ReplayIntegration.smoke.test.tsx +autonomous: true +requirements: + - STAT-01 +--- + + +Persist replay chart/layout preferences so the user returns to the same compare and analytics configuration, without trying to restore browser-only fullscreen state. + + + + + Task 1: Add replay layout persistence and restore wiring + frontend/src/utils/replayWorkspace.ts, frontend/src/components/ChartManager.tsx, frontend/src/components/ReplayAnalyticsPanel.tsx, frontend/src/test/replayLayoutPersistence.test.tsx, frontend/src/test/replayAnalyticsPanel.test.tsx, frontend/src/test/phase3ReplayIntegration.smoke.test.tsx + + - active timeframe restores + - compare mode / compare symbol / compare timeframe restore + - analytics panel open state restores + - fullscreen remains ephemeral and is not restored + + Write failing tests first, then extend the workspace helper and wire ChartManager/ReplayAnalyticsPanel to restore durable replay layout preferences only. + + cd frontend && pnpm exec vitest run src/test/replayLayoutPersistence.test.tsx src/test/replayAnalyticsPanel.test.tsx src/test/phase3ReplayIntegration.smoke.test.tsx + + + + + +- `cd frontend && pnpm exec vitest run src/test/replayLayoutPersistence.test.tsx src/test/replayAnalyticsPanel.test.tsx src/test/phase3ReplayIntegration.smoke.test.tsx` +- `cd frontend && pnpm build` + + + +After completion, create `.planning/phases/04-local-ux-polish-performance-hardening/04-02-SUMMARY.md` + diff --git a/.planning/phases/04-local-ux-polish-performance-hardening/04-02-SUMMARY.md b/.planning/phases/04-local-ux-polish-performance-hardening/04-02-SUMMARY.md new file mode 100644 index 0000000..3077d90 --- /dev/null +++ b/.planning/phases/04-local-ux-polish-performance-hardening/04-02-SUMMARY.md @@ -0,0 +1,19 @@ +# 04-02 Summary + +## Changed files +- `frontend/src/utils/replayWorkspace.ts` +- `frontend/src/components/ChartManager.tsx` +- `frontend/src/components/ReplayAnalyticsPanel.tsx` +- `frontend/src/test/replayLayoutPersistence.test.tsx` +- `frontend/src/test/replayAnalyticsPanel.test.tsx` +- `frontend/src/test/phase3ReplayIntegration.smoke.test.tsx` + +## Verification +- `cd frontend && pnpm exec vitest run src/test/replayLayoutPersistence.test.tsx src/test/replayAnalyticsPanel.test.tsx src/test/phase3ReplayIntegration.smoke.test.tsx` → 7 passed +- `cd frontend && pnpm build` → success +- `lsp_diagnostics` on changed TS/TSX files → clean + +## Decisions +- Extended the replay workspace record so layout writes merge with the existing filter bootstrap instead of overwriting it. +- Made active timeframe, compare mode/symbol/timeframe, and analytics panel open state durable. +- Explicitly kept fullscreen out of persistence so browser-only state is not restored as part of the replay workspace. diff --git a/.planning/phases/04-local-ux-polish-performance-hardening/04-03-PLAN.md b/.planning/phases/04-local-ux-polish-performance-hardening/04-03-PLAN.md new file mode 100644 index 0000000..a50678b --- /dev/null +++ b/.planning/phases/04-local-ux-polish-performance-hardening/04-03-PLAN.md @@ -0,0 +1,52 @@ +--- +phase: 04-local-ux-polish-performance-hardening +plan: 03 +type: execute +wave: 3 +depends_on: + - 04-01 + - 04-02 +files_modified: + - backend/services/backup_service.py + - backend/main.py + - backend/tests/test_backup_api.py + - frontend/src/services/api.ts + - frontend/src/components/import/LocalSafekeepingPanel.tsx + - frontend/src/pages/ImportPage.tsx + - frontend/src/test/localSafekeepingPanel.test.tsx + - frontend/src/test/import-route-smoke.test.tsx +autonomous: true +requirements: + - STAT-03 +--- + + +Add a user-facing local safekeeping surface that reuses the import-report CSV download and the SQLite backup capability, and adds a restore path for the same local backup format. + + + + + Task 1: Add local backup/export/restore entry point + backend/services/backup_service.py, backend/main.py, backend/tests/test_backup_api.py, frontend/src/services/api.ts, frontend/src/components/import/LocalSafekeepingPanel.tsx, frontend/src/pages/ImportPage.tsx, frontend/src/test/localSafekeepingPanel.test.tsx, frontend/src/test/import-route-smoke.test.tsx + + - user can download latest import report CSV from the safekeeping area + - user can download a SQLite backup generated from the current local DB + - user can restore from the same SQLite backup format + + Write failing backend and frontend tests first, then build the thinnest backup service/API plus ImportPage safekeeping panel that reuses the existing local-first capabilities. + + cd backend && uv run pytest tests/test_backup_api.py tests/test_migration_runner.py tests/test_startup_migrations.py + cd frontend && pnpm exec vitest run src/test/localSafekeepingPanel.test.tsx src/test/import-route-smoke.test.tsx + + + + + +- `cd backend && uv run pytest tests/test_backup_api.py tests/test_migration_runner.py tests/test_startup_migrations.py` +- `cd frontend && pnpm exec vitest run src/test/localSafekeepingPanel.test.tsx src/test/import-route-smoke.test.tsx` +- `cd frontend && pnpm build` + + + +After completion, create `.planning/phases/04-local-ux-polish-performance-hardening/04-03-SUMMARY.md` + diff --git a/.planning/phases/04-local-ux-polish-performance-hardening/04-03-SUMMARY.md b/.planning/phases/04-local-ux-polish-performance-hardening/04-03-SUMMARY.md new file mode 100644 index 0000000..6c456ee --- /dev/null +++ b/.planning/phases/04-local-ux-polish-performance-hardening/04-03-SUMMARY.md @@ -0,0 +1,32 @@ +# 04-03 Summary + +## Changed files +- `backend/services/backup_service.py` +- `backend/main.py` +- `backend/tests/test_backup_api.py` +- `frontend/src/services/api.ts` +- `frontend/src/components/import/LocalSafekeepingPanel.tsx` +- `frontend/src/components/import/ManualImportPanel.tsx` +- `frontend/src/pages/ImportPage.tsx` +- `frontend/src/test/localSafekeepingPanel.test.tsx` +- `frontend/src/test/import-route-smoke.test.tsx` +- `frontend/src/test/import-page-manual-entry.test.tsx` +- `backend/tests/test_trade_importer.py` +- `backend/tests/test_import_api.py` +- `frontend/src/test/import-api.contract.test.ts` + +## Verification +- `cd backend && uv run pytest tests/test_trade_importer.py tests/test_import_api.py tests/test_backup_api.py tests/test_migration_runner.py tests/test_startup_migrations.py` → 15 passed +- `cd frontend && pnpm exec vitest run src/test/localSafekeepingPanel.test.tsx src/test/import-route-smoke.test.tsx src/test/import-page-manual-entry.test.tsx src/test/import-api.contract.test.ts src/test/ImportPage.test.tsx` → passed +- `cd frontend && pnpm exec tsc -b` → success +- `cd frontend && pnpm build` → success +- Live QA: + - `POST /api/trades/import/rows` returns structured `ImportReport` + - `/import` shows manual entry + local safekeeping UI + - browser manual submission renders the shared report UI + - `/api/backups/download` returns an actual SQLite backup file + +## Decisions +- Added a row-based import ingress instead of introducing full trade CRUD or a browser workbook-writer dependency. +- Kept upload import intact and reused the same report pipeline for manual entry. +- Reused migration backup capability for user-facing local backup download and restore on `/import`. diff --git a/.planning/phases/04-local-ux-polish-performance-hardening/04-CONTEXT.md b/.planning/phases/04-local-ux-polish-performance-hardening/04-CONTEXT.md new file mode 100644 index 0000000..29c9dd6 --- /dev/null +++ b/.planning/phases/04-local-ux-polish-performance-hardening/04-CONTEXT.md @@ -0,0 +1,85 @@ +# Phase 4: Local UX Polish & Performance Hardening - Context + +**Gathered:** 2026-04-11 +**Status:** Ready for planning/execution + + +## Phase Boundary + +Phase 4 只做本地用户体验收口与数据保全: +- 恢复上次 replay workspace 的 layout / filters +- 提供用户可见的本地导出 / 备份 / 恢复入口 + +不扩展到云同步、多用户、远程存储或全新分析系统。 + + + + +## Implementation Decisions + +### Workspace 恢复边界 +- **D-04-01:** 继续复用现有 `replaySession` / `replayPlayback` 作为 trade seed 与 replay progress 的 durable source。 +- **D-04-02:** 新增独立的 replay workspace 存储层,只承载 layout / filters:symbol filter、active timeframe、compare mode、compare symbol、compare timeframe、analytics panel open state。 +- **D-04-03:** `route seed > replay session > workspace filter bootstrap > latest trade default` 的优先级保持不变。 +- **D-04-04:** fullscreen 不做 durable restore;它是浏览器瞬时状态,不属于可安全恢复的 workspace。 + +### 导出 / 备份边界 +- **D-04-05:** 复用现有 import report CSV 下载链路作为“明细导出”能力,不另造一套报表导出。 +- **D-04-06:** 复用现有 migration backup 逻辑提供用户可见的 SQLite backup 下载。 +- **D-04-07:** Phase 4 允许本地 restore 入口,但只支持与本地 SQLite backup 同格式的文件,不做跨格式导入。 +- **D-04-08:** 用户可见 safekeeping 入口放在 `/import` 页,因为它已经拥有 import report download 语义和数据保全入口。 + + + + +## Canonical References + +- `.planning/ROADMAP.md` +- `.planning/REQUIREMENTS.md` +- `.planning/STATE.md` +- `frontend/src/pages/ReplayPage.tsx` +- `frontend/src/components/TradeList.tsx` +- `frontend/src/components/ChartManager.tsx` +- `frontend/src/components/ReplayAnalyticsPanel.tsx` +- `frontend/src/utils/replaySession.ts` +- `frontend/src/utils/replayPlayback.ts` +- `backend/main.py` +- `backend/migrations/runner.py` +- `backend/services/trade_importer.py` + + + + +## Existing Code Insights + +### Already durable +- `ReplayPage.tsx` + `replaySession.ts` already persist the selected trade seed in `localStorage`. +- `ChartManager.tsx` + `replayPlayback.ts` already persist replay progress per trade. +- Import report CSV download already works end-to-end. +- Migration runner already creates SQLite backups before applying migrations. + +### Memory-only gaps +- `TradeList.tsx` keeps symbol/search selection in memory only. +- `ChartManager.tsx` keeps active timeframe, compare mode/symbol/timeframe, and fullscreen in memory only. +- `ReplayAnalyticsPanel.tsx` keeps open/closed state in memory only. + +### Product-safe scope +- The right panel layout now includes analytics + position details, so restoring analytics openness belongs to STAT-01. +- The import page is the safest place for backup/export UI because it already exposes report downloads and local-data lifecycle concepts. + + + + +## Deferred Ideas + +- Cloud sync or remote backup +- Multi-profile workspace presets +- Full database diff/merge restore flows +- Non-SQLite export formats for replay data + + + +--- + +*Phase: 04-local-ux-polish-performance-hardening* +*Context gathered: 2026-04-11* diff --git a/.planning/phases/04-local-ux-polish-performance-hardening/04-DISCUSSION-LOG.md b/.planning/phases/04-local-ux-polish-performance-hardening/04-DISCUSSION-LOG.md new file mode 100644 index 0000000..8f903ce --- /dev/null +++ b/.planning/phases/04-local-ux-polish-performance-hardening/04-DISCUSSION-LOG.md @@ -0,0 +1,21 @@ +# Phase 4 Discussion Log + +## Autonomous Discuss Outcome + +### Locked Defaults +- Replay seed and replay progress remain in their current dedicated stores. +- Phase 4 adds a separate replay workspace record for layout/filter persistence. +- Durable layout includes compare settings and analytics panel state; fullscreen stays ephemeral. +- User-facing backup/export/restore entry point lives on `/import`. + +### Reuse Strategy +- Reuse import report CSV download rather than inventing a new review-report export flow. +- Reuse migration backup creation rather than inventing a second backup format. + +### Scope Guardrails +- No cloud sync, no multi-user storage, no backend schema expansion for workspace state. +- Restore supports the same local SQLite backup format only. + +--- + +Approved for planning on 2026-04-11 from the Phase 4 worktree baseline. diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md new file mode 100644 index 0000000..0f149bf --- /dev/null +++ b/.planning/research/ARCHITECTURE.md @@ -0,0 +1,184 @@ +# Retraq Architecture Recommendation + +## 结论先说 + +Retraq 适合继续保持单体式本地应用,不需要引入微服务、消息总线、账号体系或复杂同步框架。最合理的边界是,后端只负责事实数据、导入清洗、行情缓存和少量汇总,前端只负责复盘编排、图表同步和轻量分析。 + +对这个个人本地工具来说,最重要的是把“交易记录”和“行情窗口”稳定对齐,把 Replay 做成一个可重复的工作流,而不是把系统做成通用交易台。 + +## 推荐的组件边界 + +### 1. 事实层 + +把 SQLite 当作唯一的持久事实层,核心只保留两类数据。 + +* `trades`,导入后的交易事实。 +* `klines`,拉取并缓存的行情事实。 + +其它内容都应视为派生数据,例如胜率、盈亏、持仓时长、交易对分布、回放初始窗口、默认比较周期。 + +### 2. 导入层 + +把 Excel 导入拆成三步。 + +* Intake,接收文件和字段映射。 +* Validate,做表头、时间、币对、方向、重复、空值检查。 +* Normalize,统一成内部交易模型后再写库。 + +这里建议保留一个轻量的隔离区概念。坏行不要直接污染主表,应该先进入失败列表或导入报告,再决定是否写入。这比直接“尽量写入”更适合复盘场景,因为用户更在意可追溯性。 + +### 3. 行情层 + +行情应该是有缓存的外部依赖,不是系统主数据源。 + +* 先查本地缓存。 +* 缺口再回源。 +* 回填后落库。 + +这样可以把网络抖动限制在行情补齐环节,而不会影响已经导入的交易复盘。对个人工具来说,这个边界已经足够,不需要再做专门的数据仓库或多级缓存。 + +### 4. Replay 编排层 + +Replay 最好是一个“会话控制器”,而不是一个巨型组件。 + +建议把它拆成四个职责。 + +* Trade selector,负责当前选中交易和列表筛选。 +* Window resolver,负责根据交易时间推导 K 线窗口。 +* Chart coordinator,负责主图、对比图、标记和 crosshair 同步。 +* Replay state,负责播放、暂停、步进、速度、回到上次状态。 + +当前 `ChartManager` 很适合沿这个方向拆。它现在承担的事情太多,最值得先抽出去的是“窗口推导”和“图表同步协议”。 + +### 5. 分析层 + +分析页保留前端本地计算是对的,但前提是只做轻量分析。 + +适合放前端的内容: + +* 总盈亏、胜率、盈亏比、回撤。 +* 平均持仓时长、时间分布、币对分布。 +* 只依赖导入交易本身的洞察。 + +适合留在后端的内容: + +* 需要复用的聚合结果。 +* 未来可能复用到多个页面的预计算指标。 +* 会明显拖慢浏览器渲染的重分析。 + +对当前项目,默认仍然是前端先算。因为这是个人本地工具,前端本地分析能减少一次请求,交互也更直接。只有当指标变重,或者多个页面都重复同一份计算时,才把那部分提到后端。 + +## 数据流建议 + +推荐的数据流是单向的。 + +1. Excel 导入进入后端导入层。 +2. 导入层验证后写入 `trades`。 +3. Replay 页读取交易列表,选择一笔交易。 +4. 窗口解析器根据交易时间和周期推导行情范围。 +5. 行情服务先查 `klines`,缺口回源后补齐。 +6. 图表协调器把主图、对比图、买卖点、价格线同步到 UI。 +7. 分析页读取交易事实并做本地二次计算。 + +这个流向有一个重要原则,交易是主对象,图表只是上下文。不要让图表状态反过来成为主事实,不然很容易把 Replay 变成一个难以维护的图表壳。 + +## 同步模式建议 + +### 图表同步 + +图表同步应该围绕一个协调器做,避免各图互相直接监听。 + +推荐同步项只有三类。 + +* 可见时间范围。 +* crosshair 位置。 +* 当前交易的标记和价格线。 + +实现上要避免反馈环。也就是说,A 图触发同步时,B 图更新不能再反向当成新用户输入。Lightweight Charts 官方文档已经支持用 `setCrosshairPosition` 和可见范围订阅来做这类同步,适合放在协调器里统一包一层。 + +### Replay 状态 + +Replay 状态建议保持单源。 + +* 当前交易。 +* 当前 symbol。 +* 当前 timeframe。 +* 当前播放进度。 +* 当前布局选择。 + +这些状态可以放在页面级 store 或父组件控制器里,不需要上全局复杂状态机。对个人工具来说,最好的同步是少同步,明确谁是主控,其他组件只消费。 + +### 导入同步 + +导入完成后只做两件事。 + +* 刷新交易列表。 +* 把导入报告留给用户复核。 + +不要在导入后顺手触发一堆重计算、全量行情回填或自动分析任务。那会让导入链路变长,也让错误更难定位。 + +## 本地存储和缓存边界 + +### 保持在后端本地存储的内容 + +* 交易记录。 +* 行情缓存。 +* 导入报告。 +* 需要跨会话保留的回放状态,如果确实要记住。 + +### 保持在前端内存或轻量本地存储的内容 + +* 当前筛选。 +* 当前选中交易。 +* 图表展示偏好。 +* 最近一次打开的布局。 + +这里不建议一开始就上 IndexedDB 或复杂客户端数据库。对于这个产品,前端持久化只要够记住布局和最近状态就行,核心数据仍然由后端 SQLite 负责。 + +## build-order 启示 + +按价值和风险排序,建议先做下面的顺序。 + +### 第一优先级 + +* 交易和行情的时间对齐。 +* Replay 页的单交易打开路径。 +* 图表协调器和标记同步。 + +这一步最能减少“图表和交易对不上”的核心痛点。 + +### 第二优先级 + +* 导入验证和行级错误报告。 +* 导入后回到列表并直达复盘。 +* 回放状态的基础持久化。 + +这一步让工具从“能用”变成“可靠可重复”。 + +### 第三优先级 + +* 轻量分析重整,保持纯函数化。 +* 多周期对比只作为 Replay 的辅助能力。 +* 仅在确有需要时,再补后端聚合接口。 + +### 暂时不要做 + +* 多用户、权限、协作。 +* 云同步和远程备份服务。 +* 消息队列和后台任务编排。 +* 完整 CQRS 或事件溯源。 +* 通用交易工作台式的大分析层。 + +这些东西对个人本地工具来说,收益太晚,复杂度太早。 + +## 对当前 brownfield 的落地建议 + +* 保持 `backend/main.py` 轻,不要继续把业务逻辑堆进路由层。 +* 把导入校验、窗口推导、图表协调、分析计算拆成清楚的小单元。 +* 让 `AnalysisPage` 继续用前端计算,但只保留复盘相关的指标。 +* 把 Replay 的核心复杂度收敛到一个明确的会话控制器里,而不是散落在多个组件之间。 +* 任何新能力都先问一句,它是不是在帮用户更快看懂一笔交易。如果不是,就先别加。 + +## 一句话原则 + +Retraq 不需要更大的平台,它需要更稳的边界。交易是事实,行情是上下文,Replay 是主流程,分析是辅助,其他复杂度都应该为这条主线让路。 diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md new file mode 100644 index 0000000..64cae85 --- /dev/null +++ b/.planning/research/FEATURES.md @@ -0,0 +1,48 @@ +# Retraq Feature Framing + +Retraq v1 is a personal, local-first crypto trade replay tool. The center of gravity is simple, import trades from Excel or manual files, open one trade fast, line it up with the right K-line window, compare a small number of timeframes, and review the outcome without leaving the app. + +## V1 should feel like + +* Import a sheet, fix any obvious mapping issues, and land in the replay list. +* Open one trade and jump straight into a replay view that already shows the right symbol, time window, and markers. +* Compare a few timeframes side by side without turning the app into a generic charting terminal. +* See only the metrics that help with review, not a wall of analytics. + +## Table stakes + +* Excel or manual import with sane validation, timezone handling, duplicate handling, and clear row level errors. +* Trade list with search, symbol filter, date filter, and quick open into replay. +* Replay controls, play and pause, step forward, speed control, and a way to resume from the last state. +* K-line view with correct trade markers, entry and exit context, and enough history to inspect the setup. +* Multi-timeframe comparison for the same symbol, synced well enough to answer, “What was the higher timeframe doing here?” +* Lightweight analytics, such as win rate, PnL, average hold time, drawdown, and symbol split. +* Local persistence for imports, replay state, and layout choices. + +## Differentiators + +* Local-first by default, no login, no cloud sync, no team workflow, and no hidden backend dependency for core use. +* Excel first import flow, because this product starts from the user’s own records, not from broker sync. +* Replay centered on imported trades, not on generic chart browsing. The trade is the primary object, the chart is the context. +* Multi-timeframe compare as a review aid, not as a full multi-chart workstation. +* Lightweight analytics that support replay decisions, instead of competing with the replay surface for attention. +* Clear gap handling when market history is missing, because external K-line coverage is not fully controllable. + +## Anti-features + +* No account system, permissions, sharing, or collaboration. +* No cloud sync, remote backup service, or public SaaS deployment work. +* No broker auto-sync as the v1 source of truth. +* No live trading, paper trading, order routing, or execution simulation. +* No education hub, course library, social feed, battles, leaderboard, or AI mentor. +* No heavy BI layer, custom dashboard builder, alert system, or broad strategy backtester. +* No tick replay, exotic chart types, or order book style depth visuals unless a later use case proves it matters. + +## Complexity and dependency notes for this brownfield repo + +* `backend/services/kline_service.py` depends on external exchange data through CCXT and local cache, so replay depth is bounded by what the source can provide. +* `backend/services/trade_importer.py` and `backend/import_data.py` already anchor the product to Excel style input and SQLite, so schema changes should stay additive and small. +* `frontend/src/components/ChartManager.tsx` is the main complexity hotspot. Any replay or multi-timeframe work should preserve one coherent trade centric flow. +* `frontend/src/pages/AnalysisPage.tsx` already does its own heavier front end analysis, so v1 should not pile more product logic into that path. +* The repo currently has no strong test harness and already carries lint or type debt, so feature work should stay narrow and verifiable. +* Because the product is personal and local, shipping value matters more than building infrastructure for future multi user needs. diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md new file mode 100644 index 0000000..910e3b4 --- /dev/null +++ b/.planning/research/PITFALLS.md @@ -0,0 +1,113 @@ +# Retraq 关键陷阱研究 + +这份笔记只针对当前产品方向,个人本地优先,Excel 和手动导入优先,核心目标是把单笔交易和对应行情快速、可靠地对齐后复盘。下面的“未来阶段”是建议映射,等正式 roadmap 出来后可以直接替换成阶段编号。 + +## 1. 交易时间和 K 线边界错位 + +现象:交易明明导入成功,但买入点或卖出点总是落在前一根或后一根 K 线上,尤其容易出现在分钟级、整点、收盘边界、跨天交易里。 + +警告信号:同一笔交易刷新后标记位置会变,切换 5m 和 1h 之后看起来又像“对了”,但换回来就偏,用户开始手工猜哪根是对的。 + +预防:存储时保留原始时间戳、标准化时间戳和显示时区三份信息,明确单边边界规则,固定到底是按开盘时间、收盘时间,还是区间内最接近的 bar 来对齐。边界样本要单独做回归测试。 + +建议阶段:交易对齐与回放核心阶段。 + +## 2. 时区和夏令时漂移 + +现象:同一份导入数据,在本机时区、UTC、交易所时区之间切换后,按天统计、按时段统计、日内复盘位置都会变化。 + +警告信号:日级统计的结果随系统时区变化,跨午夜的单子落到不同日期,或夏令时切换后图表和交易列表不再一致。 + +预防:数据库层只认 UTC,展示层单独负责时区转换,交易所时区和用户时区不要混用。把夏令时切换日、跨日、跨周末作为固定测试用例。 + +建议阶段:数据导入与标准化阶段。 + +## 3. 多周期同步看起来正确,实际上并不一致 + +现象:主图、对比图、不同周期图表看起来同步了,但高周期 bar 其实来自低周期重采样,或者低周期历史没加载完整,导致回放进度和视觉状态分离。 + +警告信号:切换周期后图表跳动明显,某些历史点直接报数据缺失,或高周期 bar 提前出现,看起来像“预测”了下一段走势。 + +预防:只保留一条明确的时间轴,所有周期都从同一份原始行情派生。高周期必须能解释自己如何聚合出来,未完成 bar 要明确标记,缺数据要显式提示,不要默默补成假值。 + +建议阶段:多周期图表与交互阶段。 + +## 4. 指标太漂亮,反而最危险 + +现象:胜率、盈亏比、收益曲线、最大回撤都很好看,但其实样本太少、手续费没算、滑点没算、仓位变化没算,或者只挑了一个顺风行情区间。 + +警告信号:删掉一两笔交易指标就大变样,用户开始把回放结果当成未来预测,或反复调筛选条件直到图表变得“完美”。 + +预防:指标默认展示样本数、成本假设、净值口径和日期区间。把回放结果定义成描述性工具,不要伪装成预测器。重要指标要能按市场状态分段看,而不是只给一个总数。 + +建议阶段:分析与指标阶段。 + +## 5. Excel 导入链路太脆 + +现象:表头一变、空行一多、日期格式一乱、币对命名不统一,导入就默默丢数据,或者导完才发现少了几笔。 + +警告信号:同一文件重复导入会产生重复记录,坏行只记了数量没告诉用户是哪一行,用户只能打开表格逐条猜。 + +预防:导入前先做预览和校验,按行返回错误原因,保留原始文件摘要和行级哈希,做到可重复导入且可重复排错。不要把“导入成功”定义成“文件解析没崩溃”。 + +建议阶段:数据导入与标准化阶段。 + +## 6. 过早引入同步和认证 + +现象:产品本来是单人本地复盘,却在早期就开始设计账号体系、云同步、权限分层、冲突解决、设备列表和后台同步任务。 + +警告信号:路线图里先出现登录和同步,再出现对齐准确率、导入可靠性和回放流畅度,数据库开始围绕用户、角色、分享建模,但单笔复盘体验还没稳定。 + +预防:把同步和认证明确放到后续评估阶段,v1 只保留本地 SQLite、文件导入、导出和备份。除非真的出现多设备同时编辑的需求,否则不要把“未来可能有”误当成当前必需。 + +建议阶段:扩展与协作评估阶段。 + +## 7. 图表交互复杂度失控 + +现象:图表一边要画交易标记,一边要对比多周期,一边要全屏,一边要看仓位和分析,最后用户不知道现在最重要的动作是什么。 + +警告信号:进入复盘页后 30 秒还没开始看单笔交易,控制项越来越多,但真正帮助定位交易的控件却被埋在更深层里。 + +预防:默认路径只能有一条,从导入到选中交易再到回放。先保证“看懂这笔单子”,再考虑对比、画线、指标和花哨面板。任何新控件都要回答一个问题,是否真的让这笔交易更容易看懂。 + +建议阶段:多周期图表与交互阶段。 + +## 8. SQLite 和自动建表会吃掉历史兼容性 + +现象:字段改了、表结构改了、统计口径改了,旧数据库就打不开,或者打开了但历史数据不完整,用户只能删库重导。 + +警告信号:每次改模型都要手工清库,旧数据一升级就丢,或者某些老记录在新页面里突然变成空值。 + +预防:尽早加迁移层,版本化 schema,保留原始导入记录,不要让统计口径和存储结构绑死。新版本上线前,至少要用一份旧数据库做回归。 + +建议阶段:本地存储演进阶段。 + +## 9. 本地分析太重,页面开始慢 + +现象:交易历史一多,前端计算、图表重绘、筛选和统计全挤在浏览器里,界面开始卡,用户会把“慢”误认为“复杂”,然后放弃使用。 + +警告信号:切换筛选要等几秒,图表缩放拖动变顿,分析页每次改条件都整页重算。 + +预防:把重计算拆成可缓存的派生层,设置数据量阈值,重型聚合提前算,图表只拿当前视图需要的数据。性能预算要和功能需求一起写,不要等页面卡了才补。 + +建议阶段:分析与指标阶段,外加性能加固子阶段。 + +## 10. 复盘工具被误做成交易预测器 + +现象:用户拿回放结果去推断未来胜率,团队也开始用“准不准”来评价工具,而不是评价它能不能帮助更快、更清楚地复盘单笔交易。 + +警告信号:文案和产品决策都在强调预测能力,用户开始要求“自动告诉我下一单怎么做”,但最基础的对齐和回看体验还不稳。 + +预防:从一开始就把产品定义成复盘和分析工具,而不是信号器。所有指标都要强调“解释过去”,不要暗示“预测未来”。 + +建议阶段:产品叙事与分析阶段。 + +## 研究结论 + +对这个项目来说,最危险的不是功能不够多,而是过早把复杂度放到同步、认证、多用户和高级分析上。v1 的正确方向是先把导入、时间对齐、单笔回放、时区一致性和指标口径做稳,再决定哪些扩展真的值得进路线图。 + +## 主要参考 + +1. TradingView Bar Replay 官方帮助页,重点是多图同步、历史深度和 replay 状态。 +2. FX Replay 的 backtesting pitfalls 与 perfect backtest 文章,重点是过拟合、成本、执行现实和假完美指标。 +3. Ink & Switch 的本地优先文章,以及 RxDB 的本地优先 downside 总结,重点是离线、存储、冲突、迁移、性能和 auth 复杂度。 diff --git a/.planning/research/STACK.md b/.planning/research/STACK.md new file mode 100644 index 0000000..711b428 --- /dev/null +++ b/.planning/research/STACK.md @@ -0,0 +1,135 @@ +# Retraq 2026 Stack Recommendation + +## Recommendation + +Retraq should stay a local first React SPA with FastAPI, SQLite, and Lightweight Charts. For v1, the right move is to harden the current brownfield stack, not replatform it. + +The standard 2026 shape for this app is a narrow, durable stack with strong schema discipline and a small amount of client side state management, not a bigger framework or a service split. + +## Keep from the current stack + +1. `React 19 + TypeScript + Vite + React Router` + +This is still the right front end shape for a personal replay tool. The app is interactive, mostly client rendered, and browser based. React docs still support SPA style apps, and React Router v7 already fits the Vite path. Do not move to Next.js, TanStack Start, or another server heavy React framework for v1. + +2. `Tailwind CSS v4 + DaisyUI` + +This is a good enough UI layer for a local tool. Tailwind v4’s current Vite path is CSS first and already matches the repo direction. Keep the styling stack stable and avoid a redesign of the component system while replay quality is still the main product problem. + +3. `FastAPI + SQLAlchemy 2 + Uvicorn` + +This is the right backend shape for a local monolith. FastAPI remains a good fit for small typed APIs, and SQLAlchemy 2 gives enough control for query shape and future schema work. Keep the backend simple and add stricter request and response models only where the contract is painful. + +4. `SQLite` + +SQLite is still the correct primary store for this product. The app is personal, local, and small enough that the operational cost of Postgres would buy very little right now. The real requirement is schema discipline, not a different database brand. + +5. `Lightweight Charts` + +Keep it. It is client side, fast, and good for candlestick replay. Use it as the replay surface, not as a general charting platform. For replay and multi timeframe review, this is a better fit than a heavier chart stack. + +6. `pandas + openpyxl + CCXT` + +Keep `pandas` and `openpyxl` for Excel import. Keep `CCXT` as a market data fill layer only. The source of truth for v1 should remain imported trade history, with exchange data acting as cache and context, not as the core product dependency. + +## Add soon, but do not replatform around it + +1. `Alembic` + +This is the main backend gap. `backend/main.py` currently calls `Base.metadata.create_all(...)` at startup, which is fine for a toy app but not for a schema that will keep changing. Alembic is the standard upgrade path, and SQLite batch migrations are the right way to handle future table changes. + +2. `TanStack Query` + +This is the best near term front end addition. It will help with trade list pagination, kline cache windows, and replay state refresh without turning components into fetch logic soup. Keep Axios or swap later, but add a proper server state layer soon. + +3. SQLite connection guardrails + +Enable `PRAGMA foreign_keys = ON`, use a sensible `busy_timeout`, and consider WAL if imports and reads start colliding. This is cheap insurance for a local app that may import while the user is browsing replay state. + +4. Small test stack, once the schema settles + +Add `pytest` for backend paths and `Vitest` or `Playwright` for a thin replay smoke path only after the data model is versioned. Testing matters here, but it should follow the schema boundary, not lead it. + +## Avoid changing now + +1. `Next.js` or any full stack React rewrite + +The app does not need server components, SSR, or route level data orchestration yet. The current problem is replay fidelity, not rendering strategy. + +2. `Electron` or `Tauri` + +The app already has a local browser based workflow and a Python backend. A desktop shell would add packaging work without improving replay accuracy. + +3. `Postgres`, remote sync, or a broker first source of truth + +Manual import is still the correct v1 input. Move to sync only after replay and import quality feel boringly reliable. + +4. Microservices, queues, or background infrastructure + +There is not enough workload here to justify service split. Keep the monolith and harden the data boundaries first. + +5. A different charting library + +Do not swap chart engines while replay logic is still evolving. The value is in correct trade alignment and time window control, not in more chart features. + +6. Broad front end state management rewrite + +Do not introduce Redux or a similar global rewrite. Add a small query layer first, then only add local state tools if replay state still becomes tangled. + +## Where the current repo is already aligned + +1. `frontend/package.json` already sits on React 19, TypeScript, Vite 7, React Router 7, Tailwind v4, and Lightweight Charts. That is the right base for this product. + +2. `backend/pyproject.toml` already uses FastAPI, SQLAlchemy 2, SQLite, `pandas`, and `openpyxl`. That matches the import first, local first product direction. + +3. `README.md` and `.planning/PROJECT.md` both reinforce the correct product scope, local use, Excel or manual import, and replay first priority. + +4. `frontend/src/components/ChartManager.tsx` and the replay page already reflect the core product shape, which is a trade centered review surface, not a generic dashboard. + +5. `pnpm` and `uv` are the right package managers for this repo. Keep them. + +## Where the repo is misaligned + +1. `backend/main.py` still uses `Base.metadata.create_all(...)` on startup. That is the biggest technical gap because it blocks safe schema evolution. + +2. The backend has no visible migration layer yet. That is the first stack level improvement to make before adding more tables or replay metadata. + +3. The SQLite connection behavior is not yet shaped around durability. Foreign keys, busy handling, and write concurrency should be made explicit. + +4. The front end still carries a few large, imperative components. This is not a reason to replatform, but it is a reason to split replay logic from fetch logic and marker rendering. + +5. The current test and type checking story is too thin for a product whose main job is data alignment. That is a quality gap, not a stack rewrite trigger. + +6. `allow_origins=["*"]` in `backend/main.py` is acceptable for local use, but it is another sign that the backend is still tuned for a personal machine, not for any public exposure. + +## Roadmap shape this stack suggests + +1. First, lock down schema evolution with Alembic and a stable import contract. + +2. Second, add a proper server state layer with TanStack Query so replay and kline loading stop leaking into component logic. + +3. Third, tighten SQLite pragmas and indexes, then keep replay state and chart state in smaller modules. + +4. Only after the replay path feels solid should the project consider heavier analytics storage, sync, or a desktop shell. + +## Confidence notes + +1. High confidence. Keep the current core stack. The product constraints are local, personal, and import first, so there is no strong reason to replatform. + +2. High confidence. Add Alembic soon. The current `create_all` approach is the clearest brownfield risk in the repo. + +3. Medium confidence. Add TanStack Query soon. It is a good fit for the replay and kline fetch patterns, but it should stay lightweight. + +4. Low confidence. Consider DuckDB or a desktop wrapper only if data volume, offline search, or OS level integration becomes a real requirement later. + +## Sources used + +1. React docs, `https://react.dev/learn/start-a-new-react-project` +2. Tailwind CSS docs, `https://tailwindcss.com/docs/installation/using-vite` +3. FastAPI docs, `https://fastapi.tiangolo.com/tutorial/sql-databases/` +4. Alembic docs, `https://alembic.sqlalchemy.org/en/latest/tutorial.html` +5. Alembic SQLite batch migration docs, `https://alembic.sqlalchemy.org/en/latest/batch.html` +6. SQLite foreign key docs, `https://www.sqlite.org/foreignkeys.html` +7. SQLite pragma docs, `https://www.sqlite.org/pragma.html#pragma_journal_mode` +8. Lightweight Charts docs, `https://tradingview.github.io/lightweight-charts/docs` +9. TanStack Query docs, `https://tanstack.com/query/latest/docs/framework/react/overview` diff --git a/.planning/research/SUMMARY.md b/.planning/research/SUMMARY.md new file mode 100644 index 0000000..0784ae2 --- /dev/null +++ b/.planning/research/SUMMARY.md @@ -0,0 +1,153 @@ +# Project Research Summary + +**Project:** Retraq +**Domain:** 本地优先的 crypto trade replay / 复盘工具 +**Researched:** 2026-04-11 +**Confidence:** HIGH + +## Executive Summary + +Retraq is not a trading terminal or a general analytics platform; it is a personal, local-first replay tool whose core job is to make one imported trade line up cleanly with the right market window and make the review flow feel immediate. Experts would build this as a small, durable monolith: a React SPA on the client, a FastAPI + SQLite backend for facts/import/cache, and a replay-centric UI that treats the trade as the primary object and the chart as context. + +The strongest recommendation across the research is to harden the current brownfield stack rather than replatform. The main risks are not missing features, but incorrect time alignment, shaky Excel import behavior, and premature complexity around sync, collaboration, or heavy analytics. The right mitigation is schema discipline, explicit import validation, a single replay orchestration path, and a strict local-first scope. + +## Key Findings + +### Recommended Stack + +Keep the current stack. It already matches the product shape: interactive, browser-based replay on the front end; typed local APIs and SQLite on the back end; and lightweight charting for candlestick review. The immediate stack gap is migration discipline, not a new framework. + +**Core technologies:** +- `React 19 + TypeScript + Vite + React Router`:适合 SPA 复盘体验,没必要在 v1 切到 Next.js 之类的重框架。 +- `Tailwind CSS v4 + DaisyUI`:足够支撑本地工具 UI,先稳定交互,不要重做设计系统。 +- `FastAPI + SQLAlchemy 2 + Uvicorn`:适合小而清晰的 typed API 层,继续保持单体后端。 +- `SQLite`:本地个人工具的正确事实库,关键是 schema 纪律而不是换数据库。 +- `Lightweight Charts`:适合作为 replay 主图,不要在 v1 换图表引擎。 +- `pandas + openpyxl + CCXT`:Excel 导入和行情补齐的合理组合,CCXT 只是上下文缓存,不是主数据源。 +- `Alembic`:需要尽快补上,解决 `create_all` 带来的 schema 演进风险。 +- `TanStack Query`:适合 trade list、kline 窗口和 replay state 的 server state 管理。 + +### Expected Features + +v1 的中心体验是:导入交易、快速打开单笔 trade、自动对齐到正确 K 线窗口、对比少量 timeframe、并用有限指标完成复盘。不要把产品做成通用 charting terminal。 + +**Must have (table stakes):** +- Excel / manual import with validation、timezone 处理、去重、行级错误。 +- Trade list:搜索、symbol filter、date filter、快速进入 replay。 +- Replay controls:play/pause、step、speed、resume last state。 +- K-line view:正确 trade markers、entry/exit context、足够历史窗口。 +- Multi-timeframe comparison:回答“higher timeframe 当时在做什么”。 +- Lightweight analytics:win rate、PnL、average hold time、drawdown、symbol split。 +- Local persistence:imports、replay state、layout choices。 + +**Should have (competitive):** +- Local-first 默认、无登录、无云同步、无协作。 +- Excel first 导入流。 +- Replay centered on imported trades,而不是泛化看盘。 +- Multi-timeframe compare 只作为复盘辅助。 +- 缺行情时有明确 gap handling。 + +**Defer (v2+):** +- 账号系统、权限、分享、协作。 +- 云同步、远程备份、公开 SaaS 化。 +- Broker auto-sync 作为 v1 source of truth。 +- Live trading / paper trading / order routing / execution simulation。 +- 教学、社区、leaderboard、AI mentor。 +- 重 BI、策略回测、复杂告警、tick replay、order book 深度图。 + +### Architecture Approach + +Architecturally,最合理的边界是:SQLite 保存事实数据,导入层负责清洗和归一化,行情层负责缓存与补齐,前端负责 replay 编排和轻量分析。Replay 应该收敛成一个会话控制器,而不是散落在多个组件里的状态拼图。 + +**Major components:** +1. `事实层` — 只保留 `trades` 和 `klines`,其他都当派生数据。 +2. `导入层` — Intake / Validate / Normalize 三步,坏行先进入报告或失败列表。 +3. `行情层` — 先查本地缓存,缺口再回源,再回填落库。 +4. `Replay 编排层` — Trade selector、Window resolver、Chart coordinator、Replay state 四个职责。 +5. `分析层` — 前端先算轻量指标,重聚合再考虑后端化。 + +### Critical Pitfalls + +1. **交易时间和 K 线边界错位** — 保留原始时间戳、标准化时间戳和显示时区,统一边界规则,并对分钟级/跨日样本做回归。 +2. **时区和夏令时漂移** — 数据库只认 UTC,展示层单独做转换,固定 DST/跨午夜测试。 +3. **多周期同步看起来对,实际上不一致** — 统一时间轴,所有周期从同一份原始行情派生,避免 feedback loop。 +4. **Excel 导入链路太脆** — 先预览再写入,行级报错、原始摘要、可重复导入与排错。 +5. **SQLite / 自动建表吃掉历史兼容性** — 尽早上 Alembic,版本化 schema,不要让旧库靠运气运行。 + +## Implications for Roadmap + +### Phase 1: Import Reliability + Schema Evolution +**Rationale:** 先把数据入口和数据库演进稳定住,否则后面所有 replay 和分析都会建立在不可信的事实层上。 +**Delivers:** Excel/manual import 预览、行级校验、去重、UTC 标准化、导入报告、Alembic migrations。 +**Addresses:** import validation、timezone handling、duplicate handling、local persistence。 +**Avoids:** Excel 导入脆弱、时区漂移、SQLite 历史兼容性丢失。 + +### Phase 2: Single-Trade Replay Core +**Rationale:** 这是产品主价值,必须先把“打开一笔单子并对齐到正确窗口”做稳。 +**Delivers:** trade selector、window resolver、单笔打开路径、replay state、trade markers、resume last state。 +**Uses:** FastAPI + SQLite facts、Lightweight Charts、Chart coordinator。 +**Implements:** replay 编排层。 +**Avoids:** 交易时间/K 线边界错位、图表状态失控。 + +### Phase 3: Multi-Timeframe Compare + Lightweight Analytics +**Rationale:** 在单笔 replay 稳定后,再增加辅助视角和描述性指标,避免把复杂度提前塞进主流程。 +**Delivers:** 多周期对比、crosshair/visible range 同步、win rate/PnL/hold time/drawdown/symbol split。 +**Uses:** TanStack Query、前端本地计算、缓存化派生数据。 +**Implements:** chart coordinator + analysis layer。 +**Avoids:** 多周期看似同步但实际不一致、指标“太漂亮”误导用户。 + +### Phase 4: Performance Hardening + Local UX Polish +**Rationale:** 当 replay 主链路可靠后,再处理量增带来的卡顿和细节磨损。 +**Delivers:** 计算缓存、查询分层、局部重绘控制、导入后快速回到列表、布局记忆与细节 polish。 +**Uses:** SQLite pragmas / indexes、前端 store 轻量化。 +**Implements:** 本地存储与缓存边界。 +**Avoids:** 本地分析过重、页面变慢、过度状态管理。 + +### Phase Ordering Rationale + +- 先做导入和 schema,因为事实层不稳,后面的 replay 再好看也不可信。 +- 先做单笔 replay,再做多周期和分析,因为产品的中心价值是“看懂这笔单子”。 +- 把 chart sync 和 replay state 收进单一协调器,避免组件之间互相监听造成反馈环。 +- 明确推迟 sync、协作、云化和重 BI,避免早期被平台化复杂度拖偏。 + +### Research Flags + +Phases likely needing deeper research during planning: +- **Phase 2:** chart synchronization edge cases、Lightweight Charts replay/window behavior、边界对齐规则。 +- **Phase 3:** multi-timeframe aggregation consistency、指标口径与样本展示。 + +Phases with standard patterns (skip research-phase): +- **Phase 1:** Alembic migration、SQLite batch migration、导入校验与行级报错。 +- **Phase 4:** performance hardening、local state refinement。 + +## Confidence Assessment + +| Area | Confidence | Notes | +|------|------------|-------| +| Stack | HIGH | 研究文件和现有 repo 都一致指向同一套本地单体栈,且 brownfield 方向明确。 | +| Features | HIGH | v1 范围收敛清晰:导入、单笔 replay、多周期辅助、轻量分析。 | +| Architecture | HIGH | 组件边界、数据流和职责划分在三份研究里高度一致。 | +| Pitfalls | HIGH | 主要风险集中在对齐、导入、时区、同步和迁移,识别明确。 | + +**Overall confidence:** HIGH + +### Gaps to Address + +- **Replay 对齐规则的最终口径**:在 Phase 2 规划时明确按开盘、收盘还是最近 bar 对齐,并用边界样本验证。 +- **多周期数据来源细节**:在 Phase 3 规划时确认原始行情的聚合与缺口提示策略,避免“看起来同步”的假一致。 +- **性能阈值**:在 Phase 4 规划时用实际交易量定义前端计算和重绘的阈值,而不是凭感觉优化。 + +## Sources + +### Primary (HIGH confidence) +- `.planning/research/STACK.md` — stack recommendation、current repo alignment、roadmap shape +- `.planning/research/FEATURES.md` — table stakes、differentiators、anti-features +- `.planning/research/ARCHITECTURE.md` — component boundaries、data flow、sync model +- `.planning/research/PITFALLS.md` — alignment/timezone/import/migration/performance risks + +### Secondary (MEDIUM confidence) +- `README.md` — product scope and local-first positioning already consistent with research + +--- +*Research completed: 2026-04-11* +*Ready for roadmap: yes* diff --git a/AGENTS.md b/AGENTS.md index 9d849b7..0df4e76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,3 +55,152 @@ Usage notes: + + +## Project + +**Retraq** + +Retraq 是一个面向我自己本地使用的加密货币交易复盘工具,用来把历史交易记录和对应行情对齐后做快速回看。当前仓库已经有前后端 MVP,但接下来的项目方向会收敛到一条更明确的主线:以 Excel / 手动导入为主,把单笔交易复盘体验做到顺手、可靠、可重复。 + +**Core Value:** 导入一笔历史交易后,能够快速把它和对应 K 线、买卖点、时间区间对齐,并顺畅完成一次高质量复盘。 + +### Constraints + +- **Tech stack**: 继续沿用当前 React + TypeScript + Vite 前端,以及 FastAPI + SQLAlchemy + SQLite 后端 — 这是现有代码和运行脚本已经建立的基础 +- **Data entry**: v1 以 Excel / 手动导入为主 — 这是当前用户目标和现有仓库能力的交集 +- **Runtime model**: 本地运行优先,不按公网服务设计 — README 已明确不建议直接暴露到公网 +- **Market data dependency**: K 线数据依赖外部交易所接口(当前实现通过 CCXT / OKX 等) — 这决定了市场数据可用性与历史覆盖范围受外部服务约束 +- **Product scope**: 复盘体验优先于自动同步、教育内容和多用户能力 — 这是当前项目的核心取舍 + + + +## Technology Stack + +## 运行时 / 包管理 +- Python 3.11+(`backend/pyproject.toml`, `README.md`) +- Node.js 18+、pnpm、uv(`README.md`, `start.sh`, `start.bat`) +- 前后端独立管理:后端锁文件 `backend/uv.lock`,前端锁文件 `frontend/pnpm-lock.yaml`;仓库根目录未见独立 `package.json` / workspace 文件(当前扫描结果) +## 后端 +- FastAPI + Uvicorn + SQLAlchemy 2 + CCXT + pandas + openpyxl + python-multipart + pytz(`backend/pyproject.toml`) +- 入口:`backend/main.py`(API)和 `backend/import_data.py`(示例数据导入) +- Excel 导入链路依赖 pandas/openpyxl;上传接口依赖 multipart 表单(`backend/services/trade_importer.py`, `backend/main.py`) +## 前端 +- React 19 + TypeScript + Vite 7 + React Router 7 + axios + lightweight-charts + lucide-react + Tailwind CSS v4 + DaisyUI(`frontend/package.json`, `frontend/vite.config.ts`, `frontend/src/index.css`, `frontend/src/App.tsx`) +- 构建链路:`tsc -b && vite build`;本地预览:`vite preview`(`frontend/package.json`, `start.sh`, `start.bat`) +## 存储 +- 本地 SQLite:`sqlite:///./trading.db`(`backend/database.py`) +- 表:`klines`、`trades`;启动时自动 `Base.metadata.create_all`(`backend/models.py`, `backend/main.py`, `backend/import_data.py`) +- 索引:`klines(symbol,timeframe,timestamp)` 唯一索引,`trades(symbol)` 普通索引(`backend/models.py`) +## 本地运行假设 +- 后端命令默认在 `backend/` 目录内执行,入口直接是 `uvicorn main:app`(`start.sh`, `start.bat`) +- 前端开发代理将 `/api` 转发到 `http://localhost:9527`(`frontend/vite.config.ts`) +- 启动脚本会在首次运行时导入根目录 `1.xlsx` 示例数据(`README.md`, `backend/import_data.py`) + + + +## Conventions + +## Repo shape +- Monorepo-style split: `frontend/` for React UI and `backend/` for FastAPI services. +- Root scripts (`start.sh`, `start.bat`) are the main cross-platform entry points. +- `README.md` documents the intended local workflow and the sample data import flow. +## Tooling choices +- Frontend package manager: `pnpm` (`frontend/pnpm-lock.yaml` is present). +- Backend environment/dependency manager: `uv` (`backend/uv.lock`, `README.md`, and `start.sh` use `uv sync`). +- Backend packaging: `backend/pyproject.toml` uses `hatchling` as the build backend. +- Frontend build stack: React 19 + TypeScript + Vite + TailwindCSS + DaisyUI + Lightweight Charts. +- Backend stack: FastAPI + SQLAlchemy + SQLite + CCXT (OKX integration noted in `README.md`). +## Frontend coding conventions +- Components and pages use PascalCase file names under `src/components/` and `src/pages/`. +- Non-UI modules use camelCase file names under `src/services/` and `src/utils/`. +- Layout is organized by responsibility: `components/`, `pages/`, `services/`, `utils/`. +- `src/services/api.ts` centralizes API access and shared types. +- `src/utils/tradeAnalysis.ts` keeps analysis logic pure and separate from UI. +- Styling is utility-first; `src/index.css` uses Tailwind v4 CSS-first theme tokens and DaisyUI classes. +## Backend coding conventions +- Backend code is organized by module responsibility: `main.py`, `database.py`, `models.py`, and `services/`. +- Service modules separate importer, analysis, symbol, and kline concerns. +- The codebase follows explicit domain naming (`trade_importer`, `trade_analyzer`, `kline_service`). +- `import_data.py` is a standalone script and adjusts `sys.path`, which signals script-first local execution. +## Observed consistency +- Strong separation between UI, API, and pure logic helpers. +- Strict TypeScript settings are used in the frontend configs. +- Root docs and startup scripts consistently describe a local-first workflow. +## Observed inconsistencies / quality gaps +- `frontend/tailwind.config.js` still uses CommonJS while the rest of the frontend is moving toward CSS-first Tailwind v4 configuration. +- Lint coverage is incomplete: `frontend/eslint.config.js` is present, but it is not type-aware. +- The frontend has several larger, more imperative components (`ChartManager.tsx`, `AnalysisPage.tsx`) compared with the otherwise modular structure. +- There is no visible automated test harness in the inspected files. +- Current diagnostics are not clean: frontend shows Biome lint findings, and backend shows BasedPyright import/type issues. +- Backend engineering constraints stay lightweight: no visible migration tool, no visible lint/test config, and `main.py` / `import_data.py` perform `create_all` at runtime. + + + +## Architecture + +## 1. 系统边界 +- 前端:React SPA,入口是 `frontend/src/main.tsx:1-13`,路由壳在 `frontend/src/App.tsx:7-18` +- 后端:FastAPI 服务,入口是 `backend/main.py:17-129` +## 2. 后端:数据与计算边界 +- `GET /api/klines/{symbol}/{timeframe}`:返回 K 线数据 +- `POST /api/trades/import`:上传 Excel 交易单并入库 +- `GET /api/trades`:分页返回交易记录 +- `GET /api/stats/overview`:返回汇总统计 +### 2.1 数据模型 +- `Kline`:行情缓存表,按 `symbol + timeframe + timestamp` 做唯一索引 +- `Trade`:复盘交易表,保存方向、杠杆、开平仓价格、盈亏、收益率、时间戳等字段 +### 2.2 服务层职责 +- `backend/services/kline_service.py:18-306`:行情获取与缓存层。它按时间范围查 SQLite,缺口再用 `ccxt` 从外部交易所回填,随后 upsert 到 `Kline` 表。 +- `backend/services/trade_importer.py:21-100`:Excel 导入层。它把中文表头映射到 `Trade` 字段,做方向、币对、时间戳等清洗后写库。 +- `backend/services/trade_analyzer.py:7-75`:后端汇总统计层。它从 `Trade` 计算总盈亏、胜率、盈亏比、回撤、平均持仓时长和币对分布。 +- `backend/services/symbol_utils.py:6-31`:币对规范化与校验层,保证查询和导入使用统一格式。 +### 2.3 外部数据源 +## 3. 前端:路由与视图编排边界 +- `frontend/src/main.tsx:1-13` 负责挂载 React 和 `BrowserRouter` +- `frontend/src/App.tsx:7-18` 定义三个页面路由:`/replay`、`/analysis`、`/learn` +- `frontend/src/components/Navbar.tsx:4-64` 提供全局导航壳 +### 3.1 复盘页 +- `TradeList`:左侧交易列表与交易对筛选 +- `ChartManager`:中间 K 线与对比图 +- `PositionDetails`:右侧仓位详情 +### 3.2 分析页 +- 时间分析 +- 行为分析 +- 风险分析 +- 币对分析 +### 3.3 学习页 +### 3.4 传输边界 +- `fetchKlines()`:请求 `/api/klines/...`,把后端毫秒时间戳转换成轻量图表使用的秒级时间 +- `fetchTrades()`:分页拉取 `/api/trades` +- `importTrades()`:上传 Excel 到 `/api/trades/import` +- `fetchStats()`:读取 `/api/stats/overview` +## 4. 数据流:从源头到 UI +## 5. 心智模型 + + + +## Project Skills + +No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, or `.github/skills/` with a `SKILL.md` index file. + + + +## GSD Workflow Enforcement + +Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync. + +Use these entry points: +- `/gsd-quick` for small fixes, doc updates, and ad-hoc tasks +- `/gsd-debug` for investigation and bug fixing +- `/gsd-execute-phase` for planned phase work + +Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it. + + + +## Developer Profile + +> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile. +> This section is managed by `generate-claude-profile` -- do not edit manually. + diff --git a/README.md b/README.md index 5450970..1a14b9a 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,37 @@ # Retraq -bit浪浪的交割单复盘系统,帮助你回顾和分析历史交易记录。 +Retraq 是一个本地优先的加密货币交易复盘工具。v1 围绕一条核心主线展开:导入历史交易,快速落到正确的 K 线窗口,补充少量对比与分析,然后把复盘上下文稳定地留在本地,方便反复回看。 ## 截图 +### 导入工作台 + +支持 Excel 上传、最小手动录入、行级导入报告,以及本地 SQLite 备份/恢复入口。 + +![导入工作台](docs/images/import.png) + +### 复盘页面 + +支持单笔交易 replay、买卖点标注、播放控制、同交易对多周期对比、同周期多交易对对比,以及轻量分析面板。 + ![复盘页面](docs/images/replay.png) +### 分析页面 + +提供独立的统计视图,补充时间、行为、风险和币对分布等信息。 + ![分析页面](docs/images/analysis.png) ## 功能 -- 📈 K线图表 - 从 OKX 获取实时行情数据,支持多周期(5m/15m/1h/4h/1d) -- 🔀 多周期多交易对对比 - 同时查看不同时间周期和交易对的走势 -- 📍 买卖点标注 - 明确标注买入卖出点和均价,直观复盘每笔交易 -- 📊 交易分析 - 统计胜率、盈亏比、收益曲线等关键指标 -- 📚 学习模块 - 学习浪哥的交易系统(通过所有复盘提取视频提取) +- 📥 导入工作台:支持 `.xlsx / .xls` 上传,以及最小手动录入;导入后返回统一的成功 / 失败 / 重复 / 冲突报告 +- 📄 行级导入报告:失败行、重复行、冲突行、时间归一事件和 CSV 明细导出都可直接查看 +- ▶️ 单笔交易 Replay:打开任意一笔交易后,自动落到正确 symbol / 时间窗 / 默认周期,并支持 `play / pause / step / speed` +- 📍 买卖点标注:主图显示开平仓标记与关键价格线,便于快速回看一笔交易的上下文 +- 🔀 双 compare 模式:既支持同一交易对的多周期对比,也支持同一周期下的多交易对对比 +- 📊 轻量分析:在 replay 侧边栏内查看收益曲线、胜率、盈亏比、交易时段分布、币对分布和回撤等指标 +- 💾 本地状态恢复:恢复上次的 replay workspace、比较模式、分析面板状态和逐笔 replay 进度 +- 🛟 本地数据保全:支持 SQLite 备份下载、同格式恢复,以及导入明细 CSV 下载 ## 技术栈 @@ -86,6 +103,7 @@ pnpm preview --port 9528 & - 本项目为个人学习工具,数据存储在本地 SQLite - K 线数据来自 OKX 公开 API,请遵守交易所服务条款 - 不建议直接暴露到公网,如需公开部署请自行添加认证 +- 手动导入目前提供的是最小字段录入路径:交易对、方向、开仓价格、开仓时间 ## License diff --git a/backend/import_data.py b/backend/import_data.py index 95f9e10..3aaef83 100644 --- a/backend/import_data.py +++ b/backend/import_data.py @@ -1,36 +1,43 @@ #!/usr/bin/env python3 """导入示例交易数据到数据库""" + import os import sys sys.path.insert(0, os.path.dirname(__file__)) from database import engine, SessionLocal, Base +from migrations.runner import MigrationRunner from services.trade_importer import trade_importer from models import Trade + def main(): + MigrationRunner(engine=engine).run() Base.metadata.create_all(bind=engine) db = SessionLocal() - + # 检查是否已有数据 existing = db.query(Trade).count() if existing > 0: print(f"📊 数据库已有 {existing} 条交易记录,跳过导入") db.close() return - + # 导入示例数据 excel_path = os.path.join(os.path.dirname(__file__), "..", "1.xlsx") if not os.path.exists(excel_path): print("⚠️ 未找到 1.xlsx,跳过数据导入") db.close() return - + print("📥 导入示例交易数据...") result = trade_importer.parse_excel(db, excel_path) - print(f"✅ 导入完成: 成功 {result['success']} 条, 失败 {result['failed']} 条") + print( + f"✅ 导入完成: 成功 {result.summary.success_count} 条, 失败 {result.summary.failed_count} 条" + ) db.close() + if __name__ == "__main__": main() diff --git a/backend/main.py b/backend/main.py index 563fbf7..ae8c499 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,17 +1,23 @@ import tempfile -from typing import Optional +from pathlib import Path +from typing import Any, Optional from fastapi import FastAPI, Depends, HTTPException, UploadFile, File, Query, Response +from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel from sqlalchemy.orm import Session from database import engine, get_db, Base -from models import Trade +from migrations.runner import MigrationRunner +from models import ImportSession, Trade +from services.backup_service import create_sqlite_backup, restore_sqlite_backup from services.kline_service import kline_service, TIMEFRAMES from services.trade_importer import trade_importer from services.trade_analyzer import trade_analyzer from services.symbol_utils import normalize_symbol, is_valid_symbol +MigrationRunner(engine=engine).run() Base.metadata.create_all(bind=engine) app = FastAPI(title="Trading Replay API") @@ -25,6 +31,11 @@ ) +class ManualTradeImportPayload(BaseModel): + source_filename: str = "manual-entry" + rows: list[dict[str, Any]] + + @app.get("/api/klines/{symbol}/{timeframe}") def get_klines( symbol: str, @@ -39,7 +50,9 @@ def get_klines( if timeframe not in TIMEFRAMES: raise HTTPException(400, f"Invalid timeframe. Supported: {TIMEFRAMES}") try: - response.headers["Cache-Control"] = "no-store, no-cache, max-age=0, must-revalidate" + response.headers["Cache-Control"] = ( + "no-store, no-cache, max-age=0, must-revalidate" + ) response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" data = kline_service.fetch_klines_range( @@ -57,13 +70,15 @@ def get_klines( except HTTPException: raise except Exception as e: - print(f"get_klines failed symbol={symbol} tf={timeframe} start={start} end={end}: {e!r}") + print( + f"get_klines failed symbol={symbol} tf={timeframe} start={start} end={end}: {e!r}" + ) raise HTTPException(502, f"Failed to fetch klines: {type(e).__name__}") @app.post("/api/trades/import") async def import_trades(file: UploadFile = File(...), db: Session = Depends(get_db)): - if not file.filename.endswith((".xlsx", ".xls")): + if not file.filename or not file.filename.endswith((".xlsx", ".xls")): raise HTTPException(400, "Only Excel files (.xlsx, .xls) are supported") try: with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp: @@ -76,6 +91,69 @@ async def import_trades(file: UploadFile = File(...), db: Session = Depends(get_ raise HTTPException(500, str(e)) +@app.post("/api/trades/import/rows") +def import_trade_rows(payload: ManualTradeImportPayload, db: Session = Depends(get_db)): + try: + return trade_importer.parse_rows( + db, payload.rows, source_filename=payload.source_filename + ) + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.get("/api/backups/download") +def download_sqlite_backup(): + try: + backup_path = create_sqlite_backup(engine) + return FileResponse( + backup_path, + media_type="application/octet-stream", + filename=backup_path.name, + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) + except FileNotFoundError as exc: + raise HTTPException(404, str(exc)) + + +@app.post("/api/backups/restore") +async def restore_sqlite_backup_endpoint(file: UploadFile = File(...)): + if not file.filename: + raise HTTPException(400, "Backup filename is required") + + tmp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + delete=False, suffix=Path(file.filename).suffix or ".sqlite3" + ) as tmp: + tmp_path = Path(tmp.name) + _ = tmp.write(await file.read()) + + restore_sqlite_backup(engine, tmp_path) + return {"status": "restored"} + except ValueError as exc: + raise HTTPException(400, str(exc)) + except FileNotFoundError as exc: + raise HTTPException(404, str(exc)) + finally: + if tmp_path is not None: + tmp_path.unlink(missing_ok=True) + + +@app.get("/api/trades/import/reports/{session_id}/download") +def download_import_report(session_id: int, db: Session = Depends(get_db)): + session = db.query(ImportSession).filter(ImportSession.id == session_id).first() + if session is None: + raise HTTPException(404, "Import report not found") + + csv_content = trade_importer.render_download_csv(db, session_id) + filename = session.download_filename or f"import-report-{session_id}.csv" + headers = { + "Content-Disposition": f'attachment; filename="{filename}"', + } + return Response(content=csv_content, media_type="text/csv", headers=headers) + + @app.get("/api/trades") def get_trades( symbol: Optional[str] = None, @@ -96,10 +174,14 @@ def get_trades( if end_date: query = query.filter(Trade.entry_time <= end_date) - trades = [t for t in query.order_by(Trade.entry_time.desc()).all() if is_valid_symbol(t.symbol)] + trades = [ + t + for t in query.order_by(Trade.entry_time.desc()).all() + if is_valid_symbol(t.symbol) + ] total = len(trades) start_idx = (page - 1) * limit - trades = trades[start_idx:start_idx + limit] + trades = trades[start_idx : start_idx + limit] return { "total": total, diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py new file mode 100644 index 0000000..0755bc5 --- /dev/null +++ b/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""SQLite migration package for Retraq.""" diff --git a/backend/migrations/runner.py b/backend/migrations/runner.py new file mode 100644 index 0000000..73f26a0 --- /dev/null +++ b/backend/migrations/runner.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from collections.abc import Sequence +from pathlib import Path +import shutil +from typing import Callable, cast + +from sqlalchemy import Connection, Engine, text + + +SCHEMA_VERSION_TABLE = "schema_version" + + +def _version_sort_key(version: str) -> tuple[int, str]: + try: + return (int(version), version) + except ValueError: + return (0, version) + + +def _sqlite_path_from_engine(engine: Engine) -> Path: + database = engine.url.database + if engine.url.get_backend_name() != "sqlite" or not database: + raise ValueError("MigrationRunner only supports SQLite file databases") + return Path(database).expanduser() + + +@dataclass(frozen=True, slots=True) +class MigrationStep: + version: str + name: str + apply: Callable[[Connection], None] + + +def bootstrap_schema(connection: Connection) -> None: + _ = connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS klines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + timeframe TEXT NOT NULL, + timestamp INTEGER NOT NULL, + open REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + close REAL NOT NULL, + volume REAL NOT NULL + ) + """ + ) + ) + _ = connection.execute( + text( + """ + CREATE UNIQUE INDEX IF NOT EXISTS ix_kline_symbol_tf_ts + ON klines(symbol, timeframe, timestamp) + """ + ) + ) + _ = connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + direction TEXT NOT NULL, + leverage REAL DEFAULT 1.0, + entry_price REAL NOT NULL, + exit_price REAL, + profit REAL, + profit_rate REAL, + entry_time INTEGER NOT NULL, + exit_time INTEGER, + margin REAL + ) + """ + ) + ) + _ = connection.execute( + text( + """ + CREATE INDEX IF NOT EXISTS ix_trade_symbol + ON trades(symbol) + """ + ) + ) + + +DEFAULT_MIGRATIONS: tuple[MigrationStep, ...] = ( + MigrationStep(version="001", name="bootstrap_schema", apply=bootstrap_schema), +) + + +@dataclass(slots=True) +class MigrationRunner: + engine: Engine + migrations: Sequence[MigrationStep] = DEFAULT_MIGRATIONS + backup_dir: Path | None = None + + def run(self) -> str: + ordered_migrations = self._ordered_migrations() + if not ordered_migrations: + return self.current_version() or "" + + current_version = self.current_version() + target_version = ordered_migrations[-1].version + if current_version == target_version: + return target_version + + pending_migrations = [ + migration + for migration in ordered_migrations + if current_version is None + or _version_sort_key(migration.version) > _version_sort_key(current_version) + ] + + if not pending_migrations: + return current_version or target_version + + _ = self.backup_database() + + with self.engine.begin() as connection: + self._ensure_schema_version_table(connection) + for migration in pending_migrations: + migration.apply(connection) + self._write_schema_version(connection, migration.version) + + return self.current_version() or pending_migrations[-1].version + + def current_version(self) -> str | None: + with self.engine.connect() as connection: + if not self._schema_version_table_exists(connection): + return None + version = cast( + str | None, + connection.execute( + text( + f"SELECT version FROM {SCHEMA_VERSION_TABLE} ORDER BY id DESC LIMIT 1" + ) + ).scalar_one_or_none(), + ) + return version + + def backup_database(self) -> Path: + sqlite_path = _sqlite_path_from_engine(self.engine) + if not sqlite_path.exists(): + raise FileNotFoundError(f"SQLite database not found: {sqlite_path}") + + backup_root = self.backup_dir or sqlite_path.parent / "backups" + backup_root.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f") + backup_path = backup_root / f"{sqlite_path.stem}-{timestamp}.sqlite3" + _ = shutil.copy2(sqlite_path, backup_path) + return backup_path + + def _ordered_migrations(self) -> list[MigrationStep]: + seen_versions: set[str] = set() + ordered = sorted( + self.migrations, key=lambda migration: _version_sort_key(migration.version) + ) + for migration in ordered: + if migration.version in seen_versions: + raise ValueError(f"Duplicate migration version: {migration.version}") + seen_versions.add(migration.version) + return ordered + + def _schema_version_table_exists(self, connection: Connection) -> bool: + result = connection.execute( + text( + "SELECT name FROM sqlite_master WHERE type='table' AND name = :table_name" + ), + {"table_name": SCHEMA_VERSION_TABLE}, + ).scalar_one_or_none() + return result is not None + + def _ensure_schema_version_table(self, connection: Connection) -> None: + _ = connection.execute( + text( + f""" + CREATE TABLE IF NOT EXISTS {SCHEMA_VERSION_TABLE} ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version TEXT NOT NULL, + applied_at TEXT NOT NULL + ) + """ + ) + ) + + def _write_schema_version(self, connection: Connection, version: str) -> None: + _ = connection.execute( + text( + f""" + INSERT INTO {SCHEMA_VERSION_TABLE} (id, version, applied_at) + VALUES (1, :version, :applied_at) + ON CONFLICT(id) DO UPDATE SET + version = excluded.version, + applied_at = excluded.applied_at + """ + ), + {"version": version, "applied_at": datetime.now(timezone.utc).isoformat()}, + ) diff --git a/backend/migrations/versions/001_phase1_import_reliability.sql b/backend/migrations/versions/001_phase1_import_reliability.sql new file mode 100644 index 0000000..a483af0 --- /dev/null +++ b/backend/migrations/versions/001_phase1_import_reliability.sql @@ -0,0 +1,76 @@ +-- Phase 1 import reliability bootstrap + additive upgrade script. +-- Version: 001 + +CREATE TABLE IF NOT EXISTS klines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + timeframe TEXT NOT NULL, + timestamp INTEGER NOT NULL, + open REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + close REAL NOT NULL, + volume REAL NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS ix_kline_symbol_tf_ts +ON klines(symbol, timeframe, timestamp); + +CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + direction TEXT NOT NULL, + leverage REAL DEFAULT 1.0, + entry_price REAL NOT NULL, + exit_price REAL, + profit REAL, + profit_rate REAL, + entry_time INTEGER NOT NULL, + exit_time INTEGER, + margin REAL +); + +CREATE INDEX IF NOT EXISTS ix_trade_symbol +ON trades(symbol); + +CREATE TABLE IF NOT EXISTS import_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_filename TEXT NOT NULL, + status TEXT NOT NULL, + total_rows INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + duplicate_count INTEGER NOT NULL DEFAULT 0, + conflict_count INTEGER NOT NULL DEFAULT 0, + timestamp_normalization_count INTEGER NOT NULL DEFAULT 0, + file_rejection_reason TEXT, + file_rejection_message TEXT, + download_filename TEXT, + download_mime_type TEXT NOT NULL DEFAULT 'text/csv', + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_import_sessions_status +ON import_sessions(status); + +CREATE TABLE IF NOT EXISTS import_report_rows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + row_number INTEGER NOT NULL, + outcome TEXT NOT NULL, + field TEXT NOT NULL, + raw_value TEXT, + normalized_value TEXT, + reason TEXT NOT NULL, + business_key TEXT NOT NULL, + FOREIGN KEY(session_id) REFERENCES import_sessions(id) +); + +CREATE INDEX IF NOT EXISTS ix_import_report_rows_session_id +ON import_report_rows(session_id); + +CREATE INDEX IF NOT EXISTS ix_import_report_rows_session_outcome +ON import_report_rows(session_id, outcome); + +CREATE INDEX IF NOT EXISTS ix_import_report_rows_business_key +ON import_report_rows(business_key); diff --git a/backend/models.py b/backend/models.py index 80bee17..4776c9f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,19 +1,31 @@ -from sqlalchemy import Column, Integer, String, Float, BigInteger, Index +import time + +from sqlalchemy import ( + BigInteger, + Column, + Float, + ForeignKey, + Index, + Integer, + String, + Text, +) +from sqlalchemy.orm import Mapped, mapped_column from database import Base class Kline(Base): __tablename__ = "klines" - id = Column(Integer, primary_key=True, autoincrement=True) - symbol = Column(String(32), nullable=False) - timeframe = Column(String(8), nullable=False) - timestamp = Column(BigInteger, nullable=False) - open = Column(Float, nullable=False) - high = Column(Float, nullable=False) - low = Column(Float, nullable=False) - close = Column(Float, nullable=False) - volume = Column(Float, nullable=False) + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + symbol: Mapped[str] = mapped_column(String(32), nullable=False) + timeframe: Mapped[str] = mapped_column(String(8), nullable=False) + timestamp: Mapped[int] = mapped_column(BigInteger, nullable=False) + open: Mapped[float] = mapped_column(Float, nullable=False) + high: Mapped[float] = mapped_column(Float, nullable=False) + low: Mapped[float] = mapped_column(Float, nullable=False) + close: Mapped[float] = mapped_column(Float, nullable=False) + volume: Mapped[float] = mapped_column(Float, nullable=False) __table_args__ = ( Index("ix_kline_symbol_tf_ts", "symbol", "timeframe", "timestamp", unique=True), @@ -23,16 +35,65 @@ class Kline(Base): class Trade(Base): __tablename__ = "trades" - id = Column(Integer, primary_key=True, autoincrement=True) - symbol = Column(String(32), nullable=False) - direction = Column(String(8), nullable=False) - leverage = Column(Float, default=1.0) - entry_price = Column(Float, nullable=False) - exit_price = Column(Float) - profit = Column(Float) - profit_rate = Column(Float) - entry_time = Column(BigInteger, nullable=False) - exit_time = Column(BigInteger) - margin = Column(Float) + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + symbol: Mapped[str] = mapped_column(String(32), nullable=False) + direction: Mapped[str] = mapped_column(String(8), nullable=False) + leverage: Mapped[float | None] = mapped_column(Float, default=1.0) + entry_price: Mapped[float] = mapped_column(Float, nullable=False) + exit_price: Mapped[float | None] = mapped_column(Float) + profit: Mapped[float | None] = mapped_column(Float) + profit_rate: Mapped[float | None] = mapped_column(Float) + entry_time: Mapped[int] = mapped_column(BigInteger, nullable=False) + exit_time: Mapped[int | None] = mapped_column(BigInteger) + margin: Mapped[float | None] = mapped_column(Float) __table_args__ = (Index("ix_trade_symbol", "symbol"),) + + +class ImportSession(Base): + __tablename__ = "import_sessions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + source_filename: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + total_rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + success_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + failed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + duplicate_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + conflict_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + timestamp_normalization_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0 + ) + file_rejection_reason: Mapped[str | None] = mapped_column(String(255)) + file_rejection_message: Mapped[str | None] = mapped_column(Text) + download_filename: Mapped[str | None] = mapped_column(String(255)) + download_mime_type: Mapped[str] = mapped_column( + String(64), nullable=False, default="text/csv" + ) + created_at: Mapped[int] = mapped_column( + BigInteger, nullable=False, default=lambda: int(time.time() * 1000) + ) + + __table_args__ = (Index("ix_import_sessions_status", "status"),) + + +class ImportReportRow(Base): + __tablename__ = "import_report_rows" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + session_id: Mapped[int] = mapped_column( + ForeignKey("import_sessions.id"), nullable=False + ) + row_number: Mapped[int] = mapped_column(Integer, nullable=False) + outcome: Mapped[str] = mapped_column(String(32), nullable=False) + field: Mapped[str] = mapped_column(String(64), nullable=False) + raw_value: Mapped[str | None] = mapped_column(Text) + normalized_value: Mapped[str | None] = mapped_column(Text) + reason: Mapped[str] = mapped_column(Text, nullable=False) + business_key: Mapped[str] = mapped_column(Text, nullable=False) + + __table_args__ = ( + Index("ix_import_report_rows_session_id", "session_id"), + Index("ix_import_report_rows_session_outcome", "session_id", "outcome"), + Index("ix_import_report_rows_business_key", "business_key"), + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c7c5d72..a77c9c6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,8 @@ dependencies = [ "openpyxl>=3.1.5", "python-multipart>=0.0.9", "pytz>=2024.1", + "pytest>=8.3.0", + "httpx>=0.28.1", ] [build-system] diff --git a/backend/services/backup_service.py b/backend/services/backup_service.py new file mode 100644 index 0000000..80f2b39 --- /dev/null +++ b/backend/services/backup_service.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pathlib import Path +import shutil + +from sqlalchemy.engine import Engine + +from migrations.runner import MigrationRunner + + +SQLITE_BACKUP_HEADER = b"SQLite format 3\x00" + + +def _sqlite_path_from_engine(engine: Engine) -> Path: + database = engine.url.database + if engine.url.get_backend_name() != "sqlite" or not database: + raise ValueError("Backup service only supports SQLite file databases") + return Path(database).expanduser() + + +def _is_sqlite_backup(backup_path: Path) -> bool: + with backup_path.open("rb") as backup_file: + return backup_file.read(len(SQLITE_BACKUP_HEADER)) == SQLITE_BACKUP_HEADER + + +def create_sqlite_backup(engine: Engine) -> Path: + return MigrationRunner(engine=engine).backup_database() + + +def restore_sqlite_backup(engine: Engine, backup_path: Path) -> None: + sqlite_path = _sqlite_path_from_engine(engine) + if not backup_path.exists(): + raise FileNotFoundError(f"SQLite backup not found: {backup_path}") + if not _is_sqlite_backup(backup_path): + raise ValueError("Only SQLite backup files are supported") + + engine.dispose() + try: + _ = shutil.copy2(backup_path, sqlite_path) + finally: + engine.dispose() diff --git a/backend/services/import_types.py b/backend/services/import_types.py new file mode 100644 index 0000000..21b71bc --- /dev/null +++ b/backend/services/import_types.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +IMPORT_OUTCOME_BUCKETS = ( + "success", + "failed", + "duplicate", + "conflict", + "timestamp_normalization", +) + +REQUIRED_IMPORT_COLUMNS = ("symbol", "entry_price", "entry_time") + + +@dataclass(frozen=True, slots=True) +class ImportFileRejection: + reason: str + missing_columns: tuple[str, ...] + required_columns: tuple[str, ...] = REQUIRED_IMPORT_COLUMNS + present_columns: tuple[str, ...] = () + message: str = "" + filename: str | None = None + + +@dataclass(frozen=True, slots=True) +class ImportRowOutcome: + row_number: int + field: str + raw_value: object | None + reason: str + normalized_value: object | None = None + outcome: str = "failed" + + +@dataclass(frozen=True, slots=True) +class ImportNormalizationEvent: + row_number: int + field: str + raw_value: object | None + normalized_value: object + reason: str + kind: str = "timestamp_normalization" + + +@dataclass(frozen=True, slots=True) +class ImportSummary: + total_rows: int = 0 + success_count: int = 0 + failed_count: int = 0 + duplicate_count: int = 0 + conflict_count: int = 0 + timestamp_normalization_count: int = 0 + + +@dataclass(frozen=True, slots=True) +class ImportReportDownloadReference: + download_url: str + filename: str + mime_type: str + format: str = "csv" + label: str = "Import detail export" + + +@dataclass(frozen=True, slots=True) +class ImportReport: + summary: ImportSummary + row_outcomes: tuple[ImportRowOutcome, ...] = field(default_factory=tuple) + normalization_events: tuple[ImportNormalizationEvent, ...] = field( + default_factory=tuple + ) + file_rejection: ImportFileRejection | None = None + download_reference: ImportReportDownloadReference | None = None + source_filename: str | None = None diff --git a/backend/services/trade_importer.py b/backend/services/trade_importer.py index 1be1883..a3d0f50 100644 --- a/backend/services/trade_importer.py +++ b/backend/services/trade_importer.py @@ -1,100 +1,673 @@ +from __future__ import annotations + +import csv +import io +import re +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any, cast + import pandas as pd from sqlalchemy.orm import Session -from models import Trade -from services.symbol_utils import normalize_symbol, is_valid_symbol - -# Excel column mapping -COLUMN_MAP = { - "交易对": "symbol", - "方向": "direction", - "杠杆倍数": "leverage", - "开仓均价": "entry_price", - "平仓均价": "exit_price", - "收益率": "profit_rate", - "收益 (USDT)": "profit", - "保证金(最大时)": "margin", - "买入时间": "entry_time", - "卖出时间": "exit_time", + +from models import ImportReportRow, ImportSession, Trade +from services import import_types +from services.symbol_utils import is_valid_symbol, normalize_symbol + + +COLUMN_ALIASES: dict[str, tuple[str, ...]] = { + "symbol": ( + "symbol", + "交易对", + "交易对(币对)", + "交易对(币对)", + ), + "direction": ( + "direction", + "方向", + "买卖方向", + ), + "leverage": ( + "leverage", + "杠杆倍数", + "杠杆", + ), + "entry_price": ( + "entry_price", + "开仓均价", + "买入均价", + "开仓价格", + "开仓价", + ), + "exit_price": ( + "exit_price", + "平仓均价", + "卖出均价", + "平仓价格", + "平仓价", + ), + "profit_rate": ( + "profit_rate", + "收益率", + "盈亏率", + ), + "profit": ( + "profit", + "收益 (USDT)", + "收益", + "净收益", + ), + "margin": ( + "margin", + "保证金(最大时)", + "保证金", + "最大保证金", + ), + "entry_time": ( + "entry_time", + "买入时间", + "开仓时间", + ), + "exit_time": ( + "exit_time", + "卖出时间", + "平仓时间", + ), +} + +ROW_OUTCOME_BUCKETS = import_types.IMPORT_OUTCOME_BUCKETS +REQUIRED_COLUMNS = import_types.REQUIRED_IMPORT_COLUMNS + + +@dataclass(frozen=True, slots=True) +class TradeSnapshot: + symbol: str + direction: str + leverage: float + entry_price: float + exit_price: float | None + profit: float | None + profit_rate: float | None + margin: float | None + entry_time: int + exit_time: int | None + + @property + def business_key(self) -> str: + parts = ( + self.symbol, + self.direction, + str(self.entry_time), + _format_number(self.entry_price), + _format_optional_number(self.exit_time), + _format_optional_number(self.exit_price), + ) + return "|".join(parts) + + @property + def comparison_signature(self) -> tuple[Any, ...]: + return ( + _format_number(self.leverage), + _format_optional_number(self.profit), + _format_optional_number(self.profit_rate), + _format_optional_number(self.margin), + ) + + +@dataclass(frozen=True, slots=True) +class ParsedRow: + snapshot: TradeSnapshot | None + trade: Trade | None + failure_field: str | None + failure_raw_value: object | None + failure_reason: str | None + normalization_events: tuple[import_types.ImportNormalizationEvent, ...] = () + + +def _normalize_header_name(name: object) -> str: + return re.sub(r"\s+", "", str(name).strip()) + + +ALIASES_TO_CANONICAL: dict[str, str] = { + _normalize_header_name(alias): canonical + for canonical, aliases in COLUMN_ALIASES.items() + for alias in aliases } class TradeImporter: - def parse_excel(self, db: Session, file_path: str) -> dict: - df = pd.read_excel(file_path, engine="openpyxl") - df = df.rename(columns=COLUMN_MAP) - - # Filter out invalid rows (must have valid entry_price and entry_time) - df = df[df["entry_price"].notna() & df["entry_time"].notna()] - - total = len(df) - success = 0 - failed = 0 - - for _, row in df.iterrows(): - try: - # Skip if entry_price is not a valid number - entry_price = float(row["entry_price"]) - if pd.isna(entry_price) or entry_price <= 0: - failed += 1 - continue - normalized_symbol = normalize_symbol(row["symbol"]) - if not is_valid_symbol(normalized_symbol): - failed += 1 + def parse_excel(self, db: Session, file_path: str) -> import_types.ImportReport: + workbook_path = Path(file_path) + df = pd.read_excel(workbook_path, engine="openpyxl") + return self._import_dataframe(db, df, workbook_path, row_number_start=2) + + def parse_rows( + self, + db: Session, + rows: list[dict[str, object]], + source_filename: str = "manual-entry", + ) -> import_types.ImportReport: + dataframe = pd.DataFrame(rows) + source_path = Path(source_filename or "manual-entry") + return self._import_dataframe(db, dataframe, source_path, row_number_start=1) + + def _import_dataframe( + self, + db: Session, + df: pd.DataFrame, + source_path: Path, + row_number_start: int, + ) -> import_types.ImportReport: + df = self._rename_columns(df) + + missing_columns = tuple( + column for column in REQUIRED_COLUMNS if column not in df.columns + ) + if missing_columns: + return self._reject_file(db, source_path, df.columns, missing_columns) + + session = ImportSession( + source_filename=source_path.name, + status="processing", + total_rows=int(len(df)), + ) + db.add(session) + db.flush() + + existing_snapshots: dict[str, list[TradeSnapshot]] = ( + self._load_existing_snapshots(db) + ) + seen_snapshots: dict[str, list[TradeSnapshot]] = {} + + row_outcomes: list[import_types.ImportRowOutcome] = [] + normalization_events: list[import_types.ImportNormalizationEvent] = [] + success_count = 0 + failed_count = 0 + duplicate_count = 0 + conflict_count = 0 + timestamp_normalization_count = 0 + + try: + for row_number, (_, raw_row) in enumerate( + df.iterrows(), start=row_number_start + ): + parsed = self._parse_row(raw_row, row_number) + normalization_events.extend(parsed.normalization_events) + timestamp_normalization_count += len(parsed.normalization_events) + + if parsed.failure_field is not None: + failed_count += 1 + row_outcomes.append( + import_types.ImportRowOutcome( + row_number=row_number, + field=parsed.failure_field, + raw_value=self._null_if_missing(parsed.failure_raw_value), + reason=parsed.failure_reason or "row_validation_failed", + normalized_value=None, + outcome="failed", + ) + ) + self._persist_seen_snapshot(seen_snapshots, parsed.snapshot) continue - trade = Trade( - symbol=normalized_symbol, - direction=self._normalize_direction(row["direction"]), - leverage=float(row.get("leverage", 1)) if pd.notna(row.get("leverage")) else 1, - entry_price=entry_price, - exit_price=float(row["exit_price"]) if pd.notna(row.get("exit_price")) else None, - profit=float(row["profit"]) if pd.notna(row.get("profit")) else None, - profit_rate=self._parse_rate(row.get("profit_rate")), - margin=float(row["margin"]) if pd.notna(row.get("margin")) else None, - entry_time=self._parse_timestamp(row["entry_time"]), - exit_time=self._parse_timestamp(row.get("exit_time")), + assert parsed.snapshot is not None + assert parsed.trade is not None + + same_key_snapshots = [ + *existing_snapshots.get(parsed.snapshot.business_key, []), + *seen_snapshots.get(parsed.snapshot.business_key, []), + ] + outcome = self._classify_row(parsed.snapshot, same_key_snapshots) + + if outcome == "success": + db.add(parsed.trade) + db.flush() + existing_snapshots.setdefault( + parsed.snapshot.business_key, [] + ).append(parsed.snapshot) + success_count += 1 + elif outcome == "duplicate": + duplicate_count += 1 + else: + conflict_count += 1 + + row_outcomes.append( + import_types.ImportRowOutcome( + row_number=row_number, + field="business_key", + raw_value=parsed.snapshot.business_key, + reason=self._row_reason(outcome, same_key_snapshots), + normalized_value=parsed.snapshot.business_key, + outcome=outcome, + ) ) - db.add(trade) - success += 1 - except Exception as e: - failed += 1 + self._persist_seen_snapshot(seen_snapshots, parsed.snapshot) + + summary = import_types.ImportSummary( + total_rows=int(len(df)), + success_count=success_count, + failed_count=failed_count, + duplicate_count=duplicate_count, + conflict_count=conflict_count, + timestamp_normalization_count=timestamp_normalization_count, + ) + status = self._session_status(summary) + download_reference = self._build_download_reference( + int(session.id), source_path.name + ) + session.status = status + session.success_count = summary.success_count + session.failed_count = summary.failed_count + session.duplicate_count = summary.duplicate_count + session.conflict_count = summary.conflict_count + session.timestamp_normalization_count = ( + summary.timestamp_normalization_count + ) + session.download_filename = download_reference.filename + session.download_mime_type = download_reference.mime_type + + for outcome in row_outcomes: + db.add( + ImportReportRow( + session_id=session.id, + row_number=outcome.row_number, + outcome=outcome.outcome, + field=outcome.field, + raw_value=self._stringify(outcome.raw_value), + normalized_value=self._stringify(outcome.normalized_value), + reason=outcome.reason, + business_key=self._derive_business_key_for_row(outcome), + ) + ) + + db.commit() + return import_types.ImportReport( + summary=summary, + row_outcomes=tuple(row_outcomes), + normalization_events=tuple(normalization_events), + file_rejection=None, + download_reference=download_reference, + source_filename=source_path.name, + ) + except Exception: + db.rollback() + raise + + def render_download_csv(self, db: Session, session_id: int) -> str: + rows = ( + db.query(ImportReportRow) + .filter(ImportReportRow.session_id == session_id) + .order_by(ImportReportRow.row_number.asc(), ImportReportRow.id.asc()) + .all() + ) + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow( + [ + "row_number", + "outcome", + "field", + "raw_value", + "normalized_value", + "reason", + "business_key", + ] + ) + for row in rows: + writer.writerow( + [ + row.row_number, + row.outcome, + row.field, + row.raw_value or "", + row.normalized_value or "", + row.reason, + row.business_key, + ] + ) + return buffer.getvalue() + + def _reject_file( + self, + db: Session, + workbook_path: Path, + present_columns: Any, + missing_columns: tuple[str, ...], + ) -> import_types.ImportReport: + session = ImportSession( + source_filename=workbook_path.name, + status="file_rejected", + total_rows=0, + file_rejection_reason="missing_required_columns", + file_rejection_message=f"Missing required columns: {', '.join(missing_columns)}", + ) + db.add(session) db.commit() - return {"total": total, "success": success, "failed": failed} - def _normalize_direction(self, direction: str) -> str: - d = str(direction).lower() - if "多" in d or "long" in d or "买" in d: + rejection = import_types.ImportFileRejection( + reason="missing_required_columns", + missing_columns=missing_columns, + required_columns=REQUIRED_COLUMNS, + present_columns=tuple(str(column) for column in present_columns), + message=f"Missing required columns: {', '.join(missing_columns)}", + filename=workbook_path.name, + ) + return import_types.ImportReport( + summary=import_types.ImportSummary(), + row_outcomes=(), + normalization_events=(), + file_rejection=rejection, + download_reference=None, + source_filename=workbook_path.name, + ) + + def _rename_columns(self, df: pd.DataFrame) -> pd.DataFrame: + rename_map: dict[str, str] = {} + for column in df.columns: + canonical = ALIASES_TO_CANONICAL.get(_normalize_header_name(column)) + if canonical: + rename_map[column] = canonical + return df.rename(columns=rename_map) + + def _load_existing_snapshots(self, db: Session) -> dict[str, list[TradeSnapshot]]: + snapshots: dict[str, list[TradeSnapshot]] = {} + for trade in db.query(Trade).all(): + snapshot = self._snapshot_from_trade(trade) + snapshots.setdefault(snapshot.business_key, []).append(snapshot) + return snapshots + + def _parse_row(self, row: pd.Series, row_number: int) -> ParsedRow: + normalization_events: list[import_types.ImportNormalizationEvent] = [] + + raw_symbol = cast(object | None, row.get("symbol")) + symbol = normalize_symbol(str(raw_symbol) if raw_symbol is not None else "") + if not is_valid_symbol(symbol): + return ParsedRow( + snapshot=None, + trade=None, + failure_field="symbol", + failure_raw_value=raw_symbol, + failure_reason="invalid_symbol", + normalization_events=tuple(normalization_events), + ) + + raw_direction = cast(object | None, row.get("direction")) + direction = self._normalize_direction(raw_direction) + if not direction: + return ParsedRow( + snapshot=None, + trade=None, + failure_field="direction", + failure_raw_value=raw_direction, + failure_reason="invalid_direction", + normalization_events=tuple(normalization_events), + ) + + leverage = ( + self._parse_number(cast(object | None, row.get("leverage")), default=1.0) + or 1.0 + ) + entry_price_raw = cast(object | None, row.get("entry_price")) + entry_price = self._parse_number(entry_price_raw) + if entry_price is None or entry_price <= 0: + return ParsedRow( + snapshot=None, + trade=None, + failure_field="entry_price", + failure_raw_value=entry_price_raw, + failure_reason="invalid_numeric_value", + normalization_events=tuple(normalization_events), + ) + + exit_price = self._parse_number(cast(object | None, row.get("exit_price"))) + profit = self._parse_number(cast(object | None, row.get("profit"))) + profit_rate = self._parse_rate(cast(object | None, row.get("profit_rate"))) + margin = self._parse_number(cast(object | None, row.get("margin"))) + + entry_time, entry_event = self._parse_timestamp( + cast(object | None, row.get("entry_time")), row_number, "entry_time" + ) + if entry_event is not None: + normalization_events.append(entry_event) + if entry_time is None: + return ParsedRow( + snapshot=None, + trade=None, + failure_field="entry_time", + failure_raw_value=cast(object | None, row.get("entry_time")), + failure_reason="invalid_timestamp", + normalization_events=tuple(normalization_events), + ) + + exit_time, exit_event = self._parse_timestamp( + cast(object | None, row.get("exit_time")), row_number, "exit_time" + ) + if exit_event is not None: + normalization_events.append(exit_event) + + snapshot = TradeSnapshot( + symbol=symbol, + direction=direction, + leverage=leverage, + entry_price=entry_price, + exit_price=exit_price, + profit=profit, + profit_rate=profit_rate, + margin=margin, + entry_time=entry_time, + exit_time=exit_time, + ) + trade = Trade( + symbol=symbol, + direction=direction, + leverage=leverage, + entry_price=entry_price, + exit_price=exit_price, + profit=profit, + profit_rate=profit_rate, + margin=margin, + entry_time=entry_time, + exit_time=exit_time, + ) + return ParsedRow( + snapshot=snapshot, + trade=trade, + failure_field=None, + failure_raw_value=None, + failure_reason=None, + normalization_events=tuple(normalization_events), + ) + + def _snapshot_from_trade(self, trade: Trade) -> TradeSnapshot: + return TradeSnapshot( + symbol=trade.symbol, + direction=trade.direction, + leverage=float(trade.leverage or 1.0), + entry_price=float(trade.entry_price), + exit_price=self._coerce_optional_float(trade.exit_price), + profit=self._coerce_optional_float(trade.profit), + profit_rate=self._coerce_optional_float(trade.profit_rate), + margin=self._coerce_optional_float(trade.margin), + entry_time=int(trade.entry_time), + exit_time=self._coerce_optional_int(trade.exit_time), + ) + + def _classify_row( + self, snapshot: TradeSnapshot, same_key_snapshots: list[TradeSnapshot] + ) -> str: + if not same_key_snapshots: + return "success" + if all( + existing.comparison_signature == snapshot.comparison_signature + for existing in same_key_snapshots + ): + return "duplicate" + return "conflict" + + def _persist_seen_snapshot( + self, + seen_snapshots: dict[str, list[TradeSnapshot]], + snapshot: TradeSnapshot | None, + ) -> None: + if snapshot is None: + return + seen_snapshots.setdefault(snapshot.business_key, []).append(snapshot) + + def _row_reason(self, outcome: str, same_key_snapshots: list[TradeSnapshot]) -> str: + if outcome == "duplicate": + return "duplicate_trade_exists" + if outcome == "conflict": + return "business_key_conflicts_with_existing_trade" + if same_key_snapshots: + return "trade_accepted_after_classification" + return "trade_accepted" + + def _session_status(self, summary: import_types.ImportSummary) -> str: + if summary.total_rows == 0: + return "file_rejected" + if summary.failed_count or summary.duplicate_count or summary.conflict_count: + return "partial" + return "success" + + def _build_download_reference( + self, + session_id: int, + source_filename: str, + ) -> import_types.ImportReportDownloadReference: + filename = f"import-report-{Path(source_filename).stem}-{session_id}.csv" + return import_types.ImportReportDownloadReference( + download_url=f"/api/trades/import/reports/{session_id}/download", + filename=filename, + mime_type="text/csv", + ) + + def _derive_business_key_for_row( + self, outcome: import_types.ImportRowOutcome + ) -> str: + if outcome.field == "business_key" and outcome.normalized_value is not None: + return str(outcome.normalized_value) + return "" + + def _parse_rate(self, raw: object | None) -> float | None: + if self._is_missing(raw): + return None + text = str(raw).strip().replace(",", "").replace("%", "") + try: + value = float(text) + except (TypeError, ValueError): + return None + if isinstance(raw, str) and "%" in raw: + return value / 100 + return value + + def _parse_number( + self, raw: object | None, default: float | None = None + ) -> float | None: + if self._is_missing(raw): + return default + text = str(raw).strip().replace(",", "") + if text.endswith("%"): + text = text[:-1] + try: + return float(Decimal(text)) + except (InvalidOperation, ValueError): + return default + + def _parse_timestamp( + self, + raw: object | None, + row_number: int, + field: str, + ) -> tuple[int | None, import_types.ImportNormalizationEvent | None]: + if self._is_missing(raw): + return None, None + + timestamp = pd.to_datetime(cast(Any, raw), errors="coerce") + if pd.isna(timestamp): + return None, None + + normalized_value = timestamp + normalized = False + if getattr(timestamp, "tzinfo", None) is None: + normalized_value = timestamp.tz_localize("Asia/Shanghai") + normalized = True + + millis = int(normalized_value.timestamp() * 1000) + if normalized: + return ( + millis, + import_types.ImportNormalizationEvent( + row_number=row_number, + field=field, + raw_value=raw, + normalized_value=millis, + reason="defaulted_naive_timestamp_to_asia_shanghai", + ), + ) + return millis, None + + def _normalize_direction(self, raw: object | None) -> str: + if self._is_missing(raw): + return "" + value = str(raw).strip().lower() + if any(token in value for token in ("多", "long", "buy")): return "long" - if "空" in d or "short" in d or "卖" in d: + if any(token in value for token in ("空", "short", "sell")): return "short" - return d + return "" + + def _is_missing(self, value: object | None) -> bool: + if value is None: + return True + if isinstance(value, str): + return False + try: + return bool(pd.isna(value)) + except TypeError: + return False + + def _null_if_missing(self, value: object | None) -> object | None: + return None if self._is_missing(value) else value - def _parse_rate(self, rate) -> float | None: - if pd.isna(rate): + def _coerce_optional_float(self, value: object | None) -> float | None: + if self._is_missing(value): + return None + try: + if isinstance(value, (int, float)): + return float(value) + return float(str(value)) + except (TypeError, ValueError): + return None + + def _coerce_optional_int(self, value: object | None) -> int | None: + if self._is_missing(value): + return None + try: + if isinstance(value, (int, float)): + return int(value) + return int(float(str(value))) + except (TypeError, ValueError): return None - if isinstance(rate, str): - return float(rate.replace("%", "")) / 100 - return float(rate) - def _parse_timestamp(self, ts) -> int | None: - if pd.isna(ts): + def _stringify(self, value: object | None) -> str | None: + if value is None: return None - # Excel中的时间是UTC+8,需要明确指定时区 - if isinstance(ts, pd.Timestamp): - if ts.tz is None: - ts = ts.tz_localize('Asia/Shanghai') - return int(ts.timestamp() * 1000) - if hasattr(ts, 'timestamp'): # datetime.datetime - if ts.tzinfo is None: - import pytz - tz = pytz.timezone('Asia/Shanghai') - ts = tz.localize(ts) - return int(ts.timestamp() * 1000) - if isinstance(ts, str): - dt = pd.to_datetime(ts).tz_localize('Asia/Shanghai') - return int(dt.timestamp() * 1000) - return int(ts) + if isinstance(value, str): + return value + return str(value) + + +def _format_number(value: float | int | None) -> str: + if value is None: + return "" + decimal = Decimal(str(value)).normalize() + return format(decimal, "f").rstrip("0").rstrip(".") or "0" + + +def _format_optional_number(value: float | int | None) -> str: + return _format_number(value) trade_importer = TradeImporter() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..36a37ab --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,14 @@ +from pathlib import Path +import sys + +import pytest + + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + + +@pytest.fixture +def sqlite_database_url(tmp_path: Path) -> str: + return f"sqlite:///{tmp_path / 'trading.db'}" diff --git a/backend/tests/test_backup_api.py b/backend/tests/test_backup_api.py new file mode 100644 index 0000000..62689bc --- /dev/null +++ b/backend/tests/test_backup_api.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +import database +from database import Base + + +def _make_client(db_path: Path) -> TestClient: + engine = create_engine( + f"sqlite:///{db_path}", connect_args={"check_same_thread": False} + ) + database.engine = engine + database.SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + Base.metadata.create_all(bind=engine) + + sys.modules.pop("main", None) + main = importlib.import_module("main") + return TestClient(main.app) + + +def _seed_trade(symbol: str, entry_price: float) -> None: + with database.SessionLocal() as session: + _ = session.execute( + text( + "INSERT INTO trades (symbol, direction, leverage, entry_price, entry_time) " + "VALUES (:symbol, :direction, :leverage, :entry_price, :entry_time)" + ), + { + "symbol": symbol, + "direction": "long", + "leverage": 2, + "entry_price": entry_price, + "entry_time": 1710000000000, + }, + ) + session.commit() + + +def test_backup_download_returns_current_sqlite_database(tmp_path: Path): + db_path = tmp_path / "trading.db" + client = _make_client(db_path) + + _seed_trade("BTC-USDT", 65000) + + response = client.get("/api/backups/download") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/octet-stream") + assert response.headers["content-disposition"].endswith('.sqlite3"') + assert response.content == db_path.read_bytes() + + +def test_backup_restore_replaces_the_database_contents(tmp_path: Path): + db_path = tmp_path / "trading.db" + client = _make_client(db_path) + + _seed_trade("BTC-USDT", 65000) + + backup_response = client.get("/api/backups/download") + backup_bytes = backup_response.content + + _seed_trade("ETH-USDT", 2500) + + restore_response = client.post( + "/api/backups/restore", + files={"file": ("backup.sqlite3", backup_bytes, "application/octet-stream")}, + ) + + assert restore_response.status_code == 200 + assert restore_response.json()["status"] == "restored" + + with database.SessionLocal() as session: + rows = session.execute( + text( + "SELECT symbol, direction, leverage, entry_price FROM trades ORDER BY id" + ) + ).all() + + assert rows == [("BTC-USDT", "long", 2.0, 65000.0)] diff --git a/backend/tests/test_import_api.py b/backend/tests/test_import_api.py new file mode 100644 index 0000000..e8c91c7 --- /dev/null +++ b/backend/tests/test_import_api.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +import pandas as pd +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import database +from database import Base +from models import Trade + + +def _make_client(db_path: Path) -> TestClient: + engine = create_engine( + f"sqlite:///{db_path}", connect_args={"check_same_thread": False} + ) + database.engine = engine + database.SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + Base.metadata.create_all(bind=engine) + + sys.modules.pop("main", None) + main = importlib.import_module("main") + return TestClient(main.app) + + +def _excel_bytes(tmp_path: Path, rows: list[dict[str, object]]) -> bytes: + workbook = tmp_path / "workbook.xlsx" + pd.DataFrame(rows).to_excel(workbook, index=False) + return workbook.read_bytes() + + +def test_import_api_rejects_non_excel_files(tmp_path: Path): + client = _make_client(tmp_path / "trading.db") + + response = client.post( + "/api/trades/import", + files={"file": ("notes.txt", b"not-an-excel-file", "text/plain")}, + ) + + assert response.status_code == 400 + assert "Only Excel files" in response.json()["detail"] + + +def test_import_api_returns_structured_report_for_missing_columns(tmp_path: Path): + client = _make_client(tmp_path / "trading.db") + workbook = _excel_bytes( + tmp_path, + [ + { + "交易对(币对)": "btc/usdt", + "方向": "做多", + "开仓均价": "1234.5", + } + ], + ) + + response = client.post( + "/api/trades/import", + files={ + "file": ( + "missing.xlsx", + workbook, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["file_rejection"]["reason"] == "missing_required_columns" + assert payload["file_rejection"]["missing_columns"] == ["entry_time"] + assert payload["summary"]["success_count"] == 0 + assert payload["download_reference"] is None + + +def test_import_api_returns_downloadable_structured_report(tmp_path: Path): + client = _make_client(tmp_path / "trading.db") + workbook = _excel_bytes( + tmp_path, + [ + { + "交易对(币对)": "btc/usdt", + "方向": "做多", + "开仓均价": "1,234.5", + "平仓均价": "1,400.0", + "收益率": "12.5%", + "收益 (USDT)": "1,000", + "保证金(最大时)": "500", + "开仓时间": "2024-01-02 09:15:00", + "平仓时间": "2024-01-02 10:15:00", + } + ], + ) + + response = client.post( + "/api/trades/import", + files={ + "file": ( + "report.xlsx", + workbook, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["summary"]["success_count"] == 1 + assert payload["summary"]["duplicate_count"] == 0 + assert payload["download_reference"]["format"] == "csv" + assert payload["download_reference"]["download_url"].startswith( + "/api/trades/import/reports/" + ) + + download_response = client.get(payload["download_reference"]["download_url"]) + assert download_response.status_code == 200 + assert download_response.headers["content-type"].startswith("text/csv") + assert "row_number,outcome,field" in download_response.text + assert "success" in download_response.text + + +def test_import_api_serializes_blank_required_cell_failures_without_nan(tmp_path: Path): + client = _make_client(tmp_path / "trading.db") + workbook = _excel_bytes( + tmp_path, + [ + { + "交易对(币对)": "btc/usdt", + "方向": "做多", + "开仓均价": None, + "开仓时间": "2024-01-02 09:15:00", + } + ], + ) + + response = client.post( + "/api/trades/import", + files={ + "file": ( + "blank-entry-price.xlsx", + workbook, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["summary"]["failed_count"] == 1 + assert payload["row_outcomes"][0]["field"] == "entry_price" + assert payload["row_outcomes"][0]["raw_value"] is None + + +def test_import_api_accepts_manual_rows_payload(tmp_path: Path): + client = _make_client(tmp_path / "trading.db") + db = database.SessionLocal() + db.add( + Trade( + symbol="BTC-USDT", + direction="long", + leverage=1.0, + entry_price=1234.5, + exit_price=1400.0, + profit=1000.0, + profit_rate=0.125, + margin=500.0, + entry_time=1704158100000, + exit_time=1704161700000, + ) + ) + db.commit() + db.close() + + response = client.post( + "/api/trades/import/rows", + json={ + "source_filename": "manual-entry", + "rows": [ + { + "symbol": "btc/usdt", + "direction": "做多", + "entry_price": "1,234.5", + "exit_price": "1,400.0", + "profit_rate": "12.5%", + "profit": "1,000", + "margin": "500", + "entry_time": "2024-01-02 09:15:00", + "exit_time": "2024-01-02 10:15:00", + }, + { + "symbol": "eth/usdt", + "direction": "做空", + "entry_price": "2200", + "exit_price": "2000", + "profit_rate": "-9.1%", + "profit": "-200", + "margin": "300", + "entry_time": "2024-01-03 09:15:00", + "exit_time": "2024-01-03 10:15:00", + }, + ], + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["summary"]["total_rows"] == 2 + assert payload["summary"]["success_count"] == 1 + assert payload["summary"]["duplicate_count"] == 1 + assert payload["summary"]["failed_count"] == 0 + assert payload["download_reference"]["download_url"].startswith( + "/api/trades/import/reports/" + ) + assert [row["outcome"] for row in payload["row_outcomes"]] == [ + "duplicate", + "success", + ] diff --git a/backend/tests/test_import_contracts.py b/backend/tests/test_import_contracts.py new file mode 100644 index 0000000..2715404 --- /dev/null +++ b/backend/tests/test_import_contracts.py @@ -0,0 +1,50 @@ +from dataclasses import is_dataclass, fields + +from services import import_types + + +def test_import_contract_exposes_explicit_outcome_buckets(): + assert import_types.IMPORT_OUTCOME_BUCKETS == ( + "success", + "failed", + "duplicate", + "conflict", + "timestamp_normalization", + ) + + +def test_import_row_outcome_carries_row_field_raw_reason_and_normalized_value(): + assert is_dataclass(import_types.ImportRowOutcome) + field_names = {field.name for field in fields(import_types.ImportRowOutcome)} + assert {"row_number", "field", "raw_value", "reason", "normalized_value"}.issubset( + field_names + ) + assert "outcome" in field_names + + +def test_missing_required_columns_rejection_is_explicit(): + assert is_dataclass(import_types.ImportFileRejection) + field_names = {field.name for field in fields(import_types.ImportFileRejection)} + assert {"reason", "missing_columns", "required_columns"}.issubset(field_names) + assert import_types.REQUIRED_IMPORT_COLUMNS == ( + "symbol", + "entry_price", + "entry_time", + ) + + +def test_import_report_includes_download_reference_metadata(): + assert is_dataclass(import_types.ImportReport) + field_names = {field.name for field in fields(import_types.ImportReport)} + assert { + "summary", + "row_outcomes", + "normalization_events", + "download_reference", + }.issubset(field_names) + + assert is_dataclass(import_types.ImportReportDownloadReference) + download_fields = { + field.name for field in fields(import_types.ImportReportDownloadReference) + } + assert {"download_url", "filename", "mime_type", "format"}.issubset(download_fields) diff --git a/backend/tests/test_migration_runner.py b/backend/tests/test_migration_runner.py new file mode 100644 index 0000000..39d4b26 --- /dev/null +++ b/backend/tests/test_migration_runner.py @@ -0,0 +1,122 @@ +from pathlib import Path +from typing import cast + +from sqlalchemy import Connection, create_engine, text + +from migrations.runner import MigrationRunner, MigrationStep + + +def test_migration_runner_creates_backup_before_applying_migrations( + sqlite_database_url: str, tmp_path: Path +): + engine = create_engine(sqlite_database_url) + backup_dir = tmp_path / "backups" + observed_backup_files: list[Path] = [] + + def apply_migration(connection: Connection) -> None: + observed_backup_files.extend(sorted(backup_dir.glob("*.sqlite3"))) + _ = connection.execute( + text("CREATE TABLE sentinel_table (id INTEGER PRIMARY KEY)") + ) + + runner = MigrationRunner( + engine=engine, + migrations=[ + MigrationStep(version="001", name="bootstrap", apply=apply_migration) + ], + backup_dir=backup_dir, + ) + + result = runner.run() + + assert result == "001" + assert observed_backup_files + assert observed_backup_files[0].exists() + + +def test_migration_runner_records_schema_version_and_noops_when_current( + sqlite_database_url: str, tmp_path: Path +): + engine = create_engine(sqlite_database_url) + backup_dir = tmp_path / "backups" + calls: list[str] = [] + + def apply_migration(connection: Connection) -> None: + calls.append("called") + _ = connection.execute( + text("CREATE TABLE bootstrap_marker (id INTEGER PRIMARY KEY)") + ) + + runner = MigrationRunner( + engine=engine, + migrations=[ + MigrationStep(version="001", name="bootstrap", apply=apply_migration) + ], + backup_dir=backup_dir, + ) + + first_result = runner.run() + second_result = runner.run() + + with engine.connect() as connection: + version = cast( + str, + connection.execute( + text("SELECT version FROM schema_version LIMIT 1") + ).scalar_one(), + ) + + backup_files = sorted(backup_dir.glob("*.sqlite3")) + + assert first_result == "001" + assert second_result == "001" + assert version == "001" + assert calls == ["called"] + assert len(backup_files) == 1 + + +def test_migration_runner_preserves_existing_rows_on_legacy_database( + sqlite_database_url: str, tmp_path: Path +): + engine = create_engine(sqlite_database_url) + backup_dir = tmp_path / "backups" + + with engine.begin() as connection: + _ = connection.execute( + text("CREATE TABLE trades (id INTEGER PRIMARY KEY, symbol TEXT NOT NULL)") + ) + _ = connection.execute( + text("INSERT INTO trades (id, symbol) VALUES (1, 'BTC-USDT')") + ) + + def bootstrap_legacy_schema(connection: Connection) -> None: + _ = connection.execute( + text("CREATE TABLE sentinel_table (id INTEGER PRIMARY KEY)") + ) + + runner = MigrationRunner( + engine=engine, + migrations=[ + MigrationStep( + version="001", name="bootstrap", apply=bootstrap_legacy_schema + ) + ], + backup_dir=backup_dir, + ) + + result = runner.run() + + with engine.connect() as connection: + count = cast( + int, connection.execute(text("SELECT COUNT(*) FROM trades")).scalar_one() + ) + version = cast( + str, + connection.execute( + text("SELECT version FROM schema_version LIMIT 1") + ).scalar_one(), + ) + + assert result == "001" + assert count == 1 + assert version == "001" diff --git a/backend/tests/test_startup_migrations.py b/backend/tests/test_startup_migrations.py new file mode 100644 index 0000000..8f18648 --- /dev/null +++ b/backend/tests/test_startup_migrations.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import importlib +import sys + +import database + + +def _pop_module(name: str) -> None: + sys.modules.pop(name, None) + + +def test_main_invokes_migrations_before_schema_bootstrap(monkeypatch): + events: list[str] = [] + + class RecordingMigrationRunner: + def __init__(self, *args, **kwargs): + events.append("runner.init") + + def run(self) -> str: + events.append("runner.run") + return "001" + + def record_create_all(*args, **kwargs): + events.append("create_all") + + import migrations.runner as migration_runner + + monkeypatch.setattr(migration_runner, "MigrationRunner", RecordingMigrationRunner) + monkeypatch.setattr(database.Base.metadata, "create_all", record_create_all) + + _pop_module("main") + _ = importlib.import_module("main") + + assert "runner.run" in events + if "create_all" in events: + assert events.index("runner.run") < events.index("create_all") + + +def test_import_data_invokes_migrations_before_startup_schema_access(monkeypatch): + events: list[str] = [] + + class RecordingMigrationRunner: + def __init__(self, *args, **kwargs): + events.append("runner.init") + + def run(self) -> str: + events.append("runner.run") + return "001" + + class RecordingQuery: + def count(self) -> int: + events.append("query_count") + return 0 + + class RecordingSession: + def query(self, model): + events.append("session.query") + return RecordingQuery() + + def close(self) -> None: + events.append("session.close") + + def record_create_all(*args, **kwargs): + events.append("create_all") + + def record_session_local(): + events.append("session_factory") + return RecordingSession() + + import migrations.runner as migration_runner + + monkeypatch.setattr(migration_runner, "MigrationRunner", RecordingMigrationRunner) + monkeypatch.setattr(database.Base.metadata, "create_all", record_create_all) + monkeypatch.setattr(database, "SessionLocal", record_session_local) + + _pop_module("import_data") + import_data = importlib.import_module("import_data") + monkeypatch.setattr(import_data.os.path, "exists", lambda *_args, **_kwargs: False) + + import_data.main() + + assert "runner.run" in events + assert events.index("runner.run") < events.index("query_count") + if "create_all" in events: + assert events.index("runner.run") < events.index("create_all") diff --git a/backend/tests/test_trade_importer.py b/backend/tests/test_trade_importer.py new file mode 100644 index 0000000..f8eed7e --- /dev/null +++ b/backend/tests/test_trade_importer.py @@ -0,0 +1,223 @@ +from pathlib import Path + +import pandas as pd +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from database import Base +from models import Trade +from services import import_types +from services.trade_importer import trade_importer + + +def _make_session(db_path: Path): + engine = create_engine( + f"sqlite:///{db_path}", connect_args={"check_same_thread": False} + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autocommit=False, autoflush=False)() + + +def _write_excel(path: Path, rows: list[dict[str, object]]) -> Path: + pd.DataFrame(rows).to_excel(path, index=False) + return path + + +def test_D02_missing_required_columns_rejects_the_entire_file(tmp_path: Path): + """D-02: missing required columns rejects the whole workbook.""" + + db = _make_session(tmp_path / "trading.db") + workbook = _write_excel( + tmp_path / "missing_required_columns.xlsx", + [ + { + "交易对(币对)": "btc/usdt", + "方向": "做多", + "开仓均价": "1234.5", + } + ], + ) + + report = trade_importer.parse_excel(db, str(workbook)) + + assert isinstance(report, import_types.ImportReport) + assert report.file_rejection is not None + assert report.file_rejection.missing_columns == ("entry_time",) + assert report.summary.total_rows == 0 + assert report.summary.success_count == 0 + assert report.row_outcomes == () + assert report.normalization_events == () + + +def test_D09_D10_D12_importer_returns_structured_report_for_mixed_rows(tmp_path: Path): + """D-09/D-10/D-12: mixed rows keep row-level outcomes, normalization, and report metadata.""" + + db = _make_session(tmp_path / "trading.db") + db.add( + Trade( + symbol="BTC-USDT", + direction="long", + leverage=1.0, + entry_price=1234.5, + exit_price=1400.0, + profit=1000.0, + profit_rate=0.125, + margin=500.0, + entry_time=1704158100000, + exit_time=1704161700000, + ) + ) + db.commit() + + workbook = _write_excel( + tmp_path / "mixed_rows.xlsx", + [ + { + "交易对(币对)": "btc/usdt", + "方向": "做多", + "开仓均价": "1,234.5", + "平仓均价": "1,400.0", + "收益率": "12.5%", + "收益 (USDT)": "1,000", + "保证金(最大时)": "500", + "开仓时间": "2024-01-02 09:15:00", + "平仓时间": "2024-01-02 10:15:00", + }, + { + "交易对(币对)": "btc/usdt", + "方向": "做多", + "开仓均价": "1,234.5", + "平仓均价": "1,400.0", + "收益率": "12.5%", + "收益 (USDT)": "1,000", + "保证金(最大时)": "600", + "开仓时间": "2024-01-02 09:15:00", + "平仓时间": "2024-01-02 10:15:00", + }, + { + "交易对(币对)": "eth/usdt", + "方向": "做空", + "开仓均价": "2200", + "平仓均价": "2000", + "收益率": "-9.1%", + "收益 (USDT)": "-200", + "保证金(最大时)": "300", + "开仓时间": "2024-01-03 09:15:00", + "平仓时间": "2024-01-03 10:15:00", + }, + { + "交易对(币对)": "eth/usdt", + "方向": "做空", + "开仓均价": "2200", + "平仓均价": "2000", + "收益率": "-9.1%", + "收益 (USDT)": "-200", + "保证金(最大时)": "300", + "开仓时间": "2024-01-03 09:15:00", + "平仓时间": "2024-01-03 10:15:00", + }, + { + "交易对(币对)": "xrp/usdt", + "方向": "做多", + "开仓均价": "not-a-number", + "平仓均价": "0.52", + "收益率": "5%", + "收益 (USDT)": "12", + "保证金(最大时)": "50", + "开仓时间": "2024-01-04 09:15:00", + "平仓时间": "2024-01-04 10:15:00", + }, + ], + ) + + report = trade_importer.parse_excel(db, str(workbook)) + + assert isinstance(report, import_types.ImportReport) + assert report.file_rejection is None + assert report.summary.total_rows == 5 + assert report.summary.success_count == 1 + assert report.summary.duplicate_count == 2 + assert report.summary.conflict_count == 1 + assert report.summary.failed_count == 1 + assert report.summary.timestamp_normalization_count == 8 + assert db.query(Trade).count() == 2 + assert report.download_reference is not None + assert report.download_reference.format == "csv" + assert report.download_reference.mime_type == "text/csv" + assert report.download_reference.download_url + assert len(report.row_outcomes) == 5 + assert len(report.normalization_events) == 8 + assert any(outcome.outcome == "duplicate" for outcome in report.row_outcomes) + assert any(outcome.outcome == "conflict" for outcome in report.row_outcomes) + assert any(outcome.outcome == "failed" for outcome in report.row_outcomes) + + +def test_manual_rows_import_reuses_structured_report_pipeline(tmp_path: Path): + db = _make_session(tmp_path / "trading.db") + db.add( + Trade( + symbol="BTC-USDT", + direction="long", + leverage=1.0, + entry_price=1234.5, + exit_price=1400.0, + profit=1000.0, + profit_rate=0.125, + margin=500.0, + entry_time=1704158100000, + exit_time=1704161700000, + ) + ) + db.commit() + + report = trade_importer.parse_rows( + db, + [ + { + "symbol": "btc/usdt", + "direction": "做多", + "entry_price": "1,234.5", + "exit_price": "1,400.0", + "profit_rate": "12.5%", + "profit": "1,000", + "margin": "500", + "entry_time": "2024-01-02 09:15:00", + "exit_time": "2024-01-02 10:15:00", + }, + { + "symbol": "eth/usdt", + "direction": "做空", + "entry_price": "2200", + "exit_price": "2000", + "profit_rate": "-9.1%", + "profit": "-200", + "margin": "300", + "entry_time": "2024-01-03 09:15:00", + "exit_time": "2024-01-03 10:15:00", + }, + { + "symbol": "xrp/usdt", + "direction": "做多", + "entry_price": "not-a-number", + "entry_time": "2024-01-04 09:15:00", + }, + ], + source_filename="manual-entry", + ) + + assert isinstance(report, import_types.ImportReport) + assert report.file_rejection is None + assert report.summary.total_rows == 3 + assert report.summary.success_count == 1 + assert report.summary.duplicate_count == 1 + assert report.summary.conflict_count == 0 + assert report.summary.failed_count == 1 + assert report.summary.timestamp_normalization_count == 4 + assert db.query(Trade).count() == 2 + assert report.download_reference is not None + assert report.download_reference.filename.startswith("import-report-manual-entry-") + assert [outcome.outcome for outcome in report.row_outcomes] == [ + "duplicate", + "success", + "failed", + ] diff --git a/backend/uv.lock b/backend/uv.lock index 9010969..362382f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -656,6 +656,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -665,6 +693,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "multidict" version = "6.7.0" @@ -873,6 +910,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + [[package]] name = "pandas" version = "2.3.3" @@ -927,6 +973,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -1223,6 +1278,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1348,8 +1428,10 @@ source = { editable = "." } dependencies = [ { name = "ccxt" }, { name = "fastapi" }, + { name = "httpx" }, { name = "openpyxl" }, { name = "pandas" }, + { name = "pytest" }, { name = "python-multipart" }, { name = "pytz" }, { name = "sqlalchemy" }, @@ -1360,8 +1442,10 @@ dependencies = [ requires-dist = [ { name = "ccxt", specifier = ">=4.4.26" }, { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "openpyxl", specifier = ">=3.1.5" }, { name = "pandas", specifier = ">=2.2.3" }, + { name = "pytest", specifier = ">=8.3.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "pytz", specifier = ">=2024.1" }, { name = "sqlalchemy", specifier = ">=2.0.35" }, diff --git a/docs/images/analysis.png b/docs/images/analysis.png index c356976..cd85e29 100644 Binary files a/docs/images/analysis.png and b/docs/images/analysis.png differ diff --git a/docs/images/import.png b/docs/images/import.png new file mode 100644 index 0000000..daab997 Binary files /dev/null and b/docs/images/import.png differ diff --git a/docs/images/replay.png b/docs/images/replay.png index 1a3fd69..8e00944 100644 Binary files a/docs/images/replay.png and b/docs/images/replay.png differ diff --git a/frontend/package.json b/frontend/package.json index 23ce8d4..0d99d9e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.1", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -30,8 +33,10 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "26.1.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "3.2.4" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5b5564e..5712d25 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -39,6 +39,15 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.2 + '@testing-library/jest-dom': + specifier: 6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@testing-library/user-event': + specifier: 14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: ^24.10.1 version: 24.10.6 @@ -63,6 +72,9 @@ importers: globals: specifier: ^16.5.0 version: 16.5.0 + jsdom: + specifier: 26.1.0 + version: 26.1.0 typescript: specifier: ~5.9.3 version: 5.9.3 @@ -72,9 +84,18 @@ importers: vite: specifier: ^7.2.4 version: 7.3.1(@types/node@24.10.6)(jiti@2.6.1)(lightningcss@1.30.2) + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/node@24.10.6)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2) packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -146,6 +167,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -158,6 +183,34 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -602,6 +655,38 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -614,6 +699,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -696,6 +787,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -706,16 +826,39 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -740,6 +883,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -751,10 +898,18 @@ packages: caniuse-lite@1.0.30001763: resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -780,12 +935,23 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} daisyui@5.5.14: resolution: {integrity: sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -795,6 +961,13 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -802,10 +975,20 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -817,6 +1000,10 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -825,6 +1012,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -895,10 +1085,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fancy-canvas@2.1.0: resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==} @@ -1009,6 +1206,22 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1025,6 +1238,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1033,6 +1250,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1043,10 +1263,22 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1153,6 +1385,12 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1161,6 +1399,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1176,6 +1418,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1197,6 +1443,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1213,6 +1462,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1221,6 +1473,13 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1236,6 +1495,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -1248,6 +1511,9 @@ packages: peerDependencies: react: ^19.2.3 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -1273,6 +1539,10 @@ packages: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1282,6 +1552,16 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1305,18 +1585,37 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} @@ -1324,10 +1623,43 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -1362,6 +1694,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1402,15 +1739,88 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1429,6 +1839,16 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -1518,6 +1938,8 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.29.2': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -1541,6 +1963,26 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@esbuild/aix-ppc64@0.27.2': optional: true @@ -1840,6 +2282,42 @@ snapshots: tailwindcss: 4.1.18 vite: 7.3.1(@types/node@24.10.6)(jiti@2.6.1)(lightningcss@1.30.2) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -1861,6 +2339,13 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -1980,12 +2465,56 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.10.6)(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.10.6)(jiti@2.6.1)(lightningcss@1.30.2) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + agent-base@7.1.4: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -1993,12 +2522,24 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + asynckit@0.4.0: {} axios@1.13.2: @@ -2030,6 +2571,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2039,11 +2582,21 @@ snapshots: caniuse-lite@1.0.30001763: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + check-error@2.1.3: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2066,20 +2619,42 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} daisyui@5.5.14: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + deep-is@0.1.4: {} delayed-stream@1.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2093,10 +2668,14 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@6.0.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -2222,8 +2801,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + fancy-canvas@2.1.0: {} fast-deep-equal@3.1.3: {} @@ -2317,6 +2902,28 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2328,22 +2935,55 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} jiti@2.6.1: {} js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.20.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -2422,6 +3062,10 @@ snapshots: lodash.merge@4.6.2: {} + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -2430,6 +3074,8 @@ snapshots: dependencies: react: 19.2.3 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2442,6 +3088,8 @@ snapshots: dependencies: mime-db: 1.52.0 + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -2458,6 +3106,8 @@ snapshots: node-releases@2.0.27: {} + nwsapi@2.2.23: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2479,10 +3129,18 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -2495,6 +3153,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + proxy-from-env@1.1.0: {} punycode@2.3.1: {} @@ -2504,6 +3168,8 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.18.0: {} react-router-dom@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -2522,6 +3188,11 @@ snapshots: react@19.2.3: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + resolve-from@4.0.0: {} rollup@4.55.1: @@ -2555,6 +3226,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.55.1 fsevents: 2.3.3 + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -2569,23 +3248,63 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tailwindcss@4.1.18: {} tapable@2.3.0: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -2619,6 +3338,27 @@ snapshots: dependencies: punycode: 2.3.1 + vite-node@3.2.4(@types/node@24.10.6)(jiti@2.6.1)(lightningcss@1.30.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@24.10.6)(jiti@2.6.1)(lightningcss@1.30.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@7.3.1(@types/node@24.10.6)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.2 @@ -2633,12 +3373,82 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 + vitest@3.2.4(@types/node@24.10.6)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.6)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@24.10.6)(jiti@2.6.1)(lightningcss@1.30.2) + vite-node: 3.2.4(@types/node@24.10.6)(jiti@2.6.1)(lightningcss@1.30.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.6 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + ws@8.20.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fe5b28f..f43cb09 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,9 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import Navbar from './components/Navbar'; import AnalysisPage from './pages/AnalysisPage'; -import ReplayPage from './pages/ReplayPage'; +import ImportPage from './pages/ImportPage'; import LearnPage from './pages/LearnPage'; +import ReplayPage from './pages/ReplayPage'; function App() { return ( @@ -12,6 +13,7 @@ function App() { } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/ChartManager.tsx b/frontend/src/components/ChartManager.tsx index 9b6737d..14fdea8 100644 --- a/frontend/src/components/ChartManager.tsx +++ b/frontend/src/components/ChartManager.tsx @@ -1,1066 +1,1793 @@ -import { useEffect, useMemo, useRef, useCallback, useState } from 'react'; -import { createChart, CandlestickSeries, HistogramSeries, LineSeries, createSeriesMarkers } from 'lightweight-charts'; -import { Maximize2, Minimize2 } from 'lucide-react'; -import type { CandlestickData, HistogramData, LineData, SeriesMarker, Time } from 'lightweight-charts'; -import { fetchKlines, fetchStats } from '../services/api'; -import type { Kline, StatsOverview, Trade, Timeframe } from '../services/api'; - -const TIMEFRAMES: Timeframe[] = ['5m', '15m', '1h', '4h', '1d']; +import type { + CandlestickData, + HistogramData, + IChartApi, + ISeriesApi, + LineData, + MouseEventParams, + SeriesMarker, + SeriesType, + Time, +} from "lightweight-charts"; +import { + CandlestickSeries, + createChart, + createSeriesMarkers, + HistogramSeries, + LineSeries, +} from "lightweight-charts"; +import { Maximize2, Minimize2 } from "lucide-react"; +import { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; +import type { Kline, StatsOverview, Timeframe, Trade } from "../services/api"; +import { fetchKlines, fetchStats } from "../services/api"; +import { type CompareMode, resolveComparePane } from "../utils/comparePane"; +import { + buildReplayProgressStorageKey, + createInitialReplayPlaybackState, + createReplayProgressRecord, + deserializeReplayProgress, + replayPlaybackReducer, + serializeReplayProgress, +} from "../utils/replayPlayback"; +import { loadReplayWorkspace, saveReplayWorkspace } from "../utils/replayWorkspace"; +import CompareModeControls from "./CompareModeControls"; +import ReplayControls from "./ReplayControls"; + +const TIMEFRAMES: Timeframe[] = ["5m", "15m", "1h", "4h", "1d"]; const TIMEFRAME_MS: Record = { - '5m': 5 * 60 * 1000, - '15m': 15 * 60 * 1000, - '1h': 60 * 60 * 1000, - '4h': 4 * 60 * 60 * 1000, - '1d': 24 * 60 * 60 * 1000, + "5m": 5 * 60 * 1000, + "15m": 15 * 60 * 1000, + "1h": 60 * 60 * 1000, + "4h": 4 * 60 * 60 * 1000, + "1d": 24 * 60 * 60 * 1000, }; const TIME_FILLER_BUFFER_BARS = 1200; const MAX_TIME_FILLER_POINTS = 20000; -const DEFAULT_COMPARE_SYMBOL = 'BTC-USDT'; +const DEFAULT_COMPARE_SYMBOL = "BTC-USDT"; const TRADE_FETCH_BUFFER_BARS = 2026; const TRADE_VIEW_BUFFER_BARS = 200; +const REPLAY_CURSOR_BUFFER_BARS = 60; +const REPLAY_INTERVAL_BASE_MS = 900; const UTC8_OFFSET_SEC = 8 * 60 * 60; -function alignTimeSec(timeSec: number, stepSec: number, mode: 'floor' | 'ceil' = 'floor') { - if (!Number.isFinite(timeSec) || !Number.isFinite(stepSec) || stepSec <= 0) return timeSec; - if (mode === 'ceil') return Math.ceil(timeSec / stepSec) * stepSec; - return Math.floor(timeSec / stepSec) * stepSec; +function alignTimeSec( + timeSec: number, + stepSec: number, + mode: "floor" | "ceil" = "floor", +) { + if (!Number.isFinite(timeSec) || !Number.isFinite(stepSec) || stepSec <= 0) + return timeSec; + if (mode === "ceil") return Math.ceil(timeSec / stepSec) * stepSec; + return Math.floor(timeSec / stepSec) * stepSec; +} + +function resolveNearestCandleTime(candlesByTime: Map, time: Time) { + if (candlesByTime.has(time)) return time; + + const target = Number(time); + if (!Number.isFinite(target)) return null; + + let nearestTime: Time | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + + for (const candleTime of candlesByTime.keys()) { + const candidate = Number(candleTime); + if (!Number.isFinite(candidate)) continue; + const distance = Math.abs(candidate - target); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestTime = candleTime; + } + } + + return nearestTime; } function formatUtc8Time(time: Time): string { - if (typeof time === 'object' && time && 'year' in time) { - const { year, month, day } = time; - const mm = String(month).padStart(2, '0'); - const dd = String(day).padStart(2, '0'); - return `${year}-${mm}-${dd}`; - } - const seconds = Number(time); - if (!Number.isFinite(seconds)) return ''; - const date = new Date((seconds + UTC8_OFFSET_SEC) * 1000); - const y = date.getUTCFullYear(); - const m = String(date.getUTCMonth() + 1).padStart(2, '0'); - const d = String(date.getUTCDate()).padStart(2, '0'); - const hh = String(date.getUTCHours()).padStart(2, '0'); - const mm = String(date.getUTCMinutes()).padStart(2, '0'); - return `${y}-${m}-${d} ${hh}:${mm}`; + if (typeof time === "object" && time && "year" in time) { + const { year, month, day } = time; + const mm = String(month).padStart(2, "0"); + const dd = String(day).padStart(2, "0"); + return `${year}-${mm}-${dd}`; + } + const seconds = Number(time); + if (!Number.isFinite(seconds)) return ""; + const date = new Date((seconds + UTC8_OFFSET_SEC) * 1000); + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, "0"); + const d = String(date.getUTCDate()).padStart(2, "0"); + const hh = String(date.getUTCHours()).padStart(2, "0"); + const mm = String(date.getUTCMinutes()).padStart(2, "0"); + return `${y}-${m}-${d} ${hh}:${mm}`; } interface Props { - symbol: string; - selectedTrade: Trade | null; + symbol: string; + selectedTrade: Trade | null; + initialTimeframe?: Timeframe; } -export default function ChartManager({ symbol, selectedTrade }: Props) { - const containerRef = useRef(null); - const compareContainerRef = useRef(null); - const chartRef = useRef(null); - const seriesRef = useRef(null); - const volumeSeriesRef = useRef(null); - const compareChartRef = useRef(null); - const compareSeriesRef = useRef(null); - const compareVolumeSeriesRef = useRef(null); - const markersRef = useRef(null); - const priceLinesRef = useRef<{ entry?: any; exit?: any }>({}); - const latestMainRequestIdRef = useRef(0); - const latestCompareRequestIdRef = useRef(0); - const syncingRangeRef = useRef(false); - const compareEnabledRef = useRef(false); - const syncingCrosshairRef = useRef(false); - const crosshairSyncRafRef = useRef(null); - const pendingCrosshairSyncRef = useRef<{ source: 'main' | 'compare'; time: Time | null } | null>(null); +type ChartApi = IChartApi; +type ChartSeriesApi = ISeriesApi; +type PriceLineApi = ReturnType; +type SeriesMarkersApi = { + setMarkers: (markers: SeriesMarker