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,2006 @@
1
+ """Immutable closed-bar evidence and a small multi-leg causal barrier.
2
+
3
+ The feed owns construction of a bar. This module owns the point at which a
4
+ consumer may use several already-closed bars together. It intentionally does
5
+ not aggregate ticks, query a store, create orders, or consult a process clock.
6
+ Callers provide the recorded receive/seal times, including for replay. That
7
+ keeps a fast replay from accidentally becoming evidence that a live barrier
8
+ was met.
9
+
10
+ ``BarEvidence`` is the public hand-off from a feed to a strategy. A
11
+ ``MultiLegBarBarrier`` accepts exactly two or three such objects and emits one
12
+ immutable ``MinuteDecisionInput`` per complete, same-scope bucket. Once a
13
+ bucket is emitted or skipped, a later bar cannot revise or back-fill it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from collections import OrderedDict, deque
20
+ from collections.abc import Mapping
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timedelta, timezone
23
+ from types import MappingProxyType
24
+ from typing import Any, Deque, Dict, Iterable, List, Optional, Tuple
25
+
26
+ from ..utils.log_message import get_logger
27
+ from .ctpcohort import CtpQuoteEvidence
28
+
29
+ logger = get_logger(__name__)
30
+
31
+ UTC = timezone.utc
32
+ _GOOD_QUALITY = frozenset({"GOOD", "OK", "COMPLETE", "VALID"})
33
+ _MAX_ABS_NUMBER = 1.0e30
34
+ _MISSING = object()
35
+ _PROVENANCE_PLACEHOLDERS = frozenset(
36
+ {"unknown", "unverified", "n/a", "na", "none", "null", "unset", "placeholder"}
37
+ )
38
+
39
+
40
+ def _value(item: Any, *names: str, default: Any = None) -> Any:
41
+ if isinstance(item, Mapping):
42
+ for name in names:
43
+ if name in item:
44
+ return item[name]
45
+ return default
46
+ for name in names:
47
+ if hasattr(item, name):
48
+ result = getattr(item, name)
49
+ if result is not None:
50
+ return result
51
+ return default
52
+
53
+
54
+ def _alias(item: Any, *names: str, default: Any = _MISSING) -> Any:
55
+ """Read aliases only when every supplied spelling carries the same value."""
56
+
57
+ values = []
58
+ if isinstance(item, Mapping):
59
+ values = [(name, item[name]) for name in names if name in item]
60
+ else:
61
+ values = [(name, getattr(item, name)) for name in names if hasattr(item, name)]
62
+ if not values:
63
+ return default
64
+ first = values[0][1]
65
+ if any(value != first for _, value in values[1:]):
66
+ fields = ", ".join(name for name, _ in values)
67
+ raise ValueError(f"conflicting aliases: {fields}")
68
+ return first
69
+
70
+
71
+ def _text(value: Any, field: str, *, allow_empty: bool = False) -> str:
72
+ if not isinstance(value, str) or (not allow_empty and not value) or value.strip() != value:
73
+ raise ValueError(f"{field} must be an exact non-empty string")
74
+ return value
75
+
76
+
77
+ def _provenance_text(value: Any, field: str) -> str:
78
+ value = _text(value, field)
79
+ if value.casefold() in _PROVENANCE_PLACEHOLDERS:
80
+ raise ValueError(f"{field} must identify a verified provenance scope")
81
+ return value
82
+
83
+
84
+ def _number(value: Any, field: str, *, nonnegative: bool = False) -> float:
85
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
86
+ raise ValueError(f"{field} must be a finite number")
87
+ result = float(value)
88
+ if not math.isfinite(result) or abs(result) >= _MAX_ABS_NUMBER:
89
+ raise ValueError(f"{field} must be a finite number")
90
+ if not nonnegative and result <= 0:
91
+ raise ValueError(f"{field} must be positive")
92
+ if nonnegative and result < 0:
93
+ raise ValueError(f"{field} must be non-negative")
94
+ return result
95
+
96
+
97
+ def _datetime(value: Any, field: str) -> datetime:
98
+ """Return an aware UTC time.
99
+
100
+ Naive datetimes are interpreted as UTC only for deterministic local
101
+ replay fixtures. Live producers should provide an aware UTC value.
102
+ """
103
+
104
+ if isinstance(value, datetime):
105
+ parsed = value
106
+ elif isinstance(value, (int, float)) and not isinstance(value, bool):
107
+ try:
108
+ parsed = datetime.fromtimestamp(float(value), UTC)
109
+ except (OverflowError, OSError, ValueError) as error:
110
+ logger.error("barrier:109 re-raising OverflowError,OSError,ValueError", exc_info=True)
111
+ raise ValueError(f"{field} must be a valid UTC time") from error
112
+ elif isinstance(value, str):
113
+ try:
114
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
115
+ except ValueError as error:
116
+ logger.error("barrier:114 re-raising ValueError", exc_info=True)
117
+ raise ValueError(f"{field} must be a valid UTC time") from error
118
+ else:
119
+ raise ValueError(f"{field} must be a valid UTC time")
120
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
121
+ parsed = parsed.replace(tzinfo=UTC)
122
+ else:
123
+ parsed = parsed.astimezone(UTC)
124
+ return parsed
125
+
126
+
127
+ def _optional_datetime(value: Any, field: str) -> Optional[datetime]:
128
+ return None if value is None else _datetime(value, field)
129
+
130
+
131
+ def _valid_trading_day(value: str) -> bool:
132
+ if len(value) != 8 or not value.isascii() or not value.isdecimal():
133
+ return False
134
+ try:
135
+ datetime.strptime(value, "%Y%m%d")
136
+ except ValueError:
137
+ return False
138
+ return True
139
+
140
+
141
+ def _mono(value: Any, field: str) -> float:
142
+ """Normalize a caller-supplied monotonic reading to seconds.
143
+
144
+ ``seal_received_mono`` is intentionally in seconds because the public
145
+ barrier deadlines are seconds. A nanosecond alias is accepted and
146
+ converted exactly once for integration with CTP event metadata.
147
+ """
148
+
149
+ return _number(value, field, nonnegative=True)
150
+
151
+
152
+ def _bar_seal_monotonic(item: Any) -> Any:
153
+ """Adapt explicit seconds/ns feed aliases without inferring units."""
154
+
155
+ seconds = _alias(item, "seal_received_mono", "received_monotonic", default=_MISSING)
156
+ nanoseconds = _alias(
157
+ item,
158
+ "seal_received_monotonic_ns",
159
+ "received_monotonic_ns",
160
+ "recv_monotonic_ns",
161
+ default=_MISSING,
162
+ )
163
+ if seconds is _MISSING and nanoseconds is _MISSING:
164
+ return _MISSING
165
+ converted = None
166
+ if nanoseconds is not _MISSING:
167
+ if type(nanoseconds) is not int or nanoseconds <= 0:
168
+ raise ValueError("seal monotonic nanosecond fields must be positive integers")
169
+ converted = nanoseconds / 1_000_000_000.0
170
+ if seconds is not _MISSING:
171
+ parsed = _mono(seconds, "seal_received_mono")
172
+ if converted is not None and not math.isclose(
173
+ parsed, converted, rel_tol=0.0, abs_tol=1.0e-12
174
+ ):
175
+ raise ValueError("conflicting aliases: seal monotonic units")
176
+ return parsed
177
+ return converted
178
+
179
+
180
+ def _json_safe(value: Any) -> Any:
181
+ if isinstance(value, ClockMapping):
182
+ return value.to_dict()
183
+ if isinstance(value, datetime):
184
+ return value.isoformat()
185
+ if isinstance(value, Mapping):
186
+ return {str(key): _json_safe(item) for key, item in value.items()}
187
+ if isinstance(value, (tuple, list, set, frozenset)):
188
+ return [_json_safe(item) for item in value]
189
+ return value
190
+
191
+
192
+ def _freeze(value: Any) -> Any:
193
+ """Recursively detach mutable mappings and sequences in evidence."""
194
+
195
+ if isinstance(value, Mapping):
196
+ return MappingProxyType({_freeze(key): _freeze(item) for key, item in value.items()})
197
+ if isinstance(value, list):
198
+ return tuple(_freeze(item) for item in value)
199
+ if isinstance(value, tuple):
200
+ return tuple(_freeze(item) for item in value)
201
+ if isinstance(value, set):
202
+ return frozenset(_freeze(item) for item in value)
203
+ if isinstance(value, frozenset):
204
+ return frozenset(_freeze(item) for item in value)
205
+ if hasattr(value, "__dict__"):
206
+ return MappingProxyType({_freeze(key): _freeze(item) for key, item in vars(value).items()})
207
+ return value
208
+
209
+
210
+ def _time_is_explicitly_aware(value: Any) -> bool:
211
+ if isinstance(value, datetime):
212
+ return value.tzinfo is not None and value.utcoffset() is not None
213
+ if isinstance(value, str):
214
+ try:
215
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
216
+ except ValueError:
217
+ return False
218
+ return parsed.tzinfo is not None and parsed.utcoffset() is not None
219
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
220
+
221
+
222
+ def _delta_nanoseconds(later: datetime, earlier: datetime) -> int:
223
+ """Return an exact integral nanosecond delta for two UTC datetimes."""
224
+
225
+ delta = later - earlier
226
+ return (
227
+ (delta.days * 24 * 60 * 60) + delta.seconds
228
+ ) * 1_000_000_000 + delta.microseconds * 1_000
229
+
230
+
231
+ @dataclass(frozen=True)
232
+ class ClockMapping:
233
+ """A caller-provided wall/monotonic mapping used for barrier deadlines.
234
+
235
+ The mapping is evidence, rather than a convenience conversion. It must
236
+ carry the sampled anchor, its clock domain and generation, a named source,
237
+ a finite error bound, an expiry, and the rules identity. Replay fixtures
238
+ set ``synthetic=True`` explicitly; live evidence cannot use a synthetic
239
+ mapping. No process clock is read here.
240
+ """
241
+
242
+ mapping_id: str
243
+ wall_utc_at_anchor: Any
244
+ mono_ns_at_anchor: int
245
+ clock_domain_id: str
246
+ connection_generation: int
247
+ source: str
248
+ error_bound_ns: int
249
+ valid_until_mono_ns: int
250
+ rules_hash: str
251
+ synthetic: bool = False
252
+
253
+ def __post_init__(self) -> None:
254
+ _provenance_text(self.mapping_id, "mapping_id")
255
+ anchor = self.wall_utc_at_anchor
256
+ if not _time_is_explicitly_aware(anchor):
257
+ raise ValueError("wall_utc_at_anchor must carry an explicit timezone")
258
+ anchor = _datetime(anchor, "wall_utc_at_anchor")
259
+ if type(self.mono_ns_at_anchor) is not int or self.mono_ns_at_anchor < 0:
260
+ raise ValueError("mono_ns_at_anchor must be a non-negative integer")
261
+ _provenance_text(self.clock_domain_id, "clock_domain_id")
262
+ if type(self.connection_generation) is not int or self.connection_generation <= 0:
263
+ raise ValueError("connection_generation must be a positive integer")
264
+ _provenance_text(self.source, "source")
265
+ if type(self.error_bound_ns) is not int or self.error_bound_ns < 0:
266
+ raise ValueError("error_bound_ns must be a non-negative integer")
267
+ if type(self.valid_until_mono_ns) is not int:
268
+ raise ValueError("valid_until_mono_ns must be an integer")
269
+ if self.valid_until_mono_ns <= self.mono_ns_at_anchor:
270
+ raise ValueError("valid_until_mono_ns must be after the anchor")
271
+ _provenance_text(self.rules_hash, "rules_hash")
272
+ if not isinstance(self.synthetic, bool):
273
+ raise ValueError("synthetic must be a bool")
274
+ object.__setattr__(self, "wall_utc_at_anchor", anchor)
275
+
276
+ def map_wall_to_mono_ns(self, wall_time: Any) -> int:
277
+ """Map a wall time while refusing values outside this mapping's scope."""
278
+
279
+ wall = _datetime(wall_time, "mapped_wall_time")
280
+ mapped = self.mono_ns_at_anchor + _delta_nanoseconds(wall, self.wall_utc_at_anchor)
281
+ if mapped < 0:
282
+ raise ValueError("mapped monotonic time must be non-negative")
283
+ return mapped
284
+
285
+ def validate_pair(self, wall_time: Any, mono_seconds: Any) -> None:
286
+ """Check one observed wall/monotonic pair against the error interval."""
287
+
288
+ mono = _number(mono_seconds, "mapped_monotonic", nonnegative=True)
289
+ actual_ns = int(round(mono * 1_000_000_000.0))
290
+ mapped_ns = self.map_wall_to_mono_ns(wall_time)
291
+ if abs(actual_ns - mapped_ns) > self.error_bound_ns:
292
+ raise ValueError("wall/monotonic pair exceeds mapping error bound")
293
+ if mapped_ns + self.error_bound_ns > self.valid_until_mono_ns:
294
+ raise ValueError("clock mapping is expired")
295
+ if actual_ns > self.valid_until_mono_ns:
296
+ raise ValueError("clock mapping is expired")
297
+
298
+ def conservative_deadline_seconds(self, wall_deadline: Any) -> float:
299
+ """Return the earliest monotonic deadline allowed by the error bound."""
300
+
301
+ mapped_ns = self.map_wall_to_mono_ns(wall_deadline)
302
+ if mapped_ns + self.error_bound_ns > self.valid_until_mono_ns:
303
+ raise ValueError("clock mapping expires before the deadline")
304
+ conservative_ns = max(0, mapped_ns - self.error_bound_ns)
305
+ return conservative_ns / 1_000_000_000.0
306
+
307
+ def to_dict(self) -> Dict[str, Any]:
308
+ return {
309
+ "mapping_id": self.mapping_id,
310
+ "wall_utc_at_anchor": self.wall_utc_at_anchor.isoformat(),
311
+ "mono_ns_at_anchor": self.mono_ns_at_anchor,
312
+ "clock_domain_id": self.clock_domain_id,
313
+ "connection_generation": self.connection_generation,
314
+ "source": self.source,
315
+ "error_bound_ns": self.error_bound_ns,
316
+ "valid_until_mono_ns": self.valid_until_mono_ns,
317
+ "rules_hash": self.rules_hash,
318
+ "synthetic": self.synthetic,
319
+ }
320
+
321
+
322
+ @dataclass(frozen=True)
323
+ class BarLeg:
324
+ """One exact expected instrument identity for a barrier."""
325
+
326
+ symbol: str
327
+ exchange: str
328
+
329
+ def __post_init__(self) -> None:
330
+ _text(self.symbol, "symbol")
331
+ _text(self.exchange, "exchange")
332
+
333
+
334
+ @dataclass(frozen=True)
335
+ class BarBarrierPolicy:
336
+ """Time and quality policy shared by 23 and 24 consumers."""
337
+
338
+ timeframe_seconds: float = 60.0
339
+ timeout_seconds: float = 2.0
340
+ max_quote_skew_ms: float = 500.0
341
+
342
+ def __post_init__(self) -> None:
343
+ timeframe = _number(self.timeframe_seconds, "timeframe_seconds")
344
+ timeout = _number(self.timeout_seconds, "timeout_seconds")
345
+ skew = _number(self.max_quote_skew_ms, "max_quote_skew_ms", nonnegative=True)
346
+ object.__setattr__(self, "timeframe_seconds", timeframe)
347
+ object.__setattr__(self, "timeout_seconds", timeout)
348
+ object.__setattr__(self, "max_quote_skew_ms", skew)
349
+
350
+
351
+ class BarBarrierReason:
352
+ """Stable result codes for evidence and barrier decisions."""
353
+
354
+ READY = "READY"
355
+ WAITING_FOR_LEGS = "WAITING_FOR_LEGS"
356
+ WAITING_FOR_WATERMARK = "WAITING_FOR_WATERMARK"
357
+ UNKNOWN_SYMBOL = "UNKNOWN_SYMBOL"
358
+ SYMBOL_MISMATCH = "SYMBOL_MISMATCH"
359
+ EXCHANGE_MISMATCH = "EXCHANGE_MISMATCH"
360
+ CANDIDATE_MISMATCH = "CANDIDATE_MISMATCH"
361
+ BUCKET_MISMATCH = "BUCKET_MISMATCH"
362
+ SESSION_MISMATCH = "SESSION_MISMATCH"
363
+ TRADING_DAY_MISMATCH = "TRADING_DAY_MISMATCH"
364
+ GENERATION_MISMATCH = "GENERATION_MISMATCH"
365
+ RULES_HASH_MISMATCH = "RULES_HASH_MISMATCH"
366
+ CLOCK_DOMAIN_MISMATCH = "CLOCK_DOMAIN_MISMATCH"
367
+ CLOCK_MODE_MISMATCH = "CLOCK_MODE_MISMATCH"
368
+ CLOCK_MAPPING_MISSING = "CLOCK_MAPPING_MISSING"
369
+ CLOCK_MAPPING_MISMATCH = "CLOCK_MAPPING_MISMATCH"
370
+ CLOCK_INVALID = "CLOCK_INVALID"
371
+ CLOCK_REGRESSION = "CLOCK_REGRESSION"
372
+ SCOPE_RESET_REQUIRED = "SCOPE_RESET_REQUIRED"
373
+ INVALID_BAR = "INVALID_BAR"
374
+ SKIP_INCOMPLETE_MINUTE = "SKIP_INCOMPLETE_MINUTE"
375
+ SKIP_BARRIER_TIMEOUT = "SKIP_BARRIER_TIMEOUT"
376
+ FUTURE_DATA_REJECTED = "FUTURE_DATA_REJECTED"
377
+ DUPLICATE_BAR = "DUPLICATE_BAR"
378
+ REVISION_REJECTED = "REVISION_REJECTED"
379
+ LATE_BAR_REJECTED = "LATE_BAR_REJECTED"
380
+ FUTURE_SEAL_REJECTED = "FUTURE_SEAL_REJECTED"
381
+ BLOCKED_QUOTE_CUTOFF = "BLOCKED_QUOTE_CUTOFF"
382
+ BLOCKED_CROSS_LEG_SKEW = "BLOCKED_CROSS_LEG_SKEW"
383
+ QUOTE_AFTER_CUTOFF = "QUOTE_AFTER_CUTOFF"
384
+ QUOTE_AFTER_SEAL = "QUOTE_AFTER_SEAL"
385
+ QUOTE_FUTURE_DATA = "QUOTE_FUTURE_DATA"
386
+ QUOTE_SCOPE_MISMATCH = "QUOTE_SCOPE_MISMATCH"
387
+ QUOTE_EXCHANGE_MISMATCH = "QUOTE_EXCHANGE_MISMATCH"
388
+ QUOTE_IDENTITY_CONFLICT = "QUOTE_IDENTITY_CONFLICT"
389
+ QUOTE_IDENTITY_MISSING = "QUOTE_IDENTITY_MISSING"
390
+ QUOTE_TRADING_DAY_MISMATCH = "QUOTE_TRADING_DAY_MISMATCH"
391
+ QUOTE_RULES_HASH_MISMATCH = "QUOTE_RULES_HASH_MISMATCH"
392
+ QUOTE_QUALITY_INVALID = "QUOTE_QUALITY_INVALID"
393
+ QUOTE_DUPLICATE = "QUOTE_DUPLICATE"
394
+ QUOTE_NOT_IN_FROZEN_INPUT = "QUOTE_NOT_IN_FROZEN_INPUT"
395
+ NO_FROZEN_INPUT = "NO_FROZEN_INPUT"
396
+
397
+
398
+ def _bar_quality_is_good(bar: "BarEvidence") -> bool:
399
+ quality = bar.quality
400
+ if isinstance(quality, str):
401
+ return quality.upper() in _GOOD_QUALITY
402
+ return False
403
+
404
+
405
+ @dataclass(frozen=True)
406
+ class BarEvidence:
407
+ """An immutable feed-produced closed OHLCV bar.
408
+
409
+ The object carries both trade-bar provenance and the independently frozen
410
+ quote cutoff used by the 24 minute consumer. ``quote_events`` are
411
+ optional because the 23 consumer is deliberately bar-only.
412
+ """
413
+
414
+ symbol: str
415
+ exchange: str
416
+ bucket_start: Any
417
+ bucket_end: Any
418
+ available_at: Any
419
+ seal_received_mono: Any
420
+ trading_day: str
421
+ generation: int
422
+ session_segment: str
423
+ rules_hash: str
424
+ quality: Any = _MISSING
425
+ volume_complete: Any = _MISSING
426
+ first_ingest_seq: int = 0
427
+ last_ingest_seq: int = 0
428
+ quote_cutoff_seq: Any = _MISSING
429
+ bar_id: str = ""
430
+ bar_sequence: int = 0
431
+ closure_reason: str = "watermark"
432
+ watermark: Any = None
433
+ max_event_time: Any = None
434
+ open: float = 0.0
435
+ high: float = 0.0
436
+ low: float = 0.0
437
+ close: float = 0.0
438
+ volume: float = 0.0
439
+ openinterest: float = 0.0
440
+ quote_events: Tuple[Any, ...] = ()
441
+ clock_domain: Any = _MISSING
442
+ clock_mode: Any = _MISSING
443
+ seal_received_at: Any = _MISSING
444
+ candidate_id: str = ""
445
+ timeframe_seconds: Optional[float] = None
446
+ trade_count: Optional[int] = None
447
+ complete: Any = _MISSING
448
+ clock_mapping: Any = _MISSING
449
+
450
+ def __post_init__(self) -> None:
451
+ _text(self.symbol, "symbol")
452
+ _text(self.exchange, "exchange")
453
+ raw_mode = self.clock_mode
454
+ if raw_mode == "live":
455
+ for field_name, raw in (
456
+ ("bucket_start", self.bucket_start),
457
+ ("bucket_end", self.bucket_end),
458
+ ("available_at", self.available_at),
459
+ ("seal_received_at", self.seal_received_at),
460
+ ("watermark", self.watermark),
461
+ ("max_event_time", self.max_event_time),
462
+ ):
463
+ if raw is not None and not _time_is_explicitly_aware(raw):
464
+ raise ValueError(f"{field_name} must carry an explicit timezone in live mode")
465
+ start = _datetime(self.bucket_start, "bucket_start")
466
+ end = _datetime(self.bucket_end, "bucket_end")
467
+ available = _datetime(self.available_at, "available_at")
468
+ if end <= start:
469
+ raise ValueError("bucket_end must be after bucket_start")
470
+ if available < end:
471
+ raise ValueError("available_at must be at or after bucket_end")
472
+ object.__setattr__(self, "bucket_start", start)
473
+ object.__setattr__(self, "bucket_end", end)
474
+ object.__setattr__(self, "available_at", available)
475
+
476
+ _text(self.session_segment, "session_segment")
477
+ if type(self.generation) is not int or self.generation <= 0:
478
+ raise ValueError("generation must be a positive integer")
479
+ _text(self.trading_day, "trading_day")
480
+ if not _valid_trading_day(self.trading_day):
481
+ raise ValueError("trading_day must be a valid YYYYMMDD date")
482
+ _provenance_text(self.rules_hash, "rules_hash")
483
+
484
+ if self.clock_domain is _MISSING:
485
+ raise ValueError("clock_domain is required")
486
+ _provenance_text(self.clock_domain, "clock_domain")
487
+ mode = self.clock_mode
488
+ if mode is _MISSING:
489
+ raise ValueError("clock_mode is required")
490
+ if mode not in {"replay", "live"}:
491
+ raise ValueError("clock_mode must be replay or live")
492
+ if mode == "live":
493
+ if self.seal_received_at is None:
494
+ raise ValueError("seal_received_at is required in live mode")
495
+ if self.quote_cutoff_seq is _MISSING:
496
+ raise ValueError("quote_cutoff_seq is required in live mode")
497
+ object.__setattr__(self, "clock_domain", self.clock_domain)
498
+
499
+ seal_mono = self.seal_received_mono
500
+ if seal_mono is _MISSING:
501
+ raise ValueError("seal_received_mono is required")
502
+ seal_mono = _mono(seal_mono, "seal_received_mono")
503
+ if seal_mono <= 0:
504
+ raise ValueError("seal_received_mono must be positive")
505
+ object.__setattr__(self, "seal_received_mono", seal_mono)
506
+
507
+ seal_at = self.seal_received_at
508
+ if seal_at is _MISSING or seal_at is None:
509
+ raise ValueError("seal_received_at is required")
510
+ if not _time_is_explicitly_aware(seal_at) and mode == "live":
511
+ raise ValueError("seal_received_at must carry an explicit timezone in live mode")
512
+ seal_at = _datetime(seal_at, "seal_received_at")
513
+ mapping = self.clock_mapping
514
+ if mapping is _MISSING or not isinstance(mapping, ClockMapping):
515
+ raise ValueError("clock_mapping is required")
516
+ if mapping.clock_domain_id != self.clock_domain:
517
+ raise ValueError("clock_mapping clock domain does not match bar")
518
+ if mapping.connection_generation != self.generation:
519
+ raise ValueError("clock_mapping generation does not match bar")
520
+ if mapping.rules_hash != self.rules_hash:
521
+ raise ValueError("clock_mapping rules hash does not match bar")
522
+ if mode == "replay" and not mapping.synthetic:
523
+ raise ValueError("replay bars require an explicitly synthetic clock mapping")
524
+ if mode == "live" and mapping.synthetic:
525
+ raise ValueError("live bars cannot use a synthetic clock mapping")
526
+ object.__setattr__(self, "seal_received_at", _datetime(seal_at, "seal_received_at"))
527
+ mapping.validate_pair(seal_at, seal_mono)
528
+ object.__setattr__(self, "clock_mapping", mapping)
529
+ object.__setattr__(self, "watermark", _optional_datetime(self.watermark, "watermark"))
530
+ object.__setattr__(
531
+ self, "max_event_time", _optional_datetime(self.max_event_time, "max_event_time")
532
+ )
533
+
534
+ for field_name in ("open", "high", "low", "close", "volume", "openinterest"):
535
+ raw = getattr(self, field_name)
536
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)):
537
+ raise ValueError(f"{field_name} must be numeric")
538
+ parsed = float(raw)
539
+ if not math.isfinite(parsed) or abs(parsed) >= _MAX_ABS_NUMBER:
540
+ raise ValueError(f"{field_name} must be finite")
541
+ object.__setattr__(self, field_name, parsed)
542
+
543
+ for field_name in ("first_ingest_seq", "last_ingest_seq", "bar_sequence"):
544
+ raw = getattr(self, field_name)
545
+ if type(raw) is not int or raw <= 0:
546
+ raise ValueError(f"{field_name} must be a positive integer")
547
+ if self.last_ingest_seq < self.first_ingest_seq:
548
+ raise ValueError("last_ingest_seq must not precede first_ingest_seq")
549
+ if self.quote_cutoff_seq is _MISSING:
550
+ raise ValueError("quote_cutoff_seq is required")
551
+ cutoff = self.quote_cutoff_seq
552
+ if type(cutoff) is not int or cutoff < 0 or cutoff < self.last_ingest_seq:
553
+ raise ValueError("quote_cutoff_seq must be an integer at or after last_ingest_seq")
554
+ object.__setattr__(self, "quote_cutoff_seq", cutoff)
555
+ if self.trade_count is not None and (
556
+ type(self.trade_count) is not int or self.trade_count < 0
557
+ ):
558
+ raise ValueError("trade_count must be a non-negative integer or None")
559
+ if self.quality is _MISSING or not isinstance(self.quality, str) or not self.quality:
560
+ raise ValueError("quality is required")
561
+ if not isinstance(self.volume_complete, bool) or not isinstance(self.complete, bool):
562
+ raise ValueError("volume_complete and complete must be explicit bool values")
563
+ _text(self.closure_reason, "closure_reason")
564
+ if self.candidate_id:
565
+ _text(self.candidate_id, "candidate_id")
566
+ timeframe = self.timeframe_seconds
567
+ if timeframe is not None:
568
+ object.__setattr__(self, "timeframe_seconds", _number(timeframe, "timeframe_seconds"))
569
+
570
+ raw_quotes = self.quote_events
571
+ if not isinstance(raw_quotes, (tuple, list)):
572
+ raise ValueError("quote_events must be a tuple or list")
573
+ frozen_quotes = []
574
+ for event in raw_quotes:
575
+ if isinstance(event, CtpQuoteEvidence):
576
+ frozen_quotes.append(event)
577
+ elif isinstance(event, Mapping):
578
+ frozen_quotes.append(_freeze(dict(event)))
579
+ elif hasattr(event, "__dict__"):
580
+ frozen_quotes.append(_freeze(vars(event)))
581
+ else:
582
+ raise ValueError("quote_events must contain mappings or event objects")
583
+ frozen_quotes = tuple(frozen_quotes)
584
+ object.__setattr__(self, "quote_events", frozen_quotes)
585
+
586
+ bar_id = self.bar_id
587
+ if not bar_id:
588
+ bar_id = (
589
+ f"{self.symbol}:{start.isoformat()}:{end.isoformat()}:"
590
+ f"{self.generation}:{self.first_ingest_seq}-{self.last_ingest_seq}"
591
+ )
592
+ _text(bar_id, "bar_id")
593
+ object.__setattr__(self, "bar_id", bar_id)
594
+
595
+ def to_dict(self) -> Dict[str, Any]:
596
+ return {
597
+ name: _json_safe(getattr(self, name))
598
+ for name in (
599
+ "symbol",
600
+ "exchange",
601
+ "bucket_start",
602
+ "bucket_end",
603
+ "available_at",
604
+ "seal_received_mono",
605
+ "seal_received_at",
606
+ "trading_day",
607
+ "generation",
608
+ "session_segment",
609
+ "rules_hash",
610
+ "quality",
611
+ "volume_complete",
612
+ "first_ingest_seq",
613
+ "last_ingest_seq",
614
+ "quote_cutoff_seq",
615
+ "bar_id",
616
+ "bar_sequence",
617
+ "closure_reason",
618
+ "watermark",
619
+ "max_event_time",
620
+ "open",
621
+ "high",
622
+ "low",
623
+ "close",
624
+ "volume",
625
+ "openinterest",
626
+ "quote_events",
627
+ "clock_domain",
628
+ "clock_mode",
629
+ "clock_mapping",
630
+ "candidate_id",
631
+ "timeframe_seconds",
632
+ "trade_count",
633
+ "complete",
634
+ )
635
+ }
636
+
637
+
638
+ @dataclass(frozen=True)
639
+ class MinuteDecisionInput:
640
+ """One frozen, same-scope multi-leg decision input."""
641
+
642
+ key: Tuple[Any, ...]
643
+ bars: Mapping[str, BarEvidence]
644
+ bucket_start: datetime
645
+ bucket_end: datetime
646
+ common_available_at: datetime
647
+ bar_ids: Tuple[str, ...]
648
+ quote_cutoffs: Mapping[str, int]
649
+ accepted_quotes: Mapping[str, Tuple[Mapping[str, Any], ...]]
650
+ quote_rejections: Mapping[str, Tuple[str, ...]]
651
+ source_sequences: Mapping[str, Tuple[int, int]]
652
+ quality_report: Mapping[str, Any]
653
+ trading_day: str
654
+ generation: int
655
+ session_segment: str
656
+ rules_hash: str
657
+ candidate_id: str
658
+ clock_domain: str
659
+ clock_mode: str
660
+ barrier_ready_mono: float
661
+ deadline_mono: float
662
+ clock_mapping: ClockMapping
663
+
664
+ def __post_init__(self) -> None:
665
+ bars = dict(self.bars)
666
+ if not bars or any(not isinstance(bar, BarEvidence) for bar in bars.values()):
667
+ raise ValueError("bars must contain BarEvidence values")
668
+ if not isinstance(self.clock_mapping, ClockMapping):
669
+ raise ValueError("clock_mapping is required")
670
+ object.__setattr__(self, "key", tuple(self.key))
671
+ object.__setattr__(self, "bar_ids", tuple(self.bar_ids))
672
+ object.__setattr__(self, "bars", MappingProxyType(bars))
673
+ object.__setattr__(self, "quote_cutoffs", _freeze(dict(self.quote_cutoffs)))
674
+ object.__setattr__(self, "accepted_quotes", _freeze(dict(self.accepted_quotes)))
675
+ object.__setattr__(self, "quote_rejections", _freeze(dict(self.quote_rejections)))
676
+ object.__setattr__(self, "source_sequences", _freeze(dict(self.source_sequences)))
677
+ object.__setattr__(self, "quality_report", _freeze(dict(self.quality_report)))
678
+
679
+ def to_dict(self) -> Dict[str, Any]:
680
+ return {
681
+ "key": _json_safe(self.key),
682
+ "bars": {symbol: bar.to_dict() for symbol, bar in self.bars.items()},
683
+ "bucket_start": self.bucket_start.isoformat(),
684
+ "bucket_end": self.bucket_end.isoformat(),
685
+ "common_available_at": self.common_available_at.isoformat(),
686
+ "bar_ids": list(self.bar_ids),
687
+ "quote_cutoffs": dict(self.quote_cutoffs),
688
+ "accepted_quotes": _json_safe(self.accepted_quotes),
689
+ "quote_rejections": _json_safe(self.quote_rejections),
690
+ "source_sequences": _json_safe(self.source_sequences),
691
+ "quality_report": _json_safe(self.quality_report),
692
+ "trading_day": self.trading_day,
693
+ "generation": self.generation,
694
+ "session_segment": self.session_segment,
695
+ "rules_hash": self.rules_hash,
696
+ "candidate_id": self.candidate_id,
697
+ "clock_domain": self.clock_domain,
698
+ "clock_mode": self.clock_mode,
699
+ "barrier_ready_mono": self.barrier_ready_mono,
700
+ "deadline_mono": self.deadline_mono,
701
+ "clock_mapping": self.clock_mapping.to_dict(),
702
+ }
703
+
704
+
705
+ @dataclass(frozen=True)
706
+ class BarBarrierResult:
707
+ """Result of one bar ingestion or clock advance."""
708
+
709
+ reason: str
710
+ decision_input: Optional[MinuteDecisionInput] = None
711
+ key: Optional[Tuple[Any, ...]] = None
712
+ reset_warmup: bool = False
713
+
714
+ @property
715
+ def ready(self) -> bool:
716
+ return self.decision_input is not None
717
+
718
+
719
+ @dataclass(frozen=True)
720
+ class QuoteCutoffResult:
721
+ """Side-effect-free validation result for a quote against a frozen bar."""
722
+
723
+ accepted: bool
724
+ reason: str
725
+ symbol: Optional[str] = None
726
+ event: Optional[Mapping[str, Any]] = None
727
+
728
+
729
+ def _quote_mapping(event: Any, *, bar: BarEvidence) -> Optional[Dict[str, Any]]:
730
+ """Detach a raw quote or adapt an already validated CTP quote evidence."""
731
+
732
+ if isinstance(event, CtpQuoteEvidence):
733
+ return {
734
+ "symbol": event.symbol,
735
+ "exchange": event.exchange,
736
+ "event_time": event.source_epoch,
737
+ "received_at": event.receive_epoch,
738
+ "received_monotonic_ns": event.receive_monotonic_ns,
739
+ "ingest_seq": event.ingest_seq,
740
+ "generation": event.connection_generation,
741
+ "asset_type": event.asset_type,
742
+ "bid": event.bid,
743
+ "ask": event.ask,
744
+ "bid_size": event.bid_size,
745
+ "ask_size": event.ask_size,
746
+ "last": event.last,
747
+ "lower_limit": event.lower_limit,
748
+ "upper_limit": event.upper_limit,
749
+ "subscription_epoch": event.subscription_epoch,
750
+ "trading_day": event.trading_day,
751
+ "action_day": event.action_day,
752
+ "rules_hash": event.rules_hash,
753
+ "clock_domain": event.clock_domain_id,
754
+ "clock_mode": bar.clock_mode,
755
+ "session_segment": bar.session_segment,
756
+ "candidate_id": bar.candidate_id,
757
+ "quality": "GOOD",
758
+ "volume_complete": True,
759
+ "source": event.source,
760
+ "event_time_source": event.event_time_source,
761
+ "source_clock_error_ms": event.source_clock_error_ms,
762
+ "receive_clock_error_ms": event.receive_clock_error_ms,
763
+ "validated_quote_type": "CtpQuoteEvidence",
764
+ }
765
+ if isinstance(event, Mapping):
766
+ return dict(event)
767
+ if hasattr(event, "__dict__"):
768
+ return dict(vars(event))
769
+ return None
770
+
771
+
772
+ def _quote_monotonic_seconds(event: Mapping[str, Any]) -> float:
773
+ """Normalize seconds and nanosecond aliases without guessing units."""
774
+
775
+ seconds = _alias(
776
+ event,
777
+ "received_monotonic",
778
+ "recv_monotonic",
779
+ "receive_monotonic",
780
+ default=_MISSING,
781
+ )
782
+ nanoseconds = _alias(
783
+ event,
784
+ "received_monotonic_ns",
785
+ "recv_monotonic_ns",
786
+ "receive_monotonic_ns",
787
+ default=_MISSING,
788
+ )
789
+ parsed_seconds = None
790
+ parsed_nanoseconds = None
791
+ if seconds is not _MISSING:
792
+ parsed_seconds = _number(seconds, "quote.received_monotonic", nonnegative=True)
793
+ if nanoseconds is not _MISSING:
794
+ if type(nanoseconds) is not int or nanoseconds < 0:
795
+ raise ValueError("quote monotonic nanosecond fields must be integers")
796
+ parsed_nanoseconds = nanoseconds / 1_000_000_000.0
797
+ if parsed_seconds is None and parsed_nanoseconds is None:
798
+ raise ValueError("quote receive monotonic evidence is required")
799
+ if (
800
+ parsed_seconds is not None
801
+ and parsed_nanoseconds is not None
802
+ and not math.isclose(parsed_seconds, parsed_nanoseconds, rel_tol=0.0, abs_tol=1.0e-12)
803
+ ):
804
+ raise ValueError("conflicting aliases: quote receive monotonic units")
805
+ return parsed_seconds if parsed_seconds is not None else parsed_nanoseconds
806
+
807
+
808
+ def _quote_filter(
809
+ event: Any,
810
+ *,
811
+ bar: BarEvidence,
812
+ max_skew_ms: float,
813
+ ) -> QuoteCutoffResult:
814
+ data = _quote_mapping(event, bar=bar)
815
+ if data is None:
816
+ return QuoteCutoffResult(False, BarBarrierReason.BLOCKED_QUOTE_CUTOFF)
817
+ try:
818
+ symbol = _alias(data, "symbol", "instrument_id", "InstrumentID", default=_MISSING)
819
+ exchange = _alias(data, "exchange", "exchange_id", "ExchangeID", default=_MISSING)
820
+ sequence = _alias(data, "ingest_seq", "sequence", default=_MISSING)
821
+ generation = _alias(data, "generation", "connection_generation", default=_MISSING)
822
+ trading_day = _alias(data, "trading_day", "TradingDay", default=_MISSING)
823
+ rules_hash = _alias(data, "rules_hash", default=_MISSING)
824
+ domain = _alias(data, "clock_domain", "clock_domain_id", default=_MISSING)
825
+ mode = _alias(data, "clock_mode", default=_MISSING)
826
+ quality = _alias(data, "quality", "quote_quality", "quality_status", default=_MISSING)
827
+ volume_complete = _alias(data, "volume_complete", default=_MISSING)
828
+ session = _alias(data, "session_segment", "session", default=_MISSING)
829
+ candidate = _alias(data, "candidate_id", default=_MISSING)
830
+ event_time_raw = _alias(
831
+ data,
832
+ "event_time",
833
+ "event_time_utc",
834
+ "exchange_time",
835
+ default=_MISSING,
836
+ )
837
+ receive_time_raw = _alias(
838
+ data,
839
+ "received_at",
840
+ "recv_time_utc",
841
+ "received_wall_time",
842
+ "receive_time",
843
+ default=_MISSING,
844
+ )
845
+ if symbol != bar.symbol:
846
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_SCOPE_MISMATCH, symbol=symbol)
847
+ if exchange != bar.exchange:
848
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_EXCHANGE_MISMATCH, symbol=symbol)
849
+ if type(sequence) is not int or sequence <= 0:
850
+ return QuoteCutoffResult(False, BarBarrierReason.BLOCKED_QUOTE_CUTOFF, symbol=symbol)
851
+ if sequence > bar.quote_cutoff_seq:
852
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_AFTER_CUTOFF, symbol=symbol)
853
+ if type(generation) is not int or generation <= 0:
854
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_IDENTITY_MISSING, symbol=symbol)
855
+ if generation != bar.generation:
856
+ return QuoteCutoffResult(False, BarBarrierReason.GENERATION_MISMATCH, symbol=symbol)
857
+ if not isinstance(trading_day, str):
858
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_IDENTITY_MISSING, symbol=symbol)
859
+ if trading_day != bar.trading_day:
860
+ return QuoteCutoffResult(
861
+ False, BarBarrierReason.QUOTE_TRADING_DAY_MISMATCH, symbol=symbol
862
+ )
863
+ if not isinstance(rules_hash, str):
864
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_IDENTITY_MISSING, symbol=symbol)
865
+ if rules_hash != bar.rules_hash:
866
+ return QuoteCutoffResult(
867
+ False, BarBarrierReason.QUOTE_RULES_HASH_MISMATCH, symbol=symbol
868
+ )
869
+ if not isinstance(domain, str):
870
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_IDENTITY_MISSING, symbol=symbol)
871
+ if domain != bar.clock_domain:
872
+ return QuoteCutoffResult(False, BarBarrierReason.CLOCK_DOMAIN_MISMATCH, symbol=symbol)
873
+ if mode is _MISSING or mode != bar.clock_mode:
874
+ return QuoteCutoffResult(False, BarBarrierReason.CLOCK_MODE_MISMATCH, symbol=symbol)
875
+ if not isinstance(session, str) or session != bar.session_segment:
876
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_SCOPE_MISMATCH, symbol=symbol)
877
+ if bar.candidate_id and (candidate is _MISSING or candidate != bar.candidate_id):
878
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_SCOPE_MISMATCH, symbol=symbol)
879
+ if (
880
+ quality is _MISSING
881
+ or not isinstance(quality, str)
882
+ or quality.upper() not in _GOOD_QUALITY
883
+ ):
884
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_QUALITY_INVALID, symbol=symbol)
885
+ if volume_complete is not True:
886
+ return QuoteCutoffResult(False, BarBarrierReason.BLOCKED_QUOTE_CUTOFF, symbol=symbol)
887
+ if event_time_raw is _MISSING or receive_time_raw is _MISSING:
888
+ return QuoteCutoffResult(False, BarBarrierReason.BLOCKED_QUOTE_CUTOFF, symbol=symbol)
889
+ if bar.clock_mode == "live" and (
890
+ not _time_is_explicitly_aware(event_time_raw)
891
+ or not _time_is_explicitly_aware(receive_time_raw)
892
+ ):
893
+ return QuoteCutoffResult(False, BarBarrierReason.BLOCKED_QUOTE_CUTOFF, symbol=symbol)
894
+ received_mono = _quote_monotonic_seconds(data)
895
+ except (TypeError, ValueError) as error:
896
+ symbol = _value(data, "symbol", "instrument_id", "InstrumentID")
897
+ reason = (
898
+ BarBarrierReason.QUOTE_IDENTITY_CONFLICT
899
+ if str(error).startswith("conflicting aliases")
900
+ else BarBarrierReason.BLOCKED_QUOTE_CUTOFF
901
+ )
902
+ return QuoteCutoffResult(False, reason, symbol=symbol)
903
+ try:
904
+ event_time = _datetime(event_time_raw, "quote.event_time")
905
+ receive_time = _datetime(receive_time_raw, "quote.received_at")
906
+ except ValueError:
907
+ return QuoteCutoffResult(False, BarBarrierReason.BLOCKED_QUOTE_CUTOFF, symbol=symbol)
908
+ if event_time >= bar.bucket_end:
909
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_FUTURE_DATA, symbol=symbol)
910
+ if receive_time > bar.seal_received_at:
911
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_AFTER_SEAL, symbol=symbol)
912
+ if received_mono > bar.seal_received_mono:
913
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_AFTER_SEAL, symbol=symbol)
914
+ if event_time > receive_time:
915
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_FUTURE_DATA, symbol=symbol)
916
+ try:
917
+ # Receive wall time and monotonic time are one observation. Validate
918
+ # them against the bar's frozen mapping before admitting the event;
919
+ # comparing each field only with its own cutoff would permit a stale
920
+ # monotonic value to masquerade as a historical quote.
921
+ bar.clock_mapping.validate_pair(receive_time, received_mono)
922
+ except ValueError:
923
+ return QuoteCutoffResult(False, BarBarrierReason.CLOCK_MAPPING_MISMATCH, symbol=symbol)
924
+ # This function is a frozen-bar cutoff check. Full native CTP quote-v2
925
+ # quality/schema validation remains the public CtpQuoteCohortValidator;
926
+ # this boundary still requires enough explicit scope to prevent a raw,
927
+ # under-specified quote from entering a minute decision.
928
+ del max_skew_ms
929
+ data.update(
930
+ {
931
+ "symbol": symbol,
932
+ "exchange": exchange,
933
+ "event_time": event_time,
934
+ "received_at": receive_time,
935
+ "received_monotonic": received_mono,
936
+ "ingest_seq": sequence,
937
+ "generation": generation,
938
+ "trading_day": trading_day,
939
+ "rules_hash": rules_hash,
940
+ "session_segment": session,
941
+ "clock_domain": domain,
942
+ "clock_mode": mode,
943
+ "quality": quality,
944
+ "volume_complete": volume_complete,
945
+ }
946
+ )
947
+ return QuoteCutoffResult(True, BarBarrierReason.READY, symbol=symbol, event=_freeze(data))
948
+
949
+
950
+ def validate_quote_against_bar(
951
+ event: Any, *, bar: BarEvidence, max_skew_ms: float = 500.0
952
+ ) -> QuoteCutoffResult:
953
+ """Validate one quote without mutating a barrier or its decision input."""
954
+
955
+ return _quote_filter(event, bar=bar, max_skew_ms=max_skew_ms)
956
+
957
+
958
+ class MultiLegBarBarrier:
959
+ """Causal two- or three-leg barrier for already-closed bars."""
960
+
961
+ _MAX_PENDING_BUCKETS = 128
962
+ _MAX_RETAINED_INPUTS = 64
963
+ _MAX_RESULT_HISTORY = 128
964
+
965
+ def __init__(
966
+ self,
967
+ expected_legs: Optional[Iterable[Any]] = None,
968
+ *,
969
+ legs: Optional[Iterable[Any]] = None,
970
+ candidate_id: str = "",
971
+ expected_rules_hash: Optional[str] = None,
972
+ clock_mapping: Optional[ClockMapping] = None,
973
+ policy: Optional[BarBarrierPolicy] = None,
974
+ timeframe_seconds: Optional[float] = None,
975
+ timeout_seconds: Optional[float] = None,
976
+ expected_clock_domain: Optional[str] = None,
977
+ clock_domain: Optional[str] = None,
978
+ clock_mode: Optional[str] = None,
979
+ expected_exchange: str = "",
980
+ ) -> None:
981
+ source = expected_legs if expected_legs is not None else legs
982
+ if source is None:
983
+ raise ValueError("expected_legs is required")
984
+ self.expected_legs = self._normalize_legs(source, expected_exchange)
985
+ if len(self.expected_legs) not in (2, 3):
986
+ raise ValueError("a barrier requires exactly two or three legs")
987
+ self._leg_by_symbol = {leg.symbol: leg for leg in self.expected_legs}
988
+ if len(self._leg_by_symbol) != len(self.expected_legs):
989
+ raise ValueError("expected leg symbols must be unique")
990
+ self.candidate_id = _text(candidate_id, "candidate_id", allow_empty=True)
991
+ self.expected_rules_hash = (
992
+ None
993
+ if expected_rules_hash is None
994
+ else _text(expected_rules_hash, "expected_rules_hash")
995
+ )
996
+ if clock_mapping is not None and not isinstance(clock_mapping, ClockMapping):
997
+ raise TypeError("clock_mapping must be ClockMapping")
998
+ self.clock_mapping = clock_mapping
999
+ if policy is None:
1000
+ policy = BarBarrierPolicy(
1001
+ timeframe_seconds=60.0 if timeframe_seconds is None else timeframe_seconds,
1002
+ timeout_seconds=2.0 if timeout_seconds is None else timeout_seconds,
1003
+ )
1004
+ if not isinstance(policy, BarBarrierPolicy):
1005
+ raise TypeError("policy must be BarBarrierPolicy")
1006
+ self.policy = policy
1007
+ if (
1008
+ expected_clock_domain is not None
1009
+ and clock_domain is not None
1010
+ and expected_clock_domain != clock_domain
1011
+ ):
1012
+ raise ValueError("expected_clock_domain and clock_domain aliases must agree")
1013
+ self.expected_clock_domain = (
1014
+ expected_clock_domain if expected_clock_domain is not None else clock_domain
1015
+ )
1016
+ if self.expected_clock_domain is not None:
1017
+ _text(self.expected_clock_domain, "expected_clock_domain")
1018
+ self.clock_mode = clock_mode
1019
+ if self.clock_mode is not None and self.clock_mode not in {"replay", "live"}:
1020
+ raise ValueError("clock_mode must be replay or live")
1021
+ self._pending: Dict[Tuple[Any, ...], Dict[str, Any]] = {}
1022
+ self._pending_core: Dict[Tuple[Any, ...], Tuple[Any, ...]] = {}
1023
+ self._skipped: "OrderedDict[Tuple[Any, ...], str]" = OrderedDict()
1024
+ self._finalized: "OrderedDict[Tuple[Any, ...], MinuteDecisionInput]" = OrderedDict()
1025
+ # A bucket watermark belongs to the active physical connection and
1026
+ # clock domain. A new connection may reuse a wall-time bucket in an
1027
+ # independent replay; the same connection, including recalibration,
1028
+ # must move beyond the old bucket.
1029
+ self._retired_bucket_end: Optional[datetime] = None
1030
+ self._last_input: Optional[MinuteDecisionInput] = None
1031
+ self._last_results: "Deque[Any]" = deque(maxlen=self._MAX_RESULT_HISTORY)
1032
+ self._last_now_mono: Optional[float] = None
1033
+ self._clock_fault: Optional[str] = None
1034
+ # Bind the first valid input to one immutable identity scope. A
1035
+ # cross-scope or clock fault retires that scope until reset_scope is
1036
+ # given a complete replacement declaration.
1037
+ self._scope: Optional[Tuple[Any, ...]] = None
1038
+ self._retired_scopes: "OrderedDict[Tuple[Any, ...], None]" = OrderedDict()
1039
+ # Connection generation, clock calibration, and business session are
1040
+ # independent dimensions. Keep only the greatest generation and its
1041
+ # greatest calibration anchor for each clock identity. These scalar
1042
+ # fences survive the bounded retired-scope cache and never compare
1043
+ # monotonic values belonging to different clock domains.
1044
+ self._generation_fence_by_clock: Dict[Tuple[Any, ...], int] = {}
1045
+ self._mapping_fence_by_clock: Dict[Tuple[Any, ...], Tuple[datetime, int]] = {}
1046
+ self._mapping_by_clock: Dict[Tuple[Any, ...], ClockMapping] = {}
1047
+ self._bucket_context_by_clock: Dict[Tuple[Any, ...], Tuple[int, datetime, int]] = {}
1048
+ self._bucket_watermark_by_clock: Dict[Tuple[Any, ...], datetime] = {}
1049
+ self._active_clock_key: Optional[Tuple[Any, ...]] = None
1050
+ self._active_connection_marker: Optional[Tuple[int, datetime, int]] = None
1051
+
1052
+ @staticmethod
1053
+ def _normalize_legs(source: Iterable[Any], default_exchange: str) -> Tuple[BarLeg, ...]:
1054
+ if isinstance(source, Mapping):
1055
+ items = list(source.items())
1056
+ result: List[BarLeg] = []
1057
+ for key, raw in items:
1058
+ if isinstance(raw, BarLeg):
1059
+ result.append(raw)
1060
+ elif isinstance(raw, Mapping):
1061
+ result.append(
1062
+ BarLeg(
1063
+ symbol=raw.get("symbol", key),
1064
+ exchange=raw.get("exchange", default_exchange),
1065
+ )
1066
+ )
1067
+ else:
1068
+ result.append(BarLeg(symbol=str(raw), exchange=default_exchange))
1069
+ return tuple(result)
1070
+ result = []
1071
+ for raw in source:
1072
+ if isinstance(raw, BarLeg):
1073
+ result.append(raw)
1074
+ elif isinstance(raw, Mapping):
1075
+ result.append(
1076
+ BarLeg(symbol=raw["symbol"], exchange=raw.get("exchange", default_exchange))
1077
+ )
1078
+ else:
1079
+ result.append(BarLeg(symbol=str(raw), exchange=default_exchange))
1080
+ return tuple(result)
1081
+
1082
+ @property
1083
+ def pending_keys(self) -> Tuple[Tuple[Any, ...], ...]:
1084
+ return tuple(self._pending)
1085
+
1086
+ @property
1087
+ def finalized_inputs(self) -> Mapping[Tuple[Any, ...], MinuteDecisionInput]:
1088
+ return MappingProxyType(dict(self._finalized))
1089
+
1090
+ @property
1091
+ def last_input(self) -> Optional[MinuteDecisionInput]:
1092
+ return self._last_input
1093
+
1094
+ def reset_scope(
1095
+ self,
1096
+ *,
1097
+ trading_day: Optional[str] = None,
1098
+ generation: Optional[int] = None,
1099
+ session_segment: Optional[str] = None,
1100
+ rules_hash: Optional[str] = None,
1101
+ clock_domain: Optional[str] = None,
1102
+ clock_mode: Optional[str] = None,
1103
+ clock_mapping: Optional[ClockMapping] = None,
1104
+ candidate_id: Optional[str] = None,
1105
+ ) -> None:
1106
+ """Bind a newly authorized scope and recorded wall/mono mapping.
1107
+
1108
+ An empty call deliberately does *not* reopen the barrier. It retires
1109
+ any pending scope and leaves the barrier requiring a complete
1110
+ declaration, which prevents ``reset_scope()`` from being used as a
1111
+ cache-clearing back-fill operation. A real reset must state every
1112
+ time/identity dimension and provide the new mapping.
1113
+ """
1114
+
1115
+ supplied = (
1116
+ trading_day,
1117
+ generation,
1118
+ session_segment,
1119
+ rules_hash,
1120
+ clock_domain,
1121
+ clock_mode,
1122
+ clock_mapping,
1123
+ candidate_id,
1124
+ )
1125
+ if all(value is None for value in supplied):
1126
+ self._retire_bound_scope()
1127
+ for key in list(self._pending):
1128
+ self._mark_skipped(key, BarBarrierReason.SCOPE_RESET_REQUIRED)
1129
+ self._pending.clear()
1130
+ self._pending_core.clear()
1131
+ self._finalized.clear()
1132
+ self._last_input = None
1133
+ self._clock_fault = BarBarrierReason.SCOPE_RESET_REQUIRED
1134
+ self._scope = None
1135
+ return
1136
+ if any(value is None for value in supplied[:7]):
1137
+ raise ValueError(
1138
+ "reset_scope requires trading_day, generation, session_segment, rules_hash, "
1139
+ "clock_domain, clock_mode and clock_mapping"
1140
+ )
1141
+ if candidate_id is None:
1142
+ candidate_id = self.candidate_id
1143
+ _text(candidate_id, "candidate_id", allow_empty=True)
1144
+ _text(trading_day, "trading_day")
1145
+ if not _valid_trading_day(trading_day):
1146
+ raise ValueError("trading_day must be a valid YYYYMMDD date")
1147
+ if type(generation) is not int or generation <= 0:
1148
+ raise ValueError("generation must be a positive integer")
1149
+ _text(session_segment, "session_segment")
1150
+ _provenance_text(rules_hash, "rules_hash")
1151
+ _provenance_text(clock_domain, "clock_domain")
1152
+ if clock_mode not in {"replay", "live"}:
1153
+ raise ValueError("clock_mode must be replay or live")
1154
+ if not isinstance(clock_mapping, ClockMapping):
1155
+ raise ValueError("clock_mapping must be a ClockMapping")
1156
+ if clock_mapping.clock_domain_id != clock_domain:
1157
+ raise ValueError("clock_mapping clock domain does not match scope")
1158
+ if clock_mapping.connection_generation != generation:
1159
+ raise ValueError("clock_mapping generation does not match scope")
1160
+ if clock_mapping.rules_hash != rules_hash:
1161
+ raise ValueError("clock_mapping rules hash does not match scope")
1162
+ if clock_mode == "replay" and not clock_mapping.synthetic:
1163
+ raise ValueError("replay scope requires an explicitly synthetic clock mapping")
1164
+ if clock_mode == "live" and clock_mapping.synthetic:
1165
+ raise ValueError("live scope cannot use a synthetic clock mapping")
1166
+ if self.candidate_id and candidate_id != self.candidate_id:
1167
+ raise ValueError("scope candidate_id does not match barrier")
1168
+ if self.expected_rules_hash is not None and rules_hash != self.expected_rules_hash:
1169
+ raise ValueError("scope rules_hash does not match barrier")
1170
+ if self.expected_clock_domain is not None and clock_domain != self.expected_clock_domain:
1171
+ raise ValueError("scope clock_domain does not match barrier")
1172
+ if self.clock_mode is not None and clock_mode != self.clock_mode:
1173
+ raise ValueError("scope clock_mode does not match barrier")
1174
+ if self.clock_mapping is not None and clock_mapping != self.clock_mapping:
1175
+ self._invalidate_scope(BarBarrierReason.CLOCK_MAPPING_MISMATCH)
1176
+ raise ValueError("scope clock_mapping does not match barrier")
1177
+
1178
+ requested_scope = self._scope_from_values(
1179
+ candidate_id,
1180
+ trading_day,
1181
+ generation,
1182
+ session_segment,
1183
+ rules_hash,
1184
+ clock_domain,
1185
+ clock_mode,
1186
+ clock_mapping,
1187
+ )
1188
+ if requested_scope == self._scope or requested_scope in self._retired_scopes:
1189
+ raise ValueError("reset_scope cannot reopen an active or retired scope")
1190
+ lifecycle_reason = self._scope_lifecycle_reason(requested_scope)
1191
+ if lifecycle_reason is not None:
1192
+ if lifecycle_reason == BarBarrierReason.CLOCK_MAPPING_MISMATCH:
1193
+ self._invalidate_scope(lifecycle_reason)
1194
+ raise ValueError("reset_scope received an incompatible clock mapping")
1195
+ raise ValueError("reset_scope cannot move the lifecycle fence backwards")
1196
+
1197
+ requested_clock_key = self._scope_clock_key(requested_scope)
1198
+ requested_marker = self._scope_connection_marker(requested_scope)
1199
+ # The anchor is calibration metadata, not physical connection
1200
+ # identity. Once lifecycle validation accepts an equivalent
1201
+ # recalibration, retain observations from the same generation/domain.
1202
+ preserve_clock_observation = (
1203
+ self._active_clock_key == requested_clock_key
1204
+ and self._active_connection_marker is not None
1205
+ and self._active_connection_marker[0] == requested_marker[0]
1206
+ )
1207
+
1208
+ self._retire_bound_scope()
1209
+ for key in list(self._pending):
1210
+ self._mark_skipped(key, BarBarrierReason.SCOPE_RESET_REQUIRED)
1211
+ self._pending.clear()
1212
+ self._pending_core.clear()
1213
+ self._finalized.clear()
1214
+ self._last_input = None
1215
+ self._last_results.clear()
1216
+ if not preserve_clock_observation:
1217
+ self._last_now_mono = None
1218
+ self._clock_fault = None
1219
+ self._scope = requested_scope
1220
+ self._record_scope_lifecycle(requested_scope)
1221
+
1222
+ @staticmethod
1223
+ def _scope_from_values(*values: Any) -> Tuple[Any, ...]:
1224
+ return tuple(values)
1225
+
1226
+ def _scope_for(self, bar: BarEvidence) -> Tuple[Any, ...]:
1227
+ return self._scope_from_values(
1228
+ self._candidate_for(bar),
1229
+ bar.trading_day,
1230
+ bar.generation,
1231
+ bar.session_segment,
1232
+ bar.rules_hash,
1233
+ bar.clock_domain,
1234
+ bar.clock_mode,
1235
+ bar.clock_mapping,
1236
+ )
1237
+
1238
+ def _retire_bound_scope(self) -> None:
1239
+ if self._scope is None:
1240
+ return
1241
+ self._retired_scopes[self._scope] = None
1242
+ self._retired_scopes.move_to_end(self._scope)
1243
+ while len(self._retired_scopes) > self._MAX_RETAINED_INPUTS:
1244
+ self._retired_scopes.popitem(last=False)
1245
+
1246
+ @staticmethod
1247
+ def _scope_clock_key(scope: Tuple[Any, ...]) -> Tuple[Any, ...]:
1248
+ """Return the identity whose monotonic observations are comparable."""
1249
+
1250
+ # Candidate/rules/mode are part of the evidence contract. The clock
1251
+ # domain keeps unrelated monotonic counters from being compared.
1252
+ return (scope[0], scope[4], scope[5], scope[6])
1253
+
1254
+ @staticmethod
1255
+ def _scope_connection_marker(scope: Tuple[Any, ...]) -> Tuple[int, datetime, int]:
1256
+ """Return generation plus the recorded wall/mono calibration anchor."""
1257
+
1258
+ mapping = scope[7]
1259
+ return (
1260
+ scope[2],
1261
+ mapping.wall_utc_at_anchor,
1262
+ mapping.mono_ns_at_anchor,
1263
+ )
1264
+
1265
+ def _scope_lifecycle_reason(self, scope: Tuple[Any, ...]) -> Optional[str]:
1266
+ """Reject connection/calibration rollback without ordering sessions."""
1267
+
1268
+ clock_key = self._scope_clock_key(scope)
1269
+ generation = scope[2]
1270
+ anchor = (scope[7].wall_utc_at_anchor, scope[7].mono_ns_at_anchor)
1271
+ seen_generation = self._generation_fence_by_clock.get(clock_key)
1272
+ if seen_generation is not None and generation < seen_generation:
1273
+ return BarBarrierReason.SCOPE_RESET_REQUIRED
1274
+ if seen_generation is None or generation > seen_generation:
1275
+ return None
1276
+ prior_mapping = self._mapping_by_clock.get(clock_key)
1277
+ if (
1278
+ prior_mapping is not None
1279
+ and scope[7] != prior_mapping
1280
+ and not self._mappings_are_continuous(prior_mapping, scope[7])
1281
+ ):
1282
+ return BarBarrierReason.CLOCK_MAPPING_MISMATCH
1283
+ # An incompatible calibration must take the existing fault-latching
1284
+ # path even when its anchor also moves backwards. Checking the
1285
+ # lifecycle anchor first would return SCOPE_RESET_REQUIRED and leave
1286
+ # the clock usable after the caller supplied contradictory evidence.
1287
+ # Equivalent recalibrations still use the anchor fence below.
1288
+ seen_anchor = self._mapping_fence_by_clock.get(clock_key)
1289
+ if seen_anchor is not None and anchor < seen_anchor:
1290
+ return BarBarrierReason.SCOPE_RESET_REQUIRED
1291
+ return None
1292
+
1293
+ @staticmethod
1294
+ def _mappings_are_continuous(previous: ClockMapping, current: ClockMapping) -> bool:
1295
+ """Check that two calibrations describe one uninterrupted clock."""
1296
+
1297
+ if (
1298
+ previous.clock_domain_id != current.clock_domain_id
1299
+ or previous.connection_generation != current.connection_generation
1300
+ or previous.rules_hash != current.rules_hash
1301
+ ):
1302
+ return False
1303
+ try:
1304
+ expected_current_anchor = previous.map_wall_to_mono_ns(current.wall_utc_at_anchor)
1305
+ except ValueError:
1306
+ return False
1307
+ tolerance = previous.error_bound_ns + current.error_bound_ns
1308
+ return abs(current.mono_ns_at_anchor - expected_current_anchor) <= tolerance
1309
+
1310
+ def _record_scope_lifecycle(self, scope: Tuple[Any, ...]) -> None:
1311
+ """Record connection/mapping fences and activate the bucket watermark."""
1312
+
1313
+ clock_key = self._scope_clock_key(scope)
1314
+ marker = self._scope_connection_marker(scope)
1315
+ generation = marker[0]
1316
+ anchor = marker[1:]
1317
+ seen_generation = self._generation_fence_by_clock.get(clock_key)
1318
+ is_new_connection = seen_generation is None or generation > seen_generation
1319
+ prior_mapping = self._mapping_by_clock.get(clock_key)
1320
+ is_new_mapping = not is_new_connection and prior_mapping != scope[7]
1321
+ if is_new_connection or is_new_mapping:
1322
+ self._generation_fence_by_clock[clock_key] = generation
1323
+ self._mapping_fence_by_clock[clock_key] = anchor
1324
+ self._mapping_by_clock[clock_key] = scope[7]
1325
+ self._bucket_context_by_clock[clock_key] = marker
1326
+ if is_new_connection:
1327
+ self._bucket_watermark_by_clock.pop(clock_key, None)
1328
+ self._active_clock_key = clock_key
1329
+ self._active_connection_marker = marker
1330
+ if self._bucket_context_by_clock.get(clock_key) == marker:
1331
+ self._retired_bucket_end = self._bucket_watermark_by_clock.get(clock_key)
1332
+ else:
1333
+ self._retired_bucket_end = None
1334
+
1335
+ def _record_bucket_watermark(self, bucket_end: datetime) -> None:
1336
+ """Advance the bounded active-connection bucket high-water mark."""
1337
+
1338
+ if self._active_clock_key is None or self._active_connection_marker is None:
1339
+ if self._retired_bucket_end is None or bucket_end > self._retired_bucket_end:
1340
+ self._retired_bucket_end = bucket_end
1341
+ return
1342
+ if (
1343
+ self._bucket_context_by_clock.get(self._active_clock_key)
1344
+ != self._active_connection_marker
1345
+ ):
1346
+ return
1347
+ prior = self._bucket_watermark_by_clock.get(self._active_clock_key)
1348
+ if prior is None or bucket_end > prior:
1349
+ self._bucket_watermark_by_clock[self._active_clock_key] = bucket_end
1350
+ self._retired_bucket_end = bucket_end
1351
+
1352
+ def _latch_scope_fault(self, reason: str) -> None:
1353
+ self._retire_bound_scope()
1354
+ self._scope = None
1355
+ self._clock_fault = reason
1356
+ # A fault invalidates the active consumer pointer, while finalized
1357
+ # inputs remain available through ``finalized_inputs`` for audit.
1358
+ # Keeping the pointer would let an old READY input authorize a quote
1359
+ # after the clock or scope has become unsafe.
1360
+ self._last_input = None
1361
+
1362
+ def _observe_seal(self, seal_received_mono: float) -> None:
1363
+ """Advance the same-domain observation fence on an accepted seal."""
1364
+
1365
+ if self._last_now_mono is None or seal_received_mono > self._last_now_mono:
1366
+ self._last_now_mono = seal_received_mono
1367
+
1368
+ def _candidate_for(self, bar: BarEvidence) -> str:
1369
+ return bar.candidate_id or self.candidate_id
1370
+
1371
+ def _key(self, bar: BarEvidence) -> Tuple[Any, ...]:
1372
+ return (
1373
+ self._candidate_for(bar),
1374
+ bar.trading_day,
1375
+ bar.generation,
1376
+ bar.session_segment,
1377
+ bar.bucket_start,
1378
+ bar.bucket_end,
1379
+ bar.rules_hash,
1380
+ )
1381
+
1382
+ def _core(self, bar: BarEvidence) -> Tuple[Any, ...]:
1383
+ return (self._candidate_for(bar), bar.bucket_start, bar.bucket_end)
1384
+
1385
+ def _identity_reason(self, bar: BarEvidence, key: Tuple[Any, ...]) -> Optional[str]:
1386
+ leg = self._leg_by_symbol.get(bar.symbol)
1387
+ if leg is None:
1388
+ return BarBarrierReason.UNKNOWN_SYMBOL
1389
+ if bar.exchange != leg.exchange:
1390
+ return BarBarrierReason.EXCHANGE_MISMATCH
1391
+ if self.candidate_id and bar.candidate_id != self.candidate_id:
1392
+ return BarBarrierReason.CANDIDATE_MISMATCH
1393
+ if self.expected_rules_hash is not None and bar.rules_hash != self.expected_rules_hash:
1394
+ return BarBarrierReason.RULES_HASH_MISMATCH
1395
+ if (
1396
+ self.expected_clock_domain is not None
1397
+ and bar.clock_domain != self.expected_clock_domain
1398
+ ):
1399
+ return BarBarrierReason.CLOCK_DOMAIN_MISMATCH
1400
+ if self.clock_mode is not None and bar.clock_mode != self.clock_mode:
1401
+ return BarBarrierReason.CLOCK_MODE_MISMATCH
1402
+ if self.clock_mapping is not None and bar.clock_mapping != self.clock_mapping:
1403
+ return BarBarrierReason.CLOCK_MAPPING_MISMATCH
1404
+ if bar.timeframe_seconds is not None and not math.isclose(
1405
+ bar.timeframe_seconds,
1406
+ self.policy.timeframe_seconds,
1407
+ rel_tol=0.0,
1408
+ abs_tol=1.0e-9,
1409
+ ):
1410
+ return BarBarrierReason.BUCKET_MISMATCH
1411
+ if self._scope is not None:
1412
+ current = self._scope
1413
+ incoming = self._scope_for(bar)
1414
+ for index, reason in (
1415
+ (0, BarBarrierReason.CANDIDATE_MISMATCH),
1416
+ (1, BarBarrierReason.TRADING_DAY_MISMATCH),
1417
+ (2, BarBarrierReason.GENERATION_MISMATCH),
1418
+ (3, BarBarrierReason.SESSION_MISMATCH),
1419
+ (4, BarBarrierReason.RULES_HASH_MISMATCH),
1420
+ (5, BarBarrierReason.CLOCK_DOMAIN_MISMATCH),
1421
+ (6, BarBarrierReason.CLOCK_MODE_MISMATCH),
1422
+ (7, BarBarrierReason.CLOCK_MAPPING_MISMATCH),
1423
+ ):
1424
+ if incoming[index] != current[index]:
1425
+ return reason
1426
+ del key
1427
+ return None
1428
+
1429
+ def _bar_reason(self, bar: BarEvidence) -> Optional[str]:
1430
+ if not _bar_quality_is_good(bar) or not bar.complete:
1431
+ return BarBarrierReason.SKIP_INCOMPLETE_MINUTE
1432
+ if bar.volume_complete is not True:
1433
+ return BarBarrierReason.SKIP_INCOMPLETE_MINUTE
1434
+ if not math.isclose(
1435
+ (bar.bucket_end - bar.bucket_start).total_seconds(),
1436
+ self.policy.timeframe_seconds,
1437
+ rel_tol=0.0,
1438
+ abs_tol=1.0e-9,
1439
+ ):
1440
+ return BarBarrierReason.BUCKET_MISMATCH
1441
+ if bar.available_at > bar.bucket_end + timedelta(seconds=self.policy.timeout_seconds):
1442
+ return BarBarrierReason.SKIP_BARRIER_TIMEOUT
1443
+ if bar.seal_received_at < bar.bucket_end:
1444
+ return BarBarrierReason.SKIP_INCOMPLETE_MINUTE
1445
+ if bar.seal_received_at > bar.bucket_end + timedelta(seconds=self.policy.timeout_seconds):
1446
+ return BarBarrierReason.SKIP_BARRIER_TIMEOUT
1447
+ if bar.watermark is None or bar.watermark < bar.bucket_end:
1448
+ return BarBarrierReason.SKIP_INCOMPLETE_MINUTE
1449
+ if bar.trade_count is None or bar.trade_count <= 0 or bar.volume <= 0:
1450
+ return BarBarrierReason.SKIP_INCOMPLETE_MINUTE
1451
+ if bar.high < bar.low or min(bar.open, bar.high, bar.low, bar.close) <= 0:
1452
+ return BarBarrierReason.INVALID_BAR
1453
+ if bar.first_ingest_seq <= 0 or bar.last_ingest_seq <= 0:
1454
+ return BarBarrierReason.INVALID_BAR
1455
+ if bar.max_event_time is not None and bar.max_event_time >= bar.bucket_end:
1456
+ return BarBarrierReason.FUTURE_DATA_REJECTED
1457
+ if bar.first_ingest_seq > bar.last_ingest_seq:
1458
+ return BarBarrierReason.INVALID_BAR
1459
+ if bar.quote_cutoff_seq < bar.last_ingest_seq:
1460
+ return BarBarrierReason.BLOCKED_QUOTE_CUTOFF
1461
+ return None
1462
+
1463
+ @staticmethod
1464
+ def _metadata_mismatch(first: BarEvidence, current: BarEvidence) -> Optional[str]:
1465
+ for field_name, reason in (
1466
+ ("bucket_start", BarBarrierReason.BUCKET_MISMATCH),
1467
+ ("bucket_end", BarBarrierReason.BUCKET_MISMATCH),
1468
+ ("trading_day", BarBarrierReason.TRADING_DAY_MISMATCH),
1469
+ ("generation", BarBarrierReason.GENERATION_MISMATCH),
1470
+ ("session_segment", BarBarrierReason.SESSION_MISMATCH),
1471
+ ("rules_hash", BarBarrierReason.RULES_HASH_MISMATCH),
1472
+ ("clock_domain", BarBarrierReason.CLOCK_DOMAIN_MISMATCH),
1473
+ ("clock_mode", BarBarrierReason.CLOCK_MODE_MISMATCH),
1474
+ ("clock_mapping", BarBarrierReason.CLOCK_MAPPING_MISMATCH),
1475
+ ):
1476
+ if getattr(first, field_name) != getattr(current, field_name):
1477
+ return reason
1478
+ return None
1479
+
1480
+ def _result(
1481
+ self,
1482
+ reason: str,
1483
+ *,
1484
+ key: Optional[Tuple[Any, ...]] = None,
1485
+ reset_warmup: bool = False,
1486
+ decision_input: Optional[MinuteDecisionInput] = None,
1487
+ ) -> BarBarrierResult:
1488
+ result = BarBarrierResult(
1489
+ reason=reason,
1490
+ decision_input=decision_input,
1491
+ key=key,
1492
+ reset_warmup=reset_warmup,
1493
+ )
1494
+ self._last_results.append(result)
1495
+ return result
1496
+
1497
+ def _mark_skipped(self, key: Tuple[Any, ...], reason: str) -> BarBarrierResult:
1498
+ self._skipped[key] = reason
1499
+ self._skipped.move_to_end(key)
1500
+ while len(self._skipped) > self._MAX_RETAINED_INPUTS:
1501
+ self._skipped.popitem(last=False)
1502
+ bucket_end = key[5]
1503
+ self._record_bucket_watermark(bucket_end)
1504
+ self._pending.pop(key, None)
1505
+ self._pending_core = {
1506
+ core: value for core, value in self._pending_core.items() if value != key
1507
+ }
1508
+ self._retire_pending_through(bucket_end)
1509
+ return self._result(reason, key=key, reset_warmup=True)
1510
+
1511
+ def _retire_pending_through(self, bucket_end: datetime) -> None:
1512
+ """Drop older incomplete buckets while retaining only a bounded tombstone cache."""
1513
+
1514
+ for pending_key in list(self._pending):
1515
+ if pending_key[5] > bucket_end:
1516
+ continue
1517
+ self._pending.pop(pending_key, None)
1518
+ self._skipped[pending_key] = BarBarrierReason.LATE_BAR_REJECTED
1519
+ self._skipped.move_to_end(pending_key)
1520
+ self._pending_core = {
1521
+ core: value for core, value in self._pending_core.items() if value in self._pending
1522
+ }
1523
+ while len(self._skipped) > self._MAX_RETAINED_INPUTS:
1524
+ self._skipped.popitem(last=False)
1525
+
1526
+ def _invalidate_scope(self, reason: str) -> Tuple[BarBarrierResult, ...]:
1527
+ """Retire every pending bucket after a scope or clock fault."""
1528
+
1529
+ results = []
1530
+ pending_keys = list(self._pending)
1531
+ self._latch_scope_fault(reason)
1532
+ for key in pending_keys:
1533
+ results.append(self._mark_skipped(key, reason))
1534
+ return tuple(results)
1535
+
1536
+ def _mapping_for(self, pending: Mapping[str, Any]) -> ClockMapping:
1537
+ bars = pending["bars"]
1538
+ return next(iter(bars.values())).clock_mapping
1539
+
1540
+ def _mapped_available_mono(self, pending: Mapping[str, Any]) -> float:
1541
+ mapping = self._mapping_for(pending)
1542
+ mapped = []
1543
+ for bar in pending["bars"].values():
1544
+ if bar.clock_mapping != mapping:
1545
+ raise ValueError("bars use different clock mappings")
1546
+ mapped_ns = mapping.map_wall_to_mono_ns(bar.available_at)
1547
+ if mapped_ns + mapping.error_bound_ns > mapping.valid_until_mono_ns:
1548
+ raise ValueError("clock mapping is expired before bar availability")
1549
+ # Readiness uses the latest mapped instant in the known error
1550
+ # interval, so a small mapping uncertainty cannot yield early data.
1551
+ mapped.append((mapped_ns + mapping.error_bound_ns) / 1_000_000_000.0)
1552
+ return max(mapped)
1553
+
1554
+ def _finalize_pending(
1555
+ self, key: Tuple[Any, ...], pending: Mapping[str, Any]
1556
+ ) -> BarBarrierResult:
1557
+ decision = self._freeze_input(key, pending)
1558
+ skew_reason = self._cross_leg_quote_skew(decision)
1559
+ if skew_reason is not None:
1560
+ return self._mark_skipped(key, skew_reason)
1561
+ self._pending.pop(key, None)
1562
+ self._pending_core.pop(self._core(next(iter(pending["bars"].values()))), None)
1563
+ self._finalized[key] = decision
1564
+ self._finalized.move_to_end(key)
1565
+ while len(self._finalized) > self._MAX_RETAINED_INPUTS:
1566
+ self._finalized.popitem(last=False)
1567
+ bucket_end = key[5]
1568
+ self._record_bucket_watermark(bucket_end)
1569
+ self._retire_pending_through(bucket_end)
1570
+ self._last_input = decision
1571
+ return self._result(BarBarrierReason.READY, key=key, decision_input=decision)
1572
+
1573
+ def ingest(self, bar: Any, *, now_mono: Any = None, now: Any = None) -> BarBarrierResult:
1574
+ """Ingest one feed-created bar without consulting a process clock."""
1575
+
1576
+ if not isinstance(bar, BarEvidence):
1577
+ try:
1578
+ bar = BarEvidence(
1579
+ symbol=_alias(bar, "symbol", "instrument_id", "InstrumentID"),
1580
+ exchange=_alias(bar, "exchange", "exchange_id", "ExchangeID"),
1581
+ bucket_start=_alias(bar, "bucket_start", "start"),
1582
+ bucket_end=_alias(bar, "bucket_end", "end"),
1583
+ available_at=_alias(bar, "available_at", "bar_available_at"),
1584
+ seal_received_mono=_bar_seal_monotonic(bar),
1585
+ trading_day=_alias(bar, "trading_day", "TradingDay", default=_MISSING),
1586
+ generation=_alias(bar, "generation", "connection_generation", default=_MISSING),
1587
+ session_segment=_alias(bar, "session_segment", "session", default=_MISSING),
1588
+ rules_hash=_alias(bar, "rules_hash", default=_MISSING),
1589
+ quality=_alias(bar, "quality", default=_MISSING),
1590
+ volume_complete=_alias(bar, "volume_complete", default=_MISSING),
1591
+ first_ingest_seq=_alias(bar, "first_ingest_seq", default=0),
1592
+ last_ingest_seq=_alias(bar, "last_ingest_seq", default=0),
1593
+ quote_cutoff_seq=_alias(bar, "quote_cutoff_seq", default=_MISSING),
1594
+ bar_id=_alias(bar, "bar_id", default=""),
1595
+ bar_sequence=_alias(bar, "bar_sequence", default=0),
1596
+ closure_reason=_alias(bar, "closure_reason", default="watermark"),
1597
+ watermark=_alias(bar, "watermark", "event_watermark", default=None),
1598
+ max_event_time=_alias(bar, "max_event_time", default=None),
1599
+ open=_alias(bar, "open", default=0.0),
1600
+ high=_alias(bar, "high", default=0.0),
1601
+ low=_alias(bar, "low", default=0.0),
1602
+ close=_alias(bar, "close", default=0.0),
1603
+ volume=_alias(bar, "volume", default=0.0),
1604
+ openinterest=_alias(bar, "openinterest", default=0.0),
1605
+ quote_events=_alias(bar, "quote_events", "quotes", default=()),
1606
+ clock_domain=_alias(bar, "clock_domain", "clock_domain_id", default=_MISSING),
1607
+ clock_mode=_alias(bar, "clock_mode", default=_MISSING),
1608
+ seal_received_at=_alias(
1609
+ bar, "seal_received_at", "received_at", default=_MISSING
1610
+ ),
1611
+ candidate_id=_alias(bar, "candidate_id", default=""),
1612
+ timeframe_seconds=_alias(bar, "timeframe_seconds", default=None),
1613
+ trade_count=_alias(bar, "trade_count", default=None),
1614
+ complete=_alias(bar, "complete", default=_MISSING),
1615
+ clock_mapping=_alias(bar, "clock_mapping", default=_MISSING),
1616
+ )
1617
+ except (TypeError, ValueError, KeyError):
1618
+ return self._result(BarBarrierReason.INVALID_BAR, reset_warmup=True)
1619
+
1620
+ if now_mono is not None and now is not None:
1621
+ try:
1622
+ parsed_now = _mono(now_mono, "now_mono")
1623
+ parsed_alias = _mono(now, "now")
1624
+ except ValueError:
1625
+ results = self._invalidate_scope(BarBarrierReason.CLOCK_INVALID)
1626
+ return (
1627
+ results[-1]
1628
+ if results
1629
+ else self._result(BarBarrierReason.CLOCK_INVALID, reset_warmup=True)
1630
+ )
1631
+ if parsed_now != parsed_alias:
1632
+ results = self._invalidate_scope(BarBarrierReason.CLOCK_INVALID)
1633
+ return (
1634
+ results[-1]
1635
+ if results
1636
+ else self._result(BarBarrierReason.CLOCK_INVALID, reset_warmup=True)
1637
+ )
1638
+ now_mono = parsed_now
1639
+ elif now_mono is None:
1640
+ now_mono = now
1641
+ observed_now: Optional[float] = None
1642
+ if now_mono is not None:
1643
+ try:
1644
+ observed_now = _mono(now_mono, "now_mono")
1645
+ except ValueError:
1646
+ results = self._invalidate_scope(BarBarrierReason.CLOCK_INVALID)
1647
+ return (
1648
+ results[-1]
1649
+ if results
1650
+ else self._result(BarBarrierReason.CLOCK_INVALID, reset_warmup=True)
1651
+ )
1652
+ clock_results = self.advance(observed_now)
1653
+ if clock_results and clock_results[-1].reason in {
1654
+ BarBarrierReason.CLOCK_REGRESSION,
1655
+ BarBarrierReason.CLOCK_INVALID,
1656
+ }:
1657
+ return clock_results[-1]
1658
+
1659
+ key = self._key(bar)
1660
+ core = self._core(bar)
1661
+ existing_core_key = self._pending_core.get(core)
1662
+ existing_pending = self._pending.get(existing_core_key) if existing_core_key else None
1663
+ # A leg that arrives after the first-seal deadline is permanently
1664
+ # late even when its metadata was reconstructed with a fresh mapping.
1665
+ # Evaluate this absolute deadline before scope diagnostics so a late
1666
+ # leg cannot alter the result into a new-scope path.
1667
+ if (
1668
+ existing_pending is not None
1669
+ and bar.seal_received_mono > existing_pending["deadline_mono"]
1670
+ ):
1671
+ return self._mark_skipped(existing_core_key, BarBarrierReason.SKIP_BARRIER_TIMEOUT)
1672
+ identity_reason = self._identity_reason(bar, key)
1673
+ if identity_reason is not None:
1674
+ existing_key = self._pending_core.get(core)
1675
+ if existing_key is not None and identity_reason in {
1676
+ BarBarrierReason.BUCKET_MISMATCH,
1677
+ BarBarrierReason.TRADING_DAY_MISMATCH,
1678
+ BarBarrierReason.GENERATION_MISMATCH,
1679
+ BarBarrierReason.SESSION_MISMATCH,
1680
+ BarBarrierReason.RULES_HASH_MISMATCH,
1681
+ BarBarrierReason.CLOCK_DOMAIN_MISMATCH,
1682
+ BarBarrierReason.CLOCK_MODE_MISMATCH,
1683
+ BarBarrierReason.CLOCK_MAPPING_MISMATCH,
1684
+ }:
1685
+ self._mark_skipped(existing_key, identity_reason)
1686
+ self._latch_scope_fault(identity_reason)
1687
+ elif self._scope is not None and identity_reason in {
1688
+ BarBarrierReason.CANDIDATE_MISMATCH,
1689
+ BarBarrierReason.TRADING_DAY_MISMATCH,
1690
+ BarBarrierReason.GENERATION_MISMATCH,
1691
+ BarBarrierReason.SESSION_MISMATCH,
1692
+ BarBarrierReason.RULES_HASH_MISMATCH,
1693
+ BarBarrierReason.CLOCK_DOMAIN_MISMATCH,
1694
+ BarBarrierReason.CLOCK_MODE_MISMATCH,
1695
+ BarBarrierReason.CLOCK_MAPPING_MISMATCH,
1696
+ }:
1697
+ # A completed bucket has no pending key to invalidate, but a
1698
+ # scope change still retires the old lifecycle globally.
1699
+ self._latch_scope_fault(identity_reason)
1700
+ return self._result(identity_reason, key=key, reset_warmup=True)
1701
+ if self._clock_fault is not None:
1702
+ return self._result(self._clock_fault, key=key, reset_warmup=True)
1703
+ if self._retired_bucket_end is not None and bar.bucket_end <= self._retired_bucket_end:
1704
+ return self._result(BarBarrierReason.LATE_BAR_REJECTED, key=key)
1705
+ if self._last_now_mono is not None and bar.seal_received_mono < self._last_now_mono:
1706
+ self._latch_scope_fault(BarBarrierReason.CLOCK_REGRESSION)
1707
+ return self._mark_skipped(key, BarBarrierReason.CLOCK_REGRESSION)
1708
+ if observed_now is not None and bar.seal_received_mono > observed_now:
1709
+ return self._mark_skipped(key, BarBarrierReason.FUTURE_SEAL_REJECTED)
1710
+ # A seal is itself an observation in the barrier's monotonic domain.
1711
+ # Record it before quality/payload processing so a malformed or
1712
+ # incomplete bar cannot make a later earlier seal look admissible.
1713
+ self._observe_seal(bar.seal_received_mono)
1714
+ bar_reason = self._bar_reason(bar)
1715
+ if bar_reason is not None:
1716
+ return self._mark_skipped(key, bar_reason)
1717
+ if self._scope is None:
1718
+ self._scope = self._scope_for(bar)
1719
+ self._record_scope_lifecycle(self._scope)
1720
+
1721
+ core = self._core(bar)
1722
+ existing_key = self._pending_core.get(core)
1723
+ if existing_key is not None and existing_key != key:
1724
+ first_pending = self._pending.get(existing_key)
1725
+ first_bar = next(iter(first_pending["bars"].values())) if first_pending else None
1726
+ if first_bar is not None:
1727
+ mismatch = self._metadata_mismatch(first_bar, bar)
1728
+ if mismatch is not None:
1729
+ self._mark_skipped(existing_key, mismatch)
1730
+ self._latch_scope_fault(mismatch)
1731
+ return self._result(mismatch, key=key, reset_warmup=True)
1732
+ self._mark_skipped(existing_key, BarBarrierReason.BUCKET_MISMATCH)
1733
+ self._latch_scope_fault(BarBarrierReason.BUCKET_MISMATCH)
1734
+ return self._result(BarBarrierReason.BUCKET_MISMATCH, key=key, reset_warmup=True)
1735
+
1736
+ pending = self._pending.get(key)
1737
+ if pending is not None:
1738
+ if bar.symbol in pending["bars"]:
1739
+ prior = pending["bars"][bar.symbol]
1740
+ reason = (
1741
+ BarBarrierReason.DUPLICATE_BAR
1742
+ if prior.bar_id == bar.bar_id
1743
+ else BarBarrierReason.REVISION_REJECTED
1744
+ )
1745
+ return self._result(reason, key=key)
1746
+ if bar.seal_received_mono > pending["deadline_mono"]:
1747
+ return self._mark_skipped(key, BarBarrierReason.SKIP_BARRIER_TIMEOUT)
1748
+ first_bar = next(iter(pending["bars"].values()))
1749
+ mismatch = self._metadata_mismatch(first_bar, bar)
1750
+ if mismatch is not None:
1751
+ self._mark_skipped(key, mismatch)
1752
+ self._latch_scope_fault(mismatch)
1753
+ return self._result(mismatch, key=key, reset_warmup=True)
1754
+ if bar.seal_received_mono < pending["last_seal_mono"]:
1755
+ self._latch_scope_fault(BarBarrierReason.CLOCK_REGRESSION)
1756
+ return self._mark_skipped(key, BarBarrierReason.CLOCK_REGRESSION)
1757
+ if bar.seal_received_at < pending["last_seal_at"]:
1758
+ self._latch_scope_fault(BarBarrierReason.CLOCK_REGRESSION)
1759
+ return self._mark_skipped(key, BarBarrierReason.CLOCK_REGRESSION)
1760
+ else:
1761
+ if len(self._pending) >= self._MAX_PENDING_BUCKETS:
1762
+ oldest_key = min(self._pending, key=lambda pending_key: pending_key[5])
1763
+ self._mark_skipped(oldest_key, BarBarrierReason.SKIP_BARRIER_TIMEOUT)
1764
+ mapping = bar.clock_mapping
1765
+ try:
1766
+ mapped_hard_deadline = mapping.conservative_deadline_seconds(
1767
+ bar.bucket_end + timedelta(seconds=self.policy.timeout_seconds)
1768
+ )
1769
+ except ValueError:
1770
+ return self._mark_skipped(key, BarBarrierReason.CLOCK_MAPPING_MISMATCH)
1771
+ deadline = min(
1772
+ bar.seal_received_mono + self.policy.timeout_seconds,
1773
+ mapped_hard_deadline,
1774
+ )
1775
+ if deadline < bar.seal_received_mono:
1776
+ return self._mark_skipped(key, BarBarrierReason.SKIP_BARRIER_TIMEOUT)
1777
+ pending = {
1778
+ "bars": {},
1779
+ "first_seal_mono": bar.seal_received_mono,
1780
+ "deadline_mono": deadline,
1781
+ "clock_domain": bar.clock_domain,
1782
+ "clock_mode": bar.clock_mode,
1783
+ "clock_mapping": mapping,
1784
+ "last_seal_mono": bar.seal_received_mono,
1785
+ "last_seal_at": bar.seal_received_at,
1786
+ "complete": False,
1787
+ }
1788
+ self._pending[key] = pending
1789
+ self._pending_core[core] = key
1790
+ pending["bars"][bar.symbol] = bar
1791
+ pending["last_seal_mono"] = max(pending["last_seal_mono"], bar.seal_received_mono)
1792
+ pending["last_seal_at"] = max(pending["last_seal_at"], bar.seal_received_at)
1793
+ if len(pending["bars"]) < len(self.expected_legs):
1794
+ return self._result(BarBarrierReason.WAITING_FOR_LEGS, key=key)
1795
+
1796
+ try:
1797
+ common_available_mono = self._mapped_available_mono(pending)
1798
+ except ValueError:
1799
+ return self._mark_skipped(key, BarBarrierReason.CLOCK_MAPPING_MISMATCH)
1800
+ pending["common_available_mono"] = common_available_mono
1801
+ pending["complete"] = True
1802
+ arrival_mono = max(bar.seal_received_mono for bar in pending["bars"].values())
1803
+ observed = arrival_mono if observed_now is None else observed_now
1804
+ if observed < common_available_mono:
1805
+ return self._result(BarBarrierReason.WAITING_FOR_WATERMARK, key=key)
1806
+ if observed > pending["deadline_mono"]:
1807
+ return self._mark_skipped(key, BarBarrierReason.SKIP_BARRIER_TIMEOUT)
1808
+ pending["ready_mono"] = max(arrival_mono, common_available_mono)
1809
+ return self._finalize_pending(key, pending)
1810
+
1811
+ def _cross_leg_quote_skew(self, decision: MinuteDecisionInput) -> Optional[str]:
1812
+ """Reject a complete quote cohort whose source or receive times skew."""
1813
+
1814
+ latest_source = []
1815
+ latest_receive = []
1816
+ for symbol in (leg.symbol for leg in self.expected_legs):
1817
+ events = decision.accepted_quotes.get(symbol, ())
1818
+ if not events:
1819
+ return None
1820
+ latest = max(
1821
+ events, key=lambda event: _datetime(event["event_time"], "quote.event_time")
1822
+ )
1823
+ latest_source.append(_datetime(latest["event_time"], "quote.event_time"))
1824
+ latest_receive.append(_datetime(latest["received_at"], "quote.received_at"))
1825
+ source_skew_ms = (max(latest_source) - min(latest_source)).total_seconds() * 1000.0
1826
+ receive_skew_ms = (max(latest_receive) - min(latest_receive)).total_seconds() * 1000.0
1827
+ if max(source_skew_ms, receive_skew_ms) > self.policy.max_quote_skew_ms:
1828
+ return BarBarrierReason.BLOCKED_CROSS_LEG_SKEW
1829
+ return None
1830
+
1831
+ def _freeze_input(
1832
+ self, key: Tuple[Any, ...], pending: Mapping[str, Any]
1833
+ ) -> MinuteDecisionInput:
1834
+ bars = pending["bars"]
1835
+ ordered = {leg.symbol: bars[leg.symbol] for leg in self.expected_legs}
1836
+ seals = [bar.seal_received_mono for bar in ordered.values()]
1837
+ available = max(bar.available_at for bar in ordered.values())
1838
+ accepted: Dict[str, Tuple[Mapping[str, Any], ...]] = {}
1839
+ rejected: Dict[str, Tuple[str, ...]] = {}
1840
+ quality: Dict[str, Any] = {}
1841
+ for symbol, bar in ordered.items():
1842
+ valid_events = []
1843
+ reasons = []
1844
+ seen_sequences = set()
1845
+ for event in bar.quote_events:
1846
+ result = _quote_filter(event, bar=bar, max_skew_ms=self.policy.max_quote_skew_ms)
1847
+ if result.accepted and result.event is not None:
1848
+ sequence = result.event["ingest_seq"]
1849
+ if sequence in seen_sequences:
1850
+ reasons.append(BarBarrierReason.QUOTE_DUPLICATE)
1851
+ continue
1852
+ seen_sequences.add(sequence)
1853
+ valid_events.append(result.event)
1854
+ else:
1855
+ reasons.append(result.reason)
1856
+ accepted[symbol] = tuple(valid_events)
1857
+ rejected[symbol] = tuple(reasons)
1858
+ quality[symbol] = {
1859
+ "bar_quality": bar.quality,
1860
+ "volume_complete": bar.volume_complete,
1861
+ "quote_cutoff_seq": bar.quote_cutoff_seq,
1862
+ "quote_rejections": tuple(reasons),
1863
+ }
1864
+ # ``_metadata_mismatch`` has already guaranteed same scope, so the
1865
+ # common key is safe to expose and suitable for a deterministic hash.
1866
+ return MinuteDecisionInput(
1867
+ key=key,
1868
+ bars=ordered,
1869
+ bucket_start=next(iter(ordered.values())).bucket_start,
1870
+ bucket_end=next(iter(ordered.values())).bucket_end,
1871
+ common_available_at=available,
1872
+ bar_ids=tuple(bar.bar_id for bar in ordered.values()),
1873
+ quote_cutoffs={symbol: bar.quote_cutoff_seq for symbol, bar in ordered.items()},
1874
+ accepted_quotes=accepted,
1875
+ quote_rejections=rejected,
1876
+ source_sequences={
1877
+ symbol: (bar.first_ingest_seq, bar.last_ingest_seq)
1878
+ for symbol, bar in ordered.items()
1879
+ },
1880
+ quality_report=quality,
1881
+ trading_day=next(iter(ordered.values())).trading_day,
1882
+ generation=next(iter(ordered.values())).generation,
1883
+ session_segment=next(iter(ordered.values())).session_segment,
1884
+ rules_hash=next(iter(ordered.values())).rules_hash,
1885
+ candidate_id=key[0],
1886
+ clock_domain=next(iter(ordered.values())).clock_domain,
1887
+ clock_mode=next(iter(ordered.values())).clock_mode,
1888
+ barrier_ready_mono=pending.get("ready_mono", max(seals)),
1889
+ deadline_mono=pending["deadline_mono"],
1890
+ clock_mapping=next(iter(ordered.values())).clock_mapping,
1891
+ )
1892
+
1893
+ def advance(self, now_mono: Any) -> Tuple[BarBarrierResult, ...]:
1894
+ """Expire pending buckets using an explicit replay/live monotonic time."""
1895
+
1896
+ try:
1897
+ now = _mono(now_mono, "now_mono")
1898
+ except ValueError:
1899
+ return self._invalidate_scope(BarBarrierReason.CLOCK_INVALID) or (
1900
+ self._result(BarBarrierReason.CLOCK_INVALID, reset_warmup=True),
1901
+ )
1902
+ if self._clock_fault is not None:
1903
+ return (self._result(self._clock_fault, reset_warmup=True),)
1904
+ if self._last_now_mono is not None and now < self._last_now_mono:
1905
+ results = self._invalidate_scope(BarBarrierReason.CLOCK_REGRESSION)
1906
+ return results or (self._result(BarBarrierReason.CLOCK_REGRESSION, reset_warmup=True),)
1907
+ self._last_now_mono = now
1908
+ results = []
1909
+ for key, pending in list(self._pending.items()):
1910
+ if now > pending["deadline_mono"]:
1911
+ results.append(self._mark_skipped(key, BarBarrierReason.SKIP_BARRIER_TIMEOUT))
1912
+ elif pending.get("complete") and now >= pending["common_available_mono"]:
1913
+ pending["ready_mono"] = max(
1914
+ pending["common_available_mono"],
1915
+ max(bar.seal_received_mono for bar in pending["bars"].values()),
1916
+ )
1917
+ results.append(self._finalize_pending(key, pending))
1918
+ return tuple(results)
1919
+
1920
+ def accept_quote(
1921
+ self, event: Any, *, decision_input: Optional[MinuteDecisionInput] = None
1922
+ ) -> QuoteCutoffResult:
1923
+ """Check a quote against an already-frozen input without mutating it.
1924
+
1925
+ A quote arriving after seal can be inspected for diagnostics, but it
1926
+ cannot be admitted into the stored input. This is the key protection
1927
+ against a mutable ``latest_quote`` becoming a historical feature.
1928
+ """
1929
+
1930
+ if self._clock_fault is not None:
1931
+ return QuoteCutoffResult(
1932
+ False,
1933
+ self._clock_fault,
1934
+ symbol=_value(event, "symbol", "instrument_id", "InstrumentID"),
1935
+ )
1936
+ if decision_input is not None:
1937
+ decision_scope = self._scope_from_values(
1938
+ decision_input.candidate_id,
1939
+ decision_input.trading_day,
1940
+ decision_input.generation,
1941
+ decision_input.session_segment,
1942
+ decision_input.rules_hash,
1943
+ decision_input.clock_domain,
1944
+ decision_input.clock_mode,
1945
+ decision_input.clock_mapping,
1946
+ )
1947
+ if self._scope is None or decision_scope != self._scope:
1948
+ return QuoteCutoffResult(
1949
+ False,
1950
+ BarBarrierReason.SCOPE_RESET_REQUIRED,
1951
+ symbol=_value(event, "symbol", "instrument_id", "InstrumentID"),
1952
+ )
1953
+ # A matching scope is necessary but not sufficient. The object
1954
+ # must still be one of this barrier's bounded finalized records;
1955
+ # after reset or finalized-cache eviction, an old immutable
1956
+ # decision remains audit data and cannot regain quote authority.
1957
+ if self._finalized.get(decision_input.key) is not decision_input:
1958
+ return QuoteCutoffResult(
1959
+ False,
1960
+ BarBarrierReason.SCOPE_RESET_REQUIRED,
1961
+ symbol=_value(event, "symbol", "instrument_id", "InstrumentID"),
1962
+ )
1963
+ target = decision_input or self._last_input
1964
+ if target is None:
1965
+ return QuoteCutoffResult(False, BarBarrierReason.NO_FROZEN_INPUT)
1966
+ symbol = _value(event, "symbol", "instrument_id", "InstrumentID")
1967
+ bar = target.bars.get(symbol)
1968
+ if bar is None:
1969
+ return QuoteCutoffResult(False, BarBarrierReason.QUOTE_SCOPE_MISMATCH, symbol=symbol)
1970
+ result = _quote_filter(event, bar=bar, max_skew_ms=self.policy.max_quote_skew_ms)
1971
+ if not result.accepted:
1972
+ return result
1973
+ for existing in target.accepted_quotes.get(symbol, ()):
1974
+ if existing.get("ingest_seq") == result.event.get("ingest_seq"):
1975
+ if existing == result.event:
1976
+ return QuoteCutoffResult(
1977
+ True,
1978
+ BarBarrierReason.READY,
1979
+ symbol=symbol,
1980
+ event=existing,
1981
+ )
1982
+ return QuoteCutoffResult(
1983
+ False,
1984
+ BarBarrierReason.QUOTE_IDENTITY_CONFLICT,
1985
+ symbol=symbol,
1986
+ event=existing,
1987
+ )
1988
+ return QuoteCutoffResult(
1989
+ False,
1990
+ BarBarrierReason.QUOTE_NOT_IN_FROZEN_INPUT,
1991
+ symbol=symbol,
1992
+ )
1993
+
1994
+
1995
+ __all__ = [
1996
+ "BarBarrierPolicy",
1997
+ "BarBarrierReason",
1998
+ "BarBarrierResult",
1999
+ "BarEvidence",
2000
+ "BarLeg",
2001
+ "ClockMapping",
2002
+ "MinuteDecisionInput",
2003
+ "MultiLegBarBarrier",
2004
+ "QuoteCutoffResult",
2005
+ "validate_quote_against_bar",
2006
+ ]