feat(selection): 集成 B1 FastDTW 图形评分
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# FastDTW v1 离线基线
|
||||
|
||||
十个 CSV 仅保留原项目固定案例突破日前最后 25 个升序交易日,测试运行不读取原项目、网络或数据库。`golden.json` 使用修正后可工作的 FastDTW、标量欧氏距离与 `radius=1` 离线生成;它有意不兼容原项目实际执行的 simple-DTW fallback。
|
||||
|
||||
25 行窗口不足以产生 114 日多空线。领域 extractor 将这些旧公式产生的非有限中间值显式转换为 `None`,matcher 按旧比较的最终效果记为零相似度,保证 dataclass、JSONB 和 HTTP 不包含 `NaN`/`Infinity`。案例库现在要求十例各 25 行完整 OHLCV,不再静默接受部分案例。
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-04-01,27.93,29.03,27.8,28.94,27218.84,5612754000
|
||||
2025-04-02,28.9,29.18,28.69,28.94,12933.01,5612754000
|
||||
2025-04-03,28.71,29.07,28.54,28.73,11232.75,5612754000
|
||||
2025-04-07,27.93,27.93,23.19,24.02,37675.08,5612754000
|
||||
2025-04-08,24.03,25.03,24.03,24.86,16676.65,5612754000
|
||||
2025-04-09,24.49,24.87,23.12,24.73,14468.45,5612754000
|
||||
2025-04-10,24.96,25.53,24.89,25.1,11065.87,5612754000
|
||||
2025-04-11,25.02,25.91,24.7,25.67,11201.14,5612754000
|
||||
2025-04-14,25.75,26.88,25.75,26.29,14566.38,5612754000
|
||||
2025-04-15,26.41,27.19,26.09,26.17,10132.02,5612754000
|
||||
2025-04-16,26.04,26.71,25.88,26.38,13525.38,5612754000
|
||||
2025-04-17,26.11,28.69,26.04,28.39,42729.26,5612754000
|
||||
2025-04-18,28.83,29.54,27.91,28.51,48214.96,5612754000
|
||||
2025-04-21,28.83,31.25,27.94,30.52,96121.97,5612754000
|
||||
2025-04-22,30.52,35.02,30.52,32.77,148408.79,5612754000
|
||||
2025-04-23,32.43,33.71,30.92,32.34,56626.78,5612754000
|
||||
2025-04-24,32.44,34.67,32.44,34.18,48618.11,5612754000
|
||||
2025-04-25,34.18,34.55,30.24,30.67,75477.69,5612754000
|
||||
2025-04-28,30.93,32.63,30.24,31.15,62031.09,5612754000
|
||||
2025-04-29,31.8,32.54,30.97,31.39,34211.51,5612754000
|
||||
2025-04-30,32.02,32.02,29.92,30.19,48359.44,5612754000
|
||||
2025-05-06,30.24,30.62,29.2,29.5,36216.67,5612754000
|
||||
2025-05-07,29.67,30.34,29.18,29.54,26316.22,5612754000
|
||||
2025-05-08,29.54,29.94,29.26,29.82,20883.91,5612754000
|
||||
2025-05-09,29.82,30.44,29.32,29.44,16659.71,5612754000
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-07-02,10.88,11.1,10.57,10.67,1287084.86,12045490456
|
||||
2025-07-03,10.62,10.9,10.54,10.82,991702.0,12045490456
|
||||
2025-07-04,10.83,10.88,10.4,10.45,855100.82,12045490456
|
||||
2025-07-07,10.28,11.26,10.28,10.98,1216456.82,12045490456
|
||||
2025-07-08,10.89,11.52,10.81,11.15,1558398.35,12045490456
|
||||
2025-07-09,11.18,11.23,10.79,10.84,1033990.38,12045490456
|
||||
2025-07-10,11.27,11.86,10.93,11.64,2056513.24,12045490456
|
||||
2025-07-11,11.87,12.46,11.53,12.13,2320402.62,12045490456
|
||||
2025-07-14,12.19,12.46,11.57,11.62,1491417.84,12045490456
|
||||
2025-07-15,11.58,12.78,11.58,12.2,2460847.71,12045490456
|
||||
2025-07-16,11.98,12.04,11.27,11.32,1938934.42,12045490456
|
||||
2025-07-17,11.08,11.49,10.97,11.4,1018625.42,12045490456
|
||||
2025-07-18,11.34,12.14,11.32,11.68,1574602.28,12045490456
|
||||
2025-07-21,11.6,11.98,11.57,11.8,1226347.09,12045490456
|
||||
2025-07-22,11.68,12.0,11.47,11.56,985223.02,12045490456
|
||||
2025-07-23,11.46,11.73,11.2,11.5,751845.98,12045490456
|
||||
2025-07-24,11.42,12.4,11.39,12.27,1884541.46,12045490456
|
||||
2025-07-25,12.22,13.06,12.12,12.61,1848357.03,12045490456
|
||||
2025-07-28,12.91,12.97,12.61,12.69,1106575.15,12045490456
|
||||
2025-07-29,12.41,12.64,12.28,12.4,794365.97,12045490456
|
||||
2025-07-30,12.38,12.44,11.83,12.09,880349.27,12045490456
|
||||
2025-07-31,11.97,12.13,11.75,11.81,547576.88,12045490456
|
||||
2025-08-01,11.8,11.8,11.54,11.58,448552.57,12045490456
|
||||
2025-08-04,11.6,11.68,11.51,11.63,404376.06,12045490456
|
||||
2025-08-05,11.8,11.89,11.63,11.68,518346.76,12045490456
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-05-15,17.33,17.42,17.1,17.25,20642.05,11720226798
|
||||
2025-05-16,17.18,17.73,17.17,17.43,35369.48,11720226798
|
||||
2025-05-19,17.48,17.48,17.08,17.25,25489.78,11720226798
|
||||
2025-05-20,17.36,17.74,17.32,17.53,36708.2,11720226798
|
||||
2025-05-21,17.72,18.22,17.47,17.72,41463.24,11720226798
|
||||
2025-05-22,17.62,17.81,17.37,17.58,40314.58,11720226798
|
||||
2025-05-23,17.52,17.97,17.47,17.51,46281.21,11720226798
|
||||
2025-05-26,17.63,17.63,17.05,17.09,38830.29,11720226798
|
||||
2025-05-27,17.17,17.32,17.0,17.16,42731.45,11720226798
|
||||
2025-05-28,17.17,18.4,17.08,18.21,123423.01,11720226798
|
||||
2025-05-29,18.44,20.16,18.36,19.65,194317.79,11720226798
|
||||
2025-05-30,19.74,19.96,19.39,19.76,132173.99,11720226798
|
||||
2025-06-03,19.86,22.94,19.85,22.36,290301.1,11720226798
|
||||
2025-06-04,22.17,22.76,21.58,22.54,199596.74,11720226798
|
||||
2025-06-05,22.54,23.42,21.96,23.31,231289.99,11720226798
|
||||
2025-06-06,23.01,23.11,21.66,22.86,233436.91,11720226798
|
||||
2025-06-09,22.76,24.44,22.76,23.71,261851.09,11720226798
|
||||
2025-06-10,23.69,23.82,22.66,22.81,190046.45,11720226798
|
||||
2025-06-11,22.89,23.06,22.32,22.37,116651.04,11720226798
|
||||
2025-06-12,22.64,24.05,22.18,23.28,190460.15,11720226798
|
||||
2025-06-13,23.16,23.64,22.72,22.88,106830.32,11720226798
|
||||
2025-06-16,22.88,23.32,22.51,22.75,70989.1,11720226798
|
||||
2025-06-17,23.23,23.41,21.97,22.18,139623.83,11720226798
|
||||
2025-06-18,21.85,22.29,21.61,22.22,100081.97,11720226798
|
||||
2025-06-19,22.22,22.49,21.31,21.44,76485.29,11720226798
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-06-18,4.65,4.86,4.6,4.83,2051281.76,39275697251
|
||||
2025-06-19,4.79,4.98,4.75,4.78,1715941.43,39275697251
|
||||
2025-06-20,4.77,4.81,4.63,4.65,1051952.17,39275697251
|
||||
2025-06-23,4.6,4.75,4.57,4.7,934722.87,39275697251
|
||||
2025-06-24,4.72,4.81,4.7,4.78,945714.0,39275697251
|
||||
2025-06-25,4.8,4.85,4.73,4.81,1124786.7,39275697251
|
||||
2025-06-26,4.86,5.05,4.83,4.94,2459293.2,39275697251
|
||||
2025-06-27,4.94,5.42,4.85,5.27,4029657.48,39275697251
|
||||
2025-06-30,5.25,5.43,5.25,5.34,2441261.2,39275697251
|
||||
2025-07-01,5.31,5.38,5.24,5.3,1702111.13,39275697251
|
||||
2025-07-02,5.26,5.28,5.05,5.08,1565861.38,39275697251
|
||||
2025-07-03,5.08,5.59,5.08,5.59,4250014.47,39275697251
|
||||
2025-07-04,5.6,5.74,5.52,5.6,4529145.33,39275697251
|
||||
2025-07-07,5.5,5.79,5.49,5.58,2463078.1,39275697251
|
||||
2025-07-08,5.55,5.95,5.53,5.78,3665165.9,39275697251
|
||||
2025-07-09,5.75,5.82,5.65,5.69,2274246.96,39275697251
|
||||
2025-07-10,5.67,5.76,5.51,5.58,2005171.32,39275697251
|
||||
2025-07-11,5.57,5.58,5.39,5.5,1839462.11,39275697251
|
||||
2025-07-14,5.51,5.55,5.42,5.44,1238426.57,39275697251
|
||||
2025-07-15,5.45,5.6,5.4,5.47,2322143.38,39275697251
|
||||
2025-07-16,5.29,5.47,5.29,5.36,1945350.4,39275697251
|
||||
2025-07-17,5.33,5.57,5.3,5.48,2190584.97,39275697251
|
||||
2025-07-18,5.47,5.65,5.45,5.5,2020531.6,39275697251
|
||||
2025-07-21,5.52,5.66,5.43,5.48,1384268.25,39275697251
|
||||
2025-07-22,5.45,5.55,5.35,5.37,1735870.73,39275697251
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-06-30,32.11,33.05,31.61,32.23,1421653.88,51810407315
|
||||
2025-07-01,31.82,32.18,30.63,31.51,1313625.56,51810407315
|
||||
2025-07-02,31.51,31.57,30.79,30.9,615208.98,51810407315
|
||||
2025-07-03,31.16,31.21,30.49,30.96,810657.45,51810407315
|
||||
2025-07-04,30.65,30.93,29.93,30.43,799767.87,51810407315
|
||||
2025-07-07,30.43,30.67,30.09,30.22,482072.12,51810407315
|
||||
2025-07-08,30.11,30.34,29.97,30.12,621125.66,51810407315
|
||||
2025-07-09,30.19,30.86,29.73,29.83,1103713.14,51810407315
|
||||
2025-07-10,29.58,30.08,29.46,29.7,591122.59,51810407315
|
||||
2025-07-11,29.61,30.49,29.52,30.06,833099.95,51810407315
|
||||
2025-07-14,30.07,30.42,29.69,29.86,504302.93,51810407315
|
||||
2025-07-15,29.75,30.23,29.0,29.19,737141.8,51810407315
|
||||
2025-07-16,29.18,29.45,29.01,29.21,367657.7,51810407315
|
||||
2025-07-17,29.2,29.94,28.85,29.79,688212.84,51810407315
|
||||
2025-07-18,30.09,31.56,29.9,31.0,1211206.13,51810407315
|
||||
2025-07-21,30.98,31.37,30.36,31.07,772026.09,51810407315
|
||||
2025-07-22,30.78,31.45,30.43,30.8,785708.74,51810407315
|
||||
2025-07-23,30.56,30.57,29.89,29.92,703169.55,51810407315
|
||||
2025-07-24,29.84,30.46,29.77,30.31,543627.27,51810407315
|
||||
2025-07-25,30.36,31.13,30.36,30.45,619316.45,51810407315
|
||||
2025-07-28,30.43,31.24,30.18,31.04,702169.14,51810407315
|
||||
2025-07-29,30.79,31.15,30.33,30.69,542088.91,51810407315
|
||||
2025-07-30,30.84,30.85,29.24,29.48,761650.21,51810407315
|
||||
2025-07-31,29.34,30.01,28.94,29.12,470283.8,51810407315
|
||||
2025-08-01,28.99,29.22,28.64,28.67,386407.75,51810407315
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-06-27,19.98,22.09,19.61,22.09,276542.2,3567378360
|
||||
2025-06-30,21.86,24.19,21.46,23.37,328614.33,3567378360
|
||||
2025-07-01,22.39,22.77,21.46,21.46,246739.02,3567378360
|
||||
2025-07-02,20.91,21.41,20.4,20.88,152920.45,3567378360
|
||||
2025-07-03,20.81,22.49,20.72,22.05,226316.34,3567378360
|
||||
2025-07-04,21.4,21.76,20.75,20.76,157410.92,3567378360
|
||||
2025-07-07,20.47,21.17,20.32,21.0,89066.04,3567378360
|
||||
2025-07-08,21.01,21.11,20.67,20.9,81440.83,3567378360
|
||||
2025-07-09,20.91,21.39,20.42,20.51,94518.29,3567378360
|
||||
2025-07-10,20.51,20.51,19.94,20.32,82854.2,3567378360
|
||||
2025-07-11,20.42,20.6,20.12,20.41,63631.7,3567378360
|
||||
2025-07-14,20.57,20.93,20.51,20.6,71670.49,3567378360
|
||||
2025-07-15,20.45,20.78,20.13,20.47,70849.72,3567378360
|
||||
2025-07-16,20.6,20.86,20.33,20.47,68310.79,3567378360
|
||||
2025-07-17,20.26,20.6,19.92,20.51,62354.29,3567378360
|
||||
2025-07-18,20.48,20.79,20.36,20.49,62896.87,3567378360
|
||||
2025-07-21,20.36,20.97,20.12,20.52,68576.12,3567378360
|
||||
2025-07-22,20.4,21.25,20.34,20.96,129095.1,3567378360
|
||||
2025-07-23,20.84,20.88,20.09,20.17,96276.56,3567378360
|
||||
2025-07-24,20.16,20.35,20.08,20.19,45888.62,3567378360
|
||||
2025-07-25,20.21,20.21,19.97,20.08,38465.12,3567378360
|
||||
2025-07-28,20.09,20.55,20.06,20.4,51218.04,3567378360
|
||||
2025-07-29,20.34,20.54,19.79,19.93,61055.53,3567378360
|
||||
2025-07-30,19.81,20.21,19.2,19.86,79996.39,3567378360
|
||||
2025-07-31,19.66,19.99,19.37,19.48,43501.6,3567378360
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-06-05,13.86,13.93,13.61,13.82,76839.0,9399394575
|
||||
2025-06-06,13.83,14.01,13.66,13.7,69401.0,9399394575
|
||||
2025-06-09,13.69,13.87,13.64,13.76,75300.24,9399394575
|
||||
2025-06-10,13.7,13.74,12.99,13.18,174574.3,9399394575
|
||||
2025-06-11,13.2,13.34,13.11,13.3,61388.02,9399394575
|
||||
2025-06-12,13.26,13.35,13.14,13.21,46718.0,9399394575
|
||||
2025-06-13,13.17,13.52,13.17,13.38,164443.0,9399394575
|
||||
2025-06-16,13.48,13.75,13.17,13.68,140522.0,9399394575
|
||||
2025-06-17,13.63,14.09,13.62,13.97,143405.8,9399394575
|
||||
2025-06-18,13.98,14.72,13.89,14.72,275552.83,9399394575
|
||||
2025-06-19,14.48,14.48,13.72,14.15,252934.0,9399394575
|
||||
2025-06-20,14.15,14.16,13.75,13.8,127924.0,9399394575
|
||||
2025-06-23,14.01,14.33,13.9,14.33,160493.0,9399394575
|
||||
2025-06-24,14.19,14.87,13.84,14.54,252237.43,9399394575
|
||||
2025-06-25,14.78,16.0,14.71,16.0,600588.02,9399394575
|
||||
2025-06-26,16.0,17.6,15.98,16.63,846170.51,9399394575
|
||||
2025-06-27,16.56,17.27,16.3,16.42,651687.06,9399394575
|
||||
2025-06-30,16.58,17.57,16.58,17.54,612607.43,9399394575
|
||||
2025-07-01,17.28,17.9,16.88,17.24,468426.25,9399394575
|
||||
2025-07-02,17.18,17.18,16.42,16.61,337259.72,9399394575
|
||||
2025-07-03,16.62,16.84,16.37,16.46,199869.31,9399394575
|
||||
2025-07-04,16.37,16.45,16.04,16.1,180557.04,9399394575
|
||||
2025-07-07,16.03,16.32,15.86,16.12,142471.31,9399394575
|
||||
2025-07-08,15.98,16.16,15.91,16.07,122700.83,9399394575
|
||||
2025-07-09,16.08,16.45,15.94,15.99,230184.09,9399394575
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-06-27,20.43,20.56,20.04,20.27,54029.3,4707136785
|
||||
2025-06-30,20.22,20.5,20.18,20.44,45101.98,4707136785
|
||||
2025-07-01,20.43,20.56,20.05,20.3,46657.31,4707136785
|
||||
2025-07-02,20.28,20.28,19.76,20.03,40227.67,4707136785
|
||||
2025-07-03,20.1,20.18,19.73,19.89,29471.22,4707136785
|
||||
2025-07-04,20.02,20.02,19.43,19.62,32150.52,4707136785
|
||||
2025-07-07,19.61,19.85,19.4,19.78,24560.45,4707136785
|
||||
2025-07-08,19.71,21.16,19.71,20.73,110928.39,4707136785
|
||||
2025-07-09,21.04,21.22,20.32,20.47,80045.04,4707136785
|
||||
2025-07-10,20.25,20.51,19.88,20.01,62948.74,4707136785
|
||||
2025-07-11,19.93,21.34,19.73,21.0,126620.87,4707136785
|
||||
2025-07-14,21.59,24.48,21.11,23.66,270019.12,4707136785
|
||||
2025-07-15,23.57,24.31,23.06,23.93,231598.13,4707136785
|
||||
2025-07-16,23.56,24.31,23.23,23.5,185860.26,4707136785
|
||||
2025-07-17,23.37,24.42,23.03,23.5,162749.05,4707136785
|
||||
2025-07-18,23.37,23.72,22.91,23.16,111343.75,4707136785
|
||||
2025-07-21,23.44,24.61,23.14,24.02,176471.75,4707136785
|
||||
2025-07-22,23.79,23.95,22.83,23.07,132565.5,4707136785
|
||||
2025-07-23,22.9,23.07,22.38,22.71,71180.1,4707136785
|
||||
2025-07-24,22.55,23.17,22.52,22.71,55999.01,4707136785
|
||||
2025-07-25,22.63,22.87,22.42,22.61,54779.45,4707136785
|
||||
2025-07-28,22.97,25.66,22.97,24.61,247298.15,4707136785
|
||||
2025-07-29,24.08,24.45,23.8,24.14,144127.75,4707136785
|
||||
2025-07-30,23.96,24.23,23.15,23.27,110016.38,4707136785
|
||||
2025-07-31,23.07,23.58,22.74,22.87,84262.28,4707136785
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-06-06,17.64,19.41,17.16,19.41,295488.65,4910064388
|
||||
2025-06-09,20.53,21.35,20.53,21.35,162351.45,4910064388
|
||||
2025-06-10,23.48,23.48,22.95,23.48,96161.81,4910064388
|
||||
2025-06-11,25.5,25.83,24.22,25.83,538547.27,4910064388
|
||||
2025-06-12,27.48,28.07,25.7,26.81,487592.83,4910064388
|
||||
2025-06-13,25.98,26.92,25.23,26.07,302545.06,4910064388
|
||||
2025-06-16,25.81,28.68,25.41,28.68,285190.35,4910064388
|
||||
2025-06-17,30.1,31.49,28.19,28.7,386383.92,4910064388
|
||||
2025-06-18,28.11,28.57,26.72,27.7,314113.19,4910064388
|
||||
2025-06-19,28.59,30.13,27.97,28.73,229020.92,4910064388
|
||||
2025-06-20,26.78,31.6,26.78,31.6,174055.39,4910064388
|
||||
2025-06-23,31.05,31.95,28.44,31.67,234024.79,4910064388
|
||||
2025-06-24,31.07,34.13,31.07,33.05,233411.76,4910064388
|
||||
2025-06-25,31.9,34.7,31.85,32.5,242343.26,4910064388
|
||||
2025-06-26,30.83,32.1,29.28,30.55,193713.48,4910064388
|
||||
2025-06-27,30.57,33.6,30.26,33.6,131012.98,4910064388
|
||||
2025-06-30,33.6,36.97,33.6,36.71,195320.19,4910064388
|
||||
2025-07-01,35.84,40.38,35.83,40.38,161306.24,4910064388
|
||||
2025-07-02,40.35,44.42,39.75,44.42,212138.21,4910064388
|
||||
2025-07-03,40.56,48.08,40.56,44.44,176844.52,4910064388
|
||||
2025-07-04,43.53,44.12,40.49,40.96,138196.86,4910064388
|
||||
2025-07-07,42.76,42.76,39.49,41.12,105590.86,4910064388
|
||||
2025-07-08,41.36,41.5,38.07,39.52,105213.28,4910064388
|
||||
2025-07-09,39.31,40.63,37.92,39.33,97899.1,4910064388
|
||||
2025-07-10,39.46,39.56,36.96,37.43,79650.61,4910064388
|
||||
|
+26
@@ -0,0 +1,26 @@
|
||||
date,open,high,low,close,volume,market_cap
|
||||
2025-09-30,7.57,7.76,7.57,7.74,204915.56,22650295811
|
||||
2025-10-09,7.75,7.8,7.68,7.8,196793.96,22650295811
|
||||
2025-10-10,7.79,7.82,7.73,7.75,163527.18,22650295811
|
||||
2025-10-13,7.6,7.8,7.47,7.8,208009.58,22650295811
|
||||
2025-10-14,7.82,7.9,7.73,7.78,203765.38,22650295811
|
||||
2025-10-15,7.77,7.78,7.67,7.75,158196.56,22650295811
|
||||
2025-10-16,7.74,7.76,7.61,7.63,151268.43,22650295811
|
||||
2025-10-17,7.62,7.72,7.47,7.48,162246.05,22650295811
|
||||
2025-10-20,7.55,7.61,7.51,7.58,122212.11,22650295811
|
||||
2025-10-21,7.58,7.67,7.56,7.64,121825.06,22650295811
|
||||
2025-10-22,7.64,7.86,7.58,7.82,322717.34,22650295811
|
||||
2025-10-23,7.8,7.82,7.67,7.81,170156.0,22650295811
|
||||
2025-10-24,8.1,8.3,7.95,7.98,615299.42,22650295811
|
||||
2025-10-27,8.0,8.18,7.94,8.04,433223.34,22650295811
|
||||
2025-10-28,7.99,8.84,7.97,8.84,1610159.98,22650295811
|
||||
2025-10-29,8.6,9.0,8.41,8.75,1722050.97,22650295811
|
||||
2025-10-30,8.7,8.82,8.51,8.6,1035934.51,22650295811
|
||||
2025-10-31,8.57,8.62,8.34,8.37,686044.09,22650295811
|
||||
2025-11-03,8.37,8.61,8.33,8.6,748009.31,22650295811
|
||||
2025-11-04,8.52,9.26,8.5,8.98,1365750.29,22650295811
|
||||
2025-11-05,8.76,8.91,8.67,8.81,823459.67,22650295811
|
||||
2025-11-06,8.77,8.8,8.6,8.65,553188.01,22650295811
|
||||
2025-11-07,8.67,8.75,8.56,8.67,592496.69,22650295811
|
||||
2025-11-10,8.74,8.79,8.51,8.53,513705.42,22650295811
|
||||
2025-11-11,8.48,8.54,8.39,8.47,440314.81,22650295811
|
||||
|
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"algorithm": {
|
||||
"version": "zhixing_b1_pattern_fastdtw_v1",
|
||||
"radius": 1,
|
||||
"distance": "scalar_euclidean",
|
||||
"lookback_days": 25,
|
||||
"threshold": 60.0,
|
||||
"weights": [0.10, 0.20, 0.25, 0.45]
|
||||
},
|
||||
"self_match": {
|
||||
"status": "matched",
|
||||
"value": 95.0,
|
||||
"case_id": "case_001",
|
||||
"breakdown": {
|
||||
"trend_structure": 50.0,
|
||||
"kdj_state": 100.0,
|
||||
"volume_pattern": 100.0,
|
||||
"price_shape": 100.0
|
||||
}
|
||||
},
|
||||
"time_warped": {
|
||||
"status": "matched",
|
||||
"value": 78.38,
|
||||
"case_id": "case_001",
|
||||
"breakdown": {
|
||||
"trend_structure": 44.51,
|
||||
"kdj_state": 77.15,
|
||||
"volume_pattern": 65.0,
|
||||
"price_shape": 93.89
|
||||
}
|
||||
},
|
||||
"below_threshold": {
|
||||
"status": "below_threshold",
|
||||
"value": 46.27,
|
||||
"case_id": "case_010",
|
||||
"breakdown": {
|
||||
"trend_structure": 28.33,
|
||||
"kdj_state": 91.79,
|
||||
"volume_pattern": 27.5,
|
||||
"price_shape": 40.45
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,8 @@ def test_postgres_migration_creates_market_data_contract(
|
||||
engine: Engine = create_engine(sqlalchemy_url)
|
||||
command.upgrade(config, "head")
|
||||
try:
|
||||
tables = set(inspect(engine).get_table_names())
|
||||
inspector = inspect(engine)
|
||||
tables = set(inspector.get_table_names())
|
||||
assert {
|
||||
"market_stock",
|
||||
"market_daily_bar",
|
||||
@@ -39,6 +40,25 @@ def test_postgres_migration_creates_market_data_contract(
|
||||
"selection_run_item",
|
||||
"selection_signal",
|
||||
} <= tables
|
||||
item_columns = {column["name"] for column in inspector.get_columns("selection_run_item")}
|
||||
assert {
|
||||
"score_status",
|
||||
"score_value",
|
||||
"score_threshold",
|
||||
"score_version",
|
||||
"match_case_id",
|
||||
"match_case_name",
|
||||
"match_case_breakout_date",
|
||||
"match_breakdown",
|
||||
"score_reason",
|
||||
} <= item_columns
|
||||
constraint_names = {
|
||||
constraint["name"]
|
||||
for constraint in inspector.get_check_constraints("selection_run_item")
|
||||
}
|
||||
assert "ck_selection_run_item_breakdown_range" in constraint_names
|
||||
index_names = {index["name"] for index in inspector.get_indexes("selection_run_item")}
|
||||
assert "ix_selection_run_item_score" in index_names
|
||||
finally:
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""HTTP contracts for triggering and querying persisted selection runs."""
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -11,6 +12,12 @@ from zhixing_server.bootstrap.app import create_app
|
||||
from zhixing_server.bootstrap.config import Settings
|
||||
from zhixing_server.modules.selection.application.run import PreparedSelectionRun
|
||||
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
|
||||
from zhixing_server.modules.selection.domain.pattern_scoring import (
|
||||
PATTERN_SCORING_VERSION,
|
||||
ZHIXING_B1_PATTERN_CASES,
|
||||
PatternScore,
|
||||
PatternScoreBreakdown,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.runs import (
|
||||
SelectionExecutionSource,
|
||||
SelectionRerunRequired,
|
||||
@@ -130,6 +137,14 @@ def _run(run_id: str, status: str) -> SelectionRun:
|
||||
name="平安银行",
|
||||
status="selected",
|
||||
signal_count=2,
|
||||
pattern_score=PatternScore(
|
||||
status="matched",
|
||||
value=86.4,
|
||||
threshold=60.0,
|
||||
version=PATTERN_SCORING_VERSION,
|
||||
case=ZHIXING_B1_PATTERN_CASES[0],
|
||||
breakdown=PatternScoreBreakdown(71.2, 83.0, 88.0, 90.1),
|
||||
),
|
||||
signals=(original_signal, pullback_signal),
|
||||
),
|
||||
),
|
||||
@@ -238,6 +253,24 @@ def test_query_returns_persisted_signal_details() -> None:
|
||||
assert "signals" not in body
|
||||
assert len(body["stocks"]) == 1
|
||||
assert body["stocks"][0]["ts_code"] == "000001.SZ"
|
||||
assert body["stocks"][0]["score"] == {
|
||||
"status": "matched",
|
||||
"value": 86.4,
|
||||
"threshold": 60.0,
|
||||
"version": PATTERN_SCORING_VERSION,
|
||||
"case": {
|
||||
"id": "case_001",
|
||||
"name": "华纳药厂",
|
||||
"breakout_date": "2025-05-12",
|
||||
},
|
||||
"breakdown": {
|
||||
"trend_structure": 71.2,
|
||||
"kdj_state": 83.0,
|
||||
"volume_pattern": 88.0,
|
||||
"price_shape": 90.1,
|
||||
},
|
||||
"reason": None,
|
||||
}
|
||||
assert [signal["category"] for signal in body["stocks"][0]["signals"]] == [
|
||||
"zhixing_b1_original_b1",
|
||||
"zhixing_b1_pullback_white",
|
||||
@@ -248,6 +281,59 @@ def test_query_returns_persisted_signal_details() -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pattern_score", "expected_score"),
|
||||
[
|
||||
(
|
||||
PatternScore(
|
||||
status="below_threshold",
|
||||
value=42.5,
|
||||
threshold=60.0,
|
||||
version=PATTERN_SCORING_VERSION,
|
||||
case=ZHIXING_B1_PATTERN_CASES[0],
|
||||
breakdown=PatternScoreBreakdown(40.0, 42.0, 43.0, 44.0),
|
||||
),
|
||||
{
|
||||
"status": "below_threshold",
|
||||
"value": None,
|
||||
"threshold": 60.0,
|
||||
"version": PATTERN_SCORING_VERSION,
|
||||
"case": None,
|
||||
"breakdown": None,
|
||||
"reason": "未匹配到评分阈值以上案例",
|
||||
},
|
||||
),
|
||||
(
|
||||
PatternScore.failed("FastDTW unavailable"),
|
||||
{
|
||||
"status": "failed",
|
||||
"value": None,
|
||||
"threshold": None,
|
||||
"version": None,
|
||||
"case": None,
|
||||
"breakdown": None,
|
||||
"reason": "FastDTW unavailable",
|
||||
},
|
||||
),
|
||||
(PatternScore(), None),
|
||||
],
|
||||
)
|
||||
def test_query_preserves_signals_for_every_pattern_score_state(
|
||||
pattern_score: PatternScore,
|
||||
expected_score: dict[str, object] | None,
|
||||
) -> None:
|
||||
run = _run("run-http", "success")
|
||||
run = replace(run, items=(replace(run.items[0], pattern_score=pattern_score),))
|
||||
|
||||
response = _client(FakeSelectionService(run)).get("/api/v1/selection/results")
|
||||
|
||||
assert response.status_code == 200
|
||||
stock = response.json()["stocks"][0]
|
||||
assert stock["score"] == expected_score
|
||||
assert len(stock["signals"]) == 2
|
||||
assert response.json()["failures"] == []
|
||||
|
||||
|
||||
def test_query_forwards_pagination_and_filters() -> None:
|
||||
service = FakeSelectionService(_run("run-http", "success"))
|
||||
|
||||
@@ -259,6 +345,7 @@ def test_query_forwards_pagination_and_filters() -> None:
|
||||
"page_size": 5,
|
||||
"search": " 平安银行 ",
|
||||
"category": "original",
|
||||
"sort": "score_desc",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -268,6 +355,7 @@ def test_query_forwards_pagination_and_filters() -> None:
|
||||
page_size=5,
|
||||
search="平安银行",
|
||||
category="original",
|
||||
sort="score_desc",
|
||||
)
|
||||
assert response.json()["page"] == 2
|
||||
assert response.json()["page_size"] == 5
|
||||
@@ -282,6 +370,18 @@ def test_query_rejects_invalid_page_size() -> None:
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_query_forwards_score_ascending_sort() -> None:
|
||||
service = FakeSelectionService(_run("run-http", "success"))
|
||||
|
||||
response = _client(service).get(
|
||||
"/api/v1/selection/results",
|
||||
params={"strategy": "zhixing_b1", "sort": "score_asc"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert service.last_query == SelectionResultQuery(sort="score_asc")
|
||||
|
||||
|
||||
def test_run_polling_returns_the_persisted_terminal_result() -> None:
|
||||
response = _client(FakeSelectionService(_run("run-http", "success"))).get(
|
||||
"/api/v1/selection/runs/run-http"
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Golden and invariant tests for versioned B1 FastDTW scoring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from zhixing_server.modules.selection.domain.models import SelectionBar, StockHistory
|
||||
from zhixing_server.modules.selection.domain.pattern_scoring import (
|
||||
PATTERN_FASTDTW_RADIUS,
|
||||
PATTERN_SCORING_VERSION,
|
||||
ZHIXING_B1_PATTERN_CASES,
|
||||
PatternCase,
|
||||
PatternCaseLibraryError,
|
||||
PatternFeatures,
|
||||
PatternScore,
|
||||
PatternScoreBreakdown,
|
||||
PatternScoringError,
|
||||
ZhixingB1PatternScorer,
|
||||
build_pattern_case,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parents[2] / "fixtures" / "selection" / "zhixing_b1" / "pattern_scoring"
|
||||
|
||||
|
||||
def _history(case_id: str, ts_code: str, name: str) -> StockHistory:
|
||||
frame = pd.read_csv(FIXTURES / f"{case_id}.csv")
|
||||
bars = tuple(
|
||||
SelectionBar(
|
||||
trade_date=date.fromisoformat(str(row.date)),
|
||||
open=float(str(row.open)),
|
||||
high=float(str(row.high)),
|
||||
low=float(str(row.low)),
|
||||
close=float(str(row.close)),
|
||||
volume=float(str(row.volume)),
|
||||
)
|
||||
for row in frame.itertuples(index=False)
|
||||
)
|
||||
return StockHistory(ts_code=ts_code, name=name, bars=bars)
|
||||
|
||||
|
||||
def _cases() -> tuple[PatternCase, ...]:
|
||||
return tuple(
|
||||
build_pattern_case(
|
||||
definition,
|
||||
_history(definition.id, definition.ts_code, definition.name),
|
||||
)
|
||||
for definition in ZHIXING_B1_PATTERN_CASES
|
||||
)
|
||||
|
||||
|
||||
def _golden(name: str) -> dict[str, object]:
|
||||
payload = cast(dict[str, object], json.loads((FIXTURES / "golden.json").read_text()))
|
||||
return cast(dict[str, object], payload[name])
|
||||
|
||||
|
||||
def _assert_golden(score: PatternScore, expected: dict[str, object]) -> None:
|
||||
assert score.status == expected["status"]
|
||||
assert score.value == expected["value"]
|
||||
assert score.case is not None
|
||||
assert score.case.id == expected["case_id"]
|
||||
assert score.breakdown is not None
|
||||
assert score.breakdown.as_dict() == expected["breakdown"]
|
||||
|
||||
|
||||
def test_fastdtw_v1_self_match_golden_is_finite_and_deterministic() -> None:
|
||||
cases = _cases()
|
||||
scorer = ZhixingB1PatternScorer()
|
||||
|
||||
first = scorer.score(cases[0].history, cases)
|
||||
second = scorer.score(cases[0].history, cases)
|
||||
|
||||
assert PATTERN_SCORING_VERSION == "zhixing_b1_pattern_fastdtw_v1"
|
||||
assert PATTERN_FASTDTW_RADIUS == 1
|
||||
assert first == second
|
||||
_assert_golden(first, _golden("self_match"))
|
||||
assert cases[0].features.trend_structure["short_vs_bullbear"] is None
|
||||
|
||||
|
||||
def test_fastdtw_v1_time_warped_curve_golden() -> None:
|
||||
cases = _cases()
|
||||
base = cases[0].history.bars
|
||||
delayed = base[:1] * 3 + base[:-3]
|
||||
bars = tuple(
|
||||
SelectionBar(
|
||||
trade_date=base[index].trade_date,
|
||||
open=delayed[index].open,
|
||||
high=delayed[index].high,
|
||||
low=delayed[index].low,
|
||||
close=delayed[index].close,
|
||||
volume=delayed[index].volume,
|
||||
)
|
||||
for index in range(25)
|
||||
)
|
||||
|
||||
result = ZhixingB1PatternScorer().score(
|
||||
StockHistory(ts_code="TEST.SZ", name="time warped", bars=bars),
|
||||
cases,
|
||||
)
|
||||
|
||||
_assert_golden(result, _golden("time_warped"))
|
||||
|
||||
|
||||
def test_below_threshold_golden_remains_a_successful_computation() -> None:
|
||||
bars = tuple(
|
||||
SelectionBar(
|
||||
trade_date=date(2026, 1, 1) + timedelta(days=index),
|
||||
open=100.0 if index % 2 == 0 else 1.0,
|
||||
high=110.0,
|
||||
low=0.9,
|
||||
close=1.0 if index % 2 == 0 else 100.0,
|
||||
volume=1.0 if index < 13 else 1_000_000.0,
|
||||
)
|
||||
for index in range(25)
|
||||
)
|
||||
|
||||
result = ZhixingB1PatternScorer().score(
|
||||
StockHistory(ts_code="TEST.SZ", name="below", bars=bars),
|
||||
_cases(),
|
||||
)
|
||||
|
||||
_assert_golden(result, _golden("below_threshold"))
|
||||
|
||||
|
||||
def test_case_library_rejects_partial_or_short_input() -> None:
|
||||
cases = _cases()
|
||||
with pytest.raises(PatternScoringError, match="incomplete or out of order"):
|
||||
ZhixingB1PatternScorer().score(cases[0].history, cases[:-1])
|
||||
|
||||
definition = ZHIXING_B1_PATTERN_CASES[0]
|
||||
short = _history(definition.id, definition.ts_code, definition.name)
|
||||
with pytest.raises(PatternCaseLibraryError, match="requires 25 complete rows"):
|
||||
build_pattern_case(
|
||||
definition,
|
||||
StockHistory(short.ts_code, short.name, short.bars[:-1]),
|
||||
)
|
||||
|
||||
|
||||
def test_fastdtw_failure_is_not_replaced_by_simple_dtw(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import zhixing_server.modules.selection.domain.pattern_scoring as scoring
|
||||
|
||||
def fail(*_args: object, **_kwargs: object) -> tuple[float, list[tuple[int, int]]]:
|
||||
raise RuntimeError("fastdtw unavailable")
|
||||
|
||||
monkeypatch.setattr(scoring, "_fastdtw", lambda: fail)
|
||||
|
||||
with pytest.raises(RuntimeError, match="fastdtw unavailable"):
|
||||
ZhixingB1PatternScorer().score(_cases()[0].history, _cases())
|
||||
|
||||
|
||||
def test_failed_score_requires_a_safe_reason() -> None:
|
||||
with pytest.raises(ValueError, match="requires a safe reason"):
|
||||
PatternScore(status="failed")
|
||||
|
||||
assert PatternScore.failed(" ").reason == "pattern scoring failed"
|
||||
|
||||
|
||||
def test_threshold_is_inclusive_and_equal_scores_keep_first_case(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import zhixing_server.modules.selection.domain.pattern_scoring as scoring
|
||||
|
||||
tied = PatternScoreBreakdown(60.0, 60.0, 60.0, 60.0)
|
||||
|
||||
def tied_match(
|
||||
_candidate: PatternFeatures,
|
||||
_case: PatternFeatures,
|
||||
) -> PatternScoreBreakdown:
|
||||
return tied
|
||||
|
||||
monkeypatch.setattr(scoring, "_match", tied_match)
|
||||
|
||||
result = ZhixingB1PatternScorer().score(_cases()[0].history, _cases())
|
||||
|
||||
assert result.status == "matched"
|
||||
assert result.value == 60.0
|
||||
assert result.case == ZHIXING_B1_PATTERN_CASES[0]
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import cast
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from zhixing_server.modules.selection.domain.pattern_scoring import ZHIXING_B1_PATTERN_CASES
|
||||
from zhixing_server.modules.selection.domain.runs import SelectionStock
|
||||
from zhixing_server.modules.selection.infrastructure.postgres_pool import SelectionPostgresPool
|
||||
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
|
||||
PostgresMarketDataReader,
|
||||
PostgresPatternCaseLibraryLoader,
|
||||
SelectionMarketDataNotReady,
|
||||
)
|
||||
|
||||
@@ -237,3 +239,34 @@ def test_reader_rejects_date_without_eligible_market_batch(monkeypatch: pytest.M
|
||||
"zhixing_b1",
|
||||
date(2026, 8, 8),
|
||||
)
|
||||
|
||||
|
||||
def test_pattern_case_loader_reads_one_complete_exclusive_qfq_library() -> None:
|
||||
rows: list[tuple[object, ...]] = []
|
||||
for definition in ZHIXING_B1_PATTERN_CASES:
|
||||
for offset in range(definition.lookback_days, 0, -1):
|
||||
rows.append(
|
||||
(
|
||||
definition.id,
|
||||
definition.ts_code,
|
||||
definition.breakout_date - timedelta(days=offset),
|
||||
"10",
|
||||
"11",
|
||||
"9",
|
||||
str(10 + offset / 100),
|
||||
str(1000 + offset),
|
||||
)
|
||||
)
|
||||
connection = FakeConnection(rows)
|
||||
pool = Pool(connection)
|
||||
owner = SelectionPostgresPool("postgresql://test", max_connections=2, pool=pool)
|
||||
|
||||
cases = PostgresPatternCaseLibraryLoader("postgresql://test", pool=owner).load()
|
||||
|
||||
assert tuple(case.definition for case in cases) == ZHIXING_B1_PATTERN_CASES
|
||||
assert all(len(case.history.bars) == 25 for case in cases)
|
||||
assert all(case.history.bars[-1].trade_date < case.definition.breakout_date for case in cases)
|
||||
assert "bar.trade_date < definition.breakout_date" in cast(str, connection.query)
|
||||
assert "bar.source_adj = 'qfq'" in cast(str, connection.query)
|
||||
assert connection.parameters is not None
|
||||
assert connection.parameters[0] == [definition.id for definition in ZHIXING_B1_PATTERN_CASES]
|
||||
|
||||
@@ -8,6 +8,12 @@ import pytest
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
|
||||
from zhixing_server.modules.selection.domain.pattern_scoring import (
|
||||
PATTERN_SCORING_VERSION,
|
||||
ZHIXING_B1_PATTERN_CASES,
|
||||
PatternScore,
|
||||
PatternScoreBreakdown,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.runs import (
|
||||
SelectionExecutionSource,
|
||||
SelectionRerunRequired,
|
||||
@@ -222,6 +228,14 @@ def test_record_items_uses_one_delete_and_two_batch_upserts(
|
||||
name="平安银行",
|
||||
status="selected",
|
||||
signal_count=2,
|
||||
pattern_score=PatternScore(
|
||||
status="matched",
|
||||
value=86.4,
|
||||
threshold=60.0,
|
||||
version=PATTERN_SCORING_VERSION,
|
||||
case=ZHIXING_B1_PATTERN_CASES[0],
|
||||
breakdown=PatternScoreBreakdown(71.2, 83.0, 88.0, 90.1),
|
||||
),
|
||||
signals=(first, second),
|
||||
),
|
||||
SelectionRunItem(
|
||||
@@ -238,6 +252,28 @@ def test_record_items_uses_one_delete_and_two_batch_upserts(
|
||||
assert delete_parameters == ("run-1", ["000001.SZ", "600000.SH"])
|
||||
assert len(connection.executemany_calls) == 2
|
||||
assert "INSERT INTO selection_run_item" in connection.executemany_calls[0][0]
|
||||
item_parameters = connection.executemany_calls[0][1]
|
||||
assert item_parameters[0][6:13] == (
|
||||
"matched",
|
||||
86.4,
|
||||
60.0,
|
||||
PATTERN_SCORING_VERSION,
|
||||
"case_001",
|
||||
"华纳药厂",
|
||||
date(2025, 5, 12),
|
||||
)
|
||||
assert isinstance(item_parameters[0][13], Jsonb)
|
||||
assert item_parameters[1][6:] == (
|
||||
"not_executed",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert "INSERT INTO selection_signal" in connection.executemany_calls[1][0]
|
||||
signal_parameters = connection.executemany_calls[1][1]
|
||||
assert len(signal_parameters) == 2
|
||||
@@ -281,11 +317,35 @@ class LoadConnection:
|
||||
None,
|
||||
)
|
||||
)
|
||||
if "FROM selection_run_item" in query:
|
||||
return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)])
|
||||
if "COUNT(DISTINCT ts_code) FROM selection_signal" in query:
|
||||
if "FROM selection_run_item\n" in query:
|
||||
return LoadResult(
|
||||
rows=[
|
||||
(
|
||||
"000001.SZ",
|
||||
"平安银行",
|
||||
"selected",
|
||||
2,
|
||||
None,
|
||||
"matched",
|
||||
Decimal("86.40"),
|
||||
Decimal("60.00"),
|
||||
PATTERN_SCORING_VERSION,
|
||||
"case_001",
|
||||
"华纳药厂",
|
||||
date(2025, 5, 12),
|
||||
{
|
||||
"trend_structure": 71.2,
|
||||
"kdj_state": 83.0,
|
||||
"volume_pattern": 88.0,
|
||||
"price_shape": 90.1,
|
||||
},
|
||||
None,
|
||||
)
|
||||
]
|
||||
)
|
||||
if "SELECT COUNT(*) FROM selection_run_item AS item" in query:
|
||||
return LoadResult(row=(2,))
|
||||
if "SELECT DISTINCT ts_code" in query:
|
||||
if "SELECT item.ts_code" in query:
|
||||
return LoadResult(rows=[("000001.SZ",)])
|
||||
return LoadResult(
|
||||
rows=[
|
||||
@@ -315,7 +375,7 @@ class EmptyStockPageConnection(LoadConnection):
|
||||
"""Return a non-zero filtered total with no stocks on the requested page."""
|
||||
|
||||
def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult":
|
||||
if "SELECT DISTINCT ts_code" in query:
|
||||
if "SELECT item.ts_code" in query:
|
||||
self.statements.append((query, parameters))
|
||||
return LoadResult(rows=[])
|
||||
return super().execute(query, parameters)
|
||||
@@ -355,6 +415,7 @@ def test_get_run_pages_stocks_and_loads_all_signals_for_category_matches(
|
||||
page_size=1,
|
||||
search="100%",
|
||||
category="pullback",
|
||||
sort="score_desc",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -364,19 +425,21 @@ def test_get_run_pages_stocks_and_loads_all_signals_for_category_matches(
|
||||
ZHIXING_B1_SIGNAL_ORDER[-1],
|
||||
]
|
||||
assert run.stocks_total == 2
|
||||
assert run.items[0].pattern_score.status == "matched"
|
||||
assert run.items[0].pattern_score.value == 86.4
|
||||
count_query, count_parameters = next(
|
||||
(query, parameters)
|
||||
for query, parameters in connection.statements
|
||||
if "COUNT(DISTINCT ts_code) FROM selection_signal" in query
|
||||
if "SELECT COUNT(*) FROM selection_run_item AS item" in query
|
||||
)
|
||||
assert "name ILIKE %s ESCAPE" in count_query
|
||||
assert count_parameters == ("run-1", "%100\\%%", "%100\\%%", "zhixing_b1_pullback_%")
|
||||
stock_page_query, page_parameters = next(
|
||||
(query, parameters)
|
||||
for query, parameters in connection.statements
|
||||
if "SELECT DISTINCT ts_code" in query
|
||||
if "SELECT item.ts_code" in query
|
||||
)
|
||||
assert "ORDER BY ts_code" in stock_page_query
|
||||
assert "ORDER BY item.score_value DESC NULLS LAST, item.ts_code ASC" in stock_page_query
|
||||
assert page_parameters[-2:] == (1, 0)
|
||||
signal_query, signal_parameters = next(
|
||||
(query, parameters)
|
||||
@@ -409,8 +472,30 @@ def test_get_run_does_not_load_signals_for_an_empty_stock_page(
|
||||
stock_page_query, stock_page_parameters = next(
|
||||
(query, parameters)
|
||||
for query, parameters in connection.statements
|
||||
if "SELECT DISTINCT ts_code" in query
|
||||
if "SELECT item.ts_code" in query
|
||||
)
|
||||
assert "ORDER BY ts_code" in stock_page_query
|
||||
assert "ORDER BY item.ts_code ASC" in stock_page_query
|
||||
assert stock_page_parameters[-2:] == (1, 2)
|
||||
assert not any("ts_code = ANY(%s)" in query for query, _ in connection.statements)
|
||||
|
||||
|
||||
def test_get_run_sorts_scores_ascending_with_nulls_last_and_code_tiebreak(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
connection = LoadConnection()
|
||||
|
||||
def connect(database_url: str) -> LoadConnection:
|
||||
assert database_url == "postgresql://test"
|
||||
return connection
|
||||
|
||||
monkeypatch.setattr(psycopg, "connect", connect)
|
||||
run = PostgresSelectionRunRepository("postgresql://test").get_run(
|
||||
"run-1",
|
||||
query=SelectionResultQuery(sort="score_asc"),
|
||||
)
|
||||
|
||||
assert run is not None
|
||||
stock_page_query = next(
|
||||
query for query, _ in connection.statements if "SELECT item.ts_code" in query
|
||||
)
|
||||
assert "ORDER BY item.score_value ASC NULLS LAST, item.ts_code ASC" in stock_page_query
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
@@ -15,6 +16,14 @@ from zhixing_server.modules.selection.domain.models import (
|
||||
SelectionSignal,
|
||||
StockHistory,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.pattern_scoring import (
|
||||
PATTERN_SCORE_THRESHOLD,
|
||||
PATTERN_SCORING_VERSION,
|
||||
ZHIXING_B1_PATTERN_CASES,
|
||||
PatternCase,
|
||||
PatternScore,
|
||||
PatternScoreBreakdown,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.runs import (
|
||||
SelectionExecutionSource,
|
||||
SelectionResultQuery,
|
||||
@@ -198,6 +207,37 @@ class ConcurrentHistoryEvaluator:
|
||||
return SelectionEvaluation(history.ts_code, target_trade_date, "no_signal")
|
||||
|
||||
|
||||
class FakePatternCaseLoader:
|
||||
def __init__(self, *, error: Exception | None = None) -> None:
|
||||
self.calls = 0
|
||||
self.error = error
|
||||
|
||||
def load(self) -> tuple[PatternCase, ...]:
|
||||
self.calls += 1
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return ()
|
||||
|
||||
|
||||
class FakePatternScorer:
|
||||
def __init__(self, *, error: Exception | None = None) -> None:
|
||||
self.calls: list[str] = []
|
||||
self.error = error
|
||||
|
||||
def score(self, history: StockHistory, cases: Sequence[PatternCase]) -> PatternScore:
|
||||
self.calls.append(history.ts_code)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return PatternScore(
|
||||
status="matched",
|
||||
value=88.0,
|
||||
threshold=PATTERN_SCORE_THRESHOLD,
|
||||
version=PATTERN_SCORING_VERSION,
|
||||
case=ZHIXING_B1_PATTERN_CASES[0],
|
||||
breakdown=PatternScoreBreakdown(80.0, 85.0, 90.0, 88.0),
|
||||
)
|
||||
|
||||
|
||||
def _source() -> SelectionExecutionSource:
|
||||
return SelectionExecutionSource(
|
||||
market_sync_batch_id="market-run-1",
|
||||
@@ -364,3 +404,149 @@ def test_execute_marks_batch_write_failure_as_failed() -> None:
|
||||
assert store.finished[0:2] == ("run-1", "failed")
|
||||
assert store.finished[2]["error_type"] == "batch_error"
|
||||
assert store.finished[2]["failed_count"] == 1
|
||||
|
||||
|
||||
def test_execute_loads_cases_once_and_scores_only_selected_stocks() -> None:
|
||||
source = _source()
|
||||
reader = BatchReader(source)
|
||||
store = FakeStore()
|
||||
loader = FakePatternCaseLoader()
|
||||
scorer = FakePatternScorer()
|
||||
evaluator = FakeEvaluator(
|
||||
{
|
||||
"000001.SZ": SelectionEvaluation(
|
||||
"000001.SZ",
|
||||
TARGET,
|
||||
"selected",
|
||||
signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),),
|
||||
),
|
||||
"600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"),
|
||||
}
|
||||
)
|
||||
service = RunZhixingB1(
|
||||
reader,
|
||||
store,
|
||||
evaluator,
|
||||
loader,
|
||||
scorer,
|
||||
pattern_scoring_enabled=True,
|
||||
batch_size=1,
|
||||
)
|
||||
|
||||
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
|
||||
|
||||
assert loader.calls == 1
|
||||
assert scorer.calls == ["000001.SZ"]
|
||||
assert [item.pattern_score.status for item in store.items] == ["matched", "not_executed"]
|
||||
assert store.finished is not None
|
||||
assert store.finished[0:2] == ("run-1", "success")
|
||||
assert store.finished[2]["failed_count"] == 0
|
||||
|
||||
|
||||
def test_execute_isolates_pattern_scoring_failure_from_selection_status() -> None:
|
||||
source = _source()
|
||||
store = FakeStore()
|
||||
loader = FakePatternCaseLoader()
|
||||
scorer = FakePatternScorer(error=RuntimeError("FastDTW unavailable"))
|
||||
evaluator = FakeEvaluator(
|
||||
{
|
||||
"000001.SZ": SelectionEvaluation(
|
||||
"000001.SZ",
|
||||
TARGET,
|
||||
"selected",
|
||||
signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),),
|
||||
),
|
||||
"600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"),
|
||||
}
|
||||
)
|
||||
service = RunZhixingB1(
|
||||
BatchReader(source),
|
||||
store,
|
||||
evaluator,
|
||||
loader,
|
||||
scorer,
|
||||
pattern_scoring_enabled=True,
|
||||
)
|
||||
|
||||
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
|
||||
|
||||
assert store.items[0].status == "selected"
|
||||
assert store.items[0].pattern_score == PatternScore.failed("FastDTW unavailable")
|
||||
assert store.finished is not None
|
||||
assert store.finished[0:2] == ("run-1", "success")
|
||||
assert store.finished[2]["failed_count"] == 0
|
||||
|
||||
|
||||
def test_execute_skips_pattern_dependencies_when_feature_flag_is_disabled() -> None:
|
||||
source = _source()
|
||||
store = FakeStore()
|
||||
loader = FakePatternCaseLoader(error=AssertionError("loader must not run"))
|
||||
scorer = FakePatternScorer(error=AssertionError("scorer must not run"))
|
||||
evaluator = FakeEvaluator(
|
||||
{
|
||||
"000001.SZ": SelectionEvaluation(
|
||||
"000001.SZ",
|
||||
TARGET,
|
||||
"selected",
|
||||
signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),),
|
||||
),
|
||||
"600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"),
|
||||
}
|
||||
)
|
||||
service = RunZhixingB1(
|
||||
BatchReader(source),
|
||||
store,
|
||||
evaluator,
|
||||
loader,
|
||||
scorer,
|
||||
pattern_scoring_enabled=False,
|
||||
)
|
||||
|
||||
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
|
||||
|
||||
assert loader.calls == 0
|
||||
assert scorer.calls == []
|
||||
assert [item.pattern_score.status for item in store.items] == [
|
||||
"not_executed",
|
||||
"not_executed",
|
||||
]
|
||||
assert store.items[0].signal_count == 1
|
||||
assert store.finished is not None
|
||||
assert store.finished[2]["failed_count"] == 0
|
||||
|
||||
|
||||
def test_execute_marks_scores_failed_when_case_library_is_unavailable() -> None:
|
||||
source = _source()
|
||||
store = FakeStore()
|
||||
loader = FakePatternCaseLoader(error=RuntimeError("case_011 requires 25 qfq rows"))
|
||||
scorer = FakePatternScorer()
|
||||
evaluator = FakeEvaluator(
|
||||
{
|
||||
"000001.SZ": SelectionEvaluation(
|
||||
"000001.SZ",
|
||||
TARGET,
|
||||
"selected",
|
||||
signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),),
|
||||
),
|
||||
"600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"),
|
||||
}
|
||||
)
|
||||
service = RunZhixingB1(
|
||||
BatchReader(source),
|
||||
store,
|
||||
evaluator,
|
||||
loader,
|
||||
scorer,
|
||||
pattern_scoring_enabled=True,
|
||||
)
|
||||
|
||||
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
|
||||
|
||||
assert loader.calls == 1
|
||||
assert scorer.calls == []
|
||||
assert store.items[0].status == "selected"
|
||||
assert store.items[0].pattern_score.status == "failed"
|
||||
assert store.items[0].signals[0].category.value == "zhixing_b1_original_b1"
|
||||
assert store.finished is not None
|
||||
assert store.finished[0:2] == ("run-1", "success")
|
||||
assert store.finished[2]["failed_count"] == 0
|
||||
|
||||
Reference in New Issue
Block a user