下载链接:https://download.csdn.net/download/weitingfu/93448039
开篇:那个每月定投日都要重算一遍的痛苦
你是否遇到过这种情况:每月定投日打开 Excel,手工录入净值、拖公式算目标金额、再一台台对行情决定「这期买多少」,一顿操作半小时,公式稍微拖错一格,整个仓位表全是 #REF!。
网上搜到的方案要么是商业 App 的营销话术,要么只教「无脑定额」,跟「按估值动态决定买多少」完全不沾边,更没人告诉你公式背后的边界条件怎么处理。
本文将从公式原理、代码实现、真实数据回放三个层面,完整拆解一个把 Excel「基金仓位管理」模型复刻成工具的定投助手,包含 8 个核心算法、可直接参考的引擎代码,以及 9 个真实踩坑案例。看完你至少能搞清楚一件事:为什么你的定投系统算出来的数字是错的。
一、先看全景:这个工具是什么
1.1 一句话定义
它是一个「按估值高低自动决定本期买多少」的基金定投管理器,而不是记账 App。
普通定投是「定期定额」:每月固定投 100 元,不管市场贵贱。
这个工具复刻的是「定期定值」:每月把仓位补到目标金额,涨多了少投甚至提示卖出,跌狠了多投。它天然自带低买高卖,而且每一分钱都能追溯到一条公式。

1.2 双通道架构
同一套计算引擎,两种打开方式:
graph TB
A[基金定投助手] --> B[桌面版 Tauri 2]
A --> C[单文件 HTML 网页版]
B --> B1[Rust 后端 + WebView2]
B --> B2[原生保存/打开对话框]
C --> C1[自包含单文件]
C --> C2[双击即用 无需安装]
B --> D[engine.cjs 价值平均法引擎]
C --> D
D --> E[computeFund 逐期递推]
D --> F[guidePosOf 指导仓位]
D --> G[xirrCalc 年化收益]
B --> H[localStorage / 应用本地存储]
C --> H
- 引擎层:
engine.cjs为 UMD 同构模块,当前 1324 行,浏览器直接<script src>同步加载,Node 端require可独立单测; - 展示层:单文件 HTML,当前 3718 行,只负责渲染与交互,不重复实现任何公式;
- 存储层:数据存浏览器
localStorage(主键fund-assistant-v1),桌面版走 Tauri 本地存储,导出走原生对话框。
💡 提示:公式唯一来源是引擎,展示层不允许再写一遍算法。这条约束后面会帮我省掉大量「两个页面数字不一致」的排查时间。
1.3 技术形态对比
| 形态 | 技术栈 | 数据存放 | 适用场景 |
|---|---|---|---|
| 桌面版 | Tauri 2 + Rust + WebView2 | 应用本地 | 主力使用,原生窗口与导出对话框 |
| 网页版 | 单文件 HTML(构建时内联引擎) | 浏览器 localStorage | 双击即用、快速分发、离线可用 |
二、功能全景:4 个页面,一套引擎
2.1 功能地图
graph LR
A[定投助手] --> B[总览页]
A --> C[基金列表页]
A --> D[定投明细页]
A --> E[使用说明页]
B --> B1[组合 KPI + 仓位图]
B --> B2[全局参数联动]
B --> B3[止盈止损横幅]
B --> B4[20 日轮动榜]
B --> B5[每日快照趋势]
C --> C1[收盘净值行内改]
C --> C2[置顶排序]
C --> C3[基金增删改]
D --> D1[逐期明细表]
D --> D2[今日行建议]
D --> D3[卖出与分红录入]
D --> D4[重启定投]
2.2 总览页:把「该干什么」摆到第一屏
总览页承担三件事:看结果、调参数、收信号。
- 结果区:组合市值、累计投入、累计收益、持仓收益率、各基金仓位图;
- 参数区:预计总投入
budget、市场估值星级star、股票基金投入比例stockRatio,以及利率/股息率/股债利差的参考基准bench; - 信号区:顶部横幅按优先级给出止盈、止损、高估提示;轮动榜展示跟踪指数近 20 个交易日涨幅前 5 名。




📌 总结:总览页的每个 KPI 都不是「记录出来」的,而是由参数实时算出来的。改一个基准值,整页数字连同每只基金的今日建议一起变。
2.3 基金列表页:改一个净值,全页联动
列表页的「收盘净值」是一个输入框,改完立刻触发全量重算:
sequenceDiagram
participant U as 用户
participant T as 列表页输入框
participant E as engine.cjs
participant S as localStorage
U->>T: 修改某只基金收盘净值
T->>E: setNav 归一化匹配基金代码
E->>E: 重算市值/收益/指导仓位/今日建议
E->>S: save 持久化
E->>U: 全页面联动刷新
置顶功能用 state.pinnedCodes 数组记录顺序,所有视图(总览表、基金列表、快捷 chips、仓位图)统一走同一个排序函数,避免「列表顺序在各页面不一致」这种偶发体验问题。

8种智能定投策略随意选择。

