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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +21 -0
- package/CHANGELOG.md +93 -0
- package/CONTRIBUTING.md +331 -0
- package/README.md +259 -0
- package/experts/README.md +119 -0
- package/experts/buffett.md +91 -0
- package/experts/chaogu_yangjia.md +125 -0
- package/experts/decide.md +212 -0
- package/experts/duan_yongping.md +106 -0
- package/experts/lynch.md +127 -0
- package/experts/soros.md +89 -0
- package/experts/xu_xiang.md +107 -0
- package/experts/zhao_laoge.md +143 -0
- package/experts/zuoshou_xinyi.md +144 -0
- package/install-plugin.js +69 -0
- package/methodology.md +455 -0
- package/package.json +43 -0
- package/scripts/__pycache__/announcements.cpython-314.pyc +0 -0
- package/scripts/__pycache__/backtest.cpython-314.pyc +0 -0
- package/scripts/__pycache__/chan.cpython-314.pyc +0 -0
- package/scripts/__pycache__/classifier.cpython-314.pyc +0 -0
- package/scripts/__pycache__/common.cpython-314.pyc +0 -0
- package/scripts/__pycache__/finance.cpython-314.pyc +0 -0
- package/scripts/__pycache__/init_pool.cpython-314.pyc +0 -0
- package/scripts/__pycache__/kline.cpython-314.pyc +0 -0
- package/scripts/__pycache__/patterns_local.cpython-314.pyc +0 -0
- package/scripts/__pycache__/quote.cpython-314.pyc +0 -0
- package/scripts/__pycache__/refresh_pool.cpython-314.pyc +0 -0
- package/scripts/__pycache__/screener.cpython-314.pyc +0 -0
- package/scripts/__pycache__/technical.cpython-314.pyc +0 -0
- package/scripts/announcements.py +118 -0
- package/scripts/backtest.py +528 -0
- package/scripts/chan.py +591 -0
- package/scripts/classifier.py +302 -0
- package/scripts/common.py +507 -0
- package/scripts/data/__init__.py +208 -0
- package/scripts/data/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/data/__pycache__/cache.cpython-314.pyc +0 -0
- package/scripts/data/__pycache__/config.cpython-314.pyc +0 -0
- package/scripts/data/__pycache__/types.cpython-314.pyc +0 -0
- package/scripts/data/cache.py +99 -0
- package/scripts/data/config.py +49 -0
- package/scripts/data/industry_thresholds.json +199 -0
- package/scripts/data/portfolio_example.json +14 -0
- package/scripts/data/sector_etf.csv +14 -0
- package/scripts/data/sector_mapping.json +64 -0
- package/scripts/data/sector_stocks.json +135 -0
- package/scripts/data/types.py +66 -0
- package/scripts/fetchers/__init__.py +130 -0
- package/scripts/fetchers/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/akshare_finance.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/akshare_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/akshare_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/baostock_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/eastmoney_finance.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/eastmoney_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/eastmoney_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/efinance_finance.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/efinance_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/efinance_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/pytdx_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/sina_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/sina_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/tencent_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/tencent_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/tushare_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/tushare_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/yfinance_kline.cpython-314.pyc +0 -0
- package/scripts/fetchers/akshare_finance.py +35 -0
- package/scripts/fetchers/akshare_kline.py +59 -0
- package/scripts/fetchers/akshare_quote.py +52 -0
- package/scripts/fetchers/baostock_kline.py +64 -0
- package/scripts/fetchers/eastmoney_finance.py +29 -0
- package/scripts/fetchers/eastmoney_kline.py +48 -0
- package/scripts/fetchers/eastmoney_quote.py +68 -0
- package/scripts/fetchers/efinance_finance.py +32 -0
- package/scripts/fetchers/efinance_kline.py +46 -0
- package/scripts/fetchers/efinance_quote.py +53 -0
- package/scripts/fetchers/pytdx_kline.py +70 -0
- package/scripts/fetchers/pytdx_quote.py +78 -0
- package/scripts/fetchers/sina_kline.py +30 -0
- package/scripts/fetchers/sina_quote.py +35 -0
- package/scripts/fetchers/tencent_kline.py +52 -0
- package/scripts/fetchers/tencent_quote.py +29 -0
- package/scripts/fetchers/tushare_kline.py +62 -0
- package/scripts/fetchers/tushare_quote.py +62 -0
- package/scripts/fetchers/yfinance_kline.py +66 -0
- package/scripts/finance.py +92 -0
- package/scripts/init_pool.py +105 -0
- package/scripts/kline.py +62 -0
- package/scripts/monitor.py +107 -0
- package/scripts/patterns_local.py +599 -0
- package/scripts/quote.py +69 -0
- package/scripts/refresh_pool.py +328 -0
- package/scripts/screener.py +434 -0
- package/scripts/strategies/__init__.py +11 -0
- package/scripts/strategies/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/strategies/__pycache__/registry.cpython-314.pyc +0 -0
- package/scripts/strategies/__pycache__/thresholds.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/__init__.py +8 -0
- package/scripts/strategies/factors/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/__pycache__/liquidity.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/__pycache__/momentum.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/__pycache__/quality.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/__pycache__/valuation.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/__pycache__/volatility.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/liquidity.py +49 -0
- package/scripts/strategies/factors/momentum.py +45 -0
- package/scripts/strategies/factors/quality.py +54 -0
- package/scripts/strategies/factors/valuation.py +76 -0
- package/scripts/strategies/factors/volatility.py +89 -0
- package/scripts/strategies/registry.py +87 -0
- package/scripts/strategies/thresholds.py +28 -0
- package/scripts/technical/__init__.py +116 -0
- package/scripts/technical/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/astock.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/boll.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/candlestick.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/core.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/kdj.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/macd.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/moving_average.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/report.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/rsi.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/scoring.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/signals.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/trend.cpython-314.pyc +0 -0
- package/scripts/technical/__pycache__/volume.cpython-314.pyc +0 -0
- package/scripts/technical/astock.py +98 -0
- package/scripts/technical/boll.py +49 -0
- package/scripts/technical/candlestick.py +151 -0
- package/scripts/technical/core.py +92 -0
- package/scripts/technical/kdj.py +68 -0
- package/scripts/technical/macd.py +97 -0
- package/scripts/technical/moving_average.py +59 -0
- package/scripts/technical/report.py +221 -0
- package/scripts/technical/rsi.py +37 -0
- package/scripts/technical/scoring.py +392 -0
- package/scripts/technical/signals.py +70 -0
- package/scripts/technical/trend.py +143 -0
- package/scripts/technical/volume.py +113 -0
- package/scripts/technical.py +215 -0
- package/skills/financial-analyst/SKILL.md +141 -0
- package/skills/help/SKILL.md +188 -0
- package/skills/init/SKILL.md +66 -0
- package/skills/investment-researcher/SKILL.md +152 -0
- package/skills/market/SKILL.md +99 -0
- package/skills/portfolio/SKILL.md +96 -0
- package/skills/screener/SKILL.md +128 -0
- package/skills/sector/SKILL.md +102 -0
- package/skills/stock/SKILL.md +148 -0
- package/skills/technical/SKILL.md +168 -0
- 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
|