cfquant 0.2.52__tar.gz → 0.2.54__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 (203) hide show
  1. {cfquant-0.2.52 → cfquant-0.2.54}/MANIFEST.in +1 -0
  2. {cfquant-0.2.52 → cfquant-0.2.54}/PKG-INFO +3 -1
  3. cfquant-0.2.54/cfquant/callback_dispatcher.py +218 -0
  4. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/client.py +102 -30
  5. cfquant-0.2.54/cfquant/diagnostics.py +126 -0
  6. cfquant-0.2.54/cfquant/flow_control.py +171 -0
  7. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/normal_bridge.py +83 -21
  8. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/pipe_bridge.py +2 -2
  9. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/pipe_client.py +29 -9
  10. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/pipe_hub.py +109 -16
  11. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/pipe_transport.py +54 -13
  12. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/protocol.py +86 -11
  13. cfquant-0.2.54/cfquant/qmt_component_agent.js +249 -0
  14. cfquant-0.2.54/cfquant/qmt_component_login.py +310 -0
  15. cfquant-0.2.54/cfquant/qmt_credentials.py +72 -0
  16. cfquant-0.2.54/cfquant/quote_history.py +122 -0
  17. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/rpc_timing.py +1 -0
  18. {cfquant-0.2.52 → cfquant-0.2.54}/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 +1 -1
  19. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/26_/345/205/250/346/216/250/350/256/242/351/230/205/346/265/213/350/257/225.py +149 -1
  20. cfquant-0.2.54/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
  21. cfquant-0.2.54/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
  22. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_api_tester_browser.py +6 -3
  23. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_callback_requests.py +43 -1
  24. cfquant-0.2.54/cfquant/tests/test_latency_flow_control.py +601 -0
  25. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_lite_deployment_paths.py +1 -0
  26. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_lite_qmt_dispatch.py +82 -0
  27. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_management.py +7 -2
  28. cfquant-0.2.54/cfquant/tests/test_qmt_auto_login_browser.py +175 -0
  29. cfquant-0.2.54/cfquant/tests/test_qmt_auto_login_integration.py +356 -0
  30. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_qmt_binding_settings.py +2 -2
  31. cfquant-0.2.54/cfquant/tests/test_qmt_component_login.py +165 -0
  32. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_qmt_strategy_browser.py +15 -6
  33. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_qmt_strategy_web.py +44 -34
  34. cfquant-0.2.54/cfquant/tests/test_quote_latency.py +235 -0
  35. cfquant-0.2.54/cfquant/tests/test_quote_latency_encoding.py +107 -0
  36. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_setup_qmt_prompt.py +1 -0
  37. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_stock_connect.py +1 -1
  38. cfquant-0.2.54/cfquant/tests/test_tx_shutdown.py +377 -0
  39. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/trading_calendar.py +8 -0
  40. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tx.py +1942 -1880
  41. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/version.py +2 -2
  42. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/xtdata.py +16 -5
  43. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/xttrader.py +16 -9
  44. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant.egg-info/PKG-INFO +3 -1
  45. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant.egg-info/SOURCES.txt +14 -0
  46. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant.egg-info/requires.txt +4 -0
  47. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant_web_server.py +1426 -1043
  48. {cfquant-0.2.52 → cfquant-0.2.54}/pyproject.toml +4 -1
  49. {cfquant-0.2.52 → cfquant-0.2.54}/qmt_scripts/CFQUANT_CTYPE_ALL_LOWLAT.py +2 -2
  50. {cfquant-0.2.52 → cfquant-0.2.54}/qmt_scripts/CFQUANT_LITE.py +313 -41
  51. {cfquant-0.2.52 → cfquant-0.2.54}/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 +2 -2
  52. {cfquant-0.2.52 → cfquant-0.2.54}/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 +2 -2
  53. {cfquant-0.2.52 → cfquant-0.2.54}/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 +313 -41
  54. {cfquant-0.2.52 → cfquant-0.2.54}/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 +313 -41
  55. {cfquant-0.2.52 → cfquant-0.2.54}/requirements.txt +2 -0
  56. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/app.js +453 -30
  57. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/index.html +72 -37
  58. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/python-api-data.js +2 -2
  59. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/styles.css +85 -11
  60. cfquant-0.2.52/cfquant/callback_dispatcher.py +0 -94
  61. cfquant-0.2.52/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
  62. {cfquant-0.2.52 → cfquant-0.2.54}/LICENSE +0 -0
  63. {cfquant-0.2.52 → cfquant-0.2.54}/LTtx/__init__.py +0 -0
  64. {cfquant-0.2.52 → cfquant-0.2.54}/LTtx/tx/Config.txt +0 -0
  65. {cfquant-0.2.52 → cfquant-0.2.54}/LTtx/tx/LTtx_server.py +0 -0
  66. {cfquant-0.2.52 → cfquant-0.2.54}/LTtx/tx/__init__.py +0 -0
  67. {cfquant-0.2.52 → cfquant-0.2.54}/LTtx/tx/tx.py +0 -0
  68. {cfquant-0.2.52 → cfquant-0.2.54}/README.md +0 -0
  69. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/__init__.py +0 -0
  70. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/_editable_install.py +0 -0
  71. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/account_routing.py +0 -0
  72. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/batch_orders.py +0 -0
  73. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/build_info.py +0 -0
  74. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/cftrader.py +0 -0
  75. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/channels.py +0 -0
  76. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/cli.py +0 -0
  77. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/config.py +0 -0
  78. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/hub.py +0 -0
  79. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/level2.py +0 -0
  80. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/log_management.py +0 -0
  81. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/logging_i18n.py +0 -0
  82. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/management.py +0 -0
  83. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/order_identity.py +0 -0
  84. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/order_meta.py +0 -0
  85. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/qmt_binding_settings.py +0 -0
  86. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/qmt_bridge.py +0 -0
  87. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/qmt_pipe_runtime.py +0 -0
  88. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/qmt_strategy_deploy.py +0 -0
  89. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/qmt_strategy_package.py +0 -0
  90. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/qmt_strategy_runtime.py +0 -0
  91. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/runtime_report.py +0 -0
  92. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/stock_connect.py +0 -0
  93. {cfquant-0.2.52 → cfquant-0.2.54}/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
  94. {cfquant-0.2.52 → cfquant-0.2.54}/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
  95. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/12_/350/256/242/345/215/225/345/244/207/346/263/250/346/265/213/350/257/225.py" +0 -0
  96. {cfquant-0.2.52 → cfquant-0.2.54}/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
  97. {cfquant-0.2.52 → cfquant-0.2.54}/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
  98. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/16_/345/220/257/345/212/250/345/256/211/350/243/205/346/265/213/350/257/225.py" +0 -0
  99. {cfquant-0.2.52 → cfquant-0.2.54}/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
  100. {cfquant-0.2.52 → cfquant-0.2.54}/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
  101. {cfquant-0.2.52 → cfquant-0.2.54}/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
  102. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/1_/350/241/214/346/203/205/346/216/245/346/224/266/346/265/213/350/257/225.py" +0 -0
  103. {cfquant-0.2.52 → cfquant-0.2.54}/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
  104. {cfquant-0.2.52 → cfquant-0.2.54}/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
  105. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/22_/350/277/236/346/216/245/350/257/212/346/226/255/346/265/213/350/257/225.py" +0 -0
  106. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/23_/347/244/272/344/276/213/351/205/215/347/275/256/346/265/213/350/257/225.py" +0 -0
  107. {cfquant-0.2.52 → cfquant-0.2.54}/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
  108. {cfquant-0.2.52 → cfquant-0.2.54}/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
  109. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/28_Level2/346/216/245/345/217/243/351/200/202/351/205/215/346/265/213/350/257/225.py" +0 -0
  110. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/29_Level2/350/241/214/346/203/205/346/265/213/350/257/225.py" +0 -0
  111. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/2_/346/225/260/346/215/256/350/216/267/345/217/226/346/265/213/350/257/225.py" +0 -0
  112. {cfquant-0.2.52 → cfquant-0.2.54}/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
  113. {cfquant-0.2.52 → cfquant-0.2.54}/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
  114. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/32_/345/274/202/346/255/245/344/270/213/345/215/225/346/265/213/350/257/225.py" +0 -0
  115. {cfquant-0.2.52 → cfquant-0.2.54}/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
  116. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/3_/346/225/260/346/215/256/344/270/213/350/275/275/346/265/213/350/257/225.py" +0 -0
  117. {cfquant-0.2.52 → cfquant-0.2.54}/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
  118. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/5_/347/234/237/345/256/236/344/270/213/345/215/225/346/265/213/350/257/225.py" +0 -0
  119. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/6_/345/233/236/350/260/203/346/265/213/350/257/225.py" +0 -0
  120. {cfquant-0.2.52 → cfquant-0.2.54}/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
  121. {cfquant-0.2.52 → cfquant-0.2.54}/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
  122. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/9_/345/217/257/346/222/244/350/256/242/345/215/225/346/265/213/350/257/225.py" +0 -0
  123. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/_helpers.py +0 -0
  124. {cfquant-0.2.52 → cfquant-0.2.54}/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
  125. {cfquant-0.2.52 → cfquant-0.2.54}/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
  126. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/readme.md +0 -0
  127. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_advanced_parity.py +0 -0
  128. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_api_tester_web.py +0 -0
  129. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_binding_delete_confirmation.py +0 -0
  130. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_binding_runtime_browser.py +0 -0
  131. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_cftrader.py +0 -0
  132. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_cftrader_tutorial.py +0 -0
  133. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_cftrader_web.py +0 -0
  134. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_credit_compat.py +0 -0
  135. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_dataframe_encoding.py +0 -0
  136. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_json_serialization.py +0 -0
  137. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_lite_account_routing.py +0 -0
  138. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_lite_parity.py +0 -0
  139. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_local_trading_calendar.py +0 -0
  140. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_log_management.py +0 -0
  141. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_management_batch.py +0 -0
  142. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_market_data_fallback.py +0 -0
  143. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_market_timing.py +0 -0
  144. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_order_identity.py +0 -0
  145. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_order_time_callback.py +0 -0
  146. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_project_updater.py +0 -0
  147. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_python_api_reference.py +0 -0
  148. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_qmt_fileless.py +0 -0
  149. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_qmt_strategy_deploy.py +0 -0
  150. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_startup_recovery.py +0 -0
  151. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_stock_connect_browser.py +0 -0
  152. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_table_conversion.py +0 -0
  153. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_trade_strategy_callback.py +0 -0
  154. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_trading_dates.py +0 -0
  155. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_tutorial_reader.py +0 -0
  156. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tests/test_tx_auto_import.py +0 -0
  157. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/tx_trade_bridge.py +0 -0
  158. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/xtconstant.py +0 -0
  159. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant/xttype.py +0 -0
  160. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant.egg-info/dependency_links.txt +0 -0
  161. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant.egg-info/entry_points.txt +0 -0
  162. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant.egg-info/top_level.txt +0 -0
  163. {cfquant-0.2.52 → cfquant-0.2.54}/cfquant_pipe_hub.py +0 -0
  164. {cfquant-0.2.52 → cfquant-0.2.54}/qmt_scripts/CFQUANT.py +0 -0
  165. {cfquant-0.2.52 → cfquant-0.2.54}/qmt_scripts/CFQUANT_TRADE_LOWLAT.py +0 -0
  166. {cfquant-0.2.52 → cfquant-0.2.54}/qmt_scripts/__init__.py +0 -0
  167. {cfquant-0.2.52 → cfquant-0.2.54}/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
  168. {cfquant-0.2.52 → cfquant-0.2.54}/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
  169. {cfquant-0.2.52 → cfquant-0.2.54}/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
  170. {cfquant-0.2.52 → cfquant-0.2.54}/setup.cfg +0 -0
  171. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/__init__.py +0 -0
  172. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/api-tester.js +0 -0
  173. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/avatars/amber-pulse.svg +0 -0
  174. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/avatars/copper-grid.svg +0 -0
  175. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/avatars/market-blue.svg +0 -0
  176. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/avatars/rose-circuit.svg +0 -0
  177. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/avatars/signal-green.svg +0 -0
  178. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/avatars/slate-wave.svg +0 -0
  179. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/avatars/teal-orbit.svg +0 -0
  180. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/avatars/violet-node.svg +0 -0
  181. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/cftrader-api-data.js +0 -0
  182. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/docs.js +0 -0
  183. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/favicon.svg +0 -0
  184. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-10.png +0 -0
  185. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-11.png +0 -0
  186. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-12.png +0 -0
  187. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-13.png +0 -0
  188. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-14.png +0 -0
  189. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-15.png +0 -0
  190. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-16.png +0 -0
  191. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-17.png +0 -0
  192. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-18.png +0 -0
  193. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-19.png +0 -0
  194. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-20.png +0 -0
  195. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-21.png +0 -0
  196. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-4.png +0 -0
  197. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-5.png +0 -0
  198. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-6.png +0 -0
  199. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-7.png +0 -0
  200. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-8.png +0 -0
  201. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/image/qmt_deploy/image-9.png +0 -0
  202. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/python-api.js +0 -0
  203. {cfquant-0.2.52 → cfquant-0.2.54}/web_dashboard/vendor/mermaid.min.js +0 -0
