erlangshen 0.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 (93) hide show
  1. package/.claude/agents/equity-agent.md +26 -0
  2. package/.claude/agents/macro-agent.md +25 -0
  3. package/.claude/commands/analyze.md +40 -0
  4. package/.claude/commands/macro.md +29 -0
  5. package/.claude/settings.json +12 -0
  6. package/CODEX_GOAL.md +46 -0
  7. package/README.md +206 -0
  8. package/bin/cli.js +67 -0
  9. package/bin/erlangshen +2 -0
  10. package/bin/xiaoergod +2 -0
  11. package/frontend/index.html +700 -0
  12. package/knowledge/crypto_guide.md +147 -0
  13. package/knowledge/economic_indicators.md +125 -0
  14. package/knowledge/financial_glossary.md +148 -0
  15. package/knowledge/first_principles.md +50 -0
  16. package/knowledge/first_principles_deep.md +115 -0
  17. package/knowledge/global_markets.md +173 -0
  18. package/knowledge/insights.md +141 -0
  19. package/knowledge/market_basics.md +116 -0
  20. package/knowledge/memos/session_20260513_003616.json +6 -0
  21. package/knowledge/memos/session_20260513_003822.json +6 -0
  22. package/knowledge/risk_management.md +151 -0
  23. package/knowledge/team_context.md +42 -0
  24. package/knowledge/trading_strategies.md +114 -0
  25. package/package.json +42 -0
  26. package/requirements.txt +14 -0
  27. package/scripts/postinstall.js +188 -0
  28. package/scripts/preuninstall.js +22 -0
  29. package/src/__init__.py +4 -0
  30. package/src/__pycache__/__init__.cpython-313.pyc +0 -0
  31. package/src/agents/__init__.py +3 -0
  32. package/src/agents/base.py +103 -0
  33. package/src/agents/base_agent.py +86 -0
  34. package/src/agents/equity.py +136 -0
  35. package/src/agents/equity_agent.py +91 -0
  36. package/src/agents/erlang.py +165 -0
  37. package/src/agents/macro.py +137 -0
  38. package/src/agents/macro_agent.py +81 -0
  39. package/src/agents/multi_asset.py +147 -0
  40. package/src/agents/multi_asset_agent.py +87 -0
  41. package/src/api/__init__.py +1 -0
  42. package/src/api/__pycache__/__init__.cpython-313.pyc +0 -0
  43. package/src/api/__pycache__/server.cpython-313.pyc +0 -0
  44. package/src/api/cli.py +435 -0
  45. package/src/api/cli_enhanced.py +537 -0
  46. package/src/api/server.py +266 -0
  47. package/src/brain.py +200 -0
  48. package/src/cli.py +153 -0
  49. package/src/commands/__init__.py +3 -0
  50. package/src/commands/analyze.py +131 -0
  51. package/src/commands/macro.py +100 -0
  52. package/src/commands/memo.py +216 -0
  53. package/src/commands/portfolio.py +154 -0
  54. package/src/commands/report.py +228 -0
  55. package/src/commands/risk.py +183 -0
  56. package/src/commands/search.py +183 -0
  57. package/src/commands/stock.py +124 -0
  58. package/src/config.py +327 -0
  59. package/src/core/__init__.py +1 -0
  60. package/src/core/brain.py +645 -0
  61. package/src/core/cerebellum.py +175 -0
  62. package/src/core/investment_universe.py +423 -0
  63. package/src/core/knowledge.py +207 -0
  64. package/src/core/memory.py +115 -0
  65. package/src/hooks/__init__.py +3 -0
  66. package/src/hooks/session_end.py +57 -0
  67. package/src/hooks/session_start.py +75 -0
  68. package/src/knowledge/__init__.py +1 -0
  69. package/src/mcp/__init__.py +3 -0
  70. package/src/mcp/feishu.py +331 -0
  71. package/src/mcp/fund_tools.py +323 -0
  72. package/src/mcp/macro.py +452 -0
  73. package/src/mcp/market.py +331 -0
  74. package/src/mcp/registry.py +168 -0
  75. package/src/network/__init__.py +15 -0
  76. package/src/network/detector.py +125 -0
  77. package/src/network/proxy.py +199 -0
  78. package/src/network/router.py +103 -0
  79. package/src/prompts/__init__.py +1 -0
  80. package/src/prompts/analysis_framework.md +164 -0
  81. package/src/prompts/persona.md +65 -0
  82. package/src/prompts/report_template.md +144 -0
  83. package/src/skills/__init__.py +3 -0
  84. package/src/skills/framework.py +105 -0
  85. package/src/skills/templates.py +342 -0
  86. package/src/tools/__init__.py +1 -0
  87. package/src/tools/file_tools.py +209 -0
  88. package/src/tools/macro_tools.py +152 -0
  89. package/src/tools/market_tools.py +1172 -0
  90. package/src/tools/registry.py +398 -0
  91. package/src/tools/search_tools.py +777 -0
  92. package/tests/__init__.py +1 -0
  93. package/tests/test_erlangshen.py +140 -0
