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.
- package/.claude/agents/equity-agent.md +26 -0
- package/.claude/agents/macro-agent.md +25 -0
- package/.claude/commands/analyze.md +40 -0
- package/.claude/commands/macro.md +29 -0
- package/.claude/settings.json +12 -0
- package/CODEX_GOAL.md +46 -0
- package/README.md +206 -0
- package/bin/cli.js +67 -0
- package/bin/erlangshen +2 -0
- package/bin/xiaoergod +2 -0
- package/frontend/index.html +700 -0
- package/knowledge/crypto_guide.md +147 -0
- package/knowledge/economic_indicators.md +125 -0
- package/knowledge/financial_glossary.md +148 -0
- package/knowledge/first_principles.md +50 -0
- package/knowledge/first_principles_deep.md +115 -0
- package/knowledge/global_markets.md +173 -0
- package/knowledge/insights.md +141 -0
- package/knowledge/market_basics.md +116 -0
- package/knowledge/memos/session_20260513_003616.json +6 -0
- package/knowledge/memos/session_20260513_003822.json +6 -0
- package/knowledge/risk_management.md +151 -0
- package/knowledge/team_context.md +42 -0
- package/knowledge/trading_strategies.md +114 -0
- package/package.json +42 -0
- package/requirements.txt +14 -0
- package/scripts/postinstall.js +188 -0
- package/scripts/preuninstall.js +22 -0
- package/src/__init__.py +4 -0
- package/src/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/agents/__init__.py +3 -0
- package/src/agents/base.py +103 -0
- package/src/agents/base_agent.py +86 -0
- package/src/agents/equity.py +136 -0
- package/src/agents/equity_agent.py +91 -0
- package/src/agents/erlang.py +165 -0
- package/src/agents/macro.py +137 -0
- package/src/agents/macro_agent.py +81 -0
- package/src/agents/multi_asset.py +147 -0
- package/src/agents/multi_asset_agent.py +87 -0
- package/src/api/__init__.py +1 -0
- package/src/api/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/api/__pycache__/server.cpython-313.pyc +0 -0
- package/src/api/cli.py +435 -0
- package/src/api/cli_enhanced.py +537 -0
- package/src/api/server.py +266 -0
- package/src/brain.py +200 -0
- package/src/cli.py +153 -0
- package/src/commands/__init__.py +3 -0
- package/src/commands/analyze.py +131 -0
- package/src/commands/macro.py +100 -0
- package/src/commands/memo.py +216 -0
- package/src/commands/portfolio.py +154 -0
- package/src/commands/report.py +228 -0
- package/src/commands/risk.py +183 -0
- package/src/commands/search.py +183 -0
- package/src/commands/stock.py +124 -0
- package/src/config.py +327 -0
- package/src/core/__init__.py +1 -0
- package/src/core/brain.py +645 -0
- package/src/core/cerebellum.py +175 -0
- package/src/core/investment_universe.py +423 -0
- package/src/core/knowledge.py +207 -0
- package/src/core/memory.py +115 -0
- package/src/hooks/__init__.py +3 -0
- package/src/hooks/session_end.py +57 -0
- package/src/hooks/session_start.py +75 -0
- package/src/knowledge/__init__.py +1 -0
- package/src/mcp/__init__.py +3 -0
- package/src/mcp/feishu.py +331 -0
- package/src/mcp/fund_tools.py +323 -0
- package/src/mcp/macro.py +452 -0
- package/src/mcp/market.py +331 -0
- package/src/mcp/registry.py +168 -0
- package/src/network/__init__.py +15 -0
- package/src/network/detector.py +125 -0
- package/src/network/proxy.py +199 -0
- package/src/network/router.py +103 -0
- package/src/prompts/__init__.py +1 -0
- package/src/prompts/analysis_framework.md +164 -0
- package/src/prompts/persona.md +65 -0
- package/src/prompts/report_template.md +144 -0
- package/src/skills/__init__.py +3 -0
- package/src/skills/framework.py +105 -0
- package/src/skills/templates.py +342 -0
- package/src/tools/__init__.py +1 -0
- package/src/tools/file_tools.py +209 -0
- package/src/tools/macro_tools.py +152 -0
- package/src/tools/market_tools.py +1172 -0
- package/src/tools/registry.py +398 -0
- package/src/tools/search_tools.py +777 -0
- package/tests/__init__.py +1 -0
- 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}")
|