diff --git a/app.py b/app.py index 67d2f5e..a1db786 100644 --- a/app.py +++ b/app.py @@ -1,7667 +1,5721 @@ -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.exchange.okx_orders_lib import cancel_okx_all_open_orders, fetch_okx_all_open_orders -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.trade.force_close_lib import ( - apply_force_close_display_result, - apply_force_close_to_payload, - enrich_orders_force_close, - force_close_template_context, -) -from lib.trade.manual_sltp_lib import ( - normalize_open_sltp_mode, - resolve_entrust_sltp_prices, - resolve_open_sltp_prices, -) -from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema -from lib.trade.position_sizing_lib import ( - OPEN_SOURCE_MANUAL, - assert_open_source_allowed, - compute_full_margin_sizing, - format_risk_display_text, - full_margin_requires_flat_position, - is_full_margin_mode, - leverage_for_full_margin, - load_position_sizing_mode, - mode_label_zh, - risk_percent_for_storage, -) -from 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.auto_transfer_daily_lib import run_auto_transfer_once_per_day -from lib.trade.order_monitor_display_lib import ( - apply_order_price_display_fields, - enrich_order_display_fields, - order_monitor_tpsl_needs_sync, - stale_breakeven_armed, -) -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.market.price_snapshot_lib import resolve_order_snapshot_price -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, -) -from lib.trade.trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills - - -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 = load_position_sizing_mode() -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")) -from lib.trade.daily_open_limit_lib import ( - build_daily_open_alert_prompt, - can_trade_new_open, - check_daily_open_hard_limit, - count_opens_for_trading_day, - format_daily_open_counter_line, - format_daily_open_summary_short, - load_daily_open_limits_from_env, - should_send_daily_open_alert, -) - -DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT = load_daily_open_limits_from_env() -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.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, - ) - - ensure_account_risk_schema(conn) - now = app_now() - 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, - ) - - -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.order_monitor_display_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 - try: - from lib.trade.trade_fee_lib import net_pnl_after_fee - - net = net_pnl_after_fee(gross, trigger, exit_p, open_notional=notional) - return float(net) if net is not None else round(gross, 4) - except Exception: - 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.order_monitor_display_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) - if attach_exchange_stats and tid: - ex_sym = (exchange_symbol or "").strip() or normalize_exchange_symbol(symbol) - _attach_okx_trade_exchange_stats( - conn, - tid, - exchange_symbol=ex_sym, - direction=direction, - opened_at_str=open_ts, - closed_at_str=close_ts, - opened_at_ms=open_ts_ms, - closed_at_ms=close_ts_ms, - ) - # 中控只拉 /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 enrich_order_item(raw_item, current_capital): - item = dict(raw_item or {}) - margin = float(item.get("margin_capital") or 0) - lev = float(item.get("leverage") or 0) - notional = item.get("notional_value") - ratio = item.get("position_ratio") - if notional is None: - notional = round(margin * lev, 4) if margin and lev else 0 - if ratio is None: - ratio = round(margin / current_capital * 100, 2) if current_capital else 0 - item["notional_value"] = notional - item["position_ratio"] = ratio - enrich_order_display_fields(item, calc_rr_ratio) - enrich_entry_model_display(item) - try: - be = item.get("breakeven_enabled") - item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 - except Exception: - item["breakeven_enabled"] = 1 - return apply_order_monitor_source_labels(item, default_manual=ORDER_MONITOR_TYPE_MANUAL) - - -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 auto_transfer_once_per_day(): - run_auto_transfer_once_per_day( - enabled=AUTO_TRANSFER_ENABLED, - bj_hour=AUTO_TRANSFER_BJ_HOUR, - target_amount=AUTO_TRANSFER_AMOUNT, - from_account=AUTO_TRANSFER_FROM, - to_account=AUTO_TRANSFER_TO, - funds_decimals=FUNDS_DECIMALS, - get_db=get_db, - get_active_position_count=get_active_position_count, - get_account_usdt_total=get_account_usdt_total, - execute_transfer_usdt=execute_transfer_usdt, - send_wechat_msg=send_wechat_msg, - utc_now_dt=utc_now_dt, - app_tz=APP_TZ, - utc_calendar_date_str=utc_calendar_date_str, - app_now_str=app_now_str, - ) - - -def 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 precheck_risk(conn, symbol, direction): - now = app_now() - from lib.trade.account_risk_lib import account_risk_blocks_trading - from lib.trade.force_close_lib import force_close_blocks_new_open - - ok_risk, risk_reason = account_risk_blocks_trading( - conn, - trading_day=get_trading_day(now), - now=now, - fmt_local_ms=ms_to_app_local_str, - ) - if not ok_risk: - return False, risk_reason - fc_block, fc_note = force_close_blocks_new_open( - FORCE_CLOSE_ENABLED, - FORCE_CLOSE_BJ_HOUR, - now_ms=int(now.timestamp() * 1000), - ) - if fc_block: - return False, fc_note or "强制清仓窗口内暂不可开仓" - if not trading_day_reset_allows_new_open(now): - return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" - from lib.trade.account_risk_lib import position_limit_reached - - reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) - if reached: - return False, f"已达最大持仓数({active_count}/{mx})" - ok_daily, daily_reason, _opens = check_daily_open_hard_limit( - conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR - ) - if not ok_daily: - return False, daily_reason - if direction not in ("long", "short"): - return False, "方向必须为 long 或 short" - if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): - expected = BTC_LEVERAGE - else: - expected = ALT_LEVERAGE - if expected <= 0: - return False, "杠杆配置异常" - return True, "" - - -def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): - ensure_markets_loaded() - notional = float(margin_capital) * float(leverage) - ticker = exchange.fetch_ticker(exchange_symbol) - price = float(ticker.get("last") or fallback_price) - if price <= 0: - raise ValueError("触发价必须大于 0") - market = exchange.market(exchange_symbol) - contract_size = float(market.get("contractSize") or 1) - if market.get("contract"): - # OKX 永续 amount 是“张数”,需要按合约面值换算 - amount = notional / (price * contract_size) - else: - amount = notional / price - min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") - if min_amount and amount < float(min_amount): - raise ValueError(f"下单数量过小,最小数量为 {min_amount}") - amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) - if amount_precise <= 0: - raise ValueError("下单数量精度后为 0,请提高基数或降低价格") - return amount_precise, price - - -def _to_positive_float(value): - try: - n = float(value) - return n if n > 0 else None - except Exception: - return None - - -def _extract_order_price_value(order_obj): - if not isinstance(order_obj, dict): - return None - for key in ("average", "price"): - v = _to_positive_float(order_obj.get(key)) - if v is not None: - return v - cost = _to_positive_float(order_obj.get("cost")) - filled = _to_positive_float(order_obj.get("filled")) - if cost is not None and filled is not None and filled > 0: - return cost / filled - info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} - for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): - v = _to_positive_float(info.get(key)) - if v is not None: - return v - return None - - -def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): - price = _extract_order_price_value(order_resp) - if price is not None: - return round(price, 8) - order_id = (order_resp or {}).get("id") - if order_id: - try: - fetched = exchange.fetch_order(order_id, exchange_symbol) - fetched_price = _extract_order_price_value(fetched) - if fetched_price is not None: - return round(fetched_price, 8) - except Exception: - pass - fallback = _to_positive_float(fallback_price) - return round(fallback, 8) if fallback is not None else 0.0 - - -def get_contract_size(exchange_symbol): - 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 place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): - ensure_markets_loaded() - exchange.set_leverage(leverage, exchange_symbol) - side = "buy" if direction == "long" else "sell" - params = build_okx_order_params(direction, reduce_only=False) - if stop_loss and take_profit: - params["attachAlgoOrds"] = [{ - "tpTriggerPx": _okx_algo_trigger_price_str(exchange_symbol, take_profit), - "tpOrdPx": "-1", - "slTriggerPx": _okx_algo_trigger_price_str(exchange_symbol, stop_loss), - "slOrdPx": "-1" - }] - try: - order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) - order["tpsl_attached"] = bool(stop_loss and take_profit) - return order - except Exception as e: - if stop_loss and take_profit: - raise RuntimeError(f"交易所未接受止盈止损挂单参数,已拒绝开仓:{str(e)}") - raise - - -def close_exchange_order(order_row): - """ - 市价全平.数量优先取交易所当前持仓张数,避免仅用入库 order_amount 导致平不干净. - """ - ensure_markets_loaded() - exchange_symbol = order_row["exchange_symbol"] or normalize_okx_symbol(order_row["symbol"]) - direction = order_row["direction"] - db_amt = float(order_row["order_amount"] or 0) - side = "sell" if direction == "long" else "buy" - last_resp = None - for _ in range(3): - live = get_live_position_contracts(exchange_symbol, direction) - if live is not None and live > 0: - raw_amt = live - else: - raw_amt = db_amt - if raw_amt <= 0: - if last_resp is not None: - return last_resp - raise ValueError("平仓失败:缺少有效下单数量") - try: - amount = float(exchange.amount_to_precision(exchange_symbol, raw_amt)) - except Exception: - amount = float(raw_amt) - if amount <= 0: - if last_resp is not None: - return last_resp - raise ValueError("平仓失败:数量经精度舍入后为 0") - params = build_okx_order_params(direction, reduce_only=True) - last_resp = exchange.create_order(exchange_symbol, "market", side, amount, None, params) - live_after = get_live_position_contracts(exchange_symbol, direction) - if live_after is None or live_after <= 0: - return last_resp - return last_resp - - -def cancel_okx_swap_open_orders(exchange_symbol): - ok, _ = ensure_okx_live_ready() - if not ok or not exchange_symbol: - return - ensure_markets_loaded() - try: - cancel_okx_all_open_orders(exchange, exchange_symbol) - except Exception: - pass - - -def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_profit): - """ - 为已有持仓挂条件止盈/止损(一笔 OCO 算法单). - 勿带 reduceOnly,勿分两笔 reduce-only 市价单,否则 OKX/ccxt 可能当成立即全平. - """ - ensure_markets_loaded() - close_side = "sell" if direction == "long" else "buy" - amt = float(exchange.amount_to_precision(exchange_symbol, float(amount))) - if amt <= 0: - raise RuntimeError("止盈止损:可平数量经精度舍入后为 0") - base = build_okx_order_params(direction, reduce_only=False) - sl_px = _okx_algo_trigger_price_str(exchange_symbol, stop_loss) - tp_px = _okx_algo_trigger_price_str(exchange_symbol, take_profit) - order_params = { - **base, - "stopLossPrice": float(sl_px), - "takeProfitPrice": float(tp_px), - "tpOrdPx": "-1", - "slOrdPx": "-1", - } - if OKX_POS_MODE == "hedge": - ps = "long" if direction == "long" else "short" - order_params["positionSide"] = ps - last_err = None - for attempt in range(6): - try: - exchange.create_order(exchange_symbol, "oco", close_side, amt, None, order_params) - return - except Exception as e: - last_err = e - cancel_okx_swap_open_orders(exchange_symbol) - time.sleep(0.2 * (attempt + 1)) - raise RuntimeError(f"OKX 未接受止盈/止损条件单:{last_err}") - - - -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 _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): - return resolve_entrust_sltp_prices(direction, live_price, sltp_mode, data) - - -def _okx_tpsl_slot_build(exchange_symbol, order_id, trigger_price, order_type=""): - if trigger_price is None or order_id is None: - return None - sym = exchange_symbol.replace(":USDT", "").replace("/USDT:USDT", "") - return { - "order_id": str(order_id), - "trigger_price": float(trigger_price), - "trigger_display": format_price_for_symbol(sym, trigger_price), - "type": str(order_type or ""), - } - - -def _okx_tpsl_slots_from_order(order, exchange_symbol): - """从单笔 OKX 订单解析 SL/TP(算法单常同时带 slTriggerPx 与 tpTriggerPx).""" - if not isinstance(order, dict): - return None, None - info = order.get("info") or {} - if not isinstance(info, dict): - info = {} - oid = order.get("id") or info.get("algoId") or info.get("ordId") - if oid is None: - return None, None - ord_type = str(order.get("type") or info.get("ordType") or "") - sl_px = _coerce_float( - order.get("stopLossPrice"), - info.get("slTriggerPx"), - info.get("slOrdPx"), - ) - tp_px = _coerce_float( - order.get("takeProfitPrice"), - info.get("tpTriggerPx"), - info.get("tpOrdPx"), - ) - sl_slot = _okx_tpsl_slot_build(exchange_symbol, oid, sl_px, ord_type) if sl_px is not None else None - tp_slot = _okx_tpsl_slot_build(exchange_symbol, oid, tp_px, ord_type) if tp_px is not None else None - if sl_slot or tp_slot: - return sl_slot, tp_slot - trig = _coerce_float( - info.get("triggerPx"), - order.get("triggerPrice"), - order.get("stopPrice"), - ) - if trig is None: - return None, None - one = _okx_tpsl_slot_build(exchange_symbol, oid, trig, ord_type) - return one, None - - -def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None): - slots = {"sl": None, "tp": None} - if not exchange_symbol: - return slots - ok, _ = ensure_okx_live_ready() - if not ok: - return slots - try: - ensure_markets_loaded() - plan_sl_f = plan_tp_f = None - try: - if plan_sl is not None: - plan_sl_f = float(plan_sl) - if plan_tp is not None: - plan_tp_f = float(plan_tp) - except Exception: - plan_sl_f = plan_tp_f = None - - def assign_role(trig, slot): - if trig is None or slot is None: - return - if plan_sl_f is not None and plan_tp_f is not None: - role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp" - elif plan_sl_f is not None: - role = "sl" - elif plan_tp_f is not None: - role = "tp" - else: - return - if slots[role] is None: - slots[role] = slot - - for order in fetch_okx_all_open_orders(exchange, exchange_symbol): - sl_slot, tp_slot = _okx_tpsl_slots_from_order(order, exchange_symbol) - if sl_slot and slots["sl"] is None: - slots["sl"] = sl_slot - if tp_slot and slots["tp"] is None: - slots["tp"] = tp_slot - if sl_slot or tp_slot: - continue - info = order.get("info") or {} - oid = order.get("id") or info.get("algoId") - trig = _coerce_float(info.get("triggerPx"), order.get("triggerPrice")) - if oid is None or trig is None: - continue - slot = _okx_tpsl_slot_build( - exchange_symbol, - oid, - trig, - str(order.get("type") or info.get("ordType") or ""), - ) - assign_role(trig, slot) - except Exception: - pass - return slots - - -def cancel_okx_tpsl_slot(exchange_symbol, slot): - if not slot or not exchange_symbol: - return - oid = slot.get("order_id") - if not oid: - return - ensure_markets_loaded() - cancel_id = str(oid).split(":", 1)[0] - try: - exchange.cancel_order(cancel_id, exchange_symbol, {"stop": True}) - except Exception: - exchange.cancel_order(str(oid), exchange_symbol, {"stop": True}) - - -def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): - """先撤该合约挂单/条件单,再按新价重挂 TP/SL.""" - ok, reason = ensure_okx_live_ready() - if not ok: - raise RuntimeError(reason or "实盘未就绪") - ex_sym = resolve_monitor_exchange_symbol(order_row) - direction = order_row["direction"] - cancelled = cancel_okx_all_open_orders(exchange, ex_sym) - if cancelled > 0: - time.sleep(0.12) - pos_amt = get_live_position_contracts(ex_sym, direction) - if pos_amt is None or float(pos_amt) <= 0: - try: - pos_amt = float(order_row["order_amount"] or 0) - except (TypeError, ValueError): - pos_amt = 0 - if float(pos_amt or 0) <= 0: - raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") - _okx_place_tp_sl_orders(ex_sym, direction, float(pos_amt), float(stop_loss), float(take_profit)) - - -def _okx_place_stop_loss_only(exchange_symbol, direction, stop_loss): - """OKX 永续:仅挂止损(趋势回调),止盈由程序监控. - - 须用 stopLossPrice 挂条件单;勿用 reduce-only 市价单 + params['stopLoss'], - 后者会当成立即市价平仓(开仓后约 1 秒内全平). - """ - ensure_markets_loaded() - pos_amt = get_live_position_contracts(exchange_symbol, direction) - if pos_amt is None or float(pos_amt) <= 0: - raise RuntimeError("交易所当前无持仓,无法挂止损") - cancel_okx_swap_open_orders(exchange_symbol) - close_side = "sell" if direction == "long" else "buy" - amt = float(exchange.amount_to_precision(exchange_symbol, float(pos_amt))) - if amt <= 0: - raise RuntimeError("止损:可平数量经精度舍入后为 0") - base = build_okx_order_params(direction, reduce_only=True) - sl_px = float(stop_loss) - last_err = None - for attempt in range(6): - try: - exchange.create_order( - exchange_symbol, - "market", - close_side, - amt, - None, - {**base, "stopLossPrice": sl_px}, - ) - return - except Exception as e: - last_err = e - cancel_okx_swap_open_orders(exchange_symbol) - time.sleep(0.2 * (attempt + 1)) - raise RuntimeError(f"OKX 未接受止损条件单:{last_err}") - - -def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): - try: - e = float(entry_price) - pct = float( - offset_pct - if offset_pct is not None - else float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) - ) - except (TypeError, ValueError): - return None - if e <= 0: - return None - direction = (direction or "long").strip().lower() - if direction == "short": - return e * (1.0 - pct / 100.0) - return e * (1.0 + pct / 100.0) - - -def extract_trade_price_from_order(order): - if not order: - return None - for k in ("average", "avgPrice", "price"): - try: - v = float(order.get(k) or 0) - if v > 0: - return v - except Exception: - pass - try: - info = order.get("info") or {} - if isinstance(info, dict): - for k in ("fillPx", "avgPx", "fill_price"): - v = float(info.get(k) or 0) - if v > 0: - return v - except Exception: - pass - return None - - -def is_no_position_error(err_msg): - msg = (err_msg or "").lower() - keywords = [ - "no position", "position does not exist", "position not exist", - "pos size is 0", "nothing to close", "reduceonly", "51008", - "empty position", "increase_position", - ] - return any(k in msg for k in keywords) - - -def get_live_position_contracts(exchange_symbol, direction): - 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 fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): - """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None.""" - if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): - return None - ensure_markets_loaded() - since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) - close_side = "sell" if direction == "long" else "buy" - - def pick_from_trades(trades, min_ts=None): - if not trades: - return None - candidates = [] - for t in trades: - if (t.get("side") or "").lower() != close_side: - continue - info = t.get("info") or {} - if not isinstance(info, dict): - info = {} - pos_side = (info.get("posSide") or t.get("posSide") or "").lower() - if OKX_POS_MODE == "hedge": - if pos_side in ("long", "short") and pos_side != direction: - continue - ts = t.get("timestamp") - if ts is None: - continue - try: - ts_i = int(ts) - except (TypeError, ValueError): - continue - if min_ts and ts_i < int(min_ts): - continue - candidates.append(t) - if not candidates: - return None - return max(candidates, key=lambda x: x.get("timestamp") or 0) - - try: - trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) - return pick_from_trades(trades, since_ms) - except Exception: - return None - - -def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): - """ - 拉取某条历史记录对应的减仓成交(用于按 id 回填). - 返回按时间排序的成交列表. - """ - if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): - return [] - ensure_markets_loaded() - since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) - close_side = "sell" if direction == "long" else "buy" - closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None - # 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况 - if closed_ms is not None: - closed_ms += 6 * 60 * 60 * 1000 - candidates = [] - all_side_candidates = [] - try: - trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) - except Exception: - trades = [] - for t in trades or []: - if (t.get("side") or "").lower() != close_side: - continue - ts = t.get("timestamp") - if ts is None: - continue - try: - ts = int(ts) - except Exception: - continue - if since_ms and ts < since_ms: - continue - if closed_ms and ts > closed_ms: - continue - info = t.get("info") or {} - if not isinstance(info, dict): - info = {} - pos_side = (info.get("posSide") or t.get("posSide") or "").lower() - if OKX_POS_MODE == "hedge": - if pos_side in ("long", "short") and pos_side != direction: - continue - all_side_candidates.append(t) - if since_ms and ts < since_ms: - continue - if closed_ms and ts > closed_ms: - continue - candidates.append(t) - candidates.sort(key=lambda x: x.get("timestamp") or 0) - if candidates: - return candidates - - # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败. - all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) - if not all_side_candidates: - return [] - if not closed_ms: - return all_side_candidates[-20:] - near = [] - for t in all_side_candidates: - ts = t.get("timestamp") - if ts is None: - continue - try: - delta = abs(int(ts) - int(closed_ms)) - except Exception: - continue - # 放宽到前后 7 天 - if delta <= 7 * 24 * 60 * 60 * 1000: - near.append((delta, t)) - if near: - near.sort(key=lambda x: x[0]) - picked = [x[1] for x in near[:20]] - picked.sort(key=lambda x: x.get("timestamp") or 0) - return picked - return all_side_candidates[-20:] - - -def fetch_all_position_fills_for_record( - exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None -): - if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): - return [] - ensure_markets_loaded() - since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) - closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None - if closed_ms is not None: - closed_ms += 6 * 60 * 60 * 1000 - try: - trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) - except Exception: - trades = [] - return filter_position_lifecycle_fills( - trades or [], - direction, - since_ms, - closed_ms, - hedge_mode=(OKX_POS_MODE == "hedge"), - close_buffer_ms=0, - ) - - -def _attach_okx_trade_exchange_stats( - conn, trade_id, *, exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=None, closed_at_ms=None -): - if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): - return - open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) - close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) - contract_size = 1.0 - try: - ensure_markets_loaded() - contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1) - except Exception: - pass - - def _fetch(): - return fetch_all_position_fills_for_record( - exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=open_ms, closed_at_ms=close_ms - ) - - try: - attach_exchange_stats_to_trade(conn, trade_id, fetch_fills=_fetch, contract_size=contract_size) - except Exception: - pass - - -def calc_weighted_exit_price(trades): - if not trades: - return None - total_amount = 0.0 - weighted_sum = 0.0 - for t in trades: - try: - price = float(t.get("price") or 0) - amount = float(t.get("amount") or 0) - except Exception: - continue - if price <= 0: - continue - if amount <= 0: - amount = 1.0 - weighted_sum += price * amount - total_amount += amount - if total_amount <= 0: - return None - return weighted_sum / total_amount - - -def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): - """ - 交易所已无仓,本地仍为 active 时,推断平仓类型/时间/盈亏. - 返回 (result, pnl_amount, closed_at_str, miss_reason). - """ - direction = row["direction"] - sym = row["symbol"] - trigger_price = row["trigger_price"] - stop_loss = row["stop_loss"] - take_profit = row["take_profit"] - margin_capital = row["margin_capital"] or DAILY_START_CAPITAL - leverage = row["leverage"] or infer_leverage(sym) - exchange_symbol = row["exchange_symbol"] or normalize_okx_symbol(sym) - - open_ms = _to_ms_with_fallback( - row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at_str - ) - trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) - exit_px = None - closed_at_str = app_now_str() - if trade: - try: - exit_px = float(trade.get("price") or 0) or None - except (TypeError, ValueError): - exit_px = None - ts = trade.get("timestamp") - if ts: - try: - ts_i = int(ts) - except (TypeError, ValueError): - ts_i = None - if ts_i is not None and open_ms and ts_i < int(open_ms): - exit_px = None - elif ts_i is not None: - closed_at_str = ms_to_app_local_str(ts_i) - - if exit_px is None or exit_px <= 0: - p = get_price(sym) - if p: - guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) - if guessed: - pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) - return ( - normalize_result_with_pnl(guessed, pnl), - pnl, - closed_at_str, - "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", - ) - return ( - "外部平仓", - 0.0, - closed_at_str, - "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", - ) - - result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) - pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) - if result: - return ( - normalize_result_with_pnl(result, pnl), - pnl, - closed_at_str, - "按交易所成交记录同步为止盈/止损平仓", - ) - return ( - "外部平仓", - pnl, - closed_at_str, - "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", - ) - - -def _finalize_hub_flat_monitor_okx(conn, r, *, result, pnl_amount, closed_at, miss_reason): - opened_at = get_opened_at_value(r) - closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() - hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) - session_date = r["session_date"] or get_trading_day(closed_at_dt) - update_session_capital(conn, session_date, pnl_amount) - insert_trade_record( - conn, - symbol=r["symbol"], - monitor_type=trade_record_monitor_type(conn, r), - trend_plan_id=trend_plan_id_from_monitor_row(r), - key_signal_type=order_row_key_signal_type(r), - direction=r["direction"], - trigger_price=r["trigger_price"], - stop_loss=r["stop_loss"], - initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], - take_profit=r["take_profit"], - margin_capital=r["margin_capital"], - leverage=r["leverage"], - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=r["trade_style"], - entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio( - r["direction"], - r["trigger_price"], - r["initial_stop_loss"] or r["stop_loss"], - r["take_profit"], - ), - actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), - result=result, - miss_reason=handoff_trade_miss_reason(miss_reason, r), - opened_at=opened_at, - closed_at=closed_at, - ) - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) - - -def reconcile_external_close(conn, symbol, direction): - from lib.market.reconcile_flat_lib import reconcile_external_close_impl - from lib.market.symbol_lib import symbols_match - - global _RECONCILE_FLAT_STREAK - - return reconcile_external_close_impl( - conn, - symbol, - direction, - exchange_configured=exchange_private_api_configured, - not_configured_msg="未配置 OKX_API_KEY / OKX_API_SECRET", - symbols_match=symbols_match, - get_opened_at_value=get_opened_at_value, - resolve_monitor_exchange_symbol=resolve_monitor_exchange_symbol, - get_live_position_contracts=get_live_position_contracts, - cancel_conditional_orders=cancel_okx_swap_open_orders, - resolve_synced_flat_close=resolve_synced_flat_close, - finalize_stopped_monitor=_finalize_hub_flat_monitor_okx, - sync_trade_records=sync_trade_records_from_exchange, - reconcile_flat_streak=_RECONCILE_FLAT_STREAK, - to_ms_with_fallback=_to_ms_with_fallback, - prefer_manual_resolve=False, - order_row_monitor_type=order_row_monitor_type, - ) - - -def reconcile_external_closes(conn, days=None): - global _RECONCILE_FLAT_STREAK - if not exchange_private_api_configured(): - return 0 - if time.time() - _APP_STARTED_AT < RECONCILE_STARTUP_GRACE_SEC: - return 0 - synced_count = 0 - cutoff_ms = None - if days is not None: - try: - d = int(days) - if d > 0: - cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) - except Exception: - cutoff_ms = None - rows = conn.execute( - "SELECT * FROM order_monitors WHERE status IN ('active', 'error')" - ).fetchall() - for r in rows: - if cutoff_ms is not None: - opened_at_v = get_opened_at_value(r) - opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) - # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 - if opened_ms is None or opened_ms < cutoff_ms: - continue - oid = int(r["id"]) - if r["status"] == "error": - opened_at_chk = get_opened_at_value(r) - existing = conn.execute( - "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", - (r["symbol"], opened_at_chk, order_row_monitor_type(r)), - ).fetchone() - if existing: - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) - synced_count += 1 - continue - exchange_symbol = r["exchange_symbol"] or normalize_okx_symbol(r["symbol"]) - live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) - if live_contracts is None: - _RECONCILE_FLAT_STREAK.pop(oid, None) - continue - if live_contracts > 0: - _RECONCILE_FLAT_STREAK.pop(oid, None) - continue - if r["status"] != "error": - streak = int(_RECONCILE_FLAT_STREAK.get(oid, 0)) + 1 - _RECONCILE_FLAT_STREAK[oid] = streak - if streak < RECONCILE_FLAT_CONFIRM_POLLS: - continue - _RECONCILE_FLAT_STREAK.pop(oid, None) - print( - f"[reconcile_external_closes] {r['symbol']} id={oid} " - f"flat x{streak} polls -> sync close" - ) - else: - _RECONCILE_FLAT_STREAK.pop(oid, None) - print( - f"[reconcile_external_closes] error recovery {r['symbol']} id={oid} flat -> sync close" - ) - opened_at = get_opened_at_value(r) - opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) - result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) - closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() - hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) - session_date = r["session_date"] or get_trading_day(closed_at_dt) - update_session_capital(conn, session_date, pnl_amount) - insert_trade_record( - conn, - symbol=r["symbol"], - monitor_type=trade_record_monitor_type(conn, r), - trend_plan_id=trend_plan_id_from_monitor_row(r), - key_signal_type=order_row_key_signal_type(r), - direction=r["direction"], - trigger_price=r["trigger_price"], - stop_loss=r["stop_loss"], - initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], - take_profit=r["take_profit"], - margin_capital=r["margin_capital"], - leverage=r["leverage"], - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=r["trade_style"], - entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), - actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), - result=result, - miss_reason=handoff_trade_miss_reason(miss_reason, r), - opened_at=opened_at, - closed_at=closed_at, - ) - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) - if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): - send_wechat_msg( - build_wechat_close_message( - symbol=r["symbol"], - direction=r["direction"], - result=f"{result}(自动同步)", - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=r["trigger_price"], - current_price="-", - stop_loss=r["stop_loss"], - take_profit=r["take_profit"], - close_order_id="-", - extra_note=miss_reason, - ) - ) - else: - send_wechat_msg( - build_wechat_close_message( - symbol=r["symbol"], - direction=r["direction"], - result="外部平仓(自动同步)", - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=r["trigger_price"], - current_price="-", - stop_loss=r["stop_loss"], - take_profit=r["take_profit"], - close_order_id="-", - extra_note=miss_reason, - ) - ) - synced_count += 1 - return synced_count - - -def _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): - return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[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) - active = get_active_position_count(conn) - if active <= 0: - set_key_sizing_capital_snapshot(conn, trading_day, live) - return live - if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT: - snap = get_key_sizing_capital_snapshot(conn, trading_day) - if snap is not None and snap > 0: - return snap - return live - - -def _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() - - -def check_order_monitors(): - conn = get_db() - rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() - for r in rows: - pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] - margin_capital = r["margin_capital"] or DAILY_START_CAPITAL - leverage = r["leverage"] or infer_leverage(sym) - session_date = r["session_date"] or get_trading_day() - p = get_price(sym) - if not p: continue - - # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) - risk_amount = float(r["risk_amount"] or 0) - breakeven_armed = int(r["breakeven_armed"] or 0) - if stale_breakeven_armed(direction, trigger_price, stop_loss, breakeven_armed): - conn.execute( - "UPDATE order_monitors SET breakeven_armed=0, breakeven_price=NULL WHERE id=?", - (pid,), - ) - breakeven_armed = 0 - trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) - step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) - step_r = 1.0 if step_r <= 0 else step_r - breakeven_enabled = True - try: - if "breakeven_enabled" in r.keys(): - breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 - except Exception: - breakeven_enabled = True - if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: - now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) - now_rr = now_pnl / risk_amount - if now_rr >= trigger_rr: - steps = int((now_rr - trigger_rr) // step_r) - locked_r = max(0.0, steps * step_r) - notional = float(margin_capital or 0) * float(leverage or 0) - risk_frac = (risk_amount / notional) if notional > 0 else None - if risk_frac and risk_frac > 0: - new_sl = calc_breakeven_stop( - direction, - trigger_price, - risk_frac, - locked_r=locked_r, - offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT), - ) - if new_sl is not None: - should_move = (direction == "short" and new_sl < float(stop_loss)) or ( - direction == "long" and new_sl > float(stop_loss) - ) - if should_move: - was_armed = breakeven_armed - ex_sym = resolve_monitor_exchange_symbol(r) - new_sl = round_price_to_exchange(ex_sym, new_sl) - tp_ex = float(take_profit or 0) - ok_live, _live_reason = ensure_okx_live_ready() - synced_ex = False - last_ex_sync = float(_BREAKEVEN_LAST_EX_SYNC.get(pid, 0)) - interval_ok = ( - time.time() - last_ex_sync - ) >= BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC - if ok_live and tp_ex > 0 and interval_ok: - try: - replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex) - synced_ex = True - _BREAKEVEN_LAST_EX_SYNC[pid] = time.time() - _clear_breakeven_exchange_warn(pid) - except Exception as e: - print( - f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}", - flush=True, - ) - _send_breakeven_exchange_warn_once( - pid, - f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_okx_error(e)}", - ) - elif ok_live: - print( - f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit", - flush=True, - ) - if synced_ex: - conn.execute( - "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", - (new_sl, new_sl, pid), - ) - stop_loss = new_sl - breakeven_armed = 1 - if not was_armed: - arm_txt = "保本止盈" - be_msg = build_wechat_breakeven_message( - sym, - direction, - arm_txt, - now_rr, - locked_r, - new_sl, - ) - if ok_live: - be_msg += "\n- 交易所:已先撤后挂止盈止损" - send_wechat_msg(be_msg) - - res = None - if should_trigger_time_close(r): - res = TIME_CLOSE_RESULT - # 做多 - if not res and direction == "long": - if p >= take_profit: res = "止盈" - elif p <= stop_loss: res = "止损" - # 做空 - elif not res and direction == "short": - if p <= take_profit: res = "止盈" - elif p >= stop_loss: res = "止损" - - if res: - now = app_now() - opened_at = get_opened_at_value(r) - opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None) - closed_at = now.strftime("%Y-%m-%d %H:%M:%S") - hold_seconds = calc_hold_seconds(opened_at, now) - pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) - if res == "止损" and float(pnl_amount or 0) > 0: - res = normalize_result_with_pnl("止损", pnl_amount) - else: - res = normalize_result_with_pnl(res, pnl_amount) - close_order_id = "" - try: - close_resp = close_exchange_order(r) - close_order_id = close_resp.get("id", "") - # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细. - exit_p = extract_trade_price_from_order(close_resp) - if exit_p and exit_p > 0: - pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) - guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) - if guessed_res: - res = normalize_result_with_pnl(guessed_res, pnl_amount) - else: - res = normalize_result_with_pnl(res, pnl_amount) - else: - ex_sym = r["exchange_symbol"] or normalize_okx_symbol(sym) - tr = fetch_latest_closing_fill( - ex_sym, - direction, - opened_at, - opened_at_ms=opened_at_ms, - ) - if tr and tr.get("price"): - try: - exit_p = float(tr["price"]) - pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) - guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) - if guessed_res: - if guessed_res == "止损" and float(pnl_amount or 0) > 0: - res = normalize_result_with_pnl("止损", pnl_amount) - else: - res = normalize_result_with_pnl(guessed_res, pnl_amount) - else: - res = normalize_result_with_pnl(res, pnl_amount) - except (TypeError, ValueError): - pass - ts = tr.get("timestamp") - if ts: - closed_at = ms_to_app_local_str(int(ts)) - hold_seconds = calc_hold_seconds( - opened_at, parse_dt_for_trading_day(closed_at) or now - ) - except Exception as e: - if is_no_position_error(str(e)): - ex_sym = r["exchange_symbol"] or normalize_okx_symbol(sym) - tr = fetch_latest_closing_fill( - ex_sym, - direction, - opened_at, - opened_at_ms=opened_at_ms, - ) - if tr and tr.get("price"): - try: - exit_p = float(tr["price"]) - pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) - # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判. - guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) - if guessed_res: - if guessed_res == "止损" and float(pnl_amount or 0) > 0: - res = normalize_result_with_pnl("止损", pnl_amount) - else: - res = normalize_result_with_pnl(guessed_res, pnl_amount) - else: - res = normalize_result_with_pnl(res, pnl_amount) - except (TypeError, ValueError): - pass - ts = tr.get("timestamp") - if ts: - closed_at = ms_to_app_local_str(int(ts)) - hold_seconds = calc_hold_seconds( - opened_at, parse_dt_for_trading_day(closed_at) or now - ) - insert_trade_record( - conn, - symbol=sym, - monitor_type=trade_record_monitor_type(conn, r), - trend_plan_id=trend_plan_id_from_monitor_row(r), - key_signal_type=order_row_key_signal_type(r), - direction=direction, - trigger_price=trigger_price, - stop_loss=stop_loss, - initial_stop_loss=r["initial_stop_loss"] or stop_loss, - take_profit=take_profit, - margin_capital=margin_capital, - leverage=leverage, - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=r["trade_style"], - entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), - actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), - result=res, - miss_reason=handoff_trade_miss_reason( - "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", - r, - ), - opened_at=opened_at, - closed_at=closed_at, - ) - session_capital = update_session_capital(conn, session_date, pnl_amount) - send_wechat_msg( - build_wechat_close_message( - symbol=sym, - direction=direction, - result=f"{res}(交易所已先行平仓)", - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=trigger_price, - current_price=p, - stop_loss=stop_loss, - take_profit=take_profit, - close_order_id="-", - extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", - session_capital_fallback=session_capital, - ) - ) - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) - conn.commit() - continue - ex_sym_fail = r["exchange_symbol"] or normalize_okx_symbol(sym) - live_contracts = get_live_position_contracts(ex_sym_fail, direction) - if live_contracts is not None and live_contracts <= 0: - record_res, record_pnl, record_closed, sync_miss = resolve_synced_flat_close( - r, opened_at, opened_at_ms=opened_at_ms - ) - record_miss = f"{sync_miss};本地触发{res}时平仓API失败:{e}" - monitor_status = "stopped" - else: - record_res, record_pnl, record_closed = res, pnl_amount, closed_at - record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}" - monitor_status = "error" - record_hold = calc_hold_seconds( - opened_at, parse_dt_for_trading_day(record_closed) or now - ) - insert_trade_record( - conn, - symbol=sym, - monitor_type=trade_record_monitor_type(conn, r), - trend_plan_id=trend_plan_id_from_monitor_row(r), - key_signal_type=order_row_key_signal_type(r), - direction=direction, - trigger_price=trigger_price, - stop_loss=stop_loss, - initial_stop_loss=r["initial_stop_loss"] or stop_loss, - take_profit=take_profit, - margin_capital=margin_capital, - leverage=leverage, - pnl_amount=record_pnl, - hold_seconds=record_hold, - trade_style=r["trade_style"], - entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), - actual_rr=calc_actual_rr(record_pnl, r["risk_amount"]), - result=record_res, - miss_reason=handoff_trade_miss_reason(record_miss, r), - opened_at=opened_at, - closed_at=record_closed, - ) - session_capital = update_session_capital(conn, session_date, record_pnl) - conn.execute("UPDATE order_monitors SET status=? WHERE id=?", (monitor_status, pid)) - conn.commit() - send_wechat_msg( - build_wechat_monitor_error_message( - symbol=sym, - direction=direction, - scene=f"触发{res}后交易所平仓失败", - error_text=str(e), - ) - ) - if monitor_status == "stopped": - send_wechat_msg( - build_wechat_close_message( - symbol=sym, - direction=direction, - result=f"{record_res}(已补记入交易记录)", - pnl_amount=record_pnl, - hold_seconds=record_hold, - trigger_price=trigger_price, - current_price=p, - stop_loss=stop_loss, - take_profit=take_profit, - close_order_id="-", - extra_note=record_miss, - session_capital_fallback=session_capital, - ) - ) - continue - session_capital = update_session_capital(conn, session_date, pnl_amount) - send_wechat_msg( - build_wechat_close_message( - symbol=sym, - direction=direction, - result=res, - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=trigger_price, - current_price=p, - stop_loss=stop_loss, - take_profit=take_profit, - close_order_id=close_order_id or "-", - session_capital_fallback=session_capital, - ) - ) - insert_trade_record( - conn, - symbol=sym, - monitor_type=trade_record_monitor_type(conn, r), - trend_plan_id=trend_plan_id_from_monitor_row(r), - key_signal_type=order_row_key_signal_type(r), - direction=direction, - trigger_price=trigger_price, - stop_loss=stop_loss, - initial_stop_loss=r["initial_stop_loss"] or stop_loss, - take_profit=take_profit, - margin_capital=margin_capital, - leverage=leverage, - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=r["trade_style"], - entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), - actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), - result=res, - miss_reason=handoff_trade_miss_reason(None, r), - opened_at=opened_at, - closed_at=closed_at, - ) - conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid)) - conn.commit() - conn.close() - - -def force_close_before_reset(): - if not FORCE_CLOSE_ENABLED: - return - now = app_now() - # 每天北京时间指定整点起 FORCE_CLOSE_GRACE_MINUTES 分钟内执行兜底清仓 - from lib.trade.force_close_lib import is_force_close_executing - - if not is_force_close_executing(FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000)): - return - conn = get_db() - rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() - for r in rows: - p = get_price(r["symbol"]) - if not p: - continue - direction = r["direction"] - trigger_price = r["trigger_price"] - margin_capital = r["margin_capital"] or DAILY_START_CAPITAL - leverage = r["leverage"] or infer_leverage(r["symbol"]) - session_date = r["session_date"] or get_trading_day(now) - opened_at = get_opened_at_value(r) - closed_at = now.strftime("%Y-%m-%d %H:%M:%S") - hold_seconds = calc_hold_seconds(opened_at, now) - pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) - try: - close_resp = close_exchange_order(r) - close_order_id = close_resp.get("id", "") - except Exception as e: - conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) - conn.commit() - send_wechat_msg( - build_wechat_monitor_error_message( - symbol=r["symbol"], - direction=direction, - scene="强制清仓失败", - error_text=str(e), - ) - ) - continue - session_capital = update_session_capital(conn, session_date, pnl_amount) - insert_trade_record( - conn, - symbol=r["symbol"], - monitor_type=trade_record_monitor_type(conn, r), - trend_plan_id=trend_plan_id_from_monitor_row(r), - key_signal_type=order_row_key_signal_type(r), - direction=direction, - trigger_price=trigger_price, - stop_loss=r["stop_loss"], - initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], - take_profit=r["take_profit"], - margin_capital=margin_capital, - leverage=leverage, - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=r["trade_style"], - entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), - actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), - result="强制清仓", - miss_reason=handoff_trade_miss_reason( - f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", - r, - ), - opened_at=opened_at, - closed_at=closed_at, - ) - conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"])) - send_wechat_msg( - build_wechat_close_message( - symbol=r["symbol"], - direction=direction, - result="强制清仓", - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=trigger_price, - current_price=p, - stop_loss=r["stop_loss"], - take_profit=r["take_profit"], - close_order_id=close_order_id or "-", - extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", - session_capital_fallback=session_capital, - ) - ) - conn.commit() - conn.close() - -# 后台线程(永续已下线:不再跑划转/强平/关键位/委托监控;期权监控见 options_monitor_loop) -def background_task(): - while 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 - - -@app.route("/sync_positions") -@login_required -def sync_positions(): - days_raw = (request.args.get("days") or "").strip() - sync_days = None - if days_raw: - try: - sync_days = max(1, min(365, int(days_raw))) - except Exception: - sync_days = None - conn = get_db() - synced = reconcile_external_closes(conn, days=sync_days) - conn.commit() - conn.close() - if sync_days is not None: - flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") - else: - flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") - return redirect("/") - - -@app.route("/api/sync_positions", methods=["POST"]) -@login_required -def api_sync_positions(): - payload = request.get_json(silent=True) or {} - days_raw = str(payload.get("days", "")).strip() - if not days_raw: - return jsonify({"ok": False, "msg": "请填写天数"}), 400 - try: - days = int(days_raw) - except Exception: - return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 - if days < 1 or days > 365: - return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 - conn = get_db() - synced = reconcile_external_closes(conn, days=days) - conn.commit() - conn.close() - return jsonify({"ok": True, "days": days, "synced": int(synced)}) - - -# ====================== 主页面 ====================== -def render_main_page(page="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"]) - 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) - 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) - 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 = [] - if plan.orders: - raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() - for o in raw_order_list: - order_list.append(enrich_order_item(row_to_dict(o), capital_for_sizing)) - enrich_orders_force_close( - order_list, - FORCE_CLOSE_ENABLED, - FORCE_CLOSE_BJ_HOUR, - now_ms=int(app_now().timestamp() * 1000), - ) - 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) - 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) - from lib.trade.open_trade_gate_lib import resolve_manual_open_gate - - _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 "0.95"), - 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(): - # 永续关键位监控已下线 - return redirect("/options") - - -@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(): - return redirect("/options") - - -@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"]) - 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) - 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 = conn.execute( - "SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'" - ).fetchall() - 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 - ) - except Exception: - pass - conn.close() - open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR - from lib.trade.open_trade_gate_lib import resolve_manual_open_gate - - _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,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors" - ).fetchall() - order_rows = conn.execute( - "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,order_amount," - "time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status='active'" - ).fetchall() - - try: - ensure_markets_loaded() - except Exception: - pass - - symbol_set = set() - for r in key_rows: - symbol_set.add(r["symbol"]) - for r in order_rows: - symbol_set.add(r["symbol"]) - - prices = {} - for s in symbol_set: - p = get_price(s) - if p is not None: - prices[s] = float(p) - - all_swap_positions = [] - if exchange_private_api_configured(): - try: - ensure_markets_loaded() - # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 - all_swap_positions = exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or [] - except Exception: - try: - all_swap_positions = exchange.fetch_positions() or [] - except Exception: - all_swap_positions = [] - - key_prices = [] - for r in key_rows: - price = prices.get(r["symbol"]) - if price is None: - 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"], - } - ) - - order_prices = [] - for r in order_rows: - margin = float(r["margin_capital"] or 0) - leverage = float(r["leverage"] or 0) - entry = float(r["trigger_price"] or 0) - exchange_tpsl = {"sl": None, "tp": None} - ex_sym = resolve_monitor_exchange_symbol(r) - prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) - lev_row = r["leverage"] if "leverage" in r.keys() else None - ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None - price = resolve_order_snapshot_price( - r["symbol"], - prices, - position_row=prow, - order_leverage=lev_row, - parse_position_metrics_fn=parse_ccxt_position_metrics, - get_mark_price_fn=get_symbol_mark_price, - fallback_entry=entry if entry > 0 else None, - ) - pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 and price else 0 - pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 - payload = { - "id": r["id"], - "symbol": r["symbol"], - "float_pnl": round(pnl, 2), - "float_pct": pnl_pct, - "plan_margin": round(margin, 2) if margin else None, - "order_amount": float(r["order_amount"]) if r["order_amount"] not in (None, "") else None, - "exchange_initial_margin": None, - "exchange_notional": None, - "exchange_mark_price": None, - "pnl_source": "plan", - } - if ex_metrics: - if ex_metrics.get("initial_margin") is not None: - payload["exchange_initial_margin"] = ex_metrics["initial_margin"] - if ex_metrics.get("notional") is not None: - payload["exchange_notional"] = ex_metrics["notional"] - if ex_metrics.get("mark_price") is not None: - payload["exchange_mark_price"] = ex_metrics["mark_price"] - if ex_metrics.get("unrealized_pnl") is not None: - payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 2) - payload["pnl_source"] = "exchange" - denom = ex_metrics.get("initial_margin") or margin - payload["float_pct"] = ( - round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct - ) - px_for_fmt = None - if price is not None: - try: - px_for_fmt = float(price) - except (TypeError, ValueError): - px_for_fmt = None - if ex_metrics and ex_metrics.get("mark_price") is not None: - try: - px_for_fmt = float(ex_metrics["mark_price"]) - except (TypeError, ValueError): - pass - if px_for_fmt is not None: - px_disp = format_price_for_symbol(r["symbol"], px_for_fmt) - try: - payload["price"] = float(px_disp) if px_disp != "-" else px_for_fmt - except Exception: - payload["price"] = px_for_fmt - payload["price_display"] = px_disp - else: - payload["price"] = None - payload["price_display"] = "-" - if exchange_private_api_configured(): - try: - exchange_tpsl = fetch_exchange_tpsl_slots( - ex_sym, - r["direction"], - plan_sl=r["stop_loss"], - plan_tp=r["take_profit"], - ) - except Exception: - exchange_tpsl = {"sl": None, "tp": None} - payload["exchange_tpsl"] = exchange_tpsl - avg_entry = None - if ex_metrics and ex_metrics.get("entry_price") is not None: - avg_entry = ex_metrics["entry_price"] - elif prow: - from lib.market.position_metrics_lib import parse_position_entry_price - - avg_entry = parse_position_entry_price(prow) - apply_order_price_display_fields( - payload, - direction=r["direction"], - entry_price=entry, - initial_stop_loss=r["initial_stop_loss"], - stop_loss=r["stop_loss"], - take_profit=r["take_profit"], - calc_rr_ratio_fn=calc_rr_ratio, - exchange_tpsl=exchange_tpsl, - format_price_fn=format_price_for_symbol, - symbol=r["symbol"], - margin_capital=margin, - leverage=leverage, - exchange_notional=ex_metrics.get("notional") if ex_metrics else None, - contracts=abs(_position_row_effective_contracts(prow)) if prow else None, - contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, - mark_price=ex_metrics.get("mark_price") if ex_metrics else price, - avg_entry_price=avg_entry, - funds_decimals=FUNDS_DECIMALS, - ) - apply_time_close_to_payload(payload, r) - apply_force_close_to_payload( - payload, - enabled=FORCE_CLOSE_ENABLED, - bj_hour=FORCE_CLOSE_BJ_HOUR, - ) - payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None - open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None - payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None - new_sl, new_tp, changed = order_monitor_tpsl_needs_sync( - r["stop_loss"], r["take_profit"], exchange_tpsl - ) - if changed: - try: - conn.execute( - "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", - (new_sl, new_tp, int(r["id"])), - ) - except Exception: - pass - order_prices.append(payload) - - try: - conn.commit() - except Exception: - pass - conn.close() - - from lib.market.position_metrics_lib import build_position_marks_list - - position_marks = build_position_marks_list( - all_swap_positions, - format_mark_display=lambda sym, px: format_price_for_symbol(sym, px), - ) - - 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": order_prices, - "position_marks": position_marks, - "positions_raw_count": len(all_swap_positions), - "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("/api/order_defaults") -@login_required -def api_order_defaults(): - symbol = normalize_symbol_input(request.args.get("symbol")) - direction = (request.args.get("direction") or "long").strip().lower() - if not symbol: - return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 - if direction not in ("long", "short"): - direction = "long" - exchange_symbol = normalize_okx_symbol(symbol) - try: - leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) - except Exception: - leverage = infer_leverage(symbol) - try: - available = get_available_trading_usdt() - except Exception: - available = None - last_price = get_price(symbol) - return jsonify({ - "ok": True, - "symbol": symbol, - "exchange_symbol": exchange_symbol, - "direction": direction, - "leverage": leverage, - "available_trading_usdt": round(available, 4) if available is not None else None, - "last_price": round(float(last_price), 8) if last_price is not None else None, - "price": round(float(last_price), 8) if last_price is not None else None, - }) - - -@app.route("/order_focus") -@login_required -def order_focus(): - return redirect("/options") - - -@app.route("/add_order", methods=["POST"]) -@login_required -def add_order(): - # 实盘下单界面已移除;永续开平仓请走对冲计划(place_exchange_order / close_exchange_order) - flash("永续实盘下单已下线,请使用期权页") - return redirect("/options") - - -@app.route("/api/order_kline") -@login_required -def api_order_kline(): - order_id_raw = (request.args.get("order_id") or "").strip() - if not order_id_raw.isdigit(): - return jsonify({"ok": False, "msg": "order_id 无效"}), 400 - order_id = int(order_id_raw) - timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() - allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"} - if timeframe not in allowed_tfs: - timeframe = KLINE_TIMEFRAME - limit = 100 - - now = app_now() - trading_day = get_trading_day(now) - conn = get_db() - session_row = ensure_session(conn, trading_day) - local_current_capital = float(session_row["current_capital"]) - _, trading_capital_live = get_exchange_capitals() - current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) - row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() - conn.close() - if not row: - return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404 - - order_item = enrich_order_item(row_to_dict(row), current_capital) - exchange_symbol = order_item.get("exchange_symbol") or normalize_okx_symbol(order_item["symbol"]) - try: - ensure_markets_loaded() - ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) - except Exception as e: - return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_okx_error(e)}"}), 500 - - candles = [] - for bar in ohlcv or []: - if not bar or len(bar) < 6: - continue - ts = int(bar[0] // 1000) - candles.append({ - "time": ts, - "open": float(bar[1]), - "high": float(bar[2]), - "low": float(bar[3]), - "close": float(bar[4]), - "volume": float(bar[5]), - }) - - from lib.instance.focus_chart_lib import ( - build_order_kline_order_payload, - load_swap_positions_for_order_kline, - metrics_for_order_item, - ) - - current_price = get_price(order_item["symbol"]) - positions = load_swap_positions_for_order_kline( - exchange, - private_configured=exchange_private_api_configured(), - ensure_markets_fn=ensure_markets_loaded, - ) - ex_metrics = metrics_for_order_item( - order_item, - positions, - resolve_ex_sym_fn=resolve_monitor_exchange_symbol, - select_live_fn=_select_live_position_row, - parse_metrics_fn=parse_ccxt_position_metrics, - ) - order_payload = build_order_kline_order_payload( - order_item, - ticker_price=current_price, - format_price_fn=format_price_for_symbol, - calc_pnl_fn=calc_pnl, - calc_rr_ratio_fn=calc_rr_ratio, - ex_metrics=ex_metrics, - ) - - from 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, - "timeframe": timeframe, - "limit": limit, - "order": order_payload, - "candles": candles, - "updated_at": app_now_str(), - **price_fields, - }) - - -@app.route("/key_focus") -@login_required -def key_focus(): - conn = get_db() - key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() - conn.close() - key_list = [row_to_dict(r) for r in key_rows] - - key_id_raw = (request.args.get("key_id") or "").strip() - symbol_query = normalize_symbol_input(request.args.get("symbol")) - selected_key = None - if key_id_raw.isdigit(): - selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None) - if selected_key is None and symbol_query: - selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None) - if selected_key is None and key_list: - selected_key = key_list[0] - default_symbol = 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("/api/order//cancel_tpsl", methods=["POST"]) -@login_required -def api_order_cancel_tpsl(order_id): - from lib.trade.trade_policy_lib import is_intraday_trading_profile - - if is_intraday_trading_profile(TRADE_POLICY): - return jsonify({"ok": False, "msg": "日内纪律账户禁止撤销交易所止盈止损"}), 403 - data = request.get_json(silent=True) or {} - role = (data.get("role") or "").strip().lower() - if role not in ("sl", "tp"): - return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 - conn = get_db() - row = conn.execute( - "SELECT * FROM order_monitors WHERE id=? AND status='active'", - (order_id,), - ).fetchone() - conn.close() - if not row: - return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 - ok, reason = ensure_okx_live_ready() - if not ok: - return jsonify({"ok": False, "msg": reason}), 400 - ex_sym = resolve_monitor_exchange_symbol(row) - slots = fetch_exchange_tpsl_slots(ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"]) - slot = slots.get(role) - if not slot: - return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 - try: - cancel_okx_tpsl_slot(ex_sym, slot) - return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": fetch_exchange_tpsl_slots(ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"])}) - except Exception as e: - return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 - - -@app.route("/api/order//place_tpsl", methods=["POST"]) -@login_required -def api_order_place_tpsl(order_id): - data = request.get_json(silent=True) or {} - conn = get_db() - row = conn.execute( - "SELECT * FROM order_monitors WHERE id=? AND status='active'", - (order_id,), - ).fetchone() - if not row: - conn.close() - return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 - symbol = row["symbol"] - direction = row["direction"] - live_price = get_price(symbol) - if live_price is None: - conn.close() - return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400 - try: - sltp_mode = (data.get("sltp_mode") or "price").strip().lower() - stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data) - except Exception as e: - conn.close() - return jsonify({"ok": False, "msg": str(e)}), 400 - planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit) - if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR: - conn.close() - rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" - return jsonify( - { - "ok": False, - "msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1", - } - ), 400 - try: - replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) - except Exception as e: - conn.close() - return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 - conn.execute( - "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", - (stop_loss, take_profit, order_id), - ) - conn.commit() - ex_sym = resolve_monitor_exchange_symbol(row) - slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit) - prow = None - ex_metrics = None - if exchange_private_api_configured(): - try: - rows = exchange.fetch_positions([ex_sym]) or exchange.fetch_positions() or [] - prow = _select_live_position_row(rows, ex_sym, direction) - if prow: - ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=row["leverage"]) - except Exception: - pass - from lib.trade.order_monitor_display_lib import enrich_active_monitor_tpsl_json - - ex_sym = resolve_monitor_exchange_symbol(row) - display_extra = enrich_active_monitor_tpsl_json( - row, - stop_loss, - take_profit, - slots, - position_row=prow, - exchange_notional=ex_metrics.get("notional") if ex_metrics else None, - contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, - mark_price=live_price, - calc_rr_ratio_fn=calc_rr_ratio, - format_price_fn=format_price_for_symbol, - symbol=symbol, - funds_decimals=FUNDS_DECIMALS, - ) - conn.close() - return jsonify( - { - "ok": True, - "msg": "已先撤后挂止盈止损", - "stop_loss": stop_loss, - "take_profit": take_profit, - "planned_rr": planned_rr, - "exchange_tpsl": slots, - **display_extra, - } - ) - -@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): - conn = get_db() - row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() - if not row: - conn.close() - flash("订单不存在") - return redirect("/") - if row["status"] == "active": - try: - p = get_price(row["symbol"]) or float(row["trigger_price"]) - opened_at = get_opened_at_value(row) - closed_at = app_now_str() - hold_seconds = calc_hold_seconds(opened_at, app_now()) - pnl_amount = calc_pnl( - row["direction"], - row["trigger_price"], - p, - row["margin_capital"] or DAILY_START_CAPITAL, - row["leverage"] or infer_leverage(row["symbol"]) - ) - close_resp = close_exchange_order(row) - close_order_id = close_resp.get("id", "") - session_date = row["session_date"] or get_trading_day() - session_capital = update_session_capital(conn, session_date, pnl_amount) - insert_trade_record( - conn, - symbol=row["symbol"], - monitor_type=trade_record_monitor_type(conn, row), - trend_plan_id=trend_plan_id_from_monitor_row(row), - key_signal_type=order_row_key_signal_type(row), - direction=row["direction"], - trigger_price=row["trigger_price"], - stop_loss=row["stop_loss"], - initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], - take_profit=row["take_profit"], - margin_capital=row["margin_capital"], - leverage=row["leverage"], - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=row["trade_style"], - entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), - risk_amount=row["risk_amount"], - planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), - actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), - result="手动平仓", - miss_reason=handoff_trade_miss_reason("用户手动删除订单触发平仓", row), - opened_at=opened_at, - closed_at=closed_at, - ) - from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close - - on_user_initiated_close( - conn, - source=CLOSE_SOURCE_USER_INSTANCE, - trade_record_id=insert_trade_record_id(conn), - closed_at_ms=_to_ms_with_fallback(None, closed_at), - trading_day=session_date, - now=app_now(), - ) - conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) - conn.commit() - conn.close() - send_wechat_msg( - build_wechat_close_message( - symbol=row["symbol"], - direction=row["direction"], - result="手动平仓", - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=row["trigger_price"], - current_price=p, - stop_loss=row["stop_loss"], - take_profit=row["take_profit"], - close_order_id=close_order_id or "-", - extra_note="用户在页面手动平仓", - session_capital_fallback=session_capital, - ) - ) - flash("已按实盘流程手动平仓") - return redirect("/") - except Exception as e: - if is_no_position_error(str(e)): - opened_at = get_opened_at_value(row) - opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) - result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) - miss_reason = f"手动删除时无持仓:{miss_reason}" - closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() - hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) - session_date = row["session_date"] or get_trading_day(closed_at_dt) - update_session_capital(conn, session_date, pnl_amount) - insert_trade_record( - conn, - symbol=row["symbol"], - monitor_type=trade_record_monitor_type(conn, row), - trend_plan_id=trend_plan_id_from_monitor_row(row), - key_signal_type=order_row_key_signal_type(row), - direction=row["direction"], - trigger_price=row["trigger_price"], - stop_loss=row["stop_loss"], - initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], - take_profit=row["take_profit"], - margin_capital=row["margin_capital"], - leverage=row["leverage"], - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=row["trade_style"], - entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), - risk_amount=row["risk_amount"], - planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), - actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), - result=result, - miss_reason=handoff_trade_miss_reason(miss_reason, row), - opened_at=opened_at, - closed_at=closed_at, - ) - from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close - - on_user_initiated_close( - conn, - source=CLOSE_SOURCE_USER_INSTANCE, - trade_record_id=insert_trade_record_id(conn), - closed_at_ms=_to_ms_with_fallback(None, closed_at), - trading_day=session_date, - now=app_now(), - ) - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) - conn.commit() - conn.close() - flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") - return redirect("/") - conn.close() - flash(f"手动平仓失败:{str(e)}") - return redirect("/") - conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) - conn.commit() - conn.close() - return redirect("/") - - -@app.route("/add_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): - from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks - - return enrich_order_items_with_marks( - items, get_price=get_price, get_contract_size=get_contract_size - ) - - -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(): - try: - amount = float(request.form.get("amount", "0")) - except Exception: - flash("划转金额格式错误") - return redirect("/settings?settings_tab=transfer") - from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() - to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() - ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) - conn = get_db() - conn.execute( - "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", - ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) - ) - conn.commit() - conn.close() - if ok: - invalidate_account_balance_cache() - try: - from lib.instance.instance_live_push_lib import notify_instance_balance_changed - - notify_instance_balance_changed() - except Exception: - pass - flash(f"手动划转成功:{amount}U {from_account}->{to_account}") - else: - flash(f"手动划转失败:{msg}") - return redirect("/settings?settings_tab=transfer") - - - - -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.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() - # 永续 background_task 已下线;期权监控由 install_options_trading 启动 - app.run(host=HOST, port=PORT, debug=DEBUG, threaded=True) +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.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, + ) + + ensure_account_risk_schema(conn) + now = app_now() + 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, + ) + + +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"]) + 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) + 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) + 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) + 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 "0.95"), + 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(): + return redirect("/options") + + +@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"]) + 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) + 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 + ) + 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.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) diff --git a/docs/审计报告-永续删除-关键位保留-2026-10-07.md b/docs/审计报告-永续删除-关键位保留-2026-10-07.md new file mode 100644 index 0000000..3d0f4d3 --- /dev/null +++ b/docs/审计报告-永续删除-关键位保留-2026-10-07.md @@ -0,0 +1,79 @@ +# 永续删除 / 关键位保留 — 审计报告(2026-10-07) + +## 目标 + +- **保留**:关键位监控(`key_monitor` 页面、`/key_*` 与 `/api/key_*` 路由、`check_key_monitors()`、`lib/key_monitor/*`、相关 DB 表)。 +- **删除**:永续下单/平仓、下单监控 CRUD、自动划转/强平、仅服务于永续的 lib 与模板;期权与 hub bridge 路径不受影响。 + +## 已删除或下线的能力 + +### app.py + +- 移除:`check_order_monitors`、`force_close_before_reset`、`auto_transfer_once_per_day`、`reconcile_external_close(s)`、OKX 永续开平仓与 TP/SL 同步大段逻辑。 +- `place_exchange_order` / `close_exchange_order` **已物理删除**(调用方与 sim 包装一并移除)。 +- `background_task()` 仅调用 `check_key_monitors()`;`if __name__` 下恢复 `threading.Thread(..., daemon=True).start()`。 +- `/api/price_snapshot`:`order_prices` 恒为 `[]`,不再查询 `order_monitors`。 +- `render_main_page`:`order_list` 恒为 `[]`;开仓门禁改为本地 stub(`can_trade=False`)。 +- 永续相关路由改为 `redirect('/options')` 或 410 JSON:`/add_order`、`/del_order`、`/order_focus`、`/api/order_*`、`/sync_positions`、`/manual_transfer` 等。 + +### 删除的文件(lib / templates) + +| 路径 | 说明 | +|------|------| +| `lib/trade/force_close_lib.py` | 强平 UI/逻辑 | +| `lib/trade/open_trade_gate_lib.py` | 永续开仓门禁 | +| `lib/trade/daily_open_limit_lib.py` | 日开仓上限 | +| `lib/trade/order_monitor_display_lib.py` | 下单监控展示 | +| `lib/trade/manual_sltp_lib.py` | 手动 TP/SL | +| `lib/trade/position_sizing_lib.py` | 仓位 sizing | +| `lib/trade/trade_fee_lib.py` | 手续费净盈亏 | +| `lib/trade/trade_exchange_stats_lib.py` | 成交统计回填 | +| `lib/trade/trade_stats_calendar_lib.py` | 日历统计 | +| `lib/trade/compensating_close_lib.py` | 补偿平仓 | +| `lib/common/auto_transfer_daily_lib.py` | 日自动划转 | +| `lib/exchange/gate_transfer_lib.py` | Gate 划转 | +| `lib/exchange/okx_orders_lib.py` | OKX 永续挂单查询 | +| `lib/market/reconcile_flat_lib.py` | 外部平仓对账 | +| `order_monitor_*`、`force_close_*`、`instance_transfer_panel.html`、`gate_transfer_block.html` | 永续 UI 模板 | +| `order_focus_v2.html`、`order_plan_preview_bar.html`、`order_leverage_fields.html`、`order_entry_model_fields.html`、`templates/order_focus.html` | 永续下单/聚焦页残留模板 | + +### 保留的 trade 模块 + +- `trade_policy_lib.py`、`trade_policy_app_lib.py`、`time_close_lib.py` +- `trade_labels_lib.py`、`trade_result_lib.py`(新增 `snapshot_stop_loss`)、`entry_model_lib.py`、`account_risk_lib.py`(日记/风控仍用) + +## Sim / Options + +- `lib/sim/hooks.py`:移除对 `place_exchange_order` / `close_exchange_order` 的包装;保留期权/现货桥/钱包 sim 路径。 +- `lib/sim/register.py`:不再向 options cfg 注入永续下单函数。 + +## 测试 + +在项目根目录执行: + +```text +python -m unittest tests.test_spot_dust_and_sell_cap tests.test_options_partial_close_accum tests.test_options_profit_exit_recycle tests.test_options_btc_pnl_usdt tests.test_hub_bridge_auth -v +``` + +**结果**: + +| 批次 | 用例数 | 结果 | +|------|--------|------| +| 期权粉尘/卖回封顶、部分平仓、翻倍出场、BTC 盈亏、hub auth、去对冲冒烟 | 22 | **OK**(2026-10-07) | + +`python -c "import app"` 可导入(`APP_AUTH_DISABLED=true`)。 + +## 残留风险与后续可选清理 + +1. **DB schema**:`order_monitors` 等表仍在 `init_db()` 中创建,历史数据未迁移;仅不再读写业务逻辑。 +2. **模板/静态**:`index.html` 等仍可能含永续 UI 片段(无后端路由时多为死链);可按 display_prefs 再收一层 UI。 +3. **统计口径**:`compute_stats_bundle` 仍可能统计历史 `order_monitors` 开仓次数;与「零 active 监控」展示一致,但不等于删表。 +4. **设置页**:「transfer」子页改为说明文案;期权/模拟划转仍在各自模块。 +5. **account_risk / entry_model / trade_labels**:日记与设置仍引用,未整包删除。 + +## 关键位验证建议(手工) + +1. 添加 BTC/ETH 关键支撑阻力 → `/key_monitor` 列表可见。 +2. 观察后台线程日志无 `[key_monitor_loop]` 以外永续轮询错误。 +3. `/api/price_snapshot` 返回 `key_prices` 非空(有关键位时)、`order_prices: []`。 +4. 微信提醒依赖环境变量与行情;需在实盘/模拟各测一次 RS 突破通知(若已配置 webhook)。 diff --git a/lib/common/auto_transfer_daily_lib.py b/lib/common/auto_transfer_daily_lib.py deleted file mode 100644 index aaab5bf..0000000 --- a/lib/common/auto_transfer_daily_lib.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -每日自动划转:北京时间指定整点小时内,将交易账户(AUTO_TRANSFER_TO)余额调整至目标额. - -- 交易账户 < 目标:从资金账户划入差额 -- 交易账户 > 目标:将多余划回资金账户 -- 有 active 持仓:不划转,写账簿并企业微信说明 -""" -from __future__ import annotations - -from typing import Any, Callable - - -def run_auto_transfer_once_per_day( - *, - enabled: bool, - bj_hour: int, - target_amount: float, - from_account: str, - to_account: str, - funds_decimals: int, - get_db: Callable[[], Any], - get_active_position_count: Callable[[Any], int], - get_account_usdt_total: Callable[[str], float | None], - execute_transfer_usdt: Callable[[float, str, str], tuple[bool, str, Any]], - send_wechat_msg: Callable[[str], None], - utc_now_dt: Callable[[], Any], - app_tz: Any, - utc_calendar_date_str: Callable[[], str], - app_now_str: Callable[[], str], - min_transfer: float = 0.01, -) -> None: - if not enabled: - return - utc_dt = utc_now_dt() - bj = utc_dt.astimezone(app_tz) - if bj.hour != bj_hour: - return - - transfer_day = utc_calendar_date_str() - conn = get_db() - exists = conn.execute( - "SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?", - ("auto_daily", transfer_day), - ).fetchone() - if exists: - conn.close() - return - - def _log( - amount: float, - fr: str, - to: str, - status: str, - message: str, - *, - commit_close: bool = True, - ) -> None: - conn.execute( - "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", - ("auto_daily", transfer_day, amount, fr, to, status, message[:500]), - ) - conn.commit() - if commit_close: - conn.close() - - active = get_active_position_count(conn) - if active > 0: - msg = f"持仓中({active}笔),本次资金无划转" - _log(0, from_account, to_account, "skipped", msg) - send_wechat_msg( - f"自动划转:{msg}\n" - f"目标:{to_account} 调整至 {round(float(target_amount), funds_decimals)}U\n" - f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" - ) - return - - target = round(float(target_amount), funds_decimals) - trade_bal = get_account_usdt_total(to_account) - if trade_bal is None: - _log( - 0, - from_account, - to_account, - "failed", - f"读取{to_account}账户USDT失败", - ) - return - - trade = round(float(trade_bal), funds_decimals) - diff = round(target - trade, funds_decimals) - - if abs(diff) < min_transfer: - _log( - 0, - from_account, - to_account, - "skipped", - f"{to_account}账户已为{trade}U(目标{target}U)", - ) - return - - if diff > 0: - fr, to, amount = from_account, to_account, diff - action = "划入" - else: - fr, to, amount = to_account, from_account, round(abs(diff), funds_decimals) - action = "划出" - - from_bal = get_account_usdt_total(fr) - if from_bal is not None and round(float(from_bal), funds_decimals) < amount: - cur = round(float(from_bal), funds_decimals) - _log(amount, fr, to, "failed", f"{fr}账户USDT不足,需{amount}U,当前{cur}U") - send_wechat_msg( - f"自动划转失败:{fr}余额不足,需{amount}U,当前{cur}U({action}至{to_account}目标{target}U)\n" - f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" - ) - return - - ok, msg, _ = execute_transfer_usdt(amount, fr, to) - _log(amount, fr, to, "success" if ok else "failed", msg) - if ok: - send_wechat_msg( - f"自动划转成功:{to_account} {trade}U→目标{target}U,{action}{amount}U {fr}->{to}\n" - f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" - ) - else: - send_wechat_msg( - f"自动划转失败:计划{action}{amount}U {fr}->{to}(目标{target}U)\n原因:{msg}\n" - f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" - ) diff --git a/lib/exchange/gate_transfer_lib.py b/lib/exchange/gate_transfer_lib.py deleted file mode 100644 index fd0b257..0000000 --- a/lib/exchange/gate_transfer_lib.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Gate.io 资金划转(crypto_monitor_gate 共用).""" -from __future__ import annotations - -from typing import Any, Callable, Optional - -INVALID_KEY_HINT = ( - ".常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" - "③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);" - "④ Key 已重置或权限变更.你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单." -) - - -def execute_transfer_usdt( - exchange, - amount: float, - from_account: str, - to_account: str, - *, - transfer_ccy: str = "USDT", - ensure_live_ready: Callable[[], tuple[bool, str]], - ensure_markets_loaded: Optional[Callable[[], None]] = None, -) -> tuple[bool, str, Any]: - if amount <= 0: - return False, "划转金额必须大于0", None - ccy = (transfer_ccy or "USDT").strip().upper() or "USDT" - ok_live, reason = ensure_live_ready() - if not ok_live: - return False, reason, None - if ensure_markets_loaded: - try: - ensure_markets_loaded() - except Exception: - pass - try: - resp = exchange.transfer(ccy, float(amount), from_account, to_account) - return True, "划转成功", resp - except Exception as e: - msg = str(e) - if "INVALID_KEY" in msg or "Invalid key" in msg: - msg += INVALID_KEY_HINT - return False, msg, None - - -def count_auto_transfer_blockers(conn, *, count_order_monitors: Callable[[Any], int]) -> int: - """自动划转持仓守卫:order_monitors active + 趋势回调已开仓计划.""" - n = int(count_order_monitors(conn) or 0) - if n > 0: - return n - try: - row = conn.execute( - "SELECT COUNT(*) FROM trend_pullback_plans " - "WHERE status='active' AND COALESCE(first_order_done, 0) != 0" - ).fetchone() - return int(row[0] or 0) if row else 0 - except Exception: - return n diff --git a/lib/exchange/okx_orders_lib.py b/lib/exchange/okx_orders_lib.py deleted file mode 100644 index c112128..0000000 --- a/lib/exchange/okx_orders_lib.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -OKX 挂单聚合:普通委托 + 算法单(conditional / oco / trigger). -交易所 App「止盈止损」页多为 orders-algo-pending,仅 fetch_open_orders 默认拿不到. -""" -from __future__ import annotations - -from typing import Any - - -def _order_dedupe_key(order: dict) -> str: - info = order.get("info") or {} - if not isinstance(info, dict): - info = {} - return str(order.get("id") or info.get("algoId") or info.get("ordId") or "") - - -def _okx_algo_cancel_id(order_id: str) -> str: - oid = str(order_id or "") - if ":" in oid: - return oid.split(":", 1)[0] - return oid - - -def _okx_order_needs_stop_cancel_param(order: dict) -> bool: - """OKX 条件/算法单撤单须 params.stop=True,否则 cancel_order 走普通单接口会静默失败.""" - if not isinstance(order, dict): - return False - info = order.get("info") or {} - if not isinstance(info, dict): - info = {} - if order.get("stopLossPrice") is not None or order.get("takeProfitPrice") is not None: - return True - if info.get("algoId") or info.get("slTriggerPx") or info.get("tpTriggerPx"): - return True - typ = str(order.get("type") or info.get("ordType") or "").lower() - for token in ("conditional", "oco", "trigger", "move_order_stop", "iceberg"): - if token in typ: - return True - return False - - -def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]: - """合并 OKX 普通挂单与算法挂单(去重).""" - if not exchange_symbol: - return [] - ex.load_markets() - sym = exchange_symbol - try: - sym = ex.market(exchange_symbol)["symbol"] - except Exception: - pass - seen: set[str] = set() - out: list[dict] = [] - - def add_batch(batch: list | None) -> None: - for o in batch or []: - if not isinstance(o, dict): - continue - k = _order_dedupe_key(o) - if not k or k in seen: - continue - seen.add(k) - out.append(o) - - try: - add_batch(ex.fetch_open_orders(sym)) - except Exception: - pass - for params in ( - {"ordType": "conditional"}, - {"ordType": "oco"}, - {"trigger": True}, - ): - try: - add_batch(ex.fetch_open_orders(sym, params=dict(params))) - except Exception: - pass - return out - - -def cancel_okx_all_open_orders(ex, exchange_symbol: str) -> int: - """ - 撤销某合约全部挂单(普通 + 条件/算法). - OKX 止盈止损在 orders-algo-pending,必须用 stop=True 才能撤掉. - """ - if not exchange_symbol: - return 0 - ex.load_markets() - sym = exchange_symbol - try: - sym = ex.market(exchange_symbol)["symbol"] - except Exception: - pass - n = 0 - for o in fetch_okx_all_open_orders(ex, sym): - oid = _order_dedupe_key(o) - if not oid: - continue - cancel_id = _okx_algo_cancel_id(oid) - params = {"stop": True} if _okx_order_needs_stop_cancel_param(o) else None - try: - ex.cancel_order(cancel_id, sym, params) - n += 1 - continue - except Exception: - pass - try: - ex.cancel_order(oid, sym, params) - n += 1 - except Exception: - pass - try: - ex.cancel_all_orders(sym) - except Exception: - pass - return n diff --git a/lib/instance/focus_chart_lib.py b/lib/instance/focus_chart_lib.py index 3dadc63..b61e9e5 100644 --- a/lib/instance/focus_chart_lib.py +++ b/lib/instance/focus_chart_lib.py @@ -8,10 +8,12 @@ from lib.market.ohlcv_lib import ( price_tick_from_market, round_ohlcv_bars_to_tick, ) -from lib.trade.order_monitor_display_lib import ( - apply_order_live_price_display, - apply_order_price_display_fields, -) +def apply_order_price_display_fields(payload, **kwargs): + return payload + + +def apply_order_live_price_display(payload, *args, **kwargs): + return payload def resolve_kline_price_tick( diff --git a/lib/instance/instance_dashboard_lib.py b/lib/instance/instance_dashboard_lib.py index 2437240..f568c3a 100644 --- a/lib/instance/instance_dashboard_lib.py +++ b/lib/instance/instance_dashboard_lib.py @@ -237,12 +237,8 @@ def _table_exists(conn, name: str) -> bool: def collect_orders(conn) -> list[dict[str, Any]]: - if not _table_exists(conn, "order_monitors"): - return [] - rows = conn.execute( - "SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC" - ).fetchall() - return [_format_order_item(_row_dict(r)) for r in rows] + del conn + return [] def collect_keys(conn) -> list[dict[str, Any]]: @@ -265,17 +261,8 @@ def collect_trends(conn) -> list[dict[str, Any]]: def collect_rolls(conn) -> list[dict[str, Any]]: - if not _table_exists(conn, "roll_groups") or not _table_exists(conn, "order_monitors"): - return [] - try: - rows = conn.execute( - """SELECT g.* FROM roll_groups g - INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active' - WHERE g.status='active' ORDER BY g.id DESC""" - ).fetchall() - except Exception: - return [] - return [_format_roll_item(_row_dict(r)) for r in rows] + del conn + return [] def collect_options_items( diff --git a/lib/instance/instance_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py index ea55ffc..d215954 100644 --- a/lib/instance/instance_display_prefs_lib.py +++ b/lib/instance/instance_display_prefs_lib.py @@ -10,7 +10,7 @@ DISPLAY_RUNTIME_PREFIX = "display." DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { "show_nav_dashboard": False, "show_nav_account_ledger": False, - "show_nav_key_monitor": False, # 永续关键位已下线 + "show_nav_key_monitor": True, # 关键位监控保留 "show_nav_trade": False, # 永续实盘下单已下线 "show_nav_strategy": False, "show_nav_strategy_records": False, @@ -111,8 +111,8 @@ def display_prefs_template_context(get_db: Callable) -> dict[str, Any]: def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool: prefs = normalize_display_prefs(display or {}) t = (tab or "").strip() - if t in ("trade", "records", "stats", "hedge_plan", "key_monitor"): - return False # 永续相关页面已下线 + if t in ("trade", "records", "stats", "hedge_plan"): + return False # 永续下单/记录/统计已下线;关键位监控保留 key = NAV_TAB_ALLOWED.get(t) if not key: return True @@ -123,6 +123,7 @@ def display_meta_for_ui() -> list[dict[str, Any]]: nav_keys = [ "show_nav_dashboard", "show_nav_account_ledger", + "show_nav_key_monitor", "show_nav_risk_policy", "show_nav_live_guide", "show_nav_system_guide", diff --git a/lib/instance/templates/force_close_header_badge.html b/lib/instance/templates/force_close_header_badge.html deleted file mode 100644 index 3442516..0000000 --- a/lib/instance/templates/force_close_header_badge.html +++ /dev/null @@ -1,8 +0,0 @@ -{% if force_close.enabled %} - - {{ force_close.label }} 已开启 · {{ force_close.countdown or '--:--:--' }} - -{% endif %} diff --git a/lib/instance/templates/force_close_order_badge.html b/lib/instance/templates/force_close_order_badge.html deleted file mode 100644 index e82f63f..0000000 --- a/lib/instance/templates/force_close_order_badge.html +++ /dev/null @@ -1,8 +0,0 @@ -{% if force_close.enabled %} - - {{ o.force_close_label or force_close.label }} - · {{ o.force_close_countdown or force_close.countdown or '--:--:--' }} - -{% endif %} diff --git a/lib/instance/templates/gate_transfer_block.html b/lib/instance/templates/gate_transfer_block.html deleted file mode 100644 index bedceb9..0000000 --- a/lib/instance/templates/gate_transfer_block.html +++ /dev/null @@ -1,26 +0,0 @@ -
- - 实时价格更新:--(北京时间 UTC+8) - · 划转规则 - -
- 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U:不足从 {{ auto_transfer_from }} 划入,超出划回 {{ auto_transfer_from }};持仓中不划转并微信通知) -
-
-
- - - - - -
diff --git a/lib/instance/templates/instance_transfer_panel.html b/lib/instance/templates/instance_transfer_panel.html deleted file mode 100644 index 1a7e89e..0000000 --- a/lib/instance/templates/instance_transfer_panel.html +++ /dev/null @@ -1,28 +0,0 @@ -{# 系统设置 · 资金划转(三所共用) #} -
-

- 自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}: - 每天北京时间 {{ auto_transfer_bj_hour }}:00 起该整点小时内尝试; - 账簿按 UTC 自然日 去重; - 将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U: - 不足从 {{ auto_transfer_from }} 划入,超出划回 {{ auto_transfer_from }}; - 持仓中不划转并微信通知. -

