Compare commits
No commits in common. "f55113069c78b6d9552850918cc5afaca76b70d8" and "ef412b366a46350dcb7747999434b74432e8f158" have entirely different histories.
f55113069c
...
ef412b366a
@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
Memory.md
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
README.md
|
||||
37
Dockerfile
37
Dockerfile
@ -1,37 +0,0 @@
|
||||
# 阶段 1:安装依赖
|
||||
FROM node:18-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# 阶段 2:构建产物
|
||||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# 禁用 Next.js 遥测,并注入占位环境变量以通过编译
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# 阶段 3:生产运行环境
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# 安全降权运行
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 8080
|
||||
# 确保 Next.js 监听所有网卡并在正确端口启动
|
||||
ENV PORT=8080
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
38
Memory.md
38
Memory.md
@ -1,22 +1,5 @@
|
||||
# Omniledger 架构与开发记忆 (Memory)
|
||||
|
||||
## 修复 portfolio.ts 卖出交易时的平均成本分母 Bug,将 totalBuyQuantity 替换为真实的当前 quantity,彻底消除了频繁交易导致的本金虚高幽灵账目 (Task 88)
|
||||
- **根因分析**:在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 函数中,SELL 交易的平均成本计算使用了 `holding.totalBuyQuantity`(历史累计买入总量)作为分母,而非 `holding.quantity`(卖出前的实际真实持仓量)。当同一资产存在多次"买-卖-买-卖"循环时,`totalBuyQuantity` 会不断累加而不再下降,导致分母远大于真实持仓量,平均成本被严重稀释,卖出时扣减的 `totalBuyCostNative/Cny` 不足,最终造成 Dashboard 投入本金虚高(幽灵本金)。
|
||||
- **架构红线**:计算移动平均成本时,绝对禁止使用 `holding.totalBuyQuantity` 作为分母!必须使用发生交易前的实际持仓量 `holding.quantity`。
|
||||
- **SELL 侧重构**:完全重写 `else if (isSell)` 代码块,Native 维度使用 `holding.totalBuyCostNative.div(holding.quantity)` 计算平均成本,CNY 维度使用 `holding.totalBuyCostCny.div(holding.quantity)` 计算平均成本,按卖出数量精确扣减成本本金,确保法币与外币同步等比下降。
|
||||
- **清仓重置兜底**:保留 `1e-8` 精度容差的清仓归零逻辑,防御浮点数精度残留。
|
||||
- **验收标准**:Dashboard 走势图今天节点(5月3日)的"投入本金"从错误的 267k 跌回 242k 左右,与时光机(JSON)导出的历史底盘彻底咬合。
|
||||
|
||||
## 修复 portfolio.ts 实时核算引擎,将持仓法币成本的计算逻辑对齐为逐笔乘入历史汇率,彻底消灭大盘图表尾节点本金因汇率波动而变异的 Bug (Task 87)
|
||||
- **根因分析**:在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 函数中,BUY 交易的法币成本计算存在脆弱的 fallback 逻辑——当 `tx.exchangeRate` 缺失时,代码会回退到当前实时汇率字典 (`rateMap`) 而非使用交易发生时的历史汇率,导致跨期持仓的成本基准被当前汇率污染。SELL 交易的成本扣减逻辑与 BUY 侧不一致,使用了不同的平均成本推导路径。
|
||||
- **架构红线**:绝不允许在最后一步用外币总成本乘以当前汇率!每笔买入的法币成本 = 数量 × 价格 × 该笔交易历史汇率 (`tx.exchangeRate`),禁止任何形式的全局汇率乘法。
|
||||
- **BUY 侧重构**:移除 `rateMap` fallback 逻辑,强制使用 `tx.exchangeRate || '1'` 作为该笔交易的汇率(与 `reconstructPortfolioHistory` 中的正确算法对齐):`fiatCost = qty * price * txFx`,直接累加至 `totalBuyCostCny`。
|
||||
- **SELL 侧重构**:统一使用 `totalBuyCostCny / totalBuyQuantity` 作为平均法币成本,按卖出比例 `sellRatio = sellQty / totalBuyQuantity` 等比例扣减 `totalBuyCostCny` 和 `totalBuyCostNative`,保持汇率一致性。
|
||||
- **Dashboard 验证**:`app/dashboard/page.tsx` 的 `loadSnapshots()` 直接使用 `summary.totalCostCny` 写入快照,无多余运算;`net-worth-chart.tsx` 的 Tooltip 从 `_raw.totalCostCny` 读取,数据链路纯净。
|
||||
- **验收标准**:Dashboard 走势图今天节点的 Tooltip "投入本金" 从错误的 26 万多回落,与后端 API 输出的 `242239` 保持绝对一致。
|
||||
|
||||
## 开发基于 CSV 的历史汇率数据播种脚本,在 Schema 增加联合唯一约束,实装 BOM 头剔除与分批 Upsert 逻辑,确保海量历史金融数据的幂等安全写入 (Task 50)
|
||||
|
||||
## 开发基于 CSV 的历史汇率数据播种脚本,在 Schema 增加联合唯一约束,实装 BOM 头剔除与分批 Upsert 逻辑,确保海量历史金融数据的幂等安全写入 (Task 50)
|
||||
- 在 `src/db/schema.ts` 的 `exchangeRatesHistory` 表中新增联合唯一约束 `rate_time_unq`,基于 `(from_currency, to_currency, fetch_time)` 三列,防止重复写入,确保幂等性防线。
|
||||
- 在 `scripts/` 目录下创建 `seed-historical-rates.ts` 播种脚本,支持运行方式:`npx tsx scripts/seed-historical-rates.ts`。
|
||||
@ -305,16 +288,6 @@
|
||||
- 在 `src/actions/snapshots.ts` 中引入 `desc` 与 `gte` 操作符,彻底替换原始 SQL 模板拼接(`sql`"${date}" DESC``),消除 `ReferenceError: date is not defined` 运行时错误。
|
||||
- 使用 `desc(portfolioSnapshots.date)` 实现降序排列,使用 `gte(portfolioSnapshots.date, startDate)` 实现日期范围过滤,并添加 `.$dynamic()` 支持动态条件拼接。
|
||||
|
||||
## 执行 Task 90:完成项目无状态 Docker 容器化改造。配置 standalone 模式、多阶段 Dockerfile 及 docker-compose 编排,实现外部 PgSQL 密钥的运行时动态注入隔离 (Task 90)
|
||||
- **Next.js Standalone 模式**:在 `next.config.ts` 中增加 `output: 'standalone'` 属性,构建时自动生成 `/.next/standalone` 目录,仅包含运行所需的最小文件集,大幅缩减镜像体积。
|
||||
- **.dockerignore 防腐层**:创建 `.dockerignore` 排除 `node_modules`、`.next`、`.git`、`.env` 等敏感和无用文件,防止污染镜像上下文。
|
||||
- **三阶段多阶段构建 Dockerfile**:
|
||||
- **阶段 1 (deps)**:基于 `node:18-alpine` 安装依赖,使用 `npm ci` 实现锁死版本的确定性安装。
|
||||
- **阶段 2 (builder)**:复用 deps 阶段的 `node_modules`,完整复制项目源码并执行 `npm run build`,禁用 Next.js 遥测 (`NEXT_TELEMETRY_DISABLED=1`)。
|
||||
- **阶段 3 (runner)**:极简生产环境,仅复制 `.next/standalone`、`.next/static` 和 `public` 目录;创建非 root 用户 `nextjs` (uid: 1001) 实现安全降权;暴露 8080 端口并监听 `0.0.0.0`。
|
||||
- **Docker Compose 编排**:`docker-compose.yml` 配置 `env_file: .env` 实现运行时环境变量动态注入(数据库 URL、CRON_SECRET 等敏感密钥不打包进镜像);配置 `healthcheck` 使用 `wget` 进行健康探测,每 30 秒检查一次。
|
||||
- **架构红线**:所有生产敏感配置(数据库连接串、CRON_SECRET 等)必须通过 `.env` 文件在运行时注入,严禁硬编码或打包进 Docker 镜像层。
|
||||
|
||||
## 持倉引擎 Native 幣種算法重構 (Task 38)
|
||||
- 重構底層盈虧引擎,全面轉向 Native 原生幣種計算,新增浮動/累計盈虧及百分比指標。
|
||||
- 徹底分離 Native 與 CNY 計算:單隻股票的成本與盈虧全部改用 Native (原幣種) 進行計算。
|
||||
@ -469,13 +442,4 @@
|
||||
- **核心逻辑**:脚本向 `http://localhost:8080/api/admin/rebuild-snapshots` 发送 POST 请求,携带 `Authorization: Bearer <secret>` 请求头,复用生产级 Bearer Token 强校验机制,未配置密钥时提前退出。
|
||||
- **架构红线**:`app/api/admin/rebuild-snapshots/route.ts` 中的生产级 POST + Bearer Token 强校验代码未被修改,保持原有的安全隔离设计。
|
||||
- **运行方式**:`npx tsx scripts/trigger-rebuild.ts`(需确保 `npm run dev` 在另一个终端运行且端口为 8080)。
|
||||
- **设计收益**:本地开发者无需记忆 curl 命令或手动构造请求头,通过脚本即可安全触发历史快照重建,降低了运维门槛并保持了与生产鉴权机制的一致性。
|
||||
|
||||
## 修复 portfolio.ts 卖出核算机制,引入 costBasisQuantity 隔离空投等非交易流水对平均成本分母的污染,解决中文字符解析 bug,实现实时引擎与时光机引擎投入本金的 100% 数学对齐 (Task 89)
|
||||
- **根因分析**:在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 函数中,SELL 交易的平均成本计算使用 `holding.quantity`(含空投、分红扩仓的真实持仓量)作为分母,当资产存在 AIRDROP 等零成本扩仓流水时,`holding.quantity` 被无成本污染,导致平均成本 `totalBuyCost / holding.quantity` 被严重稀释。卖出时扣减的 `totalBuyCostNative/Cny` 不足,造成 Dashboard 投入本金虚高(如 267k vs 真实 242k)。
|
||||
- **架构红线**:计算移动平均成本时,绝对禁止使用 `holding.quantity`(含非交易扩仓)作为分母!必须使用纯净的、仅由 BUY 交易驱动的成本计价基准量 `costBasisQuantity`。
|
||||
- **双轨隔离设计**:在 `holdings.set` 初始化结构中新增 `costBasisQuantity` 字段。BUY 时同步增加 `quantity` 和 `costBasisQuantity`;AIRDROP 仅增加 `quantity`,绝不触碰 `costBasisQuantity`,完美隔绝污染。
|
||||
- **SELL 侧双轨计算**:Native 维度使用 `holding.totalBuyCostNative / holding.costBasisQuantity` 计算平均成本,CNY 维度使用 `holding.totalBuyCostCny / holding.costBasisQuantity` 计算平均成本,按卖出数量精确扣减成本本金与 `costBasisQuantity`,确保法币与外币同步等比下降。
|
||||
- **清仓重置兜底**:`costBasisQuantity` 和 `quantity` 双独立归零逻辑,`1e-8` 精度容差下分别清零 `totalBuyCostCny`/`totalBuyCostNative`/`totalBuyQuantity` 与 `quantity`,防御浮点数精度残留。
|
||||
- **中文字符解析修复**:将 `txType` 比较从 Unicode 转义序列 `\u5165\u4e70`/`\u5356\u51fa` 替换为明文中文 `'买入'`/`'卖出'`,并增加 `'入金'`/`'出金'` 别名,兼容合规的 CSV 导入脏数据。
|
||||
- **验收标准**:Dashboard 走势图今天节点(5月3日)的"投入本金"从虚假的 267k 回归到与 JSON 完全相同的 242,239,两者达到 100% 完美的数学对齐。
|
||||
- **设计收益**:本地开发者无需记忆 curl 命令或手动构造请求头,通过脚本即可安全触发历史快照重建,降低了运维门槛并保持了与生产鉴权机制的一致性。
|
||||
@ -1,15 +0,0 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
container_name: stock-portfolio-web
|
||||
ports:
|
||||
- "8080:8080"
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
/* config options here */
|
||||
allowedDevOrigins: [
|
||||
'10.10.10.1', // 允许该IP访问
|
||||
// 'your-custom-domain.dev', // 如果有自定义域名也可以加在这里
|
||||
|
||||
@ -211,7 +211,6 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
|
||||
name: string | null;
|
||||
type: string;
|
||||
quantity: Big;
|
||||
costBasisQuantity: Big;
|
||||
baseCurrency: string;
|
||||
latestPrice: string;
|
||||
exchange: string;
|
||||
@ -235,8 +234,8 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
|
||||
|
||||
// [架构红线] 强制标准化交易类型:大写 + 去空格,兼容中文脏数据
|
||||
const txType = String(tx.txType).toUpperCase().trim();
|
||||
const isBuy = txType === 'BUY' || txType === '买入' || txType === '入金';
|
||||
const isSell = txType === 'SELL' || txType === '卖出' || txType === '出金';
|
||||
const isBuy = txType === 'BUY' || txType === '\u5165\u4e70';
|
||||
const isSell = txType === 'SELL' || txType === '\u5356\u51fa';
|
||||
|
||||
const existing = holdings.get(tx.assetId);
|
||||
if (!existing) {
|
||||
@ -246,7 +245,6 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
|
||||
name: tx.assetName,
|
||||
type: tx.assetType || 'CASH',
|
||||
quantity: new Big('0'),
|
||||
costBasisQuantity: new Big('0'),
|
||||
baseCurrency: tx.assetBaseCurrency || '',
|
||||
latestPrice: tx.assetLatestPrice || '0',
|
||||
exchange: tx.assetExchange || 'US',
|
||||
@ -265,62 +263,63 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
|
||||
const holding = holdings.get(tx.assetId)!;
|
||||
|
||||
if (isBuy) {
|
||||
const qty = new Big(tx.quantity);
|
||||
holding.quantity = holding.quantity.plus(qty);
|
||||
holding.costBasisQuantity = holding.costBasisQuantity.plus(qty);
|
||||
|
||||
// [架构红线] 买入法币成本 = 数量 * 价格 * 该笔交易历史汇率,禁止在最后乘以当前汇率
|
||||
const txFx = new Big(tx.exchangeRate || '1');
|
||||
const fiatCost = qty.times(new Big(tx.price)).times(txFx);
|
||||
|
||||
holding.totalBuyCostNative = holding.totalBuyCostNative.plus(qty.times(new Big(tx.price)));
|
||||
holding.totalBuyCostCny = holding.totalBuyCostCny.plus(fiatCost);
|
||||
holding.totalBuyQuantity = holding.totalBuyQuantity.plus(qty);
|
||||
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
|
||||
const costPerUnit = new Big(tx.quantity).times(new Big(tx.price));
|
||||
holding.totalBuyCostNative = holding.totalBuyCostNative.plus(costPerUnit);
|
||||
let appliedRate = tx.exchangeRate;
|
||||
if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') {
|
||||
const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY');
|
||||
if (fallbackRate) {
|
||||
appliedRate = fallbackRate;
|
||||
}
|
||||
}
|
||||
const costCny = costPerUnit.times(new Big(appliedRate || '1'));
|
||||
holding.totalBuyCostCny = holding.totalBuyCostCny.plus(costCny);
|
||||
holding.totalBuyQuantity = holding.totalBuyQuantity.plus(new Big(tx.quantity));
|
||||
|
||||
// 记录首次买入日期
|
||||
if (!holding.firstBuyDate && tx.executedAt) {
|
||||
holding.firstBuyDate = new Date(tx.executedAt);
|
||||
}
|
||||
} else if (isSell) {
|
||||
const sellQty = new Big(tx.quantity);
|
||||
const sellPrice = new Big(tx.price);
|
||||
const txFx = new Big(tx.exchangeRate || '1');
|
||||
|
||||
// 1. Native 维度 (使用纯净的 costBasisQuantity 作为分母)
|
||||
let avgCostNative = new Big('0');
|
||||
if (holding.costBasisQuantity.gt(0)) {
|
||||
avgCostNative = holding.totalBuyCostNative.div(holding.costBasisQuantity);
|
||||
// 计算卖出时的平均成本 (Native)
|
||||
let avgCostPerUnitNative = new Big('0');
|
||||
if (holding.totalBuyQuantity.gt(0)) {
|
||||
avgCostPerUnitNative = holding.totalBuyCostNative.div(holding.totalBuyQuantity);
|
||||
}
|
||||
const costBasisNative = avgCostNative.times(sellQty);
|
||||
const sellRevenueNative = sellQty.times(sellPrice);
|
||||
holding.realizedPnlNative = holding.realizedPnlNative.plus(sellRevenueNative.minus(costBasisNative));
|
||||
|
||||
// 2. CNY (法币) 维度 (使用纯净的 costBasisQuantity 作为分母)
|
||||
let avgCostCny = new Big('0');
|
||||
if (holding.costBasisQuantity.gt(0)) {
|
||||
avgCostCny = holding.totalBuyCostCny.div(holding.costBasisQuantity);
|
||||
// 已实现盈亏 = (卖出价 - 平均成本) * 卖出数量 (Native)
|
||||
const sellRevenueNative = new Big(tx.quantity).times(new Big(tx.price));
|
||||
const costBasisNative = avgCostPerUnitNative.times(new Big(tx.quantity));
|
||||
const realizedPnlNative = sellRevenueNative.minus(costBasisNative);
|
||||
holding.realizedPnlNative = holding.realizedPnlNative.plus(realizedPnlNative);
|
||||
|
||||
// 已实现盈亏 (CNY) 保留兼容
|
||||
let appliedRate = tx.exchangeRate;
|
||||
if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') {
|
||||
const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY');
|
||||
if (fallbackRate) {
|
||||
appliedRate = fallbackRate;
|
||||
}
|
||||
}
|
||||
const costBasisCny = avgCostCny.times(sellQty);
|
||||
const sellRevenueCny = sellRevenueNative.times(txFx);
|
||||
holding.realizedPnlCny = holding.realizedPnlCny.plus(sellRevenueCny.minus(costBasisCny));
|
||||
const sellRevenueCny = new Big(tx.quantity).times(new Big(tx.price)).times(new Big(appliedRate || '1'));
|
||||
let avgCostPerUnitCny = new Big('0');
|
||||
if (holding.totalBuyQuantity.gt(0)) {
|
||||
avgCostPerUnitCny = holding.totalBuyCostCny.div(holding.totalBuyQuantity);
|
||||
}
|
||||
const costBasisCny = avgCostPerUnitCny.times(new Big(tx.quantity));
|
||||
const realizedPnlCny = sellRevenueCny.minus(costBasisCny);
|
||||
holding.realizedPnlCny = holding.realizedPnlCny.plus(realizedPnlCny);
|
||||
|
||||
// 3. 扣减本金与持仓
|
||||
holding.totalBuyCostNative = holding.totalBuyCostNative.minus(costBasisNative);
|
||||
holding.totalBuyCostCny = holding.totalBuyCostCny.minus(costBasisCny);
|
||||
holding.quantity = holding.quantity.minus(new Big(tx.quantity));
|
||||
|
||||
holding.quantity = holding.quantity.minus(sellQty);
|
||||
holding.costBasisQuantity = holding.costBasisQuantity.minus(sellQty);
|
||||
|
||||
// 4. 清仓重置兜底逻辑 (防御浮点数精度残留)
|
||||
if (holding.costBasisQuantity.lte(new Big('1e-8'))) {
|
||||
holding.costBasisQuantity = new Big(0);
|
||||
// [核心阻断器] 防浮点数灰尘与清仓重置:一旦清仓,强制清零所有持仓成本
|
||||
if (holding.quantity.lte(new Big('1e-8'))) {
|
||||
holding.quantity = new Big(0);
|
||||
holding.totalBuyCostCny = new Big(0);
|
||||
holding.totalBuyCostNative = new Big(0);
|
||||
holding.totalBuyQuantity = new Big(0);
|
||||
}
|
||||
if (holding.quantity.lte(new Big('1e-8'))) {
|
||||
holding.quantity = new Big(0);
|
||||
}
|
||||
} else if (txType === 'AIRDROP') {
|
||||
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
|
||||
} else if (txType === 'DIVIDEND') {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user