@@ -4,6 +4,7 @@ include cfquant_web_server.py
4
4
  include cfquant_pipe_hub.py
5
5
 
6
6
  recursive-include cfquant *.py
7
+ include cfquant/qmt_component_agent.js
7
8
  recursive-include cfquant/tests *.py *.md
8
9
  recursive-include LTtx *.py
9
10
  include LTtx/tx/Config.txt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cfquant
3
- Version: 0.2.52
3
+ Version: 0.2.54
4
4
  Summary: QMT local bridge and xtquant-compatible Python client.
5
5
  License: MIT
6
6
  Requires-Python: >=3.8
@@ -14,6 +14,8 @@ Requires-Dist: pytz
14
14
  Requires-Dist: tabulate
15
15
  Requires-Dist: pandas-market-calendars
16
16
  Requires-Dist: importlib-resources>=5
17
+ Requires-Dist: frida<18,>=17.0; sys_platform == "win32"
18
+ Requires-Dist: pywin32>=306; sys_platform == "win32"
17
19
  Provides-Extra: dev
18
20
  Requires-Dist: build>=1.0; extra == "dev"
19
21
  Requires-Dist: twine>=5.0; extra == "dev"
@@ -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
@@ -15,10 +16,23 @@ class CfquantError(RuntimeError):
15
16
  pass
