增加关键位人工输入

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
+23
View File
@@ -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)
+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)
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)
+2
View File
@@ -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
+19 -8
View File
@@ -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(
+185 -1
View File
@@ -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(),
}
+198 -2
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.gzip import GZipMiddleware
@@ -17,6 +17,14 @@ from starlette.middleware.sessions import SessionMiddleware
from .config import Settings
from .daily_report import DailyReportService
from .gemma_client import OllamaGemmaClient
from .key_monitor_service import KeyMonitorService
from .key_sl_tp import (
KEY_MONITOR_TYPES,
normalize_monitor_type,
normalize_sl_tp_mode,
sl_tp_mode_label,
stop_outside_pct_for_mode,
)
from .monitor import MonitorService
from .notifier import WeComNotifier
from .gate import GateClient
@@ -33,6 +41,7 @@ from .storage import Storage
LOGGER = logging.getLogger("onchain_scout.web")
FIXED_BAR = "5m"
DAILY_REPORT_JOB_ID = "daily_report_job"
KEY_MONITOR_JOB_ID = "key_monitor_poll"
FUNNEL_DISPLAY_HOURS_DEFAULT = 24.0
FUNNEL_DISPLAY_HOURS_MIN = 1.0
FUNNEL_DISPLAY_HOURS_MAX = 168.0
@@ -194,6 +203,12 @@ def create_app(settings: Settings) -> FastAPI:
notifier=notifier,
gemma_client=gemma_client,
)
key_monitor = KeyMonitorService(
settings=settings,
storage=storage,
gate=gate_client,
notifier=notifier,
)
daily_report = DailyReportService(
settings=settings,
storage=storage,
@@ -206,6 +221,8 @@ def create_app(settings: Settings) -> FastAPI:
app.state.settings = settings
app.state.storage = storage
app.state.monitor = monitor
app.state.key_monitor = key_monitor
app.state.gate_client = gate_client
app.state.scheduler = scheduler
app.state.auth_user = settings.auth.username
app.state.auth_password_hash = _hash_password(settings.auth.password)
@@ -219,6 +236,15 @@ def create_app(settings: Settings) -> FastAPI:
await _ensure_runtime_defaults(storage)
monitor.state.chart_bar = FIXED_BAR
scheduler.add_job(monitor.run_cycle, "interval", seconds=settings.app.poll_interval_seconds, max_instances=1)
if settings.key_monitor.enabled:
scheduler.add_job(
key_monitor.run_poll,
"interval",
seconds=settings.key_monitor.poll_interval_seconds,
max_instances=1,
id=KEY_MONITOR_JOB_ID,
replace_existing=True,
)
dr = await _get_daily_report_settings(storage, settings)
if dr["enabled"]:
hh, mm = _parse_hhmm(str(dr["run_time_cn"]))
@@ -242,7 +268,8 @@ def create_app(settings: Settings) -> FastAPI:
f"service_started_gate_usdt gemma={'on' if settings.gemma.enabled else 'off'} "
f"proxy={'on ' + settings.proxy.url if settings.proxy.enabled else 'off'} "
f"web_login={'on' if settings.auth.enabled else 'off'} "
f"daily_report={'on' if settings.daily_report.enabled else 'off'}"
f"daily_report={'on' if settings.daily_report.enabled else 'off'} "
f"key_monitor={'on' if settings.key_monitor.enabled else 'off'}"
),
)
LOGGER.info("Service started")
@@ -314,6 +341,7 @@ def create_app(settings: Settings) -> FastAPI:
"intraday_settings": intraday,
"gemma_enabled": settings.gemma.enabled,
"gemma_model": settings.gemma.model,
"key_monitor": settings.key_monitor.model_dump(),
}
)
@@ -498,6 +526,173 @@ def create_app(settings: Settings) -> FastAPI:
)
return JSONResponse({"ok": True, "symbol_blocklist_settings": await _get_symbol_blocklist_settings(storage)})
def _key_rule_text() -> str:
km = settings.key_monitor
return (
f"周期 5m|突破K/确认K:倒数第2/第1根闭合K|量能:突破K量 > 前{km.volume_ma_bars}均量×{km.volume_ratio_min}"
f"计划RR须 > {km.min_planned_rr:g}|日成交额排名前{km.daily_volume_rank_max}"
f"箱体/收敛方案:标准突破(止损突破K外{km.standard_stop_outside_pct:g}%|止盈1×H)或 "
f"趋势突破(止损突破K外{km.trend_stop_outside_pct:g}%|止盈手填)|"
f"触发后企微+{'转发执行器' if km.forward_executor else '不转发执行器'}"
)
@app.get("/api/key-monitors")
async def api_key_monitors_list(_: None = Depends(require_login)) -> JSONResponse:
rows = await storage.list_key_monitors()
enriched = []
for row in rows:
preview = await key_monitor.preview_row(row)
enriched.append({**row, "preview": preview})
history = await storage.list_key_monitor_history(limit=200)
return JSONResponse(
{
"active": enriched,
"history": history,
"rule_text": _key_rule_text(),
"config": settings.key_monitor.model_dump(),
}
)
@app.post("/api/key-monitors")
async def api_key_monitors_add(request: Request, _: None = Depends(require_login)) -> JSONResponse:
body = await request.json()
sym = _normalize_symbol_token(body.get("symbol"))
if not sym:
return JSONResponse({"ok": False, "detail": "invalid symbol"}, status_code=400)
inst = gate_client.symbol_to_swap_inst_id(sym)
monitor_type = normalize_monitor_type(body.get("monitor_type"))
if monitor_type not in KEY_MONITOR_TYPES:
return JSONResponse({"ok": False, "detail": "invalid monitor_type"}, status_code=400)
direction = str(body.get("direction") or "").strip().lower()
if direction not in ("long", "short"):
return JSONResponse({"ok": False, "detail": "direction must be long or short"}, status_code=400)
try:
upper = float(body.get("upper"))
lower = float(body.get("lower"))
except (TypeError, ValueError):
return JSONResponse({"ok": False, "detail": "upper/lower required"}, status_code=400)
if upper <= lower:
return JSONResponse({"ok": False, "detail": "upper must be > lower"}, status_code=400)
sl_tp_mode = normalize_sl_tp_mode(body.get("sl_tp_mode"))
manual_tp = body.get("manual_take_profit")
manual_tp_f: float | None = None
if sl_tp_mode == "trend_manual":
try:
manual_tp_f = float(manual_tp)
except (TypeError, ValueError):
return JSONResponse({"ok": False, "detail": "manual_take_profit required for trend"}, status_code=400)
stop_pct = stop_outside_pct_for_mode(sl_tp_mode)
if sl_tp_mode == "standard":
stop_pct = float(settings.key_monitor.standard_stop_outside_pct)
else:
stop_pct = float(settings.key_monitor.trend_stop_outside_pct)
be = 1 if str(body.get("breakeven_enabled") or "").lower() in ("1", "true", "on", "yes") else 0
kid = await storage.add_key_monitor(
symbol=sym,
inst_id=inst,
monitor_type=monitor_type,
direction=direction,
upper=upper,
lower=lower,
sl_tp_mode=sl_tp_mode,
manual_take_profit=manual_tp_f,
stop_outside_pct=stop_pct,
breakeven_enabled=be,
note=str(body.get("note") or "")[:500] or None,
)
await storage.add_log(
"INFO",
f"key_monitor_added id={kid} sym={sym} type={monitor_type} mode={sl_tp_mode} dir={direction}",
)
return JSONResponse({"ok": True, "id": kid})
@app.delete("/api/key-monitors/{kid}")
async def api_key_monitors_delete(kid: int, _: None = Depends(require_login)) -> JSONResponse:
row = await storage.get_key_monitor(kid)
if not row:
return JSONResponse({"ok": False, "detail": "not_found"}, status_code=404)
await storage.finalize_key_monitor(
row,
close_reason="manual",
last_alert_message=None,
confirm_close=None,
planned_sl=None,
planned_tp=None,
planned_rr=None,
executor_signal_id=None,
executor_status=None,
checks=None,
)
await storage.add_log("INFO", f"key_monitor_manual_close id={kid} sym={row.get('symbol')}")
return JSONResponse({"ok": True})
@app.delete("/api/key-monitors/history/{hid}")
async def api_key_history_delete(hid: int, _: None = Depends(require_login)) -> JSONResponse:
ok = await storage.delete_key_monitor_history(hid)
if not ok:
return JSONResponse({"ok": False, "detail": "not_found"}, status_code=404)
return JSONResponse({"ok": True})
@app.get("/export/key_monitor_history.csv")
async def export_key_monitor_history(
days: int = 30,
_: None = Depends(require_login),
) -> StreamingResponse:
days = max(1, min(365, int(days)))
end_utc = datetime.utcnow()
start_utc = end_utc - timedelta(days=days)
rows = await storage.export_key_monitor_history_rows(start_utc=start_utc, end_utc=end_utc)
import csv
import io
buf = io.StringIO()
head = [
"id",
"symbol",
"monitor_type",
"direction",
"sl_tp_mode",
"upper",
"lower",
"confirm_close",
"planned_sl",
"planned_tp",
"planned_rr",
"executor_signal_id",
"executor_status",
"close_reason",
"closed_at",
]
w = csv.writer(buf)
w.writerow(head)
for r in rows:
w.writerow(
[
r.get("id"),
r.get("symbol"),
r.get("monitor_type"),
r.get("direction"),
r.get("sl_tp_mode"),
r.get("upper"),
r.get("lower"),
r.get("confirm_close"),
r.get("planned_sl"),
r.get("planned_tp"),
r.get("planned_rr"),
r.get("executor_signal_id"),
r.get("executor_status"),
r.get("close_reason"),
r.get("closed_at"),
]
)
day = datetime.utcnow().strftime("%Y%m%d")
content = buf.getvalue().encode("utf-8-sig")
return StreamingResponse(
iter([content]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="key_monitor_history_{day}.csv"'},
)
@app.get("/api/alerts")
async def api_alerts(_: None = Depends(require_login)) -> JSONResponse:
alerts = await storage.get_recent_alerts(limit=120)
@@ -541,6 +736,7 @@ def create_app(settings: Settings) -> FastAPI:
"url": settings.proxy.url if settings.proxy.enabled else "",
},
"order_executor": read_snapshot(settings),
"key_monitor": settings.key_monitor.model_dump(),
"watch_symbols": symbols,
}
)