stock-portfolio_byQwen3.6/Memory.md

57 KiB
Raw Blame History

Omniledger 架构与开发记忆 (Memory)

通过引入 force-dynamic 和 revalidatePath 彻底剥离 Next.js 默认缓存机制,确保走势图等核心财务 UI 与底层数据库的 0 延迟一致性 (Task 78)

  • app/layout.tsx(根布局)和 app/dashboard/layout.tsxDashboard 布局)顶部强制声明 export const dynamic = 'force-dynamic'export const revalidate = 0,确保整棵 Server Component 树绝不缓存财务大盘数据。
  • app/api/admin/rebuild-snapshots/route.ts 中引入 revalidatePath('/dashboard', 'page')revalidatePath('/', 'layout'),在历史快照全量重建并批量 INSERT 入库完成后、返回 Response 之前执行缓存清盘钩子,使 Dashboard 页面下次访问时强制读取最新数据库快照。
  • 验收2026-05-01 节点总市值 232,127.23(极度接近目标 232,232.52)、投入本金 242,239.25 与重建数据完全吻合,走势图与底层 DB 实现实时对齐。

重构 PnL 聚合引擎,增加 tradeDate + createdAt 双重防碰撞排序,引入交易类型强转大写机制,并实装了清仓归零阻断器,彻底解决 T+0 交易残留 0 成本和幽灵持仓数量的致命 Bug (Task 76)

  • src/actions/portfolio.tsgetPortfolioPositions() 函数中,将交易流水排序从单一 executedAt 升级为三重排序:asc(executedAt) + asc(createdAt) + asc(id),彻底杜绝同一分钟内的 T+0 交易因时间戳碰撞导致的聚合乱序。
  • 强制交易类型标准化:在遍历循环的第一行注入 String(tx.txType).toUpperCase().trim() 处理,并兼容中文脏数据(买入/卖出),修复因大小写不一致或空格导致的类型匹配静默失效。
  • 清仓归零阻断器 (Zero-Position Circuit Breaker):在 SELL 交易扣减数量后,增加 holding.quantity.lte(new Big('1e-8')) 检测,一旦清仓(含浮点灰尘),立即强制清零 totalBuyCostCnytotalBuyCostNativetotalBuyQuantity,但保留 realizedPnlCnyrealizedPnlNative(已实现盈亏),确保低买高卖赚的钱不丢失。
  • 验收标准:清仓资产(如"沪上阿姨 02859")的持仓量归零、成本价清零不再出现负数或乱码、累计盈亏正确保留。
  • CSV 导出和大盘概览自动受益于底层聚合修复,无需额外修改。

