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,507 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 公共工具:编码转换、HTTP 请求、字段映射、ETF 代码表。
4
+ 被 quote.py / finance.py / kline.py / announcements.py 复用。
5
+ """
6
+ import hashlib
7
+ import os
8
+ import random
9
+ import sys
10
+ import json
11
+ import threading
12
+ import time
13
+ import urllib.request
14
+ import urllib.error
15
+ from abc import ABC, abstractmethod
16
+ from enum import Enum
17
+ from pathlib import Path
18
+
19
+ PACKAGE_ROOT = Path(__file__).resolve().parent.parent
20
+ DATA_DIR = Path(__file__).resolve().parent / "data"
21
+ CACHE_DIR = PACKAGE_ROOT / ".cache"
22
+
23
+ USER_AGENTS = [
24
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
25
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
26
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
27
+ "stock-analyzer-skill/1.0",
28
+ ]
29
+
30
+ # ---------- 磁盘缓存(统一由 data/cache.py 管理)----------
31
+
32
+ from data.cache import (
33
+ CACHE_DIR,
34
+ get as cache_get,
35
+ set as cache_set,
36
+ cleanup as cache_cleanup,
37
+ cache_key,
38
+ cache_key_for_stock,
39
+ )
40
+
41
+
42
+ def http_get_cached(url: str, timeout: int = 10, ttl: int = 21600) -> bytes:
43
+ """带缓存的 HTTP GET。先读缓存,未命中则请求并写入缓存。"""
44
+ key = cache_key(url)
45
+ cached = cache_get(key, ttl)
46
+ if cached is not None:
47
+ return cached
48
+ data = http_get(url, timeout)
49
+ cache_set(key, data)
50
+ return data
51
+
52
+
53
+ def http_get_cached_keyed(url: str, key: str, timeout: int = 10, ttl: int = 21600) -> bytes:
54
+ """带语义缓存键的 HTTP GET。"""
55
+ cached = cache_get(key, ttl)
56
+ if cached is not None:
57
+ return cached
58
+ data = http_get(url, timeout)
59
+ cache_set(key, data)
60
+ return data
61
+
62
+ # ---------- HTTP ----------
63
+
64
+ def http_get(url: str, timeout: int = 10, max_retries: int = 3) -> bytes:
65
+ """GET 请求,指数退避重试,UA 随机轮换。429 立即抛出不重试。"""
66
+ last_err = None
67
+ for attempt in range(max_retries):
68
+ req = urllib.request.Request(url, headers={
69
+ "User-Agent": random.choice(USER_AGENTS),
70
+ })
71
+ try:
72
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
73
+ return resp.read()
74
+ except urllib.error.HTTPError as e:
75
+ last_err = e
76
+ if e.code == 429:
77
+ raise RateLimitError(f"429 Too Many Requests: {url}")
78
+ if attempt < max_retries - 1:
79
+ delay = min(1.0 * (2 ** attempt), 8.0)
80
+ jitter = random.uniform(0, delay * 0.5)
81
+ time.sleep(delay + jitter)
82
+ except (urllib.error.URLError, TimeoutError, OSError, ConnectionResetError, BrokenPipeError) as e:
83
+ last_err = e
84
+ if attempt < max_retries - 1:
85
+ delay = min(1.0 * (2 ** attempt), 8.0)
86
+ jitter = random.uniform(0, delay * 0.5)
87
+ time.sleep(delay + jitter)
88
+ raise DataSourceUnavailableError(f"GET {url} 失败(重试 {max_retries} 次): {last_err}")
89
+
90
+ # ---------- 编码 ----------
91
+
92
+ def decode_gbk(data: bytes) -> str:
93
+ """腾讯接口 GBK → UTF-8。"""
94
+ return data.decode("gbk", errors="replace")
95
+
96
+ # ---------- 腾讯行情字段映射 ----------
97
+
98
+ # 字段位(按 ~ 分隔,0-based 索引,已剥除 v_sh600989=" 前缀)
99
+ # 方法论文档的 1-based 编号 - 1 = 本表 0-based
100
+ TENCENT_FIELDS = {
101
+ "market": 0, # 市场代码
102
+ "name": 1, # 名称
103
+ "code": 2, # 股票代码
104
+ "price": 3, # 当前价
105
+ "prev_close": 4, # 昨收
106
+ "open": 5, # 今开
107
+ "change_amt": 31, # 涨跌额
108
+ "change_pct": 32, # 涨跌幅%
109
+ "high": 33, # 最高
110
+ "low": 34, # 最低
111
+ "volume": 36, # 成交量(手)
112
+ "amount": 37, # 成交额(万)
113
+ "turnover": 38, # 换手率%
114
+ "pe": 39, # PE(动)
115
+ "amplitude": 43, # 振幅%
116
+ "total_cap": 44, # 总市值(亿)
117
+ "circulating_cap": 45, # 流通市值(亿)
118
+ "pb": 46, # PB
119
+ "limit_up": 47, # 涨停价
120
+ "limit_down": 48, # 跌停价
121
+ }
122
+
123
+ def parse_tencent_line(line: str) -> dict:
124
+ """解析单行腾讯行情(v_sh600989="..." 形式)。"""
125
+ if "=" not in line or '"' not in line:
126
+ return {}
127
+ payload = line.split('"', 1)[1].rstrip('";\n')
128
+ parts = payload.split("~")
129
+ if len(parts) < 50:
130
+ return {}
131
+ return {
132
+ "code": parts[TENCENT_FIELDS["code"]],
133
+ "name": parts[TENCENT_FIELDS["name"]],
134
+ "price": parts[TENCENT_FIELDS["price"]],
135
+ "prev_close": parts[TENCENT_FIELDS["prev_close"]],
136
+ "open": parts[TENCENT_FIELDS["open"]],
137
+ "change_pct": parts[TENCENT_FIELDS["change_pct"]],
138
+ "change_amt": parts[TENCENT_FIELDS["change_amt"]],
139
+ "high": parts[TENCENT_FIELDS["high"]],
140
+ "low": parts[TENCENT_FIELDS["low"]],
141
+ "volume": parts[TENCENT_FIELDS["volume"]],
142
+ "amount": parts[TENCENT_FIELDS["amount"]],
143
+ "turnover": parts[TENCENT_FIELDS["turnover"]],
144
+ "pe": parts[TENCENT_FIELDS["pe"]],
145
+ "pb": parts[TENCENT_FIELDS["pb"]],
146
+ "total_cap": parts[TENCENT_FIELDS["total_cap"]],
147
+ "circulating_cap": parts[TENCENT_FIELDS["circulating_cap"]],
148
+ }
149
+
150
+
151
+ # ---------- 新浪行情字段映射 ----------
152
+
153
+ SINA_QUOTE_URL = "https://hq.sinajs.cn/list={codes}"
154
+
155
+ def parse_sina_quote_line(line: str) -> dict:
156
+ """解析新浪行情单行: var hq_str_sh600989="名称,今开,昨收,当前价,最高,最低,..."; """
157
+ if '="' not in line:
158
+ return {}
159
+ var_part, data_part = line.split('="', 1)
160
+ code = var_part.split("_")[-1] # sh600989
161
+ fields = data_part.rstrip('";\n').split(",")
162
+ if len(fields) < 32:
163
+ return {}
164
+ try:
165
+ prev = float(fields[2])
166
+ curr = float(fields[3])
167
+ change_pct = str(round((curr / prev - 1) * 100, 2)) if prev > 0 else "0"
168
+ change_amt = str(round(curr - prev, 2)) if prev > 0 else "0"
169
+ except (ValueError, IndexError):
170
+ change_pct = "0"
171
+ change_amt = "0"
172
+
173
+ return {
174
+ "code": code,
175
+ "name": fields[0],
176
+ "open": fields[1],
177
+ "prev_close": fields[2],
178
+ "price": fields[3],
179
+ "high": fields[4],
180
+ "low": fields[5],
181
+ "volume": fields[8], # 成交量(股)
182
+ "amount": fields[9], # 成交额
183
+ "change_pct": change_pct,
184
+ "change_amt": change_amt,
185
+ "turnover": "", # 新浪不直接提供换手率
186
+ "pe": "", # 新浪不直接提供 PE
187
+ "pb": "", # 新浪不直接提供 PB
188
+ "total_cap": "", # 新浪不直接提供总市值
189
+ "circulating_cap": "",
190
+ }
191
+
192
+ # ---------- 东财财务字段 ----------
193
+
194
+ EAST_MONEY_FIELDS = {
195
+ "EPSJB": "每股收益",
196
+ "ROEJQ": "ROE(加权)%",
197
+ "TOTALOPERATEREVETZ": "营收同比%",
198
+ "PARENTNETPROFITTZ": "净利同比%",
199
+ "XSMLL": "毛利率%",
200
+ "XSJLL": "净利率%",
201
+ "ZCFZL": "负债率%",
202
+ "BPS": "每股净资产",
203
+ "MGJYXJJE": "每股经营现金流",
204
+ "XSGJ": "销售净利率%",
205
+ "YSHZ": "营收环比%",
206
+ "SJLTZ": "净利润环比%",
207
+ }
208
+
209
+ # ---------- 工具 ----------
210
+
211
+ def split_codes(arg: str) -> list:
212
+ """支持逗号分隔或文件路径(@file)。"""
213
+ if arg.startswith("@"):
214
+ file_path = Path(arg[1:]).resolve()
215
+ if not str(file_path).startswith(str(DATA_DIR.resolve())):
216
+ raise ValueError(f"文件路径不在允许范围内: {arg[1:]}")
217
+ if not file_path.exists():
218
+ raise FileNotFoundError(f"文件不存在: {arg[1:]}")
219
+ return [line.strip() for line in file_path.read_text(encoding="utf-8").splitlines() if line.strip()]
220
+ return [c.strip() for c in arg.split(",") if c.strip()]
221
+
222
+ def plain_code(code: str) -> str:
223
+ """返回 6 位证券代码。"""
224
+ c = code.strip().lower()
225
+ if c.startswith(("sh", "sz", "bj")):
226
+ c = c[2:]
227
+ return c.upper()
228
+
229
+ def infer_exchange(code: str) -> str:
230
+ """按 A 股代码段推断交易所前缀。"""
231
+ c = plain_code(code)
232
+ if c.startswith(("60", "68", "51", "56", "58")):
233
+ return "sh"
234
+ if c.startswith(("00", "30", "15", "16", "18")):
235
+ return "sz"
236
+ if c.startswith(("43", "83", "87", "88", "92")):
237
+ return "bj"
238
+ return code.strip()[:2].lower() if code.strip()[:2].lower() in {"sh", "sz", "bj"} else ""
239
+
240
+ def normalize_quote_code(code: str) -> str:
241
+ """归一化为腾讯/新浪使用的小写交易所前缀代码。"""
242
+ c = plain_code(code)
243
+ market = infer_exchange(code)
244
+ return f"{market}{c}" if market else code.strip().lower()
245
+
246
+ def normalize_finance_code(code: str) -> str:
247
+ """归一化为东财财务接口使用的大写交易所前缀代码。"""
248
+ q = normalize_quote_code(code)
249
+ return q[:2].upper() + q[2:] if len(q) >= 8 else q.upper()
250
+
251
+ def to_secid(code: str) -> str:
252
+ """转换为东方财富 secid 格式(如 1.600519, 0.000858)。"""
253
+ c = code.strip().lower()
254
+ if c.startswith("sh"):
255
+ return f"1.{c[2:]}"
256
+ if c.startswith("sz"):
257
+ return f"0.{c[2:]}"
258
+ plain = c.lstrip("shszbj")
259
+ if plain.startswith(("60", "68", "51", "56", "58")):
260
+ return f"1.{plain}"
261
+ return f"0.{plain}"
262
+
263
+
264
+ def board_type(code: str) -> str:
265
+ """粗分 A 股板块,用于风险提示和涨跌幅判断。"""
266
+ c = plain_code(code)
267
+ if c.startswith("688"):
268
+ return "科创板"
269
+ if c.startswith(("300", "301")):
270
+ return "创业板"
271
+ if c.startswith(("43", "83", "87", "88", "92")):
272
+ return "北交所"
273
+ if c.startswith(("60", "00")):
274
+ return "主板"
275
+ return "其他"
276
+
277
+ def batchify(items: list, size: int = 15):
278
+ """将列表按 size 分批。腾讯单次 ≤15。"""
279
+ for i in range(0, len(items), size):
280
+ yield items[i:i + size]
281
+
282
+ def to_float(value, default=0.0):
283
+ """安全转浮点数,空值/异常返回默认值。"""
284
+ try:
285
+ if value in (None, "", "-"):
286
+ return default
287
+ return float(str(value).replace(",", ""))
288
+ except (TypeError, ValueError):
289
+ return default
290
+
291
+
292
+ def to_int(value, default=0):
293
+ """安全转整数,空值/异常返回默认值。"""
294
+ try:
295
+ if value in (None, "", "-"):
296
+ return default
297
+ return int(float(str(value).replace(",", "")))
298
+ except (TypeError, ValueError):
299
+ return default
300
+
301
+
302
+ def clamp(value, low=0.0, high=100.0):
303
+ """将值限制在 [low, high] 区间。"""
304
+ return max(low, min(high, value))
305
+
306
+
307
+ def err(msg: str):
308
+ """抛出 DataError 异常(替代原来的 sys.exit)。"""
309
+ print(f"❌ {msg}", file=sys.stderr)
310
+ raise DataError(msg)
311
+
312
+
313
+ def parallel_map(fn, items, max_workers=8, timeout=60):
314
+ """并发执行 fn(item),返回 {item: result} 字典。"""
315
+ import logging
316
+ from concurrent.futures import ThreadPoolExecutor, as_completed
317
+ logger = logging.getLogger(__name__)
318
+ results = {}
319
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
320
+ futures = {ex.submit(fn, item): item for item in items}
321
+ for future in as_completed(futures, timeout=timeout):
322
+ item = futures[future]
323
+ try:
324
+ results[item] = future.result()
325
+ except Exception as e:
326
+ logger.warning("parallel_map 任务失败: %s -> %s", item, e)
327
+ results[item] = None
328
+ return results
329
+
330
+
331
+ # ---------- 异常分类 ----------
332
+
333
+ class RateLimitError(Exception):
334
+ """429 Too Many Requests"""
335
+ pass
336
+
337
+ class DataSourceUnavailableError(Exception):
338
+ """数据源不可用(连接失败、超时等)"""
339
+ pass
340
+
341
+ class DataParseError(Exception):
342
+ """数据解析失败"""
343
+ pass
344
+
345
+ class DataError(Exception):
346
+ """通用数据错误,用于替代 err() 的 sys.exit。"""
347
+ pass
348
+
349
+
350
+ # ---------- 熔断器 ----------
351
+
352
+ class CircuitState(Enum):
353
+ CLOSED = "closed" # 正常
354
+ OPEN = "open" # 熔断
355
+ HALF_OPEN = "half_open" # 试探
356
+
357
+ class CircuitBreaker:
358
+ """线程安全的熔断器:连续失败 N 次后熔断,超时后半开试探。"""
359
+
360
+ def __init__(self, name: str, failure_threshold: int = 5,
361
+ recovery_timeout: int = 60, half_open_max: int = 3):
362
+ self.name = name
363
+ self.failure_threshold = failure_threshold
364
+ self.recovery_timeout = recovery_timeout
365
+ self.half_open_max = half_open_max
366
+
367
+ self._lock = threading.Lock()
368
+ self.state = CircuitState.CLOSED
369
+ self.failure_count = 0
370
+ self.last_failure_time = 0
371
+ self.half_open_success = 0
372
+
373
+ def can_execute(self) -> bool:
374
+ """判断是否允许请求(线程安全)。"""
375
+ with self._lock:
376
+ if self.state == CircuitState.CLOSED:
377
+ return True
378
+ if self.state == CircuitState.OPEN:
379
+ if time.time() - self.last_failure_time >= self.recovery_timeout:
380
+ self.state = CircuitState.HALF_OPEN
381
+ self.half_open_success = 0
382
+ return True
383
+ return False
384
+ if self.state == CircuitState.HALF_OPEN:
385
+ return True
386
+ return False
387
+
388
+ def record_success(self):
389
+ """记录成功(线程安全)。"""
390
+ with self._lock:
391
+ if self.state == CircuitState.HALF_OPEN:
392
+ self.half_open_success += 1
393
+ if self.half_open_success >= self.half_open_max:
394
+ self.state = CircuitState.CLOSED
395
+ self.failure_count = 0
396
+ elif self.state == CircuitState.CLOSED:
397
+ self.failure_count = 0
398
+
399
+ def record_failure(self):
400
+ """记录失败(线程安全)。"""
401
+ with self._lock:
402
+ self.failure_count += 1
403
+ self.last_failure_time = time.time()
404
+ if self.state == CircuitState.HALF_OPEN:
405
+ self.state = CircuitState.OPEN
406
+ elif self.failure_count >= self.failure_threshold:
407
+ self.state = CircuitState.OPEN
408
+
409
+ def reset(self):
410
+ """重置熔断器(线程安全)。"""
411
+ with self._lock:
412
+ self.state = CircuitState.CLOSED
413
+ self.failure_count = 0
414
+ self.last_failure_time = 0
415
+
416
+
417
+ # 全局熔断器实例(线程安全)
418
+ _circuit_breakers = {}
419
+ _circuit_breakers_lock = threading.Lock()
420
+
421
+ def get_circuit_breaker(name: str, **kwargs) -> CircuitBreaker:
422
+ """获取或创建熔断器实例(线程安全)。"""
423
+ with _circuit_breakers_lock:
424
+ if name not in _circuit_breakers:
425
+ _circuit_breakers[name] = CircuitBreaker(name, **kwargs)
426
+ return _circuit_breakers[name]
427
+
428
+
429
+ # ---------- 数据源抽象基类 ----------
430
+
431
+ class BaseFetcher(ABC):
432
+ """数据源抽象基类。"""
433
+
434
+ def __init__(self, name: str, priority: int = 0):
435
+ self.name = name
436
+ self.priority = priority
437
+ self.circuit_breaker = get_circuit_breaker(name)
438
+
439
+ @abstractmethod
440
+ def fetch(self, code: str, **kwargs) -> dict | list | None:
441
+ """获取数据。返回 None 表示失败。"""
442
+ pass
443
+
444
+ def is_available(self) -> bool:
445
+ """检查数据源是否可用(熔断器状态)。"""
446
+ return self.circuit_breaker.can_execute()
447
+
448
+ def on_success(self):
449
+ """记录成功。"""
450
+ self.circuit_breaker.record_success()
451
+
452
+ def on_failure(self):
453
+ """记录失败。"""
454
+ self.circuit_breaker.record_failure()
455
+
456
+
457
+ class DataFetcherManager:
458
+ """数据源策略管理器:按优先级尝试,自动故障切换。"""
459
+
460
+ def __init__(self, fetchers: list):
461
+ self.fetchers = sorted(fetchers, key=lambda f: f.priority, reverse=True)
462
+
463
+ def fetch(self, code: str, **kwargs) -> dict | list | None:
464
+ """按优先级尝试各数据源。"""
465
+ last_error = None
466
+ for fetcher in self.fetchers:
467
+ if not fetcher.is_available():
468
+ continue
469
+ try:
470
+ result = fetcher.fetch(code, **kwargs)
471
+ if result is not None:
472
+ fetcher.on_success()
473
+ return result
474
+ fetcher.on_failure()
475
+ except RateLimitError:
476
+ fetcher.on_failure()
477
+ raise # 限流直接抛出
478
+ except Exception as e:
479
+ fetcher.on_failure()
480
+ last_error = e
481
+ continue
482
+ return None
483
+
484
+ def fetch_with_fallback(self, code: str, fallback=None, **kwargs):
485
+ """带默认值的获取。"""
486
+ result = self.fetch(code, **kwargs)
487
+ return result if result is not None else fallback
488
+
489
+ def fetch_with_cache_fallback(self, code: str, cache_prefix: str = None,
490
+ cache_ttl: int = 21600, fallback=None, **kwargs):
491
+ """带缓存降级的获取:优先实时数据 → 缓存数据 → 默认值。"""
492
+ result = self.fetch(code, **kwargs)
493
+ if result is not None:
494
+ return result
495
+
496
+ # 尝试从缓存降级
497
+ if cache_prefix:
498
+ key = cache_key_for_stock(cache_prefix, code, **kwargs)
499
+ cached = cache_get(key, cache_ttl)
500
+ if cached is not None:
501
+ try:
502
+ import json
503
+ return json.loads(cached)
504
+ except (json.JSONDecodeError, Exception):
505
+ pass
506
+
507
+ return fallback