From 26eb8eb95bd2a21a1210600c63c391dcfc63f823 Mon Sep 17 00:00:00 2001 From: Xeron Date: Sat, 11 Apr 2026 12:15:04 +0800 Subject: [PATCH 01/11] chore: add project config --- .planning/config.json | 44 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .planning/config.json 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 From ab68d545f0fd42ba108b7891a9e622bcda6bfc1a Mon Sep 17 00:00:00 2001 From: Xeron Date: Sat, 11 Apr 2026 12:29:04 +0800 Subject: [PATCH 02/11] docs: complete project research --- .planning/research/ARCHITECTURE.md | 184 +++++++++++++++++++++++++++++ .planning/research/FEATURES.md | 48 ++++++++ .planning/research/PITFALLS.md | 113 ++++++++++++++++++ .planning/research/STACK.md | 135 +++++++++++++++++++++ .planning/research/SUMMARY.md | 153 ++++++++++++++++++++++++ 5 files changed, 633 insertions(+) create mode 100644 .planning/research/ARCHITECTURE.md create mode 100644 .planning/research/FEATURES.md create mode 100644 .planning/research/PITFALLS.md create mode 100644 .planning/research/STACK.md create mode 100644 .planning/research/SUMMARY.md 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* From 911af0d9123f7f4b2bedfb6707db5325715085ed Mon Sep 17 00:00:00 2001 From: Xeron Date: Sat, 11 Apr 2026 12:30:02 +0800 Subject: [PATCH 03/11] docs: initialize project --- .planning/PROJECT.md | 77 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .planning/PROJECT.md 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* From eab19db49001005b999861a208452cf8cb81e1d7 Mon Sep 17 00:00:00 2001 From: Xeron Date: Sat, 11 Apr 2026 12:30:02 +0800 Subject: [PATCH 04/11] docs: add codebase map --- .planning/codebase/ARCHITECTURE.md | 118 ++++++++++++++++++++++++++ .planning/codebase/CONCERNS.md | 32 +++++++ .planning/codebase/CONVENTIONS.md | 40 +++++++++ .planning/codebase/INTEGRATIONS.md | 28 +++++++ .planning/codebase/STACK.md | 25 ++++++ .planning/codebase/STRUCTURE.md | 130 +++++++++++++++++++++++++++++ .planning/codebase/TESTING.md | 25 ++++++ 7 files changed, 398 insertions(+) create mode 100644 .planning/codebase/ARCHITECTURE.md create mode 100644 .planning/codebase/CONCERNS.md create mode 100644 .planning/codebase/CONVENTIONS.md create mode 100644 .planning/codebase/INTEGRATIONS.md create mode 100644 .planning/codebase/STACK.md create mode 100644 .planning/codebase/STRUCTURE.md create mode 100644 .planning/codebase/TESTING.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. From 19ab70548c6e0c4e73327be3d4fd1ac324d81ebc Mon Sep 17 00:00:00 2001 From: Xeron Date: Sat, 11 Apr 2026 12:36:37 +0800 Subject: [PATCH 05/11] docs: define v1 requirements --- .planning/REQUIREMENTS.md | 77 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .planning/REQUIREMENTS.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 0000000..29c4d95 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,77 @@ +# 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 | +|-------------|-------|--------| + +**Coverage:** +- v1 requirements: 18 total +- Mapped to phases: 0 +- Unmapped: 18 ⚠️ + +--- +*Requirements defined: 2026-04-11* +*Last updated: 2026-04-11 after initial definition* From 845830e25eaed9b4228e1e7f72b263ea23b13034 Mon Sep 17 00:00:00 2001 From: Xeron Date: Sat, 11 Apr 2026 12:39:50 +0800 Subject: [PATCH 06/11] docs: create roadmap (4 phases) --- .planning/REQUIREMENTS.md | 22 +++++- .planning/ROADMAP.md | 72 ++++++++++++++++++ .planning/STATE.md | 62 ++++++++++++++++ AGENTS.md | 149 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 .planning/ROADMAP.md create mode 100644 .planning/STATE.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 29c4d95..675d418 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -66,11 +66,29 @@ 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: 0 -- Unmapped: 18 ⚠️ +- Mapped to phases: 18 +- Unmapped: 0 ✓ --- *Requirements defined: 2026-04-11* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 0000000..8c76d5d --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,72 @@ +# 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 + +- [ ] **Phase 1: Import Reliability & Schema Evolution** - Make Excel/manual import trustworthy and keep local trade data stable. +- [ ] **Phase 2: Single-Trade Replay Core** - Open any trade fast and land on the correct replay window. +- [ ] **Phase 3: Multi-Timeframe Compare & Lightweight Analytics** - Add supporting comparison and metrics without turning into a dashboard. +- [ ] **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**: 4 plans +**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 +**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 +**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 +**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 | 0/4 | Not started | - | +| 2. Single-Trade Replay Core | 0/4 | Not started | - | +| 3. Multi-Timeframe Compare & Lightweight Analytics | 0/4 | Not started | - | +| 4. Local UX Polish & Performance Hardening | 0/3 | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 0000000..b9721cc --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,62 @@ +# Project State + +## Project Reference + +See: .planning/PROJECT.md (updated 2026-04-11) + +**Core value:** 导入一笔历史交易后,能够快速把它和对应 K 线、买卖点、时间区间对齐,并顺畅完成一次高质量复盘。 +**Current focus:** Phase 1: Import Reliability & Schema Evolution + +## Current Position + +Phase: 1 of 4 (Import Reliability & Schema Evolution) +Plan: 0 of 4 in current phase +Status: Ready to plan +Last activity: 2026-04-11 — Roadmap created and requirements mapped + +Progress: [░░░░░░░░░░] 0% + +## Performance Metrics + +**Velocity:** +- Total plans completed: 0 +- Average duration: - +- Total execution time: 0.0 hours + +**By Phase:** + +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| +| 1. Import Reliability & Schema Evolution | 0 | 4 | - | +| 2. Single-Trade Replay Core | 0 | 4 | - | +| 3. Multi-Timeframe Compare & Lightweight Analytics | 0 | 4 | - | +| 4. Local UX Polish & Performance Hardening | 0 | 3 | - | + +**Recent Trend:** +- Last 5 plans: - +- Trend: Stable + +## Accumulated Context + +### Decisions + +Recent decisions affecting current work: + +- [Phase 1]: Excel/manual import is the trusted v1 data entry path. +- [Phase 2]: Single-trade replay is the primary product value. +- [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. + +### Pending Todos + +None yet. + +### Blockers/Concerns + +None yet. + +## Session Continuity + +Last session: 2026-04-11 00:00 +Stopped at: Roadmap and state initialized +Resume file: None 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. + From ea168a695bb47d7df0889ec13d8f006c6e28a12e Mon Sep 17 00:00:00 2001 From: Xeron Date: Sat, 11 Apr 2026 19:58:43 +0800 Subject: [PATCH 07/11] feat(backend): add import reporting and local backup APIs Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/import_data.py | 15 +- backend/main.py | 96 ++- backend/migrations/__init__.py | 1 + backend/migrations/runner.py | 205 +++++ .../001_phase1_import_reliability.sql | 76 ++ backend/models.py | 103 ++- backend/pyproject.toml | 2 + backend/services/backup_service.py | 41 + backend/services/import_types.py | 75 ++ backend/services/trade_importer.py | 731 ++++++++++++++++-- backend/tests/conftest.py | 14 + backend/tests/test_backup_api.py | 86 +++ backend/tests/test_import_api.py | 221 ++++++ backend/tests/test_import_contracts.py | 50 ++ backend/tests/test_migration_runner.py | 122 +++ backend/tests/test_startup_migrations.py | 86 +++ backend/tests/test_trade_importer.py | 223 ++++++ backend/uv.lock | 84 ++ 18 files changed, 2120 insertions(+), 111 deletions(-) create mode 100644 backend/migrations/__init__.py create mode 100644 backend/migrations/runner.py create mode 100644 backend/migrations/versions/001_phase1_import_reliability.sql create mode 100644 backend/services/backup_service.py create mode 100644 backend/services/import_types.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_backup_api.py create mode 100644 backend/tests/test_import_api.py create mode 100644 backend/tests/test_import_contracts.py create mode 100644 backend/tests/test_migration_runner.py create mode 100644 backend/tests/test_startup_migrations.py create mode 100644 backend/tests/test_trade_importer.py 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" }, From 84bc8c3321e0be046b7060381ad57ef1fd001c9b Mon Sep 17 00:00:00 2001 From: Xeron Date: Sat, 11 Apr 2026 19:58:55 +0800 Subject: [PATCH 08/11] feat(frontend): add import workspace and manual entry flows Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/package.json | 7 +- frontend/pnpm-lock.yaml | 810 ++++++++++++++++++ frontend/src/App.tsx | 4 +- frontend/src/components/Navbar.tsx | 15 +- .../components/import/ImportContractNote.tsx | 25 + .../src/components/import/ImportDropzone.tsx | 73 ++ .../components/import/ImportResultsTable.tsx | 162 ++++ .../src/components/import/ImportSummary.tsx | 28 + .../import/LocalSafekeepingPanel.tsx | 114 +++ .../components/import/ManualImportPanel.tsx | 122 +++ frontend/src/pages/ImportPage.tsx | 161 ++++ frontend/src/services/api.ts | 30 +- frontend/src/services/importReportAdapter.ts | 175 ++++ frontend/src/services/importTypes.ts | 52 ++ frontend/src/test/ImportPage.test.tsx | 140 +++ frontend/src/test/import-api.contract.test.ts | 219 +++++ .../test/import-page-manual-entry.test.tsx | 88 ++ frontend/src/test/import-route-smoke.test.tsx | 91 ++ .../src/test/localSafekeepingPanel.test.tsx | 60 ++ frontend/src/test/setup.ts | 7 + frontend/vitest.config.ts | 12 + 21 files changed, 2391 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/import/ImportContractNote.tsx create mode 100644 frontend/src/components/import/ImportDropzone.tsx create mode 100644 frontend/src/components/import/ImportResultsTable.tsx create mode 100644 frontend/src/components/import/ImportSummary.tsx create mode 100644 frontend/src/components/import/LocalSafekeepingPanel.tsx create mode 100644 frontend/src/components/import/ManualImportPanel.tsx create mode 100644 frontend/src/pages/ImportPage.tsx create mode 100644 frontend/src/services/importReportAdapter.ts create mode 100644 frontend/src/services/importTypes.ts create mode 100644 frontend/src/test/ImportPage.test.tsx create mode 100644 frontend/src/test/import-api.contract.test.ts create mode 100644 frontend/src/test/import-page-manual-entry.test.tsx create mode 100644 frontend/src/test/import-route-smoke.test.tsx create mode 100644 frontend/src/test/localSafekeepingPanel.test.tsx create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/vitest.config.ts 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/Navbar.tsx b/frontend/src/components/Navbar.tsx index 0543053..454d926 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,5 +1,5 @@ +import { BrainCircuit, GraduationCap, History, LineChart, SquareArrowOutUpRight } from 'lucide-react'; import { NavLink } from 'react-router-dom'; -import { BrainCircuit, History, LineChart, GraduationCap } from 'lucide-react'; export default function Navbar() { return ( @@ -28,6 +28,19 @@ export default function Navbar() { 复盘 + + `flex items-center gap-2 px-5 py-2 rounded-lg text-sm font-medium transition-all ${ + isActive + ? 'bg-primary text-primary-content' + : 'text-base-content/70 hover:text-base-content hover:bg-base-200' + }` + } + > + + 导入 + diff --git a/frontend/src/components/import/ImportContractNote.tsx b/frontend/src/components/import/ImportContractNote.tsx new file mode 100644 index 0000000..640fa6a --- /dev/null +++ b/frontend/src/components/import/ImportContractNote.tsx @@ -0,0 +1,25 @@ +export default function ImportContractNote() { + return ( +
+
+

