From 4f0243e4fe1a0235c4d066bc944785c6c654cac6 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 21 May 2026 10:08:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=80=E4=BA=9Bbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crypto_monitor_binance/app.py | 15154 ++++++++-------- crypto_monitor_gate/app.py | 15224 +++++++++-------- crypto_monitor_gate_bot/app.py | 38 +- crypto_monitor_gate_bot/templates/index.html | 216 +- crypto_monitor_okx/app.py | 11704 ++++++------- history_window_lib.py | 202 +- 6 files changed, 21409 insertions(+), 21129 deletions(-) diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index b62fa53..dba582a 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -1,7564 +1,7590 @@ -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 fib_key_monitor_lib import ( - FIB_KEY_MONITOR_TYPES, - calc_fib_plan, - entry_reason_from_key_signal, - fib_invalidate_by_mark, - fib_ratio_from_type, - is_fib_key_monitor_type, - key_signal_type_for_trade_record, - stored_key_signal_type, -) -from key_sl_tp_lib import ( - breakeven_enabled_from_row, - normalize_sl_tp_mode, - parse_breakeven_enabled_form, - plan_key_sl_tp, - sl_tp_mode_from_row, - sl_tp_mode_label, - sl_tp_plan_summary_text, -) -from history_window_lib import ( - PRESET_CUSTOM, - PRESET_UTC_LAST24H, - PRESET_UTC_LAST7D, - PRESET_UTC_TODAY, - resolve_window, - 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") - -# ====================== 登录配置 ====================== -USERNAME = os.getenv("APP_USERNAME", "dekun") -PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") -AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") - -# 企业微信机器人Webhook -WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") -SYSTEM_TYPE = "CRYPTO" -HOST = os.getenv("APP_HOST", "0.0.0.0") -PORT = int(os.getenv("APP_PORT", "5000")) -DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" -DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) - -# 训练参数(可由 .env 覆盖) -DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) -DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) -DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) -BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) -ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) -# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) -TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) -# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) -TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( - "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" -).lower() in ("1", "true", "yes", "on") -APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") - - -def _resolve_app_tz(): - if ZoneInfo is not None: - try: - return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) - except Exception: - pass - return timezone(timedelta(hours=8)) - - -APP_TZ = _resolve_app_tz() -LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" -BINANCE_API_KEY = (os.getenv("BINANCE_API_KEY") or "").strip() -BINANCE_API_SECRET = (os.getenv("BINANCE_API_SECRET") or "").strip() -BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower() -# hedge=双向持仓(需 positionSide);oneway / single=单向持仓 -_raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower() -BINANCE_POSITION_MODE = "hedge" if _raw_binance_pos in ("hedge", "dual", "double", "hedged") else "oneway" -# 条件单触发参考:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价 -BINANCE_TRIGGER_WORKING_TYPE = (os.getenv("BINANCE_TRIGGER_WORKING_TYPE") or "CONTRACT_PRICE").strip().upper() -if BINANCE_TRIGGER_WORKING_TYPE not in ("CONTRACT_PRICE", "MARK_PRICE"): - BINANCE_TRIGGER_WORKING_TYPE = "CONTRACT_PRICE" -# 页面展示的交易所名称(多实例/多环境时可按需区分) -EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Binance").strip() or "Binance" -_BINANCE_DEFAULT_MARGIN_MODE = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" -BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) -PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) -KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) -KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) -KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) -KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5")) -KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1")) -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"))) -KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20"))) -KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3")) -KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03")) -KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5")) -KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))) -KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2")) -KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1")) -KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true" -ORDER_MONITOR_TYPE_MANUAL = "下单监控" -ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" -KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) -# 与币安 App「仓位历史-实现盈亏」对齐:默认仅 REALIZED_PNL(手续费另计;避免与 COMMISSION 重复扣) -BINANCE_APP_PNL_INCOME_TYPES = frozenset({"REALIZED_PNL"}) -BINANCE_APP_PNL_INCOME_WITH_FEE = frozenset({"REALIZED_PNL", "COMMISSION"}) -BINANCE_NET_INCOME_TYPES = frozenset( - {"REALIZED_PNL", "COMMISSION", "FUNDING_FEE", "INSURANCE_CLEAR", "INTERNAL_AUTO_CLOSE"} -) -BINANCE_PNL_INCLUDE_FUNDING = os.getenv("BINANCE_PNL_INCLUDE_FUNDING", "false").lower() in ( - "1", - "true", - "yes", -) -KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) -AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" -AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) -AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") -AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") -FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" -FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) -# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 -AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) -WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) -AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) -MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) -KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") -FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) -TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT") -UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) -ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" -ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] -ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) -ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) -DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5")) -RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) -BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) -BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) -BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) -DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() -OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate") -AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") - -BINANCE_SOCKS_PROXY = (os.getenv("BINANCE_SOCKS_PROXY") or "").strip() -BINANCE_HTTP_PROXY = (os.getenv("BINANCE_HTTP_PROXY") or "").strip() -BINANCE_HTTPS_PROXY = (os.getenv("BINANCE_HTTPS_PROXY") or "").strip() - - -def build_binance_ccxt_proxies(): - """ - 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口)。 - - 推荐: - - 本机:ssh -N -D 127.0.0.1:1080 user@vps - - .env:BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080 - - 说明: - - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// - """ - socks = BINANCE_SOCKS_PROXY.strip() - http = BINANCE_HTTP_PROXY.strip() - https = BINANCE_HTTPS_PROXY.strip() or http - if socks: - return {"http": socks, "https": socks} - if http or https: - return {"http": http, "https": https} - return None - - -BINANCE_CCXT_PROXIES = build_binance_ccxt_proxies() - -os.makedirs(UPLOAD_FOLDER, exist_ok=True) -os.makedirs(ORDER_CHART_DIR, exist_ok=True) -app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER - -# Binance USDT 本位永续(ccxt unified: defaultType=swap) -exchange = ccxt.binance({ - "enableRateLimit": True, - "options": { - "defaultType": "swap", - "defaultMarginMode": _BINANCE_DEFAULT_MARGIN_MODE, - "adjustForTimeDifference": True, - }, -}) -if BINANCE_CCXT_PROXIES: - exchange.proxies = BINANCE_CCXT_PROXIES -if BINANCE_API_KEY and BINANCE_API_SECRET: - exchange.apiKey = BINANCE_API_KEY - exchange.secret = BINANCE_API_SECRET -MARKETS_LOADED = False -ACCOUNT_BALANCE_CACHE = { - "updated_at": 0.0, - "funding_usdt": None, - "trading_usdt": None -} -LIQUIDITY_RANK_CACHE = { - "updated_at": 0.0, - "ranks": {}, - "total": 0, -} - -# 企业微信推送 -def send_wechat_msg(content): - prefix = "【加密货币】" - full_msg = f"{prefix}\n{content}" - data = { - "msgtype": "text", - "text": {"content": full_msg} - } - try: - requests.post(WECHAT_WEBHOOK, json=data, timeout=WECHAT_TIMEOUT_SECONDS) - except: - pass - - -_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("BINANCE_ACCOUNT_LABEL") or "binance实盘账户").strip() - - -def _wechat_direction_text(direction): - d = (direction or "").lower() - return "多头(long)" if d == "long" else "空头(short)" - - -def _wechat_trading_capital_text(fallback=None): - try: - _, trading_capital = get_exchange_capitals(force=True) - except Exception: - trading_capital = None - if trading_capital is not None: - return f"{round(float(trading_capital), FUNDS_DECIMALS)}U" - if fallback is not None: - try: - return f"{round(float(fallback), FUNDS_DECIMALS)}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, FUNDS_DECIMALS)} U" - else: - pnl_disp = "-" - except (TypeError, ValueError): - pnl_disp = "-" - - lines = [ - f"📉 {symbol} 平仓完成", - f"💼 账户:{_wechat_account_label()}", - "", - "🧾 平仓概要", - f"🔖 平仓单号:{close_order_id or '-'}", - f"📌 方向:{_wechat_direction_text(direction)}", - f"📌 平仓结果:{result or '-'}", - f"💰 本单盈亏:{pnl_disp}", - f"⏱ 持仓时长:{hold_txt}", - f"💵 交易账户资金:{cap_txt}", - "", - "🎯 价位(计划)", - f"开仓成交价:{ep}", - f"离场参考价:{cp}", - f"止盈价位:{tp}", - f"止损价位:{sl}", - ] - if extra_note: - lines.extend(["", "📎 备注", extra_note]) - return "\n".join(lines) - - -def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): - sl_fmt = format_price_for_symbol(symbol, new_sl) - return "\n".join( - [ - f"# 🛡️ {symbol} 保护位更新", - f"**账户:{_wechat_account_label()}**", - "", - "---", - "", - "### 移动保本/止盈", - f"- 方向:**{_wechat_direction_text(direction)}**", - f"- 类型:**{arm_txt}**", - f"- 当前RR:`{round(float(now_rr), 2)}R`", - f"- 锁定RR:`{round(float(locked_r), 2)}R`", - f"- 新保护位:`{sl_fmt}`", - ] - ) - - -def build_wechat_monitor_error_message(symbol, direction, scene, error_text): - return "\n".join( - [ - f"# ⚠️ {symbol} 下单监控异常", - f"**账户:{_wechat_account_label()}**", - "", - "---", - "", - "### 异常信息", - f"- 方向:**{_wechat_direction_text(direction)}**", - f"- 场景:{scene}", - f"- 错误:{str(error_text)}", - ] - ) - - -def build_wechat_key_monitor_message( - symbol, - direction, - monitor_type, - trigger_time, - key_price, - confirm_close, - hard_lines, - btc8h_status, - coin4h_status, - swing4h_pct, - op_lines, - risk_tip=None, -): - lines = [ - f"# 🎯 {symbol} 关键位确认推送", - f"**账户:{_wechat_account_label()}**", - "", - "---", - "", - "### 交易对 / 触发时间", - f"- 交易对:**{symbol}**", - f"- 触发时间:`{trigger_time}`", - "", - "### 方向与确认K", - f"- 方向:**{_wechat_direction_text(direction)}**", - "- 确认K:第二根5m收盘完成", - "", - "### 关键价位", - f"- 类型:**{monitor_type}**", - f"- 箱体关键位:`{key_price}`", - f"- 第二根确认收盘价:`{confirm_close}`", - "", - "### 硬条件校验结果", - ] - lines.extend([f"- {x}" for x in hard_lines]) - lines.extend( - [ - "", - "### 市场状态说明", - f"- BTC 8h 状态:**{btc8h_status}**", - f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", - f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", - "", - "### 操作提示", - ] - ) - lines.extend([f"- {x}" for x in op_lines]) - if risk_tip: - lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) - return "\n".join(lines) - - -def _read_image_base64(image_path): - try: - with open(image_path, "rb") as f: - return base64.b64encode(f.read()).decode("utf-8") - except Exception: - return None - - -def _extract_json_object(text): - if not text: - return None - clean = text.strip() - if clean.startswith("```"): - clean = clean.replace("```json", "").replace("```", "").strip() - try: - return json.loads(clean) - except Exception: - pass - match = re.search(r"\{[\s\S]*\}", clean) - if not match: - return None - try: - return json.loads(match.group(0)) - except Exception: - return None - - -def _journal_row_lines_for_ai(idx, row, *, include_hold_duration=True): - """把 journal 字段拼成给 AI 的文本;字段之外的事实不要指望模型自己猜。""" - def nz(v, default="无"): - if v is None: - return default - s = str(v).strip() - return s if s else default - - lines = [ - f"{idx}. {nz(row['coin'])} {nz(row['tf'])} | 盈亏:{nz(row['pnl'])}U | 实际RR:{nz(row['real_rr'])} | 预期RR:{nz(row['expect_rr'])}", - f" 开仓逻辑:{nz(row['entry_reason'])}", - f" 平仓/离场(交易员自述):{nz(row['exit_reason'])}", - ] - if include_hold_duration: - lines.append(f" 持仓时长:{nz(row['hold_duration'])}") - ee_bits = [ - nz(row["early_exit"]), - nz(row["early_exit_reason"]), - nz(row["early_exit_trigger"]), - nz(row["early_exit_note"]), - ] - if any(x != "无" for x in ee_bits): - lines.append( - " 提前离场记录:" - f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}" - ) - mood_bits = f"心态标签:{nz(row['mood_issues'])}" - if row["mood_score"] is not None: - mood_bits += f" | 自评心态分:{row['mood_score']}" - lines.append(f" {mood_bits}") - if nz(row["post_breakeven_stare"]) != "无": - lines.append(f" 保本后盯盘:{nz(row['post_breakeven_stare'])}") - if nz(row["new_trade_while_occupied"]) != "无": - lines.append(f" 占用时新开仓:{nz(row['new_trade_while_occupied'])}") - if nz(row["note"]) != "无": - lines.append(f" 备注:{nz(row['note'])}") - return "\n".join(lines) + "\n" - - -def ai_review(trades_text, period_title, image_paths=None): - prompt = f""" -你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。 - -【硬性规则 — 必须遵守】 -- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。 -- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。 -- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。 -- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。 -- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。 -- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。 - -【输出结构】 -1. 总体盈亏结构(紧扣笔数、盈亏数字与 RR,少形容词) -2. 心态与执行(每笔 1–10 分 + 一句依据;依据必须对应记录字段) -3. 行为标签(提前离场 / 乱开仓 / 扛单等):仅在有字段或自述支撑时点名;否则写「记录未勾选或未描述,不作强加」 -4. 改进建议(最多 3 条,每条具体可执行) -5. 图表(若有且可读):结合价格行为简述;否则一两句说明无法看图分析 - -交易记录: -{trades_text} -""".strip() - payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} - images = [] - for p in image_paths or []: - b64 = _read_image_base64(p) - if b64: - images.append(b64) - if images: - payload["images"] = images - try: - r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) - return r.json().get("response", "AI 生成失败") - except Exception as e: - return f"AI 调用失败:{str(e)}" - - -def ai_short_advice(prompt_text): - prompt = f""" -你是交易风控助理。请用中文给出**最多 3 条**提醒,要求: -- 每条不超过 25 个字 -- 语气克制、具体、可执行 -- 不要输出 Markdown,不要编号前缀以外的废话 - -场景: -{prompt_text} -""".strip() - payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} - try: - r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) - return (r.json().get("response") or "").strip() - except Exception: - return "" - - -def _load_font(size): - if not ImageFont: - return None - candidates = [ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", - "C:\\Windows\\Fonts\\msyh.ttc", - "C:\\Windows\\Fonts\\arial.ttf", - ] - for path in candidates: - if path and os.path.exists(path): - try: - return ImageFont.truetype(path, size) - except Exception: - continue - try: - return ImageFont.load_default() - except Exception: - return None - - -def _ohlcv_to_rows(ohlcv): - rows = [] - for bar in ohlcv or []: - if not bar or len(bar) < 6: - continue - try: - rows.append( - { - "ts": int(bar[0]), - "o": float(bar[1]), - "h": float(bar[2]), - "l": float(bar[3]), - "c": float(bar[4]), - "v": float(bar[5]), - } - ) - except Exception: - continue - return rows - - -def _local_input_datetime_to_ms(dt_text): - raw = str(dt_text or "").strip() - if not raw: - return None - raw = raw.replace("T", " ") - for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): - try: - dt = datetime.strptime(raw, fmt) - aware = dt.replace(tzinfo=APP_TZ) - return int(aware.timestamp() * 1000) - except Exception: - continue - return None - - -def _pick_marker_point(rows, target_ts_ms, target_price=None): - if not rows or target_ts_ms is None: - return None, None - idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) - if target_price is not None: - try: - p = float(target_price) - if p > 0: - return idx, p - except Exception: - pass - return idx, float(rows[idx]["c"]) - - -def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): - if not Image or not ImageDraw: - raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") - img = Image.new("RGB", (width, height), bg_rgb) - draw = ImageDraw.Draw(img) - font = _load_font(14) - small = _load_font(12) - - pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 - plot_w = max(10, width - pad_l - pad_r) - plot_h = max(10, height - pad_t - pad_b) - - header_bg = (245, 247, 250) - draw.rectangle((0, 0, width, pad_t), fill=header_bg) - if font: - draw.text((10, 6), title, fill=(25, 35, 60), font=font) - else: - draw.text((10, 6), title, fill=(25, 35, 60)) - - if not rows: - if small: - draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) - else: - draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) - return img - - lo = min(r["l"] for r in rows) - hi = max(r["h"] for r in rows) - if hi <= lo: - hi = lo + 1e-12 - - n = len(rows) - marker_by_idx = {} - for mp in marker_points or []: - try: - idx = int(mp.get("idx")) - except Exception: - continue - if idx < 0 or idx >= n: - continue - marker_by_idx[idx] = mp - - x0 = pad_l - for i, r in enumerate(rows): - x1 = pad_l + int((i + 1) * plot_w / n) - x_mid = (x0 + x1) // 2 - wick_x = x_mid - y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) - y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) - y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) - y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) - top = min(y_open, y_close) - bot = max(y_open, y_close) - up = r["c"] >= r["o"] - wick_color = (120, 120, 120) - edge_color = (20, 20, 20) - draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) - body_w = max(1, (x1 - x0) - 2) - left = x0 + 1 - if bot - top < 2: - mid = (top + bot) // 2 - draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) - else: - if up: - draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) - else: - draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) - if i in marker_by_idx: - mp = marker_by_idx[i] - tag = str(mp.get("tag") or "") - m_price = float(mp.get("price") or r["c"]) - y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) - y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) - if tag == "ENTRY": - m_color = (0, 195, 95) - tri = [(x_mid, y_m - 20), (x_mid - 9, y_m - 4), (x_mid + 9, y_m - 4)] - text_y = y_m - 36 - else: - m_color = (235, 65, 65) - tri = [(x_mid, y_m + 20), (x_mid - 9, y_m + 4), (x_mid + 9, y_m + 4)] - text_y = y_m + 12 - draw.ellipse((x_mid - 5, y_m - 5, x_mid + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) - draw.polygon(tri, fill=m_color) - draw.line((x_mid, y_m, x_mid, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) - if font: - draw.text((x_mid + 8, text_y), tag, fill=m_color, font=font) - else: - draw.text((x_mid + 8, text_y), tag, fill=m_color) - x0 = x1 - - if len(marker_points or []) >= 2: - try: - entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) - exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) - if entry is not None and exitp is not None: - ex_i, ex_p = int(entry["idx"]), float(entry["price"]) - xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) - x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) - x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) - y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) - y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) - draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) - except Exception: - pass - - # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 - if small: - draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) - return img - - -def _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 _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)) - if not end_ts_ms: - return exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim) - period = _timeframe_period_ms(timeframe) - since = int(end_ts_ms) - period * (lim + 5) - ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 10) - rows = _ohlcv_to_rows(ohlcv) - filtered = [r for r in rows if int(r[0]) <= int(end_ts_ms)] - if len(filtered) >= lim: - return [[r[0], r[1], r[2], r[3], r[4]] for r in filtered[-lim:]] - return ohlcv[-lim:] if ohlcv else [] - - -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 - for tf in timeframes: - try: - ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) - except Exception: - ohlcv = [] - rows = _ohlcv_to_rows(ohlcv)[-limit:] - title = f"{title_prefix} | {tf} x{len(rows)}" - points = [] - tf_key = str(tf).strip().lower() - marker_tfs = {str(x).strip().lower() for x in (marker_timeframes or []) if str(x).strip()} - if marker_payload and tf_key in marker_tfs: - entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")) - exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")) - if entry_idx is not None and entry_price is not None: - points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"}) - if exit_idx is not None and exit_price is not None: - points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"}) - panels.append( - _render_candles_subplot( - rows, - title, - width=cell_w, - height=cell_h, - bg_rgb=(255, 255, 255), - marker_points=points, - ) - ) - - if not panels: - return None - - gap = 10 - cols = 2 - rows_n = int(math.ceil(len(panels) / cols)) - w = cols * cell_w + (cols - 1) * gap - h = rows_n * cell_h + (rows_n - 1) * gap - out = Image.new("RGB", (w, h), (255, 255, 255)) - idx = 0 - for r in range(rows_n): - for c in range(cols): - if idx >= len(panels): - break - x = c * (cell_w + gap) - y = r * (cell_h + gap) - out.paste(panels[idx], (x, y)) - idx += 1 - - # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图) - if ImageDraw and rows_n >= 1: - draw_out = ImageDraw.Draw(out) - line_col = (220, 225, 232) - x_mid = cell_w + gap // 2 - if w > x_mid >= 0: - draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2) - for rr in range(1, rows_n): - y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 - if 0 <= y_mid <= h: - draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) - - target_dir = out_dir or ORDER_CHART_DIR - os.makedirs(target_dir, exist_ok=True) - fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" - out_path = os.path.join(target_dir, fname) - out.save(out_path, format="PNG") - return fname - - -def generate_order_open_chart(exchange_symbol, title_prefix, timeframes=None, limit=None): - return generate_multi_timeframe_chart_png( - exchange_symbol, - title_prefix, - timeframes=timeframes, - limit=limit, - out_dir=ORDER_CHART_DIR, - filename=None, - filename_prefix="order", - ) - - -def journal_coin_from_symbol(symbol): - sym = (symbol or "").strip().upper() - if not sym: - return "" - if "/" in sym: - return sym.split("/")[0].strip() - if "-" in sym: - return sym.split("-")[0].strip() - if sym.endswith("USDT"): - return sym[:-4].strip() - return sym - - -EARLY_EXIT_TRIGGERS = ( - "", - "止盈", - "保本止盈", - "移动止盈", - "手动平仓", - "止损", - "其他", -) - -# 与用户约定的固定开仓类型(仅做这几类单子) -ENTRY_REASON_OPTIONS = ( - "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", - "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", - "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", - "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", - "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", - "关键位箱体突破", - "关键位收敛突破", - "关键位斐波0.618", - "关键位斐波0.786", -) - -STATS_SEGMENT_DEFS = ( - ("all", "全部交易", {"segment": "all"}), - ("manual", "下单监控", {"segment": "manual"}), - ("key_box", "关键位箱体突破", {"segment": "key_box"}), - ("key_conv", "关键位收敛结构", {"segment": "key_conv"}), - ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), - ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), -) -# 复盘表单「其他」选项的 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(f"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '{ORDER_MONITOR_TYPE_MANUAL}'") - except Exception: - pass - try: - c.execute( - "UPDATE order_monitors SET monitor_type=? WHERE monitor_type IS NULL OR TRIM(monitor_type)=''", - (ORDER_MONITOR_TYPE_MANUAL,), - ) - except Exception: - pass - try: - c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") - except: pass - try: - c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") - except: pass - try: - c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") - except: pass - try: - c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") - except: pass - try: - c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") - except: pass - for ddl in ( - "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", - "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", - "ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'", - "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", - "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", - ): - try: - c.execute(ddl) - except Exception: - pass - - try: - c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") - except Exception: - pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") - except Exception: - pass - for col, ddl in ( - ("key_signal_type", "ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT"), - ("exchange_realized_pnl", "ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL"), - ("exchange_opened_at", "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT"), - ("exchange_closed_at", "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT"), - ("exchange_sync_key", "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT"), - ): - try: - c.execute(ddl) - except Exception: - pass - - c.execute( - """CREATE TABLE IF NOT EXISTS key_monitor_history - (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, - upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, - close_reason TEXT, closed_at TEXT)""" - ) - - conn.commit() - conn.close() - -init_db() - -def get_db(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn - - -def app_now(): - """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" - return datetime.now(APP_TZ).replace(tzinfo=None) - - -def app_now_str(): - return app_now().strftime("%Y-%m-%d %H:%M:%S") - - -def utc_now_dt(): - """当前时刻(UTC,aware)。""" - return datetime.now(timezone.utc) - - -def utc_calendar_date_str(): - """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" - return utc_now_dt().strftime("%Y-%m-%d") - - -def get_trading_day(now=None): - """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" - now = now or app_now() - if getattr(now, "tzinfo", None): - now = now.astimezone(APP_TZ).replace(tzinfo=None) - if now.hour < TRADING_DAY_RESET_HOUR: - return (now - timedelta(days=1)).strftime("%Y-%m-%d") - return now.strftime("%Y-%m-%d") - - -TRADE_COMPLETED_RESULTS = ( - "止盈", - "止损", - "保本止盈", - "移动止盈", - "手动平仓", - "强制清仓", - "外部平仓", -) - -REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓") - - -def parse_dt_for_trading_day(s): - if not s: - return None - s = str(s).strip().replace("Z", "").replace("T", " ") - if not s: - return None - for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): - try: - return datetime.strptime(s[:ln], fmt) - except ValueError: - continue - return None - - -def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): - conn.execute( - """INSERT INTO key_monitor_history - (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) - VALUES (?,?,?,?,?,?,?,?,?)""", - ( - row["symbol"], - row["monitor_type"], - row["direction"] or "long", - row["upper"], - row["lower"], - int(notification_count or 0), - (last_msg or "")[:800] if last_msg else None, - close_reason, - app_now_str(), - ), - ) - - -def _session_week_bounds(trading_day_str): - end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() - start = end - timedelta(days=6) - return start.strftime("%Y-%m-%d"), trading_day_str - - -def _calendar_month_bounds(local_dt): - y, m = local_dt.year, local_dt.month - start = f"{y:04d}-{m:02d}-01" - if m == 12: - end_d = datetime(y, 12, 31).date() - else: - end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() - return start, end_d.strftime("%Y-%m-%d") - - -def _count_opens_between(conn, start_td, end_td): - return _count_opens_for_segment(conn, start_td, end_td, "all") - - -def _list_window_from_request(): - return resolve_window(request.args, default_preset=PRESET_UTC_TODAY) - - -def _pnl_row_matches_segment(row, segment_key): - try: - mt = (row["monitor_type"] or "").strip() - kst = (row["key_signal_type"] or "").strip() - except Exception: - return False - if segment_key == "all": - return True - if segment_key == "manual": - return mt == ORDER_MONITOR_TYPE_MANUAL and not kst - if segment_key == "key_box": - return kst == "箱体突破" - if segment_key == "key_conv": - return kst == "收敛突破" - if segment_key == "key_fib618": - return kst == "斐波回调0.618" - if segment_key == "key_fib786": - return kst == "斐波回调0.786" - return False - - -def _count_opens_for_segment(conn, start_td, end_td, segment_key): - if segment_key == "manual": - return conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " - "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " - "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", - (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), - ).fetchone()[0] - kst_map = { - "key_box": "箱体突破", - "key_conv": "收敛突破", - "key_fib618": "斐波回调0.618", - "key_fib786": "斐波回调0.786", - } - kst = kst_map.get(segment_key) - if kst: - return conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?", - (start_td, end_td, kst), - ).fetchone()[0] - return conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", - (start_td, end_td), - ).fetchone()[0] - - -def _load_completed_trade_pnls(conn): - q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, - result, reviewed_result, monitor_type, key_signal_type - FROM trade_records - ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" - rows = conn.execute(q).fetchall() - out = [] - for r in rows: - effective_result = (r["reviewed_result"] or r["result"] or "").strip() - if effective_result not in TRADE_COMPLETED_RESULTS: - continue - try: - p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) - except (TypeError, ValueError): - p = 0.0 - t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) - td = get_trading_day(t) if t else None - out.append((p, t, td, r)) - 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), FUNDS_DECIMALS) - loss_sum_raw = sum(p for p, _, _ in trades if p < 0) - loss_sum_u = round(abs(loss_sum_raw), FUNDS_DECIMALS) 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), FUNDS_DECIMALS) if neg_pnls else None - max_single_profit = round(max(pos_pnls), FUNDS_DECIMALS) 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, FUNDS_DECIMALS) - 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], FUNDS_DECIMALS) - 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): - """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" - now_dt = now_dt or app_now() - pnls = _load_completed_trade_pnls(conn) - total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] - w_start, w_end = _session_week_bounds(trading_day) - m_start, m_end = _calendar_month_bounds(now_dt) - - def slice_metrics(seg_key): - seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)] - day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] - week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] - month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] - dm = _compute_period_metrics(day_tr) - wm = _compute_period_metrics(week_tr) - mm = _compute_period_metrics(month_tr) - dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) - wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) - mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) - dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" - wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" - mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" - return dm, wm, mm - - segments = [] - for seg_key, seg_title, _meta in STATS_SEGMENT_DEFS: - dm, wm, mm = slice_metrics(seg_key) - segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm}) - - dm, wm, mm = slice_metrics("all") - - return { - "trading_day": trading_day, - "total_opens_all": total_opens_all, - "day": dm, - "week": wm, - "month": mm, - "segments": segments, - "stats_reset_hour": TRADING_DAY_RESET_HOUR, - } - - -def infer_leverage(symbol): - sym = (symbol or "").strip().upper() - if sym.startswith("BTC") or sym.startswith("ETH"): - return BTC_LEVERAGE - return ALT_LEVERAGE - - -def normalize_exchange_symbol(symbol): - sym = symbol.strip().upper() - if ":" in sym: - return sym - if "/" in sym: - base, quote = sym.split("/", 1) - quote_clean = quote.split(":")[0] - return f"{base}/{quote_clean}:{quote_clean}" - return sym - - -def resolve_monitor_exchange_symbol(row): - """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" - raw = "" - try: - if row["exchange_symbol"]: - raw = str(row["exchange_symbol"]).strip() - except (KeyError, IndexError, TypeError): - raw = "" - if not raw: - try: - raw = str(row["symbol"] or "").strip() - except (KeyError, IndexError, TypeError): - raw = "" - return normalize_exchange_symbol(raw) if raw else "" - - -def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): - if not position_symbol or not wanted_exchange_symbol: - return False - a = normalize_exchange_symbol(str(position_symbol).strip()) - b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip()) - return a == b - - -def _row_matches_monitor_direction(direction, position_dict): - """ - 判断持仓行是否属于当前监控方向。 - 币安双向持仓为 LONG/SHORT;单向持仓常为 BOTH,此时不能用 side!=direction 过滤, - 否则会把整行跳过(live 恒为 0),平仓数量错误甚至误判「无仓」。 - """ - if not position_dict: - return False - direction = (direction or "").strip().lower() - info = position_dict.get("info", {}) or {} - ps = str( - info.get("positionSide") - or position_dict.get("side") - or info.get("posSide") - or "" - ).strip().lower() - signed_amt = None - for key in ("positionAmt", "pos", "size"): - v = info.get(key) - if v is None or v == "": - continue - try: - signed_amt = float(v) - break - except (TypeError, ValueError): - continue - if BINANCE_POSITION_MODE != "hedge": - return True - if ps in ("long", "short"): - return ps == direction - if ps in ("both", "net") or ps == "": - if signed_amt is None: - return True - if direction == "long": - return signed_amt > 0 - if direction == "short": - return signed_amt < 0 - return False - if ps and ps != direction: - return False - return True - - -def _position_matches_wanted_contract(wanted_unified_sym, position_dict): - """统一 symbol 比对;不一致时用交易所原始合约代码与 ccxt market.id 对齐(兼容命名差异)。""" - if not wanted_unified_sym or not position_dict: - return False - ps = position_dict.get("symbol") - if _position_contract_symbol_match(ps, wanted_unified_sym): - return True - try: - ensure_markets_loaded() - mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper() - info = position_dict.get("info") or {} - c_raw = str(info.get("contract") or info.get("symbol") or info.get("pair") or "").strip().upper() - if mid and c_raw and mid == c_raw: - return True - except Exception: - pass - return False - - -def _position_row_effective_contracts(p): - """持仓数量:优先 ccxt contracts,否则用交易所原始 positionAmt/size/pos(避免统一层为 0 时被误判空仓)。""" - if not p: - return 0.0 - info = p.get("info") or {} - for val in (p.get("contracts"), info.get("positionAmt"), info.get("size"), info.get("pos")): - if val is None or val == "": - continue - try: - x = abs(float(val)) - if x > 0: - return x - except (TypeError, ValueError): - continue - return 0.0 - - -def normalize_symbol_input(symbol): - sym = (symbol or "").strip().upper() - if not sym: - return "" - if "/" in sym: - return sym - if ":" in sym: - sym = sym.split(":")[0] - return f"{sym}/USDT" - - -def normalize_kline_limit(limit_raw, default=200): - try: - n = int(limit_raw) - except Exception: - return default - return 200 if n >= 200 else 100 - - -def get_recommended_capital(current_capital): - if current_capital <= DAILY_LOSS_CAPITAL: - return DAILY_LOSS_CAPITAL - if current_capital >= DAILY_PROFIT_CAPITAL: - return DAILY_PROFIT_CAPITAL - return DAILY_START_CAPITAL - - -def ensure_session(conn, session_date): - row = conn.execute( - "SELECT * FROM trading_sessions WHERE session_date = ?", - (session_date,) - ).fetchone() - if row: - return row - conn.execute( - "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", - (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) - ) - conn.commit() - return conn.execute( - "SELECT * FROM trading_sessions WHERE session_date = ?", - (session_date,) - ).fetchone() - - -def update_session_capital(conn, session_date, pnl_amount): - session_row = ensure_session(conn, session_date) - new_capital = float(session_row["current_capital"]) + float(pnl_amount) - conn.execute( - "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", - (round(new_capital, FUNDS_DECIMALS), session_date) - ) - conn.commit() - return round(new_capital, FUNDS_DECIMALS) - - -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")) - open_stop = item.get("initial_stop_loss") - if open_stop in (None, ""): - open_stop = base_stop - item["display_open_stop_loss"] = open_stop - item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_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 "" - try: - _keys = row.keys() if hasattr(row, "keys") else [] - except Exception: - _keys = [] - _reviewed_pnl_raw = row["reviewed_pnl_amount"] if "reviewed_pnl_amount" in _keys else None - has_reviewed_pnl = _reviewed_pnl_raw is not None and str(_reviewed_pnl_raw).strip() != "" - ex_pnl = item.get("exchange_realized_pnl") - if not has_reviewed_pnl and ex_pnl is not None and str(ex_pnl).strip() != "": - try: - item["effective_pnl_amount"] = round(float(ex_pnl), FUNDS_DECIMALS) - item["display_pnl_source"] = "exchange" - ex_open = (str(item.get("exchange_opened_at") or "").strip() or None) - ex_close = (str(item.get("exchange_closed_at") or "").strip() or None) - if ex_open: - item["effective_opened_at"] = ex_open - if ex_close: - item["effective_closed_at"] = ex_close - except (TypeError, ValueError): - item["display_pnl_source"] = "local" - elif has_reviewed_pnl: - item["display_pnl_source"] = "reviewed" - else: - item["display_pnl_source"] = "local" - return item - - -# USDT 等资金类:展示与入库舍入统一为 2 位小数(与交易所常见口径一致) -FUNDS_DECIMALS = 2 - - -def format_funds_u(value): - if value in (None, ""): - return "-" - try: - return f"{float(value):.{FUNDS_DECIMALS}f}" - except (TypeError, ValueError): - return str(value) - - -def round_funds(value): - try: - return round(float(value), FUNDS_DECIMALS) - except (TypeError, ValueError): - return None - - -def _ccxt_swap_symbol_for_precision(symbol): - """解析为 ccxt markets 中的永续 symbol,供 price_to_precision 使用。""" - raw = (symbol or "").strip() - if not raw: - return None - try: - ensure_markets_loaded() - markets = getattr(exchange, "markets", {}) or {} - except Exception: - return None - upper = raw.upper().replace(" ", "") - candidates = [] - candidates.append(normalize_exchange_symbol(raw)) - if upper.endswith("USDT") and len(upper) > 4 and "/" not in raw and ":" not in raw: - candidates.append(f"{upper[:-4]}/USDT:USDT") - if "/" not in raw and ":" not in raw and upper.isalnum() and not upper.endswith("USDT"): - candidates.append(f"{upper}/USDT:USDT") - for c in candidates: - if c and c in markets: - return c - return None - - -def format_price_for_symbol(symbol, value): - if value in (None, ""): - return "-" - try: - v = float(value) - except (TypeError, ValueError): - return str(value) - if v == 0: - return "0" - try: - ex_sym = _ccxt_swap_symbol_for_precision(symbol) - if ex_sym: - return str(exchange.price_to_precision(ex_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 round_price_to_exchange(exchange_symbol, price): - """将价格按 U 本位永续 tick 取整;失败返回 None。""" - if price is None: - return None - try: - ensure_markets_loaded() - sym = normalize_exchange_symbol(exchange_symbol) - return float(exchange.price_to_precision(sym, float(price))) - except Exception: - return None - - -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, notional_usdt=None): - """估算盈亏(USDT)。优先用名义价值 notional_usdt,否则 margin×leverage。""" - try: - trigger = float(trigger_price) - exit_p = float(exit_price) - if trigger <= 0: - return 0.0 - if notional_usdt is not None: - notional = float(notional_usdt) - else: - margin = float(margin_capital) - lev = float(leverage) - notional = margin * lev - if notional <= 0: - return 0.0 - if direction == "short": - pnl_ratio = (trigger - exit_p) / trigger - else: - pnl_ratio = (exit_p - trigger) / trigger - return round(notional * pnl_ratio, FUNDS_DECIMALS) - except Exception: - return 0.0 - - -def get_plan_notional_usdt(row_or_dict): - """计划名义价值(USDT),与开仓 sizing 口径一致。""" - if row_or_dict is None: - return None - try: - if hasattr(row_or_dict, "keys"): - nv = row_or_dict["notional_value"] if "notional_value" in row_or_dict.keys() else None - margin = row_or_dict["margin_capital"] if "margin_capital" in row_or_dict.keys() else None - lev = row_or_dict["leverage"] if "leverage" in row_or_dict.keys() else None - sym = row_or_dict["symbol"] if "symbol" in row_or_dict.keys() else "" - else: - nv = row_or_dict.get("notional_value") - margin = row_or_dict.get("margin_capital") - lev = row_or_dict.get("leverage") - sym = row_or_dict.get("symbol") or "" - except Exception: - return None - try: - if nv is not None and str(nv).strip() != "": - v = float(nv) - if v > 0: - return round(v, FUNDS_DECIMALS) - except (TypeError, ValueError): - pass - try: - margin = float(margin or 0) - lev = float(lev or infer_leverage(sym) or 0) - if margin > 0 and lev > 0: - return round(margin * lev, FUNDS_DECIMALS) - except (TypeError, ValueError): - pass - return None - - -def _trade_ids_from_fills(trades): - """仅使用 Binance 原始 tradeId(与 income 流水一致),不用 ccxt 的 id。""" - ids = set() - for t in trades or []: - info = t.get("info") if isinstance(t.get("info"), dict) else {} - for k in ("tradeId", "trade_id"): - v = info.get(k) - if v is not None and str(v).strip() != "": - ids.add(str(v).strip()) - return ids - - -def _cluster_closing_trades_near_close(trades, closed_ms, spread_ms=8 * 60 * 1000): - """只保留平仓时刻附近的一簇减仓成交,避免把相邻其它仓位算进来。""" - if not trades: - return [] - if closed_ms is None: - return list(trades) - try: - closed_ms = int(closed_ms) - except (TypeError, ValueError): - return list(trades) - scored = [] - for t in trades: - ts = _coerce_ts_ms(t.get("timestamp")) - if ts is None: - continue - scored.append((abs(ts - closed_ms), t)) - if not scored: - return list(trades) - scored.sort(key=lambda x: x[0]) - anchor_ts = _coerce_ts_ms(scored[0][1].get("timestamp")) - if anchor_ts is None: - return [scored[0][1]] - return [ - t - for t in trades - if _coerce_ts_ms(t.get("timestamp")) is not None - and abs(_coerce_ts_ms(t.get("timestamp")) - anchor_ts) <= spread_ms - ] - - -def _income_entry_trade_id(entry): - if not isinstance(entry, dict): - return "" - info = entry.get("info") if isinstance(entry.get("info"), dict) else {} - for src in (entry, info): - for k in ("tradeId", "trade_id"): - v = src.get(k) - if v is not None and str(v).strip() != "": - return str(v).strip() - return "" - - -def calc_binance_realized_pnl_from_trades(trades): - """仅汇总成交回报中的 realizedPnl(勿再扣 commission,避免与 income 重复)。""" - if not trades: - return None - total = 0.0 - has = False - for t in trades: - info = t.get("info") if isinstance(t.get("info"), dict) else {} - v = info.get("realizedPnl") - if v is None or str(v).strip() == "": - v = t.get("realizedPnl") or t.get("realized_pnl") - if v is None or str(v).strip() == "": - continue - try: - total += float(v) - has = True - except (TypeError, ValueError): - pass - if not has: - return None - return round(total, FUNDS_DECIMALS) - - -def _sum_binance_income(entries, income_types, trade_ids=None): - net = 0.0 - first_t = None - last_t = None - strict = bool(trade_ids) - for e in entries: - it = (e.get("incomeType") or e.get("income_type") or "").strip() - if it not in income_types: - continue - if strict: - if it in ("REALIZED_PNL", "COMMISSION"): - tid = _income_entry_trade_id(e) - if not tid or tid not in trade_ids: - continue - else: - continue - elif trade_ids and it in ("REALIZED_PNL", "COMMISSION"): - tid = _income_entry_trade_id(e) - if tid and tid not in trade_ids: - continue - try: - net += float(e.get("income") or 0) - except (TypeError, ValueError): - pass - t = _coerce_ts_ms(e.get("time")) - if t: - first_t = t if first_t is None else min(first_t, t) - last_t = t if last_t is None else max(last_t, t) - if first_t is None: - return None, None, None - return round(net, FUNDS_DECIMALS), first_t, last_t - - -def calc_pnl_from_closing_trades(direction, entry_price, trades, exchange_symbol=None): - """按减仓成交数量×价差汇总盈亏(不含资金费;比单点标记价更接近交易所)。""" - try: - entry = float(entry_price) - except (TypeError, ValueError): - return None - if entry <= 0 or not trades: - return None - contract_size = 1.0 - if exchange_symbol and BINANCE_API_KEY and BINANCE_API_SECRET: - try: - ensure_markets_loaded() - contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1) - except Exception: - contract_size = 1.0 - pnl = 0.0 - qty = 0.0 - for t in trades: - try: - price = float(t.get("price") or 0) - amount = float(t.get("amount") or 0) * contract_size - except (TypeError, ValueError): - continue - if price <= 0 or amount <= 0: - continue - qty += amount - if direction == "short": - pnl += amount * (entry - price) - else: - pnl += amount * (price - entry) - if qty <= 0: - return None - return round(pnl, FUNDS_DECIMALS) - - -def resolve_trade_pnl_amount( - row, - entry_price, - exit_price=None, - opened_at_str=None, - opened_at_ms=None, - closed_at_str=None, - closed_at_ms=None, -): - """ - 平仓盈亏:优先 Binance income 净额(含手续费),其次按减仓成交汇总,最后用计划名义×涨跌。 - 返回 (pnl, exit_price, exchange_opened_at, exchange_closed_at, exchange_sync_key)。 - """ - direction = (row["direction"] if hasattr(row, "keys") else row.get("direction") or "long").strip().lower() - sym = row["symbol"] if hasattr(row, "keys") else row.get("symbol") - ex_sym = ( - row["exchange_symbol"] - if hasattr(row, "keys") and "exchange_symbol" in row.keys() - else row.get("exchange_symbol") - ) or normalize_exchange_symbol(sym) - open_ms = _to_ms_with_fallback( - opened_at_ms if opened_at_ms is not None else (row["opened_at_ms"] if hasattr(row, "keys") and "opened_at_ms" in row.keys() else None), - opened_at_str or (row["opened_at"] if hasattr(row, "keys") else row.get("opened_at")), - ) - close_ms = _to_ms_with_fallback( - closed_at_ms, - closed_at_str, - ) - closing_trades = [] - if open_ms and (close_ms or closed_at_str): - closing_trades = fetch_closing_fills_for_record( - ex_sym, - direction, - opened_at_str or (row["opened_at"] if hasattr(row, "keys") else ""), - closed_at_str, - opened_at_ms=open_ms, - closed_at_ms=close_ms, - ) - if closing_trades and close_ms: - closing_trades = _cluster_closing_trades_near_close(closing_trades, int(close_ms)) - if closing_trades: - wexit = calc_weighted_exit_price(closing_trades) - if wexit and (exit_price is None or float(exit_price or 0) <= 0): - exit_price = wexit - last_ts = closing_trades[-1].get("timestamp") - if last_ts and not closed_at_str: - closed_at_str = ms_to_app_local_str(int(last_ts)) - close_ms = int(last_ts) - net, sync_key, eo, ec = fetch_binance_net_pnl_for_trade( - ex_sym, direction, open_ms, close_ms, closing_trades=closing_trades - ) - if net is not None: - return net, exit_price, eo, ec, sync_key - if closing_trades: - trade_pnl = calc_binance_realized_pnl_from_trades(closing_trades) - if trade_pnl is not None: - return trade_pnl, exit_price, None, None, None - fill_pnl = calc_pnl_from_closing_trades(direction, entry_price, closing_trades, ex_sym) - if fill_pnl is not None: - return fill_pnl, exit_price, None, None, None - notional = get_plan_notional_usdt(row) - margin = row["margin_capital"] if hasattr(row, "keys") else row.get("margin_capital") - lev = row["leverage"] if hasattr(row, "keys") else row.get("leverage") - if exit_price: - pnl = calc_pnl( - direction, - entry_price, - exit_price, - margin or DAILY_START_CAPITAL, - lev or infer_leverage(sym), - notional_usdt=notional, - ) - return pnl, exit_price, None, None, None - return 0.0, exit_price, None, None, None - - -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, FUNDS_DECIMALS) - 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, - key_signal_type=None, - entry_reason=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) - kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) - snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss - er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or "" - cur = conn.execute( - "INSERT INTO trade_records (symbol,monitor_type,key_signal_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,entry_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, 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, er or None - ) - ) - return int(cur.lastrowid or 0) - - -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, FUNDS_DECIMALS) 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 - if not (item.get("monitor_type") or "").strip(): - item["monitor_type"] = ORDER_MONITOR_TYPE_MANUAL - return item - - -def ensure_exchange_live_ready(): - if not LIVE_TRADING_ENABLED: - return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" - if not (BINANCE_API_KEY and BINANCE_API_SECRET): - return False, "缺少 Binance API 密钥配置(BINANCE_API_KEY / BINANCE_API_SECRET)" - return True, "" - - -def order_row_monitor_type(row): - if row is None: - return ORDER_MONITOR_TYPE_MANUAL - try: - keys = row.keys() if hasattr(row, "keys") else [] - except Exception: - keys = [] - if "monitor_type" in keys: - mt = (row["monitor_type"] or "").strip() - if mt: - return mt - return ORDER_MONITOR_TYPE_MANUAL - - -def order_row_key_signal_type(row): - if row is None: - return None - try: - keys = row.keys() if hasattr(row, "keys") else [] - except Exception: - keys = [] - if "key_signal_type" not in keys: - return None - kst = (row["key_signal_type"] or "").strip() - if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst): - return kst - return None - - -def exchange_private_api_configured(): - """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。""" - return bool(BINANCE_API_KEY and BINANCE_API_SECRET) - - -def _extract_usdt_total(balance): - usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} - total_map = balance.get("total", {}) if isinstance(balance, dict) else {} - free_map = balance.get("free", {}) if isinstance(balance, dict) else {} - total = usdt_info.get("total") - if total is None: - total = usdt_info.get("equity") - if total is None: - total = total_map.get("USDT") - if total is None: - total = usdt_info.get("free") - if total is None: - total = free_map.get("USDT") - try: - return float(total) if total is not None else None - except Exception: - return None - - -def _extract_usdt_free(balance): - usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} - free_map = balance.get("free", {}) if isinstance(balance, dict) else {} - free = usdt_info.get("free") - if free is None: - free = free_map.get("USDT") - try: - return float(free) if free is not None else None - except Exception: - return None - - -def _binance_futures_usdt_asset_row(balance): - """从 U 本位合约 fetch_balance 的 info.assets 中取 USDT 一行(与币安后台口径一致)。""" - if not isinstance(balance, dict): - return None - info = balance.get("info") - if not isinstance(info, dict): - return None - assets = info.get("assets") - if not isinstance(assets, list): - return None - for a in assets: - if isinstance(a, dict) and str(a.get("asset") or "").upper() == "USDT": - return a - return None - - -def _fetch_binance_swap_usdt_total(): - """仅 U 本位永续合约账户 USDT(总额口径:优先 marginBalance / walletBalance,不回退现货)。""" - try: - ensure_markets_loaded() - bal = exchange.fetch_balance(params={"type": "swap"}) - row = _binance_futures_usdt_asset_row(bal) - if row: - for k in ("marginBalance", "walletBalance", "crossWalletBalance", "balance"): - x = row.get(k) - if x is not None and str(x).strip() != "": - try: - fv = float(x) - if fv >= 0: - return fv - except (TypeError, ValueError): - pass - v = _extract_usdt_total(bal) - return float(v) if v is not None else None - except Exception: - return None - - -def _fetch_binance_swap_usdt_free(): - """U 本位合约账户 USDT 可用(开仓可用保证金口径,不回退现货)。""" - try: - ensure_markets_loaded() - bal = exchange.fetch_balance(params={"type": "swap"}) - row = _binance_futures_usdt_asset_row(bal) - if row: - for k in ("availableBalance", "maxWithdrawAmount"): - x = row.get(k) - if x is not None and str(x).strip() != "": - try: - fv = float(x) - if fv >= 0: - return fv - except (TypeError, ValueError): - pass - return _extract_usdt_free(bal) - except Exception: - return None - - -def _fetch_binance_funding_usdt(): - """Binance 资金账户(Funding Wallet)USDT 总额。""" - try: - ensure_markets_loaded() - bal = exchange.fetch_balance(params={"type": "funding"}) - val = _extract_usdt_total(bal) - if val is not None: - return float(val) - except Exception: - pass - return None - - -def get_available_trading_usdt(): - ok_live, _ = ensure_exchange_live_ready() - if not ok_live: - return None - return _fetch_binance_swap_usdt_free() - - -def get_synced_leverage(exchange_symbol, direction): - ensure_markets_loaded() - try: - positions = exchange.fetch_positions([exchange_symbol]) - for p in positions: - if not _position_matches_wanted_contract(exchange_symbol, p): - continue - if not _row_matches_monitor_direction(direction, p): - continue - info = p.get("info", {}) or {} - if lev is None or lev == 0 or str(lev) == "0": - lev = info.get("cross_leverage_limit") or info.get("leverage") - if lev: - try: - return int(float(lev)) - except Exception: - pass - except Exception: - pass - return None - - -def friendly_exchange_error(err, available_usdt=None): - msg = str(err) - low = msg.lower() - if ( - "51008" in msg - or "insufficient" in low - or "margin" in low and ("not enough" in low or "不足" in msg) - or "balance" in low and "insufficient" in low - ): - tail = f"(当前交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U)" if available_usdt is not None else "" - return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。" - clean = re.sub(r"\s+", " ", msg).strip() - return f"交易所下单失败:{clean}" - - -def get_exchange_capitals(force=False): - ok_live, _ = ensure_exchange_live_ready() - if not ok_live: - return None, None - now_ts = time.time() - if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: - return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] - try: - ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt() - except Exception: - ACCOUNT_BALANCE_CACHE["funding_usdt"] = None - try: - ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total() - except Exception: - # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 - ACCOUNT_BALANCE_CACHE["trading_usdt"] = None - ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts - return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] - - -def execute_transfer_usdt(amount, from_account, to_account): - if amount <= 0: - return False, "划转金额必须大于0", None - ok_live, reason = ensure_exchange_live_ready() - if not ok_live: - return False, reason, None - try: - resp = exchange.transfer(TRANSFER_CCY, float(amount), from_account, to_account) - return True, "划转成功", resp - except Exception as e: - msg = str(e) - if "INVALID_KEY" in msg or "Invalid key" in msg or "-2015" in msg: - msg += ( - "。常见原因:① BINANCE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" - "③ API Key 未勾选「允许合约」「允许万向划转」等所需权限;④ Key 已重置或权限变更。" - ) - return False, msg, None - - -def get_account_usdt_total(account_type): - """读取各账户 USDT。funding 走资金钱包;swap 仅合约账户;spot 仅现货。""" - raw = (account_type or "").strip().lower() - if raw == "funding": - return _fetch_binance_funding_usdt() - if raw == "swap": - return _fetch_binance_swap_usdt_total() - try: - ensure_markets_loaded() - bal = exchange.fetch_balance(params={"type": raw}) - val = _extract_usdt_total(bal) - if val is not None: - return val - return 0.0 if raw == "spot" else None - except Exception: - return None - - -def auto_transfer_once_per_day(): - if not AUTO_TRANSFER_ENABLED: - return - utc_dt = utc_now_dt() - bj = utc_dt.astimezone(APP_TZ) - if bj.hour != AUTO_TRANSFER_BJ_HOUR: - return - transfer_day = utc_calendar_date_str() - conn = get_db() - exists = conn.execute( - "SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?", - ("auto_daily", transfer_day) - ).fetchone() - if exists: - conn.close() - return - target_amount = AUTO_TRANSFER_AMOUNT - to_balance = get_account_usdt_total(AUTO_TRANSFER_TO) - from_balance = get_account_usdt_total(AUTO_TRANSFER_FROM) - if to_balance is None: - conn.execute( - "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", - ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"读取{AUTO_TRANSFER_TO}账户USDT失败") - ) - conn.commit() - conn.close() - return - needed = round(max(target_amount - float(to_balance), 0), FUNDS_DECIMALS) - 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, FUNDS_DECIMALS)}U") - ) - conn.commit() - conn.close() - send_wechat_msg( - f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{needed}U,当前{round(from_balance, FUNDS_DECIMALS)}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): - return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) - - -def clear_key_sizing_snapshot_if_flat(conn, session_date): - if get_active_position_count(conn) > 0: - return - conn.execute( - "UPDATE trading_sessions SET key_sizing_capital_snapshot = NULL, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", - (session_date,), - ) - conn.commit() - - -def get_key_sizing_capital_snapshot(conn, session_date): - row = ensure_session(conn, session_date) - try: - val = row["key_sizing_capital_snapshot"] - except (KeyError, IndexError): - return None - if val is None: - return None - try: - return float(val) - except (TypeError, ValueError): - return None - - -def set_key_sizing_capital_snapshot(conn, session_date, capital): - ensure_session(conn, session_date) - conn.execute( - "UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", - (round(float(capital), FUNDS_DECIMALS), session_date), - ) - conn.commit() - - -def resolve_capital_base_for_key_open(conn, trading_day, live_capital): - """关键位自动开仓:有仓时用无仓时资金快照计仓(可配置)。""" - live = float(live_capital) - active = get_active_position_count(conn) - if active <= 0: - set_key_sizing_capital_snapshot(conn, trading_day, live) - return live - if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT: - snap = get_key_sizing_capital_snapshot(conn, trading_day) - if snap is not None and snap > 0: - return snap - return live - - -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})" - if direction not in ("long", "short"): - return False, "方向必须为 long 或 short" - if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): - expected = BTC_LEVERAGE - else: - expected = ALT_LEVERAGE - if expected <= 0: - return False, "杠杆配置异常" - return True, "" - - -def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): - ensure_markets_loaded() - notional = float(margin_capital) * float(leverage) - ticker = exchange.fetch_ticker(exchange_symbol) - price = float(ticker.get("last") or fallback_price) - if price <= 0: - raise ValueError("触发价必须大于 0") - market = exchange.market(exchange_symbol) - contract_size = float(market.get("contractSize") or 1) - if market.get("contract"): - # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 - amount = notional / (price * contract_size) - else: - amount = notional / price - min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") - if min_amount and amount < float(min_amount): - raise ValueError(f"下单数量过小,最小数量为 {min_amount}") - amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) - if amount_precise <= 0: - raise ValueError("下单数量精度后为 0,请提高基数或降低价格") - return amount_precise, price - - -def _to_positive_float(value): - try: - n = float(value) - return n if n > 0 else None - except Exception: - return None - - -def _extract_order_price_value(order_obj): - if not isinstance(order_obj, dict): - return None - for key in ("average", "price"): - v = _to_positive_float(order_obj.get(key)) - if v is not None: - return v - cost = _to_positive_float(order_obj.get("cost")) - filled = _to_positive_float(order_obj.get("filled")) - if cost is not None and filled is not None and filled > 0: - return cost / filled - info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} - for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): - v = _to_positive_float(info.get(key)) - if v is not None: - return v - return None - - -def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): - price = _extract_order_price_value(order_resp) - if price is not None: - return round(price, 8) - order_id = (order_resp or {}).get("id") - if order_id: - try: - fetched = exchange.fetch_order(order_id, exchange_symbol) - fetched_price = _extract_order_price_value(fetched) - if fetched_price is not None: - return round(fetched_price, 8) - except Exception: - pass - fallback = _to_positive_float(fallback_price) - return round(fallback, 8) if fallback is not None else 0.0 - - -def get_contract_size(exchange_symbol): - ensure_markets_loaded() - market = exchange.market(exchange_symbol) - return float(market.get("contractSize") or 1) - - -def parse_positive_float(value): - if value is None: - return None - raw = str(value).strip() - if not raw: - return None - num = float(raw) - if num <= 0: - raise ValueError("数值必须大于0") - return num - - -def build_binance_order_params(direction, reduce_only=False): - params = {} - if BINANCE_POSITION_MODE == "hedge": - params["positionSide"] = "LONG" if direction == "long" else "SHORT" - if reduce_only: - params["reduceOnly"] = True - return params - - -def _binance_market_close_param_candidates(direction): - """ - 平仓市价单参数组合(按顺序尝试)。 - 部分币安 U 本位账户对市价减仓报 -1106「reduceOnly sent when not required」, - 与条件单一致,需再试不带 reduceOnly 的写法;另保留双向/单向 positionSide 切换。 - """ - ps = "LONG" if direction == "long" else "SHORT" - hedge_ro = {"positionSide": ps, "reduceOnly": True} - hedge_plain = {"positionSide": ps} - oneway_ro = {"reduceOnly": True} - oneway_plain = {} - if BINANCE_POSITION_MODE == "hedge": - return [hedge_ro, hedge_plain, oneway_ro, oneway_plain] - return [oneway_ro, oneway_plain, hedge_ro, hedge_plain] - - -def _is_binance_close_param_retryable(err_msg): - s = (err_msg or "").lower() - if "-4061" in s: - return True - if "-1106" in s and ("reduceonly" in s or "reduce only" in s): - return True - if "position side" in s or "positionside" in s: - return True - if "dual side" in s or "position mode" in s: - return True - return False - - -def _filled_amount_for_tpsl(order, fallback_amount): - for key in ("filled", "amount"): - v = order.get(key) - try: - fv = float(v) - if fv > 0: - return fv - except Exception: - pass - return float(fallback_amount) - - -def _binance_trigger_order_params(): - p = {} - if BINANCE_TRIGGER_WORKING_TYPE: - p["workingType"] = BINANCE_TRIGGER_WORKING_TYPE - return p - - -def _binance_place_tp_sl_orders(exchange_symbol, direction, position_amount, stop_loss, take_profit): - """ - Binance USDT-M 永续:市价开仓成交后,挂 STOP_MARKET(止损)与 TAKE_PROFIT_MARKET(止盈)。 - 双向持仓时带 positionSide。不显式传 reduceOnly(否则会报 -1106 Parameter 'reduceOnly' sent when not required)。 - """ - ensure_markets_loaded() - market = exchange.market(exchange_symbol) - if not market.get("swap"): - raise RuntimeError("仅支持永续合约 symbol") - close_side = "sell" if direction == "long" else "buy" - amt = float(exchange.amount_to_precision(exchange_symbol, float(position_amount))) - if amt <= 0: - raise RuntimeError("止盈止损:可平数量经精度舍入后为 0") - sl_px = exchange.price_to_precision(exchange_symbol, float(stop_loss)) - tp_px = exchange.price_to_precision(exchange_symbol, float(take_profit)) - common = dict(_binance_trigger_order_params()) - if BINANCE_POSITION_MODE == "hedge": - common["positionSide"] = "LONG" if direction == "long" else "SHORT" - last_err = None - for attempt in range(8): - try: - exchange.create_order( - exchange_symbol, - "STOP_MARKET", - close_side, - amt, - None, - dict(common, stopPrice=sl_px), - ) - time.sleep(0.05) - exchange.create_order( - exchange_symbol, - "TAKE_PROFIT_MARKET", - close_side, - amt, - None, - dict(common, stopPrice=tp_px), - ) - return - except Exception as e: - last_err = e - try: - cancel_binance_futures_open_orders(exchange_symbol) - except Exception: - pass - time.sleep(0.2 * (attempt + 1)) - raise RuntimeError(f"Binance 未接受止盈/止损触发单:{last_err}") - - -def ensure_markets_loaded(force=False): - global MARKETS_LOADED - if force or not MARKETS_LOADED: - exchange.load_markets(reload=force) - MARKETS_LOADED = True - - -def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): - ensure_markets_loaded() - mm = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" - try: - exchange.set_margin_mode(mm, exchange_symbol) - except Exception: - pass - exchange.set_leverage(leverage, exchange_symbol) - side = "buy" if direction == "long" else "sell" - params = build_binance_order_params(direction, reduce_only=False) - order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) - order.setdefault("tpsl_attached", False) - if stop_loss and take_profit: - try: - pos_amt = _filled_amount_for_tpsl(order, amount) - _binance_place_tp_sl_orders(exchange_symbol, direction, pos_amt, stop_loss, take_profit) - order["tpsl_attached"] = True - except RuntimeError: - raise - except Exception as e: - raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e - return order - - -def close_exchange_order(order_row): - """ - 市价全平。数量优先取交易所当前持仓张数,避免仅用入库的 order_amount - 导致「只平一部分 → 撤单后委托没了但仓位还在」(加仓、精度或成交与计划不一致时常见)。 - """ - ensure_markets_loaded() - exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"]) - direction = order_row["direction"] - db_amt = float(order_row["order_amount"] or 0) - side = "sell" if direction == "long" else "buy" - last_resp = None - for _ in range(3): - live = get_live_position_contracts(exchange_symbol, direction) - if live is not None and live > 0: - raw_amt = live - else: - raw_amt = db_amt - if raw_amt <= 0: - if last_resp is not None: - return last_resp - raise ValueError("平仓失败:缺少有效下单数量") - try: - amount = float(exchange.amount_to_precision(exchange_symbol, raw_amt)) - except Exception: - amount = float(raw_amt) - if amount <= 0: - if last_resp is not None: - return last_resp - raise ValueError("平仓失败:数量经精度舍入后为 0") - order_resp = None - last_close_err = None - for params in _binance_market_close_param_candidates(direction): - try: - order_resp = exchange.create_order(exchange_symbol, "market", side, amount, None, params) - last_close_err = None - break - except Exception as e: - last_close_err = e - if _is_binance_close_param_retryable(str(e)): - continue - raise - if order_resp is None: - raise last_close_err if last_close_err else RuntimeError("平仓失败:交易所未返回结果") - last_resp = order_resp - live_after = get_live_position_contracts(exchange_symbol, direction) - if live_after is None or live_after <= 0: - return last_resp - return last_resp - - -def cancel_binance_futures_open_orders(exchange_symbol): - """ - 平仓后撤销该合约下剩余挂单,避免孤儿单残留。 - Binance U 本位:普通挂单走 cancel_all_orders(DELETE allOpenOrders); - 止盈/止损等条件单在「Algo」通道,需再调 DELETE algoOpenOrders,否则手动平仓后仍会留在「当前委托」。 - """ - ok, _ = ensure_exchange_live_ready() - if not ok or not exchange_symbol: - return - ensure_markets_loaded() - sym = exchange_symbol - try: - exchange.cancel_all_orders(sym, params={}) - except Exception: - pass - try: - market = exchange.market(sym) - contract_id = market.get("id") - if contract_id and hasattr(exchange, "fapiPrivateDeleteAlgoOpenOrders"): - exchange.fapiPrivateDeleteAlgoOpenOrders({"symbol": contract_id}) - except Exception: - pass - try: - pending = exchange.fetch_open_orders(sym) - except Exception: - return - for o in pending or []: - oid = o.get("id") - if oid is None: - continue - try: - exchange.cancel_order(str(oid), sym) - except Exception: - pass - - -def _binance_list_raw_open_orders(exchange_symbol): - """普通挂单 + Algo 条件单(止盈/止损)。""" - ensure_markets_loaded() - market = exchange.market(exchange_symbol) - contract_id = market.get("id") - out = [] - try: - for o in exchange.fetch_open_orders(exchange_symbol) or []: - item = dict(o) - item["_channel"] = "regular" - out.append(item) - except Exception: - pass - try: - if contract_id and hasattr(exchange, "fapiPrivateGetOpenAlgoOrders"): - raw = exchange.fapiPrivateGetOpenAlgoOrders({"symbol": contract_id}) - items = raw if isinstance(raw, list) else (raw.get("orders") or raw.get("data") or []) - for info in items or []: - if not isinstance(info, dict): - continue - out.append( - { - "id": info.get("algoId") or info.get("orderId"), - "info": info, - "_channel": "algo", - "type": info.get("orderType") or info.get("type"), - "positionSide": info.get("positionSide"), - "stopPrice": info.get("triggerPrice") or info.get("stopPrice"), - "amount": info.get("quantity") or info.get("origQty"), - } - ) - except Exception: - pass - return out - - -def _binance_order_type_str(order): - info = order.get("info") or {} - if isinstance(info, dict): - for key in ("orderType", "type", "origType", "algoType"): - val = info.get(key) - if val: - return str(val).upper() - return str(order.get("type") or "").upper() - - -def _binance_order_matches_direction(order, direction): - if BINANCE_POSITION_MODE != "hedge": - return True - info = order.get("info") or {} - ps = str(order.get("positionSide") or info.get("positionSide") or "").upper() - want = "LONG" if direction == "long" else "SHORT" - if ps and ps not in ("", "BOTH") and ps != want: - return False - return True - - -def _binance_order_trigger_price(order): - for key in ("stopPrice", "triggerPrice", "activatePrice"): - try: - v = float(order.get(key) or 0) - if v > 0: - return v - except Exception: - pass - info = order.get("info") or {} - if isinstance(info, dict): - for key in ("triggerPrice", "stopPrice", "activatePrice"): - try: - v = float(info.get(key) or 0) - if v > 0: - return v - except Exception: - pass - return None - - -def _binance_tpsl_role_from_order(order): - typ = _binance_order_type_str(order) - if "TAKE_PROFIT" in typ: - return "tp" - if "STOP" in typ: - return "sl" - return None - - -def _binance_tpsl_slot_from_order(order, exchange_symbol): - trig = _binance_order_trigger_price(order) - try: - amt = float(order.get("amount") or order.get("remaining") or 0) - except Exception: - amt = None - if amt is not None and amt <= 0: - amt = None - channel = order.get("_channel") or "regular" - oid = order.get("id") - if oid is None and isinstance(order.get("info"), dict): - oid = order["info"].get("algoId") or order["info"].get("orderId") - disp = format_price_for_symbol(exchange_symbol, trig) if trig else "-" - return { - "order_id": str(oid) if oid is not None else "", - "channel": channel, - "trigger_price": trig, - "trigger_display": disp, - "amount": amt, - "type": _binance_order_type_str(order), - } - - -def fetch_exchange_tpsl_slots(exchange_symbol, direction): - """返回 { sl: slot|None, tp: slot|None },供页面展示与单笔撤单。""" - slots = {"sl": None, "tp": None} - if not exchange_symbol: - return slots - ok, _ = ensure_exchange_live_ready() - if not ok: - return slots - try: - for order in _binance_list_raw_open_orders(exchange_symbol): - if not _binance_order_matches_direction(order, direction): - continue - role = _binance_tpsl_role_from_order(order) - if role not in ("sl", "tp") or slots[role] is not None: - continue - slots[role] = _binance_tpsl_slot_from_order(order, exchange_symbol) - except Exception: - pass - return slots - - -def cancel_binance_tpsl_slot(exchange_symbol, slot): - if not slot or not exchange_symbol: - return - ensure_markets_loaded() - market = exchange.market(exchange_symbol) - contract_id = market.get("id") - oid = slot.get("order_id") - if not oid: - return - if slot.get("channel") == "algo" and contract_id and hasattr(exchange, "fapiPrivateDeleteAlgoOrder"): - exchange.fapiPrivateDeleteAlgoOrder({"symbol": contract_id, "algoId": oid}) - return - exchange.cancel_order(str(oid), exchange_symbol) - - -def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): - sltp_mode = (sltp_mode or "price").strip().lower() - if sltp_mode == "pct": - sl_pct = float(data.get("sl_pct") or 0) - tp_pct = float(data.get("tp_pct") or 0) - if sl_pct <= 0 or tp_pct <= 0: - raise ValueError("百分比止盈止损须为正数") - sl_ratio = sl_pct / 100.0 - tp_ratio = tp_pct / 100.0 - entry = float(live_price) - if direction == "short": - stop_loss = entry * (1 + sl_ratio) - take_profit = entry * (1 - tp_ratio) - else: - stop_loss = entry * (1 - sl_ratio) - take_profit = entry * (1 + tp_ratio) - else: - stop_loss = float(data.get("sl") or data.get("stop_loss") or 0) - take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0) - if stop_loss <= 0 or take_profit <= 0: - raise ValueError("止盈止损价格须大于 0") - return stop_loss, take_profit - - -def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): - """先撤该合约全部 TP/SL,再按新价重挂(与交易所 App 一致)。""" - 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_binance_futures_open_orders(ex_sym) - pos_amt = get_live_position_contracts(ex_sym, direction) - if pos_amt is None or float(pos_amt) <= 0: - raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") - _binance_place_tp_sl_orders(ex_sym, direction, float(pos_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() - # 禁止匹配笼统的 reduceonly / -4061:会与参数错误、单向/双向模式不匹配混淆, - # 误判后走「已无仓」同步结束,交易所仓位却仍在。 - keywords = [ - "no position", - "position does not exist", - "position not exist", - "nothing to close", - "pos size is 0", - "position amount is 0", - ] - return any(k in msg for k in keywords) - - -def get_live_position_contracts(exchange_symbol, direction): - ensure_markets_loaded() - try: - rows = exchange.fetch_positions([exchange_symbol]) - except Exception: - return None - total = 0.0 - for p in rows: - if not _position_matches_wanted_contract(exchange_symbol, p): - continue - if not _row_matches_monitor_direction(direction, p): - continue - contracts = _position_row_effective_contracts(p) - if contracts <= 0: - continue - total += contracts - return total - - -def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): - """在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。""" - if not rows: - return None - candidates = [] - for p in rows: - if not _position_matches_wanted_contract(exchange_symbol, p): - continue - contracts = _position_row_effective_contracts(p) - if contracts <= 0: - continue - if (not relax_hedge) and not _row_matches_monitor_direction(direction, p): - continue - candidates.append((contracts, p)) - if not candidates and (not relax_hedge) and BINANCE_POSITION_MODE == "hedge": - return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) - if not candidates: - return None - candidates.sort(key=lambda x: x[0], reverse=True) - return candidates[0][1] - - -def _coerce_float(*values): - for v in values: - if v is None or v == "": - continue - try: - return float(v) - except (TypeError, ValueError): - continue - return None - - -def parse_ccxt_position_metrics(position, order_leverage=None): - """ - 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏。 - 「所保证金」对齐币安合约页的初始/持仓保证金:优先 initialMargin / positionInitialMargin。 - Binance 全仓下 ccxt 的 collateral 常来自 crossMargin,口径易与「名义」混淆,故不全仓优先用 collateral。 - """ - if not position: - return None - p = position - info = p.get("info", {}) or {} - margin_mode = str(p.get("marginMode") or info.get("marginType") or "").lower() - isolated = margin_mode.startswith("isolated") or str(info.get("isolated", "")).lower() == "true" - - initial = _coerce_float( - p.get("initialMargin"), - info.get("positionInitialMargin"), - info.get("initialMargin"), - ) - if (initial is None or initial <= 0) and isolated: - initial = _coerce_float(p.get("collateral"), info.get("isolatedWallet")) - if initial is None or initial <= 0: - initial = _coerce_float(p.get("margin")) - if initial is None or initial <= 0: - initial = _coerce_float( - info.get("initial_margin"), - info.get("position_margin"), - info.get("iso_margin"), - ) - notional = _coerce_float(p.get("notional"), p.get("notionalValue")) - if notional is None or notional <= 0: - notional = _coerce_float(info.get("value")) - if notional is not None: - notional = abs(notional) - # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) - if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: - try: - lev = float(order_leverage) - if lev > 0: - approx = notional / lev - if approx > 0: - initial = approx - except (TypeError, ValueError): - pass - unrealized = _coerce_float( - p.get("unrealizedPnl"), - info.get("unrealised_pnl"), - info.get("unrealized_pnl"), - ) - mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) - out = {} - if initial is not None and initial > 0: - out["initial_margin"] = round(initial, FUNDS_DECIMALS) - if notional is not None and notional > 0: - out["notional"] = round(notional, FUNDS_DECIMALS) - if unrealized is not None: - out["unrealized_pnl"] = round(unrealized, FUNDS_DECIMALS) - if mark is not None and mark > 0: - ps = p.get("symbol") - try: - ex_sym = _ccxt_swap_symbol_for_precision(ps or "") - if ex_sym: - out["mark_price"] = float(exchange.price_to_precision(ex_sym, mark)) - else: - out["mark_price"] = round(mark, 8) - except Exception: - out["mark_price"] = round(mark, 8) - return out or None - - -def get_live_position_exchange_metrics(exchange_symbol, direction): - ensure_markets_loaded() - if not exchange_private_api_configured() or not exchange_symbol: - return None - try: - rows = exchange.fetch_positions() or [] - except Exception: - try: - rows = exchange.fetch_positions([exchange_symbol]) or [] - except Exception: - return None - p = _select_live_position_row(rows, exchange_symbol, direction) - return parse_ccxt_position_metrics(p) - - -def opened_at_str_to_ms(opened_at_str): - if not opened_at_str: - return None - try: - dt = datetime.strptime(str(opened_at_str).strip()[:19], "%Y-%m-%d %H:%M:%S") - except ValueError: - return None - try: - aware = dt.replace(tzinfo=APP_TZ) - return int(aware.timestamp() * 1000) - except Exception: - return None - - -def _to_ms_with_fallback(ms_value, dt_str): - try: - if ms_value is not None and str(ms_value).strip() != "": - v = int(float(ms_value)) - if v > 0: - return v - except Exception: - pass - return opened_at_str_to_ms(dt_str) - - -def ms_to_app_local_str(ms): - if ms is None: - return app_now_str() - try: - dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) - return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") - except Exception: - return app_now_str() - - -def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): - """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" - try: - tp = float(take_profit) - sl = float(stop_loss) - ex = float(exit_price) - trig = float(trigger_price) - except (TypeError, ValueError): - return None - band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12) - if direction == "long": - if ex >= tp - band: - return "止盈" - if ex <= sl + band: - return "止损" - else: - if ex <= tp + band: - return "止盈" - if ex >= sl - band: - return "止损" - return None - - -def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): - """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" - if not (BINANCE_API_KEY and BINANCE_API_SECRET): - return None - ensure_markets_loaded() - since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) - close_side = "sell" if direction == "long" else "buy" - - def pick_from_trades(trades): - if not trades: - return None - candidates = [] - for t in trades: - if (t.get("side") or "").lower() != close_side: - continue - info = t.get("info") or {} - if not isinstance(info, dict): - info = {} - pos_side = (info.get("posSide") or t.get("posSide") or "").lower() - if BINANCE_POSITION_MODE == "hedge": - if pos_side in ("long", "short") and pos_side != direction: - continue - ts = t.get("timestamp") - if ts is None: - continue - candidates.append(t) - if not candidates: - return None - return max(candidates, key=lambda x: x.get("timestamp") or 0) - - try: - trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) - hit = pick_from_trades(trades) - if hit is None and since_ms: - trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100) - hit = pick_from_trades(trades) - return hit - except Exception: - return None - - -def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): - """ - 拉取某条历史记录对应的减仓成交(用于按 id 回填)。 - 返回按时间排序的成交列表。 - """ - if not (BINANCE_API_KEY and BINANCE_API_SECRET): - return [] - ensure_markets_loaded() - since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) - close_side = "sell" if direction == "long" else "buy" - closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None - close_upper_ms = (int(closed_ms) + 15 * 60 * 1000) if closed_ms is not None else None - 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 close_upper_ms and ts > close_upper_ms: - continue - info = t.get("info") or {} - if not isinstance(info, dict): - info = {} - pos_side = (info.get("posSide") or t.get("posSide") or "").lower() - if BINANCE_POSITION_MODE == "hedge": - if pos_side in ("long", "short") and pos_side != direction: - continue - all_side_candidates.append(t) - 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[-5:] - near = [] - for t in all_side_candidates: - ts = _coerce_ts_ms(t.get("timestamp")) - if ts is None: - continue - delta = abs(ts - int(closed_ms)) - if delta <= 45 * 60 * 1000: - near.append((delta, t)) - if near: - near.sort(key=lambda x: x[0]) - picked = [x[1] for x in near[:12]] - picked.sort(key=lambda x: x.get("timestamp") or 0) - return _cluster_closing_trades_near_close(picked, int(closed_ms)) - return _cluster_closing_trades_near_close(all_side_candidates[-5:], int(closed_ms)) - - -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"] - exchange_symbol = row["exchange_symbol"] or normalize_exchange_symbol(sym) - - closed_at_str = app_now_str() - closed_at_ms = None - closing_trades = fetch_closing_fills_for_record( - exchange_symbol, direction, opened_at_str, None, opened_at_ms=opened_at_ms - ) - exit_px = calc_weighted_exit_price(closing_trades) if closing_trades else None - if exit_px is None: - trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) - if trade: - try: - exit_px = float(trade.get("price") or 0) or None - except (TypeError, ValueError): - exit_px = None - if not closing_trades: - closing_trades = [trade] - if closing_trades: - last_ts = closing_trades[-1].get("timestamp") - if last_ts: - closed_at_str = ms_to_app_local_str(int(last_ts)) - closed_at_ms = int(last_ts) - - open_ms = _to_ms_with_fallback( - row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at_str - ) - close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) - pnl, exit_px2, _, _, _ = resolve_trade_pnl_amount( - row, - trigger_price, - exit_px, - opened_at_str=opened_at_str, - opened_at_ms=open_ms, - closed_at_str=closed_at_str, - closed_at_ms=close_ms, - ) - if exit_px2: - exit_px = float(exit_px2) - - 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: - pnl2, _, _, _, _ = resolve_trade_pnl_amount( - row, - trigger_price, - p, - opened_at_str=opened_at_str, - opened_at_ms=open_ms, - closed_at_str=closed_at_str, - closed_at_ms=close_ms, - ) - return ( - guessed, - pnl2, - closed_at_str, - "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", - ) - return ( - "外部平仓", - pnl, - closed_at_str, - "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", - ) - - result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) - if result: - return ( - result, - pnl, - closed_at_str, - "按交易所成交/流水同步为止盈/止损平仓", - ) - return ( - "外部平仓", - pnl, - closed_at_str, - "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", - ) - - -def reconcile_external_closes(conn, days=None): - synced_count = 0 - cutoff_ms = None - if days is not None: - try: - d = int(days) - if d > 0: - cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) - except Exception: - cutoff_ms = None - rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() - for r in rows: - if cutoff_ms is not None: - opened_at_v = get_opened_at_value(r) - opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) - # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 - if opened_ms is None or opened_ms < cutoff_ms: - continue - exchange_symbol = r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]) - live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) - if live_contracts is None: - continue - if live_contracts > 0: - continue - cancel_binance_futures_open_orders(exchange_symbol) - opened_at = get_opened_at_value(r) - opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) - result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) - closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() - hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) - session_date = r["session_date"] or get_trading_day(closed_at_dt) - update_session_capital(conn, session_date, pnl_amount) - insert_trade_record( - conn, - symbol=r["symbol"], - monitor_type=order_row_monitor_type(r), - key_signal_type=order_row_key_signal_type(r), - 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"],)) - clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day()) - 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 - min_closed = KEY_VOLUME_MA_BARS + 3 - if len(closed) < min_closed: - out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足" - return out - try: - breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] - confirm = closed[KEY_CONFIRM_BAR] - except IndexError: - out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" - return out - prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR] - avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1) - vol_break = float(breakout[5]) - vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN 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 > KEY_BREAKOUT_AMP_MIN_PCT) and (amp_pct < KEY_BREAKOUT_AMP_MAX_PCT) - 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 <= KEY_DAILY_VOLUME_RANK_MAX) - 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 _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): - """本条关键位一次性结案:写历史并从当前表删除。""" - n = int(row["notification_count"] or 0) + 1 - insert_key_monitor_history(conn, row, n, last_msg, close_reason) - conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) - - -def _key_hard_lines_from_checks(checks): - return [ - 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)", - ] - - -def _key_plan_sl_tp_for_row(row, direction, upper, lower, checks): - """按 key_monitors 录入的方案计算计划 SL/TP。""" - mode = sl_tp_mode_from_row(row, "standard") - manual_tp = _sqlite_row_val(row, "manual_take_profit") - planned = plan_key_sl_tp( - mode, - direction, - upper, - lower, - checks, - outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, - trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, - manual_take_profit=manual_tp, - ) - return planned, mode - - -def _market_open_for_key_monitor( - conn, - symbol, - direction, - exchange_symbol, - stop_loss, - take_profit, - key_signal_type=None, - breakeven_enabled=0, -): - """ - 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(Binance U 本位)。 - 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) - """ - now = app_now() - ok, reason = precheck_risk(conn, symbol, direction) - if not ok: - return False, f"风控拒绝下单:{reason}", None - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - return False, reason_live, None - - default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) - leverage = int(default_leverage) if default_leverage else 5 - if leverage <= 0: - leverage = 5 - - 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) - live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) - capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) - - trade_style = (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: - return False, "获取交易所实时价格失败(以损定仓需要当前价)", None - try: - ensure_markets_loaded() - except Exception: - pass - lp_adj = round_price_to_exchange(exchange_symbol, live_price) - if lp_adj is not None: - live_price = float(lp_adj) - - sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) - tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) - if sl_adj is not None: - stop_loss = float(sl_adj) - if tp_adj is not None: - take_profit = float(tp_adj) - - risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) - if risk_fraction is None: - return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None - risk_percent = max(0.01, float(RISK_PERCENT)) - risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) - notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) - margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) - - if capital_base and margin_capital > capital_base: - return False, "以损定仓后保证金超过当前交易资金", None - - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS) - if margin_capital > max_margin: - return ( - False, - f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U", - None, - ) - - 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: - return False, friendly_exchange_error(e, available_usdt=available_usdt), None - - tr_adj = round_price_to_exchange(exchange_symbol, trigger_price) - if tr_adj is not None: - trigger_price = float(tr_adj) - sl_f = round_price_to_exchange(exchange_symbol, stop_loss) - if sl_f is not None: - stop_loss = float(sl_f) - tp_f = round_price_to_exchange(exchange_symbol, take_profit) - if tp_f is not None: - take_profit = float(tp_f) - - 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) - be_enabled = 1 if int(breakeven_enabled or 0) != 0 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, monitor_type, key_signal_type) " - "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, - be_enabled, - notional_value, - position_ratio, - base_amount, - amount, - open_order_id, - opened_at_bj, - opened_at_ms, - trading_day, - ORDER_MONITOR_TYPE_KEY_AUTO, - stored_key_signal_type(key_signal_type), - ), - ) - 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] - - return True, None, { - "new_order_id": new_order_id, - "open_order_id": open_order_id, - "trigger_price": trigger_price, - "planned_rr_fill": planned_rr, - "risk_amount_final": risk_amount_final, - "margin_capital": margin_capital, - "leverage": leverage, - "amount": amount, - "base_amount": base_amount, - "notional_value": notional_value, - "position_ratio": position_ratio, - "tpsl_attached": tpsl_attached, - "opens_today_before": opens_today_before, - "opens_today_after": opens_today_after, - "trading_day": trading_day, - "risk_percent": risk_percent, - "breakeven_rr_trigger": breakeven_rr_trigger, - "breakeven_price": breakeven_price, - "capital_base_at_open": capital_base, - } - - -def _sqlite_row_val(row, key, default=None): - try: - v = row[key] - return default if v is None else v - except (KeyError, IndexError, TypeError): - return default - - -def get_symbol_mark_price(symbol): - """斐波失效判定用标记价。""" - ex_sym = normalize_exchange_symbol(symbol) - try: - ensure_markets_loaded() - ticker = exchange.fetch_ticker(ex_sym) - m = _coerce_float(ticker.get("mark"), ticker.get("last")) - if m is None: - info = ticker.get("info") or {} - m = _coerce_float(info.get("mark_price"), info.get("last")) - if m is not None and m > 0: - return float(m) - except Exception: - pass - p = get_price(symbol) - return float(p) if p is not None else None - - -def cancel_fib_limit_order(exchange_symbol, order_id): - """仅撤销本条斐波限价单,不用 cancel_all。""" - if not order_id: - return False - ok_live, _ = ensure_exchange_live_ready() - if not ok_live: - return False - ensure_markets_loaded() - oid = str(order_id) - try: - exchange.cancel_order(oid, exchange_symbol) - return True - except Exception: - pass - try: - for o in exchange.fetch_open_orders(exchange_symbol) or []: - if str(o.get("id")) == oid: - exchange.cancel_order(oid, exchange_symbol) - return True - except Exception: - pass - return False - - -def fib_limit_order_status(exchange_symbol, order_id): - if not order_id: - return "missing" - ensure_markets_loaded() - oid = str(order_id) - try: - o = exchange.fetch_order(oid, exchange_symbol) - st = (o.get("status") or "").lower() - if st in ("closed", "filled"): - filled = float(o.get("filled") or 0) - if filled > 0 or st == "filled": - return "filled" - if st in ("canceled", "cancelled", "expired", "rejected"): - return "canceled" - if st in ("open", "new", "partially_filled"): - return "open" - except Exception: - pass - try: - for o in exchange.fetch_open_orders(exchange_symbol) or []: - if str(o.get("id")) == oid: - return "open" - except Exception: - pass - return "unknown" - - -def place_fib_limit_order(exchange_symbol, direction, amount, leverage, limit_price): - ensure_markets_loaded() - mm = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" - try: - exchange.set_margin_mode(mm, exchange_symbol) - except Exception: - pass - exchange.set_leverage(leverage, exchange_symbol) - side = "buy" if direction == "long" else "sell" - price = round_price_to_exchange(exchange_symbol, float(limit_price)) - if price is None or price <= 0: - raise ValueError("挂单价无效") - params = build_binance_order_params(direction, reduce_only=False) - return exchange.create_order(exchange_symbol, "limit", side, amount, price, params) - - -def _fib_key_exists_for_symbol(conn, symbol): - ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES)) - row = conn.execute( - f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})", - (symbol, *tuple(FIB_KEY_MONITOR_TYPES)), - ).fetchone() - return row is not None - - -def _fib_plan_for_row(row): - typ = (row["monitor_type"] or "").strip() - ratio = fib_ratio_from_type(typ) - if ratio is None: - return None - return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio) - - -def _cancel_fib_monitor_limit(row): - ex_sym = normalize_exchange_symbol(row["symbol"]) - oid = _sqlite_row_val(row, "fib_limit_order_id") - if oid: - cancel_fib_limit_order(ex_sym, oid) - - -def _fib_has_live_position(exchange_symbol, direction): - live = get_live_position_contracts(exchange_symbol, direction) - return live is not None and float(live) > 0 - - -def _insert_order_monitor_from_fib_fill( - conn, row, trigger_price, stop_loss, take_profit, amount, leverage, margin_capital, - notional_value, position_ratio, base_amount, exchange_order_id, tpsl_attached, -): - symbol = row["symbol"] - direction = (row["direction"] or "long").lower() - exchange_symbol = normalize_exchange_symbol(symbol) - typ = (row["monitor_type"] or "").strip() - now = app_now() - trading_day = get_trading_day(now) - trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() - if trade_style not in ("trend", "swing"): - trade_style = "trend" - risk_percent = max(0.01, float(RISK_PERCENT)) - risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) - if risk_amount_final is None: - risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4) - 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 - if direction == "short": - breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) - else: - breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) - breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) - be_enabled = 1 if breakeven_enabled_from_row(row, 0) else 0 - opened_at_bj = app_now_str() - opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) - 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, monitor_type, key_signal_type) " - "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, - be_enabled, - notional_value, - position_ratio, - base_amount, - amount, - exchange_order_id or "", - opened_at_bj, - opened_at_ms, - trading_day, - ORDER_MONITOR_TYPE_KEY_AUTO, - stored_key_signal_type(typ), - ), - ) - new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - return new_order_id - - -def _finalize_fib_key_fill(conn, row): - symbol = row["symbol"] - direction = (row["direction"] or "long").lower() - typ = (row["monitor_type"] or "").strip() - ex_sym = normalize_exchange_symbol(symbol) - plan = _fib_plan_for_row(row) - if not plan: - _finalize_key_monitor_one_shot(conn, row, "斐波计划无效", "fib_plan_invalid") - return - entry_plan, sl_plan, tp_plan = plan - sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan) - tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan) - sl_adj = round_price_to_exchange(ex_sym, sl) - tp_adj = round_price_to_exchange(ex_sym, tp) - if sl_adj is not None: - sl = float(sl_adj) - if tp_adj is not None: - tp = float(tp_adj) - amount = float(_sqlite_row_val(row, "fib_order_amount") or 0) - leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5) - margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0) - oid = _sqlite_row_val(row, "fib_limit_order_id") - entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan) - trigger_price = entry_px - if oid: - try: - o = exchange.fetch_order(str(oid), ex_sym) - trigger_price = resolve_order_entry_price(o, ex_sym, entry_px) - except Exception: - pass - tr_adj = round_price_to_exchange(ex_sym, trigger_price) - if tr_adj is not None: - trigger_price = float(tr_adj) - if amount <= 0: - live_amt = get_live_position_contracts(ex_sym, direction) - amount = float(live_amt or 0) - if amount <= 0: - send_wechat_msg( - f"# ❌ {symbol} 斐波成交后处理失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 无法取得持仓/下单数量,未挂 TP/SL\n" - ) - return - ok, reason = precheck_risk(conn, symbol, direction) - if not ok: - send_wechat_msg( - f"# ❌ {symbol} 斐波成交后风控拒绝\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}\n" - f"- 原因:{reason}\n" - f"- 请手动处理仓位与挂单\n" - ) - return - tpsl_attached = False - try: - _binance_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) - tpsl_attached = True - except Exception as e: - send_wechat_msg( - f"# ❌ {symbol} 斐波成交后挂 TP/SL 失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 错误:{friendly_exchange_error(e)}\n" - f"- 请手动补挂止盈止损\n" - ) - return - contract_size = get_contract_size(ex_sym) - base_amount = round(float(amount) * contract_size, 8) - notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0 - session_row = ensure_session(conn, get_trading_day(app_now())) - capital_base = float(session_row["current_capital"] or 0) - position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0 - planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp) - new_order_id = _insert_order_monitor_from_fib_fill( - conn, row, trigger_price, sl, tp, amount, leverage, margin_capital, - notional_value, position_ratio, base_amount, oid, tpsl_attached, - ) - rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" - succ = ( - f"# ✅ {symbol} 斐波限价成交\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" - f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" - f"- 订单 ID:**{new_order_id}**\n" - f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n" - f"- 止损:{format_wechat_scalar_2dp(sl)}|止盈:{format_price_for_symbol(symbol, tp)}\n" - f"- 计划 RR:{rr_txt}:1\n" - f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" - ) - send_wechat_msg(succ) - _finalize_key_monitor_one_shot(conn, row, succ, "fib_filled") - - -def check_fib_key_monitors(): - conn = get_db() - rows = conn.execute("SELECT * FROM key_monitors").fetchall() - for r in rows: - typ = (r["monitor_type"] or "").strip() - if not is_fib_key_monitor_type(typ): - continue - symbol = r["symbol"] - direction = (r["direction"] or "long").lower() - ex_sym = normalize_exchange_symbol(symbol) - up, low = float(r["upper"]), float(r["lower"]) - oid = _sqlite_row_val(r, "fib_limit_order_id") - mark = get_symbol_mark_price(symbol) - if mark is None: - continue - status = fib_limit_order_status(ex_sym, oid) if oid else "missing" - if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): - _finalize_fib_key_fill(conn, r) - continue - if status == "open": - if fib_invalidate_by_mark(direction, mark, up, low): - _cancel_fib_monitor_limit(r) - msg = ( - f"# ⚠️ {symbol} 斐波监控失效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" - f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n" - ) - send_wechat_msg(msg) - _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") - continue - if status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): - msg = ( - f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 标记价触达止盈侧,本条已结案\n" - ) - send_wechat_msg(msg) - _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") - conn.commit() - conn.close() - - -def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0): - if _fib_key_exists_for_symbol(conn, symbol): - return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" - ratio = fib_ratio_from_type(mt) - plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio) - if not plan: - return False, "斐波上下沿无效(需上沿 H > 下沿 L)" - entry, sl, tp = plan - ex_sym = normalize_exchange_symbol(symbol) - entry = round_price_to_exchange(ex_sym, entry) - sl = round_price_to_exchange(ex_sym, sl) - tp = round_price_to_exchange(ex_sym, tp) - if entry is None or sl is None or tp is None: - return False, "斐波价位经交易所精度舍入后无效" - entry, sl, tp = float(entry), float(sl), float(tp) - planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp) - if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: - fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" - return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)" - ok, reason = precheck_risk(conn, symbol, direction_sel) - if not ok: - return False, reason - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - return False, reason_live - now = app_now() - trading_day = get_trading_day(now) - session_row = ensure_session(conn, trading_day) - _, trading_capital_live = get_exchange_capitals(force=True) - live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) - capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) - default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) - leverage = int(default_leverage) if default_leverage else 5 - if leverage <= 0: - leverage = 5 - available_usdt = get_available_trading_usdt() - risk_fraction = calc_risk_fraction(direction_sel, entry, sl) - if risk_fraction is None: - return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" - 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: - return False, "以损定仓后保证金超过当前交易资金" - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) - if margin_capital > max_margin: - return ( - False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", - ) - try: - amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) - order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) - oid = str(order_resp.get("id") or "") - if not oid: - return False, "交易所未返回限价单 ID" - except Exception as e: - return False, friendly_exchange_error(e, available_usdt=available_usdt) - be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 - conn.execute( - "INSERT INTO key_monitors " - "(symbol, monitor_type, direction, upper, lower, " - "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " - "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, mt, direction_sel, upper_px, lower_px, - oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, - ), - ) - return True, None - - -# 关键位监控(箱体/收敛可自动开仓;阻力/支撑位仅单次提醒结案) -def check_key_monitors(): - conn = get_db() - rows = conn.execute("SELECT * FROM key_monitors").fetchall() - for r in rows: - sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] - typ = (typ_raw or "").strip() - if is_fib_key_monitor_type(typ): - continue - direction = (r["direction"] or "long").lower() - 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)主趋势逆势,建议降低仓位并严格执行止损。" - - key_price = float(low) if direction == "long" else float(up) - hard_lines = _key_hard_lines_from_checks(checks) - trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() - - alert_only = typ in KEY_MONITOR_ALERT_ONLY_TYPES or ( - typ not in KEY_MONITOR_AUTO_TYPES and typ not in KEY_MONITOR_ALERT_ONLY_TYPES - ) - - if alert_only: - op_lines = [ - "- 本条为关键阻力/支撑或非标类型:**仅单次推送**,不进行自动开仓。", - "- 本条关键位将在推送后记入历史并从监控列表移除。", - ] - 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) - _finalize_key_monitor_one_shot(conn, r, msg, "key_level_alert_only") - continue - - plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) - if not plan_tuple: - fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)" - rr_msg = ( - f"# ⚠️ {sym} 关键位自动单:计划无效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}\n" - f"- 方向:**{_wechat_direction_text(direction)}**\n" - f"- 触发时间:`{trigger_time}`\n" - f"- 确认K收盘(E):`{format_price_for_symbol(sym, checks.get('confirm_close'))}`\n" - f"- **{fmt_rr}**(未开仓)\n" - "---\n" - "### 硬条件\n" - + "\n".join(f"- {x}" for x in hard_lines) - ) - if risk_tip: - rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" - send_wechat_msg(rr_msg) - _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") - continue - E, sl_raw, tp_raw, box_h = plan_tuple - exchange_symbol = normalize_exchange_symbol(sym) - try: - ensure_markets_loaded() - except Exception: - pass - sl_px = round_price_to_exchange(exchange_symbol, sl_raw) - tp_px = round_price_to_exchange(exchange_symbol, tp_raw) - if sl_px is not None: - sl_raw = float(sl_px) - if tp_px is not None: - tp_raw = float(tp_px) - - planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) - rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR - - if not rr_ok: - fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)" - plan_line = sl_tp_plan_summary_text( - sl_tp_mode, direction, E, sl_raw, tp_raw, box_h, - outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, - trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, - ) - rr_msg = ( - f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}|{plan_line}\n" - f"- 方向:**{_wechat_direction_text(direction)}**\n" - f"- 触发时间:`{trigger_time}`\n" - f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" - f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n" - f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" - f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" - f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n" - "---\n" - "### 硬条件\n" - + "\n".join(f"- {x}" for x in hard_lines) - ) - if risk_tip: - rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" - send_wechat_msg(rr_msg) - _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") - continue - - key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None - be_on = breakeven_enabled_from_row(r, 0) - ok_trade, trade_err, det = _market_open_for_key_monitor( - conn, - sym, - direction, - exchange_symbol, - sl_raw, - tp_raw, - key_signal_type=key_sig, - breakeven_enabled=1 if be_on else 0, - ) - planned_rr_txt = ( - format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" - ) - if not ok_trade: - fail_msg = ( - f"# ❌ {sym} 关键位自动单失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}\n" - f"- 方向:**{_wechat_direction_text(direction)}**\n" - f"- 触发时间:`{trigger_time}`\n" - f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" - f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" - f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" - f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n" - f"- **失败原因:{trade_err}**\n" - "---\n" - "### 硬条件\n" - + "\n".join(f"- {x}" for x in hard_lines) - ) - if risk_tip: - fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" - send_wechat_msg(fail_msg) - _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed") - continue - - tpsl_txt = ( - "已在交易所挂止盈/止损触发单(Binance U 本位条件单)" - if det.get("tpsl_attached") - else "⚠️ 条件单挂接状态异常或未挂上" - ) - rr_fill = det.get("planned_rr_fill") - rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-" - - succ_msg_lines = [ - f"# ✅ {sym} 关键位自动开仓成功", - f"**账户:{_wechat_account_label()}**", - f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", - f"- 页面订单 ID:**{det['new_order_id']}**", - f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", - f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", - f"- 方向:**{_wechat_direction_text(direction)}**", - f"- 触发时间:`{trigger_time}`", - f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", - f"- **计划 RR(E):{planned_rr_txt}:1**", - f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", - f"- **成交价侧计划 RR:**{rr_fill_txt}:1", - f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", - f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", - f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", - f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", - f"- **{tpsl_txt}**", - f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", - f"- 当日开仓次数:**{det.get('opens_today_after')}** / {DAILY_OPEN_ALERT_THRESHOLD}(提醒阈值)", - ] - succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines]) - if risk_tip: - succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"]) - succ_msg = "\n".join(succ_msg_lines) - send_wechat_msg(succ_msg) - _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened") - - if det.get("opens_today_before", 0) < DAILY_OPEN_ALERT_THRESHOLD <= det.get("opens_today_after", 0): - advice = ai_short_advice( - f"用户在北京时间交易日 {det['trading_day']} 已累计开仓 {det['opens_today_after']} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" - f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。" - f"用户自述“上头了”。请给克制提醒。" - ) - if advice: - send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}") - 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 = "" - exit_p = None - try: - close_resp = close_exchange_order(r) - close_order_id = close_resp.get("id", "") - # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细。 - exit_p = extract_trade_price_from_order(close_resp) - if exit_p and exit_p > 0: - pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) - guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) - if guessed_res: - res = normalize_result_with_pnl(guessed_res, pnl_amount) - else: - res = normalize_result_with_pnl(res, pnl_amount) - else: - ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) - tr = fetch_latest_closing_fill( - ex_sym, - direction, - opened_at, - opened_at_ms=opened_at_ms, - ) - if tr and tr.get("price"): - try: - exit_p = float(tr["price"]) - pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) - guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) - if guessed_res: - if guessed_res == "止损" and float(pnl_amount or 0) > 0: - res = "移动止盈" if breakeven_armed else "保本止盈" - else: - res = normalize_result_with_pnl(guessed_res, pnl_amount) - else: - res = normalize_result_with_pnl(res, pnl_amount) - except (TypeError, ValueError): - pass - ts = tr.get("timestamp") - if ts: - closed_at = ms_to_app_local_str(int(ts)) - hold_seconds = calc_hold_seconds( - opened_at, parse_dt_for_trading_day(closed_at) or now - ) - except Exception as e: - if is_no_position_error(str(e)): - ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) - cancel_binance_futures_open_orders(ex_sym) - tr = fetch_latest_closing_fill( - ex_sym, - direction, - opened_at, - opened_at_ms=opened_at_ms, - ) - if tr and tr.get("price"): - try: - exit_p = float(tr["price"]) - pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) - # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判。 - guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) - if guessed_res: - if guessed_res == "止损" and float(pnl_amount or 0) > 0: - res = "移动止盈" if breakeven_armed else "保本止盈" - else: - res = normalize_result_with_pnl(guessed_res, pnl_amount) - else: - res = normalize_result_with_pnl(res, pnl_amount) - except (TypeError, ValueError): - pass - ts = tr.get("timestamp") - if ts: - closed_at = ms_to_app_local_str(int(ts)) - hold_seconds = calc_hold_seconds( - opened_at, parse_dt_for_trading_day(closed_at) or now - ) - exit_ref = exit_p if exit_p and float(exit_p) > 0 else p - pnl_amount, _, _, _, _ = resolve_trade_pnl_amount( - r, - trigger_price, - exit_ref, - opened_at_str=opened_at, - opened_at_ms=_to_ms_with_fallback(opened_at_ms, opened_at), - closed_at_str=closed_at, - closed_at_ms=_to_ms_with_fallback(None, closed_at), - ) - insert_trade_record( - conn, - symbol=sym, - monitor_type=order_row_monitor_type(r), - key_signal_type=order_row_key_signal_type(r), - direction=direction, - trigger_price=trigger_price, - stop_loss=stop_loss, - initial_stop_loss=r["initial_stop_loss"] or stop_loss, - take_profit=take_profit, - margin_capital=margin_capital, - leverage=leverage, - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=r["trade_style"], - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), - actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), - result=res, - miss_reason="触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", - opened_at=opened_at, - closed_at=closed_at, - ) - session_capital = update_session_capital(conn, session_date, pnl_amount) - send_wechat_msg( - build_wechat_close_message( - symbol=sym, - direction=direction, - result=f"{res}(交易所已先行平仓)", - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=trigger_price, - current_price=p, - stop_loss=stop_loss, - take_profit=take_profit, - close_order_id="-", - extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", - session_capital_fallback=session_capital, - ) - ) - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) - conn.commit() - continue - conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (pid,)) - conn.commit() - send_wechat_msg( - build_wechat_monitor_error_message( - symbol=sym, - direction=direction, - scene=f"触发{res}后交易所平仓失败", - error_text=str(e), - ) - ) - continue - cancel_binance_futures_open_orders(r["exchange_symbol"] or normalize_exchange_symbol(sym)) - exit_ref = exit_p if exit_p and float(exit_p) > 0 else p - pnl_amount, _, _, _, _ = resolve_trade_pnl_amount( - r, - trigger_price, - exit_ref, - opened_at_str=opened_at, - opened_at_ms=_to_ms_with_fallback(opened_at_ms, opened_at), - closed_at_str=closed_at, - closed_at_ms=_to_ms_with_fallback(None, closed_at), - ) - 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=order_row_monitor_type(r), - key_signal_type=order_row_key_signal_type(r), - 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)) - clear_key_sizing_snapshot_if_flat(conn, get_trading_day()) - conn.commit() - conn.close() - - -def force_close_before_reset(): - if not FORCE_CLOSE_ENABLED: - return - now = app_now() - # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) - if now.hour != FORCE_CLOSE_BJ_HOUR: - return - conn = get_db() - rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() - for r in rows: - p = get_price(r["symbol"]) - if not p: - continue - direction = r["direction"] - trigger_price = r["trigger_price"] - margin_capital = r["margin_capital"] or DAILY_START_CAPITAL - leverage = r["leverage"] or infer_leverage(r["symbol"]) - session_date = r["session_date"] or get_trading_day(now) - opened_at = get_opened_at_value(r) - closed_at = now.strftime("%Y-%m-%d %H:%M:%S") - hold_seconds = calc_hold_seconds(opened_at, now) - pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) - try: - close_resp = close_exchange_order(r) - close_order_id = close_resp.get("id", "") - cancel_binance_futures_open_orders(r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"])) - except Exception as e: - conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) - conn.commit() - send_wechat_msg( - build_wechat_monitor_error_message( - symbol=r["symbol"], - direction=direction, - scene="强制清仓失败", - error_text=str(e), - ) - ) - continue - session_capital = update_session_capital(conn, session_date, pnl_amount) - insert_trade_record( - conn, - symbol=r["symbol"], - monitor_type=order_row_monitor_type(r), - key_signal_type=order_row_key_signal_type(r), - 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_fib_key_monitors() - check_key_monitors() - check_order_monitors() - except: - pass - time.sleep(MONITOR_POLL_SECONDS) - - -# ====================== 登录路由 ====================== -@app.route("/login", methods=["GET", "POST"]) -def login(): - if AUTH_DISABLED: - session["logged_in"] = True - return redirect("/") - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - if username == USERNAME and password == PASSWORD: - session["logged_in"] = True - return redirect("/") - else: - flash("账号或密码错误") - return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME) - -@app.route("/logout") -def logout(): - session.clear() - return redirect("/" if AUTH_DISABLED else "/login") - -# 登录校验装饰器 -def login_required(f): - @wraps(f) - def decorated(*args, **kwargs): - if AUTH_DISABLED: - return f(*args, **kwargs) - if not session.get("logged_in"): - return redirect("/login") - return f(*args, **kwargs) - return decorated - - -@app.route("/sync_positions") -@login_required -def sync_positions(): - days_raw = (request.args.get("days") or "").strip() - sync_days = None - if days_raw: - try: - sync_days = max(1, min(365, int(days_raw))) - except Exception: - sync_days = None - conn = get_db() - synced = reconcile_external_closes(conn, days=sync_days) - conn.commit() - conn.close() - if sync_days is not None: - flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") - else: - flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") - return redirect("/") - - -@app.route("/api/sync_positions", methods=["POST"]) -@login_required -def api_sync_positions(): - payload = request.get_json(silent=True) or {} - days_raw = str(payload.get("days", "")).strip() - if not days_raw: - return jsonify({"ok": False, "msg": "请填写天数"}), 400 - try: - days = int(days_raw) - except Exception: - return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 - if days < 1 or days > 365: - return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 - conn = get_db() - synced = reconcile_external_closes(conn, days=days) - conn.commit() - conn.close() - return jsonify({"ok": True, "days": days, "synced": int(synced)}) - - -def _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 > 1e9: - return int(v * 1000.0) - return int(v * 1000.0) - - -def _fetch_binance_income_entries(exchange_symbol, start_ms, end_ms): - if not hasattr(exchange, "fapiPrivateGetIncome"): - return [] - ensure_markets_loaded() - market = exchange.market(exchange_symbol) - contract_id = market.get("id") - if not contract_id: - return [] - out = [] - cursor = int(start_ms) - end_ms = int(end_ms) - for _ in range(20): - try: - batch = exchange.fapiPrivateGetIncome( - {"symbol": contract_id, "startTime": cursor, "endTime": end_ms, "limit": 1000} - ) - except Exception: - break - if not batch: - break - out.extend(batch) - if len(batch) < 1000: - break - last_t = _coerce_ts_ms(batch[-1].get("time")) - if last_t is None or last_t >= end_ms: - break - cursor = last_t + 1 - return out - - -def fetch_binance_net_pnl_for_trade( - exchange_symbol, direction, open_ms, close_ms, closing_trades=None -): - if open_ms is None or close_ms is None or close_ms < open_ms: - return None, None, None, None - if closing_trades: - closing_trades = _cluster_closing_trades_near_close(closing_trades, int(close_ms)) - trade_ids = _trade_ids_from_fills(closing_trades) if closing_trades else None - buffer_ms = 3 * 60 * 1000 if trade_ids else 5 * 60 * 1000 - entries = _fetch_binance_income_entries( - exchange_symbol, max(0, int(open_ms) - buffer_ms), int(close_ms) + buffer_ms - ) - ensure_markets_loaded() - market = exchange.market(exchange_symbol) - cid = market.get("id") or exchange_symbol - - def _pack(net, first_t, last_t, prefix): - if net is None: - return None - sk = f"{prefix}|{cid}|{direction}|{open_ms}|{close_ms}|{net}" - eo = ms_to_app_local_str(first_t) if first_t else None - ec = ms_to_app_local_str(last_t) if last_t else None - return net, sk, eo, ec - - if entries and trade_ids: - net, ft, lt = _sum_binance_income(entries, BINANCE_APP_PNL_INCOME_WITH_FEE, trade_ids) - out = _pack(net, ft, lt, "income_net") - if out: - return out - net, ft, lt = _sum_binance_income(entries, BINANCE_APP_PNL_INCOME_TYPES, trade_ids) - out = _pack(net, ft, lt, "income_rp") - if out: - return out - - if closing_trades: - trade_pnl = calc_binance_realized_pnl_from_trades(closing_trades) - if trade_pnl is not None: - fts = [_coerce_ts_ms(t.get("timestamp")) for t in closing_trades] - fts = [x for x in fts if x] - ft = min(fts) if fts else None - lt = max(fts) if fts else None - out = _pack(trade_pnl, ft, lt, "trades_rp") - if out: - return out - - if entries: - loose_types = ( - BINANCE_NET_INCOME_TYPES - if BINANCE_PNL_INCLUDE_FUNDING - else BINANCE_APP_PNL_INCOME_WITH_FEE - ) - net, ft, lt = _sum_binance_income(entries, loose_types, trade_ids if trade_ids else None) - out = _pack(net, ft, lt, "income") - if out: - return out - - return None, None, None, None - - -# ====================== 主页面 ====================== -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, FUNDS_DECIMALS) if funding_capital is not None else None - current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) - recommended_capital = get_recommended_capital(current_capital) - key_list = conn.execute("SELECT * FROM key_monitors").fetchall() - key_history = conn.execute( - "SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500", - (start_bj, end_bj), - ).fetchall() - stats_bundle = compute_stats_bundle(conn, trading_day, now) - raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() - order_list = [] - for o in raw_order_list: - order_list.append(enrich_order_item(row_to_dict(o), current_capital)) - raw_records = conn.execute( - "SELECT * FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " - "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id DESC LIMIT 1000", - (start_bj, end_bj), - ).fetchall() - records = [to_effective_trade_dict(r) for r in raw_records] - total = len(records) - miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") - win = sum(1 for r in records if (r.get("effective_result") or "") in ("止盈", "保本止盈", "移动止盈")) - occupied_miss_total = sum( - 1 - for r in records - if (r.get("effective_result") or "") == "错过" - and ("持仓占用" in str(r.get("effective_miss_reason") or "")) - ) - rate = round(win/total*100,2) if total else 0 - active_count = len(order_list) - can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS - key_gate_rule_text = ( - f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|" - f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" - f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|" - f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|" - f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%" - ) - conn.close() - return render_template( - "index.html", - page=page, - key=key_list, - key_history=key_history, - stats_bundle=stats_bundle, - order=order_list, - record=records, - total=total, - miss_count=miss_count, - rate=rate, - trading_day=trading_day, - funding_usdt=funding_usdt, - daily_start_capital=DAILY_START_CAPITAL, - current_capital=current_capital, - recommended_capital=recommended_capital, - btc_leverage=BTC_LEVERAGE, - alt_leverage=ALT_LEVERAGE, - reset_hour=TRADING_DAY_RESET_HOUR, - balance_refresh_seconds=BALANCE_REFRESH_SECONDS, - auto_transfer_enabled=AUTO_TRANSFER_ENABLED, - auto_transfer_amount=AUTO_TRANSFER_AMOUNT, - auto_transfer_from=AUTO_TRANSFER_FROM, - auto_transfer_to=AUTO_TRANSFER_TO, - auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, - full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, - price_refresh_seconds=PRICE_REFRESH_SECONDS, - active_count=active_count, - can_trade=can_trade, - focus_key_id=(key_list[0]["id"] if key_list else None), - focus_order_id=(order_list[0]["id"] if order_list else None), - data_export_version=3, - 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, - }, - 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, - funds_fmt=format_funds_u, - entry_reason_options=list(ENTRY_REASON_OPTIONS), - entry_reason_other_value=ENTRY_REASON_OTHER, - exchange_display=EXCHANGE_DISPLAY_NAME, - max_active_positions=MAX_ACTIVE_POSITIONS, - manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, - key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, - key_gate_rule_text=key_gate_rule_text, - kline_timeframe=KLINE_TIMEFRAME, - ) - - -@app.route("/") -@login_required -def index(): - return redirect("/trade") - - -@app.route("/key_monitor") -@login_required -def key_monitor_page(): - return render_main_page("key_monitor") - - -@app.route("/trade") -@login_required -def trade_page(): - return render_main_page("trade") - - -@app.route("/records") -@login_required -def records_page(): - return render_main_page("records") - - -@app.route("/stats") -@login_required -def stats_page(): - return render_main_page("stats") - - -@app.route("/api/account_snapshot") -@login_required -def api_account_snapshot(): - now = app_now() - trading_day = get_trading_day(now) - conn = get_db() - session_row = ensure_session(conn, trading_day) - local_current_capital = float(session_row["current_capital"]) - funding_capital, trading_capital = get_exchange_capitals(force=True) - funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None - current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) - recommended_capital = get_recommended_capital(current_capital) - 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, FUNDS_DECIMALS) 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, - "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, - "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,fib_entry_price,fib_limit_order_id FROM key_monitors" - ).fetchall() - order_rows = conn.execute( - "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'" - ).fetchall() - conn.close() - - symbol_set = set() - for r in key_rows: - symbol_set.add(r["symbol"]) - for r in order_rows: - symbol_set.add(r["symbol"]) - - prices = {} - for s in symbol_set: - p = get_price(s) - if p is not None: - prices[s] = float(p) - - all_swap_positions = [] - if exchange_private_api_configured(): - try: - ensure_markets_loaded() - all_swap_positions = exchange.fetch_positions() or [] - except Exception: - all_swap_positions = [] - - key_prices = [] - for r in key_rows: - is_fib = is_fib_key_monitor_type(r["monitor_type"]) - if is_fib: - price = get_symbol_mark_price(r["symbol"]) - else: - 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 - gate_summary = "-" - gate_metrics = "" - fib_gate_ok = True - if is_fib: - direction = (r["direction"] or "long").lower() - inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) - fib_gate_ok = not inval - entry = _sqlite_row_val(r, "fib_entry_price") - entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" - gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}" - if _sqlite_row_val(r, "fib_limit_order_id"): - gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}" - else: - try: - gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) - except Exception: - gate = None - 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 = float(gate.get("confirm_close") or 0) - edge = float(gate.get("edge_price") or 0) - gate_metrics = ( - f"量值:{vol_now}/{vol_avg} " - f"幅值:{amp_pct}% " - f"二确值:{format_price_for_symbol(r['symbol'], cfm_close)}@{format_price_for_symbol(r['symbol'], edge)}" - ) - except Exception: - gate_metrics = "" - sym_k = r["symbol"] - key_prices.append({ - "id": r["id"], - "symbol": sym_k, - "price": round(price, 6), - "price_display": format_price_for_symbol(sym_k, price), - "upper_diff": upper_diff, - "upper_pct": upper_pct, - "lower_diff": lower_diff, - "lower_pct": lower_pct, - "gate_summary": gate_summary, - "gate_ok": fib_gate_ok if is_fib else 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), 2) 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), - "price_display": format_price_for_symbol(ex_sym, price), - "float_pnl": round(pnl, FUNDS_DECIMALS), - "float_pct": pnl_pct, - "rr_ratio": rr_ratio, - "plan_margin": round(margin, FUNDS_DECIMALS) if margin else None, - "exchange_initial_margin": None, - "exchange_notional": None, - "exchange_mark_price": None, - "exchange_mark_price_display": 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: - mp = ex_metrics["mark_price"] - payload["exchange_mark_price"] = mp - payload["exchange_mark_price_display"] = format_price_for_symbol(ex_sym, mp) - if ex_metrics.get("unrealized_pnl") is not None: - payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), FUNDS_DECIMALS) - payload["pnl_source"] = "exchange" - denom = ex_metrics.get("initial_margin") or margin - payload["float_pct"] = ( - round((payload["float_pnl"] / float(denom)) * 100, 2) if denom and float(denom) > 0 else pnl_pct - ) - if exchange_private_api_configured(): - try: - payload["exchange_tpsl"] = fetch_exchange_tpsl_slots(ex_sym, r["direction"]) - except Exception: - payload["exchange_tpsl"] = {"sl": None, "tp": None} - else: - payload["exchange_tpsl"] = {"sl": None, "tp": None} - 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/order//cancel_tpsl", methods=["POST"]) -@login_required -def api_order_cancel_tpsl(order_id): - data = request.get_json(silent=True) or {} - role = (data.get("role") or "").strip().lower() - if role not in ("sl", "tp"): - return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 - conn = get_db() - 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 - ok, reason = ensure_exchange_live_ready() - if not ok: - return jsonify({"ok": False, "msg": reason}), 400 - ex_sym = resolve_monitor_exchange_symbol(row) - slots = fetch_exchange_tpsl_slots(ex_sym, row["direction"]) - slot = slots.get(role) - if not slot: - return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 - try: - cancel_binance_tpsl_slot(ex_sym, slot) - return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": fetch_exchange_tpsl_slots(ex_sym, row["direction"])}) - except Exception as e: - return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 - - -@app.route("/api/order//place_tpsl", methods=["POST"]) -@login_required -def api_order_place_tpsl(order_id): - data = request.get_json(silent=True) or {} - conn = get_db() - row = conn.execute( - "SELECT * FROM order_monitors WHERE id=? AND status='active'", - (order_id,), - ).fetchone() - if not row: - conn.close() - return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 - symbol = row["symbol"] - direction = row["direction"] - live_price = get_price(symbol) - if live_price is None: - conn.close() - return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400 - try: - sltp_mode = (data.get("sltp_mode") or "price").strip().lower() - stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data) - except Exception as e: - conn.close() - return jsonify({"ok": False, "msg": str(e)}), 400 - planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit) - if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR: - conn.close() - rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" - return jsonify( - { - "ok": False, - "msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1", - } - ), 400 - try: - replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) - except Exception as e: - conn.close() - return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 - conn.execute( - "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", - (stop_loss, take_profit, order_id), - ) - conn.commit() - ex_sym = resolve_monitor_exchange_symbol(row) - slots = fetch_exchange_tpsl_slots(ex_sym, direction) - conn.close() - return jsonify( - { - "ok": True, - "msg": "已先撤后挂止盈止损", - "stop_loss": stop_loss, - "take_profit": take_profit, - "planned_rr": planned_rr, - "exchange_tpsl": slots, - } - ) - - -@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 <= KEY_DAILY_VOLUME_RANK_MAX), - "rank_max": KEY_DAILY_VOLUME_RANK_MAX, - } - ) - - -@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() - last_price = get_price(symbol) - return jsonify({ - "ok": True, - "symbol": symbol, - "exchange_symbol": exchange_symbol, - "direction": direction, - "leverage": leverage, - "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, - "last_price": round(float(last_price), 8) if last_price 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, FUNDS_DECIMALS) if trading_capital_live is not None else round(local_current_capital, FUNDS_DECIMALS) - 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, FUNDS_DECIMALS) if trading_capital_live is not None else round(local_current_capital, FUNDS_DECIMALS) - 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), 2) 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"), - "trigger_price_display": format_price_for_symbol(exchange_symbol, order_item.get("trigger_price")), - "stop_loss_display": format_price_for_symbol(exchange_symbol, order_item.get("stop_loss")), - "take_profit_display": format_price_for_symbol(exchange_symbol, 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, - "current_price_display": format_price_for_symbol(exchange_symbol, current_price) if current_price else None, - "float_pnl": round(float(float_pnl), FUNDS_DECIMALS), - "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, - "upper_display": format_price_for_symbol(exchange_symbol, upper) if upper is not None else None, - "lower_display": format_price_for_symbol(exchange_symbol, lower) if lower is not None else None, - "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, - "current_price_display": format_price_for_symbol(exchange_symbol, current_price) 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("/key_monitor") - direction_sel = (d.get("direction") or "").strip().lower() - if direction_sel not in ("long", "short"): - flash("请选择做多或做空") - return redirect("/key_monitor") - mt = (d.get("type") or "").strip() - allowed_types = ( - tuple(KEY_MONITOR_AUTO_TYPES) - + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) - + tuple(FIB_KEY_MONITOR_TYPES) - ) - if mt not in allowed_types: - flash("监控类型无效") - return redirect("/key_monitor") - rank, total = _daily_volume_rank(symbol) - if rank is None: - flash("日成交量排名读取失败,请稍后重试") - return redirect("/key_monitor") - if rank > KEY_DAILY_VOLUME_RANK_MAX: - flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") - return redirect("/key_monitor") - conn = get_db() - if mt in KEY_MONITOR_AUTO_TYPES: - occupied = get_active_position_count(conn) - if occupied >= MAX_ACTIVE_POSITIONS: - conn.close() - flash( - f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" - "请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。" - ) - return redirect("/key_monitor") - ex_sym_key = normalize_exchange_symbol(symbol) - try: - ensure_markets_loaded() - except Exception: - pass - uh = round_price_to_exchange(ex_sym_key, float(d["upper"])) - lw = round_price_to_exchange(ex_sym_key, float(d["lower"])) - upper_px = float(uh) if uh is not None else float(d["upper"]) - lower_px = float(lw) if lw is not None else float(d["lower"]) - be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) - if is_fib_key_monitor_type(mt): - ok_fib, err_fib = _add_fib_key_monitor( - conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag, - ) - conn.commit() - conn.close() - if not ok_fib: - flash(err_fib or "斐波监控添加失败") - return redirect("/key_monitor") - flash( - f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" - f"|移动保本:{'开' if be_flag else '关'}" - ) - return redirect("/key_monitor") - sl_tp_mode = "standard" - manual_tp = None - if mt in KEY_MONITOR_AUTO_TYPES: - sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode")) - if sl_tp_mode == "trend_manual": - try: - manual_tp = float(d.get("manual_take_profit") or 0) - except (TypeError, ValueError): - manual_tp = 0 - if manual_tp <= 0: - conn.close() - flash("趋势单方案须填写有效止盈价") - return redirect("/key_monitor") - if direction_sel == "long" and manual_tp <= upper_px: - conn.close() - flash("做多趋势单:止盈价应高于上沿(阻力)") - return redirect("/key_monitor") - if direction_sel == "short" and manual_tp >= lower_px: - conn.close() - flash("做空趋势单:止盈价应低于下沿(支撑)") - return redirect("/key_monitor") - mtpx = round_price_to_exchange(ex_sym_key, manual_tp) - if mtpx is not None: - manual_tp = float(mtpx) - conn.execute( - "INSERT INTO key_monitors " - "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) " - "VALUES (?,?,?,?,?,?,?,?)", - (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag), - ) - conn.commit() - conn.close() - ctr = False - try: - coin4h_status, _, _ = _status_by_ema55(symbol, "4h") - ctr = (direction_sel == "long" and coin4h_status == "空头") or ( - direction_sel == "short" and coin4h_status == "多头" - ) - except Exception: - pass - extra = "" - if mt in KEY_MONITOR_AUTO_TYPES: - extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" - flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") - if ctr: - flash( - "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" - ) - return redirect("/key_monitor") - -@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("/trade") - 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("/trade") - planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit) - if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR: - conn.close() - rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算" - flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1") - return redirect("/trade") - 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, FUNDS_DECIMALS) - notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) - margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) - 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), FUNDS_DECIMALS) - if margin_capital > max_margin: - conn.close() - flash(f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}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, monitor_type) 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, - ORDER_MONITOR_TYPE_MANUAL, - ) - ) - conn.commit() - new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - opens_today_after = conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", - (trading_day,), - ).fetchone()[0] - conn.close() - - chart_name = None - chart_url = None - if make_order_chart and ORDER_CHART_ENABLED: - try: - title_prefix = f"{symbol} {direction} #{new_order_id}" - chart_name = generate_order_open_chart(exchange_symbol, title_prefix) - if chart_name: - chart_url = f"/static/images/order_charts/{chart_name}" - except Exception: - chart_name = None - chart_url = None - - if chart_name: - try: - journal_id = f"order_{new_order_id}" - coin = journal_coin_from_symbol(symbol) - open_local = (opened_at_bj or "")[:16].replace(" ", "T") - if len(open_local) < 16: - open_local = app_now().strftime("%Y-%m-%dT%H:%M") - close_local = open_local - hold_duration = calc_duration_text(open_local, close_local) - note = ( - f"auto_from_open_order id={new_order_id} oid={open_order_id} " - f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" - ) - conn = get_db() - conn.execute( - """INSERT OR REPLACE INTO journal_entries - (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, - expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, - mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, - new_trade_while_occupied, note, image) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - journal_id, - open_local, - close_local, - hold_duration, - coin, - "multi", - "0", - "auto:open", - "待平仓", - "", - "", - "否", - "", - "", - "", - None, - None, - None, - "", - "否", - "否", - note, - chart_name, - ), - ) - conn.commit() - conn.close() - except Exception: - try: - conn.close() - except Exception: - pass - - _, trading_capital_after = get_exchange_capitals(force=True) - account_base_display = ( - round(float(trading_capital_after), FUNDS_DECIMALS) - if trading_capital_after is not None - else round(float(capital_base), FUNDS_DECIMALS) - ) - account_name = (os.getenv("BINANCE_ACCOUNT_LABEL") or "binance实盘账户").strip() - dir_text = "多头(long)" if direction == "long" else "空头(short)" - order_state_text = ( - "已在交易所挂条件委托(止盈、止损各一张触发单)" - if tpsl_attached - else "条件委托未挂上(已拦截)" - ) - rr_show = planned_rr if planned_rr is not None else "-" - try: - rr_show_fmt = round(float(planned_rr), 4) if planned_rr is not None else None - except (TypeError, ValueError): - rr_show_fmt = None - rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1" - ep_wx = format_price_for_symbol(symbol, trigger_price) - sl_wx = format_price_for_symbol(symbol, stop_loss) - tp_wx = format_price_for_symbol(symbol, take_profit) - be_wx = format_price_for_symbol(symbol, breakeven_price) - style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" - wx_lines = [ - f"📈 {symbol} 开仓成功", - f"💼 交易类型:{dir_text}", - "🧾 订单基础信息", - f"🔖 交易所订单 ID:{open_order_id}", - f"📈 交易风格:{style_zh}", - f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), FUNDS_DECIMALS)} U", - "📊 仓位配置详情", - f"账户基数:{account_base_display} USDT", - f"合约杠杆:{leverage} 倍", - f"名义仓位:{notional_value} USDT", - f"仓位占比:{position_ratio}%", - f"合约数量:{amount}", - f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", - "🎯 价位 & 盈亏比", - f"开仓成交价:{ep_wx}", - f"止损价位:{sl_wx}", - f"止盈价位:{tp_wx}", - f"计划盈亏比:{rr_line}", - f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", - "📌 状态统计", - f"✅ 条件委托:{order_state_text}", - f"📅 当日开仓次数:{opens_today_after} / {DAILY_OPEN_ALERT_THRESHOLD} 次(风控阈值提醒)", - ] - if chart_url: - wx_lines.append(f"多周期K线图:{chart_url}") - send_wechat_msg("\n".join(wx_lines)) - - flash_lines = [ - f"实盘开单成功:风格 {trade_style};风险 {risk_percent}%≈{risk_amount_final}U;基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约数量 {amount}(折算标的 {base_amount})," - f"计划RR {planned_rr if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", - f"本交易日累计开仓:{opens_today_after}", - ] - if chart_url: - flash_lines.append(f"已生成多周期K线图:{chart_url}") - flash(" ".join(flash_lines)) - - if opens_today_before < DAILY_OPEN_ALERT_THRESHOLD <= opens_today_after: - advice = ai_short_advice( - f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_today_after} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" - f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。" - f"用户自述“上头了”。请给克制提醒。" - ) - if advice: - send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") - flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") - return redirect("/") - -@app.route("/delete_key_monitor/", methods=["POST"]) -@login_required -def delete_key_monitor(kid): - conn = get_db() - row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() - if not row: - conn.close() - return jsonify({"ok": False, "error": "not_found"}) - if is_fib_key_monitor_type(row["monitor_type"]): - _cancel_fib_monitor_limit(row) - insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") - cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) - conn.commit() - conn.close() - return jsonify({"ok": cur.rowcount > 0}) - - -@app.route("/delete_key_history/", methods=["POST"]) -@login_required -def delete_key_history(hid): - conn = get_db() - cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) - conn.commit() - conn.close() - return jsonify({"ok": cur.rowcount > 0}) - - -@app.route("/del_key/") -@login_required -def del_key(id): - conn = get_db() - row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() - if row: - if is_fib_key_monitor_type(row["monitor_type"]): - _cancel_fib_monitor_limit(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,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," - "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," - "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason," - "exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at " - "FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " - "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id ASC", - (start_bj, end_bj), - ).fetchall() - conn.close() - head = [ - "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", - "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", - "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", - "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", - "exchange_realized_pnl", "exchange_opened_at", "exchange_closed_at", "created_at", "开仓类型", - ] - 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 "" - kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" - eff = er1 or er0 or entry_reason_from_key_signal(kst) or "" - snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] - data.append(( - r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], - snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], - r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], - r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], - r["exchange_realized_pnl"] if "exchange_realized_pnl" in r.keys() else None, - r["exchange_opened_at"] if "exchange_opened_at" in r.keys() else None, - r["exchange_closed_at"] if "exchange_closed_at" in r.keys() else None, - r["created_at"], eff, - )) - day = app_now().strftime("%Y%m%d") - return _csv_response(f"trade_records_v3_{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(): - 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,upper,lower,notification_count,last_alert_message,close_reason,closed_at " - "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", - (start_bj, end_bj), - ).fetchall() - conn.close() - head = [ - "id", - "symbol", - "monitor_type", - "direction", - "upper", - "lower", - "notification_count", - "last_alert_message", - "close_reason", - "closed_at", - ] - data = [tuple(r[h] for h in head) for r in rows] - day = app_now().strftime("%Y%m%d") - return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) - -@app.route("/del_order/") -@login_required -def del_order(id): - conn = get_db() - row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() - if not row: - conn.close() - flash("订单不存在") - return redirect("/") - if row["status"] == "active": - try: - 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 - ) - close_resp = close_exchange_order(row) - close_order_id = close_resp.get("id", "") - cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) - exit_p = extract_trade_price_from_order(close_resp) - closed_at = app_now_str() - closed_at_ms = None - if not exit_p or float(exit_p) <= 0: - tr_fill = fetch_latest_closing_fill( - row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]), - row["direction"], - opened_at, - opened_at_ms=opened_at_ms, - ) - if tr_fill and tr_fill.get("price"): - try: - exit_p = float(tr_fill["price"]) - except (TypeError, ValueError): - exit_p = None - ts = tr_fill.get("timestamp") - if ts: - closed_at = ms_to_app_local_str(int(ts)) - closed_at_ms = int(ts) - else: - tr_fill = fetch_latest_closing_fill( - row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]), - row["direction"], - opened_at, - opened_at_ms=opened_at_ms, - ) - if tr_fill and tr_fill.get("timestamp"): - closed_at = ms_to_app_local_str(int(tr_fill["timestamp"])) - closed_at_ms = int(tr_fill["timestamp"]) - pnl_amount, exit_p, _, _, _ = resolve_trade_pnl_amount( - row, - row["trigger_price"], - exit_p, - opened_at_str=opened_at, - opened_at_ms=opened_at_ms, - closed_at_str=closed_at, - closed_at_ms=closed_at_ms, - ) - p = exit_p or get_price(row["symbol"]) or float(row["trigger_price"]) - 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) - session_capital = update_session_capital(conn, session_date, pnl_amount) - insert_trade_record( - conn, - symbol=row["symbol"], - monitor_type=order_row_monitor_type(row), - key_signal_type=order_row_key_signal_type(row), - 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)) - clear_key_sizing_snapshot_if_flat(conn, session_date) - 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("/trade") - except Exception as e: - if is_no_position_error(str(e)): - cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) - opened_at = get_opened_at_value(row) - opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) - result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) - miss_reason = f"手动删除时无持仓:{miss_reason}" - closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() - hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) - session_date = row["session_date"] or get_trading_day(closed_at_dt) - update_session_capital(conn, session_date, pnl_amount) - insert_trade_record( - conn, - symbol=row["symbol"], - monitor_type=order_row_monitor_type(row), - key_signal_type=order_row_key_signal_type(row), - 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]}" - close_ms = _local_input_datetime_to_ms(d.get("close_datetime")) - marker_payload = { - "exit_ts_ms": close_ms, - "entry_ts_ms": close_ms, - "entry_price": d.get("entry_price_hint"), - "exit_price": None, - } - try: - chart_fname = f"journal_{entry_id}.png" - saved = generate_multi_timeframe_chart_png( - exchange_symbol, - title_prefix, - timeframes=ORDER_CHART_TFS, - limit=ORDER_CHART_LIMIT, - out_dir=app.config["UPLOAD_FOLDER"], - filename=chart_fname, - filename_prefix="journal", - marker_payload=marker_payload, - marker_timeframes=( - {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()} - if ORDER_CHART_TFS - else {"5m", "15m", "1h", "4h"} - ), - ) - if saved: - image_filename = saved - chart_msg = f"已生成多周期K线图:/static/images/{saved}" - if uploaded_tmp: - try: - old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) - if os.path.exists(old_path): - os.remove(old_path) - except Exception: - pass - else: - chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Binance 网络/代理是否正常。" - except Exception as e: - image_filename = uploaded_tmp - chart_msg = f"自动生成K线图失败:{str(e)}" - - conn = get_db() - conn.execute( - """INSERT INTO journal_entries - (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, - expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, - mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, - new_trade_while_occupied, note, image) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - entry_id, d.get("open_datetime"), d.get("close_datetime"), hold_duration, d.get("coin"), d.get("tf"), - d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, - early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, - None, None, None, mood_issues, - d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename - ) - ) - conn.commit() - conn.close() - if chart_msg: - flash(f"交易复盘记录已保存。{chart_msg}") - else: - flash("交易复盘记录已保存") - return redirect("/records") - - -@app.route("/api/journals") -@login_required -def api_journals(): - 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 journal_entries WHERE COALESCE(close_datetime, created_at, open_datetime) >= ? " - "AND COALESCE(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/", 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/") -@login_required -def export_review_md(rid): - conn = get_db() - row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() - conn.close() - if not row: - return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") - - review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" - target_date = row["target_date"] or "-" - created_at = row["created_at"] or app_now_str() - content = (row["content"] or "").strip() - if not content: - content = "(无内容)" - - md = ( - f"# {review_type}报告\n\n" - f"- 目标日期: {target_date}\n" - f"- 生成时间: {created_at}\n" - f"- 报告ID: {row['id']}\n\n" - f"---\n\n" - f"{content}\n" - ) - - safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" - safe_type = "daily" if row["review_type"] == "daily" else "weekly" - filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" - return _md_response(filename, md) - - -@app.route("/export/reviews_md_bundle") -@login_required -def export_reviews_md_bundle(): - review_type = (request.args.get("review_type") or "").strip().lower() - target_date = (request.args.get("target_date") or "").strip() - if review_type not in ("daily", "weekly"): - return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") - if not target_date: - return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") - - conn = get_db() - rows = conn.execute( - "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", - (review_type, target_date), - ).fetchall() - conn.close() - if not rows: - return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") - - title = "日复盘" if review_type == "daily" else "周复盘" - lines = [ - f"# {title}汇总报告", - "", - f"- 目标日期: {target_date}", - f"- 条目数量: {len(rows)}", - f"- 导出时间: {app_now_str()}", - "", - "---", - "", - ] - for idx, row in enumerate(rows, 1): - created_at = row["created_at"] or "-" - content = (row["content"] or "").strip() or "(无内容)" - lines.extend( - [ - f"## 第{idx}条", - "", - f"- 报告ID: {row['id']}", - f"- 生成时间: {created_at}", - "", - content, - "", - "---", - "", - ] - ) - md = "\n".join(lines) - safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" - filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" - return _md_response(filename, md) - - -@app.route("/delete_review/", methods=["POST"]) -@login_required -def delete_review(rid): - conn = get_db() - conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) - conn.commit() - conn.close() - return jsonify({"ok": True}) - - -@app.route("/delete_trade_record/", methods=["POST"]) -@login_required -def delete_trade_record(rid): - conn = get_db() - cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) - conn.commit() - conn.close() - return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) - - -@app.route("/api/trade_record_review_update", methods=["POST"]) -@login_required -def api_trade_record_review_update(): - payload = request.get_json(silent=True) or {} - rec_id = payload.get("id") - try: - rec_id = int(rec_id) - except Exception: - return jsonify({"ok": False, "msg": "记录ID无效"}), 400 - - reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() - reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() - reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") - reviewed_take_profit_raw = payload.get("reviewed_take_profit") - reviewed_result = str(payload.get("reviewed_result") or "").strip() - reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() - reviewed_pnl_raw = payload.get("reviewed_pnl_amount") - - if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: - return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400 - - try: - reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") - reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") - except Exception: - return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 - if reviewed_close_dt < reviewed_open_dt: - return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 - hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) - hold_minutes = calc_hold_minutes(hold_seconds) - - try: - reviewed_pnl_amount = float(reviewed_pnl_raw) - except Exception: - return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 - reviewed_stop_loss = None - if reviewed_stop_loss_raw not in (None, ""): - try: - reviewed_stop_loss = float(reviewed_stop_loss_raw) - except Exception: - return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 - reviewed_take_profit = None - if reviewed_take_profit_raw not in (None, ""): - try: - reviewed_take_profit = float(reviewed_take_profit_raw) - except Exception: - return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 - - _MISSING_ER = object() - reviewed_entry_reason_update = _MISSING_ER - if "reviewed_entry_reason" in payload: - s = str(payload.get("reviewed_entry_reason") or "").strip() - if s and not entry_reason_valid_for_storage(s): - return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400 - reviewed_entry_reason_update = s or None - - conn = get_db() - row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() - if not row: - conn.close() - return jsonify({"ok": False, "msg": "记录不存在"}), 404 - risk_amount = row["risk_amount"] - actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) - base_params = [ - reviewed_opened_at, - reviewed_closed_at, - reviewed_stop_loss, - reviewed_take_profit, - round(reviewed_pnl_amount, FUNDS_DECIMALS), - reviewed_result or None, - reviewed_miss_reason or None, - hold_seconds, - hold_minutes, - app_now_str(), - actual_rr, - ] - if reviewed_entry_reason_update is not _MISSING_ER: - conn.execute( - """UPDATE trade_records - SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, - reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, - reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? - WHERE id=?""", - tuple(base_params + [reviewed_entry_reason_update, rec_id]), - ) - else: - conn.execute( - """UPDATE trade_records - SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, - reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, - reviewed_at=?, actual_rr=COALESCE(?, actual_rr) - WHERE id=?""", - tuple(base_params + [rec_id]), - ) - conn.commit() - conn.close() - return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) - - -@app.route("/manual_transfer", methods=["POST"]) -@login_required -def manual_transfer(): - try: - amount = float(request.form.get("amount", "0")) - except Exception: - flash("划转金额格式错误") - return redirect("/") - from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() - to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() - ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) - conn = get_db() - conn.execute( - "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", - ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) - ) - conn.commit() - conn.close() - if ok: - flash(f"手动划转成功:{amount}U {from_account}->{to_account}") - else: - flash(f"手动划转失败:{msg}") - return redirect("/") - - -@app.route("/ai_daily_review", methods=["POST"]) -@login_required -def ai_daily_review(): - date = request.form.get("date", "") - conn = get_db() - rows = conn.execute( - "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", - (date,) - ).fetchall() - conn.close() - if not rows: - return jsonify({"result": "该日无交易记录"}) - - text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" - for idx, row in enumerate(rows, 1): - text += _journal_row_lines_for_ai(idx, row) - text += "\n" - - image_paths = [] - for row in rows: - img = row["image"] - if not img: - continue - img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) - if os.path.exists(img_path): - image_paths.append(img_path) - ai_result = ai_review(text, "每日", image_paths=image_paths) - full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" - conn = get_db() - conn.execute( - "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", - (uuid.uuid4().hex, "daily", date, full) - ) - conn.commit() - conn.close() - return jsonify({"result": full}) - - -@app.route("/ai_weekly_review", methods=["POST"]) -@login_required -def ai_weekly_review(): - start_date = request.form.get("start_date", "") - end_date = request.form.get("end_date", "") - conn = get_db() - rows = conn.execute( - "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", - (start_date, end_date) - ).fetchall() - conn.close() - if not rows: - return jsonify({"result": "该时间段无交易记录"}) - - text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" - for idx, row in enumerate(rows, 1): - text += _journal_row_lines_for_ai(idx, row) - text += "\n" - - image_paths = [] - for row in rows: - img = row["image"] - if not img: - continue - img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) - if os.path.exists(img_path): - image_paths.append(img_path) - ai_result = ai_review(text, "周度", image_paths=image_paths) - full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" - conn = get_db() - conn.execute( - "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", - (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) - ) - conn.commit() - conn.close() - return jsonify({"result": full}) - -# 启动 -if __name__ == "__main__": - threading.Thread(target=background_task, daemon=True).start() - app.run(host=HOST, port=PORT, debug=DEBUG) +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 fib_key_monitor_lib import ( + FIB_KEY_MONITOR_TYPES, + calc_fib_plan, + entry_reason_from_key_signal, + fib_invalidate_by_mark, + fib_ratio_from_type, + is_fib_key_monitor_type, + key_signal_type_for_trade_record, + stored_key_signal_type, +) +from key_sl_tp_lib import ( + breakeven_enabled_from_row, + normalize_sl_tp_mode, + parse_breakeven_enabled_form, + plan_key_sl_tp, + sl_tp_mode_from_row, + sl_tp_mode_label, + sl_tp_plan_summary_text, +) +from history_window_lib import ( + PRESET_CUSTOM, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST7D, + PRESET_UTC_TODAY, + list_window_redirect_query, + resolve_list_window, + resolve_window, + 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") + +# ====================== 登录配置 ====================== +USERNAME = os.getenv("APP_USERNAME", "dekun") +PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") +AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") + +# 企业微信机器人Webhook +WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") +SYSTEM_TYPE = "CRYPTO" +HOST = os.getenv("APP_HOST", "0.0.0.0") +PORT = int(os.getenv("APP_PORT", "5000")) +DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" +DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) + +# 训练参数(可由 .env 覆盖) +DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) +DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) +DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) +BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) +ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) +# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" +).lower() in ("1", "true", "yes", "on") +APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") + + +def _resolve_app_tz(): + if ZoneInfo is not None: + try: + return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) + except Exception: + pass + return timezone(timedelta(hours=8)) + + +APP_TZ = _resolve_app_tz() +LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" +BINANCE_API_KEY = (os.getenv("BINANCE_API_KEY") or "").strip() +BINANCE_API_SECRET = (os.getenv("BINANCE_API_SECRET") or "").strip() +BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower() +# hedge=双向持仓(需 positionSide);oneway / single=单向持仓 +_raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower() +BINANCE_POSITION_MODE = "hedge" if _raw_binance_pos in ("hedge", "dual", "double", "hedged") else "oneway" +# 条件单触发参考:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价 +BINANCE_TRIGGER_WORKING_TYPE = (os.getenv("BINANCE_TRIGGER_WORKING_TYPE") or "CONTRACT_PRICE").strip().upper() +if BINANCE_TRIGGER_WORKING_TYPE not in ("CONTRACT_PRICE", "MARK_PRICE"): + BINANCE_TRIGGER_WORKING_TYPE = "CONTRACT_PRICE" +# 页面展示的交易所名称(多实例/多环境时可按需区分) +EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Binance").strip() or "Binance" +_BINANCE_DEFAULT_MARGIN_MODE = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" +BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) +PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) +KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) +KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) +KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) +KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5")) +KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1")) +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"))) +KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20"))) +KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3")) +KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03")) +KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5")) +KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))) +KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2")) +KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1")) +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true" +ORDER_MONITOR_TYPE_MANUAL = "下单监控" +ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" +KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) +# 与币安 App「仓位历史-实现盈亏」对齐:默认仅 REALIZED_PNL(手续费另计;避免与 COMMISSION 重复扣) +BINANCE_APP_PNL_INCOME_TYPES = frozenset({"REALIZED_PNL"}) +BINANCE_APP_PNL_INCOME_WITH_FEE = frozenset({"REALIZED_PNL", "COMMISSION"}) +BINANCE_NET_INCOME_TYPES = frozenset( + {"REALIZED_PNL", "COMMISSION", "FUNDING_FEE", "INSURANCE_CLEAR", "INTERNAL_AUTO_CLOSE"} +) +BINANCE_PNL_INCLUDE_FUNDING = os.getenv("BINANCE_PNL_INCLUDE_FUNDING", "false").lower() in ( + "1", + "true", + "yes", +) +KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) +AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" +AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) +AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") +AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") +FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" +FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) +# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 +AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) +AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) +MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) +KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") +FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) +TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT") +UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) +ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" +ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] +ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) +ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) +DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5")) +RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) +BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) +BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) +BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) +DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() +OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate") +AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") + +BINANCE_SOCKS_PROXY = (os.getenv("BINANCE_SOCKS_PROXY") or "").strip() +BINANCE_HTTP_PROXY = (os.getenv("BINANCE_HTTP_PROXY") or "").strip() +BINANCE_HTTPS_PROXY = (os.getenv("BINANCE_HTTPS_PROXY") or "").strip() + + +def build_binance_ccxt_proxies(): + """ + 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口)。 + + 推荐: + - 本机:ssh -N -D 127.0.0.1:1080 user@vps + - .env:BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + + 说明: + - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// + """ + socks = BINANCE_SOCKS_PROXY.strip() + http = BINANCE_HTTP_PROXY.strip() + https = BINANCE_HTTPS_PROXY.strip() or http + if socks: + return {"http": socks, "https": socks} + if http or https: + return {"http": http, "https": https} + return None + + +BINANCE_CCXT_PROXIES = build_binance_ccxt_proxies() + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(ORDER_CHART_DIR, exist_ok=True) +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER + +# Binance USDT 本位永续(ccxt unified: defaultType=swap) +exchange = ccxt.binance({ + "enableRateLimit": True, + "options": { + "defaultType": "swap", + "defaultMarginMode": _BINANCE_DEFAULT_MARGIN_MODE, + "adjustForTimeDifference": True, + }, +}) +if BINANCE_CCXT_PROXIES: + exchange.proxies = BINANCE_CCXT_PROXIES +if BINANCE_API_KEY and BINANCE_API_SECRET: + exchange.apiKey = BINANCE_API_KEY + exchange.secret = BINANCE_API_SECRET +MARKETS_LOADED = False +ACCOUNT_BALANCE_CACHE = { + "updated_at": 0.0, + "funding_usdt": None, + "trading_usdt": None +} +LIQUIDITY_RANK_CACHE = { + "updated_at": 0.0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + prefix = "【加密货币】" + full_msg = f"{prefix}\n{content}" + data = { + "msgtype": "text", + "text": {"content": full_msg} + } + try: + requests.post(WECHAT_WEBHOOK, json=data, timeout=WECHAT_TIMEOUT_SECONDS) + except: + pass + + +_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("BINANCE_ACCOUNT_LABEL") or "binance实盘账户").strip() + + +def _wechat_direction_text(direction): + d = (direction or "").lower() + return "多头(long)" if d == "long" else "空头(short)" + + +def _wechat_trading_capital_text(fallback=None): + try: + _, trading_capital = get_exchange_capitals(force=True) + except Exception: + trading_capital = None + if trading_capital is not None: + return f"{round(float(trading_capital), FUNDS_DECIMALS)}U" + if fallback is not None: + try: + return f"{round(float(fallback), FUNDS_DECIMALS)}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, FUNDS_DECIMALS)} U" + else: + pnl_disp = "-" + except (TypeError, ValueError): + pnl_disp = "-" + + lines = [ + f"📉 {symbol} 平仓完成", + f"💼 账户:{_wechat_account_label()}", + "", + "🧾 平仓概要", + f"🔖 平仓单号:{close_order_id or '-'}", + f"📌 方向:{_wechat_direction_text(direction)}", + f"📌 平仓结果:{result or '-'}", + f"💰 本单盈亏:{pnl_disp}", + f"⏱ 持仓时长:{hold_txt}", + f"💵 交易账户资金:{cap_txt}", + "", + "🎯 价位(计划)", + f"开仓成交价:{ep}", + f"离场参考价:{cp}", + f"止盈价位:{tp}", + f"止损价位:{sl}", + ] + if extra_note: + lines.extend(["", "📎 备注", extra_note]) + return "\n".join(lines) + + +def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): + sl_fmt = format_price_for_symbol(symbol, new_sl) + return "\n".join( + [ + f"# 🛡️ {symbol} 保护位更新", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 移动保本/止盈", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 类型:**{arm_txt}**", + f"- 当前RR:`{round(float(now_rr), 2)}R`", + f"- 锁定RR:`{round(float(locked_r), 2)}R`", + f"- 新保护位:`{sl_fmt}`", + ] + ) + + +def build_wechat_monitor_error_message(symbol, direction, scene, error_text): + return "\n".join( + [ + f"# ⚠️ {symbol} 下单监控异常", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 异常信息", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", + ] + ) + + +def build_wechat_key_monitor_message( + symbol, + direction, + monitor_type, + trigger_time, + key_price, + confirm_close, + hard_lines, + btc8h_status, + coin4h_status, + swing4h_pct, + op_lines, + risk_tip=None, +): + lines = [ + f"# 🎯 {symbol} 关键位确认推送", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 交易对 / 触发时间", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", + "", + "### 方向与确认K", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", + "", + "### 关键价位", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", + "", + "### 硬条件校验结果", + ] + lines.extend([f"- {x}" for x in hard_lines]) + lines.extend( + [ + "", + "### 市场状态说明", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + "", + "### 操作提示", + ] + ) + lines.extend([f"- {x}" for x in op_lines]) + if risk_tip: + lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) + return "\n".join(lines) + + +def _read_image_base64(image_path): + try: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + except Exception: + return None + + +def _extract_json_object(text): + if not text: + return None + clean = text.strip() + if clean.startswith("```"): + clean = clean.replace("```json", "").replace("```", "").strip() + try: + return json.loads(clean) + except Exception: + pass + match = re.search(r"\{[\s\S]*\}", clean) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: + return None + + +def _journal_row_lines_for_ai(idx, row, *, include_hold_duration=True): + """把 journal 字段拼成给 AI 的文本;字段之外的事实不要指望模型自己猜。""" + def nz(v, default="无"): + if v is None: + return default + s = str(v).strip() + return s if s else default + + lines = [ + f"{idx}. {nz(row['coin'])} {nz(row['tf'])} | 盈亏:{nz(row['pnl'])}U | 实际RR:{nz(row['real_rr'])} | 预期RR:{nz(row['expect_rr'])}", + f" 开仓逻辑:{nz(row['entry_reason'])}", + f" 平仓/离场(交易员自述):{nz(row['exit_reason'])}", + ] + if include_hold_duration: + lines.append(f" 持仓时长:{nz(row['hold_duration'])}") + ee_bits = [ + nz(row["early_exit"]), + nz(row["early_exit_reason"]), + nz(row["early_exit_trigger"]), + nz(row["early_exit_note"]), + ] + if any(x != "无" for x in ee_bits): + lines.append( + " 提前离场记录:" + f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}" + ) + mood_bits = f"心态标签:{nz(row['mood_issues'])}" + if row["mood_score"] is not None: + mood_bits += f" | 自评心态分:{row['mood_score']}" + lines.append(f" {mood_bits}") + if nz(row["post_breakeven_stare"]) != "无": + lines.append(f" 保本后盯盘:{nz(row['post_breakeven_stare'])}") + if nz(row["new_trade_while_occupied"]) != "无": + lines.append(f" 占用时新开仓:{nz(row['new_trade_while_occupied'])}") + if nz(row["note"]) != "无": + lines.append(f" 备注:{nz(row['note'])}") + return "\n".join(lines) + "\n" + + +def ai_review(trades_text, period_title, image_paths=None): + prompt = f""" +你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。 + +【硬性规则 — 必须遵守】 +- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。 +- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。 +- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。 +- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。 +- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。 +- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。 + +【输出结构】 +1. 总体盈亏结构(紧扣笔数、盈亏数字与 RR,少形容词) +2. 心态与执行(每笔 1–10 分 + 一句依据;依据必须对应记录字段) +3. 行为标签(提前离场 / 乱开仓 / 扛单等):仅在有字段或自述支撑时点名;否则写「记录未勾选或未描述,不作强加」 +4. 改进建议(最多 3 条,每条具体可执行) +5. 图表(若有且可读):结合价格行为简述;否则一两句说明无法看图分析 + +交易记录: +{trades_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + images = [] + for p in image_paths or []: + b64 = _read_image_base64(p) + if b64: + images.append(b64) + if images: + payload["images"] = images + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return r.json().get("response", "AI 生成失败") + except Exception as e: + return f"AI 调用失败:{str(e)}" + + +def ai_short_advice(prompt_text): + prompt = f""" +你是交易风控助理。请用中文给出**最多 3 条**提醒,要求: +- 每条不超过 25 个字 +- 语气克制、具体、可执行 +- 不要输出 Markdown,不要编号前缀以外的废话 + +场景: +{prompt_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return (r.json().get("response") or "").strip() + except Exception: + return "" + + +def _load_font(size): + if not ImageFont: + return None + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\arial.ttf", + ] + for path in candidates: + if path and os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except Exception: + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def _ohlcv_to_rows(ohlcv): + rows = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + rows.append( + { + "ts": int(bar[0]), + "o": float(bar[1]), + "h": float(bar[2]), + "l": float(bar[3]), + "c": float(bar[4]), + "v": float(bar[5]), + } + ) + except Exception: + continue + return rows + + +def _local_input_datetime_to_ms(dt_text): + raw = str(dt_text or "").strip() + if not raw: + return None + raw = raw.replace("T", " ") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(raw, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + return None + + +def _pick_marker_point(rows, target_ts_ms, target_price=None): + if not rows or target_ts_ms is None: + return None, None + idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) + if target_price is not None: + try: + p = float(target_price) + if p > 0: + return idx, p + except Exception: + pass + return idx, float(rows[idx]["c"]) + + +def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): + if not Image or not ImageDraw: + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") + img = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(img) + font = _load_font(14) + small = _load_font(12) + + pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 + plot_w = max(10, width - pad_l - pad_r) + plot_h = max(10, height - pad_t - pad_b) + + header_bg = (245, 247, 250) + draw.rectangle((0, 0, width, pad_t), fill=header_bg) + if font: + draw.text((10, 6), title, fill=(25, 35, 60), font=font) + else: + draw.text((10, 6), title, fill=(25, 35, 60)) + + if not rows: + if small: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) + else: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) + return img + + lo = min(r["l"] for r in rows) + hi = max(r["h"] for r in rows) + if hi <= lo: + hi = lo + 1e-12 + + n = len(rows) + marker_by_idx = {} + for mp in marker_points or []: + try: + idx = int(mp.get("idx")) + except Exception: + continue + if idx < 0 or idx >= n: + continue + marker_by_idx[idx] = mp + + x0 = pad_l + for i, r in enumerate(rows): + x1 = pad_l + int((i + 1) * plot_w / n) + x_mid = (x0 + x1) // 2 + wick_x = x_mid + y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) + y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) + y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) + y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) + top = min(y_open, y_close) + bot = max(y_open, y_close) + up = r["c"] >= r["o"] + wick_color = (120, 120, 120) + edge_color = (20, 20, 20) + draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) + body_w = max(1, (x1 - x0) - 2) + left = x0 + 1 + if bot - top < 2: + mid = (top + bot) // 2 + draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) + else: + if up: + draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) + else: + draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) + if i in marker_by_idx: + mp = marker_by_idx[i] + tag = str(mp.get("tag") or "") + m_price = float(mp.get("price") or r["c"]) + y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) + y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) + if tag == "ENTRY": + m_color = (0, 195, 95) + tri = [(x_mid, y_m - 20), (x_mid - 9, y_m - 4), (x_mid + 9, y_m - 4)] + text_y = y_m - 36 + else: + m_color = (235, 65, 65) + tri = [(x_mid, y_m + 20), (x_mid - 9, y_m + 4), (x_mid + 9, y_m + 4)] + text_y = y_m + 12 + draw.ellipse((x_mid - 5, y_m - 5, x_mid + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) + draw.polygon(tri, fill=m_color) + draw.line((x_mid, y_m, x_mid, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) + if font: + draw.text((x_mid + 8, text_y), tag, fill=m_color, font=font) + else: + draw.text((x_mid + 8, text_y), tag, fill=m_color) + x0 = x1 + + if len(marker_points or []) >= 2: + try: + entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) + exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) + if entry is not None and exitp is not None: + ex_i, ex_p = int(entry["idx"]), float(entry["price"]) + xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) + x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) + x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) + y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) + y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) + draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) + except Exception: + pass + + # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 + if small: + draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) + return img + + +def _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 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 + 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() + marker_tfs = {str(x).strip().lower() for x in (marker_timeframes or []) if str(x).strip()} + if marker_payload and tf_key in marker_tfs: + entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")) + exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")) + if entry_idx is not None and entry_price is not None: + points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"}) + if exit_idx is not None and exit_price is not None: + points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"}) + panels.append( + _render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + ) + ) + + if not panels: + return None + + gap = 10 + cols = 2 + rows_n = int(math.ceil(len(panels) / cols)) + w = cols * cell_w + (cols - 1) * gap + h = rows_n * cell_h + (rows_n - 1) * gap + out = Image.new("RGB", (w, h), (255, 255, 255)) + idx = 0 + for r in range(rows_n): + for c in range(cols): + if idx >= len(panels): + break + x = c * (cell_w + gap) + y = r * (cell_h + gap) + out.paste(panels[idx], (x, y)) + idx += 1 + + # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图) + if ImageDraw and rows_n >= 1: + draw_out = ImageDraw.Draw(out) + line_col = (220, 225, 232) + x_mid = cell_w + gap // 2 + if w > x_mid >= 0: + draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2) + for rr in range(1, rows_n): + y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 + if 0 <= y_mid <= h: + draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) + + target_dir = out_dir or ORDER_CHART_DIR + os.makedirs(target_dir, exist_ok=True) + fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" + out_path = os.path.join(target_dir, fname) + out.save(out_path, format="PNG") + return fname + + +def generate_order_open_chart(exchange_symbol, title_prefix, timeframes=None, limit=None): + return generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=timeframes, + limit=limit, + out_dir=ORDER_CHART_DIR, + filename=None, + filename_prefix="order", + ) + + +def journal_coin_from_symbol(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym.split("/")[0].strip() + if "-" in sym: + return sym.split("-")[0].strip() + if sym.endswith("USDT"): + return sym[:-4].strip() + return sym + + +EARLY_EXIT_TRIGGERS = ( + "", + "止盈", + "保本止盈", + "移动止盈", + "手动平仓", + "止损", + "其他", +) + +# 与用户约定的固定开仓类型(仅做这几类单子) +ENTRY_REASON_OPTIONS = ( + "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", + "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", + "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", + "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", + "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", + "关键位箱体突破", + "关键位收敛突破", + "关键位斐波0.618", + "关键位斐波0.786", +) + +STATS_SEGMENT_DEFS = ( + ("all", "全部交易", {"segment": "all"}), + ("manual", "下单监控", {"segment": "manual"}), + ("key_box", "关键位箱体突破", {"segment": "key_box"}), + ("key_conv", "关键位收敛结构", {"segment": "key_conv"}), + ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), + ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), +) +# 复盘表单「其他」选项的 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(f"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '{ORDER_MONITOR_TYPE_MANUAL}'") + except Exception: + pass + try: + c.execute( + "UPDATE order_monitors SET monitor_type=? WHERE monitor_type IS NULL OR TRIM(monitor_type)=''", + (ORDER_MONITOR_TYPE_MANUAL,), + ) + except Exception: + pass + try: + c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") + except: pass + for ddl in ( + "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", + "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", + "ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'", + "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", + ): + try: + c.execute(ddl) + except Exception: + pass + + try: + c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") + except Exception: + pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") + except Exception: + pass + for col, ddl in ( + ("key_signal_type", "ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT"), + ("exchange_realized_pnl", "ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL"), + ("exchange_opened_at", "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT"), + ("exchange_closed_at", "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT"), + ("exchange_sync_key", "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT"), + ): + try: + c.execute(ddl) + except Exception: + pass + + c.execute( + """CREATE TABLE IF NOT EXISTS key_monitor_history + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, + close_reason TEXT, closed_at TEXT)""" + ) + + conn.commit() + conn.close() + +init_db() + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def app_now(): + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" + return datetime.now(APP_TZ).replace(tzinfo=None) + + +def app_now_str(): + return app_now().strftime("%Y-%m-%d %H:%M:%S") + + +def utc_now_dt(): + """当前时刻(UTC,aware)。""" + return datetime.now(timezone.utc) + + +def utc_calendar_date_str(): + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" + return utc_now_dt().strftime("%Y-%m-%d") + + +def get_trading_day(now=None): + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" + now = now or app_now() + if getattr(now, "tzinfo", None): + now = now.astimezone(APP_TZ).replace(tzinfo=None) + if now.hour < TRADING_DAY_RESET_HOUR: + return (now - timedelta(days=1)).strftime("%Y-%m-%d") + return now.strftime("%Y-%m-%d") + + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓") + + +def parse_dt_for_trading_day(s): + if not s: + return None + s = str(s).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:ln], fmt) + except ValueError: + continue + return None + + +def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): + conn.execute( + """INSERT INTO key_monitor_history + (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + row["symbol"], + row["monitor_type"], + row["direction"] or "long", + row["upper"], + row["lower"], + int(notification_count or 0), + (last_msg or "")[:800] if last_msg else None, + close_reason, + app_now_str(), + ), + ) + + +def _session_week_bounds(trading_day_str): + end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() + start = end - timedelta(days=6) + return start.strftime("%Y-%m-%d"), trading_day_str + + +def _calendar_month_bounds(local_dt): + y, m = local_dt.year, local_dt.month + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end_d = datetime(y, 12, 31).date() + else: + end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() + return start, end_d.strftime("%Y-%m-%d") + + +def _count_opens_between(conn, start_td, end_td): + return _count_opens_for_segment(conn, start_td, end_td, "all") + + +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 _pnl_row_matches_segment(row, segment_key): + try: + mt = (row["monitor_type"] or "").strip() + kst = (row["key_signal_type"] or "").strip() + except Exception: + return False + if segment_key == "all": + return True + if segment_key == "manual": + return mt == ORDER_MONITOR_TYPE_MANUAL and not kst + if segment_key == "key_box": + return kst == "箱体突破" + if segment_key == "key_conv": + return kst == "收敛突破" + if segment_key == "key_fib618": + return kst == "斐波回调0.618" + if segment_key == "key_fib786": + return kst == "斐波回调0.786" + return False + + +def _count_opens_for_segment(conn, start_td, end_td, segment_key): + if segment_key == "manual": + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " + "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", + (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), + ).fetchone()[0] + kst_map = { + "key_box": "箱体突破", + "key_conv": "收敛突破", + "key_fib618": "斐波回调0.618", + "key_fib786": "斐波回调0.786", + } + kst = kst_map.get(segment_key) + if kst: + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?", + (start_td, end_td, kst), + ).fetchone()[0] + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _load_completed_trade_pnls(conn): + q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, + result, reviewed_result, monitor_type, key_signal_type + FROM trade_records + ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" + rows = conn.execute(q).fetchall() + out = [] + for r in rows: + effective_result = (r["reviewed_result"] or r["result"] or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + continue + try: + p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + except (TypeError, ValueError): + p = 0.0 + t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) + td = get_trading_day(t) if t else None + out.append((p, t, td, r)) + 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), FUNDS_DECIMALS) + loss_sum_raw = sum(p for p, _, _ in trades if p < 0) + loss_sum_u = round(abs(loss_sum_raw), FUNDS_DECIMALS) 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), FUNDS_DECIMALS) if neg_pnls else None + max_single_profit = round(max(pos_pnls), FUNDS_DECIMALS) 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, FUNDS_DECIMALS) + 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], FUNDS_DECIMALS) + 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): + """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" + now_dt = now_dt or app_now() + pnls = _load_completed_trade_pnls(conn) + total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] + w_start, w_end = _session_week_bounds(trading_day) + m_start, m_end = _calendar_month_bounds(now_dt) + + def slice_metrics(seg_key): + seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)] + day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] + week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] + month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] + dm = _compute_period_metrics(day_tr) + wm = _compute_period_metrics(week_tr) + mm = _compute_period_metrics(month_tr) + dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) + wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) + mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) + dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" + wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" + mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" + return dm, wm, mm + + segments = [] + for seg_key, seg_title, _meta in STATS_SEGMENT_DEFS: + dm, wm, mm = slice_metrics(seg_key) + segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm}) + + dm, wm, mm = slice_metrics("all") + + return { + "trading_day": trading_day, + "total_opens_all": total_opens_all, + "day": dm, + "week": wm, + "month": mm, + "segments": segments, + "stats_reset_hour": TRADING_DAY_RESET_HOUR, + } + + +def infer_leverage(symbol): + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return BTC_LEVERAGE + return ALT_LEVERAGE + + +def normalize_exchange_symbol(symbol): + sym = symbol.strip().upper() + if ":" in sym: + return sym + if "/" in sym: + base, quote = sym.split("/", 1) + quote_clean = quote.split(":")[0] + return f"{base}/{quote_clean}:{quote_clean}" + return sym + + +def resolve_monitor_exchange_symbol(row): + """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" + raw = "" + try: + if row["exchange_symbol"]: + raw = str(row["exchange_symbol"]).strip() + except (KeyError, IndexError, TypeError): + raw = "" + if not raw: + try: + raw = str(row["symbol"] or "").strip() + except (KeyError, IndexError, TypeError): + raw = "" + return normalize_exchange_symbol(raw) if raw else "" + + +def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): + if not position_symbol or not wanted_exchange_symbol: + return False + a = normalize_exchange_symbol(str(position_symbol).strip()) + b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip()) + return a == b + + +def _row_matches_monitor_direction(direction, position_dict): + """ + 判断持仓行是否属于当前监控方向。 + 币安双向持仓为 LONG/SHORT;单向持仓常为 BOTH,此时不能用 side!=direction 过滤, + 否则会把整行跳过(live 恒为 0),平仓数量错误甚至误判「无仓」。 + """ + if not position_dict: + return False + direction = (direction or "").strip().lower() + info = position_dict.get("info", {}) or {} + ps = str( + info.get("positionSide") + or position_dict.get("side") + or info.get("posSide") + or "" + ).strip().lower() + signed_amt = None + for key in ("positionAmt", "pos", "size"): + v = info.get(key) + if v is None or v == "": + continue + try: + signed_amt = float(v) + break + except (TypeError, ValueError): + continue + if BINANCE_POSITION_MODE != "hedge": + return True + if ps in ("long", "short"): + return ps == direction + if ps in ("both", "net") or ps == "": + if signed_amt is None: + return True + if direction == "long": + return signed_amt > 0 + if direction == "short": + return signed_amt < 0 + return False + if ps and ps != direction: + return False + return True + + +def _position_matches_wanted_contract(wanted_unified_sym, position_dict): + """统一 symbol 比对;不一致时用交易所原始合约代码与 ccxt market.id 对齐(兼容命名差异)。""" + if not wanted_unified_sym or not position_dict: + return False + ps = position_dict.get("symbol") + if _position_contract_symbol_match(ps, wanted_unified_sym): + return True + try: + ensure_markets_loaded() + mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper() + info = position_dict.get("info") or {} + c_raw = str(info.get("contract") or info.get("symbol") or info.get("pair") or "").strip().upper() + if mid and c_raw and mid == c_raw: + return True + except Exception: + pass + return False + + +def _position_row_effective_contracts(p): + """持仓数量:优先 ccxt contracts,否则用交易所原始 positionAmt/size/pos(避免统一层为 0 时被误判空仓)。""" + if not p: + return 0.0 + info = p.get("info") or {} + for val in (p.get("contracts"), info.get("positionAmt"), info.get("size"), info.get("pos")): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return x + except (TypeError, ValueError): + continue + return 0.0 + + +def normalize_symbol_input(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym + if ":" in sym: + sym = sym.split(":")[0] + return f"{sym}/USDT" + + +def normalize_kline_limit(limit_raw, default=200): + try: + n = int(limit_raw) + except Exception: + return default + return 200 if n >= 200 else 100 + + +def get_recommended_capital(current_capital): + if current_capital <= DAILY_LOSS_CAPITAL: + return DAILY_LOSS_CAPITAL + if current_capital >= DAILY_PROFIT_CAPITAL: + return DAILY_PROFIT_CAPITAL + return DAILY_START_CAPITAL + + +def ensure_session(conn, session_date): + row = conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + if row: + return row + conn.execute( + "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", + (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) + ) + conn.commit() + return conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + + +def update_session_capital(conn, session_date, pnl_amount): + session_row = ensure_session(conn, session_date) + new_capital = float(session_row["current_capital"]) + float(pnl_amount) + conn.execute( + "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(new_capital, FUNDS_DECIMALS), session_date) + ) + conn.commit() + return round(new_capital, FUNDS_DECIMALS) + + +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")) + open_stop = item.get("initial_stop_loss") + if open_stop in (None, ""): + open_stop = base_stop + item["display_open_stop_loss"] = open_stop + item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_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 "" + try: + _keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + _keys = [] + _reviewed_pnl_raw = row["reviewed_pnl_amount"] if "reviewed_pnl_amount" in _keys else None + has_reviewed_pnl = _reviewed_pnl_raw is not None and str(_reviewed_pnl_raw).strip() != "" + ex_pnl = item.get("exchange_realized_pnl") + if not has_reviewed_pnl and ex_pnl is not None and str(ex_pnl).strip() != "": + try: + item["effective_pnl_amount"] = round(float(ex_pnl), FUNDS_DECIMALS) + item["display_pnl_source"] = "exchange" + ex_open = (str(item.get("exchange_opened_at") or "").strip() or None) + ex_close = (str(item.get("exchange_closed_at") or "").strip() or None) + if ex_open: + item["effective_opened_at"] = ex_open + if ex_close: + item["effective_closed_at"] = ex_close + except (TypeError, ValueError): + item["display_pnl_source"] = "local" + elif has_reviewed_pnl: + item["display_pnl_source"] = "reviewed" + else: + item["display_pnl_source"] = "local" + return item + + +# USDT 等资金类:展示与入库舍入统一为 2 位小数(与交易所常见口径一致) +FUNDS_DECIMALS = 2 + + +def format_funds_u(value): + if value in (None, ""): + return "-" + try: + return f"{float(value):.{FUNDS_DECIMALS}f}" + except (TypeError, ValueError): + return str(value) + + +def round_funds(value): + try: + return round(float(value), FUNDS_DECIMALS) + except (TypeError, ValueError): + return None + + +def _ccxt_swap_symbol_for_precision(symbol): + """解析为 ccxt markets 中的永续 symbol,供 price_to_precision 使用。""" + raw = (symbol or "").strip() + if not raw: + return None + try: + ensure_markets_loaded() + markets = getattr(exchange, "markets", {}) or {} + except Exception: + return None + upper = raw.upper().replace(" ", "") + candidates = [] + candidates.append(normalize_exchange_symbol(raw)) + if upper.endswith("USDT") and len(upper) > 4 and "/" not in raw and ":" not in raw: + candidates.append(f"{upper[:-4]}/USDT:USDT") + if "/" not in raw and ":" not in raw and upper.isalnum() and not upper.endswith("USDT"): + candidates.append(f"{upper}/USDT:USDT") + for c in candidates: + if c and c in markets: + return c + return None + + +def format_price_for_symbol(symbol, value): + if value in (None, ""): + return "-" + try: + v = float(value) + except (TypeError, ValueError): + return str(value) + if v == 0: + return "0" + try: + ex_sym = _ccxt_swap_symbol_for_precision(symbol) + if ex_sym: + return str(exchange.price_to_precision(ex_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 round_price_to_exchange(exchange_symbol, price): + """将价格按 U 本位永续 tick 取整;失败返回 None。""" + if price is None: + return None + try: + ensure_markets_loaded() + sym = normalize_exchange_symbol(exchange_symbol) + return float(exchange.price_to_precision(sym, float(price))) + except Exception: + return None + + +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, notional_usdt=None): + """估算盈亏(USDT)。优先用名义价值 notional_usdt,否则 margin×leverage。""" + try: + trigger = float(trigger_price) + exit_p = float(exit_price) + if trigger <= 0: + return 0.0 + if notional_usdt is not None: + notional = float(notional_usdt) + else: + margin = float(margin_capital) + lev = float(leverage) + notional = margin * lev + if notional <= 0: + return 0.0 + if direction == "short": + pnl_ratio = (trigger - exit_p) / trigger + else: + pnl_ratio = (exit_p - trigger) / trigger + return round(notional * pnl_ratio, FUNDS_DECIMALS) + except Exception: + return 0.0 + + +def get_plan_notional_usdt(row_or_dict): + """计划名义价值(USDT),与开仓 sizing 口径一致。""" + if row_or_dict is None: + return None + try: + if hasattr(row_or_dict, "keys"): + nv = row_or_dict["notional_value"] if "notional_value" in row_or_dict.keys() else None + margin = row_or_dict["margin_capital"] if "margin_capital" in row_or_dict.keys() else None + lev = row_or_dict["leverage"] if "leverage" in row_or_dict.keys() else None + sym = row_or_dict["symbol"] if "symbol" in row_or_dict.keys() else "" + else: + nv = row_or_dict.get("notional_value") + margin = row_or_dict.get("margin_capital") + lev = row_or_dict.get("leverage") + sym = row_or_dict.get("symbol") or "" + except Exception: + return None + try: + if nv is not None and str(nv).strip() != "": + v = float(nv) + if v > 0: + return round(v, FUNDS_DECIMALS) + except (TypeError, ValueError): + pass + try: + margin = float(margin or 0) + lev = float(lev or infer_leverage(sym) or 0) + if margin > 0 and lev > 0: + return round(margin * lev, FUNDS_DECIMALS) + except (TypeError, ValueError): + pass + return None + + +def _trade_ids_from_fills(trades): + """仅使用 Binance 原始 tradeId(与 income 流水一致),不用 ccxt 的 id。""" + ids = set() + for t in trades or []: + info = t.get("info") if isinstance(t.get("info"), dict) else {} + for k in ("tradeId", "trade_id"): + v = info.get(k) + if v is not None and str(v).strip() != "": + ids.add(str(v).strip()) + return ids + + +def _cluster_closing_trades_near_close(trades, closed_ms, spread_ms=8 * 60 * 1000): + """只保留平仓时刻附近的一簇减仓成交,避免把相邻其它仓位算进来。""" + if not trades: + return [] + if closed_ms is None: + return list(trades) + try: + closed_ms = int(closed_ms) + except (TypeError, ValueError): + return list(trades) + scored = [] + for t in trades: + ts = _coerce_ts_ms(t.get("timestamp")) + if ts is None: + continue + scored.append((abs(ts - closed_ms), t)) + if not scored: + return list(trades) + scored.sort(key=lambda x: x[0]) + anchor_ts = _coerce_ts_ms(scored[0][1].get("timestamp")) + if anchor_ts is None: + return [scored[0][1]] + return [ + t + for t in trades + if _coerce_ts_ms(t.get("timestamp")) is not None + and abs(_coerce_ts_ms(t.get("timestamp")) - anchor_ts) <= spread_ms + ] + + +def _income_entry_trade_id(entry): + if not isinstance(entry, dict): + return "" + info = entry.get("info") if isinstance(entry.get("info"), dict) else {} + for src in (entry, info): + for k in ("tradeId", "trade_id"): + v = src.get(k) + if v is not None and str(v).strip() != "": + return str(v).strip() + return "" + + +def calc_binance_realized_pnl_from_trades(trades): + """仅汇总成交回报中的 realizedPnl(勿再扣 commission,避免与 income 重复)。""" + if not trades: + return None + total = 0.0 + has = False + for t in trades: + info = t.get("info") if isinstance(t.get("info"), dict) else {} + v = info.get("realizedPnl") + if v is None or str(v).strip() == "": + v = t.get("realizedPnl") or t.get("realized_pnl") + if v is None or str(v).strip() == "": + continue + try: + total += float(v) + has = True + except (TypeError, ValueError): + pass + if not has: + return None + return round(total, FUNDS_DECIMALS) + + +def _sum_binance_income(entries, income_types, trade_ids=None): + net = 0.0 + first_t = None + last_t = None + strict = bool(trade_ids) + for e in entries: + it = (e.get("incomeType") or e.get("income_type") or "").strip() + if it not in income_types: + continue + if strict: + if it in ("REALIZED_PNL", "COMMISSION"): + tid = _income_entry_trade_id(e) + if not tid or tid not in trade_ids: + continue + else: + continue + elif trade_ids and it in ("REALIZED_PNL", "COMMISSION"): + tid = _income_entry_trade_id(e) + if tid and tid not in trade_ids: + continue + try: + net += float(e.get("income") or 0) + except (TypeError, ValueError): + pass + t = _coerce_ts_ms(e.get("time")) + if t: + first_t = t if first_t is None else min(first_t, t) + last_t = t if last_t is None else max(last_t, t) + if first_t is None: + return None, None, None + return round(net, FUNDS_DECIMALS), first_t, last_t + + +def calc_pnl_from_closing_trades(direction, entry_price, trades, exchange_symbol=None): + """按减仓成交数量×价差汇总盈亏(不含资金费;比单点标记价更接近交易所)。""" + try: + entry = float(entry_price) + except (TypeError, ValueError): + return None + if entry <= 0 or not trades: + return None + contract_size = 1.0 + if exchange_symbol and BINANCE_API_KEY and BINANCE_API_SECRET: + try: + ensure_markets_loaded() + contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1) + except Exception: + contract_size = 1.0 + pnl = 0.0 + qty = 0.0 + for t in trades: + try: + price = float(t.get("price") or 0) + amount = float(t.get("amount") or 0) * contract_size + except (TypeError, ValueError): + continue + if price <= 0 or amount <= 0: + continue + qty += amount + if direction == "short": + pnl += amount * (entry - price) + else: + pnl += amount * (price - entry) + if qty <= 0: + return None + return round(pnl, FUNDS_DECIMALS) + + +def resolve_trade_pnl_amount( + row, + entry_price, + exit_price=None, + opened_at_str=None, + opened_at_ms=None, + closed_at_str=None, + closed_at_ms=None, +): + """ + 平仓盈亏:优先 Binance income 净额(含手续费),其次按减仓成交汇总,最后用计划名义×涨跌。 + 返回 (pnl, exit_price, exchange_opened_at, exchange_closed_at, exchange_sync_key)。 + """ + direction = (row["direction"] if hasattr(row, "keys") else row.get("direction") or "long").strip().lower() + sym = row["symbol"] if hasattr(row, "keys") else row.get("symbol") + ex_sym = ( + row["exchange_symbol"] + if hasattr(row, "keys") and "exchange_symbol" in row.keys() + else row.get("exchange_symbol") + ) or normalize_exchange_symbol(sym) + open_ms = _to_ms_with_fallback( + opened_at_ms if opened_at_ms is not None else (row["opened_at_ms"] if hasattr(row, "keys") and "opened_at_ms" in row.keys() else None), + opened_at_str or (row["opened_at"] if hasattr(row, "keys") else row.get("opened_at")), + ) + close_ms = _to_ms_with_fallback( + closed_at_ms, + closed_at_str, + ) + closing_trades = [] + if open_ms and (close_ms or closed_at_str): + closing_trades = fetch_closing_fills_for_record( + ex_sym, + direction, + opened_at_str or (row["opened_at"] if hasattr(row, "keys") else ""), + closed_at_str, + opened_at_ms=open_ms, + closed_at_ms=close_ms, + ) + if closing_trades and close_ms: + closing_trades = _cluster_closing_trades_near_close(closing_trades, int(close_ms)) + if closing_trades: + wexit = calc_weighted_exit_price(closing_trades) + if wexit and (exit_price is None or float(exit_price or 0) <= 0): + exit_price = wexit + last_ts = closing_trades[-1].get("timestamp") + if last_ts and not closed_at_str: + closed_at_str = ms_to_app_local_str(int(last_ts)) + close_ms = int(last_ts) + net, sync_key, eo, ec = fetch_binance_net_pnl_for_trade( + ex_sym, direction, open_ms, close_ms, closing_trades=closing_trades + ) + if net is not None: + return net, exit_price, eo, ec, sync_key + if closing_trades: + trade_pnl = calc_binance_realized_pnl_from_trades(closing_trades) + if trade_pnl is not None: + return trade_pnl, exit_price, None, None, None + fill_pnl = calc_pnl_from_closing_trades(direction, entry_price, closing_trades, ex_sym) + if fill_pnl is not None: + return fill_pnl, exit_price, None, None, None + notional = get_plan_notional_usdt(row) + margin = row["margin_capital"] if hasattr(row, "keys") else row.get("margin_capital") + lev = row["leverage"] if hasattr(row, "keys") else row.get("leverage") + if exit_price: + pnl = calc_pnl( + direction, + entry_price, + exit_price, + margin or DAILY_START_CAPITAL, + lev or infer_leverage(sym), + notional_usdt=notional, + ) + return pnl, exit_price, None, None, None + return 0.0, exit_price, None, None, None + + +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, FUNDS_DECIMALS) + 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, + key_signal_type=None, + entry_reason=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) + kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) + snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss + er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or "" + cur = conn.execute( + "INSERT INTO trade_records (symbol,monitor_type,key_signal_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,entry_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, 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, er or None + ) + ) + return int(cur.lastrowid or 0) + + +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, FUNDS_DECIMALS) 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 + if not (item.get("monitor_type") or "").strip(): + item["monitor_type"] = ORDER_MONITOR_TYPE_MANUAL + return item + + +def ensure_exchange_live_ready(): + if not LIVE_TRADING_ENABLED: + return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + if not (BINANCE_API_KEY and BINANCE_API_SECRET): + return False, "缺少 Binance API 密钥配置(BINANCE_API_KEY / BINANCE_API_SECRET)" + return True, "" + + +def order_row_monitor_type(row): + if row is None: + return ORDER_MONITOR_TYPE_MANUAL + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "monitor_type" in keys: + mt = (row["monitor_type"] or "").strip() + if mt: + return mt + return ORDER_MONITOR_TYPE_MANUAL + + +def order_row_key_signal_type(row): + if row is None: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "key_signal_type" not in keys: + return None + kst = (row["key_signal_type"] or "").strip() + if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst): + return kst + return None + + +def exchange_private_api_configured(): + """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。""" + return bool(BINANCE_API_KEY and BINANCE_API_SECRET) + + +def _extract_usdt_total(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + total_map = balance.get("total", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + total = usdt_info.get("total") + if total is None: + total = usdt_info.get("equity") + if total is None: + total = total_map.get("USDT") + if total is None: + total = usdt_info.get("free") + if total is None: + total = free_map.get("USDT") + try: + return float(total) if total is not None else None + except Exception: + return None + + +def _extract_usdt_free(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + free = usdt_info.get("free") + if free is None: + free = free_map.get("USDT") + try: + return float(free) if free is not None else None + except Exception: + return None + + +def _binance_futures_usdt_asset_row(balance): + """从 U 本位合约 fetch_balance 的 info.assets 中取 USDT 一行(与币安后台口径一致)。""" + if not isinstance(balance, dict): + return None + info = balance.get("info") + if not isinstance(info, dict): + return None + assets = info.get("assets") + if not isinstance(assets, list): + return None + for a in assets: + if isinstance(a, dict) and str(a.get("asset") or "").upper() == "USDT": + return a + return None + + +def _fetch_binance_swap_usdt_total(): + """仅 U 本位永续合约账户 USDT(总额口径:优先 marginBalance / walletBalance,不回退现货)。""" + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "swap"}) + row = _binance_futures_usdt_asset_row(bal) + if row: + for k in ("marginBalance", "walletBalance", "crossWalletBalance", "balance"): + x = row.get(k) + if x is not None and str(x).strip() != "": + try: + fv = float(x) + if fv >= 0: + return fv + except (TypeError, ValueError): + pass + v = _extract_usdt_total(bal) + return float(v) if v is not None else None + except Exception: + return None + + +def _fetch_binance_swap_usdt_free(): + """U 本位合约账户 USDT 可用(开仓可用保证金口径,不回退现货)。""" + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "swap"}) + row = _binance_futures_usdt_asset_row(bal) + if row: + for k in ("availableBalance", "maxWithdrawAmount"): + x = row.get(k) + if x is not None and str(x).strip() != "": + try: + fv = float(x) + if fv >= 0: + return fv + except (TypeError, ValueError): + pass + return _extract_usdt_free(bal) + except Exception: + return None + + +def _fetch_binance_funding_usdt(): + """Binance 资金账户(Funding Wallet)USDT 总额。""" + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "funding"}) + val = _extract_usdt_total(bal) + if val is not None: + return float(val) + except Exception: + pass + return None + + +def get_available_trading_usdt(): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None + return _fetch_binance_swap_usdt_free() + + +def get_synced_leverage(exchange_symbol, direction): + ensure_markets_loaded() + try: + positions = exchange.fetch_positions([exchange_symbol]) + for p in positions: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + if not _row_matches_monitor_direction(direction, p): + continue + info = p.get("info", {}) or {} + if lev is None or lev == 0 or str(lev) == "0": + lev = info.get("cross_leverage_limit") or info.get("leverage") + if lev: + try: + return int(float(lev)) + except Exception: + pass + except Exception: + pass + return None + + +def friendly_exchange_error(err, available_usdt=None): + msg = str(err) + low = msg.lower() + if ( + "51008" in msg + or "insufficient" in low + or "margin" in low and ("not enough" in low or "不足" in msg) + or "balance" in low and "insufficient" in low + ): + tail = f"(当前交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U)" if available_usdt is not None else "" + return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。" + clean = re.sub(r"\s+", " ", msg).strip() + return f"交易所下单失败:{clean}" + + +def get_exchange_capitals(force=False): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None, None + now_ts = time.time() + if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + try: + ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt() + except Exception: + ACCOUNT_BALANCE_CACHE["funding_usdt"] = None + try: + ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total() + except Exception: + # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 + ACCOUNT_BALANCE_CACHE["trading_usdt"] = None + ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + + +def execute_transfer_usdt(amount, from_account, to_account): + if amount <= 0: + return False, "划转金额必须大于0", None + ok_live, reason = ensure_exchange_live_ready() + if not ok_live: + return False, reason, None + try: + resp = exchange.transfer(TRANSFER_CCY, float(amount), from_account, to_account) + return True, "划转成功", resp + except Exception as e: + msg = str(e) + if "INVALID_KEY" in msg or "Invalid key" in msg or "-2015" in msg: + msg += ( + "。常见原因:① BINANCE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" + "③ API Key 未勾选「允许合约」「允许万向划转」等所需权限;④ Key 已重置或权限变更。" + ) + return False, msg, None + + +def get_account_usdt_total(account_type): + """读取各账户 USDT。funding 走资金钱包;swap 仅合约账户;spot 仅现货。""" + raw = (account_type or "").strip().lower() + if raw == "funding": + return _fetch_binance_funding_usdt() + if raw == "swap": + return _fetch_binance_swap_usdt_total() + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": raw}) + val = _extract_usdt_total(bal) + if val is not None: + return val + return 0.0 if raw == "spot" else None + except Exception: + return None + + +def auto_transfer_once_per_day(): + if not AUTO_TRANSFER_ENABLED: + return + utc_dt = utc_now_dt() + bj = utc_dt.astimezone(APP_TZ) + if bj.hour != AUTO_TRANSFER_BJ_HOUR: + return + transfer_day = utc_calendar_date_str() + conn = get_db() + exists = conn.execute( + "SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?", + ("auto_daily", transfer_day) + ).fetchone() + if exists: + conn.close() + return + target_amount = AUTO_TRANSFER_AMOUNT + to_balance = get_account_usdt_total(AUTO_TRANSFER_TO) + from_balance = get_account_usdt_total(AUTO_TRANSFER_FROM) + if to_balance is None: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"读取{AUTO_TRANSFER_TO}账户USDT失败") + ) + conn.commit() + conn.close() + return + needed = round(max(target_amount - float(to_balance), 0), FUNDS_DECIMALS) + 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, FUNDS_DECIMALS)}U") + ) + conn.commit() + conn.close() + send_wechat_msg( + f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{needed}U,当前{round(from_balance, FUNDS_DECIMALS)}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): + return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) + + +def clear_key_sizing_snapshot_if_flat(conn, session_date): + if get_active_position_count(conn) > 0: + return + conn.execute( + "UPDATE trading_sessions SET key_sizing_capital_snapshot = NULL, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (session_date,), + ) + conn.commit() + + +def get_key_sizing_capital_snapshot(conn, session_date): + row = ensure_session(conn, session_date) + try: + val = row["key_sizing_capital_snapshot"] + except (KeyError, IndexError): + return None + if val is None: + return None + try: + return float(val) + except (TypeError, ValueError): + return None + + +def set_key_sizing_capital_snapshot(conn, session_date, capital): + ensure_session(conn, session_date) + conn.execute( + "UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(float(capital), FUNDS_DECIMALS), session_date), + ) + conn.commit() + + +def resolve_capital_base_for_key_open(conn, trading_day, live_capital): + """关键位自动开仓:有仓时用无仓时资金快照计仓(可配置)。""" + live = float(live_capital) + active = get_active_position_count(conn) + if active <= 0: + set_key_sizing_capital_snapshot(conn, trading_day, live) + return live + if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT: + snap = get_key_sizing_capital_snapshot(conn, trading_day) + if snap is not None and snap > 0: + return snap + return live + + +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})" + if direction not in ("long", "short"): + return False, "方向必须为 long 或 short" + if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): + expected = BTC_LEVERAGE + else: + expected = ALT_LEVERAGE + if expected <= 0: + return False, "杠杆配置异常" + return True, "" + + +def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): + ensure_markets_loaded() + notional = float(margin_capital) * float(leverage) + ticker = exchange.fetch_ticker(exchange_symbol) + price = float(ticker.get("last") or fallback_price) + if price <= 0: + raise ValueError("触发价必须大于 0") + market = exchange.market(exchange_symbol) + contract_size = float(market.get("contractSize") or 1) + if market.get("contract"): + # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 + amount = notional / (price * contract_size) + else: + amount = notional / price + min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") + if min_amount and amount < float(min_amount): + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") + amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) + if amount_precise <= 0: + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + return amount_precise, price + + +def _to_positive_float(value): + try: + n = float(value) + return n if n > 0 else None + except Exception: + return None + + +def _extract_order_price_value(order_obj): + if not isinstance(order_obj, dict): + return None + for key in ("average", "price"): + v = _to_positive_float(order_obj.get(key)) + if v is not None: + return v + cost = _to_positive_float(order_obj.get("cost")) + filled = _to_positive_float(order_obj.get("filled")) + if cost is not None and filled is not None and filled > 0: + return cost / filled + info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} + for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): + v = _to_positive_float(info.get(key)) + if v is not None: + return v + return None + + +def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): + price = _extract_order_price_value(order_resp) + if price is not None: + return round(price, 8) + order_id = (order_resp or {}).get("id") + if order_id: + try: + fetched = exchange.fetch_order(order_id, exchange_symbol) + fetched_price = _extract_order_price_value(fetched) + if fetched_price is not None: + return round(fetched_price, 8) + except Exception: + pass + fallback = _to_positive_float(fallback_price) + return round(fallback, 8) if fallback is not None else 0.0 + + +def get_contract_size(exchange_symbol): + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + return float(market.get("contractSize") or 1) + + +def parse_positive_float(value): + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + num = float(raw) + if num <= 0: + raise ValueError("数值必须大于0") + return num + + +def build_binance_order_params(direction, reduce_only=False): + params = {} + if BINANCE_POSITION_MODE == "hedge": + params["positionSide"] = "LONG" if direction == "long" else "SHORT" + if reduce_only: + params["reduceOnly"] = True + return params + + +def _binance_market_close_param_candidates(direction): + """ + 平仓市价单参数组合(按顺序尝试)。 + 部分币安 U 本位账户对市价减仓报 -1106「reduceOnly sent when not required」, + 与条件单一致,需再试不带 reduceOnly 的写法;另保留双向/单向 positionSide 切换。 + """ + ps = "LONG" if direction == "long" else "SHORT" + hedge_ro = {"positionSide": ps, "reduceOnly": True} + hedge_plain = {"positionSide": ps} + oneway_ro = {"reduceOnly": True} + oneway_plain = {} + if BINANCE_POSITION_MODE == "hedge": + return [hedge_ro, hedge_plain, oneway_ro, oneway_plain] + return [oneway_ro, oneway_plain, hedge_ro, hedge_plain] + + +def _is_binance_close_param_retryable(err_msg): + s = (err_msg or "").lower() + if "-4061" in s: + return True + if "-1106" in s and ("reduceonly" in s or "reduce only" in s): + return True + if "position side" in s or "positionside" in s: + return True + if "dual side" in s or "position mode" in s: + return True + return False + + +def _filled_amount_for_tpsl(order, fallback_amount): + for key in ("filled", "amount"): + v = order.get(key) + try: + fv = float(v) + if fv > 0: + return fv + except Exception: + pass + return float(fallback_amount) + + +def _binance_trigger_order_params(): + p = {} + if BINANCE_TRIGGER_WORKING_TYPE: + p["workingType"] = BINANCE_TRIGGER_WORKING_TYPE + return p + + +def _binance_place_tp_sl_orders(exchange_symbol, direction, position_amount, stop_loss, take_profit): + """ + Binance USDT-M 永续:市价开仓成交后,挂 STOP_MARKET(止损)与 TAKE_PROFIT_MARKET(止盈)。 + 双向持仓时带 positionSide。不显式传 reduceOnly(否则会报 -1106 Parameter 'reduceOnly' sent when not required)。 + """ + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + if not market.get("swap"): + raise RuntimeError("仅支持永续合约 symbol") + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(position_amount))) + if amt <= 0: + raise RuntimeError("止盈止损:可平数量经精度舍入后为 0") + sl_px = exchange.price_to_precision(exchange_symbol, float(stop_loss)) + tp_px = exchange.price_to_precision(exchange_symbol, float(take_profit)) + common = dict(_binance_trigger_order_params()) + if BINANCE_POSITION_MODE == "hedge": + common["positionSide"] = "LONG" if direction == "long" else "SHORT" + last_err = None + for attempt in range(8): + try: + exchange.create_order( + exchange_symbol, + "STOP_MARKET", + close_side, + amt, + None, + dict(common, stopPrice=sl_px), + ) + time.sleep(0.05) + exchange.create_order( + exchange_symbol, + "TAKE_PROFIT_MARKET", + close_side, + amt, + None, + dict(common, stopPrice=tp_px), + ) + return + except Exception as e: + last_err = e + try: + cancel_binance_futures_open_orders(exchange_symbol) + except Exception: + pass + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"Binance 未接受止盈/止损触发单:{last_err}") + + +def ensure_markets_loaded(force=False): + global MARKETS_LOADED + if force or not MARKETS_LOADED: + exchange.load_markets(reload=force) + MARKETS_LOADED = True + + +def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): + ensure_markets_loaded() + mm = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" + try: + exchange.set_margin_mode(mm, exchange_symbol) + except Exception: + pass + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + params = build_binance_order_params(direction, reduce_only=False) + order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + order.setdefault("tpsl_attached", False) + if stop_loss and take_profit: + try: + pos_amt = _filled_amount_for_tpsl(order, amount) + _binance_place_tp_sl_orders(exchange_symbol, direction, pos_amt, stop_loss, take_profit) + order["tpsl_attached"] = True + except RuntimeError: + raise + except Exception as e: + raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e + return order + + +def close_exchange_order(order_row): + """ + 市价全平。数量优先取交易所当前持仓张数,避免仅用入库的 order_amount + 导致「只平一部分 → 撤单后委托没了但仓位还在」(加仓、精度或成交与计划不一致时常见)。 + """ + ensure_markets_loaded() + exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"]) + direction = order_row["direction"] + db_amt = float(order_row["order_amount"] or 0) + side = "sell" if direction == "long" else "buy" + last_resp = None + for _ in range(3): + live = get_live_position_contracts(exchange_symbol, direction) + if live is not None and live > 0: + raw_amt = live + else: + raw_amt = db_amt + if raw_amt <= 0: + if last_resp is not None: + return last_resp + raise ValueError("平仓失败:缺少有效下单数量") + try: + amount = float(exchange.amount_to_precision(exchange_symbol, raw_amt)) + except Exception: + amount = float(raw_amt) + if amount <= 0: + if last_resp is not None: + return last_resp + raise ValueError("平仓失败:数量经精度舍入后为 0") + order_resp = None + last_close_err = None + for params in _binance_market_close_param_candidates(direction): + try: + order_resp = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + last_close_err = None + break + except Exception as e: + last_close_err = e + if _is_binance_close_param_retryable(str(e)): + continue + raise + if order_resp is None: + raise last_close_err if last_close_err else RuntimeError("平仓失败:交易所未返回结果") + last_resp = order_resp + live_after = get_live_position_contracts(exchange_symbol, direction) + if live_after is None or live_after <= 0: + return last_resp + return last_resp + + +def cancel_binance_futures_open_orders(exchange_symbol): + """ + 平仓后撤销该合约下剩余挂单,避免孤儿单残留。 + Binance U 本位:普通挂单走 cancel_all_orders(DELETE allOpenOrders); + 止盈/止损等条件单在「Algo」通道,需再调 DELETE algoOpenOrders,否则手动平仓后仍会留在「当前委托」。 + """ + ok, _ = ensure_exchange_live_ready() + if not ok or not exchange_symbol: + return + ensure_markets_loaded() + sym = exchange_symbol + try: + exchange.cancel_all_orders(sym, params={}) + except Exception: + pass + try: + market = exchange.market(sym) + contract_id = market.get("id") + if contract_id and hasattr(exchange, "fapiPrivateDeleteAlgoOpenOrders"): + exchange.fapiPrivateDeleteAlgoOpenOrders({"symbol": contract_id}) + except Exception: + pass + try: + pending = exchange.fetch_open_orders(sym) + except Exception: + return + for o in pending or []: + oid = o.get("id") + if oid is None: + continue + try: + exchange.cancel_order(str(oid), sym) + except Exception: + pass + + +def _binance_list_raw_open_orders(exchange_symbol): + """普通挂单 + Algo 条件单(止盈/止损)。""" + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + contract_id = market.get("id") + out = [] + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + item = dict(o) + item["_channel"] = "regular" + out.append(item) + except Exception: + pass + try: + if contract_id and hasattr(exchange, "fapiPrivateGetOpenAlgoOrders"): + raw = exchange.fapiPrivateGetOpenAlgoOrders({"symbol": contract_id}) + items = raw if isinstance(raw, list) else (raw.get("orders") or raw.get("data") or []) + for info in items or []: + if not isinstance(info, dict): + continue + out.append( + { + "id": info.get("algoId") or info.get("orderId"), + "info": info, + "_channel": "algo", + "type": info.get("orderType") or info.get("type"), + "positionSide": info.get("positionSide"), + "stopPrice": info.get("triggerPrice") or info.get("stopPrice"), + "amount": info.get("quantity") or info.get("origQty"), + } + ) + except Exception: + pass + return out + + +def _binance_order_type_str(order): + info = order.get("info") or {} + if isinstance(info, dict): + for key in ("orderType", "type", "origType", "algoType"): + val = info.get(key) + if val: + return str(val).upper() + return str(order.get("type") or "").upper() + + +def _binance_order_matches_direction(order, direction): + if BINANCE_POSITION_MODE != "hedge": + return True + info = order.get("info") or {} + ps = str(order.get("positionSide") or info.get("positionSide") or "").upper() + want = "LONG" if direction == "long" else "SHORT" + if ps and ps not in ("", "BOTH") and ps != want: + return False + return True + + +def _binance_order_trigger_price(order): + for key in ("stopPrice", "triggerPrice", "activatePrice"): + try: + v = float(order.get(key) or 0) + if v > 0: + return v + except Exception: + pass + info = order.get("info") or {} + if isinstance(info, dict): + for key in ("triggerPrice", "stopPrice", "activatePrice"): + try: + v = float(info.get(key) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def _binance_tpsl_role_from_order(order): + typ = _binance_order_type_str(order) + if "TAKE_PROFIT" in typ: + return "tp" + if "STOP" in typ: + return "sl" + return None + + +def _binance_tpsl_slot_from_order(order, exchange_symbol): + trig = _binance_order_trigger_price(order) + try: + amt = float(order.get("amount") or order.get("remaining") or 0) + except Exception: + amt = None + if amt is not None and amt <= 0: + amt = None + channel = order.get("_channel") or "regular" + oid = order.get("id") + if oid is None and isinstance(order.get("info"), dict): + oid = order["info"].get("algoId") or order["info"].get("orderId") + disp = format_price_for_symbol(exchange_symbol, trig) if trig else "-" + return { + "order_id": str(oid) if oid is not None else "", + "channel": channel, + "trigger_price": trig, + "trigger_display": disp, + "amount": amt, + "type": _binance_order_type_str(order), + } + + +def fetch_exchange_tpsl_slots(exchange_symbol, direction): + """返回 { sl: slot|None, tp: slot|None },供页面展示与单笔撤单。""" + slots = {"sl": None, "tp": None} + if not exchange_symbol: + return slots + ok, _ = ensure_exchange_live_ready() + if not ok: + return slots + try: + for order in _binance_list_raw_open_orders(exchange_symbol): + if not _binance_order_matches_direction(order, direction): + continue + role = _binance_tpsl_role_from_order(order) + if role not in ("sl", "tp") or slots[role] is not None: + continue + slots[role] = _binance_tpsl_slot_from_order(order, exchange_symbol) + except Exception: + pass + return slots + + +def cancel_binance_tpsl_slot(exchange_symbol, slot): + if not slot or not exchange_symbol: + return + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + contract_id = market.get("id") + oid = slot.get("order_id") + if not oid: + return + if slot.get("channel") == "algo" and contract_id and hasattr(exchange, "fapiPrivateDeleteAlgoOrder"): + exchange.fapiPrivateDeleteAlgoOrder({"symbol": contract_id, "algoId": oid}) + return + exchange.cancel_order(str(oid), exchange_symbol) + + +def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): + sltp_mode = (sltp_mode or "price").strip().lower() + if sltp_mode == "pct": + sl_pct = float(data.get("sl_pct") or 0) + tp_pct = float(data.get("tp_pct") or 0) + if sl_pct <= 0 or tp_pct <= 0: + raise ValueError("百分比止盈止损须为正数") + sl_ratio = sl_pct / 100.0 + tp_ratio = tp_pct / 100.0 + entry = float(live_price) + if direction == "short": + stop_loss = entry * (1 + sl_ratio) + take_profit = entry * (1 - tp_ratio) + else: + stop_loss = entry * (1 - sl_ratio) + take_profit = entry * (1 + tp_ratio) + else: + stop_loss = float(data.get("sl") or data.get("stop_loss") or 0) + take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0) + if stop_loss <= 0 or take_profit <= 0: + raise ValueError("止盈止损价格须大于 0") + return stop_loss, take_profit + + +def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): + """先撤该合约全部 TP/SL,再按新价重挂(与交易所 App 一致)。""" + 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_binance_futures_open_orders(ex_sym) + pos_amt = get_live_position_contracts(ex_sym, direction) + if pos_amt is None or float(pos_amt) <= 0: + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") + _binance_place_tp_sl_orders(ex_sym, direction, float(pos_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() + # 禁止匹配笼统的 reduceonly / -4061:会与参数错误、单向/双向模式不匹配混淆, + # 误判后走「已无仓」同步结束,交易所仓位却仍在。 + keywords = [ + "no position", + "position does not exist", + "position not exist", + "nothing to close", + "pos size is 0", + "position amount is 0", + ] + return any(k in msg for k in keywords) + + +def get_live_position_contracts(exchange_symbol, direction): + ensure_markets_loaded() + try: + rows = exchange.fetch_positions([exchange_symbol]) + except Exception: + return None + total = 0.0 + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + if not _row_matches_monitor_direction(direction, p): + continue + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + total += contracts + return total + + +def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): + """在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。""" + if not rows: + return None + candidates = [] + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + if (not relax_hedge) and not _row_matches_monitor_direction(direction, p): + continue + candidates.append((contracts, p)) + if not candidates and (not relax_hedge) and BINANCE_POSITION_MODE == "hedge": + return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) + if not candidates: + return None + candidates.sort(key=lambda x: x[0], reverse=True) + return candidates[0][1] + + +def _coerce_float(*values): + for v in values: + if v is None or v == "": + continue + try: + return float(v) + except (TypeError, ValueError): + continue + return None + + +def parse_ccxt_position_metrics(position, order_leverage=None): + """ + 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏。 + 「所保证金」对齐币安合约页的初始/持仓保证金:优先 initialMargin / positionInitialMargin。 + Binance 全仓下 ccxt 的 collateral 常来自 crossMargin,口径易与「名义」混淆,故不全仓优先用 collateral。 + """ + if not position: + return None + p = position + info = p.get("info", {}) or {} + margin_mode = str(p.get("marginMode") or info.get("marginType") or "").lower() + isolated = margin_mode.startswith("isolated") or str(info.get("isolated", "")).lower() == "true" + + initial = _coerce_float( + p.get("initialMargin"), + info.get("positionInitialMargin"), + info.get("initialMargin"), + ) + if (initial is None or initial <= 0) and isolated: + initial = _coerce_float(p.get("collateral"), info.get("isolatedWallet")) + if initial is None or initial <= 0: + initial = _coerce_float(p.get("margin")) + if initial is None or initial <= 0: + initial = _coerce_float( + info.get("initial_margin"), + info.get("position_margin"), + info.get("iso_margin"), + ) + notional = _coerce_float(p.get("notional"), p.get("notionalValue")) + if notional is None or notional <= 0: + notional = _coerce_float(info.get("value")) + if notional is not None: + notional = abs(notional) + # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) + if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: + try: + lev = float(order_leverage) + if lev > 0: + approx = notional / lev + if approx > 0: + initial = approx + except (TypeError, ValueError): + pass + unrealized = _coerce_float( + p.get("unrealizedPnl"), + info.get("unrealised_pnl"), + info.get("unrealized_pnl"), + ) + mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) + out = {} + if initial is not None and initial > 0: + out["initial_margin"] = round(initial, FUNDS_DECIMALS) + if notional is not None and notional > 0: + out["notional"] = round(notional, FUNDS_DECIMALS) + if unrealized is not None: + out["unrealized_pnl"] = round(unrealized, FUNDS_DECIMALS) + if mark is not None and mark > 0: + ps = p.get("symbol") + try: + ex_sym = _ccxt_swap_symbol_for_precision(ps or "") + if ex_sym: + out["mark_price"] = float(exchange.price_to_precision(ex_sym, mark)) + else: + out["mark_price"] = round(mark, 8) + except Exception: + out["mark_price"] = round(mark, 8) + return out or None + + +def get_live_position_exchange_metrics(exchange_symbol, direction): + ensure_markets_loaded() + if not exchange_private_api_configured() or not exchange_symbol: + return None + try: + rows = exchange.fetch_positions() or [] + except Exception: + try: + rows = exchange.fetch_positions([exchange_symbol]) or [] + except Exception: + return None + p = _select_live_position_row(rows, exchange_symbol, direction) + return parse_ccxt_position_metrics(p) + + +def opened_at_str_to_ms(opened_at_str): + if not opened_at_str: + return None + try: + dt = datetime.strptime(str(opened_at_str).strip()[:19], "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + try: + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + return None + + +def _to_ms_with_fallback(ms_value, dt_str): + try: + if ms_value is not None and str(ms_value).strip() != "": + v = int(float(ms_value)) + if v > 0: + return v + except Exception: + pass + return opened_at_str_to_ms(dt_str) + + +def ms_to_app_local_str(ms): + if ms is None: + return app_now_str() + try: + dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) + return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return app_now_str() + + +def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): + """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" + try: + tp = float(take_profit) + sl = float(stop_loss) + ex = float(exit_price) + trig = float(trigger_price) + except (TypeError, ValueError): + return None + band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12) + if direction == "long": + if ex >= tp - band: + return "止盈" + if ex <= sl + band: + return "止损" + else: + if ex <= tp + band: + return "止盈" + if ex >= sl - band: + return "止损" + return None + + +def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" + if not (BINANCE_API_KEY and BINANCE_API_SECRET): + return None + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + + def pick_from_trades(trades): + if not trades: + return None + candidates = [] + for t in trades: + if (t.get("side") or "").lower() != close_side: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if BINANCE_POSITION_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + ts = t.get("timestamp") + if ts is None: + continue + candidates.append(t) + if not candidates: + return None + return max(candidates, key=lambda x: x.get("timestamp") or 0) + + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) + hit = pick_from_trades(trades) + if hit is None and since_ms: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100) + hit = pick_from_trades(trades) + return hit + except Exception: + return None + + +def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): + """ + 拉取某条历史记录对应的减仓成交(用于按 id 回填)。 + 返回按时间排序的成交列表。 + """ + if not (BINANCE_API_KEY and BINANCE_API_SECRET): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + close_upper_ms = (int(closed_ms) + 15 * 60 * 1000) if closed_ms is not None else None + 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 close_upper_ms and ts > close_upper_ms: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if BINANCE_POSITION_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + all_side_candidates.append(t) + 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[-5:] + near = [] + for t in all_side_candidates: + ts = _coerce_ts_ms(t.get("timestamp")) + if ts is None: + continue + delta = abs(ts - int(closed_ms)) + if delta <= 45 * 60 * 1000: + near.append((delta, t)) + if near: + near.sort(key=lambda x: x[0]) + picked = [x[1] for x in near[:12]] + picked.sort(key=lambda x: x.get("timestamp") or 0) + return _cluster_closing_trades_near_close(picked, int(closed_ms)) + return _cluster_closing_trades_near_close(all_side_candidates[-5:], int(closed_ms)) + + +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"] + exchange_symbol = row["exchange_symbol"] or normalize_exchange_symbol(sym) + + closed_at_str = app_now_str() + closed_at_ms = None + closing_trades = fetch_closing_fills_for_record( + exchange_symbol, direction, opened_at_str, None, opened_at_ms=opened_at_ms + ) + exit_px = calc_weighted_exit_price(closing_trades) if closing_trades else None + if exit_px is None: + trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) + if trade: + try: + exit_px = float(trade.get("price") or 0) or None + except (TypeError, ValueError): + exit_px = None + if not closing_trades: + closing_trades = [trade] + if closing_trades: + last_ts = closing_trades[-1].get("timestamp") + if last_ts: + closed_at_str = ms_to_app_local_str(int(last_ts)) + closed_at_ms = int(last_ts) + + open_ms = _to_ms_with_fallback( + row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at_str + ) + close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) + pnl, exit_px2, _, _, _ = resolve_trade_pnl_amount( + row, + trigger_price, + exit_px, + opened_at_str=opened_at_str, + opened_at_ms=open_ms, + closed_at_str=closed_at_str, + closed_at_ms=close_ms, + ) + if exit_px2: + exit_px = float(exit_px2) + + 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: + pnl2, _, _, _, _ = resolve_trade_pnl_amount( + row, + trigger_price, + p, + opened_at_str=opened_at_str, + opened_at_ms=open_ms, + closed_at_str=closed_at_str, + closed_at_ms=close_ms, + ) + return ( + guessed, + pnl2, + closed_at_str, + "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", + ) + + result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) + if result: + return ( + result, + pnl, + closed_at_str, + "按交易所成交/流水同步为止盈/止损平仓", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def reconcile_external_closes(conn, days=None): + synced_count = 0 + cutoff_ms = None + if days is not None: + try: + d = int(days) + if d > 0: + cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) + except Exception: + cutoff_ms = None + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + if cutoff_ms is not None: + opened_at_v = get_opened_at_value(r) + opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 + if opened_ms is None or opened_ms < cutoff_ms: + continue + exchange_symbol = r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + continue + if live_contracts > 0: + continue + cancel_binance_futures_open_orders(exchange_symbol) + opened_at = get_opened_at_value(r) + opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + 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"],)) + clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day()) + 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 + min_closed = KEY_VOLUME_MA_BARS + 3 + if len(closed) < min_closed: + out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足" + return out + try: + breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] + confirm = closed[KEY_CONFIRM_BAR] + except IndexError: + out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" + return out + prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR] + avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1) + vol_break = float(breakout[5]) + vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN 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 > KEY_BREAKOUT_AMP_MIN_PCT) and (amp_pct < KEY_BREAKOUT_AMP_MAX_PCT) + 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 <= KEY_DAILY_VOLUME_RANK_MAX) + 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 _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): + """本条关键位一次性结案:写历史并从当前表删除。""" + n = int(row["notification_count"] or 0) + 1 + insert_key_monitor_history(conn, row, n, last_msg, close_reason) + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + + +def _key_hard_lines_from_checks(checks): + return [ + 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)", + ] + + +def _key_plan_sl_tp_for_row(row, direction, upper, lower, checks): + """按 key_monitors 录入的方案计算计划 SL/TP。""" + mode = sl_tp_mode_from_row(row, "standard") + manual_tp = _sqlite_row_val(row, "manual_take_profit") + planned = plan_key_sl_tp( + mode, + direction, + upper, + lower, + checks, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + manual_take_profit=manual_tp, + ) + return planned, mode + + +def _market_open_for_key_monitor( + conn, + symbol, + direction, + exchange_symbol, + stop_loss, + take_profit, + key_signal_type=None, + breakeven_enabled=0, +): + """ + 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(Binance U 本位)。 + 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) + """ + now = app_now() + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + return False, f"风控拒绝下单:{reason}", None + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live, None + + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + + 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) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + + trade_style = (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: + return False, "获取交易所实时价格失败(以损定仓需要当前价)", None + try: + ensure_markets_loaded() + except Exception: + pass + lp_adj = round_price_to_exchange(exchange_symbol, live_price) + if lp_adj is not None: + live_price = float(lp_adj) + + sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) + tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) + if sl_adj is not None: + stop_loss = float(sl_adj) + if tp_adj is not None: + take_profit = float(tp_adj) + + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) + notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) + margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) + + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金", None + + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U", + None, + ) + + 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: + return False, friendly_exchange_error(e, available_usdt=available_usdt), None + + tr_adj = round_price_to_exchange(exchange_symbol, trigger_price) + if tr_adj is not None: + trigger_price = float(tr_adj) + sl_f = round_price_to_exchange(exchange_symbol, stop_loss) + if sl_f is not None: + stop_loss = float(sl_f) + tp_f = round_price_to_exchange(exchange_symbol, take_profit) + if tp_f is not None: + take_profit = float(tp_f) + + 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) + be_enabled = 1 if int(breakeven_enabled or 0) != 0 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, monitor_type, key_signal_type) " + "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, + be_enabled, + notional_value, + position_ratio, + base_amount, + amount, + open_order_id, + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(key_signal_type), + ), + ) + 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] + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "base_amount": base_amount, + "notional_value": notional_value, + "position_ratio": position_ratio, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "risk_percent": risk_percent, + "breakeven_rr_trigger": breakeven_rr_trigger, + "breakeven_price": breakeven_price, + "capital_base_at_open": capital_base, + } + + +def _sqlite_row_val(row, key, default=None): + try: + v = row[key] + return default if v is None else v + except (KeyError, IndexError, TypeError): + return default + + +def get_symbol_mark_price(symbol): + """斐波失效判定用标记价。""" + ex_sym = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + ticker = exchange.fetch_ticker(ex_sym) + m = _coerce_float(ticker.get("mark"), ticker.get("last")) + if m is None: + info = ticker.get("info") or {} + m = _coerce_float(info.get("mark_price"), info.get("last")) + if m is not None and m > 0: + return float(m) + except Exception: + pass + p = get_price(symbol) + return float(p) if p is not None else None + + +def cancel_fib_limit_order(exchange_symbol, order_id): + """仅撤销本条斐波限价单,不用 cancel_all。""" + if not order_id: + return False + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return False + ensure_markets_loaded() + oid = str(order_id) + try: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + return False + + +def fib_limit_order_status(exchange_symbol, order_id): + if not order_id: + return "missing" + ensure_markets_loaded() + oid = str(order_id) + try: + o = exchange.fetch_order(oid, exchange_symbol) + st = (o.get("status") or "").lower() + if st in ("closed", "filled"): + filled = float(o.get("filled") or 0) + if filled > 0 or st == "filled": + return "filled" + if st in ("canceled", "cancelled", "expired", "rejected"): + return "canceled" + if st in ("open", "new", "partially_filled"): + return "open" + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + return "open" + except Exception: + pass + return "unknown" + + +def place_fib_limit_order(exchange_symbol, direction, amount, leverage, limit_price): + ensure_markets_loaded() + mm = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" + try: + exchange.set_margin_mode(mm, exchange_symbol) + except Exception: + pass + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + price = round_price_to_exchange(exchange_symbol, float(limit_price)) + if price is None or price <= 0: + raise ValueError("挂单价无效") + params = build_binance_order_params(direction, reduce_only=False) + return exchange.create_order(exchange_symbol, "limit", side, amount, price, params) + + +def _fib_key_exists_for_symbol(conn, symbol): + ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})", + (symbol, *tuple(FIB_KEY_MONITOR_TYPES)), + ).fetchone() + return row is not None + + +def _fib_plan_for_row(row): + typ = (row["monitor_type"] or "").strip() + ratio = fib_ratio_from_type(typ) + if ratio is None: + return None + return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio) + + +def _cancel_fib_monitor_limit(row): + ex_sym = normalize_exchange_symbol(row["symbol"]) + oid = _sqlite_row_val(row, "fib_limit_order_id") + if oid: + cancel_fib_limit_order(ex_sym, oid) + + +def _fib_has_live_position(exchange_symbol, direction): + live = get_live_position_contracts(exchange_symbol, direction) + return live is not None and float(live) > 0 + + +def _insert_order_monitor_from_fib_fill( + conn, row, trigger_price, stop_loss, take_profit, amount, leverage, margin_capital, + notional_value, position_ratio, base_amount, exchange_order_id, tpsl_attached, +): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + exchange_symbol = normalize_exchange_symbol(symbol) + typ = (row["monitor_type"] or "").strip() + now = app_now() + trading_day = get_trading_day(now) + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4) + 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 + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + be_enabled = 1 if breakeven_enabled_from_row(row, 0) else 0 + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + 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, monitor_type, key_signal_type) " + "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, + be_enabled, + notional_value, + position_ratio, + base_amount, + amount, + exchange_order_id or "", + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(typ), + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + return new_order_id + + +def _finalize_fib_key_fill(conn, row): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + typ = (row["monitor_type"] or "").strip() + ex_sym = normalize_exchange_symbol(symbol) + plan = _fib_plan_for_row(row) + if not plan: + _finalize_key_monitor_one_shot(conn, row, "斐波计划无效", "fib_plan_invalid") + return + entry_plan, sl_plan, tp_plan = plan + sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan) + tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan) + sl_adj = round_price_to_exchange(ex_sym, sl) + tp_adj = round_price_to_exchange(ex_sym, tp) + if sl_adj is not None: + sl = float(sl_adj) + if tp_adj is not None: + tp = float(tp_adj) + amount = float(_sqlite_row_val(row, "fib_order_amount") or 0) + leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5) + margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0) + oid = _sqlite_row_val(row, "fib_limit_order_id") + entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan) + trigger_price = entry_px + if oid: + try: + o = exchange.fetch_order(str(oid), ex_sym) + trigger_price = resolve_order_entry_price(o, ex_sym, entry_px) + except Exception: + pass + tr_adj = round_price_to_exchange(ex_sym, trigger_price) + if tr_adj is not None: + trigger_price = float(tr_adj) + if amount <= 0: + live_amt = get_live_position_contracts(ex_sym, direction) + amount = float(live_amt or 0) + if amount <= 0: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后处理失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 无法取得持仓/下单数量,未挂 TP/SL\n" + ) + return + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后风控拒绝\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 原因:{reason}\n" + f"- 请手动处理仓位与挂单\n" + ) + return + tpsl_attached = False + try: + _binance_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) + tpsl_attached = True + except Exception as e: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后挂 TP/SL 失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 错误:{friendly_exchange_error(e)}\n" + f"- 请手动补挂止盈止损\n" + ) + return + contract_size = get_contract_size(ex_sym) + base_amount = round(float(amount) * contract_size, 8) + notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0 + session_row = ensure_session(conn, get_trading_day(app_now())) + capital_base = float(session_row["current_capital"] or 0) + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0 + planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp) + new_order_id = _insert_order_monitor_from_fib_fill( + conn, row, trigger_price, sl, tp, amount, leverage, margin_capital, + notional_value, position_ratio, base_amount, oid, tpsl_attached, + ) + rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + succ = ( + f"# ✅ {symbol} 斐波限价成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{new_order_id}**\n" + f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n" + f"- 止损:{format_wechat_scalar_2dp(sl)}|止盈:{format_price_for_symbol(symbol, tp)}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(succ) + _finalize_key_monitor_one_shot(conn, row, succ, "fib_filled") + + +def check_fib_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + typ = (r["monitor_type"] or "").strip() + if not is_fib_key_monitor_type(typ): + continue + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + up, low = float(r["upper"]), float(r["lower"]) + oid = _sqlite_row_val(r, "fib_limit_order_id") + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + status = fib_limit_order_status(ex_sym, oid) if oid else "missing" + if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): + _finalize_fib_key_fill(conn, r) + continue + if status == "open": + if fib_invalidate_by_mark(direction, mark, up, low): + _cancel_fib_monitor_limit(r) + msg = ( + f"# ⚠️ {symbol} 斐波监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + continue + if status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): + msg = ( + f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价触达止盈侧,本条已结案\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + conn.commit() + conn.close() + + +def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0): + if _fib_key_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" + ratio = fib_ratio_from_type(mt) + plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio) + if not plan: + return False, "斐波上下沿无效(需上沿 H > 下沿 L)" + entry, sl, tp = plan + ex_sym = normalize_exchange_symbol(symbol) + entry = round_price_to_exchange(ex_sym, entry) + sl = round_price_to_exchange(ex_sym, sl) + tp = round_price_to_exchange(ex_sym, tp) + if entry is None or sl is None or tp is None: + return False, "斐波价位经交易所精度舍入后无效" + entry, sl, tp = float(entry), float(sl), float(tp) + planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)" + ok, reason = precheck_risk(conn, symbol, direction_sel) + if not ok: + return False, reason + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + available_usdt = get_available_trading_usdt() + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" + 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: + return False, "以损定仓后保证金超过当前交易资金" + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + ) + try: + amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) + oid = str(order_resp.get("id") or "") + if not oid: + return False, "交易所未返回限价单 ID" + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, mt, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, + ), + ) + return True, None + + +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑位仅单次提醒结案) +def check_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + typ = (typ_raw or "").strip() + if is_fib_key_monitor_type(typ): + continue + direction = (r["direction"] or "long").lower() + 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)主趋势逆势,建议降低仓位并严格执行止损。" + + key_price = float(low) if direction == "long" else float(up) + hard_lines = _key_hard_lines_from_checks(checks) + trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() + + alert_only = typ in KEY_MONITOR_ALERT_ONLY_TYPES or ( + typ not in KEY_MONITOR_AUTO_TYPES and typ not in KEY_MONITOR_ALERT_ONLY_TYPES + ) + + if alert_only: + op_lines = [ + "- 本条为关键阻力/支撑或非标类型:**仅单次推送**,不进行自动开仓。", + "- 本条关键位将在推送后记入历史并从监控列表移除。", + ] + 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) + _finalize_key_monitor_one_shot(conn, r, msg, "key_level_alert_only") + continue + + plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) + if not plan_tuple: + fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)" + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划无效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, checks.get('confirm_close'))}`\n" + f"- **{fmt_rr}**(未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + E, sl_raw, tp_raw, box_h = plan_tuple + exchange_symbol = normalize_exchange_symbol(sym) + try: + ensure_markets_loaded() + except Exception: + pass + sl_px = round_price_to_exchange(exchange_symbol, sl_raw) + tp_px = round_price_to_exchange(exchange_symbol, tp_raw) + if sl_px is not None: + sl_raw = float(sl_px) + if tp_px is not None: + tp_raw = float(tp_px) + + planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) + rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR + + if not rr_ok: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)" + plan_line = sl_tp_plan_summary_text( + sl_tp_mode, direction, E, sl_raw, tp_raw, box_h, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + ) + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{plan_line}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + + key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None + be_on = breakeven_enabled_from_row(r, 0) + ok_trade, trade_err, det = _market_open_for_key_monitor( + conn, + sym, + direction, + exchange_symbol, + sl_raw, + tp_raw, + key_signal_type=key_sig, + breakeven_enabled=1 if be_on else 0, + ) + planned_rr_txt = ( + format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + ) + if not ok_trade: + fail_msg = ( + f"# ❌ {sym} 关键位自动单失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n" + f"- **失败原因:{trade_err}**\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(fail_msg) + _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed") + continue + + tpsl_txt = ( + "已在交易所挂止盈/止损触发单(Binance U 本位条件单)" + if det.get("tpsl_attached") + else "⚠️ 条件单挂接状态异常或未挂上" + ) + rr_fill = det.get("planned_rr_fill") + rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-" + + succ_msg_lines = [ + f"# ✅ {sym} 关键位自动开仓成功", + f"**账户:{_wechat_account_label()}**", + f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", + f"- 页面订单 ID:**{det['new_order_id']}**", + f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 触发时间:`{trigger_time}`", + f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", + f"- **计划 RR(E):{planned_rr_txt}:1**", + f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", + f"- **成交价侧计划 RR:**{rr_fill_txt}:1", + f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", + f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", + f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", + f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", + f"- **{tpsl_txt}**", + f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", + f"- 当日开仓次数:**{det.get('opens_today_after')}** / {DAILY_OPEN_ALERT_THRESHOLD}(提醒阈值)", + ] + succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines]) + if risk_tip: + succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"]) + succ_msg = "\n".join(succ_msg_lines) + send_wechat_msg(succ_msg) + _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened") + + if det.get("opens_today_before", 0) < DAILY_OPEN_ALERT_THRESHOLD <= det.get("opens_today_after", 0): + advice = ai_short_advice( + f"用户在北京时间交易日 {det['trading_day']} 已累计开仓 {det['opens_today_after']} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}") + 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 = "" + exit_p = None + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细。 + exit_p = extract_trade_price_from_order(close_resp) + if exit_p and exit_p > 0: + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + else: + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + except Exception as e: + if is_no_position_error(str(e)): + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + cancel_binance_futures_open_orders(ex_sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判。 + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + exit_ref = exit_p if exit_p and float(exit_p) > 0 else p + pnl_amount, _, _, _, _ = resolve_trade_pnl_amount( + r, + trigger_price, + exit_ref, + opened_at_str=opened_at, + opened_at_ms=_to_ms_with_fallback(opened_at_ms, opened_at), + closed_at_str=closed_at, + closed_at_ms=_to_ms_with_fallback(None, closed_at), + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason="触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + opened_at=opened_at, + closed_at=closed_at, + ) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{res}(交易所已先行平仓)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + session_capital_fallback=session_capital, + ) + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) + conn.commit() + continue + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (pid,)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + continue + cancel_binance_futures_open_orders(r["exchange_symbol"] or normalize_exchange_symbol(sym)) + exit_ref = exit_p if exit_p and float(exit_p) > 0 else p + pnl_amount, _, _, _, _ = resolve_trade_pnl_amount( + r, + trigger_price, + exit_ref, + opened_at_str=opened_at, + opened_at_ms=_to_ms_with_fallback(opened_at_ms, opened_at), + closed_at_str=closed_at, + closed_at_ms=_to_ms_with_fallback(None, closed_at), + ) + 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=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + 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)) + clear_key_sizing_snapshot_if_flat(conn, get_trading_day()) + conn.commit() + conn.close() + + +def force_close_before_reset(): + if not FORCE_CLOSE_ENABLED: + return + now = app_now() + # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) + if now.hour != FORCE_CLOSE_BJ_HOUR: + return + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + p = get_price(r["symbol"]) + if not p: + continue + direction = r["direction"] + trigger_price = r["trigger_price"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(r["symbol"]) + session_date = r["session_date"] or get_trading_day(now) + opened_at = get_opened_at_value(r) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + cancel_binance_futures_open_orders(r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"])) + except Exception as e: + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=r["symbol"], + direction=direction, + scene="强制清仓失败", + error_text=str(e), + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + 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_fib_key_monitors() + check_key_monitors() + check_order_monitors() + except: + pass + time.sleep(MONITOR_POLL_SECONDS) + + +# ====================== 登录路由 ====================== +@app.route("/login", methods=["GET", "POST"]) +def login(): + if AUTH_DISABLED: + session["logged_in"] = True + return redirect("/") + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + if username == USERNAME and password == PASSWORD: + session["logged_in"] = True + return redirect("/") + else: + flash("账号或密码错误") + return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME) + +@app.route("/logout") +def logout(): + session.clear() + return redirect("/" if AUTH_DISABLED else "/login") + +# 登录校验装饰器 +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if AUTH_DISABLED: + return f(*args, **kwargs) + if not session.get("logged_in"): + return redirect("/login") + return f(*args, **kwargs) + return decorated + + +@app.route("/sync_positions") +@login_required +def sync_positions(): + days_raw = (request.args.get("days") or "").strip() + sync_days = None + if days_raw: + try: + sync_days = max(1, min(365, int(days_raw))) + except Exception: + sync_days = None + conn = get_db() + synced = reconcile_external_closes(conn, days=sync_days) + conn.commit() + conn.close() + if sync_days is not None: + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + else: + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + return redirect("/") + + +@app.route("/api/sync_positions", methods=["POST"]) +@login_required +def api_sync_positions(): + payload = request.get_json(silent=True) or {} + days_raw = str(payload.get("days", "")).strip() + if not days_raw: + return jsonify({"ok": False, "msg": "请填写天数"}), 400 + try: + days = int(days_raw) + except Exception: + return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 + if days < 1 or days > 365: + return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 + conn = get_db() + synced = reconcile_external_closes(conn, days=days) + conn.commit() + conn.close() + return jsonify({"ok": True, "days": days, "synced": int(synced)}) + + +def _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 > 1e9: + return int(v * 1000.0) + return int(v * 1000.0) + + +def _fetch_binance_income_entries(exchange_symbol, start_ms, end_ms): + if not hasattr(exchange, "fapiPrivateGetIncome"): + return [] + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + contract_id = market.get("id") + if not contract_id: + return [] + out = [] + cursor = int(start_ms) + end_ms = int(end_ms) + for _ in range(20): + try: + batch = exchange.fapiPrivateGetIncome( + {"symbol": contract_id, "startTime": cursor, "endTime": end_ms, "limit": 1000} + ) + except Exception: + break + if not batch: + break + out.extend(batch) + if len(batch) < 1000: + break + last_t = _coerce_ts_ms(batch[-1].get("time")) + if last_t is None or last_t >= end_ms: + break + cursor = last_t + 1 + return out + + +def fetch_binance_net_pnl_for_trade( + exchange_symbol, direction, open_ms, close_ms, closing_trades=None +): + if open_ms is None or close_ms is None or close_ms < open_ms: + return None, None, None, None + if closing_trades: + closing_trades = _cluster_closing_trades_near_close(closing_trades, int(close_ms)) + trade_ids = _trade_ids_from_fills(closing_trades) if closing_trades else None + buffer_ms = 3 * 60 * 1000 if trade_ids else 5 * 60 * 1000 + entries = _fetch_binance_income_entries( + exchange_symbol, max(0, int(open_ms) - buffer_ms), int(close_ms) + buffer_ms + ) + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + cid = market.get("id") or exchange_symbol + + def _pack(net, first_t, last_t, prefix): + if net is None: + return None + sk = f"{prefix}|{cid}|{direction}|{open_ms}|{close_ms}|{net}" + eo = ms_to_app_local_str(first_t) if first_t else None + ec = ms_to_app_local_str(last_t) if last_t else None + return net, sk, eo, ec + + if entries and trade_ids: + net, ft, lt = _sum_binance_income(entries, BINANCE_APP_PNL_INCOME_WITH_FEE, trade_ids) + out = _pack(net, ft, lt, "income_net") + if out: + return out + net, ft, lt = _sum_binance_income(entries, BINANCE_APP_PNL_INCOME_TYPES, trade_ids) + out = _pack(net, ft, lt, "income_rp") + if out: + return out + + if closing_trades: + trade_pnl = calc_binance_realized_pnl_from_trades(closing_trades) + if trade_pnl is not None: + fts = [_coerce_ts_ms(t.get("timestamp")) for t in closing_trades] + fts = [x for x in fts if x] + ft = min(fts) if fts else None + lt = max(fts) if fts else None + out = _pack(trade_pnl, ft, lt, "trades_rp") + if out: + return out + + if entries: + loose_types = ( + BINANCE_NET_INCOME_TYPES + if BINANCE_PNL_INCLUDE_FUNDING + else BINANCE_APP_PNL_INCOME_WITH_FEE + ) + net, ft, lt = _sum_binance_income(entries, loose_types, trade_ids if trade_ids else None) + out = _pack(net, ft, lt, "income") + if out: + return out + + return None, None, None, None + + +# ====================== 主页面 ====================== +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, FUNDS_DECIMALS) if funding_capital is not None else None + current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) + recommended_capital = get_recommended_capital(current_capital) + key_list = conn.execute("SELECT * FROM key_monitors").fetchall() + key_history = conn.execute( + "SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + stats_bundle = compute_stats_bundle(conn, trading_day, now) + raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + order_list = [] + for o in raw_order_list: + order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + raw_records = conn.execute( + "SELECT * FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id DESC LIMIT 1000", + (start_bj, end_bj), + ).fetchall() + records = [to_effective_trade_dict(r) for r in raw_records] + total = len(records) + miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") + win = sum(1 for r in records if (r.get("effective_result") or "") in ("止盈", "保本止盈", "移动止盈")) + occupied_miss_total = sum( + 1 + for r in records + if (r.get("effective_result") or "") == "错过" + and ("持仓占用" in str(r.get("effective_miss_reason") or "")) + ) + rate = round(win/total*100,2) if total else 0 + active_count = len(order_list) + can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS + key_gate_rule_text = ( + f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|" + f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" + f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|" + f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|" + f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%" + ) + conn.close() + return render_template( + "index.html", + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + record=records, + total=total, + miss_count=miss_count, + rate=rate, + trading_day=trading_day, + funding_usdt=funding_usdt, + daily_start_capital=DAILY_START_CAPITAL, + current_capital=current_capital, + recommended_capital=recommended_capital, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + reset_hour=TRADING_DAY_RESET_HOUR, + balance_refresh_seconds=BALANCE_REFRESH_SECONDS, + auto_transfer_enabled=AUTO_TRANSFER_ENABLED, + auto_transfer_amount=AUTO_TRANSFER_AMOUNT, + auto_transfer_from=AUTO_TRANSFER_FROM, + auto_transfer_to=AUTO_TRANSFER_TO, + auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, + full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + active_count=active_count, + can_trade=can_trade, + focus_key_id=(key_list[0]["id"] if key_list else None), + focus_order_id=(order_list[0]["id"] if order_list else None), + data_export_version=3, + 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, + }, + 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, + funds_fmt=format_funds_u, + entry_reason_options=list(ENTRY_REASON_OPTIONS), + entry_reason_other_value=ENTRY_REASON_OTHER, + exchange_display=EXCHANGE_DISPLAY_NAME, + max_active_positions=MAX_ACTIVE_POSITIONS, + manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + key_gate_rule_text=key_gate_rule_text, + kline_timeframe=KLINE_TIMEFRAME, + ) + + +@app.route("/") +@login_required +def index(): + return redirect("/trade") + + +@app.route("/key_monitor") +@login_required +def key_monitor_page(): + return render_main_page("key_monitor") + + +@app.route("/trade") +@login_required +def trade_page(): + return render_main_page("trade") + + +@app.route("/records") +@login_required +def records_page(): + return render_main_page("records") + + +@app.route("/stats") +@login_required +def stats_page(): + return render_main_page("stats") + + +@app.route("/api/account_snapshot") +@login_required +def api_account_snapshot(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals(force=True) + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) + recommended_capital = get_recommended_capital(current_capital) + 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, FUNDS_DECIMALS) 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, + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "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,fib_entry_price,fib_limit_order_id FROM key_monitors" + ).fetchall() + order_rows = conn.execute( + "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'" + ).fetchall() + conn.close() + + symbol_set = set() + for r in key_rows: + symbol_set.add(r["symbol"]) + for r in order_rows: + symbol_set.add(r["symbol"]) + + prices = {} + for s in symbol_set: + p = get_price(s) + if p is not None: + prices[s] = float(p) + + all_swap_positions = [] + if exchange_private_api_configured(): + try: + ensure_markets_loaded() + all_swap_positions = exchange.fetch_positions() or [] + except Exception: + all_swap_positions = [] + + key_prices = [] + for r in key_rows: + is_fib = is_fib_key_monitor_type(r["monitor_type"]) + if is_fib: + price = get_symbol_mark_price(r["symbol"]) + else: + 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 + gate_summary = "-" + gate_metrics = "" + fib_gate_ok = True + if is_fib: + direction = (r["direction"] or "long").lower() + inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) + fib_gate_ok = not inval + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}" + if _sqlite_row_val(r, "fib_limit_order_id"): + gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}" + else: + try: + gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) + except Exception: + gate = None + 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 = float(gate.get("confirm_close") or 0) + edge = float(gate.get("edge_price") or 0) + gate_metrics = ( + f"量值:{vol_now}/{vol_avg} " + f"幅值:{amp_pct}% " + f"二确值:{format_price_for_symbol(r['symbol'], cfm_close)}@{format_price_for_symbol(r['symbol'], edge)}" + ) + except Exception: + gate_metrics = "" + sym_k = r["symbol"] + key_prices.append({ + "id": r["id"], + "symbol": sym_k, + "price": round(price, 6), + "price_display": format_price_for_symbol(sym_k, price), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + "gate_summary": gate_summary, + "gate_ok": fib_gate_ok if is_fib else 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), 2) 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), + "price_display": format_price_for_symbol(ex_sym, price), + "float_pnl": round(pnl, FUNDS_DECIMALS), + "float_pct": pnl_pct, + "rr_ratio": rr_ratio, + "plan_margin": round(margin, FUNDS_DECIMALS) if margin else None, + "exchange_initial_margin": None, + "exchange_notional": None, + "exchange_mark_price": None, + "exchange_mark_price_display": 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: + mp = ex_metrics["mark_price"] + payload["exchange_mark_price"] = mp + payload["exchange_mark_price_display"] = format_price_for_symbol(ex_sym, mp) + if ex_metrics.get("unrealized_pnl") is not None: + payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), FUNDS_DECIMALS) + payload["pnl_source"] = "exchange" + denom = ex_metrics.get("initial_margin") or margin + payload["float_pct"] = ( + round((payload["float_pnl"] / float(denom)) * 100, 2) if denom and float(denom) > 0 else pnl_pct + ) + if exchange_private_api_configured(): + try: + payload["exchange_tpsl"] = fetch_exchange_tpsl_slots(ex_sym, r["direction"]) + except Exception: + payload["exchange_tpsl"] = {"sl": None, "tp": None} + else: + payload["exchange_tpsl"] = {"sl": None, "tp": None} + 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/order//cancel_tpsl", methods=["POST"]) +@login_required +def api_order_cancel_tpsl(order_id): + data = request.get_json(silent=True) or {} + role = (data.get("role") or "").strip().lower() + if role not in ("sl", "tp"): + return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 + conn = get_db() + 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 + ok, reason = ensure_exchange_live_ready() + if not ok: + return jsonify({"ok": False, "msg": reason}), 400 + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, row["direction"]) + slot = slots.get(role) + if not slot: + return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 + try: + cancel_binance_tpsl_slot(ex_sym, slot) + return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": fetch_exchange_tpsl_slots(ex_sym, row["direction"])}) + except Exception as e: + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + + +@app.route("/api/order//place_tpsl", methods=["POST"]) +@login_required +def api_order_place_tpsl(order_id): + data = request.get_json(silent=True) or {} + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + symbol = row["symbol"] + direction = row["direction"] + live_price = get_price(symbol) + if live_price is None: + conn.close() + return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400 + try: + sltp_mode = (data.get("sltp_mode") or "price").strip().lower() + stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": str(e)}), 400 + planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR: + conn.close() + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return jsonify( + { + "ok": False, + "msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1", + } + ), 400 + try: + replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (stop_loss, take_profit, order_id), + ) + conn.commit() + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, direction) + conn.close() + return jsonify( + { + "ok": True, + "msg": "已先撤后挂止盈止损", + "stop_loss": stop_loss, + "take_profit": take_profit, + "planned_rr": planned_rr, + "exchange_tpsl": slots, + } + ) + + +@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 <= KEY_DAILY_VOLUME_RANK_MAX), + "rank_max": KEY_DAILY_VOLUME_RANK_MAX, + } + ) + + +@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() + last_price = get_price(symbol) + return jsonify({ + "ok": True, + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, + "last_price": round(float(last_price), 8) if last_price 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, FUNDS_DECIMALS) if trading_capital_live is not None else round(local_current_capital, FUNDS_DECIMALS) + 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, FUNDS_DECIMALS) if trading_capital_live is not None else round(local_current_capital, FUNDS_DECIMALS) + 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), 2) 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"), + "trigger_price_display": format_price_for_symbol(exchange_symbol, order_item.get("trigger_price")), + "stop_loss_display": format_price_for_symbol(exchange_symbol, order_item.get("stop_loss")), + "take_profit_display": format_price_for_symbol(exchange_symbol, 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, + "current_price_display": format_price_for_symbol(exchange_symbol, current_price) if current_price else None, + "float_pnl": round(float(float_pnl), FUNDS_DECIMALS), + "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, + "upper_display": format_price_for_symbol(exchange_symbol, upper) if upper is not None else None, + "lower_display": format_price_for_symbol(exchange_symbol, lower) if lower is not None else None, + "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, + "current_price_display": format_price_for_symbol(exchange_symbol, current_price) 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("/key_monitor") + direction_sel = (d.get("direction") or "").strip().lower() + if direction_sel not in ("long", "short"): + flash("请选择做多或做空") + return redirect("/key_monitor") + mt = (d.get("type") or "").strip() + allowed_types = ( + tuple(KEY_MONITOR_AUTO_TYPES) + + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + + tuple(FIB_KEY_MONITOR_TYPES) + ) + if mt not in allowed_types: + flash("监控类型无效") + return redirect("/key_monitor") + rank, total = _daily_volume_rank(symbol) + if rank is None: + flash("日成交量排名读取失败,请稍后重试") + return redirect("/key_monitor") + if rank > KEY_DAILY_VOLUME_RANK_MAX: + flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") + return redirect("/key_monitor") + conn = get_db() + if mt in KEY_MONITOR_AUTO_TYPES: + occupied = get_active_position_count(conn) + if occupied >= MAX_ACTIVE_POSITIONS: + conn.close() + flash( + f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" + "请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。" + ) + return redirect("/key_monitor") + ex_sym_key = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + uh = round_price_to_exchange(ex_sym_key, float(d["upper"])) + lw = round_price_to_exchange(ex_sym_key, float(d["lower"])) + upper_px = float(uh) if uh is not None else float(d["upper"]) + lower_px = float(lw) if lw is not None else float(d["lower"]) + be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) + if is_fib_key_monitor_type(mt): + ok_fib, err_fib = _add_fib_key_monitor( + conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag, + ) + conn.commit() + conn.close() + if not ok_fib: + flash(err_fib or "斐波监控添加失败") + return redirect("/key_monitor") + flash( + f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" + f"|移动保本:{'开' if be_flag else '关'}" + ) + return redirect("/key_monitor") + sl_tp_mode = "standard" + manual_tp = None + if mt in KEY_MONITOR_AUTO_TYPES: + sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode")) + if sl_tp_mode == "trend_manual": + try: + manual_tp = float(d.get("manual_take_profit") or 0) + except (TypeError, ValueError): + manual_tp = 0 + if manual_tp <= 0: + conn.close() + flash("趋势单方案须填写有效止盈价") + return redirect("/key_monitor") + if direction_sel == "long" and manual_tp <= upper_px: + conn.close() + flash("做多趋势单:止盈价应高于上沿(阻力)") + return redirect("/key_monitor") + if direction_sel == "short" and manual_tp >= lower_px: + conn.close() + flash("做空趋势单:止盈价应低于下沿(支撑)") + return redirect("/key_monitor") + mtpx = round_price_to_exchange(ex_sym_key, manual_tp) + if mtpx is not None: + manual_tp = float(mtpx) + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag), + ) + conn.commit() + conn.close() + ctr = False + try: + coin4h_status, _, _ = _status_by_ema55(symbol, "4h") + ctr = (direction_sel == "long" and coin4h_status == "空头") or ( + direction_sel == "short" and coin4h_status == "多头" + ) + except Exception: + pass + extra = "" + if mt in KEY_MONITOR_AUTO_TYPES: + extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") + if ctr: + flash( + "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" + ) + return redirect("/key_monitor") + +@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("/trade") + 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("/trade") + planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR: + conn.close() + rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算" + flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1") + return redirect("/trade") + 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, FUNDS_DECIMALS) + notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) + margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) + 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), FUNDS_DECIMALS) + if margin_capital > max_margin: + conn.close() + flash(f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}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, monitor_type) 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, + ORDER_MONITOR_TYPE_MANUAL, + ) + ) + conn.commit() + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + conn.close() + + chart_name = None + chart_url = None + if make_order_chart and ORDER_CHART_ENABLED: + try: + title_prefix = f"{symbol} {direction} #{new_order_id}" + chart_name = generate_order_open_chart(exchange_symbol, title_prefix) + if chart_name: + chart_url = f"/static/images/order_charts/{chart_name}" + except Exception: + chart_name = None + chart_url = None + + if chart_name: + try: + journal_id = f"order_{new_order_id}" + coin = journal_coin_from_symbol(symbol) + open_local = (opened_at_bj or "")[:16].replace(" ", "T") + if len(open_local) < 16: + open_local = app_now().strftime("%Y-%m-%dT%H:%M") + close_local = open_local + hold_duration = calc_duration_text(open_local, close_local) + note = ( + f"auto_from_open_order id={new_order_id} oid={open_order_id} " + f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" + ) + conn = get_db() + conn.execute( + """INSERT OR REPLACE INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + journal_id, + open_local, + close_local, + hold_duration, + coin, + "multi", + "0", + "auto:open", + "待平仓", + "", + "", + "否", + "", + "", + "", + None, + None, + None, + "", + "否", + "否", + note, + chart_name, + ), + ) + conn.commit() + conn.close() + except Exception: + try: + conn.close() + except Exception: + pass + + _, trading_capital_after = get_exchange_capitals(force=True) + account_base_display = ( + round(float(trading_capital_after), FUNDS_DECIMALS) + if trading_capital_after is not None + else round(float(capital_base), FUNDS_DECIMALS) + ) + account_name = (os.getenv("BINANCE_ACCOUNT_LABEL") or "binance实盘账户").strip() + dir_text = "多头(long)" if direction == "long" else "空头(short)" + order_state_text = ( + "已在交易所挂条件委托(止盈、止损各一张触发单)" + if tpsl_attached + else "条件委托未挂上(已拦截)" + ) + rr_show = planned_rr if planned_rr is not None else "-" + try: + rr_show_fmt = round(float(planned_rr), 4) if planned_rr is not None else None + except (TypeError, ValueError): + rr_show_fmt = None + rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1" + ep_wx = format_price_for_symbol(symbol, trigger_price) + sl_wx = format_price_for_symbol(symbol, stop_loss) + tp_wx = format_price_for_symbol(symbol, take_profit) + be_wx = format_price_for_symbol(symbol, breakeven_price) + style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" + wx_lines = [ + f"📈 {symbol} 开仓成功", + f"💼 交易类型:{dir_text}", + "🧾 订单基础信息", + f"🔖 交易所订单 ID:{open_order_id}", + f"📈 交易风格:{style_zh}", + f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), FUNDS_DECIMALS)} U", + "📊 仓位配置详情", + f"账户基数:{account_base_display} USDT", + f"合约杠杆:{leverage} 倍", + f"名义仓位:{notional_value} USDT", + f"仓位占比:{position_ratio}%", + f"合约数量:{amount}", + f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", + "🎯 价位 & 盈亏比", + f"开仓成交价:{ep_wx}", + f"止损价位:{sl_wx}", + f"止盈价位:{tp_wx}", + f"计划盈亏比:{rr_line}", + f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", + "📌 状态统计", + f"✅ 条件委托:{order_state_text}", + f"📅 当日开仓次数:{opens_today_after} / {DAILY_OPEN_ALERT_THRESHOLD} 次(风控阈值提醒)", + ] + if chart_url: + wx_lines.append(f"多周期K线图:{chart_url}") + send_wechat_msg("\n".join(wx_lines)) + + flash_lines = [ + f"实盘开单成功:风格 {trade_style};风险 {risk_percent}%≈{risk_amount_final}U;基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约数量 {amount}(折算标的 {base_amount})," + f"计划RR {planned_rr if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + f"本交易日累计开仓:{opens_today_after}", + ] + if chart_url: + flash_lines.append(f"已生成多周期K线图:{chart_url}") + flash(" ".join(flash_lines)) + + if opens_today_before < DAILY_OPEN_ALERT_THRESHOLD <= opens_today_after: + advice = ai_short_advice( + f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_today_after} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") + return redirect("/") + +@app.route("/delete_key_monitor/", methods=["POST"]) +@login_required +def delete_key_monitor(kid): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "error": "not_found"}) + if is_fib_key_monitor_type(row["monitor_type"]): + _cancel_fib_monitor_limit(row) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/delete_key_history/", methods=["POST"]) +@login_required +def delete_key_history(hid): + conn = get_db() + cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/del_key/") +@login_required +def del_key(id): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() + if row: + if is_fib_key_monitor_type(row["monitor_type"]): + _cancel_fib_monitor_limit(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,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," + "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," + "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason," + "exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at " + "FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", + "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", + "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", + "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", + "exchange_realized_pnl", "exchange_opened_at", "exchange_closed_at", "created_at", "开仓类型", + ] + 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 "" + kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" + eff = er1 or er0 or entry_reason_from_key_signal(kst) or "" + snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] + data.append(( + r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], + snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], + r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], + r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], + r["exchange_realized_pnl"] if "exchange_realized_pnl" in r.keys() else None, + r["exchange_opened_at"] if "exchange_opened_at" in r.keys() else None, + r["exchange_closed_at"] if "exchange_closed_at" in r.keys() else None, + r["created_at"], eff, + )) + day = app_now().strftime("%Y%m%d") + return _csv_response(f"trade_records_v3_{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(): + 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,upper,lower,notification_count,last_alert_message,close_reason,closed_at " + "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_alert_message", + "close_reason", + "closed_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) + +@app.route("/del_order/") +@login_required +def del_order(id): + conn = get_db() + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() + if not row: + conn.close() + flash("订单不存在") + return redirect("/") + if row["status"] == "active": + try: + 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 + ) + close_resp = close_exchange_order(row) + close_order_id = close_resp.get("id", "") + cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + exit_p = extract_trade_price_from_order(close_resp) + closed_at = app_now_str() + closed_at_ms = None + if not exit_p or float(exit_p) <= 0: + tr_fill = fetch_latest_closing_fill( + row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]), + row["direction"], + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr_fill and tr_fill.get("price"): + try: + exit_p = float(tr_fill["price"]) + except (TypeError, ValueError): + exit_p = None + ts = tr_fill.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + closed_at_ms = int(ts) + else: + tr_fill = fetch_latest_closing_fill( + row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]), + row["direction"], + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr_fill and tr_fill.get("timestamp"): + closed_at = ms_to_app_local_str(int(tr_fill["timestamp"])) + closed_at_ms = int(tr_fill["timestamp"]) + pnl_amount, exit_p, _, _, _ = resolve_trade_pnl_amount( + row, + row["trigger_price"], + exit_p, + opened_at_str=opened_at, + opened_at_ms=opened_at_ms, + closed_at_str=closed_at, + closed_at_ms=closed_at_ms, + ) + p = exit_p or get_price(row["symbol"]) or float(row["trigger_price"]) + 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) + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type=order_row_monitor_type(row), + key_signal_type=order_row_key_signal_type(row), + 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)) + clear_key_sizing_snapshot_if_flat(conn, session_date) + 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("/trade") + except Exception as e: + if is_no_position_error(str(e)): + cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + opened_at = get_opened_at_value(row) + opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) + miss_reason = f"手动删除时无持仓:{miss_reason}" + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = row["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type=order_row_monitor_type(row), + key_signal_type=order_row_key_signal_type(row), + 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]}" + close_ms = _local_input_datetime_to_ms(d.get("close_datetime")) + marker_payload = { + "exit_ts_ms": close_ms, + "entry_ts_ms": close_ms, + "entry_price": d.get("entry_price_hint"), + "exit_price": None, + } + try: + chart_fname = f"journal_{entry_id}.png" + saved = generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=ORDER_CHART_TFS, + limit=ORDER_CHART_LIMIT, + out_dir=app.config["UPLOAD_FOLDER"], + filename=chart_fname, + filename_prefix="journal", + marker_payload=marker_payload, + marker_timeframes=( + {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()} + if ORDER_CHART_TFS + else {"5m", "15m", "1h", "4h"} + ), + ) + if saved: + image_filename = saved + chart_msg = f"已生成多周期K线图:/static/images/{saved}" + if uploaded_tmp: + try: + old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) + if os.path.exists(old_path): + os.remove(old_path) + except Exception: + pass + else: + chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Binance 网络/代理是否正常。" + except Exception as e: + image_filename = uploaded_tmp + chart_msg = f"自动生成K线图失败:{str(e)}" + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, d.get("open_datetime"), d.get("close_datetime"), hold_duration, d.get("coin"), d.get("tf"), + d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, + early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, + None, None, None, mood_issues, + d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename + ) + ) + conn.commit() + conn.close() + if chart_msg: + flash(f"交易复盘记录已保存。{chart_msg}") + else: + flash("交易复盘记录已保存") + return _redirect_records() + + +@app.route("/api/journals") +@login_required +def api_journals(): + 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 journal_entries WHERE COALESCE(close_datetime, created_at, open_datetime) >= ? " + "AND COALESCE(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/", 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/") +@login_required +def export_review_md(rid): + conn = get_db() + row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() + conn.close() + if not row: + return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") + + review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" + target_date = row["target_date"] or "-" + created_at = row["created_at"] or app_now_str() + content = (row["content"] or "").strip() + if not content: + content = "(无内容)" + + md = ( + f"# {review_type}报告\n\n" + f"- 目标日期: {target_date}\n" + f"- 生成时间: {created_at}\n" + f"- 报告ID: {row['id']}\n\n" + f"---\n\n" + f"{content}\n" + ) + + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + safe_type = "daily" if row["review_type"] == "daily" else "weekly" + filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" + return _md_response(filename, md) + + +@app.route("/export/reviews_md_bundle") +@login_required +def export_reviews_md_bundle(): + review_type = (request.args.get("review_type") or "").strip().lower() + target_date = (request.args.get("target_date") or "").strip() + if review_type not in ("daily", "weekly"): + return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") + if not target_date: + return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") + + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", + (review_type, target_date), + ).fetchall() + conn.close() + if not rows: + return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") + + title = "日复盘" if review_type == "daily" else "周复盘" + lines = [ + f"# {title}汇总报告", + "", + f"- 目标日期: {target_date}", + f"- 条目数量: {len(rows)}", + f"- 导出时间: {app_now_str()}", + "", + "---", + "", + ] + for idx, row in enumerate(rows, 1): + created_at = row["created_at"] or "-" + content = (row["content"] or "").strip() or "(无内容)" + lines.extend( + [ + f"## 第{idx}条", + "", + f"- 报告ID: {row['id']}", + f"- 生成时间: {created_at}", + "", + content, + "", + "---", + "", + ] + ) + md = "\n".join(lines) + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" + return _md_response(filename, md) + + +@app.route("/delete_review/", methods=["POST"]) +@login_required +def delete_review(rid): + conn = get_db() + conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/delete_trade_record/", methods=["POST"]) +@login_required +def delete_trade_record(rid): + conn = get_db() + cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) + + +@app.route("/api/trade_record_review_update", methods=["POST"]) +@login_required +def api_trade_record_review_update(): + payload = request.get_json(silent=True) or {} + rec_id = payload.get("id") + try: + rec_id = int(rec_id) + except Exception: + return jsonify({"ok": False, "msg": "记录ID无效"}), 400 + + reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() + reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() + reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") + reviewed_take_profit_raw = payload.get("reviewed_take_profit") + reviewed_result = str(payload.get("reviewed_result") or "").strip() + reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() + reviewed_pnl_raw = payload.get("reviewed_pnl_amount") + + if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: + return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400 + + try: + reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") + reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") + except Exception: + return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 + if reviewed_close_dt < reviewed_open_dt: + return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 + hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) + hold_minutes = calc_hold_minutes(hold_seconds) + + try: + reviewed_pnl_amount = float(reviewed_pnl_raw) + except Exception: + return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 + reviewed_stop_loss = None + if reviewed_stop_loss_raw not in (None, ""): + try: + reviewed_stop_loss = float(reviewed_stop_loss_raw) + except Exception: + return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 + reviewed_take_profit = None + if reviewed_take_profit_raw not in (None, ""): + try: + reviewed_take_profit = float(reviewed_take_profit_raw) + except Exception: + return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 + + _MISSING_ER = object() + reviewed_entry_reason_update = _MISSING_ER + if "reviewed_entry_reason" in payload: + s = str(payload.get("reviewed_entry_reason") or "").strip() + if s and not entry_reason_valid_for_storage(s): + return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400 + reviewed_entry_reason_update = s or None + + conn = get_db() + row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "记录不存在"}), 404 + risk_amount = row["risk_amount"] + actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) + base_params = [ + reviewed_opened_at, + reviewed_closed_at, + reviewed_stop_loss, + reviewed_take_profit, + round(reviewed_pnl_amount, FUNDS_DECIMALS), + reviewed_result or None, + reviewed_miss_reason or None, + hold_seconds, + hold_minutes, + app_now_str(), + actual_rr, + ] + if reviewed_entry_reason_update is not _MISSING_ER: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? + WHERE id=?""", + tuple(base_params + [reviewed_entry_reason_update, rec_id]), + ) + else: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr) + WHERE id=?""", + tuple(base_params + [rec_id]), + ) + conn.commit() + conn.close() + return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) + + +@app.route("/manual_transfer", methods=["POST"]) +@login_required +def manual_transfer(): + try: + amount = float(request.form.get("amount", "0")) + except Exception: + flash("划转金额格式错误") + return redirect("/") + from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() + to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() + ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) + conn = get_db() + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + else: + flash(f"手动划转失败:{msg}") + return redirect("/") + + +@app.route("/ai_daily_review", methods=["POST"]) +@login_required +def ai_daily_review(): + date = request.form.get("date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", + (date,) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该日无交易记录"}) + + text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "每日", image_paths=image_paths) + full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "daily", date, full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + + +@app.route("/ai_weekly_review", methods=["POST"]) +@login_required +def ai_weekly_review(): + start_date = request.form.get("start_date", "") + end_date = request.form.get("end_date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", + (start_date, end_date) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该时间段无交易记录"}) + + text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "周度", image_paths=image_paths) + full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + +# 启动 +if __name__ == "__main__": + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 6f88e2a..d62c484 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -1,7599 +1,7625 @@ -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 fib_key_monitor_lib import ( - FIB_KEY_MONITOR_TYPES, - KEY_ENTRY_REASON_BY_SIGNAL, - calc_fib_plan, - entry_reason_from_key_signal, - fib_invalidate_by_mark, - fib_ratio_from_type, - is_fib_key_monitor_type, - key_signal_type_for_trade_record, - stored_key_signal_type, -) -from key_sl_tp_lib import ( - breakeven_enabled_from_row, - normalize_sl_tp_mode, - parse_breakeven_enabled_form, - plan_key_sl_tp, - sl_tp_mode_from_row, - sl_tp_mode_label, - sl_tp_plan_summary_text, -) -from history_window_lib import ( - PRESET_CUSTOM, - PRESET_UTC_LAST24H, - PRESET_UTC_LAST7D, - PRESET_UTC_TODAY, - resolve_window, - 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") - -# ====================== 登录配置 ====================== -USERNAME = os.getenv("APP_USERNAME", "dekun") -PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") -AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") - -# 企业微信机器人Webhook -WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") -SYSTEM_TYPE = "CRYPTO" -HOST = os.getenv("APP_HOST", "0.0.0.0") -PORT = int(os.getenv("APP_PORT", "5000")) -DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" -DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) - -# 训练参数(可由 .env 覆盖) -DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) -DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) -DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) -BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) -ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) -# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) -TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) -# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) -TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( - "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" -).lower() in ("1", "true", "yes", "on") -APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") - - -def _resolve_app_tz(): - if ZoneInfo is not None: - try: - return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) - except Exception: - pass - return timezone(timedelta(hours=8)) - - -APP_TZ = _resolve_app_tz() -LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" -GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip() -GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip() -GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower() -GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower() -# 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平) -GATE_TPSL_TRIGGER_EXPIRATION = int(os.getenv("GATE_TPSL_TRIGGER_EXPIRATION", str(7 * 86400))) -GATE_TPSL_PRICE_TYPE = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0")) -if GATE_TPSL_PRICE_TYPE < 0 or GATE_TPSL_PRICE_TYPE > 2: - GATE_TPSL_PRICE_TYPE = 0 -GATE_TPSL_USE_POSITION_ORDER = os.getenv("GATE_TPSL_USE_POSITION_ORDER", "true").lower() in ("1", "true", "yes") -# 页面展示的交易所名称(多实例/多环境时可按需区分) -EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Gate.io").strip() or "Gate.io" -_GATE_DEFAULT_MARGIN_MODE = "cross" if GATE_TD_MODE in ("cross", "cross_margin") else "isolated" -BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) -PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) -KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) -KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) -KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) -KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5")) -KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1")) -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"))) -KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20"))) -KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3")) -KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03")) -KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5")) -KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))) -KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2")) -KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1")) -KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true" -ORDER_MONITOR_TYPE_MANUAL = "下单监控" -ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" -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_EXCHANGE_PNL_SYNC_AT = 0.0 - -KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) -KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) -AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" -AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) -AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") -AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") -FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" -FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) -# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 -AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) -WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) -AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) -MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) -KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") -FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) -TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT") -UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) -ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" -ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] -ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) -ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) -DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5")) -RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) -BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) -BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) -BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) -DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() -OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate") -AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") - -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), 2)}U" - if fallback is not None: - try: - return f"{round(float(fallback), 2)}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_wechat_scalar_2dp(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, 2)} 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_wechat_scalar_2dp(new_sl) - return "\n".join( - [ - f"# 🛡️ {symbol} 保护位更新", - f"**账户:{_wechat_account_label()}**", - "", - "---", - "", - "### 移动保本/止盈", - f"- 方向:**{_wechat_direction_text(direction)}**", - f"- 类型:**{arm_txt}**", - f"- 当前RR:`{round(float(now_rr), 2)}R`", - f"- 锁定RR:`{round(float(locked_r), 2)}R`", - f"- 新保护位:`{sl_fmt}`", - ] - ) - - -def build_wechat_monitor_error_message(symbol, direction, scene, error_text): - return "\n".join( - [ - f"# ⚠️ {symbol} 下单监控异常", - f"**账户:{_wechat_account_label()}**", - "", - "---", - "", - "### 异常信息", - f"- 方向:**{_wechat_direction_text(direction)}**", - f"- 场景:{scene}", - f"- 错误:{str(error_text)}", - ] - ) - - -def build_wechat_key_monitor_message( - symbol, - direction, - monitor_type, - trigger_time, - key_price, - confirm_close, - hard_lines, - btc8h_status, - coin4h_status, - swing4h_pct, - op_lines, - risk_tip=None, -): - lines = [ - f"# 🎯 {symbol} 关键位确认推送", - f"**账户:{_wechat_account_label()}**", - "", - "---", - "", - "### 交易对 / 触发时间", - f"- 交易对:**{symbol}**", - f"- 触发时间:`{trigger_time}`", - "", - "### 方向与确认K", - f"- 方向:**{_wechat_direction_text(direction)}**", - "- 确认K:第二根5m收盘完成", - "", - "### 关键价位", - f"- 类型:**{monitor_type}**", - f"- 箱体关键位:`{key_price}`", - f"- 第二根确认收盘价:`{confirm_close}`", - "", - "### 硬条件校验结果", - ] - lines.extend([f"- {x}" for x in hard_lines]) - lines.extend( - [ - "", - "### 市场状态说明", - f"- BTC 8h 状态:**{btc8h_status}**", - f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", - f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", - "", - "### 操作提示", - ] - ) - lines.extend([f"- {x}" for x in op_lines]) - if risk_tip: - lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) - return "\n".join(lines) - - -def _read_image_base64(image_path): - try: - with open(image_path, "rb") as f: - return base64.b64encode(f.read()).decode("utf-8") - except Exception: - return None - - -def _extract_json_object(text): - if not text: - return None - clean = text.strip() - if clean.startswith("```"): - clean = clean.replace("```json", "").replace("```", "").strip() - try: - return json.loads(clean) - except Exception: - pass - match = re.search(r"\{[\s\S]*\}", clean) - if not match: - return None - try: - return json.loads(match.group(0)) - except Exception: - return None - - -def _journal_row_lines_for_ai(idx, row, *, include_hold_duration=True): - """把 journal 字段拼成给 AI 的文本;字段之外的事实不要指望模型自己猜。""" - def nz(v, default="无"): - if v is None: - return default - s = str(v).strip() - return s if s else default - - lines = [ - f"{idx}. {nz(row['coin'])} {nz(row['tf'])} | 盈亏:{nz(row['pnl'])}U | 实际RR:{nz(row['real_rr'])} | 预期RR:{nz(row['expect_rr'])}", - f" 开仓逻辑:{nz(row['entry_reason'])}", - f" 平仓/离场(交易员自述):{nz(row['exit_reason'])}", - ] - if include_hold_duration: - lines.append(f" 持仓时长:{nz(row['hold_duration'])}") - ee_bits = [ - nz(row["early_exit"]), - nz(row["early_exit_reason"]), - nz(row["early_exit_trigger"]), - nz(row["early_exit_note"]), - ] - if any(x != "无" for x in ee_bits): - lines.append( - " 提前离场记录:" - f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}" - ) - mood_bits = f"心态标签:{nz(row['mood_issues'])}" - if row["mood_score"] is not None: - mood_bits += f" | 自评心态分:{row['mood_score']}" - lines.append(f" {mood_bits}") - if nz(row["post_breakeven_stare"]) != "无": - lines.append(f" 保本后盯盘:{nz(row['post_breakeven_stare'])}") - if nz(row["new_trade_while_occupied"]) != "无": - lines.append(f" 占用时新开仓:{nz(row['new_trade_while_occupied'])}") - if nz(row["note"]) != "无": - lines.append(f" 备注:{nz(row['note'])}") - return "\n".join(lines) + "\n" - - -def ai_review(trades_text, period_title, image_paths=None): - prompt = f""" -你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。 - -【硬性规则 — 必须遵守】 -- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。 -- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。 -- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。 -- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。 -- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。 -- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。 - -【输出结构】 -1. 总体盈亏结构(紧扣笔数、盈亏数字与 RR,少形容词) -2. 心态与执行(每笔 1–10 分 + 一句依据;依据必须对应记录字段) -3. 行为标签(提前离场 / 乱开仓 / 扛单等):仅在有字段或自述支撑时点名;否则写「记录未勾选或未描述,不作强加」 -4. 改进建议(最多 3 条,每条具体可执行) -5. 图表(若有且可读):结合价格行为简述;否则一两句说明无法看图分析 - -交易记录: -{trades_text} -""".strip() - payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} - images = [] - for p in image_paths or []: - b64 = _read_image_base64(p) - if b64: - images.append(b64) - if images: - payload["images"] = images - try: - r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) - return r.json().get("response", "AI 生成失败") - except Exception as e: - return f"AI 调用失败:{str(e)}" - - -def ai_short_advice(prompt_text): - prompt = f""" -你是交易风控助理。请用中文给出**最多 3 条**提醒,要求: -- 每条不超过 25 个字 -- 语气克制、具体、可执行 -- 不要输出 Markdown,不要编号前缀以外的废话 - -场景: -{prompt_text} -""".strip() - payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} - try: - r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) - return (r.json().get("response") or "").strip() - except Exception: - return "" - - -def _load_font(size): - if not ImageFont: - return None - candidates = [ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", - "C:\\Windows\\Fonts\\msyh.ttc", - "C:\\Windows\\Fonts\\arial.ttf", - ] - for path in candidates: - if path and os.path.exists(path): - try: - return ImageFont.truetype(path, size) - except Exception: - continue - try: - return ImageFont.load_default() - except Exception: - return None - - -def _ohlcv_to_rows(ohlcv): - rows = [] - for bar in ohlcv or []: - if not bar or len(bar) < 6: - continue - try: - rows.append( - { - "ts": int(bar[0]), - "o": float(bar[1]), - "h": float(bar[2]), - "l": float(bar[3]), - "c": float(bar[4]), - "v": float(bar[5]), - } - ) - except Exception: - continue - return rows - - -def _local_input_datetime_to_ms(dt_text): - raw = str(dt_text or "").strip() - if not raw: - return None - raw = raw.replace("T", " ") - for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): - try: - dt = datetime.strptime(raw, fmt) - aware = dt.replace(tzinfo=APP_TZ) - return int(aware.timestamp() * 1000) - except Exception: - continue - return None - - -def _pick_marker_point(rows, target_ts_ms, target_price=None): - if not rows or target_ts_ms is None: - return None, None - idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) - if target_price is not None: - try: - p = float(target_price) - if p > 0: - return idx, p - except Exception: - pass - return idx, float(rows[idx]["c"]) - - -def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): - if not Image or not ImageDraw: - raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") - img = Image.new("RGB", (width, height), bg_rgb) - draw = ImageDraw.Draw(img) - font = _load_font(14) - small = _load_font(12) - - pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 - plot_w = max(10, width - pad_l - pad_r) - plot_h = max(10, height - pad_t - pad_b) - - header_bg = (245, 247, 250) - draw.rectangle((0, 0, width, pad_t), fill=header_bg) - if font: - draw.text((10, 6), title, fill=(25, 35, 60), font=font) - else: - draw.text((10, 6), title, fill=(25, 35, 60)) - - if not rows: - if small: - draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) - else: - draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) - return img - - lo = min(r["l"] for r in rows) - hi = max(r["h"] for r in rows) - if hi <= lo: - hi = lo + 1e-12 - - n = len(rows) - marker_by_idx = {} - for mp in marker_points or []: - try: - idx = int(mp.get("idx")) - except Exception: - continue - if idx < 0 or idx >= n: - continue - marker_by_idx[idx] = mp - - x0 = pad_l - for i, r in enumerate(rows): - x1 = pad_l + int((i + 1) * plot_w / n) - x_mid = (x0 + x1) // 2 - wick_x = x_mid - y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) - y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) - y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) - y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) - top = min(y_open, y_close) - bot = max(y_open, y_close) - up = r["c"] >= r["o"] - wick_color = (120, 120, 120) - edge_color = (20, 20, 20) - draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) - body_w = max(1, (x1 - x0) - 2) - left = x0 + 1 - if bot - top < 2: - mid = (top + bot) // 2 - draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) - else: - if up: - draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) - else: - draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) - if i in marker_by_idx: - mp = marker_by_idx[i] - tag = str(mp.get("tag") or "") - m_price = float(mp.get("price") or r["c"]) - y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) - y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) - if tag == "ENTRY": - m_color = (0, 195, 95) - tri = [(x_mid, y_m - 20), (x_mid - 9, y_m - 4), (x_mid + 9, y_m - 4)] - text_y = y_m - 36 - else: - m_color = (235, 65, 65) - tri = [(x_mid, y_m + 20), (x_mid - 9, y_m + 4), (x_mid + 9, y_m + 4)] - text_y = y_m + 12 - draw.ellipse((x_mid - 5, y_m - 5, x_mid + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) - draw.polygon(tri, fill=m_color) - draw.line((x_mid, y_m, x_mid, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) - if font: - draw.text((x_mid + 8, text_y), tag, fill=m_color, font=font) - else: - draw.text((x_mid + 8, text_y), tag, fill=m_color) - x0 = x1 - - if len(marker_points or []) >= 2: - try: - entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) - exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) - if entry is not None and exitp is not None: - ex_i, ex_p = int(entry["idx"]), float(entry["price"]) - xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) - x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) - x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) - y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) - y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) - draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) - except Exception: - pass - - # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 - if small: - draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) - return img - - -def _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 _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)) - if not end_ts_ms: - return exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim) - period = _timeframe_period_ms(timeframe) - since = int(end_ts_ms) - period * (lim + 5) - ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 10) - rows = _ohlcv_to_rows(ohlcv) - filtered = [r for r in rows if int(r[0]) <= int(end_ts_ms)] - if len(filtered) >= lim: - return [[r[0], r[1], r[2], r[3], r[4]] for r in filtered[-lim:]] - return ohlcv[-lim:] if ohlcv else [] - - -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 - for tf in timeframes: - try: - ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) - except Exception: - ohlcv = [] - rows = _ohlcv_to_rows(ohlcv)[-limit:] - title = f"{title_prefix} | {tf} x{len(rows)}" - points = [] - tf_key = str(tf).strip().lower() - marker_tfs = {str(x).strip().lower() for x in (marker_timeframes or []) if str(x).strip()} - if marker_payload and tf_key in marker_tfs: - entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")) - exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")) - if entry_idx is not None and entry_price is not None: - points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"}) - if exit_idx is not None and exit_price is not None: - points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"}) - panels.append( - _render_candles_subplot( - rows, - title, - width=cell_w, - height=cell_h, - bg_rgb=(255, 255, 255), - marker_points=points, - ) - ) - - if not panels: - return None - - gap = 10 - cols = 2 - rows_n = int(math.ceil(len(panels) / cols)) - w = cols * cell_w + (cols - 1) * gap - h = rows_n * cell_h + (rows_n - 1) * gap - out = Image.new("RGB", (w, h), (255, 255, 255)) - idx = 0 - for r in range(rows_n): - for c in range(cols): - if idx >= len(panels): - break - x = c * (cell_w + gap) - y = r * (cell_h + gap) - out.paste(panels[idx], (x, y)) - idx += 1 - - # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图) - if ImageDraw and rows_n >= 1: - draw_out = ImageDraw.Draw(out) - line_col = (220, 225, 232) - x_mid = cell_w + gap // 2 - if w > x_mid >= 0: - draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2) - for rr in range(1, rows_n): - y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 - if 0 <= y_mid <= h: - draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) - - target_dir = out_dir or ORDER_CHART_DIR - os.makedirs(target_dir, exist_ok=True) - fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" - out_path = os.path.join(target_dir, fname) - out.save(out_path, format="PNG") - return fname - - -def generate_order_open_chart(exchange_symbol, title_prefix, timeframes=None, limit=None): - return generate_multi_timeframe_chart_png( - exchange_symbol, - title_prefix, - timeframes=timeframes, - limit=limit, - out_dir=ORDER_CHART_DIR, - filename=None, - filename_prefix="order", - ) - - -def journal_coin_from_symbol(symbol): - sym = (symbol or "").strip().upper() - if not sym: - return "" - if "/" in sym: - return sym.split("/")[0].strip() - if "-" in sym: - return sym.split("-")[0].strip() - if sym.endswith("USDT"): - return sym[:-4].strip() - return sym - - -EARLY_EXIT_TRIGGERS = ( - "", - "止盈", - "保本止盈", - "移动止盈", - "手动平仓", - "止损", - "其他", -) - -# 与用户约定的固定开仓类型 -ENTRY_REASON_OPTIONS = ( - "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", - "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", - "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", - "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", - "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", - "关键位箱体突破", - "关键位收敛突破", - "关键位斐波0.618", - "关键位斐波0.786", -) - -STATS_SEGMENT_DEFS = ( - ("all", "全部交易", {"segment": "all"}), - ("manual", "下单监控", {"segment": "manual"}), - ("key_box", "关键位箱体突破", {"segment": "key_box"}), - ("key_conv", "关键位收敛结构", {"segment": "key_conv"}), - ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), - ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), -) -# 复盘表单「其他」选项的 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, - exchange_margin_usdt REAL, - 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("ALTER TABLE order_monitors ADD COLUMN exchange_margin_usdt REAL") - except Exception: - pass - try: - c.execute(f"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '{ORDER_MONITOR_TYPE_MANUAL}'") - except Exception: - pass - try: - c.execute( - "UPDATE order_monitors SET monitor_type=? WHERE monitor_type IS NULL OR TRIM(monitor_type)=''", - (ORDER_MONITOR_TYPE_MANUAL,), - ) - except Exception: - pass - try: - c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") - except: pass - try: - c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") - except: pass - try: - c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") - except: pass - try: - c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") - except: pass - try: - c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") - except: pass - for ddl in ( - "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", - "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", - "ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'", - "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", - "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", - ): - try: - c.execute(ddl) - except Exception: - pass - try: - c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") - except Exception: - pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") - except Exception: - pass - for ddl in ( - "ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT", - "ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL", - "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT", - "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT", - "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT", - ): - try: - c.execute(ddl) - except Exception: - pass - - c.execute( - """CREATE TABLE IF NOT EXISTS key_monitor_history - (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, - upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, - close_reason TEXT, closed_at TEXT)""" - ) - - conn.commit() - conn.close() - -init_db() - -def get_db(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn - - -def app_now(): - """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" - return datetime.now(APP_TZ).replace(tzinfo=None) - - -def app_now_str(): - return app_now().strftime("%Y-%m-%d %H:%M:%S") - - -def utc_now_dt(): - """当前时刻(UTC,aware)。""" - return datetime.now(timezone.utc) - - -def utc_calendar_date_str(): - """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" - return utc_now_dt().strftime("%Y-%m-%d") - - -def get_trading_day(now=None): - """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" - now = now or app_now() - if getattr(now, "tzinfo", None): - now = now.astimezone(APP_TZ).replace(tzinfo=None) - if now.hour < TRADING_DAY_RESET_HOUR: - return (now - timedelta(days=1)).strftime("%Y-%m-%d") - return now.strftime("%Y-%m-%d") - - -TRADE_COMPLETED_RESULTS = ( - "止盈", - "止损", - "保本止盈", - "移动止盈", - "手动平仓", - "强制清仓", - "外部平仓", -) - -REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓") - - -def parse_dt_for_trading_day(s): - if not s: - return None - s = str(s).strip().replace("Z", "").replace("T", " ") - if not s: - return None - for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): - try: - return datetime.strptime(s[:ln], fmt) - except ValueError: - continue - return None - - -def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): - conn.execute( - """INSERT INTO key_monitor_history - (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) - VALUES (?,?,?,?,?,?,?,?,?)""", - ( - row["symbol"], - row["monitor_type"], - row["direction"] or "long", - row["upper"], - row["lower"], - int(notification_count or 0), - (last_msg or "")[:800] if last_msg else None, - close_reason, - app_now_str(), - ), - ) - - -def _session_week_bounds(trading_day_str): - end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() - start = end - timedelta(days=6) - return start.strftime("%Y-%m-%d"), trading_day_str - - -def _calendar_month_bounds(local_dt): - y, m = local_dt.year, local_dt.month - start = f"{y:04d}-{m:02d}-01" - if m == 12: - end_d = datetime(y, 12, 31).date() - else: - end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() - return start, end_d.strftime("%Y-%m-%d") - - -def _count_opens_between(conn, start_td, end_td): - return conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", - (start_td, end_td), - ).fetchone()[0] - - -def _list_window_from_request(): - return resolve_window(request.args, default_preset=PRESET_UTC_TODAY) - - -def _pnl_row_matches_segment(row, segment_key): - try: - mt = (row["monitor_type"] or "").strip() - kst = (row["key_signal_type"] or "").strip() - except Exception: - return False - if segment_key == "all": - return True - if segment_key == "manual": - return mt == ORDER_MONITOR_TYPE_MANUAL and not kst - if segment_key == "key_box": - return kst == "箱体突破" - if segment_key == "key_conv": - return kst == "收敛突破" - if segment_key == "key_fib618": - return kst == "斐波回调0.618" - if segment_key == "key_fib786": - return kst == "斐波回调0.786" - return False - - -def _count_opens_for_segment(conn, start_td, end_td, segment_key): - if segment_key == "manual": - return conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " - "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " - "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", - (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), - ).fetchone()[0] - kst_map = { - "key_box": "箱体突破", - "key_conv": "收敛突破", - "key_fib618": "斐波回调0.618", - "key_fib786": "斐波回调0.786", - } - kst = kst_map.get(segment_key) - if kst: - return conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?", - (start_td, end_td, kst), - ).fetchone()[0] - return conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", - (start_td, end_td), - ).fetchone()[0] - - -def _load_completed_trade_pnls(conn): - q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, - result, reviewed_result, monitor_type, key_signal_type - FROM trade_records - ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" - rows = conn.execute(q).fetchall() - out = [] - for r in rows: - effective_result = (r["reviewed_result"] or r["result"] or "").strip() - if effective_result not in TRADE_COMPLETED_RESULTS: - continue - try: - p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) - except (TypeError, ValueError): - p = 0.0 - t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) - td = get_trading_day(t) if t else None - out.append((p, t, td, r)) - 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), 2) - loss_sum_raw = sum(p for p, _, _ in trades if p < 0) - loss_sum_u = round(abs(loss_sum_raw), 2) 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), 2) if neg_pnls else None - max_single_profit = round(max(pos_pnls), 2) 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, 2) - 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], 2) - 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): - """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" - now_dt = now_dt or app_now() - pnls = _load_completed_trade_pnls(conn) - total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] - w_start, w_end = _session_week_bounds(trading_day) - m_start, m_end = _calendar_month_bounds(now_dt) - - def in_week(tr): - return tr[2] and w_start <= tr[2] <= w_end - - def in_month(tr): - return tr[2] and m_start <= tr[2] <= m_end - - def slice_metrics(seg_key): - seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)] - day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] - week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] - month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] - dm = _compute_period_metrics(day_tr) - wm = _compute_period_metrics(week_tr) - mm = _compute_period_metrics(month_tr) - dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) - wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) - mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) - dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" - wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" - mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" - return dm, wm, mm - - segments = [] - for seg_key, seg_title, _meta in STATS_SEGMENT_DEFS: - dm, wm, mm = slice_metrics(seg_key) - segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm}) - - dm, wm, mm = slice_metrics("all") - - return { - "trading_day": trading_day, - "total_opens_all": total_opens_all, - "day": dm, - "week": wm, - "month": mm, - "segments": segments, - "stats_reset_hour": TRADING_DAY_RESET_HOUR, - } - - -def infer_leverage(symbol): - sym = (symbol or "").strip().upper() - if sym.startswith("BTC") or sym.startswith("ETH"): - return BTC_LEVERAGE - return ALT_LEVERAGE - - -def normalize_exchange_symbol(symbol): - sym = symbol.strip().upper() - if ":" in sym: - return sym - if "/" in sym: - base, quote = sym.split("/", 1) - quote_clean = quote.split(":")[0] - return f"{base}/{quote_clean}:{quote_clean}" - return sym - - -def resolve_monitor_exchange_symbol(row): - """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" - raw = "" - try: - if row["exchange_symbol"]: - raw = str(row["exchange_symbol"]).strip() - except (KeyError, IndexError, TypeError): - raw = "" - if not raw: - try: - raw = str(row["symbol"] or "").strip() - except (KeyError, IndexError, TypeError): - raw = "" - return normalize_exchange_symbol(raw) if raw else "" - - -def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): - if not position_symbol or not wanted_exchange_symbol: - return False - a = normalize_exchange_symbol(str(position_symbol).strip()) - b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip()) - return a == b - - -def _position_matches_wanted_contract(wanted_unified_sym, position_dict): - """统一 symbol 比对;不一致时用 Gate 原始 contract 与 ccxt market.id 对齐(兼容 1000PEPE 等命名差异)。""" - if not wanted_unified_sym or not position_dict: - return False - ps = position_dict.get("symbol") - if _position_contract_symbol_match(ps, wanted_unified_sym): - return True - try: - ensure_markets_loaded() - mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper() - info = position_dict.get("info") or {} - c_raw = str(info.get("contract") or "").strip().upper() - if mid and c_raw and mid == c_raw: - return True - except Exception: - pass - return False - - -def _position_row_effective_contracts(p): - """张数:优先 ccxt contracts,否则用 Gate 原始 size/pos(避免统一层为 0 时被误判空仓)。""" - if not p: - return 0.0 - info = p.get("info") or {} - for val in (p.get("contracts"), info.get("size"), info.get("pos")): - if val is None or val == "": - continue - try: - x = abs(float(val)) - if x > 0: - return x - except (TypeError, ValueError): - continue - return 0.0 - - -def normalize_symbol_input(symbol): - sym = (symbol or "").strip().upper() - if not sym: - return "" - if "/" in sym: - return sym - if ":" in sym: - sym = sym.split(":")[0] - return f"{sym}/USDT" - - -def normalize_kline_limit(limit_raw, default=200): - try: - n = int(limit_raw) - except Exception: - return default - return 200 if n >= 200 else 100 - - -def get_recommended_capital(current_capital): - if current_capital <= DAILY_LOSS_CAPITAL: - return DAILY_LOSS_CAPITAL - if current_capital >= DAILY_PROFIT_CAPITAL: - return DAILY_PROFIT_CAPITAL - return DAILY_START_CAPITAL - - -def ensure_session(conn, session_date): - row = conn.execute( - "SELECT * FROM trading_sessions WHERE session_date = ?", - (session_date,) - ).fetchone() - if row: - return row - conn.execute( - "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", - (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) - ) - conn.commit() - return conn.execute( - "SELECT * FROM trading_sessions WHERE session_date = ?", - (session_date,) - ).fetchone() - - -def update_session_capital(conn, session_date, pnl_amount): - session_row = ensure_session(conn, session_date) - new_capital = float(session_row["current_capital"]) + float(pnl_amount) - conn.execute( - "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", - (round(new_capital, 4), session_date) - ) - conn.commit() - return round(new_capital, 4) - - -def calc_hold_seconds(opened_at_str, closed_at_dt): - try: - opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") - return int((closed_at_dt - opened_at).total_seconds()) - except Exception: - return 0 - - -def calc_hold_minutes(seconds): - if not seconds or seconds <= 0: - return 0 - return max(1, int(seconds // 60)) - - -def get_opened_at_value(row): - try: - keys = row.keys() if hasattr(row, "keys") else [] - except Exception: - keys = [] - if "opened_at" in keys: - value = row["opened_at"] - if value: - return value - return app_now_str() - - -def get_effective_trade_field(row, reviewed_key, base_key, default=None): - try: - keys = row.keys() if hasattr(row, "keys") else row.keys() - except Exception: - keys = [] - if reviewed_key in keys: - v = row[reviewed_key] - if v is not None and str(v).strip() != "": - return v - if base_key in keys: - v = row[base_key] - if v is not None and str(v).strip() != "": - return v - return default - - -def to_effective_trade_dict(row): - item = row_to_dict(row) - base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") - item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) - item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) - open_stop = item.get("initial_stop_loss") - if open_stop in (None, ""): - open_stop = base_stop - item["display_open_stop_loss"] = open_stop - item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_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 "" - try: - _keys = row.keys() if hasattr(row, "keys") else [] - except Exception: - _keys = [] - _reviewed_pnl_raw = row["reviewed_pnl_amount"] if "reviewed_pnl_amount" in _keys else None - has_reviewed_pnl = _reviewed_pnl_raw is not None and str(_reviewed_pnl_raw).strip() != "" - ex_pnl = item.get("exchange_realized_pnl") - if not has_reviewed_pnl and ex_pnl is not None and str(ex_pnl).strip() != "": - try: - item["effective_pnl_amount"] = round(float(ex_pnl), 2) - item["display_pnl_source"] = "exchange" - ex_open = (str(item.get("exchange_opened_at") or "").strip() or None) - ex_close = (str(item.get("exchange_closed_at") or "").strip() or None) - if ex_open: - item["effective_opened_at"] = ex_open - if ex_close: - item["effective_closed_at"] = ex_close - except (TypeError, ValueError): - item["display_pnl_source"] = "local" - elif has_reviewed_pnl: - item["display_pnl_source"] = "reviewed" - else: - item["display_pnl_source"] = "local" - return item - - -def format_price_magnitude_fallback(value): - """无 markets 或解析失败时的价格展示兜底(按量级)。""" - try: - v = float(value) - except Exception: - return str(value) - if v == 0: - return "0" - av = abs(v) - if av >= 10000: - d = 2 - elif av >= 100: - d = 3 - elif av >= 1: - d = 4 - elif av >= 0.01: - d = 6 - elif av >= 0.0001: - d = 8 - else: - d = 10 - text = f"{v:.{d}f}" - return text.rstrip("0").rstrip(".") if "." in text else text - - -def resolve_ccxt_price_symbol(symbol): - """将界面/库中的品种名转为 ccxt 永续合约 id(如 BTC/USDT -> BTC/USDT:USDT)。""" - s = (symbol or "").strip() - if not s: - return "" - if "/" not in s and ":" not in s: - s = f"{s.upper()}/USDT" - else: - s = s.upper() - return normalize_exchange_symbol(s) - - -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 format_price_for_symbol(symbol, value): - """价格展示:与交易所 price_to_precision 一致(与入库 round_price_to_exchange 对齐)。""" - if value in (None, ""): - return "-" - try: - v = float(value) - except Exception: - return str(value) - ex = resolve_ccxt_price_symbol(symbol) - if not ex: - return format_price_magnitude_fallback(v) - try: - ensure_markets_loaded() - return exchange.price_to_precision(ex, v) - except Exception: - return format_price_magnitude_fallback(v) - - -def format_usdt(value): - """USDT 资金类展示:固定两位小数。""" - if value in (None, ""): - return "-" - try: - return f"{float(value):.2f}" - except (TypeError, ValueError): - return str(value) - - -def format_signed_usdt(value): - """USDT 盈亏等可正可负:+1.23 / -0.50 / 0.00""" - if value in (None, ""): - return "-" - try: - v = float(value) - except (TypeError, ValueError): - return str(value) - if v == 0: - return "0.00" - sign = "+" if v > 0 else "" - return f"{sign}{v:.2f}" - - -def format_wechat_scalar_2dp(value): - """企业微信推送:数值统一两位小数(与交易所 tick 无关)。""" - if value in (None, ""): - return "-" - try: - return f"{float(value):.2f}" - except (TypeError, ValueError): - return str(value) - - -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, - key_signal_type=None, - entry_reason=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) - kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) - snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss - er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or "" - conn.execute( - "INSERT INTO trade_records (symbol,monitor_type,key_signal_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,entry_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, 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, er or None - ) - ) - - -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, 2) 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 - if not (item.get("monitor_type") or "").strip(): - item["monitor_type"] = ORDER_MONITOR_TYPE_MANUAL - 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 order_row_monitor_type(row): - if row is None: - return ORDER_MONITOR_TYPE_MANUAL - try: - keys = row.keys() if hasattr(row, "keys") else [] - except Exception: - keys = [] - if "monitor_type" in keys: - mt = (row["monitor_type"] or "").strip() - if mt: - return mt - return ORDER_MONITOR_TYPE_MANUAL - - -def order_row_key_signal_type(row): - if row is None: - return None - try: - keys = row.keys() if hasattr(row, "keys") else [] - except Exception: - keys = [] - if "key_signal_type" not in keys: - return None - kst = (row["key_signal_type"] or "").strip() - if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst): - return kst - return None - - -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, 2)}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}账户已达到目标{round(float(target_amount), 2)}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不足,需{round(needed, 2)}U,当前{round(from_balance, 2)}U") - ) - conn.commit() - conn.close() - send_wechat_msg( - f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{round(needed, 2)}U,当前{round(from_balance, 2)}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"自动划转成功:补足到{round(float(target_amount), 2)}U,实际划转{round(needed, 2)}U " - f"{AUTO_TRANSFER_FROM}->{AUTO_TRANSFER_TO}\n" - f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" - ) - else: - send_wechat_msg( - f"自动划转失败:计划补足到{round(float(target_amount), 2)}U,需划转{round(needed, 2)}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): - return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) - - -def clear_key_sizing_snapshot_if_flat(conn, session_date): - if get_active_position_count(conn) > 0: - return - conn.execute( - "UPDATE trading_sessions SET key_sizing_capital_snapshot = NULL, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", - (session_date,), - ) - conn.commit() - - -def get_key_sizing_capital_snapshot(conn, session_date): - row = ensure_session(conn, session_date) - try: - val = row["key_sizing_capital_snapshot"] - except (KeyError, IndexError): - return None - if val is None: - return None - try: - return float(val) - except (TypeError, ValueError): - return None - - -def set_key_sizing_capital_snapshot(conn, session_date, capital): - ensure_session(conn, session_date) - conn.execute( - "UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", - (round(float(capital), 2), session_date), - ) - conn.commit() - - -def resolve_capital_base_for_key_open(conn, trading_day, live_capital): - live = float(live_capital) - active = get_active_position_count(conn) - if active <= 0: - set_key_sizing_capital_snapshot(conn, trading_day, live) - return live - if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT: - snap = get_key_sizing_capital_snapshot(conn, trading_day) - if snap is not None and snap > 0: - return snap - return live - - -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})" - if direction not in ("long", "short"): - return False, "方向必须为 long 或 short" - if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): - expected = BTC_LEVERAGE - else: - expected = ALT_LEVERAGE - if expected <= 0: - return False, "杠杆配置异常" - return True, "" - - -def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): - ensure_markets_loaded() - notional = float(margin_capital) * float(leverage) - ticker = exchange.fetch_ticker(exchange_symbol) - price = float(ticker.get("last") or fallback_price) - if price <= 0: - raise ValueError("触发价必须大于 0") - market = exchange.market(exchange_symbol) - contract_size = float(market.get("contractSize") or 1) - if market.get("contract"): - # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 - amount = notional / (price * contract_size) - else: - amount = notional / price - min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") - if min_amount and amount < float(min_amount): - raise ValueError(f"下单数量过小,最小数量为 {min_amount}") - amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) - if amount_precise <= 0: - raise ValueError("下单数量精度后为 0,请提高基数或降低价格") - return amount_precise, price - - -def _to_positive_float(value): - try: - n = float(value) - return n if n > 0 else None - except Exception: - return None - - -def _extract_order_price_value(order_obj): - if not isinstance(order_obj, dict): - return None - for key in ("average", "price"): - v = _to_positive_float(order_obj.get(key)) - if v is not None: - return v - cost = _to_positive_float(order_obj.get("cost")) - filled = _to_positive_float(order_obj.get("filled")) - if cost is not None and filled is not None and filled > 0: - return cost / filled - info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} - for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): - v = _to_positive_float(info.get(key)) - if v is not None: - return v - return None - - -def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): - price = _extract_order_price_value(order_resp) - if price is not None: - return round(price, 8) - order_id = (order_resp or {}).get("id") - if order_id: - try: - fetched = exchange.fetch_order(order_id, exchange_symbol) - fetched_price = _extract_order_price_value(fetched) - if fetched_price is not None: - return round(fetched_price, 8) - except Exception: - pass - fallback = _to_positive_float(fallback_price) - return round(fallback, 8) if fallback is not None else 0.0 - - -def get_contract_size(exchange_symbol): - ensure_markets_loaded() - market = exchange.market(exchange_symbol) - return float(market.get("contractSize") or 1) - - -def parse_positive_float(value): - if value is None: - return None - raw = str(value).strip() - if not raw: - return None - num = float(raw) - if num <= 0: - raise ValueError("数值必须大于0") - return num - - -def build_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" - 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_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 _gate_list_trigger_open_orders(exchange_symbol): - params = _gate_swap_trigger_order_params() - try: - return exchange.fetch_open_orders(exchange_symbol, params=params) or [] - except Exception: - return [] - - -def _gate_order_trigger_price(order): - for key in ("stopPrice", "triggerPrice", "price"): - try: - v = float(order.get(key) or 0) - if v > 0: - return v - except Exception: - pass - info = order.get("info") or {} - if isinstance(info, dict): - trig = info.get("trigger") - if isinstance(trig, dict): - try: - v = float(trig.get("price") or 0) - if v > 0: - return v - except Exception: - pass - for key in ("trigger_price", "triggerPrice", "stopPrice", "price"): - try: - v = float(info.get(key) or 0) - if v > 0: - return v - except Exception: - pass - return None - - -def _gate_tpsl_role_from_order(order, direction): - info = order.get("info") or {} - if not isinstance(info, dict): - info = {} - ot = str(info.get("order_type") or info.get("orderType") or order.get("type") or "").lower() - if "take" in ot and "profit" in ot: - return "tp" - if "stop" in ot and "loss" in ot: - return "sl" - trig = info.get("trigger") - rule = None - if isinstance(trig, dict) and trig.get("rule") is not None: - try: - rule = int(trig["rule"]) - except Exception: - rule = None - if rule is None: - try: - rule = int(info.get("rule")) - except Exception: - rule = None - if rule is not None: - if direction == "long": - return "sl" if rule == 2 else ("tp" if rule == 1 else None) - return "sl" if rule == 1 else ("tp" if rule == 2 else None) - if order.get("stopLossPrice"): - return "sl" - if order.get("takeProfitPrice"): - return "tp" - typ = str(order.get("type") or "").upper() - if "TAKE" in typ: - return "tp" - if "STOP" in typ: - return "sl" - return None - - -def _gate_tpsl_slot_from_order(order, exchange_symbol): - trig = _gate_order_trigger_price(order) - try: - amt = float(order.get("amount") or order.get("remaining") or 0) - except Exception: - amt = None - if amt is not None and amt <= 0: - amt = None - oid = order.get("id") - if oid is None and isinstance(order.get("info"), dict): - oid = order["info"].get("id") or order["info"].get("order_id") - disp = format_price_for_symbol(exchange_symbol, trig) if trig else "-" - return { - "order_id": str(oid) if oid is not None else "", - "channel": "gate_trigger", - "trigger_price": trig, - "trigger_display": disp, - "amount": amt, - "type": str(order.get("type") or ""), - } - - -def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None): - slots = {"sl": None, "tp": None} - if not exchange_symbol: - return slots - ok, _ = ensure_exchange_live_ready() - if not ok: - return slots - try: - ensure_markets_loaded() - ambiguous = [] - for order in _gate_list_trigger_open_orders(exchange_symbol): - role = _gate_tpsl_role_from_order(order, direction) - slot = _gate_tpsl_slot_from_order(order, exchange_symbol) - if role in ("sl", "tp"): - if slots[role] is None: - slots[role] = slot - continue - ambiguous.append(slot) - for slot in ambiguous: - trig = slot.get("trigger_price") - if trig is None: - continue - try: - plan_sl_f = float(plan_sl) if plan_sl is not None else None - plan_tp_f = float(plan_tp) if plan_tp is not None else None - except Exception: - plan_sl_f = plan_tp_f = None - if plan_sl_f is not None and plan_tp_f is not None: - role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp" - elif plan_sl_f is not None: - role = "sl" - elif plan_tp_f is not None: - role = "tp" - else: - continue - if slots[role] is None: - slots[role] = slot - except Exception: - pass - return slots - - -def cancel_gate_tpsl_slot(exchange_symbol, slot): - if not slot or not exchange_symbol: - return - ensure_markets_loaded() - oid = slot.get("order_id") - if not oid: - return - params = _gate_swap_trigger_order_params() - exchange.cancel_order(str(oid), exchange_symbol, params) - - -def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): - sltp_mode = (sltp_mode or "price").strip().lower() - if sltp_mode == "pct": - sl_pct = float(data.get("sl_pct") or 0) - tp_pct = float(data.get("tp_pct") or 0) - if sl_pct <= 0 or tp_pct <= 0: - raise ValueError("百分比止盈止损须为正数") - sl_ratio = sl_pct / 100.0 - tp_ratio = tp_pct / 100.0 - entry = float(live_price) - if direction == "short": - stop_loss = entry * (1 + sl_ratio) - take_profit = entry * (1 - tp_ratio) - else: - stop_loss = entry * (1 - sl_ratio) - take_profit = entry * (1 + tp_ratio) - else: - stop_loss = float(data.get("sl") or data.get("stop_loss") or 0) - take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0) - if stop_loss <= 0 or take_profit <= 0: - raise ValueError("止盈止损价格须大于 0") - return stop_loss, take_profit - - -def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): - 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, 2) - if notional is not None and notional > 0: - out["notional"] = round(notional, 2) - if unrealized is not None: - out["unrealized_pnl"] = round(unrealized, 2) - 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, order_leverage=None): - 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, order_leverage=order_leverage) - - -def _order_row_exchange_margin_usdt(row): - if not row: - return None - try: - keys = row.keys() - except Exception: - return None - if "exchange_margin_usdt" not in keys: - return None - v = row["exchange_margin_usdt"] - if v is None: - return None - try: - x = float(v) - except (TypeError, ValueError): - return None - return x if x > 0 else None - - -def margin_capital_for_trade_record(order_row): - """trade_records.基数:优先交易所持仓保证金快照,旧数据无快照时回退计划保证金。""" - ex = _order_row_exchange_margin_usdt(order_row) - if ex is not None: - return round(ex, 2) - if not order_row: - return None - try: - v = order_row["margin_capital"] - except (TypeError, KeyError, IndexError): - return None - if v is None: - return None - try: - return float(v) - except (TypeError, ValueError): - return None - - -def try_persist_exchange_margin_for_order(conn, order_id, exchange_symbol, direction, order_leverage=None, max_attempts=6, sleep_s=0.45): - """开仓成功后持仓可见时拉取交易所保证金并写入 order_monitors(平仓后无法再取)。""" - if not conn or not order_id or not exchange_private_api_configured(): - return False - direction = (direction or "long").lower() - ex_sym = (exchange_symbol or "").strip() - if not ex_sym: - return False - n = max(1, int(max_attempts)) - delay = max(0.05, float(sleep_s)) - for _ in range(n): - pm = get_live_position_exchange_metrics(ex_sym, direction, order_leverage=order_leverage) - if pm and pm.get("initial_margin") is not None: - try: - v = float(pm["initial_margin"]) - except (TypeError, ValueError): - v = 0.0 - if v > 0: - conn.execute( - "UPDATE order_monitors SET exchange_margin_usdt=? WHERE id=?", - (round(v, 4), int(order_id)), - ) - return True - time.sleep(delay) - return False - - -def opened_at_str_to_ms(opened_at_str): - if not opened_at_str: - return None - dt = parse_dt_for_trading_day(opened_at_str) - if dt is None: - 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=order_row_monitor_type(r), - key_signal_type=order_row_key_signal_type(r), - 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=margin_capital_for_trade_record(r), - 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"],)) - clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day()) - 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 - min_closed = KEY_VOLUME_MA_BARS + 3 - if len(closed) < min_closed: - out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足" - return out - try: - breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] - confirm = closed[KEY_CONFIRM_BAR] - except IndexError: - out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" - return out - prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR] - avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1) - vol_break = float(breakout[5]) - vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN 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 > KEY_BREAKOUT_AMP_MIN_PCT) and (amp_pct < KEY_BREAKOUT_AMP_MAX_PCT) - 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 <= KEY_DAILY_VOLUME_RANK_MAX) - 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 _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): - """本条关键位一次性结案:写历史并从当前表删除。""" - n = int(row["notification_count"] or 0) + 1 - insert_key_monitor_history(conn, row, n, last_msg, close_reason) - conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) - - -def _key_hard_lines_from_checks(checks): - return [ - 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)", - ] - - -def _key_plan_sl_tp_for_row(row, direction, upper, lower, checks): - """按 key_monitors 录入的方案计算计划 SL/TP。""" - mode = sl_tp_mode_from_row(row, "standard") - manual_tp = _sqlite_row_val(row, "manual_take_profit") - planned = plan_key_sl_tp( - mode, - direction, - upper, - lower, - checks, - outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, - trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, - manual_take_profit=manual_tp, - ) - return planned, mode - - -def _market_open_for_key_monitor( - conn, - symbol, - direction, - exchange_symbol, - stop_loss, - take_profit, - key_signal_type=None, - breakeven_enabled=0, -): - """ - 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入。 - 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) - """ - now = app_now() - ok, reason = precheck_risk(conn, symbol, direction) - if not ok: - return False, f"风控拒绝下单:{reason}", None - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - return False, reason_live, None - - default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) - leverage = int(default_leverage) if default_leverage else 5 - if leverage <= 0: - leverage = 5 - - 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) - live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) - capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) - - trade_style = (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: - return False, "获取交易所实时价格失败(以损定仓需要当前价)", None - try: - ensure_markets_loaded() - except Exception: - pass - lp_r = round_price_to_exchange(exchange_symbol, live_price) - if lp_r is not None: - live_price = lp_r - - sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) - tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) - if sl_adj is not None: - stop_loss = float(sl_adj) - if tp_adj is not None: - take_profit = float(tp_adj) - - risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) - if risk_fraction is None: - return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None - 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: - return False, "以损定仓后保证金超过当前交易资金", None - - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) - if margin_capital > max_margin: - return ( - False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", - None, - ) - - 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: - return False, friendly_exchange_error(e, available_usdt=available_usdt), None - - trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) - stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) - take_profit = round_price_to_exchange(exchange_symbol, take_profit) - - 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) - if risk_amount_final is None: - risk_amount_final = risk_amount - else: - try: - risk_amount_final = round(float(risk_amount_final), 4) - except (TypeError, ValueError): - risk_amount_final = risk_amount - - if direction == "short": - breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) - else: - breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) - breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) - be_enabled = 1 if int(breakeven_enabled or 0) != 0 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, monitor_type, key_signal_type) " - "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, - be_enabled, - notional_value, - position_ratio, - base_amount, - amount, - open_order_id, - opened_at_bj, - opened_at_ms, - trading_day, - ORDER_MONITOR_TYPE_KEY_AUTO, - stored_key_signal_type(key_signal_type), - ), - ) - new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) - opens_today_after = conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", - (trading_day,), - ).fetchone()[0] - - return True, None, { - "new_order_id": new_order_id, - "open_order_id": open_order_id, - "trigger_price": trigger_price, - "planned_rr_fill": planned_rr, - "risk_amount_final": risk_amount_final, - "margin_capital": margin_capital, - "leverage": leverage, - "amount": amount, - "base_amount": base_amount, - "notional_value": notional_value, - "position_ratio": position_ratio, - "tpsl_attached": tpsl_attached, - "opens_today_before": opens_today_before, - "opens_today_after": opens_today_after, - "trading_day": trading_day, - "risk_percent": risk_percent, - "breakeven_rr_trigger": breakeven_rr_trigger, - "breakeven_price": breakeven_price, - "capital_base_at_open": capital_base, - } - - -def _sqlite_row_val(row, key, default=None): - try: - v = row[key] - return default if v is None else v - except (KeyError, IndexError, TypeError): - return default - - -def get_symbol_mark_price(symbol): - """斐波失效判定用标记价。""" - ex_sym = normalize_exchange_symbol(symbol) - try: - ensure_markets_loaded() - ticker = exchange.fetch_ticker(ex_sym) - m = _coerce_float(ticker.get("mark"), ticker.get("last")) - if m is None: - info = ticker.get("info") or {} - m = _coerce_float(info.get("mark_price"), info.get("last")) - if m is not None and m > 0: - return float(m) - except Exception: - pass - p = get_price(symbol) - return float(p) if p is not None else None - - -def cancel_fib_limit_order(exchange_symbol, order_id): - """仅撤销本条斐波限价单,不用 cancel_all。""" - if not order_id: - return False - ok_live, _ = ensure_exchange_live_ready() - if not ok_live: - return False - ensure_markets_loaded() - oid = str(order_id) - try: - exchange.cancel_order(oid, exchange_symbol) - return True - except Exception: - pass - try: - for o in exchange.fetch_open_orders(exchange_symbol) or []: - if str(o.get("id")) == oid: - exchange.cancel_order(oid, exchange_symbol) - return True - except Exception: - pass - return False - - -def fib_limit_order_status(exchange_symbol, order_id): - if not order_id: - return "missing" - ensure_markets_loaded() - oid = str(order_id) - try: - o = exchange.fetch_order(oid, exchange_symbol) - st = (o.get("status") or "").lower() - if st in ("closed", "filled"): - filled = float(o.get("filled") or 0) - if filled > 0 or st == "filled": - return "filled" - if st in ("canceled", "cancelled", "expired", "rejected"): - return "canceled" - if st in ("open", "new", "partially_filled"): - return "open" - except Exception: - pass - try: - for o in exchange.fetch_open_orders(exchange_symbol) or []: - if str(o.get("id")) == oid: - return "open" - except Exception: - pass - return "unknown" - - -def place_fib_limit_order(exchange_symbol, direction, amount, leverage, limit_price): - ensure_markets_loaded() - exchange.set_leverage(leverage, exchange_symbol) - side = "buy" if direction == "long" else "sell" - price = round_price_to_exchange(exchange_symbol, float(limit_price)) - if price is None or price <= 0: - raise ValueError("挂单价无效") - params = build_gate_order_params(direction, reduce_only=False) - return exchange.create_order(exchange_symbol, "limit", side, amount, price, params) - - -def _fib_key_exists_for_symbol(conn, symbol): - ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES)) - row = conn.execute( - f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})", - (symbol, *tuple(FIB_KEY_MONITOR_TYPES)), - ).fetchone() - return row is not None - - -def _fib_plan_for_row(row): - typ = (row["monitor_type"] or "").strip() - ratio = fib_ratio_from_type(typ) - if ratio is None: - return None - return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio) - - -def _cancel_fib_monitor_limit(row): - ex_sym = normalize_exchange_symbol(row["symbol"]) - oid = _sqlite_row_val(row, "fib_limit_order_id") - if oid: - cancel_fib_limit_order(ex_sym, oid) - - -def _fib_has_live_position(exchange_symbol, direction): - live = get_live_position_contracts(exchange_symbol, direction) - return live is not None and float(live) > 0 - - -def _insert_order_monitor_from_fib_fill( - conn, row, trigger_price, stop_loss, take_profit, amount, leverage, margin_capital, - notional_value, position_ratio, base_amount, exchange_order_id, tpsl_attached, -): - symbol = row["symbol"] - direction = (row["direction"] or "long").lower() - exchange_symbol = normalize_exchange_symbol(symbol) - typ = (row["monitor_type"] or "").strip() - now = app_now() - trading_day = get_trading_day(now) - trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() - if trade_style not in ("trend", "swing"): - trade_style = "trend" - risk_percent = max(0.01, float(RISK_PERCENT)) - risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) - if risk_amount_final is None: - risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4) - 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 - if direction == "short": - breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) - else: - breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) - breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) - opened_at_bj = app_now_str() - opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) - 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, monitor_type, key_signal_type) " - "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, - 1 if breakeven_enabled_from_row(row, 0) else 0, - notional_value, - position_ratio, - base_amount, - amount, - exchange_order_id or "", - opened_at_bj, - opened_at_ms, - trading_day, - ORDER_MONITOR_TYPE_KEY_AUTO, - stored_key_signal_type(typ), - ), - ) - new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) - return new_order_id - - -def _finalize_fib_key_fill(conn, row): - symbol = row["symbol"] - direction = (row["direction"] or "long").lower() - typ = (row["monitor_type"] or "").strip() - ex_sym = normalize_exchange_symbol(symbol) - plan = _fib_plan_for_row(row) - if not plan: - _finalize_key_monitor_one_shot(conn, row, "斐波计划无效", "fib_plan_invalid") - return - entry_plan, sl_plan, tp_plan = plan - sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan) - tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan) - sl_adj = round_price_to_exchange(ex_sym, sl) - tp_adj = round_price_to_exchange(ex_sym, tp) - if sl_adj is not None: - sl = float(sl_adj) - if tp_adj is not None: - tp = float(tp_adj) - amount = float(_sqlite_row_val(row, "fib_order_amount") or 0) - leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5) - margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0) - oid = _sqlite_row_val(row, "fib_limit_order_id") - entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan) - trigger_price = entry_px - if oid: - try: - o = exchange.fetch_order(str(oid), ex_sym) - trigger_price = resolve_order_entry_price(o, ex_sym, entry_px) - except Exception: - pass - tr_adj = round_price_to_exchange(ex_sym, trigger_price) - if tr_adj is not None: - trigger_price = float(tr_adj) - if amount <= 0: - live_amt = get_live_position_contracts(ex_sym, direction) - amount = float(live_amt or 0) - if amount <= 0: - send_wechat_msg( - f"# ❌ {symbol} 斐波成交后处理失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 无法取得持仓/下单数量,未挂 TP/SL\n" - ) - return - ok, reason = precheck_risk(conn, symbol, direction) - if not ok: - send_wechat_msg( - f"# ❌ {symbol} 斐波成交后风控拒绝\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}\n" - f"- 原因:{reason}\n" - f"- 请手动处理仓位与挂单\n" - ) - return - tpsl_attached = False - try: - _gate_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) - tpsl_attached = True - except Exception as e: - send_wechat_msg( - f"# ❌ {symbol} 斐波成交后挂 TP/SL 失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 错误:{friendly_exchange_error(e)}\n" - f"- 请手动补挂止盈止损\n" - ) - return - contract_size = get_contract_size(ex_sym) - base_amount = round(float(amount) * contract_size, 8) - notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0 - session_row = ensure_session(conn, get_trading_day(app_now())) - capital_base = float(session_row["current_capital"] or 0) - position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0 - planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp) - new_order_id = _insert_order_monitor_from_fib_fill( - conn, row, trigger_price, sl, tp, amount, leverage, margin_capital, - notional_value, position_ratio, base_amount, oid, tpsl_attached, - ) - rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" - succ = ( - f"# ✅ {symbol} 斐波限价成交\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" - f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" - f"- 订单 ID:**{new_order_id}**\n" - f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n" - f"- 止损:{format_wechat_scalar_2dp(sl)}|止盈:{format_price_for_symbol(symbol, tp)}\n" - f"- 计划 RR:{rr_txt}:1\n" - f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" - ) - send_wechat_msg(succ) - _finalize_key_monitor_one_shot(conn, row, succ, "fib_filled") - - -def check_fib_key_monitors(): - conn = get_db() - rows = conn.execute("SELECT * FROM key_monitors").fetchall() - for r in rows: - typ = (r["monitor_type"] or "").strip() - if not is_fib_key_monitor_type(typ): - continue - symbol = r["symbol"] - direction = (r["direction"] or "long").lower() - ex_sym = normalize_exchange_symbol(symbol) - up, low = float(r["upper"]), float(r["lower"]) - oid = _sqlite_row_val(r, "fib_limit_order_id") - mark = get_symbol_mark_price(symbol) - if mark is None: - continue - status = fib_limit_order_status(ex_sym, oid) if oid else "missing" - if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): - _finalize_fib_key_fill(conn, r) - continue - if status == "open": - if fib_invalidate_by_mark(direction, mark, up, low): - _cancel_fib_monitor_limit(r) - msg = ( - f"# ⚠️ {symbol} 斐波监控失效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" - f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n" - ) - send_wechat_msg(msg) - _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") - continue - if status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): - msg = ( - f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 标记价触达止盈侧,本条已结案\n" - ) - send_wechat_msg(msg) - _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") - conn.commit() - conn.close() - - -def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0): - if _fib_key_exists_for_symbol(conn, symbol): - return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" - ratio = fib_ratio_from_type(mt) - plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio) - if not plan: - return False, "斐波上下沿无效(需上沿 H > 下沿 L)" - entry, sl, tp = plan - ex_sym = normalize_exchange_symbol(symbol) - entry = round_price_to_exchange(ex_sym, entry) - sl = round_price_to_exchange(ex_sym, sl) - tp = round_price_to_exchange(ex_sym, tp) - if entry is None or sl is None or tp is None: - return False, "斐波价位经交易所精度舍入后无效" - entry, sl, tp = float(entry), float(sl), float(tp) - planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp) - if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: - fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" - return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)" - ok, reason = precheck_risk(conn, symbol, direction_sel) - if not ok: - return False, reason - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - return False, reason_live - now = app_now() - trading_day = get_trading_day(now) - session_row = ensure_session(conn, trading_day) - _, trading_capital_live = get_exchange_capitals(force=True) - live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) - capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) - default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) - leverage = int(default_leverage) if default_leverage else 5 - if leverage <= 0: - leverage = 5 - available_usdt = get_available_trading_usdt() - risk_fraction = calc_risk_fraction(direction_sel, entry, sl) - if risk_fraction is None: - return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" - 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: - return False, "以损定仓后保证金超过当前交易资金" - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) - if margin_capital > max_margin: - return ( - False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", - ) - try: - amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) - order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) - oid = str(order_resp.get("id") or "") - if not oid: - return False, "交易所未返回限价单 ID" - except Exception as e: - return False, friendly_exchange_error(e, available_usdt=available_usdt) - be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 - conn.execute( - "INSERT INTO key_monitors " - "(symbol, monitor_type, direction, upper, lower, " - "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " - "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, mt, direction_sel, upper_px, lower_px, - oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, - ), - ) - return True, None - - -# 关键位监控(箱体/收敛可自动开仓;阻力/支撑位仅单次提醒结案) -def check_key_monitors(): - conn = get_db() - rows = conn.execute("SELECT * FROM key_monitors").fetchall() - for r in rows: - sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] - typ = (typ_raw or "").strip() - if is_fib_key_monitor_type(typ): - continue - direction = (r["direction"] or "long").lower() - 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)主趋势逆势,建议降低仓位并严格执行止损。" - - key_price = float(low) if direction == "long" else float(up) - hard_lines = _key_hard_lines_from_checks(checks) - trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() - - alert_only = typ in KEY_MONITOR_ALERT_ONLY_TYPES or ( - typ not in KEY_MONITOR_AUTO_TYPES and typ not in KEY_MONITOR_ALERT_ONLY_TYPES - ) - - if alert_only: - op_lines = [ - "- 本条为关键阻力/支撑或非标类型:**仅单次推送**,不进行自动开仓。", - "- 本条关键位将在推送后记入历史并从监控列表移除。", - ] - 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) - _finalize_key_monitor_one_shot(conn, r, msg, "key_level_alert_only") - continue - - plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) - if not plan_tuple: - fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)" - rr_msg = ( - f"# ⚠️ {sym} 关键位自动单:计划无效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}\n" - f"- 方向:**{_wechat_direction_text(direction)}**\n" - f"- 触发时间:`{trigger_time}`\n" - f"- 确认K收盘(E):`{format_price_for_symbol(sym, checks.get('confirm_close'))}`\n" - f"- **{fmt_rr}**(未开仓)\n" - "---\n" - "### 硬条件\n" - + "\n".join(f"- {x}" for x in hard_lines) - ) - if risk_tip: - rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" - send_wechat_msg(rr_msg) - _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") - continue - E, sl_raw, tp_raw, box_h = plan_tuple - exchange_symbol = normalize_exchange_symbol(sym) - try: - ensure_markets_loaded() - except Exception: - pass - sl_px = round_price_to_exchange(exchange_symbol, sl_raw) - tp_px = round_price_to_exchange(exchange_symbol, tp_raw) - if sl_px is not None: - sl_raw = float(sl_px) - if tp_px is not None: - tp_raw = float(tp_px) - - planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) - rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR - - if not rr_ok: - fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)" - plan_line = sl_tp_plan_summary_text( - sl_tp_mode, direction, E, sl_raw, tp_raw, box_h, - outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, - trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, - ) - rr_msg = ( - f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}|{plan_line}\n" - f"- 方向:**{_wechat_direction_text(direction)}**\n" - f"- 触发时间:`{trigger_time}`\n" - f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" - f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n" - f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" - f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" - f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n" - "---\n" - "### 硬条件\n" - + "\n".join(f"- {x}" for x in hard_lines) - ) - if risk_tip: - rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" - send_wechat_msg(rr_msg) - _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") - continue - - key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None - be_on = breakeven_enabled_from_row(r, 0) - ok_trade, trade_err, det = _market_open_for_key_monitor( - conn, - sym, - direction, - exchange_symbol, - sl_raw, - tp_raw, - key_signal_type=key_sig, - breakeven_enabled=1 if be_on else 0, - ) - planned_rr_txt = ( - format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" - ) - if not ok_trade: - fail_msg = ( - f"# ❌ {sym} 关键位自动单失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}\n" - f"- 方向:**{_wechat_direction_text(direction)}**\n" - f"- 触发时间:`{trigger_time}`\n" - f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" - f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" - f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" - f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n" - f"- **失败原因:{trade_err}**\n" - "---\n" - "### 硬条件\n" - + "\n".join(f"- {x}" for x in hard_lines) - ) - if risk_tip: - fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" - send_wechat_msg(fail_msg) - _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed") - continue - - tpsl_txt = ( - "已在交易所挂条件委托(止盈、止损触发单)" - if det.get("tpsl_attached") - else "⚠️ 条件委托挂接状态异常或未挂上" - ) - rr_fill = det.get("planned_rr_fill") - rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-" - - succ_msg_lines = [ - f"# ✅ {sym} 关键位自动开仓成功", - f"**账户:{_wechat_account_label()}**", - f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", - f"- 页面订单 ID:**{det['new_order_id']}**", - f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", - f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", - f"- 方向:**{_wechat_direction_text(direction)}**", - f"- 触发时间:`{trigger_time}`", - f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", - f"- **计划 RR(E):{planned_rr_txt}:1**", - f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", - f"- **成交价侧计划 RR:**{rr_fill_txt}:1", - f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", - f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", - f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", - f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", - f"- **{tpsl_txt}**", - f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", - f"- 当日开仓次数:**{det.get('opens_today_after')}** / {DAILY_OPEN_ALERT_THRESHOLD}(提醒阈值)", - ] - succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines]) - if risk_tip: - succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"]) - succ_msg = "\n".join(succ_msg_lines) - send_wechat_msg(succ_msg) - _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened") - - if det.get("opens_today_before", 0) < DAILY_OPEN_ALERT_THRESHOLD <= det.get("opens_today_after", 0): - advice = ai_short_advice( - f"用户在北京时间交易日 {det['trading_day']} 已累计开仓 {det['opens_today_after']} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" - f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。" - f"用户自述“上头了”。请给克制提醒。" - ) - if advice: - send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}") - 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) - trade_basis_row = row_to_dict(r) - ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) - if _order_row_exchange_margin_usdt(r) is None and exchange_private_api_configured(): - pm = get_live_position_exchange_metrics(ex_sym, direction, order_leverage=leverage) - if pm and pm.get("initial_margin") is not None: - try: - mv = float(pm["initial_margin"]) - if mv > 0: - conn.execute( - "UPDATE order_monitors SET exchange_margin_usdt=? WHERE id=?", - (round(mv, 4), pid), - ) - trade_basis_row["exchange_margin_usdt"] = round(mv, 4) - except (TypeError, ValueError): - pass - 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=order_row_monitor_type(r), - key_signal_type=order_row_key_signal_type(r), - 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_for_trade_record(trade_basis_row), - 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=order_row_monitor_type(r), - key_signal_type=order_row_key_signal_type(r), - 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_for_trade_record(trade_basis_row), - 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)) - clear_key_sizing_snapshot_if_flat(conn, get_trading_day()) - 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=order_row_monitor_type(r), - key_signal_type=order_row_key_signal_type(r), - 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_for_trade_record(r), - 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_fib_key_monitors() - check_key_monitors() - check_order_monitors() - except: - pass - time.sleep(MONITOR_POLL_SECONDS) - - -# ====================== 登录路由 ====================== -@app.route("/login", methods=["GET", "POST"]) -def login(): - if AUTH_DISABLED: - session["logged_in"] = True - return redirect("/") - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - if username == USERNAME and password == PASSWORD: - session["logged_in"] = True - return redirect("/") - else: - flash("账号或密码错误") - return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME) - -@app.route("/logout") -def logout(): - session.clear() - return redirect("/" if AUTH_DISABLED else "/login") - -# 登录校验装饰器 -def login_required(f): - @wraps(f) - def decorated(*args, **kwargs): - if AUTH_DISABLED: - return f(*args, **kwargs) - if not session.get("logged_in"): - return redirect("/login") - return f(*args, **kwargs) - return decorated - - -@app.route("/sync_positions") -@login_required -def sync_positions(): - days_raw = (request.args.get("days") or "").strip() - sync_days = None - if days_raw: - try: - sync_days = max(1, min(365, int(days_raw))) - except Exception: - sync_days = None - conn = get_db() - synced = reconcile_external_closes(conn, days=sync_days) - conn.commit() - conn.close() - if sync_days is not None: - flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") - else: - flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") - return redirect("/") - - -@app.route("/api/sync_positions", methods=["POST"]) -@login_required -def api_sync_positions(): - payload = request.get_json(silent=True) or {} - days_raw = str(payload.get("days", "")).strip() - if not days_raw: - return jsonify({"ok": False, "msg": "请填写天数"}), 400 - try: - days = int(days_raw) - except Exception: - return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 - if days < 1 or days > 365: - return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 - conn = get_db() - synced = reconcile_external_closes(conn, days=days) - conn.commit() - conn.close() - return jsonify({"ok": True, "days": days, "synced": int(synced)}) - - -def _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 > 1e9: - return int(v * 1000.0) - return int(v * 1000.0) - - -def _unified_symbol_for_match(symbol_str): - """统一 ETH/USDT:USDT、ETH_USDT、ETH/USDT 便于与 trade_records 比对。""" - s = (symbol_str or "").strip().upper() - if not s: - return "" - if ":" in s: - s = s.split(":")[0] - if "_" in s and "/" not in s: - s = s.replace("_", "/") - if s.endswith("USDT") and "/" not in s and len(s) > 4: - s = f"{s[:-4]}/USDT" - return s - - -def exchange_position_sync_since_ms(): - 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 _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 "" - if not sym: - c_alt = str(info.get("contract") or "").strip() - if c_alt: - sym = c_alt.replace("_", "/") - side = (p.get("side") or info.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() - until_ms = int(time.time() * 1000) - out = [] - offset = 0 - page_limit = min(100, int(EXCHANGE_POSITION_HISTORY_LIMIT)) - max_total = int(EXCHANGE_POSITION_HISTORY_LIMIT) - - def _pull(params_extra): - nonlocal offset - offset = 0 - while len(out) < max_total: - params = dict(params_extra) - params["offset"] = offset - params["until"] = until_ms - try: - rows = exchange.fetch_positions_history( - None, - since=int(since_ms), - limit=page_limit, - params=params, - ) - except Exception: - return False - if not rows: - break - for p in rows: - 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) - offset += len(rows) - if len(rows) < page_limit: - break - return True - - if not _pull({"settle": "usdt"}): - _pull({}) - return out[:max_total] - - -def sync_trade_records_from_exchange(conn, force=False): - """为未同步的 trade_records 回填 Gate 平仓历史中的已实现盈亏。返回统计 dict。""" - global _LAST_EXCHANGE_PNL_SYNC_AT - stats = {"ok": False, "hist_count": 0, "matched": 0, "pending": 0, "skipped": False} - if not exchange_private_api_configured(): - stats["reason"] = "未配置 GATE_API_KEY / GATE_API_SECRET" - return stats - now = time.time() - if not force and now - _LAST_EXCHANGE_PNL_SYNC_AT < 25.0: - stats["ok"] = True - stats["skipped"] = True - return stats - try: - hist = fetch_gate_positions_close_history() - except Exception as e: - stats["reason"] = str(e) - return stats - stats["hist_count"] = len(hist) - if not hist: - stats["ok"] = True - stats["reason"] = "交易所平仓历史为空(请检查 API 权限或 EXCHANGE_POSITION_SYNC_FROM_BJ)" - return stats - candidates = conn.execute( - """ - SELECT id, symbol, direction, closed_at, closed_at_ms, opened_at, opened_at_ms - FROM trade_records - WHERE (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '') - OR exchange_realized_pnl IS NULL - ORDER BY id DESC - LIMIT 200 - """ - ).fetchall() - stats["pending"] = len(candidates) - if not candidates: - stats["ok"] = True - _LAST_EXCHANGE_PNL_SYNC_AT = now - return stats - used = set() - matched = 0 - for tr in candidates: - close_ms_trade = _to_ms_with_fallback( - tr["closed_at_ms"] if "closed_at_ms" in tr.keys() else None, tr["closed_at"] - ) or opened_at_str_to_ms(tr["closed_at"]) - open_ms_trade = _to_ms_with_fallback( - tr["opened_at_ms"] if "opened_at_ms" in tr.keys() else None, tr["opened_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 open_ms_trade is not None: - if cm < open_ms_trade - 15 * 60 * 1000: - continue - if cm > open_ms_trade + 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 > 90 * 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) - matched += 1 - stats["matched"] = matched - stats["ok"] = True - _LAST_EXCHANGE_PNL_SYNC_AT = now - try: - conn.commit() - except Exception: - pass - return stats - - -# ====================== 主页面 ====================== -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(float(get_recommended_capital(current_capital)), 2) - key_list = conn.execute("SELECT * FROM key_monitors").fetchall() - key_history = conn.execute( - "SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500", - (start_bj, end_bj), - ).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)) - exchange_pnl_sync = {} - if exchange_private_api_configured(): - try: - exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {} - except Exception as e: - exchange_pnl_sync = {"ok": False, "reason": str(e)} - raw_records = conn.execute( - "SELECT * FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " - "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id DESC LIMIT 1000", - (start_bj, end_bj), - ).fetchall() - records = [to_effective_trade_dict(r) for r in raw_records] - total = len(records) - miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") - win = sum(1 for r in records if (r.get("effective_result") or "") in ("止盈", "保本止盈", "移动止盈")) - occupied_miss_total = sum( - 1 - for r in records - if (r.get("effective_result") or "") == "错过" - and ("持仓占用" in str(r.get("effective_miss_reason") or "")) - ) - rate = round(win/total*100,2) if total else 0 - active_count = len(order_list) - can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS - key_gate_rule_text = ( - f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|" - f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" - f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|" - f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|" - f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%" - ) - conn.close() - return render_template( - "index.html", - page=page, - key=key_list, - key_history=key_history, - stats_bundle=stats_bundle, - order=order_list, - record=records, - total=total, - miss_count=miss_count, - rate=rate, - trading_day=trading_day, - funding_usdt=funding_usdt, - daily_start_capital=DAILY_START_CAPITAL, - current_capital=current_capital, - recommended_capital=recommended_capital, - btc_leverage=BTC_LEVERAGE, - alt_leverage=ALT_LEVERAGE, - reset_hour=TRADING_DAY_RESET_HOUR, - balance_refresh_seconds=BALANCE_REFRESH_SECONDS, - auto_transfer_enabled=AUTO_TRANSFER_ENABLED, - auto_transfer_amount=AUTO_TRANSFER_AMOUNT, - auto_transfer_from=AUTO_TRANSFER_FROM, - auto_transfer_to=AUTO_TRANSFER_TO, - auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, - full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, - price_refresh_seconds=PRICE_REFRESH_SECONDS, - active_count=active_count, - can_trade=can_trade, - focus_key_id=(key_list[0]["id"] if key_list else None), - focus_order_id=(order_list[0]["id"] if order_list else None), - data_export_version=3, - 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, - }, - 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, - usdt_fmt=format_usdt, - signed_usdt_fmt=format_signed_usdt, - entry_reason_options=list(ENTRY_REASON_OPTIONS), - entry_reason_other_value=ENTRY_REASON_OTHER, - exchange_display=EXCHANGE_DISPLAY_NAME, - max_active_positions=MAX_ACTIVE_POSITIONS, - manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, - key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, - key_gate_rule_text=key_gate_rule_text, - kline_timeframe=KLINE_TIMEFRAME, - exchange_pnl_sync=exchange_pnl_sync, - ) - - -@app.route("/api/sync_exchange_pnl") -@login_required -def api_sync_exchange_pnl(): - conn = get_db() - stats = sync_trade_records_from_exchange(conn, force=True) - try: - conn.commit() - except Exception: - pass - conn.close() - return jsonify(stats) - - -@app.route("/") -@login_required -def index(): - return redirect("/trade") - - -@app.route("/key_monitor") -@login_required -def key_monitor_page(): - return render_main_page("key_monitor") - - -@app.route("/trade") -@login_required -def trade_page(): - return render_main_page("trade") - - -@app.route("/records") -@login_required -def records_page(): - return render_main_page("records") - - -@app.route("/stats") -@login_required -def stats_page(): - return render_main_page("stats") - - -@app.route("/api/account_snapshot") -@login_required -def api_account_snapshot(): - now = app_now() - trading_day = get_trading_day(now) - conn = get_db() - session_row = ensure_session(conn, trading_day) - local_current_capital = float(session_row["current_capital"]) - funding_capital, trading_capital = get_exchange_capitals(force=True) - funding_usdt = round(funding_capital, 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(float(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, - "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, - "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,fib_entry_price,fib_limit_order_id 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() - - try: - ensure_markets_loaded() - except Exception: - pass - - 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: - is_fib = is_fib_key_monitor_type(r["monitor_type"]) - if is_fib: - price = get_symbol_mark_price(r["symbol"]) - else: - 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 - gate_summary = "-" - gate_metrics = "" - fib_gate_ok = True - if is_fib: - direction = (r["direction"] or "long").lower() - inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) - fib_gate_ok = not inval - entry = _sqlite_row_val(r, "fib_entry_price") - entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" - gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}" - if _sqlite_row_val(r, "fib_limit_order_id"): - gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}" - else: - try: - gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) - except Exception: - gate = None - 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 = "" - px_disp = format_price_for_symbol(r["symbol"], price) - try: - price_num = float(px_disp) if px_disp != "-" else float(price) - except Exception: - price_num = float(price) - key_prices.append({ - "id": r["id"], - "symbol": r["symbol"], - "price": price_num, - "price_display": px_disp, - "upper_diff": upper_diff, - "upper_pct": upper_pct, - "lower_diff": lower_diff, - "lower_pct": lower_pct, - "gate_summary": gate_summary, - "gate_ok": fib_gate_ok if is_fib else 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"], - "float_pnl": round(pnl, 2), - "float_pct": pnl_pct, - "rr_ratio": rr_ratio, - "plan_margin": round(margin, 2) 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"]), 2) - 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 - ) - px_for_fmt = float(price) - if ex_metrics and ex_metrics.get("mark_price") is not None: - try: - px_for_fmt = float(ex_metrics["mark_price"]) - except (TypeError, ValueError): - pass - px_disp = format_price_for_symbol(r["symbol"], px_for_fmt) - try: - payload["price"] = float(px_disp) if px_disp != "-" else px_for_fmt - except Exception: - payload["price"] = px_for_fmt - payload["price_display"] = px_disp - if exchange_private_api_configured(): - try: - payload["exchange_tpsl"] = fetch_exchange_tpsl_slots( - ex_sym, - r["direction"], - plan_sl=r["stop_loss"], - plan_tp=r["take_profit"], - ) - except Exception: - payload["exchange_tpsl"] = {"sl": None, "tp": None} - else: - payload["exchange_tpsl"] = {"sl": None, "tp": None} - 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/order//cancel_tpsl", methods=["POST"]) -@login_required -def api_order_cancel_tpsl(order_id): - data = request.get_json(silent=True) or {} - role = (data.get("role") or "").strip().lower() - if role not in ("sl", "tp"): - return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 - conn = get_db() - 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 - ok, reason = ensure_exchange_live_ready() - if not ok: - return jsonify({"ok": False, "msg": reason}), 400 - ex_sym = resolve_monitor_exchange_symbol(row) - slots = fetch_exchange_tpsl_slots( - ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"] - ) - slot = slots.get(role) - if not slot: - return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 - try: - cancel_gate_tpsl_slot(ex_sym, slot) - slots = fetch_exchange_tpsl_slots( - ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"] - ) - return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": slots}) - except Exception as e: - return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 - - -@app.route("/api/order//place_tpsl", methods=["POST"]) -@login_required -def api_order_place_tpsl(order_id): - data = request.get_json(silent=True) or {} - conn = get_db() - row = conn.execute( - "SELECT * FROM order_monitors WHERE id=? AND status='active'", - (order_id,), - ).fetchone() - if not row: - conn.close() - return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 - symbol = row["symbol"] - direction = row["direction"] - live_price = get_price(symbol) - if live_price is None: - conn.close() - return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400 - try: - sltp_mode = (data.get("sltp_mode") or "price").strip().lower() - stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data) - except Exception as e: - conn.close() - return jsonify({"ok": False, "msg": str(e)}), 400 - planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit) - if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR: - conn.close() - rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" - return jsonify( - { - "ok": False, - "msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1", - } - ), 400 - try: - replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) - except Exception as e: - conn.close() - return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 - conn.execute( - "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", - (stop_loss, take_profit, order_id), - ) - conn.commit() - ex_sym = resolve_monitor_exchange_symbol(row) - slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit) - conn.close() - return jsonify( - { - "ok": True, - "msg": "已先撤后挂止盈止损", - "stop_loss": stop_loss, - "take_profit": take_profit, - "planned_rr": planned_rr, - "exchange_tpsl": slots, - } - ) - - -@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 <= KEY_DAILY_VOLUME_RANK_MAX), - "rank_max": KEY_DAILY_VOLUME_RANK_MAX, - } - ) - - -@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() - last_price = get_price(symbol) - return jsonify({ - "ok": True, - "symbol": symbol, - "exchange_symbol": exchange_symbol, - "direction": direction, - "leverage": leverage, - "available_trading_usdt": round(available, 2) if available is not None else None, - "last_price": round(float(last_price), 8) if last_price 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, 2) if trading_capital_live is not None else round(local_current_capital, 2) - 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, 2) if trading_capital_live is not None else round(local_current_capital, 2) - 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 - - sym = order_item["symbol"] - return jsonify({ - "ok": True, - "timeframe": timeframe, - "limit": limit, - "order": { - "id": order_item["id"], - "symbol": sym, - "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"), - "trigger_price_display": format_price_for_symbol(sym, order_item.get("trigger_price")), - "stop_loss_display": format_price_for_symbol(sym, order_item.get("stop_loss")), - "take_profit_display": format_price_for_symbol(sym, 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, - "current_price_display": format_price_for_symbol(sym, current_price) if current_price else None, - "float_pnl": round(float(float_pnl), 2), - "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("/key_monitor") - direction_sel = (d.get("direction") or "").strip().lower() - if direction_sel not in ("long", "short"): - flash("请选择做多或做空") - return redirect("/key_monitor") - mt = (d.get("type") or "").strip() - allowed_types = ( - tuple(KEY_MONITOR_AUTO_TYPES) - + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) - + tuple(FIB_KEY_MONITOR_TYPES) - ) - if mt not in allowed_types: - flash("监控类型无效") - return redirect("/key_monitor") - rank, total = _daily_volume_rank(symbol) - if rank is None: - flash("日成交量排名读取失败,请稍后重试") - return redirect("/key_monitor") - if rank > KEY_DAILY_VOLUME_RANK_MAX: - flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") - return redirect("/key_monitor") - conn = get_db() - if mt in KEY_MONITOR_AUTO_TYPES: - occupied = get_active_position_count(conn) - if occupied >= MAX_ACTIVE_POSITIONS: - conn.close() - flash( - f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" - "请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。" - ) - return redirect("/key_monitor") - ex_sym_key = normalize_exchange_symbol(symbol) - try: - ensure_markets_loaded() - except Exception: - pass - upper_px = round_price_to_exchange(ex_sym_key, float(d["upper"])) - lower_px = round_price_to_exchange(ex_sym_key, float(d["lower"])) - be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) - if is_fib_key_monitor_type(mt): - ok_fib, err_fib = _add_fib_key_monitor( - conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag, - ) - conn.commit() - conn.close() - if not ok_fib: - flash(err_fib or "斐波监控添加失败") - return redirect("/key_monitor") - flash( - f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" - f"|移动保本:{'开' if be_flag else '关'}" - ) - return redirect("/key_monitor") - sl_tp_mode = "standard" - manual_tp = None - if mt in KEY_MONITOR_AUTO_TYPES: - sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode")) - if sl_tp_mode == "trend_manual": - try: - manual_tp = float(d.get("manual_take_profit") or 0) - except (TypeError, ValueError): - manual_tp = 0 - if manual_tp <= 0: - conn.close() - flash("趋势单方案须填写有效止盈价") - return redirect("/key_monitor") - if direction_sel == "long" and manual_tp <= upper_px: - conn.close() - flash("做多趋势单:止盈价应高于上沿(阻力)") - return redirect("/key_monitor") - if direction_sel == "short" and manual_tp >= lower_px: - conn.close() - flash("做空趋势单:止盈价应低于下沿(支撑)") - return redirect("/key_monitor") - mtpx = round_price_to_exchange(ex_sym_key, manual_tp) - if mtpx is not None: - manual_tp = float(mtpx) - conn.execute( - "INSERT INTO key_monitors " - "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) " - "VALUES (?,?,?,?,?,?,?,?)", - (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag), - ) - conn.commit() - conn.close() - ctr = False - try: - coin4h_status, _, _ = _status_by_ema55(symbol, "4h") - ctr = (direction_sel == "long" and coin4h_status == "空头") or ( - direction_sel == "short" and coin4h_status == "多头" - ) - except Exception: - pass - extra = "" - if mt in KEY_MONITOR_AUTO_TYPES: - extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" - flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") - if ctr: - flash( - "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" - ) - return redirect("/key_monitor") - -@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 - ex_miss = normalize_exchange_symbol(symbol) - try: - ensure_markets_loaded() - except Exception: - pass - insert_trade_record( - conn, - symbol=symbol, - monitor_type="下单监控", - direction=direction if direction in ("long", "short") else "long", - trigger_price=round_price_to_exchange(ex_miss, tp_raw) if tp_raw else 0, - stop_loss=round_price_to_exchange(ex_miss, sl_raw) if sl_raw else 0, - take_profit=round_price_to_exchange(ex_miss, tgt_raw) if tgt_raw else 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("/trade") - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - conn.close() - flash(f"风控拒绝下单:{reason_live}") - return redirect("/trade") - 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("/") - try: - ensure_markets_loaded() - except Exception: - pass - lp_r = round_price_to_exchange(exchange_symbol, live_price) - if lp_r is not None: - live_price = lp_r - 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("/trade") - planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit) - if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR: - conn.close() - rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算" - flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1") - return redirect("/trade") - sl_adj = round_price_to_exchange(exchange_symbol, stop_loss) - tp_adj = round_price_to_exchange(exchange_symbol, take_profit) - if sl_adj is not None: - stop_loss = sl_adj - if tp_adj is not None: - take_profit = tp_adj - 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, 2)}U,当前最多建议 {round(max_margin, 2)}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("/") - - trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) - stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) - take_profit = round_price_to_exchange(exchange_symbol, take_profit) - - 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_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) - else: - breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) - breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) - 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, monitor_type) 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, - ORDER_MONITOR_TYPE_MANUAL, - ) - ) - conn.commit() - new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) - conn.commit() - opens_today_after = conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", - (trading_day,), - ).fetchone()[0] - conn.close() - - chart_name = None - chart_url = None - if make_order_chart and ORDER_CHART_ENABLED: - try: - title_prefix = f"{symbol} {direction} #{new_order_id}" - chart_name = generate_order_open_chart(exchange_symbol, title_prefix) - if chart_name: - chart_url = f"/static/images/order_charts/{chart_name}" - except Exception: - chart_name = None - chart_url = None - - if chart_name: - try: - journal_id = f"order_{new_order_id}" - coin = journal_coin_from_symbol(symbol) - open_local = (opened_at_bj or "")[:16].replace(" ", "T") - if len(open_local) < 16: - open_local = app_now().strftime("%Y-%m-%dT%H:%M") - close_local = open_local - hold_duration = calc_duration_text(open_local, close_local) - note = ( - f"auto_from_open_order id={new_order_id} oid={open_order_id} " - f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" - ) - conn = get_db() - conn.execute( - """INSERT OR REPLACE INTO journal_entries - (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, - expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, - mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, - new_trade_while_occupied, note, image) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - journal_id, - open_local, - close_local, - hold_duration, - coin, - "multi", - "0", - "auto:open", - "待平仓", - "", - "", - "否", - "", - "", - "", - None, - None, - None, - "", - "否", - "否", - note, - chart_name, - ), - ) - conn.commit() - conn.close() - except Exception: - try: - conn.close() - except Exception: - pass - - _, trading_capital_after = get_exchange_capitals(force=True) - account_base_display = ( - round(float(trading_capital_after), 2) - if trading_capital_after is not None - else round(float(capital_base), 2) - ) - 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 = f"{float(planned_rr):.2f}" 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_wechat_scalar_2dp(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), 2)} U", - "📊 仓位配置详情", - f"账户基数:{account_base_display} USDT", - f"合约杠杆:{leverage} 倍", - f"名义仓位:{format_wechat_scalar_2dp(notional_value)} USDT", - f"仓位占比:{position_ratio}%", - f"合约张数:{format_wechat_scalar_2dp(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}%≈{round(float(risk_amount_final), 2)}U;基数 {round(float(margin_capital), 2)}U,杠杆 {leverage}x,名义仓位 {format_wechat_scalar_2dp(notional_value)}U,仓位占比 {position_ratio}%,合约张数 {format_wechat_scalar_2dp(amount)}(折算标的 {base_amount})," - f"计划RR {format_wechat_scalar_2dp(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,基数{round(float(margin_capital), 2)}U。" - f"用户自述“上头了”。请给克制提醒。" - ) - if advice: - send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") - flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") - return redirect("/") - -@app.route("/delete_key_monitor/", methods=["POST"]) -@login_required -def delete_key_monitor(kid): - conn = get_db() - row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() - if not row: - conn.close() - return jsonify({"ok": False, "error": "not_found"}) - if is_fib_key_monitor_type(row["monitor_type"]): - _cancel_fib_monitor_limit(row) - insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") - cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) - conn.commit() - conn.close() - return jsonify({"ok": cur.rowcount > 0}) - - -@app.route("/delete_key_history/", methods=["POST"]) -@login_required -def delete_key_history(hid): - conn = get_db() - cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) - conn.commit() - conn.close() - return jsonify({"ok": cur.rowcount > 0}) - - -@app.route("/del_key/") -@login_required -def del_key(id): - conn = get_db() - row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() - if row: - if is_fib_key_monitor_type(row["monitor_type"]): - _cancel_fib_monitor_limit(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,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," - "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," - "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason," - "exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at " - "FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " - "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id ASC", - (start_bj, end_bj), - ).fetchall() - conn.close() - head = [ - "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", - "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", - "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", - "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", - "exchange_realized_pnl", "exchange_opened_at", "exchange_closed_at", "created_at", "开仓类型", - ] - 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 "" - kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" - eff = er1 or er0 or entry_reason_from_key_signal(kst) or "" - snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] - data.append(( - r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], - snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], - r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], - r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], - r["exchange_realized_pnl"] if "exchange_realized_pnl" in r.keys() else None, - r["exchange_opened_at"] if "exchange_opened_at" in r.keys() else None, - r["exchange_closed_at"] if "exchange_closed_at" in r.keys() else None, - r["created_at"], eff, - )) - day = app_now().strftime("%Y%m%d") - return _csv_response(f"trade_records_v3_{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(): - 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,upper,lower,notification_count,last_alert_message,close_reason,closed_at " - "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", - (start_bj, end_bj), - ).fetchall() - conn.close() - head = [ - "id", - "symbol", - "monitor_type", - "direction", - "upper", - "lower", - "notification_count", - "last_alert_message", - "close_reason", - "closed_at", - ] - data = [tuple(r[h] for h in head) for r in rows] - day = app_now().strftime("%Y%m%d") - return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) - -@app.route("/del_order/") -@login_required -def del_order(id): - conn = get_db() - row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() - if not row: - conn.close() - flash("订单不存在") - return redirect("/") - if row["status"] == "active": - try: - p = get_price(row["symbol"]) or float(row["trigger_price"]) - opened_at = get_opened_at_value(row) - closed_at = app_now_str() - hold_seconds = calc_hold_seconds(opened_at, app_now()) - pnl_amount = calc_pnl( - row["direction"], - row["trigger_price"], - p, - row["margin_capital"] or DAILY_START_CAPITAL, - row["leverage"] or infer_leverage(row["symbol"]) - ) - close_resp = close_exchange_order(row) - close_order_id = close_resp.get("id", "") - cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) - session_date = row["session_date"] or get_trading_day() - session_capital = update_session_capital(conn, session_date, pnl_amount) - row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row - insert_trade_record( - conn, - symbol=row["symbol"], - monitor_type=order_row_monitor_type(row), - key_signal_type=order_row_key_signal_type(row), - 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=margin_capital_for_trade_record(row_snap), - 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)) - clear_key_sizing_snapshot_if_flat(conn, session_date) - 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("/trade") - 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) - row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row - insert_trade_record( - conn, - symbol=row["symbol"], - monitor_type=order_row_monitor_type(row), - key_signal_type=order_row_key_signal_type(row), - 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=margin_capital_for_trade_record(row_snap), - 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") - sym_in = normalize_symbol_input(d.get("symbol")) - ex_sym = normalize_exchange_symbol(sym_in) - try: - ensure_markets_loaded() - except Exception: - pass - try: - tp_px = round_price_to_exchange(ex_sym, float(d["tp"])) - sl_px = round_price_to_exchange(ex_sym, float(d["sl"])) - tgt_px = round_price_to_exchange(ex_sym, float(d["tgt"])) - except Exception: - flash("价格格式错误") - return redirect("/records") - conn = get_db() - insert_trade_record( - conn, - symbol=sym_in, - monitor_type=d["type"], - direction=direction, - trigger_price=tp_px, - stop_loss=sl_px, - take_profit=tgt_px, - 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]}" - close_ms = _local_input_datetime_to_ms(d.get("close_datetime")) - marker_payload = { - "exit_ts_ms": close_ms, - "entry_ts_ms": close_ms, - "entry_price": d.get("entry_price_hint"), - "exit_price": None, - } - try: - chart_fname = f"journal_{entry_id}.png" - saved = generate_multi_timeframe_chart_png( - exchange_symbol, - title_prefix, - timeframes=ORDER_CHART_TFS, - limit=ORDER_CHART_LIMIT, - out_dir=app.config["UPLOAD_FOLDER"], - filename=chart_fname, - filename_prefix="journal", - marker_payload=marker_payload, - marker_timeframes=( - {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()} - if ORDER_CHART_TFS - else {"5m", "15m", "1h", "4h"} - ), - ) - if saved: - image_filename = saved - chart_msg = f"已生成多周期K线图:/static/images/{saved}" - if uploaded_tmp: - try: - old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) - if os.path.exists(old_path): - os.remove(old_path) - except Exception: - pass - else: - chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Gate 网络/代理是否正常。" - except Exception as e: - image_filename = uploaded_tmp - chart_msg = f"自动生成K线图失败:{str(e)}" - - conn = get_db() - conn.execute( - """INSERT INTO journal_entries - (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, - expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, - mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, - new_trade_while_occupied, note, image) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - entry_id, d.get("open_datetime"), d.get("close_datetime"), hold_duration, d.get("coin"), d.get("tf"), - d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, - early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, - None, None, None, mood_issues, - d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename - ) - ) - conn.commit() - conn.close() - if chart_msg: - flash(f"交易复盘记录已保存。{chart_msg}") - else: - flash("交易复盘记录已保存") - return redirect("/records") - - -@app.route("/api/journals") -@login_required -def api_journals(): - 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 journal_entries WHERE COALESCE(close_datetime, created_at, open_datetime) >= ? " - "AND COALESCE(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/", 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/") -@login_required -def export_review_md(rid): - conn = get_db() - row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() - conn.close() - if not row: - return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") - - review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" - target_date = row["target_date"] or "-" - created_at = row["created_at"] or app_now_str() - content = (row["content"] or "").strip() - if not content: - content = "(无内容)" - - md = ( - f"# {review_type}报告\n\n" - f"- 目标日期: {target_date}\n" - f"- 生成时间: {created_at}\n" - f"- 报告ID: {row['id']}\n\n" - f"---\n\n" - f"{content}\n" - ) - - safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" - safe_type = "daily" if row["review_type"] == "daily" else "weekly" - filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" - return _md_response(filename, md) - - -@app.route("/export/reviews_md_bundle") -@login_required -def export_reviews_md_bundle(): - review_type = (request.args.get("review_type") or "").strip().lower() - target_date = (request.args.get("target_date") or "").strip() - if review_type not in ("daily", "weekly"): - return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") - if not target_date: - return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") - - conn = get_db() - rows = conn.execute( - "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", - (review_type, target_date), - ).fetchall() - conn.close() - if not rows: - return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") - - title = "日复盘" if review_type == "daily" else "周复盘" - lines = [ - f"# {title}汇总报告", - "", - f"- 目标日期: {target_date}", - f"- 条目数量: {len(rows)}", - f"- 导出时间: {app_now_str()}", - "", - "---", - "", - ] - for idx, row in enumerate(rows, 1): - created_at = row["created_at"] or "-" - content = (row["content"] or "").strip() or "(无内容)" - lines.extend( - [ - f"## 第{idx}条", - "", - f"- 报告ID: {row['id']}", - f"- 生成时间: {created_at}", - "", - content, - "", - "---", - "", - ] - ) - md = "\n".join(lines) - safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" - filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" - return _md_response(filename, md) - - -@app.route("/delete_review/", methods=["POST"]) -@login_required -def delete_review(rid): - conn = get_db() - conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) - conn.commit() - conn.close() - return jsonify({"ok": True}) - - -@app.route("/delete_trade_record/", methods=["POST"]) -@login_required -def delete_trade_record(rid): - conn = get_db() - cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) - conn.commit() - conn.close() - return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) - - -@app.route("/api/trade_record_review_update", methods=["POST"]) -@login_required -def api_trade_record_review_update(): - payload = request.get_json(silent=True) or {} - rec_id = payload.get("id") - try: - rec_id = int(rec_id) - except Exception: - return jsonify({"ok": False, "msg": "记录ID无效"}), 400 - - reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() - reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() - reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") - reviewed_take_profit_raw = payload.get("reviewed_take_profit") - reviewed_result = str(payload.get("reviewed_result") or "").strip() - reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() - reviewed_pnl_raw = payload.get("reviewed_pnl_amount") - - if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: - return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400 - - try: - reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") - reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") - except Exception: - return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 - if reviewed_close_dt < reviewed_open_dt: - return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 - hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) - hold_minutes = calc_hold_minutes(hold_seconds) - - try: - reviewed_pnl_amount = float(reviewed_pnl_raw) - except Exception: - return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 - reviewed_stop_loss = None - if reviewed_stop_loss_raw not in (None, ""): - try: - reviewed_stop_loss = float(reviewed_stop_loss_raw) - except Exception: - return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 - reviewed_take_profit = None - if reviewed_take_profit_raw not in (None, ""): - try: - reviewed_take_profit = float(reviewed_take_profit_raw) - except Exception: - return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 - - _MISSING_ER = object() - reviewed_entry_reason_update = _MISSING_ER - if "reviewed_entry_reason" in payload: - s = str(payload.get("reviewed_entry_reason") or "").strip() - if s and not entry_reason_valid_for_storage(s): - return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400 - reviewed_entry_reason_update = s or None - - conn = get_db() - row = conn.execute("SELECT risk_amount, symbol FROM trade_records WHERE id=?", (rec_id,)).fetchone() - if not row: - conn.close() - return jsonify({"ok": False, "msg": "记录不存在"}), 404 - risk_amount = row["risk_amount"] - ex_review = resolve_ccxt_price_symbol(row["symbol"]) - try: - ensure_markets_loaded() - except Exception: - pass - if reviewed_stop_loss is not None: - reviewed_stop_loss = round_price_to_exchange(ex_review, reviewed_stop_loss) - if reviewed_take_profit is not None: - reviewed_take_profit = round_price_to_exchange(ex_review, reviewed_take_profit) - actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) - base_params = [ - reviewed_opened_at, - reviewed_closed_at, - reviewed_stop_loss, - reviewed_take_profit, - round(reviewed_pnl_amount, 4), - reviewed_result or None, - reviewed_miss_reason or None, - hold_seconds, - hold_minutes, - app_now_str(), - actual_rr, - ] - if reviewed_entry_reason_update is not _MISSING_ER: - conn.execute( - """UPDATE trade_records - SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, - reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, - reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? - WHERE id=?""", - tuple(base_params + [reviewed_entry_reason_update, rec_id]), - ) - else: - conn.execute( - """UPDATE trade_records - SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, - reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, - reviewed_at=?, actual_rr=COALESCE(?, actual_rr) - WHERE id=?""", - tuple(base_params + [rec_id]), - ) - conn.commit() - conn.close() - return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) - - -@app.route("/manual_transfer", methods=["POST"]) -@login_required -def manual_transfer(): - try: - amount = float(request.form.get("amount", "0")) - except Exception: - flash("划转金额格式错误") - return redirect("/") - from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() - to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() - ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) - conn = get_db() - conn.execute( - "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", - ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) - ) - conn.commit() - conn.close() - if ok: - flash(f"手动划转成功:{amount}U {from_account}->{to_account}") - else: - flash(f"手动划转失败:{msg}") - return redirect("/") - - -@app.route("/ai_daily_review", methods=["POST"]) -@login_required -def ai_daily_review(): - date = request.form.get("date", "") - conn = get_db() - rows = conn.execute( - "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", - (date,) - ).fetchall() - conn.close() - if not rows: - return jsonify({"result": "该日无交易记录"}) - - text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" - for idx, row in enumerate(rows, 1): - text += _journal_row_lines_for_ai(idx, row) - text += "\n" - - image_paths = [] - for row in rows: - img = row["image"] - if not img: - continue - img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) - if os.path.exists(img_path): - image_paths.append(img_path) - ai_result = ai_review(text, "每日", image_paths=image_paths) - full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" - conn = get_db() - conn.execute( - "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", - (uuid.uuid4().hex, "daily", date, full) - ) - conn.commit() - conn.close() - return jsonify({"result": full}) - - -@app.route("/ai_weekly_review", methods=["POST"]) -@login_required -def ai_weekly_review(): - start_date = request.form.get("start_date", "") - end_date = request.form.get("end_date", "") - conn = get_db() - rows = conn.execute( - "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", - (start_date, end_date) - ).fetchall() - conn.close() - if not rows: - return jsonify({"result": "该时间段无交易记录"}) - - text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" - for idx, row in enumerate(rows, 1): - text += _journal_row_lines_for_ai(idx, row) - text += "\n" - - image_paths = [] - for row in rows: - img = row["image"] - if not img: - continue - img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) - if os.path.exists(img_path): - image_paths.append(img_path) - ai_result = ai_review(text, "周度", image_paths=image_paths) - full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" - conn = get_db() - conn.execute( - "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", - (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) - ) - conn.commit() - conn.close() - return jsonify({"result": full}) - -# 启动 -if __name__ == "__main__": - threading.Thread(target=background_task, daemon=True).start() - app.run(host=HOST, port=PORT, debug=DEBUG) +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 fib_key_monitor_lib import ( + FIB_KEY_MONITOR_TYPES, + KEY_ENTRY_REASON_BY_SIGNAL, + calc_fib_plan, + entry_reason_from_key_signal, + fib_invalidate_by_mark, + fib_ratio_from_type, + is_fib_key_monitor_type, + key_signal_type_for_trade_record, + stored_key_signal_type, +) +from key_sl_tp_lib import ( + breakeven_enabled_from_row, + normalize_sl_tp_mode, + parse_breakeven_enabled_form, + plan_key_sl_tp, + sl_tp_mode_from_row, + sl_tp_mode_label, + sl_tp_plan_summary_text, +) +from history_window_lib import ( + PRESET_CUSTOM, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST7D, + PRESET_UTC_TODAY, + list_window_redirect_query, + resolve_list_window, + resolve_window, + 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") + +# ====================== 登录配置 ====================== +USERNAME = os.getenv("APP_USERNAME", "dekun") +PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") +AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") + +# 企业微信机器人Webhook +WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") +SYSTEM_TYPE = "CRYPTO" +HOST = os.getenv("APP_HOST", "0.0.0.0") +PORT = int(os.getenv("APP_PORT", "5000")) +DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" +DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) + +# 训练参数(可由 .env 覆盖) +DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) +DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) +DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) +BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) +ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) +# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" +).lower() in ("1", "true", "yes", "on") +APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") + + +def _resolve_app_tz(): + if ZoneInfo is not None: + try: + return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) + except Exception: + pass + return timezone(timedelta(hours=8)) + + +APP_TZ = _resolve_app_tz() +LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" +GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip() +GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip() +GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower() +GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower() +# 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平) +GATE_TPSL_TRIGGER_EXPIRATION = int(os.getenv("GATE_TPSL_TRIGGER_EXPIRATION", str(7 * 86400))) +GATE_TPSL_PRICE_TYPE = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0")) +if GATE_TPSL_PRICE_TYPE < 0 or GATE_TPSL_PRICE_TYPE > 2: + GATE_TPSL_PRICE_TYPE = 0 +GATE_TPSL_USE_POSITION_ORDER = os.getenv("GATE_TPSL_USE_POSITION_ORDER", "true").lower() in ("1", "true", "yes") +# 页面展示的交易所名称(多实例/多环境时可按需区分) +EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Gate.io").strip() or "Gate.io" +_GATE_DEFAULT_MARGIN_MODE = "cross" if GATE_TD_MODE in ("cross", "cross_margin") else "isolated" +BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) +PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) +KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) +KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) +KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) +KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5")) +KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1")) +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"))) +KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20"))) +KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3")) +KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03")) +KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5")) +KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))) +KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2")) +KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1")) +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true" +ORDER_MONITOR_TYPE_MANUAL = "下单监控" +ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" +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_EXCHANGE_PNL_SYNC_AT = 0.0 + +KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) +KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) +AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" +AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) +AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") +AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") +FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" +FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) +# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 +AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) +AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) +MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) +KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") +FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) +TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT") +UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) +ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" +ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] +ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) +ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) +DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5")) +RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) +BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) +BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) +BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) +DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() +OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate") +AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") + +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), 2)}U" + if fallback is not None: + try: + return f"{round(float(fallback), 2)}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_wechat_scalar_2dp(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, 2)} 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_wechat_scalar_2dp(new_sl) + return "\n".join( + [ + f"# 🛡️ {symbol} 保护位更新", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 移动保本/止盈", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 类型:**{arm_txt}**", + f"- 当前RR:`{round(float(now_rr), 2)}R`", + f"- 锁定RR:`{round(float(locked_r), 2)}R`", + f"- 新保护位:`{sl_fmt}`", + ] + ) + + +def build_wechat_monitor_error_message(symbol, direction, scene, error_text): + return "\n".join( + [ + f"# ⚠️ {symbol} 下单监控异常", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 异常信息", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", + ] + ) + + +def build_wechat_key_monitor_message( + symbol, + direction, + monitor_type, + trigger_time, + key_price, + confirm_close, + hard_lines, + btc8h_status, + coin4h_status, + swing4h_pct, + op_lines, + risk_tip=None, +): + lines = [ + f"# 🎯 {symbol} 关键位确认推送", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 交易对 / 触发时间", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", + "", + "### 方向与确认K", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", + "", + "### 关键价位", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", + "", + "### 硬条件校验结果", + ] + lines.extend([f"- {x}" for x in hard_lines]) + lines.extend( + [ + "", + "### 市场状态说明", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + "", + "### 操作提示", + ] + ) + lines.extend([f"- {x}" for x in op_lines]) + if risk_tip: + lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) + return "\n".join(lines) + + +def _read_image_base64(image_path): + try: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + except Exception: + return None + + +def _extract_json_object(text): + if not text: + return None + clean = text.strip() + if clean.startswith("```"): + clean = clean.replace("```json", "").replace("```", "").strip() + try: + return json.loads(clean) + except Exception: + pass + match = re.search(r"\{[\s\S]*\}", clean) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: + return None + + +def _journal_row_lines_for_ai(idx, row, *, include_hold_duration=True): + """把 journal 字段拼成给 AI 的文本;字段之外的事实不要指望模型自己猜。""" + def nz(v, default="无"): + if v is None: + return default + s = str(v).strip() + return s if s else default + + lines = [ + f"{idx}. {nz(row['coin'])} {nz(row['tf'])} | 盈亏:{nz(row['pnl'])}U | 实际RR:{nz(row['real_rr'])} | 预期RR:{nz(row['expect_rr'])}", + f" 开仓逻辑:{nz(row['entry_reason'])}", + f" 平仓/离场(交易员自述):{nz(row['exit_reason'])}", + ] + if include_hold_duration: + lines.append(f" 持仓时长:{nz(row['hold_duration'])}") + ee_bits = [ + nz(row["early_exit"]), + nz(row["early_exit_reason"]), + nz(row["early_exit_trigger"]), + nz(row["early_exit_note"]), + ] + if any(x != "无" for x in ee_bits): + lines.append( + " 提前离场记录:" + f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}" + ) + mood_bits = f"心态标签:{nz(row['mood_issues'])}" + if row["mood_score"] is not None: + mood_bits += f" | 自评心态分:{row['mood_score']}" + lines.append(f" {mood_bits}") + if nz(row["post_breakeven_stare"]) != "无": + lines.append(f" 保本后盯盘:{nz(row['post_breakeven_stare'])}") + if nz(row["new_trade_while_occupied"]) != "无": + lines.append(f" 占用时新开仓:{nz(row['new_trade_while_occupied'])}") + if nz(row["note"]) != "无": + lines.append(f" 备注:{nz(row['note'])}") + return "\n".join(lines) + "\n" + + +def ai_review(trades_text, period_title, image_paths=None): + prompt = f""" +你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。 + +【硬性规则 — 必须遵守】 +- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。 +- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。 +- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。 +- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。 +- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。 +- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。 + +【输出结构】 +1. 总体盈亏结构(紧扣笔数、盈亏数字与 RR,少形容词) +2. 心态与执行(每笔 1–10 分 + 一句依据;依据必须对应记录字段) +3. 行为标签(提前离场 / 乱开仓 / 扛单等):仅在有字段或自述支撑时点名;否则写「记录未勾选或未描述,不作强加」 +4. 改进建议(最多 3 条,每条具体可执行) +5. 图表(若有且可读):结合价格行为简述;否则一两句说明无法看图分析 + +交易记录: +{trades_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + images = [] + for p in image_paths or []: + b64 = _read_image_base64(p) + if b64: + images.append(b64) + if images: + payload["images"] = images + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return r.json().get("response", "AI 生成失败") + except Exception as e: + return f"AI 调用失败:{str(e)}" + + +def ai_short_advice(prompt_text): + prompt = f""" +你是交易风控助理。请用中文给出**最多 3 条**提醒,要求: +- 每条不超过 25 个字 +- 语气克制、具体、可执行 +- 不要输出 Markdown,不要编号前缀以外的废话 + +场景: +{prompt_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return (r.json().get("response") or "").strip() + except Exception: + return "" + + +def _load_font(size): + if not ImageFont: + return None + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\arial.ttf", + ] + for path in candidates: + if path and os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except Exception: + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def _ohlcv_to_rows(ohlcv): + rows = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + rows.append( + { + "ts": int(bar[0]), + "o": float(bar[1]), + "h": float(bar[2]), + "l": float(bar[3]), + "c": float(bar[4]), + "v": float(bar[5]), + } + ) + except Exception: + continue + return rows + + +def _local_input_datetime_to_ms(dt_text): + raw = str(dt_text or "").strip() + if not raw: + return None + raw = raw.replace("T", " ") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(raw, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + return None + + +def _pick_marker_point(rows, target_ts_ms, target_price=None): + if not rows or target_ts_ms is None: + return None, None + idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) + if target_price is not None: + try: + p = float(target_price) + if p > 0: + return idx, p + except Exception: + pass + return idx, float(rows[idx]["c"]) + + +def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): + if not Image or not ImageDraw: + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") + img = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(img) + font = _load_font(14) + small = _load_font(12) + + pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 + plot_w = max(10, width - pad_l - pad_r) + plot_h = max(10, height - pad_t - pad_b) + + header_bg = (245, 247, 250) + draw.rectangle((0, 0, width, pad_t), fill=header_bg) + if font: + draw.text((10, 6), title, fill=(25, 35, 60), font=font) + else: + draw.text((10, 6), title, fill=(25, 35, 60)) + + if not rows: + if small: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) + else: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) + return img + + lo = min(r["l"] for r in rows) + hi = max(r["h"] for r in rows) + if hi <= lo: + hi = lo + 1e-12 + + n = len(rows) + marker_by_idx = {} + for mp in marker_points or []: + try: + idx = int(mp.get("idx")) + except Exception: + continue + if idx < 0 or idx >= n: + continue + marker_by_idx[idx] = mp + + x0 = pad_l + for i, r in enumerate(rows): + x1 = pad_l + int((i + 1) * plot_w / n) + x_mid = (x0 + x1) // 2 + wick_x = x_mid + y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) + y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) + y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) + y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) + top = min(y_open, y_close) + bot = max(y_open, y_close) + up = r["c"] >= r["o"] + wick_color = (120, 120, 120) + edge_color = (20, 20, 20) + draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) + body_w = max(1, (x1 - x0) - 2) + left = x0 + 1 + if bot - top < 2: + mid = (top + bot) // 2 + draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) + else: + if up: + draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) + else: + draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) + if i in marker_by_idx: + mp = marker_by_idx[i] + tag = str(mp.get("tag") or "") + m_price = float(mp.get("price") or r["c"]) + y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) + y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) + if tag == "ENTRY": + m_color = (0, 195, 95) + tri = [(x_mid, y_m - 20), (x_mid - 9, y_m - 4), (x_mid + 9, y_m - 4)] + text_y = y_m - 36 + else: + m_color = (235, 65, 65) + tri = [(x_mid, y_m + 20), (x_mid - 9, y_m + 4), (x_mid + 9, y_m + 4)] + text_y = y_m + 12 + draw.ellipse((x_mid - 5, y_m - 5, x_mid + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) + draw.polygon(tri, fill=m_color) + draw.line((x_mid, y_m, x_mid, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) + if font: + draw.text((x_mid + 8, text_y), tag, fill=m_color, font=font) + else: + draw.text((x_mid + 8, text_y), tag, fill=m_color) + x0 = x1 + + if len(marker_points or []) >= 2: + try: + entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) + exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) + if entry is not None and exitp is not None: + ex_i, ex_p = int(entry["idx"]), float(entry["price"]) + xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) + x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) + x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) + y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) + y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) + draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) + except Exception: + pass + + # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 + if small: + draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) + return img + + +def _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 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 + 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() + marker_tfs = {str(x).strip().lower() for x in (marker_timeframes or []) if str(x).strip()} + if marker_payload and tf_key in marker_tfs: + entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")) + exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")) + if entry_idx is not None and entry_price is not None: + points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"}) + if exit_idx is not None and exit_price is not None: + points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"}) + panels.append( + _render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + ) + ) + + if not panels: + return None + + gap = 10 + cols = 2 + rows_n = int(math.ceil(len(panels) / cols)) + w = cols * cell_w + (cols - 1) * gap + h = rows_n * cell_h + (rows_n - 1) * gap + out = Image.new("RGB", (w, h), (255, 255, 255)) + idx = 0 + for r in range(rows_n): + for c in range(cols): + if idx >= len(panels): + break + x = c * (cell_w + gap) + y = r * (cell_h + gap) + out.paste(panels[idx], (x, y)) + idx += 1 + + # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图) + if ImageDraw and rows_n >= 1: + draw_out = ImageDraw.Draw(out) + line_col = (220, 225, 232) + x_mid = cell_w + gap // 2 + if w > x_mid >= 0: + draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2) + for rr in range(1, rows_n): + y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 + if 0 <= y_mid <= h: + draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) + + target_dir = out_dir or ORDER_CHART_DIR + os.makedirs(target_dir, exist_ok=True) + fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" + out_path = os.path.join(target_dir, fname) + out.save(out_path, format="PNG") + return fname + + +def generate_order_open_chart(exchange_symbol, title_prefix, timeframes=None, limit=None): + return generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=timeframes, + limit=limit, + out_dir=ORDER_CHART_DIR, + filename=None, + filename_prefix="order", + ) + + +def journal_coin_from_symbol(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym.split("/")[0].strip() + if "-" in sym: + return sym.split("-")[0].strip() + if sym.endswith("USDT"): + return sym[:-4].strip() + return sym + + +EARLY_EXIT_TRIGGERS = ( + "", + "止盈", + "保本止盈", + "移动止盈", + "手动平仓", + "止损", + "其他", +) + +# 与用户约定的固定开仓类型 +ENTRY_REASON_OPTIONS = ( + "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", + "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", + "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", + "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", + "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", + "关键位箱体突破", + "关键位收敛突破", + "关键位斐波0.618", + "关键位斐波0.786", +) + +STATS_SEGMENT_DEFS = ( + ("all", "全部交易", {"segment": "all"}), + ("manual", "下单监控", {"segment": "manual"}), + ("key_box", "关键位箱体突破", {"segment": "key_box"}), + ("key_conv", "关键位收敛结构", {"segment": "key_conv"}), + ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), + ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), +) +# 复盘表单「其他」选项的 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, + exchange_margin_usdt REAL, + 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("ALTER TABLE order_monitors ADD COLUMN exchange_margin_usdt REAL") + except Exception: + pass + try: + c.execute(f"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '{ORDER_MONITOR_TYPE_MANUAL}'") + except Exception: + pass + try: + c.execute( + "UPDATE order_monitors SET monitor_type=? WHERE monitor_type IS NULL OR TRIM(monitor_type)=''", + (ORDER_MONITOR_TYPE_MANUAL,), + ) + except Exception: + pass + try: + c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") + except: pass + for ddl in ( + "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", + "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", + "ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'", + "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", + ): + try: + c.execute(ddl) + except Exception: + pass + try: + c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") + except Exception: + pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") + except Exception: + pass + for ddl in ( + "ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL", + "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT", + ): + try: + c.execute(ddl) + except Exception: + pass + + c.execute( + """CREATE TABLE IF NOT EXISTS key_monitor_history + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, + close_reason TEXT, closed_at TEXT)""" + ) + + conn.commit() + conn.close() + +init_db() + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def app_now(): + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" + return datetime.now(APP_TZ).replace(tzinfo=None) + + +def app_now_str(): + return app_now().strftime("%Y-%m-%d %H:%M:%S") + + +def utc_now_dt(): + """当前时刻(UTC,aware)。""" + return datetime.now(timezone.utc) + + +def utc_calendar_date_str(): + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" + return utc_now_dt().strftime("%Y-%m-%d") + + +def get_trading_day(now=None): + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" + now = now or app_now() + if getattr(now, "tzinfo", None): + now = now.astimezone(APP_TZ).replace(tzinfo=None) + if now.hour < TRADING_DAY_RESET_HOUR: + return (now - timedelta(days=1)).strftime("%Y-%m-%d") + return now.strftime("%Y-%m-%d") + + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓") + + +def parse_dt_for_trading_day(s): + if not s: + return None + s = str(s).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:ln], fmt) + except ValueError: + continue + return None + + +def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): + conn.execute( + """INSERT INTO key_monitor_history + (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + row["symbol"], + row["monitor_type"], + row["direction"] or "long", + row["upper"], + row["lower"], + int(notification_count or 0), + (last_msg or "")[:800] if last_msg else None, + close_reason, + app_now_str(), + ), + ) + + +def _session_week_bounds(trading_day_str): + end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() + start = end - timedelta(days=6) + return start.strftime("%Y-%m-%d"), trading_day_str + + +def _calendar_month_bounds(local_dt): + y, m = local_dt.year, local_dt.month + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end_d = datetime(y, 12, 31).date() + else: + end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() + return start, end_d.strftime("%Y-%m-%d") + + +def _count_opens_between(conn, start_td, end_td): + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _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 _pnl_row_matches_segment(row, segment_key): + try: + mt = (row["monitor_type"] or "").strip() + kst = (row["key_signal_type"] or "").strip() + except Exception: + return False + if segment_key == "all": + return True + if segment_key == "manual": + return mt == ORDER_MONITOR_TYPE_MANUAL and not kst + if segment_key == "key_box": + return kst == "箱体突破" + if segment_key == "key_conv": + return kst == "收敛突破" + if segment_key == "key_fib618": + return kst == "斐波回调0.618" + if segment_key == "key_fib786": + return kst == "斐波回调0.786" + return False + + +def _count_opens_for_segment(conn, start_td, end_td, segment_key): + if segment_key == "manual": + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " + "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", + (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), + ).fetchone()[0] + kst_map = { + "key_box": "箱体突破", + "key_conv": "收敛突破", + "key_fib618": "斐波回调0.618", + "key_fib786": "斐波回调0.786", + } + kst = kst_map.get(segment_key) + if kst: + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?", + (start_td, end_td, kst), + ).fetchone()[0] + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _load_completed_trade_pnls(conn): + q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, + result, reviewed_result, monitor_type, key_signal_type + FROM trade_records + ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" + rows = conn.execute(q).fetchall() + out = [] + for r in rows: + effective_result = (r["reviewed_result"] or r["result"] or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + continue + try: + p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + except (TypeError, ValueError): + p = 0.0 + t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) + td = get_trading_day(t) if t else None + out.append((p, t, td, r)) + 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), 2) + loss_sum_raw = sum(p for p, _, _ in trades if p < 0) + loss_sum_u = round(abs(loss_sum_raw), 2) 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), 2) if neg_pnls else None + max_single_profit = round(max(pos_pnls), 2) 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, 2) + 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], 2) + 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): + """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" + now_dt = now_dt or app_now() + pnls = _load_completed_trade_pnls(conn) + total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] + w_start, w_end = _session_week_bounds(trading_day) + m_start, m_end = _calendar_month_bounds(now_dt) + + def in_week(tr): + return tr[2] and w_start <= tr[2] <= w_end + + def in_month(tr): + return tr[2] and m_start <= tr[2] <= m_end + + def slice_metrics(seg_key): + seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)] + day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] + week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] + month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] + dm = _compute_period_metrics(day_tr) + wm = _compute_period_metrics(week_tr) + mm = _compute_period_metrics(month_tr) + dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) + wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) + mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) + dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" + wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" + mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" + return dm, wm, mm + + segments = [] + for seg_key, seg_title, _meta in STATS_SEGMENT_DEFS: + dm, wm, mm = slice_metrics(seg_key) + segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm}) + + dm, wm, mm = slice_metrics("all") + + return { + "trading_day": trading_day, + "total_opens_all": total_opens_all, + "day": dm, + "week": wm, + "month": mm, + "segments": segments, + "stats_reset_hour": TRADING_DAY_RESET_HOUR, + } + + +def infer_leverage(symbol): + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return BTC_LEVERAGE + return ALT_LEVERAGE + + +def normalize_exchange_symbol(symbol): + sym = symbol.strip().upper() + if ":" in sym: + return sym + if "/" in sym: + base, quote = sym.split("/", 1) + quote_clean = quote.split(":")[0] + return f"{base}/{quote_clean}:{quote_clean}" + return sym + + +def resolve_monitor_exchange_symbol(row): + """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" + raw = "" + try: + if row["exchange_symbol"]: + raw = str(row["exchange_symbol"]).strip() + except (KeyError, IndexError, TypeError): + raw = "" + if not raw: + try: + raw = str(row["symbol"] or "").strip() + except (KeyError, IndexError, TypeError): + raw = "" + return normalize_exchange_symbol(raw) if raw else "" + + +def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): + if not position_symbol or not wanted_exchange_symbol: + return False + a = normalize_exchange_symbol(str(position_symbol).strip()) + b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip()) + return a == b + + +def _position_matches_wanted_contract(wanted_unified_sym, position_dict): + """统一 symbol 比对;不一致时用 Gate 原始 contract 与 ccxt market.id 对齐(兼容 1000PEPE 等命名差异)。""" + if not wanted_unified_sym or not position_dict: + return False + ps = position_dict.get("symbol") + if _position_contract_symbol_match(ps, wanted_unified_sym): + return True + try: + ensure_markets_loaded() + mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper() + info = position_dict.get("info") or {} + c_raw = str(info.get("contract") or "").strip().upper() + if mid and c_raw and mid == c_raw: + return True + except Exception: + pass + return False + + +def _position_row_effective_contracts(p): + """张数:优先 ccxt contracts,否则用 Gate 原始 size/pos(避免统一层为 0 时被误判空仓)。""" + if not p: + return 0.0 + info = p.get("info") or {} + for val in (p.get("contracts"), info.get("size"), info.get("pos")): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return x + except (TypeError, ValueError): + continue + return 0.0 + + +def normalize_symbol_input(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym + if ":" in sym: + sym = sym.split(":")[0] + return f"{sym}/USDT" + + +def normalize_kline_limit(limit_raw, default=200): + try: + n = int(limit_raw) + except Exception: + return default + return 200 if n >= 200 else 100 + + +def get_recommended_capital(current_capital): + if current_capital <= DAILY_LOSS_CAPITAL: + return DAILY_LOSS_CAPITAL + if current_capital >= DAILY_PROFIT_CAPITAL: + return DAILY_PROFIT_CAPITAL + return DAILY_START_CAPITAL + + +def ensure_session(conn, session_date): + row = conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + if row: + return row + conn.execute( + "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", + (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) + ) + conn.commit() + return conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + + +def update_session_capital(conn, session_date, pnl_amount): + session_row = ensure_session(conn, session_date) + new_capital = float(session_row["current_capital"]) + float(pnl_amount) + conn.execute( + "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(new_capital, 4), session_date) + ) + conn.commit() + return round(new_capital, 4) + + +def calc_hold_seconds(opened_at_str, closed_at_dt): + try: + opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") + return int((closed_at_dt - opened_at).total_seconds()) + except Exception: + return 0 + + +def calc_hold_minutes(seconds): + if not seconds or seconds <= 0: + return 0 + return max(1, int(seconds // 60)) + + +def get_opened_at_value(row): + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "opened_at" in keys: + value = row["opened_at"] + if value: + return value + return app_now_str() + + +def get_effective_trade_field(row, reviewed_key, base_key, default=None): + try: + keys = row.keys() if hasattr(row, "keys") else row.keys() + except Exception: + keys = [] + if reviewed_key in keys: + v = row[reviewed_key] + if v is not None and str(v).strip() != "": + return v + if base_key in keys: + v = row[base_key] + if v is not None and str(v).strip() != "": + return v + return default + + +def to_effective_trade_dict(row): + item = row_to_dict(row) + base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") + item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) + item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) + open_stop = item.get("initial_stop_loss") + if open_stop in (None, ""): + open_stop = base_stop + item["display_open_stop_loss"] = open_stop + item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_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 "" + try: + _keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + _keys = [] + _reviewed_pnl_raw = row["reviewed_pnl_amount"] if "reviewed_pnl_amount" in _keys else None + has_reviewed_pnl = _reviewed_pnl_raw is not None and str(_reviewed_pnl_raw).strip() != "" + ex_pnl = item.get("exchange_realized_pnl") + if not has_reviewed_pnl and ex_pnl is not None and str(ex_pnl).strip() != "": + try: + item["effective_pnl_amount"] = round(float(ex_pnl), 2) + item["display_pnl_source"] = "exchange" + ex_open = (str(item.get("exchange_opened_at") or "").strip() or None) + ex_close = (str(item.get("exchange_closed_at") or "").strip() or None) + if ex_open: + item["effective_opened_at"] = ex_open + if ex_close: + item["effective_closed_at"] = ex_close + except (TypeError, ValueError): + item["display_pnl_source"] = "local" + elif has_reviewed_pnl: + item["display_pnl_source"] = "reviewed" + else: + item["display_pnl_source"] = "local" + return item + + +def format_price_magnitude_fallback(value): + """无 markets 或解析失败时的价格展示兜底(按量级)。""" + try: + v = float(value) + except Exception: + return str(value) + if v == 0: + return "0" + av = abs(v) + if av >= 10000: + d = 2 + elif av >= 100: + d = 3 + elif av >= 1: + d = 4 + elif av >= 0.01: + d = 6 + elif av >= 0.0001: + d = 8 + else: + d = 10 + text = f"{v:.{d}f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +def resolve_ccxt_price_symbol(symbol): + """将界面/库中的品种名转为 ccxt 永续合约 id(如 BTC/USDT -> BTC/USDT:USDT)。""" + s = (symbol or "").strip() + if not s: + return "" + if "/" not in s and ":" not in s: + s = f"{s.upper()}/USDT" + else: + s = s.upper() + return normalize_exchange_symbol(s) + + +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 format_price_for_symbol(symbol, value): + """价格展示:与交易所 price_to_precision 一致(与入库 round_price_to_exchange 对齐)。""" + if value in (None, ""): + return "-" + try: + v = float(value) + except Exception: + return str(value) + ex = resolve_ccxt_price_symbol(symbol) + if not ex: + return format_price_magnitude_fallback(v) + try: + ensure_markets_loaded() + return exchange.price_to_precision(ex, v) + except Exception: + return format_price_magnitude_fallback(v) + + +def format_usdt(value): + """USDT 资金类展示:固定两位小数。""" + if value in (None, ""): + return "-" + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return str(value) + + +def format_signed_usdt(value): + """USDT 盈亏等可正可负:+1.23 / -0.50 / 0.00""" + if value in (None, ""): + return "-" + try: + v = float(value) + except (TypeError, ValueError): + return str(value) + if v == 0: + return "0.00" + sign = "+" if v > 0 else "" + return f"{sign}{v:.2f}" + + +def format_wechat_scalar_2dp(value): + """企业微信推送:数值统一两位小数(与交易所 tick 无关)。""" + if value in (None, ""): + return "-" + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return str(value) + + +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, + key_signal_type=None, + entry_reason=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) + kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) + snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss + er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or "" + conn.execute( + "INSERT INTO trade_records (symbol,monitor_type,key_signal_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,entry_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, 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, er or None + ) + ) + + +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, 2) 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 + if not (item.get("monitor_type") or "").strip(): + item["monitor_type"] = ORDER_MONITOR_TYPE_MANUAL + 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 order_row_monitor_type(row): + if row is None: + return ORDER_MONITOR_TYPE_MANUAL + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "monitor_type" in keys: + mt = (row["monitor_type"] or "").strip() + if mt: + return mt + return ORDER_MONITOR_TYPE_MANUAL + + +def order_row_key_signal_type(row): + if row is None: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "key_signal_type" not in keys: + return None + kst = (row["key_signal_type"] or "").strip() + if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst): + return kst + return None + + +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, 2)}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}账户已达到目标{round(float(target_amount), 2)}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不足,需{round(needed, 2)}U,当前{round(from_balance, 2)}U") + ) + conn.commit() + conn.close() + send_wechat_msg( + f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{round(needed, 2)}U,当前{round(from_balance, 2)}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"自动划转成功:补足到{round(float(target_amount), 2)}U,实际划转{round(needed, 2)}U " + f"{AUTO_TRANSFER_FROM}->{AUTO_TRANSFER_TO}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + else: + send_wechat_msg( + f"自动划转失败:计划补足到{round(float(target_amount), 2)}U,需划转{round(needed, 2)}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): + return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) + + +def clear_key_sizing_snapshot_if_flat(conn, session_date): + if get_active_position_count(conn) > 0: + return + conn.execute( + "UPDATE trading_sessions SET key_sizing_capital_snapshot = NULL, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (session_date,), + ) + conn.commit() + + +def get_key_sizing_capital_snapshot(conn, session_date): + row = ensure_session(conn, session_date) + try: + val = row["key_sizing_capital_snapshot"] + except (KeyError, IndexError): + return None + if val is None: + return None + try: + return float(val) + except (TypeError, ValueError): + return None + + +def set_key_sizing_capital_snapshot(conn, session_date, capital): + ensure_session(conn, session_date) + conn.execute( + "UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(float(capital), 2), session_date), + ) + conn.commit() + + +def resolve_capital_base_for_key_open(conn, trading_day, live_capital): + live = float(live_capital) + active = get_active_position_count(conn) + if active <= 0: + set_key_sizing_capital_snapshot(conn, trading_day, live) + return live + if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT: + snap = get_key_sizing_capital_snapshot(conn, trading_day) + if snap is not None and snap > 0: + return snap + return live + + +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})" + if direction not in ("long", "short"): + return False, "方向必须为 long 或 short" + if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): + expected = BTC_LEVERAGE + else: + expected = ALT_LEVERAGE + if expected <= 0: + return False, "杠杆配置异常" + return True, "" + + +def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): + ensure_markets_loaded() + notional = float(margin_capital) * float(leverage) + ticker = exchange.fetch_ticker(exchange_symbol) + price = float(ticker.get("last") or fallback_price) + if price <= 0: + raise ValueError("触发价必须大于 0") + market = exchange.market(exchange_symbol) + contract_size = float(market.get("contractSize") or 1) + if market.get("contract"): + # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 + amount = notional / (price * contract_size) + else: + amount = notional / price + min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") + if min_amount and amount < float(min_amount): + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") + amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) + if amount_precise <= 0: + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + return amount_precise, price + + +def _to_positive_float(value): + try: + n = float(value) + return n if n > 0 else None + except Exception: + return None + + +def _extract_order_price_value(order_obj): + if not isinstance(order_obj, dict): + return None + for key in ("average", "price"): + v = _to_positive_float(order_obj.get(key)) + if v is not None: + return v + cost = _to_positive_float(order_obj.get("cost")) + filled = _to_positive_float(order_obj.get("filled")) + if cost is not None and filled is not None and filled > 0: + return cost / filled + info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} + for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): + v = _to_positive_float(info.get(key)) + if v is not None: + return v + return None + + +def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): + price = _extract_order_price_value(order_resp) + if price is not None: + return round(price, 8) + order_id = (order_resp or {}).get("id") + if order_id: + try: + fetched = exchange.fetch_order(order_id, exchange_symbol) + fetched_price = _extract_order_price_value(fetched) + if fetched_price is not None: + return round(fetched_price, 8) + except Exception: + pass + fallback = _to_positive_float(fallback_price) + return round(fallback, 8) if fallback is not None else 0.0 + + +def get_contract_size(exchange_symbol): + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + return float(market.get("contractSize") or 1) + + +def parse_positive_float(value): + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + num = float(raw) + if num <= 0: + raise ValueError("数值必须大于0") + return num + + +def build_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" + 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_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 _gate_list_trigger_open_orders(exchange_symbol): + params = _gate_swap_trigger_order_params() + try: + return exchange.fetch_open_orders(exchange_symbol, params=params) or [] + except Exception: + return [] + + +def _gate_order_trigger_price(order): + for key in ("stopPrice", "triggerPrice", "price"): + try: + v = float(order.get(key) or 0) + if v > 0: + return v + except Exception: + pass + info = order.get("info") or {} + if isinstance(info, dict): + trig = info.get("trigger") + if isinstance(trig, dict): + try: + v = float(trig.get("price") or 0) + if v > 0: + return v + except Exception: + pass + for key in ("trigger_price", "triggerPrice", "stopPrice", "price"): + try: + v = float(info.get(key) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def _gate_tpsl_role_from_order(order, direction): + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + ot = str(info.get("order_type") or info.get("orderType") or order.get("type") or "").lower() + if "take" in ot and "profit" in ot: + return "tp" + if "stop" in ot and "loss" in ot: + return "sl" + trig = info.get("trigger") + rule = None + if isinstance(trig, dict) and trig.get("rule") is not None: + try: + rule = int(trig["rule"]) + except Exception: + rule = None + if rule is None: + try: + rule = int(info.get("rule")) + except Exception: + rule = None + if rule is not None: + if direction == "long": + return "sl" if rule == 2 else ("tp" if rule == 1 else None) + return "sl" if rule == 1 else ("tp" if rule == 2 else None) + if order.get("stopLossPrice"): + return "sl" + if order.get("takeProfitPrice"): + return "tp" + typ = str(order.get("type") or "").upper() + if "TAKE" in typ: + return "tp" + if "STOP" in typ: + return "sl" + return None + + +def _gate_tpsl_slot_from_order(order, exchange_symbol): + trig = _gate_order_trigger_price(order) + try: + amt = float(order.get("amount") or order.get("remaining") or 0) + except Exception: + amt = None + if amt is not None and amt <= 0: + amt = None + oid = order.get("id") + if oid is None and isinstance(order.get("info"), dict): + oid = order["info"].get("id") or order["info"].get("order_id") + disp = format_price_for_symbol(exchange_symbol, trig) if trig else "-" + return { + "order_id": str(oid) if oid is not None else "", + "channel": "gate_trigger", + "trigger_price": trig, + "trigger_display": disp, + "amount": amt, + "type": str(order.get("type") or ""), + } + + +def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None): + slots = {"sl": None, "tp": None} + if not exchange_symbol: + return slots + ok, _ = ensure_exchange_live_ready() + if not ok: + return slots + try: + ensure_markets_loaded() + ambiguous = [] + for order in _gate_list_trigger_open_orders(exchange_symbol): + role = _gate_tpsl_role_from_order(order, direction) + slot = _gate_tpsl_slot_from_order(order, exchange_symbol) + if role in ("sl", "tp"): + if slots[role] is None: + slots[role] = slot + continue + ambiguous.append(slot) + for slot in ambiguous: + trig = slot.get("trigger_price") + if trig is None: + continue + try: + plan_sl_f = float(plan_sl) if plan_sl is not None else None + plan_tp_f = float(plan_tp) if plan_tp is not None else None + except Exception: + plan_sl_f = plan_tp_f = None + if plan_sl_f is not None and plan_tp_f is not None: + role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp" + elif plan_sl_f is not None: + role = "sl" + elif plan_tp_f is not None: + role = "tp" + else: + continue + if slots[role] is None: + slots[role] = slot + except Exception: + pass + return slots + + +def cancel_gate_tpsl_slot(exchange_symbol, slot): + if not slot or not exchange_symbol: + return + ensure_markets_loaded() + oid = slot.get("order_id") + if not oid: + return + params = _gate_swap_trigger_order_params() + exchange.cancel_order(str(oid), exchange_symbol, params) + + +def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): + sltp_mode = (sltp_mode or "price").strip().lower() + if sltp_mode == "pct": + sl_pct = float(data.get("sl_pct") or 0) + tp_pct = float(data.get("tp_pct") or 0) + if sl_pct <= 0 or tp_pct <= 0: + raise ValueError("百分比止盈止损须为正数") + sl_ratio = sl_pct / 100.0 + tp_ratio = tp_pct / 100.0 + entry = float(live_price) + if direction == "short": + stop_loss = entry * (1 + sl_ratio) + take_profit = entry * (1 - tp_ratio) + else: + stop_loss = entry * (1 - sl_ratio) + take_profit = entry * (1 + tp_ratio) + else: + stop_loss = float(data.get("sl") or data.get("stop_loss") or 0) + take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0) + if stop_loss <= 0 or take_profit <= 0: + raise ValueError("止盈止损价格须大于 0") + return stop_loss, take_profit + + +def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): + 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, 2) + if notional is not None and notional > 0: + out["notional"] = round(notional, 2) + if unrealized is not None: + out["unrealized_pnl"] = round(unrealized, 2) + 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, order_leverage=None): + 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, order_leverage=order_leverage) + + +def _order_row_exchange_margin_usdt(row): + if not row: + return None + try: + keys = row.keys() + except Exception: + return None + if "exchange_margin_usdt" not in keys: + return None + v = row["exchange_margin_usdt"] + if v is None: + return None + try: + x = float(v) + except (TypeError, ValueError): + return None + return x if x > 0 else None + + +def margin_capital_for_trade_record(order_row): + """trade_records.基数:优先交易所持仓保证金快照,旧数据无快照时回退计划保证金。""" + ex = _order_row_exchange_margin_usdt(order_row) + if ex is not None: + return round(ex, 2) + if not order_row: + return None + try: + v = order_row["margin_capital"] + except (TypeError, KeyError, IndexError): + return None + if v is None: + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def try_persist_exchange_margin_for_order(conn, order_id, exchange_symbol, direction, order_leverage=None, max_attempts=6, sleep_s=0.45): + """开仓成功后持仓可见时拉取交易所保证金并写入 order_monitors(平仓后无法再取)。""" + if not conn or not order_id or not exchange_private_api_configured(): + return False + direction = (direction or "long").lower() + ex_sym = (exchange_symbol or "").strip() + if not ex_sym: + return False + n = max(1, int(max_attempts)) + delay = max(0.05, float(sleep_s)) + for _ in range(n): + pm = get_live_position_exchange_metrics(ex_sym, direction, order_leverage=order_leverage) + if pm and pm.get("initial_margin") is not None: + try: + v = float(pm["initial_margin"]) + except (TypeError, ValueError): + v = 0.0 + if v > 0: + conn.execute( + "UPDATE order_monitors SET exchange_margin_usdt=? WHERE id=?", + (round(v, 4), int(order_id)), + ) + return True + time.sleep(delay) + return False + + +def opened_at_str_to_ms(opened_at_str): + if not opened_at_str: + return None + dt = parse_dt_for_trading_day(opened_at_str) + if dt is None: + 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=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + 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=margin_capital_for_trade_record(r), + 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"],)) + clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day()) + 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 + min_closed = KEY_VOLUME_MA_BARS + 3 + if len(closed) < min_closed: + out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足" + return out + try: + breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] + confirm = closed[KEY_CONFIRM_BAR] + except IndexError: + out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" + return out + prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR] + avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1) + vol_break = float(breakout[5]) + vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN 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 > KEY_BREAKOUT_AMP_MIN_PCT) and (amp_pct < KEY_BREAKOUT_AMP_MAX_PCT) + 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 <= KEY_DAILY_VOLUME_RANK_MAX) + 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 _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): + """本条关键位一次性结案:写历史并从当前表删除。""" + n = int(row["notification_count"] or 0) + 1 + insert_key_monitor_history(conn, row, n, last_msg, close_reason) + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + + +def _key_hard_lines_from_checks(checks): + return [ + 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)", + ] + + +def _key_plan_sl_tp_for_row(row, direction, upper, lower, checks): + """按 key_monitors 录入的方案计算计划 SL/TP。""" + mode = sl_tp_mode_from_row(row, "standard") + manual_tp = _sqlite_row_val(row, "manual_take_profit") + planned = plan_key_sl_tp( + mode, + direction, + upper, + lower, + checks, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + manual_take_profit=manual_tp, + ) + return planned, mode + + +def _market_open_for_key_monitor( + conn, + symbol, + direction, + exchange_symbol, + stop_loss, + take_profit, + key_signal_type=None, + breakeven_enabled=0, +): + """ + 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入。 + 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) + """ + now = app_now() + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + return False, f"风控拒绝下单:{reason}", None + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live, None + + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + + 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) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + + trade_style = (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: + return False, "获取交易所实时价格失败(以损定仓需要当前价)", None + try: + ensure_markets_loaded() + except Exception: + pass + lp_r = round_price_to_exchange(exchange_symbol, live_price) + if lp_r is not None: + live_price = lp_r + + sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) + tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) + if sl_adj is not None: + stop_loss = float(sl_adj) + if tp_adj is not None: + take_profit = float(tp_adj) + + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None + 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: + return False, "以损定仓后保证金超过当前交易资金", None + + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + None, + ) + + 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: + return False, friendly_exchange_error(e, available_usdt=available_usdt), None + + trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) + stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) + take_profit = round_price_to_exchange(exchange_symbol, take_profit) + + 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) + if risk_amount_final is None: + risk_amount_final = risk_amount + else: + try: + risk_amount_final = round(float(risk_amount_final), 4) + except (TypeError, ValueError): + risk_amount_final = risk_amount + + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + be_enabled = 1 if int(breakeven_enabled or 0) != 0 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, monitor_type, key_signal_type) " + "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, + be_enabled, + notional_value, + position_ratio, + base_amount, + amount, + open_order_id, + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(key_signal_type), + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "base_amount": base_amount, + "notional_value": notional_value, + "position_ratio": position_ratio, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "risk_percent": risk_percent, + "breakeven_rr_trigger": breakeven_rr_trigger, + "breakeven_price": breakeven_price, + "capital_base_at_open": capital_base, + } + + +def _sqlite_row_val(row, key, default=None): + try: + v = row[key] + return default if v is None else v + except (KeyError, IndexError, TypeError): + return default + + +def get_symbol_mark_price(symbol): + """斐波失效判定用标记价。""" + ex_sym = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + ticker = exchange.fetch_ticker(ex_sym) + m = _coerce_float(ticker.get("mark"), ticker.get("last")) + if m is None: + info = ticker.get("info") or {} + m = _coerce_float(info.get("mark_price"), info.get("last")) + if m is not None and m > 0: + return float(m) + except Exception: + pass + p = get_price(symbol) + return float(p) if p is not None else None + + +def cancel_fib_limit_order(exchange_symbol, order_id): + """仅撤销本条斐波限价单,不用 cancel_all。""" + if not order_id: + return False + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return False + ensure_markets_loaded() + oid = str(order_id) + try: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + return False + + +def fib_limit_order_status(exchange_symbol, order_id): + if not order_id: + return "missing" + ensure_markets_loaded() + oid = str(order_id) + try: + o = exchange.fetch_order(oid, exchange_symbol) + st = (o.get("status") or "").lower() + if st in ("closed", "filled"): + filled = float(o.get("filled") or 0) + if filled > 0 or st == "filled": + return "filled" + if st in ("canceled", "cancelled", "expired", "rejected"): + return "canceled" + if st in ("open", "new", "partially_filled"): + return "open" + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + return "open" + except Exception: + pass + return "unknown" + + +def place_fib_limit_order(exchange_symbol, direction, amount, leverage, limit_price): + ensure_markets_loaded() + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + price = round_price_to_exchange(exchange_symbol, float(limit_price)) + if price is None or price <= 0: + raise ValueError("挂单价无效") + params = build_gate_order_params(direction, reduce_only=False) + return exchange.create_order(exchange_symbol, "limit", side, amount, price, params) + + +def _fib_key_exists_for_symbol(conn, symbol): + ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})", + (symbol, *tuple(FIB_KEY_MONITOR_TYPES)), + ).fetchone() + return row is not None + + +def _fib_plan_for_row(row): + typ = (row["monitor_type"] or "").strip() + ratio = fib_ratio_from_type(typ) + if ratio is None: + return None + return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio) + + +def _cancel_fib_monitor_limit(row): + ex_sym = normalize_exchange_symbol(row["symbol"]) + oid = _sqlite_row_val(row, "fib_limit_order_id") + if oid: + cancel_fib_limit_order(ex_sym, oid) + + +def _fib_has_live_position(exchange_symbol, direction): + live = get_live_position_contracts(exchange_symbol, direction) + return live is not None and float(live) > 0 + + +def _insert_order_monitor_from_fib_fill( + conn, row, trigger_price, stop_loss, take_profit, amount, leverage, margin_capital, + notional_value, position_ratio, base_amount, exchange_order_id, tpsl_attached, +): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + exchange_symbol = normalize_exchange_symbol(symbol) + typ = (row["monitor_type"] or "").strip() + now = app_now() + trading_day = get_trading_day(now) + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4) + 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 + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + 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, monitor_type, key_signal_type) " + "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, + 1 if breakeven_enabled_from_row(row, 0) else 0, + notional_value, + position_ratio, + base_amount, + amount, + exchange_order_id or "", + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(typ), + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + return new_order_id + + +def _finalize_fib_key_fill(conn, row): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + typ = (row["monitor_type"] or "").strip() + ex_sym = normalize_exchange_symbol(symbol) + plan = _fib_plan_for_row(row) + if not plan: + _finalize_key_monitor_one_shot(conn, row, "斐波计划无效", "fib_plan_invalid") + return + entry_plan, sl_plan, tp_plan = plan + sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan) + tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan) + sl_adj = round_price_to_exchange(ex_sym, sl) + tp_adj = round_price_to_exchange(ex_sym, tp) + if sl_adj is not None: + sl = float(sl_adj) + if tp_adj is not None: + tp = float(tp_adj) + amount = float(_sqlite_row_val(row, "fib_order_amount") or 0) + leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5) + margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0) + oid = _sqlite_row_val(row, "fib_limit_order_id") + entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan) + trigger_price = entry_px + if oid: + try: + o = exchange.fetch_order(str(oid), ex_sym) + trigger_price = resolve_order_entry_price(o, ex_sym, entry_px) + except Exception: + pass + tr_adj = round_price_to_exchange(ex_sym, trigger_price) + if tr_adj is not None: + trigger_price = float(tr_adj) + if amount <= 0: + live_amt = get_live_position_contracts(ex_sym, direction) + amount = float(live_amt or 0) + if amount <= 0: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后处理失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 无法取得持仓/下单数量,未挂 TP/SL\n" + ) + return + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后风控拒绝\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 原因:{reason}\n" + f"- 请手动处理仓位与挂单\n" + ) + return + tpsl_attached = False + try: + _gate_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) + tpsl_attached = True + except Exception as e: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后挂 TP/SL 失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 错误:{friendly_exchange_error(e)}\n" + f"- 请手动补挂止盈止损\n" + ) + return + contract_size = get_contract_size(ex_sym) + base_amount = round(float(amount) * contract_size, 8) + notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0 + session_row = ensure_session(conn, get_trading_day(app_now())) + capital_base = float(session_row["current_capital"] or 0) + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0 + planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp) + new_order_id = _insert_order_monitor_from_fib_fill( + conn, row, trigger_price, sl, tp, amount, leverage, margin_capital, + notional_value, position_ratio, base_amount, oid, tpsl_attached, + ) + rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + succ = ( + f"# ✅ {symbol} 斐波限价成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{new_order_id}**\n" + f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n" + f"- 止损:{format_wechat_scalar_2dp(sl)}|止盈:{format_price_for_symbol(symbol, tp)}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(succ) + _finalize_key_monitor_one_shot(conn, row, succ, "fib_filled") + + +def check_fib_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + typ = (r["monitor_type"] or "").strip() + if not is_fib_key_monitor_type(typ): + continue + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + up, low = float(r["upper"]), float(r["lower"]) + oid = _sqlite_row_val(r, "fib_limit_order_id") + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + status = fib_limit_order_status(ex_sym, oid) if oid else "missing" + if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): + _finalize_fib_key_fill(conn, r) + continue + if status == "open": + if fib_invalidate_by_mark(direction, mark, up, low): + _cancel_fib_monitor_limit(r) + msg = ( + f"# ⚠️ {symbol} 斐波监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + continue + if status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): + msg = ( + f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价触达止盈侧,本条已结案\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + conn.commit() + conn.close() + + +def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0): + if _fib_key_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" + ratio = fib_ratio_from_type(mt) + plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio) + if not plan: + return False, "斐波上下沿无效(需上沿 H > 下沿 L)" + entry, sl, tp = plan + ex_sym = normalize_exchange_symbol(symbol) + entry = round_price_to_exchange(ex_sym, entry) + sl = round_price_to_exchange(ex_sym, sl) + tp = round_price_to_exchange(ex_sym, tp) + if entry is None or sl is None or tp is None: + return False, "斐波价位经交易所精度舍入后无效" + entry, sl, tp = float(entry), float(sl), float(tp) + planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)" + ok, reason = precheck_risk(conn, symbol, direction_sel) + if not ok: + return False, reason + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + available_usdt = get_available_trading_usdt() + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" + 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: + return False, "以损定仓后保证金超过当前交易资金" + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + ) + try: + amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) + oid = str(order_resp.get("id") or "") + if not oid: + return False, "交易所未返回限价单 ID" + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, mt, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, + ), + ) + return True, None + + +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑位仅单次提醒结案) +def check_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + typ = (typ_raw or "").strip() + if is_fib_key_monitor_type(typ): + continue + direction = (r["direction"] or "long").lower() + 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)主趋势逆势,建议降低仓位并严格执行止损。" + + key_price = float(low) if direction == "long" else float(up) + hard_lines = _key_hard_lines_from_checks(checks) + trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() + + alert_only = typ in KEY_MONITOR_ALERT_ONLY_TYPES or ( + typ not in KEY_MONITOR_AUTO_TYPES and typ not in KEY_MONITOR_ALERT_ONLY_TYPES + ) + + if alert_only: + op_lines = [ + "- 本条为关键阻力/支撑或非标类型:**仅单次推送**,不进行自动开仓。", + "- 本条关键位将在推送后记入历史并从监控列表移除。", + ] + 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) + _finalize_key_monitor_one_shot(conn, r, msg, "key_level_alert_only") + continue + + plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) + if not plan_tuple: + fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)" + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划无效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, checks.get('confirm_close'))}`\n" + f"- **{fmt_rr}**(未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + E, sl_raw, tp_raw, box_h = plan_tuple + exchange_symbol = normalize_exchange_symbol(sym) + try: + ensure_markets_loaded() + except Exception: + pass + sl_px = round_price_to_exchange(exchange_symbol, sl_raw) + tp_px = round_price_to_exchange(exchange_symbol, tp_raw) + if sl_px is not None: + sl_raw = float(sl_px) + if tp_px is not None: + tp_raw = float(tp_px) + + planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) + rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR + + if not rr_ok: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)" + plan_line = sl_tp_plan_summary_text( + sl_tp_mode, direction, E, sl_raw, tp_raw, box_h, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + ) + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{plan_line}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + + key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None + be_on = breakeven_enabled_from_row(r, 0) + ok_trade, trade_err, det = _market_open_for_key_monitor( + conn, + sym, + direction, + exchange_symbol, + sl_raw, + tp_raw, + key_signal_type=key_sig, + breakeven_enabled=1 if be_on else 0, + ) + planned_rr_txt = ( + format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + ) + if not ok_trade: + fail_msg = ( + f"# ❌ {sym} 关键位自动单失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n" + f"- **失败原因:{trade_err}**\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(fail_msg) + _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed") + continue + + tpsl_txt = ( + "已在交易所挂条件委托(止盈、止损触发单)" + if det.get("tpsl_attached") + else "⚠️ 条件委托挂接状态异常或未挂上" + ) + rr_fill = det.get("planned_rr_fill") + rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-" + + succ_msg_lines = [ + f"# ✅ {sym} 关键位自动开仓成功", + f"**账户:{_wechat_account_label()}**", + f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", + f"- 页面订单 ID:**{det['new_order_id']}**", + f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 触发时间:`{trigger_time}`", + f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", + f"- **计划 RR(E):{planned_rr_txt}:1**", + f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", + f"- **成交价侧计划 RR:**{rr_fill_txt}:1", + f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", + f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", + f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", + f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", + f"- **{tpsl_txt}**", + f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", + f"- 当日开仓次数:**{det.get('opens_today_after')}** / {DAILY_OPEN_ALERT_THRESHOLD}(提醒阈值)", + ] + succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines]) + if risk_tip: + succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"]) + succ_msg = "\n".join(succ_msg_lines) + send_wechat_msg(succ_msg) + _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened") + + if det.get("opens_today_before", 0) < DAILY_OPEN_ALERT_THRESHOLD <= det.get("opens_today_after", 0): + advice = ai_short_advice( + f"用户在北京时间交易日 {det['trading_day']} 已累计开仓 {det['opens_today_after']} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}") + 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) + trade_basis_row = row_to_dict(r) + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + if _order_row_exchange_margin_usdt(r) is None and exchange_private_api_configured(): + pm = get_live_position_exchange_metrics(ex_sym, direction, order_leverage=leverage) + if pm and pm.get("initial_margin") is not None: + try: + mv = float(pm["initial_margin"]) + if mv > 0: + conn.execute( + "UPDATE order_monitors SET exchange_margin_usdt=? WHERE id=?", + (round(mv, 4), pid), + ) + trade_basis_row["exchange_margin_usdt"] = round(mv, 4) + except (TypeError, ValueError): + pass + 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=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + 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_for_trade_record(trade_basis_row), + 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=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + 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_for_trade_record(trade_basis_row), + 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)) + clear_key_sizing_snapshot_if_flat(conn, get_trading_day()) + 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=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + 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_for_trade_record(r), + 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_fib_key_monitors() + check_key_monitors() + check_order_monitors() + except: + pass + time.sleep(MONITOR_POLL_SECONDS) + + +# ====================== 登录路由 ====================== +@app.route("/login", methods=["GET", "POST"]) +def login(): + if AUTH_DISABLED: + session["logged_in"] = True + return redirect("/") + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + if username == USERNAME and password == PASSWORD: + session["logged_in"] = True + return redirect("/") + else: + flash("账号或密码错误") + return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME) + +@app.route("/logout") +def logout(): + session.clear() + return redirect("/" if AUTH_DISABLED else "/login") + +# 登录校验装饰器 +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if AUTH_DISABLED: + return f(*args, **kwargs) + if not session.get("logged_in"): + return redirect("/login") + return f(*args, **kwargs) + return decorated + + +@app.route("/sync_positions") +@login_required +def sync_positions(): + days_raw = (request.args.get("days") or "").strip() + sync_days = None + if days_raw: + try: + sync_days = max(1, min(365, int(days_raw))) + except Exception: + sync_days = None + conn = get_db() + synced = reconcile_external_closes(conn, days=sync_days) + conn.commit() + conn.close() + if sync_days is not None: + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + else: + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + return redirect("/") + + +@app.route("/api/sync_positions", methods=["POST"]) +@login_required +def api_sync_positions(): + payload = request.get_json(silent=True) or {} + days_raw = str(payload.get("days", "")).strip() + if not days_raw: + return jsonify({"ok": False, "msg": "请填写天数"}), 400 + try: + days = int(days_raw) + except Exception: + return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 + if days < 1 or days > 365: + return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 + conn = get_db() + synced = reconcile_external_closes(conn, days=days) + conn.commit() + conn.close() + return jsonify({"ok": True, "days": days, "synced": int(synced)}) + + +def _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 > 1e9: + return int(v * 1000.0) + return int(v * 1000.0) + + +def _unified_symbol_for_match(symbol_str): + """统一 ETH/USDT:USDT、ETH_USDT、ETH/USDT 便于与 trade_records 比对。""" + s = (symbol_str or "").strip().upper() + if not s: + return "" + if ":" in s: + s = s.split(":")[0] + if "_" in s and "/" not in s: + s = s.replace("_", "/") + if s.endswith("USDT") and "/" not in s and len(s) > 4: + s = f"{s[:-4]}/USDT" + return s + + +def exchange_position_sync_since_ms(): + 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 _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 "" + if not sym: + c_alt = str(info.get("contract") or "").strip() + if c_alt: + sym = c_alt.replace("_", "/") + side = (p.get("side") or info.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() + until_ms = int(time.time() * 1000) + out = [] + offset = 0 + page_limit = min(100, int(EXCHANGE_POSITION_HISTORY_LIMIT)) + max_total = int(EXCHANGE_POSITION_HISTORY_LIMIT) + + def _pull(params_extra): + nonlocal offset + offset = 0 + while len(out) < max_total: + params = dict(params_extra) + params["offset"] = offset + params["until"] = until_ms + try: + rows = exchange.fetch_positions_history( + None, + since=int(since_ms), + limit=page_limit, + params=params, + ) + except Exception: + return False + if not rows: + break + for p in rows: + 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) + offset += len(rows) + if len(rows) < page_limit: + break + return True + + if not _pull({"settle": "usdt"}): + _pull({}) + return out[:max_total] + + +def sync_trade_records_from_exchange(conn, force=False): + """为未同步的 trade_records 回填 Gate 平仓历史中的已实现盈亏。返回统计 dict。""" + global _LAST_EXCHANGE_PNL_SYNC_AT + stats = {"ok": False, "hist_count": 0, "matched": 0, "pending": 0, "skipped": False} + if not exchange_private_api_configured(): + stats["reason"] = "未配置 GATE_API_KEY / GATE_API_SECRET" + return stats + now = time.time() + if not force and now - _LAST_EXCHANGE_PNL_SYNC_AT < 25.0: + stats["ok"] = True + stats["skipped"] = True + return stats + try: + hist = fetch_gate_positions_close_history() + except Exception as e: + stats["reason"] = str(e) + return stats + stats["hist_count"] = len(hist) + if not hist: + stats["ok"] = True + stats["reason"] = "交易所平仓历史为空(请检查 API 权限或 EXCHANGE_POSITION_SYNC_FROM_BJ)" + return stats + candidates = conn.execute( + """ + SELECT id, symbol, direction, closed_at, closed_at_ms, opened_at, opened_at_ms + FROM trade_records + WHERE (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '') + OR exchange_realized_pnl IS NULL + ORDER BY id DESC + LIMIT 200 + """ + ).fetchall() + stats["pending"] = len(candidates) + if not candidates: + stats["ok"] = True + _LAST_EXCHANGE_PNL_SYNC_AT = now + return stats + used = set() + matched = 0 + for tr in candidates: + close_ms_trade = _to_ms_with_fallback( + tr["closed_at_ms"] if "closed_at_ms" in tr.keys() else None, tr["closed_at"] + ) or opened_at_str_to_ms(tr["closed_at"]) + open_ms_trade = _to_ms_with_fallback( + tr["opened_at_ms"] if "opened_at_ms" in tr.keys() else None, tr["opened_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 open_ms_trade is not None: + if cm < open_ms_trade - 15 * 60 * 1000: + continue + if cm > open_ms_trade + 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 > 90 * 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) + matched += 1 + stats["matched"] = matched + stats["ok"] = True + _LAST_EXCHANGE_PNL_SYNC_AT = now + try: + conn.commit() + except Exception: + pass + return stats + + +# ====================== 主页面 ====================== +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(float(get_recommended_capital(current_capital)), 2) + key_list = conn.execute("SELECT * FROM key_monitors").fetchall() + key_history = conn.execute( + "SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500", + (start_bj, end_bj), + ).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)) + exchange_pnl_sync = {} + if exchange_private_api_configured(): + try: + exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {} + except Exception as e: + exchange_pnl_sync = {"ok": False, "reason": str(e)} + raw_records = conn.execute( + "SELECT * FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id DESC LIMIT 1000", + (start_bj, end_bj), + ).fetchall() + records = [to_effective_trade_dict(r) for r in raw_records] + total = len(records) + miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") + win = sum(1 for r in records if (r.get("effective_result") or "") in ("止盈", "保本止盈", "移动止盈")) + occupied_miss_total = sum( + 1 + for r in records + if (r.get("effective_result") or "") == "错过" + and ("持仓占用" in str(r.get("effective_miss_reason") or "")) + ) + rate = round(win/total*100,2) if total else 0 + active_count = len(order_list) + can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS + key_gate_rule_text = ( + f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|" + f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" + f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|" + f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|" + f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%" + ) + conn.close() + return render_template( + "index.html", + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + record=records, + total=total, + miss_count=miss_count, + rate=rate, + trading_day=trading_day, + funding_usdt=funding_usdt, + daily_start_capital=DAILY_START_CAPITAL, + current_capital=current_capital, + recommended_capital=recommended_capital, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + reset_hour=TRADING_DAY_RESET_HOUR, + balance_refresh_seconds=BALANCE_REFRESH_SECONDS, + auto_transfer_enabled=AUTO_TRANSFER_ENABLED, + auto_transfer_amount=AUTO_TRANSFER_AMOUNT, + auto_transfer_from=AUTO_TRANSFER_FROM, + auto_transfer_to=AUTO_TRANSFER_TO, + auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, + full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + active_count=active_count, + can_trade=can_trade, + focus_key_id=(key_list[0]["id"] if key_list else None), + focus_order_id=(order_list[0]["id"] if order_list else None), + data_export_version=3, + 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, + }, + 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, + usdt_fmt=format_usdt, + signed_usdt_fmt=format_signed_usdt, + entry_reason_options=list(ENTRY_REASON_OPTIONS), + entry_reason_other_value=ENTRY_REASON_OTHER, + exchange_display=EXCHANGE_DISPLAY_NAME, + max_active_positions=MAX_ACTIVE_POSITIONS, + manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + key_gate_rule_text=key_gate_rule_text, + kline_timeframe=KLINE_TIMEFRAME, + exchange_pnl_sync=exchange_pnl_sync, + ) + + +@app.route("/api/sync_exchange_pnl") +@login_required +def api_sync_exchange_pnl(): + conn = get_db() + stats = sync_trade_records_from_exchange(conn, force=True) + try: + conn.commit() + except Exception: + pass + conn.close() + return jsonify(stats) + + +@app.route("/") +@login_required +def index(): + return redirect("/trade") + + +@app.route("/key_monitor") +@login_required +def key_monitor_page(): + return render_main_page("key_monitor") + + +@app.route("/trade") +@login_required +def trade_page(): + return render_main_page("trade") + + +@app.route("/records") +@login_required +def records_page(): + return render_main_page("records") + + +@app.route("/stats") +@login_required +def stats_page(): + return render_main_page("stats") + + +@app.route("/api/account_snapshot") +@login_required +def api_account_snapshot(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals(force=True) + funding_usdt = round(funding_capital, 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(float(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, + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "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,fib_entry_price,fib_limit_order_id 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() + + try: + ensure_markets_loaded() + except Exception: + pass + + 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: + is_fib = is_fib_key_monitor_type(r["monitor_type"]) + if is_fib: + price = get_symbol_mark_price(r["symbol"]) + else: + 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 + gate_summary = "-" + gate_metrics = "" + fib_gate_ok = True + if is_fib: + direction = (r["direction"] or "long").lower() + inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) + fib_gate_ok = not inval + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}" + if _sqlite_row_val(r, "fib_limit_order_id"): + gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}" + else: + try: + gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) + except Exception: + gate = None + 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 = "" + px_disp = format_price_for_symbol(r["symbol"], price) + try: + price_num = float(px_disp) if px_disp != "-" else float(price) + except Exception: + price_num = float(price) + key_prices.append({ + "id": r["id"], + "symbol": r["symbol"], + "price": price_num, + "price_display": px_disp, + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + "gate_summary": gate_summary, + "gate_ok": fib_gate_ok if is_fib else 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"], + "float_pnl": round(pnl, 2), + "float_pct": pnl_pct, + "rr_ratio": rr_ratio, + "plan_margin": round(margin, 2) 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"]), 2) + 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 + ) + px_for_fmt = float(price) + if ex_metrics and ex_metrics.get("mark_price") is not None: + try: + px_for_fmt = float(ex_metrics["mark_price"]) + except (TypeError, ValueError): + pass + px_disp = format_price_for_symbol(r["symbol"], px_for_fmt) + try: + payload["price"] = float(px_disp) if px_disp != "-" else px_for_fmt + except Exception: + payload["price"] = px_for_fmt + payload["price_display"] = px_disp + if exchange_private_api_configured(): + try: + payload["exchange_tpsl"] = fetch_exchange_tpsl_slots( + ex_sym, + r["direction"], + plan_sl=r["stop_loss"], + plan_tp=r["take_profit"], + ) + except Exception: + payload["exchange_tpsl"] = {"sl": None, "tp": None} + else: + payload["exchange_tpsl"] = {"sl": None, "tp": None} + 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/order//cancel_tpsl", methods=["POST"]) +@login_required +def api_order_cancel_tpsl(order_id): + data = request.get_json(silent=True) or {} + role = (data.get("role") or "").strip().lower() + if role not in ("sl", "tp"): + return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 + conn = get_db() + 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 + ok, reason = ensure_exchange_live_ready() + if not ok: + return jsonify({"ok": False, "msg": reason}), 400 + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots( + ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"] + ) + slot = slots.get(role) + if not slot: + return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 + try: + cancel_gate_tpsl_slot(ex_sym, slot) + slots = fetch_exchange_tpsl_slots( + ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"] + ) + return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": slots}) + except Exception as e: + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + + +@app.route("/api/order//place_tpsl", methods=["POST"]) +@login_required +def api_order_place_tpsl(order_id): + data = request.get_json(silent=True) or {} + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + symbol = row["symbol"] + direction = row["direction"] + live_price = get_price(symbol) + if live_price is None: + conn.close() + return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400 + try: + sltp_mode = (data.get("sltp_mode") or "price").strip().lower() + stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": str(e)}), 400 + planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR: + conn.close() + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return jsonify( + { + "ok": False, + "msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1", + } + ), 400 + try: + replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (stop_loss, take_profit, order_id), + ) + conn.commit() + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit) + conn.close() + return jsonify( + { + "ok": True, + "msg": "已先撤后挂止盈止损", + "stop_loss": stop_loss, + "take_profit": take_profit, + "planned_rr": planned_rr, + "exchange_tpsl": slots, + } + ) + + +@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 <= KEY_DAILY_VOLUME_RANK_MAX), + "rank_max": KEY_DAILY_VOLUME_RANK_MAX, + } + ) + + +@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() + last_price = get_price(symbol) + return jsonify({ + "ok": True, + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "available_trading_usdt": round(available, 2) if available is not None else None, + "last_price": round(float(last_price), 8) if last_price 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, 2) if trading_capital_live is not None else round(local_current_capital, 2) + 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, 2) if trading_capital_live is not None else round(local_current_capital, 2) + 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 + + sym = order_item["symbol"] + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": { + "id": order_item["id"], + "symbol": sym, + "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"), + "trigger_price_display": format_price_for_symbol(sym, order_item.get("trigger_price")), + "stop_loss_display": format_price_for_symbol(sym, order_item.get("stop_loss")), + "take_profit_display": format_price_for_symbol(sym, 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, + "current_price_display": format_price_for_symbol(sym, current_price) if current_price else None, + "float_pnl": round(float(float_pnl), 2), + "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("/key_monitor") + direction_sel = (d.get("direction") or "").strip().lower() + if direction_sel not in ("long", "short"): + flash("请选择做多或做空") + return redirect("/key_monitor") + mt = (d.get("type") or "").strip() + allowed_types = ( + tuple(KEY_MONITOR_AUTO_TYPES) + + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + + tuple(FIB_KEY_MONITOR_TYPES) + ) + if mt not in allowed_types: + flash("监控类型无效") + return redirect("/key_monitor") + rank, total = _daily_volume_rank(symbol) + if rank is None: + flash("日成交量排名读取失败,请稍后重试") + return redirect("/key_monitor") + if rank > KEY_DAILY_VOLUME_RANK_MAX: + flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") + return redirect("/key_monitor") + conn = get_db() + if mt in KEY_MONITOR_AUTO_TYPES: + occupied = get_active_position_count(conn) + if occupied >= MAX_ACTIVE_POSITIONS: + conn.close() + flash( + f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" + "请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。" + ) + return redirect("/key_monitor") + ex_sym_key = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + upper_px = round_price_to_exchange(ex_sym_key, float(d["upper"])) + lower_px = round_price_to_exchange(ex_sym_key, float(d["lower"])) + be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) + if is_fib_key_monitor_type(mt): + ok_fib, err_fib = _add_fib_key_monitor( + conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag, + ) + conn.commit() + conn.close() + if not ok_fib: + flash(err_fib or "斐波监控添加失败") + return redirect("/key_monitor") + flash( + f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" + f"|移动保本:{'开' if be_flag else '关'}" + ) + return redirect("/key_monitor") + sl_tp_mode = "standard" + manual_tp = None + if mt in KEY_MONITOR_AUTO_TYPES: + sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode")) + if sl_tp_mode == "trend_manual": + try: + manual_tp = float(d.get("manual_take_profit") or 0) + except (TypeError, ValueError): + manual_tp = 0 + if manual_tp <= 0: + conn.close() + flash("趋势单方案须填写有效止盈价") + return redirect("/key_monitor") + if direction_sel == "long" and manual_tp <= upper_px: + conn.close() + flash("做多趋势单:止盈价应高于上沿(阻力)") + return redirect("/key_monitor") + if direction_sel == "short" and manual_tp >= lower_px: + conn.close() + flash("做空趋势单:止盈价应低于下沿(支撑)") + return redirect("/key_monitor") + mtpx = round_price_to_exchange(ex_sym_key, manual_tp) + if mtpx is not None: + manual_tp = float(mtpx) + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag), + ) + conn.commit() + conn.close() + ctr = False + try: + coin4h_status, _, _ = _status_by_ema55(symbol, "4h") + ctr = (direction_sel == "long" and coin4h_status == "空头") or ( + direction_sel == "short" and coin4h_status == "多头" + ) + except Exception: + pass + extra = "" + if mt in KEY_MONITOR_AUTO_TYPES: + extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") + if ctr: + flash( + "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" + ) + return redirect("/key_monitor") + +@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 + ex_miss = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + insert_trade_record( + conn, + symbol=symbol, + monitor_type="下单监控", + direction=direction if direction in ("long", "short") else "long", + trigger_price=round_price_to_exchange(ex_miss, tp_raw) if tp_raw else 0, + stop_loss=round_price_to_exchange(ex_miss, sl_raw) if sl_raw else 0, + take_profit=round_price_to_exchange(ex_miss, tgt_raw) if tgt_raw else 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("/trade") + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(f"风控拒绝下单:{reason_live}") + return redirect("/trade") + 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("/") + try: + ensure_markets_loaded() + except Exception: + pass + lp_r = round_price_to_exchange(exchange_symbol, live_price) + if lp_r is not None: + live_price = lp_r + 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("/trade") + planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR: + conn.close() + rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算" + flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1") + return redirect("/trade") + sl_adj = round_price_to_exchange(exchange_symbol, stop_loss) + tp_adj = round_price_to_exchange(exchange_symbol, take_profit) + if sl_adj is not None: + stop_loss = sl_adj + if tp_adj is not None: + take_profit = tp_adj + 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, 2)}U,当前最多建议 {round(max_margin, 2)}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("/") + + trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) + stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) + take_profit = round_price_to_exchange(exchange_symbol, take_profit) + + 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_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + 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, monitor_type) 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, + ORDER_MONITOR_TYPE_MANUAL, + ) + ) + conn.commit() + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + conn.commit() + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + conn.close() + + chart_name = None + chart_url = None + if make_order_chart and ORDER_CHART_ENABLED: + try: + title_prefix = f"{symbol} {direction} #{new_order_id}" + chart_name = generate_order_open_chart(exchange_symbol, title_prefix) + if chart_name: + chart_url = f"/static/images/order_charts/{chart_name}" + except Exception: + chart_name = None + chart_url = None + + if chart_name: + try: + journal_id = f"order_{new_order_id}" + coin = journal_coin_from_symbol(symbol) + open_local = (opened_at_bj or "")[:16].replace(" ", "T") + if len(open_local) < 16: + open_local = app_now().strftime("%Y-%m-%dT%H:%M") + close_local = open_local + hold_duration = calc_duration_text(open_local, close_local) + note = ( + f"auto_from_open_order id={new_order_id} oid={open_order_id} " + f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" + ) + conn = get_db() + conn.execute( + """INSERT OR REPLACE INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + journal_id, + open_local, + close_local, + hold_duration, + coin, + "multi", + "0", + "auto:open", + "待平仓", + "", + "", + "否", + "", + "", + "", + None, + None, + None, + "", + "否", + "否", + note, + chart_name, + ), + ) + conn.commit() + conn.close() + except Exception: + try: + conn.close() + except Exception: + pass + + _, trading_capital_after = get_exchange_capitals(force=True) + account_base_display = ( + round(float(trading_capital_after), 2) + if trading_capital_after is not None + else round(float(capital_base), 2) + ) + 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 = f"{float(planned_rr):.2f}" 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_wechat_scalar_2dp(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), 2)} U", + "📊 仓位配置详情", + f"账户基数:{account_base_display} USDT", + f"合约杠杆:{leverage} 倍", + f"名义仓位:{format_wechat_scalar_2dp(notional_value)} USDT", + f"仓位占比:{position_ratio}%", + f"合约张数:{format_wechat_scalar_2dp(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}%≈{round(float(risk_amount_final), 2)}U;基数 {round(float(margin_capital), 2)}U,杠杆 {leverage}x,名义仓位 {format_wechat_scalar_2dp(notional_value)}U,仓位占比 {position_ratio}%,合约张数 {format_wechat_scalar_2dp(amount)}(折算标的 {base_amount})," + f"计划RR {format_wechat_scalar_2dp(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,基数{round(float(margin_capital), 2)}U。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") + return redirect("/") + +@app.route("/delete_key_monitor/", methods=["POST"]) +@login_required +def delete_key_monitor(kid): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "error": "not_found"}) + if is_fib_key_monitor_type(row["monitor_type"]): + _cancel_fib_monitor_limit(row) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/delete_key_history/", methods=["POST"]) +@login_required +def delete_key_history(hid): + conn = get_db() + cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/del_key/") +@login_required +def del_key(id): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() + if row: + if is_fib_key_monitor_type(row["monitor_type"]): + _cancel_fib_monitor_limit(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,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," + "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," + "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason," + "exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at " + "FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", + "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", + "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", + "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", + "exchange_realized_pnl", "exchange_opened_at", "exchange_closed_at", "created_at", "开仓类型", + ] + 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 "" + kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" + eff = er1 or er0 or entry_reason_from_key_signal(kst) or "" + snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] + data.append(( + r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], + snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], + r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], + r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], + r["exchange_realized_pnl"] if "exchange_realized_pnl" in r.keys() else None, + r["exchange_opened_at"] if "exchange_opened_at" in r.keys() else None, + r["exchange_closed_at"] if "exchange_closed_at" in r.keys() else None, + r["created_at"], eff, + )) + day = app_now().strftime("%Y%m%d") + return _csv_response(f"trade_records_v3_{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(): + 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,upper,lower,notification_count,last_alert_message,close_reason,closed_at " + "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_alert_message", + "close_reason", + "closed_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) + +@app.route("/del_order/") +@login_required +def del_order(id): + conn = get_db() + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() + if not row: + conn.close() + flash("订单不存在") + return redirect("/") + if row["status"] == "active": + try: + p = get_price(row["symbol"]) or float(row["trigger_price"]) + opened_at = get_opened_at_value(row) + closed_at = app_now_str() + hold_seconds = calc_hold_seconds(opened_at, app_now()) + pnl_amount = calc_pnl( + row["direction"], + row["trigger_price"], + p, + row["margin_capital"] or DAILY_START_CAPITAL, + row["leverage"] or infer_leverage(row["symbol"]) + ) + close_resp = close_exchange_order(row) + close_order_id = close_resp.get("id", "") + cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + session_date = row["session_date"] or get_trading_day() + session_capital = update_session_capital(conn, session_date, pnl_amount) + row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type=order_row_monitor_type(row), + key_signal_type=order_row_key_signal_type(row), + 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=margin_capital_for_trade_record(row_snap), + 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)) + clear_key_sizing_snapshot_if_flat(conn, session_date) + 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("/trade") + 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) + row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type=order_row_monitor_type(row), + key_signal_type=order_row_key_signal_type(row), + 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=margin_capital_for_trade_record(row_snap), + 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") + sym_in = normalize_symbol_input(d.get("symbol")) + ex_sym = normalize_exchange_symbol(sym_in) + try: + ensure_markets_loaded() + except Exception: + pass + try: + tp_px = round_price_to_exchange(ex_sym, float(d["tp"])) + sl_px = round_price_to_exchange(ex_sym, float(d["sl"])) + tgt_px = round_price_to_exchange(ex_sym, float(d["tgt"])) + except Exception: + flash("价格格式错误") + return _redirect_records() + conn = get_db() + insert_trade_record( + conn, + symbol=sym_in, + monitor_type=d["type"], + direction=direction, + trigger_price=tp_px, + stop_loss=sl_px, + take_profit=tgt_px, + 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]}" + close_ms = _local_input_datetime_to_ms(d.get("close_datetime")) + marker_payload = { + "exit_ts_ms": close_ms, + "entry_ts_ms": close_ms, + "entry_price": d.get("entry_price_hint"), + "exit_price": None, + } + try: + chart_fname = f"journal_{entry_id}.png" + saved = generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=ORDER_CHART_TFS, + limit=ORDER_CHART_LIMIT, + out_dir=app.config["UPLOAD_FOLDER"], + filename=chart_fname, + filename_prefix="journal", + marker_payload=marker_payload, + marker_timeframes=( + {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()} + if ORDER_CHART_TFS + else {"5m", "15m", "1h", "4h"} + ), + ) + if saved: + image_filename = saved + chart_msg = f"已生成多周期K线图:/static/images/{saved}" + if uploaded_tmp: + try: + old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) + if os.path.exists(old_path): + os.remove(old_path) + except Exception: + pass + else: + chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Gate 网络/代理是否正常。" + except Exception as e: + image_filename = uploaded_tmp + chart_msg = f"自动生成K线图失败:{str(e)}" + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, d.get("open_datetime"), d.get("close_datetime"), hold_duration, d.get("coin"), d.get("tf"), + d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, + early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, + None, None, None, mood_issues, + d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename + ) + ) + conn.commit() + conn.close() + if chart_msg: + flash(f"交易复盘记录已保存。{chart_msg}") + else: + flash("交易复盘记录已保存") + return _redirect_records() + + +@app.route("/api/journals") +@login_required +def api_journals(): + 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 journal_entries WHERE COALESCE(close_datetime, created_at, open_datetime) >= ? " + "AND COALESCE(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/", 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/") +@login_required +def export_review_md(rid): + conn = get_db() + row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() + conn.close() + if not row: + return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") + + review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" + target_date = row["target_date"] or "-" + created_at = row["created_at"] or app_now_str() + content = (row["content"] or "").strip() + if not content: + content = "(无内容)" + + md = ( + f"# {review_type}报告\n\n" + f"- 目标日期: {target_date}\n" + f"- 生成时间: {created_at}\n" + f"- 报告ID: {row['id']}\n\n" + f"---\n\n" + f"{content}\n" + ) + + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + safe_type = "daily" if row["review_type"] == "daily" else "weekly" + filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" + return _md_response(filename, md) + + +@app.route("/export/reviews_md_bundle") +@login_required +def export_reviews_md_bundle(): + review_type = (request.args.get("review_type") or "").strip().lower() + target_date = (request.args.get("target_date") or "").strip() + if review_type not in ("daily", "weekly"): + return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") + if not target_date: + return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") + + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", + (review_type, target_date), + ).fetchall() + conn.close() + if not rows: + return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") + + title = "日复盘" if review_type == "daily" else "周复盘" + lines = [ + f"# {title}汇总报告", + "", + f"- 目标日期: {target_date}", + f"- 条目数量: {len(rows)}", + f"- 导出时间: {app_now_str()}", + "", + "---", + "", + ] + for idx, row in enumerate(rows, 1): + created_at = row["created_at"] or "-" + content = (row["content"] or "").strip() or "(无内容)" + lines.extend( + [ + f"## 第{idx}条", + "", + f"- 报告ID: {row['id']}", + f"- 生成时间: {created_at}", + "", + content, + "", + "---", + "", + ] + ) + md = "\n".join(lines) + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" + return _md_response(filename, md) + + +@app.route("/delete_review/", methods=["POST"]) +@login_required +def delete_review(rid): + conn = get_db() + conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/delete_trade_record/", methods=["POST"]) +@login_required +def delete_trade_record(rid): + conn = get_db() + cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) + + +@app.route("/api/trade_record_review_update", methods=["POST"]) +@login_required +def api_trade_record_review_update(): + payload = request.get_json(silent=True) or {} + rec_id = payload.get("id") + try: + rec_id = int(rec_id) + except Exception: + return jsonify({"ok": False, "msg": "记录ID无效"}), 400 + + reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() + reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() + reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") + reviewed_take_profit_raw = payload.get("reviewed_take_profit") + reviewed_result = str(payload.get("reviewed_result") or "").strip() + reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() + reviewed_pnl_raw = payload.get("reviewed_pnl_amount") + + if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: + return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400 + + try: + reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") + reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") + except Exception: + return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 + if reviewed_close_dt < reviewed_open_dt: + return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 + hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) + hold_minutes = calc_hold_minutes(hold_seconds) + + try: + reviewed_pnl_amount = float(reviewed_pnl_raw) + except Exception: + return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 + reviewed_stop_loss = None + if reviewed_stop_loss_raw not in (None, ""): + try: + reviewed_stop_loss = float(reviewed_stop_loss_raw) + except Exception: + return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 + reviewed_take_profit = None + if reviewed_take_profit_raw not in (None, ""): + try: + reviewed_take_profit = float(reviewed_take_profit_raw) + except Exception: + return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 + + _MISSING_ER = object() + reviewed_entry_reason_update = _MISSING_ER + if "reviewed_entry_reason" in payload: + s = str(payload.get("reviewed_entry_reason") or "").strip() + if s and not entry_reason_valid_for_storage(s): + return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400 + reviewed_entry_reason_update = s or None + + conn = get_db() + row = conn.execute("SELECT risk_amount, symbol FROM trade_records WHERE id=?", (rec_id,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "记录不存在"}), 404 + risk_amount = row["risk_amount"] + ex_review = resolve_ccxt_price_symbol(row["symbol"]) + try: + ensure_markets_loaded() + except Exception: + pass + if reviewed_stop_loss is not None: + reviewed_stop_loss = round_price_to_exchange(ex_review, reviewed_stop_loss) + if reviewed_take_profit is not None: + reviewed_take_profit = round_price_to_exchange(ex_review, reviewed_take_profit) + actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) + base_params = [ + reviewed_opened_at, + reviewed_closed_at, + reviewed_stop_loss, + reviewed_take_profit, + round(reviewed_pnl_amount, 4), + reviewed_result or None, + reviewed_miss_reason or None, + hold_seconds, + hold_minutes, + app_now_str(), + actual_rr, + ] + if reviewed_entry_reason_update is not _MISSING_ER: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? + WHERE id=?""", + tuple(base_params + [reviewed_entry_reason_update, rec_id]), + ) + else: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr) + WHERE id=?""", + tuple(base_params + [rec_id]), + ) + conn.commit() + conn.close() + return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) + + +@app.route("/manual_transfer", methods=["POST"]) +@login_required +def manual_transfer(): + try: + amount = float(request.form.get("amount", "0")) + except Exception: + flash("划转金额格式错误") + return redirect("/") + from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() + to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() + ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) + conn = get_db() + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + else: + flash(f"手动划转失败:{msg}") + return redirect("/") + + +@app.route("/ai_daily_review", methods=["POST"]) +@login_required +def ai_daily_review(): + date = request.form.get("date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", + (date,) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该日无交易记录"}) + + text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "每日", image_paths=image_paths) + full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "daily", date, full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + + +@app.route("/ai_weekly_review", methods=["POST"]) +@login_required +def ai_weekly_review(): + start_date = request.form.get("start_date", "") + end_date = request.form.get("end_date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", + (start_date, end_date) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该时间段无交易记录"}) + + text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "周度", image_paths=image_paths) + full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + +# 启动 +if __name__ == "__main__": + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index eb59768..29f2dc1 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -39,6 +39,8 @@ from history_window_lib import ( PRESET_UTC_LAST24H, PRESET_UTC_LAST7D, PRESET_UTC_TODAY, + list_window_redirect_query, + resolve_list_window, resolve_window, utc_window_to_bj_sql_strings, ) @@ -884,6 +886,7 @@ ENTRY_REASON_OPTIONS = ( "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", + "趋势回调", ) # 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) ENTRY_REASON_OTHER = "__OTHER__" @@ -898,7 +901,7 @@ def normalize_entry_reason(raw, custom_text=None): def entry_reason_valid_for_storage(s): - """允许五种固定整句、或自定义短文本(不含未解析的 __OTHER__ 占位)。""" + """允许固定开仓类型选项、或自定义短文本(不含未解析的 __OTHER__ 占位)。""" t = str(s or "").strip() if not t: return True @@ -946,6 +949,7 @@ def ai_extract_journal_from_image(image_b64): - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 + - 趋势回调 6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):保本止盈、移动止盈、手动平仓、止损、其他。 7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 @@ -3567,7 +3571,12 @@ def trend_plan_history_status_label(status): def _list_window_from_request(): - return resolve_window(request.args, default_preset=PRESET_UTC_TODAY) + 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): @@ -6700,7 +6709,7 @@ def add_miss(): conn.commit() conn.close() flash("已记录错过机会") - return redirect("/records") + return _redirect_records() @app.route("/add_journal", methods=["POST"]) @@ -6710,15 +6719,15 @@ def add_journal(): entry_reason_norm = normalize_entry_reason(d.get("entry_reason"), d.get("entry_reason_custom")) if not entry_reason_norm: flash("请选择开仓类型;若选「其他」请在下方填写自定义说明") - return redirect("/records") + 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") + return _redirect_records() if early_exit_trigger == "手动平仓" and not early_exit_note: flash("手工平仓必须填写补充说明") - return redirect("/records") + return _redirect_records() if early_exit_trigger != "手动平仓": early_exit_note = "" # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」 @@ -6816,14 +6825,20 @@ def add_journal(): flash(f"交易复盘记录已保存。{chart_msg}") else: flash("交易复盘记录已保存") - return redirect("/records") + 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("SELECT * FROM journal_entries ORDER BY created_at DESC").fetchall() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE COALESCE(close_datetime, created_at, open_datetime) >= ? " + "AND COALESCE(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: @@ -6871,8 +6886,13 @@ def delete_journal(jid): @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 ORDER BY created_at DESC").fetchall() + 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]) diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 95150a8..4a26630 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -380,45 +380,6 @@