Why comparing insurance deductibles needs shared random medical-spend paths

dev.to

The hard part of comparing a high-deductible, low-premium plan with a low-deductible plan is not adding two premiums. Medical spending varies, and a fair comparison should expose both ordinary and expensive years. I built this simulator so both plans see the same generated spending path; otherwise random noise can make one plan look better simply because it received easier years. The useful audience is someone who can read a spreadsheet or a little JavaScript and wants to reason about uncertainty without mistaking a simulation for an insurer's forecast.

Generate one annual spend, then apply both plan rules

Annual spending uses a log-normal-style transformation. First, randomStandardNormal() makes a normal deviate with the Box-Muller transform. Then volatility becomes sigma, and the result is exponentiated and clamped:

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

function annualMedicalSpend(avg, volatilityPct) {
  const sigma = Math.max(0, volatilityPct || 0) / 100;
  if (sigma === 0) return Math.max(0, avg || 0);
  const z = randomStandardNormal();
  return Math.max(0, (avg || 0) *
    Math.exp(-0.5 * sigma * sigma + sigma * z));
}
Enter fullscreen mode Exit fullscreen mode

The tiny u1 floor prevents Math.log(0). With volatility set to zero, the function returns the average instead of needlessly consuming random samples. With volatility enabled, spending is non-negative and usually clustered around ordinary values with a longer expensive tail. “Log-normal-style” is deliberate wording: it is a convenient shape for a toy model, not a claim that health expenses follow this exact distribution.

The Monte Carlo loop generates one spend per plan-year and sends that same number to both plans:

for (let r = 0; r < runs; r++) {
  let totalA = 0;
  let totalB = 0;
  for (let y = 0; y < years; y++) {
    const spend = annualMedicalSpend(state.avgSpend,
      state.spendVolatility);
    totalA += planOutOfPocket(state.planA, spend);
    totalB += planOutOfPocket(state.planB, spend);
  }
  planA[r] = totalA;
  planB[r] = totalB;
}
Enter fullscreen mode Exit fullscreen mode

That shared path is the key comparison control. If Plan A and Plan B each generated their own random spending, the difference would mix policy design with unrelated luck. In one run, both see the same ordinary year, bad year, and expensive year; only premium, deductible, coinsurance, and cap rules differ.

Deductible, coinsurance, and cap have an order

planOutOfPocket starts with annual premium. Medical spending up to the deductible is paid fully by the member. Above it, the deductible is combined with the configured coinsurance share of the remainder. Finally, a positive out-of-pocket maximum caps that member portion:

function planOutOfPocket(plan, medicalSpend) {
  const premium = Math.max(0, plan.annualPremium || 0);
  const deductible = Math.max(0, plan.deductible || 0);
  const oopMax = Math.max(0, plan.oopMax || 0);
  const coinsurance =
    Math.min(100, Math.max(0, plan.coinsurance || 0)) / 100;
  let oop = medicalSpend <= deductible
    ? medicalSpend
    : deductible + (medicalSpend - deductible) * coinsurance;
  if (oopMax > 0) oop = Math.min(oop, oopMax);
  return premium + Math.max(0, oop);
}
Enter fullscreen mode Exit fullscreen mode

For example, with a 3,000 deductible, 20% coinsurance, and 7,000 cap, a 1,500 spend produces 1,500 member cost before premium. A 13,000 spend produces 3,000 plus 20% of 10,000, or 5,000, still below the cap. The annual premium is added regardless of whether a claim happens. This is a simplified annual rule: it does not model service categories, network pricing, copays, or a deductible that resets on a different schedule.

Read distributions instead of chasing one winner

For each run, the component totals every simulated year's cost. The results are sorted to report mean, median, 10th percentile, 90th percentile, and the share of runs where Plan A is cheaper. The chart reuses those arrays, so it shows a distribution rather than a single break-even claim. A high-deductible plan may have the lower mean but a much wider high-cost tail; a richer plan may cost more in most routine paths while limiting unpleasant surprises.

Try 10 years and 5,000 runs, then rerun with volatility at zero. The zero-volatility version isolates policy math; the volatile version shows how premiums trade against exposure. Repeated clicks will not reproduce identical values because the source uses Math.random() and does not seed it. That is useful for seeing variability, but inconvenient for a reproducible audit.

There are also practical bounds behind the controls. The simulation enforces at least one year and at least 100 runs even if a malformed value reaches the calculation, while the normal selector offers 500 through 10,000 runs. More runs generally make the displayed distribution less noisy, but they do not repair a bad average-spend assumption. In other words, increasing computational effort cannot turn an invented input into reliable insurance underwriting data.

Honest inputs are part of the algorithm

The form asks for years, runs, average annual spending, volatility, and each plan's four parameters. It does not contain a disease-rate or insurer database. Outputs are conditional on those assumptions, and currency is not inherently tied to one country's policy vocabulary. Real policies can have exclusions, eligible-service rules, family deductibles, and claim limits this model cannot represent. It should support questions for an insurer, not replace policy wording or professional advice. I turned this experiment into a small free tool: Insurance Deductible Scenario Simulator.

Source: dev.to

arrow_back Back to News