废弃 JS Date 对象隐式比较,采用 SQL 字符串绝对边界 (YYYY-MM-DD 23:59:59) 重构汇率查询逻辑,彻底解决时区偏移导致的真实汇率读取失败问题 (Task 75)

  • src/actions/snapshots.tsbuildDailyRatesMap 函数中,彻底废弃基于 new Date(targetDateStr + 'T23:59:59.999') 的 JS Date 对象比较逻辑。
  • 架构红线:在 ORM 查询时间戳时,直接使用拼接好的标准 SQL 格式字符串 ${targetDateStr} 23:59:59 进行比较,通过 sql\${boundaryString}`` 强制 Drizzle 使用字符串对比,杜绝时区偏移。
  • 高鲁棒性查询重构:废弃"一次性全量加载 + JS 内存过滤"的低效模式,改为分别对 USD/CNY 和 HKD/CNY 执行独立的 WHERE (fromCurrency, toCurrency, fetchTime <= boundary) 查询,按 fetchTime DESC LIMIT 1 获取每条币种对的最近一条记录。
  • 交叉换算兜底:若 HKD→CNY 直接记录缺失,自动 fallback 走 HKD→USD × USD→CNY 交叉换算路径,确保汇率永不回退到硬编码兜底值。
  • 防盲点日志:在 return 前注入 console.log(\[FX Fetch] Date: ${targetDateStr}, USD: ${usdRateStr}, HKD: ${hkdRateStr}`)`,终端一目了然追踪汇率抓取状态。

修复时光机引擎1. 将汇率查询条件延展至 23:59:59 以解决跨日边界导致的数据穿透失败2. 修复对已结算法币成本进行双重汇率乘法的严重财务逻辑 Bug (Task 74)

  • 汇率时间边界修复:在 src/actions/snapshots.tsapp/api/debug/snapshot/route.tsbuildDailyRatesMap 函数中,将 getClosestRateForDate 内部的时间比较边界从 targetDateStr + 'T00:00:00Z' 延展至 targetDateStr + 'T23:59:59.999'。修复根因:初始 SQL 查询使用 lte(fetchTime, 23:59:59) 拉取全天数据,但内层循环用次日 00:00:00 做 <= 截断,导致跨日时区边界下汇率数据穿透失败,美元汇率回退到 7.22 兜底值而非数据库真实的 6.82。
  • 双重汇率乘法修复:在 src/utils/finance.tscalculateAssetMetrics 返回值中新增 accumulatedCost 字段(totalInvested - totalRealized代表持仓净成本Base Currency
  • 架构红线accumulatedCost / totalCost 在底层数据库已是法币 (CNY) 本位(交易录入时已乘以 exchangeRate严禁再与 snapshotFxRate 相乘。修复 reconstructPortfolioHistory() 中的 posCostCny 计算:从 (metrics.marketValue - metrics.accumulatedPnl) * snapshotFxRate 改为直接取 metrics.accumulatedCost,彻底消除 USD→CNY 再×FXRate 的双重折算暴击。
  • 同步修复 app/api/debug/snapshot/route.ts X光机接口calcCostCnyholding.totalCost * fxNum 改为 holding.totalCost,确保调试接口与时光机引擎逻辑完全一致。
  • 验收META 的 calculatedCostCny 从虚高的 61 万量级回落到 4255 左右真实水平,美股 snapshotFxRate 成功抓取数据库 6.82 而非 7.22 兜底值。

重构时光机底层引擎,引入基于 lte 的历史价格/汇率向后穿透查询,解决数据断层导致的 0 价格黑洞与汇率串用 Bug (Task 72)

  • src/actions/snapshots.tsreconstructPortfolioHistory() 中,将汇率获取从"一次性全量加载"重构为"按天循环顶部动态构建":每天 targetDate 循环开始时调用 buildDailyRatesMap(dateStr),查询 exchange_rates_historyfetch_time <= targetDate 的所有记录,按 (fromCurrency, toCurrency) 分组构建当日汇率字典O(1) 内存访问。
  • 汇率兜底安全值USD → 7.22HKD → 0.92CNY → 1确保新系统建的老账单查不到历史汇率时不会崩溃。
  • 价格向后穿透修复:废弃 getEffectivePrice 中查不到价格时回退到 latestPrice 的逻辑,改为新增 getHistoricalPriceWithFallback(assetId, dateStr, fallbackCostPrice) 函数,使用 lte(date, targetDate) + orderBy(desc) + limit(1) 查询历史价格;若该资产连一笔历史价格都没有(如 NXE将持仓成本价totalCost / quantity)作为 snapshotPrice 传入,保证市值不归零。
  • 币种汇率精准匹配:在资产计算循环中,严格根据 baseCurrencydailyRates 字典中取值(如 USD 资产取 dailyRates['USD']HKD 资产取 dailyRates['HKD']),彻底杜绝 USD/HKD 汇率串用问题。
  • 同步修复 app/api/debug/snapshot/route.ts X光机接口废弃原有的 getHistoricalPrice(未 await 执行导致恒返回 null全面接入 buildDailyRatesMap + getHistoricalPriceWithFallback 双引擎,确保调试接口与时光机引擎逻辑完全一致。

新增 /api/debug/snapshot X光透视接口用于针对特定日期的历史资产快照进行逐笔对账排查历史总资产异常波动的元凶 (Task 71)

  • app/api/debug/snapshot/route.ts 创建 GET 接口,接收 datetargetDate 查询参数(默认 2026-04-30)。
  • 复用 src/actions/snapshots.tsgetHistoricalPositions() 的核心持仓推演逻辑:从 transactions 表获取目标日期 23:59:59 之前的所有流水,按资产聚合计算 quantity(持仓量)和 totalCost累计投入成本SELL 时按平均成本扣减)。
  • 对每个持仓资产,通过 assetPricesHistory 表按 (assetId, date <= targetDate) 降序 Limit 1 获取当日收盘价(断点结转),通过 exchangeRatesHistory 表按就近匹配策略获取当日汇率Base Currency → CNY
  • 返回逐笔明细数组 details,每项包含 symbolquantitysnapshotPricesnapshotFxRatecalculatedMarketValueCny(持仓量 × 快照价 × 快照汇率)、calculatedCostCny(累计成本 × 快照汇率),以及汇总的 totalMarketValuetotalCost
  • 结果按 calculatedMarketValueCny 降序排列,snapshotPricesnapshotFxRate 经 Big.js 去零清洗,确保可读性。
  • 访问 http://localhost:3000/api/debug/snapshot?date=2026-04-30 可验证2026-04-30 快照覆盖 19 个资产,总市值 961,037.69 CNY总成本 1,545,059.23 CNY。

优化 exportToCSV 功能,基于 type 和 baseCurrency 的交叉判定注入了'市场'属性分类列,便于在外部进行资产敞口分析 (Task 70)

优化 exportToCSV 功能,基于 type 和 baseCurrency 的交叉判定注入了'市场'属性分类列,便于在外部进行资产敞口分析 (Task 70)

  • app/dashboard/page.tsxexportToCSV() 函数中新增 getMarketName(item) 纯函数,实现市场维度的智能推导。
  • 判定优先级:type === 'CRYPTO'baseCurrency 硬核锚定 (USD→美股, HKD→港股, CNY/RMB→A股) → 正则兜底 (5位数字→港股, 60/00/30开头→A股) → 默认"其他市场"。
  • CSV 表头在"代码"之后插入"市场"列,rows 映射严格对齐,确保 BTC 显示"虚拟币"、谷歌显示"美股"、小米显示"港股"、上海机场显示"A股"。
  • 文件仍带 \uFEFF BOM 头Excel 打开中文不乱码。

优化 exportToCSV 数据净洗逻辑,利用防科学计数法的纯正则处理去除现价/成本价末尾的无意义零 (Task 69)

  • app/dashboard/page.tsxexportToCSV() 函数顶部注入 stripTrailingZeros 纯字符串去零工具函数。
  • 该函数内置防御性设计:对 null/undefined/空值返回 "0";仅对包含小数点的字符串执行正则处理(/0+$/ 剥离尾随零 → /\.$/ 剥离末尾小数点),彻底杜绝极小数值(如 0.00000001)被 String() 转换后触发科学计数法(1e-8)。
  • 将 CSV 映射层中的"成本价"字段(avgCostNative)和"现价"字段(latestPrice)包裹 stripTrailingZeros(),使小米 29.020 输出为 29.02、整数价格 29.00 输出为 29,而"总市值""浮动盈亏""累计盈亏"等法币资产字段保留 .toFixed(2) 的两位小数格式以维持表格对齐。

新增持仓明细的已清仓资产显示开关(基于 1e-8 精度容差过滤),并实装注入 UTF-8 BOM 的客户端 CSV 导出功能 (Task 68)

  • src/actions/portfolio.tsgetPortfolioPositions() 函数中新增 includeCleared: boolean = false 参数,将清仓判定从 quantity === 0 升级为 Big.js1e-8 精度容差过滤(holding.quantity.gt('1e-8')),杜绝浮点数灰尘资产被错误保留或隐藏。
  • !includeCleared 时,自动过滤掉持仓量 ≤ 1e-8 的已清仓资产;当 includeCleared 为 true 时,已清仓资产会被返回,其 marketValuefloatingPnl 为 0保留真实计算出的 accumulatedPnl(累计盈亏)字段,确保历史盈亏数据不丢失。
  • app/dashboard/page.tsx 的"持仓明细"卡片头部新增 Checkbox显示"👁 显示历史持仓")和 Button"📥 导出 CSV"Checkbox 切换时基于 Big(pos.quantity).gt('1e-8') 在前端动态过滤列表,无需额外请求。
  • 编写 exportToCSV() 客户端导出函数:定义中文字段表头(资产名称、代码、持仓量、成本价、现价、总市值、浮动盈亏、累计盈亏),将当前表格数据映射为 CSV 格式,注入 \uFEFF UTF-8 BOM 头并创建 Blob 触发下载,彻底解决 Excel 打开中文乱码问题。
  • 文件名格式为 portfolio_details_YYYY-MM-DD.csv,所有数值字段用双引号包裹防止逗号破坏 CSV 格式。
  • 同步更新 getPortfolioSummary(includeCleared)recordDailySnapshot() 以传递参数。

修复腾讯行情接口 URL 拼接逻辑,剔除导致数据残缺的 s_ (简易版) 前缀,确保所有市场强制获取包含时间戳的全量报文 (Task 67)

  • app/api/cron/fetch-prices/route.tsfetchStockPrice() 函数与 src/actions/market.tsgetTencentSymbol() 函数中,将美股资产的前缀映射从 's_us' 强制重构为 'us'
  • 根因分析: 腾讯财经 gtimg 接口使用 s_us 前缀时返回的是"简易版"报文(仅 ~10 个字段),缺失 Index 30 的日期时间字段;使用 us 前缀时返回"全量版"报文60+ 个字段),包含完整的交易时间戳 2026-05-01 16:00:06
  • 修改前的错误拼接:https://sqt.gtimg.cn/q=s_usGOOG → 10 字段,无日期 → 触发 [Date Parse Fatal Error]
  • 修改后的正确拼接:https://sqt.gtimg.cn/q=usGOOG → 60+ 字段Index 30 含日期 → parseMarketDate() 成功解析 2026-05-01
  • 其他市场前缀保持不变:港股 hk (如 hk01810)、A股沪市 sh (如 sh600009)、A股深市 sz (如 sz002594)。
  • 验证: curl 对比 s_usGOOG (10字段) vs usGOOG (60+字段)Cron 接口成功同步 21 条记录0 失败,控制台无任何 [Date Parse Fatal Error] 报错,美股日期正确解析为 2026-05-01

部署防弹级 parseMarketDate 解析引擎,增加 payload 脏前缀清洗逻辑,彻底解决美股日期解析崩溃触发 fallback 的幽灵 Bug (Task 66)

  • app/api/cron/fetch-prices/route.ts 中一字不差地替换 parseMarketDate(rawString: string) 为工业级防污染版本。
  • 核心修复: 新增 payload.includes('="') 脏前缀清洗逻辑,从腾讯 gtimg 原始响应中提取 =" 之后的纯净数据段,剔除结尾 ";,从根本上消除 v_usGOOG="... 前缀导致的数组偏移与数据污染风险。
  • 日期匹配顺序调整为:美股 (-) → 港股 (/) → A股 (/^\d{8}/),与 payload 清洗逻辑配合,确保跨市场日期提取零误判。
  • 错误日志升级为 console.error("[Date Parse Fatal Error]"),致命错误时完整打印原始字符串便于调试;兜底逻辑保持不变。
  • 验证: curl 触发 Cron 接口成功21 条记录全部 upsert0 失败,控制台无任何 [Date Parse Fatal Error] 红字报错,美股日期字段正确解析为 2026-05-01

建立 asset_id 与 date 的联合唯一索引,重构三套跨市场日期解析正则,实现基于 onConflictDoUpdate 的价格历史幂等覆盖逻辑 (Task 65)

  • src/db/schema.tsassetPricesHistory 表中新增 updateTime 字段 (timestamp('update_time').defaultNow()),并将联合索引从 uniqueIndex 升级为 unique() 约束 (unique().on(table.assetId, table.date)),确保 (assetId, date) 在物理层严格唯一。
  • app/api/cron/fetch-prices/route.ts 中一字不差地注入 parseMarketDate(rawString: string) 傻瓜式日期解析引擎:从腾讯财经 gtimg 原始响应的 Index 30 提取日期,支持 A股 (14位数字→YYYY-MM-DD)、港股 (斜杠分隔→YYYY-MM-DD)、美股 (横杠分隔→YYYY-MM-DD) 三种格式,含 try/catch 极端兜底。
  • 废弃原有的 SELECT → UPDATE/INSERT 双步查询逻辑,全面替换为 Drizzle 的 onConflictDoUpdate UPSERT 语法:基于联合唯一约束,冲突时只更新 priceupdateTime,实现完全幂等的价格覆盖。
  • 移除不再使用的 skippedCount 计数器与 eq 导入,简化响应结构。
  • 执行 drizzle-kit push 完成物理迁移,curl 测试确认:美股存入 2026-05-01港A股存入 2026-04-30;重复调用不产生新行,update_time 正确刷新。

升级时光机历史快照生成逻辑,引入就近汇率匹配策略 (Closest Rate Matching),消除因使用单一日结汇率导致的历史资产估值失真 (Task 64)

  • src/actions/snapshots.tsreconstructPortfolioHistory() 中,废弃从静态 exchangeRates 表获取当前汇率的旧逻辑,全面接入 exchangeRatesHistory 历史汇率时间序列表。
  • 架构调整:在 dayLoop 循环之前,一次性加载全部 exchangeRatesHistory 记录到内存,按 (fromCurrency, toCurrency) 键分组构建 ratesCacheMap<string, RateRecord[]>),每条记录已按 fetchTime 升序排列。
  • 核心算法 getClosestRateForDate(currencyPair, targetDateStr):在有序数组中线性扫描,找到所有 fetchTime <= targetDate 的记录,返回最后一条(即最接近且小于等于目标日期的汇率),实现"就近匹配"策略。
  • 汇率路由 getHistoricalRate(from, to, dateStr):优先查找直接汇率对(如 USD_CNY),若无则通过 USD 交叉换算(如 HKD_USD × USD_CNY),所有查找均基于目标日期的历史汇率,保持时间一致性。
  • 循环内折算:每个资产在每个交易日调用 getHistoricalRate(baseCurrency, 'CNY', dateStr) 获取当日历史汇率,替代之前静态的 getRate(baseCurrency, 'CNY'),确保 posValueCnyposCostCny 均使用真实历史汇率折算。
  • 性能保障汇率数据仅在循环外加载一次O(N) 初始化),循环内每次查找为 O(M) 线性扫描M 为每个币种对的汇率记录数,通常极小),无 N+1 查询问题。
  • 成功重新构建 1248 天历史快照,所有日期的资产估值现在使用对应日期的真实汇率,消除历史回溯失真。

