Files
stock/stock-html/services/fund_flow_analyzer.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

411 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
主力资金流向分析模块(P0
功能:
1. 从数据库读取近N日资金流向数据
2. 计算主力连续净流入/流出天数、累计净流入额
3. 检测量价背离(资金流入+价格不涨 → 吸筹;资金流出+价格不跌 → 出货)
4. 返回资金面评分和信号列表
数据来源:stock_fund_flow_history 表(由 sync_fund_flow.py 每日同步)
"""
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
def get_fund_flow_history(stock_code, days=10):
"""
从数据库读取近N日资金流向历史数据
参数:
stock_code: 股票代码
days: 获取天数
返回:
list[dict]: 每日资金流向记录,按日期升序排列
"""
from db import get_db, put_db
conn = get_db()
if not conn:
return []
try:
cur = conn.cursor()
start_date = (datetime.now() - timedelta(days=days + 5)).strftime('%Y-%m-%d')
cur.execute("""
SELECT trade_date, close_price, change_pct,
main_net_inflow, main_net_inflow_pct,
super_net_inflow, super_net_inflow_pct,
big_net_inflow, big_net_inflow_pct,
mid_net_inflow, mid_net_inflow_pct,
small_net_inflow, small_net_inflow_pct
FROM stock_fund_flow_history
WHERE code = %s AND trade_date >= %s
ORDER BY trade_date ASC
""", (stock_code, start_date))
rows = cur.fetchall()
records = []
for row in rows:
records.append({
'date': row[0].strftime('%Y-%m-%d') if row[0] else '',
'close_price': float(row[1] or 0),
'change_pct': float(row[2] or 0),
'main_net_inflow': float(row[3] or 0),
'main_net_inflow_pct': float(row[4] or 0),
'super_net_inflow': float(row[5] or 0),
'super_net_inflow_pct': float(row[6] or 0),
'big_net_inflow': float(row[7] or 0),
'big_net_inflow_pct': float(row[8] or 0),
'mid_net_inflow': float(row[9] or 0),
'mid_net_inflow_pct': float(row[10] or 0),
'small_net_inflow': float(row[11] or 0),
'small_net_inflow_pct': float(row[12] or 0),
})
return records
except Exception as e:
logger.error(f"获取资金流向历史失败({stock_code}): {e}")
return []
finally:
put_db(conn)
def _get_fund_flow_from_mairui(stock_code, days=10):
"""从麦蕊智数API获取资金流向数据,转换为与DB记录相同的格式。
API: https://api.mairuiapi.com/hsstock/history/transaction/{code}/{licence}?lt={n}
字段: zmbtdcje=主买特大单, zmbddcje=主买大单, zmbzdcje=主买中单, zmbxdcje=主买小单
zmstdcje=主卖特大单, zmsddcje=主卖大单, zmszdcje=主卖中单, zmsxdcje=主卖小单
"""
try:
import requests
from config import Config
LICENCE = Config.MAIRUI_LICENCE or "5352ED2F-94E5-4E96-8B7F-B57BA75284E3"
url = f"https://api.mairuiapi.com/hsstock/history/transaction/{stock_code}/{LICENCE}?lt={days}"
resp = requests.get(url, timeout=10)
if resp.status_code != 200:
logger.warning(f"麦蕊资金流向API返回{resp.status_code}")
return []
data = resp.json()
if not data or not isinstance(data, list):
return []
records = []
for item in data:
# 主买总额 = 特大单+大单+中单+小单
buy_total = (
float(item.get('zmbtdcje', 0) or 0) +
float(item.get('zmbddcje', 0) or 0) +
float(item.get('zmbzdcje', 0) or 0) +
float(item.get('zmbxdcje', 0) or 0)
)
# 主卖总额
sell_total = (
float(item.get('zmstdcje', 0) or 0) +
float(item.get('zmsddcje', 0) or 0) +
float(item.get('zmszdcje', 0) or 0) +
float(item.get('zmsxdcje', 0) or 0)
)
# 主力净流入 = (特大单+大单)买 - (特大单+大单)卖
main_buy = float(item.get('zmbtdcje', 0) or 0) + float(item.get('zmbddcje', 0) or 0)
main_sell = float(item.get('zmstdcje', 0) or 0) + float(item.get('zmsddcje', 0) or 0)
main_net = main_buy - main_sell
# 超大单净流入
super_net = float(item.get('zmbtdcje', 0) or 0) - float(item.get('zmstdcje', 0) or 0)
# 总成交额
total_amount = buy_total + sell_total
main_net_pct = round(main_net / total_amount * 100, 2) if total_amount > 0 else 0
super_net_pct = round(super_net / total_amount * 100, 2) if total_amount > 0 else 0
# 大单净流入
big_net = float(item.get('zmbddcje', 0) or 0) - float(item.get('zmsddcje', 0) or 0)
big_net_pct = round(big_net / total_amount * 100, 2) if total_amount > 0 else 0
# 中单净流入
mid_net = float(item.get('zmbzdcje', 0) or 0) - float(item.get('zmszdcje', 0) or 0)
mid_net_pct = round(mid_net / total_amount * 100, 2) if total_amount > 0 else 0
# 小单净流入
small_net = float(item.get('zmbxdcje', 0) or 0) - float(item.get('zmsxdcje', 0) or 0)
small_net_pct = round(small_net / total_amount * 100, 2) if total_amount > 0 else 0
# 日期解析
t_str = str(item.get('t', ''))
date_str = t_str[:10] if t_str else ''
records.append({
'date': date_str,
'close_price': 0,
'change_pct': 0,
'main_net_inflow': round(main_net, 2),
'main_net_inflow_pct': main_net_pct,
'super_net_inflow': round(super_net, 2),
'super_net_inflow_pct': super_net_pct,
'big_net_inflow': round(big_net, 2),
'big_net_inflow_pct': big_net_pct,
'mid_net_inflow': round(mid_net, 2),
'mid_net_inflow_pct': mid_net_pct,
'small_net_inflow': round(small_net, 2),
'small_net_inflow_pct': small_net_pct,
})
# 从本地DB补充 close_price 和 change_pct(避免全为0导致量价背离误判)
from db import get_db, put_db
conn = get_db()
if conn:
try:
cur = conn.cursor()
date_list = [r['date'] for r in records if r['date']]
if date_list:
cur.execute("""
SELECT k.trade_date::text, k.close,
CASE WHEN prev.close > 0
THEN ROUND((k.close - prev.close) / prev.close * 100, 2)
ELSE 0 END AS change_pct
FROM stock_kline_daily k
LEFT JOIN LATERAL (
SELECT close FROM stock_kline_daily
WHERE code = k.code AND trade_date < k.trade_date
ORDER BY trade_date DESC LIMIT 1
) prev ON true
WHERE k.code = %s AND k.trade_date::text = ANY(%s)
""", (stock_code, date_list))
price_map = {r[0]: {'close': float(r[1] or 0), 'change_pct': float(r[2] or 0)}
for r in cur.fetchall()}
for r in records:
info = price_map.get(r['date'])
if info:
r['close_price'] = info['close']
r['change_pct'] = info['change_pct']
except Exception as e:
logger.warning(f"补充K线价格失败({stock_code}): {e}")
finally:
put_db(conn)
# 按日期升序排列
records.sort(key=lambda x: x['date'])
logger.info(f"麦蕊API获取{stock_code}资金流向{len(records)}")
return records
except Exception as e:
logger.warning(f"麦蕊资金流向API失败({stock_code}): {e}")
return []
def analyze_fund_flow(stock_code, days=5):
"""
分析主力资金流向,返回资金面评分和信号
数据源优先级:
1. DB stock_fund_flow_history 表(有最新数据时)
2. 麦蕊智数API hsstock/history/transactionDB数据过期时补充)
参数:
stock_code: 股票代码
days: 分析最近几天的资金流向
返回:
dict: {
'score': int, # 资金面评分增减(-20 ~ +20)
'signals': list, # 资金信号列表
'summary': str, # 白话总结
'details': dict, # 详细数据
'reasons': list, # 评分原因列表
}
"""
records = get_fund_flow_history(stock_code, days=days + 5)
# 检查DB数据是否足够新(最近3天内有数据)
use_mairui = False
if len(records) < 2:
use_mairui = True
else:
from datetime import date
latest_date = records[-1].get('date', '')
if latest_date:
try:
latest = datetime.strptime(latest_date, '%Y-%m-%d').date()
if (date.today() - latest).days > 5:
use_mairui = True
except ValueError:
use_mairui = True
if use_mairui:
# 用麦蕊API获取资金流向数据
mairui_records = _get_fund_flow_from_mairui(stock_code, days + 5)
if mairui_records:
records = mairui_records
if len(records) < 2:
return {
'score': 0,
'signals': [],
'summary': '暂无资金流向数据',
'details': {},
'reasons': [],
}
recent = records[-days:] if len(records) >= days else records
# 计算连续净流入/流出天数
consecutive_inflow = 0
consecutive_outflow = 0
for r in reversed(recent):
if r['main_net_inflow'] > 0:
if consecutive_outflow > 0:
break
consecutive_inflow += 1
elif r['main_net_inflow'] < 0:
if consecutive_inflow > 0:
break
consecutive_outflow += 1
# 累计净流入
total_main_inflow = sum(r['main_net_inflow'] for r in recent)
avg_main_pct = sum(r['main_net_inflow_pct'] for r in recent) / len(recent) if recent else 0
# 超大单累计
total_super_inflow = sum(r['super_net_inflow'] for r in recent)
avg_super_pct = sum(r['super_net_inflow_pct'] for r in recent) / len(recent) if recent else 0
# 量价背离检测
# 吸筹:主力净流入但价格不涨(涨幅<2%)
# 出货:主力净流出但价格不跌(跌幅<2%)
accumulation = False
distribution = False
# 检查是否有有效的价格数据(close_price 全为0说明数据不完整,跳过背离检测)
has_valid_price = any(r.get('close_price', 0) > 0 for r in recent)
if has_valid_price:
if total_main_inflow > 0:
price_changes = [r['change_pct'] for r in recent]
avg_price_change = sum(price_changes) / len(price_changes) if price_changes else 0
if avg_price_change < 2:
accumulation = True
if total_main_inflow < 0:
price_changes = [r['change_pct'] for r in recent]
avg_price_change = sum(price_changes) / len(price_changes) if price_changes else 0
if avg_price_change > -2:
distribution = True
# 单日超大单突击
big_surge = False
big_surge_day = None
for r in recent:
if r['super_net_inflow_pct'] > 15:
big_surge = True
big_surge_day = r['date']
break
# 评分计算
score = 0
reasons = []
signals = []
if consecutive_inflow >= 3:
score += 10
reasons.append(f'主力连续{consecutive_inflow}日净流入(+10)')
signals.append({
'type': 'fund_continuous_inflow',
'name': '主力持续流入',
'direction': 'buy',
'strength': 80,
'description': f'主力资金连续{consecutive_inflow}日净流入,累计{total_main_inflow/10000:.0f}万元',
})
if consecutive_outflow >= 3:
score -= 10
reasons.append(f'主力连续{consecutive_outflow}日净流出(-10)')
signals.append({
'type': 'fund_continuous_outflow',
'name': '主力持续流出',
'direction': 'sell',
'strength': 75,
'description': f'主力资金连续{consecutive_outflow}日净流出,累计{total_main_inflow/10000:.0f}万元',
})
if accumulation:
score += 8
reasons.append('主力暗中吸筹(+8)')
signals.append({
'type': 'fund_accumulation',
'name': '主力吸筹',
'direction': 'buy',
'strength': 85,
'description': f'主力净流入但价格未涨,暗中吸筹,可能即将拉升',
})
if distribution:
score -= 8
reasons.append('主力暗中出货(-8)')
signals.append({
'type': 'fund_distribution',
'name': '主力出货',
'direction': 'sell',
'strength': 80,
'description': f'主力净流出但价格未跌,暗中出货,需警惕',
})
if big_surge:
score += 5
reasons.append(f'超大单突击流入({big_surge_day})(+5)')
signals.append({
'type': 'fund_big_surge',
'name': '大单突击',
'direction': 'buy',
'strength': 70,
'description': f'{big_surge_day}超大单净流入占比>15%,大机构突击入场',
})
# 主力净流入占比评分
if avg_main_pct > 10:
score += 5
reasons.append(f'主力净流入占比{avg_main_pct:.1f}%(+5)')
elif avg_main_pct < -10:
score -= 5
reasons.append(f'主力净流出占比{abs(avg_main_pct):.1f}%(-5)')
score = max(-20, min(20, score))
# 白话总结
summary_parts = []
if consecutive_inflow >= 3:
summary_parts.append(f'{consecutive_inflow}天主力持续买入,累计流入{total_main_inflow/10000:.0f}万元')
elif consecutive_outflow >= 3:
summary_parts.append(f'{consecutive_outflow}天主力持续卖出,累计流出{abs(total_main_inflow)/10000:.0f}万元')
elif total_main_inflow > 0:
summary_parts.append(f'近期主力总体净流入{total_main_inflow/10000:.0f}万元')
elif total_main_inflow < 0:
summary_parts.append(f'近期主力总体净流出{abs(total_main_inflow)/10000:.0f}万元')
if accumulation:
summary_parts.append('但价格没怎么涨,像是在暗中吸筹')
if distribution:
summary_parts.append('但价格没怎么跌,像是在暗中出货,要小心')
summary = ''.join(summary_parts) if summary_parts else '资金面无明显方向'
return {
'score': score,
'signals': signals,
'summary': summary,
'details': {
'consecutive_inflow': consecutive_inflow,
'consecutive_outflow': consecutive_outflow,
'total_main_inflow': round(total_main_inflow, 2),
'avg_main_pct': round(avg_main_pct, 2),
'total_super_inflow': round(total_super_inflow, 2),
'avg_super_pct': round(avg_super_pct, 2),
'accumulation': accumulation,
'distribution': distribution,
'recent_days': len(recent),
'daily_data': recent,
},
'reasons': reasons,
}