feat: 新增外部因素分析模块+综合评分引擎+算法文档重构

新增模块:
- fund_flow_analyzer.py: 主力资金流向分析(P0, ±20)
- market_sentiment.py: 市场情绪指标(P1, ±10)
- external_factors.py: 北向资金/美股/大宗商品/汇率(P2-P4,P7)
- news_analyzer.py: 公告/并购/政策面LLM分析(P5-P6)
- score_engine.py: 综合评分引擎,整合技术面+外部因素

路由更新:
- analysis.py: deep_analyze接入综合评分,根据最终评级修正买卖建议
- market.py: 新增4个外部因素API端点
- trades.py: 交易路由更新

算法文档重构:
- 章节重排: 技术面(二三)→外部因素(四)→买卖决策(五)→数据源(六)→性能(七)
- 架构图更新为五层,标注章节对应
- 5.1/5.2标注纯技术面,5.3整合外部因素修正推荐
This commit is contained in:
selfrelease
2026-07-17 23:50:42 +08:00
parent 04f8b9f951
commit 2b5a32ca1e
23 changed files with 4191 additions and 268 deletions
+2 -1
View File
@@ -5,9 +5,10 @@
import requests
import json
from config import Config
# API配置
API_KEY = "9fd8383f-5776-4366-855d-c6f40e867940"
API_KEY = Config.DOUBAO_API_KEY or "9fd8383f-5776-4366-855d-c6f40e867940"
API_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
MODEL = "doubao-seed-1-6-251015"
+545
View File
@@ -0,0 +1,545 @@
"""
外部因素分析模块(P2/P3/P4/P7
包含:
- P2: 北向资金(外资动向)
- P3: 美股隔夜板块变化
- P4: 大宗商品价格
- P7: 汇率变化
数据源:AKShare(开源免费)
所有数据采集均带超时和异常处理,失败时返回中性评分不影响主流程。
"""
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
# 缓存(当日有效)
_cache = {}
_cache_date = {}
def _get_cache(key):
"""获取当日缓存"""
today = datetime.now().strftime('%Y-%m-%d')
if _cache_date.get(key) == today:
return _cache.get(key)
return None
def _set_cache(key, value):
"""设置当日缓存"""
today = datetime.now().strftime('%Y-%m-%d')
_cache[key] = value
_cache_date[key] = today
# ═══════════════════════════════════════════════
# P2: 北向资金
# ═══════════════════════════════════════════════
def get_northbound_capital():
"""
获取北向资金净流入数据
返回:
dict: {
'net_inflow': float, # 今日净流入(亿)
'score': int, # 评分增减(-10 ~ +10
'summary': str, # 白话总结
'reasons': list, # 评分原因
}
"""
cached = _get_cache('northbound')
if cached:
return cached
try:
import akshare as ak
# 获取北向资金净流入数据
df = ak.stock_hsgt_north_net_flow_in_em(symbol="北向")
if df is None or df.empty:
return _neutral_result('北向资金数据为空')
# 取最近5个交易日
recent = df.tail(5)
today_inflow = float(recent.iloc[-1].get('当日净流入', 0) or 0)
# 连续流入/流出天数
consecutive_inflow = 0
consecutive_outflow = 0
for _, row in recent[::-1].iterrows():
val = float(row.get('当日净流入', 0) or 0)
if val > 0:
if consecutive_outflow > 0:
break
consecutive_inflow += 1
elif val < 0:
if consecutive_inflow > 0:
break
consecutive_outflow += 1
# 评分
score = 0
reasons = []
summary_parts = []
if today_inflow > 50:
score += 5
reasons.append(f'北向今日净流入{today_inflow:.1f}亿(+5)')
summary_parts.append(f'外资今日大幅买入{today_inflow:.1f}亿元')
elif today_inflow > 20:
score += 3
reasons.append(f'北向今日净流入{today_inflow:.1f}亿(+3)')
summary_parts.append(f'外资今日净流入{today_inflow:.1f}亿元')
elif today_inflow < -50:
score -= 5
reasons.append(f'北向今日净流出{abs(today_inflow):.1f}亿(-5)')
summary_parts.append(f'外资今日大幅卖出{abs(today_inflow):.1f}亿元')
elif today_inflow < -20:
score -= 3
reasons.append(f'北向今日净流出{abs(today_inflow):.1f}亿(-3)')
summary_parts.append(f'外资今日净流出{abs(today_inflow):.1f}亿元')
else:
summary_parts.append(f'外资今日净流入{today_inflow:.1f}亿元,方向不明')
if consecutive_inflow >= 3:
score += 3
reasons.append(f'北向连续{consecutive_inflow}日净流入(+3)')
summary_parts.append(f'已连续{consecutive_inflow}天买入')
if consecutive_outflow >= 3:
score -= 3
reasons.append(f'北向连续{consecutive_outflow}日净流出(-3)')
summary_parts.append(f'已连续{consecutive_outflow}天卖出')
score = max(-10, min(10, score))
result = {
'net_inflow': round(today_inflow, 2),
'consecutive_inflow': consecutive_inflow,
'consecutive_outflow': consecutive_outflow,
'score': score,
'summary': ''.join(summary_parts),
'reasons': reasons,
}
_set_cache('northbound', result)
return result
except Exception as e:
logger.warning(f"获取北向资金数据失败: {e}")
return _neutral_result('北向资金数据获取失败')
# ═══════════════════════════════════════════════
# P3: 美股隔夜板块变化
# ═══════════════════════════════════════════════
# 美股板块 → A股板块映射
US_A_SECTOR_MAP = {
'科技': ['半导体', '软件', '消费电子', '芯片', 'IT服务'],
'新能源车': ['锂电池', '汽车零部件', '新能源车', '充电桩'],
'金融': ['银行', '保险', '券商'],
'能源': ['石油开采', '化工', '页岩气'],
'医药': ['创新药', '医疗器械', '生物制品', 'CXO'],
'消费': ['白酒', '食品饮料', '零售', '免税'],
'房地产': ['房地产', '建材', '家居'],
'工业': ['机械', '军工', '工业4.0'],
'材料': ['有色金属', '钢铁', '化工新材料'],
'公用事业': ['电力', '环保', '水务'],
}
def get_us_market_overview():
"""
获取美股隔夜收盘数据,计算外盘情绪
返回:
dict: {
'indices': dict, # 三大指数涨跌
'sectors': dict, # 主要板块涨跌
'score': int, # 评分增减(-10 ~ +10
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_a_sectors': dict, # 对A股板块的影响
}
"""
cached = _get_cache('us_market')
if cached:
return cached
try:
import akshare as ak
# 获取全球主要指数
df = ak.index_global()
if df is None or df.empty:
return _neutral_result('美股指数数据为空')
# 筛选美股主要指数
us_indices = {}
for _, row in df.iterrows():
name = str(row.get('名称', ''))
if '纳斯达克' in name:
us_indices['nasdaq'] = {
'name': name,
'change_pct': float(row.get('涨跌幅', 0) or 0),
}
elif '道琼斯' in name:
us_indices['dow'] = {
'name': name,
'change_pct': float(row.get('涨跌幅', 0) or 0),
}
elif '标普500' in name:
us_indices['sp500'] = {
'name': name,
'change_pct': float(row.get('涨跌幅', 0) or 0),
}
if not us_indices:
return _neutral_result('未找到美股指数')
# 计算综合涨跌
avg_change = sum(v['change_pct'] for v in us_indices.values()) / len(us_indices)
# 评分
score = 0
reasons = []
summary_parts = []
if avg_change > 2:
score += 5
reasons.append(f'美股三大指数平均涨幅{avg_change:.1f}%(+5)')
summary_parts.append(f'美股大涨,平均涨幅{avg_change:.1f}%')
elif avg_change > 0.5:
score += 2
reasons.append(f'美股偏强,平均涨幅{avg_change:.1f}%(+2)')
summary_parts.append(f'美股小幅上涨,平均{avg_change:.1f}%')
elif avg_change < -2:
score -= 5
reasons.append(f'美股三大指数平均跌幅{abs(avg_change):.1f}%(-5)')
summary_parts.append(f'美股大跌,平均跌幅{abs(avg_change):.1f}%')
elif avg_change < -0.5:
score -= 2
reasons.append(f'美股偏弱,平均跌幅{abs(avg_change):.1f}%(-2)')
summary_parts.append(f'美股小幅下跌,平均{avg_change:.1f}%')
else:
summary_parts.append(f'美股基本平盘,平均变化{avg_change:.1f}%')
# 对A股板块的影响
affected_sectors = {}
if avg_change > 1:
for us_sector, a_sectors in US_A_SECTOR_MAP.items():
affected_sectors[us_sector] = {
'a_sectors': a_sectors,
'direction': '利好',
'note': f'美股{us_sector}板块偏强,A股{"".join(a_sectors[:3])}可能高开',
}
elif avg_change < -1:
for us_sector, a_sectors in US_A_SECTOR_MAP.items():
affected_sectors[us_sector] = {
'a_sectors': a_sectors,
'direction': '利空',
'note': f'美股{us_sector}板块偏弱,A股{"".join(a_sectors[:3])}可能低开',
}
score = max(-10, min(10, score))
result = {
'indices': us_indices,
'avg_change': round(avg_change, 2),
'score': score,
'summary': ''.join(summary_parts),
'reasons': reasons,
'affected_a_sectors': affected_sectors,
}
_set_cache('us_market', result)
return result
except Exception as e:
logger.warning(f"获取美股数据失败: {e}")
return _neutral_result('美股数据获取失败')
# ═══════════════════════════════════════════════
# P4: 大宗商品价格
# ═══════════════════════════════════════════════
# 大宗商品 → A股板块影响映射
COMMODITY_A_SECTOR_MAP = {
'原油': {
'beneficiary': ['石油开采', '油服', '化工'],
'victim': ['航空', '物流', '化工下游'],
'direction': '油价涨→开采受益,航空受损',
},
'黄金': {
'beneficiary': ['黄金股', '珠宝', '有色'],
'victim': [],
'direction': '金价涨→黄金企业受益',
},
'': {
'beneficiary': ['铜矿', '有色', '电缆'],
'victim': [],
'direction': '铜价涨→铜企受益',
},
'螺纹钢': {
'beneficiary': ['钢铁', '钢矿'],
'victim': ['基建', '地产'],
'direction': '钢价涨→钢企受益,基建成本增',
},
'碳酸锂': {
'beneficiary': ['锂矿', '锂电池'],
'victim': ['新能源车'],
'direction': '锂价涨→锂矿受益,新能源车成本增',
},
}
def get_commodity_overview():
"""
获取主要大宗商品价格变化
返回:
dict: {
'commodities': dict, # 各商品涨跌
'score': int, # 评分增减(-5 ~ +5)
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_sectors': dict, # 对A股板块影响
}
"""
cached = _get_cache('commodity')
if cached:
return cached
try:
import akshare as ak
# 获取国内商品期货行情
df = ak.futures_main_sina()
if df is None or df.empty:
return _neutral_result('大宗商品数据为空')
# 关注的商品
target_commodities = ['原油', '黄金', '', '螺纹钢', '碳酸锂']
commodities = {}
for _, row in df.iterrows():
symbol = str(row.get('symbol', ''))
for target in target_commodities:
if target in symbol:
change = float(row.get('change', 0) or 0)
pct = float(row.get('change_pct', 0) or 0)
commodities[target] = {
'symbol': symbol,
'change_pct': round(pct, 2),
}
break
if not commodities:
return _neutral_result('未找到关注的大宗商品')
# 评分和影响
score = 0
reasons = []
summary_parts = []
affected = {}
for name, data in commodities.items():
pct = data['change_pct']
if abs(pct) < 0.5:
continue
mapping = COMMODITY_A_SECTOR_MAP.get(name)
if not mapping:
continue
if pct > 2:
score += 1
reasons.append(f'{name}{pct:.1f}%,利好{"".join(mapping["beneficiary"][:2])}(+1)')
summary_parts.append(f'{name}大涨{pct:.1f}%')
affected[name] = {
'direction': '利好',
'beneficiary': mapping['beneficiary'],
'victim': mapping['victim'],
'note': mapping['direction'],
}
elif pct < -2:
score -= 1
reasons.append(f'{name}{abs(pct):.1f}%,利空{"".join(mapping["beneficiary"][:2])}(-1)')
summary_parts.append(f'{name}大跌{pct:.1f}%')
affected[name] = {
'direction': '利空',
'beneficiary': mapping['victim'],
'victim': mapping['beneficiary'],
'note': mapping['direction'],
}
score = max(-5, min(5, score))
result = {
'commodities': commodities,
'score': score,
'summary': ''.join(summary_parts) if summary_parts else '大宗商品整体平稳',
'reasons': reasons,
'affected_sectors': affected,
}
_set_cache('commodity', result)
return result
except Exception as e:
logger.warning(f"获取大宗商品数据失败: {e}")
return _neutral_result('大宗商品数据获取失败')
# ═══════════════════════════════════════════════
# P7: 汇率变化
# ═══════════════════════════════════════════════
# 汇率 → A股板块影响
FX_SECTOR_MAP = {
'升值': {
'beneficiary': ['航空', '造纸', '房地产'],
'victim': ['纺织', '家电出口', '电子代工'],
},
'贬值': {
'beneficiary': ['纺织', '家电', '电子代工'],
'victim': ['航空', '造纸'],
},
}
def get_fx_overview():
"""
获取人民币汇率变化
返回:
dict: {
'usd_cny': float, # 美元兑人民币汇率
'change_pct': float, # 涨跌幅
'direction': str, # 升值/贬值/稳定
'score': int, # 评分增减(-3 ~ +3)
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_sectors': dict, # 对A股板块影响
}
"""
cached = _get_cache('fx')
if cached:
return cached
try:
import akshare as ak
# 获取人民币汇率
df = ak.currency_boc_sina(symbol="美元")
if df is None or df.empty:
return _neutral_result('汇率数据为空')
# 取最近2条计算变化
recent = df.tail(2)
if len(recent) < 2:
return _neutral_result('汇率数据不足')
today_rate = float(recent.iloc[-1].get('中行折算价', 0) or 0)
prev_rate = float(recent.iloc[-2].get('中行折算价', 0) or 0)
if prev_rate == 0:
return _neutral_result('汇率数据异常')
change_pct = round((today_rate / prev_rate - 1) * 100, 3)
# 判断方向(美元兑人民币:涨=人民币贬值,跌=人民币升值)
if change_pct > 0.1:
direction = '贬值'
score = -2
summary = f'人民币贬值{abs(change_pct):.3f}%'
reasons = [f'人民币贬值{abs(change_pct):.3f}%(-2)']
affected = FX_SECTOR_MAP['贬值']
elif change_pct < -0.1:
direction = '升值'
score = 2
summary = f'人民币升值{abs(change_pct):.3f}%'
reasons = [f'人民币升值{abs(change_pct):.3f}%(+2)']
affected = FX_SECTOR_MAP['升值']
else:
direction = '稳定'
score = 0
summary = '人民币汇率基本稳定'
reasons = []
affected = {}
score = max(-3, min(3, score))
result = {
'usd_cny': round(today_rate, 4),
'change_pct': change_pct,
'direction': direction,
'score': score,
'summary': summary,
'reasons': reasons,
'affected_sectors': affected,
}
_set_cache('fx', result)
return result
except Exception as e:
logger.warning(f"获取汇率数据失败: {e}")
return _neutral_result('汇率数据获取失败')
# ═══════════════════════════════════════════════
# 综合外部因素
# ═══════════════════════════════════════════════
def get_all_external_factors():
"""
获取所有外部因素数据,返回综合结果
返回:
dict: 包含北向资金、美股、大宗商品、汇率的综合数据
"""
northbound = get_northbound_capital()
us_market = get_us_market_overview()
commodity = get_commodity_overview()
fx = get_fx_overview()
total_score = (
northbound.get('score', 0) +
us_market.get('score', 0) +
commodity.get('score', 0) +
fx.get('score', 0)
)
all_reasons = []
all_reasons.extend(northbound.get('reasons', []))
all_reasons.extend(us_market.get('reasons', []))
all_reasons.extend(commodity.get('reasons', []))
all_reasons.extend(fx.get('reasons', []))
summaries = []
for name, data in [('北向资金', northbound), ('美股', us_market), ('大宗商品', commodity), ('汇率', fx)]:
s = data.get('summary', '')
if s and '失败' not in s and '为空' not in s:
summaries.append(f'{name}{s}')
return {
'northbound_capital': northbound,
'us_market': us_market,
'commodity': commodity,
'fx': fx,
'total_score': total_score,
'all_reasons': all_reasons,
'summary': ' | '.join(summaries),
}
def _neutral_result(reason):
"""返回中性结果"""
return {
'score': 0,
'summary': reason,
'reasons': [],
}
+257
View File
@@ -0,0 +1,257 @@
"""
主力资金流向分析模块(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 analyze_fund_flow(stock_code, days=5):
"""
分析主力资金流向,返回资金面评分和信号
参数:
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)
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
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,
}
+2 -1
View File
@@ -11,7 +11,8 @@ import time
from datetime import datetime, timedelta
# API配置
LICENCE = "5352ED2F-94E5-4E96-8B7F-B57BA75284E3"
from config import Config
LICENCE = Config.MAIRUI_LICENCE or "5352ED2F-94E5-4E96-8B7F-B57BA75284E3"
BASE_URL = "https://api.mairuiapi.com"
# 缓存配置
+197
View File
@@ -0,0 +1,197 @@
"""
市场情绪指标模块(P1
从 stock_realtime_price 表直接计算市场情绪指标,无需额外数据源。
指标包括:
1. 涨停/跌停家数比
2. 连板高度(最高连板数)
3. 换手率中位数
4. 两市成交额
"""
import logging
logger = logging.getLogger(__name__)
def calc_market_sentiment():
"""
从数据库实时行情表计算市场情绪指标
返回:
dict: {
'limit_up_count': int, # 涨停家数
'limit_down_count': int, # 跌停家数
'up_down_ratio': float, # 涨跌停比
'sentiment': str, # 情绪标签
'consecutive_board': int, # 最高连板数
'turnover_median': float, # 换手率中位数
'total_amount': float, # 两市成交额(亿)
'market_temp': str, # 市场温度(偏热/偏冷/正常)
'score': int, # 情绪评分增减(-10 ~ +10)
'reasons': list, # 评分原因
}
"""
from db import get_db, put_db
conn = get_db()
if not conn:
return _empty_sentiment()
try:
cur = conn.cursor()
# 涨停跌停统计(涨停:涨幅>=9.8%,跌停:跌幅<=-9.8%
cur.execute("""
SELECT
COUNT(*) FILTER (WHERE change_pct >= 9.8) AS limit_up,
COUNT(*) FILTER (WHERE change_pct <= -9.8) AS limit_down,
COUNT(*) FILTER (WHERE change_pct > 0) AS up_count,
COUNT(*) FILTER (WHERE change_pct < 0) AS down_count,
COUNT(*) FILTER (WHERE change_pct = 0) AS flat_count,
COUNT(*) AS total,
COALESCE(SUM(amount), 0) AS total_amount,
COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY turnover), 0) AS turnover_median
FROM stock_realtime_price
WHERE volume > 0 AND price > 0
""")
row = cur.fetchone()
if not row:
return _empty_sentiment()
limit_up = int(row[0] or 0)
limit_down = int(row[1] or 0)
up_count = int(row[2] or 0)
down_count = int(row[3] or 0)
flat_count = int(row[4] or 0)
total = int(row[5] or 1)
total_amount = float(row[6] or 0) / 1e8 # 转为亿
turnover_median = float(row[7] or 0)
# 涨跌停比
up_down_ratio = round(limit_up / limit_down, 1) if limit_down > 0 else float(limit_up)
# 情绪标签
if limit_down == 0 and limit_up > 10:
sentiment = '极度乐观'
elif up_down_ratio >= 5:
sentiment = '乐观'
elif up_down_ratio >= 2:
sentiment = '偏多'
elif up_down_ratio >= 1:
sentiment = '中性'
elif up_down_ratio >= 0.5:
sentiment = '偏空'
else:
sentiment = '悲观'
# 市场温度
if total_amount > 1.2e4:
market_temp = '偏热'
elif total_amount < 6000:
market_temp = '偏冷'
else:
market_temp = '正常'
# 连板高度:查找连续涨停的股票
consecutive_board = _calc_max_consecutive_board(cur)
# 评分
score = 0
reasons = []
if up_down_ratio >= 5:
score += 5
reasons.append(f'涨跌停比{up_down_ratio}:1,情绪极度乐观(+5)')
elif up_down_ratio >= 2:
score += 3
reasons.append(f'涨跌停比{up_down_ratio}:1,情绪偏多(+3)')
elif up_down_ratio < 0.5:
score -= 5
reasons.append(f'涨跌停比{up_down_ratio}:1,情绪悲观(-5)')
elif up_down_ratio < 1:
score -= 3
reasons.append(f'涨跌停比{up_down_ratio}:1,情绪偏空(-3)')
if consecutive_board >= 5:
score += 3
reasons.append(f'最高{consecutive_board}连板,市场热度高(+3)')
if total_amount > 1.2e4:
score += 2
reasons.append(f'两市成交额{total_amount:.0f}亿,交投活跃(+2)')
elif total_amount < 6000:
score -= 2
reasons.append(f'两市成交额仅{total_amount:.0f}亿,交投清淡(-2)')
score = max(-10, min(10, score))
return {
'limit_up_count': limit_up,
'limit_down_count': limit_down,
'up_count': up_count,
'down_count': down_count,
'up_down_ratio': up_down_ratio,
'sentiment': sentiment,
'consecutive_board': consecutive_board,
'turnover_median': round(turnover_median, 2),
'total_amount': round(total_amount, 0),
'market_temp': market_temp,
'score': score,
'reasons': reasons,
}
except Exception as e:
logger.error(f"计算市场情绪指标失败: {e}")
return _empty_sentiment()
finally:
put_db(conn)
def _calc_max_consecutive_board(cur):
"""
计算最高连板数(需要历史数据辅助判断)
简化版:通过查找连续涨幅>=9.8%的股票
由于实时表只有当日数据,这里用近似方法:
查找涨停股票数量作为市场热度参考
"""
try:
# 查找涨停股票(涨幅>=9.8%)
cur.execute("""
SELECT COUNT(*) FROM stock_realtime_price
WHERE change_pct >= 9.8 AND volume > 0
""")
limit_up_count = int(cur.fetchone()[0] or 0)
# 简化:涨停家数>50视为有高连板可能
if limit_up_count > 50:
return 5
elif limit_up_count > 30:
return 4
elif limit_up_count > 15:
return 3
elif limit_up_count > 5:
return 2
elif limit_up_count > 0:
return 1
return 0
except Exception:
return 0
def _empty_sentiment():
"""返回空情绪数据"""
return {
'limit_up_count': 0,
'limit_down_count': 0,
'up_count': 0,
'down_count': 0,
'up_down_ratio': 0,
'sentiment': '无数据',
'consecutive_board': 0,
'turnover_median': 0,
'total_amount': 0,
'market_temp': '无数据',
'score': 0,
'reasons': [],
}
+584
View File
@@ -0,0 +1,584 @@
"""
新闻/公告/政策分析模块(P5/P6
功能:
- P5: 上市公司公告采集 + LLM情感分析 + 异动监测
- P6: 政策面新闻监控 + LLM政策分析
数据源:
- AKShare 公告数据 (stock_notice_report)
- 豆包LLM 做分类和情感分析
"""
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
# 当日缓存
_news_cache = {}
_news_cache_date = {}
def _get_cache(key):
today = datetime.now().strftime('%Y-%m-%d')
if _news_cache_date.get(key) == today:
return _news_cache.get(key)
return None
def _set_cache(key, value):
today = datetime.now().strftime('%Y-%m-%d')
_news_cache[key] = value
_news_cache_date[key] = today
# ═══════════════════════════════════════════════
# P5: 公告/并购消息分析
# ═══════════════════════════════════════════════
# 公告类型关键词映射
ANNOUNCEMENT_KEYWORDS = {
'并购重组': ['收购', '合并', '重组', '并购', '吸收合并'],
'增减持': ['增持', '减持', '股份变动', '股东减持', '股东增持'],
'业绩预告': ['业绩预告', '业绩快报', '盈利预测', '预增', '预减', '预亏', '扭亏'],
'股权激励': ['股权激励', '限制性股票', '股票期权'],
'定增再融资': ['定增', '非公开发行', '配股', '可转债', '再融资'],
'分红送转': ['分红', '送转', '派息', '转增', '利润分配'],
'重大合同': ['重大合同', '中标', '框架协议', '战略合作'],
'停复牌': ['停牌', '复牌', '继续停牌'],
'其他重大事项': ['重大事项', '重大投资', '资产出售', '资产剥离', '商誉减值'],
}
def classify_announcement(title):
"""
根据标题关键词对公告进行分类
参数:
title: 公告标题
返回:
str: 公告类型
"""
for category, keywords in ANNOUNCEMENT_KEYWORDS.items():
for kw in keywords:
if kw in title:
return category
return '其他'
def get_stock_announcements(stock_code, days=7):
"""
获取个股近期公告
参数:
stock_code: 股票代码
days: 获取最近几天的公告
返回:
list[dict]: 公告列表
"""
cached = _get_cache(f'announcements_{stock_code}')
if cached:
return cached
try:
import akshare as ak
end_date = datetime.now().strftime('%Y%m%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
df = ak.stock_notice_report(symbol=stock_code, date=start_date)
if df is None or df.empty:
# 尝试备用接口
try:
df = ak.stock_zh_a_disclosure_report_cninfo(
symbol=stock_code, market='沪深京',
start_date=start_date, end_date=end_date
)
except Exception:
return []
if df is None or df.empty:
return []
announcements = []
for _, row in df.iterrows():
title = str(row.get('标题', row.get('title', '')))
date_str = str(row.get('公告日期', row.get('date', '')))
category = classify_announcement(title)
announcements.append({
'title': title,
'date': date_str[:10] if date_str else '',
'category': category,
'sentiment': None, # 待LLM分析
})
_set_cache(f'announcements_{stock_code}', announcements)
return announcements
except Exception as e:
logger.warning(f"获取公告数据失败({stock_code}): {e}")
return []
def analyze_announcement_sentiment(stock_name, stock_code, announcements):
"""
使用LLM分析公告情感倾向
参数:
stock_name: 股票名称
stock_code: 股票代码
announcements: 公告列表
返回:
dict: {
'score': int, # 评分增减(-15 ~ +15
'summary': str, # 白话总结
'reasons': list, # 评分原因
'details': list, # 各公告分析结果
}
"""
if not announcements:
return {
'score': 0,
'summary': '近期无重要公告',
'reasons': [],
'details': [],
}
# 先用规则快速分类
positive_keywords = ['收购', '增持', '预增', '扭亏', '重大合同', '中标', '战略合作', '分红', '送转', '股权激励']
negative_keywords = ['减持', '预亏', '预减', '商誉减值', '资产出售', '停牌', '重大事项']
details = []
score = 0
reasons = []
positive_count = 0
negative_count = 0
for ann in announcements:
title = ann['title']
category = ann['category']
is_positive = any(kw in title for kw in positive_keywords)
is_negative = any(kw in title for kw in negative_keywords)
if is_positive and not is_negative:
sentiment = '利好'
ann_score = _get_category_score(category, positive=True)
positive_count += 1
elif is_negative and not is_positive:
sentiment = '利空'
ann_score = _get_category_score(category, positive=False)
negative_count += 1
else:
sentiment = '中性'
ann_score = 0
ann['sentiment'] = sentiment
ann['score'] = ann_score
score += ann_score
details.append(ann)
if ann_score != 0:
reasons.append(f'[{category}]{title[:30]}...({sentiment}{ann_score:+d})')
# 尝试用LLM深度分析(如果有重要公告)
important_categories = ['并购重组', '业绩预告', '增减持', '定增再融资']
important_anns = [a for a in announcements if a['category'] in important_categories]
if important_anns and len(important_anns) <= 5:
try:
llm_result = _llm_analyze_announcements(stock_name, stock_code, important_anns)
if llm_result:
# LLM分析覆盖规则评分
score = llm_result.get('score', score)
reasons = llm_result.get('reasons', reasons)
except Exception as e:
logger.warning(f"LLM公告分析失败: {e}")
score = max(-15, min(15, score))
# 白话总结
if positive_count > negative_count:
summary = f'{len(announcements)}条公告中{positive_count}条利好、{negative_count}条利空,消息面偏多'
elif negative_count > positive_count:
summary = f'{len(announcements)}条公告中{negative_count}条利空、{positive_count}条利好,消息面偏空'
else:
summary = f'{len(announcements)}条公告,消息面中性'
return {
'score': score,
'summary': summary,
'reasons': reasons,
'details': details,
}
def _get_category_score(category, positive=True):
"""根据公告类型和方向返回评分"""
scores = {
'并购重组': 10 if positive else -8,
'业绩预告': 8 if positive else -10,
'增减持': 5 if positive else -5,
'定增再融资': 5 if positive else -3,
'重大合同': 5 if positive else 0,
'分红送转': 3 if positive else 0,
'股权激励': 3 if positive else 0,
'停复牌': 0,
'其他重大事项': 0,
'其他': 0,
}
return scores.get(category, 0)
def _llm_analyze_announcements(stock_name, stock_code, announcements):
"""
调用豆包LLM分析公告情感
参数:
stock_name: 股票名称
stock_code: 股票代码
announcements: 重要公告列表
返回:
dict: LLM分析结果
"""
try:
import requests
import json
from services.doubao_api import API_KEY, API_URL, MODEL
ann_text = '\n'.join([f"- [{a['category']}]{a['title']}" for a in announcements])
prompt = f"""请分析以下{stock_name}({stock_code})的近期公告,判断每条公告是利好还是利空,并给出整体消息面评分。
公告列表:
{ann_text}
请按以下JSON格式输出(不要输出其他内容):
{{"score": <整数,-15到+15>, "reasons": ["原因1", "原因2"], "summary": "一句话总结"}}"""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"
}
payload = {
"model": MODEL,
"max_completion_tokens": 1024,
"stream": False,
"messages": [
{"role": "user", "content": prompt}
]
}
resp = requests.post(API_URL, headers=headers, json=payload, timeout=30)
if resp.status_code == 200:
data = resp.json()
content = data.get('choices', [{}])[0].get('message', {}).get('content', '')
# 尝试解析JSON
try:
result = json.loads(content)
return result
except json.JSONDecodeError:
# 尝试提取JSON
import re
match = re.search(r'\{.*\}', content, re.DOTALL)
if match:
return json.loads(match.group())
return None
except Exception as e:
logger.warning(f"LLM公告分析失败: {e}")
return None
def detect_price_anomaly(stock_code, df):
"""
检测股价异动(可能由消息面驱动)
参数:
stock_code: 股票代码
df: K线DataFrame
返回:
dict: 异动检测结果
"""
if df is None or len(df) < 20:
return {'anomaly': False, 'score': 0, 'reasons': []}
try:
import numpy as np
recent = df.tail(5)
vol_20 = float(df['volume'].tail(20).mean())
vol_recent = float(recent['volume'].mean())
vol_ratio = vol_recent / vol_20 if vol_20 > 0 else 1
change_recent = float((recent.iloc[-1]['close'] / recent.iloc[0]['close'] - 1) * 100)
# 异动条件:量比>3 且 涨跌幅>5%
if vol_ratio > 3 and abs(change_recent) > 5:
direction = '利好' if change_recent > 0 else '利空'
score = 5 if change_recent > 0 else -5
return {
'anomaly': True,
'direction': direction,
'vol_ratio': round(vol_ratio, 1),
'change_pct': round(change_recent, 2),
'score': score,
'reasons': [f'近期异动:量比{vol_ratio:.1f}倍+{direction}{abs(change_recent):.1f}%,可能有消息面催化({score:+d})'],
'summary': f'近期量比{vol_ratio:.1f}倍,{"" if change_recent > 0 else ""}{abs(change_recent):.1f}%,可能有消息面催化',
}
return {'anomaly': False, 'score': 0, 'reasons': []}
except Exception as e:
logger.warning(f"异动检测失败({stock_code}): {e}")
return {'anomaly': False, 'score': 0, 'reasons': []}
# ═══════════════════════════════════════════════
# P6: 政策面分析
# ═══════════════════════════════════════════════
# 政策关键词
POLICY_KEYWORDS = {
'行业扶持': ['扶持', '支持', '补贴', '鼓励', '促进', '加快', '推动', '振兴'],
'行业监管': ['监管', '限制', '禁止', '整顿', '规范', '处罚', '约谈'],
'货币政策': ['降准', '降息', '逆回购', 'MLF', 'SLF', '流动性', '存款准备金'],
'财政政策': ['减税', '降费', '基建', '专项债', '财政赤字', '以旧换新'],
'资本市场': ['注册制', '退市', '再融资', 'IPO', '印花税', '减持新规', '分红'],
}
def get_policy_news(days=3):
"""
获取近期财经政策新闻
返回:
list[dict]: 政策新闻列表
"""
cached = _get_cache('policy_news')
if cached:
return cached
try:
import akshare as ak
# 获取财经新闻
df = ak.stock_info_global_em()
if df is None or df.empty:
return []
# 筛选含政策关键词的新闻
policy_news = []
for _, row in df.head(50).iterrows():
title = str(row.get('标题', row.get('title', '')))
content = str(row.get('内容', row.get('content', '')))
date_str = str(row.get('发布时间', row.get('date', '')))
for category, keywords in POLICY_KEYWORDS.items():
if any(kw in title for kw in keywords):
policy_news.append({
'title': title,
'date': date_str[:10] if date_str else '',
'category': category,
'content': content[:200],
'sentiment': None,
})
break
_set_cache('policy_news', policy_news)
return policy_news
except Exception as e:
logger.warning(f"获取政策新闻失败: {e}")
return []
def analyze_policy_impact(policy_news):
"""
分析政策面对市场的影响
参数:
policy_news: 政策新闻列表
返回:
dict: {
'score': int, # 评分增减(-10 ~ +10
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_sectors': dict, # 受影响板块
}
"""
if not policy_news:
return {
'score': 0,
'summary': '近期无明显政策消息',
'reasons': [],
'affected_sectors': {},
}
# 规则评分
sector_impact = {
'行业扶持': {'direction': '利好', 'sectors': ['对应行业板块']},
'行业监管': {'direction': '利空', 'sectors': ['对应行业板块']},
'货币政策': {'direction': '利好', 'sectors': ['全市场']},
'财政政策': {'direction': '利好', 'sectors': ['基建', '消费', '相关板块']},
'资本市场': {'direction': '中性', 'sectors': ['券商', '全市场']},
}
score = 0
reasons = []
affected = {}
positive_count = 0
negative_count = 0
for news in policy_news:
category = news['category']
impact = sector_impact.get(category, {'direction': '中性', 'sectors': []})
if impact['direction'] == '利好':
score += 2
positive_count += 1
news['sentiment'] = '利好'
reasons.append(f'[{category}]{news["title"][:30]}...(利好+2)')
elif impact['direction'] == '利空':
score -= 3
negative_count += 1
news['sentiment'] = '利空'
reasons.append(f'[{category}]{news["title"][:30]}...(利空-3)')
else:
news['sentiment'] = '中性'
affected[category] = impact
# 尝试用LLM深度分析重大政策
major_policies = [n for n in policy_news if n['category'] in ['行业扶持', '行业监管', '货币政策']]
if major_policies and len(major_policies) <= 5:
try:
llm_result = _llm_analyze_policy(major_policies)
if llm_result:
score = llm_result.get('score', score)
reasons = llm_result.get('reasons', reasons)
except Exception as e:
logger.warning(f"LLM政策分析失败: {e}")
score = max(-10, min(10, score))
if positive_count > negative_count:
summary = f'近期{len(policy_news)}条政策消息,偏利好({positive_count}条利好/{negative_count}条利空)'
elif negative_count > positive_count:
summary = f'近期{len(policy_news)}条政策消息,偏利空({negative_count}条利空/{positive_count}条利好)'
else:
summary = f'近期{len(policy_news)}条政策消息,影响中性'
return {
'score': score,
'summary': summary,
'reasons': reasons,
'affected_sectors': affected,
'details': policy_news,
}
def _llm_analyze_policy(policy_news):
"""
调用豆包LLM分析政策影响
参数:
policy_news: 政策新闻列表
返回:
dict: LLM分析结果
"""
try:
import requests
import json
from services.doubao_api import API_KEY, API_URL, MODEL
news_text = '\n'.join([f"- [{n['category']}]{n['title']}" for n in policy_news])
prompt = f"""请分析以下财经政策新闻对A股市场的影响,判断整体是利好还是利空,并给出评分。
政策新闻:
{news_text}
请按以下JSON格式输出(不要输出其他内容):
{{"score": <整数,-10到+10>, "reasons": ["原因1", "原因2"], "summary": "一句话总结", "affected_sectors": {{"板块名": "利好/利空"}}}}"""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"
}
payload = {
"model": MODEL,
"max_completion_tokens": 1024,
"stream": False,
"messages": [
{"role": "user", "content": prompt}
]
}
resp = requests.post(API_URL, headers=headers, json=payload, timeout=30)
if resp.status_code == 200:
data = resp.json()
content = data.get('choices', [{}])[0].get('message', {}).get('content', '')
try:
return json.loads(content)
except json.JSONDecodeError:
import re
match = re.search(r'\{.*\}', content, re.DOTALL)
if match:
return json.loads(match.group())
return None
except Exception as e:
logger.warning(f"LLM政策分析失败: {e}")
return None
# ═══════════════════════════════════════════════
# 综合消息面分析
# ═══════════════════════════════════════════════
def analyze_news_factors(stock_code, stock_name, df=None):
"""
获取个股消息面 + 政策面综合分析
参数:
stock_code: 股票代码
stock_name: 股票名称
df: K线DataFrame(用于异动检测)
返回:
dict: 综合消息面分析结果
"""
# 公告分析
announcements = get_stock_announcements(stock_code, days=7)
ann_result = analyze_announcement_sentiment(stock_name, stock_code, announcements)
# 异动检测
anomaly_result = detect_price_anomaly(stock_code, df) if df is not None else {'anomaly': False, 'score': 0, 'reasons': []}
# 政策面
policy_news = get_policy_news(days=3)
policy_result = analyze_policy_impact(policy_news)
total_score = ann_result.get('score', 0) + anomaly_result.get('score', 0) + policy_result.get('score', 0)
total_score = max(-20, min(20, total_score))
all_reasons = []
all_reasons.extend(ann_result.get('reasons', []))
all_reasons.extend(anomaly_result.get('reasons', []))
all_reasons.extend(policy_result.get('reasons', []))
return {
'announcements': ann_result,
'price_anomaly': anomaly_result,
'policy': policy_result,
'total_score': total_score,
'all_reasons': all_reasons,
'summary': f"公告:{ann_result.get('summary', '')} | 政策:{policy_result.get('summary', '')}",
}
+134
View File
@@ -0,0 +1,134 @@
"""
综合评分引擎 — 整合所有影响因素到统一评分体系
将技术面(基础50%+)与外部因素(加减分项)整合为最终评分。
权重分配:
- 技术面评分(compute_deep_analysis 原始分):基础分(0-100)
- P0 资金面:±20
- P1 市场情绪:±10
- P2 北向资金:±10
- P3 美股外盘:±10
- P4 大宗商品:±5
- P5 公告/异动:±15
- P6 政策面:±10
- P7 汇率:±3
最终评分 = 技术面基础分 + 外部因素加减分(上限100,下限0)
"""
import logging
logger = logging.getLogger(__name__)
def compute_comprehensive_score(stock_code, stock_name, technical_score, df=None):
"""
综合评分引擎 — 整合技术面和所有外部因素
参数:
stock_code: 股票代码
stock_name: 股票名称
technical_score: float — 技术面基础评分(0-100,来自 compute_deep_analysis
df: K线DataFrame(用于异动检测,可选)
返回:
dict: {
'technical_score': float, # 技术面基础分
'external_score': int, # 外部因素总加减分
'final_score': int, # 最终综合评分(0-100)
'verdict': str, # 最终评级
'factors': dict, # 各因素详细数据
'all_reasons': list, # 所有评分原因
'summary': str, # 综合白话总结
}
"""
factors = {}
all_reasons = []
external_score = 0
summaries = []
# ---- P0: 主力资金进出 ----
try:
from services.fund_flow_analyzer import analyze_fund_flow
fund_result = analyze_fund_flow(stock_code, days=5)
factors['fund_flow'] = fund_result
external_score += fund_result.get('score', 0)
all_reasons.extend(fund_result.get('reasons', []))
s = fund_result.get('summary', '')
if s and '暂无' not in s:
summaries.append(f'资金面:{s}')
except Exception as e:
logger.warning(f"P0资金面分析失败: {e}")
factors['fund_flow'] = {'score': 0, 'summary': '分析失败', 'reasons': []}
# ---- P1: 市场情绪指标 ----
try:
from services.market_sentiment import calc_market_sentiment
sentiment_result = calc_market_sentiment()
factors['market_sentiment'] = sentiment_result
external_score += sentiment_result.get('score', 0)
all_reasons.extend(sentiment_result.get('reasons', []))
s = sentiment_result.get('sentiment', '')
if s and '无数据' not in s:
summaries.append(f'市场情绪:{s}(涨跌停{sentiment_result.get("limit_up_count",0)}:{sentiment_result.get("limit_down_count",0)}')
except Exception as e:
logger.warning(f"P1市场情绪分析失败: {e}")
factors['market_sentiment'] = {'score': 0, 'summary': '分析失败', 'reasons': []}
# ---- P2/P3/P4/P7: 外部因素(北向/美股/商品/汇率)----
try:
from services.external_factors import get_all_external_factors
ext_result = get_all_external_factors()
factors['external'] = ext_result
external_score += ext_result.get('total_score', 0)
all_reasons.extend(ext_result.get('all_reasons', []))
s = ext_result.get('summary', '')
if s:
summaries.append(s)
except Exception as e:
logger.warning(f"P2-P7外部因素分析失败: {e}")
factors['external'] = {'total_score': 0, 'summary': '分析失败', 'all_reasons': []}
# ---- P5/P6: 公告/异动/政策 ----
try:
from services.news_analyzer import analyze_news_factors
news_result = analyze_news_factors(stock_code, stock_name, df)
factors['news'] = news_result
external_score += news_result.get('total_score', 0)
all_reasons.extend(news_result.get('all_reasons', []))
s = news_result.get('summary', '')
if s:
summaries.append(s)
except Exception as e:
logger.warning(f"P5/P6消息面分析失败: {e}")
factors['news'] = {'total_score': 0, 'summary': '分析失败', 'all_reasons': []}
# ---- 最终评分 ----
# 外部因素加减分上限:±40(避免喧宾夺主)
external_score = max(-40, min(40, external_score))
final_score = max(0, min(100, int(technical_score + external_score)))
# 最终评级
if final_score >= 80:
verdict = '强烈看多'
elif final_score >= 65:
verdict = '看多'
elif final_score >= 50:
verdict = '中性偏多'
elif final_score >= 35:
verdict = '中性偏空'
else:
verdict = '看空'
# 综合总结
summary = ' | '.join(summaries) if summaries else '暂无外部因素数据'
return {
'technical_score': round(technical_score, 0),
'external_score': external_score,
'final_score': final_score,
'verdict': verdict,
'factors': factors,
'all_reasons': all_reasons,
'summary': summary,
}
+413 -22
View File
@@ -168,22 +168,23 @@ def _get_kline_from_local_db(stock_code, days=120):
"""从本地数据库读取K线(最快,毫秒级)"""
import pandas as pd
try:
import psycopg2
conn = psycopg2.connect(
host=Config.DB_HOST, port=Config.DB_PORT,
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
)
from db import get_db, put_db
conn = get_db()
if not conn:
return None
conn.autocommit = True
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
with conn.cursor() as cur:
cur.execute("""
SELECT trade_date, open, high, low, close, volume
FROM stock_kline_daily
WHERE code = %s AND trade_date >= %s
ORDER BY trade_date
""", (stock_code, start_date))
rows = cur.fetchall()
conn.close()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT trade_date, open, high, low, close, volume
FROM stock_kline_daily
WHERE code = %s AND trade_date >= %s
ORDER BY trade_date
""", (stock_code, start_date))
rows = cur.fetchall()
finally:
put_db(conn)
if rows and len(rows) >= 30:
df = pd.DataFrame(rows, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
@@ -625,9 +626,9 @@ def compute_recommend(signal_status, indicators, triggered_count, is_holding):
if has_real_dragon:
return ('watch', '关注', '真龙出现 → 趋势启动,等待龙抬头确认', 65)
# MACD死叉 → 卖出/回避
# MACD死叉 → 回避(非持仓不能卖出,应为回避/观望)
if dif is not None and dea is not None and dif < dea:
return ('sell', '卖出', f"MACD死叉(DIF={dif:.3f}<DEA={dea:.3f})", 75)
return ('watch', '回避', f"MACD死叉(DIF={dif:.3f}<DEA={dea:.3f}),趋势偏弱", 25)
# 底背离 → 关注(suanfa.md 步骤1: 纳入关注范围)
if has_divergence:
@@ -652,23 +653,23 @@ def get_latest_price(stock_code):
返回:
float: 最新价格, 失败返回 0
"""
from db import get_db, put_db
conn = get_db()
if not conn:
return 0
try:
import psycopg2
conn = psycopg2.connect(
host=Config.DB_HOST, port=Config.DB_PORT,
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
)
cur = conn.cursor()
cur.execute("""
SELECT price FROM stock_realtime_price
WHERE code = %s AND price > 0
""", (stock_code,))
row = cur.fetchone()
conn.close()
if row:
return float(row[0])
except Exception:
pass
finally:
put_db(conn)
return 0
@@ -905,3 +906,393 @@ def find_bull_stocks(scan_rows, holding_codes=None):
'total': total,
'stage_info': BULL_STAGES,
}
# ═══════════════════════════════════════════════
# 9. 单股深度分析(价格位置、压力支撑、量价、空间估算)
# ═══════════════════════════════════════════════
def _generate_plain_summary(price, change_pct, ma_trend, position, supports,
resistances, vol_ratio, vol_trend, patterns,
space, score, verdict, reasons):
"""根据技术分析结果生成通俗易懂的中文解说"""
parts = []
# 1. 当前走势概况
if change_pct > 3:
trend_desc = f'今天涨了{change_pct:.1f}%,涨势比较猛'
elif change_pct > 0:
trend_desc = f'今天小涨{change_pct:.1f}%'
elif change_pct > -3:
trend_desc = f'今天小跌{abs(change_pct):.1f}%'
else:
trend_desc = f'今天跌了{abs(change_pct):.1f}%,跌幅较大'
if ma_trend == 'bullish':
trend_desc += ',均线呈多头排列,说明中短期整体向上'
elif ma_trend == 'bearish':
trend_desc += ',均线呈空头排列,中短期趋势偏弱'
else:
trend_desc += ',均线交叉纠缠,短期方向还不太明确'
parts.append(trend_desc + '')
# 2. 价格位置(用大白话)
pos_20 = position.get('20d', {})
pct_20 = pos_20.get('pct', 50)
if pct_20 > 80:
parts.append(f'当前股价处于近20天的高位区间({pct_20:.0f}%位置),已经涨了不少,追高要小心。')
elif pct_20 > 50:
parts.append(f'股价在近20天的中高位置({pct_20:.0f}%),还有一定上涨空间。')
elif pct_20 > 20:
parts.append(f'股价在近20天的中低位置({pct_20:.0f}%),相对安全。')
else:
parts.append(f'股价处于近20天的低位区间({pct_20:.0f}%),可能存在反弹机会。')
# 3. 上方压力和下方支撑
if resistances:
nearest_r = resistances[0]
r_gap = round((nearest_r['level'] - price) / price * 100, 1) if price > 0 else 0
if r_gap > 0:
parts.append(f'往上最近的压力位在{nearest_r["level"]:.2f}元({nearest_r["name"]}),距离约{r_gap:.1f}%。')
if supports:
nearest_s = supports[0]
s_gap = round((price - nearest_s['level']) / price * 100, 1) if price > 0 else 0
if s_gap > 0:
parts.append(f'往下最近的支撑位在{nearest_s["level"]:.2f}元({nearest_s["name"]}),有{s_gap:.1f}%的安全垫。')
# 4. 成交量情况
if vol_ratio >= 2:
parts.append(f'成交量明显放大(量比{vol_ratio:.1f}倍),市场关注度很高,要留意是主力进场还是出货。')
elif vol_ratio >= 1.3:
parts.append(f'成交量温和放大(量比{vol_ratio:.1f}倍),有资金在活跃参与。')
elif vol_ratio < 0.6:
parts.append(f'成交量萎缩(量比{vol_ratio:.1f}倍),市场比较冷清,短期可能震荡。')
else:
parts.append(f'成交量正常(量比{vol_ratio:.1f}倍)。')
# 5. 形态识别
if patterns:
pattern_names = [p['name'] for p in patterns]
bullish_p = [p['name'] for p in patterns if p.get('bullish') is True]
bearish_p = [p['name'] for p in patterns if p.get('bullish') is False]
if bullish_p:
parts.append(f'发现看涨信号:{"".join(bullish_p)},这是积极的技术形态。')
if bearish_p:
parts.append(f'注意看跌信号:{"".join(bearish_p)},需要警惕。')
# 6. 综合建议(大白话)
action_tip = ''
if score >= 75:
action_tip = '综合来看比较乐观,可以考虑逢低关注或适量参与,但注意控制仓位。'
elif score >= 60:
action_tip = '整体偏积极,可以少量关注,等回调到支撑位附近再考虑。'
elif score >= 45:
action_tip = '目前多空力量比较均衡,建议观望为主,等方向更明确再做决定。'
elif score >= 30:
action_tip = '目前偏弱势,不建议急于买入。如果持有,可以在反弹时适当减仓。'
else:
action_tip = '当前走势比较弱,建议回避。已经持有的可以考虑止损或等待反弹减仓。'
# 7. 空间估算
rr = space.get('risk_reward', 0)
if rr and rr > 0:
if rr >= 2:
parts.append(f'从空间来看,潜在收益是风险的{rr:.1f}倍,性价比不错。')
elif rr >= 1:
parts.append(f'收益风险比{rr:.1f}:1,性价比一般。')
else:
parts.append(f'收益风险比仅{rr:.1f}:1,下行风险大于上涨空间,不太划算。')
summary_text = ''.join(parts)
return {
'text': summary_text,
'action_tip': action_tip,
'confidence': '' if score >= 70 or score <= 30 else '',
}
def compute_deep_analysis(df, signal_result=None, realtime_info=None):
"""
对单只股票进行深度分析,返回结构化的分析报告。
参数:
df: DataFrame (含技术指标的K线数据)
signal_result: dict (detect_all_signals 返回的结果,可选)
realtime_info: dict (stock_realtime_price 行数据,可选)
返回:
dict: 完整的深度分析报告
"""
import numpy as np
if df is None or len(df) < 30:
return {'error': 'K线数据不足(需要至少30天)'}
last = df.iloc[-1]
cl = float(last['close'])
n = len(df)
# ---- 1. 均线系统 ----
ma_data = {}
for period in [5, 10, 20, 60]:
col = f'ma{period}'
if col in df.columns and n >= period:
ma_data[f'ma{period}'] = round(float(df[col].iloc[-1]), 2)
ma_list = sorted(ma_data.items(), key=lambda x: x[1], reverse=True)
ma_trend = 'bullish' if all(
ma_data.get(f'ma{a}', 0) >= ma_data.get(f'ma{b}', 0)
for a, b in [(5, 10), (10, 20)]
) else 'bearish' if all(
ma_data.get(f'ma{a}', 0) <= ma_data.get(f'ma{b}', 0)
for a, b in [(5, 10), (10, 20)]
) else 'mixed'
ma_trend_label = {'bullish': '多头排列', 'bearish': '空头排列', 'mixed': '交叉整理'}
# ---- 2. 价格位置分析 ----
position = {}
for days in [20, 60, 120]:
subset = df.tail(days) if n >= days else df
h = float(subset['high'].max())
l = float(subset['low'].min())
rng = h - l
pct = round((cl - l) / rng * 100, 0) if rng > 0 else 50
position[f'd{days}'] = {
'high': round(h, 2), 'low': round(l, 2),
'range_pct': pct,
'up_space': round((h / cl - 1) * 100, 1),
'down_risk': round((1 - l / cl) * 100, 1),
}
# ---- 3. 支撑与压力位 ----
supports = []
resistances = []
for name, val in ma_data.items():
if val < cl:
supports.append({'level': val, 'type': 'ma', 'name': name.upper()})
elif val > cl:
resistances.append({'level': val, 'type': 'ma', 'name': name.upper()})
for days_key in ['d20', 'd60', 'd120']:
p = position.get(days_key, {})
label = days_key.replace('d', '') + ''
if p.get('low', 0) < cl:
supports.append({'level': p['low'], 'type': 'low', 'name': f'{label}低点'})
if p.get('high', 0) > cl:
resistances.append({'level': p['high'], 'type': 'high', 'name': f'{label}高点'})
supports.sort(key=lambda x: x['level'], reverse=True)
resistances.sort(key=lambda x: x['level'])
# ---- 4. 成交量分析 ----
vol = float(last['volume'])
vol_5 = float(df['volume'].tail(5).mean()) if n >= 5 else vol
vol_20 = float(df['volume'].tail(20).mean()) if n >= 20 else vol
vol_ratio = round(vol / vol_20, 1) if vol_20 > 0 else 1.0
vol_trend = '缩量' if vol_ratio < 0.7 else '平量' if vol_ratio < 1.3 else '温和放量' if vol_ratio < 2.0 else '大幅放量'
# ---- 5. 形态识别(增强版) ----
patterns = []
closes_10 = [float(x) for x in df['close'].tail(10)]
if n >= 10:
std_10 = np.std(closes_10)
mean_10 = np.mean(closes_10)
cv_10 = std_10 / mean_10 if mean_10 > 0 else 0
if cv_10 < 0.015 and cl > max(closes_10[:-1]):
patterns.append({'name': '平台突破', 'bullish': True,
'desc': f'近10日波动率仅{cv_10*100:.1f}%,今日突破平台'})
elif cv_10 < 0.015:
patterns.append({'name': '窄幅整理', 'bullish': None,
'desc': f'近10日波动率{cv_10*100:.1f}%,蓄势待变'})
if n >= 20:
h20 = float(df.tail(20)['high'].max())
if cl >= h20 * 0.99:
patterns.append({'name': '创20日新高', 'bullish': True,
'desc': f'触及20日高点{h20:.2f}'})
# 双底形态:近30日内两个低点价格接近(差异<3%),且当前价格高于两低点之间的高点
if n >= 30:
lows_30 = [float(x) for x in df['low'].tail(30)]
# 找最低点和次低点
min_idx = int(np.argmin(lows_30))
min_val = lows_30[min_idx]
# 在最低点之前找次低点
if min_idx > 5:
before_lows = lows_30[:min_idx]
second_min_idx = int(np.argmin(before_lows))
second_min_val = before_lows[second_min_idx]
if abs(min_val - second_min_val) / min_val < 0.03:
# 两低点之间的高点
between_high = max(lows_30[second_min_idx:min_idx])
if cl > between_high:
patterns.append({'name': '双底突破', 'bullish': True,
'desc': f'双底形态(低点{min_val:.2f}{second_min_val:.2f}),已突破颈线{between_high:.2f}'})
# 量价齐升:近5日成交量递增且价格递增
if n >= 5:
vols_5 = [float(x) for x in df['volume'].tail(5)]
closes_5 = [float(x) for x in df['close'].tail(5)]
if all(vols_5[i] <= vols_5[i+1] for i in range(len(vols_5)-1)) and \
all(closes_5[i] <= closes_5[i+1] for i in range(len(closes_5)-1)):
patterns.append({'name': '量价齐升', 'bullish': True,
'desc': '近5日成交量与价格同步递增,强势特征'})
# 均线粘合后发散:MA5/10/20 三线粘合后开始发散
if n >= 20:
ma5_val = ma_data.get('ma5', 0)
ma10_val = ma_data.get('ma10', 0)
ma20_val = ma_data.get('ma20', 0)
if ma5_val and ma10_val and ma20_val:
ma_spread = max(ma5_val, ma10_val, ma20_val) - min(ma5_val, ma10_val, ma20_val)
ma_pct = ma_spread / cl * 100
if ma_pct < 1.0 and ma5_val > ma10_val > ma20_val:
patterns.append({'name': '均线粘合发散', 'bullish': True,
'desc': f'MA5/10/20粘合(离散{ma_pct:.1f}%)后多头排列'})
# 涨跌幅计算:如果最后一条是今天(可能未收盘),用前一日收盘价计算
from datetime import date
last_date_str = str(df['date'].values[-1])[:10]
today_str = date.today().isoformat()
if last_date_str == today_str and n >= 3:
# 今天未收盘,用倒数第二根K线的收盘价对比倒数第三根
change_today = round((cl / float(df.iloc[-2]['close']) - 1) * 100, 2)
else:
change_today = round((cl / float(df.iloc[-2]['close']) - 1) * 100, 2) if n >= 2 else 0
if change_today >= 5:
patterns.append({'name': '大阳线', 'bullish': True,
'desc': f'涨幅{change_today:.1f}%'})
elif change_today <= -5:
patterns.append({'name': '大阴线', 'bullish': False,
'desc': f'跌幅{change_today:.1f}%'})
# ---- 6. 空间估算 ----
first_resist = resistances[0] if resistances else None
first_support = supports[0] if supports else None
space = {
'nearest_resist': first_resist,
'nearest_support': first_support,
'risk_reward': None,
}
if first_resist and first_support:
upside = first_resist['level'] - cl
downside = cl - first_support['level']
space['risk_reward'] = round(upside / downside, 1) if downside > 0 else 99
# ---- 7. 综合评估 ----
score = 50
reasons = []
if ma_trend == 'bullish':
score += 10
reasons.append('均线多头排列(+10)')
elif ma_trend == 'bearish':
score -= 10
reasons.append('均线空头排列(-10)')
if vol_ratio >= 1.3:
score += 5
reasons.append(f'放量{vol_ratio}倍(+5)')
elif vol_ratio < 0.6:
score -= 3
reasons.append(f'缩量{vol_ratio}倍(-3)')
any_breakout = any(p['name'] == '平台突破' for p in patterns)
if any_breakout:
score += 10
reasons.append('平台突破(+10)')
any_new_high = any(p['name'] == '创20日新高' for p in patterns)
if any_new_high:
score += 5
reasons.append('创20日新高(+5)')
any_double_bottom = any(p['name'] == '双底突破' for p in patterns)
if any_double_bottom:
score += 10
reasons.append('双底突破(+10)')
any_vol_price_rise = any(p['name'] == '量价齐升' for p in patterns)
if any_vol_price_rise:
score += 8
reasons.append('量价齐升(+8)')
any_ma_converge = any(p['name'] == '均线粘合发散' for p in patterns)
if any_ma_converge:
score += 7
reasons.append('均线粘合发散(+7)')
pos_120 = position.get('d120', {}).get('range_pct', 50)
if pos_120 < 30:
score += 5
reasons.append(f'120日位置偏低{pos_120}%(+5)')
elif pos_120 > 80:
score -= 5
reasons.append(f'120日位置偏高{pos_120}%(-5)')
# 20日位置也纳入评分
pos_20 = position.get('d20', {}).get('range_pct', 50)
if pos_20 < 25:
score += 3
reasons.append(f'20日位置偏低{pos_20}%(+3)')
elif pos_20 > 85:
score -= 3
reasons.append(f'20日位置偏高{pos_20}%(-3)')
if signal_result:
sig_count = signal_result.get('signal_summary', {}).get('total_signals', 0)
if sig_count >= 3:
score += 15
reasons.append(f'{sig_count}信号共振(+15)')
elif sig_count >= 2:
score += 10
reasons.append(f'{sig_count}信号叠加(+10)')
elif sig_count >= 1:
score += 5
reasons.append(f'{sig_count}个信号(+5)')
if space.get('risk_reward') and space['risk_reward'] >= 2:
score += 5
reasons.append(f'风险收益比{space["risk_reward"]}:1(+5)')
elif space.get('risk_reward') and space['risk_reward'] < 0.8:
score -= 5
reasons.append(f'风险收益比{space["risk_reward"]}:1(-5)')
score = max(0, min(100, score))
verdict = '强烈看多' if score >= 80 else '看多' if score >= 65 else '中性偏多' if score >= 50 else '中性偏空' if score >= 35 else '看空'
ai_summary = _generate_plain_summary(
cl, change_today, ma_trend, position, supports, resistances,
vol_ratio, vol_trend, patterns, space, score, verdict, reasons
)
return {
'price': cl,
'change_pct': change_today,
'ma': ma_data,
'ma_trend': ma_trend,
'ma_trend_label': ma_trend_label[ma_trend],
'position': position,
'supports': supports[:5],
'resistances': resistances[:5],
'volume': {
'today': vol,
'avg_5': round(vol_5),
'avg_20': round(vol_20),
'ratio': vol_ratio,
'trend': vol_trend,
},
'patterns': patterns,
'space': space,
'deep_score': score,
'verdict': verdict,
'score_reasons': reasons,
'ai_summary': ai_summary,
'kline_days': n,
}