back-trader-python 1.4.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 (465) hide show
  1. back_trader_python-1.4.0.dist-info/METADATA +1491 -0
  2. back_trader_python-1.4.0.dist-info/RECORD +465 -0
  3. back_trader_python-1.4.0.dist-info/WHEEL +5 -0
  4. back_trader_python-1.4.0.dist-info/licenses/LICENSE +674 -0
  5. back_trader_python-1.4.0.dist-info/top_level.txt +1 -0
  6. backtrader/__init__.py +148 -0
  7. backtrader/_cerebro/__init__.py +5 -0
  8. backtrader/_cerebro/channel.py +382 -0
  9. backtrader/_cerebro/execution.py +377 -0
  10. backtrader/_cerebro/lifecycle.py +143 -0
  11. backtrader/_cerebro/notifications.py +150 -0
  12. backtrader/_cerebro/presentation.py +230 -0
  13. backtrader/_cerebro/registry.py +593 -0
  14. backtrader/_cerebro/runnext.py +551 -0
  15. backtrader/_cerebro/runonce.py +142 -0
  16. backtrader/analyzer.py +594 -0
  17. backtrader/analyzers/__init__.py +50 -0
  18. backtrader/analyzers/annualreturn.py +226 -0
  19. backtrader/analyzers/calmar.py +165 -0
  20. backtrader/analyzers/drawdown.py +287 -0
  21. backtrader/analyzers/leverage.py +112 -0
  22. backtrader/analyzers/logreturnsrolling.py +190 -0
  23. backtrader/analyzers/periodstats.py +153 -0
  24. backtrader/analyzers/positions.py +119 -0
  25. backtrader/analyzers/pyfolio.py +470 -0
  26. backtrader/analyzers/returns.py +192 -0
  27. backtrader/analyzers/sharpe.py +307 -0
  28. backtrader/analyzers/sharpe_ratio_stats.py +534 -0
  29. backtrader/analyzers/sqn.py +112 -0
  30. backtrader/analyzers/timereturn.py +192 -0
  31. backtrader/analyzers/total_value.py +75 -0
  32. backtrader/analyzers/tradeanalyzer.py +278 -0
  33. backtrader/analyzers/transactions.py +141 -0
  34. backtrader/analyzers/vwr.py +245 -0
  35. backtrader/bokeh/__init__.py +155 -0
  36. backtrader/bokeh/analyzers/__init__.py +13 -0
  37. backtrader/bokeh/analyzers/plot.py +192 -0
  38. backtrader/bokeh/analyzers/recorder.py +181 -0
  39. backtrader/bokeh/app.py +1094 -0
  40. backtrader/bokeh/live/__init__.py +11 -0
  41. backtrader/bokeh/live/client.py +352 -0
  42. backtrader/bokeh/live/datahandler.py +346 -0
  43. backtrader/bokeh/plot_adapter.py +200 -0
  44. backtrader/bokeh/schemes/__init__.py +14 -0
  45. backtrader/bokeh/schemes/blackly.py +76 -0
  46. backtrader/bokeh/schemes/scheme.py +150 -0
  47. backtrader/bokeh/schemes/tradimo.py +82 -0
  48. backtrader/bokeh/tab.py +125 -0
  49. backtrader/bokeh/tabs/__init__.py +30 -0
  50. backtrader/bokeh/tabs/analyzer.py +120 -0
  51. backtrader/bokeh/tabs/config.py +154 -0
  52. backtrader/bokeh/tabs/live.py +109 -0
  53. backtrader/bokeh/tabs/log.py +185 -0
  54. backtrader/bokeh/tabs/metadata.py +182 -0
  55. backtrader/bokeh/tabs/performance.py +359 -0
  56. backtrader/bokeh/tabs/source.py +70 -0
  57. backtrader/bokeh/utils/__init__.py +8 -0
  58. backtrader/bokeh/utils/helpers.py +167 -0
  59. backtrader/bokeh/webapp.py +164 -0
  60. backtrader/broker.py +478 -0
  61. backtrader/brokers/__init__.py +36 -0
  62. backtrader/brokers/bbroker.py +2576 -0
  63. backtrader/brokers/btapibroker.py +8227 -0
  64. backtrader/brokers/hft/__init__.py +89 -0
  65. backtrader/brokers/hft/binance_bbo.py +625 -0
  66. backtrader/brokers/hft/binance_bbo_compare.py +1398 -0
  67. backtrader/brokers/hft/examples.py +1228 -0
  68. backtrader/brokers/hft/exchange.py +380 -0
  69. backtrader/brokers/hft/latency.py +309 -0
  70. backtrader/brokers/hft/matching_core.py +572 -0
  71. backtrader/brokers/hft/queue.py +238 -0
  72. backtrader/brokers/hft/recorder.py +88 -0
  73. backtrader/brokers/hft/state.py +138 -0
  74. backtrader/brokers/impact_models.py +118 -0
  75. backtrader/brokers/mixbroker.py +895 -0
  76. backtrader/brokers/tickbroker.py +1991 -0
  77. backtrader/btrun/__init__.py +12 -0
  78. backtrader/btrun/btrun.py +1218 -0
  79. backtrader/cerebro.py +828 -0
  80. backtrader/channel.py +682 -0
  81. backtrader/channels/__init__.py +23 -0
  82. backtrader/channels/bridge.py +186 -0
  83. backtrader/channels/funding.py +248 -0
  84. backtrader/channels/live_queue.py +216 -0
  85. backtrader/channels/live_validator.py +294 -0
  86. backtrader/channels/orderbook.py +257 -0
  87. backtrader/channels/tick.py +202 -0
  88. backtrader/comminfo.py +665 -0
  89. backtrader/commissions/__init__.py +106 -0
  90. backtrader/commissions/ctpoption.py +993 -0
  91. backtrader/configs/account_config_example.yaml +8 -0
  92. backtrader/dataseries.py +379 -0
  93. backtrader/errors.py +106 -0
  94. backtrader/events.py +980 -0
  95. backtrader/feed.py +1523 -0
  96. backtrader/feeds/__init__.py +75 -0
  97. backtrader/feeds/barrier.py +2006 -0
  98. backtrader/feeds/blaze.py +118 -0
  99. backtrader/feeds/btapifeed.py +1538 -0
  100. backtrader/feeds/btcsv.py +203 -0
  101. backtrader/feeds/chainer.py +114 -0
  102. backtrader/feeds/cryptohftdata.py +164 -0
  103. backtrader/feeds/csvgeneric.py +1205 -0
  104. backtrader/feeds/ctpcohort.py +1051 -0
  105. backtrader/feeds/influxfeed.py +158 -0
  106. backtrader/feeds/livefeed.py +71 -0
  107. backtrader/feeds/mixed_channel.py +108 -0
  108. backtrader/feeds/mt4csv.py +42 -0
  109. backtrader/feeds/pandafeed.py +381 -0
  110. backtrader/feeds/quandl.py +256 -0
  111. backtrader/feeds/rollover.py +229 -0
  112. backtrader/feeds/sierrachart.py +30 -0
  113. backtrader/feeds/vchart.py +162 -0
  114. backtrader/feeds/vchartcsv.py +84 -0
  115. backtrader/feeds/vchartfile.py +153 -0
  116. backtrader/feeds/yahoo.py +399 -0
  117. backtrader/fillers.py +148 -0
  118. backtrader/filters/__init__.py +34 -0
  119. backtrader/filters/bsplitter.py +127 -0
  120. backtrader/filters/calendardays.py +121 -0
  121. backtrader/filters/datafiller.py +192 -0
  122. backtrader/filters/datafilter.py +74 -0
  123. backtrader/filters/daysteps.py +96 -0
  124. backtrader/filters/heikinashi.py +63 -0
  125. backtrader/filters/renko.py +164 -0
  126. backtrader/filters/session.py +289 -0
  127. backtrader/flt.py +80 -0
  128. backtrader/functions.py +960 -0
  129. backtrader/indicator.py +449 -0
  130. backtrader/indicators/__init__.py +148 -0
  131. backtrader/indicators/accdecoscillator.py +110 -0
  132. backtrader/indicators/aroon.py +300 -0
  133. backtrader/indicators/atr.py +315 -0
  134. backtrader/indicators/awesomeoscillator.py +122 -0
  135. backtrader/indicators/basicops.py +834 -0
  136. backtrader/indicators/bollinger.py +223 -0
  137. backtrader/indicators/cci.py +89 -0
  138. backtrader/indicators/channels_ext.py +83 -0
  139. backtrader/indicators/contrib/__init__.py +228 -0
  140. backtrader/indicators/contrib/absolutely_no_lag_lwma.py +28 -0
  141. backtrader/indicators/contrib/absolutely_no_lag_lwma_color.py +44 -0
  142. backtrader/indicators/contrib/accumulation_distribution_line.py +92 -0
  143. backtrader/indicators/contrib/adx_cross_hull_style_indicator.py +249 -0
  144. backtrader/indicators/contrib/adxdmi.py +34 -0
  145. backtrader/indicators/contrib/ai_acceleration_deceleration_oscillator.py +34 -0
  146. backtrader/indicators/contrib/altr_trend_signal_v22.py +85 -0
  147. backtrader/indicators/contrib/anchored_momentum_line.py +115 -0
  148. backtrader/indicators/contrib/any_range_cld_tail_indicator.py +82 -0
  149. backtrader/indicators/contrib/aroon_horn_sign_indicator.py +96 -0
  150. backtrader/indicators/contrib/aroon_oscillator_sign_alert.py +50 -0
  151. backtrader/indicators/contrib/arrows_curves_indicator.py +112 -0
  152. backtrader/indicators/contrib/as_ctrend_indicator.py +143 -0
  153. backtrader/indicators/contrib/asimmetric_stoch_nr_indicator.py +187 -0
  154. backtrader/indicators/contrib/atr_normalize_histogram.py +118 -0
  155. backtrader/indicators/contrib/average_change_candle.py +165 -0
  156. backtrader/indicators/contrib/bb_squeeze_indicator.py +60 -0
  157. backtrader/indicators/contrib/bezier_st_dev_indicator.py +135 -0
  158. backtrader/indicators/contrib/binary_wave_indicator.py +233 -0
  159. backtrader/indicators/contrib/blau_c_momentum_indicator.py +123 -0
  160. backtrader/indicators/contrib/blau_cmi_indicator.py +141 -0
  161. backtrader/indicators/contrib/blau_csi.py +76 -0
  162. backtrader/indicators/contrib/blau_ergodic.py +53 -0
  163. backtrader/indicators/contrib/blau_t_stoch_i.py +72 -0
  164. backtrader/indicators/contrib/blau_ts_stochastic.py +85 -0
  165. backtrader/indicators/contrib/blau_tvi.py +55 -0
  166. backtrader/indicators/contrib/brain_trend2_indicator.py +128 -0
  167. backtrader/indicators/contrib/brain_trend_signal_proxy.py +47 -0
  168. backtrader/indicators/contrib/brake_parb_indicator.py +85 -0
  169. backtrader/indicators/contrib/breakout_bars_trend_v2.py +121 -0
  170. backtrader/indicators/contrib/bsi_indicator.py +87 -0
  171. backtrader/indicators/contrib/bulls_bears_eyes.py +67 -0
  172. backtrader/indicators/contrib/bulls_power.py +56 -0
  173. backtrader/indicators/contrib/bw_wise_man1_signal.py +102 -0
  174. backtrader/indicators/contrib/bykov_trend_indicator.py +85 -0
  175. backtrader/indicators/contrib/candle_stop_color.py +46 -0
  176. backtrader/indicators/contrib/candles_x_smoothed_indicator.py +69 -0
  177. backtrader/indicators/contrib/candlesticks_bw.py +45 -0
  178. backtrader/indicators/contrib/caudate_x_period_candle_color.py +56 -0
  179. backtrader/indicators/contrib/cci_histogram_indicator.py +53 -0
  180. backtrader/indicators/contrib/cci_woodies_indicator.py +80 -0
  181. backtrader/indicators/contrib/center_of_gravity_candle_indicator.py +83 -0
  182. backtrader/indicators/contrib/center_of_gravity_indicator.py +70 -0
  183. backtrader/indicators/contrib/cg_oscillator.py +40 -0
  184. backtrader/indicators/contrib/close_line_cci.py +38 -0
  185. backtrader/indicators/contrib/close_price_fractals.py +47 -0
  186. backtrader/indicators/contrib/color3rd_gen_xma_indicator.py +122 -0
  187. backtrader/indicators/contrib/color_bb_candles_indicator.py +108 -0
  188. backtrader/indicators/contrib/color_coppock_indicator.py +157 -0
  189. backtrader/indicators/contrib/color_hma.py +71 -0
  190. backtrader/indicators/contrib/color_j_variation_indicator.py +53 -0
  191. backtrader/indicators/contrib/color_metro_de_marker_indicator.py +78 -0
  192. backtrader/indicators/contrib/color_metro_stochastic_indicator.py +93 -0
  193. backtrader/indicators/contrib/color_metro_wpr_indicator.py +85 -0
  194. backtrader/indicators/contrib/color_schaff_de_marker_trend_cycle.py +92 -0
  195. backtrader/indicators/contrib/color_schaff_trend_cycle_indicator.py +203 -0
  196. backtrader/indicators/contrib/color_step_xccx_indicator.py +193 -0
  197. backtrader/indicators/contrib/color_x2_ma.py +49 -0
  198. backtrader/indicators/contrib/color_x_derivative.py +63 -0
  199. backtrader/indicators/contrib/color_zerolag_de_marker.py +84 -0
  200. backtrader/indicators/contrib/corrected_average_indicator.py +127 -0
  201. backtrader/indicators/contrib/darvas_boxes_system.py +73 -0
  202. backtrader/indicators/contrib/dema_range_channel_color.py +42 -0
  203. backtrader/indicators/contrib/derivative_indicator.py +95 -0
  204. backtrader/indicators/contrib/digital_ft01_indicator.py +112 -0
  205. backtrader/indicators/contrib/digital_macd.py +200 -0
  206. backtrader/indicators/contrib/donchian_channels_system.py +45 -0
  207. backtrader/indicators/contrib/dots_indicator.py +93 -0
  208. backtrader/indicators/contrib/ef_distance_indicator.py +82 -0
  209. backtrader/indicators/contrib/ema_rsi_va.py +80 -0
  210. backtrader/indicators/contrib/envelopes_jp_alonso.py +32 -0
  211. backtrader/indicators/contrib/f2a_ao_indicator.py +120 -0
  212. backtrader/indicators/contrib/fatl_filter.py +179 -0
  213. backtrader/indicators/contrib/fibo_candles_indicator.py +78 -0
  214. backtrader/indicators/contrib/fine_tuning_ma.py +100 -0
  215. backtrader/indicators/contrib/fisher_org_v1.py +102 -0
  216. backtrader/indicators/contrib/fisher_org_v1_sign.py +118 -0
  217. backtrader/indicators/contrib/force_index_ema.py +96 -0
  218. backtrader/indicators/contrib/force_index_ema_2.py +27 -0
  219. backtrader/indicators/contrib/forecast_oscilator.py +145 -0
  220. backtrader/indicators/contrib/fractal_amambk.py +81 -0
  221. backtrader/indicators/contrib/frama_series.py +84 -0
  222. backtrader/indicators/contrib/frasm_av2_indicator.py +104 -0
  223. backtrader/indicators/contrib/go_indicator.py +93 -0
  224. backtrader/indicators/contrib/hlr_indicator.py +95 -0
  225. backtrader/indicators/contrib/hma.py +50 -0
  226. backtrader/indicators/contrib/i4_drfv2.py +34 -0
  227. backtrader/indicators/contrib/i4_drfv3.py +38 -0
  228. backtrader/indicators/contrib/i_anch_mom_indicator.py +72 -0
  229. backtrader/indicators/contrib/i_de_marker_sign_indicator.py +64 -0
  230. backtrader/indicators/contrib/i_gap_indicator.py +45 -0
  231. backtrader/indicators/contrib/i_stoch_komposter_indicator.py +77 -0
  232. backtrader/indicators/contrib/i_trend_indicator.py +125 -0
  233. backtrader/indicators/contrib/iamma_indicator.py +39 -0
  234. backtrader/indicators/contrib/indexed_moving_average.py +33 -0
  235. backtrader/indicators/contrib/instantaneous_trend_filter_indicator.py +51 -0
  236. backtrader/indicators/contrib/inverse_reaction_indicator.py +41 -0
  237. backtrader/indicators/contrib/irsi_sign_indicator.py +95 -0
  238. backtrader/indicators/contrib/iwpr_sign_indicator.py +59 -0
  239. backtrader/indicators/contrib/j_brain_trend1_sig_indicator.py +233 -0
  240. backtrader/indicators/contrib/j_tpo_proxy.py +32 -0
  241. backtrader/indicators/contrib/jma_slope_indicator.py +73 -0
  242. backtrader/indicators/contrib/kalman_filter_indicator.py +119 -0
  243. backtrader/indicators/contrib/kalman_filter_line.py +127 -0
  244. backtrader/indicators/contrib/kama_indicator.py +150 -0
  245. backtrader/indicators/contrib/karacatica_indicator.py +99 -0
  246. backtrader/indicators/contrib/kdj_indicator.py +59 -0
  247. backtrader/indicators/contrib/kwan_ccc_indicator.py +195 -0
  248. backtrader/indicators/contrib/kwan_nrp_indicator.py +113 -0
  249. backtrader/indicators/contrib/kwan_rdp_indicator.py +192 -0
  250. backtrader/indicators/contrib/laguerre_adx_indicator.py +85 -0
  251. backtrader/indicators/contrib/laguerre_filter_indicator.py +66 -0
  252. backtrader/indicators/contrib/laguerre_plus_di_proxy.py +57 -0
  253. backtrader/indicators/contrib/laguerre_roc_indicator.py +81 -0
  254. backtrader/indicators/contrib/le_man_signal_indicator.py +63 -0
  255. backtrader/indicators/contrib/linear_reg_slope_v2_indicator.py +136 -0
  256. backtrader/indicators/contrib/loco_indicator.py +88 -0
  257. backtrader/indicators/contrib/lrma_indicator.py +185 -0
  258. backtrader/indicators/contrib/lsma_angle_indicator.py +106 -0
  259. backtrader/indicators/contrib/ma_rounding_channel_indicator.py +149 -0
  260. backtrader/indicators/contrib/macd2_indicator.py +61 -0
  261. backtrader/indicators/contrib/macd_candle_indicator.py +80 -0
  262. backtrader/indicators/contrib/malr_indicator.py +77 -0
  263. backtrader/indicators/contrib/momentum_candle_sign_indicator.py +51 -0
  264. backtrader/indicators/contrib/moving_average_fn_indicator.py +139 -0
  265. backtrader/indicators/contrib/mt5_stochastic_close_close.py +57 -0
  266. backtrader/indicators/contrib/muv_nor_diff_cloud_indicator.py +107 -0
  267. backtrader/indicators/contrib/non_lag_dot_indicator.py +124 -0
  268. backtrader/indicators/contrib/nrtr_extr_indicator.py +95 -0
  269. backtrader/indicators/contrib/nrtr_indicator.py +95 -0
  270. backtrader/indicators/contrib/p_channel_system.py +40 -0
  271. backtrader/indicators/contrib/percent_envelope.py +37 -0
  272. backtrader/indicators/contrib/percentage_crossover_channel.py +47 -0
  273. backtrader/indicators/contrib/pivot_zig_zag_proxy.py +47 -0
  274. backtrader/indicators/contrib/price_channel_stop_indicator.py +104 -0
  275. backtrader/indicators/contrib/price_extreme_channel.py +35 -0
  276. backtrader/indicators/contrib/qqe_cloud_indicator.py +129 -0
  277. backtrader/indicators/contrib/ravi_indicator.py +40 -0
  278. backtrader/indicators/contrib/raw_close_close_stochastic.py +74 -0
  279. backtrader/indicators/contrib/rd_trend_trigger_indicator.py +51 -0
  280. backtrader/indicators/contrib/renko_level.py +85 -0
  281. backtrader/indicators/contrib/renko_line_break.py +91 -0
  282. backtrader/indicators/contrib/rftl_indicator.py +41 -0
  283. backtrader/indicators/contrib/rkd_indicator.py +53 -0
  284. backtrader/indicators/contrib/roc2_vg_indicator.py +68 -0
  285. backtrader/indicators/contrib/rsi_histogram_indicator.py +43 -0
  286. backtrader/indicators/contrib/rsi_slowdown.py +57 -0
  287. backtrader/indicators/contrib/rsioma_v2.py +41 -0
  288. backtrader/indicators/contrib/rvi_histogram_indicator.py +107 -0
  289. backtrader/indicators/contrib/safe_adx.py +89 -0
  290. backtrader/indicators/contrib/shared_strategy_indicators.py +1651 -0
  291. backtrader/indicators/contrib/sidus_indicator.py +105 -0
  292. backtrader/indicators/contrib/silver_trend_indicator.py +79 -0
  293. backtrader/indicators/contrib/sliding_range_color.py +56 -0
  294. backtrader/indicators/contrib/slow_stoch.py +42 -0
  295. backtrader/indicators/contrib/smoothed_adx_indicator.py +86 -0
  296. backtrader/indicators/contrib/smoothed_rsi.py +31 -0
  297. backtrader/indicators/contrib/spearman_rank_correlation_histogram.py +60 -0
  298. backtrader/indicators/contrib/stalin_indicator.py +152 -0
  299. backtrader/indicators/contrib/starter_laguerre_filter.py +62 -0
  300. backtrader/indicators/contrib/step_manrtr_indicator.py +137 -0
  301. backtrader/indicators/contrib/stochastic_histogram_indicator.py +143 -0
  302. backtrader/indicators/contrib/t3_alarm_indicator.py +125 -0
  303. backtrader/indicators/contrib/t3_average.py +76 -0
  304. backtrader/indicators/contrib/t3_indicator.py +40 -0
  305. backtrader/indicators/contrib/the20s_v020_signal.py +93 -0
  306. backtrader/indicators/contrib/three_candles_indicator.py +70 -0
  307. backtrader/indicators/contrib/three_line_break_indicator.py +64 -0
  308. backtrader/indicators/contrib/time_line.py +57 -0
  309. backtrader/indicators/contrib/trading_channel_index_proxy.py +48 -0
  310. backtrader/indicators/contrib/trend_arrows_indicator.py +109 -0
  311. backtrader/indicators/contrib/trend_continuation_indicator.py +127 -0
  312. backtrader/indicators/contrib/trend_intensity_index_proxy.py +51 -0
  313. backtrader/indicators/contrib/trend_manager_indicator.py +39 -0
  314. backtrader/indicators/contrib/tri_x_candle_indicator.py +51 -0
  315. backtrader/indicators/contrib/trigger_line.py +66 -0
  316. backtrader/indicators/contrib/triple_ema_rate.py +34 -0
  317. backtrader/indicators/contrib/trvi_indicator.py +194 -0
  318. backtrader/indicators/contrib/two_pb_ideal_xosma_indicator.py +127 -0
  319. backtrader/indicators/contrib/ultra_absolutely_no_lag_lwma_color.py +92 -0
  320. backtrader/indicators/contrib/ultra_wpr_indicator.py +173 -0
  321. backtrader/indicators/contrib/up_down_candle_strength.py +68 -0
  322. backtrader/indicators/contrib/vinin_i_trend_indicator.py +139 -0
  323. backtrader/indicators/contrib/volume_weighted_ma_indicator.py +78 -0
  324. backtrader/indicators/contrib/volume_weighted_ma_st_dev_indicator.py +111 -0
  325. backtrader/indicators/contrib/vwap_close_indicator.py +65 -0
  326. backtrader/indicators/contrib/vwma_candle.py +57 -0
  327. backtrader/indicators/contrib/vwma_digit_system.py +70 -0
  328. backtrader/indicators/contrib/wami.py +43 -0
  329. backtrader/indicators/contrib/wprsi_signal_indicator.py +105 -0
  330. backtrader/indicators/contrib/x_de_marker_histogram_vol_direct_indicator.py +145 -0
  331. backtrader/indicators/contrib/x_fisher_indicator.py +64 -0
  332. backtrader/indicators/contrib/xcci_histogram_vol_direct_indicator.py +56 -0
  333. backtrader/indicators/contrib/xcci_histogram_vol_indicator.py +85 -0
  334. backtrader/indicators/contrib/xma_ichimoku.py +163 -0
  335. backtrader/indicators/contrib/xma_ishimoku_channel_indicator.py +65 -0
  336. backtrader/indicators/contrib/xma_ishimoku_line.py +68 -0
  337. backtrader/indicators/contrib/xma_range_bands_indicator.py +107 -0
  338. backtrader/indicators/contrib/xmacd_indicator.py +70 -0
  339. backtrader/indicators/contrib/xrsi_de_marker_histogram.py +67 -0
  340. backtrader/indicators/contrib/xrsi_histogram_vol_direct_indicator.py +52 -0
  341. backtrader/indicators/contrib/xrsi_histogram_vol_indicator.py +81 -0
  342. backtrader/indicators/contrib/xrvi_indicator.py +130 -0
  343. backtrader/indicators/contrib/zero_lag_macd.py +36 -0
  344. backtrader/indicators/contrib/zig_zag_recent_pivot_signal.py +90 -0
  345. backtrader/indicators/contrib/zpf_indicator.py +115 -0
  346. backtrader/indicators/crossover.py +337 -0
  347. backtrader/indicators/dema.py +175 -0
  348. backtrader/indicators/demarker.py +270 -0
  349. backtrader/indicators/deviation.py +284 -0
  350. backtrader/indicators/directionalmove.py +1071 -0
  351. backtrader/indicators/dma.py +112 -0
  352. backtrader/indicators/dpo.py +96 -0
  353. backtrader/indicators/dv2.py +56 -0
  354. backtrader/indicators/ema.py +145 -0
  355. backtrader/indicators/envelope.py +475 -0
  356. backtrader/indicators/hadelta.py +198 -0
  357. backtrader/indicators/heikinashi.py +153 -0
  358. backtrader/indicators/hma.py +153 -0
  359. backtrader/indicators/hurst.py +151 -0
  360. backtrader/indicators/ichimoku.py +267 -0
  361. backtrader/indicators/kama.py +181 -0
  362. backtrader/indicators/kst.py +159 -0
  363. backtrader/indicators/lrsi.py +125 -0
  364. backtrader/indicators/mabase.py +147 -0
  365. backtrader/indicators/macd.py +322 -0
  366. backtrader/indicators/momentum.py +267 -0
  367. backtrader/indicators/moneyflow.py +237 -0
  368. backtrader/indicators/mt5atr.py +124 -0
  369. backtrader/indicators/myind.py +179 -0
  370. backtrader/indicators/obv.py +94 -0
  371. backtrader/indicators/ols.py +265 -0
  372. backtrader/indicators/oscillator.py +161 -0
  373. backtrader/indicators/percentchange.py +83 -0
  374. backtrader/indicators/percentrank.py +46 -0
  375. backtrader/indicators/pivotpoint.py +469 -0
  376. backtrader/indicators/prettygoodoscillator.py +113 -0
  377. backtrader/indicators/priceops_ext.py +123 -0
  378. backtrader/indicators/priceoscillator.py +262 -0
  379. backtrader/indicators/psar.py +212 -0
  380. backtrader/indicators/rmi.py +69 -0
  381. backtrader/indicators/rsi.py +440 -0
  382. backtrader/indicators/sma.py +141 -0
  383. backtrader/indicators/smma.py +116 -0
  384. backtrader/indicators/spread.py +54 -0
  385. backtrader/indicators/stochastic.py +263 -0
  386. backtrader/indicators/supertrend.py +436 -0
  387. backtrader/indicators/trend_ext.py +105 -0
  388. backtrader/indicators/trix.py +202 -0
  389. backtrader/indicators/tsi.py +155 -0
  390. backtrader/indicators/ultimateoscillator.py +158 -0
  391. backtrader/indicators/vortex.py +62 -0
  392. backtrader/indicators/williams.py +194 -0
  393. backtrader/indicators/wma.py +103 -0
  394. backtrader/indicators/zlema.py +135 -0
  395. backtrader/indicators/zlind.py +104 -0
  396. backtrader/linebuffer.py +3155 -0
  397. backtrader/lineiterator.py +2911 -0
  398. backtrader/lineroot.py +1106 -0
  399. backtrader/lineseries.py +2559 -0
  400. backtrader/live_trading/__init__.py +31 -0
  401. backtrader/live_trading/interface.py +404 -0
  402. backtrader/mathsupport.py +94 -0
  403. backtrader/metabase.py +1804 -0
  404. backtrader/mixins/__init__.py +21 -0
  405. backtrader/mixins/singleton.py +118 -0
  406. backtrader/observer.py +106 -0
  407. backtrader/observers/__init__.py +45 -0
  408. backtrader/observers/benchmark.py +126 -0
  409. backtrader/observers/broker.py +184 -0
  410. backtrader/observers/buysell.py +144 -0
  411. backtrader/observers/drawdown.py +161 -0
  412. backtrader/observers/logreturns.py +113 -0
  413. backtrader/observers/timereturn.py +86 -0
  414. backtrader/observers/trade_logger.py +2972 -0
  415. backtrader/observers/tradelogger.py +6 -0
  416. backtrader/observers/trades.py +258 -0
  417. backtrader/order.py +1114 -0
  418. backtrader/parameters.py +2345 -0
  419. backtrader/plot/__init__.py +54 -0
  420. backtrader/plot/finance.py +1022 -0
  421. backtrader/plot/formatters.py +200 -0
  422. backtrader/plot/locator.py +353 -0
  423. backtrader/plot/multicursor.py +495 -0
  424. backtrader/plot/plot.py +2500 -0
  425. backtrader/plot/plot_plotly.py +1351 -0
  426. backtrader/plot/scheme.py +253 -0
  427. backtrader/plot/utils.py +104 -0
  428. backtrader/position.py +290 -0
  429. backtrader/position_modes.py +132 -0
  430. backtrader/profiles.py +254 -0
  431. backtrader/reports/__init__.py +39 -0
  432. backtrader/reports/charts.py +371 -0
  433. backtrader/reports/performance.py +620 -0
  434. backtrader/reports/reporter.py +660 -0
  435. backtrader/resamplerfilter.py +1001 -0
  436. backtrader/signal.py +118 -0
  437. backtrader/signals/__init__.py +17 -0
  438. backtrader/sizer.py +114 -0
  439. backtrader/sizers/__init__.py +26 -0
  440. backtrader/sizers/fixedsize.py +161 -0
  441. backtrader/sizers/percents_sizer.py +119 -0
  442. backtrader/store.py +221 -0
  443. backtrader/stores/__init__.py +33 -0
  444. backtrader/stores/btapistore.py +15506 -0
  445. backtrader/stores/livestore.py +137 -0
  446. backtrader/stores/vchartfile.py +96 -0
  447. backtrader/strategy.py +3655 -0
  448. backtrader/talib.py +280 -0
  449. backtrader/test_helpers.py +96 -0
  450. backtrader/timer.py +358 -0
  451. backtrader/trade.py +442 -0
  452. backtrader/tradingcal.py +361 -0
  453. backtrader/utils/__init__.py +68 -0
  454. backtrader/utils/autodict.py +251 -0
  455. backtrader/utils/date.py +71 -0
  456. backtrader/utils/dateintern.py +509 -0
  457. backtrader/utils/flushfile.py +94 -0
  458. backtrader/utils/fractal.py +101 -0
  459. backtrader/utils/get_metrics.py +101 -0
  460. backtrader/utils/load_data.py +209 -0
  461. backtrader/utils/log_message.py +998 -0
  462. backtrader/utils/ordereddefaultdict.py +75 -0
  463. backtrader/utils/py3.py +296 -0
  464. backtrader/version.py +21 -0
  465. backtrader/writer.py +372 -0