-
- - - - - -
-
diff --git a/lib/instance/templates/order_entry_model_fields.html b/lib/instance/templates/order_entry_model_fields.html deleted file mode 100644 index 0e42f9d..0000000 --- a/lib/instance/templates/order_entry_model_fields.html +++ /dev/null @@ -1,72 +0,0 @@ -{# 趋势户:两级开仓类型 → 自动 trade_style;日内户:假破 / 结构突破 #} - -{% macro order_entry_type_fields() -%} - -{% if order_entry_profile == 'trend_div' %} - -
- - - - - - - - 趋势单 - -
- -{% elif order_entry_profile == 'intraday' %} - - - - - -{% else %} - - - -{% endif %} - -{%- endmacro %} - diff --git a/lib/instance/templates/order_focus_v2.html b/lib/instance/templates/order_focus_v2.html deleted file mode 100644 index a1d8234..0000000 --- a/lib/instance/templates/order_focus_v2.html +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - {{ exchange_display }} | 实盘下单放大 - - - - -
-
-
-
- - -
-
- 返回首页 - 实盘下单放大(100根K线){{ exchange_display }} -
-
最近刷新:--
-
- {% if orders %} -
- - - - - - -
- {% else %} -
当前没有激活订单,无法展示放大K线.
- {% endif %} -
- - {% if orders %} -
-
-
交易对
-
-
方向
-
-
成交价
-
-
止损
-
-
止盈
-
-
盈亏比
-
-
移动保本
-
-
现价
-
-
浮盈亏
-
-
-
-
-
-
- {% endif %} -
- -{% if orders %} - - - -{% endif %} - - diff --git a/lib/instance/templates/order_leverage_fields.html b/lib/instance/templates/order_leverage_fields.html deleted file mode 100644 index d59d22a..0000000 --- a/lib/instance/templates/order_leverage_fields.html +++ /dev/null @@ -1,7 +0,0 @@ -{# 以损定仓:杠杆按币种默认(BTC/ETH 10x,其它 5x),不可选手输 #} -{% macro order_leverage_fields() -%} -{% if position_sizing_mode != 'full_margin' %} - -杠杆 — -{% endif %} -{%- endmacro %} diff --git a/lib/instance/templates/order_monitor_open_form.html b/lib/instance/templates/order_monitor_open_form.html deleted file mode 100644 index 20fd3b3..0000000 --- a/lib/instance/templates/order_monitor_open_form.html +++ /dev/null @@ -1,77 +0,0 @@ -{# 实盘下单监控 · 开仓表单(实例页与中控嵌入共用) #} -
-
- {% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %} - {{ trade_policy_symbol('symbol', 'order-symbol') }} - {{ trade_policy_direction('direction', 'order-direction') }} - - {% from 'order_entry_model_fields.html' import order_entry_type_fields with context %} - {{ order_entry_type_fields() }} - {% from 'order_leverage_fields.html' import order_leverage_fields with context %} - {{ order_leverage_fields() }} -
- -
- - - - - -
- {% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %} - {{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }} - 成交价以交易所回报为准 -
-
- -
- {% if not intraday_discipline %} - - - - - - {% else %} - - {% endif %} - -
- -
- - {% if not can_trade and (open_block_note|default('')) %}{{ open_block_note }}{% endif %} -
-
-{% include 'order_plan_preview_bar.html' %} diff --git a/lib/instance/templates/order_monitor_rule_tips_binance.html b/lib/instance/templates/order_monitor_rule_tips_binance.html deleted file mode 100644 index ec63b41..0000000 --- a/lib/instance/templates/order_monitor_rule_tips_binance.html +++ /dev/null @@ -1,38 +0,0 @@ -
- 开仓规则说明 -
- 规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; - 本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }}); - {% if can_trade %}可开仓{% else %}不可开仓(持仓已满,单日开仓达上限,或未到北京时间 {{ reset_hour }}:00){% endif %}; - 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1 -
-
-
- 平仓 / 委托 / 强制清仓 -
-
    -
  • 止盈止损委托:开仓后挂交易所条件止盈/止损;持仓卡可查看状态,「委托」可重挂,「撤单」只撤对应条件单、不平仓。
  • -
  • 手动平仓:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。
  • -
  • 强制清仓: - {% if force_close is defined and force_close.enabled %} - 已开启 · 北京时间 {{ force_close.hour_label }} 整点起 {{ force_close.grace_minutes|default(5) }} 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。 - {% else %} - 当前已关闭(FORCE_CLOSE_ENABLED=false);开启后按 FORCE_CLOSE_BJ_HOUR 在北京时间该整点起 FORCE_CLOSE_GRACE_MINUTES(默认 5)分钟内清掉 active 监控仓。 - {% endif %} - 冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓。 -
  • -
