From 590058871869f3560e5c7aec1863b6cb1baabcf5 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 12 May 2026 18:27:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=96=B0=E8=B4=A6=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crypto_monitor_binance/.env | 121 + crypto_monitor_binance/README.md | 79 + crypto_monitor_binance/app.py | 5488 ++++++++++++++ crypto_monitor_binance/crypto.db | Bin 0 -> 65536 bytes crypto_monitor_binance/ecosystem.config.cjs | 33 + .../scripts/fix_breakeven_labels.py | 108 + .../scripts/verify_binance_funding.py | 52 + crypto_monitor_binance/start_utf8.ps1 | Bin 0 -> 1080 bytes crypto_monitor_binance/templates/index.html | 1283 ++++ .../templates/key_focus.html | 1 + .../templates/key_focus_v2.html | 261 + crypto_monitor_binance/templates/login.html | 118 + .../templates/order_focus.html | 194 + .../templates/order_focus_v2.html | 214 + crypto_monitor_binance/部署文档.md | 286 + .../__pycache__/app.cpython-310.pyc | Bin 151859 -> 0 bytes crypto_monitor_gate/ecosystem.config.cjs | 2 +- crypto_monitor_gate_bot/.env | 123 + crypto_monitor_gate_bot/app.py | 6372 +++++++++++++++++ crypto_monitor_gate_bot/crypto.db | Bin 0 -> 65536 bytes crypto_monitor_gate_bot/ecosystem.config.cjs | 33 + .../scripts/fix_breakeven_labels.py | 108 + .../scripts/verify_gate_funding.py | 93 + crypto_monitor_gate_bot/start_utf8.ps1 | Bin 0 -> 1080 bytes crypto_monitor_gate_bot/templates/index.html | 1187 +++ .../templates/key_focus.html | 1 + .../templates/key_focus_v2.html | 261 + crypto_monitor_gate_bot/templates/login.html | 118 + .../templates/order_focus.html | 194 + .../templates/order_focus_v2.html | 214 + crypto_monitor_gate_bot/趋势回调策略说明.md | 91 + crypto_monitor_gate_bot/部署文档.md | 273 + crypto_monitor_okx/.env | 2 +- crypto_monitor_okx/ecosystem.config.cjs | 2 +- 34 files changed, 17309 insertions(+), 3 deletions(-) create mode 100644 crypto_monitor_binance/.env create mode 100644 crypto_monitor_binance/README.md create mode 100644 crypto_monitor_binance/app.py create mode 100644 crypto_monitor_binance/crypto.db create mode 100644 crypto_monitor_binance/ecosystem.config.cjs create mode 100644 crypto_monitor_binance/scripts/fix_breakeven_labels.py create mode 100644 crypto_monitor_binance/scripts/verify_binance_funding.py create mode 100644 crypto_monitor_binance/start_utf8.ps1 create mode 100644 crypto_monitor_binance/templates/index.html create mode 100644 crypto_monitor_binance/templates/key_focus.html create mode 100644 crypto_monitor_binance/templates/key_focus_v2.html create mode 100644 crypto_monitor_binance/templates/login.html create mode 100644 crypto_monitor_binance/templates/order_focus.html create mode 100644 crypto_monitor_binance/templates/order_focus_v2.html create mode 100644 crypto_monitor_binance/部署文档.md delete mode 100644 crypto_monitor_gate/__pycache__/app.cpython-310.pyc create mode 100644 crypto_monitor_gate_bot/.env create mode 100644 crypto_monitor_gate_bot/app.py create mode 100644 crypto_monitor_gate_bot/crypto.db create mode 100644 crypto_monitor_gate_bot/ecosystem.config.cjs create mode 100644 crypto_monitor_gate_bot/scripts/fix_breakeven_labels.py create mode 100644 crypto_monitor_gate_bot/scripts/verify_gate_funding.py create mode 100644 crypto_monitor_gate_bot/start_utf8.ps1 create mode 100644 crypto_monitor_gate_bot/templates/index.html create mode 100644 crypto_monitor_gate_bot/templates/key_focus.html create mode 100644 crypto_monitor_gate_bot/templates/key_focus_v2.html create mode 100644 crypto_monitor_gate_bot/templates/login.html create mode 100644 crypto_monitor_gate_bot/templates/order_focus.html create mode 100644 crypto_monitor_gate_bot/templates/order_focus_v2.html create mode 100644 crypto_monitor_gate_bot/趋势回调策略说明.md create mode 100644 crypto_monitor_gate_bot/部署文档.md diff --git a/crypto_monitor_binance/.env b/crypto_monitor_binance/.env new file mode 100644 index 0000000..5b080ae --- /dev/null +++ b/crypto_monitor_binance/.env @@ -0,0 +1,121 @@ +APP_ENV=production +# 服务监听地址(云服务器通常用 0.0.0.0) +APP_HOST=0.0.0.0 +# 服务端口 +APP_PORT=5001 +# 是否开启调试模式(生产建议 false) +APP_DEBUG=false + +# 登录账号 +APP_USERNAME=dekun +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=ChangeMe123! +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=true +# Flask 会话密钥(必须替换为长随机字符串) +FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET + +# 企业微信机器人 Webhook(用于行情/风控推送) +WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY + +# 数据库文件路径(相对路径会自动按项目目录解析) +DB_PATH=crypto.db +# 交易截图上传目录 +UPLOAD_DIR=static/images + +# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量 +# TOTAL_CAPITAL=100 +# 每天起始基数(U) +DAILY_START_CAPITAL=30 +# 日内回撤后基数(U) +DAILY_LOSS_CAPITAL=20 +# 日内盈利后基数(U) +DAILY_PROFIT_CAPITAL=50 +# BTC 默认杠杆倍数 +BTC_LEVERAGE=10 +# 山寨币默认杠杆倍数 +ALT_LEVERAGE=5 +# 交易日重置小时(北京时间) +TRADING_DAY_RESET_HOUR=8 +# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true + +# 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单) +LIVE_TRADING_ENABLED=true +# Binance API Key(需开通合约、万向划转等权限) +BINANCE_API_KEY=REPLACE_WITH_BINANCE_API_KEY +# Binance API Secret +BINANCE_API_SECRET=REPLACE_WITH_BINANCE_API_SECRET +# 保证金模式:cross=全仓,isolated=逐仓 +BINANCE_MARGIN_MODE=cross +# 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向 +BINANCE_POSITION_MODE=hedge +# 条件单触发参考价:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价(更易触发时用标记价) +BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE +# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Binance·测试网) +# EXCHANGE_DISPLAY_NAME=Binance +# 企业微信推送里展示的账户备注 +# BINANCE_ACCOUNT_LABEL=binance实盘账户 + +# 关键位监控:5m收线突破过滤参数 +KLINE_TIMEFRAME=5m +KEY_BREAKOUT_LIMIT_PCT=1.5 +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=5 + +# 资金与仓位刷新周期(秒) +BALANCE_REFRESH_SECONDS=60 +# 后台监控轮询周期(秒) +MONITOR_POLL_SECONDS=3 +# 使用可用资金时的缓冲比例(如0.98代表用98%) +FULL_MARGIN_BUFFER_RATIO=0.98 + +# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT(ccxt:funding↔swap 等) +AUTO_TRANSFER_ENABLED=false +AUTO_TRANSFER_AMOUNT=30 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +TRANSFER_CCY=USDT +# 强制清仓整点(北京时间,默认 0=凌晨00点) +FORCE_CLOSE_BJ_HOUR=0 +# 是否启用强制清仓(默认关闭,true 才会在整点执行) +FORCE_CLOSE_ENABLED=false + +# 推送与AI超时(秒) +WECHAT_TIMEOUT_SECONDS=10 +AI_TIMEOUT_SECONDS=120 + +# AI 复盘服务地址(本机 Ollama 默认地址) +OLLAMA_API=http://127.0.0.1:11434/api/generate +# AI 模型名称 +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest + +# Binance 代理(可选):本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口 +# 1) 先在本机建立隧道(示例): +# ssh -N -D 127.0.0.1:1080 user@vps -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes +# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080 +# +# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: +# BINANCE_HTTP_PROXY=http://127.0.0.1:3128 +# BINANCE_HTTPS_PROXY=http://127.0.0.1:3128 + +# 开仓多周期K线图(可选) +# ORDER_CHART_ENABLED=true +# ORDER_CHART_TFS=4h,1h,15m,5m +# ORDER_CHART_LIMIT=100 +# ORDER_CHART_DIR=static/images/order_charts +# DAILY_OPEN_ALERT_THRESHOLD=5 +# 以损定仓(按交易账户资金的百分比) +# RISK_PERCENT=2 +# 移动保本触发(达到多少R触发)与偏移(百分比) +# BREAKEVEN_RR_TRIGGER=1.0 +# 移动保本阶梯(每多少R继续上移一次,默认1R) +# BREAKEVEN_STEP_R=1.0 +# BREAKEVEN_OFFSET_PCT=0.02 +# 开单风格默认值:trend / swing +# DEFAULT_TRADE_STYLE=trend + +APP_TIMEZONE=Asia/Shanghai +AUTO_TRANSFER_BJ_HOUR=8 +# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED diff --git a/crypto_monitor_binance/README.md b/crypto_monitor_binance/README.md new file mode 100644 index 0000000..844d44a --- /dev/null +++ b/crypto_monitor_binance/README.md @@ -0,0 +1,79 @@ +# crypto_monitor_binance + +基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **Binance(USDT-M 永续)**,通过 **ccxt** 访问。 + +## 功能概要 + +- **关键位监控**:价格与硬条件校验、企业微信推送(可选) +- **下单监控**:本地风控(含移动保本逻辑)、触达止盈/止损后尝试市价平仓并记账 +- **实盘(可选)**:`LIVE_TRADING_ENABLED=true` 且配置 `BINANCE_API_KEY` / `BINANCE_API_SECRET` 时,支持合约开仓、平仓、余额读取与内部划转(依赖 API 权限) +- **止盈止损(Binance)**:市价成交后挂 **`STOP_MARKET`**(止损)、**`TAKE_PROFIT_MARKET`**(止盈);双向持仓带 `positionSide`;不显式传 `reduceOnly`(避免 API `-1106`)。触发参考价由 `BINANCE_TRIGGER_WORKING_TYPE` 控制(最新价 / 标记价) + +## 环境要求 + +- Python 3.10+(建议) +- 依赖:`flask`、`requests`、`ccxt`、`werkzeug`、`Pillow`(K 线图可选);经 SOCKS 代理时需 **`PySocks`** + +安装示例: + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +页面上的 **「当日资金(交易账户)」** 与 **「可开仓」可用 U** 仅统计 **Binance U 本位永续合约账户**(`fetch_balance` 的 `swap` / FAPI `assets` 中的 USDT),**不会**再用现货余额顶替。 + +## 配置说明(`.env`) + +项目启动时会加载**项目根目录**下的 `.env`。与 Binance 相关的常用变量: + +| 变量 | 说明 | +|------|------| +| `BINANCE_API_KEY` / `BINANCE_API_SECRET` | 币安 API(需合约等权限) | +| `LIVE_TRADING_ENABLED` | `true` 时允许真实下单;`false` 仅本地逻辑 | +| `BINANCE_MARGIN_MODE` | `cross` 全仓 / `isolated` 逐仓 | +| `BINANCE_POSITION_MODE` | `hedge` 双向(需账户开启双向持仓)/ `oneway` 单向 | +| `BINANCE_TRIGGER_WORKING_TYPE` | `CONTRACT_PRICE` 或 `MARK_PRICE`(条件单触发参考) | +| `BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY` | 可选代理(与部署文档一致) | +| `EXCHANGE_DISPLAY_NAME` | 页面展示的交易所名称,默认 `Binance` | +| `BINANCE_ACCOUNT_LABEL` | 推送文案中的账户备注 | + +其余变量(登录、企业微信、风控参数、数据库路径等)见仓库内 **`.env` 示例注释** 或 `app.py` 顶部默认值。 + +## 本地运行 + +**Windows(UTF-8 控制台)** 可使用: + +```powershell +.\start_utf8.ps1 +``` + +或直接: + +```powershell +python .\app.py +``` + +默认监听端口由 `.env` 的 `APP_PORT` 决定(未设置时多为 `5000`)。 + +## 部署(Linux / PM2 / SSH SOCKS) + +详见 **[部署文档.md](./部署文档.md)**(Ubuntu + PM2 + 可选 SOCKS 访问 Binance)。 + +## 自检脚本 + +```bash +python scripts/verify_binance_funding.py +``` + +用于核对 Key 前缀(不含 Secret)并尝试读取资金钱包 / 合约钱包 USDT(需网络与 API 权限)。 + +## 数据与脚本 + +- 默认 SQLite:`crypto.db`(路径由 `DB_PATH` 指定) +- `scripts/fix_breakeven_labels.py`:批量修正「止损」但盈亏为正的记录标签(见部署文档附录) + +## 风险与合规 + +实盘交易有亏损风险。请自行确认 API 权限、IP 白名单、杠杆与保证金模式与币安账户设置一致,并遵守当地法律法规与 Binance 用户协议。 diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py new file mode 100644 index 0000000..616077f --- /dev/null +++ b/crypto_monitor_binance/app.py @@ -0,0 +1,5488 @@ +from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response +import sqlite3 +import csv +from io import StringIO +import time +import threading +import requests +import os +import re +import base64 +import json +import math +from datetime import datetime, timedelta, timezone + +try: + from zoneinfo import ZoneInfo +except ImportError: + ZoneInfo = None # type: ignore +from functools import wraps +import uuid +import ccxt +from werkzeug.utils import secure_filename + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + Image = None # type: ignore + ImageDraw = None # type: ignore + ImageFont = None # type: ignore + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def load_env_file(path): + if not os.path.exists(path): + return + raw_bytes = open(path, "rb").read() + text = "" + for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"): + try: + text = raw_bytes.decode(enc) + break + except Exception: + continue + if not text: + text = raw_bytes.decode("utf-8", errors="ignore") + text = text.replace("\x00", "") + for line in text.splitlines(): + raw = line.strip() + if not raw or raw.startswith("#") or "=" not in raw: + continue + key, value = raw.split("=", 1) + clean_key = key.strip().lstrip("\ufeff") + if not clean_key.replace("_", "").isalnum(): + continue + clean_value = value.strip().strip('"').strip("'") + os.environ[clean_key] = clean_value + +load_env_file(os.path.join(BASE_DIR, ".env")) + + +def resolve_path(path_value): + if os.path.isabs(path_value): + return path_value + return os.path.join(BASE_DIR, path_value) + +app = Flask(__name__) +app.secret_key = os.getenv("FLASK_SECRET_KEY", "crypto_monitor_2026_secret_key") + +# ====================== 登录配置 ====================== +USERNAME = os.getenv("APP_USERNAME", "dekun") +PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") +AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") + +# 企业微信机器人Webhook +WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") +SYSTEM_TYPE = "CRYPTO" +HOST = os.getenv("APP_HOST", "0.0.0.0") +PORT = int(os.getenv("APP_PORT", "5000")) +DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" +DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) + +# 训练参数(可由 .env 覆盖) +DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) +DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) +DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) +BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) +ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) +# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" +).lower() in ("1", "true", "yes", "on") +APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") + + +def _resolve_app_tz(): + if ZoneInfo is not None: + try: + return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) + except Exception: + pass + return timezone(timedelta(hours=8)) + + +APP_TZ = _resolve_app_tz() +LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" +BINANCE_API_KEY = (os.getenv("BINANCE_API_KEY") or "").strip() +BINANCE_API_SECRET = (os.getenv("BINANCE_API_SECRET") or "").strip() +BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower() +# hedge=双向持仓(需 positionSide);oneway / single=单向持仓 +_raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower() +BINANCE_POSITION_MODE = "hedge" if _raw_binance_pos in ("hedge", "dual", "double", "hedged") else "oneway" +# 条件单触发参考:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价 +BINANCE_TRIGGER_WORKING_TYPE = (os.getenv("BINANCE_TRIGGER_WORKING_TYPE") or "CONTRACT_PRICE").strip().upper() +if BINANCE_TRIGGER_WORKING_TYPE not in ("CONTRACT_PRICE", "MARK_PRICE"): + BINANCE_TRIGGER_WORKING_TYPE = "CONTRACT_PRICE" +# 页面展示的交易所名称(多实例/多环境时可按需区分) +EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Binance").strip() or "Binance" +_BINANCE_DEFAULT_MARGIN_MODE = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" +BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) +PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) +KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) +KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) +KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5")) +AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" +AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) +AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") +AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") +FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" +FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) +# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 +AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) +AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) +MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) +KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") +FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) +TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT") +UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) +ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" +ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] +ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) +ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) +DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5")) +RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) +BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) +BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) +BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) +DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() +OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate") +AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") + +BINANCE_SOCKS_PROXY = (os.getenv("BINANCE_SOCKS_PROXY") or "").strip() +BINANCE_HTTP_PROXY = (os.getenv("BINANCE_HTTP_PROXY") or "").strip() +BINANCE_HTTPS_PROXY = (os.getenv("BINANCE_HTTPS_PROXY") or "").strip() + + +def build_binance_ccxt_proxies(): + """ + 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口)。 + + 推荐: + - 本机:ssh -N -D 127.0.0.1:1080 user@vps + - .env:BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + + 说明: + - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// + """ + socks = BINANCE_SOCKS_PROXY.strip() + http = BINANCE_HTTP_PROXY.strip() + https = BINANCE_HTTPS_PROXY.strip() or http + if socks: + return {"http": socks, "https": socks} + if http or https: + return {"http": http, "https": https} + return None + + +BINANCE_CCXT_PROXIES = build_binance_ccxt_proxies() + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(ORDER_CHART_DIR, exist_ok=True) +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER + +# Binance USDT 本位永续(ccxt unified: defaultType=swap) +exchange = ccxt.binance({ + "enableRateLimit": True, + "options": { + "defaultType": "swap", + "defaultMarginMode": _BINANCE_DEFAULT_MARGIN_MODE, + "adjustForTimeDifference": True, + }, +}) +if BINANCE_CCXT_PROXIES: + exchange.proxies = BINANCE_CCXT_PROXIES +if BINANCE_API_KEY and BINANCE_API_SECRET: + exchange.apiKey = BINANCE_API_KEY + exchange.secret = BINANCE_API_SECRET +MARKETS_LOADED = False +ACCOUNT_BALANCE_CACHE = { + "updated_at": 0.0, + "funding_usdt": None, + "trading_usdt": None +} +LIQUIDITY_RANK_CACHE = { + "updated_at": 0.0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + prefix = "【加密货币】" + full_msg = f"{prefix}\n{content}" + data = { + "msgtype": "text", + "text": {"content": full_msg} + } + try: + requests.post(WECHAT_WEBHOOK, json=data, timeout=WECHAT_TIMEOUT_SECONDS) + except: + pass + + +def _wechat_account_label(): + return (os.getenv("BINANCE_ACCOUNT_LABEL") or "binance实盘账户").strip() + + +def _wechat_direction_text(direction): + d = (direction or "").lower() + return "多头(long)" if d == "long" else "空头(short)" + + +def _wechat_trading_capital_text(fallback=None): + try: + _, trading_capital = get_exchange_capitals(force=True) + except Exception: + trading_capital = None + if trading_capital is not None: + return f"{round(float(trading_capital), 4)}U" + if fallback is not None: + try: + return f"{round(float(fallback), 4)}U" + except Exception: + pass + return "-" + + +def build_wechat_close_message( + symbol, + direction, + result, + pnl_amount, + hold_seconds=None, + trigger_price=None, + current_price=None, + stop_loss=None, + take_profit=None, + close_order_id=None, + extra_note=None, + session_capital_fallback=None, +): + hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-" + ep = format_price_for_symbol(symbol, trigger_price) + cp = format_price_for_symbol(symbol, current_price) + tp = format_price_for_symbol(symbol, take_profit) + sl = format_price_for_symbol(symbol, stop_loss) + cap_txt = _wechat_trading_capital_text(session_capital_fallback) + try: + if pnl_amount is not None: + pv = float(pnl_amount) + pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 4)} U" + else: + pnl_disp = "-" + except (TypeError, ValueError): + pnl_disp = "-" + + lines = [ + f"📉 {symbol} 平仓完成", + f"💼 账户:{_wechat_account_label()}", + "", + "🧾 平仓概要", + f"🔖 平仓单号:{close_order_id or '-'}", + f"📌 方向:{_wechat_direction_text(direction)}", + f"📌 平仓结果:{result or '-'}", + f"💰 本单盈亏:{pnl_disp}", + f"⏱ 持仓时长:{hold_txt}", + f"💵 交易账户资金:{cap_txt}", + "", + "🎯 价位(计划)", + f"开仓成交价:{ep}", + f"离场参考价:{cp}", + f"止盈价位:{tp}", + f"止损价位:{sl}", + ] + if extra_note: + lines.extend(["", "📎 备注", extra_note]) + return "\n".join(lines) + + +def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): + sl_fmt = format_price_for_symbol(symbol, new_sl) + return "\n".join( + [ + f"# 🛡️ {symbol} 保护位更新", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 移动保本/止盈", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 类型:**{arm_txt}**", + f"- 当前RR:`{round(float(now_rr), 2)}R`", + f"- 锁定RR:`{round(float(locked_r), 2)}R`", + f"- 新保护位:`{sl_fmt}`", + ] + ) + + +def build_wechat_monitor_error_message(symbol, direction, scene, error_text): + return "\n".join( + [ + f"# ⚠️ {symbol} 下单监控异常", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 异常信息", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", + ] + ) + + +def build_wechat_key_monitor_message( + symbol, + direction, + monitor_type, + trigger_time, + key_price, + confirm_close, + hard_lines, + btc8h_status, + coin4h_status, + swing4h_pct, + op_lines, + risk_tip=None, +): + lines = [ + f"# 🎯 {symbol} 关键位确认推送", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 交易对 / 触发时间", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", + "", + "### 方向与确认K", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", + "", + "### 关键价位", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", + "", + "### 硬条件校验结果", + ] + lines.extend([f"- {x}" for x in hard_lines]) + lines.extend( + [ + "", + "### 市场状态说明", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + "", + "### 操作提示", + ] + ) + lines.extend([f"- {x}" for x in op_lines]) + if risk_tip: + lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) + return "\n".join(lines) + + +def _read_image_base64(image_path): + try: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + except Exception: + return None + + +def _extract_json_object(text): + if not text: + return None + clean = text.strip() + if clean.startswith("```"): + clean = clean.replace("```json", "").replace("```", "").strip() + try: + return json.loads(clean) + except Exception: + pass + match = re.search(r"\{[\s\S]*\}", clean) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: + return None + + +def _journal_row_lines_for_ai(idx, row, *, include_hold_duration=True): + """把 journal 字段拼成给 AI 的文本;字段之外的事实不要指望模型自己猜。""" + def nz(v, default="无"): + if v is None: + return default + s = str(v).strip() + return s if s else default + + lines = [ + f"{idx}. {nz(row['coin'])} {nz(row['tf'])} | 盈亏:{nz(row['pnl'])}U | 实际RR:{nz(row['real_rr'])} | 预期RR:{nz(row['expect_rr'])}", + f" 开仓逻辑:{nz(row['entry_reason'])}", + f" 平仓/离场(交易员自述):{nz(row['exit_reason'])}", + ] + if include_hold_duration: + lines.append(f" 持仓时长:{nz(row['hold_duration'])}") + ee_bits = [ + nz(row["early_exit"]), + nz(row["early_exit_reason"]), + nz(row["early_exit_trigger"]), + nz(row["early_exit_note"]), + ] + if any(x != "无" for x in ee_bits): + lines.append( + " 提前离场记录:" + f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}" + ) + mood_bits = f"心态标签:{nz(row['mood_issues'])}" + if row["mood_score"] is not None: + mood_bits += f" | 自评心态分:{row['mood_score']}" + lines.append(f" {mood_bits}") + if nz(row["post_breakeven_stare"]) != "无": + lines.append(f" 保本后盯盘:{nz(row['post_breakeven_stare'])}") + if nz(row["new_trade_while_occupied"]) != "无": + lines.append(f" 占用时新开仓:{nz(row['new_trade_while_occupied'])}") + if nz(row["note"]) != "无": + lines.append(f" 备注:{nz(row['note'])}") + return "\n".join(lines) + "\n" + + +def ai_review(trades_text, period_title, image_paths=None): + prompt = f""" +你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。 + +【硬性规则 — 必须遵守】 +- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。 +- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。 +- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。 +- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。 +- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。 +- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。 + +【输出结构】 +1. 总体盈亏结构(紧扣笔数、盈亏数字与 RR,少形容词) +2. 心态与执行(每笔 1–10 分 + 一句依据;依据必须对应记录字段) +3. 行为标签(提前离场 / 乱开仓 / 扛单等):仅在有字段或自述支撑时点名;否则写「记录未勾选或未描述,不作强加」 +4. 改进建议(最多 3 条,每条具体可执行) +5. 图表(若有且可读):结合价格行为简述;否则一两句说明无法看图分析 + +交易记录: +{trades_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + images = [] + for p in image_paths or []: + b64 = _read_image_base64(p) + if b64: + images.append(b64) + if images: + payload["images"] = images + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return r.json().get("response", "AI 生成失败") + except Exception as e: + return f"AI 调用失败:{str(e)}" + + +def ai_short_advice(prompt_text): + prompt = f""" +你是交易风控助理。请用中文给出**最多 3 条**提醒,要求: +- 每条不超过 25 个字 +- 语气克制、具体、可执行 +- 不要输出 Markdown,不要编号前缀以外的废话 + +场景: +{prompt_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return (r.json().get("response") or "").strip() + except Exception: + return "" + + +def _load_font(size): + if not ImageFont: + return None + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\arial.ttf", + ] + for path in candidates: + if path and os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except Exception: + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def _ohlcv_to_rows(ohlcv): + rows = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + rows.append( + { + "ts": int(bar[0]), + "o": float(bar[1]), + "h": float(bar[2]), + "l": float(bar[3]), + "c": float(bar[4]), + "v": float(bar[5]), + } + ) + except Exception: + continue + return rows + + +def _local_input_datetime_to_ms(dt_text): + raw = str(dt_text or "").strip() + if not raw: + return None + raw = raw.replace("T", " ") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(raw, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + return None + + +def _pick_marker_point(rows, target_ts_ms, target_price=None): + if not rows or target_ts_ms is None: + return None, None + idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) + if target_price is not None: + try: + p = float(target_price) + if p > 0: + return idx, p + except Exception: + pass + return idx, float(rows[idx]["c"]) + + +def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): + if not Image or not ImageDraw: + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") + img = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(img) + font = _load_font(14) + small = _load_font(12) + + pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 + plot_w = max(10, width - pad_l - pad_r) + plot_h = max(10, height - pad_t - pad_b) + + header_bg = (245, 247, 250) + draw.rectangle((0, 0, width, pad_t), fill=header_bg) + if font: + draw.text((10, 6), title, fill=(25, 35, 60), font=font) + else: + draw.text((10, 6), title, fill=(25, 35, 60)) + + if not rows: + if small: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) + else: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) + return img + + lo = min(r["l"] for r in rows) + hi = max(r["h"] for r in rows) + if hi <= lo: + hi = lo + 1e-12 + + n = len(rows) + marker_by_idx = {} + for mp in marker_points or []: + try: + idx = int(mp.get("idx")) + except Exception: + continue + if idx < 0 or idx >= n: + continue + marker_by_idx[idx] = mp + + x0 = pad_l + for i, r in enumerate(rows): + x1 = pad_l + int((i + 1) * plot_w / n) + x_mid = (x0 + x1) // 2 + wick_x = x_mid + y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) + y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) + y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) + y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) + top = min(y_open, y_close) + bot = max(y_open, y_close) + up = r["c"] >= r["o"] + wick_color = (120, 120, 120) + edge_color = (20, 20, 20) + draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) + body_w = max(1, (x1 - x0) - 2) + left = x0 + 1 + if bot - top < 2: + mid = (top + bot) // 2 + draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) + else: + if up: + draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) + else: + draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) + if i in marker_by_idx: + mp = marker_by_idx[i] + tag = str(mp.get("tag") or "") + m_price = float(mp.get("price") or r["c"]) + y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) + y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) + if tag == "ENTRY": + m_color = (0, 195, 95) + tri = [(x_mid, y_m - 20), (x_mid - 9, y_m - 4), (x_mid + 9, y_m - 4)] + text_y = y_m - 36 + else: + m_color = (235, 65, 65) + tri = [(x_mid, y_m + 20), (x_mid - 9, y_m + 4), (x_mid + 9, y_m + 4)] + text_y = y_m + 12 + draw.ellipse((x_mid - 5, y_m - 5, x_mid + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) + draw.polygon(tri, fill=m_color) + draw.line((x_mid, y_m, x_mid, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) + if font: + draw.text((x_mid + 8, text_y), tag, fill=m_color, font=font) + else: + draw.text((x_mid + 8, text_y), tag, fill=m_color) + x0 = x1 + + if len(marker_points or []) >= 2: + try: + entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) + exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) + if entry is not None and exitp is not None: + ex_i, ex_p = int(entry["idx"]), float(entry["price"]) + xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) + x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) + x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) + y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) + y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) + draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) + except Exception: + pass + + # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 + if small: + draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) + return img + + +def generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + out_dir=None, + filename=None, + filename_prefix="chart", + marker_payload=None, + marker_timeframes=None, +): + if not ORDER_CHART_ENABLED: + return None + if not Image: + return None + requested = timeframes or ORDER_CHART_TFS + limit = limit or ORDER_CHART_LIMIT + preferred_layout = ["5m", "15m", "1h", "4h"] + requested_set = set(requested or []) + ordered = [tf for tf in preferred_layout if tf in requested_set] + for tf in requested: + if tf not in ordered: + ordered.append(tf) + timeframes = ordered[:4] if ordered else preferred_layout + + ensure_markets_loaded() + panels = [] + cell_w, cell_h = 980, 520 + for tf in timeframes: + try: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) + except Exception: + ohlcv = [] + rows = _ohlcv_to_rows(ohlcv)[-limit:] + title = f"{title_prefix} | {tf} x{len(rows)}" + points = [] + tf_key = str(tf).strip().lower() + marker_tfs = {str(x).strip().lower() for x in (marker_timeframes or []) if str(x).strip()} + if marker_payload and tf_key in marker_tfs: + entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")) + exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")) + if entry_idx is not None and entry_price is not None: + points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"}) + if exit_idx is not None and exit_price is not None: + points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"}) + panels.append( + _render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + ) + ) + + if not panels: + return None + + gap = 10 + cols = 2 + rows_n = int(math.ceil(len(panels) / cols)) + w = cols * cell_w + (cols - 1) * gap + h = rows_n * cell_h + (rows_n - 1) * gap + out = Image.new("RGB", (w, h), (255, 255, 255)) + idx = 0 + for r in range(rows_n): + for c in range(cols): + if idx >= len(panels): + break + x = c * (cell_w + gap) + y = r * (cell_h + gap) + out.paste(panels[idx], (x, y)) + idx += 1 + + # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图) + if ImageDraw and rows_n >= 1: + draw_out = ImageDraw.Draw(out) + line_col = (220, 225, 232) + x_mid = cell_w + gap // 2 + if w > x_mid >= 0: + draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2) + for rr in range(1, rows_n): + y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 + if 0 <= y_mid <= h: + draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) + + target_dir = out_dir or ORDER_CHART_DIR + os.makedirs(target_dir, exist_ok=True) + fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" + out_path = os.path.join(target_dir, fname) + out.save(out_path, format="PNG") + return fname + + +def generate_order_open_chart(exchange_symbol, title_prefix, timeframes=None, limit=None): + return generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=timeframes, + limit=limit, + out_dir=ORDER_CHART_DIR, + filename=None, + filename_prefix="order", + ) + + +def journal_coin_from_symbol(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym.split("/")[0].strip() + if "-" in sym: + return sym.split("-")[0].strip() + if sym.endswith("USDT"): + return sym[:-4].strip() + return sym + + +EARLY_EXIT_TRIGGERS = ( + "", + "保本止盈", + "移动止盈", + "手动平仓", + "止损", + "其他", +) + +# 与用户约定的固定开仓类型(仅做这几类单子) +ENTRY_REASON_OPTIONS = ( + "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", + "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", + "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", + "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", + "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", +) +# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) +ENTRY_REASON_OTHER = "__OTHER__" + + +def normalize_entry_reason(raw, custom_text=None): + v = str(raw or "").strip() + if v == ENTRY_REASON_OTHER: + c = str(custom_text or "").strip() + return c[:2000] if c else "" + return v if v in ENTRY_REASON_OPTIONS else "" + + +def entry_reason_valid_for_storage(s): + """允许五种固定整句、或自定义短文本(不含未解析的 __OTHER__ 占位)。""" + t = str(s or "").strip() + if not t: + return True + if t == ENTRY_REASON_OTHER: + return False + if t in ENTRY_REASON_OPTIONS: + return True + return 1 <= len(t) <= 2000 + + +def normalize_early_exit_trigger(raw): + v = str(raw or "").strip() + return v if v in EARLY_EXIT_TRIGGERS else "" + + +def compose_early_exit_reason_saved(trigger, note): + """Readable single-line string stored in early_exit_reason for legacy consumers.""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t and n: + return f"{t}|{n}" + return t or n + + +def journal_exit_reason_stored(trigger, note): + """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t == "手动平仓": + return n + return t + + +def ai_extract_journal_from_image(image_b64): + prompt = """ +你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。 +要求: +1) 仅输出一个 JSON 对象。 +2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。 +3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。 +4) 允许字段为空。 +5) entry_reason:优先从下列完整字符串中选一个(一字不差);若无法归类则可将简述写入 entry_reason(保存时也可选表单「其他」手写): + - 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低 + - 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高 + - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 + - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 + - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 +6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):保本止盈、移动止盈、手动平仓、止损、其他。 +7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 +8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 + +JSON 字段: +{ + "open_datetime": "", + "close_datetime": "", + "coin": "", + "tf": "", + "pnl": "", + "expect_rr": "", + "real_rr": "", + "entry_reason": "", + "early_exit_trigger": "", + "early_exit_note": "", + "early_exit_reason": "", + "note": "" +} +""".strip() + payload = { + "model": AI_MODEL, + "prompt": prompt, + "images": [image_b64], + "stream": False, + "options": {"temperature": 0.1}, + } + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + raw = r.json().get("response", "") + data = _extract_json_object(raw) or {} + if not isinstance(data, dict): + data = {} + trig_in = data.get("early_exit_trigger") + note_in = data.get("early_exit_note") + legacy_reason = str(data.get("early_exit_reason") or "").strip() + out = { + "open_datetime": str(data.get("open_datetime") or "").strip(), + "close_datetime": str(data.get("close_datetime") or "").strip(), + "coin": str(data.get("coin") or "").strip(), + "tf": str(data.get("tf") or "").strip(), + "pnl": str(data.get("pnl") or "").strip(), + "expect_rr": str(data.get("expect_rr") or "").strip(), + "real_rr": str(data.get("real_rr") or "").strip(), + "entry_reason": normalize_entry_reason(data.get("entry_reason")), + "early_exit_trigger": normalize_early_exit_trigger(trig_in), + "early_exit_note": str(note_in or "").strip(), + "early_exit_reason": legacy_reason, + "note": str(data.get("note") or "").strip(), + } + if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason: + out["early_exit_note"] = legacy_reason + if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason: + out["early_exit_note"] = legacy_reason + if out["early_exit_trigger"] != "手动平仓": + out["early_exit_note"] = "" + out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"]) + return out + except Exception: + return None + +# 初始化数据库(支持多空方向) +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 关键位监控 + c.execute('''CREATE TABLE IF NOT EXISTS key_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", upper REAL, lower REAL, + notification_count INTEGER DEFAULT 0, last_notified_at TEXT, + max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5, + breakout_limit_pct REAL DEFAULT 1.5, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + # 订单监控(核心:加 direction 方向字段) + c.execute('''CREATE TABLE IF NOT EXISTS order_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", + exchange_symbol TEXT, + trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5, + trade_style TEXT DEFAULT "trend", + risk_percent REAL, risk_amount REAL, + breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL, + breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL, + notional_value REAL, position_ratio REAL, base_amount REAL, + order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT, + opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT, + status TEXT DEFAULT "active")''') + + # 交易记录(必须存多空) + c.execute('''CREATE TABLE IF NOT EXISTS trade_records + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0, + trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL, + hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER, + result TEXT, miss_reason TEXT, exchange_trade_id TEXT, + reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL, + reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER, + reviewed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions + (session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS journal_entries + (id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT, + coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT, + expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT, + early_exit_trigger TEXT, early_exit_note TEXT, + mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT, + new_trade_while_occupied TEXT, note TEXT, image TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews + (id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs + (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, + amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') + c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique + ON transfer_logs(transfer_type, transfer_day) + WHERE transfer_type = 'auto_daily' ''') + + # 给旧表加 direction 字段(兼容老数据,不报错) + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") + except Exception: + pass + try: + c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") + except: pass + + c.execute( + """CREATE TABLE IF NOT EXISTS key_monitor_history + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, + close_reason TEXT, closed_at TEXT)""" + ) + + conn.commit() + conn.close() + +init_db() + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def app_now(): + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" + return datetime.now(APP_TZ).replace(tzinfo=None) + + +def app_now_str(): + return app_now().strftime("%Y-%m-%d %H:%M:%S") + + +def utc_now_dt(): + """当前时刻(UTC,aware)。""" + return datetime.now(timezone.utc) + + +def utc_calendar_date_str(): + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" + return utc_now_dt().strftime("%Y-%m-%d") + + +def get_trading_day(now=None): + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" + now = now or app_now() + if getattr(now, "tzinfo", None): + now = now.astimezone(APP_TZ).replace(tzinfo=None) + if now.hour < TRADING_DAY_RESET_HOUR: + return (now - timedelta(days=1)).strftime("%Y-%m-%d") + return now.strftime("%Y-%m-%d") + + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓") + + +def parse_dt_for_trading_day(s): + if not s: + return None + s = str(s).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:ln], fmt) + except ValueError: + continue + return None + + +def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): + conn.execute( + """INSERT INTO key_monitor_history + (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + row["symbol"], + row["monitor_type"], + row["direction"] or "long", + row["upper"], + row["lower"], + int(notification_count or 0), + (last_msg or "")[:800] if last_msg else None, + close_reason, + app_now_str(), + ), + ) + + +def _session_week_bounds(trading_day_str): + end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() + start = end - timedelta(days=6) + return start.strftime("%Y-%m-%d"), trading_day_str + + +def _calendar_month_bounds(local_dt): + y, m = local_dt.year, local_dt.month + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end_d = datetime(y, 12, 31).date() + else: + end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() + return start, end_d.strftime("%Y-%m-%d") + + +def _count_opens_between(conn, start_td, end_td): + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _load_completed_live_pnls(conn): + q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, + result, reviewed_result + FROM trade_records + WHERE monitor_type = '下单监控' + ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" + rows = conn.execute(q).fetchall() + out = [] + for r in rows: + effective_result = (r["reviewed_result"] or r["result"] or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + continue + try: + p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + except (TypeError, ValueError): + p = 0.0 + t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) + td = get_trading_day(t) if t else None + out.append((p, t, td)) + return out + + +def _compute_period_metrics(trades): + """trades: list of (pnl, close_dt, close_trading_day)""" + trades = [(p, t, td) for p, t, td in trades if t is not None] + trades.sort(key=lambda x: x[1]) + closed = len(trades) + wins = sum(1 for p, _, _ in trades if p > 0) + losses = sum(1 for p, _, _ in trades if p < 0) + net = round(sum(p for p, _, _ in trades), 4) + loss_sum_raw = sum(p for p, _, _ in trades if p < 0) + loss_sum_u = round(abs(loss_sum_raw), 4) if loss_sum_raw < 0 else 0.0 + neg_pnls = [p for p, _, _ in trades if p < 0] + pos_pnls = [p for p, _, _ in trades if p > 0] + max_single_loss = round(min(neg_pnls), 4) if neg_pnls else None + max_single_profit = round(max(pos_pnls), 4) if pos_pnls else None + cum = peak = max_dd = 0.0 + for p, _, _ in trades: + cum += p + peak = max(peak, cum) + max_dd = max(max_dd, peak - cum) + max_dd = round(max_dd, 4) + streak = 0 + for p, _, _ in reversed(trades): + if p < 0: + streak += 1 + else: + break + daily = {} + for p, _, td in trades: + if td: + daily[td] = daily.get(td, 0.0) + p + max_loss_streak_days = 0 + worst_day = None + worst_day_pnl = None + if daily: + sorted_days = sorted(daily.keys()) + run = 0 + for d in sorted_days: + if daily[d] < 0: + run += 1 + max_loss_streak_days = max(max_loss_streak_days, run) + else: + run = 0 + worst_day = min(daily.keys(), key=lambda x: daily[x]) + worst_day_pnl = round(daily[worst_day], 4) + win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None + return { + "closed_count": closed, + "win_count": wins, + "loss_count": losses, + "win_rate_pct": win_rate_pct, + "net_pnl_u": net, + "loss_sum_u": loss_sum_u, + "max_single_loss": max_single_loss, + "max_single_profit": max_single_profit, + "max_drawdown_u": max_dd, + "consecutive_losses": streak, + "max_loss_streak_days": max_loss_streak_days, + "worst_day": worst_day, + "worst_day_pnl": worst_day_pnl, + "opens_count": 0, + "range_label": "", + } + + +def compute_stats_bundle(conn, trading_day, now_dt=None): + """日 / 周 / 月 统计:平仓按平仓时间所在交易日计入。""" + now_dt = now_dt or app_now() + pnls = _load_completed_live_pnls(conn) + total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] + w_start, w_end = _session_week_bounds(trading_day) + m_start, m_end = _calendar_month_bounds(now_dt) + + def in_week(tr): + _p, _t, td = tr + return td and w_start <= td <= w_end + + def in_month(tr): + _p, _t, td = tr + return td and m_start <= td <= m_end + + day_trades = [tr for tr in pnls if tr[2] == trading_day] + week_trades = [tr for tr in pnls if in_week(tr)] + month_trades = [tr for tr in pnls if in_month(tr)] + + dm = _compute_period_metrics(day_trades) + wm = _compute_period_metrics(week_trades) + mm = _compute_period_metrics(month_trades) + dm["opens_count"] = _count_opens_between(conn, trading_day, trading_day) + wm["opens_count"] = _count_opens_between(conn, w_start, w_end) + mm["opens_count"] = _count_opens_between(conn, m_start, m_end) + dm["range_label"] = f"北京时间交易日 {trading_day}" + wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天窗口)" + mm["range_label"] = f"{m_start} ~ {m_end}(北京时间自然月)" + + return { + "trading_day": trading_day, + "total_opens_all": total_opens_all, + "day": dm, + "week": wm, + "month": mm, + } + + +def infer_leverage(symbol): + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return BTC_LEVERAGE + return ALT_LEVERAGE + + +def normalize_exchange_symbol(symbol): + sym = symbol.strip().upper() + if ":" in sym: + return sym + if "/" in sym: + base, quote = sym.split("/", 1) + quote_clean = quote.split(":")[0] + return f"{base}/{quote_clean}:{quote_clean}" + return sym + + +def resolve_monitor_exchange_symbol(row): + """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" + raw = "" + try: + if row["exchange_symbol"]: + raw = str(row["exchange_symbol"]).strip() + except (KeyError, IndexError, TypeError): + raw = "" + if not raw: + try: + raw = str(row["symbol"] or "").strip() + except (KeyError, IndexError, TypeError): + raw = "" + return normalize_exchange_symbol(raw) if raw else "" + + +def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): + if not position_symbol or not wanted_exchange_symbol: + return False + a = normalize_exchange_symbol(str(position_symbol).strip()) + b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip()) + return a == b + + +def _row_matches_monitor_direction(direction, position_dict): + """ + 判断持仓行是否属于当前监控方向。 + 币安双向持仓为 LONG/SHORT;单向持仓常为 BOTH,此时不能用 side!=direction 过滤, + 否则会把整行跳过(live 恒为 0),平仓数量错误甚至误判「无仓」。 + """ + if not position_dict: + return False + direction = (direction or "").strip().lower() + info = position_dict.get("info", {}) or {} + ps = str( + info.get("positionSide") + or position_dict.get("side") + or info.get("posSide") + or "" + ).strip().lower() + signed_amt = None + for key in ("positionAmt", "pos", "size"): + v = info.get(key) + if v is None or v == "": + continue + try: + signed_amt = float(v) + break + except (TypeError, ValueError): + continue + if BINANCE_POSITION_MODE != "hedge": + return True + if ps in ("long", "short"): + return ps == direction + if ps in ("both", "net") or ps == "": + if signed_amt is None: + return True + if direction == "long": + return signed_amt > 0 + if direction == "short": + return signed_amt < 0 + return False + if ps and ps != direction: + return False + return True + + +def _position_matches_wanted_contract(wanted_unified_sym, position_dict): + """统一 symbol 比对;不一致时用交易所原始合约代码与 ccxt market.id 对齐(兼容命名差异)。""" + if not wanted_unified_sym or not position_dict: + return False + ps = position_dict.get("symbol") + if _position_contract_symbol_match(ps, wanted_unified_sym): + return True + try: + ensure_markets_loaded() + mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper() + info = position_dict.get("info") or {} + c_raw = str(info.get("contract") or info.get("symbol") or info.get("pair") or "").strip().upper() + if mid and c_raw and mid == c_raw: + return True + except Exception: + pass + return False + + +def _position_row_effective_contracts(p): + """持仓数量:优先 ccxt contracts,否则用交易所原始 positionAmt/size/pos(避免统一层为 0 时被误判空仓)。""" + if not p: + return 0.0 + info = p.get("info") or {} + for val in (p.get("contracts"), info.get("positionAmt"), info.get("size"), info.get("pos")): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return x + except (TypeError, ValueError): + continue + return 0.0 + + +def normalize_symbol_input(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym + if ":" in sym: + sym = sym.split(":")[0] + return f"{sym}/USDT" + + +def normalize_kline_limit(limit_raw, default=200): + try: + n = int(limit_raw) + except Exception: + return default + return 200 if n >= 200 else 100 + + +def get_recommended_capital(current_capital): + if current_capital <= DAILY_LOSS_CAPITAL: + return DAILY_LOSS_CAPITAL + if current_capital >= DAILY_PROFIT_CAPITAL: + return DAILY_PROFIT_CAPITAL + return DAILY_START_CAPITAL + + +def ensure_session(conn, session_date): + row = conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + if row: + return row + conn.execute( + "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", + (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) + ) + conn.commit() + return conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + + +def update_session_capital(conn, session_date, pnl_amount): + session_row = ensure_session(conn, session_date) + new_capital = float(session_row["current_capital"]) + float(pnl_amount) + conn.execute( + "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(new_capital, 4), session_date) + ) + conn.commit() + return round(new_capital, 4) + + +def calc_hold_seconds(opened_at_str, closed_at_dt): + try: + opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") + return int((closed_at_dt - opened_at).total_seconds()) + except Exception: + return 0 + + +def calc_hold_minutes(seconds): + if not seconds or seconds <= 0: + return 0 + return max(1, int(seconds // 60)) + + +def get_opened_at_value(row): + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "opened_at" in keys: + value = row["opened_at"] + if value: + return value + return app_now_str() + + +def get_effective_trade_field(row, reviewed_key, base_key, default=None): + try: + keys = row.keys() if hasattr(row, "keys") else row.keys() + except Exception: + keys = [] + if reviewed_key in keys: + v = row[reviewed_key] + if v is not None and str(v).strip() != "": + return v + if base_key in keys: + v = row[base_key] + if v is not None and str(v).strip() != "": + return v + return default + + +def to_effective_trade_dict(row): + item = row_to_dict(row) + base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") + item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) + item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) + item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", base_stop) + item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) + item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) + item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason")) + item["effective_pnl_amount"] = get_effective_trade_field(row, "reviewed_pnl_amount", "pnl_amount", item.get("pnl_amount")) + item["effective_hold_minutes"] = get_effective_trade_field(row, "reviewed_hold_minutes", "hold_minutes", item.get("hold_minutes")) + item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds")) + er_eff = get_effective_trade_field(row, "reviewed_entry_reason", "entry_reason", item.get("entry_reason")) + item["effective_entry_reason"] = (str(er_eff).strip() if er_eff is not None else "") or "" + return item + + +def format_price_for_symbol(symbol, value): + if value in (None, ""): + return "-" + try: + v = float(value) + except Exception: + return str(value) + if v == 0: + return "0" + av = abs(v) + # 根据币价量级动态精度:低价币保留更多小数,高价币减少噪音位数 + if av >= 10000: + d = 2 + elif av >= 100: + d = 3 + elif av >= 1: + d = 4 + elif av >= 0.01: + d = 6 + elif av >= 0.0001: + d = 8 + else: + d = 10 + text = f"{v:.{d}f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +def format_hold_minutes(minutes): + if not minutes: + return "0分钟" + total = int(minutes) + hours = total // 60 + mins = total % 60 + if hours: + return f"{hours}小时{mins}分钟" + return f"{mins}分钟" + + +def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage): + try: + trigger = float(trigger_price) + exit_p = float(exit_price) + margin = float(margin_capital) + lev = float(leverage) + if trigger <= 0: + return 0.0 + if direction == "short": + pnl_ratio = (trigger - exit_p) / trigger + else: + pnl_ratio = (exit_p - trigger) / trigger + return round(margin * lev * pnl_ratio, 4) + except Exception: + return 0.0 + + +def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): + try: + entry = float(entry_price) + sl = float(stop_loss) + tp = float(take_profit) + if entry <= 0 or sl <= 0 or tp <= 0: + return None + if direction == "short": + risk = sl - entry + reward = entry - tp + else: + risk = entry - sl + reward = tp - entry + if risk <= 0 or reward <= 0: + return None + return round(reward / risk, 4) + except Exception: + return None + + +def calc_risk_fraction(direction, entry_price, stop_loss): + try: + entry = float(entry_price) + sl = float(stop_loss) + if entry <= 0 or sl <= 0: + return None + if direction == "short": + risk = sl - entry + else: + risk = entry - sl + if risk <= 0: + return None + return risk / entry + except Exception: + return None + + +def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital, leverage): + rf = calc_risk_fraction(direction, entry_price, stop_loss) + if rf is None: + return None + try: + notional = float(margin_capital) * float(leverage) + if notional <= 0: + return None + return round(notional * rf, 6) + except Exception: + return None + + +def calc_actual_rr(pnl_amount, risk_amount): + try: + r = float(risk_amount or 0) + if r <= 0: + return None + return round(float(pnl_amount or 0) / r, 4) + except Exception: + return None + + +def normalize_result_with_pnl(result, pnl_amount): + """ + 触发“止损”但实际已盈利时,归类为保本止盈,避免语义混淆。 + """ + if result == "止损": + try: + if float(pnl_amount or 0) > 0: + return "保本止盈" + except Exception: + pass + return result + + +def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): + """ + 按“已锁定R”计算目标止损位: + - long: entry + locked_r * (entry*risk_fraction) + offset + - short: entry - locked_r * (entry*risk_fraction) - offset + """ + try: + entry = float(entry_price) + rf = float(risk_fraction) + lr = float(locked_r) + off = float(offset_pct) / 100.0 + if entry <= 0 or rf <= 0 or lr < 0: + return None + base_move = entry * rf * lr + offset_move = entry * off + if direction == "short": + return round(entry - base_move - offset_move, 8) + return round(entry + base_move + offset_move, 8) + except Exception: + return None + + +def insert_trade_record( + conn, + symbol, + monitor_type, + direction, + trigger_price, + stop_loss, + initial_stop_loss=None, + take_profit=None, + margin_capital=None, + leverage=None, + pnl_amount=0, + hold_seconds=0, + trade_style=None, + risk_amount=None, + planned_rr=None, + actual_rr=None, + result="", + miss_reason=None, + opened_at=None, + opened_at_ms=None, + closed_at=None, + closed_at_ms=None, + exchange_trade_id=None, +): + hold_minutes = calc_hold_minutes(hold_seconds) + open_ts = opened_at or app_now_str() + close_ts = closed_at or app_now_str() + open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) + close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) + conn.execute( + "INSERT INTO trade_records (symbol,monitor_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, monitor_type, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, + margin_capital, leverage, pnl_amount, hold_seconds, + trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id + ) + ) + + +def calc_duration_text(open_str, close_str): + try: + fmt = "%Y-%m-%dT%H:%M" + o = datetime.strptime(open_str, fmt) + c = datetime.strptime(close_str, fmt) + delta = c - o + seconds = int(delta.total_seconds()) + if seconds <= 0: + return "0分钟" + d = seconds // 86400 + h = (seconds % 86400) // 3600 + m = (seconds % 3600) // 60 + parts = [] + if d: + parts.append(f"{d}天") + if h: + parts.append(f"{h}小时") + if m or not parts: + parts.append(f"{m}分钟") + return " ".join(parts) + except Exception: + return "计算失败" + + +def row_to_dict(row): + return {k: row[k] for k in row.keys()} + + +def enrich_order_item(raw_item, current_capital): + item = dict(raw_item or {}) + margin = float(item.get("margin_capital") or 0) + lev = float(item.get("leverage") or 0) + notional = item.get("notional_value") + ratio = item.get("position_ratio") + if notional is None: + notional = round(margin * lev, 4) if margin and lev else 0 + if ratio is None: + ratio = round(margin / current_capital * 100, 2) if current_capital else 0 + item["notional_value"] = notional + item["position_ratio"] = ratio + item["rr_ratio"] = calc_rr_ratio( + item.get("direction") or "long", + item.get("trigger_price"), + item.get("initial_stop_loss") or item.get("stop_loss"), + item.get("take_profit"), + ) + try: + be = item.get("breakeven_enabled") + item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 + except Exception: + item["breakeven_enabled"] = 1 + return item + + +def ensure_exchange_live_ready(): + if not LIVE_TRADING_ENABLED: + return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + if not (BINANCE_API_KEY and BINANCE_API_SECRET): + return False, "缺少 Binance API 密钥配置(BINANCE_API_KEY / BINANCE_API_SECRET)" + return True, "" + + +def exchange_private_api_configured(): + """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。""" + return bool(BINANCE_API_KEY and BINANCE_API_SECRET) + + +def _extract_usdt_total(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + total_map = balance.get("total", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + total = usdt_info.get("total") + if total is None: + total = usdt_info.get("equity") + if total is None: + total = total_map.get("USDT") + if total is None: + total = usdt_info.get("free") + if total is None: + total = free_map.get("USDT") + try: + return float(total) if total is not None else None + except Exception: + return None + + +def _extract_usdt_free(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + free = usdt_info.get("free") + if free is None: + free = free_map.get("USDT") + try: + return float(free) if free is not None else None + except Exception: + return None + + +def _binance_futures_usdt_asset_row(balance): + """从 U 本位合约 fetch_balance 的 info.assets 中取 USDT 一行(与币安后台口径一致)。""" + if not isinstance(balance, dict): + return None + info = balance.get("info") + if not isinstance(info, dict): + return None + assets = info.get("assets") + if not isinstance(assets, list): + return None + for a in assets: + if isinstance(a, dict) and str(a.get("asset") or "").upper() == "USDT": + return a + return None + + +def _fetch_binance_swap_usdt_total(): + """仅 U 本位永续合约账户 USDT(总额口径:优先 marginBalance / walletBalance,不回退现货)。""" + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "swap"}) + row = _binance_futures_usdt_asset_row(bal) + if row: + for k in ("marginBalance", "walletBalance", "crossWalletBalance", "balance"): + x = row.get(k) + if x is not None and str(x).strip() != "": + try: + fv = float(x) + if fv >= 0: + return fv + except (TypeError, ValueError): + pass + v = _extract_usdt_total(bal) + return float(v) if v is not None else None + except Exception: + return None + + +def _fetch_binance_swap_usdt_free(): + """U 本位合约账户 USDT 可用(开仓可用保证金口径,不回退现货)。""" + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "swap"}) + row = _binance_futures_usdt_asset_row(bal) + if row: + for k in ("availableBalance", "maxWithdrawAmount"): + x = row.get(k) + if x is not None and str(x).strip() != "": + try: + fv = float(x) + if fv >= 0: + return fv + except (TypeError, ValueError): + pass + return _extract_usdt_free(bal) + except Exception: + return None + + +def _fetch_binance_funding_usdt(): + """Binance 资金账户(Funding Wallet)USDT 总额。""" + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "funding"}) + val = _extract_usdt_total(bal) + if val is not None: + return float(val) + except Exception: + pass + return None + + +def get_available_trading_usdt(): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None + return _fetch_binance_swap_usdt_free() + + +def get_synced_leverage(exchange_symbol, direction): + ensure_markets_loaded() + try: + positions = exchange.fetch_positions([exchange_symbol]) + for p in positions: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + if not _row_matches_monitor_direction(direction, p): + continue + info = p.get("info", {}) or {} + if lev is None or lev == 0 or str(lev) == "0": + lev = info.get("cross_leverage_limit") or info.get("leverage") + if lev: + try: + return int(float(lev)) + except Exception: + pass + except Exception: + pass + return None + + +def friendly_exchange_error(err, available_usdt=None): + msg = str(err) + low = msg.lower() + if ( + "51008" in msg + or "insufficient" in low + or "margin" in low and ("not enough" in low or "不足" in msg) + or "balance" in low and "insufficient" in low + ): + tail = f"(当前交易账户可用约 {round(available_usdt, 4)}U)" if available_usdt is not None else "" + return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。" + clean = re.sub(r"\s+", " ", msg).strip() + return f"交易所下单失败:{clean}" + + +def get_exchange_capitals(force=False): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None, None + now_ts = time.time() + if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + try: + ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt() + except Exception: + ACCOUNT_BALANCE_CACHE["funding_usdt"] = None + try: + ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total() + except Exception: + # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 + ACCOUNT_BALANCE_CACHE["trading_usdt"] = None + ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + + +def execute_transfer_usdt(amount, from_account, to_account): + if amount <= 0: + return False, "划转金额必须大于0", None + ok_live, reason = ensure_exchange_live_ready() + if not ok_live: + return False, reason, None + try: + resp = exchange.transfer(TRANSFER_CCY, float(amount), from_account, to_account) + return True, "划转成功", resp + except Exception as e: + msg = str(e) + if "INVALID_KEY" in msg or "Invalid key" in msg or "-2015" in msg: + msg += ( + "。常见原因:① BINANCE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" + "③ API Key 未勾选「允许合约」「允许万向划转」等所需权限;④ Key 已重置或权限变更。" + ) + return False, msg, None + + +def get_account_usdt_total(account_type): + """读取各账户 USDT。funding 走资金钱包;swap 仅合约账户;spot 仅现货。""" + raw = (account_type or "").strip().lower() + if raw == "funding": + return _fetch_binance_funding_usdt() + if raw == "swap": + return _fetch_binance_swap_usdt_total() + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": raw}) + val = _extract_usdt_total(bal) + if val is not None: + return val + return 0.0 if raw == "spot" else None + except Exception: + return None + + +def auto_transfer_once_per_day(): + if not AUTO_TRANSFER_ENABLED: + return + utc_dt = utc_now_dt() + bj = utc_dt.astimezone(APP_TZ) + if bj.hour != AUTO_TRANSFER_BJ_HOUR: + return + transfer_day = utc_calendar_date_str() + conn = get_db() + exists = conn.execute( + "SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?", + ("auto_daily", transfer_day) + ).fetchone() + if exists: + conn.close() + return + target_amount = AUTO_TRANSFER_AMOUNT + to_balance = get_account_usdt_total(AUTO_TRANSFER_TO) + from_balance = get_account_usdt_total(AUTO_TRANSFER_FROM) + if to_balance is None: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"读取{AUTO_TRANSFER_TO}账户USDT失败") + ) + conn.commit() + conn.close() + return + needed = round(max(target_amount - float(to_balance), 0), 4) + if needed <= 0: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "skipped", f"{AUTO_TRANSFER_TO}账户已达到目标{target_amount}U") + ) + conn.commit() + conn.close() + return + if from_balance is not None and from_balance < needed: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"{AUTO_TRANSFER_FROM}账户USDT不足,需{needed}U,当前{round(from_balance,4)}U") + ) + conn.commit() + conn.close() + send_wechat_msg( + f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{needed}U,当前{round(from_balance,4)}U\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + return + + ok, msg, _ = execute_transfer_usdt(needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO) + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + send_wechat_msg( + f"自动划转成功:补足到{target_amount}U,实际划转{needed}U " + f"{AUTO_TRANSFER_FROM}->{AUTO_TRANSFER_TO}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + else: + send_wechat_msg( + f"自动划转失败:计划补足到{target_amount}U,需划转{needed}U\n原因:{msg}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + + +def trading_day_reset_allows_new_open(now): + """是否允许在满足其它风控的前提下于当前时刻新开仓(仅「整点前禁开」守卫)。""" + if not TRADING_DAY_RESET_OPEN_GUARD_ENABLED: + return True + return now.hour >= TRADING_DAY_RESET_HOUR + + +def precheck_risk(conn, symbol, direction): + now = app_now() + if not trading_day_reset_allows_new_open(now): + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0] + if active_count > 0: + return False, "一次只能持有一个仓位" + if direction not in ("long", "short"): + return False, "方向必须为 long 或 short" + if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): + expected = BTC_LEVERAGE + else: + expected = ALT_LEVERAGE + if expected <= 0: + return False, "杠杆配置异常" + return True, "" + + +def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): + ensure_markets_loaded() + notional = float(margin_capital) * float(leverage) + ticker = exchange.fetch_ticker(exchange_symbol) + price = float(ticker.get("last") or fallback_price) + if price <= 0: + raise ValueError("触发价必须大于 0") + market = exchange.market(exchange_symbol) + contract_size = float(market.get("contractSize") or 1) + if market.get("contract"): + # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 + amount = notional / (price * contract_size) + else: + amount = notional / price + min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") + if min_amount and amount < float(min_amount): + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") + amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) + if amount_precise <= 0: + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + return amount_precise, price + + +def _to_positive_float(value): + try: + n = float(value) + return n if n > 0 else None + except Exception: + return None + + +def _extract_order_price_value(order_obj): + if not isinstance(order_obj, dict): + return None + for key in ("average", "price"): + v = _to_positive_float(order_obj.get(key)) + if v is not None: + return v + cost = _to_positive_float(order_obj.get("cost")) + filled = _to_positive_float(order_obj.get("filled")) + if cost is not None and filled is not None and filled > 0: + return cost / filled + info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} + for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): + v = _to_positive_float(info.get(key)) + if v is not None: + return v + return None + + +def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): + price = _extract_order_price_value(order_resp) + if price is not None: + return round(price, 8) + order_id = (order_resp or {}).get("id") + if order_id: + try: + fetched = exchange.fetch_order(order_id, exchange_symbol) + fetched_price = _extract_order_price_value(fetched) + if fetched_price is not None: + return round(fetched_price, 8) + except Exception: + pass + fallback = _to_positive_float(fallback_price) + return round(fallback, 8) if fallback is not None else 0.0 + + +def get_contract_size(exchange_symbol): + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + return float(market.get("contractSize") or 1) + + +def parse_positive_float(value): + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + num = float(raw) + if num <= 0: + raise ValueError("数值必须大于0") + return num + + +def build_binance_order_params(direction, reduce_only=False): + params = {} + if BINANCE_POSITION_MODE == "hedge": + params["positionSide"] = "LONG" if direction == "long" else "SHORT" + if reduce_only: + params["reduceOnly"] = True + return params + + +def _binance_market_close_param_candidates(direction): + """ + 平仓市价单参数组合(按顺序尝试)。 + 部分币安 U 本位账户对市价减仓报 -1106「reduceOnly sent when not required」, + 与条件单一致,需再试不带 reduceOnly 的写法;另保留双向/单向 positionSide 切换。 + """ + ps = "LONG" if direction == "long" else "SHORT" + hedge_ro = {"positionSide": ps, "reduceOnly": True} + hedge_plain = {"positionSide": ps} + oneway_ro = {"reduceOnly": True} + oneway_plain = {} + if BINANCE_POSITION_MODE == "hedge": + return [hedge_ro, hedge_plain, oneway_ro, oneway_plain] + return [oneway_ro, oneway_plain, hedge_ro, hedge_plain] + + +def _is_binance_close_param_retryable(err_msg): + s = (err_msg or "").lower() + if "-4061" in s: + return True + if "-1106" in s and ("reduceonly" in s or "reduce only" in s): + return True + if "position side" in s or "positionside" in s: + return True + if "dual side" in s or "position mode" in s: + return True + return False + + +def _filled_amount_for_tpsl(order, fallback_amount): + for key in ("filled", "amount"): + v = order.get(key) + try: + fv = float(v) + if fv > 0: + return fv + except Exception: + pass + return float(fallback_amount) + + +def _binance_trigger_order_params(): + p = {} + if BINANCE_TRIGGER_WORKING_TYPE: + p["workingType"] = BINANCE_TRIGGER_WORKING_TYPE + return p + + +def _binance_place_tp_sl_orders(exchange_symbol, direction, position_amount, stop_loss, take_profit): + """ + Binance USDT-M 永续:市价开仓成交后,挂 STOP_MARKET(止损)与 TAKE_PROFIT_MARKET(止盈)。 + 双向持仓时带 positionSide。不显式传 reduceOnly(否则会报 -1106 Parameter 'reduceOnly' sent when not required)。 + """ + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + if not market.get("swap"): + raise RuntimeError("仅支持永续合约 symbol") + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(position_amount))) + if amt <= 0: + raise RuntimeError("止盈止损:可平数量经精度舍入后为 0") + sl_px = exchange.price_to_precision(exchange_symbol, float(stop_loss)) + tp_px = exchange.price_to_precision(exchange_symbol, float(take_profit)) + common = dict(_binance_trigger_order_params()) + if BINANCE_POSITION_MODE == "hedge": + common["positionSide"] = "LONG" if direction == "long" else "SHORT" + last_err = None + for attempt in range(8): + try: + exchange.create_order( + exchange_symbol, + "STOP_MARKET", + close_side, + amt, + None, + dict(common, stopPrice=sl_px), + ) + time.sleep(0.05) + exchange.create_order( + exchange_symbol, + "TAKE_PROFIT_MARKET", + close_side, + amt, + None, + dict(common, stopPrice=tp_px), + ) + return + except Exception as e: + last_err = e + try: + cancel_binance_futures_open_orders(exchange_symbol) + except Exception: + pass + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"Binance 未接受止盈/止损触发单:{last_err}") + + +def ensure_markets_loaded(force=False): + global MARKETS_LOADED + if force or not MARKETS_LOADED: + exchange.load_markets(reload=force) + MARKETS_LOADED = True + + +def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): + ensure_markets_loaded() + mm = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" + try: + exchange.set_margin_mode(mm, exchange_symbol) + except Exception: + pass + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + params = build_binance_order_params(direction, reduce_only=False) + order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + order.setdefault("tpsl_attached", False) + if stop_loss and take_profit: + try: + pos_amt = _filled_amount_for_tpsl(order, amount) + _binance_place_tp_sl_orders(exchange_symbol, direction, pos_amt, stop_loss, take_profit) + order["tpsl_attached"] = True + except RuntimeError: + raise + except Exception as e: + raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e + return order + + +def close_exchange_order(order_row): + """ + 市价全平。数量优先取交易所当前持仓张数,避免仅用入库的 order_amount + 导致「只平一部分 → 撤单后委托没了但仓位还在」(加仓、精度或成交与计划不一致时常见)。 + """ + ensure_markets_loaded() + exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"]) + direction = order_row["direction"] + db_amt = float(order_row["order_amount"] or 0) + side = "sell" if direction == "long" else "buy" + last_resp = None + for _ in range(3): + live = get_live_position_contracts(exchange_symbol, direction) + if live is not None and live > 0: + raw_amt = live + else: + raw_amt = db_amt + if raw_amt <= 0: + if last_resp is not None: + return last_resp + raise ValueError("平仓失败:缺少有效下单数量") + try: + amount = float(exchange.amount_to_precision(exchange_symbol, raw_amt)) + except Exception: + amount = float(raw_amt) + if amount <= 0: + if last_resp is not None: + return last_resp + raise ValueError("平仓失败:数量经精度舍入后为 0") + order_resp = None + last_close_err = None + for params in _binance_market_close_param_candidates(direction): + try: + order_resp = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + last_close_err = None + break + except Exception as e: + last_close_err = e + if _is_binance_close_param_retryable(str(e)): + continue + raise + if order_resp is None: + raise last_close_err if last_close_err else RuntimeError("平仓失败:交易所未返回结果") + last_resp = order_resp + live_after = get_live_position_contracts(exchange_symbol, direction) + if live_after is None or live_after <= 0: + return last_resp + return last_resp + + +def cancel_binance_futures_open_orders(exchange_symbol): + """ + 平仓后撤销该合约下剩余挂单,避免孤儿单残留。 + Binance U 本位:普通挂单走 cancel_all_orders(DELETE allOpenOrders); + 止盈/止损等条件单在「Algo」通道,需再调 DELETE algoOpenOrders,否则手动平仓后仍会留在「当前委托」。 + """ + ok, _ = ensure_exchange_live_ready() + if not ok or not exchange_symbol: + return + ensure_markets_loaded() + sym = exchange_symbol + try: + exchange.cancel_all_orders(sym, params={}) + except Exception: + pass + try: + market = exchange.market(sym) + contract_id = market.get("id") + if contract_id and hasattr(exchange, "fapiPrivateDeleteAlgoOpenOrders"): + exchange.fapiPrivateDeleteAlgoOpenOrders({"symbol": contract_id}) + except Exception: + pass + try: + pending = exchange.fetch_open_orders(sym) + except Exception: + return + for o in pending or []: + oid = o.get("id") + if oid is None: + continue + try: + exchange.cancel_order(str(oid), sym) + except Exception: + pass + + +def extract_trade_price_from_order(order): + if not order: + return None + for k in ("average", "avgPrice", "price"): + try: + v = float(order.get(k) or 0) + if v > 0: + return v + except Exception: + pass + try: + info = order.get("info") or {} + if isinstance(info, dict): + for k in ("fillPx", "avgPx", "fill_price"): + v = float(info.get(k) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def is_no_position_error(err_msg): + msg = (err_msg or "").lower() + # 禁止匹配笼统的 reduceonly / -4061:会与参数错误、单向/双向模式不匹配混淆, + # 误判后走「已无仓」同步结束,交易所仓位却仍在。 + keywords = [ + "no position", + "position does not exist", + "position not exist", + "nothing to close", + "pos size is 0", + "position amount is 0", + ] + return any(k in msg for k in keywords) + + +def get_live_position_contracts(exchange_symbol, direction): + ensure_markets_loaded() + try: + rows = exchange.fetch_positions([exchange_symbol]) + except Exception: + return None + total = 0.0 + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + if not _row_matches_monitor_direction(direction, p): + continue + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + total += contracts + return total + + +def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): + """在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。""" + if not rows: + return None + candidates = [] + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + if (not relax_hedge) and not _row_matches_monitor_direction(direction, p): + continue + candidates.append((contracts, p)) + if not candidates and (not relax_hedge) and BINANCE_POSITION_MODE == "hedge": + return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) + if not candidates: + return None + candidates.sort(key=lambda x: x[0], reverse=True) + return candidates[0][1] + + +def _coerce_float(*values): + for v in values: + if v is None or v == "": + continue + try: + return float(v) + except (TypeError, ValueError): + continue + return None + + +def parse_ccxt_position_metrics(position, order_leverage=None): + """ + 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏。 + 「所保证金」对齐币安合约页的初始/持仓保证金:优先 initialMargin / positionInitialMargin。 + Binance 全仓下 ccxt 的 collateral 常来自 crossMargin,口径易与「名义」混淆,故不全仓优先用 collateral。 + """ + if not position: + return None + p = position + info = p.get("info", {}) or {} + margin_mode = str(p.get("marginMode") or info.get("marginType") or "").lower() + isolated = margin_mode.startswith("isolated") or str(info.get("isolated", "")).lower() == "true" + + initial = _coerce_float( + p.get("initialMargin"), + info.get("positionInitialMargin"), + info.get("initialMargin"), + ) + if (initial is None or initial <= 0) and isolated: + initial = _coerce_float(p.get("collateral"), info.get("isolatedWallet")) + if initial is None or initial <= 0: + initial = _coerce_float(p.get("margin")) + if initial is None or initial <= 0: + initial = _coerce_float( + info.get("initial_margin"), + info.get("position_margin"), + info.get("iso_margin"), + ) + notional = _coerce_float(p.get("notional"), p.get("notionalValue")) + if notional is None or notional <= 0: + notional = _coerce_float(info.get("value")) + if notional is not None: + notional = abs(notional) + # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) + if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: + try: + lev = float(order_leverage) + if lev > 0: + approx = notional / lev + if approx > 0: + initial = approx + except (TypeError, ValueError): + pass + unrealized = _coerce_float( + p.get("unrealizedPnl"), + info.get("unrealised_pnl"), + info.get("unrealized_pnl"), + ) + mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) + out = {} + if initial is not None and initial > 0: + out["initial_margin"] = round(initial, 4) + if notional is not None and notional > 0: + out["notional"] = round(notional, 4) + if unrealized is not None: + out["unrealized_pnl"] = round(unrealized, 6) + if mark is not None and mark > 0: + out["mark_price"] = round(mark, 8) + return out or None + + +def get_live_position_exchange_metrics(exchange_symbol, direction): + ensure_markets_loaded() + if not exchange_private_api_configured() or not exchange_symbol: + return None + try: + rows = exchange.fetch_positions() or [] + except Exception: + try: + rows = exchange.fetch_positions([exchange_symbol]) or [] + except Exception: + return None + p = _select_live_position_row(rows, exchange_symbol, direction) + return parse_ccxt_position_metrics(p) + + +def opened_at_str_to_ms(opened_at_str): + if not opened_at_str: + return None + try: + dt = datetime.strptime(str(opened_at_str).strip()[:19], "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + try: + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + return None + + +def _to_ms_with_fallback(ms_value, dt_str): + try: + if ms_value is not None and str(ms_value).strip() != "": + v = int(float(ms_value)) + if v > 0: + return v + except Exception: + pass + return opened_at_str_to_ms(dt_str) + + +def ms_to_app_local_str(ms): + if ms is None: + return app_now_str() + try: + dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) + return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return app_now_str() + + +def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): + """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" + try: + tp = float(take_profit) + sl = float(stop_loss) + ex = float(exit_price) + trig = float(trigger_price) + except (TypeError, ValueError): + return None + band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12) + if direction == "long": + if ex >= tp - band: + return "止盈" + if ex <= sl + band: + return "止损" + else: + if ex <= tp + band: + return "止盈" + if ex >= sl - band: + return "止损" + return None + + +def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" + if not (BINANCE_API_KEY and BINANCE_API_SECRET): + return None + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + + def pick_from_trades(trades): + if not trades: + return None + candidates = [] + for t in trades: + if (t.get("side") or "").lower() != close_side: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if BINANCE_POSITION_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + ts = t.get("timestamp") + if ts is None: + continue + candidates.append(t) + if not candidates: + return None + return max(candidates, key=lambda x: x.get("timestamp") or 0) + + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) + hit = pick_from_trades(trades) + if hit is None and since_ms: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100) + hit = pick_from_trades(trades) + return hit + except Exception: + return None + + +def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): + """ + 拉取某条历史记录对应的减仓成交(用于按 id 回填)。 + 返回按时间排序的成交列表。 + """ + if not (BINANCE_API_KEY and BINANCE_API_SECRET): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + # 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况 + if closed_ms is not None: + closed_ms += 6 * 60 * 60 * 1000 + candidates = [] + all_side_candidates = [] + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + if not trades and since_ms: + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200) + except Exception: + trades = [] + for t in trades or []: + if (t.get("side") or "").lower() != close_side: + continue + ts = t.get("timestamp") + if ts is None: + continue + try: + ts = int(ts) + except Exception: + continue + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if BINANCE_POSITION_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + all_side_candidates.append(t) + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + candidates.append(t) + candidates.sort(key=lambda x: x.get("timestamp") or 0) + if candidates: + return candidates + + # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。 + all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) + if not all_side_candidates: + return [] + if not closed_ms: + return all_side_candidates[-20:] + near = [] + for t in all_side_candidates: + ts = t.get("timestamp") + if ts is None: + continue + try: + delta = abs(int(ts) - int(closed_ms)) + except Exception: + continue + # 放宽到前后 7 天 + if delta <= 7 * 24 * 60 * 60 * 1000: + near.append((delta, t)) + if near: + near.sort(key=lambda x: x[0]) + picked = [x[1] for x in near[:20]] + picked.sort(key=lambda x: x.get("timestamp") or 0) + return picked + return all_side_candidates[-20:] + + +def calc_weighted_exit_price(trades): + if not trades: + return None + total_amount = 0.0 + weighted_sum = 0.0 + for t in trades: + try: + price = float(t.get("price") or 0) + amount = float(t.get("amount") or 0) + except Exception: + continue + if price <= 0: + continue + if amount <= 0: + amount = 1.0 + weighted_sum += price * amount + total_amount += amount + if total_amount <= 0: + return None + return weighted_sum / total_amount + + +def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): + """ + 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 + 返回 (result, pnl_amount, closed_at_str, miss_reason)。 + """ + direction = row["direction"] + sym = row["symbol"] + trigger_price = row["trigger_price"] + stop_loss = row["stop_loss"] + take_profit = row["take_profit"] + margin_capital = row["margin_capital"] or DAILY_START_CAPITAL + leverage = row["leverage"] or infer_leverage(sym) + exchange_symbol = row["exchange_symbol"] or normalize_exchange_symbol(sym) + + trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) + exit_px = None + closed_at_str = app_now_str() + if trade: + try: + exit_px = float(trade.get("price") or 0) or None + except (TypeError, ValueError): + exit_px = None + ts = trade.get("timestamp") + if ts: + closed_at_str = ms_to_app_local_str(int(ts)) + + if exit_px is None or exit_px <= 0: + p = get_price(sym) + if p: + guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) + if guessed: + pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + return ( + guessed, + pnl, + closed_at_str, + "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", + ) + return ( + "外部平仓", + 0.0, + closed_at_str, + "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", + ) + + result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) + pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) + if result: + return ( + result, + pnl, + closed_at_str, + "按交易所成交记录同步为止盈/止损平仓", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def reconcile_external_closes(conn, days=None): + synced_count = 0 + cutoff_ms = None + if days is not None: + try: + d = int(days) + if d > 0: + cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) + except Exception: + cutoff_ms = None + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + if cutoff_ms is not None: + opened_at_v = get_opened_at_value(r) + opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 + if opened_ms is None or opened_ms < cutoff_ms: + continue + exchange_symbol = r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + continue + if live_contracts > 0: + continue + cancel_binance_futures_open_orders(exchange_symbol) + opened_at = get_opened_at_value(r) + opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type="下单监控", + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=r["margin_capital"], + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=miss_reason, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result=f"{result}(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + else: + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result="外部平仓(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + synced_count += 1 + return synced_count + +# 获取实时价格 +def get_price(symbol): + try: + ensure_markets_loaded() + return exchange.fetch_ticker(normalize_exchange_symbol(symbol))["last"] + except: + return None + +# 获取5分钟K线收盘价 +def get_5m_close(symbol): + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), KLINE_TIMEFRAME, limit=1) + return ohlcv[-1][4] if ohlcv else None + except: + return None + + +def _safe_float(v): + try: + return float(v) + except Exception: + return None + + +def _compute_ema(values, period=55): + arr = [float(x) for x in values if x is not None] + if len(arr) < period: + return None + k = 2.0 / (period + 1.0) + ema = arr[0] + for val in arr[1:]: + ema = val * k + ema * (1 - k) + return ema + + +def _status_by_ema55(symbol, timeframe): + try: + bars = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), timeframe=timeframe, limit=80) + if not bars or len(bars) < 56: + return "横盘", None, None + closes = [float(x[4]) for x in bars if x and len(x) >= 5] + ema55 = _compute_ema(closes, 55) + last_close = closes[-1] + if ema55 is None or last_close <= 0: + return "横盘", last_close, ema55 + diff_pct = (last_close - ema55) / ema55 * 100.0 + if abs(diff_pct) < 0.1: + return "横盘", last_close, ema55 + return ("多头" if diff_pct > 0 else "空头"), last_close, ema55 + except Exception: + return "横盘", None, None + + +def _daily_volume_rank(symbol): + """ + 返回(symbol_rank, total_count),按 quoteVolume 降序,缺失时 fallback 到 baseVolume*last。 + """ + sym_norm = normalize_symbol_input(symbol) + target_base = journal_coin_from_symbol(sym_norm) + + def _ticker_base(sym_text): + s = str(sym_text or "").upper().strip() + if ":" in s: + s = s.split(":", 1)[0] + if "/" in s: + return s.split("/", 1)[0].strip() + if "-" in s: + return s.split("-", 1)[0].strip() + if s.endswith("USDT"): + return s[:-4].strip() + return s + now_ts = time.time() + cached_ok = ( + LIQUIDITY_RANK_CACHE["updated_at"] + and now_ts - float(LIQUIDITY_RANK_CACHE["updated_at"]) < max(30, BALANCE_REFRESH_SECONDS) + ) + if not cached_ok: + try: + ensure_markets_loaded() + tickers = exchange.fetch_tickers() + scored = [] + for s, t in (tickers or {}).items(): + try: + mk = exchange.markets.get(s) + if not mk or not mk.get("swap"): + continue + su = str(s).upper() + if "USDT" not in su: + continue + qv = _safe_float((t or {}).get("quoteVolume")) + if qv is None: + info = (t or {}).get("info") if isinstance((t or {}).get("info"), dict) else {} + qv = _safe_float(info.get("volCcy24h") or info.get("vol24h")) + if qv is None: + bv = _safe_float((t or {}).get("baseVolume")) + lp = _safe_float((t or {}).get("last")) + if bv is not None and lp is not None: + qv = bv * lp + if qv is None or qv <= 0: + continue + scored.append((_ticker_base(s), float(qv))) + except Exception: + continue + scored.sort(key=lambda x: x[1], reverse=True) + ranks = {} + for idx, (base, _) in enumerate(scored, 1): + if base and base not in ranks: + ranks[base] = idx + LIQUIDITY_RANK_CACHE["ranks"] = ranks + LIQUIDITY_RANK_CACHE["total"] = len(scored) + LIQUIDITY_RANK_CACHE["updated_at"] = now_ts + except Exception: + pass + ranks = LIQUIDITY_RANK_CACHE.get("ranks") or {} + total = int(LIQUIDITY_RANK_CACHE.get("total") or 0) + return ranks.get(target_base), total + + +def _key_hard_checks(symbol, direction, upper, lower, monitor_type): + """ + 关键位门控:量能、突破幅度、第二根确认、日成交量前30。 + 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根。 + """ + out = {"ok": False} + ex_sym = normalize_exchange_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=80) or [] + if len(bars) < 24: + out["reason"] = "5m K线数量不足" + return out + closed = bars[:-1] if len(bars) >= 3 else bars + if len(closed) < 23: + out["reason"] = "闭合K线不足" + return out + breakout = closed[-2] + confirm = closed[-1] + prev20 = closed[-22:-2] + avg20 = sum(float(x[5]) for x in prev20) / max(len(prev20), 1) + vol_break = float(breakout[5]) + vol_ok = vol_break > avg20 * 1.3 if avg20 > 0 else False + open_b = float(breakout[1]) + close_b = float(breakout[4]) + high_b = float(breakout[2]) + low_b = float(breakout[3]) + amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0 + amp_ok = (amp_pct > 0.03) and (amp_pct < 0.5) + cfm_close = float(confirm[4]) + # 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿 + edge = float(upper) if direction == "long" else float(lower) + breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) + confirm_ok_raw = (cfm_close > edge) if direction == "long" else (cfm_close < edge) + # 口径收紧:未发生有效突破时,不标记幅度/二确通过,避免出现“还没到位却显示Y” + amp_ok = amp_ok and breakout_ok + confirm_ok = confirm_ok_raw and breakout_ok + rank, total = _daily_volume_rank(symbol) + rank_ok = (rank is not None) and (rank <= 30) + swing4h_pct = 0.0 + try: + seg48 = closed[-48:] if len(closed) >= 48 else closed + hh = max(float(x[2]) for x in seg48) + ll = min(float(x[3]) for x in seg48) + swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0 + except Exception: + swing4h_pct = 0.0 + out.update( + { + "ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]), + "vol_ok": vol_ok, + "avg20": avg20, + "vol_break": vol_break, + "amp_ok": amp_ok, + "amp_pct": amp_pct, + "breakout_ok": breakout_ok, + "breakout_close": close_b, + "confirm_ok": confirm_ok, + "confirm_close": cfm_close, + "edge_price": edge, + "rank": rank, + "rank_total": total, + "rank_ok": rank_ok, + "breakout_high": high_b, + "breakout_low": low_b, + "breakout_ts": breakout[0], + "confirm_ts": confirm[0], + "swing4h_pct": swing4h_pct, + "monitor_type": monitor_type, + "direction": direction, + } + ) + return out + + +def calc_price_diff_pct(current_price, target_price): + try: + if target_price is None: + return None, None + t = float(target_price) + if t == 0: + return None, None + c = float(current_price) + diff = c - t + pct = diff / t * 100 + return round(diff, 6), round(pct, 4) + except Exception: + return None, None + + +def can_notify_key_monitor(row, now_dt): + max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES) + if int(row["notification_count"] or 0) >= max_notify: + return False + last_at = row["last_notified_at"] + if not last_at: + return True + try: + last_dt = datetime.strptime(last_at, "%Y-%m-%d %H:%M:%S") + except Exception: + return True + interval_min = int(row["notify_interval_min"] or KEY_ALERT_INTERVAL_MINUTES) + return (now_dt - last_dt).total_seconds() >= interval_min * 60 + + +def breakout_too_far(p, edge_price, limit_pct): + try: + if edge_price is None or float(edge_price) <= 0: + return False + diff_pct = abs(float(p) - float(edge_price)) / float(edge_price) * 100 + return diff_pct > float(limit_pct) + except Exception: + return False + + +# 关键位监控 +def check_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + sym, typ, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + direction = (r["direction"] or "long").lower() + now_dt = app_now() + if not can_notify_key_monitor(r, now_dt): + continue + try: + checks = _key_hard_checks(sym, direction, up, low, typ) + except Exception: + checks = {"ok": False} + if not checks.get("ok"): + continue + btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h") + coin4h_status, _, _ = _status_by_ema55(sym, "4h") + risk_tip = None + if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"): + risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。" + box_h = abs(float(up) - float(low)) if up is not None and low is not None else 0.0 + c_close = float(checks.get("confirm_close") or 0) + b_high = float(checks.get("breakout_high") or 0) + b_low = float(checks.get("breakout_low") or 0) + key_price = float(low) if direction == "long" else float(up) + if direction == "long": + tp1 = c_close + box_h + tp2 = c_close + box_h * 1.5 + sl1 = b_low * (1 - 0.002) if b_low > 0 else None + sl2 = key_price * (1 - 0.002) if key_price > 0 else None + else: + tp1 = c_close - box_h + tp2 = c_close - box_h * 1.5 + sl1 = b_high * (1 + 0.002) if b_high > 0 else None + sl2 = key_price * (1 + 0.002) if key_price > 0 else None + hard_lines = [ + f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", + f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", + f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)", + f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})", + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)", + ] + op_lines = [ + f"方案A:止盈=箱体1.0倍({round(tp1, 8) if tp1 else '-' }),止损=突破K极值外0.2%({round(sl1, 8) if sl1 else '-' })", + f"方案B:止盈=箱体1.5倍({round(tp2, 8) if tp2 else '-' }),止损=箱体关键位外0.2%({round(sl2, 8) if sl2 else '-' })", + ] + trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() + msg = build_wechat_key_monitor_message( + symbol=sym, + direction=direction, + monitor_type=typ, + trigger_time=trigger_time, + key_price=key_price, + confirm_close=checks["confirm_close"], + hard_lines=hard_lines, + btc8h_status=btc8h_status, + coin4h_status=coin4h_status, + swing4h_pct=checks.get("swing4h_pct") or 0.0, + op_lines=op_lines, + risk_tip=risk_tip, + ) + send_wechat_msg(msg) + new_count = int(r["notification_count"] or 0) + 1 + max_n = int(r["max_notify"] or KEY_ALERT_MAX_TIMES) + conn.execute( + "UPDATE key_monitors SET notification_count = ?, last_notified_at = ? WHERE id = ?", + (new_count, app_now_str(), r["id"]), + ) + if new_count >= max_n: + insert_key_monitor_history(conn, r, new_count, msg, "alerts_complete") + conn.execute("DELETE FROM key_monitors WHERE id = ?", (r["id"],)) + send_wechat_msg( + "\n".join( + [ + f"# 🧾 {r['symbol']} 关键位监控结束", + "", + f"- 原因:已满 {max_n} 次提醒", + "- 状态:已自动结束并记入历史", + ] + ) + ) + conn.commit() + conn.close() + +# 止盈止损监控(已修复:严格区分多空,无默认做多) +def check_order_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(sym) + session_date = r["session_date"] or get_trading_day() + p = get_price(sym) + if not p: continue + + # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) + risk_amount = float(r["risk_amount"] or 0) + breakeven_armed = int(r["breakeven_armed"] or 0) + trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) + step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) + step_r = 1.0 if step_r <= 0 else step_r + breakeven_enabled = True + try: + if "breakeven_enabled" in r.keys(): + breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 + except Exception: + breakeven_enabled = True + if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: + now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + now_rr = now_pnl / risk_amount + if now_rr >= trigger_rr: + steps = int((now_rr - trigger_rr) // step_r) + locked_r = max(0.0, steps * step_r) + notional = float(margin_capital or 0) * float(leverage or 0) + risk_frac = (risk_amount / notional) if notional > 0 else None + if risk_frac and risk_frac > 0: + new_sl = calc_breakeven_stop( + direction, + trigger_price, + risk_frac, + locked_r=locked_r, + offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT), + ) + if new_sl is not None: + should_move = (direction == "short" and new_sl < float(stop_loss)) or ( + direction == "long" and new_sl > float(stop_loss) + ) + if should_move: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, pid), + ) + stop_loss = new_sl + arm_txt = "保本止盈" if not breakeven_armed else "移动止盈" + send_wechat_msg( + build_wechat_breakeven_message( + sym, + direction, + arm_txt, + now_rr, + locked_r, + new_sl, + ) + ) + + res = None + # 做多 + if direction == "long": + if p >= take_profit: res = "止盈" + elif p <= stop_loss: res = "止损" + # 做空 + elif direction == "short": + if p <= take_profit: res = "止盈" + elif p >= stop_loss: res = "止损" + + if res: + now = app_now() + opened_at = get_opened_at_value(r) + opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + if res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(res, pnl_amount) + close_order_id = "" + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细。 + exit_p = extract_trade_price_from_order(close_resp) + if exit_p and exit_p > 0: + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + else: + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + except Exception as e: + if is_no_position_error(str(e)): + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + cancel_binance_futures_open_orders(ex_sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判。 + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type="下单监控", + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason="触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + opened_at=opened_at, + closed_at=closed_at, + ) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{res}(交易所已先行平仓)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + session_capital_fallback=session_capital, + ) + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) + conn.commit() + continue + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (pid,)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + continue + cancel_binance_futures_open_orders(r["exchange_symbol"] or normalize_exchange_symbol(sym)) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=res, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id=close_order_id or "-", + session_capital_fallback=session_capital, + ) + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type="下单监控", + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid)) + conn.commit() + conn.close() + + +def force_close_before_reset(): + if not FORCE_CLOSE_ENABLED: + return + now = app_now() + # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) + if now.hour != FORCE_CLOSE_BJ_HOUR: + return + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + p = get_price(r["symbol"]) + if not p: + continue + direction = r["direction"] + trigger_price = r["trigger_price"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(r["symbol"]) + session_date = r["session_date"] or get_trading_day(now) + opened_at = get_opened_at_value(r) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + cancel_binance_futures_open_orders(r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"])) + except Exception as e: + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=r["symbol"], + direction=direction, + scene="强制清仓失败", + error_text=str(e), + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type="下单监控", + direction=direction, + trigger_price=trigger_price, + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result="强制清仓", + miss_reason=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"])) + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=direction, + result="强制清仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id=close_order_id or "-", + extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + session_capital_fallback=session_capital, + ) + ) + conn.commit() + conn.close() + +# 后台线程 +def background_task(): + while True: + try: + auto_transfer_once_per_day() + conn = get_db() + reconcile_external_closes(conn) + conn.commit() + conn.close() + force_close_before_reset() + check_key_monitors() + check_order_monitors() + except: + pass + time.sleep(MONITOR_POLL_SECONDS) + + +# ====================== 登录路由 ====================== +@app.route("/login", methods=["GET", "POST"]) +def login(): + if AUTH_DISABLED: + session["logged_in"] = True + return redirect("/") + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + if username == USERNAME and password == PASSWORD: + session["logged_in"] = True + return redirect("/") + else: + flash("账号或密码错误") + return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME) + +@app.route("/logout") +def logout(): + session.clear() + return redirect("/" if AUTH_DISABLED else "/login") + +# 登录校验装饰器 +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if AUTH_DISABLED: + return f(*args, **kwargs) + if not session.get("logged_in"): + return redirect("/login") + return f(*args, **kwargs) + return decorated + + +@app.route("/sync_positions") +@login_required +def sync_positions(): + days_raw = (request.args.get("days") or "").strip() + sync_days = None + if days_raw: + try: + sync_days = max(1, min(365, int(days_raw))) + except Exception: + sync_days = None + conn = get_db() + synced = reconcile_external_closes(conn, days=sync_days) + conn.commit() + conn.close() + if sync_days is not None: + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + else: + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + return redirect("/") + + +@app.route("/api/sync_positions", methods=["POST"]) +@login_required +def api_sync_positions(): + payload = request.get_json(silent=True) or {} + days_raw = str(payload.get("days", "")).strip() + if not days_raw: + return jsonify({"ok": False, "msg": "请填写天数"}), 400 + try: + days = int(days_raw) + except Exception: + return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 + if days < 1 or days > 365: + return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 + conn = get_db() + synced = reconcile_external_closes(conn, days=days) + conn.commit() + conn.close() + return jsonify({"ok": True, "days": days, "synced": int(synced)}) + + +# ====================== 主页面 ====================== +def render_main_page(page="trade"): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals() + # 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。 + funding_usdt = round(funding_capital, 4) if funding_capital is not None else None + current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) + recommended_capital = get_recommended_capital(current_capital) + key_list = conn.execute("SELECT * FROM key_monitors").fetchall() + key_history = conn.execute("SELECT * FROM key_monitor_history ORDER BY id DESC LIMIT 80").fetchall() + stats_bundle = compute_stats_bundle(conn, trading_day, now) + raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + order_list = [] + for o in raw_order_list: + order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC").fetchall() + records = [to_effective_trade_dict(r) for r in raw_records] + total = len(records) + miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") + win = sum(1 for r in records if (r.get("effective_result") or "") in ("止盈", "保本止盈", "移动止盈")) + occupied_miss_total = sum( + 1 + for r in records + if (r.get("effective_result") or "") == "错过" + and ("持仓占用" in str(r.get("effective_miss_reason") or "")) + ) + rate = round(win/total*100,2) if total else 0 + active_count = len(order_list) + can_trade = trading_day_reset_allows_new_open(now) and active_count == 0 + conn.close() + return render_template( + "index.html", + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + record=records, + total=total, + miss_count=miss_count, + rate=rate, + trading_day=trading_day, + funding_usdt=funding_usdt, + daily_start_capital=DAILY_START_CAPITAL, + current_capital=current_capital, + recommended_capital=recommended_capital, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + reset_hour=TRADING_DAY_RESET_HOUR, + balance_refresh_seconds=BALANCE_REFRESH_SECONDS, + auto_transfer_enabled=AUTO_TRANSFER_ENABLED, + auto_transfer_amount=AUTO_TRANSFER_AMOUNT, + auto_transfer_from=AUTO_TRANSFER_FROM, + auto_transfer_to=AUTO_TRANSFER_TO, + auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, + full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + active_count=active_count, + can_trade=can_trade, + focus_key_id=(key_list[0]["id"] if key_list else None), + focus_order_id=(order_list[0]["id"] if order_list else None), + data_export_version=2, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + risk_percent=RISK_PERCENT, + breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, + breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, + occupied_miss_total=occupied_miss_total, + price_fmt=format_price_for_symbol, + entry_reason_options=list(ENTRY_REASON_OPTIONS), + entry_reason_other_value=ENTRY_REASON_OTHER, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/") +@login_required +def index(): + return redirect("/trade") + + +@app.route("/trade") +@login_required +def trade_page(): + return render_main_page("trade") + + +@app.route("/records") +@login_required +def records_page(): + return render_main_page("records") + + +@app.route("/stats") +@login_required +def stats_page(): + return render_main_page("stats") + + +@app.route("/api/account_snapshot") +@login_required +def api_account_snapshot(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals(force=True) + funding_usdt = round(funding_capital, 4) if funding_capital is not None else None + current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) + recommended_capital = get_recommended_capital(current_capital) + active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0] + conn.close() + can_trade = trading_day_reset_allows_new_open(now) and active_count == 0 + available_trading_usdt = get_available_trading_usdt() + return jsonify({ + "funding_usdt": funding_usdt, + "current_capital": current_capital, + "available_trading_usdt": round(available_trading_usdt, 4) if available_trading_usdt is not None else None, + "recommended_capital": recommended_capital, + "active_count": active_count, + "can_trade": can_trade, + "trading_day": trading_day + }) + + +@app.route("/api/price_snapshot") +@login_required +def api_price_snapshot(): + conn = get_db() + key_rows = conn.execute("SELECT id,symbol,monitor_type,direction,upper,lower FROM key_monitors").fetchall() + order_rows = conn.execute( + "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'" + ).fetchall() + conn.close() + + symbol_set = set() + for r in key_rows: + symbol_set.add(r["symbol"]) + for r in order_rows: + symbol_set.add(r["symbol"]) + + prices = {} + for s in symbol_set: + p = get_price(s) + if p is not None: + prices[s] = float(p) + + all_swap_positions = [] + if exchange_private_api_configured(): + try: + ensure_markets_loaded() + all_swap_positions = exchange.fetch_positions() or [] + except Exception: + all_swap_positions = [] + + key_prices = [] + for r in key_rows: + price = prices.get(r["symbol"]) + if price is None: + continue + upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) + lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) + gate = None + try: + gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) + except Exception: + gate = None + gate_summary = "-" + gate_metrics = "" + if gate: + rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}" + gate_summary = ( + f"量:{'Y' if gate.get('vol_ok') else 'N'} " + f"破:{'Y' if gate.get('breakout_ok') else 'N'} " + f"幅:{'Y' if gate.get('amp_ok') else 'N'} " + f"二确:{'Y' if gate.get('confirm_ok') else 'N'} " + f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})" + ) + if gate.get("breakout_ok"): + try: + vol_now = round(float(gate.get("vol_break") or 0), 4) + vol_avg = round(float(gate.get("avg20") or 0), 4) + amp_pct = round(float(gate.get("amp_pct") or 0), 4) + cfm_close = round(float(gate.get("confirm_close") or 0), 8) + edge = round(float(gate.get("edge_price") or 0), 8) + gate_metrics = ( + f"量值:{vol_now}/{vol_avg} " + f"幅值:{amp_pct}% " + f"二确值:{cfm_close}@{edge}" + ) + except Exception: + gate_metrics = "" + key_prices.append({ + "id": r["id"], + "symbol": r["symbol"], + "price": round(price, 6), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + "gate_summary": gate_summary, + "gate_ok": bool(gate and gate.get("ok")), + "gate_metrics": gate_metrics, + }) + + order_prices = [] + for r in order_rows: + price = prices.get(r["symbol"]) + if price is None: + continue + margin = float(r["margin_capital"] or 0) + leverage = float(r["leverage"] or 0) + entry = float(r["trigger_price"] or 0) + pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 + pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 + rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) + ex_sym = resolve_monitor_exchange_symbol(r) + prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) + lev_row = r["leverage"] if "leverage" in r.keys() else None + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None + payload = { + "id": r["id"], + "symbol": r["symbol"], + "price": round(price, 6), + "float_pnl": round(pnl, 6), + "float_pct": pnl_pct, + "rr_ratio": rr_ratio, + "plan_margin": round(margin, 4) if margin else None, + "exchange_initial_margin": None, + "exchange_notional": None, + "exchange_mark_price": None, + "pnl_source": "plan", + } + if ex_metrics: + if ex_metrics.get("initial_margin") is not None: + payload["exchange_initial_margin"] = ex_metrics["initial_margin"] + if ex_metrics.get("notional") is not None: + payload["exchange_notional"] = ex_metrics["notional"] + if ex_metrics.get("mark_price") is not None: + payload["exchange_mark_price"] = ex_metrics["mark_price"] + if ex_metrics.get("unrealized_pnl") is not None: + payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 6) + payload["pnl_source"] = "exchange" + denom = ex_metrics.get("initial_margin") or margin + payload["float_pct"] = ( + round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct + ) + order_prices.append(payload) + + return jsonify({ + "updated_at": app_now_str(), + "key_prices": key_prices, + "order_prices": order_prices, + "positions_raw_count": len(all_swap_positions), + }) + + +@app.route("/api/symbol_liquidity_rank") +@login_required +def api_symbol_liquidity_rank(): + symbol = normalize_symbol_input(request.args.get("symbol")) + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + rank, total = _daily_volume_rank(symbol) + if total <= 0: + return jsonify({"ok": False, "msg": "日成交量排名读取失败"}), 502 + if rank is None: + return jsonify({"ok": True, "symbol": symbol, "rank": None, "total": int(total), "in_top30": False}) + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": int(rank), + "total": int(total), + "in_top30": bool(rank <= 30), + } + ) + + +@app.route("/api/order_defaults") +@login_required +def api_order_defaults(): + symbol = normalize_symbol_input(request.args.get("symbol")) + direction = (request.args.get("direction") or "long").strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if direction not in ("long", "short"): + direction = "long" + exchange_symbol = normalize_exchange_symbol(symbol) + leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + available = get_available_trading_usdt() + return jsonify({ + "ok": True, + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "available_trading_usdt": round(available, 4) if available is not None else None + }) + + +@app.route("/order_focus") +@login_required +def order_focus(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall() + conn.close() + orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders] + picked_id = request.args.get("order_id", "").strip() + selected = None + if picked_id.isdigit(): + selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None) + if selected is None and orders: + selected = orders[0] + return render_template( + "order_focus_v2.html", + orders=orders, + selected_order=selected, + default_timeframe=KLINE_TIMEFRAME, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/api/order_kline") +@login_required +def api_order_kline(): + order_id_raw = (request.args.get("order_id") or "").strip() + if not order_id_raw.isdigit(): + return jsonify({"ok": False, "msg": "order_id 无效"}), 400 + order_id = int(order_id_raw) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"} + if timeframe not in allowed_tfs: + timeframe = KLINE_TIMEFRAME + limit = 100 + + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404 + + order_item = enrich_order_item(row_to_dict(row), current_capital) + exchange_symbol = order_item.get("exchange_symbol") or normalize_exchange_symbol(order_item["symbol"]) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + ts = int(bar[0] // 1000) + candles.append({ + "time": ts, + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(order_item["symbol"]) + margin = float(order_item.get("margin_capital") or 0) + leverage = float(order_item.get("leverage") or 0) + entry = float(order_item.get("trigger_price") or 0) + float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0 + float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0 + + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": { + "id": order_item["id"], + "symbol": order_item["symbol"], + "direction": order_item.get("direction") or "long", + "trigger_price": order_item.get("trigger_price"), + "stop_loss": order_item.get("stop_loss"), + "take_profit": order_item.get("take_profit"), + "margin_capital": order_item.get("margin_capital"), + "leverage": order_item.get("leverage"), + "position_ratio": order_item.get("position_ratio"), + "rr_ratio": order_item.get("rr_ratio"), + "breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)), + "current_price": round(float(current_price), 8) if current_price else None, + "float_pnl": round(float(float_pnl), 6), + "float_pct": float_pct, + }, + "candles": candles, + "updated_at": app_now_str(), + }) + + +@app.route("/key_focus") +@login_required +def key_focus(): + conn = get_db() + key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + conn.close() + key_list = [row_to_dict(r) for r in key_rows] + + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_query = normalize_symbol_input(request.args.get("symbol")) + selected_key = None + if key_id_raw.isdigit(): + selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None) + if selected_key is None and symbol_query: + selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None) + if selected_key is None and key_list: + selected_key = key_list[0] + default_symbol = symbol_query or ((selected_key or {}).get("symbol")) or "BTC/USDT" + return render_template( + "key_focus_v2.html", + key_list=key_list, + selected_key=selected_key, + default_symbol=default_symbol, + default_timeframe=KLINE_TIMEFRAME, + default_kline_limit=200, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/api/key_kline") +@login_required +def api_key_kline(): + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_input = normalize_symbol_input(request.args.get("symbol")) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + if timeframe not in {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}: + timeframe = KLINE_TIMEFRAME + limit = normalize_kline_limit(request.args.get("limit"), default=200) + + conn = get_db() + key_row = None + if key_id_raw.isdigit(): + key_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (int(key_id_raw),)).fetchone() + if key_row is None and symbol_input: + key_row = conn.execute( + "SELECT * FROM key_monitors WHERE upper(symbol)=? ORDER BY id DESC LIMIT 1", + (symbol_input,), + ).fetchone() + if key_row is not None: + symbol = (key_row["symbol"] or "").upper() + else: + symbol = symbol_input + conn.close() + if not symbol: + return jsonify({"ok": False, "msg": "请先输入币种或选择关键位"}), 400 + + exchange_symbol = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + candles.append({ + "time": int(bar[0] // 1000), + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(symbol) + key_info = None + if key_row is not None: + upper = float(key_row["upper"]) if key_row["upper"] is not None else None + lower = float(key_row["lower"]) if key_row["lower"] is not None else None + upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None) + lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None) + key_info = { + "id": key_row["id"], + "monitor_type": key_row["monitor_type"], + "direction": key_row["direction"] or "long", + "upper": upper, + "lower": lower, + "notification_count": int(key_row["notification_count"] or 0), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + } + + return jsonify({ + "ok": True, + "symbol": symbol, + "timeframe": timeframe, + "limit": limit, + "current_price": round(float(current_price), 8) if current_price is not None else None, + "key_monitor": key_info, + "candles": candles, + "updated_at": app_now_str(), + }) + + +@app.route("/add_key", methods=["POST"]) +@login_required +def add_key(): + d = request.form + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + flash("symbol 不能为空") + return redirect("/") + rank, total = _daily_volume_rank(symbol) + if rank is None: + flash("日成交量排名读取失败,请稍后重试") + return redirect("/") + if rank > 30: + flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位") + return redirect("/") + conn = get_db() + conn.execute("INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)", + (symbol, d["type"], d.get("direction", "long"), d["upper"], d["lower"])) + conn.commit() + conn.close() + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total})") + return redirect("/") + +@app.route("/add_order", methods=["POST"]) +@login_required +def add_order(): + d = request.form + now = app_now() + conn = get_db() + direction = d.get("direction", "long") + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + conn.close() + flash("symbol 不能为空") + return redirect("/") + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + if "一次只能持有一个仓位" in reason: + try: + tp_raw = parse_positive_float(d.get("tp")) + sl_raw = parse_positive_float(d.get("sl")) + tgt_raw = parse_positive_float(d.get("tgt")) + except Exception: + tp_raw = sl_raw = tgt_raw = None + insert_trade_record( + conn, + symbol=symbol, + monitor_type="下单监控", + direction=direction if direction in ("long", "short") else "long", + trigger_price=tp_raw or 0, + stop_loss=sl_raw or 0, + take_profit=tgt_raw or 0, + result="错过", + miss_reason="持仓占用:一次只能持有一个仓位", + opened_at=app_now_str(), + closed_at=app_now_str(), + ) + conn.commit() + conn.close() + flash(f"风控拒绝下单:{reason}") + return redirect("/") + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(f"风控拒绝下单:{reason_live}") + return redirect("/") + exchange_symbol = normalize_exchange_symbol(symbol) + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + try: + leverage_input = parse_positive_float(d.get("leverage")) + leverage = int(leverage_input) if leverage_input is not None else default_leverage + except Exception: + conn.close() + flash("杠杆参数格式错误") + return redirect("/") + if leverage <= 0: + conn.close() + flash("杠杆必须大于0") + return redirect("/") + + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + trade_style = (d.get("trade_style") or DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + conn.close() + flash("获取交易所实时价格失败,请稍后重试") + return redirect("/") + sltp_mode = (d.get("sltp_mode") or "price").strip().lower() + if sltp_mode not in ("price", "pct"): + sltp_mode = "price" + if sltp_mode == "pct": + try: + sl_pct = float(d.get("sl_pct") or 0) + tp_pct = float(d.get("tp_pct") or 0) + if sl_pct <= 0 or tp_pct <= 0: + raise ValueError("pct") + sl_ratio = sl_pct / 100.0 + tp_ratio = tp_pct / 100.0 + if direction == "short": + stop_loss = float(live_price) * (1 + sl_ratio) + take_profit = float(live_price) * (1 - tp_ratio) + else: + stop_loss = float(live_price) * (1 - sl_ratio) + take_profit = float(live_price) * (1 + tp_ratio) + except Exception: + conn.close() + flash("百分比止盈止损参数错误,请填写正数百分比") + return redirect("/") + else: + try: + stop_loss = float(d["sl"]) + take_profit = float(d["tgt"]) + except Exception: + conn.close() + flash("价格参数格式错误") + return redirect("/") + if stop_loss <= 0 or take_profit <= 0: + conn.close() + flash("价格参数必须大于0") + return redirect("/") + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + conn.close() + flash("止损方向不合法:请检查入场方向与止损价格关系") + return redirect("/") + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + conn.close() + flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + return redirect("/") + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + conn.close() + flash(f"保证金不足:交易账户可用约 {round(available_usdt,4)}U,当前最多建议 {max_margin}U") + return redirect("/") + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + conn.close() + flash(friendly_exchange_error(e, available_usdt=available_usdt)) + return redirect("/") + + make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes") + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount + if direction == "short": + breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) + else: + breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) + breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, risk_percent, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, + breakeven_enabled, + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day + ) + ) + conn.commit() + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + conn.close() + + chart_name = None + chart_url = None + if make_order_chart and ORDER_CHART_ENABLED: + try: + title_prefix = f"{symbol} {direction} #{new_order_id}" + chart_name = generate_order_open_chart(exchange_symbol, title_prefix) + if chart_name: + chart_url = f"/static/images/order_charts/{chart_name}" + except Exception: + chart_name = None + chart_url = None + + if chart_name: + try: + journal_id = f"order_{new_order_id}" + coin = journal_coin_from_symbol(symbol) + open_local = (opened_at_bj or "")[:16].replace(" ", "T") + if len(open_local) < 16: + open_local = app_now().strftime("%Y-%m-%dT%H:%M") + close_local = open_local + hold_duration = calc_duration_text(open_local, close_local) + note = ( + f"auto_from_open_order id={new_order_id} oid={open_order_id} " + f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" + ) + conn = get_db() + conn.execute( + """INSERT OR REPLACE INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + journal_id, + open_local, + close_local, + hold_duration, + coin, + "multi", + "0", + "auto:open", + "待平仓", + "", + "", + "否", + "", + "", + "", + None, + None, + None, + "", + "否", + "否", + note, + chart_name, + ), + ) + conn.commit() + conn.close() + except Exception: + try: + conn.close() + except Exception: + pass + + _, trading_capital_after = get_exchange_capitals(force=True) + account_base_display = ( + round(float(trading_capital_after), 4) + if trading_capital_after is not None + else round(float(capital_base), 4) + ) + account_name = (os.getenv("BINANCE_ACCOUNT_LABEL") or "binance实盘账户").strip() + dir_text = "多头(long)" if direction == "long" else "空头(short)" + order_state_text = ( + "已在交易所挂条件委托(止盈、止损各一张触发单)" + if tpsl_attached + else "条件委托未挂上(已拦截)" + ) + rr_show = planned_rr if planned_rr is not None else "-" + try: + rr_show_fmt = round(float(planned_rr), 4) if planned_rr is not None else None + except (TypeError, ValueError): + rr_show_fmt = None + rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1" + ep_wx = format_price_for_symbol(symbol, trigger_price) + sl_wx = format_price_for_symbol(symbol, stop_loss) + tp_wx = format_price_for_symbol(symbol, take_profit) + be_wx = format_price_for_symbol(symbol, breakeven_price) + style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" + wx_lines = [ + f"📈 {symbol} 开仓成功", + f"💼 交易类型:{dir_text}", + "🧾 订单基础信息", + f"🔖 交易所订单 ID:{open_order_id}", + f"📈 交易风格:{style_zh}", + f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), 4)} U", + "📊 仓位配置详情", + f"账户基数:{account_base_display} USDT", + f"合约杠杆:{leverage} 倍", + f"名义仓位:{notional_value} USDT", + f"仓位占比:{position_ratio}%", + f"合约数量:{amount}", + f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", + "🎯 价位 & 盈亏比", + f"开仓成交价:{ep_wx}", + f"止损价位:{sl_wx}", + f"止盈价位:{tp_wx}", + f"计划盈亏比:{rr_line}", + f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", + "📌 状态统计", + f"✅ 条件委托:{order_state_text}", + f"📅 当日开仓次数:{opens_today_after} / {DAILY_OPEN_ALERT_THRESHOLD} 次(风控阈值提醒)", + ] + if chart_url: + wx_lines.append(f"多周期K线图:{chart_url}") + send_wechat_msg("\n".join(wx_lines)) + + flash_lines = [ + f"实盘开单成功:风格 {trade_style};风险 {risk_percent}%≈{risk_amount_final}U;基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约数量 {amount}(折算标的 {base_amount})," + f"计划RR {planned_rr if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + f"本交易日累计开仓:{opens_today_after}", + ] + if chart_url: + flash_lines.append(f"已生成多周期K线图:{chart_url}") + flash(" ".join(flash_lines)) + + if opens_today_before < DAILY_OPEN_ALERT_THRESHOLD <= opens_today_after: + advice = ai_short_advice( + f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_today_after} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") + return redirect("/") + +@app.route("/delete_key_monitor/", methods=["POST"]) +@login_required +def delete_key_monitor(kid): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "error": "not_found"}) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/delete_key_history/", methods=["POST"]) +@login_required +def delete_key_history(hid): + conn = get_db() + cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/del_key/") +@login_required +def del_key(id): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() + if row: + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) + conn.commit() + conn.close() + resp = redirect("/") + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + return resp + + +def _csv_response(filename, rows, header): + buf = StringIO() + w = csv.writer(buf) + w.writerow(header) + for row in rows: + w.writerow(row) + out = "\ufeff" + buf.getvalue() + return Response( + out, + mimetype="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +def _md_response(filename, content): + return Response( + content, + mimetype="text/markdown; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +@app.route("/export/trade_records") +@login_required +def export_trade_records(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,trigger_price,stop_loss,take_profit,margin_capital,leverage," + "pnl_amount,hold_seconds,hold_minutes,opened_at,closed_at,result,miss_reason," + "entry_reason,reviewed_entry_reason,created_at FROM trade_records ORDER BY id ASC" + ).fetchall() + conn.close() + head_base = [ + "id", + "symbol", + "monitor_type", + "direction", + "trigger_price", + "stop_loss", + "take_profit", + "margin_capital", + "leverage", + "pnl_amount", + "hold_seconds", + "hold_minutes", + "opened_at", + "closed_at", + "result", + "miss_reason", + "entry_reason", + "reviewed_entry_reason", + "created_at", + ] + head = head_base + ["开仓类型"] + data = [] + for r in rows: + er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" + er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" + eff = er1 or er0 + data.append(tuple(r[h] for h in head_base) + (eff,)) + day = app_now().strftime("%Y%m%d") + return _csv_response(f"trade_records_v2_{day}.csv", data, head) + + +@app.route("/export/journal_entries") +@login_required +def export_journal_entries(): + conn = get_db() + rows = conn.execute( + "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," + "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," + "post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC" + ).fetchall() + conn.close() + head = [ + "id", + "open_datetime", + "close_datetime", + "hold_duration", + "coin", + "tf", + "pnl", + "entry_reason", + "exit_reason", + "expect_rr", + "real_rr", + "early_exit", + "early_exit_trigger", + "early_exit_note", + "early_exit_reason", + "mood_issues", + "post_breakeven_stare", + "new_trade_while_occupied", + "note", + "image", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"journal_entries_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitors") +@login_required +def export_key_monitors(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify," + "notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC" + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_notified_at", + "max_notify", + "notify_interval_min", + "breakout_limit_pct", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitor_history") +@login_required +def export_key_monitor_history(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " + "FROM key_monitor_history ORDER BY id ASC" + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_alert_message", + "close_reason", + "closed_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) + +@app.route("/del_order/") +@login_required +def del_order(id): + conn = get_db() + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() + if not row: + conn.close() + flash("订单不存在") + return redirect("/") + if row["status"] == "active": + try: + p = get_price(row["symbol"]) or float(row["trigger_price"]) + opened_at = get_opened_at_value(row) + closed_at = app_now_str() + hold_seconds = calc_hold_seconds(opened_at, app_now()) + pnl_amount = calc_pnl( + row["direction"], + row["trigger_price"], + p, + row["margin_capital"] or DAILY_START_CAPITAL, + row["leverage"] or infer_leverage(row["symbol"]) + ) + close_resp = close_exchange_order(row) + close_order_id = close_resp.get("id", "") + cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + session_date = row["session_date"] or get_trading_day() + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type="下单监控", + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result="手动平仓", + miss_reason="用户手动删除订单触发平仓", + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + conn.commit() + conn.close() + send_wechat_msg( + build_wechat_close_message( + symbol=row["symbol"], + direction=row["direction"], + result="手动平仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=row["trigger_price"], + current_price=p, + stop_loss=row["stop_loss"], + take_profit=row["take_profit"], + close_order_id=close_order_id or "-", + extra_note="用户在页面手动平仓", + session_capital_fallback=session_capital, + ) + ) + flash("已按实盘流程手动平仓") + return redirect("/") + except Exception as e: + if is_no_position_error(str(e)): + cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + opened_at = get_opened_at_value(row) + opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) + miss_reason = f"手动删除时无持仓:{miss_reason}" + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = row["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type="下单监控", + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result=result, + miss_reason=miss_reason, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + conn.commit() + conn.close() + flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") + return redirect("/") + conn.close() + flash(f"手动平仓失败:{str(e)}") + return redirect("/") + conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) + conn.commit() + conn.close() + return redirect("/") + +@app.route("/add_miss", methods=["POST"]) +@login_required +def add_miss(): + d = request.form + direction = d.get("direction", "long") + conn = get_db() + insert_trade_record( + conn, + symbol=d["symbol"], + monitor_type=d["type"], + direction=direction, + trigger_price=d["tp"], + stop_loss=d["sl"], + take_profit=d["tgt"], + result="错过", + miss_reason=d["reason"], + opened_at=app_now_str(), + closed_at=app_now_str(), + ) + conn.commit() + conn.close() + flash("已记录错过机会") + return redirect("/records") + + +@app.route("/add_journal", methods=["POST"]) +@login_required +def add_journal(): + d = request.form + entry_reason_norm = normalize_entry_reason(d.get("entry_reason"), d.get("entry_reason_custom")) + if not entry_reason_norm: + flash("请选择开仓类型;若选「其他」请在下方填写自定义说明") + return redirect("/records") + early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger")) + early_exit_note = str(d.get("early_exit_note") or "").strip() + if not early_exit_trigger: + flash("请选择离场触发") + return redirect("/records") + if early_exit_trigger == "手动平仓" and not early_exit_note: + flash("手工平仓必须填写补充说明") + return redirect("/records") + if early_exit_trigger != "手动平仓": + early_exit_note = "" + # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」 + early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" + early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) + exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) + image_filename = None + uploaded_tmp = None + entry_id = uuid.uuid4().hex + file = request.files.get("screenshot") + if file and file.filename: + ext = os.path.splitext(file.filename)[1] + image_filename = f"{uuid.uuid4().hex}{ext}" + save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename)) + file.save(save_path) + uploaded_tmp = image_filename + + mood_issues = ",".join(request.form.getlist("mood_issues")) + hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) + real_rr_text = (d.get("real_rr") or "").strip() + try: + risk_amount_hint = float(d.get("risk_amount_hint") or 0) + pnl_hint = float(d.get("pnl") or 0) + # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 + if risk_amount_hint > 0: + real_rr_text = f"{(pnl_hint / risk_amount_hint):.4f}" + except Exception: + pass + + want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + chart_msg = None + if want_exchange_chart and ORDER_CHART_ENABLED: + coin = (d.get("coin") or "").strip().upper() + symbol_guess = normalize_symbol_input(coin) or coin + exchange_symbol = normalize_exchange_symbol(symbol_guess) + title_prefix = f"{symbol_guess} journal {entry_id[:8]}" + marker_payload = { + "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")), + "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")), + "entry_price": d.get("entry_price_hint"), + "exit_price": None, + } + try: + chart_fname = f"journal_{entry_id}.png" + saved = generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=ORDER_CHART_TFS, + limit=ORDER_CHART_LIMIT, + out_dir=app.config["UPLOAD_FOLDER"], + filename=chart_fname, + filename_prefix="journal", + marker_payload=marker_payload, + marker_timeframes=( + {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()} + if ORDER_CHART_TFS + else {"5m", "15m", "1h", "4h"} + ), + ) + if saved: + image_filename = saved + chart_msg = f"已生成多周期K线图:/static/images/{saved}" + if uploaded_tmp: + try: + old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) + if os.path.exists(old_path): + os.remove(old_path) + except Exception: + pass + else: + chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Binance 网络/代理是否正常。" + except Exception as e: + image_filename = uploaded_tmp + chart_msg = f"自动生成K线图失败:{str(e)}" + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, d.get("open_datetime"), d.get("close_datetime"), hold_duration, d.get("coin"), d.get("tf"), + d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, + early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, + None, None, None, mood_issues, + d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename + ) + ) + conn.commit() + conn.close() + if chart_msg: + flash(f"交易复盘记录已保存。{chart_msg}") + else: + flash("交易复盘记录已保存") + return redirect("/records") + + +@app.route("/api/journals") +@login_required +def api_journals(): + conn = get_db() + rows = conn.execute("SELECT * FROM journal_entries ORDER BY created_at DESC").fetchall() + conn.close() + result = [] + for r in rows: + item = row_to_dict(r) + item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] + result.append(item) + return jsonify(result) + + +@app.route("/api/journal_prefill", methods=["POST"]) +@login_required +def api_journal_prefill(): + file = request.files.get("screenshot") + if not file or not file.filename: + return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400 + try: + raw = file.read() + if not raw: + return jsonify({"ok": False, "msg": "截图为空"}), 400 + image_b64 = base64.b64encode(raw).decode("utf-8") + except Exception as e: + return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400 + + parsed = ai_extract_journal_from_image(image_b64) + if parsed is None: + return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500 + return jsonify({"ok": True, "data": parsed}) + + +@app.route("/delete_journal/", methods=["POST"]) +@login_required +def delete_journal(jid): + conn = get_db() + row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone() + if row and row["image"]: + img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"]) + if os.path.exists(img_path): + os.remove(img_path) + conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/api/reviews") +@login_required +def api_reviews(): + conn = get_db() + rows = conn.execute("SELECT * FROM ai_reviews ORDER BY created_at DESC").fetchall() + conn.close() + return jsonify([row_to_dict(r) for r in rows]) + + +@app.route("/export/review_md/") +@login_required +def export_review_md(rid): + conn = get_db() + row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() + conn.close() + if not row: + return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") + + review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" + target_date = row["target_date"] or "-" + created_at = row["created_at"] or app_now_str() + content = (row["content"] or "").strip() + if not content: + content = "(无内容)" + + md = ( + f"# {review_type}报告\n\n" + f"- 目标日期: {target_date}\n" + f"- 生成时间: {created_at}\n" + f"- 报告ID: {row['id']}\n\n" + f"---\n\n" + f"{content}\n" + ) + + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + safe_type = "daily" if row["review_type"] == "daily" else "weekly" + filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" + return _md_response(filename, md) + + +@app.route("/export/reviews_md_bundle") +@login_required +def export_reviews_md_bundle(): + review_type = (request.args.get("review_type") or "").strip().lower() + target_date = (request.args.get("target_date") or "").strip() + if review_type not in ("daily", "weekly"): + return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") + if not target_date: + return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") + + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", + (review_type, target_date), + ).fetchall() + conn.close() + if not rows: + return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") + + title = "日复盘" if review_type == "daily" else "周复盘" + lines = [ + f"# {title}汇总报告", + "", + f"- 目标日期: {target_date}", + f"- 条目数量: {len(rows)}", + f"- 导出时间: {app_now_str()}", + "", + "---", + "", + ] + for idx, row in enumerate(rows, 1): + created_at = row["created_at"] or "-" + content = (row["content"] or "").strip() or "(无内容)" + lines.extend( + [ + f"## 第{idx}条", + "", + f"- 报告ID: {row['id']}", + f"- 生成时间: {created_at}", + "", + content, + "", + "---", + "", + ] + ) + md = "\n".join(lines) + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" + return _md_response(filename, md) + + +@app.route("/delete_review/", methods=["POST"]) +@login_required +def delete_review(rid): + conn = get_db() + conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/delete_trade_record/", methods=["POST"]) +@login_required +def delete_trade_record(rid): + conn = get_db() + cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) + + +@app.route("/api/trade_record_review_update", methods=["POST"]) +@login_required +def api_trade_record_review_update(): + payload = request.get_json(silent=True) or {} + rec_id = payload.get("id") + try: + rec_id = int(rec_id) + except Exception: + return jsonify({"ok": False, "msg": "记录ID无效"}), 400 + + reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() + reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() + reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") + reviewed_take_profit_raw = payload.get("reviewed_take_profit") + reviewed_result = str(payload.get("reviewed_result") or "").strip() + reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() + reviewed_pnl_raw = payload.get("reviewed_pnl_amount") + + if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: + return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400 + + try: + reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") + reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") + except Exception: + return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 + if reviewed_close_dt < reviewed_open_dt: + return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 + hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) + hold_minutes = calc_hold_minutes(hold_seconds) + + try: + reviewed_pnl_amount = float(reviewed_pnl_raw) + except Exception: + return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 + reviewed_stop_loss = None + if reviewed_stop_loss_raw not in (None, ""): + try: + reviewed_stop_loss = float(reviewed_stop_loss_raw) + except Exception: + return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 + reviewed_take_profit = None + if reviewed_take_profit_raw not in (None, ""): + try: + reviewed_take_profit = float(reviewed_take_profit_raw) + except Exception: + return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 + + _MISSING_ER = object() + reviewed_entry_reason_update = _MISSING_ER + if "reviewed_entry_reason" in payload: + s = str(payload.get("reviewed_entry_reason") or "").strip() + if s and not entry_reason_valid_for_storage(s): + return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400 + reviewed_entry_reason_update = s or None + + conn = get_db() + row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "记录不存在"}), 404 + risk_amount = row["risk_amount"] + actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) + base_params = [ + reviewed_opened_at, + reviewed_closed_at, + reviewed_stop_loss, + reviewed_take_profit, + round(reviewed_pnl_amount, 4), + reviewed_result or None, + reviewed_miss_reason or None, + hold_seconds, + hold_minutes, + app_now_str(), + actual_rr, + ] + if reviewed_entry_reason_update is not _MISSING_ER: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? + WHERE id=?""", + tuple(base_params + [reviewed_entry_reason_update, rec_id]), + ) + else: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr) + WHERE id=?""", + tuple(base_params + [rec_id]), + ) + conn.commit() + conn.close() + return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) + + +@app.route("/manual_transfer", methods=["POST"]) +@login_required +def manual_transfer(): + try: + amount = float(request.form.get("amount", "0")) + except Exception: + flash("划转金额格式错误") + return redirect("/") + from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() + to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() + ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) + conn = get_db() + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + else: + flash(f"手动划转失败:{msg}") + return redirect("/") + + +@app.route("/ai_daily_review", methods=["POST"]) +@login_required +def ai_daily_review(): + date = request.form.get("date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", + (date,) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该日无交易记录"}) + + text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "每日", image_paths=image_paths) + full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "daily", date, full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + + +@app.route("/ai_weekly_review", methods=["POST"]) +@login_required +def ai_weekly_review(): + start_date = request.form.get("start_date", "") + end_date = request.form.get("end_date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", + (start_date, end_date) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该时间段无交易记录"}) + + text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "周度", image_paths=image_paths) + full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + +# 启动 +if __name__ == "__main__": + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/crypto_monitor_binance/crypto.db b/crypto_monitor_binance/crypto.db new file mode 100644 index 0000000000000000000000000000000000000000..9f3360be5ebe96929932d3daea3191b34c98a092 GIT binary patch literal 65536 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 NAOR$R1l}lt{{sP66Kntg literal 0 HcmV?d00001 diff --git a/crypto_monitor_binance/ecosystem.config.cjs b/crypto_monitor_binance/ecosystem.config.cjs new file mode 100644 index 0000000..65c1ffe --- /dev/null +++ b/crypto_monitor_binance/ecosystem.config.cjs @@ -0,0 +1,33 @@ +/** + * PM2 进程定义(Ubuntu / Linux)。 + * + * 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**, + * 与 `.env` 里 `BINANCE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。 + * + * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。 + * + * 启动: + * pm2 start ecosystem.config.cjs + * 保存开机列表: + * pm2 save && pm2 startup + */ +const path = require("path"); + +const ROOT = __dirname; +const PY = path.join(ROOT, ".venv", "bin", "python"); + +module.exports = { + apps: [ + { + name: "crypto_binance", + cwd: ROOT, + script: path.join(ROOT, "app.py"), + interpreter: PY, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "800M", + // app.py 会从项目根目录加载 .env,此处无需重复 env_file + }, + ], +}; diff --git a/crypto_monitor_binance/scripts/fix_breakeven_labels.py b/crypto_monitor_binance/scripts/fix_breakeven_labels.py new file mode 100644 index 0000000..80b7d04 --- /dev/null +++ b/crypto_monitor_binance/scripts/fix_breakeven_labels.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +一次性修复历史交易记录标签: +将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。 + +默认条件(可通过参数修改): +- monitor_type = 下单监控 +- result = 止损 +- pnl_amount > 0 + +用法示例: +1) 仅预览(不落库): + python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run + +2) 执行修复: + python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +""" + +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.") + parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db") + parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)") + parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)") + parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)") + parser.add_argument("--dry-run", action="store_true", help="Preview only, no write") + parser.add_argument("--apply", action="store_true", help="Execute update") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + db_path = Path(args.db).expanduser().resolve() + if not db_path.exists(): + print(f"[ERR] DB not found: {db_path}") + return 1 + + if args.dry_run and args.apply: + print("[ERR] --dry-run and --apply are mutually exclusive.") + return 1 + if not args.dry_run and not args.apply: + print("[INFO] No mode provided, defaulting to --dry-run.") + args.dry_run = True + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + where_sql = """ + monitor_type = ? + AND result = ? + AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0 + """ + params = (args.monitor_type, args.from_result) + + cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params) + will_change = int(cur.fetchone()["c"]) + print(f"[INFO] Candidate rows: {will_change}") + + if will_change == 0: + print("[INFO] Nothing to update.") + conn.close() + return 0 + + cur.execute( + f""" + SELECT id, symbol, result, pnl_amount, closed_at + FROM trade_records + WHERE {where_sql} + ORDER BY id DESC + LIMIT 10 + """, + params, + ) + sample = cur.fetchall() + print("[INFO] Sample (latest 10):") + for r in sample: + print( + f" id={r['id']} symbol={r['symbol']} result={r['result']} " + f"pnl={r['pnl_amount']} closed_at={r['closed_at']}" + ) + + if args.dry_run: + print("[DRY-RUN] No write executed.") + conn.close() + return 0 + + cur.execute( + f"UPDATE trade_records SET result=? WHERE {where_sql}", + (args.to_result, *params), + ) + changed = int(cur.rowcount) + conn.commit() + conn.close() + print(f"[DONE] Updated rows: {changed}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/crypto_monitor_binance/scripts/verify_binance_funding.py b/crypto_monitor_binance/scripts/verify_binance_funding.py new file mode 100644 index 0000000..bbe7908 --- /dev/null +++ b/crypto_monitor_binance/scripts/verify_binance_funding.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" + python scripts/verify_binance_funding.py + +打印 BINANCE_API_KEY 前 8 位便于与 Binance 控制台核对(不含 Secret)。用于服务器自检。 +""" +import os +import sys + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, BASE) + + +def load_env(path): + if not os.path.exists(path): + return + for line in open(path, "r", encoding="utf-8", errors="ignore"): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip().lstrip("\ufeff") + if k.replace("_", "").isalnum(): + os.environ[k] = v.strip().strip('"').strip("'") + + +def main(): + load_env(os.path.join(BASE, ".env")) + k = (os.getenv("BINANCE_API_KEY") or "").strip() + s = (os.getenv("BINANCE_API_SECRET") or "").strip() + if not k or "REPLACE" in k.upper(): + print("WARN: BINANCE_API_KEY 为空或仍像占位符,请核对 .env") + if not s or "REPLACE" in s.upper(): + print("WARN: BINANCE_API_SECRET 为空或仍像占位符,请核对 .env") + print("BINANCE_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)") + + import app as mod # noqa: E402 + + mod.ensure_markets_loaded() + fu = mod._fetch_binance_funding_usdt() + print(">>> _fetch_binance_funding_usdt() =", fu) + try: + sw = mod._fetch_binance_swap_usdt_total() + print(">>> _fetch_binance_swap_usdt_total() (合约账户) =", sw) + sf = mod._fetch_binance_swap_usdt_free() + print(">>> _fetch_binance_swap_usdt_free() (合约可用) =", sf) + except Exception as e: + print(">>> swap balance fetch error:", e) + + +if __name__ == "__main__": + main() diff --git a/crypto_monitor_binance/start_utf8.ps1 b/crypto_monitor_binance/start_utf8.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..4a8fd0cbe425e232604b47af88dfdd3bd069e311 GIT binary patch literal 1080 zcmcJOPfNo<5XIkF@H;HE2R*dtK@q`&Xw`z$Vighf5YtpEZ4#PjE%@2h-|V)wLg`JG z%}!=^-u!#}{Z&;%BUZ*fQmj&avy5bTN3d>>Rqb|9`=^ ziritv`1L_9b;bRRb1}bDj~nW^(X~#&*vGq1bq?P*yhE*)Pf6qvIh!@2GOH+I7ig?M zh8$B(OgyJO?P{B~gZ#<91kLhpaSm8^`(DT1v*0@b>rn@+CRn+@-9U=5Stn9GgJRvj zr!MQLByc2!!S68TEpZWdu3%Duw#@f`@ir68`|Bqgs_G5LVW!+ho;o@wq2Zf)Y zv9wLcjA=F}hc-;kO*wJu-)oS;FZ0_H?X>%j{~oF$P>^5ug!bjzq%f<%yF$-KLi}HnE(I) literal 0 HcmV?d00001 diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html new file mode 100644 index 0000000..7d59cb9 --- /dev/null +++ b/crypto_monitor_binance/templates/index.html @@ -0,0 +1,1283 @@ + + + + + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 + + + +{% macro period_stats(title, s) %} +
+

{{ title }}

+
{{ s.range_label }}
+
+
开单次数
{{ s.opens_count }}
+
平仓笔数
{{ s.closed_count }}
+
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
+
净盈亏(U)
{{ s.net_pnl_u }}
+
亏损额合计(U)
{{ s.loss_sum_u }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ s.max_single_loss }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ s.max_single_profit }}{% else %}-{% endif %}
+
最大回撤(U)
{{ s.max_drawdown_u }}
+
当前连续亏损笔数
{{ s.consecutive_losses }}
+
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ s.worst_day_pnl }}U){% else %}-{% endif %}
+
+
+{% endmacro %} +
+
+

加密货币|交易监控 + AI复盘一体化

+
{{ exchange_display }}
+
+ + {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} + +
+ 数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列,复盘单独导出): + 交易记录 + 复盘记录 + 关键位(当前) + 关键位历史 +
+
+
交易所
{{ exchange_display }}
+
总交易
{{ total }}
+
错过次数
{{ miss_count }}
+
胜率
{{ rate }}%
+
资金账户(USDT)
{% if funding_usdt is not none %}{{ funding_usdt }}U{% else %}—{% endif %}
+
交易日
{{ trading_day }}
+
当日资金(交易账户)
{{ current_capital }}U
+
+
实时价格更新时间:--(北京时间 UTC+8)
+ +
+ {% if page == 'trade' %} +
+
+

关键位监控(5m)

+ {% if focus_key_id %} + 放大查看K线(默认200根) + {% else %} + 输入币种查看K线 + {% endif %} +
+
+ + + + + + +
+
+ {% for k in key %} +
+
{{ k.symbol }} | {{ k.monitor_type }} | {{ '做多' if k.direction == 'long' else '做空' }}
+
+ 上:{{ k.upper }} 下:{{ k.lower }} + | 已提醒:{{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} + | 现价:- + | 距上沿:- + | 距下沿:- + | 门控:- + +
+ +
+ {% endfor %} +
+
+

关键位历史(满次提醒或手动删除)

+
满 {{ key_alert_max_times }} 次企业微信提醒后自动移入此处;手动删除也会归档。
+
+ {% for h in key_history %} +
+
+ {{ h.symbol }} | {{ h.monitor_type }} | {{ '做多' if h.direction == 'long' else '做空' }} | {{ h.close_reason }} + +
+
上:{{ h.upper }} 下:{{ h.lower }} | 提醒次数:{{ h.notification_count }} | {{ (h.closed_at or '-')[:16] }}
+ {% if h.last_alert_message %}
{{ h.last_alert_message[:200] }}{% if h.last_alert_message|length > 200 %}…{% endif %}
{% endif %} +
+ {% else %} +
暂无历史
+ {% endfor %} +
+
+
+ +
+
+

实盘下单监控

+ {% if focus_order_id %} + 放大查看K线(100根) + {% else %} + 暂无持仓可放大 + {% endif %} +
+
+ 规则:单仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; + {% if can_trade %}可开仓{% else %}不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00){% endif %}; + 按风险比例自动计算仓位 +
+
+ 以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% +
+
+ 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }}) +
+
+ + + + +
+
+ + + + + + + + 成交价自动取交易所实时+成交回报 + + + + + +
+
+ {% for o in order %} +
+
{{ o.symbol }} | {{ '做多' if o.direction == 'long' else '做空' }}
+
+ 风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{{ o.risk_amount or '-' }}U + | {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ o.breakeven_price or '-' }}{% else %}移动保本:关{% endif %} +
+ 成交:{{ o.trigger_price }} 止损:{{ o.stop_loss }} 止盈:{{ o.take_profit }} + | 盈亏比:{% if o.rr_ratio is not none %}1:{{ '%.2f'|format(o.rr_ratio) }}{% else %}-{% endif %} + | 现价:- + | 浮盈亏:- + | 计划基数:{{ o.margin_capital }}U | 所保证金:- + | 杠杆:{{ o.leverage }}x | 仓位占比:{{ o.position_ratio }}% +
+ 平仓 +
+ {% endfor %} +
+
+ {% endif %} + + {% if page == 'records' %} +
+

交易记录 & 错过机会

+
+ +
+
+ + + {% for r in record %} + + {% set pnl_val = (r.pnl_amount or 0)|float %} + + + + + {% set stop_show = r.effective_stop_loss or r.initial_stop_loss or r.stop_loss %} + {% set tp_show = r.effective_take_profit or r.take_profit %} + + + + + + + + {% set pnl_val = (r.effective_pnl_amount or 0)|float %} + + + + + {% endfor %} +
品种类型方向成交止损止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
{{ r.symbol }}{{ r.monitor_type }}{{ '做多' if r.direction == 'long' else '做空' }}{{ r.trigger_price }}{{ price_fmt(r.symbol, stop_show) }}{{ price_fmt(r.symbol, tp_show) }}{{ r.margin_capital or '-' }}{{ r.leverage or '-' }}{{ r.effective_hold_minutes or 0 }}{{ (r.effective_opened_at or '-')[:16] }}{{ (r.effective_closed_at or r.created_at or '-')[:16] }}{{ r.effective_pnl_amount or 0 }} + {% set effective_result = r.effective_result %} + {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} + {% elif effective_result in ["止损","强制清仓","手动平仓"] %}{{ effective_result }} + {% else %}{{ effective_result }}{% endif %} + + + + +
+
+
+ +
+

记录错过机会

+
+ + + + + + + + +
+
+ +
+

交易复盘记录上传(含截图)

+
+ + + + +
+ + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ + + + + + +
+ + +
+
+ +
+

AI复盘(按交易记录)

+
+ + + + + + + +
+ + +
+
+ 交易复盘记录 +
+
+
+ AI历史复盘 +
+
+
+
+ {% endif %} +
+ + {% if page == 'stats' %} +
+
+

数据统计

+ +
+
+
+
持仓占用导致错过(累计)
{{ occupied_miss_total }}
+
+
+ 已平仓「下单监控」按平仓时间归入北京时间下的交易日;胜率按盈笔数/(盈+亏)。历史总开仓(累计): + {{ stats_bundle.total_opens_all }} 次 +
+ {{ period_stats("日统计", stats_bundle.day) }} + {{ period_stats("周统计", stats_bundle.week) }} + {{ period_stats("月统计", stats_bundle.month) }} +
+
+ {% endif %} +
+ + +
+
+
+
详情
+ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/crypto_monitor_binance/templates/key_focus.html b/crypto_monitor_binance/templates/key_focus.html new file mode 100644 index 0000000..41a633a --- /dev/null +++ b/crypto_monitor_binance/templates/key_focus.html @@ -0,0 +1 @@ +ok2 \ No newline at end of file diff --git a/crypto_monitor_binance/templates/key_focus_v2.html b/crypto_monitor_binance/templates/key_focus_v2.html new file mode 100644 index 0000000..d4b3492 --- /dev/null +++ b/crypto_monitor_binance/templates/key_focus_v2.html @@ -0,0 +1,261 @@ + + + + + {{ exchange_display }} | 关键位放大 + + + +
+
+
+
+ 返回首页 + 关键位放大(可输入币种){{ exchange_display }} +
+
最近刷新:--
+
+ +
+ + + + + + + + + + + + + + +
+
+ +
+
+
交易对
-
+
监控类型
-
+
方向
-
+
上沿/阻力
-
+
下沿/支撑
-
+
现价
-
+
距上沿
-
+
距下沿
-
+
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/crypto_monitor_binance/templates/login.html b/crypto_monitor_binance/templates/login.html new file mode 100644 index 0000000..cfcc816 --- /dev/null +++ b/crypto_monitor_binance/templates/login.html @@ -0,0 +1,118 @@ + + + + + 登录 · {{ exchange_display }} + + + + + + diff --git a/crypto_monitor_binance/templates/order_focus.html b/crypto_monitor_binance/templates/order_focus.html new file mode 100644 index 0000000..0811e93 --- /dev/null +++ b/crypto_monitor_binance/templates/order_focus.html @@ -0,0 +1,194 @@ + + + + + 实盘下单放大 | 100根K线 + + + +
+
+
+
+ 返回首页 + 实盘下单放大(100根K线) +
+
最近刷新:--
+
+ {% if orders %} +
+ + + + + + +
+ {% else %} +
当前没有激活订单,无法展示放大K线。
+ {% endif %} +
+ + {% if orders %} +
+
+
交易对
-
+
方向
-
+
成交价
-
+
止损
-
+
止盈
-
+
盈亏比
-
+
现价
-
+
浮盈亏
-
+
+
+ +
+
+
+ {% endif %} +
+ +{% if orders %} + + +{% endif %} + + diff --git a/crypto_monitor_binance/templates/order_focus_v2.html b/crypto_monitor_binance/templates/order_focus_v2.html new file mode 100644 index 0000000..9c9add3 --- /dev/null +++ b/crypto_monitor_binance/templates/order_focus_v2.html @@ -0,0 +1,214 @@ + + + + + {{ exchange_display }} | 实盘下单放大 + + + +
+
+
+
+ 返回首页 + 实盘下单放大(100根K线){{ exchange_display }} +
+
最近刷新:--
+
+ {% if orders %} +
+ + + + + + +
+ {% else %} +
当前没有激活订单,无法展示放大K线。
+ {% endif %} +
+ + {% if orders %} +
+
+
交易对
-
+
方向
-
+
成交价
-
+
止损
-
+
止盈
-
+
盈亏比
-
+
移动保本
-
+
现价
-
+
浮盈亏
-
+
+
+ +
+
+
+ {% endif %} +
+ +{% if orders %} + + +{% endif %} + + + diff --git a/crypto_monitor_binance/部署文档.md b/crypto_monitor_binance/部署文档.md new file mode 100644 index 0000000..8aec281 --- /dev/null +++ b/crypto_monitor_binance/部署文档.md @@ -0,0 +1,286 @@ +# `crypto_monitor_binance` 部署指南:SSH SOCKS + Binance + PM2(Ubuntu) + +项目功能、环境变量总览与本地运行说明见 **[README.md](./README.md)**。 + +本文面向:**在本机或 VPS 上运行本项目**,但 **直连 Binance API 不稳定、超时或被网络策略拦截** 的场景。思路是: + +- 本机用 `ssh -D` 做动态转发,把 **SOCKS5 出口**放到能稳定访问 Binance 的机器(常见为一台境外 VPS) +- 项目在 `.env` 中设置 **`BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080`**(或你实际端口),`ccxt` 经 SOCKS 访问交易所 +- **SSH 隧道**:用 `ssh -D` 在本机常驻即可(screen / tmux / systemd 等),**不必交给 PM2** +- 使用 **PM2** 仅托管 **Flask 应用**;仓库根目录 **`ecosystem.config.cjs`** 默认进程名为 **`crypto-monitor-binance`** + +> 安全提醒:不要把 `.env`、私钥 `.pem`、Binance API Key / Secret 提交到 Git;下文只用占位符。 + +--- + +## 0. 你需要准备的东西 + +- 一台 **Ubuntu**(或同类 Linux)运行项目的机器(下文称「本机」) +- 一台可 SSH 登录、且 **能正常访问 Binance API** 的 VPS(示例:`HostName` 填你的服务器 IP,用户如 `root`) +- SSH:**私钥登录**(推荐,便于隧道脚本无人值守) +- 本机已安装:`python3`、`python3-venv`、`pip`、`curl`、`ssh`、`git`(可选)、`node` + `npm`(安装 PM2) +- Binance 账户:已开通 **USDT-M 永续合约**;API Key 勾选 **合约**、**万向划转**(若使用资金↔合约划转)等所需权限,并配置 **IP 白名单**(若启用) + +--- + +## 1. 获取代码与目录 + +将包含 `app.py` 的项目放到固定目录,例如: + +```bash +mkdir -p ~/apps +cd ~/apps +# git clone ... 或解压同步的包 +cd crypto_monitor_binance +``` + +下文用 **`/root/crypto_monitor_binance`** 仅为示例,请换成你的实际绝对路径。 + +--- + +## 2. 配置 SSH 私钥与 `~/.ssh/config` + +```bash +mkdir -p ~/.ssh +chmod 700 ~/.ssh +# 私钥示例:~/.ssh/vps1.pem +chmod 600 ~/.ssh/vps1.pem +``` + +编辑 `~/.ssh/config`(示例别名 **`bn-vps`**,与你手工启动 `ssh -D ... bn-vps` 一致即可): + +```sshconfig +Host bn-vps + HostName 你的_VPS_IP + User root + IdentityFile ~/.ssh/vps1.pem + IdentitiesOnly yes + ServerAliveInterval 30 + ServerAliveCountMax 3 + ExitOnForwardFailure yes + BatchMode yes +``` + +测试: + +```bash +ssh bn-vps true +``` + +> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开。 + +--- + +## 3. 手工验证:SSH SOCKS + Binance API + +### 3.1 本地 SOCKS(示例端口 1080) + +```bash +ssh -N -D 127.0.0.1:1080 bn-vps +``` + +保持运行,另开终端继续。 + +### 3.2 验证经 SOCKS 可访问 Binance(公开接口) + +```bash +curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.binance.com/api/v3/time +``` + +应返回 JSON(含 `serverTime` 字段)。若此处失败,**不要先启动应用**:先修隧道或 VPS 出站。 + +--- + +## 4. Python 虚拟环境 + +```bash +cd /root/crypto_monitor_binance + +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -U pip +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错。 + +可选: + +```bash +export PYTHONDONTWRITEBYTECODE=1 +``` + +--- + +## 5. 配置 `.env`(关键:Binance + 代理) + +项目通过 `app.py` 启动时 **自动加载项目根目录的 `.env`**。与交易所相关的变量使用 **`BINANCE_`** 前缀(与代码一致)。 + +至少确认: + +```env +APP_HOST=127.0.0.1 +APP_PORT=5000 + +# 实盘(按需) +LIVE_TRADING_ENABLED=false +BINANCE_API_KEY=你的_Key +BINANCE_API_SECRET=你的_Secret + +# 保证金:cross=全仓 isolated=逐仓(与币安账户/习惯一致) +BINANCE_MARGIN_MODE=cross + +# 持仓模式:hedge=双向(需在币安开启双向持仓);oneway=单向 +BINANCE_POSITION_MODE=hedge + +# 条件单触发参考:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价 +BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE + +# 经本机 SSH 动态转发访问 Binance(端口与隧道一致) +BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + +# 若不用 SOCKS,可改用 HTTP 代理(一般二选一) +# BINANCE_HTTP_PROXY=http://127.0.0.1:7890 +# BINANCE_HTTPS_PROXY=http://127.0.0.1:7890 +``` + +说明:**推荐 `socks5h://`**,由 SOCKS 端解析域名,与 `curl --proxy socks5h://...` 行为一致。 + +**止盈止损说明(应用逻辑)**:实盘开仓后,程序会在 Binance USDT-M 永续上挂 **`STOP_MARKET`(止损)** 与 **`TAKE_PROFIT_MARKET`(止盈)**;`BINANCE_POSITION_MODE=hedge` 时会自动带 **`positionSide`**,须与币安合约「双向持仓」开关一致。不显式传 **`reduceOnly`**(否则易触发 API **`-1106`**:`Parameter 'reduceOnly' sent when not required`)。 + +--- + +## 6. 自检脚本(可选) + +在已配置 `.env` 且网络可达的前提下: + +```bash +cd /root/crypto_monitor_binance +source .venv/bin/activate +python scripts/verify_binance_funding.py +``` + +用于粗测资金钱包与合约钱包 USDT 读取(需有效 API 与权限)。 + +--- + +## 7. 手工启动 Flask(验证) + +1. SOCKS 已监听 `127.0.0.1:1080`(若使用代理) +2. 已 `source .venv/bin/activate` +3. `.env` 已按需配置 `BINANCE_SOCKS_PROXY` 等 + +```bash +cd /root/crypto_monitor_binance +source .venv/bin/activate +python app.py +``` + +浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口)。 + +--- + +## 8. 安装 PM2 + +```bash +sudo npm i -g pm2 +pm2 -v +``` + +--- + +## 9. PM2:使用仓库内 `ecosystem.config.cjs`(推荐) + +在项目根目录: + +```bash +cd /root/crypto_monitor_binance +pm2 start ecosystem.config.cjs +pm2 status +pm2 logs --lines 200 +``` + +默认只启动 **`crypto-monitor-binance`**(`.venv/bin/python app.py`)。 + +### 本机已可直连 Binance、不需要隧道时 + +`.env` 里应 **去掉或留空** `BINANCE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`。 + +### 开机自启 + +```bash +pm2 save +pm2 startup +# 按屏幕提示执行一条 sudo 命令 +``` + +--- + +## 10. 等价手工命令(不使用 ecosystem 文件时) + +### 10.1 SSH SOCKS(自行后台常驻,不推荐用 PM2) + +示例(前台;实际可用 `screen`/`tmux`/`-f` 后台化或 systemd): + +```bash +ssh -N -D 127.0.0.1:1080 bn-vps \ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ + -o ExitOnForwardFailure=yes +``` + +### 10.2 Flask + +```bash +cd /root/crypto_monitor_binance +pm2 start /root/crypto_monitor_binance/.venv/bin/python --name crypto-monitor-binance -- \ + /root/crypto_monitor_binance/app.py +``` + +--- + +## 11. 交易所「连接不上」排查清单 + +1. **`.env` 是否为 Binance 变量**:`BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY` / `BINANCE_API_KEY` / `BINANCE_API_SECRET` 等前缀需与代码一致。 +2. **隧道是否在本机端口监听**(若配置了 `BINANCE_SOCKS_PROXY`): + ```bash + ss -lntp | grep 1080 || true + ``` +3. **curl 复测 Binance**(与第 3.2 节相同);curl 不通则应用也不会通。 +4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`。 +5. **SSH 隧道连不上**:检查私钥权限、`~/.ssh/config`、VPS 出站与端口是否与 `.env` 一致。 +6. **API 权限与 IP 白名单**:Secret 错误、权限不足、未放行当前出口 IP 时,私有接口会失败。 +7. **启动顺序**:若走代理,先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用)。 + +--- + +## 12. 推荐启动顺序(习惯) + +1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.binance.com/api/v3/time` 成功 +2. `pm2 start ecosystem.config.cjs` +3. 再确认页面与余额等接口正常 + +--- + +## 13. 免责声明 + +交易所有合规与地区政策要求。请确保使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署路径。 + +--- + +## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py` + +在 Ubuntu 上: + +1)预览(不写库): + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run +``` + +2)确认后执行: + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +``` + +默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`。 diff --git a/crypto_monitor_gate/__pycache__/app.cpython-310.pyc b/crypto_monitor_gate/__pycache__/app.cpython-310.pyc deleted file mode 100644 index eeb66abeab30a86c084bcd548d11d4a1ac2fc665..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 151859 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
Q=%{gat4OwvL8dP3-x`z>zaR0mP`DISD3w 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

%B!9YF{Qh_dQ0|DfjXr(@c+}CLUfA1Qnb~ zQAkr%8lw_6YrghX%p|FA7&I)k`v8+eLV%N*5L;1+3 zuB61Eu%SkV8yU0h<@CrMs#V(FnUEd32{i&k(Esm-%B{tPJOeo zN;x|#o1#=!Hbrx?vN)QXZ6}!>vOa7vkS~#>J8b2+@F^{O{dk3%Kd*JmyS2C_qQ>Mon6gG80|wBoKmw7wG_C# z{_YfH+S(1NOWr0mpfzhZu1;OC?#gwWQs*x-*~(C7X%2=l#@-Ko;AKvFw`6wZshN$; zuAjCN$MaZsbGP23WGGA-pIv9eb0O!C!9U_1?=wa!$IuU zo+9^K#W(Q{0nVpHVn}1fi0A9HmdxYs*;ZDhEE{6 z`77_Ym2$m9t(~a0GLV`{Z36?T`4~vOPL1;hQva0-V_1F43bRs2RQaMzNJPeOsrD>2 z@}HJ3k?KS(k=A@GxUlA%I&t#}?0`mP5gV8+g4^m?6*Q{+Kvx$V)&CQU8cdR7K*8KI zf{mR&ta@8*p&xB0d6^^=jtsT+Y$|PDz9M^K_#}W;{Aw$wW{!0yoZ-m3^fn1Aj@R}w za&EE{&uC0T$2n!IllD|F7ei(-qb+)S=S~e;4CGv7I(c1obHNW$I@!IW=!9GzIwe1ZukWX) zT-Uur9%`9xTZrCH>gj7VDHOpVKVgebzirm$4j@CMhD=g&84w&0FrJh zjm!3zXL;RrSB3U1g8=1Xrp?)mX4#_R`0n2R(aFx5rmSlItF#BERv36VG{WST{>_6K zGxI=RA`V!(!9_VqL9eHV%XWhuHPBDPA;^%FA>E>GF-1m7zX`)}T$YR`P5uFz%w~~4 z`x!v~CkRGc`ZsSL9FU2N^^Y0ErE6RTx9rTOTa|n_&Daf*($zm8#mY>&y)-tvjc+q| zF#eNdD##`MEOHDmlVcefe7$=7ISs346g;b-S8+w1#Vd3>mC@sWNmqLm7>*6!a*#U4hBe_UY>D3jR!G{JqNf zrh<19*D=lib3Vv1&urKlskMWYqm!Kjm(7Ddt5tFIHf~`e$Y(+@a{U*Sw?@0$Xa1V} z%sne!5OMzU7s~!G3DSVT?sBe{?Mb@Ub+)BChYF=Zn%$M(MdTOJwi9P^_gBg^x(nXM z-zmRuDA1_#|BtRpcZboY^3^q=1tCEZ;`E5eBfQ=MkMjt zw75fka4fk0j(uGz(pcnQW0NC4jI~965StP?7Hf|jjZKXliA{@)$EHWdVlyI#TW5k{ z_Wib5k(Xn$Bj1aqBL5Pb6ZvjzZseb1^CJHgn;-ed*n-G+5(_!DpBDLcY*FkVBL9iB z{tHo2c4#qkkh{<+- z?cQu#Nmr(?djL^xDMJgSbg0Dst0C60} z0Y#Qw&Sq+X9Y8R^G=;?cv1J*tahYY>0mTe7vHFrC6(TLz9fgC31YjIl0!9eJE{;L{ zS7MYOhs@^?LTz*q;Z5wdbo6;AIVUY=$Y&`UqGp9E(SBH(Pyz`@s&4YXz?V6K^|&TE z<5XlqVT~DW>%I}-6>V^~qi8i6%y?N}Yforh(1Hd-!xRO6*R{Gu1=>PvOY(Pgt0QYi z{X~nT|4o(n_X;Lh218Z(zLFHjobfwab$BiB?wyQ@jCd~bXnw6~U}e%;B_V-1R^mu2 z&27iFM~Lt%|1jAH{13OoO*ncMkS*D}qwSqcV>fzCu}b1lXCWw+S`LkN2RC1AGdK^j z`~#$pBW8{9C4e}SXw(ju9KEU6Aw3~ULWI<@FbB{i<( zXfd2{%sUryUt{)YjqNAL}dVF$K*=MC@S&?@hvjBG3s8O0{3RSc@*76Xhf zY{5no*V_9$lLGnUI6G-N2pE*9np=9VQ6EQnwlQEVCU<|;)lE6LT6uM-eJ24m*Fo|& z#J!Cp^@2?w_!~h(8O+tUyvDwy#p6d6mSQ2zLlp<2!%`R5J1qbw{m^S=CQl|!o5f8b z?v+9u$5hfyCGJEaZkokSH;cF0LM-68q?~E7lMAu4a?RvC`#`-h8R|W9SDlA8Wt-_u zy^&JPz!Iyu)R0>WPo(l&Ik3F&q_yw_Wm}$HR(LYG@MOL{*JWN*+1w7Tm9Z1-B|tHlU`tw#~H*Lt1! zh6Owsk#GZ`Ev@RG{sZ=vOJqBT5crJ!PG@yMc`p1Z&DhR12An77mv zrT)#>q|oHE%CcM3&jALH_S$9JOK=hrH9p& zLNPWrGF9dXC`8X9p4!@efhsI99SG`ExjEh&?s2=wTvD9WlB$)pDC*#-?nnI9#7qt9 z$C2Cq&hyUSUx%{#T%La+H<$7BMu1USEx3P2Zm#!6YCF$c4(O1cT~K(ooo5%=vlYR! zm4#Ju^)BN3ft;bg=Xn>esPIpaN^SssM($3goj7u$?{-2(mfn8?}rVKL;7-`LZ+MK;_ z?fRj$0avBh>vV8$oq@q+7e+@togRXf%{rxY4uX;FcVar!<=aLp*AI$9O@ZsP+*wiDEe4Rwo$aG2+Y~TAM-D!@+!-=j0t^+G`?66yUb*Oa zWrv?riBG9)8E!hzkRR#x_Y~YpoEgSZHkO9V13?TxLb?$PV?j`cB92#H>=csEq{_xI z$01`;LwSJM(OT=)F2k#+ao56wK`2W<2hr|Y(aEiF8swUU&e#o(zwQ8fanXUF+Ynl} zXhp~TFpZIK==QG(Am>-{CFvLf-ae%6Wp%1Cx9R4QIe!Ue<`?L*be-M^xB#P53PUVE za@-ghom6-r1IB)EphsH@QKShRIgi%L8Ab*M2M*^hRCE7CffOA5pX%yj1wT`8k%Av9 zFc_LwboCzu$ZQ|vgGOt`B(U)eZ@>*kcb9I-yL~pPl;hZ-&|(Mp;yK-DzeDPfXt(DX zY_dDqn{VRJssF6te^%W()Q-lHx?lAWWJ<_o(X)DLNB(D(*mPbV(ABjHE>Pn0bajcY zL}WVSL~#W=7C2E^LSN(e@5dZo(i>FeKF zPAi+B$3C)fFxn?dxs_Mq=vPWU=n!xc(Ku=XQV&3j)D#A!b^|LAO-mGMD%+7BIsg&L z5@UNJug2oFcJ3F7jpv8hDz%|_2bKw?zHq5F}HlZ(X@OG)k-o2d{3xzDHRNTQuVy)J!4#-MKWrv!3l zu2_Hx_P?qj_%#KL2nXGNvK9K#LodjGt0~O)%Ej>}So$Wvv<+#mUKhkuIss>s&G77H zem`*?35Rs4ttKQHw;5{~S0)tK9EZtXnp62Wp6Uda6Ld%u`VoQLQbR&gN*(wBg*0a= zPB|&|>(~kZH&iJ&?78*L>)FzcN1;+}2<>UA;cEVyo3Zjf4SJZP-aw-lm9TrH$^_mO znb}|la`5uv*4NeQYkDLB67B*IFnIb$^3I5CQ|YzHz?lfG6%)L#l*x2qW-?z?~T6_Pc{;$;&#V-8~O}okVVo;un{q-C@=-#qr zM`vur&jw0b)>S%CyM@_0%`^?atM$t45$uq!#hTGz-DgyYM}9@_hw!${F8=Fg8!td$Vk)iKf~)W zLA3Zh<$`F5oSDdbBX`h2IP8?DIYGB$m@Cn6CEk>`pw#PC-G;c@#KWAi1tTr}hl7!k z`hyjBKtB)H>@(`voU0?ijRQ76|Bf>7EDmGlL!V-yHL4^bH4r?t4vr&;S1e+5Qm_b_R0eC5ojv~F=)<2Rz*oudz=JCz!6ul& zqt(3`uXii%g74JK*TnD*fzZ>{y9uDG|3i;TMH!Aum*V+otGLJ^=>?*Q8+#D2C zeNg8HtFr)(@J|euJ2<}BLV%h;y>FFAC-d(FmI5ABCL5EmZTl4T`NhhZgqxBk*la17 zJng%o(}yQv713(>H)F$qPb(Nf_|~x2X%W((RI^yhCfz(q`;mTS`v5Zs)c1j>&A0N| zLP+qx3X4OKO|769f9-~6SVCKb{p+=$O8{ZeoHrMC9FAozu4mj#R1;!d-=ZB?!5XTf)6vJHF@_ue8W zjIjHuYIE2!)4Y!T=qP1p?}eckPz{UM7gtuN>*nSV8Y}W5_~Csa`r#mJ!b{pq(~&cG z^564s)J^p4Wjppg^I{ugI%eZ37_9U}@T(y2ps*KYys0n=Z z$xq@G5ps3xvme8~pwzCP`GSMGIrf0cGE&1Rj}dN-#ue)C*qtLszx-uKuA=hkORDBj zFG=N`fjKQbQ)oD*xU+cjMBFLGixf%6gf!#8YIL$STzQ-Xl(Pm3e3wm*1yEoR_@7sy zI`KQ(W-&jIsTBDH_NK8HitiK+Ey~{hE7j2s?{;Jlh&gqSxeRjJ71d}8plXq^^L$5Y zLWR_rmSUsjjP|x&;CJ0)iXxt(B;F{(;^F=as!@k3dqiJua{8%?Gt(A3$lHYrc}X(E6a*a= z{tDITYz5Y?We4y=GnbRDjXHybHk`L<1G}4#ZAIjyGl!fUIHXM-rSk%FfzB$n8R#Cl zA(m&U2B3g22s{ysN+Vy#Y8^pk-4gsAPu4V(uC;O@$J@NqM;&gpJ%<0KEE2%HiJd}{WwI+BTTxi7G-@`Ijcu>O@FK?YI>W0P zsfM0K^EQZOIo?}o^lfxsh3H}bjoDhGh@~*eai>7v3Z_-`?VI;i`fnzUXyBStq`4*+ z@%e8waCBegKvyn0kjC8!E+DBz7ZRWtrW`l`(Qyd{@p>T-VxX6RI&NG)w78TeR*Mu? zpK?XL32DrYw37eFw3UqngzTn#GLfp<&7^#i;oubOW)>QMBEl9^0crxNY5ZBOUK18B z0@r^{g`ZF$s@4At0pzN&>VipAO0PbpI#g-|%$TSSc}n$^1!hjCRHLZP=TT9qZ=~`d zb{8cyHHX{$v0Y!XKCff;7n)c>O>wYiNA?>Ad;HyHwDr@PK2)_LvS*RwKKLKyONS;E zYMv*b{T_XHwf`+1qgIwDph-8{kSCvAL4K4BQG#*@`!1skAys!m`WC6`#tB)Ms!51R zVJBaAB<9Jf3Fj(j%l<3%k?QA9QgSrg-JqdS8g<>Iz@*nTh0l9j4X})2#fXziO{>^t zT#4*;q_5VC^1t{m{T6Z?YA^y=xt&b@N<*}c|Eu|*kxAo3}rNj z%~qM&huBk95V>=+Am8Fn!lh^SNf8=NdrW`DxPSKHH4axuE?2r zD!mEV2Kd9f%2MEhyzFTuvMLC1B*JgS=p@No@jgy)oW)~0Jy+sNG_#ZW&5ZZzjTm)g zFV0e_eKn+QlaaQQwu3RNHNk6yw3X~zq;1RYPe9tjT!pk%9+FI=IBuk^%WYDKw5_?h z#z(j_>z+DNSLuw@^`-@6?W0H%^-bu0c4rv96_EQ?-i+Yc zZ|oU&!L!C3vRgk6DGz~cMB+?Xk4;F<6)zGW8F?XI4xFllgiAQvLI+PZ7vdJRbiT1l zw zF{~k{t@j%=I||F8Z*)zy;y$Uh#Abh`ALaV^g4bEo^8k!1FB)3+Ta%ffC~yze0Rl() zh7f3OpLwZ4Wy%}`P?MKFByh!NZ#nYmdmu)S?YU$8t|tnZjv=~P)Tob3D}*pww$Icp z|0gCrO;jfO)34PVwtPOR#BWxd5sW|3ZGqG@?903ytoW$-qG4lBAgb78A~?9RMOI*u zgcK>1HU-{blk|;Nl5D7B#)Pz_t&T=PZqZl%Sk*Q9@&~#y`qD^7tC)$0icG}cfnR^OzfDOjB zgP1e&qT~iE1Aa!KjHUx9NfpcJnB?RjjzRusjF3TXyst0m5VG%~XO296570 zLmxJ&#-IS>Qx1Un< z$gw*g7SOLq1~>pkuRRWcIOTtg*@uLiafAd{GtuU~32zH#i{>aRL{|M!D}bgvl7Y|Z zMLVPydBxcegUUKqD9d`0|i^7|6_U0)hhVtwo{XV}sHl|SH zH2IrsT2U0;8)YV#YG~#T#(5k=jiI3_B7mG%J`pFu9Y`kJAsDb{_=4n4!2hgg*}C0t zt)vv8vBlsV@*iK6PR$Aqk2rN$v3YgmzBw0VBaP&$2exwfNepZ!#G9nZBCkQbMg52P zsbj80LTEQg1Bk~W!phpF~DD$LaQ~w3vJG}dy~C3Q{ipFO+c&a1!+

C;Nhs`$mZ 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 diff --git a/crypto_monitor_gate/ecosystem.config.cjs b/crypto_monitor_gate/ecosystem.config.cjs index b8efe66..3169e63 100644 --- a/crypto_monitor_gate/ecosystem.config.cjs +++ b/crypto_monitor_gate/ecosystem.config.cjs @@ -19,7 +19,7 @@ const PY = path.join(ROOT, ".venv", "bin", "python"); module.exports = { apps: [ { - name: "crypto-monitor-gate", + name: "crypto_gate", cwd: ROOT, script: path.join(ROOT, "app.py"), interpreter: PY, diff --git a/crypto_monitor_gate_bot/.env b/crypto_monitor_gate_bot/.env new file mode 100644 index 0000000..797589c --- /dev/null +++ b/crypto_monitor_gate_bot/.env @@ -0,0 +1,123 @@ +APP_ENV=production +# 服务监听地址(云服务器通常用 0.0.0.0) +APP_HOST=0.0.0.0 +# 服务端口 +APP_PORT=5002 +# 是否开启调试模式(生产建议 false) +APP_DEBUG=false + +# 登录账号 +APP_USERNAME=dekun +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=ChangeMe123! +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=true +# Flask 会话密钥(必须替换为长随机字符串) +FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET + +# 企业微信机器人 Webhook(用于行情/风控推送) +WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY + +# 数据库文件路径(相对路径会自动按项目目录解析) +DB_PATH=crypto.db +# 交易截图上传目录 +UPLOAD_DIR=static/images + +# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量 +# TOTAL_CAPITAL=100 +# 每天起始基数(U) +DAILY_START_CAPITAL=30 +# 日内回撤后基数(U) +DAILY_LOSS_CAPITAL=20 +# 日内盈利后基数(U) +DAILY_PROFIT_CAPITAL=50 +# BTC 默认杠杆倍数 +BTC_LEVERAGE=10 +# 山寨币默认杠杆倍数 +ALT_LEVERAGE=5 +# 交易日重置小时(北京时间) +TRADING_DAY_RESET_HOUR=8 +# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true + +# 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单) +LIVE_TRADING_ENABLED=true +# Gate API Key(实盘) +GATE_API_KEY=REPLACE_WITH_GATE_API_KEY +# Gate API Secret(实盘) +GATE_API_SECRET=REPLACE_WITH_GATE_API_SECRET +# 保证金模式:cross=全仓,isolated=逐仓 +GATE_TD_MODE=cross +# 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤 +GATE_POS_MODE=hedge +# 永续止盈止损:是否优先用官方仓位类触发单(POST price_orders,close-*-position);false=仅用旧版两张 ccxt 条件单 +GATE_TPSL_USE_POSITION_ORDER=true +# 触发单超时(秒),默认 604800=7 天;设为 0 或负数则不向 API 传 expiration +GATE_TPSL_TRIGGER_EXPIRATION=604800 +# 触发参考价:0=最新成交 1=标记价 2=指数价(非法值按 0) +GATE_TPSL_PRICE_TYPE=0 +# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟) +# EXCHANGE_DISPLAY_NAME=Gate.io + +# 关键位监控:5m收线突破过滤参数 +KLINE_TIMEFRAME=5m +KEY_BREAKOUT_LIMIT_PCT=1.5 +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=5 + +# 资金与仓位刷新周期(秒) +BALANCE_REFRESH_SECONDS=60 +# 后台监控轮询周期(秒) +MONITOR_POLL_SECONDS=3 +# 使用可用资金时的缓冲比例(如0.98代表用98%) +FULL_MARGIN_BUFFER_RATIO=0.98 + +# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT +AUTO_TRANSFER_ENABLED=false +AUTO_TRANSFER_AMOUNT=30 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +TRANSFER_CCY=USDT +# 强制清仓整点(北京时间,默认 0=凌晨00点) +FORCE_CLOSE_BJ_HOUR=0 +# 是否启用强制清仓(默认关闭,true 才会在整点执行) +FORCE_CLOSE_ENABLED=false + +# 推送与AI超时(秒) +WECHAT_TIMEOUT_SECONDS=10 +AI_TIMEOUT_SECONDS=120 + +# AI 复盘服务地址(本机 Ollama 默认地址) +OLLAMA_API=http://127.0.0.1:11434/api/generate +# AI 模型名称 +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest + +# Gate 代理(可选):本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口 +# 1) 先在本机建立隧道(示例): +# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes +# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 +# +# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: +# GATE_HTTP_PROXY=http://127.0.0.1:3128 +# GATE_HTTPS_PROXY=http://127.0.0.1:3128 + +# 开仓多周期K线图(可选) +# ORDER_CHART_ENABLED=true +# ORDER_CHART_TFS=4h,1h,15m,5m +# ORDER_CHART_LIMIT=100 +# ORDER_CHART_DIR=static/images/order_charts +# DAILY_OPEN_ALERT_THRESHOLD=5 +# 以损定仓(按交易账户资金的百分比) +# RISK_PERCENT=2 +# 移动保本触发(达到多少R触发)与偏移(百分比) +# BREAKEVEN_RR_TRIGGER=1.0 +# 移动保本阶梯(每多少R继续上移一次,默认1R) +# BREAKEVEN_STEP_R=1.0 +# BREAKEVEN_OFFSET_PCT=0.02 +# 开单风格默认值:trend / swing +# DEFAULT_TRADE_STYLE=trend + +APP_TIMEZONE=Asia/Shanghai +AUTO_TRANSFER_BJ_HOUR=8 +# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py new file mode 100644 index 0000000..6e9c81e --- /dev/null +++ b/crypto_monitor_gate_bot/app.py @@ -0,0 +1,6372 @@ +from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response +import sqlite3 +import csv +from io import StringIO +import time +import threading +import requests +import os +import re +import base64 +import json +import math +from datetime import datetime, timedelta, timezone + +try: + from zoneinfo import ZoneInfo +except ImportError: + ZoneInfo = None # type: ignore +from functools import wraps +import uuid +import ccxt +from werkzeug.utils import secure_filename + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + Image = None # type: ignore + ImageDraw = None # type: ignore + ImageFont = None # type: ignore + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def load_env_file(path): + if not os.path.exists(path): + return + raw_bytes = open(path, "rb").read() + text = "" + for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"): + try: + text = raw_bytes.decode(enc) + break + except Exception: + continue + if not text: + text = raw_bytes.decode("utf-8", errors="ignore") + text = text.replace("\x00", "") + for line in text.splitlines(): + raw = line.strip() + if not raw or raw.startswith("#") or "=" not in raw: + continue + key, value = raw.split("=", 1) + clean_key = key.strip().lstrip("\ufeff") + if not clean_key.replace("_", "").isalnum(): + continue + clean_value = value.strip().strip('"').strip("'") + os.environ[clean_key] = clean_value + +load_env_file(os.path.join(BASE_DIR, ".env")) + + +def resolve_path(path_value): + if os.path.isabs(path_value): + return path_value + return os.path.join(BASE_DIR, path_value) + +app = Flask(__name__) +app.secret_key = os.getenv("FLASK_SECRET_KEY", "crypto_monitor_2026_secret_key") + +# ====================== 登录配置 ====================== +USERNAME = os.getenv("APP_USERNAME", "dekun") +PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") +AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") + +# 企业微信机器人Webhook +WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") +SYSTEM_TYPE = "CRYPTO" +HOST = os.getenv("APP_HOST", "0.0.0.0") +PORT = int(os.getenv("APP_PORT", "5000")) +DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" +DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) + +# 训练参数(可由 .env 覆盖) +DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) +DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) +DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) +BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) +ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) +# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" +).lower() in ("1", "true", "yes", "on") +APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") + + +def _resolve_app_tz(): + if ZoneInfo is not None: + try: + return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) + except Exception: + pass + return timezone(timedelta(hours=8)) + + +APP_TZ = _resolve_app_tz() +LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" +GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip() +GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip() +GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower() +GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower() +# 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平) +GATE_TPSL_TRIGGER_EXPIRATION = int(os.getenv("GATE_TPSL_TRIGGER_EXPIRATION", str(7 * 86400))) +GATE_TPSL_PRICE_TYPE = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0")) +if GATE_TPSL_PRICE_TYPE < 0 or GATE_TPSL_PRICE_TYPE > 2: + GATE_TPSL_PRICE_TYPE = 0 +GATE_TPSL_USE_POSITION_ORDER = os.getenv("GATE_TPSL_USE_POSITION_ORDER", "true").lower() in ("1", "true", "yes") +# 页面展示的交易所名称(多实例/多环境时可按需区分) +EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Gate.io").strip() or "Gate.io" +_GATE_DEFAULT_MARGIN_MODE = "cross" if GATE_TD_MODE in ("cross", "cross_margin") else "isolated" +BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) +PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) +KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) +KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) +KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5")) +AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" +AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) +AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") +AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") +FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" +FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) +# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 +AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) +AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) +MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) +# 趋势回调:补仓触发档位数(平分剩余 50% 计划仓位) +TREND_PULLBACK_DCA_LEGS = max(1, int(os.getenv("TREND_PULLBACK_DCA_LEGS", "5"))) +# 预览有效期(秒);超时须重新「生成预览」 +TREND_PULLBACK_PREVIEW_TTL_SECONDS = max(10, int(os.getenv("TREND_PULLBACK_PREVIEW_TTL_SECONDS", "120"))) +# 确认执行时:当前可用余额与预览快照相对偏差超过该百分比则拒绝(避免余额被划走后仍按旧计划满仓) +TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT = float(os.getenv("TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT", "5")) +MONITOR_TYPE_TREND = "趋势回调" +KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") +FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) +TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT") +UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) +ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" +ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] +ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) +ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) +DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5")) +RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) +BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) +BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) +BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) +DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() +OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate") +AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") + +GATE_SOCKS_PROXY = (os.getenv("GATE_SOCKS_PROXY") or "").strip() +GATE_HTTP_PROXY = (os.getenv("GATE_HTTP_PROXY") or "").strip() +GATE_HTTPS_PROXY = (os.getenv("GATE_HTTPS_PROXY") or "").strip() + + +def build_gate_ccxt_proxies(): + """ + 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口)。 + + 推荐: + - 本机:ssh -N -D 127.0.0.1:1080 user@vps + - .env:GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + + 说明: + - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// + """ + socks = GATE_SOCKS_PROXY.strip() + http = GATE_HTTP_PROXY.strip() + https = GATE_HTTPS_PROXY.strip() or http + if socks: + return {"http": socks, "https": socks} + if http or https: + return {"http": http, "https": https} + return None + + +GATE_CCXT_PROXIES = build_gate_ccxt_proxies() + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(ORDER_CHART_DIR, exist_ok=True) +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER + +# Gate.io USDT 永续(swap) +exchange = ccxt.gateio({ + "enableRateLimit": True, + "options": { + "defaultType": "swap", + "defaultMarginMode": _GATE_DEFAULT_MARGIN_MODE, + }, +}) +if GATE_CCXT_PROXIES: + exchange.proxies = GATE_CCXT_PROXIES +if GATE_API_KEY and GATE_API_SECRET: + exchange.apiKey = GATE_API_KEY + exchange.secret = GATE_API_SECRET +MARKETS_LOADED = False +ACCOUNT_BALANCE_CACHE = { + "updated_at": 0.0, + "funding_usdt": None, + "trading_usdt": None +} +LIQUIDITY_RANK_CACHE = { + "updated_at": 0.0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + prefix = "【加密货币】" + full_msg = f"{prefix}\n{content}" + data = { + "msgtype": "text", + "text": {"content": full_msg} + } + try: + requests.post(WECHAT_WEBHOOK, json=data, timeout=WECHAT_TIMEOUT_SECONDS) + except: + pass + + +def _wechat_account_label(): + return (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip() + + +def _wechat_direction_text(direction): + d = (direction or "").lower() + return "多头(long)" if d == "long" else "空头(short)" + + +def _wechat_trading_capital_text(fallback=None): + try: + _, trading_capital = get_exchange_capitals(force=True) + except Exception: + trading_capital = None + if trading_capital is not None: + return f"{round(float(trading_capital), 4)}U" + if fallback is not None: + try: + return f"{round(float(fallback), 4)}U" + except Exception: + pass + return "-" + + +def build_wechat_close_message( + symbol, + direction, + result, + pnl_amount, + hold_seconds=None, + trigger_price=None, + current_price=None, + stop_loss=None, + take_profit=None, + close_order_id=None, + extra_note=None, + session_capital_fallback=None, +): + hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-" + ep = format_price_for_symbol(symbol, trigger_price) + cp = format_price_for_symbol(symbol, current_price) + tp = format_price_for_symbol(symbol, take_profit) + sl = format_price_for_symbol(symbol, stop_loss) + cap_txt = _wechat_trading_capital_text(session_capital_fallback) + try: + if pnl_amount is not None: + pv = float(pnl_amount) + pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 4)} U" + else: + pnl_disp = "-" + except (TypeError, ValueError): + pnl_disp = "-" + + lines = [ + f"📉 {symbol} 平仓完成", + f"💼 账户:{_wechat_account_label()}", + "", + "🧾 平仓概要", + f"🔖 平仓单号:{close_order_id or '-'}", + f"📌 方向:{_wechat_direction_text(direction)}", + f"📌 平仓结果:{result or '-'}", + f"💰 本单盈亏:{pnl_disp}", + f"⏱ 持仓时长:{hold_txt}", + f"💵 交易账户资金:{cap_txt}", + "", + "🎯 价位(计划)", + f"开仓成交价:{ep}", + f"离场参考价:{cp}", + f"止盈价位:{tp}", + f"止损价位:{sl}", + ] + if extra_note: + lines.extend(["", "📎 备注", extra_note]) + return "\n".join(lines) + + +def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): + sl_fmt = format_price_for_symbol(symbol, new_sl) + return "\n".join( + [ + f"# 🛡️ {symbol} 保护位更新", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 移动保本/止盈", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 类型:**{arm_txt}**", + f"- 当前RR:`{round(float(now_rr), 2)}R`", + f"- 锁定RR:`{round(float(locked_r), 2)}R`", + f"- 新保护位:`{sl_fmt}`", + ] + ) + + +def build_wechat_monitor_error_message(symbol, direction, scene, error_text): + return "\n".join( + [ + f"# ⚠️ {symbol} 下单监控异常", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 异常信息", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", + ] + ) + + +def build_wechat_key_monitor_message( + symbol, + direction, + monitor_type, + trigger_time, + key_price, + confirm_close, + hard_lines, + btc8h_status, + coin4h_status, + swing4h_pct, + op_lines, + risk_tip=None, +): + lines = [ + f"# 🎯 {symbol} 关键位确认推送", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 交易对 / 触发时间", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", + "", + "### 方向与确认K", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", + "", + "### 关键价位", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", + "", + "### 硬条件校验结果", + ] + lines.extend([f"- {x}" for x in hard_lines]) + lines.extend( + [ + "", + "### 市场状态说明", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + "", + "### 操作提示", + ] + ) + lines.extend([f"- {x}" for x in op_lines]) + if risk_tip: + lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) + return "\n".join(lines) + + +def _read_image_base64(image_path): + try: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + except Exception: + return None + + +def _extract_json_object(text): + if not text: + return None + clean = text.strip() + if clean.startswith("```"): + clean = clean.replace("```json", "").replace("```", "").strip() + try: + return json.loads(clean) + except Exception: + pass + match = re.search(r"\{[\s\S]*\}", clean) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: + return None + + +def _journal_row_lines_for_ai(idx, row, *, include_hold_duration=True): + """把 journal 字段拼成给 AI 的文本;字段之外的事实不要指望模型自己猜。""" + def nz(v, default="无"): + if v is None: + return default + s = str(v).strip() + return s if s else default + + lines = [ + f"{idx}. {nz(row['coin'])} {nz(row['tf'])} | 盈亏:{nz(row['pnl'])}U | 实际RR:{nz(row['real_rr'])} | 预期RR:{nz(row['expect_rr'])}", + f" 开仓逻辑:{nz(row['entry_reason'])}", + f" 平仓/离场(交易员自述):{nz(row['exit_reason'])}", + ] + if include_hold_duration: + lines.append(f" 持仓时长:{nz(row['hold_duration'])}") + ee_bits = [ + nz(row["early_exit"]), + nz(row["early_exit_reason"]), + nz(row["early_exit_trigger"]), + nz(row["early_exit_note"]), + ] + if any(x != "无" for x in ee_bits): + lines.append( + " 提前离场记录:" + f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}" + ) + mood_bits = f"心态标签:{nz(row['mood_issues'])}" + if row["mood_score"] is not None: + mood_bits += f" | 自评心态分:{row['mood_score']}" + lines.append(f" {mood_bits}") + if nz(row["post_breakeven_stare"]) != "无": + lines.append(f" 保本后盯盘:{nz(row['post_breakeven_stare'])}") + if nz(row["new_trade_while_occupied"]) != "无": + lines.append(f" 占用时新开仓:{nz(row['new_trade_while_occupied'])}") + if nz(row["note"]) != "无": + lines.append(f" 备注:{nz(row['note'])}") + return "\n".join(lines) + "\n" + + +def ai_review(trades_text, period_title, image_paths=None): + prompt = f""" +你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。 + +【硬性规则 — 必须遵守】 +- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。 +- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。 +- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。 +- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。 +- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。 +- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。 + +【输出结构】 +1. 总体盈亏结构(紧扣笔数、盈亏数字与 RR,少形容词) +2. 心态与执行(每笔 1–10 分 + 一句依据;依据必须对应记录字段) +3. 行为标签(提前离场 / 乱开仓 / 扛单等):仅在有字段或自述支撑时点名;否则写「记录未勾选或未描述,不作强加」 +4. 改进建议(最多 3 条,每条具体可执行) +5. 图表(若有且可读):结合价格行为简述;否则一两句说明无法看图分析 + +交易记录: +{trades_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + images = [] + for p in image_paths or []: + b64 = _read_image_base64(p) + if b64: + images.append(b64) + if images: + payload["images"] = images + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return r.json().get("response", "AI 生成失败") + except Exception as e: + return f"AI 调用失败:{str(e)}" + + +def ai_short_advice(prompt_text): + prompt = f""" +你是交易风控助理。请用中文给出**最多 3 条**提醒,要求: +- 每条不超过 25 个字 +- 语气克制、具体、可执行 +- 不要输出 Markdown,不要编号前缀以外的废话 + +场景: +{prompt_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return (r.json().get("response") or "").strip() + except Exception: + return "" + + +def _load_font(size): + if not ImageFont: + return None + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\arial.ttf", + ] + for path in candidates: + if path and os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except Exception: + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def _ohlcv_to_rows(ohlcv): + rows = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + rows.append( + { + "ts": int(bar[0]), + "o": float(bar[1]), + "h": float(bar[2]), + "l": float(bar[3]), + "c": float(bar[4]), + "v": float(bar[5]), + } + ) + except Exception: + continue + return rows + + +def _local_input_datetime_to_ms(dt_text): + raw = str(dt_text or "").strip() + if not raw: + return None + raw = raw.replace("T", " ") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(raw, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + return None + + +def _pick_marker_point(rows, target_ts_ms, target_price=None): + if not rows or target_ts_ms is None: + return None, None + idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) + if target_price is not None: + try: + p = float(target_price) + if p > 0: + return idx, p + except Exception: + pass + return idx, float(rows[idx]["c"]) + + +def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): + if not Image or not ImageDraw: + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") + img = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(img) + font = _load_font(14) + small = _load_font(12) + + pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 + plot_w = max(10, width - pad_l - pad_r) + plot_h = max(10, height - pad_t - pad_b) + + header_bg = (245, 247, 250) + draw.rectangle((0, 0, width, pad_t), fill=header_bg) + if font: + draw.text((10, 6), title, fill=(25, 35, 60), font=font) + else: + draw.text((10, 6), title, fill=(25, 35, 60)) + + if not rows: + if small: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) + else: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) + return img + + lo = min(r["l"] for r in rows) + hi = max(r["h"] for r in rows) + if hi <= lo: + hi = lo + 1e-12 + + n = len(rows) + marker_by_idx = {} + for mp in marker_points or []: + try: + idx = int(mp.get("idx")) + except Exception: + continue + if idx < 0 or idx >= n: + continue + marker_by_idx[idx] = mp + + x0 = pad_l + for i, r in enumerate(rows): + x1 = pad_l + int((i + 1) * plot_w / n) + x_mid = (x0 + x1) // 2 + wick_x = x_mid + y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) + y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) + y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) + y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) + top = min(y_open, y_close) + bot = max(y_open, y_close) + up = r["c"] >= r["o"] + wick_color = (120, 120, 120) + edge_color = (20, 20, 20) + draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) + body_w = max(1, (x1 - x0) - 2) + left = x0 + 1 + if bot - top < 2: + mid = (top + bot) // 2 + draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) + else: + if up: + draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) + else: + draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) + if i in marker_by_idx: + mp = marker_by_idx[i] + tag = str(mp.get("tag") or "") + m_price = float(mp.get("price") or r["c"]) + y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) + y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) + if tag == "ENTRY": + m_color = (0, 195, 95) + tri = [(x_mid, y_m - 20), (x_mid - 9, y_m - 4), (x_mid + 9, y_m - 4)] + text_y = y_m - 36 + else: + m_color = (235, 65, 65) + tri = [(x_mid, y_m + 20), (x_mid - 9, y_m + 4), (x_mid + 9, y_m + 4)] + text_y = y_m + 12 + draw.ellipse((x_mid - 5, y_m - 5, x_mid + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) + draw.polygon(tri, fill=m_color) + draw.line((x_mid, y_m, x_mid, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) + if font: + draw.text((x_mid + 8, text_y), tag, fill=m_color, font=font) + else: + draw.text((x_mid + 8, text_y), tag, fill=m_color) + x0 = x1 + + if len(marker_points or []) >= 2: + try: + entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) + exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) + if entry is not None and exitp is not None: + ex_i, ex_p = int(entry["idx"]), float(entry["price"]) + xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) + x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) + x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) + y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) + y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) + draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) + except Exception: + pass + + # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 + if small: + draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) + return img + + +def generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + out_dir=None, + filename=None, + filename_prefix="chart", + marker_payload=None, + marker_timeframes=None, +): + if not ORDER_CHART_ENABLED: + return None + if not Image: + return None + requested = timeframes or ORDER_CHART_TFS + limit = limit or ORDER_CHART_LIMIT + preferred_layout = ["5m", "15m", "1h", "4h"] + requested_set = set(requested or []) + ordered = [tf for tf in preferred_layout if tf in requested_set] + for tf in requested: + if tf not in ordered: + ordered.append(tf) + timeframes = ordered[:4] if ordered else preferred_layout + + ensure_markets_loaded() + panels = [] + cell_w, cell_h = 980, 520 + for tf in timeframes: + try: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) + except Exception: + ohlcv = [] + rows = _ohlcv_to_rows(ohlcv)[-limit:] + title = f"{title_prefix} | {tf} x{len(rows)}" + points = [] + tf_key = str(tf).strip().lower() + marker_tfs = {str(x).strip().lower() for x in (marker_timeframes or []) if str(x).strip()} + if marker_payload and tf_key in marker_tfs: + entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")) + exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")) + if entry_idx is not None and entry_price is not None: + points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"}) + if exit_idx is not None and exit_price is not None: + points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"}) + panels.append( + _render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + ) + ) + + if not panels: + return None + + gap = 10 + cols = 2 + rows_n = int(math.ceil(len(panels) / cols)) + w = cols * cell_w + (cols - 1) * gap + h = rows_n * cell_h + (rows_n - 1) * gap + out = Image.new("RGB", (w, h), (255, 255, 255)) + idx = 0 + for r in range(rows_n): + for c in range(cols): + if idx >= len(panels): + break + x = c * (cell_w + gap) + y = r * (cell_h + gap) + out.paste(panels[idx], (x, y)) + idx += 1 + + # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图) + if ImageDraw and rows_n >= 1: + draw_out = ImageDraw.Draw(out) + line_col = (220, 225, 232) + x_mid = cell_w + gap // 2 + if w > x_mid >= 0: + draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2) + for rr in range(1, rows_n): + y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 + if 0 <= y_mid <= h: + draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) + + target_dir = out_dir or ORDER_CHART_DIR + os.makedirs(target_dir, exist_ok=True) + fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" + out_path = os.path.join(target_dir, fname) + out.save(out_path, format="PNG") + return fname + + +def generate_order_open_chart(exchange_symbol, title_prefix, timeframes=None, limit=None): + return generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=timeframes, + limit=limit, + out_dir=ORDER_CHART_DIR, + filename=None, + filename_prefix="order", + ) + + +def journal_coin_from_symbol(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym.split("/")[0].strip() + if "-" in sym: + return sym.split("-")[0].strip() + if sym.endswith("USDT"): + return sym[:-4].strip() + return sym + + +EARLY_EXIT_TRIGGERS = ( + "", + "保本止盈", + "移动止盈", + "手动平仓", + "止损", + "其他", +) + +# 与用户约定的固定开仓类型(仅做这几类单子) +ENTRY_REASON_OPTIONS = ( + "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", + "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", + "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", + "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", + "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", +) +# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) +ENTRY_REASON_OTHER = "__OTHER__" + + +def normalize_entry_reason(raw, custom_text=None): + v = str(raw or "").strip() + if v == ENTRY_REASON_OTHER: + c = str(custom_text or "").strip() + return c[:2000] if c else "" + return v if v in ENTRY_REASON_OPTIONS else "" + + +def entry_reason_valid_for_storage(s): + """允许五种固定整句、或自定义短文本(不含未解析的 __OTHER__ 占位)。""" + t = str(s or "").strip() + if not t: + return True + if t == ENTRY_REASON_OTHER: + return False + if t in ENTRY_REASON_OPTIONS: + return True + return 1 <= len(t) <= 2000 + + +def normalize_early_exit_trigger(raw): + v = str(raw or "").strip() + return v if v in EARLY_EXIT_TRIGGERS else "" + + +def compose_early_exit_reason_saved(trigger, note): + """Readable single-line string stored in early_exit_reason for legacy consumers.""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t and n: + return f"{t}|{n}" + return t or n + + +def journal_exit_reason_stored(trigger, note): + """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t == "手动平仓": + return n + return t + + +def ai_extract_journal_from_image(image_b64): + prompt = """ +你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。 +要求: +1) 仅输出一个 JSON 对象。 +2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。 +3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。 +4) 允许字段为空。 +5) entry_reason:优先从下列完整字符串中选一个(一字不差);若无法归类则可将简述写入 entry_reason(保存时也可选表单「其他」手写): + - 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低 + - 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高 + - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 + - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 + - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 +6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):保本止盈、移动止盈、手动平仓、止损、其他。 +7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 +8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 + +JSON 字段: +{ + "open_datetime": "", + "close_datetime": "", + "coin": "", + "tf": "", + "pnl": "", + "expect_rr": "", + "real_rr": "", + "entry_reason": "", + "early_exit_trigger": "", + "early_exit_note": "", + "early_exit_reason": "", + "note": "" +} +""".strip() + payload = { + "model": AI_MODEL, + "prompt": prompt, + "images": [image_b64], + "stream": False, + "options": {"temperature": 0.1}, + } + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + raw = r.json().get("response", "") + data = _extract_json_object(raw) or {} + if not isinstance(data, dict): + data = {} + trig_in = data.get("early_exit_trigger") + note_in = data.get("early_exit_note") + legacy_reason = str(data.get("early_exit_reason") or "").strip() + out = { + "open_datetime": str(data.get("open_datetime") or "").strip(), + "close_datetime": str(data.get("close_datetime") or "").strip(), + "coin": str(data.get("coin") or "").strip(), + "tf": str(data.get("tf") or "").strip(), + "pnl": str(data.get("pnl") or "").strip(), + "expect_rr": str(data.get("expect_rr") or "").strip(), + "real_rr": str(data.get("real_rr") or "").strip(), + "entry_reason": normalize_entry_reason(data.get("entry_reason")), + "early_exit_trigger": normalize_early_exit_trigger(trig_in), + "early_exit_note": str(note_in or "").strip(), + "early_exit_reason": legacy_reason, + "note": str(data.get("note") or "").strip(), + } + if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason: + out["early_exit_note"] = legacy_reason + if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason: + out["early_exit_note"] = legacy_reason + if out["early_exit_trigger"] != "手动平仓": + out["early_exit_note"] = "" + out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"]) + return out + except Exception: + return None + +# 初始化数据库(支持多空方向) +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 关键位监控 + c.execute('''CREATE TABLE IF NOT EXISTS key_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", upper REAL, lower REAL, + notification_count INTEGER DEFAULT 0, last_notified_at TEXT, + max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5, + breakout_limit_pct REAL DEFAULT 1.5, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + # 订单监控(核心:加 direction 方向字段) + c.execute('''CREATE TABLE IF NOT EXISTS order_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", + exchange_symbol TEXT, + trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5, + trade_style TEXT DEFAULT "trend", + risk_percent REAL, risk_amount REAL, + breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL, + breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL, + notional_value REAL, position_ratio REAL, base_amount REAL, + order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT, + opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT, + status TEXT DEFAULT "active")''') + + # 交易记录(必须存多空) + c.execute('''CREATE TABLE IF NOT EXISTS trade_records + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0, + trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL, + hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER, + result TEXT, miss_reason TEXT, exchange_trade_id TEXT, + reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL, + reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER, + reviewed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions + (session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS journal_entries + (id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT, + coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT, + expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT, + early_exit_trigger TEXT, early_exit_note TEXT, + mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT, + new_trade_while_occupied TEXT, note TEXT, image TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews + (id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs + (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, + amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') + c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique + ON transfer_logs(transfer_type, transfer_day) + WHERE transfer_type = 'auto_daily' ''') + + # 给旧表加 direction 字段(兼容老数据,不报错) + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") + except Exception: + pass + try: + c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") + except: pass + + c.execute( + """CREATE TABLE IF NOT EXISTS key_monitor_history + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, + close_reason TEXT, closed_at TEXT)""" + ) + + c.execute( + """CREATE TABLE IF NOT EXISTS trend_pullback_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + status TEXT DEFAULT 'active', + symbol TEXT NOT NULL, + exchange_symbol TEXT, + direction TEXT NOT NULL DEFAULT 'long', + leverage INTEGER NOT NULL, + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL DEFAULT 5, + snapshot_available_usdt REAL, + snapshot_at TEXT, + plan_margin_capital REAL, + target_order_amount REAL, + first_order_amount REAL, + remainder_total REAL, + dca_legs INTEGER DEFAULT 5, + per_leg_amount REAL, + grid_prices_json TEXT, + leg_amounts_json TEXT, + legs_done INTEGER DEFAULT 0, + first_order_done INTEGER DEFAULT 0, + last_mark_price REAL, + avg_entry_price REAL, + order_amount_open REAL, + opened_at TEXT, + opened_at_ms INTEGER, + session_date TEXT, + message TEXT + )""" + ) + + try: + c.execute("ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT") + except Exception: + pass + + c.execute( + """CREATE TABLE IF NOT EXISTS trend_pullback_previews ( + id TEXT PRIMARY KEY, + symbol TEXT NOT NULL, + exchange_symbol TEXT NOT NULL, + direction TEXT NOT NULL, + leverage INTEGER NOT NULL, + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL NOT NULL, + snapshot_available_usdt REAL NOT NULL, + snapshot_at TEXT, + live_price_ref REAL, + plan_margin_capital REAL, + target_order_amount REAL, + first_order_amount REAL, + remainder_total REAL, + dca_legs INTEGER, + per_leg_amount REAL, + grid_prices_json TEXT, + leg_amounts_json TEXT, + expires_at_ms INTEGER NOT NULL, + created_at TEXT + )""" + ) + + conn.commit() + conn.close() + +init_db() + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def app_now(): + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" + return datetime.now(APP_TZ).replace(tzinfo=None) + + +def app_now_str(): + return app_now().strftime("%Y-%m-%d %H:%M:%S") + + +def utc_now_dt(): + """当前时刻(UTC,aware)。""" + return datetime.now(timezone.utc) + + +def utc_calendar_date_str(): + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" + return utc_now_dt().strftime("%Y-%m-%d") + + +def get_trading_day(now=None): + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" + now = now or app_now() + if getattr(now, "tzinfo", None): + now = now.astimezone(APP_TZ).replace(tzinfo=None) + if now.hour < TRADING_DAY_RESET_HOUR: + return (now - timedelta(days=1)).strftime("%Y-%m-%d") + return now.strftime("%Y-%m-%d") + + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓") + + +def parse_dt_for_trading_day(s): + if not s: + return None + s = str(s).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:ln], fmt) + except ValueError: + continue + return None + + +def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): + conn.execute( + """INSERT INTO key_monitor_history + (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + row["symbol"], + row["monitor_type"], + row["direction"] or "long", + row["upper"], + row["lower"], + int(notification_count or 0), + (last_msg or "")[:800] if last_msg else None, + close_reason, + app_now_str(), + ), + ) + + +def _session_week_bounds(trading_day_str): + end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() + start = end - timedelta(days=6) + return start.strftime("%Y-%m-%d"), trading_day_str + + +def _calendar_month_bounds(local_dt): + y, m = local_dt.year, local_dt.month + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end_d = datetime(y, 12, 31).date() + else: + end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() + return start, end_d.strftime("%Y-%m-%d") + + +def _count_opens_between(conn, start_td, end_td): + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _load_completed_live_pnls(conn): + q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, + result, reviewed_result + FROM trade_records + WHERE monitor_type = '下单监控' + ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" + rows = conn.execute(q).fetchall() + out = [] + for r in rows: + effective_result = (r["reviewed_result"] or r["result"] or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + continue + try: + p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + except (TypeError, ValueError): + p = 0.0 + t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) + td = get_trading_day(t) if t else None + out.append((p, t, td)) + return out + + +def _compute_period_metrics(trades): + """trades: list of (pnl, close_dt, close_trading_day)""" + trades = [(p, t, td) for p, t, td in trades if t is not None] + trades.sort(key=lambda x: x[1]) + closed = len(trades) + wins = sum(1 for p, _, _ in trades if p > 0) + losses = sum(1 for p, _, _ in trades if p < 0) + net = round(sum(p for p, _, _ in trades), 4) + loss_sum_raw = sum(p for p, _, _ in trades if p < 0) + loss_sum_u = round(abs(loss_sum_raw), 4) if loss_sum_raw < 0 else 0.0 + neg_pnls = [p for p, _, _ in trades if p < 0] + pos_pnls = [p for p, _, _ in trades if p > 0] + max_single_loss = round(min(neg_pnls), 4) if neg_pnls else None + max_single_profit = round(max(pos_pnls), 4) if pos_pnls else None + cum = peak = max_dd = 0.0 + for p, _, _ in trades: + cum += p + peak = max(peak, cum) + max_dd = max(max_dd, peak - cum) + max_dd = round(max_dd, 4) + streak = 0 + for p, _, _ in reversed(trades): + if p < 0: + streak += 1 + else: + break + daily = {} + for p, _, td in trades: + if td: + daily[td] = daily.get(td, 0.0) + p + max_loss_streak_days = 0 + worst_day = None + worst_day_pnl = None + if daily: + sorted_days = sorted(daily.keys()) + run = 0 + for d in sorted_days: + if daily[d] < 0: + run += 1 + max_loss_streak_days = max(max_loss_streak_days, run) + else: + run = 0 + worst_day = min(daily.keys(), key=lambda x: daily[x]) + worst_day_pnl = round(daily[worst_day], 4) + win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None + return { + "closed_count": closed, + "win_count": wins, + "loss_count": losses, + "win_rate_pct": win_rate_pct, + "net_pnl_u": net, + "loss_sum_u": loss_sum_u, + "max_single_loss": max_single_loss, + "max_single_profit": max_single_profit, + "max_drawdown_u": max_dd, + "consecutive_losses": streak, + "max_loss_streak_days": max_loss_streak_days, + "worst_day": worst_day, + "worst_day_pnl": worst_day_pnl, + "opens_count": 0, + "range_label": "", + } + + +def compute_stats_bundle(conn, trading_day, now_dt=None): + """日 / 周 / 月 统计:平仓按平仓时间所在交易日计入。""" + now_dt = now_dt or app_now() + pnls = _load_completed_live_pnls(conn) + total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] + w_start, w_end = _session_week_bounds(trading_day) + m_start, m_end = _calendar_month_bounds(now_dt) + + def in_week(tr): + _p, _t, td = tr + return td and w_start <= td <= w_end + + def in_month(tr): + _p, _t, td = tr + return td and m_start <= td <= m_end + + day_trades = [tr for tr in pnls if tr[2] == trading_day] + week_trades = [tr for tr in pnls if in_week(tr)] + month_trades = [tr for tr in pnls if in_month(tr)] + + dm = _compute_period_metrics(day_trades) + wm = _compute_period_metrics(week_trades) + mm = _compute_period_metrics(month_trades) + dm["opens_count"] = _count_opens_between(conn, trading_day, trading_day) + wm["opens_count"] = _count_opens_between(conn, w_start, w_end) + mm["opens_count"] = _count_opens_between(conn, m_start, m_end) + dm["range_label"] = f"北京时间交易日 {trading_day}" + wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天窗口)" + mm["range_label"] = f"{m_start} ~ {m_end}(北京时间自然月)" + + return { + "trading_day": trading_day, + "total_opens_all": total_opens_all, + "day": dm, + "week": wm, + "month": mm, + } + + +def infer_leverage(symbol): + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return BTC_LEVERAGE + return ALT_LEVERAGE + + +def normalize_exchange_symbol(symbol): + sym = symbol.strip().upper() + if ":" in sym: + return sym + if "/" in sym: + base, quote = sym.split("/", 1) + quote_clean = quote.split(":")[0] + return f"{base}/{quote_clean}:{quote_clean}" + return sym + + +def resolve_monitor_exchange_symbol(row): + """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" + raw = "" + try: + if row["exchange_symbol"]: + raw = str(row["exchange_symbol"]).strip() + except (KeyError, IndexError, TypeError): + raw = "" + if not raw: + try: + raw = str(row["symbol"] or "").strip() + except (KeyError, IndexError, TypeError): + raw = "" + return normalize_exchange_symbol(raw) if raw else "" + + +def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): + if not position_symbol or not wanted_exchange_symbol: + return False + a = normalize_exchange_symbol(str(position_symbol).strip()) + b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip()) + return a == b + + +def _position_matches_wanted_contract(wanted_unified_sym, position_dict): + """统一 symbol 比对;不一致时用 Gate 原始 contract 与 ccxt market.id 对齐(兼容 1000PEPE 等命名差异)。""" + if not wanted_unified_sym or not position_dict: + return False + ps = position_dict.get("symbol") + if _position_contract_symbol_match(ps, wanted_unified_sym): + return True + try: + ensure_markets_loaded() + mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper() + info = position_dict.get("info") or {} + c_raw = str(info.get("contract") or "").strip().upper() + if mid and c_raw and mid == c_raw: + return True + except Exception: + pass + return False + + +def _position_row_effective_contracts(p): + """张数:优先 ccxt contracts,否则用 Gate 原始 size/pos(避免统一层为 0 时被误判空仓)。""" + if not p: + return 0.0 + info = p.get("info") or {} + for val in (p.get("contracts"), info.get("size"), info.get("pos")): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return x + except (TypeError, ValueError): + continue + return 0.0 + + +def normalize_symbol_input(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym + if ":" in sym: + sym = sym.split(":")[0] + return f"{sym}/USDT" + + +def normalize_kline_limit(limit_raw, default=200): + try: + n = int(limit_raw) + except Exception: + return default + return 200 if n >= 200 else 100 + + +def get_recommended_capital(current_capital): + if current_capital <= DAILY_LOSS_CAPITAL: + return DAILY_LOSS_CAPITAL + if current_capital >= DAILY_PROFIT_CAPITAL: + return DAILY_PROFIT_CAPITAL + return DAILY_START_CAPITAL + + +def ensure_session(conn, session_date): + row = conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + if row: + return row + conn.execute( + "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", + (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) + ) + conn.commit() + return conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + + +def update_session_capital(conn, session_date, pnl_amount): + session_row = ensure_session(conn, session_date) + new_capital = float(session_row["current_capital"]) + float(pnl_amount) + conn.execute( + "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(new_capital, 4), session_date) + ) + conn.commit() + return round(new_capital, 4) + + +def calc_hold_seconds(opened_at_str, closed_at_dt): + try: + opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") + return int((closed_at_dt - opened_at).total_seconds()) + except Exception: + return 0 + + +def calc_hold_minutes(seconds): + if not seconds or seconds <= 0: + return 0 + return max(1, int(seconds // 60)) + + +def get_opened_at_value(row): + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "opened_at" in keys: + value = row["opened_at"] + if value: + return value + return app_now_str() + + +def get_effective_trade_field(row, reviewed_key, base_key, default=None): + try: + keys = row.keys() if hasattr(row, "keys") else row.keys() + except Exception: + keys = [] + if reviewed_key in keys: + v = row[reviewed_key] + if v is not None and str(v).strip() != "": + return v + if base_key in keys: + v = row[base_key] + if v is not None and str(v).strip() != "": + return v + return default + + +def to_effective_trade_dict(row): + item = row_to_dict(row) + base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") + item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) + item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) + item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", base_stop) + item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) + item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) + item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason")) + item["effective_pnl_amount"] = get_effective_trade_field(row, "reviewed_pnl_amount", "pnl_amount", item.get("pnl_amount")) + item["effective_hold_minutes"] = get_effective_trade_field(row, "reviewed_hold_minutes", "hold_minutes", item.get("hold_minutes")) + item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds")) + er_eff = get_effective_trade_field(row, "reviewed_entry_reason", "entry_reason", item.get("entry_reason")) + item["effective_entry_reason"] = (str(er_eff).strip() if er_eff is not None else "") or "" + return item + + +def format_price_for_symbol(symbol, value): + if value in (None, ""): + return "-" + try: + v = float(value) + except Exception: + return str(value) + if v == 0: + return "0" + av = abs(v) + # 根据币价量级动态精度:低价币保留更多小数,高价币减少噪音位数 + if av >= 10000: + d = 2 + elif av >= 100: + d = 3 + elif av >= 1: + d = 4 + elif av >= 0.01: + d = 6 + elif av >= 0.0001: + d = 8 + else: + d = 10 + text = f"{v:.{d}f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +def format_hold_minutes(minutes): + if not minutes: + return "0分钟" + total = int(minutes) + hours = total // 60 + mins = total % 60 + if hours: + return f"{hours}小时{mins}分钟" + return f"{mins}分钟" + + +def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage): + try: + trigger = float(trigger_price) + exit_p = float(exit_price) + margin = float(margin_capital) + lev = float(leverage) + if trigger <= 0: + return 0.0 + if direction == "short": + pnl_ratio = (trigger - exit_p) / trigger + else: + pnl_ratio = (exit_p - trigger) / trigger + return round(margin * lev * pnl_ratio, 4) + except Exception: + return 0.0 + + +def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): + try: + entry = float(entry_price) + sl = float(stop_loss) + tp = float(take_profit) + if entry <= 0 or sl <= 0 or tp <= 0: + return None + if direction == "short": + risk = sl - entry + reward = entry - tp + else: + risk = entry - sl + reward = tp - entry + if risk <= 0 or reward <= 0: + return None + return round(reward / risk, 4) + except Exception: + return None + + +def calc_risk_fraction(direction, entry_price, stop_loss): + try: + entry = float(entry_price) + sl = float(stop_loss) + if entry <= 0 or sl <= 0: + return None + if direction == "short": + risk = sl - entry + else: + risk = entry - sl + if risk <= 0: + return None + return risk / entry + except Exception: + return None + + +def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital, leverage): + rf = calc_risk_fraction(direction, entry_price, stop_loss) + if rf is None: + return None + try: + notional = float(margin_capital) * float(leverage) + if notional <= 0: + return None + return round(notional * rf, 6) + except Exception: + return None + + +def calc_actual_rr(pnl_amount, risk_amount): + try: + r = float(risk_amount or 0) + if r <= 0: + return None + return round(float(pnl_amount or 0) / r, 4) + except Exception: + return None + + +def normalize_result_with_pnl(result, pnl_amount): + """ + 触发“止损”但实际已盈利时,归类为保本止盈,避免语义混淆。 + """ + if result == "止损": + try: + if float(pnl_amount or 0) > 0: + return "保本止盈" + except Exception: + pass + return result + + +def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): + """ + 按“已锁定R”计算目标止损位: + - long: entry + locked_r * (entry*risk_fraction) + offset + - short: entry - locked_r * (entry*risk_fraction) - offset + """ + try: + entry = float(entry_price) + rf = float(risk_fraction) + lr = float(locked_r) + off = float(offset_pct) / 100.0 + if entry <= 0 or rf <= 0 or lr < 0: + return None + base_move = entry * rf * lr + offset_move = entry * off + if direction == "short": + return round(entry - base_move - offset_move, 8) + return round(entry + base_move + offset_move, 8) + except Exception: + return None + + +def insert_trade_record( + conn, + symbol, + monitor_type, + direction, + trigger_price, + stop_loss, + initial_stop_loss=None, + take_profit=None, + margin_capital=None, + leverage=None, + pnl_amount=0, + hold_seconds=0, + trade_style=None, + risk_amount=None, + planned_rr=None, + actual_rr=None, + result="", + miss_reason=None, + opened_at=None, + opened_at_ms=None, + closed_at=None, + closed_at_ms=None, + exchange_trade_id=None, +): + hold_minutes = calc_hold_minutes(hold_seconds) + open_ts = opened_at or app_now_str() + close_ts = closed_at or app_now_str() + open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) + close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) + conn.execute( + "INSERT INTO trade_records (symbol,monitor_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, monitor_type, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, + margin_capital, leverage, pnl_amount, hold_seconds, + trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id + ) + ) + + +def calc_duration_text(open_str, close_str): + try: + fmt = "%Y-%m-%dT%H:%M" + o = datetime.strptime(open_str, fmt) + c = datetime.strptime(close_str, fmt) + delta = c - o + seconds = int(delta.total_seconds()) + if seconds <= 0: + return "0分钟" + d = seconds // 86400 + h = (seconds % 86400) // 3600 + m = (seconds % 3600) // 60 + parts = [] + if d: + parts.append(f"{d}天") + if h: + parts.append(f"{h}小时") + if m or not parts: + parts.append(f"{m}分钟") + return " ".join(parts) + except Exception: + return "计算失败" + + +def row_to_dict(row): + return {k: row[k] for k in row.keys()} + + +def enrich_order_item(raw_item, current_capital): + item = dict(raw_item or {}) + margin = float(item.get("margin_capital") or 0) + lev = float(item.get("leverage") or 0) + notional = item.get("notional_value") + ratio = item.get("position_ratio") + if notional is None: + notional = round(margin * lev, 4) if margin and lev else 0 + if ratio is None: + ratio = round(margin / current_capital * 100, 2) if current_capital else 0 + item["notional_value"] = notional + item["position_ratio"] = ratio + item["rr_ratio"] = calc_rr_ratio( + item.get("direction") or "long", + item.get("trigger_price"), + item.get("initial_stop_loss") or item.get("stop_loss"), + item.get("take_profit"), + ) + try: + be = item.get("breakeven_enabled") + item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 + except Exception: + item["breakeven_enabled"] = 1 + return item + + +def ensure_exchange_live_ready(): + if not LIVE_TRADING_ENABLED: + return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + if not (GATE_API_KEY and GATE_API_SECRET): + return False, "缺少 Gate API 密钥配置(GATE_API_KEY / GATE_API_SECRET)" + return True, "" + + +def exchange_private_api_configured(): + """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。""" + return bool(GATE_API_KEY and GATE_API_SECRET) + + +def _extract_usdt_total(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + total_map = balance.get("total", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + total = usdt_info.get("total") + if total is None: + total = usdt_info.get("equity") + if total is None: + total = total_map.get("USDT") + if total is None: + total = usdt_info.get("free") + if total is None: + total = free_map.get("USDT") + try: + return float(total) if total is not None else None + except Exception: + return None + + +def _extract_usdt_free(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + free = usdt_info.get("free") + if free is None: + free = free_map.get("USDT") + try: + return float(free) if free is not None else None + except Exception: + return None + + +def _parse_usdt_from_gate_unified_accounts_body(data): + """ + 解析 Gate GET /unified/accounts 响应体中的 USDT(dict 或 list 形态的 balances 均支持)。 + ccxt fetch_balance(unifiedAccount) 在 balances 为数组时会访问 .keys() 崩溃,故资金兜底走此解析。 + """ + if not isinstance(data, dict): + return None + raw_fd = data.get("funding") + if isinstance(raw_fd, (int, float)): + return float(raw_fd) + if isinstance(raw_fd, str) and raw_fd.strip(): + try: + return float(raw_fd) + except Exception: + pass + if isinstance(raw_fd, dict): + u = raw_fd.get("USDT") or raw_fd.get("usdt") + if isinstance(u, dict): + for k in ("equity", "available", "total", "amount"): + v = u.get(k) + if v is not None: + try: + return float(v) + except Exception: + pass + + balances = data.get("balances") + if isinstance(balances, list): + for row in balances: + if not isinstance(row, dict): + continue + sym = str(row.get("currency") or row.get("asset") or row.get("name") or "").upper() + if sym != "USDT": + continue + for k in ("equity", "balance", "available", "total", "amount"): + v = row.get(k) + if v is not None: + try: + return float(v) + except Exception: + pass + elif isinstance(balances, dict): + u = balances.get("USDT") or balances.get("usdt") + if isinstance(u, dict): + for k in ("equity", "available", "total", "amount"): + v = u.get(k) + if v is not None: + try: + return float(v) + except Exception: + pass + + tb = data.get("total_balance") + if isinstance(tb, dict): + u = tb.get("USDT") or tb.get("usdt") + if isinstance(u, (int, float, str)): + try: + return float(u) + except Exception: + pass + if isinstance(u, dict): + for k in ("equity", "available", "amount", "total"): + val = u.get(k) + if val is not None: + try: + return float(val) + except Exception: + pass + return None + + +def _parse_gate_spot_accounts_response_usdt(response): + """解析 GET /spot/accounts 列表中的 USDT(与 fetch_balance spot 同源,ccxt 解析失败时可兜底)。""" + rows = None + if isinstance(response, list): + rows = response + elif isinstance(response, dict): + inner = response.get("result") + if isinstance(inner, list): + rows = inner + elif isinstance(inner, dict) and isinstance(inner.get("list"), list): + rows = inner["list"] + if not rows: + return None + for row in rows: + if not isinstance(row, dict): + continue + if str(row.get("currency") or "").upper() != "USDT": + continue + ts = row.get("total") + if ts is not None and str(ts).strip() != "": + try: + return float(ts) + except Exception: + pass + try: + return float(row.get("available") or 0) + float(row.get("locked") or 0) + except Exception: + pass + return None + + +def _fetch_usdt_by_types(type_candidates): + """统一只用 ccxt.fetch_balance;spot 必须带 marginMode=spot,否则会随 defaultMarginMode 误走 cross_margin。""" + for t in type_candidates: + try: + params = {"type": t} + if t == "spot": + params["marginMode"] = "spot" + bal = exchange.fetch_balance(params=params) + val = _extract_usdt_total(bal) + if val is not None: + return val + except Exception: + continue + return None + + +def _fetch_gate_funding_usdt(): + """ + Gate「资金账户」: + 1) fetch_balance(type=spot, marginMode=spot) — 避免 defaultMarginMode=cross 误走 cross_margin; + 2) privateSpotGetAccounts — 与 1 同源,ccxt 聚合异常或解析不到 USDT 时再试原始列表; + 3) privateUnifiedGetAccounts + 自解析 — 统一账户 balances 常为数组,ccxt unified fetch_balance 会崩。 + """ + spot_seen_ok = False + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "spot", "marginMode": "spot"}) + spot_seen_ok = True + val = _extract_usdt_total(bal) + if val is not None: + return float(val) + except Exception: + pass + + try: + resp = exchange.privateSpotGetAccounts({}) + v = _parse_gate_spot_accounts_response_usdt(resp) + if v is not None: + return float(v) + except Exception: + pass + + try: + raw = exchange.privateUnifiedGetAccounts({}) + body = raw + if isinstance(body, dict) and isinstance(body.get("result"), dict): + body = body["result"] + v = _parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None + if v is not None: + return float(v) + except Exception: + pass + + if spot_seen_ok: + return 0.0 + return None + + +def get_available_trading_usdt(): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None + for t in ["swap", "spot"]: + try: + params = {"type": t} + if t == "spot": + params["marginMode"] = "spot" + bal = exchange.fetch_balance(params=params) + free_val = _extract_usdt_free(bal) + if free_val is not None: + return free_val + except Exception: + continue + return None + + +def get_synced_leverage(exchange_symbol, direction): + ensure_markets_loaded() + try: + positions = exchange.fetch_positions([exchange_symbol]) + for p in positions: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + if GATE_POS_MODE == "hedge" and side and side != direction: + continue + lev = p.get("leverage") + if lev is None or lev == 0 or str(lev) == "0": + lev = info.get("cross_leverage_limit") or info.get("leverage") + if lev: + try: + return int(float(lev)) + except Exception: + pass + except Exception: + pass + return None + + +def friendly_exchange_error(err, available_usdt=None): + msg = str(err) + low = msg.lower() + if ( + "51008" in msg + or "insufficient" in low + or "margin" in low and ("not enough" in low or "不足" in msg) + or "balance" in low and "insufficient" in low + ): + tail = f"(当前交易账户可用约 {round(available_usdt, 4)}U)" if available_usdt is not None else "" + return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。" + clean = re.sub(r"\s+", " ", msg).strip() + return f"交易所下单失败:{clean}" + + +def get_exchange_capitals(force=False): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None, None + now_ts = time.time() + if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + try: + ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt() + except Exception: + ACCOUNT_BALANCE_CACHE["funding_usdt"] = None + try: + ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"]) + except Exception: + # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 + ACCOUNT_BALANCE_CACHE["trading_usdt"] = None + ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + + +def execute_transfer_usdt(amount, from_account, to_account): + if amount <= 0: + return False, "划转金额必须大于0", None + ok_live, reason = ensure_exchange_live_ready() + if not ok_live: + return False, reason, None + try: + resp = exchange.transfer(TRANSFER_CCY, float(amount), from_account, to_account) + return True, "划转成功", resp + except Exception as e: + msg = str(e) + if "INVALID_KEY" in msg or "Invalid key" in msg: + msg += ( + "。常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" + "③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);" + "④ Key 已重置或权限变更。你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单。" + ) + return False, msg, None + + +def get_account_usdt_total(account_type): + """读取各账户 USDT。funding 走 _fetch_gate_funding_usdt;spot 同样 marginMode=spot,一律 ccxt。""" + raw = (account_type or "").strip().lower() + if raw == "funding": + return _fetch_gate_funding_usdt() + at = raw + try: + params = {"type": at} + if at == "spot": + params["marginMode"] = "spot" + bal = exchange.fetch_balance(params=params) + val = _extract_usdt_total(bal) + if val is not None: + return val + return 0.0 if at == "spot" else None + except Exception: + return None + + +def auto_transfer_once_per_day(): + if not AUTO_TRANSFER_ENABLED: + return + utc_dt = utc_now_dt() + bj = utc_dt.astimezone(APP_TZ) + if bj.hour != AUTO_TRANSFER_BJ_HOUR: + return + transfer_day = utc_calendar_date_str() + conn = get_db() + exists = conn.execute( + "SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?", + ("auto_daily", transfer_day) + ).fetchone() + if exists: + conn.close() + return + target_amount = AUTO_TRANSFER_AMOUNT + to_balance = get_account_usdt_total(AUTO_TRANSFER_TO) + from_balance = get_account_usdt_total(AUTO_TRANSFER_FROM) + if to_balance is None: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"读取{AUTO_TRANSFER_TO}账户USDT失败") + ) + conn.commit() + conn.close() + return + needed = round(max(target_amount - float(to_balance), 0), 4) + if needed <= 0: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "skipped", f"{AUTO_TRANSFER_TO}账户已达到目标{target_amount}U") + ) + conn.commit() + conn.close() + return + if from_balance is not None and from_balance < needed: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"{AUTO_TRANSFER_FROM}账户USDT不足,需{needed}U,当前{round(from_balance,4)}U") + ) + conn.commit() + conn.close() + send_wechat_msg( + f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{needed}U,当前{round(from_balance,4)}U\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + return + + ok, msg, _ = execute_transfer_usdt(needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO) + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + send_wechat_msg( + f"自动划转成功:补足到{target_amount}U,实际划转{needed}U " + f"{AUTO_TRANSFER_FROM}->{AUTO_TRANSFER_TO}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + else: + send_wechat_msg( + f"自动划转失败:计划补足到{target_amount}U,需划转{needed}U\n原因:{msg}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + + +def trading_day_reset_allows_new_open(now): + """是否允许在满足其它风控的前提下于当前时刻新开仓(仅「整点前禁开」守卫)。""" + if not TRADING_DAY_RESET_OPEN_GUARD_ENABLED: + return True + return now.hour >= TRADING_DAY_RESET_HOUR + + +def precheck_risk(conn, symbol, direction): + now = app_now() + if not trading_day_reset_allows_new_open(now): + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0] + if active_count > 0: + return False, "一次只能持有一个仓位" + trend_n = conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + if trend_n > 0: + return False, "已存在运行中的趋势回调计划,请先结束该计划" + if direction not in ("long", "short"): + return False, "方向必须为 long 或 short" + if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): + expected = BTC_LEVERAGE + else: + expected = ALT_LEVERAGE + if expected <= 0: + return False, "杠杆配置异常" + return True, "" + + +def precheck_trend_pullback_start(conn): + """趋势回调启动前:不与机器人单仓监控并存。""" + now = app_now() + if not trading_day_reset_allows_new_open(now): + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0] + if active_count > 0: + return False, "请先结束「机器人下单监控」中的持仓,再启动趋势回调" + trend_n = conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + if trend_n > 0: + return False, "已存在运行中的趋势回调计划" + return True, "" + + +def _trend_cleanup_stale_previews(conn): + ms = int(time.time() * 1000) + conn.execute("DELETE FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,)) + + +def parse_and_compute_trend_pullback_plan(form_dict): + """ + 解析表单并计算趋势回调预览参数(不写库、不下单)。 + 成功返回 (payload, None);失败返回 (None, 错误文案)。 + """ + d = form_dict or {} + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + return None, "symbol 不能为空" + direction = (d.get("direction") or "long").strip().lower() + if direction not in ("long", "short"): + return None, "方向错误" + try: + stop_loss = float(d.get("sl")) + add_upper = float(d.get("add_upper")) + take_profit = float(d.get("take_profit")) + risk_percent = float(d.get("risk_percent") or "5") + except Exception: + return None, "价格或风险比例格式错误" + try: + lev_raw = parse_positive_float(d.get("leverage")) + leverage = int(lev_raw) if lev_raw is not None else infer_leverage(symbol) + except Exception: + return None, "杠杆格式错误" + if leverage <= 0 or risk_percent <= 0: + return None, "杠杆与风险比例必须大于0" + if direction == "long": + if not (stop_loss < add_upper): + return None, "做多:止损价须低于补仓上沿" + else: + if not (stop_loss > add_upper): + return None, "做空:止损价须高于补仓上沿" + snap = get_available_trading_usdt() + if snap is None or snap <= 0: + return None, "无法读取合约账户 USDT 可用余额,请检查 API 与账户类型" + live_price = get_price(symbol) + if live_price is None: + return None, "获取实时价格失败" + exchange_symbol = normalize_exchange_symbol(symbol) + rf = calc_risk_fraction(direction, add_upper, stop_loss) + if rf is None or rf <= 0: + return None, "止损与补仓上沿组合无法计算风险比例" + risk_budget = float(snap) * (risk_percent / 100.0) + notional = risk_budget / rf + margin_plan = notional / float(leverage) + margin_plan = min(margin_plan, float(snap) * FULL_MARGIN_BUFFER_RATIO) + if margin_plan <= 0: + return None, "计划保证金过小" + try: + target_amt, _ = prepare_order_amount(exchange_symbol, margin_plan, leverage, live_price) + except Exception as e: + return None, str(e) + first_amt = _safe_amount_to_precision(exchange_symbol, target_amt * 0.5) + if first_amt is None or first_amt <= 0: + return None, "首仓张数过小(低于交易所最小张数),请提高风险比例或杠杆" + remainder_total = _safe_amount_to_precision( + exchange_symbol, max(0.0, float(target_amt) - float(first_amt)) + ) + if remainder_total is None: + remainder_total = 0.0 + n_legs, leg_json, per_ref = _trend_build_leg_amounts_json(exchange_symbol, remainder_total, TREND_PULLBACK_DCA_LEGS) + if n_legs <= 0: + return None, "剩余计划张数不足以拆出补仓档(低于交易所最小张数),请提高风险比例、放宽止损与补仓上沿间距,或减少补仓档数" + grid = _trend_build_grid_prices(direction, stop_loss, add_upper, n_legs) + if len(grid) != n_legs: + return None, "补仓网格生成失败" + opened_at = app_now_str() + try: + leg_list = json.loads(leg_json) + except Exception: + leg_list = [] + payload = { + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "stop_loss": stop_loss, + "add_upper": add_upper, + "take_profit": take_profit, + "risk_percent": risk_percent, + "snapshot_available_usdt": float(snap), + "snapshot_at": opened_at, + "live_price_ref": float(live_price), + "plan_margin_capital": float(margin_plan), + "target_order_amount": float(target_amt), + "first_order_amount": float(first_amt), + "remainder_total": float(remainder_total), + "dca_legs": int(n_legs), + "per_leg_amount": float(per_ref), + "grid_prices_json": json.dumps(grid), + "leg_amounts_json": leg_json, + "grid": grid, + "leg_amounts": leg_list, + } + return payload, None + + +def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): + ensure_markets_loaded() + notional = float(margin_capital) * float(leverage) + ticker = exchange.fetch_ticker(exchange_symbol) + price = float(ticker.get("last") or fallback_price) + if price <= 0: + raise ValueError("触发价必须大于 0") + market = exchange.market(exchange_symbol) + contract_size = float(market.get("contractSize") or 1) + if market.get("contract"): + # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 + amount = notional / (price * contract_size) + else: + amount = notional / price + min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") + if min_amount and amount < float(min_amount): + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") + amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) + if amount_precise <= 0: + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + return amount_precise, price + + +def _to_positive_float(value): + try: + n = float(value) + return n if n > 0 else None + except Exception: + return None + + +def _extract_order_price_value(order_obj): + if not isinstance(order_obj, dict): + return None + for key in ("average", "price"): + v = _to_positive_float(order_obj.get(key)) + if v is not None: + return v + cost = _to_positive_float(order_obj.get("cost")) + filled = _to_positive_float(order_obj.get("filled")) + if cost is not None and filled is not None and filled > 0: + return cost / filled + info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} + for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): + v = _to_positive_float(info.get(key)) + if v is not None: + return v + return None + + +def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): + price = _extract_order_price_value(order_resp) + if price is not None: + return round(price, 8) + order_id = (order_resp or {}).get("id") + if order_id: + try: + fetched = exchange.fetch_order(order_id, exchange_symbol) + fetched_price = _extract_order_price_value(fetched) + if fetched_price is not None: + return round(fetched_price, 8) + except Exception: + pass + fallback = _to_positive_float(fallback_price) + return round(fallback, 8) if fallback is not None else 0.0 + + +def get_contract_size(exchange_symbol): + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + return float(market.get("contractSize") or 1) + + +def parse_positive_float(value): + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + num = float(raw) + if num <= 0: + raise ValueError("数值必须大于0") + return num + + +def build_gate_order_params(direction, reduce_only=False): + params = {} + if reduce_only: + params["reduceOnly"] = True + return params + + +def _gate_contracts_amount_for_tpsl(order, fallback_amount): + for key in ("filled", "amount"): + v = order.get(key) + try: + fv = float(v) + if fv > 0: + return fv + except Exception: + pass + return float(fallback_amount) + + +def _gate_place_tp_sl_orders_legacy_conditional(exchange_symbol, direction, contracts_amount, stop_loss, take_profit): + """ccxt 市价减仓条件单(两张单分别带 stopLossPrice / takeProfitPrice),与官方仓位类触发单等价逻辑不同路径。""" + ensure_markets_loaded() + close_side = "sell" if direction == "long" else "buy" + base = {"reduceOnly": True} + last_err = None + for attempt in range(8): + try: + exchange.create_order( + exchange_symbol, "market", close_side, contracts_amount, None, + dict(base, stopLossPrice=float(stop_loss)), + ) + exchange.create_order( + exchange_symbol, "market", close_side, contracts_amount, None, + dict(base, takeProfitPrice=float(take_profit)), + ) + return + except Exception as e: + last_err = e + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"交易所未接受条件止盈/止损委托参数:{last_err}") + + +def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit): + """ + Gate 永续官方仓位类触发单:POST futures/{settle}/price_orders, + order_type=close-long-position / close-short-position,单向全平 close+size=0;双向需 auto_size。 + 与 App 内展示的「条件委托」一致,平仓后仍需 cancel_gate_swap_trigger_orders 避免残留。 + """ + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + if not market.get("swap"): + raise RuntimeError("仅支持永续合约 symbol") + settle = market["settleId"] + contract = market["id"] + order_type = "close-long-position" if direction == "long" else "close-short-position" + close_side = "sell" if direction == "long" else "buy" + if close_side == "sell": + sl_rule, tp_rule = 2, 1 + else: + sl_rule, tp_rule = 1, 2 + initial = { + "contract": contract, + "size": 0, + "price": "0", + "close": True, + "reduce_only": True, + "tif": "ioc", + "text": "api", + } + if GATE_POS_MODE == "hedge": + initial["auto_size"] = "close_long" if direction == "long" else "close_short" + # Gate API 1018:auto_size=close_long|close_short 时 initial.close 须为 false + initial["close"] = False + sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss)) + tp_s = exchange.price_to_precision(exchange_symbol, float(take_profit)) + + def _payload(trigger_price, rule): + trig = { + "strategy_type": 0, + "price_type": GATE_TPSL_PRICE_TYPE, + "price": trigger_price, + "rule": rule, + } + if GATE_TPSL_TRIGGER_EXPIRATION > 0: + trig["expiration"] = GATE_TPSL_TRIGGER_EXPIRATION + return { + "settle": settle, + "initial": dict(initial), + "trigger": trig, + "order_type": order_type, + } + + last_err = None + for attempt in range(8): + try: + exchange.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule)) + try: + exchange.privateFuturesPostSettlePriceOrders(_payload(tp_s, tp_rule)) + except Exception: + cancel_gate_swap_trigger_orders(exchange_symbol) + raise + return + except Exception as e: + last_err = e + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"交易所未接受仓位类条件止盈/止损:{last_err}") + + +def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss): + """Gate 永续:仅挂仓位类止损触发单(全平),止盈由程序监控市价平仓。""" + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + if not market.get("swap"): + raise RuntimeError("仅支持永续合约 symbol") + settle = market["settleId"] + contract = market["id"] + order_type = "close-long-position" if direction == "long" else "close-short-position" + close_side = "sell" if direction == "long" else "buy" + sl_rule = 2 if close_side == "sell" else 1 + initial = { + "contract": contract, + "size": 0, + "price": "0", + "close": True, + "reduce_only": True, + "tif": "ioc", + "text": "api", + } + if GATE_POS_MODE == "hedge": + initial["auto_size"] = "close_long" if direction == "long" else "close_short" + initial["close"] = False # 与 _gate_place_tp_sl_orders_position_price_orders 相同,Gate 要求 + sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss)) + + def _payload(trigger_price, rule): + trig = { + "strategy_type": 0, + "price_type": GATE_TPSL_PRICE_TYPE, + "price": trigger_price, + "rule": rule, + } + if GATE_TPSL_TRIGGER_EXPIRATION > 0: + trig["expiration"] = GATE_TPSL_TRIGGER_EXPIRATION + return { + "settle": settle, + "initial": dict(initial), + "trigger": trig, + "order_type": order_type, + } + + last_err = None + for attempt in range(8): + try: + exchange.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule)) + return + except Exception as e: + last_err = e + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"交易所未接受仅止损仓位触发单:{last_err}") + + +def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_loss, take_profit): + if GATE_TPSL_USE_POSITION_ORDER: + try: + _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit) + return + except Exception: + pass + _gate_place_tp_sl_orders_legacy_conditional( + exchange_symbol, direction, contracts_amount, stop_loss, take_profit, + ) + + +def ensure_markets_loaded(force=False): + global MARKETS_LOADED + if force or not MARKETS_LOADED: + exchange.load_markets(reload=force) + MARKETS_LOADED = True + + +def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): + ensure_markets_loaded() + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + params = build_gate_order_params(direction, reduce_only=False) + order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + order.setdefault("tpsl_attached", False) + if stop_loss and take_profit: + try: + contracts_amt = _gate_contracts_amount_for_tpsl(order, amount) + _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amt, stop_loss, take_profit) + order["tpsl_attached"] = True + except RuntimeError: + raise + except Exception as e: + raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e + return order + + +def close_exchange_order(order_row): + ensure_markets_loaded() + exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"]) + amount = float(order_row["order_amount"] or 0) + if amount <= 0: + raise ValueError("平仓失败:缺少有效下单数量") + direction = order_row["direction"] + side = "sell" if direction == "long" else "buy" + params = build_gate_order_params(direction, reduce_only=True) + return exchange.create_order(exchange_symbol, "market", side, amount, None, params) + + +def _gate_swap_trigger_order_params(): + """永续条件单(止盈/止损触发委托)查询/撤销用的 ccxt 参数。""" + p = {"type": "swap", "trigger": True} + try: + exchange.load_unified_status() + if exchange.options.get("unifiedAccount"): + p["unifiedAccount"] = True + except Exception: + pass + return p + + +def cancel_gate_swap_trigger_orders(exchange_symbol): + """ + 仓位已平时撤销该合约下剩余的永续条件委托(trigger / price_orders),避免孤儿单残留。 + 与 App 内「仓位附带止盈止损」不同,本系统挂的是独立触发单,平仓后交易所未必自动撤。 + """ + ok, _ = ensure_exchange_live_ready() + if not ok or not exchange_symbol: + return + ensure_markets_loaded() + params = _gate_swap_trigger_order_params() + sym = exchange_symbol + try: + exchange.cancel_all_orders(sym, params) + return + except Exception: + pass + try: + pending = exchange.fetch_open_orders(sym, params=params) + except Exception: + return + for o in pending or []: + oid = o.get("id") + if oid is None: + continue + try: + exchange.cancel_order(str(oid), sym, params) + except Exception: + pass + + +def cancel_all_open_orders_for_symbol(exchange_symbol): + """策略结束时:尽量撤掉该合约下条件单与普通挂单。""" + cancel_gate_swap_trigger_orders(exchange_symbol) + if not exchange_symbol: + return + ensure_markets_loaded() + plain_params = {"type": "swap"} + try: + exchange.load_unified_status() + if exchange.options.get("unifiedAccount"): + plain_params["unifiedAccount"] = True + except Exception: + pass + try: + exchange.cancel_all_orders(exchange_symbol, plain_params) + except Exception: + pass + try: + pending = exchange.fetch_open_orders(exchange_symbol, params=plain_params) + except Exception: + return + for o in pending or []: + oid = o.get("id") + if oid is None: + continue + try: + exchange.cancel_order(str(oid), exchange_symbol, plain_params) + except Exception: + pass + + +def extract_trade_price_from_order(order): + if not order: + return None + for k in ("average", "avgPrice", "price"): + try: + v = float(order.get(k) or 0) + if v > 0: + return v + except Exception: + pass + try: + info = order.get("info") or {} + if isinstance(info, dict): + for k in ("fillPx", "avgPx", "fill_price"): + v = float(info.get(k) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def is_no_position_error(err_msg): + msg = (err_msg or "").lower() + keywords = [ + "no position", "position does not exist", "position not exist", + "pos size is 0", "nothing to close", "reduceonly", "51008" + ] + return any(k in msg for k in keywords) + + +def get_live_position_contracts(exchange_symbol, direction): + ensure_markets_loaded() + try: + rows = exchange.fetch_positions([exchange_symbol]) + except Exception: + return None + total = 0.0 + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + contracts = p.get("contracts") + if contracts is None: + raw_pos = info.get("pos") or info.get("size") + try: + contracts = abs(float(raw_pos)) if raw_pos is not None else 0.0 + except Exception: + contracts = 0.0 + try: + contracts = float(contracts) + except Exception: + contracts = 0.0 + if contracts <= 0: + continue + if GATE_POS_MODE == "hedge": + if side and side != direction: + continue + total += contracts + return total + + +def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): + """在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。""" + if not rows: + return None + candidates = [] + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + if (not relax_hedge) and GATE_POS_MODE == "hedge": + if side and side != (direction or "").lower(): + continue + candidates.append((contracts, p)) + if not candidates and (not relax_hedge) and GATE_POS_MODE == "hedge": + return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) + if not candidates: + return None + candidates.sort(key=lambda x: x[0], reverse=True) + return candidates[0][1] + + +def _coerce_float(*values): + for v in values: + if v is None or v == "": + continue + try: + return float(v) + except (TypeError, ValueError): + continue + return None + + +def parse_ccxt_position_metrics(position, order_leverage=None): + """ + 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底)。 + 与 App「仓位保证金」对齐时优先用 initialMargin;缺失时再尝试 info 内字段。 + """ + if not position: + return None + p = position + info = p.get("info", {}) or {} + # Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致 + initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin")) + if initial is None or initial <= 0: + initial = _coerce_float( + info.get("margin"), + info.get("cross_margin"), + info.get("iso_margin"), + info.get("initial_margin"), + info.get("position_margin"), + info.get("initialMargin"), + ) + notional = _coerce_float(p.get("notional"), p.get("notionalValue")) + if notional is None or notional <= 0: + notional = _coerce_float(info.get("value")) + if notional is not None: + notional = abs(notional) + # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) + if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: + try: + lev = float(order_leverage) + if lev > 0: + approx = notional / lev + if approx > 0: + initial = approx + except (TypeError, ValueError): + pass + unrealized = _coerce_float( + p.get("unrealizedPnl"), + info.get("unrealised_pnl"), + info.get("unrealized_pnl"), + ) + mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) + out = {} + if initial is not None and initial > 0: + out["initial_margin"] = round(initial, 4) + if notional is not None and notional > 0: + out["notional"] = round(notional, 4) + if unrealized is not None: + out["unrealized_pnl"] = round(unrealized, 6) + if mark is not None and mark > 0: + out["mark_price"] = round(mark, 8) + return out or None + + +def get_live_position_exchange_metrics(exchange_symbol, direction): + ensure_markets_loaded() + if not exchange_private_api_configured() or not exchange_symbol: + return None + try: + rows = exchange.fetch_positions(None, {"settle": "usdt"}) or [] + except Exception: + try: + rows = exchange.fetch_positions([exchange_symbol]) or [] + except Exception: + return None + p = _select_live_position_row(rows, exchange_symbol, direction) + return parse_ccxt_position_metrics(p) + + +def opened_at_str_to_ms(opened_at_str): + if not opened_at_str: + return None + try: + dt = datetime.strptime(str(opened_at_str).strip()[:19], "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + try: + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + return None + + +def _to_ms_with_fallback(ms_value, dt_str): + try: + if ms_value is not None and str(ms_value).strip() != "": + v = int(float(ms_value)) + if v > 0: + return v + except Exception: + pass + return opened_at_str_to_ms(dt_str) + + +def ms_to_app_local_str(ms): + if ms is None: + return app_now_str() + try: + dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) + return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return app_now_str() + + +def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): + """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" + try: + tp = float(take_profit) + sl = float(stop_loss) + ex = float(exit_price) + trig = float(trigger_price) + except (TypeError, ValueError): + return None + band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12) + if direction == "long": + if ex >= tp - band: + return "止盈" + if ex <= sl + band: + return "止损" + else: + if ex <= tp + band: + return "止盈" + if ex >= sl - band: + return "止损" + return None + + +def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" + if not (GATE_API_KEY and GATE_API_SECRET): + return None + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + + def pick_from_trades(trades): + if not trades: + return None + candidates = [] + for t in trades: + if (t.get("side") or "").lower() != close_side: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if GATE_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + ts = t.get("timestamp") + if ts is None: + continue + candidates.append(t) + if not candidates: + return None + return max(candidates, key=lambda x: x.get("timestamp") or 0) + + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) + hit = pick_from_trades(trades) + if hit is None and since_ms: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100) + hit = pick_from_trades(trades) + return hit + except Exception: + return None + + +def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): + """ + 拉取某条历史记录对应的减仓成交(用于按 id 回填)。 + 返回按时间排序的成交列表。 + """ + if not (GATE_API_KEY and GATE_API_SECRET): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + # 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况 + if closed_ms is not None: + closed_ms += 6 * 60 * 60 * 1000 + candidates = [] + all_side_candidates = [] + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + if not trades and since_ms: + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200) + except Exception: + trades = [] + for t in trades or []: + if (t.get("side") or "").lower() != close_side: + continue + ts = t.get("timestamp") + if ts is None: + continue + try: + ts = int(ts) + except Exception: + continue + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if GATE_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + all_side_candidates.append(t) + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + candidates.append(t) + candidates.sort(key=lambda x: x.get("timestamp") or 0) + if candidates: + return candidates + + # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。 + all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) + if not all_side_candidates: + return [] + if not closed_ms: + return all_side_candidates[-20:] + near = [] + for t in all_side_candidates: + ts = t.get("timestamp") + if ts is None: + continue + try: + delta = abs(int(ts) - int(closed_ms)) + except Exception: + continue + # 放宽到前后 7 天 + if delta <= 7 * 24 * 60 * 60 * 1000: + near.append((delta, t)) + if near: + near.sort(key=lambda x: x[0]) + picked = [x[1] for x in near[:20]] + picked.sort(key=lambda x: x.get("timestamp") or 0) + return picked + return all_side_candidates[-20:] + + +def calc_weighted_exit_price(trades): + if not trades: + return None + total_amount = 0.0 + weighted_sum = 0.0 + for t in trades: + try: + price = float(t.get("price") or 0) + amount = float(t.get("amount") or 0) + except Exception: + continue + if price <= 0: + continue + if amount <= 0: + amount = 1.0 + weighted_sum += price * amount + total_amount += amount + if total_amount <= 0: + return None + return weighted_sum / total_amount + + +def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): + """ + 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 + 返回 (result, pnl_amount, closed_at_str, miss_reason)。 + """ + direction = row["direction"] + sym = row["symbol"] + trigger_price = row["trigger_price"] + stop_loss = row["stop_loss"] + take_profit = row["take_profit"] + margin_capital = row["margin_capital"] or DAILY_START_CAPITAL + leverage = row["leverage"] or infer_leverage(sym) + exchange_symbol = row["exchange_symbol"] or normalize_exchange_symbol(sym) + + trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) + exit_px = None + closed_at_str = app_now_str() + if trade: + try: + exit_px = float(trade.get("price") or 0) or None + except (TypeError, ValueError): + exit_px = None + ts = trade.get("timestamp") + if ts: + closed_at_str = ms_to_app_local_str(int(ts)) + + if exit_px is None or exit_px <= 0: + p = get_price(sym) + if p: + guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) + if guessed: + pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + return ( + guessed, + pnl, + closed_at_str, + "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", + ) + return ( + "外部平仓", + 0.0, + closed_at_str, + "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", + ) + + result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) + pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) + if result: + return ( + result, + pnl, + closed_at_str, + "按交易所成交记录同步为止盈/止损平仓", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def reconcile_external_closes(conn, days=None): + synced_count = 0 + cutoff_ms = None + if days is not None: + try: + d = int(days) + if d > 0: + cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) + except Exception: + cutoff_ms = None + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + if cutoff_ms is not None: + opened_at_v = get_opened_at_value(r) + opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 + if opened_ms is None or opened_ms < cutoff_ms: + continue + exchange_symbol = r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + continue + if live_contracts > 0: + continue + cancel_gate_swap_trigger_orders(exchange_symbol) + opened_at = get_opened_at_value(r) + opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type="下单监控", + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=r["margin_capital"], + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=miss_reason, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result=f"{result}(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + else: + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result="外部平仓(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + synced_count += 1 + return synced_count + +# 获取实时价格 +def get_price(symbol): + try: + ensure_markets_loaded() + return exchange.fetch_ticker(normalize_exchange_symbol(symbol))["last"] + except: + return None + +# 获取5分钟K线收盘价 +def get_5m_close(symbol): + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), KLINE_TIMEFRAME, limit=1) + return ohlcv[-1][4] if ohlcv else None + except: + return None + + +def _safe_float(v): + try: + return float(v) + except Exception: + return None + + +def _compute_ema(values, period=55): + arr = [float(x) for x in values if x is not None] + if len(arr) < period: + return None + k = 2.0 / (period + 1.0) + ema = arr[0] + for val in arr[1:]: + ema = val * k + ema * (1 - k) + return ema + + +def _status_by_ema55(symbol, timeframe): + try: + bars = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), timeframe=timeframe, limit=80) + if not bars or len(bars) < 56: + return "横盘", None, None + closes = [float(x[4]) for x in bars if x and len(x) >= 5] + ema55 = _compute_ema(closes, 55) + last_close = closes[-1] + if ema55 is None or last_close <= 0: + return "横盘", last_close, ema55 + diff_pct = (last_close - ema55) / ema55 * 100.0 + if abs(diff_pct) < 0.1: + return "横盘", last_close, ema55 + return ("多头" if diff_pct > 0 else "空头"), last_close, ema55 + except Exception: + return "横盘", None, None + + +def _daily_volume_rank(symbol): + """ + 返回(symbol_rank, total_count),按 quoteVolume 降序,缺失时 fallback 到 baseVolume*last。 + """ + sym_norm = normalize_symbol_input(symbol) + target_base = journal_coin_from_symbol(sym_norm) + + def _ticker_base(sym_text): + s = str(sym_text or "").upper().strip() + if ":" in s: + s = s.split(":", 1)[0] + if "/" in s: + return s.split("/", 1)[0].strip() + if "-" in s: + return s.split("-", 1)[0].strip() + if s.endswith("USDT"): + return s[:-4].strip() + return s + now_ts = time.time() + cached_ok = ( + LIQUIDITY_RANK_CACHE["updated_at"] + and now_ts - float(LIQUIDITY_RANK_CACHE["updated_at"]) < max(30, BALANCE_REFRESH_SECONDS) + ) + if not cached_ok: + try: + ensure_markets_loaded() + tickers = exchange.fetch_tickers() + scored = [] + for s, t in (tickers or {}).items(): + try: + mk = exchange.markets.get(s) + if not mk or not mk.get("swap"): + continue + su = str(s).upper() + if "USDT" not in su: + continue + qv = _safe_float((t or {}).get("quoteVolume")) + if qv is None: + info = (t or {}).get("info") if isinstance((t or {}).get("info"), dict) else {} + qv = _safe_float(info.get("volCcy24h") or info.get("vol24h")) + if qv is None: + bv = _safe_float((t or {}).get("baseVolume")) + lp = _safe_float((t or {}).get("last")) + if bv is not None and lp is not None: + qv = bv * lp + if qv is None or qv <= 0: + continue + scored.append((_ticker_base(s), float(qv))) + except Exception: + continue + scored.sort(key=lambda x: x[1], reverse=True) + ranks = {} + for idx, (base, _) in enumerate(scored, 1): + if base and base not in ranks: + ranks[base] = idx + LIQUIDITY_RANK_CACHE["ranks"] = ranks + LIQUIDITY_RANK_CACHE["total"] = len(scored) + LIQUIDITY_RANK_CACHE["updated_at"] = now_ts + except Exception: + pass + ranks = LIQUIDITY_RANK_CACHE.get("ranks") or {} + total = int(LIQUIDITY_RANK_CACHE.get("total") or 0) + return ranks.get(target_base), total + + +def _key_hard_checks(symbol, direction, upper, lower, monitor_type): + """ + 关键位门控:量能、突破幅度、第二根确认、日成交量前30。 + 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根。 + """ + out = {"ok": False} + ex_sym = normalize_exchange_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=80) or [] + if len(bars) < 24: + out["reason"] = "5m K线数量不足" + return out + closed = bars[:-1] if len(bars) >= 3 else bars + if len(closed) < 23: + out["reason"] = "闭合K线不足" + return out + breakout = closed[-2] + confirm = closed[-1] + prev20 = closed[-22:-2] + avg20 = sum(float(x[5]) for x in prev20) / max(len(prev20), 1) + vol_break = float(breakout[5]) + vol_ok = vol_break > avg20 * 1.3 if avg20 > 0 else False + open_b = float(breakout[1]) + close_b = float(breakout[4]) + high_b = float(breakout[2]) + low_b = float(breakout[3]) + amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0 + amp_ok = (amp_pct > 0.03) and (amp_pct < 0.5) + cfm_close = float(confirm[4]) + # 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿 + edge = float(upper) if direction == "long" else float(lower) + breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) + confirm_ok_raw = (cfm_close > edge) if direction == "long" else (cfm_close < edge) + # 口径收紧:未发生有效突破时,不标记幅度/二确通过,避免出现“还没到位却显示Y” + amp_ok = amp_ok and breakout_ok + confirm_ok = confirm_ok_raw and breakout_ok + rank, total = _daily_volume_rank(symbol) + rank_ok = (rank is not None) and (rank <= 30) + swing4h_pct = 0.0 + try: + seg48 = closed[-48:] if len(closed) >= 48 else closed + hh = max(float(x[2]) for x in seg48) + ll = min(float(x[3]) for x in seg48) + swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0 + except Exception: + swing4h_pct = 0.0 + out.update( + { + "ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]), + "vol_ok": vol_ok, + "avg20": avg20, + "vol_break": vol_break, + "amp_ok": amp_ok, + "amp_pct": amp_pct, + "breakout_ok": breakout_ok, + "breakout_close": close_b, + "confirm_ok": confirm_ok, + "confirm_close": cfm_close, + "edge_price": edge, + "rank": rank, + "rank_total": total, + "rank_ok": rank_ok, + "breakout_high": high_b, + "breakout_low": low_b, + "breakout_ts": breakout[0], + "confirm_ts": confirm[0], + "swing4h_pct": swing4h_pct, + "monitor_type": monitor_type, + "direction": direction, + } + ) + return out + + +def calc_price_diff_pct(current_price, target_price): + try: + if target_price is None: + return None, None + t = float(target_price) + if t == 0: + return None, None + c = float(current_price) + diff = c - t + pct = diff / t * 100 + return round(diff, 6), round(pct, 4) + except Exception: + return None, None + + +def can_notify_key_monitor(row, now_dt): + max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES) + if int(row["notification_count"] or 0) >= max_notify: + return False + last_at = row["last_notified_at"] + if not last_at: + return True + try: + last_dt = datetime.strptime(last_at, "%Y-%m-%d %H:%M:%S") + except Exception: + return True + interval_min = int(row["notify_interval_min"] or KEY_ALERT_INTERVAL_MINUTES) + return (now_dt - last_dt).total_seconds() >= interval_min * 60 + + +def breakout_too_far(p, edge_price, limit_pct): + try: + if edge_price is None or float(edge_price) <= 0: + return False + diff_pct = abs(float(p) - float(edge_price)) / float(edge_price) * 100 + return diff_pct > float(limit_pct) + except Exception: + return False + + +def _trend_build_grid_prices(direction, sl, upper, n_legs): + """在 (止损, 补仓上沿) 开区间内生成 n_legs 个补仓触发价(不含端点)。""" + sl, upper = float(sl), float(upper) + out = [] + if n_legs <= 0: + return out + if direction == "long": + if upper <= sl: + return out + span = upper - sl + for i in range(1, n_legs + 1): + t = i / float(n_legs + 1) + out.append(sl + t * span) + out.sort(reverse=True) + else: + if sl <= upper: + return out + span = sl - upper + for i in range(1, n_legs + 1): + t = i / float(n_legs + 1) + out.append(upper + t * span) + out.sort() + return [round(p, 10) for p in out] + + +def _safe_amount_to_precision(exchange_symbol, raw_amount): + """amount_to_precision 在低于最小步长时会抛 InvalidOrder;返回 None 表示不可用。""" + try: + if raw_amount is None: + return None + x = float(raw_amount) + if x <= 0: + return None + return float(exchange.amount_to_precision(exchange_symbol, x)) + except Exception: + return None + + +def _trend_pick_dca_legs_and_per_leg(exchange_symbol, remainder_total, want_legs): + """按交易所最小张数约束,自动减少档位数。""" + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + min_amt = (market.get("limits", {}).get("amount", {}) or {}).get("min") + min_amt = float(min_amt) if min_amt is not None else 0.0 + legs = max(1, int(want_legs)) + rem = float(remainder_total) + while legs >= 1: + per = rem / legs + per_p = _safe_amount_to_precision(exchange_symbol, per) + if per_p is None or per_p <= 0: + legs -= 1 + continue + if min_amt and per_p + 1e-12 < min_amt: + legs -= 1 + continue + return legs, per_p + one = _safe_amount_to_precision(exchange_symbol, rem) + if one is None or one <= 0: + return 0, 0.0 + return 1, one + + +def _trend_build_leg_amounts_json(exchange_symbol, remainder_total, want_legs): + """将剩余计划张数拆成若干补仓市价单张数(JSON 列表),并返回有效档位数。""" + rem = _safe_amount_to_precision(exchange_symbol, float(remainder_total)) + if rem is None or rem <= 0: + return 0, "[]", 0.0 + n, _ = _trend_pick_dca_legs_and_per_leg(exchange_symbol, rem, want_legs) + if n <= 0: + return 0, "[]", 0.0 + if n <= 1: + one = _safe_amount_to_precision(exchange_symbol, rem) + if one is None or one <= 0: + return 0, "[]", 0.0 + return 1, json.dumps([one]), one + unit = _safe_amount_to_precision(exchange_symbol, rem / n) + if unit is None or unit <= 0: + one = _safe_amount_to_precision(exchange_symbol, rem) + if one is None or one <= 0: + return 0, "[]", 0.0 + return 1, json.dumps([one]), one + parts = [] + acc = 0.0 + for _ in range(n - 1): + parts.append(unit) + acc += unit + last = _safe_amount_to_precision(exchange_symbol, max(0.0, rem - acc)) + if last is None or last <= 0: + one = _safe_amount_to_precision(exchange_symbol, rem) + if one is None or one <= 0: + return 0, "[]", 0.0 + return 1, json.dumps([one]), one + parts.append(last) + return n, json.dumps(parts), unit + + +def _trend_market_add_contracts(exchange_symbol, direction, contracts, leverage): + exchange.set_leverage(int(leverage), exchange_symbol) + side = "buy" if direction == "long" else "sell" + params = build_gate_order_params(direction, reduce_only=False) + return exchange.create_order(exchange_symbol, "market", side, float(contracts), None, params) + + +def _trend_refresh_stop_only(exchange_symbol, direction, stop_loss): + cancel_gate_swap_trigger_orders(exchange_symbol) + _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss) + + +def _trend_weighted_avg(old_avg, old_amt, fill_px, add_amt): + try: + oa, aa = float(old_amt), float(add_amt) + if oa <= 0: + return float(fill_px) + return (float(old_avg) * oa + float(fill_px) * aa) / (oa + aa) + except Exception: + return float(fill_px or 0) + + +def _trend_finalize_plan(conn, row, result_label, exit_price, closed_at=None): + """平仓后记账、撤单、结束计划。""" + sym = row["symbol"] + direction = row["direction"] or "long" + ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(sym) + closed_at = closed_at or app_now_str() + opened_at = row["opened_at"] or app_now_str() + hold_seconds = calc_hold_seconds(opened_at, parse_dt_for_trading_day(closed_at) or app_now()) + margin_cap = float(row["plan_margin_capital"] or 0) + lev = int(row["leverage"] or 1) + avg_e = float(row["avg_entry_price"] or 0) + pnl_amount = calc_pnl(direction, avg_e, float(exit_price), margin_cap, lev) + res = normalize_result_with_pnl(result_label, pnl_amount) + risk_amt = calc_risk_amount_from_plan(direction, float(row["add_upper"]), float(row["stop_loss"]), margin_cap, lev) + planned_rr = calc_rr_ratio(direction, avg_e, float(row["stop_loss"]), float(row["take_profit"])) + try: + cancel_all_open_orders_for_symbol(ex_sym) + except Exception: + try: + cancel_gate_swap_trigger_orders(ex_sym) + except Exception: + pass + session_date = row["session_date"] or get_trading_day() + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{res}({MONITOR_TYPE_TREND})", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=avg_e, + current_price=float(exit_price), + stop_loss=float(row["stop_loss"]), + take_profit=float(row["take_profit"]), + close_order_id="-", + extra_note="计划本金口径:启动时合约可用余额快照;止盈由程序监控", + session_capital_fallback=session_capital, + ) + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=MONITOR_TYPE_TREND, + direction=direction, + trigger_price=avg_e, + stop_loss=float(row["stop_loss"]), + initial_stop_loss=float(row["stop_loss"]), + take_profit=float(row["take_profit"]), + margin_capital=margin_cap, + leverage=lev, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style="trend_pullback", + risk_amount=risk_amt, + planned_rr=planned_rr, + actual_rr=calc_actual_rr(pnl_amount, risk_amt), + result=res, + opened_at=opened_at, + closed_at=closed_at, + ) + st = "stopped_tp" if result_label == "止盈" else ("stopped_sl" if result_label == "止损" else "stopped_manual") + conn.execute( + "UPDATE trend_pullback_plans SET status=?, message=? WHERE id=?", + (st, res, row["id"]), + ) + + +def check_trend_pullback_plans(): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return + conn = get_db() + rows = conn.execute("SELECT * FROM trend_pullback_plans WHERE status='active'").fetchall() + for row in rows: + try: + sym = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(sym) + sl = float(row["stop_loss"]) + upper = float(row["add_upper"]) + tp = float(row["take_profit"]) + lev = int(row["leverage"] or 1) + p = get_price(sym) + if not p: + continue + pf = float(p) + last_p = row["last_mark_price"] + last_pf = float(last_p) if last_p is not None else pf + + pos = get_live_position_contracts(ex_sym, direction) + if pos is None: + continue + + legs_done = int(row["legs_done"] or 0) + dca_legs = int(row["dca_legs"] or 0) + leg_amounts = [] + try: + leg_amounts = [float(x) for x in json.loads(row["leg_amounts_json"] or "[]")] + except Exception: + leg_amounts = [] + grid = [] + try: + grid = json.loads(row["grid_prices_json"] or "[]") + except Exception: + grid = [] + + hit_tp = (direction == "long" and pf >= tp) or (direction == "short" and pf <= tp) + if hit_tp and pos > 0: + try: + exchange.set_leverage(lev, ex_sym) + side = "sell" if direction == "long" else "buy" + params = build_gate_order_params(direction, reduce_only=True) + close_resp = exchange.create_order(ex_sym, "market", side, float(pos), None, params) + exit_p = extract_trade_price_from_order(close_resp) or pf + except Exception as e: + if not is_no_position_error(str(e)): + continue + exit_p = pf + _trend_finalize_plan(conn, row, "止盈", exit_p) + continue + + if pos <= 0 and int(row["first_order_done"] or 0): + exit_p = pf + _trend_finalize_plan(conn, row, "止损", exit_p) + continue + + if int(row["first_order_done"] or 0) and legs_done < len(grid) and legs_done < len(leg_amounts): + level = float(grid[legs_done]) + fired = False + if direction == "long": + if last_pf > level and pf <= level: + fired = True + else: + if last_pf < level and pf >= level: + fired = True + if fired: + amt = float(exchange.amount_to_precision(ex_sym, leg_amounts[legs_done])) + if amt > 0: + add_resp = _trend_market_add_contracts(ex_sym, direction, amt, lev) + fill_px = extract_trade_price_from_order(add_resp) or pf + old_avg = float(row["avg_entry_price"] or fill_px) + old_open = float(row["order_amount_open"] or 0) + new_open = old_open + amt + new_avg = _trend_weighted_avg(old_avg, old_open, fill_px, amt) + conn.execute( + "UPDATE trend_pullback_plans SET legs_done=?, avg_entry_price=?, order_amount_open=?, last_mark_price=? WHERE id=?", + (legs_done + 1, new_avg, new_open, pf, row["id"]), + ) + row = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=?", (row["id"],)).fetchone() + try: + _trend_refresh_stop_only(ex_sym, direction, sl) + except Exception: + pass + + conn.execute( + "UPDATE trend_pullback_plans SET last_mark_price=? WHERE id=?", + (pf, row["id"]), + ) + except Exception: + continue + conn.commit() + conn.close() + + +# 关键位监控(前端已下线时仍保留函数体,后台默认不再调用) +def check_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + sym, typ, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + direction = (r["direction"] or "long").lower() + now_dt = app_now() + if not can_notify_key_monitor(r, now_dt): + continue + try: + checks = _key_hard_checks(sym, direction, up, low, typ) + except Exception: + checks = {"ok": False} + if not checks.get("ok"): + continue + btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h") + coin4h_status, _, _ = _status_by_ema55(sym, "4h") + risk_tip = None + if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"): + risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。" + box_h = abs(float(up) - float(low)) if up is not None and low is not None else 0.0 + c_close = float(checks.get("confirm_close") or 0) + b_high = float(checks.get("breakout_high") or 0) + b_low = float(checks.get("breakout_low") or 0) + key_price = float(low) if direction == "long" else float(up) + if direction == "long": + tp1 = c_close + box_h + tp2 = c_close + box_h * 1.5 + sl1 = b_low * (1 - 0.002) if b_low > 0 else None + sl2 = key_price * (1 - 0.002) if key_price > 0 else None + else: + tp1 = c_close - box_h + tp2 = c_close - box_h * 1.5 + sl1 = b_high * (1 + 0.002) if b_high > 0 else None + sl2 = key_price * (1 + 0.002) if key_price > 0 else None + hard_lines = [ + f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", + f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", + f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)", + f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})", + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)", + ] + op_lines = [ + f"方案A:止盈=箱体1.0倍({round(tp1, 8) if tp1 else '-' }),止损=突破K极值外0.2%({round(sl1, 8) if sl1 else '-' })", + f"方案B:止盈=箱体1.5倍({round(tp2, 8) if tp2 else '-' }),止损=箱体关键位外0.2%({round(sl2, 8) if sl2 else '-' })", + ] + trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() + msg = build_wechat_key_monitor_message( + symbol=sym, + direction=direction, + monitor_type=typ, + trigger_time=trigger_time, + key_price=key_price, + confirm_close=checks["confirm_close"], + hard_lines=hard_lines, + btc8h_status=btc8h_status, + coin4h_status=coin4h_status, + swing4h_pct=checks.get("swing4h_pct") or 0.0, + op_lines=op_lines, + risk_tip=risk_tip, + ) + send_wechat_msg(msg) + new_count = int(r["notification_count"] or 0) + 1 + max_n = int(r["max_notify"] or KEY_ALERT_MAX_TIMES) + conn.execute( + "UPDATE key_monitors SET notification_count = ?, last_notified_at = ? WHERE id = ?", + (new_count, app_now_str(), r["id"]), + ) + if new_count >= max_n: + insert_key_monitor_history(conn, r, new_count, msg, "alerts_complete") + conn.execute("DELETE FROM key_monitors WHERE id = ?", (r["id"],)) + send_wechat_msg( + "\n".join( + [ + f"# 🧾 {r['symbol']} 关键位监控结束", + "", + f"- 原因:已满 {max_n} 次提醒", + "- 状态:已自动结束并记入历史", + ] + ) + ) + conn.commit() + conn.close() + +# 止盈止损监控(已修复:严格区分多空,无默认做多) +def check_order_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(sym) + session_date = r["session_date"] or get_trading_day() + p = get_price(sym) + if not p: continue + + # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) + risk_amount = float(r["risk_amount"] or 0) + breakeven_armed = int(r["breakeven_armed"] or 0) + trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) + step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) + step_r = 1.0 if step_r <= 0 else step_r + breakeven_enabled = True + try: + if "breakeven_enabled" in r.keys(): + breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 + except Exception: + breakeven_enabled = True + if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: + now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + now_rr = now_pnl / risk_amount + if now_rr >= trigger_rr: + steps = int((now_rr - trigger_rr) // step_r) + locked_r = max(0.0, steps * step_r) + notional = float(margin_capital or 0) * float(leverage or 0) + risk_frac = (risk_amount / notional) if notional > 0 else None + if risk_frac and risk_frac > 0: + new_sl = calc_breakeven_stop( + direction, + trigger_price, + risk_frac, + locked_r=locked_r, + offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT), + ) + if new_sl is not None: + should_move = (direction == "short" and new_sl < float(stop_loss)) or ( + direction == "long" and new_sl > float(stop_loss) + ) + if should_move: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, pid), + ) + stop_loss = new_sl + arm_txt = "保本止盈" if not breakeven_armed else "移动止盈" + send_wechat_msg( + build_wechat_breakeven_message( + sym, + direction, + arm_txt, + now_rr, + locked_r, + new_sl, + ) + ) + + res = None + # 做多 + if direction == "long": + if p >= take_profit: res = "止盈" + elif p <= stop_loss: res = "止损" + # 做空 + elif direction == "short": + if p <= take_profit: res = "止盈" + elif p >= stop_loss: res = "止损" + + if res: + now = app_now() + opened_at = get_opened_at_value(r) + opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + if res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(res, pnl_amount) + close_order_id = "" + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细。 + exit_p = extract_trade_price_from_order(close_resp) + if exit_p and exit_p > 0: + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + else: + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + except Exception as e: + if is_no_position_error(str(e)): + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + cancel_gate_swap_trigger_orders(ex_sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判。 + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type="下单监控", + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason="触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + opened_at=opened_at, + closed_at=closed_at, + ) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{res}(交易所已先行平仓)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + session_capital_fallback=session_capital, + ) + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) + conn.commit() + continue + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (pid,)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + continue + cancel_gate_swap_trigger_orders(r["exchange_symbol"] or normalize_exchange_symbol(sym)) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=res, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id=close_order_id or "-", + session_capital_fallback=session_capital, + ) + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type="下单监控", + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid)) + conn.commit() + conn.close() + + +def force_close_before_reset(): + if not FORCE_CLOSE_ENABLED: + return + now = app_now() + # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) + if now.hour != FORCE_CLOSE_BJ_HOUR: + return + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + p = get_price(r["symbol"]) + if not p: + continue + direction = r["direction"] + trigger_price = r["trigger_price"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(r["symbol"]) + session_date = r["session_date"] or get_trading_day(now) + opened_at = get_opened_at_value(r) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + cancel_gate_swap_trigger_orders(r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"])) + except Exception as e: + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=r["symbol"], + direction=direction, + scene="强制清仓失败", + error_text=str(e), + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type="下单监控", + direction=direction, + trigger_price=trigger_price, + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result="强制清仓", + miss_reason=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"])) + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=direction, + result="强制清仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id=close_order_id or "-", + extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + session_capital_fallback=session_capital, + ) + ) + conn.commit() + conn.close() + +# 后台线程 +def background_task(): + while True: + try: + auto_transfer_once_per_day() + conn = get_db() + reconcile_external_closes(conn) + conn.commit() + conn.close() + force_close_before_reset() + check_trend_pullback_plans() + check_order_monitors() + except: + pass + time.sleep(MONITOR_POLL_SECONDS) + + +# ====================== 登录路由 ====================== +@app.route("/login", methods=["GET", "POST"]) +def login(): + if AUTH_DISABLED: + session["logged_in"] = True + return redirect("/") + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + if username == USERNAME and password == PASSWORD: + session["logged_in"] = True + return redirect("/") + else: + flash("账号或密码错误") + return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME) + +@app.route("/logout") +def logout(): + session.clear() + return redirect("/" if AUTH_DISABLED else "/login") + +# 登录校验装饰器 +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if AUTH_DISABLED: + return f(*args, **kwargs) + if not session.get("logged_in"): + return redirect("/login") + return f(*args, **kwargs) + return decorated + + +@app.route("/sync_positions") +@login_required +def sync_positions(): + days_raw = (request.args.get("days") or "").strip() + sync_days = None + if days_raw: + try: + sync_days = max(1, min(365, int(days_raw))) + except Exception: + sync_days = None + conn = get_db() + synced = reconcile_external_closes(conn, days=sync_days) + conn.commit() + conn.close() + if sync_days is not None: + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + else: + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + return redirect("/") + + +@app.route("/api/sync_positions", methods=["POST"]) +@login_required +def api_sync_positions(): + payload = request.get_json(silent=True) or {} + days_raw = str(payload.get("days", "")).strip() + if not days_raw: + return jsonify({"ok": False, "msg": "请填写天数"}), 400 + try: + days = int(days_raw) + except Exception: + return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 + if days < 1 or days > 365: + return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 + conn = get_db() + synced = reconcile_external_closes(conn, days=days) + conn.commit() + conn.close() + return jsonify({"ok": True, "days": days, "synced": int(synced)}) + + +# ====================== 主页面 ====================== +def render_main_page(page="trade"): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals() + # 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。 + funding_usdt = round(funding_capital, 4) if funding_capital is not None else None + current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) + recommended_capital = get_recommended_capital(current_capital) + key_list = conn.execute("SELECT * FROM key_monitors").fetchall() + key_history = conn.execute("SELECT * FROM key_monitor_history ORDER BY id DESC LIMIT 80").fetchall() + stats_bundle = compute_stats_bundle(conn, trading_day, now) + raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + order_list = [] + for o in raw_order_list: + order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC").fetchall() + records = [to_effective_trade_dict(r) for r in raw_records] + total = len(records) + miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") + win = sum(1 for r in records if (r.get("effective_result") or "") in ("止盈", "保本止盈", "移动止盈")) + occupied_miss_total = sum( + 1 + for r in records + if (r.get("effective_result") or "") == "错过" + and ("持仓占用" in str(r.get("effective_miss_reason") or "")) + ) + rate = round(win/total*100,2) if total else 0 + active_count = len(order_list) + trend_active = conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + trend_plans = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall() + can_trade = ( + trading_day_reset_allows_new_open(now) + and active_count == 0 + and int(trend_active or 0) == 0 + ) + trend_preview = None + trend_preview_levels = [] + preview_expires_ms = None + trend_preview_expired = False + trend_preview_id_arg = "" + if page == "trade": + _trend_cleanup_stale_previews(conn) + trend_preview_id_arg = (request.args.get("preview_id") or "").strip() + if trend_preview_id_arg: + pr = conn.execute( + "SELECT * FROM trend_pullback_previews WHERE id=?", + (trend_preview_id_arg,), + ).fetchone() + now_ms = int(time.time() * 1000) + if pr and int(pr["expires_at_ms"] or 0) >= now_ms: + trend_preview = row_to_dict(pr) + preview_expires_ms = int(pr["expires_at_ms"]) + try: + grid = json.loads(trend_preview.get("grid_prices_json") or "[]") + legs = json.loads(trend_preview.get("leg_amounts_json") or "[]") + except Exception: + grid, legs = [], [] + for i, pair in enumerate(zip(grid, legs), 1): + trend_preview_levels.append({"i": i, "price": pair[0], "contracts": pair[1]}) + elif pr: + trend_preview_expired = True + conn.close() + return render_template( + "index.html", + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + record=records, + total=total, + miss_count=miss_count, + rate=rate, + trading_day=trading_day, + funding_usdt=funding_usdt, + daily_start_capital=DAILY_START_CAPITAL, + current_capital=current_capital, + recommended_capital=recommended_capital, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + reset_hour=TRADING_DAY_RESET_HOUR, + balance_refresh_seconds=BALANCE_REFRESH_SECONDS, + auto_transfer_enabled=AUTO_TRANSFER_ENABLED, + auto_transfer_amount=AUTO_TRANSFER_AMOUNT, + auto_transfer_from=AUTO_TRANSFER_FROM, + auto_transfer_to=AUTO_TRANSFER_TO, + auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, + full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + active_count=active_count, + can_trade=can_trade, + trend_plans=trend_plans, + trend_pullback_dca_legs=TREND_PULLBACK_DCA_LEGS, + trend_pullback_preview_ttl=TREND_PULLBACK_PREVIEW_TTL_SECONDS, + trend_preview=trend_preview, + trend_preview_levels=trend_preview_levels, + preview_expires_ms=preview_expires_ms, + trend_preview_expired=trend_preview_expired, + trend_preview_id_arg=trend_preview_id_arg, + trend_preview_max_drift_pct=TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT, + focus_key_id=(key_list[0]["id"] if key_list else None), + focus_order_id=(order_list[0]["id"] if order_list else None), + data_export_version=2, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + risk_percent=RISK_PERCENT, + breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, + breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, + occupied_miss_total=occupied_miss_total, + price_fmt=format_price_for_symbol, + entry_reason_options=list(ENTRY_REASON_OPTIONS), + entry_reason_other_value=ENTRY_REASON_OTHER, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/") +@login_required +def index(): + return redirect("/trade") + + +@app.route("/trade") +@login_required +def trade_page(): + return render_main_page("trade") + + +@app.route("/records") +@login_required +def records_page(): + return render_main_page("records") + + +@app.route("/stats") +@login_required +def stats_page(): + return render_main_page("stats") + + +@app.route("/api/account_snapshot") +@login_required +def api_account_snapshot(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals(force=True) + funding_usdt = round(funding_capital, 4) if funding_capital is not None else None + current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) + recommended_capital = get_recommended_capital(current_capital) + active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0] + conn.close() + can_trade = trading_day_reset_allows_new_open(now) and active_count == 0 + available_trading_usdt = get_available_trading_usdt() + return jsonify({ + "funding_usdt": funding_usdt, + "current_capital": current_capital, + "available_trading_usdt": round(available_trading_usdt, 4) if available_trading_usdt is not None else None, + "recommended_capital": recommended_capital, + "active_count": active_count, + "can_trade": can_trade, + "trading_day": trading_day + }) + + +@app.route("/api/price_snapshot") +@login_required +def api_price_snapshot(): + conn = get_db() + key_rows = conn.execute("SELECT id,symbol,monitor_type,direction,upper,lower FROM key_monitors").fetchall() + order_rows = conn.execute( + "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'" + ).fetchall() + conn.close() + + symbol_set = set() + for r in key_rows: + symbol_set.add(r["symbol"]) + for r in order_rows: + symbol_set.add(r["symbol"]) + + prices = {} + for s in symbol_set: + p = get_price(s) + if p is not None: + prices[s] = float(p) + + all_swap_positions = [] + if exchange_private_api_configured(): + try: + ensure_markets_loaded() + # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 + all_swap_positions = exchange.fetch_positions(None, {"settle": "usdt"}) or [] + except Exception: + try: + all_swap_positions = exchange.fetch_positions() or [] + except Exception: + all_swap_positions = [] + + key_prices = [] + for r in key_rows: + price = prices.get(r["symbol"]) + if price is None: + continue + upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) + lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) + gate = None + try: + gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) + except Exception: + gate = None + gate_summary = "-" + gate_metrics = "" + if gate: + rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}" + gate_summary = ( + f"量:{'Y' if gate.get('vol_ok') else 'N'} " + f"破:{'Y' if gate.get('breakout_ok') else 'N'} " + f"幅:{'Y' if gate.get('amp_ok') else 'N'} " + f"二确:{'Y' if gate.get('confirm_ok') else 'N'} " + f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})" + ) + if gate.get("breakout_ok"): + try: + vol_now = round(float(gate.get("vol_break") or 0), 4) + vol_avg = round(float(gate.get("avg20") or 0), 4) + amp_pct = round(float(gate.get("amp_pct") or 0), 4) + cfm_close = round(float(gate.get("confirm_close") or 0), 8) + edge = round(float(gate.get("edge_price") or 0), 8) + gate_metrics = ( + f"量值:{vol_now}/{vol_avg} " + f"幅值:{amp_pct}% " + f"二确值:{cfm_close}@{edge}" + ) + except Exception: + gate_metrics = "" + key_prices.append({ + "id": r["id"], + "symbol": r["symbol"], + "price": round(price, 6), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + "gate_summary": gate_summary, + "gate_ok": bool(gate and gate.get("ok")), + "gate_metrics": gate_metrics, + }) + + order_prices = [] + for r in order_rows: + price = prices.get(r["symbol"]) + if price is None: + continue + margin = float(r["margin_capital"] or 0) + leverage = float(r["leverage"] or 0) + entry = float(r["trigger_price"] or 0) + pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 + pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 + rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) + ex_sym = resolve_monitor_exchange_symbol(r) + prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) + lev_row = r["leverage"] if "leverage" in r.keys() else None + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None + payload = { + "id": r["id"], + "symbol": r["symbol"], + "price": round(price, 6), + "float_pnl": round(pnl, 6), + "float_pct": pnl_pct, + "rr_ratio": rr_ratio, + "plan_margin": round(margin, 4) if margin else None, + "exchange_initial_margin": None, + "exchange_notional": None, + "exchange_mark_price": None, + "pnl_source": "plan", + } + if ex_metrics: + if ex_metrics.get("initial_margin") is not None: + payload["exchange_initial_margin"] = ex_metrics["initial_margin"] + if ex_metrics.get("notional") is not None: + payload["exchange_notional"] = ex_metrics["notional"] + if ex_metrics.get("mark_price") is not None: + payload["exchange_mark_price"] = ex_metrics["mark_price"] + if ex_metrics.get("unrealized_pnl") is not None: + payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 6) + payload["pnl_source"] = "exchange" + denom = ex_metrics.get("initial_margin") or margin + payload["float_pct"] = ( + round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct + ) + order_prices.append(payload) + + return jsonify({ + "updated_at": app_now_str(), + "key_prices": key_prices, + "order_prices": order_prices, + "positions_raw_count": len(all_swap_positions), + }) + + +@app.route("/api/symbol_liquidity_rank") +@login_required +def api_symbol_liquidity_rank(): + symbol = normalize_symbol_input(request.args.get("symbol")) + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + rank, total = _daily_volume_rank(symbol) + if total <= 0: + return jsonify({"ok": False, "msg": "日成交量排名读取失败"}), 502 + if rank is None: + return jsonify({"ok": True, "symbol": symbol, "rank": None, "total": int(total), "in_top30": False}) + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": int(rank), + "total": int(total), + "in_top30": bool(rank <= 30), + } + ) + + +@app.route("/api/order_defaults") +@login_required +def api_order_defaults(): + symbol = normalize_symbol_input(request.args.get("symbol")) + direction = (request.args.get("direction") or "long").strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if direction not in ("long", "short"): + direction = "long" + exchange_symbol = normalize_exchange_symbol(symbol) + leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + available = get_available_trading_usdt() + return jsonify({ + "ok": True, + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "available_trading_usdt": round(available, 4) if available is not None else None + }) + + +@app.route("/order_focus") +@login_required +def order_focus(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall() + conn.close() + orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders] + picked_id = request.args.get("order_id", "").strip() + selected = None + if picked_id.isdigit(): + selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None) + if selected is None and orders: + selected = orders[0] + return render_template( + "order_focus_v2.html", + orders=orders, + selected_order=selected, + default_timeframe=KLINE_TIMEFRAME, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/api/order_kline") +@login_required +def api_order_kline(): + order_id_raw = (request.args.get("order_id") or "").strip() + if not order_id_raw.isdigit(): + return jsonify({"ok": False, "msg": "order_id 无效"}), 400 + order_id = int(order_id_raw) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"} + if timeframe not in allowed_tfs: + timeframe = KLINE_TIMEFRAME + limit = 100 + + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404 + + order_item = enrich_order_item(row_to_dict(row), current_capital) + exchange_symbol = order_item.get("exchange_symbol") or normalize_exchange_symbol(order_item["symbol"]) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + ts = int(bar[0] // 1000) + candles.append({ + "time": ts, + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(order_item["symbol"]) + margin = float(order_item.get("margin_capital") or 0) + leverage = float(order_item.get("leverage") or 0) + entry = float(order_item.get("trigger_price") or 0) + float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0 + float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0 + + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": { + "id": order_item["id"], + "symbol": order_item["symbol"], + "direction": order_item.get("direction") or "long", + "trigger_price": order_item.get("trigger_price"), + "stop_loss": order_item.get("stop_loss"), + "take_profit": order_item.get("take_profit"), + "margin_capital": order_item.get("margin_capital"), + "leverage": order_item.get("leverage"), + "position_ratio": order_item.get("position_ratio"), + "rr_ratio": order_item.get("rr_ratio"), + "breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)), + "current_price": round(float(current_price), 8) if current_price else None, + "float_pnl": round(float(float_pnl), 6), + "float_pct": float_pct, + }, + "candles": candles, + "updated_at": app_now_str(), + }) + + +@app.route("/key_focus") +@login_required +def key_focus(): + conn = get_db() + key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + conn.close() + key_list = [row_to_dict(r) for r in key_rows] + + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_query = normalize_symbol_input(request.args.get("symbol")) + selected_key = None + if key_id_raw.isdigit(): + selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None) + if selected_key is None and symbol_query: + selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None) + if selected_key is None and key_list: + selected_key = key_list[0] + default_symbol = symbol_query or ((selected_key or {}).get("symbol")) or "BTC/USDT" + return render_template( + "key_focus_v2.html", + key_list=key_list, + selected_key=selected_key, + default_symbol=default_symbol, + default_timeframe=KLINE_TIMEFRAME, + default_kline_limit=200, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/api/key_kline") +@login_required +def api_key_kline(): + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_input = normalize_symbol_input(request.args.get("symbol")) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + if timeframe not in {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}: + timeframe = KLINE_TIMEFRAME + limit = normalize_kline_limit(request.args.get("limit"), default=200) + + conn = get_db() + key_row = None + if key_id_raw.isdigit(): + key_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (int(key_id_raw),)).fetchone() + if key_row is None and symbol_input: + key_row = conn.execute( + "SELECT * FROM key_monitors WHERE upper(symbol)=? ORDER BY id DESC LIMIT 1", + (symbol_input,), + ).fetchone() + if key_row is not None: + symbol = (key_row["symbol"] or "").upper() + else: + symbol = symbol_input + conn.close() + if not symbol: + return jsonify({"ok": False, "msg": "请先输入币种或选择关键位"}), 400 + + exchange_symbol = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + candles.append({ + "time": int(bar[0] // 1000), + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(symbol) + key_info = None + if key_row is not None: + upper = float(key_row["upper"]) if key_row["upper"] is not None else None + lower = float(key_row["lower"]) if key_row["lower"] is not None else None + upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None) + lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None) + key_info = { + "id": key_row["id"], + "monitor_type": key_row["monitor_type"], + "direction": key_row["direction"] or "long", + "upper": upper, + "lower": lower, + "notification_count": int(key_row["notification_count"] or 0), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + } + + return jsonify({ + "ok": True, + "symbol": symbol, + "timeframe": timeframe, + "limit": limit, + "current_price": round(float(current_price), 8) if current_price is not None else None, + "key_monitor": key_info, + "candles": candles, + "updated_at": app_now_str(), + }) + + +@app.route("/add_key", methods=["POST"]) +@login_required +def add_key(): + d = request.form + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + flash("symbol 不能为空") + return redirect("/") + rank, total = _daily_volume_rank(symbol) + if rank is None: + flash("日成交量排名读取失败,请稍后重试") + return redirect("/") + if rank > 30: + flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位") + return redirect("/") + conn = get_db() + conn.execute("INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)", + (symbol, d["type"], d.get("direction", "long"), d["upper"], d["lower"])) + conn.commit() + conn.close() + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total})") + return redirect("/") + +@app.route("/add_order", methods=["POST"]) +@login_required +def add_order(): + d = request.form + now = app_now() + conn = get_db() + direction = d.get("direction", "long") + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + conn.close() + flash("symbol 不能为空") + return redirect("/") + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + if "一次只能持有一个仓位" in reason: + try: + tp_raw = parse_positive_float(d.get("tp")) + sl_raw = parse_positive_float(d.get("sl")) + tgt_raw = parse_positive_float(d.get("tgt")) + except Exception: + tp_raw = sl_raw = tgt_raw = None + insert_trade_record( + conn, + symbol=symbol, + monitor_type="下单监控", + direction=direction if direction in ("long", "short") else "long", + trigger_price=tp_raw or 0, + stop_loss=sl_raw or 0, + take_profit=tgt_raw or 0, + result="错过", + miss_reason="持仓占用:一次只能持有一个仓位", + opened_at=app_now_str(), + closed_at=app_now_str(), + ) + conn.commit() + conn.close() + flash(f"风控拒绝下单:{reason}") + return redirect("/") + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(f"风控拒绝下单:{reason_live}") + return redirect("/") + exchange_symbol = normalize_exchange_symbol(symbol) + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + try: + leverage_input = parse_positive_float(d.get("leverage")) + leverage = int(leverage_input) if leverage_input is not None else default_leverage + except Exception: + conn.close() + flash("杠杆参数格式错误") + return redirect("/") + if leverage <= 0: + conn.close() + flash("杠杆必须大于0") + return redirect("/") + + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + trade_style = (d.get("trade_style") or DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + conn.close() + flash("获取交易所实时价格失败,请稍后重试") + return redirect("/") + sltp_mode = (d.get("sltp_mode") or "price").strip().lower() + if sltp_mode not in ("price", "pct"): + sltp_mode = "price" + if sltp_mode == "pct": + try: + sl_pct = float(d.get("sl_pct") or 0) + tp_pct = float(d.get("tp_pct") or 0) + if sl_pct <= 0 or tp_pct <= 0: + raise ValueError("pct") + sl_ratio = sl_pct / 100.0 + tp_ratio = tp_pct / 100.0 + if direction == "short": + stop_loss = float(live_price) * (1 + sl_ratio) + take_profit = float(live_price) * (1 - tp_ratio) + else: + stop_loss = float(live_price) * (1 - sl_ratio) + take_profit = float(live_price) * (1 + tp_ratio) + except Exception: + conn.close() + flash("百分比止盈止损参数错误,请填写正数百分比") + return redirect("/") + else: + try: + stop_loss = float(d["sl"]) + take_profit = float(d["tgt"]) + except Exception: + conn.close() + flash("价格参数格式错误") + return redirect("/") + if stop_loss <= 0 or take_profit <= 0: + conn.close() + flash("价格参数必须大于0") + return redirect("/") + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + conn.close() + flash("止损方向不合法:请检查入场方向与止损价格关系") + return redirect("/") + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + conn.close() + flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + return redirect("/") + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + conn.close() + flash(f"保证金不足:交易账户可用约 {round(available_usdt,4)}U,当前最多建议 {max_margin}U") + return redirect("/") + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + conn.close() + flash(friendly_exchange_error(e, available_usdt=available_usdt)) + return redirect("/") + + make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes") + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount + if direction == "short": + breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) + else: + breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) + breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, risk_percent, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, + breakeven_enabled, + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day + ) + ) + conn.commit() + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + conn.close() + + chart_name = None + chart_url = None + if make_order_chart and ORDER_CHART_ENABLED: + try: + title_prefix = f"{symbol} {direction} #{new_order_id}" + chart_name = generate_order_open_chart(exchange_symbol, title_prefix) + if chart_name: + chart_url = f"/static/images/order_charts/{chart_name}" + except Exception: + chart_name = None + chart_url = None + + if chart_name: + try: + journal_id = f"order_{new_order_id}" + coin = journal_coin_from_symbol(symbol) + open_local = (opened_at_bj or "")[:16].replace(" ", "T") + if len(open_local) < 16: + open_local = app_now().strftime("%Y-%m-%dT%H:%M") + close_local = open_local + hold_duration = calc_duration_text(open_local, close_local) + note = ( + f"auto_from_open_order id={new_order_id} oid={open_order_id} " + f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" + ) + conn = get_db() + conn.execute( + """INSERT OR REPLACE INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + journal_id, + open_local, + close_local, + hold_duration, + coin, + "multi", + "0", + "auto:open", + "待平仓", + "", + "", + "否", + "", + "", + "", + None, + None, + None, + "", + "否", + "否", + note, + chart_name, + ), + ) + conn.commit() + conn.close() + except Exception: + try: + conn.close() + except Exception: + pass + + _, trading_capital_after = get_exchange_capitals(force=True) + account_base_display = ( + round(float(trading_capital_after), 4) + if trading_capital_after is not None + else round(float(capital_base), 4) + ) + account_name = (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip() + dir_text = "多头(long)" if direction == "long" else "空头(short)" + order_state_text = ( + "已在交易所挂条件委托(止盈、止损各一张触发单)" + if tpsl_attached + else "条件委托未挂上(已拦截)" + ) + rr_show = planned_rr if planned_rr is not None else "-" + try: + rr_show_fmt = round(float(planned_rr), 4) if planned_rr is not None else None + except (TypeError, ValueError): + rr_show_fmt = None + rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1" + ep_wx = format_price_for_symbol(symbol, trigger_price) + sl_wx = format_price_for_symbol(symbol, stop_loss) + tp_wx = format_price_for_symbol(symbol, take_profit) + be_wx = format_price_for_symbol(symbol, breakeven_price) + style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" + wx_lines = [ + f"📈 {symbol} 开仓成功", + f"💼 交易类型:{dir_text}", + "🧾 订单基础信息", + f"🔖 交易所订单 ID:{open_order_id}", + f"📈 交易风格:{style_zh}", + f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), 4)} U", + "📊 仓位配置详情", + f"账户基数:{account_base_display} USDT", + f"合约杠杆:{leverage} 倍", + f"名义仓位:{notional_value} USDT", + f"仓位占比:{position_ratio}%", + f"合约张数:{amount} 张", + f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", + "🎯 价位 & 盈亏比", + f"开仓成交价:{ep_wx}", + f"止损价位:{sl_wx}", + f"止盈价位:{tp_wx}", + f"计划盈亏比:{rr_line}", + f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", + "📌 状态统计", + f"✅ 条件委托:{order_state_text}", + f"📅 当日开仓次数:{opens_today_after} / {DAILY_OPEN_ALERT_THRESHOLD} 次(风控阈值提醒)", + ] + if chart_url: + wx_lines.append(f"多周期K线图:{chart_url}") + send_wechat_msg("\n".join(wx_lines)) + + flash_lines = [ + f"机器人开单成功:风格 {trade_style};风险 {risk_percent}%≈{risk_amount_final}U;基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约张数 {amount}(折算标的 {base_amount})," + f"计划RR {planned_rr if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + f"本交易日累计开仓:{opens_today_after}", + ] + if chart_url: + flash_lines.append(f"已生成多周期K线图:{chart_url}") + flash(" ".join(flash_lines)) + + if opens_today_before < DAILY_OPEN_ALERT_THRESHOLD <= opens_today_after: + advice = ai_short_advice( + f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_today_after} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") + return redirect("/") + + +@app.route("/preview_trend_pullback", methods=["POST"]) +@login_required +def preview_trend_pullback(): + conn = get_db() + _trend_cleanup_stale_previews(conn) + okp, reasonp = precheck_trend_pullback_start(conn) + if not okp: + conn.close() + flash(reasonp) + return redirect(url_for("trade_page")) + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(reason_live) + return redirect(url_for("trade_page")) + payload, err = parse_and_compute_trend_pullback_plan(request.form) + if err: + conn.close() + flash(err) + return redirect(url_for("trade_page")) + pid = str(uuid.uuid4()) + exp_ms = int(time.time() * 1000) + int(TREND_PULLBACK_PREVIEW_TTL_SECONDS) * 1000 + created = app_now_str() + conn.execute( + """INSERT INTO trend_pullback_previews ( + id,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, + snapshot_available_usdt,snapshot_at,live_price_ref,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, + dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,expires_at_ms,created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + pid, + payload["symbol"], + payload["exchange_symbol"], + payload["direction"], + payload["leverage"], + payload["stop_loss"], + payload["add_upper"], + payload["take_profit"], + payload["risk_percent"], + payload["snapshot_available_usdt"], + payload["snapshot_at"], + payload["live_price_ref"], + payload["plan_margin_capital"], + payload["target_order_amount"], + payload["first_order_amount"], + payload["remainder_total"], + payload["dca_legs"], + payload["per_leg_amount"], + payload["grid_prices_json"], + payload["leg_amounts_json"], + exp_ms, + created, + ), + ) + conn.commit() + conn.close() + flash(f"预览已生成,有效期 {TREND_PULLBACK_PREVIEW_TTL_SECONDS} 秒,请核对后点击「确认执行」。") + return redirect(url_for("trade_page", preview_id=pid)) + + +@app.route("/execute_trend_pullback", methods=["POST"]) +@login_required +def execute_trend_pullback(): + pid = (request.form.get("preview_id") or "").strip() + if not pid: + flash("缺少预览 ID") + return redirect(url_for("trade_page")) + conn = get_db() + _trend_cleanup_stale_previews(conn) + pr = conn.execute("SELECT * FROM trend_pullback_previews WHERE id=?", (pid,)).fetchone() + now_ms = int(time.time() * 1000) + if not pr or int(pr["expires_at_ms"] or 0) < now_ms: + conn.close() + flash("预览已过期或不存在,请重新生成预览") + return redirect(url_for("trade_page")) + okp, reasonp = precheck_trend_pullback_start(conn) + if not okp: + conn.close() + flash(reasonp) + return redirect(url_for("trade_page", preview_id=pid)) + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(reason_live) + return redirect(url_for("trade_page", preview_id=pid)) + snap_prev = float(pr["snapshot_available_usdt"] or 0) + snap_now = get_available_trading_usdt() + if snap_now is None or snap_now <= 0: + conn.close() + flash("无法读取当前合约可用余额,请稍后重试") + return redirect(url_for("trade_page", preview_id=pid)) + drift_pct = abs(float(snap_now) - snap_prev) / max(snap_prev, 1e-9) * 100.0 + if drift_pct > float(TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT): + conn.close() + flash( + f"当前可用余额与预览快照偏差 {drift_pct:.2f}%,超过允许 {TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT}% ,请重新生成预览" + ) + return redirect(url_for("trade_page")) + symbol = pr["symbol"] + exchange_symbol = pr["exchange_symbol"] + direction = pr["direction"] or "long" + leverage = int(pr["leverage"] or 1) + stop_loss = float(pr["stop_loss"]) + add_upper = float(pr["add_upper"]) + take_profit = float(pr["take_profit"]) + risk_percent = float(pr["risk_percent"] or 5) + snap = float(snap_now) + margin_plan = float(pr["plan_margin_capital"] or 0) + target_amt = float(pr["target_order_amount"] or 0) + first_amt = float(pr["first_order_amount"] or 0) + remainder_total = float(pr["remainder_total"] or 0) + n_legs = int(pr["dca_legs"] or 0) + per_ref = float(pr["per_leg_amount"] or 0) + grid_json = pr["grid_prices_json"] or "[]" + leg_json = pr["leg_amounts_json"] or "[]" + live_price = get_price(symbol) + if live_price is None: + conn.close() + flash("获取实时价格失败") + return redirect(url_for("trade_page", preview_id=pid)) + try: + o1 = place_exchange_order(exchange_symbol, direction, first_amt, leverage, stop_loss=None, take_profit=None) + fill1 = resolve_order_entry_price(o1, exchange_symbol, live_price) + _trend_refresh_stop_only(exchange_symbol, direction, stop_loss) + except Exception as e: + conn.close() + flash(friendly_exchange_error(e, available_usdt=snap_now)) + return redirect(url_for("trade_page", preview_id=pid)) + now = app_now() + trading_day = get_trading_day(now) + opened_at = app_now_str() + opened_ms = _to_ms_with_fallback(None, opened_at) + conn.execute( + """INSERT INTO trend_pullback_plans ( + status,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, + snapshot_available_usdt,snapshot_at,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, + dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,legs_done,first_order_done,last_mark_price,avg_entry_price,order_amount_open,opened_at,opened_at_ms,session_date,message + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + "active", + symbol, + exchange_symbol, + direction, + leverage, + stop_loss, + add_upper, + take_profit, + risk_percent, + snap, + opened_at, + margin_plan, + target_amt, + first_amt, + remainder_total, + n_legs, + per_ref, + grid_json, + leg_json, + 0, + 1, + float(live_price), + fill1, + first_amt, + opened_at, + opened_ms, + trading_day, + f"预览ID:{pid[:8]}…", + ), + ) + conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) + conn.commit() + conn.close() + flash( + f"趋势回调已执行:可用余额(执行时){round(snap, 4)}U;计划保证金约 {round(margin_plan, 4)}U;" + f"总张数约 {target_amt},首仓 {first_amt},补仓 {n_legs} 档;已挂交易所止损,止盈由程序监控。" + ) + return redirect(url_for("trade_page")) + + +@app.route("/cancel_trend_pullback_preview", methods=["POST"]) +@login_required +def cancel_trend_pullback_preview(): + pid = (request.form.get("preview_id") or "").strip() + conn = get_db() + if pid: + conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) + conn.commit() + conn.close() + flash("已取消预览") + return redirect(url_for("trade_page")) + + +@app.route("/stop_trend_pullback/") +@login_required +def stop_trend_pullback(pid): + conn = get_db() + row = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,)).fetchone() + if not row: + conn.close() + flash("未找到运行中的趋势回调计划") + return redirect("/trade") + ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]) + direction = row["direction"] or "long" + lev = int(row["leverage"] or 1) + px = get_price(row["symbol"]) + exit_p = float(px) if px is not None else 0.0 + ok_live, _ = ensure_exchange_live_ready() + if ok_live: + pos = get_live_position_contracts(ex_sym, direction) + if pos is not None and pos > 0: + try: + exchange.set_leverage(lev, ex_sym) + side = "sell" if direction == "long" else "buy" + params = build_gate_order_params(direction, reduce_only=True) + close_resp = exchange.create_order(ex_sym, "market", side, float(pos), None, params) + ep = extract_trade_price_from_order(close_resp) + if ep: + exit_p = float(ep) + except Exception as e: + if not is_no_position_error(str(e)): + conn.close() + flash(f"平仓失败:{e}") + return redirect("/trade") + try: + cancel_all_open_orders_for_symbol(ex_sym) + except Exception: + pass + _trend_finalize_plan(conn, row, "手动平仓", exit_p) + conn.commit() + conn.close() + flash("已结束趋势回调计划(市价平仓、撤单)") + return redirect("/trade") + + +@app.route("/delete_key_monitor/", methods=["POST"]) +@login_required +def delete_key_monitor(kid): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "error": "not_found"}) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/delete_key_history/", methods=["POST"]) +@login_required +def delete_key_history(hid): + conn = get_db() + cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/del_key/") +@login_required +def del_key(id): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() + if row: + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) + conn.commit() + conn.close() + resp = redirect("/") + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + return resp + + +def _csv_response(filename, rows, header): + buf = StringIO() + w = csv.writer(buf) + w.writerow(header) + for row in rows: + w.writerow(row) + out = "\ufeff" + buf.getvalue() + return Response( + out, + mimetype="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +def _md_response(filename, content): + return Response( + content, + mimetype="text/markdown; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +@app.route("/export/trade_records") +@login_required +def export_trade_records(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,trigger_price,stop_loss,take_profit,margin_capital,leverage," + "pnl_amount,hold_seconds,hold_minutes,opened_at,closed_at,result,miss_reason," + "entry_reason,reviewed_entry_reason,created_at FROM trade_records ORDER BY id ASC" + ).fetchall() + conn.close() + head_base = [ + "id", + "symbol", + "monitor_type", + "direction", + "trigger_price", + "stop_loss", + "take_profit", + "margin_capital", + "leverage", + "pnl_amount", + "hold_seconds", + "hold_minutes", + "opened_at", + "closed_at", + "result", + "miss_reason", + "entry_reason", + "reviewed_entry_reason", + "created_at", + ] + head = head_base + ["开仓类型"] + data = [] + for r in rows: + er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" + er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" + eff = er1 or er0 + data.append(tuple(r[h] for h in head_base) + (eff,)) + day = app_now().strftime("%Y%m%d") + return _csv_response(f"trade_records_v2_{day}.csv", data, head) + + +@app.route("/export/journal_entries") +@login_required +def export_journal_entries(): + conn = get_db() + rows = conn.execute( + "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," + "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," + "post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC" + ).fetchall() + conn.close() + head = [ + "id", + "open_datetime", + "close_datetime", + "hold_duration", + "coin", + "tf", + "pnl", + "entry_reason", + "exit_reason", + "expect_rr", + "real_rr", + "early_exit", + "early_exit_trigger", + "early_exit_note", + "early_exit_reason", + "mood_issues", + "post_breakeven_stare", + "new_trade_while_occupied", + "note", + "image", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"journal_entries_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitors") +@login_required +def export_key_monitors(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify," + "notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC" + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_notified_at", + "max_notify", + "notify_interval_min", + "breakout_limit_pct", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitor_history") +@login_required +def export_key_monitor_history(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " + "FROM key_monitor_history ORDER BY id ASC" + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_alert_message", + "close_reason", + "closed_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) + +@app.route("/del_order/") +@login_required +def del_order(id): + conn = get_db() + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() + if not row: + conn.close() + flash("订单不存在") + return redirect("/") + if row["status"] == "active": + try: + p = get_price(row["symbol"]) or float(row["trigger_price"]) + opened_at = get_opened_at_value(row) + closed_at = app_now_str() + hold_seconds = calc_hold_seconds(opened_at, app_now()) + pnl_amount = calc_pnl( + row["direction"], + row["trigger_price"], + p, + row["margin_capital"] or DAILY_START_CAPITAL, + row["leverage"] or infer_leverage(row["symbol"]) + ) + close_resp = close_exchange_order(row) + close_order_id = close_resp.get("id", "") + cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + session_date = row["session_date"] or get_trading_day() + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type="下单监控", + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result="手动平仓", + miss_reason="用户手动删除订单触发平仓", + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + conn.commit() + conn.close() + send_wechat_msg( + build_wechat_close_message( + symbol=row["symbol"], + direction=row["direction"], + result="手动平仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=row["trigger_price"], + current_price=p, + stop_loss=row["stop_loss"], + take_profit=row["take_profit"], + close_order_id=close_order_id or "-", + extra_note="用户在页面手动平仓", + session_capital_fallback=session_capital, + ) + ) + flash("已按实盘流程手动平仓") + return redirect("/") + except Exception as e: + if is_no_position_error(str(e)): + cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + opened_at = get_opened_at_value(row) + opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) + miss_reason = f"手动删除时无持仓:{miss_reason}" + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = row["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type="下单监控", + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result=result, + miss_reason=miss_reason, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + conn.commit() + conn.close() + flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") + return redirect("/") + conn.close() + flash(f"手动平仓失败:{str(e)}") + return redirect("/") + conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) + conn.commit() + conn.close() + return redirect("/") + +@app.route("/add_miss", methods=["POST"]) +@login_required +def add_miss(): + d = request.form + direction = d.get("direction", "long") + conn = get_db() + insert_trade_record( + conn, + symbol=d["symbol"], + monitor_type=d["type"], + direction=direction, + trigger_price=d["tp"], + stop_loss=d["sl"], + take_profit=d["tgt"], + result="错过", + miss_reason=d["reason"], + opened_at=app_now_str(), + closed_at=app_now_str(), + ) + conn.commit() + conn.close() + flash("已记录错过机会") + return redirect("/records") + + +@app.route("/add_journal", methods=["POST"]) +@login_required +def add_journal(): + d = request.form + entry_reason_norm = normalize_entry_reason(d.get("entry_reason"), d.get("entry_reason_custom")) + if not entry_reason_norm: + flash("请选择开仓类型;若选「其他」请在下方填写自定义说明") + return redirect("/records") + early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger")) + early_exit_note = str(d.get("early_exit_note") or "").strip() + if not early_exit_trigger: + flash("请选择离场触发") + return redirect("/records") + if early_exit_trigger == "手动平仓" and not early_exit_note: + flash("手工平仓必须填写补充说明") + return redirect("/records") + if early_exit_trigger != "手动平仓": + early_exit_note = "" + # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」 + early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" + early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) + exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) + image_filename = None + uploaded_tmp = None + entry_id = uuid.uuid4().hex + file = request.files.get("screenshot") + if file and file.filename: + ext = os.path.splitext(file.filename)[1] + image_filename = f"{uuid.uuid4().hex}{ext}" + save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename)) + file.save(save_path) + uploaded_tmp = image_filename + + mood_issues = ",".join(request.form.getlist("mood_issues")) + hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) + real_rr_text = (d.get("real_rr") or "").strip() + try: + risk_amount_hint = float(d.get("risk_amount_hint") or 0) + pnl_hint = float(d.get("pnl") or 0) + # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 + if risk_amount_hint > 0: + real_rr_text = f"{(pnl_hint / risk_amount_hint):.4f}" + except Exception: + pass + + want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + chart_msg = None + if want_exchange_chart and ORDER_CHART_ENABLED: + coin = (d.get("coin") or "").strip().upper() + symbol_guess = normalize_symbol_input(coin) or coin + exchange_symbol = normalize_exchange_symbol(symbol_guess) + title_prefix = f"{symbol_guess} journal {entry_id[:8]}" + marker_payload = { + "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")), + "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")), + "entry_price": d.get("entry_price_hint"), + "exit_price": None, + } + try: + chart_fname = f"journal_{entry_id}.png" + saved = generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=ORDER_CHART_TFS, + limit=ORDER_CHART_LIMIT, + out_dir=app.config["UPLOAD_FOLDER"], + filename=chart_fname, + filename_prefix="journal", + marker_payload=marker_payload, + marker_timeframes=( + {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()} + if ORDER_CHART_TFS + else {"5m", "15m", "1h", "4h"} + ), + ) + if saved: + image_filename = saved + chart_msg = f"已生成多周期K线图:/static/images/{saved}" + if uploaded_tmp: + try: + old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) + if os.path.exists(old_path): + os.remove(old_path) + except Exception: + pass + else: + chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Gate 网络/代理是否正常。" + except Exception as e: + image_filename = uploaded_tmp + chart_msg = f"自动生成K线图失败:{str(e)}" + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, d.get("open_datetime"), d.get("close_datetime"), hold_duration, d.get("coin"), d.get("tf"), + d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, + early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, + None, None, None, mood_issues, + d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename + ) + ) + conn.commit() + conn.close() + if chart_msg: + flash(f"交易复盘记录已保存。{chart_msg}") + else: + flash("交易复盘记录已保存") + return redirect("/records") + + +@app.route("/api/journals") +@login_required +def api_journals(): + conn = get_db() + rows = conn.execute("SELECT * FROM journal_entries ORDER BY created_at DESC").fetchall() + conn.close() + result = [] + for r in rows: + item = row_to_dict(r) + item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] + result.append(item) + return jsonify(result) + + +@app.route("/api/journal_prefill", methods=["POST"]) +@login_required +def api_journal_prefill(): + file = request.files.get("screenshot") + if not file or not file.filename: + return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400 + try: + raw = file.read() + if not raw: + return jsonify({"ok": False, "msg": "截图为空"}), 400 + image_b64 = base64.b64encode(raw).decode("utf-8") + except Exception as e: + return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400 + + parsed = ai_extract_journal_from_image(image_b64) + if parsed is None: + return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500 + return jsonify({"ok": True, "data": parsed}) + + +@app.route("/delete_journal/", methods=["POST"]) +@login_required +def delete_journal(jid): + conn = get_db() + row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone() + if row and row["image"]: + img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"]) + if os.path.exists(img_path): + os.remove(img_path) + conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/api/reviews") +@login_required +def api_reviews(): + conn = get_db() + rows = conn.execute("SELECT * FROM ai_reviews ORDER BY created_at DESC").fetchall() + conn.close() + return jsonify([row_to_dict(r) for r in rows]) + + +@app.route("/export/review_md/") +@login_required +def export_review_md(rid): + conn = get_db() + row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() + conn.close() + if not row: + return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") + + review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" + target_date = row["target_date"] or "-" + created_at = row["created_at"] or app_now_str() + content = (row["content"] or "").strip() + if not content: + content = "(无内容)" + + md = ( + f"# {review_type}报告\n\n" + f"- 目标日期: {target_date}\n" + f"- 生成时间: {created_at}\n" + f"- 报告ID: {row['id']}\n\n" + f"---\n\n" + f"{content}\n" + ) + + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + safe_type = "daily" if row["review_type"] == "daily" else "weekly" + filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" + return _md_response(filename, md) + + +@app.route("/export/reviews_md_bundle") +@login_required +def export_reviews_md_bundle(): + review_type = (request.args.get("review_type") or "").strip().lower() + target_date = (request.args.get("target_date") or "").strip() + if review_type not in ("daily", "weekly"): + return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") + if not target_date: + return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") + + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", + (review_type, target_date), + ).fetchall() + conn.close() + if not rows: + return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") + + title = "日复盘" if review_type == "daily" else "周复盘" + lines = [ + f"# {title}汇总报告", + "", + f"- 目标日期: {target_date}", + f"- 条目数量: {len(rows)}", + f"- 导出时间: {app_now_str()}", + "", + "---", + "", + ] + for idx, row in enumerate(rows, 1): + created_at = row["created_at"] or "-" + content = (row["content"] or "").strip() or "(无内容)" + lines.extend( + [ + f"## 第{idx}条", + "", + f"- 报告ID: {row['id']}", + f"- 生成时间: {created_at}", + "", + content, + "", + "---", + "", + ] + ) + md = "\n".join(lines) + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" + return _md_response(filename, md) + + +@app.route("/delete_review/", methods=["POST"]) +@login_required +def delete_review(rid): + conn = get_db() + conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/delete_trade_record/", methods=["POST"]) +@login_required +def delete_trade_record(rid): + conn = get_db() + cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) + + +@app.route("/api/trade_record_review_update", methods=["POST"]) +@login_required +def api_trade_record_review_update(): + payload = request.get_json(silent=True) or {} + rec_id = payload.get("id") + try: + rec_id = int(rec_id) + except Exception: + return jsonify({"ok": False, "msg": "记录ID无效"}), 400 + + reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() + reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() + reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") + reviewed_take_profit_raw = payload.get("reviewed_take_profit") + reviewed_result = str(payload.get("reviewed_result") or "").strip() + reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() + reviewed_pnl_raw = payload.get("reviewed_pnl_amount") + + if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: + return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400 + + try: + reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") + reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") + except Exception: + return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 + if reviewed_close_dt < reviewed_open_dt: + return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 + hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) + hold_minutes = calc_hold_minutes(hold_seconds) + + try: + reviewed_pnl_amount = float(reviewed_pnl_raw) + except Exception: + return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 + reviewed_stop_loss = None + if reviewed_stop_loss_raw not in (None, ""): + try: + reviewed_stop_loss = float(reviewed_stop_loss_raw) + except Exception: + return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 + reviewed_take_profit = None + if reviewed_take_profit_raw not in (None, ""): + try: + reviewed_take_profit = float(reviewed_take_profit_raw) + except Exception: + return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 + + _MISSING_ER = object() + reviewed_entry_reason_update = _MISSING_ER + if "reviewed_entry_reason" in payload: + s = str(payload.get("reviewed_entry_reason") or "").strip() + if s and not entry_reason_valid_for_storage(s): + return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400 + reviewed_entry_reason_update = s or None + + conn = get_db() + row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "记录不存在"}), 404 + risk_amount = row["risk_amount"] + actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) + base_params = [ + reviewed_opened_at, + reviewed_closed_at, + reviewed_stop_loss, + reviewed_take_profit, + round(reviewed_pnl_amount, 4), + reviewed_result or None, + reviewed_miss_reason or None, + hold_seconds, + hold_minutes, + app_now_str(), + actual_rr, + ] + if reviewed_entry_reason_update is not _MISSING_ER: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? + WHERE id=?""", + tuple(base_params + [reviewed_entry_reason_update, rec_id]), + ) + else: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr) + WHERE id=?""", + tuple(base_params + [rec_id]), + ) + conn.commit() + conn.close() + return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) + + +@app.route("/manual_transfer", methods=["POST"]) +@login_required +def manual_transfer(): + try: + amount = float(request.form.get("amount", "0")) + except Exception: + flash("划转金额格式错误") + return redirect("/") + from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() + to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() + ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) + conn = get_db() + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + else: + flash(f"手动划转失败:{msg}") + return redirect("/") + + +@app.route("/ai_daily_review", methods=["POST"]) +@login_required +def ai_daily_review(): + date = request.form.get("date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", + (date,) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该日无交易记录"}) + + text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "每日", image_paths=image_paths) + full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "daily", date, full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + + +@app.route("/ai_weekly_review", methods=["POST"]) +@login_required +def ai_weekly_review(): + start_date = request.form.get("start_date", "") + end_date = request.form.get("end_date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", + (start_date, end_date) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该时间段无交易记录"}) + + text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "周度", image_paths=image_paths) + full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + +# 启动 +if __name__ == "__main__": + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/crypto_monitor_gate_bot/crypto.db b/crypto_monitor_gate_bot/crypto.db new file mode 100644 index 0000000000000000000000000000000000000000..8731a8ac1bd0c05fe7e7ff7598c85cd33ae3d595 GIT binary patch literal 65536 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 literal 0 HcmV?d00001 diff --git a/crypto_monitor_gate_bot/ecosystem.config.cjs b/crypto_monitor_gate_bot/ecosystem.config.cjs new file mode 100644 index 0000000..f7ecf24 --- /dev/null +++ b/crypto_monitor_gate_bot/ecosystem.config.cjs @@ -0,0 +1,33 @@ +/** + * PM2 进程定义(Ubuntu / Linux)。 + * + * 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**, + * 与 `.env` 里 `GATE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。 + * + * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。 + * + * 启动: + * pm2 start ecosystem.config.cjs + * 保存开机列表: + * pm2 save && pm2 startup + */ +const path = require("path"); + +const ROOT = __dirname; +const PY = path.join(ROOT, ".venv", "bin", "python"); + +module.exports = { + apps: [ + { + name: "crypto_gate_bot", + cwd: ROOT, + script: path.join(ROOT, "app.py"), + interpreter: PY, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "800M", + // app.py 会从项目根目录加载 .env,此处无需重复 env_file + }, + ], +}; diff --git a/crypto_monitor_gate_bot/scripts/fix_breakeven_labels.py b/crypto_monitor_gate_bot/scripts/fix_breakeven_labels.py new file mode 100644 index 0000000..80b7d04 --- /dev/null +++ b/crypto_monitor_gate_bot/scripts/fix_breakeven_labels.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +一次性修复历史交易记录标签: +将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。 + +默认条件(可通过参数修改): +- monitor_type = 下单监控 +- result = 止损 +- pnl_amount > 0 + +用法示例: +1) 仅预览(不落库): + python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run + +2) 执行修复: + python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +""" + +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.") + parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db") + parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)") + parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)") + parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)") + parser.add_argument("--dry-run", action="store_true", help="Preview only, no write") + parser.add_argument("--apply", action="store_true", help="Execute update") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + db_path = Path(args.db).expanduser().resolve() + if not db_path.exists(): + print(f"[ERR] DB not found: {db_path}") + return 1 + + if args.dry_run and args.apply: + print("[ERR] --dry-run and --apply are mutually exclusive.") + return 1 + if not args.dry_run and not args.apply: + print("[INFO] No mode provided, defaulting to --dry-run.") + args.dry_run = True + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + where_sql = """ + monitor_type = ? + AND result = ? + AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0 + """ + params = (args.monitor_type, args.from_result) + + cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params) + will_change = int(cur.fetchone()["c"]) + print(f"[INFO] Candidate rows: {will_change}") + + if will_change == 0: + print("[INFO] Nothing to update.") + conn.close() + return 0 + + cur.execute( + f""" + SELECT id, symbol, result, pnl_amount, closed_at + FROM trade_records + WHERE {where_sql} + ORDER BY id DESC + LIMIT 10 + """, + params, + ) + sample = cur.fetchall() + print("[INFO] Sample (latest 10):") + for r in sample: + print( + f" id={r['id']} symbol={r['symbol']} result={r['result']} " + f"pnl={r['pnl_amount']} closed_at={r['closed_at']}" + ) + + if args.dry_run: + print("[DRY-RUN] No write executed.") + conn.close() + return 0 + + cur.execute( + f"UPDATE trade_records SET result=? WHERE {where_sql}", + (args.to_result, *params), + ) + changed = int(cur.rowcount) + conn.commit() + conn.close() + print(f"[DONE] Updated rows: {changed}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/crypto_monitor_gate_bot/scripts/verify_gate_funding.py b/crypto_monitor_gate_bot/scripts/verify_gate_funding.py new file mode 100644 index 0000000..bd410a8 --- /dev/null +++ b/crypto_monitor_gate_bot/scripts/verify_gate_funding.py @@ -0,0 +1,93 @@ +""" +在项目根目录执行(会加载根目录 .env): + python scripts/verify_gate_funding.py + +依次探测:[0] swap 余额(与 App「交易账户」同源);[1]–[3] 现货 / 统一账户资金路径。 +打印 GATE_API_KEY 前 8 位便于与 Gate 控制台核对(不含 Secret)。用于服务器自检。 +""" +from __future__ import annotations + +import importlib.util +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + + +def _load_app(): + path = os.path.join(ROOT, "app.py") + spec = importlib.util.spec_from_file_location("crypto_app", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def main(): + os.chdir(ROOT) + mod = _load_app() + print("LIVE_TRADING_ENABLED =", os.getenv("LIVE_TRADING_ENABLED")) + ok, reason = mod.ensure_exchange_live_ready() + print("ensure_exchange_live_ready =", ok, repr(reason)) + if not ok: + print("跳过私有接口探测") + return 1 + + mod.ensure_markets_loaded() + + k = (os.getenv("GATE_API_KEY") or "").strip() + s = (os.getenv("GATE_API_SECRET") or "").strip() + if not k or "REPLACE" in k.upper(): + print("WARN: GATE_API_KEY 为空或仍像占位符,请核对 .env") + if not s or "REPLACE" in s.upper(): + print("WARN: GATE_API_SECRET 为空或仍像占位符,请核对 .env") + print("GATE_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)") + + # 0) swap — 与 App「交易账户」余额同源(优先看此项是否与网页一致) + try: + bal = mod.exchange.fetch_balance({"type": "swap"}) + v0 = mod._extract_usdt_total(bal) + print("[0] fetch_balance(swap) USDT total =", v0) + except Exception as e: + print("[0] fetch_balance(swap) FAILED:", type(e).__name__, e) + + # 1) fetch_balance spot + marginMode spot + try: + bal = mod.exchange.fetch_balance({"type": "spot", "marginMode": "spot"}) + v = mod._extract_usdt_total(bal) + print("[1] fetch_balance(spot,marginMode=spot) USDT total =", v) + except Exception as e: + print("[1] fetch_balance(spot) FAILED:", type(e).__name__, e) + + # 2) raw spot accounts + try: + resp = mod.exchange.privateSpotGetAccounts({}) + v2 = mod._parse_gate_spot_accounts_response_usdt(resp) + print("[2] privateSpotGetAccounts USDT =", v2) + except Exception as e: + print("[2] privateSpotGetAccounts FAILED:", type(e).__name__, e) + + # 3) unified accounts raw + try: + raw = mod.exchange.privateUnifiedGetAccounts({}) + body = raw + if isinstance(body, dict) and isinstance(body.get("result"), dict): + body = body["result"] + if isinstance(body, dict): + keys = sorted(body.keys()) + print("[3] unified top-level keys (sample):", keys[:25], "..." if len(keys) > 25 else "") + v3 = mod._parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None + print("[3] parsed unified USDT =", v3) + except Exception as e: + print("[3] privateUnifiedGetAccounts FAILED:", type(e).__name__, e) + + fu = mod._fetch_gate_funding_usdt() + print(">>> _fetch_gate_funding_usdt() =", fu) + f, t = mod.get_exchange_capitals(force=True) + print(">>> get_exchange_capitals(force=True) funding, trading =", f, t) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/crypto_monitor_gate_bot/start_utf8.ps1 b/crypto_monitor_gate_bot/start_utf8.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..4a8fd0cbe425e232604b47af88dfdd3bd069e311 GIT binary patch literal 1080 zcmcJOPfNo<5XIkF@H;HE2R*dtK@q`&Xw`z$Vighf5YtpEZ4#PjE%@2h-|V)wLg`JG z%}!=^-u!#}{Z&;%BUZ*fQmj&avy5bTN3d>>Rqb|9`=^ ziritv`1L_9b;bRRb1}bDj~nW^(X~#&*vGq1bq?P*yhE*)Pf6qvIh!@2GOH+I7ig?M zh8$B(OgyJO?P{B~gZ#<91kLhpaSm8^`(DT1v*0@b>rn@+CRn+@-9U=5Stn9GgJRvj zr!MQLByc2!!S68TEpZWdu3%Duw#@f`@ir68`|Bqgs_G5LVW!+ho;o@wq2Zf)Y zv9wLcjA=F}hc-;kO*wJu-)oS;FZ0_H?X>%j{~oF$P>^5ug!bjzq%f<%yF$-KLi}HnE(I) literal 0 HcmV?d00001 diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html new file mode 100644 index 0000000..2215b91 --- /dev/null +++ b/crypto_monitor_gate_bot/templates/index.html @@ -0,0 +1,1187 @@ + + + + + {{ exchange_display }} · 加密货币 | 机器人交易监控 + + + +{% macro period_stats(title, s) %} +

+

{{ title }}

+
{{ s.range_label }}
+
+
开单次数
{{ s.opens_count }}
+
平仓笔数
{{ s.closed_count }}
+
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
+
净盈亏(U)
{{ s.net_pnl_u }}
+
亏损额合计(U)
{{ s.loss_sum_u }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ s.max_single_loss }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ s.max_single_profit }}{% else %}-{% endif %}
+
最大回撤(U)
{{ s.max_drawdown_u }}
+
当前连续亏损笔数
{{ s.consecutive_losses }}
+
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ s.worst_day_pnl }}U){% else %}-{% endif %}
+
+
+{% endmacro %} +
+
+

加密货币|Gate 机器人交易监控

+
{{ exchange_display }}
+
+ + {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} + +
+ 数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列): + 交易记录 +
+
+
交易所
{{ exchange_display }}
+
总交易
{{ total }}
+
错过次数
{{ miss_count }}
+
胜率
{{ rate }}%
+
资金账户(USDT)
{% if funding_usdt is not none %}{{ funding_usdt }}U{% else %}—{% endif %}
+
交易日
{{ trading_day }}
+
当日资金(交易账户)
{{ current_capital }}U
+
+
实时价格更新时间:--(北京时间 UTC+8)
+ +
+ {% if page == 'trade' %} +
+
+

机器人下单监控(单仓)

+ {% if focus_order_id %} + 放大查看K线(100根) + {% else %} + 暂无持仓可放大 + {% endif %} +
+
+ 规则:单仓;与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; + {% if can_trade %}可开仓{% else %}不可开仓(有持仓、有趋势回调计划,或未到北京时间 {{ reset_hour }}:00){% endif %}; + 按风险比例自动计算仓位 +
+
+ 以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% +
+
+ 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }}) +
+
+ + + + +
+
+ + + + + + + + 成交价自动取交易所实时+成交回报 + + + + + +
+
+ {% for o in order %} +
+
{{ o.symbol }} | {{ '做多' if o.direction == 'long' else '做空' }}
+
+ 风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{{ o.risk_amount or '-' }}U + | {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ o.breakeven_price or '-' }}{% else %}移动保本:关{% endif %} +
+ 成交:{{ o.trigger_price }} 止损:{{ o.stop_loss }} 止盈:{{ o.take_profit }} + | 盈亏比:{% if o.rr_ratio is not none %}1:{{ '%.2f'|format(o.rr_ratio) }}{% else %}-{% endif %} + | 现价:- + | 浮盈亏:- + | 计划基数:{{ o.margin_capital }}U | 所保证金:- + | 杠杆:{{ o.leverage }}x | 仓位占比:{{ o.position_ratio }}% +
+ 平仓 +
+ {% endfor %} +
+
+ +
+

趋势回调策略

+
+ ① 生成预览:读取合约 USDT 可用余额快照并计算计划(不下单)。预览有效期 {{ trend_pullback_preview_ttl }} 秒
+ ② 确认执行:市价首仓 50% + 挂交易所止损;剩余 50% 在止损与补仓上沿之间共 {{ trend_pullback_dca_legs }} 档(程序可能因最小张数自动减档)市价补仓;止盈由程序监控
+ 确认执行时若当前可用余额与预览快照相对偏差 > {{ trend_preview_max_drift_pct }}% 会拒绝并要求重新预览。 +
+
+ + + + + + + + +
+ + {% if trend_preview %} +
+
+ 当前预览(剩余 {{ trend_pullback_preview_ttl }}s) + 倒计时加载中… +
+
+ {{ trend_preview.symbol }} {{ '做多' if trend_preview.direction == 'long' else '做空' }} {{ trend_preview.leverage }}x | + 预览可用快照 {{ trend_preview.snapshot_available_usdt }} U | 参考价 {{ trend_preview.live_price_ref }} | + 计划保证金≈{{ trend_preview.plan_margin_capital }} U | 总张≈{{ trend_preview.target_order_amount }}(首仓 {{ trend_preview.first_order_amount }} + 补仓 {{ trend_preview.remainder_total }})
+ 止损 {{ trend_preview.stop_loss }} | 补仓上沿 {{ trend_preview.add_upper }} | 止盈 {{ trend_preview.take_profit }} | 风险比例 {{ trend_preview.risk_percent }}% +
+
+ + + {% for row in trend_preview_levels %} + + {% endfor %} +
#补仓触发价该档张数
{{ row.i }}{{ row.price }}{{ row.contracts }}
+
+
+
+ + +
+
+ + +
+
+
+ + {% elif trend_preview_expired %} +
该预览已过期(超过 {{ trend_pullback_preview_ttl }} 秒),请重新点击「生成预览」。
+ {% endif %} + +

运行中的计划

+
+ {% for t in trend_plans %} +
+
#{{ t.id }} {{ t.symbol }} | {{ '做多' if t.direction == 'long' else '做空' }} | {{ t.leverage }}x
+
+ 可用快照:{{ t.snapshot_available_usdt }}U | 计划保证金≈{{ t.plan_margin_capital }}U | 总张≈{{ t.target_order_amount }} 首仓{{ t.first_order_amount }} 补仓档{{ t.dca_legs }} +
止损:{{ t.stop_loss }} 补仓上沿:{{ t.add_upper }} 止盈:{{ t.take_profit }} +
均价:{{ t.avg_entry_price }} 已补仓:{{ t.legs_done }}/{{ t.dca_legs }} +
+ 结束计划 +
+ {% else %} +
暂无运行中的趋势回调计划
+ {% endfor %} +
+
+ {% endif %} + + {% if page == 'records' %} +
+

交易记录

+
+ + + {% for r in record %} + + {% set pnl_val = (r.pnl_amount or 0)|float %} + + + + + {% set stop_show = r.effective_stop_loss or r.initial_stop_loss or r.stop_loss %} + {% set tp_show = r.effective_take_profit or r.take_profit %} + + + + + + + + {% set pnl_val = (r.effective_pnl_amount or 0)|float %} + + + + + {% endfor %} +
品种类型方向成交止损止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
{{ r.symbol }}{{ r.monitor_type }}{{ '做多' if r.direction == 'long' else '做空' }}{{ r.trigger_price }}{{ price_fmt(r.symbol, stop_show) }}{{ price_fmt(r.symbol, tp_show) }}{{ r.margin_capital or '-' }}{{ r.leverage or '-' }}{{ r.effective_hold_minutes or 0 }}{{ (r.effective_opened_at or '-')[:16] }}{{ (r.effective_closed_at or r.created_at or '-')[:16] }}{{ r.effective_pnl_amount or 0 }} + {% set effective_result = r.effective_result %} + {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} + {% elif effective_result in ["止损","强制清仓","手动平仓"] %}{{ effective_result }} + {% else %}{{ effective_result }}{% endif %} + + +
+
+
+ +
+

记录错过机会

+
+ + + + + + + + +
+
+ + {% endif %} +
+ + {% if page == 'stats' %} +
+
+

数据统计

+ +
+
+
+
持仓占用导致错过(累计)
{{ occupied_miss_total }}
+
+
+ 已平仓「机器人下单 / 趋势回调」按平仓时间归入北京时间下的交易日;胜率按盈笔数/(盈+亏)。历史总开仓(累计): + {{ stats_bundle.total_opens_all }} 次 +
+ {{ period_stats("日统计", stats_bundle.day) }} + {{ period_stats("周统计", stats_bundle.week) }} + {{ period_stats("月统计", stats_bundle.month) }} +
+
+ {% endif %} +
+ + +
+
+
+
详情
+ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/crypto_monitor_gate_bot/templates/key_focus.html b/crypto_monitor_gate_bot/templates/key_focus.html new file mode 100644 index 0000000..41a633a --- /dev/null +++ b/crypto_monitor_gate_bot/templates/key_focus.html @@ -0,0 +1 @@ +ok2 \ No newline at end of file diff --git a/crypto_monitor_gate_bot/templates/key_focus_v2.html b/crypto_monitor_gate_bot/templates/key_focus_v2.html new file mode 100644 index 0000000..d4b3492 --- /dev/null +++ b/crypto_monitor_gate_bot/templates/key_focus_v2.html @@ -0,0 +1,261 @@ + + + + + {{ exchange_display }} | 关键位放大 + + + +
+
+
+
+ 返回首页 + 关键位放大(可输入币种){{ exchange_display }} +
+
最近刷新:--
+
+ +
+ + + + + + + + + + + + + + +
+
+ +
+
+
交易对
-
+
监控类型
-
+
方向
-
+
上沿/阻力
-
+
下沿/支撑
-
+
现价
-
+
距上沿
-
+
距下沿
-
+
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/crypto_monitor_gate_bot/templates/login.html b/crypto_monitor_gate_bot/templates/login.html new file mode 100644 index 0000000..cfcc816 --- /dev/null +++ b/crypto_monitor_gate_bot/templates/login.html @@ -0,0 +1,118 @@ + + + + + 登录 · {{ exchange_display }} + + + + + + diff --git a/crypto_monitor_gate_bot/templates/order_focus.html b/crypto_monitor_gate_bot/templates/order_focus.html new file mode 100644 index 0000000..0811e93 --- /dev/null +++ b/crypto_monitor_gate_bot/templates/order_focus.html @@ -0,0 +1,194 @@ + + + + + 实盘下单放大 | 100根K线 + + + +
+
+
+
+ 返回首页 + 实盘下单放大(100根K线) +
+
最近刷新:--
+
+ {% if orders %} +
+ + + + + + +
+ {% else %} +
当前没有激活订单,无法展示放大K线。
+ {% endif %} +
+ + {% if orders %} +
+
+
交易对
-
+
方向
-
+
成交价
-
+
止损
-
+
止盈
-
+
盈亏比
-
+
现价
-
+
浮盈亏
-
+
+
+ +
+
+
+ {% endif %} +
+ +{% if orders %} + + +{% endif %} + + diff --git a/crypto_monitor_gate_bot/templates/order_focus_v2.html b/crypto_monitor_gate_bot/templates/order_focus_v2.html new file mode 100644 index 0000000..9c9add3 --- /dev/null +++ b/crypto_monitor_gate_bot/templates/order_focus_v2.html @@ -0,0 +1,214 @@ + + + + + {{ exchange_display }} | 实盘下单放大 + + + +
+
+
+
+ 返回首页 + 实盘下单放大(100根K线){{ exchange_display }} +
+
最近刷新:--
+
+ {% if orders %} +
+ + + + + + +
+ {% else %} +
当前没有激活订单,无法展示放大K线。
+ {% endif %} +
+ + {% if orders %} +
+
+
交易对
-
+
方向
-
+
成交价
-
+
止损
-
+
止盈
-
+
盈亏比
-
+
移动保本
-
+
现价
-
+
浮盈亏
-
+
+
+ +
+
+
+ {% endif %} +
+ +{% if orders %} + + +{% endif %} + + + diff --git a/crypto_monitor_gate_bot/趋势回调策略说明.md b/crypto_monitor_gate_bot/趋势回调策略说明.md new file mode 100644 index 0000000..cbf6d5e --- /dev/null +++ b/crypto_monitor_gate_bot/趋势回调策略说明.md @@ -0,0 +1,91 @@ +# 趋势回调策略(机器人)说明 + +本文描述本仓库内 **「趋势回调」** 自动交易计划的业务规则与实现口径,便于单独策略账户使用与审计。 + +--- + +## 1. 适用场景 + +- 单独用于跑策略的 **Gate.io USDT 永续** 子账户(建议与主资金隔离)。 +- 你已明确:**方向、止损价、补仓上沿、止盈价、杠杆**,并接受程序按风险预算拆分 **首仓 50% + 多档补仓 50%**。 + +--- + +## 2. 名词与参数 + +| 名称 | 含义 | +|------|------| +| **合约 USDT 可用余额** | **生成预览**时通过 API 读取的 **swap 账户 USDT `free`** 快照;**确认执行**时再次读取并与快照比对偏差。 | +| **风险比例** | 默认 **5%**:指「若整笔计划在 **补仓上沿** 这一侧的最坏价格结构下触及止损」,目标亏损上限约为 **可用余额快照 × 风险比例**(实现上用 `calc_risk_fraction` 与 `prepare_order_amount` 反推总张数,受交易所最小张数与精度约束)。 | +| **止损价** | 用户填写;开仓后挂 **交易所仓位类止损触发单**(全平)。 | +| **补仓上沿** | 用户填写;**仅在该价位与止损价构成的区间内** 才允许程序触发剩余 50% 的市价补仓(做多:`止损 < 补仓上沿`;做空:`止损 > 补仓上沿`)。 | +| **止盈价** | 用户填写的 **固定价格**;**不由交易所条件止盈单触发**,由应用后台 **按标记价/行情价轮询**,达到后 **市价全平**。 | +| **杠杆** | 计划内固定写入;用于 `set_leverage` 与名义换算。 | +| **补仓档位数** | 默认 **5** 档(环境变量 `TREND_PULLBACK_DCA_LEGS` 可调);程序在满足最小张数前提下可能 **自动减少档数**。 | + +--- + +## 3. 执行流程(时间顺序) + +### 3.1 预览阶段(不下单) + +1. **风控**:与「机器人下单监控」**互斥**——存在活跃机器人持仓或运行中趋势计划时,不可生成预览。 +2. **读取可用余额快照** `get_available_trading_usdt()`,失败则拒绝。 +3. **计算**(写入表 `trend_pullback_previews`,并跳转带 `preview_id`): + - 在 **补仓上沿 ↔ 止损** 区间内生成 `N` 个补仓触发价; + - 将 **剩余 50% 计划张数** 拆成 `N` 份写入 `leg_amounts_json`。 +4. **预览有效期**:默认 **120 秒**(`TREND_PULLBACK_PREVIEW_TTL_SECONDS`),超时须重新点「生成预览」。 + +### 3.2 确认执行(实盘) + +5. 再次校验:预览未过期;**当前可用余额**与预览快照相对偏差 ≤ `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT`(默认 **5%**),否则拒绝执行并要求重新预览。 +6. **首仓**:**立即市价** 开立 **总计划张数 × 50%**(不附带交易所止盈单)。 +7. **止损**:撤销旧条件单后,挂 **仅止损** 的仓位触发单;之后每次补仓成交会 **刷新** 止损挂单。 +8. **补仓**:当价格 **穿越** 下一档触发价(做多为自上向下穿越)时,按该档张数 **市价加仓**;直至 `N` 档执行完毕或计划结束。 +9. **止盈监控**:后台线程若发现价格触及止盈,则 **市价全平**。 +10. **止损触发**:若仓位被交易所止损打光,本地检测到 **持仓为 0** 后记账为 **止损** 并结束计划。 +11. **计划结束**:任一结束路径(止盈 / 止损 / 用户手动结束)均会 **撤单**(条件单 + 普通挂单,尽力而为)。 + +### 3.3 取消预览 + +用户可「取消预览」删除 `trend_pullback_previews` 中对应记录;过期记录会在新预览或页面加载时清理。 + +--- + +## 4. 与「机器人下单监控」的差异 + +| 项目 | 机器人下单监控 | 趋势回调 | +|------|------------------|----------| +| 开仓 | 单次市价 + 条件止盈+止损 | 首仓 50% 市价 + 多档补仓 + **仅止损在交易所** | +| 止盈 | 条件单 + 本地监控 | **仅本地监控市价止盈** | +| 仓位基数 | 以损定仓(表单/会话基数) | **可用余额快照 × 风险比例** 推导 | +| 移动保本 | 支持 | **不支持**(未实现) | + +--- + +## 5. 风险声明(必读) + +- 市价单存在 **滑点**;极端行情下实际亏损可能 **大于** 理论 5%。 +- 补仓触发依赖应用 **轮询间隔**(`MONITOR_POLL_SECONDS`),非毫秒级高频。 +- 交易所 **最小张数 / 精度** 可能导致计划张数被截断,实际风险略低于或偏离纸面计算。 +- 请使用 **单独 API Key / 子账户**,并先在 `LIVE_TRADING_ENABLED=false` 环境验证流程(若需沙盒请自行对接测试网,本仓库默认实盘接口)。 + +--- + +## 6. 相关环境变量 + +| 变量 | 说明 | 默认 | +|------|------|------| +| `TREND_PULLBACK_DCA_LEGS` | 剩余 50% 拆档数量上限 | `5` | +| `TREND_PULLBACK_PREVIEW_TTL_SECONDS` | 预览有效时间(秒) | `120` | +| `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT` | 确认执行时允许「当前可用 / 预览快照」最大相对偏差(%) | `5` | +| `MONITOR_POLL_SECONDS` | 监控轮询间隔(秒) | `3` | +| `LIVE_TRADING_ENABLED` | 是否允许真实下单 | `false` | +| `FULL_MARGIN_BUFFER_RATIO` | 计划保证金相对可用余额上限比例 | `0.98` | + +--- + +## 7. 数据库 + +- **`trend_pullback_previews`**:未执行的预览行(含 `expires_at_ms`),执行成功或取消后删除;过期可被清理。 +- **`trend_pullback_plans`**:已执行且运行中的计划;字段含快照可用余额、计划保证金、总张数、首仓张数、补仓 JSON、网格价 JSON、已补仓档数、均价、状态等。平仓结果写入 `trade_records`,`monitor_type` 为 **`趋势回调`**。 diff --git a/crypto_monitor_gate_bot/部署文档.md b/crypto_monitor_gate_bot/部署文档.md new file mode 100644 index 0000000..809cab8 --- /dev/null +++ b/crypto_monitor_gate_bot/部署文档.md @@ -0,0 +1,273 @@ +# `crypto_monitor_gate` 部署指南:SSH SOCKS + Gate.io + PM2(Ubuntu) + +本文面向:**在本机运行本项目**,但 **直连 Gate.io API 不稳定或被重置** 的场景。思路是: + +- 本机用 `ssh -D` 做动态转发,把 **SOCKS5 出口**放到能正常访问 Gate 的机器(常见为一台境外 VPS) +- 项目在 `.env` 中设置 **`GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080`**(或你实际端口),`ccxt` 经 SOCKS 访问交易所 +- **SSH 隧道**:用 `ssh -D` 在本机常驻即可(screen / tmux / systemd 等),**不必交给 PM2** +- 使用 **PM2** 仅托管 **Flask 应用**;仓库根目录 **`ecosystem.config.cjs`** 只定义 `crypto-monitor-gate` + +> 安全提醒:不要把 `.env`、私钥 `.pem`、Gate API Key 提交到 Git;下文只用占位符。 + +--- + +## 0. 你需要准备的东西 + +- 一台 **Ubuntu**(或同类 Linux)运行项目的机器(下文称「本机」) +- 一台可 SSH 登录、且 **能正常访问 Gate.io API** 的 VPS(示例:`HostName` 填你的服务器 IP,用户如 `root`) +- SSH:**私钥登录**(推荐,便于隧道脚本无人值守) +- 本机已安装:`python3`、`python3-venv`、`pip`、`curl`、`ssh`、`git`(可选)、`node` + `npm`(安装 PM2) + +--- + +## 1. 获取代码与目录 + +将包含 `app.py` 的项目放到固定目录,例如: + +```bash +mkdir -p ~/apps +cd ~/apps +# git clone ... 或解压同步的包 +cd crypto_monitor_gate +``` + +下文用 **`/root/crypto_monitor_gate`** 仅为示例,请换成你的实际绝对路径。 + +--- + +## 2. 配置 SSH 私钥与 `~/.ssh/config` + +```bash +mkdir -p ~/.ssh +chmod 700 ~/.ssh +# 私钥示例:~/.ssh/vps1.pem +chmod 600 ~/.ssh/vps1.pem +``` + +编辑 `~/.ssh/config`(示例别名 **`gate-vps`**,与你手工启动 `ssh -D ... gate-vps` 一致即可): + +```sshconfig +Host gate-vps + HostName 你的_VPS_IP + User root + IdentityFile ~/.ssh/vps1.pem + IdentitiesOnly yes + ServerAliveInterval 30 + ServerAliveCountMax 3 + ExitOnForwardFailure yes + BatchMode yes +``` + +测试: + +```bash +ssh gate-vps true +``` + +> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开。 + +--- + +## 3. 手工验证:SSH SOCKS + Gate API + +### 3.1 本地 SOCKS(示例端口 1080) + +```bash +ssh -N -D 127.0.0.1:1080 gate-vps +``` + +保持运行,另开终端继续。 + +### 3.2 验证经 SOCKS 可访问 Gate + +```bash +curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time +``` + +应返回 JSON(含服务器时间字段)。若此处失败,**不要先启动应用**:先修隧道或 VPS 出站。 + +--- + +## 4. Python 虚拟环境 + +```bash +cd /root/crypto_monitor_gate + +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -U pip +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错。 + +可选: + +```bash +export PYTHONDONTWRITEBYTECODE=1 +``` + +--- + +## 5. 配置 `.env`(关键:Gate + 代理) + +项目通过 `app.py` 启动时 **自动加载项目根目录的 `.env`**。与交易所相关的变量必须是 **Gate** 前缀(**不要**再写 OKX 变量,否则代理不会生效、密钥也不会被识别)。 + +至少确认: + +```env +APP_HOST=127.0.0.1 +APP_PORT=5000 + +# 实盘(按需) +LIVE_TRADING_ENABLED=false +GATE_API_KEY=你的_Key +GATE_API_SECRET=你的_Secret + +# 经本机 SSH 动态转发访问 Gate(端口与隧道一致) +GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + +# 若不用 SOCKS,可改用 HTTP 代理(一般二选一) +# GATE_HTTP_PROXY=http://127.0.0.1:7890 +# GATE_HTTPS_PROXY=http://127.0.0.1:7890 +``` + +说明:**推荐 `socks5h://`**,由 SOCKS 端解析域名,与 `curl --proxy socks5h://...` 行为一致。 + +### 5.1 趋势回调策略(可选) + +若使用「交易执行」页的 **趋势回调** 计划: + +- 详细规则见项目根目录 **`趋势回调策略说明.md`**。 +- **两阶段**:先「生成预览」(默认 **120 秒**内有效),再「确认执行」;执行时若可用余额与预览快照偏差超过 **5%** 会拒绝(可调 `.env`)。 +- 补仓档位数默认 **5**,预览有效期与余额偏差阈值可在 `.env` 覆盖: + +```env +TREND_PULLBACK_DCA_LEGS=5 +TREND_PULLBACK_PREVIEW_TTL_SECONDS=120 +TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT=5 +``` + +- **生成预览**与**确认执行**时都会读取 **Gate 永续账户 USDT 可用余额**;请尽量使用 **单独子账户** 承载策略资金。 + +--- + +## 6. 手工启动 Flask(验证) + +1. SOCKS 已监听 `127.0.0.1:1080` +2. 已 `source .venv/bin/activate` +3. `.env` 已含 `GATE_SOCKS_PROXY` + +```bash +cd /root/crypto_monitor_gate +source .venv/bin/activate +python app.py +``` + +浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口)。 + +--- + +## 7. 安装 PM2 + +```bash +sudo npm i -g pm2 +pm2 -v +``` + +--- + +## 8. PM2:使用仓库内 `ecosystem.config.cjs`(推荐) + +在项目根目录: + +```bash +cd /root/crypto_monitor_gate +pm2 start ecosystem.config.cjs +pm2 status +pm2 logs --lines 200 +``` + +默认只启动 **`crypto-monitor-gate`**(`.venv/bin/python app.py`)。 + +### 本机已可直连 Gate、不需要隧道时 + +`.env` 里应 **去掉或留空** `GATE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`。 + +### 开机自启 + +```bash +pm2 save +pm2 startup +# 按屏幕提示执行一条 sudo 命令 +``` + +--- + +## 9. 等价手工命令(不使用 ecosystem 文件时) + +### 9.1 SSH SOCKS(自行后台常驻,不推荐用 PM2) + +示例(前台;实际可用 `screen`/`tmux`/`-f` 后台化或 systemd): + +```bash +ssh -N -D 127.0.0.1:1080 gate-vps \ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ + -o ExitOnForwardFailure=yes +``` + +### 9.2 Flask + +```bash +cd /root/crypto_monitor_gate +pm2 start /root/crypto_monitor_gate/.venv/bin/python --name crypto-monitor-gate -- \ + /root/crypto_monitor_gate/app.py +``` + +--- + +## 10. 交易所「连接不上」排查清单 + +1. **`.env` 是否为 Gate 变量**:必须是 `GATE_SOCKS_PROXY` / `GATE_API_KEY` / `GATE_API_SECRET`,不是 OKX。 +2. **隧道是否在本机端口监听**(若配置了 `GATE_SOCKS_PROXY`): + ```bash + ss -lntp | grep 1080 || true + ``` +3. **curl 复测 Gate**(与第 3.2 节相同);curl 不通则应用也不会通。 +4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`。 +5. **SSH 隧道连不上**:检查私钥权限、`~/.ssh/config`、VPS 出站与端口是否与 `.env` 一致。 +6. **启动顺序**:先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用)。 + +--- + +## 11. 推荐启动顺序(习惯) + +1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time` 成功 +2. `pm2 start ecosystem.config.cjs` +3. 再确认页面与余额等接口正常 + +--- + +## 12. 免责声明 + +交易所有合规与地区政策要求。请确保使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署路径。 + +--- + +## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py` + +在 Ubuntu 上: + +1)预览(不写库): + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run +``` + +2)确认后执行: + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +``` + +默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`。 diff --git a/crypto_monitor_okx/.env b/crypto_monitor_okx/.env index 992f7a5..62f9729 100644 --- a/crypto_monitor_okx/.env +++ b/crypto_monitor_okx/.env @@ -2,7 +2,7 @@ APP_ENV=production # 服务监听地址(云服务器通常用 0.0.0.0) APP_HOST=0.0.0.0 # 服务端口 -APP_PORT=5000 +APP_PORT=5004 # 是否开启调试模式(生产建议 false) APP_DEBUG=false diff --git a/crypto_monitor_okx/ecosystem.config.cjs b/crypto_monitor_okx/ecosystem.config.cjs index 40cc9bd..0cff0d7 100644 --- a/crypto_monitor_okx/ecosystem.config.cjs +++ b/crypto_monitor_okx/ecosystem.config.cjs @@ -19,7 +19,7 @@ const PY = path.join(ROOT, ".venv", "bin", "python"); module.exports = { apps: [ { - name: "crypto-monitor", + name: "crypto_okx", cwd: ROOT, script: path.join(ROOT, "app.py"), interpreter: PY,