cfquant 0.2.24__tar.gz → 0.2.35__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 (155) hide show
  1. {cfquant-0.2.24 → cfquant-0.2.35}/PKG-INFO +3 -3
  2. {cfquant-0.2.24 → cfquant-0.2.35}/README.md +2 -2
  3. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/_editable_install.py +19 -0
  4. cfquant-0.2.35/cfquant/log_management.py +97 -0
  5. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/normal_bridge.py +34 -0
  6. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/qmt_bridge.py +4 -0
  7. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/qmt_strategy_deploy.py +53 -8
  8. cfquant-0.2.35/cfquant/tests/32_/345/274/202/346/255/245/344/270/213/345/215/225/346/265/213/350/257/225.py +176 -0
  9. cfquant-0.2.35/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 +160 -0
  10. cfquant-0.2.35/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 +12 -0
  11. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_api_tester_web.py +19 -0
  12. cfquant-0.2.35/cfquant/tests/test_log_management.py +32 -0
  13. cfquant-0.2.35/cfquant/tests/test_management_batch.py +55 -0
  14. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_qmt_strategy_deploy.py +17 -0
  15. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_qmt_strategy_web.py +8 -2
  16. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tx.py +12 -4
  17. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tx_trade_bridge.py +4 -0
  18. cfquant-0.2.35/cfquant/version.py +5 -0
  19. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/xttrader.py +77 -22
  20. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/xttype.py +273 -15
  21. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant.egg-info/PKG-INFO +3 -3
  22. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant.egg-info/SOURCES.txt +6 -0
  23. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant_web_server.py +261 -70
  24. {cfquant-0.2.24 → cfquant-0.2.35}/pyproject.toml +1 -1
  25. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/app.js +476 -38
  26. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/index.html +31 -4
  27. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/python-api.js +3 -1
  28. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/styles.css +5597 -5562
  29. cfquant-0.2.24/cfquant/version.py +0 -5
  30. {cfquant-0.2.24 → cfquant-0.2.35}/LICENSE +0 -0
  31. {cfquant-0.2.24 → cfquant-0.2.35}/LTtx/__init__.py +0 -0
  32. {cfquant-0.2.24 → cfquant-0.2.35}/LTtx/tx/Config.txt +0 -0
  33. {cfquant-0.2.24 → cfquant-0.2.35}/LTtx/tx/LTtx_server.py +0 -0
  34. {cfquant-0.2.24 → cfquant-0.2.35}/LTtx/tx/__init__.py +0 -0
  35. {cfquant-0.2.24 → cfquant-0.2.35}/LTtx/tx/tx.py +0 -0
  36. {cfquant-0.2.24 → cfquant-0.2.35}/MANIFEST.in +0 -0
  37. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/__init__.py +0 -0
  38. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/account_routing.py +0 -0
  39. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/batch_orders.py +0 -0
  40. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/cftrader.py +0 -0
  41. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/channels.py +0 -0
  42. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/cli.py +0 -0
  43. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/client.py +0 -0
  44. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/config.py +0 -0
  45. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/hub.py +0 -0
  46. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/level2.py +0 -0
  47. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/logging_i18n.py +0 -0
  48. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/order_meta.py +0 -0
  49. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/pipe_bridge.py +0 -0
  50. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/pipe_client.py +0 -0
  51. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/pipe_hub.py +0 -0
  52. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/pipe_transport.py +0 -0
  53. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/protocol.py +0 -0
  54. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/qmt_strategy_package.py +0 -0
  55. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/qmt_strategy_runtime.py +0 -0
  56. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/runtime_report.py +0 -0
  57. {cfquant-0.2.24 → cfquant-0.2.35}/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
  58. {cfquant-0.2.24 → cfquant-0.2.35}/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
  59. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/12_/350/256/242/345/215/225/345/244/207/346/263/250/346/265/213/350/257/225.py" +0 -0
  60. {cfquant-0.2.24 → cfquant-0.2.35}/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
  61. {cfquant-0.2.24 → cfquant-0.2.35}/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
  62. {cfquant-0.2.24 → cfquant-0.2.35}/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
  63. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/16_/345/220/257/345/212/250/345/256/211/350/243/205/346/265/213/350/257/225.py" +0 -0
  64. {cfquant-0.2.24 → cfquant-0.2.35}/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
  65. {cfquant-0.2.24 → cfquant-0.2.35}/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
  66. {cfquant-0.2.24 → cfquant-0.2.35}/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
  67. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/1_/350/241/214/346/203/205/346/216/245/346/224/266/346/265/213/350/257/225.py" +0 -0
  68. {cfquant-0.2.24 → cfquant-0.2.35}/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
  69. {cfquant-0.2.24 → cfquant-0.2.35}/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
  70. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/22_/350/277/236/346/216/245/350/257/212/346/226/255/346/265/213/350/257/225.py" +0 -0
  71. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/23_/347/244/272/344/276/213/351/205/215/347/275/256/346/265/213/350/257/225.py" +0 -0
  72. {cfquant-0.2.24 → cfquant-0.2.35}/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
  73. {cfquant-0.2.24 → cfquant-0.2.35}/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
  74. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/26_/345/205/250/346/216/250/350/256/242/351/230/205/346/265/213/350/257/225.py" +0 -0
  75. {cfquant-0.2.24 → cfquant-0.2.35}/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 -0
  76. {cfquant-0.2.24 → cfquant-0.2.35}/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
  77. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/29_Level2/350/241/214/346/203/205/346/265/213/350/257/225.py" +0 -0
  78. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/2_/346/225/260/346/215/256/350/216/267/345/217/226/346/265/213/350/257/225.py" +0 -0
  79. {cfquant-0.2.24 → cfquant-0.2.35}/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
  80. {cfquant-0.2.24 → cfquant-0.2.35}/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
  81. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/3_/346/225/260/346/215/256/344/270/213/350/275/275/346/265/213/350/257/225.py" +0 -0
  82. {cfquant-0.2.24 → cfquant-0.2.35}/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
  83. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/5_/347/234/237/345/256/236/344/270/213/345/215/225/346/265/213/350/257/225.py" +0 -0
  84. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/6_/345/233/236/350/260/203/346/265/213/350/257/225.py" +0 -0
  85. {cfquant-0.2.24 → cfquant-0.2.35}/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
  86. {cfquant-0.2.24 → cfquant-0.2.35}/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
  87. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/9_/345/217/257/346/222/244/350/256/242/345/215/225/346/265/213/350/257/225.py" +0 -0
  88. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/_helpers.py +0 -0
  89. {cfquant-0.2.24 → cfquant-0.2.35}/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
  90. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/readme.md +0 -0
  91. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_api_tester_browser.py +0 -0
  92. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_binding_delete_confirmation.py +0 -0
  93. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_cftrader.py +0 -0
  94. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_cftrader_tutorial.py +0 -0
  95. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_cftrader_web.py +0 -0
  96. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_credit_compat.py +0 -0
  97. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_json_serialization.py +0 -0
  98. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_project_updater.py +0 -0
  99. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_python_api_reference.py +0 -0
  100. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_qmt_strategy_browser.py +0 -0
  101. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_tutorial_reader.py +0 -0
  102. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/tests/test_tx_auto_import.py +0 -0
  103. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/xtconstant.py +0 -0
  104. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant/xtdata.py +0 -0
  105. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant.egg-info/dependency_links.txt +0 -0
  106. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant.egg-info/entry_points.txt +0 -0
  107. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant.egg-info/requires.txt +0 -0
  108. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant.egg-info/top_level.txt +0 -0
  109. {cfquant-0.2.24 → cfquant-0.2.35}/cfquant_pipe_hub.py +0 -0
  110. {cfquant-0.2.24 → cfquant-0.2.35}/qmt_scripts/CFQUANT.py +0 -0
  111. {cfquant-0.2.24 → cfquant-0.2.35}/qmt_scripts/CFQUANT_CTYPE_ALL_LOWLAT.py +0 -0
  112. {cfquant-0.2.24 → cfquant-0.2.35}/qmt_scripts/CFQUANT_LITE.py +0 -0
  113. {cfquant-0.2.24 → cfquant-0.2.35}/qmt_scripts/CFQUANT_TRADE_LOWLAT.py +0 -0
  114. {cfquant-0.2.24 → cfquant-0.2.35}/qmt_scripts/__init__.py +0 -0
  115. {cfquant-0.2.24 → cfquant-0.2.35}/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" +0 -0
  116. {cfquant-0.2.24 → cfquant-0.2.35}/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" +0 -0
  117. {cfquant-0.2.24 → cfquant-0.2.35}/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" +0 -0
  118. {cfquant-0.2.24 → cfquant-0.2.35}/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" +0 -0
  119. {cfquant-0.2.24 → cfquant-0.2.35}/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
  120. {cfquant-0.2.24 → cfquant-0.2.35}/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
  121. {cfquant-0.2.24 → cfquant-0.2.35}/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
  122. {cfquant-0.2.24 → cfquant-0.2.35}/requirements.txt +0 -0
  123. {cfquant-0.2.24 → cfquant-0.2.35}/setup.cfg +0 -0
  124. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/__init__.py +0 -0
  125. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/api-tester.js +0 -0
  126. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/avatars/amber-pulse.svg +0 -0
  127. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/avatars/copper-grid.svg +0 -0
  128. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/avatars/market-blue.svg +0 -0
  129. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/avatars/rose-circuit.svg +0 -0
  130. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/avatars/signal-green.svg +0 -0
  131. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/avatars/slate-wave.svg +0 -0
  132. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/avatars/teal-orbit.svg +0 -0
  133. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/avatars/violet-node.svg +0 -0
  134. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/cftrader-api-data.js +0 -0
  135. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/favicon.svg +0 -0
  136. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-10.png +0 -0
  137. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-11.png +0 -0
  138. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-12.png +0 -0
  139. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-13.png +0 -0
  140. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-14.png +0 -0
  141. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-15.png +0 -0
  142. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-16.png +0 -0
  143. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-17.png +0 -0
  144. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-18.png +0 -0
  145. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-19.png +0 -0
  146. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-20.png +0 -0
  147. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-21.png +0 -0
  148. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-4.png +0 -0
  149. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-5.png +0 -0
  150. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-6.png +0 -0
  151. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-7.png +0 -0
  152. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-8.png +0 -0
  153. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/image/qmt_deploy/image-9.png +0 -0
  154. {cfquant-0.2.24 → cfquant-0.2.35}/web_dashboard/python-api-data.js +0 -0
  155. {cfquant-0.2.24 → cfquant-0.2.35}/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.24
