diff --git a/README.md b/README.md index 0dcb696..49c70c8 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,12 @@ flowchart LR ex2 --> gate3[Gate 私有 API 账户 B] ``` -1. 扫描端发现 **TRIGGER** 且通过推送门控 → **企业微信** 告警。 -2. 企微成功后 → 向面板中 **已启用** 的执行器 `POST /v1/signal`(方案 A 止盈/止损,同一 `signal_id`)。 -3. 各执行器自行决定是否接单(最低盈亏比等),**规则不在扫描端区分**。 -4. 转发请求 **不走** 扫描端 `proxy`,直连各执行器 `base_url`。 +1. **GEMMA 漏斗** 供参考;在扫描端 **「关键位突破监控」** 录入上下沿(箱体/收敛 × 标准/趋势)。 +2. 5m 门控通过 → **企业微信**(单一 SL/TP)→ 可选向已启用执行器 `POST /v1/signal`(同一 `signal_id` 可广播多账户)。 +3. 全市场 5m TRIGGER 默认 **不** 转发执行器(`key_monitor.auto_scan_forward_executor: false`)。 +4. 转发请求 **不走** 扫描端 `proxy`,直连各执行器 `base_url`。 + +详见 [onchain_scout_gate/更新说明.md](onchain_scout_gate/更新说明.md)、[onchain_scout_gate/docs/关键位突破监控说明.md](onchain_scout_gate/docs/关键位突破监控说明.md)。 设计归档:[onchain_scout_gate/docs/多执行器与信号转发归档.md](onchain_scout_gate/docs/多执行器与信号转发归档.md) diff --git a/onchain_scout_gate/README.md b/onchain_scout_gate/README.md index f47c1e0..b012229 100644 --- a/onchain_scout_gate/README.md +++ b/onchain_scout_gate/README.md @@ -16,10 +16,17 @@ Python service for 7x24 monitoring of **Gate.io USDT-settled linear perpetual fu - **`monitor.btc_daily_gate_enabled`(默认关闭)**:可选的 **BTC 日线横盘过滤**——在判定为日线横盘 regime 下叠加 **K 线形态**等附加条件;实验性/非必选风控,**不作为对外产品主线说明**。实现见 `app/btc_regime.py`,可在 `config.yaml` 关闭。 +### 关键位突破监控(半自动 · 主线) + +- **GEMMA 漏斗**:仅参考;在面板 **「关键位突破监控」** 录入上/下沿与类型(箱体/收敛 × 标准/趋势)。 +- **5m 门控** 通过后:企业微信(单一 SL/TP)+ 可选转发 **gate_order_executor**(`key_monitor.forward_executor`)。 +- 默认 **不** 对全市场 5m TRIGGER 转发执行器(`key_monitor.auto_scan_forward_executor: false`)。 +- 说明:[`docs/关键位突破监控说明.md`](docs/关键位突破监控说明.md);变更:[`更新说明.md`](更新说明.md)。 + ### 自动下单(gate_order_executor) - **下单执行器**:在 Web 面板 **「下单执行器 · 转发链」** 维护列表(`runtime/order_executors.json`),支持运行中增删;首次启动可从 `config.yaml` 的 `order_executor` 导入一条。 -- 仅在 **企业微信突破推送成功之后**,向列表中已启用的执行器 **广播** 同一 `POST /v1/signal`;价位与企微 **方案 A** 一致。详见 [`docs/多执行器与信号转发归档.md`](docs/多执行器与信号转发归档.md)。 +- **关键位** 触发且计划 RR 达标后,向已启用执行器 **广播** `POST /v1/signal`(单一止盈/止损,与录入方案一致)。详见 [`docs/多执行器与信号转发归档.md`](docs/多执行器与信号转发归档.md)。 - 该 HTTP 请求 **不走** `proxy.url`,便于同机访问执行器。 全市场模式下扫描量较大,建议把 `poll_interval_seconds` 调到 **300 秒或更长**,并遵守 Gate 公开频率限制。 @@ -54,6 +61,9 @@ onchain_scout_gate/ gemma_client.py notifier.py order_executor_forward.py + key_monitor_service.py + key_gate.py + key_sl_tp.py storage.py models.py config.py diff --git a/onchain_scout_gate/app/config.py b/onchain_scout_gate/app/config.py index 37a2a0c..ae867cc 100644 --- a/onchain_scout_gate/app/config.py +++ b/onchain_scout_gate/app/config.py @@ -69,6 +69,28 @@ class WatchSymbol(BaseModel): symbol: str +class KeyMonitorConfig(BaseModel): + """ + 人工关键位突破监控(漏斗下方录入)。 + 触发后按录入的标准/趋势方案计算单一 SL/TP,企微推送并可选转发执行器。 + """ + + enabled: bool = True + poll_interval_seconds: int = Field(5, ge=2, le=120) + push_wecom: bool = True + forward_executor: bool = True + standard_stop_outside_pct: float = Field(0.3, ge=0.0, le=5.0) + trend_stop_outside_pct: float = Field(1.0, ge=0.0, le=10.0) + min_planned_rr: float = Field(1.5, gt=0.0) + volume_ma_bars: int = Field(20, ge=5) + volume_ratio_min: float = Field(1.3, gt=0.0) + breakout_amp_min_pct: float = Field(0.03, ge=0.0) + breakout_amp_max_pct: float = Field(0.5, gt=0.0) + daily_volume_rank_max: int = Field(30, ge=1) + # 全市场 5m TRIGGER 是否仍转发执行器(默认关;关键位走 forward_executor) + auto_scan_forward_executor: bool = False + + class MonitorConfig(BaseModel): """ 监控侧过滤。 @@ -131,6 +153,7 @@ class Settings(BaseModel): proxy: ProxyConfig = Field(default_factory=ProxyConfig) order_executor: OrderExecutorConfig = Field(default_factory=OrderExecutorConfig) monitor: MonitorConfig = Field(default_factory=MonitorConfig) + key_monitor: KeyMonitorConfig = Field(default_factory=KeyMonitorConfig) gemma: GemmaConfig = Field(default_factory=GemmaConfig) daily_report: DailyReportConfig = Field(default_factory=DailyReportConfig) watch_symbols: list[WatchSymbol] = Field(default_factory=list) diff --git a/onchain_scout_gate/app/key_gate.py b/onchain_scout_gate/app/key_gate.py new file mode 100644 index 0000000..11f7447 --- /dev/null +++ b/onchain_scout_gate/app/key_gate.py @@ -0,0 +1,99 @@ +"""关键位 5m 硬门控(逻辑自 crypto_monitor_gate _key_hard_checks,使用 Gate K 线)。""" + +from __future__ import annotations + +from statistics import mean + +from .candle_rows import rows_to_ohlcv + + +def key_hard_checks_from_rows( + rows: list[list[str]], + *, + direction: str, + upper: float, + lower: float, + volume_ma_bars: int = 20, + volume_ratio_min: float = 1.3, + breakout_amp_min_pct: float = 0.03, + breakout_amp_max_pct: float = 0.5, + volume_rank: int | None = None, + volume_rank_total: int = 0, + volume_rank_max: int = 30, +) -> dict: + """rows:Gate K 线行,最后一根应为最近一根已闭合 5m(调用方负责剔除未闭合)。""" + out: dict = {"ok": False} + _, ah, al, ac, av = rows_to_ohlcv(rows) + vol_lb = max(5, int(volume_ma_bars)) + min_need = vol_lb + 3 + if len(ac) < min_need: + out["reason"] = f"insufficient_candles have={len(ac)} need>={min_need}" + return out + + breakout_close = float(ac[-2]) + confirm_close = float(ac[-1]) + breakout_high = float(ah[-2]) + breakout_low = float(al[-2]) + try: + open_b = float(rows[-2][1]) + except (IndexError, TypeError, ValueError): + open_b = breakout_close + + prev_vol = av[-vol_lb - 2 : -2] + avg20 = mean(prev_vol) if prev_vol else 0.0 + vol_break = float(av[-2]) + vol_ok = vol_break > avg20 * volume_ratio_min if avg20 > 0 else False + + amp_pct = abs(breakout_close - open_b) / open_b * 100 if open_b > 0 else 0.0 + amp_ok = (amp_pct > breakout_amp_min_pct) and (amp_pct < breakout_amp_max_pct) + + direction = (direction or "long").strip().lower() + edge = float(upper) if direction == "long" else float(lower) + breakout_ok = (breakout_close > float(upper)) if direction == "long" else (breakout_close < float(lower)) + confirm_ok_raw = (confirm_close > edge) if direction == "long" else (confirm_close < edge) + amp_ok = amp_ok and breakout_ok + confirm_ok = confirm_ok_raw and breakout_ok + + rank_ok = (volume_rank is not None) and (int(volume_rank) <= volume_rank_max) + + try: + seg = rows[-48:] if len(rows) >= 48 else rows + hh = max(float(x[2]) for x in seg) + ll = min(float(x[3]) for x in seg) + swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0 + except Exception: + swing4h_pct = 0.0 + + out.update( + { + "ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]), + "vol_ok": vol_ok, + "avg20": avg20, + "vol_break": vol_break, + "amp_ok": amp_ok, + "amp_pct": amp_pct, + "breakout_ok": breakout_ok, + "breakout_close": breakout_close, + "confirm_ok": confirm_ok, + "confirm_close": confirm_close, + "edge_price": edge, + "rank": volume_rank, + "rank_total": volume_rank_total, + "rank_ok": rank_ok, + "breakout_high": breakout_high, + "breakout_low": breakout_low, + "swing4h_pct": swing4h_pct, + "direction": direction, + } + ) + return out + + +def key_hard_lines_from_checks(checks: dict, *, volume_ratio_min: float) -> list[str]: + return [ + f"量能:{'通过' if checks.get('vol_ok') else '不通过'}(突破K量 {round(float(checks.get('vol_break') or 0), 4)} / 前20均量 {round(float(checks.get('avg20') or 0), 4)},阈值{volume_ratio_min:g}x)", + f"突破价位:{'通过' if checks.get('breakout_ok') else '不通过'}(突破K收盘 {round(float(checks.get('breakout_close') or 0), 8)},关键位 {checks.get('edge_price')})", + f"突破K幅度:{'通过' if checks.get('amp_ok') else '不通过'}({round(float(checks.get('amp_pct') or 0), 4)}%,要求0.03%~0.5%)", + f"第二根确认:{'通过' if checks.get('confirm_ok') else '不通过'}(确认收盘 {checks.get('confirm_close')},关键位 {checks.get('edge_price')})", + f"日成交量排名:{'通过' if checks.get('rank_ok') else '不通过'}({checks.get('rank')}/{checks.get('rank_total')},要求前30)", + ] diff --git a/onchain_scout_gate/app/key_monitor_service.py b/onchain_scout_gate/app/key_monitor_service.py new file mode 100644 index 0000000..cff68a8 --- /dev/null +++ b/onchain_scout_gate/app/key_monitor_service.py @@ -0,0 +1,383 @@ +"""人工关键位:5m 门控轮询、企微、转发执行器。""" + +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass, field + +from .config import Settings +from .gate import GateClient +from .key_gate import key_hard_checks_from_rows, key_hard_lines_from_checks +from .key_sl_tp import ( + calc_planned_rr, + normalize_sl_tp_mode, + plan_key_sl_tp, + sl_tp_mode_label, + sl_tp_plan_summary_text, + stop_outside_pct_for_mode, +) +from .notifier import WeComNotifier +from .order_executor_forward import build_key_executor_payload, forward_signal_to_executors +from .order_executors_store import read_forward_config, record_last_forward +from .storage import Storage + +LOGGER = logging.getLogger(__name__) +KLINE_BAR = "5m" + + +@dataclass +class KeyMonitorService: + settings: Settings + storage: Storage + gate: GateClient + notifier: WeComNotifier + + _vol_rank_cache: dict = field(default_factory=dict) + + async def preview_row(self, row: dict) -> dict: + """只读门控预览,不触发结案。""" + sym = str(row["symbol"]) + inst = str(row["inst_id"]) + direction = str(row["direction"]) + upper = float(row["upper"]) + lower = float(row["lower"]) + cfg = self.settings.key_monitor + out: dict = {"symbol": sym, "gate_ok": False, "checks": {}} + if upper <= lower: + out["error"] = "upper_must_gt_lower" + return out + try: + raw_rows = await self.gate.get_candles(inst, KLINE_BAR, limit=80) + closed = raw_rows[:-1] if len(raw_rows) >= 3 else raw_rows + vol_map = await self.gate.get_usdt_swap_est_quote_volume_map() + sorted_insts = sorted(vol_map.keys(), key=lambda x: float(vol_map.get(x, 0.0)), reverse=True) + rank_map = {i: idx + 1 for idx, i in enumerate(sorted_insts)} + rank = rank_map.get(inst) + checks = key_hard_checks_from_rows( + closed, + direction=direction, + upper=upper, + lower=lower, + volume_ma_bars=cfg.volume_ma_bars, + volume_ratio_min=cfg.volume_ratio_min, + breakout_amp_min_pct=cfg.breakout_amp_min_pct, + breakout_amp_max_pct=cfg.breakout_amp_max_pct, + volume_rank=rank, + volume_rank_total=len(sorted_insts), + volume_rank_max=cfg.daily_volume_rank_max, + ) + out["checks"] = checks + out["gate_ok"] = bool(checks.get("ok")) + if closed: + out["last_close"] = float(closed[-1][4]) + except Exception as exc: # noqa: BLE001 + out["error"] = str(exc) + return out + + async def run_poll(self) -> None: + cfg = self.settings.key_monitor + if not cfg.enabled: + return + rows = await self.storage.list_key_monitors() + if not rows: + return + vol_map = await self.gate.get_usdt_swap_est_quote_volume_map() + sorted_insts = sorted(vol_map.keys(), key=lambda x: float(vol_map.get(x, 0.0)), reverse=True) + rank_map = {inst: idx + 1 for idx, inst in enumerate(sorted_insts)} + rank_total = len(sorted_insts) + + for row in rows: + try: + await self._poll_one(row, vol_map=vol_map, rank_map=rank_map, rank_total=rank_total) + except Exception as exc: # noqa: BLE001 + sym = row.get("symbol", "?") + msg = f"key_monitor_poll_failed sym={sym} id={row.get('id')}: {exc}" + LOGGER.exception(msg) + await self.storage.add_log("ERROR", msg) + + async def _poll_one( + self, + row: dict, + *, + vol_map: dict[str, float], + rank_map: dict[str, int], + rank_total: int, + ) -> None: + cfg = self.settings.key_monitor + sym = str(row["symbol"]) + inst = str(row["inst_id"]) + direction = str(row["direction"]) + upper = float(row["upper"]) + lower = float(row["lower"]) + if upper <= lower: + return + + raw_rows = await self.gate.get_candles(inst, KLINE_BAR, limit=80) + if len(raw_rows) < 3: + return + closed = raw_rows[:-1] + + rank = rank_map.get(inst) + checks = key_hard_checks_from_rows( + closed, + direction=direction, + upper=upper, + lower=lower, + volume_ma_bars=cfg.volume_ma_bars, + volume_ratio_min=cfg.volume_ratio_min, + breakout_amp_min_pct=cfg.breakout_amp_min_pct, + breakout_amp_max_pct=cfg.breakout_amp_max_pct, + volume_rank=rank, + volume_rank_total=rank_total, + volume_rank_max=cfg.daily_volume_rank_max, + ) + if not checks.get("ok"): + return + + sl_tp_mode = normalize_sl_tp_mode(row.get("sl_tp_mode")) + outside = float(row.get("stop_outside_pct") or stop_outside_pct_for_mode(sl_tp_mode)) + trend_out = cfg.trend_stop_outside_pct + manual_tp = row.get("manual_take_profit") + + plan = plan_key_sl_tp( + sl_tp_mode, + direction, + upper, + lower, + checks, + outside_pct=outside, + trend_outside_pct=trend_out, + manual_take_profit=float(manual_tp) if manual_tp is not None else None, + ) + if not plan: + await self._finalize( + row, + close_reason="plan_invalid", + last_alert_message=f"{sym} 关键位计划 SL/TP 无效", + checks=checks, + confirm_close=float(checks.get("confirm_close") or 0), + planned_sl=None, + planned_tp=None, + planned_rr=None, + executor_signal_id=None, + executor_status=None, + ) + return + + e, sl_raw, tp_raw, box_h = plan + planned_rr = calc_planned_rr(direction, e, sl_raw, tp_raw) + if planned_rr is None or planned_rr <= cfg.min_planned_rr: + hard_lines = key_hard_lines_from_checks(checks, volume_ratio_min=cfg.volume_ratio_min) + msg = ( + f"⚠️ {sym} 关键位:计划 RR 未达标\n" + f"- 类型:{row['monitor_type']}|{sl_tp_mode_label(sl_tp_mode)}\n" + f"- 方向:{'做多' if direction == 'long' else '做空'}\n" + f"- 确认收盘 E:{e}\n" + f"- 计划 RR:{planned_rr if planned_rr is not None else '无效'}(要求 >{cfg.min_planned_rr:g})\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if cfg.push_wecom: + try: + await self.notifier.send_text(msg) + except Exception as exc: # noqa: BLE001 + await self.storage.add_log("ERROR", f"key_monitor_wecom_failed: {exc}") + await self._finalize( + row, + close_reason="rr_insufficient", + last_alert_message=msg, + checks=checks, + confirm_close=e, + planned_sl=sl_raw, + planned_tp=tp_raw, + planned_rr=planned_rr, + executor_signal_id=None, + executor_status=None, + ) + return + + hard_lines = key_hard_lines_from_checks(checks, volume_ratio_min=cfg.volume_ratio_min) + plan_line = sl_tp_plan_summary_text( + sl_tp_mode, direction, e, sl_raw, tp_raw, box_h, + outside_pct=outside, trend_outside_pct=trend_out, + ) + signal_id = f"key-{inst}-{uuid.uuid4().hex[:12]}" + wecom_msg = self._build_wecom_message( + row=row, + checks=checks, + hard_lines=hard_lines, + plan_line=plan_line, + e=e, + sl_raw=sl_raw, + tp_raw=tp_raw, + box_h=box_h, + planned_rr=planned_rr, + ) + + if cfg.push_wecom: + try: + await self.notifier.send_text(wecom_msg) + except Exception as exc: # noqa: BLE001 + await self.storage.add_log("ERROR", f"key_monitor_wecom_failed sym={sym}: {exc}") + + executor_status: str | None = None + close_reason = "triggered" + if cfg.forward_executor: + payload = build_key_executor_payload( + inst_id=inst, + direction=direction, + take_profit=tp_raw, + stop_loss=sl_raw, + reference_price=e, + signal_id=signal_id, + ) + fwd = read_forward_config(self.settings) + secret = str(fwd.get("webhook_secret") or "").strip() + executors = list(fwd.get("executors") or []) + if not payload: + executor_status = "payload_invalid" + close_reason = "executor_failed" + elif not fwd.get("enabled") or not secret: + executor_status = "executor_disabled" + close_reason = "executor_skipped" + elif not executors: + executor_status = "no_active_executor" + close_reason = "executor_failed" + else: + try: + results = await forward_signal_to_executors( + self.settings, + executors=executors, + webhook_secret=secret, + timeout_seconds=float(fwd.get("timeout_seconds") or 15.0), + payload=payload, + ) + any_ok = False + for r in results: + eid = str(r.get("executor_id") or "") + body = r.get("body") if isinstance(r.get("body"), dict) else {} + st = body.get("status") if isinstance(body, dict) else None + try: + record_last_forward( + self.settings, + eid, + http_status=int(r.get("http_status") or 0), + ok=bool(r.get("ok")), + exec_status=str(st) if st is not None else None, + ) + except Exception: # noqa: BLE001 + pass + if r.get("ok"): + any_ok = True + executor_status = str(st or "ok") + await self.storage.add_log( + "INFO", + f"key_executor_ok sym={sym} name={r.get('name')} signal_id={signal_id} status={st}", + ) + if any_ok: + close_reason = "executor_forwarded" + else: + executor_status = "forward_failed" + close_reason = "executor_failed" + await self.storage.add_log("WARN", f"key_executor_all_failed sym={sym}") + except Exception as exc: # noqa: BLE001 + executor_status = f"error:{exc}" + close_reason = "executor_failed" + await self.storage.add_log("ERROR", f"key_executor_forward_exception sym={sym}: {exc}") + + await self.storage.add_alert( + symbol=sym, + venue=f"KEY-{row['monitor_type']}", + trigger_types=[row["monitor_type"], sl_tp_mode_label(sl_tp_mode)], + score=float(planned_rr or 0), + details={ + "key_monitor_id": row["id"], + "checks": checks, + "plan": {"E": e, "SL": sl_raw, "TP": tp_raw, "H": box_h, "RR": planned_rr}, + "executor_signal_id": signal_id, + "executor_status": executor_status, + }, + ) + await self._finalize( + row, + close_reason=close_reason, + last_alert_message=wecom_msg, + checks=checks, + confirm_close=e, + planned_sl=sl_raw, + planned_tp=tp_raw, + planned_rr=planned_rr, + executor_signal_id=signal_id, + executor_status=executor_status, + ) + + async def _finalize( + self, + row: dict, + *, + close_reason: str, + last_alert_message: str | None, + checks: dict, + confirm_close: float | None, + planned_sl: float | None, + planned_tp: float | None, + planned_rr: float | None, + executor_signal_id: str | None, + executor_status: str | None, + ) -> None: + await self.storage.finalize_key_monitor( + row, + close_reason=close_reason, + last_alert_message=last_alert_message, + confirm_close=confirm_close, + planned_sl=planned_sl, + planned_tp=planned_tp, + planned_rr=planned_rr, + executor_signal_id=executor_signal_id, + executor_status=executor_status, + checks=checks, + ) + await self.storage.add_log( + "INFO", + f"key_monitor_closed id={row.get('id')} sym={row.get('symbol')} reason={close_reason}", + ) + + def _build_wecom_message( + self, + *, + row: dict, + checks: dict, + hard_lines: list[str], + plan_line: str, + e: float, + sl_raw: float, + tp_raw: float, + box_h: float, + planned_rr: float | None, + ) -> str: + sym = row["symbol"] + direction = row["direction"] + dir_cn = "做多" if direction == "long" else "做空" + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "-" + + def _px(x: float) -> str: + s = f"{x:.8f}".rstrip("0").rstrip(".") + return s or "0" + + return ( + "🎯 Gate 关键位突破确认\n" + "━━━━━━━━━━━━━━\n" + f"🔹 交易对:{sym}-USDT 永续\n" + f"📋 结构类型:{row['monitor_type']}|{sl_tp_mode_label(row.get('sl_tp_mode'))}\n" + f"🧭 方向:{dir_cn}\n" + f"📐 上沿:{_px(float(row['upper']))}|下沿:{_px(float(row['lower']))}|箱体高 H:{_px(box_h)}\n" + "✅ 硬条件:\n" + + "\n".join(f" · {x}" for x in hard_lines) + + "\n" + f"📌 执行计划:{plan_line}\n" + f" · 确认收盘 E:{_px(e)}\n" + f" · 止损 SL:{_px(sl_raw)}\n" + f" · 止盈 TP:{_px(tp_raw)}\n" + f" · 计划 RR:{rr_txt} : 1\n" + "💡 已按录入方案转发执行器(单一价位,无 A/B 区间)。" + ) diff --git a/onchain_scout_gate/app/key_sl_tp.py b/onchain_scout_gate/app/key_sl_tp.py new file mode 100644 index 0000000..8a0aade --- /dev/null +++ b/onchain_scout_gate/app/key_sl_tp.py @@ -0,0 +1,138 @@ +"""关键位箱体/收敛:止盈止损方案(自 crypto_monitor key_sl_tp_lib 内化,仅 standard + trend_manual)。""" + +from __future__ import annotations + +KEY_SL_TP_MODES = frozenset({"standard", "trend_manual"}) + +KEY_SL_TP_MODE_LABELS = { + "standard": "标准突破", + "trend_manual": "趋势突破", +} + +KEY_MONITOR_TYPES = frozenset({"箱体突破", "收敛突破"}) + + +def normalize_sl_tp_mode(raw: object) -> str: + m = (raw or "standard").strip().lower() + 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: object) -> str: + return KEY_SL_TP_MODE_LABELS.get(normalize_sl_tp_mode(mode), normalize_sl_tp_mode(mode)) + + +def normalize_monitor_type(raw: object) -> str: + t = (raw or "").strip() + if t in KEY_MONITOR_TYPES: + return t + return "箱体突破" + + +def stop_outside_pct_for_mode(sl_tp_mode: str) -> float: + return 1.0 if normalize_sl_tp_mode(sl_tp_mode) == "trend_manual" else 0.3 + + +def plan_key_sl_tp( + mode: str, + direction: str, + upper: float, + lower: float, + checks: dict, + *, + outside_pct: float, + trend_outside_pct: float, + manual_take_profit: float | None = None, +) -> tuple[float, float, float, float] | None: + """ + 以确认 K 收盘 E 计算计划 SL/TP。 + 返回 (E, sl_raw, tp_raw, box_h) 或 None。 + """ + 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_n = normalize_sl_tp_mode(mode) + + if mode_n == "trend_manual": + try: + br_hi = float(checks["breakout_high"]) + br_lo = float(checks["breakout_low"]) + tp_raw = float(manual_take_profit) # type: ignore[arg-type] + 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 + + 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 + if sl_raw <= 0 or tp_raw <= 0: + return None + return e, sl_raw, tp_raw, h + + +def calc_planned_rr(direction: str, entry: float, sl: float, tp: float) -> float | None: + try: + e, sl_v, tp_v = float(entry), float(sl), float(tp) + except (TypeError, ValueError): + return None + if e <= 0 or sl_v <= 0 or tp_v <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "long": + risk = e - sl_v + reward = tp_v - e + else: + risk = sl_v - e + reward = e - tp_v + if risk <= 0 or reward <= 0: + return None + return reward / risk + + +def sl_tp_plan_summary_text( + mode: str, + direction: str, + e: float, + sl_raw: float, + tp_raw: float, + box_h: float, + *, + outside_pct: float, + trend_outside_pct: float, +) -> str: + mode_n = normalize_sl_tp_mode(mode) + if mode_n == "trend_manual": + return ( + f"方案:{sl_tp_mode_label(mode_n)}|E={e}|" + f"SL=突破K极值外{trend_outside_pct:g}%|TP={tp_raw}(录入)" + ) + return ( + f"方案:{sl_tp_mode_label(mode_n)}|E={e}|" + f"SL=突破K外{outside_pct:g}%|TP=E±1×H({box_h})" + ) diff --git a/onchain_scout_gate/app/models.py b/onchain_scout_gate/app/models.py index 3af5d61..152350d 100644 --- a/onchain_scout_gate/app/models.py +++ b/onchain_scout_gate/app/models.py @@ -39,3 +39,51 @@ class KvStore(Base): key: Mapped[str] = mapped_column(String(64), primary_key=True) value: Mapped[str] = mapped_column(Text) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class KeyMonitor(Base): + """人工录入的关键位突破监控(活跃)。""" + + __tablename__ = "key_monitors" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + symbol: Mapped[str] = mapped_column(String(32), index=True) + inst_id: Mapped[str] = mapped_column(String(48), index=True) + monitor_type: Mapped[str] = mapped_column(String(32)) + direction: Mapped[str] = mapped_column(String(8)) + upper: Mapped[float] = mapped_column(Float) + lower: Mapped[float] = mapped_column(Float) + sl_tp_mode: Mapped[str] = mapped_column(String(24), default="standard") + manual_take_profit: Mapped[float | None] = mapped_column(Float, nullable=True) + stop_outside_pct: Mapped[float] = mapped_column(Float, default=0.3) + breakeven_enabled: Mapped[int] = mapped_column(Integer, default=0) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + +class KeyMonitorHistory(Base): + """已结案的关键位(可导出复盘)。""" + + __tablename__ = "key_monitor_history" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + key_monitor_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + symbol: Mapped[str] = mapped_column(String(32), index=True) + inst_id: Mapped[str] = mapped_column(String(48)) + monitor_type: Mapped[str] = mapped_column(String(32)) + direction: Mapped[str] = mapped_column(String(8)) + upper: Mapped[float] = mapped_column(Float) + lower: Mapped[float] = mapped_column(Float) + sl_tp_mode: Mapped[str] = mapped_column(String(24)) + manual_take_profit: Mapped[float | None] = mapped_column(Float, nullable=True) + stop_outside_pct: Mapped[float] = mapped_column(Float) + confirm_close: Mapped[float | None] = mapped_column(Float, nullable=True) + planned_sl: Mapped[float | None] = mapped_column(Float, nullable=True) + planned_tp: Mapped[float | None] = mapped_column(Float, nullable=True) + planned_rr: Mapped[float | None] = mapped_column(Float, nullable=True) + executor_signal_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + executor_status: Mapped[str | None] = mapped_column(String(64), nullable=True) + checks_json: Mapped[str | None] = mapped_column(Text, nullable=True) + last_alert_message: Mapped[str | None] = mapped_column(Text, nullable=True) + close_reason: Mapped[str] = mapped_column(String(48), index=True) + closed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) diff --git a/onchain_scout_gate/app/monitor.py b/onchain_scout_gate/app/monitor.py index 37bf5be..609f791 100644 --- a/onchain_scout_gate/app/monitor.py +++ b/onchain_scout_gate/app/monitor.py @@ -93,6 +93,8 @@ class MonitorService: return frozenset(out) async def _maybe_forward_order_executor(self, sym: str, inst: str, push_metrics: dict) -> None: + if not self.settings.key_monitor.auto_scan_forward_executor: + return fwd = read_forward_config(self.settings) if not fwd.get("enabled"): return diff --git a/onchain_scout_gate/app/notifier.py b/onchain_scout_gate/app/notifier.py index e03fa98..96f75da 100644 --- a/onchain_scout_gate/app/notifier.py +++ b/onchain_scout_gate/app/notifier.py @@ -63,18 +63,16 @@ class WeComNotifier: stop_pct = max(0.0, min(stop_pct, 10.0)) long_m = 1.0 - stop_pct / 100.0 short_m = 1.0 + stop_pct / 100.0 - stop_a = _px(breakout_low * long_m if signal_side == "LONG" else breakout_high * short_m) - stop_b = _px(range_low * long_m if signal_side == "LONG" else range_high * short_m) if abs(stop_pct - round(stop_pct)) < 1e-9: stop_pct_label = str(int(round(stop_pct))) else: stop_pct_label = f"{stop_pct:.4f}".rstrip("0").rstrip(".") or "0" box_size = (range_high - range_low) - tp_a = _px(confirm_close + box_size if signal_side == "LONG" else confirm_close - box_size) - tp_b = _px(confirm_close + box_size * 1.5 if signal_side == "LONG" else confirm_close - box_size * 1.5) + stop_sl = _px(breakout_low * long_m if signal_side == "LONG" else breakout_high * short_m) + tp_sl = _px(confirm_close + box_size if signal_side == "LONG" else confirm_close - box_size) t_cn = format_beijing_wall(utc_now()) content = ( - "🚨 Gate 突破预警信号\n" + "🚨 Gate 扫描突破参考(自动箱体)\n" "━━━━━━━━━━━━━━\n" f"🔹 交易对:{pair_line}\n" f"⏱️ K线周期:{bar_cn}\n" @@ -90,9 +88,10 @@ class WeComNotifier: "📌 关键价位:\n" f" {'箱体下沿' if signal_side == 'LONG' else '箱体上沿'}:{key_ref}\n" f" 确认K收盘价:{_px(confirm_close)}\n" - "💡 操作提示:\n" - f" 1. 入场区间A:止盈 {signal_cn} 箱体1.0倍距离({tp_a}),止损 突破K高低点±{stop_pct_label}%({stop_a})\n" - f" 2. 入场区间B:止盈 {signal_cn} 箱体1.5倍距离({tp_b}),止损 箱体边沿±{stop_pct_label}%({stop_b})\n" + "💡 参考计划(非关键位录入):\n" + f" · 止盈:{signal_cn} 箱体1.0倍({tp_sl})\n" + f" · 止损:突破K极值外 {stop_pct_label}%({stop_sl})\n" + " · 正式下单请以「关键位突破监控」录入上下沿后的方案为准。\n" f"⏰ 触发时间:{t_cn}(北京时间 UTC+8)" ) payload = { @@ -106,6 +105,18 @@ class WeComNotifier: resp = await client.post(self.conf.webhook, json=payload) resp.raise_for_status() + async def send_text(self, content: str) -> None: + payload = { + "msgtype": "text", + "text": { + "content": content, + "mentioned_mobile_list": self.conf.mentioned_mobile_list, + }, + } + async with httpx.AsyncClient(**self._client_kwargs()) as client: + resp = await client.post(self.conf.webhook, json=payload) + resp.raise_for_status() + async def send_funnel_priority( self, symbol: str, diff --git a/onchain_scout_gate/app/order_executor_forward.py b/onchain_scout_gate/app/order_executor_forward.py index 58b177d..de2dbb9 100644 --- a/onchain_scout_gate/app/order_executor_forward.py +++ b/onchain_scout_gate/app/order_executor_forward.py @@ -12,11 +12,57 @@ from .config import Settings logger = logging.getLogger(__name__) +def build_key_executor_payload( + *, + inst_id: str, + direction: str, + take_profit: float, + stop_loss: float, + reference_price: float, + signal_id: str | None = None, +) -> dict[str, Any] | None: + """人工关键位:单一 SL/TP 转发执行器。""" + direction = (direction or "").strip().lower() + if direction not in ("long", "short"): + return None + if take_profit <= 0 or stop_loss <= 0 or reference_price <= 0: + return None + ct = inst_id.strip().upper() + sid = signal_id or f"key-{ct}-{uuid.uuid4().hex[:12]}" + return { + "signal_id": sid, + "contract": ct, + "side": direction, + "take_profit": float(take_profit), + "stop_loss": float(stop_loss), + "reference_price": float(reference_price), + } + + def build_order_executor_payload(*, inst_id: str, metrics: dict) -> dict[str, Any] | None: """ - 与企微突破文案「方案 A」一致:止盈 = 确认收盘 ± 1 倍箱宽;止损 = 突破 K 高低点外侧 stop_buffer_pct(默认 0.2%,与面板一致)。 - 返回 gate_order_executor POST /v1/signal 的 JSON;无法构造则 None。 + 若 metrics 含 planned_take_profit / planned_stop_loss(关键位触发),优先使用; + 否则为自动扫描参考:确认收盘 ± 1×箱宽止盈,突破 K 极值外 stop_buffer_pct 止损。 """ + if metrics.get("planned_take_profit") is not None and metrics.get("planned_stop_loss") is not None: + direction = str(metrics.get("direction") or metrics.get("signal_side") or "").lower() + if direction in ("long", "short"): + pass + elif str(metrics.get("signal_side") or "") == "LONG": + direction = "long" + elif str(metrics.get("signal_side") or "") == "SHORT": + direction = "short" + else: + return None + return build_key_executor_payload( + inst_id=inst_id, + direction=direction, + take_profit=float(metrics["planned_take_profit"]), + stop_loss=float(metrics["planned_stop_loss"]), + reference_price=float(metrics.get("reference_price") or metrics.get("confirm_close") or 0), + signal_id=str(metrics.get("signal_id") or "") or None, + ) + signal_side = str(metrics.get("signal_side") or "NONE") if signal_side not in ("LONG", "SHORT"): return None @@ -35,22 +81,18 @@ def build_order_executor_payload(*, inst_id: str, metrics: dict) -> dict[str, An if signal_side == "LONG": stop_loss = breakout_low * long_m take_profit = confirm_close + box_size + direction = "long" else: stop_loss = breakout_high * short_m take_profit = confirm_close - box_size - if take_profit <= 0 or stop_loss <= 0: - return None - side = "long" if signal_side == "LONG" else "short" - ct = inst_id.strip().upper() - signal_id = f"scout-{ct}-{uuid.uuid4().hex[:12]}" - return { - "signal_id": signal_id, - "contract": ct, - "side": side, - "take_profit": float(take_profit), - "stop_loss": float(stop_loss), - "reference_price": float(confirm_close), - } + direction = "short" + return build_key_executor_payload( + inst_id=inst_id, + direction=direction, + take_profit=take_profit, + stop_loss=stop_loss, + reference_price=confirm_close, + ) async def _post_one_executor( diff --git a/onchain_scout_gate/app/storage.py b/onchain_scout_gate/app/storage.py index abc9ab9..d91c525 100644 --- a/onchain_scout_gate/app/storage.py +++ b/onchain_scout_gate/app/storage.py @@ -7,7 +7,7 @@ from sqlalchemy import desc, select from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from .models import AlertRecord, Base, KvStore, RuntimeLog +from .models import AlertRecord, Base, KeyMonitor, KeyMonitorHistory, KvStore, RuntimeLog DEFAULT_CHART_BAR = "1D" @@ -152,5 +152,189 @@ class Storage: for row in rows ] + async def add_key_monitor( + self, + *, + symbol: str, + inst_id: str, + monitor_type: str, + direction: str, + upper: float, + lower: float, + sl_tp_mode: str, + manual_take_profit: float | None, + stop_outside_pct: float, + breakeven_enabled: int, + note: str | None = None, + ) -> int: + async with self.session_factory() as session: + row = KeyMonitor( + symbol=symbol.strip().upper(), + inst_id=inst_id.strip().upper(), + monitor_type=monitor_type, + direction=direction.strip().lower(), + upper=upper, + lower=lower, + sl_tp_mode=sl_tp_mode, + manual_take_profit=manual_take_profit, + stop_outside_pct=stop_outside_pct, + breakeven_enabled=breakeven_enabled, + note=note, + ) + session.add(row) + await session.commit() + await session.refresh(row) + return int(row.id) + + async def list_key_monitors(self) -> list[dict]: + async with self.session_factory() as session: + stmt = select(KeyMonitor).order_by(desc(KeyMonitor.created_at)) + rows = (await session.execute(stmt)).scalars().all() + return [_key_monitor_to_dict(r) for r in rows] + + async def get_key_monitor(self, kid: int) -> dict | None: + async with self.session_factory() as session: + row = await session.get(KeyMonitor, kid) + return _key_monitor_to_dict(row) if row else None + + async def delete_key_monitor(self, kid: int) -> bool: + async with self.session_factory() as session: + row = await session.get(KeyMonitor, kid) + if not row: + return False + await session.delete(row) + await session.commit() + return True + + async def finalize_key_monitor( + self, + row: dict, + *, + close_reason: str, + last_alert_message: str | None, + confirm_close: float | None, + planned_sl: float | None, + planned_tp: float | None, + planned_rr: float | None, + executor_signal_id: str | None, + executor_status: str | None, + checks: dict | None, + ) -> int: + async with self.session_factory() as session: + hist = KeyMonitorHistory( + key_monitor_id=int(row["id"]), + symbol=row["symbol"], + inst_id=row["inst_id"], + monitor_type=row["monitor_type"], + direction=row["direction"], + upper=float(row["upper"]), + lower=float(row["lower"]), + sl_tp_mode=row["sl_tp_mode"], + manual_take_profit=row.get("manual_take_profit"), + stop_outside_pct=float(row["stop_outside_pct"]), + confirm_close=confirm_close, + planned_sl=planned_sl, + planned_tp=planned_tp, + planned_rr=planned_rr, + executor_signal_id=executor_signal_id, + executor_status=executor_status, + checks_json=json.dumps(checks or {}, ensure_ascii=False), + last_alert_message=last_alert_message, + close_reason=close_reason, + ) + session.add(hist) + active = await session.get(KeyMonitor, int(row["id"])) + if active: + await session.delete(active) + await session.commit() + await session.refresh(hist) + return int(hist.id) + + async def list_key_monitor_history( + self, + *, + limit: int = 500, + since: datetime | None = None, + ) -> list[dict]: + async with self.session_factory() as session: + stmt = select(KeyMonitorHistory).order_by(desc(KeyMonitorHistory.closed_at)).limit(limit) + if since is not None: + stmt = stmt.where(KeyMonitorHistory.closed_at >= since) + rows = (await session.execute(stmt)).scalars().all() + return [_key_history_to_dict(r) for r in rows] + + async def delete_key_monitor_history(self, hid: int) -> bool: + async with self.session_factory() as session: + row = await session.get(KeyMonitorHistory, hid) + if not row: + return False + await session.delete(row) + await session.commit() + return True + + async def export_key_monitor_history_rows( + self, + *, + start_utc: datetime, + end_utc: datetime, + ) -> list[dict]: + async with self.session_factory() as session: + stmt = ( + select(KeyMonitorHistory) + .where( + KeyMonitorHistory.closed_at >= start_utc, + KeyMonitorHistory.closed_at <= end_utc, + ) + .order_by(KeyMonitorHistory.id.asc()) + ) + rows = (await session.execute(stmt)).scalars().all() + return [_key_history_to_dict(r) for r in rows] + async def close(self) -> None: await self.engine.dispose() + + +def _key_monitor_to_dict(row: KeyMonitor | None) -> dict: + if row is None: + return {} + return { + "id": row.id, + "symbol": row.symbol, + "inst_id": row.inst_id, + "monitor_type": row.monitor_type, + "direction": row.direction, + "upper": row.upper, + "lower": row.lower, + "sl_tp_mode": row.sl_tp_mode, + "manual_take_profit": row.manual_take_profit, + "stop_outside_pct": row.stop_outside_pct, + "breakeven_enabled": row.breakeven_enabled, + "note": row.note, + "created_at": row.created_at.isoformat(), + } + + +def _key_history_to_dict(row: KeyMonitorHistory) -> dict: + return { + "id": row.id, + "key_monitor_id": row.key_monitor_id, + "symbol": row.symbol, + "inst_id": row.inst_id, + "monitor_type": row.monitor_type, + "direction": row.direction, + "upper": row.upper, + "lower": row.lower, + "sl_tp_mode": row.sl_tp_mode, + "manual_take_profit": row.manual_take_profit, + "stop_outside_pct": row.stop_outside_pct, + "confirm_close": row.confirm_close, + "planned_sl": row.planned_sl, + "planned_tp": row.planned_tp, + "planned_rr": row.planned_rr, + "executor_signal_id": row.executor_signal_id, + "executor_status": row.executor_status, + "checks_json": row.checks_json, + "last_alert_message": row.last_alert_message, + "close_reason": row.close_reason, + "closed_at": row.closed_at.isoformat(), + } diff --git a/onchain_scout_gate/app/web.py b/onchain_scout_gate/app/web.py index 50a6cf8..da87b90 100644 --- a/onchain_scout_gate/app/web.py +++ b/onchain_scout_gate/app/web.py @@ -8,7 +8,7 @@ from pathlib import Path from apscheduler.schedulers.asyncio import AsyncIOScheduler from fastapi import Depends, FastAPI, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.gzip import GZipMiddleware @@ -17,6 +17,14 @@ from starlette.middleware.sessions import SessionMiddleware from .config import Settings from .daily_report import DailyReportService from .gemma_client import OllamaGemmaClient +from .key_monitor_service import KeyMonitorService +from .key_sl_tp import ( + KEY_MONITOR_TYPES, + normalize_monitor_type, + normalize_sl_tp_mode, + sl_tp_mode_label, + stop_outside_pct_for_mode, +) from .monitor import MonitorService from .notifier import WeComNotifier from .gate import GateClient @@ -33,6 +41,7 @@ from .storage import Storage LOGGER = logging.getLogger("onchain_scout.web") FIXED_BAR = "5m" DAILY_REPORT_JOB_ID = "daily_report_job" +KEY_MONITOR_JOB_ID = "key_monitor_poll" FUNNEL_DISPLAY_HOURS_DEFAULT = 24.0 FUNNEL_DISPLAY_HOURS_MIN = 1.0 FUNNEL_DISPLAY_HOURS_MAX = 168.0 @@ -194,6 +203,12 @@ def create_app(settings: Settings) -> FastAPI: notifier=notifier, gemma_client=gemma_client, ) + key_monitor = KeyMonitorService( + settings=settings, + storage=storage, + gate=gate_client, + notifier=notifier, + ) daily_report = DailyReportService( settings=settings, storage=storage, @@ -206,6 +221,8 @@ def create_app(settings: Settings) -> FastAPI: app.state.settings = settings app.state.storage = storage app.state.monitor = monitor + app.state.key_monitor = key_monitor + app.state.gate_client = gate_client app.state.scheduler = scheduler app.state.auth_user = settings.auth.username app.state.auth_password_hash = _hash_password(settings.auth.password) @@ -219,6 +236,15 @@ def create_app(settings: Settings) -> FastAPI: await _ensure_runtime_defaults(storage) monitor.state.chart_bar = FIXED_BAR scheduler.add_job(monitor.run_cycle, "interval", seconds=settings.app.poll_interval_seconds, max_instances=1) + if settings.key_monitor.enabled: + scheduler.add_job( + key_monitor.run_poll, + "interval", + seconds=settings.key_monitor.poll_interval_seconds, + max_instances=1, + id=KEY_MONITOR_JOB_ID, + replace_existing=True, + ) dr = await _get_daily_report_settings(storage, settings) if dr["enabled"]: hh, mm = _parse_hhmm(str(dr["run_time_cn"])) @@ -242,7 +268,8 @@ def create_app(settings: Settings) -> FastAPI: f"service_started_gate_usdt gemma={'on' if settings.gemma.enabled else 'off'} " f"proxy={'on ' + settings.proxy.url if settings.proxy.enabled else 'off'} " f"web_login={'on' if settings.auth.enabled else 'off'} " - f"daily_report={'on' if settings.daily_report.enabled else 'off'}" + f"daily_report={'on' if settings.daily_report.enabled else 'off'} " + f"key_monitor={'on' if settings.key_monitor.enabled else 'off'}" ), ) LOGGER.info("Service started") @@ -314,6 +341,7 @@ def create_app(settings: Settings) -> FastAPI: "intraday_settings": intraday, "gemma_enabled": settings.gemma.enabled, "gemma_model": settings.gemma.model, + "key_monitor": settings.key_monitor.model_dump(), } ) @@ -498,6 +526,173 @@ def create_app(settings: Settings) -> FastAPI: ) return JSONResponse({"ok": True, "symbol_blocklist_settings": await _get_symbol_blocklist_settings(storage)}) + def _key_rule_text() -> str: + km = settings.key_monitor + return ( + f"周期 5m|突破K/确认K:倒数第2/第1根闭合K|量能:突破K量 > 前{km.volume_ma_bars}均量×{km.volume_ratio_min}|" + f"计划RR须 > {km.min_planned_rr:g}|日成交额排名前{km.daily_volume_rank_max}|" + f"箱体/收敛方案:标准突破(止损突破K外{km.standard_stop_outside_pct:g}%|止盈1×H)或 " + f"趋势突破(止损突破K外{km.trend_stop_outside_pct:g}%|止盈手填)|" + f"触发后企微+{'转发执行器' if km.forward_executor else '不转发执行器'}" + ) + + @app.get("/api/key-monitors") + async def api_key_monitors_list(_: None = Depends(require_login)) -> JSONResponse: + rows = await storage.list_key_monitors() + enriched = [] + for row in rows: + preview = await key_monitor.preview_row(row) + enriched.append({**row, "preview": preview}) + history = await storage.list_key_monitor_history(limit=200) + return JSONResponse( + { + "active": enriched, + "history": history, + "rule_text": _key_rule_text(), + "config": settings.key_monitor.model_dump(), + } + ) + + @app.post("/api/key-monitors") + async def api_key_monitors_add(request: Request, _: None = Depends(require_login)) -> JSONResponse: + body = await request.json() + sym = _normalize_symbol_token(body.get("symbol")) + if not sym: + return JSONResponse({"ok": False, "detail": "invalid symbol"}, status_code=400) + inst = gate_client.symbol_to_swap_inst_id(sym) + monitor_type = normalize_monitor_type(body.get("monitor_type")) + if monitor_type not in KEY_MONITOR_TYPES: + return JSONResponse({"ok": False, "detail": "invalid monitor_type"}, status_code=400) + direction = str(body.get("direction") or "").strip().lower() + if direction not in ("long", "short"): + return JSONResponse({"ok": False, "detail": "direction must be long or short"}, status_code=400) + try: + upper = float(body.get("upper")) + lower = float(body.get("lower")) + except (TypeError, ValueError): + return JSONResponse({"ok": False, "detail": "upper/lower required"}, status_code=400) + if upper <= lower: + return JSONResponse({"ok": False, "detail": "upper must be > lower"}, status_code=400) + sl_tp_mode = normalize_sl_tp_mode(body.get("sl_tp_mode")) + manual_tp = body.get("manual_take_profit") + manual_tp_f: float | None = None + if sl_tp_mode == "trend_manual": + try: + manual_tp_f = float(manual_tp) + except (TypeError, ValueError): + return JSONResponse({"ok": False, "detail": "manual_take_profit required for trend"}, status_code=400) + stop_pct = stop_outside_pct_for_mode(sl_tp_mode) + if sl_tp_mode == "standard": + stop_pct = float(settings.key_monitor.standard_stop_outside_pct) + else: + stop_pct = float(settings.key_monitor.trend_stop_outside_pct) + be = 1 if str(body.get("breakeven_enabled") or "").lower() in ("1", "true", "on", "yes") else 0 + kid = await storage.add_key_monitor( + symbol=sym, + inst_id=inst, + monitor_type=monitor_type, + direction=direction, + upper=upper, + lower=lower, + sl_tp_mode=sl_tp_mode, + manual_take_profit=manual_tp_f, + stop_outside_pct=stop_pct, + breakeven_enabled=be, + note=str(body.get("note") or "")[:500] or None, + ) + await storage.add_log( + "INFO", + f"key_monitor_added id={kid} sym={sym} type={monitor_type} mode={sl_tp_mode} dir={direction}", + ) + return JSONResponse({"ok": True, "id": kid}) + + @app.delete("/api/key-monitors/{kid}") + async def api_key_monitors_delete(kid: int, _: None = Depends(require_login)) -> JSONResponse: + row = await storage.get_key_monitor(kid) + if not row: + return JSONResponse({"ok": False, "detail": "not_found"}, status_code=404) + await storage.finalize_key_monitor( + row, + close_reason="manual", + last_alert_message=None, + confirm_close=None, + planned_sl=None, + planned_tp=None, + planned_rr=None, + executor_signal_id=None, + executor_status=None, + checks=None, + ) + await storage.add_log("INFO", f"key_monitor_manual_close id={kid} sym={row.get('symbol')}") + return JSONResponse({"ok": True}) + + @app.delete("/api/key-monitors/history/{hid}") + async def api_key_history_delete(hid: int, _: None = Depends(require_login)) -> JSONResponse: + ok = await storage.delete_key_monitor_history(hid) + if not ok: + return JSONResponse({"ok": False, "detail": "not_found"}, status_code=404) + return JSONResponse({"ok": True}) + + @app.get("/export/key_monitor_history.csv") + async def export_key_monitor_history( + days: int = 30, + _: None = Depends(require_login), + ) -> StreamingResponse: + days = max(1, min(365, int(days))) + end_utc = datetime.utcnow() + start_utc = end_utc - timedelta(days=days) + rows = await storage.export_key_monitor_history_rows(start_utc=start_utc, end_utc=end_utc) + import csv + import io + + buf = io.StringIO() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "sl_tp_mode", + "upper", + "lower", + "confirm_close", + "planned_sl", + "planned_tp", + "planned_rr", + "executor_signal_id", + "executor_status", + "close_reason", + "closed_at", + ] + w = csv.writer(buf) + w.writerow(head) + for r in rows: + w.writerow( + [ + r.get("id"), + r.get("symbol"), + r.get("monitor_type"), + r.get("direction"), + r.get("sl_tp_mode"), + r.get("upper"), + r.get("lower"), + r.get("confirm_close"), + r.get("planned_sl"), + r.get("planned_tp"), + r.get("planned_rr"), + r.get("executor_signal_id"), + r.get("executor_status"), + r.get("close_reason"), + r.get("closed_at"), + ] + ) + day = datetime.utcnow().strftime("%Y%m%d") + content = buf.getvalue().encode("utf-8-sig") + return StreamingResponse( + iter([content]), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="key_monitor_history_{day}.csv"'}, + ) + @app.get("/api/alerts") async def api_alerts(_: None = Depends(require_login)) -> JSONResponse: alerts = await storage.get_recent_alerts(limit=120) @@ -541,6 +736,7 @@ def create_app(settings: Settings) -> FastAPI: "url": settings.proxy.url if settings.proxy.enabled else "", }, "order_executor": read_snapshot(settings), + "key_monitor": settings.key_monitor.model_dump(), "watch_symbols": symbols, } ) diff --git a/onchain_scout_gate/config.example.yaml b/onchain_scout_gate/config.example.yaml index ae8db41..ec2d235 100644 --- a/onchain_scout_gate/config.example.yaml +++ b/onchain_scout_gate/config.example.yaml @@ -26,7 +26,7 @@ proxy: enabled: true url: "socks5h://127.0.0.1:1080" -# 企微「突破预警」推送成功后,向 gate_order_executor POST /v1/signal(与微信同源条件;方案 A 止盈止损) +# 关键位门控通过后向 gate_order_executor POST /v1/signal(单一 SL/TP;见 key_monitor.forward_executor) # 首次启动时从本段导入 runtime/order_executors.json;之后以 Web 面板「下单执行器」为准(可热增删,无需重启)。 # 请求直连各 base_url,不走 proxy。webhook_secret 须与各执行器 security.webhook_secret 一致。 order_executor: @@ -35,6 +35,23 @@ order_executor: webhook_secret: "same-as-gate-order-executor-security-webhook_secret" timeout_seconds: 15 +# 人工关键位突破(面板录入上下沿;5m 门控通过后企微 + 可选转发执行器) +key_monitor: + enabled: true + poll_interval_seconds: 5 + push_wecom: true + forward_executor: true + standard_stop_outside_pct: 0.3 + trend_stop_outside_pct: 1.0 + min_planned_rr: 1.5 + volume_ma_bars: 20 + volume_ratio_min: 1.3 + breakout_amp_min_pct: 0.03 + breakout_amp_max_pct: 0.5 + daily_volume_rank_max: 30 + # 全市场 5m TRIGGER 是否仍转发执行器(默认 false,仅关键位转发) + auto_scan_forward_executor: false + monitor: universe: "all_swaps" # 近 24h 估算成交额(USDT)下限,建议 ≥ 1 千万以缩小扫描面 diff --git a/onchain_scout_gate/docs/关键位突破监控说明.md b/onchain_scout_gate/docs/关键位突破监控说明.md new file mode 100644 index 0000000..8db3113 --- /dev/null +++ b/onchain_scout_gate/docs/关键位突破监控说明.md @@ -0,0 +1,56 @@ +# 关键位突破监控 + +## 功能定位 + +- **GEMMA 漏斗**:仅作人工参考(结构/量/空间/阻力),不自动下单。 +- **关键位突破监控**:在面板录入币种、方向、上沿/下沿与突破类型;系统按 **5m 闭合 K** 做硬门控,通过后推送企业微信,并可向 **gate_order_executor** 转发 **单一** 止盈/止损(无 A/B 双区间)。 + +逻辑自 `crypto_monitor_gate` 内化至本仓库,**不跨目录读对方数据库或代码**。 + +## 类型矩阵(2×2) + +| | 标准突破 | 趋势突破 | +|--|---------|---------| +| **箱体突破** | 止损:突破 K 极值外 **0.3%**;止盈:确认收盘 E **± 1×H** | 止损:突破 K 外 **1%**;止盈:**手填** | +| **收敛突破** | 同上 | 同上 | + +`H = |上沿 − 下沿|`。不包含「箱体 1R·止盈 1.5H」方案。 + +## 5m 硬门控(与 gate 版一致) + +- 突破 K / 确认 K:倒数第 2 / 第 1 根**已闭合** 5m +- 量能:突破 K 量 > 前 20 均量 × 1.3(可配置) +- 突破 K 实体幅度:0.03% ~ 0.5% +- 突破收盘越过录入上沿(多)或下沿(空);确认 K 收盘仍在关键位外侧 +- 日成交额排名:前 30(可配置) +- 计划 RR(按确认收盘 E):须 **>** `key_monitor.min_planned_rr`(默认 1.5) + +## 触发后 + +1. 企业微信:关键位突破确认(含硬条件与单一 SL/TP/RR) +2. 若 `key_monitor.forward_executor: true` 且执行器总开关开启:POST `/v1/signal` +3. 本条写入 `key_monitor_history`,从 `key_monitors` 删除(一次性,不重复触发) + +## 配置(config.yaml · `key_monitor`) + +| 字段 | 说明 | +|------|------| +| `enabled` | 是否启用轮询 | +| `poll_interval_seconds` | 轮询间隔(默认 5 秒) | +| `push_wecom` | 是否发企微 | +| `forward_executor` | 是否转发执行器 | +| `standard_stop_outside_pct` | 标准突破止损外扩(默认 0.3) | +| `trend_stop_outside_pct` | 趋势突破止损外扩(默认 1.0) | +| `min_planned_rr` | 最低计划 RR | +| `auto_scan_forward_executor` | 全市场 5m TRIGGER 是否仍转发(默认 **false**) | + +## 复盘导出 + +登录后访问: + +`/export/key_monitor_history.csv?days=30` + +## 与全市场扫描的关系 + +- **策略寄存器 · 5m**:仍可作全市场「雷达」;企微文案已改为参考计划(非关键位录入)。 +- **执行器**:默认仅 **关键位** 转发;勿与自动扫描 TRIGGER 重复开仓(`auto_scan_forward_executor: false`)。 diff --git a/onchain_scout_gate/docs/多执行器与信号转发归档.md b/onchain_scout_gate/docs/多执行器与信号转发归档.md index 05891fa..d237af4 100644 --- a/onchain_scout_gate/docs/多执行器与信号转发归档.md +++ b/onchain_scout_gate/docs/多执行器与信号转发归档.md @@ -8,7 +8,7 @@ | 目标 | 说明 | |------|------| -| **一套信号** | 扫描端在企微突破推送成功后,构造 **一份** 方案 A 止盈/止损 payload | +| **一套信号** | **关键位** 5m 门控通过后,按录入方案构造 **一份** 单一止盈/止损 payload 并广播 | | **多套账户** | 可向多个执行器进程广播,各绑不同 Gate API,用于盈亏比等规则的对照实验 | | **规则在执行器** | 最低盈亏比、仓位、移动保本等 **不在扫描端** 区分,由各执行器自行配置 | | **统一 Webhook** | 全系统使用 **同一个** `webhook_secret` | @@ -66,10 +66,11 @@ flowchart LR ### 3.4 转发逻辑 -1. `build_order_executor_payload()` 仍只构建 **一次**(与企微方案 A 一致)。 -2. 对 `enabled=true` 的列表项 **并行** `POST {base_url}/v1/signal`。 -3. **同一 `signal_id`** 发往所有目标。 -4. 部分失败只记日志,不阻断其他执行器。 +1. **关键位**:`build_key_executor_payload()` 使用录入上下沿与标准/趋势规则计算的 SL/TP(确认收盘 E 为 `reference_price`)。 +2. **全市场 TRIGGER**:默认 **不** 转发(`key_monitor.auto_scan_forward_executor: false`);若开启则仍用扫描箱体 metrics 构造 payload。 +3. 对 `enabled=true` 的列表项 **并行** `POST {base_url}/v1/signal`。 +4. **同一 `signal_id`** 发往所有目标。 +5. 部分失败只记日志,不阻断其他执行器。 ### 3.5 Web 面板 diff --git a/onchain_scout_gate/static/app.js b/onchain_scout_gate/static/app.js index ebaacaa..f63df7b 100644 --- a/onchain_scout_gate/static/app.js +++ b/onchain_scout_gate/static/app.js @@ -400,6 +400,7 @@ async function refresh() { windowHours: funnelWindowApplied, }); renderDailyReport(dailyReport); + loadKeyMonitors(); try { const oe = await fetchJson("/api/order-executors"); renderOrderExecutors(oe); @@ -621,6 +622,117 @@ async function patchOrderExecutor(id, body) { }); } +function syncKeySlTpFields() { + const mode = getInputText("keySlTpModeInput") || "standard"; + const tpEl = document.getElementById("keyManualTpInput"); + if (tpEl) tpEl.style.display = mode === "trend_manual" ? "" : "none"; +} + +function renderKeyMonitors(data) { + const rule = document.getElementById("keyMonitorRule"); + if (rule && data.rule_text) rule.textContent = `// ${data.rule_text}`; + + const active = data.active || []; + renderItems("keyMonitorActive", active, (row) => { + const prev = row.preview || {}; + const checks = prev.checks || {}; + const gateOk = prev.gate_ok ? '门控通过' : '门控未过'; + const dir = row.direction === "long" ? "做多" : "做空"; + const modeLabel = row.sl_tp_mode === "trend_manual" ? "趋势突破" : "标准突破"; + return ` +
${row.symbol} ${dir} · ${row.monitor_type} · ${modeLabel}
+
上 ${row.upper} / 下 ${row.lower} · 保本 ${row.breakeven_enabled ? "开" : "关"}
+
${gateOk} · 确认收盘 ${checks.confirm_close != null ? checks.confirm_close : "—"}
+ + `; + }); + if (!active.length) { + const t = document.getElementById("keyMonitorActive"); + if (t) t.innerHTML = '
暂无监控中的关键位
'; + } + + const hist = data.history || []; + renderItems("keyMonitorHistory", hist.slice(0, 80), (h) => { + const dir = h.direction === "long" ? "多" : "空"; + const modeLabel = h.sl_tp_mode === "trend_manual" ? "趋势" : "标准"; + return ` +
${h.symbol} ${dir} · ${h.monitor_type} · ${modeLabel}
+
${h.close_reason} · RR ${h.planned_rr != null ? Number(h.planned_rr).toFixed(2) : "—"} · ${formatIsoToBeijing(h.closed_at)}
+ `; + }); + if (!hist.length) { + const t = document.getElementById("keyMonitorHistory"); + if (t) t.innerHTML = '
暂无历史
'; + } +} + +async function loadKeyMonitors() { + try { + const data = await fetchJson("/api/key-monitors"); + renderKeyMonitors(data); + } catch (e) { + console.error(e); + } +} + +async function addKeyMonitor() { + const msg = document.getElementById("keyMonitorSaveMsg"); + const symbol = getInputText("keySymbolInput"); + const direction = getInputText("keyDirectionInput"); + const monitor_type = getInputText("keyMonitorTypeInput"); + const sl_tp_mode = getInputText("keySlTpModeInput") || "standard"; + if (!symbol || !direction) { + if (msg) msg.textContent = "// 请填写币种与方向"; + return; + } + const body = { + symbol, + direction, + monitor_type, + sl_tp_mode, + upper: getInputNumber("keyUpperInput"), + lower: getInputNumber("keyLowerInput"), + breakeven_enabled: getInputCheck("keyBreakevenInput"), + }; + if (sl_tp_mode === "trend_manual") { + body.manual_take_profit = getInputNumber("keyManualTpInput"); + } + try { + await fetchJson("/api/key-monitors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (msg) msg.textContent = "// 已添加,开始 5m 门控轮询"; + loadKeyMonitors(); + } catch (error) { + if (msg) msg.textContent = `// 失败 ${error}`; + } +} + +function wireKeyMonitorPanel() { + const modeSel = document.getElementById("keySlTpModeInput"); + if (modeSel) modeSel.addEventListener("change", syncKeySlTpFields); + syncKeySlTpFields(); + const addBtn = document.getElementById("keyAddBtn"); + if (addBtn) addBtn.addEventListener("click", addKeyMonitor); + const active = document.getElementById("keyMonitorActive"); + if (active) { + active.addEventListener("click", async (ev) => { + const btn = ev.target.closest && ev.target.closest(".key-del-btn"); + if (!btn) return; + const id = btn.getAttribute("data-id"); + if (!id || !confirm("删除该关键位?将写入历史。")) return; + try { + await fetchJson(`/api/key-monitors/${encodeURIComponent(id)}`, { method: "DELETE" }); + loadKeyMonitors(); + } catch (error) { + alert(String(error)); + } + }); + } +} + function wireOrderExecutorsPanel() { const saveG = document.getElementById("oeSaveGlobalBtn"); if (saveG) saveG.addEventListener("click", saveOrderExecutorsGlobal); @@ -662,8 +774,10 @@ const saveDailyBtn = document.getElementById("saveDailyReportBtn"); if (saveDailyBtn) saveDailyBtn.addEventListener("click", saveDailyReportSettings); loadIntradaySettings().catch(console.error); loadDailyReportSettings().catch(console.error); +wireKeyMonitorPanel(); wireOrderExecutorsPanel(); loadOrderExecutors().catch(console.error); +loadKeyMonitors().catch(console.error); tickClock(); setInterval(tickClock, 1000); initMatrixRain(); diff --git a/onchain_scout_gate/templates/dashboard.html b/onchain_scout_gate/templates/dashboard.html index 65b938f..da6f5bb 100644 --- a/onchain_scout_gate/templates/dashboard.html +++ b/onchain_scout_gate/templates/dashboard.html @@ -104,6 +104,52 @@
+
+
+

