DEV Community

Joe Lin for BeGoodTool.com

Posted on

Why this DCA vs lump-sum simulator caps a single month’s crash at 95%

A lot of “DCA vs lump sum” calculators are basically just compound-interest widgets wearing a different hat. You type in an amount, a return, a time horizon, and they quietly assume the market moves in one neat deterministic line.

This one doesn’t. When I read through the Vue component behind it, the interesting part wasn’t just that it runs a Monte Carlo simulation — it was how opinionated the implementation is in a few small places. There’s one line in particular that tells you the author has already been bitten by unrealistic random paths:

let factor = 1 + muM + sigmaM * z;
if (factor < 0.05) factor = 0.05;
Enter fullscreen mode Exit fullscreen mode

That single guardrail says a lot. This simulator is trying to stay statistically useful without letting one absurd random draw produce negative-or-basically-zero portfolio math and wreck the whole run.

The first bug here is cultural, not financial

Before the Monte Carlo part even starts, the component is already doing something a lot of finance tools skip: it changes the input unit system by locale.

const myriadLocales = ["tw", "cn", "jp", "kr"];
const isMyriad = computed(() => myriadLocales.includes(locale.value));
const initialAmount = isMyriad.value ? 1000000 : 12000;

const amountDisplay = computed({
  get: () =>
    isMyriad.value
      ? Math.round((state.amount / 10000) * 100) / 100
      : state.amount,
  set: (val) => {
    const num = Number(val) || 0;
    state.amount = isMyriad.value ? Math.round(num * 10000) : Math.round(num);
  },
});
Enter fullscreen mode Exit fullscreen mode

For Traditional Chinese, Simplified Chinese, Japanese, and Korean, the UI exposes the amount in ten-thousand units; everywhere else it uses the raw amount directly. That sounds minor until you realize how easy it is to build a “localized” finance tool that only translates labels while leaving the numeric mental model foreign to half its audience.

I like this implementation because the simulation itself still works on one internal state.amount. The locale-specific weirdness is pushed to the display layer instead of infecting the math. The formatter does the same thing on output:

const formatMoney = (num) => {
  if (isNaN(num) || num === null || !isFinite(num)) return "-";
  const v = Math.round(num);
  if (isMyriad.value) {
    return `${(v / 10000).toLocaleString(undefined, {
      maximumFractionDigits: 1,
    })} ${t("dcaVsLumpsumSimulator.amountUnitLabel")}`;
  }
  return v.toLocaleString();
};
Enter fullscreen mode Exit fullscreen mode

That separation is boring in a good way. It means the Monte Carlo code doesn’t need to care whether the user thinks in 10,000 or 1 萬.

The random return path is simple on purpose — and visibly hand-tuned

The core simulation is monthly, not yearly, and it uses Box–Muller to generate a standard normal random value for each month:

function randomStandardNormal() {
  let u1 = Math.random();
  let u2 = Math.random();
  if (u1 <= 1e-12) u1 = 1e-12; // avoid log(0)
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

const months = Math.max(1, Math.round(years * 12));
const muM = annualReturnPct / 100 / 12;
const sigmaM = annualVolPct / 100 / Math.sqrt(12);
Enter fullscreen mode Exit fullscreen mode

That monthly conversion is straightforward: annual expected return becomes return / 12, and annual volatility becomes vol / sqrt(12). So this is not pretending to be some fully calibrated market model. It’s a compact teaching model.

Then each month’s growth factor is built like this:

for (let m = 1; m <= months; m++) {
  const z = randomStandardNormal();
  let factor = 1 + muM + sigmaM * z;
  if (factor < 0.05) factor = 0.05;
  cumProd[m] = cumProd[m - 1] * factor;
}
Enter fullscreen mode Exit fullscreen mode

Two things matter here.

First, this is arithmetic-return sampling, not a log-return process. In other words, the simulator is drawing a monthly return and turning it into 1 + return, rather than modeling prices with something like geometric Brownian motion. That makes the code easy to explain, which is probably the point.

Second, the 0.05 floor is an explicit intervention. A bad enough normal draw could make 1 + muM + sigmaM * z go negative, which would create nonsense behavior for a portfolio multiplier. So the code clips the worst possible month to -95%. That’s not “pure” finance math, but it is honest engineering: the author chose stability over letting tail events implode the simulation.

If you’re using the output to get intuition rather than to price derivatives, that’s a pretty defensible tradeoff.

DCA isn’t averaged; every contribution gets its own remaining runway

The most correct part of the component, in my opinion, is that it doesn’t fake DCA with some blended average return. It simulates one return path, then values lump sum and DCA separately against that same path.

The contribution schedule is built first:

let contributionMonths = [];
if (frequency === "yearly") {
  for (let y = 0; y < years; y++) {
    contributionMonths.push(y * 12 + 1);
  }
} else {
  for (let m = 1; m <= months; m++) {
    contributionMonths.push(m);
  }
}
const contributionAmount = totalAmount / contributionMonths.length;
Enter fullscreen mode Exit fullscreen mode

Then the final value for each DCA contribution is computed from the month it entered to the end:

const finalMultiplier = cumProd[months];
lumpResults[r] = totalAmount * finalMultiplier;

let dcaFinal = 0;
for (let i = 0; i < contributionMonths.length; i++) {
  const t0 = contributionMonths[i];
  const growth = finalMultiplier / cumProd[t0 - 1];
  dcaFinal += contributionAmount * growth;
}
dcaResults[r] = dcaFinal;
Enter fullscreen mode Exit fullscreen mode

That finalMultiplier / cumProd[t0 - 1] detail is the whole game. Each DCA installment only earns the part of the path that happens after it was invested.

There’s also a subtle timing convention hidden in there: monthly DCA contributions happen at months 1..months, and a contribution at month 1 gets the full first month of growth because the code divides by cumProd[t0 - 1], not cumProd[t0]. So this is effectively a “contribute at the start of the period” model. That’s a valid convention, but it absolutely affects results, especially over shorter horizons.

The rest of the UI is built around making that comparison readable instead of just dumping two endpoint numbers. The histogram uses one shared min/max range across both strategies and turns counts into percentages:

const allValues = [...lumpResults, ...dcaResults];
let min = Math.min(...allValues, 0);
let max = Math.max(...allValues);
const binSize = (max - min) / BIN_COUNT;

return {
  labels,
  lump: lumpBins.map((c) => Math.round((c / n) * 1000) / 10),
  dca: dcaBins.map((c) => Math.round((c / n) * 1000) / 10),
};
Enter fullscreen mode Exit fullscreen mode

That shared axis matters. If each strategy got its own bins, the comparison would look cleaner and be less honest.

One more nice reactive detail: the random results live in a ref, not a computed, and the component re-runs the simulation through a 200ms debounced watcher. That avoids the classic bug where randomness inside reactive derivations re-fires unpredictably.

The honest gotchas are in the code too

This simulator is useful, but it’s also clearly a simplified model.

The biggest limitation is the distribution assumption itself: monthly returns are sampled from a normal distribution with a linear 1 + mu + sigma*z factor. Real markets have fatter tails, regime changes, serial correlation, crashes, recoveries, and all the other ugly things that don’t fit neatly into one normal draw per month.

The second limitation is that the factor < 0.05 clamp cuts off the left tail on purpose. That makes the simulation safer numerically, but it also means the worst-case scenarios are less extreme than a raw normal process would imply.

There are a couple of smaller implementation gotchas I’d also want to know as a user:

  • DCA is always equal-sized installments; there’s no front-loading, no custom schedule, and no cash drag model between installments.
  • The summary stats are just median, mean, 5th percentile, and 95th percentile — enough to be useful, but still a fairly compressed view of the distribution.
  • The code doesn’t model taxes, fees, inflation, or dividend reinvestment in the simulation logic.
  • If a fractional year value ever gets through the year input, the logic gets slightly awkward: months is Math.round(years * 12), but yearly contributions are generated with for (let y = 0; y < years; y++). So a non-integer horizon would be rounded one way for path length and another way for contribution count.

None of that makes the page bad. If anything, it makes it more believable. It’s not pretending to settle the DCA-vs-lump-sum argument forever; it’s a compact simulation with a few explicit assumptions, and the code mostly wears those assumptions on its sleeve.

I turned that into a small free tool if you want to poke at the assumptions yourself without reading Vue first: DCA vs Lump Sum Investment Simulator.


Available in other languages

Top comments (0)