3
+ Version: 0.2.35
4
4
  Summary: QMT local bridge and xtquant-compatible Python client.
5
5
  License: MIT
6
6
  Requires-Python: >=3.8
@@ -123,7 +123,7 @@ cfquant run
123
123
  - 如果新版本修改了 `qmt_scripts/` 里的入口脚本,网页会提示你重新更新 QMT 侧脚本并重启对应 QMT 策略。
124
124
  - 建议把源码目录固定下来,例如 `D:\cfquant`,不要频繁挪动目录。
125
125
 
126
- 新用户建议先使用**通用模式**。一个 QMT 加载一个入口脚本即可完成大多数部署。
126
+ 新用户建议先使用**通用模式**。所有部署配置都在 Web 控制台完成,保存绑定后系统会自动准备并管理 QMT 托管策略。
127
127
 
128
128
  ### Web 控制台绑定与初始化流程
129
129
 
@@ -209,7 +209,7 @@ restart_cfquant.bat 重启
209
209
 
210
210
 
211
211
  ## 项目交流群
212
- <img src="631c6525c1a95aaf22ea46aeda3297f7.jpg" alt="cfquant 项目交流群二维码" width="280" />
212
+ <img src="docs/f5f1d14256e57023dd67513481f16718.jpg" alt="cfquant 项目交流群二维码" width="280" />
213
213
 
