From 75b9bffe96925bd67c2c827dea20980f57c500d7 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 27 Oct 2026 11:25:44 +0800 Subject: [PATCH] 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 --- .env.example | 6 ++ .gitignore | 1 + app/config.py | 5 + app/factory.py | 9 ++ app/routes.py | 104 +++++++++++++++++- app/scheduler.py | 100 +++++++++++++++++ app/templates/settings.html | 35 ++++++ app/wecom.py | 210 ++++++++++++++++++++++++++++++++++++ desktop/README.md | 2 +- desktop/device_store.py | 11 ++ desktop/gold_order_app.py | 86 ++++++++++++++- docs/technical.md | 9 +- docs/usage.md | 9 ++ 13 files changed, 582 insertions(+), 5 deletions(-) create mode 100644 app/scheduler.py create mode 100644 app/wecom.py diff --git a/.env.example b/.env.example index c2cc37b..4ddc8c6 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,9 @@ PORT=5800 DATABASE_URL=sqlite:///data/gold_monitor.db # 对外访问基址(激活码/下载链接展示用,部署后改成实际 IP 或域名) PUBLIC_BASE_URL=http://127.0.0.1:5800 + +# 企业微信群机器人 Webhook(也可在网页系统设置里配置,设置页优先) +# WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx +# 日报推送北京时间(默认 23:00) +# WECOM_DAILY_HOUR=23 +# WECOM_DAILY_MINUTE=0 diff --git a/.gitignore b/.gitignore index 5f6c5c2..1bc3acf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ venv/ data/*.db data/*.sqlite data/*.sqlite3 +data/wecom_daily_*.sent *.log .DS_Store .idea/ diff --git a/app/config.py b/app/config.py index c1aee97..fa50485 100644 --- a/app/config.py +++ b/app/config.py @@ -23,6 +23,11 @@ class Config: PORT = int(os.getenv("PORT", "5800")) PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://127.0.0.1:5800").rstrip("/") + # 企业微信群机器人(也可在网页「系统设置」覆盖) + WECOM_WEBHOOK_URL = os.getenv("WECOM_WEBHOOK_URL", "").strip() + WECOM_DAILY_HOUR = int(os.getenv("WECOM_DAILY_HOUR", "23")) + WECOM_DAILY_MINUTE = int(os.getenv("WECOM_DAILY_MINUTE", "0")) + _db = os.getenv("DATABASE_URL", f"sqlite:///{DATA_DIR / 'gold_monitor.db'}") if _db.startswith("sqlite:///"): # 相对路径转到仓库根 diff --git a/app/factory.py b/app/factory.py index bedad04..2ea2cb2 100644 --- a/app/factory.py +++ b/app/factory.py @@ -40,4 +40,13 @@ def create_app() -> Flask: init_db() seed_if_empty() register_routes(app) + + # 企业微信 23:00 日报(daemon 线程;多 worker 靠锁文件去重) + try: + from app.scheduler import start_background_jobs + + start_background_jobs() + except Exception: # noqa: BLE001 + pass + return app diff --git a/app/routes.py b/app/routes.py index 381d067..0727acf 100644 --- a/app/routes.py +++ b/app/routes.py @@ -157,7 +157,52 @@ def register_routes(app: Flask) -> None: @app.get("/settings") @login_required def settings_page(): - return render_template("settings.html") + from app import wecom as wecom_mod + + wecom_cfg = {"enabled": False, "webhook": "", "daily_hour": Config.WECOM_DAILY_HOUR} + if session.get("role") == "admin": + with database.get_db() as conn: + wecom_cfg = { + "enabled": wecom_mod.is_enabled(conn), + "webhook": wecom_mod.webhook_url(conn), + "daily_hour": Config.WECOM_DAILY_HOUR, + "daily_minute": Config.WECOM_DAILY_MINUTE, + "daily_last": database.get_setting(conn, wecom_mod.SETTING_DAILY_LAST, ""), + } + return render_template("settings.html", wecom=wecom_cfg) + + @app.post("/settings/wecom") + @admin_required + def settings_wecom(): + from app import wecom as wecom_mod + + enabled = "1" if request.form.get("enabled") == "1" else "0" + webhook = (request.form.get("webhook") or "").strip() + with database.get_db() as conn: + database.set_setting(conn, wecom_mod.SETTING_ENABLED, enabled) + database.set_setting(conn, wecom_mod.SETTING_WEBHOOK, webhook) + flash("企业微信推送设置已保存", "ok") + return redirect(url_for("settings_page")) + + @app.post("/settings/wecom/test") + @admin_required + def settings_wecom_test(): + from app import wecom as wecom_mod + + ok, detail = wecom_mod.send_text( + "【测试】Gold Monitor 企业微信推送正常。\n开仓/平仓即时通知 · 每日 23:00 日报" + ) + flash("测试发送成功" if ok else f"测试失败:{detail}", "ok" if ok else "error") + return redirect(url_for("settings_page")) + + @app.post("/settings/wecom/daily") + @admin_required + def settings_wecom_daily(): + from app import wecom as wecom_mod + + ok, detail = wecom_mod.send_daily_reports() + flash("日报已推送" if ok else f"日报推送失败:{detail}", "ok" if ok else "error") + return redirect(url_for("settings_page")) @app.post("/settings/profile") @login_required @@ -654,6 +699,7 @@ def register_routes(app: Flask) -> None: def api_trades_upsert(): token = request.headers.get("X-Device-Token") or "" data = request.get_json(silent=True) or {} + wecom_job = None # ("open"|"close", kwargs) 提交后再推,避免嵌套事务 with database.get_db() as conn: act = database.auth_token(conn, token) if not act: @@ -712,11 +758,24 @@ def register_routes(app: Flask) -> None: (act["account_id"], ticket), ).fetchone() + was_open = bool( + existing and existing["close_time"] in (None, "") + ) + is_new = existing is None + event_name = str((snapshot or {}).get("event") or "").strip().lower() + now_closed = close_time not in (None, "") + notify_close_price = close_price + acc_row = conn.execute( + "SELECT name FROM accounts WHERE id=?", (act["account_id"],) + ).fetchone() + account_name = (acc_row["name"] if acc_row else "") or f"账户#{act['account_id']}" + if existing: # 浮盈更新不覆盖已有平仓;空备注不覆盖已标注 keep_close = existing["close_time"] not in (None, "") and close_time in (None, "") new_close_time = existing["close_time"] if keep_close else close_time new_close_price = existing["close_price"] if keep_close else close_price + notify_close_price = new_close_price new_entry = entry_style or (existing["entry_style"] or "") new_note = review_note or (existing["review_note"] or "") new_open_time = open_time if open_time is not None else existing["open_time"] @@ -809,6 +868,49 @@ def register_routes(app: Flask) -> None: """, (dup_snap, act["account_id"], ticket, tid), ) + + # 企业微信:仅真实开仓/平仓推送(浮盈、改 SL/TP、对账假平仓不发) + if event_name == "open" and is_new: + wecom_job = ( + "open", + { + "account_name": account_name, + "ticket": ticket, + "symbol": symbol, + "side": side, + "volume": volume, + "open_price": open_price, + "sl": sl, + "tp": tp, + }, + ) + elif event_name in ("close", "history_close") and now_closed and ( + is_new or was_open + ): + wecom_job = ( + "close", + { + "account_name": account_name, + "ticket": ticket, + "symbol": symbol, + "side": side, + "volume": volume, + "open_price": open_price, + "close_price": notify_close_price, + "profit": profit, + }, + ) + if wecom_job: + try: + from app import wecom as wecom_mod + + kind, kwargs = wecom_job + if kind == "open": + wecom_mod.notify_trade_open(**kwargs) + else: + wecom_mod.notify_trade_close(**kwargs) + except Exception: + pass return jsonify({"ok": True, "id": tid}) @app.post("/api/v1/trades/reconcile") diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..07467ea --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +"""轻量后台任务:企业微信 23:00(北京时间)日报。""" +from __future__ import annotations + +import logging +import os +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from app.config import Config, DATA_DIR +from app import db as database +from app import wecom + +log = logging.getLogger("gold_monitor.scheduler") +BJT = timezone(timedelta(hours=8)) + +_started = False +_lock = threading.Lock() + + +def _try_claim_daily(day: str) -> bool: + """多 worker 下用独占锁文件抢占当日日报;成功后再写 settings 备查。""" + DATA_DIR.mkdir(parents=True, exist_ok=True) + lock_path = Path(DATA_DIR) / f"wecom_daily_{day}.sent" + try: + fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + try: + os.write(fd, f"pid={os.getpid()} ts={time_stamp()}\n".encode("utf-8")) + finally: + os.close(fd) + except FileExistsError: + return False + try: + with database.get_db() as conn: + database.set_setting(conn, wecom.SETTING_DAILY_LAST, day) + except Exception: # noqa: BLE001 + log.exception("record daily last failed") + return True + + +def time_stamp() -> str: + return datetime.now(BJT).isoformat() + + +def _daily_tick() -> None: + hour = int(Config.WECOM_DAILY_HOUR) + minute = int(Config.WECOM_DAILY_MINUTE) + now = datetime.now(BJT) + if now.hour != hour or now.minute != minute: + return + if not wecom.is_enabled() or not wecom.webhook_url(): + return + day = now.strftime("%Y-%m-%d") + if not _try_claim_daily(day): + return + lock_path = Path(DATA_DIR) / f"wecom_daily_{day}.sent" + try: + ok, detail = wecom.send_daily_reports(day) + log.info("wecom daily %s ok=%s detail=%s", day, ok, detail) + if not ok: + try: + lock_path.unlink(missing_ok=True) + except OSError: + pass + except Exception: # noqa: BLE001 + log.exception("wecom daily failed") + try: + lock_path.unlink(missing_ok=True) + except OSError: + pass + + +def _loop(stop_event: threading.Event) -> None: + # 错开启动,减少多 worker 同时抢 + jitter = (Path(DATA_DIR).stat().st_mtime_ns % 17) if DATA_DIR.exists() else 0 + stop_event.wait(3 + int(jitter) % 5) + while not stop_event.is_set(): + try: + _daily_tick() + except Exception: # noqa: BLE001 + log.exception("scheduler tick error") + stop_event.wait(20) + + +def start_background_jobs() -> None: + """幂等启动;gunicorn 多 worker 各自起线程,靠 settings 去重。""" + global _started + with _lock: + if _started: + return + _started = True + stop = threading.Event() + t = threading.Thread(target=_loop, args=(stop,), name="wecom-daily", daemon=True) + t.start() + log.info( + "wecom daily scheduler started (BJT %02d:%02d)", + int(Config.WECOM_DAILY_HOUR), + int(Config.WECOM_DAILY_MINUTE), + ) diff --git a/app/templates/settings.html b/app/templates/settings.html index 7e19e11..464e664 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -35,4 +35,39 @@

