Compare commits
2 Commits
b4f21e7cd6
...
85583b7e06
| Author | SHA1 | Date | |
|---|---|---|---|
| 85583b7e06 | |||
| 4919ba1431 |
14
Memory.md
14
Memory.md
@ -1,5 +1,19 @@
|
|||||||
# Omniledger 架构与开发记忆 (Memory)
|
# Omniledger 架构与开发记忆 (Memory)
|
||||||
|
|
||||||
|
## 构建 /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 结果的汇率双重标准解析错误,重构 Live Overview 聚合基准 (Task 59)
|
||||||
- **核心认知纠正:** `calculateAssetMetrics` 引擎产出的所有数据(`marketValue`, `accumulatedPnl`, `floatingPnl`, `dilutedCost` 等)全都是原始基础币种 (Base Currency)!绝对不存在"部分已经是 CNY"的情况。
|
- **核心认知纠正:** `calculateAssetMetrics` 引擎产出的所有数据(`marketValue`, `accumulatedPnl`, `floatingPnl`, `dilutedCost` 等)全都是原始基础币种 (Base Currency)!绝对不存在"部分已经是 CNY"的情况。
|
||||||
- 修复 `src/actions/snapshots.ts` 的 `reconstructPortfolioHistory()`:之前错误地将 `cnyPrice`(已折算的人民币价格)传入引擎,却只对 `totalInvested` 乘汇率、对 `marketValue` 不乘,形成"双标幻觉"。现改为传入原始币种价格 `priceStr`,然后无例外地将引擎输出的所有金额字段统一乘以 `fxRate` 得到 CNY,确保 `posValueCny` 和 `posCostCny` 使用同一套汇率折算基准。
|
- 修复 `src/actions/snapshots.ts` 的 `reconstructPortfolioHistory()`:之前错误地将 `cnyPrice`(已折算的人民币价格)传入引擎,却只对 `totalInvested` 乘汇率、对 `marketValue` 不乘,形成"双标幻觉"。现改为传入原始币种价格 `priceStr`,然后无例外地将引擎输出的所有金额字段统一乘以 `fxRate` 得到 CNY,确保 `posValueCny` 和 `posCostCny` 使用同一套汇率折算基准。
|
||||||
|
|||||||
@ -333,14 +333,18 @@ export async function reconstructPortfolioHistory() {
|
|||||||
const priceStrForMetrics = priceStr || assetLatestPriceMap.get(assetId) || '0';
|
const priceStrForMetrics = priceStr || assetLatestPriceMap.get(assetId) || '0';
|
||||||
|
|
||||||
const metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics);
|
const metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics);
|
||||||
|
|
||||||
// 获取资产对人民币的汇率
|
// 1. 获取基础币种数据
|
||||||
|
// 2. 获取当前资产的汇率 (必须确保能获取到,比如从 asset 表或 rateMap)
|
||||||
const assetFxRate = new Big(getRate(baseCurrency, 'CNY') || '1');
|
const assetFxRate = new Big(getRate(baseCurrency, 'CNY') || '1');
|
||||||
|
|
||||||
// 统一汇率折算边界:calculateAssetMetrics 输出全部为 Base Currency,
|
// 3. 【核心修复】:市值和本金,必须双双乘以汇率!
|
||||||
// 必须无例外地将所有金额字段乘以 fxRate 得到 CNY
|
|
||||||
const posValueCny = new Big(metrics.marketValue).times(assetFxRate);
|
const posValueCny = new Big(metrics.marketValue).times(assetFxRate);
|
||||||
const posCostCny = new Big(metrics.totalInvested).times(assetFxRate);
|
|
||||||
|
// 投入本金 = (市值 - 累计盈亏) * 汇率,确保逻辑自洽
|
||||||
|
const posCostCny = new Big(metrics.marketValue)
|
||||||
|
.minus(metrics.accumulatedPnl)
|
||||||
|
.times(assetFxRate);
|
||||||
|
|
||||||
totalValueCny = totalValueCny.plus(posValueCny);
|
totalValueCny = totalValueCny.plus(posValueCny);
|
||||||
totalCostCny = totalCostCny.plus(posCostCny);
|
totalCostCny = totalCostCny.plus(posCostCny);
|
||||||
|
|||||||
175
src/app/api/cron/fetch-prices/route.ts
Normal file
175
src/app/api/cron/fetch-prices/route.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { assets, assetPricesHistory } from '@/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
function formatDateStr(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStockPrice(asset: { symbol: string; exchange: string | null }): Promise<string | null> {
|
||||||
|
const cleanSymbol = asset.symbol.trim().toUpperCase().replace(/[^0-9A-Z]/g, '');
|
||||||
|
let tCode: string;
|
||||||
|
|
||||||
|
switch (asset.exchange) {
|
||||||
|
case 'SSE':
|
||||||
|
tCode = 'sh' + cleanSymbol;
|
||||||
|
break;
|
||||||
|
case 'SZSE':
|
||||||
|
tCode = 'sz' + cleanSymbol;
|
||||||
|
break;
|
||||||
|
case 'HKEX':
|
||||||
|
tCode = 'hk' + cleanSymbol;
|
||||||
|
break;
|
||||||
|
case 'US':
|
||||||
|
default:
|
||||||
|
tCode = 's_us' + cleanSymbol;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`https://qt.gtimg.cn/q=${tCode}`, { cache: 'no-store' });
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const decoder = new TextDecoder('gbk');
|
||||||
|
const text = decoder.decode(arrayBuffer);
|
||||||
|
|
||||||
|
const match = text.match(/="([^"]+)"/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
const dataArr = match[1].split('~');
|
||||||
|
const latestPrice = dataArr[3];
|
||||||
|
if (latestPrice && !isNaN(Number(latestPrice)) && Number(latestPrice) > 0) {
|
||||||
|
return latestPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCryptoPrice(asset: { symbol: string }): Promise<string | null> {
|
||||||
|
const cryptoSymbol = asset.symbol.trim().toUpperCase() + 'USDT';
|
||||||
|
const response = await fetch(`https://api.binance.com/api/v3/ticker/price?symbol=${cryptoSymbol}`, { cache: 'no-store' });
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.price) {
|
||||||
|
return data.price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const cronSecret = process.env.CRON_SECRET;
|
||||||
|
const authHeader = req.headers.get('Authorization');
|
||||||
|
|
||||||
|
if (!cronSecret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'CRON_SECRET not configured' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authHeader !== `Bearer ${cronSecret}`) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyUrl = process.env.HTTPS_PROXY;
|
||||||
|
if (proxyUrl) {
|
||||||
|
const proxyAgent = new ProxyAgent(proxyUrl);
|
||||||
|
setGlobalDispatcher(proxyAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = formatDateStr(new Date());
|
||||||
|
|
||||||
|
const allAssets = await db
|
||||||
|
.select()
|
||||||
|
.from(assets)
|
||||||
|
.where(eq(assets.type, 'STOCK').or(eq(assets.type, 'CRYPTO')));
|
||||||
|
|
||||||
|
if (allAssets.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'No active assets to sync',
|
||||||
|
date: dateStr,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let syncedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
const results: Array<{ symbol: string; price: string | null; status: string }> = [];
|
||||||
|
|
||||||
|
for (const asset of allAssets) {
|
||||||
|
try {
|
||||||
|
let price: string | null = null;
|
||||||
|
|
||||||
|
if (asset.type === 'STOCK') {
|
||||||
|
price = await fetchStockPrice(asset);
|
||||||
|
} else if (asset.type === 'CRYPTO') {
|
||||||
|
price = await fetchCryptoPrice(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!price) {
|
||||||
|
failedCount++;
|
||||||
|
results.push({ symbol: asset.symbol, price: null, status: 'fetch_failed' });
|
||||||
|
console.warn(`[Cron] 获取 ${asset.symbol} 价格失败`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(assetPricesHistory)
|
||||||
|
.where(
|
||||||
|
eq(assetPricesHistory.assetId, asset.id)
|
||||||
|
)
|
||||||
|
.then((rows) =>
|
||||||
|
rows.filter((row) => row.date === dateStr)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(assetPricesHistory)
|
||||||
|
.set({ price })
|
||||||
|
.where(
|
||||||
|
eq(assetPricesHistory.id, existing[0].id)
|
||||||
|
);
|
||||||
|
skippedCount++;
|
||||||
|
results.push({ symbol: asset.symbol, price, status: 'updated' });
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.insert(assetPricesHistory)
|
||||||
|
.values({
|
||||||
|
assetId: asset.id,
|
||||||
|
price,
|
||||||
|
date: dateStr,
|
||||||
|
});
|
||||||
|
syncedCount++;
|
||||||
|
results.push({ symbol: asset.symbol, price, status: 'inserted' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failedCount++;
|
||||||
|
results.push({ symbol: asset.symbol, price: null, status: 'error' });
|
||||||
|
console.warn(`[Cron] 同步 ${asset.symbol} 失败:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
date: dateStr,
|
||||||
|
synced: syncedCount,
|
||||||
|
skipped: skippedCount,
|
||||||
|
failed: failedCount,
|
||||||
|
details: results,
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user