214
214
 
215
215
  ## 联系作者
@@ -101,7 +101,7 @@ cfquant run
101
101
  - 如果新版本修改了 `qmt_scripts/` 里的入口脚本,网页会提示你重新更新 QMT 侧脚本并重启对应 QMT 策略。
102
102
  - 建议把源码目录固定下来,例如 `D:\cfquant`,不要频繁挪动目录。
103
103
 
104
- 新用户建议先使用**通用模式**。一个 QMT 加载一个入口脚本即可完成大多数部署。
104
+ 新用户建议先使用**通用模式**。所有部署配置都在 Web 控制台完成,保存绑定后系统会自动准备并管理 QMT 托管策略。
105
105
 
106
106
  ### Web 控制台绑定与初始化流程
107
107
 
@@ -187,7 +187,7 @@ restart_cfquant.bat 重启
187
187
 
188
188
 
189
189
  ## 项目交流群
190
- <img src="631c6525c1a95aaf22ea46aeda3297f7.jpg" alt="cfquant 项目交流群二维码" width="280" />
190
+ <img src="docs/f5f1d14256e57023dd67513481f16718.jpg" alt="cfquant 项目交流群二维码" width="280" />
191
191
 
192
192
 
193
193
  ## 联系作者
@@ -11,6 +11,21 @@ from pathlib import Path
11
11
 
12
12
 
13
13
  PACKAGE_NAME = "cfquant"
14
+
15
+
16
+ def _hidden_subprocess_kwargs():
17
+ """Prevent pip/version helper processes from flashing console windows on Windows."""
18
+ if os.name != "nt":
19
+ return {}
20
+ kwargs = {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)}
21
+ try:
22
+ startupinfo = subprocess.STARTUPINFO()
23
+ startupinfo.dwFlags |= getattr(subprocess, "STARTF_USESHOWWINDOW", 1)
24
+ startupinfo.wShowWindow = getattr(subprocess, "SW_HIDE", 0)
25
+ kwargs["startupinfo"] = startupinfo
26
+ except Exception:
27
+ pass
28
+ return kwargs
14
29
  DEFAULT_PIP_INDEX_URL = "https://pypi.tuna.tsinghua.edu.cn/simple"
15
30
  PIP_INDEX_URL_ENV = "CFQUANT_PIP_INDEX_URL"