16
17
 
17
18
 
18
- class CfquantTimeout(TimeoutError):
19
- pass
20
-
21
-
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
+
22
36
  PIPE_TRANSPORTS = ("pipe", "ctypes", "named_pipe", "named-pipe")
23
37
  WEB_LTTX_TRANSPORTS = ("web", "web_lttx", "lttx_web", "web-lttx", "lttx-web")
24
38
 
@@ -114,9 +128,10 @@ class LTtxRpcClient(object):
114
128
  self._callbacks = {}
115
129
  self._lock = threading.RLock()
116
130
  self._pending_lock = threading.RLock()
117
- self._started = False
131
+ self._started = False
132
+ self._closed = False
118
133
  self._recv_thread = None
119
- self._event_dispatcher = CallbackDispatcher(self._dispatch_event)
134
+ self._event_dispatcher = CallbackDispatcher(self._dispatch_event, self._callback_buffer_failed)
120
135
 
121
136
  def start(self):
122
137
  with self._lock:
@@ -124,19 +139,37 @@ class LTtxRpcClient(object):
124
139
  if self._started:
125
140
  return
126
141
  txl = self._load_txl()
127
- self._tx = txl(self.host, self.port, self.token)
128
- self._tx.start_tx()
129
- self._tx.start_txg(self.client_id)
130
- 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
131
155
  generation = self._event_dispatcher.start()
