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:
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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:///"):
|
||||||
# 相对路径转到仓库根
|
# 相对路径转到仓库根
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
<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
@@ -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
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
|
- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本(可勾选自动)、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
|
||||||
- **通用包 + 首次激活**:仅第一次填写中控地址与激活码;**不允许离线运行**
|
- **通用包 + 首次激活**:仅第一次填写中控地址与激活码;**不允许离线运行**
|
||||||
- **打开复盘**:用已保存的中控地址打开 `/login`
|
- **打开复盘**:用已保存的中控地址打开 `/login`
|
||||||
- **心跳**:约每 2 秒 ping 中控,配合总览 SSE 秒级在线状态
|
- **心跳**:约每 2 秒 ping 中控,配合总览 SSE 秒级在线状态
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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(多写高并发时)
|
||||||
|
|||||||
@@ -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. 网页复盘
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user