-
-
-
- 计仓与保本说明 -
- 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) - {% if position_sizing_mode == 'full_margin' %} - |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度 - {% else %} - |以损定仓:风险 {{ risk_percent }}% - {% endif %} - |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% -
-
diff --git a/lib/instance/templates/order_monitor_rule_tips_gate.html b/lib/instance/templates/order_monitor_rule_tips_gate.html deleted file mode 100644 index 7719874..0000000 --- a/lib/instance/templates/order_monitor_rule_tips_gate.html +++ /dev/null @@ -1,38 +0,0 @@ -
- 开仓规则说明 -
- 规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; - 本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }}); - {% if can_trade %}可开仓{% else %}不可开仓(持仓已满,单日开仓达上限,或未到北京时间 {{ reset_hour }}:00){% endif %}; - 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1 -
-
-
- 平仓 / 委托 / 强制清仓 -
-
    -
  • 止盈止损委托:开仓后挂交易所条件止盈/止损;持仓卡可查看状态,「委托」可重挂,「撤单」只撤对应条件单、不平仓。
  • -
  • 手动平仓:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。
  • -
  • 强制清仓: - {% if force_close is defined and force_close.enabled %} - 已开启 · 北京时间 {{ force_close.hour_label }} 整点起 {{ force_close.grace_minutes|default(5) }} 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。 - {% else %} - 当前已关闭(FORCE_CLOSE_ENABLED=false);开启后按 FORCE_CLOSE_BJ_HOUR 在北京时间该整点起 FORCE_CLOSE_GRACE_MINUTES(默认 5)分钟内清掉 active 监控仓。 - {% endif %} - 冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓;交易所裸仓且无本地监控时不会被此逻辑平掉。 -
  • -
