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,1051 @@
|
|
|
1
|
+
"""Strict, side-effect-free CTP multi-leg quote cohort validation.
|
|
2
|
+
|
|
3
|
+
This module turns public ``ctp.quote.v2`` snapshots into immutable evidence
|
|
4
|
+
objects and admits a cohort only after every configured leg has supplied a
|
|
5
|
+
new, valid quote. It deliberately has no network, order, broker, or strategy
|
|
6
|
+
dependency: a caller may use an admitted cohort for a screen, a bar decision,
|
|
7
|
+
or an observation-only audit, but this module never creates an order or an
|
|
8
|
+
execution intent.
|
|
9
|
+
|
|
10
|
+
The validator treats source-time quality as an explicit prerequisite. A
|
|
11
|
+
missing or unverified source clock is rejected; it is never upgraded from a
|
|
12
|
+
receive timestamp or a local fallback clock.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from collections.abc import Iterable, Mapping
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from decimal import Decimal, InvalidOperation
|
|
22
|
+
from types import MappingProxyType
|
|
23
|
+
from typing import Any, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
_MAX_ABS_NUMBER = 1.0e30
|
|
26
|
+
_MAX_UINT64 = (1 << 64) - 1
|
|
27
|
+
_PROVENANCE_PLACEHOLDERS = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"unknown",
|
|
30
|
+
"unverified",
|
|
31
|
+
"n/a",
|
|
32
|
+
"na",
|
|
33
|
+
"none",
|
|
34
|
+
"null",
|
|
35
|
+
"unset",
|
|
36
|
+
"placeholder",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CtpCohortReason:
|
|
42
|
+
"""Stable reasons returned by :class:`CtpQuoteCohortValidator`.
|
|
43
|
+
|
|
44
|
+
A successful result has ``reason is None``. These string constants are
|
|
45
|
+
intentionally public so strategy logs and tests do not need to parse an
|
|
46
|
+
exception message.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
WAITING_FOR_LEGS = "WAITING_FOR_LEGS"
|
|
50
|
+
WAITING_FOR_ALL_LEGS_NEW = "WAITING_FOR_ALL_LEGS_NEW"
|
|
51
|
+
UNEXPECTED_SYMBOL = "UNEXPECTED_SYMBOL"
|
|
52
|
+
QUOTE_SYMBOL_MISSING = "QUOTE_SYMBOL_MISSING"
|
|
53
|
+
QUOTE_IDENTITY_CONFLICT = "QUOTE_IDENTITY_CONFLICT"
|
|
54
|
+
EXCHANGE_MISMATCH = "EXCHANGE_MISMATCH"
|
|
55
|
+
ASSET_TYPE_MISMATCH = "ASSET_TYPE_MISMATCH"
|
|
56
|
+
UNSUPPORTED_QUOTE_SCHEMA = "UNSUPPORTED_QUOTE_SCHEMA"
|
|
57
|
+
VOLUME_SEMANTICS_NOT_DELTA = "VOLUME_SEMANTICS_NOT_DELTA"
|
|
58
|
+
SOURCE_CLOCK_UNVERIFIED = "SOURCE_CLOCK_UNVERIFIED"
|
|
59
|
+
RECEIVE_CLOCK_UNVERIFIED = "RECEIVE_CLOCK_UNVERIFIED"
|
|
60
|
+
FRESHNESS_UNVERIFIED = "FRESHNESS_UNVERIFIED"
|
|
61
|
+
EVENT_TIME_SOURCE_MISSING = "EVENT_TIME_SOURCE_MISSING"
|
|
62
|
+
RULES_HASH_MISMATCH = "RULES_HASH_MISMATCH"
|
|
63
|
+
QUOTE_SOURCE_MISSING = "QUOTE_SOURCE_MISSING"
|
|
64
|
+
QUOTE_STREAM_UNREADY = "QUOTE_STREAM_UNREADY"
|
|
65
|
+
QUOTE_CONTINUITY_NOT_CONTINUOUS = "QUOTE_CONTINUITY_NOT_CONTINUOUS"
|
|
66
|
+
QUOTE_QUALITY_FLAGS_INVALID = "QUOTE_QUALITY_FLAGS_INVALID"
|
|
67
|
+
QUOTE_QUALITY_FLAGS_PRESENT = "QUOTE_QUALITY_FLAGS_PRESENT"
|
|
68
|
+
EXECUTION_INELIGIBLE_QUOTE = "EXECUTION_INELIGIBLE_QUOTE"
|
|
69
|
+
VOLUME_INCOMPLETE = "VOLUME_INCOMPLETE"
|
|
70
|
+
VOLUME_QUALITY_NOT_CONTINUOUS = "VOLUME_QUALITY_NOT_CONTINUOUS"
|
|
71
|
+
QUOTE_NUMERIC_TYPE_INVALID = "QUOTE_NUMERIC_TYPE_INVALID"
|
|
72
|
+
QUOTE_NUMERIC_INVALID = "QUOTE_NUMERIC_INVALID"
|
|
73
|
+
QUOTE_NONPOSITIVE = "QUOTE_NONPOSITIVE"
|
|
74
|
+
QUOTE_CROSSED = "QUOTE_CROSSED"
|
|
75
|
+
DAILY_PRICE_LIMIT_INVALID = "DAILY_PRICE_LIMIT_INVALID"
|
|
76
|
+
QUOTE_OUTSIDE_DAILY_LIMIT = "QUOTE_OUTSIDE_DAILY_LIMIT"
|
|
77
|
+
QUOTE_OFF_TICK_GRID = "QUOTE_OFF_TICK_GRID"
|
|
78
|
+
QUOTE_IDENTITY_TYPE_INVALID = "QUOTE_IDENTITY_TYPE_INVALID"
|
|
79
|
+
QUOTE_IDENTITY_OR_CLOCK_MISSING = "QUOTE_IDENTITY_OR_CLOCK_MISSING"
|
|
80
|
+
TRADING_DAY_INVALID = "TRADING_DAY_INVALID"
|
|
81
|
+
ACTION_DAY_INVALID = "ACTION_DAY_INVALID"
|
|
82
|
+
CLOCK_DOMAIN_UNKNOWN = "CLOCK_DOMAIN_UNKNOWN"
|
|
83
|
+
SOURCE_TIME_INVALID = "SOURCE_TIME_INVALID"
|
|
84
|
+
RECEIVE_TIME_INVALID = "RECEIVE_TIME_INVALID"
|
|
85
|
+
SOURCE_TIME_AFTER_RECEIVE = "SOURCE_TIME_AFTER_RECEIVE"
|
|
86
|
+
SOURCE_CLOCK_ERROR_INVALID = "SOURCE_CLOCK_ERROR_INVALID"
|
|
87
|
+
RECEIVE_CLOCK_ERROR_INVALID = "RECEIVE_CLOCK_ERROR_INVALID"
|
|
88
|
+
DUPLICATE_OR_OUT_OF_ORDER = "DUPLICATE_OR_OUT_OF_ORDER"
|
|
89
|
+
OUT_OF_ORDER_RECEIVE_TIME = "OUT_OF_ORDER_RECEIVE_TIME"
|
|
90
|
+
OUT_OF_ORDER_SOURCE_TIME = "OUT_OF_ORDER_SOURCE_TIME"
|
|
91
|
+
COHORT_EXCHANGE_MISMATCH = "COHORT_EXCHANGE_MISMATCH"
|
|
92
|
+
COHORT_TRADING_DAY_MISMATCH = "COHORT_TRADING_DAY_MISMATCH"
|
|
93
|
+
COHORT_ACTION_DAY_MISMATCH = "COHORT_ACTION_DAY_MISMATCH"
|
|
94
|
+
COHORT_CONNECTION_GENERATION_MISMATCH = "COHORT_CONNECTION_GENERATION_MISMATCH"
|
|
95
|
+
COHORT_SUBSCRIPTION_EPOCH_MISMATCH = "COHORT_SUBSCRIPTION_EPOCH_MISMATCH"
|
|
96
|
+
COHORT_RULES_HASH_MISMATCH = "COHORT_RULES_HASH_MISMATCH"
|
|
97
|
+
COHORT_CLOCK_DOMAIN_MISMATCH = "COHORT_CLOCK_DOMAIN_MISMATCH"
|
|
98
|
+
STALE_COHORT_RECEIVE_TIME = "STALE_COHORT_RECEIVE_TIME"
|
|
99
|
+
BLOCKED_CROSS_LEG_SKEW = "BLOCKED_CROSS_LEG_SKEW"
|
|
100
|
+
STALE_COHORT_SOURCE_TIME = "STALE_COHORT_SOURCE_TIME"
|
|
101
|
+
BLOCKED_SOURCE_SKEW = "BLOCKED_SOURCE_SKEW"
|
|
102
|
+
TRUSTED_NOW_REQUIRED = "TRUSTED_NOW_REQUIRED"
|
|
103
|
+
TRUSTED_NOW_INVALID = "TRUSTED_NOW_INVALID"
|
|
104
|
+
NOW_CLOCK_DOMAIN_MISMATCH = "NOW_CLOCK_DOMAIN_MISMATCH"
|
|
105
|
+
NOW_WALL_TIME_BEFORE_QUOTE = "NOW_WALL_TIME_BEFORE_QUOTE"
|
|
106
|
+
RETIRED_CONNECTION_SCOPE = "RETIRED_CONNECTION_SCOPE"
|
|
107
|
+
NO_CONFIRMED_COHORT = "NO_CONFIRMED_COHORT"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _strict_positive_number(value: Any, *, field: str) -> float:
|
|
111
|
+
"""Return a finite positive built-in numeric value or raise ``ValueError``."""
|
|
112
|
+
|
|
113
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
114
|
+
raise ValueError(f"{field} must be a built-in finite positive number")
|
|
115
|
+
number = float(value)
|
|
116
|
+
if not math.isfinite(number) or number <= 0.0 or abs(number) >= _MAX_ABS_NUMBER:
|
|
117
|
+
raise ValueError(f"{field} must be a built-in finite positive number")
|
|
118
|
+
return number
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _strict_nonnegative_number(value: Any, *, field: str) -> float:
|
|
122
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
123
|
+
raise ValueError(f"{field} must be a built-in finite non-negative number")
|
|
124
|
+
number = float(value)
|
|
125
|
+
if not math.isfinite(number) or number < 0.0 or abs(number) >= _MAX_ABS_NUMBER:
|
|
126
|
+
raise ValueError(f"{field} must be a built-in finite non-negative number")
|
|
127
|
+
return number
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _strict_nonempty_text(value: Any, *, field: str) -> str:
|
|
131
|
+
if not isinstance(value, str) or not value or value.strip() != value:
|
|
132
|
+
raise ValueError(f"{field} must be a non-empty string")
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _is_strict_nonempty_text(value: Any) -> bool:
|
|
137
|
+
return isinstance(value, str) and bool(value) and value.strip() == value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_provenance_identity(value: Any) -> bool:
|
|
141
|
+
"""Accept an explicit provenance identity, never a placeholder value.
|
|
142
|
+
|
|
143
|
+
CTP quote fields such as source, rules hash and clock domain are security
|
|
144
|
+
boundaries. Treating a literal ``"unknown"`` as an identity would let a
|
|
145
|
+
caller make two unrelated unknown values appear to match.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
return _is_strict_nonempty_text(value) and value.casefold() not in _PROVENANCE_PLACEHOLDERS
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _strict_provenance_identity(value: Any, *, field: str) -> str:
|
|
152
|
+
if not _is_provenance_identity(value):
|
|
153
|
+
raise ValueError(f"{field} must be a non-placeholder provenance identity")
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class CtpCohortLeg:
|
|
159
|
+
"""One immutable expected leg in a two- or three-leg CTP cohort."""
|
|
160
|
+
|
|
161
|
+
symbol: str
|
|
162
|
+
exchange: str
|
|
163
|
+
price_tick: float
|
|
164
|
+
asset_type: Optional[str] = None
|
|
165
|
+
|
|
166
|
+
def __post_init__(self) -> None:
|
|
167
|
+
_strict_nonempty_text(self.symbol, field="symbol")
|
|
168
|
+
_strict_nonempty_text(self.exchange, field="exchange")
|
|
169
|
+
object.__setattr__(
|
|
170
|
+
self,
|
|
171
|
+
"price_tick",
|
|
172
|
+
_strict_positive_number(self.price_tick, field="price_tick"),
|
|
173
|
+
)
|
|
174
|
+
if self.asset_type is not None:
|
|
175
|
+
if self.asset_type not in {"future", "option"}:
|
|
176
|
+
raise ValueError("asset_type must be future, option, or None")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class CtpCohortPolicy:
|
|
181
|
+
"""Immutable time-quality bounds for a cohort decision."""
|
|
182
|
+
|
|
183
|
+
max_receive_age_ms: float
|
|
184
|
+
max_receive_skew_ms: float
|
|
185
|
+
max_source_age_ms: float
|
|
186
|
+
max_source_skew_ms: float
|
|
187
|
+
max_source_clock_error_ms: float
|
|
188
|
+
max_receive_clock_error_ms: float
|
|
189
|
+
|
|
190
|
+
def __post_init__(self) -> None:
|
|
191
|
+
for name in (
|
|
192
|
+
"max_receive_age_ms",
|
|
193
|
+
"max_receive_skew_ms",
|
|
194
|
+
"max_source_age_ms",
|
|
195
|
+
"max_source_skew_ms",
|
|
196
|
+
"max_source_clock_error_ms",
|
|
197
|
+
"max_receive_clock_error_ms",
|
|
198
|
+
):
|
|
199
|
+
object.__setattr__(
|
|
200
|
+
self,
|
|
201
|
+
name,
|
|
202
|
+
_strict_nonnegative_number(getattr(self, name), field=name),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class CtpCohortNow:
|
|
208
|
+
"""Trusted current-time evidence supplied by a cohort caller.
|
|
209
|
+
|
|
210
|
+
The validator intentionally does not call a process clock. A caller must
|
|
211
|
+
provide a same-domain monotonic reading and a verified receive-wall-clock
|
|
212
|
+
reading for every ingestion and pre-submit recheck. This makes queue
|
|
213
|
+
delays observable instead of silently treating the most recent quote as
|
|
214
|
+
``now``.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
now_monotonic_ns: int
|
|
218
|
+
now_epoch: float
|
|
219
|
+
clock_domain_id: str
|
|
220
|
+
receive_clock_error_ms: float
|
|
221
|
+
receive_clock_quality: str = "verified"
|
|
222
|
+
freshness_verified: bool = True
|
|
223
|
+
|
|
224
|
+
def __post_init__(self) -> None:
|
|
225
|
+
monotonic = _strict_positive_uint64(self.now_monotonic_ns)
|
|
226
|
+
epoch = _epoch_seconds(self.now_epoch)
|
|
227
|
+
if monotonic is None or epoch is None:
|
|
228
|
+
raise ValueError("now_monotonic_ns and now_epoch must be valid trusted clock values")
|
|
229
|
+
_strict_provenance_identity(self.clock_domain_id, field="clock_domain_id")
|
|
230
|
+
if self.receive_clock_quality != "verified":
|
|
231
|
+
raise ValueError("receive_clock_quality must be verified")
|
|
232
|
+
if self.freshness_verified is not True:
|
|
233
|
+
raise ValueError("freshness_verified must be True")
|
|
234
|
+
error = _strict_quote_number(self.receive_clock_error_ms)
|
|
235
|
+
if error is None or error < 0.0:
|
|
236
|
+
raise ValueError("receive_clock_error_ms must be a finite non-negative number")
|
|
237
|
+
object.__setattr__(self, "now_monotonic_ns", monotonic)
|
|
238
|
+
object.__setattr__(self, "now_epoch", epoch)
|
|
239
|
+
object.__setattr__(self, "receive_clock_error_ms", error)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True)
|
|
243
|
+
class CtpQuoteEvidence:
|
|
244
|
+
"""Immutable validated CTP level-one quote evidence."""
|
|
245
|
+
|
|
246
|
+
symbol: str
|
|
247
|
+
exchange: str
|
|
248
|
+
asset_type: Optional[str]
|
|
249
|
+
bid: float
|
|
250
|
+
ask: float
|
|
251
|
+
bid_size: float
|
|
252
|
+
ask_size: float
|
|
253
|
+
last: float
|
|
254
|
+
lower_limit: float
|
|
255
|
+
upper_limit: float
|
|
256
|
+
source_epoch: float
|
|
257
|
+
receive_epoch: float
|
|
258
|
+
receive_monotonic_ns: int
|
|
259
|
+
ingest_seq: int
|
|
260
|
+
connection_generation: int
|
|
261
|
+
subscription_epoch: int
|
|
262
|
+
trading_day: str
|
|
263
|
+
action_day: str
|
|
264
|
+
clock_domain_id: str
|
|
265
|
+
rules_hash: str
|
|
266
|
+
source: str
|
|
267
|
+
event_time_source: str
|
|
268
|
+
source_clock_error_ms: float
|
|
269
|
+
receive_clock_error_ms: float
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def update_identity(self) -> Tuple[str, int, int, int]:
|
|
273
|
+
"""The immutable identity used to require a fresh quote per leg."""
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
self.symbol,
|
|
277
|
+
self.connection_generation,
|
|
278
|
+
self.subscription_epoch,
|
|
279
|
+
self.ingest_seq,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True)
|
|
284
|
+
class CtpQuoteValidation:
|
|
285
|
+
"""The result of strict quote normalization without any state mutation."""
|
|
286
|
+
|
|
287
|
+
quote: Optional[CtpQuoteEvidence]
|
|
288
|
+
reason: Optional[str]
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def accepted(self) -> bool:
|
|
292
|
+
return self.quote is not None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclass(frozen=True)
|
|
296
|
+
class CtpQuoteCohort:
|
|
297
|
+
"""An immutable set of synchronized, fresh quote evidence."""
|
|
298
|
+
|
|
299
|
+
quotes: Mapping[str, CtpQuoteEvidence]
|
|
300
|
+
exchange: str
|
|
301
|
+
trading_day: str
|
|
302
|
+
action_day: str
|
|
303
|
+
connection_generation: int
|
|
304
|
+
subscription_epoch: int
|
|
305
|
+
clock_domain_id: str
|
|
306
|
+
rules_hash: str
|
|
307
|
+
cohort_id: str
|
|
308
|
+
|
|
309
|
+
def __post_init__(self) -> None:
|
|
310
|
+
if not isinstance(self.quotes, Mapping) or not self.quotes:
|
|
311
|
+
raise ValueError("quotes must be a non-empty mapping")
|
|
312
|
+
quotes = dict(self.quotes)
|
|
313
|
+
if not all(isinstance(quote, CtpQuoteEvidence) for quote in quotes.values()):
|
|
314
|
+
raise TypeError("quotes must contain only CtpQuoteEvidence values")
|
|
315
|
+
if any(symbol != quote.symbol for symbol, quote in quotes.items()):
|
|
316
|
+
raise ValueError("quote mapping keys must exactly match quote.symbol")
|
|
317
|
+
_strict_nonempty_text(self.exchange, field="exchange")
|
|
318
|
+
if not _valid_trading_day(self.trading_day):
|
|
319
|
+
raise ValueError("trading_day must be a valid YYYYMMDD date")
|
|
320
|
+
if not _valid_trading_day(self.action_day):
|
|
321
|
+
raise ValueError("action_day must be a valid YYYYMMDD date")
|
|
322
|
+
if _strict_positive_uint64(self.connection_generation) is None:
|
|
323
|
+
raise ValueError("connection_generation must be a positive uint64")
|
|
324
|
+
if _strict_positive_uint64(self.subscription_epoch) is None:
|
|
325
|
+
raise ValueError("subscription_epoch must be a positive uint64")
|
|
326
|
+
_strict_provenance_identity(self.clock_domain_id, field="clock_domain_id")
|
|
327
|
+
_strict_provenance_identity(self.rules_hash, field="rules_hash")
|
|
328
|
+
_strict_nonempty_text(self.cohort_id, field="cohort_id")
|
|
329
|
+
expected_metadata = {
|
|
330
|
+
"exchange": self.exchange,
|
|
331
|
+
"trading_day": self.trading_day,
|
|
332
|
+
"action_day": self.action_day,
|
|
333
|
+
"connection_generation": self.connection_generation,
|
|
334
|
+
"subscription_epoch": self.subscription_epoch,
|
|
335
|
+
"clock_domain_id": self.clock_domain_id,
|
|
336
|
+
"rules_hash": self.rules_hash,
|
|
337
|
+
}
|
|
338
|
+
if any(
|
|
339
|
+
any(getattr(quote, name) != value for name, value in expected_metadata.items())
|
|
340
|
+
for quote in quotes.values()
|
|
341
|
+
):
|
|
342
|
+
raise ValueError("cohort metadata must exactly match every quote")
|
|
343
|
+
object.__setattr__(self, "quotes", MappingProxyType(quotes))
|
|
344
|
+
|
|
345
|
+
def quote_for(self, symbol: str) -> CtpQuoteEvidence:
|
|
346
|
+
"""Return the evidence for an expected symbol."""
|
|
347
|
+
|
|
348
|
+
return self.quotes[symbol]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@dataclass(frozen=True)
|
|
352
|
+
class CtpCohortResult:
|
|
353
|
+
"""The result of ingesting one quote into a stateful cohort validator."""
|
|
354
|
+
|
|
355
|
+
cohort: Optional[CtpQuoteCohort]
|
|
356
|
+
reason: Optional[str]
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def accepted(self) -> bool:
|
|
360
|
+
return self.cohort is not None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _event_value(event: Any, *names: str) -> Any:
|
|
364
|
+
"""Read the first present public field from a mapping or event object."""
|
|
365
|
+
|
|
366
|
+
if isinstance(event, Mapping):
|
|
367
|
+
for name in names:
|
|
368
|
+
if name in event:
|
|
369
|
+
return event[name]
|
|
370
|
+
return None
|
|
371
|
+
for name in names:
|
|
372
|
+
if hasattr(event, name):
|
|
373
|
+
return getattr(event, name)
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _consistent_identity_alias(event: Any, *names: str) -> Tuple[Any, bool]:
|
|
378
|
+
"""Read identity aliases and require every supplied spelling to agree."""
|
|
379
|
+
|
|
380
|
+
values = []
|
|
381
|
+
if isinstance(event, Mapping):
|
|
382
|
+
for name in names:
|
|
383
|
+
if name in event:
|
|
384
|
+
values.append(event[name])
|
|
385
|
+
else:
|
|
386
|
+
for name in names:
|
|
387
|
+
if hasattr(event, name):
|
|
388
|
+
values.append(getattr(event, name))
|
|
389
|
+
if not values:
|
|
390
|
+
return None, True
|
|
391
|
+
first = values[0]
|
|
392
|
+
return first, all(value == first for value in values[1:])
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _strict_quote_number(value: Any) -> Optional[float]:
|
|
396
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
397
|
+
return None
|
|
398
|
+
number = float(value)
|
|
399
|
+
if not math.isfinite(number) or abs(number) >= _MAX_ABS_NUMBER:
|
|
400
|
+
return None
|
|
401
|
+
return number
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _strict_positive_uint64(value: Any) -> Optional[int]:
|
|
405
|
+
if type(value) is not int or value <= 0 or value > _MAX_UINT64:
|
|
406
|
+
return None
|
|
407
|
+
return value
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _epoch_seconds(value: Any) -> Optional[float]:
|
|
411
|
+
"""Parse only explicit, timezone-qualified wall-clock evidence."""
|
|
412
|
+
|
|
413
|
+
if isinstance(value, bool):
|
|
414
|
+
return None
|
|
415
|
+
if isinstance(value, datetime):
|
|
416
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
417
|
+
return None
|
|
418
|
+
try:
|
|
419
|
+
result = value.astimezone(timezone.utc).timestamp()
|
|
420
|
+
except (OverflowError, OSError, ValueError):
|
|
421
|
+
return None
|
|
422
|
+
elif isinstance(value, (int, float)):
|
|
423
|
+
result = float(value)
|
|
424
|
+
elif isinstance(value, str):
|
|
425
|
+
try:
|
|
426
|
+
moment = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
427
|
+
except ValueError:
|
|
428
|
+
return None
|
|
429
|
+
if moment.tzinfo is None or moment.utcoffset() is None:
|
|
430
|
+
return None
|
|
431
|
+
try:
|
|
432
|
+
result = moment.astimezone(timezone.utc).timestamp()
|
|
433
|
+
except (OverflowError, OSError, ValueError):
|
|
434
|
+
return None
|
|
435
|
+
else:
|
|
436
|
+
return None
|
|
437
|
+
if not math.isfinite(result) or abs(result) >= _MAX_ABS_NUMBER:
|
|
438
|
+
return None
|
|
439
|
+
return result
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _on_tick_grid(value: float, tick: float) -> bool:
|
|
443
|
+
try:
|
|
444
|
+
amount = Decimal(str(value))
|
|
445
|
+
increment = Decimal(str(tick))
|
|
446
|
+
return increment > 0 and amount.remainder_near(increment) == 0
|
|
447
|
+
except (InvalidOperation, ValueError):
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _valid_trading_day(value: Any) -> bool:
|
|
452
|
+
if not (isinstance(value, str) and len(value) == 8 and value.isascii() and value.isdecimal()):
|
|
453
|
+
return False
|
|
454
|
+
try:
|
|
455
|
+
datetime.strptime(value, "%Y%m%d")
|
|
456
|
+
except ValueError:
|
|
457
|
+
return False
|
|
458
|
+
return True
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _normalize_trusted_now(
|
|
462
|
+
now: Any,
|
|
463
|
+
*,
|
|
464
|
+
policy: CtpCohortPolicy,
|
|
465
|
+
) -> Tuple[Optional[CtpCohortNow], Optional[str]]:
|
|
466
|
+
"""Return trusted caller time evidence without inventing clock facts."""
|
|
467
|
+
|
|
468
|
+
if now is None:
|
|
469
|
+
return None, CtpCohortReason.TRUSTED_NOW_REQUIRED
|
|
470
|
+
if isinstance(now, CtpCohortNow):
|
|
471
|
+
if now.receive_clock_error_ms > policy.max_receive_clock_error_ms:
|
|
472
|
+
return None, CtpCohortReason.RECEIVE_CLOCK_ERROR_INVALID
|
|
473
|
+
return now, None
|
|
474
|
+
|
|
475
|
+
monotonic = _strict_positive_uint64(
|
|
476
|
+
_event_value(now, "now_monotonic_ns", "recv_monotonic_ns", "received_monotonic_ns")
|
|
477
|
+
)
|
|
478
|
+
epoch = _epoch_seconds(
|
|
479
|
+
_event_value(now, "now_epoch", "now_time_utc", "wall_time_utc", "recv_time_utc")
|
|
480
|
+
)
|
|
481
|
+
clock_domain_id = _event_value(now, "clock_domain_id")
|
|
482
|
+
if monotonic is None or epoch is None or not _is_provenance_identity(clock_domain_id):
|
|
483
|
+
return None, CtpCohortReason.TRUSTED_NOW_INVALID
|
|
484
|
+
if _event_value(now, "receive_clock_quality") != "verified":
|
|
485
|
+
return None, CtpCohortReason.RECEIVE_CLOCK_UNVERIFIED
|
|
486
|
+
if _event_value(now, "freshness_verified") is not True:
|
|
487
|
+
return None, CtpCohortReason.FRESHNESS_UNVERIFIED
|
|
488
|
+
receive_clock_error_ms = _strict_quote_number(_event_value(now, "receive_clock_error_ms"))
|
|
489
|
+
if (
|
|
490
|
+
receive_clock_error_ms is None
|
|
491
|
+
or receive_clock_error_ms < 0.0
|
|
492
|
+
or receive_clock_error_ms > policy.max_receive_clock_error_ms
|
|
493
|
+
):
|
|
494
|
+
return None, CtpCohortReason.RECEIVE_CLOCK_ERROR_INVALID
|
|
495
|
+
return (
|
|
496
|
+
CtpCohortNow(
|
|
497
|
+
now_monotonic_ns=monotonic,
|
|
498
|
+
now_epoch=epoch,
|
|
499
|
+
clock_domain_id=clock_domain_id,
|
|
500
|
+
receive_clock_error_ms=receive_clock_error_ms,
|
|
501
|
+
),
|
|
502
|
+
None,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _scope_from_event(event: Any) -> Optional[Tuple[int, int]]:
|
|
507
|
+
"""Read a complete raw connection/subscription scope without coercion."""
|
|
508
|
+
|
|
509
|
+
connection_generation = _strict_positive_uint64(_event_value(event, "connection_generation"))
|
|
510
|
+
subscription_epoch = _strict_positive_uint64(_event_value(event, "subscription_epoch"))
|
|
511
|
+
if connection_generation is None or subscription_epoch is None:
|
|
512
|
+
return None
|
|
513
|
+
return connection_generation, subscription_epoch
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def validate_ctp_quote(
|
|
517
|
+
event: Any,
|
|
518
|
+
*,
|
|
519
|
+
leg: CtpCohortLeg,
|
|
520
|
+
expected_rules_hash: str,
|
|
521
|
+
policy: CtpCohortPolicy,
|
|
522
|
+
) -> CtpQuoteValidation:
|
|
523
|
+
"""Normalize one ``ctp.quote.v2`` event into immutable evidence.
|
|
524
|
+
|
|
525
|
+
The function does not retain the event and does not use system clocks. In
|
|
526
|
+
particular, a source timestamp is only usable after the producer explicitly
|
|
527
|
+
labels its source clock ``verified``.
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
symbol, symbol_consistent = _consistent_identity_alias(
|
|
531
|
+
event, "symbol", "instrument_id", "InstrumentID"
|
|
532
|
+
)
|
|
533
|
+
if not symbol_consistent:
|
|
534
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_IDENTITY_CONFLICT)
|
|
535
|
+
if not isinstance(symbol, str) or not symbol:
|
|
536
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_SYMBOL_MISSING)
|
|
537
|
+
if symbol != leg.symbol:
|
|
538
|
+
return CtpQuoteValidation(None, CtpCohortReason.UNEXPECTED_SYMBOL)
|
|
539
|
+
|
|
540
|
+
exchange, exchange_consistent = _consistent_identity_alias(
|
|
541
|
+
event, "exchange", "exchange_id", "ExchangeID"
|
|
542
|
+
)
|
|
543
|
+
if not exchange_consistent:
|
|
544
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_IDENTITY_CONFLICT)
|
|
545
|
+
if not isinstance(exchange, str) or exchange != leg.exchange:
|
|
546
|
+
return CtpQuoteValidation(None, CtpCohortReason.EXCHANGE_MISMATCH)
|
|
547
|
+
asset_type, asset_type_consistent = _consistent_identity_alias(
|
|
548
|
+
event, "asset_type", "contract_type"
|
|
549
|
+
)
|
|
550
|
+
if not asset_type_consistent:
|
|
551
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_IDENTITY_CONFLICT)
|
|
552
|
+
if leg.asset_type is not None and asset_type != leg.asset_type:
|
|
553
|
+
return CtpQuoteValidation(None, CtpCohortReason.ASSET_TYPE_MISMATCH)
|
|
554
|
+
if _event_value(event, "schema_version") != "ctp.quote.v2":
|
|
555
|
+
return CtpQuoteValidation(None, CtpCohortReason.UNSUPPORTED_QUOTE_SCHEMA)
|
|
556
|
+
if _event_value(event, "volume_semantics") != "delta":
|
|
557
|
+
return CtpQuoteValidation(None, CtpCohortReason.VOLUME_SEMANTICS_NOT_DELTA)
|
|
558
|
+
if _event_value(event, "source_clock_quality") != "verified":
|
|
559
|
+
return CtpQuoteValidation(None, CtpCohortReason.SOURCE_CLOCK_UNVERIFIED)
|
|
560
|
+
if _event_value(event, "receive_clock_quality") != "verified":
|
|
561
|
+
return CtpQuoteValidation(None, CtpCohortReason.RECEIVE_CLOCK_UNVERIFIED)
|
|
562
|
+
if _event_value(event, "freshness_verified") is not True:
|
|
563
|
+
return CtpQuoteValidation(None, CtpCohortReason.FRESHNESS_UNVERIFIED)
|
|
564
|
+
event_time_source = _event_value(event, "event_time_source")
|
|
565
|
+
if not _is_provenance_identity(event_time_source):
|
|
566
|
+
return CtpQuoteValidation(None, CtpCohortReason.EVENT_TIME_SOURCE_MISSING)
|
|
567
|
+
rules_hash = _event_value(event, "rules_hash")
|
|
568
|
+
if not _is_provenance_identity(rules_hash) or rules_hash != expected_rules_hash:
|
|
569
|
+
return CtpQuoteValidation(None, CtpCohortReason.RULES_HASH_MISMATCH)
|
|
570
|
+
source = _event_value(event, "source")
|
|
571
|
+
if not _is_provenance_identity(source):
|
|
572
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_SOURCE_MISSING)
|
|
573
|
+
if _event_value(event, "stale") is not False:
|
|
574
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_STREAM_UNREADY)
|
|
575
|
+
if _event_value(event, "stale_reason") != "":
|
|
576
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_STREAM_UNREADY)
|
|
577
|
+
if _event_value(event, "continuity_status") != "continuous":
|
|
578
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_CONTINUITY_NOT_CONTINUOUS)
|
|
579
|
+
quality_flags = _event_value(event, "quality_flags")
|
|
580
|
+
if not isinstance(quality_flags, (list, tuple, set, frozenset)):
|
|
581
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_QUALITY_FLAGS_INVALID)
|
|
582
|
+
if quality_flags:
|
|
583
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_QUALITY_FLAGS_PRESENT)
|
|
584
|
+
if _event_value(event, "execution_eligible") is not True:
|
|
585
|
+
return CtpQuoteValidation(None, CtpCohortReason.EXECUTION_INELIGIBLE_QUOTE)
|
|
586
|
+
if _event_value(event, "volume_complete") is not True:
|
|
587
|
+
return CtpQuoteValidation(None, CtpCohortReason.VOLUME_INCOMPLETE)
|
|
588
|
+
if _event_value(event, "volume_quality") != "CONTINUOUS":
|
|
589
|
+
return CtpQuoteValidation(None, CtpCohortReason.VOLUME_QUALITY_NOT_CONTINUOUS)
|
|
590
|
+
|
|
591
|
+
numeric_fields = {
|
|
592
|
+
"bid": _event_value(event, "bid_price", "bid", "BidPrice1"),
|
|
593
|
+
"ask": _event_value(event, "ask_price", "ask", "AskPrice1"),
|
|
594
|
+
"bid_size": _event_value(event, "bid_volume", "bid_size", "BidVolume1"),
|
|
595
|
+
"ask_size": _event_value(event, "ask_volume", "ask_size", "AskVolume1"),
|
|
596
|
+
"last": _event_value(event, "price", "last_price", "last", "LastPrice"),
|
|
597
|
+
"lower_limit": _event_value(
|
|
598
|
+
event,
|
|
599
|
+
"lower_limit_price",
|
|
600
|
+
"lower_limit",
|
|
601
|
+
"LowerLimitPrice",
|
|
602
|
+
),
|
|
603
|
+
"upper_limit": _event_value(
|
|
604
|
+
event,
|
|
605
|
+
"upper_limit_price",
|
|
606
|
+
"upper_limit",
|
|
607
|
+
"UpperLimitPrice",
|
|
608
|
+
),
|
|
609
|
+
"source_clock_error_ms": _event_value(event, "source_clock_error_ms"),
|
|
610
|
+
"receive_clock_error_ms": _event_value(event, "receive_clock_error_ms"),
|
|
611
|
+
}
|
|
612
|
+
parsed: dict[str, float] = {}
|
|
613
|
+
for name, value in numeric_fields.items():
|
|
614
|
+
number = _strict_quote_number(value)
|
|
615
|
+
if number is None:
|
|
616
|
+
if name in {"source_clock_error_ms", "receive_clock_error_ms"}:
|
|
617
|
+
reason = (
|
|
618
|
+
CtpCohortReason.SOURCE_CLOCK_ERROR_INVALID
|
|
619
|
+
if name == "source_clock_error_ms"
|
|
620
|
+
else CtpCohortReason.RECEIVE_CLOCK_ERROR_INVALID
|
|
621
|
+
)
|
|
622
|
+
return CtpQuoteValidation(None, reason)
|
|
623
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_NUMERIC_TYPE_INVALID)
|
|
624
|
+
parsed[name] = number
|
|
625
|
+
|
|
626
|
+
bid, ask, bid_size, ask_size, last = (
|
|
627
|
+
parsed["bid"],
|
|
628
|
+
parsed["ask"],
|
|
629
|
+
parsed["bid_size"],
|
|
630
|
+
parsed["ask_size"],
|
|
631
|
+
parsed["last"],
|
|
632
|
+
)
|
|
633
|
+
lower_limit, upper_limit = parsed["lower_limit"], parsed["upper_limit"]
|
|
634
|
+
source_clock_error_ms = parsed["source_clock_error_ms"]
|
|
635
|
+
receive_clock_error_ms = parsed["receive_clock_error_ms"]
|
|
636
|
+
if min(bid, ask, bid_size, ask_size, last) <= 0.0:
|
|
637
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_NONPOSITIVE)
|
|
638
|
+
if ask < bid:
|
|
639
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_CROSSED)
|
|
640
|
+
if lower_limit <= 0.0 or upper_limit <= lower_limit:
|
|
641
|
+
return CtpQuoteValidation(None, CtpCohortReason.DAILY_PRICE_LIMIT_INVALID)
|
|
642
|
+
if any(price < lower_limit or price > upper_limit for price in (bid, ask, last)):
|
|
643
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_OUTSIDE_DAILY_LIMIT)
|
|
644
|
+
if any(
|
|
645
|
+
not _on_tick_grid(price, leg.price_tick)
|
|
646
|
+
for price in (bid, ask, last, lower_limit, upper_limit)
|
|
647
|
+
):
|
|
648
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_OFF_TICK_GRID)
|
|
649
|
+
if source_clock_error_ms < 0.0 or source_clock_error_ms > policy.max_source_clock_error_ms:
|
|
650
|
+
return CtpQuoteValidation(None, CtpCohortReason.SOURCE_CLOCK_ERROR_INVALID)
|
|
651
|
+
if receive_clock_error_ms < 0.0 or receive_clock_error_ms > policy.max_receive_clock_error_ms:
|
|
652
|
+
return CtpQuoteValidation(None, CtpCohortReason.RECEIVE_CLOCK_ERROR_INVALID)
|
|
653
|
+
|
|
654
|
+
source_epoch = _epoch_seconds(_event_value(event, "event_time_utc", "timestamp"))
|
|
655
|
+
if source_epoch is None:
|
|
656
|
+
return CtpQuoteValidation(None, CtpCohortReason.SOURCE_TIME_INVALID)
|
|
657
|
+
receive_epoch = _epoch_seconds(
|
|
658
|
+
_event_value(event, "recv_time_utc", "received_wall_time", "local_time")
|
|
659
|
+
)
|
|
660
|
+
if receive_epoch is None:
|
|
661
|
+
return CtpQuoteValidation(None, CtpCohortReason.RECEIVE_TIME_INVALID)
|
|
662
|
+
receive_monotonic_ns = _strict_positive_uint64(
|
|
663
|
+
_event_value(event, "recv_monotonic_ns", "received_monotonic_ns")
|
|
664
|
+
)
|
|
665
|
+
ingest_seq = _strict_positive_uint64(_event_value(event, "ingest_seq", "sequence"))
|
|
666
|
+
connection_generation = _strict_positive_uint64(_event_value(event, "connection_generation"))
|
|
667
|
+
subscription_epoch = _strict_positive_uint64(_event_value(event, "subscription_epoch"))
|
|
668
|
+
if None in (receive_monotonic_ns, ingest_seq, connection_generation, subscription_epoch):
|
|
669
|
+
return CtpQuoteValidation(None, CtpCohortReason.QUOTE_IDENTITY_TYPE_INVALID)
|
|
670
|
+
trading_day = _event_value(event, "trading_day", "TradingDay")
|
|
671
|
+
if not _valid_trading_day(trading_day):
|
|
672
|
+
return CtpQuoteValidation(None, CtpCohortReason.TRADING_DAY_INVALID)
|
|
673
|
+
action_day = _event_value(event, "action_day", "ActionDay")
|
|
674
|
+
if not _valid_trading_day(action_day):
|
|
675
|
+
return CtpQuoteValidation(None, CtpCohortReason.ACTION_DAY_INVALID)
|
|
676
|
+
clock_domain_id = _event_value(event, "clock_domain_id")
|
|
677
|
+
if not _is_provenance_identity(clock_domain_id):
|
|
678
|
+
return CtpQuoteValidation(None, CtpCohortReason.CLOCK_DOMAIN_UNKNOWN)
|
|
679
|
+
if source_epoch > receive_epoch:
|
|
680
|
+
return CtpQuoteValidation(None, CtpCohortReason.SOURCE_TIME_AFTER_RECEIVE)
|
|
681
|
+
|
|
682
|
+
return CtpQuoteValidation(
|
|
683
|
+
CtpQuoteEvidence(
|
|
684
|
+
symbol=symbol,
|
|
685
|
+
exchange=exchange,
|
|
686
|
+
asset_type=asset_type if isinstance(asset_type, str) else None,
|
|
687
|
+
bid=bid,
|
|
688
|
+
ask=ask,
|
|
689
|
+
bid_size=bid_size,
|
|
690
|
+
ask_size=ask_size,
|
|
691
|
+
last=last,
|
|
692
|
+
lower_limit=lower_limit,
|
|
693
|
+
upper_limit=upper_limit,
|
|
694
|
+
source_epoch=source_epoch,
|
|
695
|
+
receive_epoch=receive_epoch,
|
|
696
|
+
receive_monotonic_ns=receive_monotonic_ns,
|
|
697
|
+
ingest_seq=ingest_seq,
|
|
698
|
+
connection_generation=connection_generation,
|
|
699
|
+
subscription_epoch=subscription_epoch,
|
|
700
|
+
trading_day=trading_day,
|
|
701
|
+
action_day=action_day,
|
|
702
|
+
clock_domain_id=clock_domain_id,
|
|
703
|
+
rules_hash=rules_hash,
|
|
704
|
+
source=source,
|
|
705
|
+
event_time_source=event_time_source,
|
|
706
|
+
source_clock_error_ms=source_clock_error_ms,
|
|
707
|
+
receive_clock_error_ms=receive_clock_error_ms,
|
|
708
|
+
),
|
|
709
|
+
None,
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
class CtpQuoteCohortValidator:
|
|
714
|
+
"""Statefully admit only fresh, synchronized CTP quote cohorts.
|
|
715
|
+
|
|
716
|
+
``expected_legs`` is copied to an immutable tuple at construction. Each
|
|
717
|
+
call to :meth:`ingest` either returns a reason or one immutable cohort.
|
|
718
|
+
The caller supplies :class:`CtpCohortNow` evidence on every call; this
|
|
719
|
+
avoids treating an arrival as the current time and makes queue delays
|
|
720
|
+
fail closed. Sequence and admission watermarks are partitioned by
|
|
721
|
+
``(connection_generation, subscription_epoch)`` so a verified reconnect
|
|
722
|
+
can restart its ingest sequence at one without mixing generations.
|
|
723
|
+
"""
|
|
724
|
+
|
|
725
|
+
def __init__(
|
|
726
|
+
self,
|
|
727
|
+
*,
|
|
728
|
+
expected_legs: Iterable[CtpCohortLeg],
|
|
729
|
+
expected_rules_hash: str,
|
|
730
|
+
policy: CtpCohortPolicy,
|
|
731
|
+
) -> None:
|
|
732
|
+
legs = tuple(expected_legs)
|
|
733
|
+
if len(legs) not in (2, 3):
|
|
734
|
+
raise ValueError("expected_legs must contain exactly two or three CtpCohortLeg values")
|
|
735
|
+
if not all(isinstance(leg, CtpCohortLeg) for leg in legs):
|
|
736
|
+
raise TypeError("expected_legs must contain only CtpCohortLeg values")
|
|
737
|
+
symbols = tuple(leg.symbol for leg in legs)
|
|
738
|
+
if len(set(symbols)) != len(symbols):
|
|
739
|
+
raise ValueError("expected_legs must have unique symbols")
|
|
740
|
+
exchanges = {leg.exchange for leg in legs}
|
|
741
|
+
if len(exchanges) != 1:
|
|
742
|
+
raise ValueError("expected_legs must use one exchange")
|
|
743
|
+
if not isinstance(policy, CtpCohortPolicy):
|
|
744
|
+
raise TypeError("policy must be a CtpCohortPolicy")
|
|
745
|
+
|
|
746
|
+
self.expected_legs = legs
|
|
747
|
+
self.expected_rules_hash = _strict_provenance_identity(
|
|
748
|
+
expected_rules_hash,
|
|
749
|
+
field="expected_rules_hash",
|
|
750
|
+
)
|
|
751
|
+
self.policy = policy
|
|
752
|
+
self._legs_by_symbol = MappingProxyType({leg.symbol: leg for leg in legs})
|
|
753
|
+
self._latest: dict[str, CtpQuoteEvidence] = {}
|
|
754
|
+
self._active_scope: Optional[Tuple[int, int]] = None
|
|
755
|
+
# Both values are producer-owned unsigned incarnations. Lexicographic
|
|
756
|
+
# order permits a new connection to restart its subscription epoch,
|
|
757
|
+
# while a delayed packet from any previously observed incarnation can
|
|
758
|
+
# never make the validator move backwards.
|
|
759
|
+
self._highest_scope: Optional[Tuple[int, int]] = None
|
|
760
|
+
self._retired_scopes: set[Tuple[int, int]] = set()
|
|
761
|
+
self._last_seen_by_scope: dict[Tuple[int, int], dict[str, CtpQuoteEvidence]] = {}
|
|
762
|
+
self._last_admitted_sequences: dict[Tuple[int, int], dict[str, int]] = {}
|
|
763
|
+
self._confirmed_cohort: Optional[CtpQuoteCohort] = None
|
|
764
|
+
|
|
765
|
+
@property
|
|
766
|
+
def expected_symbols(self) -> Tuple[str, ...]:
|
|
767
|
+
"""Configured symbols in their caller-supplied, frozen order."""
|
|
768
|
+
|
|
769
|
+
return tuple(leg.symbol for leg in self.expected_legs)
|
|
770
|
+
|
|
771
|
+
def reset(self) -> None:
|
|
772
|
+
"""Discard retained evidence, for example after an explicit session reset."""
|
|
773
|
+
|
|
774
|
+
self._latest.clear()
|
|
775
|
+
self._active_scope = None
|
|
776
|
+
self._highest_scope = None
|
|
777
|
+
self._retired_scopes.clear()
|
|
778
|
+
self._last_seen_by_scope.clear()
|
|
779
|
+
self._last_admitted_sequences.clear()
|
|
780
|
+
self._confirmed_cohort = None
|
|
781
|
+
|
|
782
|
+
def ingest(self, event: Any, *, now: Any = None) -> CtpCohortResult:
|
|
783
|
+
"""Validate one quote and return a cohort only when all legs are fresh."""
|
|
784
|
+
|
|
785
|
+
symbol = _event_value(event, "symbol", "instrument_id", "InstrumentID")
|
|
786
|
+
if not isinstance(symbol, str) or not symbol:
|
|
787
|
+
return CtpCohortResult(None, CtpCohortReason.QUOTE_SYMBOL_MISSING)
|
|
788
|
+
leg = self._legs_by_symbol.get(symbol)
|
|
789
|
+
if leg is None:
|
|
790
|
+
return CtpCohortResult(None, CtpCohortReason.UNEXPECTED_SYMBOL)
|
|
791
|
+
raw_scope = _scope_from_event(event)
|
|
792
|
+
if self._is_scope_rollback(raw_scope):
|
|
793
|
+
# A delayed prior connection/subscription packet is neither a
|
|
794
|
+
# signal nor a reason to invalidate the current newer round.
|
|
795
|
+
return CtpCohortResult(None, CtpCohortReason.RETIRED_CONNECTION_SCOPE)
|
|
796
|
+
validation = validate_ctp_quote(
|
|
797
|
+
event,
|
|
798
|
+
leg=leg,
|
|
799
|
+
expected_rules_hash=self.expected_rules_hash,
|
|
800
|
+
policy=self.policy,
|
|
801
|
+
)
|
|
802
|
+
if validation.quote is None:
|
|
803
|
+
self._invalidate_after_expected_failure(_scope_from_event(event))
|
|
804
|
+
return CtpCohortResult(None, validation.reason)
|
|
805
|
+
quote = validation.quote
|
|
806
|
+
scope = (quote.connection_generation, quote.subscription_epoch)
|
|
807
|
+
if self._is_scope_rollback(scope) or scope in self._retired_scopes:
|
|
808
|
+
return CtpCohortResult(None, CtpCohortReason.RETIRED_CONNECTION_SCOPE)
|
|
809
|
+
if self._highest_scope is None or scope > self._highest_scope:
|
|
810
|
+
self._highest_scope = scope
|
|
811
|
+
if self._active_scope != scope:
|
|
812
|
+
self._activate_scope(scope)
|
|
813
|
+
trusted_now, now_reason = _normalize_trusted_now(now, policy=self.policy)
|
|
814
|
+
if trusted_now is None:
|
|
815
|
+
self._invalidate_current_round()
|
|
816
|
+
return CtpCohortResult(None, now_reason)
|
|
817
|
+
quote_time_reason = self._validate_quote_at(quote, now=trusted_now)
|
|
818
|
+
if quote_time_reason is not None:
|
|
819
|
+
self._invalidate_current_round()
|
|
820
|
+
return CtpCohortResult(None, quote_time_reason)
|
|
821
|
+
|
|
822
|
+
prior = self._last_seen_by_scope.get(scope, {}).get(quote.symbol)
|
|
823
|
+
if prior is not None:
|
|
824
|
+
if quote.ingest_seq <= prior.ingest_seq:
|
|
825
|
+
self._invalidate_current_round()
|
|
826
|
+
return CtpCohortResult(None, CtpCohortReason.DUPLICATE_OR_OUT_OF_ORDER)
|
|
827
|
+
if quote.receive_monotonic_ns < prior.receive_monotonic_ns:
|
|
828
|
+
self._invalidate_current_round()
|
|
829
|
+
return CtpCohortResult(None, CtpCohortReason.OUT_OF_ORDER_RECEIVE_TIME)
|
|
830
|
+
if quote.source_epoch < prior.source_epoch:
|
|
831
|
+
self._invalidate_current_round()
|
|
832
|
+
return CtpCohortResult(None, CtpCohortReason.OUT_OF_ORDER_SOURCE_TIME)
|
|
833
|
+
|
|
834
|
+
if self._confirmed_cohort is not None:
|
|
835
|
+
confirmed_quote = self._confirmed_cohort.quote_for(quote.symbol)
|
|
836
|
+
if quote.update_identity != confirmed_quote.update_identity:
|
|
837
|
+
# A newer valid update makes the prior all-leg decision stale
|
|
838
|
+
# even before the remaining legs complete their next round.
|
|
839
|
+
self._confirmed_cohort = None
|
|
840
|
+
self._last_seen_by_scope.setdefault(scope, {})[quote.symbol] = quote
|
|
841
|
+
self._latest[quote.symbol] = quote
|
|
842
|
+
if len(self._latest) != len(self.expected_legs):
|
|
843
|
+
return CtpCohortResult(None, CtpCohortReason.WAITING_FOR_LEGS)
|
|
844
|
+
|
|
845
|
+
quotes = {symbol: self._latest[symbol] for symbol in self.expected_symbols}
|
|
846
|
+
cohort_reason = self._validate_cohort(quotes, now=trusted_now)
|
|
847
|
+
if cohort_reason is not None:
|
|
848
|
+
self._invalidate_current_round()
|
|
849
|
+
return CtpCohortResult(None, cohort_reason)
|
|
850
|
+
admission_watermark = self._last_admitted_sequences.setdefault(
|
|
851
|
+
scope,
|
|
852
|
+
dict.fromkeys(self.expected_symbols, 0),
|
|
853
|
+
)
|
|
854
|
+
if any(
|
|
855
|
+
quotes[symbol].ingest_seq <= admission_watermark[symbol]
|
|
856
|
+
for symbol in self.expected_symbols
|
|
857
|
+
):
|
|
858
|
+
return CtpCohortResult(None, CtpCohortReason.WAITING_FOR_ALL_LEGS_NEW)
|
|
859
|
+
|
|
860
|
+
self._last_admitted_sequences[scope] = {
|
|
861
|
+
symbol: quotes[symbol].ingest_seq for symbol in self.expected_symbols
|
|
862
|
+
}
|
|
863
|
+
first = quotes[self.expected_symbols[0]]
|
|
864
|
+
cohort = self._make_cohort(quotes, first=first)
|
|
865
|
+
self._confirmed_cohort = cohort
|
|
866
|
+
return CtpCohortResult(cohort, None)
|
|
867
|
+
|
|
868
|
+
def validate_at(self, *, now: Any = None) -> CtpCohortResult:
|
|
869
|
+
"""Recheck the currently confirmed cohort immediately before use.
|
|
870
|
+
|
|
871
|
+
A caller should invoke this at the final execution boundary. The
|
|
872
|
+
method does not create an order; it only proves that the previously
|
|
873
|
+
admitted immutable evidence is still fresh against caller-supplied,
|
|
874
|
+
trusted time evidence.
|
|
875
|
+
"""
|
|
876
|
+
|
|
877
|
+
cohort = self._confirmed_cohort
|
|
878
|
+
if cohort is None:
|
|
879
|
+
return CtpCohortResult(None, CtpCohortReason.NO_CONFIRMED_COHORT)
|
|
880
|
+
trusted_now, now_reason = _normalize_trusted_now(now, policy=self.policy)
|
|
881
|
+
if trusted_now is None:
|
|
882
|
+
self._invalidate_current_round()
|
|
883
|
+
return CtpCohortResult(None, now_reason)
|
|
884
|
+
scope = (cohort.connection_generation, cohort.subscription_epoch)
|
|
885
|
+
if self._active_scope != scope or scope in self._retired_scopes:
|
|
886
|
+
self._invalidate_current_round()
|
|
887
|
+
return CtpCohortResult(None, CtpCohortReason.RETIRED_CONNECTION_SCOPE)
|
|
888
|
+
cohort_reason = self._validate_cohort(cohort.quotes, now=trusted_now)
|
|
889
|
+
if cohort_reason is not None:
|
|
890
|
+
self._invalidate_current_round()
|
|
891
|
+
return CtpCohortResult(None, cohort_reason)
|
|
892
|
+
return CtpCohortResult(cohort, None)
|
|
893
|
+
|
|
894
|
+
def recheck(self, *, now: Any = None) -> CtpCohortResult:
|
|
895
|
+
"""Alias for :meth:`validate_at` at an execution submission boundary."""
|
|
896
|
+
|
|
897
|
+
return self.validate_at(now=now)
|
|
898
|
+
|
|
899
|
+
def _invalidate_current_round(self) -> None:
|
|
900
|
+
"""Forget retained quote and confirmation evidence after a failed gate.
|
|
901
|
+
|
|
902
|
+
Sequence watermarks remain scoped and retained. Therefore recovery
|
|
903
|
+
requires a fresh valid quote from every leg and cannot reuse a prior
|
|
904
|
+
admitted update identity.
|
|
905
|
+
"""
|
|
906
|
+
|
|
907
|
+
self._latest.clear()
|
|
908
|
+
self._confirmed_cohort = None
|
|
909
|
+
|
|
910
|
+
def _invalidate_after_expected_failure(self, failed_scope: Optional[Tuple[int, int]]) -> None:
|
|
911
|
+
"""Invalidate evidence and retire an older scope when raw identity proves a switch."""
|
|
912
|
+
|
|
913
|
+
if self._is_scope_rollback(failed_scope):
|
|
914
|
+
return
|
|
915
|
+
if (
|
|
916
|
+
failed_scope is not None
|
|
917
|
+
and failed_scope not in self._retired_scopes
|
|
918
|
+
and self._active_scope != failed_scope
|
|
919
|
+
):
|
|
920
|
+
if self._highest_scope is None or failed_scope > self._highest_scope:
|
|
921
|
+
self._highest_scope = failed_scope
|
|
922
|
+
self._activate_scope(failed_scope)
|
|
923
|
+
return
|
|
924
|
+
self._invalidate_current_round()
|
|
925
|
+
|
|
926
|
+
def _is_scope_rollback(self, scope: Optional[Tuple[int, int]]) -> bool:
|
|
927
|
+
"""Return true when a raw quote is from an older producer incarnation."""
|
|
928
|
+
|
|
929
|
+
return scope is not None and self._highest_scope is not None and scope < self._highest_scope
|
|
930
|
+
|
|
931
|
+
def _activate_scope(self, scope: Tuple[int, int]) -> None:
|
|
932
|
+
"""Start a new connection/subscription scope without mixing evidence."""
|
|
933
|
+
|
|
934
|
+
if self._active_scope == scope:
|
|
935
|
+
return
|
|
936
|
+
if self._active_scope is not None:
|
|
937
|
+
self._retired_scopes.add(self._active_scope)
|
|
938
|
+
self._invalidate_current_round()
|
|
939
|
+
self._active_scope = scope
|
|
940
|
+
|
|
941
|
+
def _validate_quote_at(
|
|
942
|
+
self,
|
|
943
|
+
quote: CtpQuoteEvidence,
|
|
944
|
+
*,
|
|
945
|
+
now: CtpCohortNow,
|
|
946
|
+
) -> Optional[str]:
|
|
947
|
+
"""Validate absolute freshness against trusted same-domain current time."""
|
|
948
|
+
|
|
949
|
+
if quote.clock_domain_id != now.clock_domain_id:
|
|
950
|
+
return CtpCohortReason.NOW_CLOCK_DOMAIN_MISMATCH
|
|
951
|
+
if now.now_monotonic_ns < quote.receive_monotonic_ns:
|
|
952
|
+
return CtpCohortReason.OUT_OF_ORDER_RECEIVE_TIME
|
|
953
|
+
monotonic_age_ms = (now.now_monotonic_ns - quote.receive_monotonic_ns) / 1_000_000.0
|
|
954
|
+
if monotonic_age_ms > self.policy.max_receive_age_ms:
|
|
955
|
+
return CtpCohortReason.STALE_COHORT_RECEIVE_TIME
|
|
956
|
+
|
|
957
|
+
now_wall_high = now.now_epoch + now.receive_clock_error_ms / 1_000.0
|
|
958
|
+
quote_receive_low = quote.receive_epoch - quote.receive_clock_error_ms / 1_000.0
|
|
959
|
+
quote_source_low = quote.source_epoch - quote.source_clock_error_ms / 1_000.0
|
|
960
|
+
if now_wall_high < quote_receive_low or now_wall_high < quote_source_low:
|
|
961
|
+
return CtpCohortReason.NOW_WALL_TIME_BEFORE_QUOTE
|
|
962
|
+
receive_age_ms = (now_wall_high - quote_receive_low) * 1_000.0
|
|
963
|
+
if receive_age_ms > self.policy.max_receive_age_ms:
|
|
964
|
+
return CtpCohortReason.STALE_COHORT_RECEIVE_TIME
|
|
965
|
+
source_age_ms = (now_wall_high - quote_source_low) * 1_000.0
|
|
966
|
+
if source_age_ms > self.policy.max_source_age_ms:
|
|
967
|
+
return CtpCohortReason.STALE_COHORT_SOURCE_TIME
|
|
968
|
+
return None
|
|
969
|
+
|
|
970
|
+
def _make_cohort(
|
|
971
|
+
self,
|
|
972
|
+
quotes: Mapping[str, CtpQuoteEvidence],
|
|
973
|
+
*,
|
|
974
|
+
first: CtpQuoteEvidence,
|
|
975
|
+
) -> CtpQuoteCohort:
|
|
976
|
+
cohort_id = "|".join(
|
|
977
|
+
f"{symbol}:{quotes[symbol].connection_generation}:{quotes[symbol].subscription_epoch}:"
|
|
978
|
+
f"{quotes[symbol].ingest_seq}"
|
|
979
|
+
for symbol in sorted(quotes)
|
|
980
|
+
)
|
|
981
|
+
return CtpQuoteCohort(
|
|
982
|
+
quotes=MappingProxyType(dict(quotes)),
|
|
983
|
+
exchange=first.exchange,
|
|
984
|
+
trading_day=first.trading_day,
|
|
985
|
+
action_day=first.action_day,
|
|
986
|
+
connection_generation=first.connection_generation,
|
|
987
|
+
subscription_epoch=first.subscription_epoch,
|
|
988
|
+
clock_domain_id=first.clock_domain_id,
|
|
989
|
+
rules_hash=first.rules_hash,
|
|
990
|
+
cohort_id=cohort_id,
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
def _validate_cohort(
|
|
994
|
+
self,
|
|
995
|
+
quotes: Mapping[str, CtpQuoteEvidence],
|
|
996
|
+
*,
|
|
997
|
+
now: CtpCohortNow,
|
|
998
|
+
) -> Optional[str]:
|
|
999
|
+
if tuple(quotes) != self.expected_symbols:
|
|
1000
|
+
return CtpCohortReason.WAITING_FOR_LEGS
|
|
1001
|
+
if any(quotes[symbol].symbol != symbol for symbol in self.expected_symbols):
|
|
1002
|
+
return CtpCohortReason.WAITING_FOR_LEGS
|
|
1003
|
+
if len({quote.exchange for quote in quotes.values()}) != 1:
|
|
1004
|
+
return CtpCohortReason.COHORT_EXCHANGE_MISMATCH
|
|
1005
|
+
if len({quote.trading_day for quote in quotes.values()}) != 1:
|
|
1006
|
+
return CtpCohortReason.COHORT_TRADING_DAY_MISMATCH
|
|
1007
|
+
if len({quote.action_day for quote in quotes.values()}) != 1:
|
|
1008
|
+
return CtpCohortReason.COHORT_ACTION_DAY_MISMATCH
|
|
1009
|
+
if len({quote.connection_generation for quote in quotes.values()}) != 1:
|
|
1010
|
+
return CtpCohortReason.COHORT_CONNECTION_GENERATION_MISMATCH
|
|
1011
|
+
if len({quote.subscription_epoch for quote in quotes.values()}) != 1:
|
|
1012
|
+
return CtpCohortReason.COHORT_SUBSCRIPTION_EPOCH_MISMATCH
|
|
1013
|
+
if len({quote.rules_hash for quote in quotes.values()}) != 1:
|
|
1014
|
+
return CtpCohortReason.COHORT_RULES_HASH_MISMATCH
|
|
1015
|
+
if len({quote.clock_domain_id for quote in quotes.values()}) != 1:
|
|
1016
|
+
return CtpCohortReason.COHORT_CLOCK_DOMAIN_MISMATCH
|
|
1017
|
+
|
|
1018
|
+
for quote in quotes.values():
|
|
1019
|
+
quote_time_reason = self._validate_quote_at(quote, now=now)
|
|
1020
|
+
if quote_time_reason is not None:
|
|
1021
|
+
return quote_time_reason
|
|
1022
|
+
|
|
1023
|
+
receive_values = [quote.receive_monotonic_ns for quote in quotes.values()]
|
|
1024
|
+
receive_skew_ms = (max(receive_values) - min(receive_values)) / 1_000_000.0
|
|
1025
|
+
if receive_skew_ms > self.policy.max_receive_skew_ms:
|
|
1026
|
+
return CtpCohortReason.BLOCKED_CROSS_LEG_SKEW
|
|
1027
|
+
|
|
1028
|
+
source_lows = [
|
|
1029
|
+
quote.source_epoch - quote.source_clock_error_ms / 1_000.0 for quote in quotes.values()
|
|
1030
|
+
]
|
|
1031
|
+
source_highs = [
|
|
1032
|
+
quote.source_epoch + quote.source_clock_error_ms / 1_000.0 for quote in quotes.values()
|
|
1033
|
+
]
|
|
1034
|
+
source_skew_ms = (max(source_highs) - min(source_lows)) * 1_000.0
|
|
1035
|
+
if source_skew_ms > self.policy.max_source_skew_ms:
|
|
1036
|
+
return CtpCohortReason.BLOCKED_SOURCE_SKEW
|
|
1037
|
+
return None
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
__all__ = [
|
|
1041
|
+
"CtpCohortPolicy",
|
|
1042
|
+
"CtpCohortNow",
|
|
1043
|
+
"CtpCohortReason",
|
|
1044
|
+
"CtpCohortLeg",
|
|
1045
|
+
"CtpQuoteEvidence",
|
|
1046
|
+
"CtpQuoteValidation",
|
|
1047
|
+
"CtpQuoteCohort",
|
|
1048
|
+
"CtpCohortResult",
|
|
1049
|
+
"CtpQuoteCohortValidator",
|
|
1050
|
+
"validate_ctp_quote",
|
|
1051
|
+
]
|