claude强化功能
This commit is contained in:
542
backend/ai_chat.py
Normal file
542
backend/ai_chat.py
Normal 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 []
|
||||
@@ -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
|
||||
|
||||
437
backend/attribution_analysis.py
Normal file
437
backend/attribution_analysis.py
Normal 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}",
|
||||
}
|
||||
499
backend/backtest_advanced.py
Normal file
499
backend/backtest_advanced.py
Normal 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
531
backend/event_driven.py
Normal 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}
|
||||
495
backend/financial_analysis.py
Normal file
495
backend/financial_analysis.py
Normal 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
416
backend/intraday_radar.py
Normal 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
394
backend/limit_analysis.py
Normal 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]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
537
backend/main.py
537
backend/main.py
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
483
backend/sector_rotation.py
Normal 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
|
||||
556
backend/sentiment_monitor.py
Normal file
556
backend/sentiment_monitor.py
Normal 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
390
backend/smart_selector.py
Normal 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": ""}
|
||||
]
|
||||
Reference in New Issue
Block a user