导入契约

+

先看系统怎么理解你的表

+
+ +
+
+

必填列

+

symbol、entry_price、entry_time

+
+
+

系统会归一

+

时区时间、数字格式、常见列头别名

+
+
+

重复、冲突、缺列、无法解析的行会被单独标出

+

方便你回到 Excel 逐行修正。

+
+
+
+ ); +} diff --git a/frontend/src/components/import/ImportDropzone.tsx b/frontend/src/components/import/ImportDropzone.tsx new file mode 100644 index 0000000..a0977a3 --- /dev/null +++ b/frontend/src/components/import/ImportDropzone.tsx @@ -0,0 +1,73 @@ +import { useRef } from 'react'; +import { CloudUpload } from 'lucide-react'; + +interface ImportDropzoneProps { + file: File | null; + isUploading: boolean; + onFileChange: (file: File | null) => void; + onSubmit: () => void; +} + +export default function ImportDropzone({ file, isUploading, onFileChange, onSubmit }: ImportDropzoneProps) { + const inputRef = useRef(null); + + return ( +
+ + + onFileChange(event.target.files?.item(0) ?? null)} + /> + +
+
+ Excel + 单文件上传 + 支持重复提交 + 行级报告 + {file ? 已选:{file.name} : null} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/import/ImportResultsTable.tsx b/frontend/src/components/import/ImportResultsTable.tsx new file mode 100644 index 0000000..d62d366 --- /dev/null +++ b/frontend/src/components/import/ImportResultsTable.tsx @@ -0,0 +1,162 @@ +import type { ReactNode } from 'react'; +import type { FileValidationError, ImportReport, ImportRowOutcome, NormalizationEvent } from '../../services/importTypes'; + +interface ImportResultsTableProps { + report: ImportReport; + statusFilter: 'all' | ImportRowOutcome['status']; + onStatusFilterChange: (status: 'all' | ImportRowOutcome['status']) => void; +} + +const statusLabels: Record = { + success: '成功', + failed: '失败', + duplicate: '重复', + conflict: '冲突', +}; + +const statusBadgeClasses: Record = { + success: 'badge-success', + failed: 'badge-error', + duplicate: 'badge-warning', + conflict: 'badge-info', +}; + +function valueLabel(value: string[] | undefined) { + return value && value.length > 0 ? value.join('、') : '—'; +} + +function formatScalar(value: ImportRowOutcome['raw_value']) { + return value === null ? '—' : String(value); +} + +function FilterButton({ + active, + children, + onClick, +}: { + active: boolean; + children: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +function renderFileValidationErrors(errors: FileValidationError[]) { + if (errors.length === 0) { + return null; + } + + return ( +
+
+

文件被拒绝

+ {errors.length} 个错误 +
+
    + {errors.map((error) => ( +
  • +

    {error.message}

    + {error.missing_columns ? ( +

    缺失列:{valueLabel(error.missing_columns)}

    + ) : null} +
  • + ))} +
+
+ ); +} + +function renderNormalizationEvents(events: NormalizationEvent[]) { + if (events.length === 0) { + return null; + } + + return ( +
+
+

归一化证据

+ {events.length} 条 +
+
+ {events.map((event) => ( +
+
+ 第 {event.row_number} 行 + {event.field} +
+

{event.reason}

+

{formatScalar(event.raw_value)} → {formatScalar(event.normalized_value)}

+
+ ))} +
+
+ ); +} + +export default function ImportResultsTable({ report, statusFilter, onStatusFilterChange }: ImportResultsTableProps) { + const filteredRows = + statusFilter === 'all' ? report.row_outcomes : report.row_outcomes.filter((row) => row.status === statusFilter); + + return ( +
+ {renderFileValidationErrors(report.file_validation_errors)} + {renderNormalizationEvents(report.normalization_events)} + + {report.row_outcomes.length > 0 ? ( +
+
+
+

处理结果

+

按行查看成功、失败、重复和冲突,方便快速确认系统如何理解这次导入。

+
+
+ onStatusFilterChange('all')}> + 全部 + + {(['failed', 'duplicate', 'conflict'] as const).map((status) => ( + onStatusFilterChange(status)}> + {statusLabels[status]} + + ))} +
+
+ +
+ + + + + + + + + + + + + {filteredRows.map((row) => ( + + + + + + + + + ))} + +
行号状态字段原值归一化值原因
{row.row_number} + + {statusLabels[row.status]} + + {row.field}{formatScalar(row.raw_value)}{row.normalized_value == null ? '—' : String(row.normalized_value)}{row.reason}
+
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/import/ImportSummary.tsx b/frontend/src/components/import/ImportSummary.tsx new file mode 100644 index 0000000..46e5f29 --- /dev/null +++ b/frontend/src/components/import/ImportSummary.tsx @@ -0,0 +1,28 @@ +import type { ImportSummary as ImportSummaryType } from '../../services/importTypes'; + +interface ImportSummaryProps { + summary: ImportSummaryType; +} + +const items = [ + { key: 'success', label: '成功', tone: 'text-success' }, + { key: 'failed', label: '失败', tone: 'text-error' }, + { key: 'duplicate', label: '重复', tone: 'text-warning' }, + { key: 'conflict', label: '冲突', tone: 'text-info' }, + { key: 'timestamp_normalization', label: '时间归一', tone: 'text-primary' }, +] as const; + +export default function ImportSummary({ summary }: ImportSummaryProps) { + return ( +
+ {items.map((item) => ( +
+

{item.label}

+

+ {item.label} {summary[item.key]} +

+
+ ))} +
+ ); +} diff --git a/frontend/src/components/import/LocalSafekeepingPanel.tsx b/frontend/src/components/import/LocalSafekeepingPanel.tsx new file mode 100644 index 0000000..e7bfa83 --- /dev/null +++ b/frontend/src/components/import/LocalSafekeepingPanel.tsx @@ -0,0 +1,114 @@ +import { useCallback, useState } from 'react'; +import { restoreSQLiteBackup } from '../../services/api'; +import type { ImportReport } from '../../services/importTypes'; + +interface LocalSafekeepingPanelProps { + importReport: ImportReport | null; +} + +export default function LocalSafekeepingPanel({ importReport }: LocalSafekeepingPanelProps) { + const [backupFile, setBackupFile] = useState(null); + const [isRestoring, setIsRestoring] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + const handleRestore = useCallback(async () => { + if (!backupFile) { + return; + } + + setIsRestoring(true); + setErrorMessage(null); + setStatusMessage(null); + + try { + await restoreSQLiteBackup(backupFile); + setBackupFile(null); + setStatusMessage('SQLite 备份已恢复。'); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : '恢复失败,请稍后重试。'); + } finally { + setIsRestoring(false); + } + }, [backupFile]); + + return ( +
+
+

本地保全

+

本地保全

+

+ 这里保留导入明细、SQLite 备份和恢复入口,全部只面向本地文件。 +

+
+ + {statusMessage ? ( +
+ {statusMessage} +
+ ) : null} + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+
+

导入明细 CSV

+

+ {importReport?.download_ref?.filename ?? '完成一次导入后,这里会出现 CSV 明细下载。'} +

+
+ + {importReport?.download_ref ? ( + + 下载明细 CSV + + ) : ( + 等待导入结果生成。 + )} +
+ +
+
+
+

SQLite 备份

+

导出当前本地数据库副本,恢复时也只接受同格式文件。

+
+ + + 下载 SQLite 备份 + +
+ +
+
+ + setBackupFile(event.target.files?.item(0) ?? null)} + /> +

仅支持本地 SQLite 备份文件。

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/import/ManualImportPanel.tsx b/frontend/src/components/import/ManualImportPanel.tsx new file mode 100644 index 0000000..5febdc4 --- /dev/null +++ b/frontend/src/components/import/ManualImportPanel.tsx @@ -0,0 +1,122 @@ +import type { ManualImportRowInput } from '../../services/api'; + +interface ManualImportPanelProps { + rows: ManualImportRowInput[]; + isSubmitting: boolean; + onAddRow: () => void; + onRemoveRow: (index: number) => void; + onRowChange: ( + index: number, + field: Field, + value: ManualImportRowInput[Field], + ) => void; + onSubmit: () => void; +} + +export default function ManualImportPanel({ + rows, + isSubmitting, + onAddRow, + onRemoveRow, + onRowChange, + onSubmit, +}: ManualImportPanelProps) { + const hasFilledRow = rows.some( + (row) => row.symbol.trim() || row.entry_price.trim() || row.entry_time.trim(), + ); + + return ( +
+
+
+

手动录入

+

补一笔也不用回 Excel

+

+ 先提供最小可用字段:交易对、方向、开仓价格和开仓时间。提交后仍然返回同一份行级报告。 +

+
+ + +
+ +
+ {rows.map((row, index) => { + const rowId = `manual-row-${index}`; + return ( +
+
+

第 {index + 1} 行

+ {rows.length > 1 ? ( + + ) : null} +
+ +
+ + + + + + + +
+
+ ); + })} +
+ +
+
+ 最小四字段 + 支持追加多行 + 复用同一份导入报告 +
+ + +
+
+ ); +} diff --git a/frontend/src/pages/ImportPage.tsx b/frontend/src/pages/ImportPage.tsx new file mode 100644 index 0000000..2d3bbc7 --- /dev/null +++ b/frontend/src/pages/ImportPage.tsx @@ -0,0 +1,161 @@ +import { useCallback, useState } from 'react'; +import ImportContractNote from '../components/import/ImportContractNote'; +import ImportDropzone from '../components/import/ImportDropzone'; +import LocalSafekeepingPanel from '../components/import/LocalSafekeepingPanel'; +import ManualImportPanel from '../components/import/ManualImportPanel'; +import ImportResultsTable from '../components/import/ImportResultsTable'; +import ImportSummary from '../components/import/ImportSummary'; +import { importTradeRows, importTrades, type ManualImportRowInput } from '../services/api'; +import type { ImportReport, ImportRowOutcome } from '../services/importTypes'; + +const createEmptyManualRow = (): ManualImportRowInput => ({ + symbol: '', + direction: 'long', + entry_price: '', + entry_time: '', +}); + +export default function ImportPage() { + const [selectedFile, setSelectedFile] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [manualRows, setManualRows] = useState([createEmptyManualRow()]); + const [isSubmittingManual, setIsSubmittingManual] = useState(false); + const [report, setReport] = useState(null); + const [statusFilter, setStatusFilter] = useState<'all' | ImportRowOutcome['status']>('all'); + const [errorMessage, setErrorMessage] = useState(null); + + const hasFileRejection = report?.status === 'file_rejected'; + + const handleImportSuccess = useCallback((nextReport: ImportReport) => { + setReport(nextReport); + setStatusFilter('all'); + }, []); + + const handleSubmit = useCallback(async () => { + if (!selectedFile) { + return; + } + + setIsUploading(true); + setErrorMessage(null); + + try { + handleImportSuccess(await importTrades(selectedFile)); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : '导入失败,请稍后重试。'); + } finally { + setIsUploading(false); + } + }, [handleImportSuccess, selectedFile]); + + const handleManualRowChange = useCallback( + ( + index: number, + field: Field, + value: ManualImportRowInput[Field], + ) => { + setManualRows((currentRows) => + currentRows.map((row, rowIndex) => (rowIndex === index ? { ...row, [field]: value } : row)), + ); + }, + [], + ); + + const handleAddManualRow = useCallback(() => { + setManualRows((currentRows) => [...currentRows, createEmptyManualRow()]); + }, []); + + const handleRemoveManualRow = useCallback((index: number) => { + setManualRows((currentRows) => { + if (currentRows.length === 1) { + return currentRows; + } + + return currentRows.filter((_, rowIndex) => rowIndex !== index); + }); + }, []); + + const handleManualSubmit = useCallback(async () => { + const rows = manualRows + .filter((row) => row.symbol.trim() || row.entry_price.trim() || row.entry_time.trim()) + .map((row) => ({ + symbol: row.symbol.trim(), + direction: row.direction, + entry_price: row.entry_price.trim(), + entry_time: row.entry_time.trim(), + })); + + if (rows.length === 0) { + return; + } + + setIsSubmittingManual(true); + setErrorMessage(null); + + try { + handleImportSuccess(await importTradeRows(rows)); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : '手动录入失败,请稍后重试。'); + } finally { + setIsSubmittingManual(false); + } + }, [handleImportSuccess, manualRows]); + + return ( +
+
+
+

Phase 1 import workspace

+

导入交易复盘

+

+ 支持 Excel 导入和手动复盘后的重复提交。系统会先告诉你它理解了什么,再给出成功、失败、重复和冲突的分层结果。 +

+
+ + + + + + + + + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + {report ? ( +
+ {hasFileRejection ? ( +
+ 文件被拒绝,先修复列头或必填列,再重新导入。 +
+ ) : null} + + + + +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index c78b1f6..4e8f218 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,4 +1,6 @@ import axios from 'axios'; +import { adaptImportReportPayload } from './importReportAdapter'; +import type { ImportReport } from './importTypes'; export interface Kline { time: number; @@ -89,10 +91,36 @@ export async function fetchTrades( return allTrades; } -export async function importTrades(file: File): Promise<{ imported: number }> { +export async function importTrades(file: File): Promise { const formData = new FormData(); formData.append('file', file); const { data } = await axios.post('/api/trades/import', formData); + return adaptImportReportPayload(data); +} + +export interface ManualImportRowInput { + symbol: string; + direction: 'long' | 'short'; + entry_price: string; + entry_time: string; +} + +export async function importTradeRows(rows: ManualImportRowInput[]): Promise { + const { data } = await axios.post('/api/trades/import/rows', { + source_filename: 'manual-entry', + rows, + }); + return adaptImportReportPayload(data); +} + +export interface RestoreSQLiteBackupResponse { + status: 'restored'; +} + +export async function restoreSQLiteBackup(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + const { data } = await axios.post('/api/backups/restore', formData); return data; } diff --git a/frontend/src/services/importReportAdapter.ts b/frontend/src/services/importReportAdapter.ts new file mode 100644 index 0000000..bba8705 --- /dev/null +++ b/frontend/src/services/importReportAdapter.ts @@ -0,0 +1,175 @@ +import type { + FileValidationError, + ImportReport, + ImportReportStatus, + ImportRowOutcome, + ImportScalar, + NormalizationEvent, + ReportDownloadRef, +} from './importTypes'; + +type ImportPayloadRecord = Record; + +const importReportStatuses = new Set(['success', 'partial', 'file_rejected']); +const importRowStatuses = new Set(['success', 'failed', 'duplicate', 'conflict']); + +function isRecord(value: unknown): value is ImportPayloadRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeNumber(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string') { + const parsed = Number(value.trim()); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return 0; +} + +function normalizeScalar(value: unknown): ImportScalar { + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ) { + return value; + } + + return null; +} + +function normalizeStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const values = value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0); + return values.length > 0 ? values : undefined; +} + +function normalizeReportStatus(value: unknown, fileValidationErrorsCount: number): ImportReportStatus { + if (typeof value === 'string' && importReportStatuses.has(value as ImportReportStatus)) { + return value as ImportReportStatus; + } + + return fileValidationErrorsCount > 0 ? 'file_rejected' : 'partial'; +} + +function normalizeSummaryCount(record: ImportPayloadRecord, primaryKey: string, legacyKey: string): number { + if (Object.prototype.hasOwnProperty.call(record, primaryKey)) { + return normalizeNumber(record[primaryKey]); + } + + return normalizeNumber(record[legacyKey]); +} + +function normalizeRowStatus(value: unknown): ImportRowOutcome['status'] { + if (typeof value === 'string' && importRowStatuses.has(value as ImportRowOutcome['status'])) { + return value as ImportRowOutcome['status']; + } + + return 'failed'; +} + +function normalizeFileValidationError(value: ImportPayloadRecord): FileValidationError { + const missingColumns = normalizeStringArray(value.missing_columns); + + return { + code: typeof value.code === 'string' && value.code.length > 0 ? value.code : 'unknown', + message: typeof value.message === 'string' ? value.message : '', + ...(missingColumns ? { missing_columns: missingColumns } : {}), + }; +} + +function normalizeFileValidationErrors(record: ImportPayloadRecord): FileValidationError[] { + const explicitErrors = normalizeRecordArray(record.file_validation_errors, normalizeFileValidationError); + if (explicitErrors.length > 0) { + return explicitErrors; + } + + if (isRecord(record.file_rejection)) { + return [normalizeFileValidationError({ + code: record.file_rejection.reason, + message: record.file_rejection.message, + missing_columns: record.file_rejection.missing_columns, + })]; + } + + return []; +} + +function normalizeRowOutcome(value: ImportPayloadRecord): ImportRowOutcome { + const normalizedValue = + Object.prototype.hasOwnProperty.call(value, 'normalized_value') && value.normalized_value !== undefined + ? normalizeScalar(value.normalized_value) + : undefined; + + return { + status: normalizeRowStatus(value.status ?? value.outcome), + row_number: normalizeNumber(value.row_number), + field: typeof value.field === 'string' ? value.field : '', + raw_value: normalizeScalar(value.raw_value), + reason: typeof value.reason === 'string' ? value.reason : '', + ...(normalizedValue !== undefined ? { normalized_value: normalizedValue } : {}), + }; +} + +function normalizeNormalizationEvent(value: ImportPayloadRecord): NormalizationEvent { + return { + kind: 'timestamp', + row_number: normalizeNumber(value.row_number), + field: typeof value.field === 'string' ? value.field : '', + raw_value: normalizeScalar(value.raw_value), + normalized_value: normalizeScalar(value.normalized_value), + reason: typeof value.reason === 'string' ? value.reason : '', + }; +} + +function normalizeDownloadRef(value: unknown): ReportDownloadRef | null { + if (!isRecord(value)) { + return null; + } + + const url = typeof value.url === 'string' ? value.url : typeof value.download_url === 'string' ? value.download_url : ''; + const filename = typeof value.filename === 'string' ? value.filename : ''; + + return url && filename ? { url, filename } : null; +} + +function normalizeRecordArray(value: unknown, mapper: (entry: ImportPayloadRecord) => T): T[] { + if (!Array.isArray(value)) { + return []; + } + + return value.filter(isRecord).map(mapper); +} + +export function adaptImportReportPayload(payload: unknown): ImportReport { + const record = isRecord(payload) ? payload : {}; + const summaryRecord = isRecord(record.summary) ? record.summary : {}; + const fileValidationErrors = normalizeFileValidationErrors(record); + const rowOutcomes = normalizeRecordArray(record.row_outcomes, normalizeRowOutcome); + + return { + status: normalizeReportStatus(record.status, fileValidationErrors.length), + summary: { + total_rows: normalizeNumber(summaryRecord.total_rows), + success: normalizeSummaryCount(summaryRecord, 'success', 'success_count'), + failed: normalizeSummaryCount(summaryRecord, 'failed', 'failed_count'), + duplicate: normalizeSummaryCount(summaryRecord, 'duplicate', 'duplicate_count'), + conflict: normalizeSummaryCount(summaryRecord, 'conflict', 'conflict_count'), + timestamp_normalization: normalizeSummaryCount(summaryRecord, 'timestamp_normalization', 'timestamp_normalization_count'), + }, + file_validation_errors: fileValidationErrors, + row_outcomes: rowOutcomes, + normalization_events: normalizeRecordArray(record.normalization_events, normalizeNormalizationEvent), + download_ref: normalizeDownloadRef(record.download_ref ?? record.download_reference), + }; +} diff --git a/frontend/src/services/importTypes.ts b/frontend/src/services/importTypes.ts new file mode 100644 index 0000000..d396860 --- /dev/null +++ b/frontend/src/services/importTypes.ts @@ -0,0 +1,52 @@ +export type ImportScalar = string | number | boolean | null; + +export type ImportReportStatus = 'success' | 'partial' | 'file_rejected'; + +export type ImportRowStatus = 'success' | 'failed' | 'duplicate' | 'conflict'; + +export interface FileValidationError { + code: string; + message: string; + missing_columns?: string[]; +} + +export interface ImportRowOutcome { + status: ImportRowStatus; + row_number: number; + field: string; + raw_value: ImportScalar; + reason: string; + normalized_value?: ImportScalar; +} + +export interface NormalizationEvent { + kind: 'timestamp'; + row_number: number; + field: string; + raw_value: ImportScalar; + normalized_value: ImportScalar; + reason: string; +} + +export interface ImportSummary { + total_rows: number; + success: number; + failed: number; + duplicate: number; + conflict: number; + timestamp_normalization: number; +} + +export interface ReportDownloadRef { + url: string; + filename: string; +} + +export interface ImportReport { + status: ImportReportStatus; + summary: ImportSummary; + file_validation_errors: FileValidationError[]; + row_outcomes: ImportRowOutcome[]; + normalization_events: NormalizationEvent[]; + download_ref: ReportDownloadRef | null; +} diff --git a/frontend/src/test/ImportPage.test.tsx b/frontend/src/test/ImportPage.test.tsx new file mode 100644 index 0000000..5c800cf --- /dev/null +++ b/frontend/src/test/ImportPage.test.tsx @@ -0,0 +1,140 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import App from '../App'; +import { importTrades } from '../services/api'; + +vi.mock('../services/api', () => ({ + importTrades: vi.fn(), +})); + +const mockedImportTrades = vi.mocked(importTrades); + +function renderImportRoute() { + return render( + + + , + ); +} + +describe('ImportPage', () => { + it('shows the dedicated import entry surface', () => { + renderImportRoute(); + + expect(screen.getByRole('heading', { name: '导入交易复盘' })).toBeInTheDocument(); + expect(screen.getByText(/支持 Excel 导入和手动复盘后的重复提交/)).toBeInTheDocument(); + expect(screen.getByText(/symbol、entry_price、entry_time/)).toBeInTheDocument(); + expect(screen.getByText(/重复.*冲突.*单独标出/)).toBeInTheDocument(); + }); + + it('submits a selected file and renders the returned report', async () => { + const user = userEvent.setup(); + mockedImportTrades.mockResolvedValue({ + status: 'partial', + summary: { + total_rows: 3, + success: 1, + failed: 1, + duplicate: 1, + conflict: 0, + timestamp_normalization: 1, + }, + file_validation_errors: [], + row_outcomes: [ + { + status: 'failed', + row_number: 8, + field: 'entry_price', + raw_value: 'abc', + reason: 'entry_price must be numeric', + }, + { + status: 'duplicate', + row_number: 9, + field: 'symbol', + raw_value: 'btc/usdt', + reason: 'duplicate trade already exists', + normalized_value: 'BTC/USDT', + }, + ], + normalization_events: [ + { + kind: 'timestamp', + row_number: 7, + field: 'entry_time', + raw_value: '2026-04-11 12:30:00', + normalized_value: '2026-04-11T04:30:00.000Z', + reason: 'interpreted as Asia/Shanghai time', + }, + ], + download_ref: { + url: '/api/trades/import/reports/imp_123/download', + filename: 'import-report-imp_123.csv', + }, + }); + + renderImportRoute(); + + const file = new File(['symbol,entry_price,entry_time\nBTC/USDT,65000,2026-04-11 12:30:00'], 'trades.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + + await user.upload(screen.getByLabelText('选择 Excel 文件'), file); + await user.click(screen.getByRole('button', { name: '开始导入' })); + + expect(mockedImportTrades).toHaveBeenCalledWith(file); + expect(await screen.findByText('处理结果')).toBeInTheDocument(); + expect(screen.getByText('成功 1')).toBeInTheDocument(); + expect(screen.getByText('失败 1')).toBeInTheDocument(); + expect(screen.getByText('重复 1')).toBeInTheDocument(); + expect(screen.getByText('时间归一 1')).toBeInTheDocument(); + expect(screen.getByText('entry_price')).toBeInTheDocument(); + expect(screen.getByText('duplicate trade already exists')).toBeInTheDocument(); + expect(screen.getByText('interpreted as Asia/Shanghai time')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '下载明细 CSV' })).toHaveAttribute( + 'href', + '/api/trades/import/reports/imp_123/download', + ); + }); + + it('surfaces file-level rejection without row noise', async () => { + const user = userEvent.setup(); + mockedImportTrades.mockResolvedValue({ + status: 'file_rejected', + summary: { + total_rows: 0, + success: 0, + failed: 0, + duplicate: 0, + conflict: 0, + timestamp_normalization: 0, + }, + file_validation_errors: [ + { + code: 'missing_required_columns', + message: 'Missing required columns: symbol, entry_time', + missing_columns: ['symbol', 'entry_time'], + }, + ], + row_outcomes: [], + normalization_events: [], + download_ref: null, + }); + + renderImportRoute(); + + const file = new File(['symbol,entry_price\nBTC/USDT,65000'], 'broken.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + + await user.upload(screen.getByLabelText('选择 Excel 文件'), file); + await user.click(screen.getByRole('button', { name: '开始导入' })); + + expect(await screen.findByText('文件被拒绝')).toBeInTheDocument(); + expect(screen.getByText('Missing required columns: symbol, entry_time')).toBeInTheDocument(); + expect(screen.queryByText('处理结果')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: '下载明细 CSV' })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/import-api.contract.test.ts b/frontend/src/test/import-api.contract.test.ts new file mode 100644 index 0000000..5482db6 --- /dev/null +++ b/frontend/src/test/import-api.contract.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest'; +import { adaptImportReportPayload } from '../services/importReportAdapter'; +import type { ImportReport, ImportRowOutcome } from '../services/importTypes'; + +const emptySummary = { + total_rows: '0', + success: '0', + failed: '0', + duplicate: '0', + conflict: '0', + timestamp_normalization: '0', +} as const; + +describe('phase 1 import contract', () => { + it('D-12 decodes the summary buckets and normalization counts', () => { + const report: ImportReport = adaptImportReportPayload({ + status: 'partial', + summary: { + total_rows: '9', + success: '4', + failed: '1', + duplicate: '2', + conflict: '1', + timestamp_normalization: '3', + }, + row_outcomes: [], + normalization_events: [], + file_validation_errors: [], + download_ref: null, + }); + + expect(report.summary).toEqual({ + total_rows: 9, + success: 4, + failed: 1, + duplicate: 2, + conflict: 1, + timestamp_normalization: 3, + }); + }); + + it('D-10 preserves row number, field, raw value, reason, and normalized value', () => { + const rowOutcome: ImportRowOutcome = adaptImportReportPayload({ + status: 'partial', + summary: emptySummary, + row_outcomes: [ + { + status: 'failed', + row_number: '12', + field: 'entry_price', + raw_value: 'abc', + reason: 'entry_price must be a number', + normalized_value: '12.5', + }, + ], + normalization_events: [], + file_validation_errors: [], + download_ref: null, + }).row_outcomes[0]; + + expect(rowOutcome).toEqual({ + status: 'failed', + row_number: 12, + field: 'entry_price', + raw_value: 'abc', + reason: 'entry_price must be a number', + normalized_value: '12.5', + }); + }); + + it('preserves success outcomes from the backend dataclass shape', () => { + const rowOutcome = adaptImportReportPayload({ + summary: emptySummary, + row_outcomes: [ + { + outcome: 'success', + row_number: 1, + field: 'business_key', + raw_value: 'ETH-USDT|short|1704244500000|2200|1704248100000|2000', + reason: 'trade_accepted', + normalized_value: 'ETH-USDT|short|1704244500000|2200|1704248100000|2000', + }, + ], + normalization_events: [], + file_rejection: null, + download_reference: null, + }).row_outcomes[0]; + + expect(rowOutcome.status).toBe('success'); + }); + + it('D-02 keeps file-level rejection separate from row-level outcomes', () => { + const report: ImportReport = adaptImportReportPayload({ + status: 'file_rejected', + summary: emptySummary, + row_outcomes: [], + normalization_events: [], + file_validation_errors: [ + { + code: 'missing_required_columns', + message: 'Missing required columns: symbol, entry_time', + missing_columns: ['symbol', 'entry_time'], + }, + ], + download_ref: null, + }); + + expect(report.status).toBe('file_rejected'); + expect(report.file_validation_errors).toEqual([ + { + code: 'missing_required_columns', + message: 'Missing required columns: symbol, entry_time', + missing_columns: ['symbol', 'entry_time'], + }, + ]); + expect(report.row_outcomes).toEqual([]); + }); + + it('includes the backend report download reference metadata', () => { + const report: ImportReport = adaptImportReportPayload({ + status: 'partial', + summary: emptySummary, + row_outcomes: [], + normalization_events: [], + file_validation_errors: [], + download_ref: { + url: '/api/imports/imp_123/report.csv', + filename: 'import-report-imp_123.csv', + }, + }); + + expect(report.download_ref).toEqual({ + url: '/api/imports/imp_123/report.csv', + filename: 'import-report-imp_123.csv', + }); + }); + + it('accepts the backend Phase 1 dataclass JSON shape', () => { + const report: ImportReport = adaptImportReportPayload({ + summary: { + total_rows: 3, + success_count: 1, + failed_count: 1, + duplicate_count: 1, + conflict_count: 0, + timestamp_normalization_count: 2, + }, + row_outcomes: [ + { + outcome: 'duplicate', + row_number: 9, + field: 'business_key', + raw_value: 'BTC-USDT|long|1|2', + reason: 'duplicate_trade_exists', + normalized_value: 'BTC-USDT|long|1|2', + }, + ], + normalization_events: [], + file_rejection: null, + download_reference: { + download_url: '/api/trades/import/reports/9/download', + filename: 'import-report-9.csv', + mime_type: 'text/csv', + }, + }); + + expect(report.status).toBe('partial'); + expect(report.summary).toEqual({ + total_rows: 3, + success: 1, + failed: 1, + duplicate: 1, + conflict: 0, + timestamp_normalization: 2, + }); + expect(report.row_outcomes[0]).toEqual({ + status: 'duplicate', + row_number: 9, + field: 'business_key', + raw_value: 'BTC-USDT|long|1|2', + reason: 'duplicate_trade_exists', + normalized_value: 'BTC-USDT|long|1|2', + }); + expect(report.download_ref).toEqual({ + url: '/api/trades/import/reports/9/download', + filename: 'import-report-9.csv', + }); + }); + + it('maps backend file_rejection objects into frontend validation state', () => { + const report: ImportReport = adaptImportReportPayload({ + summary: { + total_rows: 0, + success_count: 0, + failed_count: 0, + duplicate_count: 0, + conflict_count: 0, + timestamp_normalization_count: 0, + }, + row_outcomes: [], + normalization_events: [], + file_rejection: { + reason: 'missing_required_columns', + message: 'Missing required columns: symbol, entry_time', + missing_columns: ['symbol', 'entry_time'], + }, + download_reference: null, + }); + + expect(report.status).toBe('file_rejected'); + expect(report.file_validation_errors).toEqual([ + { + code: 'missing_required_columns', + message: 'Missing required columns: symbol, entry_time', + missing_columns: ['symbol', 'entry_time'], + }, + ]); + }); +}); diff --git a/frontend/src/test/import-page-manual-entry.test.tsx b/frontend/src/test/import-page-manual-entry.test.tsx new file mode 100644 index 0000000..ee1014b --- /dev/null +++ b/frontend/src/test/import-page-manual-entry.test.tsx @@ -0,0 +1,88 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import ImportPage from '../pages/ImportPage'; +import type { ImportReport } from '../services/importTypes'; + +const { importTradesMock, importTradeRowsMock, restoreSQLiteBackupMock } = vi.hoisted(() => ({ + importTradesMock: vi.fn(), + importTradeRowsMock: vi.fn(), + restoreSQLiteBackupMock: vi.fn(async () => ({ status: 'restored' })), +})); + +vi.mock('../services/api', () => ({ + importTrades: importTradesMock, + importTradeRows: importTradeRowsMock, + restoreSQLiteBackup: restoreSQLiteBackupMock, +})); + +const manualImportReport: ImportReport = { + status: 'success', + summary: { + total_rows: 1, + success: 1, + failed: 0, + duplicate: 0, + conflict: 0, + timestamp_normalization: 1, + }, + file_validation_errors: [], + row_outcomes: [ + { + status: 'success', + row_number: 1, + field: 'business_key', + raw_value: 'BTC-USDT|long|1704186900000|1234.5||', + reason: 'trade_accepted', + normalized_value: 'BTC-USDT|long|1704186900000|1234.5||', + }, + ], + normalization_events: [ + { + kind: 'timestamp', + row_number: 1, + field: 'entry_time', + raw_value: '2024-01-02 09:15:00', + normalized_value: 1704186900000, + reason: 'defaulted_naive_timestamp_to_asia_shanghai', + }, + ], + download_ref: { + url: '/api/trades/import/reports/9/download', + filename: 'import-report-manual-entry-9.csv', + }, +}; + +describe('ImportPage manual entry', () => { + it('submits manual rows through the row import API and shows the shared report UI', async () => { + const user = userEvent.setup(); + importTradeRowsMock.mockResolvedValueOnce(manualImportReport); + + render(); + + await user.type(screen.getByLabelText('交易对'), 'btc/usdt'); + await user.selectOptions(screen.getByLabelText('方向'), 'long'); + await user.type(screen.getByLabelText('开仓价格'), '1234.5'); + await user.type(screen.getByLabelText('开仓时间'), '2024-01-02 09:15:00'); + await user.click(screen.getByRole('button', { name: '提交手动记录' })); + + await waitFor(() => { + expect(importTradeRowsMock).toHaveBeenCalledWith([ + { + symbol: 'btc/usdt', + direction: 'long', + entry_price: '1234.5', + entry_time: '2024-01-02 09:15:00', + }, + ]); + }); + + expect(screen.getByText('处理结果')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '下载明细 CSV' })).toHaveAttribute( + 'href', + '/api/trades/import/reports/9/download', + ); + expect(screen.getAllByText('成功').length).toBeGreaterThan(0); + expect(screen.getByText('trade_accepted')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/import-route-smoke.test.tsx b/frontend/src/test/import-route-smoke.test.tsx new file mode 100644 index 0000000..f5af364 --- /dev/null +++ b/frontend/src/test/import-route-smoke.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../App'; + +vi.mock('../services/api', () => ({ + fetchTrades: vi.fn(async () => []), + fetchStats: vi.fn(async () => ({ + total_pnl: 0, + win_rate: 0, + profit_factor: 0, + max_drawdown: 0, + avg_holding_time: 0, + symbol_distribution: {}, + trade_count: 0, + })), + fetchKlines: vi.fn(async () => []), + importTrades: vi.fn(async () => { + throw new Error('importTrades is not used by the smoke test'); + }), + restoreSQLiteBackup: vi.fn(async () => ({ status: 'restored' })), +})); + +const localStorageStore = new Map(); + +const localStorageMock = { + getItem: (key: string) => localStorageStore.get(key) ?? null, + setItem: (key: string, value: string) => { + localStorageStore.set(key, String(value)); + }, + removeItem: (key: string) => { + localStorageStore.delete(key); + }, + clear: () => { + localStorageStore.clear(); + }, + key: (index: number) => Array.from(localStorageStore.keys())[index] ?? null, + get length() { + return localStorageStore.size; + }, +}; + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +function renderAt(route: string) { + return render( + + + , + ); +} + +describe('import route smoke coverage', () => { + beforeEach(() => { + localStorageStore.clear(); + vi.stubGlobal('localStorage', localStorageMock as Storage); + vi.stubGlobal('ResizeObserver', ResizeObserverMock as unknown as typeof ResizeObserver); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders the import workspace directly at /import', () => { + renderAt('/import'); + + expect(screen.getByRole('heading', { name: '导入交易复盘' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '本地保全' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '下载 SQLite 备份' })).toHaveAttribute( + 'href', + '/api/backups/download', + ); + expect(screen.getByRole('link', { name: '导入' })).toHaveAttribute('aria-current', 'page'); + }); + + it('opens the import workspace from the navbar', async () => { + const user = userEvent.setup(); + + renderAt('/replay'); + + await user.click(screen.getByRole('link', { name: '导入' })); + + expect(screen.getByRole('heading', { name: '导入交易复盘' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '导入' })).toHaveAttribute('aria-current', 'page'); + }); +}); diff --git a/frontend/src/test/localSafekeepingPanel.test.tsx b/frontend/src/test/localSafekeepingPanel.test.tsx new file mode 100644 index 0000000..f9e0053 --- /dev/null +++ b/frontend/src/test/localSafekeepingPanel.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import LocalSafekeepingPanel from '../components/import/LocalSafekeepingPanel'; +import type { ImportReport } from '../services/importTypes'; +import { restoreSQLiteBackup } from '../services/api'; + +vi.mock('../services/api', () => ({ + restoreSQLiteBackup: vi.fn(), +})); + +const mockedRestoreSQLiteBackup = vi.mocked(restoreSQLiteBackup); + +const importReport: ImportReport = { + status: 'partial', + summary: { + total_rows: 1, + success: 1, + failed: 0, + duplicate: 0, + conflict: 0, + timestamp_normalization: 0, + }, + file_validation_errors: [], + row_outcomes: [], + normalization_events: [], + download_ref: { + url: '/api/trades/import/reports/imp_123/download', + filename: 'import-report-imp_123.csv', + }, +}; + +describe('LocalSafekeepingPanel', () => { + it('reuses the import report CSV link and restores SQLite backups', async () => { + const user = userEvent.setup(); + mockedRestoreSQLiteBackup.mockResolvedValue({ status: 'restored' }); + + render(); + + expect(screen.getByRole('heading', { name: '本地保全' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '下载明细 CSV' })).toHaveAttribute( + 'href', + importReport.download_ref?.url, + ); + expect(screen.getByRole('link', { name: '下载 SQLite 备份' })).toHaveAttribute( + 'href', + '/api/backups/download', + ); + + const backupFile = new File([new Uint8Array([83, 81, 76, 105, 116, 101])], 'trading.sqlite3', { + type: 'application/octet-stream', + }); + + await user.upload(screen.getByLabelText('选择 SQLite 备份文件'), backupFile); + await user.click(screen.getByRole('button', { name: '恢复 SQLite 备份' })); + + expect(mockedRestoreSQLiteBackup).toHaveBeenCalledWith(backupFile); + expect(await screen.findByText('SQLite 备份已恢复。')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..0d74b73 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..51b3a3a --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,12 @@ +import { mergeConfig } from 'vitest/config'; +import viteConfig from './vite.config'; + +export default mergeConfig(viteConfig, { + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + clearMocks: true, + restoreMocks: true, + css: true, + }, +}); From 9d7b0ee901adf822454f2e10b2d32f95ff069359 Mon Sep 17 00:00:00 2001 From: Xeron Date: Sat, 11 Apr 2026 19:59:07 +0800 Subject: [PATCH 09/11] feat(replay): add replay controls compare modes and persistence Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/components/ChartManager.tsx | 2805 +++++++++++------ .../src/components/CompareModeControls.tsx | 37 + .../src/components/ReplayAnalyticsPanel.tsx | 434 +++ frontend/src/components/ReplayControls.tsx | 84 + frontend/src/components/TradeList.tsx | 74 +- frontend/src/pages/ReplayPage.tsx | 215 +- frontend/src/test/comparePane.test.ts | 64 + .../phase3ReplayIntegration.smoke.test.tsx | 287 ++ .../src/test/replayAnalyticsPanel.test.tsx | 114 + frontend/src/test/replayControls.test.tsx | 84 + frontend/src/test/replayFlow.smoke.test.tsx | 226 ++ .../src/test/replayLayoutPersistence.test.tsx | 255 ++ frontend/src/test/replayPage.test.tsx | 235 ++ frontend/src/test/replayPlayback.test.ts | 148 + frontend/src/test/replaySession.test.ts | 150 + frontend/src/test/replayWorkspace.test.ts | 73 + frontend/src/test/symbolCompare.test.tsx | 177 ++ frontend/src/test/timeframeCompare.test.tsx | 298 ++ frontend/src/test/tradeListSelection.test.tsx | 164 + frontend/src/utils/comparePane.ts | 64 + frontend/src/utils/replayPlayback.ts | 139 + frontend/src/utils/replaySession.ts | 106 + frontend/src/utils/replayWorkspace.ts | 122 + 23 files changed, 5258 insertions(+), 1097 deletions(-) create mode 100644 frontend/src/components/CompareModeControls.tsx create mode 100644 frontend/src/components/ReplayAnalyticsPanel.tsx create mode 100644 frontend/src/components/ReplayControls.tsx create mode 100644 frontend/src/test/comparePane.test.ts create mode 100644 frontend/src/test/phase3ReplayIntegration.smoke.test.tsx create mode 100644 frontend/src/test/replayAnalyticsPanel.test.tsx create mode 100644 frontend/src/test/replayControls.test.tsx create mode 100644 frontend/src/test/replayFlow.smoke.test.tsx create mode 100644 frontend/src/test/replayLayoutPersistence.test.tsx create mode 100644 frontend/src/test/replayPage.test.tsx create mode 100644 frontend/src/test/replayPlayback.test.ts create mode 100644 frontend/src/test/replaySession.test.ts create mode 100644 frontend/src/test/replayWorkspace.test.ts create mode 100644 frontend/src/test/symbolCompare.test.tsx create mode 100644 frontend/src/test/timeframeCompare.test.tsx create mode 100644 frontend/src/test/tradeListSelection.test.tsx create mode 100644 frontend/src/utils/comparePane.ts create mode 100644 frontend/src/utils/replayPlayback.ts create mode 100644 frontend/src/utils/replaySession.ts create mode 100644 frontend/src/utils/replayWorkspace.ts 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