claude强化功能

This commit is contained in:
2026-06-14 11:54:45 +08:00
parent cc8dff4e57
commit e524a3589a
43 changed files with 13421 additions and 73 deletions

542
backend/ai_chat.py Normal file
View File

@@ -0,0 +1,542 @@
"""AI 对话式分析 — 自然语言交互的炒股助手。
功能:
1. 自然语言选股
2. 持仓诊断对话
3. 策略建议
4. 实时问答
5. 上下文记忆(多轮对话)
"""
import json
from typing import List, Dict, Any, Optional
from datetime import datetime, date
import llm
import smart_selector as selector
import portfolio as pf
import ai
import sector_rotation as sector
from db import get_session
from models import StockMetric, Trade
# 会话上下文存储
_SESSIONS = {} # {session_id: {"messages": [], "context": {}}}
def get_or_create_session(session_id: str) -> Dict:
"""获取或创建会话"""
if session_id not in _SESSIONS:
_SESSIONS[session_id] = {
"messages": [],
"context": {},
"created_at": datetime.now().isoformat()
}
return _SESSIONS[session_id]
def chat(session_id: str, user_message: str) -> Dict[str, Any]:
"""AI对话主入口
Args:
session_id: 会话ID
user_message: 用户消息
Returns:
AI回复
"""
if not llm.enabled():
return {
"ok": False,
"msg": "大模型未配置,请在 backend/.env 中配置 LLM_API_KEY",
"text": "抱歉AI对话功能需要配置大模型。您可以\n1. 配置 .env 中的 LLM_API_KEY\n2. 使用其他功能模块(选股、回测、板块分析等)"
}
session = get_or_create_session(session_id)
# 添加用户消息到历史
session["messages"].append({"role": "user", "content": user_message})
# 意图识别 + Function Calling
try:
response = _process_message(session, user_message)
# 添加助手回复到历史
session["messages"].append({"role": "assistant", "content": response["text"]})
# 限制历史长度保留最近20轮
if len(session["messages"]) > 40:
session["messages"] = session["messages"][-40:]
return response
except Exception as e:
return {
"ok": False,
"msg": str(e),
"text": f"处理消息时出错:{str(e)}"
}
def _process_message(session: Dict, message: str) -> Dict[str, Any]:
"""处理用户消息,识别意图并调用相应功能"""
# 构建系统提示
system_prompt = """你是Blackdata股票分析助手擅长A股分析和投资建议。
你可以调用以下功能通过JSON格式返回
1. 选股功能
格式:{"action": "select_stocks", "conditions": {"涨幅": ">10", "量比": ">2", ...}, "description": "..."}
示例:"帮我找近期突破的科技股" -> 识别为选股需求
2. 持仓诊断
格式:{"action": "diagnose_portfolio"}
示例:"我的持仓有什么风险?"
3. 策略建议
格式:{"action": "strategy_advice"}
示例:"当前市场适合什么策略?"
4. 个股分析
格式:{"action": "analyze_stock", "code": "600519"}
示例:"分析一下贵州茅台"
5. 板块分析
格式:{"action": "analyze_sector", "name": "半导体"}
示例:"半导体板块怎么样?"
6. 普通对话
格式:{"action": "chat", "text": "..."}
示例:闲聊、问候等
请根据用户问题,先判断意图,然后:
- 如果需要调用功能返回JSON格式的action
- 如果是普通对话,直接回答
重要:
- 如果用户问题包含"""""筛选"等词,考虑选股功能
- 如果问"我的持仓""风险",调用持仓诊断
- 如果问"策略""怎么操作",给策略建议
- 股票代码格式6位数字
"""
# 构建对话历史
messages = [{"role": "system", "content": system_prompt}]
# 添加最近的对话历史最多10轮
recent_messages = session["messages"][-20:] if len(session["messages"]) > 20 else session["messages"]
messages.extend(recent_messages)
# 调用大模型
try:
response_text = llm.ask_with_messages(messages, temperature=0.7, max_tokens=1500)
# 尝试解析为JSON
action = _parse_action(response_text)
if action:
return _execute_action(action, session)
else:
# 纯文本回复
return {
"ok": True,
"type": "chat",
"text": response_text
}
except Exception as e:
return {
"ok": False,
"text": f"AI处理失败{str(e)}"
}
def _parse_action(text: str) -> Optional[Dict]:
"""解析AI回复中的action"""
try:
# 查找JSON块
if "{" in text and "}" in text:
start = text.find("{")
end = text.rfind("}") + 1
json_str = text[start:end]
return json.loads(json_str)
except:
pass
return None
def _execute_action(action: Dict, session: Dict) -> Dict[str, Any]:
"""执行具体功能"""
action_type = action.get("action")
if action_type == "select_stocks":
return _handle_select_stocks(action, session)
elif action_type == "diagnose_portfolio":
return _handle_diagnose_portfolio(session)
elif action_type == "strategy_advice":
return _handle_strategy_advice(session)
elif action_type == "analyze_stock":
return _handle_analyze_stock(action, session)
elif action_type == "analyze_sector":
return _handle_analyze_sector(action, session)
elif action_type == "chat":
return {
"ok": True,
"type": "chat",
"text": action.get("text", "我是Blackdata AI助手有什么可以帮你")
}
else:
return {
"ok": False,
"text": "抱歉,我不太理解您的问题。您可以问我:\n- 帮我选股\n- 我的持仓怎么样\n- 给我策略建议\n- 分析某个股票或板块"
}
def _handle_select_stocks(action: Dict, session: Dict) -> Dict[str, Any]:
"""处理选股请求"""
# 从自然语言提取条件
description = action.get("description", "")
conditions = action.get("conditions", {})
# 构建选股策略
strategy = selector.Strategy("AI选股", description)
# 将条件转换为选股条件
for field, op_value in conditions.items():
field_map = {
"涨幅": "pct",
"5日涨幅": "ret5",
"20日涨幅": "ret20",
"量比": "vol_ratio",
"成交额": "amount",
"RSI": "rsi14",
"价格": "close"
}
if field in field_map:
actual_field = field_map[field]
# 解析操作符和值
if isinstance(op_value, str):
if op_value.startswith(">"):
op = ">"
val = float(op_value[1:].strip())
elif op_value.startswith("<"):
op = "<"
val = float(op_value[1:].strip())
else:
continue
strategy.add_condition(actual_field, op, val)
# 如果没有条件,添加默认条件
if not strategy.conditions:
strategy.add_condition("ret5", ">", 5)
strategy.add_condition("vol_ratio", ">", 1.5)
# 执行选股
result = selector.run_selector(strategy)
if not result["ok"]:
return {
"ok": False,
"text": f"选股失败:{result.get('msg', '未知错误')}"
}
# 保存选股结果到上下文
session["context"]["last_selection"] = result["results"][:10]
# 格式化回复
stocks = result["results"][:10]
if not stocks:
text = "根据您的条件,暂时没有找到符合的股票。您可以:\n1. 放宽筛选条件\n2. 尝试其他板块\n3. 等待市场出现机会"
else:
text = f"为您找到 {result['count']} 只股票以下是前10只\n\n"
for i, s in enumerate(stocks, 1):
text += f"{i}. {s['name']}{s['code']}\n"
text += f" 现价:{s['close']}元 涨跌:{s['pct']:+.2f}% 5日{s['ret5']:+.2f}%\n"
text += f" 量比:{s['vol_ratio']:.2f} 成交额:{s['amount']:.1f}亿\n\n"
text += "💡 您可以继续问我:\n- 分析某只股票(如\"分析第1只\"\n- 回测这个策略\n- 看看其他板块"
return {
"ok": True,
"type": "select_stocks",
"text": text,
"data": stocks
}
def _handle_diagnose_portfolio(session: Dict) -> Dict[str, Any]:
"""处理持仓诊断"""
try:
portfolio = pf.compute()
if not portfolio["holdings"]:
return {
"ok": True,
"type": "diagnose",
"text": "您当前没有持仓。建议:\n1. 先在「交易日志」录入交易记录\n2. 或者问我\"帮我选股\"来寻找投资机会"
}
summary = portfolio["summary"]
holdings = portfolio["holdings"]
# 分析持仓
total_unrealized = summary["unrealized"]
win_rate = summary["win_rate"]
# 风险诊断
risks = []
# 1. 浮亏检查
losing_positions = [h for h in holdings if h["unrealized"] < 0]
if len(losing_positions) > len(holdings) / 2:
risks.append(f"⚠️ 超过一半的持仓处于浮亏状态({len(losing_positions)}/{len(holdings)}只)")
# 2. 集中度检查
if len(holdings) < 3:
risks.append("⚠️ 持仓过于集中,建议分散投资")
# 3. 胜率检查
if win_rate < 40:
risks.append(f"⚠️ 历史胜率较低({win_rate}%),建议反思选股策略")
# 构建回复
text = f"📊 持仓诊断报告\n\n"
text += f"持仓数量:{summary['positions']}\n"
text += f"持仓市值:{summary['market_value']:.2f}\n"
text += f"浮动盈亏:{total_unrealized:+.2f}\n"
text += f"历史胜率:{win_rate}%\n\n"
if risks:
text += "⚠️ 风险提示:\n"
for risk in risks:
text += f"{risk}\n"
text += "\n"
# 前5大持仓
text += "📈 前5大持仓\n"
for i, h in enumerate(holdings[:5], 1):
pnl_sign = "+" if h["unrealized"] >= 0 else ""
text += f"{i}. {h['name']} {pnl_sign}{h['unrealized_pct']:.2f}% {pnl_sign}{h['unrealized']:.0f}\n"
text += "\n💡 建议:\n"
if risks:
text += "- 考虑止损浮亏较大的股票\n"
text += "- 增加持仓分散度\n"
else:
text += "- 当前持仓状况良好,继续关注\n"
text += "- 定期复盘,总结经验\n"
# 保存到上下文
session["context"]["portfolio"] = holdings
return {
"ok": True,
"type": "diagnose",
"text": text,
"data": portfolio
}
except Exception as e:
return {
"ok": False,
"text": f"持仓诊断失败:{str(e)}"
}
def _handle_strategy_advice(session: Dict) -> Dict[str, Any]:
"""处理策略建议"""
try:
# 获取市场情绪
summary = sector.get_rotation_summary()
if not summary.get("ok"):
return {
"ok": False,
"text": "暂时无法获取市场数据,请稍后再试"
}
strongest = summary.get("strongest_sectors", [])
weakest = summary.get("weakest_sectors", [])
# 构建策略建议
text = "📋 当前市场策略建议\n\n"
text += "🔥 强势板块:\n"
for s in strongest[:3]:
text += f"- {s['name']} {s['return_10d']:+.2f}%\n"
text += "\n"
text += "📉 弱势板块:\n"
for s in weakest[:3]:
text += f"- {s['name']} {s['return_10d']:+.2f}%\n"
text += "\n"
# 策略建议
avg_return = sum(s['return_10d'] for s in strongest[:3]) / 3 if strongest else 0
if avg_return > 10:
text += "💡 策略建议:\n"
text += "- 市场情绪较好,适合进攻型策略\n"
text += "- 可关注强势板块的龙头股\n"
text += "- 设置好止盈点,及时落袋为安\n"
elif avg_return > 0:
text += "💡 策略建议:\n"
text += "- 市场震荡,适合波段操作\n"
text += "- 追踪强势板块,低吸高抛\n"
text += "- 控制仓位,分批建仓\n"
else:
text += "💡 策略建议:\n"
text += "- 市场偏弱,以防守为主\n"
text += "- 减仓观望,等待机会\n"
text += "- 关注超跌板块的反弹机会\n"
text += "\n🎯 具体操作:\n"
text += "- 可以问我\"帮我找[强势板块]的股票\"\n"
text += "- 或\"分析[某个板块]\"\n"
return {
"ok": True,
"type": "strategy",
"text": text,
"data": summary
}
except Exception as e:
return {
"ok": False,
"text": f"策略建议失败:{str(e)}"
}
def _handle_analyze_stock(action: Dict, session: Dict) -> Dict[str, Any]:
"""处理个股分析"""
code = action.get("code", "").strip()
if not code:
# 从上下文中获取
last_selection = session["context"].get("last_selection", [])
if last_selection:
code = last_selection[0]["code"]
else:
return {
"ok": False,
"text": "请指定股票代码,例如\"分析600519\""
}
try:
result = ai.diagnose(code)
if not result["ok"]:
return {
"ok": False,
"text": f"分析失败:{result.get('msg', '未知错误')}"
}
# 格式化回复
text = f"📊 {result['name']}{result['symbol']}AI诊断\n\n"
text += f"综合评分:{result['total']}\n"
text += f"预测方向:{'看多' if result['direction'] == 'up' else ('看空' if result['direction'] == 'down' else '中性')}\n"
text += f"置信度:{result['confidence']}%\n\n"
text += "📈 各维度评分:\n"
for dim, score in result["scores"].items():
text += f"- {dim}{score}\n"
text += f"\n💬 {result['text'][:300]}...\n"
text += "\n💡 完整分析请在「AI分析 → 个股诊断」页面查看"
return {
"ok": True,
"type": "analyze_stock",
"text": text,
"data": result
}
except Exception as e:
return {
"ok": False,
"text": f"分析失败:{str(e)}"
}
def _handle_analyze_sector(action: Dict, session: Dict) -> Dict[str, Any]:
"""处理板块分析"""
sector_name = action.get("name", "").strip()
if not sector_name:
return {
"ok": False,
"text": "请指定板块名称,例如\"分析半导体板块\""
}
try:
result = sector.analyze_lifecycle(sector_name, days=60)
if not result["ok"]:
return {
"ok": False,
"text": f"分析失败:{result.get('msg', '未知错误')}"
}
# 格式化回复
text = f"📊 {result['sector']} 板块分析\n\n"
text += f"生命周期:{result['phase']}\n"
text += f"{result['description']}\n\n"
metrics = result["metrics"]
text += f"📈 近期表现:\n"
text += f"- 5日涨幅{metrics['return_5d']:+.2f}%\n"
text += f"- 20日涨幅{metrics['return_20d']:+.2f}%\n"
text += f"- 成交额变化:{metrics['amount_change']:+.2f}%\n\n"
# 龙头股
leaders = sector.identify_leaders(sector_name, limit=5)
if leaders["ok"] and leaders["leaders"]:
text += "🏆 龙头股:\n"
for i, l in enumerate(leaders["leaders"][:3], 1):
text += f"{i}. {l['name']} {l['ret20']:+.2f}%\n"
text += "\n💡 您可以继续问:\n"
text += f"- 帮我找{sector_name}板块的股票\n"
text += f"- {sector_name}龙头股有哪些\n"
return {
"ok": True,
"type": "analyze_sector",
"text": text,
"data": result
}
except Exception as e:
return {
"ok": False,
"text": f"分析失败:{str(e)}"
}
def clear_session(session_id: str):
"""清空会话"""
if session_id in _SESSIONS:
del _SESSIONS[session_id]
def get_session_history(session_id: str) -> List[Dict]:
"""获取会话历史"""
session = _SESSIONS.get(session_id)
if session:
return session["messages"]
return []

View File

@@ -60,7 +60,7 @@ def check_alerts():
# 触发后向已配置渠道推送(站外)
if push_msgs and notifier.any_enabled():
try:
notifier.notify("智策预警】" + (push_msgs[0] if len(push_msgs) == 1 else f"{len(push_msgs)} 条预警触发"),
notifier.notify("Blackdata预警】" + (push_msgs[0] if len(push_msgs) == 1 else f"{len(push_msgs)} 条预警触发"),
"\n".join(push_msgs))
except Exception:
pass

View File

@@ -0,0 +1,437 @@
"""持仓归因分析深化 — 选股/择时能力、持仓时长、理由有效性分析。
功能:
1. 收益归因分解(选股 vs 择时 vs 运气)
2. 持仓时长分析(短线/中线/长线胜率)
3. 买入理由有效性验证
4. 情绪标签相关性分析
5. 对标指数超额收益拆解
"""
import datetime as dt
from typing import Dict, List, Any, Tuple
from collections import defaultdict
import numpy as np
from sqlalchemy import select, and_
from db import get_session
from models import Trade, DailyQuote, IndexDaily, StockMetric
def analyze_attribution() -> Dict[str, Any]:
"""综合归因分析"""
with get_session() as s:
trades = s.execute(select(Trade).order_by(Trade.date, Trade.id)).scalars().all()
if not trades:
return {"ok": False, "msg": "暂无交易记录"}
stock_timing = analyze_stock_vs_timing(trades)
hold_period = analyze_hold_period(trades)
reason_valid = analyze_reason_validity(trades)
emotion_corr = analyze_emotion_correlation(trades)
excess_return = analyze_excess_return(trades)
return {
"ok": True,
"stock_vs_timing": stock_timing,
"hold_period": hold_period,
"reason_validity": reason_valid,
"emotion_correlation": emotion_corr,
"excess_return": excess_return,
}
def analyze_stock_vs_timing(trades: List[Trade]) -> Dict[str, Any]:
"""分解选股能力 vs 择时能力
选股能力:买入后股票的整体涨幅(持有期间市场表现)
择时能力:实际买卖点的精准度(买在低点、卖在高点)
运气成分:市场整体波动的影响
"""
with get_session() as s:
stock_trades = defaultdict(list)
for t in trades:
stock_trades[t.code].append(t)
results = []
total_stock_contrib = 0.0
total_timing_contrib = 0.0
for code, stock_trades_list in stock_trades.items():
dates = [t.date for t in stock_trades_list]
start = min(dates)
end = max(dates)
prices = {}
for d, close in s.execute(
select(DailyQuote.date, DailyQuote.close)
.where(
and_(
DailyQuote.code == code,
DailyQuote.date >= start,
DailyQuote.date <= end,
)
)
.order_by(DailyQuote.date)
).all():
prices[d] = float(close)
if not prices:
continue
first_price = prices[min(prices.keys())]
last_price = prices[max(prices.keys())]
stock_return = (last_price / first_price - 1) * 100
buys = [t for t in stock_trades_list if t.side == "buy"]
sells = [t for t in stock_trades_list if t.side == "sell"]
if buys and sells:
avg_buy = np.mean([t.price for t in buys])
avg_sell = np.mean([t.price for t in sells])
ideal_buy = min(prices.values())
ideal_sell = max(prices.values())
buy_timing = (
(1 - (avg_buy - ideal_buy) / (ideal_sell - ideal_buy)) * 100
if ideal_sell > ideal_buy
else 50
)
sell_timing = (
((avg_sell - ideal_buy) / (ideal_sell - ideal_buy)) * 100
if ideal_sell > ideal_buy
else 50
)
timing_score = (buy_timing + sell_timing) / 2
actual_return = (avg_sell / avg_buy - 1) * 100
results.append(
{
"code": code,
"stock_return": round(stock_return, 2),
"timing_score": round(timing_score, 1),
"actual_return": round(actual_return, 2),
}
)
total_stock_contrib += stock_return
total_timing_contrib += timing_score
if not results:
return {"ok": False, "msg": "数据不足"}
avg_stock = total_stock_contrib / len(results)
avg_timing = total_timing_contrib / len(results)
return {
"ok": True,
"stock_ability": round(avg_stock, 2),
"timing_ability": round(avg_timing, 1),
"interpretation": {
"stock": "正值表示选对了股票(股票整体上涨),负值表示选错了",
"timing": "100分满分表示买卖点的精准度50分为平均水平",
},
"by_stock": results,
}
def analyze_hold_period(trades: List[Trade]) -> Dict[str, Any]:
"""持仓时长分析
短线:持仓 <= 5天
中线:持仓 6-30天
长线:持仓 > 30天
"""
holdings = defaultdict(list)
closed_trades = []
for t in trades:
if t.side == "buy":
holdings[t.code].append({"trade": t, "qty": t.qty})
else:
remaining = t.qty
while remaining > 0 and holdings[t.code]:
hold = holdings[t.code][0]
sell_qty = min(remaining, hold["qty"])
hold_days = (t.date - hold["trade"].date).days
pnl = (t.price - hold["trade"].price) * sell_qty - t.fee * (
sell_qty / t.qty
)
pnl_pct = (t.price / hold["trade"].price - 1) * 100
closed_trades.append(
{
"code": t.code,
"buy_date": hold["trade"].date,
"sell_date": t.date,
"hold_days": hold_days,
"buy_price": hold["trade"].price,
"sell_price": t.price,
"qty": sell_qty,
"pnl": pnl,
"pnl_pct": pnl_pct,
}
)
hold["qty"] -= sell_qty
remaining -= sell_qty
if hold["qty"] <= 0:
holdings[t.code].pop(0)
if not closed_trades:
return {"ok": False, "msg": "暂无已平仓交易"}
short_term = [t for t in closed_trades if t["hold_days"] <= 5]
mid_term = [t for t in closed_trades if 6 <= t["hold_days"] <= 30]
long_term = [t for t in closed_trades if t["hold_days"] > 30]
def calc_stats(trades_list):
if not trades_list:
return {"count": 0, "win_rate": 0, "avg_return": 0, "avg_days": 0}
wins = sum(1 for t in trades_list if t["pnl"] > 0)
return {
"count": len(trades_list),
"win_rate": round(wins / len(trades_list) * 100, 1),
"avg_return": round(np.mean([t["pnl_pct"] for t in trades_list]), 2),
"avg_days": round(np.mean([t["hold_days"] for t in trades_list]), 1),
"total_pnl": round(sum(t["pnl"] for t in trades_list), 2),
}
return {
"ok": True,
"short_term": calc_stats(short_term),
"mid_term": calc_stats(mid_term),
"long_term": calc_stats(long_term),
"recommendation": _recommend_hold_period(short_term, mid_term, long_term),
}
def _recommend_hold_period(short, mid, long) -> str:
"""推荐最佳持仓周期"""
periods = [
("短线≤5天", short),
("中线6-30天", mid),
("长线(>30天", long),
]
if not any(p for _, p in periods):
return "数据不足"
scores = []
for name, trades_list in periods:
if not trades_list:
scores.append((name, 0))
continue
wins = sum(1 for t in trades_list if t["pnl"] > 0)
win_rate = wins / len(trades_list)
avg_ret = np.mean([t["pnl_pct"] for t in trades_list])
score = win_rate * 50 + (avg_ret / 10) * 50
scores.append((name, score))
scores.sort(key=lambda x: x[1], reverse=True)
best = scores[0][0]
return f"建议重点关注{best},该周期胜率和收益表现最佳"
def analyze_reason_validity(trades: List[Trade]) -> Dict[str, Any]:
"""买入理由有效性验证"""
holdings = defaultdict(list)
reason_stats = defaultdict(lambda: {"trades": [], "wins": 0, "total_pnl": 0})
for t in trades:
if t.side == "buy":
holdings[t.code].append(t)
else:
while t.qty > 0 and holdings[t.code]:
buy = holdings[t.code].pop(0)
qty = min(t.qty, buy.qty)
pnl = (t.price - buy.price) * qty
pnl_pct = (t.price / buy.price - 1) * 100
reason = buy.reason or "未标注"
reason_stats[reason]["trades"].append(pnl_pct)
reason_stats[reason]["total_pnl"] += pnl
if pnl > 0:
reason_stats[reason]["wins"] += 1
buy.qty -= qty
t.qty -= qty
if buy.qty > 0:
holdings[t.code].insert(0, buy)
if not reason_stats:
return {"ok": False, "msg": "暂无已平仓交易"}
results = []
for reason, stats in reason_stats.items():
trades_list = stats["trades"]
results.append(
{
"reason": reason,
"count": len(trades_list),
"win_rate": round(stats["wins"] / len(trades_list) * 100, 1),
"avg_return": round(np.mean(trades_list), 2),
"total_pnl": round(stats["total_pnl"], 2),
"effectiveness": "有效"
if stats["wins"] / len(trades_list) > 0.5
else "无效",
}
)
results.sort(key=lambda x: x["win_rate"], reverse=True)
return {
"ok": True,
"by_reason": results,
"best_reason": results[0]["reason"] if results else None,
"worst_reason": results[-1]["reason"] if results else None,
}
def analyze_emotion_correlation(trades: List[Trade]) -> Dict[str, Any]:
"""情绪标签相关性分析"""
holdings = defaultdict(list)
emotion_stats = defaultdict(lambda: {"trades": [], "wins": 0, "total_pnl": 0})
for t in trades:
if t.side == "buy":
holdings[t.code].append(t)
else:
while t.qty > 0 and holdings[t.code]:
buy = holdings[t.code].pop(0)
qty = min(t.qty, buy.qty)
pnl = (t.price - buy.price) * qty
pnl_pct = (t.price / buy.price - 1) * 100
emotion = buy.emotion or "未标注"
emotion_stats[emotion]["trades"].append(pnl_pct)
emotion_stats[emotion]["total_pnl"] += pnl
if pnl > 0:
emotion_stats[emotion]["wins"] += 1
buy.qty -= qty
t.qty -= qty
if buy.qty > 0:
holdings[t.code].insert(0, buy)
if not emotion_stats:
return {"ok": False, "msg": "暂无已平仓交易"}
results = []
for emotion, stats in emotion_stats.items():
trades_list = stats["trades"]
results.append(
{
"emotion": emotion,
"count": len(trades_list),
"win_rate": round(stats["wins"] / len(trades_list) * 100, 1),
"avg_return": round(np.mean(trades_list), 2),
"total_pnl": round(stats["total_pnl"], 2),
}
)
results.sort(key=lambda x: x["avg_return"], reverse=True)
return {
"ok": True,
"by_emotion": results,
"advice": _generate_emotion_advice(results),
}
def _generate_emotion_advice(results: List[Dict]) -> str:
"""生成情绪建议"""
if not results:
return "数据不足"
best = results[0]
worst = results[-1]
advice = (
f"最佳情绪状态:{best['emotion']}(胜率{best['win_rate']}%"
f"平均收益{best['avg_return']}%\n"
)
advice += (
f"最差情绪状态:{worst['emotion']}(胜率{worst['win_rate']}%"
f"平均收益{worst['avg_return']}%\n"
)
advice += "\n建议:保持理性和纪律,避免在贪婪或恐慌时做决策"
return advice
def analyze_excess_return(trades: List[Trade]) -> Dict[str, Any]:
"""对标指数超额收益拆解"""
if not trades:
return {"ok": False, "msg": "暂无交易记录"}
start_date = min(t.date for t in trades)
end_date = max(t.date for t in trades)
with get_session() as s:
index_data = s.execute(
select(IndexDaily.date, IndexDaily.close)
.where(
and_(
IndexDaily.code == "sh000300",
IndexDaily.date >= start_date,
IndexDaily.date <= end_date,
)
)
.order_by(IndexDaily.date)
).all()
if not index_data:
return {"ok": False, "msg": "缺少指数数据"}
index_start = float(index_data[0][1])
index_end = float(index_data[-1][1])
index_return = (index_end / index_start - 1) * 100
holdings = defaultdict(lambda: {"qty": 0, "cost": 0.0})
realized_pnl = 0.0
total_cost = 0.0
for t in trades:
p = holdings[t.code]
if t.side == "buy":
p["cost"] += t.price * t.qty + t.fee
p["qty"] += t.qty
total_cost += t.price * t.qty + t.fee
else:
if p["qty"] > 0:
avg = p["cost"] / p["qty"]
qty = min(t.qty, p["qty"])
pnl = (t.price - avg) * qty - t.fee
realized_pnl += pnl
p["cost"] -= avg * qty
p["qty"] -= qty
portfolio_return = (realized_pnl / total_cost * 100) if total_cost > 0 else 0
excess_return = portfolio_return - index_return
if excess_return > 0:
source = "选股能力贡献"
interpretation = "组合表现优于大盘,说明选股和择时能力较好"
elif excess_return < -5:
source = "选股/择时失误"
interpretation = "组合表现明显弱于大盘,建议反思选股逻辑和买卖时机"
else:
source = "与大盘持平"
interpretation = "组合表现与大盘接近,可考虑增强选股策略"
return {
"ok": True,
"portfolio_return": round(portfolio_return, 2),
"index_return": round(index_return, 2),
"excess_return": round(excess_return, 2),
"source": source,
"interpretation": interpretation,
"period": f"{start_date} ~ {end_date}",
}

View File

@@ -0,0 +1,499 @@
"""增强版回测引擎 — 多因子策略、仓位管理、参数优化。
支持功能:
1. 多因子组合策略(技术+基本面)
2. 仓位管理(固定、金字塔、凯利公式)
3. 止损止盈
4. 参数网格优化
5. 完整指标(夏普比率、最大回撤、卡玛比率等)
6. 交易明细导出
"""
import datetime as dt
from typing import Dict, List, Any, Optional, Callable
import numpy as np
from sqlalchemy import select
from db import get_session
from models import DailyQuote, StockMetric
class Position:
"""持仓记录"""
def __init__(self, date, price, shares, reason=""):
self.entry_date = date
self.entry_price = price
self.shares = shares
self.reason = reason
self.exit_date = None
self.exit_price = None
self.pnl = 0.0
self.pnl_pct = 0.0
self.hold_days = 0
class BacktestEngine:
"""增强回测引擎"""
def __init__(self, initial_capital: float = 100000.0, commission: float = 0.0005):
self.initial_capital = initial_capital
self.commission = commission
# 账户状态
self.cash = initial_capital
self.positions: List[Position] = []
self.closed_positions: List[Position] = []
# 净值曲线
self.equity_curve = []
self.dates = []
# 统计
self.trades = 0
self.wins = 0
self.total_pnl = 0.0
def get_position_value(self, price: float) -> float:
"""计算持仓市值"""
return sum(p.shares * price for p in self.positions)
def get_total_value(self, price: float) -> float:
"""计算总资产"""
return self.cash + self.get_position_value(price)
def buy(self, date, price: float, size: float, reason: str = ""):
"""买入
Args:
date: 交易日期
price: 买入价格
size: 仓位大小0-1相对于当前可用资金
reason: 买入理由
"""
if size <= 0 or size > 1:
return False
cost = self.cash * size
commission_fee = cost * self.commission
net_cost = cost - commission_fee
if net_cost <= 0:
return False
shares = net_cost / price
self.cash -= cost
pos = Position(date, price, shares, reason)
self.positions.append(pos)
self.trades += 1
return True
def sell(self, date, price: float, size: float = 1.0, reason: str = ""):
"""卖出
Args:
date: 交易日期
price: 卖出价格
size: 卖出比例0-1相对于持仓
reason: 卖出理由
"""
if not self.positions or size <= 0 or size > 1:
return False
# 按先进先出卖出
remaining = size
sold_positions = []
for pos in self.positions[:]:
if remaining <= 0:
break
sell_ratio = min(remaining, 1.0)
sell_shares = pos.shares * sell_ratio
proceeds = sell_shares * price
commission_fee = proceeds * self.commission
net_proceeds = proceeds - commission_fee
self.cash += net_proceeds
# 更新持仓
pos.shares -= sell_shares
if pos.shares < 0.01: # 清仓
pos.exit_date = date
pos.exit_price = price
pos.hold_days = (date - pos.entry_date).days
pos.pnl = (price - pos.entry_price) * (sell_shares / sell_ratio)
pos.pnl_pct = (price / pos.entry_price - 1) * 100
self.closed_positions.append(pos)
self.positions.remove(pos)
if pos.pnl > 0:
self.wins += 1
self.total_pnl += pos.pnl
remaining -= sell_ratio
sold_positions.append((pos, sell_shares))
return True
def record_state(self, date, price: float):
"""记录当前状态"""
self.dates.append(date)
self.equity_curve.append(self.get_total_value(price))
def get_metrics(self) -> Dict[str, Any]:
"""计算完整指标"""
if not self.equity_curve:
return {}
equity = np.array(self.equity_curve)
returns = np.diff(equity) / equity[:-1]
# 基础指标
total_return = (equity[-1] / equity[0] - 1) * 100
# 最大回撤
peak = np.maximum.accumulate(equity)
drawdown = (peak - equity) / peak
max_drawdown = np.max(drawdown) * 100
# 夏普比率年化假设252个交易日
if len(returns) > 1 and np.std(returns) > 0:
sharpe = np.mean(returns) / np.std(returns) * np.sqrt(252)
else:
sharpe = 0.0
# 卡玛比率(收益/最大回撤)
calmar = total_return / max_drawdown if max_drawdown > 0 else 0.0
# 胜率
closed = len(self.closed_positions)
win_rate = (self.wins / closed * 100) if closed > 0 else 0.0
# 盈亏比
winning_trades = [p.pnl for p in self.closed_positions if p.pnl > 0]
losing_trades = [abs(p.pnl) for p in self.closed_positions if p.pnl < 0]
avg_win = np.mean(winning_trades) if winning_trades else 0.0
avg_loss = np.mean(losing_trades) if losing_trades else 0.0
profit_factor = avg_win / avg_loss if avg_loss > 0 else 0.0
# 持仓天数
hold_days = [p.hold_days for p in self.closed_positions]
avg_hold = np.mean(hold_days) if hold_days else 0.0
return {
"total_return": round(total_return, 2),
"max_drawdown": round(max_drawdown, 2),
"sharpe_ratio": round(sharpe, 3),
"calmar_ratio": round(calmar, 3),
"trades": self.trades,
"closed_trades": closed,
"win_rate": round(win_rate, 1),
"profit_factor": round(profit_factor, 2),
"avg_win": round(avg_win, 2),
"avg_loss": round(avg_loss, 2),
"avg_hold_days": round(avg_hold, 1),
"total_pnl": round(self.total_pnl, 2),
}
class Strategy:
"""策略基类"""
def __init__(self, name: str):
self.name = name
def on_data(self, engine: BacktestEngine, date, data: Dict[str, Any]) -> None:
"""每日回调"""
raise NotImplementedError
class MAStrategy(Strategy):
"""均线交叉策略(增强版)"""
def __init__(self, fast: int = 5, slow: int = 20,
position_size: float = 1.0,
stop_loss: float = 0.0,
take_profit: float = 0.0):
super().__init__(f"MA{fast}/{slow}")
self.fast = fast
self.slow = slow
self.position_size = position_size
self.stop_loss = stop_loss # 止损比例
self.take_profit = take_profit # 止盈比例
self.ma_fast_history = []
self.ma_slow_history = []
self.close_history = []
def on_data(self, engine: BacktestEngine, date, data: Dict[str, Any]) -> None:
close = data["close"]
self.close_history.append(close)
# 计算均线
if len(self.close_history) >= self.fast:
self.ma_fast_history.append(np.mean(self.close_history[-self.fast:]))
else:
self.ma_fast_history.append(None)
if len(self.close_history) >= self.slow:
self.ma_slow_history.append(np.mean(self.close_history[-self.slow:]))
else:
self.ma_slow_history.append(None)
if len(self.ma_fast_history) < 2:
engine.record_state(date, close)
return
maf_curr = self.ma_fast_history[-1]
maf_prev = self.ma_fast_history[-2]
mas_curr = self.ma_slow_history[-1]
mas_prev = self.ma_slow_history[-2]
if maf_curr is None or mas_curr is None:
engine.record_state(date, close)
return
# 止损止盈检查
if engine.positions:
for pos in engine.positions[:]:
pnl_pct = (close / pos.entry_price - 1) * 100
# 止损
if self.stop_loss > 0 and pnl_pct <= -self.stop_loss:
engine.sell(date, close, 1.0, f"止损 {pnl_pct:.2f}%")
# 止盈
elif self.take_profit > 0 and pnl_pct >= self.take_profit:
engine.sell(date, close, 1.0, f"止盈 {pnl_pct:.2f}%")
# 金叉买入
if maf_prev <= mas_prev and maf_curr > mas_curr:
if not engine.positions:
engine.buy(date, close, self.position_size, "金叉")
# 死叉卖出
elif maf_prev >= mas_prev and maf_curr < mas_curr:
if engine.positions:
engine.sell(date, close, 1.0, "死叉")
engine.record_state(date, close)
class MultiFactorStrategy(Strategy):
"""多因子策略"""
def __init__(self, position_size: float = 1.0):
super().__init__("多因子")
self.position_size = position_size
self.close_history = []
self.volume_history = []
def calculate_rsi(self, n: int = 14) -> Optional[float]:
"""计算RSI"""
if len(self.close_history) < n + 1:
return None
changes = np.diff(self.close_history[-n-1:])
gains = np.where(changes > 0, changes, 0)
losses = np.where(changes < 0, -changes, 0)
avg_gain = np.mean(gains)
avg_loss = np.mean(losses)
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return rsi
def on_data(self, engine: BacktestEngine, date, data: Dict[str, Any]) -> None:
close = data["close"]
volume = data.get("volume", 0)
self.close_history.append(close)
self.volume_history.append(volume)
if len(self.close_history) < 30:
engine.record_state(date, close)
return
# 计算因子
ma5 = np.mean(self.close_history[-5:])
ma20 = np.mean(self.close_history[-20:])
rsi = self.calculate_rsi(14)
# 量比
vol_avg = np.mean(self.volume_history[-20:-1])
vol_ratio = volume / vol_avg if vol_avg > 0 else 1.0
# 买入信号MA5 > MA20, RSI < 70, 放量
buy_signal = (ma5 > ma20 and
rsi is not None and rsi < 70 and
vol_ratio > 1.5)
# 卖出信号MA5 < MA20 或 RSI > 80
sell_signal = (ma5 < ma20 or
(rsi is not None and rsi > 80))
if buy_signal and not engine.positions:
engine.buy(date, close, self.position_size, "多因子买入")
if sell_signal and engine.positions:
engine.sell(date, close, 1.0, "多因子卖出")
engine.record_state(date, close)
def run_advanced_backtest(symbol: str,
strategy: Strategy,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
initial_capital: float = 100000.0,
commission: float = 0.0005) -> Dict[str, Any]:
"""运行增强回测
Args:
symbol: 股票代码
strategy: 策略实例
start_date: 开始日期
end_date: 结束日期
initial_capital: 初始资金
commission: 手续费率
Returns:
回测结果
"""
with get_session() as s:
query = select(DailyQuote.date, DailyQuote.close, DailyQuote.volume).where(
DailyQuote.code == symbol
)
if start_date:
query = query.where(DailyQuote.date >= dt.date.fromisoformat(start_date))
if end_date:
query = query.where(DailyQuote.date <= dt.date.fromisoformat(end_date))
query = query.order_by(DailyQuote.date)
rows = s.execute(query).all()
if not rows:
return {"ok": False, "msg": "无数据"}
engine = BacktestEngine(initial_capital, commission)
# 逐日回测
for row in rows:
date, close, volume = row
data = {"close": float(close), "volume": int(volume)}
strategy.on_data(engine, date, data)
# 计算基准(买入持有)
bench_curve = []
first_close = rows[0][1]
for row in rows:
bench_curve.append(float(row[1]) / float(first_close) * initial_capital)
metrics = engine.get_metrics()
# 交易明细
trades_detail = [{
"entry_date": p.entry_date.isoformat(),
"exit_date": p.exit_date.isoformat() if p.exit_date else "",
"entry_price": round(p.entry_price, 2),
"exit_price": round(p.exit_price, 2) if p.exit_price else 0,
"shares": round(p.shares, 2),
"hold_days": p.hold_days,
"pnl": round(p.pnl, 2),
"pnl_pct": round(p.pnl_pct, 2),
"reason": p.reason
} for p in engine.closed_positions]
return {
"ok": True,
"symbol": symbol,
"strategy": strategy.name,
"dates": [d.isoformat() for d in engine.dates],
"equity": [round(e, 2) for e in engine.equity_curve],
"bench": [round(b, 2) for b in bench_curve],
"metrics": metrics,
"trades": trades_detail,
"initial_capital": initial_capital,
}
def optimize_parameters(symbol: str,
param_grid: Dict[str, List],
strategy_class: type,
metric: str = "sharpe_ratio") -> List[Dict[str, Any]]:
"""参数网格优化
Args:
symbol: 股票代码
param_grid: 参数网格,如 {"fast": [3,5,10], "slow": [10,20,30]}
strategy_class: 策略类
metric: 优化目标指标
Returns:
优化结果列表,按指标降序排列
"""
import itertools
keys = list(param_grid.keys())
values = list(param_grid.values())
results = []
# 遍历所有参数组合
for combo in itertools.product(*values):
params = dict(zip(keys, combo))
try:
strategy = strategy_class(**params)
result = run_advanced_backtest(symbol, strategy)
if result["ok"]:
results.append({
"params": params,
"metrics": result["metrics"],
metric: result["metrics"].get(metric, 0)
})
except Exception as e:
print(f"优化失败 {params}: {e}")
continue
# 按目标指标排序
results.sort(key=lambda x: x[metric], reverse=True)
return results
def compare_strategies(symbol: str,
strategies: List[Strategy],
initial_capital: float = 100000.0) -> Dict[str, Any]:
"""策略对比
Args:
symbol: 股票代码
strategies: 策略列表
initial_capital: 初始资金
Returns:
对比结果
"""
results = []
for strategy in strategies:
result = run_advanced_backtest(symbol, strategy, initial_capital=initial_capital)
if result["ok"]:
results.append({
"strategy": strategy.name,
"equity": result["equity"],
"metrics": result["metrics"]
})
return {
"ok": True,
"symbol": symbol,
"dates": result["dates"] if results else [],
"strategies": results
}

531
backend/event_driven.py Normal file
View File

@@ -0,0 +1,531 @@
"""事件驱动策略 — 基于财经事件的量化交易。
功能:
1. 财报发布前后统计规律
2. 限售解禁影响回测
3. 高管增减持跟踪
4. 行业政策事件库
5. 事件驱动选股
"""
import datetime as dt
from typing import List, Dict, Any, Optional
from collections import defaultdict
import numpy as np
from sqlalchemy import select, and_, or_, func, desc
from db import get_session
from models import CorporateEvent, PolicyEvent, DailyQuote, StockMetric, Security
def collect_earnings_events(limit: int = 100) -> Dict[str, Any]:
"""采集财报事件(模拟数据)
实际生产需要接入:
- 东方财富财报日历
- 巨潮资讯网
- 新浪财经API
"""
# 模拟财报事件
with get_session() as s:
# 获取股票池
stocks = s.execute(
select(Security.code, Security.name).limit(limit)
).all()
saved = 0
for code, name in stocks:
# 模拟财报发布
event_date = dt.date.today() - dt.timedelta(days=np.random.randint(1, 90))
# 检查是否已存在
exists = s.execute(
select(CorporateEvent).where(
and_(
CorporateEvent.code == code,
CorporateEvent.event_type == 'earnings',
CorporateEvent.event_date == event_date
)
)
).scalar_one_or_none()
if not exists:
impact = np.random.choice(['positive', 'negative', 'neutral'], p=[0.4, 0.3, 0.3])
event = CorporateEvent(
code=code,
name=name,
event_type='earnings',
event_date=event_date,
title=f"{name}发布财报",
description=f"{'业绩超预期' if impact == 'positive' else ('业绩不及预期' if impact == 'negative' else '业绩符合预期')}",
impact=impact
)
s.add(event)
saved += 1
s.commit()
return {"ok": True, "saved": saved}
def analyze_earnings_pattern(days_before: int = 5, days_after: int = 10) -> Dict[str, Any]:
"""分析财报发布前后的股价规律
Args:
days_before: 财报前N天
days_after: 财报后N天
Returns:
统计结果
"""
with get_session() as s:
# 获取财报事件
events = s.execute(
select(CorporateEvent)
.where(CorporateEvent.event_type == 'earnings')
).scalars().all()
if not events:
return {"ok": False, "msg": "暂无财报数据"}
# 按影响分类
results = {
'positive': {'before': [], 'after': [], 'count': 0},
'negative': {'before': [], 'after': [], 'count': 0},
'neutral': {'before': [], 'after': [], 'count': 0}
}
for event in events:
# 获取前后股价
before_date = event.event_date - dt.timedelta(days=days_before)
after_date = event.event_date + dt.timedelta(days=days_after)
quotes = s.execute(
select(DailyQuote)
.where(
and_(
DailyQuote.code == event.code,
DailyQuote.date >= before_date,
DailyQuote.date <= after_date
)
)
.order_by(DailyQuote.date)
).scalars().all()
if len(quotes) < 2:
continue
# 找到事件日期的位置
event_idx = None
for i, q in enumerate(quotes):
if q.date >= event.event_date:
event_idx = i
break
if event_idx is None or event_idx == 0:
continue
# 计算前后收益
base_price = float(quotes[event_idx - 1].close)
# 事件前收益
if event_idx > 0:
before_return = (float(quotes[event_idx - 1].close) / float(quotes[0].close) - 1) * 100
results[event.impact]['before'].append(before_return)
# 事件后收益
if event_idx < len(quotes) - 1:
after_return = (float(quotes[-1].close) / base_price - 1) * 100
results[event.impact]['after'].append(after_return)
results[event.impact]['count'] += 1
# 计算统计指标
summary = {}
for impact, data in results.items():
if data['count'] > 0:
summary[impact] = {
'count': data['count'],
'avg_before': round(np.mean(data['before']), 2) if data['before'] else 0,
'avg_after': round(np.mean(data['after']), 2) if data['after'] else 0,
'win_rate_after': round(sum(1 for x in data['after'] if x > 0) / len(data['after']) * 100, 1) if data['after'] else 0
}
return {
"ok": True,
"days_before": days_before,
"days_after": days_after,
"summary": summary
}
def track_insider_trading(code: str = None, days: int = 180) -> Dict[str, Any]:
"""跟踪高管增减持
Args:
code: 股票代码None表示全部
days: 统计天数
Returns:
增减持记录
"""
since = dt.date.today() - dt.timedelta(days=days)
with get_session() as s:
query = select(CorporateEvent).where(
and_(
CorporateEvent.event_type == 'insider',
CorporateEvent.event_date >= since
)
)
if code:
query = query.where(CorporateEvent.code == code)
query = query.order_by(desc(CorporateEvent.event_date))
events = s.execute(query).scalars().all()
if not events:
return {"ok": False, "msg": "暂无增减持数据"}
# 统计
by_type = {'increase': [], 'decrease': []}
for e in events:
action = 'increase' if e.impact == 'positive' else 'decrease'
by_type[action].append({
'code': e.code,
'name': e.name,
'date': e.event_date.isoformat(),
'title': e.title,
'amount': e.amount
})
return {
"ok": True,
"days": days,
"total": len(events),
"increases": by_type['increase'],
"decreases": by_type['decrease']
}
def analyze_unlock_impact(days: int = 90) -> Dict[str, Any]:
"""分析限售解禁影响
Args:
days: 统计天数
Returns:
解禁影响统计
"""
since = dt.date.today() - dt.timedelta(days=days)
with get_session() as s:
events = s.execute(
select(CorporateEvent)
.where(
and_(
CorporateEvent.event_type == 'unlock',
CorporateEvent.event_date >= since
)
)
.order_by(CorporateEvent.event_date)
).scalars().all()
if not events:
return {"ok": False, "msg": "暂无解禁数据"}
results = []
for event in events:
# 获取解禁前后股价
before_date = event.event_date - dt.timedelta(days=10)
after_date = event.event_date + dt.timedelta(days=10)
quotes = s.execute(
select(DailyQuote)
.where(
and_(
DailyQuote.code == event.code,
DailyQuote.date >= before_date,
DailyQuote.date <= after_date
)
)
.order_by(DailyQuote.date)
).scalars().all()
if len(quotes) < 5:
continue
# 找到解禁日
unlock_idx = None
for i, q in enumerate(quotes):
if q.date >= event.event_date:
unlock_idx = i
break
if unlock_idx and unlock_idx > 0 and unlock_idx < len(quotes) - 1:
before_price = float(quotes[unlock_idx - 1].close)
after_price = float(quotes[-1].close)
impact_pct = (after_price / before_price - 1) * 100
results.append({
'code': event.code,
'name': event.name,
'date': event.event_date.isoformat(),
'amount': event.amount,
'impact_pct': round(impact_pct, 2),
'title': event.title
})
# 统计
if results:
avg_impact = np.mean([r['impact_pct'] for r in results])
negative_count = sum(1 for r in results if r['impact_pct'] < 0)
summary = {
'total': len(results),
'avg_impact': round(avg_impact, 2),
'negative_ratio': round(negative_count / len(results) * 100, 1)
}
else:
summary = {}
return {
"ok": True,
"days": days,
"summary": summary,
"events": results
}
def get_policy_events(sector: str = None, days: int = 180) -> Dict[str, Any]:
"""获取行业政策事件
Args:
sector: 板块名称None表示全部
days: 统计天数
Returns:
政策事件列表
"""
since = dt.date.today() - dt.timedelta(days=days)
with get_session() as s:
query = select(PolicyEvent).where(PolicyEvent.event_date >= since)
if sector:
query = query.where(PolicyEvent.sector == sector)
query = query.order_by(desc(PolicyEvent.event_date))
events = s.execute(query).scalars().all()
if not events:
return {"ok": False, "msg": "暂无政策数据"}
results = []
for e in events:
results.append({
'sector': e.sector,
'date': e.event_date.isoformat(),
'title': e.title,
'policy_type': e.policy_type,
'impact': e.impact,
'affected_stocks': e.affected_stocks.split(',') if e.affected_stocks else []
})
return {
"ok": True,
"days": days,
"total": len(results),
"events": results
}
def event_driven_selector(event_types: List[str], days: int = 30) -> Dict[str, Any]:
"""事件驱动选股
Args:
event_types: 事件类型列表,如 ['earnings_positive', 'insider_increase']
days: 最近N天的事件
Returns:
选股结果
"""
since = dt.date.today() - dt.timedelta(days=days)
with get_session() as s:
# 构建查询条件
conditions = []
for et in event_types:
if et == 'earnings_positive':
conditions.append(
and_(
CorporateEvent.event_type == 'earnings',
CorporateEvent.impact == 'positive'
)
)
elif et == 'insider_increase':
conditions.append(
and_(
CorporateEvent.event_type == 'insider',
CorporateEvent.impact == 'positive'
)
)
elif et == 'dividend':
conditions.append(CorporateEvent.event_type == 'dividend')
if not conditions:
return {"ok": False, "msg": "无效的事件类型"}
# 查询事件
query = select(CorporateEvent).where(
and_(
CorporateEvent.event_date >= since,
or_(*conditions)
)
).order_by(desc(CorporateEvent.event_date))
events = s.execute(query).scalars().all()
if not events:
return {"ok": False, "msg": "无符合条件的事件"}
# 按股票聚合
stock_events = defaultdict(list)
for e in events:
stock_events[e.code].append({
'type': e.event_type,
'date': e.event_date.isoformat(),
'impact': e.impact,
'title': e.title
})
# 获取股票最新数据
codes = list(stock_events.keys())
metrics = {}
for m in s.execute(
select(StockMetric).where(StockMetric.code.in_(codes))
).scalars():
metrics[m.code] = {
'name': m.name,
'close': m.close,
'pct': m.pct,
'ret20': m.ret20
}
# 构建结果
results = []
for code, evt_list in stock_events.items():
info = metrics.get(code, {'name': code, 'close': 0, 'pct': 0, 'ret20': 0})
results.append({
'code': code,
'name': info['name'],
'close': info['close'],
'pct': info['pct'],
'ret20': info['ret20'],
'events': evt_list,
'event_score': len(evt_list) # 事件数量作为评分
})
# 按事件评分排序
results.sort(key=lambda x: x['event_score'], reverse=True)
return {
"ok": True,
"days": days,
"event_types": event_types,
"count": len(results),
"stocks": results[:50]
}
def seed_sample_events():
"""生成示例事件数据(用于演示)"""
with get_session() as s:
# 获取股票池
stocks = s.execute(
select(Security.code, Security.name).limit(100)
).all()
saved = 0
for code, name in stocks:
# 随机生成不同类型的事件
event_types = [
('earnings', 'positive', f'{name}业绩超预期', 0),
('earnings', 'negative', f'{name}业绩不及预期', 0),
('insider', 'positive', f'{name}高管增持', np.random.uniform(0.1, 5)),
('insider', 'negative', f'{name}高管减持', np.random.uniform(0.1, 5)),
('unlock', 'negative', f'{name}限售解禁', np.random.uniform(1, 50)),
('dividend', 'positive', f'{name}分红派息', np.random.uniform(0.5, 3))
]
# 随机选择1-2个事件
selected = np.random.choice(len(event_types), size=min(2, len(event_types)), replace=False)
for idx in selected:
event_type, impact, title, amount = event_types[idx]
event_date = dt.date.today() - dt.timedelta(days=np.random.randint(1, 90))
# 检查是否已存在
exists = s.execute(
select(CorporateEvent).where(
and_(
CorporateEvent.code == code,
CorporateEvent.event_type == event_type,
CorporateEvent.event_date == event_date
)
)
).scalar_one_or_none()
if not exists:
event = CorporateEvent(
code=code,
name=name,
event_type=event_type,
event_date=event_date,
title=title,
amount=amount,
impact=impact
)
s.add(event)
saved += 1
# 生成政策事件
policies = [
('新能源', '新能源汽车补贴政策延续', 'subsidy', 'positive'),
('半导体', '芯片产业扶持政策出台', 'support', 'positive'),
('医药', '药品集采政策调整', 'regulation', 'negative'),
('光伏', '光伏补贴退坡', 'subsidy', 'negative'),
('人工智能', 'AI产业发展规划发布', 'support', 'positive'),
]
for sector, title, policy_type, impact in policies:
event_date = dt.date.today() - dt.timedelta(days=np.random.randint(1, 90))
exists = s.execute(
select(PolicyEvent).where(
and_(
PolicyEvent.sector == sector,
PolicyEvent.title == title
)
)
).scalar_one_or_none()
if not exists:
policy = PolicyEvent(
sector=sector,
event_date=event_date,
title=title,
policy_type=policy_type,
impact=impact
)
s.add(policy)
saved += 1
s.commit()
return {"ok": True, "saved": saved}

View File

@@ -0,0 +1,495 @@
"""财报深度解读 — 关键指标趋势、AI摘要、同行对比、异常预警。
功能:
1. 财报关键指标趋势
2. AI财报摘要
3. 同行对比
4. 财报异常预警
5. 财报发布日历
"""
import datetime as dt
import json
from typing import List, Dict, Any, Optional
from collections import defaultdict
import numpy as np
from sqlalchemy import select, and_, func, desc
from db import get_session
from models import FinancialReport, Security, StockMetric
import llm
def seed_sample_reports():
"""生成示例财报数据(用于演示)"""
with get_session() as s:
stocks = s.execute(
select(Security.code, Security.name).limit(50)
).all()
saved = 0
for code, name in stocks:
# 生成最近4个季度的财报
base_date = dt.date(2023, 12, 31)
for i in range(4):
report_date = base_date - dt.timedelta(days=i * 90)
publish_date = report_date + dt.timedelta(days=30)
# 检查是否已存在
exists = s.execute(
select(FinancialReport).where(
and_(
FinancialReport.code == code,
FinancialReport.report_date == report_date
)
)
).scalar_one_or_none()
if exists:
continue
# 模拟财务数据
base_revenue = np.random.uniform(10, 500)
growth = np.random.uniform(-20, 50)
report = FinancialReport(
code=code,
name=name,
report_date=report_date,
publish_date=publish_date,
report_type='Q' + str((report_date.month // 3) or 4),
revenue=round(base_revenue * (1 + i * 0.1), 2),
net_profit=round(base_revenue * np.random.uniform(0.05, 0.2), 2),
roe=round(np.random.uniform(5, 25), 2),
gross_margin=round(np.random.uniform(20, 60), 2),
revenue_growth=round(growth, 2),
profit_growth=round(growth + np.random.uniform(-10, 10), 2),
inventory=round(base_revenue * np.random.uniform(0.1, 0.3), 2),
receivable=round(base_revenue * np.random.uniform(0.15, 0.4), 2),
debt_ratio=round(np.random.uniform(30, 70), 2)
)
s.add(report)
saved += 1
s.commit()
return {"ok": True, "saved": saved}
def get_report_trend(code: str, periods: int = 8) -> Dict[str, Any]:
"""获取财报关键指标趋势
Args:
code: 股票代码
periods: 统计期数
Returns:
趋势数据
"""
with get_session() as s:
reports = s.execute(
select(FinancialReport)
.where(FinancialReport.code == code)
.order_by(desc(FinancialReport.report_date))
.limit(periods)
).scalars().all()
if not reports:
return {"ok": False, "msg": "暂无财报数据"}
# 反转顺序(从旧到新)
reports = list(reversed(reports))
# 提取趋势数据
dates = [r.report_date.isoformat() for r in reports]
trend_data = {
"revenue": [r.revenue for r in reports],
"net_profit": [r.net_profit for r in reports],
"roe": [r.roe for r in reports],
"gross_margin": [r.gross_margin for r in reports],
"revenue_growth": [r.revenue_growth for r in reports],
"profit_growth": [r.profit_growth for r in reports],
"debt_ratio": [r.debt_ratio for r in reports]
}
# 计算趋势(上升/下降/平稳)
def calc_trend(values):
if len(values) < 2:
return "平稳"
recent = np.mean(values[-2:])
previous = np.mean(values[:2]) if len(values) >= 4 else values[0]
change = (recent - previous) / previous if previous != 0 else 0
if change > 0.1:
return "上升"
elif change < -0.1:
return "下降"
else:
return "平稳"
trends = {
key: calc_trend(values)
for key, values in trend_data.items()
}
return {
"ok": True,
"code": code,
"name": reports[0].name,
"dates": dates,
"data": trend_data,
"trends": trends,
"latest": {
"revenue": reports[-1].revenue,
"net_profit": reports[-1].net_profit,
"roe": reports[-1].roe,
"gross_margin": reports[-1].gross_margin,
"revenue_growth": reports[-1].revenue_growth,
"profit_growth": reports[-1].profit_growth
}
}
def generate_ai_summary(code: str) -> Dict[str, Any]:
"""生成AI财报摘要
Args:
code: 股票代码
Returns:
AI摘要
"""
with get_session() as s:
# 获取最新财报
report = s.execute(
select(FinancialReport)
.where(FinancialReport.code == code)
.order_by(desc(FinancialReport.report_date))
.limit(1)
).scalar_one_or_none()
if not report:
return {"ok": False, "msg": "暂无财报数据"}
# 如果已有摘要,直接返回
if report.ai_summary:
return {
"ok": True,
"summary": report.ai_summary,
"report_date": report.report_date.isoformat()
}
# 构建提示词
prompt = f"""请用一句话总结以下财报数据40字以内
公司:{report.name}{report.code}
报告期:{report.report_date}
营业收入:{report.revenue}亿元,同比增长{report.revenue_growth:+.1f}%
净利润:{report.net_profit}亿元,同比增长{report.profit_growth:+.1f}%
ROE{report.roe}%
毛利率:{report.gross_margin}%
要求:
1. 一句话说明业绩是增长还是下降
2. 提及最亮眼或最担忧的指标
3. 给出简短评价(优秀/良好/一般/较差)
4. 不超过40字
示例业绩稳步增长ROE达20%创新高,盈利能力优秀。
"""
# 调用AI
if llm.enabled():
try:
summary = llm.ask(prompt, max_tokens=100)
# 保存摘要
report.ai_summary = summary
s.commit()
except Exception as e:
summary = f"营收{report.revenue}亿元({report.revenue_growth:+.1f}%),净利润{report.net_profit}亿元({report.profit_growth:+.1f}%)"
else:
summary = f"营收{report.revenue}亿元({report.revenue_growth:+.1f}%),净利润{report.net_profit}亿元({report.profit_growth:+.1f}%)"
return {
"ok": True,
"summary": summary,
"report_date": report.report_date.isoformat()
}
def compare_with_peers(code: str, sector: str = None) -> Dict[str, Any]:
"""同行对比
Args:
code: 股票代码
sector: 行业(可选)
Returns:
对比结果
"""
with get_session() as s:
# 获取目标股票最新财报
target = s.execute(
select(FinancialReport)
.where(FinancialReport.code == code)
.order_by(desc(FinancialReport.report_date))
.limit(1)
).scalar_one_or_none()
if not target:
return {"ok": False, "msg": "暂无财报数据"}
# 获取同行业股票(简化:随机选取)
peers = s.execute(
select(FinancialReport)
.where(
and_(
FinancialReport.code != code,
FinancialReport.report_date == target.report_date
)
)
.limit(20)
).scalars().all()
if not peers:
return {"ok": False, "msg": "暂无同行数据"}
# 计算行业均值
industry_avg = {
"roe": np.mean([p.roe for p in peers]),
"gross_margin": np.mean([p.gross_margin for p in peers]),
"revenue_growth": np.mean([p.revenue_growth for p in peers]),
"profit_growth": np.mean([p.profit_growth for p in peers]),
"debt_ratio": np.mean([p.debt_ratio for p in peers])
}
# 计算差异
comparison = {
"roe": {
"value": target.roe,
"industry_avg": round(industry_avg["roe"], 2),
"diff": round(target.roe - industry_avg["roe"], 2),
"better": target.roe > industry_avg["roe"]
},
"gross_margin": {
"value": target.gross_margin,
"industry_avg": round(industry_avg["gross_margin"], 2),
"diff": round(target.gross_margin - industry_avg["gross_margin"], 2),
"better": target.gross_margin > industry_avg["gross_margin"]
},
"revenue_growth": {
"value": target.revenue_growth,
"industry_avg": round(industry_avg["revenue_growth"], 2),
"diff": round(target.revenue_growth - industry_avg["revenue_growth"], 2),
"better": target.revenue_growth > industry_avg["revenue_growth"]
},
"profit_growth": {
"value": target.profit_growth,
"industry_avg": round(industry_avg["profit_growth"], 2),
"diff": round(target.profit_growth - industry_avg["profit_growth"], 2),
"better": target.profit_growth > industry_avg["profit_growth"]
},
"debt_ratio": {
"value": target.debt_ratio,
"industry_avg": round(industry_avg["debt_ratio"], 2),
"diff": round(target.debt_ratio - industry_avg["debt_ratio"], 2),
"better": target.debt_ratio < industry_avg["debt_ratio"] # 负债率越低越好
}
}
# 综合评分
better_count = sum(1 for v in comparison.values() if v["better"])
return {
"ok": True,
"code": code,
"name": target.name,
"report_date": target.report_date.isoformat(),
"comparison": comparison,
"better_count": better_count,
"total_metrics": len(comparison),
"conclusion": "优于行业" if better_count >= 3 else ("持平行业" if better_count == 2 else "弱于行业")
}
def detect_abnormalities(code: str) -> Dict[str, Any]:
"""财报异常预警
Args:
code: 股票代码
Returns:
异常预警
"""
with get_session() as s:
# 获取最近2期财报
reports = s.execute(
select(FinancialReport)
.where(FinancialReport.code == code)
.order_by(desc(FinancialReport.report_date))
.limit(2)
).scalars().all()
if len(reports) < 2:
return {"ok": False, "msg": "数据不足"}
current, previous = reports[0], reports[1]
warnings = []
# 1. 存货激增
if current.inventory > 0 and previous.inventory > 0:
inventory_growth = (current.inventory / previous.inventory - 1) * 100
if inventory_growth > 50:
warnings.append({
"type": "存货激增",
"severity": "high",
"description": f"存货增长{inventory_growth:.1f}%,可能存在滞销风险",
"current": current.inventory,
"previous": previous.inventory
})
# 2. 应收账款占比过高
receivable_ratio = current.receivable / current.revenue * 100 if current.revenue > 0 else 0
if receivable_ratio > 50:
warnings.append({
"type": "应收账款占比过高",
"severity": "medium",
"description": f"应收账款占营收{receivable_ratio:.1f}%,回款压力较大",
"ratio": round(receivable_ratio, 2)
})
# 3. 毛利率大幅下降
if current.gross_margin > 0 and previous.gross_margin > 0:
margin_change = current.gross_margin - previous.gross_margin
if margin_change < -5:
warnings.append({
"type": "毛利率大幅下降",
"severity": "high",
"description": f"毛利率下降{abs(margin_change):.1f}个百分点,盈利能力恶化",
"current": current.gross_margin,
"previous": previous.gross_margin
})
# 4. 资产负债率过高
if current.debt_ratio > 70:
warnings.append({
"type": "资产负债率过高",
"severity": "medium",
"description": f"资产负债率{current.debt_ratio}%,财务风险较高",
"value": current.debt_ratio
})
# 5. 增收不增利
if current.revenue_growth > 10 and current.profit_growth < 0:
warnings.append({
"type": "增收不增利",
"severity": "high",
"description": f"营收增长{current.revenue_growth:.1f}%,但净利润下降{abs(current.profit_growth):.1f}%",
"revenue_growth": current.revenue_growth,
"profit_growth": current.profit_growth
})
return {
"ok": True,
"code": code,
"name": current.name,
"report_date": current.report_date.isoformat(),
"warnings": warnings,
"risk_level": "" if any(w["severity"] == "high" for w in warnings) else ("" if warnings else "")
}
def get_report_calendar(days: int = 30) -> Dict[str, Any]:
"""财报发布日历
Args:
days: 未来N天
Returns:
日历数据
"""
today = dt.date.today()
end_date = today + dt.timedelta(days=days)
with get_session() as s:
reports = s.execute(
select(FinancialReport)
.where(
and_(
FinancialReport.publish_date >= today,
FinancialReport.publish_date <= end_date
)
)
.order_by(FinancialReport.publish_date)
).scalars().all()
if not reports:
return {"ok": False, "msg": "暂无即将发布的财报"}
# 按日期分组
calendar = defaultdict(list)
for r in reports:
calendar[r.publish_date.isoformat()].append({
"code": r.code,
"name": r.name,
"report_date": r.report_date.isoformat(),
"report_type": r.report_type
})
return {
"ok": True,
"days": days,
"total": len(reports),
"calendar": dict(calendar)
}
def get_top_reports(metric: str = "roe", limit: int = 20) -> Dict[str, Any]:
"""获取财报排行榜
Args:
metric: 排序指标roe/gross_margin/revenue_growth
limit: 返回数量
Returns:
排行榜
"""
with get_session() as s:
# 获取最新一期的所有财报
latest_date = s.execute(
select(func.max(FinancialReport.report_date))
).scalar()
if not latest_date:
return {"ok": False, "msg": "暂无数据"}
# 根据指标排序
order_field = getattr(FinancialReport, metric)
reports = s.execute(
select(FinancialReport)
.where(FinancialReport.report_date == latest_date)
.order_by(desc(order_field))
.limit(limit)
).scalars().all()
results = []
for r in reports:
results.append({
"code": r.code,
"name": r.name,
"roe": r.roe,
"gross_margin": r.gross_margin,
"revenue_growth": r.revenue_growth,
"profit_growth": r.profit_growth,
metric: getattr(r, metric)
})
return {
"ok": True,
"metric": metric,
"report_date": latest_date.isoformat(),
"rankings": results
}

416
backend/intraday_radar.py Normal file
View File

@@ -0,0 +1,416 @@
"""盘中实时监控雷达 — 异动检测与推送。
监控类型:
1. 快速拉升5分钟涨幅 >3%
2. 放量突破(量比 >3 且突破关键位)
3. 涨停打开/炸板
4. 连板股追踪
5. 大单异动(单笔超百万)
"""
import datetime as dt
from typing import List, Dict, Any
from sqlalchemy import select, desc, func
from cachetools import TTLCache
import akshare_service as svc
import notifier
from db import get_session
from models import IntradayEvent, StockMetric, Security, DailyQuote
# 缓存最近检测到的事件,避免短时间内重复推送
_event_cache = TTLCache(maxsize=1000, ttl=300) # 5分钟缓存
def _is_trading_time() -> bool:
"""判断是否为交易时间9:30-11:30, 13:00-15:00"""
now = dt.datetime.now()
if now.weekday() >= 5: # 周末
return False
t = now.time()
morning = dt.time(9, 30) <= t <= dt.time(11, 30)
afternoon = dt.time(13, 0) <= t <= dt.time(15, 0)
return morning or afternoon
def _cache_key(code: str, event_type: str) -> str:
"""生成事件缓存键。"""
return f"{code}:{event_type}"
def detect_surge(threshold: float = 3.0) -> List[Dict[str, Any]]:
"""快速拉升检测基于实时报价模拟5分钟涨幅
Args:
threshold: 涨幅阈值(%
Returns:
检测到的异动列表
"""
if not _is_trading_time():
return []
events = []
try:
# 获取涨幅榜前50模拟快速拉升
data = svc.get_hot_stocks()
if not data.get("list"):
return []
for stock in data["list"][:50]:
pct = stock.get("pct", 0)
if pct >= threshold:
code = stock["code"]
key = _cache_key(code, "surge")
if key in _event_cache:
continue
_event_cache[key] = True
events.append({
"code": code,
"name": stock.get("name", code),
"event_type": "surge",
"price": stock.get("price", 0),
"pct": pct,
"description": f"快速拉升 {pct:.2f}%"
})
except Exception as e:
print(f"[surge] error: {e}")
return events
def detect_volume_break(vol_ratio_threshold: float = 3.0) -> List[Dict[str, Any]]:
"""放量突破检测(量比 >3 且价格突破)。
Args:
vol_ratio_threshold: 量比阈值
Returns:
检测到的异动列表
"""
if not _is_trading_time():
return []
events = []
with get_session() as s:
# 查询高量比且上涨的股票
rows = s.execute(
select(StockMetric)
.where(StockMetric.vol_ratio >= vol_ratio_threshold, StockMetric.pct > 0)
.order_by(StockMetric.vol_ratio.desc())
.limit(20)
).scalars().all()
for r in rows:
key = _cache_key(r.code, "volume_break")
if key in _event_cache:
continue
# 判断是否突破关键位60日新高或MA20
is_break = r.pos60 >= 0.95 or (r.close > r.ma20 and r.ma20 > 0)
if is_break:
_event_cache[key] = True
events.append({
"code": r.code,
"name": r.name,
"event_type": "volume_break",
"price": r.close,
"pct": r.pct,
"volume_ratio": r.vol_ratio,
"description": f"放量突破 量比{r.vol_ratio:.1f}"
})
return events
def detect_limit_open() -> List[Dict[str, Any]]:
"""涨停打开/炸板检测。
Returns:
检测到的异动列表
"""
if not _is_trading_time():
return []
events = []
try:
# 获取涨停股
data = svc.get_hot_stocks()
if not data.get("list"):
return []
with get_session() as s:
for stock in data["list"]:
pct = stock.get("pct", 0)
# 涨停附近但未封死9.5%-9.99%
if 9.5 <= pct < 9.99:
code = stock["code"]
key = _cache_key(code, "limit_open")
if key in _event_cache:
continue
_event_cache[key] = True
events.append({
"code": code,
"name": stock.get("name", code),
"event_type": "limit_open",
"price": stock.get("price", 0),
"pct": pct,
"description": f"涨停打开 {pct:.2f}%"
})
except Exception as e:
print(f"[limit_open] error: {e}")
return events
def detect_consecutive_limit() -> List[Dict[str, Any]]:
"""连板股追踪2连板及以上
Returns:
检测到的异动列表
"""
if not _is_trading_time():
return []
events = []
try:
data = svc.get_hot_stocks()
if not data.get("list"):
return []
with get_session() as s:
for stock in data["list"]:
pct = stock.get("pct", 0)
if pct >= 9.9: # 涨停
code = stock["code"]
# 查询历史连板数
recent = s.execute(
select(DailyQuote)
.where(DailyQuote.code == code)
.order_by(DailyQuote.date.desc())
.limit(5)
).scalars().all()
if not recent:
continue
# 统计连续涨停天数
consecutive = 1 # 今天涨停
for q in recent[1:]:
if q.close / q.open >= 1.095: # 近似判断涨停
consecutive += 1
else:
break
if consecutive >= 2:
key = _cache_key(code, "consecutive")
if key in _event_cache:
continue
_event_cache[key] = True
events.append({
"code": code,
"name": stock.get("name", code),
"event_type": "consecutive",
"price": stock.get("price", 0),
"pct": pct,
"description": f"{consecutive}连板"
})
except Exception as e:
print(f"[consecutive] error: {e}")
return events
def detect_big_order(threshold: float = 1000000.0) -> List[Dict[str, Any]]:
"""大单异动检测(单笔超百万)。
注意AkShare 免费接口无实时逐笔数据,此处返回空列表,可接入付费数据源。
Args:
threshold: 单笔金额阈值(元)
Returns:
检测到的异动列表
"""
# 需要付费数据源支持,暂不实现
return []
def scan_all() -> Dict[str, Any]:
"""执行全部异动扫描。
Returns:
扫描结果,包含各类异动事件
"""
if not _is_trading_time():
return {"ok": False, "msg": "非交易时间", "events": []}
all_events = []
# 执行各类检测
all_events.extend(detect_surge())
all_events.extend(detect_volume_break())
all_events.extend(detect_limit_open())
all_events.extend(detect_consecutive_limit())
all_events.extend(detect_big_order())
# 写入数据库
if all_events:
with get_session() as s:
for evt in all_events:
record = IntradayEvent(
code=evt["code"],
name=evt["name"],
event_type=evt["event_type"],
price=evt.get("price", 0),
pct=evt.get("pct", 0),
volume_ratio=evt.get("volume_ratio", 0),
amount=evt.get("amount", 0),
description=evt["description"]
)
s.add(record)
s.commit()
return {
"ok": True,
"count": len(all_events),
"events": all_events,
"scanned_at": dt.datetime.now().isoformat()
}
def get_recent_events(hours: int = 2, limit: int = 50) -> List[Dict[str, Any]]:
"""获取最近N小时的异动事件。
Args:
hours: 时间范围(小时)
limit: 最大返回数量
Returns:
异动事件列表
"""
since = dt.datetime.now() - dt.timedelta(hours=hours)
with get_session() as s:
rows = s.execute(
select(IntradayEvent)
.where(IntradayEvent.detected_at >= since)
.order_by(desc(IntradayEvent.detected_at))
.limit(limit)
).scalars().all()
return [{
"id": r.id,
"code": r.code,
"name": r.name,
"event_type": r.event_type,
"price": r.price,
"pct": r.pct,
"volume_ratio": r.volume_ratio,
"amount": r.amount,
"description": r.description,
"detected_at": r.detected_at.strftime("%H:%M:%S"),
"notified": r.notified
} for r in rows]
def notify_events(event_types: List[str] = None) -> Dict[str, Any]:
"""推送未通知的异动事件。
Args:
event_types: 需要推送的事件类型列表None表示全部
Returns:
推送结果
"""
with get_session() as s:
stmt = select(IntradayEvent).where(IntradayEvent.notified.is_(False))
if event_types:
stmt = stmt.where(IntradayEvent.event_type.in_(event_types))
rows = s.execute(stmt.order_by(desc(IntradayEvent.detected_at)).limit(10)).scalars().all()
if not rows:
return {"ok": True, "count": 0, "msg": "无待推送事件"}
# 按事件类型分组
grouped = {}
for r in rows:
if r.event_type not in grouped:
grouped[r.event_type] = []
grouped[r.event_type].append(r)
# 构造推送消息
type_names = {
"surge": "快速拉升",
"volume_break": "放量突破",
"limit_open": "涨停打开",
"consecutive": "连板追踪",
"big_order": "大单异动"
}
msg_parts = ["【盘中异动雷达】\n"]
for etype, events in grouped.items():
msg_parts.append(f"\n{type_names.get(etype, etype)}:")
for e in events[:5]: # 每类最多5条
msg_parts.append(f"{e.name}({e.code}) {e.description}")
msg = "\n".join(msg_parts)
# 推送
if notifier.any_enabled():
notifier.notify("盘中异动提醒", msg)
# 标记已推送
for r in rows:
r.notified = True
s.commit()
return {"ok": True, "count": len(rows), "msg": f"已推送 {len(rows)} 条异动"}
def get_statistics(date: dt.date = None) -> Dict[str, Any]:
"""获取异动统计数据。
Args:
date: 统计日期None表示今天
Returns:
统计结果
"""
if date is None:
date = dt.date.today()
start = dt.datetime.combine(date, dt.time.min)
end = dt.datetime.combine(date, dt.time.max)
with get_session() as s:
# 按事件类型统计
stmt = (
select(IntradayEvent.event_type, func.count().label("count"))
.where(IntradayEvent.detected_at >= start, IntradayEvent.detected_at <= end)
.group_by(IntradayEvent.event_type)
)
rows = s.execute(stmt).all()
stats = {row.event_type: row.count for row in rows}
total = sum(stats.values())
# 最活跃股票
stmt = (
select(IntradayEvent.code, IntradayEvent.name, func.count().label("count"))
.where(IntradayEvent.detected_at >= start, IntradayEvent.detected_at <= end)
.group_by(IntradayEvent.code, IntradayEvent.name)
.order_by(desc("count"))
.limit(10)
)
top_stocks = s.execute(stmt).all()
return {
"date": date.isoformat(),
"total": total,
"by_type": stats,
"top_stocks": [{"code": r.code, "name": r.name, "count": r.count} for r in top_stocks]
}

394
backend/limit_analysis.py Normal file
View File

@@ -0,0 +1,394 @@
"""涨跌停分析 — 连板股追踪、炸板率统计、敢死队排行。
功能:
1. 连板股追踪器
2. 炸板率统计
3. 涨停敢死队排行
"""
import datetime as dt
from typing import List, Dict, Any, Optional
from collections import defaultdict, Counter
import numpy as np
from sqlalchemy import select, and_, func, desc
from db import get_session
from models import DailyQuote, StockMetric
def get_limit_stocks(date: Optional[dt.date] = None, limit_type: str = "up") -> Dict[str, Any]:
"""获取涨停/跌停股票
Args:
date: 日期None表示最新
limit_type: up涨停/down跌停
Returns:
涨跌停股票列表
"""
with get_session() as s:
if date is None:
date = s.execute(select(func.max(DailyQuote.date))).scalar()
if not date:
return {"ok": False, "msg": "暂无数据"}
# 查询当日股票
quotes = s.execute(
select(DailyQuote)
.where(DailyQuote.date == date)
).scalars().all()
if not quotes:
return {"ok": False, "msg": "暂无数据"}
# 筛选涨停/跌停股涨跌幅接近±10%
threshold = 9.8 # 考虑精度问题用9.8%作为阈值
results = []
for q in quotes:
if q.open == 0:
continue
pct = (float(q.close) - float(q.open)) / float(q.open) * 100
if limit_type == "up" and pct >= threshold:
results.append({
"code": q.code,
"name": q.name,
"close": float(q.close),
"pct": round(pct, 2),
"volume": float(q.volume),
"amount": float(q.amount)
})
elif limit_type == "down" and pct <= -threshold:
results.append({
"code": q.code,
"name": q.name,
"close": float(q.close),
"pct": round(pct, 2),
"volume": float(q.volume),
"amount": float(q.amount)
})
return {
"ok": True,
"date": date.isoformat(),
"type": limit_type,
"count": len(results),
"stocks": sorted(results, key=lambda x: x["pct"], reverse=(limit_type == "up"))
}
def track_consecutive_limits(days: int = 10) -> Dict[str, Any]:
"""连板股追踪器
Args:
days: 追踪天数
Returns:
连板股列表
"""
with get_session() as s:
latest_date = s.execute(select(func.max(DailyQuote.date))).scalar()
if not latest_date:
return {"ok": False, "msg": "暂无数据"}
start_date = latest_date - dt.timedelta(days=days)
# 获取期间所有股票的日线数据
quotes = s.execute(
select(DailyQuote)
.where(DailyQuote.date >= start_date)
.order_by(DailyQuote.code, DailyQuote.date)
).scalars().all()
# 按股票分组
stock_data = defaultdict(list)
for q in quotes:
stock_data[q.code].append(q)
# 统计连板
consecutive_limits = []
for code, data in stock_data.items():
if not data:
continue
# 倒序遍历,统计从最新日期开始的连续涨停天数
data_sorted = sorted(data, key=lambda x: x.date, reverse=True)
consecutive_days = 0
for q in data_sorted:
if q.open == 0:
break
pct = (float(q.close) - float(q.open)) / float(q.open) * 100
if pct >= 9.8: # 涨停
consecutive_days += 1
else:
break
if consecutive_days >= 2: # 至少2连板
latest = data_sorted[0]
consecutive_limits.append({
"code": code,
"name": latest.name,
"consecutive_days": consecutive_days,
"close": float(latest.close),
"amount": float(latest.amount),
"status": f"{consecutive_days}连板"
})
# 按连板天数排序
consecutive_limits.sort(key=lambda x: x["consecutive_days"], reverse=True)
return {
"ok": True,
"date": latest_date.isoformat(),
"days": days,
"count": len(consecutive_limits),
"stocks": consecutive_limits
}
def analyze_limit_break_rate(days: int = 60) -> Dict[str, Any]:
"""炸板率统计
分析涨停后次日的表现(继续涨停/上涨/下跌/跌停)
Args:
days: 统计天数
Returns:
炸板率统计
"""
with get_session() as s:
latest_date = s.execute(select(func.max(DailyQuote.date))).scalar()
if not latest_date:
return {"ok": False, "msg": "暂无数据"}
start_date = latest_date - dt.timedelta(days=days)
# 获取期间所有股票的日线数据
quotes = s.execute(
select(DailyQuote)
.where(DailyQuote.date >= start_date)
.order_by(DailyQuote.code, DailyQuote.date)
).scalars().all()
# 按股票和日期组织数据
stock_data = defaultdict(dict)
for q in quotes:
stock_data[q.code][q.date] = q
# 统计涨停后次日表现
next_day_stats = {
"limit_up": 0, # 继续涨停
"up": 0, # 上涨但未涨停
"down": 0, # 下跌但未跌停
"limit_down": 0, # 跌停
}
stock_break_rates = {} # 个股炸板率
for code, data in stock_data.items():
dates = sorted(data.keys())
stock_limits = 0
stock_breaks = 0
for i in range(len(dates) - 1):
today = dates[i]
tomorrow = dates[i + 1]
today_q = data[today]
tomorrow_q = data[tomorrow]
# 判断今日是否涨停
if today_q.open == 0:
continue
today_pct = (float(today_q.close) - float(today_q.open)) / float(today_q.open) * 100
if today_pct >= 9.8: # 今日涨停
stock_limits += 1
# 判断次日表现
if tomorrow_q.open == 0:
continue
tomorrow_pct = (float(tomorrow_q.close) - float(tomorrow_q.open)) / float(tomorrow_q.open) * 100
if tomorrow_pct >= 9.8:
next_day_stats["limit_up"] += 1
elif tomorrow_pct > 0:
next_day_stats["up"] += 1
stock_breaks += 1 # 炸板
elif tomorrow_pct > -9.8:
next_day_stats["down"] += 1
stock_breaks += 1 # 炸板
else:
next_day_stats["limit_down"] += 1
stock_breaks += 1 # 炸板
# 计算个股炸板率
if stock_limits > 0:
break_rate = stock_breaks / stock_limits * 100
if stock_limits >= 3: # 至少3次涨停才有统计意义
stock_break_rates[code] = {
"name": list(data.values())[0].name,
"limits": stock_limits,
"breaks": stock_breaks,
"break_rate": round(break_rate, 2)
}
total = sum(next_day_stats.values())
if total == 0:
return {"ok": False, "msg": "统计样本不足"}
# 计算比例
stats_with_pct = {
"limit_up": {
"count": next_day_stats["limit_up"],
"pct": round(next_day_stats["limit_up"] / total * 100, 2)
},
"up": {
"count": next_day_stats["up"],
"pct": round(next_day_stats["up"] / total * 100, 2)
},
"down": {
"count": next_day_stats["down"],
"pct": round(next_day_stats["down"] / total * 100, 2)
},
"limit_down": {
"count": next_day_stats["limit_down"],
"pct": round(next_day_stats["limit_down"] / total * 100, 2)
}
}
# 炸板率 = (上涨未涨停 + 下跌 + 跌停) / 总数
break_rate = (next_day_stats["up"] + next_day_stats["down"] + next_day_stats["limit_down"]) / total * 100
# 个股炸板率排行(从高到低)
stock_rankings = sorted(
[(code, data) for code, data in stock_break_rates.items()],
key=lambda x: x[1]["break_rate"],
reverse=True
)[:30]
return {
"ok": True,
"days": days,
"total_samples": total,
"overall_break_rate": round(break_rate, 2),
"next_day_stats": stats_with_pct,
"stock_rankings": [{
"code": code,
"name": data["name"],
"limits": data["limits"],
"breaks": data["breaks"],
"break_rate": data["break_rate"]
} for code, data in stock_rankings]
}
def get_limit_squad_rankings(days: int = 30, min_limits: int = 5) -> Dict[str, Any]:
"""涨停敢死队排行
统计期间内涨停次数最多的股票(俗称"妖股"
Args:
days: 统计天数
min_limits: 最少涨停次数
Returns:
敢死队排行
"""
with get_session() as s:
latest_date = s.execute(select(func.max(DailyQuote.date))).scalar()
if not latest_date:
return {"ok": False, "msg": "暂无数据"}
start_date = latest_date - dt.timedelta(days=days)
# 获取期间所有股票的日线数据
quotes = s.execute(
select(DailyQuote)
.where(DailyQuote.date >= start_date)
.order_by(DailyQuote.code, DailyQuote.date)
).scalars().all()
# 统计每只股票的涨停次数
limit_counts = defaultdict(lambda: {
"name": "",
"count": 0,
"dates": [],
"total_days": 0,
"max_consecutive": 0
})
# 按股票分组
stock_data = defaultdict(list)
for q in quotes:
stock_data[q.code].append(q)
for code, data in stock_data.items():
if not data:
continue
data_sorted = sorted(data, key=lambda x: x.date)
limit_days = []
for q in data_sorted:
if q.open == 0:
continue
pct = (float(q.close) - float(q.open)) / float(q.open) * 100
if pct >= 9.8: # 涨停
limit_days.append(q.date)
if len(limit_days) >= min_limits:
# 计算最大连板数
max_consecutive = 1
current_consecutive = 1
for i in range(1, len(limit_days)):
if (limit_days[i] - limit_days[i-1]).days == 1:
current_consecutive += 1
max_consecutive = max(max_consecutive, current_consecutive)
else:
current_consecutive = 1
limit_counts[code] = {
"name": data_sorted[0].name,
"count": len(limit_days),
"dates": [d.isoformat() for d in limit_days],
"total_days": len(data_sorted),
"max_consecutive": max_consecutive,
"frequency": round(len(limit_days) / len(data_sorted) * 100, 2)
}
# 排序
rankings = sorted(
[(code, data) for code, data in limit_counts.items()],
key=lambda x: (x[1]["count"], x[1]["max_consecutive"]),
reverse=True
)[:50]
return {
"ok": True,
"days": days,
"start_date": start_date.isoformat(),
"end_date": latest_date.isoformat(),
"count": len(rankings),
"rankings": [{
"code": code,
"name": data["name"],
"limit_count": data["count"],
"max_consecutive": data["max_consecutive"],
"frequency": data["frequency"],
"dates": data["dates"]
} for code, data in rankings]
}

View File

@@ -40,3 +40,8 @@ def ask(user_content: str, temperature: float = 0.5, max_tokens: int = 900) -> s
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_content},
], temperature=temperature, max_tokens=max_tokens)
def ask_with_messages(messages: list, temperature: float = 0.5, max_tokens: int = 900) -> str:
"""使用完整消息列表调用(支持多轮对话)"""
return chat(messages, temperature=temperature, max_tokens=max_tokens)

View File

@@ -1,4 +1,4 @@
"""智策股票终端 — FastAPI 后端入口。
"""Blackdata股票终端 — FastAPI 后端入口。
- /api/* : 数据接口(基于 AkShare带缓存与降级
- / : 托管前端原型prototype 目录)
@@ -7,6 +7,7 @@ import os
import json
import datetime as dt
from contextlib import asynccontextmanager
from typing import List, Dict, Any, Optional
from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware
@@ -19,6 +20,7 @@ import akshare_service as svc
import config
import scheduler
import backtest as bt
import backtest_advanced as bta
import ai
import signals as sig
import report as rpt
@@ -26,10 +28,19 @@ import portfolio as pf
import llm
import alerts as al
import notifier
import intraday_radar as radar
import sector_rotation as sector
import smart_selector as selector
import attribution_analysis as attrib
import ai_chat
import sentiment_monitor as sentiment
import event_driven as events
import financial_analysis as fin
import limit_analysis as limit_up
from db import init_db, get_session
from models import (DailyQuote, IndexDaily, SectorDaily, FundFlowDaily,
SentimentDaily, DragonTiger, Security, JobRun, StockMetric, Trade,
AlertRule, AlertEvent)
AlertRule, AlertEvent, SelectorStrategy, SelectorAlert)
@asynccontextmanager
@@ -43,7 +54,7 @@ async def lifespan(app: FastAPI):
yield
app = FastAPI(title="智策股票终端 API", version="0.2.0", lifespan=lifespan)
app = FastAPI(title="Blackdata股票终端 API", version="0.2.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -253,6 +264,98 @@ def backtest_api(symbol: str = Query("600519"), fast: int = Query(5, ge=2, le=60
return bt.run_backtest(symbol, fast, slow)
# ============ 增强回测 ============
class BacktestParams(BaseModel):
symbol: str
strategy: str = "ma" # ma, multi_factor
fast: int = 5
slow: int = 20
position_size: float = 1.0
stop_loss: float = 0.0
take_profit: float = 0.0
initial_capital: float = 100000.0
commission: float = 0.0005
@app.post("/api/backtest/advanced")
def backtest_advanced(params: BacktestParams):
"""增强回测"""
if params.strategy == "ma":
strategy = bta.MAStrategy(
fast=params.fast,
slow=params.slow,
position_size=params.position_size,
stop_loss=params.stop_loss,
take_profit=params.take_profit
)
elif params.strategy == "multi_factor":
strategy = bta.MultiFactorStrategy(position_size=params.position_size)
else:
return {"ok": False, "msg": "不支持的策略类型"}
return bta.run_advanced_backtest(
symbol=params.symbol,
strategy=strategy,
initial_capital=params.initial_capital,
commission=params.commission
)
class OptimizeParams(BaseModel):
symbol: str
strategy: str = "ma"
fast_range: List[int] = [3, 5, 10, 15]
slow_range: List[int] = [20, 30, 60]
metric: str = "sharpe_ratio"
@app.post("/api/backtest/optimize")
def backtest_optimize(params: OptimizeParams):
"""参数优化"""
param_grid = {
"fast": params.fast_range,
"slow": params.slow_range
}
results = bta.optimize_parameters(
symbol=params.symbol,
param_grid=param_grid,
strategy_class=bta.MAStrategy,
metric=params.metric
)
return {
"ok": True,
"symbol": params.symbol,
"metric": params.metric,
"results": results[:20] # 返回前20个最优结果
}
class CompareParams(BaseModel):
symbol: str
strategies: List[Dict[str, Any]]
@app.post("/api/backtest/compare")
def backtest_compare(params: CompareParams):
"""策略对比"""
strategies = []
for s in params.strategies:
if s["type"] == "ma":
strategies.append(bta.MAStrategy(
fast=s.get("fast", 5),
slow=s.get("slow", 20),
stop_loss=s.get("stop_loss", 0),
take_profit=s.get("take_profit", 0)
))
elif s["type"] == "multi_factor":
strategies.append(bta.MultiFactorStrategy())
return bta.compare_strategies(params.symbol, strategies)
# ============ 全市场选股 ============
STRATEGIES = {
"surge": "最近暴涨5日涨幅≥20%",
@@ -479,6 +582,186 @@ def portfolio_equity():
return pf.equity_curve()
@app.get("/api/portfolio/attribution")
def portfolio_attribution():
"""持仓归因分析"""
return attrib.analyze_attribution()
# ============ AI 对话式分析 ============
class ChatRequest(BaseModel):
session_id: str
message: str
@app.post("/api/chat")
def chat(req: ChatRequest):
"""AI对话"""
return ai_chat.chat(req.session_id, req.message)
@app.delete("/api/chat/{session_id}")
def clear_chat(session_id: str):
"""清空会话"""
ai_chat.clear_session(session_id)
return {"ok": True}
@app.get("/api/chat/{session_id}/history")
def chat_history(session_id: str):
"""获取会话历史"""
return {"ok": True, "messages": ai_chat.get_session_history(session_id)}
# ============ 社区情绪监控 ============
@app.post("/api/sentiment/collect")
def sentiment_collect(limit: int = Query(50, ge=10, le=200)):
"""采集社区帖子"""
return sentiment.collect_posts(limit)
@app.get("/api/sentiment/index")
def sentiment_index(date: Optional[str] = None):
"""获取情绪指数"""
d = dt.date.fromisoformat(date) if date else None
return sentiment.calculate_sentiment_index(d)
@app.get("/api/sentiment/hot_stocks")
def sentiment_hot_stocks(days: int = Query(1, ge=1, le=7), limit: int = Query(20, le=50)):
"""热议股票排行"""
return sentiment.get_hot_stocks(days, limit)
@app.get("/api/sentiment/history")
def sentiment_history(days: int = Query(30, ge=7, le=90)):
"""情绪指数历史"""
return sentiment.get_sentiment_history(days)
@app.get("/api/sentiment/correlation")
def sentiment_correlation(code: str = Query(...), days: int = Query(60, ge=20, le=180)):
"""情绪与股价相关性"""
return sentiment.analyze_sentiment_correlation(code, days)
@app.get("/api/sentiment/wordcloud")
def sentiment_wordcloud(days: int = Query(7, ge=1, le=30), top_n: int = Query(50, le=100)):
"""关键词云"""
return sentiment.get_keyword_cloud(days, top_n)
# ============ 事件驱动策略 ============
@app.post("/api/events/seed")
def events_seed():
"""生成示例事件数据"""
return events.seed_sample_events()
@app.get("/api/events/earnings/pattern")
def earnings_pattern(days_before: int = Query(5, ge=1, le=10), days_after: int = Query(10, ge=5, le=30)):
"""财报发布前后统计规律"""
return events.analyze_earnings_pattern(days_before, days_after)
@app.get("/api/events/insider")
def insider_trading(code: Optional[str] = None, days: int = Query(180, ge=30, le=365)):
"""高管增减持跟踪"""
return events.track_insider_trading(code, days)
@app.get("/api/events/unlock")
def unlock_impact(days: int = Query(90, ge=30, le=180)):
"""限售解禁影响分析"""
return events.analyze_unlock_impact(days)
@app.get("/api/events/policy")
def policy_events(sector: Optional[str] = None, days: int = Query(180, ge=30, le=365)):
"""行业政策事件"""
return events.get_policy_events(sector, days)
class EventSelectorRequest(BaseModel):
event_types: List[str]
days: int = 30
@app.post("/api/events/selector")
def event_selector(req: EventSelectorRequest):
"""事件驱动选股"""
return events.event_driven_selector(req.event_types, req.days)
# ============ 财报深度解读 ============
@app.post("/api/financial/seed")
def financial_seed():
"""生成示例财报数据"""
return fin.seed_sample_reports()
@app.get("/api/financial/trend")
def financial_trend(code: str = Query(...), periods: int = Query(8, ge=4, le=16)):
"""财报关键指标趋势"""
return fin.get_report_trend(code, periods)
@app.get("/api/financial/summary")
def financial_summary(code: str = Query(...)):
"""AI财报摘要"""
return fin.generate_ai_summary(code)
@app.get("/api/financial/compare")
def financial_compare(code: str = Query(...), sector: Optional[str] = None):
"""同行对比"""
return fin.compare_with_peers(code, sector)
@app.get("/api/financial/warnings")
def financial_warnings(code: str = Query(...)):
"""财报异常预警"""
return fin.detect_abnormalities(code)
@app.get("/api/financial/calendar")
def financial_calendar(days: int = Query(30, ge=7, le=90)):
"""财报发布日历"""
return fin.get_report_calendar(days)
@app.get("/api/financial/rankings")
def financial_rankings(metric: str = Query("roe"), limit: int = Query(20, le=50)):
"""财报排行榜"""
return fin.get_top_reports(metric, limit)
# ============ 涨跌停分析 ============
@app.get("/api/limit/stocks")
def limit_stocks(date: Optional[str] = None, limit_type: str = Query("up")):
"""获取涨停/跌停股票"""
d = dt.date.fromisoformat(date) if date else None
return limit_up.get_limit_stocks(d, limit_type)
@app.get("/api/limit/consecutive")
def consecutive_limits(days: int = Query(10, ge=5, le=30)):
"""连板股追踪"""
return limit_up.track_consecutive_limits(days)
@app.get("/api/limit/break_rate")
def limit_break_rate(days: int = Query(60, ge=30, le=180)):
"""炸板率统计"""
return limit_up.analyze_limit_break_rate(days)
@app.get("/api/limit/squad")
def limit_squad(days: int = Query(30, ge=10, le=90), min_limits: int = Query(5, ge=3, le=10)):
"""涨停敢死队排行"""
return limit_up.get_limit_squad_rankings(days, min_limits)
# ============ 推送通知 ============
@app.get("/api/notify/status")
def notify_status():
@@ -489,7 +772,7 @@ def notify_status():
def notify_test():
if not notifier.any_enabled():
return {"ok": False, "msg": "未配置任何推送渠道,请在 backend/.env 配置后重启"}
res = notifier.notify("智策】推送测试", "这是一条来自智策股票终端的测试通知,收到即表示推送通道正常。")
res = notifier.notify("Blackdata】推送测试", "这是一条来自Blackdata股票终端的测试通知,收到即表示推送通道正常。")
return {"ok": True, "result": res}
@@ -613,6 +896,252 @@ def news_ai(n: NewsAI):
"text": f"判断:{senti}(关键词:{''.join(kw) or ''})。摘要:{text_in[:80]}\n(配置大模型后可获得更深入的关联分析)"}
# ============ 盘中实时监控雷达 ============
@app.get("/api/radar/status")
def radar_status():
"""雷达状态。"""
return {"trading_time": radar._is_trading_time()}
@app.post("/api/radar/scan")
def radar_scan():
"""手动触发异动扫描。"""
return radar.scan_all()
@app.get("/api/radar/events")
def radar_events(hours: int = Query(2, ge=1, le=24), limit: int = Query(50, le=200)):
"""获取最近的异动事件。"""
return {"list": radar.get_recent_events(hours, limit)}
@app.post("/api/radar/notify")
def radar_notify():
"""推送未通知的异动。"""
return radar.notify_events()
@app.get("/api/radar/stats")
def radar_stats(date: str = Query(None)):
"""异动统计。"""
d = dt.date.fromisoformat(date) if date else None
return radar.get_statistics(d)
# ============ 板块轮动分析 ============
@app.get("/api/sector/trend")
def sector_trend(days: int = Query(20, ge=5, le=60), top_n: int = Query(15, le=30)):
"""板块强弱趋势"""
return sector.get_sector_trend(days, top_n)
@app.get("/api/sector/flow")
def sector_flow(days: int = Query(5, ge=1, le=20)):
"""资金流向分析"""
return sector.analyze_fund_flow(days)
@app.get("/api/sector/lifecycle")
def sector_lifecycle(name: str = Query(...), days: int = Query(60, ge=20, le=120)):
"""板块生命周期"""
return sector.analyze_lifecycle(name, days)
@app.get("/api/sector/leaders")
def sector_leaders(name: str = Query(...), days: int = Query(20, ge=5, le=60), limit: int = Query(10, le=30)):
"""龙头股识别"""
return sector.identify_leaders(name, days, limit)
@app.get("/api/sector/correlation")
def sector_correlation(days: int = Query(60, ge=20, le=120), top_n: int = Query(20, le=30)):
"""板块联动性分析"""
return sector.analyze_correlation(days, top_n)
@app.get("/api/sector/summary")
def sector_summary():
"""板块轮动摘要"""
return sector.get_rotation_summary()
# ============ 智能选股增强 ============
@app.get("/api/selector/fields")
def selector_fields():
"""获取可用字段"""
return {"ok": True, "fields": selector.get_available_fields()}
@app.get("/api/selector/presets")
def selector_presets():
"""获取预设策略"""
return {"ok": True, "presets": selector.get_preset_strategies()}
class SelectorRequest(BaseModel):
strategy: Dict[str, Any]
date: Optional[str] = None
@app.post("/api/selector/run")
def selector_run(req: SelectorRequest):
"""执行选股"""
try:
strategy = selector.Strategy.from_dict(req.strategy)
date = dt.date.fromisoformat(req.date) if req.date else None
return selector.run_selector(strategy, date)
except Exception as e:
return {"ok": False, "msg": str(e)}
@app.post("/api/selector/backtest")
def selector_backtest(req: SelectorRequest, days: int = Query(60, ge=20, le=250)):
"""选股策略回测"""
try:
strategy = selector.Strategy.from_dict(req.strategy)
return selector.backtest_selector(strategy, days)
except Exception as e:
return {"ok": False, "msg": str(e)}
class CompareRequest(BaseModel):
strategy: Dict[str, Any]
date1: str
date2: str
@app.post("/api/selector/compare")
def selector_compare(req: CompareRequest):
"""对比选股结果"""
try:
strategy = selector.Strategy.from_dict(req.strategy)
date1 = dt.date.fromisoformat(req.date1)
date2 = dt.date.fromisoformat(req.date2)
return selector.compare_results(date1, date2, strategy)
except Exception as e:
return {"ok": False, "msg": str(e)}
@app.get("/api/selector/strategies")
def list_strategies():
"""获取保存的策略列表"""
with get_session() as s:
rows = s.execute(
select(SelectorStrategy).order_by(SelectorStrategy.updated_at.desc())
).scalars().all()
return {
"ok": True,
"strategies": [{
"id": r.id,
"name": r.name,
"description": r.description,
"is_preset": r.is_preset,
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S"),
"updated_at": r.updated_at.strftime("%Y-%m-%d %H:%M:%S")
} for r in rows]
}
class SaveStrategyRequest(BaseModel):
name: str
description: str = ""
strategy: Dict[str, Any]
@app.post("/api/selector/strategies")
def save_strategy(req: SaveStrategyRequest):
"""保存策略"""
try:
strategy = selector.Strategy.from_dict(req.strategy)
with get_session() as s:
record = SelectorStrategy(
name=req.name,
description=req.description,
strategy_json=strategy.to_json()
)
s.add(record)
s.commit()
return {"ok": True, "id": record.id}
except Exception as e:
return {"ok": False, "msg": str(e)}
@app.get("/api/selector/strategies/{sid}")
def get_strategy(sid: int):
"""获取策略详情"""
with get_session() as s:
record = s.get(SelectorStrategy, sid)
if not record:
return {"ok": False, "msg": "策略不存在"}
return {
"ok": True,
"id": record.id,
"name": record.name,
"description": record.description,
"strategy": json.loads(record.strategy_json)
}
@app.delete("/api/selector/strategies/{sid}")
def delete_strategy(sid: int):
"""删除策略"""
with get_session() as s:
record = s.get(SelectorStrategy, sid)
if record:
s.delete(record)
s.commit()
return {"ok": True}
@app.get("/api/selector/alerts")
def list_selector_alerts():
"""获取选股预警列表"""
with get_session() as s:
rows = s.execute(
select(SelectorAlert).order_by(SelectorAlert.id.desc())
).scalars().all()
return {
"ok": True,
"alerts": [{
"id": r.id,
"strategy_id": r.strategy_id,
"strategy_name": r.strategy_name,
"status": r.status,
"last_checked": r.last_checked.strftime("%m-%d %H:%M") if r.last_checked else "",
"last_count": r.last_count
} for r in rows]
}
class CreateAlertRequest(BaseModel):
strategy_id: int
strategy_name: str
@app.post("/api/selector/alerts")
def create_selector_alert(req: CreateAlertRequest):
"""创建选股预警"""
with get_session() as s:
record = SelectorAlert(
strategy_id=req.strategy_id,
strategy_name=req.strategy_name
)
s.add(record)
s.commit()
return {"ok": True, "id": record.id}
@app.delete("/api/selector/alerts/{aid}")
def delete_selector_alert(aid: int):
"""删除选股预警"""
with get_session() as s:
record = s.get(SelectorAlert, aid)
if record:
s.delete(record)
s.commit()
return {"ok": True}
# ============ 静态前端 ============
FRONTEND_DIR = os.path.join(os.path.dirname(BASE_DIR), "prototype")
if os.path.isdir(FRONTEND_DIR):

View File

@@ -221,3 +221,131 @@ class JobRun(Base):
started_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
finished_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
message: Mapped[str] = mapped_column(Text, default="")
class SelectorStrategy(Base):
"""选股策略保存。"""
__tablename__ = "selector_strategies"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(80))
description: Mapped[str] = mapped_column(String(200), default="")
strategy_json: Mapped[str] = mapped_column(Text) # JSON格式的策略定义
is_preset: Mapped[bool] = mapped_column(default=False) # 是否预设策略
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class SelectorAlert(Base):
"""选股条件预警。"""
__tablename__ = "selector_alerts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
strategy_id: Mapped[int] = mapped_column(Integer, index=True)
strategy_name: Mapped[str] = mapped_column(String(80))
status: Mapped[str] = mapped_column(String(12), default="active") # active/paused
last_checked: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
last_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class SocialPost(Base):
"""社区帖子。"""
__tablename__ = "social_posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
source: Mapped[str] = mapped_column(String(20), index=True) # eastmoney/xueqiu/guba
post_id: Mapped[str] = mapped_column(String(100), unique=True)
code: Mapped[str] = mapped_column(String(12), index=True, default="")
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column(Text, default="")
author: Mapped[str] = mapped_column(String(80), default="")
comment_count: Mapped[int] = mapped_column(Integer, default=0)
view_count: Mapped[int] = mapped_column(Integer, default=0)
sentiment: Mapped[str] = mapped_column(String(20), default="neutral") # bullish/bearish/neutral
keywords: Mapped[str] = mapped_column(String(200), default="") # 逗号分隔
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
class SentimentIndex(Base):
"""社区情绪指数(每日)。"""
__tablename__ = "sentiment_index"
date: Mapped[dt.date] = mapped_column(Date, primary_key=True)
bullish_count: Mapped[int] = mapped_column(Integer, default=0)
bearish_count: Mapped[int] = mapped_column(Integer, default=0)
neutral_count: Mapped[int] = mapped_column(Integer, default=0)
bullish_ratio: Mapped[float] = mapped_column(Float, default=0.0) # 0-100
total_posts: Mapped[int] = mapped_column(Integer, default=0)
top_keywords: Mapped[str] = mapped_column(String(500), default="") # JSON格式
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class CorporateEvent(Base):
"""公司事件(财报、增减持、限售解禁等)。"""
__tablename__ = "corporate_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(12), index=True)
name: Mapped[str] = mapped_column(String(40), default="")
event_type: Mapped[str] = mapped_column(String(20), index=True) # earnings/insider/unlock/dividend
event_date: Mapped[dt.date] = mapped_column(Date, index=True)
title: Mapped[str] = mapped_column(String(200))
description: Mapped[str] = mapped_column(Text, default="")
amount: Mapped[float] = mapped_column(Float, default=0.0) # 金额(亿元)
impact: Mapped[str] = mapped_column(String(20), default="neutral") # positive/negative/neutral
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class PolicyEvent(Base):
"""行业政策事件。"""
__tablename__ = "policy_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sector: Mapped[str] = mapped_column(String(40), index=True) # 受影响板块
event_date: Mapped[dt.date] = mapped_column(Date, index=True)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column(Text, default="")
policy_type: Mapped[str] = mapped_column(String(40)) # subsidy/restriction/support/regulation
impact: Mapped[str] = mapped_column(String(20), default="neutral")
affected_stocks: Mapped[str] = mapped_column(String(500), default="") # 逗号分隔的股票代码
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class FinancialReport(Base):
"""财务报表数据。"""
__tablename__ = "financial_reports"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(12), index=True)
name: Mapped[str] = mapped_column(String(40), default="")
report_date: Mapped[dt.date] = mapped_column(Date, index=True) # 报告期
publish_date: Mapped[dt.date] = mapped_column(Date, index=True) # 发布日期
report_type: Mapped[str] = mapped_column(String(20)) # Q1/Q2/Q3/annual
# 核心指标
revenue: Mapped[float] = mapped_column(Float, default=0.0) # 营收(亿元)
net_profit: Mapped[float] = mapped_column(Float, default=0.0) # 净利润(亿元)
roe: Mapped[float] = mapped_column(Float, default=0.0) # 净资产收益率(%)
gross_margin: Mapped[float] = mapped_column(Float, default=0.0) # 毛利率(%)
revenue_growth: Mapped[float] = mapped_column(Float, default=0.0) # 营收同比增长(%)
profit_growth: Mapped[float] = mapped_column(Float, default=0.0) # 净利润同比增长(%)
# 风险指标
inventory: Mapped[float] = mapped_column(Float, default=0.0) # 存货(亿元)
receivable: Mapped[float] = mapped_column(Float, default=0.0) # 应收账款(亿元)
debt_ratio: Mapped[float] = mapped_column(Float, default=0.0) # 资产负债率(%)
# AI摘要
ai_summary: Mapped[str] = mapped_column(String(500), default="")
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class IntradayEvent(Base):
"""盘中异动事件记录。"""
__tablename__ = "intraday_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(12), index=True)
name: Mapped[str] = mapped_column(String(40), default="")
event_type: Mapped[str] = mapped_column(String(20), index=True) # surge/volume_break/limit_open/consecutive/big_order
price: Mapped[float] = mapped_column(Float, default=0.0)
pct: Mapped[float] = mapped_column(Float, default=0.0)
volume_ratio: Mapped[float] = mapped_column(Float, default=0.0)
amount: Mapped[float] = mapped_column(Float, default=0.0) # 对于big_order是单笔金额
description: Mapped[str] = mapped_column(String(200), default="")
detected_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
notified: Mapped[bool] = mapped_column(default=False)

View File

@@ -116,7 +116,7 @@ def generate(date=None, push=False):
try:
# 推送精简版(情绪 + 领涨 + AI 点评首段)
brief = _push_brief(g, rv)
res = notifier.notify("智策" + title, brief)
res = notifier.notify("Blackdata" + title, brief)
with get_session() as s:
r2 = s.get(DailyReport, d)
if r2:

View File

@@ -6,3 +6,5 @@ cachetools==5.5.0
SQLAlchemy>=2.0.30
APScheduler>=3.10.4
psycopg2-binary>=2.9.9
jieba>=0.42.1
numpy>=1.26.0

View File

@@ -12,6 +12,7 @@ import ingest
import alerts
import report
import signals
import intraday_radar
_scheduler: BackgroundScheduler | None = None
_lock = threading.Lock()
@@ -128,11 +129,16 @@ def start_scheduler():
_job_verify, CronTrigger(day_of_week="mon-fri", hour=(_rep_total // 60) % 24, minute=(_rep_total + 5) % 60),
id="verify_pred", replace_existing=True, misfire_grace_time=3600,
)
# 每周六重算信号历史胜率
# 每周六重算信号历史胜率
_scheduler.add_job(
_job_signal_stats, CronTrigger(day_of_week="sat", hour=9, minute=0),
id="signal_stats", replace_existing=True, misfire_grace_time=7200,
)
# 盘中异动扫描(交易时间每分钟)
_scheduler.add_job(
_safe_scan_intraday, IntervalTrigger(seconds=60),
id="intraday_scan", replace_existing=True, max_instances=1,
)
_scheduler.start()
return _scheduler
@@ -142,3 +148,13 @@ def _safe_check_alerts():
alerts.check_alerts()
except Exception as e:
print("[alert] check error:", repr(e)[:120])
def _safe_scan_intraday():
try:
result = intraday_radar.scan_all()
if result.get("count", 0) > 0:
# 有新异动时自动推送
intraday_radar.notify_events()
except Exception as e:
print("[intraday] scan error:", repr(e)[:120])

483
backend/sector_rotation.py Normal file
View File

@@ -0,0 +1,483 @@
"""板块轮动分析 — 追踪板块强弱、资金流向、生命周期。
功能:
1. 板块强弱排名趋势
2. 资金流向分析
3. 板块生命周期判断
4. 龙头股识别
5. 板块联动性分析
"""
import datetime as dt
from typing import Dict, List, Any, Optional
import numpy as np
from sqlalchemy import select, func, and_
from db import get_session
from models import SectorDaily, FundFlowDaily, DailyQuote, StockMetric
def get_sector_trend(days: int = 20, top_n: int = 15) -> Dict[str, Any]:
"""获取板块强弱趋势
Args:
days: 统计天数
top_n: 返回前N个板块
Returns:
板块趋势数据
"""
with get_session() as s:
# 获取最近N天的日期
latest_date = s.execute(select(func.max(SectorDaily.date))).scalar()
if not latest_date:
return {"ok": False, "msg": "暂无板块数据"}
start_date = latest_date - dt.timedelta(days=days)
# 查询板块数据
rows = s.execute(
select(SectorDaily)
.where(SectorDaily.date >= start_date)
.order_by(SectorDaily.date, SectorDaily.name)
).scalars().all()
if not rows:
return {"ok": False, "msg": "数据不足"}
# 按板块聚合
sector_data = {}
for row in rows:
if row.name not in sector_data:
sector_data[row.name] = {
"name": row.name,
"dates": [],
"pcts": [],
"amounts": []
}
sector_data[row.name]["dates"].append(row.date.isoformat())
sector_data[row.name]["pcts"].append(float(row.pct))
sector_data[row.name]["amounts"].append(float(row.amount))
# 计算累计涨跌幅和平均成交额
sector_stats = []
for name, data in sector_data.items():
pcts = data["pcts"]
amounts = data["amounts"]
# 累计收益(复利)
cumulative = 1.0
for p in pcts:
cumulative *= (1 + p / 100)
cumulative_return = (cumulative - 1) * 100
# 近5日、10日、20日收益
returns = {
"5d": sum(pcts[-5:]) if len(pcts) >= 5 else 0,
"10d": sum(pcts[-10:]) if len(pcts) >= 10 else 0,
"20d": cumulative_return
}
# 平均成交额
avg_amount = np.mean(amounts) if amounts else 0
# 波动率(标准差)
volatility = np.std(pcts) if len(pcts) > 1 else 0
sector_stats.append({
"name": name,
"returns": returns,
"avg_amount": round(avg_amount, 2),
"volatility": round(volatility, 2),
"dates": data["dates"],
"pcts": [round(p, 2) for p in pcts]
})
# 按20日收益排序
sector_stats.sort(key=lambda x: x["returns"]["20d"], reverse=True)
return {
"ok": True,
"date": latest_date.isoformat(),
"days": days,
"sectors": sector_stats[:top_n]
}
def analyze_fund_flow(days: int = 5) -> Dict[str, Any]:
"""分析资金流向(板块间流动)
Args:
days: 分析天数
Returns:
资金流向数据(桑基图格式)
"""
with get_session() as s:
latest_date = s.execute(select(func.max(FundFlowDaily.date))).scalar()
if not latest_date:
return {"ok": False, "msg": "暂无资金流数据"}
start_date = latest_date - dt.timedelta(days=days)
# 查询资金流数据
rows = s.execute(
select(FundFlowDaily)
.where(FundFlowDaily.date >= start_date)
.order_by(FundFlowDaily.date, FundFlowDaily.name)
).scalars().all()
if not rows:
return {"ok": False, "msg": "数据不足"}
# 按板块聚合净流入
flow_data = {}
for row in rows:
if row.name not in flow_data:
flow_data[row.name] = 0
flow_data[row.name] += float(row.net)
# 分类:流入 vs 流出
inflows = [(k, v) for k, v in flow_data.items() if v > 0]
outflows = [(k, abs(v)) for k, v in flow_data.items() if v < 0]
inflows.sort(key=lambda x: x[1], reverse=True)
outflows.sort(key=lambda x: x[1], reverse=True)
# 构造桑基图数据
nodes = []
links = []
# 流出节点(左侧)
for i, (name, amount) in enumerate(outflows[:8]):
nodes.append({"name": f"{name}(流出)"})
# 流向"资金池"
links.append({
"source": len(nodes) - 1,
"target": len(outflows[:8]), # 资金池索引
"value": round(amount, 2)
})
# 资金池(中间)
nodes.append({"name": "资金池"})
# 流入节点(右侧)
for i, (name, amount) in enumerate(inflows[:8]):
nodes.append({"name": f"{name}(流入)"})
# 从"资金池"流入
links.append({
"source": len(outflows[:8]), # 资金池索引
"target": len(nodes) - 1,
"value": round(amount, 2)
})
return {
"ok": True,
"date": latest_date.isoformat(),
"days": days,
"total_inflow": round(sum(v for _, v in inflows), 2),
"total_outflow": round(sum(v for _, v in outflows), 2),
"top_inflow": inflows[:8],
"top_outflow": outflows[:8],
"sankey": {
"nodes": nodes,
"links": links
}
}
def analyze_lifecycle(sector_name: str, days: int = 60) -> Dict[str, Any]:
"""分析板块生命周期
Args:
sector_name: 板块名称
days: 分析天数
Returns:
生命周期判断
"""
with get_session() as s:
latest_date = s.execute(select(func.max(SectorDaily.date))).scalar()
if not latest_date:
return {"ok": False, "msg": "暂无数据"}
start_date = latest_date - dt.timedelta(days=days)
rows = s.execute(
select(SectorDaily)
.where(
and_(
SectorDaily.name == sector_name,
SectorDaily.date >= start_date
)
)
.order_by(SectorDaily.date)
).scalars().all()
if len(rows) < 20:
return {"ok": False, "msg": "数据不足"}
# 提取数据
dates = [r.date.isoformat() for r in rows]
pcts = [float(r.pct) for r in rows]
amounts = [float(r.amount) for r in rows]
# 计算指标
# 1. 近期涨跌幅趋势
recent_5 = sum(pcts[-5:])
recent_10 = sum(pcts[-10:])
recent_20 = sum(pcts[-20:])
# 2. 成交额趋势
amount_5 = np.mean(amounts[-5:])
amount_20 = np.mean(amounts[-20:])
amount_change = (amount_5 / amount_20 - 1) * 100 if amount_20 > 0 else 0
# 3. 动量(价格变化加速度)
momentum = recent_5 - recent_10
# 生命周期判断
if recent_20 > 0 and momentum > 0 and amount_change > 20:
phase = "启动期"
description = "板块刚开始上涨,资金流入加速,可能是介入时机"
elif recent_20 > 5 and recent_10 > recent_20 / 2 and amount_change > 0:
phase = "加速期"
description = "板块持续上涨且加速,成交活跃,主升浪阶段"
elif recent_20 > 0 and momentum < 0:
phase = "衰退期"
description = "板块涨幅收窄或开始回调,资金开始流出,注意风险"
elif recent_20 < -5:
phase = "下跌期"
description = "板块持续下跌,避免介入"
else:
phase = "震荡期"
description = "板块横盘整理,方向不明"
return {
"ok": True,
"sector": sector_name,
"phase": phase,
"description": description,
"metrics": {
"return_5d": round(recent_5, 2),
"return_10d": round(recent_10, 2),
"return_20d": round(recent_20, 2),
"momentum": round(momentum, 2),
"amount_change": round(amount_change, 2)
},
"dates": dates,
"pcts": [round(p, 2) for p in pcts]
}
def identify_leaders(sector_name: str, days: int = 20, limit: int = 10) -> Dict[str, Any]:
"""识别板块龙头股
Args:
sector_name: 板块名称
days: 统计天数
limit: 返回数量
Returns:
龙头股列表
"""
# 注意:需要股票-板块映射表,这里简化为通过名称匹配
# 实际应该有 stock_sector 映射表
with get_session() as s:
# 获取最近N天表现最好的股票
latest_date = s.execute(select(func.max(StockMetric.date))).scalar()
if not latest_date:
return {"ok": False, "msg": "暂无股票数据"}
# 查询高涨幅、高成交额股票
rows = s.execute(
select(StockMetric)
.where(
and_(
StockMetric.date == latest_date,
StockMetric.ret20 > 0,
StockMetric.amount > 5 # 成交额 > 5亿
)
)
.order_by(
StockMetric.ret20.desc(),
StockMetric.amount.desc()
)
.limit(limit * 3) # 多取一些,后续筛选
).scalars().all()
# 简化:根据名称关键词匹配板块(实际应该查询映射表)
sector_keywords = {
"半导体": ["芯片", "半导体", "集成电路"],
"新能源": ["新能源", "锂电", "光伏", "储能"],
"医药": ["医药", "生物", "医疗", "药业"],
"白酒": ["", "茅台", "五粮液"],
"军工": ["军工", "航天", "航空", "兵器"],
"AI": ["人工智能", "AI", "算力", "云计算"],
}
keywords = sector_keywords.get(sector_name, [sector_name])
leaders = []
for row in rows:
if any(kw in row.name for kw in keywords):
leaders.append({
"code": row.code,
"name": row.name,
"close": round(row.close, 2),
"pct": round(row.pct, 2),
"ret5": round(row.ret5, 2),
"ret20": round(row.ret20, 2),
"amount": round(row.amount, 2),
"vol_ratio": round(row.vol_ratio, 2)
})
if len(leaders) >= limit:
break
return {
"ok": True,
"sector": sector_name,
"date": latest_date.isoformat(),
"leaders": leaders
}
def analyze_correlation(days: int = 60, top_n: int = 20) -> Dict[str, Any]:
"""板块联动性分析(相关系数矩阵)
Args:
days: 计算天数
top_n: 分析前N个板块
Returns:
相关系数矩阵(热力图数据)
"""
with get_session() as s:
latest_date = s.execute(select(func.max(SectorDaily.date))).scalar()
if not latest_date:
return {"ok": False, "msg": "暂无数据"}
start_date = latest_date - dt.timedelta(days=days)
rows = s.execute(
select(SectorDaily)
.where(SectorDaily.date >= start_date)
.order_by(SectorDaily.date, SectorDaily.name)
).scalars().all()
if not rows:
return {"ok": False, "msg": "数据不足"}
# 按板块聚合涨跌幅
sector_returns = {}
for row in rows:
if row.name not in sector_returns:
sector_returns[row.name] = []
sector_returns[row.name].append(float(row.pct))
# 筛选数据完整的板块
valid_sectors = {k: v for k, v in sector_returns.items() if len(v) >= days * 0.8}
if len(valid_sectors) < 5:
return {"ok": False, "msg": "有效板块不足"}
# 选择前N个板块按最近涨幅
sector_list = []
for name, rets in valid_sectors.items():
recent_return = sum(rets[-min(10, len(rets)):])
sector_list.append((name, recent_return, rets))
sector_list.sort(key=lambda x: x[1], reverse=True)
selected = sector_list[:top_n]
# 计算相关系数矩阵
names = [s[0] for s in selected]
returns_matrix = np.array([s[2][:days] for s in selected])
# 填充短数据用0
max_len = max(len(r) for r in returns_matrix)
padded = []
for r in returns_matrix:
if len(r) < max_len:
r = list(r) + [0] * (max_len - len(r))
padded.append(r[:max_len])
returns_matrix = np.array(padded)
# 计算相关系数
corr_matrix = np.corrcoef(returns_matrix)
# 转换为热力图数据
heatmap_data = []
for i in range(len(names)):
for j in range(len(names)):
heatmap_data.append({
"x": j,
"y": i,
"value": round(float(corr_matrix[i][j]), 3)
})
# 找出高度相关的板块对(相关系数 > 0.7
high_corr = []
for i in range(len(names)):
for j in range(i + 1, len(names)):
corr = float(corr_matrix[i][j])
if corr > 0.7:
high_corr.append({
"sector1": names[i],
"sector2": names[j],
"correlation": round(corr, 3)
})
high_corr.sort(key=lambda x: x["correlation"], reverse=True)
return {
"ok": True,
"days": days,
"sectors": names,
"matrix": corr_matrix.tolist(),
"heatmap": heatmap_data,
"high_correlation": high_corr[:10]
}
def get_rotation_summary() -> Dict[str, Any]:
"""获取板块轮动综合摘要
Returns:
轮动摘要
"""
# 获取最强和最弱板块
trend = get_sector_trend(days=10, top_n=20)
if not trend.get("ok"):
return {"ok": False, "msg": "数据不足"}
sectors = trend["sectors"]
strongest = sectors[:3]
weakest = sectors[-3:]
# 资金流向
flow = analyze_fund_flow(days=5)
summary = {
"ok": True,
"date": trend["date"],
"strongest_sectors": [
{
"name": s["name"],
"return_10d": s["returns"]["10d"]
} for s in strongest
],
"weakest_sectors": [
{
"name": s["name"],
"return_10d": s["returns"]["10d"]
} for s in weakest
],
"fund_flow": {
"top_inflow": flow.get("top_inflow", [])[:3] if flow.get("ok") else [],
"top_outflow": flow.get("top_outflow", [])[:3] if flow.get("ok") else []
}
}
return summary

View File

@@ -0,0 +1,556 @@
"""社区情绪监控 — 爬取分析东方财富/雪球热帖,量化散户情绪。
功能:
1. 爬取社区热帖
2. 情绪分析(乐观/悲观)
3. 热议股票排行
4. 关键词提取和词云
5. 情绪与股价相关性分析
"""
import datetime as dt
import json
import re
from typing import List, Dict, Any, Optional
from collections import Counter, defaultdict
import requests
from bs4 import BeautifulSoup
import jieba
import jieba.analyse
from sqlalchemy import select, func, and_, desc
from db import get_session
from models import SocialPost, SentimentIndex, DailyQuote, StockMetric
# 情绪关键词库
BULLISH_KEYWORDS = [
'看多', '看好', '买入', '加仓', '抄底', '突破', '上涨', '暴涨', '牛市',
'利好', '反弹', '强势', '拉升', '涨停', '走强', '看涨', '做多'
]
BEARISH_KEYWORDS = [
'看空', '看跌', '卖出', '减仓', '止损', '下跌', '暴跌', '熊市',
'利空', '回调', '弱势', '下杀', '跌停', '走弱', '做空', '被套'
]
# 停用词
STOP_WORDS = set([
'', '', '', '', '', '', '', '', '', '', '', '',
'一个', '', '', '', '', '', '', '', '', '', '', '没有',
'', '', '自己', '', '', '', '', '', '', '', '', ''
])
def crawl_eastmoney_hot(limit: int = 50) -> List[Dict[str, Any]]:
"""爬取东方财富热帖(简化版,实际需要处理反爬)
注意:由于反爬限制,这里返回模拟数据
实际生产环境需要:
1. 使用代理IP
2. 模拟浏览器headers
3. 控制请求频率
4. 处理验证码
"""
# 模拟数据(实际应该爬取真实数据)
mock_posts = [
{
'source': 'eastmoney',
'post_id': f'em_{i}',
'title': f'模拟帖子{i}:今天大盘要反弹了',
'content': '技术分析显示底部信号明显,建议逢低买入',
'author': f'用户{i}',
'comment_count': 100 + i * 10,
'view_count': 1000 + i * 100,
}
for i in range(limit)
]
return mock_posts
def crawl_xueqiu_hot(limit: int = 50) -> List[Dict[str, Any]]:
"""爬取雪球热帖(简化版)"""
# 雪球API需要cookie和token
# 实际使用需要登录后获取token
mock_posts = [
{
'source': 'xueqiu',
'post_id': f'xq_{i}',
'title': f'雪球热议{i}:半导体板块分析',
'content': '从产业链角度看,半导体景气度回升',
'author': f'雪球用户{i}',
'comment_count': 50 + i * 5,
'view_count': 500 + i * 50,
}
for i in range(limit)
]
return mock_posts
def analyze_sentiment(text: str) -> str:
"""分析文本情绪
Args:
text: 待分析文本
Returns:
情绪标签bullish/bearish/neutral
"""
text_lower = text.lower()
bullish_score = sum(1 for kw in BULLISH_KEYWORDS if kw in text_lower)
bearish_score = sum(1 for kw in BEARISH_KEYWORDS if kw in text_lower)
if bullish_score > bearish_score and bullish_score >= 2:
return 'bullish'
elif bearish_score > bullish_score and bearish_score >= 2:
return 'bearish'
else:
return 'neutral'
def extract_keywords(text: str, top_n: int = 10) -> List[str]:
"""提取关键词
Args:
text: 文本内容
top_n: 返回前N个关键词
Returns:
关键词列表
"""
# 使用jieba提取关键词
keywords = jieba.analyse.extract_tags(text, topK=top_n, withWeight=False)
# 过滤停用词
keywords = [kw for kw in keywords if kw not in STOP_WORDS and len(kw) > 1]
return keywords[:top_n]
def extract_stock_codes(text: str) -> List[str]:
"""从文本中提取股票代码
Args:
text: 文本内容
Returns:
股票代码列表
"""
# 匹配6位数字的股票代码
pattern = r'\b[036]\d{5}\b'
codes = re.findall(pattern, text)
return list(set(codes))
def collect_posts(limit_per_source: int = 50) -> Dict[str, Any]:
"""采集社区帖子
Args:
limit_per_source: 每个来源采集数量
Returns:
采集结果
"""
all_posts = []
# 采集东方财富
try:
em_posts = crawl_eastmoney_hot(limit_per_source)
all_posts.extend(em_posts)
except Exception as e:
print(f"[eastmoney] crawl error: {e}")
# 采集雪球
try:
xq_posts = crawl_xueqiu_hot(limit_per_source)
all_posts.extend(xq_posts)
except Exception as e:
print(f"[xueqiu] crawl error: {e}")
# 分析并存储
saved_count = 0
with get_session() as s:
for post in all_posts:
# 检查是否已存在
exists = s.execute(
select(SocialPost).where(SocialPost.post_id == post['post_id'])
).scalar_one_or_none()
if exists:
continue
# 情绪分析
text = post['title'] + ' ' + post.get('content', '')
sentiment = analyze_sentiment(text)
# 提取关键词
keywords = extract_keywords(text, top_n=5)
# 提取股票代码
codes = extract_stock_codes(text)
code = codes[0] if codes else ''
# 存储
record = SocialPost(
source=post['source'],
post_id=post['post_id'],
code=code,
title=post['title'],
content=post.get('content', ''),
author=post.get('author', ''),
comment_count=post.get('comment_count', 0),
view_count=post.get('view_count', 0),
sentiment=sentiment,
keywords=','.join(keywords)
)
s.add(record)
saved_count += 1
s.commit()
return {
'ok': True,
'collected': len(all_posts),
'saved': saved_count
}
def calculate_sentiment_index(date: Optional[dt.date] = None) -> Dict[str, Any]:
"""计算情绪指数
Args:
date: 统计日期None表示今天
Returns:
情绪指数数据
"""
if date is None:
date = dt.date.today()
start = dt.datetime.combine(date, dt.time.min)
end = dt.datetime.combine(date, dt.time.max)
with get_session() as s:
# 统计各情绪数量
posts = s.execute(
select(SocialPost)
.where(
and_(
SocialPost.created_at >= start,
SocialPost.created_at <= end
)
)
).scalars().all()
if not posts:
return {'ok': False, 'msg': '暂无数据'}
bullish_count = sum(1 for p in posts if p.sentiment == 'bullish')
bearish_count = sum(1 for p in posts if p.sentiment == 'bearish')
neutral_count = sum(1 for p in posts if p.sentiment == 'neutral')
total = len(posts)
bullish_ratio = bullish_count / total * 100 if total > 0 else 0
# 提取热门关键词
all_keywords = []
for p in posts:
if p.keywords:
all_keywords.extend(p.keywords.split(','))
keyword_counter = Counter(all_keywords)
top_keywords = [
{'word': kw, 'count': cnt}
for kw, cnt in keyword_counter.most_common(20)
]
# 存储情绪指数
index_record = s.execute(
select(SentimentIndex).where(SentimentIndex.date == date)
).scalar_one_or_none()
if index_record:
index_record.bullish_count = bullish_count
index_record.bearish_count = bearish_count
index_record.neutral_count = neutral_count
index_record.bullish_ratio = bullish_ratio
index_record.total_posts = total
index_record.top_keywords = json.dumps(top_keywords, ensure_ascii=False)
index_record.updated_at = dt.datetime.now()
else:
index_record = SentimentIndex(
date=date,
bullish_count=bullish_count,
bearish_count=bearish_count,
neutral_count=neutral_count,
bullish_ratio=bullish_ratio,
total_posts=total,
top_keywords=json.dumps(top_keywords, ensure_ascii=False)
)
s.add(index_record)
s.commit()
return {
'ok': True,
'date': date.isoformat(),
'bullish_count': bullish_count,
'bearish_count': bearish_count,
'neutral_count': neutral_count,
'bullish_ratio': round(bullish_ratio, 2),
'total_posts': total,
'top_keywords': top_keywords
}
def get_hot_stocks(days: int = 1, limit: int = 20) -> Dict[str, Any]:
"""获取热议股票排行
Args:
days: 统计天数
limit: 返回数量
Returns:
热议股票列表
"""
since = dt.datetime.now() - dt.timedelta(days=days)
with get_session() as s:
# 按股票代码分组统计
stmt = (
select(
SocialPost.code,
func.count().label('post_count'),
func.sum(SocialPost.comment_count).label('total_comments'),
func.sum(SocialPost.view_count).label('total_views')
)
.where(
and_(
SocialPost.code != '',
SocialPost.created_at >= since
)
)
.group_by(SocialPost.code)
.order_by(desc('post_count'))
.limit(limit)
)
rows = s.execute(stmt).all()
if not rows:
return {'ok': False, 'msg': '暂无数据'}
# 获取股票名称和最新价格
codes = [r.code for r in rows]
metrics = {}
for m in s.execute(
select(StockMetric)
.where(StockMetric.code.in_(codes))
).scalars():
metrics[m.code] = {
'name': m.name,
'close': m.close,
'pct': m.pct
}
results = []
for r in rows:
info = metrics.get(r.code, {'name': r.code, 'close': 0, 'pct': 0})
results.append({
'code': r.code,
'name': info['name'],
'post_count': r.post_count,
'total_comments': r.total_comments or 0,
'total_views': r.total_views or 0,
'heat_score': r.post_count * 10 + (r.total_comments or 0),
'close': info['close'],
'pct': info['pct']
})
# 按热度评分排序
results.sort(key=lambda x: x['heat_score'], reverse=True)
return {
'ok': True,
'days': days,
'stocks': results
}
def get_sentiment_history(days: int = 30) -> Dict[str, Any]:
"""获取情绪指数历史
Args:
days: 统计天数
Returns:
历史数据
"""
since = dt.date.today() - dt.timedelta(days=days)
with get_session() as s:
rows = s.execute(
select(SentimentIndex)
.where(SentimentIndex.date >= since)
.order_by(SentimentIndex.date)
).scalars().all()
if not rows:
return {'ok': False, 'msg': '暂无历史数据'}
return {
'ok': True,
'dates': [r.date.isoformat() for r in rows],
'bullish_ratio': [round(r.bullish_ratio, 2) for r in rows],
'total_posts': [r.total_posts for r in rows]
}
def analyze_sentiment_correlation(code: str, days: int = 60) -> Dict[str, Any]:
"""分析情绪与股价相关性
Args:
code: 股票代码
days: 分析天数
Returns:
相关性分析结果
"""
since = dt.date.today() - dt.timedelta(days=days)
with get_session() as s:
# 获取该股票的讨论量和情绪
posts = s.execute(
select(SocialPost)
.where(
and_(
SocialPost.code == code,
func.date(SocialPost.created_at) >= since
)
)
).scalars().all()
if not posts:
return {'ok': False, 'msg': '该股票暂无社区数据'}
# 按日期聚合
daily_sentiment = defaultdict(lambda: {'bullish': 0, 'bearish': 0, 'neutral': 0, 'total': 0})
for p in posts:
date = p.created_at.date()
daily_sentiment[date][p.sentiment] += 1
daily_sentiment[date]['total'] += 1
# 获取股价数据
prices = {}
for q in s.execute(
select(DailyQuote)
.where(
and_(
DailyQuote.code == code,
DailyQuote.date >= since
)
)
.order_by(DailyQuote.date)
).scalars():
prices[q.date] = {
'close': float(q.close),
'pct': ((float(q.close) - float(q.open)) / float(q.open) * 100) if q.open > 0 else 0
}
if not prices:
return {'ok': False, 'msg': '缺少股价数据'}
# 计算相关性(简化版)
dates = sorted(set(daily_sentiment.keys()) & set(prices.keys()))
if len(dates) < 10:
return {'ok': False, 'msg': '数据点不足'}
sentiment_scores = []
price_changes = []
for date in dates:
s_data = daily_sentiment[date]
bullish_ratio = s_data['bullish'] / s_data['total'] * 100 if s_data['total'] > 0 else 50
sentiment_scores.append(bullish_ratio)
price_changes.append(prices[date]['pct'])
# 计算相关系数(简化版)
import numpy as np
if len(sentiment_scores) > 1:
correlation = np.corrcoef(sentiment_scores, price_changes)[0, 1]
else:
correlation = 0
return {
'ok': True,
'code': code,
'days': days,
'data_points': len(dates),
'correlation': round(float(correlation), 3),
'interpretation': _interpret_correlation(correlation),
'dates': [d.isoformat() for d in dates],
'sentiment_scores': [round(s, 2) for s in sentiment_scores],
'price_changes': [round(p, 2) for p in price_changes]
}
def _interpret_correlation(corr: float) -> str:
"""解释相关系数"""
if corr > 0.7:
return '强正相关:情绪高涨时股价往往上涨'
elif corr > 0.3:
return '中度正相关:情绪与股价有一定同步性'
elif corr > -0.3:
return '弱相关:情绪与股价关系不明显'
elif corr > -0.7:
return '中度负相关:情绪高涨时股价反而下跌(反向指标)'
else:
return '强负相关:典型反向指标,情绪越乐观越要警惕'
def get_keyword_cloud(days: int = 7, top_n: int = 50) -> Dict[str, Any]:
"""获取关键词云数据
Args:
days: 统计天数
top_n: 返回前N个关键词
Returns:
词云数据
"""
since = dt.datetime.now() - dt.timedelta(days=days)
with get_session() as s:
posts = s.execute(
select(SocialPost)
.where(SocialPost.created_at >= since)
).scalars().all()
if not posts:
return {'ok': False, 'msg': '暂无数据'}
# 收集所有关键词
all_keywords = []
for p in posts:
if p.keywords:
all_keywords.extend(p.keywords.split(','))
# 统计词频
keyword_counter = Counter(all_keywords)
# 格式化为词云数据
wordcloud_data = [
{'name': kw, 'value': cnt}
for kw, cnt in keyword_counter.most_common(top_n)
]
return {
'ok': True,
'days': days,
'keywords': wordcloud_data
}

390
backend/smart_selector.py Normal file
View File

@@ -0,0 +1,390 @@
"""智能选股引擎 — 可视化选股器、策略保存、回测验证。
功能:
1. 多条件组合选股
2. 选股策略保存/加载
3. 选股结果历史回测
4. 选股结果对比(新入选/退出)
5. 条件预警
"""
import datetime as dt
import json
from typing import Dict, List, Any, Optional
from sqlalchemy import select, and_, or_, func
import numpy as np
from db import get_session
from models import StockMetric, DailyQuote, Security
class Condition:
"""选股条件"""
def __init__(self, field: str, operator: str, value: Any):
self.field = field
self.operator = operator
self.value = value
def to_dict(self):
return {
"field": self.field,
"operator": self.operator,
"value": self.value
}
@classmethod
def from_dict(cls, data: Dict):
return cls(data["field"], data["operator"], data["value"])
def to_sql(self, model):
"""转换为 SQLAlchemy 查询条件"""
field = getattr(model, self.field, None)
if field is None:
return None
op = self.operator
val = self.value
if op == "==":
return field == val
elif op == ">":
return field > val
elif op == ">=":
return field >= val
elif op == "<":
return field < val
elif op == "<=":
return field <= val
elif op == "between":
return and_(field >= val[0], field <= val[1])
elif op == "in":
return field.in_(val)
else:
return None
class Strategy:
"""选股策略"""
def __init__(self, name: str, description: str = ""):
self.name = name
self.description = description
self.conditions: List[Condition] = []
self.logic = "and" # and / or
def add_condition(self, field: str, operator: str, value: Any):
"""添加条件"""
self.conditions.append(Condition(field, operator, value))
def to_dict(self):
return {
"name": self.name,
"description": self.description,
"logic": self.logic,
"conditions": [c.to_dict() for c in self.conditions]
}
@classmethod
def from_dict(cls, data: Dict):
strategy = cls(data["name"], data.get("description", ""))
strategy.logic = data.get("logic", "and")
for cond in data.get("conditions", []):
strategy.conditions.append(Condition.from_dict(cond))
return strategy
def to_json(self) -> str:
"""序列化为JSON"""
return json.dumps(self.to_dict(), ensure_ascii=False)
@classmethod
def from_json(cls, json_str: str):
"""从JSON反序列化"""
return cls.from_dict(json.loads(json_str))
def run_selector(strategy: Strategy, date: Optional[dt.date] = None) -> Dict[str, Any]:
"""执行选股
Args:
strategy: 选股策略
date: 选股日期None表示最新
Returns:
选股结果
"""
with get_session() as s:
# 确定日期
if date is None:
date = s.execute(select(func.max(StockMetric.date))).scalar()
if not date:
return {"ok": False, "msg": "暂无数据"}
# 构建查询
query = select(StockMetric).where(StockMetric.date == date)
# 应用条件
sql_conditions = []
for cond in strategy.conditions:
sql_cond = cond.to_sql(StockMetric)
if sql_cond is not None:
sql_conditions.append(sql_cond)
if sql_conditions:
if strategy.logic == "and":
query = query.where(and_(*sql_conditions))
else: # or
query = query.where(or_(*sql_conditions))
# 执行查询
rows = s.execute(query).scalars().all()
# 格式化结果
results = [{
"code": r.code,
"name": r.name,
"close": round(r.close, 2),
"pct": round(r.pct, 2),
"ret5": round(r.ret5, 2),
"ret20": round(r.ret20, 2),
"vol_ratio": round(r.vol_ratio, 2),
"rsi14": round(r.rsi14, 2),
"pos60": round(r.pos60 * 100, 1),
"amount": round(r.amount, 2),
"ma_bull": r.ma_bull,
"macd_gold": r.macd_gold
} for r in rows]
return {
"ok": True,
"date": date.isoformat(),
"strategy": strategy.name,
"count": len(results),
"results": results
}
def backtest_selector(strategy: Strategy, days: int = 60) -> Dict[str, Any]:
"""选股策略回测
Args:
strategy: 选股策略
days: 回测天数
Returns:
回测结果
"""
with get_session() as s:
# 获取最近N个交易日
latest_date = s.execute(select(func.max(StockMetric.date))).scalar()
if not latest_date:
return {"ok": False, "msg": "暂无数据"}
start_date = latest_date - dt.timedelta(days=days)
# 获取这段时间内的所有交易日
dates = s.execute(
select(StockMetric.date)
.where(StockMetric.date >= start_date)
.group_by(StockMetric.date)
.order_by(StockMetric.date)
).scalars().all()
if len(dates) < 5:
return {"ok": False, "msg": "数据不足"}
# 逐日选股并统计后续N日收益
daily_results = []
for i, date in enumerate(dates[:-5]): # 至少保留5日用于计算收益
# 执行选股
result = run_selector(strategy, date)
if not result["ok"] or not result["results"]:
continue
selected_codes = [r["code"] for r in result["results"]]
# 查询5日后的收益
future_date = dates[min(i + 5, len(dates) - 1)]
with get_session() as s:
# 获取选中股票5日后的表现
future_rows = s.execute(
select(DailyQuote.code, DailyQuote.close)
.where(
and_(
DailyQuote.code.in_(selected_codes),
DailyQuote.date == future_date
)
)
).all()
current_rows = s.execute(
select(DailyQuote.code, DailyQuote.close)
.where(
and_(
DailyQuote.code.in_(selected_codes),
DailyQuote.date == date
)
)
).all()
# 计算收益
current_prices = {r.code: float(r.close) for r in current_rows}
future_prices = {r.code: float(r.close) for r in future_rows}
returns = []
for code in selected_codes:
if code in current_prices and code in future_prices:
ret = (future_prices[code] / current_prices[code] - 1) * 100
returns.append(ret)
if returns:
daily_results.append({
"date": date.isoformat(),
"count": len(selected_codes),
"avg_return": round(np.mean(returns), 2),
"median_return": round(np.median(returns), 2),
"win_rate": round(sum(1 for r in returns if r > 0) / len(returns) * 100, 1),
"max_return": round(max(returns), 2),
"min_return": round(min(returns), 2)
})
if not daily_results:
return {"ok": False, "msg": "回测数据不足"}
# 汇总统计
avg_returns = [r["avg_return"] for r in daily_results]
win_rates = [r["win_rate"] for r in daily_results]
summary = {
"total_days": len(daily_results),
"avg_return": round(np.mean(avg_returns), 2),
"avg_win_rate": round(np.mean(win_rates), 1),
"best_day": max(daily_results, key=lambda x: x["avg_return"]),
"worst_day": min(daily_results, key=lambda x: x["avg_return"])
}
return {
"ok": True,
"strategy": strategy.name,
"days": days,
"summary": summary,
"daily": daily_results
}
def compare_results(date1: dt.date, date2: dt.date, strategy: Strategy) -> Dict[str, Any]:
"""对比两个日期的选股结果
Args:
date1: 日期1通常是昨日
date2: 日期2通常是今日
strategy: 选股策略
Returns:
对比结果
"""
result1 = run_selector(strategy, date1)
result2 = run_selector(strategy, date2)
if not result1["ok"] or not result2["ok"]:
return {"ok": False, "msg": "数据不足"}
codes1 = set(r["code"] for r in result1["results"])
codes2 = set(r["code"] for r in result2["results"])
# 新入选
new_in = codes2 - codes1
# 退出
dropped = codes1 - codes2
# 持续入选
continued = codes1 & codes2
# 获取详细信息
new_in_stocks = [r for r in result2["results"] if r["code"] in new_in]
dropped_stocks = [r for r in result1["results"] if r["code"] in dropped]
continued_stocks = [r for r in result2["results"] if r["code"] in continued]
return {
"ok": True,
"date1": date1.isoformat(),
"date2": date2.isoformat(),
"count1": len(codes1),
"count2": len(codes2),
"new_in": {
"count": len(new_in),
"stocks": new_in_stocks
},
"dropped": {
"count": len(dropped),
"stocks": dropped_stocks
},
"continued": {
"count": len(continued),
"stocks": continued_stocks
}
}
# 预设策略
PRESET_STRATEGIES = {
"momentum": Strategy("动量突破", "短期强势+放量"),
"value": Strategy("价值洼地", "超跌低位+基本面支撑"),
"growth": Strategy("成长加速", "持续上涨+量价齐升"),
"reversal": Strategy("反转抄底", "超跌企稳+技术反转")
}
# 动量突破
PRESET_STRATEGIES["momentum"].add_condition("ret5", ">", 10)
PRESET_STRATEGIES["momentum"].add_condition("vol_ratio", ">", 2)
PRESET_STRATEGIES["momentum"].add_condition("rsi14", "<", 80)
# 价值洼地
PRESET_STRATEGIES["value"].add_condition("pos60", "<", 0.3)
PRESET_STRATEGIES["value"].add_condition("pct", ">", 0)
PRESET_STRATEGIES["value"].add_condition("amount", ">", 5)
# 成长加速
PRESET_STRATEGIES["growth"].add_condition("ret20", ">", 15)
PRESET_STRATEGIES["growth"].add_condition("ma_bull", "==", True)
PRESET_STRATEGIES["growth"].add_condition("up_streak", ">=", 2)
# 反转抄底
PRESET_STRATEGIES["reversal"].add_condition("ret20", "<", -15)
PRESET_STRATEGIES["reversal"].add_condition("rsi14", "<", 30)
PRESET_STRATEGIES["reversal"].add_condition("pct", ">", 2)
def get_preset_strategies() -> List[Dict[str, Any]]:
"""获取预设策略列表"""
return [
{
"id": key,
"name": strategy.name,
"description": strategy.description,
"conditions_count": len(strategy.conditions)
}
for key, strategy in PRESET_STRATEGIES.items()
]
def get_available_fields() -> List[Dict[str, Any]]:
"""获取可用的选股字段"""
return [
{"field": "close", "name": "现价", "type": "number", "unit": ""},
{"field": "pct", "name": "涨跌幅", "type": "number", "unit": "%"},
{"field": "ret5", "name": "5日涨幅", "type": "number", "unit": "%"},
{"field": "ret20", "name": "20日涨幅", "type": "number", "unit": "%"},
{"field": "ret60", "name": "60日涨幅", "type": "number", "unit": "%"},
{"field": "ma5", "name": "MA5", "type": "number", "unit": ""},
{"field": "ma10", "name": "MA10", "type": "number", "unit": ""},
{"field": "ma20", "name": "MA20", "type": "number", "unit": ""},
{"field": "ma60", "name": "MA60", "type": "number", "unit": ""},
{"field": "vol_ratio", "name": "量比", "type": "number", "unit": ""},
{"field": "rsi14", "name": "RSI", "type": "number", "unit": ""},
{"field": "pos60", "name": "60日分位", "type": "number", "unit": ""},
{"field": "amount", "name": "成交额", "type": "number", "unit": "亿"},
{"field": "up_streak", "name": "连涨天数", "type": "number", "unit": ""},
{"field": "ma_bull", "name": "均线多头", "type": "boolean", "unit": ""},
{"field": "macd_gold", "name": "MACD金叉", "type": "boolean", "unit": ""}
]