stock-analyzer-skill 1.1.0

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 (154) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +21 -0
  3. package/CHANGELOG.md +93 -0
  4. package/CONTRIBUTING.md +331 -0
  5. package/README.md +259 -0
  6. package/experts/README.md +119 -0
  7. package/experts/buffett.md +91 -0
  8. package/experts/chaogu_yangjia.md +125 -0
  9. package/experts/decide.md +212 -0
  10. package/experts/duan_yongping.md +106 -0
  11. package/experts/lynch.md +127 -0
  12. package/experts/soros.md +89 -0
  13. package/experts/xu_xiang.md +107 -0
  14. package/experts/zhao_laoge.md +143 -0
  15. package/experts/zuoshou_xinyi.md +144 -0
  16. package/install-plugin.js +69 -0
  17. package/methodology.md +455 -0
  18. package/package.json +43 -0
  19. package/scripts/__pycache__/announcements.cpython-314.pyc +0 -0
  20. package/scripts/__pycache__/backtest.cpython-314.pyc +0 -0
  21. package/scripts/__pycache__/chan.cpython-314.pyc +0 -0
  22. package/scripts/__pycache__/classifier.cpython-314.pyc +0 -0
  23. package/scripts/__pycache__/common.cpython-314.pyc +0 -0
  24. package/scripts/__pycache__/finance.cpython-314.pyc +0 -0
  25. package/scripts/__pycache__/init_pool.cpython-314.pyc +0 -0
  26. package/scripts/__pycache__/kline.cpython-314.pyc +0 -0
  27. package/scripts/__pycache__/patterns_local.cpython-314.pyc +0 -0
  28. package/scripts/__pycache__/quote.cpython-314.pyc +0 -0
  29. package/scripts/__pycache__/refresh_pool.cpython-314.pyc +0 -0
  30. package/scripts/__pycache__/screener.cpython-314.pyc +0 -0
  31. package/scripts/__pycache__/technical.cpython-314.pyc +0 -0
  32. package/scripts/announcements.py +118 -0
  33. package/scripts/backtest.py +528 -0
  34. package/scripts/chan.py +591 -0
  35. package/scripts/classifier.py +302 -0
  36. package/scripts/common.py +507 -0
  37. package/scripts/data/__init__.py +208 -0
  38. package/scripts/data/__pycache__/__init__.cpython-314.pyc +0 -0
  39. package/scripts/data/__pycache__/cache.cpython-314.pyc +0 -0
  40. package/scripts/data/__pycache__/config.cpython-314.pyc +0 -0
  41. package/scripts/data/__pycache__/types.cpython-314.pyc +0 -0
  42. package/scripts/data/cache.py +99 -0
  43. package/scripts/data/config.py +49 -0
  44. package/scripts/data/industry_thresholds.json +199 -0
  45. package/scripts/data/portfolio_example.json +14 -0
  46. package/scripts/data/sector_etf.csv +14 -0
  47. package/scripts/data/sector_mapping.json +64 -0
  48. package/scripts/data/sector_stocks.json +135 -0
  49. package/scripts/data/types.py +66 -0
  50. package/scripts/fetchers/__init__.py +130 -0
  51. package/scripts/fetchers/__pycache__/__init__.cpython-314.pyc +0 -0
  52. package/scripts/fetchers/__pycache__/akshare_finance.cpython-314.pyc +0 -0
  53. package/scripts/fetchers/__pycache__/akshare_kline.cpython-314.pyc +0 -0
  54. package/scripts/fetchers/__pycache__/akshare_quote.cpython-314.pyc +0 -0
  55. package/scripts/fetchers/__pycache__/baostock_kline.cpython-314.pyc +0 -0
  56. package/scripts/fetchers/__pycache__/eastmoney_finance.cpython-314.pyc +0 -0
  57. package/scripts/fetchers/__pycache__/eastmoney_kline.cpython-314.pyc +0 -0
  58. package/scripts/fetchers/__pycache__/eastmoney_quote.cpython-314.pyc +0 -0
  59. package/scripts/fetchers/__pycache__/efinance_finance.cpython-314.pyc +0 -0
  60. package/scripts/fetchers/__pycache__/efinance_kline.cpython-314.pyc +0 -0
  61. package/scripts/fetchers/__pycache__/efinance_quote.cpython-314.pyc +0 -0
  62. package/scripts/fetchers/__pycache__/pytdx_quote.cpython-314.pyc +0 -0
  63. package/scripts/fetchers/__pycache__/sina_kline.cpython-314.pyc +0 -0
  64. package/scripts/fetchers/__pycache__/sina_quote.cpython-314.pyc +0 -0
  65. package/scripts/fetchers/__pycache__/tencent_kline.cpython-314.pyc +0 -0
  66. package/scripts/fetchers/__pycache__/tencent_quote.cpython-314.pyc +0 -0
  67. package/scripts/fetchers/__pycache__/tushare_kline.cpython-314.pyc +0 -0
  68. package/scripts/fetchers/__pycache__/tushare_quote.cpython-314.pyc +0 -0
  69. package/scripts/fetchers/__pycache__/yfinance_kline.cpython-314.pyc +0 -0
  70. package/scripts/fetchers/akshare_finance.py +35 -0
  71. package/scripts/fetchers/akshare_kline.py +59 -0
  72. package/scripts/fetchers/akshare_quote.py +52 -0
  73. package/scripts/fetchers/baostock_kline.py +64 -0
  74. package/scripts/fetchers/eastmoney_finance.py +29 -0
  75. package/scripts/fetchers/eastmoney_kline.py +48 -0
  76. package/scripts/fetchers/eastmoney_quote.py +68 -0
  77. package/scripts/fetchers/efinance_finance.py +32 -0
  78. package/scripts/fetchers/efinance_kline.py +46 -0
  79. package/scripts/fetchers/efinance_quote.py +53 -0
  80. package/scripts/fetchers/pytdx_kline.py +70 -0
  81. package/scripts/fetchers/pytdx_quote.py +78 -0
  82. package/scripts/fetchers/sina_kline.py +30 -0
  83. package/scripts/fetchers/sina_quote.py +35 -0
  84. package/scripts/fetchers/tencent_kline.py +52 -0
  85. package/scripts/fetchers/tencent_quote.py +29 -0
  86. package/scripts/fetchers/tushare_kline.py +62 -0
  87. package/scripts/fetchers/tushare_quote.py +62 -0
  88. package/scripts/fetchers/yfinance_kline.py +66 -0
  89. package/scripts/finance.py +92 -0
  90. package/scripts/init_pool.py +105 -0
  91. package/scripts/kline.py +62 -0
  92. package/scripts/monitor.py +107 -0
  93. package/scripts/patterns_local.py +599 -0
  94. package/scripts/quote.py +69 -0
  95. package/scripts/refresh_pool.py +328 -0
  96. package/scripts/screener.py +434 -0
  97. package/scripts/strategies/__init__.py +11 -0
  98. package/scripts/strategies/__pycache__/__init__.cpython-314.pyc +0 -0
  99. package/scripts/strategies/__pycache__/registry.cpython-314.pyc +0 -0
  100. package/scripts/strategies/__pycache__/thresholds.cpython-314.pyc +0 -0
  101. package/scripts/strategies/factors/__init__.py +8 -0
  102. package/scripts/strategies/factors/__pycache__/__init__.cpython-314.pyc +0 -0
  103. package/scripts/strategies/factors/__pycache__/liquidity.cpython-314.pyc +0 -0
  104. package/scripts/strategies/factors/__pycache__/momentum.cpython-314.pyc +0 -0
  105. package/scripts/strategies/factors/__pycache__/quality.cpython-314.pyc +0 -0
  106. package/scripts/strategies/factors/__pycache__/valuation.cpython-314.pyc +0 -0
  107. package/scripts/strategies/factors/__pycache__/volatility.cpython-314.pyc +0 -0
  108. package/scripts/strategies/factors/liquidity.py +49 -0
  109. package/scripts/strategies/factors/momentum.py +45 -0
  110. package/scripts/strategies/factors/quality.py +54 -0
  111. package/scripts/strategies/factors/valuation.py +76 -0
  112. package/scripts/strategies/factors/volatility.py +89 -0
  113. package/scripts/strategies/registry.py +87 -0
  114. package/scripts/strategies/thresholds.py +28 -0
  115. package/scripts/technical/__init__.py +116 -0
  116. package/scripts/technical/__pycache__/__init__.cpython-314.pyc +0 -0
  117. package/scripts/technical/__pycache__/astock.cpython-314.pyc +0 -0
  118. package/scripts/technical/__pycache__/boll.cpython-314.pyc +0 -0
  119. package/scripts/technical/__pycache__/candlestick.cpython-314.pyc +0 -0
  120. package/scripts/technical/__pycache__/core.cpython-314.pyc +0 -0
  121. package/scripts/technical/__pycache__/kdj.cpython-314.pyc +0 -0
  122. package/scripts/technical/__pycache__/macd.cpython-314.pyc +0 -0
  123. package/scripts/technical/__pycache__/moving_average.cpython-314.pyc +0 -0
  124. package/scripts/technical/__pycache__/report.cpython-314.pyc +0 -0
  125. package/scripts/technical/__pycache__/rsi.cpython-314.pyc +0 -0
  126. package/scripts/technical/__pycache__/scoring.cpython-314.pyc +0 -0
  127. package/scripts/technical/__pycache__/signals.cpython-314.pyc +0 -0
  128. package/scripts/technical/__pycache__/trend.cpython-314.pyc +0 -0
  129. package/scripts/technical/__pycache__/volume.cpython-314.pyc +0 -0
  130. package/scripts/technical/astock.py +98 -0
  131. package/scripts/technical/boll.py +49 -0
  132. package/scripts/technical/candlestick.py +151 -0
  133. package/scripts/technical/core.py +92 -0
  134. package/scripts/technical/kdj.py +68 -0
  135. package/scripts/technical/macd.py +97 -0
  136. package/scripts/technical/moving_average.py +59 -0
  137. package/scripts/technical/report.py +221 -0
  138. package/scripts/technical/rsi.py +37 -0
  139. package/scripts/technical/scoring.py +392 -0
  140. package/scripts/technical/signals.py +70 -0
  141. package/scripts/technical/trend.py +143 -0
  142. package/scripts/technical/volume.py +113 -0
  143. package/scripts/technical.py +215 -0
  144. package/skills/financial-analyst/SKILL.md +141 -0
  145. package/skills/help/SKILL.md +188 -0
  146. package/skills/init/SKILL.md +66 -0
  147. package/skills/investment-researcher/SKILL.md +152 -0
  148. package/skills/market/SKILL.md +99 -0
  149. package/skills/portfolio/SKILL.md +96 -0
  150. package/skills/screener/SKILL.md +128 -0
  151. package/skills/sector/SKILL.md +102 -0
  152. package/skills/stock/SKILL.md +148 -0
  153. package/skills/technical/SKILL.md +168 -0
  154. package/workflow.md +91 -0
