Skip to content

Commit 6433ce5

Browse files
Add Merton jump-diffusion and fix variance/percentile estimators
Parity with Axis2/C commit 6e577eb + review fixes fb40242, 0b281fd. Merton (1976) jump-diffusion model: - New request fields: model ("gbm"|"merton"), jumpIntensity, jumpMean, jumpVol — all with defaults matching C implementation. - Drift correction: (μ − σ²/2 − λk)·dt preserves E[S(T)]. - Bernoulli jump process with lambda·dt validation (> 0.1 rejected). - Response includes "model" field echoing which model was used. Numerical stability (from Gemini quant review): - Replaced one-pass variance (sumSq/N − mean²) with two-pass algorithm. The one-pass formula suffers catastrophic cancellation when stdDev << mean. - Fixed VaR percentile indexing: ceil(p·N) − 1 instead of floor(p·N). The floor estimator selects one observation too far from the tail, systematically understating VaR. All existing tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e600831 commit 6433ce5

3 files changed

Lines changed: 122 additions & 15 deletions

File tree

modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/FinancialBenchmarkService.java

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -365,15 +365,43 @@ public MonteCarloResponse monteCarlo(MonteCarloRequest request)
365365
throw JsonRpcFaultException.validationError("volatility must be >= 0.");
366366
}
367367

368-
// ── Pre-computed GBM constants ────────────────────────────────────────
368+
// ── Model selection ────────────────────────────────────────────────────
369+
boolean isMerton = "merton".equalsIgnoreCase(request.getModel());
370+
double jumpIntensity = request.getJumpIntensity();
371+
double jumpMean = request.getJumpMean();
372+
double jumpVol = request.getJumpVol();
373+
374+
if (isMerton && jumpVol < 0.0) {
375+
throw JsonRpcFaultException.validationError("jumpVol must be >= 0.");
376+
}
377+
378+
// ── Pre-computed constants ────────────────────────────────────────────
369379
// dt — length of one time step in years (e.g., 1/252 for one
370380
// trading day when nPeriodsPerYear = 252)
371-
// drift — (μ − σ²/2)·dt. The (−σ²/2) term is the Itô correction
372-
// that keeps E[S(T)] = S(0)·exp(μ·T). Do not drop it.
381+
// drift — (μ − σ²/2 − λk)·dt for Merton, (μ − σ²/2)·dt for GBM.
382+
// The −λk term compensates for the expected jump so that
383+
// E[S(T)] = S(0)·exp(μ·T) regardless of jump parameters.
373384
// volSqrtDt — σ·√dt. Scales each standard-normal shock by the
374385
// one-step standard deviation.
375386
double dt = 1.0 / npy;
376-
double drift = (mu - 0.5 * sigma * sigma) * dt;
387+
double jumpCompensation = 0.0;
388+
double jumpLambdaDt = 0.0;
389+
if (isMerton) {
390+
double k = Math.exp(jumpMean + 0.5 * jumpVol * jumpVol) - 1.0;
391+
jumpCompensation = jumpIntensity * k;
392+
jumpLambdaDt = jumpIntensity * dt;
393+
// The Bernoulli approximation for a Poisson process is only valid
394+
// when λ·dt << 1. At λ·dt > 0.1 the probability of ≥2 jumps per
395+
// step becomes non-negligible; at λ·dt ≥ 1 the trial degenerates
396+
// to a deterministic jump every step. Fail fast.
397+
if (jumpLambdaDt > 0.1) {
398+
throw JsonRpcFaultException.validationError(
399+
"jumpIntensity too high for time step: lambda*dt=" +
400+
String.format("%.4f", jumpLambdaDt) + " > 0.1. " +
401+
"Reduce jumpIntensity or increase nPeriodsPerYear.");
402+
}
403+
}
404+
double drift = (mu - 0.5 * sigma * sigma - jumpCompensation) * dt;
377405
double volSqrtDt = sigma * Math.sqrt(dt);
378406

379407
// ── PRNG: seeded for reproducibility, unseeded for production ─────────
@@ -386,7 +414,6 @@ public MonteCarloResponse monteCarlo(MonteCarloRequest request)
386414

387415
double[] finalValues = new double[nSims];
388416
double sumFinal = 0.0;
389-
double sumSqFinal = 0.0;
390417
int profitCount = 0;
391418
double maxDrawdown = 0.0;
392419

@@ -399,7 +426,18 @@ public MonteCarloResponse monteCarlo(MonteCarloRequest request)
399426

