refactor: 将共用代码迁入 lib/ 模块化目录

统一 strategy、key_monitor、trade、hub 等共用库到 lib/ 子包,并补充 lib-structure 文档,便于四所与中控维护。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-02 16:23:09 +08:00
parent 4742a0bb9d
commit 5797d49d8a
190 changed files with 27946 additions and 27499 deletions
+1
View File
@@ -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,
}
+140
View File
@@ -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 向下回撤 ratioE = H - ratio*(H-L)SL=LTP=H。
做空:自 L 向上反弹 ratioE = L + ratio*(H-L)SL=HTP=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)
+390
View File
@@ -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,
}
+14
View File
@@ -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
+139
View File
@@ -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