免费基金定投助手全功能拆解:为什么你的基金定投还在亏钱?因为你的工具用错了。动态平衡仓位管理+8种智能定投策略引擎,会自己算买卖点的定投系统

下载链接: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.50.6 ~ 1.4
红利股息率系数clamp(1 + (divBase − divRef) × 0.5, 0.5, 2.0)0.50.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 不再用代码白名单判断基金类型

旧实现靠一张代码表区分基金类别,新增一只基金就失效。现在按优先级逐层判断:

  1. 基金自带类别字段优先;
  2. 名称关键词推断:含「债 / 货币 / 现金 / 短融」→ 债券;含「红利 / 股息 / 低波」→ 红利;含「黄金 / 贵金属」→ 黄金;
  3. 宽基名称正则:沪深 300 / 中证 500 / 中证 1000 / 上证 50 / 科创 50 / 创业板 / 北证 50 / 全指 / MSCI / A50 等;
  4. 最后回退代码表做兼容兜底(沪深 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)跑一遍引擎:

指标数值
预计总投入 budget20000.00
市场估值星级 star4.29
股票投入比例 stockRatio0.70
池内基金数量9 只
池内基准仓位 ΣN0.60
股票基金目标投入 I38400.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.351.5
rate差值 ≤ −0.350.5
div差值 ≥ +1.5 / ≥ +0.5 / < 02.0 / 1.5 / 0.5
spread差值 ≥ +1.0 / ≥ +0.5 / ≤ −0.52.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.349999999999999643.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):

基金策略计算过程系数本期应投
景顺长城沪港深红利div4.15 − 4.2 = −0.05 < 00.5150 × 0.5 = 75.00
西部利得纯债rate|1.7048 − 1.8| < 0.351.0500.00
长盛安逸纯债rate同上,中性档1.0300.00
富国红利低波spread4.15 − 1.7048 = 2.4452,差值 +0.045 未达 +0.51.0150.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联接A303.87295.458.4250.008.42
易方达沪深300联接A217.43202.8014.6350.0014.63
易方达科创50联接A162.0761.98100.0950.0050.00
西部利得纯债4952.404593.21359.19500.00359.19
长盛安逸纯债2971.442752.16219.28300.00219.28
景顺长城沪港深红利3170.483256.28−85.8075.000.00
华夏动漫游戏联接A509.91542.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

  1. 基金自带类别字段(有则直接用);
  2. 债券:名称含「债 / 货币 / 现金 / 短融」;
  3. 红利:名称含「红利 / 股息 / 低波」(或命中代码表);
  4. 黄金:名称含「黄金 / 贵金属」(或命中代码表);
  5. 宽基:代码兜底 + 名称正则(沪深 300 / 中证 500 / 中证 1000 / 上证 50 / 科创 50 / 创业板 / 北证 50 等);
  6. 其余归为股票型。

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 把所有现金流(每期买入、卖出、当前市值)按其发生日期折算成年化收益率,实现为牛顿迭代 + 二分法兜底

参数说明
maxIter100迭代上限,防不收敛死循环
precision1e-6目标精度
relTol1e-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.cjs92 项递推、闸门、配对、档位、XIRR、风控阈值
core.test.cjs50 项仓位分配、估值、策略系数、迁移校验

📌 总结:算法类代码的测试价值远高于 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.349999999999999641e-9 容差
阶梯硬编码换代码阶梯失效按基金代码判断改基金级 ladder 配置
阶梯绝对式新旧期次口径断裂历史行不消费策略系数改递推式 prevK + step × coeff
白名单入池新基金不参与分配代码表判断改「非债券 + 基准仓位 > 0」

十二、真实数据回放:一次完整快照

用真实备份数据跑一遍引擎,输出如下(2026-09-14 快照,全局参数 budget = 20000、star = 4.29、stockRatio = 0.7):

