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