"""交易机会生命周期与买点质量闸门。 v1.7.5: 把“强势发现”和“可执行买点”彻底分离。 - 低位静K蓄力 + 大户偏多/主题/板块 → 潜伏计划 - risk_reward_ok=false / rr不足 / 离突破位过远 → 禁止可即刻买入 - 高位爆发确认 → 强势发现/等回踩/观察,不再当新开仓 """ import json import re from typing import Any, Dict, Iterable, Tuple from app.core.opportunity_level import OPPORTUNITY_LEVELS from app.core.order_lifecycle import order_rr from app.core.strategy_registry import normalize_strategy_code, strategy_entry_gate_config from app.core.trade_direction import normalize_trade_side DEFAULT_ENTRY_GATE = { "enabled": True, "min_rr_buy_now": 1.2, "min_rr_observe": 1.0, "breakout_distance_wait_pct": 15, "breakout_distance_risk_pct": 30, "breakout_distance_ban_pct": 60, "gain_24h_wait_pct": 8, "gain_24h_observe_pct": 12, "low_position_max_pct": 55, "low_plan_max_gain_24h_pct": 8, "low_plan_min_static_count": 3, "low_plan_min_top_long_pct": 55, "max_wait_pullback_deviation_pct": 12, "min_entry_score_buy_now": 3, "min_entry_score_wait_pullback": 1, } # AlphaX Agent 统一状态机:所有展示/统计/推送都应消费这些派生状态,不再各自解释 status/action_status。 TERMINAL_STATUSES = {"hit_tp1", "hit_tp2", "stopped_out", "expired", "archived", "invalid"} EXIT_ACTIONS = {"止损", "衰减", "反转", "放弃"} PROFIT_ACTIONS = {"止盈1", "止盈2", "跟踪止盈"} def normalize_action_status(action_status: Any, status: str = "") -> str: action = str(action_status or "").strip() status = str(status or "").strip() terminal_map = { "hit_tp1": "止盈1", "hit_tp2": "止盈2", "stopped_out": "止损", "expired": "过期", "invalid": "放弃", "archived": "归档", } if status in terminal_map: return terminal_map[status] aliases = { "即刻买入": "可即刻买入", "🟢即刻买入": "可即刻买入", "🟢可即刻买入": "可即刻买入", "🟡等回踩": "等回踩", "等待回踩": "等回踩", "观望": "观察", "观察中": "观察", "持仓": "持有", } return aliases.get(action, action or "观察") def derive_display_bucket(status: str, action_status: str, execution_status: str = "") -> Dict[str, str]: """把 DB 主状态派生成唯一展示桶。 这是实时/历史/迭代/飞书共同口径: - realtime: 当前有效机会雷达 - watch_pool: 未触发入场的观察/等待阶段,不计推荐收益 - position: 已入场或利润管理中的交易态 - history: 失效/止损/过期/归档 """ status = str(status or "active").strip() action = normalize_action_status(action_status, status) execution_status = str(execution_status or "").strip() if status in ("stopped_out", "expired", "archived", "invalid") or action in EXIT_ACTIONS or action in ("过期", "归档"): return {"display_bucket": "history", "lifecycle_state": "invalidated", "execution_status": "invalid"} if status in ("hit_tp1", "hit_tp2") or action in PROFIT_ACTIONS: return {"display_bucket": "position", "lifecycle_state": "profit_management", "execution_status": "completed"} if action == "可即刻买入" or execution_status == "buy_now": return {"display_bucket": "realtime", "lifecycle_state": "buyable", "execution_status": "buy_now"} if action == "等回踩" or execution_status == "wait_pullback": return {"display_bucket": "watch_pool", "lifecycle_state": "waiting_entry", "execution_status": "wait_pullback"} if action == "持有": # “持有”在旧链路里经常只是默认值,不等于真实成交/持仓。 # 未出现止盈/止损/明确入场窗口前,一律归观察池,避免把粗筛候选当持仓收益。 return {"display_bucket": "watch_pool", "lifecycle_state": "watching", "execution_status": "observe"} return {"display_bucket": "watch_pool", "lifecycle_state": "watching", "execution_status": "observe"} def is_executed_lifecycle(status: str, action_status: str, execution_status: str = "") -> bool: bucket = derive_display_bucket(status, action_status, execution_status) return bucket["display_bucket"] == "position" or bucket["lifecycle_state"] in {"holding", "profit_management"} def _cfg_value(cfg: Dict[str, Any], key: str): return cfg.get(key, DEFAULT_ENTRY_GATE.get(key)) def normalize_json_object(payload: Any) -> Dict[str, Any]: if isinstance(payload, dict): return payload if not payload: return {} try: parsed = json.loads(payload) return parsed if isinstance(parsed, dict) else {} except Exception: return {} def normalize_signals(signals: Any) -> list: if isinstance(signals, list): return signals if not signals: return [] try: parsed = json.loads(signals) if isinstance(parsed, list): return parsed except Exception: pass if isinstance(signals, str): return [signals] return [] def to_float(value: Any, default: float = 0.0) -> float: try: if value is None or value == "": return default return float(value) except Exception: return default def calc_rr_target_entry(stop_loss: float, tp1: float, min_rr: float) -> float: """最高允许入场价:在该价格或更低买入,RR1 才能达到 min_rr。""" stop_loss = to_float(stop_loss) tp1 = to_float(tp1) min_rr = to_float(min_rr) if stop_loss <= 0 or tp1 <= stop_loss or min_rr <= 0: return 0.0 return round((tp1 + min_rr * stop_loss) / (1 + min_rr), 8) def calc_short_rr_target_entry(stop_loss: float, tp1: float, min_rr: float) -> float: """最低允许开空价:在该价格或更高开空,RR1 才能达到 min_rr。""" stop_loss = to_float(stop_loss) tp1 = to_float(tp1) min_rr = to_float(min_rr) if stop_loss <= tp1 or tp1 <= 0 or min_rr <= 0: return 0.0 return round((tp1 + min_rr * stop_loss) / (1 + min_rr), 8) def detect_breakout_distance_pct(signals: Iterable[Any]) -> float: """从“站稳突破位 +66.7%”等信号中提取最大追高距离。""" max_pct = 0.0 for sig in signals or []: text = str(sig) if "突破位" not in text: continue for m in re.finditer(r"\+\s*([0-9]+(?:\.[0-9]+)?)\s*%", text): max_pct = max(max_pct, to_float(m.group(1))) return round(max_pct, 2) def detect_static_count(signals: Iterable[Any]) -> int: max_count = 0 for sig in signals or []: text = str(sig) if "静K" not in text and "蓄力" not in text: continue for pattern in (r"([0-9]+)\s*静K", r"静K[^0-9]{0,8}\(?([0-9]+)\s*静K"): for m in re.finditer(pattern, text): try: max_count = max(max_count, int(m.group(1))) except Exception: pass return max_count def has_current_entry_trigger(signals: Iterable[Any], entry_plan: Dict[str, Any]) -> bool: if entry_plan.get("entry_trigger_confirmed") is True: return True summary = str(entry_plan.get("pa_15min_summary") or "") if "即刻入场" in summary and "无动K突破" not in summary: return True for sig in signals or []: text = str(sig) if ( "15min即刻入场" in text or "当前15min" in text or "当前 15min" in text or "回踩确认完毕" in text or "可即刻入场" in text or "15min动K确认" in text ): return True return False def has_bearish_flow_risk(signals: Iterable[Any]) -> bool: risk_keywords = ("空头加速", "放量阴线", "多头出货", "量价背离") return any(any(keyword in str(sig) for keyword in risk_keywords) for sig in signals or []) def _calc_position_pct(current_price: float, entry_plan: Dict[str, Any]) -> float: support = to_float(entry_plan.get("support") or entry_plan.get("support_price") or entry_plan.get("range_low")) resistance = to_float(entry_plan.get("resistance") or entry_plan.get("resistance_price") or entry_plan.get("range_high")) if support > 0 and resistance > support and current_price > 0: return round((current_price - support) / (resistance - support) * 100, 2) return to_float(entry_plan.get("position_pct"), 50.0) def build_low_ambush_plan( *, entry_plan: Dict[str, Any], signals: Iterable[Any], current_price: float, market_context: Dict[str, Any], derivatives_context: Dict[str, Any], sector_context: Dict[str, Any], cfg: Dict[str, Any] = None, ) -> Tuple[Dict[str, Any], list]: """识别低位潜伏机会,返回 opportunity_lifecycle + reasons。""" cfg = {**DEFAULT_ENTRY_GATE, **(cfg or {})} signal_list = normalize_signals(signals) static_count = detect_static_count(signal_list) top_long = to_float(derivatives_context.get("top_trader_long_pct")) change_24h = to_float(market_context.get("change_24h")) position_pct = _calc_position_pct(current_price, entry_plan) has_theme_or_sector = bool(sector_context.get("hot_sectors")) or any( key in str(s) for s in signal_list for key in ("主题", "生态", "舆情", "板块联动") ) reasons = [] if static_count >= _cfg_value(cfg, "low_plan_min_static_count"): reasons.append(f"静K蓄力{static_count}根") if top_long >= _cfg_value(cfg, "low_plan_min_top_long_pct"): reasons.append(f"大户偏多{top_long:.0f}%") if has_theme_or_sector: reasons.append("主题/板块资金线索") if position_pct <= _cfg_value(cfg, "low_position_max_pct"): reasons.append(f"结构位置偏低({position_pct:.0f}%)") if change_24h <= _cfg_value(cfg, "low_plan_max_gain_24h_pct"): reasons.append(f"24h涨幅未过热({change_24h:.1f}%)") qualifies = ( static_count >= _cfg_value(cfg, "low_plan_min_static_count") and change_24h <= _cfg_value(cfg, "low_plan_max_gain_24h_pct") and position_pct <= _cfg_value(cfg, "low_position_max_pct") and (top_long >= _cfg_value(cfg, "low_plan_min_top_long_pct") or has_theme_or_sector) ) if not qualifies: return {}, reasons plan_entry = to_float(entry_plan.get("entry_price")) or current_price stop_loss = to_float(entry_plan.get("stop_loss")) return { "stage": "低位潜伏", "plan_type": "ambush", "trigger": "静K蓄力+低位+资金/主题线索", "ambush_price": round(plan_entry, 8), "current_price": round(current_price, 8), "stop_loss": stop_loss, "static_count": static_count, "top_trader_long_pct": top_long, "change_24h": change_24h, "position_pct": position_pct, "reasons": reasons, }, reasons def apply_entry_quality_gate( *, action_status: str, entry_plan: Dict[str, Any], signals: Iterable[Any] = None, current_price: float = 0, market_context: Dict[str, Any] = None, derivatives_context: Dict[str, Any] = None, sector_context: Dict[str, Any] = None, cfg: Dict[str, Any] = None, strategy_code: str = "", ) -> Tuple[str, Dict[str, Any], list]: """返回修正后的 action_status、增强后的 entry_plan、拦截原因。""" strategy_code = normalize_strategy_code(strategy_code or (entry_plan or {}).get("strategy_code")) cfg = {**DEFAULT_ENTRY_GATE, **strategy_entry_gate_config(strategy_code), **(cfg or {})} if not cfg.get("enabled", True): return action_status, entry_plan or {}, [] entry_plan = dict(entry_plan or {}) entry_plan.setdefault("strategy_code", strategy_code) side = normalize_trade_side(entry_plan.get("side") or entry_plan.get("direction")) entry_plan["side"] = side signals = normalize_signals(signals) market_context = market_context or {} derivatives_context = derivatives_context or {} sector_context = sector_context or {} reasons = [] current_price = to_float(current_price) or to_float(entry_plan.get("current_price")) or to_float(entry_plan.get("entry_price")) rr1 = to_float(entry_plan.get("rr1"), 999.0) risk_reward_ok = entry_plan.get("risk_reward_ok") original_rr1 = rr1 original_risk_reward_ok = risk_reward_ok stop_loss = to_float(entry_plan.get("stop_loss")) tp1 = to_float(entry_plan.get("tp1") or entry_plan.get("take_profit_1")) plan_entry_price = to_float(entry_plan.get("entry_price")) invalid_plan_geometry = False if current_price > 0 and stop_loss > 0 and tp1 > 0: live_rr1 = order_rr(side, current_price, stop_loss, tp1) if live_rr1 > 0: live_rr1 = round(live_rr1, 2) else: live_rr1 = 0.0 entry_plan["rr1_live"] = live_rr1 entry_plan["rr1_live_price"] = round(current_price, 8) # 当前价已经明显低于确认时价格时,旧 rr1/risk_reward_ok 会失真。 # 买点质量闸门必须用最新现价重算 RR,否则会出现“现价低于回踩参考,却仍让等回踩”的矛盾。 rr1 = live_rr1 risk_reward_ok = live_rr1 >= _cfg_value(cfg, "min_rr_buy_now") entry_plan["risk_reward_ok_live"] = risk_reward_ok if action_status in ("可即刻买入", "等回踩") and plan_entry_price > 0: if side == "short": if stop_loss > 0 and stop_loss <= plan_entry_price: invalid_plan_geometry = True reasons.append("空头计划无效:止损价不高于计划开空价,转为观察") if tp1 > 0 and tp1 >= plan_entry_price: invalid_plan_geometry = True reasons.append("空头计划无效:TP1不低于计划开空价,转为观察") elif stop_loss > 0 and stop_loss >= plan_entry_price: invalid_plan_geometry = True reasons.append("多头计划无效:止损价不低于计划入场价,转为观察") if side != "short" and tp1 > 0 and tp1 <= plan_entry_price: invalid_plan_geometry = True reasons.append("多头计划无效:TP1不高于计划入场价,转为观察") entry_action = str(entry_plan.get("entry_action") or "").strip() score_components = normalize_json_object(entry_plan.get("score_components")) has_entry_score = "entry_score" in score_components entry_score = to_float(score_components.get("entry_score")) if has_entry_score else None opportunity_level = str(entry_plan.get("opportunity_level") or "").strip() level_meta = OPPORTUNITY_LEVELS.get(opportunity_level, {}) level_max_action = level_meta.get("max_action") or str(entry_plan.get("max_action") or "").strip() if entry_plan.get("entry_quality_gate"): entry_plan.pop("entry_quality_gate", None) breakout_distance = detect_breakout_distance_pct(signals) change_24h = to_float(market_context.get("change_24h")) current_entry_trigger = has_current_entry_trigger(signals, entry_plan) bearish_flow_risk = has_bearish_flow_risk(signals) lifecycle, ambush_reasons = build_low_ambush_plan( entry_plan=entry_plan, signals=signals, current_price=current_price, market_context=market_context, derivatives_context=derivatives_context, sector_context=sector_context, cfg=cfg, ) if lifecycle: entry_plan["opportunity_lifecycle"] = lifecycle elif ambush_reasons: entry_plan.setdefault("opportunity_lifecycle", { "stage": "强势发现", "plan_type": "watch", "reasons": ambush_reasons, }) if action_status in ("可即刻买入", "等回踩"): if risk_reward_ok is False: reasons.append(f"risk_reward_ok=false,盈亏比闸门禁止现价买入;实时rr1={rr1}") if "rr1" in entry_plan and rr1 < _cfg_value(cfg, "min_rr_buy_now"): reasons.append(f"rr1={rr1} < {_cfg_value(cfg, 'min_rr_buy_now')},禁止现价买入") if action_status == "可即刻买入": if has_entry_score and entry_score < _cfg_value(cfg, "min_entry_score_buy_now"): reasons.append(f"买点分{entry_score:.1f}低于现价买入门槛{_cfg_value(cfg, 'min_entry_score_buy_now')},禁止立即买入") if level_max_action == "observe": reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别最高只允许{level_meta.get('label') and ('观察/等待') or '观察/等待'},禁止现价买入") elif level_max_action == "wait_pullback" and not current_entry_trigger: reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别需要低周期触发后才允许买入") if not current_entry_trigger: reasons.append("缺少当前15min触发,禁止现价买入") if side != "short" and bearish_flow_risk: reasons.append("出现空头加速/放量阴线风险,禁止现价买入") if current_price > 0: plan_entry_price = to_float(entry_plan.get("entry_price")) # 价格已经回到/跌破计划参考价,且实时 RR 已达标时,应转为入场窗口,不能继续显示“等回踩”。 target_touched = ( plan_entry_price > 0 and ( (side == "short" and current_price >= plan_entry_price * 0.997) or (side != "short" and current_price <= plan_entry_price * 1.003) ) ) if target_touched and risk_reward_ok is not False and rr1 >= _cfg_value(cfg, "min_rr_buy_now"): entry_plan["entry_trigger_confirmed"] = True entry_plan["entry_action"] = "可即刻买入" # 缺少止损/目标价时 rr1 默认 999,不能因为字段不全拦截测试/候选信号;有显式 rr1 时才按硬门槛降级。 if entry_action == "等回踩" and not entry_plan.get("entry_trigger_confirmed") and ( (side == "short" and current_price < to_float(entry_plan.get("entry_price")) * 0.997) or (side != "short" and current_price > to_float(entry_plan.get("entry_price")) * 1.003) ): reasons.append("原计划为等回踩,当前尚未严格触达计划价") if breakout_distance > _cfg_value(cfg, "breakout_distance_wait_pct"): reasons.append(f"离突破位+{breakout_distance:.1f}%,现价追高降级") if change_24h > _cfg_value(cfg, "gain_24h_wait_pct") and rr1 < 1.5: reasons.append(f"24h涨幅{change_24h:.1f}%且rr1不足,禁止追涨") if action_status == "等回踩" and current_price > 0: if has_entry_score and entry_score < _cfg_value(cfg, "min_entry_score_wait_pullback"): target_action = "观察" reasons.append(f"买点分{entry_score:.1f}低于挂单门槛{_cfg_value(cfg, 'min_entry_score_wait_pullback')},不生成回踩挂单") if plan_entry_price > 0: if side == "short": wait_deviation_pct = round((plan_entry_price / current_price - 1) * 100, 2) if current_price > 0 else 0 else: wait_deviation_pct = round((current_price / plan_entry_price - 1) * 100, 2) entry_plan["wait_pullback_deviation_pct"] = wait_deviation_pct lifecycle_plan_type = ((entry_plan.get("opportunity_lifecycle") or {}).get("plan_type") or "").strip() # 回踩参考已经被有效击穿,继续挂“等回踩”会误导;先降为观察。 if wait_deviation_pct > _cfg_value(cfg, "max_wait_pullback_deviation_pct"): target_action = "观察" reasons.append(f"回踩参考距离现价{wait_deviation_pct:.1f}%,突破已走远,等待新结构") elif wait_deviation_pct < -1.2: target_action = "观察" reasons.append("回踩参考已下破,转为观察") # 参考价已到或更优,且 RR 达标时,直接转为入场窗口。 elif wait_deviation_pct <= 0.3 and lifecycle_plan_type != "ambush": if risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now"): target_action = "观察" reasons.append("回踩参考已到,但实时盈亏比不达标,转为观察") elif level_max_action == "observe" or (level_max_action == "wait_pullback" and not current_entry_trigger) or bearish_flow_risk: target_action = "等回踩" if level_max_action == "wait_pullback" and not bearish_flow_risk else "观察" if level_max_action == "observe" or (level_max_action == "wait_pullback" and not current_entry_trigger): reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别最高只允许观察/等待,不能因到价直接升级为现价买入") if side != "short" and bearish_flow_risk: reasons.append("出现空头加速/放量阴线风险,到价也不升级为现价买入") else: target_action = "可即刻买入" entry_plan["entry_trigger_confirmed"] = True entry_plan["entry_action"] = "可即刻买入" reasons.append("回踩参考已到或更优,转为入场窗口") if invalid_plan_geometry: target_action = "观察" elif breakout_distance > _cfg_value(cfg, "breakout_distance_ban_pct"): target_action = "观察" reasons.append(f"离突破位+{breakout_distance:.1f}%>{ _cfg_value(cfg, 'breakout_distance_ban_pct') }%,严禁现价追") elif breakout_distance > _cfg_value(cfg, "breakout_distance_risk_pct"): target_action = "观察" reasons.append(f"离突破位+{breakout_distance:.1f}%>{ _cfg_value(cfg, 'breakout_distance_risk_pct') }%,只做强势观察") elif change_24h > _cfg_value(cfg, "gain_24h_observe_pct") and rr1 < 1.5: target_action = "观察" elif reasons: # 只要买点质量闸门明确给出拦截原因,就不能继续保留“可即刻买入”。 # 如果当前已经回到/跌破计划参考价,但实时 RR 仍不足,说明不是“等回踩”,而是“回踩到了也不值得买”,应降级为观察。 if any("回踩参考已下破" in str(x) for x in reasons): target_action = "观察" elif any("回踩参考已到或更优" in str(x) for x in reasons) and not ( level_max_action == "observe" or (level_max_action == "wait_pullback" and not current_entry_trigger) or (side != "short" and bearish_flow_risk) ): target_action = "可即刻买入" elif action_status == "等回踩" and current_price > 0 and to_float(entry_plan.get("entry_price")) > 0 and ( (side == "short" and current_price >= to_float(entry_plan.get("entry_price")) * 0.997) or (side != "short" and current_price <= to_float(entry_plan.get("entry_price")) * 1.003) ) and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")): target_action = "观察" reasons.append("回踩参考已到,但实时盈亏比不达标,转为观察") elif has_entry_score and action_status == "可即刻买入" and entry_score < _cfg_value(cfg, "min_entry_score_wait_pullback"): target_action = "观察" reasons.append("买点分不足以进入挂单池,转为观察") elif action_status == "可即刻买入" and current_price > 0 and stop_loss > 0 and tp1 > 0 and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")): rr_target_entry = ( calc_short_rr_target_entry(stop_loss, tp1, _cfg_value(cfg, "min_rr_buy_now")) if side == "short" else calc_rr_target_entry(stop_loss, tp1, _cfg_value(cfg, "min_rr_buy_now")) ) valid_wait = rr_target_entry > current_price if side == "short" else (rr_target_entry > stop_loss and rr_target_entry < current_price) if valid_wait: target_action = "等回踩" entry_plan["entry_price"] = rr_target_entry entry_plan["entry_method"] = f"等反抽至可空RR价 {rr_target_entry:.8g}" if side == "short" else f"等回踩至可买RR价 {rr_target_entry:.8g}" entry_plan["entry_action"] = "等回踩" entry_plan["rr_target_entry"] = rr_target_entry entry_plan["rr_target_reason"] = ( f"现价RR不足,需反抽到该价或更高,RR1才≥{_cfg_value(cfg, 'min_rr_buy_now')}" if side == "short" else f"现价RR不足,需回落到该价或更低,RR1才≥{_cfg_value(cfg, 'min_rr_buy_now')}" ) reasons.append(("现价不空,等反抽到" if side == "short" else "现价不买,等回落到") + f"{rr_target_entry:.8g}附近再评估") else: target_action = "观察" reasons.append("无法给出有效回踩买点,转为观察") else: # risk_reward_ok=false / rr1不足 / 追高距离过远 都代表“现价买入被禁止”; # 展示层必须降级为“等回踩/观察”,否则会出现“闸门禁止买入但仍显示入场窗口”的矛盾。 target_action = "等回踩" if action_status == "可即刻买入" else action_status else: target_action = action_status if level_max_action == "observe" and target_action in ("可即刻买入", "等回踩"): target_action = "观察" reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别仅作观察,不直接给交易动作") elif level_max_action == "wait_pullback" and target_action == "可即刻买入" and not has_current_entry_trigger(signals, entry_plan): target_action = "等回踩" reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别需要低周期触发后才允许买入") if target_action != action_status: entry_plan["entry_quality_gate"] = { "blocked_action": action_status, "final_action": target_action, "reasons": reasons, "rr1": rr1, "risk_reward_ok": risk_reward_ok, "breakout_distance_pct": breakout_distance, "change_24h": change_24h, "strategy_code": strategy_code, } if has_entry_score: entry_plan["entry_quality_gate"]["entry_score"] = entry_score entry_plan["entry_quality_gate"]["min_entry_score_buy_now"] = _cfg_value(cfg, "min_entry_score_buy_now") entry_plan["entry_quality_gate"]["min_entry_score_wait_pullback"] = _cfg_value(cfg, "min_entry_score_wait_pullback") return target_action, entry_plan, reasons