Initial commit
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
股票数据服务 - 获取、缓存、分析
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user