7410 lines
289 KiB
Python
7410 lines
289 KiB
Python
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__))
|
||
_REPO_ROOT = os.path.dirname(BASE_DIR)
|
||
import sys
|
||
|
||
if _REPO_ROOT not in sys.path:
|
||
sys.path.insert(0, _REPO_ROOT)
|
||
from hub_auth import request_allowed as hub_request_allowed
|
||
from history_window_lib import (
|
||
PRESET_CUSTOM,
|
||
PRESET_UTC_LAST24H,
|
||
PRESET_UTC_LAST7D,
|
||
PRESET_UTC_TODAY,
|
||
list_window_redirect_query,
|
||
normalize_bj_datetime_storage,
|
||
resolve_list_window,
|
||
resolve_window,
|
||
sql_list_time_field,
|
||
utc_window_to_bj_sql_strings,
|
||
)
|
||
|
||
|
||
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")
|
||
|
||
|
||
def trend_add_zone_label(direction):
|
||
"""趋势回调:做多=补仓上沿,做空=补仓下沿(库字段仍为 add_upper)。"""
|
||
return "补仓上沿" if (direction or "long").strip().lower() == "long" else "补仓下沿"
|
||
|
||
|
||
@app.context_processor
|
||
def _inject_trend_ui_helpers():
|
||
return {"trend_add_zone_label": trend_add_zone_label}
|
||
|
||
|
||
# ====================== 登录配置 ======================
|
||
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"))
|
||
# 与 Gate 主站一致:最大同时 active 下单监控数(默认 1=单仓)
|
||
MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
|
||
# 交易日滚动与「可开仓」整点:按应用本地时区 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")
|
||
# 交易所「平仓历史」同步:自北京日期 00:00 起(与 APP_TIMEZONE 一致);空则取最近 90 天
|
||
EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip()
|
||
EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200"))))
|
||
|
||
_LAST_POSITION_HISTORY_SYNC_AT = 0.0
|
||
|
||
|
||
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"))
|
||
# 趋势回调:手动保本默认相对均价偏移(%);多=均价×(1+pct/100),空=均价×(1-pct/100)
|
||
TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT = float(
|
||
os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")
|
||
)
|
||
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
|
||
|
||
|
||
_BREAKEVEN_EXCHANGE_WARNED_IDS = set()
|
||
|
||
|
||
def _send_breakeven_exchange_warn_once(order_id, message):
|
||
"""移动保本同步交易所失败:同一笔监控单只推送一次,避免轮询刷屏。"""
|
||
oid = int(order_id)
|
||
if oid in _BREAKEVEN_EXCHANGE_WARNED_IDS:
|
||
return
|
||
_BREAKEVEN_EXCHANGE_WARNED_IDS.add(oid)
|
||
send_wechat_msg(message)
|
||
|
||
|
||
def _clear_breakeven_exchange_warn(order_id):
|
||
_BREAKEVEN_EXCHANGE_WARNED_IDS.discard(int(order_id))
|
||
|
||
|
||
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 _marker_tag_label(tag):
|
||
t = str(tag or "").strip().upper()
|
||
if t == "ENTRY":
|
||
return "开仓"
|
||
if t == "EXIT":
|
||
return "平仓"
|
||
return str(tag or "")
|
||
|
||
|
||
def _timeframe_period_ms(tf):
|
||
s = (tf or "").strip().lower()
|
||
if s.endswith("m"):
|
||
try:
|
||
return int(s[:-1]) * 60 * 1000
|
||
except ValueError:
|
||
pass
|
||
if s.endswith("h"):
|
||
try:
|
||
return int(s[:-1]) * 3600 * 1000
|
||
except ValueError:
|
||
pass
|
||
if s.endswith("d"):
|
||
try:
|
||
return int(s[:-1]) * 86400 * 1000
|
||
except ValueError:
|
||
pass
|
||
return 300000
|
||
|
||
|
||
def _ohlcv_dict_rows_to_lists(rows, lim):
|
||
if not rows:
|
||
return []
|
||
pick = rows[-lim:] if len(rows) >= lim else rows
|
||
return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick]
|
||
|
||
|
||
def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms):
|
||
"""以 end_ts_ms 为终点向前取 K 线(无 end 则拉最近 limit 根)。"""
|
||
lim = max(2, int(limit or ORDER_CHART_LIMIT))
|
||
try:
|
||
if not end_ts_ms:
|
||
ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim)
|
||
else:
|
||
period = _timeframe_period_ms(timeframe)
|
||
since = int(end_ts_ms) - period * (lim + 10)
|
||
ohlcv = exchange.fetch_ohlcv(
|
||
exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20
|
||
)
|
||
except Exception:
|
||
return []
|
||
rows = _ohlcv_to_rows(ohlcv)
|
||
if not rows:
|
||
return []
|
||
if not end_ts_ms:
|
||
return _ohlcv_dict_rows_to_lists(rows, lim)
|
||
filtered = [r for r in rows if int(r["ts"]) <= int(end_ts_ms)]
|
||
if len(filtered) >= 2:
|
||
return _ohlcv_dict_rows_to_lists(filtered, lim)
|
||
return _ohlcv_dict_rows_to_lists(rows, lim)
|
||
|
||
|
||
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.setdefault(idx, []).append(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)
|
||
for j, mp in enumerate(marker_by_idx.get(i, [])):
|
||
tag = str(mp.get("tag") or "")
|
||
label = _marker_tag_label(tag)
|
||
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))
|
||
x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14
|
||
x_draw = int(x_mid + x_off)
|
||
if tag == "ENTRY":
|
||
m_color = (0, 195, 95)
|
||
tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)]
|
||
text_y = y_m - 36
|
||
else:
|
||
m_color = (235, 65, 65)
|
||
tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)]
|
||
text_y = y_m + 12
|
||
draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1)
|
||
draw.polygon(tri, fill=m_color)
|
||
draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3)
|
||
if font:
|
||
draw.text((x_draw + 8, text_y), label, fill=m_color, font=font)
|
||
else:
|
||
draw.text((x_draw + 8, text_y), label, 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
|
||
end_ts_ms = None
|
||
if marker_payload:
|
||
try:
|
||
end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None
|
||
except (TypeError, ValueError):
|
||
end_ts_ms = None
|
||
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
|
||
for tf in timeframes:
|
||
try:
|
||
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
|
||
if not ohlcv and end_ts_ms:
|
||
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()
|
||
if marker_payload:
|
||
if marker_timeframes:
|
||
marker_tfs = {str(x).strip().lower() for x in marker_timeframes if str(x).strip()}
|
||
else:
|
||
marker_tfs = default_marker_tfs
|
||
else:
|
||
marker_tfs = set()
|
||
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,
|
||
opened_at_ms=None,
|
||
entry_price=None,
|
||
):
|
||
marker_payload = None
|
||
if opened_at_ms:
|
||
marker_payload = {
|
||
"entry_ts_ms": opened_at_ms,
|
||
"exit_ts_ms": None,
|
||
"entry_price": entry_price,
|
||
"exit_price": None,
|
||
}
|
||
marker_tfs = (
|
||
{x.strip().lower() for x in (timeframes or ORDER_CHART_TFS) if x and str(x).strip()}
|
||
or {"5m", "15m", "1h", "4h"}
|
||
)
|
||
return generate_multi_timeframe_chart_png(
|
||
exchange_symbol,
|
||
title_prefix,
|
||
timeframes=timeframes,
|
||
limit=limit,
|
||
out_dir=ORDER_CHART_DIR,
|
||
filename=None,
|
||
filename_prefix="order",
|
||
marker_payload=marker_payload,
|
||
marker_timeframes=marker_tfs,
|
||
)
|
||
|
||
|
||
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 trade_records ADD COLUMN trend_plan_id INTEGER")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT")
|
||
except Exception:
|
||
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
|
||
for ddl in (
|
||
"ALTER TABLE trend_pullback_plans ADD COLUMN initial_stop_loss REAL",
|
||
"ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied INTEGER DEFAULT 0",
|
||
"ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied_at TEXT",
|
||
):
|
||
try:
|
||
c.execute(ddl)
|
||
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
|
||
)"""
|
||
)
|
||
|
||
c.execute(
|
||
"""CREATE TABLE IF NOT EXISTS trend_pullback_preview_snapshots (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
preview_id TEXT NOT NULL UNIQUE,
|
||
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,
|
||
preview_created_at TEXT,
|
||
outcome TEXT DEFAULT 'open',
|
||
executed_plan_id INTEGER
|
||
)"""
|
||
)
|
||
for ddl in (
|
||
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN preview_created_at TEXT",
|
||
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN outcome TEXT DEFAULT 'open'",
|
||
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN executed_plan_id INTEGER",
|
||
):
|
||
try:
|
||
c.execute(ddl)
|
||
except Exception:
|
||
pass
|
||
|
||
from strategy_db import init_strategy_tables
|
||
|
||
init_strategy_tables(conn)
|
||
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 _count_trend_plan_opens_between(conn, start_td, end_td):
|
||
"""趋势回调:按计划在库里的 session_date(开仓所属北京交易日)计数。"""
|
||
return conn.execute(
|
||
"SELECT COUNT(*) FROM trend_pullback_plans WHERE session_date IS NOT NULL AND TRIM(session_date) != '' "
|
||
"AND session_date >= ? AND session_date <= ?",
|
||
(start_td, end_td),
|
||
).fetchone()[0]
|
||
|
||
|
||
def _load_completed_trade_pnls(conn, monitor_type: str):
|
||
"""已平仓实盘记录:按 monitor_type 过滤;趋势回调优先用交易所同步盈亏。"""
|
||
q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at,
|
||
result, reviewed_result, exchange_realized_pnl
|
||
FROM trade_records
|
||
WHERE monitor_type = ?
|
||
ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC"""
|
||
rows = conn.execute(q, (monitor_type,)).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:
|
||
if monitor_type == MONITOR_TYPE_TREND:
|
||
ex = None
|
||
try:
|
||
ex = r["exchange_realized_pnl"]
|
||
except (KeyError, IndexError, TypeError):
|
||
ex = None
|
||
if ex is not None and str(ex).strip() != "":
|
||
p = float(ex)
|
||
else:
|
||
p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0))
|
||
else:
|
||
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_order = _load_completed_trade_pnls(conn, "下单监控")
|
||
pnls_trend = _load_completed_trade_pnls(conn, MONITOR_TYPE_TREND)
|
||
total_opens_order = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0]
|
||
total_opens_trend = conn.execute("SELECT COUNT(*) FROM trend_pullback_plans").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_range = f"北京时间交易日 {trading_day}"
|
||
week_range = f"{w_start} ~ {w_end}(北京日期,近7天窗口)"
|
||
month_range = f"{m_start} ~ {m_end}(北京时间自然月)"
|
||
|
||
day_o = [tr for tr in pnls_order if tr[2] == trading_day]
|
||
day_t = [tr for tr in pnls_trend if tr[2] == trading_day]
|
||
dm_o = _compute_period_metrics(day_o)
|
||
dm_t = _compute_period_metrics(day_t)
|
||
dm_o["opens_count"] = _count_opens_between(conn, trading_day, trading_day)
|
||
dm_t["opens_count"] = _count_trend_plan_opens_between(conn, trading_day, trading_day)
|
||
|
||
week_o = [tr for tr in pnls_order if in_week(tr)]
|
||
week_t = [tr for tr in pnls_trend if in_week(tr)]
|
||
wm_o = _compute_period_metrics(week_o)
|
||
wm_t = _compute_period_metrics(week_t)
|
||
wm_o["opens_count"] = _count_opens_between(conn, w_start, w_end)
|
||
wm_t["opens_count"] = _count_trend_plan_opens_between(conn, w_start, w_end)
|
||
|
||
month_o = [tr for tr in pnls_order if in_month(tr)]
|
||
month_t = [tr for tr in pnls_trend if in_month(tr)]
|
||
mm_o = _compute_period_metrics(month_o)
|
||
mm_t = _compute_period_metrics(month_t)
|
||
mm_o["opens_count"] = _count_opens_between(conn, m_start, m_end)
|
||
mm_t["opens_count"] = _count_trend_plan_opens_between(conn, m_start, m_end)
|
||
|
||
return {
|
||
"trading_day": trading_day,
|
||
"total_opens_order": total_opens_order,
|
||
"total_opens_trend": total_opens_trend,
|
||
"total_opens_all": int(total_opens_order) + int(total_opens_trend),
|
||
"day": {"range_label": day_range, "order": dm_o, "trend": dm_t},
|
||
"week": {"range_label": week_range, "order": wm_o, "trend": wm_t},
|
||
"month": {"range_label": month_range, "order": mm_o, "trend": mm_t},
|
||
}
|
||
|
||
|
||
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 round_price_to_exchange(exchange_symbol, price):
|
||
"""与交易所 tick 对齐后的 float,供入库与计算;失败时退回 float(price)。"""
|
||
if price in (None, ""):
|
||
return None
|
||
try:
|
||
v = float(price)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if not exchange_symbol:
|
||
return v
|
||
try:
|
||
ensure_markets_loaded()
|
||
s = exchange.price_to_precision(exchange_symbol, v)
|
||
return float(s)
|
||
except Exception:
|
||
return v
|
||
|
||
|
||
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 ""
|
||
mt = (item.get("monitor_type") or "").strip()
|
||
ex_pnl = item.get("exchange_realized_pnl")
|
||
ex_open = item.get("exchange_opened_at")
|
||
ex_close = item.get("exchange_closed_at")
|
||
if mt == MONITOR_TYPE_TREND and ex_pnl is not None and str(ex_pnl).strip() != "":
|
||
try:
|
||
item["display_pnl_amount"] = float(ex_pnl)
|
||
except (TypeError, ValueError):
|
||
item["display_pnl_amount"] = float(item.get("effective_pnl_amount") or 0)
|
||
item["display_pnl_source"] = "exchange"
|
||
eo = (str(ex_open).strip() if ex_open else "") or item.get("effective_opened_at") or ""
|
||
ec = (str(ex_close).strip() if ex_close else "") or item.get("effective_closed_at") or ""
|
||
item["display_opened_at"] = eo[:16] if eo else "-"
|
||
item["display_closed_at"] = ec[:16] if ec else "-"
|
||
else:
|
||
try:
|
||
item["display_pnl_amount"] = float(item.get("effective_pnl_amount") or 0)
|
||
except (TypeError, ValueError):
|
||
item["display_pnl_amount"] = 0.0
|
||
item["display_pnl_source"] = "local"
|
||
eo = item.get("effective_opened_at") or ""
|
||
ec = item.get("effective_closed_at") or ""
|
||
item["display_opened_at"] = (eo[:16] if eo else "-")
|
||
item["display_closed_at"] = (ec[:16] if ec else "-")
|
||
return item
|
||
|
||
|
||
def format_money_usdt(value):
|
||
"""资金类展示:固定两位小数(USDT)。"""
|
||
if value is None or value == "":
|
||
return "—"
|
||
try:
|
||
return f"{round(float(value), 2):.2f}"
|
||
except (TypeError, ValueError):
|
||
return "—"
|
||
|
||
|
||
def _exchange_unified_symbol_for_format(symbol_str):
|
||
if not symbol_str:
|
||
return None
|
||
s = str(symbol_str).strip()
|
||
if not s:
|
||
return None
|
||
try:
|
||
if ":" in s or "/" in s:
|
||
return normalize_exchange_symbol(s)
|
||
return normalize_exchange_symbol(f"{s}/USDT")
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
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"
|
||
sym = _exchange_unified_symbol_for_format(symbol)
|
||
if sym and exchange_private_api_configured():
|
||
try:
|
||
ensure_markets_loaded()
|
||
return str(exchange.price_to_precision(sym, v))
|
||
except Exception:
|
||
pass
|
||
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_amount_for_symbol(symbol, value):
|
||
"""合约张数等:尽量与交易所 amount 精度一致。"""
|
||
if value in (None, ""):
|
||
return "-"
|
||
try:
|
||
v = float(value)
|
||
except Exception:
|
||
return str(value)
|
||
sym = _exchange_unified_symbol_for_format(symbol)
|
||
if sym and exchange_private_api_configured():
|
||
try:
|
||
ensure_markets_loaded()
|
||
return str(exchange.amount_to_precision(sym, v))
|
||
except Exception:
|
||
pass
|
||
text = f"{v:.8f}"
|
||
return text.rstrip("0").rstrip(".") if "." in text else text
|
||
|
||
|
||
def insert_trend_preview_snapshot(conn, preview_id, created, exp_ms, pl):
|
||
"""生成预览成功后归档一条快照(与 trend_pullback_previews 同参)。"""
|
||
conn.execute(
|
||
"""INSERT INTO trend_pullback_preview_snapshots (
|
||
preview_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,preview_created_at
|
||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(
|
||
preview_id,
|
||
pl["symbol"],
|
||
pl["exchange_symbol"],
|
||
pl["direction"],
|
||
pl["leverage"],
|
||
pl["stop_loss"],
|
||
pl["add_upper"],
|
||
pl["take_profit"],
|
||
pl["risk_percent"],
|
||
pl["snapshot_available_usdt"],
|
||
pl["snapshot_at"],
|
||
pl["live_price_ref"],
|
||
pl["plan_margin_capital"],
|
||
pl["target_order_amount"],
|
||
pl["first_order_amount"],
|
||
pl["remainder_total"],
|
||
pl["dca_legs"],
|
||
pl["per_leg_amount"],
|
||
pl["grid_prices_json"],
|
||
pl["leg_amounts_json"],
|
||
exp_ms,
|
||
created,
|
||
),
|
||
)
|
||
|
||
|
||
def preview_snapshot_outcome_label(outcome):
|
||
o = (outcome or "").strip().lower()
|
||
return {
|
||
"open": "待确认",
|
||
"executed": "已执行",
|
||
"cancelled": "已取消",
|
||
"expired": "已过期",
|
||
}.get(o, outcome or "-")
|
||
|
||
|
||
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,
|
||
trend_plan_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,trend_plan_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, trend_plan_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 get_active_position_count(conn):
|
||
"""与 Gate 主站一致:仅统计 order_monitors.status=active。"""
|
||
return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0])
|
||
|
||
|
||
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 = get_active_position_count(conn)
|
||
if active_count >= MAX_ACTIVE_POSITIONS:
|
||
return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})"
|
||
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 = get_active_position_count(conn)
|
||
if active_count >= MAX_ACTIVE_POSITIONS:
|
||
return False, (
|
||
f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS}),"
|
||
"请先结束「机器人下单监控」中的持仓,再启动趋势回调"
|
||
)
|
||
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)
|
||
stale = conn.execute("SELECT id FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,)).fetchall()
|
||
for row in stale:
|
||
try:
|
||
conn.execute(
|
||
"UPDATE trend_pullback_preview_snapshots SET outcome='expired' WHERE preview_id=? AND outcome='open'",
|
||
(row["id"],),
|
||
)
|
||
except Exception:
|
||
pass
|
||
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"
|
||
from strategy_trend_lib import validate_trend_bounds
|
||
|
||
bound_err = validate_trend_bounds(direction, stop_loss, add_upper)
|
||
if bound_err:
|
||
return None, bound_err
|
||
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
|
||
from strategy_trend_lib import build_grid_prices, build_leg_amounts_json
|
||
|
||
ensure_markets_loaded()
|
||
market = exchange.market(exchange_symbol)
|
||
min_amt = float((market.get("limits", {}).get("amount", {}) or {}).get("min") or 0)
|
||
n_legs, leg_json, per_ref = build_leg_amounts_json(
|
||
exchange_symbol,
|
||
remainder_total,
|
||
TREND_PULLBACK_DCA_LEGS,
|
||
_safe_amount_to_precision,
|
||
min_amt,
|
||
)
|
||
if n_legs <= 0:
|
||
return None, "剩余计划张数不足以拆出补仓档(低于交易所最小张数),请提高风险比例、放宽止损与补仓区间间距,或减少补仓档数"
|
||
grid = 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 replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):
|
||
"""移动保本/手动改价:先撤该合约 TP/SL 条件单,再按新价重挂。"""
|
||
ok, reason = ensure_exchange_live_ready()
|
||
if not ok:
|
||
raise RuntimeError(reason or "实盘未就绪")
|
||
ex_sym = resolve_monitor_exchange_symbol(order_row)
|
||
direction = order_row["direction"]
|
||
cancel_gate_swap_trigger_orders(ex_sym)
|
||
contracts = get_live_position_contracts(ex_sym, direction)
|
||
if contracts is None or float(contracts) <= 0:
|
||
raise ValueError("交易所当前无该方向持仓,无法挂止盈止损")
|
||
amt = float(contracts)
|
||
if amt <= 0:
|
||
try:
|
||
amt = float(order_row["order_amount"] or 0)
|
||
except Exception:
|
||
amt = 0
|
||
if amt <= 0:
|
||
raise ValueError("无法确定平仓数量")
|
||
_gate_place_tp_sl_orders(ex_sym, direction, amt, float(stop_loss), float(take_profit))
|
||
|
||
|
||
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 _unified_symbol_for_match(symbol_str):
|
||
"""统一 BTC/USDT:USDT 与 BTC/USDT 便于与 trade_records.symbol 比对。"""
|
||
x = (symbol_str or "").strip().upper()
|
||
if ":" in x:
|
||
x = x.split(":")[0]
|
||
return x
|
||
|
||
|
||
def exchange_position_sync_since_ms():
|
||
"""Gate fetch_positions_history 的 since(毫秒,含当日 0 点)。"""
|
||
s = EXCHANGE_POSITION_SYNC_FROM_BJ
|
||
if s:
|
||
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)):
|
||
try:
|
||
chunk = s[:ln] if len(s) >= ln else s[:10]
|
||
dt = datetime.strptime(chunk, fmt)
|
||
aware = dt.replace(tzinfo=APP_TZ)
|
||
return int(aware.timestamp() * 1000)
|
||
except Exception:
|
||
continue
|
||
dt0 = app_now() - timedelta(days=90)
|
||
try:
|
||
aware0 = datetime(dt0.year, dt0.month, dt0.day, 0, 0, 0, tzinfo=APP_TZ)
|
||
except Exception:
|
||
aware0 = datetime.now(APP_TZ)
|
||
return int(aware0.timestamp() * 1000)
|
||
|
||
|
||
def _coerce_ts_ms(val):
|
||
if val is None or val == "":
|
||
return None
|
||
try:
|
||
v = float(val)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if v > 1e12:
|
||
return int(v)
|
||
if v > 1e10:
|
||
return int(v)
|
||
return int(v * 1000.0)
|
||
|
||
|
||
def _normalize_gate_position_history_entry(p):
|
||
if not p or not isinstance(p, dict):
|
||
return None
|
||
info = p.get("info") or {}
|
||
sym = p.get("symbol") or ""
|
||
side = (p.get("side") or "").strip().lower()
|
||
if side not in ("long", "short"):
|
||
sz = info.get("accum_size") if info.get("accum_size") is not None else info.get("size")
|
||
try:
|
||
szf = float(sz)
|
||
if szf > 0:
|
||
side = "long"
|
||
elif szf < 0:
|
||
side = "short"
|
||
except (TypeError, ValueError):
|
||
side = ""
|
||
rp = p.get("realizedPnl")
|
||
if rp is None:
|
||
rp = info.get("pnl")
|
||
try:
|
||
rp_f = float(rp) if rp is not None and str(rp).strip() != "" else None
|
||
except (TypeError, ValueError):
|
||
rp_f = None
|
||
close_ms = _coerce_ts_ms(p.get("lastUpdateTimestamp"))
|
||
if close_ms is None:
|
||
close_ms = _coerce_ts_ms(info.get("time"))
|
||
open_ms = _coerce_ts_ms(p.get("timestamp"))
|
||
if open_ms is None:
|
||
open_ms = _coerce_ts_ms(info.get("first_open_time"))
|
||
c_raw = str(info.get("contract") or "").strip()
|
||
t_raw = info.get("time")
|
||
sync_key = f"{c_raw}|{t_raw}|{side}"
|
||
return {
|
||
"symbol_u": _unified_symbol_for_match(sym),
|
||
"side": side,
|
||
"close_ms": close_ms,
|
||
"open_ms": open_ms,
|
||
"pnl": rp_f,
|
||
"sync_key": sync_key,
|
||
}
|
||
|
||
|
||
def fetch_gate_positions_close_history():
|
||
if not exchange_private_api_configured():
|
||
return []
|
||
ensure_markets_loaded()
|
||
since_ms = exchange_position_sync_since_ms()
|
||
try:
|
||
rows = exchange.fetch_positions_history(
|
||
None,
|
||
since=int(since_ms),
|
||
limit=int(EXCHANGE_POSITION_HISTORY_LIMIT),
|
||
params={"settle": "usdt"},
|
||
)
|
||
except Exception:
|
||
try:
|
||
rows = exchange.fetch_positions_history(
|
||
None,
|
||
since=int(since_ms),
|
||
limit=int(EXCHANGE_POSITION_HISTORY_LIMIT),
|
||
params={},
|
||
)
|
||
except Exception:
|
||
return []
|
||
out = []
|
||
for p in rows or []:
|
||
h = _normalize_gate_position_history_entry(p)
|
||
if h and h["close_ms"] and h["side"] in ("long", "short") and h["symbol_u"]:
|
||
out.append(h)
|
||
return out
|
||
|
||
|
||
def sync_trend_trade_records_from_exchange(conn):
|
||
global _LAST_POSITION_HISTORY_SYNC_AT
|
||
if not exchange_private_api_configured():
|
||
return
|
||
now = time.time()
|
||
if now - _LAST_POSITION_HISTORY_SYNC_AT < 25.0:
|
||
return
|
||
try:
|
||
hist = fetch_gate_positions_close_history()
|
||
except Exception:
|
||
return
|
||
if not hist:
|
||
_LAST_POSITION_HISTORY_SYNC_AT = now
|
||
return
|
||
candidates = conn.execute(
|
||
"""
|
||
SELECT id, symbol, direction, closed_at, opened_at, trend_plan_id, exchange_sync_key
|
||
FROM trade_records
|
||
WHERE monitor_type = ? AND (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '')
|
||
ORDER BY id DESC
|
||
LIMIT 120
|
||
""",
|
||
(MONITOR_TYPE_TREND,),
|
||
).fetchall()
|
||
if not candidates:
|
||
_LAST_POSITION_HISTORY_SYNC_AT = now
|
||
return
|
||
used = set()
|
||
for tr in candidates:
|
||
tid = None
|
||
if "trend_plan_id" in tr.keys() and tr["trend_plan_id"]:
|
||
try:
|
||
tid = int(tr["trend_plan_id"])
|
||
except (TypeError, ValueError):
|
||
tid = None
|
||
plan_open_ms = None
|
||
if tid:
|
||
prow = conn.execute("SELECT opened_at FROM trend_pullback_plans WHERE id=?", (tid,)).fetchone()
|
||
if prow and prow["opened_at"]:
|
||
plan_open_ms = opened_at_str_to_ms(prow["opened_at"])
|
||
close_ms_trade = opened_at_str_to_ms(tr["closed_at"]) or opened_at_str_to_ms(tr["opened_at"])
|
||
if close_ms_trade is None:
|
||
continue
|
||
best = None
|
||
best_d = None
|
||
for h in hist:
|
||
sk = h["sync_key"]
|
||
if not sk or sk in used:
|
||
continue
|
||
if h["symbol_u"] != _unified_symbol_for_match(tr["symbol"]):
|
||
continue
|
||
if h["side"] != (tr["direction"] or "long").strip().lower():
|
||
continue
|
||
cm = h["close_ms"]
|
||
if cm is None:
|
||
continue
|
||
if plan_open_ms is not None:
|
||
if cm < plan_open_ms - 15 * 60 * 1000:
|
||
continue
|
||
if cm > plan_open_ms + 15 * 86400 * 1000:
|
||
continue
|
||
else:
|
||
if abs(cm - close_ms_trade) > 3 * 86400 * 1000:
|
||
continue
|
||
d = abs(cm - close_ms_trade)
|
||
if best_d is None or d < best_d:
|
||
best_d = d
|
||
best = h
|
||
if best is None or best_d is None or best_d > 25 * 60 * 1000:
|
||
continue
|
||
sk = best["sync_key"]
|
||
if sk in used:
|
||
continue
|
||
eo = ms_to_app_local_str(best["open_ms"]) if best.get("open_ms") else None
|
||
ec = ms_to_app_local_str(best["close_ms"]) if best.get("close_ms") else None
|
||
pnl_val = best.get("pnl")
|
||
if pnl_val is None:
|
||
pnl_val = 0.0
|
||
conn.execute(
|
||
"""
|
||
UPDATE trade_records
|
||
SET exchange_realized_pnl = ?, exchange_opened_at = ?, exchange_closed_at = ?, exchange_sync_key = ?
|
||
WHERE id = ?
|
||
""",
|
||
(float(pnl_val), eo, ec, sk, int(tr["id"])),
|
||
)
|
||
used.add(sk)
|
||
_LAST_POSITION_HISTORY_SYNC_AT = now
|
||
conn.commit()
|
||
|
||
|
||
def trend_plan_history_status_label(status):
|
||
s = (status or "").strip().lower()
|
||
return {
|
||
"stopped_tp": "止盈结束",
|
||
"stopped_sl": "止损结束",
|
||
"stopped_manual": "手动结束",
|
||
}.get(s, status or "-")
|
||
|
||
|
||
def _list_window_from_request():
|
||
return resolve_list_window(request.args, session, default_preset=PRESET_UTC_TODAY)
|
||
|
||
|
||
def _redirect_records():
|
||
qs = list_window_redirect_query(session)
|
||
return redirect(f"/records?{qs}" if qs else "/records")
|
||
|
||
|
||
def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
|
||
"""趋势回调手动保本:默认开仓均价 + offset_pct%(多上移、空下移)。"""
|
||
try:
|
||
e = float(entry_price)
|
||
pct = float(
|
||
offset_pct
|
||
if offset_pct is not None
|
||
else TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT
|
||
)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if e <= 0:
|
||
return None
|
||
direction = (direction or "long").strip().lower()
|
||
if direction == "short":
|
||
return e * (1.0 - pct / 100.0)
|
||
return e * (1.0 + pct / 100.0)
|
||
|
||
|
||
def enrich_active_trend_plan_row(row):
|
||
d = row_to_dict(row)
|
||
try:
|
||
d["breakeven_applied"] = int(d.get("breakeven_applied") or 0) != 0
|
||
except Exception:
|
||
d["breakeven_applied"] = False
|
||
ex_sym = d.get("exchange_symbol") or normalize_exchange_symbol(d.get("symbol") or "")
|
||
direction = (d.get("direction") or "long").lower()
|
||
m = get_live_position_exchange_metrics(ex_sym, direction)
|
||
if m and m.get("unrealized_pnl") is not None:
|
||
d["floating_pnl"] = float(m["unrealized_pnl"])
|
||
else:
|
||
d["floating_pnl"] = None
|
||
if m and m.get("mark_price") is not None:
|
||
d["floating_mark"] = float(m["mark_price"])
|
||
else:
|
||
d["floating_mark"] = None
|
||
return d
|
||
|
||
|
||
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):
|
||
"""在 (止损, 补仓区间远侧边界 add_upper) 开区间内生成 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 apply_trend_pullback_manual_breakeven(conn, row, offset_pct=None):
|
||
"""运行中趋势计划:将交易所止损移至均价+偏移(默认 0.3%),仅当新止损更优时生效。"""
|
||
if (row["status"] or "").strip() != "active":
|
||
return False, "计划已结束"
|
||
if not int(row["first_order_done"] or 0):
|
||
return False, "尚未完成首仓,无法保本"
|
||
avg_e = float(row["avg_entry_price"] or 0)
|
||
if avg_e <= 0:
|
||
return False, "缺少有效持仓均价"
|
||
direction = (row["direction"] or "long").lower()
|
||
ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])
|
||
pos = get_live_position_contracts(ex_sym, direction)
|
||
if pos is None or float(pos) <= 0:
|
||
return False, "交易所当前无该方向持仓"
|
||
new_sl_raw = calc_trend_manual_breakeven_stop(direction, avg_e, offset_pct)
|
||
if new_sl_raw is None:
|
||
return False, "保本价计算失败"
|
||
new_sl = round_price_to_exchange(ex_sym, new_sl_raw)
|
||
if new_sl is None:
|
||
return False, "保本价经交易所精度舍入后无效"
|
||
new_sl = float(new_sl)
|
||
cur_sl = float(row["stop_loss"] or 0)
|
||
if direction == "long":
|
||
if new_sl <= cur_sl:
|
||
return False, f"新止损 {new_sl} 未高于当前止损 {cur_sl}(多仓需上移)"
|
||
else:
|
||
if new_sl >= cur_sl:
|
||
return False, f"新止损 {new_sl} 未低于当前止损 {cur_sl}(空仓需下移)"
|
||
try:
|
||
_trend_refresh_stop_only(ex_sym, direction, new_sl)
|
||
except Exception as e:
|
||
return False, friendly_exchange_error(e)
|
||
now_s = app_now_str()
|
||
conn.execute(
|
||
"UPDATE trend_pullback_plans SET stop_loss=?, breakeven_applied=1, breakeven_applied_at=? WHERE id=?",
|
||
(new_sl, now_s, row["id"]),
|
||
)
|
||
pct_used = float(
|
||
offset_pct
|
||
if offset_pct is not None
|
||
else TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT
|
||
)
|
||
sym = row["symbol"]
|
||
send_wechat_msg(
|
||
"\n".join(
|
||
[
|
||
f"# ✅ {sym} 趋势回调手动保本",
|
||
f"**账户:{_wechat_account_label()}**",
|
||
f"- 计划 ID:**{row['id']}**",
|
||
f"- 方向:{_wechat_direction_text(direction)}",
|
||
f"- 持仓均价:{format_price_for_symbol(sym, avg_e)}",
|
||
f"- 偏移:{pct_used}%(相对均价)",
|
||
f"- 新止损:{format_price_for_symbol(sym, new_sl)}",
|
||
f"- 交易所:已更新仓位止损触发单",
|
||
]
|
||
)
|
||
)
|
||
return True, None
|
||
|
||
|
||
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,
|
||
trend_plan_id=int(row["id"]),
|
||
)
|
||
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:
|
||
was_armed = breakeven_armed
|
||
ex_sym = resolve_monitor_exchange_symbol(r)
|
||
new_sl = round_price_to_exchange(ex_sym, new_sl)
|
||
tp_ex = float(take_profit or 0)
|
||
ok_live, _live_reason = ensure_exchange_live_ready()
|
||
synced_ex = not ok_live
|
||
if ok_live and tp_ex > 0:
|
||
try:
|
||
replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex)
|
||
synced_ex = True
|
||
_clear_breakeven_exchange_warn(pid)
|
||
except Exception as e:
|
||
print(
|
||
f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}",
|
||
flush=True,
|
||
)
|
||
_send_breakeven_exchange_warn_once(
|
||
pid,
|
||
f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_exchange_error(e)}",
|
||
)
|
||
elif ok_live:
|
||
print(
|
||
f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit",
|
||
flush=True,
|
||
)
|
||
if synced_ex:
|
||
conn.execute(
|
||
"UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?",
|
||
(new_sl, new_sl, pid),
|
||
)
|
||
stop_loss = new_sl
|
||
breakeven_armed = 1
|
||
if not was_armed:
|
||
arm_txt = "保本止盈"
|
||
be_msg = build_wechat_breakeven_message(
|
||
sym,
|
||
direction,
|
||
arm_txt,
|
||
now_rr,
|
||
locked_r,
|
||
new_sl,
|
||
)
|
||
if ok_live:
|
||
be_msg += "\n- 交易所:已先撤后挂止盈止损"
|
||
send_wechat_msg(be_msg)
|
||
|
||
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 hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED):
|
||
return f(*args, **kwargs)
|
||
return redirect("/login")
|
||
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)
|
||
list_window = _list_window_from_request()
|
||
start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ)
|
||
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, 2) if funding_capital is not None else None
|
||
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
|
||
recommended_capital = round(get_recommended_capital(current_capital), 2)
|
||
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))
|
||
if page in ("trade", "records"):
|
||
try:
|
||
sync_trend_trade_records_from_exchange(conn)
|
||
except Exception:
|
||
pass
|
||
if page == "records":
|
||
raw_records = conn.execute(
|
||
f"SELECT * FROM trade_records WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? "
|
||
f"AND {sql_list_time_field('closed_at', 'created_at', 'opened_at')} <= ? ORDER BY id DESC LIMIT 2000",
|
||
(start_bj, end_bj),
|
||
).fetchall()
|
||
else:
|
||
raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC LIMIT 500").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 = get_active_position_count(conn)
|
||
trend_active = conn.execute(
|
||
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
|
||
).fetchone()[0]
|
||
trend_plans_raw = conn.execute(
|
||
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC"
|
||
).fetchall()
|
||
trend_plans = []
|
||
for r in trend_plans_raw:
|
||
try:
|
||
trend_plans.append(enrich_active_trend_plan_row(r))
|
||
except Exception as e:
|
||
print(f"[render_main_page] enrich trend plan: {e}")
|
||
trend_plans.append(row_to_dict(r))
|
||
preview_snapshots = []
|
||
if page == "records":
|
||
try:
|
||
snap_rows = conn.execute(
|
||
f"SELECT * FROM trend_pullback_preview_snapshots WHERE {sql_list_time_field('preview_created_at')} >= ? "
|
||
f"AND {sql_list_time_field('preview_created_at')} <= ? ORDER BY id DESC LIMIT 500",
|
||
(start_bj, end_bj),
|
||
).fetchall()
|
||
for sr in snap_rows:
|
||
sd = row_to_dict(sr)
|
||
sd["outcome_label"] = preview_snapshot_outcome_label(sd.get("outcome"))
|
||
preview_snapshots.append(sd)
|
||
except Exception as e:
|
||
print(f"[records] trend_pullback_preview_snapshots: {e}")
|
||
can_trade = (
|
||
trading_day_reset_allows_new_open(now)
|
||
and active_count < MAX_ACTIVE_POSITIONS
|
||
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 == "strategy_trend":
|
||
_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
|
||
strategy_extra = {}
|
||
if page == "strategy_roll":
|
||
from strategy_ui import fetch_roll_page_data
|
||
|
||
strategy_extra = fetch_roll_page_data(
|
||
conn,
|
||
default_risk_percent=float(RISK_PERCENT),
|
||
count_active_trends=lambda c, ta=trend_active: int(ta or 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,
|
||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||
can_trade=can_trade,
|
||
trend_plans=trend_plans,
|
||
preview_snapshots=preview_snapshots,
|
||
exchange_sync_from_label=(EXCHANGE_POSITION_SYNC_FROM_BJ or "最近90天"),
|
||
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,
|
||
trend_manual_breakeven_offset_pct=TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT,
|
||
list_window=list_window,
|
||
list_window_presets={
|
||
"utc_today": PRESET_UTC_TODAY,
|
||
"utc_last24h": PRESET_UTC_LAST24H,
|
||
"utc_last7d": PRESET_UTC_LAST7D,
|
||
"custom": PRESET_CUSTOM,
|
||
},
|
||
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=3,
|
||
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,
|
||
amt_fmt=format_amount_for_symbol,
|
||
money_fmt=format_money_usdt,
|
||
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
||
entry_reason_other_value=ENTRY_REASON_OTHER,
|
||
exchange_display=EXCHANGE_DISPLAY_NAME,
|
||
**strategy_extra,
|
||
)
|
||
|
||
|
||
@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("/plan_history")
|
||
@login_required
|
||
def plan_history_page():
|
||
qs = list_window_redirect_query(session)
|
||
return redirect(f"/records?{qs}" if qs else "/records")
|
||
|
||
|
||
@app.route("/api/preview_snapshot/<int:sid>")
|
||
@login_required
|
||
def api_preview_snapshot(sid):
|
||
conn = get_db()
|
||
row = conn.execute("SELECT * FROM trend_pullback_preview_snapshots WHERE id=?", (sid,)).fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return jsonify({"ok": False, "msg": "not_found"}), 404
|
||
d = row_to_dict(row)
|
||
d["outcome_label"] = preview_snapshot_outcome_label(d.get("outcome"))
|
||
return jsonify({"ok": True, "snapshot": d})
|
||
|
||
|
||
@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, 2) if funding_capital is not None else None
|
||
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
|
||
recommended_capital = round(get_recommended_capital(current_capital), 2)
|
||
active_count = get_active_position_count(conn)
|
||
conn.close()
|
||
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
|
||
available_trading_usdt = get_available_trading_usdt()
|
||
return jsonify({
|
||
"funding_usdt": funding_usdt,
|
||
"current_capital": current_capital,
|
||
"available_trading_usdt": round(available_trading_usdt, 2) if available_trading_usdt is not None else None,
|
||
"recommended_capital": recommended_capital,
|
||
"active_count": active_count,
|
||
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||
"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=f"持仓占用:{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,
|
||
opened_at_ms=opened_at_ms,
|
||
entry_price=trigger_price,
|
||
)
|
||
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("strategy_trend_page"))
|
||
ok_live, reason_live = ensure_exchange_live_ready()
|
||
if not ok_live:
|
||
conn.close()
|
||
flash(reason_live)
|
||
return redirect(url_for("strategy_trend_page"))
|
||
payload, err = parse_and_compute_trend_pullback_plan(request.form)
|
||
if err:
|
||
conn.close()
|
||
flash(err)
|
||
return redirect(url_for("strategy_trend_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,
|
||
),
|
||
)
|
||
insert_trend_preview_snapshot(conn, pid, created, exp_ms, payload)
|
||
conn.commit()
|
||
conn.close()
|
||
flash(f"预览已生成,有效期 {TREND_PULLBACK_PREVIEW_TTL_SECONDS} 秒,请核对后点击「确认执行」。")
|
||
return redirect(url_for("strategy_trend_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("strategy_trend_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("strategy_trend_page"))
|
||
okp, reasonp = precheck_trend_pullback_start(conn)
|
||
if not okp:
|
||
conn.close()
|
||
flash(reasonp)
|
||
return redirect(url_for("strategy_trend_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("strategy_trend_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("strategy_trend_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("strategy_trend_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("strategy_trend_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("strategy_trend_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)
|
||
cur = conn.execute(
|
||
"""INSERT INTO trend_pullback_plans (
|
||
status,symbol,exchange_symbol,direction,leverage,stop_loss,initial_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,
|
||
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]}…",
|
||
),
|
||
)
|
||
new_plan_id = int(cur.lastrowid)
|
||
conn.execute(
|
||
"UPDATE trend_pullback_preview_snapshots SET outcome='executed', executed_plan_id=? WHERE preview_id=?",
|
||
(new_plan_id, pid),
|
||
)
|
||
conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,))
|
||
conn.commit()
|
||
conn.close()
|
||
flash(
|
||
f"趋势回调已执行:可用余额(执行时){round(snap, 2)}U;计划保证金约 {round(margin_plan, 2)}U;"
|
||
f"总张数约 {target_amt},首仓 {first_amt},补仓 {n_legs} 档;已挂交易所止损,止盈由程序监控。"
|
||
)
|
||
return redirect(url_for("strategy_trend_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(
|
||
"UPDATE trend_pullback_preview_snapshots SET outcome='cancelled' WHERE preview_id=? AND outcome='open'",
|
||
(pid,),
|
||
)
|
||
conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,))
|
||
conn.commit()
|
||
conn.close()
|
||
flash("已取消预览")
|
||
return redirect(url_for("strategy_trend_page"))
|
||
|
||
|
||
@app.route("/trend_pullback_breakeven/<int:pid>", methods=["POST"])
|
||
@login_required
|
||
def trend_pullback_breakeven(pid):
|
||
offset_raw = (request.form.get("breakeven_offset_pct") or "").strip()
|
||
offset_pct = None
|
||
if offset_raw:
|
||
try:
|
||
offset_pct = float(offset_raw)
|
||
if offset_pct < 0:
|
||
raise ValueError
|
||
except ValueError:
|
||
flash("保本偏移% 格式无效")
|
||
return redirect(url_for("strategy_trend_page"))
|
||
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(url_for("strategy_trend_page"))
|
||
ok, err = apply_trend_pullback_manual_breakeven(conn, row, offset_pct=offset_pct)
|
||
conn.commit()
|
||
conn.close()
|
||
if ok:
|
||
flash("已手动保本:交易所止损已按均价+偏移更新")
|
||
else:
|
||
flash(err or "手动保本失败")
|
||
return redirect(url_for("strategy_trend_page"))
|
||
|
||
|
||
@app.route("/stop_trend_pullback/<int:pid>")
|
||
@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_trend_plan_history/<int:pid>", methods=["POST"])
|
||
@login_required
|
||
def delete_trend_plan_history(pid):
|
||
conn = get_db()
|
||
row = conn.execute("SELECT id, status FROM trend_pullback_plans WHERE id=?", (pid,)).fetchone()
|
||
if not row:
|
||
conn.close()
|
||
flash("计划不存在")
|
||
return redirect(request.referrer or url_for("records_page"))
|
||
if (row["status"] or "").strip() == "active":
|
||
conn.close()
|
||
flash("运行中的计划请使用「结束计划」,不可从历史中删除")
|
||
return redirect(request.referrer or url_for("records_page"))
|
||
conn.execute("DELETE FROM trade_records WHERE trend_plan_id=?", (pid,))
|
||
conn.execute("DELETE FROM trend_pullback_preview_snapshots WHERE executed_plan_id=?", (pid,))
|
||
conn.execute("DELETE FROM trend_pullback_plans WHERE id=?", (pid,))
|
||
conn.commit()
|
||
conn.close()
|
||
flash("已删除该计划历史及关联趋势交易记录(若有)")
|
||
return redirect(request.referrer or url_for("records_page"))
|
||
|
||
|
||
@app.route("/delete_key_monitor/<int:kid>", 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/<int:hid>", 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/<int:id>")
|
||
@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():
|
||
win = _list_window_from_request()
|
||
start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ)
|
||
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,trend_plan_id,exchange_realized_pnl,"
|
||
"exchange_opened_at,exchange_closed_at,exchange_sync_key FROM trade_records "
|
||
f"WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? "
|
||
f"AND {sql_list_time_field('closed_at', 'created_at', 'opened_at')} <= ? ORDER BY id ASC",
|
||
(start_bj, end_bj),
|
||
).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",
|
||
"trend_plan_id",
|
||
"exchange_realized_pnl",
|
||
"exchange_opened_at",
|
||
"exchange_closed_at",
|
||
"exchange_sync_key",
|
||
]
|
||
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/<int:id>")
|
||
@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,
|
||
normalize_bj_datetime_storage(d.get("open_datetime")),
|
||
normalize_bj_datetime_storage(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():
|
||
win = _list_window_from_request()
|
||
start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ)
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
f"SELECT * FROM journal_entries WHERE {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} >= ? "
|
||
f"AND {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} <= ? ORDER BY created_at DESC LIMIT 500",
|
||
(start_bj, end_bj),
|
||
).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/<jid>", 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():
|
||
win = _list_window_from_request()
|
||
start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ)
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200",
|
||
(start_bj, end_bj),
|
||
).fetchall()
|
||
conn.close()
|
||
return jsonify([row_to_dict(r) for r in rows])
|
||
|
||
|
||
@app.route("/export/review_md/<rid>")
|
||
@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/<rid>", 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/<int:rid>", 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})
|
||
|
||
def _hub_meta_bundle():
|
||
return {
|
||
"exchange_display": EXCHANGE_DISPLAY_NAME,
|
||
"trend_pullback_preview_ttl": TREND_PULLBACK_PREVIEW_TTL_SECONDS,
|
||
"trend_manual_breakeven_offset_pct": TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT,
|
||
"trend_pullback_dca_legs": TREND_PULLBACK_DCA_LEGS,
|
||
"trend_preview_max_drift_pct": TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT,
|
||
"manual_min_planned_rr": float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")),
|
||
"max_active_positions": max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))),
|
||
}
|
||
|
||
|
||
try:
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
_repo_root = Path(__file__).resolve().parent.parent
|
||
if str(_repo_root) not in sys.path:
|
||
sys.path.insert(0, str(_repo_root))
|
||
from hub_bridge import install_on_app
|
||
|
||
install_on_app(
|
||
app,
|
||
exchange="gate_bot",
|
||
capabilities=["order", "trend"],
|
||
has_trend=True,
|
||
get_db=get_db,
|
||
row_to_dict=row_to_dict,
|
||
meta_fn=_hub_meta_bundle,
|
||
views={
|
||
"add_order": add_order,
|
||
"add_key": add_key,
|
||
"preview_trend_pullback": preview_trend_pullback,
|
||
"execute_trend_pullback": execute_trend_pullback,
|
||
},
|
||
)
|
||
except Exception as _hub_err:
|
||
print(f"[hub_bridge] gate_bot: {_hub_err}")
|
||
|
||
|
||
@app.route("/strategy/trend")
|
||
@login_required
|
||
def strategy_trend_page():
|
||
return render_main_page("strategy_trend")
|
||
|
||
|
||
@app.route("/strategy/roll")
|
||
@login_required
|
||
def strategy_roll_page():
|
||
return render_main_page("strategy_roll")
|
||
|
||
|
||
from strategy_register import install_strategy_trading
|
||
|
||
install_strategy_trading(
|
||
app,
|
||
_REPO_ROOT,
|
||
app_module=sys.modules[__name__],
|
||
trend_enabled=True,
|
||
)
|
||
|
||
|
||
# 启动
|
||
if __name__ == "__main__":
|
||
threading.Thread(target=background_task, daemon=True).start()
|
||
app.run(host=HOST, port=PORT, debug=DEBUG)
|