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
+5
View File
@@ -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:///"):
# 相对路径转到仓库根
+9
View File
@@ -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
+103 -1
View File
@@ -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")
+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>
</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 %}
+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"