quantark 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantark/__init__.py +3 -0
- quantark/_compat.py +150 -0
- quantark/asset/__init__.py +8 -0
- quantark/asset/bond/__init__.py +2 -0
- quantark/asset/bond/engine/__init__.py +44 -0
- quantark/asset/bond/engine/analytical/__init__.py +12 -0
- quantark/asset/bond/engine/analytical/black_engine.py +583 -0
- quantark/asset/bond/engine/analytical/bond_forward_engine.py +390 -0
- quantark/asset/bond/engine/analytical/bond_futures_engine.py +569 -0
- quantark/asset/bond/engine/convertible/__init__.py +12 -0
- quantark/asset/bond/engine/convertible/convertible_bond_engine.py +800 -0
- quantark/asset/bond/engine/discount/__init__.py +10 -0
- quantark/asset/bond/engine/discount/bond_discount_engine.py +517 -0
- quantark/asset/bond/engine/discount/frn_engine.py +913 -0
- quantark/asset/bond/engine/pde/__init__.py +14 -0
- quantark/asset/bond/engine/pde/convertible/__init__.py +21 -0
- quantark/asset/bond/engine/pde/convertible/jump_diffusion_engine.py +603 -0
- quantark/asset/bond/engine/pde/convertible/pde_params.py +59 -0
- quantark/asset/bond/engine/pde/convertible/tf_engine.py +546 -0
- quantark/asset/bond/engine/tree/__init__.py +14 -0
- quantark/asset/bond/engine/tree/convertible/__init__.py +21 -0
- quantark/asset/bond/engine/tree/convertible/binomial_engine.py +488 -0
- quantark/asset/bond/engine/tree/convertible/tree_params.py +72 -0
- quantark/asset/bond/engine/tree/convertible/trinomial_engine.py +1341 -0
- quantark/asset/bond/product/__init__.py +37 -0
- quantark/asset/bond/product/base_bond_product.py +114 -0
- quantark/asset/bond/product/convertible/__init__.py +16 -0
- quantark/asset/bond/product/convertible/convertible_bond.py +595 -0
- quantark/asset/bond/product/couponbond/__init__.py +12 -0
- quantark/asset/bond/product/couponbond/fixed_bond.py +285 -0
- quantark/asset/bond/product/couponbond/frn.py +538 -0
- quantark/asset/bond/product/forward/__init__.py +9 -0
- quantark/asset/bond/product/forward/base_bond_forward.py +92 -0
- quantark/asset/bond/product/forward/bond_forward.py +335 -0
- quantark/asset/bond/product/futures/__init__.py +8 -0
- quantark/asset/bond/product/futures/bond_futures.py +532 -0
- quantark/asset/bond/product/option/__init__.py +9 -0
- quantark/asset/bond/product/option/euro_short_term_bond_option.py +231 -0
- quantark/asset/bond/riskmeasures/__init__.py +13 -0
- quantark/asset/bond/riskmeasures/bond_greeks_calculator.py +484 -0
- quantark/asset/bond/schedule/__init__.py +21 -0
- quantark/asset/bond/schedule/cashflow.py +595 -0
- quantark/asset/equity/__init__.py +11 -0
- quantark/asset/equity/analysis/__init__.py +4 -0
- quantark/asset/equity/analysis/autocallable_path_analyzer.py +257 -0
- quantark/asset/equity/engine/__init__.py +84 -0
- quantark/asset/equity/engine/analytical/__init__.py +37 -0
- quantark/asset/equity/engine/analytical/american_option_engine.py +682 -0
- quantark/asset/equity/engine/analytical/asian_option_analytical_engine.py +1102 -0
- quantark/asset/equity/engine/analytical/barrier_analytical_engine.py +455 -0
- quantark/asset/equity/engine/analytical/black_scholes_engine.py +322 -0
- quantark/asset/equity/engine/analytical/deltaone_engine.py +340 -0
- quantark/asset/equity/engine/analytical/digital_option_engine.py +168 -0
- quantark/asset/equity/engine/analytical/double_barrier_option_engine.py +481 -0
- quantark/asset/equity/engine/analytical/double_sharkfin_option_analytical_engine.py +508 -0
- quantark/asset/equity/engine/analytical/one_touch_analytical_engine.py +302 -0
- quantark/asset/equity/engine/analytical/range_accrual_analytical_engine.py +396 -0
- quantark/asset/equity/engine/analytical/single_sharkfin_option_analytical_engine.py +229 -0
- quantark/asset/equity/engine/base_engine.py +137 -0
- quantark/asset/equity/engine/event_stats.py +85 -0
- quantark/asset/equity/engine/mc/__init__.py +31 -0
- quantark/asset/equity/engine/mc/american_option_mc_engine.py +485 -0
- quantark/asset/equity/engine/mc/asian_option_mc_engine.py +678 -0
- quantark/asset/equity/engine/mc/barrier_option_mc_engine.py +726 -0
- quantark/asset/equity/engine/mc/digital_option_mc_engine.py +419 -0
- quantark/asset/equity/engine/mc/double_sharkfin_option_mc_engine.py +676 -0
- quantark/asset/equity/engine/mc/euro_mc_engine.py +423 -0
- quantark/asset/equity/engine/mc/phoenix_mc_engine.py +1206 -0
- quantark/asset/equity/engine/mc/range_accrual_mc_engine.py +738 -0
- quantark/asset/equity/engine/mc/single_sharkfin_option_mc_engine.py +549 -0
- quantark/asset/equity/engine/mc/snowball_mc_engine.py +2250 -0
- quantark/asset/equity/engine/pde/__init__.py +36 -0
- quantark/asset/equity/engine/pde/american_pde_solver.py +211 -0
- quantark/asset/equity/engine/pde/barrier_pde_solver.py +692 -0
- quantark/asset/equity/engine/pde/base_pde_solver.py +994 -0
- quantark/asset/equity/engine/pde/double_barrier_pde_solver.py +510 -0
- quantark/asset/equity/engine/pde/double_one_touch_pde_solver.py +435 -0
- quantark/asset/equity/engine/pde/european_pde_solver.py +170 -0
- quantark/asset/equity/engine/pde/ko_reset_snowball_pde_solver.py +477 -0
- quantark/asset/equity/engine/pde/one_touch_pde_solver.py +439 -0
- quantark/asset/equity/engine/pde/phoenix_pde_solver.py +613 -0
- quantark/asset/equity/engine/pde/snowball_pde_solver.py +1810 -0
- quantark/asset/equity/engine/pde/spatial_grid.py +750 -0
- quantark/asset/equity/engine/pde/time_grid.py +308 -0
- quantark/asset/equity/engine/pde_engine.py +238 -0
- quantark/asset/equity/engine/quad/__init__.py +23 -0
- quantark/asset/equity/engine/quad/discrete_quad_engine.py +106 -0
- quantark/asset/equity/engine/quad/european_quad_engine.py +325 -0
- quantark/asset/equity/engine/quad/ko_reset_snowball_quad_engine.py +362 -0
- quantark/asset/equity/engine/quad/phoenix_quad_engine.py +614 -0
- quantark/asset/equity/engine/quad/quad_adapters.py +1260 -0
- quantark/asset/equity/engine/quad/quad_core.py +513 -0
- quantark/asset/equity/engine/quad/quad_math.py +219 -0
- quantark/asset/equity/engine/quad/snowball_quad_engine.py +1137 -0
- quantark/asset/equity/engine/validation/script/benchmark_check_american_analytical.py +117 -0
- quantark/asset/equity/engine/validation/script/benchmark_check_american_pde.py +114 -0
- quantark/asset/equity/engine/validation/script/benchmark_check_asian_analytical.py +440 -0
- quantark/asset/equity/engine/validation/script/benchmark_check_barrier_analytical.py +269 -0
- quantark/asset/equity/engine/validation/script/benchmark_check_barrier_pde_solver.py +636 -0
- quantark/asset/equity/engine/validation/script/benchmark_check_digital_option.py +256 -0
- quantark/asset/equity/engine/validation/script/benchmark_check_snowball_pde_solver.py +807 -0
- quantark/asset/equity/engine/validation/script/boundary_check_american_analytical.py +290 -0
- quantark/asset/equity/engine/validation/script/boundary_check_american_pde.py +242 -0
- quantark/asset/equity/engine/validation/script/boundary_check_asian_analytical.py +612 -0
- quantark/asset/equity/engine/validation/script/boundary_check_barrier_analytical.py +434 -0
- quantark/asset/equity/engine/validation/script/boundary_check_barrier_pde_solver.py +748 -0
- quantark/asset/equity/engine/validation/script/boundary_check_digital_option.py +575 -0
- quantark/asset/equity/engine/validation/script/boundary_check_snowball_pde_solver.py +1101 -0
- quantark/asset/equity/engine/validation/script/greeks_check_digital_option.py +349 -0
- quantark/asset/equity/engine/validation/script/mc_comparison_barrier_pde.py +270 -0
- quantark/asset/equity/engine/validation/script/quick_mc_compare.py +51 -0
- quantark/asset/equity/engine/validation/script/validation_stepdown_improved.py +97 -0
- quantark/asset/equity/param/__init__.py +24 -0
- quantark/asset/equity/param/engine_param_profiles.py +325 -0
- quantark/asset/equity/param/engine_params.py +728 -0
- quantark/asset/equity/process/__init__.py +7 -0
- quantark/asset/equity/process/bsm/__init__.py +7 -0
- quantark/asset/equity/process/bsm/bsm_process.py +108 -0
- quantark/asset/equity/process/bsm/qmc_brownian_bridge.py +401 -0
- quantark/asset/equity/process/bsm/qmc_path_generator.py +694 -0
- quantark/asset/equity/process/bsm/qmc_rqmc_driver.py +163 -0
- quantark/asset/equity/process/bsm/qmc_sobol.py +195 -0
- quantark/asset/equity/process/bsm/qmc_variance_reduction.py +292 -0
- quantark/asset/equity/product/__init__.py +8 -0
- quantark/asset/equity/product/base_equity_product.py +72 -0
- quantark/asset/equity/product/deltaone/__init__.py +22 -0
- quantark/asset/equity/product/deltaone/base_deltaone_product.py +147 -0
- quantark/asset/equity/product/deltaone/futures.py +485 -0
- quantark/asset/equity/product/deltaone/spot_instrument.py +118 -0
- quantark/asset/equity/product/option/__init__.py +104 -0
- quantark/asset/equity/product/option/american_option.py +114 -0
- quantark/asset/equity/product/option/asian_option.py +531 -0
- quantark/asset/equity/product/option/barrier_option.py +289 -0
- quantark/asset/equity/product/option/base_equity_option.py +659 -0
- quantark/asset/equity/product/option/digital_option.py +102 -0
- quantark/asset/equity/product/option/double_barrier_option.py +286 -0
- quantark/asset/equity/product/option/double_one_touch_option.py +310 -0
- quantark/asset/equity/product/option/double_sharkfin_option.py +466 -0
- quantark/asset/equity/product/option/european_vanilla_option.py +103 -0
- quantark/asset/equity/product/option/ko_reset_snowball_option.py +563 -0
- quantark/asset/equity/product/option/observation_schedule.py +530 -0
- quantark/asset/equity/product/option/one_touch_option.py +287 -0
- quantark/asset/equity/product/option/phoenix_config.py +116 -0
- quantark/asset/equity/product/option/phoenix_helpers.py +576 -0
- quantark/asset/equity/product/option/phoenix_option.py +1167 -0
- quantark/asset/equity/product/option/range_accrual_config.py +288 -0
- quantark/asset/equity/product/option/range_accrual_helpers.py +608 -0
- quantark/asset/equity/product/option/range_accrual_option.py +526 -0
- quantark/asset/equity/product/option/single_sharkfin_option.py +420 -0
- quantark/asset/equity/product/option/snowball_config.py +261 -0
- quantark/asset/equity/product/option/snowball_helpers.py +977 -0
- quantark/asset/equity/product/option/snowball_option.py +1242 -0
- quantark/asset/equity/report/__init__.py +15 -0
- quantark/asset/equity/report/autocallable_risk_report.py +2118 -0
- quantark/asset/equity/report/plotting.py +87 -0
- quantark/asset/equity/report/snowball_risk_comparison_report.py +2230 -0
- quantark/asset/equity/report/surfaces.py +123 -0
- quantark/asset/equity/report/term_structure.py +126 -0
- quantark/asset/equity/riskmeasures/__init__.py +7 -0
- quantark/asset/equity/riskmeasures/greeks_calculator.py +1204 -0
- quantark/asset/rate/__init__.py +58 -0
- quantark/asset/rate/engine/__init__.py +25 -0
- quantark/asset/rate/engine/cap_floor_engine.py +514 -0
- quantark/asset/rate/engine/fra_engine.py +286 -0
- quantark/asset/rate/engine/irs_discount_engine.py +891 -0
- quantark/asset/rate/engine/swaption_engine.py +587 -0
- quantark/asset/rate/product/__init__.py +67 -0
- quantark/asset/rate/product/cap_floor.py +550 -0
- quantark/asset/rate/product/fra.py +219 -0
- quantark/asset/rate/product/irs.py +1223 -0
- quantark/asset/rate/product/swaption.py +372 -0
- quantark/backtest/__init__.py +153 -0
- quantark/backtest/base.py +263 -0
- quantark/backtest/dashboard.py +874 -0
- quantark/backtest/equity/__init__.py +35 -0
- quantark/backtest/equity/config.py +118 -0
- quantark/backtest/equity/engine.py +408 -0
- quantark/backtest/equity/hedge_executor.py +374 -0
- quantark/backtest/equity/metrics.py +396 -0
- quantark/backtest/equity/results.py +232 -0
- quantark/backtest/equity/state.py +252 -0
- quantark/backtest/examples/__init__.py +4 -0
- quantark/backtest/examples/advanced_backtest.py +345 -0
- quantark/backtest/examples/basic_delta_hedge.py +246 -0
- quantark/backtest/examples/fi_dv01_hedge.py +267 -0
- quantark/backtest/fi/__init__.py +30 -0
- quantark/backtest/fi/config.py +114 -0
- quantark/backtest/fi/engine.py +378 -0
- quantark/backtest/fi/hedge_executor.py +254 -0
- quantark/backtest/fi/metrics.py +308 -0
- quantark/backtest/fi/results.py +193 -0
- quantark/backtest/fi/state.py +212 -0
- quantark/backtest/logger.py +393 -0
- quantark/backtest/otc/__init__.py +74 -0
- quantark/backtest/otc/_replay.py +637 -0
- quantark/backtest/otc/book_engine.py +587 -0
- quantark/backtest/otc/config.py +175 -0
- quantark/backtest/otc/dashboard.py +1006 -0
- quantark/backtest/otc/engine.py +420 -0
- quantark/backtest/otc/engine_factory.py +138 -0
- quantark/backtest/otc/market.py +216 -0
- quantark/backtest/otc/results.py +107 -0
- quantark/backtest/otc/state.py +166 -0
- quantark/backtest/report_generator.py +608 -0
- quantark/backtest/strategy/__init__.py +28 -0
- quantark/backtest/strategy/base_strategy.py +235 -0
- quantark/backtest/strategy/convexity_neutral_strategy.py +247 -0
- quantark/backtest/strategy/delta_neutral_strategy.py +283 -0
- quantark/backtest/strategy/dv01_neutral_strategy.py +283 -0
- quantark/backtest/transaction_costs.py +485 -0
- quantark/backtest/visualizer.py +1019 -0
- quantark/cashleg/__init__.py +31 -0
- quantark/cashleg/accrual_leg.py +120 -0
- quantark/cashleg/base.py +48 -0
- quantark/cashleg/base_amount.py +60 -0
- quantark/cashleg/deterministic_leg.py +39 -0
- quantark/cashleg/event_distribution.py +262 -0
- quantark/cashleg/fixed_payoff_leg.py +92 -0
- quantark/cashleg/leg_schedule.py +95 -0
- quantark/cashleg/leg_valuator.py +40 -0
- quantark/dynamicscenario/__init__.py +97 -0
- quantark/dynamicscenario/base.py +297 -0
- quantark/dynamicscenario/config.py +122 -0
- quantark/dynamicscenario/engine.py +703 -0
- quantark/dynamicscenario/equity/__init__.py +14 -0
- quantark/dynamicscenario/fi/__init__.py +24 -0
- quantark/dynamicscenario/fi/config.py +149 -0
- quantark/dynamicscenario/fi/engine.py +500 -0
- quantark/dynamicscenario/fi/results.py +503 -0
- quantark/dynamicscenario/path/__init__.py +17 -0
- quantark/dynamicscenario/path/day_path.py +397 -0
- quantark/dynamicscenario/path/fi_path_library.py +488 -0
- quantark/dynamicscenario/path/path_builder.py +726 -0
- quantark/dynamicscenario/path/path_library.py +620 -0
- quantark/dynamicscenario/report/__init__.py +12 -0
- quantark/dynamicscenario/report/dynamic_report.py +1175 -0
- quantark/dynamicscenario/report/visualizer.py +1586 -0
- quantark/dynamicscenario/results/__init__.py +19 -0
- quantark/dynamicscenario/results/dynamic_results.py +579 -0
- quantark/dynamicscenario/results/result_exporter.py +438 -0
- quantark/param/__init__.py +75 -0
- quantark/param/basis/__init__.py +19 -0
- quantark/param/basis/basis_yield.py +301 -0
- quantark/param/div/__init__.py +16 -0
- quantark/param/div/dividend_yield.py +123 -0
- quantark/param/index/__init__.py +52 -0
- quantark/param/index/rate_index.py +568 -0
- quantark/param/quote/__init__.py +7 -0
- quantark/param/quote/spot_quote.py +35 -0
- quantark/param/rrf/__init__.py +22 -0
- quantark/param/rrf/rate_curve.py +436 -0
- quantark/param/vol/__init__.py +6 -0
- quantark/param/vol/vol_surface.py +118 -0
- quantark/portfolio/__init__.py +61 -0
- quantark/portfolio/base.py +203 -0
- quantark/portfolio/equity/__init__.py +17 -0
- quantark/portfolio/equity/portfolio.py +391 -0
- quantark/portfolio/equity/position.py +368 -0
- quantark/portfolio/fi/__init__.py +14 -0
- quantark/portfolio/fi/portfolio.py +424 -0
- quantark/portfolio/fi/position.py +272 -0
- quantark/portfolio/portfolio_snapshot.py +221 -0
- quantark/portfolio/portfolio_storage.py +414 -0
- quantark/priceenv/__init__.py +7 -0
- quantark/priceenv/pricing_environment.py +196 -0
- quantark/rfq/__init__.py +32 -0
- quantark/rfq/builders.py +102 -0
- quantark/rfq/models.py +214 -0
- quantark/rfq/registry.py +611 -0
- quantark/rfq/service.py +237 -0
- quantark/simm/__init__.py +155 -0
- quantark/simm/calibration/__init__.py +206 -0
- quantark/simm/calibration/accessors.py +439 -0
- quantark/simm/calibration/commodity.py +156 -0
- quantark/simm/calibration/credit_non_qualifying.py +79 -0
- quantark/simm/calibration/credit_qualifying.py +130 -0
- quantark/simm/calibration/cross_risk.py +39 -0
- quantark/simm/calibration/equity.py +125 -0
- quantark/simm/calibration/fx.py +92 -0
- quantark/simm/calibration/ir.py +152 -0
- quantark/simm/calibration/version.py +33 -0
- quantark/simm/config.py +186 -0
- quantark/simm/crif/__init__.py +35 -0
- quantark/simm/crif/models.py +230 -0
- quantark/simm/crif/parser.py +585 -0
- quantark/simm/engines/__init__.py +62 -0
- quantark/simm/engines/aggregation/__init__.py +67 -0
- quantark/simm/engines/aggregation/addon.py +141 -0
- quantark/simm/engines/aggregation/bucket_aggregator.py +298 -0
- quantark/simm/engines/aggregation/concentration.py +349 -0
- quantark/simm/engines/aggregation/product_class_aggregator.py +183 -0
- quantark/simm/engines/aggregation/risk_class_aggregator.py +403 -0
- quantark/simm/engines/aggregation/simm_calculator.py +430 -0
- quantark/simm/engines/aggregation/weighted_sensitivity.py +272 -0
- quantark/simm/engines/base.py +231 -0
- quantark/simm/engines/classification/__init__.py +10 -0
- quantark/simm/engines/classification/bucket_mapper.py +347 -0
- quantark/simm/engines/factory.py +137 -0
- quantark/simm/engines/portfolio_adapter.py +336 -0
- quantark/simm/engines/result.py +176 -0
- quantark/simm/engines/risk_class/__init__.py +18 -0
- quantark/simm/engines/risk_class/equity_engine.py +263 -0
- quantark/simm/engines/risk_class/ir_engine.py +264 -0
- quantark/simm/report/__init__.py +17 -0
- quantark/simm/report/crif_export.py +284 -0
- quantark/simm/report/excel_generator.py +401 -0
- quantark/simm/report/html_generator.py +840 -0
- quantark/simm/results/__init__.py +38 -0
- quantark/simm/results/attribution.py +313 -0
- quantark/simm/results/simm_result.py +339 -0
- quantark/simm/results/whatif.py +268 -0
- quantark/simm/sensitivity.py +533 -0
- quantark/simm/taxonomy.py +416 -0
- quantark/stresstest/__init__.py +67 -0
- quantark/stresstest/base.py +116 -0
- quantark/stresstest/config.py +5 -0
- quantark/stresstest/engine.py +5 -0
- quantark/stresstest/equity/__init__.py +17 -0
- quantark/stresstest/equity/config.py +69 -0
- quantark/stresstest/equity/engine.py +272 -0
- quantark/stresstest/equity/report/__init__.py +7 -0
- quantark/stresstest/equity/report/report_generator.py +423 -0
- quantark/stresstest/equity/report/visualizer.py +328 -0
- quantark/stresstest/equity/results.py +145 -0
- quantark/stresstest/fi/__init__.py +15 -0
- quantark/stresstest/fi/config.py +59 -0
- quantark/stresstest/fi/engine.py +213 -0
- quantark/stresstest/fi/metrics.py +60 -0
- quantark/stresstest/fi/results.py +64 -0
- quantark/stresstest/report/__init__.py +12 -0
- quantark/stresstest/report/report_generator.py +5 -0
- quantark/stresstest/report/visualizer.py +5 -0
- quantark/stresstest/results/__init__.py +16 -0
- quantark/stresstest/results/result_aggregator.py +325 -0
- quantark/stresstest/results/result_exporter.py +286 -0
- quantark/stresstest/results/stress_results.py +5 -0
- quantark/stresstest/scenario/__init__.py +13 -0
- quantark/stresstest/scenario/scenario.py +242 -0
- quantark/stresstest/scenario/scenario_builder.py +376 -0
- quantark/stresstest/scenario/scenario_library.py +435 -0
- quantark/stresstest/scenario/scenario_storage.py +224 -0
- quantark/stresstest/stress/__init__.py +13 -0
- quantark/stresstest/stress/stress_applicator.py +590 -0
- quantark/stresstest/stress/stress_types.py +142 -0
- quantark/util/__init__.py +23 -0
- quantark/util/barrier_shift.py +44 -0
- quantark/util/calendar/__init__.py +27 -0
- quantark/util/calendar/business_calendar.py +584 -0
- quantark/util/calendar/day_counter.py +517 -0
- quantark/util/calendar/holidayfile/china.csv +1920 -0
- quantark/util/calendar/holidayfile/china_sse.csv +1462 -0
- quantark/util/enum/__init__.py +81 -0
- quantark/util/enum/bond_enums.py +112 -0
- quantark/util/enum/deltaone_enums.py +16 -0
- quantark/util/enum/engine_enums.py +137 -0
- quantark/util/enum/greeks_enums.py +29 -0
- quantark/util/enum/option_enums.py +221 -0
- quantark/util/exceptions.py +66 -0
- quantark/util/marketdata/__init__.py +39 -0
- quantark/util/marketdata/adapter/base_adapter.py +203 -0
- quantark/util/marketdata/adapter/mock_adapter.py +265 -0
- quantark/util/marketdata/converter.py +289 -0
- quantark/util/marketdata/example_usage.py +314 -0
- quantark/util/marketdata/generator/__init__.py +7 -0
- quantark/util/marketdata/generator/mock_generator.py +466 -0
- quantark/util/marketdata/models.py +358 -0
- quantark/util/marketdata/storage/__init__.py +7 -0
- quantark/util/marketdata/storage/parquet_storage.py +340 -0
- quantark/util/numerical/__init__.py +98 -0
- quantark/util/numerical/comparison.py +219 -0
- quantark/util/numerical/constants.py +98 -0
- quantark/util/numerical/formatting.py +380 -0
- quantark/util/numerical/pnl.py +17 -0
- quantark/util/numerical/safe_math.py +238 -0
- quantark/util/numerical/validation.py +315 -0
- quantark/var/__init__.py +39 -0
- quantark/var/attribution.py +398 -0
- quantark/var/backtest/__init__.py +7 -0
- quantark/var/backtest/var_backtester.py +309 -0
- quantark/var/base.py +63 -0
- quantark/var/config.py +219 -0
- quantark/var/engines/__init__.py +13 -0
- quantark/var/engines/historical.py +925 -0
- quantark/var/engines/monte_carlo.py +870 -0
- quantark/var/engines/parametric.py +1199 -0
- quantark/var/results/__init__.py +16 -0
- quantark/var/results/incremental_var_result.py +131 -0
- quantark/var/results/var_report.py +346 -0
- quantark/var/results/var_result.py +134 -0
- quantark/var/risk_factors/__init__.py +22 -0
- quantark/var/risk_factors/base.py +41 -0
- quantark/var/risk_factors/equity_factors.py +158 -0
- quantark/var/risk_factors/fi_factors.py +99 -0
- quantark-0.1.0.dist-info/METADATA +351 -0
- quantark-0.1.0.dist-info/RECORD +399 -0
- quantark-0.1.0.dist-info/WHEEL +4 -0
- quantark-0.1.0.dist-info/licenses/LICENSE +202 -0
- quantark-0.1.0.dist-info/licenses/NOTICE +2 -0
- quantark_compat.pth +1 -0
|
@@ -0,0 +1,2250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Monte Carlo pricing engine for Snowball (autocallable) options.
|
|
3
|
+
|
|
4
|
+
This engine prices snowball options using Monte Carlo simulation with support for:
|
|
5
|
+
- Standard and reverse snowball structures
|
|
6
|
+
- KO-reset snowball structures (pre/post KO schedules)
|
|
7
|
+
- Discrete KO observations with time-varying barriers and rates
|
|
8
|
+
- Discrete or continuous KI monitoring
|
|
9
|
+
- INSTANT or EXPIRY coupon payment timing
|
|
10
|
+
- Vectorized NumPy operations for efficiency
|
|
11
|
+
- Optional Dask parallelization for batch processing
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
import warnings
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from quantark.asset.equity.engine.base_engine import BaseEngine
|
|
23
|
+
from quantark.asset.equity.engine.event_stats import AutocallableEventStats, KOResetEventStats
|
|
24
|
+
from quantark.asset.equity.param import MCParams
|
|
25
|
+
from quantark.asset.equity.process.bsm.qmc_path_generator import GBMPathGenerator
|
|
26
|
+
from quantark.asset.equity.process.bsm.qmc_rqmc_driver import run_rqmc
|
|
27
|
+
from quantark.asset.equity.process.bsm.qmc_sobol import (
|
|
28
|
+
PseudoRandomNormalGenerator,
|
|
29
|
+
SobolNormalGenerator,
|
|
30
|
+
)
|
|
31
|
+
from quantark.asset.equity.product.base_equity_product import BaseEquityProduct
|
|
32
|
+
from quantark.asset.equity.product.option.snowball_option import SnowballOption
|
|
33
|
+
from quantark.asset.equity.product.option.ko_reset_snowball_option import (
|
|
34
|
+
KnockOutResetSnowballOption,
|
|
35
|
+
)
|
|
36
|
+
from quantark.asset.equity.product.option.observation_schedule import ObservationRecord
|
|
37
|
+
from quantark.priceenv import PricingEnvironment
|
|
38
|
+
from quantark.util.calendar import DayCountConvention, calculate_year_fraction
|
|
39
|
+
from quantark.util.enum import (
|
|
40
|
+
CouponPayType,
|
|
41
|
+
ObservationType,
|
|
42
|
+
PostKOScheduleMode,
|
|
43
|
+
)
|
|
44
|
+
from quantark.util.enum.engine_enums import EngineType, MonteCarloMethod
|
|
45
|
+
from quantark.util.exceptions import PricingError, ValidationError
|
|
46
|
+
from quantark.util.numerical import safe_log
|
|
47
|
+
|
|
48
|
+
# Optional Dask import
|
|
49
|
+
try:
|
|
50
|
+
from dask import compute, delayed
|
|
51
|
+
|
|
52
|
+
DASK_AVAILABLE = True
|
|
53
|
+
except ImportError:
|
|
54
|
+
DASK_AVAILABLE = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SnowballMCResult:
|
|
59
|
+
"""Result container for Snowball MC pricing."""
|
|
60
|
+
|
|
61
|
+
price: float
|
|
62
|
+
std_error: float
|
|
63
|
+
num_paths: int
|
|
64
|
+
ko_probability: float
|
|
65
|
+
v0_probability: float
|
|
66
|
+
v1_probability: float
|
|
67
|
+
avg_ko_time: Optional[float] = None
|
|
68
|
+
batches_used: Optional[int] = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SnowballMCEngine(BaseEngine):
|
|
72
|
+
"""
|
|
73
|
+
Monte Carlo pricing engine for Snowball (autocallable) options.
|
|
74
|
+
|
|
75
|
+
Supports three Monte Carlo methods:
|
|
76
|
+
- PSEUDO: Standard Monte Carlo with pseudorandom numbers
|
|
77
|
+
- QUASI: Quasi-Monte Carlo with Sobol sequences
|
|
78
|
+
- RANDOMIZED_QUASI: Randomized QMC with adaptive batching
|
|
79
|
+
|
|
80
|
+
Usage:
|
|
81
|
+
# Preferred: Two-level enum pattern
|
|
82
|
+
engine = SnowballMCEngine(
|
|
83
|
+
params=MCParams(num_paths=100000, time_steps=252),
|
|
84
|
+
method=EngineType.MONTE_CARLO(MonteCarloMethod.QUASI)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Alternative: Direct method enum
|
|
88
|
+
engine = SnowballMCEngine(
|
|
89
|
+
params=MCParams(num_paths=100000),
|
|
90
|
+
method=MonteCarloMethod.QUASI
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# With optional Dask parallelization
|
|
94
|
+
engine = SnowballMCEngine(
|
|
95
|
+
params=MCParams(num_paths=100000),
|
|
96
|
+
use_dask=True,
|
|
97
|
+
num_batches=8
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
The engine creates a GBMPathGenerator internally based on the pricing
|
|
101
|
+
environment and product observation schedule.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
DEFAULT_METHOD = MonteCarloMethod.PSEUDO
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
params: Optional[MCParams] = None,
|
|
109
|
+
method: Union[str, MonteCarloMethod, tuple, None] = None,
|
|
110
|
+
use_dask: bool = False,
|
|
111
|
+
num_batches: int = 4,
|
|
112
|
+
):
|
|
113
|
+
"""
|
|
114
|
+
Initialize Snowball Monte Carlo engine.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
params: Monte Carlo configuration parameters (MCParams)
|
|
118
|
+
method: Monte Carlo method selection, one of:
|
|
119
|
+
- EngineType.MONTE_CARLO(MonteCarloMethod.XXX) (preferred)
|
|
120
|
+
- MonteCarloMethod.XXX
|
|
121
|
+
- String: "pseudo", "quasi", "randomized_quasi"
|
|
122
|
+
- None: defaults to MonteCarloMethod.PSEUDO
|
|
123
|
+
use_dask: Enable Dask parallel processing (requires Dask installed)
|
|
124
|
+
num_batches: Number of batches for parallel processing
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
ValidationError: If method is invalid or params are invalid
|
|
128
|
+
"""
|
|
129
|
+
if params is None:
|
|
130
|
+
params = MCParams()
|
|
131
|
+
|
|
132
|
+
if not isinstance(params, MCParams):
|
|
133
|
+
raise ValidationError(
|
|
134
|
+
f"params must be MCParams instance, got {type(params).__name__}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
super().__init__(params)
|
|
138
|
+
|
|
139
|
+
# Parse method
|
|
140
|
+
if method is None:
|
|
141
|
+
self.method = self.DEFAULT_METHOD
|
|
142
|
+
elif isinstance(method, tuple):
|
|
143
|
+
engine_type, mc_method = method
|
|
144
|
+
if engine_type != EngineType.MONTE_CARLO:
|
|
145
|
+
raise ValidationError(
|
|
146
|
+
f"Expected EngineType.MONTE_CARLO, got {engine_type}"
|
|
147
|
+
)
|
|
148
|
+
if not isinstance(mc_method, MonteCarloMethod):
|
|
149
|
+
raise ValidationError(
|
|
150
|
+
f"Expected MonteCarloMethod, got {type(mc_method).__name__}"
|
|
151
|
+
)
|
|
152
|
+
self.method = mc_method
|
|
153
|
+
elif isinstance(method, MonteCarloMethod):
|
|
154
|
+
self.method = method
|
|
155
|
+
elif isinstance(method, str):
|
|
156
|
+
try:
|
|
157
|
+
self.method = MonteCarloMethod[method.upper()]
|
|
158
|
+
except KeyError:
|
|
159
|
+
valid_methods = [m.name for m in MonteCarloMethod]
|
|
160
|
+
raise ValidationError(
|
|
161
|
+
f"Invalid method string '{method}'. Valid methods: {valid_methods}"
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
raise ValidationError(
|
|
165
|
+
f"Invalid method type {type(method).__name__}. "
|
|
166
|
+
"Expected MonteCarloMethod, tuple, str, or None"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Dask configuration
|
|
170
|
+
self.use_dask = use_dask and DASK_AVAILABLE
|
|
171
|
+
if use_dask and not DASK_AVAILABLE:
|
|
172
|
+
warnings.warn(
|
|
173
|
+
"Dask requested but not installed. Falling back to single-threaded NumPy.",
|
|
174
|
+
UserWarning,
|
|
175
|
+
)
|
|
176
|
+
self.num_batches = num_batches
|
|
177
|
+
|
|
178
|
+
# Result storage
|
|
179
|
+
self._last_result: Optional[SnowballMCResult] = None
|
|
180
|
+
|
|
181
|
+
def price(
|
|
182
|
+
self, product: BaseEquityProduct, pricing_env: PricingEnvironment
|
|
183
|
+
) -> float:
|
|
184
|
+
"""
|
|
185
|
+
Price a Snowball option using Monte Carlo simulation.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
product: Snowball option to price
|
|
189
|
+
pricing_env: Pricing environment with market data
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Option price
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
PricingError: If product is not a SnowballOption
|
|
196
|
+
ValidationError: If pricing parameters are invalid
|
|
197
|
+
"""
|
|
198
|
+
if not isinstance(product, (SnowballOption, KnockOutResetSnowballOption)):
|
|
199
|
+
raise PricingError(
|
|
200
|
+
"SnowballMCEngine only supports SnowballOption or "
|
|
201
|
+
f"KnockOutResetSnowballOption, got {type(product).__name__}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Extract market data
|
|
205
|
+
S = pricing_env.spot
|
|
206
|
+
T = (
|
|
207
|
+
product.get_max_maturity_time(pricing_env)
|
|
208
|
+
if isinstance(product, KnockOutResetSnowballOption)
|
|
209
|
+
else product.get_maturity(pricing_env)
|
|
210
|
+
)
|
|
211
|
+
r = pricing_env.get_rate(T)
|
|
212
|
+
q = pricing_env.get_div_yield(T)
|
|
213
|
+
sigma = pricing_env.get_vol(product.strike, T)
|
|
214
|
+
|
|
215
|
+
self._validate_inputs(S, T, r, q, sigma, product)
|
|
216
|
+
|
|
217
|
+
# Handle near-expiry case
|
|
218
|
+
if T < 1e-10:
|
|
219
|
+
knocked_in = bool(getattr(product, "_otc_lifecycle_knocked_in", False))
|
|
220
|
+
return product.get_payoff(S, pricing_env, knocked_in=knocked_in)
|
|
221
|
+
|
|
222
|
+
# Price using appropriate method
|
|
223
|
+
if isinstance(product, KnockOutResetSnowballOption):
|
|
224
|
+
if self.method == MonteCarloMethod.RANDOMIZED_QUASI:
|
|
225
|
+
result = self._price_ko_reset_rqmc(
|
|
226
|
+
product, pricing_env, S, T, r, q, sigma
|
|
227
|
+
)
|
|
228
|
+
elif self.use_dask and self.num_batches > 1:
|
|
229
|
+
result = self._price_ko_reset_parallel(
|
|
230
|
+
product, pricing_env, S, T, r, q, sigma
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
result = self._price_ko_reset_mc_or_qmc(
|
|
234
|
+
product, pricing_env, S, T, r, q, sigma
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
if self.method == MonteCarloMethod.RANDOMIZED_QUASI:
|
|
238
|
+
result = self._price_rqmc(product, pricing_env, S, T, r, q, sigma)
|
|
239
|
+
elif self.use_dask and self.num_batches > 1:
|
|
240
|
+
result = self._price_parallel(product, pricing_env, S, T, r, q, sigma)
|
|
241
|
+
else:
|
|
242
|
+
result = self._price_mc_or_qmc(product, pricing_env, S, T, r, q, sigma)
|
|
243
|
+
|
|
244
|
+
self._last_result = result
|
|
245
|
+
|
|
246
|
+
# Negative PV is valid for some structures (e.g., principal-excluded notes)
|
|
247
|
+
# where the payoff is effectively "coupon minus embedded option loss".
|
|
248
|
+
if result.price < 0 and product.payoff_config.include_principal:
|
|
249
|
+
raise PricingError(f"Negative price computed: {result.price}")
|
|
250
|
+
|
|
251
|
+
return result.price
|
|
252
|
+
|
|
253
|
+
def calculate_event_stats(
|
|
254
|
+
self, product: BaseEquityProduct, pricing_env: PricingEnvironment
|
|
255
|
+
) -> Optional[AutocallableEventStats]:
|
|
256
|
+
"""
|
|
257
|
+
Provide per-observation event probabilities and expected discounted cashflows.
|
|
258
|
+
|
|
259
|
+
This is a Monte Carlo implementation for Snowball. QUAD/PDE engines can
|
|
260
|
+
optionally implement the same API later to provide faster risk-neutral
|
|
261
|
+
event stats without Monte Carlo.
|
|
262
|
+
"""
|
|
263
|
+
if isinstance(product, KnockOutResetSnowballOption):
|
|
264
|
+
return self._calculate_event_stats_ko_reset(product, pricing_env)
|
|
265
|
+
if not isinstance(product, SnowballOption):
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
S = pricing_env.spot
|
|
269
|
+
T = product.get_maturity(pricing_env)
|
|
270
|
+
r = pricing_env.get_rate(T)
|
|
271
|
+
q = pricing_env.get_div_yield(T)
|
|
272
|
+
sigma = pricing_env.get_vol(product.strike, T)
|
|
273
|
+
self._validate_inputs(S, T, r, q, sigma, product)
|
|
274
|
+
|
|
275
|
+
all_times, dt_array, ko_indices, ki_indices = self._build_time_grid(
|
|
276
|
+
product, pricing_env, T
|
|
277
|
+
)
|
|
278
|
+
generator = self._create_path_generator(S, r, q, sigma, T, dt_array)
|
|
279
|
+
paths, _ = generator.generate_paths(return_aux=False)
|
|
280
|
+
|
|
281
|
+
ko_profile = product.get_ko_observation_profile(pricing_env)
|
|
282
|
+
ko_times = np.array(ko_profile["observation_times"], dtype=float)
|
|
283
|
+
ko_barriers = np.array(ko_profile["barriers"], dtype=float)
|
|
284
|
+
ko_payoffs = np.array(ko_profile["payoffs"], dtype=float)
|
|
285
|
+
ko_settlement_times = np.array(ko_profile["settlement_times"], dtype=float)
|
|
286
|
+
|
|
287
|
+
ko_triggered, first_ko_idx = self._check_ko_barriers(
|
|
288
|
+
paths, ko_indices, ko_barriers, product.is_reverse
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
ki_triggered = np.zeros(len(paths), dtype=bool)
|
|
292
|
+
first_ki_idx = np.full(len(paths), -1, dtype=int)
|
|
293
|
+
ki_event_times = np.array([], dtype=float)
|
|
294
|
+
if product.has_ki_barrier:
|
|
295
|
+
ki_continuous = (
|
|
296
|
+
product.barrier_config.ki_observation_type == ObservationType.CONTINUOUS
|
|
297
|
+
or product.barrier_config.ki_continuous
|
|
298
|
+
)
|
|
299
|
+
ki_profile = product.get_ki_observation_profile(pricing_env)
|
|
300
|
+
ki_barriers_val = np.array(ki_profile["barriers"], dtype=float)
|
|
301
|
+
if ki_continuous:
|
|
302
|
+
ki_event_times = np.array(all_times, dtype=float)
|
|
303
|
+
if ki_barriers_val.shape not in ((), (1,)):
|
|
304
|
+
raise ValidationError(
|
|
305
|
+
"Continuous KI monitoring requires a scalar ki_barrier."
|
|
306
|
+
)
|
|
307
|
+
ki_barrier_scalar = float(ki_barriers_val.reshape(-1)[0])
|
|
308
|
+
ki_triggered, first_ki_idx = (
|
|
309
|
+
self._check_ki_barriers_continuous_with_bridge(
|
|
310
|
+
paths=paths,
|
|
311
|
+
all_times=all_times,
|
|
312
|
+
ki_barrier=ki_barrier_scalar,
|
|
313
|
+
sigma=float(sigma),
|
|
314
|
+
is_reverse=product.is_reverse,
|
|
315
|
+
rng_seed=int(self.params.seed) + 1337,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
ki_event_times = np.array(ki_profile["observation_times"], dtype=float)
|
|
320
|
+
ki_triggered, first_ki_idx = self._check_ki_barriers(
|
|
321
|
+
paths, ki_indices, ki_barriers_val, product.is_reverse
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
already_knocked_in = bool(getattr(product, "_otc_lifecycle_knocked_in", False))
|
|
325
|
+
if already_knocked_in:
|
|
326
|
+
ki_triggered[:] = True
|
|
327
|
+
first_ki_idx[:] = 0
|
|
328
|
+
|
|
329
|
+
if product.barrier_config.disable_ko_after_ki and (
|
|
330
|
+
product.has_ki_barrier or already_knocked_in
|
|
331
|
+
):
|
|
332
|
+
if already_knocked_in:
|
|
333
|
+
ko_valid = np.zeros_like(ko_triggered)
|
|
334
|
+
else:
|
|
335
|
+
ko_trigger_times = np.where(
|
|
336
|
+
first_ko_idx >= 0, ko_times[first_ko_idx], np.inf
|
|
337
|
+
)
|
|
338
|
+
if len(ki_event_times) > 0:
|
|
339
|
+
ki_trigger_times = np.where(
|
|
340
|
+
first_ki_idx >= 0, ki_event_times[first_ki_idx], np.inf
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
ki_trigger_times = np.full(len(paths), np.inf, dtype=float)
|
|
344
|
+
ko_valid = ko_triggered & (ko_trigger_times < ki_trigger_times)
|
|
345
|
+
else:
|
|
346
|
+
ko_valid = ko_triggered
|
|
347
|
+
|
|
348
|
+
is_ko = ko_valid
|
|
349
|
+
is_v0 = ~is_ko & ~ki_triggered
|
|
350
|
+
is_v1 = ~is_ko & ki_triggered
|
|
351
|
+
|
|
352
|
+
ko_probability = np.zeros(len(ko_times), dtype=float)
|
|
353
|
+
expected_discounted_ko_cashflow = np.zeros(len(ko_times), dtype=float)
|
|
354
|
+
for i in range(len(ko_times)):
|
|
355
|
+
hit_i = is_ko & (first_ko_idx == i)
|
|
356
|
+
p_i = float(np.mean(hit_i))
|
|
357
|
+
ko_probability[i] = p_i
|
|
358
|
+
if p_i > 0.0:
|
|
359
|
+
df = pricing_env.get_discount_factor(float(ko_settlement_times[i]))
|
|
360
|
+
expected_discounted_ko_cashflow[i] = p_i * float(ko_payoffs[i]) * df
|
|
361
|
+
|
|
362
|
+
survival_probability = np.ones(len(ko_times), dtype=float)
|
|
363
|
+
cumulative_ko = 0.0
|
|
364
|
+
for i in range(len(ko_times)):
|
|
365
|
+
cumulative_ko += ko_probability[i]
|
|
366
|
+
survival_probability[i] = max(0.0, 1.0 - cumulative_ko)
|
|
367
|
+
|
|
368
|
+
ki_event_probability = np.array([], dtype=float)
|
|
369
|
+
ki_survival_probability = np.array([], dtype=float)
|
|
370
|
+
if already_knocked_in:
|
|
371
|
+
ki_event_times = np.array([0.0], dtype=float)
|
|
372
|
+
ki_event_probability = np.array([1.0], dtype=float)
|
|
373
|
+
ki_survival_probability = np.array([0.0], dtype=float)
|
|
374
|
+
elif product.has_ki_barrier:
|
|
375
|
+
if ki_event_times.size:
|
|
376
|
+
ki_event_probability = np.zeros(len(ki_event_times), dtype=float)
|
|
377
|
+
for i in range(len(ki_event_times)):
|
|
378
|
+
ki_event_probability[i] = float(
|
|
379
|
+
np.mean(ki_triggered & (first_ki_idx == i))
|
|
380
|
+
)
|
|
381
|
+
ki_survival_probability = np.maximum(
|
|
382
|
+
0.0, 1.0 - np.cumsum(ki_event_probability)
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
maturity_spots = paths[:, -1]
|
|
386
|
+
maturity_df = pricing_env.get_discount_factor(float(T))
|
|
387
|
+
maturity_payoff_all = np.zeros(len(paths), dtype=float)
|
|
388
|
+
if is_v0.any():
|
|
389
|
+
maturity_payoff_all[is_v0] = np.array(
|
|
390
|
+
[
|
|
391
|
+
product.get_maturity_payoff_v0(float(s), pricing_env)
|
|
392
|
+
for s in maturity_spots[is_v0]
|
|
393
|
+
],
|
|
394
|
+
dtype=float,
|
|
395
|
+
)
|
|
396
|
+
if is_v1.any():
|
|
397
|
+
maturity_payoff_all[is_v1] = np.array(
|
|
398
|
+
[
|
|
399
|
+
product.get_maturity_payoff_v1(float(s), pricing_env)
|
|
400
|
+
for s in maturity_spots[is_v1]
|
|
401
|
+
],
|
|
402
|
+
dtype=float,
|
|
403
|
+
)
|
|
404
|
+
expected_discounted_maturity_cashflow = float(
|
|
405
|
+
np.mean(maturity_payoff_all * maturity_df)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Total discounted payoff PV from the same simulation.
|
|
409
|
+
total_discounted = np.zeros(len(paths), dtype=float)
|
|
410
|
+
if is_ko.any():
|
|
411
|
+
payoff = ko_payoffs[first_ko_idx[is_ko]]
|
|
412
|
+
settle = ko_settlement_times[first_ko_idx[is_ko]]
|
|
413
|
+
dfs = np.array(
|
|
414
|
+
[pricing_env.get_discount_factor(float(t)) for t in settle], dtype=float
|
|
415
|
+
)
|
|
416
|
+
total_discounted[is_ko] = payoff * dfs
|
|
417
|
+
total_discounted[~is_ko] = maturity_payoff_all[~is_ko] * maturity_df
|
|
418
|
+
|
|
419
|
+
pv = float(np.mean(total_discounted))
|
|
420
|
+
pv_cashflows = float(
|
|
421
|
+
np.sum(expected_discounted_ko_cashflow) + expected_discounted_maturity_cashflow
|
|
422
|
+
)
|
|
423
|
+
reconciliation_error = pv - pv_cashflows
|
|
424
|
+
|
|
425
|
+
return AutocallableEventStats(
|
|
426
|
+
pv=pv,
|
|
427
|
+
ko_times=ko_times,
|
|
428
|
+
ko_probability=ko_probability,
|
|
429
|
+
survival_probability=survival_probability,
|
|
430
|
+
expected_discounted_ko_cashflow=expected_discounted_ko_cashflow,
|
|
431
|
+
ki_probability=(
|
|
432
|
+
float(np.mean(ki_triggered))
|
|
433
|
+
if product.has_ki_barrier or already_knocked_in
|
|
434
|
+
else 0.0
|
|
435
|
+
),
|
|
436
|
+
expected_discounted_maturity_cashflow=expected_discounted_maturity_cashflow,
|
|
437
|
+
reconciliation_error=float(reconciliation_error),
|
|
438
|
+
ki_times=ki_event_times,
|
|
439
|
+
ki_event_probability=ki_event_probability,
|
|
440
|
+
ki_survival_probability=ki_survival_probability,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def _calculate_event_stats_ko_reset(
|
|
444
|
+
self,
|
|
445
|
+
product: KnockOutResetSnowballOption,
|
|
446
|
+
pricing_env: PricingEnvironment,
|
|
447
|
+
) -> KOResetEventStats:
|
|
448
|
+
S = pricing_env.spot
|
|
449
|
+
T = product.get_max_maturity_time(pricing_env)
|
|
450
|
+
r = pricing_env.get_rate(T)
|
|
451
|
+
q = pricing_env.get_div_yield(T)
|
|
452
|
+
sigma = pricing_env.get_vol(product.strike, T)
|
|
453
|
+
self._validate_inputs(S, T, r, q, sigma, product)
|
|
454
|
+
|
|
455
|
+
grid = self._build_time_grid_ko_reset(product, pricing_env, T)
|
|
456
|
+
generator = self._create_path_generator(S, r, q, sigma, T, grid["dt_array"])
|
|
457
|
+
paths, _ = generator.generate_paths(return_aux=False)
|
|
458
|
+
|
|
459
|
+
payoffs, settlement_times, _stats, extra = self._compute_payoffs_ko_reset(
|
|
460
|
+
product,
|
|
461
|
+
pricing_env,
|
|
462
|
+
paths,
|
|
463
|
+
grid,
|
|
464
|
+
r,
|
|
465
|
+
T,
|
|
466
|
+
sigma,
|
|
467
|
+
rng_seed=int(self.params.seed) + 1337,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
is_pre_ko = extra["is_pre_ko"]
|
|
471
|
+
is_post_ko = extra["is_post_ko"]
|
|
472
|
+
is_ko = is_pre_ko | is_post_ko
|
|
473
|
+
ki_triggered = extra["ki_triggered"]
|
|
474
|
+
|
|
475
|
+
pre_times = grid["pre_times"]
|
|
476
|
+
post_times = grid["post_times"]
|
|
477
|
+
post_mode = grid["post_profile"]["mode"]
|
|
478
|
+
|
|
479
|
+
pre_prob = np.zeros(len(pre_times), dtype=float)
|
|
480
|
+
for i in range(len(pre_times)):
|
|
481
|
+
pre_prob[i] = float(np.mean(is_pre_ko & (extra["first_pre_idx"] == i)))
|
|
482
|
+
|
|
483
|
+
post_prob = np.zeros(len(post_times), dtype=float)
|
|
484
|
+
for i in range(len(post_times)):
|
|
485
|
+
post_prob[i] = float(np.mean(is_post_ko & (extra["first_post_idx"] == i)))
|
|
486
|
+
|
|
487
|
+
pre_prob_total = float(pre_prob.sum())
|
|
488
|
+
post_prob_total = float(post_prob.sum())
|
|
489
|
+
|
|
490
|
+
pre_profile = grid["pre_profile"]
|
|
491
|
+
pre_rates = np.array(pre_profile["rates"], dtype=float)
|
|
492
|
+
pre_records = pre_profile["records"]
|
|
493
|
+
pre_maturity = product.get_pre_maturity_time(pricing_env)
|
|
494
|
+
pre_payoffs, pre_settlement_times = self._compute_ko_schedule_payoffs(
|
|
495
|
+
product,
|
|
496
|
+
pre_times,
|
|
497
|
+
pre_rates,
|
|
498
|
+
pre_records,
|
|
499
|
+
pricing_env,
|
|
500
|
+
pre_maturity,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
expected_discounted_ko_cashflow = np.zeros(len(pre_times), dtype=float)
|
|
504
|
+
for i in range(len(pre_times)):
|
|
505
|
+
if pre_prob[i] <= 0.0:
|
|
506
|
+
continue
|
|
507
|
+
df = pricing_env.get_discount_factor(float(pre_settlement_times[i]))
|
|
508
|
+
expected_discounted_ko_cashflow[i] = pre_prob[i] * float(pre_payoffs[i]) * df
|
|
509
|
+
|
|
510
|
+
expected_discounted_post_ko_cashflow = 0.0
|
|
511
|
+
if is_post_ko.any():
|
|
512
|
+
dfs = np.array(
|
|
513
|
+
[pricing_env.get_discount_factor(float(t)) for t in settlement_times[is_post_ko]],
|
|
514
|
+
dtype=float,
|
|
515
|
+
)
|
|
516
|
+
expected_discounted_post_ko_cashflow = float(
|
|
517
|
+
np.sum(payoffs[is_post_ko] * dfs) / len(paths)
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
dfs_all = np.array(
|
|
521
|
+
[pricing_env.get_discount_factor(float(t)) for t in settlement_times],
|
|
522
|
+
dtype=float,
|
|
523
|
+
)
|
|
524
|
+
maturity_payoff_all = np.zeros(len(paths), dtype=float)
|
|
525
|
+
maturity_payoff_all[~is_ko] = payoffs[~is_ko]
|
|
526
|
+
expected_discounted_maturity_cashflow = float(
|
|
527
|
+
np.mean(maturity_payoff_all * dfs_all)
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
total_discounted = payoffs * dfs_all
|
|
531
|
+
pv = float(np.mean(total_discounted))
|
|
532
|
+
pv_cashflows = float(
|
|
533
|
+
np.sum(expected_discounted_ko_cashflow)
|
|
534
|
+
+ expected_discounted_post_ko_cashflow
|
|
535
|
+
+ expected_discounted_maturity_cashflow
|
|
536
|
+
)
|
|
537
|
+
reconciliation_error = pv - pv_cashflows
|
|
538
|
+
|
|
539
|
+
survival_probability = np.ones(len(pre_times), dtype=float)
|
|
540
|
+
cumulative_ko = 0.0
|
|
541
|
+
for i in range(len(pre_times)):
|
|
542
|
+
cumulative_ko += pre_prob[i]
|
|
543
|
+
survival_probability[i] = max(0.0, 1.0 - cumulative_ko)
|
|
544
|
+
|
|
545
|
+
return KOResetEventStats(
|
|
546
|
+
pv=pv,
|
|
547
|
+
ko_times=pre_times,
|
|
548
|
+
ko_probability=pre_prob,
|
|
549
|
+
survival_probability=survival_probability,
|
|
550
|
+
expected_discounted_ko_cashflow=expected_discounted_ko_cashflow,
|
|
551
|
+
ki_probability=float(np.mean(ki_triggered)),
|
|
552
|
+
expected_discounted_maturity_cashflow=expected_discounted_maturity_cashflow,
|
|
553
|
+
reconciliation_error=float(reconciliation_error),
|
|
554
|
+
pre_ko_times=pre_times,
|
|
555
|
+
pre_ko_probability=pre_prob,
|
|
556
|
+
post_ko_times=post_times,
|
|
557
|
+
post_ko_probability=post_prob,
|
|
558
|
+
pre_ko_probability_total=pre_prob_total,
|
|
559
|
+
post_ko_probability_total=post_prob_total,
|
|
560
|
+
expected_discounted_post_ko_cashflow=float(expected_discounted_post_ko_cashflow),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
def _validate_inputs(
|
|
564
|
+
self,
|
|
565
|
+
S: float,
|
|
566
|
+
T: float,
|
|
567
|
+
r: float,
|
|
568
|
+
q: float,
|
|
569
|
+
sigma: float,
|
|
570
|
+
product: BaseEquityProduct,
|
|
571
|
+
) -> None:
|
|
572
|
+
"""Validate pricing inputs."""
|
|
573
|
+
if S <= 0:
|
|
574
|
+
raise ValidationError(f"Spot price must be positive, got {S}")
|
|
575
|
+
if T < 0:
|
|
576
|
+
raise ValidationError(f"Time to maturity must be non-negative, got {T}")
|
|
577
|
+
if sigma <= 0:
|
|
578
|
+
raise ValidationError(f"Volatility must be positive, got {sigma}")
|
|
579
|
+
if not np.isfinite(q):
|
|
580
|
+
raise ValidationError(f"Dividend yield must be finite, got {q}")
|
|
581
|
+
|
|
582
|
+
if isinstance(product, SnowballOption):
|
|
583
|
+
# Validate observation schedule exists
|
|
584
|
+
if product.barrier_config.ko_observation_type == ObservationType.DISCRETE:
|
|
585
|
+
if (
|
|
586
|
+
product.barrier_config.ko_observation_schedule is None
|
|
587
|
+
and product.barrier_config.ko_observation_dates is None
|
|
588
|
+
):
|
|
589
|
+
raise ValidationError(
|
|
590
|
+
"KO observation schedule or dates required for discrete monitoring"
|
|
591
|
+
)
|
|
592
|
+
if isinstance(product, KnockOutResetSnowballOption):
|
|
593
|
+
pre_config = product.barrier_config
|
|
594
|
+
if pre_config.ko_observation_type == ObservationType.DISCRETE:
|
|
595
|
+
if (
|
|
596
|
+
pre_config.ko_observation_schedule is None
|
|
597
|
+
and pre_config.ko_observation_dates is None
|
|
598
|
+
):
|
|
599
|
+
raise ValidationError(
|
|
600
|
+
"Pre-KI KO observation schedule or dates required for discrete monitoring"
|
|
601
|
+
)
|
|
602
|
+
post_config = product.post_barrier_config
|
|
603
|
+
if (
|
|
604
|
+
post_config.ko_observation_schedule is None
|
|
605
|
+
and post_config.ko_observation_dates is None
|
|
606
|
+
):
|
|
607
|
+
raise ValidationError(
|
|
608
|
+
"Post-KI KO observation schedule or dates required for discrete monitoring"
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
def _build_time_grid(
|
|
612
|
+
self, product: SnowballOption, pricing_env: PricingEnvironment, T: float
|
|
613
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
614
|
+
"""
|
|
615
|
+
Build time grid aligned with observation dates.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Tuple of:
|
|
619
|
+
- all_times: Sorted unique observation times including maturity
|
|
620
|
+
- dt_array: Time increments between observations
|
|
621
|
+
- ko_indices: Indices into all_times for KO observations
|
|
622
|
+
- ki_indices: Indices into all_times for KI observations (or fine grid)
|
|
623
|
+
"""
|
|
624
|
+
# Get KO observation times
|
|
625
|
+
ko_profile = product.get_ko_observation_profile(pricing_env)
|
|
626
|
+
ko_times = ko_profile["observation_times"]
|
|
627
|
+
|
|
628
|
+
# Get KI observation times (if applicable)
|
|
629
|
+
ki_times = []
|
|
630
|
+
ki_continuous = (
|
|
631
|
+
product.barrier_config.ki_observation_type == ObservationType.CONTINUOUS
|
|
632
|
+
or product.barrier_config.ki_continuous
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
if product.has_ki_barrier:
|
|
636
|
+
if ki_continuous:
|
|
637
|
+
# Generate fine grid for continuous monitoring
|
|
638
|
+
num_ki_steps = int(pricing_env.bus_days_in_year * T) + 1
|
|
639
|
+
ki_times = list(np.linspace(0, T, num_ki_steps + 1)[1:])
|
|
640
|
+
else:
|
|
641
|
+
ki_profile = product.get_ki_observation_profile(pricing_env)
|
|
642
|
+
ki_times = ki_profile["observation_times"]
|
|
643
|
+
|
|
644
|
+
# Combine all times and ensure maturity is included
|
|
645
|
+
all_times_set = set(ko_times) | set(ki_times) | {T}
|
|
646
|
+
all_times = np.array(sorted(all_times_set))
|
|
647
|
+
|
|
648
|
+
# Build dt_array
|
|
649
|
+
times_with_zero = np.concatenate([[0.0], all_times])
|
|
650
|
+
dt_array = np.diff(times_with_zero)
|
|
651
|
+
|
|
652
|
+
# Find indices for KO and KI observations
|
|
653
|
+
ko_indices = np.searchsorted(all_times, ko_times)
|
|
654
|
+
if ki_continuous:
|
|
655
|
+
# All times except t=0 are KI observation points
|
|
656
|
+
ki_indices = np.arange(len(all_times))
|
|
657
|
+
elif ki_times:
|
|
658
|
+
ki_indices = np.searchsorted(all_times, ki_times)
|
|
659
|
+
else:
|
|
660
|
+
ki_indices = np.array([], dtype=int)
|
|
661
|
+
|
|
662
|
+
return all_times, dt_array, ko_indices, ki_indices
|
|
663
|
+
|
|
664
|
+
def _build_time_grid_ko_reset(
|
|
665
|
+
self,
|
|
666
|
+
product: KnockOutResetSnowballOption,
|
|
667
|
+
pricing_env: PricingEnvironment,
|
|
668
|
+
T: float,
|
|
669
|
+
) -> Dict[str, object]:
|
|
670
|
+
"""
|
|
671
|
+
Build time grid for KO-reset snowball options.
|
|
672
|
+
|
|
673
|
+
Returns a dictionary with grid arrays and schedule metadata.
|
|
674
|
+
"""
|
|
675
|
+
pre_profile = product.get_pre_ko_observation_profile(pricing_env)
|
|
676
|
+
post_profile = product.get_post_ko_observation_profile(pricing_env)
|
|
677
|
+
|
|
678
|
+
pre_times = np.array(pre_profile["observation_times"], dtype=float)
|
|
679
|
+
post_times = np.array(post_profile["observation_times"], dtype=float)
|
|
680
|
+
|
|
681
|
+
if not product.has_ki_barrier:
|
|
682
|
+
raise ValidationError("KO-reset snowball requires KI barrier configuration.")
|
|
683
|
+
|
|
684
|
+
ki_continuous = (
|
|
685
|
+
product.barrier_config.ki_observation_type == ObservationType.CONTINUOUS
|
|
686
|
+
or product.barrier_config.ki_continuous
|
|
687
|
+
)
|
|
688
|
+
ki_times: List[float] = []
|
|
689
|
+
if ki_continuous:
|
|
690
|
+
ki_horizon = product.get_pre_maturity_time(pricing_env)
|
|
691
|
+
num_ki_steps = int(pricing_env.bus_days_in_year * ki_horizon) + 1
|
|
692
|
+
ki_times = list(np.linspace(0, ki_horizon, num_ki_steps + 1)[1:])
|
|
693
|
+
else:
|
|
694
|
+
ki_profile = product.get_ki_observation_profile(pricing_env)
|
|
695
|
+
ki_times = ki_profile["observation_times"]
|
|
696
|
+
|
|
697
|
+
all_times_set = set(pre_times) | set(ki_times) | {T}
|
|
698
|
+
|
|
699
|
+
daily_times = np.array([], dtype=float)
|
|
700
|
+
use_daily_grid = getattr(self.params, "use_business_day_grid", False)
|
|
701
|
+
calendar = getattr(pricing_env, "calendar", None)
|
|
702
|
+
if (
|
|
703
|
+
use_daily_grid
|
|
704
|
+
and pricing_env.day_count_convention == DayCountConvention.BUSINESS_DAYS
|
|
705
|
+
and calendar is not None
|
|
706
|
+
):
|
|
707
|
+
end_dates: List[datetime] = []
|
|
708
|
+
for schedule in (
|
|
709
|
+
product.barrier_config.ko_observation_schedule,
|
|
710
|
+
product.barrier_config.ki_observation_schedule,
|
|
711
|
+
product.post_barrier_config.ko_observation_schedule,
|
|
712
|
+
):
|
|
713
|
+
if schedule is None:
|
|
714
|
+
continue
|
|
715
|
+
for rec in schedule.records:
|
|
716
|
+
if rec.observation_date is not None:
|
|
717
|
+
end_dates.append(rec.observation_date)
|
|
718
|
+
|
|
719
|
+
if end_dates:
|
|
720
|
+
end_date = max(end_dates)
|
|
721
|
+
current = pricing_env.valuation_date
|
|
722
|
+
count = 1 if calendar.is_business_day(current) else 0
|
|
723
|
+
times: List[float] = []
|
|
724
|
+
while current < end_date:
|
|
725
|
+
current = current + timedelta(days=1)
|
|
726
|
+
if calendar.is_business_day(current):
|
|
727
|
+
count += 1
|
|
728
|
+
times.append(count / float(pricing_env.bus_days_in_year))
|
|
729
|
+
daily_times = np.array(times, dtype=float)
|
|
730
|
+
all_times_set |= set(daily_times)
|
|
731
|
+
|
|
732
|
+
post_mode = post_profile["mode"]
|
|
733
|
+
post_indices_by_ki: Optional[List[np.ndarray]] = None
|
|
734
|
+
if post_mode == PostKOScheduleMode.ABSOLUTE:
|
|
735
|
+
all_times_set |= set(post_times)
|
|
736
|
+
else:
|
|
737
|
+
# REBASED: treat post_times as offsets from KI time
|
|
738
|
+
if ki_continuous:
|
|
739
|
+
raise ValidationError(
|
|
740
|
+
"Rebased post-KO schedule requires discrete KI monitoring."
|
|
741
|
+
)
|
|
742
|
+
for t_ki in ki_times:
|
|
743
|
+
for offset in post_times:
|
|
744
|
+
all_times_set.add(t_ki + offset)
|
|
745
|
+
|
|
746
|
+
all_times = np.array(sorted(all_times_set), dtype=float)
|
|
747
|
+
times_with_zero = np.concatenate([[0.0], all_times])
|
|
748
|
+
dt_array = np.diff(times_with_zero)
|
|
749
|
+
|
|
750
|
+
pre_indices = np.searchsorted(all_times, pre_times)
|
|
751
|
+
|
|
752
|
+
if post_mode == PostKOScheduleMode.ABSOLUTE:
|
|
753
|
+
post_indices = np.searchsorted(all_times, post_times)
|
|
754
|
+
else:
|
|
755
|
+
post_indices = np.array([], dtype=int)
|
|
756
|
+
post_indices_by_ki = []
|
|
757
|
+
for t_ki in ki_times:
|
|
758
|
+
actual_times = t_ki + post_times
|
|
759
|
+
post_indices_by_ki.append(np.searchsorted(all_times, actual_times))
|
|
760
|
+
|
|
761
|
+
if ki_continuous:
|
|
762
|
+
ki_indices = np.array([], dtype=int)
|
|
763
|
+
ki_horizon_idx = max(
|
|
764
|
+
0, int(np.searchsorted(all_times, product.get_pre_maturity_time(pricing_env), side="right") - 1)
|
|
765
|
+
)
|
|
766
|
+
else:
|
|
767
|
+
ki_indices = np.searchsorted(all_times, np.array(ki_times, dtype=float))
|
|
768
|
+
ki_horizon_idx = None
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
"all_times": all_times,
|
|
772
|
+
"dt_array": dt_array,
|
|
773
|
+
"pre_times": pre_times,
|
|
774
|
+
"pre_indices": pre_indices,
|
|
775
|
+
"post_times": post_times,
|
|
776
|
+
"post_indices": post_indices,
|
|
777
|
+
"post_indices_by_ki": post_indices_by_ki,
|
|
778
|
+
"ki_times": np.array(ki_times, dtype=float),
|
|
779
|
+
"ki_indices": ki_indices,
|
|
780
|
+
"ki_continuous": ki_continuous,
|
|
781
|
+
"ki_horizon_idx": ki_horizon_idx,
|
|
782
|
+
"pre_profile": pre_profile,
|
|
783
|
+
"post_profile": post_profile,
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
def _compute_ko_schedule_payoffs(
|
|
787
|
+
self,
|
|
788
|
+
product: KnockOutResetSnowballOption,
|
|
789
|
+
times: np.ndarray,
|
|
790
|
+
rates: np.ndarray,
|
|
791
|
+
records: List[ObservationRecord],
|
|
792
|
+
pricing_env: PricingEnvironment,
|
|
793
|
+
maturity_time: float,
|
|
794
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
795
|
+
"""
|
|
796
|
+
Compute KO payoffs and settlement times for a resolved schedule.
|
|
797
|
+
"""
|
|
798
|
+
if len(times) == 0:
|
|
799
|
+
return np.array([]), np.array([])
|
|
800
|
+
|
|
801
|
+
principal_component = (
|
|
802
|
+
product.initial_price * product.contract_multiplier
|
|
803
|
+
if product.payoff_config.include_principal
|
|
804
|
+
else 0.0
|
|
805
|
+
)
|
|
806
|
+
annualized_ko = product._effective_annualized_flag(
|
|
807
|
+
product.accrual_config.is_annualized_ko
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
payoffs = np.zeros(len(times), dtype=float)
|
|
811
|
+
for idx, t in enumerate(times):
|
|
812
|
+
if annualized_ko:
|
|
813
|
+
accrual_factor = product.compute_ko_accrual_factor(
|
|
814
|
+
float(t), records[idx], pricing_env
|
|
815
|
+
)
|
|
816
|
+
else:
|
|
817
|
+
accrual_factor = 1.0
|
|
818
|
+
coupon = (
|
|
819
|
+
product.initial_price
|
|
820
|
+
* product.contract_multiplier
|
|
821
|
+
* float(rates[idx])
|
|
822
|
+
* float(accrual_factor)
|
|
823
|
+
)
|
|
824
|
+
payoffs[idx] = principal_component + coupon
|
|
825
|
+
|
|
826
|
+
if product.accrual_config.coupon_pay_type == CouponPayType.INSTANT:
|
|
827
|
+
settlement_times = np.array(times, dtype=float)
|
|
828
|
+
else:
|
|
829
|
+
settlement_times = np.full(len(times), float(maturity_time), dtype=float)
|
|
830
|
+
|
|
831
|
+
return payoffs, settlement_times
|
|
832
|
+
|
|
833
|
+
def _create_path_generator(
|
|
834
|
+
self,
|
|
835
|
+
S: float,
|
|
836
|
+
r: float,
|
|
837
|
+
q: float,
|
|
838
|
+
sigma: float,
|
|
839
|
+
T: float,
|
|
840
|
+
dt_array: np.ndarray,
|
|
841
|
+
batch_id: Optional[int] = None,
|
|
842
|
+
num_paths: Optional[int] = None,
|
|
843
|
+
) -> GBMPathGenerator:
|
|
844
|
+
"""
|
|
845
|
+
Create a GBMPathGenerator configured for the observation grid.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
S: Spot price
|
|
849
|
+
r: Risk-free rate
|
|
850
|
+
q: Dividend yield
|
|
851
|
+
sigma: Volatility
|
|
852
|
+
T: Time to maturity
|
|
853
|
+
dt_array: Non-uniform time increments
|
|
854
|
+
batch_id: Batch identifier for RQMC
|
|
855
|
+
|
|
856
|
+
Returns:
|
|
857
|
+
Configured GBMPathGenerator
|
|
858
|
+
"""
|
|
859
|
+
params = self.params
|
|
860
|
+
effective_num_paths = params.num_paths if num_paths is None else int(num_paths)
|
|
861
|
+
if effective_num_paths <= 0:
|
|
862
|
+
raise ValidationError(
|
|
863
|
+
f"num_paths must be positive, got {effective_num_paths}"
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
if self.method == MonteCarloMethod.PSEUDO:
|
|
867
|
+
seed = params.seed + (batch_id or 0) * 1000
|
|
868
|
+
random_stream = PseudoRandomNormalGenerator(seed=seed)
|
|
869
|
+
is_qmc = False
|
|
870
|
+
elif self.method in (MonteCarloMethod.QUASI, MonteCarloMethod.RANDOMIZED_QUASI):
|
|
871
|
+
random_stream = SobolNormalGenerator(base_seed=params.seed)
|
|
872
|
+
is_qmc = True
|
|
873
|
+
else:
|
|
874
|
+
raise ValidationError(f"Unknown Monte Carlo method: {self.method}")
|
|
875
|
+
|
|
876
|
+
# No antithetic variates for barrier options (breaks correlation structure)
|
|
877
|
+
vr_config = None
|
|
878
|
+
|
|
879
|
+
generator = GBMPathGenerator(
|
|
880
|
+
initial_value=S,
|
|
881
|
+
vol=sigma,
|
|
882
|
+
rrf=r,
|
|
883
|
+
div=q,
|
|
884
|
+
maturity=T,
|
|
885
|
+
time_steps=len(dt_array),
|
|
886
|
+
num_paths=effective_num_paths,
|
|
887
|
+
model="bsm",
|
|
888
|
+
random_stream=random_stream,
|
|
889
|
+
use_brownian_bridge=is_qmc,
|
|
890
|
+
vr_config=vr_config,
|
|
891
|
+
is_qmc=is_qmc,
|
|
892
|
+
dt_array=dt_array,
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
return generator
|
|
896
|
+
|
|
897
|
+
def _check_ko_barriers(
|
|
898
|
+
self,
|
|
899
|
+
paths: np.ndarray,
|
|
900
|
+
ko_indices: np.ndarray,
|
|
901
|
+
ko_barriers: np.ndarray,
|
|
902
|
+
is_reverse: bool,
|
|
903
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
904
|
+
"""
|
|
905
|
+
Vectorized KO barrier checking.
|
|
906
|
+
|
|
907
|
+
Args:
|
|
908
|
+
paths: Simulated paths, shape (num_paths, num_times + 1)
|
|
909
|
+
ko_indices: Indices into paths for KO observations
|
|
910
|
+
ko_barriers: KO barrier levels, shape (num_ko_obs,)
|
|
911
|
+
is_reverse: True for reverse snowball (DOWN barrier)
|
|
912
|
+
|
|
913
|
+
Returns:
|
|
914
|
+
Tuple of:
|
|
915
|
+
- ko_triggered: Boolean array (num_paths,) indicating if KO was triggered
|
|
916
|
+
- first_ko_idx: Index of first KO trigger (-1 if never triggered)
|
|
917
|
+
"""
|
|
918
|
+
# Extract prices at KO observation times (offset by 1 for t=0)
|
|
919
|
+
ko_prices = paths[:, ko_indices + 1] # (num_paths, num_ko_obs)
|
|
920
|
+
|
|
921
|
+
# Vectorized barrier check
|
|
922
|
+
if is_reverse:
|
|
923
|
+
# Reverse snowball: DOWN barrier (KO if price <= barrier)
|
|
924
|
+
ko_hit = ko_prices <= ko_barriers
|
|
925
|
+
else:
|
|
926
|
+
# Standard snowball: UP barrier (KO if price >= barrier)
|
|
927
|
+
ko_hit = ko_prices >= ko_barriers
|
|
928
|
+
|
|
929
|
+
# Find first KO time per path
|
|
930
|
+
ko_triggered = ko_hit.any(axis=1)
|
|
931
|
+
first_ko_idx = np.full(len(paths), -1, dtype=int)
|
|
932
|
+
|
|
933
|
+
if ko_triggered.any():
|
|
934
|
+
# argmax returns first True in each row
|
|
935
|
+
first_ko_idx[ko_triggered] = np.argmax(ko_hit[ko_triggered], axis=1)
|
|
936
|
+
|
|
937
|
+
return ko_triggered, first_ko_idx
|
|
938
|
+
|
|
939
|
+
def _check_ki_barriers(
|
|
940
|
+
self,
|
|
941
|
+
paths: np.ndarray,
|
|
942
|
+
ki_indices: np.ndarray,
|
|
943
|
+
ki_barriers: Union[float, np.ndarray],
|
|
944
|
+
is_reverse: bool,
|
|
945
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
946
|
+
"""
|
|
947
|
+
Vectorized KI barrier checking.
|
|
948
|
+
|
|
949
|
+
Args:
|
|
950
|
+
paths: Simulated paths, shape (num_paths, num_times + 1)
|
|
951
|
+
ki_indices: Indices into paths for KI observations
|
|
952
|
+
ki_barrier: KI barrier level (single value for now)
|
|
953
|
+
is_reverse: True for reverse snowball (UP barrier for KI)
|
|
954
|
+
|
|
955
|
+
Returns:
|
|
956
|
+
Tuple of:
|
|
957
|
+
- ki_triggered: Boolean array (num_paths,) indicating if KI was triggered
|
|
958
|
+
- first_ki_idx: Index of first KI trigger (-1 if never triggered)
|
|
959
|
+
"""
|
|
960
|
+
if len(ki_indices) == 0:
|
|
961
|
+
return np.zeros(len(paths), dtype=bool), np.full(len(paths), -1, dtype=int)
|
|
962
|
+
|
|
963
|
+
# Extract prices at KI observation times (offset by 1 for t=0)
|
|
964
|
+
ki_prices = paths[:, ki_indices + 1] # (num_paths, num_ki_obs)
|
|
965
|
+
num_ki_obs_times = ki_prices.shape[1]
|
|
966
|
+
|
|
967
|
+
ki_barriers_effective = np.array(ki_barriers)
|
|
968
|
+
|
|
969
|
+
# If ki_barriers has a single value (i.e., scalar or [scalar]), broadcast it
|
|
970
|
+
if ki_barriers_effective.shape == () or ki_barriers_effective.shape == (1,):
|
|
971
|
+
ki_barriers_aligned = np.full(
|
|
972
|
+
num_ki_obs_times, ki_barriers_effective.item()
|
|
973
|
+
)
|
|
974
|
+
else:
|
|
975
|
+
# If it's an array with multiple values, it must match the number of observation times
|
|
976
|
+
if ki_barriers_effective.shape[0] != num_ki_obs_times:
|
|
977
|
+
raise ValidationError(
|
|
978
|
+
f"ki_barriers array (shape {ki_barriers_effective.shape[0]}) "
|
|
979
|
+
f"does not match number of KI observation times ({num_ki_obs_times})"
|
|
980
|
+
)
|
|
981
|
+
ki_barriers_aligned = ki_barriers_effective
|
|
982
|
+
|
|
983
|
+
# Vectorized barrier check
|
|
984
|
+
if is_reverse:
|
|
985
|
+
# Reverse snowball: UP barrier for KI (KI if price >= barrier)
|
|
986
|
+
ki_hit = ki_prices >= ki_barriers_aligned
|
|
987
|
+
else:
|
|
988
|
+
# Standard snowball: DOWN barrier for KI (KI if price <= barrier)
|
|
989
|
+
ki_hit = ki_prices <= ki_barriers_aligned
|
|
990
|
+
|
|
991
|
+
# Find first KI time per path
|
|
992
|
+
ki_triggered = ki_hit.any(axis=1)
|
|
993
|
+
first_ki_idx = np.full(len(paths), -1, dtype=int)
|
|
994
|
+
|
|
995
|
+
if ki_triggered.any():
|
|
996
|
+
first_ki_idx[ki_triggered] = np.argmax(ki_hit[ki_triggered], axis=1)
|
|
997
|
+
|
|
998
|
+
return ki_triggered, first_ki_idx
|
|
999
|
+
|
|
1000
|
+
def _check_ki_barriers_continuous_with_bridge(
|
|
1001
|
+
self,
|
|
1002
|
+
paths: np.ndarray,
|
|
1003
|
+
all_times: np.ndarray,
|
|
1004
|
+
ki_barrier: float,
|
|
1005
|
+
sigma: float,
|
|
1006
|
+
is_reverse: bool,
|
|
1007
|
+
rng_seed: int,
|
|
1008
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
1009
|
+
"""
|
|
1010
|
+
Continuous KI monitoring with Brownian-bridge barrier correction.
|
|
1011
|
+
|
|
1012
|
+
The path is simulated on a discrete grid. If both endpoints of an interval
|
|
1013
|
+
are on the non-breached side of the KI barrier, a Brownian-bridge estimate
|
|
1014
|
+
is used to sample whether a barrier hit occurred within the interval.
|
|
1015
|
+
"""
|
|
1016
|
+
if ki_barrier <= 0:
|
|
1017
|
+
raise ValidationError(f"ki_barrier must be positive, got {ki_barrier}")
|
|
1018
|
+
if sigma <= 0:
|
|
1019
|
+
raise ValidationError(f"volatility must be positive, got {sigma}")
|
|
1020
|
+
|
|
1021
|
+
n_paths = len(paths)
|
|
1022
|
+
if n_paths == 0:
|
|
1023
|
+
return np.zeros(0, dtype=bool), np.zeros(0, dtype=int)
|
|
1024
|
+
|
|
1025
|
+
ki_triggered = np.zeros(n_paths, dtype=bool)
|
|
1026
|
+
first_ki_idx = np.full(n_paths, -1, dtype=int)
|
|
1027
|
+
|
|
1028
|
+
# Immediate breach at valuation (t=0) counts as KI for continuous monitoring.
|
|
1029
|
+
spot0 = paths[:, 0]
|
|
1030
|
+
if is_reverse:
|
|
1031
|
+
already_breached = spot0 >= ki_barrier
|
|
1032
|
+
else:
|
|
1033
|
+
already_breached = spot0 <= ki_barrier
|
|
1034
|
+
if already_breached.any():
|
|
1035
|
+
ki_triggered[already_breached] = True
|
|
1036
|
+
first_ki_idx[already_breached] = 0
|
|
1037
|
+
|
|
1038
|
+
all_times = np.asarray(all_times, dtype=float)
|
|
1039
|
+
if all_times.ndim != 1:
|
|
1040
|
+
raise ValidationError("all_times must be a 1D array of time points")
|
|
1041
|
+
|
|
1042
|
+
n_steps = paths.shape[1] - 1
|
|
1043
|
+
if all_times.shape[0] != n_steps:
|
|
1044
|
+
raise ValidationError(
|
|
1045
|
+
f"all_times length ({all_times.shape[0]}) must match number of steps ({n_steps})"
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
dt = np.empty(n_steps, dtype=float)
|
|
1049
|
+
dt[0] = float(all_times[0])
|
|
1050
|
+
if n_steps > 1:
|
|
1051
|
+
dt[1:] = np.diff(all_times)
|
|
1052
|
+
if np.any(dt <= 0.0):
|
|
1053
|
+
raise ValidationError("all_times must be strictly increasing and > 0")
|
|
1054
|
+
|
|
1055
|
+
rng = np.random.default_rng(int(rng_seed))
|
|
1056
|
+
|
|
1057
|
+
# Sample step-wise hit events using the Brownian-bridge probabilities.
|
|
1058
|
+
# If a hit occurs in step k, we record the right endpoint index k.
|
|
1059
|
+
for k in range(n_steps):
|
|
1060
|
+
active = ~ki_triggered
|
|
1061
|
+
if not active.any():
|
|
1062
|
+
break
|
|
1063
|
+
|
|
1064
|
+
# If the barrier is already breached at the right endpoint, it's a certain hit.
|
|
1065
|
+
s1 = paths[:, k + 1]
|
|
1066
|
+
if is_reverse:
|
|
1067
|
+
breached_at_endpoint = s1 >= ki_barrier
|
|
1068
|
+
else:
|
|
1069
|
+
breached_at_endpoint = s1 <= ki_barrier
|
|
1070
|
+
|
|
1071
|
+
new_hit = active & breached_at_endpoint
|
|
1072
|
+
if new_hit.any():
|
|
1073
|
+
ki_triggered[new_hit] = True
|
|
1074
|
+
first_ki_idx[new_hit] = k
|
|
1075
|
+
|
|
1076
|
+
active = ~ki_triggered
|
|
1077
|
+
if not active.any():
|
|
1078
|
+
break
|
|
1079
|
+
|
|
1080
|
+
s0 = paths[:, k]
|
|
1081
|
+
s1 = paths[:, k + 1]
|
|
1082
|
+
|
|
1083
|
+
if is_reverse:
|
|
1084
|
+
# KI if price >= barrier; non-breached region is below the barrier.
|
|
1085
|
+
non_breached = (s0 < ki_barrier) & (s1 < ki_barrier)
|
|
1086
|
+
else:
|
|
1087
|
+
# KI if price <= barrier; non-breached region is above the barrier.
|
|
1088
|
+
non_breached = (s0 > ki_barrier) & (s1 > ki_barrier)
|
|
1089
|
+
|
|
1090
|
+
bridge_candidates = active & non_breached
|
|
1091
|
+
if not bridge_candidates.any():
|
|
1092
|
+
continue
|
|
1093
|
+
|
|
1094
|
+
idx = np.flatnonzero(bridge_candidates)
|
|
1095
|
+
dt_k = float(dt[k])
|
|
1096
|
+
h2 = float(sigma * sigma) * dt_k
|
|
1097
|
+
|
|
1098
|
+
log_term = safe_log(s0[idx] / ki_barrier) * safe_log(s1[idx] / ki_barrier)
|
|
1099
|
+
exponent = -2.0 * log_term / h2
|
|
1100
|
+
exponent = np.clip(exponent, -745.0, 0.0)
|
|
1101
|
+
p = np.exp(exponent)
|
|
1102
|
+
|
|
1103
|
+
u = rng.random(idx.size)
|
|
1104
|
+
hit = u < p
|
|
1105
|
+
if hit.any():
|
|
1106
|
+
hit_paths = idx[hit]
|
|
1107
|
+
ki_triggered[hit_paths] = True
|
|
1108
|
+
first_ki_idx[hit_paths] = k
|
|
1109
|
+
|
|
1110
|
+
return ki_triggered, first_ki_idx
|
|
1111
|
+
|
|
1112
|
+
def _compute_payoffs(
|
|
1113
|
+
self,
|
|
1114
|
+
product: SnowballOption,
|
|
1115
|
+
pricing_env: PricingEnvironment,
|
|
1116
|
+
paths: np.ndarray,
|
|
1117
|
+
all_times: np.ndarray,
|
|
1118
|
+
ko_indices: np.ndarray,
|
|
1119
|
+
ki_indices: np.ndarray,
|
|
1120
|
+
r: float,
|
|
1121
|
+
T: float,
|
|
1122
|
+
sigma: float,
|
|
1123
|
+
rng_seed: int,
|
|
1124
|
+
) -> Tuple[np.ndarray, np.ndarray, Dict[str, float]]:
|
|
1125
|
+
"""
|
|
1126
|
+
Compute payoffs for all paths based on their terminal state.
|
|
1127
|
+
|
|
1128
|
+
Args:
|
|
1129
|
+
product: SnowballOption product
|
|
1130
|
+
pricing_env: Pricing environment
|
|
1131
|
+
paths: Simulated paths, shape (num_paths, num_times + 1)
|
|
1132
|
+
all_times: All observation times
|
|
1133
|
+
ko_indices: Indices for KO observations
|
|
1134
|
+
ki_indices: Indices for KI observations
|
|
1135
|
+
r: Risk-free rate
|
|
1136
|
+
T: Time to maturity
|
|
1137
|
+
|
|
1138
|
+
Returns:
|
|
1139
|
+
Tuple of:
|
|
1140
|
+
- payoffs: Undiscounted payoffs, shape (num_paths,)
|
|
1141
|
+
- settlement_times: Settlement time for each path, shape (num_paths,)
|
|
1142
|
+
- stats: Dictionary with probability statistics
|
|
1143
|
+
"""
|
|
1144
|
+
num_paths = len(paths)
|
|
1145
|
+
|
|
1146
|
+
# Get resolved observation profiles
|
|
1147
|
+
ko_profile = product.get_ko_observation_profile(pricing_env)
|
|
1148
|
+
ko_barriers = np.array(ko_profile["barriers"])
|
|
1149
|
+
ko_payoffs_schedule = np.array(ko_profile["payoffs"])
|
|
1150
|
+
ko_times = np.array(ko_profile["observation_times"])
|
|
1151
|
+
ko_settlement_times = np.array(ko_profile["settlement_times"])
|
|
1152
|
+
|
|
1153
|
+
# Get KI barriers (can be scalar or array)
|
|
1154
|
+
ki_barriers_val = None
|
|
1155
|
+
if product.has_ki_barrier:
|
|
1156
|
+
ki_profile = product.get_ki_observation_profile(pricing_env)
|
|
1157
|
+
ki_barriers_val = np.array(ki_profile["barriers"])
|
|
1158
|
+
|
|
1159
|
+
# Check barriers
|
|
1160
|
+
ko_triggered, first_ko_idx = self._check_ko_barriers(
|
|
1161
|
+
paths, ko_indices, ko_barriers, product.is_reverse
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
ki_triggered = np.zeros(num_paths, dtype=bool)
|
|
1165
|
+
first_ki_idx = np.full(num_paths, -1, dtype=int)
|
|
1166
|
+
if ki_barriers_val is not None:
|
|
1167
|
+
ki_continuous = (
|
|
1168
|
+
product.barrier_config.ki_observation_type == ObservationType.CONTINUOUS
|
|
1169
|
+
or product.barrier_config.ki_continuous
|
|
1170
|
+
)
|
|
1171
|
+
if ki_continuous:
|
|
1172
|
+
if ki_barriers_val.shape not in ((), (1,)):
|
|
1173
|
+
raise ValidationError(
|
|
1174
|
+
"Continuous KI monitoring requires a scalar ki_barrier."
|
|
1175
|
+
)
|
|
1176
|
+
ki_barrier_scalar = float(ki_barriers_val.reshape(-1)[0])
|
|
1177
|
+
ki_triggered, first_ki_idx = (
|
|
1178
|
+
self._check_ki_barriers_continuous_with_bridge(
|
|
1179
|
+
paths=paths,
|
|
1180
|
+
all_times=all_times,
|
|
1181
|
+
ki_barrier=ki_barrier_scalar,
|
|
1182
|
+
sigma=float(sigma),
|
|
1183
|
+
is_reverse=product.is_reverse,
|
|
1184
|
+
rng_seed=int(rng_seed),
|
|
1185
|
+
)
|
|
1186
|
+
)
|
|
1187
|
+
else:
|
|
1188
|
+
ki_triggered, first_ki_idx = self._check_ki_barriers(
|
|
1189
|
+
paths, ki_indices, ki_barriers_val, product.is_reverse
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
already_knocked_in = bool(getattr(product, "_otc_lifecycle_knocked_in", False))
|
|
1193
|
+
if already_knocked_in:
|
|
1194
|
+
ki_triggered[:] = True
|
|
1195
|
+
first_ki_idx[:] = 0
|
|
1196
|
+
|
|
1197
|
+
# Handle disable_ko_after_ki logic
|
|
1198
|
+
if product.barrier_config.disable_ko_after_ki and (
|
|
1199
|
+
ki_barriers_val is not None or already_knocked_in
|
|
1200
|
+
):
|
|
1201
|
+
if already_knocked_in:
|
|
1202
|
+
ko_valid = np.zeros_like(ko_triggered)
|
|
1203
|
+
else:
|
|
1204
|
+
# Get times for comparison
|
|
1205
|
+
ko_trigger_times = np.where(
|
|
1206
|
+
first_ko_idx >= 0,
|
|
1207
|
+
ko_times[first_ko_idx],
|
|
1208
|
+
np.inf,
|
|
1209
|
+
)
|
|
1210
|
+
ki_trigger_times = np.where(
|
|
1211
|
+
first_ki_idx >= 0,
|
|
1212
|
+
all_times[ki_indices[first_ki_idx]]
|
|
1213
|
+
if len(ki_indices) > 0
|
|
1214
|
+
else np.inf,
|
|
1215
|
+
np.inf,
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
# KO is only valid if it happens before KI
|
|
1219
|
+
ko_before_ki = ko_trigger_times < ki_trigger_times
|
|
1220
|
+
ko_valid = ko_triggered & ko_before_ki
|
|
1221
|
+
else:
|
|
1222
|
+
ko_valid = ko_triggered
|
|
1223
|
+
|
|
1224
|
+
# Classify paths into states
|
|
1225
|
+
is_ko = ko_valid
|
|
1226
|
+
is_v0 = ~is_ko & ~ki_triggered
|
|
1227
|
+
is_v1 = ~is_ko & ki_triggered
|
|
1228
|
+
|
|
1229
|
+
# Initialize payoffs and settlement times
|
|
1230
|
+
payoffs = np.zeros(num_paths)
|
|
1231
|
+
settlement_times = np.full(num_paths, T)
|
|
1232
|
+
|
|
1233
|
+
# KO payoffs
|
|
1234
|
+
if is_ko.any():
|
|
1235
|
+
ko_idx_for_payoff = first_ko_idx[is_ko]
|
|
1236
|
+
payoffs[is_ko] = ko_payoffs_schedule[ko_idx_for_payoff]
|
|
1237
|
+
|
|
1238
|
+
# Settlement time depends on coupon pay type
|
|
1239
|
+
if product.accrual_config.coupon_pay_type == CouponPayType.INSTANT:
|
|
1240
|
+
settlement_times[is_ko] = ko_settlement_times[ko_idx_for_payoff]
|
|
1241
|
+
# else: EXPIRY - settlement at maturity (already set)
|
|
1242
|
+
|
|
1243
|
+
# V0 payoffs (never KO, never KI)
|
|
1244
|
+
if is_v0.any():
|
|
1245
|
+
terminal_spots = paths[is_v0, -1]
|
|
1246
|
+
v0_payoffs = np.array(
|
|
1247
|
+
[
|
|
1248
|
+
product.get_maturity_payoff_v0(spot, pricing_env)
|
|
1249
|
+
for spot in terminal_spots
|
|
1250
|
+
]
|
|
1251
|
+
)
|
|
1252
|
+
payoffs[is_v0] = v0_payoffs
|
|
1253
|
+
|
|
1254
|
+
# V1 payoffs (never KO, KI happened)
|
|
1255
|
+
if is_v1.any():
|
|
1256
|
+
terminal_spots = paths[is_v1, -1]
|
|
1257
|
+
v1_payoffs = np.array(
|
|
1258
|
+
[
|
|
1259
|
+
product.get_maturity_payoff_v1(spot, pricing_env)
|
|
1260
|
+
for spot in terminal_spots
|
|
1261
|
+
]
|
|
1262
|
+
)
|
|
1263
|
+
payoffs[is_v1] = v1_payoffs
|
|
1264
|
+
|
|
1265
|
+
# Compute statistics
|
|
1266
|
+
ko_count = int(is_ko.sum())
|
|
1267
|
+
v0_count = int(is_v0.sum())
|
|
1268
|
+
v1_count = int(is_v1.sum())
|
|
1269
|
+
|
|
1270
|
+
stats = {
|
|
1271
|
+
"ko_probability": float(is_ko.mean()),
|
|
1272
|
+
"v0_probability": float(is_v0.mean()),
|
|
1273
|
+
"v1_probability": float(is_v1.mean()),
|
|
1274
|
+
"ko_count": ko_count,
|
|
1275
|
+
"v0_count": v0_count,
|
|
1276
|
+
"v1_count": v1_count,
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if is_ko.any():
|
|
1280
|
+
ko_times_hit = ko_times[first_ko_idx[is_ko]]
|
|
1281
|
+
stats["avg_ko_time"] = float(ko_times_hit.mean())
|
|
1282
|
+
stats["ko_time_sum"] = float(ko_times_hit.sum())
|
|
1283
|
+
stats["ko_time_count"] = ko_count
|
|
1284
|
+
else:
|
|
1285
|
+
stats["avg_ko_time"] = None
|
|
1286
|
+
stats["ko_time_sum"] = 0.0
|
|
1287
|
+
stats["ko_time_count"] = 0
|
|
1288
|
+
|
|
1289
|
+
return payoffs, settlement_times, stats
|
|
1290
|
+
|
|
1291
|
+
def _compute_payoffs_ko_reset(
|
|
1292
|
+
self,
|
|
1293
|
+
product: KnockOutResetSnowballOption,
|
|
1294
|
+
pricing_env: PricingEnvironment,
|
|
1295
|
+
paths: np.ndarray,
|
|
1296
|
+
grid: Dict[str, object],
|
|
1297
|
+
r: float,
|
|
1298
|
+
T: float,
|
|
1299
|
+
sigma: float,
|
|
1300
|
+
rng_seed: int,
|
|
1301
|
+
) -> Tuple[np.ndarray, np.ndarray, Dict[str, float], Dict[str, object]]:
|
|
1302
|
+
"""
|
|
1303
|
+
Compute payoffs for KO-reset snowball paths.
|
|
1304
|
+
"""
|
|
1305
|
+
num_paths = len(paths)
|
|
1306
|
+
if num_paths == 0:
|
|
1307
|
+
return np.zeros(0), np.zeros(0), {}, {}
|
|
1308
|
+
|
|
1309
|
+
all_times = grid["all_times"]
|
|
1310
|
+
pre_times = grid["pre_times"]
|
|
1311
|
+
post_times = grid["post_times"]
|
|
1312
|
+
pre_indices = grid["pre_indices"]
|
|
1313
|
+
post_indices = grid["post_indices"]
|
|
1314
|
+
post_indices_by_ki = grid["post_indices_by_ki"]
|
|
1315
|
+
ki_times = grid["ki_times"]
|
|
1316
|
+
ki_indices = grid["ki_indices"]
|
|
1317
|
+
ki_continuous = grid["ki_continuous"]
|
|
1318
|
+
ki_horizon_idx = grid["ki_horizon_idx"]
|
|
1319
|
+
|
|
1320
|
+
pre_profile = grid["pre_profile"]
|
|
1321
|
+
post_profile = grid["post_profile"]
|
|
1322
|
+
|
|
1323
|
+
pre_barriers = np.array(pre_profile["barriers"], dtype=float)
|
|
1324
|
+
pre_rates = np.array(pre_profile["rates"], dtype=float)
|
|
1325
|
+
pre_records = pre_profile["records"]
|
|
1326
|
+
|
|
1327
|
+
post_barriers = np.array(post_profile["barriers"], dtype=float)
|
|
1328
|
+
post_rates = np.array(post_profile["rates"], dtype=float)
|
|
1329
|
+
post_records = post_profile["records"]
|
|
1330
|
+
post_mode = post_profile["mode"]
|
|
1331
|
+
|
|
1332
|
+
pre_maturity = product.get_pre_maturity_time(pricing_env)
|
|
1333
|
+
post_max_offset = float(max(post_times)) if len(post_times) else 0.0
|
|
1334
|
+
post_maturity_abs = (
|
|
1335
|
+
product.get_post_maturity_time(pricing_env)
|
|
1336
|
+
if post_mode == PostKOScheduleMode.ABSOLUTE
|
|
1337
|
+
else None
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
# KO payoffs for pre schedule
|
|
1341
|
+
pre_payoffs, pre_settlement_times = self._compute_ko_schedule_payoffs(
|
|
1342
|
+
product,
|
|
1343
|
+
pre_times,
|
|
1344
|
+
pre_rates,
|
|
1345
|
+
pre_records,
|
|
1346
|
+
pricing_env,
|
|
1347
|
+
pre_maturity,
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
post_payoffs = np.array([], dtype=float)
|
|
1351
|
+
post_settlement_times = np.array([], dtype=float)
|
|
1352
|
+
if post_mode == PostKOScheduleMode.ABSOLUTE and len(post_times) > 0:
|
|
1353
|
+
post_payoffs, post_settlement_times = self._compute_ko_schedule_payoffs(
|
|
1354
|
+
product,
|
|
1355
|
+
post_times,
|
|
1356
|
+
post_rates,
|
|
1357
|
+
post_records,
|
|
1358
|
+
pricing_env,
|
|
1359
|
+
float(post_maturity_abs),
|
|
1360
|
+
)
|
|
1361
|
+
|
|
1362
|
+
# KI barriers
|
|
1363
|
+
ki_barriers_val = None
|
|
1364
|
+
if product.has_ki_barrier:
|
|
1365
|
+
ki_profile = product.get_ki_observation_profile(pricing_env)
|
|
1366
|
+
ki_barriers_val = np.array(ki_profile["barriers"], dtype=float)
|
|
1367
|
+
|
|
1368
|
+
# Check KI triggers
|
|
1369
|
+
ki_triggered = np.zeros(num_paths, dtype=bool)
|
|
1370
|
+
first_ki_idx = np.full(num_paths, -1, dtype=int)
|
|
1371
|
+
if ki_barriers_val is not None:
|
|
1372
|
+
if ki_continuous:
|
|
1373
|
+
if ki_barriers_val.shape not in ((), (1,)):
|
|
1374
|
+
raise ValidationError(
|
|
1375
|
+
"Continuous KI monitoring requires a scalar ki_barrier."
|
|
1376
|
+
)
|
|
1377
|
+
ki_barrier_scalar = float(ki_barriers_val.reshape(-1)[0])
|
|
1378
|
+
if ki_horizon_idx is None:
|
|
1379
|
+
raise ValidationError("Missing KI horizon index for KO-reset grid.")
|
|
1380
|
+
ki_paths = paths[:, : ki_horizon_idx + 2]
|
|
1381
|
+
ki_times_slice = all_times[: ki_horizon_idx + 1]
|
|
1382
|
+
ki_triggered, first_ki_idx = (
|
|
1383
|
+
self._check_ki_barriers_continuous_with_bridge(
|
|
1384
|
+
paths=ki_paths,
|
|
1385
|
+
all_times=ki_times_slice,
|
|
1386
|
+
ki_barrier=ki_barrier_scalar,
|
|
1387
|
+
sigma=float(sigma),
|
|
1388
|
+
is_reverse=product.is_reverse,
|
|
1389
|
+
rng_seed=int(rng_seed),
|
|
1390
|
+
)
|
|
1391
|
+
)
|
|
1392
|
+
else:
|
|
1393
|
+
ki_triggered, first_ki_idx = self._check_ki_barriers(
|
|
1394
|
+
paths, ki_indices, ki_barriers_val, product.is_reverse
|
|
1395
|
+
)
|
|
1396
|
+
|
|
1397
|
+
# Map KI trigger time
|
|
1398
|
+
ki_time = np.full(num_paths, np.inf, dtype=float)
|
|
1399
|
+
if ki_continuous:
|
|
1400
|
+
valid = first_ki_idx >= 0
|
|
1401
|
+
if valid.any():
|
|
1402
|
+
ki_time[valid] = all_times[first_ki_idx[valid]]
|
|
1403
|
+
else:
|
|
1404
|
+
valid = first_ki_idx >= 0
|
|
1405
|
+
if valid.any():
|
|
1406
|
+
ki_time[valid] = ki_times[first_ki_idx[valid]]
|
|
1407
|
+
|
|
1408
|
+
# Pre-KO hits
|
|
1409
|
+
pre_ko_triggered = np.zeros(num_paths, dtype=bool)
|
|
1410
|
+
first_pre_idx = np.full(num_paths, -1, dtype=int)
|
|
1411
|
+
if len(pre_indices) > 0:
|
|
1412
|
+
pre_ko_triggered, first_pre_idx = self._check_ko_barriers(
|
|
1413
|
+
paths, pre_indices, pre_barriers, product.is_reverse
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
if len(pre_times) == 0:
|
|
1417
|
+
pre_ko_time = np.full(num_paths, np.inf, dtype=float)
|
|
1418
|
+
else:
|
|
1419
|
+
pre_ko_time = np.where(
|
|
1420
|
+
first_pre_idx >= 0, pre_times[first_pre_idx], np.inf
|
|
1421
|
+
)
|
|
1422
|
+
pre_valid = pre_ko_triggered & (pre_ko_time < ki_time)
|
|
1423
|
+
|
|
1424
|
+
# Post-KO hits
|
|
1425
|
+
post_ko_triggered = np.zeros(num_paths, dtype=bool)
|
|
1426
|
+
first_post_idx = np.full(num_paths, -1, dtype=int)
|
|
1427
|
+
post_ko_time = np.full(num_paths, np.inf, dtype=float)
|
|
1428
|
+
|
|
1429
|
+
post_allowed = ki_triggered & ~pre_valid
|
|
1430
|
+
if product.barrier_config.disable_ko_after_ki:
|
|
1431
|
+
post_allowed = np.zeros(num_paths, dtype=bool)
|
|
1432
|
+
|
|
1433
|
+
if post_allowed.any() and len(post_times) > 0:
|
|
1434
|
+
if post_mode == PostKOScheduleMode.ABSOLUTE:
|
|
1435
|
+
post_prices = paths[:, post_indices + 1]
|
|
1436
|
+
if product.is_reverse:
|
|
1437
|
+
post_hit = post_prices <= post_barriers
|
|
1438
|
+
else:
|
|
1439
|
+
post_hit = post_prices >= post_barriers
|
|
1440
|
+
time_mask = post_times[None, :] > ki_time[:, None]
|
|
1441
|
+
post_hit_filtered = post_hit & time_mask
|
|
1442
|
+
post_triggered = post_hit_filtered.any(axis=1)
|
|
1443
|
+
post_triggered &= post_allowed
|
|
1444
|
+
if post_triggered.any():
|
|
1445
|
+
first_idx_local = np.argmax(post_hit_filtered[post_triggered], axis=1)
|
|
1446
|
+
post_ko_triggered[post_triggered] = True
|
|
1447
|
+
first_post_idx[post_triggered] = first_idx_local
|
|
1448
|
+
post_ko_time[post_triggered] = post_times[first_idx_local]
|
|
1449
|
+
else:
|
|
1450
|
+
post_offsets = post_times
|
|
1451
|
+
if post_indices_by_ki is None:
|
|
1452
|
+
raise ValidationError("Missing post_indices_by_ki for rebased KO reset.")
|
|
1453
|
+
eligible_idx = np.flatnonzero(post_allowed)
|
|
1454
|
+
if eligible_idx.size > 0:
|
|
1455
|
+
for ki_idx_val in np.unique(first_ki_idx[eligible_idx]):
|
|
1456
|
+
if ki_idx_val < 0:
|
|
1457
|
+
continue
|
|
1458
|
+
group_mask = post_allowed & (first_ki_idx == ki_idx_val)
|
|
1459
|
+
if not group_mask.any():
|
|
1460
|
+
continue
|
|
1461
|
+
indices = post_indices_by_ki[ki_idx_val]
|
|
1462
|
+
if len(indices) == 0:
|
|
1463
|
+
continue
|
|
1464
|
+
group_paths = paths[group_mask][:, indices + 1]
|
|
1465
|
+
if product.is_reverse:
|
|
1466
|
+
hit_matrix = group_paths <= post_barriers
|
|
1467
|
+
else:
|
|
1468
|
+
hit_matrix = group_paths >= post_barriers
|
|
1469
|
+
triggered = hit_matrix.any(axis=1)
|
|
1470
|
+
if not triggered.any():
|
|
1471
|
+
continue
|
|
1472
|
+
first_local = np.argmax(hit_matrix[triggered], axis=1)
|
|
1473
|
+
group_idx = np.flatnonzero(group_mask)[triggered]
|
|
1474
|
+
post_ko_triggered[group_idx] = True
|
|
1475
|
+
first_post_idx[group_idx] = first_local
|
|
1476
|
+
post_ko_time[group_idx] = (
|
|
1477
|
+
ki_time[group_idx] + post_offsets[first_local]
|
|
1478
|
+
)
|
|
1479
|
+
|
|
1480
|
+
is_pre_ko = pre_valid
|
|
1481
|
+
is_post_ko = post_ko_triggered & ~is_pre_ko
|
|
1482
|
+
is_ko = is_pre_ko | is_post_ko
|
|
1483
|
+
is_v0 = ~is_ko & ~ki_triggered
|
|
1484
|
+
is_v1 = ~is_ko & ki_triggered
|
|
1485
|
+
|
|
1486
|
+
payoffs = np.zeros(num_paths, dtype=float)
|
|
1487
|
+
settlement_times = np.zeros(num_paths, dtype=float)
|
|
1488
|
+
|
|
1489
|
+
if is_pre_ko.any():
|
|
1490
|
+
ko_idx = first_pre_idx[is_pre_ko]
|
|
1491
|
+
payoffs[is_pre_ko] = pre_payoffs[ko_idx]
|
|
1492
|
+
if product.accrual_config.coupon_pay_type == CouponPayType.INSTANT:
|
|
1493
|
+
settlement_times[is_pre_ko] = pre_settlement_times[ko_idx]
|
|
1494
|
+
else:
|
|
1495
|
+
settlement_times[is_pre_ko] = float(pre_maturity)
|
|
1496
|
+
|
|
1497
|
+
if is_post_ko.any():
|
|
1498
|
+
if post_mode == PostKOScheduleMode.ABSOLUTE:
|
|
1499
|
+
ko_idx = first_post_idx[is_post_ko]
|
|
1500
|
+
payoffs[is_post_ko] = post_payoffs[ko_idx]
|
|
1501
|
+
if product.accrual_config.coupon_pay_type == CouponPayType.INSTANT:
|
|
1502
|
+
settlement_times[is_post_ko] = post_settlement_times[ko_idx]
|
|
1503
|
+
else:
|
|
1504
|
+
settlement_times[is_post_ko] = float(post_maturity_abs)
|
|
1505
|
+
else:
|
|
1506
|
+
principal_component = (
|
|
1507
|
+
product.initial_price * product.contract_multiplier
|
|
1508
|
+
if product.payoff_config.include_principal
|
|
1509
|
+
else 0.0
|
|
1510
|
+
)
|
|
1511
|
+
annualized_ko = product._effective_annualized_flag(
|
|
1512
|
+
product.accrual_config.is_annualized_ko
|
|
1513
|
+
)
|
|
1514
|
+
if annualized_ko and product.initial_date is not None:
|
|
1515
|
+
if pricing_env is None:
|
|
1516
|
+
raise ValidationError(
|
|
1517
|
+
"PricingEnvironment required to resolve KO accrual without observation_date."
|
|
1518
|
+
)
|
|
1519
|
+
if pricing_env.valuation_date < product.initial_date:
|
|
1520
|
+
raise ValidationError(
|
|
1521
|
+
"valuation_date must be on or after initial_date to resolve KO accrual."
|
|
1522
|
+
)
|
|
1523
|
+
if pricing_env.valuation_date == product.initial_date:
|
|
1524
|
+
initial_to_valuation = 0.0
|
|
1525
|
+
else:
|
|
1526
|
+
initial_to_valuation = calculate_year_fraction(
|
|
1527
|
+
product.initial_date,
|
|
1528
|
+
pricing_env.valuation_date,
|
|
1529
|
+
product.annualization_day_count,
|
|
1530
|
+
pricing_env.bus_days_in_year,
|
|
1531
|
+
calendar=getattr(pricing_env, "calendar", None),
|
|
1532
|
+
)
|
|
1533
|
+
else:
|
|
1534
|
+
initial_to_valuation = 0.0
|
|
1535
|
+
|
|
1536
|
+
idx = np.flatnonzero(is_post_ko)
|
|
1537
|
+
local_idx = first_post_idx[idx]
|
|
1538
|
+
offsets = post_times[local_idx]
|
|
1539
|
+
rates = post_rates[local_idx]
|
|
1540
|
+
if annualized_ko:
|
|
1541
|
+
accrual = initial_to_valuation + ki_time[idx] + offsets
|
|
1542
|
+
else:
|
|
1543
|
+
accrual = 1.0
|
|
1544
|
+
coupon = (
|
|
1545
|
+
product.initial_price
|
|
1546
|
+
* product.contract_multiplier
|
|
1547
|
+
* rates
|
|
1548
|
+
* accrual
|
|
1549
|
+
)
|
|
1550
|
+
payoffs[idx] = principal_component + coupon
|
|
1551
|
+
if product.accrual_config.coupon_pay_type == CouponPayType.INSTANT:
|
|
1552
|
+
settlement_times[idx] = ki_time[idx] + offsets
|
|
1553
|
+
else:
|
|
1554
|
+
settlement_times[idx] = ki_time[idx] + post_max_offset
|
|
1555
|
+
|
|
1556
|
+
if is_v0.any():
|
|
1557
|
+
terminal_spots = paths[is_v0, -1]
|
|
1558
|
+
v0_payoffs = np.array(
|
|
1559
|
+
[
|
|
1560
|
+
product.get_maturity_payoff_v0(spot, pricing_env)
|
|
1561
|
+
for spot in terminal_spots
|
|
1562
|
+
],
|
|
1563
|
+
dtype=float,
|
|
1564
|
+
)
|
|
1565
|
+
payoffs[is_v0] = v0_payoffs
|
|
1566
|
+
settlement_times[is_v0] = float(pre_maturity)
|
|
1567
|
+
|
|
1568
|
+
if is_v1.any():
|
|
1569
|
+
terminal_spots = paths[is_v1, -1]
|
|
1570
|
+
v1_payoffs = np.array(
|
|
1571
|
+
[
|
|
1572
|
+
product.get_maturity_payoff_v1(spot, pricing_env)
|
|
1573
|
+
for spot in terminal_spots
|
|
1574
|
+
],
|
|
1575
|
+
dtype=float,
|
|
1576
|
+
)
|
|
1577
|
+
payoffs[is_v1] = v1_payoffs
|
|
1578
|
+
if post_mode == PostKOScheduleMode.ABSOLUTE:
|
|
1579
|
+
settlement_times[is_v1] = float(post_maturity_abs)
|
|
1580
|
+
else:
|
|
1581
|
+
settlement_times[is_v1] = ki_time[is_v1] + post_max_offset
|
|
1582
|
+
|
|
1583
|
+
ko_time = np.where(is_pre_ko, pre_ko_time, np.where(is_post_ko, post_ko_time, np.inf))
|
|
1584
|
+
|
|
1585
|
+
stats = {
|
|
1586
|
+
"ko_probability": float(is_ko.mean()),
|
|
1587
|
+
"v0_probability": float(is_v0.mean()),
|
|
1588
|
+
"v1_probability": float(is_v1.mean()),
|
|
1589
|
+
"ko_count": int(is_ko.sum()),
|
|
1590
|
+
"v0_count": int(is_v0.sum()),
|
|
1591
|
+
"v1_count": int(is_v1.sum()),
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
if is_ko.any():
|
|
1595
|
+
stats["avg_ko_time"] = float(np.mean(ko_time[is_ko]))
|
|
1596
|
+
stats["ko_time_sum"] = float(np.sum(ko_time[is_ko]))
|
|
1597
|
+
stats["ko_time_count"] = int(is_ko.sum())
|
|
1598
|
+
else:
|
|
1599
|
+
stats["avg_ko_time"] = None
|
|
1600
|
+
stats["ko_time_sum"] = 0.0
|
|
1601
|
+
stats["ko_time_count"] = 0
|
|
1602
|
+
|
|
1603
|
+
extra = {
|
|
1604
|
+
"is_pre_ko": is_pre_ko,
|
|
1605
|
+
"is_post_ko": is_post_ko,
|
|
1606
|
+
"first_pre_idx": first_pre_idx,
|
|
1607
|
+
"first_post_idx": first_post_idx,
|
|
1608
|
+
"ki_triggered": ki_triggered,
|
|
1609
|
+
"ki_time": ki_time,
|
|
1610
|
+
"ko_time": ko_time,
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
return payoffs, settlement_times, stats, extra
|
|
1614
|
+
|
|
1615
|
+
def _price_mc_or_qmc(
|
|
1616
|
+
self,
|
|
1617
|
+
product: SnowballOption,
|
|
1618
|
+
pricing_env: PricingEnvironment,
|
|
1619
|
+
S: float,
|
|
1620
|
+
T: float,
|
|
1621
|
+
r: float,
|
|
1622
|
+
q: float,
|
|
1623
|
+
sigma: float,
|
|
1624
|
+
) -> SnowballMCResult:
|
|
1625
|
+
"""
|
|
1626
|
+
Price using normal MC or QMC (non-randomized).
|
|
1627
|
+
"""
|
|
1628
|
+
# Build time grid
|
|
1629
|
+
all_times, dt_array, ko_indices, ki_indices = self._build_time_grid(
|
|
1630
|
+
product, pricing_env, T
|
|
1631
|
+
)
|
|
1632
|
+
|
|
1633
|
+
# Create path generator
|
|
1634
|
+
generator = self._create_path_generator(S, r, q, sigma, T, dt_array)
|
|
1635
|
+
|
|
1636
|
+
# Generate paths
|
|
1637
|
+
paths, _ = generator.generate_paths(return_aux=False)
|
|
1638
|
+
|
|
1639
|
+
# Compute payoffs
|
|
1640
|
+
payoffs, settlement_times, stats = self._compute_payoffs(
|
|
1641
|
+
product,
|
|
1642
|
+
pricing_env,
|
|
1643
|
+
paths,
|
|
1644
|
+
all_times,
|
|
1645
|
+
ko_indices,
|
|
1646
|
+
ki_indices,
|
|
1647
|
+
r,
|
|
1648
|
+
T,
|
|
1649
|
+
sigma,
|
|
1650
|
+
rng_seed=int(self.params.seed) + 1337,
|
|
1651
|
+
)
|
|
1652
|
+
|
|
1653
|
+
# Discount payoffs
|
|
1654
|
+
discount_factors = np.exp(-r * settlement_times)
|
|
1655
|
+
discounted_payoffs = payoffs * discount_factors
|
|
1656
|
+
|
|
1657
|
+
# Compute price and standard error
|
|
1658
|
+
price = float(discounted_payoffs.mean())
|
|
1659
|
+
std_payoff = float(discounted_payoffs.std(ddof=1))
|
|
1660
|
+
std_error = std_payoff / math.sqrt(len(payoffs))
|
|
1661
|
+
|
|
1662
|
+
return SnowballMCResult(
|
|
1663
|
+
price=price,
|
|
1664
|
+
std_error=std_error,
|
|
1665
|
+
num_paths=len(paths),
|
|
1666
|
+
ko_probability=stats["ko_probability"],
|
|
1667
|
+
v0_probability=stats["v0_probability"],
|
|
1668
|
+
v1_probability=stats["v1_probability"],
|
|
1669
|
+
avg_ko_time=stats.get("avg_ko_time"),
|
|
1670
|
+
)
|
|
1671
|
+
|
|
1672
|
+
def _price_ko_reset_mc_or_qmc(
|
|
1673
|
+
self,
|
|
1674
|
+
product: KnockOutResetSnowballOption,
|
|
1675
|
+
pricing_env: PricingEnvironment,
|
|
1676
|
+
S: float,
|
|
1677
|
+
T: float,
|
|
1678
|
+
r: float,
|
|
1679
|
+
q: float,
|
|
1680
|
+
sigma: float,
|
|
1681
|
+
) -> SnowballMCResult:
|
|
1682
|
+
"""
|
|
1683
|
+
Price KO-reset snowball using normal MC or QMC (non-randomized).
|
|
1684
|
+
"""
|
|
1685
|
+
grid = self._build_time_grid_ko_reset(product, pricing_env, T)
|
|
1686
|
+
generator = self._create_path_generator(S, r, q, sigma, T, grid["dt_array"])
|
|
1687
|
+
|
|
1688
|
+
paths, _ = generator.generate_paths(return_aux=False)
|
|
1689
|
+
payoffs, settlement_times, stats, _ = self._compute_payoffs_ko_reset(
|
|
1690
|
+
product,
|
|
1691
|
+
pricing_env,
|
|
1692
|
+
paths,
|
|
1693
|
+
grid,
|
|
1694
|
+
r,
|
|
1695
|
+
T,
|
|
1696
|
+
sigma,
|
|
1697
|
+
rng_seed=int(self.params.seed) + 1337,
|
|
1698
|
+
)
|
|
1699
|
+
|
|
1700
|
+
discount_factors = np.exp(-r * settlement_times)
|
|
1701
|
+
discounted_payoffs = payoffs * discount_factors
|
|
1702
|
+
|
|
1703
|
+
price = float(discounted_payoffs.mean())
|
|
1704
|
+
std_payoff = float(discounted_payoffs.std(ddof=1))
|
|
1705
|
+
std_error = std_payoff / math.sqrt(len(payoffs)) if len(payoffs) > 0 else 0.0
|
|
1706
|
+
|
|
1707
|
+
return SnowballMCResult(
|
|
1708
|
+
price=price,
|
|
1709
|
+
std_error=std_error,
|
|
1710
|
+
num_paths=len(paths),
|
|
1711
|
+
ko_probability=stats.get("ko_probability", 0.0),
|
|
1712
|
+
v0_probability=stats.get("v0_probability", 0.0),
|
|
1713
|
+
v1_probability=stats.get("v1_probability", 0.0),
|
|
1714
|
+
avg_ko_time=stats.get("avg_ko_time"),
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
def _price_single_batch(
|
|
1718
|
+
self,
|
|
1719
|
+
batch_id: int,
|
|
1720
|
+
batch_num_paths: int,
|
|
1721
|
+
product: SnowballOption,
|
|
1722
|
+
pricing_env: PricingEnvironment,
|
|
1723
|
+
S: float,
|
|
1724
|
+
T: float,
|
|
1725
|
+
r: float,
|
|
1726
|
+
q: float,
|
|
1727
|
+
sigma: float,
|
|
1728
|
+
all_times: np.ndarray,
|
|
1729
|
+
dt_array: np.ndarray,
|
|
1730
|
+
ko_indices: np.ndarray,
|
|
1731
|
+
ki_indices: np.ndarray,
|
|
1732
|
+
) -> Dict[str, float]:
|
|
1733
|
+
"""
|
|
1734
|
+
Price a single batch of paths for parallel processing.
|
|
1735
|
+
"""
|
|
1736
|
+
# Create path generator for this batch
|
|
1737
|
+
generator = self._create_path_generator(
|
|
1738
|
+
S, r, q, sigma, T, dt_array, batch_id=batch_id, num_paths=batch_num_paths
|
|
1739
|
+
)
|
|
1740
|
+
|
|
1741
|
+
# Generate paths
|
|
1742
|
+
paths, _ = generator.generate_paths(return_aux=False, batch_id=batch_id)
|
|
1743
|
+
|
|
1744
|
+
# Compute payoffs
|
|
1745
|
+
payoffs, settlement_times, stats = self._compute_payoffs(
|
|
1746
|
+
product,
|
|
1747
|
+
pricing_env,
|
|
1748
|
+
paths,
|
|
1749
|
+
all_times,
|
|
1750
|
+
ko_indices,
|
|
1751
|
+
ki_indices,
|
|
1752
|
+
r,
|
|
1753
|
+
T,
|
|
1754
|
+
sigma,
|
|
1755
|
+
rng_seed=int(self.params.seed) + 1337 + int(batch_id) * 1000,
|
|
1756
|
+
)
|
|
1757
|
+
|
|
1758
|
+
# Discount payoffs
|
|
1759
|
+
discount_factors = np.exp(-r * settlement_times)
|
|
1760
|
+
discounted_payoffs = payoffs * discount_factors
|
|
1761
|
+
|
|
1762
|
+
discounted_payoffs = np.asarray(discounted_payoffs, dtype=float)
|
|
1763
|
+
n = int(discounted_payoffs.size)
|
|
1764
|
+
sum_x = float(discounted_payoffs.sum())
|
|
1765
|
+
sum_x2 = float(np.square(discounted_payoffs).sum())
|
|
1766
|
+
|
|
1767
|
+
return {
|
|
1768
|
+
"n": n,
|
|
1769
|
+
"sum_x": sum_x,
|
|
1770
|
+
"sum_x2": sum_x2,
|
|
1771
|
+
"ko_count": int(stats.get("ko_count", 0)),
|
|
1772
|
+
"v0_count": int(stats.get("v0_count", 0)),
|
|
1773
|
+
"v1_count": int(stats.get("v1_count", 0)),
|
|
1774
|
+
"ko_time_sum": float(stats.get("ko_time_sum", 0.0)),
|
|
1775
|
+
"ko_time_count": int(stats.get("ko_time_count", 0)),
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
def _price_single_batch_ko_reset(
|
|
1779
|
+
self,
|
|
1780
|
+
batch_id: int,
|
|
1781
|
+
batch_num_paths: int,
|
|
1782
|
+
product: KnockOutResetSnowballOption,
|
|
1783
|
+
pricing_env: PricingEnvironment,
|
|
1784
|
+
S: float,
|
|
1785
|
+
T: float,
|
|
1786
|
+
r: float,
|
|
1787
|
+
q: float,
|
|
1788
|
+
sigma: float,
|
|
1789
|
+
grid: Dict[str, object],
|
|
1790
|
+
) -> Dict[str, float]:
|
|
1791
|
+
"""
|
|
1792
|
+
Price a single batch of paths for KO-reset snowball parallel processing.
|
|
1793
|
+
"""
|
|
1794
|
+
generator = self._create_path_generator(
|
|
1795
|
+
S, r, q, sigma, T, grid["dt_array"], batch_id=batch_id, num_paths=batch_num_paths
|
|
1796
|
+
)
|
|
1797
|
+
|
|
1798
|
+
paths, _ = generator.generate_paths(return_aux=False, batch_id=batch_id)
|
|
1799
|
+
payoffs, settlement_times, stats, _ = self._compute_payoffs_ko_reset(
|
|
1800
|
+
product,
|
|
1801
|
+
pricing_env,
|
|
1802
|
+
paths,
|
|
1803
|
+
grid,
|
|
1804
|
+
r,
|
|
1805
|
+
T,
|
|
1806
|
+
sigma,
|
|
1807
|
+
rng_seed=int(self.params.seed) + 1337 + int(batch_id) * 1000,
|
|
1808
|
+
)
|
|
1809
|
+
|
|
1810
|
+
discount_factors = np.exp(-r * settlement_times)
|
|
1811
|
+
discounted_payoffs = payoffs * discount_factors
|
|
1812
|
+
|
|
1813
|
+
discounted_payoffs = np.asarray(discounted_payoffs, dtype=float)
|
|
1814
|
+
n = int(discounted_payoffs.size)
|
|
1815
|
+
sum_x = float(discounted_payoffs.sum())
|
|
1816
|
+
sum_x2 = float(np.square(discounted_payoffs).sum())
|
|
1817
|
+
|
|
1818
|
+
return {
|
|
1819
|
+
"n": n,
|
|
1820
|
+
"sum_x": sum_x,
|
|
1821
|
+
"sum_x2": sum_x2,
|
|
1822
|
+
"ko_count": int(stats.get("ko_count", 0)),
|
|
1823
|
+
"v0_count": int(stats.get("v0_count", 0)),
|
|
1824
|
+
"v1_count": int(stats.get("v1_count", 0)),
|
|
1825
|
+
"ko_time_sum": float(stats.get("ko_time_sum", 0.0)),
|
|
1826
|
+
"ko_time_count": int(stats.get("ko_time_count", 0)),
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
def _price_parallel(
|
|
1830
|
+
self,
|
|
1831
|
+
product: SnowballOption,
|
|
1832
|
+
pricing_env: PricingEnvironment,
|
|
1833
|
+
S: float,
|
|
1834
|
+
T: float,
|
|
1835
|
+
r: float,
|
|
1836
|
+
q: float,
|
|
1837
|
+
sigma: float,
|
|
1838
|
+
) -> SnowballMCResult:
|
|
1839
|
+
"""
|
|
1840
|
+
Price using Dask parallel batch processing.
|
|
1841
|
+
"""
|
|
1842
|
+
# Build time grid (shared across batches)
|
|
1843
|
+
all_times, dt_array, ko_indices, ki_indices = self._build_time_grid(
|
|
1844
|
+
product, pricing_env, T
|
|
1845
|
+
)
|
|
1846
|
+
|
|
1847
|
+
if self.num_batches <= 0:
|
|
1848
|
+
raise ValidationError(
|
|
1849
|
+
f"num_batches must be positive, got {self.num_batches}"
|
|
1850
|
+
)
|
|
1851
|
+
|
|
1852
|
+
# Split total paths across batches so parallel mode matches serial mode
|
|
1853
|
+
total_paths_target = int(self.params.num_paths)
|
|
1854
|
+
base = total_paths_target // self.num_batches
|
|
1855
|
+
remainder = total_paths_target % self.num_batches
|
|
1856
|
+
batch_sizes = [
|
|
1857
|
+
(base + 1 if i < remainder else base) for i in range(self.num_batches)
|
|
1858
|
+
]
|
|
1859
|
+
|
|
1860
|
+
# Create delayed tasks for non-empty batches
|
|
1861
|
+
batch_results = []
|
|
1862
|
+
batches_used = 0
|
|
1863
|
+
for batch_id, batch_num_paths in enumerate(batch_sizes):
|
|
1864
|
+
if batch_num_paths <= 0:
|
|
1865
|
+
continue
|
|
1866
|
+
batches_used += 1
|
|
1867
|
+
batch_results.append(
|
|
1868
|
+
delayed(self._price_single_batch)(
|
|
1869
|
+
batch_id=batch_id,
|
|
1870
|
+
batch_num_paths=batch_num_paths,
|
|
1871
|
+
product=product,
|
|
1872
|
+
pricing_env=pricing_env,
|
|
1873
|
+
S=S,
|
|
1874
|
+
T=T,
|
|
1875
|
+
r=r,
|
|
1876
|
+
q=q,
|
|
1877
|
+
sigma=sigma,
|
|
1878
|
+
all_times=all_times,
|
|
1879
|
+
dt_array=dt_array,
|
|
1880
|
+
ko_indices=ko_indices,
|
|
1881
|
+
ki_indices=ki_indices,
|
|
1882
|
+
)
|
|
1883
|
+
)
|
|
1884
|
+
|
|
1885
|
+
# Compute all batches in parallel
|
|
1886
|
+
results = compute(*batch_results)
|
|
1887
|
+
|
|
1888
|
+
total_n = 0
|
|
1889
|
+
total_sum_x = 0.0
|
|
1890
|
+
total_sum_x2 = 0.0
|
|
1891
|
+
total_ko_count = 0
|
|
1892
|
+
total_v0_count = 0
|
|
1893
|
+
total_v1_count = 0
|
|
1894
|
+
total_ko_time_sum = 0.0
|
|
1895
|
+
total_ko_time_count = 0
|
|
1896
|
+
|
|
1897
|
+
for res in results:
|
|
1898
|
+
total_n += int(res["n"])
|
|
1899
|
+
total_sum_x += float(res["sum_x"])
|
|
1900
|
+
total_sum_x2 += float(res["sum_x2"])
|
|
1901
|
+
total_ko_count += int(res.get("ko_count", 0))
|
|
1902
|
+
total_v0_count += int(res.get("v0_count", 0))
|
|
1903
|
+
total_v1_count += int(res.get("v1_count", 0))
|
|
1904
|
+
total_ko_time_sum += float(res.get("ko_time_sum", 0.0))
|
|
1905
|
+
total_ko_time_count += int(res.get("ko_time_count", 0))
|
|
1906
|
+
|
|
1907
|
+
if total_n <= 0:
|
|
1908
|
+
raise PricingError("Dask parallel pricing produced zero simulated paths")
|
|
1909
|
+
|
|
1910
|
+
price = total_sum_x / total_n
|
|
1911
|
+
|
|
1912
|
+
if total_n > 1:
|
|
1913
|
+
sample_var = (total_sum_x2 - (total_sum_x * total_sum_x) / total_n) / (
|
|
1914
|
+
total_n - 1
|
|
1915
|
+
)
|
|
1916
|
+
sample_var = max(sample_var, 0.0)
|
|
1917
|
+
std_error = math.sqrt(sample_var) / math.sqrt(total_n)
|
|
1918
|
+
else:
|
|
1919
|
+
std_error = 0.0
|
|
1920
|
+
|
|
1921
|
+
ko_probability = float(total_ko_count / total_n)
|
|
1922
|
+
v0_probability = float(total_v0_count / total_n)
|
|
1923
|
+
v1_probability = float(total_v1_count / total_n)
|
|
1924
|
+
|
|
1925
|
+
avg_ko_time: Optional[float]
|
|
1926
|
+
if total_ko_time_count > 0:
|
|
1927
|
+
avg_ko_time = float(total_ko_time_sum / total_ko_time_count)
|
|
1928
|
+
else:
|
|
1929
|
+
avg_ko_time = None
|
|
1930
|
+
|
|
1931
|
+
return SnowballMCResult(
|
|
1932
|
+
price=float(price),
|
|
1933
|
+
std_error=float(std_error),
|
|
1934
|
+
num_paths=int(total_n),
|
|
1935
|
+
ko_probability=ko_probability,
|
|
1936
|
+
v0_probability=v0_probability,
|
|
1937
|
+
v1_probability=v1_probability,
|
|
1938
|
+
avg_ko_time=avg_ko_time,
|
|
1939
|
+
batches_used=batches_used,
|
|
1940
|
+
)
|
|
1941
|
+
|
|
1942
|
+
def _price_ko_reset_parallel(
|
|
1943
|
+
self,
|
|
1944
|
+
product: KnockOutResetSnowballOption,
|
|
1945
|
+
pricing_env: PricingEnvironment,
|
|
1946
|
+
S: float,
|
|
1947
|
+
T: float,
|
|
1948
|
+
r: float,
|
|
1949
|
+
q: float,
|
|
1950
|
+
sigma: float,
|
|
1951
|
+
) -> SnowballMCResult:
|
|
1952
|
+
"""
|
|
1953
|
+
Price KO-reset snowball using Dask parallel batch processing.
|
|
1954
|
+
"""
|
|
1955
|
+
grid = self._build_time_grid_ko_reset(product, pricing_env, T)
|
|
1956
|
+
|
|
1957
|
+
if self.num_batches <= 0:
|
|
1958
|
+
raise ValidationError(
|
|
1959
|
+
f"num_batches must be positive, got {self.num_batches}"
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1962
|
+
total_paths_target = int(self.params.num_paths)
|
|
1963
|
+
base = total_paths_target // self.num_batches
|
|
1964
|
+
remainder = total_paths_target % self.num_batches
|
|
1965
|
+
batch_sizes = [
|
|
1966
|
+
(base + 1 if i < remainder else base) for i in range(self.num_batches)
|
|
1967
|
+
]
|
|
1968
|
+
|
|
1969
|
+
batch_results = []
|
|
1970
|
+
batches_used = 0
|
|
1971
|
+
for batch_id, batch_num_paths in enumerate(batch_sizes):
|
|
1972
|
+
if batch_num_paths <= 0:
|
|
1973
|
+
continue
|
|
1974
|
+
batches_used += 1
|
|
1975
|
+
batch_results.append(
|
|
1976
|
+
delayed(self._price_single_batch_ko_reset)(
|
|
1977
|
+
batch_id=batch_id,
|
|
1978
|
+
batch_num_paths=batch_num_paths,
|
|
1979
|
+
product=product,
|
|
1980
|
+
pricing_env=pricing_env,
|
|
1981
|
+
S=S,
|
|
1982
|
+
T=T,
|
|
1983
|
+
r=r,
|
|
1984
|
+
q=q,
|
|
1985
|
+
sigma=sigma,
|
|
1986
|
+
grid=grid,
|
|
1987
|
+
)
|
|
1988
|
+
)
|
|
1989
|
+
|
|
1990
|
+
results = compute(*batch_results)
|
|
1991
|
+
|
|
1992
|
+
total_n = 0
|
|
1993
|
+
total_sum_x = 0.0
|
|
1994
|
+
total_sum_x2 = 0.0
|
|
1995
|
+
total_ko_count = 0
|
|
1996
|
+
total_v0_count = 0
|
|
1997
|
+
total_v1_count = 0
|
|
1998
|
+
total_ko_time_sum = 0.0
|
|
1999
|
+
total_ko_time_count = 0
|
|
2000
|
+
|
|
2001
|
+
for res in results:
|
|
2002
|
+
total_n += int(res["n"])
|
|
2003
|
+
total_sum_x += float(res["sum_x"])
|
|
2004
|
+
total_sum_x2 += float(res["sum_x2"])
|
|
2005
|
+
total_ko_count += int(res.get("ko_count", 0))
|
|
2006
|
+
total_v0_count += int(res.get("v0_count", 0))
|
|
2007
|
+
total_v1_count += int(res.get("v1_count", 0))
|
|
2008
|
+
total_ko_time_sum += float(res.get("ko_time_sum", 0.0))
|
|
2009
|
+
total_ko_time_count += int(res.get("ko_time_count", 0))
|
|
2010
|
+
|
|
2011
|
+
if total_n <= 0:
|
|
2012
|
+
raise PricingError("Dask parallel pricing produced zero simulated paths")
|
|
2013
|
+
|
|
2014
|
+
price = total_sum_x / total_n
|
|
2015
|
+
|
|
2016
|
+
if total_n > 1:
|
|
2017
|
+
sample_var = (total_sum_x2 - (total_sum_x * total_sum_x) / total_n) / (
|
|
2018
|
+
total_n - 1
|
|
2019
|
+
)
|
|
2020
|
+
sample_var = max(sample_var, 0.0)
|
|
2021
|
+
std_error = math.sqrt(sample_var) / math.sqrt(total_n)
|
|
2022
|
+
else:
|
|
2023
|
+
std_error = 0.0
|
|
2024
|
+
|
|
2025
|
+
ko_probability = float(total_ko_count / total_n)
|
|
2026
|
+
v0_probability = float(total_v0_count / total_n)
|
|
2027
|
+
v1_probability = float(total_v1_count / total_n)
|
|
2028
|
+
|
|
2029
|
+
avg_ko_time: Optional[float]
|
|
2030
|
+
if total_ko_time_count > 0:
|
|
2031
|
+
avg_ko_time = float(total_ko_time_sum / total_ko_time_count)
|
|
2032
|
+
else:
|
|
2033
|
+
avg_ko_time = None
|
|
2034
|
+
|
|
2035
|
+
return SnowballMCResult(
|
|
2036
|
+
price=float(price),
|
|
2037
|
+
std_error=float(std_error),
|
|
2038
|
+
num_paths=int(total_n),
|
|
2039
|
+
ko_probability=ko_probability,
|
|
2040
|
+
v0_probability=v0_probability,
|
|
2041
|
+
v1_probability=v1_probability,
|
|
2042
|
+
avg_ko_time=avg_ko_time,
|
|
2043
|
+
batches_used=batches_used,
|
|
2044
|
+
)
|
|
2045
|
+
|
|
2046
|
+
def _price_rqmc(
|
|
2047
|
+
self,
|
|
2048
|
+
product: SnowballOption,
|
|
2049
|
+
pricing_env: PricingEnvironment,
|
|
2050
|
+
S: float,
|
|
2051
|
+
T: float,
|
|
2052
|
+
r: float,
|
|
2053
|
+
q: float,
|
|
2054
|
+
sigma: float,
|
|
2055
|
+
) -> SnowballMCResult:
|
|
2056
|
+
"""
|
|
2057
|
+
Price using Randomized QMC with adaptive batching.
|
|
2058
|
+
"""
|
|
2059
|
+
# Build time grid
|
|
2060
|
+
all_times, dt_array, ko_indices, ki_indices = self._build_time_grid(
|
|
2061
|
+
product, pricing_env, T
|
|
2062
|
+
)
|
|
2063
|
+
|
|
2064
|
+
def pricer_fn(paths, aux):
|
|
2065
|
+
"""Pricer function for RQMC driver."""
|
|
2066
|
+
batch_id = 0
|
|
2067
|
+
if aux is not None and "batch_id" in aux:
|
|
2068
|
+
batch_id = int(aux["batch_id"])
|
|
2069
|
+
payoffs, settlement_times, _ = self._compute_payoffs(
|
|
2070
|
+
product,
|
|
2071
|
+
pricing_env,
|
|
2072
|
+
paths,
|
|
2073
|
+
all_times,
|
|
2074
|
+
ko_indices,
|
|
2075
|
+
ki_indices,
|
|
2076
|
+
r,
|
|
2077
|
+
T,
|
|
2078
|
+
sigma,
|
|
2079
|
+
rng_seed=int(self.params.seed) + 1337 + batch_id * 1000,
|
|
2080
|
+
)
|
|
2081
|
+
discount_factors = np.exp(-r * settlement_times)
|
|
2082
|
+
return payoffs * discount_factors
|
|
2083
|
+
|
|
2084
|
+
params = self.params
|
|
2085
|
+
max_batches = getattr(
|
|
2086
|
+
params, "rqmc_max_batches", getattr(params, "max_batches", 32)
|
|
2087
|
+
)
|
|
2088
|
+
min_batches = getattr(
|
|
2089
|
+
params, "rqmc_min_batches", getattr(params, "min_batches", 4)
|
|
2090
|
+
)
|
|
2091
|
+
if hasattr(params, "resolve_rqmc_target_std"):
|
|
2092
|
+
target_std = params.resolve_rqmc_target_std(
|
|
2093
|
+
product=product, pricing_env=pricing_env
|
|
2094
|
+
)
|
|
2095
|
+
else:
|
|
2096
|
+
target_std = getattr(params, "target_std", 1e-4)
|
|
2097
|
+
if hasattr(params, "resolve_rqmc_paths_per_batch"):
|
|
2098
|
+
per_batch_paths = params.resolve_rqmc_paths_per_batch(
|
|
2099
|
+
max_batches=max_batches
|
|
2100
|
+
)
|
|
2101
|
+
else:
|
|
2102
|
+
per_batch_paths = params.num_paths
|
|
2103
|
+
|
|
2104
|
+
# Create path generator (will be used with different batch_ids)
|
|
2105
|
+
generator = self._create_path_generator(
|
|
2106
|
+
S, r, q, sigma, T, dt_array, num_paths=per_batch_paths
|
|
2107
|
+
)
|
|
2108
|
+
|
|
2109
|
+
result = run_rqmc(
|
|
2110
|
+
pricer_fn=pricer_fn,
|
|
2111
|
+
path_generator=generator,
|
|
2112
|
+
max_batches=max_batches,
|
|
2113
|
+
target_std=target_std,
|
|
2114
|
+
min_batches=min_batches,
|
|
2115
|
+
)
|
|
2116
|
+
|
|
2117
|
+
# Run one more batch to get statistics
|
|
2118
|
+
paths, _ = generator.generate_paths(return_aux=False, batch_id=0)
|
|
2119
|
+
_, _, stats = self._compute_payoffs(
|
|
2120
|
+
product,
|
|
2121
|
+
pricing_env,
|
|
2122
|
+
paths,
|
|
2123
|
+
all_times,
|
|
2124
|
+
ko_indices,
|
|
2125
|
+
ki_indices,
|
|
2126
|
+
r,
|
|
2127
|
+
T,
|
|
2128
|
+
sigma,
|
|
2129
|
+
rng_seed=int(self.params.seed) + 1337,
|
|
2130
|
+
)
|
|
2131
|
+
|
|
2132
|
+
return SnowballMCResult(
|
|
2133
|
+
price=result.price,
|
|
2134
|
+
std_error=result.std_error,
|
|
2135
|
+
num_paths=result.total_paths,
|
|
2136
|
+
ko_probability=stats["ko_probability"],
|
|
2137
|
+
v0_probability=stats["v0_probability"],
|
|
2138
|
+
v1_probability=stats["v1_probability"],
|
|
2139
|
+
batches_used=result.batches_used,
|
|
2140
|
+
)
|
|
2141
|
+
|
|
2142
|
+
def _price_ko_reset_rqmc(
|
|
2143
|
+
self,
|
|
2144
|
+
product: KnockOutResetSnowballOption,
|
|
2145
|
+
pricing_env: PricingEnvironment,
|
|
2146
|
+
S: float,
|
|
2147
|
+
T: float,
|
|
2148
|
+
r: float,
|
|
2149
|
+
q: float,
|
|
2150
|
+
sigma: float,
|
|
2151
|
+
) -> SnowballMCResult:
|
|
2152
|
+
"""
|
|
2153
|
+
Price KO-reset snowball using Randomized QMC with adaptive batching.
|
|
2154
|
+
"""
|
|
2155
|
+
grid = self._build_time_grid_ko_reset(product, pricing_env, T)
|
|
2156
|
+
|
|
2157
|
+
def pricer_fn(paths, aux):
|
|
2158
|
+
batch_id = 0
|
|
2159
|
+
if aux is not None and "batch_id" in aux:
|
|
2160
|
+
batch_id = int(aux["batch_id"])
|
|
2161
|
+
payoffs, settlement_times, _stats, _ = self._compute_payoffs_ko_reset(
|
|
2162
|
+
product,
|
|
2163
|
+
pricing_env,
|
|
2164
|
+
paths,
|
|
2165
|
+
grid,
|
|
2166
|
+
r,
|
|
2167
|
+
T,
|
|
2168
|
+
sigma,
|
|
2169
|
+
rng_seed=int(self.params.seed) + 1337 + batch_id * 1000,
|
|
2170
|
+
)
|
|
2171
|
+
discount_factors = np.exp(-r * settlement_times)
|
|
2172
|
+
return payoffs * discount_factors
|
|
2173
|
+
|
|
2174
|
+
params = self.params
|
|
2175
|
+
max_batches = getattr(
|
|
2176
|
+
params, "rqmc_max_batches", getattr(params, "max_batches", 32)
|
|
2177
|
+
)
|
|
2178
|
+
min_batches = getattr(
|
|
2179
|
+
params, "rqmc_min_batches", getattr(params, "min_batches", 4)
|
|
2180
|
+
)
|
|
2181
|
+
if hasattr(params, "resolve_rqmc_target_std"):
|
|
2182
|
+
target_std = params.resolve_rqmc_target_std(
|
|
2183
|
+
product=product, pricing_env=pricing_env
|
|
2184
|
+
)
|
|
2185
|
+
else:
|
|
2186
|
+
target_std = getattr(params, "target_std", 1e-4)
|
|
2187
|
+
if hasattr(params, "resolve_rqmc_paths_per_batch"):
|
|
2188
|
+
per_batch_paths = params.resolve_rqmc_paths_per_batch(
|
|
2189
|
+
max_batches=max_batches
|
|
2190
|
+
)
|
|
2191
|
+
else:
|
|
2192
|
+
per_batch_paths = params.num_paths
|
|
2193
|
+
|
|
2194
|
+
generator = self._create_path_generator(
|
|
2195
|
+
S, r, q, sigma, T, grid["dt_array"], num_paths=per_batch_paths
|
|
2196
|
+
)
|
|
2197
|
+
|
|
2198
|
+
result = run_rqmc(
|
|
2199
|
+
pricer_fn=pricer_fn,
|
|
2200
|
+
path_generator=generator,
|
|
2201
|
+
max_batches=max_batches,
|
|
2202
|
+
target_std=target_std,
|
|
2203
|
+
min_batches=min_batches,
|
|
2204
|
+
)
|
|
2205
|
+
|
|
2206
|
+
paths, _ = generator.generate_paths(return_aux=False, batch_id=0)
|
|
2207
|
+
_, _, stats, _ = self._compute_payoffs_ko_reset(
|
|
2208
|
+
product,
|
|
2209
|
+
pricing_env,
|
|
2210
|
+
paths,
|
|
2211
|
+
grid,
|
|
2212
|
+
r,
|
|
2213
|
+
T,
|
|
2214
|
+
sigma,
|
|
2215
|
+
rng_seed=int(self.params.seed) + 1337,
|
|
2216
|
+
)
|
|
2217
|
+
|
|
2218
|
+
return SnowballMCResult(
|
|
2219
|
+
price=result.price,
|
|
2220
|
+
std_error=result.std_error,
|
|
2221
|
+
num_paths=result.total_paths,
|
|
2222
|
+
ko_probability=stats.get("ko_probability", 0.0),
|
|
2223
|
+
v0_probability=stats.get("v0_probability", 0.0),
|
|
2224
|
+
v1_probability=stats.get("v1_probability", 0.0),
|
|
2225
|
+
batches_used=result.batches_used,
|
|
2226
|
+
)
|
|
2227
|
+
|
|
2228
|
+
def get_last_result(self) -> Optional[SnowballMCResult]:
|
|
2229
|
+
"""
|
|
2230
|
+
Get the full result from the last pricing run.
|
|
2231
|
+
|
|
2232
|
+
Returns:
|
|
2233
|
+
SnowballMCResult object, or None if no pricing has been performed
|
|
2234
|
+
"""
|
|
2235
|
+
return self._last_result
|
|
2236
|
+
|
|
2237
|
+
def get_last_std_error(self) -> Optional[float]:
|
|
2238
|
+
"""
|
|
2239
|
+
Get the standard error from the last pricing run.
|
|
2240
|
+
|
|
2241
|
+
Returns:
|
|
2242
|
+
Standard error, or None if no pricing has been performed
|
|
2243
|
+
"""
|
|
2244
|
+
if self._last_result is None:
|
|
2245
|
+
return None
|
|
2246
|
+
return self._last_result.std_error
|
|
2247
|
+
|
|
2248
|
+
def __repr__(self):
|
|
2249
|
+
dask_str = f", use_dask={self.use_dask}" if self.use_dask else ""
|
|
2250
|
+
return f"SnowballMCEngine(method={self.method.name}{dask_str})"
|