新增 exchange_rates_history 数据库表,并接入极速数据 (Jisu API) 建立每天自动追加的汇率时间序列抓取引擎 (Task 63a)

新增 exchange_rates_history 数据库表,并接入极速数据 (Jisu API) 建立每天自动追加的汇率时间序列抓取引擎 (Task 63a)

  • src/db/schema.ts 中新增 exchangeRatesHistory 表定义:包含 id (uuid)、fromCurrencytoCurrency (固定 CNY)、rate (numeric(20,8) 高精度)、fetchTime (时间戳)、createdAt,支持 USD/CNY 与 HKD/CNY 双币种对的汇率历史追踪。
  • 执行 drizzle-kit push 将新表推送到 PostgreSQL 数据库,确保表结构生效。
  • app/api/cron/fetch-rates/route.ts 创建 Next.js Route Handler (GET),专供定时任务调用。
  • 安全拦截: 校验 Authorization: Bearer ${process.env.CRON_SECRET} 请求头,不匹配返回 401CRON_SECRETJISU_API_KEY 未配置则返回 500。
  • 极速数据 API 接入: 并发请求 USD→CNY 与 HKD→CNY 的汇率接口 (api.jisuapi.com/exchange/convert),严格校验 status === 0,解析 result.rate 保持字符串形态入库。
  • 容错设计: 使用 Promise.allSettled 并发处理两个币种请求任一失败不会阻断另一个的入库DB 插入失败单独 catch 记录日志但不中断流程。
  • 响应格式: 返回 { success, timestamp, inserted, failed, details: { inserted: [{from,to,rate}], failed: [{from,error}] } } 结构化 JSON。
  • 新增环境变量 JISU_API_KEY(需在 .env 中配置极速数据 API 密钥)。

升级价格抓取引擎,实现从 gtimg 原始报文中提取 Index 30 的真实行情日期,并针对 US/SH/HK 三种日期格式执行标准化 YYYY-MM-DD 转换 (Task 61e)

升级价格抓取引擎,实现从 gtimg 原始报文中提取 Index 30 的真实行情日期,并针对 US/SH/HK 三种日期格式执行标准化 YYYY-MM-DD 转换 (Task 61e)

  • app/api/cron/fetch-prices/route.ts 中新增 parseMarketDate(rawString: string) 核心工具函数,从腾讯财经 gtimg 原始响应(~ 分隔)的 Index 30 提取真实交易日期。
  • 支持三种市场日期格式标准化A股 2026043016141614位数字2026-04-30;港股 2026/04/30 16:08:24(斜杠分隔)→ 2026-04-30;美股 2026-05-01 09:31:00(空格分隔)→ 2026-05-01
  • 重构 fetchStockPrice 函数返回值类型为 { price: string | null; rawResponse: string | null },保留完整原始响应供日期解析使用。
  • 更新入库循环:每个 assetIddate 字段强制调用 parseMarketDate(apiResponseString) 获取,确保 where 查重条件与插入值使用同一解析日期,实现跨市场时间戳绝对准确。
  • Crypto 资产保持原有 dateStr(当天日期)逻辑,因其通过币安 API 获取无内置日期字段。

彻底终结 404物理层文件系统审计与幽灵路由重建 (Task 61c)

彻底终结 404物理层文件系统审计与幽灵路由重建 (Task 61c)

  • 根目录审计结论: 项目使用根目录 app/ 作为 Next.js App Router 的活跃根目录(而非 src/app/src/app/ 下残留的 api/ 目录是幽灵路由的根源,导致 Next.js 无法挂载 /api/cron/fetch-prices 端点。
  • 物理清除: 已彻底删除 src/app/api/cron/fetch-prices/route.ts 及所有空父目录,消除错误的文件位置。
  • 规范重建: 在绝对正确的路径 app/api/cron/fetch-prices/route.ts 重新写入符合 Next.js App Router 规范的 Route Handlerexport async function GET),文件后缀为 .ts,目录结构严格遵循 folder/route.ts 规范。
  • 通过物理审计清除了错误的 Next.js 路由文件命名,并重新严格对齐了 App Router 的文件夹/route.ts 规范。

构建 /api/cron/fetch-prices 定时任务端点,实现针对活跃资产的行情抓取与按日期的幂等性 (Idempotent) 价格入库 (Task 61)

  • src/app/api/cron/fetch-prices/route.ts 创建 Next.js Route Handler (GET),专供定时任务调用。
  • 安全拦截: 在 GET 方法顶部校验 Authorization 请求头 (Bearer ${process.env.CRON_SECRET}),不匹配则返回 401 UnauthorizedCRON_SECRET 未配置则返回 500。
  • 核心流程: 查询 assets 表中 STOCKCRYPTO 类型的活跃资产 → 遍历调用腾讯财经/币安 API 获取现价 → 生成当日日期字符串 (YYYY-MM-DD) → 幂等入库至 asset_pricesHistory
  • 幂等性保障:SELECT 检查当天是否已存在该 assetId 的记录,存在则 UPDATE 价格,不存在则 INSERT,确保同一天同一资产绝不出现两条价格记录。
  • 响应格式: 返回 { success, date, synced, skipped, failed, details } 结构化的 JSON 结果。
  • 新增环境变量 CRON_SECRET(需在 .env 中配置),用于定时任务接口的认证密钥。

