53 KiB
持仓计价方案设计文档
一、背景与需求
1.1 产品定位
投资记账软件,用于记录用户分散在不同市场、不同券商下的所有资产,统计总市值、投资收益率、年化收益率等。
1.2 新建持仓设计决策
1.2.1 设计方案选择
问题: 新建持仓时,应该先输入并联想,还是先选择资产类型再搜索?
决策: 采用方案A:搜索优先 + 智能切换的设计。
方案对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 方案A:搜索优先 + 智能切换 | ✅ 灵活,支持多种创建方式 ✅ 容错性强,搜索失败可切换 ✅ 用户体验流畅 |
⚠️ 实现复杂度稍高 | 推荐采用 |
| 方案B:资产类型优先 + 条件搜索 | ✅ 逻辑清晰 ✅ 搜索范围精准 |
⚠️ 不够灵活 ⚠️ 必须先选择类型 |
备选方案 |
1.2.2 方案A:搜索优先 + 智能切换(最终采用)
核心设计思路:
-
搜索框优先,但不强制
- 搜索框始终显示在顶部
- 用户可以选择搜索或直接手动输入
- 搜索失败时,可以无缝切换到手动输入
-
智能联动
- 选择资产类型后,搜索框自动过滤该类型
- 搜索时,优先显示匹配的资产类型
- 字段根据选择动态显示/隐藏
-
渐进式披露
- 第一步:搜索框 + 资产类型选择
- 第二步:根据选择显示对应字段
- 第三步:填写价格和数量
弹窗布局(从上到下):
┌─────────────────────────────────────────┐
│ 搜索框(始终显示,支持全局搜索) │
│ [🔍 输入代码/名称搜索...] │
│ ↓ 联想结果列表(最多10条) │
├─────────────────────────────────────────┤
│ 资产类型选择(Tab切换,可选) │
│ [股票] [基金] [债券] [现金] [其他] │
├─────────────────────────────────────────┤
│ 根据资产类型显示字段: │
│ - 股票/基金/债券:市场、券商、代码、名称 │
│ - 现金:券商 │
├─────────────────────────────────────────┤
│ 价格和数量: │
│ - 成本价(人民币)、数量、最新价(可选) │
├─────────────────────────────────────────┤
│ 其他选项: │
│ - 货币类型、是否自动更新价格 │
└─────────────────────────────────────────┘
三种创建方式:
方式1:快速创建(通过搜索)
- 用户在搜索框输入资产代码或名称
- 系统联想出结果,用户选择
- 系统自动填充:资产类型、市场、资产代码、资产名称
- 用户只需填写:券商、成本价、数量、最新价
方式2:智能创建(先选类型再搜索)
- 用户先选择资产类型(如:股票)
- 搜索框自动过滤,只搜索该类型
- 用户输入代码或名称搜索
- 选择结果后自动填充相关信息
方式3:手动创建(不通过搜索)
- 用户选择资产类型
- 跳过搜索,直接手动填写所有字段
- 或搜索无结果时,自动切换到手动输入模式
设计原则:
- ✅ 灵活性:三种方式默认都支持,用户可以根据习惯选择
- ✅ 智能联动:搜索框根据资产类型自动过滤
- ✅ 容错性:搜索失败可以无缝切换到手动输入
- ✅ 渐进式披露:根据用户选择逐步显示字段,不会一次性显示所有字段
- ✅ 自动填充:通过搜索选择后,相关字段自动填充且禁用,防止误修改
- ✅ 可重置:用户可以点击"重新选择"清空搜索结果,恢复手动填写
关键交互细节:
-
搜索框智能过滤
- 用户选择"股票" → 搜索框自动过滤,只搜索股票
- 用户选择"基金" → 搜索框自动过滤,只搜索基金
- 用户未选择 → 搜索框全局搜索所有类型(股票、基金、债券)
-
搜索失败处理
- 搜索无结果 → 显示"未找到匹配的资产,点击手动输入"
- 点击后 → 隐藏搜索框(可选),显示完整表单
- 用户可以继续手动填写所有信息
-
字段联动
- 通过搜索选择 → 自动填充:资产类型、市场、资产代码、资产名称(禁用状态)
- 手动输入 → 所有字段可编辑
- 切换资产类型 → 清空相关字段,重新显示对应字段
-
搜索框状态
- 未选择:显示占位符"输入代码/名称搜索..."
- 已选择:显示选中的资产名称,右侧显示"重新选择"按钮
- 点击"重新选择" → 清空选择,恢复搜索状态
1.2 核心需求
- 用户记录持仓数据(成本价、最新价、持股数量)
- 每日收盘后自动更新持仓股票的最新价格
- 计算当日资产总值、净值
- 呈现所有持仓的总市值变化情况、各持仓占比
- 形成图表,方便用户统计资产总市值、投资收益率、年化收益率
- 最终总资产、总成本和各持仓市值占比需要使用人民币进行计价
1.3 用户操作场景
- 用户每次变更持仓时,只需要更新:成本价、最新价、持股数量 三个数值
- 用户可能在不同日期多次买入/卖出同一只股票
- 用户可能持有A股、港股、美股等不同市场的资产
1.4 核心问题
如何统一处理不同市场的货币计价问题,确保最终展示和计算统一为人民币?
二、方案对比
2.1 方案A:统一人民币存储方案(推荐采用)
2.1.1 核心思路
- 用户输入:所有价格统一使用人民币计价
- 数据存储:数据库中所有价格字段统一存储为人民币
- 系统处理:所有计算和展示统一为人民币
- 无需汇率:完全避免汇率问题
2.1.2 数据存储设计
数据库字段:
-- 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 用户操作流程
创建持仓:
- 用户输入成本价(人民币)
- 用户输入最新价(人民币)
- 用户输入持股数量
- 用户选择市场(CNY/HKD/USD,仅标识)
更新持仓:
- 用户修改成本价(人民币)
- 用户修改最新价(人民币)
- 用户修改持股数量
价格更新:
- 手动更新:用户直接在界面上更新人民币价格
- 自动更新:系统获取原始货币价格 → 转换为人民币 → 更新到数据库
2.1.4 系统处理逻辑
价格自动更新流程:
每日收盘后:
1. 获取原始货币价格(如港币160)
2. 获取当前汇率(如0.9)
3. 转换为人民币(160 × 0.9 = ¥144)
4. 更新到数据库的 current_price 字段
计算逻辑:
// 所有计算统一为人民币
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 数据存储设计
数据库字段:
-- 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(统一人民币存储方案)
选择理由:
- 符合记账软件定位:用户记录的是自己的资产情况,通常知道人民币成本
- 简化用户操作:用户只需输入3个数字(成本价、最新价、数量),无需考虑汇率
- 简化系统实现:无需存储和管理汇率,计算逻辑简单
- 满足最终需求:总资产、总成本、持仓占比都统一为人民币
3.2 关于价格更新的处理
方式1:系统自动更新(推荐)
- 系统获取原始货币价格
- 自动转换为人民币
- 更新到数据库
方式2:用户手动更新
- 用户直接在界面上更新人民币价格
四、产品需求文档
4.1 新建持仓
4.1.1 功能描述
用户创建新的持仓记录,支持两种创建方式:
- 快速创建:通过搜索框搜索资产代码或名称,选择后自动填充基本信息
- 手动创建:手动选择资产类型,然后填写所有信息
两种方式默认都支持,用户可以根据习惯选择。
4.1.2 用户角色
所有注册用户
4.1.3 功能入口
- 持仓列表页面 → "新增持仓" 按钮
- 顶部导航栏 → "资产" → "新增持仓"
4.1.4 弹窗设计
弹窗布局:
- PC端:居中弹窗(Modal),宽度:600px
- 移动端:底部弹窗(Drawer),高度:80%
弹窗结构(从上到下):
-
搜索框(顶部,始终显示)
- 位置:弹窗最顶部
- 功能:支持全局搜索资产代码或名称
- 交互:
- 用户输入代码(如:600519、00700、AAPL)或名称(如:贵州茅台、腾讯控股)
- 系统实时联想,显示匹配结果
- 联想结果包含:资产代码、资产名称、市场、资产类型
- 用户选择后,自动填充:资产类型、市场、资产代码、资产名称
- 填充后,搜索框显示选中的资产名称(可点击重新搜索)
-
资产类型选择(搜索框下方)
- 如果通过搜索选择了资产,此字段自动填充且禁用
- 如果未通过搜索选择,用户手动选择:股票/基金/债券/现金/其他
-
市场和券商(根据资产类型显示)
- 股票/基金/债券:显示"市场"和"券商"字段
- 现金:只显示"券商"字段,不显示"市场"字段
- 如果通过搜索选择了资产,"市场"字段自动填充
-
资产代码和资产名称(根据资产类型显示)
- 股票/基金/债券:显示"资产代码"和"资产名称"字段
- 现金:不显示这两个字段
- 如果通过搜索选择了资产,这两个字段自动填充且禁用
-
价格和数量信息
- 成本价(人民币):数字输入,单位:元(必填)
- 持股数量:数字输入(必填)
- 最新价(人民币):数字输入,单位:元(可选)
-
其他选项
- 货币类型:下拉选择(人民币/港币/美元),默认:人民币
- 是否自动更新价格:开关,默认:关闭
4.1.5 输入字段详细说明
搜索框:
- 功能:全局搜索资产代码或名称
- 搜索范围:股票、基金、债券(不包含现金)
- 联想结果格式:
600519 - 贵州茅台 (A股-上海) 00700 - 腾讯控股 (港股) AAPL - 苹果公司 (美股) - 选择后行为:
- 自动填充:资产类型、市场、资产代码、资产名称
- 搜索框显示选中的资产名称(可点击重新搜索)
- 相关字段变为禁用状态(可点击"重新选择"按钮恢复)
资产类型:
- 选项:股票、基金、债券、现金、其他
- 默认值:无(必须选择)
- 联动规则:
- 选择"股票/基金/债券":显示市场、券商、资产代码、资产名称字段
- 选择"现金":只显示券商字段,隐藏市场、资产代码、资产名称字段
市场:
- 选项:A股-上海、A股-深圳、港股、美股等
- 显示条件:资产类型为股票/基金/债券时显示
- 自动填充:如果通过搜索选择了资产,自动填充对应市场
券商:
- 选项:从用户已添加的券商中选择
- 显示条件:所有资产类型都显示
- 必填:是
资产代码:
- 输入方式:文本输入
- 显示条件:资产类型为股票/基金/债券时显示
- 自动填充:如果通过搜索选择了资产,自动填充且禁用
- 示例:600519、00700、AAPL
资产名称:
- 输入方式:文本输入
- 显示条件:资产类型为股票/基金/债券时显示
- 自动填充:如果通过搜索选择了资产,自动填充且禁用
- 示例:贵州茅台、腾讯控股、苹果公司
成本价(人民币):
- 输入方式:数字输入,支持小数
- 单位:元
- 必填:是
- 说明:用户从券商系统看到的成本价,如果是港股/美股,需要转换为人民币后输入
- 示例:1600.00
持股数量:
- 输入方式:数字输入,支持小数
- 必填:是
- 说明:当前持仓的股数/份数
- 示例:100
最新价(人民币):
- 输入方式:数字输入,支持小数
- 单位:元
- 必填:否
- 说明:当前市场价格,如果是港股/美股,需要转换为人民币后输入
- 示例:1850.00
货币类型:
- 选项:人民币、港币、美元
- 默认值:人民币
- 说明:仅用于标识市场,不影响计算和展示
是否自动更新价格:
- 类型:开关
- 默认值:关闭
- 说明:开启后,系统每日收盘后自动更新最新价
4.1.5 业务规则
-
搜索规则:
- 搜索框支持全局搜索股票、基金、债券(不包含现金、其他)
- 搜索范围:资产代码、资产名称
- 搜索方式:模糊匹配,不区分大小写
- 联想结果最多显示10条
- 联想结果按匹配度排序(完全匹配 > 前缀匹配 > 包含匹配)
- 智能过滤:如果用户选择了资产类型,搜索框自动过滤,只搜索该类型
- 防抖处理:输入后300ms才触发搜索,避免频繁请求
-
资产类型联动规则:
- 用户选择资产类型后,搜索框自动过滤该类型
- 搜索框占位符动态变化:"搜索股票..."、"搜索基金..."等
- 如果用户清空资产类型选择,搜索框恢复全局搜索
- 如果通过搜索选择了资产,资产类型自动填充且禁用
-
自动填充规则:
- 通过搜索选择资产后,自动填充:资产类型、市场、资产代码、资产名称、货币类型
- 自动填充的字段变为禁用状态,显示锁定图标,防止误修改
- 用户可以通过"重新选择"按钮清空并重新搜索
- 如果用户手动修改了资产类型,清空自动填充的字段(资产类型、市场、代码、名称)
-
字段显示规则:
- 资产类型为股票/基金/债券:
- 显示:市场、券商、资产代码、资产名称、成本价、持股数量、最新价
- 资产类型为现金:
- 显示:券商、成本价、持股数量、最新价
- 隐藏:市场、资产代码、资产名称
- 资产类型为其他:
- 显示:券商、资产代码、资产名称、成本价、持股数量、最新价
- 隐藏:市场(可选)
- 资产类型为股票/基金/债券:
-
搜索失败处理规则:
- 搜索无结果时,显示提示:"未找到匹配的资产,点击手动输入"
- 用户点击后,可以:
- 选择隐藏搜索框,显示完整表单
- 或保留搜索框,但允许用户手动填写所有字段
- 用户可以继续完成创建流程
-
唯一性约束:
- 同一用户、同一券商、同一资产代码、同一市场、同一资产类型只能有一条持仓
- 如果已存在,提示用户"该持仓已存在,请直接编辑现有持仓"
- 现金类型:同一用户、同一券商只能有一条现金持仓
-
价格输入规则:
- 成本价和最新价必须使用人民币计价
- 如果持有港股/美股,用户需要自己转换为人民币后输入
- 系统不提供汇率转换工具(简化设计)
- 价格支持小数,精度:2位小数
-
数量规则:
- 持股数量必须大于0
- 支持小数(如基金份额)
- 精度:4位小数
-
价格更新规则:
- 如果开启自动更新,系统每日收盘后自动更新最新价
- 更新时:获取原始货币价格 → 转换为人民币 → 更新
- 现金类型不支持自动更新价格
-
表单验证规则:
- 搜索选择资产后,资产类型、市场、资产代码、资产名称自动填充,无需验证
- 手动创建时,资产类型、券商、成本价、持股数量为必填
- 股票/基金/债券类型,市场、资产代码、资产名称为必填
- 现金类型,不需要市场、资产代码、资产名称
- 其他类型,资产代码、资产名称可选
4.1.6 用户提示
表单提示信息:
💡 提示:
• 成本价和最新价请使用人民币计价
• 如果持有港股/美股,请将港币/美元价格转换为人民币后输入
• 系统会自动更新价格(如果开启自动更新)
• 成本价是您从券商系统看到的加权平均成本价
4.1.7 交互流程
方式1:快速创建(通过搜索)
- 用户点击"新增持仓"按钮
- 弹出表单对话框(PC端居中,移动端底部)
- 用户在顶部搜索框输入资产代码或名称(如:600519 或 贵州茅台)
- 系统实时联想,显示匹配结果列表(最多10条)
- 用户从联想结果中选择一个资产
- 系统自动填充:
- 资产类型(自动填充且禁用)
- 市场(自动填充)
- 资产代码(自动填充且禁用)
- 资产名称(自动填充且禁用)
- 搜索框显示选中的资产名称,右侧显示"重新选择"按钮
- 用户选择券商
- 用户输入成本价(人民币)
- 用户输入持股数量
- 用户输入最新价(可选)
- 用户设置其他选项(货币类型、自动更新价格)
- 用户点击"保存"按钮
- 系统验证数据
- 系统保存持仓
- 刷新持仓列表
- 显示成功提示
方式2:智能创建(先选类型再搜索)
- 用户点击"新增持仓"按钮
- 弹出表单对话框(PC端居中,移动端底部)
- 用户先选择资产类型(如:股票)
- 搜索框自动过滤,只搜索该类型的资产
- 用户在搜索框输入资产代码或名称
- 系统实时联想,显示匹配结果列表(仅显示该类型)
- 用户从联想结果中选择一个资产
- 系统自动填充相关信息(同方式1)
- 用户填写剩余字段(券商、成本价、数量、最新价等)
- 用户点击"保存"按钮
- 系统验证数据并保存
方式3:手动创建(不通过搜索)
- 用户点击"新增持仓"按钮
- 弹出表单对话框(PC端居中,移动端底部)
- 用户选择资产类型(股票/基金/债券/现金/其他)
- 根据资产类型显示对应字段:
- 股票/基金/债券:显示市场、券商、资产代码、资产名称
- 现金:只显示券商
- 用户填写必填字段:
- 选择市场(股票/基金/债券必填)
- 选择券商(所有类型必填)
- 输入资产代码(股票/基金/债券必填)
- 输入资产名称(股票/基金/债券必填)
- 输入成本价(所有类型必填)
- 输入持股数量(所有类型必填)
- 用户填写可选字段:
- 输入最新价(可选)
- 选择货币类型(默认人民币)
- 设置是否自动更新价格(默认关闭)
- 用户点击"保存"按钮
- 系统验证数据
- 系统保存持仓
- 刷新持仓列表
- 显示成功提示
搜索失败处理流程:
- 用户输入搜索关键词
- 系统搜索无结果
- 显示提示:"未找到匹配的资产,点击手动输入"
- 用户点击"手动输入"
- 搜索框变为可选(可隐藏或保留)
- 显示完整表单,用户可以手动填写所有字段
- 用户可以继续完成创建流程
搜索框交互细节:
-
输入时:
- 用户输入字符,系统实时搜索(防抖300ms)
- 显示联想结果列表(最多10条)
- 结果包含:资产代码、资产名称、市场、资产类型
- 高亮匹配的字符
- 如果选择了资产类型,只显示该类型的结果
-
选择后:
- 搜索框显示选中的资产名称(可点击重新搜索)
- 自动填充相关字段(资产类型、市场、代码、名称)
- 填充的字段变为禁用状态,显示锁定图标
- 搜索框右侧显示"重新选择"按钮
-
重新选择:
- 点击"重新选择"按钮或点击搜索框
- 清空已填充的字段
- 恢复字段为可编辑状态
- 搜索框恢复为空,可重新输入
- 如果之前选择了资产类型,搜索框恢复为该类型的过滤状态
-
资产类型联动:
- 用户选择资产类型(如:股票)
- 搜索框自动过滤,只搜索股票类型
- 搜索框占位符变为"搜索股票..."
- 如果用户清空资产类型选择,搜索框恢复全局搜索
-
搜索框状态:
- 未选择:显示占位符"输入代码/名称搜索..."或"搜索[资产类型]..."
- 已选择:显示选中的资产名称,右侧显示"重新选择"按钮
- 搜索中:显示加载动画
- 无结果:显示"未找到匹配的资产,点击手动输入"
4.1.8 异常处理
搜索相关:
- 搜索无结果:
- 显示"未找到匹配的资产"
- 提供"点击手动输入"按钮
- 点击后可以隐藏搜索框(可选),显示完整表单
- 用户可以继续手动填写所有字段完成创建
- 搜索网络错误:
- 显示"搜索失败,请稍后重试"
- 允许用户继续手动填写
- 提供"重试"按钮
- 搜索超时:
- 显示"搜索超时,请稍后重试"
- 允许用户继续手动填写
- 提供"重试"按钮
- 资产类型过滤后无结果:
- 提示"当前类型下未找到匹配的资产"
- 提供"清除类型过滤"或"切换为手动输入"选项
表单验证:
- 持仓已存在:提示"该持仓已存在,请直接编辑现有持仓"
- 必填字段为空:表单验证提示,高亮显示未填写的必填字段
- 价格格式错误:提示"请输入有效的价格(支持小数)"
- 数量小于等于0:提示"持股数量必须大于0"
- 资产类型未选择:提示"请选择资产类型或通过搜索选择资产"
数据一致性:
- 搜索选择的资产与手动选择的资产类型不一致:
- 如果用户通过搜索选择了股票,但手动将资产类型改为基金
- 清空自动填充的字段,提示"资产类型已更改,请重新填写相关信息"
- 搜索选择的资产市场与手动选择的市场不一致:
- 提示"搜索选择的资产市场与手动选择的市场不一致,请确认"
- 允许用户选择使用哪个市场
4.2 编辑持仓
4.2.1 功能描述
用户修改现有持仓的信息,主要是更新成本价、最新价、持股数量。
4.2.2 用户角色
所有注册用户(只能编辑自己的持仓)
4.2.3 功能入口
- 持仓列表页面 → 点击持仓卡片 → "编辑" 按钮
- 持仓详情页面 → "编辑" 按钮
4.2.4 可编辑字段
可编辑字段:
- 券商:下拉选择(可修改)
- 成本价(人民币):数字输入,单位:元
- 说明:修改加权平均成本价
- 最新价(人民币):数字输入,单位:元
- 说明:修改当前市场价格
- 持股数量:数字输入
- 说明:修改持仓数量
- 货币类型:下拉选择(可修改)
- 是否自动更新价格:开关(可修改)
- 状态:下拉选择(活跃/暂停/退市)
不可编辑字段:
- 资产类型
- 资产代码
- 资产名称
- 市场(部分系统可能允许修改)
4.2.5 业务规则
-
价格更新规则:
- 更新最新价时,系统自动将旧价格保存到
previous_price字段 - 用于显示涨跌颜色(红色/绿色)
- 更新最新价时,系统自动将旧价格保存到
-
数量更新规则:
- 数量可以增加(加仓)或减少(减仓)
- 数量不能小于0
-
成本价更新规则:
- 如果用户修改了成本价,说明可能是加仓或减仓
- 系统记录变更历史(可选功能)
4.2.6 用户提示
表单提示信息:
💡 提示:
• 修改成本价、最新价、数量后,系统会自动重新计算盈亏和占比
• 成本价和最新价请使用人民币计价
• 如果修改了成本价或数量,建议检查成本价是否正确(加权平均)
4.2.7 交互流程
- 用户点击"编辑"按钮
- 弹出编辑表单对话框(预填充现有数据)
- 用户修改字段
- 用户点击"保存"按钮
- 系统验证数据
- 系统更新持仓
- 刷新持仓列表
- 显示成功提示
4.2.8 异常处理
- 持仓不存在:提示"持仓不存在"
- 无权限:提示"您没有权限编辑此持仓"
- 数据验证失败:表单验证提示
4.3 价格自动更新
4.3.1 功能描述
系统每日收盘后自动更新开启了"自动更新价格"的持仓的最新价。
4.3.2 触发时机
- 每个工作日下午4点(A股收盘后)
- 可配置定时任务
4.3.3 更新流程
-
查询需要更新的持仓:
SELECT * FROM positions WHERE auto_price_update = true AND status = 'active' AND asset_type IN ('stock', 'fund') -
获取市场价格:
- 调用市场数据API获取原始货币价格
- 根据
currency字段判断币种
-
转换为人民币:
- 如果
currency = 'CNY':直接使用价格 - 如果
currency != 'CNY':- 获取当前汇率
- 转换为人民币:
人民币价格 = 原始货币价格 × 汇率
- 如果
-
更新数据库:
- 将旧价格保存到
previous_price - 更新
current_price为人民币价格 - 更新
updated_at时间戳
- 将旧价格保存到
4.3.4 异常处理
- 市场数据获取失败:记录日志,跳过该持仓
- 汇率获取失败:使用上次汇率或跳过更新
- 价格异常:记录日志,不更新
五、技术实现细节
5.1 数据库设计
5.1.1 表结构
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 索引设计
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 搜索资产接口
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参数,只搜索该类型的资产 - 如果没有提供,搜索所有类型(股票、基金、债券)
- 搜索结果按匹配度排序,同类型优先
- 如果提供了
后端实现示例:
@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 创建持仓接口
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 更新持仓接口
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 查询持仓列表接口
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 价格自动更新服务
@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 新建持仓表单
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 持仓列表展示
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 核心要点
- 用户输入:所有价格统一使用人民币
- 数据存储:数据库中所有价格字段统一为人民币
- 系统处理:所有计算和展示统一为人民币
- 价格更新:系统自动获取原始货币价格并转换为人民币
6.3 优势
- ✅ 极简:用户操作简单,只需输入3个数字
- ✅ 统一:所有数据统一为人民币,计算和展示一致
- ✅ 无汇率问题:完全避免汇率存储、更新、同步等问题
- ✅ 符合记账场景:用户记录的是自己的资产情况
6.4 后续优化方向
- 提供简单的汇率转换工具(可选)
- 支持批量导入持仓(从Excel等)
- 支持持仓变更历史记录
- 支持持仓占比图表展示
七、方案A实施要点总结
7.1 核心优势
-
灵活性:支持三种创建方式,用户可以根据习惯选择
- 快速创建:直接搜索,自动填充
- 智能创建:先选类型再搜索,精准过滤
- 手动创建:完全手动输入,适合特殊资产
-
容错性:搜索失败可以无缝切换到手动输入
- 搜索无结果时,提供手动输入选项
- 网络错误时,允许用户继续操作
- 不会因为搜索功能影响基本功能
-
智能联动:搜索框和资产类型相互联动
- 选择资产类型后,搜索框自动过滤
- 搜索选择后,资产类型自动填充
- 字段根据选择动态显示/隐藏
-
用户体验:渐进式披露,不会一次性显示所有字段
- 先显示搜索框和资产类型
- 根据选择逐步显示对应字段
- 减少用户认知负担
7.2 实现要点
-
搜索功能:
- 实现防抖搜索(300ms)
- 支持资产类型过滤
- 搜索结果按匹配度排序
- 处理搜索失败情况
-
字段联动:
- 搜索选择后自动填充字段
- 字段禁用状态管理
- 资产类型变化时清空相关字段
-
表单验证:
- 根据资产类型动态验证
- 搜索选择后跳过相关字段验证
- 手动输入时完整验证
-
用户体验优化:
- 搜索框占位符动态变化
- 搜索状态提示(加载中、无结果等)
- 提供"重新选择"和"手动输入"选项
7.3 技术实现建议
-
前端:
- 使用
AutoComplete组件实现搜索 - 使用
Form组件管理表单状态 - 使用
useMemo和debounce优化搜索性能 - 使用状态管理控制字段显示/隐藏
- 使用
-
后端:
- 实现搜索接口,支持关键词和类型过滤
- 使用数据库索引优化搜索性能
- 实现搜索结果缓存(可选)
-
数据准备:
- 准备资产数据库(股票、基金、债券)
- 包含:代码、名称、市场、类型等信息
- 定期更新资产数据
文档版本:v1.1
最后更新:2024年
作者:AI Assistant
更新说明:采用方案A(搜索优先 + 智能切换)设计