Files
stock/stock-html/services/stock_service.py
T
freedakgmail 40ce519188 fix: 修复6个数据和代码问题
1. 修复 _generate_plain_summary position key 不匹配 (20d→d20, pct→range_pct)
2. 修复 mairui_api.py 日期解析不一致及 float(None) 崩溃风险
3. 修复 fund_flow_analyzer.py 麦蕊回退数据 close_price=0 导致量价背离误判
4. 修复 db_get_fund_flow_history 缺少完整字段和日期过滤
5. 修复 scheduler.py 连接池泄漏 (conn.close() → put_db(conn))
6. 修复多处北交所股票代码映射缺失 (8/9开头→bj)
2026-07-22 07:20:44 +08:00

360 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 'bj' if stock_code.startswith(('8', '9')) 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'
elif stock_code.startswith('8') or stock_code.startswith('9'):
market = 'bj'
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 'bj' if stock_code.startswith(('8', '9')) 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()