from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response import sqlite3 import csv from io import StringIO import time import threading import requests import os import re import base64 import json import math from datetime import datetime, timedelta, timezone try: from zoneinfo import ZoneInfo except ImportError: ZoneInfo = None # type: ignore from functools import wraps import uuid import ccxt from werkzeug.utils import secure_filename try: from PIL import Image, ImageDraw, ImageFont except ImportError: Image = None # type: ignore ImageDraw = None # type: ignore ImageFont = None # type: ignore BASE_DIR = os.path.dirname(os.path.abspath(__file__)) _REPO_ROOT = os.path.dirname(BASE_DIR) import sys if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) from fib_key_monitor_lib import ( FIB_KEY_MONITOR_TYPES, calc_fib_plan, fib_invalidate_by_mark, fib_ratio_from_type, is_fib_key_monitor_type, stored_key_signal_type, ) def load_env_file(path): if not os.path.exists(path): return raw_bytes = open(path, "rb").read() text = "" for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"): try: text = raw_bytes.decode(enc) break except Exception: continue if not text: text = raw_bytes.decode("utf-8", errors="ignore") text = text.replace("\x00", "") for line in text.splitlines(): raw = line.strip() if not raw or raw.startswith("#") or "=" not in raw: continue key, value = raw.split("=", 1) clean_key = key.strip().lstrip("\ufeff") if not clean_key.replace("_", "").isalnum(): continue clean_value = value.strip().strip('"').strip("'") os.environ[clean_key] = clean_value load_env_file(os.path.join(BASE_DIR, ".env")) def resolve_path(path_value): if os.path.isabs(path_value): return path_value return os.path.join(BASE_DIR, path_value) app = Flask(__name__) app.secret_key = os.getenv("FLASK_SECRET_KEY", "crypto_monitor_2026_secret_key") # ====================== 登录配置 ====================== USERNAME = os.getenv("APP_USERNAME", "dekun") PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") # 企业微信机器人Webhook WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") SYSTEM_TYPE = "CRYPTO" HOST = os.getenv("APP_HOST", "0.0.0.0") PORT = int(os.getenv("APP_PORT", "5000")) DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) # 训练参数(可由 .env 覆盖) DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) # 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) # false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" ).lower() in ("1", "true", "yes", "on") APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") def _resolve_app_tz(): if ZoneInfo is not None: try: return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) except Exception: pass return timezone(timedelta(hours=8)) APP_TZ = _resolve_app_tz() LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" BINANCE_API_KEY = (os.getenv("BINANCE_API_KEY") or "").strip() BINANCE_API_SECRET = (os.getenv("BINANCE_API_SECRET") or "").strip() BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower() # hedge=双向持仓(需 positionSide);oneway / single=单向持仓 _raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower() BINANCE_POSITION_MODE = "hedge" if _raw_binance_pos in ("hedge", "dual", "double", "hedged") else "oneway" # 条件单触发参考:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价 BINANCE_TRIGGER_WORKING_TYPE = (os.getenv("BINANCE_TRIGGER_WORKING_TYPE") or "CONTRACT_PRICE").strip().upper() if BINANCE_TRIGGER_WORKING_TYPE not in ("CONTRACT_PRICE", "MARK_PRICE"): BINANCE_TRIGGER_WORKING_TYPE = "CONTRACT_PRICE" # 页面展示的交易所名称(多实例/多环境时可按需区分) EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Binance").strip() or "Binance" _BINANCE_DEFAULT_MARGIN_MODE = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5")) MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")) MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))) KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20"))) KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3")) KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03")) KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5")) KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))) KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2")) KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1")) KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true" ORDER_MONITOR_TYPE_MANUAL = "下单监控" ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) 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")))) BINANCE_NET_INCOME_TYPES = frozenset( {"REALIZED_PNL", "COMMISSION", "FUNDING_FEE", "INSURANCE_CLEAR", "INTERNAL_AUTO_CLOSE"} ) _LAST_EXCHANGE_PNL_SYNC_AT = 0.0 KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) # 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT") UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5")) RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate") AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") BINANCE_SOCKS_PROXY = (os.getenv("BINANCE_SOCKS_PROXY") or "").strip() BINANCE_HTTP_PROXY = (os.getenv("BINANCE_HTTP_PROXY") or "").strip() BINANCE_HTTPS_PROXY = (os.getenv("BINANCE_HTTPS_PROXY") or "").strip() def build_binance_ccxt_proxies(): """ 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口)。 推荐: - 本机:ssh -N -D 127.0.0.1:1080 user@vps - .env:BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080 说明: - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// """ socks = BINANCE_SOCKS_PROXY.strip() http = BINANCE_HTTP_PROXY.strip() https = BINANCE_HTTPS_PROXY.strip() or http if socks: return {"http": socks, "https": socks} if http or https: return {"http": http, "https": https} return None BINANCE_CCXT_PROXIES = build_binance_ccxt_proxies() os.makedirs(UPLOAD_FOLDER, exist_ok=True) os.makedirs(ORDER_CHART_DIR, exist_ok=True) app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER # Binance USDT 本位永续(ccxt unified: defaultType=swap) exchange = ccxt.binance({ "enableRateLimit": True, "options": { "defaultType": "swap", "defaultMarginMode": _BINANCE_DEFAULT_MARGIN_MODE, "adjustForTimeDifference": True, }, }) if BINANCE_CCXT_PROXIES: exchange.proxies = BINANCE_CCXT_PROXIES if BINANCE_API_KEY and BINANCE_API_SECRET: exchange.apiKey = BINANCE_API_KEY exchange.secret = BINANCE_API_SECRET MARKETS_LOADED = False ACCOUNT_BALANCE_CACHE = { "updated_at": 0.0, "funding_usdt": None, "trading_usdt": None } LIQUIDITY_RANK_CACHE = { "updated_at": 0.0, "ranks": {}, "total": 0, } # 企业微信推送 def send_wechat_msg(content): prefix = "【加密货币】" full_msg = f"{prefix}\n{content}" data = { "msgtype": "text", "text": {"content": full_msg} } try: requests.post(WECHAT_WEBHOOK, json=data, timeout=WECHAT_TIMEOUT_SECONDS) except: pass def _wechat_account_label(): return (os.getenv("BINANCE_ACCOUNT_LABEL") or "binance实盘账户").strip() def _wechat_direction_text(direction): d = (direction or "").lower() return "多头(long)" if d == "long" else "空头(short)" def _wechat_trading_capital_text(fallback=None): try: _, trading_capital = get_exchange_capitals(force=True) except Exception: trading_capital = None if trading_capital is not None: return f"{round(float(trading_capital), FUNDS_DECIMALS)}U" if fallback is not None: try: return f"{round(float(fallback), FUNDS_DECIMALS)}U" except Exception: pass return "-" def build_wechat_close_message( symbol, direction, result, pnl_amount, hold_seconds=None, trigger_price=None, current_price=None, stop_loss=None, take_profit=None, close_order_id=None, extra_note=None, session_capital_fallback=None, ): hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-" ep = format_price_for_symbol(symbol, trigger_price) cp = format_price_for_symbol(symbol, current_price) tp = format_price_for_symbol(symbol, take_profit) sl = format_price_for_symbol(symbol, stop_loss) cap_txt = _wechat_trading_capital_text(session_capital_fallback) try: if pnl_amount is not None: pv = float(pnl_amount) pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, FUNDS_DECIMALS)} U" else: pnl_disp = "-" except (TypeError, ValueError): pnl_disp = "-" lines = [ f"📉 {symbol} 平仓完成", f"💼 账户:{_wechat_account_label()}", "", "🧾 平仓概要", f"🔖 平仓单号:{close_order_id or '-'}", f"📌 方向:{_wechat_direction_text(direction)}", f"📌 平仓结果:{result or '-'}", f"💰 本单盈亏:{pnl_disp}", f"⏱ 持仓时长:{hold_txt}", f"💵 交易账户资金:{cap_txt}", "", "🎯 价位(计划)", f"开仓成交价:{ep}", f"离场参考价:{cp}", f"止盈价位:{tp}", f"止损价位:{sl}", ] if extra_note: lines.extend(["", "📎 备注", extra_note]) return "\n".join(lines) def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): sl_fmt = format_price_for_symbol(symbol, new_sl) return "\n".join( [ f"# 🛡️ {symbol} 保护位更新", f"**账户:{_wechat_account_label()}**", "", "---", "", "### 移动保本/止盈", f"- 方向:**{_wechat_direction_text(direction)}**", f"- 类型:**{arm_txt}**", f"- 当前RR:`{round(float(now_rr), 2)}R`", f"- 锁定RR:`{round(float(locked_r), 2)}R`", f"- 新保护位:`{sl_fmt}`", ] ) def build_wechat_monitor_error_message(symbol, direction, scene, error_text): return "\n".join( [ f"# ⚠️ {symbol} 下单监控异常", f"**账户:{_wechat_account_label()}**", "", "---", "", "### 异常信息", f"- 方向:**{_wechat_direction_text(direction)}**", f"- 场景:{scene}", f"- 错误:{str(error_text)}", ] ) def build_wechat_key_monitor_message( symbol, direction, monitor_type, trigger_time, key_price, confirm_close, hard_lines, btc8h_status, coin4h_status, swing4h_pct, op_lines, risk_tip=None, ): lines = [ f"# 🎯 {symbol} 关键位确认推送", f"**账户:{_wechat_account_label()}**", "", "---", "", "### 交易对 / 触发时间", f"- 交易对:**{symbol}**", f"- 触发时间:`{trigger_time}`", "", "### 方向与确认K", f"- 方向:**{_wechat_direction_text(direction)}**", "- 确认K:第二根5m收盘完成", "", "### 关键价位", f"- 类型:**{monitor_type}**", f"- 箱体关键位:`{key_price}`", f"- 第二根确认收盘价:`{confirm_close}`", "", "### 硬条件校验结果", ] lines.extend([f"- {x}" for x in hard_lines]) lines.extend( [ "", "### 市场状态说明", f"- BTC 8h 状态:**{btc8h_status}**", f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", "", "### 操作提示", ] ) lines.extend([f"- {x}" for x in op_lines]) if risk_tip: lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) return "\n".join(lines) def _read_image_base64(image_path): try: with open(image_path, "rb") as f: return base64.b64encode(f.read()).decode("utf-8") except Exception: return None def _extract_json_object(text): if not text: return None clean = text.strip() if clean.startswith("```"): clean = clean.replace("```json", "").replace("```", "").strip() try: return json.loads(clean) except Exception: pass match = re.search(r"\{[\s\S]*\}", clean) if not match: return None try: return json.loads(match.group(0)) except Exception: return None def _journal_row_lines_for_ai(idx, row, *, include_hold_duration=True): """把 journal 字段拼成给 AI 的文本;字段之外的事实不要指望模型自己猜。""" def nz(v, default="无"): if v is None: return default s = str(v).strip() return s if s else default lines = [ f"{idx}. {nz(row['coin'])} {nz(row['tf'])} | 盈亏:{nz(row['pnl'])}U | 实际RR:{nz(row['real_rr'])} | 预期RR:{nz(row['expect_rr'])}", f" 开仓逻辑:{nz(row['entry_reason'])}", f" 平仓/离场(交易员自述):{nz(row['exit_reason'])}", ] if include_hold_duration: lines.append(f" 持仓时长:{nz(row['hold_duration'])}") ee_bits = [ nz(row["early_exit"]), nz(row["early_exit_reason"]), nz(row["early_exit_trigger"]), nz(row["early_exit_note"]), ] if any(x != "无" for x in ee_bits): lines.append( " 提前离场记录:" f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}" ) mood_bits = f"心态标签:{nz(row['mood_issues'])}" if row["mood_score"] is not None: mood_bits += f" | 自评心态分:{row['mood_score']}" lines.append(f" {mood_bits}") if nz(row["post_breakeven_stare"]) != "无": lines.append(f" 保本后盯盘:{nz(row['post_breakeven_stare'])}") if nz(row["new_trade_while_occupied"]) != "无": lines.append(f" 占用时新开仓:{nz(row['new_trade_while_occupied'])}") if nz(row["note"]) != "无": lines.append(f" 备注:{nz(row['note'])}") return "\n".join(lines) + "\n" def ai_review(trades_text, period_title, image_paths=None): prompt = f""" 你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。 【硬性规则 — 必须遵守】 - 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。 - 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。 - 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。 - 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。 - 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。 - 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。 【输出结构】 1. 总体盈亏结构(紧扣笔数、盈亏数字与 RR,少形容词) 2. 心态与执行(每笔 1–10 分 + 一句依据;依据必须对应记录字段) 3. 行为标签(提前离场 / 乱开仓 / 扛单等):仅在有字段或自述支撑时点名;否则写「记录未勾选或未描述,不作强加」 4. 改进建议(最多 3 条,每条具体可执行) 5. 图表(若有且可读):结合价格行为简述;否则一两句说明无法看图分析 交易记录: {trades_text} """.strip() payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} images = [] for p in image_paths or []: b64 = _read_image_base64(p) if b64: images.append(b64) if images: payload["images"] = images try: r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) return r.json().get("response", "AI 生成失败") except Exception as e: return f"AI 调用失败:{str(e)}" def ai_short_advice(prompt_text): prompt = f""" 你是交易风控助理。请用中文给出**最多 3 条**提醒,要求: - 每条不超过 25 个字 - 语气克制、具体、可执行 - 不要输出 Markdown,不要编号前缀以外的废话 场景: {prompt_text} """.strip() payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} try: r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) return (r.json().get("response") or "").strip() except Exception: return "" def _load_font(size): if not ImageFont: return None candidates = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "C:\\Windows\\Fonts\\msyh.ttc", "C:\\Windows\\Fonts\\arial.ttf", ] for path in candidates: if path and os.path.exists(path): try: return ImageFont.truetype(path, size) except Exception: continue try: return ImageFont.load_default() except Exception: return None def _ohlcv_to_rows(ohlcv): rows = [] for bar in ohlcv or []: if not bar or len(bar) < 6: continue try: rows.append( { "ts": int(bar[0]), "o": float(bar[1]), "h": float(bar[2]), "l": float(bar[3]), "c": float(bar[4]), "v": float(bar[5]), } ) except Exception: continue return rows def _local_input_datetime_to_ms(dt_text): raw = str(dt_text or "").strip() if not raw: return None raw = raw.replace("T", " ") for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): try: dt = datetime.strptime(raw, fmt) aware = dt.replace(tzinfo=APP_TZ) return int(aware.timestamp() * 1000) except Exception: continue return None def _pick_marker_point(rows, target_ts_ms, target_price=None): if not rows or target_ts_ms is None: return None, None idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) if target_price is not None: try: p = float(target_price) if p > 0: return idx, p except Exception: pass return idx, float(rows[idx]["c"]) def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): if not Image or not ImageDraw: raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") img = Image.new("RGB", (width, height), bg_rgb) draw = ImageDraw.Draw(img) font = _load_font(14) small = _load_font(12) pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 plot_w = max(10, width - pad_l - pad_r) plot_h = max(10, height - pad_t - pad_b) header_bg = (245, 247, 250) draw.rectangle((0, 0, width, pad_t), fill=header_bg) if font: draw.text((10, 6), title, fill=(25, 35, 60), font=font) else: draw.text((10, 6), title, fill=(25, 35, 60)) if not rows: if small: draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) else: draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) return img lo = min(r["l"] for r in rows) hi = max(r["h"] for r in rows) if hi <= lo: hi = lo + 1e-12 n = len(rows) marker_by_idx = {} for mp in marker_points or []: try: idx = int(mp.get("idx")) except Exception: continue if idx < 0 or idx >= n: continue marker_by_idx[idx] = mp x0 = pad_l for i, r in enumerate(rows): x1 = pad_l + int((i + 1) * plot_w / n) x_mid = (x0 + x1) // 2 wick_x = x_mid y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) top = min(y_open, y_close) bot = max(y_open, y_close) up = r["c"] >= r["o"] wick_color = (120, 120, 120) edge_color = (20, 20, 20) draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) body_w = max(1, (x1 - x0) - 2) left = x0 + 1 if bot - top < 2: mid = (top + bot) // 2 draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) else: if up: draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) else: draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) if i in marker_by_idx: mp = marker_by_idx[i] tag = str(mp.get("tag") or "") m_price = float(mp.get("price") or r["c"]) y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) if tag == "ENTRY": m_color = (0, 195, 95) tri = [(x_mid, y_m - 20), (x_mid - 9, y_m - 4), (x_mid + 9, y_m - 4)] text_y = y_m - 36 else: m_color = (235, 65, 65) tri = [(x_mid, y_m + 20), (x_mid - 9, y_m + 4), (x_mid + 9, y_m + 4)] text_y = y_m + 12 draw.ellipse((x_mid - 5, y_m - 5, x_mid + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) draw.polygon(tri, fill=m_color) draw.line((x_mid, y_m, x_mid, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) if font: draw.text((x_mid + 8, text_y), tag, fill=m_color, font=font) else: draw.text((x_mid + 8, text_y), tag, fill=m_color) x0 = x1 if len(marker_points or []) >= 2: try: entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) if entry is not None and exitp is not None: ex_i, ex_p = int(entry["idx"]), float(entry["price"]) xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) except Exception: pass # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 if small: draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) return img def generate_multi_timeframe_chart_png( exchange_symbol, title_prefix, timeframes=None, limit=None, out_dir=None, filename=None, filename_prefix="chart", marker_payload=None, marker_timeframes=None, ): if not ORDER_CHART_ENABLED: return None if not Image: return None requested = timeframes or ORDER_CHART_TFS limit = limit or ORDER_CHART_LIMIT preferred_layout = ["5m", "15m", "1h", "4h"] requested_set = set(requested or []) ordered = [tf for tf in preferred_layout if tf in requested_set] for tf in requested: if tf not in ordered: ordered.append(tf) timeframes = ordered[:4] if ordered else preferred_layout ensure_markets_loaded() panels = [] cell_w, cell_h = 980, 520 for tf in timeframes: try: ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) except Exception: ohlcv = [] rows = _ohlcv_to_rows(ohlcv)[-limit:] title = f"{title_prefix} | {tf} x{len(rows)}" points = [] tf_key = str(tf).strip().lower() marker_tfs = {str(x).strip().lower() for x in (marker_timeframes or []) if str(x).strip()} if marker_payload and tf_key in marker_tfs: entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")) exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")) if entry_idx is not None and entry_price is not None: points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"}) if exit_idx is not None and exit_price is not None: points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"}) panels.append( _render_candles_subplot( rows, title, width=cell_w, height=cell_h, bg_rgb=(255, 255, 255), marker_points=points, ) ) if not panels: return None gap = 10 cols = 2 rows_n = int(math.ceil(len(panels) / cols)) w = cols * cell_w + (cols - 1) * gap h = rows_n * cell_h + (rows_n - 1) * gap out = Image.new("RGB", (w, h), (255, 255, 255)) idx = 0 for r in range(rows_n): for c in range(cols): if idx >= len(panels): break x = c * (cell_w + gap) y = r * (cell_h + gap) out.paste(panels[idx], (x, y)) idx += 1 # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图) if ImageDraw and rows_n >= 1: draw_out = ImageDraw.Draw(out) line_col = (220, 225, 232) x_mid = cell_w + gap // 2 if w > x_mid >= 0: draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2) for rr in range(1, rows_n): y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 if 0 <= y_mid <= h: draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) target_dir = out_dir or ORDER_CHART_DIR os.makedirs(target_dir, exist_ok=True) fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" out_path = os.path.join(target_dir, fname) out.save(out_path, format="PNG") return fname def generate_order_open_chart(exchange_symbol, title_prefix, timeframes=None, limit=None): return generate_multi_timeframe_chart_png( exchange_symbol, title_prefix, timeframes=timeframes, limit=limit, out_dir=ORDER_CHART_DIR, filename=None, filename_prefix="order", ) def journal_coin_from_symbol(symbol): sym = (symbol or "").strip().upper() if not sym: return "" if "/" in sym: return sym.split("/")[0].strip() if "-" in sym: return sym.split("-")[0].strip() if sym.endswith("USDT"): return sym[:-4].strip() return sym EARLY_EXIT_TRIGGERS = ( "", "保本止盈", "移动止盈", "手动平仓", "止损", "其他", ) # 与用户约定的固定开仓类型(仅做这几类单子) ENTRY_REASON_OPTIONS = ( "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", ) # 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) ENTRY_REASON_OTHER = "__OTHER__" def normalize_entry_reason(raw, custom_text=None): v = str(raw or "").strip() if v == ENTRY_REASON_OTHER: c = str(custom_text or "").strip() return c[:2000] if c else "" return v if v in ENTRY_REASON_OPTIONS else "" def entry_reason_valid_for_storage(s): """允许五种固定整句、或自定义短文本(不含未解析的 __OTHER__ 占位)。""" t = str(s or "").strip() if not t: return True if t == ENTRY_REASON_OTHER: return False if t in ENTRY_REASON_OPTIONS: return True return 1 <= len(t) <= 2000 def normalize_early_exit_trigger(raw): v = str(raw or "").strip() return v if v in EARLY_EXIT_TRIGGERS else "" def compose_early_exit_reason_saved(trigger, note): """Readable single-line string stored in early_exit_reason for legacy consumers.""" t = normalize_early_exit_trigger(trigger) n = str(note or "").strip() if t and n: return f"{t}|{n}" return t or n def journal_exit_reason_stored(trigger, note): """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。""" t = normalize_early_exit_trigger(trigger) n = str(note or "").strip() if t == "手动平仓": return n return t def ai_extract_journal_from_image(image_b64): prompt = """ 你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。 要求: 1) 仅输出一个 JSON 对象。 2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。 3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。 4) 允许字段为空。 5) entry_reason:优先从下列完整字符串中选一个(一字不差);若无法归类则可将简述写入 entry_reason(保存时也可选表单「其他」手写): - 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低 - 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高 - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):保本止盈、移动止盈、手动平仓、止损、其他。 7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 JSON 字段: { "open_datetime": "", "close_datetime": "", "coin": "", "tf": "", "pnl": "", "expect_rr": "", "real_rr": "", "entry_reason": "", "early_exit_trigger": "", "early_exit_note": "", "early_exit_reason": "", "note": "" } """.strip() payload = { "model": AI_MODEL, "prompt": prompt, "images": [image_b64], "stream": False, "options": {"temperature": 0.1}, } try: r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) raw = r.json().get("response", "") data = _extract_json_object(raw) or {} if not isinstance(data, dict): data = {} trig_in = data.get("early_exit_trigger") note_in = data.get("early_exit_note") legacy_reason = str(data.get("early_exit_reason") or "").strip() out = { "open_datetime": str(data.get("open_datetime") or "").strip(), "close_datetime": str(data.get("close_datetime") or "").strip(), "coin": str(data.get("coin") or "").strip(), "tf": str(data.get("tf") or "").strip(), "pnl": str(data.get("pnl") or "").strip(), "expect_rr": str(data.get("expect_rr") or "").strip(), "real_rr": str(data.get("real_rr") or "").strip(), "entry_reason": normalize_entry_reason(data.get("entry_reason")), "early_exit_trigger": normalize_early_exit_trigger(trig_in), "early_exit_note": str(note_in or "").strip(), "early_exit_reason": legacy_reason, "note": str(data.get("note") or "").strip(), } if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason: out["early_exit_note"] = legacy_reason if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason: out["early_exit_note"] = legacy_reason if out["early_exit_trigger"] != "手动平仓": out["early_exit_note"] = "" out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"]) return out except Exception: return None # 初始化数据库(支持多空方向) def init_db(): conn = sqlite3.connect(DB_PATH) c = conn.cursor() # 关键位监控 c.execute('''CREATE TABLE IF NOT EXISTS key_monitors (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT DEFAULT "long", upper REAL, lower REAL, notification_count INTEGER DEFAULT 0, last_notified_at TEXT, max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5, breakout_limit_pct REAL DEFAULT 1.5, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') # 订单监控(核心:加 direction 方向字段) c.execute('''CREATE TABLE IF NOT EXISTS order_monitors (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", exchange_symbol TEXT, trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5, trade_style TEXT DEFAULT "trend", risk_percent REAL, risk_amount REAL, breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL, breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL, notional_value REAL, position_ratio REAL, base_amount REAL, order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT, opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT, status TEXT DEFAULT "active")''') # 交易记录(必须存多空) c.execute('''CREATE TABLE IF NOT EXISTS trade_records (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0, trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL, hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER, result TEXT, miss_reason TEXT, exchange_trade_id TEXT, reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL, reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER, reviewed_at TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions (session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') c.execute('''CREATE TABLE IF NOT EXISTS journal_entries (id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT, coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT, expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT, early_exit_trigger TEXT, early_exit_note TEXT, mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT, new_trade_while_occupied TEXT, note TEXT, image TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews (id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique ON transfer_logs(transfer_type, transfer_day) WHERE transfer_type = 'auto_daily' ''') # 给旧表加 direction 字段(兼容老数据,不报错) try: c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") except Exception: pass try: c.execute(f"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '{ORDER_MONITOR_TYPE_MANUAL}'") except Exception: pass try: c.execute( "UPDATE order_monitors SET monitor_type=? WHERE monitor_type IS NULL OR TRIM(monitor_type)=''", (ORDER_MONITOR_TYPE_MANUAL,), ) except Exception: pass try: c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") except: pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") except: pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") except: pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") except: pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") except: pass try: c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") except: pass try: c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") except: pass try: c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") except: pass try: c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") except: pass try: c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") except: pass try: c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") except: pass for ddl in ( "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", ): try: c.execute(ddl) except Exception: pass try: c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") except Exception: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") except Exception: pass for col, ddl in ( ("key_signal_type", "ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT"), ("exchange_realized_pnl", "ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL"), ("exchange_opened_at", "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT"), ("exchange_closed_at", "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT"), ("exchange_sync_key", "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT"), ): try: c.execute(ddl) except Exception: pass c.execute( """CREATE TABLE IF NOT EXISTS key_monitor_history (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, close_reason TEXT, closed_at TEXT)""" ) conn.commit() conn.close() init_db() def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def app_now(): """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" return datetime.now(APP_TZ).replace(tzinfo=None) def app_now_str(): return app_now().strftime("%Y-%m-%d %H:%M:%S") def utc_now_dt(): """当前时刻(UTC,aware)。""" return datetime.now(timezone.utc) def utc_calendar_date_str(): """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" return utc_now_dt().strftime("%Y-%m-%d") def get_trading_day(now=None): """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" now = now or app_now() if getattr(now, "tzinfo", None): now = now.astimezone(APP_TZ).replace(tzinfo=None) if now.hour < TRADING_DAY_RESET_HOUR: return (now - timedelta(days=1)).strftime("%Y-%m-%d") return now.strftime("%Y-%m-%d") TRADE_COMPLETED_RESULTS = ( "止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓", "外部平仓", ) REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓") def parse_dt_for_trading_day(s): if not s: return None s = str(s).strip().replace("Z", "").replace("T", " ") if not s: return None for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): try: return datetime.strptime(s[:ln], fmt) except ValueError: continue return None def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): conn.execute( """INSERT INTO key_monitor_history (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) VALUES (?,?,?,?,?,?,?,?,?)""", ( row["symbol"], row["monitor_type"], row["direction"] or "long", row["upper"], row["lower"], int(notification_count or 0), (last_msg or "")[:800] if last_msg else None, close_reason, app_now_str(), ), ) def _session_week_bounds(trading_day_str): end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() start = end - timedelta(days=6) return start.strftime("%Y-%m-%d"), trading_day_str def _calendar_month_bounds(local_dt): y, m = local_dt.year, local_dt.month start = f"{y:04d}-{m:02d}-01" if m == 12: end_d = datetime(y, 12, 31).date() else: end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() return start, end_d.strftime("%Y-%m-%d") def _count_opens_between(conn, start_td, end_td): return conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", (start_td, end_td), ).fetchone()[0] def _load_completed_live_pnls(conn): q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, result, reviewed_result FROM trade_records WHERE monitor_type = '下单监控' ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" rows = conn.execute(q).fetchall() out = [] for r in rows: effective_result = (r["reviewed_result"] or r["result"] or "").strip() if effective_result not in TRADE_COMPLETED_RESULTS: continue try: p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) except (TypeError, ValueError): p = 0.0 t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) td = get_trading_day(t) if t else None out.append((p, t, td)) return out def _compute_period_metrics(trades): """trades: list of (pnl, close_dt, close_trading_day)""" trades = [(p, t, td) for p, t, td in trades if t is not None] trades.sort(key=lambda x: x[1]) closed = len(trades) wins = sum(1 for p, _, _ in trades if p > 0) losses = sum(1 for p, _, _ in trades if p < 0) net = round(sum(p for p, _, _ in trades), FUNDS_DECIMALS) loss_sum_raw = sum(p for p, _, _ in trades if p < 0) loss_sum_u = round(abs(loss_sum_raw), FUNDS_DECIMALS) if loss_sum_raw < 0 else 0.0 neg_pnls = [p for p, _, _ in trades if p < 0] pos_pnls = [p for p, _, _ in trades if p > 0] max_single_loss = round(min(neg_pnls), FUNDS_DECIMALS) if neg_pnls else None max_single_profit = round(max(pos_pnls), FUNDS_DECIMALS) if pos_pnls else None cum = peak = max_dd = 0.0 for p, _, _ in trades: cum += p peak = max(peak, cum) max_dd = max(max_dd, peak - cum) max_dd = round(max_dd, FUNDS_DECIMALS) streak = 0 for p, _, _ in reversed(trades): if p < 0: streak += 1 else: break daily = {} for p, _, td in trades: if td: daily[td] = daily.get(td, 0.0) + p max_loss_streak_days = 0 worst_day = None worst_day_pnl = None if daily: sorted_days = sorted(daily.keys()) run = 0 for d in sorted_days: if daily[d] < 0: run += 1 max_loss_streak_days = max(max_loss_streak_days, run) else: run = 0 worst_day = min(daily.keys(), key=lambda x: daily[x]) worst_day_pnl = round(daily[worst_day], FUNDS_DECIMALS) win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None return { "closed_count": closed, "win_count": wins, "loss_count": losses, "win_rate_pct": win_rate_pct, "net_pnl_u": net, "loss_sum_u": loss_sum_u, "max_single_loss": max_single_loss, "max_single_profit": max_single_profit, "max_drawdown_u": max_dd, "consecutive_losses": streak, "max_loss_streak_days": max_loss_streak_days, "worst_day": worst_day, "worst_day_pnl": worst_day_pnl, "opens_count": 0, "range_label": "", } def compute_stats_bundle(conn, trading_day, now_dt=None): """日 / 周 / 月 统计:平仓按平仓时间所在交易日计入。""" now_dt = now_dt or app_now() pnls = _load_completed_live_pnls(conn) total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] w_start, w_end = _session_week_bounds(trading_day) m_start, m_end = _calendar_month_bounds(now_dt) def in_week(tr): _p, _t, td = tr return td and w_start <= td <= w_end def in_month(tr): _p, _t, td = tr return td and m_start <= td <= m_end day_trades = [tr for tr in pnls if tr[2] == trading_day] week_trades = [tr for tr in pnls if in_week(tr)] month_trades = [tr for tr in pnls if in_month(tr)] dm = _compute_period_metrics(day_trades) wm = _compute_period_metrics(week_trades) mm = _compute_period_metrics(month_trades) dm["opens_count"] = _count_opens_between(conn, trading_day, trading_day) wm["opens_count"] = _count_opens_between(conn, w_start, w_end) mm["opens_count"] = _count_opens_between(conn, m_start, m_end) dm["range_label"] = f"北京时间交易日 {trading_day}" wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天窗口)" mm["range_label"] = f"{m_start} ~ {m_end}(北京时间自然月)" return { "trading_day": trading_day, "total_opens_all": total_opens_all, "day": dm, "week": wm, "month": mm, } def infer_leverage(symbol): sym = (symbol or "").strip().upper() if sym.startswith("BTC") or sym.startswith("ETH"): return BTC_LEVERAGE return ALT_LEVERAGE def normalize_exchange_symbol(symbol): sym = symbol.strip().upper() if ":" in sym: return sym if "/" in sym: base, quote = sym.split("/", 1) quote_clean = quote.split(":")[0] return f"{base}/{quote_clean}:{quote_clean}" return sym def resolve_monitor_exchange_symbol(row): """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" raw = "" try: if row["exchange_symbol"]: raw = str(row["exchange_symbol"]).strip() except (KeyError, IndexError, TypeError): raw = "" if not raw: try: raw = str(row["symbol"] or "").strip() except (KeyError, IndexError, TypeError): raw = "" return normalize_exchange_symbol(raw) if raw else "" def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): if not position_symbol or not wanted_exchange_symbol: return False a = normalize_exchange_symbol(str(position_symbol).strip()) b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip()) return a == b def _row_matches_monitor_direction(direction, position_dict): """ 判断持仓行是否属于当前监控方向。 币安双向持仓为 LONG/SHORT;单向持仓常为 BOTH,此时不能用 side!=direction 过滤, 否则会把整行跳过(live 恒为 0),平仓数量错误甚至误判「无仓」。 """ if not position_dict: return False direction = (direction or "").strip().lower() info = position_dict.get("info", {}) or {} ps = str( info.get("positionSide") or position_dict.get("side") or info.get("posSide") or "" ).strip().lower() signed_amt = None for key in ("positionAmt", "pos", "size"): v = info.get(key) if v is None or v == "": continue try: signed_amt = float(v) break except (TypeError, ValueError): continue if BINANCE_POSITION_MODE != "hedge": return True if ps in ("long", "short"): return ps == direction if ps in ("both", "net") or ps == "": if signed_amt is None: return True if direction == "long": return signed_amt > 0 if direction == "short": return signed_amt < 0 return False if ps and ps != direction: return False return True def _position_matches_wanted_contract(wanted_unified_sym, position_dict): """统一 symbol 比对;不一致时用交易所原始合约代码与 ccxt market.id 对齐(兼容命名差异)。""" if not wanted_unified_sym or not position_dict: return False ps = position_dict.get("symbol") if _position_contract_symbol_match(ps, wanted_unified_sym): return True try: ensure_markets_loaded() mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper() info = position_dict.get("info") or {} c_raw = str(info.get("contract") or info.get("symbol") or info.get("pair") or "").strip().upper() if mid and c_raw and mid == c_raw: return True except Exception: pass return False def _position_row_effective_contracts(p): """持仓数量:优先 ccxt contracts,否则用交易所原始 positionAmt/size/pos(避免统一层为 0 时被误判空仓)。""" if not p: return 0.0 info = p.get("info") or {} for val in (p.get("contracts"), info.get("positionAmt"), info.get("size"), info.get("pos")): if val is None or val == "": continue try: x = abs(float(val)) if x > 0: return x except (TypeError, ValueError): continue return 0.0 def normalize_symbol_input(symbol): sym = (symbol or "").strip().upper() if not sym: return "" if "/" in sym: return sym if ":" in sym: sym = sym.split(":")[0] return f"{sym}/USDT" def normalize_kline_limit(limit_raw, default=200): try: n = int(limit_raw) except Exception: return default return 200 if n >= 200 else 100 def get_recommended_capital(current_capital): if current_capital <= DAILY_LOSS_CAPITAL: return DAILY_LOSS_CAPITAL if current_capital >= DAILY_PROFIT_CAPITAL: return DAILY_PROFIT_CAPITAL return DAILY_START_CAPITAL def ensure_session(conn, session_date): row = conn.execute( "SELECT * FROM trading_sessions WHERE session_date = ?", (session_date,) ).fetchone() if row: return row conn.execute( "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) ) conn.commit() return conn.execute( "SELECT * FROM trading_sessions WHERE session_date = ?", (session_date,) ).fetchone() def update_session_capital(conn, session_date, pnl_amount): session_row = ensure_session(conn, session_date) new_capital = float(session_row["current_capital"]) + float(pnl_amount) conn.execute( "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", (round(new_capital, FUNDS_DECIMALS), session_date) ) conn.commit() return round(new_capital, FUNDS_DECIMALS) def calc_hold_seconds(opened_at_str, closed_at_dt): try: opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") return int((closed_at_dt - opened_at).total_seconds()) except Exception: return 0 def calc_hold_minutes(seconds): if not seconds or seconds <= 0: return 0 return max(1, int(seconds // 60)) def get_opened_at_value(row): try: keys = row.keys() if hasattr(row, "keys") else [] except Exception: keys = [] if "opened_at" in keys: value = row["opened_at"] if value: return value return app_now_str() def get_effective_trade_field(row, reviewed_key, base_key, default=None): try: keys = row.keys() if hasattr(row, "keys") else row.keys() except Exception: keys = [] if reviewed_key in keys: v = row[reviewed_key] if v is not None and str(v).strip() != "": return v if base_key in keys: v = row[base_key] if v is not None and str(v).strip() != "": return v return default def to_effective_trade_dict(row): item = row_to_dict(row) base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) 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 "" try: _keys = row.keys() if hasattr(row, "keys") else [] except Exception: _keys = [] _reviewed_pnl_raw = row["reviewed_pnl_amount"] if "reviewed_pnl_amount" in _keys else None has_reviewed_pnl = _reviewed_pnl_raw is not None and str(_reviewed_pnl_raw).strip() != "" ex_pnl = item.get("exchange_realized_pnl") if not has_reviewed_pnl and ex_pnl is not None and str(ex_pnl).strip() != "": try: item["effective_pnl_amount"] = round(float(ex_pnl), FUNDS_DECIMALS) item["display_pnl_source"] = "exchange" ex_open = (str(item.get("exchange_opened_at") or "").strip() or None) ex_close = (str(item.get("exchange_closed_at") or "").strip() or None) if ex_open: item["effective_opened_at"] = ex_open if ex_close: item["effective_closed_at"] = ex_close except (TypeError, ValueError): item["display_pnl_source"] = "local" elif has_reviewed_pnl: item["display_pnl_source"] = "reviewed" else: item["display_pnl_source"] = "local" return item # USDT 等资金类:展示与入库舍入统一为 2 位小数(与交易所常见口径一致) FUNDS_DECIMALS = 2 def format_funds_u(value): if value in (None, ""): return "-" try: return f"{float(value):.{FUNDS_DECIMALS}f}" except (TypeError, ValueError): return str(value) def round_funds(value): try: return round(float(value), FUNDS_DECIMALS) except (TypeError, ValueError): return None def _ccxt_swap_symbol_for_precision(symbol): """解析为 ccxt markets 中的永续 symbol,供 price_to_precision 使用。""" raw = (symbol or "").strip() if not raw: return None try: ensure_markets_loaded() markets = getattr(exchange, "markets", {}) or {} except Exception: return None upper = raw.upper().replace(" ", "") candidates = [] candidates.append(normalize_exchange_symbol(raw)) if upper.endswith("USDT") and len(upper) > 4 and "/" not in raw and ":" not in raw: candidates.append(f"{upper[:-4]}/USDT:USDT") if "/" not in raw and ":" not in raw and upper.isalnum() and not upper.endswith("USDT"): candidates.append(f"{upper}/USDT:USDT") for c in candidates: if c and c in markets: return c return None def format_price_for_symbol(symbol, value): if value in (None, ""): return "-" try: v = float(value) except (TypeError, ValueError): return str(value) if v == 0: return "0" try: ex_sym = _ccxt_swap_symbol_for_precision(symbol) if ex_sym: return str(exchange.price_to_precision(ex_sym, v)) except Exception: pass av = abs(v) # 无法加载市场或无该合约时:按价格量级回退(尽量不阻断页面) if av >= 10000: d = 2 elif av >= 100: d = 3 elif av >= 1: d = 4 elif av >= 0.01: d = 6 elif av >= 0.0001: d = 8 else: d = 10 text = f"{v:.{d}f}" return text.rstrip("0").rstrip(".") if "." in text else text def round_price_to_exchange(exchange_symbol, price): """将价格按 U 本位永续 tick 取整;失败返回 None。""" if price is None: return None try: ensure_markets_loaded() sym = normalize_exchange_symbol(exchange_symbol) return float(exchange.price_to_precision(sym, float(price))) except Exception: return None def format_hold_minutes(minutes): if not minutes: return "0分钟" total = int(minutes) hours = total // 60 mins = total % 60 if hours: return f"{hours}小时{mins}分钟" return f"{mins}分钟" def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage): 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, FUNDS_DECIMALS) 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, FUNDS_DECIMALS) except Exception: return None def calc_actual_rr(pnl_amount, risk_amount): try: r = float(risk_amount or 0) if r <= 0: return None return round(float(pnl_amount or 0) / r, 4) except Exception: return None def normalize_result_with_pnl(result, pnl_amount): """ 触发“止损”但实际已盈利时,归类为保本止盈,避免语义混淆。 """ if result == "止损": try: if float(pnl_amount or 0) > 0: return "保本止盈" except Exception: pass return result def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): """ 按“已锁定R”计算目标止损位: - long: entry + locked_r * (entry*risk_fraction) + offset - short: entry - locked_r * (entry*risk_fraction) - offset """ try: entry = float(entry_price) rf = float(risk_fraction) lr = float(locked_r) off = float(offset_pct) / 100.0 if entry <= 0 or rf <= 0 or lr < 0: return None base_move = entry * rf * lr offset_move = entry * off if direction == "short": return round(entry - base_move - offset_move, 8) return round(entry + base_move + offset_move, 8) except Exception: return None def insert_trade_record( conn, symbol, monitor_type, direction, trigger_price, stop_loss, initial_stop_loss=None, take_profit=None, margin_capital=None, leverage=None, pnl_amount=0, hold_seconds=0, trade_style=None, risk_amount=None, planned_rr=None, actual_rr=None, result="", miss_reason=None, opened_at=None, opened_at_ms=None, closed_at=None, closed_at_ms=None, exchange_trade_id=None, key_signal_type=None, ): hold_minutes = calc_hold_minutes(hold_seconds) open_ts = opened_at or app_now_str() close_ts = closed_at or app_now_str() open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) kst = (key_signal_type or "").strip() if kst not in KEY_MONITOR_AUTO_TYPES: kst = None conn.execute( "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, monitor_type, kst, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, pnl_amount, hold_seconds, trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id ) ) def calc_duration_text(open_str, close_str): try: fmt = "%Y-%m-%dT%H:%M" o = datetime.strptime(open_str, fmt) c = datetime.strptime(close_str, fmt) delta = c - o seconds = int(delta.total_seconds()) if seconds <= 0: return "0分钟" d = seconds // 86400 h = (seconds % 86400) // 3600 m = (seconds % 3600) // 60 parts = [] if d: parts.append(f"{d}天") if h: parts.append(f"{h}小时") if m or not parts: parts.append(f"{m}分钟") return " ".join(parts) except Exception: return "计算失败" def row_to_dict(row): return {k: row[k] for k in row.keys()} def enrich_order_item(raw_item, current_capital): item = dict(raw_item or {}) margin = float(item.get("margin_capital") or 0) lev = float(item.get("leverage") or 0) notional = item.get("notional_value") ratio = item.get("position_ratio") if notional is None: notional = round(margin * lev, FUNDS_DECIMALS) if margin and lev else 0 if ratio is None: ratio = round(margin / current_capital * 100, 2) if current_capital else 0 item["notional_value"] = notional item["position_ratio"] = ratio item["rr_ratio"] = calc_rr_ratio( item.get("direction") or "long", item.get("trigger_price"), item.get("initial_stop_loss") or item.get("stop_loss"), item.get("take_profit"), ) try: be = item.get("breakeven_enabled") item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 except Exception: item["breakeven_enabled"] = 1 if not (item.get("monitor_type") or "").strip(): item["monitor_type"] = ORDER_MONITOR_TYPE_MANUAL return item def ensure_exchange_live_ready(): if not LIVE_TRADING_ENABLED: return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" if not (BINANCE_API_KEY and BINANCE_API_SECRET): return False, "缺少 Binance API 密钥配置(BINANCE_API_KEY / BINANCE_API_SECRET)" return True, "" def order_row_monitor_type(row): if row is None: return ORDER_MONITOR_TYPE_MANUAL try: keys = row.keys() if hasattr(row, "keys") else [] except Exception: keys = [] if "monitor_type" in keys: mt = (row["monitor_type"] or "").strip() if mt: return mt return ORDER_MONITOR_TYPE_MANUAL def order_row_key_signal_type(row): if row is None: return None try: keys = row.keys() if hasattr(row, "keys") else [] except Exception: keys = [] if "key_signal_type" not in keys: return None kst = (row["key_signal_type"] or "").strip() if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst): return kst return None def exchange_private_api_configured(): """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。""" return bool(BINANCE_API_KEY and BINANCE_API_SECRET) def _extract_usdt_total(balance): usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} total_map = balance.get("total", {}) if isinstance(balance, dict) else {} free_map = balance.get("free", {}) if isinstance(balance, dict) else {} total = usdt_info.get("total") if total is None: total = usdt_info.get("equity") if total is None: total = total_map.get("USDT") if total is None: total = usdt_info.get("free") if total is None: total = free_map.get("USDT") try: return float(total) if total is not None else None except Exception: return None def _extract_usdt_free(balance): usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} free_map = balance.get("free", {}) if isinstance(balance, dict) else {} free = usdt_info.get("free") if free is None: free = free_map.get("USDT") try: return float(free) if free is not None else None except Exception: return None def _binance_futures_usdt_asset_row(balance): """从 U 本位合约 fetch_balance 的 info.assets 中取 USDT 一行(与币安后台口径一致)。""" if not isinstance(balance, dict): return None info = balance.get("info") if not isinstance(info, dict): return None assets = info.get("assets") if not isinstance(assets, list): return None for a in assets: if isinstance(a, dict) and str(a.get("asset") or "").upper() == "USDT": return a return None def _fetch_binance_swap_usdt_total(): """仅 U 本位永续合约账户 USDT(总额口径:优先 marginBalance / walletBalance,不回退现货)。""" try: ensure_markets_loaded() bal = exchange.fetch_balance(params={"type": "swap"}) row = _binance_futures_usdt_asset_row(bal) if row: for k in ("marginBalance", "walletBalance", "crossWalletBalance", "balance"): x = row.get(k) if x is not None and str(x).strip() != "": try: fv = float(x) if fv >= 0: return fv except (TypeError, ValueError): pass v = _extract_usdt_total(bal) return float(v) if v is not None else None except Exception: return None def _fetch_binance_swap_usdt_free(): """U 本位合约账户 USDT 可用(开仓可用保证金口径,不回退现货)。""" try: ensure_markets_loaded() bal = exchange.fetch_balance(params={"type": "swap"}) row = _binance_futures_usdt_asset_row(bal) if row: for k in ("availableBalance", "maxWithdrawAmount"): x = row.get(k) if x is not None and str(x).strip() != "": try: fv = float(x) if fv >= 0: return fv except (TypeError, ValueError): pass return _extract_usdt_free(bal) except Exception: return None def _fetch_binance_funding_usdt(): """Binance 资金账户(Funding Wallet)USDT 总额。""" try: ensure_markets_loaded() bal = exchange.fetch_balance(params={"type": "funding"}) val = _extract_usdt_total(bal) if val is not None: return float(val) except Exception: pass return None def get_available_trading_usdt(): ok_live, _ = ensure_exchange_live_ready() if not ok_live: return None return _fetch_binance_swap_usdt_free() def get_synced_leverage(exchange_symbol, direction): ensure_markets_loaded() try: positions = exchange.fetch_positions([exchange_symbol]) for p in positions: if not _position_matches_wanted_contract(exchange_symbol, p): continue if not _row_matches_monitor_direction(direction, p): continue info = p.get("info", {}) or {} if lev is None or lev == 0 or str(lev) == "0": lev = info.get("cross_leverage_limit") or info.get("leverage") if lev: try: return int(float(lev)) except Exception: pass except Exception: pass return None def friendly_exchange_error(err, available_usdt=None): msg = str(err) low = msg.lower() if ( "51008" in msg or "insufficient" in low or "margin" in low and ("not enough" in low or "不足" in msg) or "balance" in low and "insufficient" in low ): tail = f"(当前交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U)" if available_usdt is not None else "" return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。" clean = re.sub(r"\s+", " ", msg).strip() return f"交易所下单失败:{clean}" def get_exchange_capitals(force=False): ok_live, _ = ensure_exchange_live_ready() if not ok_live: return None, None now_ts = time.time() if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] try: ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt() except Exception: ACCOUNT_BALANCE_CACHE["funding_usdt"] = None try: ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total() except Exception: # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 ACCOUNT_BALANCE_CACHE["trading_usdt"] = None ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] def execute_transfer_usdt(amount, from_account, to_account): if amount <= 0: return False, "划转金额必须大于0", None ok_live, reason = ensure_exchange_live_ready() if not ok_live: return False, reason, None try: resp = exchange.transfer(TRANSFER_CCY, float(amount), from_account, to_account) return True, "划转成功", resp except Exception as e: msg = str(e) if "INVALID_KEY" in msg or "Invalid key" in msg or "-2015" in msg: msg += ( "。常见原因:① BINANCE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" "③ API Key 未勾选「允许合约」「允许万向划转」等所需权限;④ Key 已重置或权限变更。" ) return False, msg, None def get_account_usdt_total(account_type): """读取各账户 USDT。funding 走资金钱包;swap 仅合约账户;spot 仅现货。""" raw = (account_type or "").strip().lower() if raw == "funding": return _fetch_binance_funding_usdt() if raw == "swap": return _fetch_binance_swap_usdt_total() try: ensure_markets_loaded() bal = exchange.fetch_balance(params={"type": raw}) val = _extract_usdt_total(bal) if val is not None: return val return 0.0 if raw == "spot" else None except Exception: return None def auto_transfer_once_per_day(): if not AUTO_TRANSFER_ENABLED: return utc_dt = utc_now_dt() bj = utc_dt.astimezone(APP_TZ) if bj.hour != AUTO_TRANSFER_BJ_HOUR: return transfer_day = utc_calendar_date_str() conn = get_db() exists = conn.execute( "SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?", ("auto_daily", transfer_day) ).fetchone() if exists: conn.close() return target_amount = AUTO_TRANSFER_AMOUNT to_balance = get_account_usdt_total(AUTO_TRANSFER_TO) from_balance = get_account_usdt_total(AUTO_TRANSFER_FROM) if to_balance is None: conn.execute( "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"读取{AUTO_TRANSFER_TO}账户USDT失败") ) conn.commit() conn.close() return needed = round(max(target_amount - float(to_balance), 0), FUNDS_DECIMALS) if needed <= 0: conn.execute( "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "skipped", f"{AUTO_TRANSFER_TO}账户已达到目标{target_amount}U") ) conn.commit() conn.close() return if from_balance is not None and from_balance < needed: conn.execute( "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"{AUTO_TRANSFER_FROM}账户USDT不足,需{needed}U,当前{round(from_balance, FUNDS_DECIMALS)}U") ) conn.commit() conn.close() send_wechat_msg( f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{needed}U,当前{round(from_balance, FUNDS_DECIMALS)}U\n" f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" ) return ok, msg, _ = execute_transfer_usdt(needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO) conn.execute( "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "success" if ok else "failed", msg[:500]) ) conn.commit() conn.close() if ok: send_wechat_msg( f"自动划转成功:补足到{target_amount}U,实际划转{needed}U " f"{AUTO_TRANSFER_FROM}->{AUTO_TRANSFER_TO}\n" f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" ) else: send_wechat_msg( f"自动划转失败:计划补足到{target_amount}U,需划转{needed}U\n原因:{msg}\n" f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" ) def trading_day_reset_allows_new_open(now): """是否允许在满足其它风控的前提下于当前时刻新开仓(仅「整点前禁开」守卫)。""" if not TRADING_DAY_RESET_OPEN_GUARD_ENABLED: return True return now.hour >= TRADING_DAY_RESET_HOUR def get_active_position_count(conn): return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) def clear_key_sizing_snapshot_if_flat(conn, session_date): if get_active_position_count(conn) > 0: return conn.execute( "UPDATE trading_sessions SET key_sizing_capital_snapshot = NULL, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", (session_date,), ) conn.commit() def get_key_sizing_capital_snapshot(conn, session_date): row = ensure_session(conn, session_date) try: val = row["key_sizing_capital_snapshot"] except (KeyError, IndexError): return None if val is None: return None try: return float(val) except (TypeError, ValueError): return None def set_key_sizing_capital_snapshot(conn, session_date, capital): ensure_session(conn, session_date) conn.execute( "UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", (round(float(capital), FUNDS_DECIMALS), session_date), ) conn.commit() def resolve_capital_base_for_key_open(conn, trading_day, live_capital): """关键位自动开仓:有仓时用无仓时资金快照计仓(可配置)。""" live = float(live_capital) active = get_active_position_count(conn) if active <= 0: set_key_sizing_capital_snapshot(conn, trading_day, live) return live if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT: snap = get_key_sizing_capital_snapshot(conn, trading_day) if snap is not None and snap > 0: return snap return live def precheck_risk(conn, symbol, direction): now = app_now() if not trading_day_reset_allows_new_open(now): return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" active_count = get_active_position_count(conn) if active_count >= MAX_ACTIVE_POSITIONS: return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})" if direction not in ("long", "short"): return False, "方向必须为 long 或 short" if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): expected = BTC_LEVERAGE else: expected = ALT_LEVERAGE if expected <= 0: return False, "杠杆配置异常" return True, "" def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): ensure_markets_loaded() notional = float(margin_capital) * float(leverage) ticker = exchange.fetch_ticker(exchange_symbol) price = float(ticker.get("last") or fallback_price) if price <= 0: raise ValueError("触发价必须大于 0") market = exchange.market(exchange_symbol) contract_size = float(market.get("contractSize") or 1) if market.get("contract"): # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 amount = notional / (price * contract_size) else: amount = notional / price min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") if min_amount and amount < float(min_amount): raise ValueError(f"下单数量过小,最小数量为 {min_amount}") amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) if amount_precise <= 0: raise ValueError("下单数量精度后为 0,请提高基数或降低价格") return amount_precise, price def _to_positive_float(value): try: n = float(value) return n if n > 0 else None except Exception: return None def _extract_order_price_value(order_obj): if not isinstance(order_obj, dict): return None for key in ("average", "price"): v = _to_positive_float(order_obj.get(key)) if v is not None: return v cost = _to_positive_float(order_obj.get("cost")) filled = _to_positive_float(order_obj.get("filled")) if cost is not None and filled is not None and filled > 0: return cost / filled info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): v = _to_positive_float(info.get(key)) if v is not None: return v return None def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): price = _extract_order_price_value(order_resp) if price is not None: return round(price, 8) order_id = (order_resp or {}).get("id") if order_id: try: fetched = exchange.fetch_order(order_id, exchange_symbol) fetched_price = _extract_order_price_value(fetched) if fetched_price is not None: return round(fetched_price, 8) except Exception: pass fallback = _to_positive_float(fallback_price) return round(fallback, 8) if fallback is not None else 0.0 def get_contract_size(exchange_symbol): ensure_markets_loaded() market = exchange.market(exchange_symbol) return float(market.get("contractSize") or 1) def parse_positive_float(value): if value is None: return None raw = str(value).strip() if not raw: return None num = float(raw) if num <= 0: raise ValueError("数值必须大于0") return num def build_binance_order_params(direction, reduce_only=False): params = {} if BINANCE_POSITION_MODE == "hedge": params["positionSide"] = "LONG" if direction == "long" else "SHORT" if reduce_only: params["reduceOnly"] = True return params def _binance_market_close_param_candidates(direction): """ 平仓市价单参数组合(按顺序尝试)。 部分币安 U 本位账户对市价减仓报 -1106「reduceOnly sent when not required」, 与条件单一致,需再试不带 reduceOnly 的写法;另保留双向/单向 positionSide 切换。 """ ps = "LONG" if direction == "long" else "SHORT" hedge_ro = {"positionSide": ps, "reduceOnly": True} hedge_plain = {"positionSide": ps} oneway_ro = {"reduceOnly": True} oneway_plain = {} if BINANCE_POSITION_MODE == "hedge": return [hedge_ro, hedge_plain, oneway_ro, oneway_plain] return [oneway_ro, oneway_plain, hedge_ro, hedge_plain] def _is_binance_close_param_retryable(err_msg): s = (err_msg or "").lower() if "-4061" in s: return True if "-1106" in s and ("reduceonly" in s or "reduce only" in s): return True if "position side" in s or "positionside" in s: return True if "dual side" in s or "position mode" in s: return True return False def _filled_amount_for_tpsl(order, fallback_amount): for key in ("filled", "amount"): v = order.get(key) try: fv = float(v) if fv > 0: return fv except Exception: pass return float(fallback_amount) def _binance_trigger_order_params(): p = {} if BINANCE_TRIGGER_WORKING_TYPE: p["workingType"] = BINANCE_TRIGGER_WORKING_TYPE return p def _binance_place_tp_sl_orders(exchange_symbol, direction, position_amount, stop_loss, take_profit): """ Binance USDT-M 永续:市价开仓成交后,挂 STOP_MARKET(止损)与 TAKE_PROFIT_MARKET(止盈)。 双向持仓时带 positionSide。不显式传 reduceOnly(否则会报 -1106 Parameter 'reduceOnly' sent when not required)。 """ ensure_markets_loaded() market = exchange.market(exchange_symbol) if not market.get("swap"): raise RuntimeError("仅支持永续合约 symbol") close_side = "sell" if direction == "long" else "buy" amt = float(exchange.amount_to_precision(exchange_symbol, float(position_amount))) if amt <= 0: raise RuntimeError("止盈止损:可平数量经精度舍入后为 0") sl_px = exchange.price_to_precision(exchange_symbol, float(stop_loss)) tp_px = exchange.price_to_precision(exchange_symbol, float(take_profit)) common = dict(_binance_trigger_order_params()) if BINANCE_POSITION_MODE == "hedge": common["positionSide"] = "LONG" if direction == "long" else "SHORT" last_err = None for attempt in range(8): try: exchange.create_order( exchange_symbol, "STOP_MARKET", close_side, amt, None, dict(common, stopPrice=sl_px), ) time.sleep(0.05) exchange.create_order( exchange_symbol, "TAKE_PROFIT_MARKET", close_side, amt, None, dict(common, stopPrice=tp_px), ) return except Exception as e: last_err = e try: cancel_binance_futures_open_orders(exchange_symbol) except Exception: pass time.sleep(0.2 * (attempt + 1)) raise RuntimeError(f"Binance 未接受止盈/止损触发单:{last_err}") def ensure_markets_loaded(force=False): global MARKETS_LOADED if force or not MARKETS_LOADED: exchange.load_markets(reload=force) MARKETS_LOADED = True def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): ensure_markets_loaded() mm = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" try: exchange.set_margin_mode(mm, exchange_symbol) except Exception: pass exchange.set_leverage(leverage, exchange_symbol) side = "buy" if direction == "long" else "sell" params = build_binance_order_params(direction, reduce_only=False) order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) order.setdefault("tpsl_attached", False) if stop_loss and take_profit: try: pos_amt = _filled_amount_for_tpsl(order, amount) _binance_place_tp_sl_orders(exchange_symbol, direction, pos_amt, stop_loss, take_profit) order["tpsl_attached"] = True except RuntimeError: raise except Exception as e: raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e return order def close_exchange_order(order_row): """ 市价全平。数量优先取交易所当前持仓张数,避免仅用入库的 order_amount 导致「只平一部分 → 撤单后委托没了但仓位还在」(加仓、精度或成交与计划不一致时常见)。 """ ensure_markets_loaded() exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"]) direction = order_row["direction"] db_amt = float(order_row["order_amount"] or 0) side = "sell" if direction == "long" else "buy" last_resp = None for _ in range(3): live = get_live_position_contracts(exchange_symbol, direction) if live is not None and live > 0: raw_amt = live else: raw_amt = db_amt if raw_amt <= 0: if last_resp is not None: return last_resp raise ValueError("平仓失败:缺少有效下单数量") try: amount = float(exchange.amount_to_precision(exchange_symbol, raw_amt)) except Exception: amount = float(raw_amt) if amount <= 0: if last_resp is not None: return last_resp raise ValueError("平仓失败:数量经精度舍入后为 0") order_resp = None last_close_err = None for params in _binance_market_close_param_candidates(direction): try: order_resp = exchange.create_order(exchange_symbol, "market", side, amount, None, params) last_close_err = None break except Exception as e: last_close_err = e if _is_binance_close_param_retryable(str(e)): continue raise if order_resp is None: raise last_close_err if last_close_err else RuntimeError("平仓失败:交易所未返回结果") last_resp = order_resp live_after = get_live_position_contracts(exchange_symbol, direction) if live_after is None or live_after <= 0: return last_resp return last_resp def cancel_binance_futures_open_orders(exchange_symbol): """ 平仓后撤销该合约下剩余挂单,避免孤儿单残留。 Binance U 本位:普通挂单走 cancel_all_orders(DELETE allOpenOrders); 止盈/止损等条件单在「Algo」通道,需再调 DELETE algoOpenOrders,否则手动平仓后仍会留在「当前委托」。 """ ok, _ = ensure_exchange_live_ready() if not ok or not exchange_symbol: return ensure_markets_loaded() sym = exchange_symbol try: exchange.cancel_all_orders(sym, params={}) except Exception: pass try: market = exchange.market(sym) contract_id = market.get("id") if contract_id and hasattr(exchange, "fapiPrivateDeleteAlgoOpenOrders"): exchange.fapiPrivateDeleteAlgoOpenOrders({"symbol": contract_id}) except Exception: pass try: pending = exchange.fetch_open_orders(sym) except Exception: return for o in pending or []: oid = o.get("id") if oid is None: continue try: exchange.cancel_order(str(oid), sym) except Exception: pass def _binance_list_raw_open_orders(exchange_symbol): """普通挂单 + Algo 条件单(止盈/止损)。""" ensure_markets_loaded() market = exchange.market(exchange_symbol) contract_id = market.get("id") out = [] try: for o in exchange.fetch_open_orders(exchange_symbol) or []: item = dict(o) item["_channel"] = "regular" out.append(item) except Exception: pass try: if contract_id and hasattr(exchange, "fapiPrivateGetOpenAlgoOrders"): raw = exchange.fapiPrivateGetOpenAlgoOrders({"symbol": contract_id}) items = raw if isinstance(raw, list) else (raw.get("orders") or raw.get("data") or []) for info in items or []: if not isinstance(info, dict): continue out.append( { "id": info.get("algoId") or info.get("orderId"), "info": info, "_channel": "algo", "type": info.get("orderType") or info.get("type"), "positionSide": info.get("positionSide"), "stopPrice": info.get("triggerPrice") or info.get("stopPrice"), "amount": info.get("quantity") or info.get("origQty"), } ) except Exception: pass return out def _binance_order_type_str(order): info = order.get("info") or {} if isinstance(info, dict): for key in ("orderType", "type", "origType", "algoType"): val = info.get(key) if val: return str(val).upper() return str(order.get("type") or "").upper() def _binance_order_matches_direction(order, direction): if BINANCE_POSITION_MODE != "hedge": return True info = order.get("info") or {} ps = str(order.get("positionSide") or info.get("positionSide") or "").upper() want = "LONG" if direction == "long" else "SHORT" if ps and ps not in ("", "BOTH") and ps != want: return False return True def _binance_order_trigger_price(order): for key in ("stopPrice", "triggerPrice", "activatePrice"): try: v = float(order.get(key) or 0) if v > 0: return v except Exception: pass info = order.get("info") or {} if isinstance(info, dict): for key in ("triggerPrice", "stopPrice", "activatePrice"): try: v = float(info.get(key) or 0) if v > 0: return v except Exception: pass return None def _binance_tpsl_role_from_order(order): typ = _binance_order_type_str(order) if "TAKE_PROFIT" in typ: return "tp" if "STOP" in typ: return "sl" return None def _binance_tpsl_slot_from_order(order, exchange_symbol): trig = _binance_order_trigger_price(order) try: amt = float(order.get("amount") or order.get("remaining") or 0) except Exception: amt = None if amt is not None and amt <= 0: amt = None channel = order.get("_channel") or "regular" oid = order.get("id") if oid is None and isinstance(order.get("info"), dict): oid = order["info"].get("algoId") or order["info"].get("orderId") disp = format_price_for_symbol(exchange_symbol, trig) if trig else "-" return { "order_id": str(oid) if oid is not None else "", "channel": channel, "trigger_price": trig, "trigger_display": disp, "amount": amt, "type": _binance_order_type_str(order), } def fetch_exchange_tpsl_slots(exchange_symbol, direction): """返回 { sl: slot|None, tp: slot|None },供页面展示与单笔撤单。""" slots = {"sl": None, "tp": None} if not exchange_symbol: return slots ok, _ = ensure_exchange_live_ready() if not ok: return slots try: for order in _binance_list_raw_open_orders(exchange_symbol): if not _binance_order_matches_direction(order, direction): continue role = _binance_tpsl_role_from_order(order) if role not in ("sl", "tp") or slots[role] is not None: continue slots[role] = _binance_tpsl_slot_from_order(order, exchange_symbol) except Exception: pass return slots def cancel_binance_tpsl_slot(exchange_symbol, slot): if not slot or not exchange_symbol: return ensure_markets_loaded() market = exchange.market(exchange_symbol) contract_id = market.get("id") oid = slot.get("order_id") if not oid: return if slot.get("channel") == "algo" and contract_id and hasattr(exchange, "fapiPrivateDeleteAlgoOrder"): exchange.fapiPrivateDeleteAlgoOrder({"symbol": contract_id, "algoId": oid}) return exchange.cancel_order(str(oid), exchange_symbol) def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): sltp_mode = (sltp_mode or "price").strip().lower() if sltp_mode == "pct": sl_pct = float(data.get("sl_pct") or 0) tp_pct = float(data.get("tp_pct") or 0) if sl_pct <= 0 or tp_pct <= 0: raise ValueError("百分比止盈止损须为正数") sl_ratio = sl_pct / 100.0 tp_ratio = tp_pct / 100.0 entry = float(live_price) if direction == "short": stop_loss = entry * (1 + sl_ratio) take_profit = entry * (1 - tp_ratio) else: stop_loss = entry * (1 - sl_ratio) take_profit = entry * (1 + tp_ratio) else: stop_loss = float(data.get("sl") or data.get("stop_loss") or 0) take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0) if stop_loss <= 0 or take_profit <= 0: raise ValueError("止盈止损价格须大于 0") return stop_loss, take_profit def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): """先撤该合约全部 TP/SL,再按新价重挂(与交易所 App 一致)。""" ok, reason = ensure_exchange_live_ready() if not ok: raise RuntimeError(reason or "实盘未就绪") ex_sym = resolve_monitor_exchange_symbol(order_row) direction = order_row["direction"] cancel_binance_futures_open_orders(ex_sym) pos_amt = get_live_position_contracts(ex_sym, direction) if pos_amt is None or float(pos_amt) <= 0: raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") _binance_place_tp_sl_orders(ex_sym, direction, float(pos_amt), float(stop_loss), float(take_profit)) def extract_trade_price_from_order(order): if not order: return None for k in ("average", "avgPrice", "price"): try: v = float(order.get(k) or 0) if v > 0: return v except Exception: pass try: info = order.get("info") or {} if isinstance(info, dict): for k in ("fillPx", "avgPx", "fill_price"): v = float(info.get(k) or 0) if v > 0: return v except Exception: pass return None def is_no_position_error(err_msg): msg = (err_msg or "").lower() # 禁止匹配笼统的 reduceonly / -4061:会与参数错误、单向/双向模式不匹配混淆, # 误判后走「已无仓」同步结束,交易所仓位却仍在。 keywords = [ "no position", "position does not exist", "position not exist", "nothing to close", "pos size is 0", "position amount is 0", ] return any(k in msg for k in keywords) def get_live_position_contracts(exchange_symbol, direction): ensure_markets_loaded() try: rows = exchange.fetch_positions([exchange_symbol]) except Exception: return None total = 0.0 for p in rows: if not _position_matches_wanted_contract(exchange_symbol, p): continue if not _row_matches_monitor_direction(direction, p): continue contracts = _position_row_effective_contracts(p) if contracts <= 0: continue total += contracts return total def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): """在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。""" if not rows: return None candidates = [] for p in rows: if not _position_matches_wanted_contract(exchange_symbol, p): continue contracts = _position_row_effective_contracts(p) if contracts <= 0: continue if (not relax_hedge) and not _row_matches_monitor_direction(direction, p): continue candidates.append((contracts, p)) if not candidates and (not relax_hedge) and BINANCE_POSITION_MODE == "hedge": return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) if not candidates: return None candidates.sort(key=lambda x: x[0], reverse=True) return candidates[0][1] def _coerce_float(*values): for v in values: if v is None or v == "": continue try: return float(v) except (TypeError, ValueError): continue return None def parse_ccxt_position_metrics(position, order_leverage=None): """ 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏。 「所保证金」对齐币安合约页的初始/持仓保证金:优先 initialMargin / positionInitialMargin。 Binance 全仓下 ccxt 的 collateral 常来自 crossMargin,口径易与「名义」混淆,故不全仓优先用 collateral。 """ if not position: return None p = position info = p.get("info", {}) or {} margin_mode = str(p.get("marginMode") or info.get("marginType") or "").lower() isolated = margin_mode.startswith("isolated") or str(info.get("isolated", "")).lower() == "true" initial = _coerce_float( p.get("initialMargin"), info.get("positionInitialMargin"), info.get("initialMargin"), ) if (initial is None or initial <= 0) and isolated: initial = _coerce_float(p.get("collateral"), info.get("isolatedWallet")) if initial is None or initial <= 0: initial = _coerce_float(p.get("margin")) if initial is None or initial <= 0: initial = _coerce_float( info.get("initial_margin"), info.get("position_margin"), info.get("iso_margin"), ) notional = _coerce_float(p.get("notional"), p.get("notionalValue")) if notional is None or notional <= 0: notional = _coerce_float(info.get("value")) if notional is not None: notional = abs(notional) # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: try: lev = float(order_leverage) if lev > 0: approx = notional / lev if approx > 0: initial = approx except (TypeError, ValueError): pass unrealized = _coerce_float( p.get("unrealizedPnl"), info.get("unrealised_pnl"), info.get("unrealized_pnl"), ) mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) out = {} if initial is not None and initial > 0: out["initial_margin"] = round(initial, FUNDS_DECIMALS) if notional is not None and notional > 0: out["notional"] = round(notional, FUNDS_DECIMALS) if unrealized is not None: out["unrealized_pnl"] = round(unrealized, FUNDS_DECIMALS) if mark is not None and mark > 0: ps = p.get("symbol") try: ex_sym = _ccxt_swap_symbol_for_precision(ps or "") if ex_sym: out["mark_price"] = float(exchange.price_to_precision(ex_sym, mark)) else: out["mark_price"] = round(mark, 8) except Exception: out["mark_price"] = round(mark, 8) return out or None def get_live_position_exchange_metrics(exchange_symbol, direction): ensure_markets_loaded() if not exchange_private_api_configured() or not exchange_symbol: return None try: rows = exchange.fetch_positions() or [] except Exception: try: rows = exchange.fetch_positions([exchange_symbol]) or [] except Exception: return None p = _select_live_position_row(rows, exchange_symbol, direction) return parse_ccxt_position_metrics(p) def opened_at_str_to_ms(opened_at_str): if not opened_at_str: return None try: dt = datetime.strptime(str(opened_at_str).strip()[:19], "%Y-%m-%d %H:%M:%S") except ValueError: return None try: aware = dt.replace(tzinfo=APP_TZ) return int(aware.timestamp() * 1000) except Exception: return None def _to_ms_with_fallback(ms_value, dt_str): try: if ms_value is not None and str(ms_value).strip() != "": v = int(float(ms_value)) if v > 0: return v except Exception: pass return opened_at_str_to_ms(dt_str) def ms_to_app_local_str(ms): if ms is None: return app_now_str() try: dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") except Exception: return app_now_str() def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" try: tp = float(take_profit) sl = float(stop_loss) ex = float(exit_price) trig = float(trigger_price) except (TypeError, ValueError): return None band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12) if direction == "long": if ex >= tp - band: return "止盈" if ex <= sl + band: return "止损" else: if ex <= tp + band: return "止盈" if ex >= sl - band: return "止损" return None def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" if not (BINANCE_API_KEY and BINANCE_API_SECRET): return None ensure_markets_loaded() since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) close_side = "sell" if direction == "long" else "buy" def pick_from_trades(trades): if not trades: return None candidates = [] for t in trades: if (t.get("side") or "").lower() != close_side: continue info = t.get("info") or {} if not isinstance(info, dict): info = {} pos_side = (info.get("posSide") or t.get("posSide") or "").lower() if BINANCE_POSITION_MODE == "hedge": if pos_side in ("long", "short") and pos_side != direction: continue ts = t.get("timestamp") if ts is None: continue candidates.append(t) if not candidates: return None return max(candidates, key=lambda x: x.get("timestamp") or 0) try: trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) hit = pick_from_trades(trades) if hit is None and since_ms: trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100) hit = pick_from_trades(trades) return hit except Exception: return None def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): """ 拉取某条历史记录对应的减仓成交(用于按 id 回填)。 返回按时间排序的成交列表。 """ if not (BINANCE_API_KEY and BINANCE_API_SECRET): return [] ensure_markets_loaded() since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) close_side = "sell" if direction == "long" else "buy" closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None # 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况 if closed_ms is not None: closed_ms += 6 * 60 * 60 * 1000 candidates = [] all_side_candidates = [] try: trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) except Exception: trades = [] if not trades and since_ms: try: trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200) except Exception: trades = [] for t in trades or []: if (t.get("side") or "").lower() != close_side: continue ts = t.get("timestamp") if ts is None: continue try: ts = int(ts) except Exception: continue if since_ms and ts < since_ms: continue if closed_ms and ts > closed_ms: continue info = t.get("info") or {} if not isinstance(info, dict): info = {} pos_side = (info.get("posSide") or t.get("posSide") or "").lower() if BINANCE_POSITION_MODE == "hedge": if pos_side in ("long", "short") and pos_side != direction: continue all_side_candidates.append(t) if since_ms and ts < since_ms: continue if closed_ms and ts > closed_ms: continue candidates.append(t) candidates.sort(key=lambda x: x.get("timestamp") or 0) if candidates: return candidates # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。 all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) if not all_side_candidates: return [] if not closed_ms: return all_side_candidates[-20:] near = [] for t in all_side_candidates: ts = t.get("timestamp") if ts is None: continue try: delta = abs(int(ts) - int(closed_ms)) except Exception: continue # 放宽到前后 7 天 if delta <= 7 * 24 * 60 * 60 * 1000: near.append((delta, t)) if near: near.sort(key=lambda x: x[0]) picked = [x[1] for x in near[:20]] picked.sort(key=lambda x: x.get("timestamp") or 0) return picked return all_side_candidates[-20:] def calc_weighted_exit_price(trades): if not trades: return None total_amount = 0.0 weighted_sum = 0.0 for t in trades: try: price = float(t.get("price") or 0) amount = float(t.get("amount") or 0) except Exception: continue if price <= 0: continue if amount <= 0: amount = 1.0 weighted_sum += price * amount total_amount += amount if total_amount <= 0: return None return weighted_sum / total_amount def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): """ 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 返回 (result, pnl_amount, closed_at_str, miss_reason)。 """ direction = row["direction"] sym = row["symbol"] trigger_price = row["trigger_price"] stop_loss = row["stop_loss"] take_profit = row["take_profit"] margin_capital = row["margin_capital"] or DAILY_START_CAPITAL leverage = row["leverage"] or infer_leverage(sym) exchange_symbol = row["exchange_symbol"] or normalize_exchange_symbol(sym) trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) exit_px = None closed_at_str = app_now_str() if trade: try: exit_px = float(trade.get("price") or 0) or None except (TypeError, ValueError): exit_px = None ts = trade.get("timestamp") if ts: closed_at_str = ms_to_app_local_str(int(ts)) if exit_px is None or exit_px <= 0: p = get_price(sym) if p: guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) if guessed: pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) return ( guessed, pnl, closed_at_str, "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", ) return ( "外部平仓", 0.0, closed_at_str, "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", ) result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) if result: return ( result, pnl, closed_at_str, "按交易所成交记录同步为止盈/止损平仓", ) return ( "外部平仓", pnl, closed_at_str, "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", ) def reconcile_external_closes(conn, days=None): synced_count = 0 cutoff_ms = None if days is not None: try: d = int(days) if d > 0: cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) except Exception: cutoff_ms = None rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() for r in rows: if cutoff_ms is not None: opened_at_v = get_opened_at_value(r) opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 if opened_ms is None or opened_ms < cutoff_ms: continue exchange_symbol = r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]) live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) if live_contracts is None: continue if live_contracts > 0: continue cancel_binance_futures_open_orders(exchange_symbol) opened_at = get_opened_at_value(r) opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) session_date = r["session_date"] or get_trading_day(closed_at_dt) update_session_capital(conn, session_date, pnl_amount) insert_trade_record( conn, symbol=r["symbol"], monitor_type=order_row_monitor_type(r), key_signal_type=order_row_key_signal_type(r), direction=r["direction"], trigger_price=r["trigger_price"], stop_loss=r["stop_loss"], initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], take_profit=r["take_profit"], margin_capital=r["margin_capital"], leverage=r["leverage"], pnl_amount=pnl_amount, hold_seconds=hold_seconds, trade_style=r["trade_style"], risk_amount=r["risk_amount"], planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), result=result, miss_reason=miss_reason, opened_at=opened_at, closed_at=closed_at, ) conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day()) if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): send_wechat_msg( build_wechat_close_message( symbol=r["symbol"], direction=r["direction"], result=f"{result}(自动同步)", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=r["trigger_price"], current_price="-", stop_loss=r["stop_loss"], take_profit=r["take_profit"], close_order_id="-", extra_note=miss_reason, ) ) else: send_wechat_msg( build_wechat_close_message( symbol=r["symbol"], direction=r["direction"], result="外部平仓(自动同步)", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=r["trigger_price"], current_price="-", stop_loss=r["stop_loss"], take_profit=r["take_profit"], close_order_id="-", extra_note=miss_reason, ) ) synced_count += 1 return synced_count # 获取实时价格 def get_price(symbol): try: ensure_markets_loaded() return exchange.fetch_ticker(normalize_exchange_symbol(symbol))["last"] except: return None # 获取5分钟K线收盘价 def get_5m_close(symbol): try: ensure_markets_loaded() ohlcv = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), KLINE_TIMEFRAME, limit=1) return ohlcv[-1][4] if ohlcv else None except: return None def _safe_float(v): try: return float(v) except Exception: return None def _compute_ema(values, period=55): arr = [float(x) for x in values if x is not None] if len(arr) < period: return None k = 2.0 / (period + 1.0) ema = arr[0] for val in arr[1:]: ema = val * k + ema * (1 - k) return ema def _status_by_ema55(symbol, timeframe): try: bars = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), timeframe=timeframe, limit=80) if not bars or len(bars) < 56: return "横盘", None, None closes = [float(x[4]) for x in bars if x and len(x) >= 5] ema55 = _compute_ema(closes, 55) last_close = closes[-1] if ema55 is None or last_close <= 0: return "横盘", last_close, ema55 diff_pct = (last_close - ema55) / ema55 * 100.0 if abs(diff_pct) < 0.1: return "横盘", last_close, ema55 return ("多头" if diff_pct > 0 else "空头"), last_close, ema55 except Exception: return "横盘", None, None def _daily_volume_rank(symbol): """ 返回(symbol_rank, total_count),按 quoteVolume 降序,缺失时 fallback 到 baseVolume*last。 """ sym_norm = normalize_symbol_input(symbol) target_base = journal_coin_from_symbol(sym_norm) def _ticker_base(sym_text): s = str(sym_text or "").upper().strip() if ":" in s: s = s.split(":", 1)[0] if "/" in s: return s.split("/", 1)[0].strip() if "-" in s: return s.split("-", 1)[0].strip() if s.endswith("USDT"): return s[:-4].strip() return s now_ts = time.time() cached_ok = ( LIQUIDITY_RANK_CACHE["updated_at"] and now_ts - float(LIQUIDITY_RANK_CACHE["updated_at"]) < max(30, BALANCE_REFRESH_SECONDS) ) if not cached_ok: try: ensure_markets_loaded() tickers = exchange.fetch_tickers() scored = [] for s, t in (tickers or {}).items(): try: mk = exchange.markets.get(s) if not mk or not mk.get("swap"): continue su = str(s).upper() if "USDT" not in su: continue qv = _safe_float((t or {}).get("quoteVolume")) if qv is None: info = (t or {}).get("info") if isinstance((t or {}).get("info"), dict) else {} qv = _safe_float(info.get("volCcy24h") or info.get("vol24h")) if qv is None: bv = _safe_float((t or {}).get("baseVolume")) lp = _safe_float((t or {}).get("last")) if bv is not None and lp is not None: qv = bv * lp if qv is None or qv <= 0: continue scored.append((_ticker_base(s), float(qv))) except Exception: continue scored.sort(key=lambda x: x[1], reverse=True) ranks = {} for idx, (base, _) in enumerate(scored, 1): if base and base not in ranks: ranks[base] = idx LIQUIDITY_RANK_CACHE["ranks"] = ranks LIQUIDITY_RANK_CACHE["total"] = len(scored) LIQUIDITY_RANK_CACHE["updated_at"] = now_ts except Exception: pass ranks = LIQUIDITY_RANK_CACHE.get("ranks") or {} total = int(LIQUIDITY_RANK_CACHE.get("total") or 0) return ranks.get(target_base), total def _key_hard_checks(symbol, direction, upper, lower, monitor_type): """ 关键位门控:量能、突破幅度、第二根确认、日成交量前30。 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根。 """ out = {"ok": False} ex_sym = normalize_exchange_symbol(symbol) bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=80) or [] if len(bars) < 24: out["reason"] = "5m K线数量不足" return out closed = bars[:-1] if len(bars) >= 3 else bars min_closed = KEY_VOLUME_MA_BARS + 3 if len(closed) < min_closed: out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足" return out try: breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] confirm = closed[KEY_CONFIRM_BAR] except IndexError: out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" return out prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR] avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1) vol_break = float(breakout[5]) vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN if avg20 > 0 else False open_b = float(breakout[1]) close_b = float(breakout[4]) high_b = float(breakout[2]) low_b = float(breakout[3]) amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0 amp_ok = (amp_pct > KEY_BREAKOUT_AMP_MIN_PCT) and (amp_pct < KEY_BREAKOUT_AMP_MAX_PCT) cfm_close = float(confirm[4]) # 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿 edge = float(upper) if direction == "long" else float(lower) breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) confirm_ok_raw = (cfm_close > edge) if direction == "long" else (cfm_close < edge) # 口径收紧:未发生有效突破时,不标记幅度/二确通过,避免出现“还没到位却显示Y” amp_ok = amp_ok and breakout_ok confirm_ok = confirm_ok_raw and breakout_ok rank, total = _daily_volume_rank(symbol) rank_ok = (rank is not None) and (rank <= KEY_DAILY_VOLUME_RANK_MAX) swing4h_pct = 0.0 try: seg48 = closed[-48:] if len(closed) >= 48 else closed hh = max(float(x[2]) for x in seg48) ll = min(float(x[3]) for x in seg48) swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0 except Exception: swing4h_pct = 0.0 out.update( { "ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]), "vol_ok": vol_ok, "avg20": avg20, "vol_break": vol_break, "amp_ok": amp_ok, "amp_pct": amp_pct, "breakout_ok": breakout_ok, "breakout_close": close_b, "confirm_ok": confirm_ok, "confirm_close": cfm_close, "edge_price": edge, "rank": rank, "rank_total": total, "rank_ok": rank_ok, "breakout_high": high_b, "breakout_low": low_b, "breakout_ts": breakout[0], "confirm_ts": confirm[0], "swing4h_pct": swing4h_pct, "monitor_type": monitor_type, "direction": direction, } ) return out def calc_price_diff_pct(current_price, target_price): try: if target_price is None: return None, None t = float(target_price) if t == 0: return None, None c = float(current_price) diff = c - t pct = diff / t * 100 return round(diff, 6), round(pct, 4) except Exception: return None, None def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): """本条关键位一次性结案:写历史并从当前表删除。""" n = int(row["notification_count"] or 0) + 1 insert_key_monitor_history(conn, row, n, last_msg, close_reason) conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) def _key_hard_lines_from_checks(checks): return [ f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)", f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})", f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)", ] def _key_plan_auto_sl_tp(direction, upper, lower, checks, outside_pct): """ 计划 SL/TP:止损在突破 K 极值外侧 outside_pct%,止盈为确认收盘 ± 箱体高。 返回 (E, raw_sl, raw_tp, box_h)。 """ E = float(checks["confirm_close"]) H = abs(float(upper) - float(lower)) br_hi = float(checks["breakout_high"]) br_lo = float(checks["breakout_low"]) m = float(outside_pct) / 100.0 if direction == "long": sl_raw = br_lo * (1.0 - m) if br_lo > 0 else 0.0 tp_raw = E + H else: sl_raw = br_hi * (1.0 + m) if br_hi > 0 else 0.0 tp_raw = E - H return E, sl_raw, tp_raw, H def _market_open_for_key_monitor(conn, symbol, direction, exchange_symbol, stop_loss, take_profit, key_signal_type=None): """ 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(Binance U 本位)。 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) """ now = app_now() ok, reason = precheck_risk(conn, symbol, direction) if not ok: return False, f"风控拒绝下单:{reason}", None ok_live, reason_live = ensure_exchange_live_ready() if not ok_live: return False, reason_live, None default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) leverage = int(default_leverage) if default_leverage else 5 if leverage <= 0: leverage = 5 trading_day = get_trading_day(now) opens_today_before = conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", (trading_day,), ).fetchone()[0] session_row = ensure_session(conn, trading_day) _, trading_capital_live = get_exchange_capitals(force=True) live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() if trade_style not in ("trend", "swing"): trade_style = "trend" available_usdt = get_available_trading_usdt() live_price = get_price(symbol) if live_price is None: return False, "获取交易所实时价格失败(以损定仓需要当前价)", None try: ensure_markets_loaded() except Exception: pass lp_adj = round_price_to_exchange(exchange_symbol, live_price) if lp_adj is not None: live_price = float(lp_adj) sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) if sl_adj is not None: stop_loss = float(sl_adj) if tp_adj is not None: take_profit = float(tp_adj) risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) if risk_fraction is None: return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None risk_percent = max(0.01, float(RISK_PERCENT)) risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) if capital_base and margin_capital > capital_base: return False, "以损定仓后保证金超过当前交易资金", None if available_usdt is not None: max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS) if margin_capital > max_margin: return ( False, f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U", None, ) position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 try: amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) contract_size = get_contract_size(exchange_symbol) base_amount = round(float(amount) * contract_size, 8) order_resp = place_exchange_order( exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit, ) open_order_id = order_resp.get("id", "") tpsl_attached = bool(order_resp.get("tpsl_attached")) trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) except Exception as e: return False, friendly_exchange_error(e, available_usdt=available_usdt), None tr_adj = round_price_to_exchange(exchange_symbol, trigger_price) if tr_adj is not None: trigger_price = float(tr_adj) sl_f = round_price_to_exchange(exchange_symbol, stop_loss) if sl_f is not None: stop_loss = float(sl_f) tp_f = round_price_to_exchange(exchange_symbol, take_profit) if tp_f is not None: take_profit = float(tp_f) opened_at_bj = app_now_str() opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount if direction == "short": breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) else: breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) breakeven_enabled = 1 conn.execute( "INSERT INTO order_monitors " "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " "margin_capital, leverage, trade_style, risk_percent, risk_amount, " "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) " "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, ORDER_MONITOR_TYPE_KEY_AUTO, stored_key_signal_type(key_signal_type), ), ) new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) opens_today_after = conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", (trading_day,), ).fetchone()[0] return True, None, { "new_order_id": new_order_id, "open_order_id": open_order_id, "trigger_price": trigger_price, "planned_rr_fill": planned_rr, "risk_amount_final": risk_amount_final, "margin_capital": margin_capital, "leverage": leverage, "amount": amount, "base_amount": base_amount, "notional_value": notional_value, "position_ratio": position_ratio, "tpsl_attached": tpsl_attached, "opens_today_before": opens_today_before, "opens_today_after": opens_today_after, "trading_day": trading_day, "risk_percent": risk_percent, "breakeven_rr_trigger": breakeven_rr_trigger, "breakeven_price": breakeven_price, "capital_base_at_open": capital_base, } def _sqlite_row_val(row, key, default=None): try: v = row[key] return default if v is None else v except (KeyError, IndexError, TypeError): return default def get_symbol_mark_price(symbol): """斐波失效判定用标记价。""" ex_sym = normalize_exchange_symbol(symbol) try: ensure_markets_loaded() ticker = exchange.fetch_ticker(ex_sym) m = _coerce_float(ticker.get("mark"), ticker.get("last")) if m is None: info = ticker.get("info") or {} m = _coerce_float(info.get("mark_price"), info.get("last")) if m is not None and m > 0: return float(m) except Exception: pass p = get_price(symbol) return float(p) if p is not None else None def cancel_fib_limit_order(exchange_symbol, order_id): """仅撤销本条斐波限价单,不用 cancel_all。""" if not order_id: return False ok_live, _ = ensure_exchange_live_ready() if not ok_live: return False ensure_markets_loaded() oid = str(order_id) try: exchange.cancel_order(oid, exchange_symbol) return True except Exception: pass try: for o in exchange.fetch_open_orders(exchange_symbol) or []: if str(o.get("id")) == oid: exchange.cancel_order(oid, exchange_symbol) return True except Exception: pass return False def fib_limit_order_status(exchange_symbol, order_id): if not order_id: return "missing" ensure_markets_loaded() oid = str(order_id) try: o = exchange.fetch_order(oid, exchange_symbol) st = (o.get("status") or "").lower() if st in ("closed", "filled"): filled = float(o.get("filled") or 0) if filled > 0 or st == "filled": return "filled" if st in ("canceled", "cancelled", "expired", "rejected"): return "canceled" if st in ("open", "new", "partially_filled"): return "open" except Exception: pass try: for o in exchange.fetch_open_orders(exchange_symbol) or []: if str(o.get("id")) == oid: return "open" except Exception: pass return "unknown" def place_fib_limit_order(exchange_symbol, direction, amount, leverage, limit_price): ensure_markets_loaded() mm = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" try: exchange.set_margin_mode(mm, exchange_symbol) except Exception: pass exchange.set_leverage(leverage, exchange_symbol) side = "buy" if direction == "long" else "sell" price = round_price_to_exchange(exchange_symbol, float(limit_price)) if price is None or price <= 0: raise ValueError("挂单价无效") params = build_binance_order_params(direction, reduce_only=False) return exchange.create_order(exchange_symbol, "limit", side, amount, price, params) def _fib_key_exists_for_symbol(conn, symbol): ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES)) row = conn.execute( f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})", (symbol, *tuple(FIB_KEY_MONITOR_TYPES)), ).fetchone() return row is not None def _fib_plan_for_row(row): typ = (row["monitor_type"] or "").strip() ratio = fib_ratio_from_type(typ) if ratio is None: return None return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio) def _cancel_fib_monitor_limit(row): ex_sym = normalize_exchange_symbol(row["symbol"]) oid = _sqlite_row_val(row, "fib_limit_order_id") if oid: cancel_fib_limit_order(ex_sym, oid) def _fib_has_live_position(exchange_symbol, direction): live = get_live_position_contracts(exchange_symbol, direction) return live is not None and float(live) > 0 def _insert_order_monitor_from_fib_fill( conn, row, trigger_price, stop_loss, take_profit, amount, leverage, margin_capital, notional_value, position_ratio, base_amount, exchange_order_id, tpsl_attached, ): symbol = row["symbol"] direction = (row["direction"] or "long").lower() exchange_symbol = normalize_exchange_symbol(symbol) typ = (row["monitor_type"] or "").strip() now = app_now() trading_day = get_trading_day(now) trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() if trade_style not in ("trend", "swing"): trade_style = "trend" risk_percent = max(0.01, float(RISK_PERCENT)) risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) if risk_amount_final is None: risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4) breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 if direction == "short": breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) else: breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) opened_at_bj = app_now_str() opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) conn.execute( "INSERT INTO order_monitors " "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " "margin_capital, leverage, trade_style, risk_percent, risk_amount, " "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) " "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, 1, notional_value, position_ratio, base_amount, amount, exchange_order_id or "", opened_at_bj, opened_at_ms, trading_day, ORDER_MONITOR_TYPE_KEY_AUTO, stored_key_signal_type(typ), ), ) new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) return new_order_id def _finalize_fib_key_fill(conn, row): symbol = row["symbol"] direction = (row["direction"] or "long").lower() typ = (row["monitor_type"] or "").strip() ex_sym = normalize_exchange_symbol(symbol) plan = _fib_plan_for_row(row) if not plan: _finalize_key_monitor_one_shot(conn, row, "斐波计划无效", "fib_plan_invalid") return entry_plan, sl_plan, tp_plan = plan sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan) tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan) sl_adj = round_price_to_exchange(ex_sym, sl) tp_adj = round_price_to_exchange(ex_sym, tp) if sl_adj is not None: sl = float(sl_adj) if tp_adj is not None: tp = float(tp_adj) amount = float(_sqlite_row_val(row, "fib_order_amount") or 0) leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5) margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0) oid = _sqlite_row_val(row, "fib_limit_order_id") entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan) trigger_price = entry_px if oid: try: o = exchange.fetch_order(str(oid), ex_sym) trigger_price = resolve_order_entry_price(o, ex_sym, entry_px) except Exception: pass tr_adj = round_price_to_exchange(ex_sym, trigger_price) if tr_adj is not None: trigger_price = float(tr_adj) if amount <= 0: live_amt = get_live_position_contracts(ex_sym, direction) amount = float(live_amt or 0) if amount <= 0: send_wechat_msg( f"# ❌ {symbol} 斐波成交后处理失败\n" f"**账户:{_wechat_account_label()}**\n" f"- 无法取得持仓/下单数量,未挂 TP/SL\n" ) return ok, reason = precheck_risk(conn, symbol, direction) if not ok: send_wechat_msg( f"# ❌ {symbol} 斐波成交后风控拒绝\n" f"**账户:{_wechat_account_label()}**\n" f"- 类型:{typ}\n" f"- 原因:{reason}\n" f"- 请手动处理仓位与挂单\n" ) return tpsl_attached = False try: _binance_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) tpsl_attached = True except Exception as e: send_wechat_msg( f"# ❌ {symbol} 斐波成交后挂 TP/SL 失败\n" f"**账户:{_wechat_account_label()}**\n" f"- 错误:{friendly_exchange_error(e)}\n" f"- 请手动补挂止盈止损\n" ) return contract_size = get_contract_size(ex_sym) base_amount = round(float(amount) * contract_size, 8) notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0 session_row = ensure_session(conn, get_trading_day(app_now())) capital_base = float(session_row["current_capital"] or 0) position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0 planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp) new_order_id = _insert_order_monitor_from_fib_fill( conn, row, trigger_price, sl, tp, amount, leverage, margin_capital, notional_value, position_ratio, base_amount, oid, tpsl_attached, ) rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" succ = ( f"# ✅ {symbol} 斐波限价成交\n" f"**账户:{_wechat_account_label()}**\n" f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" f"- 订单 ID:**{new_order_id}**\n" f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n" f"- 止损:{format_wechat_scalar_2dp(sl)}|止盈:{format_price_for_symbol(symbol, tp)}\n" f"- 计划 RR:{rr_txt}:1\n" f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" ) send_wechat_msg(succ) _finalize_key_monitor_one_shot(conn, row, succ, "fib_filled") def check_fib_key_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() for r in rows: typ = (r["monitor_type"] or "").strip() if not is_fib_key_monitor_type(typ): continue symbol = r["symbol"] direction = (r["direction"] or "long").lower() ex_sym = normalize_exchange_symbol(symbol) up, low = float(r["upper"]), float(r["lower"]) oid = _sqlite_row_val(r, "fib_limit_order_id") mark = get_symbol_mark_price(symbol) if mark is None: continue status = fib_limit_order_status(ex_sym, oid) if oid else "missing" if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): _finalize_fib_key_fill(conn, r) continue if status == "open": if fib_invalidate_by_mark(direction, mark, up, low): _cancel_fib_monitor_limit(r) msg = ( f"# ⚠️ {symbol} 斐波监控失效\n" f"**账户:{_wechat_account_label()}**\n" f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n" ) send_wechat_msg(msg) _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") continue if status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): msg = ( f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" f"**账户:{_wechat_account_label()}**\n" f"- 标记价触达止盈侧,本条已结案\n" ) send_wechat_msg(msg) _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") conn.commit() conn.close() def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px): if _fib_key_exists_for_symbol(conn, symbol): return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" ratio = fib_ratio_from_type(mt) plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio) if not plan: return False, "斐波上下沿无效(需上沿 H > 下沿 L)" entry, sl, tp = plan ex_sym = normalize_exchange_symbol(symbol) entry = round_price_to_exchange(ex_sym, entry) sl = round_price_to_exchange(ex_sym, sl) tp = round_price_to_exchange(ex_sym, tp) if entry is None or sl is None or tp is None: return False, "斐波价位经交易所精度舍入后无效" entry, sl, tp = float(entry), float(sl), float(tp) planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp) if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)" ok, reason = precheck_risk(conn, symbol, direction_sel) if not ok: return False, reason ok_live, reason_live = ensure_exchange_live_ready() if not ok_live: return False, reason_live now = app_now() trading_day = get_trading_day(now) session_row = ensure_session(conn, trading_day) _, trading_capital_live = get_exchange_capitals(force=True) live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) leverage = int(default_leverage) if default_leverage else 5 if leverage <= 0: leverage = 5 available_usdt = get_available_trading_usdt() risk_fraction = calc_risk_fraction(direction_sel, entry, sl) if risk_fraction is None: return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" risk_percent = max(0.01, float(RISK_PERCENT)) risk_amount = round(capital_base * risk_percent / 100.0, 4) notional_value = round(risk_amount / risk_fraction, 4) margin_capital = round(notional_value / leverage, 4) if capital_base and margin_capital > capital_base: return False, "以损定仓后保证金超过当前交易资金" if available_usdt is not None: max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) if margin_capital > max_margin: return ( False, f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", ) try: amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) oid = str(order_resp.get("id") or "") if not oid: return False, "交易所未返回限价单 ID" except Exception as e: return False, friendly_exchange_error(e, available_usdt=available_usdt) conn.execute( "INSERT INTO key_monitors " "(symbol, monitor_type, direction, upper, lower, " "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " "fib_order_amount, fib_margin_capital, fib_leverage) " "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, mt, direction_sel, upper_px, lower_px, oid, entry, sl, tp, float(amount), margin_capital, leverage, ), ) return True, None # 关键位监控(箱体/收敛可自动开仓;阻力/支撑位仅单次提醒结案) def check_key_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() for r in rows: sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] typ = (typ_raw or "").strip() if is_fib_key_monitor_type(typ): continue direction = (r["direction"] or "long").lower() try: checks = _key_hard_checks(sym, direction, up, low, typ) except Exception: checks = {"ok": False} if not checks.get("ok"): continue btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h") coin4h_status, _, _ = _status_by_ema55(sym, "4h") risk_tip = None if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"): risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。" key_price = float(low) if direction == "long" else float(up) hard_lines = _key_hard_lines_from_checks(checks) trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() alert_only = typ in KEY_MONITOR_ALERT_ONLY_TYPES or ( typ not in KEY_MONITOR_AUTO_TYPES and typ not in KEY_MONITOR_ALERT_ONLY_TYPES ) if alert_only: op_lines = [ "- 本条为关键阻力/支撑或非标类型:**仅单次推送**,不进行自动开仓。", "- 本条关键位将在推送后记入历史并从监控列表移除。", ] msg = build_wechat_key_monitor_message( symbol=sym, direction=direction, monitor_type=typ, trigger_time=trigger_time, key_price=key_price, confirm_close=checks["confirm_close"], hard_lines=hard_lines, btc8h_status=btc8h_status, coin4h_status=coin4h_status, swing4h_pct=checks.get("swing4h_pct") or 0.0, op_lines=op_lines, risk_tip=risk_tip, ) send_wechat_msg(msg) _finalize_key_monitor_one_shot(conn, r, msg, "key_level_alert_only") continue E, sl_raw, tp_raw, box_h = _key_plan_auto_sl_tp( direction, up, low, checks, KEY_STOP_OUTSIDE_BREAKOUT_PCT, ) exchange_symbol = normalize_exchange_symbol(sym) try: ensure_markets_loaded() except Exception: pass sl_px = round_price_to_exchange(exchange_symbol, sl_raw) tp_px = round_price_to_exchange(exchange_symbol, tp_raw) if sl_px is not None: sl_raw = float(sl_px) if tp_px is not None: tp_raw = float(tp_px) planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR if not rr_ok: fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)" rr_msg = ( f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n" f"**账户:{_wechat_account_label()}**\n" f"- 类型:{typ}\n" f"- 方向:**{_wechat_direction_text(direction)}**\n" f"- 触发时间:`{trigger_time}`\n" f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n" f"- 计划止损(突破K外侧 {KEY_STOP_OUTSIDE_BREAKOUT_PCT}%):`{format_wechat_scalar_2dp(sl_raw)}`\n" f"- 计划止盈(E±1×H):`{format_price_for_symbol(sym, tp_raw)}`\n" f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n" "---\n" "### 硬条件\n" + "\n".join(f"- {x}" for x in hard_lines) ) if risk_tip: rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" send_wechat_msg(rr_msg) _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") continue key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None ok_trade, trade_err, det = _market_open_for_key_monitor( conn, sym, direction, exchange_symbol, sl_raw, tp_raw, key_signal_type=key_sig, ) planned_rr_txt = ( format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" ) if not ok_trade: fail_msg = ( f"# ❌ {sym} 关键位自动单失败\n" f"**账户:{_wechat_account_label()}**\n" f"- 类型:{typ}\n" f"- 方向:**{_wechat_direction_text(direction)}**\n" f"- 触发时间:`{trigger_time}`\n" f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n" f"- **失败原因:{trade_err}**\n" "---\n" "### 硬条件\n" + "\n".join(f"- {x}" for x in hard_lines) ) if risk_tip: fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" send_wechat_msg(fail_msg) _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed") continue tpsl_txt = ( "已在交易所挂止盈/止损触发单(Binance U 本位条件单)" if det.get("tpsl_attached") else "⚠️ 条件单挂接状态异常或未挂上" ) rr_fill = det.get("planned_rr_fill") rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-" succ_msg_lines = [ f"# ✅ {sym} 关键位自动开仓成功", f"**账户:{_wechat_account_label()}**", f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", f"- 页面订单 ID:**{det['new_order_id']}**", f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", f"- 类型:{typ}|{_wechat_direction_text(direction)}", f"- 触发时间:`{trigger_time}`", f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", f"- **计划 RR(E):{planned_rr_txt}:1**", f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", f"- **成交价侧计划 RR:**{rr_fill_txt}:1", f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", f"- **{tpsl_txt}**", f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", f"- 当日开仓次数:**{det.get('opens_today_after')}** / {DAILY_OPEN_ALERT_THRESHOLD}(提醒阈值)", ] succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines]) if risk_tip: succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"]) succ_msg = "\n".join(succ_msg_lines) send_wechat_msg(succ_msg) _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened") if det.get("opens_today_before", 0) < DAILY_OPEN_ALERT_THRESHOLD <= det.get("opens_today_after", 0): advice = ai_short_advice( f"用户在北京时间交易日 {det['trading_day']} 已累计开仓 {det['opens_today_after']} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。" f"用户自述“上头了”。请给克制提醒。" ) if advice: send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}") conn.commit() conn.close() # 止盈止损监控(已修复:严格区分多空,无默认做多) def check_order_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() for r in rows: pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] margin_capital = r["margin_capital"] or DAILY_START_CAPITAL leverage = r["leverage"] or infer_leverage(sym) session_date = r["session_date"] or get_trading_day() p = get_price(sym) if not p: continue # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) risk_amount = float(r["risk_amount"] or 0) breakeven_armed = int(r["breakeven_armed"] or 0) trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) step_r = 1.0 if step_r <= 0 else step_r breakeven_enabled = True try: if "breakeven_enabled" in r.keys(): breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 except Exception: breakeven_enabled = True if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) now_rr = now_pnl / risk_amount if now_rr >= trigger_rr: steps = int((now_rr - trigger_rr) // step_r) locked_r = max(0.0, steps * step_r) notional = float(margin_capital or 0) * float(leverage or 0) risk_frac = (risk_amount / notional) if notional > 0 else None if risk_frac and risk_frac > 0: new_sl = calc_breakeven_stop( direction, trigger_price, risk_frac, locked_r=locked_r, offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT), ) if new_sl is not None: should_move = (direction == "short" and new_sl < float(stop_loss)) or ( direction == "long" and new_sl > float(stop_loss) ) if should_move: conn.execute( "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", (new_sl, new_sl, pid), ) stop_loss = new_sl arm_txt = "保本止盈" if not breakeven_armed else "移动止盈" send_wechat_msg( build_wechat_breakeven_message( sym, direction, arm_txt, now_rr, locked_r, new_sl, ) ) res = None # 做多 if direction == "long": if p >= take_profit: res = "止盈" elif p <= stop_loss: res = "止损" # 做空 elif direction == "short": if p <= take_profit: res = "止盈" elif p >= stop_loss: res = "止损" if res: now = app_now() opened_at = get_opened_at_value(r) opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None) closed_at = now.strftime("%Y-%m-%d %H:%M:%S") hold_seconds = calc_hold_seconds(opened_at, now) pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) if res == "止损" and float(pnl_amount or 0) > 0: res = "移动止盈" if breakeven_armed else "保本止盈" else: res = normalize_result_with_pnl(res, pnl_amount) close_order_id = "" try: close_resp = close_exchange_order(r) close_order_id = close_resp.get("id", "") # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细。 exit_p = extract_trade_price_from_order(close_resp) if exit_p and exit_p > 0: pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) if guessed_res: res = normalize_result_with_pnl(guessed_res, pnl_amount) else: res = normalize_result_with_pnl(res, pnl_amount) else: ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) tr = fetch_latest_closing_fill( ex_sym, direction, opened_at, opened_at_ms=opened_at_ms, ) if tr and tr.get("price"): try: exit_p = float(tr["price"]) pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) if guessed_res: if guessed_res == "止损" and float(pnl_amount or 0) > 0: res = "移动止盈" if breakeven_armed else "保本止盈" else: res = normalize_result_with_pnl(guessed_res, pnl_amount) else: res = normalize_result_with_pnl(res, pnl_amount) except (TypeError, ValueError): pass ts = tr.get("timestamp") if ts: closed_at = ms_to_app_local_str(int(ts)) hold_seconds = calc_hold_seconds( opened_at, parse_dt_for_trading_day(closed_at) or now ) except Exception as e: if is_no_position_error(str(e)): ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) cancel_binance_futures_open_orders(ex_sym) tr = fetch_latest_closing_fill( ex_sym, direction, opened_at, opened_at_ms=opened_at_ms, ) if tr and tr.get("price"): try: exit_p = float(tr["price"]) pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判。 guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) if guessed_res: if guessed_res == "止损" and float(pnl_amount or 0) > 0: res = "移动止盈" if breakeven_armed else "保本止盈" else: res = normalize_result_with_pnl(guessed_res, pnl_amount) else: res = normalize_result_with_pnl(res, pnl_amount) except (TypeError, ValueError): pass ts = tr.get("timestamp") if ts: closed_at = ms_to_app_local_str(int(ts)) hold_seconds = calc_hold_seconds( opened_at, parse_dt_for_trading_day(closed_at) or now ) insert_trade_record( conn, symbol=sym, monitor_type=order_row_monitor_type(r), key_signal_type=order_row_key_signal_type(r), direction=direction, trigger_price=trigger_price, stop_loss=stop_loss, initial_stop_loss=r["initial_stop_loss"] or stop_loss, take_profit=take_profit, margin_capital=margin_capital, leverage=leverage, pnl_amount=pnl_amount, hold_seconds=hold_seconds, trade_style=r["trade_style"], risk_amount=r["risk_amount"], planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), result=res, miss_reason="触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", opened_at=opened_at, closed_at=closed_at, ) session_capital = update_session_capital(conn, session_date, pnl_amount) send_wechat_msg( build_wechat_close_message( symbol=sym, direction=direction, result=f"{res}(交易所已先行平仓)", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=trigger_price, current_price=p, stop_loss=stop_loss, take_profit=take_profit, close_order_id="-", extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", session_capital_fallback=session_capital, ) ) conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) conn.commit() continue conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (pid,)) conn.commit() send_wechat_msg( build_wechat_monitor_error_message( symbol=sym, direction=direction, scene=f"触发{res}后交易所平仓失败", error_text=str(e), ) ) continue cancel_binance_futures_open_orders(r["exchange_symbol"] or normalize_exchange_symbol(sym)) session_capital = update_session_capital(conn, session_date, pnl_amount) send_wechat_msg( build_wechat_close_message( symbol=sym, direction=direction, result=res, pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=trigger_price, current_price=p, stop_loss=stop_loss, take_profit=take_profit, close_order_id=close_order_id or "-", session_capital_fallback=session_capital, ) ) insert_trade_record( conn, symbol=sym, monitor_type=order_row_monitor_type(r), key_signal_type=order_row_key_signal_type(r), direction=direction, trigger_price=trigger_price, stop_loss=stop_loss, initial_stop_loss=r["initial_stop_loss"] or stop_loss, take_profit=take_profit, margin_capital=margin_capital, leverage=leverage, pnl_amount=pnl_amount, hold_seconds=hold_seconds, trade_style=r["trade_style"], risk_amount=r["risk_amount"], planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), result=res, opened_at=opened_at, closed_at=closed_at, ) conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid)) clear_key_sizing_snapshot_if_flat(conn, get_trading_day()) conn.commit() conn.close() def force_close_before_reset(): if not FORCE_CLOSE_ENABLED: return now = app_now() # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) if now.hour != FORCE_CLOSE_BJ_HOUR: return conn = get_db() rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() for r in rows: p = get_price(r["symbol"]) if not p: continue direction = r["direction"] trigger_price = r["trigger_price"] margin_capital = r["margin_capital"] or DAILY_START_CAPITAL leverage = r["leverage"] or infer_leverage(r["symbol"]) session_date = r["session_date"] or get_trading_day(now) opened_at = get_opened_at_value(r) closed_at = now.strftime("%Y-%m-%d %H:%M:%S") hold_seconds = calc_hold_seconds(opened_at, now) pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) try: close_resp = close_exchange_order(r) close_order_id = close_resp.get("id", "") cancel_binance_futures_open_orders(r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"])) except Exception as e: conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) conn.commit() send_wechat_msg( build_wechat_monitor_error_message( symbol=r["symbol"], direction=direction, scene="强制清仓失败", error_text=str(e), ) ) continue session_capital = update_session_capital(conn, session_date, pnl_amount) insert_trade_record( conn, symbol=r["symbol"], monitor_type=order_row_monitor_type(r), key_signal_type=order_row_key_signal_type(r), direction=direction, trigger_price=trigger_price, stop_loss=r["stop_loss"], initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], take_profit=r["take_profit"], margin_capital=margin_capital, leverage=leverage, pnl_amount=pnl_amount, hold_seconds=hold_seconds, trade_style=r["trade_style"], risk_amount=r["risk_amount"], planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), result="强制清仓", miss_reason=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", opened_at=opened_at, closed_at=closed_at, ) conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"])) send_wechat_msg( build_wechat_close_message( symbol=r["symbol"], direction=direction, result="强制清仓", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=trigger_price, current_price=p, stop_loss=r["stop_loss"], take_profit=r["take_profit"], close_order_id=close_order_id or "-", extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", session_capital_fallback=session_capital, ) ) conn.commit() conn.close() # 后台线程 def background_task(): while True: try: auto_transfer_once_per_day() conn = get_db() reconcile_external_closes(conn) conn.commit() conn.close() force_close_before_reset() check_fib_key_monitors() check_key_monitors() check_order_monitors() except: pass time.sleep(MONITOR_POLL_SECONDS) # ====================== 登录路由 ====================== @app.route("/login", methods=["GET", "POST"]) def login(): if AUTH_DISABLED: session["logged_in"] = True return redirect("/") if request.method == "POST": username = request.form.get("username") password = request.form.get("password") if username == USERNAME and password == PASSWORD: session["logged_in"] = True return redirect("/") else: flash("账号或密码错误") return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME) @app.route("/logout") def logout(): session.clear() return redirect("/" if AUTH_DISABLED else "/login") # 登录校验装饰器 def login_required(f): @wraps(f) def decorated(*args, **kwargs): if AUTH_DISABLED: return f(*args, **kwargs) if not session.get("logged_in"): return redirect("/login") return f(*args, **kwargs) return decorated @app.route("/sync_positions") @login_required def sync_positions(): days_raw = (request.args.get("days") or "").strip() sync_days = None if days_raw: try: sync_days = max(1, min(365, int(days_raw))) except Exception: sync_days = None conn = get_db() synced = reconcile_external_closes(conn, days=sync_days) conn.commit() conn.close() if sync_days is not None: flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") else: flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") return redirect("/") @app.route("/api/sync_positions", methods=["POST"]) @login_required def api_sync_positions(): payload = request.get_json(silent=True) or {} days_raw = str(payload.get("days", "")).strip() if not days_raw: return jsonify({"ok": False, "msg": "请填写天数"}), 400 try: days = int(days_raw) except Exception: return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 if days < 1 or days > 365: return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 conn = get_db() synced = reconcile_external_closes(conn, days=days) conn.commit() conn.close() return jsonify({"ok": True, "days": days, "synced": int(synced)}) def _coerce_ts_ms(val): if val is None or val == "": return None try: v = float(val) except (TypeError, ValueError): return None if v > 1e12: return int(v) if v > 1e9: return int(v * 1000.0) return int(v * 1000.0) def _unified_symbol_for_match(symbol_str): s = (symbol_str or "").strip().upper() if not s: return "" if ":" in s: s = s.split(":")[0] if "_" in s and "/" not in s: s = s.replace("_", "/") if s.endswith("USDT") and "/" not in s and len(s) > 4: s = f"{s[:-4]}/USDT" return s def exchange_position_sync_since_ms(): s = EXCHANGE_POSITION_SYNC_FROM_BJ if s: for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)): try: chunk = s[:ln] if len(s) >= ln else s[:10] dt = datetime.strptime(chunk, fmt) aware = dt.replace(tzinfo=APP_TZ) return int(aware.timestamp() * 1000) except Exception: continue dt0 = app_now() - timedelta(days=90) try: aware0 = datetime(dt0.year, dt0.month, dt0.day, 0, 0, 0, tzinfo=APP_TZ) except Exception: aware0 = datetime.now(APP_TZ) return int(aware0.timestamp() * 1000) def _fetch_binance_income_entries(exchange_symbol, start_ms, end_ms): if not hasattr(exchange, "fapiPrivateGetIncome"): return [] ensure_markets_loaded() market = exchange.market(exchange_symbol) contract_id = market.get("id") if not contract_id: return [] out = [] cursor = int(start_ms) end_ms = int(end_ms) for _ in range(20): try: batch = exchange.fapiPrivateGetIncome( {"symbol": contract_id, "startTime": cursor, "endTime": end_ms, "limit": 1000} ) except Exception: break if not batch: break out.extend(batch) if len(batch) < 1000: break last_t = _coerce_ts_ms(batch[-1].get("time")) if last_t is None or last_t >= end_ms: break cursor = last_t + 1 return out def fetch_binance_net_pnl_for_trade(exchange_symbol, direction, open_ms, close_ms): if open_ms is None or close_ms is None or close_ms < open_ms: return None, None, None, None buffer_ms = 5 * 60 * 1000 entries = _fetch_binance_income_entries( exchange_symbol, max(0, int(open_ms) - buffer_ms), int(close_ms) + buffer_ms ) if not entries: return None, None, None, None net = 0.0 first_t = None last_t = None for e in entries: it = (e.get("incomeType") or e.get("income_type") or "").strip() if it not in BINANCE_NET_INCOME_TYPES: continue try: net += float(e.get("income") or 0) except (TypeError, ValueError): pass t = _coerce_ts_ms(e.get("time")) if t: first_t = t if first_t is None else min(first_t, t) last_t = t if last_t is None else max(last_t, t) if first_t is None: return None, None, None, None net = round(net, FUNDS_DECIMALS) ensure_markets_loaded() market = exchange.market(exchange_symbol) cid = market.get("id") or exchange_symbol sync_key = f"income|{cid}|{direction}|{open_ms}|{close_ms}|{net}" eo = ms_to_app_local_str(first_t) ec = ms_to_app_local_str(last_t) return net, sync_key, eo, ec def sync_trade_records_from_exchange(conn): """为未同步的 trade_records 回填交易所口径净盈亏(Binance:income 流水汇总)。""" global _LAST_EXCHANGE_PNL_SYNC_AT if not exchange_private_api_configured(): return now = time.time() if now - _LAST_EXCHANGE_PNL_SYNC_AT < 25.0: return candidates = conn.execute( """ SELECT id, symbol, direction, opened_at, opened_at_ms, closed_at, closed_at_ms FROM trade_records WHERE (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '') ORDER BY id DESC LIMIT 120 """ ).fetchall() if not candidates: _LAST_EXCHANGE_PNL_SYNC_AT = now return for tr in candidates: direction = (tr["direction"] or "long").strip().lower() open_ms = _to_ms_with_fallback( tr["opened_at_ms"] if "opened_at_ms" in tr.keys() else None, tr["opened_at"] ) close_ms = _to_ms_with_fallback( tr["closed_at_ms"] if "closed_at_ms" in tr.keys() else None, tr["closed_at"] ) if open_ms is None or close_ms is None: continue try: ex_sym = normalize_exchange_symbol(tr["symbol"]) except Exception: continue net, sync_key, eo, ec = fetch_binance_net_pnl_for_trade(ex_sym, direction, open_ms, close_ms) if net is None or not sync_key: continue conn.execute( """ UPDATE trade_records SET exchange_realized_pnl = ?, exchange_opened_at = ?, exchange_closed_at = ?, exchange_sync_key = ? WHERE id = ? """, (float(net), eo, ec, sync_key, int(tr["id"])), ) _LAST_EXCHANGE_PNL_SYNC_AT = now try: conn.commit() except Exception: pass # ====================== 主页面 ====================== def render_main_page(page="trade"): now = app_now() trading_day = get_trading_day(now) conn = get_db() session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) funding_capital, trading_capital = get_exchange_capitals() # 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。 funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) recommended_capital = get_recommended_capital(current_capital) key_list = conn.execute("SELECT * FROM key_monitors").fetchall() key_history = conn.execute("SELECT * FROM key_monitor_history 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 exchange_private_api_configured(): try: sync_trade_records_from_exchange(conn) except Exception: pass raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC").fetchall() records = [to_effective_trade_dict(r) for r in raw_records] total = len(records) miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") win = sum(1 for r in records if (r.get("effective_result") or "") in ("止盈", "保本止盈", "移动止盈")) occupied_miss_total = sum( 1 for r in records if (r.get("effective_result") or "") == "错过" and ("持仓占用" in str(r.get("effective_miss_reason") or "")) ) rate = round(win/total*100,2) if total else 0 active_count = len(order_list) can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS key_gate_rule_text = ( f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|" f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|" f"斐波:添加后立即挂限价 @ E,失效按标记价触达 H/L(未成交撤单)" ) conn.close() return render_template( "index.html", page=page, key=key_list, key_history=key_history, stats_bundle=stats_bundle, order=order_list, record=records, total=total, miss_count=miss_count, rate=rate, trading_day=trading_day, funding_usdt=funding_usdt, daily_start_capital=DAILY_START_CAPITAL, current_capital=current_capital, recommended_capital=recommended_capital, btc_leverage=BTC_LEVERAGE, alt_leverage=ALT_LEVERAGE, reset_hour=TRADING_DAY_RESET_HOUR, balance_refresh_seconds=BALANCE_REFRESH_SECONDS, auto_transfer_enabled=AUTO_TRANSFER_ENABLED, auto_transfer_amount=AUTO_TRANSFER_AMOUNT, auto_transfer_from=AUTO_TRANSFER_FROM, auto_transfer_to=AUTO_TRANSFER_TO, auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, price_refresh_seconds=PRICE_REFRESH_SECONDS, active_count=active_count, can_trade=can_trade, focus_key_id=(key_list[0]["id"] if key_list else None), focus_order_id=(order_list[0]["id"] if order_list else None), data_export_version=2, key_alert_max_times=KEY_ALERT_MAX_TIMES, risk_percent=RISK_PERCENT, breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, occupied_miss_total=occupied_miss_total, price_fmt=format_price_for_symbol, funds_fmt=format_funds_u, entry_reason_options=list(ENTRY_REASON_OPTIONS), entry_reason_other_value=ENTRY_REASON_OTHER, exchange_display=EXCHANGE_DISPLAY_NAME, max_active_positions=MAX_ACTIVE_POSITIONS, manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, key_gate_rule_text=key_gate_rule_text, kline_timeframe=KLINE_TIMEFRAME, ) @app.route("/") @login_required def index(): return redirect("/trade") @app.route("/key_monitor") @login_required def key_monitor_page(): return render_main_page("key_monitor") @app.route("/trade") @login_required def trade_page(): return render_main_page("trade") @app.route("/records") @login_required def records_page(): return render_main_page("records") @app.route("/stats") @login_required def stats_page(): return render_main_page("stats") @app.route("/api/account_snapshot") @login_required def api_account_snapshot(): now = app_now() trading_day = get_trading_day(now) conn = get_db() session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) funding_capital, trading_capital = get_exchange_capitals(force=True) funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) recommended_capital = get_recommended_capital(current_capital) active_count = get_active_position_count(conn) conn.close() can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS available_trading_usdt = get_available_trading_usdt() return jsonify({ "funding_usdt": funding_usdt, "current_capital": current_capital, "available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None, "recommended_capital": recommended_capital, "active_count": active_count, "max_active_positions": MAX_ACTIVE_POSITIONS, "can_trade": can_trade, "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, "trading_day": trading_day }) @app.route("/api/price_snapshot") @login_required def api_price_snapshot(): conn = get_db() key_rows = conn.execute( "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id FROM key_monitors" ).fetchall() order_rows = conn.execute( "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'" ).fetchall() conn.close() symbol_set = set() for r in key_rows: symbol_set.add(r["symbol"]) for r in order_rows: symbol_set.add(r["symbol"]) prices = {} for s in symbol_set: p = get_price(s) if p is not None: prices[s] = float(p) all_swap_positions = [] if exchange_private_api_configured(): try: ensure_markets_loaded() all_swap_positions = exchange.fetch_positions() or [] except Exception: all_swap_positions = [] key_prices = [] for r in key_rows: is_fib = is_fib_key_monitor_type(r["monitor_type"]) if is_fib: price = get_symbol_mark_price(r["symbol"]) else: price = prices.get(r["symbol"]) if price is None: continue upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) gate = None gate_summary = "-" gate_metrics = "" fib_gate_ok = True if is_fib: direction = (r["direction"] or "long").lower() inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) fib_gate_ok = not inval entry = _sqlite_row_val(r, "fib_entry_price") entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}" if _sqlite_row_val(r, "fib_limit_order_id"): gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}" else: try: gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) except Exception: gate = None if gate: rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}" gate_summary = ( f"量:{'Y' if gate.get('vol_ok') else 'N'} " f"破:{'Y' if gate.get('breakout_ok') else 'N'} " f"幅:{'Y' if gate.get('amp_ok') else 'N'} " f"二确:{'Y' if gate.get('confirm_ok') else 'N'} " f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})" ) if gate.get("breakout_ok"): try: vol_now = round(float(gate.get("vol_break") or 0), 4) vol_avg = round(float(gate.get("avg20") or 0), 4) amp_pct = round(float(gate.get("amp_pct") or 0), 4) cfm_close = float(gate.get("confirm_close") or 0) edge = float(gate.get("edge_price") or 0) gate_metrics = ( f"量值:{vol_now}/{vol_avg} " f"幅值:{amp_pct}% " f"二确值:{format_price_for_symbol(r['symbol'], cfm_close)}@{format_price_for_symbol(r['symbol'], edge)}" ) except Exception: gate_metrics = "" sym_k = r["symbol"] key_prices.append({ "id": r["id"], "symbol": sym_k, "price": round(price, 6), "price_display": format_price_for_symbol(sym_k, price), "upper_diff": upper_diff, "upper_pct": upper_pct, "lower_diff": lower_diff, "lower_pct": lower_pct, "gate_summary": gate_summary, "gate_ok": fib_gate_ok if is_fib else bool(gate and gate.get("ok")), "gate_metrics": gate_metrics, }) order_prices = [] for r in order_rows: price = prices.get(r["symbol"]) if price is None: continue margin = float(r["margin_capital"] or 0) leverage = float(r["leverage"] or 0) entry = float(r["trigger_price"] or 0) pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 pnl_pct = round((pnl / margin * 100), 2) if margin > 0 else 0 rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) ex_sym = resolve_monitor_exchange_symbol(r) prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) lev_row = r["leverage"] if "leverage" in r.keys() else None ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None payload = { "id": r["id"], "symbol": r["symbol"], "price": round(price, 6), "price_display": format_price_for_symbol(ex_sym, price), "float_pnl": round(pnl, FUNDS_DECIMALS), "float_pct": pnl_pct, "rr_ratio": rr_ratio, "plan_margin": round(margin, FUNDS_DECIMALS) if margin else None, "exchange_initial_margin": None, "exchange_notional": None, "exchange_mark_price": None, "exchange_mark_price_display": None, "pnl_source": "plan", } if ex_metrics: if ex_metrics.get("initial_margin") is not None: payload["exchange_initial_margin"] = ex_metrics["initial_margin"] if ex_metrics.get("notional") is not None: payload["exchange_notional"] = ex_metrics["notional"] if ex_metrics.get("mark_price") is not None: mp = ex_metrics["mark_price"] payload["exchange_mark_price"] = mp payload["exchange_mark_price_display"] = format_price_for_symbol(ex_sym, mp) if ex_metrics.get("unrealized_pnl") is not None: payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), FUNDS_DECIMALS) payload["pnl_source"] = "exchange" denom = ex_metrics.get("initial_margin") or margin payload["float_pct"] = ( round((payload["float_pnl"] / float(denom)) * 100, 2) if denom and float(denom) > 0 else pnl_pct ) if exchange_private_api_configured(): try: payload["exchange_tpsl"] = fetch_exchange_tpsl_slots(ex_sym, r["direction"]) except Exception: payload["exchange_tpsl"] = {"sl": None, "tp": None} else: payload["exchange_tpsl"] = {"sl": None, "tp": None} order_prices.append(payload) return jsonify({ "updated_at": app_now_str(), "key_prices": key_prices, "order_prices": order_prices, "positions_raw_count": len(all_swap_positions), }) @app.route("/api/order//cancel_tpsl", methods=["POST"]) @login_required def api_order_cancel_tpsl(order_id): data = request.get_json(silent=True) or {} role = (data.get("role") or "").strip().lower() if role not in ("sl", "tp"): return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 conn = get_db() row = conn.execute( "SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,), ).fetchone() conn.close() if not row: return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 ok, reason = ensure_exchange_live_ready() if not ok: return jsonify({"ok": False, "msg": reason}), 400 ex_sym = resolve_monitor_exchange_symbol(row) slots = fetch_exchange_tpsl_slots(ex_sym, row["direction"]) slot = slots.get(role) if not slot: return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 try: cancel_binance_tpsl_slot(ex_sym, slot) return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": fetch_exchange_tpsl_slots(ex_sym, row["direction"])}) except Exception as e: return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 @app.route("/api/order//place_tpsl", methods=["POST"]) @login_required def api_order_place_tpsl(order_id): data = request.get_json(silent=True) or {} conn = get_db() row = conn.execute( "SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,), ).fetchone() if not row: conn.close() return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 symbol = row["symbol"] direction = row["direction"] live_price = get_price(symbol) if live_price is None: conn.close() return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400 try: sltp_mode = (data.get("sltp_mode") or "price").strip().lower() stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data) except Exception as e: conn.close() return jsonify({"ok": False, "msg": str(e)}), 400 planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit) if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR: conn.close() rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" return jsonify( { "ok": False, "msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1", } ), 400 try: replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) except Exception as e: conn.close() return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 conn.execute( "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", (stop_loss, take_profit, order_id), ) conn.commit() ex_sym = resolve_monitor_exchange_symbol(row) slots = fetch_exchange_tpsl_slots(ex_sym, direction) conn.close() return jsonify( { "ok": True, "msg": "已先撤后挂止盈止损", "stop_loss": stop_loss, "take_profit": take_profit, "planned_rr": planned_rr, "exchange_tpsl": slots, } ) @app.route("/api/symbol_liquidity_rank") @login_required def api_symbol_liquidity_rank(): symbol = normalize_symbol_input(request.args.get("symbol")) if not symbol: return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 rank, total = _daily_volume_rank(symbol) if total <= 0: return jsonify({"ok": False, "msg": "日成交量排名读取失败"}), 502 if rank is None: return jsonify({"ok": True, "symbol": symbol, "rank": None, "total": int(total), "in_top30": False}) return jsonify( { "ok": True, "symbol": symbol, "rank": int(rank), "total": int(total), "in_top30": bool(rank <= KEY_DAILY_VOLUME_RANK_MAX), "rank_max": KEY_DAILY_VOLUME_RANK_MAX, } ) @app.route("/api/order_defaults") @login_required def api_order_defaults(): symbol = normalize_symbol_input(request.args.get("symbol")) direction = (request.args.get("direction") or "long").strip().lower() if not symbol: return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 if direction not in ("long", "short"): direction = "long" exchange_symbol = normalize_exchange_symbol(symbol) leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) available = get_available_trading_usdt() last_price = get_price(symbol) return jsonify({ "ok": True, "symbol": symbol, "exchange_symbol": exchange_symbol, "direction": direction, "leverage": leverage, "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, "last_price": round(float(last_price), 8) if last_price is not None else None, }) @app.route("/order_focus") @login_required def order_focus(): now = app_now() trading_day = get_trading_day(now) conn = get_db() session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) _, trading_capital_live = get_exchange_capitals() current_capital = round(trading_capital_live, FUNDS_DECIMALS) if trading_capital_live is not None else round(local_current_capital, FUNDS_DECIMALS) raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall() conn.close() orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders] picked_id = request.args.get("order_id", "").strip() selected = None if picked_id.isdigit(): selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None) if selected is None and orders: selected = orders[0] return render_template( "order_focus_v2.html", orders=orders, selected_order=selected, default_timeframe=KLINE_TIMEFRAME, price_refresh_seconds=PRICE_REFRESH_SECONDS, exchange_display=EXCHANGE_DISPLAY_NAME, ) @app.route("/api/order_kline") @login_required def api_order_kline(): order_id_raw = (request.args.get("order_id") or "").strip() if not order_id_raw.isdigit(): return jsonify({"ok": False, "msg": "order_id 无效"}), 400 order_id = int(order_id_raw) timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"} if timeframe not in allowed_tfs: timeframe = KLINE_TIMEFRAME limit = 100 now = app_now() trading_day = get_trading_day(now) conn = get_db() session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) _, trading_capital_live = get_exchange_capitals() current_capital = round(trading_capital_live, FUNDS_DECIMALS) if trading_capital_live is not None else round(local_current_capital, FUNDS_DECIMALS) row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() conn.close() if not row: return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404 order_item = enrich_order_item(row_to_dict(row), current_capital) exchange_symbol = order_item.get("exchange_symbol") or normalize_exchange_symbol(order_item["symbol"]) try: ensure_markets_loaded() ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) except Exception as e: return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 candles = [] for bar in ohlcv or []: if not bar or len(bar) < 6: continue ts = int(bar[0] // 1000) candles.append({ "time": ts, "open": float(bar[1]), "high": float(bar[2]), "low": float(bar[3]), "close": float(bar[4]), "volume": float(bar[5]), }) current_price = get_price(order_item["symbol"]) margin = float(order_item.get("margin_capital") or 0) leverage = float(order_item.get("leverage") or 0) entry = float(order_item.get("trigger_price") or 0) float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0 float_pct = round((float_pnl / margin * 100), 2) if margin > 0 else 0 return jsonify({ "ok": True, "timeframe": timeframe, "limit": limit, "order": { "id": order_item["id"], "symbol": order_item["symbol"], "direction": order_item.get("direction") or "long", "trigger_price": order_item.get("trigger_price"), "stop_loss": order_item.get("stop_loss"), "take_profit": order_item.get("take_profit"), "trigger_price_display": format_price_for_symbol(exchange_symbol, order_item.get("trigger_price")), "stop_loss_display": format_price_for_symbol(exchange_symbol, order_item.get("stop_loss")), "take_profit_display": format_price_for_symbol(exchange_symbol, order_item.get("take_profit")), "margin_capital": order_item.get("margin_capital"), "leverage": order_item.get("leverage"), "position_ratio": order_item.get("position_ratio"), "rr_ratio": order_item.get("rr_ratio"), "breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)), "current_price": round(float(current_price), 8) if current_price else None, "current_price_display": format_price_for_symbol(exchange_symbol, current_price) if current_price else None, "float_pnl": round(float(float_pnl), FUNDS_DECIMALS), "float_pct": float_pct, }, "candles": candles, "updated_at": app_now_str(), }) @app.route("/key_focus") @login_required def key_focus(): conn = get_db() key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() conn.close() key_list = [row_to_dict(r) for r in key_rows] key_id_raw = (request.args.get("key_id") or "").strip() symbol_query = normalize_symbol_input(request.args.get("symbol")) selected_key = None if key_id_raw.isdigit(): selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None) if selected_key is None and symbol_query: selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None) if selected_key is None and key_list: selected_key = key_list[0] default_symbol = symbol_query or ((selected_key or {}).get("symbol")) or "BTC/USDT" return render_template( "key_focus_v2.html", key_list=key_list, selected_key=selected_key, default_symbol=default_symbol, default_timeframe=KLINE_TIMEFRAME, default_kline_limit=200, price_refresh_seconds=PRICE_REFRESH_SECONDS, exchange_display=EXCHANGE_DISPLAY_NAME, ) @app.route("/api/key_kline") @login_required def api_key_kline(): key_id_raw = (request.args.get("key_id") or "").strip() symbol_input = normalize_symbol_input(request.args.get("symbol")) timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() if timeframe not in {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}: timeframe = KLINE_TIMEFRAME limit = normalize_kline_limit(request.args.get("limit"), default=200) conn = get_db() key_row = None if key_id_raw.isdigit(): key_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (int(key_id_raw),)).fetchone() if key_row is None and symbol_input: key_row = conn.execute( "SELECT * FROM key_monitors WHERE upper(symbol)=? ORDER BY id DESC LIMIT 1", (symbol_input,), ).fetchone() if key_row is not None: symbol = (key_row["symbol"] or "").upper() else: symbol = symbol_input conn.close() if not symbol: return jsonify({"ok": False, "msg": "请先输入币种或选择关键位"}), 400 exchange_symbol = normalize_exchange_symbol(symbol) try: ensure_markets_loaded() ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) except Exception as e: return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 candles = [] for bar in ohlcv or []: if not bar or len(bar) < 6: continue candles.append({ "time": int(bar[0] // 1000), "open": float(bar[1]), "high": float(bar[2]), "low": float(bar[3]), "close": float(bar[4]), "volume": float(bar[5]), }) current_price = get_price(symbol) key_info = None if key_row is not None: upper = float(key_row["upper"]) if key_row["upper"] is not None else None lower = float(key_row["lower"]) if key_row["lower"] is not None else None upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None) lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None) key_info = { "id": key_row["id"], "monitor_type": key_row["monitor_type"], "direction": key_row["direction"] or "long", "upper": upper, "lower": lower, "upper_display": format_price_for_symbol(exchange_symbol, upper) if upper is not None else None, "lower_display": format_price_for_symbol(exchange_symbol, lower) if lower is not None else None, "notification_count": int(key_row["notification_count"] or 0), "upper_diff": upper_diff, "upper_pct": upper_pct, "lower_diff": lower_diff, "lower_pct": lower_pct, } return jsonify({ "ok": True, "symbol": symbol, "timeframe": timeframe, "limit": limit, "current_price": round(float(current_price), 8) if current_price is not None else None, "current_price_display": format_price_for_symbol(exchange_symbol, current_price) if current_price is not None else None, "key_monitor": key_info, "candles": candles, "updated_at": app_now_str(), }) @app.route("/add_key", methods=["POST"]) @login_required def add_key(): d = request.form symbol = normalize_symbol_input(d.get("symbol")) if not symbol: flash("symbol 不能为空") return redirect("/key_monitor") direction_sel = (d.get("direction") or "").strip().lower() if direction_sel not in ("long", "short"): flash("请选择做多或做空") return redirect("/key_monitor") mt = (d.get("type") or "").strip() allowed_types = ( tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(FIB_KEY_MONITOR_TYPES) ) if mt not in allowed_types: flash("监控类型无效") return redirect("/key_monitor") rank, total = _daily_volume_rank(symbol) if rank is None: flash("日成交量排名读取失败,请稍后重试") return redirect("/key_monitor") if rank > KEY_DAILY_VOLUME_RANK_MAX: flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") return redirect("/key_monitor") conn = get_db() if mt in KEY_MONITOR_AUTO_TYPES: occupied = get_active_position_count(conn) if occupied >= MAX_ACTIVE_POSITIONS: conn.close() flash( f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" "请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。" ) return redirect("/key_monitor") ex_sym_key = normalize_exchange_symbol(symbol) try: ensure_markets_loaded() except Exception: pass uh = round_price_to_exchange(ex_sym_key, float(d["upper"])) lw = round_price_to_exchange(ex_sym_key, float(d["lower"])) upper_px = float(uh) if uh is not None else float(d["upper"]) lower_px = float(lw) if lw is not None else float(d["lower"]) if is_fib_key_monitor_type(mt): ok_fib, err_fib = _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px) conn.commit() conn.close() if not ok_fib: flash(err_fib or "斐波监控添加失败") return redirect("/key_monitor") flash(f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})") return redirect("/key_monitor") conn.execute( "INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)", (symbol, mt, direction_sel, upper_px, lower_px), ) conn.commit() conn.close() ctr = False try: coin4h_status, _, _ = _status_by_ema55(symbol, "4h") ctr = (direction_sel == "long" and coin4h_status == "空头") or ( direction_sel == "short" and coin4h_status == "多头" ) except Exception: pass flash(f"添加成功({symbol} 日成交量排名 {rank}/{total})") if ctr: flash( "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" ) return redirect("/key_monitor") @app.route("/add_order", methods=["POST"]) @login_required def add_order(): d = request.form now = app_now() conn = get_db() direction = d.get("direction", "long") symbol = normalize_symbol_input(d.get("symbol")) if not symbol: conn.close() flash("symbol 不能为空") return redirect("/") ok, reason = precheck_risk(conn, symbol, direction) if not ok: if "已达最大持仓数" in reason: try: tp_raw = parse_positive_float(d.get("tp")) sl_raw = parse_positive_float(d.get("sl")) tgt_raw = parse_positive_float(d.get("tgt")) except Exception: tp_raw = sl_raw = tgt_raw = None insert_trade_record( conn, symbol=symbol, monitor_type="下单监控", direction=direction if direction in ("long", "short") else "long", trigger_price=tp_raw or 0, stop_loss=sl_raw or 0, take_profit=tgt_raw or 0, result="错过", miss_reason=f"持仓占用:{reason}", opened_at=app_now_str(), closed_at=app_now_str(), ) conn.commit() conn.close() flash(f"风控拒绝下单:{reason}") return redirect("/trade") ok_live, reason_live = ensure_exchange_live_ready() if not ok_live: conn.close() flash(f"风控拒绝下单:{reason_live}") return redirect("/") exchange_symbol = normalize_exchange_symbol(symbol) default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) try: leverage_input = parse_positive_float(d.get("leverage")) leverage = int(leverage_input) if leverage_input is not None else default_leverage except Exception: conn.close() flash("杠杆参数格式错误") return redirect("/") if leverage <= 0: conn.close() flash("杠杆必须大于0") return redirect("/") trading_day = get_trading_day(now) opens_today_before = conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", (trading_day,), ).fetchone()[0] session_row = ensure_session(conn, trading_day) _, trading_capital_live = get_exchange_capitals(force=True) capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) trade_style = (d.get("trade_style") or DEFAULT_TRADE_STYLE or "trend").strip().lower() if trade_style not in ("trend", "swing"): trade_style = "trend" available_usdt = get_available_trading_usdt() live_price = get_price(symbol) if live_price is None: conn.close() flash("获取交易所实时价格失败,请稍后重试") return redirect("/") sltp_mode = (d.get("sltp_mode") or "price").strip().lower() if sltp_mode not in ("price", "pct"): sltp_mode = "price" if sltp_mode == "pct": try: sl_pct = float(d.get("sl_pct") or 0) tp_pct = float(d.get("tp_pct") or 0) if sl_pct <= 0 or tp_pct <= 0: raise ValueError("pct") sl_ratio = sl_pct / 100.0 tp_ratio = tp_pct / 100.0 if direction == "short": stop_loss = float(live_price) * (1 + sl_ratio) take_profit = float(live_price) * (1 - tp_ratio) else: stop_loss = float(live_price) * (1 - sl_ratio) take_profit = float(live_price) * (1 + tp_ratio) except Exception: conn.close() flash("百分比止盈止损参数错误,请填写正数百分比") return redirect("/") else: try: stop_loss = float(d["sl"]) take_profit = float(d["tgt"]) except Exception: conn.close() flash("价格参数格式错误") return redirect("/") if stop_loss <= 0 or take_profit <= 0: conn.close() flash("价格参数必须大于0") return redirect("/trade") planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit) if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR: conn.close() rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算" flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1") return redirect("/trade") risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) if risk_fraction is None: conn.close() flash("止损方向不合法:请检查入场方向与止损价格关系") return redirect("/") risk_percent = max(0.01, float(RISK_PERCENT)) risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) if capital_base and margin_capital > capital_base: conn.close() flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") return redirect("/") if available_usdt is not None: max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS) if margin_capital > max_margin: conn.close() flash(f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U") return redirect("/") position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 try: amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) contract_size = get_contract_size(exchange_symbol) base_amount = round(float(amount) * contract_size, 8) order_resp = place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit) open_order_id = order_resp.get("id", "") tpsl_attached = bool(order_resp.get("tpsl_attached")) trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) except Exception as e: conn.close() flash(friendly_exchange_error(e, available_usdt=available_usdt)) return redirect("/") make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes") opened_at_bj = app_now_str() opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount if direction == "short": breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) else: breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 conn.execute( "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, ORDER_MONITOR_TYPE_MANUAL, ) ) conn.commit() new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) opens_today_after = conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", (trading_day,), ).fetchone()[0] conn.close() chart_name = None chart_url = None if make_order_chart and ORDER_CHART_ENABLED: try: title_prefix = f"{symbol} {direction} #{new_order_id}" chart_name = generate_order_open_chart(exchange_symbol, title_prefix) if chart_name: chart_url = f"/static/images/order_charts/{chart_name}" except Exception: chart_name = None chart_url = None if chart_name: try: journal_id = f"order_{new_order_id}" coin = journal_coin_from_symbol(symbol) open_local = (opened_at_bj or "")[:16].replace(" ", "T") if len(open_local) < 16: open_local = app_now().strftime("%Y-%m-%dT%H:%M") close_local = open_local hold_duration = calc_duration_text(open_local, close_local) note = ( f"auto_from_open_order id={new_order_id} oid={open_order_id} " f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" ) conn = get_db() conn.execute( """INSERT OR REPLACE INTO journal_entries (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, new_trade_while_occupied, note, image) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( journal_id, open_local, close_local, hold_duration, coin, "multi", "0", "auto:open", "待平仓", "", "", "否", "", "", "", None, None, None, "", "否", "否", note, chart_name, ), ) conn.commit() conn.close() except Exception: try: conn.close() except Exception: pass _, trading_capital_after = get_exchange_capitals(force=True) account_base_display = ( round(float(trading_capital_after), FUNDS_DECIMALS) if trading_capital_after is not None else round(float(capital_base), FUNDS_DECIMALS) ) account_name = (os.getenv("BINANCE_ACCOUNT_LABEL") or "binance实盘账户").strip() dir_text = "多头(long)" if direction == "long" else "空头(short)" order_state_text = ( "已在交易所挂条件委托(止盈、止损各一张触发单)" if tpsl_attached else "条件委托未挂上(已拦截)" ) rr_show = planned_rr if planned_rr is not None else "-" try: rr_show_fmt = round(float(planned_rr), 4) if planned_rr is not None else None except (TypeError, ValueError): rr_show_fmt = None rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1" ep_wx = format_price_for_symbol(symbol, trigger_price) sl_wx = format_price_for_symbol(symbol, stop_loss) tp_wx = format_price_for_symbol(symbol, take_profit) be_wx = format_price_for_symbol(symbol, breakeven_price) style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" wx_lines = [ f"📈 {symbol} 开仓成功", f"💼 交易类型:{dir_text}", "🧾 订单基础信息", f"🔖 交易所订单 ID:{open_order_id}", f"📈 交易风格:{style_zh}", f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), FUNDS_DECIMALS)} U", "📊 仓位配置详情", f"账户基数:{account_base_display} USDT", f"合约杠杆:{leverage} 倍", f"名义仓位:{notional_value} USDT", f"仓位占比:{position_ratio}%", f"合约数量:{amount}", f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", "🎯 价位 & 盈亏比", f"开仓成交价:{ep_wx}", f"止损价位:{sl_wx}", f"止盈价位:{tp_wx}", f"计划盈亏比:{rr_line}", f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", "📌 状态统计", f"✅ 条件委托:{order_state_text}", f"📅 当日开仓次数:{opens_today_after} / {DAILY_OPEN_ALERT_THRESHOLD} 次(风控阈值提醒)", ] if chart_url: wx_lines.append(f"多周期K线图:{chart_url}") send_wechat_msg("\n".join(wx_lines)) flash_lines = [ f"实盘开单成功:风格 {trade_style};风险 {risk_percent}%≈{risk_amount_final}U;基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约数量 {amount}(折算标的 {base_amount})," f"计划RR {planned_rr if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", f"本交易日累计开仓:{opens_today_after}", ] if chart_url: flash_lines.append(f"已生成多周期K线图:{chart_url}") flash(" ".join(flash_lines)) if opens_today_before < DAILY_OPEN_ALERT_THRESHOLD <= opens_today_after: advice = ai_short_advice( f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_today_after} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。" f"用户自述“上头了”。请给克制提醒。" ) if advice: send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") return redirect("/") @app.route("/delete_key_monitor/", methods=["POST"]) @login_required def delete_key_monitor(kid): conn = get_db() row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() if not row: conn.close() return jsonify({"ok": False, "error": "not_found"}) if is_fib_key_monitor_type(row["monitor_type"]): _cancel_fib_monitor_limit(row) insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) conn.commit() conn.close() return jsonify({"ok": cur.rowcount > 0}) @app.route("/delete_key_history/", methods=["POST"]) @login_required def delete_key_history(hid): conn = get_db() cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) conn.commit() conn.close() return jsonify({"ok": cur.rowcount > 0}) @app.route("/del_key/") @login_required def del_key(id): conn = get_db() row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() if row: if is_fib_key_monitor_type(row["monitor_type"]): _cancel_fib_monitor_limit(row) insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) conn.commit() conn.close() resp = redirect("/") resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" resp.headers["Pragma"] = "no-cache" return resp def _csv_response(filename, rows, header): buf = StringIO() w = csv.writer(buf) w.writerow(header) for row in rows: w.writerow(row) out = "\ufeff" + buf.getvalue() return Response( out, mimetype="text/csv; charset=utf-8", headers={ "Content-Disposition": f'attachment; filename="{filename}"', "Cache-Control": "no-store", }, ) def _md_response(filename, content): return Response( content, mimetype="text/markdown; charset=utf-8", headers={ "Content-Disposition": f'attachment; filename="{filename}"', "Cache-Control": "no-store", }, ) @app.route("/export/trade_records") @login_required def export_trade_records(): conn = get_db() rows = conn.execute( "SELECT id,symbol,monitor_type,direction,trigger_price,stop_loss,take_profit,margin_capital,leverage," "pnl_amount,hold_seconds,hold_minutes,opened_at,closed_at,result,miss_reason," "entry_reason,reviewed_entry_reason,created_at FROM trade_records ORDER BY id ASC" ).fetchall() conn.close() head_base = [ "id", "symbol", "monitor_type", "direction", "trigger_price", "stop_loss", "take_profit", "margin_capital", "leverage", "pnl_amount", "hold_seconds", "hold_minutes", "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", "created_at", ] head = head_base + ["开仓类型"] data = [] for r in rows: er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" eff = er1 or er0 data.append(tuple(r[h] for h in head_base) + (eff,)) day = app_now().strftime("%Y%m%d") return _csv_response(f"trade_records_v2_{day}.csv", data, head) @app.route("/export/journal_entries") @login_required def export_journal_entries(): conn = get_db() rows = conn.execute( "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," "post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC" ).fetchall() conn.close() head = [ "id", "open_datetime", "close_datetime", "hold_duration", "coin", "tf", "pnl", "entry_reason", "exit_reason", "expect_rr", "real_rr", "early_exit", "early_exit_trigger", "early_exit_note", "early_exit_reason", "mood_issues", "post_breakeven_stare", "new_trade_while_occupied", "note", "image", "created_at", ] data = [tuple(r[h] for h in head) for r in rows] day = app_now().strftime("%Y%m%d") return _csv_response(f"journal_entries_v1_{day}.csv", data, head) @app.route("/export/key_monitors") @login_required def export_key_monitors(): conn = get_db() rows = conn.execute( "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify," "notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC" ).fetchall() conn.close() head = [ "id", "symbol", "monitor_type", "direction", "upper", "lower", "notification_count", "last_notified_at", "max_notify", "notify_interval_min", "breakout_limit_pct", "created_at", ] data = [tuple(r[h] for h in head) for r in rows] day = app_now().strftime("%Y%m%d") return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head) @app.route("/export/key_monitor_history") @login_required def export_key_monitor_history(): conn = get_db() rows = conn.execute( "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " "FROM key_monitor_history ORDER BY id ASC" ).fetchall() conn.close() head = [ "id", "symbol", "monitor_type", "direction", "upper", "lower", "notification_count", "last_alert_message", "close_reason", "closed_at", ] data = [tuple(r[h] for h in head) for r in rows] day = app_now().strftime("%Y%m%d") return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) @app.route("/del_order/") @login_required def del_order(id): conn = get_db() row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() if not row: conn.close() flash("订单不存在") return redirect("/") if row["status"] == "active": try: p = get_price(row["symbol"]) or float(row["trigger_price"]) opened_at = get_opened_at_value(row) closed_at = app_now_str() hold_seconds = calc_hold_seconds(opened_at, app_now()) pnl_amount = calc_pnl( row["direction"], row["trigger_price"], p, row["margin_capital"] or DAILY_START_CAPITAL, row["leverage"] or infer_leverage(row["symbol"]) ) close_resp = close_exchange_order(row) close_order_id = close_resp.get("id", "") cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) session_date = row["session_date"] or get_trading_day() session_capital = update_session_capital(conn, session_date, pnl_amount) insert_trade_record( conn, symbol=row["symbol"], monitor_type=order_row_monitor_type(row), key_signal_type=order_row_key_signal_type(row), direction=row["direction"], trigger_price=row["trigger_price"], stop_loss=row["stop_loss"], initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], take_profit=row["take_profit"], margin_capital=row["margin_capital"], leverage=row["leverage"], pnl_amount=pnl_amount, hold_seconds=hold_seconds, trade_style=row["trade_style"], risk_amount=row["risk_amount"], planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), result="手动平仓", miss_reason="用户手动删除订单触发平仓", opened_at=opened_at, closed_at=closed_at, ) conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) clear_key_sizing_snapshot_if_flat(conn, session_date) conn.commit() conn.close() send_wechat_msg( build_wechat_close_message( symbol=row["symbol"], direction=row["direction"], result="手动平仓", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=row["trigger_price"], current_price=p, stop_loss=row["stop_loss"], take_profit=row["take_profit"], close_order_id=close_order_id or "-", extra_note="用户在页面手动平仓", session_capital_fallback=session_capital, ) ) flash("已按实盘流程手动平仓") return redirect("/trade") except Exception as e: if is_no_position_error(str(e)): cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) opened_at = get_opened_at_value(row) opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) miss_reason = f"手动删除时无持仓:{miss_reason}" closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) session_date = row["session_date"] or get_trading_day(closed_at_dt) update_session_capital(conn, session_date, pnl_amount) insert_trade_record( conn, symbol=row["symbol"], monitor_type=order_row_monitor_type(row), key_signal_type=order_row_key_signal_type(row), direction=row["direction"], trigger_price=row["trigger_price"], stop_loss=row["stop_loss"], initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], take_profit=row["take_profit"], margin_capital=row["margin_capital"], leverage=row["leverage"], pnl_amount=pnl_amount, hold_seconds=hold_seconds, trade_style=row["trade_style"], risk_amount=row["risk_amount"], planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), result=result, miss_reason=miss_reason, opened_at=opened_at, closed_at=closed_at, ) conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) conn.commit() conn.close() flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") return redirect("/") conn.close() flash(f"手动平仓失败:{str(e)}") return redirect("/") conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) conn.commit() conn.close() return redirect("/") @app.route("/add_miss", methods=["POST"]) @login_required def add_miss(): d = request.form direction = d.get("direction", "long") conn = get_db() insert_trade_record( conn, symbol=d["symbol"], monitor_type=d["type"], direction=direction, trigger_price=d["tp"], stop_loss=d["sl"], take_profit=d["tgt"], result="错过", miss_reason=d["reason"], opened_at=app_now_str(), closed_at=app_now_str(), ) conn.commit() conn.close() flash("已记录错过机会") return redirect("/records") @app.route("/add_journal", methods=["POST"]) @login_required def add_journal(): d = request.form entry_reason_norm = normalize_entry_reason(d.get("entry_reason"), d.get("entry_reason_custom")) if not entry_reason_norm: flash("请选择开仓类型;若选「其他」请在下方填写自定义说明") return redirect("/records") early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger")) early_exit_note = str(d.get("early_exit_note") or "").strip() if not early_exit_trigger: flash("请选择离场触发") return redirect("/records") if early_exit_trigger == "手动平仓" and not early_exit_note: flash("手工平仓必须填写补充说明") return redirect("/records") if early_exit_trigger != "手动平仓": early_exit_note = "" # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」 early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) image_filename = None uploaded_tmp = None entry_id = uuid.uuid4().hex file = request.files.get("screenshot") if file and file.filename: ext = os.path.splitext(file.filename)[1] image_filename = f"{uuid.uuid4().hex}{ext}" save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename)) file.save(save_path) uploaded_tmp = image_filename mood_issues = ",".join(request.form.getlist("mood_issues")) hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) real_rr_text = (d.get("real_rr") or "").strip() try: risk_amount_hint = float(d.get("risk_amount_hint") or 0) pnl_hint = float(d.get("pnl") or 0) # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 if risk_amount_hint > 0: real_rr_text = f"{(pnl_hint / risk_amount_hint):.4f}" except Exception: pass want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") chart_msg = None if want_exchange_chart and ORDER_CHART_ENABLED: coin = (d.get("coin") or "").strip().upper() symbol_guess = normalize_symbol_input(coin) or coin exchange_symbol = normalize_exchange_symbol(symbol_guess) title_prefix = f"{symbol_guess} journal {entry_id[:8]}" marker_payload = { "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")), "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")), "entry_price": d.get("entry_price_hint"), "exit_price": None, } try: chart_fname = f"journal_{entry_id}.png" saved = generate_multi_timeframe_chart_png( exchange_symbol, title_prefix, timeframes=ORDER_CHART_TFS, limit=ORDER_CHART_LIMIT, out_dir=app.config["UPLOAD_FOLDER"], filename=chart_fname, filename_prefix="journal", marker_payload=marker_payload, marker_timeframes=( {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()} if ORDER_CHART_TFS else {"5m", "15m", "1h", "4h"} ), ) if saved: image_filename = saved chart_msg = f"已生成多周期K线图:/static/images/{saved}" if uploaded_tmp: try: old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) if os.path.exists(old_path): os.remove(old_path) except Exception: pass else: chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Binance 网络/代理是否正常。" except Exception as e: image_filename = uploaded_tmp chart_msg = f"自动生成K线图失败:{str(e)}" conn = get_db() conn.execute( """INSERT INTO journal_entries (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, new_trade_while_occupied, note, image) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( entry_id, d.get("open_datetime"), d.get("close_datetime"), hold_duration, d.get("coin"), d.get("tf"), d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, None, None, None, mood_issues, d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename ) ) conn.commit() conn.close() if chart_msg: flash(f"交易复盘记录已保存。{chart_msg}") else: flash("交易复盘记录已保存") return redirect("/records") @app.route("/api/journals") @login_required def api_journals(): conn = get_db() rows = conn.execute("SELECT * FROM journal_entries ORDER BY created_at DESC").fetchall() conn.close() result = [] for r in rows: item = row_to_dict(r) item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] result.append(item) return jsonify(result) @app.route("/api/journal_prefill", methods=["POST"]) @login_required def api_journal_prefill(): file = request.files.get("screenshot") if not file or not file.filename: return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400 try: raw = file.read() if not raw: return jsonify({"ok": False, "msg": "截图为空"}), 400 image_b64 = base64.b64encode(raw).decode("utf-8") except Exception as e: return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400 parsed = ai_extract_journal_from_image(image_b64) if parsed is None: return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500 return jsonify({"ok": True, "data": parsed}) @app.route("/delete_journal/", methods=["POST"]) @login_required def delete_journal(jid): conn = get_db() row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone() if row and row["image"]: img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"]) if os.path.exists(img_path): os.remove(img_path) conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) conn.commit() conn.close() return jsonify({"ok": True}) @app.route("/api/reviews") @login_required def api_reviews(): conn = get_db() rows = conn.execute("SELECT * FROM ai_reviews ORDER BY created_at DESC").fetchall() conn.close() return jsonify([row_to_dict(r) for r in rows]) @app.route("/export/review_md/") @login_required def export_review_md(rid): conn = get_db() row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() conn.close() if not row: return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" target_date = row["target_date"] or "-" created_at = row["created_at"] or app_now_str() content = (row["content"] or "").strip() if not content: content = "(无内容)" md = ( f"# {review_type}报告\n\n" f"- 目标日期: {target_date}\n" f"- 生成时间: {created_at}\n" f"- 报告ID: {row['id']}\n\n" f"---\n\n" f"{content}\n" ) safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" safe_type = "daily" if row["review_type"] == "daily" else "weekly" filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" return _md_response(filename, md) @app.route("/export/reviews_md_bundle") @login_required def export_reviews_md_bundle(): review_type = (request.args.get("review_type") or "").strip().lower() target_date = (request.args.get("target_date") or "").strip() if review_type not in ("daily", "weekly"): return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") if not target_date: return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") conn = get_db() rows = conn.execute( "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", (review_type, target_date), ).fetchall() conn.close() if not rows: return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") title = "日复盘" if review_type == "daily" else "周复盘" lines = [ f"# {title}汇总报告", "", f"- 目标日期: {target_date}", f"- 条目数量: {len(rows)}", f"- 导出时间: {app_now_str()}", "", "---", "", ] for idx, row in enumerate(rows, 1): created_at = row["created_at"] or "-" content = (row["content"] or "").strip() or "(无内容)" lines.extend( [ f"## 第{idx}条", "", f"- 报告ID: {row['id']}", f"- 生成时间: {created_at}", "", content, "", "---", "", ] ) md = "\n".join(lines) safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" return _md_response(filename, md) @app.route("/delete_review/", methods=["POST"]) @login_required def delete_review(rid): conn = get_db() conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) conn.commit() conn.close() return jsonify({"ok": True}) @app.route("/delete_trade_record/", methods=["POST"]) @login_required def delete_trade_record(rid): conn = get_db() cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) conn.commit() conn.close() return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) @app.route("/api/trade_record_review_update", methods=["POST"]) @login_required def api_trade_record_review_update(): payload = request.get_json(silent=True) or {} rec_id = payload.get("id") try: rec_id = int(rec_id) except Exception: return jsonify({"ok": False, "msg": "记录ID无效"}), 400 reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") reviewed_take_profit_raw = payload.get("reviewed_take_profit") reviewed_result = str(payload.get("reviewed_result") or "").strip() reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() reviewed_pnl_raw = payload.get("reviewed_pnl_amount") if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400 try: reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") except Exception: return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 if reviewed_close_dt < reviewed_open_dt: return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) hold_minutes = calc_hold_minutes(hold_seconds) try: reviewed_pnl_amount = float(reviewed_pnl_raw) except Exception: return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 reviewed_stop_loss = None if reviewed_stop_loss_raw not in (None, ""): try: reviewed_stop_loss = float(reviewed_stop_loss_raw) except Exception: return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 reviewed_take_profit = None if reviewed_take_profit_raw not in (None, ""): try: reviewed_take_profit = float(reviewed_take_profit_raw) except Exception: return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 _MISSING_ER = object() reviewed_entry_reason_update = _MISSING_ER if "reviewed_entry_reason" in payload: s = str(payload.get("reviewed_entry_reason") or "").strip() if s and not entry_reason_valid_for_storage(s): return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400 reviewed_entry_reason_update = s or None conn = get_db() row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() if not row: conn.close() return jsonify({"ok": False, "msg": "记录不存在"}), 404 risk_amount = row["risk_amount"] actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) base_params = [ reviewed_opened_at, reviewed_closed_at, reviewed_stop_loss, reviewed_take_profit, round(reviewed_pnl_amount, FUNDS_DECIMALS), reviewed_result or None, reviewed_miss_reason or None, hold_seconds, hold_minutes, app_now_str(), actual_rr, ] if reviewed_entry_reason_update is not _MISSING_ER: conn.execute( """UPDATE trade_records SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? WHERE id=?""", tuple(base_params + [reviewed_entry_reason_update, rec_id]), ) else: conn.execute( """UPDATE trade_records SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, reviewed_at=?, actual_rr=COALESCE(?, actual_rr) WHERE id=?""", tuple(base_params + [rec_id]), ) conn.commit() conn.close() return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) @app.route("/manual_transfer", methods=["POST"]) @login_required def manual_transfer(): try: amount = float(request.form.get("amount", "0")) except Exception: flash("划转金额格式错误") return redirect("/") from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) conn = get_db() conn.execute( "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) ) conn.commit() conn.close() if ok: flash(f"手动划转成功:{amount}U {from_account}->{to_account}") else: flash(f"手动划转失败:{msg}") return redirect("/") @app.route("/ai_daily_review", methods=["POST"]) @login_required def ai_daily_review(): date = request.form.get("date", "") conn = get_db() rows = conn.execute( "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", (date,) ).fetchall() conn.close() if not rows: return jsonify({"result": "该日无交易记录"}) text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" for idx, row in enumerate(rows, 1): text += _journal_row_lines_for_ai(idx, row) text += "\n" image_paths = [] for row in rows: img = row["image"] if not img: continue img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) if os.path.exists(img_path): image_paths.append(img_path) ai_result = ai_review(text, "每日", image_paths=image_paths) full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" conn = get_db() conn.execute( "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", (uuid.uuid4().hex, "daily", date, full) ) conn.commit() conn.close() return jsonify({"result": full}) @app.route("/ai_weekly_review", methods=["POST"]) @login_required def ai_weekly_review(): start_date = request.form.get("start_date", "") end_date = request.form.get("end_date", "") conn = get_db() rows = conn.execute( "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", (start_date, end_date) ).fetchall() conn.close() if not rows: return jsonify({"result": "该时间段无交易记录"}) text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" for idx, row in enumerate(rows, 1): text += _journal_row_lines_for_ai(idx, row) text += "\n" image_paths = [] for row in rows: img = row["image"] if not img: continue img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) if os.path.exists(img_path): image_paths.append(img_path) ai_result = ai_review(text, "周度", image_paths=image_paths) full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" conn = get_db() conn.execute( "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) ) conn.commit() conn.close() return jsonify({"result": full}) # 启动 if __name__ == "__main__": threading.Thread(target=background_task, daemon=True).start() app.run(host=HOST, port=PORT, debug=DEBUG)