libitofin 0.49.0

A ground-up Rust port of QuantLib: quantitative-finance primitives for pricing, risk, and numerical methods.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
//! Monte Carlo American-option engine.
//!
//! Port of `ql/pricingengines/vanilla/mcamericanengine.{hpp,cpp}`: the
//! Longstaff-Schwartz engine for American and Bermudan vanilla options.
//! [`AmericanPathPricer`] supplies the three things the backward induction needs
//! from the option (`mcamericanengine.cpp:31-72`), [`MCAmericanEngine`] wires it
//! into the two-pass [`McLongstaffSchwartzEngineBase`] driver, and
//! [`MakeMcAmericanEngine`] ports the `MakeMCAmericanEngine` factory
//! (`mcamericanengine.hpp:105`).
//!
//! The regression state is the spot SCALED by `1 / strike` (`:51,66`), for
//! numerical stability, while the exercise value is the payoff of the UNSCALED
//! spot (`:60`). The payoff itself is appended to the basis system as one extra
//! function (`:45-46`), so order `n` yields `n + 2` basis functions:
//! `{1, s, ..., s^n, payoff(s)}`, all evaluated at the scaled state.
//!
//! Divergences from `mcamericanengine.{hpp,cpp}`, all deliberate:
//! - **the striked-payoff scaling is unconditional**: C++ takes an
//!   `ext::shared_ptr<Payoff>` and only divides `scalingValue_` by the strike
//!   when the `dynamic_pointer_cast<StrikedTypePayoff>` succeeds (`:48-52`).
//!   `OptionArguments::payoff` is already a `StrikedTypePayoff` here, so the
//!   cast is a compile-time fact and the scaling is always `1 / strike`.
//! - **the process is concretely a [`GeneralizedBlackScholesProcess`]**, so the
//!   "generalized Black-Scholes process required" downcast
//!   (`mcamericanengine.hpp:190-193`) cannot fail at run time, as with
//!   [`MCEuropeanEngine`](super::MCEuropeanEngine).
//!
//! Bermudan exercise is restricted to contractual dates in both calibration
//! and pricing. QuantLib 1.43 instead permits exercise on refined simulation
//! nodes; this deliberate correction is covered by deterministic path and FD
//! oracles. All positive contractual dates remain mandatory simulation nodes.
//!
//! The control variate (`mcamericanengine.hpp:76-78`) is the European twin:
//! [`control_path_pricer`](MCAmericanEngine::control_path_pricer) prices the
//! discounted terminal payoff of each pricing path, and
//! [`control_variate_value`](MCAmericanEngine::control_variate_value) reprices
//! the option analytically with a European exercise on its last date. Under
//! the control variate the value is floored at zero (`:176-183`). The C++
//! "StrikedTypePayoff needed for control variate" downcast (`:214-217`) cannot
//! fail here, for the same reason as the scaling above.
//!
//! Deferred, rejected visibly rather than silently ignored:
//! - **the multi-asset `MCAmericanBasketEngine`**, needing the `MultiPath` form
//!   of the Longstaff-Schwartz pricer.

use std::any::Any;

use crate::errors::QlResult;
use crate::exercise::{EuropeanExercise, Exercise, ExerciseType};
use crate::instruments::{OneAssetOptionResults, OptionArguments, StrikedTypePayoff};
use crate::math::randomnumbers::rngtraits::McRngTraits;
use crate::methods::montecarlo::{
    ControlVariate, EarlyExercisePathPricer, LongstaffSchwartzPathPricer, LsmBasisSystem, Path,
    PolynomialType,
};
use crate::patterns::observable::{AsObservable, Observable};
use crate::pricingengine::{Arguments, PricingEngine, Results};
use crate::pricingengines::McLongstaffSchwartzEngineBase;
use crate::pricingengines::vanilla::{AnalyticEuropeanEngine, EuropeanPathPricer};
use crate::processes::GeneralizedBlackScholesProcess;
use crate::shared::{Shared, shared};
use crate::stochasticprocess::StochasticProcess1D;
use crate::types::{Real, Size};
use crate::{fail, require};

/// Values an American vanilla option along one realized path
/// (`mcamericanengine.cpp:31-72`).
pub struct AmericanPathPricer {
    payoff: Shared<dyn StrikedTypePayoff>,
    scaling_value: Real,
    polynomial_order: Size,
    polynomial_type: PolynomialType,
    exercise_indices: Option<Vec<bool>>,
}

impl AmericanPathPricer {
    /// Builds the pricer, scaling the regression state by `1 / strike`
    /// (`mcamericanengine.cpp:48-52`).
    pub fn new(
        payoff: Shared<dyn StrikedTypePayoff>,
        polynomial_order: Size,
        polynomial_type: PolynomialType,
    ) -> AmericanPathPricer {
        let scaling_value = 1.0 / payoff.strike();
        AmericanPathPricer {
            payoff,
            scaling_value,
            polynomial_order,
            polynomial_type,
            exercise_indices: None,
        }
    }
}

impl EarlyExercisePathPricer<Path> for AmericanPathPricer {
    type State = Real;

    /// The payoff of the UNSCALED spot (`mcamericanengine.cpp:55-61`). C++ round
    /// trips the state through the scaling rather than reading `path[t]`, and so
    /// does this, so the two agree to the last bit.
    fn value(&self, path: &Path, t: Size) -> Real {
        if self
            .exercise_indices
            .as_ref()
            .is_some_and(|indices| !indices[t])
        {
            return 0.0;
        }
        self.payoff.value(self.state(path, t) / self.scaling_value)
    }

    /// The spot scaled by `1 / strike` (`mcamericanengine.cpp:63-66`).
    fn state(&self, path: &Path, t: Size) -> Real {
        path[t] * self.scaling_value
    }

    /// The monomials plus the payoff as one extra function
    /// (`mcamericanengine.cpp:45-46`). The appended function takes the SCALED
    /// state and undoes the scaling before applying the payoff (`:57`).
    fn basis_system(&self) -> Vec<Box<dyn Fn(Real) -> Real>> {
        let mut v = LsmBasisSystem::path_basis_system(self.polynomial_order, self.polynomial_type);
        let payoff = Shared::clone(&self.payoff);
        let scaling_value = self.scaling_value;
        v.push(Box::new(move |state: Real| {
            payoff.value(state / scaling_value)
        }));
        v
    }
}

/// American vanilla Monte Carlo engine (`mcamericanengine.hpp:51`), generic over `RNG`.
pub struct MCAmericanEngine<RNG> {
    base: McLongstaffSchwartzEngineBase<RNG>,
    process: Shared<GeneralizedBlackScholesProcess>,
    polynomial_order: Size,
    polynomial_type: PolynomialType,
}

