# 持仓计价方案设计文档
## 一、背景与需求
### 1.1 产品定位
投资记账软件,用于记录用户分散在不同市场、不同券商下的所有资产,统计总市值、投资收益率、年化收益率等。
### 1.2 新建持仓设计决策
#### 1.2.1 设计方案选择
**问题:** 新建持仓时,应该先输入并联想,还是先选择资产类型再搜索?
**决策:** 采用**方案A:搜索优先 + 智能切换**的设计。
**方案对比:**
| 方案 | 优点 | 缺点 | 适用场景 |
|------|------|------|----------|
| **方案A:搜索优先 + 智能切换** | ✅ 灵活,支持多种创建方式
✅ 容错性强,搜索失败可切换
✅ 用户体验流畅 | ⚠️ 实现复杂度稍高 | **推荐采用** |
| 方案B:资产类型优先 + 条件搜索 | ✅ 逻辑清晰
✅ 搜索范围精准 | ⚠️ 不够灵活
⚠️ 必须先选择类型 | 备选方案 |
#### 1.2.2 方案A:搜索优先 + 智能切换(最终采用)
**核心设计思路:**
1. **搜索框优先,但不强制**
- 搜索框始终显示在顶部
- 用户可以选择搜索或直接手动输入
- 搜索失败时,可以无缝切换到手动输入
2. **智能联动**
- 选择资产类型后,搜索框自动过滤该类型
- 搜索时,优先显示匹配的资产类型
- 字段根据选择动态显示/隐藏
3. **渐进式披露**
- 第一步:搜索框 + 资产类型选择
- 第二步:根据选择显示对应字段
- 第三步:填写价格和数量
**弹窗布局(从上到下):**
```
┌─────────────────────────────────────────┐
│ 搜索框(始终显示,支持全局搜索) │
│ [🔍 输入代码/名称搜索...] │
│ ↓ 联想结果列表(最多10条) │
├─────────────────────────────────────────┤
│ 资产类型选择(Tab切换,可选) │
│ [股票] [基金] [债券] [现金] [其他] │
├─────────────────────────────────────────┤
│ 根据资产类型显示字段: │
│ - 股票/基金/债券:市场、券商、代码、名称 │
│ - 现金:券商 │
├─────────────────────────────────────────┤
│ 价格和数量: │
│ - 成本价(人民币)、数量、最新价(可选) │
├─────────────────────────────────────────┤
│ 其他选项: │
│ - 货币类型、是否自动更新价格 │
└─────────────────────────────────────────┘
```
**三种创建方式:**
**方式1:快速创建(通过搜索)**
- 用户在搜索框输入资产代码或名称
- 系统联想出结果,用户选择
- 系统自动填充:资产类型、市场、资产代码、资产名称
- 用户只需填写:券商、成本价、数量、最新价
**方式2:智能创建(先选类型再搜索)**
- 用户先选择资产类型(如:股票)
- 搜索框自动过滤,只搜索该类型
- 用户输入代码或名称搜索
- 选择结果后自动填充相关信息
**方式3:手动创建(不通过搜索)**
- 用户选择资产类型
- 跳过搜索,直接手动填写所有字段
- 或搜索无结果时,自动切换到手动输入模式
**设计原则:**
- ✅ **灵活性**:三种方式默认都支持,用户可以根据习惯选择
- ✅ **智能联动**:搜索框根据资产类型自动过滤
- ✅ **容错性**:搜索失败可以无缝切换到手动输入
- ✅ **渐进式披露**:根据用户选择逐步显示字段,不会一次性显示所有字段
- ✅ **自动填充**:通过搜索选择后,相关字段自动填充且禁用,防止误修改
- ✅ **可重置**:用户可以点击"重新选择"清空搜索结果,恢复手动填写
**关键交互细节:**
1. **搜索框智能过滤**
- 用户选择"股票" → 搜索框自动过滤,只搜索股票
- 用户选择"基金" → 搜索框自动过滤,只搜索基金
- 用户未选择 → 搜索框全局搜索所有类型(股票、基金、债券)
2. **搜索失败处理**
- 搜索无结果 → 显示"未找到匹配的资产,点击手动输入"
- 点击后 → 隐藏搜索框(可选),显示完整表单
- 用户可以继续手动填写所有信息
3. **字段联动**
- 通过搜索选择 → 自动填充:资产类型、市场、资产代码、资产名称(禁用状态)
- 手动输入 → 所有字段可编辑
- 切换资产类型 → 清空相关字段,重新显示对应字段
4. **搜索框状态**
- 未选择:显示占位符"输入代码/名称搜索..."
- 已选择:显示选中的资产名称,右侧显示"重新选择"按钮
- 点击"重新选择" → 清空选择,恢复搜索状态
### 1.2 核心需求
- 用户记录持仓数据(成本价、最新价、持股数量)
- 每日收盘后自动更新持仓股票的最新价格
- 计算当日资产总值、净值
- 呈现所有持仓的总市值变化情况、各持仓占比
- 形成图表,方便用户统计资产总市值、投资收益率、年化收益率
- **最终总资产、总成本和各持仓市值占比需要使用人民币进行计价**
### 1.3 用户操作场景
- 用户每次变更持仓时,只需要更新:**成本价、最新价、持股数量** 三个数值
- 用户可能在不同日期多次买入/卖出同一只股票
- 用户可能持有A股、港股、美股等不同市场的资产
### 1.4 核心问题
如何统一处理不同市场的货币计价问题,确保最终展示和计算统一为人民币?
---
## 二、方案对比
### 2.1 方案A:统一人民币存储方案(推荐采用)
#### 2.1.1 核心思路
- **用户输入**:所有价格统一使用人民币计价
- **数据存储**:数据库中所有价格字段统一存储为人民币
- **系统处理**:所有计算和展示统一为人民币
- **无需汇率**:完全避免汇率问题
#### 2.1.2 数据存储设计
**数据库字段:**
```sql
-- positions 表
cost_price DECIMAL(18, 4) -- 成本价(人民币)
current_price DECIMAL(18, 4) -- 最新价(人民币)
previous_price DECIMAL(18, 4) -- 上一次价格(人民币,用于涨跌显示)
shares DECIMAL(18, 4) -- 持股数量
currency VARCHAR(10) -- 货币类型(仅用于标识市场,不影响计算)
```
**字段说明:**
- `cost_price`:成本价,统一为人民币
- `current_price`:最新价,统一为人民币
- `currency`:仅用于标识市场(CNY/HKD/USD),不影响计算和展示
#### 2.1.3 用户操作流程
**创建持仓:**
1. 用户输入成本价(人民币)
2. 用户输入最新价(人民币)
3. 用户输入持股数量
4. 用户选择市场(CNY/HKD/USD,仅标识)
**更新持仓:**
1. 用户修改成本价(人民币)
2. 用户修改最新价(人民币)
3. 用户修改持股数量
**价格更新:**
- **手动更新**:用户直接在界面上更新人民币价格
- **自动更新**:系统获取原始货币价格 → 转换为人民币 → 更新到数据库
#### 2.1.4 系统处理逻辑
**价格自动更新流程:**
```
每日收盘后:
1. 获取原始货币价格(如港币160)
2. 获取当前汇率(如0.9)
3. 转换为人民币(160 × 0.9 = ¥144)
4. 更新到数据库的 current_price 字段
```
**计算逻辑:**
```typescript
// 所有计算统一为人民币
const costValue = shares * costPrice; // 人民币成本
const marketValue = shares * currentPrice; // 人民币市值
const profit = marketValue - costValue; // 人民币盈亏
const profitPercent = (profit / costValue) * 100;
const assetPercent = (marketValue / totalAsset) * 100;
```
#### 2.1.5 优点
- ✅ **极简**:用户只需输入人民币价格,操作简单
- ✅ **统一**:所有数据统一为人民币,计算和展示一致
- ✅ **无汇率问题**:完全避免汇率存储、更新、同步等问题
- ✅ **符合记账场景**:用户记录的是自己的资产情况,通常知道人民币成本
- ✅ **实现简单**:无需复杂的汇率处理逻辑
#### 2.1.6 缺点
- ⚠️ **用户需要转换**:持有港股/美股的用户需要自己将港币/美元价格转换为人民币
- ⚠️ **无法直接核对**:无法直接与市场报价(原始货币)对比
#### 2.1.7 适用场景
- ✅ 纯手动记账
- ✅ 用户主要关心人民币价值
- ✅ 记账软件定位(非实时交易软件)
---
### 2.2 方案B:混合计价方案(对比参考)
#### 2.2.1 核心思路
- **数据存储**:同时存储人民币价格和原始币种价格
- **用户输入**:可以选择输入人民币价格或原始币种价格
- **系统处理**:自动转换和同步两种价格
- **展示策略**:主要显示人民币,辅助显示原始币种
#### 2.2.2 数据存储设计
**数据库字段:**
```sql
-- positions 表
cost_price DECIMAL(18, 4) -- 成本价(人民币,主要)
current_price DECIMAL(18, 4) -- 最新价(人民币,主要)
cost_price_original DECIMAL(18, 4) -- 成本价(原始币种,可选)
current_price_original DECIMAL(18, 4) -- 最新价(原始币种,可选)
cost_exchange_rate DECIMAL(10, 6) -- 成本汇率(加权平均)
current_exchange_rate DECIMAL(10, 6) -- 当前汇率
exchange_rate_updated_at TIMESTAMP -- 汇率更新时间
shares DECIMAL(18, 4)
currency VARCHAR(10)
```
#### 2.2.3 用户操作流程
**创建持仓(三种方式):**
**方式1:直接输入人民币价格(推荐)**
```
成本价:¥135(人民币)
最新价:¥144(人民币)
数量:100股
```
**方式2:输入原始币种 + 人民币总成本**
```
成本价:HK$150(原始币种)
数量:100股
人民币总成本:¥13,500(必须输入)
→ 系统自动计算加权平均汇率:13,500 / (100 × 150) = 0.9
→ 系统自动计算人民币成本价:150 × 0.9 = ¥135
```
**方式3:输入原始币种 + 加权平均汇率**
```
成本价:HK$150(原始币种)
数量:100股
加权平均汇率:0.9(用户自己计算)
→ 系统自动计算人民币成本价:150 × 0.9 = ¥135
```
#### 2.2.4 系统处理逻辑
**汇率计算:**
- 成本汇率:从人民币总成本反推加权平均汇率
- 当前汇率:每日自动更新,用于价格转换
**价格更新:**
```
每日收盘后:
1. 获取原始货币价格(如港币160)
2. 获取当前汇率(如0.9)
3. 转换为人民币(160 × 0.9 = ¥144)
4. 同时更新 current_price(人民币)和 current_price_original(原始币种)
```
#### 2.2.5 优点
- ✅ 支持自动更新价格(可以获取市场原始数据)
- ✅ 可以核对市场报价(显示原始币种价格)
- ✅ 用户可以选择输入方式
#### 2.2.6 缺点
- ⚠️ **复杂**:需要存储和管理汇率
- ⚠️ **多次买入问题**:需要处理加权平均汇率
- ⚠️ **汇率同步**:需要定期更新汇率
- ⚠️ **实现复杂**:需要处理汇率转换逻辑
#### 2.2.7 适用场景
- ✅ 需要自动更新价格
- ✅ 用户需要核对市场报价
- ✅ 需要显示原始币种价格
---
## 三、方案选择
### 3.1 最终选择:方案A(统一人民币存储方案)
**选择理由:**
1. **符合记账软件定位**:用户记录的是自己的资产情况,通常知道人民币成本
2. **简化用户操作**:用户只需输入3个数字(成本价、最新价、数量),无需考虑汇率
3. **简化系统实现**:无需存储和管理汇率,计算逻辑简单
4. **满足最终需求**:总资产、总成本、持仓占比都统一为人民币
### 3.2 关于价格更新的处理
**方式1:系统自动更新(推荐)**
- 系统获取原始货币价格
- 自动转换为人民币
- 更新到数据库
**方式2:用户手动更新**
- 用户直接在界面上更新人民币价格
---
## 四、产品需求文档
### 4.1 新建持仓
#### 4.1.1 功能描述
用户创建新的持仓记录,支持两种创建方式:
1. **快速创建**:通过搜索框搜索资产代码或名称,选择后自动填充基本信息
2. **手动创建**:手动选择资产类型,然后填写所有信息
两种方式默认都支持,用户可以根据习惯选择。
#### 4.1.2 用户角色
所有注册用户
#### 4.1.3 功能入口
- 持仓列表页面 → "新增持仓" 按钮
- 顶部导航栏 → "资产" → "新增持仓"
#### 4.1.4 弹窗设计
**弹窗布局:**
- **PC端**:居中弹窗(Modal),宽度:600px
- **移动端**:底部弹窗(Drawer),高度:80%
**弹窗结构(从上到下):**
1. **搜索框**(顶部,始终显示)
- 位置:弹窗最顶部
- 功能:支持全局搜索资产代码或名称
- 交互:
- 用户输入代码(如:600519、00700、AAPL)或名称(如:贵州茅台、腾讯控股)
- 系统实时联想,显示匹配结果
- 联想结果包含:资产代码、资产名称、市场、资产类型
- 用户选择后,自动填充:资产类型、市场、资产代码、资产名称
- 填充后,搜索框显示选中的资产名称(可点击重新搜索)
2. **资产类型选择**(搜索框下方)
- 如果通过搜索选择了资产,此字段自动填充且禁用
- 如果未通过搜索选择,用户手动选择:股票/基金/债券/现金/其他
3. **市场和券商**(根据资产类型显示)
- **股票/基金/债券**:显示"市场"和"券商"字段
- **现金**:只显示"券商"字段,不显示"市场"字段
- 如果通过搜索选择了资产,"市场"字段自动填充
4. **资产代码和资产名称**(根据资产类型显示)
- **股票/基金/债券**:显示"资产代码"和"资产名称"字段
- **现金**:不显示这两个字段
- 如果通过搜索选择了资产,这两个字段自动填充且禁用
5. **价格和数量信息**
- **成本价(人民币)**:数字输入,单位:元(必填)
- **持股数量**:数字输入(必填)
- **最新价(人民币)**:数字输入,单位:元(可选)
6. **其他选项**
- **货币类型**:下拉选择(人民币/港币/美元),默认:人民币
- **是否自动更新价格**:开关,默认:关闭
#### 4.1.5 输入字段详细说明
**搜索框:**
- **功能**:全局搜索资产代码或名称
- **搜索范围**:股票、基金、债券(不包含现金)
- **联想结果格式**:
```
600519 - 贵州茅台 (A股-上海)
00700 - 腾讯控股 (港股)
AAPL - 苹果公司 (美股)
```
- **选择后行为**:
- 自动填充:资产类型、市场、资产代码、资产名称
- 搜索框显示选中的资产名称(可点击重新搜索)
- 相关字段变为禁用状态(可点击"重新选择"按钮恢复)
**资产类型:**
- **选项**:股票、基金、债券、现金、其他
- **默认值**:无(必须选择)
- **联动规则**:
- 选择"股票/基金/债券":显示市场、券商、资产代码、资产名称字段
- 选择"现金":只显示券商字段,隐藏市场、资产代码、资产名称字段
**市场:**
- **选项**:A股-上海、A股-深圳、港股、美股等
- **显示条件**:资产类型为股票/基金/债券时显示
- **自动填充**:如果通过搜索选择了资产,自动填充对应市场
**券商:**
- **选项**:从用户已添加的券商中选择
- **显示条件**:所有资产类型都显示
- **必填**:是
**资产代码:**
- **输入方式**:文本输入
- **显示条件**:资产类型为股票/基金/债券时显示
- **自动填充**:如果通过搜索选择了资产,自动填充且禁用
- **示例**:600519、00700、AAPL
**资产名称:**
- **输入方式**:文本输入
- **显示条件**:资产类型为股票/基金/债券时显示
- **自动填充**:如果通过搜索选择了资产,自动填充且禁用
- **示例**:贵州茅台、腾讯控股、苹果公司
**成本价(人民币):**
- **输入方式**:数字输入,支持小数
- **单位**:元
- **必填**:是
- **说明**:用户从券商系统看到的成本价,如果是港股/美股,需要转换为人民币后输入
- **示例**:1600.00
**持股数量:**
- **输入方式**:数字输入,支持小数
- **必填**:是
- **说明**:当前持仓的股数/份数
- **示例**:100
**最新价(人民币):**
- **输入方式**:数字输入,支持小数
- **单位**:元
- **必填**:否
- **说明**:当前市场价格,如果是港股/美股,需要转换为人民币后输入
- **示例**:1850.00
**货币类型:**
- **选项**:人民币、港币、美元
- **默认值**:人民币
- **说明**:仅用于标识市场,不影响计算和展示
**是否自动更新价格:**
- **类型**:开关
- **默认值**:关闭
- **说明**:开启后,系统每日收盘后自动更新最新价
#### 4.1.5 业务规则
1. **搜索规则**:
- 搜索框支持全局搜索股票、基金、债券(不包含现金、其他)
- 搜索范围:资产代码、资产名称
- 搜索方式:模糊匹配,不区分大小写
- 联想结果最多显示10条
- 联想结果按匹配度排序(完全匹配 > 前缀匹配 > 包含匹配)
- **智能过滤**:如果用户选择了资产类型,搜索框自动过滤,只搜索该类型
- **防抖处理**:输入后300ms才触发搜索,避免频繁请求
2. **资产类型联动规则**:
- 用户选择资产类型后,搜索框自动过滤该类型
- 搜索框占位符动态变化:"搜索股票..."、"搜索基金..."等
- 如果用户清空资产类型选择,搜索框恢复全局搜索
- 如果通过搜索选择了资产,资产类型自动填充且禁用
3. **自动填充规则**:
- 通过搜索选择资产后,自动填充:资产类型、市场、资产代码、资产名称、货币类型
- 自动填充的字段变为禁用状态,显示锁定图标,防止误修改
- 用户可以通过"重新选择"按钮清空并重新搜索
- 如果用户手动修改了资产类型,清空自动填充的字段(资产类型、市场、代码、名称)
4. **字段显示规则**:
- **资产类型为股票/基金/债券**:
- 显示:市场、券商、资产代码、资产名称、成本价、持股数量、最新价
- **资产类型为现金**:
- 显示:券商、成本价、持股数量、最新价
- 隐藏:市场、资产代码、资产名称
- **资产类型为其他**:
- 显示:券商、资产代码、资产名称、成本价、持股数量、最新价
- 隐藏:市场(可选)
5. **搜索失败处理规则**:
- 搜索无结果时,显示提示:"未找到匹配的资产,点击手动输入"
- 用户点击后,可以:
- 选择隐藏搜索框,显示完整表单
- 或保留搜索框,但允许用户手动填写所有字段
- 用户可以继续完成创建流程
6. **唯一性约束**:
- 同一用户、同一券商、同一资产代码、同一市场、同一资产类型只能有一条持仓
- 如果已存在,提示用户"该持仓已存在,请直接编辑现有持仓"
- 现金类型:同一用户、同一券商只能有一条现金持仓
7. **价格输入规则**:
- 成本价和最新价必须使用人民币计价
- 如果持有港股/美股,用户需要自己转换为人民币后输入
- 系统不提供汇率转换工具(简化设计)
- 价格支持小数,精度:2位小数
8. **数量规则**:
- 持股数量必须大于0
- 支持小数(如基金份额)
- 精度:4位小数
9. **价格更新规则**:
- 如果开启自动更新,系统每日收盘后自动更新最新价
- 更新时:获取原始货币价格 → 转换为人民币 → 更新
- 现金类型不支持自动更新价格
10. **表单验证规则**:
- 搜索选择资产后,资产类型、市场、资产代码、资产名称自动填充,无需验证
- 手动创建时,资产类型、券商、成本价、持股数量为必填
- 股票/基金/债券类型,市场、资产代码、资产名称为必填
- 现金类型,不需要市场、资产代码、资产名称
- 其他类型,资产代码、资产名称可选
#### 4.1.6 用户提示
**表单提示信息:**
```
💡 提示:
• 成本价和最新价请使用人民币计价
• 如果持有港股/美股,请将港币/美元价格转换为人民币后输入
• 系统会自动更新价格(如果开启自动更新)
• 成本价是您从券商系统看到的加权平均成本价
```
#### 4.1.7 交互流程
**方式1:快速创建(通过搜索)**
1. 用户点击"新增持仓"按钮
2. 弹出表单对话框(PC端居中,移动端底部)
3. 用户在顶部搜索框输入资产代码或名称(如:600519 或 贵州茅台)
4. 系统实时联想,显示匹配结果列表(最多10条)
5. 用户从联想结果中选择一个资产
6. 系统自动填充:
- 资产类型(自动填充且禁用)
- 市场(自动填充)
- 资产代码(自动填充且禁用)
- 资产名称(自动填充且禁用)
- 搜索框显示选中的资产名称,右侧显示"重新选择"按钮
7. 用户选择券商
8. 用户输入成本价(人民币)
9. 用户输入持股数量
10. 用户输入最新价(可选)
11. 用户设置其他选项(货币类型、自动更新价格)
12. 用户点击"保存"按钮
13. 系统验证数据
14. 系统保存持仓
15. 刷新持仓列表
16. 显示成功提示
**方式2:智能创建(先选类型再搜索)**
1. 用户点击"新增持仓"按钮
2. 弹出表单对话框(PC端居中,移动端底部)
3. 用户先选择资产类型(如:股票)
4. 搜索框自动过滤,只搜索该类型的资产
5. 用户在搜索框输入资产代码或名称
6. 系统实时联想,显示匹配结果列表(仅显示该类型)
7. 用户从联想结果中选择一个资产
8. 系统自动填充相关信息(同方式1)
9. 用户填写剩余字段(券商、成本价、数量、最新价等)
10. 用户点击"保存"按钮
11. 系统验证数据并保存
**方式3:手动创建(不通过搜索)**
1. 用户点击"新增持仓"按钮
2. 弹出表单对话框(PC端居中,移动端底部)
3. 用户选择资产类型(股票/基金/债券/现金/其他)
4. 根据资产类型显示对应字段:
- **股票/基金/债券**:显示市场、券商、资产代码、资产名称
- **现金**:只显示券商
5. 用户填写必填字段:
- 选择市场(股票/基金/债券必填)
- 选择券商(所有类型必填)
- 输入资产代码(股票/基金/债券必填)
- 输入资产名称(股票/基金/债券必填)
- 输入成本价(所有类型必填)
- 输入持股数量(所有类型必填)
6. 用户填写可选字段:
- 输入最新价(可选)
- 选择货币类型(默认人民币)
- 设置是否自动更新价格(默认关闭)
7. 用户点击"保存"按钮
8. 系统验证数据
9. 系统保存持仓
10. 刷新持仓列表
11. 显示成功提示
**搜索失败处理流程:**
1. 用户输入搜索关键词
2. 系统搜索无结果
3. 显示提示:"未找到匹配的资产,点击手动输入"
4. 用户点击"手动输入"
5. 搜索框变为可选(可隐藏或保留)
6. 显示完整表单,用户可以手动填写所有字段
7. 用户可以继续完成创建流程
**搜索框交互细节:**
- **输入时**:
- 用户输入字符,系统实时搜索(防抖300ms)
- 显示联想结果列表(最多10条)
- 结果包含:资产代码、资产名称、市场、资产类型
- 高亮匹配的字符
- 如果选择了资产类型,只显示该类型的结果
- **选择后**:
- 搜索框显示选中的资产名称(可点击重新搜索)
- 自动填充相关字段(资产类型、市场、代码、名称)
- 填充的字段变为禁用状态,显示锁定图标
- 搜索框右侧显示"重新选择"按钮
- **重新选择**:
- 点击"重新选择"按钮或点击搜索框
- 清空已填充的字段
- 恢复字段为可编辑状态
- 搜索框恢复为空,可重新输入
- 如果之前选择了资产类型,搜索框恢复为该类型的过滤状态
- **资产类型联动**:
- 用户选择资产类型(如:股票)
- 搜索框自动过滤,只搜索股票类型
- 搜索框占位符变为"搜索股票..."
- 如果用户清空资产类型选择,搜索框恢复全局搜索
- **搜索框状态**:
- **未选择**:显示占位符"输入代码/名称搜索..."或"搜索[资产类型]..."
- **已选择**:显示选中的资产名称,右侧显示"重新选择"按钮
- **搜索中**:显示加载动画
- **无结果**:显示"未找到匹配的资产,点击手动输入"
#### 4.1.8 异常处理
**搜索相关:**
- **搜索无结果**:
- 显示"未找到匹配的资产"
- 提供"点击手动输入"按钮
- 点击后可以隐藏搜索框(可选),显示完整表单
- 用户可以继续手动填写所有字段完成创建
- **搜索网络错误**:
- 显示"搜索失败,请稍后重试"
- 允许用户继续手动填写
- 提供"重试"按钮
- **搜索超时**:
- 显示"搜索超时,请稍后重试"
- 允许用户继续手动填写
- 提供"重试"按钮
- **资产类型过滤后无结果**:
- 提示"当前类型下未找到匹配的资产"
- 提供"清除类型过滤"或"切换为手动输入"选项
**表单验证:**
- **持仓已存在**:提示"该持仓已存在,请直接编辑现有持仓"
- **必填字段为空**:表单验证提示,高亮显示未填写的必填字段
- **价格格式错误**:提示"请输入有效的价格(支持小数)"
- **数量小于等于0**:提示"持股数量必须大于0"
- **资产类型未选择**:提示"请选择资产类型或通过搜索选择资产"
**数据一致性:**
- **搜索选择的资产与手动选择的资产类型不一致**:
- 如果用户通过搜索选择了股票,但手动将资产类型改为基金
- 清空自动填充的字段,提示"资产类型已更改,请重新填写相关信息"
- **搜索选择的资产市场与手动选择的市场不一致**:
- 提示"搜索选择的资产市场与手动选择的市场不一致,请确认"
- 允许用户选择使用哪个市场
---
### 4.2 编辑持仓
#### 4.2.1 功能描述
用户修改现有持仓的信息,主要是更新成本价、最新价、持股数量。
#### 4.2.2 用户角色
所有注册用户(只能编辑自己的持仓)
#### 4.2.3 功能入口
- 持仓列表页面 → 点击持仓卡片 → "编辑" 按钮
- 持仓详情页面 → "编辑" 按钮
#### 4.2.4 可编辑字段
**可编辑字段:**
- **券商**:下拉选择(可修改)
- **成本价(人民币)**:数字输入,单位:元
- 说明:修改加权平均成本价
- **最新价(人民币)**:数字输入,单位:元
- 说明:修改当前市场价格
- **持股数量**:数字输入
- 说明:修改持仓数量
- **货币类型**:下拉选择(可修改)
- **是否自动更新价格**:开关(可修改)
- **状态**:下拉选择(活跃/暂停/退市)
**不可编辑字段:**
- 资产类型
- 资产代码
- 资产名称
- 市场(部分系统可能允许修改)
#### 4.2.5 业务规则
1. **价格更新规则**:
- 更新最新价时,系统自动将旧价格保存到 `previous_price` 字段
- 用于显示涨跌颜色(红色/绿色)
2. **数量更新规则**:
- 数量可以增加(加仓)或减少(减仓)
- 数量不能小于0
3. **成本价更新规则**:
- 如果用户修改了成本价,说明可能是加仓或减仓
- 系统记录变更历史(可选功能)
#### 4.2.6 用户提示
**表单提示信息:**
```
💡 提示:
• 修改成本价、最新价、数量后,系统会自动重新计算盈亏和占比
• 成本价和最新价请使用人民币计价
• 如果修改了成本价或数量,建议检查成本价是否正确(加权平均)
```
#### 4.2.7 交互流程
1. 用户点击"编辑"按钮
2. 弹出编辑表单对话框(预填充现有数据)
3. 用户修改字段
4. 用户点击"保存"按钮
5. 系统验证数据
6. 系统更新持仓
7. 刷新持仓列表
8. 显示成功提示
#### 4.2.8 异常处理
- **持仓不存在**:提示"持仓不存在"
- **无权限**:提示"您没有权限编辑此持仓"
- **数据验证失败**:表单验证提示
---
### 4.3 价格自动更新
#### 4.3.1 功能描述
系统每日收盘后自动更新开启了"自动更新价格"的持仓的最新价。
#### 4.3.2 触发时机
- 每个工作日下午4点(A股收盘后)
- 可配置定时任务
#### 4.3.3 更新流程
1. **查询需要更新的持仓**:
```sql
SELECT * FROM positions
WHERE auto_price_update = true
AND status = 'active'
AND asset_type IN ('stock', 'fund')
```
2. **获取市场价格**:
- 调用市场数据API获取原始货币价格
- 根据 `currency` 字段判断币种
3. **转换为人民币**:
- 如果 `currency = 'CNY'`:直接使用价格
- 如果 `currency != 'CNY'`:
- 获取当前汇率
- 转换为人民币:`人民币价格 = 原始货币价格 × 汇率`
4. **更新数据库**:
- 将旧价格保存到 `previous_price`
- 更新 `current_price` 为人民币价格
- 更新 `updated_at` 时间戳
#### 4.3.4 异常处理
- **市场数据获取失败**:记录日志,跳过该持仓
- **汇率获取失败**:使用上次汇率或跳过更新
- **价格异常**:记录日志,不更新
---
## 五、技术实现细节
### 5.1 数据库设计
#### 5.1.1 表结构
```sql
CREATE TABLE positions (
position_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
broker_id BIGINT NOT NULL REFERENCES brokers(broker_id),
asset_type VARCHAR(20) NOT NULL CHECK (asset_type IN ('stock', 'fund', 'cash', 'bond', 'other')),
symbol VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
market VARCHAR(20),
shares DECIMAL(18, 4) NOT NULL DEFAULT 0,
cost_price DECIMAL(18, 4) NOT NULL, -- 成本价(人民币)
current_price DECIMAL(18, 4), -- 最新价(人民币)
previous_price DECIMAL(18, 4), -- 上一次价格(人民币)
currency VARCHAR(10) NOT NULL DEFAULT 'CNY', -- 货币类型(仅标识)
auto_price_update BOOLEAN NOT NULL DEFAULT false,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'delisted')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, broker_id, symbol, market, asset_type)
);
```
#### 5.1.2 索引设计
```sql
CREATE INDEX idx_positions_user_id ON positions(user_id);
CREATE INDEX idx_positions_broker_id ON positions(broker_id);
CREATE INDEX idx_positions_status ON positions(status);
CREATE INDEX idx_positions_auto_update ON positions(auto_price_update) WHERE auto_price_update = true;
```
### 5.2 后端实现
#### 5.2.1 搜索资产接口
```typescript
GET /api/assets/search?keyword=600519&assetType=stock&limit=10
Request Query:
- keyword: string (必填) - 搜索关键词(资产代码或名称)
- assetType: string (可选) - 资产类型过滤(stock/fund/bond)
- 如果提供,只搜索该类型的资产
- 如果不提供,搜索所有类型(股票、基金、债券)
- limit: number (可选) - 返回结果数量,默认10
Response:
{
code: 0,
data: [
{
symbol: string; // 资产代码
name: string; // 资产名称
market: string; // 市场(sh/sz/hk/us等)
assetType: string; // 资产类型(stock/fund/bond)
currency: string; // 货币类型(CNY/HKD/USD)
}
]
}
```
**搜索逻辑:**
- 搜索范围:股票、基金、债券(不包含现金、其他)
- 搜索字段:资产代码、资产名称
- 匹配方式:模糊匹配,不区分大小写
- 排序规则:完全匹配 > 前缀匹配 > 包含匹配
- **智能过滤**:
- 如果提供了 `assetType` 参数,只搜索该类型的资产
- 如果没有提供,搜索所有类型(股票、基金、债券)
- 搜索结果按匹配度排序,同类型优先
**后端实现示例:**
```typescript
@Get('search')
async searchAssets(
@Query('keyword') keyword: string,
@Query('assetType') assetType?: string,
@Query('limit') limit: number = 10,
) {
const query = this.assetRepository
.createQueryBuilder('asset')
.where('asset.symbol LIKE :keyword OR asset.name LIKE :keyword', {
keyword: `%${keyword}%`,
})
.andWhere('asset.assetType IN (:...types)', {
types: assetType
? [assetType]
: ['stock', 'fund', 'bond'],
})
.orderBy('CASE WHEN asset.symbol = :exact THEN 1 WHEN asset.symbol LIKE :prefix THEN 2 ELSE 3 END', 'ASC')
.addOrderBy('asset.name', 'ASC')
.limit(limit);
return query.getMany();
}
```
#### 5.2.2 创建持仓接口
```typescript
POST /api/positions
Request Body:
{
brokerId: number;
assetType: string;
symbol: string; // 资产代码(股票/基金/债券必填,现金不需要)
name: string; // 资产名称(股票/基金/债券必填,现金不需要)
market?: string; // 市场(股票/基金/债券可选,现金不需要)
shares: number;
costPrice: number; // 人民币成本价
currentPrice?: number; // 人民币最新价
currency?: string; // 仅标识,默认CNY
autoPriceUpdate?: boolean;
status?: string;
}
Response:
{
code: 0,
data: {
positionId: number;
// ... 其他字段
}
}
```
**字段验证规则:**
- 如果 `assetType` 为 `stock/fund/bond`:
- `symbol`、`name` 必填
- `market` 可选(但建议填写)
- 如果 `assetType` 为 `cash`:
- `symbol`、`name`、`market` 不需要
#### 5.2.2 更新持仓接口
```typescript
PUT /api/positions/:id
Request Body:
{
brokerId?: number;
costPrice?: number; // 人民币成本价
currentPrice?: number; // 人民币最新价
shares?: number;
currency?: string;
autoPriceUpdate?: boolean;
status?: string;
}
Response:
{
code: 0,
data: {
positionId: number;
// ... 更新后的字段
}
}
```
#### 5.2.3 查询持仓列表接口
```typescript
GET /api/positions
Response:
{
code: 0,
data: [
{
positionId: number;
costPrice: number; // 人民币成本价
currentPrice: number; // 人民币最新价
shares: number;
costValue: number; // 人民币成本 = shares * costPrice
marketValue: number; // 人民币市值 = shares * currentPrice
profit: number; // 人民币盈亏 = marketValue - costValue
profitPercent: number; // 盈亏比例
assetPercent: number; // 持仓占比
// ... 其他字段
}
]
}
```
#### 5.2.4 价格自动更新服务
```typescript
@Injectable()
export class PriceUpdateService {
/**
* 每日收盘后更新价格
*/
@Cron('0 16 * * 1-5')
async updateDailyPrices() {
const positions = await this.getPositionsToUpdate();
for (const position of positions) {
try {
// 1. 获取原始货币价格
const originalPrice = await this.getMarketPrice(
position.symbol,
position.market
);
// 2. 转换为人民币
let cnyPrice = originalPrice;
if (position.currency !== 'CNY') {
const exchangeRate = await this.getCurrentExchangeRate(
position.currency,
'CNY'
);
cnyPrice = originalPrice * exchangeRate;
}
// 3. 更新持仓
await this.updatePositionPrice(
position.positionId,
cnyPrice
);
} catch (error) {
this.logger.error(`更新价格失败: ${position.symbol}`, error);
}
}
}
}
```
### 5.3 前端实现
#### 5.3.1 新建持仓表单
```typescript
const CreatePositionForm = () => {
const [searchKeyword, setSearchKeyword] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [selectedAsset, setSelectedAsset] = useState(null);
const [assetType, setAssetType] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [showManualInput, setShowManualInput] = useState(false);
const form = Form.useForm();
// 防抖搜索
const debouncedSearch = useMemo(
() => debounce(async (keyword: string, type?: string) => {
if (!keyword || keyword.length < 1) {
setSearchResults([]);
return;
}
setIsSearching(true);
try {
const params: any = { keyword, limit: 10 };
// 如果选择了资产类型,只搜索该类型
if (type && type !== 'cash' && type !== 'other') {
params.assetType = type;
}
const response = await api.get('/api/assets/search', { params });
setSearchResults(response.data || []);
} catch (error) {
console.error('搜索失败', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, 300),
[]
);
// 搜索资产
const handleSearch = (keyword: string) => {
setSearchKeyword(keyword);
debouncedSearch(keyword, assetType);
};
// 选择资产
const handleSelectAsset = (asset: any) => {
setSelectedAsset(asset);
setSearchKeyword(asset.name);
setSearchResults([]);
setShowManualInput(false);
// 自动填充表单
form.setFieldsValue({
assetType: asset.assetType,
market: asset.market,
symbol: asset.symbol,
name: asset.name,
currency: asset.currency,
});
setAssetType(asset.assetType);
};
// 重新选择
const handleResetSearch = () => {
setSelectedAsset(null);
setSearchKeyword('');
setSearchResults([]);
form.setFieldsValue({
assetType: assetType || undefined,
market: undefined,
symbol: undefined,
name: undefined,
});
};
// 资产类型变化
const handleAssetTypeChange = (value: string) => {
setAssetType(value);
form.setFieldsValue({ assetType: value });
// 如果手动修改了资产类型,清空搜索选择
if (selectedAsset && selectedAsset.assetType !== value) {
handleResetSearch();
}
// 如果选择了现金或其他,隐藏搜索框
if (value === 'cash' || value === 'other') {
setShowManualInput(true);
} else {
setShowManualInput(false);
}
// 如果搜索框有内容,重新搜索(过滤类型)
if (searchKeyword) {
handleSearch(searchKeyword);
}
};
// 获取搜索框占位符
const getSearchPlaceholder = () => {
if (assetType === 'stock') return '搜索股票...';
if (assetType === 'fund') return '搜索基金...';
if (assetType === 'bond') return '搜索债券...';
return '输入代码/名称搜索...';
};
return (
• 可以通过搜索框快速选择资产,系统会自动填充相关信息
• 成本价和最新价请使用人民币计价
• 如果持有港股/美股,请将港币/美元价格转换为人民币后输入
• 系统会自动更新价格(如果开启自动更新)
} type="info" showIcon /> ); }; ``` #### 5.3.2 持仓列表展示 ```typescript const PositionList = () => { const { positions, totalAssetCNY } = usePositions(); return (