132
156
  self._recv_thread = threading.Thread(target=self._recv_loop, args=(self._tx, generation))
133
157
  self._recv_thread.daemon = True
134
158
  self._recv_thread.start()
135
159
 
136
- def close(self):
137
- with self._lock:
138
- self._event_dispatcher.stop()
139
- 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
140
173
  tx = self._tx
141
174
  self._tx = None
142
175
  if tx is not None:
@@ -144,10 +177,11 @@ class LTtxRpcClient(object):
144
177
  tx.Q.put(None)
145
178
  except Exception:
146
179
  pass
147
- try:
148
- tx.close()
149
- except Exception:
150
- pass
180
+ if tx is not starting_tx:
181
+ try:
182
+ tx.close()
183
+ except Exception:
184
+ pass
151
185
  with self._pending_lock:
152
186
  for q in list(self._pending.values()):
153
187
  try:
@@ -176,7 +210,7 @@ class LTtxRpcClient(object):
176
210
  self._pending[request_id] = q
177
211
  self._push("request", raw, request_channel or self.request_channel)
178
212
  try:
179
- msg = q.get(timeout=effective_timeout)
213
+ msg = _wait_response(q, effective_timeout)
180
214
  except queue.Empty:
181
215
  raise CfquantTimeout("cfquant request timeout: %s" % action)