impl<RNG: McRngTraits> MCAmericanEngine<RNG> {
    /// Builds the engine (`mcamericanengine.hpp:140-172`), which fixes the
    /// Brownian bridge to `false` (`:161`). Prefer [`MakeMcAmericanEngine`].
    ///
    /// # Errors
    ///
    /// Propagates the driver's time-step validation.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        process: Shared<GeneralizedBlackScholesProcess>,
        time_steps: Option<Size>,
        time_steps_per_year: Option<Size>,
        antithetic_variate: bool,
        control_variate: bool,
        required_samples: Option<Size>,
        required_tolerance: Option<Real>,
        max_samples: Option<Size>,
        seed: u32,
        polynomial_order: Size,
        polynomial_type: PolynomialType,
        n_calibration_samples: Option<Size>,
        antithetic_variate_calibration: Option<bool>,
        seed_calibration: Option<u32>,
    ) -> QlResult<MCAmericanEngine<RNG>> {
        require!(
            !matches!(
                polynomial_type,
                PolynomialType::Legendre | PolynomialType::Chebyshev
            ),
            "insufficient polynomial type"
        );
        let base = McLongstaffSchwartzEngineBase::new(
            Shared::clone(&process) as Shared<dyn StochasticProcess1D>,
            time_steps,
            time_steps_per_year,
            false,
            antithetic_variate,
            control_variate,
            required_samples,
            required_tolerance,
            max_samples,
            seed,
            n_calibration_samples,
            antithetic_variate_calibration,
            seed_calibration,
        )?;
        Ok(MCAmericanEngine {
            base,
            process,
            polynomial_order,
            polynomial_type,
        })
    }

    /// The two-pass driver underneath, for the calibration settings it defaults.
    pub fn lsm_base(&self) -> &McLongstaffSchwartzEngineBase<RNG> {
        &self.base
    }

    /// The regression order the basis system is built to.
    pub fn polynomial_order(&self) -> Size {
        self.polynomial_order
    }

    /// The C++ `lsmPathPricer()` hook (`mcamericanengine.hpp:186-207`): a fresh
    /// [`LongstaffSchwartzPathPricer`] over an [`AmericanPathPricer`], discounted
    /// on the process risk-free curve.
    ///
    /// # Errors
    ///
    /// Errors on a missing payoff, a missing exercise, an exercise that is not
    /// American or Bermudan (`:196`), or one paying at expiry (`:197-198`); propagates a
    /// grid or discount failure. Chebyshev2nd call payoffs are rejected because
    /// their in-the-money states exceed the weighted basis domain.
    pub fn lsm_path_pricer(&self) -> QlResult<Shared<LongstaffSchwartzPathPricer>> {
        let arguments = self.base.arguments();
        let Some(exercise) = &arguments.exercise else {
            fail!("no exercise given");
        };
        require!(
            matches!(
                exercise.exercise_type(),
                ExerciseType::American | ExerciseType::Bermudan
            ),
            "wrong exercise given"
        );
        require!(!exercise.payoff_at_expiry(), "payoff at expiry not handled");
        let Some(payoff) = &arguments.payoff else {
            fail!("no payoff given");
        };

        require!(
            self.polynomial_type != PolynomialType::Chebyshev2nd
                || payoff.option_type() != crate::option::OptionType::Call,
            "Chebyshev2nd basis is undefined for in-the-money call states"
        );
        let grid = self.base.time_grid()?;
        let mut early = AmericanPathPricer::new(
            Shared::clone(payoff),
            self.polynomial_order,
            self.polynomial_type,
        );
        if exercise.exercise_type() == ExerciseType::Bermudan {
            let mut indices = vec![false; grid.size()];
            for &time in grid.mandatory_times() {
                indices[grid.index(time)?] = true;
            }
            early.exercise_indices = Some(indices);
        }

        Ok(shared(LongstaffSchwartzPathPricer::new(
            &grid,
            shared(early) as Shared<dyn EarlyExercisePathPricer<Path, State = Real>>,
            &self.process.risk_free_rate(),
        )?))
    }
}

impl<RNG: McRngTraits> MCAmericanEngine<RNG> {
    /// The C++ `controlPathPricer()` hook (`mcamericanengine.hpp:211-229`): a
    /// [`EuropeanPathPricer`] on the option type and strike, discounted from
    /// the last node of the simulation grid.
    ///
    /// # Errors
    ///
    /// Errors on a missing payoff or a negative strike; propagates a grid or
    /// discount failure.
    pub fn control_path_pricer(&self) -> QlResult<EuropeanPathPricer> {
        let Some(payoff) = &self.base.arguments().payoff else {
            fail!("no payoff given");
        };
        let grid = self.base.time_grid()?;
        let Some(last_time) = grid.back() else {
            fail!("empty time grid");
        };
        let discount = self
            .process
            .risk_free_rate()
            .current_link()?
            .discount(last_time, false)?;
        EuropeanPathPricer::new(payoff.option_type(), payoff.strike(), discount)
    }

    /// The C++ `controlVariateValue()` hook (`mcamericanengine.hpp:244-265`):
    /// an [`AnalyticEuropeanEngine`] on the same process prices the option's
    /// payoff with a European exercise on the last exercise date. The inner
    /// engine reads only the arguments and the process, no `Settings`.
    ///
    /// # Errors
    ///
    /// Errors on a missing exercise; propagates the analytic engine's failure.
    pub fn control_variate_value(&self) -> QlResult<Real> {
        let arguments = self.base.arguments();
        let Some(exercise) = &arguments.exercise else {
            fail!("no exercise given");
        };
        let mut engine = AnalyticEuropeanEngine::new(Shared::clone(&self.process));
        let Some(control_arguments) =
            (engine.arguments_mut() as &mut dyn Any).downcast_mut::<OptionArguments>()
        else {
            fail!("engine is using inconsistent arguments");
        };
        control_arguments.payoff = arguments.payoff.clone();
        control_arguments.exercise =
            Some(shared(EuropeanExercise::new(exercise.last_date())) as Shared<dyn Exercise>);
        engine.calculate()?;
        let Some(results) = (engine.results() as &dyn Any).downcast_ref::<OneAssetOptionResults>()
        else {
            fail!("engine is using inconsistent results");
        };
        let Some(value) = results.instrument.value else {
            fail!("control variation pricing engine returned no value");
        };
        Ok(value)
    }
}

