Files
crypto_monitor/crypto_monitor_gate_bot/app.py
T
dekun 4fad5696df fix(gate_bot): exclude active trend plans from orphan position warning
Trend pullback plans manage positions before order_monitors handoff; treat them as covered and add a pre-deploy DB backup script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 09:40:14 +08:00

8212 lines
317 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response, send_file
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 ai_client import ai_generate, ai_review, ai_short_advice
from ai_review_lib import (
build_journal_ai_chart_path,
collect_images_for_ai_review,
journal_row_lines_for_ai,
)
from position_sizing_lib import (
assert_open_source_allowed,
compute_full_margin_sizing,
format_risk_display_text,
full_margin_requires_flat_position,
is_full_margin_mode,
leverage_for_full_margin,
load_position_sizing_mode,
mode_label_zh,
risk_percent_for_storage,
)
from key_monitor_full_margin_lib import (
monitor_type_disallowed_in_full_margin,
purge_disallowed_key_monitors,
)
from auto_transfer_daily_lib import run_auto_transfer_once_per_day
from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from order_monitor_display_lib import (
apply_order_live_price_display,
apply_order_price_display_fields,
enrich_order_display_fields,
stop_is_profit_protecting,
tpsl_slot_trigger_price,
tpsl_update_passes_rr_gate,
)
from journal_chart_lib import (
JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1,
JOURNAL_CHART_DEFAULT_TF2,
JOURNAL_CHART_TF_CHOICES,
compose_chart_panels,
marker_points_for_timeframe,
parse_journal_chart_anchor,
parse_journal_chart_limit,
parse_journal_chart_timeframes,
JOURNAL_CHART_DEFAULT_ANCHOR,
price_levels_from_marker_payload,
render_candles_subplot,
trade_review_fetch_window,
trim_rows_for_trade_review,
)
from hub_auth import request_allowed as hub_request_allowed
from strategy_trade_labels import (
STRATEGY_ENTRY_REASON_OPTIONS,
apply_order_monitor_source_labels,
handoff_trade_miss_reason,
trade_record_monitor_type as resolve_trade_record_monitor_type,
trend_plan_id_from_monitor_row,
)
from history_window_lib import (
PRESET_CUSTOM,
PRESET_UTC_LAST24H,
PRESET_UTC_LAST7D,
PRESET_UTC_TODAY,
list_window_redirect_query,
normalize_bj_datetime_storage,
resolve_list_window,
resolve_window,
sql_list_time_field,
utc_window_to_bj_sql_strings,
utc_window_to_utc_sql_strings,
)
def load_env_file(path):
if not os.path.exists(path):
return
raw_bytes = open(path, "rb").read()
text = ""
for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"):
try:
text = raw_bytes.decode(enc)
break
except Exception:
continue
if not text:
text = raw_bytes.decode("utf-8", errors="ignore")
text = text.replace("\x00", "")
for line in text.splitlines():
raw = line.strip()
if not raw or raw.startswith("#") or "=" not in raw:
continue
key, value = raw.split("=", 1)
clean_key = key.strip().lstrip("\ufeff")
if not clean_key.replace("_", "").isalnum():
continue
clean_value = value.strip().strip('"').strip("'")
os.environ[clean_key] = clean_value
load_env_file(os.path.join(BASE_DIR, ".env"))
def resolve_path(path_value):
if os.path.isabs(path_value):
return path_value
return os.path.join(BASE_DIR, path_value)
app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "crypto_monitor_2026_secret_key")
def trend_add_zone_label(direction):
"""趋势回调:做多=补仓上沿,做空=补仓下沿(库字段仍为 add_upper)。"""
return "补仓上沿" if (direction or "long").strip().lower() == "long" else "补仓下沿"
@app.context_processor
def _inject_trend_ui_helpers():
return {"trend_add_zone_label": trend_add_zone_label}
# ====================== 登录配置 ======================
USERNAME = os.getenv("APP_USERNAME", "dekun")
PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@")
AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on")
# 企业微信机器人Webhook
WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me")
SYSTEM_TYPE = "CRYPTO"
HOST = os.getenv("APP_HOST", "0.0.0.0")
PORT = int(os.getenv("APP_PORT", "5000"))
DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true"
DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db"))
# 训练参数(可由 .env 覆盖)
DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30"))
DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20"))
DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50"))
BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10"))
ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5"))
# 与 Gate 主站一致:最大同时 active 下单监控数(默认 1=单仓)
MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8
TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))
# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR
TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv(
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true"
).lower() in ("1", "true", "yes", "on")
APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai")
# 交易所「平仓历史」同步:自北京日期 00:00 起(与 APP_TIMEZONE 一致);空则取最近 90 天
EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip()
EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200"))))
_LAST_POSITION_HISTORY_SYNC_AT = 0.0
def _resolve_app_tz():
if ZoneInfo is not None:
try:
return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip())
except Exception:
pass
return timezone(timedelta(hours=8))
APP_TZ = _resolve_app_tz()
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip()
GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip()
GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower()
GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
# 永续仓位止盈止损触发单:POST /futures/{settle}/price_ordersorder_type=close-*-position(全平)
GATE_TPSL_TRIGGER_EXPIRATION = int(os.getenv("GATE_TPSL_TRIGGER_EXPIRATION", str(7 * 86400)))
GATE_TPSL_PRICE_TYPE = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0"))
if GATE_TPSL_PRICE_TYPE < 0 or GATE_TPSL_PRICE_TYPE > 2:
GATE_TPSL_PRICE_TYPE = 0
GATE_TPSL_USE_POSITION_ORDER = os.getenv("GATE_TPSL_USE_POSITION_ORDER", "true").lower() in ("1", "true", "yes")
# 页面展示的交易所名称(多实例/多环境时可按需区分)
EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Gate.io").strip() or "Gate.io"
_GATE_DEFAULT_MARGIN_MODE = "cross" if GATE_TD_MODE in ("cross", "cross_margin") else "isolated"
BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60"))
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3"))
KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5"))
KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5"))
AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true"
AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30"))
AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding")
AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap")
FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true"
FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0"))
# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账
AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8"))
POSITION_SIZING_MODE = load_position_sizing_mode()
WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10"))
AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120"))
MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3"))
# 趋势回调:补仓触发档位数(平分剩余 50% 计划仓位)
TREND_PULLBACK_DCA_LEGS = max(1, int(os.getenv("TREND_PULLBACK_DCA_LEGS", "5")))
# 预览有效期(秒);超时须重新「生成预览」
TREND_PULLBACK_PREVIEW_TTL_SECONDS = max(10, int(os.getenv("TREND_PULLBACK_PREVIEW_TTL_SECONDS", "120")))
# 确认执行时:当前可用余额与预览快照相对偏差超过该百分比则拒绝(避免余额被划走后仍按旧计划满仓)
TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT = float(os.getenv("TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT", "5"))
# 趋势回调:手动保本默认相对均价偏移(%);多=均价×(1+pct/100),空=均价×(1-pct/100)
TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT = float(
os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")
)
MONITOR_TYPE_TREND = "趋势回调"
KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98"))
TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT")
UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images"))
ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100"))
ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts"))
DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5"))
RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2"))
MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
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()
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
- .envGATE_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):
from wechat_notify_lib import send_wechat_webhook
send_wechat_webhook(
WECHAT_WEBHOOK, content, timeout=WECHAT_TIMEOUT_SECONDS
)
_BREAKEVEN_EXCHANGE_WARNED_IDS = set()
def _send_breakeven_exchange_warn_once(order_id, message):
"""移动保本同步交易所失败:同一笔监控单只推送一次,避免轮询刷屏。"""
oid = int(order_id)
if oid in _BREAKEVEN_EXCHANGE_WARNED_IDS:
return
_BREAKEVEN_EXCHANGE_WARNED_IDS.add(oid)
send_wechat_msg(message)
def _clear_breakeven_exchange_warn(order_id):
_BREAKEVEN_EXCHANGE_WARNED_IDS.discard(int(order_id))
def _wechat_account_label():
return (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip()
def _wechat_direction_text(direction):
d = (direction or "").lower()
return "多头(long" if d == "long" else "空头(short"
def _wechat_trading_capital_text(fallback=None):
try:
_, trading_capital = get_exchange_capitals(force=True)
except Exception:
trading_capital = None
if trading_capital is not None:
return f"{round(float(trading_capital), 4)}U"
if fallback is not None:
try:
return f"{round(float(fallback), 4)}U"
except Exception:
pass
return "-"
def build_wechat_close_message(
symbol,
direction,
result,
pnl_amount,
hold_seconds=None,
trigger_price=None,
current_price=None,
stop_loss=None,
take_profit=None,
close_order_id=None,
extra_note=None,
session_capital_fallback=None,
):
hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-"
ep = format_price_for_symbol(symbol, trigger_price)
cp = format_price_for_symbol(symbol, current_price)
tp = format_price_for_symbol(symbol, take_profit)
sl = format_price_for_symbol(symbol, stop_loss)
cap_txt = _wechat_trading_capital_text(session_capital_fallback)
try:
if pnl_amount is not None:
pv = float(pnl_amount)
pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 4)} U"
else:
pnl_disp = "-"
except (TypeError, ValueError):
pnl_disp = "-"
lines = [
f"📉 {symbol} 平仓完成",
f"💼 账户:{_wechat_account_label()}",
"",
"🧾 平仓概要",
f"🔖 平仓单号:{close_order_id or '-'}",
f"📌 方向:{_wechat_direction_text(direction)}",
f"📌 平仓结果:{result or '-'}",
f"💰 本单盈亏:{pnl_disp}",
f"⏱ 持仓时长:{hold_txt}",
f"💵 交易账户资金:{cap_txt}",
"",
"🎯 价位(计划)",
f"开仓成交价:{ep}",
f"离场参考价:{cp}",
f"止盈价位:{tp}",
f"止损价位:{sl}",
]
if extra_note:
lines.extend(["", "📎 备注", extra_note])
return "\n".join(lines)
def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl):
sl_fmt = format_price_for_symbol(symbol, new_sl)
return "\n".join(
[
f"# 🛡️ {symbol} 保护位更新",
f"**账户:{_wechat_account_label()}**",
"",
"---",
"",
"### 移动保本/止盈",
f"- 方向:**{_wechat_direction_text(direction)}**",
f"- 类型:**{arm_txt}**",
f"- 当前RR`{round(float(now_rr), 2)}R`",
f"- 锁定RR`{round(float(locked_r), 2)}R`",
f"- 新保护位:`{sl_fmt}`",
]
)
def build_wechat_monitor_error_message(symbol, direction, scene, error_text):
return "\n".join(
[
f"# ⚠️ {symbol} 下单监控异常",
f"**账户:{_wechat_account_label()}**",
"",
"---",
"",
"### 异常信息",
f"- 方向:**{_wechat_direction_text(direction)}**",
f"- 场景:{scene}",
f"- 错误:{str(error_text)}",
]
)
def build_wechat_key_monitor_message(
symbol,
direction,
monitor_type,
trigger_time,
key_price,
confirm_close,
hard_lines,
btc8h_status,
coin4h_status,
swing4h_pct,
op_lines,
risk_tip=None,
):
lines = [
f"# 🎯 {symbol} 关键位确认推送",
f"**账户:{_wechat_account_label()}**",
"",
"---",
"",
"### 交易对 / 触发时间",
f"- 交易对:**{symbol}**",
f"- 触发时间:`{trigger_time}`",
"",
"### 方向与确认K",
f"- 方向:**{_wechat_direction_text(direction)}**",
"- 确认K:第二根5m收盘完成",
"",
"### 关键价位",
f"- 类型:**{monitor_type}**",
f"- 箱体关键位:`{key_price}`",
f"- 第二根确认收盘价:`{confirm_close}`",
"",
"### 硬条件校验结果",
]
lines.extend([f"- {x}" for x in hard_lines])
lines.extend(
[
"",
"### 市场状态说明",
f"- BTC 8h 状态:**{btc8h_status}**",
f"- 本币 4h(EMA55) 状态:**{coin4h_status}**",
f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`",
"",
"### 操作提示",
]
)
lines.extend([f"- {x}" for x in op_lines])
if risk_tip:
lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"])
return "\n".join(lines)
def _read_image_base64(image_path):
try:
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
except Exception:
return None
def _extract_json_object(text):
if not text:
return None
clean = text.strip()
if clean.startswith("```"):
clean = clean.replace("```json", "").replace("```", "").strip()
try:
return json.loads(clean)
except Exception:
pass
match = re.search(r"\{[\s\S]*\}", clean)
if not match:
return None
try:
return json.loads(match.group(0))
except Exception:
return None
def _load_font(size):
if not ImageFont:
return None
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"C:\\Windows\\Fonts\\msyh.ttc",
"C:\\Windows\\Fonts\\arial.ttf",
]
for path in candidates:
if path and os.path.exists(path):
try:
return ImageFont.truetype(path, size)
except Exception:
continue
try:
return ImageFont.load_default()
except Exception:
return None
def _ohlcv_to_rows(ohlcv):
rows = []
for bar in ohlcv or []:
if not bar or len(bar) < 6:
continue
try:
rows.append(
{
"ts": int(bar[0]),
"o": float(bar[1]),
"h": float(bar[2]),
"l": float(bar[3]),
"c": float(bar[4]),
"v": float(bar[5]),
}
)
except Exception:
continue
return rows
def _local_input_datetime_to_ms(dt_text):
raw = str(dt_text or "").strip()
if not raw:
return None
raw = raw.replace("T", " ")
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
try:
dt = datetime.strptime(raw, fmt)
aware = dt.replace(tzinfo=APP_TZ)
return int(aware.timestamp() * 1000)
except Exception:
continue
return None
def _marker_tag_label(tag):
t = str(tag or "").strip().upper()
if t == "ENTRY":
return "开仓"
if t == "EXIT":
return "平仓"
return str(tag or "")
def _timeframe_period_ms(tf):
s = (tf or "").strip().lower()
if s.endswith("m"):
try:
return int(s[:-1]) * 60 * 1000
except ValueError:
pass
if s.endswith("h"):
try:
return int(s[:-1]) * 3600 * 1000
except ValueError:
pass
if s.endswith("d"):
try:
return int(s[:-1]) * 86400 * 1000
except ValueError:
pass
return 300000
def _ohlcv_dict_rows_to_lists(rows, lim):
if not rows:
return []
pick = rows[-lim:] if len(rows) >= lim else rows
return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick]
def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms):
"""以 end_ts_ms 为终点向前取 K 线(无 end 则拉最近 limit 根)。"""
lim = max(2, int(limit or ORDER_CHART_LIMIT))
try:
if not end_ts_ms:
ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim)
else:
period = _timeframe_period_ms(timeframe)
since = int(end_ts_ms) - period * (lim + 10)
ohlcv = exchange.fetch_ohlcv(
exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20
)
except Exception:
return []
rows = _ohlcv_to_rows(ohlcv)
if not rows:
return []
if not end_ts_ms:
return _ohlcv_dict_rows_to_lists(rows, lim)
filtered = [r for r in rows if int(r["ts"]) <= int(end_ts_ms)]
if len(filtered) >= 2:
return _ohlcv_dict_rows_to_lists(filtered, lim)
return _ohlcv_dict_rows_to_lists(rows, lim)
def _pick_marker_point(rows, target_ts_ms, target_price=None):
if not rows or target_ts_ms is None:
return None, None
idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms)))
if target_price is not None:
try:
p = float(target_price)
if p > 0:
return idx, p
except Exception:
pass
return idx, float(rows[idx]["c"])
def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None):
if not Image or not ImageDraw:
raise RuntimeError("缺少依赖:Pillowpip install Pillow")
img = Image.new("RGB", (width, height), bg_rgb)
draw = ImageDraw.Draw(img)
font = _load_font(14)
small = _load_font(12)
pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28
plot_w = max(10, width - pad_l - pad_r)
plot_h = max(10, height - pad_t - pad_b)
header_bg = (245, 247, 250)
draw.rectangle((0, 0, width, pad_t), fill=header_bg)
if font:
draw.text((10, 6), title, fill=(25, 35, 60), font=font)
else:
draw.text((10, 6), title, fill=(25, 35, 60))
if not rows:
if small:
draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small)
else:
draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120))
return img
lo = min(r["l"] for r in rows)
hi = max(r["h"] for r in rows)
if hi <= lo:
hi = lo + 1e-12
n = len(rows)
marker_by_idx = {}
for mp in marker_points or []:
try:
idx = int(mp.get("idx"))
except Exception:
continue
if idx < 0 or idx >= n:
continue
marker_by_idx.setdefault(idx, []).append(mp)
x0 = pad_l
for i, r in enumerate(rows):
x1 = pad_l + int((i + 1) * plot_w / n)
x_mid = (x0 + x1) // 2
wick_x = x_mid
y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h)
y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h)
y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h)
y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h)
top = min(y_open, y_close)
bot = max(y_open, y_close)
up = r["c"] >= r["o"]
wick_color = (120, 120, 120)
edge_color = (20, 20, 20)
draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color)
body_w = max(1, (x1 - x0) - 2)
left = x0 + 1
if bot - top < 2:
mid = (top + bot) // 2
draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color)
else:
if up:
draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1)
else:
draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1)
for j, mp in enumerate(marker_by_idx.get(i, [])):
tag = str(mp.get("tag") or "")
label = _marker_tag_label(tag)
m_price = float(mp.get("price") or r["c"])
y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h)
y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m))
x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14
x_draw = int(x_mid + x_off)
if tag == "ENTRY":
m_color = (0, 195, 95)
tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)]
text_y = y_m - 36
else:
m_color = (235, 65, 65)
tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)]
text_y = y_m + 12
draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1)
draw.polygon(tri, fill=m_color)
draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3)
if font:
draw.text((x_draw + 8, text_y), label, fill=m_color, font=font)
else:
draw.text((x_draw + 8, text_y), label, fill=m_color)
x0 = x1
if len(marker_points or []) >= 2:
try:
entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None)
exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None)
if entry is not None and exitp is not None:
ex_i, ex_p = int(entry["idx"]), float(entry["price"])
xx_i, xx_p = int(exitp["idx"]), float(exitp["price"])
x_ex = pad_l + int((ex_i + 0.5) * plot_w / n)
x_xx = pad_l + int((xx_i + 0.5) * plot_w / n)
y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h)
y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h)
draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3)
except Exception:
pass
# 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息
if small:
draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small)
return img
def generate_multi_timeframe_chart_png(
exchange_symbol,
title_prefix,
timeframes=None,
limit=None,
out_dir=None,
filename=None,
filename_prefix="chart",
marker_payload=None,
marker_timeframes=None,
layout="grid",
):
if not ORDER_CHART_ENABLED:
return None
if not Image:
return None
requested = list(timeframes or ORDER_CHART_TFS)
limit = limit or ORDER_CHART_LIMIT
if layout == "vertical":
timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
else:
preferred_layout = ["5m", "15m", "1h", "4h"]
requested_set = set(requested or [])
ordered = [tf for tf in preferred_layout if tf in requested_set]
for tf in requested:
if tf not in ordered:
ordered.append(tf)
timeframes = ordered[:4] if ordered else preferred_layout
ensure_markets_loaded()
panels = []
cell_w, cell_h = 980, 520
end_ts_ms = None
if marker_payload:
try:
end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None
except (TypeError, ValueError):
end_ts_ms = None
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
price_levels = price_levels_from_marker_payload(marker_payload)
for tf in timeframes:
rows = []
try:
if layout == "vertical" and marker_payload:
win = trade_review_fetch_window(
marker_payload.get("entry_ts_ms"),
marker_payload.get("exit_ts_ms"),
tf,
limit,
anchor=marker_payload.get("chart_anchor"),
now_ms=marker_payload.get("now_ts_ms"),
)
if win:
ohlcv = exchange.fetch_ohlcv(
exchange_symbol,
timeframe=tf,
since=max(0, int(win["since_ms"])),
limit=int(win["fetch_limit"]),
)
rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win)
if not rows:
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)
rows = _ohlcv_to_rows(ohlcv)[-limit:]
except Exception:
rows = []
title = f"{title_prefix} | {tf} x{len(rows)}"
tf_key = str(tf).strip().lower()
if marker_payload:
if marker_timeframes:
marker_tfs = {str(x).strip().lower() for x in marker_timeframes if str(x).strip()}
else:
marker_tfs = default_marker_tfs
else:
marker_tfs = set()
points = (
marker_points_for_timeframe(rows, marker_payload)
if marker_payload and tf_key in marker_tfs
else []
)
panels.append(
render_candles_subplot(
rows,
title,
width=cell_w,
height=cell_h,
bg_rgb=(255, 255, 255),
marker_points=points,
price_levels=price_levels,
)
)
if not panels:
return None
out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10)
if out is None:
return None
target_dir = out_dir or ORDER_CHART_DIR
os.makedirs(target_dir, exist_ok=True)
fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png"
out_path = os.path.join(target_dir, fname)
out.save(out_path, format="PNG")
return fname
def generate_order_open_chart(
exchange_symbol,
title_prefix,
timeframes=None,
limit=None,
opened_at_ms=None,
entry_price=None,
):
marker_payload = None
if opened_at_ms:
marker_payload = {
"entry_ts_ms": opened_at_ms,
"exit_ts_ms": None,
"entry_price": entry_price,
"exit_price": None,
}
marker_tfs = (
{x.strip().lower() for x in (timeframes or ORDER_CHART_TFS) if x and str(x).strip()}
or {"5m", "15m", "1h", "4h"}
)
return generate_multi_timeframe_chart_png(
exchange_symbol,
title_prefix,
timeframes=timeframes,
limit=limit,
out_dir=ORDER_CHART_DIR,
filename=None,
filename_prefix="order",
marker_payload=marker_payload,
marker_timeframes=marker_tfs,
)
def journal_coin_from_symbol(symbol):
sym = (symbol or "").strip().upper()
if not sym:
return ""
if "/" in sym:
return sym.split("/")[0].strip()
if "-" in sym:
return sym.split("-")[0].strip()
if sym.endswith("USDT"):
return sym[:-4].strip()
return sym
EARLY_EXIT_TRIGGERS = (
"",
"保本止盈",
"移动止盈",
"手动平仓",
"止损",
"其他",
)
# 与用户约定的固定开仓类型(仅做这几类单子)
ENTRY_REASON_OPTIONS = (
"趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低",
"趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高",
"趋势多头:小分歧低吸入场(左侧),确认条件:二次探底",
"趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶",
"波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20",
"趋势回调",
"顺势加仓",
)
# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom
ENTRY_REASON_OTHER = "__OTHER__"
def normalize_entry_reason(raw, custom_text=None):
v = str(raw or "").strip()
if v == ENTRY_REASON_OTHER:
c = str(custom_text or "").strip()
return c[:2000] if c else ""
return v if v in ENTRY_REASON_OPTIONS else ""
def entry_reason_valid_for_storage(s):
"""允许固定开仓类型选项、或自定义短文本(不含未解析的 __OTHER__ 占位)。"""
t = str(s or "").strip()
if not t:
return True
if t == ENTRY_REASON_OTHER:
return False
if t in ENTRY_REASON_OPTIONS:
return True
return 1 <= len(t) <= 2000
def normalize_early_exit_trigger(raw):
v = str(raw or "").strip()
return v if v in EARLY_EXIT_TRIGGERS else ""
def compose_early_exit_reason_saved(trigger, note):
"""Readable single-line string stored in early_exit_reason for legacy consumers."""
t = normalize_early_exit_trigger(trigger)
n = str(note or "").strip()
if t and n:
return f"{t}{n}"
return t or n
def journal_exit_reason_stored(trigger, note):
"""exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。"""
t = normalize_early_exit_trigger(trigger)
n = str(note or "").strip()
if t == "手动平仓":
return n
return t
def ai_extract_journal_from_image(image_b64):
prompt = """
你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。
要求:
1) 仅输出一个 JSON 对象。
2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。
3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。
4) 允许字段为空。
5) entry_reason:优先从下列完整字符串中选一个(一字不差);若无法归类则可将简述写入 entry_reason(保存时也可选表单「其他」手写):
- 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低
- 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高
- 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底
- 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶
- 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20
- 趋势回调
6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):保本止盈、移动止盈、手动平仓、止损、其他。
7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。
8) 若图中有无法归类的离场说明原文,可放进 early_exit_noteearly_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()
try:
raw = ai_generate(prompt, images_b64=[image_b64], temperature=0.1)
if raw.startswith("AI 调用失败"):
return {}
data = _extract_json_object(raw) or {}
if not isinstance(data, dict):
data = {}
trig_in = data.get("early_exit_trigger")
note_in = data.get("early_exit_note")
legacy_reason = str(data.get("early_exit_reason") or "").strip()
out = {
"open_datetime": str(data.get("open_datetime") or "").strip(),
"close_datetime": str(data.get("close_datetime") or "").strip(),
"coin": str(data.get("coin") or "").strip(),
"tf": str(data.get("tf") or "").strip(),
"pnl": str(data.get("pnl") or "").strip(),
"expect_rr": str(data.get("expect_rr") or "").strip(),
"real_rr": str(data.get("real_rr") or "").strip(),
"entry_reason": normalize_entry_reason(data.get("entry_reason")),
"early_exit_trigger": normalize_early_exit_trigger(trig_in),
"early_exit_note": str(note_in or "").strip(),
"early_exit_reason": legacy_reason,
"note": str(data.get("note") or "").strip(),
}
if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason:
out["early_exit_note"] = legacy_reason
if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason:
out["early_exit_note"] = legacy_reason
if out["early_exit_trigger"] != "手动平仓":
out["early_exit_note"] = ""
out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"])
return out
except Exception:
return None
# 初始化数据库(支持多空方向)
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# 关键位监控
c.execute('''CREATE TABLE IF NOT EXISTS key_monitors
(id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT,
direction TEXT DEFAULT "long", upper REAL, lower REAL,
notification_count INTEGER DEFAULT 0, last_notified_at TEXT,
max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5,
breakout_limit_pct REAL DEFAULT 1.5,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
# 订单监控(核心:加 direction 方向字段)
c.execute('''CREATE TABLE IF NOT EXISTS order_monitors
(id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long",
exchange_symbol TEXT,
trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL,
margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5,
trade_style TEXT DEFAULT "trend",
risk_percent REAL, risk_amount REAL,
breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL,
breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL,
notional_value REAL, position_ratio REAL, base_amount REAL,
order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT,
opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT,
status TEXT DEFAULT "active")''')
# 交易记录(必须存多空)
c.execute('''CREATE TABLE IF NOT EXISTS trade_records
(id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT,
direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL,
margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0,
trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL,
hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER,
result TEXT, miss_reason TEXT, exchange_trade_id TEXT,
reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL,
reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER,
reviewed_at TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions
(session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
c.execute('''CREATE TABLE IF NOT EXISTS journal_entries
(id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT,
coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT,
expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT,
early_exit_trigger TEXT, early_exit_note TEXT,
mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT,
new_trade_while_occupied TEXT, note TEXT, image TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews
(id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs
(id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT,
amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''')
c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique
ON transfer_logs(transfer_type, transfer_day)
WHERE transfer_type = 'auto_daily' ''')
# 给旧表加 direction 字段(兼容老数据,不报错)
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT")
except: pass
try:
c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1")
except Exception:
pass
try:
c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT")
except: pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN trend_plan_id INTEGER")
except Exception:
pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL")
except Exception:
pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT")
except Exception:
pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT")
except Exception:
pass
try:
c.execute("ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT")
except Exception:
pass
try:
c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER")
except: pass
try:
c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT")
except: pass
try:
c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT")
except: pass
try:
c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT")
except: pass
try:
c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'")
except: pass
try:
c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0")
except: pass
try:
c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT")
except: pass
try:
c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3")
except: pass
try:
c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5")
except: pass
try:
c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5")
except: pass
c.execute(
"""CREATE TABLE IF NOT EXISTS key_monitor_history
(id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT,
upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT,
close_reason TEXT, closed_at TEXT)"""
)
c.execute(
"""CREATE TABLE IF NOT EXISTS trend_pullback_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT DEFAULT 'active',
symbol TEXT NOT NULL,
exchange_symbol TEXT,
direction TEXT NOT NULL DEFAULT 'long',
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL DEFAULT 5,
snapshot_available_usdt REAL,
snapshot_at TEXT,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER DEFAULT 5,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
legs_done INTEGER DEFAULT 0,
first_order_done INTEGER DEFAULT 0,
last_mark_price REAL,
avg_entry_price REAL,
order_amount_open REAL,
opened_at TEXT,
opened_at_ms INTEGER,
session_date TEXT,
message TEXT
)"""
)
try:
c.execute("ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT")
except Exception:
pass
for ddl in (
"ALTER TABLE trend_pullback_plans ADD COLUMN initial_stop_loss REAL",
"ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied INTEGER DEFAULT 0",
"ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied_at TEXT",
):
try:
c.execute(ddl)
except Exception:
pass
c.execute(
"""CREATE TABLE IF NOT EXISTS trend_pullback_previews (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
exchange_symbol TEXT NOT NULL,
direction TEXT NOT NULL,
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL NOT NULL,
snapshot_available_usdt REAL NOT NULL,
snapshot_at TEXT,
live_price_ref REAL,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
expires_at_ms INTEGER NOT NULL,
created_at TEXT
)"""
)
c.execute(
"""CREATE TABLE IF NOT EXISTS trend_pullback_preview_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
preview_id TEXT NOT NULL UNIQUE,
symbol TEXT NOT NULL,
exchange_symbol TEXT NOT NULL,
direction TEXT NOT NULL,
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL NOT NULL,
snapshot_available_usdt REAL NOT NULL,
snapshot_at TEXT,
live_price_ref REAL,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
expires_at_ms INTEGER NOT NULL,
preview_created_at TEXT,
outcome TEXT DEFAULT 'open',
executed_plan_id INTEGER
)"""
)
for ddl in (
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN preview_created_at TEXT",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN outcome TEXT DEFAULT 'open'",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN executed_plan_id INTEGER",
):
try:
c.execute(ddl)
except Exception:
pass
from strategy_db import init_strategy_tables
init_strategy_tables(conn)
conn.commit()
conn.close()
init_db()
def _purge_key_monitors_if_full_margin():
if not is_full_margin_mode(POSITION_SIZING_MODE):
return
conn = get_db()
try:
purge_disallowed_key_monitors(
conn,
sizing_mode=POSITION_SIZING_MODE,
select_rows=lambda c: c.execute("SELECT * FROM key_monitors").fetchall(),
cancel_fib_limit=lambda _row: None,
delete_monitor=lambda c, kid: c.execute("DELETE FROM key_monitors WHERE id=?", (kid,)),
send_wechat=send_wechat_msg,
)
conn.commit()
except Exception as e:
print(f"[full_margin] purge key monitors: {e}", flush=True)
finally:
conn.close()
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():
"""当前时刻(UTCaware)。"""
return datetime.now(timezone.utc)
def utc_calendar_date_str():
"""UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。"""
return utc_now_dt().strftime("%Y-%m-%d")
def get_trading_day(now=None):
"""交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。"""
now = now or app_now()
if getattr(now, "tzinfo", None):
now = now.astimezone(APP_TZ).replace(tzinfo=None)
if now.hour < TRADING_DAY_RESET_HOUR:
return (now - timedelta(days=1)).strftime("%Y-%m-%d")
return now.strftime("%Y-%m-%d")
TRADE_COMPLETED_RESULTS = (
"止盈",
"止损",
"保本止盈",
"移动止盈",
"手动平仓",
"强制清仓",
"外部平仓",
)
REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓")
def parse_dt_for_trading_day(s):
if not s:
return None
s = str(s).strip().replace("Z", "").replace("T", " ")
if not s:
return None
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
try:
return datetime.strptime(s[:ln], fmt)
except ValueError:
continue
return None
def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason):
conn.execute(
"""INSERT INTO key_monitor_history
(symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
row["symbol"],
row["monitor_type"],
row["direction"] or "long",
row["upper"],
row["lower"],
int(notification_count or 0),
(last_msg or "")[:800] if last_msg else None,
close_reason,
app_now_str(),
),
)
def _session_week_bounds(trading_day_str):
end = datetime.strptime(trading_day_str, "%Y-%m-%d").date()
start = end - timedelta(days=6)
return start.strftime("%Y-%m-%d"), trading_day_str
def _calendar_month_bounds(local_dt):
y, m = local_dt.year, local_dt.month
start = f"{y:04d}-{m:02d}-01"
if m == 12:
end_d = datetime(y, 12, 31).date()
else:
end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date()
return start, end_d.strftime("%Y-%m-%d")
def _count_opens_between(conn, start_td, end_td):
return conn.execute(
"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?",
(start_td, end_td),
).fetchone()[0]
def _count_trend_plan_opens_between(conn, start_td, end_td):
"""趋势回调:按计划在库里的 session_date(开仓所属北京交易日)计数。"""
return conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE session_date IS NOT NULL AND TRIM(session_date) != '' "
"AND session_date >= ? AND session_date <= ?",
(start_td, end_td),
).fetchone()[0]
def _load_completed_trade_pnls(conn, monitor_type: str):
"""已平仓实盘记录:按 monitor_type 过滤;趋势回调优先用交易所同步盈亏。"""
q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at,
result, reviewed_result, exchange_realized_pnl
FROM trade_records
WHERE monitor_type = ?
ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC"""
rows = conn.execute(q, (monitor_type,)).fetchall()
out = []
for r in rows:
effective_result = (r["reviewed_result"] or r["result"] or "").strip()
if effective_result not in TRADE_COMPLETED_RESULTS:
continue
try:
if monitor_type == MONITOR_TYPE_TREND:
ex = None
try:
ex = r["exchange_realized_pnl"]
except (KeyError, IndexError, TypeError):
ex = None
if ex is not None and str(ex).strip() != "":
p = float(ex)
else:
p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0))
else:
p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0))
except (TypeError, ValueError):
p = 0.0
t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"])
td = get_trading_day(t) if t else None
out.append((p, t, td))
return out
def _compute_period_metrics(trades):
"""trades: list of (pnl, close_dt, close_trading_day)"""
trades = [(p, t, td) for p, t, td in trades if t is not None]
trades.sort(key=lambda x: x[1])
closed = len(trades)
wins = sum(1 for p, _, _ in trades if p > 0)
losses = sum(1 for p, _, _ in trades if p < 0)
net = round(sum(p for p, _, _ in trades), 4)
loss_sum_raw = sum(p for p, _, _ in trades if p < 0)
loss_sum_u = round(abs(loss_sum_raw), 4) if loss_sum_raw < 0 else 0.0
neg_pnls = [p for p, _, _ in trades if p < 0]
pos_pnls = [p for p, _, _ in trades if p > 0]
max_single_loss = round(min(neg_pnls), 4) if neg_pnls else None
max_single_profit = round(max(pos_pnls), 4) if pos_pnls else None
cum = peak = max_dd = 0.0
for p, _, _ in trades:
cum += p
peak = max(peak, cum)
max_dd = max(max_dd, peak - cum)
max_dd = round(max_dd, 4)
streak = 0
for p, _, _ in reversed(trades):
if p < 0:
streak += 1
else:
break
daily = {}
for p, _, td in trades:
if td:
daily[td] = daily.get(td, 0.0) + p
max_loss_streak_days = 0
worst_day = None
worst_day_pnl = None
if daily:
sorted_days = sorted(daily.keys())
run = 0
for d in sorted_days:
if daily[d] < 0:
run += 1
max_loss_streak_days = max(max_loss_streak_days, run)
else:
run = 0
worst_day = min(daily.keys(), key=lambda x: daily[x])
worst_day_pnl = round(daily[worst_day], 4)
win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None
return {
"closed_count": closed,
"win_count": wins,
"loss_count": losses,
"win_rate_pct": win_rate_pct,
"net_pnl_u": net,
"loss_sum_u": loss_sum_u,
"max_single_loss": max_single_loss,
"max_single_profit": max_single_profit,
"max_drawdown_u": max_dd,
"consecutive_losses": streak,
"max_loss_streak_days": max_loss_streak_days,
"worst_day": worst_day,
"worst_day_pnl": worst_day_pnl,
"opens_count": 0,
"range_label": "",
}
def compute_stats_bundle(conn, trading_day, now_dt=None):
"""日 / 周 / 月 统计:平仓按平仓时间所在交易日计入;下单监控与趋势回调分列。"""
now_dt = now_dt or app_now()
pnls_order = _load_completed_trade_pnls(conn, "下单监控")
pnls_trend = _load_completed_trade_pnls(conn, MONITOR_TYPE_TREND)
total_opens_order = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0]
total_opens_trend = conn.execute("SELECT COUNT(*) FROM trend_pullback_plans").fetchone()[0]
w_start, w_end = _session_week_bounds(trading_day)
m_start, m_end = _calendar_month_bounds(now_dt)
def in_week(tr):
_p, _t, td = tr
return td and w_start <= td <= w_end
def in_month(tr):
_p, _t, td = tr
return td and m_start <= td <= m_end
day_range = f"北京时间交易日 {trading_day}"
week_range = f"{w_start} ~ {w_end}(北京日期,近7天窗口)"
month_range = f"{m_start} ~ {m_end}(北京时间自然月)"
day_o = [tr for tr in pnls_order if tr[2] == trading_day]
day_t = [tr for tr in pnls_trend if tr[2] == trading_day]
dm_o = _compute_period_metrics(day_o)
dm_t = _compute_period_metrics(day_t)
dm_o["opens_count"] = _count_opens_between(conn, trading_day, trading_day)
dm_t["opens_count"] = _count_trend_plan_opens_between(conn, trading_day, trading_day)
week_o = [tr for tr in pnls_order if in_week(tr)]
week_t = [tr for tr in pnls_trend if in_week(tr)]
wm_o = _compute_period_metrics(week_o)
wm_t = _compute_period_metrics(week_t)
wm_o["opens_count"] = _count_opens_between(conn, w_start, w_end)
wm_t["opens_count"] = _count_trend_plan_opens_between(conn, w_start, w_end)
month_o = [tr for tr in pnls_order if in_month(tr)]
month_t = [tr for tr in pnls_trend if in_month(tr)]
mm_o = _compute_period_metrics(month_o)
mm_t = _compute_period_metrics(month_t)
mm_o["opens_count"] = _count_opens_between(conn, m_start, m_end)
mm_t["opens_count"] = _count_trend_plan_opens_between(conn, m_start, m_end)
return {
"trading_day": trading_day,
"total_opens_order": total_opens_order,
"total_opens_trend": total_opens_trend,
"total_opens_all": int(total_opens_order) + int(total_opens_trend),
"day": {"range_label": day_range, "order": dm_o, "trend": dm_t},
"week": {"range_label": week_range, "order": wm_o, "trend": wm_t},
"month": {"range_label": month_range, "order": mm_o, "trend": mm_t},
}
def infer_leverage(symbol):
sym = (symbol or "").strip().upper()
if sym.startswith("BTC") or sym.startswith("ETH"):
return BTC_LEVERAGE
return ALT_LEVERAGE
def normalize_exchange_symbol(symbol):
sym = symbol.strip().upper()
if ":" in sym:
return sym
if "/" in sym:
base, quote = sym.split("/", 1)
quote_clean = quote.split(":")[0]
return f"{base}/{quote_clean}:{quote_clean}"
return sym
def resolve_monitor_exchange_symbol(row):
"""将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。"""
raw = ""
try:
if row["exchange_symbol"]:
raw = str(row["exchange_symbol"]).strip()
except (KeyError, IndexError, TypeError):
raw = ""
if not raw:
try:
raw = str(row["symbol"] or "").strip()
except (KeyError, IndexError, TypeError):
raw = ""
return normalize_exchange_symbol(raw) if raw else ""
def round_price_to_exchange(exchange_symbol, price):
"""与交易所 tick 对齐后的 float,供入库与计算;失败时退回 float(price)。"""
if price in (None, ""):
return None
try:
v = float(price)
except (TypeError, ValueError):
return None
if not exchange_symbol:
return v
try:
ensure_markets_loaded()
s = exchange.price_to_precision(exchange_symbol, v)
return float(s)
except Exception:
return v
def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol):
if not position_symbol or not wanted_exchange_symbol:
return False
a = normalize_exchange_symbol(str(position_symbol).strip())
b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip())
return a == b
def _position_matches_wanted_contract(wanted_unified_sym, position_dict):
"""统一 symbol 比对;不一致时用 Gate 原始 contract 与 ccxt market.id 对齐(兼容 1000PEPE 等命名差异)。"""
if not wanted_unified_sym or not position_dict:
return False
ps = position_dict.get("symbol")
if _position_contract_symbol_match(ps, wanted_unified_sym):
return True
try:
ensure_markets_loaded()
mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper()
info = position_dict.get("info") or {}
c_raw = str(info.get("contract") or "").strip().upper()
if mid and c_raw and mid == c_raw:
return True
except Exception:
pass
return False
def _position_row_effective_contracts(p):
"""张数:优先 ccxt contracts,否则用 Gate 原始 size/pos(避免统一层为 0 时被误判空仓)。"""
if not p:
return 0.0
info = p.get("info") or {}
for val in (p.get("contracts"), info.get("size"), info.get("pos")):
if val is None or val == "":
continue
try:
x = abs(float(val))
if x > 0:
return x
except (TypeError, ValueError):
continue
return 0.0
def normalize_symbol_input(symbol):
sym = (symbol or "").strip().upper()
if not sym:
return ""
if "/" in sym:
return sym
if ":" in sym:
sym = sym.split(":")[0]
return f"{sym}/USDT"
def normalize_kline_limit(limit_raw, default=200):
try:
n = int(limit_raw)
except Exception:
return default
return 200 if n >= 200 else 100
def get_recommended_capital(current_capital):
if current_capital <= DAILY_LOSS_CAPITAL:
return DAILY_LOSS_CAPITAL
if current_capital >= DAILY_PROFIT_CAPITAL:
return DAILY_PROFIT_CAPITAL
return DAILY_START_CAPITAL
def ensure_session(conn, session_date):
row = conn.execute(
"SELECT * FROM trading_sessions WHERE session_date = ?",
(session_date,)
).fetchone()
if row:
return row
conn.execute(
"INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)",
(session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL)
)
conn.commit()
return conn.execute(
"SELECT * FROM trading_sessions WHERE session_date = ?",
(session_date,)
).fetchone()
def update_session_capital(conn, session_date, pnl_amount):
session_row = ensure_session(conn, session_date)
new_capital = float(session_row["current_capital"]) + float(pnl_amount)
conn.execute(
"UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?",
(round(new_capital, 4), session_date)
)
conn.commit()
return round(new_capital, 4)
def calc_hold_seconds(opened_at_str, closed_at_dt):
try:
opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S")
return int((closed_at_dt - opened_at).total_seconds())
except Exception:
return 0
def calc_hold_minutes(seconds):
if not seconds or seconds <= 0:
return 0
return max(1, int(seconds // 60))
def get_opened_at_value(row):
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
if "opened_at" in keys:
value = row["opened_at"]
if value:
return value
return app_now_str()
def get_effective_trade_field(row, reviewed_key, base_key, default=None):
try:
keys = row.keys() if hasattr(row, "keys") else row.keys()
except Exception:
keys = []
if reviewed_key in keys:
v = row[reviewed_key]
if v is not None and str(v).strip() != "":
return v
if base_key in keys:
v = row[base_key]
if v is not None and str(v).strip() != "":
return v
return default
def to_effective_trade_dict(row):
item = row_to_dict(row)
base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss")
item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at"))
item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at"))
item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", base_stop)
item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit"))
item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result"))
item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason"))
item["effective_pnl_amount"] = get_effective_trade_field(row, "reviewed_pnl_amount", "pnl_amount", item.get("pnl_amount"))
item["effective_hold_minutes"] = get_effective_trade_field(row, "reviewed_hold_minutes", "hold_minutes", item.get("hold_minutes"))
item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds"))
er_eff = get_effective_trade_field(row, "reviewed_entry_reason", "entry_reason", item.get("entry_reason"))
item["effective_entry_reason"] = (str(er_eff).strip() if er_eff is not None else "") or ""
mt = (item.get("monitor_type") or "").strip()
ex_pnl = item.get("exchange_realized_pnl")
ex_open = item.get("exchange_opened_at")
ex_close = item.get("exchange_closed_at")
if mt == MONITOR_TYPE_TREND and ex_pnl is not None and str(ex_pnl).strip() != "":
try:
item["display_pnl_amount"] = float(ex_pnl)
except (TypeError, ValueError):
item["display_pnl_amount"] = float(item.get("effective_pnl_amount") or 0)
item["display_pnl_source"] = "exchange"
eo = (str(ex_open).strip() if ex_open else "") or item.get("effective_opened_at") or ""
ec = (str(ex_close).strip() if ex_close else "") or item.get("effective_closed_at") or ""
item["display_opened_at"] = eo[:16] if eo else "-"
item["display_closed_at"] = ec[:16] if ec else "-"
else:
try:
item["display_pnl_amount"] = float(item.get("effective_pnl_amount") or 0)
except (TypeError, ValueError):
item["display_pnl_amount"] = 0.0
item["display_pnl_source"] = "local"
eo = item.get("effective_opened_at") or ""
ec = item.get("effective_closed_at") or ""
item["display_opened_at"] = (eo[:16] if eo else "-")
item["display_closed_at"] = (ec[:16] if ec else "-")
return item
def format_money_usdt(value):
"""资金类展示:固定两位小数(USDT)。"""
if value is None or value == "":
return ""
try:
return f"{round(float(value), 2):.2f}"
except (TypeError, ValueError):
return ""
def _exchange_unified_symbol_for_format(symbol_str):
if not symbol_str:
return None
s = str(symbol_str).strip()
if not s:
return None
try:
if ":" in s or "/" in s:
return normalize_exchange_symbol(s)
return normalize_exchange_symbol(f"{s}/USDT")
except Exception:
return None
def format_price_for_symbol(symbol, value):
if value in (None, ""):
return "-"
try:
v = float(value)
except Exception:
return str(value)
if v == 0:
return "0"
sym = _exchange_unified_symbol_for_format(symbol)
if sym and exchange_private_api_configured():
try:
ensure_markets_loaded()
return str(exchange.price_to_precision(sym, v))
except Exception:
pass
av = abs(v)
if av >= 10000:
d = 2
elif av >= 100:
d = 3
elif av >= 1:
d = 4
elif av >= 0.01:
d = 6
elif av >= 0.0001:
d = 8
else:
d = 10
text = f"{v:.{d}f}"
return text.rstrip("0").rstrip(".") if "." in text else text
def format_amount_for_symbol(symbol, value):
"""合约张数等:尽量与交易所 amount 精度一致。"""
if value in (None, ""):
return "-"
try:
v = float(value)
except Exception:
return str(value)
sym = _exchange_unified_symbol_for_format(symbol)
if sym and exchange_private_api_configured():
try:
ensure_markets_loaded()
return str(exchange.amount_to_precision(sym, v))
except Exception:
pass
text = f"{v:.8f}"
return text.rstrip("0").rstrip(".") if "." in text else text
def insert_trend_preview_snapshot(conn, preview_id, created, exp_ms, pl):
"""生成预览成功后归档一条快照(与 trend_pullback_previews 同参)。"""
conn.execute(
"""INSERT INTO trend_pullback_preview_snapshots (
preview_id,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent,
snapshot_available_usdt,snapshot_at,live_price_ref,plan_margin_capital,target_order_amount,first_order_amount,remainder_total,
dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,expires_at_ms,preview_created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
preview_id,
pl["symbol"],
pl["exchange_symbol"],
pl["direction"],
pl["leverage"],
pl["stop_loss"],
pl["add_upper"],
pl["take_profit"],
pl["risk_percent"],
pl["snapshot_available_usdt"],
pl["snapshot_at"],
pl["live_price_ref"],
pl["plan_margin_capital"],
pl["target_order_amount"],
pl["first_order_amount"],
pl["remainder_total"],
pl["dca_legs"],
pl["per_leg_amount"],
pl["grid_prices_json"],
pl["leg_amounts_json"],
exp_ms,
created,
),
)
def preview_snapshot_outcome_label(outcome):
o = (outcome or "").strip().lower()
return {
"open": "待确认",
"executed": "已执行",
"cancelled": "已取消",
"expired": "已过期",
}.get(o, outcome or "-")
def format_hold_minutes(minutes):
if not minutes:
return "0分钟"
total = int(minutes)
hours = total // 60
mins = total % 60
if hours:
return f"{hours}小时{mins}分钟"
return f"{mins}分钟"
def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage):
try:
trigger = float(trigger_price)
exit_p = float(exit_price)
margin = float(margin_capital)
lev = float(leverage)
if trigger <= 0:
return 0.0
if direction == "short":
pnl_ratio = (trigger - exit_p) / trigger
else:
pnl_ratio = (exit_p - trigger) / trigger
return round(margin * lev * pnl_ratio, 4)
except Exception:
return 0.0
def calc_rr_ratio(direction, entry_price, stop_loss, take_profit):
try:
entry = float(entry_price)
sl = float(stop_loss)
tp = float(take_profit)
if entry <= 0 or sl <= 0 or tp <= 0:
return None
if direction == "short":
risk = sl - entry
reward = entry - tp
else:
risk = entry - sl
reward = tp - entry
if risk <= 0 or reward <= 0:
return None
return round(reward / risk, 4)
except Exception:
return None
def calc_risk_fraction(direction, entry_price, stop_loss):
try:
entry = float(entry_price)
sl = float(stop_loss)
if entry <= 0 or sl <= 0:
return None
if direction == "short":
risk = sl - entry
else:
risk = entry - sl
if risk <= 0:
return None
return risk / entry
except Exception:
return None
def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital, leverage):
rf = calc_risk_fraction(direction, entry_price, stop_loss)
if rf is None:
return None
try:
notional = float(margin_capital) * float(leverage)
if notional <= 0:
return None
return round(notional * rf, 6)
except Exception:
return None
def calc_actual_rr(pnl_amount, risk_amount):
try:
r = float(risk_amount or 0)
if r <= 0:
return None
return round(float(pnl_amount or 0) / r, 4)
except Exception:
return None
def normalize_result_with_pnl(result, pnl_amount):
"""
触发“止损”但实际已盈利时,归类为保本止盈,避免语义混淆。
"""
if result == "止损":
try:
if float(pnl_amount or 0) > 0:
return "保本止盈"
except Exception:
pass
return result
def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct):
"""
按“已锁定R”计算目标止损位:
- long: entry + locked_r * (entry*risk_fraction) + offset
- short: entry - locked_r * (entry*risk_fraction) - offset
"""
try:
entry = float(entry_price)
rf = float(risk_fraction)
lr = float(locked_r)
off = float(offset_pct) / 100.0
if entry <= 0 or rf <= 0 or lr < 0:
return None
base_move = entry * rf * lr
offset_move = entry * off
if direction == "short":
return round(entry - base_move - offset_move, 8)
return round(entry + base_move + offset_move, 8)
except Exception:
return None
def insert_trade_record(
conn,
symbol,
monitor_type,
direction,
trigger_price,
stop_loss,
initial_stop_loss=None,
take_profit=None,
margin_capital=None,
leverage=None,
pnl_amount=0,
hold_seconds=0,
trade_style=None,
risk_amount=None,
planned_rr=None,
actual_rr=None,
result="",
miss_reason=None,
opened_at=None,
opened_at_ms=None,
closed_at=None,
closed_at_ms=None,
exchange_trade_id=None,
trend_plan_id=None,
):
hold_minutes = calc_hold_minutes(hold_seconds)
open_ts = opened_at or app_now_str()
close_ts = closed_at or app_now_str()
open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts)
close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts)
conn.execute(
"INSERT INTO trade_records (symbol,monitor_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol, monitor_type, direction, trigger_price, stop_loss, initial_stop_loss, take_profit,
margin_capital, leverage, pnl_amount, hold_seconds,
trade_style, risk_amount, planned_rr, actual_rr, hold_minutes,
open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, trend_plan_id
)
)
def calc_duration_text(open_str, close_str):
try:
fmt = "%Y-%m-%dT%H:%M"
o = datetime.strptime(open_str, fmt)
c = datetime.strptime(close_str, fmt)
delta = c - o
seconds = int(delta.total_seconds())
if seconds <= 0:
return "0分钟"
d = seconds // 86400
h = (seconds % 86400) // 3600
m = (seconds % 3600) // 60
parts = []
if d:
parts.append(f"{d}")
if h:
parts.append(f"{h}小时")
if m or not parts:
parts.append(f"{m}分钟")
return " ".join(parts)
except Exception:
return "计算失败"
def row_to_dict(row):
return {k: row[k] for k in row.keys()}
def enrich_order_item(raw_item, current_capital):
item = dict(raw_item or {})
margin = float(item.get("margin_capital") or 0)
lev = float(item.get("leverage") or 0)
notional = item.get("notional_value")
ratio = item.get("position_ratio")
if notional is None:
notional = round(margin * lev, 4) if margin and lev else 0
if ratio is None:
ratio = round(margin / current_capital * 100, 2) if current_capital else 0
item["notional_value"] = notional
item["position_ratio"] = ratio
enrich_order_display_fields(item, calc_rr_ratio)
try:
be = item.get("breakeven_enabled")
item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1
except Exception:
item["breakeven_enabled"] = 1
return apply_order_monitor_source_labels(item, default_manual="下单监控")
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 trade_record_monitor_type(conn, row):
return resolve_trade_record_monitor_type(conn, row, default_manual="下单监控")
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 响应体中的 USDTdict 或 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_balancespot 必须带 marginMode=spot,否则会随 defaultMarginMode 误走 cross_margin。"""
for t in type_candidates:
try:
params = {"type": t}
if t == "spot":
params["marginMode"] = "spot"
bal = exchange.fetch_balance(params=params)
val = _extract_usdt_total(bal)
if val is not None:
return val
except Exception:
continue
return None
def _fetch_gate_funding_usdt():
"""
Gate「资金账户」:
1) fetch_balance(type=spot, marginMode=spot) — 避免 defaultMarginMode=cross 误走 cross_margin
2) privateSpotGetAccounts — 与 1 同源,ccxt 聚合异常或解析不到 USDT 时再试原始列表;
3) privateUnifiedGetAccounts + 自解析 — 统一账户 balances 常为数组,ccxt unified fetch_balance 会崩。
"""
spot_seen_ok = False
try:
ensure_markets_loaded()
bal = exchange.fetch_balance(params={"type": "spot", "marginMode": "spot"})
spot_seen_ok = True
val = _extract_usdt_total(bal)
if val is not None:
return float(val)
except Exception:
pass
try:
resp = exchange.privateSpotGetAccounts({})
v = _parse_gate_spot_accounts_response_usdt(resp)
if v is not None:
return float(v)
except Exception:
pass
try:
raw = exchange.privateUnifiedGetAccounts({})
body = raw
if isinstance(body, dict) and isinstance(body.get("result"), dict):
body = body["result"]
v = _parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None
if v is not None:
return float(v)
except Exception:
pass
if spot_seen_ok:
return 0.0
return None
def get_available_trading_usdt():
ok_live, _ = ensure_exchange_live_ready()
if not ok_live:
return None
for t in ["swap", "spot"]:
try:
params = {"type": t}
if t == "spot":
params["marginMode"] = "spot"
bal = exchange.fetch_balance(params=params)
free_val = _extract_usdt_free(bal)
if free_val is not None:
return free_val
except Exception:
continue
return None
def get_synced_leverage(exchange_symbol, direction):
ensure_markets_loaded()
try:
positions = exchange.fetch_positions([exchange_symbol])
for p in positions:
if not _position_matches_wanted_contract(exchange_symbol, p):
continue
info = p.get("info", {}) or {}
side = (p.get("side") or info.get("posSide") or "").lower()
if GATE_POS_MODE == "hedge" and side and side != direction:
continue
lev = p.get("leverage")
if lev is None or lev == 0 or str(lev) == "0":
lev = info.get("cross_leverage_limit") or info.get("leverage")
if lev:
try:
return int(float(lev))
except Exception:
pass
except Exception:
pass
return None
def friendly_exchange_error(err, available_usdt=None):
msg = str(err)
low = msg.lower()
if (
"51008" in msg
or "insufficient" in low
or "margin" in low and ("not enough" in low or "不足" in msg)
or "balance" in low and "insufficient" in low
):
tail = f"(当前交易账户可用约 {round(available_usdt, 4)}U" if available_usdt is not None else ""
return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。"
clean = re.sub(r"\s+", " ", msg).strip()
return f"交易所下单失败:{clean}"
def get_exchange_capitals(force=False):
ok_live, _ = ensure_exchange_live_ready()
if not ok_live:
return None, None
now_ts = time.time()
if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS:
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
try:
ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt()
except Exception:
ACCOUNT_BALANCE_CACHE["funding_usdt"] = None
try:
ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"])
except Exception:
# 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」
ACCOUNT_BALANCE_CACHE["trading_usdt"] = None
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
def execute_transfer_usdt(amount, from_account, to_account):
if amount <= 0:
return False, "划转金额必须大于0", None
ok_live, reason = ensure_exchange_live_ready()
if not ok_live:
return False, reason, None
try:
resp = exchange.transfer(TRANSFER_CCY, float(amount), from_account, to_account)
return True, "划转成功", resp
except Exception as e:
msg = str(e)
if "INVALID_KEY" in msg or "Invalid key" in msg:
msg += (
"。常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;"
"③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);"
"④ Key 已重置或权限变更。你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单。"
)
return False, msg, None
def get_account_usdt_total(account_type):
"""读取各账户 USDT。funding 走 _fetch_gate_funding_usdtspot 同样 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():
run_auto_transfer_once_per_day(
enabled=AUTO_TRANSFER_ENABLED,
bj_hour=AUTO_TRANSFER_BJ_HOUR,
target_amount=AUTO_TRANSFER_AMOUNT,
from_account=AUTO_TRANSFER_FROM,
to_account=AUTO_TRANSFER_TO,
funds_decimals=2,
get_db=get_db,
get_active_position_count=get_active_position_count,
get_account_usdt_total=get_account_usdt_total,
execute_transfer_usdt=execute_transfer_usdt,
send_wechat_msg=send_wechat_msg,
utc_now_dt=utc_now_dt,
app_tz=APP_TZ,
utc_calendar_date_str=utc_calendar_date_str,
app_now_str=app_now_str,
)
def trading_day_reset_allows_new_open(now):
"""是否允许在满足其它风控的前提下于当前时刻新开仓(仅「整点前禁开」守卫)。"""
if not TRADING_DAY_RESET_OPEN_GUARD_ENABLED:
return True
return now.hour >= TRADING_DAY_RESET_HOUR
def get_active_position_count(conn):
"""与 Gate 主站一致:仅统计 order_monitors.status=active。"""
return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0])
def precheck_risk(conn, symbol, direction):
now = app_now()
if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
active_count = get_active_position_count(conn)
if active_count >= MAX_ACTIVE_POSITIONS:
return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS}"
trend_n = conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
if trend_n > 0:
return False, "已存在运行中的趋势回调计划,请先结束该计划"
if direction not in ("long", "short"):
return False, "方向必须为 long 或 short"
if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"):
expected = BTC_LEVERAGE
else:
expected = ALT_LEVERAGE
if expected <= 0:
return False, "杠杆配置异常"
return True, ""
def precheck_trend_pullback_start(conn):
"""趋势回调启动前:不与机器人下单监控达持仓上限并存。"""
now = app_now()
if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
active_count = get_active_position_count(conn)
if active_count >= MAX_ACTIVE_POSITIONS:
return False, (
f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS}),"
"请先结束「机器人下单监控」中的持仓,再启动趋势回调"
)
trend_n = conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
if trend_n > 0:
return False, "已存在运行中的趋势回调计划"
return True, ""
def _trend_cleanup_stale_previews(conn):
ms = int(time.time() * 1000)
stale = conn.execute("SELECT id FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,)).fetchall()
for row in stale:
try:
conn.execute(
"UPDATE trend_pullback_preview_snapshots SET outcome='expired' WHERE preview_id=? AND outcome='open'",
(row["id"],),
)
except Exception:
pass
conn.execute("DELETE FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,))
def parse_and_compute_trend_pullback_plan(form_dict):
"""
解析表单并计算趋势回调预览参数(不写库、不下单)。
成功返回 (payload, None);失败返回 (None, 错误文案)。
"""
d = form_dict or {}
symbol = normalize_symbol_input(d.get("symbol"))
if not symbol:
return None, "symbol 不能为空"
direction = (d.get("direction") or "long").strip().lower()
if direction not in ("long", "short"):
return None, "方向错误"
try:
stop_loss = float(d.get("sl"))
add_upper = float(d.get("add_upper"))
take_profit = float(d.get("take_profit"))
risk_percent = float(d.get("risk_percent") or "5")
except Exception:
return None, "价格或风险比例格式错误"
try:
lev_raw = parse_positive_float(d.get("leverage"))
leverage = int(lev_raw) if lev_raw is not None else infer_leverage(symbol)
except Exception:
return None, "杠杆格式错误"
if leverage <= 0 or risk_percent <= 0:
return None, "杠杆与风险比例必须大于0"
from strategy_trend_lib import validate_trend_bounds
bound_err = validate_trend_bounds(direction, stop_loss, add_upper)
if bound_err:
return None, bound_err
snap = get_available_trading_usdt()
if snap is None or snap <= 0:
return None, "无法读取合约账户 USDT 可用余额,请检查 API 与账户类型"
live_price = get_price(symbol)
if live_price is None:
return None, "获取实时价格失败"
exchange_symbol = normalize_exchange_symbol(symbol)
rf = calc_risk_fraction(direction, add_upper, stop_loss)
if rf is None or rf <= 0:
return None, "止损与补仓区间边界组合无法计算风险比例"
risk_budget = float(snap) * (risk_percent / 100.0)
notional = risk_budget / rf
margin_plan = notional / float(leverage)
margin_plan = min(margin_plan, float(snap) * FULL_MARGIN_BUFFER_RATIO)
if margin_plan <= 0:
return None, "计划保证金过小"
try:
target_amt, _ = prepare_order_amount(exchange_symbol, margin_plan, leverage, live_price)
except Exception as e:
return None, str(e)
first_amt = _safe_amount_to_precision(exchange_symbol, target_amt * 0.5)
if first_amt is None or first_amt <= 0:
return None, "首仓张数过小(低于交易所最小张数),请提高风险比例或杠杆"
remainder_total = _safe_amount_to_precision(
exchange_symbol, max(0.0, float(target_amt) - float(first_amt))
)
if remainder_total is None:
remainder_total = 0.0
from strategy_trend_lib import build_grid_prices, build_leg_amounts_json
ensure_markets_loaded()
market = exchange.market(exchange_symbol)
min_amt = float((market.get("limits", {}).get("amount", {}) or {}).get("min") or 0)
n_legs, leg_json, per_ref = build_leg_amounts_json(
exchange_symbol,
remainder_total,
TREND_PULLBACK_DCA_LEGS,
_safe_amount_to_precision,
min_amt,
)
if n_legs <= 0:
return None, "剩余计划张数不足以拆出补仓档(低于交易所最小张数),请提高风险比例、放宽止损与补仓区间间距,或减少补仓档数"
grid = build_grid_prices(direction, stop_loss, add_upper, n_legs)
if len(grid) != n_legs:
return None, "补仓网格生成失败"
opened_at = app_now_str()
try:
leg_list = json.loads(leg_json)
except Exception:
leg_list = []
payload = {
"symbol": symbol,
"exchange_symbol": exchange_symbol,
"direction": direction,
"leverage": leverage,
"stop_loss": stop_loss,
"add_upper": add_upper,
"take_profit": take_profit,
"risk_percent": risk_percent,
"snapshot_available_usdt": float(snap),
"snapshot_at": opened_at,
"live_price_ref": float(live_price),
"plan_margin_capital": float(margin_plan),
"target_order_amount": float(target_amt),
"first_order_amount": float(first_amt),
"remainder_total": float(remainder_total),
"dca_legs": int(n_legs),
"per_leg_amount": float(per_ref),
"grid_prices_json": json.dumps(grid),
"leg_amounts_json": leg_json,
"grid": grid,
"leg_amounts": leg_list,
"contract_size": float(market.get("contractSize") or 1),
}
return payload, None
def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price):
ensure_markets_loaded()
notional = float(margin_capital) * float(leverage)
ticker = exchange.fetch_ticker(exchange_symbol)
price = float(ticker.get("last") or fallback_price)
if price <= 0:
raise ValueError("触发价必须大于 0")
market = exchange.market(exchange_symbol)
contract_size = float(market.get("contractSize") or 1)
if market.get("contract"):
# 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理
amount = notional / (price * contract_size)
else:
amount = notional / price
min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min")
if min_amount and amount < float(min_amount):
raise ValueError(f"下单数量过小,最小数量为 {min_amount}")
amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount))
if amount_precise <= 0:
raise ValueError("下单数量精度后为 0,请提高基数或降低价格")
return amount_precise, price
def _to_positive_float(value):
try:
n = float(value)
return n if n > 0 else None
except Exception:
return None
def _extract_order_price_value(order_obj):
if not isinstance(order_obj, dict):
return None
for key in ("average", "price"):
v = _to_positive_float(order_obj.get(key))
if v is not None:
return v
cost = _to_positive_float(order_obj.get("cost"))
filled = _to_positive_float(order_obj.get("filled"))
if cost is not None and filled is not None and filled > 0:
return cost / filled
info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {}
for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"):
v = _to_positive_float(info.get(key))
if v is not None:
return v
return None
def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price):
price = _extract_order_price_value(order_resp)
if price is not None:
return round(price, 8)
order_id = (order_resp or {}).get("id")
if order_id:
try:
fetched = exchange.fetch_order(order_id, exchange_symbol)
fetched_price = _extract_order_price_value(fetched)
if fetched_price is not None:
return round(fetched_price, 8)
except Exception:
pass
fallback = _to_positive_float(fallback_price)
return round(fallback, 8) if fallback is not None else 0.0
def get_contract_size(exchange_symbol):
ensure_markets_loaded()
market = exchange.market(exchange_symbol)
return float(market.get("contractSize") or 1)
def parse_positive_float(value):
if value is None:
return None
raw = str(value).strip()
if not raw:
return None
num = float(raw)
if num <= 0:
raise ValueError("数值必须大于0")
return num
def build_gate_order_params(direction, reduce_only=False):
params = {}
if reduce_only:
params["reduceOnly"] = True
return params
def _gate_contracts_amount_for_tpsl(order, fallback_amount):
for key in ("filled", "amount"):
v = order.get(key)
try:
fv = float(v)
if fv > 0:
return fv
except Exception:
pass
return float(fallback_amount)
def _gate_place_tp_sl_orders_legacy_conditional(exchange_symbol, direction, contracts_amount, stop_loss, take_profit):
"""ccxt 市价减仓条件单(两张单分别带 stopLossPrice / takeProfitPrice),与官方仓位类触发单等价逻辑不同路径。"""
ensure_markets_loaded()
close_side = "sell" if direction == "long" else "buy"
base = {"reduceOnly": True}
last_err = None
for attempt in range(8):
try:
exchange.create_order(
exchange_symbol, "market", close_side, contracts_amount, None,
dict(base, stopLossPrice=float(stop_loss)),
)
exchange.create_order(
exchange_symbol, "market", close_side, contracts_amount, None,
dict(base, takeProfitPrice=float(take_profit)),
)
return
except Exception as e:
last_err = e
time.sleep(0.2 * (attempt + 1))
raise RuntimeError(f"交易所未接受条件止盈/止损委托参数:{last_err}")
def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit):
"""
Gate 永续官方仓位类触发单:POST futures/{settle}/price_orders
order_type=close-long-position / close-short-position,单向全平 close+size=0;双向需 auto_size。
与 App 内展示的「条件委托」一致,平仓后仍需 cancel_gate_swap_trigger_orders 避免残留。
"""
ensure_markets_loaded()
market = exchange.market(exchange_symbol)
if not market.get("swap"):
raise RuntimeError("仅支持永续合约 symbol")
settle = market["settleId"]
contract = market["id"]
order_type = "close-long-position" if direction == "long" else "close-short-position"
close_side = "sell" if direction == "long" else "buy"
if close_side == "sell":
sl_rule, tp_rule = 2, 1
else:
sl_rule, tp_rule = 1, 2
initial = {
"contract": contract,
"size": 0,
"price": "0",
"close": True,
"reduce_only": True,
"tif": "ioc",
"text": "api",
}
if GATE_POS_MODE == "hedge":
initial["auto_size"] = "close_long" if direction == "long" else "close_short"
# Gate API 1018auto_size=close_long|close_short 时 initial.close 须为 false
initial["close"] = False
sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss))
tp_s = exchange.price_to_precision(exchange_symbol, float(take_profit))
def _payload(trigger_price, rule):
trig = {
"strategy_type": 0,
"price_type": GATE_TPSL_PRICE_TYPE,
"price": trigger_price,
"rule": rule,
}
if GATE_TPSL_TRIGGER_EXPIRATION > 0:
trig["expiration"] = GATE_TPSL_TRIGGER_EXPIRATION
return {
"settle": settle,
"initial": dict(initial),
"trigger": trig,
"order_type": order_type,
}
last_err = None
for attempt in range(8):
try:
exchange.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule))
try:
exchange.privateFuturesPostSettlePriceOrders(_payload(tp_s, tp_rule))
except Exception:
cancel_gate_swap_trigger_orders(exchange_symbol)
raise
return
except Exception as e:
last_err = e
time.sleep(0.2 * (attempt + 1))
raise RuntimeError(f"交易所未接受仓位类条件止盈/止损:{last_err}")
def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss):
"""Gate 永续:仅挂仓位类止损触发单(全平),止盈由程序监控市价平仓。"""
ensure_markets_loaded()
market = exchange.market(exchange_symbol)
if not market.get("swap"):
raise RuntimeError("仅支持永续合约 symbol")
settle = market["settleId"]
contract = market["id"]
order_type = "close-long-position" if direction == "long" else "close-short-position"
close_side = "sell" if direction == "long" else "buy"
sl_rule = 2 if close_side == "sell" else 1
initial = {
"contract": contract,
"size": 0,
"price": "0",
"close": True,
"reduce_only": True,
"tif": "ioc",
"text": "api",
}
if GATE_POS_MODE == "hedge":
initial["auto_size"] = "close_long" if direction == "long" else "close_short"
initial["close"] = False # 与 _gate_place_tp_sl_orders_position_price_orders 相同,Gate 要求
sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss))
def _payload(trigger_price, rule):
trig = {
"strategy_type": 0,
"price_type": GATE_TPSL_PRICE_TYPE,
"price": trigger_price,
"rule": rule,
}
if GATE_TPSL_TRIGGER_EXPIRATION > 0:
trig["expiration"] = GATE_TPSL_TRIGGER_EXPIRATION
return {
"settle": settle,
"initial": dict(initial),
"trigger": trig,
"order_type": order_type,
}
last_err = None
for attempt in range(8):
try:
exchange.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule))
return
except Exception as e:
last_err = e
time.sleep(0.2 * (attempt + 1))
raise RuntimeError(f"交易所未接受仅止损仓位触发单:{last_err}")
def _gate_td_mode_is_cross():
return _GATE_DEFAULT_MARGIN_MODE == "cross"
def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_loss, take_profit):
pos_err = None
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 as e:
pos_err = e
if _gate_td_mode_is_cross():
raise RuntimeError(
f"交易所未接受仓位类条件止盈/止损(全仓不支持 ccxt 条件单回退):{pos_err}"
) from e
try:
_gate_place_tp_sl_orders_legacy_conditional(
exchange_symbol, direction, contracts_amount, stop_loss, take_profit,
)
except Exception as legacy_err:
if pos_err is not None:
raise RuntimeError(
f"交易所未接受仓位类条件止盈/止损:{pos_err};条件单回退亦失败:{legacy_err}"
) from legacy_err
raise
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, *, fallback_sl=None, fallback_tp=None
):
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 and fallback_sl is not None:
stop_loss = float(fallback_sl)
if take_profit <= 0 and fallback_tp is not None:
take_profit = float(fallback_tp)
if stop_loss <= 0:
raise ValueError("止损价格须大于 0")
if take_profit <= 0:
raise ValueError("请填写止盈价格,或保留原计划止盈")
return stop_loss, take_profit
def cancel_all_open_orders_for_symbol(exchange_symbol):
"""策略结束时:尽量撤掉该合约下条件单与普通挂单。"""
cancel_gate_swap_trigger_orders(exchange_symbol)
if not exchange_symbol:
return
ensure_markets_loaded()
plain_params = {"type": "swap"}
try:
exchange.load_unified_status()
if exchange.options.get("unifiedAccount"):
plain_params["unifiedAccount"] = True
except Exception:
pass
try:
exchange.cancel_all_orders(exchange_symbol, plain_params)
except Exception:
pass
try:
pending = exchange.fetch_open_orders(exchange_symbol, params=plain_params)
except Exception:
return
for o in pending or []:
oid = o.get("id")
if oid is None:
continue
try:
exchange.cancel_order(str(oid), exchange_symbol, plain_params)
except Exception:
pass
def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):
"""移动保本/手动改价:先撤该合约 TP/SL 条件单,再按新价重挂。"""
ok, reason = ensure_exchange_live_ready()
if not ok:
raise RuntimeError(reason or "实盘未就绪")
ex_sym = resolve_monitor_exchange_symbol(order_row)
direction = order_row["direction"]
cancel_gate_swap_trigger_orders(ex_sym)
contracts = get_live_position_contracts(ex_sym, direction)
if contracts is None or float(contracts) <= 0:
raise ValueError("交易所当前无该方向持仓,无法挂止盈止损")
amt = float(contracts)
if amt <= 0:
try:
amt = float(order_row["order_amount"] or 0)
except Exception:
amt = 0
if amt <= 0:
raise ValueError("无法确定平仓数量")
_gate_place_tp_sl_orders(ex_sym, direction, amt, float(stop_loss), float(take_profit))
def extract_trade_price_from_order(order):
if not order:
return None
for k in ("average", "avgPrice", "price"):
try:
v = float(order.get(k) or 0)
if v > 0:
return v
except Exception:
pass
try:
info = order.get("info") or {}
if isinstance(info, dict):
for k in ("fillPx", "avgPx", "fill_price"):
v = float(info.get(k) or 0)
if v > 0:
return v
except Exception:
pass
return None
def is_no_position_error(err_msg):
msg = (err_msg or "").lower()
keywords = [
"no position", "position does not exist", "position not exist",
"pos size is 0", "nothing to close", "reduceonly", "51008",
"empty position", "increase_position",
]
return any(k in msg for k in keywords)
def get_live_position_contracts(exchange_symbol, direction):
ensure_markets_loaded()
try:
rows = exchange.fetch_positions([exchange_symbol])
except Exception:
return None
total = 0.0
for p in rows:
if not _position_matches_wanted_contract(exchange_symbol, p):
continue
info = p.get("info", {}) or {}
side = (p.get("side") or info.get("posSide") or "").lower()
contracts = p.get("contracts")
if contracts is None:
raw_pos = info.get("pos") or info.get("size")
try:
contracts = abs(float(raw_pos)) if raw_pos is not None else 0.0
except Exception:
contracts = 0.0
try:
contracts = float(contracts)
except Exception:
contracts = 0.0
if contracts <= 0:
continue
if GATE_POS_MODE == "hedge":
if side and side != direction:
continue
total += contracts
return total
def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False):
"""在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。"""
if not rows:
return None
candidates = []
for p in rows:
if not _position_matches_wanted_contract(exchange_symbol, p):
continue
info = p.get("info", {}) or {}
side = (p.get("side") or info.get("posSide") or "").lower()
contracts = _position_row_effective_contracts(p)
if contracts <= 0:
continue
if (not relax_hedge) and GATE_POS_MODE == "hedge":
if side and side != (direction or "").lower():
continue
candidates.append((contracts, p))
if not candidates and (not relax_hedge) and GATE_POS_MODE == "hedge":
return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True)
if not candidates:
return None
candidates.sort(key=lambda x: x[0], reverse=True)
return candidates[0][1]
def _coerce_float(*values):
for v in values:
if v is None or v == "":
continue
try:
return float(v)
except (TypeError, ValueError):
continue
return None
def parse_ccxt_position_metrics(position, order_leverage=None):
"""
从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底)。
与 App「仓位保证金」对齐时优先用 initialMargin;缺失时再尝试 info 内字段。
"""
if not position:
return None
p = position
info = p.get("info", {}) or {}
# Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致
initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin"))
if initial is None or initial <= 0:
initial = _coerce_float(
info.get("margin"),
info.get("cross_margin"),
info.get("iso_margin"),
info.get("initial_margin"),
info.get("position_margin"),
info.get("initialMargin"),
)
notional = _coerce_float(p.get("notional"), p.get("notionalValue"))
if notional is None or notional <= 0:
notional = _coerce_float(info.get("value"))
if notional is not None:
notional = abs(notional)
# 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近)
if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage:
try:
lev = float(order_leverage)
if lev > 0:
approx = notional / lev
if approx > 0:
initial = approx
except (TypeError, ValueError):
pass
unrealized = _coerce_float(
p.get("unrealizedPnl"),
info.get("unrealised_pnl"),
info.get("unrealized_pnl"),
)
mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice"))
out = {}
if initial is not None and initial > 0:
out["initial_margin"] = round(initial, 4)
if notional is not None and notional > 0:
out["notional"] = round(notional, 4)
if unrealized is not None:
out["unrealized_pnl"] = round(unrealized, 6)
if mark is not None and mark > 0:
out["mark_price"] = round(mark, 8)
if out:
sym = (p.get("symbol") or "").strip()
try:
cs = float(get_contract_size(sym)) if sym else 1.0
except Exception:
cs = 1.0
from hub_position_metrics import enrich_ccxt_position_metrics_out
enrich_ccxt_position_metrics_out(p, out, contract_size=cs, funds_decimals=2)
return out or None
def get_live_position_exchange_metrics(exchange_symbol, direction):
ensure_markets_loaded()
if not exchange_private_api_configured() or not exchange_symbol:
return None
try:
rows = exchange.fetch_positions(None, {"settle": "usdt"}) or []
except Exception:
try:
rows = exchange.fetch_positions([exchange_symbol]) or []
except Exception:
return None
p = _select_live_position_row(rows, exchange_symbol, direction)
return parse_ccxt_position_metrics(p)
def _fetch_all_swap_positions_live():
if not exchange_private_api_configured():
return []
ensure_markets_loaded()
try:
return exchange.fetch_positions(None, {"settle": "usdt"}) or []
except Exception:
try:
return exchange.fetch_positions() or []
except Exception:
return []
def _active_monitor_position_keys(active_orders):
covered = set()
for o in active_orders or []:
sym = (o.get("symbol") or "").strip()
ex = (o.get("exchange_symbol") or normalize_exchange_symbol(sym)).strip()
direction = (o.get("direction") or "long").lower()
for s in (ex, sym, _unified_symbol_for_match(ex), _unified_symbol_for_match(sym)):
if s:
covered.add((s, direction))
return covered
def _active_trend_plan_position_keys(conn):
"""运行中的趋势回调计划已开仓时,持仓由计划表管理而非 order_monitors。"""
covered = set()
if conn is None:
return covered
try:
rows = conn.execute(
"SELECT symbol, exchange_symbol, direction FROM trend_pullback_plans "
"WHERE status='active' AND COALESCE(first_order_done, 0) != 0"
).fetchall()
except Exception:
return covered
for r in rows:
sym = (r["symbol"] or "").strip()
ex = (r["exchange_symbol"] or normalize_exchange_symbol(sym)).strip()
direction = (r["direction"] or "long").lower()
for s in (ex, sym, _unified_symbol_for_match(ex), _unified_symbol_for_match(sym)):
if s:
covered.add((s, direction))
return covered
def _strategy_managed_position_keys(active_orders, conn=None):
covered = _active_monitor_position_keys(active_orders)
covered |= _active_trend_plan_position_keys(conn)
return covered
def collect_orphan_exchange_positions(active_orders, conn=None):
"""交易所有持仓但未匹配本地策略/监控(order_monitors 或运行中趋势计划)。"""
from hub_position_metrics import (
parse_position_mark_price,
position_contracts,
position_side_from_ccxt,
)
rows = _fetch_all_swap_positions_live()
if not rows:
return []
covered = _strategy_managed_position_keys(active_orders, conn)
orphans = []
seen = set()
for p in rows:
contracts = position_contracts(p)
if abs(contracts) < 1e-12:
continue
ex_sym = (p.get("symbol") or "").strip()
if not ex_sym:
continue
direction = position_side_from_ccxt(p, contracts)
match_keys = (
(ex_sym, direction),
(_unified_symbol_for_match(ex_sym), direction),
)
if any(k in covered for k in match_keys):
continue
dedupe = (ex_sym, direction)
if dedupe in seen:
continue
seen.add(dedupe)
metrics = parse_ccxt_position_metrics(p) or {}
info = p.get("info") or {}
entry = _coerce_float(
p.get("entryPrice"),
p.get("entry_price"),
info.get("entry_price"),
info.get("avgEntryPrice"),
)
mark = parse_position_mark_price(p) or metrics.get("mark_price")
sym = normalize_symbol_input(ex_sym.split(":")[0] if ":" in ex_sym else ex_sym)
orphans.append(
{
"symbol": sym,
"exchange_symbol": ex_sym,
"direction": direction,
"contracts": round(abs(float(contracts)), 6),
"entry_price": entry,
"mark_price": mark,
"unrealized_pnl": metrics.get("unrealized_pnl"),
"initial_margin": metrics.get("initial_margin"),
}
)
return orphans
def _unified_symbol_for_match(symbol_str):
"""统一 BTC/USDT:USDT 与 BTC/USDT 便于与 trade_records.symbol 比对。"""
x = (symbol_str or "").strip().upper()
if ":" in x:
x = x.split(":")[0]
return x
def exchange_position_sync_since_ms():
"""Gate fetch_positions_history 的 since(毫秒,含当日 0 点)。"""
s = EXCHANGE_POSITION_SYNC_FROM_BJ
if s:
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)):
try:
chunk = s[:ln] if len(s) >= ln else s[:10]
dt = datetime.strptime(chunk, fmt)
aware = dt.replace(tzinfo=APP_TZ)
return int(aware.timestamp() * 1000)
except Exception:
continue
dt0 = app_now() - timedelta(days=90)
try:
aware0 = datetime(dt0.year, dt0.month, dt0.day, 0, 0, 0, tzinfo=APP_TZ)
except Exception:
aware0 = datetime.now(APP_TZ)
return int(aware0.timestamp() * 1000)
def _coerce_ts_ms(val):
if val is None or val == "":
return None
try:
v = float(val)
except (TypeError, ValueError):
return None
if v > 1e12:
return int(v)
if v > 1e10:
return int(v)
return int(v * 1000.0)
def _normalize_gate_position_history_entry(p):
if not p or not isinstance(p, dict):
return None
info = p.get("info") or {}
sym = p.get("symbol") or ""
side = (p.get("side") or "").strip().lower()
if side not in ("long", "short"):
sz = info.get("accum_size") if info.get("accum_size") is not None else info.get("size")
try:
szf = float(sz)
if szf > 0:
side = "long"
elif szf < 0:
side = "short"
except (TypeError, ValueError):
side = ""
rp = p.get("realizedPnl")
if rp is None:
rp = info.get("pnl")
try:
rp_f = float(rp) if rp is not None and str(rp).strip() != "" else None
except (TypeError, ValueError):
rp_f = None
close_ms = _coerce_ts_ms(p.get("lastUpdateTimestamp"))
if close_ms is None:
close_ms = _coerce_ts_ms(info.get("time"))
open_ms = _coerce_ts_ms(p.get("timestamp"))
if open_ms is None:
open_ms = _coerce_ts_ms(info.get("first_open_time"))
c_raw = str(info.get("contract") or "").strip()
t_raw = info.get("time")
sync_key = f"{c_raw}|{t_raw}|{side}"
return {
"symbol_u": _unified_symbol_for_match(sym),
"side": side,
"close_ms": close_ms,
"open_ms": open_ms,
"pnl": rp_f,
"sync_key": sync_key,
}
def fetch_gate_positions_close_history():
if not exchange_private_api_configured():
return []
ensure_markets_loaded()
since_ms = exchange_position_sync_since_ms()
try:
rows = exchange.fetch_positions_history(
None,
since=int(since_ms),
limit=int(EXCHANGE_POSITION_HISTORY_LIMIT),
params={"settle": "usdt"},
)
except Exception:
try:
rows = exchange.fetch_positions_history(
None,
since=int(since_ms),
limit=int(EXCHANGE_POSITION_HISTORY_LIMIT),
params={},
)
except Exception:
return []
out = []
for p in rows or []:
h = _normalize_gate_position_history_entry(p)
if h and h["close_ms"] and h["side"] in ("long", "short") and h["symbol_u"]:
out.append(h)
return out
def sync_trend_trade_records_from_exchange(conn):
global _LAST_POSITION_HISTORY_SYNC_AT
if not exchange_private_api_configured():
return
now = time.time()
if now - _LAST_POSITION_HISTORY_SYNC_AT < 25.0:
return
try:
hist = fetch_gate_positions_close_history()
except Exception:
return
if not hist:
_LAST_POSITION_HISTORY_SYNC_AT = now
return
candidates = conn.execute(
"""
SELECT id, symbol, direction, closed_at, opened_at, trend_plan_id, exchange_sync_key
FROM trade_records
WHERE monitor_type = ? AND (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '')
ORDER BY id DESC
LIMIT 120
""",
(MONITOR_TYPE_TREND,),
).fetchall()
if not candidates:
_LAST_POSITION_HISTORY_SYNC_AT = now
return
used = set()
for tr in candidates:
tid = None
if "trend_plan_id" in tr.keys() and tr["trend_plan_id"]:
try:
tid = int(tr["trend_plan_id"])
except (TypeError, ValueError):
tid = None
plan_open_ms = None
if tid:
prow = conn.execute("SELECT opened_at FROM trend_pullback_plans WHERE id=?", (tid,)).fetchone()
if prow and prow["opened_at"]:
plan_open_ms = opened_at_str_to_ms(prow["opened_at"])
close_ms_trade = opened_at_str_to_ms(tr["closed_at"]) or opened_at_str_to_ms(tr["opened_at"])
if close_ms_trade is None:
continue
best = None
best_d = None
for h in hist:
sk = h["sync_key"]
if not sk or sk in used:
continue
if h["symbol_u"] != _unified_symbol_for_match(tr["symbol"]):
continue
if h["side"] != (tr["direction"] or "long").strip().lower():
continue
cm = h["close_ms"]
if cm is None:
continue
if plan_open_ms is not None:
if cm < plan_open_ms - 15 * 60 * 1000:
continue
if cm > plan_open_ms + 15 * 86400 * 1000:
continue
else:
if abs(cm - close_ms_trade) > 3 * 86400 * 1000:
continue
d = abs(cm - close_ms_trade)
if best_d is None or d < best_d:
best_d = d
best = h
if best is None or best_d is None or best_d > 25 * 60 * 1000:
continue
sk = best["sync_key"]
if sk in used:
continue
eo = ms_to_app_local_str(best["open_ms"]) if best.get("open_ms") else None
ec = ms_to_app_local_str(best["close_ms"]) if best.get("close_ms") else None
pnl_val = best.get("pnl")
if pnl_val is None:
pnl_val = 0.0
conn.execute(
"""
UPDATE trade_records
SET exchange_realized_pnl = ?, exchange_opened_at = ?, exchange_closed_at = ?, exchange_sync_key = ?
WHERE id = ?
""",
(float(pnl_val), eo, ec, sk, int(tr["id"])),
)
used.add(sk)
_LAST_POSITION_HISTORY_SYNC_AT = now
conn.commit()
def trend_plan_history_status_label(status):
s = (status or "").strip().lower()
return {
"stopped_tp": "止盈结束",
"stopped_sl": "止损结束",
"stopped_manual": "手动结束",
}.get(s, status or "-")
def _list_window_from_request():
return resolve_list_window(request.args, session, default_preset=PRESET_UTC_TODAY)
def _redirect_records():
qs = list_window_redirect_query(session)
return redirect(f"/records?{qs}" if qs else "/records")
def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
"""趋势回调手动保本:默认开仓均价 + offset_pct%(多上移、空下移)。"""
try:
e = float(entry_price)
pct = float(
offset_pct
if offset_pct is not None
else TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT
)
except (TypeError, ValueError):
return None
if e <= 0:
return None
direction = (direction or "long").strip().lower()
if direction == "short":
return e * (1.0 - pct / 100.0)
return e * (1.0 + pct / 100.0)
def enrich_active_trend_plan_row(row):
d = row_to_dict(row)
try:
d["breakeven_applied"] = int(d.get("breakeven_applied") or 0) != 0
except Exception:
d["breakeven_applied"] = False
ex_sym = d.get("exchange_symbol") or normalize_exchange_symbol(d.get("symbol") or "")
direction = (d.get("direction") or "long").lower()
m = get_live_position_exchange_metrics(ex_sym, direction)
if m and m.get("unrealized_pnl") is not None:
d["floating_pnl"] = float(m["unrealized_pnl"])
else:
d["floating_pnl"] = None
if m and m.get("mark_price") is not None:
d["floating_mark"] = float(m["mark_price"])
else:
d["floating_mark"] = None
from strategy_snapshot_lib import attach_trend_dca_levels
return attach_trend_dca_levels(d)
def opened_at_str_to_ms(opened_at_str):
if not opened_at_str:
return None
try:
dt = datetime.strptime(str(opened_at_str).strip()[:19], "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
try:
aware = dt.replace(tzinfo=APP_TZ)
return int(aware.timestamp() * 1000)
except Exception:
return None
def _to_ms_with_fallback(ms_value, dt_str):
try:
if ms_value is not None and str(ms_value).strip() != "":
v = int(float(ms_value))
if v > 0:
return v
except Exception:
pass
return opened_at_str_to_ms(dt_str)
def ms_to_app_local_str(ms):
if ms is None:
return app_now_str()
try:
dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ)
return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return app_now_str()
def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price):
"""根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。"""
try:
tp = float(take_profit)
sl = float(stop_loss)
ex = float(exit_price)
trig = float(trigger_price)
except (TypeError, ValueError):
return None
band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12)
if direction == "long":
if ex >= tp - band:
return "止盈"
if ex <= sl + band:
return "止损"
else:
if ex <= tp + band:
return "止盈"
if ex >= sl - band:
return "止损"
return None
def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None):
"""取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。"""
if not (GATE_API_KEY and GATE_API_SECRET):
return None
ensure_markets_loaded()
since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
close_side = "sell" if direction == "long" else "buy"
def pick_from_trades(trades):
if not trades:
return None
candidates = []
for t in trades:
if (t.get("side") or "").lower() != close_side:
continue
info = t.get("info") or {}
if not isinstance(info, dict):
info = {}
pos_side = (info.get("posSide") or t.get("posSide") or "").lower()
if GATE_POS_MODE == "hedge":
if pos_side in ("long", "short") and pos_side != direction:
continue
ts = t.get("timestamp")
if ts is None:
continue
candidates.append(t)
if not candidates:
return None
return max(candidates, key=lambda x: x.get("timestamp") or 0)
try:
trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100)
hit = pick_from_trades(trades)
if hit is None and since_ms:
trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100)
hit = pick_from_trades(trades)
return hit
except Exception:
return None
def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None):
"""
拉取某条历史记录对应的减仓成交(用于按 id 回填)。
返回按时间排序的成交列表。
"""
if not (GATE_API_KEY and GATE_API_SECRET):
return []
ensure_markets_loaded()
since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
close_side = "sell" if direction == "long" else "buy"
closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None
# 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况
if closed_ms is not None:
closed_ms += 6 * 60 * 60 * 1000
candidates = []
all_side_candidates = []
try:
trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200)
except Exception:
trades = []
if not trades and since_ms:
try:
trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200)
except Exception:
trades = []
for t in trades or []:
if (t.get("side") or "").lower() != close_side:
continue
ts = t.get("timestamp")
if ts is None:
continue
try:
ts = int(ts)
except Exception:
continue
if since_ms and ts < since_ms:
continue
if closed_ms and ts > closed_ms:
continue
info = t.get("info") or {}
if not isinstance(info, dict):
info = {}
pos_side = (info.get("posSide") or t.get("posSide") or "").lower()
if GATE_POS_MODE == "hedge":
if pos_side in ("long", "short") and pos_side != direction:
continue
all_side_candidates.append(t)
if since_ms and ts < since_ms:
continue
if closed_ms and ts > closed_ms:
continue
candidates.append(t)
candidates.sort(key=lambda x: x.get("timestamp") or 0)
if candidates:
return candidates
# 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。
all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0)
if not all_side_candidates:
return []
if not closed_ms:
return all_side_candidates[-20:]
near = []
for t in all_side_candidates:
ts = t.get("timestamp")
if ts is None:
continue
try:
delta = abs(int(ts) - int(closed_ms))
except Exception:
continue
# 放宽到前后 7 天
if delta <= 7 * 24 * 60 * 60 * 1000:
near.append((delta, t))
if near:
near.sort(key=lambda x: x[0])
picked = [x[1] for x in near[:20]]
picked.sort(key=lambda x: x.get("timestamp") or 0)
return picked
return all_side_candidates[-20:]
def calc_weighted_exit_price(trades):
if not trades:
return None
total_amount = 0.0
weighted_sum = 0.0
for t in trades:
try:
price = float(t.get("price") or 0)
amount = float(t.get("amount") or 0)
except Exception:
continue
if price <= 0:
continue
if amount <= 0:
amount = 1.0
weighted_sum += price * amount
total_amount += amount
if total_amount <= 0:
return None
return weighted_sum / total_amount
def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None):
"""
交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。
返回 (result, pnl_amount, closed_at_str, miss_reason)。
"""
direction = row["direction"]
sym = row["symbol"]
trigger_price = row["trigger_price"]
stop_loss = row["stop_loss"]
take_profit = row["take_profit"]
margin_capital = row["margin_capital"] or DAILY_START_CAPITAL
leverage = row["leverage"] or infer_leverage(sym)
exchange_symbol = row["exchange_symbol"] or normalize_exchange_symbol(sym)
trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms)
exit_px = None
closed_at_str = app_now_str()
if trade:
try:
exit_px = float(trade.get("price") or 0) or None
except (TypeError, ValueError):
exit_px = None
ts = trade.get("timestamp")
if ts:
closed_at_str = ms_to_app_local_str(int(ts))
if exit_px is None or exit_px <= 0:
p = get_price(sym)
if p:
guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p)
if guessed:
pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage)
return (
guessed,
pnl,
closed_at_str,
"未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)",
)
return (
"外部平仓",
0.0,
closed_at_str,
"检测到交易所仓位已关闭,且无法从成交记录还原平仓价",
)
result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px)
pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage)
if result:
return (
result,
pnl,
closed_at_str,
"按交易所成交记录同步为止盈/止损平仓",
)
return (
"外部平仓",
pnl,
closed_at_str,
"交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)",
)
def reconcile_external_closes(conn, days=None):
synced_count = 0
cutoff_ms = None
if days is not None:
try:
d = int(days)
if d > 0:
cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000)
except Exception:
cutoff_ms = None
rows = conn.execute(
"SELECT * FROM order_monitors WHERE status IN ('active', 'error')"
).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
oid = int(r["id"])
if r["status"] == "error":
opened_at_chk = get_opened_at_value(r)
existing = conn.execute(
"SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type='下单监控' LIMIT 1",
(r["symbol"], opened_at_chk),
).fetchone()
if existing:
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,))
synced_count += 1
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=trade_record_monitor_type(conn, r),
trend_plan_id=trend_plan_id_from_monitor_row(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=handoff_trade_miss_reason(miss_reason, r),
opened_at=opened_at,
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],))
if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
send_wechat_msg(
build_wechat_close_message(
symbol=r["symbol"],
direction=r["direction"],
result=f"{result}(自动同步)",
pnl_amount=pnl_amount,
hold_seconds=hold_seconds,
trigger_price=r["trigger_price"],
current_price="-",
stop_loss=r["stop_loss"],
take_profit=r["take_profit"],
close_order_id="-",
extra_note=miss_reason,
)
)
else:
send_wechat_msg(
build_wechat_close_message(
symbol=r["symbol"],
direction=r["direction"],
result="外部平仓(自动同步)",
pnl_amount=pnl_amount,
hold_seconds=hold_seconds,
trigger_price=r["trigger_price"],
current_price="-",
stop_loss=r["stop_loss"],
take_profit=r["take_profit"],
close_order_id="-",
extra_note=miss_reason,
)
)
synced_count += 1
return synced_count
# 获取实时价格
def get_price(symbol):
try:
ensure_markets_loaded()
return exchange.fetch_ticker(normalize_exchange_symbol(symbol))["last"]
except:
return None
# 获取5分钟K线收盘价
def get_5m_close(symbol):
try:
ensure_markets_loaded()
ohlcv = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), KLINE_TIMEFRAME, limit=1)
return ohlcv[-1][4] if ohlcv else None
except:
return None
def _safe_float(v):
try:
return float(v)
except Exception:
return None
def _compute_ema(values, period=55):
arr = [float(x) for x in values if x is not None]
if len(arr) < period:
return None
k = 2.0 / (period + 1.0)
ema = arr[0]
for val in arr[1:]:
ema = val * k + ema * (1 - k)
return ema
def _status_by_ema55(symbol, timeframe):
try:
bars = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), timeframe=timeframe, limit=80)
if not bars or len(bars) < 56:
return "横盘", None, None
closes = [float(x[4]) for x in bars if x and len(x) >= 5]
ema55 = _compute_ema(closes, 55)
last_close = closes[-1]
if ema55 is None or last_close <= 0:
return "横盘", last_close, ema55
diff_pct = (last_close - ema55) / ema55 * 100.0
if abs(diff_pct) < 0.1:
return "横盘", last_close, ema55
return ("多头" if diff_pct > 0 else "空头"), last_close, ema55
except Exception:
return "横盘", None, None
def _daily_volume_rank(symbol):
"""
返回(symbol_rank, total_count),按 quoteVolume 降序,缺失时 fallback 到 baseVolume*last。
"""
sym_norm = normalize_symbol_input(symbol)
target_base = journal_coin_from_symbol(sym_norm)
def _ticker_base(sym_text):
s = str(sym_text or "").upper().strip()
if ":" in s:
s = s.split(":", 1)[0]
if "/" in s:
return s.split("/", 1)[0].strip()
if "-" in s:
return s.split("-", 1)[0].strip()
if s.endswith("USDT"):
return s[:-4].strip()
return s
now_ts = time.time()
cached_ok = (
LIQUIDITY_RANK_CACHE["updated_at"]
and now_ts - float(LIQUIDITY_RANK_CACHE["updated_at"]) < max(30, BALANCE_REFRESH_SECONDS)
)
if not cached_ok:
try:
ensure_markets_loaded()
tickers = exchange.fetch_tickers()
scored = []
for s, t in (tickers or {}).items():
try:
mk = exchange.markets.get(s)
if not mk or not mk.get("swap"):
continue
su = str(s).upper()
if "USDT" not in su:
continue
qv = _safe_float((t or {}).get("quoteVolume"))
if qv is None:
info = (t or {}).get("info") if isinstance((t or {}).get("info"), dict) else {}
qv = _safe_float(info.get("volCcy24h") or info.get("vol24h"))
if qv is None:
bv = _safe_float((t or {}).get("baseVolume"))
lp = _safe_float((t or {}).get("last"))
if bv is not None and lp is not None:
qv = bv * lp
if qv is None or qv <= 0:
continue
scored.append((_ticker_base(s), float(qv)))
except Exception:
continue
scored.sort(key=lambda x: x[1], reverse=True)
ranks = {}
for idx, (base, _) in enumerate(scored, 1):
if base and base not in ranks:
ranks[base] = idx
LIQUIDITY_RANK_CACHE["ranks"] = ranks
LIQUIDITY_RANK_CACHE["total"] = len(scored)
LIQUIDITY_RANK_CACHE["updated_at"] = now_ts
except Exception:
pass
ranks = LIQUIDITY_RANK_CACHE.get("ranks") or {}
total = int(LIQUIDITY_RANK_CACHE.get("total") or 0)
return ranks.get(target_base), total
def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
"""
关键位门控:量能、突破幅度、第二根确认、日成交量前30。
使用最近闭合Kbreakout=倒数第2根,confirm=倒数第1根。
"""
out = {"ok": False}
ex_sym = normalize_exchange_symbol(symbol)
bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=80) or []
if len(bars) < 24:
out["reason"] = "5m K线数量不足"
return out
closed = bars[:-1] if len(bars) >= 3 else bars
if len(closed) < 23:
out["reason"] = "闭合K线不足"
return out
breakout = closed[-2]
confirm = closed[-1]
prev20 = closed[-22:-2]
avg20 = sum(float(x[5]) for x in prev20) / max(len(prev20), 1)
vol_break = float(breakout[5])
vol_ok = vol_break > avg20 * 1.3 if avg20 > 0 else False
open_b = float(breakout[1])
close_b = float(breakout[4])
high_b = float(breakout[2])
low_b = float(breakout[3])
amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0
amp_ok = (amp_pct > 0.03) and (amp_pct < 0.5)
cfm_close = float(confirm[4])
# 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿
edge = float(upper) if direction == "long" else float(lower)
breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower))
confirm_ok_raw = (cfm_close > edge) if direction == "long" else (cfm_close < edge)
# 口径收紧:未发生有效突破时,不标记幅度/二确通过,避免出现“还没到位却显示Y”
amp_ok = amp_ok and breakout_ok
confirm_ok = confirm_ok_raw and breakout_ok
rank, total = _daily_volume_rank(symbol)
rank_ok = (rank is not None) and (rank <= 30)
swing4h_pct = 0.0
try:
seg48 = closed[-48:] if len(closed) >= 48 else closed
hh = max(float(x[2]) for x in seg48)
ll = min(float(x[3]) for x in seg48)
swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0
except Exception:
swing4h_pct = 0.0
out.update(
{
"ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]),
"vol_ok": vol_ok,
"avg20": avg20,
"vol_break": vol_break,
"amp_ok": amp_ok,
"amp_pct": amp_pct,
"breakout_ok": breakout_ok,
"breakout_close": close_b,
"confirm_ok": confirm_ok,
"confirm_close": cfm_close,
"edge_price": edge,
"rank": rank,
"rank_total": total,
"rank_ok": rank_ok,
"breakout_high": high_b,
"breakout_low": low_b,
"breakout_ts": breakout[0],
"confirm_ts": confirm[0],
"swing4h_pct": swing4h_pct,
"monitor_type": monitor_type,
"direction": direction,
}
)
return out
def calc_price_diff_pct(current_price, target_price):
try:
if target_price is None:
return None, None
t = float(target_price)
if t == 0:
return None, None
c = float(current_price)
diff = c - t
pct = diff / t * 100
return round(diff, 6), round(pct, 4)
except Exception:
return None, None
def can_notify_key_monitor(row, now_dt):
max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES)
if int(row["notification_count"] or 0) >= max_notify:
return False
last_at = row["last_notified_at"]
if not last_at:
return True
try:
last_dt = datetime.strptime(last_at, "%Y-%m-%d %H:%M:%S")
except Exception:
return True
interval_min = int(row["notify_interval_min"] or KEY_ALERT_INTERVAL_MINUTES)
return (now_dt - last_dt).total_seconds() >= interval_min * 60
def breakout_too_far(p, edge_price, limit_pct):
try:
if edge_price is None or float(edge_price) <= 0:
return False
diff_pct = abs(float(p) - float(edge_price)) / float(edge_price) * 100
return diff_pct > float(limit_pct)
except Exception:
return False
def _trend_build_grid_prices(direction, sl, upper, n_legs):
"""在 (止损, 补仓区间远侧边界 add_upper) 开区间内生成 n_legs 个补仓触发价(不含端点)。"""
sl, upper = float(sl), float(upper)
out = []
if n_legs <= 0:
return out
if direction == "long":
if upper <= sl:
return out
span = upper - sl
for i in range(1, n_legs + 1):
t = i / float(n_legs + 1)
out.append(sl + t * span)
out.sort(reverse=True)
else:
if sl <= upper:
return out
span = sl - upper
for i in range(1, n_legs + 1):
t = i / float(n_legs + 1)
out.append(upper + t * span)
out.sort()
return [round(p, 10) for p in out]
def _safe_amount_to_precision(exchange_symbol, raw_amount):
"""amount_to_precision 在低于最小步长时会抛 InvalidOrder;返回 None 表示不可用。"""
try:
if raw_amount is None:
return None
x = float(raw_amount)
if x <= 0:
return None
return float(exchange.amount_to_precision(exchange_symbol, x))
except Exception:
return None
def _trend_pick_dca_legs_and_per_leg(exchange_symbol, remainder_total, want_legs):
"""按交易所最小张数约束,自动减少档位数。"""
ensure_markets_loaded()
market = exchange.market(exchange_symbol)
min_amt = (market.get("limits", {}).get("amount", {}) or {}).get("min")
min_amt = float(min_amt) if min_amt is not None else 0.0
legs = max(1, int(want_legs))
rem = float(remainder_total)
while legs >= 1:
per = rem / legs
per_p = _safe_amount_to_precision(exchange_symbol, per)
if per_p is None or per_p <= 0:
legs -= 1
continue
if min_amt and per_p + 1e-12 < min_amt:
legs -= 1
continue
return legs, per_p
one = _safe_amount_to_precision(exchange_symbol, rem)
if one is None or one <= 0:
return 0, 0.0
return 1, one
def _trend_build_leg_amounts_json(exchange_symbol, remainder_total, want_legs):
"""将剩余计划张数拆成若干补仓市价单张数(JSON 列表),并返回有效档位数。"""
rem = _safe_amount_to_precision(exchange_symbol, float(remainder_total))
if rem is None or rem <= 0:
return 0, "[]", 0.0
n, _ = _trend_pick_dca_legs_and_per_leg(exchange_symbol, rem, want_legs)
if n <= 0:
return 0, "[]", 0.0
if n <= 1:
one = _safe_amount_to_precision(exchange_symbol, rem)
if one is None or one <= 0:
return 0, "[]", 0.0
return 1, json.dumps([one]), one
unit = _safe_amount_to_precision(exchange_symbol, rem / n)
if unit is None or unit <= 0:
one = _safe_amount_to_precision(exchange_symbol, rem)
if one is None or one <= 0:
return 0, "[]", 0.0
return 1, json.dumps([one]), one
parts = []
acc = 0.0
for _ in range(n - 1):
parts.append(unit)
acc += unit
last = _safe_amount_to_precision(exchange_symbol, max(0.0, rem - acc))
if last is None or last <= 0:
one = _safe_amount_to_precision(exchange_symbol, rem)
if one is None or one <= 0:
return 0, "[]", 0.0
return 1, json.dumps([one]), one
parts.append(last)
return n, json.dumps(parts), unit
def _trend_market_add_contracts(exchange_symbol, direction, contracts, leverage):
exchange.set_leverage(int(leverage), exchange_symbol)
side = "buy" if direction == "long" else "sell"
params = build_gate_order_params(direction, reduce_only=False)
return exchange.create_order(exchange_symbol, "market", side, float(contracts), None, params)
def _trend_refresh_stop_only(exchange_symbol, direction, stop_loss):
cancel_gate_swap_trigger_orders(exchange_symbol)
_gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss)
def _trend_weighted_avg(old_avg, old_amt, fill_px, add_amt):
try:
oa, aa = float(old_amt), float(add_amt)
if oa <= 0:
return float(fill_px)
return (float(old_avg) * oa + float(fill_px) * aa) / (oa + aa)
except Exception:
return float(fill_px or 0)
def _trend_plan_stop_status(result_label):
if result_label == "止盈":
return "stopped_tp"
if result_label == "止损":
return "stopped_sl"
return "stopped_manual"
def _trend_plan_trade_exists(conn, plan_id):
try:
return conn.execute(
"SELECT id FROM trade_records WHERE trend_plan_id=? LIMIT 1",
(int(plan_id),),
).fetchone() is not None
except Exception:
return False
def _trend_finalize_plan(conn, row, result_label, exit_price, closed_at=None):
"""平仓后记账、撤单、结束计划。"""
plan_id = int(row["id"])
active = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'",
(plan_id,),
).fetchone()
if not active:
return
row = active
sym = row["symbol"]
direction = row["direction"] or "long"
ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(sym)
closed_at = closed_at or app_now_str()
opened_at = row["opened_at"] or app_now_str()
hold_seconds = calc_hold_seconds(opened_at, parse_dt_for_trading_day(closed_at) or app_now())
margin_cap = float(row["plan_margin_capital"] or 0)
lev = int(row["leverage"] or 1)
avg_e = float(row["avg_entry_price"] or 0)
pnl_amount = calc_pnl(direction, avg_e, float(exit_price), margin_cap, lev)
res = normalize_result_with_pnl(result_label, pnl_amount)
risk_amt = calc_risk_amount_from_plan(direction, float(row["add_upper"]), float(row["stop_loss"]), margin_cap, lev)
planned_rr = calc_rr_ratio(direction, avg_e, float(row["stop_loss"]), float(row["take_profit"]))
try:
cancel_all_open_orders_for_symbol(ex_sym)
except Exception:
try:
cancel_gate_swap_trigger_orders(ex_sym)
except Exception:
pass
st = _trend_plan_stop_status(result_label)
cur = conn.execute(
"UPDATE trend_pullback_plans SET status=?, message=? WHERE id=? AND status='active'",
(st, res, plan_id),
)
if not getattr(cur, "rowcount", 0):
return
conn.commit()
try:
from strategy_trend_register import build_trend_config
from strategy_wechat_notify import notify_trend_plan_ended
_tcfg = build_trend_config(sys.modules[__name__])
notify_trend_plan_ended(
_tcfg,
plan_id=plan_id,
symbol=sym,
direction=direction,
end_type=result_label,
result_label=res,
exit_price=float(exit_price) if exit_price is not None else None,
pnl_amount=float(pnl_amount) if pnl_amount is not None else None,
)
except Exception:
pass
try:
cfg = app.extensions.get("strategy_trend_cfg") or {}
closed = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE id=?", (plan_id,)
).fetchone()
if closed and cfg:
from strategy_snapshot_lib import save_trend_plan_snapshot
save_trend_plan_snapshot(
cfg,
conn,
closed,
result_label=result_label,
exit_price=float(exit_price),
pnl_amount=float(pnl_amount) if pnl_amount is not None else None,
)
conn.commit()
except Exception:
pass
if _trend_plan_trade_exists(conn, plan_id):
return
session_date = row["session_date"] or get_trading_day()
session_capital = update_session_capital(conn, session_date, pnl_amount)
insert_trade_record(
conn,
symbol=sym,
monitor_type=MONITOR_TYPE_TREND,
direction=direction,
trigger_price=avg_e,
stop_loss=float(row["stop_loss"]),
initial_stop_loss=float(row["stop_loss"]),
take_profit=float(row["take_profit"]),
margin_capital=margin_cap,
leverage=lev,
pnl_amount=pnl_amount,
hold_seconds=hold_seconds,
trade_style="trend_pullback",
risk_amount=risk_amt,
planned_rr=planned_rr,
actual_rr=calc_actual_rr(pnl_amount, risk_amt),
result=res,
opened_at=opened_at,
closed_at=closed_at,
trend_plan_id=plan_id,
)
send_wechat_msg(
build_wechat_close_message(
symbol=sym,
direction=direction,
result=f"{res}{MONITOR_TYPE_TREND}",
pnl_amount=pnl_amount,
hold_seconds=hold_seconds,
trigger_price=avg_e,
current_price=float(exit_price),
stop_loss=float(row["stop_loss"]),
take_profit=float(row["take_profit"]),
close_order_id="-",
extra_note="计划本金口径:启动时合约可用余额快照;止盈由程序监控",
session_capital_fallback=session_capital,
)
)
conn.commit()
def check_trend_pullback_plans():
ok_live, _ = ensure_exchange_live_ready()
if not ok_live:
return
conn = get_db()
rows = conn.execute("SELECT * FROM trend_pullback_plans WHERE status='active'").fetchall()
for row in rows:
try:
sym = row["symbol"]
direction = (row["direction"] or "long").lower()
ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(sym)
sl = float(row["stop_loss"])
upper = float(row["add_upper"])
tp = float(row["take_profit"])
lev = int(row["leverage"] or 1)
p = get_price(sym)
if not p:
continue
pf = float(p)
last_p = row["last_mark_price"]
last_pf = float(last_p) if last_p is not None else pf
pos = get_live_position_contracts(ex_sym, direction)
if pos is None:
continue
legs_done = int(row["legs_done"] or 0)
dca_legs = int(row["dca_legs"] or 0)
leg_amounts = []
try:
leg_amounts = [float(x) for x in json.loads(row["leg_amounts_json"] or "[]")]
except Exception:
leg_amounts = []
grid = []
try:
grid = json.loads(row["grid_prices_json"] or "[]")
except Exception:
grid = []
hit_tp = (direction == "long" and pf >= tp) or (direction == "short" and pf <= tp)
if hit_tp and pos > 0:
try:
exchange.set_leverage(lev, ex_sym)
side = "sell" if direction == "long" else "buy"
params = build_gate_order_params(direction, reduce_only=True)
close_resp = exchange.create_order(ex_sym, "market", side, float(pos), None, params)
exit_p = extract_trade_price_from_order(close_resp) or pf
except Exception as e:
if not is_no_position_error(str(e)):
continue
exit_p = pf
_trend_finalize_plan(conn, row, "止盈", exit_p)
continue
if pos <= 0 and int(row["first_order_done"] or 0):
exit_p = pf
_trend_finalize_plan(conn, row, "止损", exit_p)
continue
if int(row["first_order_done"] or 0) and legs_done < len(grid) and legs_done < len(leg_amounts):
level = float(grid[legs_done])
fired = False
if direction == "long":
if last_pf > level and pf <= level:
fired = True
else:
if last_pf < level and pf >= level:
fired = True
if fired:
amt = float(exchange.amount_to_precision(ex_sym, leg_amounts[legs_done]))
if amt > 0:
add_resp = _trend_market_add_contracts(ex_sym, direction, amt, lev)
fill_px = extract_trade_price_from_order(add_resp) or pf
old_avg = float(row["avg_entry_price"] or fill_px)
old_open = float(row["order_amount_open"] or 0)
new_open = old_open + amt
new_avg = _trend_weighted_avg(old_avg, old_open, fill_px, amt)
conn.execute(
"UPDATE trend_pullback_plans SET legs_done=?, avg_entry_price=?, order_amount_open=?, last_mark_price=? WHERE id=?",
(legs_done + 1, new_avg, new_open, pf, row["id"]),
)
row = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=?", (row["id"],)).fetchone()
try:
_trend_refresh_stop_only(ex_sym, direction, sl)
except Exception:
pass
conn.execute(
"UPDATE trend_pullback_plans SET last_mark_price=? WHERE id=?",
(pf, row["id"]),
)
except Exception:
continue
conn.commit()
conn.close()
# 关键位监控(前端已下线时仍保留函数体,后台默认不再调用)
def check_key_monitors():
conn = get_db()
rows = conn.execute("SELECT * FROM key_monitors").fetchall()
for r in rows:
sym, typ, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"]
direction = (r["direction"] or "long").lower()
now_dt = app_now()
if not can_notify_key_monitor(r, now_dt):
continue
try:
checks = _key_hard_checks(sym, direction, up, low, typ)
except Exception:
checks = {"ok": False}
if not checks.get("ok"):
continue
btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h")
coin4h_status, _, _ = _status_by_ema55(sym, "4h")
risk_tip = None
if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"):
risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。"
box_h = abs(float(up) - float(low)) if up is not None and low is not None else 0.0
c_close = float(checks.get("confirm_close") or 0)
b_high = float(checks.get("breakout_high") or 0)
b_low = float(checks.get("breakout_low") or 0)
key_price = float(low) if direction == "long" else float(up)
if direction == "long":
tp1 = c_close + box_h
tp2 = c_close + box_h * 1.5
sl1 = b_low * (1 - 0.002) if b_low > 0 else None
sl2 = key_price * (1 - 0.002) if key_price > 0 else None
else:
tp1 = c_close - box_h
tp2 = c_close - box_h * 1.5
sl1 = b_high * (1 + 0.002) if b_high > 0 else None
sl2 = key_price * (1 + 0.002) if key_price > 0 else None
hard_lines = [
f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x",
f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']}",
f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}{round(checks['amp_pct'], 4)}%,要求0.03%~0.5%",
f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']}",
f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}{checks['rank']}/{checks['rank_total']},要求前30",
]
op_lines = [
f"方案A:止盈=箱体1.0倍({round(tp1, 8) if tp1 else '-' }),止损=突破K极值外0.2%{round(sl1, 8) if sl1 else '-' }",
f"方案B:止盈=箱体1.5倍({round(tp2, 8) if tp2 else '-' }),止损=箱体关键位外0.2%{round(sl2, 8) if sl2 else '-' }",
]
trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str()
msg = build_wechat_key_monitor_message(
symbol=sym,
direction=direction,
monitor_type=typ,
trigger_time=trigger_time,
key_price=key_price,
confirm_close=checks["confirm_close"],
hard_lines=hard_lines,
btc8h_status=btc8h_status,
coin4h_status=coin4h_status,
swing4h_pct=checks.get("swing4h_pct") or 0.0,
op_lines=op_lines,
risk_tip=risk_tip,
)
send_wechat_msg(msg)
new_count = int(r["notification_count"] or 0) + 1
max_n = int(r["max_notify"] or KEY_ALERT_MAX_TIMES)
conn.execute(
"UPDATE key_monitors SET notification_count = ?, last_notified_at = ? WHERE id = ?",
(new_count, app_now_str(), r["id"]),
)
if new_count >= max_n:
insert_key_monitor_history(conn, r, new_count, msg, "alerts_complete")
conn.execute("DELETE FROM key_monitors WHERE id = ?", (r["id"],))
send_wechat_msg(
"\n".join(
[
f"# 🧾 {r['symbol']} 关键位监控结束",
"",
f"- 原因:已满 {max_n} 次提醒",
"- 状态:已自动结束并记入历史",
]
)
)
conn.commit()
conn.close()
# 止盈止损监控(已修复:严格区分多空,无默认做多)
def check_order_monitors():
conn = get_db()
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
for r in rows:
pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"]
margin_capital = r["margin_capital"] or DAILY_START_CAPITAL
leverage = r["leverage"] or infer_leverage(sym)
session_date = r["session_date"] or get_trading_day()
p = get_price(sym)
if not p: continue
# 到达设定 R 倍后,按阶梯持续上移止损(本地风控层)
risk_amount = float(r["risk_amount"] or 0)
breakeven_armed = int(r["breakeven_armed"] or 0)
trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER)
step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0)
step_r = 1.0 if step_r <= 0 else step_r
breakeven_enabled = True
try:
if "breakeven_enabled" in r.keys():
breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0
except Exception:
breakeven_enabled = True
if breakeven_enabled and risk_amount > 0 and trigger_rr > 0:
now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage)
now_rr = now_pnl / risk_amount
if now_rr >= trigger_rr:
steps = int((now_rr - trigger_rr) // step_r)
locked_r = max(0.0, steps * step_r)
notional = float(margin_capital or 0) * float(leverage or 0)
risk_frac = (risk_amount / notional) if notional > 0 else None
if risk_frac and risk_frac > 0:
new_sl = calc_breakeven_stop(
direction,
trigger_price,
risk_frac,
locked_r=locked_r,
offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT),
)
if new_sl is not None:
should_move = (direction == "short" and new_sl < float(stop_loss)) or (
direction == "long" and new_sl > float(stop_loss)
)
if should_move:
was_armed = breakeven_armed
ex_sym = resolve_monitor_exchange_symbol(r)
new_sl = round_price_to_exchange(ex_sym, new_sl)
tp_ex = float(take_profit or 0)
ok_live, _live_reason = ensure_exchange_live_ready()
synced_ex = not ok_live
if ok_live and tp_ex > 0:
try:
replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex)
synced_ex = True
_clear_breakeven_exchange_warn(pid)
except Exception as e:
print(
f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}",
flush=True,
)
_send_breakeven_exchange_warn_once(
pid,
f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_exchange_error(e)}",
)
elif ok_live:
print(
f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit",
flush=True,
)
if synced_ex:
conn.execute(
"UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?",
(new_sl, new_sl, pid),
)
stop_loss = new_sl
breakeven_armed = 1
if not was_armed:
arm_txt = "保本止盈"
be_msg = build_wechat_breakeven_message(
sym,
direction,
arm_txt,
now_rr,
locked_r,
new_sl,
)
if ok_live:
be_msg += "\n- 交易所:已先撤后挂止盈止损"
send_wechat_msg(be_msg)
res = None
# 做多
if direction == "long":
if p >= take_profit: res = "止盈"
elif p <= stop_loss: res = "止损"
# 做空
elif direction == "short":
if p <= take_profit: res = "止盈"
elif p >= stop_loss: res = "止损"
if res:
now = app_now()
opened_at = get_opened_at_value(r)
opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None)
closed_at = now.strftime("%Y-%m-%d %H:%M:%S")
hold_seconds = calc_hold_seconds(opened_at, now)
pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage)
if res == "止损" and float(pnl_amount or 0) > 0:
res = "移动止盈" if breakeven_armed else "保本止盈"
else:
res = normalize_result_with_pnl(res, pnl_amount)
close_order_id = ""
try:
close_resp = close_exchange_order(r)
close_order_id = close_resp.get("id", "")
# 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细。
exit_p = extract_trade_price_from_order(close_resp)
if exit_p and exit_p > 0:
pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage)
guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p)
if guessed_res:
res = normalize_result_with_pnl(guessed_res, pnl_amount)
else:
res = normalize_result_with_pnl(res, pnl_amount)
else:
ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym)
tr = fetch_latest_closing_fill(
ex_sym,
direction,
opened_at,
opened_at_ms=opened_at_ms,
)
if tr and tr.get("price"):
try:
exit_p = float(tr["price"])
pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage)
guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p)
if guessed_res:
if guessed_res == "止损" and float(pnl_amount or 0) > 0:
res = "移动止盈" if breakeven_armed else "保本止盈"
else:
res = normalize_result_with_pnl(guessed_res, pnl_amount)
else:
res = normalize_result_with_pnl(res, pnl_amount)
except (TypeError, ValueError):
pass
ts = tr.get("timestamp")
if ts:
closed_at = ms_to_app_local_str(int(ts))
hold_seconds = calc_hold_seconds(
opened_at, parse_dt_for_trading_day(closed_at) or now
)
except Exception as e:
if is_no_position_error(str(e)):
ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym)
cancel_gate_swap_trigger_orders(ex_sym)
tr = fetch_latest_closing_fill(
ex_sym,
direction,
opened_at,
opened_at_ms=opened_at_ms,
)
if tr and tr.get("price"):
try:
exit_p = float(tr["price"])
pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage)
# 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判。
guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p)
if guessed_res:
if guessed_res == "止损" and float(pnl_amount or 0) > 0:
res = "移动止盈" if breakeven_armed else "保本止盈"
else:
res = normalize_result_with_pnl(guessed_res, pnl_amount)
else:
res = normalize_result_with_pnl(res, pnl_amount)
except (TypeError, ValueError):
pass
ts = tr.get("timestamp")
if ts:
closed_at = ms_to_app_local_str(int(ts))
hold_seconds = calc_hold_seconds(
opened_at, parse_dt_for_trading_day(closed_at) or now
)
insert_trade_record(
conn,
symbol=sym,
monitor_type=trade_record_monitor_type(conn, r),
trend_plan_id=trend_plan_id_from_monitor_row(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=handoff_trade_miss_reason(
"触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)",
r,
),
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
ex_sym_fail = r["exchange_symbol"] or normalize_exchange_symbol(sym)
cancel_gate_swap_trigger_orders(ex_sym_fail)
live_contracts = get_live_position_contracts(ex_sym_fail, direction)
if live_contracts is not None and live_contracts <= 0:
record_res, record_pnl, record_closed, sync_miss = resolve_synced_flat_close(
r, opened_at, opened_at_ms=opened_at_ms
)
record_miss = f"{sync_miss};本地触发{res}时平仓API失败:{e}"
monitor_status = "stopped"
else:
record_res, record_pnl, record_closed = res, pnl_amount, closed_at
record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}"
monitor_status = "error"
record_hold = calc_hold_seconds(
opened_at, parse_dt_for_trading_day(record_closed) or now
)
insert_trade_record(
conn,
symbol=sym,
monitor_type=trade_record_monitor_type(conn, r),
trend_plan_id=trend_plan_id_from_monitor_row(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=record_pnl,
hold_seconds=record_hold,
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(record_pnl, r["risk_amount"]),
result=record_res,
miss_reason=handoff_trade_miss_reason(record_miss, r),
opened_at=opened_at,
closed_at=record_closed,
)
session_capital = update_session_capital(conn, session_date, record_pnl)
conn.execute("UPDATE order_monitors SET status=? WHERE id=?", (monitor_status, pid))
conn.commit()
send_wechat_msg(
build_wechat_monitor_error_message(
symbol=sym,
direction=direction,
scene=f"触发{res}后交易所平仓失败",
error_text=str(e),
)
)
if monitor_status == "stopped":
send_wechat_msg(
build_wechat_close_message(
symbol=sym,
direction=direction,
result=f"{record_res}(已补记入交易记录)",
pnl_amount=record_pnl,
hold_seconds=record_hold,
trigger_price=trigger_price,
current_price=p,
stop_loss=stop_loss,
take_profit=take_profit,
close_order_id="-",
extra_note=record_miss,
session_capital_fallback=session_capital,
)
)
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=trade_record_monitor_type(conn, r),
trend_plan_id=trend_plan_id_from_monitor_row(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=handoff_trade_miss_reason(None, r),
opened_at=opened_at,
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid))
conn.commit()
conn.close()
def force_close_before_reset():
if not FORCE_CLOSE_ENABLED:
return
now = app_now()
# 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx)
if now.hour != FORCE_CLOSE_BJ_HOUR:
return
conn = get_db()
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
for r in rows:
p = get_price(r["symbol"])
if not p:
continue
direction = r["direction"]
trigger_price = r["trigger_price"]
margin_capital = r["margin_capital"] or DAILY_START_CAPITAL
leverage = r["leverage"] or infer_leverage(r["symbol"])
session_date = r["session_date"] or get_trading_day(now)
opened_at = get_opened_at_value(r)
closed_at = now.strftime("%Y-%m-%d %H:%M:%S")
hold_seconds = calc_hold_seconds(opened_at, now)
pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage)
try:
close_resp = close_exchange_order(r)
close_order_id = close_resp.get("id", "")
cancel_gate_swap_trigger_orders(r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]))
except Exception as e:
conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],))
conn.commit()
send_wechat_msg(
build_wechat_monitor_error_message(
symbol=r["symbol"],
direction=direction,
scene="强制清仓失败",
error_text=str(e),
)
)
continue
session_capital = update_session_capital(conn, session_date, pnl_amount)
insert_trade_record(
conn,
symbol=r["symbol"],
monitor_type=trade_record_monitor_type(conn, r),
trend_plan_id=trend_plan_id_from_monitor_row(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=handoff_trade_miss_reason(
f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓",
r,
),
opened_at=opened_at,
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"]))
send_wechat_msg(
build_wechat_close_message(
symbol=r["symbol"],
direction=direction,
result="强制清仓",
pnl_amount=pnl_amount,
hold_seconds=hold_seconds,
trigger_price=trigger_price,
current_price=p,
stop_loss=r["stop_loss"],
take_profit=r["take_profit"],
close_order_id=close_order_id or "-",
extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓",
session_capital_fallback=session_capital,
)
)
conn.commit()
conn.close()
# 后台线程
def background_task():
while True:
try:
auto_transfer_once_per_day()
conn = get_db()
reconcile_external_closes(conn)
conn.commit()
conn.close()
force_close_before_reset()
check_trend_pullback_plans()
_roll_cfg = app.extensions.get("strategy_roll_cfg")
if _roll_cfg:
from strategy_roll_monitor_lib import check_roll_monitors
check_roll_monitors(_roll_cfg)
check_order_monitors()
except:
pass
time.sleep(MONITOR_POLL_SECONDS)
# ====================== 登录路由 ======================
@app.route("/login", methods=["GET", "POST"])
def login():
if AUTH_DISABLED:
session["logged_in"] = True
return redirect("/")
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
if username == USERNAME and password == PASSWORD:
session["logged_in"] = True
return redirect("/")
else:
flash("账号或密码错误")
return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME)
@app.route("/logout")
def logout():
session.clear()
return redirect("/" if AUTH_DISABLED else "/login")
# 登录校验装饰器
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED):
return f(*args, **kwargs)
return redirect("/login")
return decorated
@app.route("/sync_positions")
@login_required
def sync_positions():
days_raw = (request.args.get("days") or "").strip()
sync_days = None
if days_raw:
try:
sync_days = max(1, min(365, int(days_raw)))
except Exception:
sync_days = None
conn = get_db()
synced = reconcile_external_closes(conn, days=sync_days)
conn.commit()
conn.close()
if sync_days is not None:
flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新")
else:
flash(f"同步完成:{synced} 笔持仓已按交易所状态更新")
return redirect("/")
@app.route("/api/sync_positions", methods=["POST"])
@login_required
def api_sync_positions():
payload = request.get_json(silent=True) or {}
days_raw = str(payload.get("days", "")).strip()
if not days_raw:
return jsonify({"ok": False, "msg": "请填写天数"}), 400
try:
days = int(days_raw)
except Exception:
return jsonify({"ok": False, "msg": "天数必须是整数"}), 400
if days < 1 or days > 365:
return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400
conn = get_db()
synced = reconcile_external_closes(conn, days=days)
conn.commit()
conn.close()
return jsonify({"ok": True, "days": days, "synced": int(synced)})
# ====================== 主页面 ======================
def render_main_page(page="trade"):
now = app_now()
trading_day = get_trading_day(now)
list_window = _list_window_from_request()
start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ)
conn = get_db()
session_row = ensure_session(conn, trading_day)
local_current_capital = float(session_row["current_capital"])
funding_capital, trading_capital = get_exchange_capitals()
# 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
recommended_capital = round(get_recommended_capital(current_capital), 2)
key_list = conn.execute("SELECT * FROM key_monitors").fetchall()
key_history = conn.execute("SELECT * FROM key_monitor_history ORDER BY id DESC LIMIT 80").fetchall()
stats_bundle = compute_stats_bundle(conn, trading_day, now)
raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
order_list = []
for o in raw_order_list:
order_list.append(enrich_order_item(row_to_dict(o), current_capital))
if page in ("trade", "records"):
try:
sync_trend_trade_records_from_exchange(conn)
except Exception:
pass
if page == "records":
raw_records = conn.execute(
f"SELECT * FROM trade_records WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? "
f"AND {sql_list_time_field('closed_at', 'created_at', 'opened_at')} <= ? ORDER BY id DESC LIMIT 2000",
(start_bj, end_bj),
).fetchall()
else:
raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC LIMIT 500").fetchall()
records = [to_effective_trade_dict(r) for r in raw_records]
total = len(records)
miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过")
win = sum(1 for r in records if (r.get("effective_result") or "") in ("止盈", "保本止盈", "移动止盈"))
occupied_miss_total = sum(
1
for r in records
if (r.get("effective_result") or "") == "错过"
and ("持仓占用" in str(r.get("effective_miss_reason") or ""))
)
rate = round(win/total*100,2) if total else 0
active_count = get_active_position_count(conn)
trend_active = conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
trend_plans_raw = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC"
).fetchall()
trend_plans = []
for r in trend_plans_raw:
try:
trend_plans.append(enrich_active_trend_plan_row(r))
except Exception as e:
print(f"[render_main_page] enrich trend plan: {e}")
trend_plans.append(row_to_dict(r))
preview_snapshots = []
if page == "records":
try:
snap_ts = sql_list_time_field("preview_created_at", "snapshot_at")
snap_rows = conn.execute(
f"SELECT * FROM trend_pullback_preview_snapshots WHERE {snap_ts} >= ? "
f"AND {snap_ts} <= ? ORDER BY id DESC LIMIT 500",
(start_bj, end_bj),
).fetchall()
for sr in snap_rows:
sd = row_to_dict(sr)
sd["outcome_label"] = preview_snapshot_outcome_label(sd.get("outcome"))
preview_snapshots.append(sd)
except Exception as e:
print(f"[records] trend_pullback_preview_snapshots: {e}")
can_trade = (
trading_day_reset_allows_new_open(now)
and active_count < MAX_ACTIVE_POSITIONS
and int(trend_active or 0) == 0
)
trend_preview = None
trend_preview_levels = []
preview_expires_ms = None
trend_preview_expired = False
trend_preview_id_arg = ""
if page in ("strategy", "strategy_trend", "strategy_roll"):
_trend_cleanup_stale_previews(conn)
if page in ("strategy", "strategy_trend"):
trend_preview_id_arg = (request.args.get("preview_id") or "").strip()
if trend_preview_id_arg:
pr = conn.execute(
"SELECT * FROM trend_pullback_previews WHERE id=?",
(trend_preview_id_arg,),
).fetchone()
now_ms = int(time.time() * 1000)
if pr and int(pr["expires_at_ms"] or 0) >= now_ms:
from strategy_trend_lib import build_trend_preview_level_rows
trend_preview = row_to_dict(pr)
preview_expires_ms = int(pr["expires_at_ms"])
if not trend_preview.get("contract_size"):
try:
ensure_markets_loaded()
ex_sym = trend_preview.get("exchange_symbol") or trend_preview.get("symbol")
mk = exchange.market(ex_sym)
trend_preview["contract_size"] = float(mk.get("contractSize") or 1)
except Exception:
pass
trend_preview, trend_preview_levels = build_trend_preview_level_rows(trend_preview)
elif pr:
trend_preview_expired = True
strategy_extra = {}
if page == "strategy_records":
from strategy_ui import strategy_render_extras
strategy_extra = strategy_render_extras(conn, page)
elif page in ("strategy", "strategy_trend", "strategy_roll"):
from strategy_ui import fetch_roll_page_data
strategy_extra = fetch_roll_page_data(
conn,
default_risk_percent=float(RISK_PERCENT),
count_active_trends=lambda c, ta=trend_active: int(ta or 0),
)
orphan_positions: list = []
if page == "trade":
try:
orphan_positions = collect_orphan_exchange_positions(order_list, conn)
except Exception as exc:
print(f"[render_main_page] orphan positions: {exc}")
conn.close()
return render_template(
"index.html",
page=page,
key=key_list,
key_history=key_history,
stats_bundle=stats_bundle,
order=order_list,
orphan_positions=orphan_positions,
record=records,
total=total,
miss_count=miss_count,
rate=rate,
trading_day=trading_day,
funding_usdt=funding_usdt,
daily_start_capital=DAILY_START_CAPITAL,
current_capital=current_capital,
recommended_capital=recommended_capital,
btc_leverage=BTC_LEVERAGE,
alt_leverage=ALT_LEVERAGE,
reset_hour=TRADING_DAY_RESET_HOUR,
balance_refresh_seconds=BALANCE_REFRESH_SECONDS,
auto_transfer_enabled=AUTO_TRANSFER_ENABLED,
auto_transfer_amount=AUTO_TRANSFER_AMOUNT,
auto_transfer_from=AUTO_TRANSFER_FROM,
auto_transfer_to=AUTO_TRANSFER_TO,
auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR,
full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
price_refresh_seconds=PRICE_REFRESH_SECONDS,
active_count=active_count,
max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
can_trade=can_trade,
trend_plans=trend_plans,
preview_snapshots=preview_snapshots,
exchange_sync_from_label=(EXCHANGE_POSITION_SYNC_FROM_BJ or "最近90天"),
trend_pullback_dca_legs=TREND_PULLBACK_DCA_LEGS,
trend_pullback_preview_ttl=TREND_PULLBACK_PREVIEW_TTL_SECONDS,
trend_preview=trend_preview,
trend_preview_levels=trend_preview_levels,
preview_expires_ms=preview_expires_ms,
trend_preview_expired=trend_preview_expired,
trend_preview_id_arg=trend_preview_id_arg,
trend_preview_max_drift_pct=TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT,
trend_manual_breakeven_offset_pct=TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT,
list_window=list_window,
list_window_presets={
"utc_today": PRESET_UTC_TODAY,
"utc_last24h": PRESET_UTC_LAST24H,
"utc_last7d": PRESET_UTC_LAST7D,
"custom": PRESET_CUSTOM,
},
focus_key_id=(key_list[0]["id"] if key_list else None),
focus_order_id=(order_list[0]["id"] if order_list else None),
data_export_version=3,
key_alert_max_times=KEY_ALERT_MAX_TIMES,
risk_percent=RISK_PERCENT,
position_sizing_mode=POSITION_SIZING_MODE,
position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE),
open_position_button_label=(
"开仓(全仓杠杆)" if is_full_margin_mode(POSITION_SIZING_MODE) else "开仓(以损定仓)"
),
breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER,
breakeven_offset_pct=BREAKEVEN_OFFSET_PCT,
occupied_miss_total=occupied_miss_total,
price_fmt=format_price_for_symbol,
amt_fmt=format_amount_for_symbol,
money_fmt=format_money_usdt,
entry_reason_options=list(ENTRY_REASON_OPTIONS),
entry_reason_other_value=ENTRY_REASON_OTHER,
journal_chart_tf_choices=JOURNAL_CHART_TF_CHOICES,
journal_chart_default_tf1=JOURNAL_CHART_DEFAULT_TF1,
journal_chart_default_tf2=JOURNAL_CHART_DEFAULT_TF2,
journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT,
journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR,
exchange_display=EXCHANGE_DISPLAY_NAME,
**strategy_extra,
)
@app.route("/")
@login_required
def index():
return redirect("/trade")
@app.route("/trade")
@login_required
def trade_page():
return render_main_page("trade")
@app.route("/records")
@login_required
def records_page():
return render_main_page("records")
@app.route("/stats")
@login_required
def stats_page():
return render_main_page("stats")
@app.route("/plan_history")
@login_required
def plan_history_page():
qs = list_window_redirect_query(session)
return redirect(f"/records?{qs}" if qs else "/records")
@app.route("/api/preview_snapshot/<int:sid>")
@login_required
def api_preview_snapshot(sid):
conn = get_db()
row = conn.execute("SELECT * FROM trend_pullback_preview_snapshots WHERE id=?", (sid,)).fetchone()
conn.close()
if not row:
return jsonify({"ok": False, "msg": "not_found"}), 404
d = row_to_dict(row)
d["outcome_label"] = preview_snapshot_outcome_label(d.get("outcome"))
return jsonify({"ok": True, "snapshot": d})
@app.route("/api/account_snapshot")
@login_required
def api_account_snapshot():
now = app_now()
trading_day = get_trading_day(now)
conn = get_db()
session_row = ensure_session(conn, trading_day)
local_current_capital = float(session_row["current_capital"])
funding_capital, trading_capital = get_exchange_capitals(force=True)
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
recommended_capital = round(get_recommended_capital(current_capital), 2)
active_count = get_active_position_count(conn)
trend_active = conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
conn.close()
can_trade = (
trading_day_reset_allows_new_open(now)
and active_count < MAX_ACTIVE_POSITIONS
and int(trend_active or 0) == 0
)
available_trading_usdt = get_available_trading_usdt()
return jsonify({
"funding_usdt": funding_usdt,
"current_capital": current_capital,
"available_trading_usdt": round(available_trading_usdt, 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 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()
symbol_set = set()
for r in key_rows:
symbol_set.add(r["symbol"])
for r in order_rows:
symbol_set.add(r["symbol"])
prices = {}
for s in symbol_set:
p = get_price(s)
if p is not None:
prices[s] = float(p)
all_swap_positions = []
if exchange_private_api_configured():
try:
ensure_markets_loaded()
# 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐
all_swap_positions = exchange.fetch_positions(None, {"settle": "usdt"}) or []
except Exception:
try:
all_swap_positions = exchange.fetch_positions() or []
except Exception:
all_swap_positions = []
key_prices = []
for r in key_rows:
price = prices.get(r["symbol"])
if price is None:
continue
upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"])
lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"])
gate = None
try:
gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"])
except Exception:
gate = None
gate_summary = "-"
gate_metrics = ""
if gate:
rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}"
gate_summary = (
f"量:{'Y' if gate.get('vol_ok') else 'N'} "
f"破:{'Y' if gate.get('breakout_ok') else 'N'} "
f"幅:{'Y' if gate.get('amp_ok') else 'N'} "
f"二确:{'Y' if gate.get('confirm_ok') else 'N'} "
f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})"
)
if gate.get("breakout_ok"):
try:
vol_now = round(float(gate.get("vol_break") or 0), 4)
vol_avg = round(float(gate.get("avg20") or 0), 4)
amp_pct = round(float(gate.get("amp_pct") or 0), 4)
cfm_close = round(float(gate.get("confirm_close") or 0), 8)
edge = round(float(gate.get("edge_price") or 0), 8)
gate_metrics = (
f"量值:{vol_now}/{vol_avg} "
f"幅值:{amp_pct}% "
f"二确值:{cfm_close}@{edge}"
)
except Exception:
gate_metrics = ""
key_prices.append({
"id": r["id"],
"symbol": r["symbol"],
"price": round(price, 6),
"upper_diff": upper_diff,
"upper_pct": upper_pct,
"lower_diff": lower_diff,
"lower_pct": lower_pct,
"gate_summary": gate_summary,
"gate_ok": bool(gate and gate.get("ok")),
"gate_metrics": gate_metrics,
})
order_prices = []
for r in order_rows:
price = prices.get(r["symbol"])
if price is None:
continue
margin = float(r["margin_capital"] or 0)
leverage = float(r["leverage"] or 0)
entry = float(r["trigger_price"] or 0)
pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0
pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0
exchange_tpsl = {"sl": None, "tp": None}
ex_sym = resolve_monitor_exchange_symbol(r)
prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"])
lev_row = r["leverage"] if "leverage" in r.keys() else None
ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None
payload = {
"id": r["id"],
"symbol": r["symbol"],
"price": round(price, 6),
"float_pnl": round(pnl, 6),
"float_pct": pnl_pct,
"plan_margin": round(margin, 4) if margin else None,
"exchange_initial_margin": None,
"exchange_notional": None,
"exchange_mark_price": None,
"pnl_source": "plan",
}
if ex_metrics:
if ex_metrics.get("initial_margin") is not None:
payload["exchange_initial_margin"] = ex_metrics["initial_margin"]
if ex_metrics.get("notional") is not None:
payload["exchange_notional"] = ex_metrics["notional"]
if ex_metrics.get("mark_price") is not None:
payload["exchange_mark_price"] = ex_metrics["mark_price"]
if ex_metrics.get("unrealized_pnl") is not None:
payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 6)
payload["pnl_source"] = "exchange"
denom = ex_metrics.get("initial_margin") or margin
payload["float_pct"] = (
round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct
)
apply_order_live_price_display(
payload,
r["symbol"],
price,
payload.get("exchange_mark_price"),
format_price_for_symbol,
)
if exchange_private_api_configured():
try:
exchange_tpsl = fetch_exchange_tpsl_slots(
ex_sym,
r["direction"],
plan_sl=r["stop_loss"],
plan_tp=r["take_profit"],
)
except Exception:
exchange_tpsl = {"sl": None, "tp": None}
payload["exchange_tpsl"] = exchange_tpsl
live_sl = tpsl_slot_trigger_price(exchange_tpsl.get("sl"))
live_tp = tpsl_slot_trigger_price(exchange_tpsl.get("tp"))
disp_sl = live_sl if live_sl is not None else r["stop_loss"]
disp_tp = live_tp if live_tp is not None else r["take_profit"]
sym = r["symbol"]
payload["stop_loss_raw"] = disp_sl
payload["take_profit_raw"] = disp_tp
payload["stop_loss_display"] = (
format_price_for_symbol(sym, disp_sl) if disp_sl not in (None, "") else ""
)
payload["take_profit_display"] = (
format_price_for_symbol(sym, disp_tp) if disp_tp not in (None, "") else ""
)
apply_order_price_display_fields(
payload,
direction=r["direction"],
entry_price=entry,
initial_stop_loss=r["initial_stop_loss"],
stop_loss=disp_sl,
take_profit=disp_tp,
calc_rr_ratio_fn=calc_rr_ratio,
exchange_tpsl=exchange_tpsl,
)
order_prices.append(payload)
if live_sl is not None or live_tp is not None:
try:
cur_sl = float(r["stop_loss"] or 0)
cur_tp = float(r["take_profit"] or 0)
except (TypeError, ValueError):
cur_sl, cur_tp = 0.0, 0.0
new_sl = live_sl if live_sl is not None else cur_sl
new_tp = live_tp if live_tp is not None else cur_tp
if (live_sl is not None and abs(new_sl - cur_sl) > 1e-12) or (
live_tp is not None and abs(new_tp - cur_tp) > 1e-12
):
conn.execute(
"UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?",
(new_sl, new_tp, int(r["id"])),
)
try:
conn.commit()
except Exception:
pass
conn.close()
from hub_position_metrics import build_position_marks_list
position_marks = build_position_marks_list(
all_swap_positions,
format_mark_display=lambda sym, px: format_price_for_symbol(sym, px),
)
return jsonify({
"updated_at": app_now_str(),
"key_prices": key_prices,
"order_prices": order_prices,
"position_marks": position_marks,
"positions_raw_count": len(all_swap_positions),
})
@app.route("/api/order/<int:order_id>/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/<int:order_id>/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,
fallback_sl=row["stop_loss"],
fallback_tp=row["take_profit"],
)
except Exception as e:
conn.close()
return jsonify({"ok": False, "msg": str(e)}), 400
entry_price = float(row["trigger_price"] or live_price or 0)
rr_ok, rr_err = tpsl_update_passes_rr_gate(
direction,
entry_price,
stop_loss,
take_profit,
MANUAL_MIN_PLANNED_RR,
calc_rr_ratio,
)
if not rr_ok:
conn.close()
return jsonify({"ok": False, "msg": rr_err}), 400
planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit)
if stop_is_profit_protecting(direction, entry_price, stop_loss):
planned_rr = None
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 <= 30),
}
)
@app.route("/api/order/relink_orphan", methods=["POST"])
@login_required
def api_order_relink_orphan():
"""交易所有仓但本地无 active 监控时,恢复最近一条已停止的同向监控记录。"""
data = request.get_json(silent=True) or {}
symbol = normalize_symbol_input(data.get("symbol"))
direction = (data.get("direction") or "long").strip().lower()
if not symbol:
return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400
if direction not in ("long", "short"):
direction = "long"
ok, reason = ensure_exchange_live_ready()
if not ok:
return jsonify({"ok": False, "msg": reason}), 400
exchange_symbol = normalize_exchange_symbol(symbol)
contracts = get_live_position_contracts(exchange_symbol, direction)
if contracts is None or float(contracts) <= 0:
return jsonify({"ok": False, "msg": "交易所当前无该方向持仓,无法恢复监控"}), 400
conn = get_db()
active = conn.execute(
"SELECT id FROM order_monitors WHERE status='active' AND symbol=? AND direction=? LIMIT 1",
(symbol, direction),
).fetchone()
if active:
conn.close()
return jsonify({"ok": True, "msg": "已有运行中的监控", "order_id": int(active["id"])})
trend_plan = conn.execute(
"SELECT id FROM trend_pullback_plans WHERE status='active' AND symbol=? AND direction=? "
"AND COALESCE(first_order_done, 0) != 0 LIMIT 1",
(symbol, direction),
).fetchone()
if trend_plan:
conn.close()
return jsonify(
{
"ok": False,
"msg": f"该持仓由趋势回调计划 #{int(trend_plan['id'])} 管理,请在策略页操作",
}
), 400
row = conn.execute(
"""
SELECT * FROM order_monitors
WHERE symbol=? AND direction=? AND status IN ('stopped', 'error')
ORDER BY id DESC LIMIT 1
""",
(symbol, direction),
).fetchone()
if not row:
conn.close()
return jsonify(
{
"ok": False,
"msg": "未找到可恢复的历史监控记录,请在中控核对持仓或联系管理员",
}
), 404
opened_at = get_opened_at_value(row)
purged = conn.execute(
"DELETE FROM trade_records WHERE symbol=? AND direction=? AND opened_at=? AND result LIKE ?",
(symbol, direction, opened_at, "%外部平仓%"),
).rowcount
conn.execute("UPDATE order_monitors SET status='active' WHERE id=?", (int(row["id"]),))
conn.commit()
oid = int(row["id"])
conn.close()
msg = "已恢复本地监控"
if purged:
msg += f"(已清除 {purged} 条误记的外部平仓记录)"
return jsonify({"ok": True, "msg": msg, "order_id": oid, "purged_trade_records": purged})
@app.route("/api/order_defaults")
@login_required
def api_order_defaults():
symbol = normalize_symbol_input(request.args.get("symbol"))
direction = (request.args.get("direction") or "long").strip().lower()
if not symbol:
return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400
if direction not in ("long", "short"):
direction = "long"
exchange_symbol = normalize_exchange_symbol(symbol)
leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
available = get_available_trading_usdt()
return jsonify({
"ok": True,
"symbol": symbol,
"exchange_symbol": exchange_symbol,
"direction": direction,
"leverage": leverage,
"available_trading_usdt": round(available, 4) if available is not None else None
})
@app.route("/order_focus")
@login_required
def order_focus():
now = app_now()
trading_day = get_trading_day(now)
conn = get_db()
session_row = ensure_session(conn, trading_day)
local_current_capital = float(session_row["current_capital"])
_, trading_capital_live = get_exchange_capitals()
current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4)
raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall()
conn.close()
orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders]
picked_id = request.args.get("order_id", "").strip()
selected = None
if picked_id.isdigit():
selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None)
if selected is None and orders:
selected = orders[0]
return render_template(
"order_focus_v2.html",
orders=orders,
selected_order=selected,
default_timeframe=KLINE_TIMEFRAME,
price_refresh_seconds=PRICE_REFRESH_SECONDS,
exchange_display=EXCHANGE_DISPLAY_NAME,
)
@app.route("/api/order_kline")
@login_required
def api_order_kline():
order_id_raw = (request.args.get("order_id") or "").strip()
if not order_id_raw.isdigit():
return jsonify({"ok": False, "msg": "order_id 无效"}), 400
order_id = int(order_id_raw)
timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip()
allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}
if timeframe not in allowed_tfs:
timeframe = KLINE_TIMEFRAME
limit = 100
now = app_now()
trading_day = get_trading_day(now)
conn = get_db()
session_row = ensure_session(conn, trading_day)
local_current_capital = float(session_row["current_capital"])
_, trading_capital_live = get_exchange_capitals()
current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4)
row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone()
conn.close()
if not row:
return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404
order_item = enrich_order_item(row_to_dict(row), current_capital)
exchange_symbol = order_item.get("exchange_symbol") or normalize_exchange_symbol(order_item["symbol"])
try:
ensure_markets_loaded()
ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit)
except Exception as e:
return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500
candles = []
for bar in ohlcv or []:
if not bar or len(bar) < 6:
continue
ts = int(bar[0] // 1000)
candles.append({
"time": ts,
"open": float(bar[1]),
"high": float(bar[2]),
"low": float(bar[3]),
"close": float(bar[4]),
"volume": float(bar[5]),
})
from focus_chart_lib import (
build_order_kline_order_payload,
load_swap_positions_for_order_kline,
metrics_for_order_item,
)
current_price = get_price(order_item["symbol"])
positions = load_swap_positions_for_order_kline(
exchange,
private_configured=exchange_private_api_configured(),
ensure_markets_fn=ensure_markets_loaded,
)
ex_metrics = metrics_for_order_item(
order_item,
positions,
resolve_ex_sym_fn=resolve_monitor_exchange_symbol,
select_live_fn=_select_live_position_row,
parse_metrics_fn=parse_ccxt_position_metrics,
)
order_payload = build_order_kline_order_payload(
order_item,
ticker_price=current_price,
format_price_fn=format_price_for_symbol,
calc_pnl_fn=calc_pnl,
calc_rr_ratio_fn=calc_rr_ratio,
ex_metrics=ex_metrics,
)
from focus_chart_lib import kline_api_price_fields
price_fields = kline_api_price_fields(
exchange,
exchange_symbol,
candles,
ensure_markets_fn=ensure_markets_loaded,
)
return jsonify({
"ok": True,
"timeframe": timeframe,
"limit": limit,
"order": order_payload,
"candles": candles,
"updated_at": app_now_str(),
**price_fields,
})
@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,
}
from focus_chart_lib import enrich_key_kline_response
price_display, key_info = enrich_key_kline_response(
symbol=symbol,
current_price=current_price,
key_info=key_info,
format_price_fn=format_price_for_symbol,
)
from focus_chart_lib import kline_api_price_fields
price_fields = kline_api_price_fields(
exchange,
exchange_symbol,
candles,
ensure_markets_fn=ensure_markets_loaded,
)
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": price_display,
"key_monitor": key_info,
"candles": candles,
"updated_at": app_now_str(),
**price_fields,
})
@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("/")
mt = (d.get("type") or "").strip()
direction_pre = (d.get("direction") or "long").strip().lower()
dup_msg = check_duplicate_submit(
session, submit_scope_add_key(symbol, mt, direction_pre)
)
if dup_msg:
flash(dup_msg)
return redirect("/")
rank, total = _daily_volume_rank(symbol)
if rank is None:
flash("日成交量排名读取失败,请稍后重试")
return redirect("/")
if rank > 30:
flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位")
return redirect("/")
conn = get_db()
conn.execute("INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)",
(symbol, d["type"], d.get("direction", "long"), d["upper"], d["lower"]))
conn.commit()
conn.close()
flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}")
return redirect("/")
@app.route("/add_order", methods=["POST"])
@login_required
def add_order():
d = request.form
now = app_now()
conn = get_db()
direction = d.get("direction", "long")
symbol = normalize_symbol_input(d.get("symbol"))
if not symbol:
conn.close()
flash("symbol 不能为空")
return redirect("/")
dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction))
if dup_msg:
conn.close()
flash(dup_msg)
return redirect("/trade")
ok, reason = precheck_risk(conn, symbol, direction)
if not ok:
if "已达最大持仓数" in reason:
try:
tp_raw = parse_positive_float(d.get("tp"))
sl_raw = parse_positive_float(d.get("sl"))
tgt_raw = parse_positive_float(d.get("tgt"))
except Exception:
tp_raw = sl_raw = tgt_raw = None
insert_trade_record(
conn,
symbol=symbol,
monitor_type="下单监控",
direction=direction if direction in ("long", "short") else "long",
trigger_price=tp_raw or 0,
stop_loss=sl_raw or 0,
take_profit=tgt_raw or 0,
result="错过",
miss_reason=f"持仓占用:{reason}",
opened_at=app_now_str(),
closed_at=app_now_str(),
)
conn.commit()
conn.close()
flash(f"风控拒绝下单:{reason}")
return redirect("/")
ok_live, reason_live = ensure_exchange_live_ready()
if not ok_live:
conn.close()
flash(f"风控拒绝下单:{reason_live}")
return redirect("/")
exchange_symbol = normalize_exchange_symbol(symbol)
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("/")
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("/")
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, 2)
if is_full_margin_mode(POSITION_SIZING_MODE):
ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
if not ok_flat:
conn.close()
flash(flat_msg)
return redirect("/")
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
sizing, sizing_err = compute_full_margin_sizing(
symbol=symbol,
available_usdt=available_usdt if available_usdt is not None else 0.0,
capital_base=capital_base,
buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
btc_leverage=BTC_LEVERAGE,
alt_leverage=ALT_LEVERAGE,
funds_decimals=2,
)
if sizing_err:
conn.close()
flash(sizing_err)
return redirect("/")
margin_capital = sizing["margin_capital"]
notional_value = sizing["notional_value"]
position_ratio = sizing["position_ratio"]
else:
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("/")
notional_value = round(risk_amount / risk_fraction, 2)
margin_capital = round(notional_value / leverage, 2)
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), 2)
if margin_capital > max_margin:
conn.close()
flash(f"保证金不足:交易账户可用约 {round(available_usdt, 2)}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
risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent)
risk_display = format_risk_display_text(
POSITION_SIZING_MODE, risk_percent, risk_amount_final, decimals=2
)
if direction == "short":
breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8)
else:
breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8)
breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0
conn.execute(
"INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit,
margin_capital, leverage, trade_style, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price,
breakeven_enabled,
notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day
)
)
conn.commit()
new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
opens_today_after = conn.execute(
"SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
(trading_day,),
).fetchone()[0]
conn.close()
chart_name = None
chart_url = None
if make_order_chart and ORDER_CHART_ENABLED:
try:
title_prefix = f"{symbol} {direction} #{new_order_id}"
chart_name = generate_order_open_chart(
exchange_symbol,
title_prefix,
opened_at_ms=opened_at_ms,
entry_price=trigger_price,
)
if chart_name:
chart_url = f"/static/images/order_charts/{chart_name}"
except Exception:
chart_name = None
chart_url = None
if chart_name:
try:
journal_id = f"order_{new_order_id}"
coin = journal_coin_from_symbol(symbol)
open_local = (opened_at_bj or "")[:16].replace(" ", "T")
if len(open_local) < 16:
open_local = app_now().strftime("%Y-%m-%dT%H:%M")
close_local = open_local
hold_duration = calc_duration_text(open_local, close_local)
note = (
f"auto_from_open_order id={new_order_id} oid={open_order_id} "
f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}"
)
conn = get_db()
conn.execute(
"""INSERT OR REPLACE INTO journal_entries
(id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason,
expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note,
mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare,
new_trade_while_occupied, note, image)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
journal_id,
open_local,
close_local,
hold_duration,
coin,
"multi",
"0",
"auto:open",
"待平仓",
"",
"",
"",
"",
"",
"",
None,
None,
None,
"",
"",
"",
note,
chart_name,
),
)
conn.commit()
conn.close()
except Exception:
try:
conn.close()
except Exception:
pass
_, trading_capital_after = get_exchange_capitals(force=True)
account_base_display = (
round(float(trading_capital_after), 4)
if trading_capital_after is not None
else round(float(capital_base), 4)
)
account_name = (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip()
dir_text = "多头(long" if direction == "long" else "空头(short"
order_state_text = (
"已在交易所挂条件委托(止盈、止损各一张触发单)"
if tpsl_attached
else "条件委托未挂上(已拦截)"
)
rr_show = planned_rr if planned_rr is not None else "-"
try:
rr_show_fmt = round(float(planned_rr), 4) if planned_rr is not None else None
except (TypeError, ValueError):
rr_show_fmt = None
rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1"
ep_wx = format_price_for_symbol(symbol, trigger_price)
sl_wx = format_price_for_symbol(symbol, stop_loss)
tp_wx = format_price_for_symbol(symbol, take_profit)
be_wx = format_price_for_symbol(symbol, breakeven_price)
style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势"
wx_lines = [
f"📈 {symbol} 开仓成功",
f"💼 交易类型:{dir_text}",
"🧾 订单基础信息",
f"🔖 交易所订单 ID{open_order_id}",
f"📈 交易风格:{style_zh}",
f"⚠️ 单笔风控风险:{risk_display}",
"📊 仓位配置详情",
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_display};基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约张数 {amount}(折算标的 {base_amount}),"
f"计划RR {planned_rr if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)",
f"本交易日累计开仓:{opens_today_after}",
]
if chart_url:
flash_lines.append(f"已生成多周期K线图:{chart_url}")
flash(" ".join(flash_lines))
if opens_today_before < DAILY_OPEN_ALERT_THRESHOLD <= opens_today_after:
advice = ai_short_advice(
f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_today_after} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。"
f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。"
f"用户自述“上头了”。请给克制提醒。"
)
if advice:
send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}")
flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}{advice[:300]}")
return redirect("/")
@app.route("/preview_trend_pullback", methods=["POST"])
@login_required
def preview_trend_pullback():
conn = get_db()
_trend_cleanup_stale_previews(conn)
okp, reasonp = precheck_trend_pullback_start(conn)
if not okp:
conn.close()
flash(reasonp)
return redirect(url_for("strategy_trading_page"))
ok_live, reason_live = ensure_exchange_live_ready()
if not ok_live:
conn.close()
flash(reason_live)
return redirect(url_for("strategy_trading_page"))
payload, err = parse_and_compute_trend_pullback_plan(request.form)
if err:
conn.close()
flash(err)
return redirect(url_for("strategy_trading_page"))
pid = str(uuid.uuid4())
exp_ms = int(time.time() * 1000) + int(TREND_PULLBACK_PREVIEW_TTL_SECONDS) * 1000
created = app_now_str()
conn.execute(
"""INSERT INTO trend_pullback_previews (
id,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent,
snapshot_available_usdt,snapshot_at,live_price_ref,plan_margin_capital,target_order_amount,first_order_amount,remainder_total,
dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,expires_at_ms,created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
pid,
payload["symbol"],
payload["exchange_symbol"],
payload["direction"],
payload["leverage"],
payload["stop_loss"],
payload["add_upper"],
payload["take_profit"],
payload["risk_percent"],
payload["snapshot_available_usdt"],
payload["snapshot_at"],
payload["live_price_ref"],
payload["plan_margin_capital"],
payload["target_order_amount"],
payload["first_order_amount"],
payload["remainder_total"],
payload["dca_legs"],
payload["per_leg_amount"],
payload["grid_prices_json"],
payload["leg_amounts_json"],
exp_ms,
created,
),
)
insert_trend_preview_snapshot(conn, pid, created, exp_ms, payload)
conn.commit()
conn.close()
flash(f"预览已生成,有效期 {TREND_PULLBACK_PREVIEW_TTL_SECONDS} 秒,请核对后点击「确认执行」。")
return redirect(url_for("strategy_trend_page", preview_id=pid))
@app.route("/execute_trend_pullback", methods=["POST"])
@login_required
def execute_trend_pullback():
pid = (request.form.get("preview_id") or "").strip()
if not pid:
flash("缺少预览 ID")
return redirect(url_for("strategy_trading_page"))
conn = get_db()
_trend_cleanup_stale_previews(conn)
pr = conn.execute("SELECT * FROM trend_pullback_previews WHERE id=?", (pid,)).fetchone()
now_ms = int(time.time() * 1000)
if not pr or int(pr["expires_at_ms"] or 0) < now_ms:
conn.close()
flash("预览已过期或不存在,请重新生成预览")
return redirect(url_for("strategy_trading_page"))
okp, reasonp = precheck_trend_pullback_start(conn)
if not okp:
conn.close()
flash(reasonp)
return redirect(url_for("strategy_trend_page", preview_id=pid))
ok_live, reason_live = ensure_exchange_live_ready()
if not ok_live:
conn.close()
flash(reason_live)
return redirect(url_for("strategy_trend_page", preview_id=pid))
snap_prev = float(pr["snapshot_available_usdt"] or 0)
snap_now = get_available_trading_usdt()
if snap_now is None or snap_now <= 0:
conn.close()
flash("无法读取当前合约可用余额,请稍后重试")
return redirect(url_for("strategy_trend_page", preview_id=pid))
drift_pct = abs(float(snap_now) - snap_prev) / max(snap_prev, 1e-9) * 100.0
if drift_pct > float(TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT):
conn.close()
flash(
f"当前可用余额与预览快照偏差 {drift_pct:.2f}%,超过允许 {TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT}% ,请重新生成预览"
)
return redirect(url_for("strategy_trading_page"))
symbol = pr["symbol"]
exchange_symbol = pr["exchange_symbol"]
direction = pr["direction"] or "long"
leverage = int(pr["leverage"] or 1)
stop_loss = float(pr["stop_loss"])
add_upper = float(pr["add_upper"])
take_profit = float(pr["take_profit"])
risk_percent = float(pr["risk_percent"] or 5)
snap = float(snap_now)
margin_plan = float(pr["plan_margin_capital"] or 0)
target_amt = float(pr["target_order_amount"] or 0)
first_amt = float(pr["first_order_amount"] or 0)
remainder_total = float(pr["remainder_total"] or 0)
n_legs = int(pr["dca_legs"] or 0)
per_ref = float(pr["per_leg_amount"] or 0)
grid_json = pr["grid_prices_json"] or "[]"
leg_json = pr["leg_amounts_json"] or "[]"
live_price = get_price(symbol)
if live_price is None:
conn.close()
flash("获取实时价格失败")
return redirect(url_for("strategy_trend_page", preview_id=pid))
try:
o1 = place_exchange_order(exchange_symbol, direction, first_amt, leverage, stop_loss=None, take_profit=None)
fill1 = resolve_order_entry_price(o1, exchange_symbol, live_price)
_trend_refresh_stop_only(exchange_symbol, direction, stop_loss)
except Exception as e:
conn.close()
flash(friendly_exchange_error(e, available_usdt=snap_now))
return redirect(url_for("strategy_trend_page", preview_id=pid))
now = app_now()
trading_day = get_trading_day(now)
opened_at = app_now_str()
opened_ms = _to_ms_with_fallback(None, opened_at)
cur = conn.execute(
"""INSERT INTO trend_pullback_plans (
status,symbol,exchange_symbol,direction,leverage,stop_loss,initial_stop_loss,add_upper,take_profit,risk_percent,
snapshot_available_usdt,snapshot_at,plan_margin_capital,target_order_amount,first_order_amount,remainder_total,
dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,legs_done,first_order_done,last_mark_price,avg_entry_price,order_amount_open,opened_at,opened_at_ms,session_date,message
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
"active",
symbol,
exchange_symbol,
direction,
leverage,
stop_loss,
stop_loss,
add_upper,
take_profit,
risk_percent,
snap,
opened_at,
margin_plan,
target_amt,
first_amt,
remainder_total,
n_legs,
per_ref,
grid_json,
leg_json,
0,
1,
float(live_price),
fill1,
first_amt,
opened_at,
opened_ms,
trading_day,
f"预览ID:{pid[:8]}",
),
)
new_plan_id = int(cur.lastrowid)
conn.execute(
"UPDATE trend_pullback_preview_snapshots SET outcome='executed', executed_plan_id=? WHERE preview_id=?",
(new_plan_id, pid),
)
conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,))
conn.commit()
try:
from strategy_trend_register import build_trend_config
from strategy_wechat_notify import notify_trend_plan_started
_tcfg = build_trend_config(sys.modules[__name__])
notify_trend_plan_started(
_tcfg,
plan_id=new_plan_id,
symbol=symbol,
direction=direction,
leverage=leverage,
stop_loss=stop_loss,
take_profit=take_profit,
add_upper=add_upper,
risk_percent=risk_percent,
dca_legs=n_legs,
first_order_amount=first_amt,
avg_entry=fill1,
snapshot_usdt=snap,
)
except Exception:
pass
conn.close()
flash(
f"趋势回调已执行:可用余额(执行时){round(snap, 2)}U;计划保证金约 {round(margin_plan, 2)}U"
f"总张数约 {target_amt},首仓 {first_amt},补仓 {n_legs} 档;已挂交易所止损,止盈由程序监控。"
)
return redirect(url_for("strategy_trend_page"))
@app.route("/cancel_trend_pullback_preview", methods=["POST"])
@login_required
def cancel_trend_pullback_preview():
pid = (request.form.get("preview_id") or "").strip()
conn = get_db()
if pid:
conn.execute(
"UPDATE trend_pullback_preview_snapshots SET outcome='cancelled' WHERE preview_id=? AND outcome='open'",
(pid,),
)
conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,))
conn.commit()
conn.close()
flash("已取消预览")
return redirect(url_for("strategy_trend_page"))
@app.route("/trend_pullback_breakeven/<int:pid>", methods=["POST"])
@login_required
def trend_pullback_breakeven(pid):
offset_raw = (request.form.get("breakeven_offset_pct") or "").strip()
offset_pct = None
if offset_raw:
try:
offset_pct = float(offset_raw)
if offset_pct < 0:
raise ValueError
except ValueError:
flash("保本偏移% 格式无效")
return redirect(url_for("strategy_trading_page"))
conn = get_db()
row = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,)
).fetchone()
if not row:
conn.close()
flash("未找到运行中的趋势回调计划")
return redirect(url_for("strategy_trading_page"))
from strategy_trend_register import apply_manual_breakeven, build_trend_config
cfg = build_trend_config(sys.modules[__name__])
ok, err = apply_manual_breakeven(cfg, conn, row, offset_pct=offset_pct)
conn.commit()
conn.close()
flash(
"已保本:趋势计划已结束,持仓已移交下单监控并挂止盈止损;平仓后将写入交易记录"
if ok
else (err or "保本移交失败")
)
return redirect(url_for("strategy_trend_page"))
@app.route("/stop_trend_pullback/<int:pid>")
@login_required
def stop_trend_pullback(pid):
conn = get_db()
row = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,)).fetchone()
if not row:
conn.close()
flash("未找到运行中的趋势回调计划")
return redirect("/trade")
ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])
direction = row["direction"] or "long"
lev = int(row["leverage"] or 1)
px = get_price(row["symbol"])
exit_p = float(px) if px is not None else 0.0
ok_live, _ = ensure_exchange_live_ready()
if ok_live:
pos = get_live_position_contracts(ex_sym, direction)
if pos is not None and pos > 0:
try:
exchange.set_leverage(lev, ex_sym)
side = "sell" if direction == "long" else "buy"
params = build_gate_order_params(direction, reduce_only=True)
close_resp = exchange.create_order(ex_sym, "market", side, float(pos), None, params)
ep = extract_trade_price_from_order(close_resp)
if ep:
exit_p = float(ep)
except Exception as e:
if not is_no_position_error(str(e)):
conn.close()
flash(f"平仓失败:{e}")
return redirect("/trade")
try:
cancel_all_open_orders_for_symbol(ex_sym)
except Exception:
pass
try:
_trend_finalize_plan(conn, row, "手动平仓", exit_p)
except Exception as e:
conn.execute(
"UPDATE trend_pullback_plans SET status='stopped_manual', message=? "
"WHERE id=? AND status='active'",
(f"结束异常:{e}", pid),
)
conn.commit()
conn.close()
flash(f"计划已结束但记账可能不完整:{e}")
return redirect(url_for("strategy_trend_page"))
conn.close()
flash("已结束趋势回调计划(市价平仓、撤单)")
return redirect(url_for("strategy_trend_page"))
@app.route("/delete_trend_plan_history/<int:pid>", methods=["POST"])
@login_required
def delete_trend_plan_history(pid):
conn = get_db()
row = conn.execute("SELECT id, status FROM trend_pullback_plans WHERE id=?", (pid,)).fetchone()
if not row:
conn.close()
flash("计划不存在")
return redirect(request.referrer or url_for("records_page"))
if (row["status"] or "").strip() == "active":
conn.close()
flash("运行中的计划请使用「结束计划」,不可从历史中删除")
return redirect(request.referrer or url_for("records_page"))
conn.execute("DELETE FROM trade_records WHERE trend_plan_id=?", (pid,))
conn.execute("DELETE FROM trend_pullback_preview_snapshots WHERE executed_plan_id=?", (pid,))
conn.execute("DELETE FROM trend_pullback_plans WHERE id=?", (pid,))
conn.commit()
conn.close()
flash("已删除该计划历史及关联趋势交易记录(若有)")
return redirect(request.referrer or url_for("records_page"))
@app.route("/delete_key_monitor/<int:kid>", methods=["POST"])
@login_required
def delete_key_monitor(kid):
conn = get_db()
row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone()
if not row:
conn.close()
return jsonify({"ok": False, "error": "not_found"})
insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual")
cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,))
conn.commit()
conn.close()
return jsonify({"ok": cur.rowcount > 0})
@app.route("/delete_key_history/<int:hid>", methods=["POST"])
@login_required
def delete_key_history(hid):
conn = get_db()
cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,))
conn.commit()
conn.close()
return jsonify({"ok": cur.rowcount > 0})
@app.route("/del_key/<int:id>")
@login_required
def del_key(id):
conn = get_db()
row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone()
if row:
insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual")
conn.execute("DELETE FROM key_monitors WHERE id=?", (id,))
conn.commit()
conn.close()
resp = redirect("/")
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
def _csv_response(filename, rows, header):
buf = StringIO()
w = csv.writer(buf)
w.writerow(header)
for row in rows:
w.writerow(row)
out = "\ufeff" + buf.getvalue()
return Response(
out,
mimetype="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "no-store",
},
)
def _md_response(filename, content):
return Response(
content,
mimetype="text/markdown; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "no-store",
},
)
@app.route("/export/trade_records")
@login_required
def export_trade_records():
win = _list_window_from_request()
start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ)
conn = get_db()
rows = conn.execute(
"SELECT id,symbol,monitor_type,direction,trigger_price,stop_loss,take_profit,margin_capital,leverage,"
"pnl_amount,hold_seconds,hold_minutes,opened_at,closed_at,result,miss_reason,"
"entry_reason,reviewed_entry_reason,created_at,trend_plan_id,exchange_realized_pnl,"
"exchange_opened_at,exchange_closed_at,exchange_sync_key FROM trade_records "
f"WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? "
f"AND {sql_list_time_field('closed_at', 'created_at', 'opened_at')} <= ? ORDER BY id ASC",
(start_bj, end_bj),
).fetchall()
conn.close()
head_base = [
"id",
"symbol",
"monitor_type",
"direction",
"trigger_price",
"stop_loss",
"take_profit",
"margin_capital",
"leverage",
"pnl_amount",
"hold_seconds",
"hold_minutes",
"opened_at",
"closed_at",
"result",
"miss_reason",
"entry_reason",
"reviewed_entry_reason",
"created_at",
"trend_plan_id",
"exchange_realized_pnl",
"exchange_opened_at",
"exchange_closed_at",
"exchange_sync_key",
]
head = head_base + ["开仓类型"]
data = []
for r in rows:
er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else ""
er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else ""
eff = er1 or er0
data.append(tuple(r[h] for h in head_base) + (eff,))
day = app_now().strftime("%Y%m%d")
return _csv_response(f"trade_records_v2_{day}.csv", data, head)
@app.route("/export/journal_entries")
@login_required
def export_journal_entries():
conn = get_db()
rows = conn.execute(
"SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason,"
"expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues,"
"post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC"
).fetchall()
conn.close()
head = [
"id",
"open_datetime",
"close_datetime",
"hold_duration",
"coin",
"tf",
"pnl",
"entry_reason",
"exit_reason",
"expect_rr",
"real_rr",
"early_exit",
"early_exit_trigger",
"early_exit_note",
"early_exit_reason",
"mood_issues",
"post_breakeven_stare",
"new_trade_while_occupied",
"note",
"image",
"created_at",
]
data = [tuple(r[h] for h in head) for r in rows]
day = app_now().strftime("%Y%m%d")
return _csv_response(f"journal_entries_v1_{day}.csv", data, head)
@app.route("/export/key_monitors")
@login_required
def export_key_monitors():
conn = get_db()
rows = conn.execute(
"SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify,"
"notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC"
).fetchall()
conn.close()
head = [
"id",
"symbol",
"monitor_type",
"direction",
"upper",
"lower",
"notification_count",
"last_notified_at",
"max_notify",
"notify_interval_min",
"breakout_limit_pct",
"created_at",
]
data = [tuple(r[h] for h in head) for r in rows]
day = app_now().strftime("%Y%m%d")
return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head)
@app.route("/export/key_monitor_history")
@login_required
def export_key_monitor_history():
conn = get_db()
rows = conn.execute(
"SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at "
"FROM key_monitor_history ORDER BY id ASC"
).fetchall()
conn.close()
head = [
"id",
"symbol",
"monitor_type",
"direction",
"upper",
"lower",
"notification_count",
"last_alert_message",
"close_reason",
"closed_at",
]
data = [tuple(r[h] for h in head) for r in rows]
day = app_now().strftime("%Y%m%d")
return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head)
@app.route("/del_order/<int:id>")
@login_required
def del_order(id):
conn = get_db()
row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone()
if not row:
conn.close()
flash("订单不存在")
return redirect("/")
if row["status"] == "active":
try:
p = get_price(row["symbol"]) or float(row["trigger_price"])
opened_at = get_opened_at_value(row)
closed_at = app_now_str()
hold_seconds = calc_hold_seconds(opened_at, app_now())
pnl_amount = calc_pnl(
row["direction"],
row["trigger_price"],
p,
row["margin_capital"] or DAILY_START_CAPITAL,
row["leverage"] or infer_leverage(row["symbol"])
)
close_resp = close_exchange_order(row)
close_order_id = close_resp.get("id", "")
cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]))
session_date = row["session_date"] or get_trading_day()
session_capital = update_session_capital(conn, session_date, pnl_amount)
insert_trade_record(
conn,
symbol=row["symbol"],
monitor_type=trade_record_monitor_type(conn, row),
trend_plan_id=trend_plan_id_from_monitor_row(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=handoff_trade_miss_reason("用户手动删除订单触发平仓", row),
opened_at=opened_at,
closed_at=closed_at,
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
conn.commit()
conn.close()
send_wechat_msg(
build_wechat_close_message(
symbol=row["symbol"],
direction=row["direction"],
result="手动平仓",
pnl_amount=pnl_amount,
hold_seconds=hold_seconds,
trigger_price=row["trigger_price"],
current_price=p,
stop_loss=row["stop_loss"],
take_profit=row["take_profit"],
close_order_id=close_order_id or "-",
extra_note="用户在页面手动平仓",
session_capital_fallback=session_capital,
)
)
flash("已按实盘流程手动平仓")
return redirect("/")
except Exception as e:
if is_no_position_error(str(e)):
cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]))
opened_at = get_opened_at_value(row)
opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at)
result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms)
miss_reason = f"手动删除时无持仓:{miss_reason}"
closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now()
hold_seconds = calc_hold_seconds(opened_at, closed_at_dt)
session_date = row["session_date"] or get_trading_day(closed_at_dt)
update_session_capital(conn, session_date, pnl_amount)
insert_trade_record(
conn,
symbol=row["symbol"],
monitor_type=trade_record_monitor_type(conn, 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=handoff_trade_miss_reason(miss_reason, row),
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]}"
journal_tfs = parse_journal_chart_timeframes(
d.get("journal_chart_tf1"),
d.get("journal_chart_tf2"),
ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None,
)
journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT)
chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor"))
marker_payload = {
"entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
"exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
"entry_price": d.get("entry_price_hint"),
"exit_price": d.get("exit_price_hint"),
"stop_loss_price": d.get("stop_loss_hint"),
"chart_anchor": chart_anchor,
"now_ts_ms": int(app_now().timestamp() * 1000),
}
try:
chart_fname = f"journal_{entry_id}.png"
saved = generate_multi_timeframe_chart_png(
exchange_symbol,
title_prefix,
timeframes=journal_tfs,
limit=journal_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 journal_tfs},
layout="vertical",
)
if saved:
image_filename = saved
chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)}{journal_limit}根):/static/images/{saved}"
if uploaded_tmp:
try:
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
if os.path.exists(old_path):
os.remove(old_path)
except Exception:
pass
else:
chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Gate 网络/代理是否正常。"
except Exception as e:
image_filename = uploaded_tmp
chart_msg = f"自动生成K线图失败:{str(e)}"
conn = get_db()
conn.execute(
"""INSERT INTO journal_entries
(id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason,
expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note,
mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare,
new_trade_while_occupied, note, image)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
entry_id,
normalize_bj_datetime_storage(d.get("open_datetime")),
normalize_bj_datetime_storage(d.get("close_datetime")),
hold_duration,
d.get("coin"),
d.get("tf"),
d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text,
early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note,
None, None, None, mood_issues,
d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename
)
)
conn.commit()
conn.close()
if chart_msg:
flash(f"交易复盘记录已保存。{chart_msg}")
else:
flash("交易复盘记录已保存")
return _redirect_records()
@app.route("/api/journals")
@login_required
def api_journals():
win = _list_window_from_request()
start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ)
conn = get_db()
rows = conn.execute(
f"SELECT * FROM journal_entries WHERE {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} >= ? "
f"AND {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} <= ? ORDER BY created_at DESC LIMIT 500",
(start_bj, end_bj),
).fetchall()
conn.close()
result = []
for r in rows:
item = row_to_dict(r)
item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x]
result.append(item)
return jsonify(result)
@app.route("/api/journal_prefill", methods=["POST"])
@login_required
def api_journal_prefill():
file = request.files.get("screenshot")
if not file or not file.filename:
return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400
try:
raw = file.read()
if not raw:
return jsonify({"ok": False, "msg": "截图为空"}), 400
image_b64 = base64.b64encode(raw).decode("utf-8")
except Exception as e:
return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400
parsed = ai_extract_journal_from_image(image_b64)
if parsed is None:
return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500
return jsonify({"ok": True, "data": parsed})
@app.route("/delete_journal/<jid>", methods=["POST"])
@login_required
def delete_journal(jid):
conn = get_db()
row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone()
if row and row["image"]:
img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"])
if os.path.exists(img_path):
os.remove(img_path)
conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,))
conn.commit()
conn.close()
return jsonify({"ok": True})
@app.route("/api/reviews")
@login_required
def api_reviews():
win = _list_window_from_request()
start_sql, end_sql = utc_window_to_utc_sql_strings(win["start_utc"], win["end_utc"])
conn = get_db()
rows = conn.execute(
"SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200",
(start_sql, end_sql),
).fetchall()
conn.close()
return jsonify([row_to_dict(r) for r in rows])
_REPO_STATIC_DIR = os.path.join(os.path.dirname(BASE_DIR), "static")
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
@app.route("/static/ai_review_render.js")
def static_ai_review_render_js():
if not os.path.isfile(_AI_REVIEW_RENDER_JS):
return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8")
@app.route("/static/form_submit_guard.js")
def static_form_submit_guard_js():
if not os.path.isfile(_FORM_SUBMIT_GUARD_JS):
return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8")
@app.route("/export/review_md/<rid>")
@login_required
def export_review_md(rid):
conn = get_db()
row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone()
conn.close()
if not row:
return Response("review not found", status=404, mimetype="text/plain; charset=utf-8")
review_type = "日复盘" if row["review_type"] == "daily" else "周复盘"
target_date = row["target_date"] or "-"
created_at = row["created_at"] or app_now_str()
content = (row["content"] or "").strip()
if not content:
content = "(无内容)"
md = (
f"# {review_type}报告\n\n"
f"- 目标日期: {target_date}\n"
f"- 生成时间: {created_at}\n"
f"- 报告ID: {row['id']}\n\n"
f"---\n\n"
f"{content}\n"
)
safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date"
safe_type = "daily" if row["review_type"] == "daily" else "weekly"
filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md"
return _md_response(filename, md)
@app.route("/export/reviews_md_bundle")
@login_required
def export_reviews_md_bundle():
review_type = (request.args.get("review_type") or "").strip().lower()
target_date = (request.args.get("target_date") or "").strip()
if review_type not in ("daily", "weekly"):
return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8")
if not target_date:
return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8")
conn = get_db()
rows = conn.execute(
"SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC",
(review_type, target_date),
).fetchall()
conn.close()
if not rows:
return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8")
title = "日复盘" if review_type == "daily" else "周复盘"
lines = [
f"# {title}汇总报告",
"",
f"- 目标日期: {target_date}",
f"- 条目数量: {len(rows)}",
f"- 导出时间: {app_now_str()}",
"",
"---",
"",
]
for idx, row in enumerate(rows, 1):
created_at = row["created_at"] or "-"
content = (row["content"] or "").strip() or "(无内容)"
lines.extend(
[
f"## 第{idx}",
"",
f"- 报告ID: {row['id']}",
f"- 生成时间: {created_at}",
"",
content,
"",
"---",
"",
]
)
md = "\n".join(lines)
safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date"
filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md"
return _md_response(filename, md)
@app.route("/delete_review/<rid>", methods=["POST"])
@login_required
def delete_review(rid):
conn = get_db()
conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,))
conn.commit()
conn.close()
return jsonify({"ok": True})
@app.route("/delete_trade_record/<int:rid>", methods=["POST"])
@login_required
def delete_trade_record(rid):
conn = get_db()
cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,))
conn.commit()
conn.close()
return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount})
@app.route("/api/trade_record_review_update", methods=["POST"])
@login_required
def api_trade_record_review_update():
payload = request.get_json(silent=True) or {}
rec_id = payload.get("id")
try:
rec_id = int(rec_id)
except Exception:
return jsonify({"ok": False, "msg": "记录ID无效"}), 400
reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip()
reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip()
reviewed_stop_loss_raw = payload.get("reviewed_stop_loss")
reviewed_take_profit_raw = payload.get("reviewed_take_profit")
reviewed_result = str(payload.get("reviewed_result") or "").strip()
reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip()
reviewed_pnl_raw = payload.get("reviewed_pnl_amount")
if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS:
return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400
try:
reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S")
reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S")
except Exception:
return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400
if reviewed_close_dt < reviewed_open_dt:
return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400
hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds())
hold_minutes = calc_hold_minutes(hold_seconds)
try:
reviewed_pnl_amount = float(reviewed_pnl_raw)
except Exception:
return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400
reviewed_stop_loss = None
if reviewed_stop_loss_raw not in (None, ""):
try:
reviewed_stop_loss = float(reviewed_stop_loss_raw)
except Exception:
return jsonify({"ok": False, "msg": "止损必须为数字"}), 400
reviewed_take_profit = None
if reviewed_take_profit_raw not in (None, ""):
try:
reviewed_take_profit = float(reviewed_take_profit_raw)
except Exception:
return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400
_MISSING_ER = object()
reviewed_entry_reason_update = _MISSING_ER
if "reviewed_entry_reason" in payload:
s = str(payload.get("reviewed_entry_reason") or "").strip()
if s and not entry_reason_valid_for_storage(s):
return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400
reviewed_entry_reason_update = s or None
conn = get_db()
row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone()
if not row:
conn.close()
return jsonify({"ok": False, "msg": "记录不存在"}), 404
risk_amount = row["risk_amount"]
actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount)
base_params = [
reviewed_opened_at,
reviewed_closed_at,
reviewed_stop_loss,
reviewed_take_profit,
round(reviewed_pnl_amount, 4),
reviewed_result or None,
reviewed_miss_reason or None,
hold_seconds,
hold_minutes,
app_now_str(),
actual_rr,
]
if reviewed_entry_reason_update is not _MISSING_ER:
conn.execute(
"""UPDATE trade_records
SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?,
reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?,
reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=?
WHERE id=?""",
tuple(base_params + [reviewed_entry_reason_update, rec_id]),
)
else:
conn.execute(
"""UPDATE trade_records
SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?,
reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?,
reviewed_at=?, actual_rr=COALESCE(?, actual_rr)
WHERE id=?""",
tuple(base_params + [rec_id]),
)
conn.commit()
conn.close()
return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes})
@app.route("/manual_transfer", methods=["POST"])
@login_required
def manual_transfer():
try:
amount = float(request.form.get("amount", "0"))
except Exception:
flash("划转金额格式错误")
return redirect("/")
from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip()
to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip()
ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account)
conn = get_db()
conn.execute(
"INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)",
("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500])
)
conn.commit()
conn.close()
if ok:
flash(f"手动划转成功:{amount}U {from_account}->{to_account}")
else:
flash(f"手动划转失败:{msg}")
return redirect("/")
def _journal_ai_chart_builder(row):
return build_journal_ai_chart_path(
row,
app.config["UPLOAD_FOLDER"],
order_chart_enabled=ORDER_CHART_ENABLED,
normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)),
generate_chart_fn=generate_multi_timeframe_chart_png,
local_datetime_to_ms_fn=_local_input_datetime_to_ms,
now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000),
)
@app.route("/ai_daily_review", methods=["POST"])
@login_required
def ai_daily_review():
date = request.form.get("date", "")
try:
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 = collect_images_for_ai_review(
rows,
app.config["UPLOAD_FOLDER"],
build_chart_if_missing=_journal_ai_chart_builder,
)
print(f"[ai_daily_review] date={date} rows={len(rows)} images={len(image_paths)}")
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})
except Exception as e:
print(f"[ai_daily_review] date={date} failed: {e}")
return jsonify({"ok": False, "result": f"生成失败:{e}"}), 500
@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", "")
try:
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 = collect_images_for_ai_review(
rows,
app.config["UPLOAD_FOLDER"],
build_chart_if_missing=_journal_ai_chart_builder,
)
print(f"[ai_weekly_review] range={start_date}~{end_date} rows={len(rows)} images={len(image_paths)}")
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})
except Exception as e:
print(f"[ai_weekly_review] range={start_date}~{end_date} failed: {e}")
return jsonify({"ok": False, "result": f"生成失败:{e}"}), 500
def _hub_meta_bundle():
return {
"exchange_display": EXCHANGE_DISPLAY_NAME,
"trend_pullback_preview_ttl": TREND_PULLBACK_PREVIEW_TTL_SECONDS,
"trend_manual_breakeven_offset_pct": TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT,
"trend_pullback_dca_legs": TREND_PULLBACK_DCA_LEGS,
"trend_preview_max_drift_pct": TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT,
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
"max_active_positions": max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))),
}
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
from hub_ohlcv_lib import fetch_ohlcv_for_hub
return fetch_ohlcv_for_hub(
symbol=symbol,
timeframe=timeframe,
since_ms=since_ms,
limit=limit,
normalize_symbol_input=normalize_symbol_input,
normalize_exchange_symbol=normalize_exchange_symbol,
ensure_markets_loaded=ensure_markets_loaded,
exchange=exchange,
friendly_error=friendly_exchange_error,
)
try:
import sys
from pathlib import Path
_repo_root = Path(__file__).resolve().parent.parent
if str(_repo_root) not in sys.path:
sys.path.insert(0, str(_repo_root))
from hub_bridge import install_on_app
install_on_app(
app,
exchange="gate_bot",
capabilities=["order", "trend"],
has_trend=True,
get_db=get_db,
row_to_dict=row_to_dict,
meta_fn=_hub_meta_bundle,
views={
"add_order": add_order,
"add_key": add_key,
"preview_trend_pullback": preview_trend_pullback,
"execute_trend_pullback": execute_trend_pullback,
"stop_trend_pullback": stop_trend_pullback,
"trend_pullback_breakeven": trend_pullback_breakeven,
},
ohlcv_fn=_hub_fetch_ohlcv,
)
except Exception as _hub_err:
print(f"[hub_bridge] gate_bot: {_hub_err}")
@app.route("/strategy")
@login_required
def strategy_trading_page():
return render_main_page("strategy")
@app.route("/strategy/trend")
@login_required
def strategy_trend_page():
qs = request.query_string.decode()
return redirect(f"/strategy?{qs}" if qs else "/strategy")
@app.route("/strategy/roll")
@login_required
def strategy_roll_page():
return redirect("/strategy")
from strategy_register import install_strategy_trading
install_strategy_trading(
app,
_REPO_ROOT,
app_module=sys.modules[__name__],
trend_enabled=True,
)
_purge_key_monitors_if_full_margin()
# 启动
if __name__ == "__main__":
threading.Thread(target=background_task, daemon=True).start()
app.run(host=HOST, port=PORT, debug=DEBUG)