// 关键位突破监控

+ 人工录入 · 5m 门控 +
+

GEMMA 漏斗仅供参考;在此录入上/下沿。箱体突破与收敛突破均支持「标准突破」或「趋势突破」(无 1.5H 方案)。

+

// 规则加载中…

+
+ + + + + + + + + +
+

+
+
+

监控中

+
+
+
+
+

关键位历史

+ 导出 CSV +
+
+
+
+
+

// 每日晨报 · 昨日复盘

@@ -154,7 +200,7 @@ 仅扫描端维护 · 同一信号广播

- 企微突破推送成功后,向列表中已启用的执行器 POST /v1/signal(方案 A 止盈止损)。 + 关键位门控通过且计划 RR 达标后,向列表中已启用的执行器 POST /v1/signal(单一 SL/TP,与录入方案一致)。 各执行器自行配置 Gate API、盈亏比、移动保本等;不支持执行器反向注册。 修改 webhook 密钥后请同步到各执行器 security.webhook_secret

@@ -183,7 +229,7 @@

// 策略寄存器 · 5m

-

横盘 + 5m 收盘上破 + 放量 · 保存后下一轮生效 · 止损缓冲为企微区间A/B共用

+

全市场雷达:横盘 + 5m 突破 + 放量 · 仅参考推送 · 正式下单请用「关键位突破监控」

