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,1538 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Unified bt_api_py-backed live data feed."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import collections
|
|
7
|
+
import copy
|
|
8
|
+
import datetime as _dt
|
|
9
|
+
import math
|
|
10
|
+
import time as _time
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
|
|
13
|
+
from ..channel import Event, EventPriority
|
|
14
|
+
from ..dataseries import TimeFrame
|
|
15
|
+
from ..events import BarEvent
|
|
16
|
+
from ..feed import DataBase
|
|
17
|
+
from ..stores.btapistore import _normalize_bar, _redact_diagnostic
|
|
18
|
+
from ..utils import date2num
|
|
19
|
+
from ..utils.log_message import get_logger
|
|
20
|
+
from .barrier import BarEvidence
|
|
21
|
+
from .ctpcohort import CtpCohortNow
|
|
22
|
+
from .livefeed import LiveFeedBase
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
_LOGGING_HEALTH: "collections.Counter[str]" = collections.Counter()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _safe_log(level, message, *args):
|
|
29
|
+
"""Keep a failing log sink outside feed control flow."""
|
|
30
|
+
try:
|
|
31
|
+
getattr(logger, level)(_redact_diagnostic(message), *map(_redact_diagnostic, args))
|
|
32
|
+
except Exception:
|
|
33
|
+
# The sink itself failed. Calling it again would escape this guard
|
|
34
|
+
# before its health counter is incremented and disrupt feed callbacks.
|
|
35
|
+
_LOGGING_HEALTH["logging_errors"] += 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_UTC = _dt.timezone.utc
|
|
39
|
+
_CTP_INVALID_ABS = 1.0e50
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _set_tick_value(tick, name, value):
|
|
43
|
+
"""Set one normalized field on mapping and object event shapes."""
|
|
44
|
+
if isinstance(tick, dict):
|
|
45
|
+
tick[name] = value
|
|
46
|
+
else:
|
|
47
|
+
setattr(tick, name, value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _finite_market_number(value):
|
|
51
|
+
"""Return a finite market number, rejecting CTP's DBL_MAX-style sentinels."""
|
|
52
|
+
if value in (None, "") or isinstance(value, bool):
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
number = float(value)
|
|
56
|
+
except (TypeError, ValueError, OverflowError):
|
|
57
|
+
return None
|
|
58
|
+
if not math.isfinite(number) or abs(number) >= _CTP_INVALID_ABS:
|
|
59
|
+
return None
|
|
60
|
+
return number
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _as_utc_datetime(value):
|
|
64
|
+
"""Parse an event-time field without silently replacing invalid source time."""
|
|
65
|
+
if isinstance(value, _dt.datetime):
|
|
66
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
67
|
+
return value.replace(tzinfo=_UTC)
|
|
68
|
+
return value.astimezone(_UTC)
|
|
69
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
70
|
+
try:
|
|
71
|
+
return _dt.datetime.fromtimestamp(_coerce_epoch_seconds(value), _UTC)
|
|
72
|
+
except (OSError, OverflowError, TypeError, ValueError):
|
|
73
|
+
return None
|
|
74
|
+
if isinstance(value, str) and value.strip():
|
|
75
|
+
try:
|
|
76
|
+
parsed = _dt.datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
77
|
+
except ValueError:
|
|
78
|
+
return None
|
|
79
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
80
|
+
parsed = parsed.replace(tzinfo=_UTC)
|
|
81
|
+
return parsed.astimezone(_UTC)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _coerce_epoch_seconds(value):
|
|
86
|
+
ts = float(value)
|
|
87
|
+
if ts > 10_000_000_000:
|
|
88
|
+
ts /= 1000.0
|
|
89
|
+
return ts
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _datetime_to_utc_naive(value):
|
|
93
|
+
if value.tzinfo is not None and value.utcoffset() is not None:
|
|
94
|
+
return value.astimezone(_UTC).replace(tzinfo=None)
|
|
95
|
+
return value.replace(tzinfo=None)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _datetime_to_timestamp(value):
|
|
99
|
+
return _datetime_to_utc_naive(value).replace(tzinfo=_UTC).timestamp()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _tick_value(tick, *names, default=None):
|
|
103
|
+
if isinstance(tick, dict):
|
|
104
|
+
for name in names:
|
|
105
|
+
if name in tick and tick[name] is not None:
|
|
106
|
+
return tick[name]
|
|
107
|
+
return default
|
|
108
|
+
|
|
109
|
+
for name in names:
|
|
110
|
+
value = getattr(tick, name, None)
|
|
111
|
+
if value is not None:
|
|
112
|
+
return value
|
|
113
|
+
return default
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _tick_timestamp(tick):
|
|
117
|
+
event_time = _as_utc_datetime(_tick_value(tick, "event_time_utc", default=None))
|
|
118
|
+
if event_time is not None:
|
|
119
|
+
return event_time.timestamp()
|
|
120
|
+
|
|
121
|
+
value = _tick_value(tick, "timestamp", "Timestamp", default=None)
|
|
122
|
+
if value is not None:
|
|
123
|
+
return _coerce_epoch_seconds(value)
|
|
124
|
+
|
|
125
|
+
dt_value = _tick_value(tick, "datetime", "dt", default=None)
|
|
126
|
+
if isinstance(dt_value, _dt.datetime):
|
|
127
|
+
return _datetime_to_timestamp(dt_value)
|
|
128
|
+
if isinstance(dt_value, str) and dt_value:
|
|
129
|
+
try:
|
|
130
|
+
return _datetime_to_timestamp(
|
|
131
|
+
_dt.datetime.fromisoformat(dt_value.replace("Z", "+00:00"))
|
|
132
|
+
)
|
|
133
|
+
except ValueError:
|
|
134
|
+
_safe_log("debug", "btapifeed:132 ignored ValueError")
|
|
135
|
+
|
|
136
|
+
return _coerce_epoch_seconds(_tick_value(tick, "local_time", "LocalTime", default=0.0) or 0.0)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _tick_datetime(tick):
|
|
140
|
+
event_time = _as_utc_datetime(_tick_value(tick, "event_time_utc", default=None))
|
|
141
|
+
if event_time is not None:
|
|
142
|
+
return event_time.replace(tzinfo=None)
|
|
143
|
+
|
|
144
|
+
timestamp_value = _tick_value(tick, "timestamp", "Timestamp", default=None)
|
|
145
|
+
if timestamp_value not in (None, ""):
|
|
146
|
+
try:
|
|
147
|
+
ts = _coerce_epoch_seconds(timestamp_value)
|
|
148
|
+
except (TypeError, ValueError):
|
|
149
|
+
_safe_log("debug", "btapifeed:147 ignored TypeError,ValueError")
|
|
150
|
+
else:
|
|
151
|
+
if ts > 0:
|
|
152
|
+
return _dt.datetime.fromtimestamp(ts, _UTC).replace(tzinfo=None)
|
|
153
|
+
|
|
154
|
+
value = _tick_value(tick, "datetime", "dt", default=None)
|
|
155
|
+
if isinstance(value, _dt.datetime):
|
|
156
|
+
return _datetime_to_utc_naive(value)
|
|
157
|
+
if isinstance(value, str) and value:
|
|
158
|
+
try:
|
|
159
|
+
return _datetime_to_utc_naive(_dt.datetime.fromisoformat(value.replace("Z", "+00:00")))
|
|
160
|
+
except ValueError:
|
|
161
|
+
_safe_log("debug", "btapifeed:159 ignored ValueError")
|
|
162
|
+
return _dt.datetime.fromtimestamp(_tick_timestamp(tick), _UTC).replace(tzinfo=None)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _causal_event_kwargs(event):
|
|
166
|
+
"""Copy standard timing and identity fields into derived events."""
|
|
167
|
+
return {
|
|
168
|
+
key: _tick_value(event, key, default=None)
|
|
169
|
+
for key in (
|
|
170
|
+
"exchange_time",
|
|
171
|
+
"received_wall_time",
|
|
172
|
+
"received_monotonic_ns",
|
|
173
|
+
"clock_domain_id",
|
|
174
|
+
"sequence",
|
|
175
|
+
"previous_sequence",
|
|
176
|
+
"snapshot_or_delta",
|
|
177
|
+
"continuity_status",
|
|
178
|
+
"stale",
|
|
179
|
+
"stale_reason",
|
|
180
|
+
"source",
|
|
181
|
+
"event_id",
|
|
182
|
+
"coalesced_count",
|
|
183
|
+
)
|
|
184
|
+
if _tick_value(event, key, default=None) is not None
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class BtApiFeed(DataBase, LiveFeedBase):
|
|
189
|
+
"""Data feed that backfills and streams bars through BtApiStore.
|
|
190
|
+
|
|
191
|
+
``orderbook_as_ticks=True`` exposes each depth snapshot as a zero-volume
|
|
192
|
+
midpoint tick bar before calling ``notify_orderbook``. This gives native
|
|
193
|
+
broker orders a valid feed price and clock even without trade/bar streams.
|
|
194
|
+
It requires ``timeframe=TimeFrame.Ticks``.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
params = (
|
|
198
|
+
("store", None),
|
|
199
|
+
("provider", "btapi"),
|
|
200
|
+
("historical_bars", None),
|
|
201
|
+
("live_bars", None),
|
|
202
|
+
("backfill_start", True),
|
|
203
|
+
("dispatch_ticks", True),
|
|
204
|
+
("dispatch_orderbooks", True),
|
|
205
|
+
("dispatch_bars", True),
|
|
206
|
+
("orderbook_as_ticks", False),
|
|
207
|
+
("bar_watermark_ms", 500),
|
|
208
|
+
("event_time_max_age", 2.0),
|
|
209
|
+
("receive_time_max_age", 2.0),
|
|
210
|
+
("price_tick", None),
|
|
211
|
+
("clock", None),
|
|
212
|
+
# A caller-owned, calibrated provider invoked at the synchronous
|
|
213
|
+
# strategy-dispatch boundary for strict ctp.quote.v2 ticks. It must
|
|
214
|
+
# return CtpCohortNow in the event's exact monotonic clock domain.
|
|
215
|
+
# There is deliberately no process-clock fallback here.
|
|
216
|
+
("ctp_decision_now_provider", None),
|
|
217
|
+
# A caller-owned adapter from a Feed-owned, immutable closed BarEvent
|
|
218
|
+
# to the public BarEvidence hand-off. The Feed only attaches a
|
|
219
|
+
# successfully validated object; it never invents a clock mapping or
|
|
220
|
+
# candidate scope from process-local state.
|
|
221
|
+
("closed_bar_evidence_provider", None),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def __init__(self, *args, **kwargs):
|
|
225
|
+
"""Initialize the feed, normalize inputs, and prepare internal state.
|
|
226
|
+
|
|
227
|
+
The constructor performs three pieces of work:
|
|
228
|
+
|
|
229
|
+
1. Resolves the :class:`BtApiStore` instance and the data provider
|
|
230
|
+
tag from the parsed parameters and stashes them on the instance
|
|
231
|
+
for quick access during :meth:`start` / :meth:`_load`.
|
|
232
|
+
2. Normalizes the optional pre-supplied ``historical_bars`` and
|
|
233
|
+
``live_bars`` parameters into :class:`collections.deque`
|
|
234
|
+
instances so that :meth:`_load` can ``popleft`` from them in O(1).
|
|
235
|
+
3. Initializes the runtime flags that govern backfill behavior
|
|
236
|
+
(``_history_backfilled``) and bar aggregation
|
|
237
|
+
(``_bar_builder``).
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
*args: Positional arguments forwarded to the
|
|
241
|
+
:class:`backtrader.feed.DataBase` constructor. Typically
|
|
242
|
+
this is just the ``dataname`` (symbol/contract identifier).
|
|
243
|
+
**kwargs: Parameter overrides. Any key matching a name in
|
|
244
|
+
:attr:`params` overrides the corresponding default; unknown
|
|
245
|
+
keys are forwarded to the base class unchanged.
|
|
246
|
+
"""
|
|
247
|
+
super().__init__(*args, **kwargs)
|
|
248
|
+
if self.p.closed_bar_evidence_provider is not None and not callable(
|
|
249
|
+
self.p.closed_bar_evidence_provider
|
|
250
|
+
):
|
|
251
|
+
raise ValueError("closed_bar_evidence_provider must be callable")
|
|
252
|
+
self.store = self.p.store
|
|
253
|
+
self.provider = self.p.provider
|
|
254
|
+
self._history = collections.deque(
|
|
255
|
+
_normalize_bar(bar) for bar in (self.p.historical_bars or [])
|
|
256
|
+
)
|
|
257
|
+
self._live = collections.deque(_normalize_bar(bar) for bar in (self.p.live_bars or []))
|
|
258
|
+
self._live_notified = False
|
|
259
|
+
self._bar_builder = None
|
|
260
|
+
self._bar_builders = collections.OrderedDict()
|
|
261
|
+
self._bar_quality_overrides = collections.defaultdict(set)
|
|
262
|
+
self._max_event_timestamp = None
|
|
263
|
+
self._last_ingest_monotonic_ns = None
|
|
264
|
+
self._last_closed_bucket_end = None
|
|
265
|
+
self._last_connection_generation = None
|
|
266
|
+
self._last_ctp_scope = None
|
|
267
|
+
self._highest_ctp_scope = None
|
|
268
|
+
self._bar_sequence = 0
|
|
269
|
+
# Per-feed opaque marker proves that a strategy callback received the
|
|
270
|
+
# sealed event from this exact Feed instance, rather than a caller
|
|
271
|
+
# constructing a look-alike object around a BarEvidence value.
|
|
272
|
+
self._closed_bar_evidence_dispatch_token = object()
|
|
273
|
+
# This short-lived identity binding is populated immediately before
|
|
274
|
+
# synchronous strategy dispatch and cleared immediately afterward.
|
|
275
|
+
# It prevents a callback hook from retaining the event marker while
|
|
276
|
+
# replacing the immutable evidence object with a different one.
|
|
277
|
+
self._sealed_closed_bar_evidence_by_event_id = {}
|
|
278
|
+
self._tick_consumer_claimed = False
|
|
279
|
+
self._history_backfilled = bool(self._history)
|
|
280
|
+
self._continuity_degraded = False
|
|
281
|
+
self._session_active = False
|
|
282
|
+
|
|
283
|
+
def start(self):
|
|
284
|
+
"""Start the feed, register it, and backfill if configured."""
|
|
285
|
+
new_session = not self._session_active
|
|
286
|
+
if new_session:
|
|
287
|
+
self._live_notified = False
|
|
288
|
+
self._continuity_degraded = False
|
|
289
|
+
claimed_this_start = False
|
|
290
|
+
try:
|
|
291
|
+
super().start()
|
|
292
|
+
if self.p.orderbook_as_ticks and self._timeframe != TimeFrame.Ticks:
|
|
293
|
+
raise ValueError("orderbook_as_ticks requires timeframe=TimeFrame.Ticks")
|
|
294
|
+
|
|
295
|
+
if self.store is None:
|
|
296
|
+
self.store = getattr(self, "_store", None)
|
|
297
|
+
|
|
298
|
+
if self.store is None:
|
|
299
|
+
self._session_active = True
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
self.store.start(data=self)
|
|
303
|
+
self.store.register(self)
|
|
304
|
+
|
|
305
|
+
if self.p.backfill_start and not self._history and not self._history_backfilled:
|
|
306
|
+
try:
|
|
307
|
+
bars = self.store.fetch_history(
|
|
308
|
+
self._dataname,
|
|
309
|
+
timeframe=self._timeframe,
|
|
310
|
+
compression=self._compression,
|
|
311
|
+
)
|
|
312
|
+
self._history.extend(bars)
|
|
313
|
+
self._history_backfilled = True
|
|
314
|
+
except Exception as e:
|
|
315
|
+
_safe_log("warning", "btapifeed:312 fallback on Exception")
|
|
316
|
+
_safe_log("debug", "Failed to backfill history: %s", e)
|
|
317
|
+
|
|
318
|
+
claim = getattr(self.store, "claim_tick_consumer", None)
|
|
319
|
+
if (
|
|
320
|
+
callable(claim)
|
|
321
|
+
and not self.p.orderbook_as_ticks
|
|
322
|
+
and not self._tick_consumer_claimed
|
|
323
|
+
):
|
|
324
|
+
claim(self._dataname, self)
|
|
325
|
+
self._tick_consumer_claimed = True
|
|
326
|
+
claimed_this_start = True
|
|
327
|
+
self.store.subscribe(self._dataname)
|
|
328
|
+
self._session_active = True
|
|
329
|
+
except Exception:
|
|
330
|
+
_safe_log("error", "btapifeed:326 exception before re-raise (Exception)")
|
|
331
|
+
if claimed_this_start and self.store is not None:
|
|
332
|
+
release = getattr(self.store, "release_tick_consumer", None)
|
|
333
|
+
if callable(release):
|
|
334
|
+
release(self._dataname, self)
|
|
335
|
+
self._tick_consumer_claimed = False
|
|
336
|
+
if new_session:
|
|
337
|
+
self._session_active = False
|
|
338
|
+
raise
|
|
339
|
+
|
|
340
|
+
def stop(self):
|
|
341
|
+
"""Stop the feed."""
|
|
342
|
+
try:
|
|
343
|
+
super().stop()
|
|
344
|
+
finally:
|
|
345
|
+
if self._tick_consumer_claimed and self.store is not None:
|
|
346
|
+
release = getattr(self.store, "release_tick_consumer", None)
|
|
347
|
+
if callable(release):
|
|
348
|
+
release(self._dataname, self)
|
|
349
|
+
# A live partial bucket is not a completed market bar. Clear it
|
|
350
|
+
# during teardown without dispatching a synthetic notify_bar after
|
|
351
|
+
# Cerebro has already stopped the strategy.
|
|
352
|
+
self._bar_builders.clear()
|
|
353
|
+
self._bar_builder = None
|
|
354
|
+
self._bar_quality_overrides.clear()
|
|
355
|
+
self._max_event_timestamp = None
|
|
356
|
+
self._last_ingest_monotonic_ns = None
|
|
357
|
+
self._last_closed_bucket_end = None
|
|
358
|
+
self._last_connection_generation = None
|
|
359
|
+
self._last_ctp_scope = None
|
|
360
|
+
self._highest_ctp_scope = None
|
|
361
|
+
self._tick_consumer_claimed = False
|
|
362
|
+
self._session_active = False
|
|
363
|
+
|
|
364
|
+
def islive(self) -> bool:
|
|
365
|
+
"""Return whether this feed has a configured live data source."""
|
|
366
|
+
dataname = getattr(self, "_dataname", None)
|
|
367
|
+
|
|
368
|
+
if self._live:
|
|
369
|
+
return True
|
|
370
|
+
|
|
371
|
+
store = self.store or getattr(self, "_store", None)
|
|
372
|
+
if store is None:
|
|
373
|
+
return bool(self.p.live_bars)
|
|
374
|
+
|
|
375
|
+
# Cerebro queries islive before Store.start. A public BtApi event
|
|
376
|
+
# source is live without the legacy supports_live_* duck protocol.
|
|
377
|
+
if getattr(store, "_sdk_mode", False):
|
|
378
|
+
return True
|
|
379
|
+
|
|
380
|
+
live_cache = getattr(store, "_live_bars", {})
|
|
381
|
+
if dataname is not None and live_cache.get(dataname):
|
|
382
|
+
return True
|
|
383
|
+
|
|
384
|
+
api = getattr(store, "_api", None)
|
|
385
|
+
if api is not None and dataname is not None:
|
|
386
|
+
api_live = self._api_indicates_live(api, dataname)
|
|
387
|
+
if api_live is not None:
|
|
388
|
+
return api_live
|
|
389
|
+
|
|
390
|
+
if getattr(store, "_api_cls", None) is not None:
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
if api is None:
|
|
394
|
+
return True
|
|
395
|
+
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
@staticmethod
|
|
399
|
+
def _api_indicates_live(api, dataname):
|
|
400
|
+
"""Whether the store API reports a live source for ``dataname``.
|
|
401
|
+
|
|
402
|
+
Returns True/False when the API gives a definitive answer, or None when
|
|
403
|
+
it has no opinion (caller falls through to other heuristics). Extracted
|
|
404
|
+
from islive() to flatten the repeated supports_live_* probes.
|
|
405
|
+
"""
|
|
406
|
+
for capability in (
|
|
407
|
+
"supports_live_streaming",
|
|
408
|
+
"supports_live_ticks",
|
|
409
|
+
"supports_live_orderbook",
|
|
410
|
+
):
|
|
411
|
+
if hasattr(api, capability):
|
|
412
|
+
try:
|
|
413
|
+
if bool(getattr(api, capability)(dataname)):
|
|
414
|
+
return True
|
|
415
|
+
except Exception as e:
|
|
416
|
+
_safe_log("warning", "btapifeed:411 fallback on Exception")
|
|
417
|
+
_safe_log("debug", "%s check failed: %s", capability, e)
|
|
418
|
+
|
|
419
|
+
live_ticks = getattr(api, "live_ticks", None)
|
|
420
|
+
if live_ticks is not None:
|
|
421
|
+
return dataname in live_ticks
|
|
422
|
+
|
|
423
|
+
live_orderbooks = getattr(api, "live_orderbooks", None)
|
|
424
|
+
if live_orderbooks is not None:
|
|
425
|
+
return dataname in live_orderbooks
|
|
426
|
+
|
|
427
|
+
live_bars = getattr(api, "live", None)
|
|
428
|
+
if live_bars is not None:
|
|
429
|
+
return dataname in live_bars
|
|
430
|
+
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
def haslivedata(self) -> bool:
|
|
434
|
+
"""Return whether a completed live bar is immediately available.
|
|
435
|
+
|
|
436
|
+
Pending raw ticks/orderbooks are realtime traffic, but they do not
|
|
437
|
+
advance the strategy clock until they aggregate into a completed bar.
|
|
438
|
+
Treating them as live data here makes Cerebro skip qcheck and spin while
|
|
439
|
+
repeatedly draining ticks that produce no bar.
|
|
440
|
+
"""
|
|
441
|
+
if self._live:
|
|
442
|
+
return True
|
|
443
|
+
|
|
444
|
+
store = self.store or getattr(self, "_store", None)
|
|
445
|
+
if store is None:
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
live_cache = getattr(store, "_live_bars", {})
|
|
449
|
+
return bool(live_cache.get(self._dataname))
|
|
450
|
+
|
|
451
|
+
def _load_history(self) -> bool:
|
|
452
|
+
"""Load one historical bar if available."""
|
|
453
|
+
if not self._history:
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
return self._load_bar(self._history.popleft())
|
|
457
|
+
|
|
458
|
+
def _load(self) -> bool:
|
|
459
|
+
"""Load the next historical or live bar."""
|
|
460
|
+
if self._history:
|
|
461
|
+
return self._load_history()
|
|
462
|
+
|
|
463
|
+
# Preserve the causal pair between a completed bar callback and the
|
|
464
|
+
# matching data-line advance. Do not consume newer ticks while an
|
|
465
|
+
# already completed bar is waiting for Strategy.next().
|
|
466
|
+
if self._live:
|
|
467
|
+
self._mark_live()
|
|
468
|
+
return self._load_bar(self._live.popleft())
|
|
469
|
+
|
|
470
|
+
if self.p.orderbook_as_ticks:
|
|
471
|
+
if self._load_orderbook_tick():
|
|
472
|
+
return True
|
|
473
|
+
if self._qcheck > 0:
|
|
474
|
+
_time.sleep(self._qcheck)
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
drained_ticks = self._drain_live_ticks()
|
|
478
|
+
drained_orderbooks = self._drain_live_orderbooks()
|
|
479
|
+
self._flush_ready_bars(reason="load")
|
|
480
|
+
# If this turn already produced a line bar, deliver it before an EOF
|
|
481
|
+
# watermark is allowed to close the following bucket.
|
|
482
|
+
source_exhausted = False if self._live else self._handle_source_exhaustion()
|
|
483
|
+
|
|
484
|
+
if self._live:
|
|
485
|
+
bar = self._live.popleft()
|
|
486
|
+
elif self.store is not None:
|
|
487
|
+
bar = self.store.poll_live(self._dataname)
|
|
488
|
+
else:
|
|
489
|
+
bar = None
|
|
490
|
+
|
|
491
|
+
if bar is None:
|
|
492
|
+
if source_exhausted and not self._bar_builders:
|
|
493
|
+
return False
|
|
494
|
+
if drained_ticks or drained_orderbooks:
|
|
495
|
+
self._mark_live()
|
|
496
|
+
if self._qcheck > 0:
|
|
497
|
+
_time.sleep(self._qcheck)
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
self._mark_live()
|
|
501
|
+
|
|
502
|
+
return self._load_bar(bar)
|
|
503
|
+
|
|
504
|
+
def _check(self, forcedata=None):
|
|
505
|
+
"""Drain live ticks while waiting for the next completed bar."""
|
|
506
|
+
super()._check(forcedata=forcedata)
|
|
507
|
+
if self.p.orderbook_as_ticks:
|
|
508
|
+
return # _load must establish the feed clock before the callback.
|
|
509
|
+
if self._live:
|
|
510
|
+
return # _load must pair the queued callback with its line bar.
|
|
511
|
+
drained_ticks = self._drain_live_ticks()
|
|
512
|
+
drained_orderbooks = self._drain_live_orderbooks()
|
|
513
|
+
self._flush_ready_bars(reason="idle")
|
|
514
|
+
self._handle_source_exhaustion()
|
|
515
|
+
if not self._history and (drained_ticks or drained_orderbooks):
|
|
516
|
+
self._mark_live()
|
|
517
|
+
|
|
518
|
+
def _load_orderbook_tick(self):
|
|
519
|
+
"""Load one snapshot per turn so neither another venue nor the broker starves."""
|
|
520
|
+
if self.store is None:
|
|
521
|
+
return False
|
|
522
|
+
orderbook = self.store.poll_orderbook(self._dataname)
|
|
523
|
+
if orderbook is None:
|
|
524
|
+
return False
|
|
525
|
+
if self._handle_event_health(orderbook):
|
|
526
|
+
if self.p.dispatch_orderbooks:
|
|
527
|
+
self._dispatch_event("orderbook", EventPriority.ORDERBOOK, orderbook)
|
|
528
|
+
else:
|
|
529
|
+
self._mark_event_dropped(orderbook, "orderbook_dispatch_disabled")
|
|
530
|
+
return False
|
|
531
|
+
bids = _tick_value(orderbook, "bids", default=[]) or []
|
|
532
|
+
asks = _tick_value(orderbook, "asks", default=[]) or []
|
|
533
|
+
if not bids or not asks:
|
|
534
|
+
self._mark_event_dropped(orderbook, "orderbook_missing_top_of_book")
|
|
535
|
+
return False
|
|
536
|
+
bid, ask = float(bids[0][0]), float(asks[0][0])
|
|
537
|
+
if not math.isfinite(bid) or not math.isfinite(ask) or bid <= 0 or ask < bid:
|
|
538
|
+
self._mark_event_dropped(orderbook, "orderbook_invalid_top_of_book")
|
|
539
|
+
return False
|
|
540
|
+
midpoint = (bid + ask) / 2.0
|
|
541
|
+
stamp = _tick_timestamp(orderbook)
|
|
542
|
+
bar = BarEvent(
|
|
543
|
+
timestamp=stamp,
|
|
544
|
+
symbol=self._dataname,
|
|
545
|
+
exchange=_tick_value(orderbook, "exchange", default=""),
|
|
546
|
+
asset_type=_tick_value(orderbook, "asset_type", default="futures"),
|
|
547
|
+
local_time=_tick_value(orderbook, "local_time", default=stamp),
|
|
548
|
+
**_causal_event_kwargs(orderbook),
|
|
549
|
+
open=midpoint,
|
|
550
|
+
high=midpoint,
|
|
551
|
+
low=midpoint,
|
|
552
|
+
close=midpoint,
|
|
553
|
+
volume=0.0,
|
|
554
|
+
)
|
|
555
|
+
self._load_bar(
|
|
556
|
+
{
|
|
557
|
+
"datetime": _tick_datetime(orderbook),
|
|
558
|
+
"open": midpoint,
|
|
559
|
+
"high": midpoint,
|
|
560
|
+
"low": midpoint,
|
|
561
|
+
"close": midpoint,
|
|
562
|
+
"volume": 0.0,
|
|
563
|
+
"openinterest": 0.0,
|
|
564
|
+
}
|
|
565
|
+
)
|
|
566
|
+
self._mark_live()
|
|
567
|
+
if self.p.dispatch_orderbooks:
|
|
568
|
+
self._dispatch_event("orderbook", EventPriority.ORDERBOOK, orderbook)
|
|
569
|
+
else:
|
|
570
|
+
self._mark_event_dropped(orderbook, "orderbook_dispatch_disabled")
|
|
571
|
+
if self.p.dispatch_bars:
|
|
572
|
+
self._dispatch_event("bar", EventPriority.BAR, bar)
|
|
573
|
+
return True
|
|
574
|
+
|
|
575
|
+
def _load_bar(self, bar) -> bool:
|
|
576
|
+
"""Write a normalized bar into line buffers."""
|
|
577
|
+
bar = _normalize_bar(bar)
|
|
578
|
+
self.lines.datetime[0] = date2num(bar["datetime"])
|
|
579
|
+
self.lines.open[0] = bar["open"]
|
|
580
|
+
self.lines.high[0] = bar["high"]
|
|
581
|
+
self.lines.low[0] = bar["low"]
|
|
582
|
+
self.lines.close[0] = bar["close"]
|
|
583
|
+
self.lines.volume[0] = bar["volume"]
|
|
584
|
+
self.lines.openinterest[0] = bar["openinterest"]
|
|
585
|
+
return True
|
|
586
|
+
|
|
587
|
+
def _drain_live_ticks(self):
|
|
588
|
+
"""Consume ticks only until the next completed bar boundary.
|
|
589
|
+
|
|
590
|
+
A single ``_load`` turn may inspect many ticks inside one bucket, but
|
|
591
|
+
it must stop as soon as any bar event closes. Otherwise callbacks for
|
|
592
|
+
several future bars can run before the first matching data-line/next
|
|
593
|
+
turn, which makes the strategy observe the final callback repeatedly.
|
|
594
|
+
"""
|
|
595
|
+
if self.store is None or not hasattr(self.store, "poll_tick"):
|
|
596
|
+
return False
|
|
597
|
+
|
|
598
|
+
drained = False
|
|
599
|
+
|
|
600
|
+
while True:
|
|
601
|
+
bar_sequence_before = self._bar_sequence
|
|
602
|
+
tick = self.store.poll_tick(self._dataname)
|
|
603
|
+
if tick is None:
|
|
604
|
+
break
|
|
605
|
+
drained = True
|
|
606
|
+
|
|
607
|
+
self._prepare_tick(tick)
|
|
608
|
+
|
|
609
|
+
if self._handle_event_health(tick):
|
|
610
|
+
if self.p.dispatch_ticks:
|
|
611
|
+
self._dispatch_event(
|
|
612
|
+
channel_type="tick",
|
|
613
|
+
priority=EventPriority.TICK,
|
|
614
|
+
event_data=tick,
|
|
615
|
+
)
|
|
616
|
+
else:
|
|
617
|
+
self._mark_event_dropped(tick, "tick_dispatch_disabled")
|
|
618
|
+
continue
|
|
619
|
+
|
|
620
|
+
if self.p.dispatch_ticks:
|
|
621
|
+
self._dispatch_event(
|
|
622
|
+
channel_type="tick",
|
|
623
|
+
priority=EventPriority.TICK,
|
|
624
|
+
event_data=tick,
|
|
625
|
+
)
|
|
626
|
+
else:
|
|
627
|
+
self._mark_event_dropped(tick, "tick_dispatch_disabled")
|
|
628
|
+
self._ingest_tick(tick)
|
|
629
|
+
self._flush_ready_bars(reason="tick")
|
|
630
|
+
if self._bar_sequence != bar_sequence_before:
|
|
631
|
+
break
|
|
632
|
+
return drained
|
|
633
|
+
|
|
634
|
+
def _handle_source_exhaustion(self):
|
|
635
|
+
"""Finalize an explicitly finite source and report natural EOF.
|
|
636
|
+
|
|
637
|
+
Live transports do not expose this contract and therefore continue to
|
|
638
|
+
return ``None`` while idle. Deterministic replay sources may declare
|
|
639
|
+
both exhaustion and their final event-time watermark. A missing or
|
|
640
|
+
insufficient watermark invalidates any residual bucket rather than
|
|
641
|
+
promoting a partial bar to executable data.
|
|
642
|
+
"""
|
|
643
|
+
|
|
644
|
+
store = self.store
|
|
645
|
+
exhausted = getattr(store, "is_source_exhausted", None) if store is not None else None
|
|
646
|
+
if not callable(exhausted) or not exhausted(self._dataname):
|
|
647
|
+
return False
|
|
648
|
+
|
|
649
|
+
watermark_reader = getattr(store, "get_source_event_time_watermark", None)
|
|
650
|
+
watermark = watermark_reader(self._dataname) if callable(watermark_reader) else None
|
|
651
|
+
watermark_dt = _as_utc_datetime(watermark)
|
|
652
|
+
if watermark_dt is not None:
|
|
653
|
+
watermark_ts = watermark_dt.timestamp()
|
|
654
|
+
if self._max_event_timestamp is None or watermark_ts > self._max_event_timestamp:
|
|
655
|
+
self._max_event_timestamp = watermark_ts
|
|
656
|
+
self._last_ingest_monotonic_ns = self._now_monotonic_ns()
|
|
657
|
+
self._flush_ready_bars(reason="source_exhausted")
|
|
658
|
+
|
|
659
|
+
if self._bar_builders:
|
|
660
|
+
self._flush_ready_bars(reason="source_exhausted_incomplete", force_invalid=True)
|
|
661
|
+
return True
|
|
662
|
+
|
|
663
|
+
def _drain_live_orderbooks(self):
|
|
664
|
+
if self.store is None or not hasattr(self.store, "poll_orderbook"):
|
|
665
|
+
return False
|
|
666
|
+
|
|
667
|
+
drained = False
|
|
668
|
+
|
|
669
|
+
while True:
|
|
670
|
+
orderbook = self.store.poll_orderbook(self._dataname)
|
|
671
|
+
if orderbook is None:
|
|
672
|
+
break
|
|
673
|
+
drained = True
|
|
674
|
+
|
|
675
|
+
self._handle_event_health(orderbook)
|
|
676
|
+
|
|
677
|
+
if self.p.dispatch_orderbooks:
|
|
678
|
+
self._dispatch_event(
|
|
679
|
+
channel_type="orderbook",
|
|
680
|
+
priority=EventPriority.ORDERBOOK,
|
|
681
|
+
event_data=orderbook,
|
|
682
|
+
)
|
|
683
|
+
else:
|
|
684
|
+
self._mark_event_dropped(orderbook, "orderbook_dispatch_disabled")
|
|
685
|
+
return drained
|
|
686
|
+
|
|
687
|
+
def _ingest_tick(self, tick):
|
|
688
|
+
"""Update the current bar builder from a live tick."""
|
|
689
|
+
tick_dt = _tick_datetime(tick)
|
|
690
|
+
tick_ts = _tick_timestamp(tick)
|
|
691
|
+
|
|
692
|
+
price = _finite_market_number(
|
|
693
|
+
_tick_value(tick, "price", "last_price", "LastPrice", default=None)
|
|
694
|
+
)
|
|
695
|
+
if price is None or price <= 0 or not bool(_tick_value(tick, "bar_eligible", default=True)):
|
|
696
|
+
return
|
|
697
|
+
|
|
698
|
+
volume = _finite_market_number(
|
|
699
|
+
_tick_value(tick, "delta_volume", "volume", "Volume", default=0.0)
|
|
700
|
+
)
|
|
701
|
+
if volume is None or volume <= 0:
|
|
702
|
+
return
|
|
703
|
+
openinterest = _finite_market_number(
|
|
704
|
+
_tick_value(tick, "openinterest", "open_interest", "OpenInterest", default=0.0)
|
|
705
|
+
)
|
|
706
|
+
openinterest = max(openinterest or 0.0, 0.0)
|
|
707
|
+
|
|
708
|
+
if self._timeframe == TimeFrame.Ticks:
|
|
709
|
+
self._enqueue_bar_event(
|
|
710
|
+
BarEvent(
|
|
711
|
+
timestamp=tick_ts,
|
|
712
|
+
symbol=self._dataname,
|
|
713
|
+
exchange=_tick_value(tick, "exchange", "exchange_id", "ExchangeID", default=""),
|
|
714
|
+
asset_type=_tick_value(tick, "asset_type", "assetType", default="futures"),
|
|
715
|
+
local_time=_tick_value(tick, "local_time", "LocalTime", default=None),
|
|
716
|
+
**_causal_event_kwargs(tick),
|
|
717
|
+
open=price,
|
|
718
|
+
high=price,
|
|
719
|
+
low=price,
|
|
720
|
+
close=price,
|
|
721
|
+
volume=volume,
|
|
722
|
+
openinterest=openinterest,
|
|
723
|
+
),
|
|
724
|
+
tick_dt,
|
|
725
|
+
)
|
|
726
|
+
return
|
|
727
|
+
|
|
728
|
+
bucket_start = self._get_bucket_start(tick_dt)
|
|
729
|
+
current = self._bar_builders.get(bucket_start)
|
|
730
|
+
if current is None:
|
|
731
|
+
current = self._new_bar_builder(bucket_start, tick, price, volume, openinterest)
|
|
732
|
+
self._bar_builders[bucket_start] = current
|
|
733
|
+
self._bar_builders.move_to_end(bucket_start)
|
|
734
|
+
self._bar_builder = current
|
|
735
|
+
return
|
|
736
|
+
|
|
737
|
+
if bucket_start == current["bucket_start"]:
|
|
738
|
+
current["high"] = max(current["high"], price)
|
|
739
|
+
current["low"] = min(current["low"], price)
|
|
740
|
+
current["close"] = price
|
|
741
|
+
current["volume"] += volume
|
|
742
|
+
current["openinterest"] = openinterest
|
|
743
|
+
current["last_timestamp"] = tick_ts
|
|
744
|
+
current["last_ingest_seq"] = _tick_value(
|
|
745
|
+
tick, "ingest_seq", "sequence", default=current["last_ingest_seq"]
|
|
746
|
+
)
|
|
747
|
+
current["trade_count"] += 1
|
|
748
|
+
for field, aliases, mismatch_flag in (
|
|
749
|
+
("rules_hash", ("rules_hash",), "RULES_HASH_CHANGED"),
|
|
750
|
+
("session_segment", ("session_segment",), "SESSION_SEGMENT_CHANGED"),
|
|
751
|
+
("trading_day", ("trading_day", "TradingDay"), "TRADING_DAY_CHANGED"),
|
|
752
|
+
):
|
|
753
|
+
value = _tick_value(tick, *aliases, default=current[field])
|
|
754
|
+
if value != current[field]:
|
|
755
|
+
current["quality_flags"].add(mismatch_flag)
|
|
756
|
+
current["quality_flags"].update(_tick_value(tick, "quality_flags", default=()) or ())
|
|
757
|
+
return
|
|
758
|
+
|
|
759
|
+
def _new_bar_builder(self, bucket_start, tick, price, volume, openinterest):
|
|
760
|
+
"""Create the mutable state for an in-progress aggregated bar."""
|
|
761
|
+
ingest_seq = _tick_value(tick, "ingest_seq", "sequence", default=0)
|
|
762
|
+
return {
|
|
763
|
+
"bucket_start": bucket_start,
|
|
764
|
+
"bucket_end": self._get_bucket_end(bucket_start),
|
|
765
|
+
"open": price,
|
|
766
|
+
"high": price,
|
|
767
|
+
"low": price,
|
|
768
|
+
"close": price,
|
|
769
|
+
"volume": volume,
|
|
770
|
+
"openinterest": openinterest,
|
|
771
|
+
"last_timestamp": _tick_timestamp(tick),
|
|
772
|
+
"causal": _causal_event_kwargs(tick),
|
|
773
|
+
"exchange": _tick_value(tick, "exchange", "exchange_id", "ExchangeID", default=""),
|
|
774
|
+
"asset_type": _tick_value(tick, "asset_type", "assetType", default="futures"),
|
|
775
|
+
"trading_day": _tick_value(tick, "trading_day", "TradingDay", default=""),
|
|
776
|
+
"action_day": _tick_value(tick, "action_day", "ActionDay", default=""),
|
|
777
|
+
"rules_hash": _tick_value(tick, "rules_hash", default=None),
|
|
778
|
+
"session_segment": _tick_value(tick, "session_segment", default=None),
|
|
779
|
+
"connection_generation": _tick_value(
|
|
780
|
+
tick, "connection_generation", "stream_generation", default=None
|
|
781
|
+
),
|
|
782
|
+
"first_ingest_seq": ingest_seq,
|
|
783
|
+
"last_ingest_seq": ingest_seq,
|
|
784
|
+
"trade_count": 1,
|
|
785
|
+
"volume_complete": bool(_tick_value(tick, "volume_complete", default=True)),
|
|
786
|
+
"quality_flags": set(_tick_value(tick, "quality_flags", default=()) or ()),
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
def _attach_closed_bar_evidence(self, bar_event):
|
|
790
|
+
"""Attach only a scope-consistent caller-produced BarEvidence object.
|
|
791
|
+
|
|
792
|
+
The adapter receives a detached snapshot after the Feed has frozen
|
|
793
|
+
its closed-bar metadata but before the channel callback. Validation
|
|
794
|
+
remains against the Feed-owned event, so a provider cannot mutate its
|
|
795
|
+
input and make a forged result appear scope-consistent. The hand-off
|
|
796
|
+
stays narrow: unlike a strategy it cannot reconstruct evidence from
|
|
797
|
+
mutable line buffers, and unlike the Feed it cannot invent a clock
|
|
798
|
+
mapping or candidate identity.
|
|
799
|
+
"""
|
|
800
|
+
|
|
801
|
+
provider = self.p.closed_bar_evidence_provider
|
|
802
|
+
if provider is None:
|
|
803
|
+
return
|
|
804
|
+
provider_input = SimpleNamespace(**copy.deepcopy(vars(bar_event)))
|
|
805
|
+
evidence = provider(provider_input)
|
|
806
|
+
if not isinstance(evidence, BarEvidence):
|
|
807
|
+
raise ValueError("closed_bar_evidence_provider must return BarEvidence")
|
|
808
|
+
if evidence.symbol != bar_event.symbol or evidence.exchange != bar_event.exchange:
|
|
809
|
+
raise ValueError("closed BarEvidence identity does not match BarEvent")
|
|
810
|
+
for name in (
|
|
811
|
+
"bucket_start",
|
|
812
|
+
"bucket_end",
|
|
813
|
+
"available_at",
|
|
814
|
+
"trading_day",
|
|
815
|
+
"connection_generation",
|
|
816
|
+
"rules_hash",
|
|
817
|
+
"session_segment",
|
|
818
|
+
"first_ingest_seq",
|
|
819
|
+
"last_ingest_seq",
|
|
820
|
+
"quote_cutoff_seq",
|
|
821
|
+
"bar_id",
|
|
822
|
+
"bar_sequence",
|
|
823
|
+
"complete",
|
|
824
|
+
):
|
|
825
|
+
event_name = "generation" if name == "connection_generation" else name
|
|
826
|
+
if getattr(evidence, event_name) != getattr(bar_event, name):
|
|
827
|
+
raise ValueError(f"closed BarEvidence {event_name} does not match BarEvent")
|
|
828
|
+
for name in ("quality", "volume_complete", "closure_reason", "trade_count", "watermark"):
|
|
829
|
+
if getattr(evidence, name) != getattr(bar_event, name):
|
|
830
|
+
raise ValueError(f"closed BarEvidence {name} does not match BarEvent")
|
|
831
|
+
if evidence.max_event_time != getattr(bar_event, "max_event_time"):
|
|
832
|
+
raise ValueError("closed BarEvidence max_event_time does not match BarEvent")
|
|
833
|
+
for name in ("open", "high", "low", "close", "volume", "openinterest"):
|
|
834
|
+
if getattr(evidence, name) != float(getattr(bar_event, name)):
|
|
835
|
+
raise ValueError(f"closed BarEvidence {name} does not match BarEvent")
|
|
836
|
+
if evidence.clock_domain != getattr(bar_event, "clock_domain_id", None):
|
|
837
|
+
raise ValueError("closed BarEvidence clock domain does not match BarEvent")
|
|
838
|
+
setattr(bar_event, "closed_bar_evidence", evidence)
|
|
839
|
+
|
|
840
|
+
def _has_sealed_closed_bar_evidence(self, bar_event, evidence):
|
|
841
|
+
"""Return whether this exact event/evidence pair is still in dispatch."""
|
|
842
|
+
|
|
843
|
+
return self._sealed_closed_bar_evidence_by_event_id.get(id(bar_event)) is evidence
|
|
844
|
+
|
|
845
|
+
def _enqueue_bar_event(self, bar_event, bar_datetime, *, deliver_lines=True):
|
|
846
|
+
"""Queue a completed bar for both notify_bar and line delivery."""
|
|
847
|
+
bar_event.datetime = bar_datetime
|
|
848
|
+
if self.p.dispatch_bars:
|
|
849
|
+
self._dispatch_event(
|
|
850
|
+
channel_type="bar",
|
|
851
|
+
priority=EventPriority.BAR,
|
|
852
|
+
event_data=bar_event,
|
|
853
|
+
)
|
|
854
|
+
if deliver_lines:
|
|
855
|
+
self._live.append(
|
|
856
|
+
{
|
|
857
|
+
"datetime": bar_datetime,
|
|
858
|
+
"open": bar_event.open,
|
|
859
|
+
"high": bar_event.high,
|
|
860
|
+
"low": bar_event.low,
|
|
861
|
+
"close": bar_event.close,
|
|
862
|
+
"volume": bar_event.volume,
|
|
863
|
+
"openinterest": bar_event.openinterest,
|
|
864
|
+
}
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
def _now_monotonic_ns(self):
|
|
868
|
+
clock = self.p.clock
|
|
869
|
+
method = getattr(clock, "monotonic_ns", None) if clock is not None else None
|
|
870
|
+
if callable(method):
|
|
871
|
+
return int(method())
|
|
872
|
+
method = getattr(clock, "monotonic_now", None) if clock is not None else None
|
|
873
|
+
if callable(method):
|
|
874
|
+
return int(float(method()) * 1_000_000_000)
|
|
875
|
+
method = getattr(clock, "monotonic", None) if clock is not None else None
|
|
876
|
+
if callable(method):
|
|
877
|
+
return int(float(method()) * 1_000_000_000)
|
|
878
|
+
return _time.monotonic_ns()
|
|
879
|
+
|
|
880
|
+
def _event_time_watermark(self):
|
|
881
|
+
if self._max_event_timestamp is None:
|
|
882
|
+
return None
|
|
883
|
+
elapsed = 0.0
|
|
884
|
+
if self._last_ingest_monotonic_ns is not None:
|
|
885
|
+
elapsed = (
|
|
886
|
+
max(
|
|
887
|
+
self._now_monotonic_ns() - self._last_ingest_monotonic_ns,
|
|
888
|
+
0,
|
|
889
|
+
)
|
|
890
|
+
/ 1_000_000_000.0
|
|
891
|
+
)
|
|
892
|
+
return self._max_event_timestamp + elapsed
|
|
893
|
+
|
|
894
|
+
def _cached_price_tick(self):
|
|
895
|
+
configured = _finite_market_number(self.p.price_tick)
|
|
896
|
+
if configured is not None and configured > 0:
|
|
897
|
+
return configured
|
|
898
|
+
store = self.store
|
|
899
|
+
metadata = getattr(store, "contract_metadata", {}) if store is not None else {}
|
|
900
|
+
candidates = [self._dataname]
|
|
901
|
+
text = str(self._dataname or "")
|
|
902
|
+
for separator in (".", ":", "/"):
|
|
903
|
+
candidates.extend(part for part in text.split(separator) if part)
|
|
904
|
+
for key in candidates:
|
|
905
|
+
row = metadata.get(key) if isinstance(metadata, dict) else None
|
|
906
|
+
if not isinstance(row, dict):
|
|
907
|
+
continue
|
|
908
|
+
value = _finite_market_number(
|
|
909
|
+
row.get("price_tick") or row.get("tick_size") or row.get("min_price_tick")
|
|
910
|
+
)
|
|
911
|
+
if value is not None and value > 0:
|
|
912
|
+
return value
|
|
913
|
+
return None
|
|
914
|
+
|
|
915
|
+
@staticmethod
|
|
916
|
+
def _on_price_grid(value, price_tick):
|
|
917
|
+
if value is None or price_tick is None:
|
|
918
|
+
return True
|
|
919
|
+
scaled = value / price_tick
|
|
920
|
+
return math.isfinite(scaled) and abs(scaled - round(scaled)) <= 1e-8
|
|
921
|
+
|
|
922
|
+
def _add_bar_quality_override(self, bucket_start, *flags):
|
|
923
|
+
"""Retain blocking evidence only while its minute can still be built."""
|
|
924
|
+
if bucket_start is None:
|
|
925
|
+
return
|
|
926
|
+
bucket_end = self._get_bucket_end(bucket_start)
|
|
927
|
+
if self._last_closed_bucket_end is not None and bucket_end <= self._last_closed_bucket_end:
|
|
928
|
+
return
|
|
929
|
+
self._bar_quality_overrides[bucket_start].update(flag for flag in flags if flag)
|
|
930
|
+
|
|
931
|
+
def _prune_bar_quality_overrides(self, watermark=None):
|
|
932
|
+
"""Discard override-only buckets after their watermark can no longer admit data."""
|
|
933
|
+
if not self._bar_quality_overrides:
|
|
934
|
+
return
|
|
935
|
+
watermark = self._event_time_watermark() if watermark is None else watermark
|
|
936
|
+
watermark_delay = max(float(self.p.bar_watermark_ms or 0.0), 0.0) / 1000.0
|
|
937
|
+
for bucket_start in list(self._bar_quality_overrides):
|
|
938
|
+
if bucket_start in self._bar_builders:
|
|
939
|
+
continue
|
|
940
|
+
bucket_end = self._get_bucket_end(bucket_start)
|
|
941
|
+
already_closed = (
|
|
942
|
+
self._last_closed_bucket_end is not None
|
|
943
|
+
and bucket_end <= self._last_closed_bucket_end
|
|
944
|
+
)
|
|
945
|
+
deadline = bucket_end.replace(tzinfo=_UTC).timestamp() + watermark_delay
|
|
946
|
+
if already_closed or (watermark is not None and deadline <= watermark):
|
|
947
|
+
self._bar_quality_overrides.pop(bucket_start, None)
|
|
948
|
+
|
|
949
|
+
def _prepare_tick(self, tick):
|
|
950
|
+
"""Normalize one tick's schema, quality, ordering and volume semantics."""
|
|
951
|
+
schema = str(_tick_value(tick, "schema_version", default="") or "").strip()
|
|
952
|
+
if not schema:
|
|
953
|
+
schema = "backtrader.tick.v1"
|
|
954
|
+
_set_tick_value(tick, "schema_version", schema)
|
|
955
|
+
semantics = "delta"
|
|
956
|
+
_set_tick_value(tick, "volume_semantics", semantics)
|
|
957
|
+
legacy = True
|
|
958
|
+
else:
|
|
959
|
+
semantics = str(_tick_value(tick, "volume_semantics", default="") or "").strip().lower()
|
|
960
|
+
legacy = False
|
|
961
|
+
|
|
962
|
+
strict_ctp_v2 = schema == "ctp.quote.v2"
|
|
963
|
+
raw_quality_flags = _tick_value(tick, "quality_flags", default=None)
|
|
964
|
+
valid_quality_container = isinstance(raw_quality_flags, (list, tuple, set, frozenset))
|
|
965
|
+
try:
|
|
966
|
+
quality_items = tuple(raw_quality_flags or ()) if valid_quality_container else ()
|
|
967
|
+
except TypeError:
|
|
968
|
+
# A custom collection is allowed by the broad runtime protocol,
|
|
969
|
+
# but a broken iterator must never turn into an uncaught dispatch
|
|
970
|
+
# failure or a clean quote.
|
|
971
|
+
quality_items = ()
|
|
972
|
+
valid_quality_container = False
|
|
973
|
+
valid_quality_items = all(
|
|
974
|
+
isinstance(flag, str) and bool(flag) and flag.strip() == flag for flag in quality_items
|
|
975
|
+
)
|
|
976
|
+
if strict_ctp_v2 and (not valid_quality_container or not valid_quality_items):
|
|
977
|
+
# A V2 producer must make both the evidence container and every
|
|
978
|
+
# flag explicit. Do not coerce malformed input into apparently
|
|
979
|
+
# clean evidence or let an unhashable/non-string item crash the
|
|
980
|
+
# strategy dispatch path.
|
|
981
|
+
flags = {"QUOTE_QUALITY_FLAGS_INVALID"}
|
|
982
|
+
elif not valid_quality_items:
|
|
983
|
+
flags = {"QUOTE_QUALITY_FLAGS_INVALID"}
|
|
984
|
+
else:
|
|
985
|
+
flags = set(quality_items)
|
|
986
|
+
if legacy:
|
|
987
|
+
flags.add("LEGACY_SCHEMA")
|
|
988
|
+
|
|
989
|
+
if semantics in {"delta", "incremental"}:
|
|
990
|
+
delta = _finite_market_number(
|
|
991
|
+
_tick_value(tick, "delta_volume", "volume", "Volume", default=None)
|
|
992
|
+
)
|
|
993
|
+
semantics = "delta"
|
|
994
|
+
elif semantics in {"cumulative", "cum", "total"}:
|
|
995
|
+
# Conversion is owned by the SDK/Store. Feed never differences a
|
|
996
|
+
# declared cumulative value because doing so can double-difference.
|
|
997
|
+
delta = _finite_market_number(_tick_value(tick, "delta_volume", default=None))
|
|
998
|
+
semantics = "cumulative"
|
|
999
|
+
if delta is None:
|
|
1000
|
+
flags.add("DELTA_VOLUME_MISSING")
|
|
1001
|
+
else:
|
|
1002
|
+
delta = None
|
|
1003
|
+
flags.add("VOLUME_SEMANTICS_UNKNOWN")
|
|
1004
|
+
if delta is None or delta < 0:
|
|
1005
|
+
flags.add("DELTA_VOLUME_INVALID")
|
|
1006
|
+
delta = 0.0
|
|
1007
|
+
_set_tick_value(tick, "volume_semantics", semantics)
|
|
1008
|
+
_set_tick_value(tick, "delta_volume", delta)
|
|
1009
|
+
|
|
1010
|
+
cumulative = _finite_market_number(
|
|
1011
|
+
_tick_value(tick, "cum_volume", "cumulative_volume", default=None)
|
|
1012
|
+
)
|
|
1013
|
+
if cumulative is not None:
|
|
1014
|
+
_set_tick_value(tick, "cum_volume", cumulative)
|
|
1015
|
+
_set_tick_value(tick, "cumulative_volume", cumulative)
|
|
1016
|
+
|
|
1017
|
+
price = _finite_market_number(
|
|
1018
|
+
_tick_value(tick, "price", "last_price", "LastPrice", default=None)
|
|
1019
|
+
)
|
|
1020
|
+
bid = _finite_market_number(_tick_value(tick, "bid_price", "BidPrice1", default=None))
|
|
1021
|
+
ask = _finite_market_number(_tick_value(tick, "ask_price", "AskPrice1", default=None))
|
|
1022
|
+
bid_size = _finite_market_number(
|
|
1023
|
+
_tick_value(tick, "bid_volume", "bid_size", "BidVolume1", default=None)
|
|
1024
|
+
)
|
|
1025
|
+
ask_size = _finite_market_number(
|
|
1026
|
+
_tick_value(tick, "ask_volume", "ask_size", "AskVolume1", default=None)
|
|
1027
|
+
)
|
|
1028
|
+
ctp_schema = schema.startswith("ctp.")
|
|
1029
|
+
if price is None or price <= 0:
|
|
1030
|
+
flags.add("LAST_PRICE_INVALID")
|
|
1031
|
+
if ctp_schema:
|
|
1032
|
+
if bid is None or bid <= 0:
|
|
1033
|
+
flags.add("BID_PRICE_INVALID")
|
|
1034
|
+
if ask is None or ask <= 0:
|
|
1035
|
+
flags.add("ASK_PRICE_INVALID")
|
|
1036
|
+
if bid_size is None or bid_size < 0:
|
|
1037
|
+
flags.add("BID_SIZE_INVALID")
|
|
1038
|
+
elif bid_size == 0:
|
|
1039
|
+
flags.add("BID_DEPTH_ZERO")
|
|
1040
|
+
if ask_size is None or ask_size < 0:
|
|
1041
|
+
flags.add("ASK_SIZE_INVALID")
|
|
1042
|
+
elif ask_size == 0:
|
|
1043
|
+
flags.add("ASK_DEPTH_ZERO")
|
|
1044
|
+
if bid is not None and ask is not None and bid > ask:
|
|
1045
|
+
flags.add("CROSSED_BOOK")
|
|
1046
|
+
|
|
1047
|
+
price_tick = self._cached_price_tick()
|
|
1048
|
+
if ctp_schema and price_tick is None:
|
|
1049
|
+
flags.add("PRICE_TICK_UNKNOWN")
|
|
1050
|
+
elif price_tick is not None:
|
|
1051
|
+
for name, value in (("LAST", price), ("BID", bid), ("ASK", ask)):
|
|
1052
|
+
if value is not None and value > 0 and not self._on_price_grid(value, price_tick):
|
|
1053
|
+
flags.add(f"{name}_PRICE_OFF_GRID")
|
|
1054
|
+
|
|
1055
|
+
upstream_execution_eligible = _tick_value(
|
|
1056
|
+
tick,
|
|
1057
|
+
"execution_eligible",
|
|
1058
|
+
default=None,
|
|
1059
|
+
)
|
|
1060
|
+
if strict_ctp_v2:
|
|
1061
|
+
# ``BtApiFeed`` is a consumer-side quality boundary, not an
|
|
1062
|
+
# authority that may promote a hand-built or incomplete V2 quote.
|
|
1063
|
+
# The SDK/Store must explicitly attest the upstream decision; this
|
|
1064
|
+
# Feed only keeps it false when any local gate also fails.
|
|
1065
|
+
if upstream_execution_eligible is not True:
|
|
1066
|
+
flags.add("UPSTREAM_EXECUTION_INELIGIBLE")
|
|
1067
|
+
if _tick_value(tick, "source_clock_quality", default="") != "verified":
|
|
1068
|
+
flags.add("SOURCE_CLOCK_UNVERIFIED")
|
|
1069
|
+
if _tick_value(tick, "receive_clock_quality", default="") != "verified":
|
|
1070
|
+
flags.add("RECEIVE_CLOCK_UNVERIFIED")
|
|
1071
|
+
if _tick_value(tick, "freshness_verified", default=False) is not True:
|
|
1072
|
+
flags.add("FRESHNESS_UNVERIFIED")
|
|
1073
|
+
if _tick_value(tick, "stale", default=None) is not False:
|
|
1074
|
+
flags.add("STREAM_UNREADY")
|
|
1075
|
+
if _tick_value(tick, "stale_reason", default=None) != "":
|
|
1076
|
+
flags.add("STREAM_UNREADY")
|
|
1077
|
+
raw_event_time = _tick_value(tick, "event_time_utc", default=None)
|
|
1078
|
+
if strict_ctp_v2 and raw_event_time in (None, ""):
|
|
1079
|
+
flags.add("EVENT_TIME_MISSING")
|
|
1080
|
+
event_dt = _as_utc_datetime(
|
|
1081
|
+
raw_event_time
|
|
1082
|
+
if raw_event_time not in (None, "")
|
|
1083
|
+
else _tick_value(tick, "timestamp", "datetime", default=None)
|
|
1084
|
+
)
|
|
1085
|
+
if event_dt is None:
|
|
1086
|
+
flags.add("EVENT_TIME_INVALID")
|
|
1087
|
+
raw_recv_time = _tick_value(tick, "recv_time_utc", default=None)
|
|
1088
|
+
if strict_ctp_v2 and raw_recv_time in (None, ""):
|
|
1089
|
+
flags.add("RECV_TIME_MISSING")
|
|
1090
|
+
received_wall = _as_utc_datetime(
|
|
1091
|
+
raw_recv_time
|
|
1092
|
+
if raw_recv_time not in (None, "")
|
|
1093
|
+
else _tick_value(tick, "received_wall_time", "local_time", default=None)
|
|
1094
|
+
)
|
|
1095
|
+
if strict_ctp_v2 and received_wall is None:
|
|
1096
|
+
flags.add("RECV_TIME_INVALID")
|
|
1097
|
+
if received_wall is not None and event_dt is not None:
|
|
1098
|
+
event_age = (received_wall - event_dt).total_seconds()
|
|
1099
|
+
_set_tick_value(tick, "event_age_seconds", event_age)
|
|
1100
|
+
maximum = max(float(self.p.event_time_max_age or 0.0), 0.0)
|
|
1101
|
+
if ctp_schema and (event_age < -0.5 or (maximum and event_age > maximum)):
|
|
1102
|
+
flags.add("EVENT_TIME_STALE")
|
|
1103
|
+
|
|
1104
|
+
raw_recv_mono = _tick_value(tick, "recv_monotonic_ns", default=None)
|
|
1105
|
+
if strict_ctp_v2 and raw_recv_mono in (None, ""):
|
|
1106
|
+
flags.add("RECV_MONOTONIC_MISSING")
|
|
1107
|
+
recv_mono = (
|
|
1108
|
+
raw_recv_mono
|
|
1109
|
+
if raw_recv_mono not in (None, "")
|
|
1110
|
+
else _tick_value(tick, "received_monotonic_ns", default=None)
|
|
1111
|
+
)
|
|
1112
|
+
if isinstance(recv_mono, int) and recv_mono > 0:
|
|
1113
|
+
recv_age = max(self._now_monotonic_ns() - recv_mono, 0) / 1_000_000_000.0
|
|
1114
|
+
_set_tick_value(tick, "recv_age_seconds", recv_age)
|
|
1115
|
+
maximum = max(float(self.p.receive_time_max_age or 0.0), 0.0)
|
|
1116
|
+
if ctp_schema and maximum and recv_age > maximum:
|
|
1117
|
+
flags.add("RECEIVE_TIME_STALE")
|
|
1118
|
+
elif strict_ctp_v2:
|
|
1119
|
+
flags.add("RECV_MONOTONIC_INVALID")
|
|
1120
|
+
|
|
1121
|
+
tick_ts = event_dt.timestamp() if event_dt is not None else None
|
|
1122
|
+
raw_timestamp = _finite_market_number(
|
|
1123
|
+
_tick_value(tick, "timestamp", "Timestamp", default=None)
|
|
1124
|
+
)
|
|
1125
|
+
if strict_ctp_v2 and event_dt is not None and raw_timestamp is not None:
|
|
1126
|
+
raw_timestamp = _coerce_epoch_seconds(raw_timestamp)
|
|
1127
|
+
if abs(raw_timestamp - tick_ts) > 1.0e-6:
|
|
1128
|
+
flags.add("EVENT_TIME_CONFLICT")
|
|
1129
|
+
prior_watermark = self._event_time_watermark()
|
|
1130
|
+
bucket_start = (
|
|
1131
|
+
self._get_bucket_start(event_dt.replace(tzinfo=None)) if event_dt is not None else None
|
|
1132
|
+
)
|
|
1133
|
+
bucket_end = self._get_bucket_end(bucket_start) if bucket_start is not None else None
|
|
1134
|
+
bucket_end_ts = (
|
|
1135
|
+
bucket_end.replace(tzinfo=_UTC).timestamp() if bucket_end is not None else None
|
|
1136
|
+
)
|
|
1137
|
+
watermark_delay = max(float(self.p.bar_watermark_ms or 0.0), 0.0) / 1000.0
|
|
1138
|
+
if (
|
|
1139
|
+
self._timeframe != TimeFrame.Ticks
|
|
1140
|
+
and prior_watermark is not None
|
|
1141
|
+
and bucket_end_ts is not None
|
|
1142
|
+
and bucket_end_ts + watermark_delay <= prior_watermark
|
|
1143
|
+
):
|
|
1144
|
+
flags.add("LATE_AFTER_WATERMARK")
|
|
1145
|
+
elif (
|
|
1146
|
+
self._max_event_timestamp is not None
|
|
1147
|
+
and tick_ts is not None
|
|
1148
|
+
and tick_ts < self._max_event_timestamp
|
|
1149
|
+
):
|
|
1150
|
+
flags.add("OUT_OF_ORDER_EVENT_TIME")
|
|
1151
|
+
if delta > 0:
|
|
1152
|
+
flags.add("ORDERING_VOLUME_GAP")
|
|
1153
|
+
if bucket_start is not None:
|
|
1154
|
+
self._add_bar_quality_override(bucket_start, "ORDERING_VOLUME_GAP")
|
|
1155
|
+
current_start = self._get_bucket_start(
|
|
1156
|
+
_dt.datetime.fromtimestamp(self._max_event_timestamp, _UTC).replace(tzinfo=None)
|
|
1157
|
+
)
|
|
1158
|
+
self._add_bar_quality_override(current_start, "ORDERING_VOLUME_GAP")
|
|
1159
|
+
|
|
1160
|
+
generation = _tick_value(tick, "connection_generation", "stream_generation", default=None)
|
|
1161
|
+
subscription_epoch = _tick_value(tick, "subscription_epoch", default=None)
|
|
1162
|
+
retired_ctp_scope = False
|
|
1163
|
+
if strict_ctp_v2:
|
|
1164
|
+
scope_is_valid = (
|
|
1165
|
+
type(generation) is int
|
|
1166
|
+
and generation > 0
|
|
1167
|
+
and type(subscription_epoch) is int
|
|
1168
|
+
and subscription_epoch > 0
|
|
1169
|
+
)
|
|
1170
|
+
if not scope_is_valid:
|
|
1171
|
+
flags.add("CTP_SCOPE_INVALID")
|
|
1172
|
+
else:
|
|
1173
|
+
scope = (generation, subscription_epoch)
|
|
1174
|
+
if self._highest_ctp_scope is not None and scope < self._highest_ctp_scope:
|
|
1175
|
+
# A delayed callback from an old connection/subscribe
|
|
1176
|
+
# scope must not reopen a retired stream after a newer
|
|
1177
|
+
# scope has been observed. In particular, `(8, 1)` is
|
|
1178
|
+
# newer than `(7, 99)` because generation dominates.
|
|
1179
|
+
flags.add("RETIRED_CONNECTION_SCOPE")
|
|
1180
|
+
retired_ctp_scope = True
|
|
1181
|
+
elif self._highest_ctp_scope is not None and scope != self._highest_ctp_scope:
|
|
1182
|
+
for builder in self._bar_builders.values():
|
|
1183
|
+
builder["quality_flags"].add(
|
|
1184
|
+
(
|
|
1185
|
+
"CONNECTION_GENERATION_CHANGED"
|
|
1186
|
+
if generation != self._highest_ctp_scope[0]
|
|
1187
|
+
else "SUBSCRIPTION_EPOCH_CHANGED"
|
|
1188
|
+
)
|
|
1189
|
+
)
|
|
1190
|
+
self._flush_ready_bars(
|
|
1191
|
+
reason=(
|
|
1192
|
+
"generation"
|
|
1193
|
+
if generation != self._highest_ctp_scope[0]
|
|
1194
|
+
else "subscription_epoch"
|
|
1195
|
+
),
|
|
1196
|
+
force_invalid=True,
|
|
1197
|
+
)
|
|
1198
|
+
self._max_event_timestamp = None
|
|
1199
|
+
flags.add(
|
|
1200
|
+
(
|
|
1201
|
+
"CONNECTION_GENERATION_CHANGED"
|
|
1202
|
+
if generation != self._highest_ctp_scope[0]
|
|
1203
|
+
else "SUBSCRIPTION_EPOCH_CHANGED"
|
|
1204
|
+
)
|
|
1205
|
+
)
|
|
1206
|
+
self._add_bar_quality_override(
|
|
1207
|
+
bucket_start,
|
|
1208
|
+
(
|
|
1209
|
+
"CONNECTION_GENERATION_CHANGED"
|
|
1210
|
+
if generation != self._highest_ctp_scope[0]
|
|
1211
|
+
else "SUBSCRIPTION_EPOCH_CHANGED"
|
|
1212
|
+
),
|
|
1213
|
+
)
|
|
1214
|
+
if not retired_ctp_scope:
|
|
1215
|
+
self._highest_ctp_scope = scope
|
|
1216
|
+
self._last_ctp_scope = scope
|
|
1217
|
+
self._last_connection_generation = generation
|
|
1218
|
+
elif generation not in (None, ""):
|
|
1219
|
+
if (
|
|
1220
|
+
self._last_connection_generation is not None
|
|
1221
|
+
and generation != self._last_connection_generation
|
|
1222
|
+
):
|
|
1223
|
+
for builder in self._bar_builders.values():
|
|
1224
|
+
builder["quality_flags"].add("CONNECTION_GENERATION_CHANGED")
|
|
1225
|
+
self._flush_ready_bars(reason="generation", force_invalid=True)
|
|
1226
|
+
self._max_event_timestamp = None
|
|
1227
|
+
flags.add("CONNECTION_GENERATION_CHANGED")
|
|
1228
|
+
self._add_bar_quality_override(bucket_start, "CONNECTION_GENERATION_CHANGED")
|
|
1229
|
+
self._last_connection_generation = generation
|
|
1230
|
+
|
|
1231
|
+
if (
|
|
1232
|
+
self._timeframe != TimeFrame.Ticks
|
|
1233
|
+
and bucket_end is not None
|
|
1234
|
+
and self._last_closed_bucket_end is not None
|
|
1235
|
+
and bucket_end <= self._last_closed_bucket_end
|
|
1236
|
+
):
|
|
1237
|
+
flags.add("BUCKET_ALREADY_CLOSED")
|
|
1238
|
+
|
|
1239
|
+
if tick_ts is not None and "EVENT_TIME_CONFLICT" not in flags and not retired_ctp_scope:
|
|
1240
|
+
if self._max_event_timestamp is None or tick_ts >= self._max_event_timestamp:
|
|
1241
|
+
self._max_event_timestamp = tick_ts
|
|
1242
|
+
self._last_ingest_monotonic_ns = self._now_monotonic_ns()
|
|
1243
|
+
|
|
1244
|
+
blocking = {
|
|
1245
|
+
flag
|
|
1246
|
+
for flag in flags
|
|
1247
|
+
if flag
|
|
1248
|
+
not in {
|
|
1249
|
+
"LEGACY_SCHEMA",
|
|
1250
|
+
"NO_TRADE",
|
|
1251
|
+
"VOLUME_BASELINE",
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
volume_complete = bool(_tick_value(tick, "volume_complete", default=not ctp_schema))
|
|
1255
|
+
if ctp_schema and not volume_complete and delta > 0:
|
|
1256
|
+
blocking.add("VOLUME_INCOMPLETE")
|
|
1257
|
+
flags.add("VOLUME_INCOMPLETE")
|
|
1258
|
+
# A rejected snapshot can still prove that an already-open bucket is
|
|
1259
|
+
# incomplete. Preserve that evidence before _ingest_tick declines to
|
|
1260
|
+
# mutate OHLCV. Otherwise a later watermark could publish the earlier
|
|
1261
|
+
# trades as a deceptively complete bar after a volume/order/time gap.
|
|
1262
|
+
if bucket_start is not None and blocking:
|
|
1263
|
+
already_closed = (
|
|
1264
|
+
self._last_closed_bucket_end is not None
|
|
1265
|
+
and bucket_end is not None
|
|
1266
|
+
and bucket_end <= self._last_closed_bucket_end
|
|
1267
|
+
)
|
|
1268
|
+
if not already_closed:
|
|
1269
|
+
self._add_bar_quality_override(bucket_start, *blocking)
|
|
1270
|
+
execution_eligible = (
|
|
1271
|
+
(not strict_ctp_v2 or upstream_execution_eligible is True)
|
|
1272
|
+
and not blocking
|
|
1273
|
+
and all(value is not None and value > 0 for value in (bid, ask, bid_size, ask_size))
|
|
1274
|
+
)
|
|
1275
|
+
bar_eligible = not blocking and price is not None and price > 0 and delta > 0
|
|
1276
|
+
_set_tick_value(tick, "quality_flags", tuple(sorted(flags)))
|
|
1277
|
+
_set_tick_value(tick, "quality", "GOOD" if not blocking else "INVALID")
|
|
1278
|
+
_set_tick_value(tick, "execution_eligible", execution_eligible)
|
|
1279
|
+
_set_tick_value(tick, "bar_eligible", bar_eligible)
|
|
1280
|
+
self._prune_bar_quality_overrides()
|
|
1281
|
+
|
|
1282
|
+
def _flush_ready_bars(self, *, reason, force_invalid=False):
|
|
1283
|
+
"""Close trade-backed buckets once the event-time watermark has passed."""
|
|
1284
|
+
watermark = self._event_time_watermark()
|
|
1285
|
+
if not self._bar_builders:
|
|
1286
|
+
self._prune_bar_quality_overrides(watermark)
|
|
1287
|
+
return 0
|
|
1288
|
+
watermark_delay = max(float(self.p.bar_watermark_ms or 0.0), 0.0) / 1000.0
|
|
1289
|
+
closed = 0
|
|
1290
|
+
for bucket_start in sorted(self._bar_builders):
|
|
1291
|
+
current = self._bar_builders[bucket_start]
|
|
1292
|
+
bucket_end = current["bucket_end"]
|
|
1293
|
+
deadline = bucket_end.replace(tzinfo=_UTC).timestamp() + watermark_delay
|
|
1294
|
+
if not force_invalid and (watermark is None or watermark < deadline):
|
|
1295
|
+
continue
|
|
1296
|
+
flags = set(current["quality_flags"])
|
|
1297
|
+
flags.update(self._bar_quality_overrides.pop(bucket_start, set()))
|
|
1298
|
+
if force_invalid:
|
|
1299
|
+
flags.add("FORCED_INVALIDATION")
|
|
1300
|
+
complete = bool(current["volume_complete"] and not flags.difference({"LEGACY_SCHEMA"}))
|
|
1301
|
+
available_ts = max(deadline, watermark or deadline)
|
|
1302
|
+
available_at = _dt.datetime.fromtimestamp(available_ts, _UTC)
|
|
1303
|
+
self._bar_sequence += 1
|
|
1304
|
+
first_seq = current["first_ingest_seq"]
|
|
1305
|
+
last_seq = current["last_ingest_seq"]
|
|
1306
|
+
generation = current["connection_generation"]
|
|
1307
|
+
bar_id = (
|
|
1308
|
+
f"{self._dataname}:{bucket_start.isoformat()}:{generation}:"
|
|
1309
|
+
f"{first_seq}-{last_seq}"
|
|
1310
|
+
)
|
|
1311
|
+
completed = BarEvent(
|
|
1312
|
+
timestamp=bucket_end.replace(tzinfo=_UTC).timestamp(),
|
|
1313
|
+
symbol=self._dataname,
|
|
1314
|
+
exchange=current["exchange"],
|
|
1315
|
+
asset_type=current["asset_type"],
|
|
1316
|
+
local_time=available_ts,
|
|
1317
|
+
**current["causal"],
|
|
1318
|
+
open=current["open"],
|
|
1319
|
+
high=current["high"],
|
|
1320
|
+
low=current["low"],
|
|
1321
|
+
close=current["close"],
|
|
1322
|
+
volume=current["volume"],
|
|
1323
|
+
openinterest=current["openinterest"],
|
|
1324
|
+
)
|
|
1325
|
+
extensions = {
|
|
1326
|
+
"bucket_start": bucket_start.replace(tzinfo=_UTC),
|
|
1327
|
+
"bucket_end": bucket_end.replace(tzinfo=_UTC),
|
|
1328
|
+
"closed_at": available_at,
|
|
1329
|
+
"available_at": available_at,
|
|
1330
|
+
"bar_available_at": available_at,
|
|
1331
|
+
"complete": complete,
|
|
1332
|
+
"quality": "GOOD" if complete else "INVALID",
|
|
1333
|
+
"quality_flags": tuple(sorted(flags)),
|
|
1334
|
+
"volume_complete": bool(current["volume_complete"]),
|
|
1335
|
+
"first_ingest_seq": first_seq,
|
|
1336
|
+
"last_ingest_seq": last_seq,
|
|
1337
|
+
"quote_cutoff_seq": last_seq,
|
|
1338
|
+
"trading_day": current["trading_day"],
|
|
1339
|
+
"action_day": current["action_day"],
|
|
1340
|
+
"rules_hash": current["rules_hash"],
|
|
1341
|
+
"session_segment": current["session_segment"],
|
|
1342
|
+
"connection_generation": generation,
|
|
1343
|
+
"bar_id": bar_id,
|
|
1344
|
+
"decision_version": bar_id,
|
|
1345
|
+
"closure_reason": reason,
|
|
1346
|
+
"bar_sequence": self._bar_sequence,
|
|
1347
|
+
"trade_count": current["trade_count"],
|
|
1348
|
+
"watermark": _dt.datetime.fromtimestamp(watermark or available_ts, _UTC),
|
|
1349
|
+
"max_event_time": _dt.datetime.fromtimestamp(current["last_timestamp"], _UTC),
|
|
1350
|
+
}
|
|
1351
|
+
for name, value in extensions.items():
|
|
1352
|
+
setattr(completed, name, value)
|
|
1353
|
+
self._attach_closed_bar_evidence(completed)
|
|
1354
|
+
if getattr(completed, "closed_bar_evidence", None) is not None:
|
|
1355
|
+
setattr(
|
|
1356
|
+
completed,
|
|
1357
|
+
"_closed_bar_evidence_dispatch_token",
|
|
1358
|
+
self._closed_bar_evidence_dispatch_token,
|
|
1359
|
+
)
|
|
1360
|
+
self._enqueue_bar_event(completed, bucket_start, deliver_lines=complete)
|
|
1361
|
+
del self._bar_builders[bucket_start]
|
|
1362
|
+
self._last_closed_bucket_end = bucket_end
|
|
1363
|
+
closed += 1
|
|
1364
|
+
self._bar_builder = next(reversed(self._bar_builders.values()), None)
|
|
1365
|
+
self._prune_bar_quality_overrides(watermark)
|
|
1366
|
+
return closed
|
|
1367
|
+
|
|
1368
|
+
def _dispatch_event(self, channel_type, priority, event_data):
|
|
1369
|
+
"""Dispatch a tick/bar event into Cerebro's channel callback surface."""
|
|
1370
|
+
env = getattr(self, "_env", None)
|
|
1371
|
+
if env is None or not hasattr(env, "dispatch_channel_event"):
|
|
1372
|
+
self._mark_event_dropped(event_data, "strategy_dispatch_unavailable")
|
|
1373
|
+
return False
|
|
1374
|
+
|
|
1375
|
+
if channel_type == "tick":
|
|
1376
|
+
self._attach_ctp_decision_now(event_data)
|
|
1377
|
+
|
|
1378
|
+
event = Event(
|
|
1379
|
+
timestamp=_tick_timestamp(event_data),
|
|
1380
|
+
priority=priority,
|
|
1381
|
+
channel_type=channel_type,
|
|
1382
|
+
channel_name=self._dataname,
|
|
1383
|
+
data=event_data,
|
|
1384
|
+
)
|
|
1385
|
+
# Only feed-origin events carry this private reference. Channel queues
|
|
1386
|
+
# already drive the matching broker in their own event loop.
|
|
1387
|
+
event._source_feed = self
|
|
1388
|
+
sealed_evidence = (
|
|
1389
|
+
getattr(event_data, "closed_bar_evidence", None) if channel_type == "bar" else None
|
|
1390
|
+
)
|
|
1391
|
+
if sealed_evidence is not None:
|
|
1392
|
+
self._sealed_closed_bar_evidence_by_event_id[id(event_data)] = sealed_evidence
|
|
1393
|
+
try:
|
|
1394
|
+
env.dispatch_channel_event(event)
|
|
1395
|
+
except Exception:
|
|
1396
|
+
_safe_log("error", "btapifeed:1390 exception before re-raise (Exception)")
|
|
1397
|
+
self._mark_event_dropped(event_data, "strategy_dispatch_failed")
|
|
1398
|
+
raise
|
|
1399
|
+
finally:
|
|
1400
|
+
# Native callbacks are synchronous. Do not retain evidence
|
|
1401
|
+
# identity after their dispatch window has closed.
|
|
1402
|
+
self._sealed_closed_bar_evidence_by_event_id.pop(id(event_data), None)
|
|
1403
|
+
if self.store is not None and hasattr(self.store, "mark_strategy_delivered"):
|
|
1404
|
+
self.store.mark_strategy_delivered(event_data)
|
|
1405
|
+
return True
|
|
1406
|
+
|
|
1407
|
+
def _attach_ctp_decision_now(self, tick):
|
|
1408
|
+
"""Attach caller-owned decision-boundary time to a strict CTP V2 tick.
|
|
1409
|
+
|
|
1410
|
+
Parent receipt time is useful evidence but cannot measure time spent
|
|
1411
|
+
in the Store/Feed path. A live caller must explicitly provide a
|
|
1412
|
+
calibrated same-domain provider; raw tick fields never supply this
|
|
1413
|
+
boundary. Replay code can provide its own deterministic evidence
|
|
1414
|
+
without involving this Feed.
|
|
1415
|
+
"""
|
|
1416
|
+
|
|
1417
|
+
if _tick_value(tick, "schema_version", default=None) != "ctp.quote.v2":
|
|
1418
|
+
return
|
|
1419
|
+
decision_fields = (
|
|
1420
|
+
"cohort_decision_now_monotonic_ns",
|
|
1421
|
+
"cohort_decision_now_epoch",
|
|
1422
|
+
"cohort_decision_now_clock_domain_id",
|
|
1423
|
+
"cohort_decision_now_receive_clock_error_ms",
|
|
1424
|
+
"cohort_decision_now_receive_clock_quality",
|
|
1425
|
+
"cohort_decision_now_freshness_verified",
|
|
1426
|
+
)
|
|
1427
|
+
# These fields belong to this dispatch boundary. A raw transport
|
|
1428
|
+
# payload must never pre-populate them and masquerade as a later local
|
|
1429
|
+
# decision timestamp.
|
|
1430
|
+
for name in decision_fields:
|
|
1431
|
+
_set_tick_value(tick, name, None)
|
|
1432
|
+
provider = self.p.ctp_decision_now_provider
|
|
1433
|
+
if not callable(provider):
|
|
1434
|
+
return
|
|
1435
|
+
try:
|
|
1436
|
+
now = provider(tick)
|
|
1437
|
+
except Exception:
|
|
1438
|
+
_safe_log("warning", "btapifeed:1431 fallback on Exception")
|
|
1439
|
+
return
|
|
1440
|
+
if not isinstance(now, CtpCohortNow):
|
|
1441
|
+
return
|
|
1442
|
+
if now.clock_domain_id != _tick_value(tick, "clock_domain_id", default=None):
|
|
1443
|
+
return
|
|
1444
|
+
for name, value in (
|
|
1445
|
+
("cohort_decision_now_monotonic_ns", now.now_monotonic_ns),
|
|
1446
|
+
("cohort_decision_now_epoch", now.now_epoch),
|
|
1447
|
+
("cohort_decision_now_clock_domain_id", now.clock_domain_id),
|
|
1448
|
+
("cohort_decision_now_receive_clock_error_ms", now.receive_clock_error_ms),
|
|
1449
|
+
("cohort_decision_now_receive_clock_quality", now.receive_clock_quality),
|
|
1450
|
+
("cohort_decision_now_freshness_verified", now.freshness_verified),
|
|
1451
|
+
):
|
|
1452
|
+
_set_tick_value(tick, name, value)
|
|
1453
|
+
|
|
1454
|
+
def _mark_event_dropped(self, event_data, reason):
|
|
1455
|
+
"""Close Store conservation accounting for an undispatched feed event."""
|
|
1456
|
+
marker = getattr(self.store, "mark_feed_dropped", None)
|
|
1457
|
+
if callable(marker):
|
|
1458
|
+
marker(event_data, reason)
|
|
1459
|
+
|
|
1460
|
+
def _handle_event_health(self, event_data):
|
|
1461
|
+
"""Emit feed status transitions and tell callers whether data is unsafe."""
|
|
1462
|
+
stale = bool(_tick_value(event_data, "stale", default=False))
|
|
1463
|
+
continuity = str(
|
|
1464
|
+
_tick_value(event_data, "continuity_status", "continuity", default="unknown")
|
|
1465
|
+
or "unknown"
|
|
1466
|
+
).lower()
|
|
1467
|
+
unhealthy = stale or continuity in {
|
|
1468
|
+
"gap",
|
|
1469
|
+
"stale",
|
|
1470
|
+
"disconnected",
|
|
1471
|
+
"checksum_failed",
|
|
1472
|
+
"out_of_order",
|
|
1473
|
+
"invalid",
|
|
1474
|
+
}
|
|
1475
|
+
if unhealthy:
|
|
1476
|
+
if not self._continuity_degraded:
|
|
1477
|
+
self.put_notification(
|
|
1478
|
+
self.DELAYED,
|
|
1479
|
+
stale_reason=_tick_value(
|
|
1480
|
+
event_data, "stale_reason", default=continuity or "stale"
|
|
1481
|
+
),
|
|
1482
|
+
event_id=_tick_value(event_data, "event_id", default=""),
|
|
1483
|
+
)
|
|
1484
|
+
# A later verified recovery is a fresh LIVE transition.
|
|
1485
|
+
self._live_notified = False
|
|
1486
|
+
self._continuity_degraded = True
|
|
1487
|
+
return True
|
|
1488
|
+
if self._continuity_degraded and continuity in {
|
|
1489
|
+
"ok",
|
|
1490
|
+
"continuous",
|
|
1491
|
+
"recovered",
|
|
1492
|
+
"snapshot",
|
|
1493
|
+
}:
|
|
1494
|
+
self._continuity_degraded = False
|
|
1495
|
+
self._mark_live()
|
|
1496
|
+
return False
|
|
1497
|
+
|
|
1498
|
+
def get_logging_health(self):
|
|
1499
|
+
"""Return the number of feed log-sink failures observed in this process."""
|
|
1500
|
+
return dict(_LOGGING_HEALTH)
|
|
1501
|
+
|
|
1502
|
+
def _mark_live(self):
|
|
1503
|
+
"""Emit the LIVE status exactly once when real-time traffic begins."""
|
|
1504
|
+
if self._continuity_degraded:
|
|
1505
|
+
return
|
|
1506
|
+
if not self._live_notified:
|
|
1507
|
+
self.put_notification(self.LIVE)
|
|
1508
|
+
self._live_notified = True
|
|
1509
|
+
|
|
1510
|
+
def _get_bucket_start(self, dt_value):
|
|
1511
|
+
"""Round a tick timestamp down to the current feed timeframe bucket."""
|
|
1512
|
+
dt_value = dt_value.replace(microsecond=0)
|
|
1513
|
+
|
|
1514
|
+
if self._timeframe == TimeFrame.Seconds:
|
|
1515
|
+
second = (dt_value.second // self._compression) * self._compression
|
|
1516
|
+
return dt_value.replace(second=second)
|
|
1517
|
+
|
|
1518
|
+
if self._timeframe == TimeFrame.Minutes:
|
|
1519
|
+
minute = (dt_value.minute // self._compression) * self._compression
|
|
1520
|
+
return dt_value.replace(minute=minute, second=0)
|
|
1521
|
+
|
|
1522
|
+
if self._timeframe == TimeFrame.Days:
|
|
1523
|
+
return dt_value.replace(hour=0, minute=0, second=0)
|
|
1524
|
+
|
|
1525
|
+
# Fall back to minute-style bucketing for other sub-day frames.
|
|
1526
|
+
return dt_value.replace(second=0)
|
|
1527
|
+
|
|
1528
|
+
def _get_bucket_end(self, bucket_start):
|
|
1529
|
+
"""Return the exclusive right edge for a feed bucket."""
|
|
1530
|
+
if self._timeframe == TimeFrame.Ticks:
|
|
1531
|
+
return bucket_start
|
|
1532
|
+
if self._timeframe == TimeFrame.Seconds:
|
|
1533
|
+
return bucket_start + _dt.timedelta(seconds=self._compression)
|
|
1534
|
+
if self._timeframe == TimeFrame.Minutes:
|
|
1535
|
+
return bucket_start + _dt.timedelta(minutes=self._compression)
|
|
1536
|
+
if self._timeframe == TimeFrame.Days:
|
|
1537
|
+
return bucket_start + _dt.timedelta(days=self._compression)
|
|
1538
|
+
return bucket_start + _dt.timedelta(minutes=self._compression)
|