impl<RNG: McRngTraits> AsObservable for MCAmericanEngine<RNG> {
    fn observable(&self) -> &Observable {
        self.base.observable()
    }
}

impl<RNG: McRngTraits> PricingEngine for MCAmericanEngine<RNG> {
    fn arguments_mut(&mut self) -> &mut dyn Arguments {
        self.base.arguments_mut()
    }

    fn results(&self) -> &dyn Results {
        self.base.results()
    }

    fn reset(&mut self) {
        self.base.reset();
    }

    /// Builds a fresh path pricer and runs both passes, then floors the value
    /// at zero under the control variate (`mcamericanengine.hpp:175-183`).
    ///
    /// # Errors
    ///
    /// Propagates a [`lsm_path_pricer`](MCAmericanEngine::lsm_path_pricer),
    /// control-variate, or simulation failure.
    fn calculate(&mut self) -> QlResult<()> {
        let pricer = self.lsm_path_pricer()?;
        let control_variate = if self.base.control_variate() {
            Some(ControlVariate {
                value: self.control_variate_value()?,
                path_pricer: Box::new(self.control_path_pricer()?),
            })
        } else {
            None
        };
        self.base.calculate_with(pricer, control_variate)?;
        if self.base.control_variate() {
            let instrument = &mut self.base.results_mut().instrument;
            instrument.value = instrument.value.map(|value| value.max(0.0));
        }
        Ok(())
    }
}

/// Factory for [`MCAmericanEngine`] (`mcamericanengine.hpp:105`), generic over
/// the RNG policy `RNG`.
///
/// As with [`MakeMcEuropeanEngine`](super::MakeMcEuropeanEngine), the validation
/// the C++ builder splits across its setters is deferred to
/// [`build`](MakeMcAmericanEngine::build) so the setters stay chainable.
pub struct MakeMcAmericanEngine<RNG> {
    process: Shared<GeneralizedBlackScholesProcess>,
    steps: Option<Size>,
    steps_per_year: Option<Size>,
    samples: Option<Size>,
    max_samples: Option<Size>,
    tolerance: Option<Real>,
    antithetic: bool,
    control_variate: bool,
    seed: u32,
    polynomial_order: Size,
    calibration_samples: Option<Size>,
    polynomial_type: PolynomialType,
    _rng: std::marker::PhantomData<RNG>,
}

impl<RNG: McRngTraits> MakeMcAmericanEngine<RNG> {
    /// Starts a builder on the given Black-Scholes process, with the C++
    /// defaults: polynomial order 2 and `Monomial` (`mcamericanengine.hpp:136`),
    /// no antithetic variate, and the driver's 2048 calibration samples.
    pub fn new(process: Shared<GeneralizedBlackScholesProcess>) -> MakeMcAmericanEngine<RNG> {
        MakeMcAmericanEngine {
            process,
            steps: None,
            steps_per_year: None,
            samples: None,
            max_samples: None,
            tolerance: None,
            antithetic: false,
            control_variate: false,
            seed: 0,
            polynomial_order: 2,
            calibration_samples: None,
            polynomial_type: PolynomialType::Monomial,
            _rng: std::marker::PhantomData,
        }
    }

    /// Sets the fixed number of time steps (`mcamericanengine.hpp:280`).
    #[must_use]
    pub fn with_steps(mut self, steps: Size) -> Self {
        self.steps = Some(steps);
        self
    }

    /// Sets the number of time steps per year (`mcamericanengine.hpp:287`).
    #[must_use]
    pub fn with_steps_per_year(mut self, steps: Size) -> Self {
        self.steps_per_year = Some(steps);
        self
    }

    /// Sets the required number of samples (`mcamericanengine.hpp:295`).
    #[must_use]
    pub fn with_samples(mut self, samples: Size) -> Self {
        self.samples = Some(samples);
        self
    }

    /// Sets the required absolute tolerance (`mcamericanengine.hpp:304`).
    #[must_use]
    pub fn with_absolute_tolerance(mut self, tolerance: Real) -> Self {
        self.tolerance = Some(tolerance);
        self
    }

    /// Sets the maximum number of samples (`mcamericanengine.hpp:317`).
    #[must_use]
    pub fn with_max_samples(mut self, samples: Size) -> Self {
        self.max_samples = Some(samples);
        self
    }

    /// Sets the RNG seed (`mcamericanengine.hpp:333`).
    #[must_use]
    pub fn with_seed(mut self, seed: u32) -> Self {
        self.seed = seed;
        self
    }

    /// Requests the antithetic-variate variance reduction
    /// (`mcamericanengine.hpp:340`), on both the pricing and, by the driver's
    /// fallback, the calibration pass.
    #[must_use]
    pub fn with_antithetic_variate(mut self, antithetic: bool) -> Self {
        self.antithetic = antithetic;
        self
    }

    /// Requests the control variate (`mcamericanengine.hpp:119,358-361`): the
    /// analytic European twin, on the pricing pass only.
    #[must_use]
    pub fn with_control_variate(mut self, control_variate: bool) -> Self {
        self.control_variate = control_variate;
        self
    }

    /// Sets the regression order (`mcamericanengine.hpp:266`).
    #[must_use]
    pub fn with_polynomial_order(mut self, order: Size) -> Self {
        self.polynomial_order = order;
        self
    }

    /// Selects the regression family. The engine rejects Legendre and
    /// first-kind Chebyshev, matching QuantLib's American path pricer.
    #[must_use]
    pub fn with_basis_system(mut self, family: PolynomialType) -> Self {
        self.polynomial_type = family;
        self
    }

    /// Sets the number of calibration paths (`mcamericanengine.hpp:325`).
    #[must_use]
    pub fn with_calibration_samples(mut self, samples: Size) -> Self {
        self.calibration_samples = Some(samples);
        self
    }

    /// Builds the configured [`MCAmericanEngine`]
    /// (`mcamericanengine.hpp:348-370`).
    ///
    /// # Errors
    ///
    /// Errors if neither or both of `steps`/`steps_per_year` are set (`:352-355`),
    /// if both `samples` and `tolerance` are set (`:296,305`), or if a tolerance
    /// is set on an RNG policy without an error estimate (`:307`).
    pub fn build(self) -> QlResult<MCAmericanEngine<RNG>> {
        require!(
            self.steps.is_some() || self.steps_per_year.is_some(),
            "number of steps not given"
        );
        require!(
            self.steps.is_none() || self.steps_per_year.is_none(),
            "number of steps overspecified"
        );
        require!(
            !(self.samples.is_some() && self.tolerance.is_some()),
            "number of samples already set"
        );
        if self.tolerance.is_some() {
            require!(
                RNG::ALLOWS_ERROR_ESTIMATE,
                "chosen random generator policy does not allow an error estimate"
            );
        }
        MCAmericanEngine::new(
            self.process,
            self.steps,
            self.steps_per_year,
            self.antithetic,
            self.control_variate,
            self.samples,
            self.tolerance,
            self.max_samples,
            self.seed,
            self.polynomial_order,
            self.polynomial_type,
            self.calibration_samples,
            None,
            None,
        )
    }
}

