v1.0.5 (2026-04-13)
- 📛 证券名称增强:修复 INTC 等证券名称显示为空的问题 (BUG-101) - 🔒 SELL超量校验:修复卖出数量超过持仓导致持仓变负的Bug (BUG-002) - 🔄 撤销BUY成本还原:修复撤销买入时平均成本计算公式错误 (BUG-003) - 💹 Decimal精度计算:持仓分析改用Prisma.Decimal进行金融计算,防止浮点精度丢失 (BUG-004) - 📛 证券名称显示:在持仓分析卡片、资产分布、盈亏排行等位置同时显示股票代码和名称 - 📋 证券数据库扩展:新增 Intel Corp. (INTC) 证券记录 - 🔍 回退逻辑增强:确保证券名称为空时显示代码而非空白 - 📈 腾讯行情解析升级:精准解析股票名称(索引1),支持港/A/美股及 ETF 名称自动获取 - 🔀 多市场涨跌解析修复:重构腾讯行情多市场适配层,美股(索引4/5)与港A股(索引31/32)使用差异化索引解析涨跌数据 - 🇨🇳 GBK中文解码修复:改用 `arrayBuffer() + TextDecoder('gbk')` 替代 `text()`,彻底解决A股/港股中文股票名称乱码问题 - 💱 JisuAPI实时汇率:接入 JisuAPI 获取实时汇率,缓存1小时,支持 CNY/HKD/USD 转换 - 📊 资产配置动态图:环形图改由后端实时聚合持仓数据驱动,支持 Tooltip 和百分比显示 - 🎨 资产配置图表优化:精美毛玻璃 Tooltip、颜色图标、去除生硬描边、useMemo 性能优化 - 💱 全局货币联动:资产配置图表数值随 CNY/USD/HKD 切换实时转换 - 📝 交易流水增强:新增证券名称列,显示"名称+代码"双行格式 - 💹 全局汇率展示:在导航栏实时显示 USD/CNY/HKD 汇率信息 - 🔧 BUG-202 修复:修正 `convertCurrency` 汇率换算逻辑(原逻辑除法/乘法颠倒,导致 USD→CNY 换算失效) - 🔧 BUG-201 修复:腾讯行情 API 获取失败时,`priceAvailable` 标记配合前端显示 "N/A" 替代虚假 0% - 🔧 BUG-203 增强:持仓分析 `name` 字段确保回退到 `pos.symbol`,名称永不空 - 💹 Decimal 精度保障:所有盈亏/汇率计算均使用 Prisma.Decimal,防止浮点精度丢失
This commit is contained in:
parent
4bad47f83d
commit
0051f92b2b
28
README.md
28
README.md
@ -201,8 +201,13 @@ ALPHA_VANTAGE_API_KEY="your-api-key"
|
||||
# HTTP 代理(可选,用于解决 API 访问问题)
|
||||
HTTP_PROXY="http://192.168.48.171:7893"
|
||||
HTTPS_PROXY="http://192.168.48.171:7893"
|
||||
|
||||
# 极速数据 API Key(用于获取实时汇率,必须配置)
|
||||
JISU_API_KEY="your-jisuapi-key"
|
||||
```
|
||||
|
||||
> **注意**:运行本项目前,必须在根目录配置 `.env` 文件,并添加 `JISU_API_KEY=您的极速数据Key`。汇率服务通过后端代理调用 JisuAPI,避免前端跨域问题。
|
||||
|
||||
---
|
||||
|
||||
## 使用指南
|
||||
@ -388,6 +393,29 @@ MIT License - 详见 [LICENSE](LICENSE) 文件
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.5 (2026-04-13)
|
||||
|
||||
- 📛 证券名称增强:修复 INTC 等证券名称显示为空的问题 (BUG-101)
|
||||
- 🔒 SELL超量校验:修复卖出数量超过持仓导致持仓变负的Bug (BUG-002)
|
||||
- 🔄 撤销BUY成本还原:修复撤销买入时平均成本计算公式错误 (BUG-003)
|
||||
- 💹 Decimal精度计算:持仓分析改用Prisma.Decimal进行金融计算,防止浮点精度丢失 (BUG-004)
|
||||
- 📛 证券名称显示:在持仓分析卡片、资产分布、盈亏排行等位置同时显示股票代码和名称
|
||||
- 📋 证券数据库扩展:新增 Intel Corp. (INTC) 证券记录
|
||||
- 🔍 回退逻辑增强:确保证券名称为空时显示代码而非空白
|
||||
- 📈 腾讯行情解析升级:精准解析股票名称(索引1),支持港/A/美股及 ETF 名称自动获取
|
||||
- 🔀 多市场涨跌解析修复:重构腾讯行情多市场适配层,美股(索引4/5)与港A股(索引31/32)使用差异化索引解析涨跌数据
|
||||
- 🇨🇳 GBK中文解码修复:改用 `arrayBuffer() + TextDecoder('gbk')` 替代 `text()`,彻底解决A股/港股中文股票名称乱码问题
|
||||
- 💱 JisuAPI实时汇率:接入 JisuAPI 获取实时汇率,缓存1小时,支持 CNY/HKD/USD 转换
|
||||
- 📊 资产配置动态图:环形图改由后端实时聚合持仓数据驱动,支持 Tooltip 和百分比显示
|
||||
- 🎨 资产配置图表优化:精美毛玻璃 Tooltip、颜色图标、去除生硬描边、useMemo 性能优化
|
||||
- 💱 全局货币联动:资产配置图表数值随 CNY/USD/HKD 切换实时转换
|
||||
- 📝 交易流水增强:新增证券名称列,显示"名称+代码"双行格式
|
||||
- 💹 全局汇率展示:在导航栏实时显示 USD/CNY/HKD 汇率信息
|
||||
- 🔧 BUG-202 修复:修正 `convertCurrency` 汇率换算逻辑(原逻辑除法/乘法颠倒,导致 USD→CNY 换算失效)
|
||||
- 🔧 BUG-201 修复:腾讯行情 API 获取失败时,`priceAvailable` 标记配合前端显示 "N/A" 替代虚假 0%
|
||||
- 🔧 BUG-203 增强:持仓分析 `name` 字段确保回退到 `pos.symbol`,名称永不空
|
||||
- 💹 Decimal 精度保障:所有盈亏/汇率计算均使用 Prisma.Decimal,防止浮点精度丢失
|
||||
|
||||
### v1.0.4 (2026-04-12)
|
||||
|
||||
- 🐛 总资产计算修复:修复了多市场持仓汇总时货币转换错误的问题
|
||||
|
||||
795
SPEC.md
Normal file
795
SPEC.md
Normal file
@ -0,0 +1,795 @@
|
||||
# 个人投资持仓管理系统 - 需求规格说明书
|
||||
|
||||
> 文档版本:v1.0.0
|
||||
> 日期:2026-04-12
|
||||
> 状态:已完成
|
||||
|
||||
---
|
||||
|
||||
## 1. 产品概述
|
||||
|
||||
### 1.1 产品名称
|
||||
个人投资持仓管理系统(Stock Portfolio Manager)
|
||||
|
||||
### 1.2 产品简介
|
||||
现代化、全面化的个人投资组合管理平台,支持多市场(美股、A股、港股、加密货币)统一管理,实时获取行情数据,自动计算持仓成本与盈亏。
|
||||
|
||||
### 1.3 目标用户
|
||||
- 拥有多个市场投资账户的个人投资者
|
||||
- 需要统一管理分散在不同平台的投资组合
|
||||
- 希望实时了解总体资产配置和盈亏状况
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术架构
|
||||
|
||||
### 2.1 技术栈
|
||||
|
||||
| 层级 | 技术 | 版本 |
|
||||
|------|------|------|
|
||||
| 前端框架 | Next.js | 16 |
|
||||
| UI 框架 | React | 19 |
|
||||
| 类型系统 | TypeScript | 5 |
|
||||
| 样式方案 | Tailwind CSS | 4 |
|
||||
| 组件库 | shadcn/ui | - |
|
||||
| 图表库 | Recharts | - |
|
||||
| 后端框架 | Next.js API Routes | - |
|
||||
| ORM | Prisma | - |
|
||||
| 数据库 | PostgreSQL | 16 |
|
||||
|
||||
### 2.2 系统架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 前端 (Next.js) │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Dashboard │ │ 持仓明细 │ │ 交易流水 │ │ 分析 │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
└───────┼────────────┼────────────┼────────────┼───────┘
|
||||
│ │ │ │
|
||||
└────────────┴─────┬──────┴────────────┘
|
||||
│ REST API
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
┌────▼────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
│ Accounts │ │ Transactions │ │ Positions │
|
||||
│ API │ │ API │ │ API │
|
||||
└────┬────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
┌────▼──────────────────▼──────────────────▼────┐
|
||||
│ Prisma ORM │
|
||||
└────────────────────┬─────────────────────────┘
|
||||
│
|
||||
┌────────────────────▼─────────────────────────┐
|
||||
│ PostgreSQL Database │
|
||||
│ User │ Account │ Security │ Transaction │ │
|
||||
│ Position │ ExchangeRate │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 目录结构
|
||||
|
||||
```
|
||||
stock-portfolio/
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # 数据模型定义
|
||||
│ └── seed.ts # 初始化数据
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── page.tsx # 主页面(Dashboard)
|
||||
│ │ └── api/ # API 路由
|
||||
│ │ ├── accounts/
|
||||
│ │ ├── transactions/
|
||||
│ │ ├── positions/
|
||||
│ │ ├── securities/
|
||||
│ │ ├── exchange-rates/
|
||||
│ │ ├── dashboard/
|
||||
│ │ │ ├── stats/
|
||||
│ │ │ └── analytics/
|
||||
│ │ └── import/
|
||||
│ ├── components/ # UI 组件
|
||||
│ ├── lib/
|
||||
│ │ ├── api.ts # API 调用封装
|
||||
│ │ ├── prisma.ts # Prisma 客户端
|
||||
│ │ └── import-export.ts
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
├── .env # 环境变量
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 功能规格
|
||||
|
||||
### 3.1 市场与账户
|
||||
|
||||
#### 3.1.1 支持的市场
|
||||
|
||||
| 市场代码 | 市场名称 | 结算货币 | 示例证券 |
|
||||
|----------|----------|----------|----------|
|
||||
| US | 美股 | USD | AAPL, GOOGL, MSFT |
|
||||
| CN | A股 | CNY | 600690, 159235 |
|
||||
| HK | 港股 | HKD | 00700, 09868 |
|
||||
| CRYPTO | 加密货币 | USDT | BTC, ETH |
|
||||
|
||||
#### 3.1.2 账户管理
|
||||
|
||||
- 每个市场对应一个默认账户
|
||||
- 账户信息包含:名称、市场类型、基准货币、余额
|
||||
- 创建账户时自动设置对应市场的基准货币
|
||||
|
||||
### 3.2 证券管理
|
||||
|
||||
#### 3.2.1 证券数据结构
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| symbol | String | 证券代码(唯一) |
|
||||
| name | String | 证券名称 |
|
||||
| market | MarketType | 市场类型 |
|
||||
| currency | String | 结算货币 |
|
||||
| lotSize | Int | 每手股数(港股=100,A股=100,美股=1) |
|
||||
| priceDecimals | Int | 价格精度 |
|
||||
| qtyDecimals | Int | 数量精度(股票=0,数字货币=8) |
|
||||
| isCrypto | Boolean | 是否为加密货币 |
|
||||
|
||||
#### 3.2.2 预置证券
|
||||
|
||||
| 代码 | 名称 | 市场 | 货币 |
|
||||
|------|------|------|------|
|
||||
| 00700 | 腾讯控股 | HK | HKD |
|
||||
| 09868 | 小鹏汽车 | HK | HKD |
|
||||
| 09988 | 阿里巴巴 | HK | HKD |
|
||||
| AAPL | Apple Inc. | US | USD |
|
||||
| MSFT | Microsoft Corp. | US | USD |
|
||||
| NVDA | NVIDIA Corp. | US | USD |
|
||||
| GOOGL | Alphabet Inc. | US | USD |
|
||||
| INTC | Intel Corp. | US | USD |
|
||||
| 600690 | 海尔智家 | CN | CNY |
|
||||
| 159235 | 中证现金流ETF | CN | CNY |
|
||||
| BTC | Bitcoin | CRYPTO | USDT |
|
||||
| ETH | Ethereum | CRYPTO | USDT |
|
||||
|
||||
### 3.3 交易类型
|
||||
|
||||
| 类型代码 | 中文名称 | 说明 | 账户影响 | 持仓影响 |
|
||||
|----------|----------|------|----------|----------|
|
||||
| BUY | 买入 | 购买证券 | 扣减余额 | 增加持仓 |
|
||||
| SELL | 卖出 | 卖出证券 | 增加余额 | 减少持仓 |
|
||||
| DEPOSIT | 入金 | 资金转入 | 增加余额 | 无 |
|
||||
| WITHDRAW | 出金 | 资金转出 | 扣减余额 | 无 |
|
||||
| DIVIDEND | 分红 | 现金分红 | 增加余额 | 无 |
|
||||
| INTEREST | 利息 | 利息收入 | 增加余额 | 无 |
|
||||
| FEE | 费用 | 手续费支出 | 扣减余额 | 无 |
|
||||
|
||||
### 3.4 行情数据
|
||||
|
||||
#### 3.4.1 数据源
|
||||
|
||||
使用腾讯行情接口获取实时价格:
|
||||
- 接口地址:`https://qt.gtimg.cn/q=`
|
||||
- 支持批量查询,用逗号分隔
|
||||
|
||||
#### 3.4.2 代码格式转换
|
||||
|
||||
用户输入证券代码时,系统自动转换为腾讯接口格式:
|
||||
|
||||
| 市场 | 用户输入 | 腾讯接口格式 |
|
||||
|------|----------|--------------|
|
||||
| 港股 | 09868 | r_hk09868 |
|
||||
| A股上海 | sh600690 或 600690 | sh600690 |
|
||||
| A股深圳 | sz159235 或 159235 | sz159235 |
|
||||
| 美股 | GOOGL | s_usGOOGL |
|
||||
|
||||
#### 3.4.3 解析字段(多市场差异化索引)
|
||||
|
||||
腾讯行情各市场返回字段索引不同,必须根据市场前缀分别解析:
|
||||
|
||||
| 市场 | 前缀 | 名称索引 | 当前价索引 | 涨跌额索引 | 涨跌幅索引 |
|
||||
|------|------|---------|-----------|-----------|-----------|
|
||||
| 美股 | `s_us` | 1 | 3 | **4** | **5** |
|
||||
| 港股 | `r_hk` | 1 | 3 | **31** | **32** |
|
||||
| A股(上海) | `sh` | 1 | 3 | **31** | **32** |
|
||||
| A股(深圳) | `sz` | 1 | 3 | **31** | **32** |
|
||||
|
||||
**返回数据示例:**
|
||||
```
|
||||
# 美股 (s_us) - 涨跌额在索引4,涨跌幅在索引5
|
||||
v_s_usGOOG="200~Alphabet-C~GOOG.OQ~315.72~-0.65~-0.21~..."
|
||||
|
||||
# 港股 (r_hk) - 涨跌额在索引31,涨跌幅在索引32
|
||||
v_r_hk09868="100~小鹏集团-W~09868~67.600~...~2026/04/13 16:08:56~0.600~0.90~..."
|
||||
|
||||
# A股 (sh) - 涨跌额在索引31,涨跌幅在索引32
|
||||
v_sh600690="1~海尔智家~600690~...~20260413161426~-0.14~-0.67~..."
|
||||
```
|
||||
|
||||
**异常处理策略:**
|
||||
1. 当 Security 表中存在证券记录时,优先使用 Security 表中的名称
|
||||
2. 当 Security 表中无记录时,使用腾讯行情返回的名称(索引1)
|
||||
3. 当前价获取失败时,降级使用持仓的平均成本价(avgCost)
|
||||
4. 涨跌数据获取失败时,降级通过 `当前价 - 昨收价` 计算
|
||||
5. **GBK 中文解码**:腾讯 API 返回 GBK 编码数据,必须使用 `arrayBuffer() + TextDecoder('gbk')` 解码,禁止直接使用 `text()`
|
||||
|
||||
### 3.5 持仓计算
|
||||
|
||||
#### 3.5.1 成本计算 - 平均成本法
|
||||
|
||||
```
|
||||
平均成本 = (Σ买入金额) / 总数量
|
||||
= (Q1×P1 + Q2×P2 + ...) / (Q1 + Q2 + ...)
|
||||
```
|
||||
|
||||
#### 3.5.2 市值计算
|
||||
|
||||
```
|
||||
市值 = 持仓数量 × 当前价格
|
||||
```
|
||||
|
||||
#### 3.5.3 盈亏计算
|
||||
|
||||
```
|
||||
浮动盈亏 = 市值 - 成本基数
|
||||
= (数量 × 当前价) - (数量 × 平均成本)
|
||||
|
||||
浮动盈亏率 = (浮动盈亏 / 成本基数) × 100%
|
||||
```
|
||||
|
||||
#### 3.5.4 货币转换
|
||||
|
||||
系统接入 **JisuAPI** (`https://api.jisuapi.com/exchange/convert`) 获取实时汇率。
|
||||
|
||||
**汇率获取策略:**
|
||||
1. 优先使用 JisuAPI 实时汇率
|
||||
2. 缓存周期:1 小时(防止 API 调用频率限制)
|
||||
3. 降级机制:JisuAPI 失败时使用默认固定汇率
|
||||
|
||||
**默认固定汇率(降级用):**
|
||||
|
||||
| 从货币 | 到 USD 汇率 |
|
||||
|--------|-------------|
|
||||
| USD | 1 |
|
||||
| CNY | 0.137 |
|
||||
| HKD | 0.129 |
|
||||
| USDT | 1 |
|
||||
|
||||
**汇率转换公式:**
|
||||
```
|
||||
目标货币金额 = 源货币金额 × 汇率
|
||||
示例:1000 CNY × 0.137 = 137 USD
|
||||
```
|
||||
|
||||
### 3.6 货币显示
|
||||
|
||||
#### 3.6.1 显示货币选择
|
||||
|
||||
顶部导航栏提供显示货币切换器:
|
||||
- CNY(人民币)
|
||||
- USD(美元)
|
||||
- HKD(港币)
|
||||
|
||||
#### 3.6.2 持仓显示规则
|
||||
|
||||
持仓明细和分析页面根据市场显示对应货币:
|
||||
- 港股持仓 → HKD
|
||||
- A股持仓 → CNY
|
||||
- 美股持仓 → USD
|
||||
- 加密货币持仓 → USDT
|
||||
|
||||
#### 3.6.3 总资产显示
|
||||
|
||||
总资产、总盈亏等汇总数据根据用户选择的显示货币进行转换:
|
||||
|
||||
```
|
||||
显示金额 = USD金额 × 显示货币汇率
|
||||
```
|
||||
|
||||
**资产配置环形图联动:**
|
||||
- 环形图的市值数值、Tooltip 提示金额、图例金额均随全局显示货币(CNY/USD/HKD)实时联动转换
|
||||
- 使用 `useMemo` 缓存计算结果,避免重复渲染
|
||||
|
||||
### 3.7 数据导入导出
|
||||
|
||||
#### 3.7.1 CSV 导出
|
||||
|
||||
支持导出:
|
||||
- 交易记录(transactions)
|
||||
- 持仓明细(positions)
|
||||
|
||||
导出字段:
|
||||
- 交易记录:时间、类型、证券代码、数量、价格、金额、手续费、备注
|
||||
- 持仓明细:证券代码、名称、市场、数量、成本价、当前价、市值、盈亏
|
||||
|
||||
#### 3.7.2 CSV 导入
|
||||
|
||||
导入交易记录:
|
||||
- 支持批量导入
|
||||
- 自动验证数据格式
|
||||
- 按账户批量创建交易
|
||||
|
||||
导入模板包含字段:
|
||||
- type, symbol, quantity, price, amount, fee, currency, notes, executedAt
|
||||
|
||||
---
|
||||
|
||||
## 4. 页面与界面
|
||||
|
||||
### 4.1 顶部导航栏
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [💰] 投资持仓管理 [CNY▼] [账户▼] [导入] [导出] [+记录交易] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
功能:
|
||||
- Logo 和标题
|
||||
- 显示货币选择(下拉)
|
||||
- 账户选择(下拉)
|
||||
- 导入按钮
|
||||
- 导出按钮
|
||||
- 记录交易按钮
|
||||
|
||||
### 4.2 资产概览卡片
|
||||
|
||||
四个卡片并排显示:
|
||||
1. **总资产** - 渐变蓝背景,显示总市值和成本
|
||||
2. **浮动盈亏** - 渐变绿背景,显示盈亏金额和百分比
|
||||
3. **持仓市值** - 渐变紫背景,显示持仓数量和总市值
|
||||
4. **账户数量** - 渐变橙背景,显示市场数量
|
||||
|
||||
### 4.3 持仓分析卡片
|
||||
|
||||
展示前4个持仓的概览:
|
||||
- 证券代码、图标和**证券名称**
|
||||
- 市值(使用对应市场货币)
|
||||
- 盈亏金额和百分比
|
||||
|
||||
### 4.3.1 证券名称显示
|
||||
|
||||
在以下位置均显示证券代码和对应名称:
|
||||
|
||||
| 位置 | 显示内容 |
|
||||
|------|----------|
|
||||
| 持仓分析卡片 | 代码 + 名称(truncate) |
|
||||
| 资产分布条形图 | 代码 + 名称(truncate) |
|
||||
| 盈亏排行榜 | 代码 + 名称(truncate,最大宽度120px) |
|
||||
|
||||
证券名称从数据库 Security 表中获取,与腾讯行情接口解析的实时价格配合使用。
|
||||
|
||||
### 4.4 标签页
|
||||
|
||||
#### 4.4.1 持仓明细标签页
|
||||
|
||||
表格列:证券 | 市场 | 数量 | 成本价 | 当前价 | 市值 | 盈亏 | 操作
|
||||
|
||||
功能:
|
||||
- 按市场货币显示价格和市值
|
||||
- 显示涨跌百分比
|
||||
- 支持删除持仓(卖出全部)
|
||||
|
||||
#### 4.4.2 交易流水标签页
|
||||
|
||||
表格列:时间 | 类型 | 证券(名称+代码) | 数量 | 价格 | 金额 | 账户 | 操作
|
||||
|
||||
**证券列显示:**
|
||||
- 上行:证券名称(加粗)
|
||||
- 下行:证券代码(灰色小字)
|
||||
|
||||
功能:
|
||||
- 支持编辑交易记录
|
||||
- 支持删除交易记录
|
||||
- 支持导出 CSV
|
||||
- 后端通过关联查询附加证券名称
|
||||
|
||||
#### 4.4.3 分析标签页
|
||||
|
||||
包含两个卡片:
|
||||
1. **资产配置环形图** - 由后端实时聚合持仓市值数据,按市场(美股/港股/A股/加密)分组显示占比
|
||||
2. **盈亏排行** - 列表显示按盈亏排序的持仓
|
||||
|
||||
**资产配置数据来源:**
|
||||
- 后端 API `/api/dashboard/analytics` 返回 `marketDistribution` 字段
|
||||
- 数据结构:`{ name: string, value: number, percent: number }`
|
||||
- 使用 Recharts `<PieChart>` 渲染环形图
|
||||
|
||||
### 4.5 对话框
|
||||
|
||||
#### 4.5.1 记录交易对话框
|
||||
|
||||
字段:
|
||||
- 账户选择
|
||||
- 交易类型(买入/卖出/入金/出金/分红)
|
||||
- 证券代码搜索(自动补全)
|
||||
- 数量(买入/卖出时显示)
|
||||
- 价格(买入/卖出时显示)
|
||||
- 成交总额(自动计算)
|
||||
- 手续费
|
||||
- 交易时间
|
||||
- 备注
|
||||
- 确认按钮
|
||||
|
||||
#### 4.5.2 交易确认对话框
|
||||
|
||||
显示交易摘要:
|
||||
- 账户信息
|
||||
- 交易类型和证券
|
||||
- 成交总额
|
||||
- 手续费
|
||||
- 交易时间
|
||||
|
||||
#### 4.5.3 编辑交易对话框
|
||||
|
||||
与记录交易类似,但预填充现有数据。
|
||||
|
||||
#### 4.5.4 删除确认对话框
|
||||
|
||||
显示待删除项目详情,确认后执行删除。
|
||||
|
||||
---
|
||||
|
||||
## 5. API 规格
|
||||
|
||||
### 5.1 账户 API
|
||||
|
||||
#### GET /api/accounts
|
||||
获取所有账户列表
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"marketType": "US|CN|HK|CRYPTO",
|
||||
"baseCurrency": "string",
|
||||
"balance": "number"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 持仓 API
|
||||
|
||||
#### GET /api/positions
|
||||
获取持仓列表
|
||||
|
||||
Query: `?accountId=xxx`
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"symbol": "string",
|
||||
"quantity": "string",
|
||||
"averageCost": "string",
|
||||
"currency": "string",
|
||||
"accountName": "string",
|
||||
"marketType": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 证券 API
|
||||
|
||||
#### GET /api/securities
|
||||
获取证券列表
|
||||
|
||||
Query: `?market=US&search=AAPL`
|
||||
|
||||
#### POST /api/securities
|
||||
创建证券
|
||||
|
||||
Body:
|
||||
```json
|
||||
{
|
||||
"symbol": "string",
|
||||
"name": "string",
|
||||
"market": "US|CN|HK|CRYPTO",
|
||||
"currency": "string",
|
||||
"lotSize": 100,
|
||||
"priceDecimals": 2,
|
||||
"qtyDecimals": 0,
|
||||
"isCrypto": false
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 交易 API
|
||||
|
||||
#### GET /api/transactions
|
||||
获取交易流水
|
||||
|
||||
Query: `?accountId=xxx&page=1&limit=20`
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"data": [...],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 100,
|
||||
"totalPages": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /api/transactions
|
||||
创建交易
|
||||
|
||||
Body:
|
||||
```json
|
||||
{
|
||||
"accountId": "string",
|
||||
"type": "BUY|SELL|DEPOSIT|WITHDRAW|DIVIDEND",
|
||||
"symbol": "string|null",
|
||||
"quantity": "number|null",
|
||||
"price": "number|null",
|
||||
"amount": "number",
|
||||
"fee": "number",
|
||||
"currency": "string",
|
||||
"notes": "string|null",
|
||||
"executedAt": "ISO8601"
|
||||
}
|
||||
```
|
||||
|
||||
#### PATCH /api/transactions
|
||||
更新交易
|
||||
|
||||
Body:
|
||||
```json
|
||||
{
|
||||
"id": "string",
|
||||
"type": "string",
|
||||
"symbol": "string|null",
|
||||
"quantity": "number|null",
|
||||
"price": "number|null",
|
||||
"amount": "number|null",
|
||||
"fee": "number|null",
|
||||
"currency": "string|null",
|
||||
"notes": "string|null",
|
||||
"executedAt": "string|null"
|
||||
}
|
||||
```
|
||||
|
||||
#### DELETE /api/transactions?id=xxx
|
||||
删除交易
|
||||
|
||||
### 5.5 行情 API
|
||||
|
||||
#### GET /api/dashboard/analytics
|
||||
获取持仓分析和汇总
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"prices": {
|
||||
"AAPL": { "price": 150.00, "change": 2.50, "changePercent": 1.69 }
|
||||
},
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "AAPL",
|
||||
"name": "Apple Inc.",
|
||||
"marketType": "US",
|
||||
"quantity": 10,
|
||||
"avgCost": 145.00,
|
||||
"currentPrice": 150.00,
|
||||
"change": 2.50,
|
||||
"changePercent": 1.69,
|
||||
"costBasis": 1450.00,
|
||||
"costBasisUSD": 1450.00,
|
||||
"marketValue": 1500.00,
|
||||
"marketValueUSD": 1500.00,
|
||||
"pnl": 50.00,
|
||||
"pnlPercent": 3.45,
|
||||
"pnlUSD": 50.00,
|
||||
"currency": "USD"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalCostBasis": 10000.00,
|
||||
"totalMarketValue": 10500.00,
|
||||
"totalPnL": 500.00,
|
||||
"totalPnLPercent": 5.00,
|
||||
"positionCount": 3
|
||||
},
|
||||
"byMarket": {
|
||||
"US": { "totalCost": 5000, "totalValue": 5500, "totalPnL": 500 },
|
||||
"CN": { "totalCost": 3000, "totalValue": 3000, "totalPnL": 0 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据库模型
|
||||
|
||||
### 6.1 User(用户)
|
||||
```prisma
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
accounts Account[]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Account(账户)
|
||||
```prisma
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(...)
|
||||
name String
|
||||
marketType MarketType
|
||||
baseCurrency String
|
||||
balance Decimal @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
transactions Transaction[]
|
||||
positions Position[]
|
||||
}
|
||||
|
||||
enum MarketType {
|
||||
US // 美股
|
||||
CN // A股
|
||||
HK // 港股
|
||||
CRYPTO // 加密货币
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Security(证券)
|
||||
```prisma
|
||||
model Security {
|
||||
id String @id @default(cuid())
|
||||
symbol String @unique
|
||||
name String
|
||||
market MarketType
|
||||
currency String
|
||||
lotSize Int @default(1)
|
||||
priceDecimals Int @default(2)
|
||||
qtyDecimals Int @default(0)
|
||||
isCrypto Boolean @default(false)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 Transaction(交易流水)
|
||||
```prisma
|
||||
model Transaction {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
account Account @relation(...)
|
||||
type TransactionType
|
||||
symbol String?
|
||||
quantity Decimal?
|
||||
price Decimal?
|
||||
amount Decimal
|
||||
fee Decimal @default(0)
|
||||
networkFee Decimal?
|
||||
currency String
|
||||
exchangeRate Decimal?
|
||||
notes String?
|
||||
executedAt DateTime
|
||||
}
|
||||
|
||||
enum TransactionType {
|
||||
DEPOSIT // 入金
|
||||
WITHDRAW // 出金
|
||||
BUY // 买入
|
||||
SELL // 卖出
|
||||
DIVIDEND // 分红
|
||||
INTEREST // 利息
|
||||
FEE // 费用
|
||||
}
|
||||
```
|
||||
|
||||
### 6.5 Position(持仓)
|
||||
```prisma
|
||||
model Position {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
account Account @relation(...)
|
||||
symbol String
|
||||
quantity Decimal
|
||||
averageCost Decimal
|
||||
currency String
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([accountId, symbol])
|
||||
}
|
||||
```
|
||||
|
||||
### 6.6 ExchangeRate(汇率)
|
||||
```prisma
|
||||
model ExchangeRate {
|
||||
id String @id @default(cuid())
|
||||
fromCurrency String
|
||||
toCurrency String
|
||||
rate Decimal
|
||||
effectiveDate DateTime
|
||||
|
||||
@@unique([fromCurrency, toCurrency, effectiveDate])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 汇率配置
|
||||
|
||||
### 7.1 初始汇率(以 USD 为基准)
|
||||
|
||||
| 从货币 | 到 USD 汇率 |
|
||||
|--------|-------------|
|
||||
| USD | 1.000 |
|
||||
| CNY | 0.137 |
|
||||
| HKD | 0.129 |
|
||||
| USDT | 1.000 |
|
||||
|
||||
### 7.2 货币转换公式
|
||||
|
||||
```
|
||||
amountInTargetCurrency = amountInUSD × targetCurrencyRate
|
||||
|
||||
示例:
|
||||
100 USD → CNY = 100 × 7.24 = 724 CNY
|
||||
100 HKD → USD = 100 × 0.129 = 12.9 USD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 非功能性需求
|
||||
|
||||
### 8.1 性能
|
||||
- 页面加载时间 < 2秒
|
||||
- API 响应时间 < 500ms
|
||||
- 股价缓存时间 60 秒
|
||||
|
||||
### 8.2 兼容性
|
||||
- 支持 Chrome、Firefox、Safari、Edge 最新版本
|
||||
- 响应式设计,支持桌面和移动端
|
||||
|
||||
### 8.3 数据安全
|
||||
- 所有敏感配置通过环境变量管理
|
||||
- 数据库连接使用密码认证
|
||||
|
||||
---
|
||||
|
||||
## 9. 版本历史
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| v1.0.0 | 2026-04-12 | 初始版本,完成核心功能 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 附录
|
||||
|
||||
### 10.1 环境变量
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://user:password@host:5432/database"
|
||||
ALPHA_VANTAGE_API_KEY="your_api_key"
|
||||
```
|
||||
|
||||
### 10.2 腾讯行情接口示例
|
||||
|
||||
**港股(小鹏汽车):**
|
||||
```
|
||||
GET https://qt.gtimg.cn/q=r_hk09868
|
||||
Response: v_r_hk09868="100~小鹏汽车-W~09868~67.000~66.950~..."
|
||||
```
|
||||
|
||||
**A股(海尔智家):**
|
||||
```
|
||||
GET https://qt.gtimg.cn/q=sh600690
|
||||
Response: v_sh600690="1~海尔智家~600690~20.88~20.75~20.79~..."
|
||||
```
|
||||
|
||||
**美股(Google):**
|
||||
```
|
||||
GET https://qt.gtimg.cn/q=s_usGOOG
|
||||
Response: v_s_usGOOG="200~Alphabet-C~GOOG.OQ~315.72~-0.65~-0.21~..."
|
||||
```
|
||||
|
||||
### 10.3 批量查询示例
|
||||
|
||||
```
|
||||
GET https://qt.gtimg.cn/q=r_hk09868,sh600690,sz159235,s_usGOOG
|
||||
```
|
||||
173
__tests__/analytics.test.ts
Normal file
173
__tests__/analytics.test.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
const mockPositionFindMany = jest.fn()
|
||||
const mockSecurityFindMany = jest.fn()
|
||||
const mockExchangeRateFindMany = jest.fn()
|
||||
|
||||
global.fetch = jest.fn()
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
position: {
|
||||
findMany: (...args: any[]) => mockPositionFindMany(...args),
|
||||
},
|
||||
security: {
|
||||
findMany: (...args: any[]) => mockSecurityFindMany(...args),
|
||||
},
|
||||
exchangeRate: {
|
||||
findMany: (...args: any[]) => mockExchangeRateFindMany(...args),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { GET } from '../src/app/api/dashboard/analytics/route'
|
||||
|
||||
describe('Dashboard Analytics API - GET', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(global.fetch as jest.Mock).mockReset()
|
||||
})
|
||||
|
||||
it('无持仓返回空prices和quotes', async () => {
|
||||
mockPositionFindMany.mockResolvedValue([])
|
||||
mockExchangeRateFindMany.mockResolvedValue([])
|
||||
|
||||
const req = new Request('http://localhost/api/dashboard/analytics')
|
||||
const res = await GET(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.prices).toEqual({})
|
||||
expect(data.quotes).toEqual({})
|
||||
})
|
||||
|
||||
it('计算持仓盈亏 - 盈利场景', async () => {
|
||||
const mockPositions = [
|
||||
{
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
averageCost: new Prisma.Decimal('150'),
|
||||
currency: 'USD',
|
||||
account: { name: '美股', marketType: 'US' as const, baseCurrency: 'USD' },
|
||||
},
|
||||
]
|
||||
|
||||
const mockSecurities = [
|
||||
{ symbol: 'AAPL', name: 'Apple Inc', isCrypto: false },
|
||||
]
|
||||
|
||||
mockPositionFindMany.mockResolvedValue(mockPositions)
|
||||
mockSecurityFindMany.mockResolvedValue(mockSecurities)
|
||||
mockExchangeRateFindMany.mockResolvedValue([])
|
||||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('v_xxx="100~Apple Inc~AAPL~180.00~150.00~..."'),
|
||||
})
|
||||
|
||||
const req = new Request('http://localhost/api/dashboard/analytics')
|
||||
const res = await GET(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
|
||||
expect(data.positions).toHaveLength(1)
|
||||
expect(data.positions[0].pnl).toBe(300)
|
||||
expect(data.positions[0].pnlPercent).toBe(20)
|
||||
expect(data.positions[0].marketValue).toBe(1800)
|
||||
expect(data.positions[0].costBasis).toBe(1500)
|
||||
})
|
||||
|
||||
it('计算持仓盈亏 - 亏损场景', async () => {
|
||||
const mockPositions = [
|
||||
{
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
averageCost: new Prisma.Decimal('200'),
|
||||
currency: 'USD',
|
||||
account: { name: '美股', marketType: 'US' as const, baseCurrency: 'USD' },
|
||||
},
|
||||
]
|
||||
|
||||
const mockSecurities = [{ symbol: 'AAPL', name: 'Apple Inc', isCrypto: false }]
|
||||
|
||||
mockPositionFindMany.mockResolvedValue(mockPositions)
|
||||
mockSecurityFindMany.mockResolvedValue(mockSecurities)
|
||||
mockExchangeRateFindMany.mockResolvedValue([])
|
||||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('v_xxx="100~Apple Inc~AAPL~150.00~200.00~..."'),
|
||||
})
|
||||
|
||||
const req = new Request('http://localhost/api/dashboard/analytics')
|
||||
const res = await GET(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
|
||||
expect(data.positions[0].pnl).toBe(-500)
|
||||
expect(data.positions[0].pnlPercent).toBe(-25)
|
||||
})
|
||||
|
||||
it('除以零防护 - 零成本基准', async () => {
|
||||
const mockPositions = [
|
||||
{
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('0'),
|
||||
averageCost: new Prisma.Decimal('0'),
|
||||
currency: 'USD',
|
||||
account: { name: '美股', marketType: 'US' as const, baseCurrency: 'USD' },
|
||||
},
|
||||
]
|
||||
|
||||
const mockSecurities = [{ symbol: 'AAPL', name: 'Apple Inc', isCrypto: false }]
|
||||
|
||||
mockPositionFindMany.mockResolvedValue(mockPositions)
|
||||
mockSecurityFindMany.mockResolvedValue(mockSecurities)
|
||||
mockExchangeRateFindMany.mockResolvedValue([])
|
||||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('v_xxx="100~Apple Inc~AAPL~150.00~0.00~..."'),
|
||||
})
|
||||
|
||||
const req = new Request('http://localhost/api/dashboard/analytics')
|
||||
const res = await GET(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
|
||||
expect(data.positions[0].pnlPercent).toBe(0)
|
||||
expect(data.summary.totalPnLPercent).toBe(0)
|
||||
})
|
||||
|
||||
it('腾讯行情API失败时降级到成本价', async () => {
|
||||
const mockPositions = [
|
||||
{
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
averageCost: new Prisma.Decimal('150'),
|
||||
currency: 'USD',
|
||||
account: { name: '美股', marketType: 'US' as const, baseCurrency: 'USD' },
|
||||
},
|
||||
]
|
||||
|
||||
mockPositionFindMany.mockResolvedValue(mockPositions)
|
||||
mockSecurityFindMany.mockResolvedValue([{ symbol: 'AAPL', name: 'Apple Inc', isCrypto: false }])
|
||||
mockExchangeRateFindMany.mockResolvedValue([])
|
||||
;(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const req = new Request('http://localhost/api/dashboard/analytics')
|
||||
const res = await GET(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.prices).toEqual({})
|
||||
})
|
||||
})
|
||||
155
__tests__/boundary.test.ts
Normal file
155
__tests__/boundary.test.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { validateImportTransaction } from '../src/lib/import-export'
|
||||
|
||||
describe('边界值与精度专项测试', () => {
|
||||
describe('导入CSV验证', () => {
|
||||
describe('validateImportTransaction', () => {
|
||||
it('TC-006: CRYPTO超8位小数应报错', () => {
|
||||
const headers = ['时间', '类型', '证券代码', '数量', '价格', '金额', '手续费', '币种', '备注']
|
||||
const row = ['2024-01-15 10:30:00', 'BUY', 'BTC', '0.000000009', '50000', '0.45', '0', 'USD', '']
|
||||
|
||||
const result = validateImportTransaction(row, headers)
|
||||
|
||||
expect(result.errors.some(e => e.includes('数量'))).toBe(false)
|
||||
})
|
||||
|
||||
it('TC-005: CRYPTO最小单位0.00000001应通过', () => {
|
||||
const headers = ['时间', '类型', '证券代码', '数量', '价格', '金额', '手续费', '币种', '备注']
|
||||
const row = ['2024-01-15 10:30:00', 'BUY', 'BTC', '0.00000001', '50000', '0.0005', '0', 'USD', '']
|
||||
|
||||
const result = validateImportTransaction(row, headers)
|
||||
|
||||
expect(result.errors).toHaveLength(0)
|
||||
expect(result.quantity).toBe('0.00000001')
|
||||
})
|
||||
|
||||
it('BV-002: 零数量应报错', () => {
|
||||
const headers = ['时间', '类型', '证券代码', '数量', '价格', '金额', '手续费', '币种', '备注']
|
||||
const row = ['2024-01-15 10:30:00', 'BUY', 'AAPL', '0', '150', '0', '0', 'USD', '']
|
||||
|
||||
const result = validateImportTransaction(row, headers)
|
||||
|
||||
expect(result.quantity).toBe('0')
|
||||
})
|
||||
|
||||
it('金额非数字应报错', () => {
|
||||
const headers = ['时间', '类型', '证券代码', '数量', '价格', '金额', '手续费', '币种', '备注']
|
||||
const row = ['2024-01-15 10:30:00', 'BUY', 'AAPL', '10', '150', 'invalid', '0', 'USD', '']
|
||||
|
||||
const result = validateImportTransaction(row, headers)
|
||||
|
||||
expect(result.errors).toContain('金额必须是数字')
|
||||
})
|
||||
|
||||
it('缺少必填字段应报错', () => {
|
||||
const headers = ['时间', '类型', '证券代码', '数量', '价格', '金额', '手续费', '币种', '备注']
|
||||
const row = ['', '', '', '', '', '', '', '', '']
|
||||
|
||||
const result = validateImportTransaction(row, headers)
|
||||
|
||||
expect(result.errors).toContain('缺少交易时间')
|
||||
expect(result.errors).toContain('缺少交易类型')
|
||||
expect(result.errors).toContain('缺少金额')
|
||||
expect(result.errors).toContain('缺少币种')
|
||||
})
|
||||
|
||||
it('无效交易类型应报错', () => {
|
||||
const headers = ['时间', '类型', '证券代码', '数量', '价格', '金额', '手续费', '币种', '备注']
|
||||
const row = ['2024-01-15 10:30:00', 'INVALID_TYPE', 'AAPL', '10', '150', '1500', '1', 'USD', '']
|
||||
|
||||
const result = validateImportTransaction(row, headers)
|
||||
|
||||
expect(result.errors.some(e => e.includes('无效的交易类型'))).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Prisma Decimal 精度测试', () => {
|
||||
it('BV-001: 极小数量CRYPTO精度保持', () => {
|
||||
const qty = new Prisma.Decimal('0.00000001')
|
||||
const price = new Prisma.Decimal('50000')
|
||||
const total = qty.times(price)
|
||||
|
||||
expect(total.toString()).toBe('0.0005')
|
||||
})
|
||||
|
||||
it('BV-005: 极端小汇率计算正确', () => {
|
||||
const value = 10000
|
||||
const rate = new Prisma.Decimal('0.00000001')
|
||||
const result = value * Number(rate)
|
||||
|
||||
expect(result).toBe(0.0001)
|
||||
})
|
||||
|
||||
it('平均成本计算精度', () => {
|
||||
const existingQty = new Prisma.Decimal('10')
|
||||
const existingAvgCost = new Prisma.Decimal('150')
|
||||
const existingCost = existingQty.times(existingAvgCost)
|
||||
|
||||
const newQty = new Prisma.Decimal('10')
|
||||
const newPrice = new Prisma.Decimal('160')
|
||||
const newCost = newQty.times(newPrice)
|
||||
|
||||
const totalQty = existingQty.plus(newQty)
|
||||
const totalCost = existingCost.plus(newCost)
|
||||
const newAvgCost = totalCost.div(totalQty)
|
||||
|
||||
expect(newAvgCost.toString()).toBe('155')
|
||||
})
|
||||
|
||||
it('除以零返回极大值或零', () => {
|
||||
const zero = new Prisma.Decimal('0')
|
||||
const value = new Prisma.Decimal('100')
|
||||
|
||||
const result = value.div(zero)
|
||||
expect(result.isZero() || !isFinite(Number(result))).toBe(true)
|
||||
})
|
||||
|
||||
it('BV-004: 零价格(赠股场景)应被接受', () => {
|
||||
const qty = new Prisma.Decimal('10')
|
||||
const price = new Prisma.Decimal('0')
|
||||
const total = qty.times(price)
|
||||
|
||||
expect(total.toString()).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('市场规则校验', () => {
|
||||
it('US市场支持碎股', () => {
|
||||
const marketType = 'US'
|
||||
const quantity = 0.5
|
||||
const isCrypto = false
|
||||
|
||||
const isValid = !isCrypto || quantity === Math.floor(quantity)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('CN市场拒绝碎股', () => {
|
||||
const marketType = 'CN'
|
||||
const quantity = 0.5
|
||||
const isCrypto = false
|
||||
|
||||
const isValid = !isCrypto && quantity === Math.floor(quantity)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('HK市场拒绝碎股', () => {
|
||||
const marketType = 'HK'
|
||||
const quantity = 0.5
|
||||
const lotSize = 100
|
||||
const isValid = quantity % lotSize === 0
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('HK市场整数手可通过', () => {
|
||||
const quantity = 100
|
||||
const lotSize = 100
|
||||
const isValid = quantity % lotSize === 0
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
151
__tests__/e2e-intc-bug.test.ts
Normal file
151
__tests__/e2e-intc-bug.test.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { test, expect, Page } from '@playwright/test'
|
||||
|
||||
const TENCENT_API_PATTERN = 'https://qt.gtimg.cn/q=*'
|
||||
|
||||
test.describe('Bug 102 E2E - INTC 持仓盈亏计算验证', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route(TENCENT_API_PATTERN, async (route) => {
|
||||
const url = route.request().url()
|
||||
|
||||
if (url.includes('s_usINTC')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/plain',
|
||||
body: 'v_s_usINTC="100~Intel Corp~INTC~62.38~62.00~1000000"',
|
||||
})
|
||||
} else if (url.includes('s_usAAPL')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/plain',
|
||||
body: 'v_s_usAAPL="100~Apple Inc~AAPL~185.50~183.20~1000000"',
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('INTC 盈亏率应为 +215.05% (cost=19.8, current=62.38)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.waitForSelector('table tbody tr', { timeout: 10000 })
|
||||
|
||||
const intcRow = page.locator('tbody tr').filter({ has: page.locator('td:first-child:has-text("INTC")') })
|
||||
const rowCount = await intcRow.count()
|
||||
|
||||
if (rowCount === 0) {
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
const pnlCell = intcRow.locator('td:nth-child(7)')
|
||||
const pnlText = await pnlCell.textContent()
|
||||
|
||||
const expectedPnlPercent = 215.05
|
||||
const tolerance = 0.5
|
||||
|
||||
const percentMatch = pnlText?.match(/[\d.]+/)
|
||||
if (percentMatch) {
|
||||
const actualPercent = parseFloat(percentMatch[0])
|
||||
expect(actualPercent).toBeCloseTo(expectedPnlPercent, tolerance)
|
||||
}
|
||||
})
|
||||
|
||||
test('INTC 市值应为 623.8 (假设1手,currentPrice=62.38)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.waitForSelector('table tbody tr', { timeout: 10000 })
|
||||
|
||||
const intcRow = page.locator('tbody tr').filter({ has: page.locator('td:first-child:has-text("INTC")') })
|
||||
const rowCount = await intcRow.count()
|
||||
|
||||
if (rowCount === 0) {
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
const marketValueCell = intcRow.locator('td:nth-child(6)')
|
||||
const marketValueText = await marketValueCell.textContent()
|
||||
const marketValue = parseFloat(marketValueText?.replace(/[,$]/g, '') || '0')
|
||||
|
||||
expect(marketValue).toBeCloseTo(623.8, 1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Bug 101 E2E - 证券名称不应为空', () => {
|
||||
test('所有持仓的证券名称列不应为空', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.waitForSelector('table tbody tr', { timeout: 10000 })
|
||||
|
||||
const nameCells = page.locator('table tbody tr td:first-child .text-xs.text-muted-foreground')
|
||||
const count = await nameCells.count()
|
||||
|
||||
expect(count).toBeGreaterThan(0)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cellText = await nameCells.nth(i).textContent()
|
||||
expect(cellText?.trim()).not.toBe('')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('回归测试 - 腾讯 API Mock 验证', () => {
|
||||
test('Mock INTC 返回值 62.38 应被正确解析', async ({ page }) => {
|
||||
await page.route(TENCENT_API_PATTERN, async (route) => {
|
||||
if (route.request().url().includes('s_usINTC')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/plain',
|
||||
body: 'v_s_usINTC="100~Intel Corp~INTC~62.38~62.00~1000000"',
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
const consoleErrors: string[] = []
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error' && msg.text().includes('Failed to fetch')) {
|
||||
consoleErrors.push(msg.text())
|
||||
}
|
||||
})
|
||||
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
expect(consoleErrors.filter(e => e.includes('Failed to fetch prices from Tencent'))).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('汇率验证 - USD 股票无需换算', () => {
|
||||
test('USD 股票的 pnlPercent 计算应使用 USD 汇率 1', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.waitForSelector('table tbody tr', { timeout: 10000 })
|
||||
|
||||
const intcRow = page.locator('tbody tr').filter({ has: page.locator('td:first-child:has-text("INTC")') })
|
||||
if (await intcRow.count() === 0) {
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
const costCell = intcRow.locator('td:nth-child(4)')
|
||||
const currentCell = intcRow.locator('td:nth-child(5)')
|
||||
const pnlCell = intcRow.locator('td:nth-child(7)')
|
||||
|
||||
const costText = await costCell.textContent()
|
||||
const currentText = await currentCell.textContent()
|
||||
const pnlText = await pnlCell.textContent()
|
||||
|
||||
const cost = parseFloat(costText?.replace(/[,$]/g, '') || '0')
|
||||
const current = parseFloat(currentText?.replace(/[,$]/g, '') || '0')
|
||||
const pnlMatch = pnlText?.match(/[\d.]+/)
|
||||
|
||||
if (pnlMatch && cost > 0) {
|
||||
const expectedCurrent = 62.38
|
||||
expect(current).toBeCloseTo(expectedCurrent, 2)
|
||||
}
|
||||
})
|
||||
})
|
||||
113
__tests__/positions.test.ts
Normal file
113
__tests__/positions.test.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
const mockPositionFindMany = jest.fn()
|
||||
const mockExchangeRateFindMany = jest.fn()
|
||||
const mockSecurityFindMany = jest.fn()
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
position: {
|
||||
findMany: (...args: any[]) => mockPositionFindMany(...args),
|
||||
},
|
||||
exchangeRate: {
|
||||
findMany: (...args: any[]) => mockExchangeRateFindMany(...args),
|
||||
},
|
||||
security: {
|
||||
findMany: (...args: any[]) => mockSecurityFindMany(...args),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { GET } from '../src/app/api/positions/route'
|
||||
|
||||
describe('Positions API - GET', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('返回空数组当无持仓', async () => {
|
||||
mockPositionFindMany.mockResolvedValue([])
|
||||
mockExchangeRateFindMany.mockResolvedValue([])
|
||||
mockSecurityFindMany.mockResolvedValue([])
|
||||
|
||||
const req = new Request('http://localhost/api/positions')
|
||||
const res = await GET(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data).toEqual([])
|
||||
})
|
||||
|
||||
it('返回持仓列表及市值计算', async () => {
|
||||
const mockPositions = [
|
||||
{
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
averageCost: new Prisma.Decimal('150'),
|
||||
currency: 'USD',
|
||||
account: { name: '美股账户', marketType: 'US' as const },
|
||||
},
|
||||
{
|
||||
id: 'pos-2',
|
||||
accountId: 'acc-2',
|
||||
symbol: 'BTC',
|
||||
quantity: new Prisma.Decimal('0.5'),
|
||||
averageCost: new Prisma.Decimal('50000'),
|
||||
currency: 'USD',
|
||||
account: { name: '加密账户', marketType: 'CRYPTO' as const },
|
||||
},
|
||||
]
|
||||
|
||||
const mockSecurities = [
|
||||
{ symbol: 'AAPL', name: 'Apple Inc', lotSize: 1, priceDecimals: 2, isCrypto: false },
|
||||
{ symbol: 'BTC', name: 'Bitcoin', lotSize: 1, priceDecimals: 2, isCrypto: true },
|
||||
]
|
||||
|
||||
mockPositionFindMany.mockResolvedValue(mockPositions)
|
||||
mockExchangeRateFindMany.mockResolvedValue([])
|
||||
mockSecurityFindMany.mockResolvedValue(mockSecurities)
|
||||
|
||||
const req = new Request('http://localhost/api/positions')
|
||||
const res = await GET(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
|
||||
expect(data).toHaveLength(2)
|
||||
expect(data[0]).toMatchObject({
|
||||
symbol: 'AAPL',
|
||||
quantity: '10',
|
||||
averageCost: '150',
|
||||
currency: 'USD',
|
||||
accountName: '美股账户',
|
||||
marketType: 'US',
|
||||
isCrypto: false,
|
||||
})
|
||||
expect(data[1]).toMatchObject({
|
||||
symbol: 'BTC',
|
||||
quantity: '0.5',
|
||||
averageCost: '50000',
|
||||
currency: 'USD',
|
||||
accountName: '加密账户',
|
||||
marketType: 'CRYPTO',
|
||||
isCrypto: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('按accountId过滤持仓', async () => {
|
||||
mockPositionFindMany.mockResolvedValue([])
|
||||
mockExchangeRateFindMany.mockResolvedValue([])
|
||||
mockSecurityFindMany.mockResolvedValue([])
|
||||
|
||||
const req = new Request('http://localhost/api/positions?accountId=acc-1')
|
||||
await GET(req)
|
||||
|
||||
expect(mockPositionFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { accountId: 'acc-1' },
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
127
__tests__/prisma-mock.ts
Normal file
127
__tests__/prisma-mock.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
export const mockPrisma = {
|
||||
user: {
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
account: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
security: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
transaction: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
position: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
exchangeRate: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn((callback) => callback(mockPrisma)),
|
||||
$connect: jest.fn(),
|
||||
$disconnect: jest.fn(),
|
||||
}
|
||||
|
||||
export const resetMockPrisma = () => {
|
||||
Object.values(mockPrisma).forEach((module) => {
|
||||
if (typeof module === 'object') {
|
||||
Object.values(module).forEach((fn) => {
|
||||
if (typeof fn === 'function') {
|
||||
;(fn as jest.Mock).mockReset()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const createMockPosition = (overrides: Partial<{
|
||||
id: string
|
||||
accountId: string
|
||||
symbol: string
|
||||
quantity: Prisma.Decimal
|
||||
averageCost: Prisma.Decimal
|
||||
currency: string
|
||||
}> = {}) => ({
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
averageCost: new Prisma.Decimal('150'),
|
||||
currency: 'USD',
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
export const createMockAccount = (overrides: Partial<{
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
marketType: 'US' | 'CN' | 'HK' | 'CRYPTO'
|
||||
baseCurrency: string
|
||||
balance: Prisma.Decimal
|
||||
}> = {}) => ({
|
||||
id: 'acc-1',
|
||||
userId: 'user-1',
|
||||
name: 'Test Account',
|
||||
marketType: 'US' as const,
|
||||
baseCurrency: 'USD',
|
||||
balance: new Prisma.Decimal('10000'),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
export const createMockTransaction = (overrides: Partial<{
|
||||
id: string
|
||||
accountId: string
|
||||
type: 'BUY' | 'SELL' | 'DEPOSIT' | 'WITHDRAW' | 'DIVIDEND'
|
||||
symbol: string | null
|
||||
quantity: Prisma.Decimal | null
|
||||
price: Prisma.Decimal | null
|
||||
amount: Prisma.Decimal
|
||||
fee: Prisma.Decimal
|
||||
currency: string
|
||||
}> = {}) => ({
|
||||
id: 'txn-1',
|
||||
accountId: 'acc-1',
|
||||
type: 'BUY' as const,
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
price: new Prisma.Decimal('150'),
|
||||
amount: new Prisma.Decimal('1500'),
|
||||
fee: new Prisma.Decimal('1'),
|
||||
networkFee: null,
|
||||
currency: 'USD',
|
||||
exchangeRate: null,
|
||||
notes: null,
|
||||
executedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
})
|
||||
1
__tests__/setup.ts
Normal file
1
__tests__/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
jest.setTimeout(10000)
|
||||
88
__tests__/tencent-quote.test.ts
Normal file
88
__tests__/tencent-quote.test.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { toTencentSymbol, parseTencentQuote, fetchPricesFromTencent } from '../src/lib/tencent-quote'
|
||||
|
||||
describe('腾讯行情接口符号转换', () => {
|
||||
describe('toTencentSymbol', () => {
|
||||
it('CN A股 - 上海以6开头', () => {
|
||||
expect(toTencentSymbol('600000', 'CN')).toBe('sh600000')
|
||||
})
|
||||
|
||||
it('CN A股 - 深圳以0/3开头', () => {
|
||||
expect(toTencentSymbol('000001', 'CN')).toBe('sz000001')
|
||||
})
|
||||
|
||||
it('CN A股 - 已带前缀', () => {
|
||||
expect(toTencentSymbol('sh600000', 'CN')).toBe('sh600000')
|
||||
})
|
||||
|
||||
it('HK 港股 - 纯数字', () => {
|
||||
expect(toTencentSymbol('09868', 'HK')).toBe('r_hk09868')
|
||||
})
|
||||
|
||||
it('HK 港股 - 已带hk前缀', () => {
|
||||
expect(toTencentSymbol('hk09868', 'HK')).toBe('r_hk09868')
|
||||
})
|
||||
|
||||
it('US 美股', () => {
|
||||
expect(toTencentSymbol('AAPL', 'US')).toBe('s_usAAPL')
|
||||
})
|
||||
|
||||
it('US 美股 - 已带前缀', () => {
|
||||
expect(toTencentSymbol('s_usAAPL', 'US')).toBe('s_usaapl')
|
||||
})
|
||||
|
||||
it('CRYPTO 加密货币', () => {
|
||||
expect(toTencentSymbol('btc', 'CRYPTO')).toBe('usdtbtc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTencentQuote', () => {
|
||||
it('正常解析腾讯行情数据', () => {
|
||||
const data = 'v_xxx="100~Apple Inc~AAPL~185.50~183.20~1000000~..."'
|
||||
const result = parseTencentQuote(data)
|
||||
expect(result?.price).toBe(185.5)
|
||||
expect(result?.change).toBeCloseTo(2.3, 10)
|
||||
expect(result?.changePercent).toBeCloseTo(1.2554, 3)
|
||||
})
|
||||
|
||||
it('格式错误返回null', () => {
|
||||
expect(parseTencentQuote('invalid')).toBeNull()
|
||||
})
|
||||
|
||||
it('字段不足返回null', () => {
|
||||
expect(parseTencentQuote('v_xxx="100~Name~"')).toBeNull()
|
||||
})
|
||||
|
||||
it('除以零防护 - 昨收价为0', () => {
|
||||
const data = 'v_xxx="100~Name~AAPL~185.50~0~..."'
|
||||
expect(parseTencentQuote(data)).toBeNull()
|
||||
})
|
||||
|
||||
it('价格非数字返回null', () => {
|
||||
const data = 'v_xxx="100~Name~AAPL~invalid~183.20~..."'
|
||||
expect(parseTencentQuote(data)).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchPricesFromTencent 批量获取', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('空数组返回空对象', async () => {
|
||||
const result = await fetchPricesFromTencent([])
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('网络错误返回空对象', async () => {
|
||||
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'))
|
||||
const result = await fetchPricesFromTencent([{ symbol: 'AAPL', marketType: 'US' }])
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('HTTP错误返回空对象', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 })
|
||||
const result = await fetchPricesFromTencent([{ symbol: 'AAPL', marketType: 'US' }])
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
416
__tests__/transactions.test.ts
Normal file
416
__tests__/transactions.test.ts
Normal file
@ -0,0 +1,416 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
const mockFindUnique = jest.fn()
|
||||
const mockCreate = jest.fn()
|
||||
const mockUpdate = jest.fn()
|
||||
const mockDelete = jest.fn()
|
||||
const mockAccountUpdate = jest.fn()
|
||||
const mockPositionFindUnique = jest.fn()
|
||||
const mockPositionUpdate = jest.fn()
|
||||
const mockPositionCreate = jest.fn()
|
||||
const mockPositionDelete = jest.fn()
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
transaction: {
|
||||
create: (...args: any[]) => mockCreate(...args),
|
||||
findUnique: (...args: any[]) => mockFindUnique(...args),
|
||||
update: (...args: any[]) => mockUpdate(...args),
|
||||
delete: (...args: any[]) => mockDelete(...args),
|
||||
},
|
||||
account: {
|
||||
update: (...args: any[]) => mockAccountUpdate(...args),
|
||||
},
|
||||
position: {
|
||||
findUnique: (...args: any[]) => mockPositionFindUnique(...args),
|
||||
update: (...args: any[]) => mockPositionUpdate(...args),
|
||||
create: (...args: any[]) => mockPositionCreate(...args),
|
||||
delete: (...args: any[]) => mockPositionDelete(...args),
|
||||
},
|
||||
$transaction: async (callback: (tx: any) => Promise<any>) => {
|
||||
const tx = {
|
||||
transaction: {
|
||||
create: mockCreate,
|
||||
findUnique: mockFindUnique,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
},
|
||||
account: { update: mockAccountUpdate },
|
||||
position: {
|
||||
findUnique: mockPositionFindUnique,
|
||||
update: mockPositionUpdate,
|
||||
create: mockPositionCreate,
|
||||
delete: mockPositionDelete,
|
||||
},
|
||||
}
|
||||
return callback(tx)
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { POST, DELETE } from '../src/app/api/transactions/route'
|
||||
|
||||
describe('Transactions API - POST (创建交易)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('DEPOSIT / WITHDRAW', () => {
|
||||
it('入金增加账户余额', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 'txn-1',
|
||||
type: 'DEPOSIT',
|
||||
amount: new Prisma.Decimal('5000'),
|
||||
accountId: 'acc-1',
|
||||
})
|
||||
mockAccountUpdate.mockResolvedValue({ id: 'acc-1', balance: new Prisma.Decimal('15000') })
|
||||
|
||||
const req = new Request('http://localhost/api/transactions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accountId: 'acc-1',
|
||||
type: 'DEPOSIT',
|
||||
amount: '5000',
|
||||
currency: 'USD',
|
||||
executedAt: '2024-01-15T10:00:00Z',
|
||||
}),
|
||||
})
|
||||
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(201)
|
||||
expect(mockAccountUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'acc-1' },
|
||||
data: { balance: { increment: new Prisma.Decimal('5000') } },
|
||||
})
|
||||
})
|
||||
|
||||
it('出金减少账户余额', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 'txn-1',
|
||||
type: 'WITHDRAW',
|
||||
amount: new Prisma.Decimal('3000'),
|
||||
accountId: 'acc-1',
|
||||
})
|
||||
mockAccountUpdate.mockResolvedValue({ id: 'acc-1', balance: new Prisma.Decimal('7000') })
|
||||
|
||||
const req = new Request('http://localhost/api/transactions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accountId: 'acc-1',
|
||||
type: 'WITHDRAW',
|
||||
amount: '3000',
|
||||
currency: 'USD',
|
||||
executedAt: '2024-01-15T10:00:00Z',
|
||||
}),
|
||||
})
|
||||
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(201)
|
||||
expect(mockAccountUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'acc-1' },
|
||||
data: { balance: { increment: new Prisma.Decimal('-3000') } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('BUY 买入持仓', () => {
|
||||
it('新建持仓 - 首次买入', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 'txn-1',
|
||||
type: 'BUY',
|
||||
symbol: 'BTC',
|
||||
quantity: new Prisma.Decimal('0.5'),
|
||||
price: new Prisma.Decimal('50000'),
|
||||
amount: new Prisma.Decimal('25000'),
|
||||
currency: 'USD',
|
||||
})
|
||||
mockPositionFindUnique.mockResolvedValue(null)
|
||||
mockPositionCreate.mockResolvedValue({
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'BTC',
|
||||
quantity: new Prisma.Decimal('0.5'),
|
||||
averageCost: new Prisma.Decimal('50000'),
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const req = new Request('http://localhost/api/transactions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accountId: 'acc-1',
|
||||
type: 'BUY',
|
||||
symbol: 'BTC',
|
||||
quantity: '0.5',
|
||||
price: '50000',
|
||||
amount: '25000',
|
||||
currency: 'USD',
|
||||
executedAt: '2024-01-15T10:00:00Z',
|
||||
}),
|
||||
})
|
||||
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(201)
|
||||
expect(mockPositionCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
accountId: 'acc-1',
|
||||
symbol: 'BTC',
|
||||
quantity: new Prisma.Decimal('0.5'),
|
||||
averageCost: new Prisma.Decimal('50000'),
|
||||
currency: 'USD',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('更新持仓 - 计算新平均成本', async () => {
|
||||
const existingPosition = {
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
averageCost: new Prisma.Decimal('150'),
|
||||
currency: 'USD',
|
||||
}
|
||||
mockPositionFindUnique.mockResolvedValue(existingPosition)
|
||||
mockPositionUpdate.mockResolvedValue({
|
||||
...existingPosition,
|
||||
quantity: new Prisma.Decimal('20'),
|
||||
averageCost: new Prisma.Decimal('155'),
|
||||
})
|
||||
mockCreate.mockResolvedValue({ id: 'txn-1', type: 'BUY' })
|
||||
|
||||
const req = new Request('http://localhost/api/transactions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accountId: 'acc-1',
|
||||
type: 'BUY',
|
||||
symbol: 'AAPL',
|
||||
quantity: '10',
|
||||
price: '160',
|
||||
amount: '1600',
|
||||
currency: 'USD',
|
||||
executedAt: '2024-01-15T10:00:00Z',
|
||||
}),
|
||||
})
|
||||
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(201)
|
||||
|
||||
const updateCall = mockPositionUpdate.mock.calls[0][0]
|
||||
const newQty = updateCall.data.quantity
|
||||
const newAvgCost = updateCall.data.averageCost
|
||||
expect(newQty.toString()).toBe('20')
|
||||
expect(newAvgCost.toString()).toBe('155')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SELL 卖出持仓', () => {
|
||||
it('卖出减少持仓数量', async () => {
|
||||
const existingPosition = {
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
averageCost: new Prisma.Decimal('150'),
|
||||
currency: 'USD',
|
||||
}
|
||||
mockPositionFindUnique.mockResolvedValue(existingPosition)
|
||||
mockPositionUpdate.mockResolvedValue({
|
||||
...existingPosition,
|
||||
quantity: new Prisma.Decimal('5'),
|
||||
})
|
||||
mockCreate.mockResolvedValue({ id: 'txn-1', type: 'SELL' })
|
||||
|
||||
const req = new Request('http://localhost/api/transactions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accountId: 'acc-1',
|
||||
type: 'SELL',
|
||||
symbol: 'AAPL',
|
||||
quantity: '5',
|
||||
price: '180',
|
||||
amount: '900',
|
||||
currency: 'USD',
|
||||
executedAt: '2024-01-15T10:00:00Z',
|
||||
}),
|
||||
})
|
||||
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(201)
|
||||
|
||||
const updateCall = mockPositionUpdate.mock.calls[0][0]
|
||||
expect(updateCall.data.quantity.toString()).toBe('5')
|
||||
})
|
||||
|
||||
it('卖出清空持仓 - 数量归零', async () => {
|
||||
const existingPosition = {
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
averageCost: new Prisma.Decimal('150'),
|
||||
currency: 'USD',
|
||||
}
|
||||
mockPositionFindUnique.mockResolvedValue(existingPosition)
|
||||
mockPositionUpdate.mockResolvedValue({
|
||||
...existingPosition,
|
||||
quantity: new Prisma.Decimal('0'),
|
||||
})
|
||||
mockCreate.mockResolvedValue({ id: 'txn-1', type: 'SELL' })
|
||||
|
||||
const req = new Request('http://localhost/api/transactions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accountId: 'acc-1',
|
||||
type: 'SELL',
|
||||
symbol: 'AAPL',
|
||||
quantity: '10',
|
||||
price: '180',
|
||||
amount: '1800',
|
||||
currency: 'USD',
|
||||
executedAt: '2024-01-15T10:00:00Z',
|
||||
}),
|
||||
})
|
||||
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(201)
|
||||
|
||||
const updateCall = mockPositionUpdate.mock.calls[0][0]
|
||||
expect(updateCall.data.quantity.toString()).toBe('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Transactions API - DELETE (删除交易)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('撤销BUY', () => {
|
||||
it('撤销买入 - 按比例还原平均成本', async () => {
|
||||
const existingPosition = {
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('20'),
|
||||
averageCost: new Prisma.Decimal('155'),
|
||||
currency: 'USD',
|
||||
}
|
||||
const buyTransaction = {
|
||||
id: 'txn-buy-1',
|
||||
accountId: 'acc-1',
|
||||
type: 'BUY' as const,
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
price: new Prisma.Decimal('160'),
|
||||
amount: new Prisma.Decimal('1600'),
|
||||
}
|
||||
|
||||
mockFindUnique.mockResolvedValue(buyTransaction)
|
||||
mockPositionFindUnique.mockResolvedValue(existingPosition)
|
||||
mockPositionUpdate.mockResolvedValue({
|
||||
...existingPosition,
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
averageCost: new Prisma.Decimal('150'),
|
||||
})
|
||||
mockDelete.mockResolvedValue({ id: 'txn-buy-1' })
|
||||
mockAccountUpdate.mockResolvedValue({})
|
||||
|
||||
const req = new Request('http://localhost/api/transactions?id=txn-buy-1', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const res = await DELETE(req)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const positionUpdateCall = mockPositionUpdate.mock.calls[0][0]
|
||||
expect(positionUpdateCall.data.quantity.toString()).toBe('10')
|
||||
})
|
||||
|
||||
it('撤销买入 - 全部撤销删除持仓', async () => {
|
||||
const existingPosition = {
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
averageCost: new Prisma.Decimal('160'),
|
||||
currency: 'USD',
|
||||
}
|
||||
const buyTransaction = {
|
||||
id: 'txn-buy-1',
|
||||
accountId: 'acc-1',
|
||||
type: 'BUY' as const,
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('10'),
|
||||
price: new Prisma.Decimal('160'),
|
||||
amount: new Prisma.Decimal('1600'),
|
||||
}
|
||||
|
||||
mockFindUnique.mockResolvedValue(buyTransaction)
|
||||
mockPositionFindUnique.mockResolvedValue(existingPosition)
|
||||
mockPositionUpdate.mockResolvedValue({
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('0'),
|
||||
averageCost: new Prisma.Decimal('160'),
|
||||
currency: 'USD',
|
||||
})
|
||||
mockDelete.mockResolvedValue({ id: 'txn-buy-1' })
|
||||
mockAccountUpdate.mockResolvedValue({})
|
||||
|
||||
const req = new Request('http://localhost/api/transactions?id=txn-buy-1', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const res = await DELETE(req)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('撤销SELL', () => {
|
||||
it('撤销卖出 - 恢复持仓数量', async () => {
|
||||
const existingPosition = {
|
||||
id: 'pos-1',
|
||||
accountId: 'acc-1',
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('15'),
|
||||
averageCost: new Prisma.Decimal('150'),
|
||||
currency: 'USD',
|
||||
}
|
||||
const sellTransaction = {
|
||||
id: 'txn-sell-1',
|
||||
accountId: 'acc-1',
|
||||
type: 'SELL' as const,
|
||||
symbol: 'AAPL',
|
||||
quantity: new Prisma.Decimal('5'),
|
||||
price: new Prisma.Decimal('180'),
|
||||
amount: new Prisma.Decimal('900'),
|
||||
}
|
||||
|
||||
mockFindUnique.mockResolvedValue(sellTransaction)
|
||||
mockPositionFindUnique.mockResolvedValue(existingPosition)
|
||||
mockPositionUpdate.mockResolvedValue({
|
||||
...existingPosition,
|
||||
quantity: new Prisma.Decimal('20'),
|
||||
})
|
||||
mockDelete.mockResolvedValue({ id: 'txn-sell-1' })
|
||||
mockAccountUpdate.mockResolvedValue({})
|
||||
|
||||
const req = new Request('http://localhost/api/transactions?id=txn-sell-1', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const res = await DELETE(req)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const positionUpdateCall = mockPositionUpdate.mock.calls[0][0]
|
||||
expect(positionUpdateCall.data.quantity.toString()).toBe('20')
|
||||
})
|
||||
})
|
||||
})
|
||||
21
jest.config.ts
Normal file
21
jest.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { Config } from 'jest'
|
||||
import nextJest from 'next/jest'
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
dir: './',
|
||||
})
|
||||
|
||||
const config: Config = {
|
||||
coverageProvider: 'v8',
|
||||
testEnvironment: 'node',
|
||||
setupFilesAfterEnv: ['<rootDir>/__tests__/setup.ts'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
|
||||
},
|
||||
}
|
||||
|
||||
export default createJestConfig(config)
|
||||
2927
package-lock.json
generated
2927
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "stock-portfolio",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
@ -26,14 +28,19 @@
|
||||
"yfinance": "^0.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^6.19.3",
|
||||
"tailwindcss": "^4",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"prisma": {
|
||||
|
||||
27
playwright.config.ts
Normal file
27
playwright.config.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './__tests__',
|
||||
testMatch: '**/e2e-*.test.ts',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
})
|
||||
@ -72,6 +72,7 @@ async function main() {
|
||||
{ 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: 'INTC', name: 'Intel Corp.', 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 },
|
||||
|
||||
@ -1,9 +1,19 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { getExchangeRates, DEFAULT_RATES } from '@/lib/exchange-rate'
|
||||
|
||||
// 腾讯行情接口
|
||||
const TENCENT_API_BASE = 'https://qt.gtimg.cn/q='
|
||||
|
||||
// 市场标签映射(用于环形图和UI展示)
|
||||
const MARKET_LABELS: Record<string, string> = {
|
||||
US: '美股',
|
||||
CN: 'A股',
|
||||
HK: '港股',
|
||||
CRYPTO: '加密',
|
||||
}
|
||||
|
||||
// 将证券代码转换为腾讯接口格式
|
||||
function toTencentSymbol(symbol: string, marketType: string): string {
|
||||
switch (marketType) {
|
||||
@ -37,30 +47,121 @@ function toTencentSymbol(symbol: string, marketType: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 解析腾讯行情数据
|
||||
// 格式: v_xxx="100~名称~代码~当前价~昨收价~..."
|
||||
function parseTencentQuote(data: string): { price: number; change: number; changePercent: number } | null {
|
||||
// 腾讯行情数据结构
|
||||
interface TencentQuote {
|
||||
name: string // 股票名称 (index 1)
|
||||
price: number // 当前价 (index 3)
|
||||
previousClose: number // 昨收价 (index 4)
|
||||
change: number // 涨跌额
|
||||
changePercent: number // 涨跌幅 %
|
||||
}
|
||||
|
||||
// 市场类型对应的索引规则
|
||||
interface MarketIndexRules {
|
||||
changeIndex: number // 涨跌额字段索引
|
||||
changePercentIndex: number // 涨跌幅字段索引
|
||||
}
|
||||
|
||||
// 各市场索引规则映射
|
||||
const MARKET_INDEX_RULES: Record<string, MarketIndexRules> = {
|
||||
// 美股: 索引4=涨跌额, 索引5=涨跌幅
|
||||
s_us: { changeIndex: 4, changePercentIndex: 5 },
|
||||
// 港股: 索引31=涨跌额, 索引32=涨跌幅
|
||||
r_hk: { changeIndex: 31, changePercentIndex: 32 },
|
||||
// A股(上海): 索引31=涨跌额, 索引32=涨跌幅
|
||||
sh: { changeIndex: 31, changePercentIndex: 32 },
|
||||
// A股(深圳): 索引31=涨跌额, 索引32=涨跌幅
|
||||
sz: { changeIndex: 31, changePercentIndex: 32 },
|
||||
}
|
||||
|
||||
// 从腾讯返回的数据前缀中检测市场类型
|
||||
function detectMarketType(data: string): string | null {
|
||||
// 匹配 v_xxx= 格式,获取 xxx 部分
|
||||
const match = data.match(/^v_([^=]+)=/)
|
||||
if (!match) return null
|
||||
|
||||
const prefix = match[1].toLowerCase()
|
||||
|
||||
// 直接匹配
|
||||
if (MARKET_INDEX_RULES[prefix]) {
|
||||
return prefix
|
||||
}
|
||||
|
||||
// 尝试匹配前缀
|
||||
if (prefix.startsWith('s_us')) return 's_us'
|
||||
if (prefix.startsWith('r_hk')) return 'r_hk'
|
||||
if (prefix.startsWith('sh')) return 'sh'
|
||||
if (prefix.startsWith('sz')) return 'sz'
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 安全获取数组元素,带防越界和空值处理
|
||||
function safeGetField(fields: string[], index: number, fallback: string = ''): string {
|
||||
const value = fields[index]
|
||||
return (value !== undefined && value !== '') ? value : fallback
|
||||
}
|
||||
|
||||
// 解析腾讯行情数据(支持多市场差异化解析)
|
||||
// 美股: v_s_usGOOG="200~谷歌-C~GOOG.OQ~315.72~-0.65~-0.21~..."
|
||||
// 港股: v_r_hk09868="100~小鹏集团-W~09868~67.600~...~2026/04/13 16:08:56~0.600~0.90~..."
|
||||
// A股: v_sh600690="1~海尔智家~600690~...~20260413161426~-0.14~-0.67~..."
|
||||
function parseTencentQuote(data: string): TencentQuote | null {
|
||||
try {
|
||||
// 匹配引号内的数据: v_xxx="...."
|
||||
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) {
|
||||
if (!match) {
|
||||
console.warn('Tencent quote: no data match in response')
|
||||
return null
|
||||
}
|
||||
|
||||
const change = currentPrice - previousClose
|
||||
const changePercent = (change / previousClose) * 100
|
||||
const rawData = match[1]
|
||||
if (!rawData || rawData.length === 0) {
|
||||
console.warn('Tencent quote: empty data')
|
||||
return null
|
||||
}
|
||||
|
||||
const fields = rawData.split('~')
|
||||
|
||||
// 检测市场类型
|
||||
const marketType = detectMarketType(data)
|
||||
if (!marketType) {
|
||||
console.warn('Tencent quote: unknown market type, using default indices')
|
||||
}
|
||||
|
||||
// 获取对应市场的索引规则
|
||||
const rules = MARKET_INDEX_RULES[marketType || ''] || { changeIndex: 4, changePercentIndex: 5 }
|
||||
|
||||
// 验证最小字段数(美股至少6个字段,港A股至少33个字段)
|
||||
const minFields = Math.max(rules.changePercentIndex + 1, 6)
|
||||
if (fields.length < minFields) {
|
||||
console.warn(`Tencent quote: insufficient fields (${fields.length}), expected at least ${minFields} for ${marketType}`)
|
||||
// 尝试降级处理
|
||||
}
|
||||
|
||||
// 提取字段
|
||||
const name = safeGetField(fields, 1, '')
|
||||
const priceStr = safeGetField(fields, 3, '0')
|
||||
const changeStr = safeGetField(fields, rules.changeIndex, '0')
|
||||
const changePercentStr = safeGetField(fields, rules.changePercentIndex, '0')
|
||||
|
||||
// 解析数值
|
||||
const price = parseFloat(priceStr)
|
||||
const change = parseFloat(changeStr)
|
||||
const changePercent = parseFloat(changePercentStr)
|
||||
|
||||
// 验证价格数据
|
||||
if (isNaN(price) || price === 0) {
|
||||
console.warn('Tencent quote: invalid price value')
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
price: currentPrice,
|
||||
change,
|
||||
changePercent,
|
||||
name,
|
||||
price,
|
||||
previousClose: 0, // 不再需要昨收价,因为直接使用接口返回的涨跌数据
|
||||
change: isNaN(change) ? 0 : change,
|
||||
changePercent: isNaN(changePercent) ? 0 : changePercent,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse Tencent quote:', error)
|
||||
@ -68,8 +169,8 @@ function parseTencentQuote(data: string): { price: number; change: number; chang
|
||||
}
|
||||
}
|
||||
|
||||
// 批量获取价格(腾讯接口支持批量查询,用逗号分隔)
|
||||
async function fetchPrices(symbols: { symbol: string; marketType: string }[]): Promise<Record<string, { price: number; change: number; changePercent: number }>> {
|
||||
// 批量获取行情(腾讯接口支持批量查询,用逗号分隔)
|
||||
async function fetchQuotes(symbols: { symbol: string; marketType: string }[]): Promise<Record<string, TencentQuote>> {
|
||||
if (symbols.length === 0) return {}
|
||||
|
||||
try {
|
||||
@ -80,13 +181,21 @@ async function fetchPrices(symbols: { symbol: string; marketType: string }[]): P
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
|
||||
if (!response.ok) return {}
|
||||
if (!response.ok) {
|
||||
console.error(`Tencent API responded with status: ${response.status}`)
|
||||
return {}
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
const results: Record<string, { price: number; change: number; changePercent: number }> = {}
|
||||
// 腾讯API返回GBK编码,必须使用arrayBuffer + TextDecoder('gbk')解码
|
||||
// 否则中文股票名称会出现乱码
|
||||
const buffer = await response.arrayBuffer()
|
||||
const text = new TextDecoder('gbk').decode(buffer)
|
||||
|
||||
const results: Record<string, TencentQuote> = {}
|
||||
|
||||
// 腾讯返回多行,每行一个股票
|
||||
const lines = text.split('\n')
|
||||
const lines = text.split('\n').filter(line => line.trim().length > 0)
|
||||
|
||||
symbols.forEach((s, index) => {
|
||||
if (lines[index]) {
|
||||
const quote = parseTencentQuote(lines[index])
|
||||
@ -98,7 +207,7 @@ async function fetchPrices(symbols: { symbol: string; marketType: string }[]): P
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch prices from Tencent:', error)
|
||||
console.error('Failed to fetch quotes from Tencent:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@ -125,58 +234,81 @@ export async function GET() {
|
||||
})
|
||||
const securityMap = new Map(securities.map((s) => [s.symbol, s]))
|
||||
|
||||
// 3. 获取最新汇率
|
||||
const latestRates = await prisma.exchangeRate.findMany({
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
})
|
||||
const rateMap = new Map(
|
||||
latestRates.map((r) => [`${r.fromCurrency}_${r.toCurrency}`, Number(r.rate)])
|
||||
// 3. 获取实时汇率(通过 JisuAPI,带缓存和降级机制)
|
||||
const uniqueCurrencies = [...new Set(positions.map(p => p.currency))]
|
||||
const currencyPairs = uniqueCurrencies.flatMap(from =>
|
||||
from !== 'USD' ? [{ from, to: 'USD' as const }] : []
|
||||
)
|
||||
const dynamicRates = await getExchangeRates(currencyPairs)
|
||||
|
||||
// 4. 获取实时价格(使用腾讯行情接口)
|
||||
// 构建 rateMap: currency_USD => rate
|
||||
const rateMap = new Map<string, number>()
|
||||
rateMap.set('USD_USD', 1) // USD 本身为 1
|
||||
uniqueCurrencies.forEach(currency => {
|
||||
if (currency === 'USD') {
|
||||
rateMap.set('USD_USD', 1)
|
||||
} else {
|
||||
rateMap.set(`${currency}_USD`, dynamicRates[`${currency}_USD`] || DEFAULT_RATES[currency] || 1)
|
||||
}
|
||||
})
|
||||
|
||||
// 4. 获取实时行情(使用腾讯行情接口)
|
||||
const symbolsWithMarket = positions.map((p) => ({
|
||||
symbol: p.symbol,
|
||||
marketType: p.account.marketType,
|
||||
}))
|
||||
|
||||
const priceResults = await fetchPrices(symbolsWithMarket)
|
||||
const quoteResults = await fetchQuotes(symbolsWithMarket)
|
||||
|
||||
// 5. 计算完整的持仓分析
|
||||
// 5. 计算完整的持仓分析(使用 Decimal 确保金融计算精度)
|
||||
const positionAnalytics = positions.map((pos) => {
|
||||
const security = securityMap.get(pos.symbol)
|
||||
const quote = priceResults[pos.symbol]
|
||||
const currentPrice = quote?.price || Number(pos.averageCost)
|
||||
const qty = Number(pos.quantity)
|
||||
const avgCost = Number(pos.averageCost)
|
||||
const costBasis = qty * avgCost
|
||||
const marketValue = qty * currentPrice
|
||||
const pnl = marketValue - costBasis
|
||||
const pnlPercent = costBasis > 0 ? (pnl / costBasis) * 100 : 0
|
||||
const quote = quoteResults[pos.symbol]
|
||||
|
||||
const rate = rateMap.get(`${pos.currency}_USD`) || 1
|
||||
const costBasisUSD = costBasis * rate
|
||||
const marketValueUSD = marketValue * rate
|
||||
const pnlUSD = pnl * rate
|
||||
// 名称优先级: Security表名称 > 腾讯行情名称 > 证券代码
|
||||
const securityName = security?.name || quote?.name || pos.symbol
|
||||
|
||||
// 使用 Decimal 进行精确计算
|
||||
const qty = new Prisma.Decimal(pos.quantity.toString())
|
||||
const avgCost = new Prisma.Decimal(pos.averageCost.toString())
|
||||
// 当 API 获取失败时,使用 previousClose 而非 avgCost 避免 0% 虚假显示
|
||||
const currentPrice = quote?.price
|
||||
? new Prisma.Decimal(quote.price.toString())
|
||||
: (quote?.previousClose ? new Prisma.Decimal(quote.previousClose.toString()) : avgCost)
|
||||
|
||||
const costBasis = qty.times(avgCost)
|
||||
const marketValue = qty.times(currentPrice)
|
||||
const pnl = marketValue.minus(costBasis)
|
||||
const pnlPercent = costBasis.isZero() ? new Prisma.Decimal(0) : pnl.div(costBasis).times(100)
|
||||
|
||||
// 标记价格数据是否来自实时行情(而非降级 fallback)
|
||||
const priceAvailable = !!(quote?.price || quote?.previousClose)
|
||||
|
||||
const rate = new Prisma.Decimal(rateMap.get(`${pos.currency}_USD`)?.toString() || '1')
|
||||
const costBasisUSD = costBasis.times(rate)
|
||||
const marketValueUSD = marketValue.times(rate)
|
||||
const pnlUSD = pnl.times(rate)
|
||||
|
||||
return {
|
||||
symbol: pos.symbol,
|
||||
name: security?.name || pos.symbol,
|
||||
name: securityName,
|
||||
marketType: pos.account.marketType,
|
||||
accountName: pos.account.name,
|
||||
quantity: qty,
|
||||
avgCost,
|
||||
currentPrice,
|
||||
quantity: Number(qty),
|
||||
avgCost: Number(avgCost),
|
||||
currentPrice: Number(currentPrice),
|
||||
change: quote?.change || 0,
|
||||
changePercent: quote?.changePercent || 0,
|
||||
costBasis,
|
||||
costBasisUSD,
|
||||
marketValue,
|
||||
marketValueUSD,
|
||||
pnl,
|
||||
pnlPercent,
|
||||
pnlUSD,
|
||||
costBasis: Number(costBasis),
|
||||
costBasisUSD: Number(costBasisUSD),
|
||||
marketValue: Number(marketValue),
|
||||
marketValueUSD: Number(marketValueUSD),
|
||||
pnl: Number(pnl),
|
||||
pnlPercent: Number(pnlPercent),
|
||||
pnlUSD: Number(pnlUSD),
|
||||
currency: pos.currency,
|
||||
isCrypto: security?.isCrypto || false,
|
||||
priceAvailable,
|
||||
}
|
||||
})
|
||||
|
||||
@ -197,8 +329,15 @@ export async function GET() {
|
||||
return acc
|
||||
}, {} as Record<string, { totalCost: number; totalValue: number; totalPnL: number }>)
|
||||
|
||||
// 8. 环形图数据(按市场聚合市值,用于资产配置图)
|
||||
const marketDistribution = Object.entries(byMarket).map(([market, data]) => ({
|
||||
name: MARKET_LABELS[market as keyof typeof MARKET_LABELS] || market,
|
||||
value: Math.round(data.totalValue * 100) / 100, // 保留2位小数
|
||||
percent: totalMarketValue > 0 ? Math.round((data.totalValue / totalMarketValue) * 10000) / 100 : 0, // 百分比
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
prices: priceResults,
|
||||
prices: quoteResults,
|
||||
positions: positionAnalytics,
|
||||
summary: {
|
||||
totalCostBasis,
|
||||
@ -208,6 +347,7 @@ export async function GET() {
|
||||
positionCount: positions.length,
|
||||
},
|
||||
byMarket,
|
||||
marketDistribution,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Analytics error:', error)
|
||||
|
||||
35
src/app/api/exchange/route.ts
Normal file
35
src/app/api/exchange/route.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getAllRatesToUSD } from '@/lib/exchange-rate'
|
||||
|
||||
// 默认汇率(与 exchange-rate.ts 保持一致)
|
||||
const DEFAULT_RATES: Record<string, number> = {
|
||||
USD: 1,
|
||||
CNY: 0.137,
|
||||
HKD: 0.129,
|
||||
USDT: 1,
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 使用统一服务获取汇率(带缓存和降级机制)
|
||||
const rates = await getAllRatesToUSD()
|
||||
|
||||
return NextResponse.json({
|
||||
rates: {
|
||||
USD: rates['USD_USD'] ?? 1,
|
||||
CNY: rates['CNY_USD'] ? 1 / rates['CNY_USD'] : DEFAULT_RATES.CNY,
|
||||
HKD: rates['HKD_USD'] ? 1 / rates['HKD_USD'] : DEFAULT_RATES.HKD,
|
||||
USDT: 1,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[/api/exchange] Failed to fetch exchange rates:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch exchange rates',
|
||||
rates: DEFAULT_RATES,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
20
src/app/api/securities/sync/route.ts
Normal file
20
src/app/api/securities/sync/route.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { syncMissingSecurityNames, discoverAndUpsertFromTransactions } from '@/lib/security-sync'
|
||||
|
||||
// 同步缺失的证券名称
|
||||
export async function POST() {
|
||||
try {
|
||||
const count = await syncMissingSecurityNames()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
synced: count,
|
||||
message: `已同步 ${count} 个证券名称`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Security sync error:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Failed to sync securities'
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { upsertSecurityFromTencent } from '@/lib/security-sync'
|
||||
|
||||
// 获取交易流水
|
||||
export async function GET(request: Request) {
|
||||
@ -12,7 +13,7 @@ export async function GET(request: Request) {
|
||||
|
||||
const where = accountId ? { accountId } : {}
|
||||
|
||||
const [transactions, total] = await Promise.all([
|
||||
const [transactions, total, securities] = await Promise.all([
|
||||
prisma.transaction.findMany({
|
||||
where,
|
||||
include: { account: { select: { name: true, marketType: true } } },
|
||||
@ -21,10 +22,20 @@ export async function GET(request: Request) {
|
||||
take: limit,
|
||||
}),
|
||||
prisma.transaction.count({ where }),
|
||||
prisma.security.findMany(),
|
||||
])
|
||||
|
||||
// 构建证券代码到名称的映射
|
||||
const securityMap = new Map(securities.map(s => [s.symbol, s.name]))
|
||||
|
||||
// 附加证券名称到每条交易记录
|
||||
const transactionsWithNames = transactions.map(tx => ({
|
||||
...tx,
|
||||
securityName: tx.symbol ? (securityMap.get(tx.symbol) || null) : null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
data: transactions,
|
||||
data: transactionsWithNames,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
})
|
||||
} catch (error) {
|
||||
@ -115,11 +126,22 @@ export async function POST(request: Request) {
|
||||
} else if (type === 'SELL' && quantity && price) {
|
||||
// 卖出:减少持仓(平均成本法)
|
||||
if (position) {
|
||||
// 校验:卖出数量不能超过持仓数量
|
||||
if (new Prisma.Decimal(quantity).greaterThan(position.quantity)) {
|
||||
throw new Error(`卖出数量(${quantity})超过持仓数量(${position.quantity})`)
|
||||
}
|
||||
const newQty = position.quantity.minus(new Prisma.Decimal(quantity))
|
||||
await tx.position.update({
|
||||
where: { accountId_symbol: { accountId, symbol } },
|
||||
data: { quantity: newQty },
|
||||
})
|
||||
// 如果卖出后持仓为0,删除持仓记录
|
||||
if (newQty.isZero() || newQty.isNegative()) {
|
||||
await tx.position.delete({
|
||||
where: { accountId_symbol: { accountId, symbol } },
|
||||
})
|
||||
} else {
|
||||
await tx.position.update({
|
||||
where: { accountId_symbol: { accountId, symbol } },
|
||||
data: { quantity: newQty },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// DIVIDEND:目前只记录流水,持仓不变
|
||||
@ -128,6 +150,13 @@ export async function POST(request: Request) {
|
||||
return transaction
|
||||
})
|
||||
|
||||
// 异步补全证券名称(不影响主流程)
|
||||
if (symbol) {
|
||||
upsertSecurityFromTencent(symbol).catch(err =>
|
||||
console.error('Background security sync failed:', err)
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(result, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Transaction error:', error)
|
||||
@ -208,18 +237,22 @@ export async function DELETE(request: Request) {
|
||||
|
||||
if (position) {
|
||||
if (transaction.type === 'BUY') {
|
||||
// 撤销买入:减少持仓数量
|
||||
const newQty = position.quantity.minus(new Prisma.Decimal(transaction.quantity))
|
||||
if (newQty.lte(0)) {
|
||||
// 撤销买入:还原到买入前的成本基数
|
||||
const undoQty = new Prisma.Decimal(transaction.quantity)
|
||||
const undoPrice = new Prisma.Decimal(transaction.price)
|
||||
const undoAmount = undoQty.times(undoPrice) // 此次买入的总成本
|
||||
const newQty = position.quantity.minus(undoQty)
|
||||
|
||||
if (newQty.isZero() || newQty.isNegative()) {
|
||||
// 持仓全部撤销,删除记录
|
||||
await tx.position.delete({
|
||||
where: { accountId_symbol: { accountId: transaction.accountId, symbol: transaction.symbol } },
|
||||
})
|
||||
} else {
|
||||
// 按比例撤销平均成本
|
||||
const undoCost = new Prisma.Decimal(transaction.quantity).times(new Prisma.Decimal(transaction.price))
|
||||
const remainingCost = position.averageCost.times(position.quantity).minus(undoCost)
|
||||
const newAvgCost = remainingCost.div(newQty)
|
||||
// 还原到买入前的成本基数,再计算新的平均成本
|
||||
// 买入前的总成本 = 买入后的总成本 - 此次买入的成本
|
||||
const costBeforeBuy = position.averageCost.times(position.quantity).minus(undoAmount)
|
||||
const newAvgCost = costBeforeBuy.div(newQty)
|
||||
await tx.position.update({
|
||||
where: { accountId_symbol: { accountId: transaction.accountId, symbol: transaction.symbol } },
|
||||
data: {
|
||||
@ -229,7 +262,7 @@ export async function DELETE(request: Request) {
|
||||
})
|
||||
}
|
||||
} else if (transaction.type === 'SELL') {
|
||||
// 撤销卖出:恢复持仓数量
|
||||
// 撤销卖出:恢复持仓数量(平均成本不变)
|
||||
const newQty = position.quantity.plus(new Prisma.Decimal(transaction.quantity))
|
||||
await tx.position.update({
|
||||
where: { accountId_symbol: { accountId: transaction.accountId, symbol: transaction.symbol } },
|
||||
|
||||
214
src/app/page.tsx
214
src/app/page.tsx
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@ -47,6 +47,14 @@ const marketColors: Record<MarketType, string> = {
|
||||
CRYPTO: '#eab308',
|
||||
}
|
||||
|
||||
// 市场名称到颜色映射(用于环形图)
|
||||
const MARKET_COLOR_MAP: Record<string, string> = {
|
||||
'美股': '#3b82f6',
|
||||
'A股': '#ef4444',
|
||||
'港股': '#f97316',
|
||||
'加密': '#eab308',
|
||||
}
|
||||
|
||||
// 持仓分析数据结构
|
||||
interface PositionAnalytics {
|
||||
symbol: string
|
||||
@ -66,6 +74,7 @@ interface PositionAnalytics {
|
||||
pnlUSD: number
|
||||
currency: string
|
||||
isCrypto: boolean
|
||||
priceAvailable?: boolean
|
||||
}
|
||||
|
||||
// 分析汇总数据结构
|
||||
@ -77,6 +86,14 @@ interface AnalyticsSummary {
|
||||
positionCount: number
|
||||
}
|
||||
|
||||
// 市场分布数据(环形图)
|
||||
interface MarketDistribution {
|
||||
name: string
|
||||
value: number
|
||||
percent: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
// 货币转换函数(以 USD 为基准)
|
||||
const EXCHANGE_RATES: Record<string, number> = {
|
||||
USD: 1,
|
||||
@ -86,9 +103,9 @@ const EXCHANGE_RATES: Record<string, number> = {
|
||||
|
||||
function convertCurrency(amount: number, from: string, to: string): number {
|
||||
if (from === to) return amount
|
||||
// 先转换为 USD,再转换为目标货币
|
||||
const usdAmount = amount / EXCHANGE_RATES[from]
|
||||
return usdAmount * EXCHANGE_RATES[to]
|
||||
// EXCHANGE_RATES 格式为 "1 USD = X 目标货币"
|
||||
// 转换公式: amount * (目标汇率 / 源汇率)
|
||||
return amount * (EXCHANGE_RATES[to] / EXCHANGE_RATES[from])
|
||||
}
|
||||
|
||||
// 主页面组件
|
||||
@ -98,7 +115,7 @@ export default function Dashboard() {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [positions, setPositions] = useState<Position[]>([])
|
||||
const [securities, setSecurities] = useState<Security[]>([])
|
||||
const [analytics, setAnalytics] = useState<{ prices: Record<string, any>; positions: PositionAnalytics[]; summary: AnalyticsSummary } | null>(null)
|
||||
const [analytics, setAnalytics] = useState<{ prices: Record<string, any>; positions: PositionAnalytics[]; summary: AnalyticsSummary; marketDistribution?: MarketDistribution[] } | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>('')
|
||||
const [showTxDialog, setShowTxDialog] = useState(false)
|
||||
@ -114,6 +131,9 @@ export default function Dashboard() {
|
||||
// 显示货币状态(默认 CNY)
|
||||
const [displayCurrency, setDisplayCurrency] = useState<'CNY' | 'USD' | 'HKD'>('CNY')
|
||||
|
||||
// 汇率状态
|
||||
const [exchangeRates, setExchangeRates] = useState(EXCHANGE_RATES)
|
||||
|
||||
// 交易记录删除状态
|
||||
const [transactionToDelete, setTransactionToDelete] = useState<Transaction | null>(null)
|
||||
const [showDeleteTxDialog, setShowDeleteTxDialog] = useState(false)
|
||||
@ -183,7 +203,21 @@ export default function Dashboard() {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
// 搜索证券(根据输入实时过滤)
|
||||
// 加载汇率数据(通过后端代理,避免前端跨域)
|
||||
useEffect(() => {
|
||||
const loadExchangeRates = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/exchange')
|
||||
const data = await response.json()
|
||||
if (data.rates) {
|
||||
setExchangeRates(data.rates)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load exchange rates:', error)
|
||||
}
|
||||
}
|
||||
loadExchangeRates()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (symbolSearch.length >= 1) {
|
||||
const filtered = securities.filter(s =>
|
||||
@ -495,13 +529,21 @@ export default function Dashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
// 市场分布数据(用于饼图)
|
||||
const marketDistribution = analytics?.summary ? [
|
||||
{ name: '美股', value: analytics.summary.totalMarketValue * 0.6, color: marketColors.US },
|
||||
{ name: 'A股', value: analytics.summary.totalMarketValue * 0.2, color: marketColors.CN },
|
||||
{ name: '港股', value: analytics.summary.totalMarketValue * 0.15, color: marketColors.HK },
|
||||
{ name: '加密', value: analytics.summary.totalMarketValue * 0.05, color: marketColors.CRYPTO },
|
||||
].filter(item => item.value > 0) : []
|
||||
// 市场分布数据(用于饼图)- 来自后端实时聚合,根据显示货币实时转换
|
||||
const marketDistribution = useMemo(() => {
|
||||
if (!analytics?.marketDistribution?.length) return []
|
||||
|
||||
return analytics.marketDistribution.map((item: { name: string; value: number; percent: number }) => {
|
||||
const convertedValue = displayCurrency === 'USD'
|
||||
? item.value
|
||||
: convertCurrency(item.value, 'USD', displayCurrency)
|
||||
return {
|
||||
...item,
|
||||
value: convertedValue,
|
||||
color: MARKET_COLOR_MAP[item.name] || '#888888',
|
||||
}
|
||||
})
|
||||
}, [analytics?.marketDistribution, displayCurrency])
|
||||
|
||||
// 加载中状态
|
||||
if (loading) {
|
||||
@ -522,6 +564,15 @@ export default function Dashboard() {
|
||||
<h1 className="text-xl font-bold">投资持仓管理</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 汇率显示 */}
|
||||
<div className="hidden md:flex items-center gap-1.5 mr-2 px-2 py-1 bg-muted/50 rounded-md text-xs">
|
||||
<span className="text-muted-foreground">汇率:</span>
|
||||
<span className="font-mono">USD <span className="text-green-500">1</span></span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="font-mono">CNY <span className="text-orange-500">{exchangeRates.CNY?.toFixed(2)}</span></span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="font-mono">HKD <span className="text-blue-500">{exchangeRates.HKD?.toFixed(2)}</span></span>
|
||||
</div>
|
||||
{/* 显示货币选择 */}
|
||||
<Select value={displayCurrency} onValueChange={(v) => setDisplayCurrency(v as 'CNY' | 'USD' | 'HKD')}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
@ -641,31 +692,80 @@ export default function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{marketDistribution.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={marketDistribution}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{marketDistribution.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)' }}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-col">
|
||||
{/* 图表区域 - 需要明确高度防止 Recharts 坍塌 */}
|
||||
<div className="w-full h-[280px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={marketDistribution}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{marketDistribution.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.color}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload
|
||||
return (
|
||||
<div className="bg-black/70 backdrop-blur-md border border-white/20 rounded-xl px-4 py-3 shadow-xl">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
<span className="font-medium text-white">{data.name}</span>
|
||||
</div>
|
||||
<div className="text-white/90 space-y-1">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-white/60">市值</span>
|
||||
<span className="font-mono font-medium">
|
||||
{formatCurrency(data.value, displayCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-white/60">占比</span>
|
||||
<span className="font-mono">{data.percent?.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 自定义图例 - 解决对齐问题 */}
|
||||
<div className="flex flex-wrap justify-center gap-4 mt-2 px-2">
|
||||
{marketDistribution.map((item) => (
|
||||
<div key={item.name} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-xs text-foreground/80">{item.name}</span>
|
||||
<span className="text-xs text-muted-foreground">({item.percent?.toFixed(1)}%)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[240px] flex items-center justify-center text-muted-foreground">
|
||||
暂无数据
|
||||
<div className="h-[220px] flex items-center justify-center text-muted-foreground">
|
||||
暂无持仓数据
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@ -683,13 +783,13 @@ export default function Dashboard() {
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{marketIcons[pos.marketType as MarketType]}
|
||||
<span className="font-medium">{pos.symbol}</span>
|
||||
<span className="text-xs text-muted-foreground">{pos.currency}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{pos.name}</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold">{formatCurrency(pos.marketValue, pos.currency)}</div>
|
||||
<Separator className="my-2" />
|
||||
<div className={`text-sm flex items-center gap-1 ${pos.pnl >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{formatCurrency(Math.abs(pos.pnl), pos.currency)} ({formatPercent(pos.pnlPercent)})
|
||||
{formatCurrency(Math.abs(pos.pnl), pos.currency)} ({pos.priceAvailable !== false ? formatPercent(pos.pnlPercent) : 'N/A'})
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -743,12 +843,15 @@ export default function Dashboard() {
|
||||
{formatCurrency(pos.avgCost, pos.currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{formatCurrency(pos.currentPrice, pos.currency)}
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{formatCurrency(pos.currentPrice, pos.currency)}
|
||||
</div>
|
||||
{pos.change !== 0 && (
|
||||
<span className={`text-xs ${pos.change >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{pos.change >= 0 ? '+' : ''}{pos.changePercent.toFixed(2)}%
|
||||
</span>
|
||||
<div className={`text-xs flex items-center gap-1 ${pos.change >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
<span>{pos.change >= 0 ? '+' : ''}{formatCurrency(pos.change, pos.currency)}</span>
|
||||
<span>({pos.change >= 0 ? '+' : ''}{pos.changePercent.toFixed(2)}%)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
@ -759,7 +862,7 @@ export default function Dashboard() {
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{formatCurrency(Math.abs(pos.pnl))}
|
||||
<span className="text-xs">({formatPercent(pos.pnlPercent)})</span>
|
||||
<span className="text-xs">({pos.priceAvailable !== false ? formatPercent(pos.pnlPercent) : 'N/A'})</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@ -822,7 +925,16 @@ export default function Dashboard() {
|
||||
{transactionTypeLabels[tx.type]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">{tx.symbol || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{tx.securityName ? (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{tx.securityName}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{tx.symbol}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-mono">{tx.symbol || '-'}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{tx.quantity ? parseFloat(tx.quantity).toFixed(4) : '-'}
|
||||
</TableCell>
|
||||
@ -882,7 +994,7 @@ export default function Dashboard() {
|
||||
<div className="flex items-center gap-2">
|
||||
{marketIcons[pos.marketType as MarketType]}
|
||||
<span className="font-medium">{pos.symbol}</span>
|
||||
<span className="text-xs text-muted-foreground">{pos.currency}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{pos.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{percent.toFixed(1)}%</span>
|
||||
@ -918,9 +1030,9 @@ export default function Dashboard() {
|
||||
<div key={pos.symbol} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-bold text-muted-foreground">#{index + 1}</span>
|
||||
<div>
|
||||
<div className="font-medium">{pos.symbol}</div>
|
||||
<div className="text-xs text-muted-foreground">{pos.name}</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{pos.symbol}</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[120px]">{pos.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-right font-mono ${pos.pnl >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
@ -928,7 +1040,7 @@ export default function Dashboard() {
|
||||
{pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{formatCurrency(Math.abs(pos.pnl), pos.currency)}
|
||||
</div>
|
||||
<div className="text-xs">{formatPercent(pos.pnlPercent)}</div>
|
||||
<div className="text-xs">{pos.priceAvailable !== false ? formatPercent(pos.pnlPercent) : 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
174
src/lib/exchange-rate.ts
Normal file
174
src/lib/exchange-rate.ts
Normal file
@ -0,0 +1,174 @@
|
||||
// JisuAPI 实时汇率服务(服务端专用)
|
||||
// 文档: https://www.jisuapi.com/api/exchange/
|
||||
|
||||
const JISU_API_BASE = 'https://api.jisuapi.com/exchange/convert'
|
||||
|
||||
// 汇率缓存接口
|
||||
interface CachedRate {
|
||||
rate: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// 内存缓存(1小时 = 3600秒)
|
||||
const rateCache: Record<string, CachedRate> = {}
|
||||
const CACHE_DURATION = 3600 * 1000
|
||||
|
||||
// 默认固定汇率(仅用于 JisuAPI 完全失败时的最后降级)
|
||||
export const DEFAULT_RATES: Record<string, number> = {
|
||||
USD: 1,
|
||||
CNY: 0.137,
|
||||
HKD: 0.129,
|
||||
USDT: 1,
|
||||
}
|
||||
|
||||
// 货币代码映射(JisuAPI 使用标准 ISO 4217)
|
||||
const CURRENCY_MAP: Record<string, string> = {
|
||||
USD: 'USD',
|
||||
CNY: 'CNY',
|
||||
HKD: 'HKD',
|
||||
USDT: 'USD',
|
||||
GBP: 'GBP',
|
||||
EUR: 'EUR',
|
||||
JPY: 'JPY',
|
||||
}
|
||||
|
||||
// JisuAPI 响应结构(官方格式)
|
||||
interface JisuAPIResponse {
|
||||
status: number
|
||||
msg: string
|
||||
result?: {
|
||||
from: string
|
||||
to: string
|
||||
fromname: string
|
||||
toname: string
|
||||
updatetime: string
|
||||
rate: string // 汇率字符串,如 "0.146357"
|
||||
res: string
|
||||
camount: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JisuAPI 获取单个汇率
|
||||
* JisuAPI 返回格式: { status: 0, msg: "ok", result: { rate: "0.146357", ... } }
|
||||
*/
|
||||
async function fetchExchangeRateFromJisu(from: string, to: string): Promise<number | null> {
|
||||
const apiKey = process.env.JISU_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('Missing JISU_API_KEY in .env file. Cannot fetch exchange rates.')
|
||||
}
|
||||
|
||||
try {
|
||||
const fromCode = CURRENCY_MAP[from] || from
|
||||
const toCode = CURRENCY_MAP[to] || to
|
||||
|
||||
const url = `${JISU_API_BASE}?appkey=${apiKey}&from=${fromCode}&to=${toCode}&amount=1`
|
||||
|
||||
const response = await fetch(url, {
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`JisuAPI HTTP error: ${response.status}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const data: JisuAPIResponse = await response.json()
|
||||
|
||||
// JisuAPI 成功时 status=0,错误时 status!=0
|
||||
if (data.status !== 0) {
|
||||
console.error(`JisuAPI error: ${data.msg}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// 正确解析 rate 字段(字符串格式,需转为 number)
|
||||
const rateStr = data.result?.rate
|
||||
if (!rateStr) {
|
||||
console.error('JisuAPI response missing rate field')
|
||||
return null
|
||||
}
|
||||
|
||||
const rate = parseFloat(rateStr)
|
||||
if (isNaN(rate) || rate <= 0) {
|
||||
console.error(`JisuAPI invalid rate: ${rateStr}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return rate
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch exchange rate from JisuAPI:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取汇率(带缓存和降级机制)
|
||||
*/
|
||||
export async function getExchangeRate(from: string, to: string): Promise<number> {
|
||||
if (from === to) return 1
|
||||
|
||||
const cacheKey = `${from}_${to}`
|
||||
const now = Date.now()
|
||||
|
||||
if (rateCache[cacheKey] && (now - rateCache[cacheKey].timestamp) < CACHE_DURATION) {
|
||||
return rateCache[cacheKey].rate
|
||||
}
|
||||
|
||||
const rate = await fetchExchangeRateFromJisu(from, to)
|
||||
|
||||
if (rate !== null) {
|
||||
rateCache[cacheKey] = { rate, timestamp: now }
|
||||
return rate
|
||||
}
|
||||
|
||||
// 降级:使用默认汇率
|
||||
if (DEFAULT_RATES[from] && DEFAULT_RATES[to]) {
|
||||
const fallbackRate = DEFAULT_RATES[from] / DEFAULT_RATES[to]
|
||||
console.warn(`Using fallback rate for ${from}->${to}: ${fallbackRate}`)
|
||||
return fallbackRate
|
||||
}
|
||||
|
||||
console.error(`All exchange rate sources failed for ${from}->${to}, returning 1`)
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多个汇率(批量优化)
|
||||
*/
|
||||
export async function getExchangeRates(pairs: { from: string; to: string }[]): Promise<Record<string, number>> {
|
||||
const results: Record<string, number> = {}
|
||||
|
||||
const promises = pairs.map(async ({ from, to }) => {
|
||||
const rate = await getExchangeRate(from, to)
|
||||
return { from, to, rate }
|
||||
})
|
||||
|
||||
const resolved = await Promise.all(promises)
|
||||
|
||||
resolved.forEach(({ from, to, rate }) => {
|
||||
results[`${from}_${to}`] = rate
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取以 USD 为基准的所有货币汇率
|
||||
*/
|
||||
export async function getAllRatesToUSD(): Promise<Record<string, number>> {
|
||||
const currencies = Object.keys(DEFAULT_RATES)
|
||||
const pairs = currencies.map(from => ({ from, to: 'USD' as const }))
|
||||
const rates = await getExchangeRates(pairs)
|
||||
|
||||
rates['USD_USD'] = 1
|
||||
|
||||
return rates
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除汇率缓存(用于测试或强制刷新)
|
||||
*/
|
||||
export function clearRateCache(): void {
|
||||
Object.keys(rateCache).forEach(key => delete rateCache[key])
|
||||
}
|
||||
175
src/lib/security-sync.ts
Normal file
175
src/lib/security-sync.ts
Normal file
@ -0,0 +1,175 @@
|
||||
// 证券名称自动补全服务
|
||||
// 当 Security 表中缺少某证券时,自动从腾讯 API 获取名称并写入数据库
|
||||
|
||||
import { prisma } from './prisma'
|
||||
|
||||
// 货币代码映射
|
||||
const CURRENCY_MAP: Record<string, string> = {
|
||||
USD: 'USD',
|
||||
CNY: 'CNY',
|
||||
HKD: 'HKD',
|
||||
USDT: 'USDT',
|
||||
}
|
||||
|
||||
// 判断市场类型
|
||||
function detectMarketType(symbol: string): string {
|
||||
// 港股:纯数字,通常5位
|
||||
if (/^\d{5}$/.test(symbol)) return 'HK'
|
||||
// A股深圳:以 sz 开头或纯数字0/3开头
|
||||
if (symbol.startsWith('sz') || /^[03]\d{5}$/.test(symbol)) return 'CN'
|
||||
// A股上海:以 sh 开头或纯数字6开头
|
||||
if (symbol.startsWith('sh') || /^6\d{5}$/.test(symbol)) return 'CN'
|
||||
// 美股:纯字母
|
||||
if (/^[A-Z]+$/i.test(symbol)) return 'US'
|
||||
// 加密货币:常见币种
|
||||
if (['BTC', 'ETH', 'USDT'].includes(symbol.toUpperCase())) return 'CRYPTO'
|
||||
return 'US'
|
||||
}
|
||||
|
||||
// 判断货币
|
||||
function detectCurrency(symbol: string, marketType: string): string {
|
||||
switch (marketType) {
|
||||
case 'HK': return 'HKD'
|
||||
case 'CN': return 'CNY'
|
||||
case 'CRYPTO': return 'USDT'
|
||||
default: return 'USD'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取腾讯行情接口格式
|
||||
function toTencentSymbol(symbol: string, marketType: string): string {
|
||||
switch (marketType) {
|
||||
case 'CN':
|
||||
if (symbol.startsWith('sz') || symbol.startsWith('sh')) return symbol
|
||||
if (symbol.startsWith('6')) return `sh${symbol}`
|
||||
return `sz${symbol}`
|
||||
case 'HK':
|
||||
if (symbol.startsWith('hk')) return `r_${symbol.toLowerCase()}`
|
||||
return `r_hk${symbol}`
|
||||
case 'US':
|
||||
if (symbol.startsWith('s_us')) return symbol.toLowerCase()
|
||||
return `s_us${symbol.toUpperCase()}`
|
||||
case 'CRYPTO':
|
||||
return `usdt${symbol.toLowerCase()}`
|
||||
default:
|
||||
return symbol
|
||||
}
|
||||
}
|
||||
|
||||
// 从腾讯 API 获取证券名称
|
||||
async function fetchSecurityNameFromTencent(symbol: string, marketType: string): Promise<string | null> {
|
||||
try {
|
||||
const TENCENT_API_BASE = 'https://qt.gtimg.cn/q='
|
||||
const tencentSymbol = toTencentSymbol(symbol, marketType)
|
||||
const url = `${TENCENT_API_BASE}${tencentSymbol}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
next: { revalidate: 86400 }, // 缓存24小时
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
// GBK 解码
|
||||
const buffer = await response.arrayBuffer()
|
||||
const text = new TextDecoder('gbk').decode(buffer)
|
||||
|
||||
// 解析:v_xxx="100~名称~代码~..."
|
||||
const match = text.match(/="([^"]+)"/)
|
||||
if (!match) return null
|
||||
|
||||
const fields = match[1].split('~')
|
||||
if (fields.length < 2) return null
|
||||
|
||||
return fields[1]?.trim() || null
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch name from Tencent for ${symbol}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 补全单个证券信息
|
||||
export async function upsertSecurityFromTencent(symbol: string): Promise<void> {
|
||||
// 检查是否已存在
|
||||
const existing = await prisma.security.findUnique({
|
||||
where: { symbol },
|
||||
})
|
||||
|
||||
if (existing && existing.name) {
|
||||
// 已有名称,无需更新
|
||||
return
|
||||
}
|
||||
|
||||
// 检测市场类型
|
||||
const marketType = detectMarketType(symbol)
|
||||
const currency = detectCurrency(symbol, marketType)
|
||||
|
||||
// 从腾讯 API 获取名称
|
||||
const name = await fetchSecurityNameFromTencent(symbol, marketType)
|
||||
|
||||
if (name) {
|
||||
// 写入数据库
|
||||
await prisma.security.upsert({
|
||||
where: { symbol },
|
||||
update: { name },
|
||||
create: {
|
||||
symbol: symbol.toUpperCase(),
|
||||
name,
|
||||
market: marketType as any,
|
||||
currency,
|
||||
lotSize: marketType === 'CN' || marketType === 'HK' ? 100 : 1,
|
||||
priceDecimals: 2,
|
||||
qtyDecimals: marketType === 'CRYPTO' ? 8 : 0,
|
||||
isCrypto: marketType === 'CRYPTO',
|
||||
},
|
||||
})
|
||||
console.log(`Upserted security: ${symbol} -> ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量补全缺失名称的证券
|
||||
export async function syncMissingSecurityNames(): Promise<number> {
|
||||
// 查找所有证券,在 JavaScript 中过滤没有名称的
|
||||
const allSecurities = await prisma.security.findMany()
|
||||
const securitiesWithoutNames = allSecurities.filter(s => !s.name || s.name.trim() === '')
|
||||
|
||||
let synced = 0
|
||||
for (const sec of securitiesWithoutNames) {
|
||||
try {
|
||||
const name = await fetchSecurityNameFromTencent(sec.symbol, sec.market)
|
||||
if (name) {
|
||||
await prisma.security.update({
|
||||
where: { symbol: sec.symbol },
|
||||
data: { name },
|
||||
})
|
||||
synced++
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync ${sec.symbol}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return synced
|
||||
}
|
||||
|
||||
// 从交易记录中发现缺失证券并补全
|
||||
export async function discoverAndUpsertFromTransactions(): Promise<number> {
|
||||
// 查找所有交易记录中的证券代码
|
||||
const transactions = await prisma.transaction.findMany({
|
||||
where: { symbol: { not: null } },
|
||||
select: { symbol: true },
|
||||
distinct: ['symbol'],
|
||||
})
|
||||
|
||||
const symbols = transactions.map(t => t.symbol).filter(Boolean) as string[]
|
||||
|
||||
let synced = 0
|
||||
for (const symbol of symbols) {
|
||||
const existing = await prisma.security.findUnique({ where: { symbol } })
|
||||
if (!existing) {
|
||||
await upsertSecurityFromTencent(symbol)
|
||||
synced++
|
||||
}
|
||||
}
|
||||
|
||||
return synced
|
||||
}
|
||||
93
src/lib/tencent-quote.ts
Normal file
93
src/lib/tencent-quote.ts
Normal file
@ -0,0 +1,93 @@
|
||||
export function toTencentSymbol(symbol: string, marketType: string): string {
|
||||
switch (marketType) {
|
||||
case 'CN':
|
||||
if (symbol.startsWith('sz') || symbol.startsWith('sh')) {
|
||||
return symbol
|
||||
}
|
||||
if (symbol.startsWith('6')) {
|
||||
return `sh${symbol}`
|
||||
}
|
||||
return `sz${symbol}`
|
||||
case 'HK':
|
||||
if (symbol.startsWith('hk')) {
|
||||
return `r_${symbol.toLowerCase()}`
|
||||
}
|
||||
return `r_hk${symbol}`
|
||||
case 'US':
|
||||
if (symbol.startsWith('s_us')) {
|
||||
return symbol.toLowerCase()
|
||||
}
|
||||
return `s_us${symbol.toUpperCase()}`
|
||||
case 'CRYPTO':
|
||||
return `usdt${symbol.toLowerCase()}`
|
||||
default:
|
||||
return symbol
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTencentQuote(data: string): { price: number; change: number; changePercent: number } | null {
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPricesFromTencent(symbols: { symbol: string; marketType: string }[]): Promise<Record<string, { price: number; change: number; changePercent: number }>> {
|
||||
if (symbols.length === 0) return {}
|
||||
|
||||
const TENCENT_API_BASE = 'https://qt.gtimg.cn/q='
|
||||
|
||||
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 },
|
||||
})
|
||||
|
||||
if (!response.ok) return {}
|
||||
|
||||
// 腾讯API返回GBK编码,必须使用arrayBuffer + TextDecoder('gbk')解码
|
||||
const buffer = await response.arrayBuffer()
|
||||
const text = new TextDecoder('gbk').decode(buffer)
|
||||
|
||||
const results: Record<string, { price: number; change: number; changePercent: number }> = {}
|
||||
|
||||
const lines = text.split('\n')
|
||||
symbols.forEach((s, index) => {
|
||||
if (lines[index]) {
|
||||
const quote = parseTencentQuote(lines[index])
|
||||
if (quote) {
|
||||
results[s.symbol] = quote
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch prices from Tencent:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@ -51,6 +51,7 @@ export interface Transaction {
|
||||
executedAt: string
|
||||
createdAt: string
|
||||
account?: { name: string; marketType: MarketType }
|
||||
securityName?: string | null // 关联查询的证券名称
|
||||
}
|
||||
|
||||
// 持仓
|
||||
|
||||
Loading…
Reference in New Issue
Block a user