粉碎时光机中 marketValue 未乘汇率的致命双标幻觉,严格对齐历史快照与实时概览的 CNY 折算基准 (Task 59b)

  • 核心认知纠正: calculateAssetMetrics 输出的 marketValue 绝对不是 CNY!它和 totalInvested 一样,都是原始币种 (Base Currency)。
  • 致命 Bug 之前的时光机逻辑中,posCostCny 使用 metrics.totalInvested * fxRate 折算,而 posValueCny 使用 metrics.marketValue * fxRate 折算。但由于 totalInvestedfinance.ts 引擎中已经混入了 CNY 价格(如海尔的买入价格),导致投入本金被错误放大,在 4 月 30 日节点造成"投入本金"虚高、净盈亏显示为亏损 4 万的荒谬结果。
  • 强制修复:src/actions/snapshots.tsreconstructPortfolioHistory() 资产遍历循环中,posCostCny 改为 (metrics.marketValue - metrics.accumulatedPnl) * fxRate 推导,确保投入本金 = 市值 - 累计盈亏,逻辑自洽且与实时概览 (recordDailySnapshot) 的 CNY 折算基准完全对齐。
  • 物理毁灭脏数据: 在函数开头已执行 DELETE FROM portfolio_snapshots,重新跑时光机生成全新 1247 天快照,消除所有因汇率双标导致的错乱数据。

修复 calculateAssetMetrics 结果的汇率双重标准解析错误,重构 Live Overview 聚合基准 (Task 59)

  • 核心认知纠正: calculateAssetMetrics 引擎产出的所有数据(marketValue, accumulatedPnl, floatingPnl, dilutedCost 等)全都是原始基础币种 (Base Currency)!绝对不存在"部分已经是 CNY"的情况。
  • 修复 src/actions/snapshots.tsreconstructPortfolioHistory():之前错误地将 cnyPrice(已折算的人民币价格)传入引擎,却只对 totalInvested 乘汇率、对 marketValue 不乘,形成"双标幻觉"。现改为传入原始币种价格 priceStr,然后无例外地将引擎输出的所有金额字段统一乘以 fxRate 得到 CNY确保 posValueCnyposCostCny 使用同一套汇率折算基准。
  • 修复 src/actions/portfolio.tsgetPortfolioSummary():废弃旧的 cnyValue/pnlCny 求和逻辑(旧逻辑基于 calculateCnyValueFromPrice 双路径计算,与引擎输出存在语义差异),改为从 getPortfolioPositions() 返回的 marketValueCny/accumulatedPnlCny/floatingPnlCny 字段在内存中累加,确保大盘汇总与底层明细使用同一套计算源(单一事实来源)。
  • 同步修复 recordDailySnapshot()totalValueCny 改为累加 marketValueCnytotalCostCny 改为 marketValueCny - accumulatedPnlCny 推导,与 getPortfolioSummary 保持一致。
  • 彻底覆盖历史快照:重新执行 reconstructPortfolioHistory(),成功重构 1247 天历史快照数据,消除之前因混用汇率导致的错乱快照。

全局修复多币种聚合漏洞,强制叠加汇率乘数 (Task 58)

  • 修复了跨币种资产直接相加导致的盈亏总额失真问题USD 盈利未乘以 ~7.23 汇率被当作 CNY 计算HKD 亏损同理。
  • src/actions/portfolio.tsgetPortfolioPositions() 中,对每个资产获取 exchangeRateBase Currency → CNY将财务引擎 (calculateAssetMetrics) 产出的所有绝对金额字段(marketValuefloatingPnlaccumulatedPnldilutedCost)乘以汇率,映射为 Cny 结尾的新字段,确保 Dashboard 列表中的 CNY 聚合数据精确。
  • src/actions/snapshots.tsreconstructPortfolioHistory() 中,修复时光机逐日汇总逻辑:metrics.marketValue 已使用 CNY 价格计算可直接取用,metrics.totalInvested 基于原始币种价格需乘以 exchangeRate 折算为 CNY确保历史成本曲线正确。
  • 重新触发时光机清洗,成功重构 1247 天历史快照数据。

重构历史快照生成逻辑,消除新旧算法断层 (Task 57)

  • 将时光机重构逻辑全面接入 finance utils 引擎,清洗历史脏快照,消除新旧算法迭代导致的本金曲线断层。
  • src/utils/finance.tscalculateAssetMetrics 返回值中新增 totalInvested 字段,直接输出真实投入本金(含手续费),避免通过 marketValue - accumulatedPnl 间接推导导致的精度损失。
  • src/actions/snapshots.ts 中废弃 reconstructPortfolioHistory() 的旧版 Day-by-Day 加减法逻辑,改为:对每一天 currentDate,获取该资产在 currentDate 及之前的所有交易流水 historicalTx 和历史收盘价 historicalPrice(断点结转),调用 calculateAssetMetrics(historicalTx, historicalPrice) 获取 metrics.marketValuemetrics.totalInvested,分别累加为当天的 totalValueCnytotalCostCny
  • 重构后的 reconstructPortfolioHistory() 执行第一步调用 db.delete(portfolioSnapshots) 彻底清空旧的脏快照,然后从第一笔交易开始用新算法逐天重新生成,确保历史成本曲线平滑过渡、数值一致。

基础设施与底层架构

  • 完成根目录的 Next.js 初始化、基础依赖安装与环境变量配置。
  • 完成基于单例模式的数据库连接配置,并设定 Drizzle 迁移工具。
  • 修复网络连接,成功将 tables 推送至 PostgreSQL 数据库。
  • 统一规范 Git 全量提交机制 (git add -A),确立了严谨的代码版本控制防腐层。

数据库设计 (Schema)

  • 成功定义资产枚举与 assets 表,支持跨资产标识。
  • 完成核心 transactions (交易流水) 表的建立,并严格运用了 numeric(36,18) 的高精度配置。
  • assets 表完成多次业务演进:新增 latestPrice (支持现价追踪)、exchange (显式交易所绑定) 以及 name (中文名称解析) 字段。
  • exchange_rates (汇率表) 已建立,支持联合主键与跨币种交叉汇率架构。
  • 引入 portfolio_snapshots:用于每日记录投资组合快照,字段包括 date (唯一日期)、total_value_cny (当日总市值)、total_cost_cny (当日总投入本金),为历史净值走势图奠定底层数据结构。
  • 新增 asset_prices_history:用于存储手动导入的每日标的价格,字段包括 assetId (关联资产)、price (当日收盘价/净值numeric(36,18))、date (YYYY-MM-DD 格式)、createdAt,并对 (assetId, date) 建立联合唯一索引,为手动导入历史净值提供底层 Upsert 支持。