#[cfg(test)]
mod tests {
    use std::any::Any;

    use super::*;
    use crate::exercise::{AmericanExercise, EuropeanExercise, Exercise};
    use crate::instruments::{OptionArguments, PlainVanillaPayoff};
    use crate::math::array::Array;
    use crate::math::randomnumbers::rngtraits::PseudoRandom;
    use crate::math::timegrid::TimeGrid;
    use crate::option::OptionType;
    use crate::pricingengines::vanilla::test_market::{Market, market, time_to_days, today};
    use crate::time::date::Date;

    const STRIKE: Real = 40.0;
    const TOL: Real = 1e-14;

    fn american(expiry: Date) -> Shared<dyn Exercise> {
        shared(AmericanExercise::over(today(), expiry).unwrap()) as Shared<dyn Exercise>
    }

    fn put_pricer(order: Size) -> AmericanPathPricer {
        AmericanPathPricer::new(
            shared(PlainVanillaPayoff::new(OptionType::Put, STRIKE))
                as Shared<dyn StrikedTypePayoff>,
            order,
            PolynomialType::Monomial,
        )
    }

    fn path(values: [Real; 3]) -> Path {
        Path::new(TimeGrid::new(1.0, 2).unwrap(), Array::from(values)).unwrap()
    }

    fn flat_market() -> Market {
        let market = market();
        market.set(36.0, 0.0, 0.06, 0.20);
        market
    }

    fn engine(market: &Market) -> MCAmericanEngine<PseudoRandom> {
        MakeMcAmericanEngine::<PseudoRandom>::new(Shared::clone(&market.process))
            .with_steps(4)
            .with_samples(256)
            .with_seed(42)
            .build()
            .unwrap()
    }

    fn set_option(engine: &mut MCAmericanEngine<PseudoRandom>, exercise: Shared<dyn Exercise>) {
        let args = (engine.arguments_mut() as &mut dyn Any)
            .downcast_mut::<OptionArguments>()
            .unwrap();
        args.payoff = Some(shared(PlainVanillaPayoff::new(OptionType::Put, STRIKE))
            as Shared<dyn StrikedTypePayoff>);
        args.exercise = Some(exercise);
    }

    /// The scaling arithmetic of `mcamericanengine.cpp:48-66` on a fixture where
    /// every wrong wiring lands somewhere else: a K=40 put at a spot of 36 gives
    /// `scaling = 0.025`, `state = 0.9`, and `value = 4`. Dividing rather than
    /// multiplying in `state` gives 1440; evaluating the payoff at the SCALED
    /// state gives 39.1.
    #[test]
    fn the_state_is_scaled_and_the_value_is_not() {
        let pricer = put_pricer(3);
        let p = path([36.0, 44.0, 38.0]);

        assert!((pricer.state(&p, 0) - 0.9).abs() < TOL);
        assert!((pricer.state(&p, 1) - 1.1).abs() < TOL);
        assert!((pricer.value(&p, 0) - 4.0).abs() < TOL);
        assert_eq!(pricer.value(&p, 1), 0.0, "an OTM put is worth 0");
        assert!((pricer.value(&p, 2) - 2.0).abs() < TOL);
    }

    /// The exercise value goes through the same scaling round trip C++ takes
    /// (`mcamericanengine.cpp:58-60`), not a shortcut to `payoff(path[t])`. On a
    /// K=3 put at a spot of 0.89 the round trip is not the identity, so the two
    /// compositions differ in the last bit and the exact compare separates them.
    #[test]
    fn the_exercise_value_round_trips_through_the_scaling() {
        let pricer = AmericanPathPricer::new(
            shared(PlainVanillaPayoff::new(OptionType::Put, 3.0)) as Shared<dyn StrikedTypePayoff>,
            2,
            PolynomialType::Monomial,
        );
        let p = path([0.89, 0.89, 0.89]);

        assert_eq!(pricer.value(&p, 0), 2.1100000000000003);
        assert_ne!(pricer.value(&p, 0), 3.0 - 0.89);
    }

    /// The payoff is APPENDED to the monomials (`mcamericanengine.cpp:45-46`),
    /// so order 3 gives five functions and the last one, fed the SCALED state
    /// 0.9, must report the payoff of the unscaled 36.
    #[test]
    fn the_payoff_is_the_extra_basis_function() {
        for order in [2, 3] {
            let basis = put_pricer(order).basis_system();
            assert_eq!(
                basis.len(),
                order + 2,
                "order {order}: monomials plus payoff"
            );

            assert!((basis[0](0.9) - 1.0).abs() < TOL);
            assert!((basis[1](0.9) - 0.9).abs() < TOL);
            assert!(
                (basis[order + 1](0.9) - 4.0).abs() < TOL,
                "the appended function must unscale before applying the payoff"
            );
            assert_eq!(
                basis[order + 1](1.1),
                0.0,
                "and must stay a payoff, not a monomial"
            );
        }
    }

    /// The exercise guards of `mcamericanengine.hpp:196-198`: a plain American
    /// exercise is accepted, a European one and an American one paying at
    /// expiry are not.
    #[test]
    fn only_a_plain_american_exercise_builds_a_path_pricer() {
        let market = flat_market();
        let expiry = today() + time_to_days(1.0);

        let mut accepted = engine(&market);
        set_option(&mut accepted, american(expiry));
        assert!(accepted.lsm_path_pricer().is_ok());

        let mut rejected = engine(&market);
        set_option(
            &mut rejected,
            shared(EuropeanExercise::new(expiry)) as Shared<dyn Exercise>,
        );
        assert_eq!(
            rejected.lsm_path_pricer().err().unwrap().message(),
            "wrong exercise given"
        );

        let mut at_expiry = engine(&market);
        set_option(
            &mut at_expiry,
            shared(AmericanExercise::new(today(), expiry, true).unwrap()) as Shared<dyn Exercise>,
        );
        assert_eq!(
            at_expiry.lsm_path_pricer().err().unwrap().message(),
            "payoff at expiry not handled"
        );
    }

