358 lines
13 KiB
Python
358 lines
13 KiB
Python
"""
|
|
股票数据服务 - 获取、缓存、分析
|
|
"""
|
|
import pandas as pd
|
|
import numpy as np
|
|
from datetime import datetime, timedelta
|
|
import traceback
|
|
import json
|
|
import os
|
|
from config import Config
|
|
|
|
|
|
# ========== 股票名称缓存 ==========
|
|
_stock_name_cache = {}
|
|
|
|
|
|
def _load_stock_name_cache():
|
|
"""从本地文件加载股票名称缓存"""
|
|
global _stock_name_cache
|
|
try:
|
|
if os.path.exists(Config.STOCK_NAME_CACHE_FILE):
|
|
with open(Config.STOCK_NAME_CACHE_FILE, 'r', encoding='utf-8') as f:
|
|
_stock_name_cache = json.load(f)
|
|
print(f"加载股票名称缓存:{len(_stock_name_cache)}条")
|
|
except Exception as e:
|
|
print(f"加载股票名称缓存失败: {e}")
|
|
|
|
|
|
def _save_stock_name_cache():
|
|
"""保存股票名称缓存到本地"""
|
|
try:
|
|
with open(Config.STOCK_NAME_CACHE_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump(_stock_name_cache, f, ensure_ascii=False, indent=2)
|
|
except Exception as e:
|
|
print(f"保存股票名称缓存失败: {e}")
|
|
|
|
|
|
def get_stock_name(stock_code):
|
|
"""获取股票名称 — 使用腾讯财经API"""
|
|
global _stock_name_cache
|
|
|
|
if stock_code in _stock_name_cache:
|
|
return _stock_name_cache[stock_code]
|
|
|
|
# 腾讯财经API获取股票名称
|
|
try:
|
|
import requests as _req
|
|
tcode = ('sh' if stock_code.startswith('6') else 'sz') + stock_code
|
|
_r = _req.get(f'http://qt.gtimg.cn/q={tcode}', timeout=5,
|
|
headers={'Referer': 'https://finance.qq.com'})
|
|
if _r.status_code == 200 and '\"' in _r.text:
|
|
_fields = _r.text.split('\"')[1].split('~')
|
|
if len(_fields) > 2 and _fields[1]:
|
|
_stock_name_cache[stock_code] = _fields[1]
|
|
_save_stock_name_cache()
|
|
return _fields[1]
|
|
except Exception as e:
|
|
print(f"获取股票名称失败(腾讯): {e}")
|
|
|
|
return None
|
|
|
|
|
|
# ========== 股票数据缓存 ==========
|
|
|
|
def _get_cache_file_path(stock_code):
|
|
"""获取缓存文件路径"""
|
|
return os.path.join(Config.STOCK_DATA_CACHE_DIR, f'{stock_code}.json')
|
|
|
|
|
|
def load_cached_data(stock_code):
|
|
"""加载缓存的股票数据"""
|
|
cache_file = _get_cache_file_path(stock_code)
|
|
if os.path.exists(cache_file):
|
|
try:
|
|
with open(cache_file, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
df = pd.DataFrame(data['records'])
|
|
if not df.empty and '日期' in df.columns:
|
|
df['日期'] = pd.to_datetime(df['日期'])
|
|
return df, data.get('stock_name'), data.get('last_update')
|
|
except Exception as e:
|
|
print(f"加载缓存数据失败: {e}")
|
|
return None, None, None
|
|
|
|
|
|
def save_cached_data(stock_code, df, stock_name):
|
|
"""保存股票数据到缓存"""
|
|
cache_file = _get_cache_file_path(stock_code)
|
|
try:
|
|
df_copy = df.copy()
|
|
df_copy['日期'] = df_copy['日期'].dt.strftime('%Y-%m-%d')
|
|
records = df_copy.to_dict('records')
|
|
|
|
data = {
|
|
'stock_code': stock_code,
|
|
'stock_name': stock_name,
|
|
'last_update': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
'records': records
|
|
}
|
|
|
|
with open(cache_file, 'w', encoding='utf-8') as f:
|
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
print(f"已保存 {stock_code} 数据,共 {len(records)} 条")
|
|
except Exception as e:
|
|
print(f"保存缓存数据失败: {e}")
|
|
|
|
|
|
# ========== 获取股票资金流向数据 ==========
|
|
|
|
def get_stock_fund_flow(stock_code, start_date, end_date, force_refresh=False):
|
|
"""
|
|
获取股票资金流向数据(支持缓存,增量获取)
|
|
force_refresh: 强制刷新缓存
|
|
返回: (DataFrame, stock_name, error_msg)
|
|
"""
|
|
try:
|
|
# 判断市场
|
|
if stock_code.startswith('6'):
|
|
market = 'sh'
|
|
elif stock_code.startswith('0') or stock_code.startswith('3'):
|
|
market = 'sz'
|
|
else:
|
|
return None, None, "无法识别股票代码所属市场"
|
|
|
|
stock_name = get_stock_name(stock_code)
|
|
start = pd.to_datetime(start_date)
|
|
end = pd.to_datetime(end_date)
|
|
|
|
# 加载缓存
|
|
cached_df, cached_name, last_update = load_cached_data(stock_code)
|
|
need_fetch = force_refresh
|
|
new_data_df = None
|
|
|
|
if cached_df is not None and not cached_df.empty:
|
|
# 如果缓存是今天的,直接使用
|
|
if last_update:
|
|
try:
|
|
update_date = pd.to_datetime(last_update.split()[0])
|
|
today = pd.to_datetime(datetime.now().strftime('%Y-%m-%d'))
|
|
if update_date >= today and not force_refresh:
|
|
# 今天已更新,直接使用缓存
|
|
df = cached_df[(cached_df['日期'] >= start) & (cached_df['日期'] <= end)]
|
|
df = df.sort_values('日期').reset_index(drop=True)
|
|
return df, cached_name or stock_name, None
|
|
except:
|
|
pass
|
|
cached_max_date = cached_df['日期'].max()
|
|
today = pd.to_datetime(datetime.now().strftime('%Y-%m-%d'))
|
|
|
|
# 如果缓存数据不超过2天,直接使用(优化分析速度)
|
|
if cached_max_date >= today - timedelta(days=2) and not force_refresh:
|
|
if cached_df['日期'].min() <= start:
|
|
need_fetch = False
|
|
new_data_df = cached_df
|
|
print(f"使用缓存数据: {stock_code}, 最新日期: {cached_max_date.strftime('%Y-%m-%d')}")
|
|
|
|
if stock_name is None and cached_name:
|
|
stock_name = cached_name
|
|
|
|
if need_fetch:
|
|
# 东方财富资金流向API已不可用(腾讯云网络限制),使用缓存数据
|
|
print(f"资金流向API不可用,使用缓存: {stock_code}")
|
|
if cached_df is not None:
|
|
new_data_df = cached_df
|
|
else:
|
|
return None, None, "资金流向API不可用(东方财富已封锁),且无缓存数据"
|
|
|
|
if new_data_df is None or new_data_df.empty:
|
|
# 最后尝试使用缓存数据(即使不在日期范围内)
|
|
if cached_df is not None and not cached_df.empty:
|
|
print(f"使用全部缓存数据: {stock_code}")
|
|
df = cached_df.sort_values('日期').reset_index(drop=True)
|
|
return df, cached_name or stock_name, None
|
|
return None, None, "无法获取数据"
|
|
|
|
# 筛选日期范围
|
|
df = new_data_df[(new_data_df['日期'] >= start) & (new_data_df['日期'] <= end)]
|
|
|
|
# 如果筛选后为空,使用全部数据
|
|
if df.empty and not new_data_df.empty:
|
|
print(f"日期范围无数据,使用全部缓存: {stock_code}")
|
|
df = new_data_df
|
|
|
|
df = df.sort_values('日期').reset_index(drop=True)
|
|
|
|
return df, stock_name, None
|
|
except Exception as e:
|
|
traceback.print_exc()
|
|
return None, None, f"获取数据失败: {str(e)}"
|
|
|
|
|
|
# ========== 分析股票数据 ==========
|
|
|
|
def analyze_fund_flow_impact(df):
|
|
"""分析资金流向对股价的影响(含成交量分析)"""
|
|
if df is None or df.empty:
|
|
return None
|
|
|
|
df = df.sort_values('日期').reset_index(drop=True)
|
|
threshold = 2.0
|
|
|
|
if '超大单净流入-净占比' not in df.columns:
|
|
return None
|
|
|
|
df['超大单净流入-净占比'] = df['超大单净流入-净占比'].fillna(0)
|
|
df['主力净流入-净占比'] = df['主力净流入-净占比'].fillna(0)
|
|
|
|
df['超大单流向'] = df['超大单净流入-净占比'].apply(
|
|
lambda x: '大额流入' if x >= threshold else ('大额流出' if x <= -threshold else '普通')
|
|
)
|
|
df['主力流向'] = df['主力净流入-净占比'].apply(
|
|
lambda x: '大额流入' if x >= threshold else ('大额流出' if x <= -threshold else '普通')
|
|
)
|
|
|
|
# 计算价格位置(改为60日)
|
|
latest = df.iloc[-1]
|
|
lookback = 60 # 从20日改为60日
|
|
try:
|
|
actual_lookback = min(len(df), lookback)
|
|
if actual_lookback >= 5: # 至少需要5天数据
|
|
recent = df.tail(actual_lookback)
|
|
high = float(recent['收盘价'].max() or 0)
|
|
low = float(recent['收盘价'].min() or 0)
|
|
current_price = float(latest.get('收盘价') or 0)
|
|
price_position = (current_price - low) / (high - low) * 100 if high != low else 50
|
|
else:
|
|
price_position = 50
|
|
except:
|
|
price_position = 50
|
|
|
|
# 成交量分析(基于主力净流入-净额作为成交额指标)
|
|
volume_ratio = 1.0 # 默认值
|
|
volume_trend = '普通'
|
|
try:
|
|
if '主力净流入-净额' in df.columns and len(df) >= 10:
|
|
# 使用主力净流入绝对值作为活跃度指标
|
|
df['活跃度'] = df['主力净流入-净额'].abs()
|
|
recent_5 = df.tail(5)['活跃度'].mean()
|
|
recent_20 = df.tail(min(20, len(df)))['活跃度'].mean()
|
|
|
|
if recent_20 > 0:
|
|
volume_ratio = recent_5 / recent_20
|
|
if volume_ratio >= 1.5:
|
|
volume_trend = '放量'
|
|
elif volume_ratio <= 0.5:
|
|
volume_trend = '缩量'
|
|
else:
|
|
volume_trend = '正常'
|
|
except:
|
|
pass
|
|
|
|
# 计算均线MA5和MA20
|
|
ma5 = 0
|
|
ma20 = 0
|
|
try:
|
|
if '收盘价' in df.columns and len(df) >= 5:
|
|
ma5 = df.tail(5)['收盘价'].mean()
|
|
if '收盘价' in df.columns and len(df) >= 20:
|
|
ma20 = df.tail(20)['收盘价'].mean()
|
|
except:
|
|
pass
|
|
|
|
# 处理日期格式(可能是datetime或字符串)
|
|
def format_date(d):
|
|
if hasattr(d, 'strftime'):
|
|
return d.strftime('%Y-%m-%d')
|
|
return str(d)[:10] if d else ''
|
|
|
|
def safe_float(val, default=0):
|
|
try:
|
|
return float(val) if val is not None else default
|
|
except:
|
|
return default
|
|
|
|
return {
|
|
'最新数据': {
|
|
'日期': format_date(latest['日期']),
|
|
'收盘价': safe_float(latest.get('收盘价')),
|
|
'涨跌幅': safe_float(latest.get('涨跌幅')),
|
|
'价格位置': safe_float(price_position),
|
|
'超大单净流入占比': safe_float(latest.get('超大单净流入-净占比')),
|
|
'主力净流入占比': safe_float(latest.get('主力净流入-净占比')),
|
|
'超大单流向': latest.get('超大单流向', '普通'),
|
|
'主力流向': latest.get('主力流向', '普通'),
|
|
'成交量比': safe_float(volume_ratio, 1.0),
|
|
'量能趋势': volume_trend,
|
|
'MA5': safe_float(ma5),
|
|
'MA20': safe_float(ma20)
|
|
},
|
|
'数据概览': {
|
|
'总交易日数': len(df),
|
|
'计算周期': min(len(df), lookback),
|
|
'日期范围': {
|
|
'开始': format_date(df['日期'].min()),
|
|
'结束': format_date(df['日期'].max())
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
# ========== 实时价格 ==========
|
|
|
|
def get_realtime_price(stock_code):
|
|
"""获取实时价格(使用mairuiapi,更稳定)"""
|
|
try:
|
|
from services.mairui_api import get_realtime_price as mairui_get_price
|
|
result = mairui_get_price(stock_code)
|
|
if result['success']:
|
|
return result
|
|
except Exception as e:
|
|
print(f"mairuiapi获取实时价格失败({stock_code}): {e}")
|
|
|
|
# 备用方案2:使用腾讯财经API(腾讯云可用)
|
|
try:
|
|
import requests as _req
|
|
tcode = ('sh' if stock_code.startswith('6') else 'sz') + stock_code
|
|
_r = _req.get(f'http://qt.gtimg.cn/q={tcode}', timeout=5,
|
|
headers={'Referer': 'https://finance.qq.com'})
|
|
if _r.status_code == 200 and '\"' in _r.text:
|
|
_fields = _r.text.split('\"')[1].split('~')
|
|
if len(_fields) > 35 and _fields[3]:
|
|
return {
|
|
'success': True,
|
|
'data': {
|
|
'code': stock_code,
|
|
'name': _fields[1],
|
|
'price': float(_fields[3]),
|
|
'change': float(_fields[32]) if _fields[32] else 0,
|
|
}
|
|
}
|
|
except Exception as e:
|
|
print(f"腾讯财经备用方案失败({stock_code}): {e}")
|
|
|
|
return {'success': False, 'error': '获取失败'}
|
|
|
|
|
|
def get_realtime_prices_batch(stock_codes):
|
|
"""批量获取实时价格"""
|
|
try:
|
|
from services.mairui_api import get_realtime_prices_batch as mairui_batch
|
|
return mairui_batch(stock_codes)
|
|
except Exception as e:
|
|
print(f"mairuiapi批量获取失败: {e}")
|
|
|
|
return {}
|
|
|
|
|
|
# ========== 热门股票 ==========
|
|
|
|
def get_hot_stocks(limit=100):
|
|
"""获取热门股票 — 东方财富API已不可用,返回空"""
|
|
# stock_hot_rank_em 为东方财富API,已在腾讯云被封锁
|
|
return []
|
|
|
|
|
|
# 初始化时加载缓存
|
|
_load_stock_name_cache()
|