-
-
-
- 计仓与保本说明 -
- 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) - {% if position_sizing_mode == 'full_margin' %} - |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度 - {% else %} - |以损定仓:风险 {{ risk_percent }}% - {% endif %} - |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% -
-
diff --git a/lib/instance/templates/order_monitor_rule_tips_gate_bot.html b/lib/instance/templates/order_monitor_rule_tips_gate_bot.html deleted file mode 100644 index ec88a25..0000000 --- a/lib/instance/templates/order_monitor_rule_tips_gate_bot.html +++ /dev/null @@ -1,21 +0,0 @@ -
- 开仓规则说明 -
- 规则:最大同时持仓 {{ max_active_positions }}(当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; - 本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }}); - {% if can_trade %}可开仓{% else %}不可开仓(持仓达上限,单日开仓达上限,有趋势回调计划,或未到北京时间 {{ reset_hour }}:00){% endif %}; - 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1 -
-
-
- 计仓与保本说明 -
- 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) - {% if position_sizing_mode == 'full_margin' %} - |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度 - {% else %} - |以损定仓:风险 {{ risk_percent }}% - {% endif %} - |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% -
-
diff --git a/lib/instance/templates/order_monitor_rule_tips_okx.html b/lib/instance/templates/order_monitor_rule_tips_okx.html deleted file mode 100644 index 737327a..0000000 --- a/lib/instance/templates/order_monitor_rule_tips_okx.html +++ /dev/null @@ -1,38 +0,0 @@ -
- 开仓规则说明 -
- 规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; - 本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }}); - {% if can_trade %}可开仓{% else %}不可开仓{% if active_count >= max_active_positions %}(持仓 {{ active_count }}/{{ max_active_positions }}){% endif %}{% if daily_open_hard_limit > 0 and opens_today >= daily_open_hard_limit %}(单日开仓达上限){% endif %}{% if open_guard_blocks_now %}(未到北京时间 {{ reset_hour }}:00){% endif %}{% endif %}; - 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1 -
-
-
- 平仓 / 委托 / 强制清仓 -
-
    -
  • 止盈止损委托:开仓后挂交易所条件止盈/止损;持仓卡可查看状态,「委托」可重挂,「撤单」只撤对应条件单、不平仓。
  • -
  • 手动平仓:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。
  • -
  • 强制清仓: - {% if force_close is defined and force_close.enabled %} - 已开启 · 北京时间 {{ force_close.hour_label }} 整点起 {{ force_close.grace_minutes|default(5) }} 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。 - {% else %} - 当前已关闭(FORCE_CLOSE_ENABLED=false);开启后按 FORCE_CLOSE_BJ_HOUR 在北京时间该整点起 FORCE_CLOSE_GRACE_MINUTES(默认 5)分钟内清掉 active 监控仓。 - {% endif %} - 冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓。 -
  • -
