Add auto break-even toggle and WeCom hub notifications.
Desktop can auto-pull stop to open+/-1 after a 3$ favorable move; hub pushes open/close events and a 23:00 BJT daily summary via webhook. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+210
@@ -0,0 +1,210 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""企业微信群机器人 Webhook 推送。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Optional
|
||||
|
||||
from app import db as database
|
||||
from app.config import Config
|
||||
|
||||
log = logging.getLogger("gold_monitor.wecom")
|
||||
|
||||
SETTING_WEBHOOK = "wecom_webhook_url"
|
||||
SETTING_ENABLED = "wecom_enabled"
|
||||
SETTING_DAILY_LAST = "wecom_daily_last"
|
||||
|
||||
|
||||
def is_enabled(conn: Optional[Any] = None) -> bool:
|
||||
def _read(c) -> bool:
|
||||
flag = database.get_setting(c, SETTING_ENABLED, "")
|
||||
if flag == "0":
|
||||
return False
|
||||
if flag == "1":
|
||||
return True
|
||||
# 未配置开关时:有 Webhook 即视为开启
|
||||
return bool(webhook_url(c))
|
||||
|
||||
if conn is not None:
|
||||
return _read(conn)
|
||||
with database.get_db() as c:
|
||||
return _read(c)
|
||||
|
||||
|
||||
def webhook_url(conn: Optional[Any] = None) -> str:
|
||||
def _read(c) -> str:
|
||||
url = (database.get_setting(c, SETTING_WEBHOOK, "") or "").strip()
|
||||
if url:
|
||||
return url
|
||||
return (Config.WECOM_WEBHOOK_URL or "").strip()
|
||||
|
||||
if conn is not None:
|
||||
return _read(conn)
|
||||
with database.get_db() as c:
|
||||
return _read(c)
|
||||
|
||||
|
||||
def send_text(content: str, mentioned: Optional[list[str]] = None) -> tuple[bool, str]:
|
||||
"""发送文本消息。返回 (ok, detail)。"""
|
||||
text = (content or "").strip()
|
||||
if not text:
|
||||
return False, "空消息"
|
||||
with database.get_db() as conn:
|
||||
if not is_enabled(conn):
|
||||
return False, "未启用"
|
||||
url = webhook_url(conn)
|
||||
if not url:
|
||||
return False, "未配置 Webhook"
|
||||
body: dict[str, Any] = {
|
||||
"msgtype": "text",
|
||||
"text": {"content": text[:2048]},
|
||||
}
|
||||
if mentioned:
|
||||
body["text"]["mentioned_list"] = mentioned
|
||||
raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=raw,
|
||||
headers={"Content-Type": "application/json; charset=utf-8"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
payload = resp.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return True, payload[:200]
|
||||
errcode = int(data.get("errcode", 0) or 0)
|
||||
if errcode != 0:
|
||||
return False, data.get("errmsg") or payload[:200]
|
||||
return True, "ok"
|
||||
except urllib.error.HTTPError as exc:
|
||||
return False, f"HTTP {exc.code}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _side_cn(side: str) -> str:
|
||||
s = (side or "").lower()
|
||||
if s == "buy":
|
||||
return "买入"
|
||||
if s == "sell":
|
||||
return "卖出"
|
||||
return side or "—"
|
||||
|
||||
|
||||
def notify_trade_open(
|
||||
account_name: str,
|
||||
ticket: str,
|
||||
symbol: str,
|
||||
side: str,
|
||||
volume: float,
|
||||
open_price: float,
|
||||
sl: Any = None,
|
||||
tp: Any = None,
|
||||
) -> None:
|
||||
sl_txt = f"{float(sl):.3f}" if sl not in (None, "") else "—"
|
||||
tp_txt = f"{float(tp):.3f}" if tp not in (None, "") else "—"
|
||||
msg = (
|
||||
f"【开仓】{account_name or '账户'}\n"
|
||||
f"#{ticket} {_side_cn(side)} {volume:.2f} 手 {symbol}\n"
|
||||
f"开仓价 {float(open_price):.3f} SL {sl_txt} TP {tp_txt}"
|
||||
)
|
||||
ok, detail = send_text(msg)
|
||||
if not ok:
|
||||
log.info("wecom open skip/fail: %s", detail)
|
||||
|
||||
|
||||
def notify_trade_close(
|
||||
account_name: str,
|
||||
ticket: str,
|
||||
symbol: str,
|
||||
side: str,
|
||||
volume: float,
|
||||
open_price: float,
|
||||
close_price: Any,
|
||||
profit: Any,
|
||||
) -> None:
|
||||
px = f"{float(close_price):.3f}" if close_price not in (None, "") else "—"
|
||||
try:
|
||||
pnl = float(profit)
|
||||
pnl_txt = f"{pnl:+.2f}"
|
||||
except (TypeError, ValueError):
|
||||
pnl_txt = "—"
|
||||
msg = (
|
||||
f"【平仓】{account_name or '账户'}\n"
|
||||
f"#{ticket} {_side_cn(side)} {volume:.2f} 手 {symbol}\n"
|
||||
f"开 {float(open_price):.3f} → 平 {px} 盈亏 {pnl_txt}"
|
||||
)
|
||||
ok, detail = send_text(msg)
|
||||
if not ok:
|
||||
log.info("wecom close skip/fail: %s", detail)
|
||||
|
||||
|
||||
def format_daily_account(account_name: str, day: str, stats: dict[str, Any]) -> str:
|
||||
closed = int(stats.get("closed") or 0)
|
||||
open_pos = int(stats.get("open_pos") or 0)
|
||||
trade_n = closed + open_pos
|
||||
profit = float(stats.get("profit") or 0.0)
|
||||
wins = int(stats.get("wins") or 0)
|
||||
losses = int(stats.get("losses") or 0)
|
||||
be = int(stats.get("breakeven") or 0)
|
||||
wr = float(stats.get("win_rate") or 0.0)
|
||||
return (
|
||||
f"【黄金日报】{account_name or '账户'} · {day}\n"
|
||||
f"当日交易:{trade_n} 笔(已平 {closed} · 持仓 {open_pos})\n"
|
||||
f"已平盈亏:{profit:+.2f}\n"
|
||||
f"胜率:{wr:.0f}%({wins}胜{losses}负{be}平)"
|
||||
)
|
||||
|
||||
|
||||
def send_daily_reports(day: Optional[str] = None) -> tuple[bool, str]:
|
||||
"""按账户推送北京时间当日汇总。"""
|
||||
with database.get_db() as conn:
|
||||
if not is_enabled(conn):
|
||||
return False, "未启用"
|
||||
if not webhook_url(conn):
|
||||
return False, "未配置 Webhook"
|
||||
accounts = database.list_accounts(conn)
|
||||
if not accounts:
|
||||
return False, "无账户"
|
||||
start_ts, end_ts, day_label = database.day_bounds_bjt(day)
|
||||
_ = start_ts, end_ts
|
||||
lines: list[str] = []
|
||||
any_trade = False
|
||||
for acc in accounts:
|
||||
aid = int(acc["id"])
|
||||
stats = database.trade_stats(conn, account_id=aid, day=day_label, period="today")
|
||||
closed = int(stats.get("closed") or 0)
|
||||
open_pos = int(stats.get("open_pos") or 0)
|
||||
if closed == 0 and open_pos == 0:
|
||||
continue
|
||||
any_trade = True
|
||||
lines.append(format_daily_account(acc.get("name") or f"#{aid}", day_label, stats))
|
||||
if not any_trade:
|
||||
lines.append(f"【黄金日报】{day_label}\n今日各账户无成交记录。")
|
||||
# 多账户合并一条,避免刷屏;过长则拆分
|
||||
chunks: list[str] = []
|
||||
buf = ""
|
||||
for block in lines:
|
||||
if not buf:
|
||||
buf = block
|
||||
elif len(buf) + len(block) + 2 < 1800:
|
||||
buf = buf + "\n\n" + block
|
||||
else:
|
||||
chunks.append(buf)
|
||||
buf = block
|
||||
if buf:
|
||||
chunks.append(buf)
|
||||
|
||||
ok_all = True
|
||||
details = []
|
||||
for chunk in chunks:
|
||||
ok, detail = send_text(chunk)
|
||||
ok_all = ok_all and ok
|
||||
details.append(detail)
|
||||
return ok_all, "; ".join(details) if details else "empty"
|
||||
Reference in New Issue
Block a user