通用 exe 放置目录:releases/黄金市价辅助.exe

+ +{% if is_admin %} +
+

企业微信推送

+

+ 群机器人 Webhook:开仓/平仓即时推送;北京时间 + {{ '%02d'|format(wecom.daily_hour|int) }}:{{ '%02d'|format((wecom.daily_minute or 0)|int) }} + 推送各账户日报(交易次数、已平盈亏、胜率)。 +

+
+
+ +
+
+ + +
+ +
+
+
+ +
+
+ +
+
+ {% if wecom.daily_last %} +

上次自动日报日期:{{ wecom.daily_last }}

+ {% endif %} +
+{% endif %} {% endblock %} diff --git a/app/wecom.py b/app/wecom.py new file mode 100644 index 0000000..03c2b5e --- /dev/null +++ b/app/wecom.py @@ -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" diff --git a/desktop/README.md b/desktop/README.md index 1caf0f0..f4bda54 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -2,7 +2,7 @@ ## 功能 -- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入) +- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本(可勾选自动)、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入) - **通用包 + 首次激活**:仅第一次填写中控地址与激活码;**不允许离线运行** - **打开复盘**:用已保存的中控地址打开 `/login` - **心跳**:约每 2 秒 ping 中控,配合总览 SSE 秒级在线状态 diff --git a/desktop/device_store.py b/desktop/device_store.py index 2ba7196..0f2ef4e 100644 --- a/desktop/device_store.py +++ b/desktop/device_store.py @@ -43,9 +43,20 @@ def load_device() -> dict[str, Any]: data.setdefault("machine_name", platform.node() or "PC") data.setdefault("server", "") data.setdefault("token", "") + data.setdefault("auto_break_even", False) return data +def get_auto_break_even() -> bool: + return bool(load_device().get("auto_break_even")) + + +def set_auto_break_even(enabled: bool) -> None: + d = load_device() + d["auto_break_even"] = bool(enabled) + save_device(d) + + def save_device(data: dict[str, Any]) -> None: ensure_dir() DEVICE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/desktop/gold_order_app.py b/desktop/gold_order_app.py index de6ff03..9154a93 100644 --- a/desktop/gold_order_app.py +++ b/desktop/gold_order_app.py @@ -22,12 +22,19 @@ import MetaTrader5 as mt5 try: import hub_client from activation_dialog import ensure_activated - from device_store import is_activated, load_device + from device_store import ( + get_auto_break_even, + is_activated, + load_device, + set_auto_break_even, + ) except ImportError: # 允许单独拷贝本文件调试 hub_client = None # type: ignore ensure_activated = None # type: ignore is_activated = lambda: False # type: ignore load_device = lambda: {} # type: ignore + get_auto_break_even = lambda: False # type: ignore + set_auto_break_even = lambda _enabled: None # type: ignore APP_TITLE = "黄金市价辅助" @@ -1096,6 +1103,7 @@ class OrderApp(tk.Tk): self._lot_auto_done = False self._selected_tickets_set: set = set() self._be_done_tickets: set = set() # 已拉过保本的单号,不再判断 + self._auto_be_busy = False # 自动保本进行中,防并发改单 self._pos_sig = None self._pos_profit_labels: dict = {} self._metric_values: dict = {} @@ -1320,6 +1328,13 @@ class OrderApp(tk.Tk): self._be_ready = False self.btn_break_even = self._mk_btn(pos_bar, "保本", COLOR_BE, self.on_break_even, h=34, w=90) self.btn_break_even.pack(side=tk.RIGHT, padx=4) + self.var_auto_be = tk.BooleanVar(value=bool(get_auto_break_even())) + ttk.Checkbutton( + pos_bar, + text="自动保本", + variable=self.var_auto_be, + command=self._on_auto_be_toggle, + ).pack(side=tk.RIGHT, padx=(4, 8)) self._mk_btn(pos_bar, "打开复盘", "#3a5a80", self.on_open_review, h=34, w=100).pack( side=tk.RIGHT, padx=4 ) @@ -1384,7 +1399,8 @@ class OrderApp(tk.Tk): text=( f"最多同时 {MAX_OPEN_POSITIONS} 笔 · 同向≤{MAX_SIDE_LOTS:.2f} 手 · " f"点差>{SPREAD_WARN_USD:.2f} 买卖提示 · " - "亏≥5%或盈≥10%禁止开仓 · 持仓可单独「改止损/改止盈」 · 保本:有利≥3$ 后止损到开仓价±1$" + "亏≥5%或盈≥10%禁止开仓 · 持仓可单独「改止损/改止盈」 · " + "保本:有利≥3$ 后止损到开仓价±1$(可勾选自动保本)" ), bg=COLOR_BG, fg=COLOR_MUTED, @@ -1743,6 +1759,72 @@ class OrderApp(tk.Tk): except Exception: ok = False self._set_be_button_enabled(ok) + if ok and getattr(self, "var_auto_be", None) is not None and self.var_auto_be.get(): + self._maybe_auto_break_even() + + def _on_auto_be_toggle(self) -> None: + enabled = bool(self.var_auto_be.get()) + try: + set_auto_break_even(enabled) + except Exception: + pass + self.set_status( + "已开启自动保本:有利≥3$ 时自动拉止损到开仓价±1$" + if enabled + else "已关闭自动保本(仍可手动点「保本」)", + ok=True, + ) + if enabled: + try: + positions = self.bridge.positions(self._symbol()) + except Exception: + positions = [] + self._maybe_auto_break_even(positions) + + def _maybe_auto_break_even(self, positions=None) -> None: + """开启自动保本且存在候选单时,后台改止损(不抢手动操作的 _busy)。""" + if self._busy or self._auto_be_busy: + return + if not getattr(self, "var_auto_be", None) or not self.var_auto_be.get(): + return + symbol = self._symbol() + + def work(): + self.bridge.connect() + pos = positions + if pos is None: + pos = self.bridge.positions(symbol) + tick = self.bridge.tick(symbol) + tickets = self._be_candidate_tickets(pos, tick) + if not tickets: + return None + msg, done = self.bridge.break_even_tickets(tickets, BE_OFFSET_USD) + self._be_done_tickets.update(done) + return f"自动保本:{msg}" + + def after_ok(msg: str) -> None: + self.set_status(msg, ok=True) + self._pos_sig = None + try: + digits = int(self.bridge.symbol_info(symbol).digits) + except Exception: + digits = 3 + self._refresh_positions(symbol, digits) + + def runner(): + try: + msg = work() + if msg: + self.after(0, lambda m=msg: after_ok(m)) + except TradeError as exc: + self.after(0, lambda: self.set_status(f"自动保本:{exc}", ok=False)) + except Exception as exc: # noqa: BLE001 + self.after(0, lambda: self.set_status(f"自动保本异常:{exc}", ok=False)) + finally: + self._auto_be_busy = False + + self._auto_be_busy = True + threading.Thread(target=runner, daemon=True).start() def _hub_upload_bars(self, symbol: str) -> None: if hub_client is None or not is_activated(): diff --git a/docs/technical.md b/docs/technical.md index bb9d644..558d590 100644 --- a/docs/technical.md +++ b/docs/technical.md @@ -137,7 +137,14 @@ Header:`X-Device-Token` 开仓/平仓成功返回 `(消息, report_dict)`,后台线程上报 trades + 四周期 bars。 -## 9. 后续扩展(规划) +## 9. 企业微信推送 + +- 模块:`app/wecom.py`(Webhook 文本消息);`app/scheduler.py`(北京时间日报,默认 23:00) +- 配置:网页系统设置(管理员)或 `.env` 的 `WECOM_WEBHOOK_URL` / `WECOM_DAILY_HOUR` +- 触发:`POST /api/v1/trades` 且 `snapshot.event` 为 `open` / `close` / `history_close`(浮盈与改单不推) +- 多 worker:日报用 `data/wecom_daily_YYYY-MM-DD.sent` 锁文件去重 + +## 10. 后续扩展(规划) - 复盘筛选、统计面板、AI 点评(可对齐 crypto_monitor) - PostgreSQL 替换 SQLite(多写高并发时) diff --git a/docs/usage.md b/docs/usage.md index cdb8511..beefbff 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -56,6 +56,15 @@ 2. 打开已激活的辅助 exe 下单 / 保本 / 平仓 3. 需要复盘时点 exe 上的 **打开复盘**,浏览器登录网页账号即可 4. 客户端在成交或平仓时自动上报(接口见技术文档) +5. 持仓旁可勾选 **自动保本**:价格有利走出 ≥3$ 后,自动把止损拉到开仓价 ±1$(与手动「保本」同一规则;勾选状态保存在本机) + +## 4.1 企业微信推送(管理员) + +1. 企业微信群 → 添加群机器人 → 复制 Webhook +2. 网页 **系统设置** → 填写 Webhook → 勾选启用 → 保存 +3. 可用「发送测试消息」「立即推送今日日报」验证 +4. 生效后:客户端上报的 **开仓 / 平仓** 即时推送;北京时间 **23:00** 自动推送各账户日报(交易次数、已平盈亏、胜率) +5. 也可在服务器 `.env` 写 `WECOM_WEBHOOK_URL=...`(设置页保存的地址优先) ## 5. 网页复盘