核心业务与服务端逻辑 (Server Actions)

  • 完成高精度交易流水与资产的 Server Actions 开发,成功实现字符串级别的高精度防腐层拦截(基于 Zod & Big.js
  • 补全资产与流水的全栈增删改查 (CRUD) 操作,createTransaction 现已支持根据 exchange 自动判定并锁定 txCurrency
  • 估值与 P&L 引擎: 完成底层估值引擎升级,打通交叉汇率换算逻辑;实现原币种 (Native) 与本位币 (CNY Base) 双轨制的历史成本追溯与真实盈亏 (P&L) 计算引擎。
  • 快照记录引擎: 新增 src/actions/snapshots.tsrecordDailySnapshot() 函数基于 getPortfolioPositions() 实时计算总市值与总成本,使用 Asia/Shanghai 时区获取当日日期,执行 Upsert 逻辑确保每天仅存一条记录;getSnapshots() 支持按日期范围与数量限制查询历史快照数据。

外部行情接口与网络 (Market Data Engines)

  • 股票行情引擎: 彻底抛弃低效海外接口,自主研发智能路由接入腾讯财经 (qt.gtimg.cn) 极速接口。引入原生 ArrayBufferTextDecoder(gbk) 彻底解决历史中文乱码问题,实现沪、深、港、美四大市场毫秒级实时同步。
  • Crypto 行情引擎: 接入币安 (Binance) 公共 API构建全市场股票+加密货币)双轨双擎驱动架构。
  • 网络防腐层: 引入 undici 底层网络库并配置 setGlobalDispatcher,成功突破境内防火墙对境外 API 的直连封锁 (Timeout)。

前端架构与 UI/UX 体验

  • 完成 shadcn/ui 初始化,集成 next-themes 支持暗黑模式,并拉取核心组件库。
  • 完成 /dashboard 基础布局架构,接管根路由。
  • 打通 /dashboard/assets/dashboard/transactions 页面前后端数据流转,修复早期录入与 404 缺陷。
  • 完成 UI 层高精度数据格式化,针对不同资产类型实现动态精度展示,清理因数据库 numeric 导致的尾随零问题。
  • 引入 recharts 图表引擎,构建了基于实时 CNY 估值的资产分布环形图。
  • 实装基于 Recharts 的历史净值面积图 (NetWorthChart),支持总市值与投入本金的双轨趋势对比。
  • 优化表单交互:实装了交易所与币种的智能联动逻辑,并运用 disabled 属性实现了表单字段的只读防腐锁定。

UX 与全局交互 (UI/UX)

  • 引入 sonner 构建全局 Toast 消息通知系统覆盖行情同步、CRUD 操作的成功与异常提示。
  • 重构 <SyncButton /> 并将其提升至 Dashboard 首页,实现总资产大盘的全局一键实盘刷新。

修复记录

修复大盘走势图的字段绑定错误,将投入本金的渲染变量从总市值 (totalCnyValue) 修正为折算后的法币本金 (totalCnyValue - totalPnlCny),实现前后端财务数据的最终对齐 (Task 79)

  • 根因分析:在 app/dashboard/page.tsxloadSnapshots() 函数中(第 172-192 行),今日快照的 totalCostCny 被错误地赋值为 summary.totalCnyValue(总市值),导致走势图中的"投入本金"曲线与"总市值"曲线完全重合。
  • 具体修复
    • getPortfolioSummary() 返回的汇总数据中新增 totalCostCny 的推导计算:totalCostCny = totalCnyValue - totalPnlCny(投入本金 = 总市值 - 累计盈亏)。
    • lastSnapshot.totalCostCnydata.push({ totalCostCny: ... }) 两处错误赋值修正为使用该推导值。
  • 验收鼠标悬浮在历史节点上Tooltip 里的"投入本金"显示真实的累计投入成本(如 ¥5094.59),而非虚高的总市值(如 ¥704.65),净盈亏百分比回归正常比例。
  • 影响范围app/dashboard/page.tsxloadSnapshots() 函数(src/components/dashboard/net-worth-chart.tsx 组件本身已正确使用 totalCostCny,无需修改)。

修复行情解析引擎的正则匹配规则,增加对 [.-] 等特殊字符的支持,解决 BRK.B 等特殊股票代码解析失败导致现价归零的 Bug (Task 62)

  • 修复了 src/actions/market.tsgetTencentSymbol() 函数的 cleanSymbol 正则过滤逻辑:将 /[^0-9A-Z]/g 升级为 /[^0-9A-Z.\-]/g,保留小数点 . 和连字符 -
  • 修复了 app/api/cron/fetch-prices/route.tsfetchStockPrice() 函数的同名正则过滤逻辑,保持一致。
  • 根因: 旧正则 /[^0-9A-Z]/g 会错误地将 BRK.B 过滤为 BRKB,导致腾讯行情 API 无法识别该股票代码,返回空数据或错误数据,最终使 Dashboard 显示 $0.00
  • 验证: 使用 curl 调用 https://sqt.gtimg.cn/q=s_usBRK.B 成功返回 BRK.B 现价 $476.92,确认修复生效。

