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,2972 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Trade Logger Observer - Comprehensive logging for backtrader.
|
|
3
|
+
|
|
4
|
+
This module provides the TradeLogger observer for automatically recording
|
|
5
|
+
all trading activities including orders, trades, positions, indicators,
|
|
6
|
+
and signals.
|
|
7
|
+
|
|
8
|
+
Features:
|
|
9
|
+
- Order logging (order.log)
|
|
10
|
+
- Trade logging (trade.log)
|
|
11
|
+
- Position logging (position.log) - every bar
|
|
12
|
+
- Indicator logging (indicator.log) - every bar
|
|
13
|
+
- Signal logging (signal.log) - on buy/sell
|
|
14
|
+
- Tick logging (tick.log) - every tick received
|
|
15
|
+
- Bar logging (bar.log) - every synthesized bar
|
|
16
|
+
- Position snapshot (current_position.yaml)
|
|
17
|
+
- Optional MySQL support
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
>>> cerebro = bt.Cerebro()
|
|
21
|
+
>>> cerebro.addobserver(bt.observers.TradeLogger,
|
|
22
|
+
... log_dir='./logs',
|
|
23
|
+
... log_orders=True,
|
|
24
|
+
... log_trades=True,
|
|
25
|
+
... log_positions=True,
|
|
26
|
+
... log_indicators=True,
|
|
27
|
+
... log_signals=True)
|
|
28
|
+
>>> cerebro.run()
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import collections
|
|
32
|
+
import copy
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import math
|
|
36
|
+
import os
|
|
37
|
+
import time
|
|
38
|
+
import uuid
|
|
39
|
+
from collections.abc import Mapping
|
|
40
|
+
from datetime import datetime, timedelta, timezone
|
|
41
|
+
|
|
42
|
+
from ..observer import Observer
|
|
43
|
+
from ..utils.log_message import get_logger
|
|
44
|
+
|
|
45
|
+
logger = get_logger(__name__)
|
|
46
|
+
|
|
47
|
+
# Shanghai timezone (UTC+8) used for all log timestamps
|
|
48
|
+
_SHANGHAI_TZ = timezone(timedelta(hours=8))
|
|
49
|
+
|
|
50
|
+
# The report is deliberately an in-memory observer product. It must stay
|
|
51
|
+
# independent from the file/MySQL logging switches below so a caller can keep
|
|
52
|
+
# a lightweight, real-time status view without producing another stream of
|
|
53
|
+
# high-frequency log records.
|
|
54
|
+
_REPORT_SCHEMA_VERSION = 1
|
|
55
|
+
_REPORT_EVENT_KEYS = (
|
|
56
|
+
"orders",
|
|
57
|
+
"trades",
|
|
58
|
+
"signals",
|
|
59
|
+
"ticks",
|
|
60
|
+
"bars",
|
|
61
|
+
"store",
|
|
62
|
+
"data",
|
|
63
|
+
"errors",
|
|
64
|
+
)
|
|
65
|
+
# Completed feed callbacks are normally consumed by the immediately following
|
|
66
|
+
# LineSeries observer step. Keep a bounded safety window for malformed/custom
|
|
67
|
+
# events whose timestamp never reaches that step.
|
|
68
|
+
_REPORT_PENDING_BAR_LIMIT = 1024
|
|
69
|
+
_STARTUP_ACCOUNT_OBSERVATION_SCOPE = "authoritative_startup_account_observation"
|
|
70
|
+
_STARTUP_ACCOUNT_OBSERVATION_SENSITIVE_KEY_FRAGMENTS = (
|
|
71
|
+
"password",
|
|
72
|
+
"passwd",
|
|
73
|
+
"secret",
|
|
74
|
+
"token",
|
|
75
|
+
"apikey",
|
|
76
|
+
"accesskey",
|
|
77
|
+
"privatekey",
|
|
78
|
+
"authorization",
|
|
79
|
+
"cookie",
|
|
80
|
+
"credential",
|
|
81
|
+
"passphrase",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Optional MySQL support
|
|
85
|
+
try:
|
|
86
|
+
import pymysql
|
|
87
|
+
|
|
88
|
+
MYSQL_AVAILABLE = True
|
|
89
|
+
except ImportError:
|
|
90
|
+
MYSQL_AVAILABLE = False
|
|
91
|
+
|
|
92
|
+
# Optional YAML support
|
|
93
|
+
try:
|
|
94
|
+
import yaml
|
|
95
|
+
|
|
96
|
+
YAML_AVAILABLE = True
|
|
97
|
+
except ImportError:
|
|
98
|
+
YAML_AVAILABLE = False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TradeLogger(Observer):
|
|
102
|
+
"""Observer that automatically logs all trading activities.
|
|
103
|
+
|
|
104
|
+
This observer tracks and records:
|
|
105
|
+
- Order status changes (submitted, executed, canceled, etc.)
|
|
106
|
+
- Trade openings and closings with PnL
|
|
107
|
+
- Position changes on every bar
|
|
108
|
+
- Indicator values on every bar
|
|
109
|
+
- Buy/sell signals
|
|
110
|
+
|
|
111
|
+
Params:
|
|
112
|
+
log_dir (str): Directory for log files. Default: './logs'
|
|
113
|
+
log_orders (bool): Enable order logging. Default: True
|
|
114
|
+
log_trades (bool): Enable trade logging. Default: True
|
|
115
|
+
log_positions (bool): Enable position logging. Default: True
|
|
116
|
+
log_indicators (bool): Enable indicator logging. Default: True
|
|
117
|
+
log_signals (bool): Enable signal logging. Default: True
|
|
118
|
+
log_ticks (bool): Enable tick logging. Default: True
|
|
119
|
+
log_bars (bool): Enable bar logging. Default: True
|
|
120
|
+
log_position_snapshot (bool): Enable YAML position snapshot. Default: True
|
|
121
|
+
snapshot_file (str): Snapshot filename. Default: 'current_position.yaml'
|
|
122
|
+
startup_snapshot_file (str | None): Optional YAML filename for one
|
|
123
|
+
startup-only snapshot of the broker's already-cached report state.
|
|
124
|
+
The snapshot has no market-data mark and never invokes provider
|
|
125
|
+
getters. Default: None (disabled).
|
|
126
|
+
startup_account_observation (Mapping | None): Optional credential-free
|
|
127
|
+
authoritative account observation supplied by the caller before the
|
|
128
|
+
run. It is normalized once, retained separately from the broker's
|
|
129
|
+
local cache, and never triggers a provider request or market-price
|
|
130
|
+
read. Default: None (disabled).
|
|
131
|
+
log_format (str): Log format ('json' or 'text'). Default: 'json'
|
|
132
|
+
log_to_console (bool): Also print to console. Default: False
|
|
133
|
+
|
|
134
|
+
mysql_enabled (bool): Enable MySQL logging. Default: False
|
|
135
|
+
mysql_host (str): MySQL host. Default: 'localhost'
|
|
136
|
+
mysql_port (int): MySQL port. Default: 3306
|
|
137
|
+
mysql_user (str): MySQL user. Default: 'root'
|
|
138
|
+
mysql_password (str): MySQL password. Default: ''
|
|
139
|
+
mysql_database (str): MySQL database. Default: 'backtrader'
|
|
140
|
+
|
|
141
|
+
report_max_records (int): Maximum retained order and trade callback
|
|
142
|
+
summaries in the in-memory report. Default: 100. Set to 0 to
|
|
143
|
+
retain counters only.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
>>> cerebro.addobserver(bt.observers.TradeLogger,
|
|
147
|
+
... log_dir='./logs',
|
|
148
|
+
... mysql_enabled=True,
|
|
149
|
+
... mysql_database='trading_logs')
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
_stclock = True
|
|
153
|
+
_ltype = 2 # LineIterator.ObsType - ensure observer is registered for next() calls
|
|
154
|
+
lines = ("dummy",) # Observer requires at least one line
|
|
155
|
+
|
|
156
|
+
params = {
|
|
157
|
+
# File logging settings
|
|
158
|
+
"log_dir": "./logs",
|
|
159
|
+
"log_orders": True,
|
|
160
|
+
"log_trades": True,
|
|
161
|
+
"log_positions": True,
|
|
162
|
+
"log_indicators": True,
|
|
163
|
+
"log_signals": True,
|
|
164
|
+
"log_ticks": True,
|
|
165
|
+
"log_bars": True,
|
|
166
|
+
"log_system": True,
|
|
167
|
+
"log_monitoring": True,
|
|
168
|
+
"log_errors": True,
|
|
169
|
+
"log_value": True,
|
|
170
|
+
"log_position_snapshot": True,
|
|
171
|
+
"snapshot_file": "current_position.yaml",
|
|
172
|
+
# An opt-in, separate file avoids changing the established legacy
|
|
173
|
+
# snapshot output while allowing live users to retain the account
|
|
174
|
+
# state observed before the first strategy bar.
|
|
175
|
+
"startup_snapshot_file": None,
|
|
176
|
+
# Caller-supplied, credential-free startup evidence. It intentionally
|
|
177
|
+
# remains separate from the broker-local cache and is not refreshed.
|
|
178
|
+
"startup_account_observation": None,
|
|
179
|
+
"log_format": "json",
|
|
180
|
+
"log_to_console": False,
|
|
181
|
+
"submit_count_warn_threshold": 0,
|
|
182
|
+
"cancel_count_warn_threshold": 0,
|
|
183
|
+
"submit_cancel_total_warn_threshold": 0,
|
|
184
|
+
"duplicate_order_warn_threshold": 0,
|
|
185
|
+
"duplicate_order_window_seconds": 60.0,
|
|
186
|
+
# In-memory generic report settings. These do not enable any file I/O.
|
|
187
|
+
"report_max_records": 100,
|
|
188
|
+
# MySQL settings - disabled by default
|
|
189
|
+
"mysql_enabled": False,
|
|
190
|
+
"mysql_host": "localhost",
|
|
191
|
+
"mysql_port": 3306,
|
|
192
|
+
"mysql_user": "root",
|
|
193
|
+
"mysql_password": "",
|
|
194
|
+
"mysql_database": "backtrader",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def __init__(self):
|
|
198
|
+
"""Initialize the TradeLogger observer."""
|
|
199
|
+
super().__init__()
|
|
200
|
+
# CRITICAL: Set _ltype AFTER super().__init__() and ensure registration
|
|
201
|
+
self._ltype = 2 # LineIterator.ObsType
|
|
202
|
+
# Register self to owner's _lineiterators if not already done
|
|
203
|
+
if hasattr(self, "_owner") and self._owner is not None:
|
|
204
|
+
if hasattr(self._owner, "_lineiterators"):
|
|
205
|
+
if self._ltype in self._owner._lineiterators:
|
|
206
|
+
if self not in self._owner._lineiterators[self._ltype]:
|
|
207
|
+
self._owner._lineiterators[self._ltype].append(self)
|
|
208
|
+
self._order_logger = None
|
|
209
|
+
self._trade_logger = None
|
|
210
|
+
self._position_logger = None
|
|
211
|
+
self._indicator_logger = None
|
|
212
|
+
self._signal_logger = None
|
|
213
|
+
self._system_logger = None
|
|
214
|
+
self._monitor_logger = None
|
|
215
|
+
self._tick_logger = None
|
|
216
|
+
self._bar_logger = None
|
|
217
|
+
self._value_logger = None
|
|
218
|
+
self._error_logger = None
|
|
219
|
+
self._mysql_conn = None
|
|
220
|
+
self._last_position_state = {}
|
|
221
|
+
self._run_id = self._generate_run_id()
|
|
222
|
+
self._monitoring: collections.Counter = collections.Counter()
|
|
223
|
+
self._duplicate_requests = collections.defaultdict(collections.deque)
|
|
224
|
+
self._triggered_thresholds = set()
|
|
225
|
+
self._loggers_initialized = False
|
|
226
|
+
self._init_report_state()
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# Generic in-memory report API
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _init_report_state(self):
|
|
233
|
+
"""Initialize bounded, JSON-safe report state for this observer run."""
|
|
234
|
+
try:
|
|
235
|
+
record_limit = max(0, int(self.p.report_max_records))
|
|
236
|
+
except (AttributeError, TypeError, ValueError):
|
|
237
|
+
record_limit = 100
|
|
238
|
+
|
|
239
|
+
self._report_record_limit = record_limit
|
|
240
|
+
self._report_event_counts = collections.Counter(dict.fromkeys(_REPORT_EVENT_KEYS, 0))
|
|
241
|
+
self._report_dispatched_line_bars = collections.OrderedDict()
|
|
242
|
+
self._report_orders = collections.deque(maxlen=record_limit)
|
|
243
|
+
self._report_trades = collections.deque(maxlen=record_limit)
|
|
244
|
+
self._report_dropped_records = collections.Counter({"orders": 0, "trades": 0})
|
|
245
|
+
self._report_extensions = {}
|
|
246
|
+
self._report_portfolio = {"cash": None, "value": None}
|
|
247
|
+
self._report_positions = {}
|
|
248
|
+
self._report_startup_account_observation = self._capture_startup_account_observation()
|
|
249
|
+
self._report_strategy = {"name": "Unknown", "module": None}
|
|
250
|
+
self._report_provider = ""
|
|
251
|
+
self._report_session_id = ""
|
|
252
|
+
self._report_monitoring_thresholds = {}
|
|
253
|
+
self._report_started_at = None
|
|
254
|
+
self._report_last_updated_at = self._log_time_str()
|
|
255
|
+
self._report_last_event_at = None
|
|
256
|
+
self._report_finalized_at = None
|
|
257
|
+
self._report_finalized = False
|
|
258
|
+
self._final_report = None
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def _normalize_report_context_value(cls, value, active=None):
|
|
262
|
+
"""Strictly normalize a value accepted by ``update_report_context``.
|
|
263
|
+
|
|
264
|
+
Strategy context is part of an exported report, so accepting arbitrary
|
|
265
|
+
Python objects here would make the contract depend on ``json.dumps``
|
|
266
|
+
implementation details. Only JSON primitives, mappings with string
|
|
267
|
+
keys, and list/tuple containers are accepted. ``active`` tracks the
|
|
268
|
+
current recursion path to reject cycles while allowing shared values.
|
|
269
|
+
"""
|
|
270
|
+
if active is None:
|
|
271
|
+
active = set()
|
|
272
|
+
|
|
273
|
+
if value is None or isinstance(value, (bool, str, int)):
|
|
274
|
+
return value
|
|
275
|
+
if isinstance(value, float):
|
|
276
|
+
if not math.isfinite(value):
|
|
277
|
+
raise ValueError("report context floats must be finite")
|
|
278
|
+
return value
|
|
279
|
+
|
|
280
|
+
if isinstance(value, Mapping):
|
|
281
|
+
value_id = id(value)
|
|
282
|
+
if value_id in active:
|
|
283
|
+
raise ValueError("report context cannot contain cycles")
|
|
284
|
+
active.add(value_id)
|
|
285
|
+
try:
|
|
286
|
+
normalized = {}
|
|
287
|
+
for key, item in value.items():
|
|
288
|
+
if not isinstance(key, str):
|
|
289
|
+
raise TypeError("report context mapping keys must be strings")
|
|
290
|
+
normalized[key] = cls._normalize_report_context_value(item, active)
|
|
291
|
+
return normalized
|
|
292
|
+
finally:
|
|
293
|
+
active.remove(value_id)
|
|
294
|
+
|
|
295
|
+
if isinstance(value, (list, tuple)):
|
|
296
|
+
value_id = id(value)
|
|
297
|
+
if value_id in active:
|
|
298
|
+
raise ValueError("report context cannot contain cycles")
|
|
299
|
+
active.add(value_id)
|
|
300
|
+
try:
|
|
301
|
+
return [cls._normalize_report_context_value(item, active) for item in value]
|
|
302
|
+
finally:
|
|
303
|
+
active.remove(value_id)
|
|
304
|
+
|
|
305
|
+
raise TypeError(f"report context value is not JSON-safe: {type(value).__name__}")
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def _normalize_report_context(cls, mapping):
|
|
309
|
+
"""Return a strict JSON-safe context mapping, or ``None`` when invalid."""
|
|
310
|
+
if not isinstance(mapping, Mapping):
|
|
311
|
+
return None
|
|
312
|
+
try:
|
|
313
|
+
normalized = cls._normalize_report_context_value(mapping)
|
|
314
|
+
except (TypeError, ValueError, RecursionError):
|
|
315
|
+
return None
|
|
316
|
+
return normalized if isinstance(normalized, dict) else None
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def _startup_observation_has_sensitive_key(value):
|
|
320
|
+
"""Return whether a caller observation contains an obvious credential key."""
|
|
321
|
+
if isinstance(value, Mapping):
|
|
322
|
+
for key, item in value.items():
|
|
323
|
+
normalized_key = "".join(
|
|
324
|
+
character for character in str(key).lower() if character.isalnum()
|
|
325
|
+
)
|
|
326
|
+
if any(
|
|
327
|
+
fragment in normalized_key
|
|
328
|
+
for fragment in _STARTUP_ACCOUNT_OBSERVATION_SENSITIVE_KEY_FRAGMENTS
|
|
329
|
+
):
|
|
330
|
+
return True
|
|
331
|
+
if TradeLogger._startup_observation_has_sensitive_key(item):
|
|
332
|
+
return True
|
|
333
|
+
return False
|
|
334
|
+
if isinstance(value, (list, tuple)):
|
|
335
|
+
return any(TradeLogger._startup_observation_has_sensitive_key(item) for item in value)
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
def _capture_startup_account_observation(self):
|
|
339
|
+
"""Capture opt-in startup evidence without broker or feed reads.
|
|
340
|
+
|
|
341
|
+
The caller owns the observation's provenance. TradeLogger only accepts a
|
|
342
|
+
strict JSON mapping, rejects common credential-bearing keys, and wraps
|
|
343
|
+
the value under a distinct scope so it cannot be confused with the
|
|
344
|
+
broker-local cache used for ``portfolio`` and ``positions``.
|
|
345
|
+
"""
|
|
346
|
+
raw_observation = getattr(getattr(self, "p", None), "startup_account_observation", None)
|
|
347
|
+
if raw_observation is None:
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
normalized = self._normalize_report_context(raw_observation)
|
|
351
|
+
if normalized is None:
|
|
352
|
+
logger.warning("Ignoring invalid startup account observation")
|
|
353
|
+
return None
|
|
354
|
+
if self._startup_observation_has_sensitive_key(normalized):
|
|
355
|
+
logger.debug("Ignoring startup account observation containing a credential-like key")
|
|
356
|
+
return None
|
|
357
|
+
return {
|
|
358
|
+
"source": "caller_supplied",
|
|
359
|
+
"scope": _STARTUP_ACCOUNT_OBSERVATION_SCOPE,
|
|
360
|
+
"read_only": True,
|
|
361
|
+
# This observer never reads a feed line while retaining startup
|
|
362
|
+
# evidence, so an observation cannot gain a preloaded future mark
|
|
363
|
+
# through TradeLogger itself.
|
|
364
|
+
"market_data_status": "unmarked",
|
|
365
|
+
"observation": normalized,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
@classmethod
|
|
369
|
+
def _report_json_safe_value(cls, value, active=None):
|
|
370
|
+
"""Best-effort JSON-safe conversion for framework event summaries.
|
|
371
|
+
|
|
372
|
+
Incoming broker/store objects are intentionally less strict than
|
|
373
|
+
caller-provided report context. A logging observer must never break a
|
|
374
|
+
trading run because a provider supplied an unusual value, so opaque
|
|
375
|
+
values are represented as strings and non-finite numbers become null.
|
|
376
|
+
"""
|
|
377
|
+
if active is None:
|
|
378
|
+
active = set()
|
|
379
|
+
|
|
380
|
+
if value is None or isinstance(value, (bool, str, int)):
|
|
381
|
+
return value
|
|
382
|
+
if isinstance(value, float):
|
|
383
|
+
return value if math.isfinite(value) else None
|
|
384
|
+
if isinstance(value, datetime):
|
|
385
|
+
return cls._event_time_str(value, "")
|
|
386
|
+
|
|
387
|
+
if isinstance(value, Mapping):
|
|
388
|
+
value_id = id(value)
|
|
389
|
+
if value_id in active:
|
|
390
|
+
return "<cycle>"
|
|
391
|
+
active.add(value_id)
|
|
392
|
+
try:
|
|
393
|
+
return {
|
|
394
|
+
str(key): cls._report_json_safe_value(item, active)
|
|
395
|
+
for key, item in value.items()
|
|
396
|
+
}
|
|
397
|
+
except Exception:
|
|
398
|
+
logger.warning("trade_logger:397 fallback on Exception")
|
|
399
|
+
return "<unavailable-mapping>"
|
|
400
|
+
finally:
|
|
401
|
+
active.remove(value_id)
|
|
402
|
+
|
|
403
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
404
|
+
value_id = id(value)
|
|
405
|
+
if value_id in active:
|
|
406
|
+
return "<cycle>"
|
|
407
|
+
active.add(value_id)
|
|
408
|
+
try:
|
|
409
|
+
return [cls._report_json_safe_value(item, active) for item in value]
|
|
410
|
+
except Exception:
|
|
411
|
+
logger.warning("trade_logger:409 fallback on Exception")
|
|
412
|
+
return ["<unavailable-sequence>"]
|
|
413
|
+
finally:
|
|
414
|
+
active.remove(value_id)
|
|
415
|
+
|
|
416
|
+
item_method = getattr(value, "item", None)
|
|
417
|
+
if callable(item_method):
|
|
418
|
+
try:
|
|
419
|
+
return cls._report_json_safe_value(item_method(), active)
|
|
420
|
+
except Exception:
|
|
421
|
+
logger.warning("trade_logger:419 suppressed Exception")
|
|
422
|
+
try:
|
|
423
|
+
return str(value)
|
|
424
|
+
except Exception:
|
|
425
|
+
logger.warning("trade_logger:422 fallback on Exception")
|
|
426
|
+
return f"<{type(value).__name__}>"
|
|
427
|
+
|
|
428
|
+
def _report_touch(self, event_time=None):
|
|
429
|
+
"""Advance the report's in-memory as-of timestamp."""
|
|
430
|
+
if not hasattr(self, "_report_last_updated_at"):
|
|
431
|
+
return
|
|
432
|
+
timestamp = event_time or self._log_time_str()
|
|
433
|
+
self._report_last_updated_at = timestamp
|
|
434
|
+
self._report_last_event_at = timestamp
|
|
435
|
+
|
|
436
|
+
def _refresh_report_metadata(self):
|
|
437
|
+
"""Cache framework metadata outside of ``snapshot()``."""
|
|
438
|
+
if not hasattr(self, "_report_strategy") or getattr(self, "_report_finalized", False):
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
owner = getattr(self, "_owner", None)
|
|
442
|
+
strategy_name = self._get_strategy_name()
|
|
443
|
+
strategy_module = None
|
|
444
|
+
try:
|
|
445
|
+
strategy_module = owner.__class__.__module__ if owner is not None else None
|
|
446
|
+
except Exception:
|
|
447
|
+
logger.warning("trade_logger:443 fallback on Exception")
|
|
448
|
+
strategy_module = None
|
|
449
|
+
|
|
450
|
+
self._report_strategy = {
|
|
451
|
+
"name": self._report_json_safe_value(strategy_name),
|
|
452
|
+
"module": self._report_json_safe_value(strategy_module),
|
|
453
|
+
}
|
|
454
|
+
self._report_provider = self._report_json_safe_value(self._store_provider())
|
|
455
|
+
self._report_session_id = self._report_json_safe_value(self._session_id())
|
|
456
|
+
try:
|
|
457
|
+
self._report_monitoring_thresholds = self._report_json_safe_value(
|
|
458
|
+
self._configured_risk_thresholds()
|
|
459
|
+
)
|
|
460
|
+
except Exception:
|
|
461
|
+
logger.warning("trade_logger:456 fallback on Exception")
|
|
462
|
+
self._report_monitoring_thresholds = {}
|
|
463
|
+
|
|
464
|
+
def _has_active_report_bar(self, owner):
|
|
465
|
+
"""Return whether the strategy has advanced to a safe current bar.
|
|
466
|
+
|
|
467
|
+
With preloaded data, ``data.close[0]`` may point at the final buffered
|
|
468
|
+
value during ``start()``. Strategy length is still zero then, so do
|
|
469
|
+
not construct a price-bearing position snapshot until a real strategy
|
|
470
|
+
callback has begun.
|
|
471
|
+
"""
|
|
472
|
+
try:
|
|
473
|
+
return owner is not None and len(owner) > 0
|
|
474
|
+
except Exception:
|
|
475
|
+
logger.warning("trade_logger:469 fallback on Exception")
|
|
476
|
+
return False
|
|
477
|
+
|
|
478
|
+
@staticmethod
|
|
479
|
+
def _report_timestamp_key(value):
|
|
480
|
+
"""Return a millisecond UTC key for a bar event or line datetime."""
|
|
481
|
+
if isinstance(value, datetime):
|
|
482
|
+
dt_value = value
|
|
483
|
+
elif isinstance(value, (int, float)):
|
|
484
|
+
try:
|
|
485
|
+
return int(round(float(value) * 1000.0))
|
|
486
|
+
except (TypeError, ValueError, OverflowError):
|
|
487
|
+
return None
|
|
488
|
+
else:
|
|
489
|
+
return None
|
|
490
|
+
if dt_value.tzinfo is None or dt_value.utcoffset() is None:
|
|
491
|
+
dt_value = dt_value.replace(tzinfo=timezone.utc)
|
|
492
|
+
try:
|
|
493
|
+
return int(round(dt_value.timestamp() * 1000.0))
|
|
494
|
+
except (OverflowError, OSError, ValueError):
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
@classmethod
|
|
498
|
+
def _report_bar_event_identity(cls, bar):
|
|
499
|
+
"""Identify one dispatched bar using its symbol and event timestamp."""
|
|
500
|
+
name = getattr(bar, "symbol", None) or getattr(bar, "_name", None)
|
|
501
|
+
# BtApiFeed sets ``bar.datetime`` to the same bucket start it writes
|
|
502
|
+
# into LineSeries, while a completed BarEvent's transport timestamp
|
|
503
|
+
# can be the bucket end. Prefer the line timestamp for deduplication.
|
|
504
|
+
timestamp = cls._report_timestamp_key(getattr(bar, "datetime", None))
|
|
505
|
+
if timestamp is None:
|
|
506
|
+
timestamp = cls._report_timestamp_key(getattr(bar, "timestamp", None))
|
|
507
|
+
return (str(name), timestamp) if name not in (None, "") and timestamp is not None else None
|
|
508
|
+
|
|
509
|
+
@classmethod
|
|
510
|
+
def _report_data_bar_identities(cls, data):
|
|
511
|
+
"""Identify the current line bar under every stable data name.
|
|
512
|
+
|
|
513
|
+
``Cerebro.adddata(feed, name=...)`` decorates ``_name`` but leaves a
|
|
514
|
+
live feed's transport ``_dataname`` intact. Feed callbacks carry the
|
|
515
|
+
latter, so both names must participate in completed-bar
|
|
516
|
+
deduplication.
|
|
517
|
+
"""
|
|
518
|
+
names = cls._report_data_names(data)
|
|
519
|
+
if not names:
|
|
520
|
+
return set()
|
|
521
|
+
data_datetime = getattr(data, "datetime", None)
|
|
522
|
+
converter = getattr(data_datetime, "datetime", None)
|
|
523
|
+
if callable(converter):
|
|
524
|
+
try:
|
|
525
|
+
timestamp = cls._report_timestamp_key(converter(0))
|
|
526
|
+
if timestamp is not None:
|
|
527
|
+
return {(name, timestamp) for name in names}
|
|
528
|
+
except Exception:
|
|
529
|
+
logger.warning("trade_logger:523 suppressed Exception")
|
|
530
|
+
try:
|
|
531
|
+
numeric = data_datetime[0]
|
|
532
|
+
to_datetime = getattr(data, "num2date", None)
|
|
533
|
+
if callable(to_datetime):
|
|
534
|
+
timestamp = cls._report_timestamp_key(to_datetime(numeric))
|
|
535
|
+
if timestamp is not None:
|
|
536
|
+
return {(name, timestamp) for name in names}
|
|
537
|
+
except Exception:
|
|
538
|
+
logger.warning("trade_logger:532 suppressed Exception")
|
|
539
|
+
return set()
|
|
540
|
+
|
|
541
|
+
def _consume_dispatched_line_bar(self, owner):
|
|
542
|
+
"""Return whether the current observer step already has a bar event.
|
|
543
|
+
|
|
544
|
+
BtApiFeed can dispatch a synthesized bar to native callbacks and then
|
|
545
|
+
deliver the same bar through its regular line buffer. The callback
|
|
546
|
+
has already incremented ``bars``; consume its identity here so the
|
|
547
|
+
subsequent observer ``next`` does not double count it.
|
|
548
|
+
"""
|
|
549
|
+
pending = getattr(self, "_report_dispatched_line_bars", None)
|
|
550
|
+
if not pending:
|
|
551
|
+
return False
|
|
552
|
+
current = set()
|
|
553
|
+
for data in getattr(owner, "datas", ()) or ():
|
|
554
|
+
current.update(self._report_data_bar_identities(data))
|
|
555
|
+
pending_identities = set(pending)
|
|
556
|
+
matching = pending_identities.intersection(current)
|
|
557
|
+
if not matching:
|
|
558
|
+
return False
|
|
559
|
+
if isinstance(pending, Mapping):
|
|
560
|
+
for identity in matching:
|
|
561
|
+
pending.pop(identity, None)
|
|
562
|
+
else:
|
|
563
|
+
# Tolerate legacy test fixtures/instances that created the old
|
|
564
|
+
# set before the bounded OrderedDict implementation landed.
|
|
565
|
+
pending.difference_update(matching)
|
|
566
|
+
return True
|
|
567
|
+
|
|
568
|
+
@classmethod
|
|
569
|
+
def _owner_line_data_names(cls, owner):
|
|
570
|
+
"""Return all stable names represented by the owner's LineSeries feeds."""
|
|
571
|
+
names = set()
|
|
572
|
+
for data in getattr(owner, "datas", ()) or ():
|
|
573
|
+
names.update(cls._report_data_names(data))
|
|
574
|
+
return names
|
|
575
|
+
|
|
576
|
+
def _remember_dispatched_line_bar(self, identity, owner):
|
|
577
|
+
"""Queue a dedup identity only for an active LineSeries feed.
|
|
578
|
+
|
|
579
|
+
Runtime strategies can forward diagnostic bars alongside their feed
|
|
580
|
+
bars. A foreign symbol has no corresponding observer ``next`` step,
|
|
581
|
+
so storing it would leak one identity per event in a long live run.
|
|
582
|
+
The ordered window also bounds malformed matching events that cannot
|
|
583
|
+
be consumed because their timestamps do not align with LineSeries.
|
|
584
|
+
"""
|
|
585
|
+
if identity is None or identity[0] not in self._owner_line_data_names(owner):
|
|
586
|
+
return
|
|
587
|
+
pending = getattr(self, "_report_dispatched_line_bars", None)
|
|
588
|
+
if not isinstance(pending, collections.OrderedDict):
|
|
589
|
+
pending = collections.OrderedDict()
|
|
590
|
+
self._report_dispatched_line_bars = pending
|
|
591
|
+
pending[identity] = None
|
|
592
|
+
pending.move_to_end(identity)
|
|
593
|
+
while len(pending) > _REPORT_PENDING_BAR_LIMIT:
|
|
594
|
+
pending.popitem(last=False)
|
|
595
|
+
|
|
596
|
+
def _report_position_summary(self, data, position, data_name):
|
|
597
|
+
"""Return local broker position state without requesting store metadata.
|
|
598
|
+
|
|
599
|
+
File logs retain their richer contract metadata path. The generic
|
|
600
|
+
in-memory report must never trigger a provider/API lookup in a hot
|
|
601
|
+
strategy callback, so it derives only from the feed, broker position,
|
|
602
|
+
and configured commission object already resident in the process.
|
|
603
|
+
"""
|
|
604
|
+
if data is None:
|
|
605
|
+
# A live broker can cache account positions for symbols the
|
|
606
|
+
# strategy has not subscribed to. Preserve the account state in
|
|
607
|
+
# the report without guessing a current mark or commission setup.
|
|
608
|
+
# This is also the only safe position representation during
|
|
609
|
+
# ``start``: preloaded LineSeries data can otherwise expose a
|
|
610
|
+
# future close before the first strategy callback.
|
|
611
|
+
return {
|
|
612
|
+
"size": self._report_json_safe_value(getattr(position, "size", None)),
|
|
613
|
+
"price": self._report_json_safe_value(getattr(position, "price", None)),
|
|
614
|
+
"value": None,
|
|
615
|
+
"current_price": None,
|
|
616
|
+
"multiplier": None,
|
|
617
|
+
"position_source": "broker_local_cache",
|
|
618
|
+
"market_data_status": "unmarked",
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
current_price = self._current_position_price(data, position)
|
|
622
|
+
comminfo = self._cached_commission_info_for_data(data)
|
|
623
|
+
multiplier = self._positive_float(self._comminfo_param(comminfo, "mult"), 1.0)
|
|
624
|
+
market_value = float(position.size) * current_price * multiplier
|
|
625
|
+
return {
|
|
626
|
+
"size": self._report_json_safe_value(position.size),
|
|
627
|
+
"price": self._report_json_safe_value(position.price),
|
|
628
|
+
"value": self._report_json_safe_value(market_value),
|
|
629
|
+
"current_price": self._report_json_safe_value(current_price),
|
|
630
|
+
"multiplier": self._report_json_safe_value(multiplier),
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
@staticmethod
|
|
634
|
+
def _report_data_names(data):
|
|
635
|
+
"""Return stable report names for a feed, cache key, or plain symbol."""
|
|
636
|
+
names = set()
|
|
637
|
+
for name in (getattr(data, "_name", None), getattr(data, "_dataname", None)):
|
|
638
|
+
# PandasData keeps its source DataFrame in ``_dataname``. It is
|
|
639
|
+
# not an account identity and comparing it to an empty string
|
|
640
|
+
# raises an ambiguous-truth-value error, so accept scalar names
|
|
641
|
+
# only.
|
|
642
|
+
if isinstance(name, str) and name:
|
|
643
|
+
names.add(name)
|
|
644
|
+
elif isinstance(name, (int, float)) and not isinstance(name, bool):
|
|
645
|
+
names.add(str(name))
|
|
646
|
+
if not names:
|
|
647
|
+
if isinstance(data, str) and data:
|
|
648
|
+
names.add(data)
|
|
649
|
+
elif isinstance(data, (int, float)) and not isinstance(data, bool):
|
|
650
|
+
names.add(str(data))
|
|
651
|
+
return names
|
|
652
|
+
|
|
653
|
+
@classmethod
|
|
654
|
+
def _cached_position_for_data(cls, positions, data, data_name, aliases=()):
|
|
655
|
+
"""Read a position from a broker's local report-state mapping only."""
|
|
656
|
+
if not isinstance(positions, Mapping):
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
accepted_names = {str(data_name), *(str(alias) for alias in aliases)}
|
|
660
|
+
if data is not None:
|
|
661
|
+
try:
|
|
662
|
+
if data in positions:
|
|
663
|
+
return positions[data]
|
|
664
|
+
except (TypeError, KeyError):
|
|
665
|
+
logger.debug("trade_logger:659 ignored TypeError,KeyError")
|
|
666
|
+
try:
|
|
667
|
+
direct = positions.get(data_name)
|
|
668
|
+
if direct is not None:
|
|
669
|
+
return direct
|
|
670
|
+
except (AttributeError, TypeError):
|
|
671
|
+
logger.debug("trade_logger:665 ignored AttributeError,TypeError")
|
|
672
|
+
try:
|
|
673
|
+
for key, value in positions.items():
|
|
674
|
+
if cls._report_data_names(key).intersection(accepted_names):
|
|
675
|
+
return value
|
|
676
|
+
except Exception:
|
|
677
|
+
logger.warning("trade_logger:671 suppressed Exception")
|
|
678
|
+
return None
|
|
679
|
+
|
|
680
|
+
def _cached_position_legs_for_data(self, position_legs, data, data_name):
|
|
681
|
+
"""Return the local long/short leg mapping for one data identity.
|
|
682
|
+
|
|
683
|
+
``position_legs`` is optional because ordinary net-position brokers do
|
|
684
|
+
not need it. Dual-side brokers use the same identity rules as their
|
|
685
|
+
net ``positions`` entry, so a feed object and its display name work
|
|
686
|
+
consistently for both maps.
|
|
687
|
+
"""
|
|
688
|
+
legs = self._cached_position_for_data(
|
|
689
|
+
position_legs, data, data_name, self._report_data_names(data)
|
|
690
|
+
)
|
|
691
|
+
return legs if isinstance(legs, Mapping) else {}
|
|
692
|
+
|
|
693
|
+
def _report_position_entry(self, data, position, cached_legs, data_name):
|
|
694
|
+
"""Build one net-plus-gross position entry from local cached objects."""
|
|
695
|
+
leg_summaries = {}
|
|
696
|
+
for side in ("long", "short"):
|
|
697
|
+
leg_position = cached_legs.get(side)
|
|
698
|
+
if leg_position is None:
|
|
699
|
+
continue
|
|
700
|
+
leg_summaries[side] = self._report_position_summary(data, leg_position, data_name)
|
|
701
|
+
|
|
702
|
+
if position is None and not leg_summaries:
|
|
703
|
+
return None
|
|
704
|
+
|
|
705
|
+
# A custom dual-side broker may intentionally expose only gross legs.
|
|
706
|
+
# Keep the absence of a normalized net view explicit rather than
|
|
707
|
+
# inventing a price or a signed value.
|
|
708
|
+
summary = (
|
|
709
|
+
self._report_position_summary(data, position, data_name)
|
|
710
|
+
if position is not None
|
|
711
|
+
else {
|
|
712
|
+
"size": None,
|
|
713
|
+
"price": None,
|
|
714
|
+
"value": None,
|
|
715
|
+
"current_price": None,
|
|
716
|
+
"multiplier": None,
|
|
717
|
+
}
|
|
718
|
+
)
|
|
719
|
+
if leg_summaries:
|
|
720
|
+
summary["position_mode"] = "dual_side"
|
|
721
|
+
summary["position_legs"] = leg_summaries
|
|
722
|
+
return summary
|
|
723
|
+
|
|
724
|
+
def _cached_broker_report_state(self):
|
|
725
|
+
"""Read the explicit local-only broker report cache, if available."""
|
|
726
|
+
broker = getattr(getattr(self, "_owner", None), "broker", None)
|
|
727
|
+
getter = getattr(broker, "get_cached_report_state", None)
|
|
728
|
+
if not callable(getter):
|
|
729
|
+
return {}
|
|
730
|
+
try:
|
|
731
|
+
state = getter()
|
|
732
|
+
except Exception as exc:
|
|
733
|
+
logger.debug("Failed to read cached broker report state: %s", exc)
|
|
734
|
+
return {}
|
|
735
|
+
return state if isinstance(state, Mapping) else {}
|
|
736
|
+
|
|
737
|
+
def _refresh_report_state(self, *, include_positions=True):
|
|
738
|
+
"""Cache explicit local broker state without file, MySQL, or provider I/O."""
|
|
739
|
+
if not hasattr(self, "_report_portfolio") or getattr(self, "_report_finalized", False):
|
|
740
|
+
return
|
|
741
|
+
|
|
742
|
+
self._refresh_report_metadata()
|
|
743
|
+
state = self._cached_broker_report_state()
|
|
744
|
+
self._report_portfolio = {
|
|
745
|
+
"cash": self._report_json_safe_value(state.get("cash")),
|
|
746
|
+
"value": self._report_json_safe_value(state.get("value")),
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
owner = getattr(self, "_owner", None)
|
|
750
|
+
if not include_positions:
|
|
751
|
+
return
|
|
752
|
+
|
|
753
|
+
positions = {}
|
|
754
|
+
cached_positions = state.get("positions", {})
|
|
755
|
+
cached_position_legs = state.get("position_legs", {})
|
|
756
|
+
known_cache_names = set()
|
|
757
|
+
if self._has_active_report_bar(owner):
|
|
758
|
+
for data in self._iter_position_datas():
|
|
759
|
+
try:
|
|
760
|
+
data_name = str(
|
|
761
|
+
getattr(data, "_name", None) or getattr(data, "_dataname", None) or data
|
|
762
|
+
)
|
|
763
|
+
aliases = self._report_data_names(data)
|
|
764
|
+
known_cache_names.update(aliases)
|
|
765
|
+
position = self._cached_position_for_data(
|
|
766
|
+
cached_positions, data, data_name, aliases
|
|
767
|
+
)
|
|
768
|
+
cached_legs = self._cached_position_legs_for_data(
|
|
769
|
+
cached_position_legs, data, data_name
|
|
770
|
+
)
|
|
771
|
+
summary = self._report_position_entry(data, position, cached_legs, data_name)
|
|
772
|
+
if summary is None:
|
|
773
|
+
continue
|
|
774
|
+
positions[data_name] = summary
|
|
775
|
+
except Exception as exc:
|
|
776
|
+
logger.debug("Failed to collect report position state: %s", exc)
|
|
777
|
+
|
|
778
|
+
# A broker's report cache represents account state, not only the
|
|
779
|
+
# current strategy subscription. Preserve cached symbols that are not
|
|
780
|
+
# LineSeries/HFT references, while making their unavailable mark and
|
|
781
|
+
# commission fields explicit. This keeps a live account's unrelated
|
|
782
|
+
# risk visible without initiating a provider query.
|
|
783
|
+
cache_keys = []
|
|
784
|
+
for cached_map in (cached_positions, cached_position_legs):
|
|
785
|
+
if not isinstance(cached_map, Mapping):
|
|
786
|
+
continue
|
|
787
|
+
try:
|
|
788
|
+
cache_keys.extend(cached_map.keys())
|
|
789
|
+
except Exception:
|
|
790
|
+
logger.warning("trade_logger:784 suppressed Exception")
|
|
791
|
+
continue
|
|
792
|
+
for cache_key in cache_keys:
|
|
793
|
+
cache_names = self._report_data_names(cache_key)
|
|
794
|
+
if not cache_names:
|
|
795
|
+
continue
|
|
796
|
+
data_name = sorted(cache_names)[0]
|
|
797
|
+
if cache_names.intersection(known_cache_names) or data_name in positions:
|
|
798
|
+
continue
|
|
799
|
+
try:
|
|
800
|
+
position = self._cached_position_for_data(
|
|
801
|
+
cached_positions, cache_key, data_name, cache_names
|
|
802
|
+
)
|
|
803
|
+
cached_legs = self._cached_position_legs_for_data(
|
|
804
|
+
cached_position_legs, cache_key, data_name
|
|
805
|
+
)
|
|
806
|
+
summary = self._report_position_entry(None, position, cached_legs, data_name)
|
|
807
|
+
if summary is not None:
|
|
808
|
+
positions[data_name] = summary
|
|
809
|
+
except Exception as exc:
|
|
810
|
+
logger.debug("Failed to collect cached account position state: %s", exc)
|
|
811
|
+
self._report_positions = positions
|
|
812
|
+
|
|
813
|
+
def _start_report(self):
|
|
814
|
+
"""Mark the report active and capture the initial framework state."""
|
|
815
|
+
if not hasattr(self, "_report_started_at") or getattr(self, "_report_finalized", False):
|
|
816
|
+
return
|
|
817
|
+
timestamp = self._log_time_str()
|
|
818
|
+
self._report_started_at = timestamp
|
|
819
|
+
# Do not read a price-bearing feed field during start: preloaded data
|
|
820
|
+
# can otherwise expose the final bar before strategy execution starts.
|
|
821
|
+
# ``_refresh_report_state`` still retains broker-cache positions here,
|
|
822
|
+
# but it represents all of them as unmarked cache entries.
|
|
823
|
+
self._refresh_report_state()
|
|
824
|
+
self._report_touch(timestamp)
|
|
825
|
+
|
|
826
|
+
@classmethod
|
|
827
|
+
def _report_position_has_exposure(cls, summary):
|
|
828
|
+
"""Return whether a cached report position contains non-zero exposure."""
|
|
829
|
+
if not isinstance(summary, Mapping):
|
|
830
|
+
return False
|
|
831
|
+
size = cls._float_or_none(summary.get("size"))
|
|
832
|
+
if size is not None and size != 0.0:
|
|
833
|
+
return True
|
|
834
|
+
legs = summary.get("position_legs")
|
|
835
|
+
if not isinstance(legs, Mapping):
|
|
836
|
+
return False
|
|
837
|
+
return any(
|
|
838
|
+
cls._report_position_has_exposure(leg)
|
|
839
|
+
for leg in legs.values()
|
|
840
|
+
if isinstance(leg, Mapping)
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
def _save_startup_position_snapshot(self):
|
|
844
|
+
"""Persist one opt-in, cache-only startup position snapshot.
|
|
845
|
+
|
|
846
|
+
This deliberately reads the already-built generic report cache rather
|
|
847
|
+
than ``owner.getposition()``, ``data.close[0]``, or any provider
|
|
848
|
+
getter. It therefore remains safe when a live strategy starts with
|
|
849
|
+
preloaded history or an account containing positions outside the
|
|
850
|
+
strategy subscription.
|
|
851
|
+
"""
|
|
852
|
+
if not YAML_AVAILABLE:
|
|
853
|
+
return
|
|
854
|
+
filename = getattr(self.p, "startup_snapshot_file", None)
|
|
855
|
+
if not isinstance(filename, str) or not filename.strip():
|
|
856
|
+
return
|
|
857
|
+
|
|
858
|
+
positions = copy.deepcopy(getattr(self, "_report_positions", {}))
|
|
859
|
+
if not isinstance(positions, Mapping):
|
|
860
|
+
positions = {}
|
|
861
|
+
position_entries = dict(positions)
|
|
862
|
+
snapshot = {
|
|
863
|
+
# Use wall-clock report time rather than the strategy's line time:
|
|
864
|
+
# the latter may refer to a preloaded future bar at startup.
|
|
865
|
+
"datetime": getattr(self, "_report_started_at", None) or self._log_time_str(),
|
|
866
|
+
"strategy": self._get_strategy_name(),
|
|
867
|
+
"snapshot_phase": "startup",
|
|
868
|
+
"snapshot_scope": "broker_local_cached_report_state",
|
|
869
|
+
"market_data_status": "unmarked",
|
|
870
|
+
"portfolio": copy.deepcopy(
|
|
871
|
+
getattr(self, "_report_portfolio", {"cash": None, "value": None})
|
|
872
|
+
),
|
|
873
|
+
"position_entry_count": len(position_entries),
|
|
874
|
+
"nonzero_position_entry_count": sum(
|
|
875
|
+
self._report_position_has_exposure(summary) for summary in position_entries.values()
|
|
876
|
+
),
|
|
877
|
+
"positions": position_entries,
|
|
878
|
+
}
|
|
879
|
+
startup_observation = getattr(self, "_report_startup_account_observation", None)
|
|
880
|
+
if startup_observation is not None:
|
|
881
|
+
snapshot["startup_account_observation"] = copy.deepcopy(startup_observation)
|
|
882
|
+
|
|
883
|
+
snapshot_path = os.path.join(self.p.log_dir, filename)
|
|
884
|
+
try:
|
|
885
|
+
with open(snapshot_path, "w", encoding="utf-8") as handle:
|
|
886
|
+
yaml.dump(
|
|
887
|
+
snapshot, handle, allow_unicode=True, default_flow_style=False, sort_keys=False
|
|
888
|
+
)
|
|
889
|
+
except Exception as exc:
|
|
890
|
+
logger.debug("Failed to save startup position snapshot: %s", exc)
|
|
891
|
+
if self.p.log_to_console:
|
|
892
|
+
logger.warning(f"[TradeLogger] Failed to save startup position snapshot: {exc}")
|
|
893
|
+
|
|
894
|
+
def _record_report_event(self, event_name, payload=None, record_kind=None):
|
|
895
|
+
"""Record a generic callback count and optionally a bounded summary."""
|
|
896
|
+
if not hasattr(self, "_report_event_counts") or getattr(self, "_report_finalized", False):
|
|
897
|
+
return
|
|
898
|
+
|
|
899
|
+
if event_name in _REPORT_EVENT_KEYS:
|
|
900
|
+
self._report_event_counts[event_name] += 1
|
|
901
|
+
|
|
902
|
+
event_time = None
|
|
903
|
+
if isinstance(payload, Mapping):
|
|
904
|
+
event_time = (
|
|
905
|
+
payload.get("log_time") or payload.get("event_time") or payload.get("datetime")
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
if record_kind in {"orders", "trades"} and payload is not None:
|
|
909
|
+
records = self._report_orders if record_kind == "orders" else self._report_trades
|
|
910
|
+
maxlen = records.maxlen
|
|
911
|
+
if not maxlen:
|
|
912
|
+
self._report_dropped_records[record_kind] += 1
|
|
913
|
+
else:
|
|
914
|
+
if len(records) >= maxlen:
|
|
915
|
+
self._report_dropped_records[record_kind] += 1
|
|
916
|
+
records.append(self._report_json_safe_value(payload))
|
|
917
|
+
|
|
918
|
+
self._report_touch(event_time)
|
|
919
|
+
|
|
920
|
+
def update_report_context(self, mapping, namespace="strategy"):
|
|
921
|
+
"""Shallow-merge JSON-safe strategy context into a report namespace.
|
|
922
|
+
|
|
923
|
+
The operation is atomic: invalid values, cycles, non-string mapping
|
|
924
|
+
keys, and non-finite floats return ``False`` without changing any
|
|
925
|
+
existing context. Context is immutable after the observer freezes its
|
|
926
|
+
final report in :meth:`stop`.
|
|
927
|
+
"""
|
|
928
|
+
if (
|
|
929
|
+
not isinstance(namespace, str)
|
|
930
|
+
or not namespace.strip()
|
|
931
|
+
or not hasattr(self, "_report_extensions")
|
|
932
|
+
or getattr(self, "_report_finalized", False)
|
|
933
|
+
):
|
|
934
|
+
return False
|
|
935
|
+
|
|
936
|
+
normalized = self._normalize_report_context(mapping)
|
|
937
|
+
if normalized is None:
|
|
938
|
+
return False
|
|
939
|
+
|
|
940
|
+
existing = self._report_extensions.get(namespace, {})
|
|
941
|
+
merged = dict(existing)
|
|
942
|
+
merged.update(normalized)
|
|
943
|
+
self._report_extensions[namespace] = merged
|
|
944
|
+
self._report_touch()
|
|
945
|
+
return True
|
|
946
|
+
|
|
947
|
+
def _report_monitoring_snapshot(self):
|
|
948
|
+
"""Return a JSON-safe copy of monitoring state already held in memory."""
|
|
949
|
+
counts = getattr(self, "_monitoring", {}) or {}
|
|
950
|
+
triggered = getattr(self, "_triggered_thresholds", set()) or set()
|
|
951
|
+
try:
|
|
952
|
+
triggered_values = sorted("|".join(map(str, value)) for value in triggered)
|
|
953
|
+
except Exception:
|
|
954
|
+
logger.warning("trade_logger:947 fallback on Exception")
|
|
955
|
+
triggered_values = []
|
|
956
|
+
return {
|
|
957
|
+
"counts": self._report_json_safe_value(dict(counts)),
|
|
958
|
+
"configured_thresholds": copy.deepcopy(
|
|
959
|
+
getattr(self, "_report_monitoring_thresholds", {})
|
|
960
|
+
),
|
|
961
|
+
"triggered_thresholds": triggered_values,
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
def _build_report_snapshot(self):
|
|
965
|
+
"""Build a report from cached state only; never scan or write logs here."""
|
|
966
|
+
event_counts = getattr(self, "_report_event_counts", {})
|
|
967
|
+
records_dropped = getattr(self, "_report_dropped_records", {})
|
|
968
|
+
report = {
|
|
969
|
+
"schema_version": _REPORT_SCHEMA_VERSION,
|
|
970
|
+
"finalized": bool(getattr(self, "_report_finalized", False)),
|
|
971
|
+
"generated_at": getattr(self, "_report_last_updated_at", None),
|
|
972
|
+
"run_id": self._report_json_safe_value(getattr(self, "_run_id", None)),
|
|
973
|
+
"started_at": getattr(self, "_report_started_at", None),
|
|
974
|
+
"finalized_at": getattr(self, "_report_finalized_at", None),
|
|
975
|
+
"last_event_at": getattr(self, "_report_last_event_at", None),
|
|
976
|
+
"strategy": copy.deepcopy(getattr(self, "_report_strategy", {"name": "Unknown"})),
|
|
977
|
+
"provider": copy.deepcopy(getattr(self, "_report_provider", "")),
|
|
978
|
+
"session_id": copy.deepcopy(getattr(self, "_report_session_id", "")),
|
|
979
|
+
"portfolio": copy.deepcopy(
|
|
980
|
+
getattr(self, "_report_portfolio", {"cash": None, "value": None})
|
|
981
|
+
),
|
|
982
|
+
"positions": copy.deepcopy(getattr(self, "_report_positions", {})),
|
|
983
|
+
"event_counts": {key: int(event_counts.get(key, 0)) for key in _REPORT_EVENT_KEYS},
|
|
984
|
+
"monitoring": self._report_monitoring_snapshot(),
|
|
985
|
+
"order_summaries": copy.deepcopy(list(getattr(self, "_report_orders", ()))),
|
|
986
|
+
"trade_summaries": copy.deepcopy(list(getattr(self, "_report_trades", ()))),
|
|
987
|
+
"records_dropped": {
|
|
988
|
+
"orders": int(records_dropped.get("orders", 0)),
|
|
989
|
+
"trades": int(records_dropped.get("trades", 0)),
|
|
990
|
+
},
|
|
991
|
+
"extensions": copy.deepcopy(getattr(self, "_report_extensions", {})),
|
|
992
|
+
}
|
|
993
|
+
startup_observation = getattr(self, "_report_startup_account_observation", None)
|
|
994
|
+
if startup_observation is not None:
|
|
995
|
+
report["startup_account_observation"] = copy.deepcopy(startup_observation)
|
|
996
|
+
return report
|
|
997
|
+
|
|
998
|
+
def snapshot(self):
|
|
999
|
+
"""Return a deep-copied, real-time report from in-memory cached state.
|
|
1000
|
+
|
|
1001
|
+
This method does not initialize loggers, query log files, write to
|
|
1002
|
+
files/MySQL, or request store/provider metadata. Before returning it
|
|
1003
|
+
refreshes local broker state when a strategy has reached a current bar,
|
|
1004
|
+
so a call from ``Strategy.next`` sees that same bar rather than the
|
|
1005
|
+
observer's previous callback.
|
|
1006
|
+
"""
|
|
1007
|
+
final_report = getattr(self, "_final_report", None)
|
|
1008
|
+
if getattr(self, "_report_finalized", False) and final_report is not None:
|
|
1009
|
+
return copy.deepcopy(final_report)
|
|
1010
|
+
self._refresh_report_state()
|
|
1011
|
+
return copy.deepcopy(self._build_report_snapshot())
|
|
1012
|
+
|
|
1013
|
+
def final_report(self):
|
|
1014
|
+
"""Return the frozen final report after :meth:`stop`, otherwise ``None``."""
|
|
1015
|
+
final_report = getattr(self, "_final_report", None)
|
|
1016
|
+
return copy.deepcopy(final_report) if final_report is not None else None
|
|
1017
|
+
|
|
1018
|
+
def report(self):
|
|
1019
|
+
"""Return the current live snapshot, or the frozen final report after stop."""
|
|
1020
|
+
return self.snapshot()
|
|
1021
|
+
|
|
1022
|
+
def _freeze_report(self):
|
|
1023
|
+
"""Freeze the final report exactly once after the strategy has stopped."""
|
|
1024
|
+
if not hasattr(self, "_report_finalized") or self._report_finalized:
|
|
1025
|
+
return
|
|
1026
|
+
timestamp = self._log_time_str()
|
|
1027
|
+
self._report_finalized = True
|
|
1028
|
+
self._report_finalized_at = timestamp
|
|
1029
|
+
self._report_last_updated_at = timestamp
|
|
1030
|
+
self._report_last_event_at = timestamp
|
|
1031
|
+
self._final_report = self._build_report_snapshot()
|
|
1032
|
+
|
|
1033
|
+
def start(self):
|
|
1034
|
+
"""Called at the start of the backtest/live run."""
|
|
1035
|
+
# CRITICAL: Ensure registration to _lineiterators for next() to be called
|
|
1036
|
+
self._ltype = 2 # LineIterator.ObsType
|
|
1037
|
+
if hasattr(self, "_owner") and self._owner is not None:
|
|
1038
|
+
if hasattr(self._owner, "_lineiterators"):
|
|
1039
|
+
if self._ltype in self._owner._lineiterators:
|
|
1040
|
+
if self not in self._owner._lineiterators[self._ltype]:
|
|
1041
|
+
self._owner._lineiterators[self._ltype].append(self)
|
|
1042
|
+
self._ensure_loggers_initialized()
|
|
1043
|
+
self._start_report()
|
|
1044
|
+
self._save_startup_position_snapshot()
|
|
1045
|
+
self._log_event(
|
|
1046
|
+
"system",
|
|
1047
|
+
"session_started",
|
|
1048
|
+
level="INFO",
|
|
1049
|
+
details={"observer": self.__class__.__name__},
|
|
1050
|
+
)
|
|
1051
|
+
self._log_configured_risk_thresholds()
|
|
1052
|
+
|
|
1053
|
+
def _configured_risk_thresholds(self):
|
|
1054
|
+
"""Return enabled monitoring thresholds for certification evidence."""
|
|
1055
|
+
thresholds = {
|
|
1056
|
+
"submit_count": int(self.p.submit_count_warn_threshold or 0),
|
|
1057
|
+
"cancel_count": int(self.p.cancel_count_warn_threshold or 0),
|
|
1058
|
+
"submit_cancel_total": int(self.p.submit_cancel_total_warn_threshold or 0),
|
|
1059
|
+
"duplicate_order": int(self.p.duplicate_order_warn_threshold or 0),
|
|
1060
|
+
}
|
|
1061
|
+
return {name: value for name, value in thresholds.items() if value > 0}
|
|
1062
|
+
|
|
1063
|
+
def _log_configured_risk_thresholds(self):
|
|
1064
|
+
"""Record threshold configuration using the canonical certification event."""
|
|
1065
|
+
thresholds = self._configured_risk_thresholds()
|
|
1066
|
+
if not thresholds:
|
|
1067
|
+
return
|
|
1068
|
+
|
|
1069
|
+
self._log_event(
|
|
1070
|
+
"monitor",
|
|
1071
|
+
"risk_threshold_configured",
|
|
1072
|
+
level="INFO",
|
|
1073
|
+
details={
|
|
1074
|
+
"thresholds": thresholds,
|
|
1075
|
+
"repeat_window_sec": float(self.p.duplicate_order_window_seconds or 0.0),
|
|
1076
|
+
},
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
def _ensure_loggers_initialized(self):
|
|
1080
|
+
"""Ensure loggers are initialized (lazy initialization)."""
|
|
1081
|
+
if self._loggers_initialized:
|
|
1082
|
+
return
|
|
1083
|
+
self._loggers_initialized = True
|
|
1084
|
+
self._init_loggers()
|
|
1085
|
+
if self.p.mysql_enabled:
|
|
1086
|
+
self._init_mysql()
|
|
1087
|
+
|
|
1088
|
+
def _init_loggers(self):
|
|
1089
|
+
"""Initialize all file loggers using Python standard logging."""
|
|
1090
|
+
os.makedirs(self.p.log_dir, exist_ok=True)
|
|
1091
|
+
|
|
1092
|
+
if self.p.log_orders:
|
|
1093
|
+
self._order_logger = self._create_file_logger(
|
|
1094
|
+
"bt_order", os.path.join(self.p.log_dir, "order.log")
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
if self.p.log_trades:
|
|
1098
|
+
self._trade_logger = self._create_file_logger(
|
|
1099
|
+
"bt_trade", os.path.join(self.p.log_dir, "trade.log")
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
if self.p.log_positions:
|
|
1103
|
+
self._position_logger = self._create_file_logger(
|
|
1104
|
+
"bt_position", os.path.join(self.p.log_dir, "position.log")
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
if self.p.log_indicators:
|
|
1108
|
+
self._indicator_logger = self._create_file_logger(
|
|
1109
|
+
"bt_indicator", os.path.join(self.p.log_dir, "indicator.log")
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
if self.p.log_signals:
|
|
1113
|
+
self._signal_logger = self._create_file_logger(
|
|
1114
|
+
"bt_signal", os.path.join(self.p.log_dir, "signal.log")
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
if self.p.log_ticks:
|
|
1118
|
+
self._tick_logger = self._create_file_logger(
|
|
1119
|
+
"bt_tick", os.path.join(self.p.log_dir, "tick.log")
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
if self.p.log_bars:
|
|
1123
|
+
self._bar_logger = self._create_file_logger(
|
|
1124
|
+
"bt_bar", os.path.join(self.p.log_dir, "bar.log")
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
if self.p.log_system:
|
|
1128
|
+
self._system_logger = self._create_file_logger(
|
|
1129
|
+
"bt_system", os.path.join(self.p.log_dir, "system.log")
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
if self.p.log_monitoring:
|
|
1133
|
+
self._monitor_logger = self._create_file_logger(
|
|
1134
|
+
"bt_monitor", os.path.join(self.p.log_dir, "monitor.log")
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
if self.p.log_value:
|
|
1138
|
+
self._value_logger = self._create_file_logger(
|
|
1139
|
+
"bt_value", os.path.join(self.p.log_dir, "value.log")
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
if self.p.log_errors:
|
|
1143
|
+
self._error_logger = self._create_file_logger(
|
|
1144
|
+
"bt_error", os.path.join(self.p.log_dir, "error.log")
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
def _create_file_logger(self, name, file_path):
|
|
1148
|
+
"""Create a file logger using Python standard logging.
|
|
1149
|
+
|
|
1150
|
+
Args:
|
|
1151
|
+
name: Logger name
|
|
1152
|
+
file_path: Path to log file
|
|
1153
|
+
|
|
1154
|
+
Returns:
|
|
1155
|
+
logging.Logger instance
|
|
1156
|
+
"""
|
|
1157
|
+
logger = logging.getLogger(f"{name}:{id(self)}")
|
|
1158
|
+
logger.setLevel(logging.INFO)
|
|
1159
|
+
logger.propagate = False
|
|
1160
|
+
self._close_logger_handlers(logger)
|
|
1161
|
+
|
|
1162
|
+
# File handler - write to file
|
|
1163
|
+
file_handler = logging.FileHandler(file_path, encoding="utf-8")
|
|
1164
|
+
file_handler.setLevel(logging.INFO)
|
|
1165
|
+
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
1166
|
+
logger.addHandler(file_handler)
|
|
1167
|
+
|
|
1168
|
+
# Console handler - optional
|
|
1169
|
+
if self.p.log_to_console:
|
|
1170
|
+
console_handler = logging.StreamHandler()
|
|
1171
|
+
console_handler.setLevel(logging.INFO)
|
|
1172
|
+
console_handler.setFormatter(logging.Formatter("[%(name)s] %(message)s"))
|
|
1173
|
+
logger.addHandler(console_handler)
|
|
1174
|
+
|
|
1175
|
+
return logger
|
|
1176
|
+
|
|
1177
|
+
@staticmethod
|
|
1178
|
+
def _close_logger_handlers(file_logger):
|
|
1179
|
+
"""Detach and close every handler owned by one per-instance file logger."""
|
|
1180
|
+
for handler in list(getattr(file_logger, "handlers", ()) or ()):
|
|
1181
|
+
remove_handler = getattr(file_logger, "removeHandler", None)
|
|
1182
|
+
if callable(remove_handler):
|
|
1183
|
+
try:
|
|
1184
|
+
remove_handler(handler)
|
|
1185
|
+
except Exception:
|
|
1186
|
+
logger.warning("Failed to remove TradeLogger file handler", exc_info=True)
|
|
1187
|
+
close_handler = getattr(handler, "close", None)
|
|
1188
|
+
if callable(close_handler):
|
|
1189
|
+
try:
|
|
1190
|
+
close_handler()
|
|
1191
|
+
except Exception:
|
|
1192
|
+
logger.warning("Failed to close TradeLogger file handler", exc_info=True)
|
|
1193
|
+
|
|
1194
|
+
def _shutdown_file_loggers(self):
|
|
1195
|
+
"""Release each per-run log file before a caller cleans up its directory."""
|
|
1196
|
+
for attribute in (
|
|
1197
|
+
"_order_logger",
|
|
1198
|
+
"_trade_logger",
|
|
1199
|
+
"_position_logger",
|
|
1200
|
+
"_indicator_logger",
|
|
1201
|
+
"_signal_logger",
|
|
1202
|
+
"_system_logger",
|
|
1203
|
+
"_monitor_logger",
|
|
1204
|
+
"_tick_logger",
|
|
1205
|
+
"_bar_logger",
|
|
1206
|
+
"_value_logger",
|
|
1207
|
+
"_error_logger",
|
|
1208
|
+
):
|
|
1209
|
+
file_logger = getattr(self, attribute, None)
|
|
1210
|
+
if file_logger is not None:
|
|
1211
|
+
self._close_logger_handlers(file_logger)
|
|
1212
|
+
setattr(self, attribute, None)
|
|
1213
|
+
|
|
1214
|
+
@staticmethod
|
|
1215
|
+
def _generate_run_id():
|
|
1216
|
+
"""Generate a stable per-run identifier for correlation."""
|
|
1217
|
+
timestamp = datetime.now(_SHANGHAI_TZ).strftime("%Y%m%d%H%M%S")
|
|
1218
|
+
return f"trade-log-{timestamp}-{uuid.uuid4().hex[:8]}"
|
|
1219
|
+
|
|
1220
|
+
@staticmethod
|
|
1221
|
+
def _log_time_str():
|
|
1222
|
+
"""Return the current Shanghai (UTC+8) timestamp as an ISO string."""
|
|
1223
|
+
return datetime.now(_SHANGHAI_TZ).isoformat(timespec="milliseconds")
|
|
1224
|
+
|
|
1225
|
+
@staticmethod
|
|
1226
|
+
def _event_time_str(event_time, fallback):
|
|
1227
|
+
"""Return an ISO event timestamp with an explicit timezone offset."""
|
|
1228
|
+
if event_time in (None, ""):
|
|
1229
|
+
return fallback
|
|
1230
|
+
|
|
1231
|
+
if isinstance(event_time, datetime):
|
|
1232
|
+
dt_value = event_time
|
|
1233
|
+
elif isinstance(event_time, (int, float)):
|
|
1234
|
+
try:
|
|
1235
|
+
dt_value = datetime.fromtimestamp(float(event_time), timezone.utc)
|
|
1236
|
+
except (OverflowError, OSError, ValueError):
|
|
1237
|
+
return str(event_time)
|
|
1238
|
+
elif isinstance(event_time, str):
|
|
1239
|
+
value = event_time.strip()
|
|
1240
|
+
if not value:
|
|
1241
|
+
return fallback
|
|
1242
|
+
try:
|
|
1243
|
+
normalized = f"{value[:-1]}+00:00" if value.endswith("Z") else value
|
|
1244
|
+
dt_value = datetime.fromisoformat(normalized)
|
|
1245
|
+
except ValueError:
|
|
1246
|
+
return value
|
|
1247
|
+
else:
|
|
1248
|
+
return str(event_time)
|
|
1249
|
+
|
|
1250
|
+
if dt_value.tzinfo is None or dt_value.utcoffset() is None:
|
|
1251
|
+
dt_value = dt_value.replace(tzinfo=timezone.utc)
|
|
1252
|
+
return dt_value.isoformat(timespec="milliseconds")
|
|
1253
|
+
|
|
1254
|
+
def _normalize_event_time_fields(self, payload, fallback=None):
|
|
1255
|
+
"""Normalize human-facing event time fields without touching epoch timestamps."""
|
|
1256
|
+
normalized = dict(payload)
|
|
1257
|
+
fallback = fallback or self._log_time_str()
|
|
1258
|
+
for key in ("datetime", "time", "local_time"):
|
|
1259
|
+
if key in normalized:
|
|
1260
|
+
normalized[key] = self._event_time_str(normalized.get(key), fallback)
|
|
1261
|
+
return normalized
|
|
1262
|
+
|
|
1263
|
+
def _store_provider(self):
|
|
1264
|
+
"""Return the active live provider when available."""
|
|
1265
|
+
try:
|
|
1266
|
+
broker = getattr(self._owner, "broker", None)
|
|
1267
|
+
store = getattr(broker, "store", None)
|
|
1268
|
+
if store is not None:
|
|
1269
|
+
return getattr(store, "provider", "")
|
|
1270
|
+
return getattr(broker, "provider", "")
|
|
1271
|
+
except Exception as e:
|
|
1272
|
+
logger.debug("Failed to read store provider: %s", e)
|
|
1273
|
+
return ""
|
|
1274
|
+
|
|
1275
|
+
def _session_id(self):
|
|
1276
|
+
"""Return the active store session id when available."""
|
|
1277
|
+
try:
|
|
1278
|
+
broker = getattr(self._owner, "broker", None)
|
|
1279
|
+
store = getattr(broker, "store", None)
|
|
1280
|
+
return getattr(store, "session_id", "") if store is not None else ""
|
|
1281
|
+
except Exception as e:
|
|
1282
|
+
logger.debug("Failed to read session id: %s", e)
|
|
1283
|
+
return ""
|
|
1284
|
+
|
|
1285
|
+
@staticmethod
|
|
1286
|
+
def _safe_order_info(order, key, default=None):
|
|
1287
|
+
"""Read a value from order.info with a stable fallback."""
|
|
1288
|
+
info = getattr(order, "info", None)
|
|
1289
|
+
if info is None:
|
|
1290
|
+
return default
|
|
1291
|
+
|
|
1292
|
+
try:
|
|
1293
|
+
value = getattr(info, key)
|
|
1294
|
+
if isinstance(value, Mapping) and not value:
|
|
1295
|
+
return default
|
|
1296
|
+
return value
|
|
1297
|
+
except AttributeError:
|
|
1298
|
+
# No attribute named `key`; try the dict-style .get() path below.
|
|
1299
|
+
logger.debug("trade_logger:1259 ignored AttributeError")
|
|
1300
|
+
except Exception:
|
|
1301
|
+
# Attribute access raised unexpectedly; this is a best-effort read
|
|
1302
|
+
# for logging only, so fall back to the .get() path below. Logged
|
|
1303
|
+
# at debug to keep the failure visible without breaking logging.
|
|
1304
|
+
logger.debug("order.info attribute read failed for key %r", key, exc_info=True)
|
|
1305
|
+
|
|
1306
|
+
get_method = getattr(info, "get", None)
|
|
1307
|
+
if callable(get_method):
|
|
1308
|
+
try:
|
|
1309
|
+
value = get_method(key, default)
|
|
1310
|
+
if isinstance(value, Mapping) and not value:
|
|
1311
|
+
return default
|
|
1312
|
+
return value
|
|
1313
|
+
except Exception:
|
|
1314
|
+
logger.warning("trade_logger:1274 fallback on Exception")
|
|
1315
|
+
return default
|
|
1316
|
+
|
|
1317
|
+
return default
|
|
1318
|
+
|
|
1319
|
+
def _base_event(self, event_type, level="INFO", event_time=None, **fields):
|
|
1320
|
+
"""Create a common structured event payload."""
|
|
1321
|
+
log_time = self._log_time_str()
|
|
1322
|
+
payload = {
|
|
1323
|
+
"log_time": log_time,
|
|
1324
|
+
"event_time": self._event_time_str(event_time, log_time),
|
|
1325
|
+
"event_type": event_type,
|
|
1326
|
+
"level": str(level).upper(),
|
|
1327
|
+
"run_id": self._run_id,
|
|
1328
|
+
"session_id": self._session_id(),
|
|
1329
|
+
"provider": self._store_provider(),
|
|
1330
|
+
"strategy_name": self._get_strategy_name(),
|
|
1331
|
+
}
|
|
1332
|
+
payload.update(fields)
|
|
1333
|
+
return payload
|
|
1334
|
+
|
|
1335
|
+
def _emit_payload(self, logger, payload, text_line=None):
|
|
1336
|
+
"""Write a structured payload to a logger."""
|
|
1337
|
+
if logger is None:
|
|
1338
|
+
return
|
|
1339
|
+
|
|
1340
|
+
if self.p.log_format == "json":
|
|
1341
|
+
logger.info(json.dumps(payload, ensure_ascii=False, default=str))
|
|
1342
|
+
return
|
|
1343
|
+
|
|
1344
|
+
if text_line is None:
|
|
1345
|
+
parts = [
|
|
1346
|
+
payload.get("log_time", ""),
|
|
1347
|
+
payload.get("level", "INFO"),
|
|
1348
|
+
payload.get("event_type", ""),
|
|
1349
|
+
]
|
|
1350
|
+
for key in ("data_name", "status", "error_code", "error_msg"):
|
|
1351
|
+
value = payload.get(key)
|
|
1352
|
+
if value not in ("", None):
|
|
1353
|
+
parts.append(f"{key}={value}")
|
|
1354
|
+
details = payload.get("details")
|
|
1355
|
+
if details:
|
|
1356
|
+
parts.append(str(details))
|
|
1357
|
+
text_line = " | ".join(str(part) for part in parts if part != "")
|
|
1358
|
+
|
|
1359
|
+
logger.info(text_line)
|
|
1360
|
+
|
|
1361
|
+
def _log_event(self, category, event_type, level="INFO", text_line=None, **fields):
|
|
1362
|
+
"""Route a structured event into the appropriate runtime log."""
|
|
1363
|
+
logger_map = {
|
|
1364
|
+
"system": self._system_logger,
|
|
1365
|
+
"monitor": self._monitor_logger,
|
|
1366
|
+
"error": self._error_logger,
|
|
1367
|
+
}
|
|
1368
|
+
payload = self._base_event(event_type, level=level, **fields)
|
|
1369
|
+
self._emit_payload(logger_map.get(category), payload, text_line=text_line)
|
|
1370
|
+
return payload
|
|
1371
|
+
|
|
1372
|
+
def _log_internal_error(self, source, exc):
|
|
1373
|
+
self._record_report_event("errors")
|
|
1374
|
+
try:
|
|
1375
|
+
self._log_event(
|
|
1376
|
+
"error",
|
|
1377
|
+
"observer_internal_error",
|
|
1378
|
+
level="ERROR",
|
|
1379
|
+
error_code=str(source),
|
|
1380
|
+
error_msg=str(exc),
|
|
1381
|
+
details={"source": str(source)},
|
|
1382
|
+
)
|
|
1383
|
+
except Exception:
|
|
1384
|
+
logger.error("TradeLogger internal error in %s: %s", source, exc)
|
|
1385
|
+
|
|
1386
|
+
def _monitor_threshold(self, counter_name, threshold, event_type):
|
|
1387
|
+
"""Emit a warning event when a monitoring threshold is crossed."""
|
|
1388
|
+
if threshold <= 0:
|
|
1389
|
+
return
|
|
1390
|
+
|
|
1391
|
+
value = int(self._monitoring.get(counter_name, 0))
|
|
1392
|
+
if value < threshold:
|
|
1393
|
+
return
|
|
1394
|
+
|
|
1395
|
+
key = (counter_name, threshold)
|
|
1396
|
+
if key in self._triggered_thresholds:
|
|
1397
|
+
return
|
|
1398
|
+
|
|
1399
|
+
self._triggered_thresholds.add(key)
|
|
1400
|
+
self._log_event(
|
|
1401
|
+
"monitor",
|
|
1402
|
+
event_type,
|
|
1403
|
+
level="WARNING",
|
|
1404
|
+
details={"counter": counter_name, "value": value, "threshold": threshold},
|
|
1405
|
+
)
|
|
1406
|
+
self._log_event(
|
|
1407
|
+
"monitor",
|
|
1408
|
+
"risk_threshold_triggered",
|
|
1409
|
+
level="WARNING",
|
|
1410
|
+
details={
|
|
1411
|
+
"counter": counter_name,
|
|
1412
|
+
"value": value,
|
|
1413
|
+
"threshold": threshold,
|
|
1414
|
+
"source_event_type": event_type,
|
|
1415
|
+
},
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
def _make_duplicate_key(self, action_type, details):
|
|
1419
|
+
"""Build a duplicate-request key within the configured time window."""
|
|
1420
|
+
|
|
1421
|
+
def normalize(value):
|
|
1422
|
+
return "" if value is None else str(value)
|
|
1423
|
+
|
|
1424
|
+
if action_type == "cancel":
|
|
1425
|
+
return (
|
|
1426
|
+
action_type,
|
|
1427
|
+
normalize(details.get("data_name")),
|
|
1428
|
+
"",
|
|
1429
|
+
"",
|
|
1430
|
+
"",
|
|
1431
|
+
"",
|
|
1432
|
+
"",
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
return (
|
|
1436
|
+
action_type,
|
|
1437
|
+
normalize(details.get("data_name")),
|
|
1438
|
+
normalize(details.get("side")),
|
|
1439
|
+
normalize(details.get("offset")),
|
|
1440
|
+
normalize(details.get("size")),
|
|
1441
|
+
normalize(details.get("price")),
|
|
1442
|
+
normalize(details.get("order_ref")),
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
def _track_request_monitoring(self, action_type, details):
|
|
1446
|
+
"""Update request counters, duplicate detection, and threshold checks."""
|
|
1447
|
+
if action_type == "submit":
|
|
1448
|
+
self._monitoring["submit_count"] += 1
|
|
1449
|
+
self._monitoring["submit_cancel_total"] += 1
|
|
1450
|
+
self._log_event(
|
|
1451
|
+
"monitor",
|
|
1452
|
+
"risk_monitor_event",
|
|
1453
|
+
level="INFO",
|
|
1454
|
+
details={
|
|
1455
|
+
"metric": "submitted_order_count",
|
|
1456
|
+
"value": int(self._monitoring["submit_count"]),
|
|
1457
|
+
"action_type": action_type,
|
|
1458
|
+
**details,
|
|
1459
|
+
},
|
|
1460
|
+
)
|
|
1461
|
+
self._monitor_threshold(
|
|
1462
|
+
"submit_count",
|
|
1463
|
+
int(self.p.submit_count_warn_threshold or 0),
|
|
1464
|
+
"submit_count_threshold_reached",
|
|
1465
|
+
)
|
|
1466
|
+
self._monitor_threshold(
|
|
1467
|
+
"submit_cancel_total",
|
|
1468
|
+
int(self.p.submit_cancel_total_warn_threshold or 0),
|
|
1469
|
+
"submit_cancel_total_threshold_reached",
|
|
1470
|
+
)
|
|
1471
|
+
elif action_type == "cancel":
|
|
1472
|
+
self._monitoring["cancel_count"] += 1
|
|
1473
|
+
self._monitoring["submit_cancel_total"] += 1
|
|
1474
|
+
self._log_event(
|
|
1475
|
+
"monitor",
|
|
1476
|
+
"risk_monitor_event",
|
|
1477
|
+
level="INFO",
|
|
1478
|
+
details={
|
|
1479
|
+
"metric": "cancel_order_count",
|
|
1480
|
+
"value": int(self._monitoring["cancel_count"]),
|
|
1481
|
+
"action_type": action_type,
|
|
1482
|
+
**details,
|
|
1483
|
+
},
|
|
1484
|
+
)
|
|
1485
|
+
self._monitor_threshold(
|
|
1486
|
+
"cancel_count",
|
|
1487
|
+
int(self.p.cancel_count_warn_threshold or 0),
|
|
1488
|
+
"cancel_count_threshold_reached",
|
|
1489
|
+
)
|
|
1490
|
+
self._monitor_threshold(
|
|
1491
|
+
"submit_cancel_total",
|
|
1492
|
+
int(self.p.submit_cancel_total_warn_threshold or 0),
|
|
1493
|
+
"submit_cancel_total_threshold_reached",
|
|
1494
|
+
)
|
|
1495
|
+
|
|
1496
|
+
key = self._make_duplicate_key(action_type, details)
|
|
1497
|
+
window = float(self.p.duplicate_order_window_seconds or 0.0)
|
|
1498
|
+
if window <= 0:
|
|
1499
|
+
return
|
|
1500
|
+
|
|
1501
|
+
now = time.time()
|
|
1502
|
+
queue = self._duplicate_requests[key]
|
|
1503
|
+
queue.append(now)
|
|
1504
|
+
while queue and (now - queue[0]) > window:
|
|
1505
|
+
queue.popleft()
|
|
1506
|
+
|
|
1507
|
+
if len(queue) <= 1:
|
|
1508
|
+
return
|
|
1509
|
+
|
|
1510
|
+
counter_name = f"duplicate_{action_type}_count"
|
|
1511
|
+
self._monitoring[counter_name] += 1
|
|
1512
|
+
self._log_event(
|
|
1513
|
+
"monitor",
|
|
1514
|
+
"duplicate_order_detected",
|
|
1515
|
+
level="WARNING",
|
|
1516
|
+
details={
|
|
1517
|
+
"action_type": action_type,
|
|
1518
|
+
"duplicate_count": len(queue),
|
|
1519
|
+
**details,
|
|
1520
|
+
},
|
|
1521
|
+
)
|
|
1522
|
+
repeat_event_type = (
|
|
1523
|
+
"risk_repeat_cancel_detected"
|
|
1524
|
+
if action_type == "cancel"
|
|
1525
|
+
else "risk_repeat_order_detected"
|
|
1526
|
+
)
|
|
1527
|
+
self._log_event(
|
|
1528
|
+
"monitor",
|
|
1529
|
+
repeat_event_type,
|
|
1530
|
+
level="WARNING",
|
|
1531
|
+
details={
|
|
1532
|
+
"action_type": action_type,
|
|
1533
|
+
"repeat_key": "|".join(str(part) for part in key),
|
|
1534
|
+
"repeat_count": len(queue),
|
|
1535
|
+
**details,
|
|
1536
|
+
},
|
|
1537
|
+
)
|
|
1538
|
+
self._monitor_threshold(
|
|
1539
|
+
counter_name,
|
|
1540
|
+
int(self.p.duplicate_order_warn_threshold or 0),
|
|
1541
|
+
"duplicate_order_threshold_reached",
|
|
1542
|
+
)
|
|
1543
|
+
|
|
1544
|
+
def _init_mysql(self):
|
|
1545
|
+
"""Initialize MySQL connection and create tables."""
|
|
1546
|
+
if not MYSQL_AVAILABLE:
|
|
1547
|
+
logger.warning("pymysql not installed, MySQL logging disabled")
|
|
1548
|
+
if self.p.log_to_console:
|
|
1549
|
+
logger.warning(
|
|
1550
|
+
"[TradeLogger] Warning: pymysql not installed, MySQL logging disabled"
|
|
1551
|
+
)
|
|
1552
|
+
return
|
|
1553
|
+
|
|
1554
|
+
try:
|
|
1555
|
+
self._mysql_conn = pymysql.connect(
|
|
1556
|
+
host=self.p.mysql_host,
|
|
1557
|
+
port=self.p.mysql_port,
|
|
1558
|
+
user=self.p.mysql_user,
|
|
1559
|
+
password=self.p.mysql_password,
|
|
1560
|
+
database=self.p.mysql_database,
|
|
1561
|
+
charset="utf8mb4",
|
|
1562
|
+
autocommit=True,
|
|
1563
|
+
)
|
|
1564
|
+
self._create_mysql_tables()
|
|
1565
|
+
except Exception as e:
|
|
1566
|
+
logger.error("MySQL connection failed: %s", e)
|
|
1567
|
+
if self.p.log_to_console:
|
|
1568
|
+
logger.warning(f"[TradeLogger] MySQL connection failed: {e}")
|
|
1569
|
+
self._mysql_conn = None
|
|
1570
|
+
|
|
1571
|
+
def _create_mysql_tables(self):
|
|
1572
|
+
"""Create MySQL tables if they don't exist."""
|
|
1573
|
+
if not self._mysql_conn:
|
|
1574
|
+
return
|
|
1575
|
+
|
|
1576
|
+
cursor = self._mysql_conn.cursor()
|
|
1577
|
+
|
|
1578
|
+
# Orders table
|
|
1579
|
+
cursor.execute("""
|
|
1580
|
+
CREATE TABLE IF NOT EXISTS bt_orders (
|
|
1581
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
1582
|
+
datetime DATETIME,
|
|
1583
|
+
ref INT,
|
|
1584
|
+
order_type VARCHAR(10),
|
|
1585
|
+
status VARCHAR(20),
|
|
1586
|
+
size DOUBLE,
|
|
1587
|
+
price DOUBLE,
|
|
1588
|
+
executed_price DOUBLE,
|
|
1589
|
+
executed_size DOUBLE,
|
|
1590
|
+
executed_value DOUBLE,
|
|
1591
|
+
commission DOUBLE,
|
|
1592
|
+
data_name VARCHAR(50),
|
|
1593
|
+
strategy_name VARCHAR(100),
|
|
1594
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
1595
|
+
INDEX idx_datetime (datetime),
|
|
1596
|
+
INDEX idx_ref (ref),
|
|
1597
|
+
INDEX idx_data_name (data_name)
|
|
1598
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
1599
|
+
""")
|
|
1600
|
+
|
|
1601
|
+
# Trades table
|
|
1602
|
+
cursor.execute("""
|
|
1603
|
+
CREATE TABLE IF NOT EXISTS bt_trades (
|
|
1604
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
1605
|
+
datetime DATETIME,
|
|
1606
|
+
ref INT,
|
|
1607
|
+
data_name VARCHAR(50),
|
|
1608
|
+
size DOUBLE,
|
|
1609
|
+
price DOUBLE,
|
|
1610
|
+
value DOUBLE,
|
|
1611
|
+
pnl DOUBLE,
|
|
1612
|
+
pnlcomm DOUBLE,
|
|
1613
|
+
commission DOUBLE,
|
|
1614
|
+
isclosed BOOLEAN,
|
|
1615
|
+
isopen BOOLEAN,
|
|
1616
|
+
baropen INT,
|
|
1617
|
+
barclose INT,
|
|
1618
|
+
barlen INT,
|
|
1619
|
+
strategy_name VARCHAR(100),
|
|
1620
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
1621
|
+
INDEX idx_datetime (datetime),
|
|
1622
|
+
INDEX idx_data_name (data_name),
|
|
1623
|
+
INDEX idx_isclosed (isclosed)
|
|
1624
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
1625
|
+
""")
|
|
1626
|
+
|
|
1627
|
+
# Positions table
|
|
1628
|
+
cursor.execute("""
|
|
1629
|
+
CREATE TABLE IF NOT EXISTS bt_positions (
|
|
1630
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
1631
|
+
datetime DATETIME,
|
|
1632
|
+
data_name VARCHAR(50),
|
|
1633
|
+
size DOUBLE,
|
|
1634
|
+
price DOUBLE,
|
|
1635
|
+
value DOUBLE,
|
|
1636
|
+
strategy_name VARCHAR(100),
|
|
1637
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
1638
|
+
INDEX idx_datetime (datetime),
|
|
1639
|
+
INDEX idx_data_name (data_name)
|
|
1640
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
1641
|
+
""")
|
|
1642
|
+
|
|
1643
|
+
# Indicators table
|
|
1644
|
+
cursor.execute("""
|
|
1645
|
+
CREATE TABLE IF NOT EXISTS bt_indicators (
|
|
1646
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
1647
|
+
datetime DATETIME,
|
|
1648
|
+
indicator_name VARCHAR(100),
|
|
1649
|
+
indicator_value DOUBLE,
|
|
1650
|
+
data_name VARCHAR(50),
|
|
1651
|
+
strategy_name VARCHAR(100),
|
|
1652
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
1653
|
+
INDEX idx_datetime (datetime),
|
|
1654
|
+
INDEX idx_indicator_name (indicator_name)
|
|
1655
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
1656
|
+
""")
|
|
1657
|
+
|
|
1658
|
+
# Signals table
|
|
1659
|
+
cursor.execute("""
|
|
1660
|
+
CREATE TABLE IF NOT EXISTS bt_signals (
|
|
1661
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
1662
|
+
datetime DATETIME,
|
|
1663
|
+
action VARCHAR(10),
|
|
1664
|
+
size DOUBLE,
|
|
1665
|
+
price DOUBLE,
|
|
1666
|
+
data_name VARCHAR(50),
|
|
1667
|
+
reason VARCHAR(255),
|
|
1668
|
+
strategy_name VARCHAR(100),
|
|
1669
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
1670
|
+
INDEX idx_datetime (datetime),
|
|
1671
|
+
INDEX idx_action (action)
|
|
1672
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
1673
|
+
""")
|
|
1674
|
+
|
|
1675
|
+
cursor.close()
|
|
1676
|
+
|
|
1677
|
+
def _get_datetime_str(self):
|
|
1678
|
+
"""Get current strategy datetime as an ISO string with an explicit offset."""
|
|
1679
|
+
fallback = self._log_time_str()
|
|
1680
|
+
try:
|
|
1681
|
+
dt = self._owner.datetime.datetime()
|
|
1682
|
+
except Exception as e:
|
|
1683
|
+
logger.debug("Failed to read strategy datetime: %s", e)
|
|
1684
|
+
return fallback
|
|
1685
|
+
return self._event_time_str(dt, fallback)
|
|
1686
|
+
|
|
1687
|
+
@staticmethod
|
|
1688
|
+
def _is_epoch_zero_text(value):
|
|
1689
|
+
"""Return True when a normalized timestamp is the platform zero date."""
|
|
1690
|
+
text = str(value or "").strip()
|
|
1691
|
+
return text.startswith(("1970-01-01T00:00:00", "1970-01-01 00:00:00"))
|
|
1692
|
+
|
|
1693
|
+
def _event_time_str_or_none(self, event_time, fallback):
|
|
1694
|
+
"""Normalize an event time, treating empty/zero dates as missing."""
|
|
1695
|
+
if event_time in (None, "", 0, 0.0):
|
|
1696
|
+
return None
|
|
1697
|
+
text = self._event_time_str(event_time, fallback)
|
|
1698
|
+
if self._is_epoch_zero_text(text):
|
|
1699
|
+
return None
|
|
1700
|
+
return text
|
|
1701
|
+
|
|
1702
|
+
def _trade_numdate_str(self, trade, value, fallback):
|
|
1703
|
+
"""Convert a backtrader numeric trade date into an ISO timestamp."""
|
|
1704
|
+
if value in (None, "", 0, 0.0):
|
|
1705
|
+
return None
|
|
1706
|
+
data = getattr(trade, "data", None)
|
|
1707
|
+
try:
|
|
1708
|
+
if data is None or not hasattr(data, "num2date"):
|
|
1709
|
+
return None
|
|
1710
|
+
dt_value = data.num2date(value)
|
|
1711
|
+
except Exception as e:
|
|
1712
|
+
logger.debug("Failed to convert trade datetime: %s", e)
|
|
1713
|
+
return None
|
|
1714
|
+
return self._event_time_str_or_none(dt_value, fallback)
|
|
1715
|
+
|
|
1716
|
+
def _data_current_datetime_str(self, data, fallback):
|
|
1717
|
+
"""Return the current data timestamp as an ISO string when available."""
|
|
1718
|
+
if data is None:
|
|
1719
|
+
return None
|
|
1720
|
+
|
|
1721
|
+
data_datetime = getattr(data, "datetime", None)
|
|
1722
|
+
datetime_reader = getattr(data_datetime, "datetime", None)
|
|
1723
|
+
if callable(datetime_reader):
|
|
1724
|
+
for args in ((), (0,)):
|
|
1725
|
+
try:
|
|
1726
|
+
text = self._event_time_str_or_none(datetime_reader(*args), fallback)
|
|
1727
|
+
except Exception as e:
|
|
1728
|
+
logger.debug("Failed to read data datetime: %s", e)
|
|
1729
|
+
continue
|
|
1730
|
+
if text is not None:
|
|
1731
|
+
return text
|
|
1732
|
+
|
|
1733
|
+
try:
|
|
1734
|
+
numeric_dt = data_datetime[0]
|
|
1735
|
+
except Exception:
|
|
1736
|
+
logger.warning("trade_logger:1695 fallback on Exception")
|
|
1737
|
+
return None
|
|
1738
|
+
|
|
1739
|
+
try:
|
|
1740
|
+
if hasattr(data, "num2date"):
|
|
1741
|
+
return self._event_time_str_or_none(data.num2date(numeric_dt), fallback)
|
|
1742
|
+
except Exception as e:
|
|
1743
|
+
logger.debug("Failed to convert current data datetime: %s", e)
|
|
1744
|
+
return None
|
|
1745
|
+
|
|
1746
|
+
def _trade_time_fields(self, trade, log_time=None):
|
|
1747
|
+
"""Return event/open/close timestamps for a trade without zero-date leaks."""
|
|
1748
|
+
fallback = log_time or self._log_time_str()
|
|
1749
|
+
data = getattr(trade, "data", None)
|
|
1750
|
+
|
|
1751
|
+
dtopen = self._trade_numdate_str(trade, getattr(trade, "dtopen", None), fallback)
|
|
1752
|
+
dtclose = self._trade_numdate_str(trade, getattr(trade, "dtclose", None), fallback)
|
|
1753
|
+
data_current = self._data_current_datetime_str(data, fallback)
|
|
1754
|
+
owner_current = self._event_time_str_or_none(self._get_datetime_str(), fallback)
|
|
1755
|
+
|
|
1756
|
+
if getattr(trade, "isclosed", False):
|
|
1757
|
+
event_time = dtclose or data_current or owner_current or fallback
|
|
1758
|
+
dtclose = dtclose or event_time
|
|
1759
|
+
else:
|
|
1760
|
+
event_time = dtopen or data_current or owner_current or fallback
|
|
1761
|
+
|
|
1762
|
+
if getattr(trade, "isopen", False):
|
|
1763
|
+
dtopen = dtopen or event_time
|
|
1764
|
+
|
|
1765
|
+
return event_time, dtopen, dtclose
|
|
1766
|
+
|
|
1767
|
+
def _get_strategy_name(self):
|
|
1768
|
+
"""Get the strategy class name."""
|
|
1769
|
+
if self._owner is None:
|
|
1770
|
+
return "Unknown"
|
|
1771
|
+
try:
|
|
1772
|
+
return self._owner.__class__.__name__
|
|
1773
|
+
except Exception as e:
|
|
1774
|
+
logger.debug("Failed to read strategy name: %s", e)
|
|
1775
|
+
return "Unknown"
|
|
1776
|
+
|
|
1777
|
+
def _get_broker_value(self):
|
|
1778
|
+
"""Get current broker portfolio value."""
|
|
1779
|
+
try:
|
|
1780
|
+
broker = getattr(self._owner, "broker", None)
|
|
1781
|
+
if broker is None:
|
|
1782
|
+
return 0.0
|
|
1783
|
+
return float(broker.getvalue())
|
|
1784
|
+
except Exception as e:
|
|
1785
|
+
logger.debug("Failed to read broker value: %s", e)
|
|
1786
|
+
return 0.0
|
|
1787
|
+
|
|
1788
|
+
def _get_broker_cash(self):
|
|
1789
|
+
"""Get current broker cash."""
|
|
1790
|
+
try:
|
|
1791
|
+
broker = getattr(self._owner, "broker", None)
|
|
1792
|
+
if broker is None:
|
|
1793
|
+
return 0.0
|
|
1794
|
+
return float(broker.getcash())
|
|
1795
|
+
except Exception as e:
|
|
1796
|
+
logger.debug("Failed to read broker cash: %s", e)
|
|
1797
|
+
return 0.0
|
|
1798
|
+
|
|
1799
|
+
def _iter_position_datas(self):
|
|
1800
|
+
"""Yield known data identities without creating broker-side state."""
|
|
1801
|
+
if not hasattr(self, "_owner") or self._owner is None:
|
|
1802
|
+
return []
|
|
1803
|
+
|
|
1804
|
+
result = []
|
|
1805
|
+
names = set()
|
|
1806
|
+
|
|
1807
|
+
def add(data):
|
|
1808
|
+
if data is None:
|
|
1809
|
+
return
|
|
1810
|
+
name = str(getattr(data, "_name", None) or getattr(data, "_dataname", None) or data)
|
|
1811
|
+
if name in names:
|
|
1812
|
+
return
|
|
1813
|
+
names.add(name)
|
|
1814
|
+
result.append(data)
|
|
1815
|
+
|
|
1816
|
+
for data in getattr(self._owner, "datas", []) or []:
|
|
1817
|
+
add(data)
|
|
1818
|
+
|
|
1819
|
+
placeholder_data = getattr(self._owner, "placeholder_data", None)
|
|
1820
|
+
if isinstance(placeholder_data, dict):
|
|
1821
|
+
for _, data in sorted(placeholder_data.items()):
|
|
1822
|
+
add(data)
|
|
1823
|
+
elif placeholder_data:
|
|
1824
|
+
try:
|
|
1825
|
+
for data in placeholder_data:
|
|
1826
|
+
add(data)
|
|
1827
|
+
except TypeError:
|
|
1828
|
+
logger.debug("trade_logger:1784 ignored TypeError")
|
|
1829
|
+
|
|
1830
|
+
# Channel-only strategies receive these stable references from Cerebro
|
|
1831
|
+
# before their event callbacks. They are required when a strategy
|
|
1832
|
+
# intentionally has neither a LineSeries data feed nor a hand-made
|
|
1833
|
+
# placeholder object.
|
|
1834
|
+
hft_refs = getattr(self._owner, "_hft_data_refs", None)
|
|
1835
|
+
if isinstance(hft_refs, Mapping):
|
|
1836
|
+
for _, data in sorted(hft_refs.items()):
|
|
1837
|
+
add(data)
|
|
1838
|
+
|
|
1839
|
+
return result
|
|
1840
|
+
|
|
1841
|
+
@staticmethod
|
|
1842
|
+
def _float_or_none(value):
|
|
1843
|
+
if value in (None, ""):
|
|
1844
|
+
return None
|
|
1845
|
+
try:
|
|
1846
|
+
return float(value)
|
|
1847
|
+
except (TypeError, ValueError):
|
|
1848
|
+
return None
|
|
1849
|
+
|
|
1850
|
+
@staticmethod
|
|
1851
|
+
def _positive_float(value, default=1.0):
|
|
1852
|
+
number = TradeLogger._float_or_none(value)
|
|
1853
|
+
if number is None or number <= 0:
|
|
1854
|
+
return default
|
|
1855
|
+
return number
|
|
1856
|
+
|
|
1857
|
+
def _current_position_price(self, data, position):
|
|
1858
|
+
"""Best-effort local mark price for generic position valuation."""
|
|
1859
|
+
try:
|
|
1860
|
+
return float(data.close[0])
|
|
1861
|
+
except Exception:
|
|
1862
|
+
logger.warning("trade_logger:1818 suppressed Exception")
|
|
1863
|
+
|
|
1864
|
+
# TickBroker and compatible brokers expose this explicit local-cache
|
|
1865
|
+
# hook. Do not fall back to a generic broker getter here: live
|
|
1866
|
+
# implementations may make an account/provider request from those.
|
|
1867
|
+
try:
|
|
1868
|
+
broker = getattr(self._owner, "broker", None)
|
|
1869
|
+
mark_price = getattr(broker, "get_cached_mark_price", None)
|
|
1870
|
+
if callable(mark_price):
|
|
1871
|
+
value = mark_price(data)
|
|
1872
|
+
if value is not None:
|
|
1873
|
+
return float(value)
|
|
1874
|
+
except (TypeError, ValueError):
|
|
1875
|
+
logger.debug("trade_logger:1831 ignored TypeError,ValueError")
|
|
1876
|
+
except Exception as exc:
|
|
1877
|
+
logger.debug("Failed to read cached broker mark price: %s", exc)
|
|
1878
|
+
return float(getattr(position, "price", 0.0) or 0.0)
|
|
1879
|
+
|
|
1880
|
+
def _cached_commission_info_for_data(self, data):
|
|
1881
|
+
"""Return configured commission info without calling a broker method."""
|
|
1882
|
+
try:
|
|
1883
|
+
broker = getattr(self._owner, "broker", None)
|
|
1884
|
+
comminfo = getattr(broker, "comminfo", None)
|
|
1885
|
+
if isinstance(comminfo, Mapping):
|
|
1886
|
+
name = getattr(data, "_name", None) or getattr(data, "_dataname", None)
|
|
1887
|
+
return comminfo.get(name, comminfo.get(None))
|
|
1888
|
+
except Exception as exc:
|
|
1889
|
+
logger.warning("Failed to read commission info: %s", exc)
|
|
1890
|
+
return None
|
|
1891
|
+
|
|
1892
|
+
def _commission_info_for_data(self, data):
|
|
1893
|
+
"""Return broker commission info for legacy file log enrichment."""
|
|
1894
|
+
try:
|
|
1895
|
+
broker = getattr(self._owner, "broker", None)
|
|
1896
|
+
getter = getattr(broker, "getcommissioninfo", None)
|
|
1897
|
+
if callable(getter):
|
|
1898
|
+
return getter(data)
|
|
1899
|
+
except Exception as exc:
|
|
1900
|
+
logger.debug("Failed to read commission info: %s", exc)
|
|
1901
|
+
return None
|
|
1902
|
+
|
|
1903
|
+
@staticmethod
|
|
1904
|
+
def _comminfo_param(comminfo, name, default=None):
|
|
1905
|
+
if comminfo is None:
|
|
1906
|
+
return default
|
|
1907
|
+
getter = getattr(comminfo, "get_param", None)
|
|
1908
|
+
if callable(getter):
|
|
1909
|
+
try:
|
|
1910
|
+
value = getter(name)
|
|
1911
|
+
if value is not None:
|
|
1912
|
+
return value
|
|
1913
|
+
except Exception:
|
|
1914
|
+
logger.warning("trade_logger:1870 suppressed Exception")
|
|
1915
|
+
params = getattr(comminfo, "p", None)
|
|
1916
|
+
if params is not None:
|
|
1917
|
+
try:
|
|
1918
|
+
value = getattr(params, name)
|
|
1919
|
+
if value is not None:
|
|
1920
|
+
return value
|
|
1921
|
+
except Exception:
|
|
1922
|
+
logger.warning("trade_logger:1878 suppressed Exception")
|
|
1923
|
+
return getattr(comminfo, name, default)
|
|
1924
|
+
|
|
1925
|
+
def _contract_metadata_for_data(self, data, data_name):
|
|
1926
|
+
"""Return configured contract metadata from broker/store if present."""
|
|
1927
|
+
metadata = {}
|
|
1928
|
+
try:
|
|
1929
|
+
broker = getattr(self._owner, "broker", None)
|
|
1930
|
+
resolver = getattr(broker, "_contract_rules_for", None)
|
|
1931
|
+
if callable(resolver):
|
|
1932
|
+
value = resolver(data_name)
|
|
1933
|
+
if isinstance(value, dict):
|
|
1934
|
+
metadata.update(value)
|
|
1935
|
+
|
|
1936
|
+
broker_metadata = getattr(broker, "_contract_metadata", None)
|
|
1937
|
+
if isinstance(broker_metadata, dict):
|
|
1938
|
+
value = broker_metadata.get(str(data_name))
|
|
1939
|
+
if isinstance(value, dict):
|
|
1940
|
+
metadata.update(value)
|
|
1941
|
+
|
|
1942
|
+
store = getattr(broker, "store", None) if broker is not None else None
|
|
1943
|
+
getter = getattr(store, "get_contract_metadata", None)
|
|
1944
|
+
if callable(getter):
|
|
1945
|
+
value = getter(data_name)
|
|
1946
|
+
if isinstance(value, dict):
|
|
1947
|
+
metadata.update(value)
|
|
1948
|
+
except Exception as exc:
|
|
1949
|
+
logger.debug("Failed to read contract metadata for %s: %s", data_name, exc)
|
|
1950
|
+
return metadata
|
|
1951
|
+
|
|
1952
|
+
def _position_contract_fields(self, data, position, data_name):
|
|
1953
|
+
"""Build valuation metadata for position logs and snapshots."""
|
|
1954
|
+
current_price = self._current_position_price(data, position)
|
|
1955
|
+
comminfo = self._commission_info_for_data(data)
|
|
1956
|
+
metadata = self._contract_metadata_for_data(data, data_name)
|
|
1957
|
+
|
|
1958
|
+
multiplier = self._positive_float(
|
|
1959
|
+
metadata.get("multiplier")
|
|
1960
|
+
or metadata.get("mult")
|
|
1961
|
+
or metadata.get("contract_multiplier")
|
|
1962
|
+
or metadata.get("contract_size")
|
|
1963
|
+
or self._comminfo_param(comminfo, "mult"),
|
|
1964
|
+
1.0,
|
|
1965
|
+
)
|
|
1966
|
+
|
|
1967
|
+
margin_rate = self._float_or_none(
|
|
1968
|
+
metadata.get("margin_rate") or metadata.get("margin") or metadata.get("margin_ratio")
|
|
1969
|
+
)
|
|
1970
|
+
if margin_rate is None:
|
|
1971
|
+
margin_param = self._float_or_none(self._comminfo_param(comminfo, "margin"))
|
|
1972
|
+
class_name = comminfo.__class__.__name__ if comminfo is not None else ""
|
|
1973
|
+
if margin_param is not None and (
|
|
1974
|
+
0.0 <= margin_param <= 1.0 or class_name.startswith("ComminfoFutures")
|
|
1975
|
+
):
|
|
1976
|
+
margin_rate = margin_param
|
|
1977
|
+
|
|
1978
|
+
commission_rate = self._float_or_none(
|
|
1979
|
+
metadata.get("commission_rate")
|
|
1980
|
+
or metadata.get("fee_rate")
|
|
1981
|
+
or metadata.get("open_fee_rate")
|
|
1982
|
+
or self._comminfo_param(comminfo, "commission")
|
|
1983
|
+
)
|
|
1984
|
+
margin_value = None
|
|
1985
|
+
if abs(float(position.size or 0.0)) > 0:
|
|
1986
|
+
margin_getter = getattr(comminfo, "get_margin", None)
|
|
1987
|
+
if callable(margin_getter):
|
|
1988
|
+
try:
|
|
1989
|
+
margin_value = abs(float(position.size)) * float(margin_getter(current_price))
|
|
1990
|
+
except Exception:
|
|
1991
|
+
logger.warning("trade_logger:1949 fallback on Exception")
|
|
1992
|
+
margin_value = None
|
|
1993
|
+
if margin_value is None and margin_rate is not None:
|
|
1994
|
+
margin_value = abs(float(position.size)) * current_price * multiplier * margin_rate
|
|
1995
|
+
|
|
1996
|
+
fields = {
|
|
1997
|
+
"current_price": current_price,
|
|
1998
|
+
"multiplier": multiplier,
|
|
1999
|
+
"contract_multiplier": multiplier,
|
|
2000
|
+
"contract_size": multiplier,
|
|
2001
|
+
}
|
|
2002
|
+
if margin_rate is not None:
|
|
2003
|
+
fields["margin"] = margin_rate
|
|
2004
|
+
fields["margin_rate"] = margin_rate
|
|
2005
|
+
if margin_value is not None:
|
|
2006
|
+
fields["margin_value"] = margin_value
|
|
2007
|
+
if commission_rate is not None:
|
|
2008
|
+
fields["commission_rate"] = commission_rate
|
|
2009
|
+
for key in (
|
|
2010
|
+
"commission_method",
|
|
2011
|
+
"commission_amount",
|
|
2012
|
+
"open_commission_rate",
|
|
2013
|
+
"open_fee_rate",
|
|
2014
|
+
"open_fee_amount",
|
|
2015
|
+
"long_margin_rate",
|
|
2016
|
+
"short_margin_rate",
|
|
2017
|
+
"exchange",
|
|
2018
|
+
"exchange_id",
|
|
2019
|
+
"asset_type",
|
|
2020
|
+
):
|
|
2021
|
+
value = metadata.get(key)
|
|
2022
|
+
if value not in (None, ""):
|
|
2023
|
+
fields[key] = value
|
|
2024
|
+
return fields
|
|
2025
|
+
|
|
2026
|
+
def _position_market_value(self, data, position):
|
|
2027
|
+
"""Best-effort mark-to-market notional exposure for position logs."""
|
|
2028
|
+
if position.size == 0:
|
|
2029
|
+
return 0.0
|
|
2030
|
+
|
|
2031
|
+
data_name = getattr(data, "_name", str(data))
|
|
2032
|
+
fields = self._position_contract_fields(data, position, data_name)
|
|
2033
|
+
current_price = fields.get("current_price", 0.0)
|
|
2034
|
+
multiplier = fields.get("multiplier", 1.0)
|
|
2035
|
+
return float(position.size) * float(current_price or 0.0) * float(multiplier or 1.0)
|
|
2036
|
+
|
|
2037
|
+
def _log_bar_snapshots(self):
|
|
2038
|
+
"""Log per-bar OHLC snapshots during regular backtests."""
|
|
2039
|
+
if not self._bar_logger:
|
|
2040
|
+
return
|
|
2041
|
+
|
|
2042
|
+
if not hasattr(self, "_owner") or self._owner is None:
|
|
2043
|
+
return
|
|
2044
|
+
|
|
2045
|
+
if not hasattr(self._owner, "datas") or not self._owner.datas:
|
|
2046
|
+
return
|
|
2047
|
+
|
|
2048
|
+
broker_value = self._get_broker_value()
|
|
2049
|
+
broker_cash = self._get_broker_cash()
|
|
2050
|
+
|
|
2051
|
+
for data in self._owner.datas:
|
|
2052
|
+
try:
|
|
2053
|
+
data_name = getattr(data, "_name", str(data))
|
|
2054
|
+
log_data = {
|
|
2055
|
+
"log_time": self._log_time_str(),
|
|
2056
|
+
"event_type": "bar",
|
|
2057
|
+
"strategy_name": self._get_strategy_name(),
|
|
2058
|
+
"data_name": data_name,
|
|
2059
|
+
"datetime": self._get_datetime_str(),
|
|
2060
|
+
"open": float(data.open[0]),
|
|
2061
|
+
"high": float(data.high[0]),
|
|
2062
|
+
"low": float(data.low[0]),
|
|
2063
|
+
"close": float(data.close[0]),
|
|
2064
|
+
"volume": float(data.volume[0]) if hasattr(data, "volume") else 0.0,
|
|
2065
|
+
"openinterest": (
|
|
2066
|
+
float(data.openinterest[0]) if hasattr(data, "openinterest") else 0.0
|
|
2067
|
+
),
|
|
2068
|
+
"broker_value": broker_value,
|
|
2069
|
+
"broker_cash": broker_cash,
|
|
2070
|
+
}
|
|
2071
|
+
self._emit_payload(
|
|
2072
|
+
self._bar_logger,
|
|
2073
|
+
log_data,
|
|
2074
|
+
text_line=(
|
|
2075
|
+
f"{log_data['log_time']} | BAR | datetime={log_data['datetime']} | "
|
|
2076
|
+
f"data_name={data_name} | open={log_data['open']:.4f} | "
|
|
2077
|
+
f"high={log_data['high']:.4f} | low={log_data['low']:.4f} | "
|
|
2078
|
+
f"close={log_data['close']:.4f} | volume={log_data['volume']:.2f} | "
|
|
2079
|
+
f"broker_value={broker_value:.2f} | broker_cash={broker_cash:.2f}"
|
|
2080
|
+
),
|
|
2081
|
+
)
|
|
2082
|
+
except Exception as e:
|
|
2083
|
+
logger.debug(
|
|
2084
|
+
"Failed to log bar snapshot for %s: %s", getattr(data, "_name", str(data)), e
|
|
2085
|
+
)
|
|
2086
|
+
continue
|
|
2087
|
+
|
|
2088
|
+
def next(self):
|
|
2089
|
+
"""Called on every bar - log positions and indicators."""
|
|
2090
|
+
self._ensure_loggers_initialized()
|
|
2091
|
+
# In a regular Cerebro run, an observer step is one real bar unless a
|
|
2092
|
+
# feed already dispatched that same bar to ``notify_bar_event``. In a
|
|
2093
|
+
# channel-only run Cerebro invokes ``_next`` for every event, including
|
|
2094
|
+
# ticks/order books/funding; channel bars are counted exclusively by
|
|
2095
|
+
# ``notify_bar_event`` so they are neither misclassified nor doubled.
|
|
2096
|
+
owner = getattr(self, "_owner", None)
|
|
2097
|
+
if owner is None or (
|
|
2098
|
+
bool(getattr(owner, "datas", ())) and not self._consume_dispatched_line_bar(owner)
|
|
2099
|
+
):
|
|
2100
|
+
self._record_report_event("bars")
|
|
2101
|
+
|
|
2102
|
+
# Set dummy line value (required for observer)
|
|
2103
|
+
self.lines.dummy[0] = 0
|
|
2104
|
+
|
|
2105
|
+
try:
|
|
2106
|
+
if self.p.log_bars:
|
|
2107
|
+
self._log_bar_snapshots()
|
|
2108
|
+
|
|
2109
|
+
if self.p.log_value:
|
|
2110
|
+
self._log_value()
|
|
2111
|
+
|
|
2112
|
+
if self.p.log_positions:
|
|
2113
|
+
self._log_positions()
|
|
2114
|
+
|
|
2115
|
+
if self.p.log_indicators:
|
|
2116
|
+
self._log_indicators()
|
|
2117
|
+
|
|
2118
|
+
if self.p.log_position_snapshot:
|
|
2119
|
+
self._save_position_snapshot()
|
|
2120
|
+
except Exception as e:
|
|
2121
|
+
self._log_internal_error("next", e)
|
|
2122
|
+
if self.p.log_to_console:
|
|
2123
|
+
import traceback
|
|
2124
|
+
|
|
2125
|
+
logger.error(f"[TradeLogger] Error in next(): {e}")
|
|
2126
|
+
traceback.print_exc()
|
|
2127
|
+
|
|
2128
|
+
def notify_order(self, order):
|
|
2129
|
+
"""Log order status changes."""
|
|
2130
|
+
self._ensure_loggers_initialized()
|
|
2131
|
+
|
|
2132
|
+
try:
|
|
2133
|
+
log_data = self._format_order(order)
|
|
2134
|
+
self._record_report_event("orders", log_data, record_kind="orders")
|
|
2135
|
+
except Exception as exc:
|
|
2136
|
+
logger.warning("trade_logger:2093 fallback on Exception")
|
|
2137
|
+
self._record_report_event("orders")
|
|
2138
|
+
self._log_internal_error("notify_order", exc)
|
|
2139
|
+
return
|
|
2140
|
+
|
|
2141
|
+
is_rejected = str(order.getstatusname()).lower() == "rejected"
|
|
2142
|
+
if is_rejected:
|
|
2143
|
+
self._record_report_event("errors")
|
|
2144
|
+
|
|
2145
|
+
# Reporting is independent from file logging. Preserve the existing
|
|
2146
|
+
# output behavior when order logging itself is disabled.
|
|
2147
|
+
if not self.p.log_orders:
|
|
2148
|
+
return
|
|
2149
|
+
|
|
2150
|
+
self._emit_payload(self._order_logger, log_data, text_line=self._format_order_text(order))
|
|
2151
|
+
|
|
2152
|
+
if is_rejected:
|
|
2153
|
+
self._log_event(
|
|
2154
|
+
"error",
|
|
2155
|
+
"order_rejected",
|
|
2156
|
+
level="ERROR",
|
|
2157
|
+
data_name=log_data.get("data_name"),
|
|
2158
|
+
order_ref=order.ref,
|
|
2159
|
+
error_code=log_data.get("error_code", ""),
|
|
2160
|
+
error_msg=log_data.get("error_msg", ""),
|
|
2161
|
+
status=log_data.get("status"),
|
|
2162
|
+
details={"order_type": log_data.get("order_type")},
|
|
2163
|
+
)
|
|
2164
|
+
|
|
2165
|
+
# MySQL logging
|
|
2166
|
+
if self.p.mysql_enabled and self._mysql_conn:
|
|
2167
|
+
self._insert_order_mysql(log_data)
|
|
2168
|
+
|
|
2169
|
+
def notify_trade(self, trade):
|
|
2170
|
+
"""Log trade information."""
|
|
2171
|
+
self._ensure_loggers_initialized()
|
|
2172
|
+
|
|
2173
|
+
try:
|
|
2174
|
+
log_data = self._format_trade(trade)
|
|
2175
|
+
self._record_report_event("trades", log_data, record_kind="trades")
|
|
2176
|
+
except Exception as exc:
|
|
2177
|
+
logger.warning("trade_logger:2133 fallback on Exception")
|
|
2178
|
+
self._record_report_event("trades")
|
|
2179
|
+
self._log_internal_error("notify_trade", exc)
|
|
2180
|
+
return
|
|
2181
|
+
|
|
2182
|
+
if not self.p.log_trades:
|
|
2183
|
+
return
|
|
2184
|
+
|
|
2185
|
+
self._emit_payload(self._trade_logger, log_data, text_line=self._format_trade_text(trade))
|
|
2186
|
+
|
|
2187
|
+
# MySQL logging
|
|
2188
|
+
if self.p.mysql_enabled and self._mysql_conn:
|
|
2189
|
+
self._insert_trade_mysql(log_data)
|
|
2190
|
+
|
|
2191
|
+
def log_signal(self, action, size, price, data_name=None, reason=None):
|
|
2192
|
+
"""Log a trading signal.
|
|
2193
|
+
|
|
2194
|
+
Args:
|
|
2195
|
+
action (str): 'buy' or 'sell'
|
|
2196
|
+
size (float): Order size
|
|
2197
|
+
price (float): Signal price
|
|
2198
|
+
data_name (str, optional): Data feed name
|
|
2199
|
+
reason (str, optional): Signal reason/description
|
|
2200
|
+
"""
|
|
2201
|
+
self._ensure_loggers_initialized()
|
|
2202
|
+
|
|
2203
|
+
owner_data_name = getattr(getattr(self._owner, "data", None), "_name", None)
|
|
2204
|
+
if owner_data_name is None:
|
|
2205
|
+
position_datas = self._iter_position_datas()
|
|
2206
|
+
if position_datas:
|
|
2207
|
+
owner_data_name = getattr(position_datas[0], "_name", None)
|
|
2208
|
+
|
|
2209
|
+
log_data = {
|
|
2210
|
+
"log_time": self._log_time_str(),
|
|
2211
|
+
"datetime": self._get_datetime_str(),
|
|
2212
|
+
"action": action,
|
|
2213
|
+
"size": size,
|
|
2214
|
+
"price": price,
|
|
2215
|
+
"data_name": data_name or owner_data_name,
|
|
2216
|
+
"reason": reason or "",
|
|
2217
|
+
"strategy_name": self._get_strategy_name(),
|
|
2218
|
+
}
|
|
2219
|
+
self._record_report_event("signals", log_data)
|
|
2220
|
+
|
|
2221
|
+
if not self.p.log_signals:
|
|
2222
|
+
return
|
|
2223
|
+
|
|
2224
|
+
self._emit_payload(
|
|
2225
|
+
self._signal_logger,
|
|
2226
|
+
log_data,
|
|
2227
|
+
text_line=(
|
|
2228
|
+
f"{log_data['log_time']} | {action.upper()} | datetime={log_data['datetime']} | "
|
|
2229
|
+
f"data_name={log_data['data_name'] or ''} | size={size} | "
|
|
2230
|
+
f"price={price} | reason={reason or ''}"
|
|
2231
|
+
),
|
|
2232
|
+
)
|
|
2233
|
+
|
|
2234
|
+
# MySQL logging
|
|
2235
|
+
if self.p.mysql_enabled and self._mysql_conn:
|
|
2236
|
+
self._insert_signal_mysql(log_data)
|
|
2237
|
+
|
|
2238
|
+
def notify_tick_event(self, tick):
|
|
2239
|
+
"""Log a tick event.
|
|
2240
|
+
|
|
2241
|
+
Called by the strategy's _notify_tick_to_observers when a new tick arrives.
|
|
2242
|
+
|
|
2243
|
+
Args:
|
|
2244
|
+
tick: Tick data object with attributes like symbol, price, volume, etc.
|
|
2245
|
+
"""
|
|
2246
|
+
self._ensure_loggers_initialized()
|
|
2247
|
+
self._record_report_event("ticks")
|
|
2248
|
+
|
|
2249
|
+
if not self.p.log_ticks or not self._tick_logger:
|
|
2250
|
+
return
|
|
2251
|
+
|
|
2252
|
+
try:
|
|
2253
|
+
# Extract tick fields — support both dict-like and attribute-based objects
|
|
2254
|
+
if hasattr(tick, "to_dict") and callable(tick.to_dict):
|
|
2255
|
+
tick_dict = tick.to_dict()
|
|
2256
|
+
elif isinstance(tick, dict):
|
|
2257
|
+
tick_dict = dict(tick)
|
|
2258
|
+
else:
|
|
2259
|
+
tick_dict = {}
|
|
2260
|
+
for attr in (
|
|
2261
|
+
"symbol",
|
|
2262
|
+
"price",
|
|
2263
|
+
"volume",
|
|
2264
|
+
"timestamp",
|
|
2265
|
+
"datetime",
|
|
2266
|
+
"bid_price",
|
|
2267
|
+
"ask_price",
|
|
2268
|
+
"bid_volume",
|
|
2269
|
+
"ask_volume",
|
|
2270
|
+
"openinterest",
|
|
2271
|
+
"turnover",
|
|
2272
|
+
"trade_id",
|
|
2273
|
+
"exchange",
|
|
2274
|
+
"exchange_id",
|
|
2275
|
+
"instrument_id",
|
|
2276
|
+
"trading_day",
|
|
2277
|
+
"update_time",
|
|
2278
|
+
"update_millisec",
|
|
2279
|
+
"asset_type",
|
|
2280
|
+
"local_time",
|
|
2281
|
+
):
|
|
2282
|
+
val = getattr(tick, attr, None)
|
|
2283
|
+
if val is not None:
|
|
2284
|
+
tick_dict[attr] = val
|
|
2285
|
+
|
|
2286
|
+
tick_dict = self._normalize_event_time_fields(tick_dict)
|
|
2287
|
+
log_data = {
|
|
2288
|
+
"log_time": self._log_time_str(),
|
|
2289
|
+
"event_type": "tick",
|
|
2290
|
+
"strategy_name": self._get_strategy_name(),
|
|
2291
|
+
**tick_dict,
|
|
2292
|
+
}
|
|
2293
|
+
self._emit_payload(
|
|
2294
|
+
self._tick_logger,
|
|
2295
|
+
log_data,
|
|
2296
|
+
text_line=(
|
|
2297
|
+
f"{log_data['log_time']} | TICK | "
|
|
2298
|
+
f"symbol={tick_dict.get('symbol', '')} | "
|
|
2299
|
+
f"price={tick_dict.get('price', '')} | "
|
|
2300
|
+
f"volume={tick_dict.get('volume', '')} | "
|
|
2301
|
+
f"bid={tick_dict.get('bid_price', '')} | "
|
|
2302
|
+
f"ask={tick_dict.get('ask_price', '')}"
|
|
2303
|
+
),
|
|
2304
|
+
)
|
|
2305
|
+
except Exception as e:
|
|
2306
|
+
logger.warning("trade_logger:2261 fallback on Exception")
|
|
2307
|
+
self._log_internal_error("notify_tick_event", e)
|
|
2308
|
+
|
|
2309
|
+
def notify_bar_event(self, bar):
|
|
2310
|
+
"""Log a bar event.
|
|
2311
|
+
|
|
2312
|
+
Called by the strategy's _notify_bar_to_observers when a new bar is synthesized.
|
|
2313
|
+
|
|
2314
|
+
Args:
|
|
2315
|
+
bar: Bar data object with attributes like symbol, open, high, low, close, volume.
|
|
2316
|
+
"""
|
|
2317
|
+
self._ensure_loggers_initialized()
|
|
2318
|
+
self._record_report_event("bars")
|
|
2319
|
+
owner = getattr(self, "_owner", None)
|
|
2320
|
+
# Feed-origin completed bars are also delivered into LineSeries for a
|
|
2321
|
+
# subsequent standard observer step. Remember only those line-backed
|
|
2322
|
+
# bars; incomplete diagnostic bars have no matching ``next`` call.
|
|
2323
|
+
if (
|
|
2324
|
+
owner is not None
|
|
2325
|
+
and bool(getattr(owner, "datas", ()))
|
|
2326
|
+
and getattr(bar, "complete", True) is not False
|
|
2327
|
+
):
|
|
2328
|
+
identity = self._report_bar_event_identity(bar)
|
|
2329
|
+
self._remember_dispatched_line_bar(identity, owner)
|
|
2330
|
+
|
|
2331
|
+
if not self.p.log_bars or not self._bar_logger:
|
|
2332
|
+
return
|
|
2333
|
+
|
|
2334
|
+
try:
|
|
2335
|
+
if hasattr(bar, "to_dict") and callable(bar.to_dict):
|
|
2336
|
+
bar_dict = bar.to_dict()
|
|
2337
|
+
elif isinstance(bar, dict):
|
|
2338
|
+
bar_dict = dict(bar)
|
|
2339
|
+
else:
|
|
2340
|
+
bar_dict = {}
|
|
2341
|
+
for attr in (
|
|
2342
|
+
"symbol",
|
|
2343
|
+
"open",
|
|
2344
|
+
"high",
|
|
2345
|
+
"low",
|
|
2346
|
+
"close",
|
|
2347
|
+
"volume",
|
|
2348
|
+
"timestamp",
|
|
2349
|
+
"datetime",
|
|
2350
|
+
"interval",
|
|
2351
|
+
"period",
|
|
2352
|
+
"exchange",
|
|
2353
|
+
"asset_type",
|
|
2354
|
+
"turnover",
|
|
2355
|
+
"openinterest",
|
|
2356
|
+
"trading_day",
|
|
2357
|
+
):
|
|
2358
|
+
val = getattr(bar, attr, None)
|
|
2359
|
+
if val is not None:
|
|
2360
|
+
bar_dict[attr] = val
|
|
2361
|
+
|
|
2362
|
+
bar_dict = self._normalize_event_time_fields(bar_dict)
|
|
2363
|
+
broker_value = self._get_broker_value()
|
|
2364
|
+
broker_cash = self._get_broker_cash()
|
|
2365
|
+
log_data = {
|
|
2366
|
+
"log_time": self._log_time_str(),
|
|
2367
|
+
"event_type": "bar",
|
|
2368
|
+
"strategy_name": self._get_strategy_name(),
|
|
2369
|
+
"broker_value": broker_value,
|
|
2370
|
+
"broker_cash": broker_cash,
|
|
2371
|
+
**bar_dict,
|
|
2372
|
+
}
|
|
2373
|
+
self._emit_payload(
|
|
2374
|
+
self._bar_logger,
|
|
2375
|
+
log_data,
|
|
2376
|
+
text_line=(
|
|
2377
|
+
f"{log_data['log_time']} | BAR | "
|
|
2378
|
+
f"symbol={bar_dict.get('symbol', '')} | "
|
|
2379
|
+
f"O={bar_dict.get('open', '')} H={bar_dict.get('high', '')} "
|
|
2380
|
+
f"L={bar_dict.get('low', '')} C={bar_dict.get('close', '')} | "
|
|
2381
|
+
f"vol={bar_dict.get('volume', '')} | "
|
|
2382
|
+
f"broker_value={broker_value:.2f} | broker_cash={broker_cash:.2f}"
|
|
2383
|
+
),
|
|
2384
|
+
)
|
|
2385
|
+
except Exception as e:
|
|
2386
|
+
logger.warning("trade_logger:2340 fallback on Exception")
|
|
2387
|
+
self._log_internal_error("notify_bar_event", e)
|
|
2388
|
+
|
|
2389
|
+
def notify_store_event(self, msg, *args, **kwargs):
|
|
2390
|
+
"""Log a structured runtime event forwarded from a store."""
|
|
2391
|
+
self._ensure_loggers_initialized()
|
|
2392
|
+
self._record_report_event("store")
|
|
2393
|
+
|
|
2394
|
+
event = kwargs.get("event")
|
|
2395
|
+
if not isinstance(event, dict):
|
|
2396
|
+
event = {
|
|
2397
|
+
"event_type": str(msg),
|
|
2398
|
+
"level": "INFO",
|
|
2399
|
+
"details": {"args": args, "kwargs": kwargs},
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
event_type = str(event.get("event_type") or msg or "runtime_event")
|
|
2403
|
+
level = str(event.get("level") or "INFO").upper()
|
|
2404
|
+
details = dict(event.get("details") or {})
|
|
2405
|
+
data_name = details.get("data_name")
|
|
2406
|
+
|
|
2407
|
+
category = "system"
|
|
2408
|
+
if level in {"ERROR", "CRITICAL"} or event.get("error_code") or event.get("error_msg"):
|
|
2409
|
+
category = "error"
|
|
2410
|
+
self._record_report_event("errors")
|
|
2411
|
+
elif event_type.startswith(("order_", "duplicate_", "batch_cancel_")):
|
|
2412
|
+
category = "monitor"
|
|
2413
|
+
|
|
2414
|
+
self._log_event(
|
|
2415
|
+
category,
|
|
2416
|
+
event_type,
|
|
2417
|
+
level=level,
|
|
2418
|
+
event_time=event.get("timestamp"),
|
|
2419
|
+
data_name=data_name,
|
|
2420
|
+
order_ref=event.get("order_ref") or details.get("order_ref"),
|
|
2421
|
+
error_code=event.get("error_code", ""),
|
|
2422
|
+
error_msg=event.get("error_msg", ""),
|
|
2423
|
+
account_id_masked=event.get("account_id_masked", ""),
|
|
2424
|
+
provider=event.get("provider") or self._store_provider(),
|
|
2425
|
+
session_id=event.get("session_id") or self._session_id(),
|
|
2426
|
+
status=event.get("status", ""),
|
|
2427
|
+
details=details,
|
|
2428
|
+
)
|
|
2429
|
+
|
|
2430
|
+
if event_type in {"order_submit_request", "order_reject_local", "order_reject_remote"}:
|
|
2431
|
+
self._track_request_monitoring("submit", details)
|
|
2432
|
+
elif event_type == "order_cancel_request":
|
|
2433
|
+
self._track_request_monitoring("cancel", details)
|
|
2434
|
+
|
|
2435
|
+
def notify_data_event(self, data, status, *args, **kwargs):
|
|
2436
|
+
"""Log data-feed runtime status forwarded from Cerebro."""
|
|
2437
|
+
self._ensure_loggers_initialized()
|
|
2438
|
+
self._record_report_event("data")
|
|
2439
|
+
|
|
2440
|
+
data_name = getattr(data, "_name", None) or getattr(data, "_dataname", None) or repr(data)
|
|
2441
|
+
status_names = getattr(data, "_NOTIFNAMES", ())
|
|
2442
|
+
if isinstance(status, int) and 0 <= status < len(status_names):
|
|
2443
|
+
status_name = status_names[status]
|
|
2444
|
+
else:
|
|
2445
|
+
status_name = str(status)
|
|
2446
|
+
|
|
2447
|
+
level = "INFO"
|
|
2448
|
+
if status_name in {"DISCONNECTED", "CONNBROKEN"}:
|
|
2449
|
+
level = "ERROR"
|
|
2450
|
+
self._record_report_event("errors")
|
|
2451
|
+
elif status_name == "DELAYED":
|
|
2452
|
+
level = "WARNING"
|
|
2453
|
+
|
|
2454
|
+
self._log_event(
|
|
2455
|
+
"system" if level == "INFO" else "error",
|
|
2456
|
+
"data_status",
|
|
2457
|
+
level=level,
|
|
2458
|
+
data_name=data_name,
|
|
2459
|
+
status=status_name,
|
|
2460
|
+
details={"args": args, "kwargs": kwargs},
|
|
2461
|
+
)
|
|
2462
|
+
|
|
2463
|
+
def _log_value(self):
|
|
2464
|
+
"""Log portfolio value and cash on every bar."""
|
|
2465
|
+
if not self._value_logger:
|
|
2466
|
+
return
|
|
2467
|
+
|
|
2468
|
+
if not hasattr(self, "_owner") or self._owner is None:
|
|
2469
|
+
return
|
|
2470
|
+
|
|
2471
|
+
broker_value = self._get_broker_value()
|
|
2472
|
+
broker_cash = self._get_broker_cash()
|
|
2473
|
+
|
|
2474
|
+
log_data = {
|
|
2475
|
+
"log_time": self._log_time_str(),
|
|
2476
|
+
"datetime": self._get_datetime_str(),
|
|
2477
|
+
"strategy_name": self._get_strategy_name(),
|
|
2478
|
+
"broker_value": broker_value,
|
|
2479
|
+
"broker_cash": broker_cash,
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
self._emit_payload(
|
|
2483
|
+
self._value_logger,
|
|
2484
|
+
log_data,
|
|
2485
|
+
text_line=(
|
|
2486
|
+
f"{log_data['log_time']} | "
|
|
2487
|
+
f"datetime={log_data['datetime']} | "
|
|
2488
|
+
f"value={broker_value:.2f} | cash={broker_cash:.2f}"
|
|
2489
|
+
),
|
|
2490
|
+
)
|
|
2491
|
+
|
|
2492
|
+
def _log_positions(self):
|
|
2493
|
+
"""Log position information for all data feeds."""
|
|
2494
|
+
if not self._position_logger and not (self.p.mysql_enabled and self._mysql_conn):
|
|
2495
|
+
return
|
|
2496
|
+
|
|
2497
|
+
if not hasattr(self, "_owner") or self._owner is None:
|
|
2498
|
+
return
|
|
2499
|
+
|
|
2500
|
+
position_datas = self._iter_position_datas()
|
|
2501
|
+
if not position_datas:
|
|
2502
|
+
return
|
|
2503
|
+
|
|
2504
|
+
broker_value = self._get_broker_value()
|
|
2505
|
+
broker_cash = self._get_broker_cash()
|
|
2506
|
+
|
|
2507
|
+
for data in position_datas:
|
|
2508
|
+
position = self._owner.getposition(data)
|
|
2509
|
+
data_name = getattr(data, "_name", str(data))
|
|
2510
|
+
contract_fields = self._position_contract_fields(data, position, data_name)
|
|
2511
|
+
market_value = (
|
|
2512
|
+
float(position.size)
|
|
2513
|
+
* float(contract_fields.get("current_price") or 0.0)
|
|
2514
|
+
* float(contract_fields.get("multiplier") or 1.0)
|
|
2515
|
+
)
|
|
2516
|
+
|
|
2517
|
+
log_data = {
|
|
2518
|
+
"log_time": self._log_time_str(),
|
|
2519
|
+
"datetime": self._get_datetime_str(),
|
|
2520
|
+
"data_name": data_name,
|
|
2521
|
+
"size": position.size,
|
|
2522
|
+
"price": position.price,
|
|
2523
|
+
"value": market_value,
|
|
2524
|
+
**contract_fields,
|
|
2525
|
+
"broker_value": broker_value,
|
|
2526
|
+
"broker_cash": broker_cash,
|
|
2527
|
+
"strategy_name": self._get_strategy_name(),
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
# File logging
|
|
2531
|
+
if self._position_logger:
|
|
2532
|
+
self._emit_payload(
|
|
2533
|
+
self._position_logger,
|
|
2534
|
+
log_data,
|
|
2535
|
+
text_line=(
|
|
2536
|
+
f"{log_data['log_time']} | POSITION | datetime={log_data['datetime']} | "
|
|
2537
|
+
f"data_name={data_name} | size={position.size} | "
|
|
2538
|
+
f"price={position.price:.4f} | "
|
|
2539
|
+
f"value={log_data['value']:.2f} | "
|
|
2540
|
+
f"broker_value={broker_value:.2f} | broker_cash={broker_cash:.2f}"
|
|
2541
|
+
),
|
|
2542
|
+
)
|
|
2543
|
+
|
|
2544
|
+
# MySQL logging
|
|
2545
|
+
if self.p.mysql_enabled and self._mysql_conn:
|
|
2546
|
+
self._insert_position_mysql(log_data)
|
|
2547
|
+
|
|
2548
|
+
def _log_indicators(self):
|
|
2549
|
+
"""Log all indicator values from the strategy."""
|
|
2550
|
+
if not self._indicator_logger and not (self.p.mysql_enabled and self._mysql_conn):
|
|
2551
|
+
return
|
|
2552
|
+
|
|
2553
|
+
indicators_data = self._collect_indicators()
|
|
2554
|
+
|
|
2555
|
+
if not indicators_data:
|
|
2556
|
+
return
|
|
2557
|
+
|
|
2558
|
+
log_data = {
|
|
2559
|
+
"log_time": self._log_time_str(),
|
|
2560
|
+
"datetime": self._get_datetime_str(),
|
|
2561
|
+
"strategy_name": self._get_strategy_name(),
|
|
2562
|
+
**indicators_data,
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
# File logging
|
|
2566
|
+
if self._indicator_logger:
|
|
2567
|
+
indicator_str = " | ".join(
|
|
2568
|
+
[f"{k}={v:.4f}" for k, v in indicators_data.items() if isinstance(v, (int, float))]
|
|
2569
|
+
)
|
|
2570
|
+
self._emit_payload(
|
|
2571
|
+
self._indicator_logger,
|
|
2572
|
+
log_data,
|
|
2573
|
+
text_line=(
|
|
2574
|
+
f"{log_data['log_time']} | INDICATOR | datetime={log_data['datetime']} | "
|
|
2575
|
+
f"{indicator_str}"
|
|
2576
|
+
),
|
|
2577
|
+
)
|
|
2578
|
+
|
|
2579
|
+
# MySQL logging - insert each indicator separately
|
|
2580
|
+
if self.p.mysql_enabled and self._mysql_conn:
|
|
2581
|
+
for name, value in indicators_data.items():
|
|
2582
|
+
if isinstance(value, (int, float)):
|
|
2583
|
+
self._insert_indicator_mysql(name, value)
|
|
2584
|
+
|
|
2585
|
+
def _collect_indicators(self):
|
|
2586
|
+
"""Collect all indicator values from the strategy.
|
|
2587
|
+
|
|
2588
|
+
Returns:
|
|
2589
|
+
dict: Dictionary of indicator names and their current values.
|
|
2590
|
+
"""
|
|
2591
|
+
indicators: dict = {}
|
|
2592
|
+
|
|
2593
|
+
try:
|
|
2594
|
+
# Get all indicators from the strategy
|
|
2595
|
+
if hasattr(self._owner, "_lineiterators"):
|
|
2596
|
+
for item in self._owner._lineiterators.get(self._owner.IndType, []):
|
|
2597
|
+
self._extract_indicator_values(item, indicators)
|
|
2598
|
+
|
|
2599
|
+
# Also check for indicators stored as attributes
|
|
2600
|
+
for attr_name in dir(self._owner):
|
|
2601
|
+
if attr_name.startswith("_"):
|
|
2602
|
+
continue
|
|
2603
|
+
try:
|
|
2604
|
+
attr = getattr(self._owner, attr_name)
|
|
2605
|
+
if hasattr(attr, "lines") and hasattr(attr, "__len__"):
|
|
2606
|
+
self._extract_indicator_values(attr, indicators, attr_name)
|
|
2607
|
+
except Exception as e:
|
|
2608
|
+
logger.debug("Failed to read indicator attr %s: %s", attr_name, e)
|
|
2609
|
+
continue
|
|
2610
|
+
|
|
2611
|
+
except Exception as e:
|
|
2612
|
+
logger.debug("Failed to collect indicator values: %s", e)
|
|
2613
|
+
|
|
2614
|
+
# Check for custom indicators method on the strategy
|
|
2615
|
+
if hasattr(self._owner, "get_custom_indicators") and callable(
|
|
2616
|
+
self._owner.get_custom_indicators
|
|
2617
|
+
):
|
|
2618
|
+
try:
|
|
2619
|
+
custom = self._owner.get_custom_indicators()
|
|
2620
|
+
if isinstance(custom, dict):
|
|
2621
|
+
indicators.update(custom)
|
|
2622
|
+
except Exception as e:
|
|
2623
|
+
logger.debug("Failed to get custom indicators: %s", e)
|
|
2624
|
+
|
|
2625
|
+
return indicators
|
|
2626
|
+
|
|
2627
|
+
def _extract_indicator_values(self, indicator, indicators_dict, prefix=""):
|
|
2628
|
+
"""Extract values from an indicator object.
|
|
2629
|
+
|
|
2630
|
+
Args:
|
|
2631
|
+
indicator: The indicator object
|
|
2632
|
+
indicators_dict: Dictionary to store values
|
|
2633
|
+
prefix: Optional prefix for indicator names
|
|
2634
|
+
"""
|
|
2635
|
+
try:
|
|
2636
|
+
# Get indicator class name
|
|
2637
|
+
ind_name = indicator.__class__.__name__
|
|
2638
|
+
if prefix:
|
|
2639
|
+
ind_name = f"{prefix}_{ind_name}"
|
|
2640
|
+
|
|
2641
|
+
# Get line values
|
|
2642
|
+
if hasattr(indicator, "lines"):
|
|
2643
|
+
for line_name in indicator.lines.getlinealiases():
|
|
2644
|
+
try:
|
|
2645
|
+
line = getattr(indicator.lines, line_name)
|
|
2646
|
+
if len(line) > 0:
|
|
2647
|
+
value = line[0]
|
|
2648
|
+
if value is not None and not (
|
|
2649
|
+
hasattr(value, "__float__") and float(value) != float(value)
|
|
2650
|
+
):
|
|
2651
|
+
full_name = (
|
|
2652
|
+
f"{ind_name}_{line_name}"
|
|
2653
|
+
if line_name != ind_name.lower()
|
|
2654
|
+
else ind_name
|
|
2655
|
+
)
|
|
2656
|
+
indicators_dict[full_name] = float(value)
|
|
2657
|
+
except Exception as e_line:
|
|
2658
|
+
logger.debug("Failed to read indicator line %s: %s", line_name, e_line)
|
|
2659
|
+
continue
|
|
2660
|
+
except Exception as e:
|
|
2661
|
+
logger.debug("Failed to extract indicator values: %s", e)
|
|
2662
|
+
|
|
2663
|
+
def _save_position_snapshot(self):
|
|
2664
|
+
"""Save current position snapshot to YAML file."""
|
|
2665
|
+
if not YAML_AVAILABLE:
|
|
2666
|
+
return
|
|
2667
|
+
|
|
2668
|
+
snapshot = {
|
|
2669
|
+
"datetime": self._get_datetime_str(),
|
|
2670
|
+
"strategy": self._get_strategy_name(),
|
|
2671
|
+
"positions": {},
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
for data in self._iter_position_datas():
|
|
2675
|
+
position = self._owner.getposition(data)
|
|
2676
|
+
data_name = getattr(data, "_name", str(data))
|
|
2677
|
+
contract_fields = self._position_contract_fields(data, position, data_name)
|
|
2678
|
+
|
|
2679
|
+
if position.size != 0:
|
|
2680
|
+
current_price = round(float(contract_fields.get("current_price") or 0.0), 4)
|
|
2681
|
+
market_value = (
|
|
2682
|
+
float(position.size)
|
|
2683
|
+
* float(contract_fields.get("current_price") or 0.0)
|
|
2684
|
+
* float(contract_fields.get("multiplier") or 1.0)
|
|
2685
|
+
)
|
|
2686
|
+
snapshot_fields = {
|
|
2687
|
+
key: round(value, 8) if isinstance(value, float) else value
|
|
2688
|
+
for key, value in contract_fields.items()
|
|
2689
|
+
if key != "current_price"
|
|
2690
|
+
}
|
|
2691
|
+
snapshot["positions"][data_name] = {
|
|
2692
|
+
"size": position.size,
|
|
2693
|
+
"price": round(position.price, 4),
|
|
2694
|
+
"value": round(market_value, 8),
|
|
2695
|
+
"current_price": current_price,
|
|
2696
|
+
**snapshot_fields,
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
snapshot_path = os.path.join(self.p.log_dir, self.p.snapshot_file)
|
|
2700
|
+
try:
|
|
2701
|
+
with open(snapshot_path, "w", encoding="utf-8") as f:
|
|
2702
|
+
yaml.dump(
|
|
2703
|
+
snapshot, f, allow_unicode=True, default_flow_style=False, sort_keys=False
|
|
2704
|
+
)
|
|
2705
|
+
except Exception as e:
|
|
2706
|
+
logger.debug("Failed to save position snapshot: %s", e)
|
|
2707
|
+
if self.p.log_to_console:
|
|
2708
|
+
logger.warning(f"[TradeLogger] Failed to save position snapshot: {e}")
|
|
2709
|
+
|
|
2710
|
+
def _format_order(self, order):
|
|
2711
|
+
"""Format order data for logging."""
|
|
2712
|
+
data = getattr(order, "data", None)
|
|
2713
|
+
return {
|
|
2714
|
+
"log_time": self._log_time_str(),
|
|
2715
|
+
"datetime": self._get_datetime_str(),
|
|
2716
|
+
"ref": order.ref,
|
|
2717
|
+
"order_type": "Buy" if order.isbuy() else "Sell",
|
|
2718
|
+
"status": order.getstatusname(),
|
|
2719
|
+
"size": order.size,
|
|
2720
|
+
"price": order.price,
|
|
2721
|
+
"executed_price": order.executed.price if order.executed.size else None,
|
|
2722
|
+
"executed_size": order.executed.size,
|
|
2723
|
+
"executed_value": order.executed.value,
|
|
2724
|
+
"commission": order.executed.comm,
|
|
2725
|
+
"data_name": getattr(data, "_name", None) if data is not None else None,
|
|
2726
|
+
"strategy_name": self._get_strategy_name(),
|
|
2727
|
+
"external_order_id": self._safe_order_info(order, "external_order_id"),
|
|
2728
|
+
"error_code": self._safe_order_info(order, "error_code", ""),
|
|
2729
|
+
"error_msg": self._safe_order_info(order, "error_msg", ""),
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
def _format_order_text(self, order):
|
|
2733
|
+
"""Format order data as text."""
|
|
2734
|
+
return (
|
|
2735
|
+
f"{self._log_time_str()} | "
|
|
2736
|
+
f"{'BUY' if order.isbuy() else 'SELL'} | "
|
|
2737
|
+
f"datetime={self._get_datetime_str()} | "
|
|
2738
|
+
f"ref={order.ref} | status={order.getstatusname()} | "
|
|
2739
|
+
f"size={order.size} | price={order.price} | "
|
|
2740
|
+
f"executed_price={order.executed.price if order.executed.size else None}"
|
|
2741
|
+
)
|
|
2742
|
+
|
|
2743
|
+
def _format_trade(self, trade):
|
|
2744
|
+
"""Format trade data for logging."""
|
|
2745
|
+
log_time = self._log_time_str()
|
|
2746
|
+
event_time, dtopen, dtclose = self._trade_time_fields(trade, log_time)
|
|
2747
|
+
return {
|
|
2748
|
+
"log_time": log_time,
|
|
2749
|
+
"datetime": event_time,
|
|
2750
|
+
"dtopen": dtopen,
|
|
2751
|
+
"dtclose": dtclose if trade.isclosed else None,
|
|
2752
|
+
"ref": trade.ref,
|
|
2753
|
+
"data_name": trade.data._name,
|
|
2754
|
+
"size": trade.size,
|
|
2755
|
+
"price": trade.price,
|
|
2756
|
+
"value": trade.value,
|
|
2757
|
+
"pnl": trade.pnl,
|
|
2758
|
+
"pnlcomm": trade.pnlcomm,
|
|
2759
|
+
"commission": trade.commission,
|
|
2760
|
+
"isclosed": trade.isclosed,
|
|
2761
|
+
"isopen": trade.isopen,
|
|
2762
|
+
"baropen": trade.baropen,
|
|
2763
|
+
"barclose": trade.barclose if trade.isclosed else None,
|
|
2764
|
+
"barlen": trade.barlen,
|
|
2765
|
+
"strategy_name": self._get_strategy_name(),
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
def _format_trade_text(self, trade):
|
|
2769
|
+
"""Format trade data as text."""
|
|
2770
|
+
status = "CLOSED" if trade.isclosed else ("OPEN" if trade.isopen else "UPDATE")
|
|
2771
|
+
log_time = self._log_time_str()
|
|
2772
|
+
event_time, dtopen, dtclose = self._trade_time_fields(trade, log_time)
|
|
2773
|
+
return (
|
|
2774
|
+
f"{log_time} | {status} | "
|
|
2775
|
+
f"datetime={event_time} | dtopen={dtopen or ''} | "
|
|
2776
|
+
f"dtclose={dtclose or ''} | ref={trade.ref} | data={trade.data._name} | "
|
|
2777
|
+
f"size={trade.size} | price={trade.price:.4f} | value={trade.value:.4f} | "
|
|
2778
|
+
f"commission={trade.commission:.4f} | pnl={trade.pnl:.2f} | pnlcomm={trade.pnlcomm:.2f}"
|
|
2779
|
+
)
|
|
2780
|
+
|
|
2781
|
+
def _insert_order_mysql(self, log_data):
|
|
2782
|
+
"""Insert order record into MySQL."""
|
|
2783
|
+
if not self._mysql_conn:
|
|
2784
|
+
return
|
|
2785
|
+
|
|
2786
|
+
try:
|
|
2787
|
+
cursor = self._mysql_conn.cursor()
|
|
2788
|
+
cursor.execute(
|
|
2789
|
+
"""
|
|
2790
|
+
INSERT INTO bt_orders (datetime, ref, order_type, status, size, price,
|
|
2791
|
+
executed_price, executed_size, executed_value, commission, data_name, strategy_name)
|
|
2792
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
2793
|
+
""",
|
|
2794
|
+
(
|
|
2795
|
+
log_data["datetime"],
|
|
2796
|
+
log_data["ref"],
|
|
2797
|
+
log_data["order_type"],
|
|
2798
|
+
log_data["status"],
|
|
2799
|
+
log_data["size"],
|
|
2800
|
+
log_data["price"],
|
|
2801
|
+
log_data["executed_price"],
|
|
2802
|
+
log_data["executed_size"],
|
|
2803
|
+
log_data["executed_value"],
|
|
2804
|
+
log_data["commission"],
|
|
2805
|
+
log_data["data_name"],
|
|
2806
|
+
log_data["strategy_name"],
|
|
2807
|
+
),
|
|
2808
|
+
)
|
|
2809
|
+
cursor.close()
|
|
2810
|
+
except Exception as e:
|
|
2811
|
+
logger.debug("MySQL insert order failed: %s", e)
|
|
2812
|
+
if self.p.log_to_console:
|
|
2813
|
+
logger.warning(f"[TradeLogger] MySQL insert order failed: {e}")
|
|
2814
|
+
|
|
2815
|
+
def _insert_trade_mysql(self, log_data):
|
|
2816
|
+
"""Insert trade record into MySQL."""
|
|
2817
|
+
if not self._mysql_conn:
|
|
2818
|
+
return
|
|
2819
|
+
|
|
2820
|
+
try:
|
|
2821
|
+
cursor = self._mysql_conn.cursor()
|
|
2822
|
+
cursor.execute(
|
|
2823
|
+
"""
|
|
2824
|
+
INSERT INTO bt_trades (datetime, ref, data_name, size, price, value,
|
|
2825
|
+
pnl, pnlcomm, commission, isclosed, isopen, baropen, barclose, barlen, strategy_name)
|
|
2826
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
2827
|
+
""",
|
|
2828
|
+
(
|
|
2829
|
+
log_data["datetime"],
|
|
2830
|
+
log_data["ref"],
|
|
2831
|
+
log_data["data_name"],
|
|
2832
|
+
log_data["size"],
|
|
2833
|
+
log_data["price"],
|
|
2834
|
+
log_data["value"],
|
|
2835
|
+
log_data["pnl"],
|
|
2836
|
+
log_data["pnlcomm"],
|
|
2837
|
+
log_data["commission"],
|
|
2838
|
+
log_data["isclosed"],
|
|
2839
|
+
log_data["isopen"],
|
|
2840
|
+
log_data["baropen"],
|
|
2841
|
+
log_data["barclose"],
|
|
2842
|
+
log_data["barlen"],
|
|
2843
|
+
log_data["strategy_name"],
|
|
2844
|
+
),
|
|
2845
|
+
)
|
|
2846
|
+
cursor.close()
|
|
2847
|
+
except Exception as e:
|
|
2848
|
+
logger.debug("MySQL insert trade failed: %s", e)
|
|
2849
|
+
if self.p.log_to_console:
|
|
2850
|
+
logger.warning(f"[TradeLogger] MySQL insert trade failed: {e}")
|
|
2851
|
+
|
|
2852
|
+
def _insert_position_mysql(self, log_data):
|
|
2853
|
+
"""Insert position record into MySQL."""
|
|
2854
|
+
if not self._mysql_conn:
|
|
2855
|
+
return
|
|
2856
|
+
|
|
2857
|
+
try:
|
|
2858
|
+
cursor = self._mysql_conn.cursor()
|
|
2859
|
+
cursor.execute(
|
|
2860
|
+
"""
|
|
2861
|
+
INSERT INTO bt_positions (datetime, data_name, size, price, value, strategy_name)
|
|
2862
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
2863
|
+
""",
|
|
2864
|
+
(
|
|
2865
|
+
log_data["datetime"],
|
|
2866
|
+
log_data["data_name"],
|
|
2867
|
+
log_data["size"],
|
|
2868
|
+
log_data["price"],
|
|
2869
|
+
log_data["value"],
|
|
2870
|
+
log_data["strategy_name"],
|
|
2871
|
+
),
|
|
2872
|
+
)
|
|
2873
|
+
cursor.close()
|
|
2874
|
+
except Exception as e:
|
|
2875
|
+
logger.debug("MySQL insert position failed: %s", e)
|
|
2876
|
+
if self.p.log_to_console:
|
|
2877
|
+
logger.warning(f"[TradeLogger] MySQL insert position failed: {e}")
|
|
2878
|
+
|
|
2879
|
+
def _insert_indicator_mysql(self, indicator_name, indicator_value):
|
|
2880
|
+
"""Insert indicator record into MySQL."""
|
|
2881
|
+
if not self._mysql_conn:
|
|
2882
|
+
return
|
|
2883
|
+
|
|
2884
|
+
try:
|
|
2885
|
+
cursor = self._mysql_conn.cursor()
|
|
2886
|
+
cursor.execute(
|
|
2887
|
+
"""
|
|
2888
|
+
INSERT INTO bt_indicators (datetime, indicator_name, indicator_value, strategy_name)
|
|
2889
|
+
VALUES (%s, %s, %s, %s)
|
|
2890
|
+
""",
|
|
2891
|
+
(
|
|
2892
|
+
self._get_datetime_str(),
|
|
2893
|
+
indicator_name,
|
|
2894
|
+
indicator_value,
|
|
2895
|
+
self._get_strategy_name(),
|
|
2896
|
+
),
|
|
2897
|
+
)
|
|
2898
|
+
cursor.close()
|
|
2899
|
+
except Exception as e:
|
|
2900
|
+
logger.debug("MySQL insert indicator failed: %s", e)
|
|
2901
|
+
if self.p.log_to_console:
|
|
2902
|
+
logger.warning(f"[TradeLogger] MySQL insert indicator failed: {e}")
|
|
2903
|
+
|
|
2904
|
+
def _insert_signal_mysql(self, log_data):
|
|
2905
|
+
"""Insert signal record into MySQL."""
|
|
2906
|
+
if not self._mysql_conn:
|
|
2907
|
+
return
|
|
2908
|
+
|
|
2909
|
+
try:
|
|
2910
|
+
cursor = self._mysql_conn.cursor()
|
|
2911
|
+
cursor.execute(
|
|
2912
|
+
"""
|
|
2913
|
+
INSERT INTO bt_signals (datetime, action, size, price, data_name, reason, strategy_name)
|
|
2914
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
2915
|
+
""",
|
|
2916
|
+
(
|
|
2917
|
+
log_data["datetime"],
|
|
2918
|
+
log_data["action"],
|
|
2919
|
+
log_data["size"],
|
|
2920
|
+
log_data["price"],
|
|
2921
|
+
log_data["data_name"],
|
|
2922
|
+
log_data["reason"],
|
|
2923
|
+
log_data["strategy_name"],
|
|
2924
|
+
),
|
|
2925
|
+
)
|
|
2926
|
+
cursor.close()
|
|
2927
|
+
except Exception as e:
|
|
2928
|
+
logger.debug("MySQL insert signal failed: %s", e)
|
|
2929
|
+
if self.p.log_to_console:
|
|
2930
|
+
logger.warning(f"[TradeLogger] MySQL insert signal failed: {e}")
|
|
2931
|
+
|
|
2932
|
+
def stop(self):
|
|
2933
|
+
"""Called at the end of the backtest/live run."""
|
|
2934
|
+
# Strategy.stop() runs before Observer.stop() in both normal and
|
|
2935
|
+
# channel lifecycles, so any final update_report_context call is now
|
|
2936
|
+
# present. Legacy file sinks can fail independently of the generic
|
|
2937
|
+
# report, so finalization belongs in ``finally``.
|
|
2938
|
+
try:
|
|
2939
|
+
self._refresh_report_state()
|
|
2940
|
+
if self.p.log_monitoring:
|
|
2941
|
+
self._log_event(
|
|
2942
|
+
"monitor",
|
|
2943
|
+
"monitoring_summary",
|
|
2944
|
+
level="INFO",
|
|
2945
|
+
details=dict(self._monitoring),
|
|
2946
|
+
)
|
|
2947
|
+
|
|
2948
|
+
self._log_event(
|
|
2949
|
+
"system",
|
|
2950
|
+
"session_stopped",
|
|
2951
|
+
level="INFO",
|
|
2952
|
+
details={"observer": self.__class__.__name__},
|
|
2953
|
+
)
|
|
2954
|
+
|
|
2955
|
+
# Save final position snapshot
|
|
2956
|
+
if self.p.log_position_snapshot:
|
|
2957
|
+
self._save_position_snapshot()
|
|
2958
|
+
except Exception as exc:
|
|
2959
|
+
logger.warning("trade_logger:2912 fallback on Exception")
|
|
2960
|
+
self._log_internal_error("stop", exc)
|
|
2961
|
+
finally:
|
|
2962
|
+
# Close MySQL connection and always freeze the generic report.
|
|
2963
|
+
try:
|
|
2964
|
+
if self._mysql_conn:
|
|
2965
|
+
self._mysql_conn.close()
|
|
2966
|
+
except Exception as exc:
|
|
2967
|
+
logger.debug("Failed to close MySQL connection: %s", exc)
|
|
2968
|
+
finally:
|
|
2969
|
+
try:
|
|
2970
|
+
self._shutdown_file_loggers()
|
|
2971
|
+
finally:
|
|
2972
|
+
self._freeze_report()
|