16
31
  INSTALLED_CHECK_CODE = (
@@ -122,6 +137,7 @@ def _run_pip_command(
122
137
  "errors": "replace",
123
138
  "timeout": float(timeout),
124
139
  }
140
+ kwargs.update(_hidden_subprocess_kwargs())
125
141
  if subprocess_kwargs:
126
142
  kwargs.update(dict(subprocess_kwargs))
127
143
  try:
@@ -326,6 +342,7 @@ def run_editable_install(
326
342
  "errors": "replace",
327
343
  "timeout": float(timeout),
328
344
  }
345
+ kwargs.update(_hidden_subprocess_kwargs())
329
346
  if subprocess_kwargs:
330
347
  kwargs.update(dict(subprocess_kwargs))
331
348
  try:
@@ -436,6 +453,7 @@ def run(args, cwd, quiet=False, clear_pythonpath=False):
436
453
  else python_environment(clear_pythonpath=clear_pythonpath)),
437
454
  stdout=stdout,
438
455
  stderr=stderr,
456
+ **_hidden_subprocess_kwargs(),
439
457
  )
440
458
 
441
459
 
@@ -468,6 +486,7 @@ def installed_package_version(python_exe=None):
468
486
  env=python_environment(clear_pythonpath=True),
469
487
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
470
488
  text=True, encoding="utf-8", timeout=30,
489
+ **_hidden_subprocess_kwargs(),
471
490
  )
472
491
  return completed.stdout.strip() if completed.returncode == 0 else ""
473
492
 
@@ -0,0 +1,97 @@
1
+ """Bounded local log browsing and rotation, independent of the web server."""
2
+ import datetime
3
+ import os
4
+ import re
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from pathlib import Path
9
+
10
+
11
+ def retention_days(value):
12
+ if isinstance(value, bool) or not re.fullmatch(r"[0-9]+", str(value)):
13
+ raise ValueError("retention_days must be an integer between 1 and 3650")
14
+ result = int(value)
15
+ if not 1 <= result <= 3650:
16
+ raise ValueError("retention_days must be between 1 and 3650")
17
+ return result
18
+
19
+
20
+ def log_files(root, day):
21
+ datetime.date.fromisoformat(day)
22
+ root = Path(root).resolve()
23
+ rows = []
24
+ if not root.is_dir():
25
+ return rows
26
+ # The managed log directory only; never traverse symlinks or user paths.
27
+ for current, dirs, files in os.walk(root, followlinks=False):
28
+ dirs[:] = [d for d in dirs if not Path(current, d).is_symlink()]
29
+ for name in files:
30
+ path = Path(current, name)
31
+ if path.is_symlink() or path.suffix.lower() != '.log':
32
+ continue
33
+ try:
34
+ stat = path.stat()
35
+ except OSError:
36
+ continue
37
+ modified = datetime.datetime.fromtimestamp(stat.st_mtime).date().isoformat()
38
+ dates = re.findall(r"(?<!\d)(20\d{2})[-_]?(\d{2})[-_]?(\d{2})(?!\d)", name)
39
+ if modified != day and day not in ['-'.join(parts) for parts in dates]:
40
+ continue
41
+ rows.append({'name': path.relative_to(root).as_posix(), 'size': stat.st_size,
42
+ 'modified': stat.st_mtime})
43
+ if len(rows) >= 1000:
44
+ return rows
45
+ return sorted(rows, key=lambda row: row['modified'], reverse=True)
46
+
47
+
48
+ def read_log(root, name, limit=256 * 1024):
49
+ root = Path(root).resolve()
50
+ path = (root / name).resolve()
51
+ if not name or root not in path.parents or path.suffix.lower() != '.log':
52
+ raise ValueError('Invalid log file')
53
+ with path.open('rb') as stream:
54
+ stream.seek(0, 2)
55
+ size = stream.tell()
56
+ stream.seek(max(0, size - limit))
57
+ data = stream.read(limit)
58
+ if size > limit and b'\n' in data:
59
+ data = data.split(b'\n', 1)[1]
60
+ return {'name': name, 'size': size, 'truncated': size > limit,
61
+ 'text': data.decode('utf-8', errors='replace')}
62
+
63
+
64
+ class RollingLogWriter:
65
+ """File-like sink: rotate daily or at 20 MiB, preserving open-stream users."""
66
+ encoding = 'utf-8'
67
+
68
+ def __init__(self, path, max_bytes=20 * 1024 * 1024):
69
+ self.path = Path(path)
70
+ self.max_bytes = max_bytes
71
+ self._lock = threading.RLock()
72
+ self._day = time.strftime('%Y-%m-%d', time.localtime(self.path.stat().st_mtime)) if self.path.exists() else time.strftime('%Y-%m-%d')
73
+ self._stream = self.path.open('a', encoding='utf-8', buffering=1)
74
+
75
+ def write(self, text):
76
+ with self._lock:
77
+ day = time.strftime('%Y-%m-%d')
78
+ if self._day != day or self._stream.tell() >= self.max_bytes:
79
+ self._stream.close()
80
+ try:
81
+ target = self.path.with_name('%s.%s.%s.log' % (self.path.stem, self._day, uuid.uuid4().hex[:12]))
82
+ self.path.rename(target)
83
+ self._day = day
84
+ finally:
85
+ self._stream = self.path.open('a', encoding='utf-8', buffering=1)
86
+ return self._stream.write(text)
87
+
88
+ def flush(self):
89
+ with self._lock:
90
+ self._stream.flush()
91
+
92
+ def close(self):
93
+ with self._lock:
94
+ self._stream.close()
95
+
96
+ def isatty(self):
97
+ return False
@@ -88,6 +88,8 @@ class NormalQmtBridge(TxTradeBridge):
88
88
  self.callback_asset_dedupe_lock = threading.RLock()
