diff --git a/backend/app/analysis/breakout_signals.py b/backend/app/analysis/breakout_signals.py index fd4109c3..f4dcad25 100644 --- a/backend/app/analysis/breakout_signals.py +++ b/backend/app/analysis/breakout_signals.py @@ -196,14 +196,14 @@ def detect_pullback(df: pd.DataFrame) -> dict | None: 2. 近 5 日内有 MA5 > MA20 — 近期处于上升趋势 3. 价格靠近 MA10 或 MA20 (<3%) 4. 近 3 日缩量(相对前 5 日)或 当日开始放量反弹 - 5. RSI 30-70(放宽) + 5. RSI 只做风险提示,不作为回踩成立的硬条件 """ last = df.iloc[-1] - required = ["close", "vol", "ma5", "ma10", "ma20", "ma60", "rsi14"] + required = ["close", "vol", "ma5", "ma10", "ma20", "ma60"] if any(c not in df.columns for c in required): return None - if pd.isna(last["ma60"]) or pd.isna(last["rsi14"]): + if pd.isna(last["ma60"]): return None # 中长期上升趋势 @@ -251,13 +251,10 @@ def detect_pullback(df: pd.DataFrame) -> dict | None: if not (shrinking or bouncing_today): return None - # RSI(放宽至 30-70) - if not (30 <= last["rsi14"] <= 70): - return None - support_ma = "MA10" if near_ma10 else "MA20" support_price = last["ma10"] if near_ma10 else last["ma20"] shrink_ratio = recent_3_vol / prev_5_vol if prev_5_vol > 0 else 1 + rsi14 = last.get("rsi14") if "rsi14" in df.columns else None return { "signal_type": EntrySignal.PULLBACK, @@ -266,6 +263,7 @@ def detect_pullback(df: pd.DataFrame) -> dict | None: "support_price": round(support_price, 2), "volume_shrink_ratio": round(shrink_ratio, 2), "bouncing": bouncing_today, + "rsi14": round(float(rsi14), 1) if rsi14 is not None and not pd.isna(rsi14) else None, }, } @@ -343,16 +341,16 @@ def detect_reversal(df: pd.DataFrame) -> dict | None: 1. 近 5 日有 3 日以上下跌 2. 今日放量长阳(pct_chg > 3%, vol > vol_ma5 * 1.5) 3. 收盘价高于近 5 日最高价(强势反转) - 4. RSI 从低位回升(近 5 日最低 RSI < 40) + 4. RSI 低位只做背景信息,不作为反转成立的硬条件 """ if len(df) < 10: return None last = df.iloc[-1] - required = ["close", "high", "low", "vol", "pct_chg", "rsi14", "vol_ma5"] + required = ["close", "high", "low", "vol", "pct_chg", "vol_ma5"] if any(c not in df.columns for c in required): return None - if pd.isna(last["vol_ma5"]) or last["vol_ma5"] == 0 or pd.isna(last["rsi14"]): + if pd.isna(last["vol_ma5"]) or last["vol_ma5"] == 0: return None # 近 5 日有 3 日以上下跌 @@ -372,11 +370,10 @@ def detect_reversal(df: pd.DataFrame) -> dict | None: if last["close"] < high_5d: return None - # RSI 曾在低位 - rsi_5d = df["rsi14"].tail(5) - min_rsi = rsi_5d.min() - if not pd.isna(min_rsi) and min_rsi > 40: - return None + min_rsi = None + if "rsi14" in df.columns: + rsi_5d = df["rsi14"].tail(5) + min_rsi = rsi_5d.min() return { "signal_type": EntrySignal.REVERSAL, @@ -384,7 +381,7 @@ def detect_reversal(df: pd.DataFrame) -> dict | None: "down_days": down_count, "reversal_pct": round(last["pct_chg"], 2), "volume_ratio": round(last["vol"] / last["vol_ma5"], 2), - "min_rsi": round(float(min_rsi), 1), + "min_rsi": round(float(min_rsi), 1) if min_rsi is not None and not pd.isna(min_rsi) else None, }, } @@ -432,9 +429,9 @@ def _score_breakout(df: pd.DataFrame, signal: dict) -> float: if "rsi14" in df.columns and not pd.isna(last["rsi14"]): if 50 <= last["rsi14"] <= 75: - score += 10 + score += 4 elif 45 <= last["rsi14"] <= 80: - score += 5 + score += 2 return min(score, 100) @@ -474,9 +471,9 @@ def _score_breakout_confirm(df: pd.DataFrame, signal: dict) -> float: if "rsi14" in df.columns and not pd.isna(last["rsi14"]): if 50 <= last["rsi14"] <= 70: - score += 15 + score += 4 elif 45 <= last["rsi14"] <= 75: - score += 8 + score += 2 return min(score, 100) @@ -519,9 +516,9 @@ def _score_pullback(df: pd.DataFrame, signal: dict) -> float: if "rsi14" in df.columns and not pd.isna(last["rsi14"]): if 40 <= last["rsi14"] <= 55: - score += 15 + score += 3 elif 35 <= last["rsi14"] <= 65: - score += 8 + score += 1 return min(score, 100) @@ -588,14 +585,6 @@ def _score_reversal(df: pd.DataFrame, signal: dict) -> float: elif vol_ratio > 1.5: score += 12 - min_rsi = signal["details"].get("min_rsi", 50) - if min_rsi < 25: - score += 20 # 极度超卖反转 - elif min_rsi < 30: - score += 15 - elif min_rsi < 35: - score += 10 - # 站上 MA5 加分 if "ma5" in df.columns and not pd.isna(last["ma5"]): if last["close"] > last["ma5"]: diff --git a/backend/app/analysis/signals.py b/backend/app/analysis/signals.py index 19787a59..193ee029 100644 --- a/backend/app/analysis/signals.py +++ b/backend/app/analysis/signals.py @@ -1,6 +1,6 @@ """买卖信号生成 -基于技术指标判断 7 种买入信号,生成综合技术面评分。 +基于量价与趋势判断辅助信号。RSI 等滞后指标只做风险备注,不主导推荐。 """ import logging @@ -53,7 +53,7 @@ def _check_macd_golden(df: pd.DataFrame) -> bool: def _check_rsi_healthy(df: pd.DataFrame) -> bool: - """RSI 健康区间: RSI(14) 在 40-70""" + """RSI 节奏区间: RSI(14) 在 40-70,仅作为辅助节奏提示""" if df.empty: return False rsi = df.iloc[-1].get("rsi14", 50) @@ -258,7 +258,7 @@ def generate_signals(ts_code: str, name: str = "") -> TechnicalSignal: (ma_bullish, 15), (volume_breakout, 20), (macd_golden, 15), - (rsi_healthy, 10), + (rsi_healthy, 3), (pullback_support, 15), (big_yang, 15), (boll_support, 10), diff --git a/backend/app/api/stocks.py b/backend/app/api/stocks.py index 372e437d..57d235d4 100644 --- a/backend/app/api/stocks.py +++ b/backend/app/api/stocks.py @@ -384,12 +384,12 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): ) signal_str = ( - f"推荐体系评分: 趋势评分={signals.trend_score}/100(均线排列+高低点结构+MA20方向,主评分10%权重), " - f"辅助信号计数={signals.signal_count}/7(触发计分,仅供参考不参与主评分), " + f"推荐体系评分: 趋势评分={signals.trend_score}/100(均线排列+高低点结构+MA20方向,主评分辅助项), " + f"辅助信号计数={signals.signal_count}/7(触发计分,仅供节奏参考,不作为主评分裁判), " f"均线多头: {signals.ma_bullish}, " f"放量突破: {signals.volume_breakout}, " f"MACD金叉: {signals.macd_golden}, " - f"RSI健康: {signals.rsi_healthy}, " + f"RSI节奏区间: {signals.rsi_healthy}, " f"缩量回踩: {signals.pullback_support}, " f"放量长阳: {signals.big_yang}, " f"布林支撑: {signals.boll_support}, " @@ -471,7 +471,7 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): rec_result = await db.execute( text( "SELECT score, supply_demand_score, price_action_score, " - "technical_score, position_score, sector, signal " + "capital_score, technical_score, position_score, sector, signal, entry_signal_type " "FROM recommendations " "WHERE ts_code = :code " "AND (action_plan IN ('可操作', '重点关注') OR COALESCE(llm_score, 0) >= 6 OR score >= 56) " @@ -484,12 +484,14 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): rm = rec_row._mapping rec_score_str = ( f"\n推荐体系评分: 综合={rm['score']}, " - f"供需={rm['supply_demand_score']}(50%权重), " - f"形态={rm['price_action_score']}(40%权重), " - f"趋势={rm['technical_score']}(10%权重), " + f"资金={rm['capital_score']}(资金顺势核心), " + f"供需={rm['supply_demand_score']}, " + f"形态={rm['price_action_score']}, " + f"趋势={rm['technical_score']}, " f"位置安全={rm['position_score']}, " f"板块={rm['sector']}, " - f"信号={rm['signal']}" + f"信号={rm['signal']}, " + f"入场类型={rm['entry_signal_type']}" ) except Exception: pass @@ -556,11 +558,12 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): 重要提示: 1. 你不是在写传统研报,而是在给交易作战台输出结构化会诊意见。 2. 如果有推荐体系评分、操作计划、跟踪信息,请优先沿用当前推荐体系,而不是另起一套标准。 -3. 趋势评分是推荐体系的技术面核心分数(均线排列40+高低点结构35+MA20方向25=满分100),辅助信号计数仅供参考不参与主评分。 -4. 位置安全评分高(>80)表示股价处于相对低位,低(<40)表示可能追高。 -5. 板块信息和推荐体系信息优先级高于单一技术指标。 -6. 先给结论和动作,再解释原因;不要先铺陈背景再拖到最后才下结论。 -7. 如果证据不足,也要明确给出“观察”或“回避”,不能写成模糊建议。 +3. 当前推荐体系以“资金顺势 + 主线板块 + 供需/量价 + 趋势”为主,技术指标只做节奏和风控确认。 +4. RSI、MACD、KDJ 等滞后指标不能单独决定买卖;RSI 超买只提示追高风险,超卖只提示弱势或反弹弹性,不等于可买或不可买。 +5. 位置安全评分高(>80)表示股价处于相对低位,低(<40)表示可能追高。 +6. 板块信息、资金面和推荐体系信息优先级高于单一技术指标。 +7. 先给结论和动作,再解释原因;不要先铺陈背景再拖到最后才下结论。 +8. 如果证据不足,也要明确给出“观察”或“回避”,不能写成模糊建议。 {freshness_note} 请严格按以下 Markdown 结构输出,不要写成泛泛长文: diff --git a/backend/app/data/models.py b/backend/app/data/models.py index b995b108..54510898 100644 --- a/backend/app/data/models.py +++ b/backend/app/data/models.py @@ -119,7 +119,7 @@ class TechnicalSignal(BaseModel): ma_bullish: bool = False # 均线多头排列 volume_breakout: bool = False # 放量突破 macd_golden: bool = False # MACD金叉 - rsi_healthy: bool = False # RSI健康区间 + rsi_healthy: bool = False # RSI节奏区间(辅助参考) pullback_support: bool = False # 缩量回踩支撑 big_yang: bool = False # 底部放量长阳 boll_support: bool = False # 布林带下轨支撑 @@ -148,8 +148,8 @@ class Recommendation(BaseModel): sector_score: float capital_score: float technical_score: float - supply_demand_score: float = 0 # 供需评分(主评分50%权重) - price_action_score: float = 0 # 价格行为评分(主评分40%权重) + supply_demand_score: float = 0 # 供需评分 + price_action_score: float = 0 # 价格行为评分 position_score: float = 50 # 位置安全得分 valuation_score: float = 50 # 估值安全得分 signal: str # BUY / SELL / HOLD diff --git a/backend/app/engine/screener.py b/backend/app/engine/screener.py index cf56b9c7..1c5ebdd9 100644 --- a/backend/app/engine/screener.py +++ b/backend/app/engine/screener.py @@ -3,10 +3,10 @@ 三阶段管道: Step 1: 主线定位 — 把实时板块/快照板块归一成系统 MarketTheme Step 2: 主题内选股 — 从主线主题成分、领涨股和实时异动中召回候选 - Step 3: 深度分析 — 供需 + 价格行为 + 趋势 + LLM (10-15只推荐) + Step 3: 深度分析 — 资金顺势 + 供需 + 价格行为 + 趋势 + LLM -评分公式:供需关系 50% + 价格行为 40% + 趋势 10% -主题地位和资金流作为前置上下文,涨停/广度只作为辅助证据。 +评分公式:资金顺势 + 供需关系 + 价格行为 + 趋势。 +资金流和主题地位进入主评分,RSI/MACD 只作为节奏与风险参考。 风险乘数:惩罚取最大而非叠加(防过度惩罚),奖励可叠加。 @@ -605,10 +605,12 @@ async def _build_recommendations( total = len(candidates) signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0} score_weights = strategy_profile.score_weights if strategy_profile else { - "supply_demand": 0.50, - "price_action": 0.40, - "trend": 0.10, + "capital_momentum": 0.30, + "supply_demand": 0.30, + "price_action": 0.25, + "trend": 0.15, } + score_weights = _normalize_score_weights(score_weights) signal_priority = strategy_profile.entry_signal_priority if strategy_profile else [] buy_threshold = strategy_profile.buy_threshold if strategy_profile else 60 @@ -651,6 +653,8 @@ async def _build_recommendations( supply_demand_score = score_supply_demand(df) price_action_score = _score_price_action(df, entry_signal) trend_score = _score_trend(df) + capital_score = _score_capital_simple(stock) + flow_momentum_score = _score_flow_momentum(stock, sector, hot_sectors) last = df.iloc[-1] trend_penalty = 1.0 @@ -660,6 +664,7 @@ async def _build_recommendations( trend_penalty = 0.82 final_score = ( + flow_momentum_score * score_weights["capital_momentum"] + supply_demand_score * score_weights["supply_demand"] + price_action_score * score_weights["price_action"] + trend_score * score_weights["trend"] @@ -699,6 +704,8 @@ async def _build_recommendations( if entry_signal.get("signal_score", 0) >= 80: final_score *= 1.10 + final_score *= _flow_confirmation_multiplier(stock, hot_theme_match, market_temp) + if not hot_theme_match: final_score *= 0.82 elif hot_theme_match not in hot_sectors[:5]: @@ -721,11 +728,25 @@ async def _build_recommendations( level = _score_to_level(final_score) signal = "HOLD" position_score = tech_signal.position_score if tech_signal else 50 + flow_confirmed = _is_flow_confirmed( + stock=stock, + flow_momentum_score=flow_momentum_score, + trend_score=trend_score, + price_action_score=price_action_score, + ) + effective_signal_name = signal_name + if signal_name == "none" and flow_confirmed: + effective_signal_name = "flow_momentum" + if ( signal_type != EntrySignal.NONE and entry_signal.get("signal_score", 0) >= 50 and position_score >= 30 and final_score >= buy_threshold + ) or ( + flow_confirmed + and position_score >= 30 + and final_score >= buy_threshold + 2 ): signal = "BUY" @@ -796,13 +817,13 @@ async def _build_recommendations( if target_price < entry_price * (1 + min_target_pct): target_price = round(entry_price * (1 + min_target_pct), 2) + stock["entry_signal_type"] = effective_signal_name reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday) - stock["entry_signal_type"] = signal_name risk_note = _generate_risk_note(market_temp, tech_signal, stock) vol_pattern = analyze_volume_pattern(df) - entry_timing = _generate_entry_timing(signal_name, intraday) + entry_timing = _generate_entry_timing(effective_signal_name, intraday) trade_plan = _build_trade_plan( - signal_type=signal_name, + signal_type=effective_signal_name, score=final_score, market_temp=market_temp, sector_stage=sector_stage, @@ -820,7 +841,7 @@ async def _build_recommendations( score=round(final_score, 1), market_temp_score=round(market_temp_score, 1), sector_score=round(_get_sector_heat(sector, hot_sectors), 1), - capital_score=round(_score_capital_simple(stock), 1), + capital_score=round(capital_score, 1), technical_score=round(trend_score, 1), supply_demand_score=round(supply_demand_score, 1), price_action_score=round(price_action_score, 1), @@ -834,7 +855,7 @@ async def _build_recommendations( risk_note=risk_note, level=level, strategy=strategy_profile.strategy_id if strategy_profile else "trend_breakout", - entry_signal_type=signal_name, + entry_signal_type=effective_signal_name, entry_timing=entry_timing, action_plan=trade_plan["action_plan"], trigger_condition=trade_plan["trigger_condition"], @@ -869,8 +890,9 @@ async def _build_recommendations( "hot_theme_name": hot_theme_match.sector_name if hot_theme_match else "", "hot_theme_aliases": hot_theme_match.theme_aliases if hot_theme_match else [], "stock_role_hint": stock.get("stock_role_hint", "待判断"), - "entry_signal_type": signal_name, + "entry_signal_type": effective_signal_name, "entry_signal_score": round(entry_signal.get("signal_score", 0), 1), + "flow_momentum_score": round(flow_momentum_score, 1), "signal_matches_profile": signal_matches_profile, "risk_tags": _build_risk_tags(market_temp, tech_signal, sector_stage, trend_penalty), "focus_points": _build_focus_points(stock, entry_signal, tech_signal, vol_pattern, sector_stage), @@ -1042,6 +1064,145 @@ async def _build_recommendations( return recommendations +# ── 主评分辅助 ── + + +def _normalize_score_weights(weights: dict[str, float]) -> dict[str, float]: + """兼容旧策略权重,并保证资金顺势进入主评分。""" + defaults = { + "capital_momentum": 0.30, + "supply_demand": 0.30, + "price_action": 0.25, + "trend": 0.15, + } + merged = {**defaults, **(weights or {})} + keys = ["capital_momentum", "supply_demand", "price_action", "trend"] + total = sum(max(float(merged.get(k, 0) or 0), 0) for k in keys) + if total <= 0: + return defaults + return {k: max(float(merged.get(k, 0) or 0), 0) / total for k in keys} + + +def _score_flow_momentum(stock: dict, sector_name: str, hot_sectors: list[SectorInfo]) -> float: + """资金顺势评分:个股资金在场 + 主线板块顺风 + 活跃度确认。""" + main_net = float(stock.get("main_net_inflow", 0) or 0) + inflow_ratio = float(stock.get("inflow_ratio", 0) or 0) + turnover_rate = float(stock.get("turnover_rate", 0) or 0) + volume_ratio = stock.get("volume_ratio") + volume_ratio = float(volume_ratio) if volume_ratio not in (None, "") else 0.0 + recall_score = float(stock.get("recall_score", 0) or 0) + sector_heat = _get_sector_heat(sector_name, hot_sectors) + sector_limit_up = _get_sector_limit_up(sector_name, hot_sectors) + + score = 0.0 + + # 个股主力资金占 40 分。负流入不一票否决,但会明显降权。 + if main_net > 15000: + score += 28 + elif main_net > 8000: + score += 24 + elif main_net > 3000: + score += 18 + elif main_net > 0: + score += 10 + elif main_net < -5000: + score -= 12 + elif main_net < 0: + score -= 6 + + if inflow_ratio > 12: + score += 12 + elif inflow_ratio > 8: + score += 9 + elif inflow_ratio > 4: + score += 6 + elif inflow_ratio > 0: + score += 3 + elif inflow_ratio < -8: + score -= 8 + + # 主线板块和涨停广度占 25 分。 + if sector_heat >= 80: + score += 16 + elif sector_heat >= 65: + score += 12 + elif sector_heat >= 50: + score += 8 + elif sector_heat >= 35: + score += 4 + + if sector_limit_up >= 5: + score += 9 + elif sector_limit_up >= 3: + score += 6 + elif sector_limit_up >= 1: + score += 3 + + # 活跃度和召回强度占 35 分。 + if volume_ratio >= 2.5: + score += 12 + elif volume_ratio >= 1.8: + score += 9 + elif volume_ratio >= 1.2: + score += 6 + elif volume_ratio > 0: + score += 2 + + if 4 <= turnover_rate <= 12: + score += 10 + elif 2 <= turnover_rate <= 20: + score += 7 + elif turnover_rate > 0: + score += 3 + + score += min(max(recall_score, 0), 100) * 0.13 + + return round(max(0, min(score, 100)), 1) + + +def _flow_confirmation_multiplier(stock: dict, hot_theme_match: SectorInfo | None, market_temp: MarketTemperature) -> float: + """资金与主线共振时加分,资金背离时降权。""" + main_net = float(stock.get("main_net_inflow", 0) or 0) + inflow_ratio = float(stock.get("inflow_ratio", 0) or 0) + volume_ratio = stock.get("volume_ratio") + volume_ratio = float(volume_ratio) if volume_ratio not in (None, "") else 0.0 + + multiplier = 1.0 + if hot_theme_match and main_net > 3000 and inflow_ratio > 3: + multiplier += 0.05 + if hot_theme_match and volume_ratio >= 1.5 and market_temp.temperature >= 50: + multiplier += 0.04 + if main_net < -3000 and inflow_ratio < -3: + multiplier -= 0.10 + elif main_net < 0 and not hot_theme_match: + multiplier -= 0.06 + return max(0.82, min(multiplier, 1.12)) + + +def _is_flow_confirmed( + stock: dict, + flow_momentum_score: float, + trend_score: float, + price_action_score: float, +) -> bool: + """允许资金顺势票进入候选,不必等 RSI/MACD 等滞后指标确认。""" + main_net = float(stock.get("main_net_inflow", 0) or 0) + inflow_ratio = float(stock.get("inflow_ratio", 0) or 0) + volume_ratio = stock.get("volume_ratio") + volume_ratio = float(volume_ratio) if volume_ratio not in (None, "") else 0.0 + tags = set(stock.get("recall_tags", []) or []) + + return ( + flow_momentum_score >= 72 + and main_net > 2000 + and inflow_ratio > 2 + and volume_ratio >= 1.15 + and trend_score >= 45 + and price_action_score >= 42 + and bool(tags & {"hot_theme_core", "theme_leader", "top_theme_member", "sector_recall"}) + ) + + # ── 价格行为评分 ── @@ -1309,6 +1470,7 @@ def _generate_entry_timing(signal_type: str, intraday: bool) -> str: "pullback": "盘中靠近支撑位时分批进场,尾盘14:30确认支撑有效可加仓", "launch": "早盘放量确认后即可进场,注意开盘9:30-10:00量能", "reversal": "午后13:30确认不回落再进场,避免早盘追高", + "flow_momentum": "优先看资金是否延续流入和板块前排是否继续走强,分时承接确认后再分批", } return timing_map.get(signal_type, "盘中观察量价配合,确认信号后进场") @@ -1334,7 +1496,8 @@ def _build_trade_plan( "pullback": "回踩支撑", "launch": "缩量整理后启动", "reversal": "放量反转", - }.get(signal_type, "技术信号") + "flow_momentum": "资金顺势确认", + }.get(signal_type, "资金与量价信号") if market_temp.temperature < 40 or sector_stage in ("end",): action_plan = "观察" @@ -1421,9 +1584,13 @@ def _generate_reasons( EntrySignal.PULLBACK: "回踩型", EntrySignal.LAUNCH: "启动型", EntrySignal.REVERSAL: "反转型"} entry_label = signal_map.get(signal_type, "") + if stock.get("entry_signal_type") == "flow_momentum": + entry_label = "资金顺势型" # 入场信号 - if entry_label and signal_type: + if entry_label and stock.get("entry_signal_type") == "flow_momentum": + reasons.append("主线主题内资金顺势增强,优先跟踪承接而非等待滞后指标") + elif entry_label and signal_type: st = signal_type.value if st == "breakout": breakout_pct = details.get("breakout_pct", 0) @@ -1492,6 +1659,8 @@ def _generate_risk_note( notes.append("启动型整理可能延长,注意时间成本") elif entry_type == "reversal": notes.append("反转型可能二次探底,确认底部后再加仓") + elif entry_type == "flow_momentum": + notes.append("资金顺势型需防板块分歧和资金一日游,重点看次日承接") if market.temperature < 30: notes.append("市场情绪偏冷,系统性风险较高") @@ -1541,6 +1710,8 @@ def _build_focus_points( signal_type = entry_signal.get("signal_type") if signal_type and getattr(signal_type, "value", "none") != "none": points.append(f"确认{signal_type.value}信号是否延续") + elif stock.get("entry_signal_type") == "flow_momentum": + points.append("确认主力流入和板块前排强度是否延续") if stock.get("main_net_inflow", 0) > 0: points.append("观察主力流入是否继续放大") if vol_pattern.get("volume_trend"): @@ -1623,7 +1794,7 @@ def _summarize_for_llm(df, entry_signal: dict, tech_signal: TechnicalSignal | No vol_conclusion += ",近5日缩量" parts.append(vol_conclusion) - # ── MACD 结论 ── + # ── MACD 结论(节奏参考) ── dif = last.get("dif", 0) or 0 dea = last.get("dea", 0) or 0 macd_desc = "" @@ -1643,19 +1814,19 @@ def _summarize_for_llm(df, entry_signal: dict, tech_signal: TechnicalSignal | No macd_desc += ",零轴上方(偏多)" else: macd_desc += ",零轴下方(偏空)" - parts.append(macd_desc or "MACD数据不足") + parts.append((macd_desc or "MACD数据不足") + ";仅作节奏参考") - # ── RSI 结论 ── + # ── RSI 结论(风险提示,不做买卖裁判) ── rsi = last.get("rsi14", 50) if not pd.isna(rsi): if rsi > 80: - parts.append(f"RSI14={rsi:.0f},超买区,回调风险大") + parts.append(f"RSI14={rsi:.0f},偏热,提示追高风险但不单独否决资金顺势") elif rsi > 70: parts.append(f"RSI14={rsi:.0f},偏高,注意追高风险") elif rsi >= 40: - parts.append(f"RSI14={rsi:.0f},健康区间") + parts.append(f"RSI14={rsi:.0f},节奏中性") else: - parts.append(f"RSI14={rsi:.0f},偏低,可能超卖") + parts.append(f"RSI14={rsi:.0f},偏低,提示弱势或反弹弹性,不单独构成买点") # ── 价格位置结论 ── if tech_signal: diff --git a/backend/app/llm/batch_screener.py b/backend/app/llm/batch_screener.py index 1dcd9fa2..6718e021 100644 --- a/backend/app/llm/batch_screener.py +++ b/backend/app/llm/batch_screener.py @@ -27,6 +27,7 @@ async def prefilter_single_stock(candidate: dict, market_summary: str) -> dict: 主题别名: {", ".join(candidate.get("hot_theme_aliases", []) or ["无"])} 召回来源: {', '.join(candidate.get('recall_tags', []) or ['未标注'])} 规则参考分: {candidate.get('quant_score', 0)}/100 +资金顺势分: {candidate.get('flow_momentum_score', 0)}/100 位置安全: {candidate.get('position_score', 50)}/100 当前价: {candidate.get('current_price', '未知')} 主题阶段: {candidate.get('sector_stage', '未知')} @@ -109,6 +110,7 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict: 主题: {candidate.get('sector', '未知')} 主线主题匹配: {"是,匹配 " + candidate.get("hot_theme_name", "") if candidate.get("hot_theme_matched") else "否"} 规则参考分: {candidate.get('quant_score', 0)}/100 +资金顺势分: {candidate.get('flow_momentum_score', 0)}/100 位置安全: {candidate.get('position_score', 50)}/100 当前价: {candidate.get('current_price', '未知')}""" diff --git a/backend/app/llm/prompts.py b/backend/app/llm/prompts.py index b448e3c9..2269b38c 100644 --- a/backend/app/llm/prompts.py +++ b/backend/app/llm/prompts.py @@ -126,9 +126,11 @@ SINGLE_STOCK_ANALYSIS_PROMPT = """\ 你的原则: 1. 量化分数只是参考,不是最终答案 -2. 如果板块地位、量价质量、位置或时机不匹配,可以直接否决高分股 -3. 如果股票具备明确触发与失效边界,即使量化分不是最高,也可以提升优先级 -4. 输出的是交易裁决单,不是研报 +2. A股先看资金是否在主线里形成合力,再看K线形态是否给出可执行边界 +3. RSI、MACD、KDJ 这类滞后指标只能作为节奏和风险备注,不能因为超买/超卖本身否决资金顺势标的 +4. 如果板块地位、资金延续、量价质量、位置或时机不匹配,可以直接否决高分股 +5. 如果股票具备明确触发与失效边界,即使量化分不是最高,也可以提升优先级 +6. 输出的是交易裁决单,不是研报 请严格输出 JSON,不要输出 Markdown,不要添加多余解释。字段如下: { @@ -163,9 +165,10 @@ STOCK_PREFILTER_PROMPT = """\ 你的原则: 1. 这一步不是最终买卖结论,只做资源分配 -2. 不能因为某一个规则分数低就直接忽略,要看题材位置、角色、量价异常、时机感 -3. 可以容忍不标准的形态,但不能容忍明显失真、明显追高、明显没有交易边界的票 -4. 输出必须是 JSON,不要输出 Markdown +2. 优先识别资金正在进攻的主线前排或强承接标的 +3. 不能因为 RSI/MACD/KDJ 等滞后指标“不好看”就直接忽略,要看题材位置、角色、量价异常、时机感 +4. 可以容忍不标准的形态,但不能容忍明显失真、明显追高、明显没有交易边界的票 +5. 输出必须是 JSON,不要输出 Markdown 字段格式: { diff --git a/backend/app/llm/strategy_selector.py b/backend/app/llm/strategy_selector.py index 8b304033..c305448f 100644 --- a/backend/app/llm/strategy_selector.py +++ b/backend/app/llm/strategy_selector.py @@ -50,7 +50,7 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile: name="主线突破", description="市场偏强,优先寻找主线板块内的突破和突破确认。", entry_signal_priority=["breakout", "breakout_confirm", "launch", "pullback", "reversal"], - score_weights={"supply_demand": 0.45, "price_action": 0.35, "trend": 0.20}, + score_weights={"capital_momentum": 0.30, "supply_demand": 0.30, "price_action": 0.25, "trend": 0.15}, min_score=62, buy_threshold=66, max_position_pct=30, @@ -67,7 +67,7 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile: name="回踩轮动", description="市场震荡分化,优先做回踩支撑和板块轮动中的低吸确认。", entry_signal_priority=["pullback", "breakout_confirm", "launch", "breakout", "reversal"], - score_weights={"supply_demand": 0.40, "price_action": 0.30, "trend": 0.30}, + score_weights={"capital_momentum": 0.28, "supply_demand": 0.30, "price_action": 0.22, "trend": 0.20}, min_score=60, buy_threshold=63, max_position_pct=20, @@ -84,7 +84,7 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile: name="启动试错", description="市场偏弱,适合少量观察启动型和反转型机会,不做强追涨。", entry_signal_priority=["launch", "reversal", "pullback", "breakout_confirm", "breakout"], - score_weights={"supply_demand": 0.35, "price_action": 0.35, "trend": 0.30}, + score_weights={"capital_momentum": 0.32, "supply_demand": 0.28, "price_action": 0.25, "trend": 0.15}, min_score=58, buy_threshold=61, max_position_pct=10, @@ -101,7 +101,7 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile: name="防守观察", description="市场退潮,系统以观察池为主,不主动扩大出手。", entry_signal_priority=["pullback", "launch", "reversal", "breakout_confirm", "breakout"], - score_weights={"supply_demand": 0.35, "price_action": 0.40, "trend": 0.25}, + score_weights={"capital_momentum": 0.35, "supply_demand": 0.25, "price_action": 0.25, "trend": 0.15}, min_score=56, buy_threshold=64, max_position_pct=5,