stock-analyzer-skill 1.1.0 → 1.2.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/CHANGELOG.md +36 -0
- package/README.md +110 -39
- package/data/reports/202506_Stock_Analysis_Summary.md +271 -0
- package/experts/README.md +23 -2
- package/experts/buffett.md +44 -1
- package/experts/chaogu_yangjia.md +50 -0
- package/experts/decide.md +54 -2
- package/experts/duan_yongping.md +45 -0
- package/experts/lynch.md +48 -1
- package/experts/soros.md +43 -0
- package/experts/xu_xiang.md +44 -0
- package/experts/zhao_laoge.md +53 -0
- package/experts/zuoshou_xinyi.md +66 -0
- package/methodology.md +313 -13
- package/package.json +1 -1
- package/scripts/__pycache__/screener.cpython-314.pyc +0 -0
- package/scripts/api/__init__.py +22 -0
- package/scripts/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/api/__pycache__/quote_cli.cpython-314.pyc +0 -0
- package/scripts/api/__pycache__/screener_cli.cpython-314.pyc +0 -0
- package/scripts/api/quote_cli.py +106 -0
- package/scripts/api/screener_cli.py +149 -0
- package/scripts/business/__init__.py +15 -0
- package/scripts/business/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/business/__pycache__/screening_service.cpython-314.pyc +0 -0
- package/scripts/business/__pycache__/stock_analysis.cpython-314.pyc +0 -0
- package/scripts/business/screening_service.py +267 -0
- package/scripts/business/stock_analysis.py +183 -0
- package/scripts/common/__init__.py +334 -0
- package/scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/common/__pycache__/http.cpython-314.pyc +0 -0
- package/scripts/common/__pycache__/parsers.cpython-314.pyc +0 -0
- package/scripts/common/__pycache__/utils.cpython-314.pyc +0 -0
- package/scripts/common/__pycache__/validators.cpython-314.pyc +0 -0
- package/scripts/common/exceptions/__init__.py +172 -0
- package/scripts/common/exceptions/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/common/http.py +79 -0
- package/scripts/common/metrics.py +92 -0
- package/scripts/common/parsers.py +125 -0
- package/scripts/common/utils.py +195 -0
- package/scripts/common/validators.py +219 -0
- package/scripts/config/__init__.py +24 -0
- package/scripts/config/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/config/__pycache__/loader.cpython-314.pyc +0 -0
- package/scripts/config/data_source.yaml +126 -0
- package/scripts/config/industry_thresholds.yaml +158 -0
- package/scripts/config/limits.yaml +48 -0
- package/scripts/config/loader.py +141 -0
- package/scripts/config/notification.yaml +57 -0
- package/scripts/config/scoring.yaml +159 -0
- package/scripts/data/__pycache__/config.cpython-314.pyc +0 -0
- package/scripts/data/__pycache__/types.cpython-314.pyc +0 -0
- package/scripts/data/config.py +56 -4
- package/scripts/data/portfolio.json +100 -0
- package/scripts/data/portfolio_example.json +66 -11
- package/scripts/data/sector_stocks.json +244 -80
- package/scripts/data/types.py +3 -3
- package/scripts/fetchers/__init__.py +54 -0
- package/scripts/fetchers/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/akshare_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/eastmoney_event.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/eastmoney_flow.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/eastmoney_lhb.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/eastmoney_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/efinance_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/sina_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/__pycache__/tencent_quote.cpython-314.pyc +0 -0
- package/scripts/fetchers/akshare_quote.py +17 -3
- package/scripts/fetchers/eastmoney_event.py +148 -0
- package/scripts/fetchers/eastmoney_flow.py +118 -0
- package/scripts/fetchers/eastmoney_lhb.py +134 -0
- package/scripts/fetchers/eastmoney_quote.py +3 -3
- package/scripts/fetchers/efinance_quote.py +17 -3
- package/scripts/fetchers/sina_quote.py +5 -9
- package/scripts/fetchers/tencent_quote.py +3 -1
- package/scripts/monitor/__init__.py +13 -0
- package/scripts/monitor/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/monitor/__pycache__/manager.cpython-314.pyc +0 -0
- package/scripts/monitor/channels/__init__.py +6 -0
- package/scripts/monitor/channels/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/monitor/channels/__pycache__/bark.cpython-314.pyc +0 -0
- package/scripts/monitor/channels/__pycache__/base.cpython-314.pyc +0 -0
- package/scripts/monitor/channels/bark.py +75 -0
- package/scripts/monitor/channels/base.py +36 -0
- package/scripts/monitor/health.py +148 -0
- package/scripts/monitor/manager.py +229 -0
- package/scripts/portfolio/__init__.py +13 -0
- package/scripts/portfolio/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/portfolio/__pycache__/manager.cpython-314.pyc +0 -0
- package/scripts/portfolio/manager.py +329 -0
- package/scripts/portfolio/performance.py +209 -0
- package/scripts/screener.py +78 -23
- package/scripts/strategies/factors/__pycache__/liquidity.cpython-314.pyc +0 -0
- package/scripts/strategies/factors/liquidity.py +1 -1
- package/skills/backtest/SKILL.md +57 -0
- package/skills/help/SKILL.md +69 -5
- package/skills/monitor/SKILL.md +98 -0
- package/skills/portfolio/SKILL.md +135 -40
- package/skills/stock/SKILL.md +99 -1
- package/skills/{init → stock-init}/SKILL.md +5 -5
- package/workflow.md +36 -2
- package/scripts/common.py +0 -507
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""
|
|
2
|
+
公共工具包:HTTP 请求、字段映射、工具函数、熔断器、数据源抽象。
|
|
3
|
+
|
|
4
|
+
包结构:
|
|
5
|
+
- common/__init__.py # 主模块(re-export + 熔断器/数据源抽象)
|
|
6
|
+
- common/http.py # HTTP 客户端
|
|
7
|
+
- common/parsers.py # 字段映射与解析
|
|
8
|
+
- common/utils.py # 工具函数
|
|
9
|
+
- common/exceptions/ # 统一异常类
|
|
10
|
+
- common/validators.py # 输入验证器
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from enum import Enum
|
|
17
|
+
|
|
18
|
+
# ---------- 子模块导入 ----------
|
|
19
|
+
|
|
20
|
+
# 统一异常类
|
|
21
|
+
from common.exceptions import (
|
|
22
|
+
StockAnalyzerError,
|
|
23
|
+
DataError,
|
|
24
|
+
NetworkError,
|
|
25
|
+
RateLimitError,
|
|
26
|
+
ParseError,
|
|
27
|
+
DataUnavailableError,
|
|
28
|
+
BusinessError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
StrategyError,
|
|
31
|
+
InsufficientDataError,
|
|
32
|
+
ConfigurationError,
|
|
33
|
+
format_error,
|
|
34
|
+
is_retryable_error,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# HTTP 客户端
|
|
38
|
+
from common.http import (
|
|
39
|
+
USER_AGENTS,
|
|
40
|
+
http_get,
|
|
41
|
+
http_get_with_headers,
|
|
42
|
+
decode_gbk,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# 字段映射与解析
|
|
46
|
+
from common.parsers import (
|
|
47
|
+
TENCENT_FIELDS,
|
|
48
|
+
parse_tencent_line,
|
|
49
|
+
SINA_QUOTE_URL,
|
|
50
|
+
parse_sina_quote_line,
|
|
51
|
+
EAST_MONEY_FIELDS,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# 工具函数
|
|
55
|
+
from common.utils import (
|
|
56
|
+
PACKAGE_ROOT,
|
|
57
|
+
DATA_DIR,
|
|
58
|
+
split_codes,
|
|
59
|
+
plain_code,
|
|
60
|
+
infer_exchange,
|
|
61
|
+
normalize_quote_code,
|
|
62
|
+
normalize_finance_code,
|
|
63
|
+
to_secid,
|
|
64
|
+
board_type,
|
|
65
|
+
is_etf,
|
|
66
|
+
batchify,
|
|
67
|
+
to_float,
|
|
68
|
+
to_int,
|
|
69
|
+
clamp,
|
|
70
|
+
normalize_volume,
|
|
71
|
+
normalize_amount,
|
|
72
|
+
err,
|
|
73
|
+
parallel_map,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# 输入验证器
|
|
77
|
+
from common.validators import (
|
|
78
|
+
validate_code,
|
|
79
|
+
normalize_code,
|
|
80
|
+
validate_codes,
|
|
81
|
+
validate_date,
|
|
82
|
+
validate_date_range,
|
|
83
|
+
validate_positive,
|
|
84
|
+
validate_in_range,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# 向后兼容别名
|
|
88
|
+
DataSourceUnavailableError = NetworkError
|
|
89
|
+
DataParseError = ParseError
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------- 缓存代理(延迟导入避免循环依赖)----------
|
|
93
|
+
|
|
94
|
+
_cache_module = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _get_cache_module():
|
|
98
|
+
global _cache_module
|
|
99
|
+
if _cache_module is None:
|
|
100
|
+
from data import cache as _cache_module
|
|
101
|
+
return _cache_module
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _get_cache_items():
|
|
105
|
+
cache = _get_cache_module()
|
|
106
|
+
return cache.CACHE_DIR, cache.get, cache.set, cache.cleanup, cache.cache_key, cache.cache_key_for_stock
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def __getattr__(name):
|
|
110
|
+
if name in ("CACHE_DIR", "cache_get", "cache_set", "cache_cleanup", "cache_key", "cache_key_for_stock"):
|
|
111
|
+
items = _get_cache_items()
|
|
112
|
+
names = ("CACHE_DIR", "cache_get", "cache_set", "cache_cleanup", "cache_key", "cache_key_for_stock")
|
|
113
|
+
idx = names.index(name)
|
|
114
|
+
return items[idx]
|
|
115
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def http_get_cached(url: str, timeout: int = 10, ttl: int = 21600) -> bytes:
|
|
119
|
+
"""带缓存的 HTTP GET。先读缓存,未命中则请求并写入缓存。"""
|
|
120
|
+
cache = _get_cache_module()
|
|
121
|
+
key = cache.cache_key(url)
|
|
122
|
+
cached = cache.get(key, ttl)
|
|
123
|
+
if cached is not None:
|
|
124
|
+
return cached
|
|
125
|
+
data = http_get(url, timeout)
|
|
126
|
+
cache.set(key, data)
|
|
127
|
+
return data
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def http_get_cached_keyed(url: str, key: str, timeout: int = 10, ttl: int = 21600) -> bytes:
|
|
131
|
+
"""带语义缓存键的 HTTP GET。"""
|
|
132
|
+
cache = _get_cache_module()
|
|
133
|
+
cached = cache.get(key, ttl)
|
|
134
|
+
if cached is not None:
|
|
135
|
+
return cached
|
|
136
|
+
data = http_get(url, timeout)
|
|
137
|
+
cache.set(key, data)
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------- 熔断器 ----------
|
|
142
|
+
|
|
143
|
+
class CircuitState(Enum):
|
|
144
|
+
CLOSED = "closed" # 正常
|
|
145
|
+
OPEN = "open" # 熔断
|
|
146
|
+
HALF_OPEN = "half_open" # 试探
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class CircuitBreaker:
|
|
150
|
+
"""线程安全的熔断器:连续失败 N 次后熔断,超时后半开试探。"""
|
|
151
|
+
|
|
152
|
+
def __init__(self, name: str, failure_threshold: int = 5,
|
|
153
|
+
recovery_timeout: int = 60, half_open_max: int = 3):
|
|
154
|
+
self.name = name
|
|
155
|
+
self.failure_threshold = failure_threshold
|
|
156
|
+
self.recovery_timeout = recovery_timeout
|
|
157
|
+
self.half_open_max = half_open_max
|
|
158
|
+
|
|
159
|
+
self._lock = threading.Lock()
|
|
160
|
+
self.state = CircuitState.CLOSED
|
|
161
|
+
self.failure_count = 0
|
|
162
|
+
self.last_failure_time = 0
|
|
163
|
+
self.half_open_success = 0
|
|
164
|
+
|
|
165
|
+
def can_execute(self) -> bool:
|
|
166
|
+
"""判断是否允许请求(线程安全)。"""
|
|
167
|
+
with self._lock:
|
|
168
|
+
if self.state == CircuitState.CLOSED:
|
|
169
|
+
return True
|
|
170
|
+
if self.state == CircuitState.OPEN:
|
|
171
|
+
if time.time() - self.last_failure_time >= self.recovery_timeout:
|
|
172
|
+
self.state = CircuitState.HALF_OPEN
|
|
173
|
+
self.half_open_success = 0
|
|
174
|
+
return True
|
|
175
|
+
return False
|
|
176
|
+
if self.state == CircuitState.HALF_OPEN:
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
def record_success(self):
|
|
181
|
+
"""记录成功(线程安全)。"""
|
|
182
|
+
with self._lock:
|
|
183
|
+
if self.state == CircuitState.HALF_OPEN:
|
|
184
|
+
self.half_open_success += 1
|
|
185
|
+
if self.half_open_success >= self.half_open_max:
|
|
186
|
+
self.state = CircuitState.CLOSED
|
|
187
|
+
self.failure_count = 0
|
|
188
|
+
elif self.state == CircuitState.CLOSED:
|
|
189
|
+
self.failure_count = 0
|
|
190
|
+
|
|
191
|
+
def record_failure(self):
|
|
192
|
+
"""记录失败(线程安全)。"""
|
|
193
|
+
with self._lock:
|
|
194
|
+
self.failure_count += 1
|
|
195
|
+
self.last_failure_time = time.time()
|
|
196
|
+
if self.state == CircuitState.HALF_OPEN:
|
|
197
|
+
self.state = CircuitState.OPEN
|
|
198
|
+
elif self.failure_count >= self.failure_threshold:
|
|
199
|
+
self.state = CircuitState.OPEN
|
|
200
|
+
|
|
201
|
+
def reset(self):
|
|
202
|
+
"""重置熔断器(线程安全)。"""
|
|
203
|
+
with self._lock:
|
|
204
|
+
self.state = CircuitState.CLOSED
|
|
205
|
+
self.failure_count = 0
|
|
206
|
+
self.last_failure_time = 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# 全局熔断器实例(线程安全)
|
|
210
|
+
_circuit_breakers = {}
|
|
211
|
+
_circuit_breakers_lock = threading.Lock()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_circuit_breaker(name: str, **kwargs) -> CircuitBreaker:
|
|
215
|
+
"""获取或创建熔断器实例(线程安全)。"""
|
|
216
|
+
with _circuit_breakers_lock:
|
|
217
|
+
if name not in _circuit_breakers:
|
|
218
|
+
_circuit_breakers[name] = CircuitBreaker(name, **kwargs)
|
|
219
|
+
return _circuit_breakers[name]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------- 数据源抽象基类 ----------
|
|
223
|
+
|
|
224
|
+
class BaseFetcher(ABC):
|
|
225
|
+
"""数据源抽象基类。"""
|
|
226
|
+
|
|
227
|
+
def __init__(self, name: str, priority: int = 0):
|
|
228
|
+
self.name = name
|
|
229
|
+
self.priority = priority
|
|
230
|
+
self.circuit_breaker = get_circuit_breaker(name)
|
|
231
|
+
|
|
232
|
+
@abstractmethod
|
|
233
|
+
def fetch(self, code: str, **kwargs) -> dict | list | None:
|
|
234
|
+
"""获取数据。返回 None 表示失败。"""
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
def is_available(self) -> bool:
|
|
238
|
+
"""检查数据源是否可用(熔断器状态)。"""
|
|
239
|
+
return self.circuit_breaker.can_execute()
|
|
240
|
+
|
|
241
|
+
def on_success(self):
|
|
242
|
+
"""记录成功。"""
|
|
243
|
+
self.circuit_breaker.record_success()
|
|
244
|
+
|
|
245
|
+
def on_failure(self):
|
|
246
|
+
"""记录失败。"""
|
|
247
|
+
self.circuit_breaker.record_failure()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class DataFetcherManager:
|
|
251
|
+
"""数据源策略管理器:按优先级尝试,自动故障切换。"""
|
|
252
|
+
|
|
253
|
+
def __init__(self, fetchers: list):
|
|
254
|
+
self.fetchers = sorted(fetchers, key=lambda f: f.priority, reverse=True)
|
|
255
|
+
|
|
256
|
+
def fetch(self, code: str, **kwargs) -> dict | list | None:
|
|
257
|
+
"""按优先级尝试各数据源。"""
|
|
258
|
+
last_error = None
|
|
259
|
+
for fetcher in self.fetchers:
|
|
260
|
+
if not fetcher.is_available():
|
|
261
|
+
continue
|
|
262
|
+
try:
|
|
263
|
+
result = fetcher.fetch(code, **kwargs)
|
|
264
|
+
if result is not None:
|
|
265
|
+
fetcher.on_success()
|
|
266
|
+
return result
|
|
267
|
+
fetcher.on_failure()
|
|
268
|
+
except RateLimitError:
|
|
269
|
+
fetcher.on_failure()
|
|
270
|
+
raise # 限流直接抛出
|
|
271
|
+
except Exception as e:
|
|
272
|
+
fetcher.on_failure()
|
|
273
|
+
last_error = e
|
|
274
|
+
continue
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def fetch_with_fallback(self, code: str, fallback=None, **kwargs):
|
|
278
|
+
"""带默认值的获取。"""
|
|
279
|
+
result = self.fetch(code, **kwargs)
|
|
280
|
+
return result if result is not None else fallback
|
|
281
|
+
|
|
282
|
+
def fetch_with_cache_fallback(self, code: str, cache_prefix: str = None,
|
|
283
|
+
cache_ttl: int = 21600, fallback=None, **kwargs):
|
|
284
|
+
"""带缓存降级的获取:优先实时数据 → 缓存数据 → 默认值。"""
|
|
285
|
+
result = self.fetch(code, **kwargs)
|
|
286
|
+
if result is not None:
|
|
287
|
+
return result
|
|
288
|
+
|
|
289
|
+
# 尝试从缓存降级
|
|
290
|
+
if cache_prefix:
|
|
291
|
+
cache = _get_cache_module()
|
|
292
|
+
key = cache.cache_key_for_stock(cache_prefix, code, **kwargs)
|
|
293
|
+
cached = cache.get(key, cache_ttl)
|
|
294
|
+
if cached is not None:
|
|
295
|
+
try:
|
|
296
|
+
return json.loads(cached)
|
|
297
|
+
except (json.JSONDecodeError, Exception):
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
return fallback
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------- 导出列表 ----------
|
|
304
|
+
|
|
305
|
+
__all__ = [
|
|
306
|
+
# 基础设施
|
|
307
|
+
"PACKAGE_ROOT", "DATA_DIR", "USER_AGENTS",
|
|
308
|
+
"http_get", "http_get_with_headers", "http_get_cached", "http_get_cached_keyed",
|
|
309
|
+
"decode_gbk",
|
|
310
|
+
# 字段映射与解析
|
|
311
|
+
"TENCENT_FIELDS", "parse_tencent_line",
|
|
312
|
+
"SINA_QUOTE_URL", "parse_sina_quote_line", "EAST_MONEY_FIELDS",
|
|
313
|
+
# 工具函数
|
|
314
|
+
"split_codes", "plain_code", "infer_exchange",
|
|
315
|
+
"normalize_quote_code", "normalize_finance_code", "to_secid",
|
|
316
|
+
"board_type", "is_etf", "batchify", "to_float", "to_int", "clamp",
|
|
317
|
+
"normalize_volume", "normalize_amount",
|
|
318
|
+
"err", "parallel_map",
|
|
319
|
+
# 异常类
|
|
320
|
+
"StockAnalyzerError", "DataError", "NetworkError", "RateLimitError",
|
|
321
|
+
"ParseError", "DataUnavailableError", "BusinessError",
|
|
322
|
+
"ValidationError", "StrategyError", "InsufficientDataError",
|
|
323
|
+
"ConfigurationError", "format_error", "is_retryable_error",
|
|
324
|
+
# 向后兼容别名
|
|
325
|
+
"DataSourceUnavailableError", "DataParseError",
|
|
326
|
+
# 熔断器
|
|
327
|
+
"CircuitState", "CircuitBreaker", "get_circuit_breaker",
|
|
328
|
+
# 数据源抽象
|
|
329
|
+
"BaseFetcher", "DataFetcherManager",
|
|
330
|
+
# 输入验证器
|
|
331
|
+
"validate_code", "normalize_code", "validate_codes",
|
|
332
|
+
"validate_date", "validate_date_range",
|
|
333
|
+
"validate_positive", "validate_in_range",
|
|
334
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
统一异常类定义。
|
|
3
|
+
|
|
4
|
+
异常层次结构:
|
|
5
|
+
- StockAnalyzerError (基础)
|
|
6
|
+
├── DataError (数据层)
|
|
7
|
+
│ ├── NetworkError
|
|
8
|
+
│ ├── RateLimitError
|
|
9
|
+
│ ├── ParseError
|
|
10
|
+
│ └── DataUnavailableError
|
|
11
|
+
│
|
|
12
|
+
└── BusinessError (业务层)
|
|
13
|
+
├── ValidationError
|
|
14
|
+
├── StrategyError
|
|
15
|
+
└── InsufficientDataError
|
|
16
|
+
"""
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StockAnalyzerError(Exception):
|
|
21
|
+
"""项目基础异常类。"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, details: dict = None):
|
|
24
|
+
self.message = message
|
|
25
|
+
self.details = details or {}
|
|
26
|
+
super().__init__(self.message)
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict:
|
|
29
|
+
return {
|
|
30
|
+
"error_type": self.__class__.__name__,
|
|
31
|
+
"message": self.message,
|
|
32
|
+
"details": self.details,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def __repr__(self):
|
|
36
|
+
return f"{self.__class__.__name__}({self.message})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ═══════════════════════════════════════════════════════════════
|
|
40
|
+
# 数据层异常
|
|
41
|
+
# ═══════════════════════════════════════════════════════════════
|
|
42
|
+
|
|
43
|
+
class DataError(StockAnalyzerError):
|
|
44
|
+
"""数据层基类异常。"""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NetworkError(DataError):
|
|
49
|
+
"""网络请求失败。"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, url: str, reason: str, retry_count: int = 0):
|
|
52
|
+
self.url = url
|
|
53
|
+
self.retry_count = retry_count
|
|
54
|
+
super().__init__(
|
|
55
|
+
f"网络请求失败: {reason}",
|
|
56
|
+
{"url": url, "retry_count": retry_count}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RateLimitError(NetworkError):
|
|
61
|
+
"""触发速率限制 (429)。"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, url: str, retry_after: int = None):
|
|
64
|
+
self.retry_after = retry_after
|
|
65
|
+
super().__init__(url, "429 Too Many Requests", 0)
|
|
66
|
+
self.message = f"触发速率限制,请 {retry_after} 秒后重试"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ParseError(DataError):
|
|
70
|
+
"""数据解析失败。"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, raw_data: str, parser: str, reason: str):
|
|
73
|
+
self.parser = parser
|
|
74
|
+
self.raw_preview = raw_data[:200] if raw_data else ""
|
|
75
|
+
super().__init__(
|
|
76
|
+
f"数据解析失败 [{parser}]: {reason}",
|
|
77
|
+
{"parser": parser, "data_preview": self.raw_preview}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DataUnavailableError(DataError):
|
|
82
|
+
"""数据源不可用(连续失败)。"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, source: str, failures: int):
|
|
85
|
+
self.source = source
|
|
86
|
+
self.failures = failures
|
|
87
|
+
super().__init__(
|
|
88
|
+
f"数据源 [{source}] 不可用,已连续失败 {failures} 次",
|
|
89
|
+
{"source": source, "failures": failures}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ═══════════════════════════════════════════════════════════════
|
|
94
|
+
# 业务层异常
|
|
95
|
+
# ═══════════════════════════════════════════════════════════════
|
|
96
|
+
|
|
97
|
+
class BusinessError(StockAnalyzerError):
|
|
98
|
+
"""业务层基类异常。"""
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ValidationError(BusinessError):
|
|
103
|
+
"""输入校验失败。"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, field: str, value: Any, constraint: str):
|
|
106
|
+
self.field = field
|
|
107
|
+
self.value_str = str(value)[:100] if value is not None else None
|
|
108
|
+
super().__init__(
|
|
109
|
+
f"字段 {field} 校验失败: {constraint}",
|
|
110
|
+
{"field": field, "value": self.value_str, "constraint": constraint}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class StrategyError(BusinessError):
|
|
115
|
+
"""策略执行错误。"""
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class InsufficientDataError(BusinessError):
|
|
120
|
+
"""数据不足,无法执行分析。"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, data_type: str, required: int, actual: int, context: str = ""):
|
|
123
|
+
self.data_type = data_type
|
|
124
|
+
self.required = required
|
|
125
|
+
self.actual = actual
|
|
126
|
+
context_info = f" [{context}]" if context else ""
|
|
127
|
+
super().__init__(
|
|
128
|
+
f"{data_type}数据不足{context_info}: 需要 {required} 条,实际 {actual} 条",
|
|
129
|
+
{"data_type": data_type, "required": required, "actual": actual, "context": context}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ConfigurationError(StockAnalyzerError):
|
|
134
|
+
"""配置错误。"""
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ═══════════════════════════════════════════════════════════════
|
|
139
|
+
# 辅助函数
|
|
140
|
+
# ═══════════════════════════════════════════════════════════════
|
|
141
|
+
|
|
142
|
+
def format_error(error: Exception) -> str:
|
|
143
|
+
"""格式化异常为用户友好消息。"""
|
|
144
|
+
if isinstance(error, StockAnalyzerError):
|
|
145
|
+
return error.message
|
|
146
|
+
return f"未知错误: {str(error)}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def is_retryable_error(error: Exception) -> bool:
|
|
150
|
+
"""判断错误是否可重试。"""
|
|
151
|
+
if isinstance(error, RateLimitError):
|
|
152
|
+
return True
|
|
153
|
+
if isinstance(error, NetworkError):
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
__all__ = [
|
|
159
|
+
"StockAnalyzerError",
|
|
160
|
+
"DataError",
|
|
161
|
+
"NetworkError",
|
|
162
|
+
"RateLimitError",
|
|
163
|
+
"ParseError",
|
|
164
|
+
"DataUnavailableError",
|
|
165
|
+
"BusinessError",
|
|
166
|
+
"ValidationError",
|
|
167
|
+
"StrategyError",
|
|
168
|
+
"InsufficientDataError",
|
|
169
|
+
"ConfigurationError",
|
|
170
|
+
"format_error",
|
|
171
|
+
"is_retryable_error",
|
|
172
|
+
]
|
|
Binary file
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""HTTP 客户端:GET 请求、重试、编码转换。"""
|
|
2
|
+
import random
|
|
3
|
+
import time
|
|
4
|
+
import urllib.request
|
|
5
|
+
import urllib.error
|
|
6
|
+
|
|
7
|
+
from common.exceptions import RateLimitError, NetworkError
|
|
8
|
+
|
|
9
|
+
USER_AGENTS = [
|
|
10
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
11
|
+
"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",
|
|
12
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
13
|
+
"stock-analyzer-skill/1.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def http_get(url: str, timeout: int = 10, max_retries: int = 3) -> bytes:
|
|
18
|
+
"""GET 请求,指数退避重试,UA 随机轮换。429 立即抛出不重试。"""
|
|
19
|
+
last_err = None
|
|
20
|
+
for attempt in range(max_retries):
|
|
21
|
+
req = urllib.request.Request(url, headers={
|
|
22
|
+
"User-Agent": random.choice(USER_AGENTS),
|
|
23
|
+
})
|
|
24
|
+
try:
|
|
25
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
26
|
+
return resp.read()
|
|
27
|
+
except urllib.error.HTTPError as e:
|
|
28
|
+
last_err = e
|
|
29
|
+
if e.code == 429:
|
|
30
|
+
raise RateLimitError(url)
|
|
31
|
+
if attempt < max_retries - 1:
|
|
32
|
+
delay = min(1.0 * (2 ** attempt), 8.0)
|
|
33
|
+
jitter = random.uniform(0, delay * 0.5)
|
|
34
|
+
time.sleep(delay + jitter)
|
|
35
|
+
except (urllib.error.URLError, TimeoutError, OSError, ConnectionResetError, BrokenPipeError) as e:
|
|
36
|
+
last_err = e
|
|
37
|
+
if attempt < max_retries - 1:
|
|
38
|
+
delay = min(1.0 * (2 ** attempt), 8.0)
|
|
39
|
+
jitter = random.uniform(0, delay * 0.5)
|
|
40
|
+
time.sleep(delay + jitter)
|
|
41
|
+
raise NetworkError(url, str(last_err), max_retries)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def http_get_with_headers(url: str, headers: dict = None, timeout: int = 10, max_retries: int = 3) -> bytes:
|
|
45
|
+
"""带自定义 headers 的 GET 请求(用于新浪等需要 Referer 的源)。"""
|
|
46
|
+
last_err = None
|
|
47
|
+
req_headers = {"User-Agent": random.choice(USER_AGENTS)}
|
|
48
|
+
if headers:
|
|
49
|
+
req_headers.update(headers)
|
|
50
|
+
for attempt in range(max_retries):
|
|
51
|
+
req = urllib.request.Request(url, headers=req_headers)
|
|
52
|
+
try:
|
|
53
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
54
|
+
return resp.read()
|
|
55
|
+
except urllib.error.HTTPError as e:
|
|
56
|
+
last_err = e
|
|
57
|
+
if e.code == 429:
|
|
58
|
+
raise RateLimitError(url)
|
|
59
|
+
if attempt < max_retries - 1:
|
|
60
|
+
delay = min(1.0 * (2 ** attempt), 8.0)
|
|
61
|
+
jitter = random.uniform(0, delay * 0.5)
|
|
62
|
+
time.sleep(delay + jitter)
|
|
63
|
+
except (urllib.error.URLError, TimeoutError, OSError, ConnectionResetError, BrokenPipeError) as e:
|
|
64
|
+
last_err = e
|
|
65
|
+
if attempt < max_retries - 1:
|
|
66
|
+
delay = min(1.0 * (2 ** attempt), 8.0)
|
|
67
|
+
jitter = random.uniform(0, delay * 0.5)
|
|
68
|
+
time.sleep(delay + jitter)
|
|
69
|
+
raise NetworkError(url, str(last_err), max_retries)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def decode_gbk(data: bytes) -> str:
|
|
73
|
+
"""腾讯接口 GBK → UTF-8。"""
|
|
74
|
+
return data.decode("gbk", errors="replace")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"USER_AGENTS", "http_get", "http_get_with_headers", "decode_gbk",
|
|
79
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""轻量级指标收集器:fetch 延迟、成功率、缓存命中率。"""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import threading
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MetricsCollector:
|
|
10
|
+
"""线程安全的指标收集器。"""
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self._lock = threading.Lock()
|
|
14
|
+
self._counters = defaultdict(int)
|
|
15
|
+
self._latencies = defaultdict(list)
|
|
16
|
+
self._start_time = time.time()
|
|
17
|
+
|
|
18
|
+
def record_fetch(self, source: str, success: bool, latency_ms: float):
|
|
19
|
+
"""记录一次 fetch 调用。"""
|
|
20
|
+
with self._lock:
|
|
21
|
+
self._counters[f"fetch.{source}.total"] += 1
|
|
22
|
+
if success:
|
|
23
|
+
self._counters[f"fetch.{source}.success"] += 1
|
|
24
|
+
else:
|
|
25
|
+
self._counters[f"fetch.{source}.failure"] += 1
|
|
26
|
+
self._latencies[f"fetch.{source}"].append(latency_ms)
|
|
27
|
+
|
|
28
|
+
def record_cache(self, hit: bool):
|
|
29
|
+
"""记录一次缓存访问。"""
|
|
30
|
+
with self._lock:
|
|
31
|
+
self._counters["cache.total"] += 1
|
|
32
|
+
if hit:
|
|
33
|
+
self._counters["cache.hit"] += 1
|
|
34
|
+
else:
|
|
35
|
+
self._counters["cache.miss"] += 1
|
|
36
|
+
|
|
37
|
+
def get_summary(self) -> dict:
|
|
38
|
+
"""获取指标摘要。"""
|
|
39
|
+
with self._lock:
|
|
40
|
+
summary = {
|
|
41
|
+
"uptime_seconds": round(time.time() - self._start_time, 1),
|
|
42
|
+
"counters": dict(self._counters),
|
|
43
|
+
"latency": {},
|
|
44
|
+
}
|
|
45
|
+
# 计算延迟统计
|
|
46
|
+
for key, values in self._latencies.items():
|
|
47
|
+
if values:
|
|
48
|
+
summary["latency"][key] = {
|
|
49
|
+
"avg_ms": round(sum(values) / len(values), 1),
|
|
50
|
+
"min_ms": round(min(values), 1),
|
|
51
|
+
"max_ms": round(max(values), 1),
|
|
52
|
+
"p50_ms": round(sorted(values)[len(values) // 2], 1),
|
|
53
|
+
"count": len(values),
|
|
54
|
+
}
|
|
55
|
+
# 计算成功率
|
|
56
|
+
for source in set(k.split(".")[1] for k in self._counters if k.startswith("fetch.")):
|
|
57
|
+
total = self._counters.get(f"fetch.{source}.total", 0)
|
|
58
|
+
success = self._counters.get(f"fetch.{source}.success", 0)
|
|
59
|
+
if total > 0:
|
|
60
|
+
summary["counters"][f"fetch.{source}.success_rate"] = round(success / total * 100, 1)
|
|
61
|
+
# 缓存命中率
|
|
62
|
+
cache_total = self._counters.get("cache.total", 0)
|
|
63
|
+
cache_hit = self._counters.get("cache.hit", 0)
|
|
64
|
+
if cache_total > 0:
|
|
65
|
+
summary["counters"]["cache.hit_rate"] = round(cache_hit / cache_total * 100, 1)
|
|
66
|
+
return summary
|
|
67
|
+
|
|
68
|
+
def dump(self, path: Path = None):
|
|
69
|
+
"""将指标写入 JSON 文件。"""
|
|
70
|
+
if path is None:
|
|
71
|
+
from data import cache
|
|
72
|
+
path = cache.CACHE_DIR / "metrics.json"
|
|
73
|
+
path.parent.mkdir(exist_ok=True)
|
|
74
|
+
path.write_text(json.dumps(self.get_summary(), ensure_ascii=False, indent=2))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# 全局实例
|
|
78
|
+
_collector = None
|
|
79
|
+
_collector_lock = threading.Lock()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_collector() -> MetricsCollector:
|
|
83
|
+
"""获取全局指标收集器。"""
|
|
84
|
+
global _collector
|
|
85
|
+
if _collector is None:
|
|
86
|
+
with _collector_lock:
|
|
87
|
+
if _collector is None:
|
|
88
|
+
_collector = MetricsCollector()
|
|
89
|
+
return _collector
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = ["MetricsCollector", "get_collector"]
|