182
216
  timing.received(msg)
@@ -184,6 +218,9 @@ class LTtxRpcClient(object):
184
218
  err = msg.get("error") or {}
185
219
  raise CfquantError(err.get("message") or str(err))
186
220
  return timing.decode(msg.get("result"), decode_value)
221
+ except (KeyboardInterrupt, SystemExit):
222
+ self.close()
223
+ raise
187
224
  finally:
188
225
  timing.finish()
189
226
  with self._pending_lock:
@@ -213,20 +250,26 @@ class LTtxRpcClient(object):
213
250
  raw = tx.Q.get()
214
251
  if raw is None:
215
252
  break
216
- msg = loads_message(raw)
253
+ parse_started = time.perf_counter()
254
+ msg = loads_message(raw)
217
255
  if not msg:
218
256
  continue
219
257
  with self._lock:
220
258
  if not self._started or tx is not self._tx:
221
259
  return
222
260
  msg_type = msg.get("type")
223
- if msg_type == "response":
261
+ if msg_type == "response":
262
+ msg["_client_parse_ms"] = round((time.perf_counter() - parse_started) * 1000, 3)
224
263
  with self._pending_lock:
225
264
  q = self._pending.pop(msg.get("id"), None)
226
265
  if q:
227
266
  q.put(msg)
228
- elif msg_type == "event":
229
- 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
230
273
  except Exception:
231
274
  time.sleep(0.05)
232
275
  if not self._started:
@@ -235,8 +278,25 @@ class LTtxRpcClient(object):
235
278
  if self._tx is expected_tx:
236
279
  self.close()
237
280
 
238
- def _dispatch_event(self, msg):
239
- 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()
240
300
 
241
301
  def _push(self, key, payload, channel):
242
302
  tx = self._tx
@@ -337,7 +397,7 @@ _default_client = None
337
397
  _client_lock = threading.Lock()
338
398
 
339
399
 
340
- def get_client():
400
+ def get_client():
341
401
  global _default_client
342
402
  with _client_lock:
343
403
  if _default_client is None:
@@ -347,9 +407,21 @@ def get_client():
347
407
  timeout=cfg.get("timeout"),
348
408
  client_id=cfg.get("client_id"),
349
409
  )
350
- return _default_client
351
-
352
-
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
+
353
425
  def configure(**kwargs):
354
426
  from .config import configure as configure_config
355
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)