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,2230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bilingual risk comparison report for normalized snowball structures.
|
|
3
|
+
|
|
4
|
+
This module compares matched snowball structures under exact discrete
|
|
5
|
+
observation schedules and produces a bilingual DOCX report.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import importlib.util
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, Iterable, Mapping, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
from docx import Document
|
|
21
|
+
from docx.enum.section import WD_SECTION
|
|
22
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
23
|
+
from docx.shared import Inches, Pt
|
|
24
|
+
|
|
25
|
+
from quantark.asset.equity.engine.base_engine import BaseEngine
|
|
26
|
+
from quantark.asset.equity.engine.mc.snowball_mc_engine import SnowballMCEngine
|
|
27
|
+
from quantark.asset.equity.param import EngineParams, MCParams, PDEParams, QuadParams
|
|
28
|
+
from quantark.asset.equity.product.option.snowball_config import (
|
|
29
|
+
AccrualConfig,
|
|
30
|
+
BarrierConfig,
|
|
31
|
+
PayoffConfig,
|
|
32
|
+
)
|
|
33
|
+
from quantark.asset.equity.product.option.snowball_helpers import (
|
|
34
|
+
create_european_ki_snowball,
|
|
35
|
+
create_parachute_snowball,
|
|
36
|
+
)
|
|
37
|
+
from quantark.asset.equity.product.option.snowball_option import SnowballOption
|
|
38
|
+
from quantark.asset.equity.report.autocallable_risk_report import (
|
|
39
|
+
_barrier_distance_metrics,
|
|
40
|
+
_clone_env,
|
|
41
|
+
_scale_vol_surface,
|
|
42
|
+
_select_snowball_pricing_engine,
|
|
43
|
+
_shift_dividend_yield,
|
|
44
|
+
)
|
|
45
|
+
from quantark.asset.equity.report.plotting import save_heatmap
|
|
46
|
+
from quantark.asset.equity.report.term_structure import SkewSmileVolSurface
|
|
47
|
+
from quantark.asset.equity.riskmeasures.greeks_calculator import GreeksCalculator
|
|
48
|
+
from quantark.param import FlatVolSurface, SpotQuote
|
|
49
|
+
from quantark.param.div import ContinuousDividendYield
|
|
50
|
+
from quantark.param.rrf import FlatRateCurve
|
|
51
|
+
from quantark.priceenv import PricingEnvironment
|
|
52
|
+
from quantark.util.calendar import (
|
|
53
|
+
BusinessDayConvention,
|
|
54
|
+
CalendarType,
|
|
55
|
+
create_calendar,
|
|
56
|
+
)
|
|
57
|
+
from quantark.util.enum import CouponPayType, ObservationType, ProtectionType
|
|
58
|
+
from quantark.util.enum.engine_enums import EngineType, MonteCarloMethod
|
|
59
|
+
from quantark.util.exceptions import ValidationError
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class SnowballRiskComparisonConfig:
|
|
64
|
+
valuation_date: datetime = datetime(2026, 1, 5)
|
|
65
|
+
output_dir: Path = Path("output/doc/snowball_risk_comparison")
|
|
66
|
+
report_filename: str = "snowball_risk_comparison_bilingual.docx"
|
|
67
|
+
bilingual_layout: str = "english_then_chinese"
|
|
68
|
+
initial_price: float = 100.0
|
|
69
|
+
strike: float = 100.0
|
|
70
|
+
tenor_months: int = 36
|
|
71
|
+
ko_start_month: int = 3
|
|
72
|
+
ko_barrier: float = 103.0
|
|
73
|
+
ki_barrier: float = 70.0
|
|
74
|
+
annual_coupon: float = 0.12
|
|
75
|
+
rate: float = 0.02
|
|
76
|
+
dividend_yield: float = 0.10
|
|
77
|
+
volatility: float = 0.22
|
|
78
|
+
business_days_in_year: int = 244
|
|
79
|
+
calendar_type: CalendarType = CalendarType.CHINA
|
|
80
|
+
protection_rate: float = 0.20
|
|
81
|
+
contract_multiplier: float = 1.0
|
|
82
|
+
num_paths: int = 50000
|
|
83
|
+
seed: int = 42
|
|
84
|
+
mc_method: MonteCarloMethod = MonteCarloMethod.QUASI
|
|
85
|
+
mc_use_parallel: bool = True
|
|
86
|
+
mc_num_batches: int = 8
|
|
87
|
+
engine_preference: Sequence[str] = ("quad",)
|
|
88
|
+
quad_params: Optional[QuadParams] = field(
|
|
89
|
+
default_factory=lambda: QuadParams(grid_points=1001)
|
|
90
|
+
)
|
|
91
|
+
pde_params: Optional[PDEParams] = field(default_factory=PDEParams)
|
|
92
|
+
stress_spot_shocks: Sequence[float] = (
|
|
93
|
+
-0.20,
|
|
94
|
+
-0.15,
|
|
95
|
+
-0.10,
|
|
96
|
+
-0.05,
|
|
97
|
+
0.00,
|
|
98
|
+
0.05,
|
|
99
|
+
0.10,
|
|
100
|
+
)
|
|
101
|
+
stress_vol_shocks: Sequence[float] = (-0.10, -0.05, 0.00, 0.05, 0.10)
|
|
102
|
+
stress_div_shifts: Sequence[float] = (-0.02, -0.01, 0.00, 0.03, 0.05)
|
|
103
|
+
stress_skew_shocks: Sequence[float] = (-0.12, -0.06, 0.0, 0.06, 0.12)
|
|
104
|
+
stress_smile_shocks: Sequence[float] = (-0.06, -0.03, 0.0, 0.03, 0.06)
|
|
105
|
+
ladder_spot_shocks: Sequence[float] = (-0.15, -0.10, -0.05, 0.0, 0.05)
|
|
106
|
+
ladder_vol_shocks: Sequence[float] = (-0.05, 0.0, 0.05)
|
|
107
|
+
terminal_cliff_band: float = 0.02
|
|
108
|
+
greek_spot_multipliers: Sequence[float] = (
|
|
109
|
+
0.60,
|
|
110
|
+
0.65,
|
|
111
|
+
0.70,
|
|
112
|
+
0.75,
|
|
113
|
+
0.80,
|
|
114
|
+
0.85,
|
|
115
|
+
0.90,
|
|
116
|
+
0.95,
|
|
117
|
+
1.00,
|
|
118
|
+
1.03,
|
|
119
|
+
1.05,
|
|
120
|
+
1.10,
|
|
121
|
+
1.15,
|
|
122
|
+
1.20,
|
|
123
|
+
)
|
|
124
|
+
greek_key_spots: Sequence[float] = (65.0, 70.0, 75.0, 85.0, 100.0, 103.0)
|
|
125
|
+
greek_q_shifts: Sequence[float] = (-0.02, -0.015, -0.01, -0.005, 0.0, 0.01, 0.02, 0.03, 0.05)
|
|
126
|
+
greek_key_q_shifts: Sequence[float] = (-0.02, 0.0, 0.03, 0.05)
|
|
127
|
+
greek_q_slice_spots: Mapping[str, float] = field(
|
|
128
|
+
default_factory=lambda: {"near_ki": 70.0, "near_ko": 103.0}
|
|
129
|
+
)
|
|
130
|
+
generate_plots: bool = True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class SnowballRiskComparisonArtifacts:
|
|
135
|
+
report_path: Path
|
|
136
|
+
output_dir: Path
|
|
137
|
+
plot_paths: Mapping[str, Path]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class StructuralDeltas:
|
|
142
|
+
delta_monitoring: float
|
|
143
|
+
delta_protection: float
|
|
144
|
+
delta_parachute_dki: float
|
|
145
|
+
delta_parachute_eki: float
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class StructureMetrics:
|
|
150
|
+
label: str
|
|
151
|
+
pv: float
|
|
152
|
+
std_error: float
|
|
153
|
+
expected_life: float
|
|
154
|
+
no_ko_probability: float
|
|
155
|
+
ki_probability: float
|
|
156
|
+
ki_no_ko_probability: float
|
|
157
|
+
loss_prob_5: float
|
|
158
|
+
loss_prob_10: float
|
|
159
|
+
loss_prob_20: float
|
|
160
|
+
es95: float
|
|
161
|
+
es99: float
|
|
162
|
+
rebound_band_probability: float
|
|
163
|
+
rebound_band_loss_probability: float
|
|
164
|
+
parachute_rescue_band_probability: float
|
|
165
|
+
parachute_rescue_loss_probability: float
|
|
166
|
+
terminal_cliff_band_probability: float
|
|
167
|
+
terminal_cliff_loss_probability: float
|
|
168
|
+
conditional_ko_cashflows: pd.DataFrame
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class DeterministicCaseOutcome:
|
|
173
|
+
state: str
|
|
174
|
+
knocked_out: bool
|
|
175
|
+
knocked_in: bool
|
|
176
|
+
payoff: float
|
|
177
|
+
payoff_ratio: float
|
|
178
|
+
terminal_spot: float
|
|
179
|
+
notes: str
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class DeterministicCaseResult:
|
|
184
|
+
name: str
|
|
185
|
+
narrative: str
|
|
186
|
+
anchors: Sequence[tuple[float, float]]
|
|
187
|
+
outcomes: Mapping[str, DeterministicCaseOutcome]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class GreekCurves:
|
|
192
|
+
spot_grid: np.ndarray
|
|
193
|
+
delta: pd.DataFrame
|
|
194
|
+
gamma: pd.DataFrame
|
|
195
|
+
vega: pd.DataFrame
|
|
196
|
+
rhoq: pd.DataFrame
|
|
197
|
+
key_spot_table: pd.DataFrame
|
|
198
|
+
q_grid: np.ndarray
|
|
199
|
+
delta_q_curve: pd.DataFrame
|
|
200
|
+
gamma_q_curve: pd.DataFrame
|
|
201
|
+
vega_q_curve: pd.DataFrame
|
|
202
|
+
rhoq_q_curve: pd.DataFrame
|
|
203
|
+
key_q_table: pd.DataFrame
|
|
204
|
+
q_slice_curves: Mapping[str, Mapping[str, pd.DataFrame]]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass
|
|
208
|
+
class _PathSnapshot:
|
|
209
|
+
metrics: StructureMetrics
|
|
210
|
+
discounted_payoff: np.ndarray
|
|
211
|
+
payoff: np.ndarray
|
|
212
|
+
settlement_times: np.ndarray
|
|
213
|
+
terminal_spots: np.ndarray
|
|
214
|
+
min_spots: np.ndarray
|
|
215
|
+
is_ko: np.ndarray
|
|
216
|
+
is_v0: np.ndarray
|
|
217
|
+
is_v1: np.ndarray
|
|
218
|
+
first_ko_idx: np.ndarray
|
|
219
|
+
first_ki_idx: np.ndarray
|
|
220
|
+
ko_times: np.ndarray
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def build_default_snowball_risk_comparison_config(
|
|
224
|
+
output_dir: Optional[Path] = None,
|
|
225
|
+
) -> SnowballRiskComparisonConfig:
|
|
226
|
+
config = SnowballRiskComparisonConfig()
|
|
227
|
+
if output_dir is None:
|
|
228
|
+
return config
|
|
229
|
+
return SnowballRiskComparisonConfig(output_dir=Path(output_dir))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _add_months(base_date: datetime, months: int) -> datetime:
|
|
233
|
+
year = base_date.year + (base_date.month - 1 + months) // 12
|
|
234
|
+
month = (base_date.month - 1 + months) % 12 + 1
|
|
235
|
+
day = min(
|
|
236
|
+
base_date.day,
|
|
237
|
+
[31, 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28, 31,
|
|
238
|
+
30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1],
|
|
239
|
+
)
|
|
240
|
+
return datetime(year, month, day)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _business_day_time(
|
|
244
|
+
calendar,
|
|
245
|
+
start_date: datetime,
|
|
246
|
+
end_date: datetime,
|
|
247
|
+
bus_days_in_year: int,
|
|
248
|
+
) -> float:
|
|
249
|
+
days = calendar.count_business_days(
|
|
250
|
+
start_date, end_date, include_start=True, include_end=True
|
|
251
|
+
)
|
|
252
|
+
return days / float(bus_days_in_year)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _build_ko_schedule(
|
|
256
|
+
config: SnowballRiskComparisonConfig,
|
|
257
|
+
):
|
|
258
|
+
calendar = create_calendar(
|
|
259
|
+
config.calendar_type,
|
|
260
|
+
year_range=(config.valuation_date.year, config.valuation_date.year + 2),
|
|
261
|
+
)
|
|
262
|
+
ko_dates = []
|
|
263
|
+
for month in range(config.ko_start_month, config.tenor_months + 1):
|
|
264
|
+
raw = _add_months(config.valuation_date, month)
|
|
265
|
+
adj = calendar.adjust_date(raw, BusinessDayConvention.FOLLOWING)
|
|
266
|
+
ko_dates.append(adj)
|
|
267
|
+
maturity_date = ko_dates[-1]
|
|
268
|
+
ko_times = [
|
|
269
|
+
_business_day_time(
|
|
270
|
+
calendar, config.valuation_date, dt, config.business_days_in_year
|
|
271
|
+
)
|
|
272
|
+
for dt in ko_dates
|
|
273
|
+
]
|
|
274
|
+
daily_ki_dates = []
|
|
275
|
+
current = config.valuation_date + timedelta(days=1)
|
|
276
|
+
while current <= maturity_date:
|
|
277
|
+
if calendar.is_business_day(current):
|
|
278
|
+
daily_ki_dates.append(current)
|
|
279
|
+
current += timedelta(days=1)
|
|
280
|
+
daily_ki_times = [
|
|
281
|
+
_business_day_time(
|
|
282
|
+
calendar, config.valuation_date, dt, config.business_days_in_year
|
|
283
|
+
)
|
|
284
|
+
for dt in daily_ki_dates
|
|
285
|
+
]
|
|
286
|
+
maturity_time = _business_day_time(
|
|
287
|
+
calendar, config.valuation_date, maturity_date, config.business_days_in_year
|
|
288
|
+
)
|
|
289
|
+
return calendar, ko_dates, ko_times, daily_ki_dates, daily_ki_times, maturity_date, maturity_time
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _build_pricing_environment(
|
|
293
|
+
config: SnowballRiskComparisonConfig,
|
|
294
|
+
calendar,
|
|
295
|
+
) -> PricingEnvironment:
|
|
296
|
+
return PricingEnvironment(
|
|
297
|
+
spot_quote=SpotQuote(spot=config.initial_price),
|
|
298
|
+
vol_surface=FlatVolSurface(volatility=config.volatility),
|
|
299
|
+
rate_curve=FlatRateCurve(rate=config.rate),
|
|
300
|
+
div_yield=ContinuousDividendYield(div_yield=config.dividend_yield),
|
|
301
|
+
valuation_date=config.valuation_date,
|
|
302
|
+
bus_days_in_year=config.business_days_in_year,
|
|
303
|
+
calendar=calendar,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _base_payoff_config(
|
|
308
|
+
config: SnowballRiskComparisonConfig,
|
|
309
|
+
*,
|
|
310
|
+
protection_type: ProtectionType,
|
|
311
|
+
) -> PayoffConfig:
|
|
312
|
+
return PayoffConfig(
|
|
313
|
+
rebate_rate=config.annual_coupon,
|
|
314
|
+
include_principal=False,
|
|
315
|
+
participation_rate=1.0,
|
|
316
|
+
protection_type=protection_type,
|
|
317
|
+
protection_rate=config.protection_rate if protection_type == ProtectionType.PARTIAL else 0.0,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _build_structures(
|
|
322
|
+
config: SnowballRiskComparisonConfig,
|
|
323
|
+
ko_times: Sequence[float],
|
|
324
|
+
daily_ki_times: Sequence[float],
|
|
325
|
+
maturity_time: float,
|
|
326
|
+
) -> Dict[str, SnowballOption]:
|
|
327
|
+
ko_times_list = [float(t) for t in ko_times]
|
|
328
|
+
daily_ki_list = [float(t) for t in daily_ki_times]
|
|
329
|
+
|
|
330
|
+
ppp_dki = SnowballOption(
|
|
331
|
+
initial_price=config.initial_price,
|
|
332
|
+
strike=config.strike,
|
|
333
|
+
maturity=maturity_time,
|
|
334
|
+
contract_multiplier=config.contract_multiplier,
|
|
335
|
+
barrier_config=BarrierConfig(
|
|
336
|
+
ko_barrier=config.ko_barrier,
|
|
337
|
+
ko_rate=config.annual_coupon,
|
|
338
|
+
ko_observation_type=ObservationType.DISCRETE,
|
|
339
|
+
ko_observation_dates=ko_times_list,
|
|
340
|
+
ki_barrier=config.ki_barrier,
|
|
341
|
+
ki_observation_type=ObservationType.DISCRETE,
|
|
342
|
+
ki_observation_dates=daily_ki_list,
|
|
343
|
+
ki_continuous=False,
|
|
344
|
+
),
|
|
345
|
+
payoff_config=_base_payoff_config(
|
|
346
|
+
config, protection_type=ProtectionType.PARTIAL
|
|
347
|
+
),
|
|
348
|
+
accrual_config=AccrualConfig(
|
|
349
|
+
coupon_pay_type=CouponPayType.INSTANT,
|
|
350
|
+
is_annualized=True,
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
ppp_eki = create_european_ki_snowball(
|
|
355
|
+
initial_price=config.initial_price,
|
|
356
|
+
strike=config.strike,
|
|
357
|
+
maturity=maturity_time,
|
|
358
|
+
contract_multiplier=config.contract_multiplier,
|
|
359
|
+
ko_barrier=config.ko_barrier,
|
|
360
|
+
ko_rate=config.annual_coupon,
|
|
361
|
+
ki_barrier=config.ki_barrier,
|
|
362
|
+
num_ko_observations=len(ko_times_list),
|
|
363
|
+
ko_observation_dates=ko_times_list,
|
|
364
|
+
include_principal=False,
|
|
365
|
+
participation_rate=1.0,
|
|
366
|
+
protection_type=ProtectionType.PARTIAL,
|
|
367
|
+
protection_rate=config.protection_rate,
|
|
368
|
+
rebate_rate=config.annual_coupon,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
npp_dki = SnowballOption(
|
|
372
|
+
initial_price=config.initial_price,
|
|
373
|
+
strike=config.strike,
|
|
374
|
+
maturity=maturity_time,
|
|
375
|
+
contract_multiplier=config.contract_multiplier,
|
|
376
|
+
barrier_config=BarrierConfig(
|
|
377
|
+
ko_barrier=config.ko_barrier,
|
|
378
|
+
ko_rate=config.annual_coupon,
|
|
379
|
+
ko_observation_type=ObservationType.DISCRETE,
|
|
380
|
+
ko_observation_dates=ko_times_list,
|
|
381
|
+
ki_barrier=config.ki_barrier,
|
|
382
|
+
ki_observation_type=ObservationType.DISCRETE,
|
|
383
|
+
ki_observation_dates=daily_ki_list,
|
|
384
|
+
ki_continuous=False,
|
|
385
|
+
),
|
|
386
|
+
payoff_config=_base_payoff_config(config, protection_type=ProtectionType.NONE),
|
|
387
|
+
accrual_config=AccrualConfig(
|
|
388
|
+
coupon_pay_type=CouponPayType.INSTANT,
|
|
389
|
+
is_annualized=True,
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
ppp_dki_parachute = create_parachute_snowball(
|
|
394
|
+
initial_price=config.initial_price,
|
|
395
|
+
strike=config.strike,
|
|
396
|
+
maturity=maturity_time,
|
|
397
|
+
contract_multiplier=config.contract_multiplier,
|
|
398
|
+
ko_barrier=config.ko_barrier,
|
|
399
|
+
ko_rate=config.annual_coupon,
|
|
400
|
+
ki_barrier=config.ki_barrier,
|
|
401
|
+
num_observations=len(ko_times_list),
|
|
402
|
+
ko_observation_dates=ko_times_list,
|
|
403
|
+
ki_observation_type=ObservationType.DISCRETE,
|
|
404
|
+
ki_observation_dates=daily_ki_list,
|
|
405
|
+
ki_continuous=False,
|
|
406
|
+
include_principal=False,
|
|
407
|
+
participation_rate=1.0,
|
|
408
|
+
protection_type=ProtectionType.PARTIAL,
|
|
409
|
+
protection_rate=config.protection_rate,
|
|
410
|
+
rebate_rate=config.annual_coupon,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
ppp_eki_parachute = create_parachute_snowball(
|
|
414
|
+
initial_price=config.initial_price,
|
|
415
|
+
strike=config.strike,
|
|
416
|
+
maturity=maturity_time,
|
|
417
|
+
contract_multiplier=config.contract_multiplier,
|
|
418
|
+
ko_barrier=config.ko_barrier,
|
|
419
|
+
ko_rate=config.annual_coupon,
|
|
420
|
+
ki_barrier=config.ki_barrier,
|
|
421
|
+
num_observations=len(ko_times_list),
|
|
422
|
+
ko_observation_dates=ko_times_list,
|
|
423
|
+
ki_observation_type=ObservationType.DISCRETE,
|
|
424
|
+
ki_observation_dates=[maturity_time],
|
|
425
|
+
ki_continuous=False,
|
|
426
|
+
include_principal=False,
|
|
427
|
+
participation_rate=1.0,
|
|
428
|
+
protection_type=ProtectionType.PARTIAL,
|
|
429
|
+
protection_rate=config.protection_rate,
|
|
430
|
+
rebate_rate=config.annual_coupon,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
"PPP-DKI": ppp_dki,
|
|
435
|
+
"PPP-EKI": ppp_eki,
|
|
436
|
+
"NPP-DKI": npp_dki,
|
|
437
|
+
"PPP-DKI-Parachute": ppp_dki_parachute,
|
|
438
|
+
"PPP-EKI-Parachute": ppp_eki_parachute,
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _get_principal(product: SnowballOption) -> float:
|
|
443
|
+
return product.initial_price * product.contract_multiplier
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _build_mc_engine(
|
|
447
|
+
config: SnowballRiskComparisonConfig,
|
|
448
|
+
) -> SnowballMCEngine:
|
|
449
|
+
return SnowballMCEngine(
|
|
450
|
+
params=MCParams(
|
|
451
|
+
num_paths=int(config.num_paths),
|
|
452
|
+
seed=int(config.seed),
|
|
453
|
+
bus_days_in_year=int(config.business_days_in_year),
|
|
454
|
+
),
|
|
455
|
+
method=EngineType.MONTE_CARLO(config.mc_method),
|
|
456
|
+
use_dask=bool(config.mc_use_parallel),
|
|
457
|
+
num_batches=int(config.mc_num_batches),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _generate_shared_paths(
|
|
462
|
+
*,
|
|
463
|
+
config: SnowballRiskComparisonConfig,
|
|
464
|
+
pricing_env: PricingEnvironment,
|
|
465
|
+
all_times: np.ndarray,
|
|
466
|
+
) -> np.ndarray:
|
|
467
|
+
dt_array = np.diff(np.concatenate([[0.0], all_times]))
|
|
468
|
+
mc_engine = _build_mc_engine(config)
|
|
469
|
+
maturity = float(all_times[-1])
|
|
470
|
+
spot = float(pricing_env.spot)
|
|
471
|
+
rate = float(pricing_env.get_rate(maturity))
|
|
472
|
+
div = float(pricing_env.get_div_yield(maturity))
|
|
473
|
+
vol = float(pricing_env.get_vol(config.strike, maturity))
|
|
474
|
+
|
|
475
|
+
if mc_engine.use_dask and mc_engine.num_batches > 1:
|
|
476
|
+
from dask import compute, delayed
|
|
477
|
+
|
|
478
|
+
total_paths_target = int(config.num_paths)
|
|
479
|
+
base = total_paths_target // mc_engine.num_batches
|
|
480
|
+
remainder = total_paths_target % mc_engine.num_batches
|
|
481
|
+
batch_sizes = [
|
|
482
|
+
(base + 1 if i < remainder else base)
|
|
483
|
+
for i in range(mc_engine.num_batches)
|
|
484
|
+
]
|
|
485
|
+
|
|
486
|
+
def _generate_batch(batch_id: int, batch_num_paths: int) -> np.ndarray:
|
|
487
|
+
generator = mc_engine._create_path_generator(
|
|
488
|
+
spot,
|
|
489
|
+
rate,
|
|
490
|
+
div,
|
|
491
|
+
vol,
|
|
492
|
+
maturity,
|
|
493
|
+
dt_array,
|
|
494
|
+
batch_id=batch_id,
|
|
495
|
+
num_paths=batch_num_paths,
|
|
496
|
+
)
|
|
497
|
+
paths, _ = generator.generate_paths(
|
|
498
|
+
return_aux=False,
|
|
499
|
+
batch_id=batch_id,
|
|
500
|
+
)
|
|
501
|
+
return np.asarray(paths, dtype=float)
|
|
502
|
+
|
|
503
|
+
tasks = [
|
|
504
|
+
delayed(_generate_batch)(batch_id, batch_num_paths)
|
|
505
|
+
for batch_id, batch_num_paths in enumerate(batch_sizes)
|
|
506
|
+
if batch_num_paths > 0
|
|
507
|
+
]
|
|
508
|
+
results = compute(*tasks)
|
|
509
|
+
return np.concatenate(results, axis=0)
|
|
510
|
+
|
|
511
|
+
generator = mc_engine._create_path_generator(
|
|
512
|
+
spot,
|
|
513
|
+
rate,
|
|
514
|
+
div,
|
|
515
|
+
vol,
|
|
516
|
+
maturity,
|
|
517
|
+
dt_array,
|
|
518
|
+
batch_id=0,
|
|
519
|
+
num_paths=int(config.num_paths),
|
|
520
|
+
)
|
|
521
|
+
paths, _ = generator.generate_paths(return_aux=False, batch_id=0)
|
|
522
|
+
return np.asarray(paths, dtype=float)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _compute_ko_cashflow_table(
|
|
526
|
+
discounted_payoff: np.ndarray,
|
|
527
|
+
is_ko: np.ndarray,
|
|
528
|
+
first_ko_idx: np.ndarray,
|
|
529
|
+
ko_times: np.ndarray,
|
|
530
|
+
) -> pd.DataFrame:
|
|
531
|
+
rows = []
|
|
532
|
+
cumulative = 0.0
|
|
533
|
+
for idx, ko_time in enumerate(ko_times):
|
|
534
|
+
hit = is_ko & (first_ko_idx == idx)
|
|
535
|
+
p_ko = float(np.mean(hit))
|
|
536
|
+
cumulative += p_ko
|
|
537
|
+
rows.append(
|
|
538
|
+
{
|
|
539
|
+
"ko_time": float(ko_time),
|
|
540
|
+
"p_ko": p_ko,
|
|
541
|
+
"p_survive": max(0.0, 1.0 - cumulative),
|
|
542
|
+
"ed_ko_cf": float(np.mean(np.where(hit, discounted_payoff, 0.0))),
|
|
543
|
+
}
|
|
544
|
+
)
|
|
545
|
+
return pd.DataFrame(rows)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _loss_metrics(payoff_ratio: np.ndarray) -> tuple[float, float, float, float, float]:
|
|
549
|
+
losses = -np.minimum(payoff_ratio, 0.0)
|
|
550
|
+
loss_prob_5 = float(np.mean(losses > 0.05))
|
|
551
|
+
loss_prob_10 = float(np.mean(losses > 0.10))
|
|
552
|
+
loss_prob_20 = float(np.mean(losses > 0.20))
|
|
553
|
+
q95 = float(np.quantile(losses, 0.95))
|
|
554
|
+
q99 = float(np.quantile(losses, 0.99))
|
|
555
|
+
es95 = float(np.mean(losses[losses >= q95])) if np.any(losses >= q95) else 0.0
|
|
556
|
+
es99 = float(np.mean(losses[losses >= q99])) if np.any(losses >= q99) else 0.0
|
|
557
|
+
return loss_prob_5, loss_prob_10, loss_prob_20, es95, es99
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _classify_paths(
|
|
561
|
+
*,
|
|
562
|
+
config: SnowballRiskComparisonConfig,
|
|
563
|
+
product: SnowballOption,
|
|
564
|
+
pricing_env: PricingEnvironment,
|
|
565
|
+
engine: SnowballMCEngine,
|
|
566
|
+
paths: np.ndarray,
|
|
567
|
+
all_times: np.ndarray,
|
|
568
|
+
rng_seed: int,
|
|
569
|
+
) -> _PathSnapshot:
|
|
570
|
+
ko_profile = product.get_ko_observation_profile(pricing_env)
|
|
571
|
+
ko_times = np.array(ko_profile["observation_times"], dtype=float)
|
|
572
|
+
ko_barriers = np.array(ko_profile["barriers"], dtype=float)
|
|
573
|
+
ko_indices = np.searchsorted(all_times, ko_times)
|
|
574
|
+
|
|
575
|
+
ki_profile = product.get_ki_observation_profile(pricing_env)
|
|
576
|
+
ki_times = np.array(ki_profile["observation_times"], dtype=float)
|
|
577
|
+
ki_barriers = np.array(ki_profile["barriers"], dtype=float)
|
|
578
|
+
ki_indices = np.searchsorted(all_times, ki_times)
|
|
579
|
+
|
|
580
|
+
ko_triggered, first_ko_idx = engine._check_ko_barriers(
|
|
581
|
+
paths, ko_indices, ko_barriers, product.is_reverse
|
|
582
|
+
)
|
|
583
|
+
ki_triggered, first_ki_idx = engine._check_ki_barriers(
|
|
584
|
+
paths, ki_indices, ki_barriers, product.is_reverse
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
is_ko = ko_triggered
|
|
588
|
+
is_v0 = ~is_ko & ~ki_triggered
|
|
589
|
+
is_v1 = ~is_ko & ki_triggered
|
|
590
|
+
|
|
591
|
+
maturity = product.get_maturity(pricing_env)
|
|
592
|
+
sigma = pricing_env.get_vol(product.strike, maturity)
|
|
593
|
+
payoffs, settlement_times, stats = engine._compute_payoffs(
|
|
594
|
+
product=product,
|
|
595
|
+
pricing_env=pricing_env,
|
|
596
|
+
paths=paths,
|
|
597
|
+
all_times=all_times,
|
|
598
|
+
ko_indices=ko_indices,
|
|
599
|
+
ki_indices=ki_indices,
|
|
600
|
+
r=pricing_env.get_rate(maturity),
|
|
601
|
+
T=maturity,
|
|
602
|
+
sigma=sigma,
|
|
603
|
+
rng_seed=rng_seed,
|
|
604
|
+
)
|
|
605
|
+
discounted_payoff = np.array(
|
|
606
|
+
[
|
|
607
|
+
payoff * pricing_env.get_discount_factor(float(t))
|
|
608
|
+
for payoff, t in zip(payoffs, settlement_times)
|
|
609
|
+
],
|
|
610
|
+
dtype=float,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
principal = _get_principal(product)
|
|
614
|
+
payoff_ratio = payoffs / principal
|
|
615
|
+
loss_prob_5, loss_prob_10, loss_prob_20, es95, es99 = _loss_metrics(payoff_ratio)
|
|
616
|
+
min_spots = np.min(paths, axis=1)
|
|
617
|
+
terminal_spots = paths[:, -1]
|
|
618
|
+
ki_level = float(product.barrier_config.ki_barrier)
|
|
619
|
+
standard_final_ko = float(config.ko_barrier)
|
|
620
|
+
rebound_band = (~is_ko) & (min_spots < ki_level) & (terminal_spots > ki_level) & (
|
|
621
|
+
terminal_spots < product.strike
|
|
622
|
+
)
|
|
623
|
+
rescue_band = (~is_ko) & (min_spots < ki_level) & (terminal_spots >= ki_level) & (
|
|
624
|
+
terminal_spots < standard_final_ko
|
|
625
|
+
)
|
|
626
|
+
cliff_band = (~is_ko) & (
|
|
627
|
+
np.abs(terminal_spots / ki_level - 1.0) <= float(config.terminal_cliff_band)
|
|
628
|
+
)
|
|
629
|
+
losses = payoff_ratio < 0.0
|
|
630
|
+
|
|
631
|
+
metrics = StructureMetrics(
|
|
632
|
+
label="",
|
|
633
|
+
pv=float(np.mean(discounted_payoff)),
|
|
634
|
+
std_error=float(np.std(discounted_payoff, ddof=1) / np.sqrt(len(discounted_payoff))),
|
|
635
|
+
expected_life=float(np.mean(settlement_times)),
|
|
636
|
+
no_ko_probability=float(np.mean(~is_ko)),
|
|
637
|
+
ki_probability=float(np.mean(ki_triggered)),
|
|
638
|
+
ki_no_ko_probability=float(np.mean(is_v1)),
|
|
639
|
+
loss_prob_5=loss_prob_5,
|
|
640
|
+
loss_prob_10=loss_prob_10,
|
|
641
|
+
loss_prob_20=loss_prob_20,
|
|
642
|
+
es95=es95,
|
|
643
|
+
es99=es99,
|
|
644
|
+
rebound_band_probability=float(np.mean(rebound_band)),
|
|
645
|
+
rebound_band_loss_probability=float(np.mean(rebound_band & losses)),
|
|
646
|
+
parachute_rescue_band_probability=float(np.mean(rescue_band)),
|
|
647
|
+
parachute_rescue_loss_probability=float(np.mean(rescue_band & losses)),
|
|
648
|
+
terminal_cliff_band_probability=float(np.mean(cliff_band)),
|
|
649
|
+
terminal_cliff_loss_probability=float(np.mean(cliff_band & losses)),
|
|
650
|
+
conditional_ko_cashflows=_compute_ko_cashflow_table(
|
|
651
|
+
discounted_payoff=discounted_payoff,
|
|
652
|
+
is_ko=is_ko,
|
|
653
|
+
first_ko_idx=first_ko_idx,
|
|
654
|
+
ko_times=ko_times,
|
|
655
|
+
),
|
|
656
|
+
)
|
|
657
|
+
return _PathSnapshot(
|
|
658
|
+
metrics=metrics,
|
|
659
|
+
discounted_payoff=discounted_payoff,
|
|
660
|
+
payoff=payoffs,
|
|
661
|
+
settlement_times=settlement_times,
|
|
662
|
+
terminal_spots=terminal_spots,
|
|
663
|
+
min_spots=min_spots,
|
|
664
|
+
is_ko=is_ko,
|
|
665
|
+
is_v0=is_v0,
|
|
666
|
+
is_v1=is_v1,
|
|
667
|
+
first_ko_idx=first_ko_idx,
|
|
668
|
+
first_ki_idx=first_ki_idx,
|
|
669
|
+
ko_times=ko_times,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _simulate_common_paths(
|
|
674
|
+
*,
|
|
675
|
+
config: SnowballRiskComparisonConfig,
|
|
676
|
+
pricing_env: PricingEnvironment,
|
|
677
|
+
products: Mapping[str, SnowballOption],
|
|
678
|
+
) -> tuple[np.ndarray, np.ndarray, Dict[str, _PathSnapshot]]:
|
|
679
|
+
all_times_set = {float(products["PPP-DKI"].get_maturity(pricing_env))}
|
|
680
|
+
for product in products.values():
|
|
681
|
+
ko_profile = product.get_ko_observation_profile(pricing_env)
|
|
682
|
+
all_times_set.update(float(t) for t in ko_profile["observation_times"])
|
|
683
|
+
ki_profile = product.get_ki_observation_profile(pricing_env)
|
|
684
|
+
all_times_set.update(float(t) for t in ki_profile["observation_times"])
|
|
685
|
+
all_times = np.array(sorted(all_times_set), dtype=float)
|
|
686
|
+
mc_engine = _build_mc_engine(config)
|
|
687
|
+
paths = _generate_shared_paths(
|
|
688
|
+
config=config,
|
|
689
|
+
pricing_env=pricing_env,
|
|
690
|
+
all_times=all_times,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
snapshots: Dict[str, _PathSnapshot] = {}
|
|
694
|
+
for idx, (label, product) in enumerate(products.items()):
|
|
695
|
+
snapshot = _classify_paths(
|
|
696
|
+
config=config,
|
|
697
|
+
product=product,
|
|
698
|
+
pricing_env=pricing_env,
|
|
699
|
+
engine=mc_engine,
|
|
700
|
+
paths=paths,
|
|
701
|
+
all_times=all_times,
|
|
702
|
+
rng_seed=config.seed + 1000 + idx,
|
|
703
|
+
)
|
|
704
|
+
snapshot.metrics = StructureMetrics(
|
|
705
|
+
label=label,
|
|
706
|
+
pv=snapshot.metrics.pv,
|
|
707
|
+
std_error=snapshot.metrics.std_error,
|
|
708
|
+
expected_life=snapshot.metrics.expected_life,
|
|
709
|
+
no_ko_probability=snapshot.metrics.no_ko_probability,
|
|
710
|
+
ki_probability=snapshot.metrics.ki_probability,
|
|
711
|
+
ki_no_ko_probability=snapshot.metrics.ki_no_ko_probability,
|
|
712
|
+
loss_prob_5=snapshot.metrics.loss_prob_5,
|
|
713
|
+
loss_prob_10=snapshot.metrics.loss_prob_10,
|
|
714
|
+
loss_prob_20=snapshot.metrics.loss_prob_20,
|
|
715
|
+
es95=snapshot.metrics.es95,
|
|
716
|
+
es99=snapshot.metrics.es99,
|
|
717
|
+
rebound_band_probability=snapshot.metrics.rebound_band_probability,
|
|
718
|
+
rebound_band_loss_probability=snapshot.metrics.rebound_band_loss_probability,
|
|
719
|
+
parachute_rescue_band_probability=snapshot.metrics.parachute_rescue_band_probability,
|
|
720
|
+
parachute_rescue_loss_probability=snapshot.metrics.parachute_rescue_loss_probability,
|
|
721
|
+
terminal_cliff_band_probability=snapshot.metrics.terminal_cliff_band_probability,
|
|
722
|
+
terminal_cliff_loss_probability=snapshot.metrics.terminal_cliff_loss_probability,
|
|
723
|
+
conditional_ko_cashflows=snapshot.metrics.conditional_ko_cashflows,
|
|
724
|
+
)
|
|
725
|
+
snapshots[label] = snapshot
|
|
726
|
+
return all_times, paths, snapshots
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _interpolate_path(
|
|
730
|
+
anchors: Sequence[tuple[float, float]],
|
|
731
|
+
observation_times: np.ndarray,
|
|
732
|
+
) -> np.ndarray:
|
|
733
|
+
anchor_times = np.array([float(t) for t, _ in anchors], dtype=float)
|
|
734
|
+
anchor_spots = np.array([float(s) for _, s in anchors], dtype=float)
|
|
735
|
+
return np.interp(observation_times, anchor_times, anchor_spots)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _evaluate_deterministic_path(
|
|
739
|
+
*,
|
|
740
|
+
product: SnowballOption,
|
|
741
|
+
pricing_env: PricingEnvironment,
|
|
742
|
+
observation_times: np.ndarray,
|
|
743
|
+
path_spots: np.ndarray,
|
|
744
|
+
) -> DeterministicCaseOutcome:
|
|
745
|
+
ko_profile = product.get_ko_observation_profile(pricing_env)
|
|
746
|
+
ko_times = np.array(ko_profile["observation_times"], dtype=float)
|
|
747
|
+
ko_barriers = np.array(ko_profile["barriers"], dtype=float)
|
|
748
|
+
ko_payoffs = np.array(ko_profile["payoffs"], dtype=float)
|
|
749
|
+
ko_indices = np.searchsorted(observation_times, ko_times)
|
|
750
|
+
ko_prices = path_spots[ko_indices]
|
|
751
|
+
ko_hit = ko_prices >= ko_barriers
|
|
752
|
+
if ko_hit.any():
|
|
753
|
+
idx = int(np.argmax(ko_hit))
|
|
754
|
+
payoff = float(ko_payoffs[idx])
|
|
755
|
+
return DeterministicCaseOutcome(
|
|
756
|
+
state="KO",
|
|
757
|
+
knocked_out=True,
|
|
758
|
+
knocked_in=False,
|
|
759
|
+
payoff=payoff,
|
|
760
|
+
payoff_ratio=payoff / _get_principal(product),
|
|
761
|
+
terminal_spot=float(path_spots[-1]),
|
|
762
|
+
notes=f"KO at observation {idx + 1}",
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
ki_profile = product.get_ki_observation_profile(pricing_env)
|
|
766
|
+
ki_times = np.array(ki_profile["observation_times"], dtype=float)
|
|
767
|
+
ki_barriers = np.array(ki_profile["barriers"], dtype=float)
|
|
768
|
+
ki_indices = np.searchsorted(observation_times, ki_times)
|
|
769
|
+
ki_prices = path_spots[ki_indices]
|
|
770
|
+
ki_hit = ki_prices <= ki_barriers
|
|
771
|
+
knocked_in = bool(ki_hit.any())
|
|
772
|
+
terminal_spot = float(path_spots[-1])
|
|
773
|
+
if knocked_in:
|
|
774
|
+
payoff = float(product.get_maturity_payoff_v1(terminal_spot, pricing_env))
|
|
775
|
+
state = "KI / No KO"
|
|
776
|
+
else:
|
|
777
|
+
payoff = float(product.get_maturity_payoff_v0(terminal_spot, pricing_env))
|
|
778
|
+
state = "No KI / No KO"
|
|
779
|
+
return DeterministicCaseOutcome(
|
|
780
|
+
state=state,
|
|
781
|
+
knocked_out=False,
|
|
782
|
+
knocked_in=knocked_in,
|
|
783
|
+
payoff=payoff,
|
|
784
|
+
payoff_ratio=payoff / _get_principal(product),
|
|
785
|
+
terminal_spot=terminal_spot,
|
|
786
|
+
notes="Terminal payoff",
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _build_deterministic_cases(maturity_time: float) -> Sequence[tuple[str, str, Sequence[tuple[float, float]]]]:
|
|
791
|
+
return [
|
|
792
|
+
(
|
|
793
|
+
"Crash Then Full Rebound",
|
|
794
|
+
"Sharp selloff followed by a full recovery above the KO region.",
|
|
795
|
+
[(0.0, 100.0), (0.35 * maturity_time, 65.0), (maturity_time, 110.0)],
|
|
796
|
+
),
|
|
797
|
+
(
|
|
798
|
+
"Crash Then Partial Rebound 85",
|
|
799
|
+
"Rebound path used to highlight the monitoring-rule advantage of EKI.",
|
|
800
|
+
[(0.0, 100.0), (0.35 * maturity_time, 65.0), (maturity_time, 85.0)],
|
|
801
|
+
),
|
|
802
|
+
(
|
|
803
|
+
"Parachute Rescue 74",
|
|
804
|
+
"Rebound into the KI-to-standard-final-KO band where parachute should matter most.",
|
|
805
|
+
[(0.0, 100.0), (0.35 * maturity_time, 65.0), (maturity_time, 74.0)],
|
|
806
|
+
),
|
|
807
|
+
(
|
|
808
|
+
"Slow Bleed Lower",
|
|
809
|
+
"Persistent drawdown finishing below KI to show convergence of protected structures.",
|
|
810
|
+
[
|
|
811
|
+
(0.0, 100.0),
|
|
812
|
+
(0.25 * maturity_time, 92.0),
|
|
813
|
+
(0.50 * maturity_time, 84.0),
|
|
814
|
+
(0.75 * maturity_time, 76.0),
|
|
815
|
+
(maturity_time, 68.0),
|
|
816
|
+
],
|
|
817
|
+
),
|
|
818
|
+
(
|
|
819
|
+
"High-Vol Sideways Around KI",
|
|
820
|
+
"Sideways market oscillating around the KI level without recovering materially.",
|
|
821
|
+
[
|
|
822
|
+
(0.0, 100.0),
|
|
823
|
+
(0.25 * maturity_time, 74.0),
|
|
824
|
+
(0.50 * maturity_time, 69.0),
|
|
825
|
+
(0.75 * maturity_time, 73.0),
|
|
826
|
+
(maturity_time, 71.0),
|
|
827
|
+
],
|
|
828
|
+
),
|
|
829
|
+
(
|
|
830
|
+
"Late Selloff Final Window",
|
|
831
|
+
"Late move near maturity to expose EKI terminal-state concentration.",
|
|
832
|
+
[
|
|
833
|
+
(0.0, 100.0),
|
|
834
|
+
(0.70 * maturity_time, 101.0),
|
|
835
|
+
(0.92 * maturity_time, 95.0),
|
|
836
|
+
(maturity_time, 69.0),
|
|
837
|
+
],
|
|
838
|
+
),
|
|
839
|
+
]
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _run_deterministic_cases(
|
|
843
|
+
*,
|
|
844
|
+
products: Mapping[str, SnowballOption],
|
|
845
|
+
pricing_env: PricingEnvironment,
|
|
846
|
+
observation_times: np.ndarray,
|
|
847
|
+
) -> Sequence[DeterministicCaseResult]:
|
|
848
|
+
cases = []
|
|
849
|
+
maturity_time = float(observation_times[-1])
|
|
850
|
+
for name, narrative, anchors in _build_deterministic_cases(maturity_time):
|
|
851
|
+
path_spots = _interpolate_path(anchors, observation_times)
|
|
852
|
+
outcomes = {
|
|
853
|
+
label: _evaluate_deterministic_path(
|
|
854
|
+
product=product,
|
|
855
|
+
pricing_env=pricing_env,
|
|
856
|
+
observation_times=observation_times,
|
|
857
|
+
path_spots=path_spots,
|
|
858
|
+
)
|
|
859
|
+
for label, product in products.items()
|
|
860
|
+
}
|
|
861
|
+
cases.append(
|
|
862
|
+
DeterministicCaseResult(
|
|
863
|
+
name=name,
|
|
864
|
+
narrative=narrative,
|
|
865
|
+
anchors=anchors,
|
|
866
|
+
outcomes=outcomes,
|
|
867
|
+
)
|
|
868
|
+
)
|
|
869
|
+
return cases
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _select_stress_engine(
|
|
873
|
+
config: SnowballRiskComparisonConfig,
|
|
874
|
+
) -> tuple[BaseEngine, str]:
|
|
875
|
+
return _select_snowball_pricing_engine(
|
|
876
|
+
preference=config.engine_preference,
|
|
877
|
+
quad_params=config.quad_params,
|
|
878
|
+
pde_params=config.pde_params,
|
|
879
|
+
mc_params=MCParams(
|
|
880
|
+
num_paths=max(4000, min(config.num_paths, 12000)),
|
|
881
|
+
seed=config.seed,
|
|
882
|
+
bus_days_in_year=config.business_days_in_year,
|
|
883
|
+
),
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _compute_stress_curve(
|
|
888
|
+
*,
|
|
889
|
+
base_product: SnowballOption,
|
|
890
|
+
pricing_env: PricingEnvironment,
|
|
891
|
+
engine: BaseEngine,
|
|
892
|
+
shocks: Sequence[float],
|
|
893
|
+
kind: str,
|
|
894
|
+
) -> pd.DataFrame:
|
|
895
|
+
rows = []
|
|
896
|
+
base_pv = float(engine.price(base_product, pricing_env))
|
|
897
|
+
for shock in shocks:
|
|
898
|
+
if kind == "spot":
|
|
899
|
+
env = _clone_env(pricing_env, spot=pricing_env.spot * (1.0 + float(shock)))
|
|
900
|
+
elif kind == "vol":
|
|
901
|
+
vol_surface = _scale_vol_surface(pricing_env.vol_surface, 1.0 + float(shock))
|
|
902
|
+
env = _clone_env(pricing_env, vol_surface=vol_surface)
|
|
903
|
+
elif kind == "div":
|
|
904
|
+
div = _shift_dividend_yield(pricing_env.div_yield, float(shock))
|
|
905
|
+
env = _clone_env(pricing_env, div_yield=div)
|
|
906
|
+
else:
|
|
907
|
+
raise ValidationError(f"Unknown stress curve kind: {kind}")
|
|
908
|
+
pv = float(engine.price(base_product, env))
|
|
909
|
+
rows.append({"shock": float(shock), "pv": pv, "pnl": pv - base_pv})
|
|
910
|
+
return pd.DataFrame(rows)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _compute_skew_smile_heatmap(
|
|
914
|
+
*,
|
|
915
|
+
products: Mapping[str, SnowballOption],
|
|
916
|
+
pricing_env: PricingEnvironment,
|
|
917
|
+
engine: BaseEngine,
|
|
918
|
+
skew_shocks: Sequence[float],
|
|
919
|
+
smile_shocks: Sequence[float],
|
|
920
|
+
) -> np.ndarray:
|
|
921
|
+
ppp_eki = products["PPP-EKI"]
|
|
922
|
+
ppp_dki = products["PPP-DKI"]
|
|
923
|
+
z = np.zeros((len(skew_shocks), len(smile_shocks)), dtype=float)
|
|
924
|
+
if pricing_env.vol_surface is None:
|
|
925
|
+
return z
|
|
926
|
+
for i, skew in enumerate(skew_shocks):
|
|
927
|
+
for j, smile in enumerate(smile_shocks):
|
|
928
|
+
skew_surface = SkewSmileVolSurface(
|
|
929
|
+
base=pricing_env.vol_surface,
|
|
930
|
+
skew=float(skew),
|
|
931
|
+
smile=float(smile),
|
|
932
|
+
)
|
|
933
|
+
env = _clone_env(pricing_env, vol_surface=skew_surface)
|
|
934
|
+
z[i, j] = float(engine.price(ppp_eki, env) - engine.price(ppp_dki, env))
|
|
935
|
+
return z
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _compute_delta_heatmap(
|
|
939
|
+
*,
|
|
940
|
+
left_product: SnowballOption,
|
|
941
|
+
right_product: SnowballOption,
|
|
942
|
+
pricing_env: PricingEnvironment,
|
|
943
|
+
engine: BaseEngine,
|
|
944
|
+
spot_shocks: Sequence[float],
|
|
945
|
+
vol_shocks: Sequence[float],
|
|
946
|
+
) -> np.ndarray:
|
|
947
|
+
z = np.zeros((len(spot_shocks), len(vol_shocks)), dtype=float)
|
|
948
|
+
if pricing_env.vol_surface is None:
|
|
949
|
+
return z
|
|
950
|
+
for i, s_shock in enumerate(spot_shocks):
|
|
951
|
+
spot = pricing_env.spot * (1.0 + float(s_shock))
|
|
952
|
+
for j, v_shock in enumerate(vol_shocks):
|
|
953
|
+
vol_surface = _scale_vol_surface(
|
|
954
|
+
pricing_env.vol_surface, 1.0 + float(v_shock)
|
|
955
|
+
)
|
|
956
|
+
env = _clone_env(pricing_env, spot=spot, vol_surface=vol_surface)
|
|
957
|
+
z[i, j] = float(engine.price(left_product, env) - engine.price(right_product, env))
|
|
958
|
+
return z
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _require_matplotlib():
|
|
962
|
+
os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib")
|
|
963
|
+
import matplotlib.pyplot as plt # type: ignore
|
|
964
|
+
|
|
965
|
+
return plt
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _save_multi_line_plot(
|
|
969
|
+
*,
|
|
970
|
+
curves: Mapping[str, pd.DataFrame],
|
|
971
|
+
x_col: str,
|
|
972
|
+
y_col: str,
|
|
973
|
+
title: str,
|
|
974
|
+
xlabel: str,
|
|
975
|
+
ylabel: str,
|
|
976
|
+
path: Path,
|
|
977
|
+
) -> None:
|
|
978
|
+
plt = _require_matplotlib()
|
|
979
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
980
|
+
fig, ax = plt.subplots(figsize=(8, 4.5))
|
|
981
|
+
for label, df in curves.items():
|
|
982
|
+
ax.plot(df[x_col].to_numpy(), df[y_col].to_numpy(), linewidth=2, label=label)
|
|
983
|
+
ax.set_title(title)
|
|
984
|
+
ax.set_xlabel(xlabel)
|
|
985
|
+
ax.set_ylabel(ylabel)
|
|
986
|
+
ax.grid(True, alpha=0.3)
|
|
987
|
+
ax.legend()
|
|
988
|
+
fig.tight_layout()
|
|
989
|
+
fig.savefig(path, dpi=150)
|
|
990
|
+
plt.close(fig)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _save_terminal_cliff_plot(
|
|
994
|
+
*,
|
|
995
|
+
snapshots: Mapping[str, _PathSnapshot],
|
|
996
|
+
ki_barrier: float,
|
|
997
|
+
path: Path,
|
|
998
|
+
) -> None:
|
|
999
|
+
plt = _require_matplotlib()
|
|
1000
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1001
|
+
fig, ax = plt.subplots(figsize=(8, 4.5))
|
|
1002
|
+
bins = np.linspace(0.90 * ki_barrier, 1.10 * ki_barrier, 17)
|
|
1003
|
+
for label in ("PPP-EKI", "PPP-DKI"):
|
|
1004
|
+
snapshot = snapshots[label]
|
|
1005
|
+
mask = ~snapshot.is_ko
|
|
1006
|
+
if not np.any(mask):
|
|
1007
|
+
continue
|
|
1008
|
+
x = snapshot.terminal_spots[mask]
|
|
1009
|
+
y = snapshot.payoff[mask]
|
|
1010
|
+
centers = 0.5 * (bins[1:] + bins[:-1])
|
|
1011
|
+
values = []
|
|
1012
|
+
for left, right in zip(bins[:-1], bins[1:]):
|
|
1013
|
+
bin_mask = (x >= left) & (x < right)
|
|
1014
|
+
values.append(float(np.mean(y[bin_mask])) if np.any(bin_mask) else np.nan)
|
|
1015
|
+
ax.plot(centers, values, marker="o", linewidth=2, label=label)
|
|
1016
|
+
ax.axvline(ki_barrier, linestyle="--", color="black", alpha=0.6, label="KI")
|
|
1017
|
+
ax.set_title("Terminal Cliff Analysis")
|
|
1018
|
+
ax.set_xlabel("Terminal Spot")
|
|
1019
|
+
ax.set_ylabel("Average Payoff")
|
|
1020
|
+
ax.grid(True, alpha=0.3)
|
|
1021
|
+
ax.legend()
|
|
1022
|
+
fig.tight_layout()
|
|
1023
|
+
fig.savefig(path, dpi=150)
|
|
1024
|
+
plt.close(fig)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _generate_plot_artifacts(
|
|
1028
|
+
*,
|
|
1029
|
+
config: SnowballRiskComparisonConfig,
|
|
1030
|
+
products: Mapping[str, SnowballOption],
|
|
1031
|
+
pricing_env: PricingEnvironment,
|
|
1032
|
+
snapshots: Mapping[str, _PathSnapshot],
|
|
1033
|
+
greek_curves: GreekCurves,
|
|
1034
|
+
output_dir: Path,
|
|
1035
|
+
) -> Dict[str, Path]:
|
|
1036
|
+
if not config.generate_plots:
|
|
1037
|
+
return {}
|
|
1038
|
+
|
|
1039
|
+
plot_dir = output_dir / "plots"
|
|
1040
|
+
plot_dir.mkdir(parents=True, exist_ok=True)
|
|
1041
|
+
engine, _ = _select_stress_engine(config)
|
|
1042
|
+
|
|
1043
|
+
spot_curves = {
|
|
1044
|
+
label: _compute_stress_curve(
|
|
1045
|
+
base_product=product,
|
|
1046
|
+
pricing_env=pricing_env,
|
|
1047
|
+
engine=engine,
|
|
1048
|
+
shocks=config.stress_spot_shocks,
|
|
1049
|
+
kind="spot",
|
|
1050
|
+
)
|
|
1051
|
+
for label, product in products.items()
|
|
1052
|
+
if label in {"PPP-EKI", "PPP-DKI", "NPP-DKI"}
|
|
1053
|
+
}
|
|
1054
|
+
vol_curves = {
|
|
1055
|
+
label: _compute_stress_curve(
|
|
1056
|
+
base_product=product,
|
|
1057
|
+
pricing_env=pricing_env,
|
|
1058
|
+
engine=engine,
|
|
1059
|
+
shocks=config.stress_vol_shocks,
|
|
1060
|
+
kind="vol",
|
|
1061
|
+
)
|
|
1062
|
+
for label, product in products.items()
|
|
1063
|
+
if label in {"PPP-EKI", "PPP-DKI", "NPP-DKI"}
|
|
1064
|
+
}
|
|
1065
|
+
div_curves = {
|
|
1066
|
+
label: _compute_stress_curve(
|
|
1067
|
+
base_product=product,
|
|
1068
|
+
pricing_env=pricing_env,
|
|
1069
|
+
engine=engine,
|
|
1070
|
+
shocks=config.stress_div_shifts,
|
|
1071
|
+
kind="div",
|
|
1072
|
+
)
|
|
1073
|
+
for label, product in products.items()
|
|
1074
|
+
if label in {"PPP-EKI", "PPP-DKI", "NPP-DKI"}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
plot_paths = {
|
|
1078
|
+
"spot_stress": plot_dir / "spot_stress.png",
|
|
1079
|
+
"vol_stress": plot_dir / "vol_stress.png",
|
|
1080
|
+
"div_stress": plot_dir / "div_stress.png",
|
|
1081
|
+
"skew_smile": plot_dir / "skew_smile_delta_monitoring.png",
|
|
1082
|
+
"delta_monitoring": plot_dir / "delta_monitoring_heatmap.png",
|
|
1083
|
+
"delta_protection": plot_dir / "delta_protection_heatmap.png",
|
|
1084
|
+
"terminal_cliff": plot_dir / "terminal_cliff.png",
|
|
1085
|
+
"greek_delta": plot_dir / "greek_delta_vs_spot.png",
|
|
1086
|
+
"greek_gamma": plot_dir / "greek_gamma_vs_spot.png",
|
|
1087
|
+
"greek_vega": plot_dir / "greek_vega_vs_spot.png",
|
|
1088
|
+
"greek_rhoq": plot_dir / "greek_rhoq_vs_spot.png",
|
|
1089
|
+
}
|
|
1090
|
+
_save_multi_line_plot(
|
|
1091
|
+
curves=spot_curves,
|
|
1092
|
+
x_col="shock",
|
|
1093
|
+
y_col="pnl",
|
|
1094
|
+
title="Spot Shock PnL",
|
|
1095
|
+
xlabel="Spot Shock",
|
|
1096
|
+
ylabel="PnL",
|
|
1097
|
+
path=plot_paths["spot_stress"],
|
|
1098
|
+
)
|
|
1099
|
+
_save_multi_line_plot(
|
|
1100
|
+
curves=vol_curves,
|
|
1101
|
+
x_col="shock",
|
|
1102
|
+
y_col="pnl",
|
|
1103
|
+
title="Volatility Shock PnL",
|
|
1104
|
+
xlabel="Vol Shock",
|
|
1105
|
+
ylabel="PnL",
|
|
1106
|
+
path=plot_paths["vol_stress"],
|
|
1107
|
+
)
|
|
1108
|
+
_save_multi_line_plot(
|
|
1109
|
+
curves=div_curves,
|
|
1110
|
+
x_col="shock",
|
|
1111
|
+
y_col="pnl",
|
|
1112
|
+
title="Dividend Shock PnL",
|
|
1113
|
+
xlabel="Dividend Yield Shift",
|
|
1114
|
+
ylabel="PnL",
|
|
1115
|
+
path=plot_paths["div_stress"],
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
skew_smile = _compute_skew_smile_heatmap(
|
|
1119
|
+
products=products,
|
|
1120
|
+
pricing_env=pricing_env,
|
|
1121
|
+
engine=engine,
|
|
1122
|
+
skew_shocks=config.stress_skew_shocks,
|
|
1123
|
+
smile_shocks=config.stress_smile_shocks,
|
|
1124
|
+
)
|
|
1125
|
+
save_heatmap(
|
|
1126
|
+
x=np.array(config.stress_skew_shocks, dtype=float),
|
|
1127
|
+
y=np.array(config.stress_smile_shocks, dtype=float),
|
|
1128
|
+
z=skew_smile,
|
|
1129
|
+
title="Delta Monitoring under Skew / Smile Shocks",
|
|
1130
|
+
xlabel="Skew Shock",
|
|
1131
|
+
ylabel="Smile Shock",
|
|
1132
|
+
colorbar_label="PPP-EKI minus PPP-DKI",
|
|
1133
|
+
path=plot_paths["skew_smile"],
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
monitoring_heatmap = _compute_delta_heatmap(
|
|
1137
|
+
left_product=products["PPP-EKI"],
|
|
1138
|
+
right_product=products["PPP-DKI"],
|
|
1139
|
+
pricing_env=pricing_env,
|
|
1140
|
+
engine=engine,
|
|
1141
|
+
spot_shocks=config.stress_spot_shocks,
|
|
1142
|
+
vol_shocks=config.stress_vol_shocks,
|
|
1143
|
+
)
|
|
1144
|
+
save_heatmap(
|
|
1145
|
+
x=np.array(config.stress_spot_shocks, dtype=float),
|
|
1146
|
+
y=np.array(config.stress_vol_shocks, dtype=float),
|
|
1147
|
+
z=monitoring_heatmap,
|
|
1148
|
+
title="Monitoring Delta Heatmap",
|
|
1149
|
+
xlabel="Spot Shock",
|
|
1150
|
+
ylabel="Vol Shock",
|
|
1151
|
+
colorbar_label="PPP-EKI minus PPP-DKI",
|
|
1152
|
+
path=plot_paths["delta_monitoring"],
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
protection_heatmap = _compute_delta_heatmap(
|
|
1156
|
+
left_product=products["PPP-DKI"],
|
|
1157
|
+
right_product=products["NPP-DKI"],
|
|
1158
|
+
pricing_env=pricing_env,
|
|
1159
|
+
engine=engine,
|
|
1160
|
+
spot_shocks=config.stress_spot_shocks,
|
|
1161
|
+
vol_shocks=config.stress_vol_shocks,
|
|
1162
|
+
)
|
|
1163
|
+
save_heatmap(
|
|
1164
|
+
x=np.array(config.stress_spot_shocks, dtype=float),
|
|
1165
|
+
y=np.array(config.stress_vol_shocks, dtype=float),
|
|
1166
|
+
z=protection_heatmap,
|
|
1167
|
+
title="Protection Delta Heatmap",
|
|
1168
|
+
xlabel="Spot Shock",
|
|
1169
|
+
ylabel="Vol Shock",
|
|
1170
|
+
colorbar_label="PPP-DKI minus NPP-DKI",
|
|
1171
|
+
path=plot_paths["delta_protection"],
|
|
1172
|
+
)
|
|
1173
|
+
_save_terminal_cliff_plot(
|
|
1174
|
+
snapshots=snapshots,
|
|
1175
|
+
ki_barrier=config.ki_barrier,
|
|
1176
|
+
path=plot_paths["terminal_cliff"],
|
|
1177
|
+
)
|
|
1178
|
+
_save_greek_curve_plot(
|
|
1179
|
+
greek_df=greek_curves.delta,
|
|
1180
|
+
x_col="spot",
|
|
1181
|
+
title="Delta vs Spot",
|
|
1182
|
+
xlabel="Spot",
|
|
1183
|
+
ylabel="Delta",
|
|
1184
|
+
path=plot_paths["greek_delta"],
|
|
1185
|
+
)
|
|
1186
|
+
_save_greek_curve_plot(
|
|
1187
|
+
greek_df=greek_curves.gamma,
|
|
1188
|
+
x_col="spot",
|
|
1189
|
+
title="Gamma vs Spot",
|
|
1190
|
+
xlabel="Spot",
|
|
1191
|
+
ylabel="Gamma",
|
|
1192
|
+
path=plot_paths["greek_gamma"],
|
|
1193
|
+
)
|
|
1194
|
+
_save_greek_curve_plot(
|
|
1195
|
+
greek_df=greek_curves.vega,
|
|
1196
|
+
x_col="spot",
|
|
1197
|
+
title="Vega vs Spot",
|
|
1198
|
+
xlabel="Spot",
|
|
1199
|
+
ylabel="Vega",
|
|
1200
|
+
path=plot_paths["greek_vega"],
|
|
1201
|
+
)
|
|
1202
|
+
_save_greek_curve_plot(
|
|
1203
|
+
greek_df=greek_curves.rhoq,
|
|
1204
|
+
x_col="spot",
|
|
1205
|
+
title="RhoQ vs Spot",
|
|
1206
|
+
xlabel="Spot",
|
|
1207
|
+
ylabel="Dividend Rho (RhoQ)",
|
|
1208
|
+
path=plot_paths["greek_rhoq"],
|
|
1209
|
+
)
|
|
1210
|
+
plot_paths.update(
|
|
1211
|
+
{
|
|
1212
|
+
"greek_delta_q": plot_dir / "greek_delta_vs_q.png",
|
|
1213
|
+
"greek_gamma_q": plot_dir / "greek_gamma_vs_q.png",
|
|
1214
|
+
"greek_vega_q": plot_dir / "greek_vega_vs_q.png",
|
|
1215
|
+
"greek_rhoq_q": plot_dir / "greek_rhoq_vs_q.png",
|
|
1216
|
+
}
|
|
1217
|
+
)
|
|
1218
|
+
_save_greek_curve_plot(
|
|
1219
|
+
greek_df=greek_curves.delta_q_curve,
|
|
1220
|
+
x_col="q",
|
|
1221
|
+
title="Delta vs Dividend Yield",
|
|
1222
|
+
xlabel="Dividend Yield q",
|
|
1223
|
+
ylabel="Delta",
|
|
1224
|
+
path=plot_paths["greek_delta_q"],
|
|
1225
|
+
)
|
|
1226
|
+
_save_greek_curve_plot(
|
|
1227
|
+
greek_df=greek_curves.gamma_q_curve,
|
|
1228
|
+
x_col="q",
|
|
1229
|
+
title="Gamma vs Dividend Yield",
|
|
1230
|
+
xlabel="Dividend Yield q",
|
|
1231
|
+
ylabel="Gamma",
|
|
1232
|
+
path=plot_paths["greek_gamma_q"],
|
|
1233
|
+
)
|
|
1234
|
+
_save_greek_curve_plot(
|
|
1235
|
+
greek_df=greek_curves.vega_q_curve,
|
|
1236
|
+
x_col="q",
|
|
1237
|
+
title="Vega vs Dividend Yield",
|
|
1238
|
+
xlabel="Dividend Yield q",
|
|
1239
|
+
ylabel="Vega",
|
|
1240
|
+
path=plot_paths["greek_vega_q"],
|
|
1241
|
+
)
|
|
1242
|
+
_save_greek_curve_plot(
|
|
1243
|
+
greek_df=greek_curves.rhoq_q_curve,
|
|
1244
|
+
x_col="q",
|
|
1245
|
+
title="RhoQ vs Dividend Yield",
|
|
1246
|
+
xlabel="Dividend Yield q",
|
|
1247
|
+
ylabel="Dividend Rho (RhoQ)",
|
|
1248
|
+
path=plot_paths["greek_rhoq_q"],
|
|
1249
|
+
)
|
|
1250
|
+
plot_paths.update(
|
|
1251
|
+
{
|
|
1252
|
+
"greek_q_near_ki": plot_dir / "greek_q_near_ki_panel.png",
|
|
1253
|
+
"greek_q_near_ko": plot_dir / "greek_q_near_ko_panel.png",
|
|
1254
|
+
}
|
|
1255
|
+
)
|
|
1256
|
+
_save_greek_q_slice_panel(
|
|
1257
|
+
slice_curves=greek_curves.q_slice_curves["near_ki"],
|
|
1258
|
+
title=f"Greeks vs q at Spot = KI ({config.ki_barrier:.4f})",
|
|
1259
|
+
path=plot_paths["greek_q_near_ki"],
|
|
1260
|
+
)
|
|
1261
|
+
_save_greek_q_slice_panel(
|
|
1262
|
+
slice_curves=greek_curves.q_slice_curves["near_ko"],
|
|
1263
|
+
title=f"Greeks vs q at Spot = KO ({config.ko_barrier:.4f})",
|
|
1264
|
+
path=plot_paths["greek_q_near_ko"],
|
|
1265
|
+
)
|
|
1266
|
+
return plot_paths
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _fmt_pct(value: float) -> str:
|
|
1270
|
+
return f"{value:.4%}"
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def _fmt_num(value: float) -> str:
|
|
1274
|
+
return f"{value:,.4f}"
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _add_heading(document: Document, text: str, level: int = 1) -> None:
|
|
1278
|
+
document.add_heading(text, level=level)
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _add_paragraph(document: Document, text: str, *, bold: bool = False) -> None:
|
|
1282
|
+
paragraph = document.add_paragraph()
|
|
1283
|
+
run = paragraph.add_run(text)
|
|
1284
|
+
run.bold = bold
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def _add_dataframe_table(document: Document, df: pd.DataFrame, title: Optional[str] = None) -> None:
|
|
1288
|
+
if title:
|
|
1289
|
+
_add_paragraph(document, title, bold=True)
|
|
1290
|
+
table = document.add_table(rows=1, cols=len(df.columns))
|
|
1291
|
+
table.style = "Light Grid Accent 1"
|
|
1292
|
+
hdr_cells = table.rows[0].cells
|
|
1293
|
+
for idx, col in enumerate(df.columns):
|
|
1294
|
+
hdr_cells[idx].text = str(col)
|
|
1295
|
+
for _, row in df.iterrows():
|
|
1296
|
+
cells = table.add_row().cells
|
|
1297
|
+
for idx, col in enumerate(df.columns):
|
|
1298
|
+
value = row[col]
|
|
1299
|
+
if isinstance(value, float):
|
|
1300
|
+
cells[idx].text = f"{value:.4f}"
|
|
1301
|
+
else:
|
|
1302
|
+
cells[idx].text = str(value)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _add_picture_if_exists(document: Document, path: Optional[Path], width: float = 6.6) -> None:
|
|
1306
|
+
if path is None or not path.exists():
|
|
1307
|
+
return
|
|
1308
|
+
document.add_picture(str(path), width=Inches(width))
|
|
1309
|
+
document.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def _build_metrics_table(
|
|
1313
|
+
snapshots: Mapping[str, _PathSnapshot],
|
|
1314
|
+
) -> pd.DataFrame:
|
|
1315
|
+
rows = []
|
|
1316
|
+
order = ["PPP-EKI", "PPP-DKI", "NPP-DKI", "PPP-DKI-Parachute", "PPP-EKI-Parachute"]
|
|
1317
|
+
for label in order:
|
|
1318
|
+
metrics = snapshots[label].metrics
|
|
1319
|
+
rows.append(
|
|
1320
|
+
{
|
|
1321
|
+
"Structure": label,
|
|
1322
|
+
"PV": metrics.pv,
|
|
1323
|
+
"StdErr": metrics.std_error,
|
|
1324
|
+
"P(No KO)": metrics.no_ko_probability,
|
|
1325
|
+
"P(KI)": metrics.ki_probability,
|
|
1326
|
+
"P(KI & No KO)": metrics.ki_no_ko_probability,
|
|
1327
|
+
"P(loss>5%)": metrics.loss_prob_5,
|
|
1328
|
+
"P(loss>10%)": metrics.loss_prob_10,
|
|
1329
|
+
"P(loss>20%)": metrics.loss_prob_20,
|
|
1330
|
+
"ES95": metrics.es95,
|
|
1331
|
+
"ES99": metrics.es99,
|
|
1332
|
+
}
|
|
1333
|
+
)
|
|
1334
|
+
return pd.DataFrame(rows)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _build_contract_terms_table(
|
|
1338
|
+
*,
|
|
1339
|
+
config: SnowballRiskComparisonConfig,
|
|
1340
|
+
products: Mapping[str, SnowballOption],
|
|
1341
|
+
language: str,
|
|
1342
|
+
) -> pd.DataFrame:
|
|
1343
|
+
is_english = language == "en"
|
|
1344
|
+
rows = []
|
|
1345
|
+
order = ["PPP-EKI", "PPP-DKI", "NPP-DKI", "PPP-DKI-Parachute", "PPP-EKI-Parachute"]
|
|
1346
|
+
for label in order:
|
|
1347
|
+
product = products[label]
|
|
1348
|
+
ko_barrier = product.barrier_config.ko_barrier
|
|
1349
|
+
final_ko_barrier = (
|
|
1350
|
+
ko_barrier[-1] if isinstance(ko_barrier, list) else ko_barrier
|
|
1351
|
+
)
|
|
1352
|
+
protection_type = product.payoff_config.protection_type.name
|
|
1353
|
+
ki_dates = product.barrier_config.ki_observation_dates or []
|
|
1354
|
+
if len(ki_dates) == 1:
|
|
1355
|
+
monitoring = "European final-only KI" if is_english else "欧式终值敲入"
|
|
1356
|
+
else:
|
|
1357
|
+
monitoring = "Daily business-day KI" if is_english else "交易日日度敲入"
|
|
1358
|
+
rows.append(
|
|
1359
|
+
{
|
|
1360
|
+
("Structure" if is_english else "结构"): label,
|
|
1361
|
+
("Monitoring" if is_english else "敲入监控"): monitoring,
|
|
1362
|
+
("Tenor" if is_english else "期限"): (
|
|
1363
|
+
f"{config.tenor_months}M" if is_english else f"{config.tenor_months}个月"
|
|
1364
|
+
),
|
|
1365
|
+
("KO Schedule" if is_english else "敲出安排"): (
|
|
1366
|
+
f"Monthly, months {config.ko_start_month}-{config.tenor_months}"
|
|
1367
|
+
if is_english
|
|
1368
|
+
else f"月度观察,第{config.ko_start_month}-{config.tenor_months}个月"
|
|
1369
|
+
),
|
|
1370
|
+
("KO Barrier" if is_english else "敲出价"): f"{float(config.ko_barrier):.4f}",
|
|
1371
|
+
("Final KO" if is_english else "最终敲出价"): f"{float(final_ko_barrier):.4f}",
|
|
1372
|
+
("KI Barrier" if is_english else "敲入价"): f"{float(config.ki_barrier):.4f}",
|
|
1373
|
+
("Coupon / Rebate" if is_english else "票息 / 红利"): _fmt_pct(config.annual_coupon),
|
|
1374
|
+
("PV Principal" if is_english else "PV本金处理"): (
|
|
1375
|
+
"Excluded" if is_english else "不含本金"
|
|
1376
|
+
),
|
|
1377
|
+
("Protection" if is_english else "保本方式"): (
|
|
1378
|
+
f"{protection_type} ({_fmt_pct(config.protection_rate)} floor)"
|
|
1379
|
+
if is_english and protection_type == "PARTIAL"
|
|
1380
|
+
else (
|
|
1381
|
+
protection_type
|
|
1382
|
+
if is_english
|
|
1383
|
+
else ("部分保本" if protection_type == "PARTIAL" else "非保本")
|
|
1384
|
+
)
|
|
1385
|
+
),
|
|
1386
|
+
("Post-KI KO" if is_english else "敲入后可敲出"): (
|
|
1387
|
+
"Allowed" if is_english else "允许"
|
|
1388
|
+
),
|
|
1389
|
+
}
|
|
1390
|
+
)
|
|
1391
|
+
return pd.DataFrame(rows)
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def _build_delta_table(deltas: StructuralDeltas) -> pd.DataFrame:
|
|
1395
|
+
return pd.DataFrame(
|
|
1396
|
+
[
|
|
1397
|
+
{"Measure": "Δ_monitoring", "Value": deltas.delta_monitoring},
|
|
1398
|
+
{"Measure": "Δ_protection", "Value": deltas.delta_protection},
|
|
1399
|
+
{"Measure": "Δ_parachute_DKI", "Value": deltas.delta_parachute_dki},
|
|
1400
|
+
{"Measure": "Δ_parachute_EKI", "Value": deltas.delta_parachute_eki},
|
|
1401
|
+
]
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _build_band_table(snapshots: Mapping[str, _PathSnapshot]) -> pd.DataFrame:
|
|
1406
|
+
rows = []
|
|
1407
|
+
for label in ("PPP-EKI", "PPP-DKI", "NPP-DKI", "PPP-DKI-Parachute", "PPP-EKI-Parachute"):
|
|
1408
|
+
metrics = snapshots[label].metrics
|
|
1409
|
+
rows.append(
|
|
1410
|
+
{
|
|
1411
|
+
"Structure": label,
|
|
1412
|
+
"Rebound Band": metrics.rebound_band_probability,
|
|
1413
|
+
"Rebound Band Loss": metrics.rebound_band_loss_probability,
|
|
1414
|
+
"Parachute Rescue Band": metrics.parachute_rescue_band_probability,
|
|
1415
|
+
"Parachute Rescue Loss": metrics.parachute_rescue_loss_probability,
|
|
1416
|
+
"Terminal Cliff Band": metrics.terminal_cliff_band_probability,
|
|
1417
|
+
"Terminal Cliff Loss": metrics.terminal_cliff_loss_probability,
|
|
1418
|
+
}
|
|
1419
|
+
)
|
|
1420
|
+
return pd.DataFrame(rows)
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def _build_deterministic_table(cases: Sequence[DeterministicCaseResult]) -> pd.DataFrame:
|
|
1424
|
+
rows = []
|
|
1425
|
+
for case in cases:
|
|
1426
|
+
for structure, outcome in case.outcomes.items():
|
|
1427
|
+
rows.append(
|
|
1428
|
+
{
|
|
1429
|
+
"Case": case.name,
|
|
1430
|
+
"Structure": structure,
|
|
1431
|
+
"State": outcome.state,
|
|
1432
|
+
"Payoff": outcome.payoff,
|
|
1433
|
+
"Return": outcome.payoff_ratio,
|
|
1434
|
+
"Terminal": outcome.terminal_spot,
|
|
1435
|
+
}
|
|
1436
|
+
)
|
|
1437
|
+
return pd.DataFrame(rows)
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _build_terminology_table(language: str) -> pd.DataFrame:
|
|
1441
|
+
is_english = language == "en"
|
|
1442
|
+
terms = [
|
|
1443
|
+
(
|
|
1444
|
+
"Snowball",
|
|
1445
|
+
"雪球",
|
|
1446
|
+
"Autocallable structure with KO observations, KI condition, coupons, and a maturity payoff that depends on the path state.",
|
|
1447
|
+
"带有敲出观察、敲入条件、票息以及按路径状态决定到期收益的自动赎回结构。",
|
|
1448
|
+
),
|
|
1449
|
+
(
|
|
1450
|
+
"KI",
|
|
1451
|
+
"敲入",
|
|
1452
|
+
"Knock-in event. Once activated, the note moves into the downside payoff regime if it survives to maturity without KO.",
|
|
1453
|
+
"敲入事件。一旦触发,若产品未提前敲出并持有到期,则进入下跌损失收益状态。",
|
|
1454
|
+
),
|
|
1455
|
+
(
|
|
1456
|
+
"EKI",
|
|
1457
|
+
"欧式敲入",
|
|
1458
|
+
"European knock-in. The KI barrier is checked only at the final fixing.",
|
|
1459
|
+
"欧式敲入。仅在最终观察日检查敲入障碍。",
|
|
1460
|
+
),
|
|
1461
|
+
(
|
|
1462
|
+
"DKI",
|
|
1463
|
+
"日度敲入",
|
|
1464
|
+
"Daily knock-in. The KI barrier is checked on each business-day close.",
|
|
1465
|
+
"日度敲入。每个交易日收盘检查敲入障碍。",
|
|
1466
|
+
),
|
|
1467
|
+
(
|
|
1468
|
+
"KO",
|
|
1469
|
+
"敲出",
|
|
1470
|
+
"Knock-out event. If triggered on an observation date, the note terminates early and pays the contractual KO payoff.",
|
|
1471
|
+
"敲出事件。在观察日触发后,产品提前终止并支付约定的敲出收益。",
|
|
1472
|
+
),
|
|
1473
|
+
(
|
|
1474
|
+
"PPP",
|
|
1475
|
+
"部分保本",
|
|
1476
|
+
"Partial principal protection. Losses are floored by a contractual protection rate rather than fully exposed.",
|
|
1477
|
+
"部分保本。损失受到保本比例下限约束,而不是完全暴露。",
|
|
1478
|
+
),
|
|
1479
|
+
(
|
|
1480
|
+
"NPP",
|
|
1481
|
+
"非保本",
|
|
1482
|
+
"Non-principal protected. Downside is not floored by a protection level.",
|
|
1483
|
+
"非保本。下行损失没有保本下限约束。",
|
|
1484
|
+
),
|
|
1485
|
+
(
|
|
1486
|
+
"Parachute",
|
|
1487
|
+
"降落伞",
|
|
1488
|
+
"Feature that lowers the final KO barrier to the KI barrier to rescue some KI paths that recover by maturity.",
|
|
1489
|
+
"一种将最终敲出价降至敲入价的条款,用于挽救部分到期前反弹的敲入路径。",
|
|
1490
|
+
),
|
|
1491
|
+
(
|
|
1492
|
+
"Rebound Band",
|
|
1493
|
+
"反弹带",
|
|
1494
|
+
"State bucket defined by interim breach below KI, no KO, and terminal spot between KI and strike.",
|
|
1495
|
+
"一种状态区间:路径中途跌破 KI、未敲出、且终值位于 KI 与行权价之间。",
|
|
1496
|
+
),
|
|
1497
|
+
(
|
|
1498
|
+
"Parachute Rescue Band",
|
|
1499
|
+
"降落伞救援带",
|
|
1500
|
+
"State bucket where the path breached KI, avoided standard KO, and finished between KI and the standard final KO barrier.",
|
|
1501
|
+
"一种状态区间:路径曾跌破 KI、未达到标准敲出、且终值位于 KI 与标准最终敲出价之间。",
|
|
1502
|
+
),
|
|
1503
|
+
(
|
|
1504
|
+
"Terminal Cliff",
|
|
1505
|
+
"终值悬崖",
|
|
1506
|
+
"Concentrated terminal-state risk when spot finishes close to the KI barrier near maturity.",
|
|
1507
|
+
"到期前终值贴近 KI 时形成的集中终值状态风险。",
|
|
1508
|
+
),
|
|
1509
|
+
(
|
|
1510
|
+
"PV",
|
|
1511
|
+
"现值",
|
|
1512
|
+
"Present value of discounted expected cashflows under the report model.",
|
|
1513
|
+
"在本报告模型下,贴现后的期望现金流现值。",
|
|
1514
|
+
),
|
|
1515
|
+
(
|
|
1516
|
+
"ES95 / ES99",
|
|
1517
|
+
"ES95 / ES99",
|
|
1518
|
+
"Expected shortfall at the 95% / 99% confidence tail, measuring average tail loss severity.",
|
|
1519
|
+
"95% / 99% 置信尾部的期望短缺,用于衡量平均尾部损失严重度。",
|
|
1520
|
+
),
|
|
1521
|
+
(
|
|
1522
|
+
"Common Random Numbers",
|
|
1523
|
+
"共同随机数",
|
|
1524
|
+
"Matched Monte Carlo draws reused across structures to make structural deltas less noisy.",
|
|
1525
|
+
"在不同结构之间复用相同蒙特卡洛随机路径,以减少结构差值的噪声。",
|
|
1526
|
+
),
|
|
1527
|
+
]
|
|
1528
|
+
rows = []
|
|
1529
|
+
for en_term, zh_term, en_def, zh_def in terms:
|
|
1530
|
+
rows.append(
|
|
1531
|
+
{
|
|
1532
|
+
("Term" if is_english else "术语"): en_term if is_english else zh_term,
|
|
1533
|
+
("English" if is_english else "英文"): en_term,
|
|
1534
|
+
("Chinese" if is_english else "中文"): zh_term,
|
|
1535
|
+
("Definition" if is_english else "定义"): en_def if is_english else zh_def,
|
|
1536
|
+
}
|
|
1537
|
+
)
|
|
1538
|
+
return pd.DataFrame(rows)
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def _compute_greek_curves(
|
|
1542
|
+
*,
|
|
1543
|
+
config: SnowballRiskComparisonConfig,
|
|
1544
|
+
pricing_env: PricingEnvironment,
|
|
1545
|
+
products: Mapping[str, SnowballOption],
|
|
1546
|
+
) -> GreekCurves:
|
|
1547
|
+
engine, _ = _select_stress_engine(config)
|
|
1548
|
+
greek_params = EngineParams(bus_days_in_year=config.business_days_in_year)
|
|
1549
|
+
calculator = GreeksCalculator(params=greek_params)
|
|
1550
|
+
labels = ("PPP-EKI", "PPP-DKI", "NPP-DKI")
|
|
1551
|
+
spot_grid = np.array(
|
|
1552
|
+
[config.initial_price * float(mult) for mult in config.greek_spot_multipliers],
|
|
1553
|
+
dtype=float,
|
|
1554
|
+
)
|
|
1555
|
+
base_q = float(config.dividend_yield)
|
|
1556
|
+
q_grid = np.array(
|
|
1557
|
+
[max(0.0, base_q + float(shift)) for shift in config.greek_q_shifts],
|
|
1558
|
+
dtype=float,
|
|
1559
|
+
)
|
|
1560
|
+
greek_frames: dict[str, pd.DataFrame] = {}
|
|
1561
|
+
for greek_name in ("delta", "gamma", "vega", "rhoq"):
|
|
1562
|
+
rows = []
|
|
1563
|
+
for spot in spot_grid:
|
|
1564
|
+
row = {"spot": float(spot)}
|
|
1565
|
+
env = _clone_env(pricing_env, spot=float(spot))
|
|
1566
|
+
for label in labels:
|
|
1567
|
+
values = calculator.calculate(
|
|
1568
|
+
products[label],
|
|
1569
|
+
env,
|
|
1570
|
+
engine,
|
|
1571
|
+
greeks=[
|
|
1572
|
+
"delta",
|
|
1573
|
+
"gamma",
|
|
1574
|
+
"vega",
|
|
1575
|
+
"dividend_rho",
|
|
1576
|
+
],
|
|
1577
|
+
)
|
|
1578
|
+
key = "dividend_rho" if greek_name == "rhoq" else greek_name
|
|
1579
|
+
row[label] = float(values[key])
|
|
1580
|
+
rows.append(row)
|
|
1581
|
+
greek_frames[greek_name] = pd.DataFrame(rows)
|
|
1582
|
+
|
|
1583
|
+
greek_q_frames: dict[str, pd.DataFrame] = {}
|
|
1584
|
+
for greek_name in ("delta", "gamma", "vega", "rhoq"):
|
|
1585
|
+
rows = []
|
|
1586
|
+
for q_val in q_grid:
|
|
1587
|
+
row = {"q": float(q_val)}
|
|
1588
|
+
env = _clone_env(
|
|
1589
|
+
pricing_env,
|
|
1590
|
+
div_yield=ContinuousDividendYield(div_yield=float(q_val)),
|
|
1591
|
+
)
|
|
1592
|
+
for label in labels:
|
|
1593
|
+
values = calculator.calculate(
|
|
1594
|
+
products[label],
|
|
1595
|
+
env,
|
|
1596
|
+
engine,
|
|
1597
|
+
greeks=[
|
|
1598
|
+
"delta",
|
|
1599
|
+
"gamma",
|
|
1600
|
+
"vega",
|
|
1601
|
+
"dividend_rho",
|
|
1602
|
+
],
|
|
1603
|
+
)
|
|
1604
|
+
key = "dividend_rho" if greek_name == "rhoq" else greek_name
|
|
1605
|
+
row[label] = float(values[key])
|
|
1606
|
+
rows.append(row)
|
|
1607
|
+
greek_q_frames[greek_name] = pd.DataFrame(rows)
|
|
1608
|
+
|
|
1609
|
+
q_slice_curves: dict[str, dict[str, pd.DataFrame]] = {}
|
|
1610
|
+
for slice_name, slice_spot in config.greek_q_slice_spots.items():
|
|
1611
|
+
slice_frames: dict[str, pd.DataFrame] = {}
|
|
1612
|
+
for greek_name in ("delta", "gamma", "vega", "rhoq"):
|
|
1613
|
+
rows = []
|
|
1614
|
+
for q_val in q_grid:
|
|
1615
|
+
row = {"q": float(q_val)}
|
|
1616
|
+
env = _clone_env(
|
|
1617
|
+
pricing_env,
|
|
1618
|
+
spot=float(slice_spot),
|
|
1619
|
+
div_yield=ContinuousDividendYield(div_yield=float(q_val)),
|
|
1620
|
+
)
|
|
1621
|
+
for label in labels:
|
|
1622
|
+
values = calculator.calculate(
|
|
1623
|
+
products[label],
|
|
1624
|
+
env,
|
|
1625
|
+
engine,
|
|
1626
|
+
greeks=[
|
|
1627
|
+
"delta",
|
|
1628
|
+
"gamma",
|
|
1629
|
+
"vega",
|
|
1630
|
+
"dividend_rho",
|
|
1631
|
+
],
|
|
1632
|
+
)
|
|
1633
|
+
key = "dividend_rho" if greek_name == "rhoq" else greek_name
|
|
1634
|
+
row[label] = float(values[key])
|
|
1635
|
+
rows.append(row)
|
|
1636
|
+
slice_frames[greek_name] = pd.DataFrame(rows)
|
|
1637
|
+
q_slice_curves[slice_name] = slice_frames
|
|
1638
|
+
|
|
1639
|
+
key_rows = []
|
|
1640
|
+
for spot in config.greek_key_spots:
|
|
1641
|
+
env = _clone_env(pricing_env, spot=float(spot))
|
|
1642
|
+
for label in labels:
|
|
1643
|
+
values = calculator.calculate(
|
|
1644
|
+
products[label],
|
|
1645
|
+
env,
|
|
1646
|
+
engine,
|
|
1647
|
+
greeks=[
|
|
1648
|
+
"delta",
|
|
1649
|
+
"gamma",
|
|
1650
|
+
"vega",
|
|
1651
|
+
"dividend_rho",
|
|
1652
|
+
],
|
|
1653
|
+
)
|
|
1654
|
+
key_rows.append(
|
|
1655
|
+
{
|
|
1656
|
+
"Spot": float(spot),
|
|
1657
|
+
"Structure": label,
|
|
1658
|
+
"Delta": float(values["delta"]),
|
|
1659
|
+
"Gamma": float(values["gamma"]),
|
|
1660
|
+
"Vega": float(values["vega"]),
|
|
1661
|
+
"RhoQ": float(values["dividend_rho"]),
|
|
1662
|
+
}
|
|
1663
|
+
)
|
|
1664
|
+
|
|
1665
|
+
key_q_rows = []
|
|
1666
|
+
for q_shift in config.greek_key_q_shifts:
|
|
1667
|
+
q_val = max(0.0, base_q + float(q_shift))
|
|
1668
|
+
env = _clone_env(
|
|
1669
|
+
pricing_env,
|
|
1670
|
+
div_yield=ContinuousDividendYield(div_yield=float(q_val)),
|
|
1671
|
+
)
|
|
1672
|
+
for label in labels:
|
|
1673
|
+
values = calculator.calculate(
|
|
1674
|
+
products[label],
|
|
1675
|
+
env,
|
|
1676
|
+
engine,
|
|
1677
|
+
greeks=[
|
|
1678
|
+
"delta",
|
|
1679
|
+
"gamma",
|
|
1680
|
+
"vega",
|
|
1681
|
+
"dividend_rho",
|
|
1682
|
+
],
|
|
1683
|
+
)
|
|
1684
|
+
key_q_rows.append(
|
|
1685
|
+
{
|
|
1686
|
+
"QShift": float(q_shift),
|
|
1687
|
+
"QLevel": float(q_val),
|
|
1688
|
+
"Structure": label,
|
|
1689
|
+
"Delta": float(values["delta"]),
|
|
1690
|
+
"Gamma": float(values["gamma"]),
|
|
1691
|
+
"Vega": float(values["vega"]),
|
|
1692
|
+
"RhoQ": float(values["dividend_rho"]),
|
|
1693
|
+
}
|
|
1694
|
+
)
|
|
1695
|
+
|
|
1696
|
+
return GreekCurves(
|
|
1697
|
+
spot_grid=spot_grid,
|
|
1698
|
+
delta=greek_frames["delta"],
|
|
1699
|
+
gamma=greek_frames["gamma"],
|
|
1700
|
+
vega=greek_frames["vega"],
|
|
1701
|
+
rhoq=greek_frames["rhoq"],
|
|
1702
|
+
key_spot_table=pd.DataFrame(key_rows),
|
|
1703
|
+
q_grid=q_grid,
|
|
1704
|
+
delta_q_curve=greek_q_frames["delta"],
|
|
1705
|
+
gamma_q_curve=greek_q_frames["gamma"],
|
|
1706
|
+
vega_q_curve=greek_q_frames["vega"],
|
|
1707
|
+
rhoq_q_curve=greek_q_frames["rhoq"],
|
|
1708
|
+
key_q_table=pd.DataFrame(key_q_rows),
|
|
1709
|
+
q_slice_curves=q_slice_curves,
|
|
1710
|
+
)
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def _save_greek_curve_plot(
|
|
1714
|
+
*,
|
|
1715
|
+
greek_df: pd.DataFrame,
|
|
1716
|
+
x_col: str,
|
|
1717
|
+
title: str,
|
|
1718
|
+
xlabel: str,
|
|
1719
|
+
ylabel: str,
|
|
1720
|
+
path: Path,
|
|
1721
|
+
) -> None:
|
|
1722
|
+
plt = _require_matplotlib()
|
|
1723
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1724
|
+
fig, ax = plt.subplots(figsize=(8, 4.5))
|
|
1725
|
+
for label in ("PPP-EKI", "PPP-DKI", "NPP-DKI"):
|
|
1726
|
+
ax.plot(
|
|
1727
|
+
greek_df[x_col].to_numpy(),
|
|
1728
|
+
greek_df[label].to_numpy(),
|
|
1729
|
+
linewidth=2,
|
|
1730
|
+
label=label,
|
|
1731
|
+
)
|
|
1732
|
+
ax.set_title(title)
|
|
1733
|
+
ax.set_xlabel(xlabel)
|
|
1734
|
+
ax.set_ylabel(ylabel)
|
|
1735
|
+
ax.grid(True, alpha=0.3)
|
|
1736
|
+
ax.legend()
|
|
1737
|
+
fig.tight_layout()
|
|
1738
|
+
fig.savefig(path, dpi=150)
|
|
1739
|
+
plt.close(fig)
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
def _save_greek_q_slice_panel(
|
|
1743
|
+
*,
|
|
1744
|
+
slice_curves: Mapping[str, pd.DataFrame],
|
|
1745
|
+
title: str,
|
|
1746
|
+
path: Path,
|
|
1747
|
+
) -> None:
|
|
1748
|
+
plt = _require_matplotlib()
|
|
1749
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1750
|
+
fig, axes = plt.subplots(2, 2, figsize=(10, 7))
|
|
1751
|
+
specs = [
|
|
1752
|
+
("delta", "Delta vs q", "Delta"),
|
|
1753
|
+
("gamma", "Gamma vs q", "Gamma"),
|
|
1754
|
+
("vega", "Vega vs q", "Vega"),
|
|
1755
|
+
("rhoq", "RhoQ vs q", "Dividend Rho (RhoQ)"),
|
|
1756
|
+
]
|
|
1757
|
+
for ax, (greek_key, subtitle, ylabel) in zip(axes.flatten(), specs):
|
|
1758
|
+
greek_df = slice_curves[greek_key]
|
|
1759
|
+
for label in ("PPP-EKI", "PPP-DKI", "NPP-DKI"):
|
|
1760
|
+
ax.plot(
|
|
1761
|
+
greek_df["q"].to_numpy(),
|
|
1762
|
+
greek_df[label].to_numpy(),
|
|
1763
|
+
linewidth=2,
|
|
1764
|
+
label=label,
|
|
1765
|
+
)
|
|
1766
|
+
ax.set_title(subtitle)
|
|
1767
|
+
ax.set_xlabel("Dividend Yield q")
|
|
1768
|
+
ax.set_ylabel(ylabel)
|
|
1769
|
+
ax.grid(True, alpha=0.3)
|
|
1770
|
+
axes[0, 0].legend()
|
|
1771
|
+
fig.suptitle(title)
|
|
1772
|
+
fig.tight_layout()
|
|
1773
|
+
fig.savefig(path, dpi=150)
|
|
1774
|
+
plt.close(fig)
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
def _compose_english_summary(
|
|
1778
|
+
*,
|
|
1779
|
+
snapshots: Mapping[str, _PathSnapshot],
|
|
1780
|
+
deltas: StructuralDeltas,
|
|
1781
|
+
) -> str:
|
|
1782
|
+
ppp_eki = snapshots["PPP-EKI"].metrics
|
|
1783
|
+
ppp_dki = snapshots["PPP-DKI"].metrics
|
|
1784
|
+
npp_dki = snapshots["NPP-DKI"].metrics
|
|
1785
|
+
return (
|
|
1786
|
+
"Under normalized China-style terms, PPP-EKI is lower risk than PPP-DKI on "
|
|
1787
|
+
f"raw loss probability ({_fmt_pct(ppp_eki.loss_prob_5)} vs {_fmt_pct(ppp_dki.loss_prob_5)} for loss >5%) "
|
|
1788
|
+
f"and NPP-DKI is materially worse on tail loss severity (ES95 {_fmt_num(npp_dki.es95)} vs {_fmt_num(ppp_dki.es95)}). "
|
|
1789
|
+
f"The monitoring value Δ_monitoring is {_fmt_num(deltas.delta_monitoring)}, while Δ_protection is {_fmt_num(deltas.delta_protection)}. "
|
|
1790
|
+
"PV is quoted ex-principal, consistent with current China index-note convention. "
|
|
1791
|
+
"The residual risk in EKI is more terminally concentrated: near the KI barrier at maturity, small terminal moves "
|
|
1792
|
+
"can flip the note between the rebate state and the downside state."
|
|
1793
|
+
)
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def _compose_chinese_summary(
|
|
1797
|
+
*,
|
|
1798
|
+
snapshots: Mapping[str, _PathSnapshot],
|
|
1799
|
+
deltas: StructuralDeltas,
|
|
1800
|
+
) -> str:
|
|
1801
|
+
ppp_eki = snapshots["PPP-EKI"].metrics
|
|
1802
|
+
ppp_dki = snapshots["PPP-DKI"].metrics
|
|
1803
|
+
npp_dki = snapshots["NPP-DKI"].metrics
|
|
1804
|
+
return (
|
|
1805
|
+
"在归一化的中资雪球条款下,部分保本欧式敲入雪球在本金亏损概率上低于部分保本日度敲入雪球,"
|
|
1806
|
+
f"其中亏损超过5%的概率分别为{_fmt_pct(ppp_eki.loss_prob_5)}与{_fmt_pct(ppp_dki.loss_prob_5)};"
|
|
1807
|
+
f"非保本日度敲入雪球的尾部损失显著更差,ES95分别为{_fmt_num(npp_dki.es95)}与{_fmt_num(ppp_dki.es95)}。"
|
|
1808
|
+
f"监控方式增量价值 Δ_monitoring 为{_fmt_num(deltas.delta_monitoring)},保本增量价值 Δ_protection 为{_fmt_num(deltas.delta_protection)}。"
|
|
1809
|
+
"PV 采用当前中国指数雪球常见的不含本金口径。欧式敲入并非在所有维度都更安全,其剩余风险更集中于终值附近,尤其是在到期前临近敲入价时,终值的小幅波动即可触发状态切换。"
|
|
1810
|
+
)
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
def _add_report_sections(
|
|
1814
|
+
*,
|
|
1815
|
+
document: Document,
|
|
1816
|
+
language: str,
|
|
1817
|
+
config: SnowballRiskComparisonConfig,
|
|
1818
|
+
pricing_env: PricingEnvironment,
|
|
1819
|
+
products: Mapping[str, SnowballOption],
|
|
1820
|
+
snapshots: Mapping[str, _PathSnapshot],
|
|
1821
|
+
greek_curves: GreekCurves,
|
|
1822
|
+
deltas: StructuralDeltas,
|
|
1823
|
+
deterministic_cases: Sequence[DeterministicCaseResult],
|
|
1824
|
+
plot_paths: Mapping[str, Path],
|
|
1825
|
+
) -> None:
|
|
1826
|
+
is_english = language == "en"
|
|
1827
|
+
title = (
|
|
1828
|
+
"Snowball Monitoring / Protection / Parachute Risk Comparison"
|
|
1829
|
+
if is_english
|
|
1830
|
+
else "雪球监控方式 / 保本 / 降落伞特征风险对比报告"
|
|
1831
|
+
)
|
|
1832
|
+
subtitle = (
|
|
1833
|
+
"Normalized China-style structures, English first."
|
|
1834
|
+
if is_english
|
|
1835
|
+
else "归一化中资雪球结构,对应英文版镜像内容。"
|
|
1836
|
+
)
|
|
1837
|
+
_add_heading(document, title, level=0)
|
|
1838
|
+
_add_paragraph(document, subtitle)
|
|
1839
|
+
|
|
1840
|
+
_add_heading(
|
|
1841
|
+
document,
|
|
1842
|
+
"Executive Conclusion" if is_english else "执行摘要",
|
|
1843
|
+
level=1,
|
|
1844
|
+
)
|
|
1845
|
+
_add_paragraph(
|
|
1846
|
+
document,
|
|
1847
|
+
_compose_english_summary(snapshots=snapshots, deltas=deltas)
|
|
1848
|
+
if is_english
|
|
1849
|
+
else _compose_chinese_summary(snapshots=snapshots, deltas=deltas),
|
|
1850
|
+
)
|
|
1851
|
+
|
|
1852
|
+
_add_heading(
|
|
1853
|
+
document,
|
|
1854
|
+
"Structure Normalization and Methodology" if is_english else "结构归一与方法说明",
|
|
1855
|
+
level=1,
|
|
1856
|
+
)
|
|
1857
|
+
methodology = (
|
|
1858
|
+
f"Baseline assumptions: S0={config.initial_price:.0f}, strike={config.strike:.0f}, "
|
|
1859
|
+
f"KI={config.ki_barrier:.0f}, KO={config.ko_barrier:.0f}, coupon={_fmt_pct(config.annual_coupon)}, "
|
|
1860
|
+
f"r={_fmt_pct(config.rate)}, q={_fmt_pct(config.dividend_yield)}, vol={_fmt_pct(config.volatility)}, "
|
|
1861
|
+
f"tenor={config.tenor_months}M, business-day count={config.business_days_in_year}. "
|
|
1862
|
+
"Dividend yield q is calibrated to the current China equity-index convention and analyzed over the practical band [8.0000%, 15.0000%]. "
|
|
1863
|
+
"Daily-KI uses exact business-day discrete observations; EKI uses final-only KI. "
|
|
1864
|
+
"PV is quoted ex-principal and state probabilities come from common-random-number Monte Carlo on one shared observation grid."
|
|
1865
|
+
if is_english
|
|
1866
|
+
else f"基准假设为:S0={config.initial_price:.0f},行权价={config.strike:.0f},敲入价={config.ki_barrier:.0f},"
|
|
1867
|
+
f"敲出价={config.ko_barrier:.0f},票息={_fmt_pct(config.annual_coupon)},r={_fmt_pct(config.rate)},"
|
|
1868
|
+
f"q={_fmt_pct(config.dividend_yield)},波动率={_fmt_pct(config.volatility)},期限={config.tenor_months}个月,"
|
|
1869
|
+
f"年交易日={config.business_days_in_year}。股息率 q 采用当前中国股票指数常见口径,并在 [8.0000%, 15.0000%] 区间内分析。"
|
|
1870
|
+
"日度敲入采用精确离散交易日观察,欧式敲入仅在终值观察。"
|
|
1871
|
+
"PV 采用不含本金口径,状态概率基于统一观测网格与共同随机数的蒙特卡洛路径。"
|
|
1872
|
+
)
|
|
1873
|
+
_add_paragraph(document, methodology)
|
|
1874
|
+
_add_dataframe_table(
|
|
1875
|
+
document,
|
|
1876
|
+
_build_contract_terms_table(
|
|
1877
|
+
config=config,
|
|
1878
|
+
products=products,
|
|
1879
|
+
language=language,
|
|
1880
|
+
),
|
|
1881
|
+
title="Contract Terms Comparison" if is_english else "合约条款对比表",
|
|
1882
|
+
)
|
|
1883
|
+
|
|
1884
|
+
_add_heading(
|
|
1885
|
+
document,
|
|
1886
|
+
"Matched-Structure Pricing Comparison" if is_english else "匹配结构定价对比",
|
|
1887
|
+
level=1,
|
|
1888
|
+
)
|
|
1889
|
+
_add_dataframe_table(document, _build_metrics_table(snapshots))
|
|
1890
|
+
_add_dataframe_table(document, _build_delta_table(deltas))
|
|
1891
|
+
|
|
1892
|
+
_add_heading(
|
|
1893
|
+
document,
|
|
1894
|
+
"Greeks Comparison" if is_english else "Greeks 对比",
|
|
1895
|
+
level=1,
|
|
1896
|
+
)
|
|
1897
|
+
_add_paragraph(
|
|
1898
|
+
document,
|
|
1899
|
+
(
|
|
1900
|
+
"This section compares how delta, gamma, vega, and rhoq evolve as spot moves through the KI region, the rebound band, and the KO region. "
|
|
1901
|
+
"It now includes both spot sweeps and dividend-yield (q) sweeps, plus dedicated q panels at the KI and KO barrier neighborhoods. PPP-EKI should usually show less path-driven downside sensitivity than PPP-DKI after rebound states, while NPP-DKI should retain the same directional shape but with worse downside convexity."
|
|
1902
|
+
if is_english
|
|
1903
|
+
else "本节比较 delta、gamma、vega 与 rhoq 随现货穿越 KI 区间、反弹区间与 KO 区间时的变化,"
|
|
1904
|
+
"并新增了随股息率 q 变化的敏感度曲线,以及贴近 KI / KO 障碍位置的 q 切片图。一般而言,PPP-EKI 在反弹状态后的路径依赖型下行敏感度会弱于 PPP-DKI,而 NPP-DKI 的方向形态类似,但下行凸性风险更差。"
|
|
1905
|
+
),
|
|
1906
|
+
)
|
|
1907
|
+
_add_dataframe_table(
|
|
1908
|
+
document,
|
|
1909
|
+
greek_curves.key_spot_table,
|
|
1910
|
+
title="Key Spot Greek Levels" if is_english else "关键点位 Greek 对比",
|
|
1911
|
+
)
|
|
1912
|
+
_add_dataframe_table(
|
|
1913
|
+
document,
|
|
1914
|
+
greek_curves.key_q_table,
|
|
1915
|
+
title="Key Dividend Yield Greek Levels"
|
|
1916
|
+
if is_english
|
|
1917
|
+
else "关键 q 水平 Greek 对比",
|
|
1918
|
+
)
|
|
1919
|
+
for key in (
|
|
1920
|
+
"greek_delta",
|
|
1921
|
+
"greek_gamma",
|
|
1922
|
+
"greek_vega",
|
|
1923
|
+
"greek_rhoq",
|
|
1924
|
+
"greek_delta_q",
|
|
1925
|
+
"greek_gamma_q",
|
|
1926
|
+
"greek_vega_q",
|
|
1927
|
+
"greek_rhoq_q",
|
|
1928
|
+
):
|
|
1929
|
+
_add_picture_if_exists(document, plot_paths.get(key))
|
|
1930
|
+
_add_paragraph(
|
|
1931
|
+
document,
|
|
1932
|
+
"Near-KI and Near-KO q slices" if is_english else "近 KI 与近 KO 的 q 切片",
|
|
1933
|
+
bold=True,
|
|
1934
|
+
)
|
|
1935
|
+
for key in ("greek_q_near_ki", "greek_q_near_ko"):
|
|
1936
|
+
_add_picture_if_exists(document, plot_paths.get(key))
|
|
1937
|
+
|
|
1938
|
+
_add_heading(
|
|
1939
|
+
document,
|
|
1940
|
+
"Path Template Evidence" if is_english else "路径模板证据",
|
|
1941
|
+
level=1,
|
|
1942
|
+
)
|
|
1943
|
+
_add_paragraph(
|
|
1944
|
+
document,
|
|
1945
|
+
"Deterministic path tests show where monitoring rules matter most. "
|
|
1946
|
+
"The 100→65→85 case is the clean rebound band where PPP-EKI avoids the loss regime that still applies to PPP-DKI. "
|
|
1947
|
+
"The 100→65→74 case isolates parachute rescue value and shows why DKI benefits more than EKI."
|
|
1948
|
+
if is_english
|
|
1949
|
+
else "确定性路径测试展示了监控规则最有差异的区间。100→65→85 对应典型反弹带,在该区间中 PPP-EKI 通常可以避免 PPP-DKI 仍会触发的亏损状态;"
|
|
1950
|
+
"100→65→74 则单独刻画了降落伞的救援价值,并显示其对 DKI 的提升明显大于对 EKI 的提升。",
|
|
1951
|
+
)
|
|
1952
|
+
_add_dataframe_table(document, _build_deterministic_table(deterministic_cases))
|
|
1953
|
+
|
|
1954
|
+
_add_heading(
|
|
1955
|
+
document,
|
|
1956
|
+
"Event Probability and Cashflow Decomposition"
|
|
1957
|
+
if is_english
|
|
1958
|
+
else "事件概率与现金流分解",
|
|
1959
|
+
level=1,
|
|
1960
|
+
)
|
|
1961
|
+
_add_dataframe_table(
|
|
1962
|
+
document,
|
|
1963
|
+
snapshots["PPP-EKI"].metrics.conditional_ko_cashflows,
|
|
1964
|
+
title="PPP-EKI" if is_english else "PPP-EKI 现金流分解",
|
|
1965
|
+
)
|
|
1966
|
+
_add_dataframe_table(
|
|
1967
|
+
document,
|
|
1968
|
+
snapshots["PPP-DKI"].metrics.conditional_ko_cashflows,
|
|
1969
|
+
title="PPP-DKI" if is_english else "PPP-DKI 现金流分解",
|
|
1970
|
+
)
|
|
1971
|
+
|
|
1972
|
+
_add_heading(
|
|
1973
|
+
document,
|
|
1974
|
+
"Loss Tail and Expected Shortfall" if is_english else "损失尾部与期望短缺",
|
|
1975
|
+
level=1,
|
|
1976
|
+
)
|
|
1977
|
+
_add_dataframe_table(document, _build_band_table(snapshots))
|
|
1978
|
+
|
|
1979
|
+
_add_heading(
|
|
1980
|
+
document,
|
|
1981
|
+
"Barrier / Terminal-Cliff Concentration"
|
|
1982
|
+
if is_english
|
|
1983
|
+
else "障碍附近 / 到期悬崖风险集中",
|
|
1984
|
+
level=1,
|
|
1985
|
+
)
|
|
1986
|
+
ko_barrier, ko_pct, ko_sigma = _barrier_distance_metrics(
|
|
1987
|
+
spot=pricing_env.spot,
|
|
1988
|
+
barrier=config.ko_barrier,
|
|
1989
|
+
time_to_barrier=float(snapshots["PPP-EKI"].ko_times[0]),
|
|
1990
|
+
pricing_env=pricing_env,
|
|
1991
|
+
product=products["PPP-EKI"],
|
|
1992
|
+
)
|
|
1993
|
+
_add_paragraph(
|
|
1994
|
+
document,
|
|
1995
|
+
(
|
|
1996
|
+
f"Initial KO barrier watch: level={ko_barrier:.2f}, pct distance={_fmt_pct(ko_pct)}, sigma distance={ko_sigma:.3f}. "
|
|
1997
|
+
f"Within the terminal cliff band (±{_fmt_pct(config.terminal_cliff_band)} around KI), "
|
|
1998
|
+
f"PPP-EKI loss probability is {_fmt_pct(snapshots['PPP-EKI'].metrics.terminal_cliff_loss_probability)} "
|
|
1999
|
+
f"versus {_fmt_pct(snapshots['PPP-DKI'].metrics.terminal_cliff_loss_probability)} for PPP-DKI."
|
|
2000
|
+
if is_english
|
|
2001
|
+
else f"初始敲出障碍监控:障碍水平={ko_barrier:.2f},百分比距离={_fmt_pct(ko_pct)},标准差距离={ko_sigma:.3f}。"
|
|
2002
|
+
f"在终值位于 KI 附近 ±{_fmt_pct(config.terminal_cliff_band)} 的悬崖区间内,PPP-EKI 的亏损概率为"
|
|
2003
|
+
f"{_fmt_pct(snapshots['PPP-EKI'].metrics.terminal_cliff_loss_probability)},PPP-DKI 为"
|
|
2004
|
+
f"{_fmt_pct(snapshots['PPP-DKI'].metrics.terminal_cliff_loss_probability)}。"
|
|
2005
|
+
),
|
|
2006
|
+
)
|
|
2007
|
+
_add_picture_if_exists(document, plot_paths.get("terminal_cliff"))
|
|
2008
|
+
|
|
2009
|
+
_add_heading(
|
|
2010
|
+
document,
|
|
2011
|
+
"Parachute Feature Analysis" if is_english else "降落伞特征分析",
|
|
2012
|
+
level=1,
|
|
2013
|
+
)
|
|
2014
|
+
_add_paragraph(
|
|
2015
|
+
document,
|
|
2016
|
+
(
|
|
2017
|
+
f"Δ_parachute_DKI = {_fmt_num(deltas.delta_parachute_dki)} and "
|
|
2018
|
+
f"Δ_parachute_EKI = {_fmt_num(deltas.delta_parachute_eki)}. "
|
|
2019
|
+
"The MC evidence and deterministic 100→65→74 path both support the same conclusion: "
|
|
2020
|
+
"parachute mostly rescues paths that had already knocked in under daily monitoring."
|
|
2021
|
+
if is_english
|
|
2022
|
+
else f"Δ_parachute_DKI = {_fmt_num(deltas.delta_parachute_dki)},"
|
|
2023
|
+
f"Δ_parachute_EKI = {_fmt_num(deltas.delta_parachute_eki)}。"
|
|
2024
|
+
"蒙特卡洛结果与 100→65→74 的确定性路径结论一致:降落伞的主要价值在于挽救那些在日度监控下已提前敲入、但终值又回到 KI 以上的路径。"
|
|
2025
|
+
)
|
|
2026
|
+
)
|
|
2027
|
+
|
|
2028
|
+
_add_heading(document, "Stress Section" if is_english else "压力测试部分", level=1)
|
|
2029
|
+
_add_paragraph(
|
|
2030
|
+
document,
|
|
2031
|
+
"The stress layer uses fast repricing to show how the same structures redistribute risk under spot, vol, dividend, skew/smile, and combined trader ladders."
|
|
2032
|
+
if is_english
|
|
2033
|
+
else "压力测试层通过快速重定价展示相同结构在现货、波动率、股息、偏斜/微笑以及组合交易员阶梯情景下如何重新分配风险。",
|
|
2034
|
+
)
|
|
2035
|
+
for key in (
|
|
2036
|
+
"spot_stress",
|
|
2037
|
+
"vol_stress",
|
|
2038
|
+
"div_stress",
|
|
2039
|
+
"skew_smile",
|
|
2040
|
+
"delta_monitoring",
|
|
2041
|
+
"delta_protection",
|
|
2042
|
+
):
|
|
2043
|
+
_add_picture_if_exists(document, plot_paths.get(key))
|
|
2044
|
+
|
|
2045
|
+
_add_heading(
|
|
2046
|
+
document,
|
|
2047
|
+
"Limitations and Interpretation" if is_english else "局限性与解读边界",
|
|
2048
|
+
level=1,
|
|
2049
|
+
)
|
|
2050
|
+
_add_paragraph(
|
|
2051
|
+
document,
|
|
2052
|
+
"This report is a normalized matched-structure study. It does not model issuer spread, secondary-market liquidity, or a dedicated local-vol / jump-diffusion snowball process. "
|
|
2053
|
+
"The purpose is to isolate the marginal contribution of monitoring, protection, and parachute design under exact discrete barrier rules."
|
|
2054
|
+
if is_english
|
|
2055
|
+
else "本报告是归一化的匹配结构研究,不包含发行人信用利差、二级市场流动性,也未单独实现局部波动率或跳扩散雪球模型。"
|
|
2056
|
+
"其目标是基于精确离散障碍规则,分离监控方式、保本设置与降落伞设计的边际贡献。",
|
|
2057
|
+
)
|
|
2058
|
+
|
|
2059
|
+
_add_heading(
|
|
2060
|
+
document,
|
|
2061
|
+
"Appendix: Terminology" if is_english else "附录:术语说明",
|
|
2062
|
+
level=1,
|
|
2063
|
+
)
|
|
2064
|
+
_add_paragraph(
|
|
2065
|
+
document,
|
|
2066
|
+
"Definitions for the key terms used throughout the report."
|
|
2067
|
+
if is_english
|
|
2068
|
+
else "以下列示报告中反复出现的核心术语及其定义。",
|
|
2069
|
+
)
|
|
2070
|
+
_add_dataframe_table(document, _build_terminology_table(language))
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
def _build_docx_report(
|
|
2074
|
+
*,
|
|
2075
|
+
config: SnowballRiskComparisonConfig,
|
|
2076
|
+
pricing_env: PricingEnvironment,
|
|
2077
|
+
products: Mapping[str, SnowballOption],
|
|
2078
|
+
snapshots: Mapping[str, _PathSnapshot],
|
|
2079
|
+
greek_curves: GreekCurves,
|
|
2080
|
+
deltas: StructuralDeltas,
|
|
2081
|
+
deterministic_cases: Sequence[DeterministicCaseResult],
|
|
2082
|
+
plot_paths: Mapping[str, Path],
|
|
2083
|
+
output_dir: Path,
|
|
2084
|
+
) -> Path:
|
|
2085
|
+
document = Document()
|
|
2086
|
+
style = document.styles["Normal"]
|
|
2087
|
+
style.font.name = "Calibri"
|
|
2088
|
+
style.font.size = Pt(10)
|
|
2089
|
+
_add_report_sections(
|
|
2090
|
+
document=document,
|
|
2091
|
+
language="en",
|
|
2092
|
+
config=config,
|
|
2093
|
+
pricing_env=pricing_env,
|
|
2094
|
+
products=products,
|
|
2095
|
+
snapshots=snapshots,
|
|
2096
|
+
greek_curves=greek_curves,
|
|
2097
|
+
deltas=deltas,
|
|
2098
|
+
deterministic_cases=deterministic_cases,
|
|
2099
|
+
plot_paths=plot_paths,
|
|
2100
|
+
)
|
|
2101
|
+
document.add_section(WD_SECTION.NEW_PAGE)
|
|
2102
|
+
_add_report_sections(
|
|
2103
|
+
document=document,
|
|
2104
|
+
language="zh",
|
|
2105
|
+
config=config,
|
|
2106
|
+
pricing_env=pricing_env,
|
|
2107
|
+
products=products,
|
|
2108
|
+
snapshots=snapshots,
|
|
2109
|
+
greek_curves=greek_curves,
|
|
2110
|
+
deltas=deltas,
|
|
2111
|
+
deterministic_cases=deterministic_cases,
|
|
2112
|
+
plot_paths=plot_paths,
|
|
2113
|
+
)
|
|
2114
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
2115
|
+
report_path = output_dir / config.report_filename
|
|
2116
|
+
document.save(str(report_path))
|
|
2117
|
+
return report_path
|
|
2118
|
+
|
|
2119
|
+
|
|
2120
|
+
def _load_input_module(path: str):
|
|
2121
|
+
spec = importlib.util.spec_from_file_location("snowball_risk_input", path)
|
|
2122
|
+
if spec is None or spec.loader is None:
|
|
2123
|
+
raise ValidationError(f"Unable to load input module: {path}")
|
|
2124
|
+
module = importlib.util.module_from_spec(spec)
|
|
2125
|
+
spec.loader.exec_module(module)
|
|
2126
|
+
return module
|
|
2127
|
+
|
|
2128
|
+
|
|
2129
|
+
def generate_snowball_risk_comparison_report(
|
|
2130
|
+
config: Optional[SnowballRiskComparisonConfig] = None,
|
|
2131
|
+
) -> SnowballRiskComparisonArtifacts:
|
|
2132
|
+
if config is None:
|
|
2133
|
+
config = build_default_snowball_risk_comparison_config()
|
|
2134
|
+
if config.bilingual_layout != "english_then_chinese":
|
|
2135
|
+
raise ValidationError("Only english_then_chinese bilingual layout is supported.")
|
|
2136
|
+
|
|
2137
|
+
output_dir = Path(config.output_dir)
|
|
2138
|
+
(
|
|
2139
|
+
calendar,
|
|
2140
|
+
_ko_dates,
|
|
2141
|
+
ko_times,
|
|
2142
|
+
_daily_ki_dates,
|
|
2143
|
+
daily_ki_times,
|
|
2144
|
+
_maturity_date,
|
|
2145
|
+
maturity_time,
|
|
2146
|
+
) = _build_ko_schedule(config)
|
|
2147
|
+
pricing_env = _build_pricing_environment(config, calendar)
|
|
2148
|
+
products = _build_structures(config, ko_times, daily_ki_times, maturity_time)
|
|
2149
|
+
observation_times, _paths, snapshots = _simulate_common_paths(
|
|
2150
|
+
config=config,
|
|
2151
|
+
pricing_env=pricing_env,
|
|
2152
|
+
products=products,
|
|
2153
|
+
)
|
|
2154
|
+
deterministic_cases = _run_deterministic_cases(
|
|
2155
|
+
products=products,
|
|
2156
|
+
pricing_env=pricing_env,
|
|
2157
|
+
observation_times=observation_times,
|
|
2158
|
+
)
|
|
2159
|
+
greek_curves = _compute_greek_curves(
|
|
2160
|
+
config=config,
|
|
2161
|
+
pricing_env=pricing_env,
|
|
2162
|
+
products=products,
|
|
2163
|
+
)
|
|
2164
|
+
|
|
2165
|
+
deltas = StructuralDeltas(
|
|
2166
|
+
delta_monitoring=snapshots["PPP-EKI"].metrics.pv
|
|
2167
|
+
- snapshots["PPP-DKI"].metrics.pv,
|
|
2168
|
+
delta_protection=snapshots["PPP-DKI"].metrics.pv
|
|
2169
|
+
- snapshots["NPP-DKI"].metrics.pv,
|
|
2170
|
+
delta_parachute_dki=snapshots["PPP-DKI-Parachute"].metrics.pv
|
|
2171
|
+
- snapshots["PPP-DKI"].metrics.pv,
|
|
2172
|
+
delta_parachute_eki=snapshots["PPP-EKI-Parachute"].metrics.pv
|
|
2173
|
+
- snapshots["PPP-EKI"].metrics.pv,
|
|
2174
|
+
)
|
|
2175
|
+
plot_paths = _generate_plot_artifacts(
|
|
2176
|
+
config=config,
|
|
2177
|
+
products=products,
|
|
2178
|
+
pricing_env=pricing_env,
|
|
2179
|
+
snapshots=snapshots,
|
|
2180
|
+
greek_curves=greek_curves,
|
|
2181
|
+
output_dir=output_dir,
|
|
2182
|
+
)
|
|
2183
|
+
report_path = _build_docx_report(
|
|
2184
|
+
config=config,
|
|
2185
|
+
pricing_env=pricing_env,
|
|
2186
|
+
products=products,
|
|
2187
|
+
snapshots=snapshots,
|
|
2188
|
+
greek_curves=greek_curves,
|
|
2189
|
+
deltas=deltas,
|
|
2190
|
+
deterministic_cases=deterministic_cases,
|
|
2191
|
+
plot_paths=plot_paths,
|
|
2192
|
+
output_dir=output_dir,
|
|
2193
|
+
)
|
|
2194
|
+
return SnowballRiskComparisonArtifacts(
|
|
2195
|
+
report_path=report_path,
|
|
2196
|
+
output_dir=output_dir,
|
|
2197
|
+
plot_paths=plot_paths,
|
|
2198
|
+
)
|
|
2199
|
+
|
|
2200
|
+
|
|
2201
|
+
def main() -> None:
|
|
2202
|
+
parser = argparse.ArgumentParser(
|
|
2203
|
+
description="Generate a bilingual DOCX risk comparison report for snowball structures."
|
|
2204
|
+
)
|
|
2205
|
+
parser.add_argument("--input", type=str, help="Optional Python input module path.")
|
|
2206
|
+
parser.add_argument("--out", type=str, help="Override output directory.")
|
|
2207
|
+
args = parser.parse_args()
|
|
2208
|
+
|
|
2209
|
+
if args.input:
|
|
2210
|
+
module = _load_input_module(args.input)
|
|
2211
|
+
if hasattr(module, "build_config"):
|
|
2212
|
+
config = module.build_config()
|
|
2213
|
+
elif hasattr(module, "config"):
|
|
2214
|
+
config = module.config
|
|
2215
|
+
else:
|
|
2216
|
+
raise ValidationError("Input module must define build_config() or config.")
|
|
2217
|
+
else:
|
|
2218
|
+
config = build_default_snowball_risk_comparison_config()
|
|
2219
|
+
|
|
2220
|
+
if args.out:
|
|
2221
|
+
config = SnowballRiskComparisonConfig(
|
|
2222
|
+
**{**config.__dict__, "output_dir": Path(args.out)}
|
|
2223
|
+
)
|
|
2224
|
+
|
|
2225
|
+
result = generate_snowball_risk_comparison_report(config)
|
|
2226
|
+
print(f"Generated report: {result.report_path}")
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
if __name__ == "__main__":
|
|
2230
|
+
main()
|