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:
@@ -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:///"):
|
||||
# 相对路径转到仓库根
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,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
@@ -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