增加关键位人工输入
This commit is contained in:
@@ -43,11 +43,13 @@ flowchart LR
|
||||
ex2 --> gate3[Gate 私有 API 账户 B]
|
||||
```
|
||||
|
||||
1. 扫描端发现 **TRIGGER** 且通过推送门控 → **企业微信** 告警。
|
||||
2. 企微成功后 → 向面板中 **已启用** 的执行器 `POST /v1/signal`(方案 A 止盈/止损,同一 `signal_id`)。
|
||||
3. 各执行器自行决定是否接单(最低盈亏比等),**规则不在扫描端区分**。
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 区间)。"
|
||||
)
|
||||
@@ -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})"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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"]))
|
||||
@@ -243,6 +269,7 @@ def create_app(settings: Settings) -> FastAPI:
|
||||
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"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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 千万以缩小扫描面
|
||||
|
||||
@@ -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,用于盈亏比等规则的对照实验 |
|
||||
| **规则在执行器** | 最低盈亏比、仓位、移动保本等 **不在扫描端** 区分,由各执行器自行配置 |
|
||||
| **统一 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 面板
|
||||
|
||||
|
||||
@@ -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 ? '<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() {
|
||||
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();
|
||||
|
||||
@@ -104,6 +104,52 @@
|
||||
<div id="funnelMatrix" class="matrix-grid"></div>
|
||||
</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">
|
||||
<div class="matrix-panel-head matrix-panel-head-row">
|
||||
<h2>// 每日晨报 · 昨日复盘</h2>
|
||||
@@ -154,7 +200,7 @@
|
||||
<span class="matrix-chip matrix-dim">仅扫描端维护 · 同一信号广播</span>
|
||||
</div>
|
||||
<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>。
|
||||
修改 webhook 密钥后请同步到各执行器 <code>security.webhook_secret</code>。
|
||||
</p>
|
||||
@@ -183,7 +229,7 @@
|
||||
|
||||
<section class="matrix-panel matrix-panel-chrome">
|
||||
<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">
|
||||
<label>横盘时长(h)</label>
|
||||
<input id="rangeHoursInput" type="number" step="0.5" min="1" />
|
||||
@@ -196,7 +242,7 @@
|
||||
<label>突破缓冲(%)</label>
|
||||
<input id="breakoutBufferInput" type="number" step="0.01" min="0" />
|
||||
<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>
|
||||
<input id="pushTimeWindowEnabledInput" type="checkbox" />
|
||||
<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()
|
||||
@@ -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**。
|
||||
|
||||
@@ -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`。
|
||||
Reference in New Issue
Block a user