    /// An engine with nothing set reports the missing exercise before anything
    /// else touches it.
    #[test]
    fn a_missing_exercise_is_rejected() {
        let market = flat_market();
        let mut bare = engine(&market);
        assert_eq!(
            bare.lsm_path_pricer().err().unwrap().message(),
            "no exercise given"
        );

        let args = (bare.arguments_mut() as &mut dyn Any)
            .downcast_mut::<OptionArguments>()
            .unwrap();
        args.exercise = Some(american(today() + time_to_days(1.0)));
        assert_eq!(
            bare.lsm_path_pricer().err().unwrap().message(),
            "no payoff given"
        );
    }

    /// The builder defaults of `mcamericanengine.hpp:130-137`: order 2, no
    /// antithetic variate, and the driver's 2048 calibration samples.
    #[test]
    fn the_builder_defaults_match_the_cpp_factory() {
        let market = flat_market();
        let built = engine(&market);
        assert_eq!(built.polynomial_order(), 2);
        assert_eq!(built.lsm_base().n_calibration_samples(), 2048);
        assert!(!built.lsm_base().antithetic_variate_calibration());

        let overridden = MakeMcAmericanEngine::<PseudoRandom>::new(Shared::clone(&market.process))
            .with_steps(4)
            .with_samples(256)
            .with_polynomial_order(4)
            .with_calibration_samples(64)
            .build()
            .unwrap();
        assert_eq!(overridden.polynomial_order(), 4);
        assert_eq!(overridden.lsm_base().n_calibration_samples(), 64);
    }

    /// The build guards of `mcamericanengine.hpp:296,305,352-355`.
    #[test]
    fn the_builder_validates_its_named_parameters() {
        let market = flat_market();
        let maker = || MakeMcAmericanEngine::<PseudoRandom>::new(Shared::clone(&market.process));

        assert!(maker().with_samples(256).build().is_err(), "no steps given");
        assert!(
            maker()
                .with_steps(4)
                .with_steps_per_year(50)
                .with_samples(256)
                .build()
                .is_err(),
            "steps overspecified"
        );
        assert!(
            maker()
                .with_steps(4)
                .with_samples(256)
                .with_absolute_tolerance(0.02)
                .build()
                .is_err(),
            "samples and tolerance are exclusive"
        );
        let antithetic = maker()
            .with_steps(4)
            .with_samples(256)
            .with_antithetic_variate(true)
            .build()
            .unwrap();
        assert!(
            antithetic.lsm_base().antithetic_variate_calibration(),
            "the pricing flag must reach the engine, not a hardcoded false"
        );
        assert!(
            maker()
                .with_steps(4)
                .with_absolute_tolerance(0.02)
                .with_max_samples(4_096)
                .build()
                .is_ok()
        );
    }

    #[test]
    fn bermudan_grid_retains_only_positive_mandatory_dates() {
        use crate::exercise::BermudanExercise;
        let market = flat_market();
        let mut engine = engine(&market);
        set_option(
            &mut engine,
            shared(
                BermudanExercise::new(
                    vec![
                        today() - 3,
                        today(),
                        today() + 91,
                        today() + 91,
                        today() + 203,
                        today() + 365,
                    ],
                    false,
                )
                .unwrap(),
            ),
        );
        let grid = engine.lsm_base().time_grid().unwrap();
        assert_eq!(
            grid.mandatory_times(),
            &[91.0 / 360.0, 203.0 / 360.0, 365.0 / 360.0]
        );
        assert_eq!(grid.size(), 5);
        assert_eq!(grid[1], 91.0 / 360.0);
        assert_eq!(grid[2], 203.0 / 360.0);
        assert!(engine.lsm_path_pricer().is_ok());
        set_option(
            &mut engine,
            shared(BermudanExercise::new(vec![today()], false).unwrap()),
        );
        assert!(
            engine
                .lsm_path_pricer()
                .err()
                .unwrap()
                .message()
                .contains("no positive exercise time")
        );
        set_option(
            &mut engine,
            shared(BermudanExercise::new(vec![today() + 365], true).unwrap()),
        );
        assert_eq!(
            engine.lsm_path_pricer().err().unwrap().message(),
            "payoff at expiry not handled"
        );
    }

    #[test]
    fn bermudan_mask_forbids_exercise_between_contract_dates_in_both_passes() {
        use crate::methods::montecarlo::PathPricer;
        let market = flat_market();
        let grid = TimeGrid::new(1.0, 2).unwrap();
        for (allowed, expected_positive) in [(false, false), (true, true)] {
            let mut early = put_pricer(2);
            early.exercise_indices = Some(vec![false, allowed, true]);
            let lsm = LongstaffSchwartzPathPricer::new(
                &grid,
                shared(early),
                &market.process.risk_free_rate(),
            )
            .unwrap();
            let sample = path([40.0, 1.0, 40.0]);
            assert_eq!(lsm.price(&sample), 0.0);
            lsm.calibrate().unwrap();
            assert_eq!(lsm.price(&sample) > 0.0, expected_positive);
            assert_eq!(
                lsm.exercise_probability().unwrap(),
                if allowed { 1.0 } else { 0.0 }
            );
        }
    }

    #[test]
    fn unsupported_engine_bases_are_rejected_before_sampling() {
        let market = flat_market();
        for family in [PolynomialType::Legendre, PolynomialType::Chebyshev] {
            let error = MakeMcAmericanEngine::<PseudoRandom>::new(Shared::clone(&market.process))
                .with_steps(4)
                .with_samples(16)
                .with_basis_system(family)
                .build()
                .err()
                .unwrap();
            assert_eq!(error.message(), "insufficient polynomial type");
        }
    }
}