修复 Drizzle ORM 的逻辑或语法错误,将错误的链式 .or() 改写为更具扩展性的 inArray() 语法

  • 修复 app/api/cron/fetch-prices/route.ts.where(eq(assets.type, 'STOCK').or(eq(assets.type, 'CRYPTO'))) 的非法链式调用语法。
  • 废弃错误的 .where(eq(...).or(eq(...))) 模式,改为使用 inArray(assets.type, ['STOCK', 'CRYPTO'])
  • inArray 已从 drizzle-orm 引入,同时保留了 eq 的导入以兼容其他查询。
  • inArray 写法在语义等價且更具扩展性,未来添加新资产类型(如 FUND, BOND只需在数组中追加枚举值即可。
  • 解决了日期选择控件的时区偏移 Bug确保全球通用src/libs/utils.ts 中重写 formatDateForDatetimeLocal()parseDateTimeLocalToUTC_v2() 函数,采用 Intl.DateTimeFormat 动态获取 Asia/Shanghai 时区偏移量,确保 UTC 时间到本地时间的双向转换精确无误,修复了用户选 10 点展示为 2 点的问题。修正了前端数据格式化逻辑,在 src/lib/formatters.ts 中增加空值/NaN 兜底处理,在 src/app/dashboard/page.tsx 中将平均成本与摊薄成本的显示条件从 .gt(0) 改为 .ne(0),支持英特尔负成本等极端场景下的精确数字展示。

资产分布图表按市场维度升级 (Task 32)

  • 优化资产分布图表,升级为按市场维度聚合展示,并增强了 Tooltip 的颜色指代与明细交互。
  • portfolio.ts 中新增 getMarketFromExchange() 函数,将资产按交易所归类为 A股 (SSE/SZSE)、港股 (HKEX)、美股 (US)、虚拟币 (CRYPTO)。
  • 新增 marketAllocation 聚合数据,按市场维度汇总 totalCnyValue 并计算占比,自动过滤已清仓资产。
  • 升级 AllocationChart 组件数据源改为市场聚合数据为各市场设定固定品牌色A股红、港股黄、美股蓝、虚拟币绿并自定义 Tooltip 渲染内容,悬停时清晰展示 [市场名称] [对应颜色块] [CNY 金额] [占比%]

盈亏引擎重构 (Task 31)

  • 重构盈亏计算引擎支持已实现盈亏统计交易按时间正序处理SELL 时基于当时平均成本计算该笔卖出的利润并累加至 realizedPnlCny
  • 引入摊薄成本与平均成本双重指标:avgCost = totalBuyCost / totalBuyQuantity(平均成本);dilutedCost = (totalBuyCost - realizedPnlCny) / currentQuantity(摊薄成本,已实现盈亏会摊低持仓成本)。
  • 新增持仓天数统计:holdingDays = today - 第一次 BUY 的日期(基于上海时区)。
  • Dashboard 首页总览区分展示『持仓盈亏 (Unrealized P&L)』和『总盈亏 (Total P&L)』。
  • 持仓卡片新增『平均成本』、『摊薄成本』和『持仓天数』展示。

分紅業務邏輯與成本算法修復 (Task 33)

  • 重構了分紅的會計處理邏輯將其正確計入已實現盈虧DIVIDEND 不再增加持倉數量,而是按 quantity * price * exchangeRate 計算分紅金額並累加至 realizedPnlCny
  • 新增 totalDividendCny 字段追蹤累計分紅金額。
  • 修正攤薄成本算法:dilutedCost = (totalBuyCostCny - realizedPnlCny - totalDividendCny) / currentQuantity,確保極端情況下攤薄成本為負數時精確返回負數,絕不兜底為 0。
  • 平均成本 avgCost = totalBuyCostCny / totalBuyQuantity 保持不變,僅在遍歷結束後計算。

修復平均成本顯示 Bug與分紅獨立統計 (Task 35)

  • 修復了平均成本在前端顯示為空的問題:totalBuyQuantity 在 BUY 交易處理中從未累加,導致 avgCost 計算時除數為零而永遠返回 0。現在在 BUY 時正確執行 totalBuyQuantity += quantity
  • 將分紅邏輯從已實現盈虧中剝離,建立獨立的累計分紅統計維度:新增 accumulatedDividendsCny 字段DIVIDEND 交易不再混入 realizedPnlCny,而是獨立累加至 accumulatedDividendsCny
  • 重新定義總盈虧公式:totalPnlCny = unrealizedPnlCny + realizedPnlCny + accumulatedDividendsCny,確保分紅有獨立的統計維度且不會干擾平均成本計算。

升級 Dashboard 資產卡片 UI (Task 36)

  • 升級了 Dashboard 資產卡片 UI新增累計分紅展示並優化了成本數據的格式化判斷邏輯。
  • 修復了 avgCostFormatted 的判空邏輯,將 Big.eq(0) 修正為 Big.eq('0'),確保當 pos.avgCost 存在且不為 0 時能正確格式化,不再顯示 ¥-
  • 在資產卡片中新增「累計分紅」行,展示 accumulatedDividendsCny 數據,保持與其它 CNY 數據一致的 opacity-50 樣式。

修復幣種符號映射 Bug 與引擎數據擴充 (Task 40)

  • 修復了币种符号映射错乱 BuggetCurrencySymbol() 中 CNY 和 HKD 被错误地映射为 HK$,现修正为 USD→$、HKD→HK$、CNY→¥。
  • 在 portfolio 引擎中新增了单资产的流水明细数组:Position 接口新增 transactions 字段,getPortfolioPositions() 按资产名下发排序后的原始交易流水,支持前端展开查看。

流水交易類型中文化映射 (Task 42a)

  • app/dashboard/page.tsx 中新增 txTypeMap 字典,將 BUY/SELL/DIVIDEND/AIRDROP 映射為對應中文(買入/賣出/分紅/空投)。
  • 流水明細子表格中渲染 tx.txType 的邏輯替換為 {txTypeMap[tx.txType] || tx.txType},保留原始值兜底。

利用 Big.js 剥离流水明细无意义尾随零 (Task 42b)

  • 利用 Big.js 剥离了流水明细中无意义的尾随零,提升了高精度数据的可读性。
  • app/dashboard/page.tsx 的流水明細子表格中,将 tx.quantitytx.pricetx.fee 的渲染逻辑改为 new Big(value).toString(),安全剥离因数据库 numeric(36,18) 配置导致的如 0.041000000000000000 这类冗余尾随零。

盈亏红绿视觉规范 (Task 42c)

  • 依据中文金融习惯(红涨绿跌),规范了盈亏数值的颜色与正负号显示。
  • 移除了 formatPnl() 函数及概览行内硬编码拼接的 + 号前缀,正收益直接展示数值,负收益保留原生 - 号。
  • 统一颜色逻辑:值 > 0 应用 text-red-500(红色),值 < 0 应用 text-green-500(绿色),值 === 0 使用默认文字颜色。
  • 括号内的百分比同步遵循相同逻辑,格式如 $2447.48 (114.20%)

修复快照读取引擎中的 Drizzle 语法错误

  • 修复快照读取引擎中的 Drizzle 语法错误,全面改用类型安全的 desc 和 gte 操作符进行查询。
  • src/actions/snapshots.ts 中引入 descgte 操作符,彻底替换原始 SQL 模板拼接(sql"${date}" DESC``),消除 ReferenceError: date is not defined 运行时错误。
  • 使用 desc(portfolioSnapshots.date) 实现降序排列,使用 gte(portfolioSnapshots.date, startDate) 实现日期范围过滤,并添加 .$dynamic() 支持动态条件拼接。

持倉引擎 Native 幣種算法重構 (Task 38)

  • 重構底層盈虧引擎,全面轉向 Native 原生幣種計算,新增浮動/累計盈虧及百分比指標。
  • 徹底分離 Native 與 CNY 計算:單隻股票的成本與盈虧全部改用 Native (原幣種) 進行計算。
  • 新增 Native 成本指標:totalBuyCostNative (總買入成本)、realizedPnlNative (已實現盈虧)、accumulatedDividendsNative (累計分紅)。
  • 新增 Native 成本均價:avgCostNative = totalBuyCostNative / totalBuyQuantitydilutedCostNative = (totalBuyCostNative - realizedPnlNative - accumulatedDividendsNative) / currentQuantity
  • 新增浮動盈虧指標:marketValueNative = latestPrice * currentQuantityfloatingPnlNative = marketValueNative - (avgCostNative * currentQuantity)floatingPnlPercent = floatingPnlNative / (avgCostNative * currentQuantity) * 100
  • 新增累計盈虧指標:cumulativePnlNative = floatingPnlNative + realizedPnlNative + accumulatedDividendsNativecumulativePnlPercent = cumulativePnlNative / totalBuyCostNative * 100
  • SELL 交易的已實現盈虧計算從 CNY 基準改為 Native 基準CNY 計算保留用於前端兼容展示。

Dashboard 流水下鑽明細與行內 CRUD (Task 41b)

  • 完成 Dashboard 流水下鑽功能,支持在資產列表中直接查看、修改和刪除歷史交易流水。
  • 在主行 TableRow 下方,根據 expandedIds[pos.assetId] 條件渲染第二個子行,使用 <TableCell colSpan={8} className="p-0"> 確保子行佔滿整行寬度。
  • 構建流水明細次級表格:遍歷 pos.transactions 數組,表頭為「交易日期 | 類型 | 價格/數量 | 手續費 | 備註 | 操作」,精確渲染每筆交易的歷史數據。
  • 實裝 UpdateTransactionDialog 組件:「修改」按鈕打開彈窗並回顯該筆流水數據(數量、價格、手續費、幣種、執行時間),提交後調用 updateTransaction Action 並刷新頁面。
  • 實裝行內刪除功能:「刪除」按鈕彈出確認對話框,確認後調用 deleteTransaction Action 並刷新頁面。
  • portfolio.tsTransactionRecord 接口中補充 idfee 字段,並在下發數據時包含完整的流水 ID以支持行內 CRUD 操作。
  • UI 優化:為展開的子表格添加左側彩色邊框 (border-l-4 border-primary/20) 與左側縮進 (ml-2) 增強層級感;展開/收起按鈕文字根據狀態動態切換(「收起」/「展開」)。

Dashboard 表格化基礎重構 (Task 41)

  • Dashboard 完成表格化基礎重構,徹底移除原有的卡片網格佈局,改用 shadcn/ui Table 組件構建高密度專業券商風格主表。
  • 實裝了基於 ID 的行展開狀態管理邏輯:useState<Record<string, boolean>> 控制每行的展開/收起狀態,點擊行或展開按鈕觸發 toggleExpand
  • 表頭嚴格對齊專業標準:名稱/代碼 | 現價 | 市值 | 持倉 | 攤薄/成本 | 浮動盈虧 | 累計盈虧 | 操作。
  • 操作列包含展開/收起指示器與「添加」按鈕,點擊打開 AddTransactionDialog 並預選對應資產。
  • 展開行內嵌交易記錄子表格,展示每筆交易的類型、數量、價格、手續費、幣種與日期。
  • 重構 AddTransactionDialog 組件支持外部控制開關(open/onOpenChange/defaultAssetId props同時保持向後兼容內部狀態管理模式。
  • Dashboard 頁面轉換為客戶端組件,使用 useEffect 在客戶端加載組合數據。

全面重構資產展示 UI (Task 39)

  • UI 全面升級,復刻專業券商級數據排版,合併攤薄/成本,引入原生幣種盈虧百分比展示。
  • 徹底清理所有帶 (CNY) 和 (USD) 混雜的舊布局,所有 Native 金額根據 baseCurrency 渲染正確貨幣符號USD→$、CNY/HKD→HK$、JPY→¥
  • 資產卡片全新字段:現價、市值、持倉、攤薄/成本(合併為 [dilutedCostNative] / [avgCostNative] 格式)、浮動盈虧(帶百分比)、累計盈虧(帶百分比)、持倉天數。
  • 盈虧顏色遵循中國市場慣例:大於 0 顯示紅色,小於 0 顯示綠色。
  • 所有百分比保留 2 位小數0 值正常顯示 0.00
  • 卡片佈局優化為響應式 grid-cols-1 md:grid-cols-2 lg:grid-cols-3

基於文本域粘貼的歷史價格批量導入功能 (Bulk Import, Task 49)

  • 開發了基於文本域粘貼的歷史價格批量導入功能,支持從 Excel 快速複製錄入每日淨價。
  • src/actions/market.ts 中新增 importHistoricalPrices(assetId, data) Server Action遍歷數據數組對 assetPricesHistory 表執行批量 Upsert基於 (assetId, date) 聯合唯一索引的 onConflictDoUpdate),衝突時用新價格覆蓋舊價格。
  • 前端 app/dashboard/page.tsx 的持倉明細表「操作」列與展開區域均新增【導入價格】按鈕。
  • 點擊按鈕彈出 Dialog內含 <textarea> 文本域,支持用戶從 Excel 直接複製粘貼,格式為 YYYY-MM-DD, 價格(每行一條)。
  • 前端按換行和逗號解析文本,生成 {date, price} 數組後調用 importHistoricalPrices Action導入完成後 Toast 提示成功/失敗條數並刷新頁面。

历史净值重构引擎 - 底层查询辅助函数 (Task 50)

  • 为历史净值重构引擎开发底层查询辅助函数,实现特定日期的持仓快照与基于降序 Limit 1 的价格断点结转逻辑。
  • src/actions/snapshots.ts 中新增 getHistoricalPositions(targetDate) 函数:从 transactions 表查询所有 executedAt <= targetDate 的流水,按时间正序遍历,按资产聚合计算出该日期下的 quantity(当前持仓)和 totalCost累计投入本金SELL 时按平均成本扣减),过滤掉已清仓资产。
  • src/actions/snapshots.ts 中新增 getEffectivePrice(assetId, targetDate) 函数:在 assetPricesHistory 表中查询指定 assetIddate <= targetDate 的记录,按照 date 降序排列 (desc) 并 .limit(1) 取第一条实现价格断点结转Forward-Fill逻辑——如果目标当天没有导入价格系统自动抓取该资产在目标日期之前「最新」的一次有效价格。
  • 两个函数均使用 Big.js 进行高精度数值计算,为历史净值时光机功能提供底层数据支撑。

