a1abe159fa
Add deploy/manage.sh bootstrap for git.bz121.com/dekun/crypto_okx and point docs at this repo. Co-authored-by: Cursor <cursoragent@cursor.com>
463 lines
15 KiB
Python
463 lines
15 KiB
Python
"""
|
|
关键位监控:阻力/支撑双向提醒与箱体/收敛自动门控的共享逻辑.
|
|
"""
|
|
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 = 0,
|
|
trigger_entry_validity_hours: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""关键位监控页规则说明表格(Jinja key_rule_ctx);自动单已移除,保留字段兼容模板."""
|
|
del false_breakout_validity_hours, 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": key_alert_interval_minutes,
|
|
"stop_outside_pct": key_stop_outside_breakout_pct,
|
|
"trend_stop_outside_pct": key_trend_stop_outside_pct,
|
|
"false_breakout_hours": 0,
|
|
"false_breakout_offset_pct": 0,
|
|
"false_breakout_sl_pct": 0,
|
|
"false_breakout_rr": 0,
|
|
"trigger_entry_hours": 0,
|
|
}
|
|
|
|
|
|
# ---- 历史 key_signal / entry_reason 兼容(自动单已移除,仅读旧数据) ----
|
|
KEY_MONITOR_TRADE_TYPE = "关键位监控"
|
|
FIB_KEY_MONITOR_TYPES = frozenset({"斐波回调0.618", "斐波回调0.786"})
|
|
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=None):
|
|
kst = (key_signal_type or "").strip()
|
|
if not kst:
|
|
return None
|
|
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
|
|
if kst in KEY_MONITOR_AUTO_TYPES:
|
|
return kst
|
|
return kst or None
|
|
|
|
|
|
def stored_key_signal_type(monitor_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
|
|
|
|
|
|
def backfill_missing_key_signal_types(conn, *, monitor_type: str = KEY_MONITOR_TRADE_TYPE) -> int:
|
|
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 not entry_reason:
|
|
continue
|
|
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)
|
|
return updated
|
|
|
|
|
|
def is_fib_key_monitor_type(monitor_type):
|
|
return (monitor_type or "").strip() in FIB_KEY_MONITOR_TYPES
|
|
|
|
|
|
def is_false_breakout_key_monitor_type(monitor_type):
|
|
return (monitor_type or "").strip() == "假突破"
|
|
|
|
|
|
def is_trigger_entry_key_monitor_type(monitor_type):
|
|
return (monitor_type or "").strip() in (
|
|
"触价开仓",
|
|
"回调触价开仓",
|
|
"突破触价开仓",
|
|
)
|
|
|
|
|
|
def is_limit_key_monitor_type(monitor_type):
|
|
mt = (monitor_type or "").strip()
|
|
return is_fib_key_monitor_type(mt) or is_false_breakout_key_monitor_type(mt)
|