#[cfg(test)]
mod oracle {
    //! The American-MC milestone: `test-suite/mclongstaffschwartzengine.cpp:123`
    //! `testAmericanOption`, the `i = 0, j = 0` case.
    //!
    //! Fixture, digit for digit from the C++ (`:127-193`): an American put on a
    //! spot of 36 struck at 36, `q = 0`, `r = 6%`, `sigma = 20%`, evaluation
    //! date 15 May 1998, curve reference 17 May 1998, maturity 17 May 1999,
    //! Actual365Fixed throughout. The engine is
    //! `MakeMCAmericanEngine<PseudoRandom>` with 75 steps, the antithetic
    //! variate, an absolute tolerance of 0.02, seed 42, polynomial order 3 over
    //! the Monomial basis, and the driver's default 2048 calibration paths. The
    //! evaluation date is deliberately NOT the curves' reference date; the C++
    //! fixture is the same way round.
    //!
    //! REFERENCE SUBSTITUTION, stated plainly. The C++ test reprices the option
    //! with `FdBlackScholesVanillaEngine(process, 401, 200)` and checks
    //! `|mc - fd| < 2.34 * errorEstimate` (`:201-206`). This historical test
    //! predates this crate's American FD support and retains its cached MC
    //! reference. The reference is the MC value QuantLib itself produces on this
    //! fixture, measured on a locally built QuantLib 1.43 dylib:
    //! `mc = 2.054422273006143`, `errorEstimate = 0.01775722870215829`,
    //! `exerciseProbability = 0.4897360703812317`, `fd = 2.08679820123328`.
    //! The band factor 2.34 is the C++ one unchanged.
    //!
    //! The 2.105 that the C++ file records in a comment (`:154`) is NOT usable
    //! as the reference: it is a third-party binomial number that fails the C++
    //! test's own band against QuantLib's own MC value
    //! (`|2.0544 - 2.105| = 0.0506 > 0.0416`), and an independent
    //! Cox-Rubinstein tree at 20001 steps puts the true American price at
    //! 2.08764, agreeing with the FD engine rather than with 2.105.
    //!
    //! Rust and C++ do not share a sample path bit for bit: the generated paths
    //! agree to 14 significant digits (a 75-step accumulation of last-bit
    //! ordering differences), and the near-collinear order-3 regression
    //! amplifies that into a different exercise boundary at only 2048
    //! calibration paths. The two agree where that noise washes out: at 131072
    //! calibration paths Rust prices 2.06774 against C++'s 2.06907, 0.07 error
    //! estimates apart, with exercise probabilities 0.4703 and 0.4712.
    //!
    //! Three independent gates: the price against QuantLib's own value within
    //! the C++ band, the exercise probability against 0.48013 within the C++
    //! 1.5% tolerance (`:154,214`), and the never-early-exercise floor, since
    //! an American put cannot be worth less than its European twin.
    //!
    //! The five remaining parameter cases below use independently generated
    //! QuantLib FD references and the original band/probability tolerances.
    //! The first-case cached-MC exception above remains explicit and unchanged.
    //! The Brownian bridge is not separate work: C++ fixes it to `false`
    //! (`mcamericanengine.hpp:161,169`). Multi-asset LSM remains separate
    //! work; the control-variate oracles close this module.

    use std::any::Any;

    use super::{MCAmericanEngine, MakeMcAmericanEngine};
    use crate::exercise::{AmericanExercise, EuropeanExercise, Exercise};
    use crate::handle::Handle;
    use crate::instrument::Instrument;
    use crate::instruments::{OneAssetOptionResults, OptionArguments};
    use crate::instruments::{PlainVanillaPayoff, StrikedTypePayoff, VanillaOption};
    use crate::interestrate::Compounding;
    use crate::math::randomnumbers::rngtraits::McRngTraits;
    use crate::math::randomnumbers::rngtraits::PseudoRandom;
    use crate::math::statistics::{GeneralStatistics, MeanStdDev, Statistics};
    use crate::methods::montecarlo::{MonteCarloModel, PathGenerator};
    use crate::option::OptionType;
    use crate::pricingengine::PricingEngine;
    use crate::pricingengines::vanilla::AnalyticEuropeanEngine;
    use crate::processes::GeneralizedBlackScholesProcess;
    use crate::quotes::{Quote, SimpleQuote};
    use crate::settings::Settings;
    use crate::shared::{Shared, SharedMut, shared, shared_mut};
    use crate::stochasticprocess::StochasticProcess1D;
    use crate::termstructures::volatility::{BlackConstantVol, BlackVolTermStructure};
    use crate::termstructures::yields::FlatForward;
    use crate::termstructures::yieldtermstructure::YieldTermStructure;
    use crate::time::date::{Date, Month};
    use crate::time::daycounters::actual365fixed::Actual365Fixed;
    use crate::time::frequency::Frequency;
    use crate::types::Size;
    use crate::types::{Rate, Real, Volatility};

    const UNDERLYING: Real = 36.0;
    const DIVIDEND_YIELD: Rate = 0.0;
    const RISK_FREE_RATE: Rate = 0.06;
    const VOLATILITY: Volatility = 0.20;

    const QUANTLIB_MC_VALUE: Real = 2.054422273006143;
    const EXPECTED_EXERCISE_PROBABILITY: Real = 0.48013;

    fn todays_date() -> Date {
        Date::new(15, Month::May, 1998)
    }

    fn settlement_date() -> Date {
        Date::new(17, Month::May, 1998)
    }

    fn maturity() -> Date {
        Date::new(17, Month::May, 1999)
    }

    fn flat_curve(rate: Rate) -> Handle<dyn YieldTermStructure> {
        Handle::new(shared(FlatForward::with_rate(
            settlement_date(),
            rate,
            Actual365Fixed::new(),
            Compounding::Continuous,
            Frequency::Annual,
        )) as Shared<dyn YieldTermStructure>)
    }

    fn process() -> Shared<GeneralizedBlackScholesProcess> {
        process_with_vol(VOLATILITY)
    }

    fn process_with_vol(volatility: Real) -> Shared<GeneralizedBlackScholesProcess> {
        let spot = Handle::new(shared(SimpleQuote::new(UNDERLYING)) as Shared<dyn Quote>);
        let vol = Handle::new(shared(BlackConstantVol::new(
            settlement_date(),
            None,
            volatility,
            Actual365Fixed::new(),
        )) as Shared<dyn BlackVolTermStructure>);
        shared(GeneralizedBlackScholesProcess::new(
            spot,
            flat_curve(DIVIDEND_YIELD),
            flat_curve(RISK_FREE_RATE),
            vol,
        ))
    }

    fn payoff() -> Shared<dyn StrikedTypePayoff> {
        shared(PlainVanillaPayoff::new(OptionType::Put, UNDERLYING))
            as Shared<dyn StrikedTypePayoff>
    }