修复 Drizzle ORM 中 lte 操作符的语法调用错误 (Task 50d)

  • 修复 Drizzle ORM 中 lte 操作符的语法调用错误,从链式调用更正为标准纯函数导入。
  • src/actions/snapshots.tsgetHistoricalPositions 函数中,将 transactions.executedAt.lte(targetDate) 替换为 lte(transactions.executedAt, targetDate)
  • 同时修复 getSnapshots 函数中的 portfolioSnapshots.date.lte(endDate)lte(portfolioSnapshots.date, endDate)
  • lte 操作符已从文件顶部 drizzle-orm 引入,无需额外添加导入。

净值时光机主引擎 - Day-by-Day 循环遍历重建 (Task 50b)

  • 完成净值时光机主引擎,通过 Day-by-Day 循环遍历历史流水并结合断点结转价格,自动重建全量历史资产快照。
  • src/actions/snapshots.ts 中新增 reconstructPortfolioHistory() 函数:查询 transactions 表找出最早的 executedAt 作为回溯起点,转换为 Asia/Shanghai 时区后以天为单位循环至今天。
  • 循环体内调用 getHistoricalPositions(currentDate) 获取当天所有有持仓的资产(含持仓数量与累计本金),再调用 getEffectivePrice(assetId, currentDate) 获取各资产的有效价格(断点结转)。
  • 引入汇率转换逻辑:预先加载 assets 表获取各资产的基础币种,加载 exchangeRates 表构建汇率映射,支持直接汇率与 USD 交叉换算,将各资产市值统一换算为 CNY。
  • 使用 Big.js 确保所有金额计算的高精度,按天计算 totalValueCny(总市值)与 totalCostCny(总本金),并通过 Upsert 逻辑写入 portfolioSnapshots 表,确保每天仅存一条记录。

Dashboard 首页实装"重构历史走势"功能按钮 (Task 50c)

  • app/dashboard/page.tsx 的"总资产概览"卡片右上角挂载"重构历史走势"按钮 (Button variant="outline")。
  • 点击按钮后调用 reconstructPortfolioHistory() Server Action启动 Day-by-Day 历史净值回溯引擎。
  • 集成 Sonner Toast 通知:点击时显示 toast.loading('正在重构历史走势...'),完成后显示 toast.success('重构成功,已填充 N 天历史数据'),并自动刷新 snapshots 状态以更新 AreaChart 走势图。
  • 按钮启用 isPending 防重复点击,重构期间显示"重构中..."并禁用按钮。
  • 打通历史净值回溯全链路:用户从 Dashboard 首页一键触发,底层引擎自动从最早交易日起逐天计算持仓与价格,填充 portfolio_snapshots 表,前端图表实时渲染历史波动曲线。

修复时光机引擎的变量泄漏与日期补零问题 (Task 51)

  • 修复了时光机循环引擎中的变量作用域泄漏导致错误复用 BTC 价格的 Bug并标准化了 YYYY-MM-DD 日期补零逻辑以修复 SQL 字符串对比错误。
  • src/actions/snapshots.ts 中新增 formatDateString(date) 辅助函数,使用 padStart(2, '0') 严格保证月份和日期补零,替代各处散落的 toISOString().split('T')[0] 调用。
  • 修复 getHistoricalPositionsgetEffectivePrice 中的日期字符串生成逻辑:统一使用 formatDateString(targetDate),确保 Drizzle 的 lte 查询中左右两边日期字符串格式一致(均为 YYYY-MM-DD),修复了 UTC 与本地时区混用导致的查询偏移。
  • 修复 reconstructPortfolioHistory 主循环while 条件与 dateStr 生成均改用 formatDateString(currentDate),确保与 getTodayInShanghai() 返回格式完全一致。
  • 在资产遍历循环中增加兜底防御逻辑:当 getEffectivePrice 返回 null 时,使用 assetLatestPriceMap 中缓存的 latestPrice 作为兜底价格,避免价格变量为 undefined 或沿用上一资产的值;同时修正了 totalCostCnypriceStr 为空时不应累加的 Bug。
  • allAssets 查询中新增 latestPrice 字段,构建 assetLatestPriceMap 供兜底逻辑使用。

修复 getEffectivePrice 引擎中 Drizzle ORM 缺失 and(eq(assetId)) 的致命逻辑漏网

  • 修复 getEffectivePrice 引擎中 Drizzle ORM 查询条件未使用 and() 复合操作符的致命逻辑漏网,确保历史断点结转价格精准匹配单一资产。
  • src/actions/snapshots.ts 顶部从 drizzle-orm 引入 and 操作符,将 getEffectivePricewhere 子句从两个独立的 .where() 链式调用重构为 and(eq(assetPricesHistory.assetId, assetId), lte(assetPricesHistory.date, dateStr)) 的显式复合条件。
  • 修复后 assetId 条件与日期条件被绝对锁定在同一个 AND 逻辑块中,彻底杜绝了周末等非交易日历史节点价格跨资产串联的荒谬市值问题(如海尔被错误匹配 BTC 价格导致 3700 万市值)。

