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.
- back_trader_python-1.4.0.dist-info/METADATA +1491 -0
- back_trader_python-1.4.0.dist-info/RECORD +465 -0
- back_trader_python-1.4.0.dist-info/WHEEL +5 -0
- back_trader_python-1.4.0.dist-info/licenses/LICENSE +674 -0
- back_trader_python-1.4.0.dist-info/top_level.txt +1 -0
- backtrader/__init__.py +148 -0
- backtrader/_cerebro/__init__.py +5 -0
- backtrader/_cerebro/channel.py +382 -0
- backtrader/_cerebro/execution.py +377 -0
- backtrader/_cerebro/lifecycle.py +143 -0
- backtrader/_cerebro/notifications.py +150 -0
- backtrader/_cerebro/presentation.py +230 -0
- backtrader/_cerebro/registry.py +593 -0
- backtrader/_cerebro/runnext.py +551 -0
- backtrader/_cerebro/runonce.py +142 -0
- backtrader/analyzer.py +594 -0
- backtrader/analyzers/__init__.py +50 -0
- backtrader/analyzers/annualreturn.py +226 -0
- backtrader/analyzers/calmar.py +165 -0
- backtrader/analyzers/drawdown.py +287 -0
- backtrader/analyzers/leverage.py +112 -0
- backtrader/analyzers/logreturnsrolling.py +190 -0
- backtrader/analyzers/periodstats.py +153 -0
- backtrader/analyzers/positions.py +119 -0
- backtrader/analyzers/pyfolio.py +470 -0
- backtrader/analyzers/returns.py +192 -0
- backtrader/analyzers/sharpe.py +307 -0
- backtrader/analyzers/sharpe_ratio_stats.py +534 -0
- backtrader/analyzers/sqn.py +112 -0
- backtrader/analyzers/timereturn.py +192 -0
- backtrader/analyzers/total_value.py +75 -0
- backtrader/analyzers/tradeanalyzer.py +278 -0
- backtrader/analyzers/transactions.py +141 -0
- backtrader/analyzers/vwr.py +245 -0
- backtrader/bokeh/__init__.py +155 -0
- backtrader/bokeh/analyzers/__init__.py +13 -0
- backtrader/bokeh/analyzers/plot.py +192 -0
- backtrader/bokeh/analyzers/recorder.py +181 -0
- backtrader/bokeh/app.py +1094 -0
- backtrader/bokeh/live/__init__.py +11 -0
- backtrader/bokeh/live/client.py +352 -0
- backtrader/bokeh/live/datahandler.py +346 -0
- backtrader/bokeh/plot_adapter.py +200 -0
- backtrader/bokeh/schemes/__init__.py +14 -0
- backtrader/bokeh/schemes/blackly.py +76 -0
- backtrader/bokeh/schemes/scheme.py +150 -0
- backtrader/bokeh/schemes/tradimo.py +82 -0
- backtrader/bokeh/tab.py +125 -0
- backtrader/bokeh/tabs/__init__.py +30 -0
- backtrader/bokeh/tabs/analyzer.py +120 -0
- backtrader/bokeh/tabs/config.py +154 -0
- backtrader/bokeh/tabs/live.py +109 -0
- backtrader/bokeh/tabs/log.py +185 -0
- backtrader/bokeh/tabs/metadata.py +182 -0
- backtrader/bokeh/tabs/performance.py +359 -0
- backtrader/bokeh/tabs/source.py +70 -0
- backtrader/bokeh/utils/__init__.py +8 -0
- backtrader/bokeh/utils/helpers.py +167 -0
- backtrader/bokeh/webapp.py +164 -0
- backtrader/broker.py +478 -0
- backtrader/brokers/__init__.py +36 -0
- backtrader/brokers/bbroker.py +2576 -0
- backtrader/brokers/btapibroker.py +8227 -0
- backtrader/brokers/hft/__init__.py +89 -0
- backtrader/brokers/hft/binance_bbo.py +625 -0
- backtrader/brokers/hft/binance_bbo_compare.py +1398 -0
- backtrader/brokers/hft/examples.py +1228 -0
- backtrader/brokers/hft/exchange.py +380 -0
- backtrader/brokers/hft/latency.py +309 -0
- backtrader/brokers/hft/matching_core.py +572 -0
- backtrader/brokers/hft/queue.py +238 -0
- backtrader/brokers/hft/recorder.py +88 -0
- backtrader/brokers/hft/state.py +138 -0
- backtrader/brokers/impact_models.py +118 -0
- backtrader/brokers/mixbroker.py +895 -0
- backtrader/brokers/tickbroker.py +1991 -0
- backtrader/btrun/__init__.py +12 -0
- backtrader/btrun/btrun.py +1218 -0
- backtrader/cerebro.py +828 -0
- backtrader/channel.py +682 -0
- backtrader/channels/__init__.py +23 -0
- backtrader/channels/bridge.py +186 -0
- backtrader/channels/funding.py +248 -0
- backtrader/channels/live_queue.py +216 -0
- backtrader/channels/live_validator.py +294 -0
- backtrader/channels/orderbook.py +257 -0
- backtrader/channels/tick.py +202 -0
- backtrader/comminfo.py +665 -0
- backtrader/commissions/__init__.py +106 -0
- backtrader/commissions/ctpoption.py +993 -0
- backtrader/configs/account_config_example.yaml +8 -0
- backtrader/dataseries.py +379 -0
- backtrader/errors.py +106 -0
- backtrader/events.py +980 -0
- backtrader/feed.py +1523 -0
- backtrader/feeds/__init__.py +75 -0
- backtrader/feeds/barrier.py +2006 -0
- backtrader/feeds/blaze.py +118 -0
- backtrader/feeds/btapifeed.py +1538 -0
- backtrader/feeds/btcsv.py +203 -0
- backtrader/feeds/chainer.py +114 -0
- backtrader/feeds/cryptohftdata.py +164 -0
- backtrader/feeds/csvgeneric.py +1205 -0
- backtrader/feeds/ctpcohort.py +1051 -0
- backtrader/feeds/influxfeed.py +158 -0
- backtrader/feeds/livefeed.py +71 -0
- backtrader/feeds/mixed_channel.py +108 -0
- backtrader/feeds/mt4csv.py +42 -0
- backtrader/feeds/pandafeed.py +381 -0
- backtrader/feeds/quandl.py +256 -0
- backtrader/feeds/rollover.py +229 -0
- backtrader/feeds/sierrachart.py +30 -0
- backtrader/feeds/vchart.py +162 -0
- backtrader/feeds/vchartcsv.py +84 -0
- backtrader/feeds/vchartfile.py +153 -0
- backtrader/feeds/yahoo.py +399 -0
- backtrader/fillers.py +148 -0
- backtrader/filters/__init__.py +34 -0
- backtrader/filters/bsplitter.py +127 -0
- backtrader/filters/calendardays.py +121 -0
- backtrader/filters/datafiller.py +192 -0
- backtrader/filters/datafilter.py +74 -0
- backtrader/filters/daysteps.py +96 -0
- backtrader/filters/heikinashi.py +63 -0
- backtrader/filters/renko.py +164 -0
- backtrader/filters/session.py +289 -0
- backtrader/flt.py +80 -0
- backtrader/functions.py +960 -0
- backtrader/indicator.py +449 -0
- backtrader/indicators/__init__.py +148 -0
- backtrader/indicators/accdecoscillator.py +110 -0
- backtrader/indicators/aroon.py +300 -0
- backtrader/indicators/atr.py +315 -0
- backtrader/indicators/awesomeoscillator.py +122 -0
- backtrader/indicators/basicops.py +834 -0
- backtrader/indicators/bollinger.py +223 -0
- backtrader/indicators/cci.py +89 -0
- backtrader/indicators/channels_ext.py +83 -0
- backtrader/indicators/contrib/__init__.py +228 -0
- backtrader/indicators/contrib/absolutely_no_lag_lwma.py +28 -0
- backtrader/indicators/contrib/absolutely_no_lag_lwma_color.py +44 -0
- backtrader/indicators/contrib/accumulation_distribution_line.py +92 -0
- backtrader/indicators/contrib/adx_cross_hull_style_indicator.py +249 -0
- backtrader/indicators/contrib/adxdmi.py +34 -0
- backtrader/indicators/contrib/ai_acceleration_deceleration_oscillator.py +34 -0
- backtrader/indicators/contrib/altr_trend_signal_v22.py +85 -0
- backtrader/indicators/contrib/anchored_momentum_line.py +115 -0
- backtrader/indicators/contrib/any_range_cld_tail_indicator.py +82 -0
- backtrader/indicators/contrib/aroon_horn_sign_indicator.py +96 -0
- backtrader/indicators/contrib/aroon_oscillator_sign_alert.py +50 -0
- backtrader/indicators/contrib/arrows_curves_indicator.py +112 -0
- backtrader/indicators/contrib/as_ctrend_indicator.py +143 -0
- backtrader/indicators/contrib/asimmetric_stoch_nr_indicator.py +187 -0
- backtrader/indicators/contrib/atr_normalize_histogram.py +118 -0
- backtrader/indicators/contrib/average_change_candle.py +165 -0
- backtrader/indicators/contrib/bb_squeeze_indicator.py +60 -0
- backtrader/indicators/contrib/bezier_st_dev_indicator.py +135 -0
- backtrader/indicators/contrib/binary_wave_indicator.py +233 -0
- backtrader/indicators/contrib/blau_c_momentum_indicator.py +123 -0
- backtrader/indicators/contrib/blau_cmi_indicator.py +141 -0
- backtrader/indicators/contrib/blau_csi.py +76 -0
- backtrader/indicators/contrib/blau_ergodic.py +53 -0
- backtrader/indicators/contrib/blau_t_stoch_i.py +72 -0
- backtrader/indicators/contrib/blau_ts_stochastic.py +85 -0
- backtrader/indicators/contrib/blau_tvi.py +55 -0
- backtrader/indicators/contrib/brain_trend2_indicator.py +128 -0
- backtrader/indicators/contrib/brain_trend_signal_proxy.py +47 -0
- backtrader/indicators/contrib/brake_parb_indicator.py +85 -0
- backtrader/indicators/contrib/breakout_bars_trend_v2.py +121 -0
- backtrader/indicators/contrib/bsi_indicator.py +87 -0
- backtrader/indicators/contrib/bulls_bears_eyes.py +67 -0
- backtrader/indicators/contrib/bulls_power.py +56 -0
- backtrader/indicators/contrib/bw_wise_man1_signal.py +102 -0
- backtrader/indicators/contrib/bykov_trend_indicator.py +85 -0
- backtrader/indicators/contrib/candle_stop_color.py +46 -0
- backtrader/indicators/contrib/candles_x_smoothed_indicator.py +69 -0
- backtrader/indicators/contrib/candlesticks_bw.py +45 -0
- backtrader/indicators/contrib/caudate_x_period_candle_color.py +56 -0
- backtrader/indicators/contrib/cci_histogram_indicator.py +53 -0
- backtrader/indicators/contrib/cci_woodies_indicator.py +80 -0
- backtrader/indicators/contrib/center_of_gravity_candle_indicator.py +83 -0
- backtrader/indicators/contrib/center_of_gravity_indicator.py +70 -0
- backtrader/indicators/contrib/cg_oscillator.py +40 -0
- backtrader/indicators/contrib/close_line_cci.py +38 -0
- backtrader/indicators/contrib/close_price_fractals.py +47 -0
- backtrader/indicators/contrib/color3rd_gen_xma_indicator.py +122 -0
- backtrader/indicators/contrib/color_bb_candles_indicator.py +108 -0
- backtrader/indicators/contrib/color_coppock_indicator.py +157 -0
- backtrader/indicators/contrib/color_hma.py +71 -0
- backtrader/indicators/contrib/color_j_variation_indicator.py +53 -0
- backtrader/indicators/contrib/color_metro_de_marker_indicator.py +78 -0
- backtrader/indicators/contrib/color_metro_stochastic_indicator.py +93 -0
- backtrader/indicators/contrib/color_metro_wpr_indicator.py +85 -0
- backtrader/indicators/contrib/color_schaff_de_marker_trend_cycle.py +92 -0
- backtrader/indicators/contrib/color_schaff_trend_cycle_indicator.py +203 -0
- backtrader/indicators/contrib/color_step_xccx_indicator.py +193 -0
- backtrader/indicators/contrib/color_x2_ma.py +49 -0
- backtrader/indicators/contrib/color_x_derivative.py +63 -0
- backtrader/indicators/contrib/color_zerolag_de_marker.py +84 -0
- backtrader/indicators/contrib/corrected_average_indicator.py +127 -0
- backtrader/indicators/contrib/darvas_boxes_system.py +73 -0
- backtrader/indicators/contrib/dema_range_channel_color.py +42 -0
- backtrader/indicators/contrib/derivative_indicator.py +95 -0
- backtrader/indicators/contrib/digital_ft01_indicator.py +112 -0
- backtrader/indicators/contrib/digital_macd.py +200 -0
- backtrader/indicators/contrib/donchian_channels_system.py +45 -0
- backtrader/indicators/contrib/dots_indicator.py +93 -0
- backtrader/indicators/contrib/ef_distance_indicator.py +82 -0
- backtrader/indicators/contrib/ema_rsi_va.py +80 -0
- backtrader/indicators/contrib/envelopes_jp_alonso.py +32 -0
- backtrader/indicators/contrib/f2a_ao_indicator.py +120 -0
- backtrader/indicators/contrib/fatl_filter.py +179 -0
- backtrader/indicators/contrib/fibo_candles_indicator.py +78 -0
- backtrader/indicators/contrib/fine_tuning_ma.py +100 -0
- backtrader/indicators/contrib/fisher_org_v1.py +102 -0
- backtrader/indicators/contrib/fisher_org_v1_sign.py +118 -0
- backtrader/indicators/contrib/force_index_ema.py +96 -0
- backtrader/indicators/contrib/force_index_ema_2.py +27 -0
- backtrader/indicators/contrib/forecast_oscilator.py +145 -0
- backtrader/indicators/contrib/fractal_amambk.py +81 -0
- backtrader/indicators/contrib/frama_series.py +84 -0
- backtrader/indicators/contrib/frasm_av2_indicator.py +104 -0
- backtrader/indicators/contrib/go_indicator.py +93 -0
- backtrader/indicators/contrib/hlr_indicator.py +95 -0
- backtrader/indicators/contrib/hma.py +50 -0
- backtrader/indicators/contrib/i4_drfv2.py +34 -0
- backtrader/indicators/contrib/i4_drfv3.py +38 -0
- backtrader/indicators/contrib/i_anch_mom_indicator.py +72 -0
- backtrader/indicators/contrib/i_de_marker_sign_indicator.py +64 -0
- backtrader/indicators/contrib/i_gap_indicator.py +45 -0
- backtrader/indicators/contrib/i_stoch_komposter_indicator.py +77 -0
- backtrader/indicators/contrib/i_trend_indicator.py +125 -0
- backtrader/indicators/contrib/iamma_indicator.py +39 -0
- backtrader/indicators/contrib/indexed_moving_average.py +33 -0
- backtrader/indicators/contrib/instantaneous_trend_filter_indicator.py +51 -0
- backtrader/indicators/contrib/inverse_reaction_indicator.py +41 -0
- backtrader/indicators/contrib/irsi_sign_indicator.py +95 -0
- backtrader/indicators/contrib/iwpr_sign_indicator.py +59 -0
- backtrader/indicators/contrib/j_brain_trend1_sig_indicator.py +233 -0
- backtrader/indicators/contrib/j_tpo_proxy.py +32 -0
- backtrader/indicators/contrib/jma_slope_indicator.py +73 -0
- backtrader/indicators/contrib/kalman_filter_indicator.py +119 -0
- backtrader/indicators/contrib/kalman_filter_line.py +127 -0
- backtrader/indicators/contrib/kama_indicator.py +150 -0
- backtrader/indicators/contrib/karacatica_indicator.py +99 -0
- backtrader/indicators/contrib/kdj_indicator.py +59 -0
- backtrader/indicators/contrib/kwan_ccc_indicator.py +195 -0
- backtrader/indicators/contrib/kwan_nrp_indicator.py +113 -0
- backtrader/indicators/contrib/kwan_rdp_indicator.py +192 -0
- backtrader/indicators/contrib/laguerre_adx_indicator.py +85 -0
- backtrader/indicators/contrib/laguerre_filter_indicator.py +66 -0
- backtrader/indicators/contrib/laguerre_plus_di_proxy.py +57 -0
- backtrader/indicators/contrib/laguerre_roc_indicator.py +81 -0
- backtrader/indicators/contrib/le_man_signal_indicator.py +63 -0
- backtrader/indicators/contrib/linear_reg_slope_v2_indicator.py +136 -0
- backtrader/indicators/contrib/loco_indicator.py +88 -0
- backtrader/indicators/contrib/lrma_indicator.py +185 -0
- backtrader/indicators/contrib/lsma_angle_indicator.py +106 -0
- backtrader/indicators/contrib/ma_rounding_channel_indicator.py +149 -0
- backtrader/indicators/contrib/macd2_indicator.py +61 -0
- backtrader/indicators/contrib/macd_candle_indicator.py +80 -0
- backtrader/indicators/contrib/malr_indicator.py +77 -0
- backtrader/indicators/contrib/momentum_candle_sign_indicator.py +51 -0
- backtrader/indicators/contrib/moving_average_fn_indicator.py +139 -0
- backtrader/indicators/contrib/mt5_stochastic_close_close.py +57 -0
- backtrader/indicators/contrib/muv_nor_diff_cloud_indicator.py +107 -0
- backtrader/indicators/contrib/non_lag_dot_indicator.py +124 -0
- backtrader/indicators/contrib/nrtr_extr_indicator.py +95 -0
- backtrader/indicators/contrib/nrtr_indicator.py +95 -0
- backtrader/indicators/contrib/p_channel_system.py +40 -0
- backtrader/indicators/contrib/percent_envelope.py +37 -0
- backtrader/indicators/contrib/percentage_crossover_channel.py +47 -0
- backtrader/indicators/contrib/pivot_zig_zag_proxy.py +47 -0
- backtrader/indicators/contrib/price_channel_stop_indicator.py +104 -0
- backtrader/indicators/contrib/price_extreme_channel.py +35 -0
- backtrader/indicators/contrib/qqe_cloud_indicator.py +129 -0
- backtrader/indicators/contrib/ravi_indicator.py +40 -0
- backtrader/indicators/contrib/raw_close_close_stochastic.py +74 -0
- backtrader/indicators/contrib/rd_trend_trigger_indicator.py +51 -0
- backtrader/indicators/contrib/renko_level.py +85 -0
- backtrader/indicators/contrib/renko_line_break.py +91 -0
- backtrader/indicators/contrib/rftl_indicator.py +41 -0
- backtrader/indicators/contrib/rkd_indicator.py +53 -0
- backtrader/indicators/contrib/roc2_vg_indicator.py +68 -0
- backtrader/indicators/contrib/rsi_histogram_indicator.py +43 -0
- backtrader/indicators/contrib/rsi_slowdown.py +57 -0
- backtrader/indicators/contrib/rsioma_v2.py +41 -0
- backtrader/indicators/contrib/rvi_histogram_indicator.py +107 -0
- backtrader/indicators/contrib/safe_adx.py +89 -0
- backtrader/indicators/contrib/shared_strategy_indicators.py +1651 -0
- backtrader/indicators/contrib/sidus_indicator.py +105 -0
- backtrader/indicators/contrib/silver_trend_indicator.py +79 -0
- backtrader/indicators/contrib/sliding_range_color.py +56 -0
- backtrader/indicators/contrib/slow_stoch.py +42 -0
- backtrader/indicators/contrib/smoothed_adx_indicator.py +86 -0
- backtrader/indicators/contrib/smoothed_rsi.py +31 -0
- backtrader/indicators/contrib/spearman_rank_correlation_histogram.py +60 -0
- backtrader/indicators/contrib/stalin_indicator.py +152 -0
- backtrader/indicators/contrib/starter_laguerre_filter.py +62 -0
- backtrader/indicators/contrib/step_manrtr_indicator.py +137 -0
- backtrader/indicators/contrib/stochastic_histogram_indicator.py +143 -0
- backtrader/indicators/contrib/t3_alarm_indicator.py +125 -0
- backtrader/indicators/contrib/t3_average.py +76 -0
- backtrader/indicators/contrib/t3_indicator.py +40 -0
- backtrader/indicators/contrib/the20s_v020_signal.py +93 -0
- backtrader/indicators/contrib/three_candles_indicator.py +70 -0
- backtrader/indicators/contrib/three_line_break_indicator.py +64 -0
- backtrader/indicators/contrib/time_line.py +57 -0
- backtrader/indicators/contrib/trading_channel_index_proxy.py +48 -0
- backtrader/indicators/contrib/trend_arrows_indicator.py +109 -0
- backtrader/indicators/contrib/trend_continuation_indicator.py +127 -0
- backtrader/indicators/contrib/trend_intensity_index_proxy.py +51 -0
- backtrader/indicators/contrib/trend_manager_indicator.py +39 -0
- backtrader/indicators/contrib/tri_x_candle_indicator.py +51 -0
- backtrader/indicators/contrib/trigger_line.py +66 -0
- backtrader/indicators/contrib/triple_ema_rate.py +34 -0
- backtrader/indicators/contrib/trvi_indicator.py +194 -0
- backtrader/indicators/contrib/two_pb_ideal_xosma_indicator.py +127 -0
- backtrader/indicators/contrib/ultra_absolutely_no_lag_lwma_color.py +92 -0
- backtrader/indicators/contrib/ultra_wpr_indicator.py +173 -0
- backtrader/indicators/contrib/up_down_candle_strength.py +68 -0
- backtrader/indicators/contrib/vinin_i_trend_indicator.py +139 -0
- backtrader/indicators/contrib/volume_weighted_ma_indicator.py +78 -0
- backtrader/indicators/contrib/volume_weighted_ma_st_dev_indicator.py +111 -0
- backtrader/indicators/contrib/vwap_close_indicator.py +65 -0
- backtrader/indicators/contrib/vwma_candle.py +57 -0
- backtrader/indicators/contrib/vwma_digit_system.py +70 -0
- backtrader/indicators/contrib/wami.py +43 -0
- backtrader/indicators/contrib/wprsi_signal_indicator.py +105 -0
- backtrader/indicators/contrib/x_de_marker_histogram_vol_direct_indicator.py +145 -0
- backtrader/indicators/contrib/x_fisher_indicator.py +64 -0
- backtrader/indicators/contrib/xcci_histogram_vol_direct_indicator.py +56 -0
- backtrader/indicators/contrib/xcci_histogram_vol_indicator.py +85 -0
- backtrader/indicators/contrib/xma_ichimoku.py +163 -0
- backtrader/indicators/contrib/xma_ishimoku_channel_indicator.py +65 -0
- backtrader/indicators/contrib/xma_ishimoku_line.py +68 -0
- backtrader/indicators/contrib/xma_range_bands_indicator.py +107 -0
- backtrader/indicators/contrib/xmacd_indicator.py +70 -0
- backtrader/indicators/contrib/xrsi_de_marker_histogram.py +67 -0
- backtrader/indicators/contrib/xrsi_histogram_vol_direct_indicator.py +52 -0
- backtrader/indicators/contrib/xrsi_histogram_vol_indicator.py +81 -0
- backtrader/indicators/contrib/xrvi_indicator.py +130 -0
- backtrader/indicators/contrib/zero_lag_macd.py +36 -0
- backtrader/indicators/contrib/zig_zag_recent_pivot_signal.py +90 -0
- backtrader/indicators/contrib/zpf_indicator.py +115 -0
- backtrader/indicators/crossover.py +337 -0
- backtrader/indicators/dema.py +175 -0
- backtrader/indicators/demarker.py +270 -0
- backtrader/indicators/deviation.py +284 -0
- backtrader/indicators/directionalmove.py +1071 -0
- backtrader/indicators/dma.py +112 -0
- backtrader/indicators/dpo.py +96 -0
- backtrader/indicators/dv2.py +56 -0
- backtrader/indicators/ema.py +145 -0
- backtrader/indicators/envelope.py +475 -0
- backtrader/indicators/hadelta.py +198 -0
- backtrader/indicators/heikinashi.py +153 -0
- backtrader/indicators/hma.py +153 -0
- backtrader/indicators/hurst.py +151 -0
- backtrader/indicators/ichimoku.py +267 -0
- backtrader/indicators/kama.py +181 -0
- backtrader/indicators/kst.py +159 -0
- backtrader/indicators/lrsi.py +125 -0
- backtrader/indicators/mabase.py +147 -0
- backtrader/indicators/macd.py +322 -0
- backtrader/indicators/momentum.py +267 -0
- backtrader/indicators/moneyflow.py +237 -0
- backtrader/indicators/mt5atr.py +124 -0
- backtrader/indicators/myind.py +179 -0
- backtrader/indicators/obv.py +94 -0
- backtrader/indicators/ols.py +265 -0
- backtrader/indicators/oscillator.py +161 -0
- backtrader/indicators/percentchange.py +83 -0
- backtrader/indicators/percentrank.py +46 -0
- backtrader/indicators/pivotpoint.py +469 -0
- backtrader/indicators/prettygoodoscillator.py +113 -0
- backtrader/indicators/priceops_ext.py +123 -0
- backtrader/indicators/priceoscillator.py +262 -0
- backtrader/indicators/psar.py +212 -0
- backtrader/indicators/rmi.py +69 -0
- backtrader/indicators/rsi.py +440 -0
- backtrader/indicators/sma.py +141 -0
- backtrader/indicators/smma.py +116 -0
- backtrader/indicators/spread.py +54 -0
- backtrader/indicators/stochastic.py +263 -0
- backtrader/indicators/supertrend.py +436 -0
- backtrader/indicators/trend_ext.py +105 -0
- backtrader/indicators/trix.py +202 -0
- backtrader/indicators/tsi.py +155 -0
- backtrader/indicators/ultimateoscillator.py +158 -0
- backtrader/indicators/vortex.py +62 -0
- backtrader/indicators/williams.py +194 -0
- backtrader/indicators/wma.py +103 -0
- backtrader/indicators/zlema.py +135 -0
- backtrader/indicators/zlind.py +104 -0
- backtrader/linebuffer.py +3155 -0
- backtrader/lineiterator.py +2911 -0
- backtrader/lineroot.py +1106 -0
- backtrader/lineseries.py +2559 -0
- backtrader/live_trading/__init__.py +31 -0
- backtrader/live_trading/interface.py +404 -0
- backtrader/mathsupport.py +94 -0
- backtrader/metabase.py +1804 -0
- backtrader/mixins/__init__.py +21 -0
- backtrader/mixins/singleton.py +118 -0
- backtrader/observer.py +106 -0
- backtrader/observers/__init__.py +45 -0
- backtrader/observers/benchmark.py +126 -0
- backtrader/observers/broker.py +184 -0
- backtrader/observers/buysell.py +144 -0
- backtrader/observers/drawdown.py +161 -0
- backtrader/observers/logreturns.py +113 -0
- backtrader/observers/timereturn.py +86 -0
- backtrader/observers/trade_logger.py +2972 -0
- backtrader/observers/tradelogger.py +6 -0
- backtrader/observers/trades.py +258 -0
- backtrader/order.py +1114 -0
- backtrader/parameters.py +2345 -0
- backtrader/plot/__init__.py +54 -0
- backtrader/plot/finance.py +1022 -0
- backtrader/plot/formatters.py +200 -0
- backtrader/plot/locator.py +353 -0
- backtrader/plot/multicursor.py +495 -0
- backtrader/plot/plot.py +2500 -0
- backtrader/plot/plot_plotly.py +1351 -0
- backtrader/plot/scheme.py +253 -0
- backtrader/plot/utils.py +104 -0
- backtrader/position.py +290 -0
- backtrader/position_modes.py +132 -0
- backtrader/profiles.py +254 -0
- backtrader/reports/__init__.py +39 -0
- backtrader/reports/charts.py +371 -0
- backtrader/reports/performance.py +620 -0
- backtrader/reports/reporter.py +660 -0
- backtrader/resamplerfilter.py +1001 -0
- backtrader/signal.py +118 -0
- backtrader/signals/__init__.py +17 -0
- backtrader/sizer.py +114 -0
- backtrader/sizers/__init__.py +26 -0
- backtrader/sizers/fixedsize.py +161 -0
- backtrader/sizers/percents_sizer.py +119 -0
- backtrader/store.py +221 -0
- backtrader/stores/__init__.py +33 -0
- backtrader/stores/btapistore.py +15506 -0
- backtrader/stores/livestore.py +137 -0
- backtrader/stores/vchartfile.py +96 -0
- backtrader/strategy.py +3655 -0
- backtrader/talib.py +280 -0
- backtrader/test_helpers.py +96 -0
- backtrader/timer.py +358 -0
- backtrader/trade.py +442 -0
- backtrader/tradingcal.py +361 -0
- backtrader/utils/__init__.py +68 -0
- backtrader/utils/autodict.py +251 -0
- backtrader/utils/date.py +71 -0
- backtrader/utils/dateintern.py +509 -0
- backtrader/utils/flushfile.py +94 -0
- backtrader/utils/fractal.py +101 -0
- backtrader/utils/get_metrics.py +101 -0
- backtrader/utils/load_data.py +209 -0
- backtrader/utils/log_message.py +998 -0
- backtrader/utils/ordereddefaultdict.py +75 -0
- backtrader/utils/py3.py +296 -0
- backtrader/version.py +21 -0
- 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
|
+
]
|