42 KiB
42 KiB
Omniledger 架构与开发记忆 (Memory)
部署防弹级 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 条记录全部 upsert,0 失败,控制台无任何[Date Parse Fatal Error]红字报错,美股日期字段正确解析为2026-05-01。
建立 asset_id 与 date 的联合唯一索引,重构三套跨市场日期解析正则,实现基于 onConflictDoUpdate 的价格历史幂等覆盖逻辑 (Task 65)
- 在
src/db/schema.ts的assetPricesHistory表中新增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 的onConflictDoUpdateUPSERT 语法:基于联合唯一约束,冲突时只更新price与updateTime,实现完全幂等的价格覆盖。 - 移除不再使用的
skippedCount计数器与eq导入,简化响应结构。 - 执行
drizzle-kit push完成物理迁移,curl测试确认:美股存入2026-05-01,港A股存入2026-04-30;重复调用不产生新行,update_time正确刷新。
升级时光机历史快照生成逻辑,引入就近汇率匹配策略 (Closest Rate Matching),消除因使用单一日结汇率导致的历史资产估值失真 (Task 64)
- 在
src/actions/snapshots.ts的reconstructPortfolioHistory()中,废弃从静态exchangeRates表获取当前汇率的旧逻辑,全面接入exchangeRatesHistory历史汇率时间序列表。 - 架构调整:在
dayLoop循环之前,一次性加载全部exchangeRatesHistory记录到内存,按(fromCurrency, toCurrency)键分组构建ratesCache(Map<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'),确保posValueCny和posCostCny均使用真实历史汇率折算。 - 性能保障:汇率数据仅在循环外加载一次(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)、fromCurrency、toCurrency(固定 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}请求头,不匹配返回 401;若CRON_SECRET或JISU_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股
20260430161416(14位数字)→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 },保留完整原始响应供日期解析使用。 - 更新入库循环:每个
assetId的date字段强制调用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 Handler(export 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 Unauthorized;若CRON_SECRET未配置则返回 500。 - 核心流程: 查询
assets表中STOCK和CRYPTO类型的活跃资产 → 遍历调用腾讯财经/币安 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折算。但由于totalInvested在finance.ts引擎中已经混入了 CNY 价格(如海尔的买入价格),导致投入本金被错误放大,在 4 月 30 日节点造成"投入本金"虚高、净盈亏显示为亏损 4 万的荒谬结果。 - 强制修复: 在
src/actions/snapshots.ts的reconstructPortfolioHistory()资产遍历循环中,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.ts的reconstructPortfolioHistory():之前错误地将cnyPrice(已折算的人民币价格)传入引擎,却只对totalInvested乘汇率、对marketValue不乘,形成"双标幻觉"。现改为传入原始币种价格priceStr,然后无例外地将引擎输出的所有金额字段统一乘以fxRate得到 CNY,确保posValueCny和posCostCny使用同一套汇率折算基准。 - 修复
src/actions/portfolio.ts的getPortfolioSummary():废弃旧的cnyValue/pnlCny求和逻辑(旧逻辑基于calculateCnyValueFromPrice双路径计算,与引擎输出存在语义差异),改为从getPortfolioPositions()返回的marketValueCny/accumulatedPnlCny/floatingPnlCny字段在内存中累加,确保大盘汇总与底层明细使用同一套计算源(单一事实来源)。 - 同步修复
recordDailySnapshot():totalValueCny改为累加marketValueCny,totalCostCny改为marketValueCny - accumulatedPnlCny推导,与getPortfolioSummary保持一致。 - 彻底覆盖历史快照:重新执行
reconstructPortfolioHistory(),成功重构 1247 天历史快照数据,消除之前因混用汇率导致的错乱快照。
全局修复多币种聚合漏洞,强制叠加汇率乘数 (Task 58)
- 修复了跨币种资产直接相加导致的盈亏总额失真问题:USD 盈利未乘以 ~7.23 汇率被当作 CNY 计算,HKD 亏损同理。
- 在
src/actions/portfolio.ts的getPortfolioPositions()中,对每个资产获取exchangeRate(Base Currency → CNY),将财务引擎 (calculateAssetMetrics) 产出的所有绝对金额字段(marketValue、floatingPnl、accumulatedPnl、dilutedCost)乘以汇率,映射为Cny结尾的新字段,确保 Dashboard 列表中的 CNY 聚合数据精确。 - 在
src/actions/snapshots.ts的reconstructPortfolioHistory()中,修复时光机逐日汇总逻辑:metrics.marketValue已使用 CNY 价格计算可直接取用,metrics.totalInvested基于原始币种价格需乘以exchangeRate折算为 CNY,确保历史成本曲线正确。 - 重新触发时光机清洗,成功重构 1247 天历史快照数据。
重构历史快照生成逻辑,消除新旧算法断层 (Task 57)
- 将时光机重构逻辑全面接入 finance utils 引擎,清洗历史脏快照,消除新旧算法迭代导致的本金曲线断层。
- 在
src/utils/finance.ts的calculateAssetMetrics返回值中新增totalInvested字段,直接输出真实投入本金(含手续费),避免通过marketValue - accumulatedPnl间接推导导致的精度损失。 - 在
src/actions/snapshots.ts中废弃reconstructPortfolioHistory()的旧版 Day-by-Day 加减法逻辑,改为:对每一天currentDate,获取该资产在currentDate及之前的所有交易流水historicalTx和历史收盘价historicalPrice(断点结转),调用calculateAssetMetrics(historicalTx, historicalPrice)获取metrics.marketValue和metrics.totalInvested,分别累加为当天的totalValueCny和totalCostCny。 - 重构后的
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.ts,recordDailySnapshot()函数基于getPortfolioPositions()实时计算总市值与总成本,使用Asia/Shanghai时区获取当日日期,执行 Upsert 逻辑确保每天仅存一条记录;getSnapshots()支持按日期范围与数量限制查询历史快照数据。
外部行情接口与网络 (Market Data Engines)
- 股票行情引擎: 彻底抛弃低效海外接口,自主研发智能路由接入腾讯财经 (
qt.gtimg.cn) 极速接口。引入原生ArrayBuffer与TextDecoder(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 首页,实现总资产大盘的全局一键实盘刷新。
修复记录
修复行情解析引擎的正则匹配规则,增加对 [.-] 等特殊字符的支持,解决 BRK.B 等特殊股票代码解析失败导致现价归零的 Bug (Task 62)
- 修复了
src/actions/market.ts中getTencentSymbol()函数的cleanSymbol正则过滤逻辑:将/[^0-9A-Z]/g升级为/[^0-9A-Z.\-]/g,保留小数点.和连字符-。 - 修复了
app/api/cron/fetch-prices/route.ts中fetchStockPrice()函数的同名正则过滤逻辑,保持一致。 - 根因: 旧正则
/[^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)
- 修復了币种符号映射错乱 Bug:
getCurrencySymbol()中 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.quantity、tx.price、tx.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中引入desc与gte操作符,彻底替换原始 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 / totalBuyQuantity、dilutedCostNative = (totalBuyCostNative - realizedPnlNative - accumulatedDividendsNative) / currentQuantity。 - 新增浮動盈虧指標:
marketValueNative = latestPrice * currentQuantity、floatingPnlNative = marketValueNative - (avgCostNative * currentQuantity)、floatingPnlPercent = floatingPnlNative / (avgCostNative * currentQuantity) * 100。 - 新增累計盈虧指標:
cumulativePnlNative = floatingPnlNative + realizedPnlNative + accumulatedDividendsNative、cumulativePnlPercent = 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組件:「修改」按鈕打開彈窗並回顯該筆流水數據(數量、價格、手續費、幣種、執行時間),提交後調用updateTransactionAction 並刷新頁面。 - 實裝行內刪除功能:「刪除」按鈕彈出確認對話框,確認後調用
deleteTransactionAction 並刷新頁面。 - 在
portfolio.ts的TransactionRecord接口中補充id和fee字段,並在下發數據時包含完整的流水 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/defaultAssetIdprops),同時保持向後兼容內部狀態管理模式。 - 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}數組後調用importHistoricalPricesAction,導入完成後 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表中查询指定assetId且date <= targetDate的记录,按照date降序排列 (desc) 并.limit(1)取第一条,实现价格断点结转(Forward-Fill)逻辑——如果目标当天没有导入价格,系统自动抓取该资产在目标日期之前「最新」的一次有效价格。 - 两个函数均使用
Big.js进行高精度数值计算,为历史净值时光机功能提供底层数据支撑。
修复 Drizzle ORM 中 lte 操作符的语法调用错误 (Task 50d)
- 修复 Drizzle ORM 中 lte 操作符的语法调用错误,从链式调用更正为标准纯函数导入。
- 在
src/actions/snapshots.ts的getHistoricalPositions函数中,将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]调用。 - 修复
getHistoricalPositions和getEffectivePrice中的日期字符串生成逻辑:统一使用formatDateString(targetDate),确保 Drizzle 的lte查询中左右两边日期字符串格式一致(均为YYYY-MM-DD),修复了 UTC 与本地时区混用导致的查询偏移。 - 修复
reconstructPortfolioHistory主循环:while 条件与dateStr生成均改用formatDateString(currentDate),确保与getTodayInShanghai()返回格式完全一致。 - 在资产遍历循环中增加兜底防御逻辑:当
getEffectivePrice返回 null 时,使用assetLatestPriceMap中缓存的latestPrice作为兜底价格,避免价格变量为 undefined 或沿用上一资产的值;同时修正了totalCostCny在priceStr为空时不应累加的 Bug。 - 在
allAssets查询中新增latestPrice字段,构建assetLatestPriceMap供兜底逻辑使用。
修复 getEffectivePrice 引擎中 Drizzle ORM 缺失 and(eq(assetId)) 的致命逻辑漏网
- 修复
getEffectivePrice引擎中 Drizzle ORM 查询条件未使用and()复合操作符的致命逻辑漏网,确保历史断点结转价格精准匹配单一资产。 - 在
src/actions/snapshots.ts顶部从drizzle-orm引入and操作符,将getEffectivePrice的where子句从两个独立的.where()链式调用重构为and(eq(assetPricesHistory.assetId, assetId), lte(assetPricesHistory.date, dateStr))的显式复合条件。 - 修复后
assetId条件与日期条件被绝对锁定在同一个AND逻辑块中,彻底杜绝了周末等非交易日历史节点价格跨资产串联的荒谬市值问题(如海尔被错误匹配 BTC 价格导致 3700 万市值)。
通过前端运行时覆盖策略完美对齐图表今日快照与实时总资产的汇率时差 (Task 54)
- 修复了 Dashboard 图表末端(Today 节点)与页面顶部超大号"总资产"数字之间存在时差误差的问题:图表依赖数据库里几小时前的快照,而概览数字基于实时计算,导致两者不完全一致。
- 在
app/dashboard/page.tsx的loadSnapshots中引入前端运行时覆盖策略:先调用getPortfolioSummary()获取实时总览数据,再调用getSnapshots()获取历史快照,动态替换或追加今天的节点,确保图表末端与大数字 100% 严丝合缝。 - 具体逻辑:如果今天已有快照记录(
lastSnapshot.date === todayStr),则将totalValueCny和totalCostCny覆盖为实时值;如果今天尚无快照,则直接追加一个实时点。 - 移除
getSnapshots查询的视口限制:将src/actions/snapshots.ts中getSnapshots的默认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),将返回的holdings、averageCost、dilutedCost、floatingPnl、accumulatedPnl、marketValue映射到 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.ts中getPortfolioPositions函数的 ReferenceError:avgCost is not defined和dilutedCost is not defined。 - 根本原因:在将财务引擎 (
calculateAssetMetrics) 接入 portfolio 引擎时,avgCost和dilutedCost变量名在结果对象装配环节被直接引用,但它们从未在本作用域中声明——它们实际上是metrics对象的属性 (metrics.averageCost,metrics.dilutedCost)。 - 核心修复:将
avgCost: avgCost.toString()替换为avgCost: metrics.averageCost,将dilutedCost: dilutedCost.toString()替换为dilutedCost: metrics.dilutedCost。 - 同时新增
floatingPnl和accumulatedPnl字段映射到 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→CNY与HKD→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动态汇率流水表,汇率精度与时效性由定时任务保障。