5b3448b52b
Co-authored-by: Cursor <cursoragent@cursor.com>
297 lines
9.7 KiB
Python
297 lines
9.7 KiB
Python
"""回调/突破触价开仓关键位监控:程序盯价、触达计划入场后市价成交(四所共用逻辑)。"""
|
||
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
|