基金基线切换实战:DCA 引擎的"新旧共存"设计

一、背景:DCA 模拟 → 实际持仓

venxine.vip 的基金数据管线最初基于 DCA 模拟:从某个起始日期(如 2026-06-06)开始,按定投计划逐日累加份额,用每日净值计算收益。

7 月 1 日,用户完成了实际建仓,提供了真实持仓数据。需求很明确:

7 月 1 日的历史数据不能改。从 7 月 2 日开始,用实际持仓作为新基线计算收益。

这意味着基金计算引擎需要同时维护两份基线:

0606 基线DCA 模拟    7/1 及之前
0701 基线实际持仓    7/2 及之后

二、DCA 引擎的基线选择逻辑

核心函数是 _demo2_calc_fund_pl(),它接收一个日期,选择对应的基线文件,然后计算该日的持仓和收益。

基线选择逻辑概览:

# app.py — _demo2_calc_fund_pl() 内部
wn = date.isocalendar()[1]

if month == 7 and day >= 2:
    baseline_file = 'baseline-0701.json'    # ← 新基线(实际持仓)
elif month > 7:
    baseline_file = 'baseline-0701.json'    # ← 7 月以后也用新基线
elif wn >= 24:
    baseline_file = 'baseline-0606.json'    # ← 旧基线(DCA 模拟)
else:
    baseline_file = 'baseline-0530.json'    # ← 更早的基线

关键设计点:if month == 7 and day >= 2 而不是 if month >= 7

如果写成 if month >= 7,7 月 1 日也会用新基线,但 7/1 的历史数据应该保持不变。所以必须是 == 7 and >= 2,精确到天。

先说清楚这段逻辑的局限:if month == 7 and day >= 2 / elif month > 7 / elif wn >= 24 把月、日、ISO 周号混在一起判断,是为这一次迁移硬写的,跨到明年就会错乱(明年 6 月的周号又会 >= 24)。它现在能用,是因为这个项目的基线切换是低频事件,一年切一两回,写死反而直观。

真要频繁切换,更稳的做法是把"生效日期 → 基线文件"做成一张有序表,按查询日期往前找最近的一条:

from datetime import date

BASELINES = [
    ("2026-05-30", "baseline-0530.json"),
    ("2026-06-06", "baseline-0606.json"),
    ("2026-07-02", "baseline-0701.json"),  # 7/2 起生效,7/1 仍走 0606
]

def pick_baseline(d):          # d 是 date 对象
    chosen = BASELINES[0][1]
    for eff, f in BASELINES:
        if d >= date.fromisoformat(eff):
            chosen = f
    return chosen

这样加一次基线只是往表里追一行,不用再碰 if/elif,也不会明年就失效。本次没上这套,纯粹是就切这一回、杀鸡不必用牛刀,但值得记一笔。

三、基线文件的结构

baseline-0701.json 包含每只基金的实际持仓:

{
  "008086": {
    "name": "华夏5G",
    "shares": 8538.00,
    "cost_basis": 3.7709,
    "total_cost": 32195.94
  },
  "017853": {
    "name": "云计算",
    "shares": 19519.07,
    "cost_basis": 1.6547,
    "total_cost": 32298.21
  }
}

DCA 引擎读取基线后,从基线日期开始逐日累加定投份额,计算每日收益:

当日收益 = (基线份额 + 累计定投份额) × (当日净值 - 前日净值)

这个公式确保: - 7/1:用 0606 基线 + 累积到 7/1 的定投 → 历史数据不变 - 7/2:用 0701 基线 + 7/2 当天的定投 → 起点改为实际持仓

四、数据验证:新旧两套数据的一致性

迁移完成后必须验证,两套基线在同一天(7/1)的数据必须完全一致:

验证项:
├── 份额对齐? → baseline-0606 累积至 7/1 的份额 = 用户实际建仓份额
├── 总金额对齐? → funddata 页面金额 = 用户 MD 文件中的金额
├── funddata 不变? → 7/1 页面刷新后数据完全不变
└── fundreturns 不变? → /api/fund-pl 返回 7/1 数据不变

验证结果:

基金 基线累积份额 用户实际份额 偏差
008086·5G 8,538.00 8,538.00 ✅ 0
017853·云 19,519.07 19,519.07 ✅ 0
016370·信澳5G 2,455.32 2,455.32 ✅ 0
... ... ... ✅ 全部一致

全部 7 只 A 股基金的 DCA 累积份额与用户实际持仓完全一致,说明 DCA 模拟的参数(起始日期、定投金额)精准还原了真实交易。

五、四条输出路径的一致性

基线切换影响 4 条数据输出路径,全部需要验证:

daily-pl.json  →  _demo2_calc_fund_pl()  →  ┌→ funddata 月视图
                                             ├→ funddata 周视图
                                             ├→ funddata 日视图
                                             └→ /api/fund-pl → fundreturns 图表

修改后逐页验证:

路径 7/1 数据 7/2 数据 状态
funddata/月视图 不变(旧基线) 新基线计算
funddata/周视图 不变 新基线计算
funddata/日视图 不变 新基线计算
fundreturns 图表 /api/fund-pl 不变 新基线计算

六、设计原则总结

原则 1:历史数据不可变

任何基线切换都不能修改已展示过的历史数据。用户看到的 7/1 数字,在切换前后必须完全一致。

原则 2:条件判断精确到天

# ❌ 粗暴:7/1 也会被改
if month >= 7:
    baseline = '0701'

# ✅ 精确:7/1 不动,7/2+ 切新
if month == 7 and day >= 2:
    baseline = '0701'

原则 3:统一计算引擎,多路径输出

所有视图(月/周/日/图表)共享同一个 _demo2_calc_fund_pl() 引擎。不允许多个地方各自实现 DCA 逻辑,那是数据不一致的温床。

原则 4:验证先于部署

改完基线逻辑后,先跑验证脚本确认份额对齐、金额对齐、历史数据不变,再重启服务。不要"部署了再看对不对"。