|
|
"""
|
|
|
V2 交易计划生成器 - 5维度综合评分 + 多周期共振分析
|
|
|
"""
|
|
|
import json
|
|
|
import logging
|
|
|
from collections import Counter
|
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
from datetime import datetime
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
from app.services.cache import get_cached_data
|
|
|
from app.analysis_models import (
|
|
|
ReviewPlanV2, SymbolScoreV2, TradingPlanV2, SectorHeat,
|
|
|
)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# 板块分类
|
|
|
SECTOR_MAP = {
|
|
|
"贵金属": ["沪银", "沪金"],
|
|
|
"有色金属": ["沪铜", "沪锌", "沪锡", "沪铅", "沪铝", "铝合金", "沪镍"],
|
|
|
"黑色系": ["焦炭", "焦煤", "螺纹钢", "热卷", "铁矿石"],
|
|
|
"能源": ["原油", "低硫燃油", "燃油"],
|
|
|
"化工": ["PTA", "甲醇", "尿素", "PVC", "乙二醇", "合成橡胶", "烧碱", "纯碱", "玻璃", "橡胶"],
|
|
|
"农产品": ["白糖", "棉花", "棕榈油", "豆粕"],
|
|
|
"新能源": ["氧化铝", "多晶硅", "碳酸锂", "工业硅"],
|
|
|
"股指": ["中证1000"],
|
|
|
"航运": ["集运欧线"],
|
|
|
"橡胶": ["20号胶"],
|
|
|
}
|
|
|
|
|
|
# 权重
|
|
|
WEIGHTS = {
|
|
|
"amplitude": 0.20,
|
|
|
"volume": 0.15,
|
|
|
"change": 0.15,
|
|
|
"trend": 0.35,
|
|
|
"activity": 0.15,
|
|
|
}
|
|
|
|
|
|
|
|
|
def load_symbols_config() -> Dict[str, str]:
|
|
|
"""加载品种配置 {中文名: 合约代码}"""
|
|
|
from app.config_store import get_config_store
|
|
|
data = get_config_store().get_config("symbols", {"futures": {}, "stock": {}})
|
|
|
return data.get("futures", {})
|
|
|
|
|
|
|
|
|
def _get_candle_date(candle: dict) -> Optional[str]:
|
|
|
"""从K线数据中提取日期字符串 (YYYY-MM-DD)"""
|
|
|
dt_str = candle.get("datetime") or candle.get("time")
|
|
|
if not dt_str:
|
|
|
return None
|
|
|
try:
|
|
|
if isinstance(dt_str, str):
|
|
|
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
|
|
else:
|
|
|
dt = dt_str
|
|
|
return dt.strftime("%Y-%m-%d")
|
|
|
except Exception:
|
|
|
return None
|
|
|
|
|
|
|
|
|
def _get_candle_values(candles: List[dict]) -> Tuple[list, list, list, list, list]:
|
|
|
"""从K线列表提取 OHLCV"""
|
|
|
opens, highs, lows, closes, volumes = [], [], [], [], []
|
|
|
for c in candles:
|
|
|
opens.append(float(c.get("open", 0)))
|
|
|
highs.append(float(c.get("high", 0)))
|
|
|
lows.append(float(c.get("low", 0)))
|
|
|
closes.append(float(c.get("close", 0)))
|
|
|
volumes.append(float(c.get("volume", 0)))
|
|
|
return opens, highs, lows, closes, volumes
|
|
|
|
|
|
|
|
|
def _calc_single_period_trend(candles: List[dict]) -> float:
|
|
|
"""
|
|
|
计算单周期趋势分 (-50 ~ +50)
|
|
|
MA排列: 多头+50 / 空头-50
|
|
|
MACD: DIF>DEA +30 / DIF<DEA -30
|
|
|
综合: -50 ~ +50 (归一化后映射)
|
|
|
"""
|
|
|
if len(candles) < 5:
|
|
|
return 0.0
|
|
|
|
|
|
last = candles[-1]
|
|
|
ma10 = last.get("ma10")
|
|
|
ma20 = last.get("ma20")
|
|
|
macd_dif = last.get("macd_dif", 0) or 0
|
|
|
macd_dea = last.get("macd_dea", 0) or 0
|
|
|
|
|
|
score = 0.0
|
|
|
# MA排列
|
|
|
if ma10 and ma20:
|
|
|
if ma10 > ma20:
|
|
|
score += 50
|
|
|
else:
|
|
|
score -= 50
|
|
|
# MACD
|
|
|
if macd_dif > macd_dea:
|
|
|
score += 30
|
|
|
else:
|
|
|
score -= 30
|
|
|
|
|
|
# 归一化到 -50 ~ +50
|
|
|
return max(-50, min(50, score * 50 / 80))
|
|
|
|
|
|
|
|
|
def _calc_trend_score(t60: float, t15: float, t5: float) -> Tuple[float, str, str]:
|
|
|
"""
|
|
|
三周期共振判定
|
|
|
Returns: (趋势总分 0-100, 方向标签, 方向简写)
|
|
|
"""
|
|
|
# 共振判定
|
|
|
if t60 > 20 and t15 > 20 and t5 > 20:
|
|
|
direction = "多头共振"
|
|
|
tag = "强多"
|
|
|
raw = 80 + min(20, (t60 + t15 + t5) / 3 * 20 / 50)
|
|
|
elif t60 < -20 and t15 < -20 and t5 < -20:
|
|
|
direction = "空头共振"
|
|
|
tag = "强空"
|
|
|
raw = 80 + min(20, abs(t60 + t15 + t5) / 3 * 20 / 50)
|
|
|
elif t60 > 0 and t15 > 0:
|
|
|
direction = "偏多震荡"
|
|
|
tag = "偏多"
|
|
|
raw = 50 + min(20, (t60 + t15) / 2 * 20 / 50)
|
|
|
elif t60 < 0 and t15 < 0:
|
|
|
direction = "偏空震荡"
|
|
|
tag = "偏空"
|
|
|
raw = 50 + min(20, abs(t60 + t15) / 2 * 20 / 50)
|
|
|
else:
|
|
|
direction = "多空交织"
|
|
|
tag = "震荡"
|
|
|
raw = max(0, 40 - abs(t60 + t15 + t5) / 3)
|
|
|
|
|
|
return round(raw, 1), direction, tag
|
|
|
|
|
|
|
|
|
def _get_price_precision(price: float) -> int:
|
|
|
"""根据价格大小确定合适的小数位数"""
|
|
|
if price >= 10000:
|
|
|
return 0 # 如白银8250、股指5000+
|
|
|
elif price >= 1000:
|
|
|
return 1 # 如原油528、黄金685(实际2位)
|
|
|
elif price >= 100:
|
|
|
return 2 # 如大部分化工品
|
|
|
else:
|
|
|
return 2 # 小数值的品种
|
|
|
|
|
|
|
|
|
def _round_by_price(value: float, price: float) -> float:
|
|
|
"""根据价格精度四舍五入"""
|
|
|
precision = _get_price_precision(price)
|
|
|
return round(value, precision)
|
|
|
|
|
|
|
|
|
def _calc_pivot_points(high: float, low: float, close: float) -> dict:
|
|
|
"""计算枢轴点和支撑/阻力位(根据价格精度自动调整小数位)"""
|
|
|
pivot = (high + low + close) / 3
|
|
|
r1 = 2 * pivot - low
|
|
|
s1 = 2 * pivot - high
|
|
|
r2 = pivot + (high - low)
|
|
|
s2 = pivot - (high - low)
|
|
|
# 根据价格精度四舍五入
|
|
|
p = _get_price_precision(close)
|
|
|
return {
|
|
|
"pivot": round(pivot, p),
|
|
|
"r1": round(r1, p),
|
|
|
"r2": round(r2, p),
|
|
|
"s1": round(s1, p),
|
|
|
"s2": round(s2, p),
|
|
|
}
|
|
|
|
|
|
|
|
|
def _normalize_scores(values: List[float], reverse: bool = False) -> List[float]:
|
|
|
"""将一组值归一化到 0-100"""
|
|
|
if not values:
|
|
|
return []
|
|
|
min_v = min(values)
|
|
|
max_v = max(values)
|
|
|
if max_v == min_v:
|
|
|
return [50.0] * len(values)
|
|
|
result = []
|
|
|
for v in values:
|
|
|
if reverse:
|
|
|
score = (max_v - v) / (max_v - min_v) * 100
|
|
|
else:
|
|
|
score = (v - min_v) / (max_v - min_v) * 100
|
|
|
result.append(round(score, 1))
|
|
|
return result
|
|
|
|
|
|
|
|
|
def _normalize_abs_scores(values: List[float]) -> List[float]:
|
|
|
"""将绝对值归一化到 0-100(涨跌幅用绝对值排名)"""
|
|
|
abs_values = [abs(v) for v in values]
|
|
|
return _normalize_scores(abs_values)
|
|
|
|
|
|
|
|
|
def _generate_trigger(score_data: dict, direction: str) -> str:
|
|
|
"""
|
|
|
根据分析结果动态生成触发条件
|
|
|
|
|
|
Args:
|
|
|
score_data: 品种评分数据
|
|
|
direction: 方向 (long/short)
|
|
|
|
|
|
Returns:
|
|
|
触发条件文本
|
|
|
"""
|
|
|
conditions = []
|
|
|
direction_tag = score_data.get("direction_tag", "震荡")
|
|
|
volume_ratio = score_data.get("volume_ratio", 1.0)
|
|
|
amplitude_pct = score_data.get("amplitude_pct", 0)
|
|
|
change_pct = score_data.get("change_pct", 0)
|
|
|
|
|
|
# 根据方向标签生成基础条件
|
|
|
if direction == "long":
|
|
|
if direction_tag == "强多":
|
|
|
conditions.append("价格站稳支撑位上方")
|
|
|
elif direction_tag == "偏多":
|
|
|
conditions.append("回踩支撑企稳")
|
|
|
else:
|
|
|
conditions.append("突破枢轴点P位")
|
|
|
else:
|
|
|
if direction_tag == "强空":
|
|
|
conditions.append("价格跌破阻力位下方")
|
|
|
elif direction_tag == "偏空":
|
|
|
conditions.append("反弹阻力受阻")
|
|
|
else:
|
|
|
conditions.append("跌破枢轴点P位")
|
|
|
|
|
|
# 根据量比添加量能条件
|
|
|
if volume_ratio > 1.5:
|
|
|
conditions.append("量能持续放大")
|
|
|
elif volume_ratio > 1.2:
|
|
|
conditions.append("5分钟K线放量确认")
|
|
|
else:
|
|
|
conditions.append("等待量能配合")
|
|
|
|
|
|
# 根据振幅添加波动条件
|
|
|
if amplitude_pct > 2:
|
|
|
conditions.append("注意波动风险")
|
|
|
elif amplitude_pct > 1:
|
|
|
conditions.append("关注区间突破")
|
|
|
|
|
|
# 根据涨跌幅添加趋势条件
|
|
|
if abs(change_pct) > 2:
|
|
|
if change_pct > 0:
|
|
|
conditions.append("强势延续确认")
|
|
|
else:
|
|
|
conditions.append("弱势延续确认")
|
|
|
|
|
|
return " + ".join(conditions[:3]) # 最多返回3个条件
|
|
|
|
|
|
|
|
|
def generate_plan(db: Session, review_date_str: str, week_day: str) -> dict:
|
|
|
"""
|
|
|
生成V2复盘与交易计划
|
|
|
|
|
|
Returns:
|
|
|
{
|
|
|
"review_date_id": int,
|
|
|
"summary": {...},
|
|
|
"scores": [...],
|
|
|
"plans": [...],
|
|
|
"sectors": [...],
|
|
|
}
|
|
|
"""
|
|
|
symbols_config = load_symbols_config()
|
|
|
if not symbols_config:
|
|
|
raise ValueError("品种配置文件为空")
|
|
|
|
|
|
# 反转映射: 合约代码 -> 中文名
|
|
|
code_to_name = {v: k for k, v in symbols_config.items()}
|
|
|
all_symbols = list(symbols_config.values())
|
|
|
|
|
|
logger.info(f"开始生成交易计划: {review_date_str} ({week_day}), 共 {len(all_symbols)} 个品种")
|
|
|
|
|
|
# 将复盘日期作为数据截止时间(当天 23:59:59)
|
|
|
try:
|
|
|
end_time = datetime.strptime(review_date_str, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
|
|
except ValueError:
|
|
|
end_time = datetime.now()
|
|
|
|
|
|
# ==================== 第一步: 数据收集 ====================
|
|
|
symbol_data = {}
|
|
|
periods_needed = ["daily", "60min", "15min", "5min"]
|
|
|
|
|
|
for symbol in all_symbols:
|
|
|
try:
|
|
|
data = get_cached_data(
|
|
|
db, symbol, "futures",
|
|
|
periods=periods_needed,
|
|
|
end_time=end_time,
|
|
|
max_candles=100,
|
|
|
)
|
|
|
if data and data.get("timeframes"):
|
|
|
symbol_data[symbol] = data
|
|
|
else:
|
|
|
logger.warning(f"{symbol} 无数据")
|
|
|
except Exception as e:
|
|
|
logger.error(f"获取 {symbol} 数据失败: {e}")
|
|
|
|
|
|
if not symbol_data:
|
|
|
raise ValueError("无法从数据库获取任何品种数据,请先进行数据更新")
|
|
|
|
|
|
logger.info(f"成功获取 {len(symbol_data)}/{len(all_symbols)} 个品种数据")
|
|
|
|
|
|
# ==================== 第二步: 多维度打分 ====================
|
|
|
raw_scores = []
|
|
|
|
|
|
for symbol, data in symbol_data.items():
|
|
|
name = code_to_name.get(symbol, symbol)
|
|
|
tf = data.get("timeframes", {})
|
|
|
|
|
|
daily = tf.get("daily", [])
|
|
|
h1 = tf.get("60min", [])
|
|
|
m15 = tf.get("15min", [])
|
|
|
m5 = tf.get("5min", [])
|
|
|
|
|
|
# 日线数据
|
|
|
if not daily or len(daily) < 2:
|
|
|
continue
|
|
|
|
|
|
_, d_highs, d_lows, d_closes, d_volumes = _get_candle_values(daily)
|
|
|
today_high = d_highs[-1]
|
|
|
today_low = d_lows[-1]
|
|
|
today_close = d_closes[-1]
|
|
|
prev_close = d_closes[-2] if len(d_closes) >= 2 else today_close
|
|
|
today_volume = d_volumes[-1]
|
|
|
|
|
|
# 提取实际数据日期(日线最后一根K线的日期)
|
|
|
data_date = _get_candle_date(daily[-1])
|
|
|
|
|
|
# 近5日均量
|
|
|
recent_vols = d_volumes[-6:-1] if len(d_volumes) >= 6 else d_volumes[:-1]
|
|
|
avg_vol_5 = sum(recent_vols) / len(recent_vols) if recent_vols else today_volume
|
|
|
|
|
|
# A. 振幅
|
|
|
amplitude_pct = (today_high - today_low) / today_close * 100 if today_close else 0
|
|
|
|
|
|
# B. 量比
|
|
|
volume_ratio = today_volume / avg_vol_5 if avg_vol_5 > 0 else 1.0
|
|
|
|
|
|
# C. 涨跌幅
|
|
|
change_pct = (today_close - prev_close) / prev_close * 100 if prev_close else 0
|
|
|
|
|
|
# D. 多周期趋势
|
|
|
t60 = _calc_single_period_trend(h1) if h1 else 0
|
|
|
t15 = _calc_single_period_trend(m15) if m15 else 0
|
|
|
t5 = _calc_single_period_trend(m5) if m5 else 0
|
|
|
trend_total, direction, direction_tag = _calc_trend_score(t60, t15, t5)
|
|
|
|
|
|
# E. 活跃度 (5min K线近10根)
|
|
|
if m5 and len(m5) >= 5:
|
|
|
recent_m5 = m5[-10:] if len(m5) >= 10 else m5
|
|
|
_, _, _, m5_closes, m5_vols = _get_candle_values(recent_m5)
|
|
|
# 价格变动率
|
|
|
if m5_closes and m5_closes[0] > 0:
|
|
|
price_change_rate = abs(m5_closes[-1] - m5_closes[0]) / m5_closes[0] * 100
|
|
|
else:
|
|
|
price_change_rate = 0
|
|
|
# 量能活跃度
|
|
|
avg_m5_vol = sum(m5_vols) / len(m5_vols) if m5_vols else 0
|
|
|
vol_activity = min(100, avg_m5_vol / 100) if avg_m5_vol > 0 else 0
|
|
|
price_activity = min(100, price_change_rate * 50)
|
|
|
activity_score = (vol_activity + price_activity) / 2
|
|
|
else:
|
|
|
activity_score = 0
|
|
|
|
|
|
# 关键点位
|
|
|
pivots = _calc_pivot_points(today_high, today_low, today_close)
|
|
|
|
|
|
raw_scores.append({
|
|
|
"symbol": symbol,
|
|
|
"name": name,
|
|
|
"data_date": data_date,
|
|
|
"close_price": today_close,
|
|
|
"prev_close": prev_close,
|
|
|
"high_price": today_high,
|
|
|
"low_price": today_low,
|
|
|
"volume": today_volume,
|
|
|
"avg_volume_5": avg_vol_5,
|
|
|
"amplitude_pct": round(amplitude_pct, 2),
|
|
|
"volume_ratio": round(volume_ratio, 2),
|
|
|
"change_pct": round(change_pct, 2),
|
|
|
"amplitude_raw": amplitude_pct,
|
|
|
"volume_raw": volume_ratio,
|
|
|
"change_raw": abs(change_pct),
|
|
|
"trend_score": trend_total,
|
|
|
"trend_60m": round(t60, 1),
|
|
|
"trend_15m": round(t15, 1),
|
|
|
"trend_5m": round(t5, 1),
|
|
|
"direction": direction,
|
|
|
"direction_tag": direction_tag,
|
|
|
"activity_raw": activity_score,
|
|
|
"pivots": pivots,
|
|
|
})
|
|
|
|
|
|
if not raw_scores:
|
|
|
raise ValueError("没有足够的数据进行评分计算")
|
|
|
|
|
|
# ==================== 第三步: 归一化评分 ====================
|
|
|
amp_scores = _normalize_scores([s["amplitude_raw"] for s in raw_scores])
|
|
|
vol_scores = _normalize_scores([s["volume_raw"] for s in raw_scores])
|
|
|
chg_scores = _normalize_abs_scores([s["change_raw"] for s in raw_scores])
|
|
|
act_scores = _normalize_scores([s["activity_raw"] for s in raw_scores])
|
|
|
|
|
|
for i, s in enumerate(raw_scores):
|
|
|
s["amplitude_score"] = amp_scores[i]
|
|
|
s["volume_score"] = vol_scores[i]
|
|
|
s["change_score"] = chg_scores[i]
|
|
|
s["activity_score"] = act_scores[i]
|
|
|
|
|
|
# 综合评分 = 振幅×0.20 + 量能×0.15 + 涨跌幅×0.15 + 趋势×0.35 + 活跃度×0.15
|
|
|
# 趋势分映射: trend_score 0-100 直接使用
|
|
|
s["composite_score"] = round(
|
|
|
amp_scores[i] * WEIGHTS["amplitude"]
|
|
|
+ vol_scores[i] * WEIGHTS["volume"]
|
|
|
+ chg_scores[i] * WEIGHTS["change"]
|
|
|
+ s["trend_score"] * WEIGHTS["trend"]
|
|
|
+ act_scores[i] * WEIGHTS["activity"],
|
|
|
1
|
|
|
)
|
|
|
|
|
|
# 排序
|
|
|
raw_scores.sort(key=lambda x: x["composite_score"], reverse=True)
|
|
|
|
|
|
# ==================== 第四步: 分类 ====================
|
|
|
for rank, s in enumerate(raw_scores, 1):
|
|
|
s["rank"] = rank
|
|
|
score = s["composite_score"]
|
|
|
trend = s["trend_score"]
|
|
|
|
|
|
if score >= 55 and trend >= 50:
|
|
|
s["category"] = "green"
|
|
|
elif score >= 45 or (score >= 40 and 30 <= trend < 50):
|
|
|
s["category"] = "yellow"
|
|
|
else:
|
|
|
s["category"] = "red"
|
|
|
|
|
|
# ==================== 第五步: 生成交易计划 ====================
|
|
|
plans = []
|
|
|
green_items = [s for s in raw_scores if s["category"] == "green"]
|
|
|
|
|
|
for s in green_items:
|
|
|
# 根据方向标签判断实际方向(强多/偏多→做多,强空/偏空→做空)
|
|
|
direction_tag = s.get("direction_tag", "震荡")
|
|
|
if direction_tag in ["强空", "偏空"]:
|
|
|
direction = "short"
|
|
|
elif direction_tag in ["强多", "偏多"]:
|
|
|
direction = "long"
|
|
|
else:
|
|
|
# 震荡情况下根据涨跌幅判断
|
|
|
direction = "long" if s["change_pct"] > 0 else "short"
|
|
|
|
|
|
pivots = s["pivots"]
|
|
|
price = s["close_price"]
|
|
|
|
|
|
if direction == "long":
|
|
|
entry_low = _round_by_price(pivots["s1"], price)
|
|
|
entry_high = _round_by_price(pivots["pivot"], price)
|
|
|
stop_loss = _round_by_price(pivots["s2"], price)
|
|
|
target1 = _round_by_price(pivots["r1"], price)
|
|
|
target2 = _round_by_price(pivots["r2"], price)
|
|
|
else:
|
|
|
entry_low = _round_by_price(pivots["pivot"], price)
|
|
|
entry_high = _round_by_price(pivots["r1"], price)
|
|
|
stop_loss = _round_by_price(pivots["r2"], price)
|
|
|
target1 = _round_by_price(pivots["s1"], price)
|
|
|
target2 = _round_by_price(pivots["s2"], price)
|
|
|
|
|
|
# 根据分析结果动态生成触发条件
|
|
|
trigger = _generate_trigger(s, direction)
|
|
|
|
|
|
plans.append({
|
|
|
"symbol": s["symbol"],
|
|
|
"name": s["name"],
|
|
|
"direction": direction,
|
|
|
"composite_score": s["composite_score"],
|
|
|
"entry_low": entry_low,
|
|
|
"entry_high": entry_high,
|
|
|
"stop_loss": stop_loss,
|
|
|
"target1": target1,
|
|
|
"target2": target2,
|
|
|
"trigger": trigger,
|
|
|
"amplitude_score": s["amplitude_score"],
|
|
|
"volume_score": s["volume_score"],
|
|
|
"trend_score": s["trend_score"],
|
|
|
"activity_score": s["activity_score"],
|
|
|
"category": s["category"],
|
|
|
})
|
|
|
|
|
|
# ==================== 第六步: 板块热度 ====================
|
|
|
sectors = _calc_sector_heat(raw_scores, code_to_name)
|
|
|
|
|
|
# ==================== 第七步: 汇总 ====================
|
|
|
bull_count = sum(1 for s in raw_scores if s["trend_score"] >= 50)
|
|
|
bear_count = sum(1 for s in raw_scores if s["trend_score"] < 30)
|
|
|
neutral_count = len(raw_scores) - bull_count - bear_count
|
|
|
|
|
|
# 统计实际数据日期(取出现最多的日期作为基准数据日期)
|
|
|
date_counts = Counter(s.get("data_date") for s in raw_scores if s.get("data_date"))
|
|
|
actual_data_date = date_counts.most_common(1)[0][0] if date_counts else review_date_str
|
|
|
# 判断数据日期是否与复盘日期一致
|
|
|
data_date_matches = (actual_data_date == review_date_str)
|
|
|
|
|
|
# 核心结论
|
|
|
top3 = raw_scores[:3]
|
|
|
top_names = [f"{s['symbol']} {s['name']}" for s in top3]
|
|
|
main_direction = "多头" if bull_count > bear_count else "空头" if bear_count > bull_count else "震荡"
|
|
|
core_conclusion = f"{main_direction}格局{'延续' if bull_count > 15 else '分化'},{'贵金属+黑色系共振做多' if bull_count > 20 else '板块轮动明显'}"
|
|
|
|
|
|
# 风险提示
|
|
|
risk_warnings = []
|
|
|
if top3:
|
|
|
top_sym = top3[0]
|
|
|
risk_warnings.append(
|
|
|
f"极值品种严禁追{'多' if top_sym['trend_score'] >= 50 else '空'}:"
|
|
|
f"{top_sym['symbol']}({top_sym['composite_score']}分) "
|
|
|
f"{'周一大概率高开' if top_sym['change_pct'] > 2 else '注意风险控制'}"
|
|
|
)
|
|
|
if bear_count > 0:
|
|
|
bear_syms = [s for s in raw_scores if s["category"] == "red" and s["trend_score"] < 30][:2]
|
|
|
if bear_syms:
|
|
|
names = "/".join(s["symbol"] for s in bear_syms)
|
|
|
risk_warnings.append(f"空头品种谨慎:{names} 趋势偏空,需严格止损")
|
|
|
risk_warnings.append("关注宏观数据和消息面变化,可能影响开盘方向")
|
|
|
if any(s["volume_ratio"] > 2.5 for s in raw_scores):
|
|
|
high_vol = [s for s in raw_scores if s["volume_ratio"] > 2.5]
|
|
|
risk_warnings.append(
|
|
|
f"量能异常品种:{', '.join(s['symbol'] for s in high_vol[:3])} "
|
|
|
f"量比超2.5倍,需确认量能可持续性"
|
|
|
)
|
|
|
|
|
|
# 构建数据基准描述
|
|
|
if data_date_matches:
|
|
|
data_basis = f"{actual_data_date} 收盘 ({len(symbol_data)} 品种) | 多周期共振分析"
|
|
|
else:
|
|
|
data_basis = f"复盘日期: {review_date_str} | 数据截至: {actual_data_date} 收盘 ({len(symbol_data)} 品种) | 多周期共振分析"
|
|
|
|
|
|
return {
|
|
|
"review_date": review_date_str,
|
|
|
"week_day": week_day,
|
|
|
"actual_data_date": actual_data_date,
|
|
|
"data_date_matches": data_date_matches,
|
|
|
"data_basis": data_basis,
|
|
|
"core_conclusion": core_conclusion,
|
|
|
"bull_count": bull_count,
|
|
|
"bear_count": bear_count,
|
|
|
"neutral_count": neutral_count,
|
|
|
"opportunity_count": len(green_items),
|
|
|
"risk_warnings": risk_warnings,
|
|
|
"scores": raw_scores,
|
|
|
"plans": plans,
|
|
|
"sectors": sectors,
|
|
|
"symbol_count": len(symbol_data),
|
|
|
}
|
|
|
|
|
|
|
|
|
def _calc_sector_heat(scores: List[dict], code_to_name: dict) -> List[dict]:
|
|
|
"""计算板块热度"""
|
|
|
# 构建 symbol -> score 映射
|
|
|
name_to_score = {}
|
|
|
for s in scores:
|
|
|
name_to_score[s["name"]] = s
|
|
|
|
|
|
sectors = []
|
|
|
for sector_name, members_names in SECTOR_MAP.items():
|
|
|
members = []
|
|
|
for mn in members_names:
|
|
|
if mn in name_to_score:
|
|
|
sc = name_to_score[mn]
|
|
|
members.append({
|
|
|
"symbol": sc["symbol"],
|
|
|
"name": sc["name"],
|
|
|
"score": sc["composite_score"],
|
|
|
"trend": sc["trend_score"],
|
|
|
"change_pct": sc["change_pct"],
|
|
|
})
|
|
|
|
|
|
if not members:
|
|
|
continue
|
|
|
|
|
|
avg_score = round(sum(m["score"] for m in members) / len(members), 1)
|
|
|
avg_trend = round(sum(m["trend"] for m in members) / len(members), 0)
|
|
|
|
|
|
if avg_trend > 20:
|
|
|
direction = "多头"
|
|
|
elif avg_trend < -20:
|
|
|
direction = "空头"
|
|
|
else:
|
|
|
direction = "震荡"
|
|
|
|
|
|
# 热度等级
|
|
|
if avg_score >= 75:
|
|
|
heat = 3
|
|
|
elif avg_score >= 60:
|
|
|
heat = 2
|
|
|
elif avg_score >= 45:
|
|
|
heat = 1
|
|
|
else:
|
|
|
heat = 0
|
|
|
|
|
|
# 龙头
|
|
|
leader = max(members, key=lambda m: m["score"])
|
|
|
|
|
|
sectors.append({
|
|
|
"sector_name": sector_name,
|
|
|
"avg_score": avg_score,
|
|
|
"avg_trend": int(avg_trend),
|
|
|
"direction": direction,
|
|
|
"heat_level": heat,
|
|
|
"leader_symbol": leader["symbol"],
|
|
|
"leader_score": leader["score"],
|
|
|
"members": sorted(members, key=lambda m: m["score"], reverse=True),
|
|
|
})
|
|
|
|
|
|
sectors.sort(key=lambda s: s["avg_score"], reverse=True)
|
|
|
return sectors
|
|
|
|
|
|
|
|
|
def save_plan_to_db(db: Session, plan_data: dict) -> int:
|
|
|
"""将计划数据保存到数据库"""
|
|
|
review_date_str = plan_data["review_date"]
|
|
|
week_day = plan_data["week_day"]
|
|
|
|
|
|
# 清理旧数据
|
|
|
existing = db.query(ReviewPlanV2).filter_by(review_date=review_date_str).first()
|
|
|
if existing:
|
|
|
review_plan_id = existing.id
|
|
|
# 清理关联数据
|
|
|
db.query(SymbolScoreV2).filter_by(review_date_id=review_plan_id).delete()
|
|
|
db.query(TradingPlanV2).filter_by(review_date_id=review_plan_id).delete()
|
|
|
db.query(SectorHeat).filter_by(review_date_id=review_plan_id).delete()
|
|
|
# 更新元数据
|
|
|
existing.week_day = week_day
|
|
|
existing.data_basis = plan_data.get("data_basis")
|
|
|
existing.core_conclusion = plan_data.get("core_conclusion")
|
|
|
existing.bull_count = plan_data.get("bull_count")
|
|
|
existing.bear_count = plan_data.get("bear_count")
|
|
|
existing.neutral_count = plan_data.get("neutral_count")
|
|
|
existing.opportunity_count = plan_data.get("opportunity_count")
|
|
|
existing.risk_warnings = plan_data.get("risk_warnings")
|
|
|
existing.actual_data_date = plan_data.get("actual_data_date")
|
|
|
existing.data_date_matches = 1 if plan_data.get("data_date_matches") else 0
|
|
|
else:
|
|
|
review_plan = ReviewPlanV2(
|
|
|
review_date=review_date_str,
|
|
|
week_day=week_day,
|
|
|
data_basis=plan_data.get("data_basis"),
|
|
|
core_conclusion=plan_data.get("core_conclusion"),
|
|
|
bull_count=plan_data.get("bull_count"),
|
|
|
bear_count=plan_data.get("bear_count"),
|
|
|
neutral_count=plan_data.get("neutral_count"),
|
|
|
opportunity_count=plan_data.get("opportunity_count"),
|
|
|
risk_warnings=plan_data.get("risk_warnings"),
|
|
|
actual_data_date=plan_data.get("actual_data_date"),
|
|
|
data_date_matches=1 if plan_data.get("data_date_matches") else 0,
|
|
|
)
|
|
|
db.add(review_plan)
|
|
|
db.flush()
|
|
|
review_plan_id = review_plan.id
|
|
|
|
|
|
# 保存评分数据
|
|
|
for s in plan_data.get("scores", []):
|
|
|
pivots = s.get("pivots", {})
|
|
|
score_record = SymbolScoreV2(
|
|
|
review_date_id=review_plan_id,
|
|
|
symbol=s["symbol"],
|
|
|
name=s["name"],
|
|
|
close_price=s.get("close_price"),
|
|
|
prev_close=s.get("prev_close"),
|
|
|
high_price=s.get("high_price"),
|
|
|
low_price=s.get("low_price"),
|
|
|
volume=s.get("volume"),
|
|
|
avg_volume_5=s.get("avg_volume_5"),
|
|
|
amplitude_score=s.get("amplitude_score"),
|
|
|
volume_score=s.get("volume_score"),
|
|
|
change_score=s.get("change_score"),
|
|
|
trend_score=s.get("trend_score"),
|
|
|
activity_score=s.get("activity_score"),
|
|
|
composite_score=s.get("composite_score"),
|
|
|
amplitude_pct=s.get("amplitude_pct"),
|
|
|
change_pct=s.get("change_pct"),
|
|
|
volume_ratio=s.get("volume_ratio"),
|
|
|
trend_60m=s.get("trend_60m"),
|
|
|
trend_15m=s.get("trend_15m"),
|
|
|
trend_5m=s.get("trend_5m"),
|
|
|
direction=s.get("direction"),
|
|
|
direction_tag=s.get("direction_tag"),
|
|
|
category=s.get("category"),
|
|
|
pivot=pivots.get("pivot"),
|
|
|
r1=pivots.get("r1"),
|
|
|
r2=pivots.get("r2"),
|
|
|
s1=pivots.get("s1"),
|
|
|
s2=pivots.get("s2"),
|
|
|
rank=s.get("rank"),
|
|
|
data_date=s.get("data_date"),
|
|
|
)
|
|
|
db.add(score_record)
|
|
|
|
|
|
# 保存交易计划
|
|
|
for p in plan_data.get("plans", []):
|
|
|
plan_record = TradingPlanV2(
|
|
|
review_date_id=review_plan_id,
|
|
|
symbol=p["symbol"],
|
|
|
name=p["name"],
|
|
|
direction=p["direction"],
|
|
|
composite_score=p.get("composite_score"),
|
|
|
entry_low=p.get("entry_low"),
|
|
|
entry_high=p.get("entry_high"),
|
|
|
stop_loss=p.get("stop_loss"),
|
|
|
target1=p.get("target1"),
|
|
|
target2=p.get("target2"),
|
|
|
trigger=p.get("trigger"),
|
|
|
amplitude_score=p.get("amplitude_score"),
|
|
|
volume_score=p.get("volume_score"),
|
|
|
trend_score=p.get("trend_score"),
|
|
|
activity_score=p.get("activity_score"),
|
|
|
category=p.get("category"),
|
|
|
)
|
|
|
db.add(plan_record)
|
|
|
|
|
|
# 保存板块热度
|
|
|
for sec in plan_data.get("sectors", []):
|
|
|
sector_record = SectorHeat(
|
|
|
review_date_id=review_plan_id,
|
|
|
sector_name=sec["sector_name"],
|
|
|
avg_score=sec.get("avg_score"),
|
|
|
avg_trend=sec.get("avg_trend"),
|
|
|
direction=sec.get("direction"),
|
|
|
heat_level=sec.get("heat_level"),
|
|
|
leader_symbol=sec.get("leader_symbol"),
|
|
|
leader_score=sec.get("leader_score"),
|
|
|
members=sec.get("members"),
|
|
|
)
|
|
|
db.add(sector_record)
|
|
|
|
|
|
db.commit()
|
|
|
logger.info(f"交易计划已保存: {review_date_str}, ID={review_plan_id}")
|
|
|
return review_plan_id
|
|
|
|
|
|
|
|
|
def get_plan_data(db: Session, review_plan_id: int) -> Optional[dict]:
|
|
|
"""从数据库读取完整的计划数据"""
|
|
|
plan_meta = db.query(ReviewPlanV2).filter_by(id=review_plan_id).first()
|
|
|
if not plan_meta:
|
|
|
return None
|
|
|
|
|
|
scores = db.query(SymbolScoreV2).filter_by(
|
|
|
review_date_id=review_plan_id
|
|
|
).order_by(SymbolScoreV2.composite_score.desc()).all()
|
|
|
|
|
|
plans = db.query(TradingPlanV2).filter_by(
|
|
|
review_date_id=review_plan_id
|
|
|
).order_by(TradingPlanV2.composite_score.desc()).all()
|
|
|
|
|
|
sectors = db.query(SectorHeat).filter_by(
|
|
|
review_date_id=review_plan_id
|
|
|
).order_by(SectorHeat.avg_score.desc()).all()
|
|
|
|
|
|
return {
|
|
|
"meta": {
|
|
|
"id": plan_meta.id,
|
|
|
"review_date": plan_meta.review_date,
|
|
|
"week_day": plan_meta.week_day,
|
|
|
"data_basis": plan_meta.data_basis,
|
|
|
"actual_data_date": plan_meta.actual_data_date,
|
|
|
"data_date_matches": bool(plan_meta.data_date_matches),
|
|
|
"core_conclusion": plan_meta.core_conclusion,
|
|
|
"bull_count": plan_meta.bull_count,
|
|
|
"bear_count": plan_meta.bear_count,
|
|
|
"neutral_count": plan_meta.neutral_count,
|
|
|
"opportunity_count": plan_meta.opportunity_count,
|
|
|
"risk_warnings": plan_meta.risk_warnings,
|
|
|
"created_at": plan_meta.created_at.isoformat() if plan_meta.created_at else None,
|
|
|
},
|
|
|
"scores": [_score_to_dict(s) for s in scores],
|
|
|
"plans": [_plan_to_dict(p) for p in plans],
|
|
|
"sectors": [_sector_to_dict(s) for s in sectors],
|
|
|
}
|
|
|
|
|
|
|
|
|
def _score_to_dict(s: SymbolScoreV2) -> dict:
|
|
|
return {
|
|
|
"id": s.id,
|
|
|
"symbol": s.symbol,
|
|
|
"name": s.name,
|
|
|
"data_date": s.data_date,
|
|
|
"close_price": s.close_price,
|
|
|
"prev_close": s.prev_close,
|
|
|
"high_price": s.high_price,
|
|
|
"low_price": s.low_price,
|
|
|
"volume": s.volume,
|
|
|
"avg_volume_5": s.avg_volume_5,
|
|
|
"amplitude_score": s.amplitude_score,
|
|
|
"volume_score": s.volume_score,
|
|
|
"change_score": s.change_score,
|
|
|
"trend_score": s.trend_score,
|
|
|
"activity_score": s.activity_score,
|
|
|
"composite_score": s.composite_score,
|
|
|
"amplitude_pct": s.amplitude_pct,
|
|
|
"change_pct": s.change_pct,
|
|
|
"volume_ratio": s.volume_ratio,
|
|
|
"trend_60m": s.trend_60m,
|
|
|
"trend_15m": s.trend_15m,
|
|
|
"trend_5m": s.trend_5m,
|
|
|
"direction": s.direction,
|
|
|
"direction_tag": s.direction_tag,
|
|
|
"category": s.category,
|
|
|
"pivot": s.pivot,
|
|
|
"r1": s.r1,
|
|
|
"r2": s.r2,
|
|
|
"s1": s.s1,
|
|
|
"s2": s.s2,
|
|
|
"rank": s.rank,
|
|
|
}
|
|
|
|
|
|
|
|
|
def _plan_to_dict(p: TradingPlanV2) -> dict:
|
|
|
return {
|
|
|
"id": p.id,
|
|
|
"symbol": p.symbol,
|
|
|
"name": p.name,
|
|
|
"direction": p.direction,
|
|
|
"composite_score": p.composite_score,
|
|
|
"entry_low": p.entry_low,
|
|
|
"entry_high": p.entry_high,
|
|
|
"stop_loss": p.stop_loss,
|
|
|
"target1": p.target1,
|
|
|
"target2": p.target2,
|
|
|
"trigger": p.trigger,
|
|
|
"amplitude_score": p.amplitude_score,
|
|
|
"volume_score": p.volume_score,
|
|
|
"trend_score": p.trend_score,
|
|
|
"activity_score": p.activity_score,
|
|
|
"category": p.category,
|
|
|
}
|
|
|
|
|
|
|
|
|
def _sector_to_dict(s: SectorHeat) -> dict:
|
|
|
return {
|
|
|
"id": s.id,
|
|
|
"sector_name": s.sector_name,
|
|
|
"avg_score": s.avg_score,
|
|
|
"avg_trend": s.avg_trend,
|
|
|
"direction": s.direction,
|
|
|
"heat_level": s.heat_level,
|
|
|
"leader_symbol": s.leader_symbol,
|
|
|
"leader_score": s.leader_score,
|
|
|
"members": s.members,
|
|
|
}
|