增加关键位人工输入
This commit is contained in:
@@ -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"]))
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user