@@ -196,7 +242,7 @@ - + diff --git a/onchain_scout_gate/tests/test_key_sl_tp.py b/onchain_scout_gate/tests/test_key_sl_tp.py new file mode 100644 index 0000000..f10a503 --- /dev/null +++ b/onchain_scout_gate/tests/test_key_sl_tp.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import unittest + +from app.key_sl_tp import calc_planned_rr, normalize_sl_tp_mode, plan_key_sl_tp, stop_outside_pct_for_mode + + +class TestKeySlTp(unittest.TestCase): + def test_standard_long_plan(self): + checks = { + "confirm_close": 100.0, + "breakout_high": 101.0, + "breakout_low": 98.0, + } + plan = plan_key_sl_tp( + "standard", + "long", + 102.0, + 98.0, + checks, + outside_pct=0.3, + trend_outside_pct=1.0, + ) + self.assertIsNotNone(plan) + e, sl, tp, h = plan # type: ignore[misc] + self.assertEqual(e, 100.0) + self.assertEqual(h, 4.0) + self.assertEqual(tp, 104.0) + self.assertAlmostEqual(sl, 98.0 * (1 - 0.003), places=6) + + def test_trend_requires_manual_tp(self): + checks = { + "confirm_close": 100.0, + "breakout_high": 101.0, + "breakout_low": 98.0, + } + bad = plan_key_sl_tp( + "trend_manual", + "long", + 102.0, + 98.0, + checks, + outside_pct=0.3, + trend_outside_pct=1.0, + manual_take_profit=None, + ) + self.assertIsNone(bad) + ok = plan_key_sl_tp( + "trend_manual", + "long", + 102.0, + 98.0, + checks, + outside_pct=0.3, + trend_outside_pct=1.0, + manual_take_profit=110.0, + ) + self.assertIsNotNone(ok) + + def test_stop_pct_for_mode(self): + self.assertEqual(stop_outside_pct_for_mode("standard"), 0.3) + self.assertEqual(stop_outside_pct_for_mode("trend_manual"), 1.0) + self.assertEqual(normalize_sl_tp_mode("box_1p5"), "standard") + + def test_rr(self): + rr = calc_planned_rr("long", 100.0, 98.0, 104.0) + self.assertAlmostEqual(rr, 2.0, places=4) + + +if __name__ == "__main__": + unittest.main() diff --git a/onchain_scout_gate/交易系统部署说明.md b/onchain_scout_gate/交易系统部署说明.md index 06146d5..6a1e649 100644 --- a/onchain_scout_gate/交易系统部署说明.md +++ b/onchain_scout_gate/交易系统部署说明.md @@ -12,9 +12,19 @@ ## 2. 当前策略(摘要) +### 2.1 全市场 5m 扫描(雷达) + - WATCH:横盘结构成立 - TRIGGER:横盘 + 5m 收盘突破边界 + 放量 - 可调参数:横盘时长、振幅、放量倍数、回看根数、缓冲(见 Web 面板 / SQLite `kv_store`) +- 企微为**参考计划**;默认**不**转发执行器(见 `key_monitor.auto_scan_forward_executor`) + +### 2.2 关键位突破监控(半自动 · 推荐) + +- 面板 **GEMMA 漏斗** 下方 **「关键位突破监控」**:录入币种、方向、上沿/下沿。 +- **箱体突破 / 收敛突破**,各支持 **标准突破**(止损突破 K 外 0.3%,止盈 1×H)或 **趋势突破**(止损外 1%,止盈手填)。 +- 5m 硬门控通过后:企微 + 可选 `POST /v1/signal`(单一 SL/TP);历史可导出 CSV。 +- 详见 `docs/关键位突破监控说明.md`、`更新说明.md`。 ## 3. config.yaml 示例 @@ -46,7 +56,17 @@ monitor: watch_symbols: [] -# 可选:与并列项目 gate_order_executor 联动(企微推送成功后再 POST /v1/signal) +key_monitor: + enabled: true + poll_interval_seconds: 5 + push_wecom: true + forward_executor: true + standard_stop_outside_pct: 0.3 + trend_stop_outside_pct: 1.0 + min_planned_rr: 1.5 + auto_scan_forward_executor: false + +# 可选:与并列项目 gate_order_executor 联动(关键位门控通过后 POST /v1/signal) order_executor: enabled: false base_url: "http://127.0.0.1:8090" @@ -56,7 +76,9 @@ order_executor: ### 3.1 企微与自动下单 -- 默认仅 **企业微信** 文本告警。若部署 **gate_order_executor** 并设置 `order_executor.enabled: true`、**`webhook_secret` 与执行器一致**,则在 **企微推送成功之后** 自动向执行器发结构化信号(方案 A 止盈/止损与企微文案一致)。 +- **关键位**:门控与计划 RR 通过后发企微,并在 `key_monitor.forward_executor: true` 时向执行器发 **单一** SL/TP(与录入方案一致)。 +- 执行器:面板 **「下单执行器 · 转发链」** 总开关开启,**`webhook_secret` 与各执行器一致**。 +- **全市场 TRIGGER** 默认仅企微参考,不转发(`auto_scan_forward_executor: false`)。 ### 3.2 执行器联调(curl,无面板按钮) 并列项目 **`gate_order_executor`** 的 Web 面板 **不再提供**「拉取余额 / 测试市价」入口;需在服务器用 **`curl`** 或脚本调用 **`POST /api/test`**、**`POST /v1/test`** 做联调(`micro_market` 须 `gate.test_orders_enabled: true`)。**完整命令与鉴权说明**见 **`gate_order_executor/docs/使用说明.md` §4.1** 与 **`gate_order_executor/docs/部署说明.md` §11**。 diff --git a/onchain_scout_gate/更新说明.md b/onchain_scout_gate/更新说明.md new file mode 100644 index 0000000..280c772 --- /dev/null +++ b/onchain_scout_gate/更新说明.md @@ -0,0 +1,34 @@ +# onchain_scout_gate 更新说明 + +## 2026-05-22 · 关键位突破监控(定稿) + +### 新增 + +- **面板「关键位突破监控」**(位于 GEMMA 漏斗下方):人工录入币种、方向、上沿/下沿。 +- **结构类型**:箱体突破 / 收敛突破;各支持 **标准突破**、**趋势突破**(已移除「箱体 1R·止盈 1.5H」)。 +- **止盈止损规则**: + - 标准:突破 K 极值外 **0.3%** 止损,止盈 **1× 箱体高度 H** + - 趋势:突破 K 极值外 **1%** 止损,止盈 **手填** +- **5m 硬门控**:量能、突破幅度、破线、二根确认、日成交额 Top30、计划 RR > 1.5(均可 `config.yaml` → `key_monitor` 调整)。 +- **触发后**:企业微信(单一 SL/TP/RR 文案)+ 可选 `POST /v1/signal` 转发执行器;结案进 `key_monitor_history`。 +- **复盘导出**:`/export/key_monitor_history.csv?days=30`(需登录)。 + +### 调整 + +- **执行器转发**:默认仅 **关键位** 触发转发(`key_monitor.forward_executor`);全市场 5m TRIGGER 默认 **不再** 转发(`monitor` 侧 `key_monitor.auto_scan_forward_executor: false`)。 +- **企微 · 自动扫描突破**:去掉 A/B 双区间,改为参考计划说明;正式下单以关键位录入为准。 +- **执行器 payload**:关键位使用录入方案计算的 **单一** `take_profit` / `stop_loss` / `reference_price`(确认收盘 E)。 + +### 数据 + +- SQLite 新表:`key_monitors`、`key_monitor_history`(与 `crypto_monitor` 独立,不共用对方库)。 + +### 配置示例 + +见 `config.example.yaml` 中 `key_monitor` 段;详见 `docs/关键位突破监控说明.md`。 + +### 升级注意 + +1. 拉代码后重启 `onchain-scout`(或 PM2),启动时会自动建表。 +2. 在面板录入关键位前,确认 **下单执行器** 总开关、Webhook 密钥与各执行器 `security.webhook_secret` 一致。 +3. 若仅需关键位半自动,请保持 `auto_scan_forward_executor: false`。