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