增加关键位人工输入

This commit is contained in:
dekun
2026-05-22 22:15:46 +08:00
parent 593f8fcff5
commit ac762b540c
20 changed files with 1541 additions and 42 deletions
+5 -3
View File
@@ -43,11 +43,13 @@ flowchart LR
ex2 --> gate3[Gate 私有 API 账户 B] ex2 --> gate3[Gate 私有 API 账户 B]
``` ```
1. 扫描端发现 **TRIGGER** 且通过推送门控 → **企业微信** 告警 1. **GEMMA 漏斗** 供参考;在扫描端 **「关键位突破监控」** 录入上下沿(箱体/收敛 × 标准/趋势)
2. 企微成功后 → 向面板中 **已启用**执行器 `POST /v1/signal`方案 A 止盈/止损,同一 `signal_id`)。 2. 5m 门控通过 → **企业微信**(单一 SL/TP)→ 可选向已启用执行器 `POST /v1/signal`(同一 `signal_id` 可广播多账户)。
3. 各执行器自行决定是否接单(最低盈亏比等),**规则不在扫描端区分** 3. 全市场 5m TRIGGER 默认 **不** 转发执行器(`key_monitor.auto_scan_forward_executor: false`
4. 转发请求 **不走** 扫描端 `proxy`,直连各执行器 `base_url` 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) 设计归档:[onchain_scout_gate/docs/多执行器与信号转发归档.md](onchain_scout_gate/docs/多执行器与信号转发归档.md)
--- ---
+11 -1
View File
@@ -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` 关闭。 - **`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 ### 自动下单(gate_order_executor
- **下单执行器**:在 Web 面板 **「下单执行器 · 转发链」** 维护列表(`runtime/order_executors.json`),支持运行中增删;首次启动可从 `config.yaml``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`,便于同机访问执行器。 - 该 HTTP 请求 **不走** `proxy.url`,便于同机访问执行器。
全市场模式下扫描量较大,建议把 `poll_interval_seconds` 调到 **300 秒或更长**,并遵守 Gate 公开频率限制。 全市场模式下扫描量较大,建议把 `poll_interval_seconds` 调到 **300 秒或更长**,并遵守 Gate 公开频率限制。
@@ -54,6 +61,9 @@ onchain_scout_gate/
gemma_client.py gemma_client.py
notifier.py notifier.py
order_executor_forward.py order_executor_forward.py
key_monitor_service.py
key_gate.py
key_sl_tp.py
storage.py storage.py
models.py models.py
config.py config.py
+23
View File
@@ -69,6 +69,28 @@ class WatchSymbol(BaseModel):
symbol: str 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): class MonitorConfig(BaseModel):
""" """
监控侧过滤。 监控侧过滤。
@@ -131,6 +153,7 @@ class Settings(BaseModel):
proxy: ProxyConfig = Field(default_factory=ProxyConfig) proxy: ProxyConfig = Field(default_factory=ProxyConfig)
order_executor: OrderExecutorConfig = Field(default_factory=OrderExecutorConfig) order_executor: OrderExecutorConfig = Field(default_factory=OrderExecutorConfig)
monitor: MonitorConfig = Field(default_factory=MonitorConfig) monitor: MonitorConfig = Field(default_factory=MonitorConfig)
key_monitor: KeyMonitorConfig = Field(default_factory=KeyMonitorConfig)
gemma: GemmaConfig = Field(default_factory=GemmaConfig) gemma: GemmaConfig = Field(default_factory=GemmaConfig)
daily_report: DailyReportConfig = Field(default_factory=DailyReportConfig) daily_report: DailyReportConfig = Field(default_factory=DailyReportConfig)
watch_symbols: list[WatchSymbol] = Field(default_factory=list) watch_symbols: list[WatchSymbol] = Field(default_factory=list)
+99
View File
@@ -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",
]
@@ -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 区间)。"
)
+138
View File
@@ -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})"
)
+48
View File
@@ -39,3 +39,51 @@ class KvStore(Base):
key: Mapped[str] = mapped_column(String(64), primary_key=True) key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(Text) value: Mapped[str] = mapped_column(Text)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 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)
+2
View File
@@ -93,6 +93,8 @@ class MonitorService:
return frozenset(out) return frozenset(out)
async def _maybe_forward_order_executor(self, sym: str, inst: str, push_metrics: dict) -> None: 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) fwd = read_forward_config(self.settings)
if not fwd.get("enabled"): if not fwd.get("enabled"):
return return
+19 -8
View File
@@ -63,18 +63,16 @@ class WeComNotifier:
stop_pct = max(0.0, min(stop_pct, 10.0)) stop_pct = max(0.0, min(stop_pct, 10.0))
long_m = 1.0 - stop_pct / 100.0 long_m = 1.0 - stop_pct / 100.0
short_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: if abs(stop_pct - round(stop_pct)) < 1e-9:
stop_pct_label = str(int(round(stop_pct))) stop_pct_label = str(int(round(stop_pct)))
else: else:
stop_pct_label = f"{stop_pct:.4f}".rstrip("0").rstrip(".") or "0" stop_pct_label = f"{stop_pct:.4f}".rstrip("0").rstrip(".") or "0"
box_size = (range_high - range_low) box_size = (range_high - range_low)
tp_a = _px(confirm_close + box_size if signal_side == "LONG" else confirm_close - box_size) stop_sl = _px(breakout_low * long_m if signal_side == "LONG" else breakout_high * short_m)
tp_b = _px(confirm_close + box_size * 1.5 if signal_side == "LONG" else confirm_close - box_size * 1.5) tp_sl = _px(confirm_close + box_size if signal_side == "LONG" else confirm_close - box_size)
t_cn = format_beijing_wall(utc_now()) t_cn = format_beijing_wall(utc_now())
content = ( content = (
"🚨 Gate 突破预警信号\n" "🚨 Gate 扫描突破参考(自动箱体)\n"
"━━━━━━━━━━━━━━\n" "━━━━━━━━━━━━━━\n"
f"🔹 交易对:{pair_line}\n" f"🔹 交易对:{pair_line}\n"
f"⏱️ K线周期:{bar_cn}\n" f"⏱️ K线周期:{bar_cn}\n"
@@ -90,9 +88,10 @@ class WeComNotifier:
"📌 关键价位:\n" "📌 关键价位:\n"
f" {'箱体下沿' if signal_side == 'LONG' else '箱体上沿'}{key_ref}\n" f" {'箱体下沿' if signal_side == 'LONG' else '箱体上沿'}{key_ref}\n"
f" 确认K收盘价:{_px(confirm_close)}\n" f" 确认K收盘价:{_px(confirm_close)}\n"
"💡 操作提示\n" "💡 参考计划(非关键位录入)\n"
f" 1. 入场区间A止盈 {signal_cn} 箱体1.0倍距离{tp_a}),止损 突破K高低点±{stop_pct_label}%{stop_a}\n" f" · 止盈{signal_cn} 箱体1.0倍({tp_sl}\n"
f" 2. 入场区间B:止盈 {signal_cn} 箱体1.5倍距离({tp_b}),止损 箱体边沿±{stop_pct_label}%{stop_b}\n" f" · 止损:突破K极值外 {stop_pct_label}%{stop_sl}\n"
" · 正式下单请以「关键位突破监控」录入上下沿后的方案为准。\n"
f"⏰ 触发时间:{t_cn}(北京时间 UTC+8" f"⏰ 触发时间:{t_cn}(北京时间 UTC+8"
) )
payload = { payload = {
@@ -106,6 +105,18 @@ class WeComNotifier:
resp = await client.post(self.conf.webhook, json=payload) resp = await client.post(self.conf.webhook, json=payload)
resp.raise_for_status() 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( async def send_funnel_priority(
self, self,
symbol: str, symbol: str,
@@ -12,11 +12,57 @@ from .config import Settings
logger = logging.getLogger(__name__) 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: def build_order_executor_payload(*, inst_id: str, metrics: dict) -> dict[str, Any] | None:
""" """
与企微突破文案方案 A一致止盈 = 确认收盘 ± 1 倍箱宽止损 = 突破 K 高低点外侧 stop_buffer_pct默认 0.2%与面板一致 metrics planned_take_profit / planned_stop_loss关键位触发优先使用
返回 gate_order_executor POST /v1/signal JSON无法构造则 None 否则为自动扫描参考确认收盘 ± 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") signal_side = str(metrics.get("signal_side") or "NONE")
if signal_side not in ("LONG", "SHORT"): if signal_side not in ("LONG", "SHORT"):
return None return None
@@ -35,22 +81,18 @@ def build_order_executor_payload(*, inst_id: str, metrics: dict) -> dict[str, An
if signal_side == "LONG": if signal_side == "LONG":
stop_loss = breakout_low * long_m stop_loss = breakout_low * long_m
take_profit = confirm_close + box_size take_profit = confirm_close + box_size
direction = "long"
else: else:
stop_loss = breakout_high * short_m stop_loss = breakout_high * short_m
take_profit = confirm_close - box_size take_profit = confirm_close - box_size
if take_profit <= 0 or stop_loss <= 0: direction = "short"
return None return build_key_executor_payload(
side = "long" if signal_side == "LONG" else "short" inst_id=inst_id,
ct = inst_id.strip().upper() direction=direction,
signal_id = f"scout-{ct}-{uuid.uuid4().hex[:12]}" take_profit=take_profit,
return { stop_loss=stop_loss,
"signal_id": signal_id, reference_price=confirm_close,
"contract": ct, )
"side": side,
"take_profit": float(take_profit),
"stop_loss": float(stop_loss),
"reference_price": float(confirm_close),
}
async def _post_one_executor( async def _post_one_executor(
+185 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy import desc, select
from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine 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" DEFAULT_CHART_BAR = "1D"
@@ -152,5 +152,189 @@ class Storage:
for row in rows 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: async def close(self) -> None:
await self.engine.dispose() 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(),
}
+197 -1
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status 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.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.gzip import GZipMiddleware
@@ -17,6 +17,14 @@ from starlette.middleware.sessions import SessionMiddleware
from .config import Settings from .config import Settings
from .daily_report import DailyReportService from .daily_report import DailyReportService
from .gemma_client import OllamaGemmaClient 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 .monitor import MonitorService
from .notifier import WeComNotifier from .notifier import WeComNotifier
from .gate import GateClient from .gate import GateClient
@@ -33,6 +41,7 @@ from .storage import Storage
LOGGER = logging.getLogger("onchain_scout.web") LOGGER = logging.getLogger("onchain_scout.web")
FIXED_BAR = "5m" FIXED_BAR = "5m"
DAILY_REPORT_JOB_ID = "daily_report_job" DAILY_REPORT_JOB_ID = "daily_report_job"
KEY_MONITOR_JOB_ID = "key_monitor_poll"
FUNNEL_DISPLAY_HOURS_DEFAULT = 24.0 FUNNEL_DISPLAY_HOURS_DEFAULT = 24.0
FUNNEL_DISPLAY_HOURS_MIN = 1.0 FUNNEL_DISPLAY_HOURS_MIN = 1.0
FUNNEL_DISPLAY_HOURS_MAX = 168.0 FUNNEL_DISPLAY_HOURS_MAX = 168.0
@@ -194,6 +203,12 @@ def create_app(settings: Settings) -> FastAPI:
notifier=notifier, notifier=notifier,
gemma_client=gemma_client, gemma_client=gemma_client,
) )
key_monitor = KeyMonitorService(
settings=settings,
storage=storage,
gate=gate_client,
notifier=notifier,
)
daily_report = DailyReportService( daily_report = DailyReportService(
settings=settings, settings=settings,
storage=storage, storage=storage,
@@ -206,6 +221,8 @@ def create_app(settings: Settings) -> FastAPI:
app.state.settings = settings app.state.settings = settings
app.state.storage = storage app.state.storage = storage
app.state.monitor = monitor app.state.monitor = monitor
app.state.key_monitor = key_monitor
app.state.gate_client = gate_client
app.state.scheduler = scheduler app.state.scheduler = scheduler
app.state.auth_user = settings.auth.username app.state.auth_user = settings.auth.username
app.state.auth_password_hash = _hash_password(settings.auth.password) 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) await _ensure_runtime_defaults(storage)
monitor.state.chart_bar = FIXED_BAR monitor.state.chart_bar = FIXED_BAR
scheduler.add_job(monitor.run_cycle, "interval", seconds=settings.app.poll_interval_seconds, max_instances=1) 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) dr = await _get_daily_report_settings(storage, settings)
if dr["enabled"]: if dr["enabled"]:
hh, mm = _parse_hhmm(str(dr["run_time_cn"])) hh, mm = _parse_hhmm(str(dr["run_time_cn"]))
@@ -243,6 +269,7 @@ def create_app(settings: Settings) -> FastAPI:
f"proxy={'on ' + settings.proxy.url if settings.proxy.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"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") LOGGER.info("Service started")
@@ -314,6 +341,7 @@ def create_app(settings: Settings) -> FastAPI:
"intraday_settings": intraday, "intraday_settings": intraday,
"gemma_enabled": settings.gemma.enabled, "gemma_enabled": settings.gemma.enabled,
"gemma_model": settings.gemma.model, "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)}) 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") @app.get("/api/alerts")
async def api_alerts(_: None = Depends(require_login)) -> JSONResponse: async def api_alerts(_: None = Depends(require_login)) -> JSONResponse:
alerts = await storage.get_recent_alerts(limit=120) 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 "", "url": settings.proxy.url if settings.proxy.enabled else "",
}, },
"order_executor": read_snapshot(settings), "order_executor": read_snapshot(settings),
"key_monitor": settings.key_monitor.model_dump(),
"watch_symbols": symbols, "watch_symbols": symbols,
} }
) )
+18 -1
View File
@@ -26,7 +26,7 @@ proxy:
enabled: true enabled: true
url: "socks5h://127.0.0.1:1080" 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 面板「下单执行器」为准(可热增删,无需重启)。 # 首次启动时从本段导入 runtime/order_executors.json;之后以 Web 面板「下单执行器」为准(可热增删,无需重启)。
# 请求直连各 base_url,不走 proxy。webhook_secret 须与各执行器 security.webhook_secret 一致。 # 请求直连各 base_url,不走 proxy。webhook_secret 须与各执行器 security.webhook_secret 一致。
order_executor: order_executor:
@@ -35,6 +35,23 @@ order_executor:
webhook_secret: "same-as-gate-order-executor-security-webhook_secret" webhook_secret: "same-as-gate-order-executor-security-webhook_secret"
timeout_seconds: 15 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: monitor:
universe: "all_swaps" universe: "all_swaps"
# 近 24h 估算成交额(USDT)下限,建议 ≥ 1 千万以缩小扫描面 # 近 24h 估算成交额(USDT)下限,建议 ≥ 1 千万以缩小扫描面
@@ -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`)。
@@ -8,7 +8,7 @@
| 目标 | 说明 | | 目标 | 说明 |
|------|------| |------|------|
| **一套信号** | 扫描端在企微突破推送成功后,构造 **一份** 方案 A 止盈/止损 payload | | **一套信号** | **关键位** 5m 门控通过后,按录入方案构造 **一份** 单一止盈/止损 payload 并广播 |
| **多套账户** | 可向多个执行器进程广播,各绑不同 Gate API,用于盈亏比等规则的对照实验 | | **多套账户** | 可向多个执行器进程广播,各绑不同 Gate API,用于盈亏比等规则的对照实验 |
| **规则在执行器** | 最低盈亏比、仓位、移动保本等 **不在扫描端** 区分,由各执行器自行配置 | | **规则在执行器** | 最低盈亏比、仓位、移动保本等 **不在扫描端** 区分,由各执行器自行配置 |
| **统一 Webhook** | 全系统使用 **同一个** `webhook_secret` | | **统一 Webhook** | 全系统使用 **同一个** `webhook_secret` |
@@ -66,10 +66,11 @@ flowchart LR
### 3.4 转发逻辑 ### 3.4 转发逻辑
1. `build_order_executor_payload()` 仍只构建 **一次**(与企微方案 A 一致)。 1. **关键位**`build_key_executor_payload()` 使用录入上下沿与标准/趋势规则计算的 SL/TP(确认收盘 E 为 `reference_price`)。
2. `enabled=true` 的列表项 **并行** `POST {base_url}/v1/signal` 2. **全市场 TRIGGER**:默认 **** 转发(`key_monitor.auto_scan_forward_executor: false`);若开启则仍用扫描箱体 metrics 构造 payload
3. **同一 `signal_id`** 发往所有目标 3. `enabled=true` 的列表项 **并行** `POST {base_url}/v1/signal`
4. 部分失败只记日志,不阻断其他执行器 4. **同一 `signal_id`** 发往所有目标
5. 部分失败只记日志,不阻断其他执行器。
### 3.5 Web 面板 ### 3.5 Web 面板
+114
View File
@@ -400,6 +400,7 @@ async function refresh() {
windowHours: funnelWindowApplied, windowHours: funnelWindowApplied,
}); });
renderDailyReport(dailyReport); renderDailyReport(dailyReport);
loadKeyMonitors();
try { try {
const oe = await fetchJson("/api/order-executors"); const oe = await fetchJson("/api/order-executors");
renderOrderExecutors(oe); 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 ? '<span style="color:#4cd97f">门控通过</span>' : '<span style="color:#8892b0">门控未过</span>';
const dir = row.direction === "long" ? "做多" : "做空";
const modeLabel = row.sl_tp_mode === "trend_manual" ? "趋势突破" : "标准突破";
return `
<div class="matrix-list-item-head"><strong>${row.symbol}</strong> ${dir} · ${row.monitor_type} · ${modeLabel}</div>
<div class="matrix-dim"> ${row.upper} / ${row.lower} · 保本 ${row.breakeven_enabled ? "开" : "关"}</div>
<div class="matrix-dim">${gateOk} · 确认收盘 ${checks.confirm_close != null ? checks.confirm_close : "—"}</div>
<button type="button" class="matrix-btn ghost key-del-btn" data-id="${row.id}" style="margin-top:6px">删除并归档</button>
`;
});
if (!active.length) {
const t = document.getElementById("keyMonitorActive");
if (t) t.innerHTML = '<div class="matrix-dim">暂无监控中的关键位</div>';
}
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 `
<div><strong>${h.symbol}</strong> ${dir} · ${h.monitor_type} · ${modeLabel}</div>
<div class="matrix-dim">${h.close_reason} · RR ${h.planned_rr != null ? Number(h.planned_rr).toFixed(2) : "—"} · ${formatIsoToBeijing(h.closed_at)}</div>
`;
});
if (!hist.length) {
const t = document.getElementById("keyMonitorHistory");
if (t) t.innerHTML = '<div class="matrix-dim">暂无历史</div>';
}
}
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() { function wireOrderExecutorsPanel() {
const saveG = document.getElementById("oeSaveGlobalBtn"); const saveG = document.getElementById("oeSaveGlobalBtn");
if (saveG) saveG.addEventListener("click", saveOrderExecutorsGlobal); if (saveG) saveG.addEventListener("click", saveOrderExecutorsGlobal);
@@ -662,8 +774,10 @@ const saveDailyBtn = document.getElementById("saveDailyReportBtn");
if (saveDailyBtn) saveDailyBtn.addEventListener("click", saveDailyReportSettings); if (saveDailyBtn) saveDailyBtn.addEventListener("click", saveDailyReportSettings);
loadIntradaySettings().catch(console.error); loadIntradaySettings().catch(console.error);
loadDailyReportSettings().catch(console.error); loadDailyReportSettings().catch(console.error);
wireKeyMonitorPanel();
wireOrderExecutorsPanel(); wireOrderExecutorsPanel();
loadOrderExecutors().catch(console.error); loadOrderExecutors().catch(console.error);
loadKeyMonitors().catch(console.error);
tickClock(); tickClock();
setInterval(tickClock, 1000); setInterval(tickClock, 1000);
initMatrixRain(); initMatrixRain();
+49 -3
View File
@@ -104,6 +104,52 @@
<div id="funnelMatrix" class="matrix-grid"></div> <div id="funnelMatrix" class="matrix-grid"></div>
</section> </section>
<section class="matrix-panel matrix-panel-chrome" id="keyMonitorPanel">
<div class="matrix-panel-head matrix-panel-head-row">
<h2>// 关键位突破监控</h2>
<span class="matrix-chip matrix-dim">人工录入 · 5m 门控</span>
</div>
<p class="matrix-hint">GEMMA 漏斗仅供参考;在此录入上/下沿。箱体突破与收敛突破均支持「标准突破」或「趋势突破」(无 1.5H 方案)。</p>
<p id="keyMonitorRule" class="matrix-hint matrix-dim">// 规则加载中…</p>
<div class="matrix-form-row matrix-form-row-wrap">
<input id="keySymbolInput" class="matrix-input" type="text" placeholder="BTC 或 BTC/USDT" style="min-width:7rem" />
<select id="keyMonitorTypeInput" class="matrix-input">
<option value="箱体突破">箱体突破</option>
<option value="收敛突破">收敛突破</option>
</select>
<select id="keyDirectionInput" class="matrix-input">
<option value="">方向</option>
<option value="long">做多</option>
<option value="short">做空</option>
</select>
<input id="keyUpperInput" class="matrix-input" type="number" step="any" placeholder="上沿/阻力" style="min-width:6rem" />
<input id="keyLowerInput" class="matrix-input" type="number" step="any" placeholder="下沿/支撑" style="min-width:6rem" />
<select id="keySlTpModeInput" class="matrix-input" title="止盈止损方案">
<option value="standard">标准突破</option>
<option value="trend_manual">趋势突破</option>
</select>
<input id="keyManualTpInput" class="matrix-input" type="number" step="any" placeholder="趋势止盈价" style="min-width:6rem;display:none" />
<label class="matrix-hint" style="display:inline-flex;align-items:center;gap:4px">
<input id="keyBreakevenInput" type="checkbox" /> 移动保本
</label>
<button type="button" id="keyAddBtn" class="matrix-btn matrix-btn-pulse">添加监控</button>
</div>
<p id="keyMonitorSaveMsg" class="matrix-msg"></p>
<div class="matrix-two-col" style="margin-top:12px">
<div>
<h3 class="matrix-hint" style="margin-bottom:8px">监控中</h3>
<div id="keyMonitorActive" class="matrix-list"></div>
</div>
<div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px">
<h3 class="matrix-hint" style="margin:0">关键位历史</h3>
<a id="keyHistoryExport" class="matrix-btn ghost" href="/export/key_monitor_history.csv?days=30" style="text-decoration:none;font-size:.75rem">导出 CSV</a>
</div>
<div id="keyMonitorHistory" class="matrix-list"></div>
</div>
</div>
</section>
<section class="matrix-panel matrix-panel-chrome"> <section class="matrix-panel matrix-panel-chrome">
<div class="matrix-panel-head matrix-panel-head-row"> <div class="matrix-panel-head matrix-panel-head-row">
<h2>// 每日晨报 · 昨日复盘</h2> <h2>// 每日晨报 · 昨日复盘</h2>
@@ -154,7 +200,7 @@
<span class="matrix-chip matrix-dim">仅扫描端维护 · 同一信号广播</span> <span class="matrix-chip matrix-dim">仅扫描端维护 · 同一信号广播</span>
</div> </div>
<p class="matrix-hint"> <p class="matrix-hint">
企微突破推送成功后,向列表中<strong>已启用</strong>的执行器 POST <code>/v1/signal</code>方案 A 止盈止损)。 关键位门控通过且计划 RR 达标后,向列表中<strong>已启用</strong>的执行器 POST <code>/v1/signal</code>单一 SL/TP,与录入方案一致)。
各执行器自行配置 Gate API、盈亏比、移动保本等;<strong>不支持执行器反向注册</strong> 各执行器自行配置 Gate API、盈亏比、移动保本等;<strong>不支持执行器反向注册</strong>
修改 webhook 密钥后请同步到各执行器 <code>security.webhook_secret</code> 修改 webhook 密钥后请同步到各执行器 <code>security.webhook_secret</code>
</p> </p>
@@ -183,7 +229,7 @@
<section class="matrix-panel matrix-panel-chrome"> <section class="matrix-panel matrix-panel-chrome">
<div class="matrix-panel-head"><h2>// 策略寄存器 · 5m</h2></div> <div class="matrix-panel-head"><h2>// 策略寄存器 · 5m</h2></div>
<p class="matrix-hint">横盘 + 5m 收盘上破 + 放量 · 保存后下一轮生效 · 止损缓冲为企微区间A/B共用</p> <p class="matrix-hint">全市场雷达:横盘 + 5m 破 + 放量 · 仅参考推送 · 正式下单请用「关键位突破监控」</p>
<div class="matrix-form-row"> <div class="matrix-form-row">
<label>横盘时长(h)</label> <label>横盘时长(h)</label>
<input id="rangeHoursInput" type="number" step="0.5" min="1" /> <input id="rangeHoursInput" type="number" step="0.5" min="1" />
@@ -196,7 +242,7 @@
<label>突破缓冲(%)</label> <label>突破缓冲(%)</label>
<input id="breakoutBufferInput" type="number" step="0.01" min="0" /> <input id="breakoutBufferInput" type="number" step="0.01" min="0" />
<label>止损缓冲(%)</label> <label>止损缓冲(%)</label>
<input id="stopBufferPctInput" type="number" step="0.05" min="0" max="10" title="企微文案区间A/B共用:突破K极值与箱体边沿外侧缓冲" /> <input id="stopBufferPctInput" type="number" step="0.05" min="0" max="10" title="全市场扫描企微参考止损:突破K极值外侧缓冲" />
<label>启用推送时间窗(09:00-23:00)</label> <label>启用推送时间窗(09:00-23:00)</label>
<input id="pushTimeWindowEnabledInput" type="checkbox" /> <input id="pushTimeWindowEnabledInput" type="checkbox" />
<button type="button" id="saveIntradayBtn" class="matrix-btn matrix-btn-pulse">写入寄存器</button> <button type="button" id="saveIntradayBtn" class="matrix-btn matrix-btn-pulse">写入寄存器</button>
@@ -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()
+24 -2
View File
@@ -12,9 +12,19 @@
## 2. 当前策略(摘要) ## 2. 当前策略(摘要)
### 2.1 全市场 5m 扫描(雷达)
- WATCH:横盘结构成立 - WATCH:横盘结构成立
- TRIGGER:横盘 + 5m 收盘突破边界 + 放量 - TRIGGER:横盘 + 5m 收盘突破边界 + 放量
- 可调参数:横盘时长、振幅、放量倍数、回看根数、缓冲(见 Web 面板 / SQLite `kv_store` - 可调参数:横盘时长、振幅、放量倍数、回看根数、缓冲(见 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 示例 ## 3. config.yaml 示例
@@ -46,7 +56,17 @@ monitor:
watch_symbols: [] 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: order_executor:
enabled: false enabled: false
base_url: "http://127.0.0.1:8090" base_url: "http://127.0.0.1:8090"
@@ -56,7 +76,9 @@ order_executor:
### 3.1 企微与自动下单 ### 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,无面板按钮) ### 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**。 并列项目 **`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**。
+34
View File
@@ -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`