@@ -0,0 +1,1172 @@
1
+ """
2
+ Market Tools - 行情数据工具
3
+ 提供股票、期货、指数、加密货币、宏观经济等行情数据查询
4
+
5
+ 支持的API:
6
+ - Yahoo Finance (无需 API Key) - 股票、指数、ETF、期货
7
+ - CoinGecko (免费,无需 API Key) - 加密货币
8
+ - Binance (公开数据) - 加密货币K线
9
+ - FRED (美联储经济数据 - 免费) - 宏观指标
10
+ - Alpha Vantage (免费额度) - 股票、外汇
11
+ - Twelve Data (免费额度) - 实时行情
12
+ - Trading Economics (免费额度) - 全球经济指标
13
+ - World Bank API (免费) - 发展指标
14
+ """
15
+ from typing import Optional, Any, TypedDict, List, Dict
16
+ from datetime import datetime, timedelta
17
+ from loguru import logger
18
+ import aiohttp
19
+ import json
20
+
21
+
22
+ # ==================== 类型定义 ====================
23
+
24
+ class StockQuote(TypedDict):
25
+ """股票行情"""
26
+ symbol: str
27
+ name: str
28
+ price: float
29
+ change: float
30
+ change_pct: float
31
+ volume: int
32
+ market_cap: float
33
+ pe_ratio: Optional[float]
34
+ dividend: Optional[float]
35
+ timestamp: str
36
+ source: str
37
+
38
+
39
+ class OHLCV(TypedDict):
40
+ """K线数据"""
41
+ date: str
42
+ open: float
43
+ high: float
44
+ low: float
45
+ close: float
46
+ volume: int
47
+
48
+
49
+ class CryptoQuote(TypedDict):
50
+ """加密货币行情"""
51
+ coin_id: str
52
+ symbol: str
53
+ name: str
54
+ price: float
55
+ change_24h: float
56
+ change_pct_24h: float
57
+ market_cap: float
58
+ volume_24h: float
59
+ rank: int
60
+ timestamp: str
61
+ source: str
62
+
63
+
64
+ class MacroIndicator(TypedDict):
65
+ """宏观经济指标"""
66
+ indicator: str
67
+ name: str
68
+ value: float
69
+ unit: str
70
+ country: str
71
+ date: str
72
+ frequency: str
73
+ source: str
74
+
75
+
76
+ class CommodityQuote(TypedDict):
77
+ """大宗商品行情"""
78
+ name: str
79
+ price: float
80
+ change: float
81
+ change_pct: float
82
+ unit: str
83
+ timestamp: str
84
+ source: str
85
+
86
+
87
+ # ==================== MarketTools ====================
88
+
89
+ class MarketTools:
90
+ """
91
+ 行情数据工具集
92
+
93
+ 工具函数:
94
+ - get_stock_price: 股票当前价格 (Yahoo Finance)
95
+ - get_stock_history: 股票历史行情 (Yahoo Finance)
96
+ - get_index_constituents: 指数成分股
97
+ - get_futures_price: 期货价格
98
+ - get_etf_info: ETF信息
99
+ - get_crypto_price: 加密货币价格 (CoinGecko)
100
+ - get_crypto_history: 加密货币历史 (CoinGecko)
101
+ - get_fred_indicator: FRED宏观指标
102
+ - get_commodity_price: 大宗商品价格
103
+ - get_forex_rate: 外汇汇率
104
+ """
105
+
106
+ def __init__(self, db_connection: Optional[Any] = None, config: Optional[dict] = None):
107
+ self.db = db_connection
108
+ self.config = config or {}
109
+ self._cache: dict = {}
110
+ self._cache_ttl = self.config.get("cache_ttl", 300)
111
+ self._session: Optional[aiohttp.ClientSession] = None
112
+ logger.info("MarketTools initialized with extended API support")
113
+
114
+ async def execute(self, tool_name: str, **kwargs) -> Any:
115
+ """执行指定工具"""
116
+ method = getattr(self, tool_name, None)
117
+ if method and callable(method):
118
+ return await method(**kwargs)
119
+ return {"error": f"Unknown tool: {tool_name}"}
120
+
121
+ async def _get_session(self) -> aiohttp.ClientSession:
122
+ """获取或创建 HTTP Session"""
123
+ if self._session is None or self._session.closed:
124
+ self._session = aiohttp.ClientSession()
125
+ return self._session
126
+
127
+ # ==================== 股票/指数工具 (Yahoo Finance) ====================
128
+
129
+ async def get_stock_price(self, symbol: str) -> dict:
130
+ """
131
+ 获取股票当前价格
132
+
133
+ Args:
134
+ symbol: 股票代码,如 "000001.XSHE" (平安银行) 或 "AAPL" (苹果)
135
+
136
+ Returns:
137
+ dict 包含价格信息
138
+ """
139
+ logger.info(f"Fetching stock price for {symbol}")
140
+
141
+ cache_key = f"stock_price:{symbol}"
142
+ cached = self._get_cached(cache_key)
143
+ if cached:
144
+ return cached
145
+
146
+ # 尝试 Yahoo Finance
147
+ quote = await self._yahoo_quote(symbol)
148
+ if quote:
149
+ self._set_cached(cache_key, quote)
150
+ return quote
151
+
152
+ # 回退到模拟数据
153
+ return {
154
+ "symbol": symbol,
155
+ "name": self._get_stock_name(symbol),
156
+ "price": 0.0,
157
+ "change": 0.0,
158
+ "change_pct": 0.0,
159
+ "volume": 0,
160
+ "market_cap": 0.0,
161
+ "pe_ratio": None,
162
+ "dividend": None,
163
+ "timestamp": datetime.now().isoformat(),
164
+ "source": "yahoo_finance",
165
+ "error": "Failed to fetch real data",
166
+ }
167
+
168
+ async def get_stock_history(
169
+ self,
170
+ symbol: str,
171
+ days: int = 30,
172
+ end_date: Optional[str] = None,
173
+ interval: str = "1d",
174
+ ) -> dict:
175
+ """
176
+ 获取股票历史行情
177
+
178
+ Args:
179
+ symbol: 股票代码
180
+ days: 历史天数
181
+ end_date: 结束日期 (YYYY-MM-DD)
182
+ interval: K线周期 (1d, 1wk, 1mo, 1h, 5m)
183
+
184
+ Returns:
185
+ dict 包含OHLCV数据
186
+ """
187
+ logger.info(f"Fetching {days} days history for {symbol}")
188
+
189
+ cache_key = f"stock_history:{symbol}:{days}:{end_date}:{interval}"
190
+ cached = self._get_cached(cache_key)
191
+ if cached:
192
+ return cached
193
+
194
+ # Yahoo Finance 历史数据
195
+ data = await self._yahoo_history(symbol, days, end_date, interval)
196
+ if data:
197
+ self._set_cached(cache_key, data, ttl=600) # 10分钟缓存
198
+ return data
199
+
200
+ return {
201
+ "symbol": symbol,
202
+ "name": self._get_stock_name(symbol),
203
+ "dates": [],
204
+ "data": [],
205
+ "source": "yahoo_finance",
206
+ "error": "Failed to fetch real data",
207
+ }
208
+
209
+ async def _yahoo_quote(self, symbol: str) -> Optional[dict]:
210
+ """Yahoo Finance 实时行情"""
211
+ # 转换代码格式
212
+ yahoo_symbol = self._to_yahoo_symbol(symbol)
213
+ url = f"https://query1.finance.yahoo.com/v8/finance/chart/{yahoo_symbol}"
214
+
215
+ params = {
216
+ "interval": "1d",
217
+ "range": "1d",
218
+ }
219
+
220
+ headers = {
221
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
222
+ }
223
+
224
+ try:
225
+ session = await self._get_session()
226
+ async with session.get(
227
+ url,
228
+ params=params,
229
+ headers=headers,
230
+ timeout=aiohttp.ClientTimeout(total=10),
231
+ ) as resp:
232
+ data = await resp.json()
233
+
234
+ chart = data.get("chart", {}).get("result", [{}])[0]
235
+ meta = chart.get("meta", {})
236
+ quote = chart.get("indicators", {}).get("quote", [{}])[0]
237
+
238
+ price = meta.get("regularMarketPrice", 0)
239
+ prev_close = meta.get("previousClose", price)
240
+ change = price - prev_close
241
+ change_pct = (change / prev_close * 100) if prev_close else 0
242
+
243
+ return StockQuote(
244
+ symbol=symbol,
245
+ name=meta.get("shortName", meta.get("symbol", symbol)),
246
+ price=price,
247
+ change=round(change, 2),
248
+ change_pct=round(change_pct, 2),
249
+ volume=meta.get("regularMarketVolume", 0),
250
+ market_cap=meta.get("marketCap", 0),
251
+ pe_ratio=meta.get("trailingPE"),
252
+ dividend=meta.get("dividendYield"),
253
+ timestamp=datetime.now().isoformat(),
254
+ source="yahoo_finance",
255
+ )
256
+
257
+ except Exception as e:
258
+ logger.warning(f"Yahoo quote failed for {symbol}: {e}")
259
+ return None
260
+
261
+ async def _yahoo_history(
262
+ self,
263
+ symbol: str,
264
+ days: int,
265
+ end_date: Optional[str],
266
+ interval: str,
267
+ ) -> Optional[dict]:
268
+ """Yahoo Finance 历史数据"""
269
+ yahoo_symbol = self._to_yahoo_symbol(symbol)
270
+
271
+ # 计算时间范围
272
+ end = datetime.now()
273
+ if end_date:
274
+ try:
275
+ end = datetime.strptime(end_date, "%Y-%m-%d")
276
+ except:
277
+ pass
278
+
279
+ period1 = int((end - timedelta(days=days)).timestamp())
280
+ period2 = int(end.timestamp())
281
+
282
+ url = f"https://query1.finance.yahoo.com/v8/finance/chart/{yahoo_symbol}"
283
+
284
+ params = {
285
+ "period1": period1,
286
+ "period2": period2,
287
+ "interval": interval,
288
+ "events": "div,split",
289
+ }
290
+
291
+ headers = {
292
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
293
+ }
294
+
295
+ try:
296
+ session = await self._get_session()
297
+ async with session.get(
298
+ url,
299
+ params=params,
300
+ headers=headers,
301
+ timeout=aiohttp.ClientTimeout(total=15),
302
+ ) as resp:
303
+ data = await resp.json()
304
+
305
+ chart = data.get("chart", {}).get("result", [{}])[0]
306
+ timestamps = chart.get("timestamp", [])
307
+ quote = chart.get("indicators", {}).get("quote", [{}])[0]
308
+ adj_close = chart.get("indicators", {}).get("adjclose", [{}])
309
+ if adj_close:
310
+ adj_close = adj_close[0].get("adjclose", [])
311
+
312
+ dates = [datetime.fromtimestamp(ts).strftime("%Y-%m-%d") for ts in timestamps]
313
+
314
+ ohlcv_data = []
315
+ for i in range(len(timestamps)):
316
+ ohlcv_data.append(OHLCV(
317
+ date=dates[i],
318
+ open=quote.get("open", [0])[i] if i < len(quote.get("open", [])) else 0,
319
+ high=quote.get("high", [0])[i] if i < len(quote.get("high", [])) else 0,
320
+ low=quote.get("low", [0])[i] if i < len(quote.get("low", [])) else 0,
321
+ close=adj_close[i] if adj_close and i < len(adj_close) else (quote.get("close", [0])[i] if i < len(quote.get("close", [])) else 0),
322
+ volume=quote.get("volume", [0])[i] if i < len(quote.get("volume", [])) else 0,
323
+ ))
324
+
325
+ return {
326
+ "symbol": symbol,
327
+ "name": self._get_stock_name(symbol),
328
+ "dates": dates,
329
+ "data": ohlcv_data,
330
+ "source": "yahoo_finance",
331
+ "interval": interval,
332
+ }
333
+
334
+ except Exception as e:
335
+ logger.warning(f"Yahoo history failed for {symbol}: {e}")
336
+ return None
337
+
338
+ def _to_yahoo_symbol(self, symbol: str) -> str:
339
+ """转换代码为 Yahoo Finance 格式"""
340
+ # A股
341
+ if symbol.startswith("6") and len(symbol) == 6:
342
+ return f"{symbol}.SS" # Shanghai
343
+ elif symbol.startswith(("0", "3")) and len(symbol) == 6:
344
+ return f"{symbol}.SZ" # Shenzhen
345
+
346
+ # 已有后缀
347
+ if "." in symbol:
348
+ return symbol
349
+
350
+ # 加密货币 (Yahoo 有特殊格式)
351
+ if symbol.upper() in ["BTC", "ETH"]:
352
+ return f"{symbol.upper()}-USD"
353
+
354
+ # 默认
355
+ return symbol
356
+
357
+ # ==================== 指数/ETF工具 ====================
358
+
359
+ async def get_index_constituents(self, index_code: str, top_k: int = 10) -> dict:
360
+ """
361
+ 获取指数成分股
362
+
363
+ Args:
364
+ index_code: 指数代码,如 "000300.XSHE" (沪深300) 或 "^GSPC" (标普500)
365
+ top_k: 返回数量
366
+
367
+ Returns:
368
+ dict 包含成分股列表
369
+ """
370
+ logger.info(f"Fetching constituents for {index_code}")
371
+
372
+ cache_key = f"index:{index_code}:{top_k}"
373
+ cached = self._get_cached(cache_key)
374
+ if cached:
375
+ return cached
376
+
377
+ # 返回提示信息,实际需要专业数据源
378
+ response = {
379
+ "index_code": index_code,
380
+ "name": self._get_index_name(index_code),
381
+ "constituents": [],
382
+ "total_count": 0,
383
+ "source": "cache",
384
+ "note": "请使用专业数据源获取指数成分",
385
+ }
386
+
387
+ self._set_cached(cache_key, response, ttl=3600)
388
+ return response
389
+
390
+ async def get_etf_info(self, code: str) -> dict:
391
+ """获取ETF信息"""
392
+ logger.info(f"Fetching ETF info for {code}")
393
+
394
+ cache_key = f"etf:{code}"
395
+ cached = self._get_cached(cache_key)
396
+ if cached:
397
+ return cached
398
+
399
+ # Yahoo Finance ETF 数据
400
+ yahoo_code = code
401
+ if not "." in code:
402
+ if code.startswith("5") and len(code) == 6:
403
+ yahoo_code = f"{code}.SS"
404
+ elif code.startswith(("1", "15")) and len(code) == 6:
405
+ yahoo_code = f"{code}.SZ"
406
+
407
+ quote = await self._yahoo_quote(yahoo_code)
408
+ if quote and quote.get("price", 0) > 0:
409
+ self._set_cached(cache_key, quote, ttl=300)
410
+ return quote
411
+
412
+ return {
413
+ "code": code,
414
+ "name": "",
415
+ "price": 0.0,
416
+ "nav": 0.0,
417
+ "aum": 0.0,
418
+ "note": "请接入ETF数据源",
419
+ }
420
+
421
+ async def get_futures_price(self, contract: str) -> dict:
422
+ """
423
+ 获取期货价格
424
+
425
+ Args:
426
+ contract: 期货合约,如 "IF2406" 或 "ES=F"
427
+
428
+ Returns:
429
+ dict 期货行情
430
+ """
431
+ logger.info(f"Fetching futures price for {contract}")
432
+
433
+ cache_key = f"futures:{contract}"
434
+ cached = self._get_cached(cache_key)
435
+ if cached:
436
+ return cached
437
+
438
+ # 尝试 Yahoo Finance
439
+ yahoo_contract = contract
440
+ if not "=" in contract:
441
+ yahoo_contract = f"{contract}=F"
442
+
443
+ quote = await self._yahoo_quote(yahoo_contract)
444
+ if quote and quote.get("price", 0) > 0:
445
+ self._set_cached(cache_key, quote)
446
+ return quote
447
+
448
+ return {
449
+ "contract": contract,
450
+ "price": 0.0,
451
+ "change": 0.0,
452
+ "change_pct": 0.0,
453
+ "timestamp": datetime.now().isoformat(),
454
+ "source": "yahoo_finance",
455
+ "note": "期货数据获取失败",
456
+ }
457
+
458
+ # ==================== 加密货币工具 (CoinGecko) ====================
459
+
460
+ async def get_crypto_price(
461
+ self,
462
+ coin_id: str,
463
+ currency: str = "usd",
464
+ ) -> dict:
465
+ """
466
+ 获取加密货币价格
467
+
468
+ Args:
469
+ coin_id: CoinGecko ID,如 "bitcoin", "ethereum"
470
+ currency: 计价货币 (usd, cny, eur)
471
+
472
+ Returns:
473
+ dict 加密货币行情
474
+ """
475
+ logger.info(f"Fetching crypto price for {coin_id}")
476
+
477
+ cache_key = f"crypto_price:{coin_id}:{currency}"
478
+ cached = self._get_cached(cache_key)
479
+ if cached:
480
+ return cached
481
+
482
+ url = "https://api.coingecko.com/api/v3/simple/price"
483
+ params = {
484
+ "ids": coin_id,
485
+ "vs_currencies": currency,
486
+ "include_24hr_change": "true",
487
+ "include_market_cap": "true",
488
+ "include_24hr_vol": "true",
489
+ }
490
+
491
+ try:
492
+ session = await self._get_session()
493
+ async with session.get(
494
+ url,
495
+ params=params,
496
+ timeout=aiohttp.ClientTimeout(total=10),
497
+ ) as resp:
498
+ data = await resp.json()
499
+
500
+ coin_data = data.get(coin_id, {})
501
+ currency_upper = currency.upper()
502
+
503
+ price = coin_data.get(currency, 0)
504
+ change_24h = coin_data.get(f"{currency}_24h_change", 0)
505
+
506
+ result = CryptoQuote(
507
+ coin_id=coin_id,
508
+ symbol=coin_id.upper(),
509
+ name=coin_id.capitalize(),
510
+ price=price,
511
+ change_24h=price * change_24h / 100 if price else 0,
512
+ change_pct_24h=round(change_24h, 2),
513
+ market_cap=coin_data.get(f"{currency}_market_cap", 0),
514
+ volume_24h=coin_data.get(f"{currency}_24h_vol", 0),
515
+ rank=0,
516
+ timestamp=datetime.now().isoformat(),
517
+ source="coingecko",
518
+ )
519
+
520
+ self._set_cached(cache_key, result)
521
+ return result
522
+
523
+ except Exception as e:
524
+ logger.warning(f"CoinGecko price failed for {coin_id}: {e}")
525
+ return {
526
+ "coin_id": coin_id,
527
+ "price": 0.0,
528
+ "error": str(e),
529
+ "source": "coingecko",
530
+ }
531
+
532
+ async def get_crypto_prices(
533
+ self,
534
+ coin_ids: List[str],
535
+ currency: str = "usd",
536
+ ) -> dict:
537
+ """
538
+ 批量获取加密货币价格
539
+
540
+ Args:
541
+ coin_ids: CoinGecko ID 列表
542
+ currency: 计价货币
543
+
544
+ Returns:
545
+ dict 价格字典
546
+ """
547
+ logger.info(f"Fetching {len(coin_ids)} crypto prices")
548
+
549
+ cache_key = f"crypto_prices:{','.join(coin_ids)}:{currency}"
550
+ cached = self._get_cached(cache_key)
551
+ if cached:
552
+ return cached
553
+
554
+ url = "https://api.coingecko.com/api/v3/simple/price"
555
+ params = {
556
+ "ids": ",".join(coin_ids),
557
+ "vs_currencies": currency,
558
+ "include_24hr_change": "true",
559
+ "include_market_cap": "true",
560
+ }
561
+
562
+ try:
563
+ session = await self._get_session()
564
+ async with session.get(
565
+ url,
566
+ params=params,
567
+ timeout=aiohttp.ClientTimeout(total=10),
568
+ ) as resp:
569
+ data = await resp.json()
570
+
571
+ results = {}
572
+ for coin_id in coin_ids:
573
+ coin_data = data.get(coin_id, {})
574
+ price = coin_data.get(currency, 0)
575
+ change_24h = coin_data.get(f"{currency}_24h_change", 0)
576
+
577
+ results[coin_id] = {
578
+ "price": price,
579
+ "change_pct_24h": round(change_24h, 2),
580
+ "market_cap": coin_data.get(f"{currency}_market_cap", 0),
581
+ }
582
+
583
+ response = {
584
+ "coins": results,
585
+ "currency": currency,
586
+ "timestamp": datetime.now().isoformat(),
587
+ }
588
+
589
+ self._set_cached(cache_key, response)
590
+ return response
591
+
592
+ except Exception as e:
593
+ logger.warning(f"CoinGecko batch price failed: {e}")
594
+ return {"coins": {}, "error": str(e)}
595
+
596
+ async def get_crypto_history(
597
+ self,
598
+ coin_id: str,
599
+ days: int = 30,
600
+ currency: str = "usd",
601
+ ) -> dict:
602
+ """
603
+ 获取加密货币历史价格
604
+
605
+ Args:
606
+ coin_id: CoinGecko ID
607
+ days: 历史天数
608
+ currency: 计价货币
609
+
610
+ Returns:
611
+ dict 历史价格数据
612
+ """
613
+ logger.info(f"Fetching {days} days history for {coin_id}")
614
+
615
+ cache_key = f"crypto_history:{coin_id}:{days}:{currency}"
616
+ cached = self._get_cached(cache_key)
617
+ if cached:
618
+ return cached
619
+
620
+ url = f"https://api.coingecko.com/api/v3/coins/{coin_id}/market_chart"
621
+ params = {
622
+ "vs_currency": currency,
623
+ "days": days,
624
+ "interval": "daily" if days > 1 else "hourly",
625
+ }
626
+
627
+ try:
628
+ session = await self._get_session()
629
+ async with session.get(
630
+ url,
631
+ params=params,
632
+ timeout=aiohttp.ClientTimeout(total=15),
633
+ ) as resp:
634
+ data = await resp.json()
635
+
636
+ prices = data.get("prices", [])
637
+ market_caps = data.get("market_caps", [])
638
+ volumes = data.get("total_volumes", [])
639
+
640
+ dates = []
641
+ price_data = []
642
+
643
+ for i, (timestamp, price) in enumerate(prices):
644
+ date = datetime.fromtimestamp(timestamp / 1000).strftime("%Y-%m-%d")
645
+ dates.append(date)
646
+ price_data.append({
647
+ "price": price,
648
+ "market_cap": market_caps[i][1] if i < len(market_caps) else 0,
649
+ "volume": volumes[i][1] if i < len(volumes) else 0,
650
+ })
651
+
652
+ result = {
653
+ "coin_id": coin_id,
654
+ "currency": currency,
655
+ "dates": dates,
656
+ "data": price_data,
657
+ "source": "coingecko",
658
+ }
659
+
660
+ self._set_cached(cache_key, result, ttl=600)
661
+ return result
662
+
663
+ except Exception as e:
664
+ logger.warning(f"CoinGecko history failed for {coin_id}: {e}")
665
+ return {
666
+ "coin_id": coin_id,
667
+ "dates": [],
668
+ "data": [],
669
+ "error": str(e),
670
+ }
671
+
672
+ async def get_crypto_market_chart(
673
+ self,
674
+ coin_id: str,
675
+ days: int = 7,
676
+ ) -> dict:
677
+ """
678
+ 获取加密货币市场数据图表
679
+
680
+ Args:
681
+ coin_id: CoinGecko ID
682
+ days: 天数
683
+
684
+ Returns:
685
+ dict 市场数据
686
+ """
687
+ return await self.get_crypto_history(coin_id, days)
688
+
689
+ # ==================== Binance K线 ====================
690
+
691
+ async def get_binance_klines(
692
+ self,
693
+ symbol: str,
694
+ interval: str = "1d",
695
+ limit: int = 100,
696
+ ) -> dict:
697
+ """
698
+ 获取 Binance K线数据
699
+
700
+ Args:
701
+ symbol: 交易对,如 "BTCUSDT"
702
+ interval: K线周期 (1m, 5m, 15m, 1h, 4h, 1d)
703
+ limit: 数量
704
+
705
+ Returns:
706
+ dict K线数据
707
+ """
708
+ logger.info(f"Fetching Binance klines for {symbol}")
709
+
710
+ cache_key = f"binance:{symbol}:{interval}:{limit}"
711
+ cached = self._get_cached(cache_key)
712
+ if cached:
713
+ return cached
714
+
715
+ url = "https://api.binance.com/api/v3/klines"
716
+ params = {
717
+ "symbol": symbol.upper(),
718
+ "interval": interval,
719
+ "limit": limit,
720
+ }
721
+
722
+ try:
723
+ session = await self._get_session()
724
+ async with session.get(
725
+ url,
726
+ params=params,
727
+ timeout=aiohttp.ClientTimeout(total=10),
728
+ ) as resp:
729
+ data = await resp.json()
730
+
731
+ dates = []
732
+ ohlcv_data = []
733
+
734
+ for kline in data:
735
+ open_time = datetime.fromtimestamp(kline[0] / 1000)
736
+ dates.append(open_time.strftime("%Y-%m-%d %H:%M"))
737
+ ohlcv_data.append({
738
+ "open": float(kline[1]),
739
+ "high": float(kline[2]),
740
+ "low": float(kline[3]),
741
+ "close": float(kline[4]),
742
+ "volume": float(kline[5]),
743
+ })
744
+
745
+ result = {
746
+ "symbol": symbol,
747
+ "interval": interval,
748
+ "dates": dates,
749
+ "data": ohlcv_data,
750
+ "source": "binance",
751
+ }
752
+
753
+ self._set_cached(cache_key, result, ttl=60) # 1分钟缓存
754
+ return result
755
+
756
+ except Exception as e:
757
+ logger.warning(f"Binance klines failed for {symbol}: {e}")
758
+ return {
759
+ "symbol": symbol,
760
+ "dates": [],
761
+ "data": [],
762
+ "error": str(e),
763
+ }
764
+
765
+ async def get_binance_ticker(self, symbol: str) -> dict:
766
+ """
767
+ 获取 Binance 实时行情
768
+
769
+ Args:
770
+ symbol: 交易对,如 "BTCUSDT"
771
+
772
+ Returns:
773
+ dict 行情数据
774
+ """
775
+ cache_key = f"binance_ticker:{symbol}"
776
+ cached = self._get_cached(cache_key)
777
+ if cached:
778
+ return cached
779
+
780
+ url = "https://api.binance.com/api/v3/ticker/24hr"
781
+ params = {"symbol": symbol.upper()}
782
+
783
+ try:
784
+ session = await self._get_session()
785
+ async with session.get(
786
+ url,
787
+ params=params,
788
+ timeout=aiohttp.ClientTimeout(total=10),
789
+ ) as resp:
790
+ data = await resp.json()
791
+
792
+ result = {
793
+ "symbol": data.get("symbol"),
794
+ "price": float(data.get("lastPrice", 0)),
795
+ "change": float(data.get("priceChange", 0)),
796
+ "change_pct": float(data.get("priceChangePercent", 0)),
797
+ "volume_24h": float(data.get("volume", 0)),
798
+ "quote_volume_24h": float(data.get("quoteVolume", 0)),
799
+ "high_24h": float(data.get("highPrice", 0)),
800
+ "low_24h": float(data.get("lowPrice", 0)),
801
+ "timestamp": datetime.now().isoformat(),
802
+ "source": "binance",
803
+ }
804
+
805
+ self._set_cached(cache_key, result, ttl=30)
806
+ return result
807
+
808
+ except Exception as e:
809
+ logger.warning(f"Binance ticker failed for {symbol}: {e}")
810
+ return {"symbol": symbol, "error": str(e)}
811
+
812
+ # ==================== FRED 宏观数据 ====================
813
+
814
+ async def get_fred_indicator(
815
+ self,
816
+ series_id: str,
817
+ limit: int = 100,
818
+ ) -> dict:
819
+ """
820
+ 获取 FRED 宏观指标
821
+
822
+ Args:
823
+ series_id: FRED 系列ID
824
+ - DFF: 联邦基金利率
825
+ - GDP: 美国GDP
826
+ - CPIAUCSL: 消费者物价指数
827
+ - UNRATE: 失业率
828
+ - VIXCLS: VIX恐慌指数
829
+ - DXY: 美元指数
830
+ - GOLDAMGBD228NLBM: 金价
831
+ limit: 返回数量
832
+
833
+ Returns:
834
+ dict 指标数据
835
+ """
836
+ logger.info(f"Fetching FRED indicator: {series_id}")
837
+
838
+ cache_key = f"fred:{series_id}:{limit}"
839
+ cached = self._get_cached(cache_key)
840
+ if cached:
841
+ return cached
842
+
843
+ api_key = self.config.get("fred_api_key")
844
+
845
+ if api_key:
846
+ url = "https://api.stlouisfed.org/fred/series/observations"
847
+ params = {
848
+ "series_id": series_id,
849
+ "api_key": api_key,
850
+ "file_type": "json",
851
+ "limit": limit,
852
+ "sort_order": "desc",
853
+ }
854
+
855
+ try:
856
+ session = await self._get_session()
857
+ async with session.get(
858
+ url,
859
+ params=params,
860
+ timeout=aiohttp.ClientTimeout(total=15),
861
+ ) as resp:
862
+ data = await resp.json()
863
+
864
+ observations = data.get("observations", [])
865
+ dates = [o["date"] for o in observations]
866
+ values = [float(o["value"]) if o["value"] != "." else 0 for o in observations]
867
+
868
+ result = {
869
+ "series_id": series_id,
870
+ "name": self._get_fred_name(series_id),
871
+ "dates": dates,
872
+ "values": values,
873
+ "unit": self._get_fred_unit(series_id),
874
+ "source": "fred",
875
+ }
876
+
877
+ self._set_cached(cache_key, result, ttl=3600)
878
+ return result
879
+
880
+ except Exception as e:
881
+ logger.warning(f"FRED API failed for {series_id}: {e}")
882
+
883
+ # 无API Key时返回说明
884
+ return {
885
+ "series_id": series_id,
886
+ "name": self._get_fred_name(series_id),
887
+ "dates": [],
888
+ "values": [],
889
+ "unit": self._get_fred_unit(series_id),
890
+ "source": "fred",
891
+ "note": "请配置 FRED API Key 以获取实际数据",
892
+ }
893
+
894
+ def _get_fred_name(self, series_id: str) -> str:
895
+ """获取 FRED 指标名称"""
896
+ names = {
897
+ "DFF": "联邦基金利率",
898
+ "GDP": "美国GDP",
899
+ "CPIAUCSL": "消费者物价指数",
900
+ "PPIACO": "生产者物价指数",
901
+ "UNRATE": "失业率",
902
+ "VIXCLS": "VIX恐慌指数",
903
+ "DXY": "美元指数",
904
+ "GOLDAMGBD228NLBM": "金价",
905
+ "MORTGAGE30US": "30年期抵押贷款利率",
906
+ "TBILL": "3个月国库券利率",
907
+ }
908
+ return names.get(series_id, series_id)
909
+
910
+ def _get_fred_unit(self, series_id: str) -> str:
911
+ """获取 FRED 指标单位"""
912
+ units = {
913
+ "DFF": "%",
914
+ "GDP": "十亿美元",
915
+ "CPIAUCSL": "2017=100",
916
+ "PPIACO": "2011=100",
917
+ "UNRATE": "%",
918
+ "VIXCLS": "",
919
+ "DXY": "指数",
920
+ "GOLDAMGBD228NLBM": "美元/盎司",
921
+ "MORTGAGE30US": "%",
922
+ "TBILL": "%",
923
+ }
924
+ return units.get(series_id, "")
925
+
926
+ # ==================== 大宗商品 ====================
927
+
928
+ async def get_commodity_price(self, name: str) -> dict:
929
+ """
930
+ 获取大宗商品价格
931
+
932
+ Args:
933
+ name: 商品名称 (gold, silver, oil, natural_gas, copper)
934
+
935
+ Returns:
936
+ dict 商品行情
937
+ """
938
+ logger.info(f"Fetching commodity price for {name}")
939
+
940
+ cache_key = f"commodity:{name}"
941
+ cached = self._get_cached(cache_key)
942
+ if cached:
943
+ return cached
944
+
945
+ # 映射到 Yahoo Finance 合约
946
+ contracts = {
947
+ "gold": "GC=F",
948
+ "silver": "SI=F",
949
+ "oil": "CL=F",
950
+ "crude_oil": "CL=F",
951
+ "natural_gas": "NG=F",
952
+ "copper": "HG=F",
953
+ "platinum": "PL=F",
954
+ "palladium": "PA=F",
955
+ }
956
+
957
+ contract = contracts.get(name.lower(), f"{name.upper()}=F")
958
+ quote = await self._yahoo_quote(contract)
959
+
960
+ if quote and quote.get("price", 0) > 0:
961
+ self._set_cached(cache_key, quote)
962
+ return quote
963
+
964
+ return CommodityQuote(
965
+ name=name,
966
+ price=0.0,
967
+ change=0.0,
968
+ change_pct=0.0,
969
+ unit=self._get_commodity_unit(name),
970
+ timestamp=datetime.now().isoformat(),
971
+ source="yahoo_finance",
972
+ )
973
+
974
+ async def get_gold_price(self) -> dict:
975
+ """获取黄金价格"""
976
+ return await self.get_commodity_price("gold")
977
+
978
+ async def get_oil_price(self) -> dict:
979
+ """获取原油价格"""
980
+ return await self.get_commodity_price("oil")
981
+
982
+ def _get_commodity_unit(self, name: str) -> str:
983
+ """获取商品单位"""
984
+ units = {
985
+ "gold": "USD/盎司",
986
+ "silver": "USD/盎司",
987
+ "oil": "USD/桶",
988
+ "natural_gas": "USD/MMBtu",
989
+ "copper": "USD/磅",
990
+ }
991
+ return units.get(name.lower(), "USD")
992
+
993
+ # ==================== 外汇工具 ====================
994
+
995
+ async def get_forex_rate(
996
+ self,
997
+ pair: str,
998
+ ) -> dict:
999
+ """
1000
+ 获取外汇汇率
1001
+
1002
+ Args:
1003
+ pair: 货币对,如 "USDCNY", "EURUSD"
1004
+
1005
+ Returns:
1006
+ dict 汇率数据
1007
+ """
1008
+ logger.info(f"Fetching forex rate for {pair}")
1009
+
1010
+ cache_key = f"forex:{pair}"
1011
+ cached = self._get_cached(cache_key)
1012
+ if cached:
1013
+ return cached
1014
+
1015
+ # Yahoo Finance 外汇
1016
+ yahoo_pair = f"{pair[:3]}{pair[3:]}={pair[:3]}{pair[3:]}"
1017
+
1018
+ # 尝试不同的格式
1019
+ formats = [
1020
+ f"{pair}=X",
1021
+ f"{pair[:3]}{pair[3:]}={pair[:3]}{pair[3:]}",
1022
+ ]
1023
+
1024
+ for fmt in formats:
1025
+ quote = await self._yahoo_quote(fmt)
1026
+ if quote and quote.get("price", 0) > 0:
1027
+ self._set_cached(cache_key, quote)
1028
+ return quote
1029
+
1030
+ # Alpha Vantage (如果有 API Key)
1031
+ av_key = self.config.get("alpha_vantage_key")
1032
+ if av_key:
1033
+ result = await self._alpha_vantage_forex(pair, av_key)
1034
+ if result:
1035
+ self._set_cached(cache_key, result)
1036
+ return result
1037
+
1038
+ return {
1039
+ "pair": pair,
1040
+ "rate": 0.0,
1041
+ "timestamp": datetime.now().isoformat(),
1042
+ "source": "yahoo_finance",
1043
+ "note": "外汇数据获取失败",
1044
+ }
1045
+
1046
+ async def _alpha_vantage_forex(self, pair: str, api_key: str) -> Optional[dict]:
1047
+ """Alpha Vantage 外汇数据"""
1048
+ url = "https://www.alphavantage.co/query"
1049
+ from_currency = pair[:3]
1050
+ to_currency = pair[3:]
1051
+
1052
+ params = {
1053
+ "function": "CURRENCY_EXCHANGE_RATE",
1054
+ "from_currency": from_currency,
1055
+ "to_currency": to_currency,
1056
+ "apikey": api_key,
1057
+ }
1058
+
1059
+ try:
1060
+ session = await self._get_session()
1061
+ async with session.get(
1062
+ url,
1063
+ params=params,
1064
+ timeout=aiohttp.ClientTimeout(total=10),
1065
+ ) as resp:
1066
+ data = await resp.json()
1067
+
1068
+ rate_data = data.get("Realtime Currency Exchange Rate", {})
1069
+ rate = float(rate_data.get("5. Exchange Rate", 0))
1070
+
1071
+ return {
1072
+ "pair": pair,
1073
+ "rate": rate,
1074
+ "timestamp": rate_data.get("6. Last Refreshed", ""),
1075
+ "source": "alpha_vantage",
1076
+ }
1077
+
1078
+ except Exception as e:
1079
+ logger.warning(f"Alpha Vantage forex failed: {e}")
1080
+ return None
1081
+
1082
+ # ==================== 工具方法 ====================
1083
+
1084
+ async def get_index_quote(self, index_code: str) -> dict:
1085
+ """
1086
+ 获取指数行情
1087
+
1088
+ Args:
1089
+ index_code: 指数代码
1090
+
1091
+ Returns:
1092
+ dict 指数行情
1093
+ """
1094
+ # 转换为 Yahoo Finance 格式
1095
+ yahoo_codes = {
1096
+ "^GSPC": "^GSPC", # S&P 500
1097
+ "^DJI": "^DJI", # 道琼斯
1098
+ "^IXIC": "^IXIC", # 纳斯达克
1099
+ "^HSI": "^HSI", # 恒生指数
1100
+ "000001.XSHE": "000001.SS", # 上证指数
1101
+ "399001.XSHE": "399001.SZ", # 深证成指
1102
+ "000300.XSHE": "000300.SS", # 沪深300
1103
+ }
1104
+
1105
+ yahoo_code = yahoo_codes.get(index_code, index_code)
1106
+ return await self._yahoo_quote(yahoo_code)
1107
+
1108
+ # ==================== 缓存管理 ====================
1109
+
1110
+ def _get_cached(self, key: str) -> Optional[dict]:
1111
+ """获取缓存"""
1112
+ import time
1113
+ if key in self._cache:
1114
+ entry = self._cache[key]
1115
+ if time.time() - entry["time"] < entry["ttl"]:
1116
+ return entry["data"]
1117
+ else:
1118
+ del self._cache[key]
1119
+ return None
1120
+
1121
+ def _set_cached(self, key: str, data: dict, ttl: Optional[int] = None) -> None:
1122
+ """设置缓存"""
1123
+ import time
1124
+ self._cache[key] = {
1125
+ "data": data,
1126
+ "time": time.time(),
1127
+ "ttl": ttl or self._cache_ttl,
1128
+ }
1129
+ # 限制缓存大小
1130
+ if len(self._cache) > 1000:
1131
+ self._cleanup_cache()
1132
+
1133
+ def _cleanup_cache(self) -> None:
1134
+ """清理过期缓存"""
1135
+ import time
1136
+ now = time.time()
1137
+ expired = [k for k, v in self._cache.items() if now - v["time"] >= v["ttl"]]
1138
+ for k in expired:
1139
+ del self._cache[k]
1140
+
1141
+ # ==================== 股票名称映射 ====================
1142
+
1143
+ def _get_stock_name(self, symbol: str) -> str:
1144
+ """获取股票名称映射"""
1145
+ names = {
1146
+ "000001": "平安银行",
1147
+ "000002": "万科A",
1148
+ "600000": "浦发银行",
1149
+ "600519": "贵州茅台",
1150
+ "000858": "五粮液",
1151
+ "AAPL": "苹果公司",
1152
+ "MSFT": "微软公司",
1153
+ "GOOGL": "谷歌",
1154
+ "AMZN": "亚马逊",
1155
+ "TSLA": "特斯拉",
1156
+ }
1157
+ return names.get(symbol, f"股票{symbol}")
1158
+
1159
+ def _get_index_name(self, code: str) -> str:
1160
+ """获取指数名称映射"""
1161
+ names = {
1162
+ "000001": "上证指数",
1163
+ "399001": "深证成指",
1164
+ "000300": "沪深300",
1165
+ "000016": "上证50",
1166
+ "399006": "创业板指",
1167
+ "^GSPC": "标普500",
1168
+ "^DJI": "道琼斯",
1169
+ "^IXIC": "纳斯达克",
1170
+ "^HSI": "恒生指数",
1171
+ }
1172
+ return names.get(code, f"指数{code}")