alphax/app/core/opportunity_lifecycle.py
2026-05-31 22:47:03 +08:00

532 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""交易机会生命周期与买点质量闸门。
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