""" 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 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"] try: # 清理旧数据 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 except Exception as e: db.rollback() logger.exception(f"保存交易计划失败: {e}") raise 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, }