diff --git a/README.md b/README.md index bb5535d..0686680 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,19 @@ MIT License - 详见 [LICENSE](LICENSE) 文件 ## 更新日志 +### v1.0.4 (2026-04-12) + +- 🐛 总资产计算修复:修复了多市场持仓汇总时货币转换错误的问题 +- 🐛 浮动盈亏修复:修复了成本基数和市值货币单位不一致导致的错误 +- 💹 持仓分析修复:确保所有持仓数据统一转换为 USD 后再汇总 +- 📈 港股价格获取:改用腾讯行情接口(r_hk前缀)获取港股实时价格(小鹏汽车等) +- 📈 A股价格获取:改用腾讯行情接口(sz/sh前缀)获取A股实时价格(海尔智家、中证现金流ETF等) +- 📈 美股价格获取:改用腾讯行情接口(s_us前缀)获取美股实时价格 +- 🔍 证券名称显示:搜索和选择证券时同时显示代码和中文名称 +- 📋 证券数据库扩展:新增小鹏汽车(09868)、海尔智家(600690)、中证现金流ETF(159235)、Alphabet(GOOGL) +- ✏️ 交易流水编辑:支持编辑已创建的交易记录 +- 💰 持仓货币随市场:持仓明细和分析中各市场使用对应货币显示(港股用HKD、A股用CNY、美股用USD) + ### v1.0.3 (2026-04-12) - 🗑️ 删除交易记录功能:支持删除误添加的交易记录,自动回滚账户余额和持仓变化 diff --git a/package-lock.json b/package-lock.json index 9f61123..4f2f71e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "shadcn": "^4.2.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "yfinance": "^0.0.6" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -10484,6 +10485,12 @@ "node": ">=8" } }, + "node_modules/yfinance": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/yfinance/-/yfinance-0.0.6.tgz", + "integrity": "sha512-EcJrrcX+ZCpbhouGO6s5y7XRUHGBwFY19PKtUJ17vg1L0dSK2N59Ns0A/ZfLtpWC8WatWjen6gE+KW51bxthsA==", + "license": "MIT" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 2dc5a0d..63a09cb 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "shadcn": "^4.2.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "yfinance": "^0.0.6" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/prisma/seed.ts b/prisma/seed.ts index 17c8cc2..44eaaf5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -66,10 +66,14 @@ async function main() { // 初始化常见证券 const securities = [ { symbol: '00700', name: '腾讯控股', market: MarketType.HK, currency: 'HKD', lotSize: 100, priceDecimals: 2, qtyDecimals: 0 }, + { symbol: '09868', name: '小鹏汽车', market: MarketType.HK, currency: 'HKD', lotSize: 100, priceDecimals: 2, qtyDecimals: 0 }, { symbol: '09988', name: '阿里巴巴', market: MarketType.HK, currency: 'HKD', lotSize: 100, priceDecimals: 2, qtyDecimals: 0 }, { symbol: 'AAPL', name: 'Apple Inc.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 }, { symbol: 'MSFT', name: 'Microsoft Corp.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 }, { symbol: 'NVDA', name: 'NVIDIA Corp.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 }, + { symbol: 'GOOGL', name: 'Alphabet Inc.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 }, + { symbol: '600690', name: '海尔智家', market: MarketType.CN, currency: 'CNY', lotSize: 100, priceDecimals: 2, qtyDecimals: 0 }, + { symbol: '159235', name: '中证现金流ETF', market: MarketType.CN, currency: 'CNY', lotSize: 100, priceDecimals: 3, qtyDecimals: 0 }, { symbol: 'BTC', name: 'Bitcoin', market: MarketType.CRYPTO, currency: 'USDT', lotSize: 1, priceDecimals: 2, qtyDecimals: 8, isCrypto: true }, { symbol: 'ETH', name: 'Ethereum', market: MarketType.CRYPTO, currency: 'USDT', lotSize: 1, priceDecimals: 2, qtyDecimals: 8, isCrypto: true }, ] diff --git a/src/app/api/dashboard/analytics/route.ts b/src/app/api/dashboard/analytics/route.ts index 4e99b08..fbefe33 100644 --- a/src/app/api/dashboard/analytics/route.ts +++ b/src/app/api/dashboard/analytics/route.ts @@ -1,44 +1,105 @@ import { NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' -// Alpha Vantage API 配置 -const ALPHA_VANTAGE_BASE_URL = 'https://www.alphavantage.co/query' -const API_KEY = process.env.ALPHA_VANTAGE_API_KEY || 'EZ4LLXN8DW0J4G7P' +// 腾讯行情接口 +const TENCENT_API_BASE = 'https://qt.gtimg.cn/q=' -interface StockQuote { - '01. symbol': string - '05. price': string - '09. change': string - '10. change percent': string - '06. volume': string - '07. latest trading day': string +// 将证券代码转换为腾讯接口格式 +function toTencentSymbol(symbol: string, marketType: string): string { + switch (marketType) { + case 'CN': + // A股:sz开头深圳,sh开头上海 + if (symbol.startsWith('sz') || symbol.startsWith('sh')) { + return symbol + } + // 纯数字A股,需要判断深圳还是上海 + if (symbol.startsWith('6')) { + return `sh${symbol}` + } + return `sz${symbol}` + case 'HK': + // 港股:纯数字加 r_hk 前缀 + if (symbol.startsWith('hk')) { + return `r_${symbol.toLowerCase()}` + } + return `r_hk${symbol}` + case 'US': + // 美股:纯字母加 s_us 前缀 + if (symbol.startsWith('s_us')) { + return symbol.toLowerCase() + } + return `s_us${symbol.toUpperCase()}` + case 'CRYPTO': + // 加密货币暂时用 USDT 价格 + return `usdt${symbol.toLowerCase()}` + default: + return symbol + } } -async function fetchStockQuote(symbol: string): Promise<{ price: number; change: number; changePercent: number } | null> { +// 解析腾讯行情数据 +// 格式: v_xxx="100~名称~代码~当前价~昨收价~..." +function parseTencentQuote(data: string): { price: number; change: number; changePercent: number } | null { try { - const url = `${ALPHA_VANTAGE_BASE_URL}?function=GLOBAL_QUOTE&symbol=${symbol}&apikey=${API_KEY}` + const match = data.match(/="([^"]+)"/) + if (!match) return null + + const fields = match[1].split('~') + if (fields.length < 5) return null + + const currentPrice = parseFloat(fields[3]) + const previousClose = parseFloat(fields[4]) + + if (isNaN(currentPrice) || isNaN(previousClose) || previousClose === 0) { + return null + } + + const change = currentPrice - previousClose + const changePercent = (change / previousClose) * 100 + + return { + price: currentPrice, + change, + changePercent, + } + } catch (error) { + console.error('Failed to parse Tencent quote:', error) + return null + } +} + +// 批量获取价格(腾讯接口支持批量查询,用逗号分隔) +async function fetchPrices(symbols: { symbol: string; marketType: string }[]): Promise> { + if (symbols.length === 0) return {} + + try { + const querySymbols = symbols.map(s => toTencentSymbol(s.symbol, s.marketType)).join(',') + const url = `${TENCENT_API_BASE}${querySymbols}` const response = await fetch(url, { - next: { revalidate: 60 }, // 缓存1分钟 + next: { revalidate: 60 }, }) - - if (!response.ok) return null - - const data = await response.json() - const quote = data['Global Quote'] as StockQuote | undefined - - if (quote && quote['05. price']) { - return { - price: parseFloat(quote['05. price']), - change: parseFloat(quote['09. change'] || '0'), - changePercent: parseFloat(quote['10. change percent']?.replace('%', '') || '0'), + + if (!response.ok) return {} + + const text = await response.text() + const results: Record = {} + + // 腾讯返回多行,每行一个股票 + const lines = text.split('\n') + symbols.forEach((s, index) => { + if (lines[index]) { + const quote = parseTencentQuote(lines[index]) + if (quote) { + results[s.symbol] = quote + } } - } - - return null + }) + + return results } catch (error) { - console.error(`Failed to fetch quote for ${symbol}:`, error) - return null + console.error('Failed to fetch prices from Tencent:', error) + return {} } } @@ -72,24 +133,13 @@ export async function GET() { latestRates.map((r) => [`${r.fromCurrency}_${r.toCurrency}`, Number(r.rate)]) ) - // 4. 获取实时价格(只获取美股) - const usSymbols = positions - .filter((p) => p.account.marketType === 'US') - .map((p) => p.symbol) - - const priceResults: Record = {} + // 4. 获取实时价格(使用腾讯行情接口) + const symbolsWithMarket = positions.map((p) => ({ + symbol: p.symbol, + marketType: p.account.marketType, + })) - // 并行获取价格(限制并发数) - const batchSize = 3 - for (let i = 0; i < usSymbols.length; i += batchSize) { - const batch = usSymbols.slice(i, i + batchSize) - const results = await Promise.all(batch.map((s) => fetchStockQuote(s))) - batch.forEach((symbol, index) => { - if (results[index]) { - priceResults[symbol] = results[index]! - } - }) - } + const priceResults = await fetchPrices(symbolsWithMarket) // 5. 计算完整的持仓分析 const positionAnalytics = positions.map((pos) => { @@ -104,6 +154,7 @@ export async function GET() { const pnlPercent = costBasis > 0 ? (pnl / costBasis) * 100 : 0 const rate = rateMap.get(`${pos.currency}_USD`) || 1 + const costBasisUSD = costBasis * rate const marketValueUSD = marketValue * rate const pnlUSD = pnl * rate @@ -118,6 +169,7 @@ export async function GET() { change: quote?.change || 0, changePercent: quote?.changePercent || 0, costBasis, + costBasisUSD, marketValue, marketValueUSD, pnl, @@ -128,18 +180,18 @@ export async function GET() { } }) - // 6. 汇总统计 - const totalCostBasis = positionAnalytics.reduce((sum, p) => sum + p.costBasis, 0) + // 6. 汇总统计(全部转换为 USD) + const totalCostBasis = positionAnalytics.reduce((sum, p) => sum + p.costBasisUSD, 0) const totalMarketValue = positionAnalytics.reduce((sum, p) => sum + p.marketValueUSD, 0) const totalPnL = totalMarketValue - totalCostBasis const totalPnLPercent = totalCostBasis > 0 ? (totalPnL / totalCostBasis) * 100 : 0 - // 7. 按市场分组 + // 7. 按市场分组(全部使用 USD 计值) const byMarket = positionAnalytics.reduce((acc, p) => { if (!acc[p.marketType]) { acc[p.marketType] = { totalCost: 0, totalValue: 0, totalPnL: 0 } } - acc[p.marketType].totalCost += p.costBasis + acc[p.marketType].totalCost += p.costBasisUSD acc[p.marketType].totalValue += p.marketValueUSD acc[p.marketType].totalPnL += p.pnlUSD return acc diff --git a/src/app/api/transactions/route.ts b/src/app/api/transactions/route.ts index 707dc7b..b144382 100644 --- a/src/app/api/transactions/route.ts +++ b/src/app/api/transactions/route.ts @@ -135,6 +135,39 @@ export async function POST(request: Request) { } } +// 更新交易记录 +export async function PATCH(request: Request) { + try { + const body = await request.json() + const { id, type, symbol, quantity, price, amount, fee, currency, notes, executedAt } = body + + if (!id) { + return NextResponse.json({ error: 'Transaction ID is required' }, { status: 400 }) + } + + const updatePayload: Record = {} + if (type !== undefined) updatePayload.type = type + if (symbol !== undefined) updatePayload.symbol = symbol || null + if (quantity !== undefined) updatePayload.quantity = quantity ? new Prisma.Decimal(quantity) : null + if (price !== undefined) updatePayload.price = price ? new Prisma.Decimal(price) : null + if (amount !== undefined) updatePayload.amount = amount ? new Prisma.Decimal(amount) : null + if (fee !== undefined) updatePayload.fee = new Prisma.Decimal(fee) + if (currency !== undefined) updatePayload.currency = currency + if (notes !== undefined) updatePayload.notes = notes + if (executedAt !== undefined) updatePayload.executedAt = new Date(executedAt) + + const transaction = await prisma.transaction.update({ + where: { id }, + data: updatePayload, + }) + + return NextResponse.json(transaction) + } catch (error) { + console.error('Update transaction error:', error) + return NextResponse.json({ error: 'Failed to update transaction' }, { status: 500 }) + } +} + // 删除交易记录 export async function DELETE(request: Request) { try { diff --git a/src/app/page.tsx b/src/app/page.tsx index 2231eff..7d9ab67 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -17,11 +17,11 @@ import { import { Wallet, TrendingUp, TrendingDown, Plus, ArrowUpRight, ArrowDownRight, Bitcoin, Building2, Globe2, RefreshCw, DollarSign, Search, Check, - Download, Upload, Trash2, + Download, Upload, Trash2, Edit, } from 'lucide-react' import { fetchAccounts, fetchTransactions, fetchPositions, - fetchSecurities, createTransaction, deleteTransaction, formatCurrency, formatPercent, + fetchSecurities, createTransaction, deleteTransaction, updateTransaction, formatCurrency, formatPercent, marketLabels, transactionTypeLabels } from '@/lib/api' import { Account, Position, Transaction, MarketType, TransactionType, Security } from '@/types' @@ -118,6 +118,21 @@ export default function Dashboard() { const [transactionToDelete, setTransactionToDelete] = useState(null) const [showDeleteTxDialog, setShowDeleteTxDialog] = useState(false) + // 交易记录编辑状态 + const [transactionToEdit, setTransactionToEdit] = useState(null) + const [showEditTxDialog, setShowEditTxDialog] = useState(false) + const [editTxForm, setEditTxForm] = useState({ + type: 'BUY' as TransactionType, + symbol: '', + quantity: '', + price: '', + amount: '', + fee: '0', + currency: 'USD', + notes: '', + executedAt: new Date().toISOString().slice(0, 16), + }) + // 交易表单状态 const [txForm, setTxForm] = useState({ type: 'BUY' as TransactionType, @@ -181,6 +196,13 @@ export default function Dashboard() { } }, [symbolSearch, securities]) + // 获取证券显示文本(代码+名称) + const getSecurityDisplayText = (symbol: string) => { + if (!symbol) return '' + const sec = securities.find(s => s.symbol === symbol) + return sec ? `${sec.symbol} ${sec.name}` : symbol + } + // 选择证券后自动填充价格和币种 const handleSelectSecurity = (symbol: string) => { const price = analytics?.prices[symbol]?.price || 0 @@ -295,6 +317,52 @@ export default function Dashboard() { } } + // 打开编辑交易记录对话框 + const openEditTxDialog = (tx: Transaction) => { + setTransactionToEdit(tx) + setEditTxForm({ + type: tx.type, + symbol: tx.symbol || '', + quantity: tx.quantity ? parseFloat(tx.quantity.toString()).toString() : '', + price: tx.price ? parseFloat(tx.price.toString()).toString() : '', + amount: parseFloat(tx.amount.toString()).toString(), + fee: parseFloat(tx.fee.toString()).toString(), + currency: tx.currency, + notes: tx.notes || '', + executedAt: new Date(tx.executedAt).toISOString().slice(0, 16), + }) + setShowEditTxDialog(true) + } + + // 提交编辑交易记录 + const handleEditTransaction = async () => { + if (!transactionToEdit) return + + try { + const amount = editTxForm.quantity && editTxForm.price + ? (parseFloat(editTxForm.quantity) * parseFloat(editTxForm.price)).toFixed(2) + : editTxForm.amount + + await updateTransaction(transactionToEdit.id, { + type: editTxForm.type, + symbol: editTxForm.symbol || undefined, + quantity: editTxForm.quantity ? parseFloat(editTxForm.quantity) : undefined, + price: editTxForm.price ? parseFloat(editTxForm.price) : undefined, + amount: parseFloat(amount || '0'), + fee: parseFloat(editTxForm.fee) || 0, + currency: editTxForm.currency, + notes: editTxForm.notes || undefined, + executedAt: editTxForm.executedAt, + }) + toast.success('交易记录已更新') + setShowEditTxDialog(false) + setTransactionToEdit(null) + loadData() + } catch (error) { + toast.error('更新交易记录失败') + } + } + // 重置交易表单 const resetTxForm = () => { setTxForm({ @@ -615,12 +683,13 @@ export default function Dashboard() {
{marketIcons[pos.marketType as MarketType]} {pos.symbol} + {pos.currency}
-
{formatCurrency(pos.marketValueUSD)}
+
{formatCurrency(pos.marketValue, pos.currency)}
= 0 ? 'text-green-500' : 'text-red-500'}`}> {pos.pnl >= 0 ? : } - {formatCurrency(Math.abs(pos.pnlUSD))} ({formatPercent(pos.pnlPercent)}) + {formatCurrency(Math.abs(pos.pnl), pos.currency)} ({formatPercent(pos.pnlPercent)})
))} @@ -770,9 +839,14 @@ export default function Dashboard() { - +
+ + +
)) : ( @@ -808,10 +882,11 @@ export default function Dashboard() {
{marketIcons[pos.marketType as MarketType]} {pos.symbol} + {pos.currency}
{percent.toFixed(1)}% - {formatCurrency(pos.marketValueUSD)} + {formatCurrency(pos.marketValue, pos.currency)}
@@ -851,7 +926,7 @@ export default function Dashboard() {
= 0 ? 'text-green-500' : 'text-red-500'}`}>
{pos.pnl >= 0 ? : } - {formatCurrency(Math.abs(pos.pnlUSD))} + {formatCurrency(Math.abs(pos.pnl), pos.currency)}
{formatPercent(pos.pnlPercent)}
@@ -926,7 +1001,7 @@ export default function Dashboard() { { setSymbolSearch(e.target.value) setTxForm({ ...txForm, symbol: e.target.value.toUpperCase() }) @@ -1116,6 +1191,123 @@ export default function Dashboard() { + {/* 编辑交易记录对话框 */} + + + + 编辑交易记录 + +
+
+ {/* 交易类型选择 */} +
+ + +
+ {/* 交易时间 */} +
+ + setEditTxForm({ ...editTxForm, executedAt: e.target.value })} + /> +
+
+ + {/* 证券代码(买入/卖出/分红时显示) */} + {['BUY', 'SELL', 'DIVIDEND'].includes(editTxForm.type) && ( +
+ + setEditTxForm({ ...editTxForm, symbol: e.target.value.toUpperCase() })} + /> +
+ )} + + {/* 数量和价格输入(买入/卖出/分红时显示) */} + {['BUY', 'SELL', 'DIVIDEND'].includes(editTxForm.type) && ( +
+
+ + setEditTxForm({ ...editTxForm, quantity: e.target.value })} + /> +
+
+ + setEditTxForm({ ...editTxForm, price: e.target.value })} + /> +
+
+ )} + + {/* 金额输入(入金/出金时显示) */} + {['DEPOSIT', 'WITHDRAW'].includes(editTxForm.type) && ( +
+ + setEditTxForm({ ...editTxForm, amount: e.target.value })} + /> +
+ )} + + {/* 手续费输入 */} +
+ + setEditTxForm({ ...editTxForm, fee: e.target.value })} + /> +
+ + {/* 备注 */} +
+ + setEditTxForm({ ...editTxForm, notes: e.target.value })} + /> +
+ + {/* 提交按钮 */} + +
+
+
+ {/* 删除交易记录确认对话框 */} diff --git a/src/lib/api.ts b/src/lib/api.ts index 738783b..18f89ad 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -70,6 +70,27 @@ export async function deleteTransaction(id: string): Promise { if (!res.ok) throw new Error('Failed to delete transaction') } +// 更新交易记录 +export async function updateTransaction(id: string, data: { + type?: TransactionType + symbol?: string + quantity?: number + price?: number + amount?: number + fee?: number + currency?: string + notes?: string + executedAt?: string +}): Promise { + const res = await fetch(`${API_BASE}/transactions`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, ...data }), + }) + if (!res.ok) throw new Error('Failed to update transaction') + return res.json() +} + // 持仓 API export async function fetchPositions(accountId?: string): Promise { const searchParams = accountId ? `?accountId=${accountId}` : '' diff --git a/src/types/yfinance.d.ts b/src/types/yfinance.d.ts new file mode 100644 index 0000000..f31c87b --- /dev/null +++ b/src/types/yfinance.d.ts @@ -0,0 +1,26 @@ +declare module 'yfinance' { + interface TickerInfo { + regularMarketPrice?: number + regularMarketPreviousClose?: number + symbol?: string + shortName?: string + longName?: string + currency?: string + marketCap?: number + fiftyTwoWeekHigh?: number + fiftyTwoWeekLow?: number + } + + interface Ticker { + info: Promise + history: (options?: { period?: string; interval?: string }) => Promise + } + + interface YFinance { + (symbol: string): Ticker + default: (symbol: string) => Ticker + } + + const yfinance: YFinance + export default yfinance +} \ No newline at end of file