Files
invest-mind-store/packages/design-document/机生文档/持仓计价方案设计文档.md
2026-02-11 16:01:42 +08:00

1472 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 持仓计价方案设计文档
## 一、背景与需求
### 1.1 产品定位
投资记账软件,用于记录用户分散在不同市场、不同券商下的所有资产,统计总市值、投资收益率、年化收益率等。
### 1.2 新建持仓设计决策
#### 1.2.1 设计方案选择
**问题:** 新建持仓时,应该先输入并联想,还是先选择资产类型再搜索?
**决策:** 采用**方案A搜索优先 + 智能切换**的设计。
**方案对比:**
| 方案 | 优点 | 缺点 | 适用场景 |
|------|------|------|----------|
| **方案A搜索优先 + 智能切换** | ✅ 灵活,支持多种创建方式<br>✅ 容错性强,搜索失败可切换<br>✅ 用户体验流畅 | ⚠️ 实现复杂度稍高 | **推荐采用** |
| 方案B资产类型优先 + 条件搜索 | ✅ 逻辑清晰<br>✅ 搜索范围精准 | ⚠️ 不够灵活<br>⚠️ 必须先选择类型 | 备选方案 |
#### 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 (
<Form form={form}>
{/* 搜索框(现金和其他类型时隐藏) */}
{!showManualInput && (
<Form.Item label="搜索资产(可选)">
<AutoComplete
value={searchKeyword}
options={searchResults.map(asset => ({
value: `${asset.symbol} - ${asset.name}`,
label: (
<div>
<div>
<strong>{asset.symbol}</strong> - {asset.name}
</div>
<div style={{ fontSize: '12px', color: '#999' }}>
{getMarketName(asset.market)} - {getAssetTypeName(asset.assetType)}
</div>
</div>
),
asset: asset,
}))}
onSearch={handleSearch}
onSelect={(value, option) => handleSelectAsset(option.asset)}
placeholder={getSearchPlaceholder()}
allowClear
disabled={!!selectedAsset}
notFoundContent={
searchKeyword && !isSearching ? (
<div>
<div>未找到匹配的资产</div>
<Button
type="link"
size="small"
onClick={() => setShowManualInput(true)}
>
点击手动输入
</Button>
</div>
) : null
}
/>
{selectedAsset && (
<div style={{ marginTop: 8 }}>
<Tag color="blue">{selectedAsset.name}</Tag>
<Button
type="link"
size="small"
onClick={handleResetSearch}
>
重新选择
</Button>
</div>
)}
</Form.Item>
)}
{/* 资产类型 */}
<Form.Item name="assetType" label="资产类型" required>
<Select
onChange={handleAssetTypeChange}
disabled={!!selectedAsset}
>
<Option value="stock">股票</Option>
<Option value="fund">基金</Option>
<Option value="bond">债券</Option>
<Option value="cash">现金</Option>
<Option value="other">其他</Option>
</Select>
</Form.Item>
{/* 市场和券商(股票/基金/债券显示) */}
{(assetType === 'stock' || assetType === 'fund' || assetType === 'bond') && (
<>
<Form.Item name="market" label="市场">
<Select disabled={!!selectedAsset}>
<Option value="sh">A股-上海</Option>
<Option value="sz">A股-深圳</Option>
<Option value="hk">港股</Option>
<Option value="us">美股</Option>
</Select>
</Form.Item>
<Form.Item name="brokerId" label="券商" required>
<Select>
{/* 券商列表 */}
</Select>
</Form.Item>
<Form.Item name="symbol" label="资产代码" required>
<Input
placeholder="如600519、00700、AAPL"
disabled={!!selectedAsset}
/>
</Form.Item>
<Form.Item name="name" label="资产名称" required>
<Input
placeholder="如:贵州茅台"
disabled={!!selectedAsset}
/>
</Form.Item>
</>
)}
{/* 券商(现金显示) */}
{assetType === 'cash' && (
<Form.Item name="brokerId" label="券商" required>
<Select>
{/* 券商列表 */}
</Select>
</Form.Item>
)}
{/* 价格和数量 */}
<Form.Item name="costPrice" label="成本价(人民币)" required>
<InputNumber
addonBefore="¥"
precision={2}
placeholder="输入成本价(人民币)"
/>
</Form.Item>
<Form.Item name="shares" label="持股数量" required>
<InputNumber
precision={4}
placeholder="输入持股数量"
/>
</Form.Item>
<Form.Item name="currentPrice" label="最新价(人民币)">
<InputNumber
addonBefore="¥"
precision={2}
placeholder="输入最新价(人民币)"
/>
</Form.Item>
{/* 其他选项 */}
<Form.Item name="currency" label="货币类型">
<Select defaultValue="CNY">
<Option value="CNY">人民币</Option>
<Option value="HKD">港币</Option>
<Option value="USD">美元</Option>
</Select>
</Form.Item>
<Form.Item name="autoPriceUpdate" label="自动更新价格">
<Switch />
</Form.Item>
<Alert
message="提示"
description={
<div>
<p>• 可以通过搜索框快速选择资产,系统会自动填充相关信息</p>
<p>• 成本价和最新价请使用人民币计价</p>
<p>• 如果持有港股/美股,请将港币/美元价格转换为人民币后输入</p>
<p>• 系统会自动更新价格(如果开启自动更新)</p>
</div>
}
type="info"
showIcon
/>
</Form>
);
};
```
#### 5.3.2 持仓列表展示
```typescript
const PositionList = () => {
const { positions, totalAssetCNY } = usePositions();
return (
<div>
{/* 总资产 */}
<div className="total-asset">
<h2>总资产</h2>
<div className="amount">
¥{totalAssetCNY.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</div>
</div>
{/* 持仓列表 */}
{positions.map(position => (
<Card key={position.positionId}>
<div className="position-info">
<div className="name">{position.name}</div>
<div className="meta">
<span>{position.symbol}</span>
{position.market && <span>{getMarketName(position.market)}</span>}
</div>
<div className="shares">
持股:{position.shares.toLocaleString()} 股
</div>
<div className="values">
<div className="market-value">
市值:¥{position.marketValue.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</div>
<div className="cost-value">
成本:¥{position.costValue.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</div>
<div className={`profit ${position.profit >= 0 ? 'positive' : 'negative'}`}>
盈亏:¥{position.profit.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})} ({position.profitPercent >= 0 ? '+' : ''}{position.profitPercent.toFixed(2)}%)
</div>
<div className="asset-percent">
占比:{position.assetPercent.toFixed(2)}%
</div>
</div>
</div>
</Card>
))}
</div>
);
};
```
---
## 六、总结
### 6.1 最终方案
采用**统一人民币存储方案**,所有价格统一使用人民币计价,简化用户操作和系统实现。
### 6.2 核心要点
1. **用户输入**:所有价格统一使用人民币
2. **数据存储**:数据库中所有价格字段统一为人民币
3. **系统处理**:所有计算和展示统一为人民币
4. **价格更新**:系统自动获取原始货币价格并转换为人民币
### 6.3 优势
- ✅ 极简用户操作简单只需输入3个数字
- ✅ 统一:所有数据统一为人民币,计算和展示一致
- ✅ 无汇率问题:完全避免汇率存储、更新、同步等问题
- ✅ 符合记账场景:用户记录的是自己的资产情况
### 6.4 后续优化方向
1. 提供简单的汇率转换工具(可选)
2. 支持批量导入持仓从Excel等
3. 支持持仓变更历史记录
4. 支持持仓占比图表展示
---
---
## 七、方案A实施要点总结
### 7.1 核心优势
1. **灵活性**:支持三种创建方式,用户可以根据习惯选择
- 快速创建:直接搜索,自动填充
- 智能创建:先选类型再搜索,精准过滤
- 手动创建:完全手动输入,适合特殊资产
2. **容错性**:搜索失败可以无缝切换到手动输入
- 搜索无结果时,提供手动输入选项
- 网络错误时,允许用户继续操作
- 不会因为搜索功能影响基本功能
3. **智能联动**:搜索框和资产类型相互联动
- 选择资产类型后,搜索框自动过滤
- 搜索选择后,资产类型自动填充
- 字段根据选择动态显示/隐藏
4. **用户体验**:渐进式披露,不会一次性显示所有字段
- 先显示搜索框和资产类型
- 根据选择逐步显示对应字段
- 减少用户认知负担
### 7.2 实现要点
1. **搜索功能**
- 实现防抖搜索300ms
- 支持资产类型过滤
- 搜索结果按匹配度排序
- 处理搜索失败情况
2. **字段联动**
- 搜索选择后自动填充字段
- 字段禁用状态管理
- 资产类型变化时清空相关字段
3. **表单验证**
- 根据资产类型动态验证
- 搜索选择后跳过相关字段验证
- 手动输入时完整验证
4. **用户体验优化**
- 搜索框占位符动态变化
- 搜索状态提示(加载中、无结果等)
- 提供"重新选择"和"手动输入"选项
### 7.3 技术实现建议
1. **前端**
- 使用 `AutoComplete` 组件实现搜索
- 使用 `Form` 组件管理表单状态
- 使用 `useMemo` 和 `debounce` 优化搜索性能
- 使用状态管理控制字段显示/隐藏
2. **后端**
- 实现搜索接口,支持关键词和类型过滤
- 使用数据库索引优化搜索性能
- 实现搜索结果缓存(可选)
3. **数据准备**
- 准备资产数据库(股票、基金、债券)
- 包含:代码、名称、市场、类型等信息
- 定期更新资产数据
---
**文档版本**v1.1
**最后更新**2024年
**作者**AI Assistant
**更新说明**采用方案A搜索优先 + 智能切换)设计