    #[test]
    fn american_put_reproduces_the_quantlib_price_and_exercise_probability() {
        let settings = shared(Settings::new());
        settings.set_evaluation_date(todays_date());
        let process = process();

        let exercise = shared(AmericanExercise::over(settlement_date(), maturity()).unwrap())
            as Shared<dyn Exercise>;
        let mut american = VanillaOption::new(payoff(), exercise, Shared::clone(&settings));
        american.base_mut().set_pricing_engine(shared_mut(
            MakeMcAmericanEngine::<PseudoRandom>::new(Shared::clone(&process))
                .with_steps(75)
                .with_antithetic_variate(true)
                .with_absolute_tolerance(0.02)
                .with_seed(42)
                .with_polynomial_order(3)
                .build()
                .unwrap(),
        ) as SharedMut<dyn PricingEngine>);

        let calculated = american.npv().unwrap();
        let error_estimate = american.error_estimate().unwrap();
        let exercise_probability = american.result::<Real>("exerciseProbability").unwrap();

        let mut european = VanillaOption::new(
            payoff(),
            shared(EuropeanExercise::new(maturity())) as Shared<dyn Exercise>,
            settings,
        );
        european.base_mut().set_pricing_engine(
            shared_mut(AnalyticEuropeanEngine::new(process)) as SharedMut<dyn PricingEngine>
        );
        let european_value = european.npv().unwrap();

        assert!(
            (calculated - QUANTLIB_MC_VALUE).abs() < 2.34 * error_estimate,
            "price {calculated} +/- {error_estimate} misses QuantLib's \
             {QUANTLIB_MC_VALUE} by {}, band {}",
            (calculated - QUANTLIB_MC_VALUE).abs(),
            2.34 * error_estimate
        );
        assert!(
            (exercise_probability - EXPECTED_EXERCISE_PROBABILITY).abs() < 0.015,
            "exercise probability {exercise_probability} vs \
             {EXPECTED_EXERCISE_PROBABILITY}"
        );
        assert!(
            calculated > european_value,
            "an American put {calculated} cannot be worth less than its \
             European twin {european_value}"
        );
    }

    #[test]
    fn five_remaining_american_cases_preserve_upstream_acceptance() {
        let references = [
            (36.0, 0.2 + 0.1, 3.430219464343261, 0.51678),
            (36.0, 0.4, 4.785433883245219, 0.54598),
            (40.0, 0.2, 4.485350467454564, 0.75549),
            (40.0, 0.2 + 0.1, 5.736716960463202, 0.67569),
            (40.0, 0.4, 7.107644070425648, 0.65562),
        ];
        for (strike, volatility, fd, probability) in references {
            let settings = shared(Settings::new());
            settings.set_evaluation_date(todays_date());
            let process = process_with_vol(volatility);
            let exercise = shared(AmericanExercise::until(maturity(), false).unwrap());
            let mut option = VanillaOption::new(
                shared(PlainVanillaPayoff::new(OptionType::Put, strike)),
                exercise,
                settings,
            );
            option.base_mut().set_pricing_engine(shared_mut(
                MakeMcAmericanEngine::<PseudoRandom>::new(process)
                    .with_steps(75)
                    .with_antithetic_variate(true)
                    .with_absolute_tolerance(0.02)
                    .with_seed(42)
                    .with_polynomial_order(3)
                    .build()
                    .unwrap(),
            ));
            let value = option.npv().unwrap();
            let error = option.error_estimate().unwrap();
            assert!(
                error.is_finite() && error > 0.0,
                "invalid standard error {error}"
            );
            let exercise = option.result::<Real>("exerciseProbability").unwrap();
            assert!(
                (value - fd).abs() <= 2.34 * error,
                "strike={strike}, vol={volatility}: {value} +/- {error} vs FD {fd}"
            );
            assert!(
                (exercise - probability).abs() <= 0.015,
                "strike={strike}, vol={volatility}: exercise {exercise} vs {probability}"
            );
        }
    }

    #[test]
    fn bermudan_exercise_only_and_refined_grids_match_independent_oracles() {
        use crate::exercise::BermudanExercise;
        for (steps, expected) in [(3, 4.331587683099873), (75, 4.334758948830429)] {
            let settings = shared(Settings::new());
            settings.set_evaluation_date(todays_date());
            let exercise = shared(
                BermudanExercise::new(
                    vec![settlement_date() + 91, settlement_date() + 203, maturity()],
                    false,
                )
                .unwrap(),
            );
            let mut option = VanillaOption::new(
                shared(PlainVanillaPayoff::new(OptionType::Put, 40.0)),
                exercise,
                settings,
            );
            option.base_mut().set_pricing_engine(shared_mut(
                MakeMcAmericanEngine::<PseudoRandom>::new(process())
                    .with_steps(steps)
                    .with_samples(32768)
                    .with_calibration_samples(8192)
                    .with_seed(42)
                    .with_antithetic_variate(true)
                    .build()
                    .unwrap(),
            ));
            let value = option.npv().unwrap();
            let error = option.error_estimate().unwrap();
            assert!(
                error.is_finite() && error > 0.0,
                "invalid standard error {error}"
            );
            assert!(
                (value - expected).abs() <= 2.34 * error,
                "steps={steps}: {value} +/- {error} vs {expected}"
            );
        }
    }

    const CV_SAMPLES: Size = 4096;

    fn cv_engine(
        strike: Real,
        control_variate: bool,
        samples: Size,
        seed: u32,
    ) -> MCAmericanEngine<PseudoRandom> {
        cv_engine_with(strike, true, control_variate, samples, seed)
    }

    fn cv_engine_with(
        strike: Real,
        antithetic_variate: bool,
        control_variate: bool,
        samples: Size,
        seed: u32,
    ) -> MCAmericanEngine<PseudoRandom> {
        let mut engine = MakeMcAmericanEngine::<PseudoRandom>::new(process())
            .with_steps(75)
            .with_antithetic_variate(antithetic_variate)
            .with_control_variate(control_variate)
            .with_samples(samples)
            .with_seed(seed)
            .with_polynomial_order(3)
            .build()
            .unwrap();
        let args = (engine.arguments_mut() as &mut dyn Any)
            .downcast_mut::<OptionArguments>()
            .unwrap();
        args.payoff = Some(shared(PlainVanillaPayoff::new(OptionType::Put, strike))
            as Shared<dyn StrikedTypePayoff>);
        args.exercise = Some(
            shared(AmericanExercise::over(settlement_date(), maturity()).unwrap())
                as Shared<dyn Exercise>,
        );
        engine.calculate().unwrap();
        engine
    }

    /// The value, error estimate, and exercise probability of a calculation.
    fn outcome(engine: &MCAmericanEngine<PseudoRandom>) -> (Real, Real, Real) {
        let results = (engine.results() as &dyn Any)
            .downcast_ref::<OneAssetOptionResults>()
            .unwrap();
        let probability = *results.instrument.additional_results["exerciseProbability"]
            .downcast_ref::<Real>()
            .unwrap();
        (
            results.instrument.value.unwrap(),
            results.instrument.error_estimate.unwrap(),
            probability,
        )
    }