@@ -0,0 +1,1051 @@
1
+ """Strict, side-effect-free CTP multi-leg quote cohort validation.
2
+
3
+ This module turns public ``ctp.quote.v2`` snapshots into immutable evidence
4
+ objects and admits a cohort only after every configured leg has supplied a
5
+ new, valid quote. It deliberately has no network, order, broker, or strategy
6
+ dependency: a caller may use an admitted cohort for a screen, a bar decision,
7
+ or an observation-only audit, but this module never creates an order or an
8
+ execution intent.
9
+
10
+ The validator treats source-time quality as an explicit prerequisite. A
11
+ missing or unverified source clock is rejected; it is never upgraded from a
12
+ receive timestamp or a local fallback clock.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from collections.abc import Iterable, Mapping
19
+ from dataclasses import dataclass
20
+ from datetime import datetime, timezone
21
+ from decimal import Decimal, InvalidOperation
22
+ from types import MappingProxyType
23
+ from typing import Any, Optional, Tuple
24
+
25
+ _MAX_ABS_NUMBER = 1.0e30
26
+ _MAX_UINT64 = (1 << 64) - 1
27
+ _PROVENANCE_PLACEHOLDERS = frozenset(
28
+ {
29
+ "unknown",
30
+ "unverified",
31
+ "n/a",
32
+ "na",
33
+ "none",
34
+ "null",
35
+ "unset",
36
+ "placeholder",
37
+ }
38
+ )
39
+
40
+
41
+ class CtpCohortReason:
42
+ """Stable reasons returned by :class:`CtpQuoteCohortValidator`.
43
+
44
+ A successful result has ``reason is None``. These string constants are
45
+ intentionally public so strategy logs and tests do not need to parse an
46
+ exception message.
47
+ """
48
+
49
+ WAITING_FOR_LEGS = "WAITING_FOR_LEGS"
50
+ WAITING_FOR_ALL_LEGS_NEW = "WAITING_FOR_ALL_LEGS_NEW"
51
+ UNEXPECTED_SYMBOL = "UNEXPECTED_SYMBOL"
52
+ QUOTE_SYMBOL_MISSING = "QUOTE_SYMBOL_MISSING"
53
+ QUOTE_IDENTITY_CONFLICT = "QUOTE_IDENTITY_CONFLICT"
54
+ EXCHANGE_MISMATCH = "EXCHANGE_MISMATCH"
55
+ ASSET_TYPE_MISMATCH = "ASSET_TYPE_MISMATCH"
56
+ UNSUPPORTED_QUOTE_SCHEMA = "UNSUPPORTED_QUOTE_SCHEMA"
57
+ VOLUME_SEMANTICS_NOT_DELTA = "VOLUME_SEMANTICS_NOT_DELTA"
58
+ SOURCE_CLOCK_UNVERIFIED = "SOURCE_CLOCK_UNVERIFIED"
59
+ RECEIVE_CLOCK_UNVERIFIED = "RECEIVE_CLOCK_UNVERIFIED"
60
+ FRESHNESS_UNVERIFIED = "FRESHNESS_UNVERIFIED"
61
+ EVENT_TIME_SOURCE_MISSING = "EVENT_TIME_SOURCE_MISSING"
62
+ RULES_HASH_MISMATCH = "RULES_HASH_MISMATCH"
63
+ QUOTE_SOURCE_MISSING = "QUOTE_SOURCE_MISSING"
64
+ QUOTE_STREAM_UNREADY = "QUOTE_STREAM_UNREADY"
65
+ QUOTE_CONTINUITY_NOT_CONTINUOUS = "QUOTE_CONTINUITY_NOT_CONTINUOUS"
66
+ QUOTE_QUALITY_FLAGS_INVALID = "QUOTE_QUALITY_FLAGS_INVALID"
67
+ QUOTE_QUALITY_FLAGS_PRESENT = "QUOTE_QUALITY_FLAGS_PRESENT"
68
+ EXECUTION_INELIGIBLE_QUOTE = "EXECUTION_INELIGIBLE_QUOTE"
69
+ VOLUME_INCOMPLETE = "VOLUME_INCOMPLETE"
70
+ VOLUME_QUALITY_NOT_CONTINUOUS = "VOLUME_QUALITY_NOT_CONTINUOUS"
71
+ QUOTE_NUMERIC_TYPE_INVALID = "QUOTE_NUMERIC_TYPE_INVALID"
72
+ QUOTE_NUMERIC_INVALID = "QUOTE_NUMERIC_INVALID"
73
+ QUOTE_NONPOSITIVE = "QUOTE_NONPOSITIVE"
74
+ QUOTE_CROSSED = "QUOTE_CROSSED"
75
+ DAILY_PRICE_LIMIT_INVALID = "DAILY_PRICE_LIMIT_INVALID"
76
+ QUOTE_OUTSIDE_DAILY_LIMIT = "QUOTE_OUTSIDE_DAILY_LIMIT"
77
+ QUOTE_OFF_TICK_GRID = "QUOTE_OFF_TICK_GRID"
78
+ QUOTE_IDENTITY_TYPE_INVALID = "QUOTE_IDENTITY_TYPE_INVALID"
79
+ QUOTE_IDENTITY_OR_CLOCK_MISSING = "QUOTE_IDENTITY_OR_CLOCK_MISSING"
80
+ TRADING_DAY_INVALID = "TRADING_DAY_INVALID"
81
+ ACTION_DAY_INVALID = "ACTION_DAY_INVALID"
82
+ CLOCK_DOMAIN_UNKNOWN = "CLOCK_DOMAIN_UNKNOWN"
83
+ SOURCE_TIME_INVALID = "SOURCE_TIME_INVALID"
84
+ RECEIVE_TIME_INVALID = "RECEIVE_TIME_INVALID"
85
+ SOURCE_TIME_AFTER_RECEIVE = "SOURCE_TIME_AFTER_RECEIVE"
86
+ SOURCE_CLOCK_ERROR_INVALID = "SOURCE_CLOCK_ERROR_INVALID"
87
+ RECEIVE_CLOCK_ERROR_INVALID = "RECEIVE_CLOCK_ERROR_INVALID"
88
+ DUPLICATE_OR_OUT_OF_ORDER = "DUPLICATE_OR_OUT_OF_ORDER"
89
+ OUT_OF_ORDER_RECEIVE_TIME = "OUT_OF_ORDER_RECEIVE_TIME"
90
+ OUT_OF_ORDER_SOURCE_TIME = "OUT_OF_ORDER_SOURCE_TIME"
91
+ COHORT_EXCHANGE_MISMATCH = "COHORT_EXCHANGE_MISMATCH"
92
+ COHORT_TRADING_DAY_MISMATCH = "COHORT_TRADING_DAY_MISMATCH"
93
+ COHORT_ACTION_DAY_MISMATCH = "COHORT_ACTION_DAY_MISMATCH"
94
+ COHORT_CONNECTION_GENERATION_MISMATCH = "COHORT_CONNECTION_GENERATION_MISMATCH"
95
+ COHORT_SUBSCRIPTION_EPOCH_MISMATCH = "COHORT_SUBSCRIPTION_EPOCH_MISMATCH"
96
+ COHORT_RULES_HASH_MISMATCH = "COHORT_RULES_HASH_MISMATCH"
97
+ COHORT_CLOCK_DOMAIN_MISMATCH = "COHORT_CLOCK_DOMAIN_MISMATCH"
98
+ STALE_COHORT_RECEIVE_TIME = "STALE_COHORT_RECEIVE_TIME"
99
+ BLOCKED_CROSS_LEG_SKEW = "BLOCKED_CROSS_LEG_SKEW"
100
+ STALE_COHORT_SOURCE_TIME = "STALE_COHORT_SOURCE_TIME"
101
+ BLOCKED_SOURCE_SKEW = "BLOCKED_SOURCE_SKEW"
102
+ TRUSTED_NOW_REQUIRED = "TRUSTED_NOW_REQUIRED"
103
+ TRUSTED_NOW_INVALID = "TRUSTED_NOW_INVALID"
104
+ NOW_CLOCK_DOMAIN_MISMATCH = "NOW_CLOCK_DOMAIN_MISMATCH"
105
+ NOW_WALL_TIME_BEFORE_QUOTE = "NOW_WALL_TIME_BEFORE_QUOTE"
106
+ RETIRED_CONNECTION_SCOPE = "RETIRED_CONNECTION_SCOPE"
107
+ NO_CONFIRMED_COHORT = "NO_CONFIRMED_COHORT"
108
+
109
+
110
+ def _strict_positive_number(value: Any, *, field: str) -> float:
111
+ """Return a finite positive built-in numeric value or raise ``ValueError``."""
112
+
113
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
114
+ raise ValueError(f"{field} must be a built-in finite positive number")
115
+ number = float(value)
116
+ if not math.isfinite(number) or number <= 0.0 or abs(number) >= _MAX_ABS_NUMBER:
117
+ raise ValueError(f"{field} must be a built-in finite positive number")
118
+ return number
119
+
120
+
121
+ def _strict_nonnegative_number(value: Any, *, field: str) -> float:
122
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
123
+ raise ValueError(f"{field} must be a built-in finite non-negative number")
124
+ number = float(value)
125
+ if not math.isfinite(number) or number < 0.0 or abs(number) >= _MAX_ABS_NUMBER:
126
+ raise ValueError(f"{field} must be a built-in finite non-negative number")
127
+ return number
128
+
129
+
130
+ def _strict_nonempty_text(value: Any, *, field: str) -> str:
131
+ if not isinstance(value, str) or not value or value.strip() != value:
132
+ raise ValueError(f"{field} must be a non-empty string")
133
+ return value
134
+
135
+
136
+ def _is_strict_nonempty_text(value: Any) -> bool:
137
+ return isinstance(value, str) and bool(value) and value.strip() == value
138
+
139
+
140
+ def _is_provenance_identity(value: Any) -> bool:
141
+ """Accept an explicit provenance identity, never a placeholder value.
142
+
143
+ CTP quote fields such as source, rules hash and clock domain are security
144
+ boundaries. Treating a literal ``"unknown"`` as an identity would let a
145
+ caller make two unrelated unknown values appear to match.
146
+ """
147
+
148
+ return _is_strict_nonempty_text(value) and value.casefold() not in _PROVENANCE_PLACEHOLDERS
149
+
150
+
151
+ def _strict_provenance_identity(value: Any, *, field: str) -> str:
152
+ if not _is_provenance_identity(value):
153
+ raise ValueError(f"{field} must be a non-placeholder provenance identity")
154
+ return value
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class CtpCohortLeg:
159
+ """One immutable expected leg in a two- or three-leg CTP cohort."""
160
+
161
+ symbol: str
162
+ exchange: str
163
+ price_tick: float
164
+ asset_type: Optional[str] = None
165
+
166
+ def __post_init__(self) -> None:
167
+ _strict_nonempty_text(self.symbol, field="symbol")
168
+ _strict_nonempty_text(self.exchange, field="exchange")
169
+ object.__setattr__(
170
+ self,
171
+ "price_tick",
172
+ _strict_positive_number(self.price_tick, field="price_tick"),
173
+ )
174
+ if self.asset_type is not None:
175
+ if self.asset_type not in {"future", "option"}:
176
+ raise ValueError("asset_type must be future, option, or None")
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class CtpCohortPolicy:
181
+ """Immutable time-quality bounds for a cohort decision."""
182
+
183
+ max_receive_age_ms: float
184
+ max_receive_skew_ms: float
185
+ max_source_age_ms: float
186
+ max_source_skew_ms: float
187
+ max_source_clock_error_ms: float
188
+ max_receive_clock_error_ms: float
189
+
190
+ def __post_init__(self) -> None:
191
+ for name in (
192
+ "max_receive_age_ms",
193
+ "max_receive_skew_ms",
194
+ "max_source_age_ms",
195
+ "max_source_skew_ms",
196
+ "max_source_clock_error_ms",
197
+ "max_receive_clock_error_ms",
198
+ ):
199
+ object.__setattr__(
200
+ self,
201
+ name,
202
+ _strict_nonnegative_number(getattr(self, name), field=name),
203
+ )
204
+
205
+
206
+ @dataclass(frozen=True)
207
+ class CtpCohortNow:
208
+ """Trusted current-time evidence supplied by a cohort caller.
209
+
210
+ The validator intentionally does not call a process clock. A caller must
211
+ provide a same-domain monotonic reading and a verified receive-wall-clock
212
+ reading for every ingestion and pre-submit recheck. This makes queue
213
+ delays observable instead of silently treating the most recent quote as
214
+ ``now``.
215
+ """
216
+
217
+ now_monotonic_ns: int
218
+ now_epoch: float
219
+ clock_domain_id: str
220
+ receive_clock_error_ms: float
221
+ receive_clock_quality: str = "verified"
222
+ freshness_verified: bool = True
223
+
224
+ def __post_init__(self) -> None:
225
+ monotonic = _strict_positive_uint64(self.now_monotonic_ns)
226
+ epoch = _epoch_seconds(self.now_epoch)
227
+ if monotonic is None or epoch is None:
228
+ raise ValueError("now_monotonic_ns and now_epoch must be valid trusted clock values")
229
+ _strict_provenance_identity(self.clock_domain_id, field="clock_domain_id")
230
+ if self.receive_clock_quality != "verified":
231
+ raise ValueError("receive_clock_quality must be verified")
232
+ if self.freshness_verified is not True:
233
+ raise ValueError("freshness_verified must be True")
234
+ error = _strict_quote_number(self.receive_clock_error_ms)
235
+ if error is None or error < 0.0:
236
+ raise ValueError("receive_clock_error_ms must be a finite non-negative number")
237
+ object.__setattr__(self, "now_monotonic_ns", monotonic)
238
+ object.__setattr__(self, "now_epoch", epoch)
239
+ object.__setattr__(self, "receive_clock_error_ms", error)
240
+
241
+
242
+ @dataclass(frozen=True)
243
+ class CtpQuoteEvidence:
244
+ """Immutable validated CTP level-one quote evidence."""
245
+
246
+ symbol: str
247
+ exchange: str
248
+ asset_type: Optional[str]
249
+ bid: float
250
+ ask: float
251
+ bid_size: float
252
+ ask_size: float
253
+ last: float
254
+ lower_limit: float
255
+ upper_limit: float
256
+ source_epoch: float
257
+ receive_epoch: float
258
+ receive_monotonic_ns: int
259
+ ingest_seq: int
260
+ connection_generation: int
261
+ subscription_epoch: int
262
+ trading_day: str
263
+ action_day: str
264
+ clock_domain_id: str
265
+ rules_hash: str
266
+ source: str
267
+ event_time_source: str
268
+ source_clock_error_ms: float
269
+ receive_clock_error_ms: float
270
+
271
+ @property
272
+ def update_identity(self) -> Tuple[str, int, int, int]:
273
+ """The immutable identity used to require a fresh quote per leg."""
274
+
275
+ return (
276
+ self.symbol,
277
+ self.connection_generation,
278
+ self.subscription_epoch,
279
+ self.ingest_seq,
280
+ )
281
+
282
+
283
+ @dataclass(frozen=True)
284
+ class CtpQuoteValidation:
285
+ """The result of strict quote normalization without any state mutation."""
286
+
287
+ quote: Optional[CtpQuoteEvidence]
288
+ reason: Optional[str]
289
+
290
+ @property
291
+ def accepted(self) -> bool:
292
+ return self.quote is not None
293
+
294
+
295
+ @dataclass(frozen=True)
296
+ class CtpQuoteCohort:
297
+ """An immutable set of synchronized, fresh quote evidence."""
298
+
299
+ quotes: Mapping[str, CtpQuoteEvidence]
300
+ exchange: str
301
+ trading_day: str
302
+ action_day: str
303
+ connection_generation: int
304
+ subscription_epoch: int
305
+ clock_domain_id: str
306
+ rules_hash: str
307
+ cohort_id: str
308
+
309
+ def __post_init__(self) -> None:
310
+ if not isinstance(self.quotes, Mapping) or not self.quotes:
311
+ raise ValueError("quotes must be a non-empty mapping")
312
+ quotes = dict(self.quotes)
313
+ if not all(isinstance(quote, CtpQuoteEvidence) for quote in quotes.values()):
314
+ raise TypeError("quotes must contain only CtpQuoteEvidence values")
315
+ if any(symbol != quote.symbol for symbol, quote in quotes.items()):
316
+ raise ValueError("quote mapping keys must exactly match quote.symbol")
317
+ _strict_nonempty_text(self.exchange, field="exchange")
318
+ if not _valid_trading_day(self.trading_day):
319
+ raise ValueError("trading_day must be a valid YYYYMMDD date")
320
+ if not _valid_trading_day(self.action_day):
321
+ raise ValueError("action_day must be a valid YYYYMMDD date")
322
+ if _strict_positive_uint64(self.connection_generation) is None:
323
+ raise ValueError("connection_generation must be a positive uint64")
324
+ if _strict_positive_uint64(self.subscription_epoch) is None:
325
+ raise ValueError("subscription_epoch must be a positive uint64")
326
+ _strict_provenance_identity(self.clock_domain_id, field="clock_domain_id")
327
+ _strict_provenance_identity(self.rules_hash, field="rules_hash")
328
+ _strict_nonempty_text(self.cohort_id, field="cohort_id")
329
+ expected_metadata = {
330
+ "exchange": self.exchange,
331
+ "trading_day": self.trading_day,
332
+ "action_day": self.action_day,
333
+ "connection_generation": self.connection_generation,
334
+ "subscription_epoch": self.subscription_epoch,
335
+ "clock_domain_id": self.clock_domain_id,
336
+ "rules_hash": self.rules_hash,
337
+ }
338
+ if any(
339
+ any(getattr(quote, name) != value for name, value in expected_metadata.items())
340
+ for quote in quotes.values()
341
+ ):
342
+ raise ValueError("cohort metadata must exactly match every quote")
343
+ object.__setattr__(self, "quotes", MappingProxyType(quotes))
344
+
345
+ def quote_for(self, symbol: str) -> CtpQuoteEvidence:
346
+ """Return the evidence for an expected symbol."""
347
+
348
+ return self.quotes[symbol]
349
+
350
+
351
+ @dataclass(frozen=True)
352
+ class CtpCohortResult:
353
+ """The result of ingesting one quote into a stateful cohort validator."""
354
+
355
+ cohort: Optional[CtpQuoteCohort]
356
+ reason: Optional[str]
357
+
358
+ @property
359
+ def accepted(self) -> bool:
360
+ return self.cohort is not None
361
+
362
+
363
+ def _event_value(event: Any, *names: str) -> Any:
364
+ """Read the first present public field from a mapping or event object."""
365
+
366
+ if isinstance(event, Mapping):
367
+ for name in names:
368
+ if name in event:
369
+ return event[name]
370
+ return None
371
+ for name in names:
372
+ if hasattr(event, name):
373
+ return getattr(event, name)
374
+ return None
375
+
376
+
377
+ def _consistent_identity_alias(event: Any, *names: str) -> Tuple[Any, bool]:
378
+ """Read identity aliases and require every supplied spelling to agree."""
379
+
380
+ values = []
381
+ if isinstance(event, Mapping):
382
+ for name in names:
383
+ if name in event:
384
+ values.append(event[name])
385
+ else:
386
+ for name in names:
387
+ if hasattr(event, name):
388
+ values.append(getattr(event, name))
389
+ if not values:
390
+ return None, True
391
+ first = values[0]
392
+ return first, all(value == first for value in values[1:])
393
+
394
+
395
+ def _strict_quote_number(value: Any) -> Optional[float]:
396
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
397
+ return None
398
+ number = float(value)
399
+ if not math.isfinite(number) or abs(number) >= _MAX_ABS_NUMBER:
400
+ return None
401
+ return number
402
+
403
+
404
+ def _strict_positive_uint64(value: Any) -> Optional[int]:
405
+ if type(value) is not int or value <= 0 or value > _MAX_UINT64:
406
+ return None
407
+ return value
408
+
409
+
410
+ def _epoch_seconds(value: Any) -> Optional[float]:
411
+ """Parse only explicit, timezone-qualified wall-clock evidence."""
412
+
413
+ if isinstance(value, bool):
414
+ return None
415
+ if isinstance(value, datetime):
416
+ if value.tzinfo is None or value.utcoffset() is None:
417
+ return None
418
+ try:
419
+ result = value.astimezone(timezone.utc).timestamp()
420
+ except (OverflowError, OSError, ValueError):
421
+ return None
422
+ elif isinstance(value, (int, float)):
423
+ result = float(value)
424
+ elif isinstance(value, str):
425
+ try:
426
+ moment = datetime.fromisoformat(value.replace("Z", "+00:00"))
427
+ except ValueError:
428
+ return None
429
+ if moment.tzinfo is None or moment.utcoffset() is None:
430
+ return None
431
+ try:
432
+ result = moment.astimezone(timezone.utc).timestamp()
433
+ except (OverflowError, OSError, ValueError):
434
+ return None
435
+ else:
436
+ return None
437
+ if not math.isfinite(result) or abs(result) >= _MAX_ABS_NUMBER:
438
+ return None
439
+ return result
440
+
441
+
442
+ def _on_tick_grid(value: float, tick: float) -> bool:
443
+ try:
444
+ amount = Decimal(str(value))
445
+ increment = Decimal(str(tick))
446
+ return increment > 0 and amount.remainder_near(increment) == 0
447
+ except (InvalidOperation, ValueError):
448
+ return False
449
+
450
+
451
+ def _valid_trading_day(value: Any) -> bool:
452
+ if not (isinstance(value, str) and len(value) == 8 and value.isascii() and value.isdecimal()):
453
+ return False
454
+ try:
455
+ datetime.strptime(value, "%Y%m%d")
456
+ except ValueError:
457
+ return False
458
+ return True
459
+
460
+
461
+ def _normalize_trusted_now(
462
+ now: Any,
463
+ *,
464
+ policy: CtpCohortPolicy,
465
+ ) -> Tuple[Optional[CtpCohortNow], Optional[str]]:
466
+ """Return trusted caller time evidence without inventing clock facts."""
467
+
468
+ if now is None:
469
+ return None, CtpCohortReason.TRUSTED_NOW_REQUIRED
470
+ if isinstance(now, CtpCohortNow):
471
+ if now.receive_clock_error_ms > policy.max_receive_clock_error_ms:
472
+ return None, CtpCohortReason.RECEIVE_CLOCK_ERROR_INVALID
473
+ return now, None
474
+
475
+ monotonic = _strict_positive_uint64(
476
+ _event_value(now, "now_monotonic_ns", "recv_monotonic_ns", "received_monotonic_ns")
477
+ )
478
+ epoch = _epoch_seconds(
479
+ _event_value(now, "now_epoch", "now_time_utc", "wall_time_utc", "recv_time_utc")
480
+ )
481
+ clock_domain_id = _event_value(now, "clock_domain_id")
482
+ if monotonic is None or epoch is None or not _is_provenance_identity(clock_domain_id):
483
+ return None, CtpCohortReason.TRUSTED_NOW_INVALID
484
+ if _event_value(now, "receive_clock_quality") != "verified":
485
+ return None, CtpCohortReason.RECEIVE_CLOCK_UNVERIFIED
486
+ if _event_value(now, "freshness_verified") is not True:
487
+ return None, CtpCohortReason.FRESHNESS_UNVERIFIED
488
+ receive_clock_error_ms = _strict_quote_number(_event_value(now, "receive_clock_error_ms"))
489
+ if (
490
+ receive_clock_error_ms is None
491
+ or receive_clock_error_ms < 0.0
492
+ or receive_clock_error_ms > policy.max_receive_clock_error_ms
493
+ ):
494
+ return None, CtpCohortReason.RECEIVE_CLOCK_ERROR_INVALID
495
+ return (
496
+ CtpCohortNow(
497
+ now_monotonic_ns=monotonic,
498
+ now_epoch=epoch,
499
+ clock_domain_id=clock_domain_id,
500
+ receive_clock_error_ms=receive_clock_error_ms,
501
+ ),
502
+ None,
503
+ )
504
+
505
+
506
+ def _scope_from_event(event: Any) -> Optional[Tuple[int, int]]:
507
+ """Read a complete raw connection/subscription scope without coercion."""
508
+
509
+ connection_generation = _strict_positive_uint64(_event_value(event, "connection_generation"))
510
+ subscription_epoch = _strict_positive_uint64(_event_value(event, "subscription_epoch"))
511
+ if connection_generation is None or subscription_epoch is None:
512
+ return None
513
+ return connection_generation, subscription_epoch
514
+
515
+
516
+ def validate_ctp_quote(
517
+ event: Any,
518
+ *,
519
+ leg: CtpCohortLeg,
520
+ expected_rules_hash: str,
521
+ policy: CtpCohortPolicy,
522
+ ) -> CtpQuoteValidation:
523
+ """Normalize one ``ctp.quote.v2`` event into immutable evidence.
524
+
525
+ The function does not retain the event and does not use system clocks. In
526
+ particular, a source timestamp is only usable after the producer explicitly
527
+ labels its source clock ``verified``.
528
+ """
529
+
530
+ symbol, symbol_consistent = _consistent_identity_alias(
531
+ event, "symbol", "instrument_id", "InstrumentID"
532
+ )
533
+ if not symbol_consistent:
534
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_IDENTITY_CONFLICT)
535
+ if not isinstance(symbol, str) or not symbol:
536
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_SYMBOL_MISSING)
537
+ if symbol != leg.symbol:
538
+ return CtpQuoteValidation(None, CtpCohortReason.UNEXPECTED_SYMBOL)
539
+
540
+ exchange, exchange_consistent = _consistent_identity_alias(
541
+ event, "exchange", "exchange_id", "ExchangeID"
542
+ )
543
+ if not exchange_consistent:
544
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_IDENTITY_CONFLICT)
545
+ if not isinstance(exchange, str) or exchange != leg.exchange:
546
+ return CtpQuoteValidation(None, CtpCohortReason.EXCHANGE_MISMATCH)
547
+ asset_type, asset_type_consistent = _consistent_identity_alias(
548
+ event, "asset_type", "contract_type"
549
+ )
550
+ if not asset_type_consistent:
551
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_IDENTITY_CONFLICT)
552
+ if leg.asset_type is not None and asset_type != leg.asset_type:
553
+ return CtpQuoteValidation(None, CtpCohortReason.ASSET_TYPE_MISMATCH)
554
+ if _event_value(event, "schema_version") != "ctp.quote.v2":
555
+ return CtpQuoteValidation(None, CtpCohortReason.UNSUPPORTED_QUOTE_SCHEMA)
556
+ if _event_value(event, "volume_semantics") != "delta":
557
+ return CtpQuoteValidation(None, CtpCohortReason.VOLUME_SEMANTICS_NOT_DELTA)
558
+ if _event_value(event, "source_clock_quality") != "verified":
559
+ return CtpQuoteValidation(None, CtpCohortReason.SOURCE_CLOCK_UNVERIFIED)
560
+ if _event_value(event, "receive_clock_quality") != "verified":
561
+ return CtpQuoteValidation(None, CtpCohortReason.RECEIVE_CLOCK_UNVERIFIED)
562
+ if _event_value(event, "freshness_verified") is not True:
563
+ return CtpQuoteValidation(None, CtpCohortReason.FRESHNESS_UNVERIFIED)
564
+ event_time_source = _event_value(event, "event_time_source")
565
+ if not _is_provenance_identity(event_time_source):
566
+ return CtpQuoteValidation(None, CtpCohortReason.EVENT_TIME_SOURCE_MISSING)
567
+ rules_hash = _event_value(event, "rules_hash")
568
+ if not _is_provenance_identity(rules_hash) or rules_hash != expected_rules_hash:
569
+ return CtpQuoteValidation(None, CtpCohortReason.RULES_HASH_MISMATCH)
570
+ source = _event_value(event, "source")
571
+ if not _is_provenance_identity(source):
572
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_SOURCE_MISSING)
573
+ if _event_value(event, "stale") is not False:
574
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_STREAM_UNREADY)
575
+ if _event_value(event, "stale_reason") != "":
576
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_STREAM_UNREADY)
577
+ if _event_value(event, "continuity_status") != "continuous":
578
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_CONTINUITY_NOT_CONTINUOUS)
579
+ quality_flags = _event_value(event, "quality_flags")
580
+ if not isinstance(quality_flags, (list, tuple, set, frozenset)):
581
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_QUALITY_FLAGS_INVALID)
582
+ if quality_flags:
583
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_QUALITY_FLAGS_PRESENT)
584
+ if _event_value(event, "execution_eligible") is not True:
585
+ return CtpQuoteValidation(None, CtpCohortReason.EXECUTION_INELIGIBLE_QUOTE)
586
+ if _event_value(event, "volume_complete") is not True:
587
+ return CtpQuoteValidation(None, CtpCohortReason.VOLUME_INCOMPLETE)
588
+ if _event_value(event, "volume_quality") != "CONTINUOUS":
589
+ return CtpQuoteValidation(None, CtpCohortReason.VOLUME_QUALITY_NOT_CONTINUOUS)
590
+
591
+ numeric_fields = {
592
+ "bid": _event_value(event, "bid_price", "bid", "BidPrice1"),
593
+ "ask": _event_value(event, "ask_price", "ask", "AskPrice1"),
594
+ "bid_size": _event_value(event, "bid_volume", "bid_size", "BidVolume1"),
595
+ "ask_size": _event_value(event, "ask_volume", "ask_size", "AskVolume1"),
596
+ "last": _event_value(event, "price", "last_price", "last", "LastPrice"),
597
+ "lower_limit": _event_value(
598
+ event,
599
+ "lower_limit_price",
600
+ "lower_limit",
601
+ "LowerLimitPrice",
602
+ ),
603
+ "upper_limit": _event_value(
604
+ event,
605
+ "upper_limit_price",
606
+ "upper_limit",
607
+ "UpperLimitPrice",
608
+ ),
609
+ "source_clock_error_ms": _event_value(event, "source_clock_error_ms"),
610
+ "receive_clock_error_ms": _event_value(event, "receive_clock_error_ms"),
611
+ }
612
+ parsed: dict[str, float] = {}
613
+ for name, value in numeric_fields.items():
614
+ number = _strict_quote_number(value)
615
+ if number is None:
616
+ if name in {"source_clock_error_ms", "receive_clock_error_ms"}:
617
+ reason = (
618
+ CtpCohortReason.SOURCE_CLOCK_ERROR_INVALID
619
+ if name == "source_clock_error_ms"
620
+ else CtpCohortReason.RECEIVE_CLOCK_ERROR_INVALID
621
+ )
622
+ return CtpQuoteValidation(None, reason)
623
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_NUMERIC_TYPE_INVALID)
624
+ parsed[name] = number
625
+
626
+ bid, ask, bid_size, ask_size, last = (
627
+ parsed["bid"],
628
+ parsed["ask"],
629
+ parsed["bid_size"],
630
+ parsed["ask_size"],
631
+ parsed["last"],
632
+ )
633
+ lower_limit, upper_limit = parsed["lower_limit"], parsed["upper_limit"]
634
+ source_clock_error_ms = parsed["source_clock_error_ms"]
635
+ receive_clock_error_ms = parsed["receive_clock_error_ms"]
636
+ if min(bid, ask, bid_size, ask_size, last) <= 0.0:
637
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_NONPOSITIVE)
638
+ if ask < bid:
639
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_CROSSED)
640
+ if lower_limit <= 0.0 or upper_limit <= lower_limit:
641
+ return CtpQuoteValidation(None, CtpCohortReason.DAILY_PRICE_LIMIT_INVALID)
642
+ if any(price < lower_limit or price > upper_limit for price in (bid, ask, last)):
643
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_OUTSIDE_DAILY_LIMIT)
644
+ if any(
645
+ not _on_tick_grid(price, leg.price_tick)
646
+ for price in (bid, ask, last, lower_limit, upper_limit)
647
+ ):
648
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_OFF_TICK_GRID)
649
+ if source_clock_error_ms < 0.0 or source_clock_error_ms > policy.max_source_clock_error_ms:
650
+ return CtpQuoteValidation(None, CtpCohortReason.SOURCE_CLOCK_ERROR_INVALID)
651
+ if receive_clock_error_ms < 0.0 or receive_clock_error_ms > policy.max_receive_clock_error_ms:
652
+ return CtpQuoteValidation(None, CtpCohortReason.RECEIVE_CLOCK_ERROR_INVALID)
653
+
654
+ source_epoch = _epoch_seconds(_event_value(event, "event_time_utc", "timestamp"))
655
+ if source_epoch is None:
656
+ return CtpQuoteValidation(None, CtpCohortReason.SOURCE_TIME_INVALID)
657
+ receive_epoch = _epoch_seconds(
658
+ _event_value(event, "recv_time_utc", "received_wall_time", "local_time")
659
+ )
660
+ if receive_epoch is None:
661
+ return CtpQuoteValidation(None, CtpCohortReason.RECEIVE_TIME_INVALID)
662
+ receive_monotonic_ns = _strict_positive_uint64(
663
+ _event_value(event, "recv_monotonic_ns", "received_monotonic_ns")
664
+ )
665
+ ingest_seq = _strict_positive_uint64(_event_value(event, "ingest_seq", "sequence"))
666
+ connection_generation = _strict_positive_uint64(_event_value(event, "connection_generation"))
667
+ subscription_epoch = _strict_positive_uint64(_event_value(event, "subscription_epoch"))
668
+ if None in (receive_monotonic_ns, ingest_seq, connection_generation, subscription_epoch):
669
+ return CtpQuoteValidation(None, CtpCohortReason.QUOTE_IDENTITY_TYPE_INVALID)
670
+ trading_day = _event_value(event, "trading_day", "TradingDay")
671
+ if not _valid_trading_day(trading_day):
672
+ return CtpQuoteValidation(None, CtpCohortReason.TRADING_DAY_INVALID)
673
+ action_day = _event_value(event, "action_day", "ActionDay")
674
+ if not _valid_trading_day(action_day):
675
+ return CtpQuoteValidation(None, CtpCohortReason.ACTION_DAY_INVALID)
676
+ clock_domain_id = _event_value(event, "clock_domain_id")
677
+ if not _is_provenance_identity(clock_domain_id):
678
+ return CtpQuoteValidation(None, CtpCohortReason.CLOCK_DOMAIN_UNKNOWN)
679
+ if source_epoch > receive_epoch:
680
+ return CtpQuoteValidation(None, CtpCohortReason.SOURCE_TIME_AFTER_RECEIVE)
681
+
682
+ return CtpQuoteValidation(
683
+ CtpQuoteEvidence(
684
+ symbol=symbol,
685
+ exchange=exchange,
686
+ asset_type=asset_type if isinstance(asset_type, str) else None,
687
+ bid=bid,
688
+ ask=ask,
689
+ bid_size=bid_size,
690
+ ask_size=ask_size,
691
+ last=last,
692
+ lower_limit=lower_limit,
693
+ upper_limit=upper_limit,
694
+ source_epoch=source_epoch,
695
+ receive_epoch=receive_epoch,
696
+ receive_monotonic_ns=receive_monotonic_ns,
697
+ ingest_seq=ingest_seq,
698
+ connection_generation=connection_generation,
699
+ subscription_epoch=subscription_epoch,
700
+ trading_day=trading_day,
701
+ action_day=action_day,
702
+ clock_domain_id=clock_domain_id,
703
+ rules_hash=rules_hash,
704
+ source=source,
705
+ event_time_source=event_time_source,
706
+ source_clock_error_ms=source_clock_error_ms,
707
+ receive_clock_error_ms=receive_clock_error_ms,
708
+ ),
709
+ None,
710
+ )
711
+
712
+
713
+ class CtpQuoteCohortValidator:
714
+ """Statefully admit only fresh, synchronized CTP quote cohorts.
715
+
716
+ ``expected_legs`` is copied to an immutable tuple at construction. Each
717
+ call to :meth:`ingest` either returns a reason or one immutable cohort.
718
+ The caller supplies :class:`CtpCohortNow` evidence on every call; this
719
+ avoids treating an arrival as the current time and makes queue delays
720
+ fail closed. Sequence and admission watermarks are partitioned by
721
+ ``(connection_generation, subscription_epoch)`` so a verified reconnect
722
+ can restart its ingest sequence at one without mixing generations.
723
+ """
724
+
725
+ def __init__(
726
+ self,
727
+ *,
728
+ expected_legs: Iterable[CtpCohortLeg],
729
+ expected_rules_hash: str,
730
+ policy: CtpCohortPolicy,
731
+ ) -> None:
732
+ legs = tuple(expected_legs)
733
+ if len(legs) not in (2, 3):
734
+ raise ValueError("expected_legs must contain exactly two or three CtpCohortLeg values")
735
+ if not all(isinstance(leg, CtpCohortLeg) for leg in legs):
736
+ raise TypeError("expected_legs must contain only CtpCohortLeg values")
737
+ symbols = tuple(leg.symbol for leg in legs)
738
+ if len(set(symbols)) != len(symbols):
739
+ raise ValueError("expected_legs must have unique symbols")
740
+ exchanges = {leg.exchange for leg in legs}
741
+ if len(exchanges) != 1:
742
+ raise ValueError("expected_legs must use one exchange")
743
+ if not isinstance(policy, CtpCohortPolicy):
744
+ raise TypeError("policy must be a CtpCohortPolicy")
745
+
746
+ self.expected_legs = legs
747
+ self.expected_rules_hash = _strict_provenance_identity(
748
+ expected_rules_hash,
749
+ field="expected_rules_hash",
750
+ )
751
+ self.policy = policy
752
+ self._legs_by_symbol = MappingProxyType({leg.symbol: leg for leg in legs})
753
+ self._latest: dict[str, CtpQuoteEvidence] = {}
754
+ self._active_scope: Optional[Tuple[int, int]] = None
755
+ # Both values are producer-owned unsigned incarnations. Lexicographic
756
+ # order permits a new connection to restart its subscription epoch,
757
+ # while a delayed packet from any previously observed incarnation can
758
+ # never make the validator move backwards.
759
+ self._highest_scope: Optional[Tuple[int, int]] = None
760
+ self._retired_scopes: set[Tuple[int, int]] = set()
761
+ self._last_seen_by_scope: dict[Tuple[int, int], dict[str, CtpQuoteEvidence]] = {}
762
+ self._last_admitted_sequences: dict[Tuple[int, int], dict[str, int]] = {}
763
+ self._confirmed_cohort: Optional[CtpQuoteCohort] = None
764
+
765
+ @property
766
+ def expected_symbols(self) -> Tuple[str, ...]:
767
+ """Configured symbols in their caller-supplied, frozen order."""
768
+
769
+ return tuple(leg.symbol for leg in self.expected_legs)
770
+
771
+ def reset(self) -> None:
772
+ """Discard retained evidence, for example after an explicit session reset."""
773
+
774
+ self._latest.clear()
775
+ self._active_scope = None
776
+ self._highest_scope = None
777
+ self._retired_scopes.clear()
778
+ self._last_seen_by_scope.clear()
779
+ self._last_admitted_sequences.clear()
780
+ self._confirmed_cohort = None
781
+
782
+ def ingest(self, event: Any, *, now: Any = None) -> CtpCohortResult:
783
+ """Validate one quote and return a cohort only when all legs are fresh."""
784
+
785
+ symbol = _event_value(event, "symbol", "instrument_id", "InstrumentID")
786
+ if not isinstance(symbol, str) or not symbol:
787
+ return CtpCohortResult(None, CtpCohortReason.QUOTE_SYMBOL_MISSING)
788
+ leg = self._legs_by_symbol.get(symbol)
789
+ if leg is None:
790
+ return CtpCohortResult(None, CtpCohortReason.UNEXPECTED_SYMBOL)
791
+ raw_scope = _scope_from_event(event)
792
+ if self._is_scope_rollback(raw_scope):
793
+ # A delayed prior connection/subscription packet is neither a
794
+ # signal nor a reason to invalidate the current newer round.
795
+ return CtpCohortResult(None, CtpCohortReason.RETIRED_CONNECTION_SCOPE)
796
+ validation = validate_ctp_quote(
797
+ event,
798
+ leg=leg,
799
+ expected_rules_hash=self.expected_rules_hash,
800
+ policy=self.policy,
801
+ )
802
+ if validation.quote is None:
803
+ self._invalidate_after_expected_failure(_scope_from_event(event))
804
+ return CtpCohortResult(None, validation.reason)
805
+ quote = validation.quote
806
+ scope = (quote.connection_generation, quote.subscription_epoch)
807
+ if self._is_scope_rollback(scope) or scope in self._retired_scopes:
808
+ return CtpCohortResult(None, CtpCohortReason.RETIRED_CONNECTION_SCOPE)
809
+ if self._highest_scope is None or scope > self._highest_scope:
810
+ self._highest_scope = scope
811
+ if self._active_scope != scope:
812
+ self._activate_scope(scope)
813
+ trusted_now, now_reason = _normalize_trusted_now(now, policy=self.policy)
814
+ if trusted_now is None:
815
+ self._invalidate_current_round()
816
+ return CtpCohortResult(None, now_reason)
817
+ quote_time_reason = self._validate_quote_at(quote, now=trusted_now)
818
+ if quote_time_reason is not None:
819
+ self._invalidate_current_round()
820
+ return CtpCohortResult(None, quote_time_reason)
821
+
822
+ prior = self._last_seen_by_scope.get(scope, {}).get(quote.symbol)
823
+ if prior is not None:
824
+ if quote.ingest_seq <= prior.ingest_seq:
825
+ self._invalidate_current_round()
826
+ return CtpCohortResult(None, CtpCohortReason.DUPLICATE_OR_OUT_OF_ORDER)
827
+ if quote.receive_monotonic_ns < prior.receive_monotonic_ns:
828
+ self._invalidate_current_round()
829
+ return CtpCohortResult(None, CtpCohortReason.OUT_OF_ORDER_RECEIVE_TIME)
830
+ if quote.source_epoch < prior.source_epoch:
831
+ self._invalidate_current_round()
832
+ return CtpCohortResult(None, CtpCohortReason.OUT_OF_ORDER_SOURCE_TIME)
833
+
834
+ if self._confirmed_cohort is not None:
835
+ confirmed_quote = self._confirmed_cohort.quote_for(quote.symbol)
836
+ if quote.update_identity != confirmed_quote.update_identity:
837
+ # A newer valid update makes the prior all-leg decision stale
838
+ # even before the remaining legs complete their next round.
839
+ self._confirmed_cohort = None
840
+ self._last_seen_by_scope.setdefault(scope, {})[quote.symbol] = quote
841
+ self._latest[quote.symbol] = quote
842
+ if len(self._latest) != len(self.expected_legs):
843
+ return CtpCohortResult(None, CtpCohortReason.WAITING_FOR_LEGS)
844
+
845
+ quotes = {symbol: self._latest[symbol] for symbol in self.expected_symbols}
846
+ cohort_reason = self._validate_cohort(quotes, now=trusted_now)
847
+ if cohort_reason is not None:
848
+ self._invalidate_current_round()
849
+ return CtpCohortResult(None, cohort_reason)
850
+ admission_watermark = self._last_admitted_sequences.setdefault(
851
+ scope,
852
+ dict.fromkeys(self.expected_symbols, 0),
853
+ )
854
+ if any(
855
+ quotes[symbol].ingest_seq <= admission_watermark[symbol]
856
+ for symbol in self.expected_symbols
857
+ ):
858
+ return CtpCohortResult(None, CtpCohortReason.WAITING_FOR_ALL_LEGS_NEW)
859
+
860
+ self._last_admitted_sequences[scope] = {
861
+ symbol: quotes[symbol].ingest_seq for symbol in self.expected_symbols
862
+ }
863
+ first = quotes[self.expected_symbols[0]]
864
+ cohort = self._make_cohort(quotes, first=first)
865
+ self._confirmed_cohort = cohort
866
+ return CtpCohortResult(cohort, None)
867
+
868
+ def validate_at(self, *, now: Any = None) -> CtpCohortResult:
869
+ """Recheck the currently confirmed cohort immediately before use.
870
+
871
+ A caller should invoke this at the final execution boundary. The
872
+ method does not create an order; it only proves that the previously
873
+ admitted immutable evidence is still fresh against caller-supplied,
874
+ trusted time evidence.
875
+ """
876
+
877
+ cohort = self._confirmed_cohort
878
+ if cohort is None:
879
+ return CtpCohortResult(None, CtpCohortReason.NO_CONFIRMED_COHORT)
880
+ trusted_now, now_reason = _normalize_trusted_now(now, policy=self.policy)
881
+ if trusted_now is None:
882
+ self._invalidate_current_round()
883
+ return CtpCohortResult(None, now_reason)
884
+ scope = (cohort.connection_generation, cohort.subscription_epoch)
885
+ if self._active_scope != scope or scope in self._retired_scopes:
886
+ self._invalidate_current_round()
887
+ return CtpCohortResult(None, CtpCohortReason.RETIRED_CONNECTION_SCOPE)
888
+ cohort_reason = self._validate_cohort(cohort.quotes, now=trusted_now)
889
+ if cohort_reason is not None:
890
+ self._invalidate_current_round()
891
+ return CtpCohortResult(None, cohort_reason)
892
+ return CtpCohortResult(cohort, None)
893
+
894
+ def recheck(self, *, now: Any = None) -> CtpCohortResult:
895
+ """Alias for :meth:`validate_at` at an execution submission boundary."""
896
+
897
+ return self.validate_at(now=now)
898
+
899
+ def _invalidate_current_round(self) -> None:
900
+ """Forget retained quote and confirmation evidence after a failed gate.
901
+
902
+ Sequence watermarks remain scoped and retained. Therefore recovery
903
+ requires a fresh valid quote from every leg and cannot reuse a prior
904
+ admitted update identity.
905
+ """
906
+
907
+ self._latest.clear()
908
+ self._confirmed_cohort = None
909
+
910
+ def _invalidate_after_expected_failure(self, failed_scope: Optional[Tuple[int, int]]) -> None:
911
+ """Invalidate evidence and retire an older scope when raw identity proves a switch."""
912
+
913
+ if self._is_scope_rollback(failed_scope):
914
+ return
915
+ if (
916
+ failed_scope is not None
917
+ and failed_scope not in self._retired_scopes
918
+ and self._active_scope != failed_scope
919
+ ):
920
+ if self._highest_scope is None or failed_scope > self._highest_scope:
921
+ self._highest_scope = failed_scope
922
+ self._activate_scope(failed_scope)
923
+ return
924
+ self._invalidate_current_round()
925
+
926
+ def _is_scope_rollback(self, scope: Optional[Tuple[int, int]]) -> bool:
927
+ """Return true when a raw quote is from an older producer incarnation."""
928
+
929
+ return scope is not None and self._highest_scope is not None and scope < self._highest_scope
930
+
931
+ def _activate_scope(self, scope: Tuple[int, int]) -> None:
932
+ """Start a new connection/subscription scope without mixing evidence."""
933
+
934
+ if self._active_scope == scope:
935
+ return
936
+ if self._active_scope is not None:
937
+ self._retired_scopes.add(self._active_scope)
938
+ self._invalidate_current_round()
939
+ self._active_scope = scope
940
+
941
+ def _validate_quote_at(
942
+ self,
943
+ quote: CtpQuoteEvidence,
944
+ *,
945
+ now: CtpCohortNow,
946
+ ) -> Optional[str]:
947
+ """Validate absolute freshness against trusted same-domain current time."""
948
+
949
+ if quote.clock_domain_id != now.clock_domain_id:
950
+ return CtpCohortReason.NOW_CLOCK_DOMAIN_MISMATCH
951
+ if now.now_monotonic_ns < quote.receive_monotonic_ns:
952
+ return CtpCohortReason.OUT_OF_ORDER_RECEIVE_TIME
953
+ monotonic_age_ms = (now.now_monotonic_ns - quote.receive_monotonic_ns) / 1_000_000.0
954
+ if monotonic_age_ms > self.policy.max_receive_age_ms:
955
+ return CtpCohortReason.STALE_COHORT_RECEIVE_TIME
956
+
957
+ now_wall_high = now.now_epoch + now.receive_clock_error_ms / 1_000.0
958
+ quote_receive_low = quote.receive_epoch - quote.receive_clock_error_ms / 1_000.0
959
+ quote_source_low = quote.source_epoch - quote.source_clock_error_ms / 1_000.0
960
+ if now_wall_high < quote_receive_low or now_wall_high < quote_source_low:
961
+ return CtpCohortReason.NOW_WALL_TIME_BEFORE_QUOTE
962
+ receive_age_ms = (now_wall_high - quote_receive_low) * 1_000.0
963
+ if receive_age_ms > self.policy.max_receive_age_ms:
964
+ return CtpCohortReason.STALE_COHORT_RECEIVE_TIME
965
+ source_age_ms = (now_wall_high - quote_source_low) * 1_000.0
966
+ if source_age_ms > self.policy.max_source_age_ms:
967
+ return CtpCohortReason.STALE_COHORT_SOURCE_TIME
968
+ return None
969
+
970
+ def _make_cohort(
971
+ self,
972
+ quotes: Mapping[str, CtpQuoteEvidence],
973
+ *,
974
+ first: CtpQuoteEvidence,
975
+ ) -> CtpQuoteCohort:
976
+ cohort_id = "|".join(
977
+ f"{symbol}:{quotes[symbol].connection_generation}:{quotes[symbol].subscription_epoch}:"
978
+ f"{quotes[symbol].ingest_seq}"
979
+ for symbol in sorted(quotes)
980
+ )
981
+ return CtpQuoteCohort(
982
+ quotes=MappingProxyType(dict(quotes)),
983
+ exchange=first.exchange,
984
+ trading_day=first.trading_day,
985
+ action_day=first.action_day,
986
+ connection_generation=first.connection_generation,
987
+ subscription_epoch=first.subscription_epoch,
988
+ clock_domain_id=first.clock_domain_id,
989
+ rules_hash=first.rules_hash,
990
+ cohort_id=cohort_id,
991
+ )
992
+
993
+ def _validate_cohort(
994
+ self,
995
+ quotes: Mapping[str, CtpQuoteEvidence],
996
+ *,
997
+ now: CtpCohortNow,
998
+ ) -> Optional[str]:
999
+ if tuple(quotes) != self.expected_symbols:
1000
+ return CtpCohortReason.WAITING_FOR_LEGS
1001
+ if any(quotes[symbol].symbol != symbol for symbol in self.expected_symbols):
1002
+ return CtpCohortReason.WAITING_FOR_LEGS
1003
+ if len({quote.exchange for quote in quotes.values()}) != 1:
1004
+ return CtpCohortReason.COHORT_EXCHANGE_MISMATCH
1005
+ if len({quote.trading_day for quote in quotes.values()}) != 1:
1006
+ return CtpCohortReason.COHORT_TRADING_DAY_MISMATCH
1007
+ if len({quote.action_day for quote in quotes.values()}) != 1:
1008
+ return CtpCohortReason.COHORT_ACTION_DAY_MISMATCH
1009
+ if len({quote.connection_generation for quote in quotes.values()}) != 1:
1010
+ return CtpCohortReason.COHORT_CONNECTION_GENERATION_MISMATCH
1011
+ if len({quote.subscription_epoch for quote in quotes.values()}) != 1:
1012
+ return CtpCohortReason.COHORT_SUBSCRIPTION_EPOCH_MISMATCH
1013
+ if len({quote.rules_hash for quote in quotes.values()}) != 1:
1014
+ return CtpCohortReason.COHORT_RULES_HASH_MISMATCH
1015
+ if len({quote.clock_domain_id for quote in quotes.values()}) != 1:
1016
+ return CtpCohortReason.COHORT_CLOCK_DOMAIN_MISMATCH
1017
+
1018
+ for quote in quotes.values():
1019
+ quote_time_reason = self._validate_quote_at(quote, now=now)
1020
+ if quote_time_reason is not None:
1021
+ return quote_time_reason
1022
+
1023
+ receive_values = [quote.receive_monotonic_ns for quote in quotes.values()]
1024
+ receive_skew_ms = (max(receive_values) - min(receive_values)) / 1_000_000.0
1025
+ if receive_skew_ms > self.policy.max_receive_skew_ms:
1026
+ return CtpCohortReason.BLOCKED_CROSS_LEG_SKEW
1027
+
1028
+ source_lows = [
1029
+ quote.source_epoch - quote.source_clock_error_ms / 1_000.0 for quote in quotes.values()
1030
+ ]
1031
+ source_highs = [
1032
+ quote.source_epoch + quote.source_clock_error_ms / 1_000.0 for quote in quotes.values()
1033
+ ]
1034
+ source_skew_ms = (max(source_highs) - min(source_lows)) * 1_000.0
1035
+ if source_skew_ms > self.policy.max_source_skew_ms:
1036
+ return CtpCohortReason.BLOCKED_SOURCE_SKEW
1037
+ return None
1038
+
1039
+
1040
+ __all__ = [
1041
+ "CtpCohortPolicy",
1042
+ "CtpCohortNow",
1043
+ "CtpCohortReason",
1044
+ "CtpCohortLeg",
1045
+ "CtpQuoteEvidence",
1046
+ "CtpQuoteValidation",
1047
+ "CtpQuoteCohort",
1048
+ "CtpCohortResult",
1049
+ "CtpQuoteCohortValidator",
1050
+ "validate_ctp_quote",
1051
+ ]