@@ -68,6 +68,42 @@ class MetaAPIKeys(type):
|
||||
from app.utils.config_loader import load_addon_config
|
||||
val = load_addon_config().get('grok', {}).get('api_key')
|
||||
return val if val else ''
|
||||
|
||||
@property
|
||||
def TAVILY_API_KEYS(cls):
|
||||
"""Tavily Search API keys (comma-separated for rotation)"""
|
||||
env_val = os.getenv('TAVILY_API_KEYS', '').strip()
|
||||
if env_val:
|
||||
return [k.strip() for k in env_val.split(',') if k.strip()]
|
||||
from app.utils.config_loader import load_addon_config
|
||||
val = load_addon_config().get('tavily', {}).get('api_keys', '')
|
||||
if val:
|
||||
return [k.strip() for k in val.split(',') if k.strip()]
|
||||
return []
|
||||
|
||||
@property
|
||||
def BOCHA_API_KEYS(cls):
|
||||
"""Bocha Search API keys (comma-separated for rotation)"""
|
||||
env_val = os.getenv('BOCHA_API_KEYS', '').strip()
|
||||
if env_val:
|
||||
return [k.strip() for k in env_val.split(',') if k.strip()]
|
||||
from app.utils.config_loader import load_addon_config
|
||||
val = load_addon_config().get('bocha', {}).get('api_keys', '')
|
||||
if val:
|
||||
return [k.strip() for k in val.split(',') if k.strip()]
|
||||
return []
|
||||
|
||||
@property
|
||||
def SERPAPI_KEYS(cls):
|
||||
"""SerpAPI keys (comma-separated for rotation)"""
|
||||
env_val = os.getenv('SERPAPI_KEYS', '').strip()
|
||||
if env_val:
|
||||
return [k.strip() for k in env_val.split(',') if k.strip()]
|
||||
from app.utils.config_loader import load_addon_config
|
||||
val = load_addon_config().get('serpapi', {}).get('api_keys', '')
|
||||
if val:
|
||||
return [k.strip() for k in val.split(',') if k.strip()]
|
||||
return []
|
||||
|
||||
|
||||
class APIKeys(metaclass=MetaAPIKeys):
|
||||
|
||||
@@ -1,8 +1,61 @@
|
||||
"""
|
||||
数据源模块
|
||||
支持多种市场的K线数据获取
|
||||
|
||||
改进版本(参考 daily_stock_analysis 项目):
|
||||
- 熔断器保护 (circuit_breaker)
|
||||
- 数据缓存 (cache_manager)
|
||||
- 防封禁策略 (rate_limiter)
|
||||
- 多数据源自动切换 (data_manager)
|
||||
"""
|
||||
from app.data_sources.factory import DataSourceFactory
|
||||
from app.data_sources.circuit_breaker import (
|
||||
CircuitBreaker,
|
||||
get_ashare_circuit_breaker,
|
||||
get_realtime_circuit_breaker
|
||||
)
|
||||
from app.data_sources.cache_manager import (
|
||||
DataCache,
|
||||
get_realtime_cache,
|
||||
get_kline_cache,
|
||||
get_stock_info_cache
|
||||
)
|
||||
from app.data_sources.rate_limiter import (
|
||||
RateLimiter,
|
||||
get_eastmoney_limiter,
|
||||
get_tencent_limiter,
|
||||
get_akshare_limiter,
|
||||
get_random_user_agent,
|
||||
random_sleep,
|
||||
retry_with_backoff
|
||||
)
|
||||
from app.data_sources.data_manager import (
|
||||
AShareDataManager,
|
||||
get_ashare_data_manager
|
||||
)
|
||||
|
||||
__all__ = ['DataSourceFactory']
|
||||
__all__ = [
|
||||
# 工厂
|
||||
'DataSourceFactory',
|
||||
# 熔断器
|
||||
'CircuitBreaker',
|
||||
'get_ashare_circuit_breaker',
|
||||
'get_realtime_circuit_breaker',
|
||||
# 缓存
|
||||
'DataCache',
|
||||
'get_realtime_cache',
|
||||
'get_kline_cache',
|
||||
'get_stock_info_cache',
|
||||
# 限流器
|
||||
'RateLimiter',
|
||||
'get_eastmoney_limiter',
|
||||
'get_tencent_limiter',
|
||||
'get_akshare_limiter',
|
||||
'get_random_user_agent',
|
||||
'random_sleep',
|
||||
'retry_with_backoff',
|
||||
# 数据管理器
|
||||
'AShareDataManager',
|
||||
'get_ashare_data_manager',
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
数据缓存管理模块
|
||||
===================================
|
||||
|
||||
参考 daily_stock_analysis 项目实现
|
||||
用于缓存实时行情和K线数据,减少重复请求
|
||||
|
||||
特性:
|
||||
1. TTL (Time To Live) 过期机制
|
||||
2. LRU (Least Recently Used) 淘汰策略
|
||||
3. 按数据类型分区管理
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheEntry:
|
||||
"""缓存条目"""
|
||||
data: Any
|
||||
timestamp: float
|
||||
ttl: float
|
||||
hit_count: int = 0
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""检查是否过期"""
|
||||
return time.time() - self.timestamp > self.ttl
|
||||
|
||||
def age(self) -> float:
|
||||
"""返回缓存年龄(秒)"""
|
||||
return time.time() - self.timestamp
|
||||
|
||||
|
||||
class DataCache:
|
||||
"""
|
||||
数据缓存管理器
|
||||
|
||||
特性:
|
||||
- TTL 过期机制
|
||||
- 最大容量限制
|
||||
- LRU 淘汰策略
|
||||
- 线程安全
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "default",
|
||||
default_ttl: float = 600.0, # 默认10分钟
|
||||
max_size: int = 1000 # 最大缓存条目数
|
||||
):
|
||||
self.name = name
|
||||
self.default_ttl = default_ttl
|
||||
self.max_size = max_size
|
||||
self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# 统计信息
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
获取缓存数据
|
||||
|
||||
Returns:
|
||||
缓存的数据,不存在或过期返回 None
|
||||
"""
|
||||
with self._lock:
|
||||
if key not in self._cache:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
entry = self._cache[key]
|
||||
|
||||
# 检查是否过期
|
||||
if entry.is_expired():
|
||||
del self._cache[key]
|
||||
self._misses += 1
|
||||
logger.debug(f"[缓存] {self.name}:{key} 已过期,删除")
|
||||
return None
|
||||
|
||||
# 更新访问顺序(LRU)
|
||||
self._cache.move_to_end(key)
|
||||
entry.hit_count += 1
|
||||
self._hits += 1
|
||||
|
||||
logger.debug(f"[缓存命中] {self.name}:{key} (年龄: {entry.age():.0f}s/{entry.ttl:.0f}s)")
|
||||
return entry.data
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
data: Any,
|
||||
ttl: Optional[float] = None
|
||||
) -> None:
|
||||
"""
|
||||
设置缓存数据
|
||||
|
||||
Args:
|
||||
key: 缓存键
|
||||
data: 缓存数据
|
||||
ttl: 过期时间(秒),None 使用默认值
|
||||
"""
|
||||
with self._lock:
|
||||
# 检查容量,执行 LRU 淘汰
|
||||
while len(self._cache) >= self.max_size:
|
||||
oldest_key, _ = self._cache.popitem(last=False)
|
||||
logger.debug(f"[缓存] {self.name} 容量已满,淘汰: {oldest_key}")
|
||||
|
||||
actual_ttl = ttl if ttl is not None else self.default_ttl
|
||||
self._cache[key] = CacheEntry(
|
||||
data=data,
|
||||
timestamp=time.time(),
|
||||
ttl=actual_ttl
|
||||
)
|
||||
|
||||
logger.debug(f"[缓存更新] {self.name}:{key} TTL={actual_ttl}s")
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""删除缓存条目"""
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
del self._cache[key]
|
||||
logger.debug(f"[缓存] {self.name}:{key} 已删除")
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear(self) -> int:
|
||||
"""清空缓存"""
|
||||
with self._lock:
|
||||
count = len(self._cache)
|
||||
self._cache.clear()
|
||||
logger.info(f"[缓存] {self.name} 已清空 {count} 条记录")
|
||||
return count
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""清理过期条目"""
|
||||
with self._lock:
|
||||
expired_keys = [
|
||||
key for key, entry in self._cache.items()
|
||||
if entry.is_expired()
|
||||
]
|
||||
for key in expired_keys:
|
||||
del self._cache[key]
|
||||
|
||||
if expired_keys:
|
||||
logger.debug(f"[缓存] {self.name} 清理 {len(expired_keys)} 条过期记录")
|
||||
return len(expired_keys)
|
||||
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
"""获取缓存统计信息"""
|
||||
with self._lock:
|
||||
total_requests = self._hits + self._misses
|
||||
hit_rate = self._hits / total_requests if total_requests > 0 else 0
|
||||
|
||||
return {
|
||||
'name': self.name,
|
||||
'size': len(self._cache),
|
||||
'max_size': self.max_size,
|
||||
'hits': self._hits,
|
||||
'misses': self._misses,
|
||||
'hit_rate': f"{hit_rate:.1%}",
|
||||
'default_ttl': self.default_ttl
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# 全局缓存实例
|
||||
# ============================================
|
||||
|
||||
# A股实时行情缓存(20分钟TTL,全市场数据量大)
|
||||
_ashare_realtime_cache = DataCache(
|
||||
name="ashare_realtime",
|
||||
default_ttl=1200.0, # 20分钟
|
||||
max_size=6000 # 约5000+股票
|
||||
)
|
||||
|
||||
# K线数据缓存(5分钟TTL,按需缓存)
|
||||
_kline_cache = DataCache(
|
||||
name="kline",
|
||||
default_ttl=300.0, # 5分钟
|
||||
max_size=500 # 最多500个交易对
|
||||
)
|
||||
|
||||
# 股票基本信息缓存(1天TTL)
|
||||
_stock_info_cache = DataCache(
|
||||
name="stock_info",
|
||||
default_ttl=86400.0, # 24小时
|
||||
max_size=6000
|
||||
)
|
||||
|
||||
|
||||
def get_realtime_cache() -> DataCache:
|
||||
"""获取实时行情缓存"""
|
||||
return _ashare_realtime_cache
|
||||
|
||||
|
||||
def get_kline_cache() -> DataCache:
|
||||
"""获取K线数据缓存"""
|
||||
return _kline_cache
|
||||
|
||||
|
||||
def get_stock_info_cache() -> DataCache:
|
||||
"""获取股票信息缓存"""
|
||||
return _stock_info_cache
|
||||
|
||||
|
||||
def generate_kline_cache_key(
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> str:
|
||||
"""
|
||||
生成K线缓存键
|
||||
|
||||
格式: symbol:timeframe:limit[:before_time]
|
||||
"""
|
||||
key = f"{symbol}:{timeframe}:{limit}"
|
||||
if before_time:
|
||||
key += f":{before_time}"
|
||||
return key
|
||||
@@ -0,0 +1,186 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
熔断器模块 (Circuit Breaker)
|
||||
===================================
|
||||
|
||||
参考 daily_stock_analysis 项目实现
|
||||
用于管理数据源的熔断/冷却状态,避免连续失败时反复请求
|
||||
|
||||
状态机:
|
||||
CLOSED(正常) --失败N次--> OPEN(熔断)--冷却时间到--> HALF_OPEN(半开)
|
||||
HALF_OPEN --成功--> CLOSED
|
||||
HALF_OPEN --失败--> OPEN
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CircuitState(Enum):
|
||||
"""熔断器状态"""
|
||||
CLOSED = "closed" # 正常状态
|
||||
OPEN = "open" # 熔断状态(不可用)
|
||||
HALF_OPEN = "half_open" # 半开状态(试探性请求)
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""
|
||||
熔断器 - 管理数据源的熔断/冷却状态
|
||||
|
||||
策略:
|
||||
- 连续失败 N 次后进入熔断状态
|
||||
- 熔断期间跳过该数据源
|
||||
- 冷却时间后自动恢复半开状态
|
||||
- 半开状态下单次成功则完全恢复,失败则继续熔断
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
failure_threshold: int = 3, # 连续失败次数阈值
|
||||
cooldown_seconds: float = 300.0, # 冷却时间(秒),默认5分钟
|
||||
half_open_max_calls: int = 1 # 半开状态最大尝试次数
|
||||
):
|
||||
self.failure_threshold = failure_threshold
|
||||
self.cooldown_seconds = cooldown_seconds
|
||||
self.half_open_max_calls = half_open_max_calls
|
||||
|
||||
# 各数据源状态 {source_name: {state, failures, last_failure_time, half_open_calls}}
|
||||
self._states: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _get_state(self, source: str) -> Dict[str, Any]:
|
||||
"""获取或初始化数据源状态"""
|
||||
if source not in self._states:
|
||||
self._states[source] = {
|
||||
'state': CircuitState.CLOSED,
|
||||
'failures': 0,
|
||||
'last_failure_time': 0.0,
|
||||
'half_open_calls': 0,
|
||||
'last_error': None
|
||||
}
|
||||
return self._states[source]
|
||||
|
||||
def is_available(self, source: str) -> bool:
|
||||
"""
|
||||
检查数据源是否可用
|
||||
|
||||
返回 True 表示可以尝试请求
|
||||
返回 False 表示应跳过该数据源
|
||||
"""
|
||||
state = self._get_state(source)
|
||||
current_time = time.time()
|
||||
|
||||
if state['state'] == CircuitState.CLOSED:
|
||||
return True
|
||||
|
||||
if state['state'] == CircuitState.OPEN:
|
||||
# 检查冷却时间
|
||||
time_since_failure = current_time - state['last_failure_time']
|
||||
if time_since_failure >= self.cooldown_seconds:
|
||||
# 冷却完成,进入半开状态
|
||||
state['state'] = CircuitState.HALF_OPEN
|
||||
state['half_open_calls'] = 0
|
||||
logger.info(f"[熔断器] {source} 冷却完成,进入半开状态")
|
||||
return True
|
||||
else:
|
||||
remaining = self.cooldown_seconds - time_since_failure
|
||||
logger.debug(f"[熔断器] {source} 处于熔断状态,剩余冷却时间: {remaining:.0f}s")
|
||||
return False
|
||||
|
||||
if state['state'] == CircuitState.HALF_OPEN:
|
||||
# 半开状态下限制请求次数
|
||||
if state['half_open_calls'] < self.half_open_max_calls:
|
||||
return True
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def record_success(self, source: str) -> None:
|
||||
"""记录成功请求"""
|
||||
state = self._get_state(source)
|
||||
|
||||
if state['state'] == CircuitState.HALF_OPEN:
|
||||
# 半开状态下成功,完全恢复
|
||||
logger.info(f"[熔断器] {source} 半开状态请求成功,恢复正常")
|
||||
|
||||
# 重置状态
|
||||
state['state'] = CircuitState.CLOSED
|
||||
state['failures'] = 0
|
||||
state['half_open_calls'] = 0
|
||||
state['last_error'] = None
|
||||
|
||||
def record_failure(self, source: str, error: Optional[str] = None) -> None:
|
||||
"""记录失败请求"""
|
||||
state = self._get_state(source)
|
||||
current_time = time.time()
|
||||
|
||||
state['failures'] += 1
|
||||
state['last_failure_time'] = current_time
|
||||
state['last_error'] = error
|
||||
|
||||
if state['state'] == CircuitState.HALF_OPEN:
|
||||
# 半开状态下失败,继续熔断
|
||||
state['state'] = CircuitState.OPEN
|
||||
state['half_open_calls'] = 0
|
||||
logger.warning(f"[熔断器] {source} 半开状态请求失败,继续熔断 {self.cooldown_seconds}s")
|
||||
elif state['failures'] >= self.failure_threshold:
|
||||
# 达到阈值,进入熔断
|
||||
state['state'] = CircuitState.OPEN
|
||||
logger.warning(f"[熔断器] {source} 连续失败 {state['failures']} 次,进入熔断状态 "
|
||||
f"(冷却 {self.cooldown_seconds}s)")
|
||||
if error:
|
||||
logger.warning(f"[熔断器] 最后错误: {error}")
|
||||
|
||||
def get_status(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""获取所有数据源状态"""
|
||||
return {
|
||||
source: {
|
||||
'state': info['state'].value,
|
||||
'failures': info['failures'],
|
||||
'last_error': info['last_error']
|
||||
}
|
||||
for source, info in self._states.items()
|
||||
}
|
||||
|
||||
def reset(self, source: Optional[str] = None) -> None:
|
||||
"""重置熔断器状态"""
|
||||
if source:
|
||||
if source in self._states:
|
||||
del self._states[source]
|
||||
logger.info(f"[熔断器] 已重置 {source} 的熔断状态")
|
||||
else:
|
||||
self._states.clear()
|
||||
logger.info("[熔断器] 已重置所有数据源的熔断状态")
|
||||
|
||||
|
||||
# ============================================
|
||||
# 全局熔断器实例
|
||||
# ============================================
|
||||
|
||||
# A股数据源熔断器(标准策略)
|
||||
_ashare_circuit_breaker = CircuitBreaker(
|
||||
failure_threshold=3, # 连续失败3次熔断
|
||||
cooldown_seconds=300.0, # 冷却5分钟
|
||||
half_open_max_calls=1
|
||||
)
|
||||
|
||||
# 实时行情熔断器(更严格的策略)
|
||||
_realtime_circuit_breaker = CircuitBreaker(
|
||||
failure_threshold=2, # 连续失败2次熔断
|
||||
cooldown_seconds=180.0, # 冷却3分钟
|
||||
half_open_max_calls=1
|
||||
)
|
||||
|
||||
|
||||
def get_ashare_circuit_breaker() -> CircuitBreaker:
|
||||
"""获取A股数据源熔断器"""
|
||||
return _ashare_circuit_breaker
|
||||
|
||||
|
||||
def get_realtime_circuit_breaker() -> CircuitBreaker:
|
||||
"""获取实时行情熔断器"""
|
||||
return _realtime_circuit_breaker
|
||||
@@ -1,11 +1,18 @@
|
||||
"""
|
||||
CN/HK stock data source.
|
||||
Supports A-Share and H-Share with multiple public sources.
|
||||
Priority (AShare): Eastmoney (intraday/daily) > yfinance (daily) > akshare (daily, optional).
|
||||
Priority (HShare): Tencent (intraday) > Eastmoney/Tencent (daily) > yfinance (daily) > akshare (daily, optional).
|
||||
|
||||
改进版本(参考 daily_stock_analysis 项目):
|
||||
- 多数据源自动切换(按优先级)
|
||||
- 熔断器保护
|
||||
- 数据缓存
|
||||
- 防封禁策略(随机休眠+UA轮换)
|
||||
|
||||
Priority (AShare): Eastmoney > Tencent > Sina > Akshare > yfinance
|
||||
Priority (HShare): Tencent > Eastmoney > yfinance > akshare
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, List, Any, Optional
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
import requests
|
||||
|
||||
@@ -13,6 +20,9 @@ import yfinance as yf
|
||||
|
||||
from app.data_sources.base import BaseDataSource
|
||||
from app.data_sources.us_stock import USStockDataSource
|
||||
from app.data_sources.data_manager import get_ashare_data_manager, AShareDataManager
|
||||
from app.data_sources.circuit_breaker import get_ashare_circuit_breaker, get_realtime_circuit_breaker
|
||||
from app.data_sources.rate_limiter import get_request_headers, get_tencent_limiter, get_eastmoney_limiter
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.http import get_retry_session
|
||||
|
||||
@@ -166,7 +176,14 @@ class TencentDataMixin:
|
||||
|
||||
|
||||
class AShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
"""A-Share data source."""
|
||||
"""
|
||||
A-Share data source.
|
||||
|
||||
改进版本:使用 AShareDataManager 实现多数据源自动切换
|
||||
- 熔断器保护
|
||||
- 数据缓存
|
||||
- 防封禁策略
|
||||
"""
|
||||
|
||||
name = "AShare"
|
||||
|
||||
@@ -190,6 +207,11 @@ class AShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
|
||||
def __init__(self):
|
||||
self.us_stock_source = USStockDataSource()
|
||||
# 使用新的数据管理器
|
||||
self._data_manager = get_ashare_data_manager()
|
||||
# 熔断器和限流器
|
||||
self._circuit_breaker = get_ashare_circuit_breaker()
|
||||
self._em_limiter = get_eastmoney_limiter()
|
||||
|
||||
def get_kline(
|
||||
self,
|
||||
@@ -198,11 +220,28 @@ class AShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch A-Share Kline data."""
|
||||
klines = []
|
||||
"""
|
||||
Fetch A-Share Kline data.
|
||||
|
||||
# Prefer Eastmoney (supports most intraday timeframes)
|
||||
klines = self._fetch_eastmoney_ashare(symbol, timeframe, limit)
|
||||
改进版本:使用数据管理器自动切换数据源
|
||||
"""
|
||||
# 使用新的数据管理器获取数据(自动切换数据源)
|
||||
klines, source = self._data_manager.get_kline(
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
limit=limit,
|
||||
before_time=before_time
|
||||
)
|
||||
|
||||
if klines:
|
||||
self.log_result(symbol, klines, timeframe)
|
||||
return klines
|
||||
|
||||
# 如果数据管理器失败,使用传统方式作为最后备选
|
||||
logger.warning(f"[AShare] 数据管理器获取 {symbol} 失败,尝试传统方式")
|
||||
|
||||
# 传统方式:直接调用东方财富
|
||||
klines = self._fetch_eastmoney_ashare_legacy(symbol, timeframe, limit)
|
||||
if klines:
|
||||
klines = self.filter_and_limit(klines, limit, before_time)
|
||||
self.log_result(symbol, klines, timeframe)
|
||||
@@ -212,20 +251,24 @@ class AShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
if timeframe in ('1D', '1W'):
|
||||
yahoo_symbol = self._to_yahoo_symbol(symbol)
|
||||
if yahoo_symbol:
|
||||
# logger.info(f"尝试使用 yfinance 获取A股: {yahoo_symbol}")
|
||||
klines = self.us_stock_source.get_kline(yahoo_symbol, timeframe, limit, before_time)
|
||||
if klines:
|
||||
# logger.info(f"yfinance 成功获取 {len(klines)} 条A股数据")
|
||||
return klines
|
||||
|
||||
# Fallback: akshare (daily/weekly)
|
||||
if HAS_AKSHARE and timeframe in self.AKSHARE_PERIOD_MAP:
|
||||
klines = self._fetch_akshare(symbol, timeframe, limit, before_time)
|
||||
if klines:
|
||||
return klines
|
||||
|
||||
logger.warning(f"AShare {symbol} data fetch failed")
|
||||
return klines
|
||||
return []
|
||||
|
||||
def _fetch_eastmoney_ashare_legacy(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
传统方式获取东方财富数据(兜底用)
|
||||
不使用新的熔断器和限流器,保持原有逻辑
|
||||
"""
|
||||
return self._fetch_eastmoney_ashare(symbol, timeframe, limit)
|
||||
|
||||
def _to_tencent_symbol(self, symbol: str) -> Optional[str]:
|
||||
"""转换为腾讯财经格式"""
|
||||
@@ -391,7 +434,10 @@ class AShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
"""
|
||||
获取A股实时报价
|
||||
|
||||
使用东方财富实时行情API获取实时报价
|
||||
改进版本:使用数据管理器自动切换数据源
|
||||
- 熔断器保护
|
||||
- 数据缓存(60秒TTL)
|
||||
- 多数据源自动切换
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
@@ -406,6 +452,21 @@ class AShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
"""
|
||||
symbol = (symbol or '').strip()
|
||||
|
||||
# 使用数据管理器获取实时报价(自动切换数据源)
|
||||
quote, source = self._data_manager.get_realtime_quote(symbol)
|
||||
if quote and quote.get('last', 0) > 0:
|
||||
return quote
|
||||
|
||||
# 如果数据管理器失败,使用传统方式作为兜底
|
||||
logger.debug(f"[AShare] 数据管理器获取 {symbol} 实时报价失败,尝试传统方式")
|
||||
return self._get_ticker_legacy(symbol)
|
||||
|
||||
def _get_ticker_legacy(self, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
传统方式获取实时报价(兜底用)
|
||||
|
||||
保持原有逻辑,不使用熔断器和限流器
|
||||
"""
|
||||
# 优先使用东方财富实时行情 API
|
||||
try:
|
||||
# 判断市场
|
||||
@@ -423,8 +484,6 @@ class AShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields': 'f43,f44,f45,f46,f47,f48,f57,f58,f60,f169,f170',
|
||||
# f43=最新价, f44=最高价, f45=最低价, f46=开盘价
|
||||
# f60=昨收价, f169=涨跌额, f170=涨跌幅
|
||||
}
|
||||
|
||||
session = get_retry_session()
|
||||
@@ -434,9 +493,8 @@ class AShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
if data and data.get('data'):
|
||||
d = data['data']
|
||||
last_price = d.get('f43', 0)
|
||||
# 东方财富返回的价格是整数(分),需要除以100
|
||||
if last_price and last_price > 0:
|
||||
divisor = 100 if last_price > 1000 else 1 # 价格超过10元时用分表示
|
||||
divisor = 100 if last_price > 1000 else 1
|
||||
return {
|
||||
'last': last_price / divisor,
|
||||
'high': d.get('f44', 0) / divisor,
|
||||
@@ -444,7 +502,7 @@ class AShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
'open': d.get('f46', 0) / divisor,
|
||||
'previousClose': d.get('f60', 0) / divisor,
|
||||
'change': d.get('f169', 0) / divisor,
|
||||
'changePercent': d.get('f170', 0) / 100 # 涨跌幅是整数(%*100)
|
||||
'changePercent': d.get('f170', 0) / 100
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Eastmoney ticker failed for {symbol}: {e}")
|
||||
@@ -473,28 +531,6 @@ class AShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
except Exception as e:
|
||||
logger.debug(f"Tencent ticker failed for {symbol}: {e}")
|
||||
|
||||
# 第三备选: Akshare
|
||||
try:
|
||||
import akshare as ak
|
||||
# 使用 akshare 获取实时行情
|
||||
df = ak.stock_zh_a_spot_em()
|
||||
if df is not None and not df.empty:
|
||||
# 在数据中查找对应股票
|
||||
row = df[df['代码'] == symbol]
|
||||
if not row.empty:
|
||||
row = row.iloc[0]
|
||||
return {
|
||||
'last': float(row.get('最新价', 0) or 0),
|
||||
'change': float(row.get('涨跌额', 0) or 0),
|
||||
'changePercent': float(row.get('涨跌幅', 0) or 0),
|
||||
'high': float(row.get('最高', 0) or 0),
|
||||
'low': float(row.get('最低', 0) or 0),
|
||||
'open': float(row.get('今开', 0) or 0),
|
||||
'previousClose': float(row.get('昨收', 0) or 0)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Akshare ticker failed for {symbol}: {e}")
|
||||
|
||||
return {'last': 0, 'symbol': symbol}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
A股数据源管理器 (Data Manager)
|
||||
===================================
|
||||
|
||||
参考 daily_stock_analysis 项目的 DataFetcherManager 实现
|
||||
统一管理多个A股数据源,实现自动故障切换
|
||||
|
||||
数据源优先级:
|
||||
1. 东方财富 (Eastmoney) - 数据最全,首选
|
||||
2. 腾讯财经 (Tencent) - 稳定可靠
|
||||
3. 新浪财经 (Sina) - 轻量级
|
||||
4. Akshare - 功能丰富,但容易被封
|
||||
5. yfinance - 国际数据源,兜底
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from datetime import datetime
|
||||
import requests
|
||||
|
||||
from app.data_sources.circuit_breaker import (
|
||||
CircuitBreaker,
|
||||
get_ashare_circuit_breaker,
|
||||
get_realtime_circuit_breaker
|
||||
)
|
||||
from app.data_sources.cache_manager import (
|
||||
DataCache,
|
||||
get_realtime_cache,
|
||||
get_kline_cache,
|
||||
generate_kline_cache_key
|
||||
)
|
||||
from app.data_sources.rate_limiter import (
|
||||
RateLimiter,
|
||||
get_eastmoney_limiter,
|
||||
get_tencent_limiter,
|
||||
get_akshare_limiter,
|
||||
get_request_headers,
|
||||
retry_with_backoff
|
||||
)
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.http import get_retry_session
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 数据源常量
|
||||
# ============================================
|
||||
|
||||
class DataSource:
|
||||
"""数据源标识"""
|
||||
EASTMONEY = "eastmoney"
|
||||
TENCENT = "tencent"
|
||||
SINA = "sina"
|
||||
AKSHARE = "akshare"
|
||||
YFINANCE = "yfinance"
|
||||
|
||||
|
||||
# 数据源优先级(数字越小优先级越高)
|
||||
DATA_SOURCE_PRIORITY = {
|
||||
DataSource.EASTMONEY: 0,
|
||||
DataSource.TENCENT: 1,
|
||||
DataSource.SINA: 2,
|
||||
DataSource.AKSHARE: 3,
|
||||
DataSource.YFINANCE: 4,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# A股数据管理器
|
||||
# ============================================
|
||||
|
||||
class AShareDataManager:
|
||||
"""
|
||||
A股数据源管理器
|
||||
|
||||
功能:
|
||||
1. 多数据源自动切换(按优先级)
|
||||
2. 熔断器保护
|
||||
3. 数据缓存
|
||||
4. 防封禁策略
|
||||
"""
|
||||
|
||||
# 东方财富 K 线周期映射
|
||||
EM_PERIOD_MAP = {
|
||||
'1m': '1',
|
||||
'5m': '5',
|
||||
'15m': '15',
|
||||
'30m': '30',
|
||||
'1H': '60',
|
||||
'4H': '240',
|
||||
'1D': '101',
|
||||
'1W': '102',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
# 熔断器
|
||||
self._circuit_breaker = get_ashare_circuit_breaker()
|
||||
self._realtime_cb = get_realtime_circuit_breaker()
|
||||
|
||||
# 缓存
|
||||
self._realtime_cache = get_realtime_cache()
|
||||
self._kline_cache = get_kline_cache()
|
||||
|
||||
# 限流器
|
||||
self._em_limiter = get_eastmoney_limiter()
|
||||
self._tencent_limiter = get_tencent_limiter()
|
||||
self._akshare_limiter = get_akshare_limiter()
|
||||
|
||||
# Akshare 可用性检查
|
||||
self._has_akshare = self._check_akshare()
|
||||
|
||||
def _check_akshare(self) -> bool:
|
||||
"""检查 akshare 是否可用"""
|
||||
try:
|
||||
import akshare
|
||||
return True
|
||||
except ImportError:
|
||||
logger.debug("akshare 未安装,相关功能已禁用")
|
||||
return False
|
||||
|
||||
def get_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None,
|
||||
use_cache: bool = True
|
||||
) -> Tuple[List[Dict[str, Any]], str]:
|
||||
"""
|
||||
获取K线数据(自动切换数据源)
|
||||
|
||||
Args:
|
||||
symbol: 股票代码
|
||||
timeframe: 时间周期
|
||||
limit: 数据条数
|
||||
before_time: 获取此时间之前的数据
|
||||
use_cache: 是否使用缓存
|
||||
|
||||
Returns:
|
||||
(K线数据列表, 成功的数据源名称)
|
||||
"""
|
||||
# 检查缓存
|
||||
if use_cache:
|
||||
cache_key = generate_kline_cache_key(symbol, timeframe, limit, before_time)
|
||||
cached = self._kline_cache.get(cache_key)
|
||||
if cached:
|
||||
logger.debug(f"[缓存命中] K线数据 {symbol}:{timeframe}")
|
||||
return cached, "cache"
|
||||
|
||||
errors = []
|
||||
|
||||
# 按优先级尝试各个数据源
|
||||
sources = [
|
||||
(DataSource.EASTMONEY, self._fetch_eastmoney_kline),
|
||||
(DataSource.TENCENT, self._fetch_tencent_kline),
|
||||
(DataSource.AKSHARE, self._fetch_akshare_kline),
|
||||
(DataSource.YFINANCE, self._fetch_yfinance_kline),
|
||||
]
|
||||
|
||||
for source_name, fetch_func in sources:
|
||||
# 检查熔断器
|
||||
if not self._circuit_breaker.is_available(source_name):
|
||||
logger.debug(f"[熔断] {source_name} 处于熔断状态,跳过")
|
||||
continue
|
||||
|
||||
# 跳过不可用的 akshare
|
||||
if source_name == DataSource.AKSHARE and not self._has_akshare:
|
||||
continue
|
||||
|
||||
try:
|
||||
logger.debug(f"[数据源] 尝试 {source_name} 获取 {symbol}")
|
||||
klines = fetch_func(symbol, timeframe, limit, before_time)
|
||||
|
||||
if klines:
|
||||
self._circuit_breaker.record_success(source_name)
|
||||
|
||||
# 更新缓存
|
||||
if use_cache:
|
||||
cache_key = generate_kline_cache_key(symbol, timeframe, limit, before_time)
|
||||
self._kline_cache.set(cache_key, klines)
|
||||
|
||||
logger.info(f"[数据源] {source_name} 成功获取 {symbol} {len(klines)} 条数据")
|
||||
return klines, source_name
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"{source_name}: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
self._circuit_breaker.record_failure(source_name, str(e))
|
||||
logger.warning(f"[数据源] {error_msg}")
|
||||
|
||||
# 所有数据源都失败
|
||||
logger.error(f"[数据源] 所有数据源获取 {symbol} 失败: {errors}")
|
||||
return [], ""
|
||||
|
||||
def _fetch_eastmoney_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用东方财富获取K线数据"""
|
||||
klines = []
|
||||
|
||||
period = self.EM_PERIOD_MAP.get(timeframe)
|
||||
if not period:
|
||||
raise ValueError(f"Eastmoney 不支持时间周期: {timeframe}")
|
||||
|
||||
# 限流
|
||||
self._em_limiter.wait()
|
||||
|
||||
# 确定市场代码
|
||||
if symbol.startswith('6'):
|
||||
secid = f"1.{symbol}" # 上海
|
||||
else:
|
||||
secid = f"0.{symbol}" # 深圳
|
||||
|
||||
url = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields1': 'f1,f2,f3,f4,f5,f6',
|
||||
'fields2': 'f51,f52,f53,f54,f55,f56,f57',
|
||||
'klt': period,
|
||||
'fqt': '1', # 前复权
|
||||
'end': '20500101',
|
||||
'lmt': limit,
|
||||
}
|
||||
|
||||
headers = get_request_headers(referer='https://quote.eastmoney.com/')
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, params=params, headers=headers, timeout=15)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ConnectionError(f"HTTP {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
|
||||
if data.get('data') and data['data'].get('klines'):
|
||||
for line in data['data']['klines']:
|
||||
try:
|
||||
parts = line.split(',')
|
||||
if len(parts) >= 6:
|
||||
time_str = parts[0]
|
||||
if ' ' in time_str:
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%d %H:%M')
|
||||
else:
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%d')
|
||||
|
||||
klines.append({
|
||||
'time': int(dt.timestamp()),
|
||||
'open': round(float(parts[1]), 4),
|
||||
'high': round(float(parts[3]), 4),
|
||||
'low': round(float(parts[4]), 4),
|
||||
'close': round(float(parts[2]), 4),
|
||||
'volume': round(float(parts[5]), 2)
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# 过滤和排序
|
||||
klines.sort(key=lambda x: x['time'])
|
||||
if before_time:
|
||||
klines = [k for k in klines if k['time'] < before_time]
|
||||
if len(klines) > limit:
|
||||
klines = klines[-limit:]
|
||||
|
||||
return klines
|
||||
|
||||
def _fetch_tencent_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用腾讯财经获取K线数据"""
|
||||
# 腾讯周期映射
|
||||
period_map = {
|
||||
'1m': 1, '5m': 5, '15m': 15, '30m': 30,
|
||||
'1H': 60, '1D': 'day', '1W': 'week'
|
||||
}
|
||||
|
||||
period = period_map.get(timeframe)
|
||||
if period is None:
|
||||
raise ValueError(f"腾讯财经不支持时间周期: {timeframe}")
|
||||
|
||||
# 转换代码格式
|
||||
if symbol.startswith('6'):
|
||||
tencent_symbol = f"sh{symbol}"
|
||||
elif symbol.startswith('0') or symbol.startswith('3'):
|
||||
tencent_symbol = f"sz{symbol}"
|
||||
else:
|
||||
tencent_symbol = f"bj{symbol}"
|
||||
|
||||
# 限流
|
||||
self._tencent_limiter.wait()
|
||||
|
||||
# 构建URL
|
||||
if isinstance(period, int):
|
||||
url = f"http://ifzq.gtimg.cn/appstock/app/kline/mkline?param={tencent_symbol},m{period},,{limit}"
|
||||
else:
|
||||
url = f"http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={tencent_symbol},{period},,,{limit},qfq"
|
||||
|
||||
response = requests.get(url, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ConnectionError(f"HTTP {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
klines = []
|
||||
|
||||
if data.get('code') == 0 and 'data' in data:
|
||||
stock_data = data['data'].get(tencent_symbol)
|
||||
if stock_data:
|
||||
if isinstance(period, int):
|
||||
candles = stock_data.get(f'm{period}', [])
|
||||
else:
|
||||
candles = stock_data.get('qfqday', stock_data.get('day', []))
|
||||
|
||||
for candle in candles:
|
||||
if len(candle) >= 5:
|
||||
try:
|
||||
time_str = str(candle[0])
|
||||
if len(time_str) == 12:
|
||||
dt = datetime.strptime(time_str, '%Y%m%d%H%M')
|
||||
elif len(time_str) == 10:
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%d')
|
||||
else:
|
||||
continue
|
||||
|
||||
klines.append({
|
||||
'time': int(dt.timestamp()),
|
||||
'open': round(float(candle[1]), 4),
|
||||
'high': round(float(candle[3]), 4),
|
||||
'low': round(float(candle[4]), 4),
|
||||
'close': round(float(candle[2]), 4),
|
||||
'volume': round(float(candle[5]), 2) if len(candle) > 5 else 0
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# 过滤和排序
|
||||
klines.sort(key=lambda x: x['time'])
|
||||
if before_time:
|
||||
klines = [k for k in klines if k['time'] < before_time]
|
||||
if len(klines) > limit:
|
||||
klines = klines[-limit:]
|
||||
|
||||
return klines
|
||||
|
||||
def _fetch_akshare_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用 Akshare 获取K线数据"""
|
||||
if not self._has_akshare:
|
||||
raise RuntimeError("akshare 未安装")
|
||||
|
||||
import akshare as ak
|
||||
from datetime import timedelta
|
||||
|
||||
# Akshare 只支持日线/周线
|
||||
period_map = {'1D': 'daily', '1W': 'weekly'}
|
||||
period = period_map.get(timeframe)
|
||||
if not period:
|
||||
raise ValueError(f"Akshare 不支持时间周期: {timeframe}")
|
||||
|
||||
# 限流
|
||||
self._akshare_limiter.wait()
|
||||
|
||||
# 计算日期范围
|
||||
if before_time:
|
||||
end_date = datetime.fromtimestamp(before_time).strftime('%Y%m%d')
|
||||
else:
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
days = limit * 2 if timeframe == '1D' else limit * 10
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
|
||||
|
||||
df = ak.stock_zh_a_hist(
|
||||
symbol=symbol,
|
||||
period=period,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
adjust="qfq"
|
||||
)
|
||||
|
||||
klines = []
|
||||
if df is not None and not df.empty:
|
||||
df = df.tail(limit)
|
||||
for _, row in df.iterrows():
|
||||
ts = int(datetime.strptime(str(row['日期']), '%Y-%m-%d').timestamp())
|
||||
klines.append({
|
||||
'time': ts,
|
||||
'open': round(float(row['开盘']), 4),
|
||||
'high': round(float(row['最高']), 4),
|
||||
'low': round(float(row['最低']), 4),
|
||||
'close': round(float(row['收盘']), 4),
|
||||
'volume': round(float(row['成交量']), 2)
|
||||
})
|
||||
|
||||
return klines
|
||||
|
||||
def _fetch_yfinance_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用 yfinance 获取K线数据(兜底)"""
|
||||
import yfinance as yf
|
||||
|
||||
# 转换为 Yahoo 格式
|
||||
if symbol.startswith('6'):
|
||||
yahoo_symbol = f"{symbol}.SS"
|
||||
elif symbol.startswith('0') or symbol.startswith('3'):
|
||||
yahoo_symbol = f"{symbol}.SZ"
|
||||
else:
|
||||
yahoo_symbol = f"{symbol}.SS"
|
||||
|
||||
# 周期映射
|
||||
period_map = {
|
||||
'1D': ('1d', f'{limit}d'),
|
||||
'1W': ('1wk', f'{limit * 7}d'),
|
||||
'1H': ('1h', f'{limit}d'),
|
||||
}
|
||||
|
||||
interval, period = period_map.get(timeframe, ('1d', f'{limit}d'))
|
||||
|
||||
ticker = yf.Ticker(yahoo_symbol)
|
||||
df = ticker.history(period=period, interval=interval)
|
||||
|
||||
klines = []
|
||||
if df is not None and not df.empty:
|
||||
df = df.tail(limit)
|
||||
for idx, row in df.iterrows():
|
||||
ts = int(idx.timestamp())
|
||||
klines.append({
|
||||
'time': ts,
|
||||
'open': round(float(row['Open']), 4),
|
||||
'high': round(float(row['High']), 4),
|
||||
'low': round(float(row['Low']), 4),
|
||||
'close': round(float(row['Close']), 4),
|
||||
'volume': round(float(row['Volume']), 2)
|
||||
})
|
||||
|
||||
return klines
|
||||
|
||||
def get_realtime_quote(
|
||||
self,
|
||||
symbol: str,
|
||||
use_cache: bool = True
|
||||
) -> Tuple[Dict[str, Any], str]:
|
||||
"""
|
||||
获取实时报价(自动切换数据源)
|
||||
|
||||
Args:
|
||||
symbol: 股票代码
|
||||
use_cache: 是否使用缓存
|
||||
|
||||
Returns:
|
||||
(报价数据字典, 成功的数据源名称)
|
||||
"""
|
||||
# 检查缓存
|
||||
if use_cache:
|
||||
cached = self._realtime_cache.get(f"quote:{symbol}")
|
||||
if cached:
|
||||
return cached, "cache"
|
||||
|
||||
errors = []
|
||||
|
||||
# 按优先级尝试各个数据源
|
||||
sources = [
|
||||
(DataSource.EASTMONEY, self._fetch_eastmoney_quote),
|
||||
(DataSource.TENCENT, self._fetch_tencent_quote),
|
||||
(DataSource.AKSHARE, self._fetch_akshare_quote),
|
||||
]
|
||||
|
||||
for source_name, fetch_func in sources:
|
||||
if not self._realtime_cb.is_available(source_name):
|
||||
continue
|
||||
|
||||
if source_name == DataSource.AKSHARE and not self._has_akshare:
|
||||
continue
|
||||
|
||||
try:
|
||||
quote = fetch_func(symbol)
|
||||
if quote and quote.get('last', 0) > 0:
|
||||
self._realtime_cb.record_success(source_name)
|
||||
|
||||
# 更新缓存
|
||||
if use_cache:
|
||||
self._realtime_cache.set(f"quote:{symbol}", quote, ttl=60.0)
|
||||
|
||||
return quote, source_name
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"{source_name}: {str(e)}")
|
||||
self._realtime_cb.record_failure(source_name, str(e))
|
||||
|
||||
logger.warning(f"[实时报价] 所有数据源获取 {symbol} 失败")
|
||||
return {'last': 0, 'symbol': symbol}, ""
|
||||
|
||||
def _fetch_eastmoney_quote(self, symbol: str) -> Dict[str, Any]:
|
||||
"""使用东方财富获取实时报价"""
|
||||
if symbol.startswith('6'):
|
||||
secid = f"1.{symbol}"
|
||||
else:
|
||||
secid = f"0.{symbol}"
|
||||
|
||||
url = "https://push2.eastmoney.com/api/qt/stock/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields': 'f43,f44,f45,f46,f47,f48,f57,f58,f60,f169,f170',
|
||||
}
|
||||
|
||||
self._em_limiter.wait()
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, params=params, headers=get_request_headers(), timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data and data.get('data'):
|
||||
d = data['data']
|
||||
last_price = d.get('f43', 0)
|
||||
if last_price and last_price > 0:
|
||||
divisor = 100 if last_price > 1000 else 1
|
||||
return {
|
||||
'last': last_price / divisor,
|
||||
'high': d.get('f44', 0) / divisor,
|
||||
'low': d.get('f45', 0) / divisor,
|
||||
'open': d.get('f46', 0) / divisor,
|
||||
'previousClose': d.get('f60', 0) / divisor,
|
||||
'change': d.get('f169', 0) / divisor,
|
||||
'changePercent': d.get('f170', 0) / 100
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
def _fetch_tencent_quote(self, symbol: str) -> Dict[str, Any]:
|
||||
"""使用腾讯财经获取实时报价"""
|
||||
if symbol.startswith('6'):
|
||||
tencent_symbol = f"sh{symbol}"
|
||||
else:
|
||||
tencent_symbol = f"sz{symbol}"
|
||||
|
||||
url = f"http://qt.gtimg.cn/q={tencent_symbol}"
|
||||
|
||||
self._tencent_limiter.wait()
|
||||
|
||||
response = requests.get(url, timeout=10)
|
||||
content = response.content.decode('gbk', errors='ignore')
|
||||
|
||||
if '="' in content:
|
||||
data_str = content.split('="')[1].strip('";\n')
|
||||
if data_str:
|
||||
parts = data_str.split('~')
|
||||
if len(parts) > 32:
|
||||
return {
|
||||
'last': float(parts[3]) if parts[3] else 0,
|
||||
'change': float(parts[31]) if parts[31] else 0,
|
||||
'changePercent': float(parts[32]) if parts[32] else 0,
|
||||
'high': float(parts[33]) if len(parts) > 33 and parts[33] else 0,
|
||||
'low': float(parts[34]) if len(parts) > 34 and parts[34] else 0,
|
||||
'open': float(parts[5]) if len(parts) > 5 and parts[5] else 0,
|
||||
'previousClose': float(parts[4]) if parts[4] else 0
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
def _fetch_akshare_quote(self, symbol: str) -> Dict[str, Any]:
|
||||
"""使用 Akshare 获取实时报价"""
|
||||
if not self._has_akshare:
|
||||
return {}
|
||||
|
||||
import akshare as ak
|
||||
|
||||
self._akshare_limiter.wait()
|
||||
|
||||
df = ak.stock_zh_a_spot_em()
|
||||
if df is not None and not df.empty:
|
||||
row = df[df['代码'] == symbol]
|
||||
if not row.empty:
|
||||
row = row.iloc[0]
|
||||
return {
|
||||
'last': float(row.get('最新价', 0) or 0),
|
||||
'change': float(row.get('涨跌额', 0) or 0),
|
||||
'changePercent': float(row.get('涨跌幅', 0) or 0),
|
||||
'high': float(row.get('最高', 0) or 0),
|
||||
'low': float(row.get('最低', 0) or 0),
|
||||
'open': float(row.get('今开', 0) or 0),
|
||||
'previousClose': float(row.get('昨收', 0) or 0)
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""获取数据管理器状态"""
|
||||
return {
|
||||
'circuit_breaker': self._circuit_breaker.get_status(),
|
||||
'realtime_circuit_breaker': self._realtime_cb.get_status(),
|
||||
'realtime_cache_stats': self._realtime_cache.stats(),
|
||||
'kline_cache_stats': self._kline_cache.stats(),
|
||||
'has_akshare': self._has_akshare,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# 全局实例
|
||||
# ============================================
|
||||
|
||||
_ashare_data_manager: Optional[AShareDataManager] = None
|
||||
|
||||
|
||||
def get_ashare_data_manager() -> AShareDataManager:
|
||||
"""获取A股数据管理器单例"""
|
||||
global _ashare_data_manager
|
||||
if _ashare_data_manager is None:
|
||||
_ashare_data_manager = AShareDataManager()
|
||||
return _ashare_data_manager
|
||||
@@ -0,0 +1,272 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
防封禁工具模块 (Rate Limiter)
|
||||
===================================
|
||||
|
||||
参考 daily_stock_analysis 项目实现
|
||||
提供反爬虫策略:
|
||||
1. 随机休眠(Jitter)
|
||||
2. 随机 User-Agent 轮换
|
||||
3. 指数退避重试
|
||||
4. 请求频率限制
|
||||
"""
|
||||
|
||||
import time
|
||||
import random
|
||||
import logging
|
||||
from typing import Optional, Callable, Any, Type, Tuple
|
||||
from functools import wraps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================
|
||||
# User-Agent 池
|
||||
# ============================================
|
||||
|
||||
USER_AGENTS = [
|
||||
# Chrome Windows
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
# Chrome Mac
|
||||
'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',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
|
||||
# Firefox
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0',
|
||||
# Safari
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15',
|
||||
# Edge
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0',
|
||||
# Linux Chrome
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
]
|
||||
|
||||
|
||||
def get_random_user_agent() -> str:
|
||||
"""获取随机 User-Agent"""
|
||||
return random.choice(USER_AGENTS)
|
||||
|
||||
|
||||
def get_request_headers(referer: Optional[str] = None) -> dict:
|
||||
"""
|
||||
获取带有随机 User-Agent 的请求头
|
||||
|
||||
Args:
|
||||
referer: 可选的 Referer 头
|
||||
|
||||
Returns:
|
||||
请求头字典
|
||||
"""
|
||||
headers = {
|
||||
'User-Agent': get_random_user_agent(),
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'Connection': 'keep-alive',
|
||||
}
|
||||
|
||||
if referer:
|
||||
headers['Referer'] = referer
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
# ============================================
|
||||
# 随机休眠
|
||||
# ============================================
|
||||
|
||||
def random_sleep(
|
||||
min_seconds: float = 1.0,
|
||||
max_seconds: float = 3.0,
|
||||
log: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
随机休眠(Jitter)
|
||||
|
||||
防封禁策略:模拟人类行为的随机延迟
|
||||
在请求之间加入不规则的等待时间
|
||||
|
||||
Args:
|
||||
min_seconds: 最小休眠时间(秒)
|
||||
max_seconds: 最大休眠时间(秒)
|
||||
log: 是否记录日志
|
||||
"""
|
||||
sleep_time = random.uniform(min_seconds, max_seconds)
|
||||
if log:
|
||||
logger.debug(f"随机休眠 {sleep_time:.2f} 秒...")
|
||||
time.sleep(sleep_time)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 请求频率限制器
|
||||
# ============================================
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
请求频率限制器
|
||||
|
||||
确保请求之间有最小间隔时间
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_interval: float = 1.0,
|
||||
jitter_min: float = 0.5,
|
||||
jitter_max: float = 1.5
|
||||
):
|
||||
"""
|
||||
初始化频率限制器
|
||||
|
||||
Args:
|
||||
min_interval: 最小请求间隔(秒)
|
||||
jitter_min: 随机抖动最小值(秒)
|
||||
jitter_max: 随机抖动最大值(秒)
|
||||
"""
|
||||
self.min_interval = min_interval
|
||||
self.jitter_min = jitter_min
|
||||
self.jitter_max = jitter_max
|
||||
self._last_request_time: Optional[float] = None
|
||||
|
||||
def wait(self) -> float:
|
||||
"""
|
||||
等待直到可以发起下一次请求
|
||||
|
||||
Returns:
|
||||
实际等待的时间(秒)
|
||||
"""
|
||||
wait_time = 0.0
|
||||
|
||||
if self._last_request_time is not None:
|
||||
elapsed = time.time() - self._last_request_time
|
||||
if elapsed < self.min_interval:
|
||||
# 补充休眠到最小间隔
|
||||
wait_time = self.min_interval - elapsed
|
||||
time.sleep(wait_time)
|
||||
|
||||
# 添加随机抖动
|
||||
jitter = random.uniform(self.jitter_min, self.jitter_max)
|
||||
time.sleep(jitter)
|
||||
wait_time += jitter
|
||||
|
||||
# 记录本次请求时间
|
||||
self._last_request_time = time.time()
|
||||
|
||||
return wait_time
|
||||
|
||||
def reset(self) -> None:
|
||||
"""重置限制器"""
|
||||
self._last_request_time = None
|
||||
|
||||
|
||||
# ============================================
|
||||
# 指数退避重试装饰器
|
||||
# ============================================
|
||||
|
||||
def retry_with_backoff(
|
||||
max_attempts: int = 3,
|
||||
base_delay: float = 2.0,
|
||||
max_delay: float = 30.0,
|
||||
exponential_base: float = 2.0,
|
||||
exceptions: Tuple[Type[Exception], ...] = (Exception,),
|
||||
on_retry: Optional[Callable[[int, Exception], None]] = None
|
||||
):
|
||||
"""
|
||||
指数退避重试装饰器
|
||||
|
||||
Args:
|
||||
max_attempts: 最大重试次数
|
||||
base_delay: 基础延迟时间(秒)
|
||||
max_delay: 最大延迟时间(秒)
|
||||
exponential_base: 指数基数
|
||||
exceptions: 需要重试的异常类型
|
||||
on_retry: 重试时的回调函数
|
||||
|
||||
使用示例:
|
||||
@retry_with_backoff(max_attempts=3, exceptions=(ConnectionError, TimeoutError))
|
||||
def fetch_data():
|
||||
...
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> Any:
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exceptions as e:
|
||||
last_exception = e
|
||||
|
||||
if attempt == max_attempts:
|
||||
logger.error(f"[重试] {func.__name__} 已达最大重试次数 ({max_attempts}),放弃")
|
||||
raise
|
||||
|
||||
# 计算退避延迟: base_delay * (exponential_base ^ (attempt - 1))
|
||||
delay = min(
|
||||
base_delay * (exponential_base ** (attempt - 1)),
|
||||
max_delay
|
||||
)
|
||||
# 添加随机抖动 (±20%)
|
||||
delay *= random.uniform(0.8, 1.2)
|
||||
|
||||
logger.warning(
|
||||
f"[重试] {func.__name__} 第 {attempt}/{max_attempts} 次失败: {e}, "
|
||||
f"等待 {delay:.1f}s 后重试..."
|
||||
)
|
||||
|
||||
if on_retry:
|
||||
on_retry(attempt, e)
|
||||
|
||||
time.sleep(delay)
|
||||
|
||||
# 不应该到达这里
|
||||
raise last_exception
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# ============================================
|
||||
# 全局限流器实例
|
||||
# ============================================
|
||||
|
||||
# 东方财富接口限流器(较严格)
|
||||
_eastmoney_limiter = RateLimiter(
|
||||
min_interval=2.0,
|
||||
jitter_min=1.0,
|
||||
jitter_max=3.0
|
||||
)
|
||||
|
||||
# 腾讯财经接口限流器(较宽松)
|
||||
_tencent_limiter = RateLimiter(
|
||||
min_interval=1.0,
|
||||
jitter_min=0.5,
|
||||
jitter_max=1.5
|
||||
)
|
||||
|
||||
# Akshare 接口限流器
|
||||
_akshare_limiter = RateLimiter(
|
||||
min_interval=2.0,
|
||||
jitter_min=1.5,
|
||||
jitter_max=3.5
|
||||
)
|
||||
|
||||
|
||||
def get_eastmoney_limiter() -> RateLimiter:
|
||||
"""获取东方财富限流器"""
|
||||
return _eastmoney_limiter
|
||||
|
||||
|
||||
def get_tencent_limiter() -> RateLimiter:
|
||||
"""获取腾讯财经限流器"""
|
||||
return _tencent_limiter
|
||||
|
||||
|
||||
def get_akshare_limiter() -> RateLimiter:
|
||||
"""获取 Akshare 限流器"""
|
||||
return _akshare_limiter
|
||||
@@ -658,9 +658,9 @@ CONFIG_SCHEMA = {
|
||||
'key': 'SEARCH_PROVIDER',
|
||||
'label': 'Search Provider',
|
||||
'type': 'select',
|
||||
'options': ['google', 'bing', 'none'],
|
||||
'default': 'google',
|
||||
'description': 'Web search provider for AI research features'
|
||||
'options': ['bocha', 'tavily', 'google', 'bing', 'none'],
|
||||
'default': 'bocha',
|
||||
'description': 'Web search provider for AI research features. Bocha recommended for A-share news'
|
||||
},
|
||||
{
|
||||
'key': 'SEARCH_MAX_RESULTS',
|
||||
@@ -669,6 +669,36 @@ CONFIG_SCHEMA = {
|
||||
'default': '10',
|
||||
'description': 'Maximum search results to return'
|
||||
},
|
||||
# Tavily Search API
|
||||
{
|
||||
'key': 'TAVILY_API_KEYS',
|
||||
'label': 'Tavily API Keys',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'link': 'https://tavily.com/',
|
||||
'link_text': 'settings.link.getApiKey',
|
||||
'description': 'Tavily Search API keys, comma-separated for rotation. Free 1000 requests/month'
|
||||
},
|
||||
# Bocha Search API
|
||||
{
|
||||
'key': 'BOCHA_API_KEYS',
|
||||
'label': 'Bocha API Keys',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'link': 'https://bochaai.com/',
|
||||
'link_text': 'settings.link.getApiKey',
|
||||
'description': 'Bocha Search API keys, comma-separated for rotation. Best for A-share news'
|
||||
},
|
||||
# SerpAPI
|
||||
{
|
||||
'key': 'SERPAPI_KEYS',
|
||||
'label': 'SerpAPI Keys',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'link': 'https://serpapi.com/',
|
||||
'link_text': 'settings.link.getApiKey',
|
||||
'description': 'SerpAPI keys for Google/Bing search, comma-separated for rotation'
|
||||
},
|
||||
{
|
||||
'key': 'SEARCH_GOOGLE_API_KEY',
|
||||
'label': 'Google API Key',
|
||||
|
||||
@@ -890,15 +890,16 @@ class MarketDataCollector:
|
||||
"""
|
||||
获取新闻和情绪数据
|
||||
|
||||
策略:
|
||||
1. 使用结构化API (Finnhub) - 无需深度阅读
|
||||
2. 只获取标题和摘要 - 不读取全文
|
||||
3. 多来源聚合 - Finnhub + 市场特定来源
|
||||
策略(按优先级):
|
||||
1. 结构化API (Finnhub) - 美股首选
|
||||
2. akshare 多源 - A股首选(东方财富/新浪/同花顺/雪球)
|
||||
3. 搜索引擎 (Bocha/Tavily) - 补充搜索
|
||||
4. 情绪分析 - Finnhub 社交媒体情绪
|
||||
"""
|
||||
news_list = []
|
||||
sentiment = {}
|
||||
|
||||
# 1) Finnhub 新闻 (最可靠)
|
||||
# === 1) Finnhub 新闻 (美股首选) ===
|
||||
if self._finnhub_client:
|
||||
try:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
@@ -908,29 +909,32 @@ class MarketDataCollector:
|
||||
|
||||
if market == 'USStock':
|
||||
raw_news = self._finnhub_client.company_news(symbol, _from=start_date, to=end_date)
|
||||
else:
|
||||
# 通用新闻
|
||||
elif market == 'Crypto':
|
||||
# 加密货币通用新闻
|
||||
raw_news = self._finnhub_client.general_news('crypto', min_id=0)
|
||||
elif market not in ('AShare', 'HShare'):
|
||||
# 其他市场通用新闻
|
||||
raw_news = self._finnhub_client.general_news('general', min_id=0)
|
||||
|
||||
if raw_news:
|
||||
for item in raw_news[:10]: # 最多10条
|
||||
for item in raw_news[:10]:
|
||||
if not item.get('headline'):
|
||||
continue
|
||||
news_list.append({
|
||||
"datetime": datetime.fromtimestamp(item.get('datetime', 0)).strftime('%Y-%m-%d %H:%M'),
|
||||
"headline": item.get('headline', ''),
|
||||
"summary": item.get('summary', '')[:300] if item.get('summary') else '', # 截断摘要
|
||||
"summary": item.get('summary', '')[:300] if item.get('summary') else '',
|
||||
"source": item.get('source', 'Finnhub'),
|
||||
"url": item.get('url', ''),
|
||||
"sentiment": item.get('sentiment', 'neutral'), # Finnhub有时提供情绪
|
||||
"sentiment": item.get('sentiment', 'neutral'),
|
||||
})
|
||||
logger.info(f"Finnhub 新闻获取成功: {len(news_list)} 条")
|
||||
except Exception as e:
|
||||
logger.debug(f"Finnhub news fetch failed: {e}")
|
||||
|
||||
# 2) Finnhub 情绪分数 (如果可用)
|
||||
# === 2) Finnhub 情绪分数 (美股社交媒体情绪) ===
|
||||
if self._finnhub_client and market == 'USStock':
|
||||
try:
|
||||
# Finnhub 提供社交媒体情绪
|
||||
social = self._finnhub_client.stock_social_sentiment(symbol)
|
||||
if social:
|
||||
sentiment['reddit'] = social.get('reddit', {})
|
||||
@@ -938,31 +942,183 @@ class MarketDataCollector:
|
||||
except Exception as e:
|
||||
logger.debug(f"Finnhub sentiment fetch failed: {e}")
|
||||
|
||||
# 3) A股特定新闻 (akshare)
|
||||
# === 3) A股多源新闻 (akshare) ===
|
||||
if market == 'AShare' and self._ak:
|
||||
try:
|
||||
# 个股新闻
|
||||
df = self._ak.stock_news_em(symbol=symbol)
|
||||
if df is not None and not df.empty:
|
||||
for _, row in df.head(10).iterrows():
|
||||
news_list.append({
|
||||
"datetime": str(row.get('发布时间', ''))[:16],
|
||||
"headline": row.get('新闻标题', ''),
|
||||
"summary": row.get('新闻内容', '')[:200] if row.get('新闻内容') else '',
|
||||
"source": row.get('文章来源', 'eastmoney'),
|
||||
"url": row.get('新闻链接', ''),
|
||||
"sentiment": 'neutral',
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"akshare news fetch failed: {e}")
|
||||
ashare_news = self._get_ashare_news_multi_source(symbol)
|
||||
news_list.extend(ashare_news)
|
||||
|
||||
# === 4) 港股新闻 (akshare) ===
|
||||
if market == 'HShare' and self._ak:
|
||||
hshare_news = self._get_hshare_news(symbol)
|
||||
news_list.extend(hshare_news)
|
||||
|
||||
# === 5) 搜索引擎补充 (如果新闻太少) ===
|
||||
if len(news_list) < 5:
|
||||
search_news = self._get_news_from_search(market, symbol, company_name)
|
||||
news_list.extend(search_news)
|
||||
|
||||
# 去重(按标题)
|
||||
seen_titles = set()
|
||||
unique_news = []
|
||||
for item in news_list:
|
||||
title = item.get('headline', '')
|
||||
if title and title not in seen_titles:
|
||||
seen_titles.add(title)
|
||||
unique_news.append(item)
|
||||
|
||||
# 按时间排序
|
||||
news_list.sort(key=lambda x: x.get('datetime', ''), reverse=True)
|
||||
unique_news.sort(key=lambda x: x.get('datetime', ''), reverse=True)
|
||||
|
||||
return {
|
||||
"news": news_list[:15], # 最多15条
|
||||
"news": unique_news[:15], # 最多15条
|
||||
"sentiment": sentiment,
|
||||
}
|
||||
|
||||
def _get_ashare_news_multi_source(self, symbol: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
A股多源新闻获取
|
||||
|
||||
来源(按优先级):
|
||||
1. 东方财富个股新闻 (stock_news_em)
|
||||
2. 新浪财经滚动新闻 (stock_news_sina)
|
||||
3. 同花顺个股新闻 (stock_news_ths)
|
||||
4. 雪球热帖 (stock_xuqiu)
|
||||
"""
|
||||
news_list = []
|
||||
|
||||
# 1) 东方财富个股新闻
|
||||
try:
|
||||
df = self._ak.stock_news_em(symbol=symbol)
|
||||
if df is not None and not df.empty:
|
||||
for _, row in df.head(8).iterrows():
|
||||
news_list.append({
|
||||
"datetime": str(row.get('发布时间', ''))[:16],
|
||||
"headline": row.get('新闻标题', ''),
|
||||
"summary": row.get('新闻内容', '')[:200] if row.get('新闻内容') else '',
|
||||
"source": "东方财富",
|
||||
"url": row.get('新闻链接', ''),
|
||||
"sentiment": 'neutral',
|
||||
})
|
||||
logger.debug(f"东方财富新闻: {len(df)} 条")
|
||||
except Exception as e:
|
||||
logger.debug(f"东方财富新闻获取失败: {e}")
|
||||
|
||||
# 2) 新浪财经个股新闻
|
||||
try:
|
||||
# 注意:akshare 的新浪新闻接口可能需要股票名称而非代码
|
||||
df = self._ak.stock_news_sina(symbol=symbol)
|
||||
if df is not None and not df.empty:
|
||||
for _, row in df.head(5).iterrows():
|
||||
title = row.get('title', '') or row.get('新闻标题', '')
|
||||
if title and title not in [n.get('headline') for n in news_list]:
|
||||
news_list.append({
|
||||
"datetime": str(row.get('time', row.get('发布时间', '')))[:16],
|
||||
"headline": title,
|
||||
"summary": row.get('content', row.get('新闻内容', ''))[:200] if row.get('content') or row.get('新闻内容') else '',
|
||||
"source": "新浪财经",
|
||||
"url": row.get('url', row.get('新闻链接', '')),
|
||||
"sentiment": 'neutral',
|
||||
})
|
||||
logger.debug(f"新浪财经新闻: {len(df)} 条")
|
||||
except Exception as e:
|
||||
logger.debug(f"新浪财经新闻获取失败: {e}")
|
||||
|
||||
# 3) 同花顺个股新闻
|
||||
try:
|
||||
df = self._ak.stock_news_ths(symbol=symbol)
|
||||
if df is not None and not df.empty:
|
||||
for _, row in df.head(5).iterrows():
|
||||
title = row.get('标题', '') or row.get('title', '')
|
||||
if title and title not in [n.get('headline') for n in news_list]:
|
||||
news_list.append({
|
||||
"datetime": str(row.get('发布时间', row.get('time', '')))[:16],
|
||||
"headline": title,
|
||||
"summary": row.get('内容', row.get('content', ''))[:200] if row.get('内容') or row.get('content') else '',
|
||||
"source": "同花顺",
|
||||
"url": row.get('链接', row.get('url', '')),
|
||||
"sentiment": 'neutral',
|
||||
})
|
||||
logger.debug(f"同花顺新闻: {len(df)} 条")
|
||||
except Exception as e:
|
||||
logger.debug(f"同花顺新闻获取失败: {e}")
|
||||
|
||||
# 4) 雪球热帖(社区讨论)
|
||||
try:
|
||||
df = self._ak.stock_xuqiu(symbol=symbol)
|
||||
if df is not None and not df.empty:
|
||||
for _, row in df.head(3).iterrows():
|
||||
title = row.get('标题', '') or row.get('title', '')
|
||||
if title and title not in [n.get('headline') for n in news_list]:
|
||||
news_list.append({
|
||||
"datetime": str(row.get('发布时间', row.get('time', '')))[:16],
|
||||
"headline": title,
|
||||
"summary": row.get('内容摘要', row.get('content', ''))[:200] if row.get('内容摘要') or row.get('content') else '',
|
||||
"source": "雪球",
|
||||
"url": row.get('链接', row.get('url', '')),
|
||||
"sentiment": 'neutral',
|
||||
})
|
||||
logger.debug(f"雪球热帖: {len(df)} 条")
|
||||
except Exception as e:
|
||||
logger.debug(f"雪球热帖获取失败: {e}")
|
||||
|
||||
return news_list
|
||||
|
||||
def _get_hshare_news(self, symbol: str) -> List[Dict[str, Any]]:
|
||||
"""港股新闻获取"""
|
||||
news_list = []
|
||||
|
||||
try:
|
||||
# 港股新闻 (如果 akshare 支持)
|
||||
df = self._ak.stock_hk_spot_em()
|
||||
# 港股一般没有专门的新闻接口,可以通过搜索补充
|
||||
except Exception as e:
|
||||
logger.debug(f"港股新闻获取失败: {e}")
|
||||
|
||||
return news_list
|
||||
|
||||
def _get_news_from_search(
|
||||
self, market: str, symbol: str, company_name: str = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
从搜索引擎获取新闻
|
||||
|
||||
使用增强的搜索服务 (Bocha/Tavily/SerpAPI)
|
||||
"""
|
||||
news_list = []
|
||||
|
||||
try:
|
||||
from app.services.search import get_search_service
|
||||
search_service = get_search_service()
|
||||
|
||||
if not search_service.is_available:
|
||||
return news_list
|
||||
|
||||
# 构建搜索名称
|
||||
search_name = company_name or symbol
|
||||
|
||||
# 搜索股票新闻
|
||||
response = search_service.search_stock_news(
|
||||
stock_code=symbol,
|
||||
stock_name=search_name,
|
||||
market=market,
|
||||
max_results=5
|
||||
)
|
||||
|
||||
if response.success and response.results:
|
||||
for result in response.results:
|
||||
news_list.append({
|
||||
"datetime": result.published_date or datetime.now().strftime('%Y-%m-%d'),
|
||||
"headline": result.title,
|
||||
"summary": result.snippet[:200] if result.snippet else '',
|
||||
"source": f"搜索:{result.source}",
|
||||
"url": result.url,
|
||||
"sentiment": result.sentiment,
|
||||
})
|
||||
logger.info(f"搜索引擎新闻补充: {len(news_list)} 条 (来源: {response.provider})")
|
||||
except Exception as e:
|
||||
logger.debug(f"搜索引擎新闻获取失败: {e}")
|
||||
|
||||
return news_list
|
||||
|
||||
|
||||
# 全局实例
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,15 @@ def load_addon_config() -> Dict[str, Any]:
|
||||
('SEARCH_GOOGLE_API_KEY', 'search.google.api_key', 'string'),
|
||||
('SEARCH_GOOGLE_CX', 'search.google.cx', 'string'),
|
||||
('SEARCH_BING_API_KEY', 'search.bing.api_key', 'string'),
|
||||
|
||||
# Tavily (AI-optimized search)
|
||||
('TAVILY_API_KEYS', 'tavily.api_keys', 'string'),
|
||||
|
||||
# Bocha (Chinese search optimization)
|
||||
('BOCHA_API_KEYS', 'bocha.api_keys', 'string'),
|
||||
|
||||
# SerpAPI (Google/Bing scraper)
|
||||
('SERPAPI_KEYS', 'serpapi.api_keys', 'string'),
|
||||
]
|
||||
|
||||
for env_name, dotted_key, value_type in mappings:
|
||||
|
||||
@@ -219,14 +219,34 @@ TIINGO_API_KEY=
|
||||
TIINGO_TIMEOUT=10
|
||||
|
||||
# =========================
|
||||
# Web Search (optional)
|
||||
# Web Search & News (optional)
|
||||
# =========================
|
||||
# Search provider priority: tavily > bocha > google > bing > duckduckgo
|
||||
SEARCH_PROVIDER=google
|
||||
SEARCH_MAX_RESULTS=10
|
||||
|
||||
# Google Custom Search (CSE)
|
||||
SEARCH_GOOGLE_API_KEY=
|
||||
SEARCH_GOOGLE_CX=
|
||||
|
||||
# Bing Search API
|
||||
SEARCH_BING_API_KEY=
|
||||
|
||||
# Tavily Search API (专为AI设计,推荐!免费1000次/月)
|
||||
# Get your key at: https://tavily.com/
|
||||
# 支持多个 key 轮换,用逗号分隔: key1,key2,key3
|
||||
TAVILY_API_KEYS=
|
||||
|
||||
# 博查 Bocha Search API (国内搜索优化,A股新闻推荐!)
|
||||
# Get your key at: https://bochaai.com/
|
||||
# 支持多个 key 轮换,用逗号分隔: key1,key2,key3
|
||||
BOCHA_API_KEYS=
|
||||
|
||||
# SerpAPI (Google/Bing 结果抓取,免费100次/月)
|
||||
# Get your key at: https://serpapi.com/
|
||||
# 支持多个 key 轮换,用逗号分隔: key1,key2,key3
|
||||
SERPAPI_KEYS=
|
||||
|
||||
# Internal API key (optional, if you add internal-service auth later)
|
||||
INTERNAL_API_KEY=
|
||||
|
||||
|
||||
@@ -21,3 +21,8 @@ ib_insync>=0.9.86
|
||||
# Note: MetaTrader5 is Windows-only and not available on Linux/macOS
|
||||
# Install separately on Windows: pip install MetaTrader5>=5.0.45
|
||||
# MetaTrader5>=5.0.45
|
||||
# Enhanced search services (optional, for better news search)
|
||||
# Tavily - AI-optimized search API (free 1000 requests/month)
|
||||
# tavily-python>=0.3.0
|
||||
# SerpAPI - Google/Bing search scraper (free 100 requests/month)
|
||||
# google-search-results>=2.4.0
|
||||
Reference in New Issue
Block a user