2.4 定投明细页:逐期账本 + 今日建议
明细页是 Engine 的「可视化调试器」,一行一期,列出:
| 列 | 含义 | 来源 |
|---|---|---|
| 日期 / 价格 / 份额 / 费用 | 当期交易记录 | 用户录入 |
| 目标金额 K | 这期「应该」有多少钱 | 引擎递推 |
| 累计市值 | 账上实际有多少钱 | 份额 × 现价 |
| 指导定投额 L | 本期该买多少(可负 = 该卖) | 引擎分流计算 |
| 截断标记 | 本期应投被目标额度闸门限制 | 引擎标记 |
| 收益率 | 现价 / 该期买价 − 1 | 逐行计算 |
2.5 支持的关键操作

- 记一笔卖出:填日期、份额、净值、费用,系统自动生成一条「同日净 0」的配对锚点行;

- 分红录入:区分现金分红与红利再投,红利再投直接记份额;
- 重启定投:先导出 CSV 存历史,再以底仓金额重建第 1 期;
- 导出备份:JSON 完整状态 + 真 xlsx(3 个 sheet),导入时做 schema 校验;
- 每日快照:记录组合状态,形成趋势图。

- 每种策略的详细介绍

三、核心算法 1:价值平均法递推引擎
3.1 灵魂公式只有一行
每只基金每一期,引擎算三个数:
目标金额 K(n) = 上一期 K + 本期增量(封顶 G6)
累计市值 J =(累计买入份额 − 已生效卖出份额)× 现价
指导定投额 L = 目标金额 K − 累计市值 J
L = K − J 是整个引擎的魂。K 是「应达目标」,J 是「当前市值」,一减就知道该买该卖。
3.2 逐期递推数据流
flowchart TB
A[第 n 期记录 日期/价格/份额/费用] --> B[本期增量 incr]
B --> C[目标金额 K = prevK + incr 封顶 G6]
A --> D[累计买入份额 += 本期份额]
A --> E[本期卖出逐笔消耗]
D --> F[累计市值 J = 净份额 × 现价]
E --> F
C --> G{策略语义分流}
F --> G
G -->|value 价值平均法| H[L = K - J 可负]
G -->|其余 7 种策略| I[L = 本期计划投入金额]
H --> J[额度闸门 capByTargetRoom]
I --> J
J --> K1[指导份额 = L / 现价]
3.3 K 列的四种分支
目标金额 K 看起来简单,实际有四个分支(引擎里的真实代码):
// engine.cjs · 目标金额 K 递推(节选)
const threshold = G6 - incr;
if(isReinvestRow || isSellRow){
K = prevK; // 红利再投行 / 卖出锚点行:不推进目标线
} else if(baseAmt > 0 && n === 1){
K = Math.min(baseAmt, G6); // 重启定投:第 1 期 = 底仓金额
} else if(baseAmt > 0){
K = Math.min(prevK + incr, G6); // 重启定投:底仓后按增量递推
} else {
const l = ladderOf(f);
if(l){
const step = hkHsLadderStepOf(f, n) || 0; // 沪港深阶梯基础增量
const coeff = (per > 0) ? (incr / per) : 1; // 策略系数 = 本期增量 / 每期定投额
K = Math.min(prevK + step * coeff, G6); // 阶梯分支同样消费策略系数
} else {
K = prevK < threshold ? Math.min(prevK + incr, G6) : G6; // 标准线性段
}
}
⚠️ 注意:线性段必须用「上一期 K + 本期增量」递推,不能用「期数 × 每期定投额」回乘。原因见 3.6 的踩坑案例,这是真实翻过的车。
3.4 沪港深阶梯加速
沪港深红利(007751)用了一套阶梯模型,常量写在 CONFIG.hkHsLadder:
| 期数区间 | 每期基础增量 | 设计意图 |
|---|---|---|
| 第 1 ~ 240 期 | 10 元 | 匀速爬坡,控制早期风险 |
| 第 241 ~ 247 期 | 30 元 | 中段加速 |
| 第 248 期起 | 50 元 | 后段加速,保证补得到目标仓位 |
原因是:长期定投越到后期,单期小额定投对总仓位的边际贡献越小。不加速,这只基金的仓位永远追不上目标。
实测(真实数据回放):该基金已录入 259 期,末行目标金额 K 已经等于上限 G6 = 3170.48,阶梯曲线被正确封顶。
3.5 踩坑实录:改一次定投额,目标金额直接跳到天花板
现象:白酒基金(161725)第 31 期 K = 620 元,用户把每期定投额从 20 元改成 50 元后,第 32 期目标金额直接跳到 G6。
原因:旧实现用 K = min(n × per, G6),改金额会把新的 50 元回乘到全部 32 期历史上(32 × 50 = 1600 > G6),于是瞬间封顶。
修复:改成递推式 K = min(prevK + incr, G6),新金额只作用于新增期次。
💡 提示:这类「历史被批量重写」的 bug 在财务系统里特别危险,因为历史行会静默变化,用户月底对数时会以为是自己记错了。
四、核心算法 2:综合估值与指导仓位
4.1 动态综合估值 L7
光有「基准估值百分位」不够,因为它是个静态锚点,而净值天天在动。所以复刻了 Excel 的动态综合估值:
综合估值 M = 基准估值 J7 + (当前净值 O2 − 基准价格 O7) / 基准价格 O7
flowchart LR
A[基准估值 J7<br/>静态手工锚点] --> C[综合估值 M]
B[净值偏离率<br/>当前净值-基准价 / 基准价] --> C
C --> D[指导仓位计算]
D --> E[债券 利率系数分支]
D --> F[红利 股息率系数分支]
D --> G[黄金 不乘星级]
D --> H[股票/指数 × 星级/5]
翻译成人话:基准估值负责「历史贵贱」,净值偏离负责「当前贵贱」,两者相加就是此刻的综合估值。净值涨过基准价 → 综合估值抬升 → 指导仓位自动下调;跌下去 → 自动上调。
参数缺失时函数返回 null,界面降级显示 --,不阻塞其他基金计算。
4.2 指导仓位的四个分支
// engine.cjs · 指导仓位(Excel O 列)= 四类分支
function guidePosOf(f, st){
const S = resolveState(st);
const N = +f.basePosition || 0;
// ① 债券:N × (0.8 + 0.2×利率系数),不折减估值、不走星级
if(isBondFund(f)) return N*(0.8 + 0.2*posRateCoeffOf(f, S));
const compVal = compositeValuationOf(f);
const M = (compVal !== null && compVal >= 0) ? compVal : (+f.baseValuation || 0);
const m = M < 0 ? 0 : (M > 1 ? 1 : M); // P0-3:估值百分位先钳制到 [0,1]
// ② 红利:N × (1−m) × (0.6 + 0.4×股息率系数),不走星级
if(isDividendFund(f)){ const O = N*(1-m)*(0.6 + 0.4*posDivCoeffOf(f, S)); return O>0?O:0; }
const base = N*(1-m);
// ③④ 黄金不乘星级;股票/指数 ×(星级/5)
const O = isStockFund(f) ? base*((S.star||4.02)/CONFIG.starMax) : base;
return O>0 ? O : 0;
}
⚠️ 注意:估值百分位必须先钳制到
[0,1]再代入。原始综合估值仍按真实值对外展示,只钳制仓位公式的输入,避免外部指标异常时算出负仓位。
4.3 从「档位表」升级为「连续映射」
早期版本用档位表(比如 10 年期国债收益率 < 2.5% → 系数 1.5),问题是边界跳变:2.51% 和 2.49% 会得到两个完全不同的系数。
现在改成连续映射:
系数 = clamp(1 + (实际值 − 参考基准) × 斜率, 下限, 上限)
| 系数 | 公式 | 斜率 | 区间 |
|---|---|---|---|
| 债券利率系数 | clamp(1 + (rateBase − rateRef) × 0.5, 0.6, 1.4) | 0.5 | 0.6 ~ 1.4 |
| 红利股息率系数 | clamp(1 + (divBase − divRef) × 0.5, 0.5, 2.0) | 0.5 | 0.5 ~ 2.0 |
锚点语义很关键:实际值等于参考基准时系数恰好为 1.0(中性,不干预),每偏离 1 个百分点系数变动 0.5。
// engine.cjs · 连续映射(注意 null 必须先排除:+null === 0 会误判成 0%)
function linearClamp(x, anchor, slope, lo, hi){
if (x === null || x === undefined || x === '') return 1.0;
x = +x;
if(!(x >= 0)) return 1.0;
const v = 1 + (x - anchor) * slope;
return v < lo ? lo : (v > hi ? hi : v);
}
💡 效率技巧:
+null === 0是 JavaScript 的经典陷阱。如果这里不先排除null,一个「没填数据」的字段会被当成「实际值 = 0」,直接算出下限系数,静默地把仓位砍掉一半。
4.4 不再用代码白名单判断基金类型
旧实现靠一张代码表区分基金类别,新增一只基金就失效。现在按优先级逐层判断:
- 基金自带类别字段优先;
- 名称关键词推断:含「债 / 货币 / 现金 / 短融」→ 债券;含「红利 / 股息 / 低波」→ 红利;含「黄金 / 贵金属」→ 黄金;
- 宽基名称正则:沪深 300 / 中证 500 / 中证 1000 / 上证 50 / 科创 50 / 创业板 / 北证 50 / 全指 / MSCI / A50 等;
- 最后回退代码表做兼容兜底(沪深 300 联接、科创 50 联接两只在表内),其余归股票型。
真实数据可以验证:组合里新增的 021707 富国中证红利低波动ETF联接A(名称含「红利」「低波」,策略为股债利差)不需要改任何代码,就被正确识别为红利类,止损阈值取 −15%、浮动止盈回落取 8%。
五、核心算法 3:目标金额分配
5.1 组合的钱怎么分
每只基金的目标金额上限 G6 不是拍脑袋定的,而是按基准仓位比例分蛋糕:
I3(股票基金目标投入)= budget × Σ(池内基金基准仓位) × stockRatio
P(单只目标金额) = O / ΣO × I3
flowchart LR
A[预计总投入 budget] --> D[I3 股票基金目标投入]
B[池内基准仓位之和 ΣN] --> D
C[股票投入比例 stockRatio] --> D
D --> E[按指导仓位占比分配]
F[各基金指导仓位 O] --> E
E --> G[单只目标金额 P = O/ΣO×I3]
H[债券基金] --> I[P = 指导仓位 × budget<br/>不参与 I3 分配]
5.2 分配池的判定规则
股票基金池的入池条件只有一条:非债券 且 基准仓位 > 0。
以前是「命中代码白名单才入池」,导致新增基金必须改代码。现在换成条件判断后:
- 新增基金只要填了基准仓位,自动参与分配;
- 基准仓位置 0 的基金(例如组合里预留的恒生科技)自动排除,与原 Excel「未启用行」口径一致;
- 字段完全缺失的极老备份,才回退代码表兜底,保证存量数据行为不突变。
5.3 债券为什么独立
债券基金的目标金额走另一条路:P = 指导仓位 × budget。
因为债券在组合里是防守仓位、稳定器,它的目标规模由总预算直接决定,而不是跟股票抢同一块蛋糕。混在一起分配,就会出现「债券把股票的钱分走了」这种逻辑错误。
5.4 真实数据回放
用真实备份(2026-09-14)跑一遍引擎:
| 指标 | 数值 |
|---|---|
| 预计总投入 budget | 20000.00 |
| 市场估值星级 star | 4.29 |
| 股票投入比例 stockRatio | 0.70 |
| 池内基金数量 | 9 只 |
| 池内基准仓位 ΣN | 0.60 |
| 股票基金目标投入 I3 | 8400.00 |
六、核心算法 4:8 种定投策略分流
6.1 策略清单
| 策略标识 | 含义 | 增量系数来源 |
|---|---|---|
value | 价值平均法 | 目标金额 − 累计市值(可负) |
regular | 定期定额 | 恒为 1 倍 |
valuation | 估值百分位 | 净值序列计算 |
ma | 均线偏离 | 净值序列计算 |
dd | 回撤加仓 | 净值序列计算 |
rate | 利率锚定 | 10Y 国债收益率档位 |
div | 股息率锚定 | 中证红利股息率档位 |
spread | 股债利差 | 股息率 − 国债收益率 档位 |
⚠️ 注意:这 8 种策略不能共用同一个公式。
value是「市值导向」——本质是让持仓市值沿目标线 K 爬升,所以它的输出可以(也应该)是负数;其余 7 种是「增量导向」——本质是决定本期投多少钱,输出天然是正数。
实测反例:把市值导向的 K − 累计市值 套到增量策略上,每期计划投 100 元、净值累计上涨 50% 时,第 30 期会被误判为「建议卖出 653 元」,第 120 期直接飙到「建议卖出 2600 元」——而正确值恒为 100 元。偏离量约等于持仓市值 × 涨跌幅,随持仓规模线性放大,时间越久错得越离谱。
另外要注意:rate / div / spread 三种系数由引擎自身就能算出来;valuation / ma / dd 依赖净值序列,引擎侧缺省回退「每期定投额 × 1.0」,只有展示层把序列算好的系数通过参数传进来,结果才精确。
6.2 档位表:相对基准,而非绝对阈值
档位判定一律是「实际值 − 参考基准」,绝不硬编码绝对阈值:
| 策略 | 档位条件(相对基准) | 倍率 |
|---|---|---|
| rate | 差值 ≥ +0.35 | 1.5 |
| rate | 差值 ≤ −0.35 | 0.5 |
| div | 差值 ≥ +1.5 / ≥ +0.5 / < 0 | 2.0 / 1.5 / 0.5 |
| spread | 差值 ≥ +1.0 / ≥ +0.5 / ≤ −0.5 | 2.0 / 1.5 / 0.5 |
// engine.cjs · 档位倍率(带浮点容差,未命中 → 1.0 中性)
function tierCoeffOf(value, ref, tiers){
if (value === null || value === undefined || value === '') return 1.0;
const v = +value;
if(!isFinite(v)) return 1.0;
const diff = v - (+ref);
const EPS = 1e-9; // 为什么需要它?见下方踩坑
for (const t of (tiers || [])){
if (t.dir === 'ge' && diff + EPS >= t.off) return t.mult;
if (t.dir === 'le' && diff - EPS <= t.off) return t.mult;
if (t.dir === 'lt' && diff + EPS < t.off) return t.mult;
}
return 1.0;
}
⚠️ 注意:
EPS = 1e-9不是装饰。浮点二进制误差会让「恰好落档」的值漏判——例如3.55 − 3.2 = 0.34999999999999964、3.2 − 2.85 = 0.35000000000000009。同一个政策阈值,两边判定结果相反。
6.3 参考基准与市场实际值必须分离
state.bench = { rateRef, divRef, spreadRef } 是用户手改的参考基准;f.rateBase / f.divBase 是自动任务每 30 分钟刷新的市场实际值。
两者严格分离的意义在于:自动获取只刷新「实际值」,永远不会覆盖你设定的基准。否则你会遇到「改一下基准,第二天被接口刷新悄悄改回去」的灵异事件。
6.4 实测系数
真实数据(股息率 4.15%、10Y 国债 1.7048%、基准 divRef = 4.2 / rateRef = 1.8 / spreadRef = 2.4):
| 基金 | 策略 | 计算过程 | 系数 | 本期应投 |
|---|---|---|---|---|
| 景顺长城沪港深红利 | div | 4.15 − 4.2 = −0.05 < 0 | 0.5 | 150 × 0.5 = 75.00 |
| 西部利得纯债 | rate | |1.7048 − 1.8| < 0.35 | 1.0 | 500.00 |
| 长盛安逸纯债 | rate | 同上,中性档 | 1.0 | 300.00 |
| 富国红利低波 | spread | 4.15 − 1.7048 = 2.4452,差值 +0.045 未达 +0.5 | 1.0 | 150.00 |
6.5 数据充分性门槛
ma / dd 策略依赖「由定投记录还原的净值序列」。记录太少、或最近一条记录太旧时,序列主要靠旧价复制,会产出错误激进信号。
所以引擎设了硬门槛:序列点数 ≥ 20 且最近记录距今 ≤ 30 天,否则系数回退 1.0,并在详情页提示「数据不足」。
七、核心算法 5:额度闸门(累计投入不越目标)
7.1 为什么需要这道闸门
价值平均法的指导定投额是 L = K − 累计市值。当持仓浮亏时,累计市值小于累计投入,于是 K − 累计市值 会大于「目标金额 − 累计投入」的剩余空间。
照此买入,累计投入就会突破该基金的目标金额上限。
实测案例(当时参数下):沪深 300 联接基金目标上限 139.07 元、已投 112.87 元,剩余空间只有 26.20 元,但 VA 差额算出来是 29.84 元——照此买入累计将达 142.71 元,越过目标 3.64 元;另一只基金同样越过 3.9 元。
7.2 闸门实现
// engine.cjs · P0-5 指导定投额受「本基金目标金额上限」约束
function capByTargetRoom(rawL, room){
if(!(rawL > 0)) return rawL; // 0 / 负数(应卖出)不受额度闸门影响
const r = room > 0 ? room : 0;
return rawL < r ? rawL : r; // 只钳制正数
}
// engine.cjs · 逐行接入(节选)
let rawL;
if(isValueAveraging(f)){
rawL = K - cumMv; // 价值平均法:可负 = 应卖出
} else if(isReinvestRow || isSellRow){
rawL = 0; // 非新投入,不产生应投
} else if(baseAmt > 0 && n === 1){
rawL = K; // 底仓建仓
} else {
rawL = incr; // 其余策略:本期计划投入金额
}
const room = G6 - cumCostRun; // 剩余额度 = 目标金额 − 累计投入净额
const L = capByTargetRoom(rawL, room);
const invLimited = (rawL > 0) && (L < rawL - 1e-9); // 是否被截断(界面加注)
7.3 真实回放:谁被闸门拦住了
| 基金 | 目标金额 G6 | 累计投入净额 | 剩余额度 | 策略应投 | 实际建议 | 是否截断 |
|---|---|---|---|---|---|---|
| 国泰黄金ETF联接A | 303.87 | 295.45 | 8.42 | 50.00 | 8.42 | 是 |
| 易方达沪深300联接A | 217.43 | 202.80 | 14.63 | 50.00 | 14.63 | 是 |
| 易方达科创50联接A | 162.07 | 61.98 | 100.09 | 50.00 | 50.00 | 否 |
| 西部利得纯债 | 4952.40 | 4593.21 | 359.19 | 500.00 | 359.19 | 是 |
| 长盛安逸纯债 | 2971.44 | 2752.16 | 219.28 | 300.00 | 219.28 | 是 |
| 景顺长城沪港深红利 | 3170.48 | 3256.28 | −85.80 | 75.00 | 0.00 | 是 |
| 华夏动漫游戏联接A | 509.91 | 542.00 | −32.09 | −37.95 | −37.95 | 否 |
最后一行特别值得看一眼:负数建议原样保留。
因为卖出信号的职责属于 VA 差额与止盈止损逻辑,额度闸门只该管「买入不要超」,不该把「该减仓」改写为「买 0」——否则一个应该提示卖出的仓位,会被闸门静默伪装成「只是这期不买」。
📌 总结:闸门的作用是让累计投入永不超过目标金额;额度用尽时指导定投额归 0,界面显示「停买」,而不是偷偷多买一笔。
八、核心算法 6:卖出链路
8.1 三类行的语义
卖出是定投系统里最容易算错的部分,因为它涉及「同一天既买又卖」的账务归属:
flowchart TB
A[用户点击 记一笔卖出] --> B[写入 sells 记录]
B --> C[自动补一条同日锚点行 __sellRow]
C --> D[引擎逐笔消费配对]
D -->|同日 + 容差内同份额| E[正常卖出行]
D -->|找不到配对| F[孤儿卖出按日期兜底生效]
E --> G[扣减持仓份额 + 计入回收]
F --> G
G --> H[已实现收益 / 持仓口径成本]
- 卖出行(也叫锚点行):
__sellRow: true的标记行,成本按「卖出价 × 份额 + 费用」记录,但份额与成本都不计入买入侧,也不推进目标线 K,唯一作用是让这笔卖出「落在某一期」; - 孤儿卖出:找不到同日同份额配对行的卖出(例如赎回多年前的历史底仓),按日期兜底独立生效,不依赖锚点行。
8.2 容差配对:浮点不能用 ===
份额是「金额 ÷ 净值」算出来的长尾浮点,例如 1000 ÷ 3.1415 = 318.31927423205474,而用户手工录入时常常只写 318.31。
旧实现用严格相等匹配,任何尾差都会让卖出静默失配:既不扣份额,也不计回收,只在页面角落留一个几乎没人注意的提示。
现在改为「同日 + 容差内最近邻」:
// engine.cjs · 容差 = max(绝对容差, |份额| × 相对容差)
function sellSharesEqual(a, b){
if(a===null||a===undefined||a===''||b===null||b===undefined||b==='') return false;
const x = +a, y = +b;
if(!isFinite(x) || !isFinite(y)) return false;
const tol = Math.max(CONFIG.sellMatchAbsTol, Math.abs(y) * CONFIG.sellMatchRelTol);
return Math.abs(x - y) <= tol; // absTol = 0.01;relTol = 1e-6
}
- 绝对容差 0.01:覆盖「用户填 2 位小数」与「券商四舍五入」的差异;
- 相对容差 1e-6:覆盖大份额的二进制表示误差;
- 最近邻:同一天有多笔卖出时,优先配给份额最接近的那笔,降低误配概率。
撤销卖出(delSell)复用同一个容差函数,保证「配对」与「撤销配对」两处判定完全一致。
8.3 踩坑实录:卖出 300 份,净份额纹丝不动
现象:某基金持有 318.3193 份,记录卖出 300 份后,净份额仍然是 318.3193(应为 18.3193)。
原因:锚点行被引擎当成了普通买入,于是「累计买入 +300」与「累计卖出 +300」互相抵消,卖出对持仓毫无影响。
修复:锚点行标记 __sellRow,份额与成本一律不计入买入侧,仅消耗一笔卖出。
8.4 踩坑实录:XIRR 2903%
现象:某基金年化收益率算出 2903%。
原因:孤儿卖出在引擎层被静默丢弃(不扣份额、不计回收),但展示层的现金流序列照单计入收入——两层口径相反,一边当没发生、一边当赚到了,年化自然炸天。
修复:孤儿卖出改为「按日期兜底独立生效」,两层口径统一到同一个 usedSells 集合上。
⚠️ 注意:任何「引擎层」与「展示层」各算一遍的指标,都要做一次交叉验证。同一份数据出现两个口径,就是 bug 的温床。
九、核心算法 7:风控信号与止损止盈
9.1 类别判定优先级
风控阈值必须先知道「这是什么类型的基金」,判定优先级为:
显式类别字段 > bond > dividend > gold > broad > stock
- 基金自带类别字段(有则直接用);
- 债券:名称含「债 / 货币 / 现金 / 短融」;
- 红利:名称含「红利 / 股息 / 低波」(或命中代码表);
- 黄金:名称含「黄金 / 贵金属」(或命中代码表);
- 宽基:代码兜底 + 名称正则(沪深 300 / 中证 500 / 中证 1000 / 上证 50 / 科创 50 / 创业板 / 北证 50 等);
- 其余归为股票型。
9.2 阈值表:按类型分档
| 类别 | 止损线 | 浮动止盈回落阈值 | 示例基金 |
|---|---|---|---|
| 债券 | −5% | 5% | 西部利得纯债、长盛安逸纯债 |
| 红利 | −15% | 8% | 沪港深红利、富国红利低波 |
| 黄金 | −15% | 8% | 国泰黄金 ETF 联接 |
| 宽基 | −20% | 10% | 沪深 300 联接、科创 50 联接 |
| 股票/行业 | −20% | 15% | 白酒、中概互联、动漫游戏 |
「浮动止盈线」的计算方式:取全历史最高持仓收益率 − 回落阈值,且不低于目标收益率。同时剔除已被卖出的批次,避免历史高收益残留永久抬高止盈线。
9.3 止损价与信号必须同源
止损价 = 每份持仓成本 × (1 + 止损线)。看似简单,但**「每份成本」有两个口径**:
- 现金流口径:
(累计买入成本 − 卖出回收) ÷ 净份额; - 持仓口径:
(净份额 × 份额均价 − 现金分红回收) ÷ 净份额。
旧实现里止损价用现金流口径、信号判定用持仓口径。没卖出没分红时两者恰好相等,一直没暴露;一旦有卖出或分红就分裂——要么「价格早跌破但横幅不报警」,要么「横幅说已触发但价格离得很远」。
现在统一到 holdUnitCost(持仓口径每份成本),价格与信号永远同步。
9.4 高估止盈与信号输出
- 非债券基金若
市值 > 1.2 × 当前目标金额,给出「建议赎回超出部分」提示,回落到 1.2 倍目标线; - 债券基金超目标只停买不赎回,符合债券作为底仓的定位。
信号按固定优先级输出:浮动止盈 → 目标收益率 → 估值 > 90% → 高估赎回 → 止损。引擎只产出判定码与数值,文案由展示层映射,避免文案改动影响算法。
十、核心算法 8:收益口径与 XIRR
10.1 两套口径并行
这是财务报表里的经典问题:已经卖出的部分,成本还算不算?
| 口径 | 成本定义 | 适用 |
|---|---|---|
| 现金流口径 | 累计买入成本 − 卖出回收 | 对齐 Excel 的收益率列 |
| 持仓口径 | 净份额 × 份额均价 − 现金分红回收 | 衡量「现在手上这部分」表现 |
累计收益采用总收益口径,公式是 市值 + 卖出回收 + 现金分红 − 累计买入成本。它回答的是「这套系统一共帮你赚了多少」,而不是「账面上此刻浮盈多少」。
10.2 达标率必须连续
旧实现照抄了 Excel 的分支公式:收益 > 0 用 O3/O4,否则用 (O3−O4)/O4。两个分支的分母不同,导致在盈亏平衡点不连续:
| 场景 | 收益值 | 达标率显示 |
|---|---|---|
| 略微为负 | 0.999 | ≈ −100% |
| 略微为正 | 1.001 | ≈ 0% |
一次跳变近 100 个百分点,用户看到的是「收益从 −100% 瞬间变成 0%」。
修复后统一为 收益率 = 实际收益 ÷ 目标收益,在盈亏平衡点连续过渡,亏损时表现为负达标率,语义自洽。
10.3 XIRR:唯一诚实的收益指标
基金定投最大的收益谎言是「只算浮盈不算时间」。同样赚 1000 元,投了 3 个月和投了 3 年,完全不是一回事。
XIRR 把所有现金流(每期买入、卖出、当前市值)按其发生日期折算成年化收益率,实现为牛顿迭代 + 二分法兜底:
| 参数 | 值 | 说明 |
|---|---|---|
maxIter | 100 | 迭代上限,防不收敛死循环 |
precision | 1e-6 | 目标精度 |
relTol | 1e-9 | 相对收敛容差 |
💡 效率技巧:收敛判据必须用相对值
|NPV| / Σ|现金流|,而不是绝对值。旧实现用绝对值 1e-4,万元级现金流几乎必然被判定为「未收敛」,白白掉进二分兜底,性能与精度双输。
十一、工程实践:同构引擎 + 142 项回归
11.1 UMD 同构
engine.cjs 的好处是:同一份代码,浏览器能加载,Node 能 require。
- 浏览器:
<script src="engine.cjs"></script>同步加载,函数挂到window; - Node:
require('./engine.cjs')后设置globalThis.state,即可对核心算法做单元测试。
// 测试侧:注入 state 后直接调用引擎
const eng = require('./engine.cjs');
globalThis.state = { budget: 20000, star: 4.29, stockRatio: 0.7, funds: [...] };
const c = eng.computeFund(fund, state);
const nx = eng.nextRowOf(fund, state); // 今日行建议
11.2 测试规模
| 测试文件 | 用例数 | 覆盖重点 |
|---|---|---|
engine.test.cjs | 92 项 | 递推、闸门、配对、档位、XIRR、风控阈值 |
core.test.cjs | 50 项 | 仓位分配、估值、策略系数、迁移校验 |
📌 总结:算法类代码的测试价值远高于 UI 代码。一个边界条件写错,可能要好几个月后才会在对账时被发现。
11.3 数据迁移与校验
- 迁移:旧备份按
code + 日期补齐新增字段(例如卖出字段),仅在字段完全缺失时补,避免覆盖用户手工修改; - 快照口径切换:2026-09-07 起快照收益口径由「不含分红」改为「含分红」,此前快照标记为
legacy,趋势图分段标注 + 表格角标,诚实标注不折算; - 导入校验:字段缺失、类型错误、重复代码、日期格式非法一律拒绝,绝不污染现有数据。
11.4 踩坑清单
| 踩坑 | 现象 | 根因 | 修复 |
|---|---|---|---|
| 目标线回乘 | 改定投额后 K 跳到上限 | 用 n × per 而非递推 | 改 prevK + incr |
| 卖出锚点行当买入 | 卖 300 份净份额不变 | 买入 +300 与卖出 +300 抵消 | 打 __sellRow 标记 |
| 浮点全等配对 | 卖出静默丢失 | s.shares === D 有尾差即失配 | 容差最近邻 |
| 孤儿卖出双口径 | XIRR 2903% | 引擎丢弃、展示层计入 | 按日期兜底统一生效 |
| 达标率跳变 | 0.999 → −100.5% | 两个分支分母不同 | 统一为 收益 ÷ 目标收益 |
| 档位漏判 | 恰好落档不生效 | 浮点误差 0.34999999999999964 | 加 1e-9 容差 |
| 阶梯硬编码 | 换代码阶梯失效 | 按基金代码判断 | 改基金级 ladder 配置 |
| 阶梯绝对式 | 新旧期次口径断裂 | 历史行不消费策略系数 | 改递推式 prevK + step × coeff |
| 白名单入池 | 新基金不参与分配 | 代码表判断 | 改「非债券 + 基准仓位 > 0」 |
十二、真实数据回放:一次完整快照
用真实备份数据跑一遍引擎,输出如下(2026-09-14 快照,全局参数 budget = 20000、star = 4.29、stockRatio = 0.7):
| 基金 | 策略 | 期数 | 目标金额 G6 | 累计投入净额 | 剩余额度 | 市值 | 本期建议 |
|---|---|---|---|---|---|---|---|
| 招商中证白酒 | value | 32 | 1153.56 | 672.56 | 481.00 | 645.20 | 74.80 |
| 易方达中概互联 | value | 19 | 755.89 | 395.95 | 359.94 | 355.96 | 74.04 |
| 景顺长城沪港深红利 | div | 259 | 3170.48 | 3256.28 | −85.80 | 3226.16 | 0.00 |
| 国泰黄金ETF联接 | dd | 14 | 303.87 | 295.45 | 8.42 | 301.82 | 8.42 |
| 易方达沪深300联接 | valuation | 12 | 217.43 | 202.80 | 14.63 | 197.14 | 14.63 |
| 华夏动漫游戏联接 | value | 25 | 509.91 | 542.00 | −32.09 | 547.85 | −37.95 |
| 招商量化精选 | dd | 9 | 361.26 | 390.12 | −28.86 | 375.08 | 0.00 |
| 易方达科创50联接 | valuation | 2 | 162.07 | 61.98 | 100.09 | 57.62 | 50.00 |
| 西部利得得尊纯债 | rate | 11 | 4952.40 | 4593.21 | 359.19 | 4590.48 | 359.19 |
| 长盛安逸纯债 | rate | 12 | 2971.44 | 2752.16 | 219.28 | 2760.68 | 219.28 |
| 富国红利低波动联接 | spread | 1 | 1765.53 | 100.00 | 1665.53 | 99.09 | 150.00 |
组合汇总:
| 指标 | 数值 |
|---|---|
| 组合市值 | 13157.07 |
| 累计买入成本 | 13262.51 |
| 卖出回收 | 0.00 |
| 累计投入净额 | 13262.51 |
| 累计收益 | −31.47 |
| 其中现金分红 | 73.97 |
| 持仓收益率 | −0.80% |
💡 提示:这张表里有两个数字很有信息量。一是沪港深红利剩余额度为负——说明 G6 是随参数动态变化的,当总预算或分配比例下调时,历史已投可能超过新的上限,引擎的处理是「停买」而不是「假装没事继续投」。二是富国红利低波剩余额度 1665.53,因为它刚建仓 1 期,属于典型的「新基金还有大量空间」。
十三、适用人群与边界
适合谁:
- 用 Excel 管理基金仓位、想把手动流程自动化的投资者;
- 认可价值平均法 / 估值定投理念、关心「算法怎么算」的人;
- 想研究「估值仓位模型」代码实现的技术同学。
边界要说清楚:
- 行情来自公开免费接口(基金净值、指数点位),以基金公司官方净值为准;
- 桌面版与网页版数据不互通,需通过导入导出迁移;
- 卖出录入需要「同日配对」的账务完整性,赎回多年前的历史底仓时请配合备注使用;
- 工具只做仓位计算与决策辅助,不构成任何投资建议。
十四、小结
回头看,这个东西的技术含量其实集中在四个字:别算错账。
- 目标金额要递推,不能回乘;
- 份额是浮点,不能用全等;
- 卖出要配对,不能丢也不能重;
- 成本要分口径,止损价与信号必须同源;
- 买入要有闸门,但只闸正数;
- 档位判定要留浮点容差。
每一行看起来都是细节,但这些细节叠加起来,才是一个能长期用下去的系统。写注释是为了让半年后的自己能看懂——以及让未来的自己确信当时没有发疯。
下一篇我将带你拆解定投助手的数据安全体系:JSON 备份 + 真 xlsx 双通道导出是怎么做的,导入时的 schema 校验又如何挡住了一份被手工改坏的备份文件,敬请关注。
如果这篇文章帮你理清了某个公式,欢迎点赞收藏;如果发现你的算法实现和这里不一样,也欢迎在评论区贴出来,我们一起对齐一下口径。
标签:#基金定投助手 #价值平均法 #基金定投 #量化定投 #JavaScript #算法拆解 #Tauri


被折叠的 条评论
为什么被折叠?



