cfquant 0.2.51__tar.gz → 0.2.53__tar.gz

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 (197) hide show
  1. {cfquant-0.2.51 → cfquant-0.2.53}/PKG-INFO +1 -1
  2. cfquant-0.2.53/cfquant/callback_dispatcher.py +218 -0
  3. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/client.py +111 -35
  4. cfquant-0.2.53/cfquant/diagnostics.py +126 -0
  5. cfquant-0.2.53/cfquant/flow_control.py +171 -0
  6. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/level2.py +7 -1
  7. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/normal_bridge.py +85 -22
  8. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/pipe_bridge.py +8 -4
  9. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/pipe_client.py +38 -14
  10. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/pipe_hub.py +109 -16
  11. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/pipe_transport.py +54 -13
  12. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/protocol.py +147 -25
  13. cfquant-0.2.53/cfquant/qmt_binding_settings.py +39 -0
  14. cfquant-0.2.53/cfquant/quote_history.py +122 -0
  15. cfquant-0.2.53/cfquant/rpc_timing.py +46 -0
  16. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/26_/345/205/250/346/216/250/350/256/242/351/230/205/346/265/213/350/257/225.py +149 -1
  17. cfquant-0.2.53/cfquant/tests/27_/345/205/250/346/216/250/350/256/242/351/230/205/351/241/265/351/235/242/346/265/213/350/257/225.py +143 -0
  18. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/28_Level2/346/216/245/345/217/243/351/200/202/351/205/215/346/265/213/350/257/225.py +6 -3
  19. cfquant-0.2.53/cfquant/tests/34_/345/205/250/346/216/250/345/206/205/351/203/250/345/273/266/350/277/237/346/265/213/350/257/225.py +77 -0
  20. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_api_tester_browser.py +106 -8
  21. cfquant-0.2.53/cfquant/tests/test_binding_runtime_browser.py +109 -0
  22. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_callback_requests.py +43 -1
  23. cfquant-0.2.53/cfquant/tests/test_dataframe_encoding.py +192 -0
  24. cfquant-0.2.53/cfquant/tests/test_latency_flow_control.py +601 -0
  25. cfquant-0.2.53/cfquant/tests/test_lite_qmt_dispatch.py +348 -0
  26. cfquant-0.2.53/cfquant/tests/test_market_timing.py +238 -0
  27. cfquant-0.2.53/cfquant/tests/test_qmt_binding_settings.py +123 -0
  28. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_qmt_fileless.py +14 -3
  29. cfquant-0.2.53/cfquant/tests/test_quote_latency.py +235 -0
  30. cfquant-0.2.53/cfquant/tests/test_quote_latency_encoding.py +107 -0
  31. cfquant-0.2.53/cfquant/tests/test_table_conversion.py +122 -0
  32. cfquant-0.2.53/cfquant/tests/test_tx_shutdown.py +377 -0
  33. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tx.py +1942 -1880
  34. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tx_trade_bridge.py +91 -44
  35. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/version.py +2 -2
  36. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/xtdata.py +16 -5
  37. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/xttrader.py +16 -9
  38. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant.egg-info/PKG-INFO +1 -1
  39. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant.egg-info/SOURCES.txt +16 -0
  40. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant_web_server.py +1140 -850
  41. {cfquant-0.2.51 → cfquant-0.2.53}/pyproject.toml +1 -1
  42. {cfquant-0.2.51 → cfquant-0.2.53}/qmt_scripts/CFQUANT_CTYPE_ALL_LOWLAT.py +35 -7
  43. {cfquant-0.2.51 → cfquant-0.2.53}/qmt_scripts/CFQUANT_LITE.py +605 -97
  44. {cfquant-0.2.51 → cfquant-0.2.53}/qmt_scripts//345/220/214/350/264/246/345/217/267/347/213/254/347/253/213/345/270/202/345/234/272/CFQUANT_CTYPE_ALL_LOWLAT_SH.py +35 -7
  45. {cfquant-0.2.51 → cfquant-0.2.53}/qmt_scripts//345/220/214/350/264/246/345/217/267/347/213/254/347/253/213/345/270/202/345/234/272/CFQUANT_CTYPE_ALL_LOWLAT_SZ.py +35 -7
  46. {cfquant-0.2.51 → cfquant-0.2.53}/qmt_scripts//345/220/214/350/264/246/345/217/267/347/213/254/347/253/213/345/270/202/345/234/272/CFQUANT_LITE_SH.py +605 -97
  47. {cfquant-0.2.51 → cfquant-0.2.53}/qmt_scripts//345/220/214/350/264/246/345/217/267/347/213/254/347/253/213/345/270/202/345/234/272/CFQUANT_LITE_SZ.py +605 -97
  48. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/app.js +310 -21
  49. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/index.html +47 -4
  50. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/python-api-data.js +2 -2
  51. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/styles.css +123 -25
  52. cfquant-0.2.51/cfquant/callback_dispatcher.py +0 -94
  53. cfquant-0.2.51/cfquant/tests/27_/345/205/250/346/216/250/350/256/242/351/230/205/351/241/265/351/235/242/346/265/213/350/257/225.py +0 -31
  54. {cfquant-0.2.51 → cfquant-0.2.53}/LICENSE +0 -0
  55. {cfquant-0.2.51 → cfquant-0.2.53}/LTtx/__init__.py +0 -0
  56. {cfquant-0.2.51 → cfquant-0.2.53}/LTtx/tx/Config.txt +0 -0
  57. {cfquant-0.2.51 → cfquant-0.2.53}/LTtx/tx/LTtx_server.py +0 -0
  58. {cfquant-0.2.51 → cfquant-0.2.53}/LTtx/tx/__init__.py +0 -0
  59. {cfquant-0.2.51 → cfquant-0.2.53}/LTtx/tx/tx.py +0 -0
  60. {cfquant-0.2.51 → cfquant-0.2.53}/MANIFEST.in +0 -0
  61. {cfquant-0.2.51 → cfquant-0.2.53}/README.md +0 -0
  62. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/__init__.py +0 -0
  63. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/_editable_install.py +0 -0
  64. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/account_routing.py +0 -0
  65. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/batch_orders.py +0 -0
  66. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/build_info.py +0 -0
  67. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/cftrader.py +0 -0
  68. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/channels.py +0 -0
  69. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/cli.py +0 -0
  70. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/config.py +0 -0
  71. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/hub.py +0 -0
  72. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/log_management.py +0 -0
  73. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/logging_i18n.py +0 -0
  74. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/management.py +0 -0
  75. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/order_identity.py +0 -0
  76. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/order_meta.py +0 -0
  77. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/qmt_bridge.py +0 -0
  78. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/qmt_pipe_runtime.py +0 -0
  79. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/qmt_strategy_deploy.py +0 -0
  80. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/qmt_strategy_package.py +0 -0
  81. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/qmt_strategy_runtime.py +0 -0
  82. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/runtime_report.py +0 -0
  83. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/stock_connect.py +0 -0
  84. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/10_/345/221/275/344/273/244/350/241/214/346/216/245/345/217/243/346/265/213/350/257/225.py" +0 -0
  85. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/11_/346/231/256/351/200/232/346/241/245/346/216/245/346/264/276/345/217/221/346/265/213/350/257/225.py" +0 -0
  86. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/12_/350/256/242/345/215/225/345/244/207/346/263/250/346/265/213/350/257/225.py" +0 -0
  87. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/13_/347/256/241/351/201/223/344/270/255/345/277/203/350/277/236/346/216/245/346/265/213/350/257/225.py" +0 -0
  88. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/14_/350/277/220/350/241/214/346/227/266/346/212/245/345/221/212/346/265/213/350/257/225.py" +0 -0
  89. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/15_/347/275/221/351/241/265/347/212/266/346/200/201/347/274/223/345/255/230/346/265/213/350/257/225.py" +0 -0
  90. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/16_/345/220/257/345/212/250/345/256/211/350/243/205/346/265/213/350/257/225.py" +0 -0
  91. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/17_/345/244/247QMT/346/216/245/345/217/243/351/200/202/351/205/215/346/265/213/350/257/225.py" +0 -0
  92. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/18_/351/253/230/347/272/247/346/250/241/345/274/217/345/256/236/346/234/272/350/201/224/350/260/203.py" +0 -0
  93. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/19_/345/256/236/346/234/272/350/201/224/350/260/203/345/210/244/345/256/232/346/265/213/350/257/225.py" +0 -0
  94. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/1_/350/241/214/346/203/205/346/216/245/346/224/266/346/265/213/350/257/225.py" +0 -0
  95. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/20_/350/201/224/350/260/203/351/227/256/351/242/230/344/277/256/345/244/215/346/265/213/350/257/225.py" +0 -0
  96. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/21_/345/215/225/350/202/241/350/241/214/346/203/205/350/256/242/351/230/205/346/265/213/350/257/225.py" +0 -0
  97. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/22_/350/277/236/346/216/245/350/257/212/346/226/255/346/265/213/350/257/225.py" +0 -0
  98. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/23_/347/244/272/344/276/213/351/205/215/347/275/256/346/265/213/350/257/225.py" +0 -0
  99. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/24_/350/264/246/346/210/267/347/274/223/345/255/230/346/214/211/351/234/200/345/210/267/346/226/260/346/265/213/350/257/225.py" +0 -0
  100. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/25_/350/264/246/346/210/267/347/274/223/345/255/230/351/241/265/351/235/242/346/265/213/350/257/225.py" +0 -0
  101. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/29_Level2/350/241/214/346/203/205/346/265/213/350/257/225.py" +0 -0
  102. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/2_/346/225/260/346/215/256/350/216/267/345/217/226/346/265/213/350/257/225.py" +0 -0
  103. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/30_cftrader/346/211/271/351/207/217/344/270/213/345/215/225/346/200/247/350/203/275/345/237/272/345/207/206.py" +0 -0
  104. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/31_cftrader/346/250/241/346/213/237/350/264/246/345/217/267/346/211/271/351/207/217/344/270/213/345/215/225/346/265/213/350/257/225.py" +0 -0
  105. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/32_/345/274/202/346/255/245/344/270/213/345/215/225/346/265/213/350/257/225.py" +0 -0
  106. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/33_/345/220/214/346/255/245/344/270/213/345/215/225/345/233/236/350/260/203/346/265/213/350/257/225.py" +0 -0
  107. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/3_/346/225/260/346/215/256/344/270/213/350/275/275/346/265/213/350/257/225.py" +0 -0
  108. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/4_/344/272/244/346/230/223/345/247/224/346/211/230/346/237/245/350/257/242/346/265/213/350/257/225.py" +0 -0
  109. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/5_/347/234/237/345/256/236/344/270/213/345/215/225/346/265/213/350/257/225.py" +0 -0
  110. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/6_/345/233/236/350/260/203/346/265/213/350/257/225.py" +0 -0
  111. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/7_/345/220/214/346/255/245/345/274/202/346/255/245/344/270/213/345/215/225/346/265/213/350/257/225.py" +0 -0
  112. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/8_/346/210/220/344/272/244/350/256/242/345/215/225/345/222/214/345/247/224/346/211/230/350/256/242/345/215/225/346/237/245/350/257/242/346/265/213/350/257/225.py" +0 -0
  113. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/9_/345/217/257/346/222/244/350/256/242/345/215/225/346/265/213/350/257/225.py" +0 -0
  114. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/_helpers.py +0 -0
  115. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/log/cftrader/346/250/241/346/213/237/350/264/246/345/217/267/346/211/271/351/207/217/344/270/213/345/215/225/346/265/213/350/257/225_20260911_024617.md" +0 -0
  116. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/log/cftrader/346/250/241/346/213/237/350/264/246/345/217/267/346/211/271/351/207/217/344/270/213/345/215/225/346/265/213/350/257/225_20260921_175732.md" +0 -0
  117. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/readme.md +0 -0
  118. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_advanced_parity.py +0 -0
  119. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_api_tester_web.py +0 -0
  120. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_binding_delete_confirmation.py +0 -0
  121. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_cftrader.py +0 -0
  122. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_cftrader_tutorial.py +0 -0
  123. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_cftrader_web.py +0 -0
  124. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_credit_compat.py +0 -0
  125. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_json_serialization.py +0 -0
  126. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_lite_account_routing.py +0 -0
  127. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_lite_deployment_paths.py +0 -0
  128. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_lite_parity.py +0 -0
  129. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_local_trading_calendar.py +0 -0
  130. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_log_management.py +0 -0
  131. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_management.py +0 -0
  132. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_management_batch.py +0 -0
  133. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_market_data_fallback.py +0 -0
  134. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_order_identity.py +0 -0
  135. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_order_time_callback.py +0 -0
  136. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_project_updater.py +0 -0
  137. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_python_api_reference.py +0 -0
  138. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_qmt_strategy_browser.py +0 -0
  139. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_qmt_strategy_deploy.py +0 -0
  140. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_qmt_strategy_web.py +0 -0
  141. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_setup_qmt_prompt.py +0 -0
  142. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_startup_recovery.py +0 -0
  143. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_stock_connect.py +0 -0
  144. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_stock_connect_browser.py +0 -0
  145. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_trade_strategy_callback.py +0 -0
  146. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_trading_dates.py +0 -0
  147. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_tutorial_reader.py +0 -0
  148. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/tests/test_tx_auto_import.py +0 -0
  149. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/trading_calendar.py +0 -0
  150. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/xtconstant.py +0 -0
  151. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant/xttype.py +0 -0
  152. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant.egg-info/dependency_links.txt +0 -0
  153. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant.egg-info/entry_points.txt +0 -0
  154. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant.egg-info/requires.txt +0 -0
  155. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant.egg-info/top_level.txt +0 -0
  156. {cfquant-0.2.51 → cfquant-0.2.53}/cfquant_pipe_hub.py +0 -0
  157. {cfquant-0.2.51 → cfquant-0.2.53}/qmt_scripts/CFQUANT.py +0 -0
  158. {cfquant-0.2.51 → cfquant-0.2.53}/qmt_scripts/CFQUANT_TRADE_LOWLAT.py +0 -0
  159. {cfquant-0.2.51 → cfquant-0.2.53}/qmt_scripts/__init__.py +0 -0
  160. {cfquant-0.2.51 → cfquant-0.2.53}/qmt_scripts//345/220/214/350/264/246/345/217/267/347/213/254/347/253/213/345/270/202/345/234/272/CFQUANT_TRADE_LOWLAT_SH.py" +0 -0
  161. {cfquant-0.2.51 → cfquant-0.2.53}/qmt_scripts//345/220/214/350/264/246/345/217/267/347/213/254/347/253/213/345/270/202/345/234/272/CFQUANT_TRADE_LOWLAT_SZ.py" +0 -0
  162. {cfquant-0.2.51 → cfquant-0.2.53}/qmt_scripts//345/220/214/350/264/246/345/217/267/347/213/254/347/253/213/345/270/202/345/234/272/readme.md" +0 -0
  163. {cfquant-0.2.51 → cfquant-0.2.53}/requirements.txt +0 -0
  164. {cfquant-0.2.51 → cfquant-0.2.53}/setup.cfg +0 -0
  165. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/__init__.py +0 -0
  166. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/api-tester.js +0 -0
  167. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/avatars/amber-pulse.svg +0 -0
  168. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/avatars/copper-grid.svg +0 -0
  169. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/avatars/market-blue.svg +0 -0
  170. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/avatars/rose-circuit.svg +0 -0
  171. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/avatars/signal-green.svg +0 -0
  172. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/avatars/slate-wave.svg +0 -0
  173. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/avatars/teal-orbit.svg +0 -0
  174. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/avatars/violet-node.svg +0 -0
  175. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/cftrader-api-data.js +0 -0
  176. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/docs.js +0 -0
  177. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/favicon.svg +0 -0
  178. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-10.png +0 -0
  179. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-11.png +0 -0
  180. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-12.png +0 -0
  181. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-13.png +0 -0
  182. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-14.png +0 -0
  183. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-15.png +0 -0
  184. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-16.png +0 -0
  185. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-17.png +0 -0
  186. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-18.png +0 -0
  187. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-19.png +0 -0
  188. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-20.png +0 -0
  189. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-21.png +0 -0
  190. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-4.png +0 -0
  191. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-5.png +0 -0
  192. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-6.png +0 -0
  193. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-7.png +0 -0
  194. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-8.png +0 -0
  195. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/image/qmt_deploy/image-9.png +0 -0
  196. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/python-api.js +0 -0
  197. {cfquant-0.2.51 → cfquant-0.2.53}/web_dashboard/vendor/mermaid.min.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cfquant
