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,1810 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PDE solver for Snowball (autocallable) options using the Two-Surface method.
|
|
3
|
+
|
|
4
|
+
This solver maintains two value surfaces:
|
|
5
|
+
- V0: Value when knock-in (KI) has NOT occurred
|
|
6
|
+
- V1: Value when knock-in (KI) HAS occurred
|
|
7
|
+
|
|
8
|
+
The surfaces interact at barrier observation times:
|
|
9
|
+
- KO barrier hit: Both surfaces jump to KO payoff (product terminates)
|
|
10
|
+
- KI barrier hit: V0 transitions to V1 (V0 <- V1)
|
|
11
|
+
|
|
12
|
+
For detailed design, see: asset/equity/engine/docs/snowball_pde_engine.md
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections import OrderedDict
|
|
16
|
+
from time import perf_counter
|
|
17
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import scipy.sparse as sp
|
|
21
|
+
import scipy.sparse.linalg as spla
|
|
22
|
+
from scipy.linalg import solve_banded
|
|
23
|
+
|
|
24
|
+
from quantark.asset.equity.engine.pde.base_pde_solver import BasePDESolver, PDESolutionResult
|
|
25
|
+
from quantark.asset.equity.engine.event_stats import AutocallableEventStats
|
|
26
|
+
from quantark.asset.equity.param import PDEParams
|
|
27
|
+
from quantark.asset.equity.product.base_equity_product import BaseEquityProduct
|
|
28
|
+
from quantark.asset.equity.product.option.observation_schedule import ResolvedObservationRecord
|
|
29
|
+
from quantark.asset.equity.product.option.snowball_option import SnowballOption
|
|
30
|
+
from quantark.priceenv import PricingEnvironment
|
|
31
|
+
from quantark.util.enum import ObservationType, ProtectionType
|
|
32
|
+
from quantark.util.exceptions import PricingError, ValidationError
|
|
33
|
+
from quantark.util.numerical import Tolerance, is_close, is_zero, safe_divide
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SnowballPDESolver(BasePDESolver):
|
|
37
|
+
"""
|
|
38
|
+
PDE solver for Snowball (autocallable) options using the Two-Surface method.
|
|
39
|
+
|
|
40
|
+
Maintains two price grids to track the knock-in state:
|
|
41
|
+
- grid_v0: Value surface for "not knocked-in" state (receives rebate at maturity)
|
|
42
|
+
- grid_v1: Value surface for "knocked-in" state (has downside exposure)
|
|
43
|
+
|
|
44
|
+
The solver handles:
|
|
45
|
+
- Discrete KO observations with time-varying barriers and rates
|
|
46
|
+
- Continuous or discrete KI monitoring
|
|
47
|
+
- INSTANT or EXPIRY coupon payment timing
|
|
48
|
+
- Standard and reverse snowball structures
|
|
49
|
+
- Airbag and protection features (via product payoff methods)
|
|
50
|
+
|
|
51
|
+
Algorithm:
|
|
52
|
+
1. Initialize both grids with terminal conditions at maturity
|
|
53
|
+
2. Step backward in time using Crank-Nicolson
|
|
54
|
+
3. At KO observation times: apply KO payoff to breached regions
|
|
55
|
+
4. At KI observation times (or every step for continuous): V0 <- V1
|
|
56
|
+
5. Interpolate final price from V0 (or V1 if already knocked-in)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# Subclasses can override this to specify their supported product type
|
|
60
|
+
_supported_product_type: type = SnowballOption
|
|
61
|
+
_solver_name: str = "SnowballPDESolver"
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self, params: Optional[PDEParams] = None, enable_profiling: bool = False
|
|
65
|
+
):
|
|
66
|
+
"""
|
|
67
|
+
Initialize Snowball PDE solver.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
params: PDE engine configuration parameters
|
|
71
|
+
enable_profiling: Enable timing breakdown for matrix, RHS, solve, barrier
|
|
72
|
+
"""
|
|
73
|
+
super().__init__(params)
|
|
74
|
+
|
|
75
|
+
# Two-surface grids
|
|
76
|
+
self._grid_v0: Optional[np.ndarray] = None
|
|
77
|
+
self._grid_v1: Optional[np.ndarray] = None
|
|
78
|
+
|
|
79
|
+
# KO observation tracking
|
|
80
|
+
self._ko_observation_indices: Dict[int, ResolvedObservationRecord] = {}
|
|
81
|
+
self._ko_terminal_record: Optional[ResolvedObservationRecord] = None
|
|
82
|
+
self._has_terminal_ko: bool = False
|
|
83
|
+
|
|
84
|
+
# KI observation tracking
|
|
85
|
+
self._ki_observation_indices: Set[int] = set()
|
|
86
|
+
self._ki_barrier_by_tidx: Dict[int, float] = {}
|
|
87
|
+
self._ki_continuous: bool = False
|
|
88
|
+
self._ki_barrier: float = 0.0
|
|
89
|
+
self._is_reverse: bool = False
|
|
90
|
+
|
|
91
|
+
# Time tracking
|
|
92
|
+
self._total_tau: float = 0.0
|
|
93
|
+
self._banded_cache: "OrderedDict[Tuple[float, float], Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]" = OrderedDict()
|
|
94
|
+
self._banded_cache_max_entries = self.params.banded_cache_max_entries
|
|
95
|
+
self._profile_enabled = enable_profiling
|
|
96
|
+
self._profile_stats: Dict[str, float] = {}
|
|
97
|
+
self._ko_records_cache: "OrderedDict[Tuple, List[ResolvedObservationRecord]]" = OrderedDict()
|
|
98
|
+
self._ki_profile_cache: "OrderedDict[Tuple, Dict[str, List[Optional[float]]]]" = OrderedDict()
|
|
99
|
+
|
|
100
|
+
def enable_profiling(self, enabled: bool = True) -> None:
|
|
101
|
+
"""Toggle internal timing breakdown collection."""
|
|
102
|
+
self._profile_enabled = enabled
|
|
103
|
+
|
|
104
|
+
def get_profile_stats(self) -> Dict[str, float]:
|
|
105
|
+
"""Return timing breakdown from the most recent solve."""
|
|
106
|
+
return dict(self._profile_stats)
|
|
107
|
+
|
|
108
|
+
def _reset_profile_stats(self) -> None:
|
|
109
|
+
self._profile_stats = {
|
|
110
|
+
"grid_build": 0.0,
|
|
111
|
+
"boundary": 0.0,
|
|
112
|
+
"matrix_build": 0.0,
|
|
113
|
+
"rhs": 0.0,
|
|
114
|
+
"solve": 0.0,
|
|
115
|
+
"barrier": 0.0,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def _solve(
|
|
119
|
+
self, product: BaseEquityProduct, pricing_env: PricingEnvironment
|
|
120
|
+
) -> PDESolutionResult:
|
|
121
|
+
"""
|
|
122
|
+
Core Two-Surface PDE solving logic for Snowball options.
|
|
123
|
+
|
|
124
|
+
This method contains the common solving logic shared by price() and
|
|
125
|
+
calculate_greeks(). It handles the two-surface approach with V0/V1
|
|
126
|
+
state transitions.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
product: SnowballOption to price (already validated)
|
|
130
|
+
pricing_env: Pricing environment with market data
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
PDESolutionResult with appropriate surface (V0 or V1) at t=0
|
|
134
|
+
|
|
135
|
+
Note:
|
|
136
|
+
This method assumes the product has been validated and is not
|
|
137
|
+
expired or knocked out at valuation. Callers should check these
|
|
138
|
+
conditions before calling _solve().
|
|
139
|
+
"""
|
|
140
|
+
spot = pricing_env.spot
|
|
141
|
+
tau = product.get_maturity(pricing_env)
|
|
142
|
+
|
|
143
|
+
# Determine knocked-in state at valuation
|
|
144
|
+
ki_continuous = (
|
|
145
|
+
product.barrier_config.ki_continuous
|
|
146
|
+
or product.barrier_config.ki_observation_type == ObservationType.CONTINUOUS
|
|
147
|
+
)
|
|
148
|
+
knocked_in_at_valuation = self._is_knocked_in_at_valuation(
|
|
149
|
+
product, spot, pricing_env, ki_continuous=ki_continuous
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Store the state for potential use in calculate_greeks
|
|
153
|
+
self._knocked_in_at_valuation = knocked_in_at_valuation
|
|
154
|
+
|
|
155
|
+
# Extract market data
|
|
156
|
+
strike = product.strike
|
|
157
|
+
r = pricing_env.get_rate(tau)
|
|
158
|
+
q = pricing_env.get_div_yield(tau)
|
|
159
|
+
sigma = pricing_env.get_vol(strike, tau)
|
|
160
|
+
|
|
161
|
+
# Store product properties for later use
|
|
162
|
+
self._is_reverse = product.is_reverse
|
|
163
|
+
self._ki_continuous = ki_continuous
|
|
164
|
+
if product.has_ki_barrier:
|
|
165
|
+
ki_barrier = product.barrier_config.ki_barrier
|
|
166
|
+
if isinstance(ki_barrier, list):
|
|
167
|
+
self._ki_barrier = ki_barrier[0]
|
|
168
|
+
else:
|
|
169
|
+
self._ki_barrier = ki_barrier
|
|
170
|
+
|
|
171
|
+
if self._profile_enabled:
|
|
172
|
+
self._reset_profile_stats()
|
|
173
|
+
|
|
174
|
+
# Build grids
|
|
175
|
+
if self._profile_enabled:
|
|
176
|
+
t0 = perf_counter()
|
|
177
|
+
x_vec, s_vec, dx_vec, t_vec, dt_vec = self._build_grids(
|
|
178
|
+
product, pricing_env, spot, sigma, tau, r, q
|
|
179
|
+
)
|
|
180
|
+
if self._profile_enabled:
|
|
181
|
+
self._profile_stats["grid_build"] += perf_counter() - t0
|
|
182
|
+
|
|
183
|
+
# Initialize both grids
|
|
184
|
+
num_x, num_t = len(x_vec), len(t_vec)
|
|
185
|
+
self._grid_v0 = np.zeros((num_x, num_t))
|
|
186
|
+
self._grid_v1 = np.zeros((num_x, num_t))
|
|
187
|
+
|
|
188
|
+
# Set terminal conditions
|
|
189
|
+
self._set_terminal_condition_v0(
|
|
190
|
+
self._grid_v0, x_vec, s_vec, product, pricing_env
|
|
191
|
+
)
|
|
192
|
+
self._set_terminal_condition_v1(
|
|
193
|
+
self._grid_v1, x_vec, s_vec, product, pricing_env
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Apply terminal KO if at maturity observation
|
|
197
|
+
if self._has_terminal_ko and self._ko_terminal_record is not None:
|
|
198
|
+
self._apply_terminal_ko(
|
|
199
|
+
self._grid_v0,
|
|
200
|
+
self._grid_v1,
|
|
201
|
+
s_vec,
|
|
202
|
+
product,
|
|
203
|
+
pricing_env,
|
|
204
|
+
self._ko_terminal_record,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Apply terminal KI if at maturity observation (European KI fix)
|
|
208
|
+
if product.has_ki_barrier:
|
|
209
|
+
is_terminal_ki = self._ki_continuous
|
|
210
|
+
if not is_terminal_ki:
|
|
211
|
+
if (num_t - 1) in self._ki_observation_indices:
|
|
212
|
+
is_terminal_ki = True
|
|
213
|
+
if is_terminal_ki:
|
|
214
|
+
self._apply_ki_jump(
|
|
215
|
+
self._grid_v0, self._grid_v1, s_vec, num_t - 1, product
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Build operator matrices
|
|
219
|
+
l, c, u = self._calculate_coefficients(r, q, sigma, dx_vec, num_x)
|
|
220
|
+
A = self._build_operator_matrix(l, c, u, num_x)
|
|
221
|
+
|
|
222
|
+
# Time stepping for both surfaces
|
|
223
|
+
self._time_stepping_two_surface(
|
|
224
|
+
self._grid_v0,
|
|
225
|
+
self._grid_v1,
|
|
226
|
+
A,
|
|
227
|
+
l,
|
|
228
|
+
c,
|
|
229
|
+
u,
|
|
230
|
+
x_vec,
|
|
231
|
+
s_vec,
|
|
232
|
+
t_vec,
|
|
233
|
+
dt_vec,
|
|
234
|
+
product,
|
|
235
|
+
pricing_env,
|
|
236
|
+
r,
|
|
237
|
+
q,
|
|
238
|
+
sigma,
|
|
239
|
+
tau,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Return appropriate surface based on knocked-in state
|
|
243
|
+
spot_log = np.log(spot)
|
|
244
|
+
if knocked_in_at_valuation:
|
|
245
|
+
solution_vec = self._grid_v1[:, 0]
|
|
246
|
+
else:
|
|
247
|
+
solution_vec = self._grid_v0[:, 0]
|
|
248
|
+
|
|
249
|
+
return PDESolutionResult(
|
|
250
|
+
solution_vec=solution_vec,
|
|
251
|
+
x_vec=x_vec,
|
|
252
|
+
s_vec=s_vec,
|
|
253
|
+
spot_log=spot_log,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _check_product_type(self, product: BaseEquityProduct) -> None:
|
|
257
|
+
"""
|
|
258
|
+
Check that the product is of the supported type for this solver.
|
|
259
|
+
|
|
260
|
+
Subclasses can override _supported_product_type and _solver_name class
|
|
261
|
+
attributes to customize the type check.
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
PricingError: If product is not of the supported type
|
|
265
|
+
"""
|
|
266
|
+
if not isinstance(product, self._supported_product_type):
|
|
267
|
+
raise PricingError(
|
|
268
|
+
f"{self._solver_name} only supports {self._supported_product_type.__name__}, "
|
|
269
|
+
f"got {type(product).__name__}"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def price(
|
|
273
|
+
self, product: BaseEquityProduct, pricing_env: PricingEnvironment
|
|
274
|
+
) -> float:
|
|
275
|
+
"""
|
|
276
|
+
Price a Snowball option using the Two-Surface PDE method.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
product: SnowballOption to price
|
|
280
|
+
pricing_env: Pricing environment with market data
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Option price
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
PricingError: If product is not a SnowballOption
|
|
287
|
+
ValidationError: If product configuration is incompatible with PDE
|
|
288
|
+
"""
|
|
289
|
+
self._check_product_type(product)
|
|
290
|
+
|
|
291
|
+
if pricing_env is None:
|
|
292
|
+
raise ValidationError(
|
|
293
|
+
f"PricingEnvironment is required for {self._solver_name}"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Validate PDE compatibility
|
|
297
|
+
self._validate_product(product)
|
|
298
|
+
|
|
299
|
+
spot = pricing_env.spot
|
|
300
|
+
tau = product.get_maturity(pricing_env)
|
|
301
|
+
|
|
302
|
+
if tau <= 0 or is_zero(tau):
|
|
303
|
+
# Expired: return terminal payoff
|
|
304
|
+
return self._calculate_terminal_value(product, spot, pricing_env)
|
|
305
|
+
|
|
306
|
+
# Check if knocked out at valuation
|
|
307
|
+
knocked_out_at_valuation = self._is_knocked_out_at_valuation(
|
|
308
|
+
product, spot, pricing_env
|
|
309
|
+
)
|
|
310
|
+
if knocked_out_at_valuation:
|
|
311
|
+
return self._get_immediate_ko_payoff(product, pricing_env)
|
|
312
|
+
|
|
313
|
+
# Solve PDE and interpolate price
|
|
314
|
+
result = self._solve(product, pricing_env)
|
|
315
|
+
return self._interpolate_price(result.solution_vec, result.x_vec, result.spot_log)
|
|
316
|
+
|
|
317
|
+
def calculate_event_stats(
|
|
318
|
+
self, product: BaseEquityProduct, pricing_env: PricingEnvironment
|
|
319
|
+
) -> Optional[AutocallableEventStats]:
|
|
320
|
+
"""
|
|
321
|
+
Provide per-observation KO probabilities and expected discounted cashflows.
|
|
322
|
+
|
|
323
|
+
Native PDE implementation:
|
|
324
|
+
- Propagates stacked indicator surfaces through the same backward PDE stepping.
|
|
325
|
+
- Applies KO/KI jumps to all indicator surfaces at observation times.
|
|
326
|
+
- Returns KO per-observation probabilities (by dividing discounted indicators by
|
|
327
|
+
discount factors) and expected discounted KO cashflows.
|
|
328
|
+
"""
|
|
329
|
+
if not isinstance(product, SnowballOption):
|
|
330
|
+
return None
|
|
331
|
+
if pricing_env is None:
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
spot = pricing_env.spot
|
|
335
|
+
tau = product.get_maturity(pricing_env)
|
|
336
|
+
if tau <= 0 or is_zero(tau):
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
# Validate PDE compatibility
|
|
340
|
+
self._validate_product(product)
|
|
341
|
+
|
|
342
|
+
# Determine knocked-in state at valuation
|
|
343
|
+
already_knocked_in = bool(getattr(product, "_otc_lifecycle_knocked_in", False))
|
|
344
|
+
ki_continuous = (
|
|
345
|
+
product.barrier_config.ki_continuous
|
|
346
|
+
or product.barrier_config.ki_observation_type == ObservationType.CONTINUOUS
|
|
347
|
+
)
|
|
348
|
+
knocked_in_at_valuation = self._is_knocked_in_at_valuation(
|
|
349
|
+
product, spot, pricing_env, ki_continuous=ki_continuous
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Extract market data
|
|
353
|
+
r = pricing_env.get_rate(tau)
|
|
354
|
+
q = pricing_env.get_div_yield(tau)
|
|
355
|
+
sigma = pricing_env.get_vol(product.strike, tau)
|
|
356
|
+
|
|
357
|
+
# Store product properties needed by _build_grids
|
|
358
|
+
self._is_reverse = product.is_reverse
|
|
359
|
+
self._ki_continuous = ki_continuous
|
|
360
|
+
if product.has_ki_barrier:
|
|
361
|
+
ki_barrier = product.barrier_config.ki_barrier
|
|
362
|
+
if isinstance(ki_barrier, list):
|
|
363
|
+
self._ki_barrier = ki_barrier[0]
|
|
364
|
+
else:
|
|
365
|
+
self._ki_barrier = ki_barrier
|
|
366
|
+
|
|
367
|
+
x_vec, s_vec, dx_vec, t_vec, dt_vec = self._build_grids(
|
|
368
|
+
product, pricing_env, spot, sigma, tau, r, q
|
|
369
|
+
)
|
|
370
|
+
num_x, num_t = len(x_vec), len(t_vec)
|
|
371
|
+
|
|
372
|
+
ko_records = self._filter_observations_by_tau(
|
|
373
|
+
product.resolve_ko_observations(pricing_env), tau
|
|
374
|
+
)
|
|
375
|
+
if not ko_records:
|
|
376
|
+
return None
|
|
377
|
+
n_ko = len(ko_records)
|
|
378
|
+
|
|
379
|
+
# Map time index -> ko record index.
|
|
380
|
+
ko_index_by_tidx: Dict[int, int] = {}
|
|
381
|
+
for k, rec in enumerate(ko_records):
|
|
382
|
+
obs_time = float(rec.observation_time)
|
|
383
|
+
if is_close(obs_time, 0.0):
|
|
384
|
+
t_idx = 0
|
|
385
|
+
elif is_close(obs_time, tau):
|
|
386
|
+
t_idx = num_t - 1
|
|
387
|
+
else:
|
|
388
|
+
t_idx = int(np.argmin(np.abs(t_vec - obs_time)))
|
|
389
|
+
if not is_close(float(t_vec[t_idx]), obs_time):
|
|
390
|
+
raise ValidationError(
|
|
391
|
+
"Time grid must align with KO observation times for event stats."
|
|
392
|
+
)
|
|
393
|
+
ko_index_by_tidx[t_idx] = k
|
|
394
|
+
|
|
395
|
+
# Surface columns: [KO_0, ..., KO_{n_ko-1}, KI_indicator]
|
|
396
|
+
ki_col = n_ko
|
|
397
|
+
n_cols = n_ko + 1
|
|
398
|
+
|
|
399
|
+
# Terminal conditions at maturity (t = T):
|
|
400
|
+
# - KO indicators are zero at maturity (KO only at discrete observations via jumps)
|
|
401
|
+
# - KI indicator is 1 on the KI surface and 0 on the no-KI surface
|
|
402
|
+
v0_next = np.zeros((num_x, n_cols), dtype=float)
|
|
403
|
+
v1_next = np.zeros((num_x, n_cols), dtype=float)
|
|
404
|
+
v1_next[:, ki_col] = 1.0
|
|
405
|
+
|
|
406
|
+
# Apply terminal KO/KI events at maturity if observation schedules include t=T.
|
|
407
|
+
terminal_tidx = num_t - 1
|
|
408
|
+
terminal_ko_idx = ko_index_by_tidx.get(terminal_tidx)
|
|
409
|
+
if terminal_ko_idx is not None:
|
|
410
|
+
rec = ko_records[terminal_ko_idx]
|
|
411
|
+
barrier = float(rec.barrier) if rec.barrier is not None else 0.0
|
|
412
|
+
mask_ko = self._get_barrier_mask(s_vec, barrier, product.is_reverse, is_up_barrier=True)
|
|
413
|
+
|
|
414
|
+
v0_next[mask_ko, :] = 0.0
|
|
415
|
+
v1_next[mask_ko, :] = 0.0
|
|
416
|
+
df_delay = self._cashflow_value_at_time(
|
|
417
|
+
pricing_env=pricing_env,
|
|
418
|
+
cashflow=1.0,
|
|
419
|
+
current_time=float(t_vec[terminal_tidx]),
|
|
420
|
+
settlement_time=rec.settlement_time,
|
|
421
|
+
)
|
|
422
|
+
v0_next[mask_ko, terminal_ko_idx] = df_delay
|
|
423
|
+
v1_next[mask_ko, terminal_ko_idx] = df_delay
|
|
424
|
+
|
|
425
|
+
is_terminal_ki = product.has_ki_barrier and (
|
|
426
|
+
self._ki_continuous or terminal_tidx in self._ki_observation_indices
|
|
427
|
+
)
|
|
428
|
+
if is_terminal_ki:
|
|
429
|
+
ki_barrier = self._resolve_ki_barrier_at_tidx(terminal_tidx)
|
|
430
|
+
mask_ki = self._get_barrier_mask(s_vec, ki_barrier, product.is_reverse, is_up_barrier=False)
|
|
431
|
+
v0_next[mask_ki, :] = v1_next[mask_ki, :]
|
|
432
|
+
|
|
433
|
+
# Operator coefficients and banded solver setup
|
|
434
|
+
params: PDEParams = self.params
|
|
435
|
+
l, c, u = self._calculate_coefficients(r, q, sigma, dx_vec, num_x)
|
|
436
|
+
use_banded = params.use_banded_solver and (num_x - 2) > 2
|
|
437
|
+
if not use_banded:
|
|
438
|
+
raise ValidationError("Event stats PDE currently requires banded solver path.")
|
|
439
|
+
|
|
440
|
+
# Rannacher smoothing indices (reuse the same rule as the pricing solver).
|
|
441
|
+
smooth_js: set[int] = set()
|
|
442
|
+
if params.use_rannacher and params.auto_grid and params.rannacher_at_events:
|
|
443
|
+
event_times = self._get_event_times(product, tau)
|
|
444
|
+
if event_times:
|
|
445
|
+
for et in event_times:
|
|
446
|
+
idx = int(np.argmin(np.abs(t_vec - et)))
|
|
447
|
+
if 0 < idx < num_t - 1 and is_close(float(t_vec[idx]), float(et)):
|
|
448
|
+
for k in range(params.rannacher_steps):
|
|
449
|
+
smooth_idx = idx - 1 - k
|
|
450
|
+
if smooth_idx >= 0:
|
|
451
|
+
smooth_js.add(smooth_idx)
|
|
452
|
+
|
|
453
|
+
n_int = num_x - 2
|
|
454
|
+
rhs = np.empty((n_int, 2 * n_cols), dtype=float)
|
|
455
|
+
|
|
456
|
+
for j in range(num_t - 2, -1, -1):
|
|
457
|
+
dt = float(dt_vec[j])
|
|
458
|
+
steps_from_end = num_t - 1 - j
|
|
459
|
+
theta = (
|
|
460
|
+
1.0
|
|
461
|
+
if params.use_rannacher
|
|
462
|
+
and (steps_from_end < params.rannacher_steps or j in smooth_js)
|
|
463
|
+
else params.theta
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
banded, lower1, main1, upper1 = self._get_banded_system(l, c, u, dt, theta)
|
|
467
|
+
|
|
468
|
+
# Initialize "current" with next boundaries (approximation); interior will be solved.
|
|
469
|
+
v0_cur = v0_next.copy()
|
|
470
|
+
v1_cur = v1_next.copy()
|
|
471
|
+
|
|
472
|
+
# Build RHS for all columns.
|
|
473
|
+
v0n = v0_next[1:-1, :]
|
|
474
|
+
v1n = v1_next[1:-1, :]
|
|
475
|
+
|
|
476
|
+
rhs_v0 = rhs[:, :n_cols]
|
|
477
|
+
rhs_v1 = rhs[:, n_cols:]
|
|
478
|
+
|
|
479
|
+
rhs_v0[:] = main1[:, None] * v0n
|
|
480
|
+
rhs_v0[1:, :] += lower1[:, None] * v0n[:-1, :]
|
|
481
|
+
rhs_v0[:-1, :] += upper1[:, None] * v0n[1:, :]
|
|
482
|
+
|
|
483
|
+
rhs_v1[:] = main1[:, None] * v1n
|
|
484
|
+
rhs_v1[1:, :] += lower1[:, None] * v1n[:-1, :]
|
|
485
|
+
rhs_v1[:-1, :] += upper1[:, None] * v1n[1:, :]
|
|
486
|
+
|
|
487
|
+
# Boundary contributions (Dirichlet terms).
|
|
488
|
+
if num_x > 2:
|
|
489
|
+
lhs_l = float(l[1])
|
|
490
|
+
lhs_u = float(u[-2])
|
|
491
|
+
rhs_v0[0, :] += dt * (
|
|
492
|
+
(1.0 - theta) * lhs_l * v0_next[0, :] + theta * lhs_l * v0_cur[0, :]
|
|
493
|
+
)
|
|
494
|
+
rhs_v0[-1, :] += dt * (
|
|
495
|
+
(1.0 - theta) * lhs_u * v0_next[-1, :] + theta * lhs_u * v0_cur[-1, :]
|
|
496
|
+
)
|
|
497
|
+
rhs_v1[0, :] += dt * (
|
|
498
|
+
(1.0 - theta) * lhs_l * v1_next[0, :] + theta * lhs_l * v1_cur[0, :]
|
|
499
|
+
)
|
|
500
|
+
rhs_v1[-1, :] += dt * (
|
|
501
|
+
(1.0 - theta) * lhs_u * v1_next[-1, :] + theta * lhs_u * v1_cur[-1, :]
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
sol = solve_banded(
|
|
505
|
+
(1, 1),
|
|
506
|
+
banded,
|
|
507
|
+
rhs,
|
|
508
|
+
overwrite_b=False,
|
|
509
|
+
check_finite=False,
|
|
510
|
+
)
|
|
511
|
+
v0_cur[1:-1, :] = sol[:, :n_cols]
|
|
512
|
+
v1_cur[1:-1, :] = sol[:, n_cols:]
|
|
513
|
+
|
|
514
|
+
# Apply KO jump (if observation time).
|
|
515
|
+
ko_idx = ko_index_by_tidx.get(j)
|
|
516
|
+
if ko_idx is not None:
|
|
517
|
+
rec = ko_records[ko_idx]
|
|
518
|
+
barrier = float(rec.barrier) if rec.barrier is not None else 0.0
|
|
519
|
+
mask_ko = self._get_barrier_mask(s_vec, barrier, product.is_reverse, is_up_barrier=True)
|
|
520
|
+
|
|
521
|
+
# Zero all event surfaces in KO region, then set the KO_i indicator.
|
|
522
|
+
v0_cur[mask_ko, :] = 0.0
|
|
523
|
+
v1_cur[mask_ko, :] = 0.0
|
|
524
|
+
df_delay = self._cashflow_value_at_time(
|
|
525
|
+
pricing_env=pricing_env,
|
|
526
|
+
cashflow=1.0,
|
|
527
|
+
current_time=float(t_vec[j]),
|
|
528
|
+
settlement_time=rec.settlement_time,
|
|
529
|
+
)
|
|
530
|
+
v0_cur[mask_ko, ko_idx] = df_delay
|
|
531
|
+
v1_cur[mask_ko, ko_idx] = df_delay
|
|
532
|
+
|
|
533
|
+
# Apply KI jump (continuous or discrete at observation indices).
|
|
534
|
+
if product.has_ki_barrier:
|
|
535
|
+
should_apply_ki = self._ki_continuous or j in self._ki_observation_indices
|
|
536
|
+
if should_apply_ki:
|
|
537
|
+
ki_barrier = self._resolve_ki_barrier_at_tidx(j)
|
|
538
|
+
mask_ki = self._get_barrier_mask(s_vec, ki_barrier, product.is_reverse, is_up_barrier=False)
|
|
539
|
+
v0_cur[mask_ki, :] = v1_cur[mask_ki, :]
|
|
540
|
+
|
|
541
|
+
# Enforce simple Neumann-like boundary (zero slope) for stability.
|
|
542
|
+
v0_cur[0, :] = v0_cur[1, :]
|
|
543
|
+
v0_cur[-1, :] = v0_cur[-2, :]
|
|
544
|
+
v1_cur[0, :] = v1_cur[1, :]
|
|
545
|
+
v1_cur[-1, :] = v1_cur[-2, :]
|
|
546
|
+
|
|
547
|
+
v0_next = v0_cur
|
|
548
|
+
v1_next = v1_cur
|
|
549
|
+
|
|
550
|
+
# Select initial regime based on knocked-in at valuation.
|
|
551
|
+
initial_grid = v1_next if knocked_in_at_valuation else v0_next
|
|
552
|
+
spot_log = float(np.log(spot))
|
|
553
|
+
|
|
554
|
+
ed_unit = np.array(
|
|
555
|
+
[float(np.interp(spot_log, x_vec, initial_grid[:, i])) for i in range(n_ko)],
|
|
556
|
+
dtype=float,
|
|
557
|
+
)
|
|
558
|
+
ko_times = np.array([float(rec.observation_time) for rec in ko_records], dtype=float)
|
|
559
|
+
ko_probability = np.zeros(n_ko, dtype=float)
|
|
560
|
+
ed_ko_cf = np.zeros(n_ko, dtype=float)
|
|
561
|
+
|
|
562
|
+
for i, rec in enumerate(ko_records):
|
|
563
|
+
obs_time = float(rec.observation_time)
|
|
564
|
+
settle = rec.settlement_time if rec.settlement_time is not None else obs_time
|
|
565
|
+
settle = float(settle)
|
|
566
|
+
df0 = pricing_env.get_discount_factor(settle)
|
|
567
|
+
if df0 > 0.0:
|
|
568
|
+
ko_probability[i] = float(ed_unit[i] / df0)
|
|
569
|
+
payoff = float(rec.payoff) if rec.payoff is not None else 0.0
|
|
570
|
+
ed_ko_cf[i] = float(ed_unit[i] * payoff)
|
|
571
|
+
|
|
572
|
+
survival_probability = np.ones(n_ko, dtype=float)
|
|
573
|
+
cumulative = 0.0
|
|
574
|
+
for i in range(n_ko):
|
|
575
|
+
cumulative += ko_probability[i]
|
|
576
|
+
survival_probability[i] = max(0.0, 1.0 - cumulative)
|
|
577
|
+
|
|
578
|
+
ki_times = np.array([], dtype=float)
|
|
579
|
+
ki_event_probability = np.array([], dtype=float)
|
|
580
|
+
ki_survival_probability = np.array([], dtype=float)
|
|
581
|
+
if already_knocked_in:
|
|
582
|
+
ki_probability = 1.0
|
|
583
|
+
ki_times = np.array([0.0], dtype=float)
|
|
584
|
+
ki_event_probability = np.array([1.0], dtype=float)
|
|
585
|
+
ki_survival_probability = np.array([0.0], dtype=float)
|
|
586
|
+
else:
|
|
587
|
+
ed_ki = float(np.interp(spot_log, x_vec, initial_grid[:, ki_col]))
|
|
588
|
+
df_T = pricing_env.get_discount_factor(float(tau))
|
|
589
|
+
ki_probability = float(ed_ki / df_T) if df_T > 0.0 else 0.0
|
|
590
|
+
|
|
591
|
+
pv = float(self.price(product, pricing_env))
|
|
592
|
+
expected_discounted_maturity_cf = float(pv - float(np.sum(ed_ko_cf)))
|
|
593
|
+
|
|
594
|
+
return AutocallableEventStats(
|
|
595
|
+
pv=pv,
|
|
596
|
+
ko_times=ko_times,
|
|
597
|
+
ko_probability=ko_probability,
|
|
598
|
+
survival_probability=survival_probability,
|
|
599
|
+
expected_discounted_ko_cashflow=ed_ko_cf,
|
|
600
|
+
ki_probability=ki_probability,
|
|
601
|
+
expected_discounted_maturity_cashflow=expected_discounted_maturity_cf,
|
|
602
|
+
reconciliation_error=0.0,
|
|
603
|
+
ki_times=ki_times,
|
|
604
|
+
ki_event_probability=ki_event_probability,
|
|
605
|
+
ki_survival_probability=ki_survival_probability,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
def calculate_greeks(
|
|
609
|
+
self, product: BaseEquityProduct, pricing_env: PricingEnvironment
|
|
610
|
+
) -> Dict[str, float]:
|
|
611
|
+
"""
|
|
612
|
+
Calculate Greeks for a Snowball option using Two-Surface PDE method.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
product: SnowballOption
|
|
616
|
+
pricing_env: Pricing environment with market data
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
Dictionary with price, delta, gamma
|
|
620
|
+
|
|
621
|
+
Raises:
|
|
622
|
+
PricingError: If product is not a SnowballOption
|
|
623
|
+
ValidationError: If product configuration is incompatible with PDE
|
|
624
|
+
"""
|
|
625
|
+
self._check_product_type(product)
|
|
626
|
+
|
|
627
|
+
if pricing_env is None:
|
|
628
|
+
raise ValidationError(
|
|
629
|
+
f"PricingEnvironment is required for {self._solver_name}"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# Validate PDE compatibility
|
|
633
|
+
self._validate_product(product)
|
|
634
|
+
|
|
635
|
+
spot = pricing_env.spot
|
|
636
|
+
tau = product.get_maturity(pricing_env)
|
|
637
|
+
|
|
638
|
+
if tau <= 0 or is_zero(tau):
|
|
639
|
+
# Expired: return terminal value with zero Greeks
|
|
640
|
+
return {
|
|
641
|
+
"price": self._calculate_terminal_value(product, spot, pricing_env),
|
|
642
|
+
"delta": 0.0,
|
|
643
|
+
"gamma": 0.0,
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
# Check if knocked out at valuation
|
|
647
|
+
knocked_out_at_valuation = self._is_knocked_out_at_valuation(
|
|
648
|
+
product, spot, pricing_env
|
|
649
|
+
)
|
|
650
|
+
if knocked_out_at_valuation:
|
|
651
|
+
# KO payoff is fixed, so delta and gamma are zero
|
|
652
|
+
return {
|
|
653
|
+
"price": self._get_immediate_ko_payoff(product, pricing_env),
|
|
654
|
+
"delta": 0.0,
|
|
655
|
+
"gamma": 0.0,
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
# Solve PDE
|
|
659
|
+
result = self._solve(product, pricing_env)
|
|
660
|
+
|
|
661
|
+
# Extract price and Greeks from appropriate surface
|
|
662
|
+
price = self._interpolate_price(
|
|
663
|
+
result.solution_vec, result.x_vec, result.spot_log
|
|
664
|
+
)
|
|
665
|
+
delta, gamma = self._calculate_delta_gamma(
|
|
666
|
+
result.solution_vec, result.x_vec, result.spot_log, spot
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
return {"price": price, "delta": delta, "gamma": gamma}
|
|
670
|
+
|
|
671
|
+
def _validate_product(self, product: SnowballOption) -> None:
|
|
672
|
+
"""
|
|
673
|
+
Validate that product configuration is compatible with PDE solver.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
product: SnowballOption to validate
|
|
677
|
+
|
|
678
|
+
Raises:
|
|
679
|
+
ValidationError: If configuration is incompatible
|
|
680
|
+
"""
|
|
681
|
+
# Check for continuous KI with non-scalar barrier
|
|
682
|
+
ki_continuous = (
|
|
683
|
+
product.barrier_config.ki_continuous
|
|
684
|
+
or product.barrier_config.ki_observation_type == ObservationType.CONTINUOUS
|
|
685
|
+
)
|
|
686
|
+
if ki_continuous and product.has_ki_barrier:
|
|
687
|
+
if isinstance(product.barrier_config.ki_barrier, list):
|
|
688
|
+
raise ValidationError(
|
|
689
|
+
"Continuous KI monitoring requires scalar ki_barrier. "
|
|
690
|
+
"Use discrete monitoring for time-varying KI barriers."
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
@staticmethod
|
|
694
|
+
def _filter_observations_by_tau(
|
|
695
|
+
records: List[ResolvedObservationRecord], tau: float
|
|
696
|
+
) -> List[ResolvedObservationRecord]:
|
|
697
|
+
"""
|
|
698
|
+
Filter and sort observation records within [0, tau] range.
|
|
699
|
+
|
|
700
|
+
This utility consolidates the repeated observation filtering pattern.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
records: List of resolved observation records
|
|
704
|
+
tau: Time to maturity (upper bound for filtering)
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
Sorted list of records with observation_time in [0, tau]
|
|
708
|
+
"""
|
|
709
|
+
filtered = [
|
|
710
|
+
rec for rec in records
|
|
711
|
+
if rec.observation_time is not None and 0.0 <= rec.observation_time <= tau
|
|
712
|
+
]
|
|
713
|
+
filtered.sort(key=lambda rec: float(rec.observation_time))
|
|
714
|
+
return filtered
|
|
715
|
+
|
|
716
|
+
@staticmethod
|
|
717
|
+
def _get_barrier_mask(
|
|
718
|
+
s_vec: np.ndarray, barrier: float, is_reverse: bool, is_up_barrier: bool = True
|
|
719
|
+
) -> np.ndarray:
|
|
720
|
+
"""
|
|
721
|
+
Get boolean mask for grid points that breach a barrier.
|
|
722
|
+
|
|
723
|
+
This helper consolidates the barrier mask logic used throughout the solver.
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
s_vec: Array of spot prices on the grid
|
|
727
|
+
barrier: Barrier level
|
|
728
|
+
is_reverse: True for reverse snowball (inverts barrier direction)
|
|
729
|
+
is_up_barrier: True for UP barrier (KO), False for DOWN barrier (KI)
|
|
730
|
+
|
|
731
|
+
Returns:
|
|
732
|
+
Boolean mask where True indicates barrier is breached
|
|
733
|
+
|
|
734
|
+
Logic:
|
|
735
|
+
- Standard UP KO: mask = s_vec >= barrier
|
|
736
|
+
- Reverse UP KO: mask = s_vec <= barrier (inverted)
|
|
737
|
+
- Standard DOWN KI: mask = s_vec <= barrier
|
|
738
|
+
- Reverse DOWN KI: mask = s_vec >= barrier (inverted)
|
|
739
|
+
"""
|
|
740
|
+
if is_up_barrier:
|
|
741
|
+
# UP barrier (typically KO)
|
|
742
|
+
if is_reverse:
|
|
743
|
+
return s_vec <= barrier # DOWN for reverse
|
|
744
|
+
else:
|
|
745
|
+
return s_vec >= barrier # UP for standard
|
|
746
|
+
else:
|
|
747
|
+
# DOWN barrier (typically KI)
|
|
748
|
+
if is_reverse:
|
|
749
|
+
return s_vec >= barrier # UP for reverse
|
|
750
|
+
else:
|
|
751
|
+
return s_vec <= barrier # DOWN for standard
|
|
752
|
+
|
|
753
|
+
@staticmethod
|
|
754
|
+
def _record_is_non_negative_time(record: ResolvedObservationRecord) -> bool:
|
|
755
|
+
"""Return True if record's time is >= 0 (within numerical tolerance)."""
|
|
756
|
+
return bool(
|
|
757
|
+
record.observation_time > 0.0 or is_close(record.observation_time, 0.0)
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
@staticmethod
|
|
761
|
+
def _find_record_at_time(
|
|
762
|
+
records: List[ResolvedObservationRecord], target_time: float
|
|
763
|
+
) -> Optional[ResolvedObservationRecord]:
|
|
764
|
+
"""Find an observation record at a specific time (within tolerance)."""
|
|
765
|
+
for rec in records:
|
|
766
|
+
if is_close(rec.observation_time, target_time):
|
|
767
|
+
return rec
|
|
768
|
+
return None
|
|
769
|
+
|
|
770
|
+
def _is_already_knocked_in(self, product: SnowballOption, spot: float) -> bool:
|
|
771
|
+
"""Check if spot is in the knocked-in region (spot-only proxy)."""
|
|
772
|
+
if not product.has_ki_barrier:
|
|
773
|
+
return False
|
|
774
|
+
|
|
775
|
+
ki_barrier = product.barrier_config.ki_barrier
|
|
776
|
+
if isinstance(ki_barrier, list):
|
|
777
|
+
ki_barrier = ki_barrier[0]
|
|
778
|
+
|
|
779
|
+
if product.is_reverse:
|
|
780
|
+
return spot >= ki_barrier # UP KI for reverse
|
|
781
|
+
else:
|
|
782
|
+
return spot <= ki_barrier # DOWN KI for standard
|
|
783
|
+
|
|
784
|
+
def _is_knocked_in_at_valuation(
|
|
785
|
+
self,
|
|
786
|
+
product: SnowballOption,
|
|
787
|
+
spot: float,
|
|
788
|
+
pricing_env: PricingEnvironment,
|
|
789
|
+
ki_continuous: bool,
|
|
790
|
+
) -> bool:
|
|
791
|
+
"""
|
|
792
|
+
Determine KI state at valuation date (t=0).
|
|
793
|
+
|
|
794
|
+
For continuous monitoring, a barrier breach at valuation implies immediate KI.
|
|
795
|
+
For discrete monitoring, a barrier breach only matters if there is a KI observation at t=0.
|
|
796
|
+
"""
|
|
797
|
+
if getattr(product, "_otc_lifecycle_knocked_in", False):
|
|
798
|
+
return True
|
|
799
|
+
if not product.has_ki_barrier:
|
|
800
|
+
return False
|
|
801
|
+
|
|
802
|
+
if ki_continuous:
|
|
803
|
+
return self._is_already_knocked_in(product, spot)
|
|
804
|
+
|
|
805
|
+
ki_records = product.resolve_ki_observations(pricing_env)
|
|
806
|
+
ki_record_0 = self._find_record_at_time(ki_records, 0.0)
|
|
807
|
+
if ki_record_0 is None:
|
|
808
|
+
return False
|
|
809
|
+
if ki_record_0.barrier is None:
|
|
810
|
+
raise ValidationError(
|
|
811
|
+
"KI observation at valuation requires a barrier level."
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
if product.is_reverse:
|
|
815
|
+
return spot >= ki_record_0.barrier
|
|
816
|
+
return spot <= ki_record_0.barrier
|
|
817
|
+
|
|
818
|
+
def _is_knocked_out_at_valuation(
|
|
819
|
+
self, product: SnowballOption, spot: float, pricing_env: PricingEnvironment
|
|
820
|
+
) -> bool:
|
|
821
|
+
"""
|
|
822
|
+
Determine KO state at valuation date (t=0).
|
|
823
|
+
|
|
824
|
+
KO for SnowballOption is modeled as discrete in this solver; a KO breach at valuation
|
|
825
|
+
only matters if there is a KO observation scheduled at t=0.
|
|
826
|
+
"""
|
|
827
|
+
if product.barrier_config.ko_observation_type != ObservationType.DISCRETE:
|
|
828
|
+
return False
|
|
829
|
+
|
|
830
|
+
ko_records = product.resolve_ko_observations(pricing_env)
|
|
831
|
+
ko_record_0 = self._find_record_at_time(ko_records, 0.0)
|
|
832
|
+
if ko_record_0 is None:
|
|
833
|
+
return False
|
|
834
|
+
if ko_record_0.barrier is None:
|
|
835
|
+
raise ValidationError(
|
|
836
|
+
"KO observation at valuation requires a barrier level."
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
if product.is_reverse:
|
|
840
|
+
return spot <= ko_record_0.barrier
|
|
841
|
+
return spot >= ko_record_0.barrier
|
|
842
|
+
|
|
843
|
+
def _get_immediate_ko_payoff(
|
|
844
|
+
self, product: SnowballOption, pricing_env: PricingEnvironment
|
|
845
|
+
) -> float:
|
|
846
|
+
"""Get KO payoff when valuation date is a KO observation and KO is triggered."""
|
|
847
|
+
ko_records = product.resolve_ko_observations(pricing_env)
|
|
848
|
+
ko_record_0 = self._find_record_at_time(ko_records, 0.0)
|
|
849
|
+
if ko_record_0 is None:
|
|
850
|
+
raise ValidationError(
|
|
851
|
+
"Immediate KO payoff requested but no KO observation exists at valuation date."
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
payoff = ko_record_0.payoff if ko_record_0.payoff is not None else 0.0
|
|
855
|
+
settlement_time = ko_record_0.settlement_time
|
|
856
|
+
if settlement_time is not None and settlement_time > 0.0:
|
|
857
|
+
df = pricing_env.get_discount_factor(settlement_time)
|
|
858
|
+
return float(payoff) * float(df)
|
|
859
|
+
return float(payoff)
|
|
860
|
+
|
|
861
|
+
def _calculate_terminal_value(
|
|
862
|
+
self, product: SnowballOption, spot: float, pricing_env: PricingEnvironment
|
|
863
|
+
) -> float:
|
|
864
|
+
"""Calculate terminal payoff when already expired."""
|
|
865
|
+
# Determine if knocked-in based on current spot
|
|
866
|
+
knocked_in = bool(getattr(product, "_otc_lifecycle_knocked_in", False))
|
|
867
|
+
if not knocked_in:
|
|
868
|
+
knocked_in = self._is_already_knocked_in(product, spot)
|
|
869
|
+
return product.get_payoff(spot, pricing_env, knocked_in=knocked_in)
|
|
870
|
+
|
|
871
|
+
def _build_grids(
|
|
872
|
+
self,
|
|
873
|
+
product: BaseEquityProduct,
|
|
874
|
+
pricing_env: PricingEnvironment,
|
|
875
|
+
spot: float,
|
|
876
|
+
sigma: float,
|
|
877
|
+
tau: float,
|
|
878
|
+
r: float,
|
|
879
|
+
q: float,
|
|
880
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
881
|
+
"""
|
|
882
|
+
Build spatial and temporal grids for the Two-Surface PDE solver.
|
|
883
|
+
|
|
884
|
+
Extends base class to:
|
|
885
|
+
- Include all KO barriers and KI barrier in spatial grid
|
|
886
|
+
- Align time grid with all observation times
|
|
887
|
+
- Track observation indices for discrete barrier checks
|
|
888
|
+
"""
|
|
889
|
+
result = super()._build_grids(product, pricing_env, spot, sigma, tau, r, q)
|
|
890
|
+
x_vec, s_vec, dx_vec, t_vec, dt_vec = result
|
|
891
|
+
|
|
892
|
+
# Store total time to maturity
|
|
893
|
+
self._total_tau = tau
|
|
894
|
+
|
|
895
|
+
# Clear previous observation tracking
|
|
896
|
+
self._ko_observation_indices.clear()
|
|
897
|
+
self._ki_observation_indices.clear()
|
|
898
|
+
self._ki_barrier_by_tidx.clear()
|
|
899
|
+
self._ko_terminal_record = None
|
|
900
|
+
self._has_terminal_ko = False
|
|
901
|
+
|
|
902
|
+
# Setup KO observation indices
|
|
903
|
+
ko_records = self._get_cached_ko_records(pricing_env, product)
|
|
904
|
+
for rec in ko_records:
|
|
905
|
+
obs_time = rec.observation_time
|
|
906
|
+
if is_close(obs_time, 0.0):
|
|
907
|
+
self._ko_observation_indices[0] = rec
|
|
908
|
+
elif is_close(obs_time, tau):
|
|
909
|
+
self._ko_terminal_record = rec
|
|
910
|
+
self._has_terminal_ko = True
|
|
911
|
+
elif 0.0 < obs_time < tau:
|
|
912
|
+
idx = self._aligned_time_index(t_vec, obs_time, "KO observation")
|
|
913
|
+
self._ko_observation_indices[idx] = rec
|
|
914
|
+
|
|
915
|
+
# Setup KI observation indices (if discrete)
|
|
916
|
+
if product.has_ki_barrier and not self._ki_continuous:
|
|
917
|
+
ki_profile = self._get_cached_ki_profile(pricing_env, product)
|
|
918
|
+
ki_times = ki_profile["observation_times"]
|
|
919
|
+
ki_barriers = ki_profile.get("barriers") or []
|
|
920
|
+
for obs_idx, obs_time in enumerate(ki_times):
|
|
921
|
+
if obs_time is None:
|
|
922
|
+
continue
|
|
923
|
+
barrier = None
|
|
924
|
+
if obs_idx < len(ki_barriers):
|
|
925
|
+
barrier = ki_barriers[obs_idx]
|
|
926
|
+
if barrier is None:
|
|
927
|
+
barrier = self._ki_barrier
|
|
928
|
+
if is_close(obs_time, 0.0):
|
|
929
|
+
self._ki_observation_indices.add(0)
|
|
930
|
+
self._ki_barrier_by_tidx[0] = float(barrier)
|
|
931
|
+
elif 0.0 < obs_time <= tau:
|
|
932
|
+
idx = self._aligned_time_index(t_vec, obs_time, "KI observation")
|
|
933
|
+
self._ki_observation_indices.add(idx)
|
|
934
|
+
self._ki_barrier_by_tidx[idx] = float(barrier)
|
|
935
|
+
|
|
936
|
+
return result
|
|
937
|
+
|
|
938
|
+
def _resolve_ki_barrier_at_tidx(self, t_idx: int) -> float:
|
|
939
|
+
"""Resolve KI barrier for a specific PDE time index."""
|
|
940
|
+
if not self._ki_continuous:
|
|
941
|
+
mapped = self._ki_barrier_by_tidx.get(t_idx)
|
|
942
|
+
if mapped is not None:
|
|
943
|
+
return float(mapped)
|
|
944
|
+
return float(self._ki_barrier)
|
|
945
|
+
|
|
946
|
+
def _aligned_time_index(
|
|
947
|
+
self, t_vec: np.ndarray, obs_time: float, label: str
|
|
948
|
+
) -> int:
|
|
949
|
+
for idx, t_val in enumerate(t_vec):
|
|
950
|
+
if is_close(float(t_val), float(obs_time), abs_tol=Tolerance.PRECISION):
|
|
951
|
+
return int(idx)
|
|
952
|
+
nearest = int(np.argmin(np.abs(t_vec - obs_time)))
|
|
953
|
+
nearest_time = float(t_vec[nearest])
|
|
954
|
+
raise ValidationError(
|
|
955
|
+
f"{label} time {obs_time} does not align with PDE time grid "
|
|
956
|
+
f"(nearest {nearest_time}). Use event-aligned time grid or increase time steps."
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
def get_critical_points(
|
|
960
|
+
self, product: BaseEquityProduct, pricing_env: PricingEnvironment
|
|
961
|
+
) -> List[float]:
|
|
962
|
+
"""
|
|
963
|
+
Get critical prices for grid concentration.
|
|
964
|
+
|
|
965
|
+
For Snowball options, includes:
|
|
966
|
+
- Strike price
|
|
967
|
+
- All KO barriers
|
|
968
|
+
- KI barrier
|
|
969
|
+
|
|
970
|
+
Args:
|
|
971
|
+
product: SnowballOption
|
|
972
|
+
pricing_env: Pricing environment
|
|
973
|
+
|
|
974
|
+
Returns:
|
|
975
|
+
List of critical price levels
|
|
976
|
+
"""
|
|
977
|
+
points = []
|
|
978
|
+
|
|
979
|
+
if hasattr(product, "strike") and product.strike > 0:
|
|
980
|
+
points.append(product.strike)
|
|
981
|
+
|
|
982
|
+
if hasattr(product, "initial_price") and product.initial_price > 0:
|
|
983
|
+
points.append(product.initial_price)
|
|
984
|
+
|
|
985
|
+
# Add KO barriers
|
|
986
|
+
ko_barrier = product.barrier_config.ko_barrier
|
|
987
|
+
if isinstance(ko_barrier, list):
|
|
988
|
+
points.extend([b for b in ko_barrier if b > 0])
|
|
989
|
+
elif ko_barrier > 0:
|
|
990
|
+
points.append(ko_barrier)
|
|
991
|
+
|
|
992
|
+
# Add KI barrier
|
|
993
|
+
if product.has_ki_barrier:
|
|
994
|
+
ki_barrier = product.barrier_config.ki_barrier
|
|
995
|
+
if isinstance(ki_barrier, list):
|
|
996
|
+
points.extend([b for b in ki_barrier if b > 0])
|
|
997
|
+
elif ki_barrier > 0:
|
|
998
|
+
points.append(ki_barrier)
|
|
999
|
+
|
|
1000
|
+
# Add airbag barrier if present
|
|
1001
|
+
if product.airbag_config.airbag_barrier is not None:
|
|
1002
|
+
points.append(product.airbag_config.airbag_barrier)
|
|
1003
|
+
|
|
1004
|
+
return sorted(set([p for p in points if p > 0]))
|
|
1005
|
+
|
|
1006
|
+
def _get_barriers(self, product: BaseEquityProduct) -> List[float]:
|
|
1007
|
+
"""Collect all barrier levels for spatial grid construction."""
|
|
1008
|
+
barriers = []
|
|
1009
|
+
|
|
1010
|
+
if hasattr(product, "barrier_config"):
|
|
1011
|
+
# KO barriers
|
|
1012
|
+
ko_barrier = product.barrier_config.ko_barrier
|
|
1013
|
+
if isinstance(ko_barrier, list):
|
|
1014
|
+
barriers.extend(ko_barrier)
|
|
1015
|
+
elif ko_barrier > 0:
|
|
1016
|
+
barriers.append(ko_barrier)
|
|
1017
|
+
|
|
1018
|
+
# KI barrier
|
|
1019
|
+
if product.barrier_config.ki_barrier is not None:
|
|
1020
|
+
ki_barrier = product.barrier_config.ki_barrier
|
|
1021
|
+
if isinstance(ki_barrier, list):
|
|
1022
|
+
barriers.extend(ki_barrier)
|
|
1023
|
+
elif ki_barrier > 0:
|
|
1024
|
+
barriers.append(ki_barrier)
|
|
1025
|
+
|
|
1026
|
+
return barriers
|
|
1027
|
+
|
|
1028
|
+
def _get_event_times(
|
|
1029
|
+
self, product: BaseEquityProduct, tau: float
|
|
1030
|
+
) -> Optional[List[float]]:
|
|
1031
|
+
"""Collect all observation times for time grid alignment."""
|
|
1032
|
+
event_times = []
|
|
1033
|
+
|
|
1034
|
+
if hasattr(product, "barrier_config"):
|
|
1035
|
+
# KO observation times
|
|
1036
|
+
ko_schedule = product.barrier_config.ko_observation_schedule
|
|
1037
|
+
if ko_schedule is not None:
|
|
1038
|
+
for rec in ko_schedule.records:
|
|
1039
|
+
if rec.observation_time is not None:
|
|
1040
|
+
t = rec.observation_time
|
|
1041
|
+
if 0 < t < tau:
|
|
1042
|
+
event_times.append(t)
|
|
1043
|
+
elif product.barrier_config.ko_observation_dates is not None:
|
|
1044
|
+
for t in product.barrier_config.ko_observation_dates:
|
|
1045
|
+
if 0 < t < tau:
|
|
1046
|
+
event_times.append(t)
|
|
1047
|
+
|
|
1048
|
+
# KI observation times (if discrete)
|
|
1049
|
+
if not self._ki_continuous:
|
|
1050
|
+
ki_schedule = product.barrier_config.ki_observation_schedule
|
|
1051
|
+
if ki_schedule is not None:
|
|
1052
|
+
for rec in ki_schedule.records:
|
|
1053
|
+
if rec.observation_time is not None:
|
|
1054
|
+
t = rec.observation_time
|
|
1055
|
+
if 0 < t < tau:
|
|
1056
|
+
event_times.append(t)
|
|
1057
|
+
elif product.barrier_config.ki_observation_dates is not None:
|
|
1058
|
+
for t in product.barrier_config.ki_observation_dates:
|
|
1059
|
+
if 0 < t < tau:
|
|
1060
|
+
event_times.append(t)
|
|
1061
|
+
|
|
1062
|
+
return sorted(set(event_times)) if event_times else None
|
|
1063
|
+
|
|
1064
|
+
def _set_terminal_condition_v0(
|
|
1065
|
+
self,
|
|
1066
|
+
grid: np.ndarray,
|
|
1067
|
+
x_vec: np.ndarray,
|
|
1068
|
+
s_vec: np.ndarray,
|
|
1069
|
+
product: SnowballOption,
|
|
1070
|
+
pricing_env: PricingEnvironment,
|
|
1071
|
+
) -> None:
|
|
1072
|
+
"""
|
|
1073
|
+
Set terminal condition for the V0 (not knocked-in) surface.
|
|
1074
|
+
|
|
1075
|
+
V0 payoff at maturity = Principal + Rebate (fixed or call-style)
|
|
1076
|
+
"""
|
|
1077
|
+
payoffs = np.array(
|
|
1078
|
+
[product.get_maturity_payoff_v0(s, pricing_env) for s in s_vec]
|
|
1079
|
+
)
|
|
1080
|
+
grid[:, -1] = payoffs
|
|
1081
|
+
|
|
1082
|
+
def _set_terminal_condition_v1(
|
|
1083
|
+
self,
|
|
1084
|
+
grid: np.ndarray,
|
|
1085
|
+
x_vec: np.ndarray,
|
|
1086
|
+
s_vec: np.ndarray,
|
|
1087
|
+
product: SnowballOption,
|
|
1088
|
+
pricing_env: PricingEnvironment,
|
|
1089
|
+
) -> None:
|
|
1090
|
+
"""
|
|
1091
|
+
Set terminal condition for the V1 (knocked-in) surface.
|
|
1092
|
+
|
|
1093
|
+
V1 payoff at maturity = Principal + Participation × downside
|
|
1094
|
+
(with protection floor applied)
|
|
1095
|
+
"""
|
|
1096
|
+
payoffs = np.array(
|
|
1097
|
+
[product.get_maturity_payoff_v1(s, pricing_env) for s in s_vec]
|
|
1098
|
+
)
|
|
1099
|
+
grid[:, -1] = payoffs
|
|
1100
|
+
|
|
1101
|
+
def _apply_terminal_ko(
|
|
1102
|
+
self,
|
|
1103
|
+
grid_v0: np.ndarray,
|
|
1104
|
+
grid_v1: np.ndarray,
|
|
1105
|
+
s_vec: np.ndarray,
|
|
1106
|
+
product: SnowballOption,
|
|
1107
|
+
pricing_env: PricingEnvironment,
|
|
1108
|
+
ko_record: ResolvedObservationRecord,
|
|
1109
|
+
) -> None:
|
|
1110
|
+
"""Apply KO payoff at terminal time for grid points in breached region."""
|
|
1111
|
+
barrier = ko_record.barrier
|
|
1112
|
+
payoff = ko_record.payoff if ko_record.payoff is not None else 0.0
|
|
1113
|
+
|
|
1114
|
+
# Discount payoff if settlement is different from observation
|
|
1115
|
+
cashflow_value = self._cashflow_value_at_time(
|
|
1116
|
+
pricing_env=pricing_env,
|
|
1117
|
+
cashflow=payoff,
|
|
1118
|
+
current_time=self._total_tau,
|
|
1119
|
+
settlement_time=ko_record.settlement_time,
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
# Apply to breached region (KO is an UP barrier)
|
|
1123
|
+
mask = self._get_barrier_mask(s_vec, barrier, product.is_reverse, is_up_barrier=True)
|
|
1124
|
+
|
|
1125
|
+
grid_v0[mask, -1] = cashflow_value
|
|
1126
|
+
grid_v1[mask, -1] = cashflow_value
|
|
1127
|
+
|
|
1128
|
+
def _time_stepping_two_surface(
|
|
1129
|
+
self,
|
|
1130
|
+
grid_v0: np.ndarray,
|
|
1131
|
+
grid_v1: np.ndarray,
|
|
1132
|
+
A: sp.csc_matrix,
|
|
1133
|
+
l: np.ndarray,
|
|
1134
|
+
c: np.ndarray,
|
|
1135
|
+
u: np.ndarray,
|
|
1136
|
+
x_vec: np.ndarray,
|
|
1137
|
+
s_vec: np.ndarray,
|
|
1138
|
+
t_vec: np.ndarray,
|
|
1139
|
+
dt_vec: np.ndarray,
|
|
1140
|
+
product: SnowballOption,
|
|
1141
|
+
pricing_env: PricingEnvironment,
|
|
1142
|
+
r: float,
|
|
1143
|
+
q: float,
|
|
1144
|
+
sigma: float,
|
|
1145
|
+
tau: float,
|
|
1146
|
+
) -> None:
|
|
1147
|
+
"""
|
|
1148
|
+
Backward time stepping for both V0 and V1 surfaces.
|
|
1149
|
+
|
|
1150
|
+
At each time step:
|
|
1151
|
+
1. Step both surfaces backward using Crank-Nicolson
|
|
1152
|
+
2. Apply boundary conditions
|
|
1153
|
+
3. Apply KO jump (if observation time)
|
|
1154
|
+
4. Apply KI jump (if observation time or continuous)
|
|
1155
|
+
"""
|
|
1156
|
+
params: PDEParams = self.params
|
|
1157
|
+
num_t, num_x = len(t_vec), len(x_vec)
|
|
1158
|
+
profile = self._profile_enabled
|
|
1159
|
+
timings = self._profile_stats
|
|
1160
|
+
use_banded = params.use_banded_solver
|
|
1161
|
+
n_int = num_x - 2
|
|
1162
|
+
I_int = sp.eye(n_int, format="csc")
|
|
1163
|
+
self._matrix_cache.clear()
|
|
1164
|
+
self._banded_cache.clear()
|
|
1165
|
+
|
|
1166
|
+
# Rannacher smoothing indices
|
|
1167
|
+
smooth_js = set()
|
|
1168
|
+
event_theta = params.event_theta
|
|
1169
|
+
event_steps = params.event_rannacher_steps
|
|
1170
|
+
if params.use_rannacher and params.auto_grid and params.rannacher_at_events:
|
|
1171
|
+
event_times = self._get_event_times(product, tau)
|
|
1172
|
+
if event_times and event_steps > 0:
|
|
1173
|
+
for et in event_times:
|
|
1174
|
+
idx = int(np.argmin(np.abs(t_vec - et)))
|
|
1175
|
+
if 0 < idx < num_t - 1 and is_close(float(t_vec[idx]), float(et)):
|
|
1176
|
+
for k in range(event_steps):
|
|
1177
|
+
smooth_idx = idx - 1 - k
|
|
1178
|
+
if smooth_idx >= 0:
|
|
1179
|
+
smooth_js.add(smooth_idx)
|
|
1180
|
+
|
|
1181
|
+
rhs = None
|
|
1182
|
+
rhs_v0 = None
|
|
1183
|
+
rhs_v1 = None
|
|
1184
|
+
if use_banded and n_int > 2:
|
|
1185
|
+
rhs = np.empty((n_int, 2))
|
|
1186
|
+
rhs_v0 = rhs[:, 0]
|
|
1187
|
+
rhs_v1 = rhs[:, 1]
|
|
1188
|
+
|
|
1189
|
+
for j in range(num_t - 2, -1, -1):
|
|
1190
|
+
dt = dt_vec[j]
|
|
1191
|
+
steps_from_end = num_t - 1 - j
|
|
1192
|
+
current_time = t_vec[j]
|
|
1193
|
+
tau_remaining = tau - current_time
|
|
1194
|
+
|
|
1195
|
+
# Determine theta (Rannacher smoothing uses backward Euler)
|
|
1196
|
+
theta = params.theta
|
|
1197
|
+
if params.use_rannacher and steps_from_end < params.rannacher_steps:
|
|
1198
|
+
theta = 1.0
|
|
1199
|
+
elif j in smooth_js:
|
|
1200
|
+
theta = event_theta
|
|
1201
|
+
|
|
1202
|
+
# Set boundary conditions for both surfaces
|
|
1203
|
+
if profile:
|
|
1204
|
+
t0 = perf_counter()
|
|
1205
|
+
self._set_boundary_conditions_v0(
|
|
1206
|
+
grid_v0, x_vec, s_vec, j, tau_remaining, product, pricing_env
|
|
1207
|
+
)
|
|
1208
|
+
self._set_boundary_conditions_v1(
|
|
1209
|
+
grid_v1, x_vec, s_vec, j, tau_remaining, product, pricing_env
|
|
1210
|
+
)
|
|
1211
|
+
if profile:
|
|
1212
|
+
timings["boundary"] += perf_counter() - t0
|
|
1213
|
+
|
|
1214
|
+
if use_banded and n_int > 2:
|
|
1215
|
+
if profile:
|
|
1216
|
+
t0 = perf_counter()
|
|
1217
|
+
banded, lower1, main1, upper1 = self._get_banded_system(
|
|
1218
|
+
l, c, u, dt, theta
|
|
1219
|
+
)
|
|
1220
|
+
if profile:
|
|
1221
|
+
timings["matrix_build"] += perf_counter() - t0
|
|
1222
|
+
|
|
1223
|
+
v0_next = grid_v0[1:-1, j + 1]
|
|
1224
|
+
v1_next = grid_v1[1:-1, j + 1]
|
|
1225
|
+
|
|
1226
|
+
if profile:
|
|
1227
|
+
t0 = perf_counter()
|
|
1228
|
+
np.multiply(main1, v0_next, out=rhs_v0)
|
|
1229
|
+
rhs_v0[1:] += lower1 * v0_next[:-1]
|
|
1230
|
+
rhs_v0[:-1] += upper1 * v0_next[1:]
|
|
1231
|
+
self._inject_boundary_contributions(rhs_v0, grid_v0, l, u, j, dt, theta)
|
|
1232
|
+
|
|
1233
|
+
np.multiply(main1, v1_next, out=rhs_v1)
|
|
1234
|
+
rhs_v1[1:] += lower1 * v1_next[:-1]
|
|
1235
|
+
rhs_v1[:-1] += upper1 * v1_next[1:]
|
|
1236
|
+
self._inject_boundary_contributions(rhs_v1, grid_v1, l, u, j, dt, theta)
|
|
1237
|
+
if profile:
|
|
1238
|
+
timings["rhs"] += perf_counter() - t0
|
|
1239
|
+
|
|
1240
|
+
if profile:
|
|
1241
|
+
t0 = perf_counter()
|
|
1242
|
+
sol = solve_banded(
|
|
1243
|
+
(1, 1),
|
|
1244
|
+
banded,
|
|
1245
|
+
rhs,
|
|
1246
|
+
overwrite_b=True,
|
|
1247
|
+
check_finite=False,
|
|
1248
|
+
)
|
|
1249
|
+
if profile:
|
|
1250
|
+
timings["solve"] += perf_counter() - t0
|
|
1251
|
+
grid_v0[1:-1, j] = sol[:, 0]
|
|
1252
|
+
grid_v1[1:-1, j] = sol[:, 1]
|
|
1253
|
+
else:
|
|
1254
|
+
if profile:
|
|
1255
|
+
t0 = perf_counter()
|
|
1256
|
+
M1, M2_lu = self._get_matrices(I_int, A, dt, theta)
|
|
1257
|
+
if profile:
|
|
1258
|
+
timings["matrix_build"] += perf_counter() - t0
|
|
1259
|
+
|
|
1260
|
+
if profile:
|
|
1261
|
+
t0 = perf_counter()
|
|
1262
|
+
rhs_v0 = M1 @ grid_v0[1:-1, j + 1]
|
|
1263
|
+
self._inject_boundary_contributions(rhs_v0, grid_v0, l, u, j, dt, theta)
|
|
1264
|
+
|
|
1265
|
+
rhs_v1 = M1 @ grid_v1[1:-1, j + 1]
|
|
1266
|
+
self._inject_boundary_contributions(rhs_v1, grid_v1, l, u, j, dt, theta)
|
|
1267
|
+
if profile:
|
|
1268
|
+
timings["rhs"] += perf_counter() - t0
|
|
1269
|
+
|
|
1270
|
+
if profile:
|
|
1271
|
+
t0 = perf_counter()
|
|
1272
|
+
grid_v0[1:-1, j] = M2_lu.solve(rhs_v0)
|
|
1273
|
+
grid_v1[1:-1, j] = M2_lu.solve(rhs_v1)
|
|
1274
|
+
if profile:
|
|
1275
|
+
timings["solve"] += perf_counter() - t0
|
|
1276
|
+
|
|
1277
|
+
# Apply barrier modifications
|
|
1278
|
+
if profile:
|
|
1279
|
+
t0 = perf_counter()
|
|
1280
|
+
self._apply_step_modifications_two_surface(
|
|
1281
|
+
grid_v0, grid_v1, x_vec, s_vec, j, tau_remaining, product, pricing_env
|
|
1282
|
+
)
|
|
1283
|
+
if profile:
|
|
1284
|
+
timings["barrier"] += perf_counter() - t0
|
|
1285
|
+
|
|
1286
|
+
def _get_banded_system(
|
|
1287
|
+
self,
|
|
1288
|
+
l: np.ndarray,
|
|
1289
|
+
c: np.ndarray,
|
|
1290
|
+
u: np.ndarray,
|
|
1291
|
+
dt: float,
|
|
1292
|
+
theta: float,
|
|
1293
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
1294
|
+
if not self._is_cache_enabled():
|
|
1295
|
+
lower = -theta * dt * l[2:-1]
|
|
1296
|
+
main = 1.0 - theta * dt * c[1:-1]
|
|
1297
|
+
upper = -theta * dt * u[1:-2]
|
|
1298
|
+
|
|
1299
|
+
banded = np.zeros((3, len(main)))
|
|
1300
|
+
banded[0, 1:] = upper
|
|
1301
|
+
banded[1, :] = main
|
|
1302
|
+
banded[2, :-1] = lower
|
|
1303
|
+
|
|
1304
|
+
lower1 = (1.0 - theta) * dt * l[2:-1]
|
|
1305
|
+
main1 = 1.0 + (1.0 - theta) * dt * c[1:-1]
|
|
1306
|
+
upper1 = (1.0 - theta) * dt * u[1:-2]
|
|
1307
|
+
return banded, lower1, main1, upper1
|
|
1308
|
+
|
|
1309
|
+
key = (round(dt, 12), round(theta, 12))
|
|
1310
|
+
cached = self._banded_cache.get(key)
|
|
1311
|
+
if cached is not None:
|
|
1312
|
+
self._banded_cache.move_to_end(key)
|
|
1313
|
+
return cached
|
|
1314
|
+
|
|
1315
|
+
lower = -theta * dt * l[2:-1]
|
|
1316
|
+
main = 1.0 - theta * dt * c[1:-1]
|
|
1317
|
+
upper = -theta * dt * u[1:-2]
|
|
1318
|
+
|
|
1319
|
+
banded = np.zeros((3, len(main)))
|
|
1320
|
+
banded[0, 1:] = upper
|
|
1321
|
+
banded[1, :] = main
|
|
1322
|
+
banded[2, :-1] = lower
|
|
1323
|
+
|
|
1324
|
+
lower1 = (1.0 - theta) * dt * l[2:-1]
|
|
1325
|
+
main1 = 1.0 + (1.0 - theta) * dt * c[1:-1]
|
|
1326
|
+
upper1 = (1.0 - theta) * dt * u[1:-2]
|
|
1327
|
+
|
|
1328
|
+
self._banded_cache[key] = (banded, lower1, main1, upper1)
|
|
1329
|
+
self._banded_cache.move_to_end(key)
|
|
1330
|
+
if len(self._banded_cache) > self._banded_cache_max_entries:
|
|
1331
|
+
self._banded_cache.popitem(last=False)
|
|
1332
|
+
return banded, lower1, main1, upper1
|
|
1333
|
+
|
|
1334
|
+
def _set_boundary_conditions_v0(
|
|
1335
|
+
self,
|
|
1336
|
+
grid: np.ndarray,
|
|
1337
|
+
x_vec: np.ndarray,
|
|
1338
|
+
s_vec: np.ndarray,
|
|
1339
|
+
t_idx: int,
|
|
1340
|
+
tau: float,
|
|
1341
|
+
product: SnowballOption,
|
|
1342
|
+
pricing_env: PricingEnvironment,
|
|
1343
|
+
) -> None:
|
|
1344
|
+
"""Set boundary conditions for V0 surface."""
|
|
1345
|
+
current_time = self._total_tau - tau
|
|
1346
|
+
df_to_maturity = self._df_between_times(
|
|
1347
|
+
pricing_env, current_time, self._total_tau
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
principal_per_contract = product.initial_price * product.contract_multiplier
|
|
1351
|
+
principal = (
|
|
1352
|
+
principal_per_contract if product.payoff_config.include_principal else 0.0
|
|
1353
|
+
)
|
|
1354
|
+
rebate = product.payoff_config.rebate_rate * principal_per_contract
|
|
1355
|
+
|
|
1356
|
+
# Lower boundary (S -> 0)
|
|
1357
|
+
# For V0, if continuous KI, it will transition to V1
|
|
1358
|
+
# Otherwise, discounted principal + rebate
|
|
1359
|
+
if self._ki_continuous and product.has_ki_barrier:
|
|
1360
|
+
# Will be overwritten by KI jump
|
|
1361
|
+
grid[0, t_idx] = (
|
|
1362
|
+
self._grid_v1[0, t_idx] if self._grid_v1 is not None else 0.0
|
|
1363
|
+
)
|
|
1364
|
+
else:
|
|
1365
|
+
grid[0, t_idx] = (principal + rebate) * df_to_maturity
|
|
1366
|
+
|
|
1367
|
+
# Upper boundary (S -> ∞)
|
|
1368
|
+
# Check if above all KO barriers - if so, value is KO payoff
|
|
1369
|
+
max_ko_barrier = self._get_max_ko_barrier(product)
|
|
1370
|
+
if s_vec[-1] >= max_ko_barrier:
|
|
1371
|
+
# Use current KO payoff
|
|
1372
|
+
ko_payoff = self._get_ko_payoff_at_time(
|
|
1373
|
+
product, pricing_env, current_time, t_idx
|
|
1374
|
+
)
|
|
1375
|
+
grid[-1, t_idx] = ko_payoff
|
|
1376
|
+
else:
|
|
1377
|
+
if (
|
|
1378
|
+
self.params.boundary_mode == "asymptotic"
|
|
1379
|
+
and product.payoff_config.call_rebate_enabled
|
|
1380
|
+
and product.payoff_config.call_strike is not None
|
|
1381
|
+
):
|
|
1382
|
+
df, df_div = self._get_asymptotic_discount_factors(pricing_env, tau)
|
|
1383
|
+
participation = (
|
|
1384
|
+
product.payoff_config.call_participation_rate
|
|
1385
|
+
* product.contract_multiplier
|
|
1386
|
+
)
|
|
1387
|
+
tenor_factor = (
|
|
1388
|
+
product.get_contract_tenor(pricing_env)
|
|
1389
|
+
if product.accrual_config.is_annualized_rebate
|
|
1390
|
+
else 1.0
|
|
1391
|
+
)
|
|
1392
|
+
participation *= tenor_factor
|
|
1393
|
+
strike = product.payoff_config.call_strike
|
|
1394
|
+
grid[-1, t_idx] = (
|
|
1395
|
+
principal * df
|
|
1396
|
+
+ participation * (s_vec[-1] * df_div - strike * df)
|
|
1397
|
+
)
|
|
1398
|
+
else:
|
|
1399
|
+
# Deep OTM: principal + rebate (discounted)
|
|
1400
|
+
grid[-1, t_idx] = (principal + rebate) * df_to_maturity
|
|
1401
|
+
|
|
1402
|
+
def _set_boundary_conditions_v1(
|
|
1403
|
+
self,
|
|
1404
|
+
grid: np.ndarray,
|
|
1405
|
+
x_vec: np.ndarray,
|
|
1406
|
+
s_vec: np.ndarray,
|
|
1407
|
+
t_idx: int,
|
|
1408
|
+
tau: float,
|
|
1409
|
+
product: SnowballOption,
|
|
1410
|
+
pricing_env: PricingEnvironment,
|
|
1411
|
+
) -> None:
|
|
1412
|
+
"""Set boundary conditions for V1 surface."""
|
|
1413
|
+
current_time = self._total_tau - tau
|
|
1414
|
+
df_to_maturity = self._df_between_times(
|
|
1415
|
+
pricing_env, current_time, self._total_tau
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
principal_per_contract = product.initial_price * product.contract_multiplier
|
|
1419
|
+
principal = (
|
|
1420
|
+
principal_per_contract if product.payoff_config.include_principal else 0.0
|
|
1421
|
+
)
|
|
1422
|
+
strike = product.strike
|
|
1423
|
+
initial_price = product.initial_price
|
|
1424
|
+
participation = product.payoff_config.participation_rate
|
|
1425
|
+
|
|
1426
|
+
# Lower boundary (S -> 0)
|
|
1427
|
+
# Deep ITM put for standard snowball: maximum loss
|
|
1428
|
+
if product.is_reverse:
|
|
1429
|
+
# Reverse: embedded call, S=0 means no loss
|
|
1430
|
+
grid[0, t_idx] = principal * df_to_maturity
|
|
1431
|
+
else:
|
|
1432
|
+
if (
|
|
1433
|
+
self.params.boundary_mode == "asymptotic"
|
|
1434
|
+
and product.payoff_config.protection_type == ProtectionType.NONE
|
|
1435
|
+
):
|
|
1436
|
+
df, df_div = self._get_asymptotic_discount_factors(pricing_env, tau)
|
|
1437
|
+
effective_strike = strike
|
|
1438
|
+
effective_participation = participation
|
|
1439
|
+
airbag = product.airbag_config
|
|
1440
|
+
if airbag.airbag_barrier is not None and s_vec[0] < airbag.airbag_barrier:
|
|
1441
|
+
effective_participation = airbag.airbag_participation_rate
|
|
1442
|
+
if airbag.airbag_strike is not None:
|
|
1443
|
+
effective_strike = airbag.airbag_strike
|
|
1444
|
+
|
|
1445
|
+
slope = effective_participation * product.contract_multiplier
|
|
1446
|
+
grid[0, t_idx] = (
|
|
1447
|
+
principal * df
|
|
1448
|
+
+ slope * (s_vec[0] * df_div - effective_strike * df)
|
|
1449
|
+
)
|
|
1450
|
+
else:
|
|
1451
|
+
# Standard: embedded put, S=0 means maximum loss
|
|
1452
|
+
# Loss = participation × (-K/S0) × N
|
|
1453
|
+
max_loss = participation * (-strike / initial_price) * principal_per_contract
|
|
1454
|
+
# Apply protection floor if applicable
|
|
1455
|
+
if product.payoff_config.protection_type.name == "FULL":
|
|
1456
|
+
max_loss = 0.0
|
|
1457
|
+
elif product.payoff_config.protection_type.name == "PARTIAL":
|
|
1458
|
+
floor = -product.payoff_config.protection_rate * principal_per_contract
|
|
1459
|
+
max_loss = max(max_loss, floor)
|
|
1460
|
+
grid[0, t_idx] = (principal + max_loss) * df_to_maturity
|
|
1461
|
+
|
|
1462
|
+
# Upper boundary (S -> ∞)
|
|
1463
|
+
# For standard: no loss (put is worthless)
|
|
1464
|
+
# For reverse: maximum loss (call is deep ITM)
|
|
1465
|
+
if product.is_reverse:
|
|
1466
|
+
# For very high S, reverse payoff depends on protection type.
|
|
1467
|
+
protection = product.payoff_config.protection_type
|
|
1468
|
+
if protection == ProtectionType.NONE:
|
|
1469
|
+
df, df_div = self._get_asymptotic_discount_factors(pricing_env, tau)
|
|
1470
|
+
participation = product.payoff_config.participation_rate
|
|
1471
|
+
effective_strike = strike
|
|
1472
|
+
airbag = product.airbag_config
|
|
1473
|
+
if airbag.airbag_barrier is not None and s_vec[-1] > airbag.airbag_barrier:
|
|
1474
|
+
participation = airbag.airbag_participation_rate
|
|
1475
|
+
if airbag.airbag_strike is not None:
|
|
1476
|
+
effective_strike = airbag.airbag_strike
|
|
1477
|
+
|
|
1478
|
+
slope = participation * product.contract_multiplier
|
|
1479
|
+
grid[-1, t_idx] = (
|
|
1480
|
+
(principal + slope * effective_strike) * df
|
|
1481
|
+
- slope * s_vec[-1] * df_div
|
|
1482
|
+
)
|
|
1483
|
+
else:
|
|
1484
|
+
if protection == ProtectionType.PARTIAL:
|
|
1485
|
+
floor = (
|
|
1486
|
+
product.payoff_config.protection_rate
|
|
1487
|
+
* product.initial_price
|
|
1488
|
+
* product.contract_multiplier
|
|
1489
|
+
)
|
|
1490
|
+
grid[-1, t_idx] = (principal - floor) * df_to_maturity
|
|
1491
|
+
else:
|
|
1492
|
+
grid[-1, t_idx] = principal * df_to_maturity
|
|
1493
|
+
else:
|
|
1494
|
+
# Put is worthless at high S
|
|
1495
|
+
grid[-1, t_idx] = principal * df_to_maturity
|
|
1496
|
+
|
|
1497
|
+
def _apply_step_modifications_two_surface(
|
|
1498
|
+
self,
|
|
1499
|
+
grid_v0: np.ndarray,
|
|
1500
|
+
grid_v1: np.ndarray,
|
|
1501
|
+
x_vec: np.ndarray,
|
|
1502
|
+
s_vec: np.ndarray,
|
|
1503
|
+
t_idx: int,
|
|
1504
|
+
tau: float,
|
|
1505
|
+
product: SnowballOption,
|
|
1506
|
+
pricing_env: PricingEnvironment,
|
|
1507
|
+
) -> None:
|
|
1508
|
+
"""
|
|
1509
|
+
Apply barrier modifications to both surfaces at a time step.
|
|
1510
|
+
|
|
1511
|
+
Order of operations:
|
|
1512
|
+
1. Apply KO jump to both surfaces (KO takes precedence)
|
|
1513
|
+
2. Apply KI jump: V0 <- V1 in breached region
|
|
1514
|
+
"""
|
|
1515
|
+
current_time = self._total_tau - tau
|
|
1516
|
+
|
|
1517
|
+
# 1. Apply KO jump if this is a KO observation time
|
|
1518
|
+
ko_record = self._ko_observation_indices.get(t_idx)
|
|
1519
|
+
if ko_record is not None:
|
|
1520
|
+
self._apply_ko_jump(
|
|
1521
|
+
grid_v0,
|
|
1522
|
+
grid_v1,
|
|
1523
|
+
s_vec,
|
|
1524
|
+
t_idx,
|
|
1525
|
+
current_time,
|
|
1526
|
+
product,
|
|
1527
|
+
pricing_env,
|
|
1528
|
+
ko_record,
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
# 2. Apply KI jump
|
|
1532
|
+
# For continuous KI: apply at every time step
|
|
1533
|
+
# For discrete KI: apply only at observation times
|
|
1534
|
+
if product.has_ki_barrier:
|
|
1535
|
+
should_apply_ki = (
|
|
1536
|
+
self._ki_continuous or t_idx in self._ki_observation_indices
|
|
1537
|
+
)
|
|
1538
|
+
if should_apply_ki:
|
|
1539
|
+
self._apply_ki_jump(grid_v0, grid_v1, s_vec, t_idx, product)
|
|
1540
|
+
|
|
1541
|
+
def _apply_ko_jump(
|
|
1542
|
+
self,
|
|
1543
|
+
grid_v0: np.ndarray,
|
|
1544
|
+
grid_v1: np.ndarray,
|
|
1545
|
+
s_vec: np.ndarray,
|
|
1546
|
+
t_idx: int,
|
|
1547
|
+
current_time: float,
|
|
1548
|
+
product: SnowballOption,
|
|
1549
|
+
pricing_env: PricingEnvironment,
|
|
1550
|
+
ko_record: ResolvedObservationRecord,
|
|
1551
|
+
) -> None:
|
|
1552
|
+
"""Apply KO payoff to both surfaces in the breached region."""
|
|
1553
|
+
barrier = ko_record.barrier
|
|
1554
|
+
payoff = ko_record.payoff if ko_record.payoff is not None else 0.0
|
|
1555
|
+
|
|
1556
|
+
# Discount payoff based on settlement time
|
|
1557
|
+
cashflow_value = self._cashflow_value_at_time(
|
|
1558
|
+
pricing_env=pricing_env,
|
|
1559
|
+
cashflow=payoff,
|
|
1560
|
+
current_time=current_time,
|
|
1561
|
+
settlement_time=ko_record.settlement_time,
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
# Determine breached region (KO is an UP barrier)
|
|
1565
|
+
mask = self._get_barrier_mask(s_vec, barrier, product.is_reverse, is_up_barrier=True)
|
|
1566
|
+
|
|
1567
|
+
# Apply to both surfaces
|
|
1568
|
+
grid_v0[mask, t_idx] = cashflow_value
|
|
1569
|
+
grid_v1[mask, t_idx] = cashflow_value
|
|
1570
|
+
|
|
1571
|
+
def _apply_ki_jump(
|
|
1572
|
+
self,
|
|
1573
|
+
grid_v0: np.ndarray,
|
|
1574
|
+
grid_v1: np.ndarray,
|
|
1575
|
+
s_vec: np.ndarray,
|
|
1576
|
+
t_idx: int,
|
|
1577
|
+
product: SnowballOption,
|
|
1578
|
+
) -> None:
|
|
1579
|
+
"""
|
|
1580
|
+
Apply KI jump: V0 <- V1 in the breached region.
|
|
1581
|
+
|
|
1582
|
+
When the KI barrier is hit, the "not knocked-in" value becomes
|
|
1583
|
+
the "knocked-in" value at that spot.
|
|
1584
|
+
"""
|
|
1585
|
+
ki_barrier = self._resolve_ki_barrier_at_tidx(t_idx)
|
|
1586
|
+
|
|
1587
|
+
# Determine breached region (KI is a DOWN barrier)
|
|
1588
|
+
mask = self._get_barrier_mask(s_vec, ki_barrier, product.is_reverse, is_up_barrier=False)
|
|
1589
|
+
|
|
1590
|
+
# V0 transitions to V1 in breached region
|
|
1591
|
+
grid_v0[mask, t_idx] = grid_v1[mask, t_idx]
|
|
1592
|
+
|
|
1593
|
+
def _get_max_ko_barrier(self, product: SnowballOption) -> float:
|
|
1594
|
+
"""Get the maximum KO barrier level."""
|
|
1595
|
+
ko_barrier = product.barrier_config.ko_barrier
|
|
1596
|
+
if isinstance(ko_barrier, list):
|
|
1597
|
+
return max(ko_barrier)
|
|
1598
|
+
return ko_barrier
|
|
1599
|
+
|
|
1600
|
+
def _get_ko_payoff_at_time(
|
|
1601
|
+
self,
|
|
1602
|
+
product: SnowballOption,
|
|
1603
|
+
pricing_env: PricingEnvironment,
|
|
1604
|
+
current_time: float,
|
|
1605
|
+
t_idx: int,
|
|
1606
|
+
) -> float:
|
|
1607
|
+
"""Get KO payoff for current time/index."""
|
|
1608
|
+
ko_record = self._ko_observation_indices.get(t_idx)
|
|
1609
|
+
if ko_record is not None:
|
|
1610
|
+
payoff = ko_record.payoff if ko_record.payoff is not None else 0.0
|
|
1611
|
+
return self._cashflow_value_at_time(
|
|
1612
|
+
pricing_env=pricing_env,
|
|
1613
|
+
cashflow=payoff,
|
|
1614
|
+
current_time=current_time,
|
|
1615
|
+
settlement_time=ko_record.settlement_time,
|
|
1616
|
+
)
|
|
1617
|
+
|
|
1618
|
+
# Boundary fallback: use next scheduled KO record (ignore past observations).
|
|
1619
|
+
ko_records = product.resolve_ko_observations(pricing_env)
|
|
1620
|
+
future_records = [
|
|
1621
|
+
rec for rec in ko_records if self._record_is_non_negative_time(rec)
|
|
1622
|
+
]
|
|
1623
|
+
if not future_records:
|
|
1624
|
+
return 0.0
|
|
1625
|
+
|
|
1626
|
+
next_rec: Optional[ResolvedObservationRecord] = None
|
|
1627
|
+
for rec in future_records:
|
|
1628
|
+
if rec.observation_time >= current_time or is_close(
|
|
1629
|
+
rec.observation_time, current_time
|
|
1630
|
+
):
|
|
1631
|
+
next_rec = rec
|
|
1632
|
+
break
|
|
1633
|
+
next_rec = next_rec if next_rec is not None else future_records[-1]
|
|
1634
|
+
return self._cashflow_value_at_time(
|
|
1635
|
+
pricing_env=pricing_env,
|
|
1636
|
+
cashflow=next_rec.payoff or 0.0,
|
|
1637
|
+
current_time=current_time,
|
|
1638
|
+
settlement_time=next_rec.settlement_time,
|
|
1639
|
+
)
|
|
1640
|
+
|
|
1641
|
+
@staticmethod
|
|
1642
|
+
def _df_between_times(
|
|
1643
|
+
pricing_env: PricingEnvironment, start_time: float, end_time: float
|
|
1644
|
+
) -> float:
|
|
1645
|
+
"""Calculate discount factor between two times."""
|
|
1646
|
+
if end_time <= start_time:
|
|
1647
|
+
return 1.0
|
|
1648
|
+
df_end = pricing_env.get_discount_factor(end_time)
|
|
1649
|
+
df_start = pricing_env.get_discount_factor(start_time)
|
|
1650
|
+
return float(safe_divide(df_end, df_start, fallback=1.0))
|
|
1651
|
+
|
|
1652
|
+
@staticmethod
|
|
1653
|
+
def _get_asymptotic_discount_factors(
|
|
1654
|
+
pricing_env: PricingEnvironment, tau_to_maturity: float
|
|
1655
|
+
) -> Tuple[float, float]:
|
|
1656
|
+
"""
|
|
1657
|
+
Get risk-free and dividend discount factors for asymptotic boundary conditions.
|
|
1658
|
+
|
|
1659
|
+
This helper consolidates the repeated pattern of computing discount factors
|
|
1660
|
+
for boundary conditions when using asymptotic mode.
|
|
1661
|
+
|
|
1662
|
+
Args:
|
|
1663
|
+
pricing_env: Pricing environment with rate curves
|
|
1664
|
+
tau_to_maturity: Time to maturity
|
|
1665
|
+
|
|
1666
|
+
Returns:
|
|
1667
|
+
Tuple of (risk_free_df, dividend_df)
|
|
1668
|
+
"""
|
|
1669
|
+
if tau_to_maturity <= 0:
|
|
1670
|
+
return 1.0, 1.0
|
|
1671
|
+
r = pricing_env.get_rate(tau_to_maturity)
|
|
1672
|
+
q = pricing_env.get_div_yield(tau_to_maturity)
|
|
1673
|
+
df = np.exp(-r * tau_to_maturity)
|
|
1674
|
+
df_div = np.exp(-q * tau_to_maturity)
|
|
1675
|
+
return float(df), float(df_div)
|
|
1676
|
+
|
|
1677
|
+
def _cashflow_value_at_time(
|
|
1678
|
+
self,
|
|
1679
|
+
pricing_env: PricingEnvironment,
|
|
1680
|
+
cashflow: float,
|
|
1681
|
+
current_time: float,
|
|
1682
|
+
settlement_time: Optional[float],
|
|
1683
|
+
) -> float:
|
|
1684
|
+
"""Discount a cashflow from settlement time to current time."""
|
|
1685
|
+
if settlement_time is None or settlement_time <= current_time:
|
|
1686
|
+
return float(cashflow)
|
|
1687
|
+
df = self._df_between_times(pricing_env, current_time, settlement_time)
|
|
1688
|
+
return float(cashflow) * df
|
|
1689
|
+
|
|
1690
|
+
# Override abstract methods from base class (not used for two-surface)
|
|
1691
|
+
def set_terminal_condition(
|
|
1692
|
+
self,
|
|
1693
|
+
grid: np.ndarray,
|
|
1694
|
+
x_vec: np.ndarray,
|
|
1695
|
+
s_vec: np.ndarray,
|
|
1696
|
+
product: BaseEquityProduct,
|
|
1697
|
+
pricing_env: PricingEnvironment,
|
|
1698
|
+
) -> None:
|
|
1699
|
+
"""Not used - two-surface solver has separate terminal conditions."""
|
|
1700
|
+
pass
|
|
1701
|
+
|
|
1702
|
+
def set_boundary_conditions(
|
|
1703
|
+
self,
|
|
1704
|
+
grid: np.ndarray,
|
|
1705
|
+
x_vec: np.ndarray,
|
|
1706
|
+
s_vec: np.ndarray,
|
|
1707
|
+
t_idx: int,
|
|
1708
|
+
tau: float,
|
|
1709
|
+
product: BaseEquityProduct,
|
|
1710
|
+
pricing_env: PricingEnvironment,
|
|
1711
|
+
) -> None:
|
|
1712
|
+
"""Not used - two-surface solver has separate boundary conditions."""
|
|
1713
|
+
pass
|
|
1714
|
+
|
|
1715
|
+
def __repr__(self) -> str:
|
|
1716
|
+
return "SnowballPDESolver()"
|
|
1717
|
+
|
|
1718
|
+
def _grid_cache_key(
|
|
1719
|
+
self,
|
|
1720
|
+
product: BaseEquityProduct,
|
|
1721
|
+
pricing_env: PricingEnvironment,
|
|
1722
|
+
spot: float,
|
|
1723
|
+
sigma: float,
|
|
1724
|
+
tau: float,
|
|
1725
|
+
r: float,
|
|
1726
|
+
q: float,
|
|
1727
|
+
) -> Tuple:
|
|
1728
|
+
base_key = super()._grid_cache_key(
|
|
1729
|
+
product, pricing_env, spot, sigma, tau, r, q
|
|
1730
|
+
)
|
|
1731
|
+
if not hasattr(product, "barrier_config"):
|
|
1732
|
+
return base_key
|
|
1733
|
+
|
|
1734
|
+
ko_records = self._get_cached_ko_records(pricing_env, product)
|
|
1735
|
+
ko_key = tuple(
|
|
1736
|
+
sorted(
|
|
1737
|
+
(
|
|
1738
|
+
round(rec.observation_time, 12),
|
|
1739
|
+
round(rec.barrier if rec.barrier is not None else 0.0, 12),
|
|
1740
|
+
)
|
|
1741
|
+
for rec in ko_records
|
|
1742
|
+
)
|
|
1743
|
+
)
|
|
1744
|
+
|
|
1745
|
+
ki_key = ()
|
|
1746
|
+
ki_continuous = (
|
|
1747
|
+
product.barrier_config.ki_continuous
|
|
1748
|
+
or product.barrier_config.ki_observation_type == ObservationType.CONTINUOUS
|
|
1749
|
+
)
|
|
1750
|
+
if product.has_ki_barrier:
|
|
1751
|
+
ki_profile = self._get_cached_ki_profile(pricing_env, product)
|
|
1752
|
+
ki_barriers = tuple(
|
|
1753
|
+
round(float(b), 12) for b in (ki_profile.get("barriers") or [])
|
|
1754
|
+
)
|
|
1755
|
+
ki_times = tuple(
|
|
1756
|
+
round(float(t), 12)
|
|
1757
|
+
for t in (ki_profile.get("observation_times") or [])
|
|
1758
|
+
if 0.0 <= float(t) <= tau
|
|
1759
|
+
)
|
|
1760
|
+
ki_key = (ki_continuous, ki_barriers, ki_times)
|
|
1761
|
+
|
|
1762
|
+
return base_key + (ko_key, ki_key)
|
|
1763
|
+
|
|
1764
|
+
def _observation_cache_key(
|
|
1765
|
+
self, pricing_env: PricingEnvironment, product: SnowballOption, kind: str
|
|
1766
|
+
) -> Tuple:
|
|
1767
|
+
strategy = self._resolve_cache_strategy()
|
|
1768
|
+
return (
|
|
1769
|
+
kind,
|
|
1770
|
+
strategy,
|
|
1771
|
+
f"{product.__class__.__module__}.{product.__class__.__qualname__}",
|
|
1772
|
+
self._product_cache_token(product, strategy),
|
|
1773
|
+
pricing_env.valuation_date,
|
|
1774
|
+
pricing_env.day_count_convention,
|
|
1775
|
+
pricing_env.bus_days_in_year,
|
|
1776
|
+
)
|
|
1777
|
+
|
|
1778
|
+
def _get_cached_ko_records(
|
|
1779
|
+
self, pricing_env: PricingEnvironment, product: SnowballOption
|
|
1780
|
+
) -> List[ResolvedObservationRecord]:
|
|
1781
|
+
if not self._is_cache_enabled():
|
|
1782
|
+
return product.resolve_ko_observations(pricing_env)
|
|
1783
|
+
key = self._observation_cache_key(pricing_env, product, "ko")
|
|
1784
|
+
cached = self._ko_records_cache.get(key)
|
|
1785
|
+
if cached is not None:
|
|
1786
|
+
self._ko_records_cache.move_to_end(key)
|
|
1787
|
+
return cached
|
|
1788
|
+
records = product.resolve_ko_observations(pricing_env)
|
|
1789
|
+
self._ko_records_cache[key] = records
|
|
1790
|
+
self._ko_records_cache.move_to_end(key)
|
|
1791
|
+
if len(self._ko_records_cache) > self.params.grid_cache_max_entries:
|
|
1792
|
+
self._ko_records_cache.popitem(last=False)
|
|
1793
|
+
return records
|
|
1794
|
+
|
|
1795
|
+
def _get_cached_ki_profile(
|
|
1796
|
+
self, pricing_env: PricingEnvironment, product: SnowballOption
|
|
1797
|
+
) -> Dict[str, List[Optional[float]]]:
|
|
1798
|
+
if not self._is_cache_enabled():
|
|
1799
|
+
return product.get_ki_observation_profile(pricing_env)
|
|
1800
|
+
key = self._observation_cache_key(pricing_env, product, "ki")
|
|
1801
|
+
cached = self._ki_profile_cache.get(key)
|
|
1802
|
+
if cached is not None:
|
|
1803
|
+
self._ki_profile_cache.move_to_end(key)
|
|
1804
|
+
return cached
|
|
1805
|
+
profile = product.get_ki_observation_profile(pricing_env)
|
|
1806
|
+
self._ki_profile_cache[key] = profile
|
|
1807
|
+
self._ki_profile_cache.move_to_end(key)
|
|
1808
|
+
if len(self._ki_profile_cache) > self.params.grid_cache_max_entries:
|
|
1809
|
+
self._ki_profile_cache.popitem(last=False)
|
|
1810
|
+
return profile
|