890 lines
35 KiB
Python
890 lines
35 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
实时市场数据服务 (增强版)
|
||
使用 akshare 库获取 A 股市场的实时行情数据,包括:
|
||
- 核心指数(上证、深证、创业板等)
|
||
- 成交额、振幅
|
||
- 北向资金流向
|
||
- 主力资金流向
|
||
- 涨跌停统计
|
||
- 热门板块排行
|
||
|
||
参考 daily_stock_analysis 项目的数据获取模式
|
||
"""
|
||
|
||
import logging
|
||
import time
|
||
import random
|
||
from typing import Dict, Any, Optional, List
|
||
from datetime import datetime
|
||
from dataclasses import dataclass, field
|
||
import requests
|
||
|
||
logging.basicConfig(level=logging.INFO)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 尝试导入 akshare
|
||
try:
|
||
import akshare as ak
|
||
import pandas as pd
|
||
AKSHARE_AVAILABLE = True
|
||
except ImportError:
|
||
AKSHARE_AVAILABLE = False
|
||
logger.warning("akshare 未安装,部分功能将不可用。请运行: pip install akshare")
|
||
|
||
try:
|
||
import efinance as ef
|
||
EF_AVAILABLE = True
|
||
except Exception:
|
||
EF_AVAILABLE = False
|
||
|
||
|
||
@dataclass
|
||
class MarketIndex:
|
||
"""大盘指数数据"""
|
||
code: str
|
||
name: str
|
||
price: float = 0.0
|
||
change_pct: float = 0.0
|
||
change_amt: float = 0.0
|
||
volume: float = 0.0
|
||
amount: float = 0.0
|
||
amplitude: float = 0.0
|
||
high: float = 0.0
|
||
low: float = 0.0
|
||
open: float = 0.0
|
||
prev_close: float = 0.0
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
'code': self.code,
|
||
'name': self.name,
|
||
'price': self.price,
|
||
'change_pct': self.change_pct,
|
||
'change_amt': self.change_amt,
|
||
'volume': self.volume,
|
||
'amount': self.amount,
|
||
'amplitude': self.amplitude,
|
||
'high': self.high,
|
||
'low': self.low,
|
||
'open': self.open,
|
||
'prev_close': self.prev_close,
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class MarketOverview:
|
||
"""市场概览数据"""
|
||
date: str
|
||
indices: List[Dict] = field(default_factory=list)
|
||
up_count: int = 0
|
||
down_count: int = 0
|
||
flat_count: int = 0
|
||
limit_up_count: int = 0
|
||
limit_down_count: int = 0
|
||
total_amount: float = 0.0
|
||
north_flow: Dict = field(default_factory=dict)
|
||
main_flow: Dict = field(default_factory=dict)
|
||
top_sectors: List[Dict] = field(default_factory=list)
|
||
bottom_sectors: List[Dict] = field(default_factory=list)
|
||
|
||
|
||
class MarketDataService:
|
||
"""
|
||
实时市场数据服务
|
||
使用 akshare 获取数据,带缓存和重试机制
|
||
"""
|
||
|
||
_instance = None
|
||
|
||
# 主要指数代码映射 (akshare 格式)
|
||
MAIN_INDICES = {
|
||
'sh000001': '上证指数',
|
||
'sz399001': '深证成指',
|
||
'sz399006': '创业板指',
|
||
'sh000688': '科创50',
|
||
'sh000300': '沪深300',
|
||
}
|
||
|
||
def __new__(cls):
|
||
if cls._instance is None:
|
||
cls._instance = super(MarketDataService, cls).__new__(cls)
|
||
cls._instance._initialized = False
|
||
return cls._instance
|
||
|
||
def __init__(self):
|
||
if self._initialized:
|
||
return
|
||
self._cache = {}
|
||
self._cache_time = {}
|
||
self._cache_ttl = 60 # 缓存60秒
|
||
self._initialized = True
|
||
self._last_request_time = None
|
||
self._min_interval = 1.0 # 最小请求间隔(秒)
|
||
|
||
def _enforce_rate_limit(self):
|
||
"""强制执行速率限制,避免被封禁"""
|
||
if self._last_request_time is not None:
|
||
elapsed = time.time() - self._last_request_time
|
||
if elapsed < self._min_interval:
|
||
time.sleep(self._min_interval - elapsed)
|
||
self._last_request_time = time.time()
|
||
|
||
def _is_cache_valid(self, key: str) -> bool:
|
||
"""检查缓存是否有效"""
|
||
if key not in self._cache_time:
|
||
return False
|
||
return (time.time() - self._cache_time[key]) < self._cache_ttl
|
||
|
||
def _set_cache(self, key: str, data: Any):
|
||
"""设置缓存"""
|
||
self._cache[key] = data
|
||
self._cache_time[key] = time.time()
|
||
|
||
def _get_cache(self, key: str) -> Optional[Any]:
|
||
"""获取缓存"""
|
||
if self._is_cache_valid(key):
|
||
return self._cache.get(key)
|
||
return None
|
||
|
||
def _call_akshare_with_retry(self, fn, name: str, attempts: int = 2):
|
||
"""带重试的 akshare 调用"""
|
||
last_error = None
|
||
for attempt in range(1, attempts + 1):
|
||
try:
|
||
self._enforce_rate_limit()
|
||
return fn()
|
||
except Exception as e:
|
||
last_error = e
|
||
logger.warning(f"[市场数据] {name} 获取失败 (attempt {attempt}/{attempts}): {e}")
|
||
if attempt < attempts:
|
||
time.sleep(min(2 ** attempt, 5))
|
||
logger.error(f"[市场数据] {name} 最终失败: {last_error}")
|
||
return None
|
||
|
||
def _fetch_em_json(self, url: str, params: Optional[Dict] = None) -> Optional[Dict[str, Any]]:
|
||
try:
|
||
headers = {
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||
}
|
||
resp = requests.get(url, params=params or {}, headers=headers, timeout=8)
|
||
if resp.status_code == 200:
|
||
return resp.json()
|
||
logger.warning(f"[市场数据] EM {url} 返回状态 {resp.status_code}")
|
||
except Exception as e:
|
||
logger.error(f"[市场数据] EM 请求失败: {e}")
|
||
return None
|
||
|
||
def _safe_float(self, val, default=0.0):
|
||
"""安全转换为浮点数"""
|
||
try:
|
||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||
return default
|
||
return float(val)
|
||
except (ValueError, TypeError):
|
||
return default
|
||
|
||
def get_index_realtime(self) -> List[Dict[str, Any]]:
|
||
"""
|
||
获取核心指数实时行情
|
||
使用 akshare 的 stock_zh_index_spot_sina 接口
|
||
"""
|
||
cache_key = 'index_realtime'
|
||
cached = self._get_cache(cache_key)
|
||
if cached:
|
||
logger.debug("[市场数据] 使用缓存的指数行情数据")
|
||
return cached
|
||
|
||
if not AKSHARE_AVAILABLE:
|
||
logger.warning("[市场数据] akshare 不可用,返回空数据")
|
||
return self._get_fallback_indices()
|
||
|
||
indices = []
|
||
|
||
try:
|
||
logger.info("[市场数据] 获取主要指数实时行情...")
|
||
|
||
# 使用新浪财经接口获取指数行情
|
||
df = self._call_akshare_with_retry(
|
||
ak.stock_zh_index_spot_sina,
|
||
"指数行情"
|
||
)
|
||
|
||
if df is not None and not df.empty:
|
||
for code, name in self.MAIN_INDICES.items():
|
||
# 查找对应指数
|
||
row = df[df['代码'] == code]
|
||
if row.empty:
|
||
# 尝试不带前缀查找
|
||
row = df[df['代码'].str.contains(code[-6:])]
|
||
|
||
if not row.empty:
|
||
row = row.iloc[0]
|
||
prev_close = self._safe_float(row.get('昨收', 0))
|
||
high = self._safe_float(row.get('最高', 0))
|
||
low = self._safe_float(row.get('最低', 0))
|
||
|
||
# 计算振幅
|
||
amplitude = 0
|
||
if prev_close > 0:
|
||
amplitude = (high - low) / prev_close * 100
|
||
|
||
index_data = {
|
||
'code': code[-6:], # 去掉前缀
|
||
'name': name,
|
||
'price': self._safe_float(row.get('最新价', 0)),
|
||
'change_pct': self._safe_float(row.get('涨跌幅', 0)),
|
||
'change_amt': self._safe_float(row.get('涨跌额', 0)),
|
||
'volume': self._safe_float(row.get('成交量', 0)),
|
||
'amount': self._safe_float(row.get('成交额', 0)),
|
||
'amplitude': round(amplitude, 2),
|
||
'high': high,
|
||
'low': low,
|
||
'open': self._safe_float(row.get('今开', 0)),
|
||
'prev_close': prev_close,
|
||
}
|
||
indices.append(index_data)
|
||
|
||
logger.info(f"[市场数据] 获取到 {len(indices)} 个指数行情")
|
||
|
||
if not indices:
|
||
data = self._fetch_em_json(
|
||
"https://push2.eastmoney.com/api/qt/ulist/get",
|
||
{
|
||
"fltt": "2",
|
||
"invt": "2",
|
||
"fields": "f2,f3,f4,f12,f13,f14",
|
||
"secids": "1.000001,0.399001,0.399006"
|
||
}
|
||
)
|
||
if data and data.get("data") and data["data"].get("diff"):
|
||
diff = data["data"]["diff"]
|
||
mapping = {"1.000001": "上证指数", "0.399001": "深证成指", "0.399006": "创业板指"}
|
||
for item in diff:
|
||
secid = f"{item.get('f13')}.{item.get('f12')}"
|
||
name = mapping.get(secid)
|
||
if not name:
|
||
continue
|
||
indices.append({
|
||
"code": item.get("f12"),
|
||
"name": name,
|
||
"price": float(item.get("f2") or 0),
|
||
"change_pct": float(item.get("f3") or 0),
|
||
"change_amt": float(item.get("f4") or 0),
|
||
"volume": 0.0,
|
||
"amount": 0.0,
|
||
"amplitude": 0.0,
|
||
"high": 0.0,
|
||
"low": 0.0,
|
||
"open": 0.0,
|
||
"prev_close": 0.0
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.error(f"[市场数据] 获取指数行情失败: {e}")
|
||
|
||
if not indices:
|
||
indices = self._get_fallback_indices()
|
||
|
||
self._set_cache(cache_key, indices)
|
||
return indices
|
||
|
||
def _get_fallback_indices(self) -> List[Dict[str, Any]]:
|
||
"""获取备用指数数据(当API失败时)"""
|
||
return [
|
||
{'code': '000001', 'name': '上证指数', 'price': 0, 'change_pct': 0, 'amount': 0, 'amplitude': 0},
|
||
{'code': '399001', 'name': '深证成指', 'price': 0, 'change_pct': 0, 'amount': 0, 'amplitude': 0},
|
||
{'code': '399006', 'name': '创业板指', 'price': 0, 'change_pct': 0, 'amount': 0, 'amplitude': 0},
|
||
]
|
||
|
||
def get_north_flow(self) -> Dict[str, Any]:
|
||
"""
|
||
获取北向资金流向数据
|
||
使用 akshare 的沪深港通接口
|
||
注意:部分接口可能不稳定,使用多种备选方案
|
||
"""
|
||
cache_key = 'north_flow'
|
||
cached = self._get_cache(cache_key)
|
||
if cached:
|
||
return cached
|
||
|
||
result = {
|
||
'total': 0,
|
||
'sh': 0, # 沪股通(亿元)
|
||
'sz': 0, # 深股通(亿元)
|
||
'update_time': datetime.now().strftime('%H:%M:%S'),
|
||
'status': 'unknown'
|
||
}
|
||
|
||
if not AKSHARE_AVAILABLE:
|
||
return result
|
||
|
||
try:
|
||
logger.info("[市场数据] 获取北向资金数据...")
|
||
|
||
# 方案1: 尝试使用 stock_hsgt_hist_em 获取历史数据(取最新一天)
|
||
try:
|
||
df = self._call_akshare_with_retry(
|
||
lambda: ak.stock_hsgt_hist_em(symbol="沪股通"),
|
||
"沪股通历史"
|
||
)
|
||
if df is not None and not df.empty:
|
||
latest = df.iloc[-1]
|
||
for col in ['当日资金流入', '资金流入', '当日净流入']:
|
||
if col in df.columns:
|
||
result['sh'] = round(self._safe_float(latest[col]), 2)
|
||
break
|
||
logger.info(f"[市场数据] 沪股通: {result['sh']}亿")
|
||
except Exception as e:
|
||
logger.warning(f"[市场数据] 沪股通数据获取失败: {e}")
|
||
|
||
try:
|
||
df = self._call_akshare_with_retry(
|
||
lambda: ak.stock_hsgt_hist_em(symbol="深股通"),
|
||
"深股通历史"
|
||
)
|
||
if df is not None and not df.empty:
|
||
latest = df.iloc[-1]
|
||
for col in ['当日资金流入', '资金流入', '当日净流入']:
|
||
if col in df.columns:
|
||
result['sz'] = round(self._safe_float(latest[col]), 2)
|
||
break
|
||
logger.info(f"[市场数据] 深股通: {result['sz']}亿")
|
||
except Exception as e:
|
||
logger.warning(f"[市场数据] 深股通数据获取失败: {e}")
|
||
|
||
# 计算总额
|
||
result['total'] = round(result['sh'] + result['sz'], 2)
|
||
if result['total'] != 0:
|
||
result['status'] = 'trading'
|
||
|
||
logger.info(f"[市场数据] 北向资金净流入: {result['total']}亿 (沪:{result['sh']} 深:{result['sz']})")
|
||
|
||
except Exception as e:
|
||
logger.error(f"[市场数据] 获取北向资金失败: {e}")
|
||
|
||
self._set_cache(cache_key, result)
|
||
return result
|
||
|
||
def get_main_flow(self) -> Dict[str, Any]:
|
||
"""
|
||
获取主力资金流向数据
|
||
使用 akshare 的资金流向接口
|
||
"""
|
||
cache_key = 'main_flow'
|
||
cached = self._get_cache(cache_key)
|
||
if cached:
|
||
return cached
|
||
|
||
result = {
|
||
'main_net': 0, # 主力净流入(亿元)
|
||
'super_large': 0, # 超大单净流入
|
||
'large': 0, # 大单净流入
|
||
'medium': 0, # 中单净流入
|
||
'small': 0, # 小单净流入
|
||
'update_time': datetime.now().strftime('%H:%M:%S')
|
||
}
|
||
|
||
if not AKSHARE_AVAILABLE:
|
||
return result
|
||
|
||
try:
|
||
logger.info("[市场数据] 获取主力资金数据...")
|
||
|
||
# 获取大盘资金流向
|
||
df = self._call_akshare_with_retry(
|
||
ak.stock_market_fund_flow,
|
||
"主力资金"
|
||
)
|
||
|
||
if df is not None and not df.empty:
|
||
# 取最新一条数据
|
||
latest = df.iloc[-1]
|
||
|
||
# 主力净流入 = 超大单 + 大单
|
||
super_large = self._safe_float(latest.get('超大单净流入', 0))
|
||
large = self._safe_float(latest.get('大单净流入', 0))
|
||
medium = self._safe_float(latest.get('中单净流入', 0))
|
||
small = self._safe_float(latest.get('小单净流入', 0))
|
||
|
||
result['super_large'] = round(super_large / 1e8, 2)
|
||
result['large'] = round(large / 1e8, 2)
|
||
result['medium'] = round(medium / 1e8, 2)
|
||
result['small'] = round(small / 1e8, 2)
|
||
result['main_net'] = round((super_large + large) / 1e8, 2)
|
||
|
||
logger.info(f"[市场数据] 主力净流入: {result['main_net']}亿")
|
||
|
||
except Exception as e:
|
||
logger.error(f"[市场数据] 获取主力资金失败: {e}")
|
||
|
||
self._set_cache(cache_key, result)
|
||
return result
|
||
|
||
def get_market_breadth(self) -> Dict[str, Any]:
|
||
"""
|
||
获取市场广度数据(涨跌统计)
|
||
使用 akshare 的 A 股实时行情接口
|
||
"""
|
||
cache_key = 'market_breadth'
|
||
cached = self._get_cache(cache_key)
|
||
if cached:
|
||
return cached
|
||
|
||
result = {
|
||
'up_count': 0,
|
||
'down_count': 0,
|
||
'flat_count': 0,
|
||
'limit_up': 0,
|
||
'limit_down': 0,
|
||
'update_time': datetime.now().strftime('%H:%M:%S')
|
||
}
|
||
|
||
if not AKSHARE_AVAILABLE:
|
||
return result
|
||
|
||
try:
|
||
logger.info("[市场数据] 获取市场涨跌统计...")
|
||
|
||
# 获取全部A股实时行情
|
||
df = self._call_akshare_with_retry(ak.stock_zh_a_spot_em, "A股实时行情")
|
||
if (df is None or df.empty) and AKSHARE_AVAILABLE:
|
||
try:
|
||
df = self._call_akshare_with_retry(ak.stock_zh_a_spot, "A股实时行情(Sina)")
|
||
except Exception as _:
|
||
df = None
|
||
if (df is None or df.empty) and EF_AVAILABLE:
|
||
try:
|
||
df = ef.stock.get_realtime_quotes()
|
||
except Exception as _:
|
||
df = None
|
||
|
||
if df is not None and not df.empty:
|
||
change_col = '涨跌幅'
|
||
if change_col in df.columns:
|
||
df[change_col] = pd.to_numeric(df[change_col], errors='coerce')
|
||
|
||
result['up_count'] = len(df[df[change_col] > 0])
|
||
result['down_count'] = len(df[df[change_col] < 0])
|
||
result['flat_count'] = len(df[df[change_col] == 0])
|
||
|
||
# 涨停跌停统计(涨跌幅 >= 9.9% 或 <= -9.9%)
|
||
result['limit_up'] = len(df[df[change_col] >= 9.9])
|
||
result['limit_down'] = len(df[df[change_col] <= -9.9])
|
||
|
||
logger.info(f"[市场数据] 涨:{result['up_count']} 跌:{result['down_count']} "
|
||
f"涨停:{result['limit_up']} 跌停:{result['limit_down']}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"[市场数据] 获取市场广度失败: {e}")
|
||
|
||
self._set_cache(cache_key, result)
|
||
return result
|
||
|
||
def get_hot_sectors(self) -> List[Dict[str, Any]]:
|
||
"""
|
||
获取热门板块排行
|
||
使用 akshare 的行业板块接口
|
||
"""
|
||
cache_key = 'hot_sectors'
|
||
cached = self._get_cache(cache_key)
|
||
if cached:
|
||
return cached
|
||
|
||
sectors = []
|
||
|
||
if not AKSHARE_AVAILABLE:
|
||
return sectors
|
||
|
||
try:
|
||
logger.info("[市场数据] 获取热门板块...")
|
||
|
||
# 获取行业板块行情
|
||
# 优先使用同花顺接口 (东财接口容易失败)
|
||
df = None
|
||
if AKSHARE_AVAILABLE:
|
||
try:
|
||
df = self._call_akshare_with_retry(ak.stock_board_industry_summary_ths, "行业板块(THS)")
|
||
except Exception as _:
|
||
df = None
|
||
if df is None or df.empty:
|
||
data = self._fetch_em_json(
|
||
"https://push2.eastmoney.com/api/qt/clist/get",
|
||
{
|
||
"pn": "1",
|
||
"pz": "50",
|
||
"po": "1",
|
||
"np": "1",
|
||
"fltt": "2",
|
||
"invt": "2",
|
||
"fid": "f3",
|
||
"fs": "m:90 t:2",
|
||
"fields": "f12,f14,f3,f62"
|
||
}
|
||
)
|
||
if data and data.get("data") and data["data"].get("diff"):
|
||
em_rows = data["data"]["diff"]
|
||
sectors = []
|
||
for item in sorted(em_rows, key=lambda x: float(x.get("f3") or 0), reverse=True)[:10]:
|
||
sectors.append({
|
||
'code': str(item.get('f12', '')),
|
||
'name': str(item.get('f14', '')),
|
||
'change_pct': float(item.get('f3') or 0),
|
||
'up_count': 0,
|
||
'down_count': 0,
|
||
'leader': '',
|
||
'leader_pct': 0.0,
|
||
'amount': self._safe_float(item.get('f62', 0))
|
||
})
|
||
self._set_cache(cache_key, sectors)
|
||
return sectors
|
||
|
||
if df is not None and not df.empty:
|
||
change_col = '涨跌幅'
|
||
if change_col in df.columns:
|
||
df[change_col] = pd.to_numeric(df[change_col], errors='coerce')
|
||
df = df.dropna(subset=[change_col])
|
||
|
||
df_sorted = df.sort_values(by=change_col, ascending=False)
|
||
|
||
for _, row in df_sorted.head(10).iterrows():
|
||
name = row.get('板块名称')
|
||
if name is None:
|
||
name = row.get('板块')
|
||
leader = row.get('领涨股票')
|
||
if leader is None:
|
||
leader = row.get('领 涨股')
|
||
leader_pct = row.get('领涨股票-涨跌幅')
|
||
if leader_pct is None:
|
||
leader_pct = row.get('领涨股-涨跌幅')
|
||
amount = row.get('总成交额')
|
||
if amount is None:
|
||
amount = row.get('总成交额', 0)
|
||
sector = {
|
||
'code': str(row.get('板块代码', '')),
|
||
'name': str(name or ''),
|
||
'change_pct': self._safe_float(row.get(change_col, 0)),
|
||
'up_count': int(self._safe_float(row.get('上涨家数', 0))),
|
||
'down_count': int(self._safe_float(row.get('下跌家数', 0))),
|
||
'leader': str(leader or ''),
|
||
'leader_pct': self._safe_float(leader_pct or 0),
|
||
'amount': self._safe_float(amount or 0),
|
||
}
|
||
sectors.append(sector)
|
||
|
||
logger.info(f"[市场数据] 获取到 {len(sectors)} 个热门板块")
|
||
if sectors:
|
||
logger.info(f"[市场数据] 领涨板块: {[s['name'] for s in sectors[:3]]}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"[市场数据] 获取热门板块失败: {e}")
|
||
|
||
self._set_cache(cache_key, sectors)
|
||
return sectors
|
||
|
||
def get_limit_up_stocks(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||
"""
|
||
获取涨停股票列表
|
||
使用 akshare 的涨停板接口
|
||
"""
|
||
cache_key = f'limit_up_stocks_{limit}'
|
||
cached = self._get_cache(cache_key)
|
||
if cached:
|
||
return cached
|
||
|
||
stocks = []
|
||
|
||
if not AKSHARE_AVAILABLE:
|
||
return stocks
|
||
|
||
try:
|
||
logger.info("[市场数据] 获取涨停股票...")
|
||
|
||
# 获取涨停股池
|
||
today = datetime.now().strftime('%Y%m%d')
|
||
df = self._call_akshare_with_retry(
|
||
lambda: ak.stock_zt_pool_em(date=today),
|
||
"涨停股池"
|
||
)
|
||
|
||
if df is not None and not df.empty:
|
||
for _, row in df.head(limit).iterrows():
|
||
stock = {
|
||
'code': str(row.get('代码', '')),
|
||
'name': str(row.get('名称', '')),
|
||
'price': self._safe_float(row.get('最新价', 0)),
|
||
'change_pct': self._safe_float(row.get('涨跌幅', 0)),
|
||
'amount': round(self._safe_float(row.get('成交额', 0)) / 1e8, 2),
|
||
'reason': str(row.get('涨停原因', '')),
|
||
'first_time': str(row.get('首次封板时间', '')),
|
||
'last_time': str(row.get('最后封板时间', '')),
|
||
'open_count': int(self._safe_float(row.get('炸板次数', 0))),
|
||
'continuous_days': int(self._safe_float(row.get('连板数', 1))),
|
||
}
|
||
stocks.append(stock)
|
||
|
||
logger.info(f"[市场数据] 获取到 {len(stocks)} 只涨停股票")
|
||
|
||
except Exception as e:
|
||
logger.error(f"[市场数据] 获取涨停股票失败: {e}")
|
||
|
||
self._set_cache(cache_key, stocks)
|
||
return stocks
|
||
|
||
def get_concept_sectors(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||
"""
|
||
获取概念板块排行
|
||
使用 akshare 的概念板块接口
|
||
"""
|
||
cache_key = f'concept_sectors_{limit}'
|
||
cached = self._get_cache(cache_key)
|
||
if cached:
|
||
return cached
|
||
|
||
sectors = []
|
||
|
||
if not AKSHARE_AVAILABLE:
|
||
return sectors
|
||
|
||
try:
|
||
logger.info("[市场数据] 获取概念板块...")
|
||
|
||
df = self._call_akshare_with_retry(
|
||
ak.stock_board_concept_name_em,
|
||
"概念板块"
|
||
)
|
||
|
||
if df is not None and not df.empty:
|
||
change_col = '涨跌幅'
|
||
if change_col in df.columns:
|
||
df[change_col] = pd.to_numeric(df[change_col], errors='coerce')
|
||
df = df.dropna(subset=[change_col])
|
||
df_sorted = df.sort_values(by=change_col, ascending=False)
|
||
|
||
for _, row in df_sorted.head(limit).iterrows():
|
||
sector = {
|
||
'code': str(row.get('板块代码', '')),
|
||
'name': str(row.get('板块名称', '')),
|
||
'change_pct': self._safe_float(row.get(change_col, 0)),
|
||
'up_count': int(self._safe_float(row.get('上涨家数', 0))),
|
||
'down_count': int(self._safe_float(row.get('下跌家数', 0))),
|
||
'leader': str(row.get('领涨股票', '')),
|
||
'leader_pct': self._safe_float(row.get('领涨股票-涨跌幅', 0)),
|
||
}
|
||
sectors.append(sector)
|
||
|
||
logger.info(f"[市场数据] 获取到 {len(sectors)} 个概念板块")
|
||
|
||
except Exception as e:
|
||
logger.error(f"[市场数据] 获取概念板块失败: {e}")
|
||
|
||
self._set_cache(cache_key, sectors)
|
||
return sectors
|
||
|
||
def get_market_overview(self) -> Dict[str, Any]:
|
||
"""
|
||
获取市场概览数据(整合所有数据)
|
||
"""
|
||
logger.info("========== 开始获取市场概览数据 ==========")
|
||
|
||
indices = self.get_index_realtime()
|
||
north_flow = self.get_north_flow()
|
||
main_flow = self.get_main_flow()
|
||
breadth = self.get_market_breadth()
|
||
hot_sectors = self.get_hot_sectors()
|
||
limit_up_stocks = self.get_limit_up_stocks(5)
|
||
|
||
# 计算两市总成交额
|
||
total_amount = sum(idx.get('amount', 0) for idx in indices[:2]) / 1e8 if indices else 0
|
||
|
||
result = {
|
||
'indices': indices,
|
||
'north_flow': north_flow,
|
||
'main_flow': main_flow,
|
||
'breadth': breadth,
|
||
'hot_sectors': hot_sectors,
|
||
'limit_up_stocks': limit_up_stocks,
|
||
'total_amount': round(total_amount, 2),
|
||
'update_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
}
|
||
|
||
logger.info("========== 市场概览数据获取完成 ==========")
|
||
return result
|
||
|
||
def generate_ai_strategy(self, market_data: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""
|
||
根据市场数据生成 AI 策略建议
|
||
"""
|
||
indices = market_data.get('indices', [])
|
||
north_flow = market_data.get('north_flow', {})
|
||
main_flow = market_data.get('main_flow', {})
|
||
breadth = market_data.get('breadth', {})
|
||
|
||
# 计算综合得分
|
||
score = 50 # 基础分
|
||
signals = []
|
||
|
||
# 1. 指数涨跌
|
||
sh_index = next((i for i in indices if '上证' in i.get('name', '')), None)
|
||
if sh_index:
|
||
change = sh_index.get('change_pct', 0)
|
||
if change > 1:
|
||
score += 10
|
||
signals.append('大盘上涨')
|
||
elif change > 0:
|
||
score += 5
|
||
signals.append('大盘微涨')
|
||
elif change < -1:
|
||
score -= 10
|
||
signals.append('大盘下跌')
|
||
elif change < 0:
|
||
score -= 5
|
||
signals.append('大盘微跌')
|
||
|
||
# 2. 成交量
|
||
total_amount = market_data.get('total_amount', 0)
|
||
if total_amount > 10000:
|
||
score += 10
|
||
signals.append('成交放量')
|
||
elif total_amount > 8000:
|
||
score += 5
|
||
signals.append('成交活跃')
|
||
elif total_amount < 6000:
|
||
score -= 5
|
||
signals.append('成交萎缩')
|
||
|
||
# 3. 北向资金
|
||
north_total = north_flow.get('total', 0)
|
||
if north_total > 50:
|
||
score += 15
|
||
signals.append('北向大幅流入')
|
||
elif north_total > 20:
|
||
score += 10
|
||
signals.append('北向流入')
|
||
elif north_total < -50:
|
||
score -= 15
|
||
signals.append('北向大幅流出')
|
||
elif north_total < -20:
|
||
score -= 10
|
||
signals.append('北向流出')
|
||
|
||
# 4. 主力资金
|
||
main_net = main_flow.get('main_net', 0)
|
||
if main_net > 100:
|
||
score += 10
|
||
signals.append('主力大幅流入')
|
||
elif main_net > 0:
|
||
score += 5
|
||
signals.append('主力流入')
|
||
elif main_net < -100:
|
||
score -= 10
|
||
signals.append('主力大幅流出')
|
||
elif main_net < 0:
|
||
score -= 5
|
||
signals.append('主力流出')
|
||
|
||
# 5. 涨跌家数
|
||
up_count = breadth.get('up_count', 0)
|
||
down_count = breadth.get('down_count', 0)
|
||
if up_count > 0 and down_count > 0:
|
||
ratio = up_count / (up_count + down_count)
|
||
if ratio > 0.7:
|
||
score += 15
|
||
signals.append('普涨行情')
|
||
elif ratio > 0.5:
|
||
score += 5
|
||
signals.append('多数上涨')
|
||
elif ratio < 0.3:
|
||
score -= 15
|
||
signals.append('普跌行情')
|
||
elif ratio < 0.5:
|
||
score -= 5
|
||
signals.append('多数下跌')
|
||
|
||
# 6. 涨跌停比
|
||
limit_up = breadth.get('limit_up', 0)
|
||
limit_down = breadth.get('limit_down', 0)
|
||
if limit_up > 100:
|
||
score += 10
|
||
signals.append(f'涨停{limit_up}家')
|
||
elif limit_up > 50:
|
||
score += 5
|
||
signals.append(f'涨停{limit_up}家')
|
||
if limit_down > 50:
|
||
score -= 10
|
||
signals.append(f'跌停{limit_down}家')
|
||
elif limit_down > 20:
|
||
score -= 5
|
||
signals.append(f'跌停{limit_down}家')
|
||
|
||
# 限制分数范围
|
||
score = max(0, min(100, score))
|
||
|
||
# 生成建议
|
||
sentiment_desc = ""
|
||
suggestion_detail = ""
|
||
|
||
# 详细分析文案生成
|
||
if score >= 70:
|
||
sentiment = '乐观'
|
||
sentiment_desc = "市场多头氛围浓厚,资金进场意愿强烈。"
|
||
suggestion = '市场情绪积极,可适当增加仓位,关注热门板块龙头'
|
||
suggestion_detail = "建议重点关注资金持续流入的强势板块,利用回调机会积极布局。当前市场风险偏好提升,可适当提高仓位,但需警惕短期乖离率过大的获利回吐风险。"
|
||
risk_level = '低'
|
||
elif score >= 55:
|
||
sentiment = '偏多'
|
||
sentiment_desc = "市场整体震荡上行,结构性机会为主。"
|
||
suggestion = '市场整体偏强,可维持仓位,精选个股'
|
||
suggestion_detail = "指数表现稳健,但板块轮动较快。建议“轻指数、重个股”,关注业绩确定性强的优质标的,避免盲目追高。资金流向分化,需甄别真假突破。"
|
||
risk_level = '中低'
|
||
elif score >= 45:
|
||
sentiment = '中性'
|
||
sentiment_desc = "多空双方势均力敌,市场进入观望期。"
|
||
suggestion = '市场震荡整理,建议控制仓位,等待方向明确'
|
||
suggestion_detail = "当前市场缺乏明确主线,成交量未能有效放大,上方压力显现。建议多看少动,控制仓位在半仓以下,耐心等待市场方向选择。可关注防御性板块进行避险。"
|
||
risk_level = '中'
|
||
elif score >= 30:
|
||
sentiment = '偏空'
|
||
sentiment_desc = "空头力量占据上风,市场情绪低迷。"
|
||
suggestion = '市场偏弱,建议降低仓位,注意风险控制'
|
||
suggestion_detail = "指数承压下行,资金流出迹象明显。建议严格执行止损纪律,降低仓位,避免接飞刀。耐心等待底部形态确立后再考虑进场。"
|
||
risk_level = '中高'
|
||
else:
|
||
sentiment = '悲观'
|
||
sentiment_desc = "市场恐慌情绪蔓延,下跌趋势明显。"
|
||
suggestion = '市场风险较大,建议轻仓或观望,严格止损'
|
||
suggestion_detail = "系统性风险释放中,切勿盲目抄底。建议保持极低仓位或空仓观望,现金为王,等待市场企稳信号出现。"
|
||
risk_level = '高'
|
||
|
||
# 动态补充分析
|
||
if '成交放量' in signals:
|
||
suggestion_detail += " 今日成交量有效放大,显示有增量资金入场,有利于行情延续。"
|
||
elif '成交萎缩' in signals:
|
||
suggestion_detail += " 缩量整理意味着变盘节点临近,需密切关注量能变化。"
|
||
|
||
if '北向大幅流入' in signals:
|
||
suggestion_detail += " 北向资金大幅净买入,外资对A股配置信心增强,核心资产有望受益。"
|
||
elif '北向大幅流出' in signals:
|
||
suggestion_detail += " 北向资金大幅流出,需警惕权重股抛压。"
|
||
|
||
return {
|
||
'score': score,
|
||
'sentiment': sentiment,
|
||
'signals': signals,
|
||
'suggestion': suggestion, # 简短建议(用于标题或摘要)
|
||
'suggestion_detail': f"{sentiment_desc} {suggestion_detail}", # 详细建议(用于正文)
|
||
'risk_level': risk_level,
|
||
'update_time': datetime.now().strftime('%H:%M:%S')
|
||
}
|
||
|
||
|
||
# 单例获取函数
|
||
_market_data_service_instance = None
|
||
|
||
def get_market_data_service() -> MarketDataService:
|
||
"""获取市场数据服务单例"""
|
||
global _market_data_service_instance
|
||
if _market_data_service_instance is None:
|
||
_market_data_service_instance = MarketDataService()
|
||
return _market_data_service_instance
|