"""回调/突破触价开仓关键位监控:程序盯价、触达计划入场后市价成交(四所共用逻辑)。""" from __future__ import annotations from datetime import datetime from typing import Any, Callable, Optional from false_breakout_key_monitor_lib import ( _parse_created_at, expires_at_text, is_false_breakout_expired, ) from strategy_trend_lib import trend_dca_level_reached # 回调触价(原「触价开仓」) CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE = "回调触价开仓" LEGACY_TRIGGER_ENTRY_MONITOR_TYPE = "触价开仓" # 突破触价:标记价穿越 E 后立即市价开仓 BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE = "突破触价开仓" TRIGGER_ENTRY_MONITOR_TYPES = frozenset( { CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE, LEGACY_TRIGGER_ENTRY_MONITOR_TYPE, } ) TRIGGER_ENTRY_VALIDITY_HOURS = 24 TRIGGER_ENTRY_CLOSE_FILLED = "trigger_entry_filled" TRIGGER_ENTRY_CLOSE_TP_INVALIDATE = "trigger_tp_invalidate" TRIGGER_ENTRY_CLOSE_SL_INVALIDATE = "trigger_sl_invalidate" TRIGGER_ENTRY_CLOSE_EXPIRED = "trigger_entry_expired" TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED = "trigger_exchange_failed" KEY_ENTRY_REASON_CALLBACK = "关键位回调触价开仓" KEY_ENTRY_REASON_BREAKOUT = "关键位突破触价开仓" KEY_ENTRY_REASON_TRIGGER_LEGACY = "关键位触价开仓" def normalize_trigger_entry_monitor_type(monitor_type: Optional[str]) -> str: mt = (monitor_type or "").strip() if mt == LEGACY_TRIGGER_ENTRY_MONITOR_TYPE: return CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE return mt def is_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool: return (monitor_type or "").strip() in TRIGGER_ENTRY_MONITOR_TYPES def is_callback_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool: mt = normalize_trigger_entry_monitor_type(monitor_type) return mt == CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE def is_breakout_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool: return (monitor_type or "").strip() == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE def key_entry_reason_for_monitor_type(monitor_type: Optional[str]) -> str: if is_breakout_trigger_entry_key_monitor_type(monitor_type): return KEY_ENTRY_REASON_BREAKOUT if is_trigger_entry_key_monitor_type(monitor_type): return KEY_ENTRY_REASON_CALLBACK return KEY_ENTRY_REASON_TRIGGER_LEGACY def trigger_entry_reached(direction: str, mark_price: float, entry: float) -> bool: """回调触价:多=价跌至 E;空=价涨至 E。""" return trend_dca_level_reached(direction, mark_price, entry) def breakout_trigger_entry_crossed( direction: str, prev_mark: Optional[float], mark: float, entry: float, ) -> bool: """突破触价:多=向上穿越 E;空=向下穿越 E。""" try: m = float(mark) e = float(entry) pm = float(prev_mark) if prev_mark is not None else None except (TypeError, ValueError): return False direction = (direction or "long").strip().lower() if direction == "long": if pm is None: return m > e return pm <= e and m > e if pm is None: return m < e return pm >= e and m < e def trigger_should_fire( monitor_type: Optional[str], direction: str, mark: float, entry: float, prev_mark: Optional[float] = None, ) -> bool: if is_breakout_trigger_entry_key_monitor_type(monitor_type): return breakout_trigger_entry_crossed(direction, prev_mark, mark, entry) return trigger_entry_reached(direction, mark, entry) def trigger_entry_invalidate_by_tp(direction: str, mark_price: float, take_profit: float) -> bool: """未开仓前标记价先触达止盈侧则失效。""" try: m = float(mark_price) tp = float(take_profit) except (TypeError, ValueError): return False d = (direction or "long").strip().lower() if d == "short": return m <= tp return m >= tp def trigger_entry_invalidate_by_sl(direction: str, mark_price: float, stop_loss: float) -> bool: """突破触价:未到 E 先触达止损侧则失效。""" try: m = float(mark_price) sl = float(stop_loss) except (TypeError, ValueError): return False d = (direction or "long").strip().lower() if d == "long": return m <= sl return m >= sl def trigger_entry_invalidate( monitor_type: Optional[str], direction: str, mark: float, stop_loss: float, take_profit: float, ) -> Optional[str]: if trigger_entry_invalidate_by_tp(direction, mark, take_profit): return "tp" if is_breakout_trigger_entry_key_monitor_type(monitor_type): if trigger_entry_invalidate_by_sl(direction, mark, stop_loss): return "sl" return None def validate_trigger_entry_geometry( direction: str, entry: float, stop_loss: float, take_profit: float, mark_at_add: Optional[float] = None, *, monitor_type: Optional[str] = None, ) -> Optional[str]: """返回错误文案;合法则 None。""" try: e = float(entry) sl = float(stop_loss) tp = float(take_profit) except (TypeError, ValueError): return "入场价、止损、止盈须为有效数字" if e <= 0 or sl <= 0 or tp <= 0: return "入场价、止损、止盈须大于 0" d = (direction or "long").strip().lower() mt = normalize_trigger_entry_monitor_type(monitor_type) label = "突破触价开仓" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调触价开仓" if d == "long": if not (sl < e < tp): return "做多:须满足 止损 < 入场价 < 止盈" if mark_at_add is not None: m = float(mark_at_add) if m >= tp: return f"做多:当前价已不低于止盈,无法添加{label}" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m >= e: return "做多:当前价须低于入场价(等待向上突破)" elif d == "short": if not (tp < e < sl): return "做空:须满足 止盈 < 入场价 < 止损" if mark_at_add is not None: m = float(mark_at_add) if m <= tp: return f"做空:当前价已不高于止盈,无法添加{label}" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m <= e: return "做空:当前价须高于入场价(等待向下跌破)" else: return "方向须为 long 或 short" return None def validate_trigger_entry_rr( direction: str, entry: float, stop_loss: float, take_profit: float, min_rr: float, calc_rr_ratio: Callable[..., Optional[float]], ) -> Optional[str]: rr = calc_rr_ratio(direction, entry, stop_loss, take_profit) if rr is None or rr <= float(min_rr): fmt = f"{rr:.4f}" if rr is not None else "无法计算" return f"计划盈亏比 {fmt}:1 未达要求(>{float(min_rr)}:1)" return None def is_trigger_entry_expired( created_at: Any, now: datetime, *, hours: int = TRIGGER_ENTRY_VALIDITY_HOURS, ) -> bool: return is_false_breakout_expired(created_at, now, hours=hours) def trigger_entry_expires_at_text( created_at: Any, *, hours: int = TRIGGER_ENTRY_VALIDITY_HOURS, ) -> str: return expires_at_text(created_at, hours=hours) def count_pending_trigger_entries(conn: Any, trading_day: str) -> int: td = (trading_day or "").strip() if not td: return 0 placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) row = conn.execute( f"SELECT COUNT(*) FROM key_monitors WHERE monitor_type IN ({placeholders}) AND session_date=?", (*TRIGGER_ENTRY_MONITOR_TYPES, td), ).fetchone() return int(row[0] if row else 0) def check_trigger_entry_intent_limit( conn: Any, trading_day: str, opens_today: int, hard_limit: int, ) -> tuple[bool, str]: """当日开仓意图:已成交次数 + 待触发触价条数。""" if int(hard_limit) <= 0: return True, "" pending = count_pending_trigger_entries(conn, trading_day) total = int(opens_today) + pending if total >= int(hard_limit): return ( False, f"本交易日开仓意图已达上限(已开 {int(opens_today)} + 待触发 {pending} / 硬上限 {int(hard_limit)})", ) return True, "" def trigger_entry_gate_preview( *, monitor_type: Optional[str] = None, entry_display: str, take_profit_display: str, created_at: Any = None, now: Optional[datetime] = None, expired: bool = False, tp_invalidated: bool = False, sl_invalidated: bool = False, hours: int = TRIGGER_ENTRY_VALIDITY_HOURS, ) -> dict[str, Any]: now_dt = now or datetime.now() is_exp = expired or is_trigger_entry_expired(created_at, now_dt, hours=hours) exp_txt = trigger_entry_expires_at_text(created_at, hours=hours) mt = normalize_trigger_entry_monitor_type(monitor_type) if tp_invalidated: status = "止盈侧失效" elif sl_invalidated: status = "止损侧失效" elif is_exp: status = "已过期" elif mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE: status = "突破待触发" else: status = "回调待触发" mode = "突破" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调" metrics_parts: list[str] = [f"TP:{take_profit_display}"] if exp_txt != "—": metrics_parts.append(f"截至:{exp_txt}") return { "summary": f"{mode}触价 E={entry_display} {status}", "metrics": " ".join(metrics_parts), "gate_ok": not is_exp and not tp_invalidated and not sl_invalidated, } # 兼容旧 import TRIGGER_ENTRY_MONITOR_TYPE = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE KEY_ENTRY_REASON_TRIGGER = KEY_ENTRY_REASON_CALLBACK