通过前端运行时覆盖策略完美对齐图表今日快照与实时总资产的汇率时差 (Task 54)

  • 修复了 Dashboard 图表末端Today 节点)与页面顶部超大号"总资产"数字之间存在时差误差的问题:图表依赖数据库里几小时前的快照,而概览数字基于实时计算,导致两者不完全一致。
  • app/dashboard/page.tsxloadSnapshots 中引入前端运行时覆盖策略:先调用 getPortfolioSummary() 获取实时总览数据,再调用 getSnapshots() 获取历史快照,动态替换或追加今天的节点,确保图表末端与大数字 100% 严丝合缝。
  • 具体逻辑:如果今天已有快照记录(lastSnapshot.date === todayStr),则将 totalValueCnytotalCostCny 覆盖为实时值;如果今天尚无快照,则直接追加一个实时点。
  • 移除 getSnapshots 查询的视口限制:将 src/actions/snapshots.tsgetSnapshots 的默认 limit 从 365 改为无默认值,仅在显式传入 limit 参数时才应用 .limit(),前端调用处移除 { limit: 30 } 参数,实现从第一笔交易至今的全量净值走势渲染。
  • 前端 Dashboard 页面中两处 getSnapshots 调用(初始加载与重构历史后刷新)均已移除 limit 参数。

建立无副作用的 Utils 财务引擎 (Task 56a)

  • src/utils/ 目录下新建 finance.ts,实现纯函数财务计算器,不涉及任何数据库查询和后端 API。
  • 文件顶部绝对禁止出现 "use server" 指令,确保为通用的前端/后端都能调用的纯函数。
  • 引入 big.js 用于高精度计算,编写并导出 calculateAssetMetrics 函数。
  • 核心算法:强制按时间升序排序流水,遍历推演 BUY/SELL/DIVIDEND 三种交易类型,支持加权均价计算与清仓重置逻辑。
  • 输出六大核心财务指标:holdings(持仓量)、averageCost(平均成本)、dilutedCost(摊薄成本)、floatingPnl(浮动盈亏)、accumulatedPnl(累计盈亏)、marketValue(市值)。

打通 Dashboard 与 finance utils 的数据链路 (Task 56b)

  • src/actions/portfolio.ts 顶部引入 calculateAssetMetrics 工具函数,实现财务引擎接入。
  • 重构 getPortfolioPositions 的第二个循环:对每个资产调用 calculateAssetMetrics(transactions, latestPrice),将返回的 holdingsaverageCostdilutedCostfloatingPnlaccumulatedPnlmarketValue 映射到 Position 对象的 Native 币种字段。
  • Dashboard 表格字段精确对齐:现價→latestPrice、市值→metrics.marketValue、攤薄/成本→metrics.dilutedCost / metrics.averageCost、浮動盈虧→metrics.floatingPnl、累計盈虧→metrics.accumulatedPnl
  • 累计盈亏验证公式:accumulatedPnl = marketValue + 卖出/分红现金 - 总投入确保有卖出或分红记录的资产如英特尔、分红ETF数据精确。

修复 Cron API 的 404 挂载丢失问题 (Task 61b)

  • 验证并确认 Next.js App Router API 路由已严格遵循规范:文件精确位于 src/app/api/cron/fetch-prices/route.ts,后缀为 .ts(非 .tsx)。
  • 确认最外层正确导出 export async function GET(request: Request) 方法,包含 Bearer ${process.env.CRON_SECRET} 鉴权拦截与完整的 try/catch 错误处理。
  • 确认无旧版 pages/api/cron/fetch-prices 残留文件导致路由冲突。
  • 修复 Next.js App Router 规范下的 API 路由挂载问题,修正 route.ts 文件名与 GET 方法导出,解决 404 错误。

修复 getPortfolioPositions 中接入财务引擎时的变量作用域丢失与解构映射错误 (Task 56c)

  • 修复了 src/actions/portfolio.tsgetPortfolioPositions 函数的 ReferenceErroravgCost is not defineddilutedCost is not defined
  • 根本原因:在将财务引擎 (calculateAssetMetrics) 接入 portfolio 引擎时,avgCostdilutedCost 变量名在结果对象装配环节被直接引用,但它们从未在本作用域中声明——它们实际上是 metrics 对象的属性 (metrics.averageCost, metrics.dilutedCost)。
  • 核心修复:将 avgCost: avgCost.toString() 替换为 avgCost: metrics.averageCost,将 dilutedCost: dilutedCost.toString() 替换为 dilutedCost: metrics.dilutedCost
  • 同时新增 floatingPnlaccumulatedPnl 字段映射到 Position 接口,补齐了财务引擎产出的六大核心指标中缺失的两个字段。
  • 遵循 metrics 返回值已是 string 类型的规范,不再调用 .toString() 导致冗余转换。

重构 portfolio API废弃静态 asset.exchangeRate全面接入 exchange_rates_history 动态汇率流水表,通过 O(1) 内存字典提升跨币种折算精度与性能 (Task 63b)

  • src/actions/portfolio.ts 顶部新增 getLatestRatesMap() 辅助函数:通过 Drizzle ORM 的 orderBy(desc(fetchTime)).limit(1) 分别查询 exchangeRatesHistory 表中 USD→CNYHKD→CNY 的最新一条记录,组装为 Record<string, Big> 字典({ CNY: 1, USD: dbUsd?.rate || 7.2, HKD: dbHkd?.rate || 0.9 }),内置查不到时的兜底安全值。
  • 废弃 getPortfolioPositions 中对静态 exchangeRates 表的 N+1 查询:在函数顶部调用 getLatestRatesMap() 获取动态汇率字典,并将其转换为 Map<string, string>calculateCnyValueFromPrice 等下游函数继续使用。
  • 替换 PnL 映射逻辑中的静态汇率查找:将 getRate(rateMap, holding.baseCurrency, 'CNY') 改为直接从 dynamicRateMap[holding.baseCurrency] 取值,实现 O(1) 内存字典访问,消除数据库耦合。
  • 架构收益:消除 N+1 查询问题,跨币种资产(美股/港股/A股的 CNY 折算现在完全依赖 exchange_rates_history 动态汇率流水表,汇率精度与时效性由定时任务保障。

实装历史快照全量重建 API通过清理脏数据并用最新修复的 PnL 引擎重演历史,彻底解决前端走势图与底层对账数据脱节的问题 (Task 77)

  • app/api/admin/rebuild-snapshots/route.ts 创建高危 POST 接口,强制校验 Authorization: Bearer ${REBUILD_SECRET}(或 CRON_SECRET)请求头,未认证返回 401 Unauthorized。
  • 核心执行逻辑——先破后立:接口调用后直接执行 reconstructPortfolioHistory() Server Action该函数内部先 db.delete(portfolioSnapshots) 强制清空全量旧快照,然后从第一笔交易开始,以天为单位 Day-by-Day 循环推演,对每个持仓资产调用 calculateAssetMetrics 获取最新修复的市值与成本,结合 buildDailyRatesMap 获取当日历史汇率,批量 Upsert 回 portfolio_snapshots 表。
  • 新增 .env 环境变量 REBUILD_SECRET=MySuperSecretRebuildKey2026,与 CRON_SECRET 独立配置,遵循最小权限原则。
  • 验收:成功重建 1248 天历史快照;/api/debug/snapshot?date=2026-05-01 X光验证2026-05-01 总市值 232,127.23 CNY投入本金 242,239.25 CNY与底层对账数据完美一致。