89
89
  self.callback_asset_fingerprints = {}
90
90
  self.callback_asset_dedupe_max = 4096
91
+ self.order_terminal_statuses = {}
92
+ self.order_terminal_statuses_lock = threading.RLock()
91
93
 
92
94
  def start(self):
93
95
  if self.running:
@@ -773,6 +775,8 @@ class NormalQmtBridge(TxTradeBridge):
773
775
  return
774
776
  if event_name == "trader:on_stock_order":
775
777
  data = self._format_trade_detail(obj, "order")
778
+ if not self._accept_order_callback(data):
779
+ return
776
780
  elif event_name == "trader:on_stock_trade":
777
781
  data = self._format_trade_detail(obj, "deal")
778
782
  else:
@@ -836,6 +840,36 @@ class NormalQmtBridge(TxTradeBridge):
836
840
  % (event_name, account_id or "-", not channel_duplicate, sent_clients, channel_duplicate and sent_clients == 0)
837
841
  )
838
842
 
843
+ def _accept_order_callback(self, data):
844
+ """Filter a stale partial-fill update emitted after a filled update."""
845
+ try:
846
+ status = int(data.get("order_status"))
847
+ except (TypeError, ValueError):
848
+ return True
849
+ partial = 55
850
+ succeeded = 56
851
+ if status not in (partial, succeeded):
852
+ return True
853
+ order_id = ""
854
+ for name in ("order_sysid", "order_id", "m_nRef", "m_nOrderID", "m_strOrderRef", "m_strOrderID"):
855
+ value = data.get(name)
856
+ if value not in (None, ""):
857
+ order_id = str(value).strip()
858
+ if order_id:
859
+ break
860
+ if not order_id:
861
+ return True
862
+ key = (str(data.get("account_id") or ""), order_id)
863
+ with self.order_terminal_statuses_lock:
864
+ if status == partial and self.order_terminal_statuses.get(key) == succeeded:
865
+ self._log("drop stale partial order callback account=%s order=%s" % key)
866
+ return False
867
+ if status == succeeded:
868
+ self.order_terminal_statuses[key] = succeeded
869
+ if len(self.order_terminal_statuses) > 4096:
870
+ self.order_terminal_statuses.pop(next(iter(self.order_terminal_statuses)))
871
+ return True
872
+
839
873
  def _duplicate_asset_callback(self, scope, event_name, account_id, account_type, data):
840
874
  if event_name != "trader:on_stock_asset" or not account_id:
841
875
  return False
@@ -1140,6 +1140,8 @@ class CfquantQmtBridge(object):
1140
1140
  "status_msg": self._first_value(obj, ("m_strStatusMsg", "m_strErrorMsg", "m_strCancelInfo", "m_strStatus", "m_strOrderStatus")),
1141
1141
  "strategy_name": self._get_value(obj, "m_strStrategyName"),
1142
1142
  "order_remark": self._first_value(obj, ("m_strRemark", "m_strOrderRemark")),
1143
+ "contract_no": self._first_value(obj, ("contract_no", "m_strCompactNo", "m_strContractNo", "m_strCompactID")),
1144
+ "stock_code1": self._first_value(obj, ("stock_code1", "m_stockCode", "m_strStockCode1", "m_strUnderCode")),
1143
1145
  "m_strAccountID": self._get_value(obj, "m_strAccountID"),
1144
1146
  "m_strInstrumentID": self._get_value(obj, "m_strInstrumentID"),
1145
1147
  "m_strExchangeID": self._get_value(obj, "m_strExchangeID"),
@@ -1213,6 +1215,8 @@ class CfquantQmtBridge(object):
1213
1215
  )),
1214
1216
  "strategy_name": self._get_value(obj, "m_strStrategyName"),
1215
1217
  "order_remark": self._first_value(obj, ("m_strRemark", "m_strOrderRemark")),
1218
+ "contract_no": self._first_value(obj, ("contract_no", "m_strCompactNo", "m_strContractNo", "m_strCompactID")),
1219
+ "stock_code1": self._first_value(obj, ("stock_code1", "m_stockCode", "m_strStockCode1", "m_strUnderCode")),
1216
1220
  "direction": self._get_value(obj, "m_nDirection"),
1217
1221
  "offset_flag": self._get_value(obj, "m_nOffsetFlag"),