400427
for (int period = 0; period < nPeriods; period++) {
401428
double z = rng.nextGaussian();
402-
value *= Math.exp(drift + volSqrtDt * z);
429+
double exponent = drift + volSqrtDt * z;
430+
431+
// Merton jump component: compound Poisson process.
432+
// At each step, a jump occurs with probability λ·dt.
433+
// The jump magnitude is log-normal: J = exp(μ_J + σ_J · W)
434+
// where W ~ N(0,1) is independent of the diffusion Z.
435+
if (isMerton && rng.nextDouble() < jumpLambdaDt) {
436+
double w = rng.nextGaussian();
437+
exponent += jumpMean + jumpVol * w;
438+
}
439+
440+
value *= Math.exp(exponent);
403441

404442
if (value > peak) {
405443
peak = value;
@@ -411,26 +449,35 @@ public MonteCarloResponse monteCarlo(MonteCarloRequest request)
411449

412450
finalValues[sim] = value;
413451
sumFinal += value;
414-
sumSqFinal += value * value;
415452
if (value > initialValue) profitCount++;
416453
if (simMaxDrawdown > maxDrawdown) maxDrawdown = simMaxDrawdown;
417454
}
418455

419456
long elapsedUs = (System.nanoTime() - startNs) / 1_000;
420457

421458
// ── Statistics ────────────────────────────────────────────────────────
459+
// Two-pass algorithm for variance: the one-pass formula
460+
// (sumSq/N − mean²) suffers catastrophic cancellation when
461+
// stdDev << mean (common for low-vol strategies). Two-pass:
462+
// compute mean first, then sum squared deviations.
422463
double mean = sumFinal / nSims;
423-
double variance = (sumSqFinal / nSims) - (mean * mean);
424-
if (variance < 0.0) variance = 0.0;
464+
double sumSqDiff = 0.0;
465+
for (int i = 0; i < nSims; i++) {
466+
double d = finalValues[i] - mean;
467+
sumSqDiff += d * d;
468+
}
469+
double variance = sumSqDiff / nSims;
425470

426471
// Sort ascending so finalValues[0] is the worst outcome and
427472
// finalValues[nSims-1] is the best. VaR and CVaR then become
428-
// simple index lookups: the p-th percentile loss corresponds to
429-
// finalValues[floor(p * nSims)].
473+
// simple index lookups.
430474
Arrays.sort(finalValues);
431475

432-
int idx5 = (int)(0.05 * nSims);
433-
int idx1 = (int)(0.01 * nSims);
476+
// Percentile indexing: ceil(p·N) − 1 selects the k-th order
477+
// statistic such that exactly floor(p·N) observations are strictly
478+
// below the VaR level. Matches the standard quantile definition.
479+
int idx5 = Math.max(0, (int) Math.ceil(0.05 * nSims) - 1);
480+
int idx1 = Math.max(0, (int) Math.ceil(0.01 * nSims) - 1);
434481

435482
// Sample median of a sorted array: for odd N take the middle element,
436483
// for even N average the two central elements. Using a single index
@@ -467,14 +514,15 @@ public MonteCarloResponse monteCarlo(MonteCarloRequest request)
467514
for (int pi = 0; pi < maxPct; pi++) {
468515
double p = request.getPercentiles()[pi];
469516
if (p <= 0.0 || p >= 1.0) continue;
470-
int idx = (int)(p * nSims);
517+
int idx = Math.max(0, (int) Math.ceil(p * nSims) - 1);
471518
if (idx >= nSims) idx = nSims - 1;
472519
percentileVars.add(new MonteCarloResponse.PercentileVar(
473520
p, initialValue - finalValues[idx]));
474521
}
475522
}
476523

477524
MonteCarloResponse response = new MonteCarloResponse();
525+
response.setModel(isMerton ? "merton" : "gbm");
478526
response.setStatus("SUCCESS");
479527
response.setMeanFinalValue(mean);
480528
response.setMedianFinalValue(median);

modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/MonteCarloRequest.java

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,23 @@
2121
/**
2222
* Request for Monte Carlo Value-at-Risk simulation.
2323
*
24-
* <p>Simulates portfolio value paths using Geometric Brownian Motion:
24+
* <p>Simulates portfolio value paths using one of two stochastic models:
25+
*
26+
* <p><b>GBM</b> (model="gbm", default): Geometric Brownian Motion.
2527
* <pre>S(t+dt) = S(t) × exp((μ − σ²/2)·dt + σ·√dt·Z)</pre>
2628
* where dt = 1/nPeriodsPerYear and Z ~ N(0,1).
2729
*
30+
* <p><b>Merton jump-diffusion</b> (model="merton"): GBM plus a compound
31+
* Poisson jump process that captures fat tails and discontinuities
32+
* (crashes, gaps) that GBM systematically understates.
33+
* <pre>S(t+dt) = S(t) × exp((μ − σ²/2 − λk)·dt + σ·√dt·Z) × J</pre>
34+
* where J = exp(μ_J + σ_J·W) with probability λ·dt per step, else J = 1.
35+
* Z, W ~ N(0,1) independent. k = exp(μ_J + σ_J²/2) − 1.
36+
* The −λk drift correction preserves E[S(T)] = S(0)·exp(μ·T).
37+
*
38+
* <p>Reference: Merton, R. (1976). "Option pricing when underlying stock
39+
* returns are discontinuous." J. Financial Economics, 3(1-2): 125-144.
40+
*
2841
* <p>All fields have defaults so a minimal {@code {}} request body is valid.
2942
*
3043
* <h3>Example</h3>
@@ -196,6 +209,38 @@ public class MonteCarloRequest {
196209
*/
197210
private double[] percentiles = {0.01, 0.05};
198211

212+
/**
213+
* Simulation model: "gbm" (default) or "merton".
214+
*
215+
* <p>GBM is the textbook baseline — constant vol, continuous paths.
216+
* Merton adds a compound Poisson jump process for fat tails.
217+
* See class javadoc for the full model equations.
218+
*/
219+
private String model = "gbm";
220+
221+
/**
222+
* Jump intensity (Merton only): expected number of jumps per YEAR
223+
* (Poisson λ). Must be &gt;= 0. Default: 1.0. At λ=1, approximately
224+
* one jump per simulated year. Higher values increase kurtosis.
225+
* Ignored when model="gbm".
226+
*/
227+
private double jumpIntensity = 1.0;
228+
229+
/**
230+
* Mean of the log-normal jump size distribution (Merton only).
231+
* Negative values produce downward jumps on average (crashes).
232+
* Default: -0.03 (average jump ≈ 3% drop). Ignored when model="gbm".
233+
*/
234+
private double jumpMean = -0.03;
235+
236+
/**
237+
* Volatility of the log-normal jump size (Merton only). Controls how
238+
* variable individual jump magnitudes are. Must be &gt;= 0.
239+
* Default: 0.05. At jumpVol=0 every jump has exactly size
240+
* exp(jumpMean). Ignored when model="gbm".
241+
*/
242+
private double jumpVol = 0.05;
243+
199244
/** Optional identifier echoed in the response for request tracing. */
200245
private String requestId;
201246

@@ -218,6 +263,10 @@ public class MonteCarloRequest {
218263
public int getNPeriodsPerYear() { return nPeriodsPerYear > 0 ? nPeriodsPerYear : 252; }
219264
public double[] getPercentiles() { return percentiles != null ? percentiles : new double[]{0.01, 0.05}; }
220265
public String getRequestId() { return requestId; }
266+
public String getModel() { return model != null ? model : "gbm"; }
267+
public double getJumpIntensity() { return jumpIntensity >= 0 ? jumpIntensity : 1.0; }
268+
public double getJumpMean() { return jumpMean; }
269+
public double getJumpVol() { return jumpVol >= 0 ? jumpVol : 0.05; }
221270

222271
// ── setters ──────────────────────────────────────────────────────────────
223272

@@ -230,4 +279,8 @@ public class MonteCarloRequest {
230279
public void setNPeriodsPerYear(int nPeriodsPerYear) { this.nPeriodsPerYear = nPeriodsPerYear; }
231280
public void setPercentiles(double[] percentiles) { this.percentiles = percentiles; }
232281
public void setRequestId(String requestId) { this.requestId = requestId; }
282+
public void setModel(String model) { this.model = model; }
283+
public void setJumpIntensity(double jumpIntensity) { this.jumpIntensity = jumpIntensity; }
284+
public void setJumpMean(double jumpMean) { this.jumpMean = jumpMean; }
285+
public void setJumpVol(double jumpVol) { this.jumpVol = jumpVol; }
233286
}

modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/MonteCarloResponse.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ public class MonteCarloResponse {
8181
/** JVM heap used at response time in MB */
8282
private long memoryUsedMb;
8383

84+
/** Model used for this simulation: "gbm" or "merton" */
85+
private String model;
86+
8487
/** Echoed from request */
8588
private String requestId;
8689

@@ -161,4 +164,7 @@ public static MonteCarloResponse failed(String errorMessage) {
161164

162165
public String getRequestId() { return requestId; }
163166
public void setRequestId(String requestId) { this.requestId = requestId; }
167+
168+
public String getModel() { return model; }
169+
public void setModel(String model) { this.model = model; }
164170
}

0 commit comments

Comments
 (0)