-
-
-
- 计仓与保本说明 -
- 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) - {% if position_sizing_mode == 'full_margin' %} - |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度 - {% else %} - |以损定仓:风险 {{ risk_percent }}% - {% endif %} - |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% -
-
diff --git a/lib/instance/templates/order_plan_preview_bar.html b/lib/instance/templates/order_plan_preview_bar.html deleted file mode 100644 index 7fa3ffc..0000000 --- a/lib/instance/templates/order_plan_preview_bar.html +++ /dev/null @@ -1,5 +0,0 @@ -
- 预估风险:— - 预估盈利:— - 预估盈亏比:— -
diff --git a/lib/instance/templates/settings_panel.html b/lib/instance/templates/settings_panel.html index b2166c3..f120d1e 100644 --- a/lib/instance/templates/settings_panel.html +++ b/lib/instance/templates/settings_panel.html @@ -37,9 +37,8 @@ {% elif tab.key == 'password' %} {% include 'password_settings_panel.html' %} {% elif tab.key == 'transfer' %} -

永续资金划转

-

账户内:资金账户与交易账户之间划转 USDT.

- {% include 'instance_transfer_panel.html' %} +

资金划转

+

永续账户划转已下线;期权/模拟盘请使用对应页内划转.

{% elif tab.key == 'export' %}

数据导出

CSV · v{{ instance_settings.data_export_version }}

