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:
dekun
2026-10-27 11:25:44 +08:00
parent 828038a5ae
commit 75b9bffe96
13 changed files with 582 additions and 5 deletions
+6
View File
@@ -7,3 +7,9 @@ PORT=5800
DATABASE_URL=sqlite:///data/gold_monitor.db DATABASE_URL=sqlite:///data/gold_monitor.db
# 对外访问基址(激活码/下载链接展示用,部署后改成实际 IP 或域名) # 对外访问基址(激活码/下载链接展示用,部署后改成实际 IP 或域名)
PUBLIC_BASE_URL=http://127.0.0.1:5800 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
+1
View File
@@ -7,6 +7,7 @@ venv/
data/*.db data/*.db
data/*.sqlite data/*.sqlite
data/*.sqlite3 data/*.sqlite3
data/wecom_daily_*.sent
*.log *.log
.DS_Store .DS_Store
.idea/ .idea/
+5
View File
@@ -23,6 +23,11 @@ class Config:
PORT = int(os.getenv("PORT", "5800")) PORT = int(os.getenv("PORT", "5800"))
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://127.0.0.1:5800").rstrip("/") 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'}") _db = os.getenv("DATABASE_URL", f"sqlite:///{DATA_DIR / 'gold_monitor.db'}")
if _db.startswith("sqlite:///"): if _db.startswith("sqlite:///"):
# 相对路径转到仓库根 # 相对路径转到仓库根
+9
View File
@@ -40,4 +40,13 @@ def create_app() -> Flask:
init_db() init_db()
seed_if_empty() seed_if_empty()
register_routes(app) 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 return app
+103 -1
View File
@@ -157,7 +157,52 @@ def register_routes(app: Flask) -> None:
@app.get("/settings") @app.get("/settings")
@login_required @login_required
def settings_page(): 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") @app.post("/settings/profile")
@login_required @login_required
@@ -654,6 +699,7 @@ def register_routes(app: Flask) -> None:
def api_trades_upsert(): def api_trades_upsert():
token = request.headers.get("X-Device-Token") or "" token = request.headers.get("X-Device-Token") or ""
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
wecom_job = None # ("open"|"close", kwargs) 提交后再推,避免嵌套事务
with database.get_db() as conn: with database.get_db() as conn:
act = database.auth_token(conn, token) act = database.auth_token(conn, token)
if not act: if not act:
@@ -712,11 +758,24 @@ def register_routes(app: Flask) -> None:
(act["account_id"], ticket), (act["account_id"], ticket),
).fetchone() ).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: if existing:
# 浮盈更新不覆盖已有平仓;空备注不覆盖已标注 # 浮盈更新不覆盖已有平仓;空备注不覆盖已标注
keep_close = existing["close_time"] not in (None, "") and close_time in (None, "") 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_time = existing["close_time"] if keep_close else close_time
new_close_price = existing["close_price"] if keep_close else close_price 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_entry = entry_style or (existing["entry_style"] or "")
new_note = review_note or (existing["review_note"] 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"] 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), (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}) return jsonify({"ok": True, "id": tid})
@app.post("/api/v1/trades/reconcile") @app.post("/api/v1/trades/reconcile")
+100
View File
@@ -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),
)
+35
View File
@@ -35,4 +35,39 @@
<p class="muted" style="margin-top:12px">通用 exe 放置目录:<code>releases/黄金市价辅助.exe</code></p> <p class="muted" style="margin-top:12px">通用 exe 放置目录:<code>releases/黄金市价辅助.exe</code></p>
</div> </div>
</div> </div>
{% if is_admin %}
<div class="card" style="margin-top:16px">
<h3>企业微信推送</h3>
<p class="muted">
群机器人 Webhook:开仓/平仓即时推送;北京时间
{{ '%02d'|format(wecom.daily_hour|int) }}:{{ '%02d'|format((wecom.daily_minute or 0)|int) }}
推送各账户日报(交易次数、已平盈亏、胜率)。
</p>
<form method="post" action="{{ url_for('settings_wecom') }}" style="margin-top:12px">
<div class="form-row">
<label>
<input type="checkbox" name="enabled" value="1" {% if wecom.enabled %}checked{% endif %}>
启用推送
</label>
</div>
<div class="form-row">
<label>Webhook 地址</label>
<input name="webhook" value="{{ wecom.webhook }}" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." style="width:100%">
</div>
<button class="btn" type="submit">保存</button>
</form>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
<form method="post" action="{{ url_for('settings_wecom_test') }}">
<button class="btn secondary" type="submit">发送测试消息</button>
</form>
<form method="post" action="{{ url_for('settings_wecom_daily') }}">
<button class="btn secondary" type="submit">立即推送今日日报</button>
</form>
</div>
{% if wecom.daily_last %}
<p class="muted" style="margin-top:10px">上次自动日报日期:{{ wecom.daily_last }}</p>
{% endif %}
</div>
{% endif %}
{% endblock %} {% endblock %}
+210
View File
@@ -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"
+1 -1
View File
@@ -2,7 +2,7 @@
## 功能 ## 功能
- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入) - 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本(可勾选自动)、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
- **通用包 + 首次激活**:仅第一次填写中控地址与激活码;**不允许离线运行** - **通用包 + 首次激活**:仅第一次填写中控地址与激活码;**不允许离线运行**
- **打开复盘**:用已保存的中控地址打开 `/login` - **打开复盘**:用已保存的中控地址打开 `/login`
- **心跳**:约每 2 秒 ping 中控,配合总览 SSE 秒级在线状态 - **心跳**:约每 2 秒 ping 中控,配合总览 SSE 秒级在线状态
+11
View File
@@ -43,9 +43,20 @@ def load_device() -> dict[str, Any]:
data.setdefault("machine_name", platform.node() or "PC") data.setdefault("machine_name", platform.node() or "PC")
data.setdefault("server", "") data.setdefault("server", "")
data.setdefault("token", "") data.setdefault("token", "")
data.setdefault("auto_break_even", False)
return data 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: def save_device(data: dict[str, Any]) -> None:
ensure_dir() ensure_dir()
DEVICE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") DEVICE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+84 -2
View File
@@ -22,12 +22,19 @@ import MetaTrader5 as mt5
try: try:
import hub_client import hub_client
from activation_dialog import ensure_activated 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: # 允许单独拷贝本文件调试 except ImportError: # 允许单独拷贝本文件调试
hub_client = None # type: ignore hub_client = None # type: ignore
ensure_activated = None # type: ignore ensure_activated = None # type: ignore
is_activated = lambda: False # type: ignore is_activated = lambda: False # type: ignore
load_device = lambda: {} # 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 = "黄金市价辅助" APP_TITLE = "黄金市价辅助"
@@ -1096,6 +1103,7 @@ class OrderApp(tk.Tk):
self._lot_auto_done = False self._lot_auto_done = False
self._selected_tickets_set: set = set() self._selected_tickets_set: set = set()
self._be_done_tickets: set = set() # 已拉过保本的单号,不再判断 self._be_done_tickets: set = set() # 已拉过保本的单号,不再判断
self._auto_be_busy = False # 自动保本进行中,防并发改单
self._pos_sig = None self._pos_sig = None
self._pos_profit_labels: dict = {} self._pos_profit_labels: dict = {}
self._metric_values: dict = {} self._metric_values: dict = {}
@@ -1320,6 +1328,13 @@ class OrderApp(tk.Tk):
self._be_ready = False 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 = 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.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( self._mk_btn(pos_bar, "打开复盘", "#3a5a80", self.on_open_review, h=34, w=100).pack(
side=tk.RIGHT, padx=4 side=tk.RIGHT, padx=4
) )
@@ -1384,7 +1399,8 @@ class OrderApp(tk.Tk):
text=( text=(
f"最多同时 {MAX_OPEN_POSITIONS} 笔 · 同向≤{MAX_SIDE_LOTS:.2f} 手 · " f"最多同时 {MAX_OPEN_POSITIONS} 笔 · 同向≤{MAX_SIDE_LOTS:.2f} 手 · "
f"点差>{SPREAD_WARN_USD:.2f} 买卖提示 · " f"点差>{SPREAD_WARN_USD:.2f} 买卖提示 · "
"亏≥5%或盈≥10%禁止开仓 · 持仓可单独「改止损/改止盈」 · 保本:有利≥3$ 后止损到开仓价±1$" "亏≥5%或盈≥10%禁止开仓 · 持仓可单独「改止损/改止盈」 · "
"保本:有利≥3$ 后止损到开仓价±1$(可勾选自动保本)"
), ),
bg=COLOR_BG, bg=COLOR_BG,
fg=COLOR_MUTED, fg=COLOR_MUTED,
@@ -1743,6 +1759,72 @@ class OrderApp(tk.Tk):
except Exception: except Exception:
ok = False ok = False
self._set_be_button_enabled(ok) 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: def _hub_upload_bars(self, symbol: str) -> None:
if hub_client is None or not is_activated(): if hub_client is None or not is_activated():
+8 -1
View File
@@ -137,7 +137,14 @@ Header:`X-Device-Token`
开仓/平仓成功返回 `(消息, report_dict)`,后台线程上报 trades + 四周期 bars。 开仓/平仓成功返回 `(消息, 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) - 复盘筛选、统计面板、AI 点评(可对齐 crypto_monitor)
- PostgreSQL 替换 SQLite(多写高并发时) - PostgreSQL 替换 SQLite(多写高并发时)
+9
View File
@@ -56,6 +56,15 @@
2. 打开已激活的辅助 exe 下单 / 保本 / 平仓 2. 打开已激活的辅助 exe 下单 / 保本 / 平仓
3. 需要复盘时点 exe 上的 **打开复盘**,浏览器登录网页账号即可 3. 需要复盘时点 exe 上的 **打开复盘**,浏览器登录网页账号即可
4. 客户端在成交或平仓时自动上报(接口见技术文档) 4. 客户端在成交或平仓时自动上报(接口见技术文档)
5. 持仓旁可勾选 **自动保本**:价格有利走出 ≥3$ 后,自动把止损拉到开仓价 ±1$(与手动「保本」同一规则;勾选状态保存在本机)
## 4.1 企业微信推送(管理员)
1. 企业微信群 → 添加群机器人 → 复制 Webhook
2. 网页 **系统设置** → 填写 Webhook → 勾选启用 → 保存
3. 可用「发送测试消息」「立即推送今日日报」验证
4. 生效后:客户端上报的 **开仓 / 平仓** 即时推送;北京时间 **23:00** 自动推送各账户日报(交易次数、已平盈亏、胜率)
5. 也可在服务器 `.env` 写 `WECOM_WEBHOOK_URL=...`(设置页保存的地址优先)
## 5. 网页复盘 ## 5. 网页复盘