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. 网页复盘