1218
1222
  "price": self._get_value(obj, "m_dPrice"),
@@ -10,6 +10,7 @@ import re
10
10
  import threading
11
11
  import time
12
12
  import tokenize
13
+ import traceback
13
14
  import uuid
14
15
  from xml.dom import minidom
15
16
 
@@ -202,8 +203,13 @@ def _remove_formula_catalog_entries(document, names):
202
203
  for catalog_group in document.getElementsByTagName("FormulaCatalog"):
203
204
  for entry in list(catalog_group.getElementsByTagName("catalog")):
204
205
  if entry.getAttribute("name") in names:
205
- catalog_group.removeChild(entry)
206
- changed = True
206
+ # QMT versions differ in whether catalog entries are direct
207
+ # children of FormulaCatalog or nested inside a sub-group.
208
+ # Always remove from the node that actually owns the entry.
209
+ parent = entry.parentNode
210
+ if parent is not None:
211
+ parent.removeChild(entry)
212
+ changed = True
207
213
  return changed
208
214
 
209
215
 
@@ -329,8 +335,15 @@ class QmtStrategyManager:
329
335
  _write_json(self.path, {"version": 1, "jobs": self.jobs, "errors": self.errors})
330
336
 
331
337
  def report_error(self, account_key, error):
338
+ detail = str(error or "策略部署失败").strip() or "策略部署失败"
332
339
  with self.lock:
333
- self.errors[account_key] = str(error)
340
+ self.errors[account_key] = detail
341
+ # Keep the concrete exception on each affected job as well. The
342
+ # status endpoint previously returned only the generic state
343
+ # label, which made the Web UI hide the deployment root cause.
344
+ for job in self.jobs.values():
345
+ if job.get("account_key") == account_key:
346
+ job.update(state="error", error=detail, message="策略部署失败: " + detail, updated_at=time.time())
334
347
  self._save()
335
348
  return self.status(account_key)
336
349
 
@@ -338,6 +351,9 @@ class QmtStrategyManager:
338
351
  if job.get("state") == state and job.get("error", "") == error:
339
352
  return
340
353
  job.update(state=state, message=error or STATES[state], error=error, updated_at=time.time())
354
+ if state != "error":
355
+ job.pop("deploy_retry_count", None)
356
+ job.pop("deploy_retry_after", None)
341
357
 
342
358
  def _clear_generation_transition(self, job):
343
359
  changed = False
@@ -423,7 +439,8 @@ class QmtStrategyManager:
423
439
  "targets": [dict({k: job.get(k, "") for k in fields},
424
440
  strategies=[r["name"] for r in job.get("roles", [])]) for job in jobs]}
425
441
  if account_key in self.errors:
426
- result.update(error=self.errors[account_key], message="账号已保存,策略部署失败: " + self.errors[account_key])
442
+ detail = str(self.errors[account_key] or "策略部署失败")
443
+ result.update(error=detail, message="账号已保存,策略部署失败: " + detail)
427
444
  if self.load_error:
428
445
  result.update(error=self.load_error, message=self.load_error)
429
446
  return result
@@ -605,10 +622,26 @@ class QmtStrategyManager:
605
622
  if not job.get("control_generation") and not _generation_values(
606
623
  job.get("accepted_generations")):
607
624
  continue
625
+ retry_count = int(job.get("deploy_retry_count") or 0)
626
+ if retry_count >= 5 or time.time() < float(job.get("deploy_retry_after") or 0):
627
+ continue
608
628
  try:
609
629
  self._advance(job)
610
630
  except Exception as error:
611
- self._state(job, "error", str(error))
631
+ retry_count = int(job.get("deploy_retry_count") or 0) + 1
632
+ job["deploy_retry_count"] = retry_count
633
+ job["deploy_retry_after"] = time.time() + min(10.0, 2.0 * retry_count)
634
+ detail = str(error).strip()
635
+ if not detail:
636
+ trace = traceback.format_exc().strip().splitlines()
637
+ frames = [line.strip() for line in trace if line.strip().startswith("File ")]
638
+ location = frames[-1] if frames else ""
639
+ detail = "%s (%s)" % (type(error).__name__, location or repr(error))
640
+ if retry_count < 5:
641
+ detail = "部署写入暂时失败(第 %d/5 次),系统将自动重试:%s" % (retry_count, detail)
642
+ else:
643
+ detail = "部署连续失败 5 次,请关闭 QMT 并检查目录权限后重新保存:%s" % detail
644
+ self._state(job, "error", detail)
612
645
  changed = changed or before != job
613
646
  if changed:
614
647
  self._save()
@@ -666,11 +699,23 @@ class QmtStrategyManager:
666
699
  imported = name in catalog and (root / "python" / (name + ".py")).is_file()
667
700
  if not imported:
668
701
  if not role.get("queued"):
