40ce519188
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)
411 lines
16 KiB
Python
411 lines
16 KiB
Python
"""
|
||
主力资金流向分析模块(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/transaction(DB数据过期时补充)
|
||
|
||
参数:
|
||
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,
|
||
}
|