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.
Files changed (399) hide show
  1. quantark/__init__.py +3 -0
  2. quantark/_compat.py +150 -0
  3. quantark/asset/__init__.py +8 -0
  4. quantark/asset/bond/__init__.py +2 -0
  5. quantark/asset/bond/engine/__init__.py +44 -0
  6. quantark/asset/bond/engine/analytical/__init__.py +12 -0
  7. quantark/asset/bond/engine/analytical/black_engine.py +583 -0
  8. quantark/asset/bond/engine/analytical/bond_forward_engine.py +390 -0
  9. quantark/asset/bond/engine/analytical/bond_futures_engine.py +569 -0
  10. quantark/asset/bond/engine/convertible/__init__.py +12 -0
  11. quantark/asset/bond/engine/convertible/convertible_bond_engine.py +800 -0
  12. quantark/asset/bond/engine/discount/__init__.py +10 -0
  13. quantark/asset/bond/engine/discount/bond_discount_engine.py +517 -0
  14. quantark/asset/bond/engine/discount/frn_engine.py +913 -0
  15. quantark/asset/bond/engine/pde/__init__.py +14 -0
  16. quantark/asset/bond/engine/pde/convertible/__init__.py +21 -0
  17. quantark/asset/bond/engine/pde/convertible/jump_diffusion_engine.py +603 -0
  18. quantark/asset/bond/engine/pde/convertible/pde_params.py +59 -0
  19. quantark/asset/bond/engine/pde/convertible/tf_engine.py +546 -0
  20. quantark/asset/bond/engine/tree/__init__.py +14 -0
  21. quantark/asset/bond/engine/tree/convertible/__init__.py +21 -0
  22. quantark/asset/bond/engine/tree/convertible/binomial_engine.py +488 -0
  23. quantark/asset/bond/engine/tree/convertible/tree_params.py +72 -0
  24. quantark/asset/bond/engine/tree/convertible/trinomial_engine.py +1341 -0
  25. quantark/asset/bond/product/__init__.py +37 -0
  26. quantark/asset/bond/product/base_bond_product.py +114 -0
  27. quantark/asset/bond/product/convertible/__init__.py +16 -0
  28. quantark/asset/bond/product/convertible/convertible_bond.py +595 -0
  29. quantark/asset/bond/product/couponbond/__init__.py +12 -0
  30. quantark/asset/bond/product/couponbond/fixed_bond.py +285 -0
  31. quantark/asset/bond/product/couponbond/frn.py +538 -0
  32. quantark/asset/bond/product/forward/__init__.py +9 -0
  33. quantark/asset/bond/product/forward/base_bond_forward.py +92 -0
  34. quantark/asset/bond/product/forward/bond_forward.py +335 -0
  35. quantark/asset/bond/product/futures/__init__.py +8 -0
  36. quantark/asset/bond/product/futures/bond_futures.py +532 -0
  37. quantark/asset/bond/product/option/__init__.py +9 -0
  38. quantark/asset/bond/product/option/euro_short_term_bond_option.py +231 -0
  39. quantark/asset/bond/riskmeasures/__init__.py +13 -0
  40. quantark/asset/bond/riskmeasures/bond_greeks_calculator.py +484 -0
  41. quantark/asset/bond/schedule/__init__.py +21 -0
  42. quantark/asset/bond/schedule/cashflow.py +595 -0
  43. quantark/asset/equity/__init__.py +11 -0
  44. quantark/asset/equity/analysis/__init__.py +4 -0
  45. quantark/asset/equity/analysis/autocallable_path_analyzer.py +257 -0
  46. quantark/asset/equity/engine/__init__.py +84 -0
  47. quantark/asset/equity/engine/analytical/__init__.py +37 -0
  48. quantark/asset/equity/engine/analytical/american_option_engine.py +682 -0
  49. quantark/asset/equity/engine/analytical/asian_option_analytical_engine.py +1102 -0
  50. quantark/asset/equity/engine/analytical/barrier_analytical_engine.py +455 -0
  51. quantark/asset/equity/engine/analytical/black_scholes_engine.py +322 -0
  52. quantark/asset/equity/engine/analytical/deltaone_engine.py +340 -0
  53. quantark/asset/equity/engine/analytical/digital_option_engine.py +168 -0
  54. quantark/asset/equity/engine/analytical/double_barrier_option_engine.py +481 -0
  55. quantark/asset/equity/engine/analytical/double_sharkfin_option_analytical_engine.py +508 -0
  56. quantark/asset/equity/engine/analytical/one_touch_analytical_engine.py +302 -0
  57. quantark/asset/equity/engine/analytical/range_accrual_analytical_engine.py +396 -0
  58. quantark/asset/equity/engine/analytical/single_sharkfin_option_analytical_engine.py +229 -0
  59. quantark/asset/equity/engine/base_engine.py +137 -0
  60. quantark/asset/equity/engine/event_stats.py +85 -0
  61. quantark/asset/equity/engine/mc/__init__.py +31 -0
  62. quantark/asset/equity/engine/mc/american_option_mc_engine.py +485 -0
  63. quantark/asset/equity/engine/mc/asian_option_mc_engine.py +678 -0
  64. quantark/asset/equity/engine/mc/barrier_option_mc_engine.py +726 -0
  65. quantark/asset/equity/engine/mc/digital_option_mc_engine.py +419 -0
  66. quantark/asset/equity/engine/mc/double_sharkfin_option_mc_engine.py +676 -0
  67. quantark/asset/equity/engine/mc/euro_mc_engine.py +423 -0
  68. quantark/asset/equity/engine/mc/phoenix_mc_engine.py +1206 -0
  69. quantark/asset/equity/engine/mc/range_accrual_mc_engine.py +738 -0
  70. quantark/asset/equity/engine/mc/single_sharkfin_option_mc_engine.py +549 -0
  71. quantark/asset/equity/engine/mc/snowball_mc_engine.py +2250 -0
  72. quantark/asset/equity/engine/pde/__init__.py +36 -0
  73. quantark/asset/equity/engine/pde/american_pde_solver.py +211 -0
  74. quantark/asset/equity/engine/pde/barrier_pde_solver.py +692 -0
  75. quantark/asset/equity/engine/pde/base_pde_solver.py +994 -0
  76. quantark/asset/equity/engine/pde/double_barrier_pde_solver.py +510 -0
  77. quantark/asset/equity/engine/pde/double_one_touch_pde_solver.py +435 -0
  78. quantark/asset/equity/engine/pde/european_pde_solver.py +170 -0
  79. quantark/asset/equity/engine/pde/ko_reset_snowball_pde_solver.py +477 -0
  80. quantark/asset/equity/engine/pde/one_touch_pde_solver.py +439 -0
  81. quantark/asset/equity/engine/pde/phoenix_pde_solver.py +613 -0
  82. quantark/asset/equity/engine/pde/snowball_pde_solver.py +1810 -0
  83. quantark/asset/equity/engine/pde/spatial_grid.py +750 -0
  84. quantark/asset/equity/engine/pde/time_grid.py +308 -0
  85. quantark/asset/equity/engine/pde_engine.py +238 -0
  86. quantark/asset/equity/engine/quad/__init__.py +23 -0
  87. quantark/asset/equity/engine/quad/discrete_quad_engine.py +106 -0
  88. quantark/asset/equity/engine/quad/european_quad_engine.py +325 -0
  89. quantark/asset/equity/engine/quad/ko_reset_snowball_quad_engine.py +362 -0
  90. quantark/asset/equity/engine/quad/phoenix_quad_engine.py +614 -0
  91. quantark/asset/equity/engine/quad/quad_adapters.py +1260 -0
  92. quantark/asset/equity/engine/quad/quad_core.py +513 -0
  93. quantark/asset/equity/engine/quad/quad_math.py +219 -0
  94. quantark/asset/equity/engine/quad/snowball_quad_engine.py +1137 -0
  95. quantark/asset/equity/engine/validation/script/benchmark_check_american_analytical.py +117 -0
  96. quantark/asset/equity/engine/validation/script/benchmark_check_american_pde.py +114 -0
  97. quantark/asset/equity/engine/validation/script/benchmark_check_asian_analytical.py +440 -0
  98. quantark/asset/equity/engine/validation/script/benchmark_check_barrier_analytical.py +269 -0
  99. quantark/asset/equity/engine/validation/script/benchmark_check_barrier_pde_solver.py +636 -0
  100. quantark/asset/equity/engine/validation/script/benchmark_check_digital_option.py +256 -0
  101. quantark/asset/equity/engine/validation/script/benchmark_check_snowball_pde_solver.py +807 -0
  102. quantark/asset/equity/engine/validation/script/boundary_check_american_analytical.py +290 -0
  103. quantark/asset/equity/engine/validation/script/boundary_check_american_pde.py +242 -0
  104. quantark/asset/equity/engine/validation/script/boundary_check_asian_analytical.py +612 -0
  105. quantark/asset/equity/engine/validation/script/boundary_check_barrier_analytical.py +434 -0
  106. quantark/asset/equity/engine/validation/script/boundary_check_barrier_pde_solver.py +748 -0
  107. quantark/asset/equity/engine/validation/script/boundary_check_digital_option.py +575 -0
  108. quantark/asset/equity/engine/validation/script/boundary_check_snowball_pde_solver.py +1101 -0
  109. quantark/asset/equity/engine/validation/script/greeks_check_digital_option.py +349 -0
  110. quantark/asset/equity/engine/validation/script/mc_comparison_barrier_pde.py +270 -0
  111. quantark/asset/equity/engine/validation/script/quick_mc_compare.py +51 -0
  112. quantark/asset/equity/engine/validation/script/validation_stepdown_improved.py +97 -0
  113. quantark/asset/equity/param/__init__.py +24 -0
  114. quantark/asset/equity/param/engine_param_profiles.py +325 -0
  115. quantark/asset/equity/param/engine_params.py +728 -0
  116. quantark/asset/equity/process/__init__.py +7 -0
  117. quantark/asset/equity/process/bsm/__init__.py +7 -0
  118. quantark/asset/equity/process/bsm/bsm_process.py +108 -0
  119. quantark/asset/equity/process/bsm/qmc_brownian_bridge.py +401 -0
  120. quantark/asset/equity/process/bsm/qmc_path_generator.py +694 -0
  121. quantark/asset/equity/process/bsm/qmc_rqmc_driver.py +163 -0
  122. quantark/asset/equity/process/bsm/qmc_sobol.py +195 -0
  123. quantark/asset/equity/process/bsm/qmc_variance_reduction.py +292 -0
  124. quantark/asset/equity/product/__init__.py +8 -0
  125. quantark/asset/equity/product/base_equity_product.py +72 -0
  126. quantark/asset/equity/product/deltaone/__init__.py +22 -0
  127. quantark/asset/equity/product/deltaone/base_deltaone_product.py +147 -0
  128. quantark/asset/equity/product/deltaone/futures.py +485 -0
  129. quantark/asset/equity/product/deltaone/spot_instrument.py +118 -0
  130. quantark/asset/equity/product/option/__init__.py +104 -0
  131. quantark/asset/equity/product/option/american_option.py +114 -0
  132. quantark/asset/equity/product/option/asian_option.py +531 -0
  133. quantark/asset/equity/product/option/barrier_option.py +289 -0
  134. quantark/asset/equity/product/option/base_equity_option.py +659 -0
  135. quantark/asset/equity/product/option/digital_option.py +102 -0
  136. quantark/asset/equity/product/option/double_barrier_option.py +286 -0
  137. quantark/asset/equity/product/option/double_one_touch_option.py +310 -0
  138. quantark/asset/equity/product/option/double_sharkfin_option.py +466 -0
  139. quantark/asset/equity/product/option/european_vanilla_option.py +103 -0
  140. quantark/asset/equity/product/option/ko_reset_snowball_option.py +563 -0
  141. quantark/asset/equity/product/option/observation_schedule.py +530 -0
  142. quantark/asset/equity/product/option/one_touch_option.py +287 -0
  143. quantark/asset/equity/product/option/phoenix_config.py +116 -0
  144. quantark/asset/equity/product/option/phoenix_helpers.py +576 -0
  145. quantark/asset/equity/product/option/phoenix_option.py +1167 -0
  146. quantark/asset/equity/product/option/range_accrual_config.py +288 -0
  147. quantark/asset/equity/product/option/range_accrual_helpers.py +608 -0
  148. quantark/asset/equity/product/option/range_accrual_option.py +526 -0
  149. quantark/asset/equity/product/option/single_sharkfin_option.py +420 -0
  150. quantark/asset/equity/product/option/snowball_config.py +261 -0
  151. quantark/asset/equity/product/option/snowball_helpers.py +977 -0
  152. quantark/asset/equity/product/option/snowball_option.py +1242 -0
  153. quantark/asset/equity/report/__init__.py +15 -0
  154. quantark/asset/equity/report/autocallable_risk_report.py +2118 -0
  155. quantark/asset/equity/report/plotting.py +87 -0
  156. quantark/asset/equity/report/snowball_risk_comparison_report.py +2230 -0
  157. quantark/asset/equity/report/surfaces.py +123 -0
  158. quantark/asset/equity/report/term_structure.py +126 -0
  159. quantark/asset/equity/riskmeasures/__init__.py +7 -0
  160. quantark/asset/equity/riskmeasures/greeks_calculator.py +1204 -0
  161. quantark/asset/rate/__init__.py +58 -0
  162. quantark/asset/rate/engine/__init__.py +25 -0
  163. quantark/asset/rate/engine/cap_floor_engine.py +514 -0
  164. quantark/asset/rate/engine/fra_engine.py +286 -0
  165. quantark/asset/rate/engine/irs_discount_engine.py +891 -0
  166. quantark/asset/rate/engine/swaption_engine.py +587 -0
  167. quantark/asset/rate/product/__init__.py +67 -0
  168. quantark/asset/rate/product/cap_floor.py +550 -0
  169. quantark/asset/rate/product/fra.py +219 -0
  170. quantark/asset/rate/product/irs.py +1223 -0
  171. quantark/asset/rate/product/swaption.py +372 -0
  172. quantark/backtest/__init__.py +153 -0
  173. quantark/backtest/base.py +263 -0
  174. quantark/backtest/dashboard.py +874 -0
  175. quantark/backtest/equity/__init__.py +35 -0
  176. quantark/backtest/equity/config.py +118 -0
  177. quantark/backtest/equity/engine.py +408 -0
  178. quantark/backtest/equity/hedge_executor.py +374 -0
  179. quantark/backtest/equity/metrics.py +396 -0
  180. quantark/backtest/equity/results.py +232 -0
  181. quantark/backtest/equity/state.py +252 -0
  182. quantark/backtest/examples/__init__.py +4 -0
  183. quantark/backtest/examples/advanced_backtest.py +345 -0
  184. quantark/backtest/examples/basic_delta_hedge.py +246 -0
  185. quantark/backtest/examples/fi_dv01_hedge.py +267 -0
  186. quantark/backtest/fi/__init__.py +30 -0
  187. quantark/backtest/fi/config.py +114 -0
  188. quantark/backtest/fi/engine.py +378 -0
  189. quantark/backtest/fi/hedge_executor.py +254 -0
  190. quantark/backtest/fi/metrics.py +308 -0
  191. quantark/backtest/fi/results.py +193 -0
  192. quantark/backtest/fi/state.py +212 -0
  193. quantark/backtest/logger.py +393 -0
  194. quantark/backtest/otc/__init__.py +74 -0
  195. quantark/backtest/otc/_replay.py +637 -0
  196. quantark/backtest/otc/book_engine.py +587 -0
  197. quantark/backtest/otc/config.py +175 -0
  198. quantark/backtest/otc/dashboard.py +1006 -0
  199. quantark/backtest/otc/engine.py +420 -0
  200. quantark/backtest/otc/engine_factory.py +138 -0
  201. quantark/backtest/otc/market.py +216 -0
  202. quantark/backtest/otc/results.py +107 -0
  203. quantark/backtest/otc/state.py +166 -0
  204. quantark/backtest/report_generator.py +608 -0
  205. quantark/backtest/strategy/__init__.py +28 -0
  206. quantark/backtest/strategy/base_strategy.py +235 -0
  207. quantark/backtest/strategy/convexity_neutral_strategy.py +247 -0
  208. quantark/backtest/strategy/delta_neutral_strategy.py +283 -0
  209. quantark/backtest/strategy/dv01_neutral_strategy.py +283 -0
  210. quantark/backtest/transaction_costs.py +485 -0
  211. quantark/backtest/visualizer.py +1019 -0
  212. quantark/cashleg/__init__.py +31 -0
  213. quantark/cashleg/accrual_leg.py +120 -0
  214. quantark/cashleg/base.py +48 -0
  215. quantark/cashleg/base_amount.py +60 -0
  216. quantark/cashleg/deterministic_leg.py +39 -0
  217. quantark/cashleg/event_distribution.py +262 -0
  218. quantark/cashleg/fixed_payoff_leg.py +92 -0
  219. quantark/cashleg/leg_schedule.py +95 -0
  220. quantark/cashleg/leg_valuator.py +40 -0
  221. quantark/dynamicscenario/__init__.py +97 -0
  222. quantark/dynamicscenario/base.py +297 -0
  223. quantark/dynamicscenario/config.py +122 -0
  224. quantark/dynamicscenario/engine.py +703 -0
  225. quantark/dynamicscenario/equity/__init__.py +14 -0
  226. quantark/dynamicscenario/fi/__init__.py +24 -0
  227. quantark/dynamicscenario/fi/config.py +149 -0
  228. quantark/dynamicscenario/fi/engine.py +500 -0
  229. quantark/dynamicscenario/fi/results.py +503 -0
  230. quantark/dynamicscenario/path/__init__.py +17 -0
  231. quantark/dynamicscenario/path/day_path.py +397 -0
  232. quantark/dynamicscenario/path/fi_path_library.py +488 -0
  233. quantark/dynamicscenario/path/path_builder.py +726 -0
  234. quantark/dynamicscenario/path/path_library.py +620 -0
  235. quantark/dynamicscenario/report/__init__.py +12 -0
  236. quantark/dynamicscenario/report/dynamic_report.py +1175 -0
  237. quantark/dynamicscenario/report/visualizer.py +1586 -0
  238. quantark/dynamicscenario/results/__init__.py +19 -0
  239. quantark/dynamicscenario/results/dynamic_results.py +579 -0
  240. quantark/dynamicscenario/results/result_exporter.py +438 -0
  241. quantark/param/__init__.py +75 -0
  242. quantark/param/basis/__init__.py +19 -0
  243. quantark/param/basis/basis_yield.py +301 -0
  244. quantark/param/div/__init__.py +16 -0
  245. quantark/param/div/dividend_yield.py +123 -0
  246. quantark/param/index/__init__.py +52 -0
  247. quantark/param/index/rate_index.py +568 -0
  248. quantark/param/quote/__init__.py +7 -0
  249. quantark/param/quote/spot_quote.py +35 -0
  250. quantark/param/rrf/__init__.py +22 -0
  251. quantark/param/rrf/rate_curve.py +436 -0
  252. quantark/param/vol/__init__.py +6 -0
  253. quantark/param/vol/vol_surface.py +118 -0
  254. quantark/portfolio/__init__.py +61 -0
  255. quantark/portfolio/base.py +203 -0
  256. quantark/portfolio/equity/__init__.py +17 -0
  257. quantark/portfolio/equity/portfolio.py +391 -0
  258. quantark/portfolio/equity/position.py +368 -0
  259. quantark/portfolio/fi/__init__.py +14 -0
  260. quantark/portfolio/fi/portfolio.py +424 -0
  261. quantark/portfolio/fi/position.py +272 -0
  262. quantark/portfolio/portfolio_snapshot.py +221 -0
  263. quantark/portfolio/portfolio_storage.py +414 -0
  264. quantark/priceenv/__init__.py +7 -0
  265. quantark/priceenv/pricing_environment.py +196 -0
  266. quantark/rfq/__init__.py +32 -0
  267. quantark/rfq/builders.py +102 -0
  268. quantark/rfq/models.py +214 -0
  269. quantark/rfq/registry.py +611 -0
  270. quantark/rfq/service.py +237 -0
  271. quantark/simm/__init__.py +155 -0
  272. quantark/simm/calibration/__init__.py +206 -0
  273. quantark/simm/calibration/accessors.py +439 -0
  274. quantark/simm/calibration/commodity.py +156 -0
  275. quantark/simm/calibration/credit_non_qualifying.py +79 -0
  276. quantark/simm/calibration/credit_qualifying.py +130 -0
  277. quantark/simm/calibration/cross_risk.py +39 -0
  278. quantark/simm/calibration/equity.py +125 -0
  279. quantark/simm/calibration/fx.py +92 -0
  280. quantark/simm/calibration/ir.py +152 -0
  281. quantark/simm/calibration/version.py +33 -0
  282. quantark/simm/config.py +186 -0
  283. quantark/simm/crif/__init__.py +35 -0
  284. quantark/simm/crif/models.py +230 -0
  285. quantark/simm/crif/parser.py +585 -0
  286. quantark/simm/engines/__init__.py +62 -0
  287. quantark/simm/engines/aggregation/__init__.py +67 -0
  288. quantark/simm/engines/aggregation/addon.py +141 -0
  289. quantark/simm/engines/aggregation/bucket_aggregator.py +298 -0
  290. quantark/simm/engines/aggregation/concentration.py +349 -0
  291. quantark/simm/engines/aggregation/product_class_aggregator.py +183 -0
  292. quantark/simm/engines/aggregation/risk_class_aggregator.py +403 -0
  293. quantark/simm/engines/aggregation/simm_calculator.py +430 -0
  294. quantark/simm/engines/aggregation/weighted_sensitivity.py +272 -0
  295. quantark/simm/engines/base.py +231 -0
  296. quantark/simm/engines/classification/__init__.py +10 -0
  297. quantark/simm/engines/classification/bucket_mapper.py +347 -0
  298. quantark/simm/engines/factory.py +137 -0
  299. quantark/simm/engines/portfolio_adapter.py +336 -0
  300. quantark/simm/engines/result.py +176 -0
  301. quantark/simm/engines/risk_class/__init__.py +18 -0
  302. quantark/simm/engines/risk_class/equity_engine.py +263 -0
  303. quantark/simm/engines/risk_class/ir_engine.py +264 -0
  304. quantark/simm/report/__init__.py +17 -0
  305. quantark/simm/report/crif_export.py +284 -0
  306. quantark/simm/report/excel_generator.py +401 -0
  307. quantark/simm/report/html_generator.py +840 -0
  308. quantark/simm/results/__init__.py +38 -0
  309. quantark/simm/results/attribution.py +313 -0
  310. quantark/simm/results/simm_result.py +339 -0
  311. quantark/simm/results/whatif.py +268 -0
  312. quantark/simm/sensitivity.py +533 -0
  313. quantark/simm/taxonomy.py +416 -0
  314. quantark/stresstest/__init__.py +67 -0
  315. quantark/stresstest/base.py +116 -0
  316. quantark/stresstest/config.py +5 -0
  317. quantark/stresstest/engine.py +5 -0
  318. quantark/stresstest/equity/__init__.py +17 -0
  319. quantark/stresstest/equity/config.py +69 -0
  320. quantark/stresstest/equity/engine.py +272 -0
  321. quantark/stresstest/equity/report/__init__.py +7 -0
  322. quantark/stresstest/equity/report/report_generator.py +423 -0
  323. quantark/stresstest/equity/report/visualizer.py +328 -0
  324. quantark/stresstest/equity/results.py +145 -0
  325. quantark/stresstest/fi/__init__.py +15 -0
  326. quantark/stresstest/fi/config.py +59 -0
  327. quantark/stresstest/fi/engine.py +213 -0
  328. quantark/stresstest/fi/metrics.py +60 -0
  329. quantark/stresstest/fi/results.py +64 -0
  330. quantark/stresstest/report/__init__.py +12 -0
  331. quantark/stresstest/report/report_generator.py +5 -0
  332. quantark/stresstest/report/visualizer.py +5 -0
  333. quantark/stresstest/results/__init__.py +16 -0
  334. quantark/stresstest/results/result_aggregator.py +325 -0
  335. quantark/stresstest/results/result_exporter.py +286 -0
  336. quantark/stresstest/results/stress_results.py +5 -0
  337. quantark/stresstest/scenario/__init__.py +13 -0
  338. quantark/stresstest/scenario/scenario.py +242 -0
  339. quantark/stresstest/scenario/scenario_builder.py +376 -0
  340. quantark/stresstest/scenario/scenario_library.py +435 -0
  341. quantark/stresstest/scenario/scenario_storage.py +224 -0
  342. quantark/stresstest/stress/__init__.py +13 -0
  343. quantark/stresstest/stress/stress_applicator.py +590 -0
  344. quantark/stresstest/stress/stress_types.py +142 -0
  345. quantark/util/__init__.py +23 -0
  346. quantark/util/barrier_shift.py +44 -0
  347. quantark/util/calendar/__init__.py +27 -0
  348. quantark/util/calendar/business_calendar.py +584 -0
  349. quantark/util/calendar/day_counter.py +517 -0
  350. quantark/util/calendar/holidayfile/china.csv +1920 -0
  351. quantark/util/calendar/holidayfile/china_sse.csv +1462 -0
  352. quantark/util/enum/__init__.py +81 -0
  353. quantark/util/enum/bond_enums.py +112 -0
  354. quantark/util/enum/deltaone_enums.py +16 -0
  355. quantark/util/enum/engine_enums.py +137 -0
  356. quantark/util/enum/greeks_enums.py +29 -0
  357. quantark/util/enum/option_enums.py +221 -0
  358. quantark/util/exceptions.py +66 -0
  359. quantark/util/marketdata/__init__.py +39 -0
  360. quantark/util/marketdata/adapter/base_adapter.py +203 -0
  361. quantark/util/marketdata/adapter/mock_adapter.py +265 -0
  362. quantark/util/marketdata/converter.py +289 -0
  363. quantark/util/marketdata/example_usage.py +314 -0
  364. quantark/util/marketdata/generator/__init__.py +7 -0
  365. quantark/util/marketdata/generator/mock_generator.py +466 -0
  366. quantark/util/marketdata/models.py +358 -0
  367. quantark/util/marketdata/storage/__init__.py +7 -0
  368. quantark/util/marketdata/storage/parquet_storage.py +340 -0
  369. quantark/util/numerical/__init__.py +98 -0
  370. quantark/util/numerical/comparison.py +219 -0
  371. quantark/util/numerical/constants.py +98 -0
  372. quantark/util/numerical/formatting.py +380 -0
  373. quantark/util/numerical/pnl.py +17 -0
  374. quantark/util/numerical/safe_math.py +238 -0
  375. quantark/util/numerical/validation.py +315 -0
  376. quantark/var/__init__.py +39 -0
  377. quantark/var/attribution.py +398 -0
  378. quantark/var/backtest/__init__.py +7 -0
  379. quantark/var/backtest/var_backtester.py +309 -0
  380. quantark/var/base.py +63 -0
  381. quantark/var/config.py +219 -0
  382. quantark/var/engines/__init__.py +13 -0
  383. quantark/var/engines/historical.py +925 -0
  384. quantark/var/engines/monte_carlo.py +870 -0
  385. quantark/var/engines/parametric.py +1199 -0
  386. quantark/var/results/__init__.py +16 -0
  387. quantark/var/results/incremental_var_result.py +131 -0
  388. quantark/var/results/var_report.py +346 -0
  389. quantark/var/results/var_result.py +134 -0
  390. quantark/var/risk_factors/__init__.py +22 -0
  391. quantark/var/risk_factors/base.py +41 -0
  392. quantark/var/risk_factors/equity_factors.py +158 -0
  393. quantark/var/risk_factors/fi_factors.py +99 -0
  394. quantark-0.1.0.dist-info/METADATA +351 -0
  395. quantark-0.1.0.dist-info/RECORD +399 -0
  396. quantark-0.1.0.dist-info/WHEEL +4 -0
  397. quantark-0.1.0.dist-info/licenses/LICENSE +202 -0
  398. quantark-0.1.0.dist-info/licenses/NOTICE +2 -0
  399. 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()