669
- if (root / "python" / (name + ".py")).exists() or name in catalog:
670
- raise ValueError("QMT 策略名称冲突: %s" % name)
702
+ generated = root / "python" / (name + ".py")
703
+ if generated.exists() or name in catalog:
704
+ # CFQ_* is cfquant's reserved namespace. A stale
705
+ # container/catalog entry can be left behind by a
706
+ # deployment on another machine; remove it while
707
+ # QMT is stopped and enqueue the current package.
708
+ # User strategies with other names still fail
709
+ # loudly instead of being overwritten.
710
+ if not _valid_managed_strategy_name(name):
711
+ raise ValueError("QMT 策略名称冲突: %s" % name)
712
+ changed = _remove_formula_catalog_entries(document, {name}) or changed
713
+ if generated.exists():
714
+ generated.unlink()
715
+ changed = True
671
716
  package = Path(role["package_path"]).read_bytes()
672
717
  if queue.exists() and queue.read_bytes() != package:
673
- if role.get("reimport_required"):
718
+ if _valid_managed_strategy_name(name) or role.get("reimport_required"):
674
719
  _atomic_write(queue, package)
675
720
  else:
676
721
  raise ValueError("QMT 导入队列存在同名文件: %s" % name)
@@ -0,0 +1,176 @@
1
+ # -*- coding: utf-8 -*-
2
+ """default 账号多笔并发异步下单及回调数量核对。
3
+
4
+ 运行:python -X utf8 "cfquant/tests/32_异步下单测试.py"
5
+ """
6
+ import json
7
+ import sys
8
+ import threading
9
+ import time
10
+ import uuid
11
+ from concurrent.futures import ThreadPoolExecutor, as_completed
12
+ from pathlib import Path
13
+
14
+ from _helpers import PROJECT_ROOT, configure_stdout
15
+ from cfquant import configure, xtconstant
16
+ from cfquant.xttrader import XtQuantTrader, XtQuantTraderCallback
17
+ from cfquant.xttype import StockAccount
18
+
19
+
20
+ CONFIG_FILE = PROJECT_ROOT / "runtime/config/cfquant_web_config.json"
21
+ BRIDGE_ID = "default"
22
+ TRANSPORT = "auto"
23
+ ORDER_COUNT = 10
24
+ MAX_WORKERS = 5
25
+ STOCK_CODE = "000001.SZ"
26
+ ORDER_TYPE = xtconstant.STOCK_BUY
27
+ ORDER_VOLUME = 100
28
+ PRICE_TYPE = xtconstant.FIX_PRICE
29
+ PRICE = 10.0
30
+ REQUEST_TIMEOUT = 15.0
31
+ CALLBACK_TIMEOUT = 30.0
32
+ EXTRA_OBSERVE_SECONDS = 3.0
33
+ STRATEGY_NAME = "cfquant_async32"
34
+ DRY_RUN = False
35
+
36
+ PRINT_LOCK = threading.Lock()
37
+
38
+
39
+ def emit(event, **fields):
40
+ with PRINT_LOCK:
41
+ print(json.dumps({"event": event, **fields}, ensure_ascii=False, default=str), flush=True)
42
+
43
+
44
+ def plain(value):
45
+ return dict(value) if isinstance(value, dict) else dict(vars(value))
46
+
47
+
48
+ def value_of(value, *names):
49
+ for name in names:
50
+ item = value.get(name) if isinstance(value, dict) else getattr(value, name, None)
51
+ if item not in (None, ""):
52
+ return item
53
+ return None
54
+
55
+
56
+ def load_default_account():
57
+ data = json.loads(CONFIG_FILE.read_text(encoding="utf-8-sig"))
58
+ key = str(data.get("default_account_key") or "default:STOCK:").strip()
59
+ configs = data.get("account_configs") or {}
60
+ row = configs.get(key)
61
+ if not row:
62
+ matches = [item for item in configs.values() if item.get("bridge_id") == BRIDGE_ID and item.get("enabled") is not False]
63
+ if len(matches) != 1:
64
+ raise RuntimeError("无法唯一确定 default 启用账号")
65
+ row = matches[0]
66
+ if row.get("bridge_id") != BRIDGE_ID or row.get("account_type", "").upper() != "STOCK":
67
+ raise RuntimeError("default 配置不是启用的 STOCK 账号")
68
+ return row
69
+
70
+
71
+ class Callback(XtQuantTraderCallback):
72
+ def __init__(self, account_id):
73
+ self.account_id = str(account_id)
74
+ self.lock = threading.Lock()
75
+ self.responses = []
76
+ self.errors = []
77
+
78
+ def on_order_stock_async_response(self, response):
79
+ row = plain(response)
80
+ with self.lock:
81
+ self.responses.append(row)
82
+ emit("异步委托回调", seq=row.get("seq"), order_id=row.get("order_id"), order_remark=row.get("order_remark"))
83
+
84
+ def on_order_error(self, error):
85
+ row = plain(error)
86
+ with self.lock:
87
+ self.errors.append(row)
88
+ emit("下单错误回调", **row)
89
+
90
+ def snapshot(self):
91
+ with self.lock:
92
+ return list(self.responses), list(self.errors)
93
+
94
+
95
+ def main():
96
+ configure_stdout()
97
+ account_row = load_default_account()
98
+ account_id = str(account_row["account_id"])
99
+ remarks = ["async32_%s_%02d" % (uuid.uuid4().hex[:8], index + 1) for index in range(ORDER_COUNT)]
100
+ emit("测试配置", account_id=account_id, account_type=account_row.get("account_type"), bridge_id=BRIDGE_ID,
101
+ order_count=ORDER_COUNT, stock_code=STOCK_CODE, order_volume=ORDER_VOLUME, price=PRICE, dry_run=DRY_RUN)
102
+ if DRY_RUN:
103
+ return 0
104
+
105
+ configure(transport=TRANSPORT, bridge_id=BRIDGE_ID, timeout=REQUEST_TIMEOUT)
106
+ account = StockAccount(account_id, account_row["account_type"], BRIDGE_ID)
107
+ callback = Callback(account_id)
108
+ trader = XtQuantTrader(callback=callback, account=account)
109
+ trader.set_timeout(REQUEST_TIMEOUT)
110
+ submissions = []
111
+
112
+ def submit(index):
113
+ remark = remarks[index]
114
+ row = {"index": index + 1, "order_remark": remark}
115
+ try:
116
+ seq = trader.order_stock_async(account, STOCK_CODE, ORDER_TYPE, ORDER_VOLUME,
117
+ PRICE_TYPE, PRICE, STRATEGY_NAME, remark)
118
+ row.update(seq=seq, status="submitted" if seq not in (None, -1, "-1") else "rejected")
119
+ except Exception as error:
120
+ row.update(status="unknown", error="%s: %s" % (type(error).__name__, error))
121
+ emit("异步下单返回", **row)
122
+ return row
123
+
124
+ report_path = PROJECT_ROOT / "log" / ("async32_%s.json" % uuid.uuid4().hex[:10])
125
+ try:
126
+ trader.start()
127
+ if trader.connect() != 0:
128
+ raise RuntimeError("交易连接失败")
129
+ if trader.subscribe(account) != 0:
130
+ raise RuntimeError("账号订阅失败")
131
+ with ThreadPoolExecutor(max_workers=min(MAX_WORKERS, ORDER_COUNT)) as pool:
132
+ futures = [pool.submit(submit, index) for index in range(ORDER_COUNT)]
133
+ submissions = [future.result() for future in as_completed(futures)]
134
+
135
+ expected = {str(row["seq"]) for row in submissions if row["status"] == "submitted"}
136
+ deadline = time.time() + CALLBACK_TIMEOUT
137
+ while time.time() < deadline:
138
+ responses, _ = callback.snapshot()
139
+ received = {str(value_of(row, "seq", "m_nSeq")) for row in responses}
140
+ if expected.issubset(received):
141
+ break
142
+ time.sleep(0.1)
143
+ time.sleep(EXTRA_OBSERVE_SECONDS)
144
+ finally:
145
+ responses, errors = callback.snapshot()
146
+ expected = {str(row["seq"]) for row in submissions if row.get("status") == "submitted"}
147
+ received = [row for row in responses if str(value_of(row, "seq", "m_nSeq")) in expected]
148
+ counts = {}
149
+ for row in received:
150
+ seq = str(value_of(row, "seq", "m_nSeq"))
151
+ counts[seq] = counts.get(seq, 0) + 1
152
+ summary = {
153
+ "planned": ORDER_COUNT,
154
+ "submitted": len(expected),
155
+ "rejected": sum(row.get("status") == "rejected" for row in submissions),
156
+ "unknown": sum(row.get("status") == "unknown" for row in submissions),
157
+ "callback_count": len(received),
158
+ "missing_seqs": sorted(expected - set(counts)),
159
+ "duplicate_seqs": {seq: count for seq, count in counts.items() if count > 1},
160
+ "invalid_order_id_count": sum(value_of(row, "order_id", "m_nRef", "m_nOrderID") in (None, "", -1, "-1") for row in received),
161
+ }
162
+ summary["count_consistent"] = (
163
+ summary["submitted"] == summary["callback_count"]
164
+ and not summary["missing_seqs"]
165
+ and not summary["duplicate_seqs"]
166
+ )
167
+ report_path.parent.mkdir(parents=True, exist_ok=True)
168
+ report_path.write_text(json.dumps({"summary": summary, "submissions": submissions,
169
+ "responses": responses, "errors": errors}, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
170
+ emit("测试汇总", **summary, report=str(report_path))
171
+ trader.stop()
172
+ return 0 if summary["count_consistent"] else 1
173
+
174
+
175
+ if __name__ == "__main__":
176
+ sys.exit(main())