diff --git a/lib/market/reconcile_flat_lib.py b/lib/market/reconcile_flat_lib.py deleted file mode 100644 index 69c40d6..0000000 --- a/lib/market/reconcile_flat_lib.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Sync order_monitors after an external/market flat.""" -from __future__ import annotations - -import time -from typing import Any, Callable - - -def reconcile_hub_external_close_impl( - conn, - symbol: str, - direction: str, - *, - exchange_configured: Callable[[], bool], - not_configured_msg: str, - symbols_match: Callable[[str, str], bool], - get_opened_at_value: Callable[[Any], str], - resolve_monitor_exchange_symbol: Callable[[Any], str], - get_live_position_contracts: Callable[[str, str], float | None], - cancel_conditional_orders: Callable[[str], None], - resolve_synced_flat_close: Callable[..., tuple], - finalize_stopped_monitor: Callable[..., None], - sync_trade_records: Callable[..., None] | None = None, - reconcile_flat_streak: dict | None = None, - to_ms_with_fallback: Callable[..., int | None] | None = None, - prefer_manual_resolve: bool = False, - order_row_monitor_type: Callable[[Any], str] | None = None, -) -> dict[str, Any]: - if not exchange_configured(): - return {"ok": False, "msg": not_configured_msg, "synced": 0} - sym_req = (symbol or "").strip() - dir_l = (direction or "").strip().lower() - if dir_l not in ("long", "short"): - return {"ok": False, "msg": "side 须为 long 或 short", "synced": 0} - synced = 0 - streak = reconcile_flat_streak if reconcile_flat_streak is not None else {} - rows = conn.execute( - "SELECT * FROM order_monitors WHERE status IN ('active', 'error')" - ).fetchall() - for r in rows: - if not symbols_match(str(r["symbol"] or ""), sym_req): - continue - if (r["direction"] or "").strip().lower() != dir_l: - continue - oid = int(r["id"]) - if r["status"] == "error": - opened_at_chk = get_opened_at_value(r) - mtype = order_row_monitor_type(r) if order_row_monitor_type else r["monitor_type"] - existing = conn.execute( - "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", - (r["symbol"], opened_at_chk, mtype), - ).fetchone() - if existing: - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) - synced += 1 - continue - exchange_symbol = resolve_monitor_exchange_symbol(r) - live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) - if live_contracts is None: - continue - if live_contracts > 0: - time.sleep(0.6) - live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) - if live_contracts is None or live_contracts > 0: - continue - streak.pop(oid, None) - cancel_conditional_orders(exchange_symbol) - opened_at = get_opened_at_value(r) - opened_at_ms = None - if to_ms_with_fallback is not None: - keys = r.keys() if hasattr(r, "keys") else () - opened_at_ms = to_ms_with_fallback( - r["opened_at_ms"] if "opened_at_ms" in keys else None, - opened_at, - ) - resolve_kw = {"opened_at_ms": opened_at_ms} - if prefer_manual_resolve: - resolve_kw["prefer_manual"] = True - result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close( - r, opened_at, **resolve_kw - ) - finalize_stopped_monitor( - conn, - r, - result=result, - pnl_amount=pnl_amount, - closed_at=closed_at, - miss_reason=miss_reason, - ) - synced += 1 - if sync_trade_records is not None: - try: - sync_trade_records(conn, force=True) - except Exception: - pass - return {"ok": True, "synced": synced} - - -reconcile_external_close_impl = reconcile_hub_external_close_impl diff --git a/lib/sim/hooks.py b/lib/sim/hooks.py index c9e9791..9cc334b 100644 --- a/lib/sim/hooks.py +++ b/lib/sim/hooks.py @@ -40,9 +40,7 @@ def apply_sim_hooks(app_module: Any) -> None: _orig_ensure = app_module.ensure_okx_live_ready _orig_capitals = app_module.get_exchange_capitals _orig_avail = app_module.get_available_trading_usdt - _orig_place = app_module.place_exchange_order - _orig_close = app_module.close_exchange_order - _orig_live_contracts = app_module.get_live_position_contracts + _orig_live_contracts = getattr(app_module, "get_live_position_contracts", None) def ensure_okx_live_ready(): if is_sim_mode(app_module.get_db): @@ -60,67 +58,6 @@ def apply_sim_hooks(app_module: Any) -> None: return float(broker().balances_header()["trading_usdt"]) return _orig_avail() - def place_exchange_order( - exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None - ): - if is_sim_mode(app_module.get_db): - ex = getattr(app_module, "exchange", None) - if ex is None: - raise RuntimeError("sim: exchange 未就绪(公开行情)") - ensure = getattr(app_module, "ensure_markets_loaded", None) - if callable(ensure): - try: - ensure() - except Exception: - pass - return broker().place_perp_market( - ex, - symbol=exchange_symbol, - direction=direction, - contracts=float(amount), - leverage=int(leverage or 1), - fee_rate=sim_fee_rate(), - stop_loss=stop_loss, - take_profit=take_profit, - ) - return _orig_place( - exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit - ) - - def close_exchange_order(order_row): - if is_sim_mode(app_module.get_db): - ex = getattr(app_module, "exchange", None) - if ex is None: - raise RuntimeError("sim: exchange 未就绪") - ensure = getattr(app_module, "ensure_markets_loaded", None) - if callable(ensure): - try: - ensure() - except Exception: - pass - normalize = getattr(app_module, "normalize_okx_symbol", None) or getattr( - app_module, "normalize_exchange_symbol", None - ) - try: - symbol = order_row["exchange_symbol"] or None - except Exception: - symbol = None - if not symbol: - try: - symbol = order_row["symbol"] - except Exception: - symbol = None - if callable(normalize): - symbol = normalize(symbol) - direction = order_row["direction"] - db_amt = float(order_row["order_amount"] or 0) - live = broker().get_perp_contracts(symbol, direction) - amt = live if live and live > 0 else db_amt - return broker().close_perp_market( - ex, symbol=symbol, direction=direction, contracts=amt, fee_rate=sim_fee_rate() - ) - return _orig_close(order_row) - def get_live_position_contracts(exchange_symbol, direction): if is_sim_mode(app_module.get_db): normalize = getattr(app_module, "normalize_okx_symbol", None) @@ -128,13 +65,13 @@ def apply_sim_hooks(app_module: Any) -> None: if callable(normalize): sym = normalize(exchange_symbol or "") return broker().get_perp_contracts(sym, direction) - return _orig_live_contracts(exchange_symbol, direction) + if callable(_orig_live_contracts): + return _orig_live_contracts(exchange_symbol, direction) + return 0.0 app_module.ensure_okx_live_ready = ensure_okx_live_ready app_module.get_exchange_capitals = get_exchange_capitals app_module.get_available_trading_usdt = get_available_trading_usdt - app_module.place_exchange_order = place_exchange_order - app_module.close_exchange_order = close_exchange_order app_module.get_live_position_contracts = get_live_position_contracts _orig_xfer = getattr(app_module, "execute_transfer_usdt", None) diff --git a/lib/sim/register.py b/lib/sim/register.py index 68a1da3..9d875d4 100644 --- a/lib/sim/register.py +++ b/lib/sim/register.py @@ -56,8 +56,6 @@ def install_sim_trading(app: Flask, repo_root: str, app_module: Any = None) -> N if isinstance(cfg, dict): patch_options_cfg(cfg) for fn_name in ( - "place_exchange_order", - "close_exchange_order", "get_exchange_capitals", "get_available_trading_usdt", "ensure_okx_live_ready", diff --git a/lib/trade/compensating_close_lib.py b/lib/trade/compensating_close_lib.py deleted file mode 100644 index 47fbebd..0000000 --- a/lib/trade/compensating_close_lib.py +++ /dev/null @@ -1,16 +0,0 @@ -"""开仓后挂 TP/SL 失败时的补偿平仓(避免裸仓).""" -from __future__ import annotations - -from typing import Callable - - -def log_compensating_close_error(prefix: str, exc: BaseException) -> None: - print(f"[{prefix}] {exc}", flush=True) - - -def run_compensating_close(close_fn: Callable[[], None], *, log_prefix: str = "compensating_close") -> None: - """执行补偿平仓;二次失败只打日志,不掩盖原始异常.""" - try: - close_fn() - except Exception as e: - log_compensating_close_error(log_prefix, e) diff --git a/lib/trade/daily_open_limit_lib.py b/lib/trade/daily_open_limit_lib.py deleted file mode 100644 index 2769e57..0000000 --- a/lib/trade/daily_open_limit_lib.py +++ /dev/null @@ -1,140 +0,0 @@ -"""单日开仓次数:软提醒阈值 + 硬上限(三所实例共用).""" -from __future__ import annotations - -import os -from typing import Any, Optional - - -def parse_daily_open_alert_threshold(raw: Any = None, *, default: int = 5) -> int: - """AI 克制提醒阈值;至少 1.""" - try: - v = int(raw if raw is not None and str(raw).strip() != "" else default) - except (TypeError, ValueError): - v = default - return max(1, v) - - -def parse_daily_open_hard_limit(raw: Any = None, *, default: int = 0) -> int: - """硬上限;0 表示不启用.至少 0.""" - try: - v = int(raw if raw is not None and str(raw).strip() != "" else default) - except (TypeError, ValueError): - v = default - return max(0, v) - - -def load_daily_open_limits_from_env( - env: Optional[dict[str, str]] = None, -) -> tuple[int, int]: - """从环境变量读取 (alert_threshold, hard_limit).""" - src = env if env is not None else os.environ - alert = parse_daily_open_alert_threshold(src.get("DAILY_OPEN_ALERT_THRESHOLD")) - hard = parse_daily_open_hard_limit(src.get("DAILY_OPEN_HARD_LIMIT")) - return alert, hard - - -def count_opens_for_trading_day(conn, trading_day: str) -> int: - """本交易日已成功写入 order_monitors 的开仓次数.""" - td = (trading_day or "").strip() - if not td: - return 0 - row = conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", - (td,), - ).fetchone() - return int(row[0] if row else 0) - - -def daily_open_hard_limit_blocks(opens_today: int, hard_limit: int) -> bool: - return int(hard_limit) > 0 and int(opens_today) >= int(hard_limit) - - -def hard_limit_block_reason(opens_today: int, hard_limit: int, reset_hour: int) -> str: - return ( - f"本交易日开仓次数已达上限({int(opens_today)}/{int(hard_limit)})," - f"次日北京时间 {int(reset_hour)}:00 后恢复" - ) - - -def check_daily_open_hard_limit( - conn, - trading_day: str, - hard_limit: int, - reset_hour: int, -) -> tuple[bool, str, int]: - """返回 (允许继续开仓, 拒绝原因, 当日已开次数).""" - opens_today = count_opens_for_trading_day(conn, trading_day) - if daily_open_hard_limit_blocks(opens_today, hard_limit): - return False, hard_limit_block_reason(opens_today, hard_limit, reset_hour), opens_today - return True, "", opens_today - - -def can_trade_new_open( - *, - time_allows: bool, - active_count: int, - max_active_positions: int, - opens_today: int, - hard_limit: int, - extra_blocks: bool = False, -) -> bool: - if extra_blocks: - return False - if not time_allows: - return False - if int(active_count) >= int(max_active_positions): - return False - if daily_open_hard_limit_blocks(opens_today, hard_limit): - return False - return True - - -def should_send_daily_open_alert(before: int, after: int, alert_threshold: int) -> bool: - return int(before) < int(alert_threshold) <= int(after) - - -def build_daily_open_alert_prompt( - trading_day: str, - opens_after: int, - alert_threshold: int, - *, - hard_limit: int = 0, - detail_line: str = "", -) -> str: - hard_txt = ( - f"硬上限 {hard_limit} 次(已达后将禁止新开仓直至下一交易日)." - if int(hard_limit) > 0 - else "未配置单日硬上限." - ) - extra = f" {detail_line}" if detail_line else "" - return ( - f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_after} 次" - f"(AI 提醒阈值 {alert_threshold};{hard_txt})" - f"{extra}" - f"用户自述“上头了”.请给克制提醒." - ) - - -def format_daily_open_counter_line( - opens_today: int, - alert_threshold: int, - hard_limit: int, -) -> str: - if int(hard_limit) > 0: - return ( - f"📅 当日开仓次数:{int(opens_today)} / 硬上限 {int(hard_limit)} 次" - f"(AI 提醒阈值 {int(alert_threshold)})" - ) - return ( - f"📅 当日开仓次数:{int(opens_today)} / AI 提醒阈值 {int(alert_threshold)} 次" - ) - - -def format_daily_open_summary_short( - opens_today: int, - alert_threshold: int, - hard_limit: int, -) -> str: - if int(hard_limit) > 0: - return f"本交易日累计开仓:{int(opens_today)}(硬上限 {int(hard_limit)},提醒 {int(alert_threshold)})" - return f"本交易日累计开仓:{int(opens_today)}(提醒阈值 {int(alert_threshold)})" diff --git a/lib/trade/entry_model_lib.py b/lib/trade/entry_model_lib.py index e73a882..821a076 100644 --- a/lib/trade/entry_model_lib.py +++ b/lib/trade/entry_model_lib.py @@ -441,10 +441,13 @@ def enrich_entry_model_display(item: dict) -> dict: return item -def open_position_button_label(policy: TradePolicy, sizing_mode: str) -> str: - from lib.trade.position_sizing_lib import mode_label_zh +def _sizing_mode_label_zh(mode: str) -> str: + m = (mode or "").strip().lower() + return {"risk": "按风险", "full_margin": "满保证金"}.get(m, m or "-") - mode_txt = mode_label_zh(sizing_mode) + +def open_position_button_label(policy: TradePolicy, sizing_mode: str) -> str: + mode_txt = _sizing_mode_label_zh(sizing_mode) if is_intraday_trading_profile(policy): return f"开仓(日内·{mode_txt})" return f"开仓({mode_txt})" diff --git a/lib/trade/force_close_lib.py b/lib/trade/force_close_lib.py deleted file mode 100644 index ef7c1db..0000000 --- a/lib/trade/force_close_lib.py +++ /dev/null @@ -1,366 +0,0 @@ -"""整点强制清仓(FORCE_CLOSE_*):UI 标识与持仓倒计时.""" -from __future__ import annotations - -import os -import time -from datetime import datetime, timedelta -from typing import Any, Optional -from zoneinfo import ZoneInfo - -FORCE_CLOSE_RESULT = "强制清仓" -# 默认宽限(分钟);运行时优先读 FORCE_CLOSE_GRACE_MINUTES -FORCE_CLOSE_GRACE_MINUTES = 5 - - -def force_close_grace_minutes(override: Any = None) -> int: - """整点强制清仓执行窗口长度(分钟).""" - if override is not None and str(override).strip() != "": - raw = override - else: - raw = os.getenv("FORCE_CLOSE_GRACE_MINUTES") - if raw is None or str(raw).strip() == "": - raw = FORCE_CLOSE_GRACE_MINUTES - try: - return max(1, int(raw)) - except (TypeError, ValueError): - return max(1, int(FORCE_CLOSE_GRACE_MINUTES)) - - -def app_timezone_name() -> str: - return (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai" - - -def normalize_force_close_bj_hour(value: Any) -> int: - try: - h = int(value) - except (TypeError, ValueError): - return 0 - return max(0, min(23, h)) - - -def _now_dt(*, now_ms: Optional[int] = None, tz_name: Optional[str] = None) -> datetime: - tz = ZoneInfo(tz_name or app_timezone_name()) - if now_ms is None: - return datetime.now(tz) - return datetime.fromtimestamp(int(now_ms) / 1000, tz=tz) - - -def force_close_hour_label(bj_hour: Any) -> str: - return f"{normalize_force_close_bj_hour(bj_hour):02d}:00" - - -def force_close_label(bj_hour: Any) -> str: - return f"强制清仓 {force_close_hour_label(bj_hour)}" - - -def is_force_close_active_hour( - bj_hour: Any, - *, - now_ms: Optional[int] = None, - tz_name: Optional[str] = None, - grace_minutes: Optional[int] = None, -) -> bool: - """当前是否处于整点强制清仓执行窗口(整点起 grace 分钟内).""" - return is_force_close_executing( - bj_hour, - now_ms=now_ms, - tz_name=tz_name, - grace_minutes=grace_minutes, - ) - - -def is_force_close_executing( - bj_hour: Any, - *, - now_ms: Optional[int] = None, - tz_name: Optional[str] = None, - grace_minutes: Optional[int] = None, -) -> bool: - hour = normalize_force_close_bj_hour(bj_hour) - now = _now_dt(now_ms=now_ms, tz_name=tz_name) - target = now.replace(hour=hour, minute=0, second=0, microsecond=0) - if now < target: - return False - grace = force_close_grace_minutes(grace_minutes) - end = target + timedelta(minutes=grace) - return now < end - - -def force_close_blocks_new_open( - enabled: bool, - bj_hour: Any, - *, - now_ms: Optional[int] = None, - tz_name: Optional[str] = None, - grace_minutes: Optional[int] = None, -) -> tuple[bool, str]: - """强制清仓执行窗口内禁止新开仓.返回 (是否拦截, 说明文案).""" - if not enabled: - return False, "" - grace = force_close_grace_minutes(grace_minutes) - if not is_force_close_executing( - bj_hour, now_ms=now_ms, tz_name=tz_name, grace_minutes=grace - ): - return False, "" - label = force_close_hour_label(bj_hour) - return ( - True, - f"强制清仓窗口内(北京时间 {label} 起 {grace} 分钟),暂不可开仓", - ) - - -def parse_closed_at_dt( - closed_at: Any, - *, - tz_name: Optional[str] = None, -) -> Optional[datetime]: - if closed_at is None: - return None - text = str(closed_at).strip() - if not text: - return None - tz = ZoneInfo(tz_name or app_timezone_name()) - for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): - try: - return datetime.strptime(text, fmt).replace(tzinfo=tz) - except ValueError: - continue - return None - - -def is_close_at_force_close_window( - closed_at: Any, - bj_hour: Any, - *, - grace_minutes: Optional[int] = None, - tz_name: Optional[str] = None, -) -> bool: - """平仓时刻是否落在北京时间整点强制清仓窗口内.""" - dt = parse_closed_at_dt(closed_at, tz_name=tz_name) - if dt is None: - return False - hour = normalize_force_close_bj_hour(bj_hour) - if dt.hour != hour: - return False - return dt.minute < force_close_grace_minutes(grace_minutes) - - -def infer_force_close_result( - closed_at: Any, - *, - enabled: bool, - bj_hour: Any, - grace_minutes: Optional[int] = None, - tz_name: Optional[str] = None, -) -> Optional[str]: - if not enabled: - return None - if is_close_at_force_close_window( - closed_at, bj_hour, grace_minutes=grace_minutes, tz_name=tz_name - ): - return FORCE_CLOSE_RESULT - return None - - -def coerce_force_close_result( - result: Optional[str], - closed_at: Any, - *, - enabled: bool, - bj_hour: Any, - miss_reason: Optional[str] = None, - grace_minutes: Optional[int] = None, - tz_name: Optional[str] = None, -) -> tuple[str, str]: - """同步平仓归类:整点窗口内优先记为强制清仓.""" - res = (result or "").strip() - note = (miss_reason or "").strip() - if res == FORCE_CLOSE_RESULT: - return res, note - fc = infer_force_close_result( - closed_at, - enabled=enabled, - bj_hour=bj_hour, - grace_minutes=grace_minutes, - tz_name=tz_name, - ) - if not fc: - return res, note - if not note: - note = f"北京时间 {force_close_hour_label(bj_hour)} 整点风控清仓" - return fc, note - - -def apply_force_close_display_result( - result: Optional[str], - closed_at: Any, - *, - enabled: bool, - bj_hour: Any, - grace_minutes: Optional[int] = None, - tz_name: Optional[str] = None, -) -> str: - """展示层:外部平仓/手动平仓若落在整点窗口,显示为强制清仓.""" - res = (result or "").strip() - if res == FORCE_CLOSE_RESULT: - return res - fc = infer_force_close_result( - closed_at, - enabled=enabled, - bj_hour=bj_hour, - grace_minutes=grace_minutes, - tz_name=tz_name, - ) - if fc and (res in ("", "外部平仓", "手动平仓") or res.startswith("外部平仓")): - return fc - return res - - -def compute_next_force_close_at_ms( - *, - bj_hour: Any, - now_ms: Optional[int] = None, - tz_name: Optional[str] = None, -) -> Optional[int]: - """下一次强制清仓时刻(北京时间整点)的 epoch 毫秒.""" - hour = normalize_force_close_bj_hour(bj_hour) - now = _now_dt(now_ms=now_ms, tz_name=tz_name) - target = now.replace(hour=hour, minute=0, second=0, microsecond=0) - if now >= target: - target += timedelta(days=1) - return int(target.timestamp() * 1000) - - -def force_close_remaining_seconds( - close_at_ms: Any, - *, - now_ms: Optional[int] = None, -) -> Optional[int]: - try: - close_at = int(close_at_ms) - except (TypeError, ValueError): - return None - now = int(now_ms if now_ms is not None else time.time() * 1000) - return max(0, int((close_at - now) / 1000)) - - -def format_force_close_countdown(seconds: Any, *, active: bool = False) -> str: - if active: - return "执行中" - try: - sec = max(0, int(seconds)) - except (TypeError, ValueError): - return "--:--:--" - h = sec // 3600 - m = (sec % 3600) // 60 - s = sec % 60 - return f"{h:02d}:{m:02d}:{s:02d}" - - -def build_force_close_state( - enabled: bool, - bj_hour: Any, - *, - now_ms: Optional[int] = None, - tz_name: Optional[str] = None, - has_active_positions: Optional[bool] = None, -) -> dict[str, Any]: - """实例级强制清仓状态(模板 / API 共用).""" - grace = force_close_grace_minutes() - if not enabled: - return { - "enabled": False, - "bj_hour": normalize_force_close_bj_hour(bj_hour), - "hour_label": force_close_hour_label(bj_hour), - "label": force_close_label(bj_hour), - "grace_minutes": grace, - "next_at_ms": None, - "remaining_sec": None, - "countdown": "", - "active": False, - "executing": False, - } - hour = normalize_force_close_bj_hour(bj_hour) - executing = is_force_close_executing( - hour, now_ms=now_ms, tz_name=tz_name, grace_minutes=grace - ) - active = executing and (has_active_positions is not False) - next_at_ms = compute_next_force_close_at_ms(bj_hour=hour, now_ms=now_ms, tz_name=tz_name) - rem = force_close_remaining_seconds(next_at_ms, now_ms=now_ms) if next_at_ms else None - return { - "enabled": True, - "bj_hour": hour, - "hour_label": force_close_hour_label(hour), - "label": force_close_label(hour), - "grace_minutes": grace, - "next_at_ms": next_at_ms, - "remaining_sec": rem, - "countdown": format_force_close_countdown(rem, active=active), - "active": active, - "executing": executing, - } - - -def force_close_template_context( - enabled: bool, - bj_hour: Any, - *, - now_ms: Optional[int] = None, - tz_name: Optional[str] = None, - has_active_positions: Optional[bool] = None, -) -> dict[str, dict[str, Any]]: - return { - "force_close": build_force_close_state( - enabled, - bj_hour, - now_ms=now_ms, - tz_name=tz_name, - has_active_positions=has_active_positions, - ) - } - - -def apply_force_close_to_payload( - payload: dict[str, Any], - *, - enabled: bool, - bj_hour: Any, - now_ms: Optional[int] = None, - tz_name: Optional[str] = None, -) -> None: - """为 active 持仓 JSON 附加整点强制清仓倒计时.""" - state = build_force_close_state( - enabled, - bj_hour, - now_ms=now_ms, - tz_name=tz_name, - has_active_positions=True, - ) - payload["force_close_enabled"] = bool(state["enabled"]) - payload["force_close_bj_hour"] = state["bj_hour"] - payload["force_close_at_ms"] = state["next_at_ms"] - payload["force_close_label"] = state["label"] if state["enabled"] else "" - payload["force_close_remaining_sec"] = state["remaining_sec"] - payload["force_close_countdown"] = state["countdown"] - payload["force_close_active"] = bool(state["active"]) - - -def enrich_orders_force_close( - orders: list[dict[str, Any]], - enabled: bool, - bj_hour: Any, - *, - now_ms: Optional[int] = None, - tz_name: Optional[str] = None, -) -> None: - if not enabled or not orders: - return - for item in orders: - if isinstance(item, dict): - apply_force_close_to_payload( - item, - enabled=enabled, - bj_hour=bj_hour, - now_ms=now_ms, - tz_name=tz_name, - ) diff --git a/lib/trade/manual_sltp_lib.py b/lib/trade/manual_sltp_lib.py deleted file mode 100644 index 41a5783..0000000 --- a/lib/trade/manual_sltp_lib.py +++ /dev/null @@ -1,136 +0,0 @@ -"""实盘人工下单:止盈止损模式(价格 / 百分比 / 固定盈亏比).""" -from __future__ import annotations - -from typing import Any, Optional, Tuple - -MANUAL_FIXED_RR_DEFAULT = 1.5 - -SLTP_MODE_PRICE = "price" -SLTP_MODE_PCT = "pct" -SLTP_MODE_FIXED_RR = "fixed_rr" - -OPEN_SLTP_MODES = frozenset({SLTP_MODE_PRICE, SLTP_MODE_PCT, SLTP_MODE_FIXED_RR}) -ENTRUST_SLTP_MODES = frozenset({SLTP_MODE_PRICE, SLTP_MODE_PCT}) - - -def normalize_open_sltp_mode(raw: Optional[str]) -> str: - mode = (raw or SLTP_MODE_FIXED_RR).strip().lower() - if mode in OPEN_SLTP_MODES: - return mode - return SLTP_MODE_PRICE - - -def normalize_entrust_sltp_mode(raw: Optional[str]) -> str: - mode = (raw or SLTP_MODE_PRICE).strip().lower() - if mode in ENTRUST_SLTP_MODES: - return mode - return SLTP_MODE_PRICE - - -def parse_fixed_rr(raw: Any, *, default: float = MANUAL_FIXED_RR_DEFAULT) -> float: - try: - v = float(raw) - if v > 0: - return v - except (TypeError, ValueError): - pass - return float(default) - - -def calc_tp_from_fixed_rr( - direction: str, - entry_price: float, - stop_loss: float, - rr_ratio: float, -) -> float: - entry = float(entry_price) - sl = float(stop_loss) - rr = float(rr_ratio) - if entry <= 0 or sl <= 0 or rr <= 0: - raise ValueError("固定盈亏比参数无效") - side = (direction or "long").strip().lower() - if side == "short": - risk = sl - entry - if risk <= 0: - raise ValueError("止损方向不合法:做空时止损须高于入场价") - return entry - risk * rr - risk = entry - sl - if risk <= 0: - raise ValueError("止损方向不合法:做多时止损须低于入场价") - return entry + risk * rr - - -def _resolve_pct_sltp(direction: str, live_price: float, data: dict[str, Any]) -> Tuple[float, float]: - sl_pct = float(data.get("sl_pct") or 0) - tp_pct = float(data.get("tp_pct") or 0) - if sl_pct <= 0 or tp_pct <= 0: - raise ValueError("百分比止盈止损须为正数") - sl_ratio = sl_pct / 100.0 - tp_ratio = tp_pct / 100.0 - entry = float(live_price) - if (direction or "long").strip().lower() == "short": - stop_loss = entry * (1 + sl_ratio) - take_profit = entry * (1 - tp_ratio) - else: - stop_loss = entry * (1 - sl_ratio) - take_profit = entry * (1 + tp_ratio) - return stop_loss, take_profit - - -def _resolve_price_sltp( - data: dict[str, Any], - *, - fallback_sl: Optional[float] = None, - fallback_tp: Optional[float] = None, - require_tp: bool = True, -) -> Tuple[float, float]: - stop_loss = float(data.get("sl") or data.get("stop_loss") or 0) - take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0) - if stop_loss <= 0 and fallback_sl is not None: - stop_loss = float(fallback_sl) - if take_profit <= 0 and fallback_tp is not None: - take_profit = float(fallback_tp) - if stop_loss <= 0: - raise ValueError("止损价格须大于 0" if require_tp else "请填写止损价格") - if require_tp and take_profit <= 0: - raise ValueError("止盈止损价格须大于 0" if fallback_tp is None else "请填写止盈价格,或保留原计划止盈") - return stop_loss, take_profit - - -def resolve_open_sltp_prices( - direction: str, - live_price: float, - sltp_mode: Optional[str], - data: dict[str, Any], -) -> Tuple[float, float]: - """新开仓 /add_order:支持 price,pct,fixed_rr.""" - mode = normalize_open_sltp_mode(sltp_mode) - if mode == SLTP_MODE_PCT: - return _resolve_pct_sltp(direction, live_price, data) - if mode == SLTP_MODE_FIXED_RR: - stop_loss, _ = _resolve_price_sltp(data, require_tp=False) - rr = parse_fixed_rr(data.get("fixed_rr")) - take_profit = calc_tp_from_fixed_rr(direction, live_price, stop_loss, rr) - return stop_loss, take_profit - return _resolve_price_sltp(data, require_tp=True) - - -def resolve_entrust_sltp_prices( - direction: str, - live_price: float, - sltp_mode: Optional[str], - data: dict[str, Any], - *, - fallback_sl: Optional[float] = None, - fallback_tp: Optional[float] = None, -) -> Tuple[float, float]: - """持仓委托弹窗:仅 price / pct,不校验盈亏比.""" - mode = normalize_entrust_sltp_mode(sltp_mode) - if mode == SLTP_MODE_PCT: - return _resolve_pct_sltp(direction, live_price, data) - return _resolve_price_sltp( - data, - fallback_sl=fallback_sl, - fallback_tp=fallback_tp, - require_tp=True, - ) diff --git a/lib/trade/open_trade_gate_lib.py b/lib/trade/open_trade_gate_lib.py deleted file mode 100644 index be298d5..0000000 --- a/lib/trade/open_trade_gate_lib.py +++ /dev/null @@ -1,57 +0,0 @@ -"""三所开仓门禁:账户风控 + 强制清仓窗口 + 仓位/日开仓上限.""" -from __future__ import annotations - -from typing import Any, Optional - -from lib.trade.daily_open_limit_lib import can_trade_new_open -from lib.trade.force_close_lib import force_close_blocks_new_open - - -def resolve_manual_open_gate( - *, - time_allows: bool, - active_count: int, - max_active_positions: int, - opens_today: int, - hard_limit: int, - risk_status: Optional[dict[str, Any]], - force_close_enabled: bool, - force_close_bj_hour: Any, - now_ms: Optional[int] = None, - reset_hour: int = 8, -) -> dict[str, Any]: - """汇总是否可开仓及按钮旁说明文案.""" - rs = risk_status if isinstance(risk_status, dict) else {} - risk_can = bool(rs.get("can_trade", True)) - fc_block, fc_note = force_close_blocks_new_open( - bool(force_close_enabled), - force_close_bj_hour, - now_ms=now_ms, - ) - can_trade = can_trade_new_open( - time_allows=time_allows, - active_count=active_count, - max_active_positions=max_active_positions, - opens_today=opens_today, - hard_limit=hard_limit, - extra_blocks=(not risk_can) or fc_block, - ) - note = "" - if fc_block and fc_note: - note = fc_note - elif not risk_can: - note = str(rs.get("reason") or "账户冷静期/日冻结中,暂不可开仓") - elif not time_allows: - note = f"未到北京时间 {int(reset_hour)}:00,暂不可开仓" - elif int(active_count) >= int(max_active_positions): - note = f"已达最大持仓数({int(active_count)}/{int(max_active_positions)}),暂不可开仓" - elif int(hard_limit) > 0 and int(opens_today) >= int(hard_limit): - note = ( - f"本交易日开仓已达上限({int(opens_today)}/{int(hard_limit)})," - f"次日北京时间 {int(reset_hour)}:00 后恢复" - ) - return { - "can_trade": can_trade, - "open_block_note": note if not can_trade else "", - "force_close_blocks": fc_block, - } diff --git a/lib/trade/order_monitor_display_lib.py b/lib/trade/order_monitor_display_lib.py deleted file mode 100644 index 6351fc1..0000000 --- a/lib/trade/order_monitor_display_lib.py +++ /dev/null @@ -1,461 +0,0 @@ -"""实时持仓展示:开仓快照盈亏比,交易所止损是否已保本.""" -from __future__ import annotations - -from typing import Any, Callable, Optional - - -def _positive_float(value: Any) -> Optional[float]: - try: - v = float(value) - return v if v > 0 else None - except (TypeError, ValueError): - return None - - -def snapshot_stop_loss(initial_stop_loss: Any, stop_loss: Any) -> Optional[float]: - """展示盈亏比 / 交易记录时优先用开仓时止损快照,不用后续改单后的止损.""" - sl = _positive_float(initial_stop_loss) - if sl is not None: - return sl - return _positive_float(stop_loss) - - -def monitor_open_stop_loss(row: Any) -> Optional[float]: - """从 order_monitors 行取开仓止损快照.""" - try: - keys = row.keys() if hasattr(row, "keys") else () - except Exception: - keys = () - init = row["initial_stop_loss"] if "initial_stop_loss" in keys else None - cur = row["stop_loss"] if "stop_loss" in keys else None - if init is None and isinstance(row, dict): - init = row.get("initial_stop_loss") - cur = row.get("stop_loss") - return snapshot_stop_loss(init, cur) - - -def snapshot_rr( - calc_rr_ratio_fn: Callable[..., Optional[float]], - direction: str, - trigger_price: Any, - initial_stop_loss: Any, - stop_loss: Any, - take_profit: Any, -) -> Optional[float]: - entry = _positive_float(trigger_price) - sl = snapshot_stop_loss(initial_stop_loss, stop_loss) - tp = _positive_float(take_profit) - if entry is None or sl is None or tp is None: - return None - return calc_rr_ratio_fn(direction or "long", entry, sl, tp) - - -def tpsl_slot_trigger_price(slot: Any) -> Optional[float]: - if not isinstance(slot, dict): - return None - for key in ("trigger_price", "trigger_display"): - v = _positive_float(slot.get(key)) - if v is not None: - return v - return None - - -def stop_is_profit_protecting(direction: str, entry_price: Any, stop_loss: Any) -> bool: - """ - 止损是否已在盈利侧(保本/锁盈),不再适用「开仓盈亏比」风控. - 做空:止损 < 成交价;做多:止损 > 成交价. - """ - entry = _positive_float(entry_price) - sl = _positive_float(stop_loss) - if entry is None or sl is None: - return False - d = (direction or "long").strip().lower() - if d == "short": - return sl < entry - return sl > entry - - -def tpsl_update_passes_rr_gate( - direction: str, - entry_price: Any, - stop_loss: Any, - take_profit: Any, - min_rr: float, - calc_rr_ratio_fn: Callable[..., Optional[float]], -) -> tuple[bool, Optional[str]]: - """持仓委托改价:盈利侧止损跳过最低盈亏比;否则按开仓价几何校验.""" - if stop_is_profit_protecting(direction, entry_price, stop_loss): - return True, None - rr = calc_rr_ratio_fn(direction or "long", entry_price, stop_loss, take_profit) - if rr is not None and rr >= float(min_rr): - return True, None - rr_txt = f"{rr:.4f}" if rr is not None else "无法计算" - return False, f"计划盈亏比 {rr_txt}:1 低于最低要求 {min_rr}:1(盈利侧保本止损不受此限)" - - -def resolve_breakeven_entry_price(entry_price: Any, avg_entry_price: Any = None) -> Optional[float]: - """保本判断基准价:有持仓加权均价时优先(滚仓后),否则用首仓成交价.""" - avg = _positive_float(avg_entry_price) - if avg is not None: - return avg - return _positive_float(entry_price) - - -def stale_breakeven_armed(direction: str, entry_price: Any, stop_loss: Any, breakeven_armed: Any) -> bool: - """止损已回到亏损侧时 breakeven_armed 视为过期(如滚仓下移止损).""" - try: - armed = int(breakeven_armed or 0) != 0 - except (TypeError, ValueError): - return False - if not armed: - return False - return not stop_is_profit_protecting(direction, entry_price, stop_loss) - - -def is_sl_breakeven_secured(direction: str, entry_price: Any, exchange_sl_price: Any) -> bool: - """ - 交易所当前止损相对开仓成交价是否已保本. - 做多:止损 >= 成交价;做空:止损 <= 成交价. - """ - entry = _positive_float(entry_price) - sl = _positive_float(exchange_sl_price) - if entry is None or sl is None: - return False - d = (direction or "long").strip().lower() - if d == "short": - return sl <= entry - return sl >= entry - - -def sl_breakeven_from_exchange_tpsl( - direction: str, - entry_price: Any, - exchange_tpsl: Any, -) -> bool: - if not isinstance(exchange_tpsl, dict): - return False - sl_px = tpsl_slot_trigger_price(exchange_tpsl.get("sl")) - if sl_px is None: - return False - return is_sl_breakeven_secured(direction, entry_price, sl_px) - - -def enrich_order_display_fields(item: dict[str, Any], calc_rr_ratio_fn: Callable[..., Optional[float]]) -> dict[str, Any]: - item["rr_ratio"] = snapshot_rr( - calc_rr_ratio_fn, - item.get("direction") or "long", - item.get("trigger_price"), - item.get("initial_stop_loss"), - item.get("stop_loss"), - item.get("take_profit"), - ) - return item - - -def apply_order_live_price_display( - payload: dict[str, Any], - symbol: Any, - ticker_price: Any, - exchange_mark_price: Any, - format_price_fn: Callable[[Any, Any], str], -) -> dict[str, Any]: - """标记价/现价展示:与交易所 price_to_precision 对齐,避免前端 toFixed(8).""" - px_for_fmt = ticker_price - mark_raw = exchange_mark_price - if mark_raw is not None: - try: - px_for_fmt = float(mark_raw) - except (TypeError, ValueError): - pass - px_disp = format_price_fn(symbol, px_for_fmt) - payload["price_display"] = px_disp - if mark_raw is not None: - try: - payload["exchange_mark_price_display"] = format_price_fn(symbol, float(mark_raw)) - except (TypeError, ValueError): - payload["exchange_mark_price_display"] = px_disp - else: - payload["exchange_mark_price_display"] = None - return payload - - -def resolve_live_tpsl_prices( - plan_sl: Any, - plan_tp: Any, - exchange_tpsl: Any, -) -> tuple[Optional[float], Optional[float], Optional[float], Optional[float]]: - """返回 (展示用止损, 展示用止盈, 交易所止损, 交易所止盈).""" - ex_sl = ex_tp = None - if isinstance(exchange_tpsl, dict): - ex_sl = tpsl_slot_trigger_price(exchange_tpsl.get("sl")) - ex_tp = tpsl_slot_trigger_price(exchange_tpsl.get("tp")) - disp_sl = ex_sl if ex_sl is not None else _positive_float(plan_sl) - disp_tp = ex_tp if ex_tp is not None else _positive_float(plan_tp) - return disp_sl, disp_tp, ex_sl, ex_tp - - -def calc_risk_fraction(direction: str, entry_price: Any, stop_loss: Any) -> Optional[float]: - """|入场-止损|/入场;盈利侧止损返回 0.""" - entry = _positive_float(entry_price) - sl = _positive_float(stop_loss) - if entry is None or sl is None: - return None - d = (direction or "long").strip().lower() - if d == "short": - risk = sl - entry - else: - risk = entry - sl - if risk <= 0: - return 0.0 - return risk / entry - - -def calc_latest_risk_amount( - direction: str, - entry_price: Any, - stop_loss: Any, - *, - margin_capital: Any = None, - leverage: Any = None, - exchange_notional: Any = None, - contracts: Any = None, - contract_size: Any = None, - mark_price: Any = None, - funds_decimals: int = 2, -) -> Optional[float]: - """按当前止损与持仓名义价值估算最新风险(U).""" - rf = calc_risk_fraction(direction, entry_price, stop_loss) - if rf is None: - return None - if rf <= 0: - return 0.0 - notional = _positive_float(exchange_notional) - if notional is None: - try: - mc = float(margin_capital or 0) - lev = float(leverage or 0) - if mc > 0 and lev > 0: - notional = mc * lev - except (TypeError, ValueError): - pass - if notional is None: - try: - c = abs(float(contracts or 0)) - cs = float(contract_size or 1) - if cs <= 0: - cs = 1.0 - px = _positive_float(mark_price) or _positive_float(entry_price) - if c > 0 and px is not None: - notional = c * cs * px - except (TypeError, ValueError): - pass - if notional is None or notional <= 0: - return None - return round(notional * rf, funds_decimals) - - -def order_monitor_tpsl_needs_sync( - plan_sl: Any, - plan_tp: Any, - exchange_tpsl: Any, - *, - eps: float = 1e-12, -) -> tuple[Optional[float], Optional[float], bool]: - """若交易所 TP/SL 与库中不一致,返回应写回的 (sl, tp) 及是否需更新.""" - _, _, ex_sl, ex_tp = resolve_live_tpsl_prices(plan_sl, plan_tp, exchange_tpsl) - try: - cur_sl = float(plan_sl or 0) - cur_tp = float(plan_tp or 0) - except (TypeError, ValueError): - cur_sl, cur_tp = 0.0, 0.0 - new_sl = ex_sl if ex_sl is not None else cur_sl - new_tp = ex_tp if ex_tp is not None else cur_tp - changed = ( - (ex_sl is not None and abs(new_sl - cur_sl) > eps) - or (ex_tp is not None and abs(new_tp - cur_tp) > eps) - ) - return new_sl, new_tp, changed - - -def apply_order_price_display_fields( - payload: dict[str, Any], - *, - direction: str, - entry_price: Any, - initial_stop_loss: Any, - stop_loss: Any, - take_profit: Any, - calc_rr_ratio_fn: Callable[..., Optional[float]], - exchange_tpsl: Any = None, - format_price_fn: Optional[Callable[[Any, Any], str]] = None, - symbol: Any = None, - margin_capital: Any = None, - leverage: Any = None, - exchange_notional: Any = None, - contracts: Any = None, - contract_size: Any = None, - mark_price: Any = None, - avg_entry_price: Any = None, - funds_decimals: int = 2, -) -> dict[str, Any]: - disp_sl, disp_tp, _, _ = resolve_live_tpsl_prices(stop_loss, take_profit, exchange_tpsl) - payload["stop_loss_raw"] = _positive_float(stop_loss) - payload["take_profit_raw"] = _positive_float(take_profit) - payload["rr_ratio"] = snapshot_rr( - calc_rr_ratio_fn, - direction, - entry_price, - initial_stop_loss, - stop_loss, - take_profit, - ) - risk_entry = resolve_breakeven_entry_price(entry_price, avg_entry_price) - payload["avg_entry_price"] = risk_entry - payload["sl_breakeven_secured"] = sl_breakeven_from_exchange_tpsl( - direction, risk_entry, exchange_tpsl - ) - payload["stop_loss"] = disp_sl - payload["take_profit"] = disp_tp - if disp_sl is not None and disp_tp is not None: - payload["display_rr_ratio"] = calc_rr_ratio_fn( - direction or "long", entry_price, disp_sl, disp_tp - ) - else: - payload["display_rr_ratio"] = None - if contracts is not None: - try: - from lib.market.position_metrics_lib import normalize_contracts_qty - - c = normalize_contracts_qty(contracts) - if c > 0: - payload["contracts"] = c - except (TypeError, ValueError): - pass - payload["latest_risk_amount"] = calc_latest_risk_amount( - direction, - risk_entry, - disp_sl if disp_sl is not None else stop_loss, - margin_capital=margin_capital, - leverage=leverage, - exchange_notional=exchange_notional, - contracts=payload.get("contracts") if payload.get("contracts") is not None else contracts, - contract_size=contract_size, - mark_price=mark_price, - funds_decimals=funds_decimals, - ) - tp_for_reward = disp_tp if disp_tp is not None else _positive_float(take_profit) - qty_for_reward = payload.get("contracts") - if qty_for_reward is None and contracts is not None: - try: - qty_for_reward = abs(float(contracts)) - except (TypeError, ValueError): - qty_for_reward = None - if risk_entry is not None and tp_for_reward is not None and qty_for_reward: - try: - def reward_at_tp_usdt(direction, entry, tp, contracts, contract_size, fee_rate=0.0): - try: - d = (direction or "long").lower() - e, t, c, cs = float(entry), float(tp), float(contracts), float(contract_size or 1) - if e <= 0 or t <= 0 or c <= 0: - return None - raw = (t - e) * c * cs if d == "long" else (e - t) * c * cs - return round(raw, 2) - except Exception: - return None - - reward = reward_at_tp_usdt( - direction, - risk_entry, - tp_for_reward, - float(qty_for_reward), - contract_size=float(contract_size or 1.0), - ) - payload["reward_at_tp_usdt"] = ( - round(reward, funds_decimals) if reward is not None else None - ) - except Exception: - payload["reward_at_tp_usdt"] = None - else: - payload["reward_at_tp_usdt"] = None - if format_price_fn is not None and symbol is not None: - payload["stop_loss_display"] = ( - format_price_fn(symbol, disp_sl) if disp_sl is not None else "—" - ) - payload["take_profit_display"] = ( - format_price_fn(symbol, disp_tp) if disp_tp is not None else "—" - ) - mark_raw = mark_price if mark_price is not None else None - if mark_raw is not None and format_price_fn is not None and symbol is not None: - try: - payload["exchange_mark_price_display"] = format_price_fn(symbol, float(mark_raw)) - except (TypeError, ValueError): - payload["exchange_mark_price_display"] = None - return payload - - -def enrich_active_monitor_tpsl_json( - row: Any, - stop_loss: Any, - take_profit: Any, - exchange_tpsl: Any, - *, - position_row: Any = None, - exchange_notional: Any = None, - contracts: Any = None, - contract_size: float = 1.0, - mark_price: Any = None, - calc_rr_ratio_fn: Callable[..., Optional[float]], - format_price_fn: Optional[Callable[[Any, Any], str]] = None, - symbol: Any = None, - funds_decimals: int = 2, -) -> dict[str, Any]: - """place_tpsl 响应:展示用 TP/SL,最新风险,当前盈亏比.""" - def _row_val(key: str, default=None): - try: - if hasattr(row, "keys") and key in row.keys(): - return row[key] - except Exception: - pass - if isinstance(row, dict): - return row.get(key, default) - return default - - direction = _row_val("direction") or "long" - entry = _row_val("trigger_price") - init_sl = _row_val("initial_stop_loss") - margin = _row_val("margin_capital") - leverage = _row_val("leverage") - if position_row is not None: - from lib.market.position_metrics_lib import parse_position_entry_price, position_contracts - - live_c = position_contracts(position_row) - if abs(live_c) >= 1e-12: - contracts = abs(live_c) - avg_entry = parse_position_entry_price(position_row) - else: - avg_entry = None - payload: dict[str, Any] = { - "stop_loss": stop_loss, - "take_profit": take_profit, - } - apply_order_price_display_fields( - payload, - direction=direction, - entry_price=entry, - initial_stop_loss=init_sl, - stop_loss=stop_loss, - take_profit=take_profit, - calc_rr_ratio_fn=calc_rr_ratio_fn, - exchange_tpsl=exchange_tpsl, - format_price_fn=format_price_fn, - symbol=symbol or _row_val("symbol"), - margin_capital=margin, - leverage=leverage, - exchange_notional=exchange_notional, - contracts=contracts, - contract_size=contract_size, - mark_price=mark_price, - avg_entry_price=avg_entry, - funds_decimals=funds_decimals, - ) - return payload diff --git a/lib/trade/position_sizing_lib.py b/lib/trade/position_sizing_lib.py deleted file mode 100644 index e6252af..0000000 --- a/lib/trade/position_sizing_lib.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -三所共用:计仓模式 risk(以损定仓)| full_margin(全仓杠杆). -仅 env POSITION_SIZING_MODE 切换;须无持仓(由部署流程保证). -""" -from __future__ import annotations - -import os -from typing import Any, Optional, Tuple - -MODE_RISK = "risk" -MODE_FULL_MARGIN = "full_margin" -VALID_MODES = frozenset({MODE_RISK, MODE_FULL_MARGIN}) - -OPEN_SOURCE_MANUAL = "manual" -# 历史兼容常量(策略/关键位自动单已移除,assert 仍可引用) -OPEN_SOURCE_KEY_AUTO = "key_auto" -OPEN_SOURCE_KEY_FIB = "key_fib" -OPEN_SOURCE_KEY_TRIGGER = "key_trigger" -OPEN_SOURCE_TREND = "trend" -OPEN_SOURCE_ROLL = "roll" - -FULL_MARGIN_BLOCKED_SOURCES = frozenset() - - -def normalize_position_sizing_mode(raw: Optional[str]) -> str: - v = (raw or MODE_RISK).strip().lower() - if v in ("full", "full_margin", "fullmargin", "全仓", "全仓杠杆"): - return MODE_FULL_MARGIN - return MODE_RISK if v in ("risk", "r", "以损定仓", "") else MODE_RISK - - -def load_position_sizing_mode(env: Optional[dict] = None) -> str: - e = env if env is not None else os.environ - return normalize_position_sizing_mode(e.get("POSITION_SIZING_MODE")) - - -def is_full_margin_mode(mode: str) -> bool: - return normalize_position_sizing_mode(mode) == MODE_FULL_MARGIN - - -def mode_label_zh(mode: str) -> str: - return "全仓杠杆" if is_full_margin_mode(mode) else "以损定仓" - - -def leverage_for_full_margin(symbol: str, btc_leverage: int, alt_leverage: int) -> int: - sym = (symbol or "").strip().upper() - if sym.startswith("BTC") or sym.startswith("ETH"): - return max(1, int(btc_leverage or 10)) - return max(1, int(alt_leverage or 5)) - - -def round_funds(value: float, decimals: int = 2) -> float: - return round(float(value), int(decimals)) - - -def risk_percent_for_storage(mode: str, risk_percent: float) -> Optional[float]: - """全仓杠杆:库内不写风险百分比(仅 risk_amount U).""" - if is_full_margin_mode(mode): - return None - return risk_percent - - -def format_risk_display_text( - mode: str, - risk_percent: Optional[float], - risk_amount: Optional[float], - *, - decimals: int = 2, -) -> str: - """持仓/通知「风险」文案:全仓仅 U;以损定仓为 %≈U.""" - amt: Optional[float] = None - if risk_amount is not None and risk_amount != "": - try: - amt = float(risk_amount) - except (TypeError, ValueError): - amt = None - if is_full_margin_mode(mode): - if amt is None: - return "—" - return f"{round_funds(amt, decimals)}U" - pct: Optional[float] = None - if risk_percent is not None and risk_percent != "": - try: - pct = float(risk_percent) - except (TypeError, ValueError): - pct = None - pct_txt = f"{pct:g}" if pct is not None else "—" - amt_txt = round_funds(amt, decimals) if amt is not None else "—" - return f"{pct_txt}%≈{amt_txt}U" - - -def assert_open_source_allowed(mode: str, source: str) -> Tuple[bool, str]: - if not is_full_margin_mode(mode): - return True, "" - src = (source or "").strip().lower() - if src in FULL_MARGIN_BLOCKED_SOURCES: - return False, ( - "当前为全仓杠杆模式(POSITION_SIZING_MODE=full_margin)," - "不允许关键位突破/斐波自动开仓,趋势回调与顺势加仓;" - "仅支持实盘人工下单与阻力/支撑提醒." - ) - return True, "" - - -def full_margin_requires_flat_position(active_count: int) -> Tuple[bool, str]: - if active_count > 0: - return False, "全仓杠杆模式仅允许单仓且无其它持仓,请先平仓后再开仓" - return True, "" - - -def compute_full_margin_sizing( - *, - symbol: str, - available_usdt: float, - capital_base: float, - buffer_ratio: float, - btc_leverage: int, - alt_leverage: int, - funds_decimals: int = 2, -) -> Tuple[Optional[dict[str, Any]], Optional[str]]: - if available_usdt is None or float(available_usdt) <= 0: - return None, "全仓杠杆:无法读取合约账户可用保证金" - lev = leverage_for_full_margin(symbol, btc_leverage, alt_leverage) - margin = round_funds(float(available_usdt) * float(buffer_ratio), funds_decimals) - if margin <= 0: - return None, "全仓杠杆:可用保证金不足" - notional = round_funds(margin * lev, funds_decimals) - ratio = round(margin / float(capital_base) * 100, 2) if capital_base else 0.0 - return { - "margin_capital": margin, - "leverage": lev, - "notional_value": notional, - "position_ratio": ratio, - "mode": MODE_FULL_MARGIN, - }, None diff --git a/lib/trade/trade_exchange_stats_lib.py b/lib/trade/trade_exchange_stats_lib.py deleted file mode 100644 index 7158f14..0000000 --- a/lib/trade/trade_exchange_stats_lib.py +++ /dev/null @@ -1,229 +0,0 @@ -"""平仓交易:交易所口径双边成交额与手续费(三所共用聚合逻辑).""" -from __future__ import annotations - -from typing import Any, Callable, Optional - - -def _coerce_ts_ms(raw: Any) -> int | None: - if raw in (None, ""): - return None - try: - v = int(raw) - return v if v > 1_000_000_000_000 else v * 1000 - except (TypeError, ValueError): - return None - - -def quote_turnover_usdt_from_fill(trade: dict, *, contract_size: float = 1.0) -> float: - """单笔成交的报价币成交额(USDT 口径).""" - info = trade.get("info") or {} - if not isinstance(info, dict): - info = {} - for key in ("quoteQty", "quote_qty", "fillNotionalUsd", "notional"): - try: - v = float(info.get(key) or 0) - if v > 0: - return abs(v) - except (TypeError, ValueError): - continue - try: - cost = float(trade.get("cost") or 0) - if cost > 0: - return abs(cost) - except (TypeError, ValueError): - pass - try: - price = float(trade.get("price") or 0) - amount = float(trade.get("amount") or 0) * float(contract_size or 1.0) - if price > 0 and amount > 0: - return abs(price * amount) - except (TypeError, ValueError): - pass - return 0.0 - - -def commission_usdt_from_fill(trade: dict) -> float: - """单笔成交手续费(正数表示成本).""" - fee = trade.get("fee") - if isinstance(fee, dict): - try: - cost = float(fee.get("cost") or 0) - except (TypeError, ValueError): - cost = 0.0 - if cost != 0: - cur = str(fee.get("currency") or "USDT").upper() - if cur in ("USDT", "USD", "BUSD", "USDC"): - return abs(cost) - return abs(cost) - info = trade.get("info") or {} - if isinstance(info, dict): - for key in ("fee", "commission", "fillFee"): - try: - v = float(info.get(key) or 0) - if v != 0: - return abs(v) - except (TypeError, ValueError): - continue - return 0.0 - - -def aggregate_bilateral_stats( - fills: list[dict], - *, - contract_size: float = 1.0, -) -> dict[str, float] | None: - """双边成交额 = 开+平所有相关 fill 的报价币成交额之和;手续费 = fill fee 之和.""" - if not fills: - return None - turnover = 0.0 - commission = 0.0 - for t in fills: - turnover += quote_turnover_usdt_from_fill(t, contract_size=contract_size) - commission += commission_usdt_from_fill(t) - if turnover <= 0 and commission <= 0: - return None - return { - "exchange_turnover_usdt": round(turnover, 4), - "exchange_commission_usdt": round(commission, 4), - } - - -def filter_position_lifecycle_fills( - trades: list[dict], - direction: str, - open_ms: int | None, - close_ms: int | None, - *, - hedge_mode: bool = False, - close_buffer_ms: int = 15 * 60 * 1000, -) -> list[dict]: - """ - 持仓生命周期内 fill:多=开买+平卖;空=开卖+平买. - hedge_mode 时按 posSide 与 direction 过滤. - """ - direction = (direction or "long").strip().lower() - open_side = "buy" if direction == "long" else "sell" - close_side = "sell" if direction == "long" else "buy" - allowed_sides = {open_side, close_side} - upper = int(close_ms) + int(close_buffer_ms) if close_ms else None - out: list[dict] = [] - for t in trades or []: - side = (t.get("side") or "").lower() - if side not in allowed_sides: - continue - ts = _coerce_ts_ms(t.get("timestamp")) - if ts is None: - continue - if open_ms and ts < int(open_ms) - 60_000: - continue - if upper and ts > upper: - continue - if hedge_mode: - info = t.get("info") or {} - if not isinstance(info, dict): - info = {} - pos_side = (info.get("posSide") or t.get("posSide") or "").lower() - if pos_side in ("long", "short") and pos_side != direction: - continue - out.append(t) - out.sort(key=lambda x: x.get("timestamp") or 0) - return out - - -def sum_binance_commission_income(entries: list[dict], trade_ids: set[str] | None) -> float | None: - """Binance income 流水中 COMMISSION 合计(负值取绝对值为成本).""" - if not entries: - return None - total = 0.0 - found = False - for e in entries: - it = (e.get("incomeType") or e.get("income_type") or "").strip() - if it != "COMMISSION": - continue - if trade_ids: - tid = str(e.get("tradeId") or e.get("trade_id") or "").strip() - if tid and tid not in trade_ids: - continue - try: - total += float(e.get("income") or 0) - found = True - except (TypeError, ValueError): - continue - if not found: - return None - return round(abs(total), 4) - - -def trade_ids_from_fills(fills: list[dict]) -> set[str]: - out: set[str] = set() - for t in fills or []: - info = t.get("info") or {} - if not isinstance(info, dict): - info = {} - for key in ("id", "tradeId", "trade_id"): - raw = t.get(key) if key in t else info.get(key) - if raw is not None and str(raw).strip(): - out.add(str(raw).strip()) - break - return out - - -def merge_commission_prefer_income( - fill_commission: float, - income_commission: float | None, -) -> float: - if income_commission is not None and income_commission > 0: - return round(income_commission, 4) - return round(max(fill_commission, 0.0), 4) - - -def update_trade_record_stats_columns( - conn: Any, - trade_id: int, - turnover_usdt: float | None, - commission_usdt: float | None, -) -> None: - if turnover_usdt is None and commission_usdt is None: - return - conn.execute( - """ - UPDATE trade_records - SET exchange_turnover_usdt = COALESCE(?, exchange_turnover_usdt), - exchange_commission_usdt = COALESCE(?, exchange_commission_usdt) - WHERE id = ? - """, - (turnover_usdt, commission_usdt, int(trade_id)), - ) - - -def attach_exchange_stats_to_trade( - conn: Any, - trade_id: int, - *, - fetch_fills: Callable[[], list[dict]], - contract_size: float = 1.0, - income_commission: float | None = None, -) -> dict[str, float] | None: - """拉 fill 并写库;仅在新单平仓路径调用.""" - try: - fills = fetch_fills() or [] - except Exception: - fills = [] - stats = aggregate_bilateral_stats(fills, contract_size=contract_size) - if not stats and income_commission is None: - return None - turnover = stats.get("exchange_turnover_usdt") if stats else None - fill_comm = float(stats.get("exchange_commission_usdt") or 0) if stats else 0.0 - commission = merge_commission_prefer_income(fill_comm, income_commission) - update_trade_record_stats_columns( - conn, - trade_id, - turnover, - commission if commission > 0 else None, - ) - out = {} - if turnover is not None: - out["exchange_turnover_usdt"] = turnover - if commission > 0: - out["exchange_commission_usdt"] = commission - return out or None diff --git a/lib/trade/trade_fee_lib.py b/lib/trade/trade_fee_lib.py deleted file mode 100644 index ed27312..0000000 --- a/lib/trade/trade_fee_lib.py +++ /dev/null @@ -1,94 +0,0 @@ -"""永续估算盈亏:固定 taker 手续费(默认单边 0.05%,开+平双边). - -浮盈亏仍读交易所;本模块只服务「盈利金额 / 止盈盈利 / 推送 / 记账 pnl_amount」等估算口径. -""" -from __future__ import annotations - -import math -import os -from typing import Optional - - -def _finite(v) -> Optional[float]: - try: - f = float(v) - return f if math.isfinite(f) else None - except (TypeError, ValueError): - return None - - -def taker_fee_rate() -> float: - """单边 taker 费率,默认 0.0005(=0.05%).""" - raw = os.getenv("PERP_TAKER_FEE_RATE", "0.0005") - rate = _finite(raw) - if rate is None or rate < 0: - return 0.0005 - return rate - - -def notional_usdt(price, qty, contract_size: float = 1.0) -> Optional[float]: - """名义价值 U = 价格 × 张数 × 合约面值.""" - p = _finite(price) - q = _finite(qty) - cs = _finite(contract_size) - if p is None or q is None or p <= 0 or q <= 0: - return None - if cs is None or cs <= 0: - cs = 1.0 - return abs(q) * p * cs - - -def estimate_roundtrip_fee_usdt( - entry_price, - exit_price, - qty=None, - contract_size: float = 1.0, - *, - open_notional: float | None = None, - rate: float | None = None, -) -> float: - """开+平双边手续费(各单边 rate). - - 优先用 价×张×面值;若无张数则用 open_notional 估开仓名义, - 平仓名义按 exit/entry 缩放. - """ - fee_rate = taker_fee_rate() if rate is None else float(rate) - if fee_rate <= 0: - return 0.0 - entry = _finite(entry_price) - exit_p = _finite(exit_price) - open_n = notional_usdt(entry, qty, contract_size) if qty is not None else None - if open_n is None: - open_n = _finite(open_notional) - if open_n is None or open_n <= 0: - return 0.0 - if entry is not None and entry > 0 and exit_p is not None and exit_p > 0: - close_n = open_n * (exit_p / entry) - else: - close_n = open_n - return round(open_n * fee_rate + close_n * fee_rate, 8) - - -def net_pnl_after_fee( - gross_pnl, - entry_price, - exit_price, - qty=None, - contract_size: float = 1.0, - *, - open_notional: float | None = None, - rate: float | None = None, -) -> Optional[float]: - """毛利扣双边手续费后的净盈亏;gross 无效则返回 None.""" - gross = _finite(gross_pnl) - if gross is None: - return None - fee = estimate_roundtrip_fee_usdt( - entry_price, - exit_price, - qty, - contract_size, - open_notional=open_notional, - rate=rate, - ) - return round(gross - fee, 4) diff --git a/lib/trade/trade_labels_lib.py b/lib/trade/trade_labels_lib.py index 12b36cf..1b52380 100644 --- a/lib/trade/trade_labels_lib.py +++ b/lib/trade/trade_labels_lib.py @@ -158,12 +158,5 @@ def order_monitor_excluded_from_position_limit(conn, row) -> bool: def count_position_limit_active_monitors(conn) -> int: - try: - rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() - except Exception: - return 0 - n = 0 - for row in rows: - if not order_monitor_excluded_from_position_limit(conn, row): - n += 1 - return n + del conn + return 0 diff --git a/lib/trade/trade_result_lib.py b/lib/trade/trade_result_lib.py index 156f953..dd4d3bf 100644 --- a/lib/trade/trade_result_lib.py +++ b/lib/trade/trade_result_lib.py @@ -2,11 +2,23 @@ from __future__ import annotations -from typing import Optional +from typing import Any, Optional _WIN_EPS = 1e-9 +def snapshot_stop_loss(initial_stop_loss: Any, stop_loss: Any) -> Optional[float]: + """展示盈亏比时优先用开仓止损快照.""" + for v in (initial_stop_loss, stop_loss): + try: + f = float(v) + if f > 0: + return f + except (TypeError, ValueError): + continue + return None + + def classify_exit_by_levels( direction, trigger_price, diff --git a/lib/trade/trade_stats_calendar_lib.py b/lib/trade/trade_stats_calendar_lib.py deleted file mode 100644 index 18d5361..0000000 --- a/lib/trade/trade_stats_calendar_lib.py +++ /dev/null @@ -1,115 +0,0 @@ -"""按交易日聚合实例 trade_records 盈亏,供统计分析页日历 API 使用.""" -from __future__ import annotations - -import json -from datetime import datetime, timedelta -from typing import Any, Callable - - -def build_trade_stats_calendar( - pnls: list[tuple], - year: int, - month: int, - segment_key: str, - row_matches_fn: Callable[[Any, str], bool], - *, - reset_hour: int = 8, -) -> dict[str, Any]: - """pnls: _load_completed_trade_pnls 返回值 (pnl, close_dt, trading_day, row).""" - y = int(year) - m = int(month) - if m < 1 or m > 12: - raise ValueError("month 无效") - first = f"{y:04d}-{m:02d}-01" - if m == 12: - next_first = datetime(y + 1, 1, 1) - else: - next_first = datetime(y, m + 1, 1) - last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d") - seg = (segment_key or "all").strip() or "all" - days: dict[str, dict[str, Any]] = {} - for pnl, _close_dt, td, row in pnls: - if not td or td < first or td > last: - continue - if not row_matches_fn(row, seg): - continue - bucket = days.setdefault( - td, - { - "trading_day": td, - "open_count": 0, - "pnl_total": 0.0, - "turnover_total": 0.0, - "commission_total": 0.0, - "has_sick": False, - "sick_count": 0, - }, - ) - bucket["open_count"] += 1 - bucket["pnl_total"] += float(pnl or 0) - try: - bucket["turnover_total"] += float(row["exchange_turnover_usdt"] or 0) - except (TypeError, ValueError, KeyError): - pass - try: - bucket["commission_total"] += float(row["exchange_commission_usdt"] or 0) - except (TypeError, ValueError, KeyError): - pass - for d in days.values(): - d["pnl_total"] = round(float(d["pnl_total"]), 4) - d["turnover_total"] = round(float(d["turnover_total"]), 4) - d["commission_total"] = round(float(d["commission_total"]), 4) - month_pnl = sum(float(d["pnl_total"]) for d in days.values()) - month_count = sum(int(d["open_count"]) for d in days.values()) - return { - "year": y, - "month": m, - "date_from": first, - "date_to": last, - "segment": seg, - "reset_hour": int(reset_hour), - "days": days, - "month_pnl_total": round(month_pnl, 4), - "month_open_count": month_count, - } - - -def build_initial_stats_calendar( - pnls: list[tuple], - now_dt: datetime, - row_matches_fn: Callable[[Any, str], bool], - *, - reset_hour: int = 8, - segment_key: str = "all", -) -> dict[str, Any]: - """统计页首屏内嵌日历(当前自然月,默认品类).""" - return build_trade_stats_calendar( - pnls, - now_dt.year, - now_dt.month, - segment_key, - row_matches_fn, - reset_hour=reset_hour, - ) - - -def build_stats_calendar_bootstrap( - pnls: list[tuple], - now_dt: datetime, - row_matches_fn: Callable[[Any, str], bool], - *, - reset_hour: int = 8, - segment_key: str = "all", -) -> tuple[dict[str, Any] | None, str | None]: - """返回 (payload, json_str);失败时 (None, None),供模板安全内嵌.""" - try: - payload = build_initial_stats_calendar( - pnls, - now_dt, - row_matches_fn, - reset_hour=reset_hour, - segment_key=segment_key, - ) - return payload, json.dumps(payload, ensure_ascii=False, separators=(",", ":")) - except Exception: - return None, None diff --git a/templates/order_focus.html b/templates/order_focus.html deleted file mode 100644 index 3dc7ce3..0000000 --- a/templates/order_focus.html +++ /dev/null @@ -1,195 +0,0 @@ - - - - - 实盘下单放大 | 100根K线 - - - -
-
-
-
- 返回首页 - 实盘下单放大(100根K线) -
-
最近刷新:--
-
- {% if orders %} -
- - - - - - -
- {% else %} -
当前没有激活订单,无法展示放大K线.
- {% endif %} -
- - {% if orders %} -
-
-
交易对
-
-
方向
-
-
成交价
-
-
止损
-
-
止盈
-
-
盈亏比
-
-
现价
-
-
浮盈亏
-
-
-
- -
-
-
- {% endif %} -
- -{% if orders %} - - -{% endif %} - -