from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response, send_file import sqlite3 import csv from io import StringIO import time import threading import requests import os import re import base64 import json import math from datetime import datetime, timedelta, timezone try: from zoneinfo import ZoneInfo except ImportError: ZoneInfo = None # type: ignore from functools import wraps import uuid import ccxt from werkzeug.utils import secure_filename try: from PIL import Image, ImageDraw, ImageFont except ImportError: Image = None # type: ignore ImageDraw = None # type: ignore ImageFont = None # type: ignore BASE_DIR = os.path.dirname(os.path.abspath(__file__)) _REPO_ROOT = BASE_DIR # 独立项目: lib/ 与 app.py 同级 import sys if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) from lib.paths import common_static_dir from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order from lib.key_monitor.key_monitor_lib import ( KEY_DIRECTION_WATCH, KEY_MONITOR_ALERT_ONLY_TYPES, KEY_MONITOR_AUTO_TYPES, KEY_MONITOR_RS_TYPE, KEY_MONITOR_RS_TYPES, backfill_missing_key_signal_types, claim_rs_level_notify, detect_rs_box_break, entry_reason_from_key_signal, is_fib_key_monitor_type, is_false_breakout_key_monitor_type, is_limit_key_monitor_type, is_rs_key_monitor_type, is_trigger_entry_key_monitor_type, key_monitor_rule_template_context, key_signal_type_for_trade_record, notify_interval_elapsed, resolve_rs_break_for_alert, rs_break_from_direction, run_rs_level_alert_tick, ) from lib.trade.trade_labels_lib import ( JOURNAL_ORDER_TYPE_OPTIONS, apply_order_monitor_source_labels, entry_reason_for_monitor_type, handoff_trade_miss_reason, normalize_journal_order_type, order_monitor_source_type, trade_record_monitor_type as resolve_trade_record_monitor_type, trend_plan_id_from_monitor_row, ) from lib.instance.journal_form_lib import normalize_journal_direction, normalize_journal_entry_reason from lib.instance.journal_images_lib import ( collect_journal_slot_images, enrich_journal_api_item, images_json_dumps, journal_image_paths, normalize_journal_draft_id, primary_journal_image, ) from lib.instance.journal_upload_api_lib import handle_journal_upload_slot from lib.instance.journal_chart_lib import ( JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2, JOURNAL_CHART_TF_CHOICES, compose_chart_panels, marker_points_for_timeframe, parse_journal_chart_anchor, parse_journal_chart_limit, parse_journal_chart_timeframes, JOURNAL_CHART_DEFAULT_ANCHOR, price_levels_from_marker_payload, render_candles_subplot, trade_review_fetch_window, trim_rows_for_trade_review, ) from lib.key_monitor.key_sl_tp_lib import ( breakeven_enabled_from_row, normalize_sl_tp_mode, parse_breakeven_enabled_form, plan_key_sl_tp, sl_tp_mode_from_row, sl_tp_mode_label, sl_tp_plan_summary_text, ) from lib.trade.time_close_lib import ( TIME_CLOSE_RESULT, apply_time_close_to_payload, ensure_time_close_schema, parse_time_close_enabled_form, parse_time_close_hours_form, should_trigger_time_close, time_close_insert_values, time_close_label, time_close_settings_from_row, ) from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema from lib.trade.trade_policy_lib import load_trade_policy from lib.trade.entry_model_lib import ( build_intraday_entry_reason_options, build_journal_entry_reason_options, enrich_entry_model_display, migrate_entry_model_columns, order_entry_template_context, open_position_button_label, parse_manual_order_style_fields, resolve_effective_trade_entry_reason, format_entry_type_display, resolve_trade_record_entry_reason, trend_manual_entry_reason_count, ) from lib.trade.trade_policy_app_lib import ( check_direction_policy, check_open_policy, check_symbol_policy, default_symbol_for_policy, trade_policy_template_context, ) from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook from lib.instance.instance_nav_lib import request_is_hub_soft_nav from lib.market.volume_rank_lib import resolve_daily_volume_rank from lib.common.history_window_lib import ( PRESET_ALL, PRESET_CUSTOM, PRESET_DEFAULT, PRESET_UTC_LAST24H, PRESET_UTC_LAST3M, PRESET_UTC_LAST6M, PRESET_UTC_LAST7D, PRESET_UTC_THIS_MONTH, PRESET_UTC_TODAY, list_window_redirect_query, normalize_bj_datetime_storage, resolve_list_window, resolve_window, sql_list_time_field, utc_window_to_bj_sql_strings, utc_window_to_utc_sql_strings, ) from lib.trade.trade_result_lib import ( classify_exit_by_levels, count_winning_trades, filter_trade_records_excluding_miss, normalize_result_with_pnl, ) def force_close_template_context(enabled=False, bj_hour=0, **kwargs): return {"force_close": {"enabled": False, "hour_label": "00:00", "grace_minutes": 5}} def apply_force_close_to_payload(payload, **kwargs): return payload def apply_force_close_display_result(result, closed_at, *, enabled=False, bj_hour=0): return result def enrich_orders_force_close(order_list, *args, **kwargs): return order_list def resolve_manual_open_gate(**kwargs): return {"can_trade": False, "open_block_note": "永续开仓已下线", "force_close_blocks": False} def count_opens_for_trading_day(conn, trading_day): del conn, trading_day return 0 def can_trade_new_open(**kwargs): return False def mode_label_zh(mode): return str(mode or "") 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") or os.urandom(32) # 登录态:同一设备 7 天内免重新登录(permanent cookie;访问时滑动续期) try: _SESSION_DAYS = max(1, int(os.getenv("APP_SESSION_DAYS", "7") or "7")) except Exception: _SESSION_DAYS = 7 app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=_SESSION_DAYS) app.config["SESSION_REFRESH_EACH_REQUEST"] = True # 中控跨站 iframe 嵌入:默认 SameSite=None;Secure,否则仅首屏有会话、后续 API 全 401 _samesite = (os.getenv("SESSION_COOKIE_SAMESITE") or "None").strip() or "None" app.config["SESSION_COOKIE_SAMESITE"] = _samesite _secure_raw = (os.getenv("SESSION_COOKIE_SECURE") or "").strip().lower() if _secure_raw in ("1", "true", "yes", "on"): app.config["SESSION_COOKIE_SECURE"] = True elif _secure_raw in ("0", "false", "no", "off"): app.config["SESSION_COOKIE_SECURE"] = False elif _samesite.lower() == "none": app.config["SESSION_COOKIE_SECURE"] = True from lib.instance.instance_embed_lib import attach_embed_templates, redirect_to_embed_shell_if_enabled from lib.instance.instance_static_lib import install_common_static attach_embed_templates(app, _REPO_ROOT) install_common_static(app, _REPO_ROOT) # ====================== 登录配置 ====================== # 独立部署须在 .env 配置账号;勿在源码写真实口令 USERNAME = (os.getenv("APP_USERNAME") or "").strip() PASSWORD = (os.getenv("APP_PASSWORD") or "").strip() AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") if not AUTH_DISABLED and (not USERNAME or not PASSWORD): print("[auth] APP_USERNAME/APP_PASSWORD 未配置:请设置 .env,或临时 APP_AUTH_DISABLED=true") # 企业微信机器人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") def _env_float(name: str, default: float) -> float: try: raw = os.getenv(name) if raw is None or str(raw).strip() == "": return float(default) return float(raw) except (TypeError, ValueError): return float(default) def _env_int(name: str, default: int) -> int: try: raw = os.getenv(name) if raw is None or str(raw).strip() == "": return int(default) return int(float(raw)) except (TypeError, ValueError): return int(default) PORT = _env_int("APP_PORT", 5000) DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) # 训练参数(可由 .env 覆盖) TOTAL_CAPITAL = _env_float("TOTAL_CAPITAL", 100) DAILY_START_CAPITAL = _env_float("DAILY_START_CAPITAL", 30) DAILY_LOSS_CAPITAL = _env_float("DAILY_LOSS_CAPITAL", 20) DAILY_PROFIT_CAPITAL = _env_float("DAILY_PROFIT_CAPITAL", 50) BTC_LEVERAGE = _env_int("BTC_LEVERAGE", 10) ALT_LEVERAGE = _env_int("ALT_LEVERAGE", 5) # 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) TRADING_DAY_RESET_HOUR = _env_int("TRADING_DAY_RESET_HOUR", 8) TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" ).lower() in ("1", "true", "yes", "on") RUNTIME_KEY_OPEN_GUARD = "trading_day_reset_open_guard_enabled" 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" def _promote_legacy_options_api_keys() -> None: """1B: OKX_API_* 为空时,用废弃的 OKX_OPTIONS_API_* 回填到进程环境.""" if (os.getenv("OKX_API_KEY") or "").strip(): return legacy_key = (os.getenv("OKX_OPTIONS_API_KEY") or "").strip() legacy_secret = (os.getenv("OKX_OPTIONS_API_SECRET") or "").strip() legacy_pass = (os.getenv("OKX_OPTIONS_API_PASSPHRASE") or "").strip() if not (legacy_key and legacy_secret and legacy_pass): return os.environ["OKX_API_KEY"] = legacy_key os.environ["OKX_API_SECRET"] = legacy_secret os.environ["OKX_API_PASSPHRASE"] = legacy_pass _promote_legacy_options_api_keys() OKX_API_KEY = os.getenv("OKX_API_KEY", "") OKX_API_SECRET = os.getenv("OKX_API_SECRET", "") OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "") OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "true").lower() in ("1", "true", "yes", "on") OKX_OPTIONS_TRADE_BUDGET_USDC = _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10) OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross") OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge") EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX" BALANCE_REFRESH_SECONDS = _env_int("BALANCE_REFRESH_SECONDS", 60) PRICE_REFRESH_SECONDS = _env_int("PRICE_REFRESH_SECONDS", 5) KEY_ALERT_MAX_TIMES = _env_int("KEY_ALERT_MAX_TIMES", 3) KEY_ALERT_INTERVAL_MINUTES = _env_int("KEY_ALERT_INTERVAL_MINUTES", 5) KEY_BREAKOUT_LIMIT_PCT = _env_float("KEY_BREAKOUT_LIMIT_PCT", 1.5) AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" AUTO_TRANSFER_AMOUNT = _env_float("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 = _env_int("FORCE_CLOSE_BJ_HOUR", 0) # 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日(与 OKX 日界一致便于对账) AUTO_TRANSFER_BJ_HOUR = _env_int("AUTO_TRANSFER_BJ_HOUR", 8) POSITION_SIZING_MODE = (os.getenv("POSITION_SIZING_MODE") or "risk").strip().lower() TRADE_POLICY = load_trade_policy() WECHAT_TIMEOUT_SECONDS = _env_int("WECHAT_TIMEOUT_SECONDS", 10) MONITOR_POLL_SECONDS = _env_int("MONITOR_POLL_SECONDS", 3) RECONCILE_STARTUP_GRACE_SEC = _env_int("RECONCILE_STARTUP_GRACE_SEC", 90) RECONCILE_FLAT_CONFIRM_POLLS = max(1, _env_int("RECONCILE_FLAT_CONFIRM_POLLS", 3)) _APP_STARTED_AT = time.time() _RECONCILE_FLAT_STREAK = {} BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC = max(15, _env_int("BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC", 60)) _BREAKEVEN_LAST_EX_SYNC: dict[int, float] = {} KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") FULL_MARGIN_BUFFER_RATIO = _env_float("FULL_MARGIN_BUFFER_RATIO", 0.98) TRANSFER_CCY = (os.getenv("TRANSFER_CCY", "USDT") or "USDT").strip().upper() or "USDT" OKX_POSITION_INST_TYPE = os.getenv("OKX_POSITION_INST_TYPE", "SWAP") EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip() EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, _env_int("EXCHANGE_POSITION_HISTORY_LIMIT", 200))) _LAST_EXCHANGE_PNL_SYNC_AT = 0.0 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 = _env_int("ORDER_CHART_LIMIT", 100) ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT = 5, 0 RISK_PERCENT = _env_float("RISK_PERCENT", 2) BREAKEVEN_RR_TRIGGER = _env_float("BREAKEVEN_RR_TRIGGER", 1.0) BREAKEVEN_OFFSET_PCT = _env_float("BREAKEVEN_OFFSET_PCT", 0.02) BREAKEVEN_STEP_R = _env_float("BREAKEVEN_STEP_R", 1.0) ORDER_MONITOR_TYPE_MANUAL = "下单监控" ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" KEY_AUTO_MIN_PLANNED_RR = _env_float("KEY_AUTO_MIN_PLANNED_RR", 1.5) KEY_STOP_OUTSIDE_BREAKOUT_PCT = _env_float("KEY_STOP_OUTSIDE_BREAKOUT_PCT", 0.5) KEY_TREND_STOP_OUTSIDE_PCT = _env_float("KEY_TREND_STOP_OUTSIDE_PCT", 1) KEY_DAILY_VOLUME_RANK_MAX = max(1, _env_int("KEY_DAILY_VOLUME_RANK_MAX", 30)) MANUAL_MIN_PLANNED_RR = _env_float("MANUAL_MIN_PLANNED_RR", 1.4) MAX_ACTIVE_POSITIONS = max(1, _env_int("MAX_ACTIVE_POSITIONS", 1)) KEY_VOLUME_MA_BARS = max(1, _env_int("KEY_VOLUME_MA_BARS", 20)) KEY_VOLUME_RATIO_MIN = _env_float("KEY_VOLUME_RATIO_MIN", 1.3) KEY_BREAKOUT_AMP_MIN_PCT = _env_float("KEY_BREAKOUT_AMP_MIN_PCT", 0.03) KEY_BREAKOUT_AMP_MAX_PCT = _env_float("KEY_BREAKOUT_AMP_MAX_PCT", 0.5) KEY_CONFIRM_BREAKOUT_BAR = _env_int("KEY_CONFIRM_BREAKOUT_BAR", -2) KEY_CONFIRM_BAR = _env_int("KEY_CONFIRM_BAR", -1) KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() in ( "1", "true", "yes", "on", ) DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() OKX_SOCKS_PROXY = (os.getenv("OKX_SOCKS_PROXY") or "").strip() OKX_HTTP_PROXY = (os.getenv("OKX_HTTP_PROXY") or "").strip() OKX_HTTPS_PROXY = (os.getenv("OKX_HTTPS_PROXY") or "").strip() def build_okx_ccxt_proxies(): """ 为 ccxt 配置代理(常用于:本地网络对 OKX TLS/SNI 不稳定,通过 SSH 动态转发 SOCKS5 出口). 推荐: - 本机:ssh -N -D 127.0.0.1:1080 user@vps - .env:OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 说明: - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// """ socks = OKX_SOCKS_PROXY.strip() http = OKX_HTTP_PROXY.strip() https = OKX_HTTPS_PROXY.strip() or http if socks: return {"http": socks, "https": socks} if http or https: return {"http": http, "https": https} return None OKX_CCXT_PROXIES = build_okx_ccxt_proxies() os.makedirs(UPLOAD_FOLDER, exist_ok=True) os.makedirs(ORDER_CHART_DIR, exist_ok=True) app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER # 同一套 OKX_API_*:swap 客户端跑永续,option 客户端跑期权(身份相同,defaultType 不同) exchange = ccxt.okx({ "enableRateLimit": True, "options": {"defaultType": "swap"}, # OKX 用 swap 表示永续 }) if OKX_CCXT_PROXIES: exchange.proxies = OKX_CCXT_PROXIES if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE: exchange.apiKey = OKX_API_KEY exchange.secret = OKX_API_SECRET exchange.password = OKX_API_PASSPHRASE exchange_options = ccxt.okx( { "enableRateLimit": True, "options": {"defaultType": "option"}, } ) if OKX_CCXT_PROXIES: exchange_options.proxies = OKX_CCXT_PROXIES if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE: exchange_options.apiKey = OKX_API_KEY exchange_options.secret = OKX_API_SECRET exchange_options.password = OKX_API_PASSPHRASE MARKETS_LOADED = False ACCOUNT_BALANCE_CACHE = { "updated_at": 0.0, "funding_usdt": None, "trading_usdt": None } LIQUIDITY_RANK_CACHE = { "updated_at": 0.0, "version": 0, "ranks": {}, "total": 0, } # 企业微信推送 def send_wechat_msg(content): send_wechat_webhook( WECHAT_WEBHOOK, content, timeout=WECHAT_TIMEOUT_SECONDS ) _BREAKEVEN_EXCHANGE_WARNED_IDS = set() def _send_breakeven_exchange_warn_once(order_id, message): """移动保本同步交易所失败:同一笔监控单只推送一次,避免轮询刷屏.""" oid = int(order_id) if oid in _BREAKEVEN_EXCHANGE_WARNED_IDS: return _BREAKEVEN_EXCHANGE_WARNED_IDS.add(oid) send_wechat_msg(message) def _clear_breakeven_exchange_warn(order_id): _BREAKEVEN_EXCHANGE_WARNED_IDS.discard(int(order_id)) def _wechat_account_label(): return (os.getenv("OKX_ACCOUNT_LABEL") or "okx实盘子账户").strip() def _wechat_direction_text(direction): d = (direction or "").lower() return "多头(long)" if d == "long" else "空头(short)" def _wechat_trading_capital_text(fallback=None): try: _, trading_capital = get_exchange_capitals(force=True) except Exception: trading_capital = None if trading_capital is not None: return f"{round(float(trading_capital), 2)}U" if fallback is not None: try: return f"{round(float(fallback), 2)}U" except Exception: pass return "-" def format_wechat_scalar_2dp(value): """企业微信推送:数值统一两位小数(与交易所 tick 无关).""" if value in (None, ""): return "-" try: return f"{float(value):.2f}" except (TypeError, ValueError): return str(value) def build_wechat_close_message( symbol, direction, result, pnl_amount, hold_seconds=None, trigger_price=None, current_price=None, stop_loss=None, take_profit=None, close_order_id=None, extra_note=None, session_capital_fallback=None, ): hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-" ep = format_price_for_symbol(symbol, trigger_price) cp = format_price_for_symbol(symbol, current_price) tp = format_price_for_symbol(symbol, take_profit) sl = format_wechat_scalar_2dp(stop_loss) cap_txt = _wechat_trading_capital_text(session_capital_fallback) try: if pnl_amount is not None: pv = float(pnl_amount) pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 2)} U" else: pnl_disp = "-" except (TypeError, ValueError): pnl_disp = "-" lines = [ f"📉 {symbol} 平仓完成", f"💼 账户:{_wechat_account_label()}", "", "🧾 平仓概要", f"🔖 平仓单号:{close_order_id or '-'}", f"📌 方向:{_wechat_direction_text(direction)}", f"📌 平仓结果:{result or '-'}", f"💰 本单盈亏:{pnl_disp}", f"⏱ 持仓时长:{hold_txt}", f"💵 交易账户资金:{cap_txt}", "", "🎯 价位(计划)", f"开仓成交价:{ep}", f"离场参考价:{cp}", f"止盈价位:{tp}", f"止损价位:{sl}", ] if extra_note: lines.extend(["", "📎 备注", extra_note]) return "\n".join(lines) def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): 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"- 新保护位:`{format_wechat_scalar_2dp(new_sl)}`", ] ) def build_wechat_monitor_error_message(symbol, direction, scene, error_text): return "\n".join( [ f"# ⚠️ {symbol} 下单监控异常", f"**账户:{_wechat_account_label()}**", "", "---", "", "### 异常信息", f"- 方向:**{_wechat_direction_text(direction)}**", f"- 场景:{scene}", f"- 错误:{str(error_text)}", ] ) def build_wechat_key_monitor_message( symbol, direction, monitor_type, trigger_time, key_price, confirm_close, hard_lines, btc8h_status, coin4h_status, swing4h_pct, op_lines, risk_tip=None, ): lines = [ f"# 🎯 {symbol} 关键位确认推送", f"**账户:{_wechat_account_label()}**", "", "---", "", "### 交易对 / 触发时间", f"- 交易对:**{symbol}**", f"- 触发时间:`{trigger_time}`", "", "### 方向与确认K", f"- 方向:**{_wechat_direction_text(direction)}**", "- 确认K:第二根5m收盘完成", "", "### 关键价位", f"- 类型:**{monitor_type}**", f"- 箱体关键位:`{key_price}`", f"- 第二根确认收盘价:`{confirm_close}`", "", "### 硬条件校验结果", ] lines.extend([f"- {x}" for x in hard_lines]) lines.extend( [ "", "### 市场状态说明", f"- BTC 8h 状态:**{btc8h_status}**", f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", "", "### 操作提示", ] ) lines.extend([f"- {x}" for x in op_lines]) if risk_tip: lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) return "\n".join(lines) def _read_image_base64(image_path): try: with open(image_path, "rb") as f: return base64.b64encode(f.read()).decode("utf-8") except Exception: return None def _extract_json_object(text): if not text: return None clean = text.strip() if clean.startswith("```"): clean = clean.replace("```json", "").replace("```", "").strip() try: return json.loads(clean) except Exception: pass match = re.search(r"\{[\s\S]*\}", clean) if not match: return None try: return json.loads(match.group(0)) except Exception: return None def _load_font(size): if not ImageFont: return None candidates = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "C:\\Windows\\Fonts\\msyh.ttc", "C:\\Windows\\Fonts\\arial.ttf", ] for path in candidates: if path and os.path.exists(path): try: return ImageFont.truetype(path, size) except Exception: continue try: return ImageFont.load_default() except Exception: return None def _ohlcv_to_rows(ohlcv): rows = [] for bar in ohlcv or []: if not bar or len(bar) < 6: continue try: rows.append( { "ts": int(bar[0]), "o": float(bar[1]), "h": float(bar[2]), "l": float(bar[3]), "c": float(bar[4]), "v": float(bar[5]), } ) except Exception: continue return rows def _local_input_datetime_to_ms(dt_text): raw = str(dt_text or "").strip() if not raw: return None raw = raw.replace("T", " ") for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): try: dt = datetime.strptime(raw, fmt) aware = dt.replace(tzinfo=APP_TZ) return int(aware.timestamp() * 1000) except Exception: continue return None def _marker_tag_label(tag): t = str(tag or "").strip().upper() if t == "ENTRY": return "开仓" if t == "EXIT": return "平仓" return str(tag or "") def _pick_marker_point(rows, target_ts_ms, target_price=None): if not rows or target_ts_ms is None: return None, None idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) if target_price is not None: try: p = float(target_price) if p > 0: return idx, p except Exception: pass return idx, float(rows[idx]["c"]) def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): if not Image or not ImageDraw: raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") img = Image.new("RGB", (width, height), bg_rgb) draw = ImageDraw.Draw(img) font = _load_font(14) small = _load_font(12) pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 plot_w = max(10, width - pad_l - pad_r) plot_h = max(10, height - pad_t - pad_b) header_bg = (245, 247, 250) draw.rectangle((0, 0, width, pad_t), fill=header_bg) if font: draw.text((10, 6), title, fill=(25, 35, 60), font=font) else: draw.text((10, 6), title, fill=(25, 35, 60)) if not rows: if small: draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) else: draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) return img lo = min(r["l"] for r in rows) hi = max(r["h"] for r in rows) if hi <= lo: hi = lo + 1e-12 n = len(rows) marker_by_idx = {} for mp in marker_points or []: try: idx = int(mp.get("idx")) except Exception: continue if idx < 0 or idx >= n: continue marker_by_idx.setdefault(idx, []).append(mp) x0 = pad_l for i, r in enumerate(rows): x1 = pad_l + int((i + 1) * plot_w / n) x_mid = (x0 + x1) // 2 wick_x = x_mid y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) top = min(y_open, y_close) bot = max(y_open, y_close) up = r["c"] >= r["o"] wick_color = (120, 120, 120) edge_color = (20, 20, 20) draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) body_w = max(1, (x1 - x0) - 2) left = x0 + 1 if bot - top < 2: mid = (top + bot) // 2 draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) else: if up: draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) else: draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) for j, mp in enumerate(marker_by_idx.get(i, [])): tag = str(mp.get("tag") or "") label = _marker_tag_label(tag) m_price = float(mp.get("price") or r["c"]) y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14 x_draw = int(x_mid + x_off) if tag == "ENTRY": m_color = (0, 195, 95) tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)] text_y = y_m - 36 else: m_color = (235, 65, 65) tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)] text_y = y_m + 12 draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) draw.polygon(tri, fill=m_color) draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) if font: draw.text((x_draw + 8, text_y), label, fill=m_color, font=font) else: draw.text((x_draw + 8, text_y), label, fill=m_color) x0 = x1 if len(marker_points or []) >= 2: try: entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) if entry is not None and exitp is not None: ex_i, ex_p = int(entry["idx"]), float(entry["price"]) xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) except Exception: pass # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 if small: draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) return img def _timeframe_period_ms(tf): s = (tf or "").strip().lower() if s.endswith("m"): try: return int(s[:-1]) * 60 * 1000 except ValueError: pass if s.endswith("h"): try: return int(s[:-1]) * 3600 * 1000 except ValueError: pass if s.endswith("d"): try: return int(s[:-1]) * 86400 * 1000 except ValueError: pass return 300000 def _ohlcv_dict_rows_to_lists(rows, lim): if not rows: return [] pick = rows[-lim:] if len(rows) >= lim else rows return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick] def _okx_swap_inst_id(exchange_symbol): """ETH/USDT:USDT → ETH-USDT-SWAP(公开 candles 用).""" sym = str(exchange_symbol or "").strip().upper() if not sym: return "" if "/" in sym: base, rest = sym.split("/", 1) quote = rest.split(":", 1)[0].strip() or "USDT" return f"{base.strip()}-{quote}-SWAP" if sym.endswith("-SWAP"): return sym if "-" in sym: return sym if sym.endswith("USDT") and len(sym) > 4: return f"{sym[:-4]}-USDT-SWAP" return sym def _okx_public_bar(timeframe): """ccxt 周期 → OKX public bar(1H/4H 大写 H).""" s = str(timeframe or "").strip() if not s: return "15m" low = s.lower() if low.endswith("m") and low[:-1].isdigit(): return low if low.endswith("h") and low[:-1].isdigit(): return f"{low[:-1]}H" if low.endswith("d") and low[:-1].isdigit(): return f"{low[:-1]}D" return s def _fetch_ohlcv_public(exchange_symbol, timeframe, limit=100, since_ms=None, end_ts_ms=None): """无 API Key 也可拉永续 K 线:OKX public_get_market_candles.""" inst = _okx_swap_inst_id(exchange_symbol) bar = _okx_public_bar(timeframe) lim = max(2, min(int(limit or 100), 300)) if not inst or not hasattr(exchange, "public_get_market_candles"): return [] params = {"instId": inst, "bar": bar, "limit": str(lim)} # after=ts → 早于 ts 的更旧数据,适合「截止到平仓」 if end_ts_ms: try: params["after"] = str(int(end_ts_ms)) except (TypeError, ValueError): pass try: raw = (exchange.public_get_market_candles(params) or {}).get("data") or [] except Exception: return [] out = [] for row in reversed(list(raw or [])): if not row or len(row) < 5: continue try: ts = int(row[0]) o = float(row[1]) h = float(row[2]) l = float(row[3]) c = float(row[4]) v = float(row[5]) if len(row) > 5 else 0.0 except (TypeError, ValueError, IndexError): continue if since_ms is not None: try: if ts < int(since_ms): continue except (TypeError, ValueError): pass if end_ts_ms is not None: try: if ts > int(end_ts_ms): continue except (TypeError, ValueError): pass out.append([ts, o, h, l, c, v]) if len(out) > lim: return out[-lim:] return out def _fetch_ohlcv_safe(exchange_symbol, timeframe, limit=100, since_ms=None, end_ts_ms=None): """公开接口优先(模拟盘无密钥);失败再试 ccxt.fetch_ohlcv.""" pub = _fetch_ohlcv_public( exchange_symbol, timeframe, limit=limit, since_ms=since_ms, end_ts_ms=end_ts_ms, ) if pub: return pub try: kwargs = {"timeframe": timeframe, "limit": int(limit or 100)} if since_ms: kwargs["since"] = max(0, int(since_ms)) return list(exchange.fetch_ohlcv(exchange_symbol, **kwargs) or []) except Exception: return [] def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): lim = max(2, int(limit or ORDER_CHART_LIMIT)) ohlcv = _fetch_ohlcv_safe( exchange_symbol, timeframe, limit=lim + 20, end_ts_ms=end_ts_ms, ) if not ohlcv and end_ts_ms: ohlcv = _fetch_ohlcv_safe(exchange_symbol, timeframe, limit=lim) rows = _ohlcv_to_rows(ohlcv) if not rows: return [] if not end_ts_ms: return _ohlcv_dict_rows_to_lists(rows, lim) filtered = [r for r in rows if int(r["ts"]) <= int(end_ts_ms)] if len(filtered) >= 2: return _ohlcv_dict_rows_to_lists(filtered, lim) return _ohlcv_dict_rows_to_lists(rows, lim) def generate_multi_timeframe_chart_png( exchange_symbol, title_prefix, timeframes=None, limit=None, out_dir=None, filename=None, filename_prefix="chart", marker_payload=None, marker_timeframes=None, layout="grid", ): if not ORDER_CHART_ENABLED: return None if not Image: return None requested = list(timeframes or ORDER_CHART_TFS) limit = limit or ORDER_CHART_LIMIT if layout == "vertical": timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2] else: preferred_layout = ["5m", "15m", "1h", "4h"] requested_set = set(requested or []) ordered = [tf for tf in preferred_layout if tf in requested_set] for tf in requested: if tf not in ordered: ordered.append(tf) timeframes = ordered[:4] if ordered else preferred_layout try: ensure_markets_loaded() except Exception: # 模拟盘无 API Key 时 load_markets 可能失败;公开 candles 不依赖 markets pass panels = [] cell_w, cell_h = 980, 520 end_ts_ms = None if marker_payload: try: end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None except (TypeError, ValueError): end_ts_ms = None default_marker_tfs = {str(t).strip().lower() for t in timeframes} price_levels = price_levels_from_marker_payload(marker_payload) for tf in timeframes: rows = [] try: if layout == "vertical" and marker_payload: win = trade_review_fetch_window( marker_payload.get("entry_ts_ms"), marker_payload.get("exit_ts_ms"), tf, limit, anchor=marker_payload.get("chart_anchor"), now_ms=marker_payload.get("now_ts_ms"), ) if win: ohlcv = _fetch_ohlcv_safe( exchange_symbol, tf, limit=int(win["fetch_limit"]), since_ms=max(0, int(win["since_ms"])), end_ts_ms=end_ts_ms, ) rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win) if not rows: ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) if not ohlcv and end_ts_ms: ohlcv = _fetch_ohlcv_safe(exchange_symbol, tf, limit=limit) rows = _ohlcv_to_rows(ohlcv)[-limit:] except Exception: rows = [] title = f"{title_prefix} | {tf} x{len(rows)}" tf_key = str(tf).strip().lower() if marker_payload: if marker_timeframes: marker_tfs = {str(x).strip().lower() for x in marker_timeframes if str(x).strip()} else: marker_tfs = default_marker_tfs else: marker_tfs = set() points = ( marker_points_for_timeframe(rows, marker_payload) if marker_payload and tf_key in marker_tfs else [] ) panels.append( render_candles_subplot( rows, title, width=cell_w, height=cell_h, bg_rgb=(255, 255, 255), marker_points=points, price_levels=price_levels, ) ) if not panels: return None out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10) if out is None: return None target_dir = out_dir or ORDER_CHART_DIR os.makedirs(target_dir, exist_ok=True) fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" out_path = os.path.join(target_dir, fname) out.save(out_path, format="PNG") return fname def generate_order_open_chart( exchange_symbol, title_prefix, timeframes=None, limit=None, opened_at_ms=None, entry_price=None, ): marker_payload = None if opened_at_ms: marker_payload = { "entry_ts_ms": opened_at_ms, "exit_ts_ms": None, "entry_price": entry_price, "exit_price": None, } marker_tfs = ( {x.strip().lower() for x in (timeframes or ORDER_CHART_TFS) if x and str(x).strip()} or {"5m", "15m", "1h", "4h"} ) return generate_multi_timeframe_chart_png( exchange_symbol, title_prefix, timeframes=timeframes, limit=limit, out_dir=ORDER_CHART_DIR, filename=None, filename_prefix="order", marker_payload=marker_payload, marker_timeframes=marker_tfs, ) def journal_coin_from_symbol(symbol): sym = (symbol or "").strip().upper() if not sym: return "" if "/" in sym: return sym.split("/")[0].strip() if "-" in sym: return sym.split("-")[0].strip() if sym.endswith("USDT"): return sym[:-4].strip() return sym EARLY_EXIT_TRIGGERS = ( "", "止盈", "保本止盈", "移动止盈", TIME_CLOSE_RESULT, "强制清仓", "手动平仓", "止损", "其他", ) # 趋势户:复盘开仓类型仅 entry model;策略/风格项已拆至下单类型 ENTRY_REASON_OPTIONS = build_journal_entry_reason_options() STATS_SEGMENT_DEFS = ( ("all", "全部交易", {"segment": "all"}), ("manual", "下单监控", {"segment": "manual"}), ("key", "关键位监控", {"segment": "key"}), ) def normalize_entry_reason(raw, custom_text=None): del custom_text return normalize_journal_entry_reason(raw, ENTRY_REASON_OPTIONS, allow_legacy=True) 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 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( """CREATE TABLE IF NOT EXISTS app_runtime_settings (key TEXT PRIMARY KEY, value TEXT, updated_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("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 for ddl in ( "ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL", "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT", "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT", "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT", "ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL", "ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL", ): try: c.execute(ddl) except Exception: pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") except: pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") except: pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") except: pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") except: pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN images_json TEXT") except: pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN order_type TEXT") except: pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN direction 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 try: c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '下单监控'") except: pass try: c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") except: pass try: c.execute("ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT") except: pass for ddl in ( "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", "ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'", "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", "ALTER TABLE key_monitors ADD COLUMN session_date TEXT", ): try: c.execute(ddl) except Exception: pass ensure_time_close_schema(c) ensure_key_monitor_schema(c) try: c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") 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)""" ) from lib.options.options_db import init_options_tables init_options_tables(conn) from lib.instance.instance_cash_sample_lib import init_cash_sample_table init_cash_sample_table(conn) from lib.sim.db_lib import init_sim_tables init_sim_tables(conn) from lib.trade.account_risk_lib import ensure_account_risk_schema ensure_account_risk_schema(conn) migrate_entry_model_columns(conn) backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO) conn.commit() conn.close() init_db() def _purge_key_monitors_if_full_margin(): return def get_db(): conn = sqlite3.connect(DB_PATH, timeout=30) conn.row_factory = sqlite3.Row try: conn.execute("PRAGMA busy_timeout=30000") # WAL:读(中控快照)与写(卖币/同步)可并行,避免 database is locked→全部超时离线 conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") except Exception: pass return conn def account_risk_status(conn): from lib.trade.account_risk_lib import ( apply_position_limit_risk, compute_account_risk_status, enrich_risk_status_countdown, ensure_account_risk_schema, ) now = app_now() try: ensure_account_risk_schema(conn) st = compute_account_risk_status( conn, trading_day=get_trading_day(), now=now, fmt_local_ms=ms_to_app_local_str, ) st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) from lib.trade.trade_labels_lib import count_position_limit_active_monitors return apply_position_limit_risk( st, count_position_limit_active_monitors(conn), max_active_positions=MAX_ACTIVE_POSITIONS, ) except Exception as e: print(f"[account_risk_status] {e}", flush=True) return { "enabled": True, "status": "normal", "status_label": "正常", "can_trade": True, "reason": "", "cooloff_until_ms": None, "cooloff_until": None, "manual_close_count": 0, "daily_loss_count": 0, "daily_frozen": False, "freeze_remaining_sec": 0, } def hub_user_initiated_close( conn, *, source, count=1, trade_record_id=None, closed_at_ms=None, ): from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close src = (source or "").strip() or CLOSE_SOURCE_USER_HUB on_user_initiated_close( conn, source=src, trade_record_id=trade_record_id, closed_at_ms=closed_at_ms, trading_day=get_trading_day(), now=app_now(), count=count, ) 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 = ( "止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓", "外部平仓", TIME_CLOSE_RESULT, ) REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓", TIME_CLOSE_RESULT) def parse_dt_for_trading_day(s): if not s: return None s = str(s).strip().replace("Z", "").replace("T", " ") if not s: return None for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): try: return datetime.strptime(s[:ln], fmt) except ValueError: continue return None def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): conn.execute( """INSERT INTO key_monitor_history (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) VALUES (?,?,?,?,?,?,?,?,?)""", ( row["symbol"], row["monitor_type"], row["direction"] or "long", row["upper"], row["lower"], int(notification_count or 0), (last_msg or "")[:800] if last_msg else None, close_reason, app_now_str(), ), ) def _session_week_bounds(trading_day_str): end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() start = end - timedelta(days=6) return start.strftime("%Y-%m-%d"), trading_day_str def _calendar_month_bounds(local_dt): y, m = local_dt.year, local_dt.month start = f"{y:04d}-{m:02d}-01" if m == 12: end_d = datetime(y, 12, 31).date() else: end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() return start, end_d.strftime("%Y-%m-%d") def _count_opens_between(conn, start_td, end_td): return _count_opens_for_segment(conn, start_td, end_td, "all") def _list_window_from_request(): return resolve_list_window(request.args, session, default_preset=PRESET_DEFAULT) def _redirect_records(): qs = list_window_redirect_query(session) return redirect(f"/records?{qs}" if qs else "/records") def _pnl_row_matches_segment(row, segment_key): try: mt = (row["monitor_type"] or "").strip() kst = (row["key_signal_type"] or "").strip() except Exception: return False if segment_key == "all": return True if segment_key == "manual": return mt == ORDER_MONITOR_TYPE_MANUAL and not kst if segment_key == "key": return mt == ORDER_MONITOR_TYPE_KEY_AUTO or bool(kst) or "关键位" in mt return False def _count_opens_for_segment(conn, start_td, end_td, segment_key): if segment_key == "manual": return conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), ).fetchone()[0] if segment_key == "key": return conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " "AND (monitor_type=? OR (key_signal_type IS NOT NULL AND TRIM(key_signal_type)!=''))", (start_td, end_td, ORDER_MONITOR_TYPE_KEY_AUTO), ).fetchone()[0] return conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", (start_td, end_td), ).fetchone()[0] def _load_completed_trade_pnls(conn): q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, result, reviewed_result, monitor_type, key_signal_type FROM trade_records ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" rows = conn.execute(q).fetchall() out = [] for r in rows: effective_result = (r["reviewed_result"] or r["result"] or "").strip() if effective_result not in TRADE_COMPLETED_RESULTS: continue try: p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) except (TypeError, ValueError): p = 0.0 t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) td = get_trading_day(t) if t else None out.append((p, t, td, r)) return out def _compute_period_metrics(trades): """trades: list of (pnl, close_dt, close_trading_day)""" trades = [(p, t, td) for p, t, td in trades if t is not None] trades.sort(key=lambda x: x[1]) closed = len(trades) wins = sum(1 for p, _, _ in trades if p > 0) losses = sum(1 for p, _, _ in trades if p < 0) net = round(sum(p for p, _, _ in trades), 4) loss_sum_raw = sum(p for p, _, _ in trades if p < 0) loss_sum_u = round(abs(loss_sum_raw), 4) if loss_sum_raw < 0 else 0.0 neg_pnls = [p for p, _, _ in trades if p < 0] pos_pnls = [p for p, _, _ in trades if p > 0] max_single_loss = round(min(neg_pnls), 4) if neg_pnls else None max_single_profit = round(max(pos_pnls), 4) if pos_pnls else None cum = peak = max_dd = 0.0 for p, _, _ in trades: cum += p peak = max(peak, cum) max_dd = max(max_dd, peak - cum) max_dd = round(max_dd, 4) streak = 0 for p, _, _ in reversed(trades): if p < 0: streak += 1 else: break daily = {} for p, _, td in trades: if td: daily[td] = daily.get(td, 0.0) + p max_loss_streak_days = 0 worst_day = None worst_day_pnl = None if daily: sorted_days = sorted(daily.keys()) run = 0 for d in sorted_days: if daily[d] < 0: run += 1 max_loss_streak_days = max(max_loss_streak_days, run) else: run = 0 worst_day = min(daily.keys(), key=lambda x: daily[x]) worst_day_pnl = round(daily[worst_day], 4) win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None return { "closed_count": closed, "win_count": wins, "loss_count": losses, "win_rate_pct": win_rate_pct, "net_pnl_u": net, "loss_sum_u": loss_sum_u, "max_single_loss": max_single_loss, "max_single_profit": max_single_profit, "max_drawdown_u": max_dd, "consecutive_losses": streak, "max_loss_streak_days": max_loss_streak_days, "worst_day": worst_day, "worst_day_pnl": worst_day_pnl, "opens_count": 0, "range_label": "", } def _bounds_for_month_key(ym): """ym: YYYY-MM → 该自然月首末日(北京日历).""" y, m = [int(x) for x in str(ym).split("-", 1)] start = f"{y:04d}-{m:02d}-01" if m == 12: end = f"{y:04d}-12-31" else: end = (datetime(y, m + 1, 1) - timedelta(days=1)).date().strftime("%Y-%m-%d") return start, end def _build_monthly_stats_rows(conn, all_tr, seg_key): """按北京交易日所在自然月聚合;新月在前.""" by_month = {} for p, t, td in all_tr: if not td or len(str(td)) < 7: continue mk = str(td)[:7] by_month.setdefault(mk, []).append((p, t, td)) rows = [] for mk in sorted(by_month.keys(), reverse=True): metrics = _compute_period_metrics(by_month[mk]) ms, me = _bounds_for_month_key(mk) metrics["opens_count"] = _count_opens_for_segment(conn, ms, me, seg_key) metrics["range_label"] = f"{ms} ~ {me}" metrics["month_key"] = mk rows.append(metrics) return rows def compute_stats_bundle(conn, trading_day, now_dt=None): """日 / 周 / 月 / 全部 统计:平仓按北京时间交易日(默认 8:00 切日)计入.""" now_dt = now_dt or app_now() pnls = _load_completed_trade_pnls(conn) total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] w_start, w_end = _session_week_bounds(trading_day) m_start, m_end = _calendar_month_bounds(now_dt) def slice_metrics(seg_key): seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)] day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] all_tr = [(p, t, td) for p, t, td, _r in seg_rows if t] dm = _compute_period_metrics(day_tr) wm = _compute_period_metrics(week_tr) mm = _compute_period_metrics(month_tr) am = _compute_period_metrics(all_tr) dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) am["opens_count"] = _count_opens_for_segment(conn, "1970-01-01", "9999-12-31", seg_key) dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" tds = [td for _, _, td in all_tr if td] if tds: am["range_label"] = f"全部历史 {min(tds)} ~ {max(tds)}(北京交易日)" else: am["range_label"] = "全部历史(暂无平仓)" am["monthly_rows"] = _build_monthly_stats_rows(conn, all_tr, seg_key) return dm, wm, mm, am segments = [] seg_defs = STATS_SEGMENT_DEFS for seg_key, seg_title, _meta in seg_defs: dm, wm, mm, am = slice_metrics(seg_key) segments.append( {"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm, "all": am} ) dm, wm, mm, am = slice_metrics("all") return { "trading_day": trading_day, "total_opens_all": total_opens_all, "day": dm, "week": wm, "month": mm, "all": am, "segments": segments, "stats_reset_hour": TRADING_DAY_RESET_HOUR, } def infer_leverage(symbol): sym = (symbol or "").strip().upper() if sym.startswith("BTC") or sym.startswith("ETH"): return BTC_LEVERAGE return ALT_LEVERAGE def normalize_okx_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): 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_okx_symbol(raw) if raw else "" def round_price_to_exchange(exchange_symbol, price): if price in (None, ""): return None try: v = float(price) except (TypeError, ValueError): return None if not exchange_symbol: return v try: ensure_markets_loaded() return float(exchange.price_to_precision(exchange_symbol, v)) except Exception: return v 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 validate_trade_policy_open(symbol, direction): return check_open_policy( TRADE_POLICY, symbol, direction, normalize_symbol_input ) def normalize_kline_limit(limit_raw, default=200): try: n = int(limit_raw) except Exception: return default return 200 if n >= 200 else 100 def get_recommended_capital(current_capital): if current_capital <= DAILY_LOSS_CAPITAL: return DAILY_LOSS_CAPITAL if current_capital >= DAILY_PROFIT_CAPITAL: return DAILY_PROFIT_CAPITAL return DAILY_START_CAPITAL def ensure_session(conn, session_date): row = conn.execute( "SELECT * FROM trading_sessions WHERE session_date = ?", (session_date,) ).fetchone() if row: return row conn.execute( "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) ) conn.commit() return conn.execute( "SELECT * FROM trading_sessions WHERE session_date = ?", (session_date,) ).fetchone() def update_session_capital(conn, session_date, pnl_amount): session_row = ensure_session(conn, session_date) new_capital = float(session_row["current_capital"]) + float(pnl_amount) conn.execute( "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", (round(new_capital, 4), session_date) ) conn.commit() return round(new_capital, 4) def calc_hold_seconds(opened_at_str, closed_at_dt): try: opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") return int((closed_at_dt - opened_at).total_seconds()) except Exception: return 0 def calc_hold_minutes(seconds): if not seconds or seconds <= 0: return 0 return max(1, int(seconds // 60)) def get_opened_at_value(row): try: keys = row.keys() if hasattr(row, "keys") else [] except Exception: keys = [] if "opened_at" in keys: value = row["opened_at"] if value: return value return app_now_str() def get_effective_trade_field(row, reviewed_key, base_key, default=None): try: keys = row.keys() if hasattr(row, "keys") else row.keys() except Exception: keys = [] if reviewed_key in keys: v = row[reviewed_key] if v is not None and str(v).strip() != "": return v if base_key in keys: v = row[base_key] if v is not None and str(v).strip() != "": return v return default def to_effective_trade_dict(row): item = row_to_dict(row) from lib.trade.trade_result_lib import snapshot_stop_loss open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss")) item["display_open_stop_loss"] = open_stop 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", open_stop) item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason")) item["effective_pnl_amount"] = get_effective_trade_field(row, "reviewed_pnl_amount", "pnl_amount", item.get("pnl_amount")) item["effective_hold_minutes"] = get_effective_trade_field(row, "reviewed_hold_minutes", "hold_minutes", item.get("hold_minutes")) item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds")) try: _er_keys = row.keys() if hasattr(row, "keys") else [] except Exception: _er_keys = [] reviewed_er = row["reviewed_entry_reason"] if "reviewed_entry_reason" in _er_keys else None item["effective_entry_reason"] = resolve_effective_trade_entry_reason( reviewed_entry_reason=reviewed_er, entry_reason=item.get("entry_reason"), entry_model=item.get("entry_model"), key_signal_type=(item.get("key_signal_type") or "").strip() or None, monitor_type=item.get("monitor_type"), trade_style=item.get("trade_style"), entry_reason_from_key_signal=entry_reason_from_key_signal, entry_reason_for_monitor_type=entry_reason_for_monitor_type, ) 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" item["effective_result"] = normalize_result_with_pnl( item.get("effective_result"), item.get("effective_pnl_amount"), ) item["effective_result"] = apply_force_close_display_result( item.get("effective_result"), item.get("effective_closed_at"), enabled=FORCE_CLOSE_ENABLED, bj_hour=FORCE_CLOSE_BJ_HOUR, ) return item def format_price_for_symbol(symbol, value): """价格展示:与交易所 price_to_precision 一致(与入库 round_price_to_exchange 对齐).""" if value in (None, ""): return "-" try: v = float(value) except Exception: return str(value) if v == 0: return "0" try: ex_sym = normalize_okx_symbol(str(symbol or "").strip()) if symbol else "" if ex_sym: ensure_markets_loaded() 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 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 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): """估算净盈亏(USDT):价差毛利 − 双边 taker 费(默认各 0.05%).""" 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 notional = margin * lev gross = notional * pnl_ratio return round(gross, 4) except Exception: return 0.0 def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): """ 计划盈亏比 = 盈利空间 / 亏损空间(展示为 X:1,即 reward:risk). 做多:止损须低于入场,止盈须高于入场;做空相反. """ 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 active_sl_tp_for_rr(stop_loss, initial_stop_loss, take_profit): """展示/校验用:优先当前 stop_loss(委托改价后),否则回落 initial_stop_loss.""" sl = stop_loss if stop_loss not in (None, "") else initial_stop_loss return sl, take_profit def calc_planned_rr_ratio(direction, entry_price, stop_loss, initial_stop_loss, take_profit): sl, tp = active_sl_tp_for_rr(stop_loss, initial_stop_loss, take_profit) return calc_rr_ratio(direction, entry_price, sl, tp) def calc_risk_fraction(direction, entry_price, stop_loss): try: entry = float(entry_price) sl = float(stop_loss) if entry <= 0 or sl <= 0: return None if direction == "short": risk = sl - entry else: risk = entry - sl if risk <= 0: return None return risk / entry except Exception: return None def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital, leverage): rf = calc_risk_fraction(direction, entry_price, stop_loss) if rf is None: return None try: notional = float(margin_capital) * float(leverage) if notional <= 0: return None return round(notional * rf, 6) except Exception: return None def calc_actual_rr(pnl_amount, risk_amount): try: r = float(risk_amount or 0) if r <= 0: return None return round(float(pnl_amount or 0) / r, 2) except Exception: return None def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): """ 按“已锁定R”计算目标止损位: - long: entry + locked_r * (entry*risk_fraction) + offset - short: entry - locked_r * (entry*risk_fraction) - offset """ try: entry = float(entry_price) rf = float(risk_fraction) lr = float(locked_r) off = float(offset_pct) / 100.0 if entry <= 0 or rf <= 0 or lr < 0: return None base_move = entry * rf * lr offset_move = entry * off if direction == "short": return round(entry - base_move - offset_move, 8) return round(entry + base_move + offset_move, 8) except Exception: return None def insert_trade_record( conn, symbol, monitor_type, direction, trigger_price, stop_loss, initial_stop_loss=None, take_profit=None, margin_capital=None, leverage=None, pnl_amount=0, hold_seconds=0, trade_style=None, risk_amount=None, planned_rr=None, actual_rr=None, result="", miss_reason=None, opened_at=None, opened_at_ms=None, closed_at=None, closed_at_ms=None, exchange_trade_id=None, key_signal_type=None, entry_reason=None, entry_model=None, trend_plan_id=None, exchange_symbol=None, attach_exchange_stats=True, ): hold_minutes = calc_hold_minutes(hold_seconds) open_ts = opened_at or app_now_str() close_ts = closed_at or app_now_str() open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) from lib.trade.trade_result_lib import snapshot_stop_loss snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss) er = resolve_trade_record_entry_reason( entry_reason=entry_reason, entry_model=entry_model, key_signal_type=kst, monitor_type=monitor_type, trade_style=trade_style, entry_reason_from_key_signal=entry_reason_from_key_signal, entry_reason_for_monitor_type=entry_reason_for_monitor_type, ) cur = conn.execute( "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit, margin_capital, leverage, pnl_amount, hold_seconds, trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None, trend_plan_id, ) ) tid = int(cur.lastrowid or 0) # 中控只拉 /api/trade_records,平仓当下也尝试回填交易所盈亏(内部 25s 节流) try: sync_trade_records_from_exchange(conn, force=False) except Exception: pass try: from lib.trade.account_risk_lib import on_closed_trade_pnl close_dt = parse_dt_for_trading_day(close_ts) on_closed_trade_pnl( conn, pnl_amount=pnl_amount, trading_day=get_trading_day(close_dt), ) except Exception: pass return tid 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 ensure_okx_live_ready(): if not LIVE_TRADING_ENABLED: return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): return False, "缺少 OKX API 密钥配置" return True, "" def order_row_monitor_type(row): return order_monitor_source_type(row, default_manual=ORDER_MONITOR_TYPE_MANUAL) def trade_record_monitor_type(conn, row): return resolve_trade_record_monitor_type( conn, row, default_manual=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 key_signal_type_for_trade_record(kst, KEY_MONITOR_AUTO_TYPES): return key_signal_type_for_trade_record(kst, KEY_MONITOR_AUTO_TYPES) return None 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 = 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 _fetch_usdt_by_types(type_candidates): for t in type_candidates: try: bal = exchange.fetch_balance(params={"type": t}) val = _extract_usdt_total(bal) if val is not None: return val except Exception: continue return None def get_available_trading_usdt(): ok_live, _ = ensure_okx_live_ready() if not ok_live: return None for t in ["swap", "trading", "spot"]: try: bal = exchange.fetch_balance(params={"type": t}) free_val = _extract_usdt_free(bal) if free_val is not None: return free_val except Exception: continue return None def get_synced_leverage(exchange_symbol, direction): try: ensure_markets_loaded() except Exception: return None # 1) 优先读取交易所杠杆配置 try: if hasattr(exchange, "fetch_leverage"): lev = exchange.fetch_leverage(exchange_symbol, params={"mgnMode": OKX_TD_MODE}) long_lev = lev.get("longLeverage") or lev.get("long") short_lev = lev.get("shortLeverage") or lev.get("short") base_lev = lev.get("leverage") if direction == "long" and long_lev: return int(float(long_lev)) if direction == "short" and short_lev: return int(float(short_lev)) if base_lev: return int(float(base_lev)) except Exception: pass # 2) 从当前仓位里兜底读取 try: positions = exchange.fetch_positions([exchange_symbol], params={"instType": "SWAP"}) for p in positions: if p.get("symbol") != exchange_symbol: continue info = p.get("info", {}) or {} side = (p.get("side") or info.get("posSide") or "").lower() if OKX_POS_MODE == "hedge" and side and side != direction: continue lev = p.get("leverage") or info.get("lever") if lev: return int(float(lev)) except Exception: pass return None def friendly_okx_error(err, available_usdt=None): msg = str(err) if "51008" in msg or "Insufficient USDT margin" in msg: tail = f"(当前交易账户可用约 {round(available_usdt, 4)}U)" if available_usdt is not None else "" return f"交易所下单失败:保证金不足 {tail}.请降低保证金/杠杆,或先划转USDT到交易账户." clean = re.sub(r"\s+", " ", msg).strip() return f"交易所下单失败:{clean}" friendly_exchange_error = friendly_okx_error def invalidate_account_balance_cache() -> None: ACCOUNT_BALANCE_CACHE["updated_at"] = 0 ACCOUNT_BALANCE_CACHE["funding_usdt"] = None ACCOUNT_BALANCE_CACHE["trading_usdt"] = None def get_exchange_capitals(force=False): ok_live, _ = ensure_okx_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: funding = _fetch_usdt_by_types(["funding"]) trading = _fetch_usdt_by_types(["swap", "trading", "spot"]) # 显式写入(含 0 / None),避免模式切换后残留旧缓存 ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts except Exception: # 拉取失败不沿用旧值 ACCOUNT_BALANCE_CACHE["funding_usdt"] = None ACCOUNT_BALANCE_CACHE["trading_usdt"] = None ACCOUNT_BALANCE_CACHE["updated_at"] = 0 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_okx_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: return False, str(e), None def get_account_usdt_total(account_type): try: bal = exchange.fetch_balance(params={"type": account_type}) return _extract_usdt_total(bal) except Exception: return None def get_trading_day_reset_open_guard_enabled(conn=None): """True=启用整点限制(默认 8:00 前禁止新开仓/登记监控).""" owns = conn is None if owns: conn = get_db() try: row = conn.execute( "SELECT value FROM app_runtime_settings WHERE key=?", (RUNTIME_KEY_OPEN_GUARD,), ).fetchone() if row is not None: return str(row[0]).lower() in ("1", "true", "yes", "on") except Exception: pass finally: if owns: conn.close() return TRADING_DAY_RESET_OPEN_GUARD_ENABLED def set_trading_day_reset_open_guard_enabled(enabled: bool, conn=None): owns = conn is None if owns: conn = get_db() try: conn.execute( "INSERT INTO app_runtime_settings(key, value, updated_at) VALUES (?,?,?) " "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", (RUNTIME_KEY_OPEN_GUARD, "1" if enabled else "0", app_now_str()), ) if owns: conn.commit() finally: if owns: conn.close() def trading_day_reset_allows_new_open(now, conn=None): if not get_trading_day_reset_open_guard_enabled(conn): return True return now.hour >= TRADING_DAY_RESET_HOUR 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): try: ensure_markets_loaded() market = exchange.market(normalize_okx_symbol(exchange_symbol)) return float(market.get("contractSize") or 1) except Exception: return 1.0 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_okx_order_params(direction, reduce_only=False): params = {"tdMode": OKX_TD_MODE} if OKX_POS_MODE == "hedge": params["posSide"] = "long" if direction == "long" else "short" if reduce_only: params["reduceOnly"] = True return params def ensure_markets_loaded(force=False): global MARKETS_LOADED if force or not MARKETS_LOADED: exchange.load_markets(reload=force) MARKETS_LOADED = True def _okx_algo_trigger_price_str(exchange_symbol, price): """OKX attachAlgoOrds 触发价须为按合约 tick 格式化的十进制字符串;直接用 str(float) 低价币会得到科学计数法(如 8.5e-06),会报 tpTriggerPx/slTriggerPx 参数错误.""" ensure_markets_loaded() return exchange.price_to_precision(exchange_symbol, float(price)) def exchange_private_api_configured(): return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE) def _position_row_effective_contracts(p): """张数:OKX 以 info.pos 为准,再兜底 ccxt contracts 等(与 Binance/Gate 多字段一致).""" from lib.market.position_metrics_lib import normalize_contracts_qty if not p: return 0.0 info = p.get("info", {}) or {} for val in (info.get("pos"), p.get("contracts"), info.get("positionAmt"), info.get("size")): if val is None or val == "": continue try: x = abs(float(val)) if x > 0: return normalize_contracts_qty(x) except (TypeError, ValueError): continue return 0.0 def _position_matches_wanted_contract(exchange_symbol, position): if not position: return False sym = position.get("symbol") if sym == exchange_symbol: return True try: if normalize_okx_symbol(sym or "") == normalize_okx_symbol(exchange_symbol or ""): return True except Exception: pass info = position.get("info") or {} inst = (info.get("instId") or "").strip().upper() if not inst: return False try: ensure_markets_loaded() want = exchange.market(exchange_symbol) mid = (want.get("id") or "").strip().upper() if mid and inst == mid: return True base = (want.get("base") or "").strip().upper() quote = (want.get("quote") or "").strip().upper() if base and quote and inst == f"{base}-{quote}-SWAP": return True except Exception: pass return False def _okx_position_direction(position): info = position.get("info") or {} side = (position.get("side") or info.get("posSide") or "").strip().lower() if side in ("long", "short"): return side try: raw = float(info.get("pos") or position.get("contracts") or 0) except (TypeError, ValueError): raw = 0.0 if raw > 0: return "long" if raw < 0: return "short" return "" def _fetch_okx_swap_position_rows(): """OKX 单合约 fetch_positions([sym]) 常返回空;与 /api/prices 一致拉全量 SWAP 再本地匹配.""" ensure_markets_loaded() rows = None for fetcher in ( lambda: exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}), lambda: exchange.fetch_positions(), ): try: rows = fetcher() or [] break except Exception: continue if rows is None: return None return rows def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): exchange_symbol = normalize_okx_symbol(exchange_symbol or "") if not rows: return None candidates = [] for p in rows: if not _position_matches_wanted_contract(exchange_symbol, p): continue info = p.get("info", {}) or {} side = (p.get("side") or info.get("posSide") or "").lower() contracts = _position_row_effective_contracts(p) if contracts <= 0: continue want_dir = (direction or "").lower() if OKX_POS_MODE == "net" or side == "net": pos_dir = _okx_position_direction(p) if pos_dir and pos_dir != want_dir: continue elif (not relax_hedge) and OKX_POS_MODE == "hedge": if side and side != want_dir: continue candidates.append((contracts, p)) if not candidates and (not relax_hedge) and OKX_POS_MODE == "hedge": return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) if not candidates: return None candidates.sort(key=lambda x: x[0], reverse=True) return candidates[0][1] def parse_ccxt_position_metrics(position, order_leverage=None): if not position: return None p = position info = p.get("info", {}) or {} initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin")) if initial is None or initial <= 0: initial = _coerce_float( info.get("margin"), info.get("imr"), info.get("initial_margin"), ) notional = _coerce_float(p.get("notional"), p.get("notionalValue")) if notional is None or notional <= 0: notional = _coerce_float(info.get("notionalUsd"), info.get("notional")) if notional is not None: notional = abs(notional) 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_signed( p.get("unrealizedPnl"), info.get("upl"), info.get("uplLast"), info.get("unrealized_pnl"), info.get("unrealisedPnl"), ) mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("markPx")) 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: out["mark_price"] = round(mark, 8) if out: sym = (p.get("symbol") or "").strip() try: cs = float(get_contract_size(sym)) if sym else 1.0 except Exception: cs = 1.0 from lib.market.position_metrics_lib import enrich_ccxt_position_metrics_out enrich_ccxt_position_metrics_out( p, out, contract_size=cs, funds_decimals=FUNDS_DECIMALS ) return out or None def is_no_position_error(err_msg): msg = (err_msg or "").lower() keywords = [ "no position", "position does not exist", "position not exist", "pos size is 0", "nothing to close", "reduceonly", "51008", "empty position", "increase_position", ] return any(k in msg for k in keywords) def get_live_position_contracts(exchange_symbol, direction): ex_sym = normalize_okx_symbol(exchange_symbol or "") rows = _fetch_okx_swap_position_rows() if rows is None: return None prow = _select_live_position_row(rows, ex_sym, direction) if not prow: return 0.0 return _position_row_effective_contracts(prow) def get_live_position_exchange_metrics(exchange_symbol, direction, order_leverage=None): """趋势回调/下单监控:从交易所持仓读标记价与未实现盈亏.""" if not exchange_private_api_configured() or not exchange_symbol: return None rows = _fetch_okx_swap_position_rows() if rows is None: return None prow = _select_live_position_row(rows, exchange_symbol, direction) return parse_ccxt_position_metrics(prow, order_leverage=order_leverage) 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 _coerce_ts_ms(val): if val is None or val == "": return None try: v = float(val) except (TypeError, ValueError): return None if v > 1e12: return int(v) if v > 1e9: return int(v * 1000.0) return int(v * 1000.0) def _unified_symbol_for_match(symbol_str): """统一 ETH/USDT:USDT,ETH-USDT-SWAP 便于与 trade_records 比对.""" s = (symbol_str or "").strip().upper() if not s: return "" if ":" in s: s = s.split(":")[0] if "-" in s and "/" not in s: parts = s.split("-") if len(parts) >= 2 and parts[-1] in ("SWAP", "FUTURES", "FUTURE"): s = f"{parts[0]}/{parts[1]}" else: s = s.replace("-", "/") if "_" in s and "/" not in s: s = s.replace("_", "/") if s.endswith("USDT") and "/" not in s and len(s) > 4: s = f"{s[:-4]}/USDT" return s def exchange_position_sync_since_ms(): s = EXCHANGE_POSITION_SYNC_FROM_BJ if s: for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)): try: chunk = s[:ln] if len(s) >= ln else s[:10] dt = datetime.strptime(chunk, fmt) aware = dt.replace(tzinfo=APP_TZ) return int(aware.timestamp() * 1000) except Exception: continue dt0 = app_now() - timedelta(days=90) try: aware0 = datetime(dt0.year, dt0.month, dt0.day, 0, 0, 0, tzinfo=APP_TZ) except Exception: aware0 = datetime.now(APP_TZ) return int(aware0.timestamp() * 1000) def _normalize_okx_position_history_entry(p): if not p or not isinstance(p, dict): return None info = p.get("info") or {} if not isinstance(info, dict): info = {} sym = p.get("symbol") or "" if not sym: inst = str(info.get("instId") or "").strip() if inst: try: ensure_markets_loaded() sym = exchange.market(inst).get("symbol") or "" except Exception: parts = inst.split("-") if len(parts) >= 2: sym = f"{parts[0]}/{parts[1]}" side = (p.get("side") or info.get("direction") or info.get("posSide") or "").strip().lower() if side not in ("long", "short"): try: pos_val = float(info.get("pos") or 0) if pos_val > 0: side = "long" elif pos_val < 0: side = "short" except (TypeError, ValueError): side = "" rp = p.get("realizedPnl") if rp is None: rp = info.get("realizedPnl") if rp is None: rp = info.get("pnl") try: rp_f = float(rp) if rp is not None and str(rp).strip() != "" else None except (TypeError, ValueError): rp_f = None close_ms = _coerce_ts_ms(p.get("lastUpdateTimestamp")) if close_ms is None: close_ms = _coerce_ts_ms(info.get("uTime")) open_ms = _coerce_ts_ms(p.get("timestamp")) if open_ms is None: open_ms = _coerce_ts_ms(info.get("cTime")) pos_id = str(info.get("posId") or "").strip() inst_id = str(info.get("instId") or "").strip() u_raw = info.get("uTime") sync_key = pos_id or f"{inst_id}|{u_raw}|{side}" return { "symbol_u": _unified_symbol_for_match(sym), "side": side, "close_ms": close_ms, "open_ms": open_ms, "pnl": rp_f, "sync_key": sync_key, } def fetch_okx_positions_close_history(): if not exchange_private_api_configured(): return [] ensure_markets_loaded() since_ms = exchange_position_sync_since_ms() out = [] page_limit = 100 max_total = int(EXCHANGE_POSITION_HISTORY_LIMIT) before = None while len(out) < max_total: params = {"instType": OKX_POSITION_INST_TYPE} if before is not None: params["before"] = str(before) try: rows = exchange.fetch_positions_history( None, since=int(since_ms), limit=page_limit, params=params, ) except Exception: break if not rows: break batch_min_u = None for p in rows: h = _normalize_okx_position_history_entry(p) if h and h["close_ms"] and h["side"] in ("long", "short") and h["symbol_u"]: out.append(h) info = p.get("info") or {} u = _coerce_ts_ms(info.get("uTime")) or _coerce_ts_ms(p.get("lastUpdateTimestamp")) if u and (batch_min_u is None or u < batch_min_u): batch_min_u = u if len(rows) < page_limit or batch_min_u is None: break if before is not None and batch_min_u >= before: break before = batch_min_u return out[:max_total] def sync_trade_records_from_exchange(conn, force=False): """为未同步的 trade_records 回填 OKX 历史仓位中的已实现盈亏.返回统计 dict.""" global _LAST_EXCHANGE_PNL_SYNC_AT stats = {"ok": False, "hist_count": 0, "matched": 0, "pending": 0, "skipped": False} if not exchange_private_api_configured(): stats["reason"] = "未配置 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE" return stats now = time.time() if not force and now - _LAST_EXCHANGE_PNL_SYNC_AT < 25.0: stats["ok"] = True stats["skipped"] = True return stats try: hist = fetch_okx_positions_close_history() except Exception as e: stats["reason"] = str(e) return stats stats["hist_count"] = len(hist) if not hist: stats["ok"] = True stats["reason"] = "交易所平仓历史为空(请检查 API 权限或 EXCHANGE_POSITION_SYNC_FROM_BJ)" return stats candidates = conn.execute( """ SELECT id, symbol, direction, closed_at, closed_at_ms, opened_at, opened_at_ms FROM trade_records WHERE (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '') OR exchange_realized_pnl IS NULL ORDER BY id DESC LIMIT 200 """ ).fetchall() stats["pending"] = len(candidates) if not candidates: stats["ok"] = True _LAST_EXCHANGE_PNL_SYNC_AT = now return stats used = set() matched = 0 for tr in candidates: close_ms_trade = _to_ms_with_fallback( tr["closed_at_ms"] if "closed_at_ms" in tr.keys() else None, tr["closed_at"] ) or opened_at_str_to_ms(tr["closed_at"]) open_ms_trade = _to_ms_with_fallback( tr["opened_at_ms"] if "opened_at_ms" in tr.keys() else None, tr["opened_at"] ) or opened_at_str_to_ms(tr["opened_at"]) if close_ms_trade is None: continue best = None best_d = None for h in hist: sk = h["sync_key"] if not sk or sk in used: continue if h["symbol_u"] != _unified_symbol_for_match(tr["symbol"]): continue if h["side"] != (tr["direction"] or "long").strip().lower(): continue cm = h["close_ms"] if cm is None: continue if open_ms_trade is not None: if cm < open_ms_trade - 15 * 60 * 1000: continue if cm > open_ms_trade + 15 * 86400 * 1000: continue else: if abs(cm - close_ms_trade) > 3 * 86400 * 1000: continue d = abs(cm - close_ms_trade) if best_d is None or d < best_d: best_d = d best = h if best is None or best_d is None or best_d > 90 * 60 * 1000: continue sk = best["sync_key"] if sk in used: continue eo = ms_to_app_local_str(best["open_ms"]) if best.get("open_ms") else None ec = ms_to_app_local_str(best["close_ms"]) if best.get("close_ms") else None pnl_val = best.get("pnl") if pnl_val is None: pnl_val = 0.0 conn.execute( """ UPDATE trade_records SET exchange_realized_pnl = ?, exchange_opened_at = ?, exchange_closed_at = ?, exchange_sync_key = ? WHERE id = ? """, (float(pnl_val), eo, ec, sk, int(tr["id"])), ) used.add(sk) matched += 1 stats["matched"] = matched stats["ok"] = True # 仍有未匹配且历史非空:缩短节流,避免平仓后历史稍晚入库时卡在「估」 if matched < stats["pending"] and hist: _LAST_EXCHANGE_PNL_SYNC_AT = now - 15.0 else: _LAST_EXCHANGE_PNL_SYNC_AT = now try: conn.commit() except Exception: pass return stats # 获取实时价格 def get_price(symbol): """最新价:公开 SWAP/现货 ticker 优先(与顶栏同源),再回退 ccxt.""" sym = normalize_symbol_input(symbol) or (symbol or "").strip().upper() base = "" if "/" in sym: base = sym.split("/", 1)[0].strip().upper() elif sym: base = sym.split(":", 1)[0].strip().upper() # 1) 与顶栏 BTC/ETH 同一公开接口(模拟盘无密钥也可用) if base in ("BTC", "ETH"): try: from lib.market.nav_spot_tickers_lib import _ticker_row for inst in (f"{base}-USDT-SWAP", f"{base}-USDT"): row = _ticker_row(exchange, inst) if not row: continue last = _safe_float(row.get("last") or row.get("lastPx") or row.get("markPx")) if last is not None and last > 0: return float(last) except Exception: pass # 2) 其它币种:公开 ticker if base and hasattr(exchange, "public_get_market_ticker"): try: for inst in (f"{base}-USDT-SWAP", f"{base}-USDT"): rows = (exchange.public_get_market_ticker({"instId": inst}) or {}).get("data") or [] if not rows: continue row = rows[0] or {} last = _safe_float(row.get("last") or row.get("lastPx") or row.get("markPx")) if last is not None and last > 0: return float(last) except Exception: pass # 3) ccxt 永续 try: ensure_markets_loaded() last = exchange.fetch_ticker(normalize_okx_symbol(sym or symbol)).get("last") last = _safe_float(last) if last is not None and last > 0: return float(last) except Exception: pass return None # 获取5分钟K线收盘价 def get_5m_close(symbol): try: ensure_markets_loaded() ohlcv = exchange.fetch_ohlcv(normalize_okx_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_okx_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):OKX USDT 永续 24h 成交额(USDT) 在全市场币种中的排名. """ sym_norm = normalize_symbol_input(symbol) target_base = journal_coin_from_symbol(sym_norm) return resolve_daily_volume_rank( target_base, LIQUIDITY_RANK_CACHE, now_ts=time.time(), ttl_sec=max(30, BALANCE_REFRESH_SECONDS), exchange=exchange, ensure_markets_loaded=ensure_markets_loaded, ) def _key_hard_checks(symbol, direction, upper, lower, monitor_type): """ 关键位门控:量能,突破幅度,第二根确认,日成交量前30. 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根. """ out = {"ok": False} ex_sym = normalize_okx_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 close_b = float(breakout[4]) high_b = float(breakout[2]) low_b = float(breakout[3]) cfm_close = float(confirm[4]) edge = float(upper) if direction == "long" else float(lower) breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) amp_ok, amp_pct = auto_amp_ok( direction, close_b, float(upper), float(lower), KEY_BREAKOUT_AMP_MIN_PCT ) amp_ok = amp_ok and breakout_ok confirm_ok_raw = auto_confirm_ok(direction, cfm_close, float(upper), float(lower)) 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 _key_plan_sl_tp_for_row(row, direction, upper, lower, checks): mode = sl_tp_mode_from_row(row, "standard") manual_tp = _sqlite_row_val(row, "manual_take_profit") return plan_key_sl_tp( mode, direction, upper, lower, checks, outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, manual_take_profit=manual_tp, ), mode 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 _coerce_float(*values): """取第一个可解析且 > 0 的数(用于价格,保证金等).""" for v in values: if v is None: continue try: f = float(v) if f > 0: return f except (TypeError, ValueError): continue return None def _coerce_float_signed(*values): """取第一个有限浮点数(含 0 与负数),用于未实现盈亏等.""" for v in values: if v is None or v == "": continue try: f = float(v) if math.isfinite(f): return f except (TypeError, ValueError): continue return None 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_active_position_count(conn): del conn return 0 def get_key_sizing_capital_snapshot(conn, session_date): row = conn.execute( "SELECT key_sizing_capital_snapshot FROM trading_sessions WHERE session_date=?", (session_date,), ).fetchone() if not row: return None try: v = row["key_sizing_capital_snapshot"] return float(v) if v is not None else None except (TypeError, ValueError, KeyError): 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), 4), session_date), ) conn.commit() def resolve_capital_base_for_key_open(conn, trading_day, live_capital): live = float(live_capital) set_key_sizing_capital_snapshot(conn, trading_day, live) return live 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 _fetch_last_closed_bar(symbol): ex_sym = normalize_okx_symbol(symbol) bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or [] if len(bars) < 2: return None closed = bars[:-1] return closed[-1] if closed else None def _key_rs_gate_preview(symbol, upper, lower): bar = _fetch_last_closed_bar(symbol) if not bar: return {"summary": "5m数据不足", "metrics": ""} close = float(bar[4]) br = detect_rs_box_break(close, upper, lower) if br: return { "summary": f"已越线:{br['break_label']}", "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", } return { "summary": "待突破", "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", } def _process_key_rs_level_alert(conn, row): sym = row["symbol"] typ = (row["monitor_type"] or "").strip() up, low = float(row["upper"]), float(row["lower"]) if up <= low: return bar = _fetch_last_closed_bar(sym) if not bar: return close = float(bar[4]) ts = bar[0] now_dt = app_now() tick = run_rs_level_alert_tick( row, close, ts, now_dt, default_max_notify=KEY_ALERT_MAX_TIMES, default_interval_min=KEY_ALERT_INTERVAL_MINUTES, ) if not tick: return br = tick["break_info"] notify_index = int(tick["notify_index"]) max_n = int(tick["notify_max"]) interval = int(tick["interval_min"]) bar_ts = tick.get("bar_ts") prior_count = int(tick.get("prior_count", notify_index - 1)) notified_at = app_now_str() if not claim_rs_level_notify( conn, row["id"], notify_index, br["direction"], notified_at, bar_ts, prior_count=prior_count, ): return conn.commit() trigger_time = ms_to_app_local_str(int(ts)) if ts else app_now_str() msg = build_wechat_rs_level_message( symbol=sym, monitor_type=typ, account_label=_wechat_account_label(), trigger_time=trigger_time, upper_txt=format_price_for_symbol(sym, up), lower_txt=format_price_for_symbol(sym, low), close_txt=format_price_for_symbol(sym, close), edge_txt=format_price_for_symbol(sym, br["edge_price"]), break_label=br["break_label"], direction=br["direction"], notify_index=notify_index, notify_max=max_n, interval_min=interval, ) send_wechat_msg(msg) conn.execute( "UPDATE key_monitors SET last_alert_message=? WHERE id=?", (msg, row["id"]), ) conn.commit() if notify_index >= max_n: hist_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (row["id"],)).fetchone() if hist_row: insert_key_monitor_history(conn, hist_row, notify_index, msg, "key_level_alert_done") conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) conn.commit() def _key_hard_lines_from_checks(checks): direction = (checks.get("direction") or "long").lower() 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']})", format_auto_amp_line(checks["amp_ok"], checks["amp_pct"], KEY_BREAKOUT_AMP_MIN_PCT), format_auto_confirm_line( checks["confirm_ok"], checks["confirm_close"], checks["edge_price"], direction ), f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前{KEY_DAILY_VOLUME_RANK_MAX})", ] def get_symbol_mark_price(symbol): """斐波失效判定用标记价.""" ex_sym = normalize_okx_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("markPx"), info.get("last")) if m is not None: return float(m) except Exception: pass p = get_price(symbol) return float(p) if p is not None else None def can_notify_key_monitor(row, now_dt): max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES) if int(row["notification_count"] or 0) >= max_notify: return False last_at = row["last_notified_at"] if not last_at: return True try: last_dt = datetime.strptime(last_at, "%Y-%m-%d %H:%M:%S") except Exception: return True interval_min = int(row["notify_interval_min"] or KEY_ALERT_INTERVAL_MINUTES) return (now_dt - last_dt).total_seconds() >= interval_min * 60 def breakout_too_far(p, edge_price, limit_pct): try: if edge_price is None or float(edge_price) <= 0: return False diff_pct = abs(float(p) - float(edge_price)) / float(edge_price) * 100 return diff_pct > float(limit_pct) except Exception: return False # 关键位监控:仅关键支撑阻力人工盯盘提醒(自动开仓已移除) def check_key_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() for r in rows: sym, typ = r["symbol"], (r["monitor_type"] or "").strip() if is_limit_key_monitor_type(typ): continue if typ in KEY_MONITOR_RS_TYPES: try: _process_key_rs_level_alert(conn, r) except Exception as e: print(f"[key_rs_level_alert] {sym} id={r['id']}: {e}") conn.close() # 后台线程:仅关键位监控;永续委托/划转/强平已删除;期权见 options_monitor_loop def background_task(): while True: try: check_key_monitors() except Exception as e: print(f"[key_monitor_loop] {e}", flush=True) time.sleep(MONITOR_POLL_SECONDS) # ====================== 登录路由 ====================== def _mark_logged_in(): session.clear() session.permanent = True session["logged_in"] = True @app.route("/login", methods=["GET", "POST"]) def login(): if AUTH_DISABLED: _mark_logged_in() return redirect("/") if request.method == "POST": username = request.form.get("username") password = request.form.get("password") if username == USERNAME and password == PASSWORD: _mark_logged_in() return redirect("/") else: flash("账号或密码错误") is_sim = False try: from lib.sim.mode_lib import is_sim_mode as _is_sim_mode_fn is_sim = bool(_is_sim_mode_fn(get_db)) except Exception: is_sim = False from lib.instance.instance_embed_lib import pwa_app_name return render_template( "login.html", exchange_display=EXCHANGE_DISPLAY_NAME, pwa_app_name=pwa_app_name("okx", is_sim=is_sim), ) @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 or bool(session.get("logged_in")): return f(*args, **kwargs) # 中控 iframe:Cookie 可能被拦,改认 embed_sso try: from lib.hub.hub_bridge_auth import hub_embed_sso_ok if hub_embed_sso_ok(request): session["logged_in"] = True session.permanent = True session.modified = True return f(*args, **kwargs) except Exception: pass # API 必须返回 JSON,否则前端 r.json() 会报 Unexpected token... path = request.path or "" wants_json = ( path.startswith("/api/") or (request.headers.get("X-Requested-With") or "").lower() == "xmlhttprequest" or "application/json" in (request.headers.get("Accept") or "") ) if wants_json: return jsonify({"ok": False, "msg": "未登录或会话过期,请从中控重新打开工作台"}), 401 return redirect("/login") return decorated # ====================== 主页面 ====================== def render_main_page(page="options", embed_mode=None): now = app_now() trading_day = get_trading_day(now) list_window = _list_window_from_request() start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ) conn = get_db() session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) try: conn.commit() except Exception: pass conn.close() from lib.instance.instance_embed_context_lib import ( embed_render_plan, minimal_stats_bundle, options_funding_label, profit_loss_ratio_from_trades, show_perp_funds_enabled, total_funds_usdt, trade_records_summary, trading_account_label, ) plan = embed_render_plan(page, embed_mode) # 拉交易所余额前不持库连接,避免 database is locked → 页面 500 if plan.exchange_capitals: funding_capital, trading_capital = get_exchange_capitals() else: funding_capital, trading_capital = None, None funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None # 顶栏交易账户只展示交易所余额;勿回退会话 current_capital(模拟盘残留会污染实盘显示) current_capital = ( round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None ) capital_for_sizing = ( current_capital if current_capital is not None else round(local_current_capital, FUNDS_DECIMALS) ) options_trading_usdc = None options_funding_usdc = None options_funding_usdt = None options_trading_usdt = None options_funding_eth = None options_trading_eth = None options_trading_btc = None options_margin_mode = "coin" options_underly = "ETH" _sim_mode_for_header = False _exchange_display_for_header = EXCHANGE_DISPLAY_NAME try: from lib.sim.mode_lib import exchange_mode_label as _exchange_mode_label from lib.sim.mode_lib import is_sim_mode as _is_sim_mode_fn _sim_mode_for_header = bool(_is_sim_mode_fn(get_db)) _exchange_display_for_header = _exchange_mode_label( is_sim=_sim_mode_for_header, exchange_key="okx" ) except Exception: _sim_mode_for_header = False _exchange_display_for_header = EXCHANGE_DISPLAY_NAME if ( OKX_OPTIONS_ENABLED and embed_mode != "fragment" and (getattr(exchange_options, "apiKey", None) or _sim_mode_for_header) ): try: from lib.exchange.okx_options_lib import options_header_balance_pack _op = options_header_balance_pack(exchange_options) options_trading_usdc = _op.get("trading_usdc") options_funding_usdc = _op.get("funding_usdc") options_funding_usdt = _op.get("funding_usdt") options_trading_usdt = _op.get("trading_usdt") options_funding_eth = _op.get("funding_eth") options_trading_eth = _op.get("trading_eth") options_trading_btc = _op.get("trading_btc") options_margin_mode = _op.get("options_margin_mode") or "coin" options_underly = _op.get("options_underly") or "ETH" except Exception: options_trading_usdc = None options_funding_usdc = None options_funding_usdt = None options_trading_usdt = None options_funding_eth = None options_trading_eth = None options_trading_btc = None options_margin_mode = "coin" options_underly = "ETH" recommended_capital = get_recommended_capital(capital_for_sizing) conn = get_db() key_list = ( conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else [] ) if key_list: key_list = [ r for r in key_list if is_rs_key_monitor_type((r["monitor_type"] or "").strip()) ] key_history = ( conn.execute( "SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500", (start_bj, end_bj), ).fetchall() if plan.key_history else [] ) stats_bundle = ( compute_stats_bundle(conn, trading_day, now) if plan.stats_bundle else minimal_stats_bundle(TRADING_DAY_RESET_HOUR) ) order_list = [] exchange_pnl_sync = {} if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in ( "fragment", "shell", ): try: exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {} except Exception as e: exchange_pnl_sync = {"ok": False, "reason": str(e)} tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") if plan.records_rows: raw_records = conn.execute( f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? ORDER BY id DESC LIMIT 1000", (start_bj, end_bj), ).fetchall() records = filter_trade_records_excluding_miss( [to_effective_trade_dict(r) for r in raw_records] ) total = len(records) win = count_winning_trades(records) rate = round(win / total * 100, 2) if total else 0 profit_loss_ratio = profit_loss_ratio_from_trades(records) elif plan.records_summary: summary = trade_records_summary(conn, start_bj, end_bj, tr_ts) records = summary["records"] total = summary["total"] rate = summary["rate"] profit_loss_ratio = summary.get("profit_loss_ratio") else: records = [] total = rate = 0 profit_loss_ratio = None # 顶栏总交易/胜率/盈亏比:期权开启时全站读纯期权,与当前导航页无关 if OKX_OPTIONS_ENABLED: try: from lib.options.options_stats_lib import header_options_stats_for_window opt_header = header_options_stats_for_window( conn, list_window, APP_TZ, ex=exchange_options ) total = opt_header["total"] rate = opt_header["rate"] profit_loss_ratio = opt_header.get("profit_loss_ratio") except Exception: pass active_count = len(order_list) from lib.trade.trade_labels_lib import count_position_limit_active_monitors position_limit_count = count_position_limit_active_monitors(conn) open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = account_risk_status(conn) _open_gate = resolve_manual_open_gate( time_allows=trading_day_reset_allows_new_open(now, conn), active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, risk_status=risk_status, force_close_enabled=FORCE_CLOSE_ENABLED, force_close_bj_hour=FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000), reset_hour=TRADING_DAY_RESET_HOUR, ) can_trade = _open_gate["can_trade"] open_block_note = _open_gate["open_block_note"] key_rule_ctx = {} if page in ("key_monitor",): key_rule_ctx = key_monitor_rule_template_context( kline_timeframe=KLINE_TIMEFRAME, key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT, key_volume_ma_bars=KEY_VOLUME_MA_BARS, key_volume_ratio_min=KEY_VOLUME_RATIO_MIN, key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, key_daily_volume_rank_max=KEY_DAILY_VOLUME_RANK_MAX, key_confirm_breakout_bar=KEY_CONFIRM_BREAKOUT_BAR, key_confirm_bar=KEY_CONFIRM_BAR, key_alert_max_times=KEY_ALERT_MAX_TIMES, key_alert_interval_minutes=KEY_ALERT_INTERVAL_MINUTES, key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, ) strategy_extra = {} conn.close() from lib.instance.instance_embed_lib import embed_context_extras from lib.instance.instance_settings_lib import settings_page_context from lib.instance.instance_display_prefs_lib import display_prefs_template_context _display_ctx = display_prefs_template_context(get_db) from lib.options.okx_trade_mode_lib import get_okx_trade_mode _okx_trade_mode = get_okx_trade_mode() _hedge_mode_on = False _show_perp_funds = show_perp_funds_enabled(exchange_key="okx") or (options_margin_mode == "coin") template_ctx = dict( page=page, key=key_list, key_history=key_history, stats_bundle=stats_bundle, order=order_list, record=records, total=total, rate=rate, profit_loss_ratio=profit_loss_ratio, total_funds=total_funds_usdt( funding_usdt if _show_perp_funds else None, current_capital if _show_perp_funds else None, options_trading_usdc, options_funding_usdc, None, None, ), options_funding_usdc=options_funding_usdc, options_funding_usdt=options_funding_usdt, options_trading_usdc=options_trading_usdc, options_trading_usdt=options_trading_usdt, options_funding_eth=options_funding_eth, options_trading_eth=options_trading_eth, options_trading_btc=options_trading_btc, options_margin_mode=options_margin_mode, options_underly=options_underly, trading_day=trading_day, 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, open_guard_enabled=open_guard_enabled, open_guard_blocks_now=open_guard_blocks_now, 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=position_limit_count, can_trade=can_trade, open_block_note=open_block_note, opens_today=opens_today, daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, focus_key_id=(key_list[0]["id"] if key_list else None), focus_order_id=(order_list[0]["id"] if order_list else None), data_export_version=3, list_window=list_window, list_window_presets={ "utc_this_month": PRESET_UTC_THIS_MONTH, "utc_last3m": PRESET_UTC_LAST3M, "utc_last6m": PRESET_UTC_LAST6M, "all": PRESET_ALL, "utc_today": PRESET_UTC_TODAY, "utc_last24h": PRESET_UTC_LAST24H, "utc_last7d": PRESET_UTC_LAST7D, "custom": PRESET_CUSTOM, }, key_alert_max_times=KEY_ALERT_MAX_TIMES, risk_percent=RISK_PERCENT, position_sizing_mode=POSITION_SIZING_MODE, position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE), trade_policy=trade_policy_template_context(TRADE_POLICY), **order_entry_template_context(TRADE_POLICY), open_position_button_label=open_position_button_label(TRADE_POLICY, POSITION_SIZING_MODE), breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, price_fmt=format_price_for_symbol, entry_reason_options=list(ENTRY_REASON_OPTIONS), order_type_options=list(JOURNAL_ORDER_TYPE_OPTIONS), journal_chart_tf_choices=JOURNAL_CHART_TF_CHOICES, journal_chart_default_tf1=JOURNAL_CHART_DEFAULT_TF1, journal_chart_default_tf2=JOURNAL_CHART_DEFAULT_TF2, journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT, journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, key_rule_ctx=key_rule_ctx, funds_fmt=format_funds_u, options_funding_label=options_funding_label, trading_account_label=trading_account_label, exchange_display=_exchange_display_for_header, options_enabled=OKX_OPTIONS_ENABLED, trading_mode=("sim" if _sim_mode_for_header else "live"), is_sim_mode=_sim_mode_for_header, show_perp_funds=_show_perp_funds, options_nav_visible=True, okx_trade_mode=_okx_trade_mode, options_open_allowed=_okx_trade_mode == "options", hedge_plan_enabled=_hedge_mode_on, hedge_plan_nav_visible=_hedge_mode_on, hedge_plan_show_perp_options=_okx_trade_mode == "perp_options", hedge_plan_show_options_options=_okx_trade_mode == "options_options", hedge_plan_oo_close_mode_enabled=os.getenv("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "true").lower() in ("1", "true", "yes", "on"), hedge_plan_option_primary=os.getenv("HEDGE_PLAN_OPTION_PRIMARY", "true").lower() in ("1", "true", "yes", "on"), hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"), options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC, options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "1"), options_compound_full_enabled=os.getenv( "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true" ).lower() in ("1", "true", "yes", "on"), options_compound_full_cap_enabled=os.getenv( "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false" ).lower() in ("1", "true", "yes", "on"), options_compound_full_cap_usdc=float( os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300" ), options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, options_chain_ask_liq_filter=os.getenv( "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true" ).lower() in ("1", "true", "yes", "on"), risk_status=risk_status, show_logout=not AUTH_DISABLED, max_active_positions=MAX_ACTIVE_POSITIONS, manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, kline_timeframe=KLINE_TIMEFRAME, funding_usdt=funding_usdt, exchange_pnl_sync=exchange_pnl_sync, **strategy_extra, **embed_context_extras("okx", is_sim=_sim_mode_for_header), **_display_ctx, **settings_page_context( page, display=_display_ctx["display"], instance_base_dir=BASE_DIR, exchange_key="okx", exchange_display=EXCHANGE_DISPLAY_NAME, risk_status=risk_status, trade_policy=TRADE_POLICY, data_export_version=3, open_guard_enabled=open_guard_enabled, ), **force_close_template_context( FORCE_CLOSE_ENABLED, FORCE_CLOSE_BJ_HOUR, now_ms=int(app_now().timestamp() * 1000), ), ) if embed_mode == "fragment": return render_template("embed_page_fragment.html", **template_ctx) if embed_mode == "shell": return render_template("embed_shell.html", initial_tab=page, **template_ctx) return render_template("index.html", **template_ctx) @app.route("/api/sync_exchange_pnl") @login_required def api_sync_exchange_pnl(): conn = get_db() stats = sync_trade_records_from_exchange(conn, force=True) try: conn.commit() except Exception: pass conn.close() return jsonify(stats) @app.route("/") @login_required def index(): return redirect("/options") @app.route("/key_monitor") @login_required def key_monitor_page(): redir = redirect_to_embed_shell_if_enabled("key_monitor") if redir is not None: return redir return render_main_page("key_monitor") @app.route("/trade") @login_required def trade_page(): # 永续实盘下单已下线 return redirect("/options") @app.route("/records") @login_required def records_page(): # 永续交易记录与复盘已移除;保留期权复盘 /options/review return redirect("/options/review") @app.route("/stats") @login_required def stats_page(): redir = redirect_to_embed_shell_if_enabled("stats") if redir is not None: return redir return render_main_page("stats") @app.route("/dashboard") @login_required def dashboard_page(): redir = redirect_to_embed_shell_if_enabled("dashboard") if redir is not None: return redir return render_main_page("dashboard") @app.route("/risk_policy") @login_required def risk_policy_page(): redir = redirect_to_embed_shell_if_enabled("risk_policy") if redir is not None: return redir return render_main_page("risk_policy") @app.route("/system_guide") @login_required def system_guide_page(): redir = redirect_to_embed_shell_if_enabled("system_guide") if redir is not None: return redir return render_main_page("system_guide") @app.route("/live_guide") @login_required def live_guide_page(): redir = redirect_to_embed_shell_if_enabled("live_guide") if redir is not None: return redir return render_main_page("live_guide") @app.route("/env_config") @login_required def env_config_page(): redir = redirect_to_embed_shell_if_enabled("env_config") if redir is not None: return redir return render_main_page("env_config") @app.route("/settings") @login_required def settings_page(): redir = redirect_to_embed_shell_if_enabled("settings") if redir is not None: return redir return render_main_page("settings") @app.route("/options") @login_required def options_main_page(): redir = redirect_to_embed_shell_if_enabled("options") if redir is not None: return redir return render_main_page("options") @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"]) try: conn.commit() except Exception: pass conn.close() force_refresh = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") # 拉交易所余额前不持库连接 funding_capital, trading_capital = get_exchange_capitals(force=force_refresh) funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None # 顶栏只展示交易所余额;勿用会话资金回退(避免模拟盘 30U 残留到实盘) current_capital = ( round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None ) capital_for_sizing = ( current_capital if current_capital is not None else round(local_current_capital, FUNDS_DECIMALS) ) options_trading_usdc = None options_funding_usdc = None options_funding_usdt = None options_trading_usdt = None options_funding_eth = None options_trading_eth = None options_trading_btc = None options_margin_mode = "coin" options_underly = "ETH" if OKX_OPTIONS_ENABLED and exchange_options.apiKey: try: from lib.exchange.okx_options_lib import options_header_balance_pack _op = options_header_balance_pack(exchange_options, force=force_refresh) options_trading_usdc = _op.get("trading_usdc") options_funding_usdc = _op.get("funding_usdc") options_funding_usdt = _op.get("funding_usdt") options_trading_usdt = _op.get("trading_usdt") options_funding_eth = _op.get("funding_eth") options_trading_eth = _op.get("trading_eth") options_trading_btc = _op.get("trading_btc") options_margin_mode = _op.get("options_margin_mode") or "coin" options_underly = _op.get("options_underly") or "ETH" except Exception: options_trading_usdc = None options_funding_usdc = None options_funding_usdt = None options_trading_usdt = None options_funding_eth = None options_trading_eth = None options_trading_btc = None options_margin_mode = "coin" options_underly = "ETH" recommended_capital = get_recommended_capital(capital_for_sizing) conn = get_db() from lib.trade.trade_labels_lib import count_position_limit_active_monitors position_limit_count = count_position_limit_active_monitors(conn) open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = account_risk_status(conn) active_pnl_rows = [] from lib.instance.instance_embed_context_lib import ( header_trade_stats_for_window, show_perp_funds_enabled, total_funds_usdt, ) header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ) if OKX_OPTIONS_ENABLED: try: from lib.options.options_stats_lib import header_options_stats_for_window header_trade_stats = header_options_stats_for_window( conn, _list_window_from_request(), APP_TZ, ex=exchange_options ) except Exception: pass conn.close() open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR _open_gate = resolve_manual_open_gate( time_allows=trading_day_reset_allows_new_open(now), active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, risk_status=risk_status, force_close_enabled=FORCE_CLOSE_ENABLED, force_close_bj_hour=FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000), reset_hour=TRADING_DAY_RESET_HOUR, ) can_trade = _open_gate["can_trade"] open_block_note = _open_gate["open_block_note"] available_trading_usdt = get_available_trading_usdt() unrealized_pnl = None if exchange_private_api_configured(): from lib.instance.instance_live_pnl_lib import resolve_instance_unrealized_pnl def _okx_positions(): ensure_markets_loaded() try: return exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or [] except Exception: return exchange.fetch_positions() or [] try: unrealized_pnl = resolve_instance_unrealized_pnl( _okx_positions, active_pnl_rows, get_live_position_exchange_metrics, ) except Exception: unrealized_pnl = None options_unrealized_pnl = None if OKX_OPTIONS_ENABLED and exchange_options.apiKey: try: from lib.instance.instance_live_pnl_lib import merge_unrealized_pnl_components from lib.options.options_positions_lib import sum_options_net_pnl_usdc opt_cfg = app.extensions.get("options_cfg") if opt_cfg: # 与持仓卡「净盈亏」同口径(买一回收−权利金),不用交易所标记价 upl options_unrealized_pnl = sum_options_net_pnl_usdc(opt_cfg, exchange_options) else: from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl) except Exception: options_unrealized_pnl = None _show_perp_funds = show_perp_funds_enabled(exchange_key="okx") or (options_margin_mode == "coin") try: from lib.sim.mode_lib import exchange_mode_label as _ex_mode_label from lib.sim.mode_lib import is_sim_mode as _is_sim _snapshot_mode_label = _ex_mode_label(is_sim=_is_sim(get_db), exchange_key="okx") except Exception: _snapshot_mode_label = EXCHANGE_DISPLAY_NAME return jsonify({ "funding_usdt": funding_usdt, "current_capital": current_capital, "show_perp_funds": _show_perp_funds, "exchange_mode_label": _snapshot_mode_label, "options_funding_usdc": options_funding_usdc, "options_funding_usdt": options_funding_usdt, "options_trading_usdc": options_trading_usdc, "options_trading_usdt": options_trading_usdt, "options_funding_eth": options_funding_eth, "options_trading_eth": options_trading_eth, "options_trading_btc": options_trading_btc, "options_margin_mode": options_margin_mode, "options_underly": options_underly, "total_funds": total_funds_usdt( funding_usdt if _show_perp_funds else None, current_capital if _show_perp_funds else None, options_trading_usdc, options_funding_usdc, None, None, ), "available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None, "unrealized_pnl": unrealized_pnl, "options_unrealized_pnl": options_unrealized_pnl, "recommended_capital": recommended_capital, "active_count": position_limit_count, "max_active_positions": MAX_ACTIVE_POSITIONS, "can_trade": can_trade, "open_block_note": open_block_note, "opens_today": opens_today, "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, "open_guard_enabled": open_guard_enabled, "open_guard_blocks_now": open_guard_blocks_now, "reset_hour": TRADING_DAY_RESET_HOUR, "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, "trading_day": trading_day, "total": header_trade_stats["total"], "rate": header_trade_stats["rate"], "profit_loss_ratio": header_trade_stats.get("profit_loss_ratio"), "updated_at": app_now_str(), "risk_status": risk_status, **force_close_template_context( FORCE_CLOSE_ENABLED, FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000), ), }) @app.route("/api/settings/open_guard", methods=["POST"]) @login_required def api_settings_open_guard(): data = request.get_json(silent=True) or {} raw = data.get("enabled") if raw is None: raw = request.form.get("enabled") if raw is None: return jsonify({"ok": False, "msg": "缺少 enabled 参数"}), 400 enabled = str(raw).lower() in ("1", "true", "yes", "on") set_trading_day_reset_open_guard_enabled(enabled) now = app_now() conn = get_db() trading_day = get_trading_day(now) from lib.trade.trade_labels_lib import count_position_limit_active_monitors position_limit_count = count_position_limit_active_monitors(conn) guard_on = get_trading_day_reset_open_guard_enabled(conn) opens_today = count_opens_for_trading_day(conn, trading_day) conn.close() can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, ) return jsonify( { "ok": True, "open_guard_enabled": guard_on, "can_trade": can_trade, "opens_today": opens_today, "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, "reset_hour": TRADING_DAY_RESET_HOUR, } ) @app.route("/api/nav_spot_tickers") @login_required def api_nav_spot_tickers(): """BTC/ETH 现货最新价,相对北京时间 08:00(OKX 日K开盘)涨跌.""" try: from lib.market.nav_spot_tickers_lib import build_nav_spot_tickers tickers = build_nav_spot_tickers( exchange, reset_hour=TRADING_DAY_RESET_HOUR, now_fn=lambda: datetime.now(APP_TZ), ) return jsonify( { "ok": True, "updated_at": app_now_str(), "tickers": tickers, } ) except Exception as e: return jsonify({"ok": False, "msg": str(e) or "行情读取失败", "tickers": []}), 502 @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,notification_count,max_notify FROM key_monitors" ).fetchall() try: ensure_markets_loaded() except Exception: pass key_prices = [] for r in key_rows: price = get_price(r["symbol"]) if price is None: price = get_symbol_mark_price(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"]) typ = (r["monitor_type"] or "").strip() is_rs = typ in KEY_MONITOR_RS_TYPES gate_summary = "关键支撑阻力提醒" if is_rs else (typ or "-") key_prices.append( { "id": r["id"], "symbol": r["symbol"], "monitor_type": typ, "direction": r["direction"], "price": price, "upper": r["upper"], "lower": r["lower"], "upper_diff": upper_diff, "upper_pct": upper_pct, "lower_diff": lower_diff, "lower_pct": lower_pct, "gate_summary": gate_summary, "gate_metrics": "", "gate_ok": True, "notification_count": r["notification_count"], "max_notify": r["max_notify"], } ) conn.close() options_unrealized_pnl = None if OKX_OPTIONS_ENABLED and exchange_options.apiKey: try: from lib.options.options_positions_lib import sum_options_net_pnl_usdc opt_cfg = app.extensions.get("options_cfg") if opt_cfg: options_unrealized_pnl = sum_options_net_pnl_usdc(opt_cfg, exchange_options) else: from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) except Exception: options_unrealized_pnl = None return jsonify( { "updated_at": app_now_str(), "key_prices": key_prices, "order_prices": [], "position_marks": [], "positions_raw_count": 0, "options_unrealized_pnl": options_unrealized_pnl, **force_close_template_context(FORCE_CLOSE_ENABLED, FORCE_CLOSE_BJ_HOUR), } ) @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) base = journal_coin_from_symbol(symbol) vol_24h = (LIQUIDITY_RANK_CACHE.get("volumes") or {}).get(base) if total <= 0: return jsonify({"ok": False, "msg": "24h成交额排名读取失败"}), 502 if rank is None: return jsonify( { "ok": True, "symbol": symbol, "rank": None, "total": int(total), "vol_usdt_24h": vol_24h, "in_top30": False, "rank_max": KEY_DAILY_VOLUME_RANK_MAX, } ) in_top = bool(rank <= KEY_DAILY_VOLUME_RANK_MAX) return jsonify( { "ok": True, "symbol": symbol, "rank": int(rank), "total": int(total), "vol_usdt_24h": vol_24h, "in_top30": in_top, "in_top": in_top, "rank_max": KEY_DAILY_VOLUME_RANK_MAX, } ) @app.route("/sync_positions") @login_required def sync_positions(): return redirect("/options") @app.route("/api/sync_positions", methods=["POST"]) @login_required def api_sync_positions(): return jsonify({"ok": False, "msg": "永续持仓同步已下线"}), 410 @app.route("/api/order_defaults") @login_required def api_order_defaults(): return redirect("/options") @app.route("/order_focus") @login_required def order_focus(): return redirect("/options") @app.route("/add_order", methods=["POST"]) @login_required def add_order(): return redirect("/options") @app.route("/api/order_kline") @login_required def api_order_kline(): return redirect("/options") @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 = default_symbol_for_policy( TRADE_POLICY, 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, trade_policy=trade_policy_template_context(TRADE_POLICY), ) @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_okx_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_okx_error(e)}"}), 500 candles = [] for bar in ohlcv or []: if not bar or len(bar) < 6: continue candles.append({ "time": int(bar[0] // 1000), "open": float(bar[1]), "high": float(bar[2]), "low": float(bar[3]), "close": float(bar[4]), "volume": float(bar[5]), }) current_price = get_price(symbol) key_info = None if key_row is not None: upper = float(key_row["upper"]) if key_row["upper"] is not None else None lower = float(key_row["lower"]) if key_row["lower"] is not None else None upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None) lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None) key_info = { "id": key_row["id"], "monitor_type": key_row["monitor_type"], "direction": key_row["direction"] or "long", "upper": upper, "lower": lower, "notification_count": int(key_row["notification_count"] or 0), "upper_diff": upper_diff, "upper_pct": upper_pct, "lower_diff": lower_diff, "lower_pct": lower_pct, } from lib.instance.focus_chart_lib import enrich_key_kline_response price_display, key_info = enrich_key_kline_response( symbol=symbol, current_price=current_price, key_info=key_info, format_price_fn=format_price_for_symbol, ) from lib.instance.focus_chart_lib import kline_api_price_fields price_fields = kline_api_price_fields( exchange, exchange_symbol, candles, ensure_markets_fn=ensure_markets_loaded, ) return jsonify({ "ok": True, "symbol": symbol, "timeframe": timeframe, "limit": limit, "current_price": round(float(current_price), 8) if current_price is not None else None, "current_price_display": price_display, "key_monitor": key_info, "candles": candles, "updated_at": app_now_str(), **price_fields, }) @app.route("/add_key", methods=["POST"]) @login_required def add_key(): d = request.form symbol = normalize_symbol_input(d.get("symbol")) if not symbol: flash("symbol 不能为空") return redirect("/key_monitor") if symbol not in ("BTC/USDT", "ETH/USDT"): flash("关键位仅支持 BTC/USDT、ETH/USDT(对应期权标的)") return redirect("/key_monitor") ok_sym, sym_msg = check_symbol_policy( TRADE_POLICY, symbol, normalize_symbol_input ) if not ok_sym: flash(sym_msg) return redirect("/key_monitor") mt = (d.get("type") or "").strip() direction_sel = (d.get("direction") or "").strip().lower() dup_msg = check_duplicate_submit( session, submit_scope_add_key(symbol, mt, direction_sel or "watch") ) if dup_msg: flash(dup_msg) return redirect("/key_monitor") if mt not in KEY_MONITOR_RS_TYPES: flash("仅支持「关键支撑阻力」提醒(关键位自动单已移除)") return redirect("/key_monitor") direction_sel = KEY_DIRECTION_WATCH mt = KEY_MONITOR_RS_TYPE ok_dir, dir_msg = check_direction_policy(TRADE_POLICY, direction_sel) if not ok_dir: flash(dir_msg) return redirect("/key_monitor") try: upper = float(d.get("upper") or 0) lower = float(d.get("lower") or 0) except (TypeError, ValueError): upper = lower = 0 if upper <= 0 or lower <= 0 or upper <= lower: 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() max_notify = KEY_ALERT_MAX_TIMES interval = KEY_ALERT_INTERVAL_MINUTES try: if d.get("max_notify"): max_notify = max(1, int(d.get("max_notify"))) if d.get("notify_interval_min"): interval = max(1, int(d.get("notify_interval_min"))) except (TypeError, ValueError): pass conn.execute( """INSERT INTO key_monitors (symbol, monitor_type, direction, upper, lower, notification_count, max_notify, notify_interval_min, breakout_limit_pct) VALUES (?,?,?,?,?,0,?,?,?)""", ( symbol, mt, direction_sel, upper, lower, max_notify, interval, KEY_BREAKOUT_LIMIT_PCT, ), ) conn.commit() conn.close() flash(f"已添加关键支撑阻力提醒:{symbol} H={upper} L={lower}|日成交量排名 {rank}/{total}") return redirect("/key_monitor") @app.route("/delete_key_monitor/", methods=["POST"]) @login_required def delete_key_monitor(kid): conn = get_db() row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() if not row: conn.close() return jsonify({"ok": False, "error": "not_found"}) insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) conn.commit() conn.close() return jsonify({"ok": cur.rowcount > 0}) @app.route("/delete_key_history/", methods=["POST"]) @login_required def delete_key_history(hid): conn = get_db() cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) conn.commit() conn.close() return jsonify({"ok": cur.rowcount > 0}) @app.route("/del_key/") @login_required def del_key(id): conn = get_db() row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() if row: insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) conn.commit() conn.close() resp = redirect("/") resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" resp.headers["Pragma"] = "no-cache" return resp def _csv_response(filename, rows, header): buf = StringIO() w = csv.writer(buf) w.writerow(header) for row in rows: w.writerow(row) out = "\ufeff" + buf.getvalue() return Response( out, mimetype="text/csv; charset=utf-8", headers={ "Content-Disposition": f'attachment; filename="{filename}"', "Cache-Control": "no-store", }, ) def _md_response(filename, content): return Response( content, mimetype="text/markdown; charset=utf-8", headers={ "Content-Disposition": f'attachment; filename="{filename}"', "Cache-Control": "no-store", }, ) @app.route("/export/trade_records") @login_required def export_trade_records(): win = _list_window_from_request() start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) conn = get_db() rows = conn.execute( "SELECT id,symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason," "exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at " f"FROM trade_records WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? " f"AND {sql_list_time_field('closed_at', 'created_at', 'opened_at')} <= ? ORDER BY id ASC", (start_bj, end_bj), ).fetchall() conn.close() head = [ "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", "exchange_realized_pnl", "exchange_opened_at", "exchange_closed_at", "created_at", "开仓类型", ] data = [] for r in rows: er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" eff = format_entry_type_display( er1 or er0 or entry_reason_from_key_signal(kst) or "", entry_model=r["entry_model"] if "entry_model" in r.keys() else None, trade_style=r["trade_style"] if "trade_style" in r.keys() else None, ) snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] data.append(( r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], r["exchange_realized_pnl"] if "exchange_realized_pnl" in r.keys() else None, r["exchange_opened_at"] if "exchange_opened_at" in r.keys() else None, r["exchange_closed_at"] if "exchange_closed_at" in r.keys() else None, r["created_at"], eff, )) day = app_now().strftime("%Y%m%d") return _csv_response(f"trade_records_v3_{day}.csv", data, head) @app.route("/export/journal_entries") @login_required def export_journal_entries(): conn = get_db() rows = conn.execute( "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," "post_breakeven_stare,new_trade_while_occupied,note,image,images_json,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", "images_json", "created_at", ] data = [tuple(r[h] for h in head) for r in rows] day = app_now().strftime("%Y%m%d") return _csv_response(f"journal_entries_v1_{day}.csv", data, head) @app.route("/export/key_monitors") @login_required def export_key_monitors(): conn = get_db() rows = conn.execute( "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify," "notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC" ).fetchall() conn.close() head = [ "id", "symbol", "monitor_type", "direction", "upper", "lower", "notification_count", "last_notified_at", "max_notify", "notify_interval_min", "breakout_limit_pct", "created_at", ] data = [tuple(r[h] for h in head) for r in rows] day = app_now().strftime("%Y%m%d") return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head) @app.route("/export/key_monitor_history") @login_required def export_key_monitor_history(): win = _list_window_from_request() start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) conn = get_db() rows = conn.execute( "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", (start_bj, end_bj), ).fetchall() conn.close() head = [ "id", "symbol", "monitor_type", "direction", "upper", "lower", "notification_count", "last_alert_message", "close_reason", "closed_at", ] data = [tuple(r[h] for h in head) for r in rows] day = app_now().strftime("%Y%m%d") return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) @app.route("/del_order/") @login_required def del_order(id): return redirect("/options") @app.route("/add_journal", methods=["POST"]) @login_required def add_journal(): from lib.instance.journal_chart_async_lib import journal_ajax_or_flash_error d = request.form order_type_norm = normalize_journal_order_type(d.get("order_type")) if not order_type_norm: return journal_ajax_or_flash_error(request, "请选择下单类型", redirect_fn=_redirect_records) direction_norm = normalize_journal_direction(d.get("direction") or d.get("direction_hint")) if not direction_norm: return journal_ajax_or_flash_error(request, "请选择方向", redirect_fn=_redirect_records) entry_reason_norm = normalize_journal_entry_reason( d.get("entry_reason"), ENTRY_REASON_OPTIONS, allow_legacy=False ) if not entry_reason_norm: return journal_ajax_or_flash_error(request, "请选择开仓类型", redirect_fn=_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: return journal_ajax_or_flash_error(request, "请选择离场触发", redirect_fn=_redirect_records) if early_exit_trigger == "手动平仓" and not early_exit_note: return journal_ajax_or_flash_error( request, "手工平仓必须填写补充说明", redirect_fn=_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) entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex manual_images = collect_journal_slot_images( d, request.files, entry_id, app.config["UPLOAD_FOLDER"], secure_filename_fn=secure_filename, ) images_json_str = images_json_dumps(manual_images) image_filename = primary_journal_image(manual_images) has_manual_uploads = bool(manual_images) 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):.2f}" except Exception: pass want_exchange_chart = ( not has_manual_uploads and ORDER_CHART_ENABLED and d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") ) chart_job = None if want_exchange_chart: coin = (d.get("coin") or "").strip().upper() symbol_guess = normalize_symbol_input(coin) or coin exchange_symbol = normalize_okx_symbol(symbol_guess) journal_tfs = parse_journal_chart_timeframes( d.get("journal_chart_tf1"), d.get("journal_chart_tf2"), ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None, ) journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT) chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor")) chart_job = { "exchange_symbol": exchange_symbol, "title_prefix": f"{symbol_guess} journal {entry_id[:8]}", "journal_tfs": journal_tfs, "journal_limit": journal_limit, "marker_payload": { "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")), "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")), "entry_price": d.get("entry_price_hint"), "exit_price": d.get("exit_price_hint"), "stop_loss_price": d.get("stop_loss_hint"), "chart_anchor": chart_anchor, "now_ts_ms": int(app_now().timestamp() * 1000), }, } conn = get_db() conn.execute( """INSERT INTO journal_entries (id, open_datetime, close_datetime, hold_duration, coin, tf, direction, pnl, order_type, 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, images_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( entry_id, normalize_bj_datetime_storage(d.get("open_datetime")), normalize_bj_datetime_storage(d.get("close_datetime")), hold_duration, d.get("coin"), d.get("tf"), direction_norm, d.get("pnl"), order_type_norm, 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"), None, d.get("note"), image_filename, images_json_str, ) ) from lib.trade.account_risk_lib import on_journal_saved on_journal_saved( conn, early_exit_trigger=early_exit_trigger, early_exit_note=early_exit_note, mood_issues_raw=mood_issues, trading_day=get_trading_day(), now=app_now(), ) conn.commit() conn.close() if chart_job: from lib.instance.journal_chart_async_lib import schedule_journal_exchange_chart schedule_journal_exchange_chart( entry_id=entry_id, exchange_symbol=chart_job["exchange_symbol"], title_prefix=chart_job["title_prefix"], journal_tfs=chart_job["journal_tfs"], journal_limit=chart_job["journal_limit"], marker_payload=chart_job["marker_payload"], upload_folder=app.config["UPLOAD_FOLDER"], generate_chart_fn=generate_multi_timeframe_chart_png, get_db_fn=get_db, ) msg = ( "交易复盘记录已保存.K 线图后台生成中" f"({'/'.join(chart_job['journal_tfs'])} 各{chart_job['journal_limit']}根),稍后刷新列表可见." ) else: msg = "交易复盘记录已保存" from lib.instance.journal_chart_async_lib import request_wants_journal_ajax if request_wants_journal_ajax(request): return jsonify( { "ok": True, "id": entry_id, "msg": msg, "chart_pending": bool(chart_job), } ) flash(msg) return _redirect_records() @app.route("/api/journal_upload_slot", methods=["POST"]) @login_required def api_journal_upload_slot(): payload, code = handle_journal_upload_slot( request, upload_folder=app.config["UPLOAD_FOLDER"], secure_filename_fn=secure_filename, ) return jsonify(payload), code from lib.instance.records_api_register import register_trade_records_api register_trade_records_api( app, login_required=login_required, get_db=get_db, list_window_from_request=_list_window_from_request, utc_window_to_bj_sql_strings=utc_window_to_bj_sql_strings, sql_list_time_field=sql_list_time_field, to_effective_trade_dict=to_effective_trade_dict, filter_trade_records_excluding_miss=filter_trade_records_excluding_miss, app_tz=APP_TZ, format_price_fn=format_price_for_symbol, sync_exchange_pnl_fn=lambda conn: sync_trade_records_from_exchange(conn, force=False), ) def _dashboard_fetch_options_positions(): if not OKX_OPTIONS_ENABLED: return [] cfg = app.extensions.get("options_cfg") if not isinstance(cfg, dict) or not cfg.get("enabled"): return [] try: from lib.options.options_dashboard_lib import fetch_light_option_positions_for_dashboard return fetch_light_option_positions_for_dashboard(cfg) except Exception: return [] def _dashboard_enrich_orders(items): return items from lib.options.okx_trade_mode_lib import hedge_module_enabled from lib.instance.instance_dashboard_register import register_instance_dashboard_routes register_instance_dashboard_routes( app, login_required=login_required, get_db=get_db, fetch_options_positions=_dashboard_fetch_options_positions, enrich_orders=_dashboard_enrich_orders, hedge_enabled=hedge_module_enabled, ) from lib.account_ledger.account_ledger_register import install_account_ledger install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="okx") @app.route("/api/journals") @login_required def api_journals(): win = _list_window_from_request() start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) conn = get_db() rows = conn.execute( f"SELECT * FROM journal_entries WHERE {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} >= ? " f"AND {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} <= ? ORDER BY created_at DESC LIMIT 500", (start_bj, end_bj), ).fetchall() conn.close() result = [] for r in rows: item = enrich_journal_api_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("/delete_journal/", methods=["POST"]) @login_required def delete_journal(jid): conn = get_db() row = conn.execute( "SELECT image, images_json FROM journal_entries WHERE id=?", (jid,), ).fetchone() if row: for img_path in journal_image_paths(row, app.config["UPLOAD_FOLDER"]): try: if os.path.exists(img_path): os.remove(img_path) except Exception: pass conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) conn.commit() conn.close() return jsonify({"ok": True}) @app.route("/manifest.webmanifest") @app.route("/static/icons/manifest.webmanifest") def pwa_manifest(): """安装 App 用 manifest;模拟盘 short_name=OKX_sim.""" is_sim = False try: from lib.sim.mode_lib import is_sim_mode as _is_sim_mode_fn is_sim = bool(_is_sim_mode_fn(get_db)) except Exception: is_sim = False from lib.instance.instance_embed_lib import pwa_app_name name = pwa_app_name("okx", is_sim=is_sim) short = "OKX_sim" if is_sim else "OKX" payload = { "name": name, "short_name": short, "description": "OKX 期权与对冲交易监控", "id": "/", "start_url": "/", "scope": "/", "display": "standalone", "background_color": "#0b0d14", "theme_color": "#0b0d14", "icons": [ { "src": "/static/icons/icon-192.png?v=2", "sizes": "192x192", "type": "image/png", "purpose": "any", }, { "src": "/static/icons/icon-512.png?v=2", "sizes": "512x512", "type": "image/png", "purpose": "any", }, { "src": "/static/icons/icon-192-maskable.png?v=2", "sizes": "192x192", "type": "image/png", "purpose": "maskable", }, { "src": "/static/icons/icon-512-maskable.png?v=2", "sizes": "512x512", "type": "image/png", "purpose": "maskable", }, ], } resp = jsonify(payload) resp.headers["Content-Type"] = "application/manifest+json; charset=utf-8" resp.headers["Cache-Control"] = "no-cache" return resp _REPO_STATIC_DIR = common_static_dir(_REPO_ROOT) _FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js") _MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js") _OPEN_SUBMIT_GATE_JS = os.path.join(_REPO_STATIC_DIR, "open_submit_gate.js") _OPTIONS_PANEL_JS = os.path.join(_REPO_STATIC_DIR, "options_panel.js") _OPTIONS_EXPIRY_COUNTDOWN_JS = os.path.join(_REPO_STATIC_DIR, "options_expiry_countdown.js") _OPTIONS_SETTINGS_JS = os.path.join(_REPO_STATIC_DIR, "options_settings.js") @app.route("/static/form_submit_guard.js") def static_form_submit_guard_js(): if not os.path.isfile(_FORM_SUBMIT_GUARD_JS): return Response("not found", status=404, mimetype="text/plain; charset=utf-8") return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8") @app.route("/static/manual_order_rr_preview.js") def static_manual_order_rr_preview_js(): if not os.path.isfile(_MANUAL_ORDER_RR_PREVIEW_JS): return Response("not found", status=404, mimetype="text/plain; charset=utf-8") return send_file(_MANUAL_ORDER_RR_PREVIEW_JS, mimetype="application/javascript; charset=utf-8") @app.route("/static/open_submit_gate.js") def static_open_submit_gate_js(): if not os.path.isfile(_OPEN_SUBMIT_GATE_JS): return Response("not found", status=404, mimetype="text/plain; charset=utf-8") return send_file(_OPEN_SUBMIT_GATE_JS, mimetype="application/javascript; charset=utf-8") @app.route("/static/options_panel.js") def static_options_panel_js(): if not os.path.isfile(_OPTIONS_PANEL_JS): return Response("not found", status=404, mimetype="text/plain; charset=utf-8") return send_file(_OPTIONS_PANEL_JS, mimetype="application/javascript; charset=utf-8") @app.route("/static/options_expiry_countdown.js") def static_options_expiry_countdown_js(): if not os.path.isfile(_OPTIONS_EXPIRY_COUNTDOWN_JS): return Response("not found", status=404, mimetype="text/plain; charset=utf-8") return send_file(_OPTIONS_EXPIRY_COUNTDOWN_JS, mimetype="application/javascript; charset=utf-8") @app.route("/static/options_settings.js") def static_options_settings_js(): if not os.path.isfile(_OPTIONS_SETTINGS_JS): return Response("not found", status=404, mimetype="text/plain; charset=utf-8") return send_file(_OPTIONS_SETTINGS_JS, mimetype="application/javascript; charset=utf-8") @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": "结果仅允许:" + "/".join(REVIEW_RESULT_OPTIONS)}), 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() norm = normalize_entry_reason(s) if s else None if s and not norm: return jsonify({"ok": False, "msg": "开仓类型须为下拉选项之一或留空"}), 400 reviewed_entry_reason_update = norm conn = get_db() row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() if not row: conn.close() return jsonify({"ok": False, "msg": "记录不存在"}), 404 risk_amount = row["risk_amount"] actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) base_params = [ reviewed_opened_at, reviewed_closed_at, reviewed_stop_loss, reviewed_take_profit, round(reviewed_pnl_amount, 4), reviewed_result or None, reviewed_miss_reason or None, hold_seconds, hold_minutes, app_now_str(), actual_rr, ] if reviewed_entry_reason_update is not _MISSING_ER: conn.execute( """UPDATE trade_records SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? WHERE id=?""", tuple(base_params + [reviewed_entry_reason_update, rec_id]), ) else: conn.execute( """UPDATE trade_records SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, reviewed_at=?, actual_rr=COALESCE(?, actual_rr) WHERE id=?""", tuple(base_params + [rec_id]), ) if reviewed_result == "手动平仓" and reviewed_miss_reason: from lib.trade.account_risk_lib import apply_manual_close_journal_cooloff apply_manual_close_journal_cooloff( conn, early_exit_note=reviewed_miss_reason, trading_day=get_trading_day(), now=app_now(), ) 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(): return redirect("/options") try: from lib.instance.instance_settings_register import register_instance_settings_routes register_instance_settings_routes( app, get_db=get_db, login_required_fn=login_required, base_dir=BASE_DIR, exchange_key="okx", username=USERNAME, password=PASSWORD, db_path=DB_PATH, upload_folder=UPLOAD_FOLDER, ) except Exception as _settings_err: print(f"[instance_settings] okx: {_settings_err}") try: from lib.instance.backup_lib import start_backup_scheduler start_backup_scheduler( base_dir=BASE_DIR, db_path_fn=lambda: DB_PATH, upload_folder_fn=lambda: UPLOAD_FOLDER, hour=3, ) except Exception as _backup_err: print(f"[backup] scheduler: {_backup_err}") from lib.sim.register import install_sim_trading install_sim_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) from lib.options.options_register import install_options_trading install_options_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) from lib.options.options_review_register import install_options_review install_options_review(app, _REPO_ROOT, app_module=sys.modules[__name__]) from lib.instance.instance_data_stats_register import install_instance_data_stats install_instance_data_stats(app, sys.modules[__name__]) from lib.hub.hub_bridge_register import register_hub_bridge_routes register_hub_bridge_routes(app, sys.modules[__name__]) # 独立部署无 hub_bridge:须自行注册 /embed 壳,否则登录后跳转 /embed 会 404 from lib.instance.instance_embed_lib import register_embed_routes register_embed_routes(app, login_required, render_main_page) _purge_key_monitors_if_full_margin() # 启动 if __name__ == "__main__": from lib.common.flask_access_log_lib import silence_werkzeug_access_log silence_werkzeug_access_log() threading.Thread(target=background_task, daemon=True).start() app.run(host=HOST, port=PORT, debug=DEBUG, threaded=True)