@@ -0,0 +1,528 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 多因子选股策略回测框架。
4
+ 用法:
5
+ python3 scripts/backtest.py --strategy balanced --top 5
6
+ python3 scripts/backtest.py --strategy quality_value --top 10 --days 60
7
+ python3 scripts/backtest.py --all --top 5
8
+ python3 scripts/backtest.py --optimize --strategy balanced
9
+ """
10
+ import argparse
11
+ import json
12
+ import sys
13
+ import time
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
18
+ from common import to_float, normalize_quote_code, normalize_finance_code, DATA_DIR
19
+ from data import get_kline, get_finance
20
+ from strategies import STRATEGIES
21
+ from strategies.factors.volatility import volatility_score as _volatility_score
22
+
23
+
24
+ def fetch_historical_returns(code: str, days: int = 60) -> list:
25
+ """获取历史日收益率序列。"""
26
+ bars = get_kline(normalize_quote_code(code), scale=240, datalen=days + 5)
27
+ if not bars or len(bars) < 2:
28
+ return []
29
+ returns = []
30
+ for i in range(1, len(bars)):
31
+ prev_close = bars[i - 1].close
32
+ curr_close = bars[i].close
33
+ if prev_close > 0:
34
+ returns.append((curr_close - prev_close) / prev_close)
35
+ return returns
36
+
37
+
38
+ def _build_hist_quote(bars, i, fin, code):
39
+ """基于历史 K 线和财务数据构建估值/流动性用的行情 dict(严格无前瞻)。"""
40
+ close = bars[i].close
41
+ eps = to_float(fin.get("eps", 0))
42
+ bps = to_float(fin.get("bps", 0))
43
+ pe = close / eps if eps > 0 else 0
44
+ pb = close / bps if bps > 0 else 0
45
+ # 估算总市值(亿元):close * 总股本,总股本从 total_cap / price 推算
46
+ total_cap = to_float(fin.get("total_cap", 0))
47
+ if total_cap <= 0 and bps > 0 and eps > 0:
48
+ # 无法推算,使用 0
49
+ total_cap = 0
50
+ return {
51
+ "code": code,
52
+ "price": close,
53
+ "pe": pe,
54
+ "pb": pb,
55
+ "amount": bars[i].amount,
56
+ "volume": bars[i].volume,
57
+ "total_cap": total_cap,
58
+ "turnover": 0, # 无法从 K 线精确计算,设为 0
59
+ }
60
+
61
+
62
+ def simulate_strategy(strategy_name: str, codes: list, top_n: int = 5,
63
+ holding_days: int = 5, initial_capital: float = 100000):
64
+ """
65
+ 模拟策略收益(滚动窗口回测,无前瞻偏差)。
66
+
67
+ 回测逻辑:
68
+ 1. 获取所有候选股票的 K 线历史数据
69
+ 2. 在每个可用的历史时点 T,仅用 T 及之前的数据计算因子得分
70
+ 3. 选出 top_n 只股票,持有 holding_days 天
71
+ 4. 用 T+1 ~ T+holding_days 的实际收益评估
72
+ 5. 滚动窗口,重复上述过程
73
+
74
+ 注意:财务数据使用回测开始前的最新快照(API 不支持历史快照),
75
+ quality 因子存在轻微前瞻偏差。valuation 和 liquidity 因子
76
+ 基于历史 K 线价格计算,严格无前瞻。
77
+
78
+ Args:
79
+ strategy_name: 策略名称
80
+ codes: 候选股票代码列表
81
+ top_n: 买入数量
82
+ holding_days: 持有天数
83
+ initial_capital: 初始资金
84
+
85
+ Returns:
86
+ 回测结果 dict
87
+ """
88
+ from screener import infer_industry
89
+
90
+ weights = STRATEGIES[strategy_name]
91
+ min_history = 60 # 计算技术指标需要的最少 K 线数
92
+
93
+ # 获取所有候选股票的 K 线历史
94
+ kline_data = {}
95
+ for code in codes:
96
+ ncode = normalize_quote_code(code)
97
+ bars = get_kline(ncode, scale=240, datalen=min_history + holding_days + 10)
98
+ if bars and len(bars) >= min_history:
99
+ kline_data[code] = bars
100
+
101
+ if not kline_data:
102
+ return {"error": "无法获取足够的 K 线数据"}
103
+
104
+ # 获取财务数据(注:API 不支持历史快照,此处使用当前最新数据,
105
+ # quality 因子存在轻微前瞻偏差,valuation/liquidity 因子已改为基于历史价格计算)
106
+ fin_cache = {}
107
+ industry_cache = {}
108
+ for code in codes:
109
+ industry_cache[code] = infer_industry("", code)
110
+ try:
111
+ fin_records = get_finance(normalize_finance_code(code))
112
+ fin_cache[code] = fin_records[0].to_dict() if fin_records else {}
113
+ except Exception:
114
+ fin_cache[code] = {}
115
+
116
+ # 滚动窗口回测(不再获取当前行情快照,改用历史 K 线数据)
117
+ from screener import quality_score, valuation_score, liquidity_score
118
+
119
+ all_selections = []
120
+
121
+ for code, bars in kline_data.items():
122
+ if len(bars) < min_history + holding_days:
123
+ continue
124
+
125
+ fin = fin_cache.get(code, {})
126
+ industry = industry_cache.get(code, "manufacturing")
127
+
128
+ # 滚动窗口:从 min_history 位置开始,每次前进 holding_days
129
+ i = min_history
130
+ while i + holding_days <= len(bars):
131
+ # 用 i 及之前的数据计算动量因子(严格无前瞻)
132
+ hist = bars[:i]
133
+ momentum = _compute_momentum_from_bars(hist)
134
+
135
+ # 基于历史 K 线价格构建行情 dict(严格无前瞻)
136
+ hist_quote = _build_hist_quote(bars, i, fin, code)
137
+
138
+ parts = {
139
+ "quality": quality_score(fin, industry), # 注:财务数据为当前快照
140
+ "valuation": valuation_score(hist_quote, fin, industry), # 基于历史价格
141
+ "momentum": momentum,
142
+ "liquidity": liquidity_score(hist_quote), # 基于历史成交量
143
+ "volatility": _volatility_score(bars[:i], industry), # 基于历史 K 线
144
+ }
145
+ score = sum(parts.get(k, 0) * weights.get(k, 0) for k in set(parts) | set(weights) if k != "label")
146
+
147
+ # 计算持有期收益(T+1 ~ T+holding_days)
148
+ entry_price = bars[i].close
149
+ exit_price = bars[i + holding_days - 1].close
150
+ if entry_price > 0:
151
+ ret = (exit_price - entry_price) / entry_price
152
+ all_selections.append({
153
+ "code": code,
154
+ "date": bars[i].day,
155
+ "score": round(score, 1),
156
+ "return_pct": round(ret * 100, 2),
157
+ "daily_returns": _calc_daily_returns(bars, i, holding_days),
158
+ })
159
+
160
+ i += holding_days
161
+
162
+ if not all_selections:
163
+ return {"error": "无法计算收益"}
164
+
165
+ # 按日期分组,每组取 top_n 只得分最高的股票
166
+ from itertools import groupby
167
+ all_selections.sort(key=lambda x: x["date"])
168
+ portfolio_returns = []
169
+ portfolio_daily_returns = []
170
+ selection_details = []
171
+
172
+ for date, group in groupby(all_selections, key=lambda x: x["date"]):
173
+ group_list = sorted(group, key=lambda x: x["score"], reverse=True)[:top_n]
174
+ avg_ret = sum(s["return_pct"] for s in group_list) / len(group_list)
175
+ portfolio_returns.append(avg_ret / 100)
176
+ # 合并日收益率用于精确回撤计算
177
+ for s in group_list:
178
+ portfolio_daily_returns.extend(s["daily_returns"])
179
+ selection_details.extend(group_list)
180
+
181
+ avg_return = sum(portfolio_returns) / len(portfolio_returns) * 100
182
+
183
+ return {
184
+ "strategy": strategy_name,
185
+ "selections": selection_details[:20],
186
+ "returns": [round(r * 100, 2) for r in portfolio_returns],
187
+ "daily_returns": portfolio_daily_returns,
188
+ "avg_return_pct": round(avg_return, 2),
189
+ "total_periods": len(portfolio_returns),
190
+ "holding_days": holding_days,
191
+ "top_n": top_n,
192
+ }
193
+
194
+
195
+ def _calc_daily_returns(bars, start, holding_days):
196
+ """计算持有期内的日收益率序列(用于精确回撤计算)。"""
197
+ returns = []
198
+ for j in range(start, start + holding_days):
199
+ if j > 0 and bars[j - 1].close > 0:
200
+ returns.append((bars[j].close - bars[j - 1].close) / bars[j - 1].close)
201
+ return returns
202
+
203
+
204
+ def _compute_momentum_from_bars(bars) -> float:
205
+ """从 K 线数据计算动量因子得分(0-100),严格无前瞻。"""
206
+ if len(bars) < 60:
207
+ return 50.0
208
+
209
+ closes = [b.close for b in bars]
210
+ volumes = [b.volume for b in bars]
211
+
212
+ # 1. 趋势方向:MA5 vs MA20
213
+ ma5 = sum(closes[-5:]) / 5
214
+ ma20 = sum(closes[-20:]) / 20
215
+ trend_score = 70 if ma5 > ma20 else 30
216
+
217
+ # 2. RSI(14) - Wilder 平滑
218
+ rsi_val = _calc_rsi(closes, 14)
219
+ if rsi_val < 30:
220
+ rsi_score = 80
221
+ elif rsi_val < 50:
222
+ rsi_score = 60
223
+ elif rsi_val < 70:
224
+ rsi_score = 40
225
+ else:
226
+ rsi_score = 20
227
+
228
+ # 3. 价格动量(20 日收益率)
229
+ ret20 = (closes[-1] / closes[-20] - 1) if closes[-20] > 0 else 0
230
+ if ret20 > 0.1:
231
+ mom_score = 80
232
+ elif ret20 > 0:
233
+ mom_score = 60
234
+ elif ret20 > -0.1:
235
+ mom_score = 40
236
+ else:
237
+ mom_score = 20
238
+
239
+ # 4. 量比
240
+ if len(volumes) >= 25:
241
+ avg_5 = sum(volumes[-5:]) / 5
242
+ avg_20 = sum(volumes[-25:-5]) / 20 if sum(volumes[-25:-5]) > 0 else 1
243
+ vol_ratio = avg_5 / avg_20 if avg_20 > 0 else 1
244
+ vol_score = min(100, max(0, 50 + (vol_ratio - 1) * 50))
245
+ else:
246
+ vol_score = 50
247
+
248
+ return (trend_score * 0.3 + rsi_score * 0.2 + mom_score * 0.3 + vol_score * 0.2)
249
+
250
+
251
+ def _calc_rsi(closes: list, period: int = 14) -> float:
252
+ """计算 RSI(Wilder 平滑),无前瞻。"""
253
+ if len(closes) < period + 1:
254
+ return 50.0
255
+ gains = []
256
+ losses = []
257
+ for i in range(1, len(closes)):
258
+ diff = closes[i] - closes[i - 1]
259
+ gains.append(max(diff, 0))
260
+ losses.append(max(-diff, 0))
261
+ avg_gain = sum(gains[:period]) / period
262
+ avg_loss = sum(losses[:period]) / period
263
+ for i in range(period, len(gains)):
264
+ avg_gain = (avg_gain * (period - 1) + gains[i]) / period
265
+ avg_loss = (avg_loss * (period - 1) + losses[i]) / period
266
+ if avg_loss == 0:
267
+ return 100.0
268
+ rs = avg_gain / avg_loss
269
+ return 100 - 100 / (1 + rs)
270
+
271
+
272
+ def run_backtest(strategy_name: str, codes: list, top_n: int = 5,
273
+ days: int = 60, rounds: int = 5):
274
+ """
275
+ 运行多轮回测。
276
+
277
+ Args:
278
+ strategy_name: 策略名称
279
+ codes: 候选股票代码
280
+ top_n: 每轮买入数量
281
+ days: 回测天数
282
+ rounds: 回测轮数
283
+
284
+ Returns:
285
+ 回测报告 dict
286
+ """
287
+ all_returns = []
288
+ all_daily_returns = []
289
+ round_results = []
290
+
291
+ for i in range(rounds):
292
+ result = simulate_strategy(strategy_name, codes, top_n, holding_days=days // rounds)
293
+ if "error" not in result:
294
+ all_returns.append(result["avg_return_pct"])
295
+ all_daily_returns.extend(result.get("daily_returns", []))
296
+ round_results.append(result)
297
+
298
+ if not all_returns:
299
+ return {"error": "回测失败,无有效数据"}
300
+
301
+ # 计算统计指标
302
+ total_return = 1.0
303
+ for r in all_returns:
304
+ total_return *= (1 + r / 100)
305
+ total_return = (total_return - 1) * 100
306
+
307
+ avg_return = sum(all_returns) / len(all_returns)
308
+ max_return = max(all_returns)
309
+ min_return = min(all_returns)
310
+ win_rate = sum(1 for r in all_returns if r > 0) / len(all_returns) * 100
311
+
312
+ # 夏普比率(年化,假设无风险利率 3%,一年 252 个交易日)
313
+ annual_risk_free = 0.03
314
+ # 优先使用日收益率计算(更精确),回退到轮次收益率
315
+ if len(all_daily_returns) > 1:
316
+ import statistics
317
+ daily_rf = annual_risk_free / 252
318
+ daily_excess = [r - daily_rf for r in all_daily_returns]
319
+ mean_excess = sum(daily_excess) / len(daily_excess)
320
+ std = statistics.stdev(daily_excess)
321
+ sharpe = mean_excess / std * (252 ** 0.5) if std > 0 else 0
322
+ elif len(all_returns) > 1:
323
+ import statistics
324
+ holding_days_per_round = days // rounds
325
+ risk_free_per_round = annual_risk_free * holding_days_per_round / 252
326
+ excess_returns = [r / 100 - risk_free_per_round for r in all_returns]
327
+ mean_excess = sum(excess_returns) / len(excess_returns)
328
+ std = statistics.stdev(excess_returns)
329
+ periods_per_year = 252 / holding_days_per_round
330
+ sharpe = mean_excess / std * (periods_per_year ** 0.5) if std > 0 else 0
331
+ else:
332
+ sharpe = 0
333
+
334
+ # 最大回撤(优先使用日收益率计算,更精确)
335
+ max_drawdown = 0
336
+ if all_daily_returns:
337
+ cumulative = [1.0]
338
+ for r in all_daily_returns:
339
+ cumulative.append(cumulative[-1] * (1 + r))
340
+ peak = cumulative[0]
341
+ for val in cumulative:
342
+ if val > peak:
343
+ peak = val
344
+ drawdown = (peak - val) / peak
345
+ if drawdown > max_drawdown:
346
+ max_drawdown = drawdown
347
+ else:
348
+ cumulative = [1.0]
349
+ for r in all_returns:
350
+ cumulative.append(cumulative[-1] * (1 + r / 100))
351
+ peak = cumulative[0]
352
+ for val in cumulative:
353
+ if val > peak:
354
+ peak = val
355
+ drawdown = (peak - val) / peak
356
+ if drawdown > max_drawdown:
357
+ max_drawdown = drawdown
358
+
359
+ return {
360
+ "strategy": strategy_name,
361
+ "rounds": rounds,
362
+ "total_return_pct": round(total_return, 2),
363
+ "avg_return_pct": round(avg_return, 2),
364
+ "max_return_pct": round(max_return, 2),
365
+ "min_return_pct": round(min_return, 2),
366
+ "win_rate_pct": round(win_rate, 1),
367
+ "sharpe_ratio": round(sharpe, 2),
368
+ "max_drawdown_pct": round(max_drawdown * 100, 2),
369
+ "round_details": round_results,
370
+ }
371
+
372
+
373
+ def compare_strategies(codes: list, top_n: int = 5, days: int = 60, rounds: int = 5):
374
+ """比较所有策略的表现。"""
375
+ results = {}
376
+ for strategy_name in STRATEGIES:
377
+ print(f" 回测策略: {STRATEGIES[strategy_name]['label']}...", flush=True)
378
+ report = run_backtest(strategy_name, codes, top_n, days, rounds)
379
+ results[strategy_name] = report
380
+ return results
381
+
382
+
383
+ def optimize_weights(codes: list, strategy_name: str, top_n: int = 5, days: int = 60):
384
+ """
385
+ 简单网格搜索优化策略权重。
386
+
387
+ 在当前权重基础上,对 quality/valuation/momentum/liquidity 各 ±5% 做网格搜索。
388
+ """
389
+ import copy
390
+ base_keys = ["quality", "valuation", "momentum", "liquidity"]
391
+ original_weights = {k: STRATEGIES[strategy_name][k] for k in base_keys}
392
+
393
+ best_score = -999
394
+ best_weights = original_weights.copy()
395
+ results = []
396
+
397
+ # 简化:只测试 3 个档位(-5%, 不变, +5%)
398
+ steps = [-0.05, 0, 0.05]
399
+
400
+ print(f" 基准权重: {original_weights}", flush=True)
401
+ base_report = run_backtest(strategy_name, codes, top_n, days, 3)
402
+ base_score = base_report.get("sharpe_ratio", 0)
403
+ print(f" 基准夏普: {base_score:.2f}", flush=True)
404
+
405
+ # 网格搜索(简化版:逐维度调整)
406
+ for key in base_keys:
407
+ for step in steps:
408
+ test_weights = original_weights.copy()
409
+ test_weights[key] = max(0.05, test_weights[key] + step)
410
+
411
+ # 归一化
412
+ total = sum(test_weights.values())
413
+ test_weights = {k: v / total for k, v in test_weights.items()}
414
+
415
+ # 使用深拷贝修改策略权重,避免影响全局状态
416
+ backup = copy.deepcopy(STRATEGIES[strategy_name])
417
+ try:
418
+ STRATEGIES[strategy_name].update(test_weights)
419
+ report = run_backtest(strategy_name, codes, top_n, days, 3)
420
+ finally:
421
+ STRATEGIES[strategy_name].update(backup)
422
+
423
+ score = report.get("sharpe_ratio", 0)
424
+
425
+ results.append({
426
+ "weights": {k: round(v, 3) for k, v in test_weights.items()},
427
+ "sharpe": score,
428
+ "return": report.get("total_return_pct", 0),
429
+ })
430
+
431
+ if score > best_score:
432
+ best_score = score
433
+ best_weights = test_weights.copy()
434
+
435
+ return {
436
+ "strategy": strategy_name,
437
+ "best_weights": {k: round(v, 3) for k, v in best_weights.items()},
438
+ "best_sharpe": round(best_score, 3),
439
+ "baseline_sharpe": round(base_score, 3),
440
+ "improvement": round(best_score - base_score, 3),
441
+ "all_results": results,
442
+ }
443
+
444
+
445
+ def load_test_universe():
446
+ """加载测试股票池。"""
447
+ path = DATA_DIR / "sector_stocks.json"
448
+ if not path.exists():
449
+ return []
450
+ sectors = json.loads(path.read_text(encoding="utf-8"))
451
+ all_codes = []
452
+ for items in sectors.values():
453
+ all_codes.extend(items)
454
+ return sorted(set(all_codes))
455
+
456
+
457
+ def main():
458
+ parser = argparse.ArgumentParser(description="多因子选股策略回测")
459
+ parser.add_argument("--strategy", choices=STRATEGIES.keys(), default="balanced",
460
+ help="回测策略")
461
+ parser.add_argument("--all", action="store_true", help="比较所有策略")
462
+ parser.add_argument("--optimize", action="store_true", help="优化权重")
463
+ parser.add_argument("--top", type=int, default=5, help="每轮买入数量")
464
+ parser.add_argument("--days", type=int, default=60, help="回测天数")
465
+ parser.add_argument("--rounds", type=int, default=5, help="回测轮数")
466
+ parser.add_argument("--codes", help="自定义股票代码(逗号分隔)")
467
+ parser.add_argument("-j", "--json", action="store_true", help="JSON 输出")
468
+ args = parser.parse_args()
469
+
470
+ # 加载股票池
471
+ if args.codes:
472
+ codes = [normalize_quote_code(c) for c in args.codes.split(",")]
473
+ else:
474
+ codes = load_test_universe()
475
+
476
+ if not codes:
477
+ print("❌ 无可用股票池", file=sys.stderr)
478
+ sys.exit(1)
479
+
480
+ print(f"📊 回测股票池: {len(codes)} 只", flush=True)
481
+
482
+ if args.optimize:
483
+ print(f"\n🔧 优化策略权重: {args.strategy}", flush=True)
484
+ result = optimize_weights(codes, args.strategy, args.top, args.days)
485
+ if args.json:
486
+ print(json.dumps(result, ensure_ascii=False, indent=2))
487
+ else:
488
+ print(f"\n最优权重: {result['best_weights']}")
489
+ print(f"最优夏普: {result['best_sharpe']:.3f}")
490
+ print(f"基准夏普: {result['baseline_sharpe']:.3f}")
491
+ print(f"提升: {result['improvement']:+.3f}")
492
+
493
+ elif args.all:
494
+ print(f"\n📈 比较所有策略 (top={args.top}, days={args.days}, rounds={args.rounds})", flush=True)
495
+ results = compare_strategies(codes, args.top, args.days, args.rounds)
496
+ if args.json:
497
+ print(json.dumps(results, ensure_ascii=False, indent=2))
498
+ else:
499
+ print(f"\n{'策略':<18} {'总收益%':>8} {'夏普':>6} {'最大回撤%':>8} {'胜率%':>6}")
500
+ print("-" * 50)
501
+ for name, report in results.items():
502
+ if "error" in report:
503
+ print(f"{name:<18} {'ERROR':>8}")
504
+ else:
505
+ print(f"{name:<18} {report['total_return_pct']:>8.2f} "
506
+ f"{report['sharpe_ratio']:>6.2f} "
507
+ f"{report['max_drawdown_pct']:>8.2f} "
508
+ f"{report['win_rate_pct']:>6.1f}")
509
+
510
+ else:
511
+ print(f"\n📈 回测策略: {args.strategy} (top={args.top}, days={args.days}, rounds={args.rounds})", flush=True)
512
+ report = run_backtest(args.strategy, codes, args.top, args.days, args.rounds)
513
+ if args.json:
514
+ print(json.dumps(report, ensure_ascii=False, indent=2))
515
+ elif "error" in report:
516
+ print(f"❌ {report['error']}")
517
+ else:
518
+ print(f"\n总收益: {report['total_return_pct']:.2f}%")
519
+ print(f"平均收益: {report['avg_return_pct']:.2f}%")
520
+ print(f"最大收益: {report['max_return_pct']:.2f}%")
521
+ print(f"最小收益: {report['min_return_pct']:.2f}%")
522
+ print(f"胜率: {report['win_rate_pct']:.1f}%")
523
+ print(f"夏普比率: {report['sharpe_ratio']:.2f}")
524
+ print(f"最大回撤: {report['max_drawdown_pct']:.2f}%")
525
+
526
+
527
+ if __name__ == "__main__":
528
+ main()