refactor: 将共用代码迁入 lib/ 模块化目录
统一 strategy、key_monitor、trade、hub 等共用库到 lib/ 子包,并补充 lib-structure 文档,便于四所与中控维护。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -0,0 +1,145 @@
|
||||
"""假突破关键位监控:BTC/ETH 限价挂单(共享计算与校验)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
FALSE_BREAKOUT_MONITOR_TYPE = "假突破"
|
||||
FALSE_BREAKOUT_SYMBOLS = frozenset({"BTC/USDT", "ETH/USDT"})
|
||||
FALSE_BREAKOUT_OFFSET_PCT = 0.1
|
||||
FALSE_BREAKOUT_SL_PCT = 0.5
|
||||
FALSE_BREAKOUT_RR = 1.5
|
||||
FALSE_BREAKOUT_VALIDITY_HOURS = 24
|
||||
|
||||
|
||||
def is_false_breakout_key_monitor_type(monitor_type: Optional[str]) -> bool:
|
||||
return (monitor_type or "").strip() == FALSE_BREAKOUT_MONITOR_TYPE
|
||||
|
||||
|
||||
def is_limit_key_monitor_type(monitor_type: Optional[str]) -> bool:
|
||||
from lib.key_monitor.fib_key_monitor_lib import is_fib_key_monitor_type
|
||||
|
||||
return is_fib_key_monitor_type(monitor_type) or is_false_breakout_key_monitor_type(monitor_type)
|
||||
|
||||
|
||||
def normalize_false_breakout_symbol(symbol: Optional[str]) -> Optional[str]:
|
||||
s = (symbol or "").strip().upper()
|
||||
if not s:
|
||||
return None
|
||||
if "/" not in s:
|
||||
s = f"{s}/USDT"
|
||||
return s if s in FALSE_BREAKOUT_SYMBOLS else None
|
||||
|
||||
|
||||
def storage_bounds_from_key_price(direction: str, key_price: float) -> tuple[float, float]:
|
||||
k = float(key_price)
|
||||
if k <= 0:
|
||||
raise ValueError("关键价位须为正数")
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
return k, k * 0.9999
|
||||
if d == "long":
|
||||
return k * 1.0001, k
|
||||
raise ValueError("方向须为 long 或 short")
|
||||
|
||||
|
||||
def key_price_from_row(direction: str, upper: Any, lower: Any) -> Optional[float]:
|
||||
d = (direction or "long").strip().lower()
|
||||
try:
|
||||
if d == "short":
|
||||
v = float(upper)
|
||||
else:
|
||||
v = float(lower)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return v if v > 0 else None
|
||||
|
||||
|
||||
def calc_false_breakout_plan(direction: str, key_price: float) -> Optional[tuple[float, float, float]]:
|
||||
try:
|
||||
k = float(key_price)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if k <= 0:
|
||||
return None
|
||||
d = (direction or "long").strip().lower()
|
||||
off = FALSE_BREAKOUT_OFFSET_PCT / 100.0
|
||||
sl_pct = FALSE_BREAKOUT_SL_PCT / 100.0
|
||||
rr = float(FALSE_BREAKOUT_RR)
|
||||
if d == "short":
|
||||
entry = k * (1 + off)
|
||||
sl = entry * (1 + sl_pct)
|
||||
risk = sl - entry
|
||||
if risk <= 0:
|
||||
return None
|
||||
tp = entry - risk * rr
|
||||
return entry, sl, tp
|
||||
if d == "long":
|
||||
entry = k * (1 - off)
|
||||
sl = entry * (1 - sl_pct)
|
||||
risk = entry - sl
|
||||
if risk <= 0:
|
||||
return None
|
||||
tp = entry + risk * rr
|
||||
return entry, sl, tp
|
||||
return None
|
||||
|
||||
|
||||
def _parse_created_at(raw: Any) -> Optional[datetime]:
|
||||
s = str(raw or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(s[:26], fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")[:32])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_false_breakout_expired(
|
||||
created_at: Any,
|
||||
now: datetime,
|
||||
*,
|
||||
hours: int = FALSE_BREAKOUT_VALIDITY_HOURS,
|
||||
) -> bool:
|
||||
dt = _parse_created_at(created_at)
|
||||
if dt is None:
|
||||
return False
|
||||
return now >= dt + timedelta(hours=hours)
|
||||
|
||||
|
||||
def expires_at_text(created_at: Any, *, hours: int = FALSE_BREAKOUT_VALIDITY_HOURS) -> str:
|
||||
dt = _parse_created_at(created_at)
|
||||
if dt is None:
|
||||
return "—"
|
||||
return (dt + timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def false_breakout_gate_preview(
|
||||
*,
|
||||
entry_display: str,
|
||||
limit_order_id: Any = None,
|
||||
created_at: Any = None,
|
||||
now: Optional[datetime] = None,
|
||||
hours: int = FALSE_BREAKOUT_VALIDITY_HOURS,
|
||||
) -> dict[str, Any]:
|
||||
"""假突破门控预览:限价挂单状态,不使用箱体/收敛的量破幅二确门控。"""
|
||||
now_dt = now or datetime.now()
|
||||
expired = is_false_breakout_expired(created_at, now_dt, hours=hours)
|
||||
exp_txt = expires_at_text(created_at, hours=hours)
|
||||
status = "已过期" if expired else "等待成交"
|
||||
metrics_parts: list[str] = []
|
||||
oid = str(limit_order_id or "").strip()
|
||||
if oid:
|
||||
metrics_parts.append(f"限价单:{oid}")
|
||||
if exp_txt != "—":
|
||||
metrics_parts.append(f"截至:{exp_txt}")
|
||||
return {
|
||||
"summary": f"假突破 挂E={entry_display} {status}",
|
||||
"metrics": " ".join(metrics_parts),
|
||||
"gate_ok": not expired,
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"""斐波关键位监控:纯计算与类型判断(Gate / Binance 主站共用)。"""
|
||||
|
||||
from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES
|
||||
|
||||
FIB_KEY_MONITOR_TYPES = frozenset({"斐波回调0.618", "斐波回调0.786"})
|
||||
KEY_MONITOR_TRADE_TYPE = "关键位监控"
|
||||
|
||||
FIB_RATIO_BY_TYPE = {
|
||||
"斐波回调0.618": 0.618,
|
||||
"斐波回调0.786": 0.786,
|
||||
}
|
||||
|
||||
|
||||
def is_fib_key_monitor_type(monitor_type):
|
||||
return (monitor_type or "").strip() in FIB_KEY_MONITOR_TYPES
|
||||
|
||||
|
||||
def fib_ratio_from_type(monitor_type):
|
||||
return FIB_RATIO_BY_TYPE.get((monitor_type or "").strip())
|
||||
|
||||
|
||||
def calc_fib_plan(direction, upper, lower, ratio):
|
||||
"""
|
||||
上沿 H、下沿 L(H > L)。
|
||||
做多:自 H 向下回撤 ratio,E = H - ratio*(H-L);SL=L,TP=H。
|
||||
做空:自 L 向上反弹 ratio,E = L + ratio*(H-L);SL=H,TP=L。
|
||||
返回 (entry, stop_loss, take_profit) 或 None。
|
||||
"""
|
||||
try:
|
||||
h = float(upper)
|
||||
l = float(lower)
|
||||
r = float(ratio)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if h <= l or r <= 0 or r >= 1:
|
||||
return None
|
||||
span = h - l
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "short":
|
||||
entry = l + r * span
|
||||
return entry, h, l
|
||||
entry = h - r * span
|
||||
return entry, l, h
|
||||
|
||||
|
||||
def stored_key_signal_type(monitor_type):
|
||||
"""写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波/假突破/触价开仓)。"""
|
||||
mt = (monitor_type or "").strip()
|
||||
if mt in FIB_KEY_MONITOR_TYPES:
|
||||
return mt
|
||||
if mt in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"):
|
||||
return mt if mt != "触价开仓" else "回调触价开仓"
|
||||
if mt in KEY_MONITOR_AUTO_TYPES:
|
||||
return mt
|
||||
return None
|
||||
|
||||
|
||||
KEY_ENTRY_REASON_BY_SIGNAL = {
|
||||
"箱体突破": "关键位箱体突破",
|
||||
"收敛突破": "关键位收敛突破",
|
||||
"斐波回调0.618": "关键位斐波0.618",
|
||||
"斐波回调0.786": "关键位斐波0.786",
|
||||
"假突破": "关键位假突破",
|
||||
"回调触价开仓": "关键位回调触价开仓",
|
||||
"突破触价开仓": "关键位突破触价开仓",
|
||||
"触价开仓": "关键位触价开仓",
|
||||
"趋势回调": "趋势回调",
|
||||
}
|
||||
|
||||
|
||||
def entry_reason_from_key_signal(key_signal_type):
|
||||
return KEY_ENTRY_REASON_BY_SIGNAL.get((key_signal_type or "").strip())
|
||||
|
||||
|
||||
def key_signal_type_for_trade_record(key_signal_type, box_auto_types):
|
||||
"""平仓写入 trade_records 时保留箱体/收敛/斐波/假突破来源。"""
|
||||
kst = (key_signal_type or "").strip()
|
||||
if kst in FIB_KEY_MONITOR_TYPES:
|
||||
return kst
|
||||
if kst in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"):
|
||||
return kst if kst != "触价开仓" else "回调触价开仓"
|
||||
if box_auto_types and kst in box_auto_types:
|
||||
return kst
|
||||
return None
|
||||
|
||||
|
||||
def backfill_missing_key_signal_types(conn, *, monitor_type: str = KEY_MONITOR_TRADE_TYPE) -> int:
|
||||
"""补全历史 trade_records / order_monitors 中缺失的箱体/收敛 key_signal_type。"""
|
||||
mt = (monitor_type or KEY_MONITOR_TRADE_TYPE).strip()
|
||||
updated = 0
|
||||
for signal in KEY_MONITOR_AUTO_TYPES:
|
||||
entry_reason = KEY_ENTRY_REASON_BY_SIGNAL.get(signal)
|
||||
if entry_reason:
|
||||
cur = conn.execute(
|
||||
"""UPDATE trade_records SET key_signal_type=?
|
||||
WHERE monitor_type=? AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')
|
||||
AND TRIM(COALESCE(entry_reason, ''))=?""",
|
||||
(signal, mt, entry_reason),
|
||||
)
|
||||
updated += int(cur.rowcount or 0)
|
||||
rows = conn.execute(
|
||||
"""SELECT id, symbol, opened_at FROM trade_records
|
||||
WHERE monitor_type=? AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')""",
|
||||
(mt,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
# init_db 连接未设 row_factory,结果为 tuple
|
||||
rid, sym, opened_at = row[0], row[1], row[2]
|
||||
opened = (opened_at or "").strip()
|
||||
for signal in KEY_MONITOR_AUTO_TYPES:
|
||||
hist = conn.execute(
|
||||
"""SELECT monitor_type FROM key_monitor_history
|
||||
WHERE symbol=? AND monitor_type=? AND close_reason='auto_opened'
|
||||
AND (?='' OR closed_at <= ?)
|
||||
ORDER BY closed_at DESC LIMIT 1""",
|
||||
(sym, signal, opened, opened),
|
||||
).fetchone()
|
||||
if not hist:
|
||||
continue
|
||||
conn.execute(
|
||||
"UPDATE trade_records SET key_signal_type=? WHERE id=?",
|
||||
(signal, rid),
|
||||
)
|
||||
updated += 1
|
||||
break
|
||||
return updated
|
||||
|
||||
|
||||
def fib_invalidate_by_mark(direction, mark_price, upper, lower):
|
||||
"""先触达止盈侧(标记价)则失效。多:mark>=H;空:mark<=L。"""
|
||||
try:
|
||||
m = float(mark_price)
|
||||
h = float(upper)
|
||||
l = float(lower)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "short":
|
||||
return m <= l
|
||||
return m >= h
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
全仓杠杆模式下:撤销已添加的箱体/收敛/斐波关键位监控并微信说明。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Iterable, Optional
|
||||
|
||||
from lib.key_monitor.fib_key_monitor_lib import FIB_KEY_MONITOR_TYPES, is_fib_key_monitor_type
|
||||
from lib.key_monitor.false_breakout_key_monitor_lib import is_false_breakout_key_monitor_type
|
||||
from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES
|
||||
from lib.trade.position_sizing_lib import is_full_margin_mode, mode_label_zh
|
||||
|
||||
|
||||
def monitor_type_disallowed_in_full_margin(monitor_type: str) -> bool:
|
||||
mt = (monitor_type or "").strip()
|
||||
if mt in KEY_MONITOR_AUTO_TYPES:
|
||||
return True
|
||||
if is_fib_key_monitor_type(mt):
|
||||
return True
|
||||
return is_false_breakout_key_monitor_type(mt)
|
||||
|
||||
|
||||
def purge_disallowed_key_monitors(
|
||||
conn: Any,
|
||||
*,
|
||||
sizing_mode: str,
|
||||
select_rows: Callable[[Any], Iterable[Any]],
|
||||
cancel_fib_limit: Callable[[Any], None],
|
||||
delete_monitor: Callable[[Any, int], None],
|
||||
send_wechat: Callable[[str], None],
|
||||
row_symbol: Callable[[Any], str] = lambda r: str(r["symbol"] or ""),
|
||||
row_monitor_type: Callable[[Any], str] = lambda r: str(r["monitor_type"] or ""),
|
||||
row_id: Callable[[Any], int] = lambda r: int(r["id"]),
|
||||
) -> int:
|
||||
if not is_full_margin_mode(sizing_mode):
|
||||
return 0
|
||||
removed = []
|
||||
for row in select_rows(conn):
|
||||
mt = row_monitor_type(row)
|
||||
if not monitor_type_disallowed_in_full_margin(mt):
|
||||
continue
|
||||
sym = row_symbol(row)
|
||||
kid = row_id(row)
|
||||
if is_fib_key_monitor_type(mt) or is_false_breakout_key_monitor_type(mt):
|
||||
try:
|
||||
cancel_fib_limit(row)
|
||||
except Exception:
|
||||
pass
|
||||
delete_monitor(conn, kid)
|
||||
removed.append((sym, mt, kid))
|
||||
if removed:
|
||||
lines = [f"· {s} {t} (#{i})" for s, t, i in removed[:12]]
|
||||
if len(removed) > 12:
|
||||
lines.append(f"… 共 {len(removed)} 条")
|
||||
send_wechat(
|
||||
"# ⚠️ 全仓杠杆模式:已自动撤销关键位监控\n"
|
||||
f"计仓模式:{mode_label_zh(sizing_mode)}(仅 env 可切换,须无仓)\n"
|
||||
"已撤销:箱体突破 / 收敛突破 / 斐波回调 / 假突破监控(不可与全仓杠杆并存)\n"
|
||||
+ "\n".join(lines)
|
||||
)
|
||||
return len(removed)
|
||||
@@ -0,0 +1,390 @@
|
||||
"""
|
||||
关键位监控:阻力/支撑双向提醒与箱体/收敛自动门控的共享逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
|
||||
KEY_MONITOR_RS_TYPE = "关键支撑阻力"
|
||||
KEY_MONITOR_RS_LEGACY_TYPES = frozenset({"关键阻力位", "关键支撑位"})
|
||||
KEY_MONITOR_RS_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES
|
||||
KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES
|
||||
KEY_DIRECTION_WATCH = "watch"
|
||||
|
||||
|
||||
def is_rs_key_monitor_type(monitor_type: str) -> bool:
|
||||
return (monitor_type or "").strip() in KEY_MONITOR_RS_TYPES
|
||||
|
||||
|
||||
def rs_monitor_type_label(monitor_type: str) -> str:
|
||||
"""展示用:旧库里的阻力/支撑合并为「关键支撑阻力」。"""
|
||||
if is_rs_key_monitor_type(monitor_type):
|
||||
return KEY_MONITOR_RS_TYPE
|
||||
return (monitor_type or "").strip()
|
||||
|
||||
|
||||
def rs_monitor_type_for_storage(monitor_type: str) -> str:
|
||||
if is_rs_key_monitor_type(monitor_type):
|
||||
return KEY_MONITOR_RS_TYPE
|
||||
return (monitor_type or "").strip()
|
||||
|
||||
|
||||
def calc_breakout_breach_pct(direction: str, close: float, upper: float, lower: float) -> float:
|
||||
"""突破 K 收盘相对关键位的越过幅度(%)。未越过对应边界时返回 0。"""
|
||||
direction = (direction or "long").strip().lower()
|
||||
c = float(close)
|
||||
if direction == "long":
|
||||
u = float(upper)
|
||||
if u <= 0 or c <= u:
|
||||
return 0.0
|
||||
return (c - u) / u * 100.0
|
||||
lo = float(lower)
|
||||
if lo <= 0 or c >= lo:
|
||||
return 0.0
|
||||
return (lo - c) / lo * 100.0
|
||||
|
||||
|
||||
def auto_amp_ok(
|
||||
direction: str,
|
||||
close_b: float,
|
||||
upper: float,
|
||||
lower: float,
|
||||
min_pct: float,
|
||||
) -> tuple[bool, float]:
|
||||
breach = calc_breakout_breach_pct(direction, close_b, upper, lower)
|
||||
return breach > float(min_pct), breach
|
||||
|
||||
|
||||
def auto_confirm_ok(direction: str, cfm_close: float, upper: float, lower: float) -> bool:
|
||||
"""确认 K 收盘须在箱体外(不得回到 [lower, upper] 内)。"""
|
||||
direction = (direction or "long").strip().lower()
|
||||
c = float(cfm_close)
|
||||
if direction == "long":
|
||||
return c > float(upper)
|
||||
return c < float(lower)
|
||||
|
||||
|
||||
BOX_BREAKOUT_CLOSE_OPPOSITE = "box_opposite_break"
|
||||
|
||||
|
||||
def box_breakout_invalidate_by_mark(
|
||||
direction: str, mark_price: float, upper: float, lower: float
|
||||
) -> bool:
|
||||
"""箱体/收敛:标记价先突破反向边界则失效。多:mark<=L;空:mark>=H。"""
|
||||
try:
|
||||
m = float(mark_price)
|
||||
h = float(upper)
|
||||
lo = float(lower)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "short":
|
||||
return m >= h
|
||||
return m <= lo
|
||||
|
||||
|
||||
def box_breakout_invalidate_edge_label(direction: str) -> str:
|
||||
direction = (direction or "long").strip().lower()
|
||||
return "下沿" if direction == "long" else "上沿"
|
||||
|
||||
|
||||
def detect_rs_box_break(close: float, upper: float, lower: float) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
阻力/支撑人工盯盘:最近 5m 收盘突破上沿或下沿(严格 > / <)。
|
||||
上沿优先:同一根 K 不可能同时满足两者。
|
||||
"""
|
||||
u, lo, c = float(upper), float(lower), float(close)
|
||||
if c > u:
|
||||
return {
|
||||
"break_side": "upper",
|
||||
"direction": "long",
|
||||
"edge_price": u,
|
||||
"key_price": u,
|
||||
"break_label": "向上突破上沿",
|
||||
}
|
||||
if c < lo:
|
||||
return {
|
||||
"break_side": "lower",
|
||||
"direction": "short",
|
||||
"edge_price": lo,
|
||||
"key_price": lo,
|
||||
"break_label": "向下突破下沿",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def rs_break_from_direction(direction: str, upper: float, lower: float) -> Optional[dict[str, Any]]:
|
||||
"""已触发后根据入库方向还原突破边(long=上沿,short=下沿)。"""
|
||||
d = (direction or "").strip().lower()
|
||||
if d == "long":
|
||||
return {
|
||||
"break_side": "upper",
|
||||
"direction": "long",
|
||||
"edge_price": float(upper),
|
||||
"key_price": float(upper),
|
||||
"break_label": "向上突破上沿",
|
||||
}
|
||||
if d == "short":
|
||||
return {
|
||||
"break_side": "lower",
|
||||
"direction": "short",
|
||||
"edge_price": float(lower),
|
||||
"key_price": float(lower),
|
||||
"break_label": "向下突破下沿",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def rs_break_infer_from_close(close: float, upper: float, lower: float) -> dict[str, Any]:
|
||||
"""
|
||||
续发提醒时价格已回到箱体内:按收盘价相对箱体中线推断首次突破边,
|
||||
保证第 2/3 次企业微信提醒仍能发出。
|
||||
"""
|
||||
mid = (float(upper) + float(lower)) / 2.0
|
||||
if float(close) >= mid:
|
||||
br = rs_break_from_direction("long", upper, lower)
|
||||
else:
|
||||
br = rs_break_from_direction("short", upper, lower)
|
||||
if br:
|
||||
return br
|
||||
return {
|
||||
"break_side": "upper",
|
||||
"direction": "long",
|
||||
"edge_price": float(upper),
|
||||
"key_price": float(upper),
|
||||
"break_label": "向上突破上沿",
|
||||
}
|
||||
|
||||
|
||||
def _parse_notify_datetime(raw: Optional[str]) -> Optional[datetime]:
|
||||
s = str(raw or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.replace(tzinfo=None)
|
||||
return dt
|
||||
except Exception:
|
||||
pass
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(s[:19], fmt)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def claim_rs_level_notify(
|
||||
conn: Any,
|
||||
monitor_id: int,
|
||||
notify_index: int,
|
||||
direction: str,
|
||||
notified_at: str,
|
||||
bar_ts: Optional[int],
|
||||
*,
|
||||
prior_count: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
原子占位:仅在 notification_count 仍为 prior_count 时推进到 notify_index。
|
||||
须在发送企业微信之前调用并 commit,避免 (2/3) 重复刷屏。
|
||||
"""
|
||||
prior = int(prior_count if prior_count is not None else notify_index - 1)
|
||||
if prior < 0 or notify_index != prior + 1:
|
||||
return False
|
||||
bar_val: Optional[int] = None
|
||||
if bar_ts is not None:
|
||||
try:
|
||||
bar_val = int(bar_ts)
|
||||
except (TypeError, ValueError):
|
||||
bar_val = None
|
||||
cur = conn.execute(
|
||||
"UPDATE key_monitors SET notification_count=?, direction=?, last_notified_at=?, last_rs_bar_ts=? "
|
||||
"WHERE id=? AND COALESCE(notification_count,0)=?",
|
||||
(notify_index, direction, notified_at, bar_val, int(monitor_id), prior),
|
||||
)
|
||||
return int(cur.rowcount or 0) > 0
|
||||
|
||||
|
||||
def parse_last_rs_bar_ts(row: Any) -> Optional[int]:
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
keys = row.keys() if hasattr(row, "keys") else []
|
||||
except Exception:
|
||||
keys = []
|
||||
raw = row["last_rs_bar_ts"] if "last_rs_bar_ts" in keys else None
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def run_rs_level_alert_tick(
|
||||
row: Any,
|
||||
close: float,
|
||||
bar_ts: Optional[int],
|
||||
now_dt: datetime,
|
||||
*,
|
||||
default_max_notify: int,
|
||||
default_interval_min: int,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
判定本轮回合是否应推送阻力/支撑提醒。
|
||||
首条:仅在新闭合 K 越线时触发;发送前须 claim_rs_level_notify 占位防轮询/多进程重复。
|
||||
"""
|
||||
up, lo = float(row["upper"]), float(row["lower"])
|
||||
if up <= lo:
|
||||
return None
|
||||
count = int(row["notification_count"] or 0)
|
||||
max_n = max(1, int(row["max_notify"] or default_max_notify))
|
||||
interval = max(1, int(row["notify_interval_min"] or default_interval_min))
|
||||
if count >= max_n:
|
||||
return None
|
||||
|
||||
bar_ts_i: Optional[int] = None
|
||||
if bar_ts is not None:
|
||||
try:
|
||||
bar_ts_i = int(bar_ts)
|
||||
except (TypeError, ValueError):
|
||||
bar_ts_i = None
|
||||
last_bar_i = parse_last_rs_bar_ts(row)
|
||||
|
||||
if count == 0:
|
||||
br = detect_rs_box_break(close, up, lo)
|
||||
if not br:
|
||||
return None
|
||||
if bar_ts_i is not None and last_bar_i is not None and bar_ts_i == last_bar_i:
|
||||
return None
|
||||
return {
|
||||
"break_info": br,
|
||||
"notify_index": 1,
|
||||
"prior_count": 0,
|
||||
"notify_max": max_n,
|
||||
"interval_min": interval,
|
||||
"bar_ts": bar_ts_i,
|
||||
}
|
||||
|
||||
if not notify_interval_elapsed(row["last_notified_at"], interval, now_dt):
|
||||
return None
|
||||
br = resolve_rs_break_for_alert(count, row["direction"], close, up, lo)
|
||||
if not br:
|
||||
return None
|
||||
return {
|
||||
"break_info": br,
|
||||
"notify_index": count + 1,
|
||||
"prior_count": count,
|
||||
"notify_max": max_n,
|
||||
"interval_min": interval,
|
||||
"bar_ts": bar_ts_i,
|
||||
}
|
||||
|
||||
|
||||
def resolve_rs_break_for_alert(
|
||||
notification_count: int,
|
||||
direction: Optional[str],
|
||||
close: float,
|
||||
upper: float,
|
||||
lower: float,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
阻力/支撑提醒:首次用 5m 收盘越线判定;后续用已存方向,兼容 direction=watch。
|
||||
"""
|
||||
count = int(notification_count or 0)
|
||||
up, lo, c = float(upper), float(lower), float(close)
|
||||
if count <= 0:
|
||||
return detect_rs_box_break(c, up, lo)
|
||||
br = rs_break_from_direction(direction, up, lo)
|
||||
if br:
|
||||
return br
|
||||
d = (direction or "").strip().lower()
|
||||
if d not in ("", KEY_DIRECTION_WATCH):
|
||||
return None
|
||||
br = detect_rs_box_break(c, up, lo)
|
||||
if br:
|
||||
return br
|
||||
return rs_break_infer_from_close(c, up, lo)
|
||||
|
||||
|
||||
def notify_interval_elapsed(
|
||||
last_notified_at: Optional[str],
|
||||
interval_min: int,
|
||||
now_dt: datetime,
|
||||
) -> bool:
|
||||
if not last_notified_at:
|
||||
return False
|
||||
last_dt = _parse_notify_datetime(last_notified_at)
|
||||
if last_dt is None:
|
||||
return False
|
||||
return (now_dt - last_dt).total_seconds() >= max(1, int(interval_min)) * 60
|
||||
|
||||
|
||||
def format_auto_amp_line(amp_ok: bool, amp_pct: float, min_pct: float) -> str:
|
||||
return (
|
||||
f"突破越过幅度:{'通过' if amp_ok else '不通过'}"
|
||||
f"({round(float(amp_pct), 4)}%,要求 > {min_pct}%)"
|
||||
)
|
||||
|
||||
|
||||
def format_auto_confirm_line(confirm_ok: bool, cfm_close, edge_price, direction: str) -> str:
|
||||
side = "箱外上方" if (direction or "").lower() == "long" else "箱外下方"
|
||||
return (
|
||||
f"第二根确认:{'通过' if confirm_ok else '不通过'}"
|
||||
f"(确认收盘 {cfm_close},须收于{side},关键位 {edge_price})"
|
||||
)
|
||||
|
||||
|
||||
def key_monitor_rule_template_context(
|
||||
*,
|
||||
kline_timeframe: str,
|
||||
key_breakout_amp_min_pct: float,
|
||||
key_volume_ma_bars: int,
|
||||
key_volume_ratio_min: float,
|
||||
key_auto_min_planned_rr: float,
|
||||
key_daily_volume_rank_max: int,
|
||||
key_confirm_breakout_bar: int,
|
||||
key_confirm_bar: int,
|
||||
key_alert_max_times: int,
|
||||
key_alert_interval_minutes: int,
|
||||
key_stop_outside_breakout_pct: float,
|
||||
key_trend_stop_outside_pct: float,
|
||||
false_breakout_validity_hours: int,
|
||||
trigger_entry_validity_hours: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""关键位监控页规则说明表格(Jinja key_rule_ctx)。"""
|
||||
from lib.key_monitor.false_breakout_key_monitor_lib import (
|
||||
FALSE_BREAKOUT_OFFSET_PCT,
|
||||
FALSE_BREAKOUT_RR,
|
||||
FALSE_BREAKOUT_SL_PCT,
|
||||
)
|
||||
from lib.key_monitor.trigger_entry_key_monitor_lib import TRIGGER_ENTRY_VALIDITY_HOURS
|
||||
|
||||
te_hours = (
|
||||
int(trigger_entry_validity_hours)
|
||||
if trigger_entry_validity_hours is not None
|
||||
else TRIGGER_ENTRY_VALIDITY_HOURS
|
||||
)
|
||||
|
||||
return {
|
||||
"tf": (kline_timeframe or "5m").strip(),
|
||||
"amp_min_pct": key_breakout_amp_min_pct,
|
||||
"vol_ma_bars": key_volume_ma_bars,
|
||||
"vol_ratio_min": key_volume_ratio_min,
|
||||
"min_rr": key_auto_min_planned_rr,
|
||||
"vol_rank_max": key_daily_volume_rank_max,
|
||||
"breakout_bar": key_confirm_breakout_bar,
|
||||
"confirm_bar": key_confirm_bar,
|
||||
"alert_max": key_alert_max_times,
|
||||
"alert_interval_min": key_alert_interval_minutes,
|
||||
"stop_outside_pct": key_stop_outside_breakout_pct,
|
||||
"trend_stop_outside_pct": key_trend_stop_outside_pct,
|
||||
"fb_offset_pct": FALSE_BREAKOUT_OFFSET_PCT,
|
||||
"fb_sl_pct": FALSE_BREAKOUT_SL_PCT,
|
||||
"fb_rr": FALSE_BREAKOUT_RR,
|
||||
"fb_valid_hours": false_breakout_validity_hours,
|
||||
"trigger_entry_validity_hours": te_hours,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"""关键位监控表结构迁移(四所共用)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def ensure_key_monitor_schema(conn: Any) -> None:
|
||||
for sql in (
|
||||
"ALTER TABLE key_monitors ADD COLUMN last_mark_price REAL",
|
||||
):
|
||||
try:
|
||||
conn.execute(sql)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,139 @@
|
||||
"""关键位箱体/收敛:止盈止损方案(Binance / Gate / OKX 共用)。"""
|
||||
|
||||
KEY_SL_TP_MODES = frozenset({"standard", "box_1p5", "trend_manual"})
|
||||
|
||||
KEY_SL_TP_MODE_LABELS = {
|
||||
"standard": "标准突破",
|
||||
"box_1p5": "箱体1R·止盈1.5H",
|
||||
"trend_manual": "趋势单·自填止盈",
|
||||
}
|
||||
|
||||
KEY_MONITOR_AUTO_TYPES_FOR_FORM = frozenset({"箱体突破", "收敛突破"})
|
||||
|
||||
|
||||
def normalize_sl_tp_mode(raw):
|
||||
m = (raw or "standard").strip().lower()
|
||||
if m in ("box_1p5", "box15", "box-1.5", "box_1.5"):
|
||||
return "box_1p5"
|
||||
if m in ("trend_manual", "trend", "manual"):
|
||||
return "trend_manual"
|
||||
if m in KEY_SL_TP_MODES:
|
||||
return m
|
||||
return "standard"
|
||||
|
||||
|
||||
def sl_tp_mode_label(mode):
|
||||
return KEY_SL_TP_MODE_LABELS.get(normalize_sl_tp_mode(mode), normalize_sl_tp_mode(mode))
|
||||
|
||||
|
||||
def sl_tp_mode_from_row(row, default="standard"):
|
||||
try:
|
||||
if hasattr(row, "keys") and "sl_tp_mode" in row.keys():
|
||||
raw = row["sl_tp_mode"]
|
||||
else:
|
||||
raw = row.get("sl_tp_mode") if isinstance(row, dict) else None
|
||||
except Exception:
|
||||
raw = None
|
||||
return normalize_sl_tp_mode(raw if raw not in (None, "") else default)
|
||||
|
||||
|
||||
def breakeven_enabled_from_row(row, default=0):
|
||||
try:
|
||||
if hasattr(row, "keys") and "breakeven_enabled" in row.keys():
|
||||
v = row["breakeven_enabled"]
|
||||
else:
|
||||
v = row.get("breakeven_enabled") if isinstance(row, dict) else None
|
||||
except Exception:
|
||||
v = None
|
||||
if v is None:
|
||||
return int(default) != 0
|
||||
return int(v) != 0
|
||||
|
||||
|
||||
def parse_breakeven_enabled_form(form_value):
|
||||
return 1 if (form_value or "").strip().lower() in ("1", "true", "on", "yes") else 0
|
||||
|
||||
|
||||
def plan_key_sl_tp(
|
||||
mode,
|
||||
direction,
|
||||
upper,
|
||||
lower,
|
||||
checks,
|
||||
*,
|
||||
outside_pct,
|
||||
trend_outside_pct,
|
||||
manual_take_profit=None,
|
||||
):
|
||||
"""
|
||||
以确认 K 收盘 E 为「当前价」计算计划 SL/TP。
|
||||
返回 (E, sl_raw, tp_raw, box_h) 或 None(几何无效 / 模式3缺止盈)。
|
||||
"""
|
||||
try:
|
||||
E = float(checks["confirm_close"])
|
||||
H = abs(float(upper) - float(lower))
|
||||
except (TypeError, ValueError, KeyError):
|
||||
return None
|
||||
if H <= 0:
|
||||
return None
|
||||
direction = (direction or "long").strip().lower()
|
||||
mode = normalize_sl_tp_mode(mode)
|
||||
|
||||
if mode == "box_1p5":
|
||||
if direction == "long":
|
||||
sl_raw = E - H
|
||||
tp_raw = E + 1.5 * H
|
||||
else:
|
||||
sl_raw = E + H
|
||||
tp_raw = E - 1.5 * H
|
||||
return E, sl_raw, tp_raw, H
|
||||
|
||||
if mode == "trend_manual":
|
||||
try:
|
||||
br_hi = float(checks["breakout_high"])
|
||||
br_lo = float(checks["breakout_low"])
|
||||
tp_raw = float(manual_take_profit)
|
||||
except (TypeError, ValueError, KeyError):
|
||||
return None
|
||||
m = float(trend_outside_pct) / 100.0
|
||||
if direction == "long":
|
||||
sl_raw = br_lo * (1.0 - m) if br_lo > 0 else 0.0
|
||||
if tp_raw <= E or sl_raw <= 0:
|
||||
return None
|
||||
else:
|
||||
sl_raw = br_hi * (1.0 + m) if br_hi > 0 else 0.0
|
||||
if tp_raw >= E or sl_raw <= 0:
|
||||
return None
|
||||
return E, sl_raw, tp_raw, H
|
||||
|
||||
# standard:突破 K 极值外侧 + 止盈 E±1×H
|
||||
try:
|
||||
br_hi = float(checks["breakout_high"])
|
||||
br_lo = float(checks["breakout_low"])
|
||||
except (TypeError, ValueError, KeyError):
|
||||
return None
|
||||
om = float(outside_pct) / 100.0
|
||||
if direction == "long":
|
||||
sl_raw = br_lo * (1.0 - om) if br_lo > 0 else 0.0
|
||||
tp_raw = E + H
|
||||
else:
|
||||
sl_raw = br_hi * (1.0 + om) if br_hi > 0 else 0.0
|
||||
tp_raw = E - H
|
||||
return E, sl_raw, tp_raw, H
|
||||
|
||||
|
||||
def sl_tp_plan_summary_text(mode, direction, E, sl_raw, tp_raw, box_h, *, outside_pct, trend_outside_pct):
|
||||
"""微信/页面用一行计划 SL/TP 说明。"""
|
||||
mode = normalize_sl_tp_mode(mode)
|
||||
direction = (direction or "long").strip().lower()
|
||||
if mode == "box_1p5":
|
||||
return (
|
||||
f"方案:{sl_tp_mode_label(mode)}|E={E}|SL=E∓1×H({box_h})|TP=E∓1.5×H"
|
||||
)
|
||||
if mode == "trend_manual":
|
||||
return (
|
||||
f"方案:{sl_tp_mode_label(mode)}|E={E}|SL=突破K极值外{trend_outside_pct}%|TP={tp_raw}(录入)"
|
||||
)
|
||||
return (
|
||||
f"方案:{sl_tp_mode_label(mode)}|E={E}|SL=突破K外{outside_pct}%|TP=E±1×H({box_h})"
|
||||
)
|
||||
@@ -0,0 +1,296 @@
|
||||
"""回调/突破触价开仓关键位监控:程序盯价、触达计划入场后市价成交(四所共用逻辑)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.key_monitor.false_breakout_key_monitor_lib import (
|
||||
_parse_created_at,
|
||||
expires_at_text,
|
||||
is_false_breakout_expired,
|
||||
)
|
||||
from lib.strategy.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
|
||||
Reference in New Issue
Block a user