back-trader-python 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (465) hide show
  1. back_trader_python-1.4.0.dist-info/METADATA +1491 -0
  2. back_trader_python-1.4.0.dist-info/RECORD +465 -0
  3. back_trader_python-1.4.0.dist-info/WHEEL +5 -0
  4. back_trader_python-1.4.0.dist-info/licenses/LICENSE +674 -0
  5. back_trader_python-1.4.0.dist-info/top_level.txt +1 -0
  6. backtrader/__init__.py +148 -0
  7. backtrader/_cerebro/__init__.py +5 -0
  8. backtrader/_cerebro/channel.py +382 -0
  9. backtrader/_cerebro/execution.py +377 -0
  10. backtrader/_cerebro/lifecycle.py +143 -0
  11. backtrader/_cerebro/notifications.py +150 -0
  12. backtrader/_cerebro/presentation.py +230 -0
  13. backtrader/_cerebro/registry.py +593 -0
  14. backtrader/_cerebro/runnext.py +551 -0
  15. backtrader/_cerebro/runonce.py +142 -0
  16. backtrader/analyzer.py +594 -0
  17. backtrader/analyzers/__init__.py +50 -0
  18. backtrader/analyzers/annualreturn.py +226 -0
  19. backtrader/analyzers/calmar.py +165 -0
  20. backtrader/analyzers/drawdown.py +287 -0
  21. backtrader/analyzers/leverage.py +112 -0
  22. backtrader/analyzers/logreturnsrolling.py +190 -0
  23. backtrader/analyzers/periodstats.py +153 -0
  24. backtrader/analyzers/positions.py +119 -0
  25. backtrader/analyzers/pyfolio.py +470 -0
  26. backtrader/analyzers/returns.py +192 -0
  27. backtrader/analyzers/sharpe.py +307 -0
  28. backtrader/analyzers/sharpe_ratio_stats.py +534 -0
  29. backtrader/analyzers/sqn.py +112 -0
  30. backtrader/analyzers/timereturn.py +192 -0
  31. backtrader/analyzers/total_value.py +75 -0
  32. backtrader/analyzers/tradeanalyzer.py +278 -0
  33. backtrader/analyzers/transactions.py +141 -0
  34. backtrader/analyzers/vwr.py +245 -0
  35. backtrader/bokeh/__init__.py +155 -0
  36. backtrader/bokeh/analyzers/__init__.py +13 -0
  37. backtrader/bokeh/analyzers/plot.py +192 -0
  38. backtrader/bokeh/analyzers/recorder.py +181 -0
  39. backtrader/bokeh/app.py +1094 -0
  40. backtrader/bokeh/live/__init__.py +11 -0
  41. backtrader/bokeh/live/client.py +352 -0
  42. backtrader/bokeh/live/datahandler.py +346 -0
  43. backtrader/bokeh/plot_adapter.py +200 -0
  44. backtrader/bokeh/schemes/__init__.py +14 -0
  45. backtrader/bokeh/schemes/blackly.py +76 -0
  46. backtrader/bokeh/schemes/scheme.py +150 -0
  47. backtrader/bokeh/schemes/tradimo.py +82 -0
  48. backtrader/bokeh/tab.py +125 -0
  49. backtrader/bokeh/tabs/__init__.py +30 -0
  50. backtrader/bokeh/tabs/analyzer.py +120 -0
  51. backtrader/bokeh/tabs/config.py +154 -0
  52. backtrader/bokeh/tabs/live.py +109 -0
  53. backtrader/bokeh/tabs/log.py +185 -0
  54. backtrader/bokeh/tabs/metadata.py +182 -0
  55. backtrader/bokeh/tabs/performance.py +359 -0
  56. backtrader/bokeh/tabs/source.py +70 -0
  57. backtrader/bokeh/utils/__init__.py +8 -0
  58. backtrader/bokeh/utils/helpers.py +167 -0
  59. backtrader/bokeh/webapp.py +164 -0
  60. backtrader/broker.py +478 -0
  61. backtrader/brokers/__init__.py +36 -0
  62. backtrader/brokers/bbroker.py +2576 -0
  63. backtrader/brokers/btapibroker.py +8227 -0
  64. backtrader/brokers/hft/__init__.py +89 -0
  65. backtrader/brokers/hft/binance_bbo.py +625 -0
  66. backtrader/brokers/hft/binance_bbo_compare.py +1398 -0
  67. backtrader/brokers/hft/examples.py +1228 -0
  68. backtrader/brokers/hft/exchange.py +380 -0
  69. backtrader/brokers/hft/latency.py +309 -0
  70. backtrader/brokers/hft/matching_core.py +572 -0
  71. backtrader/brokers/hft/queue.py +238 -0
  72. backtrader/brokers/hft/recorder.py +88 -0
  73. backtrader/brokers/hft/state.py +138 -0
  74. backtrader/brokers/impact_models.py +118 -0
  75. backtrader/brokers/mixbroker.py +895 -0
  76. backtrader/brokers/tickbroker.py +1991 -0
  77. backtrader/btrun/__init__.py +12 -0
  78. backtrader/btrun/btrun.py +1218 -0
  79. backtrader/cerebro.py +828 -0
  80. backtrader/channel.py +682 -0
  81. backtrader/channels/__init__.py +23 -0
  82. backtrader/channels/bridge.py +186 -0
  83. backtrader/channels/funding.py +248 -0
  84. backtrader/channels/live_queue.py +216 -0
  85. backtrader/channels/live_validator.py +294 -0
  86. backtrader/channels/orderbook.py +257 -0
  87. backtrader/channels/tick.py +202 -0
  88. backtrader/comminfo.py +665 -0
  89. backtrader/commissions/__init__.py +106 -0
  90. backtrader/commissions/ctpoption.py +993 -0
  91. backtrader/configs/account_config_example.yaml +8 -0
  92. backtrader/dataseries.py +379 -0
  93. backtrader/errors.py +106 -0
  94. backtrader/events.py +980 -0
  95. backtrader/feed.py +1523 -0
  96. backtrader/feeds/__init__.py +75 -0
  97. backtrader/feeds/barrier.py +2006 -0
  98. backtrader/feeds/blaze.py +118 -0
  99. backtrader/feeds/btapifeed.py +1538 -0
  100. backtrader/feeds/btcsv.py +203 -0
  101. backtrader/feeds/chainer.py +114 -0
  102. backtrader/feeds/cryptohftdata.py +164 -0
  103. backtrader/feeds/csvgeneric.py +1205 -0
  104. backtrader/feeds/ctpcohort.py +1051 -0
  105. backtrader/feeds/influxfeed.py +158 -0
  106. backtrader/feeds/livefeed.py +71 -0
  107. backtrader/feeds/mixed_channel.py +108 -0
  108. backtrader/feeds/mt4csv.py +42 -0
  109. backtrader/feeds/pandafeed.py +381 -0
  110. backtrader/feeds/quandl.py +256 -0
  111. backtrader/feeds/rollover.py +229 -0
  112. backtrader/feeds/sierrachart.py +30 -0
  113. backtrader/feeds/vchart.py +162 -0
  114. backtrader/feeds/vchartcsv.py +84 -0
  115. backtrader/feeds/vchartfile.py +153 -0
  116. backtrader/feeds/yahoo.py +399 -0
  117. backtrader/fillers.py +148 -0
  118. backtrader/filters/__init__.py +34 -0
  119. backtrader/filters/bsplitter.py +127 -0
  120. backtrader/filters/calendardays.py +121 -0
  121. backtrader/filters/datafiller.py +192 -0
  122. backtrader/filters/datafilter.py +74 -0
  123. backtrader/filters/daysteps.py +96 -0
  124. backtrader/filters/heikinashi.py +63 -0
  125. backtrader/filters/renko.py +164 -0
  126. backtrader/filters/session.py +289 -0
  127. backtrader/flt.py +80 -0
  128. backtrader/functions.py +960 -0
  129. backtrader/indicator.py +449 -0
  130. backtrader/indicators/__init__.py +148 -0
  131. backtrader/indicators/accdecoscillator.py +110 -0
  132. backtrader/indicators/aroon.py +300 -0
  133. backtrader/indicators/atr.py +315 -0
  134. backtrader/indicators/awesomeoscillator.py +122 -0
  135. backtrader/indicators/basicops.py +834 -0
  136. backtrader/indicators/bollinger.py +223 -0
  137. backtrader/indicators/cci.py +89 -0
  138. backtrader/indicators/channels_ext.py +83 -0
  139. backtrader/indicators/contrib/__init__.py +228 -0
  140. backtrader/indicators/contrib/absolutely_no_lag_lwma.py +28 -0
  141. backtrader/indicators/contrib/absolutely_no_lag_lwma_color.py +44 -0
  142. backtrader/indicators/contrib/accumulation_distribution_line.py +92 -0
  143. backtrader/indicators/contrib/adx_cross_hull_style_indicator.py +249 -0
  144. backtrader/indicators/contrib/adxdmi.py +34 -0
  145. backtrader/indicators/contrib/ai_acceleration_deceleration_oscillator.py +34 -0
  146. backtrader/indicators/contrib/altr_trend_signal_v22.py +85 -0
  147. backtrader/indicators/contrib/anchored_momentum_line.py +115 -0
  148. backtrader/indicators/contrib/any_range_cld_tail_indicator.py +82 -0
  149. backtrader/indicators/contrib/aroon_horn_sign_indicator.py +96 -0
  150. backtrader/indicators/contrib/aroon_oscillator_sign_alert.py +50 -0
  151. backtrader/indicators/contrib/arrows_curves_indicator.py +112 -0
  152. backtrader/indicators/contrib/as_ctrend_indicator.py +143 -0
  153. backtrader/indicators/contrib/asimmetric_stoch_nr_indicator.py +187 -0
  154. backtrader/indicators/contrib/atr_normalize_histogram.py +118 -0
  155. backtrader/indicators/contrib/average_change_candle.py +165 -0
  156. backtrader/indicators/contrib/bb_squeeze_indicator.py +60 -0
  157. backtrader/indicators/contrib/bezier_st_dev_indicator.py +135 -0
  158. backtrader/indicators/contrib/binary_wave_indicator.py +233 -0
  159. backtrader/indicators/contrib/blau_c_momentum_indicator.py +123 -0
  160. backtrader/indicators/contrib/blau_cmi_indicator.py +141 -0
  161. backtrader/indicators/contrib/blau_csi.py +76 -0
  162. backtrader/indicators/contrib/blau_ergodic.py +53 -0
  163. backtrader/indicators/contrib/blau_t_stoch_i.py +72 -0
  164. backtrader/indicators/contrib/blau_ts_stochastic.py +85 -0
  165. backtrader/indicators/contrib/blau_tvi.py +55 -0
  166. backtrader/indicators/contrib/brain_trend2_indicator.py +128 -0
  167. backtrader/indicators/contrib/brain_trend_signal_proxy.py +47 -0
  168. backtrader/indicators/contrib/brake_parb_indicator.py +85 -0
  169. backtrader/indicators/contrib/breakout_bars_trend_v2.py +121 -0
  170. backtrader/indicators/contrib/bsi_indicator.py +87 -0
  171. backtrader/indicators/contrib/bulls_bears_eyes.py +67 -0
  172. backtrader/indicators/contrib/bulls_power.py +56 -0
  173. backtrader/indicators/contrib/bw_wise_man1_signal.py +102 -0
  174. backtrader/indicators/contrib/bykov_trend_indicator.py +85 -0
  175. backtrader/indicators/contrib/candle_stop_color.py +46 -0
  176. backtrader/indicators/contrib/candles_x_smoothed_indicator.py +69 -0
  177. backtrader/indicators/contrib/candlesticks_bw.py +45 -0
  178. backtrader/indicators/contrib/caudate_x_period_candle_color.py +56 -0
  179. backtrader/indicators/contrib/cci_histogram_indicator.py +53 -0
  180. backtrader/indicators/contrib/cci_woodies_indicator.py +80 -0
  181. backtrader/indicators/contrib/center_of_gravity_candle_indicator.py +83 -0
  182. backtrader/indicators/contrib/center_of_gravity_indicator.py +70 -0
  183. backtrader/indicators/contrib/cg_oscillator.py +40 -0
  184. backtrader/indicators/contrib/close_line_cci.py +38 -0
  185. backtrader/indicators/contrib/close_price_fractals.py +47 -0
  186. backtrader/indicators/contrib/color3rd_gen_xma_indicator.py +122 -0
  187. backtrader/indicators/contrib/color_bb_candles_indicator.py +108 -0
  188. backtrader/indicators/contrib/color_coppock_indicator.py +157 -0
  189. backtrader/indicators/contrib/color_hma.py +71 -0
  190. backtrader/indicators/contrib/color_j_variation_indicator.py +53 -0
  191. backtrader/indicators/contrib/color_metro_de_marker_indicator.py +78 -0
  192. backtrader/indicators/contrib/color_metro_stochastic_indicator.py +93 -0
  193. backtrader/indicators/contrib/color_metro_wpr_indicator.py +85 -0
  194. backtrader/indicators/contrib/color_schaff_de_marker_trend_cycle.py +92 -0
  195. backtrader/indicators/contrib/color_schaff_trend_cycle_indicator.py +203 -0
  196. backtrader/indicators/contrib/color_step_xccx_indicator.py +193 -0
  197. backtrader/indicators/contrib/color_x2_ma.py +49 -0
  198. backtrader/indicators/contrib/color_x_derivative.py +63 -0
  199. backtrader/indicators/contrib/color_zerolag_de_marker.py +84 -0
  200. backtrader/indicators/contrib/corrected_average_indicator.py +127 -0
  201. backtrader/indicators/contrib/darvas_boxes_system.py +73 -0
  202. backtrader/indicators/contrib/dema_range_channel_color.py +42 -0
  203. backtrader/indicators/contrib/derivative_indicator.py +95 -0
  204. backtrader/indicators/contrib/digital_ft01_indicator.py +112 -0
  205. backtrader/indicators/contrib/digital_macd.py +200 -0
  206. backtrader/indicators/contrib/donchian_channels_system.py +45 -0
  207. backtrader/indicators/contrib/dots_indicator.py +93 -0
  208. backtrader/indicators/contrib/ef_distance_indicator.py +82 -0
  209. backtrader/indicators/contrib/ema_rsi_va.py +80 -0
  210. backtrader/indicators/contrib/envelopes_jp_alonso.py +32 -0
  211. backtrader/indicators/contrib/f2a_ao_indicator.py +120 -0
  212. backtrader/indicators/contrib/fatl_filter.py +179 -0
  213. backtrader/indicators/contrib/fibo_candles_indicator.py +78 -0
  214. backtrader/indicators/contrib/fine_tuning_ma.py +100 -0
  215. backtrader/indicators/contrib/fisher_org_v1.py +102 -0
  216. backtrader/indicators/contrib/fisher_org_v1_sign.py +118 -0
  217. backtrader/indicators/contrib/force_index_ema.py +96 -0
  218. backtrader/indicators/contrib/force_index_ema_2.py +27 -0
  219. backtrader/indicators/contrib/forecast_oscilator.py +145 -0
  220. backtrader/indicators/contrib/fractal_amambk.py +81 -0
  221. backtrader/indicators/contrib/frama_series.py +84 -0
  222. backtrader/indicators/contrib/frasm_av2_indicator.py +104 -0
  223. backtrader/indicators/contrib/go_indicator.py +93 -0
  224. backtrader/indicators/contrib/hlr_indicator.py +95 -0
  225. backtrader/indicators/contrib/hma.py +50 -0
  226. backtrader/indicators/contrib/i4_drfv2.py +34 -0
  227. backtrader/indicators/contrib/i4_drfv3.py +38 -0
  228. backtrader/indicators/contrib/i_anch_mom_indicator.py +72 -0
  229. backtrader/indicators/contrib/i_de_marker_sign_indicator.py +64 -0
  230. backtrader/indicators/contrib/i_gap_indicator.py +45 -0
  231. backtrader/indicators/contrib/i_stoch_komposter_indicator.py +77 -0
  232. backtrader/indicators/contrib/i_trend_indicator.py +125 -0
  233. backtrader/indicators/contrib/iamma_indicator.py +39 -0
  234. backtrader/indicators/contrib/indexed_moving_average.py +33 -0
  235. backtrader/indicators/contrib/instantaneous_trend_filter_indicator.py +51 -0
  236. backtrader/indicators/contrib/inverse_reaction_indicator.py +41 -0
  237. backtrader/indicators/contrib/irsi_sign_indicator.py +95 -0
  238. backtrader/indicators/contrib/iwpr_sign_indicator.py +59 -0
  239. backtrader/indicators/contrib/j_brain_trend1_sig_indicator.py +233 -0
  240. backtrader/indicators/contrib/j_tpo_proxy.py +32 -0
  241. backtrader/indicators/contrib/jma_slope_indicator.py +73 -0
  242. backtrader/indicators/contrib/kalman_filter_indicator.py +119 -0
  243. backtrader/indicators/contrib/kalman_filter_line.py +127 -0
  244. backtrader/indicators/contrib/kama_indicator.py +150 -0
  245. backtrader/indicators/contrib/karacatica_indicator.py +99 -0
  246. backtrader/indicators/contrib/kdj_indicator.py +59 -0
  247. backtrader/indicators/contrib/kwan_ccc_indicator.py +195 -0
  248. backtrader/indicators/contrib/kwan_nrp_indicator.py +113 -0
  249. backtrader/indicators/contrib/kwan_rdp_indicator.py +192 -0
  250. backtrader/indicators/contrib/laguerre_adx_indicator.py +85 -0
  251. backtrader/indicators/contrib/laguerre_filter_indicator.py +66 -0
  252. backtrader/indicators/contrib/laguerre_plus_di_proxy.py +57 -0
  253. backtrader/indicators/contrib/laguerre_roc_indicator.py +81 -0
  254. backtrader/indicators/contrib/le_man_signal_indicator.py +63 -0
  255. backtrader/indicators/contrib/linear_reg_slope_v2_indicator.py +136 -0
  256. backtrader/indicators/contrib/loco_indicator.py +88 -0
  257. backtrader/indicators/contrib/lrma_indicator.py +185 -0
  258. backtrader/indicators/contrib/lsma_angle_indicator.py +106 -0
  259. backtrader/indicators/contrib/ma_rounding_channel_indicator.py +149 -0
  260. backtrader/indicators/contrib/macd2_indicator.py +61 -0
  261. backtrader/indicators/contrib/macd_candle_indicator.py +80 -0
  262. backtrader/indicators/contrib/malr_indicator.py +77 -0
  263. backtrader/indicators/contrib/momentum_candle_sign_indicator.py +51 -0
  264. backtrader/indicators/contrib/moving_average_fn_indicator.py +139 -0
  265. backtrader/indicators/contrib/mt5_stochastic_close_close.py +57 -0
  266. backtrader/indicators/contrib/muv_nor_diff_cloud_indicator.py +107 -0
  267. backtrader/indicators/contrib/non_lag_dot_indicator.py +124 -0
  268. backtrader/indicators/contrib/nrtr_extr_indicator.py +95 -0
  269. backtrader/indicators/contrib/nrtr_indicator.py +95 -0
  270. backtrader/indicators/contrib/p_channel_system.py +40 -0
  271. backtrader/indicators/contrib/percent_envelope.py +37 -0
  272. backtrader/indicators/contrib/percentage_crossover_channel.py +47 -0
  273. backtrader/indicators/contrib/pivot_zig_zag_proxy.py +47 -0
  274. backtrader/indicators/contrib/price_channel_stop_indicator.py +104 -0
  275. backtrader/indicators/contrib/price_extreme_channel.py +35 -0
  276. backtrader/indicators/contrib/qqe_cloud_indicator.py +129 -0
  277. backtrader/indicators/contrib/ravi_indicator.py +40 -0
  278. backtrader/indicators/contrib/raw_close_close_stochastic.py +74 -0
  279. backtrader/indicators/contrib/rd_trend_trigger_indicator.py +51 -0
  280. backtrader/indicators/contrib/renko_level.py +85 -0
  281. backtrader/indicators/contrib/renko_line_break.py +91 -0
  282. backtrader/indicators/contrib/rftl_indicator.py +41 -0
  283. backtrader/indicators/contrib/rkd_indicator.py +53 -0
  284. backtrader/indicators/contrib/roc2_vg_indicator.py +68 -0
  285. backtrader/indicators/contrib/rsi_histogram_indicator.py +43 -0
  286. backtrader/indicators/contrib/rsi_slowdown.py +57 -0
  287. backtrader/indicators/contrib/rsioma_v2.py +41 -0
  288. backtrader/indicators/contrib/rvi_histogram_indicator.py +107 -0
  289. backtrader/indicators/contrib/safe_adx.py +89 -0
  290. backtrader/indicators/contrib/shared_strategy_indicators.py +1651 -0
  291. backtrader/indicators/contrib/sidus_indicator.py +105 -0
  292. backtrader/indicators/contrib/silver_trend_indicator.py +79 -0
  293. backtrader/indicators/contrib/sliding_range_color.py +56 -0
  294. backtrader/indicators/contrib/slow_stoch.py +42 -0
  295. backtrader/indicators/contrib/smoothed_adx_indicator.py +86 -0
  296. backtrader/indicators/contrib/smoothed_rsi.py +31 -0
  297. backtrader/indicators/contrib/spearman_rank_correlation_histogram.py +60 -0
  298. backtrader/indicators/contrib/stalin_indicator.py +152 -0
  299. backtrader/indicators/contrib/starter_laguerre_filter.py +62 -0
  300. backtrader/indicators/contrib/step_manrtr_indicator.py +137 -0
  301. backtrader/indicators/contrib/stochastic_histogram_indicator.py +143 -0
  302. backtrader/indicators/contrib/t3_alarm_indicator.py +125 -0
  303. backtrader/indicators/contrib/t3_average.py +76 -0
  304. backtrader/indicators/contrib/t3_indicator.py +40 -0
  305. backtrader/indicators/contrib/the20s_v020_signal.py +93 -0
  306. backtrader/indicators/contrib/three_candles_indicator.py +70 -0
  307. backtrader/indicators/contrib/three_line_break_indicator.py +64 -0
  308. backtrader/indicators/contrib/time_line.py +57 -0
  309. backtrader/indicators/contrib/trading_channel_index_proxy.py +48 -0
  310. backtrader/indicators/contrib/trend_arrows_indicator.py +109 -0
  311. backtrader/indicators/contrib/trend_continuation_indicator.py +127 -0
  312. backtrader/indicators/contrib/trend_intensity_index_proxy.py +51 -0
  313. backtrader/indicators/contrib/trend_manager_indicator.py +39 -0
  314. backtrader/indicators/contrib/tri_x_candle_indicator.py +51 -0
  315. backtrader/indicators/contrib/trigger_line.py +66 -0
  316. backtrader/indicators/contrib/triple_ema_rate.py +34 -0
  317. backtrader/indicators/contrib/trvi_indicator.py +194 -0
  318. backtrader/indicators/contrib/two_pb_ideal_xosma_indicator.py +127 -0
  319. backtrader/indicators/contrib/ultra_absolutely_no_lag_lwma_color.py +92 -0
  320. backtrader/indicators/contrib/ultra_wpr_indicator.py +173 -0
  321. backtrader/indicators/contrib/up_down_candle_strength.py +68 -0
  322. backtrader/indicators/contrib/vinin_i_trend_indicator.py +139 -0
  323. backtrader/indicators/contrib/volume_weighted_ma_indicator.py +78 -0
  324. backtrader/indicators/contrib/volume_weighted_ma_st_dev_indicator.py +111 -0
  325. backtrader/indicators/contrib/vwap_close_indicator.py +65 -0
  326. backtrader/indicators/contrib/vwma_candle.py +57 -0
  327. backtrader/indicators/contrib/vwma_digit_system.py +70 -0
  328. backtrader/indicators/contrib/wami.py +43 -0
  329. backtrader/indicators/contrib/wprsi_signal_indicator.py +105 -0
  330. backtrader/indicators/contrib/x_de_marker_histogram_vol_direct_indicator.py +145 -0
  331. backtrader/indicators/contrib/x_fisher_indicator.py +64 -0
  332. backtrader/indicators/contrib/xcci_histogram_vol_direct_indicator.py +56 -0
  333. backtrader/indicators/contrib/xcci_histogram_vol_indicator.py +85 -0
  334. backtrader/indicators/contrib/xma_ichimoku.py +163 -0
  335. backtrader/indicators/contrib/xma_ishimoku_channel_indicator.py +65 -0
  336. backtrader/indicators/contrib/xma_ishimoku_line.py +68 -0
  337. backtrader/indicators/contrib/xma_range_bands_indicator.py +107 -0
  338. backtrader/indicators/contrib/xmacd_indicator.py +70 -0
  339. backtrader/indicators/contrib/xrsi_de_marker_histogram.py +67 -0
  340. backtrader/indicators/contrib/xrsi_histogram_vol_direct_indicator.py +52 -0
  341. backtrader/indicators/contrib/xrsi_histogram_vol_indicator.py +81 -0
  342. backtrader/indicators/contrib/xrvi_indicator.py +130 -0
  343. backtrader/indicators/contrib/zero_lag_macd.py +36 -0
  344. backtrader/indicators/contrib/zig_zag_recent_pivot_signal.py +90 -0
  345. backtrader/indicators/contrib/zpf_indicator.py +115 -0
  346. backtrader/indicators/crossover.py +337 -0
  347. backtrader/indicators/dema.py +175 -0
  348. backtrader/indicators/demarker.py +270 -0
  349. backtrader/indicators/deviation.py +284 -0
  350. backtrader/indicators/directionalmove.py +1071 -0
  351. backtrader/indicators/dma.py +112 -0
  352. backtrader/indicators/dpo.py +96 -0
  353. backtrader/indicators/dv2.py +56 -0
  354. backtrader/indicators/ema.py +145 -0
  355. backtrader/indicators/envelope.py +475 -0
  356. backtrader/indicators/hadelta.py +198 -0
  357. backtrader/indicators/heikinashi.py +153 -0
  358. backtrader/indicators/hma.py +153 -0
  359. backtrader/indicators/hurst.py +151 -0
  360. backtrader/indicators/ichimoku.py +267 -0
  361. backtrader/indicators/kama.py +181 -0
  362. backtrader/indicators/kst.py +159 -0
  363. backtrader/indicators/lrsi.py +125 -0
  364. backtrader/indicators/mabase.py +147 -0
  365. backtrader/indicators/macd.py +322 -0
  366. backtrader/indicators/momentum.py +267 -0
  367. backtrader/indicators/moneyflow.py +237 -0
  368. backtrader/indicators/mt5atr.py +124 -0
  369. backtrader/indicators/myind.py +179 -0
  370. backtrader/indicators/obv.py +94 -0
  371. backtrader/indicators/ols.py +265 -0
  372. backtrader/indicators/oscillator.py +161 -0
  373. backtrader/indicators/percentchange.py +83 -0
  374. backtrader/indicators/percentrank.py +46 -0
  375. backtrader/indicators/pivotpoint.py +469 -0
  376. backtrader/indicators/prettygoodoscillator.py +113 -0
  377. backtrader/indicators/priceops_ext.py +123 -0
  378. backtrader/indicators/priceoscillator.py +262 -0
  379. backtrader/indicators/psar.py +212 -0
  380. backtrader/indicators/rmi.py +69 -0
  381. backtrader/indicators/rsi.py +440 -0
  382. backtrader/indicators/sma.py +141 -0
  383. backtrader/indicators/smma.py +116 -0
  384. backtrader/indicators/spread.py +54 -0
  385. backtrader/indicators/stochastic.py +263 -0
  386. backtrader/indicators/supertrend.py +436 -0
  387. backtrader/indicators/trend_ext.py +105 -0
  388. backtrader/indicators/trix.py +202 -0
  389. backtrader/indicators/tsi.py +155 -0
  390. backtrader/indicators/ultimateoscillator.py +158 -0
  391. backtrader/indicators/vortex.py +62 -0
  392. backtrader/indicators/williams.py +194 -0
  393. backtrader/indicators/wma.py +103 -0
  394. backtrader/indicators/zlema.py +135 -0
  395. backtrader/indicators/zlind.py +104 -0
  396. backtrader/linebuffer.py +3155 -0
  397. backtrader/lineiterator.py +2911 -0
  398. backtrader/lineroot.py +1106 -0
  399. backtrader/lineseries.py +2559 -0
  400. backtrader/live_trading/__init__.py +31 -0
  401. backtrader/live_trading/interface.py +404 -0
  402. backtrader/mathsupport.py +94 -0
  403. backtrader/metabase.py +1804 -0
  404. backtrader/mixins/__init__.py +21 -0
  405. backtrader/mixins/singleton.py +118 -0
  406. backtrader/observer.py +106 -0
  407. backtrader/observers/__init__.py +45 -0
  408. backtrader/observers/benchmark.py +126 -0
  409. backtrader/observers/broker.py +184 -0
  410. backtrader/observers/buysell.py +144 -0
  411. backtrader/observers/drawdown.py +161 -0
  412. backtrader/observers/logreturns.py +113 -0
  413. backtrader/observers/timereturn.py +86 -0
  414. backtrader/observers/trade_logger.py +2972 -0
  415. backtrader/observers/tradelogger.py +6 -0
  416. backtrader/observers/trades.py +258 -0
  417. backtrader/order.py +1114 -0
  418. backtrader/parameters.py +2345 -0
  419. backtrader/plot/__init__.py +54 -0
  420. backtrader/plot/finance.py +1022 -0
  421. backtrader/plot/formatters.py +200 -0
  422. backtrader/plot/locator.py +353 -0
  423. backtrader/plot/multicursor.py +495 -0
  424. backtrader/plot/plot.py +2500 -0
  425. backtrader/plot/plot_plotly.py +1351 -0
  426. backtrader/plot/scheme.py +253 -0
  427. backtrader/plot/utils.py +104 -0
  428. backtrader/position.py +290 -0
  429. backtrader/position_modes.py +132 -0
  430. backtrader/profiles.py +254 -0
  431. backtrader/reports/__init__.py +39 -0
  432. backtrader/reports/charts.py +371 -0
  433. backtrader/reports/performance.py +620 -0
  434. backtrader/reports/reporter.py +660 -0
  435. backtrader/resamplerfilter.py +1001 -0
  436. backtrader/signal.py +118 -0
  437. backtrader/signals/__init__.py +17 -0
  438. backtrader/sizer.py +114 -0
  439. backtrader/sizers/__init__.py +26 -0
  440. backtrader/sizers/fixedsize.py +161 -0
  441. backtrader/sizers/percents_sizer.py +119 -0
  442. backtrader/store.py +221 -0
  443. backtrader/stores/__init__.py +33 -0
  444. backtrader/stores/btapistore.py +15506 -0
  445. backtrader/stores/livestore.py +137 -0
  446. backtrader/stores/vchartfile.py +96 -0
  447. backtrader/strategy.py +3655 -0
  448. backtrader/talib.py +280 -0
  449. backtrader/test_helpers.py +96 -0
  450. backtrader/timer.py +358 -0
  451. backtrader/trade.py +442 -0
  452. backtrader/tradingcal.py +361 -0
  453. backtrader/utils/__init__.py +68 -0
  454. backtrader/utils/autodict.py +251 -0
  455. backtrader/utils/date.py +71 -0
  456. backtrader/utils/dateintern.py +509 -0
  457. backtrader/utils/flushfile.py +94 -0
  458. backtrader/utils/fractal.py +101 -0
  459. backtrader/utils/get_metrics.py +101 -0
  460. backtrader/utils/load_data.py +209 -0
  461. backtrader/utils/log_message.py +998 -0
  462. backtrader/utils/ordereddefaultdict.py +75 -0
  463. backtrader/utils/py3.py +296 -0
  464. backtrader/version.py +21 -0
  465. backtrader/writer.py +372 -0
@@ -0,0 +1,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()