    /// The mean and error of the engine's own control pricer over the pricing
    /// paths, rebuilt independently: same grid, same seed, same antithetic
    /// pairing.
    fn european_over_the_pricing_paths(
        engine: &MCAmericanEngine<PseudoRandom>,
        samples: Size,
        seed: u32,
    ) -> (Real, Real) {
        let grid = engine.lsm_base().time_grid().unwrap();
        let generator = PseudoRandom::make_sequence_generator(grid.size() - 1, seed).unwrap();
        let paths = PathGenerator::from_time_grid(
            process() as Shared<dyn StochasticProcess1D>,
            grid,
            generator,
            false,
        )
        .unwrap();
        let mut model = MonteCarloModel::new(
            paths,
            engine.control_path_pricer().unwrap(),
            GeneralStatistics::new(),
            true,
        )
        .unwrap();
        model.add_samples(samples).unwrap();
        let accumulator = model.sample_accumulator();
        (
            accumulator.mean().unwrap(),
            accumulator.error_estimate().unwrap(),
        )
    }

    /// The CV oracles on the `mclongstaffschwartzengine.cpp:123` fixture, at
    /// a fixed sample count so both runs draw the SAME pricing paths. No QL
    /// test constructs `MCAmericanEngine` with a control variate, so the
    /// oracles are the deterministic consequences of the formula
    /// (`montecarlomodel.hpp:98-117`) rather than a cached number:
    /// - the CV run minus the plain run is exactly `analytic - mean(european)`
    ///   over those paths (the shift is material, so skipping the adjustment
    ///   fails);
    /// - the exercise probability is bit-identical, since the control never
    ///   reaches the Longstaff-Schwartz pricer (`mclongstaffschwartzengine.hpp:204`);
    /// - the control value is the analytic European twin and the control
    ///   pricer is an unbiased estimator of it.
    ///
    /// No FD band is asserted: a control variate does not remove the LSM low
    /// bias, so a smaller error would fail a band the plain run only just meets.
    #[test]
    fn control_variate_shifts_by_the_european_error_on_the_same_paths() {
        const SEED: u32 = 42;
        let plain = cv_engine(UNDERLYING, false, CV_SAMPLES, SEED);
        let controlled = cv_engine(UNDERLYING, true, CV_SAMPLES, SEED);
        let (v0, e0, p0) = outcome(&plain);
        let (v1, e1, p1) = outcome(&controlled);
        let analytic = controlled.control_variate_value().unwrap();
        let (mean, mean_error) = european_over_the_pricing_paths(&controlled, CV_SAMPLES, SEED);

        assert!(
            ((v1 - v0) - (analytic - mean)).abs() < 1e-12,
            "cv {v1} - plain {v0} != analytic {analytic} - european mean {mean}"
        );
        assert!((analytic - mean).abs() > 1e-4, "the shift must be material");
        assert_eq!(p1, p0, "the control must not touch the exercise decision");
        assert!(e0.is_finite() && e1.is_finite() && e1 > 0.0);

        let settings = shared(Settings::new());
        settings.set_evaluation_date(todays_date());
        let mut european = VanillaOption::new(
            payoff(),
            shared(EuropeanExercise::new(maturity())) as Shared<dyn Exercise>,
            settings,
        );
        european.base_mut().set_pricing_engine(
            shared_mut(AnalyticEuropeanEngine::new(process())) as SharedMut<dyn PricingEngine>
        );
        assert_eq!(analytic, european.npv().unwrap());
        assert!(
            (mean - analytic).abs() < 4.0 * mean_error,
            "control pricer mean {mean} +/- {mean_error} vs analytic {analytic}"
        );
    }

    /// The error estimate shrinks where the European twin tracks the American
    /// value. QuantLib's control enters with a fixed coefficient of one
    /// (`montecarlomodel.hpp:100`), so it reduces the variance only when
    /// `2 cov(american, european) > var(european)`, that is when the optimal
    /// coefficient `beta*` exceeds one half. Two pins on the same fixture:
    /// - the antithetic out-of-the-money K=32 put: 0.01184 against 0.01380;
    /// - the NON-antithetic at-the-money K=36 put: 0.03257 against 0.03817
    ///   (`beta* = 0.59`).
    ///
    /// The at-the-money failure needs the antithetic variate as well: with it,
    /// K=36 gives 0.02278 against 0.01662 under the same exercise probability.
    /// Antithetic pairing cuts `var(american)` sharply but leaves the residual
    /// `american - european`, which is almost even in the Gaussian draw,
    /// untouched; the review's probe on its own calibration seed measures se
    /// 0.0278 to 0.0174 for the former and 0.02198 single against 0.02191
    /// paired for the latter (`docs/internal/reviews-2026-11-08/1363.md`).
    /// After that cut the fixed coefficient overshoots: `beta*` falls to 0.32
    /// on this engine's calibration (0.37 in the review's probe).
    #[test]
    fn control_variate_reduces_the_error_where_the_twin_tracks_the_value() {
        for (strike, antithetic) in [(32.0, true), (UNDERLYING, false)] {
            let (_, plain, _) = outcome(&cv_engine_with(strike, antithetic, false, CV_SAMPLES, 42));
            let (_, controlled, _) =
                outcome(&cv_engine_with(strike, antithetic, true, CV_SAMPLES, 42));
            assert!(
                controlled < plain,
                "K={strike}, antithetic={antithetic}: cv error {controlled} \
                 must fall below plain error {plain}"
            );
        }
    }

    /// The `max(0, value)` floor under the control variate
    /// (`mcamericanengine.hpp:178-181`). On this deep-OTM put and seed, found
    /// by scanning, the unfloored CV estimate `plain + analytic - mean` is
    /// about `-0.029`; the engine must report exactly zero.
    #[test]
    fn a_negative_control_variate_estimate_is_floored_at_zero() {
        const STRIKE: Real = 22.0;
        const SAMPLES: Size = 64;
        const SEED: u32 = 16;
        let plain = cv_engine(STRIKE, false, SAMPLES, SEED);
        let controlled = cv_engine(STRIKE, true, SAMPLES, SEED);
        let analytic = controlled.control_variate_value().unwrap();
        let (mean, _) = european_over_the_pricing_paths(&controlled, SAMPLES, SEED);
        let raw = outcome(&plain).0 + analytic - mean;

        assert!(
            raw < -0.01,
            "fixture must produce a negative raw estimate: {raw}"
        );
        assert_eq!(outcome(&controlled).0, 0.0);
    }
}