433 lines
21 KiB
Python
433 lines
21 KiB
Python
"""交易机会生命周期与买点质量闸门。
|
||
|
||
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
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
|
||
# 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 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:
|
||
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,
|
||
) -> Tuple[str, Dict[str, Any], list]:
|
||
"""返回修正后的 action_status、增强后的 entry_plan、拦截原因。"""
|
||
cfg = {**DEFAULT_ENTRY_GATE, **(cfg or {})}
|
||
if not cfg.get("enabled", True):
|
||
return action_status, entry_plan or {}, []
|
||
|
||
entry_plan = dict(entry_plan or {})
|
||
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")
|
||
stop_loss = to_float(entry_plan.get("stop_loss"))
|
||
tp1 = to_float(entry_plan.get("tp1") or entry_plan.get("take_profit_1"))
|
||
if current_price > 0 and stop_loss > 0 and tp1 > 0 and current_price > stop_loss:
|
||
live_rr1 = round((tp1 - current_price) / (current_price - stop_loss), 2)
|
||
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
|
||
entry_action = str(entry_plan.get("entry_action") or "").strip()
|
||
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"))
|
||
|
||
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 level_max_action in ("observe", "wait_pullback"):
|
||
reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别最高只允许{level_meta.get('label') and ('观察/等待') or '观察/等待'},禁止现价买入")
|
||
if not has_current_entry_trigger(signals, entry_plan):
|
||
reasons.append("缺少当前15min触发,禁止现价买入")
|
||
if has_bearish_flow_risk(signals):
|
||
reasons.append("出现空头加速/放量阴线风险,禁止现价买入")
|
||
if current_price > 0:
|
||
plan_entry_price = to_float(entry_plan.get("entry_price"))
|
||
# 价格已经回到/跌破计划参考价,且实时 RR 已达标时,应转为入场窗口,不能继续显示“等回踩”。
|
||
if plan_entry_price > 0 and current_price <= plan_entry_price * 1.003 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 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:
|
||
plan_entry_price = to_float(entry_plan.get("entry_price"))
|
||
if plan_entry_price > 0:
|
||
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 < -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 in ("observe", "wait_pullback") or has_bearish_flow_risk(signals):
|
||
target_action = "等回踩" if level_max_action == "wait_pullback" else "观察"
|
||
if level_max_action in ("observe", "wait_pullback"):
|
||
reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别最高只允许观察/等待,不能因到价直接升级为现价买入")
|
||
if has_bearish_flow_risk(signals):
|
||
reasons.append("出现空头加速/放量阴线风险,到价也不升级为现价买入")
|
||
else:
|
||
target_action = "可即刻买入"
|
||
entry_plan["entry_trigger_confirmed"] = True
|
||
entry_plan["entry_action"] = "可即刻买入"
|
||
reasons.append("回踩参考已到或更优,转为入场窗口")
|
||
|
||
if 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 in ("observe", "wait_pullback") or has_bearish_flow_risk(signals)
|
||
):
|
||
target_action = "可即刻买入"
|
||
elif action_status == "等回踩" and current_price > 0 and to_float(entry_plan.get("entry_price")) > 0 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 action_status == "可即刻买入" and current_price > 0 and stop_loss > 0 and tp1 > stop_loss and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")):
|
||
rr_target_entry = calc_rr_target_entry(stop_loss, tp1, _cfg_value(cfg, "min_rr_buy_now"))
|
||
if rr_target_entry > stop_loss and rr_target_entry < current_price:
|
||
target_action = "等回踩"
|
||
entry_plan["entry_price"] = rr_target_entry
|
||
entry_plan["entry_method"] = 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')}"
|
||
reasons.append(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,
|
||
}
|
||
return target_action, entry_plan, reasons
|