3
- Version: 0.2.51
3
+ Version: 0.2.53
4
4
  Summary: QMT local bridge and xtquant-compatible Python client.
5
5
  License: MIT
6
6
  Requires-Python: >=3.8
@@ -0,0 +1,218 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Serial user callbacks, independent of the RPC receive thread."""
3
+ from collections import deque
4
+ import logging
5
+ import os
6
+ import tempfile
7
+ import threading
8
+ import time
9
+
10
+ from .protocol import decode_value, dumps_message, loads_message
11
+
12
+
13
+ class CallbackDispatcher(object):
14
+ def __init__(self, dispatch, on_error=None):
15
+ self._dispatch = dispatch
16
+ self._on_error = on_error
17
+ self._condition = threading.Condition()
18
+ self._queue = deque()
19
+ self._generation = 0
20
+ self._active = False
21
+ self._thread = None
22
+ self._context = threading.local()
23
+ self._memory_limit = max(0, int(os.environ.get("CFQUANT_CALLBACK_MEMORY_BYTES", 64 * 1024 * 1024)))
24
+ self._memory_bytes = 0
25
+ self._spool = None
26
+ self._spool_size = 0
27
+ self._spool_live = 0
28
+ self._last_wait_ms = self._last_callback_ms = 0.0
29
+ self._last_warning = 0.0
30
+
31
+ def start(self):
32
+ with self._condition:
33
+ self._generation += 1
34
+ self._active = True
35
+ self._clear_queue()
36
+ return self._generation
37
+
38
+ def stop(self):
39
+ with self._condition:
40
+ self._active = False
41
+ self._generation += 1
42
+ self._clear_queue()
43
+ self._condition.notify_all()
44
+ # Never join: close/stop may be called by a callback itself.
45
+
46
+ def submit(self, generation, message, raw=None):
47
+ with self._condition:
48
+ if not self._active or generation != self._generation:
49
+ return
50
+ # Do not block the receive thread on a slow user callback. Trade
51
+ # events cannot be silently dropped or coalesced.
52
+ raw = raw if raw is not None else dumps_message(message)
53
+ size = len(raw) * 8 + 256 # Include the decoded dict/list object overhead.
54
+ self._queue.append([generation, message, time.monotonic(), raw, None, size])
55
+ self._memory_bytes += size
56
+ self._spill_queue()
57
+ if self._thread is None:
58
+ self._thread = threading.Thread(target=self._run, name="cfquant-event-callback")
59
+ self._thread.daemon = True
60
+ self._thread.start()
61
+ self._condition.notify()
62
+
63
+ def _clear_queue(self):
64
+ self._queue.clear()
65
+ self._memory_bytes = self._spool_size = self._spool_live = 0
66
+ if self._spool is not None:
67
+ self._spool.close()
68
+ self._spool = None
69
+
70
+ def _spill_queue(self):
71
+ # Allow one full batch in memory: a single large snapshot is not a
72
+ # backlog and must not make every healthy callback perform disk IO.
73
+ if len(self._queue) <= 1:
74
+ return
75
+ for item in self._queue:
76
+ if self._memory_bytes <= self._memory_limit:
77
+ break
78
+ if item[4] is not None:
79
+ continue
80
+ if self._spool is None:
81
+ self._spool = tempfile.TemporaryFile(prefix="cfquant-callbacks-")
82
+ raw = item[3].encode("utf-8") if isinstance(item[3], str) else item[3]
83
+ self._spool.seek(self._spool_size)
84
+ if self._spool.write(raw) != len(raw):
85
+ raise OSError("callback backlog could not be preserved")
86
+ item[4] = (self._spool_size, len(raw))
87
+ self._spool_size += len(raw)
88
+ self._spool_live += len(raw)
89
+ self._memory_bytes -= item[5]
90
+ item[1] = item[3] = None
91
+ if self._queue and len(self._queue) >= 1000 and time.monotonic() - self._last_warning >= 5:
92
+ self._last_warning = time.monotonic()
93
+ logging.getLogger(__name__).warning("callback backlog count=%s oldest_ms=%.1f spooled_bytes=%s",
94
+ len(self._queue), (time.monotonic() - self._queue[0][2]) * 1000, self._spool_live)
95
+
96
+ def _take_message(self, item):
97
+ if item[4] is None:
98
+ self._memory_bytes -= item[5]
99
+ return item[1], None
100
+ offset, size = item[4]
101
+ self._spool.seek(offset)
102
+ raw = self._spool.read(size)
103
+ if len(raw) != size:
104
+ raise OSError("callback backlog read was incomplete")
105
+ self._spool_live -= size
106
+ if self._spool_live == 0:
107
+ self._spool.close()
108
+ self._spool = None
109
+ self._spool_size = 0
110
+ elif self._spool_size > max(self._memory_limit, 1024 * 1024) + self._spool_live * 2:
111
+ replacement = tempfile.TemporaryFile(prefix="cfquant-callbacks-")
112
+ offsets = []
113
+ try:
114
+ for queued in self._queue:
115
+ if queued[4] is None:
116
+ continue
117
+ previous, length = queued[4]
118
+ self._spool.seek(previous)
119
+ value = self._spool.read(length)
120
+ next_offset = replacement.tell()
121
+ if len(value) != length or replacement.write(value) != length:
122
+ raise OSError("callback backlog compaction was incomplete")
123
+ offsets.append((queued, (next_offset, length)))
124
+ except BaseException:
125
+ replacement.close()
126
+ raise
127
+ self._spool.close()
128
+ self._spool = replacement
129
+ self._spool_size = replacement.tell()
130
+ for queued, location in offsets:
131
+ queued[4] = location
132
+ return None, raw
133
+
134
+ def status(self):
135
+ with self._condition:
136
+ return {"queued_callbacks": len(self._queue), "estimated_memory_bytes": self._memory_bytes,
137
+ "memory_limit_bytes": self._memory_limit, "spooled_bytes": self._spool_live,
138
+ "oldest_age_ms": round((time.monotonic() - self._queue[0][2]) * 1000, 3) if self._queue else 0,
139
+ "last_queue_ms": round(self._last_wait_ms, 3), "last_callback_ms": round(self._last_callback_ms, 3)}
140
+
141
+ def is_current_callback(self):
142
+ generation = getattr(self._context, "generation", None)
143
+ with self._condition:
144
+ return generation is None or (self._active and generation == self._generation)
145
+
146
+ def is_generation(self, generation):
147
+ with self._condition:
148
+ return generation == self._generation
149
+
150
+ def check_request(self):
151
+ if not self.is_current_callback():
152
+ raise RuntimeError("callback belongs to a closed cfquant connection")
153
+
154
+ def _run(self):
155
+ while True:
156
+ try:
157
+ with self._condition:
158
+ while self._active and not self._queue:
159
+ self._condition.wait()
160
+ if not self._active:
161
+ self._thread = None
162
+ return
163
+ item = self._queue.popleft()
164
+ generation = item[0]
165
+ message, raw = self._take_message(item)
166
+ if message is None:
167
+ message = loads_message(raw)
168
+ if message is None:
169
+ raise ValueError("invalid preserved callback message")
170
+ except Exception as error:
171
+ logging.getLogger(__name__).exception("callback backlog failed; connection must be restarted")
172
+ with self._condition:
173
+ if generation != self._generation:
174
+ continue # An old connection must not close its replacement.
175
+ self._active = False
176
+ self._clear_queue()
177
+ self._thread = None
178
+ if self._on_error is not None:
179
+ self._on_error(error, generation)
180
+ return
181
+ started = time.monotonic()
182
+ self._last_wait_ms = (started - item[2]) * 1000
183
+ self._context.generation = generation
184
+ try:
185
+ if self.is_current_callback():
186
+ self._dispatch(message)
187
+ except Exception:
188
+ logging.getLogger(__name__).exception("callback dispatch failed event=%s", message.get("event"))
189
+ finally:
190
+ self._last_callback_ms = (time.monotonic() - started) * 1000
191
+ del self._context.generation
192
+
193
+
194
+ def dispatch_callbacks(callbacks, message, is_current):
195
+ event = message.get("event")
196
+ raw_data = message.get("data")
197
+ meta = message.get("meta")
198
+ plain_quote = (isinstance(raw_data, dict) and isinstance(meta, dict)
199
+ and meta.get("quote_json_plain") is True)
200
+ # The direct snapshot encoder emits only JSON scalars/containers, no tagged
201
+ # SDK values. Do not walk all symbols a second time after JSON parsing.
202
+ data = raw_data if plain_quote else decode_value(raw_data)
203
+ full_message = dict(message, data=data)
204
+ deliveries = [(list(callbacks.get("__event__", [])), full_message),
205
+ (list(callbacks.get(event, [])), data)]
206
+ if event and event.startswith("quote:"):
207
+ quote_message = dict(full_message)
208
+ if quote_message.get("subscription_id") is not None and quote_message.get("subscribe_id") is None:
209
+ quote_message["subscribe_id"] = quote_message["subscription_id"]
210
+ deliveries.append((list(callbacks.get("quote", [])), quote_message))
211
+ for handlers, payload in deliveries:
212
+ for callback in handlers:
213
+ if not is_current():
214
+ return
215
+ try:
216
+ callback(payload)
217
+ except Exception:
218
+ logging.getLogger(__name__).exception("user callback failed event=%s", event)
@@ -1,5 +1,6 @@
1
1
  # -*- coding: utf-8 -*-
2
- import json
2
+ import json
3
+ import atexit
3
4
  import queue
4
5
  import socket
5
6
  import threading
@@ -7,17 +8,31 @@ import time
7
8
 
8
9
  from .config import get_config
9
10
  from .callback_dispatcher import CallbackDispatcher, dispatch_callbacks
10
- from .protocol import decode_value, dumps_message, loads_message, new_id, pack_request
11
+ from .protocol import decode_value, dumps_message, loads_message, new_id, pack_request
12
+ from .rpc_timing import RpcTiming
11
13
 
12
14
 
13
15
  class CfquantError(RuntimeError):
14
16
  pass
15
17
 
16
18
 
17
- class CfquantTimeout(TimeoutError):
18
- pass
19
-
20
-
19
+ class CfquantTimeout(TimeoutError):
20
+ pass
21
+
22
+
23
+ def _wait_response(q, timeout):
24
+ """Keep long Windows waits interruptible without delaying incoming data."""
25
+ if timeout < 0 or threading.current_thread() is not threading.main_thread():
26
+ return q.get(timeout=timeout)
27
+ deadline = time.monotonic() + timeout
28
+ while True:
29
+ try:
30
+ return q.get(timeout=min(.2, max(0, deadline - time.monotonic())))
31
+ except queue.Empty:
32
+ if time.monotonic() >= deadline:
33
+ raise
34
+
35
+
21
36
  PIPE_TRANSPORTS = ("pipe", "ctypes", "named_pipe", "named-pipe")
22
37
  WEB_LTTX_TRANSPORTS = ("web", "web_lttx", "lttx_web", "web-lttx", "lttx-web")
23
38
 
@@ -113,9 +128,10 @@ class LTtxRpcClient(object):
113
128
  self._callbacks = {}
114
129
  self._lock = threading.RLock()
115
130
  self._pending_lock = threading.RLock()
116
- self._started = False
131
+ self._started = False
132
+ self._closed = False
117
133
  self._recv_thread = None
118
- self._event_dispatcher = CallbackDispatcher(self._dispatch_event)
134
+ self._event_dispatcher = CallbackDispatcher(self._dispatch_event, self._callback_buffer_failed)
119
135
 
120
136
  def start(self):
121
137
  with self._lock:
@@ -123,19 +139,37 @@ class LTtxRpcClient(object):
123
139
  if self._started:
124
140
  return
125
141
  txl = self._load_txl()
126
- self._tx = txl(self.host, self.port, self.token)
127
- self._tx.start_tx()
128
- self._tx.start_txg(self.client_id)
129
- self._started = True
142
+ self._tx = txl(self.host, self.port, self.token)
143
+ try:
144
+ result = self._tx.start_tx()
145
+ if isinstance(result, dict) and result.get("code", 0) != 0:
146
+ raise CfquantError(result.get("msg") or "LTtx connection failed")
147
+ self._tx.start_txg(self.client_id)
148
+ if not getattr(self._tx, "run_on", True):
149
+ raise CfquantError("cfquant client closed during connection startup")
150
+ except BaseException:
151
+ self.close()
152
+ raise
153
+ self._started = True
154
+ self._closed = False
130
155
  generation = self._event_dispatcher.start()
131
156
  self._recv_thread = threading.Thread(target=self._recv_loop, args=(self._tx, generation))
132
157
  self._recv_thread.daemon = True
133
158
  self._recv_thread.start()
134
159
 
135
- def close(self):
136
- with self._lock:
137
- self._event_dispatcher.stop()
138
- self._started = False
160
+ def close(self):
161
+ # start() owns _lock while connecting/retrying. Cancel the underlying
162
+ # transport first so a close from another thread can acquire that lock.
163
+ starting_tx = self._tx if not self._started else None
164
+ if starting_tx is not None:
165
+ try:
166
+ starting_tx.close()
167
+ except Exception:
168
+ pass
169
+ with self._lock:
170
+ self._event_dispatcher.stop()
171
+ self._started = False
172
+ self._closed = True
139
173
  tx = self._tx
140
174
  self._tx = None
141
175
  if tx is not None:
@@ -143,10 +177,11 @@ class LTtxRpcClient(object):
143
177
  tx.Q.put(None)
144
178
  except Exception:
145
179
  pass
146
- try:
147
- tx.close()
148
- except Exception:
149
- pass
180
+ if tx is not starting_tx:
181
+ try:
182
+ tx.close()
183
+ except Exception:
184
+ pass
150
185
  with self._pending_lock:
151
186
  for q in list(self._pending.values()):
152
187
  try:
@@ -155,7 +190,7 @@ class LTtxRpcClient(object):
155
190
  pass
156
191
  self._pending.clear()
157
192
 
158
- def request(self, action, params=None, timeout=None, request_channel=None):
193
+ def request(self, action, params=None, timeout=None, request_channel=None, response_meta=None):
159
194
  effective_timeout = float(timeout or self.timeout)
160
195
  request_id = new_id("req")
161
196
  raw = pack_request(
@@ -167,6 +202,7 @@ class LTtxRpcClient(object):
167
202
  timeout=effective_timeout,
168
203
  )
169
204
  q = queue.Queue(maxsize=1)
205
+ timing = RpcTiming(response_meta, request_id)
170
206
  try:
171
207
  with self._lock:
172
208
  self.start()
@@ -174,14 +210,19 @@ class LTtxRpcClient(object):
174
210
  self._pending[request_id] = q
175
211
  self._push("request", raw, request_channel or self.request_channel)
176
212
  try:
177
- msg = q.get(timeout=effective_timeout)
213
+ msg = _wait_response(q, effective_timeout)
178
214
  except queue.Empty:
179
215
  raise CfquantTimeout("cfquant request timeout: %s" % action)
180
- if not msg.get("ok"):
216
+ timing.received(msg)
217
+ if not msg.get("ok"):
181
218
  err = msg.get("error") or {}
182
219
  raise CfquantError(err.get("message") or str(err))
183
- return decode_value(msg.get("result"))
184
- finally:
220
+ return timing.decode(msg.get("result"), decode_value)
221
+ except (KeyboardInterrupt, SystemExit):
222
+ self.close()
223
+ raise
224
+ finally:
225
+ timing.finish()
185
226
  with self._pending_lock:
186
227
  self._pending.pop(request_id, None)
187
228
 
@@ -209,20 +250,26 @@ class LTtxRpcClient(object):
209
250
  raw = tx.Q.get()
210
251
  if raw is None:
211
252
  break
212
- msg = loads_message(raw)
253
+ parse_started = time.perf_counter()
254
+ msg = loads_message(raw)
213
255
  if not msg:
214
256
  continue
215
257
  with self._lock:
216
258
  if not self._started or tx is not self._tx:
217
259
  return
218
260
  msg_type = msg.get("type")
219
- if msg_type == "response":
261
+ if msg_type == "response":
262
+ msg["_client_parse_ms"] = round((time.perf_counter() - parse_started) * 1000, 3)
220
263
  with self._pending_lock:
221
264
  q = self._pending.pop(msg.get("id"), None)
222
265
  if q:
223
266
  q.put(msg)
224
- elif msg_type == "event":
225
- self._event_dispatcher.submit(generation, msg)
267
+ elif msg_type == "event":
268
+ try:
269
+ self._event_dispatcher.submit(generation, msg, raw=raw)
270
+ except Exception as error:
271
+ self._callback_buffer_failed(error)
272
+ return
226
273
  except Exception:
227
274
  time.sleep(0.05)
228
275
  if not self._started:
@@ -231,8 +278,25 @@ class LTtxRpcClient(object):
231
278
  if self._tx is expected_tx:
232
279
  self.close()
233
280
 
234
- def _dispatch_event(self, msg):
235
- dispatch_callbacks(self._callbacks, msg, self._event_dispatcher.is_current_callback)
281
+ def _dispatch_event(self, msg):
282
+ dispatch_callbacks(self._callbacks, msg, self._event_dispatcher.is_current_callback)
283
+
284
+ def callback_status(self):
285
+ return self._event_dispatcher.status()
286
+
287
+ def _callback_buffer_failed(self, error, generation=None):
288
+ with self._lock:
289
+ if generation is not None and not self._event_dispatcher.is_generation(generation):
290
+ return
291
+ with self._pending_lock:
292
+ pending = list(self._pending.values())
293
+ self._pending.clear()
294
+ for waiter in pending:
295
+ try:
296
+ waiter.put_nowait({"ok": False, "error": {"message": "cfquant callback buffering failed: %s" % error}})
297
+ except queue.Full:
298
+ pass
299
+ self.close()
236
300
 
237
301
  def _push(self, key, payload, channel):
238
302
  tx = self._tx
@@ -333,7 +397,7 @@ _default_client = None
333
397
  _client_lock = threading.Lock()
334
398
 
335
399
 
336
- def get_client():
400
+ def get_client():
337
401
  global _default_client
338
402
  with _client_lock:
339
403
  if _default_client is None:
@@ -343,9 +407,21 @@ def get_client():
343
407
  timeout=cfg.get("timeout"),
344
408
  client_id=cfg.get("client_id"),
345
409
  )
346
- return _default_client
347
-
348
-
410
+ return _default_client
411
+
412
+
413
+ def close_client():
414
+ """Close the default market-data client without creating a connection."""
415
+ global _default_client
416
+ with _client_lock:
417
+ client, _default_client = _default_client, None
418
+ if client is not None:
419
+ client.close()
420
+
421
+
422
+ atexit.register(close_client)
423
+
424
+
349
425
  def configure(**kwargs):
350
426
  from .config import configure as configure_config
351
427
 
@@ -0,0 +1,126 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Opt-in whole-quote latency diagnostics; ordinary xtdata payloads are unchanged."""
3
+ import datetime
4
+ import math
5
+ import time
6
+
7
+ from . import xtdata
8
+
9
+
10
+ SCHEMA = "cfquant.quote_latency.v1"
11
+ ACTION = "xtdata.subscribe_whole_quote_latency"
12
+ _SHANGHAI = datetime.timezone(datetime.timedelta(hours=8))
13
+
14
+
15
+ def quote_time_ms(value):
16
+ """Accept epoch seconds/ms/us/ns and QMT calendar strings (China time).
17
+
18
+ Time-only and date-only fields are deliberately not assigned today's date.
19
+ """
20
+ if value is None or isinstance(value, bool):
21
+ return None
22
+ text = str(value).strip()
23
+ if text.isdigit() and len(text) in (14, 17) and text[:2] in ("19", "20"):
24
+ try:
25
+ stamp = datetime.datetime.strptime(text[:14], "%Y%m%d%H%M%S").replace(tzinfo=_SHANGHAI)
26
+ return stamp.timestamp() * 1000.0 + (int(text[14:]) if len(text) == 17 else 0)
27
+ except ValueError:
28
+ return None
29
+ try:
30
+ number = float(value)
31
+ except (ValueError, TypeError, OverflowError):
32
+ for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y%m%d %H:%M:%S.%f", "%Y%m%d %H:%M:%S"):
33
+ try:
34
+ return datetime.datetime.strptime(text, fmt).replace(tzinfo=_SHANGHAI).timestamp() * 1000.0
35
+ except ValueError:
36
+ pass
37
+ return None
38
+ if not math.isfinite(number) or number < 1000000000:
39
+ return None
40
+ if number >= 1e17:
41
+ number /= 1e6
42
+ elif number >= 1e14:
43
+ number /= 1e3
44
+ elif number < 1e11:
45
+ number *= 1000.0
46
+ # Reject short dates and implausible timestamps rather than report bogus latency.
47
+ return number if 946684800000 <= number < 4102444800000 else None
48
+
49
+
50
+ def latency_stats(values):
51
+ values = sorted(value for value in values if value is not None and math.isfinite(value))
52
+ if not values:
53
+ return {"count": 0, "min_ms": None, "max_ms": None, "avg_ms": None, "p95_ms": None}
54
+ return {"count": len(values), "min_ms": values[0], "max_ms": values[-1],
55
+ "avg_ms": round(sum(values) / len(values), 3),
56
+ "p95_ms": values[max(0, (len(values) * 95 + 99) // 100 - 1)]}
57
+
58
+
59
+ def latency_report(payload, received_at_ms=None, sample_limit=None):
60
+ """Measure at callback entry, before reporting/rendering. Never clamp negative deltas."""
61
+ received_at_ms = time.time() * 1000.0 if received_at_ms is None else received_at_ms
62
+ analysis_started = time.perf_counter()
63
+ if not isinstance(payload, dict) or payload.get("schema") != SCHEMA:
64
+ raise ValueError("not a cfquant whole-quote latency event; update the QMT entry script")
65
+ quotes = payload.get("quotes") or {}
66
+ pushed = quote_time_ms(payload.get("qmt_push_at_ms"))
67
+ entered = quote_time_ms(payload.get("qmt_callback_at_ms"))
68
+
69
+ def delta(end, start):
70
+ return round(end - start, 3) if end is not None and start is not None else None
71
+
72
+ sample_limit = None if sample_limit is None else max(1, int(sample_limit))
73
+ samples = []
74
+ for code, quote in quotes.items():
75
+ if sample_limit is not None and len(samples) >= sample_limit:
76
+ break
77
+ if not isinstance(quote, dict):
78
+ continue
79
+ stamp, field, raw = None, None, None
80
+ for name in ("time", "stime", "timetag", "datetime", "updateTime", "update_time"):
81
+ candidate = quote.get(name)
82
+ if raw is None and candidate is not None:
83
+ field, raw = name, candidate
84
+ stamp = quote_time_ms(candidate)
85
+ if stamp is not None:
86
+ field, raw = name, candidate
87
+ break
88
+ samples.append({
89
+ "code": str(code), "quote_time_field": field, "quote_time_raw": raw,
90
+ "quote_at_ms": stamp, "quote_latency_ms": delta(received_at_ms, stamp),
91
+ # Use the timestamp captured at QMT callback entry, before locking
92
+ # or conversion; receiver/formatting time must not affect this value.
93
+ "qmt_quote_latency_ms": delta(entered, stamp),
94
+ "last_price": quote.get("lastPrice", quote.get("last_price")),
95
+ "volume": quote.get("volume"), "amount": quote.get("amount"),
96
+ })
97
+ summary = {
98
+ "quote_latency": latency_stats(row["quote_latency_ms"] for row in samples),
99
+ "qmt_quote_latency": latency_stats(row["qmt_quote_latency_ms"] for row in samples),
100
+ }
101
+ return {
102
+ "schema": SCHEMA, "received_at_ms": received_at_ms,
103
+ "qmt_callback_at_ms": entered, "qmt_push_at_ms": pushed,
104
+ "internal_latency_ms": delta(received_at_ms, pushed),
105
+ "qmt_prepare_ms": delta(pushed, entered),
106
+ "qmt_encode_ms": payload.get("qmt_encode_ms"), "qmt_encoding": payload.get("qmt_encoding"),
107
+ "code_count": len(quotes), "samples": samples, "summary": summary,
108
+ "analysis_ms": round((time.perf_counter() - analysis_started) * 1000, 3),
109
+ }
110
+
111
+
112
+ def subscribe_whole_quote(code_list, callback, sample_limit=None):
113
+ """Independent diagnostic subscription. Callback receives a measured report.
114
+
115
+ Use xtdata.unsubscribe_quote(id) to stop. Internal latency includes encoding,
116
+ transport, decoding and callback scheduling, ending at this Python callback.
117
+ """
118
+ if not callable(callback):
119
+ raise TypeError("callback must be callable")
120
+ sample_limit = None if sample_limit is None else max(1, int(sample_limit))
121
+
122
+ def on_quote(payload):
123
+ received_at_ms = time.time() * 1000.0
124
+ callback(latency_report(payload, received_at_ms, sample_limit))
125
+
126
+ return xtdata._subscribe_quote_request(ACTION, {"code_list": code_list}, on_quote)