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

53 KiB
Raw Blame History

持仓计价方案设计文档

一、背景与需求

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 数据存储设计

数据库字段:

-- 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 字段

计算逻辑:

// 所有计算统一为人民币
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统一人民币存储方案

选择理由:

  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. 查询需要更新的持仓

    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 表结构

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;
        // ... 其他字段
    }
}

字段验证规则:

  • 如果 assetTypestock/fund/bond
    • symbolname 必填
    • market 可选(但建议填写)
  • 如果 assetTypecash
    • symbolnamemarket 不需要

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 核心要点

  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 组件管理表单状态
    • 使用 useMemodebounce 优化搜索性能
    • 使用状态管理控制字段显示/隐藏
  2. 后端

    • 实现搜索接口,支持关键词和类型过滤
    • 使用数据库索引优化搜索性能
    • 实现搜索结果缓存(可选)
  3. 数据准备

    • 准备资产数据库(股票、基金、债券)
    • 包含:代码、名称、市场、类型等信息
    • 定期更新资产数据

文档版本v1.1
最后更新2024年
作者AI Assistant
更新说明采用方案A搜索优先 + 智能切换)设计