基金策略期数目标金额 G6累计投入净额剩余额度市值本期建议
招商中证白酒value321153.56672.56481.00645.2074.80
易方达中概互联value19755.89395.95359.94355.9674.04
景顺长城沪港深红利div2593170.483256.28−85.803226.160.00
国泰黄金ETF联接dd14303.87295.458.42301.828.42
易方达沪深300联接valuation12217.43202.8014.63197.1414.63
华夏动漫游戏联接value25509.91542.00−32.09547.85−37.95
招商量化精选dd9361.26390.12−28.86375.080.00
易方达科创50联接valuation2162.0761.98100.0957.6250.00
西部利得得尊纯债rate114952.404593.21359.194590.48359.19
长盛安逸纯债rate122971.442752.16219.282760.68219.28
富国红利低波动联接spread11765.53100.001665.5399.09150.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

数据说明:源数据来自王晓磊博士提供的全国空气质量监测数据。经过数据清洗,制作成分城市、分站点、按指标存储的小时浓度监测数据,如果需要其他城市、站点、指标数据可以站内消息联系本人。 内容概要:本文档记录了北京市2015年部分日期每小时的PM2.5浓度监测数据,时间跨度从1月到12月的部分时段,数据以“年月日 时 PM2.5值”的格式呈现,数值单位为微克/立方米(μg/m³),部分时间段存在缺失值(用NaN表示)。数据反映了北京在不同季节、不同时段的空气质量变化情况,包括污染高峰期(如冬季)和较为清洁时段(如夏季),部分数据显示PM2.5浓度严重超标,达到300以上,属于重度或严重污染级别。; 适合人群:环境科学研究人员、空气质量数据分析人员、气象学爱好者、公共卫生政策制定者以及关注城市空气污染问题的社会公众。; 使用场景及目标:①用于分析北京2015年PM2.5浓度的时间变化趋势与周期性特征;②支持空气质量建模、污染源追踪及健康风险评估研究;③作为教学案例帮助学生理解大气污染物的时间序列特性;④辅助政府机构制定雾霾治理措施并评估其效果。; 阅读建议:此数据为原始时间序列记录,使用前应进行数据清洗(处理NaN值)、时间对齐和统计分析,建议结合气象数据(如风速、湿度)和地理信息综合解读,以便更准确地识别污染成因与传播规律。
代码转载自:https://pan.quark.cn/s/b92216efb941 在Windows 11的操作系统环境中,Microsoft Terminal Services Client (MSTSC) 被视作执行远程桌面连接的核心工具,其功能在于使得用户能够访问并操控远端的计机设备。文档所提及的更新是专针对Win11版本的MSTSC,其具体版本标识为10.0.22621,这表明其属于一个较新阶的补丁或升级,其中或许囊括了效能的增强、安全性的修补以及其他功能的优化。在描述中列出的17个文件,或包含有MSTSC组件的整体或部分更新资料,这些文件能够直接用以替换现有的系统文件,从而达成升级的目标。 1. **远程桌面协议 (RDP)**: RDP是由Microsoft设计的一种协议,其目的是让用户可以通过网络对远程的计机实施图形化的操作。RDP 10.11版本提供了更迅捷的连接速度、更优越的用户体验以及更为坚实的安保保障。这一版本或许集成了图像编码的优化,旨在提升对延迟敏感型应用的效能表现,以及对高分辨率显示设备的支持。 2. **MSTSC更新**: 对MSTSC进行更新意在修正已知的技术缺陷,强化功能表现,并提升安全性。例如,可能对多显示器环境的配置进行了改善,优化了网络带宽的利用效率,加强了身份验证的机制,或引入了新的配置选项。 3. **文件替换**: 用户在实施文件替换时需持谨慎态度,务必备份原有的文件以防止意外情况发生。通常,这些文件存放在系统目录,例如`C:\Windows\System32`。在替换之前,应关闭所有相关的系统服务,以避免因文件正在被使用而导致替换操作无法进行。 4. **安全性与稳定性**: 新版...
源码直接下载地址: https://pan.quark.cn/s/a4b39357ea24 基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪吃蛇玩法,基于Java语言的休闲游戏,主题为经典的贪...
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

每日干货分享

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值