@@ -547,6 +547,222 @@ void testScenarioAnalysis_customProbTolerance_acceptsSlightlyOff() throws JsonRp
547547 "loose tolerance should accept sum=0.999" );
548548 }
549549
550+ // ═══════════════════════════════════════════════════════════════════════
551+ // monteCarlo — Merton jump-diffusion
552+ // ═══════════════════════════════════════════════════════════════════════
553+
554+ @ Test
555+ void testMonteCarlo_mertonModel_respondsWithModelField () throws JsonRpcFaultException {
556+ MonteCarloRequest req = new MonteCarloRequest ();
557+ req .setModel ("merton" );
558+ req .setNSimulations (1000 );
559+ req .setRandomSeed (42L );
560+
561+ MonteCarloResponse resp = service .monteCarlo (req );
562+
563+ assertEquals ("SUCCESS" , resp .getStatus ());
564+ assertEquals ("merton" , resp .getModel (), "response should echo model=merton" );
565+ }
566+
567+ @ Test
568+ void testMonteCarlo_gbmModel_respondsWithModelField () throws JsonRpcFaultException {
569+ MonteCarloRequest req = new MonteCarloRequest ();
570+ req .setNSimulations (1000 );
571+ req .setRandomSeed (42L );
572+
573+ MonteCarloResponse resp = service .monteCarlo (req );
574+
575+ assertEquals ("SUCCESS" , resp .getStatus ());
576+ assertEquals ("gbm" , resp .getModel (), "default model should be gbm" );
577+ }
578+
579+ @ Test
580+ void testMonteCarlo_mertonSeededReproducibility () throws JsonRpcFaultException {
581+ MonteCarloRequest req1 = new MonteCarloRequest ();
582+ req1 .setModel ("merton" );
583+ req1 .setNSimulations (1000 );
584+ req1 .setRandomSeed (123L );
585+
586+ MonteCarloRequest req2 = new MonteCarloRequest ();
587+ req2 .setModel ("merton" );
588+ req2 .setNSimulations (1000 );
589+ req2 .setRandomSeed (123L );
590+
591+ MonteCarloResponse r1 = service .monteCarlo (req1 );
592+ MonteCarloResponse r2 = service .monteCarlo (req2 );
593+
594+ assertEquals (r1 .getVar95 (), r2 .getVar95 (), 1e-9 , "seeded Merton runs must be identical" );
595+ assertEquals (r1 .getMeanFinalValue (), r2 .getMeanFinalValue (), 1e-9 );
596+ assertEquals (r1 .getMaxDrawdown (), r2 .getMaxDrawdown (), 1e-9 );
597+ }
598+
599+ @ Test
600+ void testMonteCarlo_mertonFatterTailsThanGbm () throws JsonRpcFaultException {
601+ // Same parameters, same seed. Merton should produce wider tails
602+ // (higher VaR, worse drawdowns) because jumps add kurtosis.
603+ MonteCarloRequest gbmReq = new MonteCarloRequest ();
604+ gbmReq .setModel ("gbm" );
605+ gbmReq .setNSimulations (50_000 );
606+ gbmReq .setNPeriods (252 );
607+ gbmReq .setVolatility (0.20 );
608+ gbmReq .setExpectedReturn (0.08 );
609+ gbmReq .setRandomSeed (777L );
610+
611+ MonteCarloRequest mertonReq = new MonteCarloRequest ();
612+ mertonReq .setModel ("merton" );
613+ mertonReq .setNSimulations (50_000 );
614+ mertonReq .setNPeriods (252 );
615+ mertonReq .setVolatility (0.20 );
616+ mertonReq .setExpectedReturn (0.08 );
617+ mertonReq .setJumpIntensity (2.0 ); // 2 jumps/year
618+ mertonReq .setJumpMean (-0.05 ); // avg 5% crash
619+ mertonReq .setJumpVol (0.08 ); // variable jump size
620+ mertonReq .setRandomSeed (777L );
621+
622+ MonteCarloResponse gbm = service .monteCarlo (gbmReq );
623+ MonteCarloResponse merton = service .monteCarlo (mertonReq );
624+
625+ assertEquals ("SUCCESS" , gbm .getStatus ());
626+ assertEquals ("SUCCESS" , merton .getStatus ());
627+
628+ // Merton should have wider 99% VaR (fatter left tail)
629+ assertTrue (merton .getVar99 () > gbm .getVar99 (),
630+ "Merton 99% VaR (" + merton .getVar99 () + ") should exceed GBM (" + gbm .getVar99 () + ")" );
631+
632+ // Merton should have worse max drawdown
633+ assertTrue (merton .getMaxDrawdown () > gbm .getMaxDrawdown (),
634+ "Merton max drawdown (" + merton .getMaxDrawdown () + ") should exceed GBM (" + gbm .getMaxDrawdown () + ")" );
635+ }
636+
637+ @ Test
638+ void testMonteCarlo_mertonPreservesDriftCompensation () throws JsonRpcFaultException {
639+ // With drift compensation, E[S(T)] should equal S(0) * exp(μ*T)
640+ // regardless of jump parameters. Test with large N for convergence.
641+ double mu = 0.10 ;
642+ double initialValue = 1_000_000.0 ;
643+ // Expected: 1M * exp(0.10) = 1,105,170.92
644+ double expectedMean = initialValue * Math .exp (mu );
645+
646+ MonteCarloRequest req = new MonteCarloRequest ();
647+ req .setModel ("merton" );
648+ req .setNSimulations (100_000 );
649+ req .setNPeriods (252 );
650+ req .setInitialValue (initialValue );
651+ req .setExpectedReturn (mu );
652+ req .setVolatility (0.20 );
653+ req .setJumpIntensity (3.0 );
654+ req .setJumpMean (-0.04 );
655+ req .setJumpVol (0.06 );
656+ req .setRandomSeed (42L );
657+
658+ MonteCarloResponse resp = service .monteCarlo (req );
659+
660+ assertEquals ("SUCCESS" , resp .getStatus ());
661+ // With 100k sims, mean should be within ~1% of theoretical
662+ double relativeError = Math .abs (resp .getMeanFinalValue () - expectedMean ) / expectedMean ;
663+ assertTrue (relativeError < 0.015 ,
664+ "Merton mean (" + String .format ("%.2f" , resp .getMeanFinalValue ()) +
665+ ") should be within 1.5% of theoretical (" + String .format ("%.2f" , expectedMean ) +
666+ "), relative error = " + String .format ("%.4f" , relativeError ));
667+ }
668+
669+ @ Test
670+ void testMonteCarlo_mertonZeroJumpIntensity_matchesDrift () throws JsonRpcFaultException {
671+ // Merton with jumpIntensity=0: no jumps occur, drift compensation is
672+ // zero, so the expected mean should match GBM's theoretical mean.
673+ // Note: PRNG state diverges because the Merton path still evaluates
674+ // rng.nextDouble() on each step (short-circuit is at the probability
675+ // check, not the random draw), so we compare statistical properties
676+ // rather than bit-exact values.
677+ MonteCarloRequest req = new MonteCarloRequest ();
678+ req .setModel ("merton" );
679+ req .setJumpIntensity (0.0 );
680+ req .setNSimulations (50_000 );
681+ req .setExpectedReturn (0.08 );
682+ req .setVolatility (0.20 );
683+ req .setRandomSeed (42L );
684+
685+ MonteCarloResponse resp = service .monteCarlo (req );
686+
687+ double expectedMean = 1_000_000.0 * Math .exp (0.08 );
688+ double relativeError = Math .abs (resp .getMeanFinalValue () - expectedMean ) / expectedMean ;
689+ assertTrue (relativeError < 0.01 ,
690+ "Merton with λ=0 should match GBM mean within 1%, got " +
691+ String .format ("%.4f" , relativeError ));
692+ }
693+
694+ @ Test
695+ void testMonteCarlo_mertonLambdaDtTooHigh_fails () {
696+ // jumpIntensity=300 with nPeriodsPerYear=252 → λ·dt ≈ 1.19 > 0.1
697+ MonteCarloRequest req = new MonteCarloRequest ();
698+ req .setModel ("merton" );
699+ req .setJumpIntensity (300.0 );
700+
701+ JsonRpcFaultException ex = assertThrows (JsonRpcFaultException .class ,
702+ () -> service .monteCarlo (req ));
703+ assertEquals (422 , ex .getHttpStatusCode ());
704+ assertTrue (ex .getMessage ().contains ("jumpIntensity" ) || ex .getMessage ().contains ("lambda" ),
705+ "error must mention jump intensity: " + ex .getMessage ());
706+ }
707+
708+ @ Test
709+ void testMonteCarlo_mertonNegativeJumpVol_clampedToDefault () throws JsonRpcFaultException {
710+ // The getter clamps negative jumpVol to the default (0.05),
711+ // so the service sees a valid value. This is consistent with
712+ // the getter-enforced-defaults pattern used by other fields.
713+ MonteCarloRequest req = new MonteCarloRequest ();
714+ req .setModel ("merton" );
715+ req .setJumpVol (-0.05 );
716+ req .setNSimulations (100 );
717+ req .setRandomSeed (1L );
718+
719+ MonteCarloResponse resp = service .monteCarlo (req );
720+ assertEquals ("SUCCESS" , resp .getStatus (),
721+ "negative jumpVol is clamped to default by getter, not rejected" );
722+ }
723+
724+ // ═══════════════════════════════════════════════════════════════════════
725+ // monteCarlo — two-pass variance and percentile indexing
726+ // ═══════════════════════════════════════════════════════════════════════
727+
728+ @ Test
729+ void testMonteCarlo_twoPassVariance_lowVolDoesNotGoNegative () throws JsonRpcFaultException {
730+ // Zero vol, positive drift: all paths converge to same value.
731+ // Old one-pass formula (sumSq/N - mean²) would produce catastrophic
732+ // cancellation here; two-pass should give exactly 0.
733+ MonteCarloRequest req = new MonteCarloRequest ();
734+ req .setNSimulations (10_000 );
735+ req .setVolatility (0.0 );
736+ req .setExpectedReturn (0.10 );
737+ req .setRandomSeed (1L );
738+
739+ MonteCarloResponse resp = service .monteCarlo (req );
740+
741+ assertEquals ("SUCCESS" , resp .getStatus ());
742+ assertTrue (resp .getStdDevFinalValue () < 1e-6 ,
743+ "zero-vol paths should have near-zero std dev (two-pass variance), got " +
744+ resp .getStdDevFinalValue ());
745+ }
746+
747+ @ Test
748+ void testMonteCarlo_percentileIndexing_ceilFormula () throws JsonRpcFaultException {
749+ // With 1000 sims and percentile=0.05, ceil(0.05*1000)-1 = 49 (50th worst)
750+ // With floor(0.05*1000) = 50 (51st worst) — the old, less-correct formula.
751+ // We verify that VaR at 5% uses the tighter (larger loss) estimate.
752+ MonteCarloRequest req = new MonteCarloRequest ();
753+ req .setNSimulations (1000 );
754+ req .setRandomSeed (42L );
755+ req .setPercentiles (new double []{0.05 });
756+
757+ MonteCarloResponse resp = service .monteCarlo (req );
758+
759+ assertEquals ("SUCCESS" , resp .getStatus ());
760+ // The VaR95 (fixed field) and the percentile VaR at 0.05 should match
761+ assertEquals (1 , resp .getPercentileVars ().size ());
762+ assertEquals (resp .getVar95 (), resp .getPercentileVars ().get (0 ).getVar (), 1e-9 ,
763+ "fixed var95 and percentile p=0.05 should use same index" );
764+ }
765+
550766 // ═══════════════════════════════════════════════════════════════════════
551767 // MonteCarloRequest — getter defaults
552768 // ═══════════════════════════════════════════════════════════════════════
0 commit comments