diff --git a/app.py b/app.py index a2a9789..560b9db 100644 --- a/app.py +++ b/app.py @@ -1,7558 +1,7547 @@ -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 -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 - from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables - - init_options_tables(conn) - init_hedge_plan_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) - conn.row_factory = sqlite3.Row - 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): - ensure_markets_loaded() - # 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 - - -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"]) - ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding - ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading - ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts - except Exception: - pass - 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): - try: - ensure_markets_loaded() - return exchange.fetch_ticker(normalize_okx_symbol(symbol))["last"] - except: - 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() - -# 后台线程 -def background_task(): - while True: - try: - auto_transfer_once_per_day() - conn = get_db() - force_close_before_reset() - reconcile_external_closes(conn) - conn.commit() - conn.close() - check_key_monitors() - check_order_monitors() - except Exception as e: - print(f"[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) - 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 = round(trading_capital, FUNDS_DECIMALS) if trading_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(current_capital) - 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), current_capital)) - 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.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode - - _okx_trade_mode = get_okx_trade_mode() - _hedge_mode_on = _okx_trade_mode in ("perp_options", "options_options") - _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, - 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(): - # 实盘下单界面已移除;永续下单仍由对冲计划走 place_exchange_order - 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("/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 - current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_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(current_capital) - 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) - leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) - available = get_available_trading_usdt() - last_price = get_price(symbol) - return jsonify({ - "ok": True, - "symbol": symbol, - "exchange_symbol": exchange_symbol, - "direction": direction, - "leverage": leverage, - "available_trading_usdt": round(available, 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") - 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.hedge_plan.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") -_HEDGE_PLAN_JS = os.path.join(_REPO_STATIC_DIR, "hedge_plan.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("/static/hedge_plan.js") -def static_hedge_plan_js(): - if not os.path.isfile(_HEDGE_PLAN_JS): - return Response("not found", status=404, mimetype="text/plain; charset=utf-8") - return send_file(_HEDGE_PLAN_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, - ) -except Exception as _settings_err: - print(f"[instance_settings] okx: {_settings_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.hedge_plan.hedge_plan_register import install_hedge_plan - -install_hedge_plan(app, _REPO_ROOT, app_module=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) +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 +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) + conn.row_factory = sqlite3.Row + 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): + ensure_markets_loaded() + # 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 + + +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"]) + ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding + ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading + ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts + except Exception: + pass + 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): + try: + ensure_markets_loaded() + return exchange.fetch_ticker(normalize_okx_symbol(symbol))["last"] + except: + 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() + +# 后台线程 +def background_task(): + while True: + try: + auto_transfer_once_per_day() + conn = get_db() + force_close_before_reset() + reconcile_external_closes(conn) + conn.commit() + conn.close() + check_key_monitors() + check_order_monitors() + except Exception as e: + print(f"[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) + 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 = round(trading_capital, FUNDS_DECIMALS) if trading_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(current_capital) + 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), current_capital)) + 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, + 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(): + # 实盘下单界面已移除;永续下单仍由对冲计划走 place_exchange_order + 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("/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 + current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_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(current_capital) + 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) + leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + available = get_available_trading_usdt() + last_price = get_price(symbol) + return jsonify({ + "ok": True, + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "available_trading_usdt": round(available, 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") + 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, + ) +except Exception as _settings_err: + print(f"[instance_settings] okx: {_settings_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__]) + + +# 独立部署无 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/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js deleted file mode 100644 index 46847a0..0000000 --- a/lib/common/static/hedge_plan.js +++ /dev/null @@ -1,2791 +0,0 @@ -/** - * OKX 对冲计划 P0:行情 + 永期列表 / 期期 T + 情景测算 + 门禁. - */ -(function () { - const root = document.getElementById("hedge-plan-root"); - if (!root) return; - - function flagOn(attr) { - return root.getAttribute(attr) !== "0"; - } - - const showPerp = flagOn("data-show-perp"); - const showOo = flagOn("data-show-oo"); - - function pickDefaultTab() { - if (showPerp) return "perp_options"; - if (showOo) return "options_options"; - return "active"; - } - - const state = { - tab: pickDefaultTab(), - mode: showPerp ? "perp_options" : showOo ? "options_options" : "perp_options", - underlying: root.getAttribute("data-default-underly") || "ETH", - moneyFilter: root.getAttribute("data-option-primary") !== "0" ? "otm" : "itm", - ooMoneyFilter: "atm_otm", // 期期锁定:平值+虚值 - ooRecommend: null, // atm_straddle | double_otm | null - ooStrikeExpandAll: false, // 默认 Call/Put 各 3 档 - chain: null, - selected: null, - legA: null, - legB: null, - market: null, - ooSheetsMode: "same_sheets", - ooBiasSplitBy: "budget", - ooBiasRatio: 0.7, - ooCloseModeEnabled: root.getAttribute("data-oo-close-mode-enabled") !== "0", - ooCloseMode: "close_all", - direction: "long", - optionPrimary: root.getAttribute("data-option-primary") !== "0", - opLevTouched: false, - opRatioTouched: false, - tradingUsdc: null, - fundingUsdc: null, - tradeBudgetUsdc: null, - budgetBuffer: (function () { - const raw = root.getAttribute("data-budget-buffer"); - const n = raw != null && raw !== "" ? Number(raw) : NaN; - return !Number.isNaN(n) && n > 0 ? n : 0.95; - })(), - previewOk: false, - canStart: false, - previewPlanType: null, - }; - - function getDirection() { - return (state.direction || "long").toLowerCase() === "short" ? "short" : "long"; - } - - function syncPoDirUI() { - const dir = getDirection(); - document.querySelectorAll(".hp-po-dir").forEach(function (b) { - const on = (b.getAttribute("data-dir") || "") === dir; - b.classList.toggle("is-selected", on); - b.classList.toggle("active", on); - }); - } - - function setDirection(dir, forceReload) { - const next = (dir || "long").toLowerCase() === "short" ? "short" : "long"; - const changed = next !== getDirection(); - state.direction = next; - syncPoDirUI(); - if (!changed && !forceReload) return; - state.selected = null; - if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; - void loadMarket().then(function () { - renderListStrikes(); - }); - } - - function $(id) { - return document.getElementById(id); - } - - async function apiJson(url, opts) { - const res = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); - const data = await res.json().catch(function () { - return {}; - }); - if (!res.ok) throw new Error(data.msg || res.statusText || "请求失败"); - return data; - } - - function fmt(v, d) { - if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; - return Number(v).toFixed(d == null ? 2 : d); - } - - function fmtOptionPx(v, tickSz) { - if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; - const n = Number(v); - const tick = Number(tickSz); - if (!tickSz || Number.isNaN(tick) || tick <= 0) { - return String(n).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, ""); - } - let decimals = 0; - if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick))); - else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length; - let s = n.toFixed(decimals); - // 仅裁小数尾零;整数 tick(BTC=5)时绝不能把 1370 裁成 137 - if (decimals > 0) s = s.replace(/\.?0+$/, ""); - return s || "0"; - } - - /** 价格/流动性(张),价格按 tick_sz 对齐交易所精度. */ - function fmtPxSz(px, sz, estimated, tickSz) { - if (px === null || px === undefined || Number.isNaN(Number(px))) return "—"; - let price = fmtOptionPx(px, tickSz); - if (price === "—") return "—"; - if (estimated) price += "~"; - if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price; - const s = Number(sz); - const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); - return price + "/" + size; - } - - function moneynessBadge(c) { - const m = (c && c.moneyness) || ""; - const label = (c && c.moneyness_label) || "—"; - return '' + label + ""; - } - - function pnlClass(v) { - if (v == null || v === "") return ""; - const n = Number(v); - if (Number.isNaN(n)) return ""; - return n >= 0 ? "hp-pnl-pos" : "hp-pnl-neg"; - } - - function fmtPnlHtml(v, digits) { - if (v == null || v === "" || Number.isNaN(Number(v))) return "—"; - const cls = pnlClass(v); - return '' + fmt(v, digits) + ""; - } - - function fmtRr(v) { - if (v == null || Number.isNaN(Number(v))) return "—"; - return fmt(v, 2) + ":1"; - } - - /** 列表/盯盘候选杠杆门槛(与后端 effective_min_opt_leverage 对齐). */ - function optionPrimaryMinLev() { - const minLev = numInput("hp-opt-leverage", opMoneyKind() === "otm" ? 200 : 100); - if (!(minLev > 0)) return 0; - if (opMoneyKind() === "otm") return Math.max(minLev, 180); - return minLev; - } - - function optionPrimaryLevOk(c) { - const idx = indexPx(); - const ask = Number(c && c.ask); - const floor = optionPrimaryMinLev(); - if (!(floor > 0)) return true; - if (!(idx > 0) || !(ask > 0)) return false; - return idx / ask >= floor - 1e-9; - } - - function matchesMoneyFilter(c) { - const f = state.moneyFilter || "itm"; - const m = (c.moneyness || "").toLowerCase(); - if (!isOptionPrimary() && f === "otm") return false; - // 列表:间隔+虚实值+杠杆门槛(达标才显示;启动盯盘后监控同样门槛) - if (isOptionPrimary()) { - const idx = indexPx(); - const interval = numInput("hp-strike-interval", 15); - if (idx && interval > 0 && Math.abs(Number(c.strike) - idx) > interval + 1e-9) { - return false; - } - if (!optionPrimaryLevOk(c)) return false; - } - if (f === "itm") return m === "itm" || m === "atm"; - if (f === "atm") return m === "atm"; - if (f === "otm") return m === "otm"; - return m === "itm" || m === "atm"; - } - - function matchesOoMoneyFilter(c) { - // 期期:仅平值/虚值(禁实值) - if (!c) return false; - const m = (c.moneyness || "").toLowerCase(); - const f = state.ooMoneyFilter || "atm_otm"; - if (f === "atm") return m === "atm"; - if (f === "otm") return m === "otm"; - return m === "atm" || m === "otm"; - } - - function indexPx() { - const fromChain = state.chain && Number(state.chain.index_px); - if (fromChain && !Number.isNaN(fromChain) && fromChain > 0) return fromChain; - const fromMkt = state.market && Number(state.market.index_px || state.market.mark); - if (fromMkt && !Number.isNaN(fromMkt) && fromMkt > 0) return fromMkt; - return null; - } - - function currentContracts(expSelectId) { - const exp = currentExp(expSelectId); - return (exp && exp.contracts) || []; - } - - function pickClosestItmAtm(contracts, want) { - const idx = indexPx(); - if (!idx) return null; - const list = (contracts || []).filter(function (c) { - return ( - String(c.opt_type || "").toUpperCase() === want && - matchesMoneyFilter(c) - ); - }); - if (!list.length) return null; - list.sort(function (a, b) { - return Math.abs(Number(a.strike) - idx) - Math.abs(Number(b.strike) - idx); - }); - return list[0]; - } - - function pickOoTemplate(template) { - const idx = indexPx(); - const contracts = currentContracts("hp-oo-exp-select"); - if (!idx || !contracts.length) return null; - const preferOtm = template === "double_otm"; - function pickSide(want) { - const list = contracts.filter(function (c) { - if (String(c.opt_type || "").toUpperCase() !== want) return false; - if (!matchesOoMoneyFilter(c)) return false; - const m = (c.moneyness || "").toLowerCase(); - if (preferOtm) return m === "otm"; - return m === "atm" || m === "otm"; - }); - if (!list.length) return null; - list.sort(function (a, b) { - const ma = (a.moneyness || "").toLowerCase(); - const mb = (b.moneyness || "").toLowerCase(); - if (!preferOtm) { - if (ma === "atm" && mb !== "atm") return -1; - if (mb === "atm" && ma !== "atm") return 1; - } - return Math.abs(Number(a.strike) - idx) - Math.abs(Number(b.strike) - idx); - }); - return list[0]; - } - const call = pickSide("C"); - const put = pickSide("P"); - if (!call || !put) return null; - return { call: call, put: put }; - } - - function isOptionPrimary() { - return !!state.optionPrimary; - } - - function optTypeForDirection(dir) { - if (isOptionPrimary()) { - return dir === "short" ? "P" : "C"; - } - return dir === "short" ? "C" : "P"; - } - - function opMoneyKind() { - const f = state.moneyFilter || "itm"; - if (f === "otm") return "otm"; - if (f === "atm") return "atm"; - return "itm"; - } - - function applyOpDefaultsFromMoney(force) { - const kind = opMoneyKind(); - const levEl = $("hp-opt-leverage"); - const ratioEl = $("hp-opt-perp-ratio"); - if (levEl && (force || !state.opLevTouched)) { - levEl.value = kind === "otm" ? "200" : "100"; - } - if (ratioEl && (force || !state.opRatioTouched)) { - ratioEl.value = kind === "otm" ? "4" : "2"; - } - } - - function syncOptionPrimaryUI() { - const on = isOptionPrimary(); - const ins = $("hp-po-fields-insurance"); - const op = $("hp-po-fields-option-primary"); - // 保险模式只显示开仓价/张数/止盈止损;以期权为主显示三组参数(env 切换,页内不可改) - if (ins) { - ins.classList.toggle("hidden", on); - if (on) ins.setAttribute("hidden", "hidden"); - else ins.removeAttribute("hidden"); - ins.style.display = on ? "none" : ""; - } - if (op) { - op.classList.toggle("hidden", !on); - if (!on) op.setAttribute("hidden", "hidden"); - else op.removeAttribute("hidden"); - op.style.display = on ? "" : "none"; - } - const insMoney = $("hp-po-ins-money"); - if (insMoney) { - if (on) { - insMoney.setAttribute("hidden", "hidden"); - insMoney.classList.add("hidden"); - } else { - insMoney.removeAttribute("hidden"); - insMoney.classList.remove("hidden"); - } - } - document.querySelectorAll(".hp-money-otm").forEach(function (otmBtn) { - otmBtn.classList.toggle("hidden", !on); - if (!on) otmBtn.setAttribute("hidden", "hidden"); - else otmBtn.removeAttribute("hidden"); - }); - if (!on && state.moneyFilter === "otm") { - state.moneyFilter = "itm"; - syncMoneyUI(); - } - if (on) applyOpDefaultsFromMoney(false); - const badge = $("hp-po-mode-badge"); - if (badge) { - badge.textContent = on ? "以期权为主" : "保险模式"; - badge.classList.toggle("is-insurance", !on); - } - const title = $("hp-po-card-title"); - if (title) title.textContent = "执行参数"; - const dirLong = document.querySelector('.hp-po-dir[data-dir="long"]'); - const dirShort = document.querySelector('.hp-po-dir[data-dir="short"]'); - if (dirLong) dirLong.title = on ? "做多=买Call+永续空" : "做多永续"; - if (dirShort) dirShort.title = on ? "做空=买Put+永续多" : "做空永续"; - syncPoActionBtn(); - } - - function hoursFromExpMs(expMs) { - const n = Number(expMs); - if (!n || Number.isNaN(n)) return null; - const ms = n < 1e12 ? n * 1000 : n; - return (ms - Date.now()) / 3600000; - } - - function numInput(id, fallback) { - const el = $(id); - const n = Number(el && el.value); - if (Number.isNaN(n)) return fallback; - return n; - } - - function computeOpSizing(ask, ctMult) { - const budget = numInput("hp-premium-budget", 0); - const ratio = numInput("hp-opt-perp-ratio", 2); - const cs = Number((state.market && state.market.contract_size) || 0.01); - const usable = budget * 0.95; - const a = Number(ask || 0); - const ct = Number(ctMult || 0.01); - if (!(budget > 0) || !(a > 0) || !(ct > 0) || !(ratio > 0) || !(cs > 0)) { - return null; - } - let eth = Math.floor((usable / a) * 100 + 1e-12) / 100; - if (!(eth > 0)) return null; - let sheets = Math.floor(eth / ct + 1e-12); - if (!(sheets > 0)) return null; - eth = Math.round(sheets * ct * 100) / 100; - const perpEth = eth / ratio; - const contracts = perpEth / cs; - return { - usable: usable, - eth_qty: eth, - sheets: sheets, - contracts: contracts, - premium_est: a * sheets * ct, - ratio: ratio, - }; - } - - function syncUnderlyingUI() { - const uly = state.underlying || "ETH"; - document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) { - const on = b.getAttribute("data-uly") === uly; - b.classList.toggle("active", on); - b.setAttribute("aria-pressed", on ? "true" : "false"); - }); - const lab = $("hp-perp-uly-label"); - if (lab) lab.textContent = uly; - const ooLab = $("hp-oo-uly-label"); - if (ooLab) ooLab.textContent = uly; - } - - function syncMoneyUI() { - const moneySel = $("hp-money-select"); - if (moneySel && isOptionPrimary()) { - moneySel.value = state.moneyFilter === "otm" ? "otm" : state.moneyFilter === "atm" ? "atm" : "itm"; - } - document.querySelectorAll(".hp-money-btn").forEach(function (b) { - const on = b.getAttribute("data-money") === state.moneyFilter; - b.classList.toggle("active", on); - b.classList.toggle("is-selected", on); - b.setAttribute("aria-pressed", on ? "true" : "false"); - }); - document.querySelectorAll(".hp-oo-money-btn").forEach(function (b) { - const on = b.getAttribute("data-oo-money") === state.ooMoneyFilter; - b.classList.toggle("active", on); - b.classList.toggle("is-selected", on); - b.setAttribute("aria-pressed", on ? "true" : "false"); - }); - } - - function syncPoActionBtn() { - const btn = $("hp-preview-btn"); - if (!btn) return; - if (isOptionPrimary()) { - btn.textContent = "策略启动"; - btn.title = "按参数启动盯盘;杠杆/间隔达标后自动开仓(非现场开)"; - } else { - btn.textContent = "计算"; - btn.title = "情景测算后再启动"; - } - } - - function isPoOptionPrimaryPlan(p) { - if (!p || p.plan_type !== "perp_options") return false; - return p.option_primary == 1 || p.option_primary === true || Number(p.option_primary) === 1; - } - - function setPoStrategyStatus(kind, planId) { - const el = $("hp-po-strategy-status"); - if (!el) return; - el.classList.remove("is-watching", "is-holding", "is-idle"); - if (!isOptionPrimary()) { - el.textContent = ""; - return; - } - const idPart = planId ? " #" + planId : ""; - if (kind === "watching") { - el.textContent = "盯盘中" + idPart; - el.classList.add("is-watching"); - } else if (kind === "holding") { - el.textContent = "持仓中" + idPart; - el.classList.add("is-holding"); - } else { - el.textContent = "未启动"; - el.classList.add("is-idle"); - } - } - - async function refreshPoStrategyStatus() { - const el = $("hp-po-strategy-status"); - if (!el) return; - if (!isOptionPrimary()) { - setPoStrategyStatus("", null); - return; - } - try { - const d = await apiJson("/api/hedge-plan/active"); - const uly = (state.underlying || "ETH").toUpperCase(); - const rows = (d.plans || []).filter(function (p) { - return isPoOptionPrimaryPlan(p) && String(p.underlying || "").toUpperCase() === uly; - }); - if (!rows.length) { - setPoStrategyStatus("idle", null); - return; - } - rows.sort(function (a, b) { - return Number(b.id || 0) - Number(a.id || 0); - }); - const p = rows[0]; - const st = String(p.status || ""); - if (st === "watching") setPoStrategyStatus("watching", p.id); - else if (st === "active" || st === "opening" || st === "partial") setPoStrategyStatus("holding", p.id); - else setPoStrategyStatus("idle", null); - } catch (e) { - /* ignore status refresh errors */ - } - } - - function syncOoRecommendUI() { - const cur = state.ooRecommend || ""; - document.querySelectorAll(".hp-oo-recommend-btn").forEach(function (b) { - const on = (b.getAttribute("data-oo-rec") || "") === cur; - b.classList.toggle("active", on); - b.classList.toggle("is-selected", on); - b.setAttribute("aria-pressed", on ? "true" : "false"); - }); - } - - function syncOoExpandUI() { - const btn = $("hp-oo-expand-all"); - if (!btn) return; - const on = !!state.ooStrikeExpandAll; - btn.classList.toggle("active", on); - btn.classList.toggle("is-selected", on); - btn.setAttribute("aria-pressed", on ? "true" : "false"); - } - - function calcStrikeAskLeverage(strike, ask) { - const k = Number(strike); - const a = Number(ask); - if (!Number.isFinite(k) || k <= 0 || !Number.isFinite(a) || a <= 0) return "—"; - return (Math.round((k / a) * 10) / 10).toFixed(1) + "×"; - } - - /** 永期期权行情杠杆:指数÷卖一(与选约杠杆门一致). */ - function calcIndexAskLeverage(ask) { - const idx = indexPx(); - const a = Number(ask); - if (!(idx > 0) || !(a > 0)) return "—"; - return (Math.round((idx / a) * 10) / 10).toFixed(1) + "×"; - } - - function clearPoSelection() { - state.selected = null; - if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; - updatePremiumLine(); - updatePerpPnlHint(); - } - - /** 以期权为主:按类型/间隔自动匹配最近合约. */ - function autoMatchPoOption(force) { - if (!isOptionPrimary()) return; - const want = optTypeForDirection(getDirection()); - const sel = state.selected; - const stillOk = - !force && - sel && - String(sel.opt_type || "").toUpperCase() === want && - matchesMoneyFilter(sel); - if (stillOk) return; - const c = pickClosestItmAtm(currentContracts("hp-exp-select"), want); - if (c) pickContract(c); - else clearPoSelection(); - } - - function findAtmStrikeFromRows(rows, idx) { - if (!rows.length) return null; - if (idx == null || Number.isNaN(Number(idx))) return rows[0].strike; - let best = rows[0].strike; - let bestDist = Math.abs(Number(rows[0].strike) - Number(idx)); - rows.forEach(function (row) { - const d = Math.abs(Number(row.strike) - Number(idx)); - if (d < bestDist || (d === bestDist && Number(row.strike) < Number(best))) { - bestDist = d; - best = row.strike; - } - }); - return best; - } - - function sliceOoTRows(prepared, idx) { - // prepared: [{strike, call, put, callOk, putOk}] - if (state.ooStrikeExpandAll || !prepared.length) return prepared; - const n = 3; - const anchor = Number(idx) || Number(findAtmStrikeFromRows(prepared, idx)) || 0; - function nearest(list, count) { - return list - .slice() - .sort(function (a, b) { - return Math.abs(Number(a.strike) - anchor) - Math.abs(Number(b.strike) - anchor); - }) - .slice(0, count); - } - const byStrike = {}; - nearest( - prepared.filter(function (r) { - return r.callOk; - }), - n - ).forEach(function (r) { - byStrike[String(r.strike)] = r; - }); - nearest( - prepared.filter(function (r) { - return r.putOk; - }), - n - ).forEach(function (r) { - byStrike[String(r.strike)] = r; - }); - // 已选用腿始终保留,避免折叠后看不到选中项 - [state.legA, state.legB].forEach(function (leg) { - if (!leg) return; - const hit = prepared.find(function (r) { - return ( - (r.call && r.call.inst_id === leg.inst_id) || - (r.put && r.put.inst_id === leg.inst_id) - ); - }); - if (hit) byStrike[String(hit.strike)] = hit; - }); - return Object.keys(byStrike) - .map(function (k) { - return byStrike[k]; - }) - .sort(function (a, b) { - return Number(a.strike) - Number(b.strike); - }); - } - - function syncTabUI() { - let tab = state.tab || pickDefaultTab(); - if (tab === "perp_options" && !showPerp) tab = pickDefaultTab(); - if (tab === "options_options" && !showOo) tab = pickDefaultTab(); - state.tab = tab; - document.querySelectorAll(".hp-tab").forEach(function (b) { - const on = b.getAttribute("data-tab") === tab; - b.classList.toggle("active", on); - b.setAttribute("aria-selected", on ? "true" : "false"); - }); - ["perp_options", "options_options", "active", "history", "stats"].forEach(function (id) { - const panel = $("hp-tab-" + id); - if (!panel) return; - const on = id === tab; - panel.classList.toggle("hidden", !on); - if (on) panel.removeAttribute("hidden"); - else panel.setAttribute("hidden", ""); - }); - if (tab === "perp_options" || tab === "options_options") { - state.mode = tab; - } - const hint = $("hp-acct-hint"); - if (hint) { - if (tab === "options_options") { - hint.textContent = "期期双腿→期权账户"; - } else if (tab === "perp_options") { - hint.textContent = "永续腿→合约账户 · 期权腿→期权账户"; - } else { - hint.textContent = "进行中/历史含永期与期期;显示开关只影响新建测算 Tab"; - } - } - } - - function setGateLine(gates) { - const el = $("hp-gate-line"); - if (!el) return; - if (!gates) { - el.textContent = ""; - return; - } - const parts = [ - "计仓:" + (gates.is_full_margin ? "全仓" : "非全仓"), - "测算:" + (gates.can_preview ? "可" : "否"), - "开仓:" + (gates.can_start ? "可" : "否"), - ]; - if (gates.reasons && gates.reasons.length) parts.push(gates.reasons.join("; ")); - el.textContent = parts.join(" · "); - state.canStart = !!gates.can_start; - syncPreviewStartBtn(); - } - - function syncPreviewStartBtn() { - const start = $("hp-preview-start"); - if (!start) return; - start.disabled = !(state.previewOk && state.canStart); - } - - function openPreviewModal() { - const modal = $("hp-preview-modal"); - if (modal) modal.hidden = false; - } - - function closePreviewModal() { - const modal = $("hp-preview-modal"); - if (modal) modal.hidden = true; - } - - function applyBudgetBuffer(raw) { - if (raw == null || raw === "") return; - const buf = Number(raw); - if (Number.isNaN(buf) || buf <= 0) return; - state.budgetBuffer = buf; - const el = $("hp-oo-buf-ratio"); - if (el) el.textContent = fmt(buf, 2); - } - - function setOptionsBalance(chain) { - const acct = (chain && chain.options_account) || {}; - const label = (chain && chain.account_label) || acct.label || "期权账户"; - const tag = $("hp-opt-acct-tag"); - if (tag) tag.textContent = label; - if (acct.trading_usdc != null && acct.trading_usdc !== "") { - state.tradingUsdc = Number(acct.trading_usdc); - } - if (acct.funding_usdc != null && acct.funding_usdc !== "") { - state.fundingUsdc = Number(acct.funding_usdc); - } - if (chain && chain.trade_budget_usdc != null && chain.trade_budget_usdc !== "") { - state.tradeBudgetUsdc = Number(chain.trade_budget_usdc); - } - if (chain && chain.budget_buffer != null && chain.budget_buffer !== "") { - applyBudgetBuffer(chain.budget_buffer); - } - const line = - label + - " · 交易 USDC " + - fmt(acct.trading_usdc, 2) + - " · 资金 USDC " + - fmt(acct.funding_usdc, 2); - const el = $("hp-opt-bal-line"); - if (el) el.textContent = line; - const fundEl = $("hp-oo-funding-usdc"); - const tradeEl = $("hp-oo-trading-usdc"); - if (fundEl) fundEl.textContent = fmt(acct.funding_usdc, 2); - if (tradeEl) tradeEl.textContent = fmt(acct.trading_usdc, 2); - autoFillOoSheets(); - } - - function setOoXferMsg(text, kind) { - const el = $("hp-oo-xfer-msg"); - if (!el) return; - el.textContent = text || ""; - el.classList.toggle("is-err", kind === "err"); - el.classList.toggle("is-ok", kind === "ok"); - el.classList.toggle("muted", !kind); - } - - function ooXferDir() { - const sel = $("hp-oo-xfer-dir"); - return (sel && sel.value) || "funding_to_trading"; - } - - async function submitOoUsdcTransfer(amount) { - const dir = ooXferDir(); - const from = dir === "trading_to_funding" ? "trading" : "funding"; - const to = dir === "trading_to_funding" ? "funding" : "trading"; - setOoXferMsg("划转中…", null); - try { - const d = await apiJson("/api/options/transfer", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ccy: "USDC", from: from, to: to, amount: amount }), - }); - if (!d.ok) { - setOoXferMsg(d.msg || "划转失败", "err"); - return; - } - setOoXferMsg("划转成功", "ok"); - if ($("hp-oo-xfer-amount")) $("hp-oo-xfer-amount").value = ""; - await loadChain(); - } catch (e) { - setOoXferMsg(e.message || "划转失败", "err"); - } - } - - function resolveOoBudget() { - const buf = state.budgetBuffer > 0 ? state.budgetBuffer : 0.95; - const trading = state.tradingUsdc; - const cap = state.tradeBudgetUsdc; - let tradingCap = null; - let tradeCap = null; - if (trading != null && !Number.isNaN(Number(trading))) { - tradingCap = Math.max(0, Number(trading) * buf); - } - if (cap != null && !Number.isNaN(Number(cap))) { - tradeCap = Math.max(0, Number(cap)); - } - if (tradingCap == null && tradeCap == null) { - return { ok: false, budget: 0, tradingCap: null, tradeCap: null, buf: buf, msg: "缺少交易户余额与单笔预算" }; - } - let budget = 0; - if (tradingCap == null) budget = tradeCap; - else if (tradeCap == null) budget = tradingCap; - else budget = Math.min(tradingCap, tradeCap); - budget = Math.floor(budget * 1e6 + 1e-12) / 1e6; - return { - ok: budget > 0, - budget: budget, - tradingCap: tradingCap, - tradeCap: tradeCap, - buf: buf, - msg: budget > 0 ? "" : "可用预算为 0", - }; - } - - function unitCost(c) { - if (!c) return 0; - const ask = Number(c.ask || 0); - if (!(ask > 0)) return 0; - return ask * Number(c.ct_mult || 0.01); - } - - function capByDepth(n, askSz) { - let out = Math.max(0, Math.floor(Number(n) || 0)); - if (askSz == null || askSz === "") return out; - const d = Number(askSz); - if (Number.isNaN(d)) return out; - if (d <= 0) return 0; - return Math.min(out, Math.floor(d + 1e-12)); - } - - function normalizeOptCP(ot) { - const u = String(ot || "").toUpperCase(); - if (u.indexOf("C") === 0) return "C"; - if (u.indexOf("P") === 0) return "P"; - return ""; - } - - function ooSizeModeLabel(mode) { - if (mode === "long_bias") return "做多"; - if (mode === "short_bias") return "做空"; - if (mode === "split_budget") return "均分"; - return "同张数"; - } - - function suggestOoSheetsLocal(budget, mode) { - const costA = unitCost(state.legA); - const costB = unitCost(state.legB); - if (!(budget > 0)) { - return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "可用预算为 0" }; - } - if (!(costA > 0) || !(costB > 0)) { - return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "缺少有效卖一价,无法建议张数" }; - } - let ratio = Number(state.ooBiasRatio); - if (!(ratio > 0) || !(ratio < 1)) ratio = 0.7; - const splitBy = state.ooBiasSplitBy === "sheets" ? "sheets" : "budget"; - const pair = costA + costB; - const nPair = pair > 0 ? Math.floor(budget / pair + 1e-12) : 0; - const nSame = Math.min( - capByDepth(nPair, state.legA && state.legA.ask_sz), - capByDepth(nPair, state.legB && state.legB.ask_sz) - ); - - let nA = 0; - let nB = 0; - if (mode === "long_bias" || mode === "short_bias") { - const aCP = normalizeOptCP(state.legA && state.legA.opt_type); - const bCP = normalizeOptCP(state.legB && state.legB.opt_type); - if (!((aCP === "C" && bCP === "P") || (aCP === "P" && bCP === "C"))) { - return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "做多/做空需一腿 Call、一腿 Put" }; - } - const callIsA = aCP === "C"; - const majorIsCall = mode === "long_bias"; - let nCall = 0; - let nPut = 0; - if (splitBy === "sheets") { - // 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆 - const total = nSame * 2; - if (total < 2) { - return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "同张数总规模不足 2,无法按比例拆分" }; - } - let majorN = Math.round(total * ratio); - majorN = Math.max(1, Math.min(majorN, total - 1)); - const minorN = total - majorN; - nCall = majorIsCall ? majorN : minorN; - nPut = majorIsCall ? minorN : majorN; - } else { - const majBudget = budget * ratio; - const minBudget = budget * (1 - ratio); - const costCall = callIsA ? costA : costB; - const costPut = callIsA ? costB : costA; - if (majorIsCall) { - nCall = Math.floor(majBudget / costCall + 1e-12); - nPut = Math.floor(minBudget / costPut + 1e-12); - } else { - nPut = Math.floor(majBudget / costPut + 1e-12); - nCall = Math.floor(minBudget / costCall + 1e-12); - } - } - nA = callIsA ? nCall : nPut; - nB = callIsA ? nPut : nCall; - nA = capByDepth(nA, state.legA && state.legA.ask_sz); - nB = capByDepth(nB, state.legB && state.legB.ask_sz); - } else if (mode === "split_budget") { - const half = budget / 2; - nA = Math.floor(half / costA + 1e-12); - nB = Math.floor(half / costB + 1e-12); - nA = capByDepth(nA, state.legA && state.legA.ask_sz); - nB = capByDepth(nB, state.legB && state.legB.ask_sz); - } else { - nA = nSame; - nB = nSame; - } - const premium = costA * nA + costB * nB; - const ok = nA >= 1 && nB >= 1; - return { - sheetsA: nA, - sheetsB: nB, - premium: premium, - ok: ok, - msg: ok ? "" : "预算不够开 1+1(或卖一深度不足)", - }; - } - - function syncOoSizeModeUI() { - document.querySelectorAll(".hp-oo-size-mode").forEach(function (b) { - const on = b.getAttribute("data-oo-size") === state.ooSheetsMode; - b.classList.toggle("active", on); - b.classList.toggle("is-selected", on); - b.setAttribute("aria-pressed", on ? "true" : "false"); - }); - } - - function syncOoCloseModeUI() { - const enabled = !!state.ooCloseModeEnabled; - const row = $("hp-oo-close-mode-row"); - if (row) row.classList.toggle("hidden", !enabled); - if (!enabled) state.ooCloseMode = "hold_expiry"; - document.querySelectorAll(".hp-oo-close-mode").forEach(function (b) { - const on = b.getAttribute("data-oo-close") === state.ooCloseMode; - b.classList.toggle("active", on); - b.classList.toggle("is-selected", on); - b.setAttribute("aria-pressed", on ? "true" : "false"); - }); - } - - function updateOoBudgetLine(extra) { - const line = $("hp-oo-budget-line"); - if (!line) return; - const b = resolveOoBudget(); - const sizeLabel = ooSizeModeLabel(state.ooSheetsMode); - const closeLabel = !state.ooCloseModeEnabled - ? "" - : state.ooCloseMode === "hold_expiry" - ? "到期平" - : "全平"; - const parts = ["可用 " + fmt(b.budget, 2) + "U", sizeLabel]; - if (closeLabel) parts.push(closeLabel); - if (extra && extra.msg) parts.push(extra.msg); - else if (b.msg) parts.push(b.msg); - line.textContent = parts.join(" · "); - line.title = - "对冲预算=min(交易×" + - fmt(b.buf, 2) + - ", 单笔) · 缓冲 HEDGE_PLAN_BUDGET_BUFFER" + - (b.tradingCap != null ? " · 交易×缓冲 " + fmt(b.tradingCap, 2) : "") + - (b.tradeCap != null ? " · 单笔 " + fmt(b.tradeCap, 2) : ""); - line.classList.toggle("hp-oo-budget-warn", !!(extra && extra.msg) || !b.ok); - } - - function autoFillOoSheets() { - syncOoSizeModeUI(); - if (!state.legA || !state.legB) { - updateOoBudgetLine(null); - return; - } - const b = resolveOoBudget(); - const sug = suggestOoSheetsLocal(b.budget, state.ooSheetsMode); - const a = $("hp-oo-sheets-a"); - const bb = $("hp-oo-sheets-b"); - if (a && !a.disabled) a.value = String(sug.sheetsA); - if (bb && !bb.disabled) bb.value = String(sug.sheetsB); - updateOoBudgetLine(sug.ok ? null : sug); - updateOoPremiumLine(); - } - - async function loadGates() { - try { - const d = await apiJson("/api/hedge-plan/gates?plan_type=" + encodeURIComponent(state.mode)); - if (d.oo_close_mode_enabled != null) { - state.ooCloseModeEnabled = !!d.oo_close_mode_enabled; - } - if (!state.ooCloseModeEnabled) { - state.ooCloseMode = "hold_expiry"; - } else if (d.oo_close_mode_default && !state._ooCloseModeTouched) { - state.ooCloseMode = d.oo_close_mode_default === "hold_expiry" ? "hold_expiry" : "close_all"; - } - if (d.oo_bias_split_by != null) { - state.ooBiasSplitBy = d.oo_bias_split_by === "sheets" ? "sheets" : "budget"; - } - if (d.oo_bias_ratio != null && Number(d.oo_bias_ratio) > 0 && Number(d.oo_bias_ratio) < 1) { - state.ooBiasRatio = Number(d.oo_bias_ratio); - } - if (d.budget_buffer != null) applyBudgetBuffer(d.budget_buffer); - syncOoCloseModeUI(); - setGateLine(d); - if (state.mode === "options_options") autoFillOoSheets(); - } catch (e) { - setGateLine({ can_preview: false, can_start: false, reasons: [e.message], is_full_margin: false }); - } - } - - async function loadMarket() { - const dir = getDirection(); - const d = await apiJson( - "/api/hedge-plan/market?base=" + - encodeURIComponent(state.underlying) + - "&direction=" + - encodeURIComponent(dir) + - "&option_primary=" + - (isOptionPrimary() ? "1" : "0") - ); - state.market = d; - setGateLine(d.gates); - const acctLabel = d.account_label || "合约账户"; - const tag = $("hp-perp-acct-tag"); - if (tag) tag.textContent = acctLabel; - const amtPrec = d.amount_precision != null ? Number(d.amount_precision) : 4; - const markEl = $("hp-po-mark"); - if (markEl) markEl.textContent = "标记 " + fmt(d.mark, 2); - const quoteHtml = - "可用 " + - fmt(d.available_usdt, 2) + - " USDT · 卖一 " + - fmt(d.ask, 2) + - " · 买一 " + - fmt(d.bid, 2) + - " · 面值 " + - fmt(d.contract_size, 4) + - " · 精度 " + - amtPrec + - " 位" + - (d.perp_direction - ? " · 永续方向 " + (d.perp_direction === "short" ? "空" : "多") - : ""); - const q = $("hp-perp-quote"); - if (q) q.innerHTML = quoteHtml; - const contractsInput = $("hp-contracts"); - if (contractsInput) { - const step = amtPrec <= 0 ? "1" : String(Math.pow(10, -amtPrec)); - contractsInput.step = step; - } - const sz = $("hp-sizing-line"); - if (sz) { - if (isOptionPrimary()) { - const sized = - state.selected && - computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); - if (sized) { - sz.innerHTML = - "执行预算 " + - fmt(sized.usable, 2) + - " · 期权 ETH " + - fmt(sized.eth_qty, 2) + - " / " + - sized.sheets + - " 张 · 永续 " + - fmt(sized.contracts, amtPrec) + - " 张"; - } else { - sz.textContent = "填写权利金并选用期权后显示定仓(权利金×0.95,ETH两位小数)"; - } - } else if (d.full_margin_sizing) { - const s = d.full_margin_sizing; - sz.innerHTML = - "全仓建议 " + - fmt(d.suggest_contracts, amtPrec) + - " 张 · 保证金 " + - fmt(s.margin_capital, 2) + - " × " + - s.leverage + - "x · 名义 " + - fmt(s.notional_value, 2) + - " USDT"; - } else { - sz.textContent = "非全仓时请手动填张数;永期开仓需全仓"; - } - } - const entry = $("hp-entry"); - if (!isOptionPrimary() && entry && d.entry_ref && !entry.value) entry.value = d.entry_ref; - if (!isOptionPrimary() && contractsInput && d.suggest_contracts != null && !contractsInput.value) { - contractsInput.value = fmt(d.suggest_contracts, amtPrec); - } - const label = $("hp-opt-type-label"); - if (label) label.textContent = d.suggested_opt_type === "C" ? "Call" : "Put"; - updatePerpPnlHint(); - } - - function updatePerpPnlHint() { - const el = $("hp-perp-pnl-line"); - if (!el) return; - if (isOptionPrimary()) { - const n = numInput("hp-opt-target-pts", NaN); - const m = numInput("hp-perp-target-pts", NaN); - const k = state.selected && Number(state.selected.strike); - if (!(k > 0) || (!(n >= 0) && !(m >= 0))) { - el.innerHTML = '选用期权并填目标点数后显示 K±N 出场参考'; - return; - } - const dir = getDirection(); - const optT = n >= 0 ? (dir === "short" ? k - n : k + n) : null; - const perpT = m >= 0 ? (dir === "short" ? k - m : k + m) : null; - el.innerHTML = - "期权目标指数 " + - (optT != null ? fmt(optT, 2) : "—") + - " · 永续目标指数 " + - (perpT != null ? fmt(perpT, 2) : "—") + - ' (相对K;期权目标需买一且扣费净利>0)'; - return; - } - const entry = Number(($("hp-entry") && $("hp-entry").value) || NaN); - const tp = Number(($("hp-tp") && $("hp-tp").value) || NaN); - const sl = Number(($("hp-sl") && $("hp-sl").value) || NaN); - const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || NaN); - const cs = Number((state.market && state.market.contract_size) || 0.01); - const dir = getDirection(); - if (!(entry > 0) || !(contracts > 0) || !(cs > 0)) { - el.innerHTML = '填开仓价与张数后,输入止盈/止损可看盈亏'; - return; - } - function pnlAt(exitPx) { - const coins = contracts * cs; - if (dir === "short") return (entry - exitPx) * coins; - return (exitPx - entry) * coins; - } - function chip(lab, px) { - if (!(px > 0)) { - return '' + lab + " —"; - } - const p = pnlAt(px); - const cls = p >= 0 ? "hp-pnl-pos" : "hp-pnl-neg"; - const sign = p >= 0 ? "+" : ""; - return ( - '' + - lab + - ' ' + - sign + - fmt(p, 2) + - "" - ); - } - el.innerHTML = chip("止盈", tp) + chip("止损", sl); - } - - function fillExpSelect(sel, chain) { - if (!sel) return; - const prev = sel.value; - const isPoSel = sel.id === "hp-exp-select"; - // 永期以期权为主:到期下拉按「最低剩余小时」过滤;期期下拉不过滤,保证明天到期可见 - const minH = isPoSel && isOptionPrimary() ? numInput("hp-min-hours", 36) : 0; - sel.innerHTML = ''; - let firstOk = null; - let skippedNear = 0; - (chain.expiries || []).forEach(function (e) { - const h = hoursFromExpMs(e.exp_time); - if (minH > 0 && h != null && h < minH) { - skippedNear += 1; - return; - } - const opt = document.createElement("option"); - opt.value = String(e.exp_time); - const dt = new Date(Number(e.exp_time) < 1e12 ? Number(e.exp_time) * 1000 : Number(e.exp_time)); - opt.textContent = dt.toLocaleString() + (h != null ? " · " + fmt(h, 1) + "h" : ""); - sel.appendChild(opt); - if (!firstOk) firstOk = e; - }); - if (prev) sel.value = prev; - if (!sel.value && firstOk) { - sel.value = String(firstOk.exp_time); - } - if (isPoSel && skippedNear > 0 && minH > 0) { - const tip = document.createElement("option"); - tip.disabled = true; - tip.textContent = "(已隐藏 " + skippedNear + " 个不足 " + minH + "h 的到期 · 可改左侧最低剩余小时)"; - sel.appendChild(tip); - } - } - - async function loadChain() { - // 拉完整链(按 CHAIN_MAX_DTE);永期「最低剩余小时」只在左侧到期下拉里过滤,不影响期期看到明天到期 - const url = - "/api/hedge-plan/options-chain?underlying=" + encodeURIComponent(state.underlying); - const d = await apiJson(url); - state.chain = d; - const idx = $("hp-index-line"); - if (idx) idx.textContent = "指数 " + fmt(d.index_px, 2); - const ooIdx = $("hp-oo-index"); - if (ooIdx) ooIdx.textContent = "指数 " + fmt(d.index_px, 2); - setOptionsBalance(d); - fillExpSelect($("hp-exp-select"), d); - fillExpSelect($("hp-oo-exp-select"), d); - renderListStrikes(); - renderTStrikes(); - if (d.index_px) { - // 盈亏比默认2,不随指数自动改写 - if ($("hp-profit-rr") && !$("hp-profit-rr").value) { - $("hp-profit-rr").value = "2"; - } - } - } - - function currentExp(selectId) { - const sel = $(selectId); - const expMs = sel && sel.value; - if (!expMs || !state.chain) return null; - return (state.chain.expiries || []).find(function (e) { - return String(e.exp_time) === String(expMs); - }); - } - - function pickContract(c) { - if (!c) return; - const m = (c.moneyness || "").toLowerCase(); - if (!isOptionPrimary() && m === "otm") { - alert("永期保险腿须为实值或平值,不可选虚值"); - return; - } - if (isOptionPrimary() && !matchesMoneyFilter(c)) { - alert("不符合当前间隔/虚实值/杠杆门槛"); - return; - } - state.selected = c; - const el = $("hp-sel-inst"); - if (el) el.textContent = c.inst_id; - const tbody = $("hp-strike-tbody"); - if (tbody) { - tbody.querySelectorAll(".opt-strike-row").forEach(function (r) { - r.classList.toggle("opt-row-selected", r.getAttribute("data-inst") === c.inst_id); - }); - tbody.querySelectorAll(".hp-pick").forEach(function (b) { - b.classList.toggle("active", b.getAttribute("data-inst") === c.inst_id); - }); - } - updatePremiumLine(); - if (isOptionPrimary()) { - const sized = computeOpSizing(c.ask, c.ct_mult || 0.01); - if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets); - void loadMarket(); - updatePerpPnlHint(); - } - } - - function renderListStrikes() { - const tbody = $("hp-strike-tbody"); - if (!tbody) return; - const want = optTypeForDirection(getDirection()); - const exp = currentExp("hp-exp-select"); - const prevInst = state.selected && state.selected.inst_id; - tbody.innerHTML = ""; - if (!exp) { - tbody.innerHTML = '请选择到期日'; - if (isOptionPrimary()) clearPoSelection(); - return; - } - const sameType = (exp.contracts || []).filter(function (c) { - return String(c.opt_type || "").toUpperCase() === want; - }); - const list = sameType.filter(matchesMoneyFilter); - if (!list.length) { - const idx = indexPx(); - const interval = isOptionPrimary() ? numInput("hp-strike-interval", 15) : null; - let hint = "无匹配合约"; - if (isOptionPrimary() && sameType.length) { - hint = - "无匹配" + - (want === "C" ? "Call" : "Put") + - "(已滤 " + - sameType.length + - " 档)·检查间隔" + - (interval != null ? "≤" + interval : "") + - "点/虚实值/杠杆≥" + - optionPrimaryMinLev(); - } else if (!sameType.length) { - hint = - "该到期无 " + - (want === "C" ? "Call" : "Put") + - (idx ? " · 指数 " + fmt(idx, 2) : "") + - " · 可换到期或点刷新链"; - } - tbody.innerHTML = '' + hint + ""; - if (isOptionPrimary()) clearPoSelection(); - return; - } - list.forEach(function (c) { - const tr = document.createElement("tr"); - tr.className = "opt-strike-row" + (c.moneyness ? " opt-row-" + c.moneyness : ""); - if (prevInst && c.inst_id === prevInst) tr.classList.add("opt-row-selected"); - tr.setAttribute("data-inst", c.inst_id); - tr.innerHTML = - "" + - c.strike + - "" + - moneynessBadge(c) + - '' + - calcIndexAskLeverage(c.ask) + - '' + - fmtPxSz(c.ask, c.ask_sz, c.ask_estimated, c.tick_sz) + - '' + - fmtPxSz(c.bid, c.bid_sz, false, c.tick_sz) + - ''; - tbody.appendChild(tr); - }); - tbody.querySelectorAll(".hp-pick").forEach(function (btn) { - btn.addEventListener("click", function () { - const inst = btn.getAttribute("data-inst"); - const c = list.find(function (x) { - return x.inst_id === inst; - }); - pickContract(c); - }); - }); - autoMatchPoOption(false); - } - - function updatePremiumLine() { - const line = $("hp-premium-line"); - if (!line || !state.selected) { - if (line) line.textContent = ""; - return; - } - const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); - const ct = Number(state.selected.ct_mult || 0.01); - const ask = Number(state.selected.ask || 0); - const prem = ask * sheets * ct; - line.textContent = "预估权利金 ≈ " + fmt(prem, 4) + " USDC(期权账户)"; - } - - function buildStraddleRows(contracts) { - const map = {}; - (contracts || []).forEach(function (c) { - const key = String(c.strike); - if (!map[key]) map[key] = { strike: c.strike, call: null, put: null }; - const o = (c.opt_type || "").toUpperCase(); - if (o === "C") map[key].call = c; - else if (o === "P") map[key].put = c; - }); - return Object.keys(map) - .map(function (k) { - return map[k]; - }) - .sort(function (a, b) { - return Number(a.strike) - Number(b.strike); - }); - } - - function selectedOoLegMap() { - const map = {}; - if (state.legA && state.legA.inst_id) map[String(state.legA.inst_id)] = "A"; - if (state.legB && state.legB.inst_id) map[String(state.legB.inst_id)] = "B"; - return map; - } - - function syncOoPickHighlight() { - const tbody = $("hp-oo-tbody"); - if (!tbody) return; - const map = selectedOoLegMap(); - tbody.querySelectorAll(".hp-oo-pick").forEach(function (btn) { - const inst = String(btn.getAttribute("data-inst") || ""); - const leg = map[inst]; - const on = !!leg; - btn.classList.toggle("is-selected", on); - btn.classList.toggle("active", on); - btn.setAttribute("aria-pressed", on ? "true" : "false"); - const side = String(btn.getAttribute("data-side") || "").toUpperCase(); - const base = side === "P" ? "Put" : "Call"; - btn.textContent = on ? "腿" + leg + " · " + base : base; - btn.title = on ? "已选用为腿" + leg + "(再点其他合约可改选)" : "选用此 " + base; - }); - tbody.querySelectorAll("tr").forEach(function (tr) { - const callBtn = tr.querySelector('.hp-oo-pick[data-side="C"]'); - const putBtn = tr.querySelector('.hp-oo-pick[data-side="P"]'); - const callOn = !!(callBtn && map[String(callBtn.getAttribute("data-inst") || "")]); - const putOn = !!(putBtn && map[String(putBtn.getAttribute("data-inst") || "")]); - tr.classList.toggle("hp-oo-row-selected", callOn || putOn); - tr.querySelectorAll("td.opt-t-call").forEach(function (td) { - td.classList.toggle("hp-oo-side-selected", callOn); - }); - if (callBtn && callBtn.parentElement) { - callBtn.parentElement.classList.toggle("hp-oo-side-selected", callOn); - } - tr.querySelectorAll("td.opt-t-put").forEach(function (td) { - td.classList.toggle("hp-oo-side-selected", putOn); - }); - if (putBtn && putBtn.parentElement) { - putBtn.parentElement.classList.toggle("hp-oo-side-selected", putOn); - } - }); - } - - function renderTStrikes() { - const tbody = $("hp-oo-tbody"); - if (!tbody) return; - const exp = currentExp("hp-oo-exp-select"); - const cols = 9; - tbody.innerHTML = ""; - if (!exp) { - tbody.innerHTML = '请选择到期日'; - return; - } - const prepared = []; - buildStraddleRows(exp.contracts).forEach(function (row) { - const callOk = !!(row.call && matchesOoMoneyFilter(row.call)); - const putOk = !!(row.put && matchesOoMoneyFilter(row.put)); - if (!callOk && !putOk) return; - prepared.push({ - strike: row.strike, - call: row.call, - put: row.put, - callOk: callOk, - putOk: putOk, - }); - }); - const rows = sliceOoTRows(prepared, indexPx()); - const selected = selectedOoLegMap(); - if (!rows.length) { - tbody.innerHTML = '该筛选下暂无平值/虚值合约'; - return; - } - rows.forEach(function (row) { - const tr = document.createElement("tr"); - const call = row.call; - const put = row.put; - const callOk = row.callOk; - const putOk = row.putOk; - const callAsk = callOk ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated, call.tick_sz) : "—"; - const putAsk = putOk ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated, put.tick_sz) : "—"; - const callLev = callOk ? calcStrikeAskLeverage(row.strike, call.ask) : "—"; - const putLev = putOk ? calcStrikeAskLeverage(row.strike, put.ask) : "—"; - const callLeg = callOk ? selected[String(call.inst_id)] : ""; - const putLeg = putOk ? selected[String(put.inst_id)] : ""; - tr.innerHTML = - '' + - callAsk + - '' + - callLev + - '' + - (callOk ? moneynessBadge(call) : "—") + - "" + - (callOk - ? '" - : "—") + - '' + - row.strike + - '' + - (putOk ? moneynessBadge(put) : "—") + - '' + - putAsk + - '' + - putLev + - "" + - (putOk - ? '" - : "—") + - ""; - tbody.appendChild(tr); - }); - if (!state.ooStrikeExpandAll && prepared.length > rows.length) { - const hint = document.createElement("tr"); - hint.className = "opt-strike-hint-row"; - hint.innerHTML = - '默认显示 Call/Put 各最近 3 档 · 点「显示全部」查看该到期更多行权价(若当前为「仅平值」会自动切到「平/虚」)'; - tbody.appendChild(hint); - } else if (state.ooStrikeExpandAll && state.ooMoneyFilter === "otm") { - const hint = document.createElement("tr"); - hint.className = "opt-strike-hint-row"; - hint.innerHTML = - '当前为「仅虚值」筛选 · 切到「平/虚」可看平值档'; - tbody.appendChild(hint); - } - tbody.querySelectorAll(".hp-oo-pick").forEach(function (btn) { - btn.addEventListener("click", function () { - const inst = btn.getAttribute("data-inst"); - const exp2 = currentExp("hp-oo-exp-select"); - const c = (exp2.contracts || []).find(function (x) { - return x.inst_id === inst; - }); - if (!c || !matchesOoMoneyFilter(c)) { - alert("期期仅可选平值或虚值,不可选实值"); - return; - } - if (!state.legA) state.legA = c; - else if (!state.legB || state.legB.inst_id === state.legA.inst_id) state.legB = c; - else { - state.legA = c; - state.legB = null; - } - state.ooRecommend = null; - syncOoRecommendUI(); - renderOoLegs(); - }); - }); - syncOoPickHighlight(); - } - - function renderOoLegs() { - function fill(tag, c, infoId, sheetsId) { - const info = $(infoId); - const sheets = $(sheetsId); - if (!info) return; - if (!c) { - info.textContent = tag + ": 尚未选用"; - if (sheets) { - sheets.disabled = true; - sheets.value = "1"; - } - return; - } - info.innerHTML = - tag + - ": " + - c.opt_type + - " K" + - c.strike + - " " + - (c.moneyness_label || "") + - " · 卖一 " + - fmtPxSz(c.ask, c.ask_sz, c.ask_estimated, c.tick_sz) + - " " + - c.inst_id + - ""; - if (sheets) sheets.disabled = false; - } - fill("腿A", state.legA, "hp-oo-leg-a-info", "hp-oo-sheets-a"); - fill("腿B", state.legB, "hp-oo-leg-b-info", "hp-oo-sheets-b"); - autoFillOoSheets(); - syncOoPickHighlight(); - } - - function ooSheets(id) { - const n = Number(($(id) && $(id).value) || 1); - return n > 0 ? n : 1; - } - - function updateOoPremiumLine() { - const line = $("hp-oo-prem-line"); - if (!line) return; - if (!state.legA && !state.legB) { - line.textContent = ""; - return; - } - function prem(c, sheets) { - if (!c) return 0; - return Number(c.ask || 0) * sheets * Number(c.ct_mult || 0.01); - } - const a = prem(state.legA, ooSheets("hp-oo-sheets-a")); - const b = prem(state.legB, ooSheets("hp-oo-sheets-b")); - line.textContent = - "预估权利金 A " + - fmt(a, 4) + - " + B " + - fmt(b, 4) + - " ≈ " + - fmt(a + b, 4) + - " USDC"; - } - - function legPayload(c, sheets) { - return { - opt_type: c.opt_type, - strike: c.strike, - sheets: sheets, - ct_mult: c.ct_mult || 0.01, - ask: c.ask, - inst_id: c.inst_id, - }; - } - - function setUnderlying(uly, forceReload) { - const next = (uly || "ETH").toUpperCase(); - const changed = next !== state.underlying; - state.underlying = next; - syncUnderlyingUI(); - if (!changed && !forceReload) return; - state.selected = null; - state.legA = null; - state.legB = null; - state.ooRecommend = null; - syncOoRecommendUI(); - if ($("hp-entry")) $("hp-entry").value = ""; - if ($("hp-contracts")) $("hp-contracts").value = ""; - if ($("hp-tp")) $("hp-tp").value = ""; - if ($("hp-sl")) $("hp-sl").value = ""; - if ($("hp-profit-rr")) $("hp-profit-rr").value = "2"; - if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; - if ($("hp-premium-line")) $("hp-premium-line").textContent = ""; - if ($("hp-oo-sheets-a")) { - $("hp-oo-sheets-a").value = "1"; - $("hp-oo-sheets-a").disabled = true; - } - if ($("hp-oo-sheets-b")) { - $("hp-oo-sheets-b").value = "1"; - $("hp-oo-sheets-b").disabled = true; - } - renderOoLegs(); - void refreshAll(); - } - - async function runPreview() { - const isOo = state.mode === "options_options"; - const tbody = $("hp-result-tbody"); - const summary = $("hp-preview-summary"); - const midTh = $("hp-preview-mid-th"); - const title = $("hp-preview-title"); - state.previewOk = false; - state.previewPlanType = isOo ? "options_options" : "perp_options"; - syncPreviewStartBtn(); - if (midTh) midTh.textContent = isOo ? "腿盈亏" : "永续/腿盈亏"; - if (title) title.textContent = isOo ? "情景测算 · 期期" : "情景测算 · 永期"; - if (summary) summary.textContent = ""; - if (tbody) tbody.innerHTML = '计算中…'; - openPreviewModal(); - try { - let body; - if (isOo) { - if (!state.legA || !state.legB) throw new Error("请选用两条期权腿"); - if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { - throw new Error("期期两腿须为平值或虚值,不可选实值"); - } - const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0); - if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)"); - body = { - plan_type: "options_options", - profit_rr: rr, - index_px: indexPx() || 0, - leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), - leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), - }; - } else { - if (!state.selected) throw new Error("请选用期权腿"); - if (isOptionPrimary()) { - const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); - if (!sized) throw new Error("请填写权利金并确认卖一有效"); - const optPts = numInput("hp-opt-target-pts", NaN); - const perpPts = numInput("hp-perp-target-pts", NaN); - if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)"); - const exp = currentExp("hp-exp-select"); - body = { - plan_type: "perp_options", - option_primary: true, - direction: getDirection(), - entry: indexPx() || Number((state.market && state.market.mark) || 0), - contracts: sized.contracts, - sheets: sized.sheets, - contract_size: (state.market && state.market.contract_size) || 0.01, - opt_type: state.selected.opt_type, - strike: state.selected.strike, - ct_mult: state.selected.ct_mult || 0.01, - ask: state.selected.ask, - index_px: indexPx() || 0, - premium_budget: numInput("hp-premium-budget", 0), - option_perp_ratio: numInput("hp-opt-perp-ratio", 2), - option_target_points: optPts, - perp_target_points: perpPts, - strike_interval: numInput("hp-strike-interval", 15), - min_option_hours: numInput("hp-min-hours", 36), - option_leverage: numInput("hp-opt-leverage", 100), - leverage: numInput("hp-perp-leverage", 100), - moneyness: opMoneyKind(), - hours_to_expiry: exp ? hoursFromExpMs(exp.exp_time) : null, - }; - } else { - const mSel = (state.selected.moneyness || "").toLowerCase(); - if (mSel === "otm") throw new Error("永期保险腿须为实值或平值,不可选虚值"); - const entry = Number(($("hp-entry") && $("hp-entry").value) || 0); - const tp = Number(($("hp-tp") && $("hp-tp").value) || 0); - const sl = Number(($("hp-sl") && $("hp-sl").value) || 0); - const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0); - const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); - if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数"); - body = { - plan_type: "perp_options", - direction: getDirection(), - entry: entry, - tp: tp, - sl: sl, - contracts: contracts, - contract_size: (state.market && state.market.contract_size) || 0.01, - opt_type: state.selected.opt_type, - strike: state.selected.strike, - sheets: sheets, - ct_mult: state.selected.ct_mult || 0.01, - ask: state.selected.ask, - index_px: indexPx() || entry, - }; - } - } - const d = await apiJson("/api/hedge-plan/preview", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - setGateLine(d.gates); - const s = d.summary || {}; - if (summary) { - if (d.plan_type === "perp_options" && (d.option_primary || s.opt_target_total != null)) { - const sz = d.sizing || {}; - summary.innerHTML = - "期权目标净利 " + - fmtPnlHtml(s.opt_target_total) + - " · 永续目标净利 " + - fmtPnlHtml(s.perp_target_total) + - " · 保费 " + - fmt(s.premium_paid) + - (sz.eth_qty != null ? " · ETH " + fmt(sz.eth_qty, 2) : "") + - (s.perp_direction ? " · 永续" + (s.perp_direction === "short" ? "空" : "多") : ""); - } else if (d.plan_type === "perp_options") { - summary.innerHTML = - "止盈合计 " + - fmtPnlHtml(s.tp_total) + - " · 止损合计 " + - fmtPnlHtml(s.sl_total) + - " · 保费 " + - fmt(s.premium_paid) + - (s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : ""); - } else { - const rrTarget = s.profit_rr != null ? s.profit_rr : null; - let rrLine = ""; - if (rrTarget != null) { - rrLine = - " · 目标盈亏比 " + - fmt(rrTarget, 2) + - '(盈利金额/总权利金)'; - } else if (s.rr_at_up != null || s.rr_at_down != null) { - rrLine = - " · 盈亏比 上破 " + - fmtRr(s.rr_at_up) + - (s.at_target_down_total != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") + - '(亏=全额保费 ' + - fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) + - ")"; - } - const aTot = s.at_rr_a_full_total != null ? s.at_rr_a_full_total : s.at_target_up_total; - const bTot = s.at_rr_b_full_total != null ? s.at_rr_b_full_total : s.at_target_down_total; - summary.innerHTML = - (rrTarget != null ? "腿A达标 " : "上破 ") + - fmtPnlHtml(aTot) + - (bTot != null ? (rrTarget != null ? " · 腿B达标 " : " · 下破 ") + fmtPnlHtml(bTot) : "") + - " · 到期现价 " + - fmtPnlHtml(s.expiry_flat_total) + - " · 保费 " + - fmt(s.premium_paid) + - rrLine + - (s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : ""); - } - } - if (!tbody) return; - tbody.innerHTML = ""; - (d.scenarios || []).forEach(function (sc) { - const tr = document.createElement("tr"); - let mid; - if (sc.perp_pnl != null) { - mid = "永续 " + fmtPnlHtml(sc.perp_pnl); - } else { - mid = "A " + fmtPnlHtml(sc.leg_a_pnl) + " / B " + fmtPnlHtml(sc.leg_b_pnl); - } - const optCol = sc.options_pnl != null ? fmtPnlHtml(sc.options_pnl) : "—"; - tr.innerHTML = - "" + - (sc.label || sc.id) + - "" + - fmt(sc.spot) + - "" + - mid + - "" + - optCol + - "" + - fmtPnlHtml(sc.total) + - "" + - (sc.note || "") + - ""; - tbody.appendChild(tr); - }); - state.previewOk = true; - syncPreviewStartBtn(); - } catch (e) { - state.previewOk = false; - syncPreviewStartBtn(); - if (tbody) tbody.innerHTML = '' + (e.message || e) + ""; - if (summary) summary.textContent = ""; - } - } - - function hardenAmountAutofill(ids) { - (ids || []).forEach(function (id) { - const el = $(id); - if (!el) return; - function wipe() { - const v = String(el.value || "").trim(); - // 浏览器常把登录用户名(如 dekun)灌进数量框 - if (/^[a-z][a-z0-9._-]{1,31}$/i.test(v)) el.value = ""; - } - wipe(); - const lockRo = id.indexOf("xfer") >= 0 || el.hasAttribute("readonly"); - if (lockRo) { - el.setAttribute("readonly", "readonly"); - el.addEventListener("focus", function () { - el.removeAttribute("readonly"); - }); - el.addEventListener("blur", function () { - if (!el.value) el.setAttribute("readonly", "readonly"); - }); - } - setTimeout(wipe, 200); - setTimeout(wipe, 800); - setTimeout(wipe, 2000); - }); - } - - function bind() { - document.querySelectorAll(".hp-tab").forEach(function (b) { - b.addEventListener("click", function () { - const next = b.getAttribute("data-tab") || pickDefaultTab(); - if (next === "perp_options" && !showPerp) return; - if (next === "options_options" && !showOo) return; - state.tab = next; - syncTabUI(); - if (state.tab === "perp_options" || state.tab === "options_options") { - void loadGates(); - if (state.tab === "perp_options") void refreshPoStrategyStatus(); - } else if (state.tab === "active") { - void loadActivePlans(); - } else if (state.tab === "history") { - void loadHistory(); - } else if (state.tab === "stats") { - void loadStats(); - } else { - const el = $("hp-gate-line"); - if (el) el.textContent = ""; - } - }); - }); - document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) { - b.addEventListener("click", function () { - setUnderlying(b.getAttribute("data-uly") || "ETH", true); - }); - }); - document.querySelectorAll(".hp-money-btn").forEach(function (b) { - b.addEventListener("click", function () { - const m = b.getAttribute("data-money") || "itm"; - if (m === "otm" && !isOptionPrimary()) { - alert("永期保险腿仅允许实值或平值;请在 env 将 HEDGE_PLAN_OPTION_PRIMARY=true"); - return; - } - state.moneyFilter = m === "otm" ? "otm" : m === "atm" ? "atm" : "itm"; - if (isOptionPrimary()) applyOpDefaultsFromMoney(false); - syncMoneyUI(); - renderListStrikes(); - }); - }); - if ($("hp-money-select")) { - $("hp-money-select").addEventListener("change", function () { - const m = $("hp-money-select").value || "otm"; - state.moneyFilter = m === "otm" ? "otm" : m === "atm" ? "atm" : "itm"; - state.opLevTouched = false; - state.opRatioTouched = false; - applyOpDefaultsFromMoney(true); - syncMoneyUI(); - renderListStrikes(); - }); - } - if ($("hp-opt-leverage")) { - $("hp-opt-leverage").addEventListener("input", function () { - state.opLevTouched = true; - if (isOptionPrimary()) renderListStrikes(); - }); - } - if ($("hp-opt-perp-ratio")) { - $("hp-opt-perp-ratio").addEventListener("input", function () { - state.opRatioTouched = true; - if (state.selected) { - const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); - if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets); - } - void loadMarket(); - }); - } - let _poChainReloadTimer = null; - function schedulePoChainReload() { - if (_poChainReloadTimer) clearTimeout(_poChainReloadTimer); - _poChainReloadTimer = setTimeout(function () { - _poChainReloadTimer = null; - void loadChain(); - }, 350); - } - ["hp-premium-budget", "hp-strike-interval", "hp-min-hours", "hp-opt-target-pts", "hp-perp-target-pts"].forEach( - function (id) { - const el = $(id); - if (!el) return; - el.addEventListener("input", function () { - if (id === "hp-min-hours" || id === "hp-strike-interval") { - schedulePoChainReload(); - } - if (id === "hp-premium-budget") renderListStrikes(); - if (state.selected && id === "hp-premium-budget") { - const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); - if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets); - void loadMarket(); - } - updatePerpPnlHint(); - }); - } - ); - document.querySelectorAll(".hp-oo-money-btn").forEach(function (b) { - b.addEventListener("click", function () { - const m = b.getAttribute("data-oo-money") || "atm_otm"; - if (m === "itm") { - alert("期期两腿仅允许平值或虚值"); - return; - } - state.ooMoneyFilter = m; - syncMoneyUI(); - renderTStrikes(); - }); - }); - if ($("hp-recommend-opt")) { - $("hp-recommend-opt").addEventListener("click", function () { - if (isOptionPrimary()) { - autoMatchPoOption(true); - if (!state.selected) { - alert("当前选约条件下无匹配合约,请调整类型/间隔或换到期"); - } - return; - } - const want = optTypeForDirection(getDirection()); - const c = pickClosestItmAtm(currentContracts("hp-exp-select"), want); - if (!c) { - alert("当前到期日无可用实值/平值合约,请换到期或刷新链"); - return; - } - pickContract(c); - }); - } - if ($("hp-oo-recommend-atm")) { - $("hp-oo-recommend-atm").addEventListener("click", function () { - const pair = pickOoTemplate("atm_straddle"); - if (!pair) { - alert("无法推荐平值跨式,请确认到期日与链数据"); - return; - } - state.legA = pair.call; - state.legB = pair.put; - state.ooRecommend = "atm_straddle"; - syncOoRecommendUI(); - renderOoLegs(); - autoFillOoSheets(); - updateOoPremiumLine(); - }); - } - if ($("hp-oo-recommend-otm")) { - $("hp-oo-recommend-otm").addEventListener("click", function () { - const pair = pickOoTemplate("double_otm"); - if (!pair) { - alert("无法推荐双虚值,请确认到期日与链数据"); - return; - } - state.legA = pair.call; - state.legB = pair.put; - state.ooRecommend = "double_otm"; - syncOoRecommendUI(); - renderOoLegs(); - autoFillOoSheets(); - updateOoPremiumLine(); - }); - } - document.querySelectorAll(".hp-po-dir").forEach(function (b) { - b.addEventListener("click", function () { - setDirection(b.getAttribute("data-dir") || "long", true); - }); - }); - ["hp-entry", "hp-tp", "hp-sl", "hp-contracts"].forEach(function (id) { - const el = $(id); - if (el) el.addEventListener("input", updatePerpPnlHint); - }); - syncOptionPrimaryUI(); - if ($("hp-refresh")) - $("hp-refresh").addEventListener("click", function () { - void refreshAll(); - }); - if ($("hp-load-chain")) - $("hp-load-chain").addEventListener("click", function () { - void loadChain(); - }); - if ($("hp-oo-load-chain")) - $("hp-oo-load-chain").addEventListener("click", function () { - void loadChain(); - }); - if ($("hp-oo-expand-all")) { - $("hp-oo-expand-all").addEventListener("click", function () { - state.ooStrikeExpandAll = !state.ooStrikeExpandAll; - // 「仅平值」时本来就只有 1~3 档,展开几乎无变化;展开时自动切到平/虚以便看到全部可选档 - if (state.ooStrikeExpandAll && state.ooMoneyFilter === "atm") { - state.ooMoneyFilter = "atm_otm"; - syncMoneyUI(); - } - syncOoExpandUI(); - renderTStrikes(); - }); - } - if ($("hp-exp-select")) $("hp-exp-select").addEventListener("change", renderListStrikes); - if ($("hp-oo-exp-select")) $("hp-oo-exp-select").addEventListener("change", renderTStrikes); - if ($("hp-sheets")) $("hp-sheets").addEventListener("input", updatePremiumLine); - ["hp-oo-sheets-a", "hp-oo-sheets-b"].forEach(function (id) { - const el = $(id); - if (el) el.addEventListener("input", updateOoPremiumLine); - }); - document.querySelectorAll(".hp-oo-size-mode").forEach(function (b) { - b.addEventListener("click", function () { - state.ooSheetsMode = b.getAttribute("data-oo-size") || "same_sheets"; - syncOoSizeModeUI(); - autoFillOoSheets(); - updateOoBudgetLine(null); - }); - }); - document.querySelectorAll(".hp-oo-close-mode").forEach(function (b) { - b.addEventListener("click", function () { - if (!state.ooCloseModeEnabled) return; - state.ooCloseMode = b.getAttribute("data-oo-close") || "close_all"; - state._ooCloseModeTouched = true; - syncOoCloseModeUI(); - updateOoBudgetLine(null); - }); - }); - if ($("hp-oo-xfer-btn")) { - $("hp-oo-xfer-btn").addEventListener("click", function () { - const amount = Number(($("hp-oo-xfer-amount") && $("hp-oo-xfer-amount").value) || 0); - if (!(amount > 0)) { - setOoXferMsg("请输入有效数量", "err"); - return; - } - void submitOoUsdcTransfer(amount); - }); - } - if ($("hp-oo-xfer-all")) { - $("hp-oo-xfer-all").addEventListener("click", function () { - const dir = ooXferDir(); - const max = - dir === "trading_to_funding" ? Number(state.tradingUsdc || 0) : Number(state.fundingUsdc || 0); - if (!(max > 0)) { - setOoXferMsg("划出账户可用余额不足", "err"); - return; - } - const amt = Math.floor(max * 100) / 100; - const label = dir === "trading_to_funding" ? "交易 → 资金" : "资金 → 交易"; - if (!window.confirm("确认全部划转?\n方向:" + label + "\n数量:" + amt + " USDC")) return; - const amtEl = $("hp-oo-xfer-amount"); - if (amtEl) { - amtEl.removeAttribute("readonly"); - amtEl.value = String(amt); - } - void submitOoUsdcTransfer(amt); - }); - } - hardenAmountAutofill([ - "hp-oo-xfer-amount", - "hp-entry", - "hp-contracts", - "hp-tp", - "hp-sl", - "hp-sheets", - "hp-profit-rr", - ]); - if ($("hp-preview-btn")) - $("hp-preview-btn").addEventListener("click", function () { - state.mode = "perp_options"; - if (isOptionPrimary()) void startOptionPrimaryWatch(); - else void runPreview(); - }); - if ($("hp-preview-btn-oo")) - $("hp-preview-btn-oo").addEventListener("click", function () { - state.mode = "options_options"; - void runPreview(); - }); - if ($("hp-preview-cancel")) $("hp-preview-cancel").addEventListener("click", closePreviewModal); - if ($("hp-preview-cancel-x")) $("hp-preview-cancel-x").addEventListener("click", closePreviewModal); - if ($("hp-preview-start")) - $("hp-preview-start").addEventListener("click", function () { - const planType = state.previewPlanType || state.mode; - void startPlan(planType, true); - }); - const previewModal = $("hp-preview-modal"); - if (previewModal) { - previewModal.addEventListener("click", function (ev) { - if (ev.target === previewModal) closePreviewModal(); - }); - } - if ($("hp-detail-close")) $("hp-detail-close").addEventListener("click", closeModal); - const modal = $("hp-detail-modal"); - if (modal) { - modal.addEventListener("click", function (ev) { - if (ev.target === modal) closeModal(); - }); - } - } - - async function loadHistory() { - const tbody = $("hp-history-tbody"); - if (!tbody) return; - try { - const d = await apiJson("/api/hedge-plan/history"); - const rows = d.plans || []; - if (!rows.length) { - tbody.innerHTML = '暂无已结束计划'; - return; - } - tbody.innerHTML = ""; - rows.forEach(function (p) { - const tr = document.createElement("tr"); - const typeLabel = p.plan_type === "perp_options" ? "永期" : "期期"; - const contracts = p.contracts_summary || "—"; - const pnl = p.realized_pnl_total; - const pnlCls = - pnl == null || Number.isNaN(Number(pnl)) ? "" : Number(pnl) >= 0 ? "hp-pnl-pos" : "hp-pnl-neg"; - tr.innerHTML = - "#" + - p.id + - "" + - typeLabel + - "" + - (p.underlying || "") + - "" + - contracts + - "" + - (p.status || "") + - '' + - fmt(pnl) + - "" + - reasonLabel(p.close_reason) + - "" + - (p.opened_at || "—") + - "" + - (p.closed_at || "—") + - '' + - ' ' + - '' + - ""; - tbody.appendChild(tr); - }); - tbody.querySelectorAll(".hp-btn-detail").forEach(function (btn) { - btn.addEventListener("click", function () { - void showPlanDetail(Number(btn.getAttribute("data-id"))); - }); - }); - tbody.querySelectorAll(".hp-btn-del").forEach(function (btn) { - btn.addEventListener("click", function () { - void deletePlan(Number(btn.getAttribute("data-id"))); - }); - }); - } catch (e) { - tbody.innerHTML = '' + (e.message || e) + ""; - } - } - - function activeTargetLabel(p) { - if (p.plan_type === "perp_options" && (p.option_primary == 1 || p.option_primary === true || Number(p.option_primary) === 1)) { - return ( - "期权K±" + - fmt(p.option_target_points, 0) + - " · 永续K±" + - fmt(p.perp_target_points, 0) - ); - } - if (p.plan_type === "perp_options") { - return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl); - } - if (p.profit_rr != null && Number(p.profit_rr) > 0) { - return "盈亏比 " + fmt(p.profit_rr, 2); - } - return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price); - } - - function activeStatusLabel(p) { - if ((p.status || "") === "watching") { - return '盯盘中'; - } - if ((p.status || "") === "partial") { - return '半腿待补'; - } - if ((p.status || "") === "opening") { - return '开仓中'; - } - return '进行中'; - } - - function completeLegButtonHtml(p) { - if ((p.status || "") !== "partial") return ""; - const role = p.missing_leg || ""; - let label = ""; - if (role === "perp") label = "补开永续"; - else if (role === "option_b") label = "补开腿B"; - else if (role === "option_hedge" || role === "option_a") label = "补开期权"; - else return ""; - return ( - ' " - ); - } - - async function completeMissingLeg(planId) { - if (!window.confirm("确认补开缺失腿并真实下单?")) return; - try { - const d = await apiJson("/api/hedge-plan/" + planId + "/complete-leg", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - alert("补开成功 #" + (d.plan_id || planId) + " · 已完全成交并进入进行中"); - void loadActivePlans(); - void loadGates(); - } catch (e) { - alert(e.message || String(e)); - } - } - - async function endActivePlan(planId) { - if ( - !window.confirm( - "确认结束计划 #" + - planId + - "?\n不会自动平仓;未成交/待补腿将标为未成交取消。\n已有持仓请自行平掉。" - ) - ) { - return; - } - try { - const d = await apiJson("/api/hedge-plan/" + planId + "/end", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - alert(d.msg || "计划已结束 #" + (d.plan_id || planId)); - void loadActivePlans(); - void loadHistory(); - void loadGates(); - void refreshPoStrategyStatus(); - } catch (e) { - alert(e.message || String(e)); - } - } - - function legStatusLabel(st) { - const map = { - open: "持仓中", - pending: "待补/未成交", - cancelled: "未成交取消", - canceled: "未成交取消", - closed: "已平仓", - }; - return map[st] || st || "—"; - } - - async function loadActivePlans() { - const tbody = $("hp-active-tbody"); - if (!tbody) return; - try { - const d = await apiJson("/api/hedge-plan/active"); - const rows = d.plans || []; - if (!rows.length) { - tbody.innerHTML = '暂无进行中的计划'; - return; - } - tbody.innerHTML = ""; - rows.forEach(function (p) { - const tr = document.createElement("tr"); - const typeLabel = p.plan_type === "perp_options" ? "永期" : "期期"; - const contracts = p.contracts_summary || "—"; - tr.innerHTML = - "#" + - p.id + - "" + - typeLabel + - "" + - (p.underlying || "") + - "" + - contracts + - "" + - activeStatusLabel(p) + - "" + - activeTargetLabel(p) + - "" + - (p.opened_at || "—") + - '' + - completeLegButtonHtml(p) + - ' ' + - ''; - tbody.appendChild(tr); - }); - tbody.querySelectorAll(".hp-btn-detail").forEach(function (btn) { - btn.addEventListener("click", function () { - void showPlanDetail(Number(btn.getAttribute("data-id"))); - }); - }); - tbody.querySelectorAll(".hp-btn-complete").forEach(function (btn) { - btn.addEventListener("click", function () { - void completeMissingLeg(Number(btn.getAttribute("data-id"))); - }); - }); - tbody.querySelectorAll(".hp-btn-end").forEach(function (btn) { - btn.addEventListener("click", function () { - void endActivePlan(Number(btn.getAttribute("data-id"))); - }); - }); - } catch (e) { - tbody.innerHTML = '' + (e.message || e) + ""; - } - } - - function reasonLabel(r) { - const map = { - perp_tp: "永续止盈", - perp_sl: "永续止损", - oo_expiry_loss: "期期到期亏损", - oo_expiry_win: "期期到期盈利", - target_win_leg: "期期平盈利腿", - target_up_win_leg: "期期上破·平盈利腿", - target_down_win_leg: "期期下破·平盈利腿", - profit_rr_win_leg: "期期盈亏比达标·平盈利腿", - oo_rest_closing: "期期残值平·清亏损腿中", - oo_rest_closed: "期期残值平·两腿已平", - orphaned_after_tp: "止盈后持有至到期", - orphaned_option_expiry: "残腿到期", - hold_to_expiry: "持有至到期", - expiry: "到期", - manual: "人工结束", - manual_end: "人工结束", - partial_fail: "半腿失败", - cancelled: "已取消", - unfilled: "未成交", - }; - return map[r] || r || "—"; - } - - function roleLabel(role) { - const map = { - perp: "永续腿", - option_hedge: "保险期权", - option_a: "期期腿A", - option_b: "期期腿B", - }; - return map[role] || role || "—"; - } - - function closeModal() { - const m = $("hp-detail-modal"); - if (m) m.hidden = true; - } - - async function showPlanDetail(planId) { - const modal = $("hp-detail-modal"); - const body = $("hp-detail-body"); - const title = $("hp-detail-title"); - if (!modal || !body) return; - modal.hidden = false; - body.innerHTML = '

加载中…

'; - if (title) title.textContent = "成交细节 #" + planId; - try { - const d = await apiJson("/api/hedge-plan/" + planId); - const p = d.plan || {}; - const legs = d.legs || []; - const typeLabel = p.plan_type === "perp_options" ? "永期对冲" : "期期对冲"; - let html = ""; - html += '
'; - html += "
类型 " + typeLabel + "
"; - html += "
标的 " + (p.underlying || "—"); - if (p.direction) html += " · " + (p.direction === "long" ? "做多" : "做空"); - html += "
"; - html += "
状态 " + (p.status || "—") + " / " + reasonLabel(p.close_reason) + "
"; - html += "
时间 " + (p.opened_at || "—") + " → " + (p.closed_at || "—") + "
"; - html += - "
盈亏 永续 " + - fmt(p.realized_pnl_perp) + - " · 期权 " + - fmt(p.realized_pnl_options) + - " · 合计 = 0 ? "hp-pnl-pos" : "hp-pnl-neg") + - '">' + - fmt(p.realized_pnl_total) + - " ≈U
"; - if (p.plan_type === "perp_options") { - html += - "
参考价 开 " + - fmt(p.entry_mark) + - " · 止盈 " + - fmt(p.tp) + - " · 止损 " + - fmt(p.sl) + - " · 杠杆 " + - fmt(p.leverage, 0) + - "x · 张数 " + - fmt(p.perp_size, 4) + - "
"; - } else { - if (p.profit_rr != null && Number(p.profit_rr) > 0) { - html += - "
盈亏比 " + - fmt(p.profit_rr, 2) + - " (盈利金额/总权利金)
"; - } else { - html += - "
目标价 上破 " + - fmt(p.target_price_up || p.target_price) + - " · 下破 " + - fmt(p.target_price_down || p.target_price) + - "
"; - } - } - html += - "
权利金合计 " + - fmt(p.premium_total, 4) + - " USDC
"; - html += - "
合约摘要 " + - (d.contracts_summary || "—") + - "
"; - html += "
"; - html += ''; - html += - ""; - html += ""; - if (!legs.length) { - html += ''; - } else { - legs.forEach(function (leg) { - const contract = - leg.leg_role === "perp" - ? leg.symbol || "—" - : leg.inst_id || "—"; - const side = - leg.leg_role === "perp" - ? leg.side || "—" - : (leg.opt_type || "") + (leg.strike != null ? " K" + fmt(leg.strike, 0) : ""); - html += ""; - html += ""; - html += ""; - html += ""; - html += ""; - html += ""; - html += ""; - html += ""; - html += ""; - html += ""; - html += ""; - html += ""; - }); - } - html += "
角色合约名称方向/类型数量开仓价权利金状态腿盈亏成交号平仓原因
无腿记录
" + roleLabel(leg.leg_role) + "" + contract + "" + side + "" + fmt(leg.size, leg.leg_role === "perp" ? 4 : 0) + "" + fmt(leg.avg_open, 4) + "" + (leg.premium != null ? fmt(leg.premium, 4) : "—") + "" + legStatusLabel(leg.status) + "" + fmt(leg.realized_pnl, 4) + "" + (leg.exchange_ord_id || "—") + "" + reasonLabel(leg.close_reason) + "
"; - if (p.note) { - html += '

备注 ' + String(p.note) + "

"; - } - body.innerHTML = html; - } catch (e) { - body.innerHTML = '

' + (e.message || e) + "

"; - } - } - - async function deletePlan(planId) { - if (!window.confirm("确认删除历史计划 #" + planId + "?此操作不可恢复。")) return; - try { - await apiJson("/api/hedge-plan/" + planId, { method: "DELETE" }); - await loadHistory(); - if (state.tab === "stats") await loadStats(); - } catch (e) { - window.alert(e.message || String(e)); - } - } - - function metricCard(title, m) { - if (!m || !m.count) { - return ( - '

' + - title + - '

暂无已结束样本

' - ); - } - const wr = m.win_rate == null ? "—" : (Number(m.win_rate) * 100).toFixed(1) + "%"; - let pf = "—"; - if (m.profit_factor_infinite) pf = "∞"; - else if (m.profit_factor != null) pf = fmt(m.profit_factor, 2); - return ( - '

' + - title + - "

    " + - "
  • 笔数" + - m.count + - "
  • " + - "
  • 胜率" + - wr + - " (" + - m.wins + - "/" + - m.count + - ")
  • " + - "
  • 净盈亏≈U= 0 ? "hp-pnl-pos" : "hp-pnl-neg") + - '">' + - fmt(m.net_pnl) + - "
  • " + - "
  • 盈亏比" + - pf + - " 毛利/|毛亏|
  • " + - "
  • 最大盈利" + - fmt(m.max_profit) + - "
  • " + - "
  • 最大亏损" + - fmt(m.max_loss) + - "
  • " + - "
  • 最大回撤" + - fmt(m.max_drawdown) + - "
  • " + - "
  • 平均保费" + - fmt(m.avg_premium, 4) + - "
  • " + - "
" - ); - } - - async function loadStats() { - const box = $("hp-stats-box"); - if (!box) return; - try { - const d = await apiJson("/api/hedge-plan/stats"); - const by = d.by_type || {}; - let html = '
'; - html += - '

总览

活跃 ' + - (d.active || 0) + - " · 已结 " + - (d.closed_count || 0) + - ' · 合计 ' + - fmt(d.closed_pnl_total) + - " ≈U

"; - html += metricCard("永期对冲", by.perp_options); - html += metricCard("期期对冲", by.options_options); - html += "
"; - const poB = (by.perp_options && by.perp_options.buckets) || {}; - const ooB = (by.options_options && by.options_options.buckets) || {}; - if ((poB.tp && poB.tp.count) || (poB.sl && poB.sl.count) || (ooB.expiry_loss && ooB.expiry_loss.count)) { - html += '
'; - if (poB.tp && poB.tp.count) html += metricCard("永期·止盈桶", poB.tp); - if (poB.sl && poB.sl.count) html += metricCard("永期·止损桶", poB.sl); - if (ooB.expiry_loss && ooB.expiry_loss.count) html += metricCard("期期·到期亏损", ooB.expiry_loss); - if (ooB.expiry_win && ooB.expiry_win.count) html += metricCard("期期·到期盈利", ooB.expiry_win); - html += "
"; - } - box.innerHTML = html; - } catch (e) { - box.textContent = e.message || String(e); - } - } - - async function startOptionPrimaryWatch() { - try { - const optPts = numInput("hp-opt-target-pts", NaN); - const perpPts = numInput("hp-perp-target-pts", NaN); - const prem = numInput("hp-premium-budget", 0); - const optLev = numInput("hp-opt-leverage", 200); - if (!(prem > 0)) throw new Error("请填写权利金预算"); - if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)"); - if (!(optLev > 0)) throw new Error("请填写期权杠杆门槛"); - if (!(state.market && state.market.exchange_symbol)) throw new Error("永续行情未就绪,请先刷新"); - if (!state.canStart) { - throw new Error("当前不可启动(门禁未满足),请查看上方提示"); - } - const msg = - "确认启动盯盘?\n" + - "类型 " + - (opMoneyKind() === "otm" ? "虚值" : opMoneyKind() === "atm" ? "平值" : "实/平") + - " · 间隔 " + - numInput("hp-strike-interval", 15) + - " · 杠杆≥" + - optionPrimaryMinLev() + - "\n达标后自动开仓(非现场立即开)"; - if (!window.confirm(msg)) return; - const body = { - plan_type: "perp_options", - option_primary: true, - watch_entry: 1, - underlying: state.underlying, - direction: getDirection(), - exchange_symbol: state.market.exchange_symbol, - contract_size: state.market.contract_size || 0.01, - index_px: indexPx() || Number(state.market.mark || 0), - entry: indexPx() || Number(state.market.mark || 0), - premium_budget: prem, - option_perp_ratio: numInput("hp-opt-perp-ratio", 4), - option_target_points: optPts, - perp_target_points: perpPts, - strike_interval: numInput("hp-strike-interval", 15), - min_option_hours: numInput("hp-min-hours", 36), - option_leverage: optLev, - leverage: numInput("hp-perp-leverage", 100), - moneyness: opMoneyKind(), - }; - const d = await apiJson("/api/hedge-plan/start", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - setGateLine(d.gates); - setPoStrategyStatus("watching", d.plan_id || null); - alert((d.msg || "已启动盯盘") + (d.plan_id ? "\n计划 #" + d.plan_id : "")); - void refreshPoStrategyStatus(); - void loadGates(); - } catch (e) { - alert(e.message || String(e)); - } - } - - async function startPlan(planType, fromPreviewModal) { - const isOo = planType === "options_options"; - const startBtn = $("hp-preview-start"); - try { - let body; - if (isOo) { - if (!state.legA || !state.legB) throw new Error("请选用两条期权腿"); - if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { - throw new Error("期期两腿须为平值或虚值,不可选实值"); - } - const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0); - if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)"); - body = { - plan_type: "options_options", - underlying: state.underlying, - profit_rr: rr, - index_px: indexPx() || 0, - oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry", - oo_sheets_mode: state.ooSheetsMode || "same_sheets", - leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), - leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), - }; - } else { - if (!state.selected) throw new Error("请选用期权腿"); - if (isOptionPrimary()) { - const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); - if (!sized) throw new Error("请填写权利金并确认卖一有效"); - const optPts = numInput("hp-opt-target-pts", NaN); - const perpPts = numInput("hp-perp-target-pts", NaN); - if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)"); - const exp = currentExp("hp-exp-select"); - const entry = indexPx() || Number((state.market && state.market.mark) || 0); - body = { - plan_type: "perp_options", - option_primary: true, - underlying: state.underlying, - direction: getDirection(), - entry: entry, - contracts: sized.contracts, - sheets: sized.sheets, - contract_size: (state.market && state.market.contract_size) || 0.01, - ct_mult: state.selected.ct_mult || 0.01, - opt_inst_id: state.selected.inst_id, - opt_type: state.selected.opt_type, - strike: state.selected.strike, - ask: state.selected.ask, - index_px: entry, - exchange_symbol: (state.market && state.market.exchange_symbol) || "", - leverage: numInput("hp-perp-leverage", 100), - option_leverage: numInput("hp-opt-leverage", 100), - premium_budget: numInput("hp-premium-budget", 0), - option_perp_ratio: numInput("hp-opt-perp-ratio", 2), - option_target_points: optPts, - perp_target_points: perpPts, - strike_interval: numInput("hp-strike-interval", 15), - min_option_hours: numInput("hp-min-hours", 36), - moneyness: opMoneyKind(), - hours_to_expiry: exp ? hoursFromExpMs(exp.exp_time) : null, - }; - } else { - const m = (state.selected.moneyness || "").toLowerCase(); - if (m === "otm") throw new Error("永期保险腿须为实值或平值,不可选虚值"); - const entry = Number(($("hp-entry") && $("hp-entry").value) || 0); - const tp = Number(($("hp-tp") && $("hp-tp").value) || 0); - const sl = Number(($("hp-sl") && $("hp-sl").value) || 0); - const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0); - const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); - if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数"); - body = { - plan_type: "perp_options", - underlying: state.underlying, - direction: getDirection(), - entry: entry, - tp: tp, - sl: sl, - contracts: contracts, - sheets: sheets, - opt_inst_id: state.selected.inst_id, - opt_type: state.selected.opt_type, - strike: state.selected.strike, - ask: state.selected.ask, - index_px: indexPx() || entry, - exchange_symbol: (state.market && state.market.exchange_symbol) || "", - leverage: 10, - margin: state.market && state.market.full_margin_sizing && state.market.full_margin_sizing.margin_capital, - }; - } - } - if (!fromPreviewModal) { - if (!window.confirm("确认启动对冲计划并真实下单?\n(将按期权账户/合约账户分别下单)")) return; - } - if (startBtn) startBtn.disabled = true; - const d = await apiJson("/api/hedge-plan/start", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - setGateLine(d.gates); - closePreviewModal(); - const refreshHint = - d.refresh && d.refresh.msg ? "\n" + String(d.refresh.msg) : ""; - if (d.partial) { - alert( - (d.msg || "半腿失败,已挂待补") + - (d.plan_id ? "\n计划 #" + d.plan_id : "") + - refreshHint + - "\n请到「进行中的计划」补开缺失腿" - ); - state.tab = "active"; - syncTabUI(); - void loadActivePlans(); - } else { - alert( - "计划已启动 #" + - (d.plan_id || "") + - (d.dry_run ? " (dry_run)" : "") + - refreshHint - ); - } - void loadGates(); - if (isOo) void loadChain(); - } catch (e) { - alert(e.message || String(e)); - syncPreviewStartBtn(); - } - } - - async function refreshAll() { - if (state.tab === "perp_options" || state.tab === "options_options") { - await loadGates(); - } - try { - await loadMarket(); - } catch (e) { - const q = $("hp-perp-quote"); - if (q) q.textContent = e.message || String(e); - } - try { - await loadChain(); - } catch (e) { - const tbody = $("hp-strike-tbody"); - if (tbody) tbody.innerHTML = '' + (e.message || e) + ""; - } - if (state.tab === "perp_options") void refreshPoStrategyStatus(); - } - - syncTabUI(); - syncUnderlyingUI(); - syncMoneyUI(); - syncOoRecommendUI(); - syncOoExpandUI(); - syncPoDirUI(); - syncOoSizeModeUI(); - syncOoCloseModeUI(); - applyBudgetBuffer(state.budgetBuffer); - updateOoBudgetLine(null); - bind(); - syncOptionPrimaryUI(); - void refreshAll().then(function () { - void refreshPoStrategyStatus(); - }); - setInterval(function () { - if (state.tab === "perp_options" && isOptionPrimary()) void refreshPoStrategyStatus(); - }, 15000); -})(); diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index efc994d..37764a5 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -307,11 +307,11 @@ def _effective_env_value(key: str, file_values: dict[str, str], schema_default: # 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式 file_val = str(file_values.get(key) or "").strip() if key in file_values else "" if file_val: - from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode + from lib.options.okx_trade_mode_lib import normalize_okx_trade_mode return normalize_okx_trade_mode(file_val) or file_val try: - from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode + from lib.options.okx_trade_mode_lib import get_okx_trade_mode return get_okx_trade_mode() except Exception: @@ -398,7 +398,7 @@ def _build_field( def _okx_mode_for_env_ui() -> str: try: - from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode + from lib.options.okx_trade_mode_lib import get_okx_trade_mode return get_okx_trade_mode() except Exception: @@ -431,7 +431,7 @@ def ui_sections_for_exchange( sections.append({"title": "交易所与实盘", "fields": live_fields}) sections.extend(_SHARED_SECTIONS) if ex in _MODE_SECTION.get("exchanges", frozenset()): - from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode + from lib.options.okx_trade_mode_lib import normalize_okx_trade_mode m = normalize_okx_trade_mode(mode) if mode else "" if not m: diff --git a/lib/hedge_plan/__init__.py b/lib/hedge_plan/__init__.py deleted file mode 100644 index 300c2bd..0000000 --- a/lib/hedge_plan/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# hedge_plan package diff --git a/lib/hedge_plan/hedge_options_exclusive_lib.py b/lib/hedge_plan/hedge_options_exclusive_lib.py deleted file mode 100644 index 162af36..0000000 --- a/lib/hedge_plan/hedge_options_exclusive_lib.py +++ /dev/null @@ -1,86 +0,0 @@ -"""对冲计划与单独期权开仓互斥门控. - -默认开启:有进行中对冲计划时禁止单独开期权;有纯期权持仓时禁止启动对冲计划. -关闭 HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE 后两边可同时开. -""" -from __future__ import annotations - -import os -from typing import Any, Callable, Optional - - -def _env_bool(key: str, default: bool = False) -> bool: - v = (os.getenv(key) or "").strip().lower() - if not v: - return default - return v in ("1", "true", "yes", "on") - - -def mutual_exclusive_enabled() -> bool: - return _env_bool("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", True) - - -def block_standalone_option_open_msg(conn: Any) -> Optional[str]: - """若应拦截单独开期权,返回中文原因;否则 None.""" - if not mutual_exclusive_enabled(): - return None - try: - from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables - - init_hedge_plan_tables(conn) - if count_active_plans(conn) > 0: - return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)" - except Exception: - return "互斥门控校验失败,暂禁止单独开期权" - return None - - -def _pos_nonzero(raw: dict[str, Any]) -> bool: - try: - return abs(float(raw.get("pos") or 0)) > 1e-12 - except (TypeError, ValueError): - return False - - -def has_standalone_option_position(conn: Any, raw_positions: list[dict[str, Any]] | None) -> bool: - """交易所期权持仓中,是否存在未挂在进行中对冲计划腿上的仓位.""" - if not raw_positions: - return False - from lib.instance.instance_dashboard_lib import _resolve_options_source - - for p in raw_positions: - if not isinstance(p, dict) or not _pos_nonzero(p): - continue - inst = str(p.get("instId") or p.get("inst_id") or "").strip() - if not inst: - continue - source, _, _ = _resolve_options_source(conn, inst) - if source == "option": - return True - return False - - -def block_hedge_plan_start_msg( - conn: Any, - *, - fetch_positions: Optional[Callable[[Any], Any]] = None, - exchange: Any = None, - raw_positions: list[dict[str, Any]] | None = None, -) -> Optional[str]: - """若应拦截启动对冲计划,返回中文原因;否则 None.""" - if not mutual_exclusive_enabled(): - return None - rows = raw_positions - if rows is None: - if fetch_positions is None or exchange is None: - return None - try: - rows = fetch_positions(exchange) or [] - except Exception: - return "获取期权持仓失败,暂禁止启动对冲计划" - try: - if has_standalone_option_position(conn, rows): - return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)" - except Exception: - return "互斥门控校验失败,暂禁止启动对冲计划" - return None diff --git a/lib/hedge_plan/hedge_plan_calc_lib.py b/lib/hedge_plan/hedge_plan_calc_lib.py deleted file mode 100644 index b44cc53..0000000 --- a/lib/hedge_plan/hedge_plan_calc_lib.py +++ /dev/null @@ -1,782 +0,0 @@ -"""对冲计划:情景测算与全仓建议仓(纯函数,无 IO).""" -from __future__ import annotations - -from typing import Any, Optional - - -def _f(v: Any) -> Optional[float]: - if v is None or v == "": - return None - try: - return float(v) - except (TypeError, ValueError): - return None - - -def perp_coin_amount(*, contracts: float, contract_size: float) -> float: - return float(contracts) * float(contract_size or 1.0) - - -def perp_pnl( - *, - direction: str, - entry: float, - exit_px: float, - contracts: float, - contract_size: float, -) -> float: - coins = perp_coin_amount(contracts=contracts, contract_size=contract_size) - d = (direction or "long").strip().lower() - if d == "short": - return (float(entry) - float(exit_px)) * coins - return (float(exit_px) - float(entry)) * coins - - -def option_premium_total(*, ask: float, sheets: float, ct_mult: float) -> float: - """卖一报价为每 1 币;权利金 = ask × 张数 × ct_mult.""" - return float(ask) * float(sheets) * float(ct_mult or 0.01) - - -def option_expiry_pnl( - *, - opt_type: str, - strike: float, - spot: float, - sheets: float, - ct_mult: float, - premium_paid: float, -) -> float: - o = (opt_type or "").strip().upper() - intrinsic_per_coin = 0.0 - if o in ("C", "CALL"): - intrinsic_per_coin = max(0.0, float(spot) - float(strike)) - elif o in ("P", "PUT"): - intrinsic_per_coin = max(0.0, float(strike) - float(spot)) - else: - return -float(premium_paid) - value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01) - return value - float(premium_paid) - - -def spot_from_expiry_intrinsic_profit( - *, - opt_type: str, - strike: float, - sheets: float, - ct_mult: float, - premium_paid: float, - profit: float, -) -> float | None: - """按到期实值反推现货价:使该腿到期盈亏 ≈ profit. - - 到期价值=实值×张数×乘数;盈亏=价值−权利金 → 实值/币=(profit+权利金)/(张数×乘数). - Call: spot=K+实值/币; Put: spot=K−实值/币. - """ - try: - k = float(strike) - n = float(sheets or 0) - ct = float(ct_mult or 0.01) - prem = float(premium_paid or 0) - pnl = float(profit) - except (TypeError, ValueError): - return None - denom = n * ct - if denom <= 0: - return None - need = (pnl + prem) / denom - if need < 0: - need = 0.0 - o = (opt_type or "").strip().upper() - if o in ("C", "CALL"): - return round(k + need, 2) - if o in ("P", "PUT"): - return round(k - need, 2) - return None - - -def suggest_contracts_from_notional( - *, - notional: float, - entry: float, - contract_size: float, -) -> float: - if entry <= 0 or contract_size <= 0 or notional <= 0: - return 0.0 - return float(notional) / (float(entry) * float(contract_size)) - - -def floor_contracts_to_precision(contracts: float, decimals: int) -> float: - """按交易所张数精度向下取整,避免建议张数超过可用保证金.""" - import math - - raw = float(contracts or 0.0) - if raw <= 0: - return 0.0 - try: - d = int(decimals) - except (TypeError, ValueError): - d = 0 - if d <= 0: - return float(math.floor(raw + 1e-12)) - scale = 10**d - return math.floor(raw * scale + 1e-12) / scale - - -def option_unit_cost_usdc(*, ask: float, ct_mult: float) -> float: - """单张权利金(USDC) = 卖一价 × ct_mult.""" - a = _f(ask) - if a is None or a <= 0: - return 0.0 - return float(a) * float(ct_mult or 0.01) - - -def resolve_oo_budget_usdc( - *, - trading_usdc: Any, - trade_budget_usdc: Any, - buffer_ratio: Any = 0.95, -) -> dict[str, Any]: - """期期可用预算 = min(交易户×buffer, 单笔预算).""" - import math - - trading = _f(trading_usdc) - cap = _f(trade_budget_usdc) - buf = _f(buffer_ratio) - if buf is None or buf <= 0: - buf = 0.95 - if buf > 1: - buf = 1.0 - trading_cap = None if trading is None else max(0.0, float(trading) * float(buf)) - trade_cap = None if cap is None else max(0.0, float(cap)) - if trading_cap is None and trade_cap is None: - return { - "ok": False, - "budget_usdc": 0.0, - "trading_cap": None, - "trade_budget_cap": None, - "buffer_ratio": float(buf), - "msg": "缺少交易户余额与单笔预算", - } - if trading_cap is None: - budget = float(trade_cap or 0.0) - elif trade_cap is None: - budget = float(trading_cap) - else: - budget = min(float(trading_cap), float(trade_cap)) - budget = float(math.floor(budget * 1e6 + 1e-12) / 1e6) - return { - "ok": budget > 0, - "budget_usdc": budget, - "trading_cap": None if trading_cap is None else round(float(trading_cap), 6), - "trade_budget_cap": None if trade_cap is None else round(float(trade_cap), 6), - "buffer_ratio": float(buf), - "msg": "" if budget > 0 else "可用预算为 0", - } - - -def _cap_sheets_by_ask_depth(sheets: int, ask_sz: Any) -> int: - import math - - n = max(0, int(sheets)) - depth = _f(ask_sz) - if depth is None: - return n - if depth <= 0: - return 0 - return min(n, int(math.floor(float(depth) + 1e-12))) - - -def _normalize_oo_sheets_mode(mode: str) -> str: - m = (mode or "same_sheets").strip().lower() - if m in ("long_bias", "bias_long", "long", "做多"): - return "long_bias" - if m in ("short_bias", "bias_short", "short", "做空"): - return "short_bias" - # 旧「均分」兼容:按预算 50/50(页面已移除) - if m in ("split", "equal_budget", "split_budget", "均分"): - return "split_budget" - return "same_sheets" - - -def _normalize_oo_bias_split_by(raw: Any) -> str: - v = str(raw or "budget").strip().lower() - if v in ("sheets", "qty", "quantity", "张数"): - return "sheets" - return "budget" - - -def _clamp_oo_bias_ratio(raw: Any, default: float = 0.7) -> float: - try: - r = float(raw) - except (TypeError, ValueError): - r = float(default) - if r <= 0 or r >= 1: - r = float(default) - return r - - -def _oo_call_put_leg_index(opt_type_a: str, opt_type_b: str) -> tuple[Optional[str], Optional[str], str]: - """返回 (call_side, put_side, err);side 为 'a'/'b'.""" - a = (opt_type_a or "").strip().upper() - b = (opt_type_b or "").strip().upper() - if a.startswith("C"): - a = "C" - elif a.startswith("P"): - a = "P" - if b.startswith("C"): - b = "C" - elif b.startswith("P"): - b = "P" - if {a, b} != {"C", "P"}: - return None, None, "做多/做空需一腿 Call、一腿 Put" - call_side = "a" if a == "C" else "b" - put_side = "b" if call_side == "a" else "a" - return call_side, put_side, "" - - -def suggest_oo_sheets( - *, - mode: str, - budget_usdc: float, - ask_a: float, - ct_mult_a: float = 0.01, - ask_sz_a: Any = None, - opt_type_a: str = "", - ask_b: float, - ct_mult_b: float = 0.01, - ask_sz_b: Any = None, - opt_type_b: str = "", - bias_split_by: str = "budget", - bias_ratio: float = 0.7, -) -> dict[str, Any]: - """期期建议张数:same_sheets / long_bias / short_bias(及旧 split_budget).""" - import math - - m = _normalize_oo_sheets_mode(mode) - split_by = _normalize_oo_bias_split_by(bias_split_by) - ratio = _clamp_oo_bias_ratio(bias_ratio) - budget = max(0.0, float(budget_usdc or 0.0)) - cost_a = option_unit_cost_usdc(ask=ask_a, ct_mult=ct_mult_a) - cost_b = option_unit_cost_usdc(ask=ask_b, ct_mult=ct_mult_b) - - def _fail(msg: str, n_a: int = 0, n_b: int = 0) -> dict[str, Any]: - return { - "mode": m, - "sheets_a": n_a, - "sheets_b": n_b, - "cost_a": round(cost_a, 8), - "cost_b": round(cost_b, 8), - "premium_est": round(cost_a * n_a + cost_b * n_b, 6), - "ok": False, - "msg": msg, - "bias_split_by": split_by, - "bias_ratio": ratio, - } - - if budget <= 0: - return _fail("可用预算为 0") - if cost_a <= 0 or cost_b <= 0: - return _fail("缺少有效卖一价,无法建议张数") - - pair = cost_a + cost_b - n_pair = int(math.floor(budget / pair + 1e-12)) if pair > 0 else 0 - # 与同张数一致:先按预算得 n,再各自深度封顶后取 min - n_same = min( - _cap_sheets_by_ask_depth(n_pair, ask_sz_a), - _cap_sheets_by_ask_depth(n_pair, ask_sz_b), - ) - - if m == "same_sheets": - n_a = n_same - n_b = n_same - elif m == "split_budget": - half = budget / 2.0 - n_a = int(math.floor(half / cost_a + 1e-12)) - n_b = int(math.floor(half / cost_b + 1e-12)) - n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a) - n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b) - else: - call_side, put_side, err = _oo_call_put_leg_index(opt_type_a, opt_type_b) - if err: - return _fail(err) - major_is_call = m == "long_bias" - if split_by == "sheets": - # 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆到 Call/Put - total = int(n_same) * 2 - if total < 2: - return _fail("同张数总规模不足 2,无法按比例拆分") - major_n = int(round(total * ratio)) - major_n = max(1, min(major_n, total - 1)) - minor_n = total - major_n - n_call = major_n if major_is_call else minor_n - n_put = minor_n if major_is_call else major_n - else: - maj_budget = budget * ratio - min_budget = budget * (1.0 - ratio) - cost_call = cost_a if call_side == "a" else cost_b - cost_put = cost_b if call_side == "a" else cost_a - if major_is_call: - n_call = int(math.floor(maj_budget / cost_call + 1e-12)) if cost_call > 0 else 0 - n_put = int(math.floor(min_budget / cost_put + 1e-12)) if cost_put > 0 else 0 - else: - n_put = int(math.floor(maj_budget / cost_put + 1e-12)) if cost_put > 0 else 0 - n_call = int(math.floor(min_budget / cost_call + 1e-12)) if cost_call > 0 else 0 - n_a = n_call if call_side == "a" else n_put - n_b = n_put if call_side == "a" else n_call - n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a) - n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b) - - prem = cost_a * n_a + cost_b * n_b - ok = n_a >= 1 and n_b >= 1 - msg = "" if ok else "预算不够开 1+1(或卖一深度不足)" - return { - "mode": m, - "sheets_a": n_a, - "sheets_b": n_b, - "cost_a": round(cost_a, 8), - "cost_b": round(cost_b, 8), - "premium_est": round(prem, 6), - "ok": ok, - "msg": msg, - "bias_split_by": split_by, - "bias_ratio": ratio, - } - - -def build_perp_options_preview( - *, - direction: str, - entry: float, - tp: float, - sl: float, - contracts: float, - contract_size: float, - opt_type: str, - strike: float, - sheets: float, - ct_mult: float, - premium_paid: float, - index_px: Optional[float] = None, -) -> dict[str, Any]: - """ - 永期情景. - 止盈账:永续止盈盈利 - 权利金. - 止损账:期权到期内在(按 SL 价) - 永续止损亏损额. - """ - d = (direction or "long").strip().lower() - pnl_tp_perp = perp_pnl( - direction=d, entry=entry, exit_px=tp, contracts=contracts, contract_size=contract_size - ) - pnl_sl_perp = perp_pnl( - direction=d, entry=entry, exit_px=sl, contracts=contracts, contract_size=contract_size - ) - # 止盈统计口径 - tp_total = float(pnl_tp_perp) - float(premium_paid) - # 止损:期权按 SL 价结算内在 - |永续亏损| - opt_at_sl = option_expiry_pnl( - opt_type=opt_type, - strike=strike, - spot=sl, - sheets=sheets, - ct_mult=ct_mult, - premium_paid=premium_paid, - ) - sl_total = float(opt_at_sl) - abs(float(pnl_sl_perp)) if pnl_sl_perp < 0 else float(opt_at_sl) + float( - pnl_sl_perp - ) - # 有符号相加更稳:期权盈亏 + 永续盈亏 - sl_total_signed = float(opt_at_sl) + float(pnl_sl_perp) - - spot = float(index_px) if index_px is not None else float(entry) - opt_flat = option_expiry_pnl( - opt_type=opt_type, - strike=strike, - spot=spot, - sheets=sheets, - ct_mult=ct_mult, - premium_paid=premium_paid, - ) - flat_total = 0.0 + float(opt_flat) - - opt_at_tp = option_expiry_pnl( - opt_type=opt_type, - strike=strike, - spot=tp, - sheets=sheets, - ct_mult=ct_mult, - premium_paid=premium_paid, - ) - - return { - "plan_type": "perp_options", - "direction": d, - "contracts": contracts, - "coin_amount": perp_coin_amount(contracts=contracts, contract_size=contract_size), - "premium_paid": round(float(premium_paid), 6), - "scenarios": [ - { - "id": "tp", - "label": "止盈(计划结束口径)", - "spot": tp, - "perp_pnl": round(pnl_tp_perp, 4), - "options_pnl": round(-float(premium_paid), 4), - "total": round(tp_total, 4), - "note": "止盈盈利 − 权利金;期权可不强平", - }, - { - "id": "sl", - "label": "止损(计划结束口径)", - "spot": sl, - "perp_pnl": round(pnl_sl_perp, 4), - "options_pnl": round(opt_at_sl, 4), - "total": round(sl_total_signed, 4), - "note": "期权盈利 − 永续亏损(有符号相加);期权须强平", - }, - { - "id": "flat", - "label": "到期·现价附近", - "spot": spot, - "perp_pnl": 0.0, - "options_pnl": round(opt_flat, 4), - "total": round(flat_total, 4), - "note": "示意:永续未动,期权按到期内在", - }, - { - "id": "expiry_tp", - "label": "到期·止盈价", - "spot": tp, - "perp_pnl": round(pnl_tp_perp, 4), - "options_pnl": round(opt_at_tp, 4), - "total": round(pnl_tp_perp + opt_at_tp, 4), - "note": "若期权拿到 TP 价到期(参考)", - }, - { - "id": "expiry_sl", - "label": "到期·止损价", - "spot": sl, - "perp_pnl": round(pnl_sl_perp, 4), - "options_pnl": round(opt_at_sl, 4), - "total": round(pnl_sl_perp + opt_at_sl, 4), - "note": "与止损口径相近(期权用内在)", - }, - ], - "summary": { - "tp_total": round(tp_total, 4), - "sl_total": round(sl_total_signed, 4), - "premium_paid": round(float(premium_paid), 4), - "hedge_ratio_at_sl": _hedge_ratio(opt_at_sl, pnl_sl_perp), - }, - } - - -def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]: - loss = abs(float(perp_pnl)) if float(perp_pnl) < 0 else 0.0 - if loss <= 1e-12: - return None - if float(opt_pnl) <= 0: - return 0.0 - return round(float(opt_pnl) / loss * 100.0, 2) - - -def build_options_options_preview( - *, - target_price: float | None = None, - target_price_up: float | None = None, - target_price_down: float | None = None, - profit_rr: float | None = None, - index_px: float, - leg_a: dict[str, Any], - leg_b: dict[str, Any], -) -> dict[str, Any]: - """期期情景:盈亏比达标 / 到期现价 / 最大保费损耗. - - 新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价. - 残值按亏损腿本合约权利金的 20% 计. - """ - - def _leg_pnl(leg: dict[str, Any], spot: float) -> float: - return option_expiry_pnl( - opt_type=str(leg.get("opt_type") or ""), - strike=float(leg["strike"]), - spot=spot, - sheets=float(leg.get("sheets") or 0), - ct_mult=float(leg.get("ct_mult") or 0.01), - premium_paid=float(leg.get("premium_paid") or 0), - ) - - prem_a = float(leg_a.get("premium_paid") or 0) - prem_b = float(leg_b.get("premium_paid") or 0) - prem = prem_a + prem_b - rr = float(profit_rr) if profit_rr is not None else None - - # 新:盈亏比情景(不依赖指数上下破价) - if rr is not None and rr > 0: - # 盈利腿达 RR:盈利金额 = rr × 总权利金;亏损腿按全亏 / 本合约残值20%回收 - win_profit = rr * prem - a_at_a = win_profit - b_at_a_full = -prem_b - b_at_a_res = -prem_b * 0.8 # 本合约回收 20% - b_at_b = win_profit - a_at_b_full = -prem_a - a_at_b_res = -prem_a * 0.8 - - spot_a = spot_from_expiry_intrinsic_profit( - opt_type=str(leg_a.get("opt_type") or ""), - strike=float(leg_a["strike"]), - sheets=float(leg_a.get("sheets") or 0), - ct_mult=float(leg_a.get("ct_mult") or 0.01), - premium_paid=prem_a, - profit=win_profit, - ) - spot_b = spot_from_expiry_intrinsic_profit( - opt_type=str(leg_b.get("opt_type") or ""), - strike=float(leg_b["strike"]), - sheets=float(leg_b.get("sheets") or 0), - ct_mult=float(leg_b.get("ct_mult") or 0.01), - premium_paid=prem_b, - profit=win_profit, - ) - - a_flat = _leg_pnl(leg_a, index_px) - b_flat = _leg_pnl(leg_b, index_px) - flat_total = a_flat + b_flat - - return { - "plan_type": "options_options", - "premium_paid": round(prem, 6), - "profit_rr": rr, - "target_price": None, - "target_price_up": None, - "target_price_down": None, - "winner_at_up": "a", - "winner_at_down": "b", - "winner_at_target": "a", - "scenarios": [ - { - "id": "rr_leg_a_full", - "label": f"腿A达盈亏比{rr:g}(亏腿全损)", - "spot": spot_a, - "leg_a_pnl": round(a_at_a, 4), - "leg_b_pnl": round(b_at_a_full, 4), - "total": round(a_at_a + b_at_a_full, 4), - "note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏", - }, - { - "id": "rr_leg_b_full", - "label": f"腿B达盈亏比{rr:g}(亏腿全损)", - "spot": spot_b, - "leg_a_pnl": round(a_at_b_full, 4), - "leg_b_pnl": round(b_at_b, 4), - "total": round(a_at_b_full + b_at_b, 4), - "note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏", - }, - { - "id": "rr_leg_a_residual", - "label": f"腿A达盈亏比{rr:g}(亏腿残值20%)", - "spot": spot_a, - "leg_a_pnl": round(a_at_a, 4), - "leg_b_pnl": round(b_at_a_res, 4), - "total": round(a_at_a + b_at_a_res, 4), - "note": "现货同腿A达标反推;亏腿买一回收约本合约权利金20%", - }, - { - "id": "expiry_flat", - "label": "到期·现价", - "spot": index_px, - "leg_a_pnl": round(a_flat, 4), - "leg_b_pnl": round(b_flat, 4), - "total": round(flat_total, 4), - "note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值", - }, - { - "id": "max_premium_loss", - "label": "最大保费损耗", - "spot": None, - "leg_a_pnl": round(-prem_a, 4), - "leg_b_pnl": round(-prem_b, 4), - "total": round(-prem, 4), - "note": "双腿权利金全部损失", - }, - ], - "summary": { - "profit_rr": rr, - "spot_at_rr_a": spot_a, - "spot_at_rr_b": spot_b, - "at_rr_a_full_total": round(a_at_a + b_at_a_full, 4), - "at_rr_b_full_total": round(a_at_b_full + b_at_b, 4), - "at_rr_a_residual_total": round(a_at_a + b_at_a_res, 4), - "at_target_up_total": round(a_at_a + b_at_a_full, 4), - "at_target_down_total": round(a_at_b_full + b_at_b, 4), - "at_target_total": round(a_at_a + b_at_a_full, 4), - "expiry_flat_total": round(flat_total, 4), - "premium_paid": round(prem, 6), - "expiry_is_loss": flat_total <= 0, - "rr_risk_premium": round(prem, 6), - "rr_at_up": round((a_at_a + b_at_a_full) / prem, 4) if prem > 0 else None, - "rr_at_down": round((a_at_b_full + b_at_b) / prem, 4) if prem > 0 else None, - }, - } - - # 兼容旧单目标:若未传上下目标则用 target_price 填两边 - up = target_price_up if target_price_up is not None else target_price - down = target_price_down if target_price_down is not None else target_price - if up is None or down is None: - raise ValueError("缺少盈亏比或上破/下破目标价") - up_f = float(up) - down_f = float(down) - - a_up = _leg_pnl(leg_a, up_f) - b_up = _leg_pnl(leg_b, up_f) - at_up = a_up + b_up - win_up = "a" if a_up >= b_up else "b" - - a_dn = _leg_pnl(leg_a, down_f) - b_dn = _leg_pnl(leg_b, down_f) - at_dn = a_dn + b_dn - win_dn = "a" if a_dn >= b_dn else "b" - - a_flat = _leg_pnl(leg_a, index_px) - b_flat = _leg_pnl(leg_b, index_px) - flat_total = a_flat + b_flat - expiry_loss = flat_total if flat_total <= 0 else flat_total - - return { - "plan_type": "options_options", - "premium_paid": round(prem, 6), - "target_price": up_f, # 兼容旧字段,取上破 - "target_price_up": up_f, - "target_price_down": down_f, - "winner_at_up": win_up, - "winner_at_down": win_dn, - "winner_at_target": win_up, - "scenarios": [ - { - "id": "target_up", - "label": "上破目标", - "spot": up_f, - "leg_a_pnl": round(a_up, 4), - "leg_b_pnl": round(b_up, 4), - "total": round(at_up, 4), - "note": f"盈利方≈腿{win_up.upper()}(可平);亏损方默认到期", - }, - { - "id": "target_down", - "label": "下破目标", - "spot": down_f, - "leg_a_pnl": round(a_dn, 4), - "leg_b_pnl": round(b_dn, 4), - "total": round(at_dn, 4), - "note": f"盈利方≈腿{win_dn.upper()}(可平);亏损方默认到期", - }, - { - "id": "expiry_flat", - "label": "到期·现价(无突破)", - "spot": index_px, - "leg_a_pnl": round(a_flat, 4), - "leg_b_pnl": round(b_flat, 4), - "total": round(flat_total, 4), - "note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值", - }, - { - "id": "max_premium_loss", - "label": "最大保费损耗", - "spot": None, - "leg_a_pnl": round(-prem_a, 4), - "leg_b_pnl": round(-prem_b, 4), - "total": round(-prem, 4), - "note": "双腿权利金全部损失", - }, - ], - "summary": { - "at_target_up_total": round(at_up, 4), - "at_target_down_total": round(at_dn, 4), - "at_target_total": round(at_up, 4), - "expiry_flat_total": round(expiry_loss, 4), - "premium_paid": round(prem, 6), - "expiry_is_loss": flat_total <= 0, - # 盈亏比:盈利/全亏保费(风险=权利金全损) - "rr_risk_premium": round(prem, 6), - "rr_at_up": round(at_up / prem, 4) if prem > 0 else None, - "rr_at_down": round(at_dn / prem, 4) if prem > 0 else None, - }, - } - - -def gate_status( - *, - hedge_enabled: bool, - sizing_mode: str, - plan_type: str, - options_enabled: bool, - live_order: bool = False, - live_trading: bool = False, - active_count: int = 0, - max_active: int = 1, - show_perp_options: bool = True, - show_options_options: bool = True, - mutual_exclusive: bool = True, - has_standalone_option: bool = False, -) -> dict[str, Any]: - from lib.trade.position_sizing_lib import is_full_margin_mode - - full = is_full_margin_mode(sizing_mode) - pt = (plan_type or "").strip().lower() - can_preview = True - can_start = True - reasons: list[str] = [] - if not hedge_enabled: - can_start = False - reasons.append("对冲计划未启用(HEDGE_PLAN_ENABLED)") - if not options_enabled: - can_preview = False - can_start = False - reasons.append("期权模块未启用") - if pt == "perp_options" and not show_perp_options: - can_preview = False - can_start = False - reasons.append("永期对冲已隐藏(HEDGE_PLAN_SHOW_PERP_OPTIONS)") - if pt == "options_options" and not show_options_options: - can_preview = False - can_start = False - reasons.append("期期对冲已隐藏(HEDGE_PLAN_SHOW_OPTIONS_OPTIONS)") - if not live_order: - can_start = False - reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)") - if active_count >= max(1, int(max_active or 1)): - can_start = False - reasons.append(f"活跃计划已达上限({max_active})") - if mutual_exclusive and has_standalone_option: - can_start = False - reasons.append("存在单独期权持仓,禁止启动对冲计划(互斥门控)") - if pt == "perp_options": - if not full: - can_start = False - reasons.append("永期开仓仅全仓模式可用(当前可测算)") - if not live_trading: - can_start = False - reasons.append("未开启实盘(LIVE_TRADING_ENABLED)") - elif pt == "options_options": - pass - else: - can_start = False - reasons.append("未知计划类型") - if can_start: - reasons = [] - return { - "hedge_enabled": hedge_enabled, - "options_enabled": options_enabled, - "sizing_mode": sizing_mode, - "is_full_margin": full, - "plan_type": pt, - "live_order": live_order, - "live_trading": live_trading, - "active_count": active_count, - "max_active": max_active, - "show_perp_options": bool(show_perp_options), - "show_options_options": bool(show_options_options), - "mutual_exclusive": bool(mutual_exclusive), - "has_standalone_option": bool(has_standalone_option), - "can_preview": can_preview, - "can_start": can_start, - "reasons": reasons, - } diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py deleted file mode 100644 index 319452f..0000000 --- a/lib/hedge_plan/hedge_plan_db.py +++ /dev/null @@ -1,468 +0,0 @@ -"""对冲计划 SQLite 表.""" -from __future__ import annotations - -import sqlite3 -from typing import Any, Optional - - -def init_hedge_plan_tables(conn: sqlite3.Connection) -> None: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS hedge_plans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_type TEXT NOT NULL, - status TEXT NOT NULL, - underlying TEXT NOT NULL, - direction TEXT, - entry_mark REAL, - tp REAL, - sl REAL, - target_price REAL, - sizing_mode_at_open TEXT, - perp_size REAL, - margin REAL, - leverage REAL, - premium_total REAL, - realized_pnl_perp REAL, - realized_pnl_options REAL, - realized_pnl_total REAL, - stats_bucket TEXT, - close_reason TEXT, - wechat_start_sent INTEGER DEFAULT 0, - wechat_end_sent INTEGER DEFAULT 0, - note TEXT, - preview_json TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - opened_at TIMESTAMP, - closed_at TIMESTAMP - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS hedge_plan_legs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL, - leg_role TEXT NOT NULL, - symbol TEXT, - inst_id TEXT, - opt_type TEXT, - strike REAL, - side TEXT, - size REAL, - avg_open REAL, - premium REAL, - status TEXT, - linked_monitor_id INTEGER, - options_trade_id INTEGER, - exchange_ord_id TEXT, - realized_pnl REAL, - close_reason TEXT, - opened_at TIMESTAMP, - closed_at TIMESTAMP, - FOREIGN KEY(plan_id) REFERENCES hedge_plans(id) - ) - """ - ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_hedge_plans_status ON hedge_plans(status)" - ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_hedge_plan_legs_plan ON hedge_plan_legs(plan_id)" - ) - _ensure_column(conn, "hedge_plans", "target_price_up", "REAL") - _ensure_column(conn, "hedge_plans", "target_price_down", "REAL") - # 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价 - _ensure_column(conn, "hedge_plans", "profit_rr", "REAL") - # close_all=残值平(本合约权利金≤20%且有买一);hold_expiry=残腿持有至到期 - _ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT") - # 永期「以期权为主」 - _ensure_column(conn, "hedge_plans", "option_primary", "INTEGER") - _ensure_column(conn, "hedge_plans", "option_target_points", "REAL") - _ensure_column(conn, "hedge_plans", "perp_target_points", "REAL") - _ensure_column(conn, "hedge_plans", "option_perp_ratio", "REAL") - _ensure_column(conn, "hedge_plans", "premium_budget", "REAL") - _ensure_column(conn, "hedge_plans", "strike_interval", "REAL") - _ensure_column(conn, "hedge_plans", "min_option_hours", "REAL") - _ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT") - _ensure_column(conn, "hedge_plans", "option_leverage", "REAL") - _ensure_column(conn, "hedge_plans", "perp_direction", "TEXT") - _ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL") - - -def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: - rows = conn.execute(f"PRAGMA table_info({table})").fetchall() - names: set[str] = set() - for r in rows: - try: - names.add(str(r["name"])) - except (TypeError, KeyError, IndexError): - names.add(str(r[1])) - if col not in names: - conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") - - -_ACTIVE_STATUSES = ("opening", "active", "partial", "watching") - - -def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int: - statuses = ",".join(f"'{s}'" for s in _ACTIVE_STATUSES) - if plan_type: - row = conn.execute( - f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses}) AND plan_type=?", - (plan_type,), - ).fetchone() - else: - row = conn.execute( - f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses})" - ).fetchone() - return int((row["c"] if row else 0) or 0) - - -def insert_plan(conn: sqlite3.Connection, row: dict[str, Any]) -> int: - cols = list(row.keys()) - placeholders = ",".join(["?"] * len(cols)) - conn.execute( - f"INSERT INTO hedge_plans ({','.join(cols)}) VALUES ({placeholders})", - [row[c] for c in cols], - ) - return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - - -def insert_leg(conn: sqlite3.Connection, row: dict[str, Any]) -> int: - cols = list(row.keys()) - placeholders = ",".join(["?"] * len(cols)) - conn.execute( - f"INSERT INTO hedge_plan_legs ({','.join(cols)}) VALUES ({placeholders})", - [row[c] for c in cols], - ) - return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - - -def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None: - if not fields: - return - sets = ", ".join(f"{k}=?" for k in fields) - conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id]) - - -def update_leg(conn: sqlite3.Connection, leg_id: int, **fields: Any) -> None: - if not fields: - return - sets = ", ".join(f"{k}=?" for k in fields) - conn.execute(f"UPDATE hedge_plan_legs SET {sets} WHERE id=?", [*fields.values(), int(leg_id)]) - - -def missing_leg_role(legs: list[dict[str, Any]]) -> Optional[str]: - for leg in legs or []: - if str(leg.get("status") or "").strip().lower() == "pending": - role = str(leg.get("leg_role") or "").strip() - if role: - return role - return None - - -def list_plans( - conn: sqlite3.Connection, - *, - status: Optional[str] = None, - plan_type: Optional[str] = None, - underlying: Optional[str] = None, - limit: int = 50, -) -> list[dict[str, Any]]: - wheres: list[str] = [] - args: list[Any] = [] - if status: - wheres.append("status=?") - args.append(status) - if plan_type: - wheres.append("plan_type=?") - args.append(plan_type) - if underlying: - wheres.append("underlying=?") - args.append(underlying) - where = (" WHERE " + " AND ".join(wheres)) if wheres else "" - rows = conn.execute( - f"SELECT * FROM hedge_plans{where} ORDER BY id DESC LIMIT ?", - [*args, int(limit)], - ).fetchall() - return [dict(r) for r in rows] - - -def get_plan(conn: sqlite3.Connection, plan_id: int) -> Optional[dict[str, Any]]: - row = conn.execute("SELECT * FROM hedge_plans WHERE id=?", (plan_id,)).fetchone() - return dict(row) if row else None - - -def get_plan_legs(conn: sqlite3.Connection, plan_id: int) -> list[dict[str, Any]]: - rows = conn.execute( - "SELECT * FROM hedge_plan_legs WHERE plan_id=? ORDER BY id", (plan_id,) - ).fetchall() - return [dict(r) for r in rows] - - -def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]: - """删除已结束/失败/取消的计划及其腿;活跃计划拒绝删除.""" - plan = get_plan(conn, int(plan_id)) - if not plan: - return {"ok": False, "msg": "计划不存在"} - st = str(plan.get("status") or "") - if st in ("opening", "active", "partial", "watching"): - return {"ok": False, "msg": "进行中的计划不可删除,请先结束"} - conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),)) - conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),)) - return {"ok": True, "deleted_id": int(plan_id)} - - -def legs_contract_summary(legs: list[dict[str, Any]]) -> str: - parts: list[str] = [] - for leg in legs: - role = str(leg.get("leg_role") or "") - st = str(leg.get("status") or "").strip().lower() - if st == "pending": - suffix = "(待补)" - elif st in ("cancelled", "canceled"): - suffix = "(未成交)" - else: - suffix = "" - if role == "perp": - name = str(leg.get("symbol") or "永续") - parts.append(f"永续 {name}{suffix}") - else: - inst = str(leg.get("inst_id") or "") - ot = str(leg.get("opt_type") or "").upper() - strike = leg.get("strike") - label = inst or (f"{ot}{strike}" if ot or strike is not None else role) - parts.append(f"{label}{suffix}") - return " · ".join(parts) if parts else "—" - - -def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]]) -> list[dict[str, Any]]: - out: list[dict[str, Any]] = [] - for p in plans: - legs = get_plan_legs(conn, int(p["id"])) - row = dict(p) - row["legs"] = legs - summary = legs_contract_summary(legs) - if str(p.get("status") or "") == "watching" and (not legs or summary == "—"): - money = str(p.get("option_moneyness") or "otm") - money_lab = {"itm": "实/平", "atm": "平值", "otm": "虚值"}.get(money, money) - parts = [f"盯盘·{money_lab}"] - try: - if p.get("strike_interval") not in (None, ""): - parts.append(f"间隔{float(p.get('strike_interval')):g}") - except (TypeError, ValueError): - pass - try: - if p.get("option_leverage") not in (None, ""): - parts.append(f"杠杆≥{float(p.get('option_leverage')):g}") - except (TypeError, ValueError): - pass - summary = "·".join(parts) - row["contracts_summary"] = summary - row["missing_leg"] = missing_leg_role(legs) - out.append(row) - return out - - -def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: - """返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。 - - 这些目标由 hedge_plan_monitor_lib 执行,绝不能写入 options_target_monitors, - 否则两套监控会同时尝试平掉同一条期权腿。 - """ - rows = conn.execute( - """ - SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down, - p.profit_rr, l.inst_id, l.opt_type - FROM hedge_plans p - JOIN hedge_plan_legs l ON l.plan_id = p.id - WHERE p.plan_type = 'options_options' - AND p.status IN ('opening', 'active', 'partial') - AND l.status = 'open' - AND l.inst_id IS NOT NULL - AND l.inst_id != '' - ORDER BY p.id DESC, l.id DESC - """ - ).fetchall() - out: dict[str, dict[str, Any]] = {} - for raw in rows: - row = dict(raw) - inst_id = str(row.get("inst_id") or "") - opt_type = str(row.get("opt_type") or "").upper() - if not inst_id or inst_id in out: - continue - profit_rr = _sf(row.get("profit_rr")) - if profit_rr is not None and profit_rr > 0: - out[inst_id] = { - "plan_id": int(row["plan_id"]), - "inst_id": inst_id, - "underlying": row.get("underlying"), - "opt_type": opt_type, - "profit_rr": profit_rr, - "target_index": None, - "exit_mode": "profit_rr", - "managed_by": "hedge_plan", - } - continue - target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down") - target_f = _sf(target) - if target_f is None or target_f <= 0: - continue - out[inst_id] = { - "plan_id": int(row["plan_id"]), - "inst_id": inst_id, - "underlying": row.get("underlying"), - "opt_type": opt_type, - "target_index": target_f, - "plan_type": "options_options", - "managed_by": "hedge_plan", - } - return out - - -def active_hedge_option_inst_ids(conn: sqlite3.Connection) -> set[str]: - """进行中对冲计划托管的期权合约,禁止单独期权页 close/target 拆组.""" - rows = conn.execute( - """ - SELECT DISTINCT l.inst_id - FROM hedge_plan_legs l - JOIN hedge_plans p ON p.id = l.plan_id - WHERE p.status IN ('opening', 'active', 'partial') - AND l.status IN ('open', 'hold_to_expiry') - AND l.inst_id IS NOT NULL - AND TRIM(l.inst_id) != '' - AND ( - l.leg_role LIKE 'option%' - OR (l.opt_type IS NOT NULL AND TRIM(l.opt_type) != '') - ) - """ - ).fetchall() - return {str(r[0]).strip() for r in rows if r and r[0]} - - -def _sf(v: Any) -> Optional[float]: - try: - if v is None or v == "": - return None - return float(v) - except (TypeError, ValueError): - return None - - -def _metrics_from_pnls(rows: list[dict[str, Any]]) -> dict[str, Any]: - """对一组已结束计划计算胜率/盈亏比/最大盈亏/最大回撤.""" - pnls: list[float] = [] - timed: list[tuple[str, float]] = [] - for r in rows: - pnl = _sf(r.get("realized_pnl_total")) - if pnl is None: - continue - pnls.append(pnl) - t = str(r.get("closed_at") or r.get("opened_at") or r.get("created_at") or "") - timed.append((t, pnl)) - n = len(pnls) - if n == 0: - return { - "count": 0, - "wins": 0, - "losses": 0, - "win_rate": None, - "net_pnl": 0.0, - "avg_pnl": None, - "avg_premium": None, - "profit_factor": None, - "max_profit": None, - "max_loss": None, - "max_drawdown": None, - } - wins = [x for x in pnls if x > 0] - losses = [x for x in pnls if x < 0] - gross_win = sum(wins) - gross_loss = abs(sum(losses)) - if gross_loss > 0: - profit_factor = round(gross_win / gross_loss, 4) - elif gross_win > 0: - profit_factor = None # 全胜,标无限 - else: - profit_factor = 0.0 - - timed.sort(key=lambda x: x[0] or "") - cum = 0.0 - peak = 0.0 - mdd = 0.0 - for _, p in timed: - cum += p - if cum > peak: - peak = cum - dd = peak - cum - if dd > mdd: - mdd = dd - - premiums = [_sf(r.get("premium_total")) for r in rows] - premiums_f = [x for x in premiums if x is not None] - return { - "count": n, - "wins": len(wins), - "losses": len(losses), - "win_rate": round(len(wins) / n, 4), - "net_pnl": round(sum(pnls), 4), - "avg_pnl": round(sum(pnls) / n, 4), - "avg_premium": round(sum(premiums_f) / len(premiums_f), 4) if premiums_f else None, - "profit_factor": profit_factor, - "profit_factor_infinite": bool(gross_loss <= 0 and gross_win > 0), - "max_profit": round(max(pnls), 4), - "max_loss": round(min(pnls), 4), - "max_drawdown": round(mdd, 4), - } - - -def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]: - reason_rows = conn.execute( - """ - SELECT plan_type, close_reason, COUNT(1) AS n, - COALESCE(SUM(realized_pnl_total), 0) AS pnl - FROM hedge_plans - WHERE status='closed' - GROUP BY plan_type, close_reason - """ - ).fetchall() - closed_rows = [ - dict(r) - for r in conn.execute( - "SELECT * FROM hedge_plans WHERE status='closed' ORDER BY COALESCE(closed_at, opened_at, created_at), id" - ).fetchall() - ] - active = count_active_plans(conn) - overall = _metrics_from_pnls(closed_rows) - by_type = { - "perp_options": _metrics_from_pnls( - [r for r in closed_rows if r.get("plan_type") == "perp_options"] - ), - "options_options": _metrics_from_pnls( - [r for r in closed_rows if r.get("plan_type") == "options_options"] - ), - } - # 永期止盈/止损分桶 - po = [r for r in closed_rows if r.get("plan_type") == "perp_options"] - by_type["perp_options"]["buckets"] = { - "tp": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_tp"]), - "sl": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_sl"]), - } - oo = [r for r in closed_rows if r.get("plan_type") == "options_options"] - by_type["options_options"]["buckets"] = { - "expiry_loss": _metrics_from_pnls( - [r for r in oo if r.get("close_reason") == "oo_expiry_loss"] - ), - "expiry_win": _metrics_from_pnls( - [r for r in oo if r.get("close_reason") == "oo_expiry_win"] - ), - } - return { - "active": active, - "closed_count": overall["count"], - "closed_pnl_total": overall["net_pnl"], - "overall": overall, - "by_type": by_type, - "by_reason": [dict(r) for r in reason_rows], - } diff --git a/lib/hedge_plan/hedge_plan_moneyness_lib.py b/lib/hedge_plan/hedge_plan_moneyness_lib.py deleted file mode 100644 index 94856b9..0000000 --- a/lib/hedge_plan/hedge_plan_moneyness_lib.py +++ /dev/null @@ -1,285 +0,0 @@ -"""对冲计划虚实值选约与校验. - -永期(perp_options):期权腿仅允许实值或平值(禁虚值). -期期(options_options):两腿仅允许平值或虚值(禁实值). -""" -from __future__ import annotations - -import os -from typing import Any, Optional - - -def _env_float(name: str, default: float) -> float: - try: - return float(os.getenv(name) or default) - except (TypeError, ValueError): - return float(default) - - -def itm_max_dist_usd() -> float: - """过深实值上限(USD).优先对冲专用,否则回退期权页.""" - raw = (os.getenv("HEDGE_PLAN_ITM_MAX_DIST_USD") or "").strip() - if raw: - try: - return max(0.0, float(raw)) - except ValueError: - pass - return max(0.0, _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0)) - - -def min_option_hours() -> float: - return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_HOURS", 8.0)) - - -def min_option_leverage() -> float: - """指数/卖一 最低杠杆门槛;0=不启用.""" - return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_LEVERAGE", 0.0)) - - -def _sf(v: Any) -> Optional[float]: - if v is None or v == "": - return None - try: - return float(v) - except (TypeError, ValueError): - return None - - -def normalize_opt_type(opt_type: Any, inst_id: str = "") -> str: - o = str(opt_type or "").strip().upper() - if o in ("C", "CALL"): - return "C" - if o in ("P", "PUT"): - return "P" - inst = str(inst_id or "").upper() - if inst.endswith("-C") or inst.endswith("-CALL"): - return "C" - if inst.endswith("-P") or inst.endswith("-PUT"): - return "P" - return "" - - -def classify_moneyness(*, opt_type: str, strike: float, index_px: float) -> str: - """itm / atm / otm / unknown.与 options_pricing_lib.option_moneyness 同口径.""" - from lib.options.options_pricing_lib import option_moneyness - - return option_moneyness(opt_type=opt_type, strike=strike, index_px=index_px) - - -def is_itm_or_atm(*, opt_type: str, strike: float, index_px: float) -> bool: - """Call: K<=S(+atm 带);Put: K>=S.用 classify 结果含 atm/itm.""" - m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px) - if m in ("itm", "atm"): - return True - # 几何兜底(与 eth_hedge_sim 一致),避免 atm 带边界漏判 - o = normalize_opt_type(opt_type) - k = float(strike) - s = float(index_px) - if o == "C": - return k <= s + 1e-9 - if o == "P": - return k >= s - 1e-9 - return False - - -def is_atm_or_otm(*, opt_type: str, strike: float, index_px: float) -> bool: - m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px) - if m in ("atm", "otm"): - return True - o = normalize_opt_type(opt_type) - k = float(strike) - s = float(index_px) - if o == "C": - return k >= s - 1e-9 # 平值带内或虚值 - if o == "P": - return k <= s + 1e-9 - return False - - -def itm_depth_usd(*, opt_type: str, strike: float, index_px: float) -> float: - o = normalize_opt_type(opt_type) - k = float(strike) - s = float(index_px) - if o == "C" and k < s: - return s - k - if o == "P" and k > s: - return k - s - return 0.0 - - -def parse_strike_from_inst(inst_id: str) -> Optional[float]: - """从 OKX 合约名解析行权价: ETH-USD-260731-1800-P.""" - parts = str(inst_id or "").strip().upper().split("-") - if len(parts) < 5: - return None - return _sf(parts[-2]) - - -def pick_itm_or_atm_contract( - contracts: list[dict[str, Any]], - *, - opt_type: str, - index_px: float, - itm_max_dist: Optional[float] = None, -) -> Optional[dict[str, Any]]: - """在合约列表中选距标的最近的实值/平值腿.""" - want = normalize_opt_type(opt_type) - if not want or index_px <= 0: - return None - max_dist = itm_max_dist if itm_max_dist is not None else itm_max_dist_usd() - cands: list[tuple[float, float, dict[str, Any]]] = [] - for c in contracts or []: - if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want: - continue - k = _sf(c.get("strike")) - if k is None: - continue - if not is_itm_or_atm(opt_type=want, strike=k, index_px=index_px): - continue - depth = itm_depth_usd(opt_type=want, strike=k, index_px=index_px) - if max_dist > 0 and depth > max_dist: - continue - cands.append((abs(k - index_px), k, c)) - if not cands: - return None - cands.sort(key=lambda x: (x[0], x[1])) - return cands[0][2] - - -def pick_atm_or_otm_contract( - contracts: list[dict[str, Any]], - *, - opt_type: str, - index_px: float, - prefer: str = "atm", -) -> Optional[dict[str, Any]]: - """选平值或虚值腿.prefer=atm 取距标的最近;prefer=otm 取最近虚值(不含实值).""" - want = normalize_opt_type(opt_type) - if not want or index_px <= 0: - return None - prefer_l = (prefer or "atm").strip().lower() - cands: list[tuple[float, float, dict[str, Any]]] = [] - for c in contracts or []: - if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want: - continue - k = _sf(c.get("strike")) - if k is None: - continue - if not is_atm_or_otm(opt_type=want, strike=k, index_px=index_px): - continue - m = classify_moneyness(opt_type=want, strike=k, index_px=index_px) - if prefer_l == "otm" and m != "otm": - continue - if prefer_l == "atm" and m == "otm": - # 仍可入选,但排序靠后(先 atm) - cands.append((1_000_000 + abs(k - index_px), k, c)) - else: - cands.append((abs(k - index_px), k, c)) - if not cands: - return None - cands.sort(key=lambda x: (x[0], x[1])) - return cands[0][2] - - -def recommend_oo_legs( - contracts: list[dict[str, Any]], - *, - index_px: float, - template: str = "atm_straddle", -) -> Optional[tuple[dict[str, Any], dict[str, Any]]]: - """期期推荐两腿.atm_straddle=最近平值 Call+Put;double_otm=最近虚值 Call+Put.""" - tpl = (template or "atm_straddle").strip().lower() - prefer = "otm" if tpl in ("double_otm", "otm_otm", "otm") else "atm" - call = pick_atm_or_otm_contract( - contracts, opt_type="C", index_px=index_px, prefer=prefer - ) - put = pick_atm_or_otm_contract( - contracts, opt_type="P", index_px=index_px, prefer=prefer - ) - if not call or not put: - return None - if str(call.get("inst_id") or "") == str(put.get("inst_id") or ""): - return None - return call, put - - -def validate_po_option_moneyness( - *, - opt_type: str, - strike: Any, - index_px: Any, - ask: Any = None, - hours_to_expiry: Any = None, -) -> Optional[str]: - """永期保险腿校验;返回错误文案或 None.""" - o = normalize_opt_type(opt_type) - k = _sf(strike) - s = _sf(index_px) - if o not in ("C", "P"): - return "期权类型无效" - if k is None or s is None or s <= 0: - return "行权价或指数无效,无法校验虚实值" - if not is_itm_or_atm(opt_type=o, strike=k, index_px=s): - return "永期保险腿须为实值或平值,不可选虚值" - max_dist = itm_max_dist_usd() - depth = itm_depth_usd(opt_type=o, strike=k, index_px=s) - if max_dist > 0 and depth > max_dist: - return f"实值过深(距现价 {depth:.1f}U > {max_dist:.0f}U),请换更接近平值的档" - min_h = min_option_hours() - h = _sf(hours_to_expiry) - if min_h > 0 and h is not None and h < min_h: - return f"剩余到期约 {h:.1f}h,低于最低 {min_h:.0f}h" - min_lev = min_option_leverage() - a = _sf(ask) - if min_lev > 0 and a is not None and a > 0: - lev = s / a - if lev < min_lev: - return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}" - return None - - -def validate_oo_leg_moneyness( - *, - opt_type: str, - strike: Any, - index_px: Any, - role: str = "腿", -) -> Optional[str]: - o = normalize_opt_type(opt_type) - k = _sf(strike) - s = _sf(index_px) - if o not in ("C", "P"): - return f"{role}期权类型无效" - if k is None or s is None or s <= 0: - return f"{role}行权价或指数无效,无法校验虚实值" - m = classify_moneyness(opt_type=o, strike=k, index_px=s) - if m == "itm": - return f"{role}须为平值或虚值,不可选实值" - if not is_atm_or_otm(opt_type=o, strike=k, index_px=s): - return f"{role}须为平值或虚值" - return None - - -def validate_oo_legs_moneyness( - leg_a: dict[str, Any], - leg_b: dict[str, Any], - *, - index_px: Any, -) -> Optional[str]: - err = validate_oo_leg_moneyness( - opt_type=leg_a.get("opt_type"), - strike=leg_a.get("strike"), - index_px=index_px, - role="腿A", - ) - if err: - return err - err = validate_oo_leg_moneyness( - opt_type=leg_b.get("opt_type"), - strike=leg_b.get("strike"), - index_px=index_px, - role="腿B", - ) - if err: - return err - return None diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py deleted file mode 100644 index 9dddff5..0000000 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ /dev/null @@ -1,1439 +0,0 @@ -"""对冲计划监控:永期 TP/SL、期期目标价、到期结算与微信收口推送.""" -from __future__ import annotations - -import os -from datetime import datetime, timezone -from typing import Any, Optional - -from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, list_plans, update_plan -from lib.hedge_plan.hedge_plan_notify_lib import notify_hedge, notify_plan_end, build_hedge_alert_message -from lib.hedge_plan.hedge_plan_orders_lib import _sell_option -from lib.hedge_plan.hedge_plan_settle_lib import ( - leg_is_expired, - resolve_option_leg_realized_pnl, - settle_option_leg_at_spot, -) - - -def _now() -> str: - return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") - - -def _env_bool(key: str, default: bool = False) -> bool: - raw = (os.getenv(key) or "").strip().lower() - if not raw: - return default - return raw in ("1", "true", "yes", "on") - - -def _sf(v: Any) -> Optional[float]: - try: - if v is None or v == "": - return None - return float(v) - except (TypeError, ValueError): - return None - - -def _perp_live_contracts(cfg: dict[str, Any], symbol: str, direction: str) -> Optional[float]: - fn = cfg.get("get_live_position_contracts") - if not callable(fn): - return None - try: - return fn(symbol, direction) - except Exception: - return None - - -def _index_px(cfg: dict[str, Any], underlying: str) -> Optional[float]: - ex = cfg.get("exchange_options") - fn = cfg.get("fetch_index_price") - if callable(fn) and ex is not None: - try: - return fn(ex, underlying) - except Exception: - return None - # 无期权账户时回退永续 ticker - ex_perp = cfg.get("exchange") - if ex_perp is not None: - try: - base = (underlying or "ETH").upper() - sym = f"{base}/USDT:USDT" - t = ex_perp.fetch_ticker(sym) - return _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) - except Exception: - return None - return None - - -def _plan_open_grace_sec() -> float: - try: - return max(0.0, float(os.getenv("HEDGE_PLAN_OPEN_GRACE_SEC") or "90")) - except (TypeError, ValueError): - return 90.0 - - -def _within_open_grace(plan: dict[str, Any]) -> bool: - """开仓后宽限期:仓位尚未同步到交易所时禁止按「已平」收口.""" - grace = _plan_open_grace_sec() - if grace <= 0: - return False - raw = str(plan.get("opened_at") or plan.get("created_at") or "").strip() - if not raw: - return True - try: - # "YYYY-MM-DD HH:MM:SS" 本地墙钟 - opened = datetime.strptime(raw[:19], "%Y-%m-%d %H:%M:%S") - age = (datetime.now() - opened).total_seconds() - return age < grace - except Exception: - return True - - -def _classify_po_flat_reason( - *, - direction: str, - entry: float, - mark: Optional[float], - tp: Optional[float], - sl: Optional[float], -) -> str: - """永续已平时分类 TP/SL.歧义时偏 SL(触发强平期权),避免误判 TP 跳过强平.""" - d = (direction or "long").lower() - if mark is None or not entry: - return "perp_flat_unknown" - if sl is not None: - if d == "long" and mark <= sl: - return "perp_sl" - if d == "short" and mark >= sl: - return "perp_sl" - if tp is not None: - if d == "long" and mark >= tp: - return "perp_tp" - if d == "short" and mark <= tp: - return "perp_tp" - if sl is not None and tp is not None: - return "perp_sl" if abs(mark - sl) <= abs(mark - tp) else "perp_tp" - if sl is not None: - return "perp_sl" - if tp is not None: - return "perp_tp" - return "perp_flat_unknown" - - -def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]: - """扫描 active/partial 计划 + 止盈后遗留期权到期收口.返回处理摘要.""" - get_db = cfg.get("get_db") - if not callable(get_db): - return {"ok": False, "msg": "get_db missing"} - conn = get_db() - acted: list[dict[str, Any]] = [] - backfill_stats: dict[str, int] = {} - try: - from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables - - init_hedge_plan_tables(conn) - plans = list_plans(conn, status="watching", limit=20) - plans.extend(list_plans(conn, status="active", limit=40)) - # partial:裸永续/半腿也需侦测永续 TP/SL - plans.extend(list_plans(conn, status="partial", limit=20)) - seen: set[int] = set() - for plan in plans: - pid = int(plan.get("id") or 0) - if pid in seen: - continue - seen.add(pid) - r = _tick_one(cfg, conn, plan) - if r: - acted.append(r) - orphaned = _settle_orphaned_after_tp(cfg, conn) - acted.extend(orphaned) - try: - ex = cfg.get("exchange_options") - if ex is not None: - from lib.exchange.okx_options_lib import fetch_all_option_positions_history - from lib.hedge_plan.hedge_plan_settle_lib import ( - backfill_hedge_option_legs_realized_pnl, - ) - - hist = fetch_all_option_positions_history(ex, limit=200) - backfill_stats = backfill_hedge_option_legs_realized_pnl(conn, hist) - except Exception: - pass - conn.commit() - finally: - conn.close() - return {"ok": True, "acted": acted, "pnl_backfill": backfill_stats} - - -def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None: - plan = get_plan(conn, int(plan_id)) - if plan: - notify_plan_end(cfg, conn, plan) - - -def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str: - """盈利腿平后另一腿:close_all(残值平) / hold_expiry(到期平). - - - 方案C关闭 → 强制到期平 - - 计划未写 oo_close_mode(旧单) → 到期平,避免误清残腿 - - 新开仓默认写入 close_all(残值平:权利金≤初始20%且有买一) - """ - if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True): - return "hold_expiry" - raw = plan.get("oo_close_mode") - if raw is None or str(raw).strip() == "": - return "hold_expiry" - v = str(raw).strip().lower() - if v in ("hold_expiry", "hold_to_expiry", "expiry", "到期平"): - return "hold_expiry" - return "close_all" - - -# 期期亏损腿残值平:当前买一回收 ≤ 本合约初始权利金 × 该比例 -OO_LOSS_LEG_RESIDUAL_RATIO = 0.20 -# 期期默认盈亏比:盈利金额 / 总权利金 -OO_DEFAULT_PROFIT_RR = 2.0 - - -def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) -> list[dict[str, Any]]: - out = [] - for x in legs: - if not str(x.get("leg_role") or "").startswith("option"): - continue - if str(x.get("status") or "") in statuses: - out.append(x) - return out - - -def _oo_quote_bid(cfg: dict[str, Any], inst_id: str) -> tuple[Optional[float], Optional[float]]: - quote_fn = cfg.get("quote_option_contract") - ex_opt = cfg.get("exchange_options") - if not callable(quote_fn) or ex_opt is None or not inst_id: - return None, None - try: - q = quote_fn(ex_opt, inst_id) - if not q.get("ok"): - return None, None - return _sf(q.get("bid")), _sf(q.get("bid_sz")) - except Exception: - return None, None - - -def _oo_leg_mark_value(leg: dict[str, Any], bid: Optional[float]) -> Optional[float]: - """买一可回收金额(USDC)= bid × 张数 × ct_mult.""" - b = _sf(bid) - if b is None or b < 0: - return None - sheets = float(leg.get("size") or 1) - ct = float(leg.get("ct_mult") or 0.01) - return float(b) * sheets * ct - - -def _oo_plan_premium_total(plan: dict[str, Any], legs: list[dict[str, Any]]) -> float: - """双腿总权利金:优先计划字段,否则对期权腿 premium 求和.""" - total = _sf(plan.get("premium_total")) - if total is not None and total > 0: - return float(total) - s = 0.0 - for leg in legs: - if not str(leg.get("leg_role") or "").startswith("option"): - continue - s += float(leg.get("premium") or 0) - return s - - -def _oo_leg_profit_rr( - leg: dict[str, Any], bid: Optional[float], *, total_premium: float -) -> Optional[float]: - """盈亏比 = 该腿盈利金额 / 总权利金;盈利金额 = 买一回收 − 本腿权利金.""" - if total_premium <= 0: - return None - leg_prem = float(leg.get("premium") or 0) - value = _oo_leg_mark_value(leg, bid) - if value is None: - return None - return (value - leg_prem) / total_premium - - -def _oo_resolve_profit_rr(plan: dict[str, Any]) -> Optional[float]: - rr = _sf(plan.get("profit_rr")) - if rr is not None and rr > 0: - return rr - return None - - -def _finalize_oo_all_closed( - cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str -) -> dict[str, Any]: - closed_opts = _oo_option_legs(legs, statuses=("closed",)) - total_opts = sum(float(x.get("realized_pnl") or 0) for x in closed_opts) - close_reason = reason or "oo_rest_closed" - bucket = "oo_target" if total_opts > 0 else "oo_expiry_loss" - update_plan( - conn, - int(plan["id"]), - status="closed", - close_reason=close_reason, - realized_pnl_options=round(total_opts, 4), - realized_pnl_total=round(total_opts, 4), - stats_bucket=bucket, - closed_at=_now(), - ) - _notify_end_reload(cfg, conn, int(plan["id"])) - return {"plan_id": plan["id"], "close_reason": close_reason, "total": total_opts} - - -def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[dict[str, Any]]: - pt = plan.get("plan_type") - legs = get_plan_legs(conn, int(plan["id"])) - if pt == "perp_options": - from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary - - if is_option_primary(plan): - if str(plan.get("status") or "") == "watching": - return _tick_po_option_primary_watching(cfg, conn, plan) - # 期权为主:半平重试 → 到期 → 目标位分叉 - r = _tick_po_option_primary_pending(cfg, conn, plan, legs) - if r: - return r - r = _tick_po_option_primary_expiry(cfg, conn, plan, legs) - if r: - return r - r = _tick_po_option_primary_both_expired(cfg, conn, plan, legs) - if r: - return r - return _tick_po_option_primary(cfg, conn, plan, legs) - r = _tick_po(cfg, conn, plan, legs) - return r - if pt == "options_options": - r = _tick_oo_expiry(cfg, conn, plan, legs) - if r: - return r - r = _tick_oo_close_rest(cfg, conn, plan, legs) - if r: - return r - return _tick_oo_target(cfg, conn, plan, legs) - return None - - -def _tick_po_option_primary_watching( - cfg: dict[str, Any], conn: Any, plan: dict[str, Any] -) -> Optional[dict[str, Any]]: - """盯盘:链上出现杠杆/间隔达标合约后自动开仓.""" - import json - import os - - from lib.hedge_plan.hedge_plan_option_primary_lib import ( - pick_option_primary_candidate, - size_from_premium, - ) - from lib.hedge_plan.hedge_plan_orders_lib import execute_perp_options_start - from lib.hedge_plan.hedge_plan_register import _activate_watching_po - - build_chain = cfg.get("build_option_chain") - ex = cfg.get("exchange_options") - if not callable(build_chain) or ex is None: - return None - body0: dict[str, Any] = {} - try: - raw = plan.get("preview_json") or "" - blob = json.loads(raw) if raw else {} - body0 = dict(blob.get("start_body") or blob or {}) - except Exception: - body0 = {} - uly = str(plan.get("underlying") or body0.get("underlying") or "ETH").upper() - direction = str(plan.get("direction") or body0.get("direction") or "long").lower() - money = str(plan.get("option_moneyness") or body0.get("moneyness") or "otm").lower() - interval = plan.get("strike_interval") - if interval in (None, ""): - interval = body0.get("strike_interval", 15) - min_h = plan.get("min_option_hours") - if min_h in (None, ""): - min_h = body0.get("min_option_hours", 36) - opt_lev = plan.get("option_leverage") - if opt_lev in (None, ""): - opt_lev = body0.get("option_leverage") - try: - chain = build_chain( - ex, - uly, - max_dte_days=float(cfg.get("chain_max_dte") or 14), - itm_only=False, - itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"), - ) - except Exception as e: - update_plan(conn, int(plan["id"]), note=f"盯盘拉链失败: {e}"[:500]) - return None - cand = pick_option_primary_candidate( - chain, - direction=direction, - moneyness=money, - strike_interval=interval, - min_hours=min_h, - min_opt_leverage=opt_lev, - ) - if not cand: - return None - ask = float(cand.get("ask") or 0) - ct = float(cand.get("ct_mult") or body0.get("ct_mult") or 0.01) - sized = size_from_premium( - premium_budget=float(plan.get("premium_budget") or body0.get("premium_budget") or 0), - ask=ask, - ct_mult=ct, - ratio=float(plan.get("option_perp_ratio") or body0.get("option_perp_ratio") or 2), - contract_size=float(body0.get("contract_size") or 0.01), - ) - if not sized.get("ok"): - update_plan(conn, int(plan["id"]), note=f"盯盘定仓失败: {sized.get('msg')}"[:500]) - return None - idx = float(cand.get("index_px") or chain.get("index_px") or 0) - body = dict(body0) - body.update( - { - "plan_type": "perp_options", - "option_primary": True, - "watch_entry": 0, - "underlying": uly, - "direction": direction, - "moneyness": money, - "opt_inst_id": cand.get("inst_id"), - "opt_type": cand.get("opt_type"), - "strike": cand.get("strike"), - "ask": ask, - "ct_mult": ct, - "sheets": sized["sheets"], - "contracts": sized["contracts"], - "eth_qty": sized.get("eth_qty"), - "index_px": idx, - "entry": idx, - "hours_to_expiry": cand.get("hours_to_expiry"), - "strike_interval": interval, - "min_option_hours": min_h, - "option_leverage": opt_lev, - "option_perp_ratio": plan.get("option_perp_ratio") or body0.get("option_perp_ratio"), - "option_target_points": plan.get("option_target_points") or body0.get("option_target_points"), - "perp_target_points": plan.get("perp_target_points") or body0.get("perp_target_points"), - "premium_budget": plan.get("premium_budget") or body0.get("premium_budget"), - "leverage": plan.get("leverage") or body0.get("leverage") or 100, - "exchange_symbol": body0.get("exchange_symbol") or f"{uly}-USDT-SWAP", - "contract_size": body0.get("contract_size") or 0.01, - } - ) - dry = str(os.getenv("HEDGE_PLAN_DRY_RUN") or "").strip().lower() in ("1", "true", "yes", "on") - out = execute_perp_options_start(cfg, body, dry_run=dry, persist=None) - if not out.get("ok"): - update_plan(conn, int(plan["id"]), note=f"盯盘开仓未成: {out.get('msg')}"[:500]) - return {"plan_id": plan["id"], "watching_open": False, "msg": out.get("msg")} - if dry: - update_plan(conn, int(plan["id"]), note=f"dry_run命中 {cand.get('inst_id')}"[:500]) - return {"plan_id": plan["id"], "watching_open": True, "dry_run": True, "inst_id": cand.get("inst_id")} - _activate_watching_po(cfg, conn, int(plan["id"]), out, body) - return { - "plan_id": plan["id"], - "watching_open": True, - "inst_id": cand.get("inst_id"), - "leverage": cand.get("leverage"), - } - - -def _tick_po_option_primary_pending( - cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] -) -> Optional[dict[str, Any]]: - """期权已平、永续待平(opt_target_perp_pending)时只重试平永续.""" - from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view - from lib.hedge_plan.hedge_plan_orders_lib import _close_perp - - pending = str(plan.get("close_reason") or "") - if pending not in ("opt_target_perp_pending", "opt_target_pending"): - return None - perp = next((x for x in legs if x.get("leg_role") == "perp"), None) - opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) - if not perp or str(perp.get("status") or "") != "open": - return None - view = str(plan.get("direction") or "long").lower() - perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower() - symbol = str(perp.get("symbol") or "") - contracts = float(perp.get("size") or plan.get("perp_size") or 0) - - # 期权仍 open:继续走主路径,不在此强平 - if pending == "opt_target_pending" and opt and str(opt.get("status") or "") == "open": - return None - - # 期权已平或 already flat:只补平永续 - if opt and str(opt.get("status") or "") == "open": - return None - - perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False) - if not perp_close.get("ok"): - notify_hedge( - cfg, - build_hedge_alert_message( - title="期权已平·永续平仓重试失败", - plan_id=plan.get("id"), - detail=str(perp_close.get("msg") or perp_close), - ), - ) - update_plan(conn, int(plan["id"]), close_reason="opt_target_perp_pending") - return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} - - entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0 - mark = entry - ex = cfg.get("exchange") - if ex is not None and symbol: - try: - t = ex.fetch_ticker(symbol) - mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) or entry - except Exception: - pass - cs = float(cfg.get("default_contract_size") or 0.01) - get_cs = cfg.get("get_contract_size") - if callable(get_cs) and symbol: - try: - cs = float(get_cs(symbol) or cs) - except Exception: - pass - coins = contracts * cs - if perp_dir == "short": - perp_pnl = (float(entry or 0) - float(mark or 0)) * coins - else: - perp_pnl = (float(mark or 0) - float(entry or 0)) * coins - opt_pnl = float(opt.get("realized_pnl") or 0) if opt else float(plan.get("realized_pnl_options") or 0) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", "opt_target_points", _now(), round(perp_pnl, 4), perp["id"]), - ) - total = opt_pnl + perp_pnl - update_plan( - conn, - int(plan["id"]), - status="closed", - close_reason="opt_target_points", - realized_pnl_perp=round(perp_pnl, 4), - realized_pnl_options=round(opt_pnl, 4), - realized_pnl_total=round(total, 4), - stats_bucket="opt_primary", - closed_at=_now(), - ) - _notify_end_reload(cfg, conn, int(plan["id"])) - return {"plan_id": plan["id"], "close_reason": "opt_target_points", "total": total, "recovered": True} - - -def _tick_po_option_primary_both_expired( - cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] -) -> Optional[dict[str, Any]]: - """两腿仍 open 但期权已到期:结算期权并市价平永续,避免裸奔.""" - from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view - from lib.hedge_plan.hedge_plan_orders_lib import _close_perp - - perp = next((x for x in legs if x.get("leg_role") == "perp"), None) - opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) - if not perp or str(perp.get("status") or "") != "open": - return None - if not opt or str(opt.get("status") or "") != "open": - return None - if not leg_is_expired(opt): - return None - spot = _index_px(cfg, str(plan.get("underlying") or "ETH")) - if spot is None: - return None - est = settle_option_leg_at_spot(opt, float(spot)) - opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", "expiry", _now(), round(opt_pnl, 4), opt["id"]), - ) - view = str(plan.get("direction") or "long").lower() - perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower() - symbol = str(perp.get("symbol") or "") - contracts = float(perp.get("size") or plan.get("perp_size") or 0) - perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False) - entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or float(spot) - cs = float(cfg.get("default_contract_size") or 0.01) - get_cs = cfg.get("get_contract_size") - if callable(get_cs) and symbol: - try: - cs = float(get_cs(symbol) or cs) - except Exception: - pass - coins = contracts * cs - if perp_dir == "short": - perp_pnl = (float(entry) - float(spot)) * coins - else: - perp_pnl = (float(spot) - float(entry)) * coins - if not perp_close.get("ok"): - notify_hedge( - cfg, - build_hedge_alert_message( - title="期权到期后永续平仓失败(将重试)", - plan_id=plan.get("id"), - detail=str(perp_close.get("msg") or perp_close), - ), - ) - update_plan( - conn, - int(plan["id"]), - close_reason="opt_target_perp_pending", - realized_pnl_options=round(opt_pnl, 4), - note="期权已到期结算,永续待平", - ) - return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", "option_expired", _now(), round(perp_pnl, 4), perp["id"]), - ) - total = opt_pnl + perp_pnl - update_plan( - conn, - int(plan["id"]), - status="closed", - close_reason="option_expired", - realized_pnl_perp=round(perp_pnl, 4), - realized_pnl_options=round(opt_pnl, 4), - realized_pnl_total=round(total, 4), - stats_bucket="opt_primary", - closed_at=_now(), - ) - _notify_end_reload(cfg, conn, int(plan["id"])) - return {"plan_id": plan["id"], "close_reason": "option_expired", "total": total} - - -def _tick_po_option_primary_expiry( - cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] -) -> Optional[dict[str, Any]]: - """期权为主且永续已平、期权 hold_to_expiry → 到期结算后收口计划.""" - perp = next((x for x in legs if x.get("leg_role") == "perp"), None) - opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) - if not opt or str(opt.get("status") or "") != "hold_to_expiry": - return None - if perp and str(perp.get("status") or "") == "open": - return None - if not leg_is_expired(opt): - return None - spot = _index_px(cfg, str(plan.get("underlying") or "ETH")) - if spot is None: - return None - est = settle_option_leg_at_spot(opt, float(spot)) - opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", "expiry", _now(), round(opt_pnl, 4), opt["id"]), - ) - perp_pnl = float(perp.get("realized_pnl") or 0) if perp else float(plan.get("realized_pnl_perp") or 0) - total = perp_pnl + opt_pnl - update_plan( - conn, - int(plan["id"]), - status="closed", - close_reason="perp_target_points_expiry", - realized_pnl_perp=round(perp_pnl, 4), - realized_pnl_options=round(opt_pnl, 4), - realized_pnl_total=round(total, 4), - stats_bucket="opt_primary", - closed_at=_now(), - ) - _notify_end_reload(cfg, conn, int(plan["id"])) - return {"plan_id": plan["id"], "close_reason": "perp_target_points_expiry", "total": total} - - -def _tick_po_option_primary( - cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] -) -> Optional[dict[str, Any]]: - """以期权为主:触达目标位立即执行分叉平仓规则.""" - from lib.hedge_plan.hedge_plan_option_primary_lib import ( - estimate_combo_net_pnl, - option_bid_liquidity_ok, - perp_direction_for_view, - target_hit, - ) - from lib.hedge_plan.hedge_plan_orders_lib import _close_perp - - perp = next((x for x in legs if x.get("leg_role") == "perp"), None) - opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) - if not perp or str(perp.get("status") or "") != "open": - return None - if not opt or str(opt.get("status") or "") != "open": - return None - if _within_open_grace(plan): - return None - - view = str(plan.get("direction") or "long").lower() - perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower() - strike = _sf(opt.get("strike")) - n = _sf(plan.get("option_target_points")) - m = _sf(plan.get("perp_target_points")) - if strike is None or strike <= 0: - return None - idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) - if idx is None: - return None - - hit_opt = bool(n is not None and target_hit(view_side=view, index_px=idx, strike=strike, points=float(n))) - hit_perp = bool(m is not None and target_hit(view_side=view, index_px=idx, strike=strike, points=float(m))) - if not hit_opt and not hit_perp: - return None - - symbol = str(perp.get("symbol") or "") - mark = None - ex = cfg.get("exchange") - if ex is not None and symbol: - try: - t = ex.fetch_ticker(symbol) - mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) - except Exception: - mark = None - mark = mark or idx - entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or mark - cs = float(cfg.get("default_contract_size") or 0.01) - get_cs = cfg.get("get_contract_size") - if callable(get_cs) and symbol: - try: - cs = float(get_cs(symbol) or cs) - except Exception: - pass - - quote_fn = cfg.get("quote_option_contract") - ex_opt = cfg.get("exchange_options") - bid = None - bid_sz = None - if callable(quote_fn) and ex_opt is not None: - try: - q = quote_fn(ex_opt, str(opt.get("inst_id") or "")) - if q.get("ok"): - bid = _sf(q.get("bid")) - bid_sz = _sf(q.get("bid_sz")) - except Exception: - bid = None - - ask_open = _sf(opt.get("avg_open")) or 0.0 - sheets = float(opt.get("size") or 1) - ct = float(opt.get("ct_mult") or 0.01) - contracts = float(perp.get("size") or plan.get("perp_size") or 0) - - # 优先期权目标;买一不足或净利≤0 时若永续目标已触达则改走永续目标 - if hit_opt: - liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets) - net = None - if liq_ok: - net = estimate_combo_net_pnl( - view_side=view, - strike=float(strike), - index_px=float(idx), - ask_open=float(ask_open), - bid=float(bid or 0), - sheets=sheets, - ct_mult=ct, - perp_direction=perp_dir, - perp_entry=float(entry or 0), - perp_mark=float(mark or 0), - contracts=contracts, - contract_size=cs, - ) - can_opt_exit = bool(liq_ok and net is not None and float(net.get("net") or 0) > 0) - if can_opt_exit: - reason = "opt_target_points" - close_r = _sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=sheets) - if close_r.get("already_flat"): - opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=net["opt_net"]) - elif not close_r.get("ok") or not close_r.get("fully_closed", True): - notify_hedge( - cfg, - build_hedge_alert_message( - title="期权目标平仓失败(将重试)", - plan_id=plan.get("id"), - detail=str(close_r.get("msg") or close_r), - ), - ) - update_plan(conn, int(plan["id"]), close_reason="opt_target_pending") - return {"plan_id": plan["id"], "retry": True, "close": close_r} - else: - opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=net["opt_net"]) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", reason, _now(), round(opt_pnl, 4), opt["id"]), - ) - perp_close = _close_perp( - cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False - ) - if not perp_close.get("ok"): - notify_hedge( - cfg, - build_hedge_alert_message( - title="期权已平但永续平仓失败(将重试)", - plan_id=plan.get("id"), - detail=str(perp_close.get("msg") or perp_close), - ), - ) - update_plan(conn, int(plan["id"]), close_reason="opt_target_perp_pending") - return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} - perp_pnl = float(net.get("perp_net") or 0) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", reason, _now(), round(perp_pnl, 4), perp["id"]), - ) - total = float(opt_pnl) + float(perp_pnl) - update_plan( - conn, - int(plan["id"]), - status="closed", - close_reason=reason, - realized_pnl_perp=round(perp_pnl, 4), - realized_pnl_options=round(opt_pnl, 4), - realized_pnl_total=round(total, 4), - stats_bucket="opt_primary", - closed_at=_now(), - ) - _notify_end_reload(cfg, conn, int(plan["id"])) - return {"plan_id": plan["id"], "close_reason": reason, "total": total, "net": net} - if not hit_perp: - return { - "plan_id": plan["id"], - "skip": True, - "msg": (liq_msg if not liq_ok else "净利≤0,继续持有"), - "net": net, - } - - if not hit_perp: - return None - - reason = "perp_target_points" - # 永续目标:平永续,期权持有至到期 - perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False) - if not perp_close.get("ok"): - notify_hedge( - cfg, - build_hedge_alert_message( - title="永续目标平仓失败(将重试)", - plan_id=plan.get("id"), - detail=str(perp_close.get("msg") or perp_close), - ), - ) - update_plan(conn, int(plan["id"]), close_reason="perp_target_pending") - return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} - # 估永续已实现 - coins = contracts * cs - if perp_dir == "short": - perp_pnl = (float(entry or 0) - float(mark or 0)) * coins - else: - perp_pnl = (float(mark or 0) - float(entry or 0)) * coins - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", reason, _now(), round(perp_pnl, 4), perp["id"]), - ) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?", - ("hold_to_expiry", "hold_expiry_after_perp_target", opt["id"]), - ) - update_plan( - conn, - int(plan["id"]), - # 计划保持 active,等期权到期收口 - close_reason="perp_target_points", - realized_pnl_perp=round(perp_pnl, 4), - note="永续已按目标平仓,期权持有至到期", - ) - notify_hedge( - cfg, - build_hedge_alert_message( - title="永续目标已平·期权持有至到期", - plan_id=plan.get("id"), - detail=f"指数 {idx:.2f} · 永续盈亏约 {perp_pnl:.2f}", - ), - ) - return {"plan_id": plan["id"], "close_reason": reason, "perp_pnl": perp_pnl, "opt_hold": True} - - -def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]) -> Optional[dict[str, Any]]: - perp = next((x for x in legs if x.get("leg_role") == "perp"), None) - opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) - if not perp or perp.get("status") != "open": - return None - symbol = perp.get("symbol") or "" - direction = (plan.get("perp_direction") or plan.get("direction") or "long").lower() - live = _perp_live_contracts(cfg, symbol, direction) - # API 失败 / 未注入 → 本轮跳过,绝不当「已平」 - if live is None: - return None - # 仍有仓 → 未触达交易所 TP/SL - if live > 0: - return None - # 开仓后宽限期:仓位同步延迟可误读为 0 - if _within_open_grace(plan): - return None - - entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0 - tp = _sf(plan.get("tp")) - sl = _sf(plan.get("sl")) - mark = None - ex = cfg.get("exchange") - if ex is not None and symbol: - try: - t = ex.fetch_ticker(symbol) - mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) - except Exception: - mark = None - reason = _classify_po_flat_reason( - direction=direction, entry=float(entry or 0), mark=mark, tp=tp, sl=sl - ) - # 上一轮止损强平未完成:粘滞为 SL,避免 mark 反弹误判 TP 跳过强平 - pending_reason = str(plan.get("close_reason") or "") - if pending_reason == "perp_sl_pending_opt": - reason = "perp_sl" - elif pending_reason == "perp_tp_pending_opt": - reason = "perp_tp" - # 未明确 TP/SL 时不收口,下轮再判 - if reason == "perp_flat_unknown": - return None - - premium = float(plan.get("premium_total") or 0) - cs = float(cfg.get("default_contract_size") or 0.01) - get_cs = cfg.get("get_contract_size") - if callable(get_cs) and symbol: - try: - cs = float(get_cs(symbol) or cs) - except Exception: - pass - size = float(perp.get("size") or 0) - exit_px = mark or (tp if reason == "perp_tp" else sl) or entry - coins = size * cs - if direction == "short": - perp_pnl = (entry - exit_px) * coins - else: - perp_pnl = (exit_px - entry) * coins - - opt_pnl = -premium - if reason == "perp_sl" and opt and str(opt.get("status") or "") == "open": - if _env_bool("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", True): - close_r = _sell_option( - cfg, - inst_id=str(opt.get("inst_id") or ""), - sheets=float(opt.get("size") or 1), - ) - if close_r.get("already_flat"): - opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=-premium) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", reason, _now(), opt_pnl, opt["id"]), - ) - elif not close_r.get("ok") or not close_r.get("fully_closed", True): - notify_hedge( - cfg, - build_hedge_alert_message( - title="永续止损后期权强制平仓失败(将重试)", - plan_id=plan.get("id"), - detail=str(close_r.get("msg") or close_r), - ), - ) - update_plan(conn, int(plan["id"]), close_reason="perp_sl_pending_opt") - return { - "plan_id": plan["id"], - "msg": "止损后期权未平完", - "close": close_r, - "retry": True, - } - else: - bid = _sf(close_r.get("bid") or close_r.get("locked_bid_px")) - ask_open = _sf(opt.get("avg_open")) - if bid is not None and ask_open is not None: - ct = float(opt.get("ct_mult") or 0.01) - est = (bid - ask_open) * float(opt.get("size") or 1) * ct - else: - est = -premium - opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", reason, _now(), opt_pnl, opt["id"]), - ) - else: - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?", - ("hold_to_expiry", "orphaned_after_sl", opt["id"]), - ) - opt_pnl = -premium - elif reason == "perp_tp" and opt and str(opt.get("status") or "") == "open": - if _env_bool("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", False): - close_r = _sell_option( - cfg, inst_id=str(opt.get("inst_id") or ""), sheets=float(opt.get("size") or 1) - ) - if close_r.get("already_flat"): - opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=-premium) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", reason, _now(), opt_pnl, opt["id"]), - ) - elif not close_r.get("ok") or not close_r.get("fully_closed", True): - notify_hedge( - cfg, - build_hedge_alert_message( - title="永续止盈后期权平仓失败(将重试)", - plan_id=plan.get("id"), - detail=str(close_r.get("msg") or close_r), - ), - ) - update_plan(conn, int(plan["id"]), close_reason="perp_tp_pending_opt") - return { - "plan_id": plan["id"], - "msg": "止盈后期权未平完", - "close": close_r, - "retry": True, - } - else: - bid = _sf(close_r.get("bid") or close_r.get("locked_bid_px")) - ask_open = _sf(opt.get("avg_open")) - if bid is not None and ask_open is not None: - ct = float(opt.get("ct_mult") or 0.01) - est = (bid - ask_open) * float(opt.get("size") or 1) * ct - else: - est = -premium - opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", reason, _now(), opt_pnl, opt["id"]), - ) - else: - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?", - ("hold_to_expiry", "orphaned_after_tp", opt["id"]), - ) - opt_pnl = -premium - - total = perp_pnl + opt_pnl - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", reason, _now(), perp_pnl, perp["id"]), - ) - # partial → closed 也走同一收口 - update_plan( - conn, - int(plan["id"]), - status="closed", - close_reason=reason, - realized_pnl_perp=round(perp_pnl, 4), - realized_pnl_options=round(opt_pnl, 4), - realized_pnl_total=round(total, 4), - stats_bucket="tp" if reason == "perp_tp" else "sl", - closed_at=_now(), - ) - _notify_end_reload(cfg, conn, int(plan["id"])) - return {"plan_id": plan["id"], "close_reason": reason, "total": total} - - -def _option_leg_pnl_after_close( - cfg: dict[str, Any], - leg: dict[str, Any], - *, - fallback: float, -) -> float: - """平仓后写腿盈亏:优先交易所历史,否则用估算.""" - ex = cfg.get("exchange_options") - pnl, _src = resolve_option_leg_realized_pnl(ex=ex, leg=leg, fallback=fallback) - return float(pnl if pnl is not None else fallback) - - -def _estimate_leg_close_pnl(leg: dict[str, Any], idx: Optional[float], bid: Optional[float]) -> float: - """残腿平仓盈亏估算:优先买一回收 − 权利金;无买一则用内在价值.""" - premium = float(leg.get("premium") or 0) - sheets = float(leg.get("size") or 1) - ct = float(leg.get("ct_mult") or 0.01) - if bid is not None and float(bid) > 0: - return float(bid) * sheets * ct - premium - if idx is None: - return -premium - strike = _sf(leg.get("strike")) or 0 - o = (leg.get("opt_type") or "").upper() - intrinsic = max(0.0, idx - strike) if o == "C" else max(0.0, strike - idx) - return intrinsic * sheets * ct - premium - - -def _tick_oo_close_rest( - cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] -) -> Optional[dict[str, Any]]: - """盈利腿已平后:残值平模式清亏损腿. - - 条件:买一回收 ≤ 本合约初始权利金×20%,且买一有流动性;失败或未达条件则下轮重试. - """ - from lib.hedge_plan.hedge_plan_option_primary_lib import option_bid_liquidity_ok - - if resolve_oo_rest_close_mode(plan) != "close_all": - return None - open_legs = _oo_option_legs(legs, statuses=("open",)) - closed_legs = _oo_option_legs(legs, statuses=("closed",)) - # 至少已平一条,且仍有残腿;避免双腿都还 open 时误清 - if len(closed_legs) < 1 or len(open_legs) < 1: - return None - reason0 = str(plan.get("close_reason") or "") - allowed_reasons = ( - "target_win_leg", - "target_up_win_leg", - "target_down_win_leg", - "profit_rr_win_leg", - "oo_rest_closing", - "", - ) - if reason0 not in allowed_reasons and not ( - len(closed_legs) >= 1 and len(open_legs) == 1 - ): - return None - - idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) - acted = False - waiting = False - for leg in list(open_legs): - inst_id = str(leg.get("inst_id") or "") - sheets = float(leg.get("size") or 1) - premium = float(leg.get("premium") or 0) - bid, bid_sz = _oo_quote_bid(cfg, inst_id) - value = _oo_leg_mark_value(leg, bid) - # 残值门槛:相对本合约初始权利金,买一回收须 ≤ 20% - if premium > 0: - if value is None: - waiting = True - continue - if value > premium * OO_LOSS_LEG_RESIDUAL_RATIO + 1e-12: - waiting = True - continue - liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets) - if not liq_ok: - waiting = True - update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing") - return { - "plan_id": plan["id"], - "msg": "残值平等待买一流动性", - "detail": liq_msg, - "waiting": True, - } - close_r = _sell_option(cfg, inst_id=inst_id, sheets=sheets) - if not close_r.get("ok"): - notify_hedge( - cfg, - build_hedge_alert_message( - title="期期残值平·亏损腿平仓失败(将重试)", - plan_id=plan.get("id"), - detail=str(close_r.get("msg") or close_r), - ), - ) - update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing") - return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True} - bid_fill = _sf(close_r.get("bid")) or bid - est = _estimate_leg_close_pnl(leg, idx, bid_fill) - pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", "oo_rest_close", _now(), round(pnl, 4), leg["id"]), - ) - leg["status"] = "closed" - leg["realized_pnl"] = round(pnl, 4) - acted = True - - if not acted: - if waiting: - update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing") - return {"plan_id": plan["id"], "msg": "残值平等待本合约权利金≤20%", "waiting": True} - return None - legs2 = get_plan_legs(conn, int(plan["id"])) - still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry")) - if still_open: - update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing") - return {"plan_id": plan["id"], "msg": "残腿部分已平,继续重试", "remaining": len(still_open)} - return _finalize_oo_all_closed( - cfg, conn, plan, legs2, reason="oo_rest_closed" - ) - - -def _after_oo_winner_closed( - cfg: dict[str, Any], - conn: Any, - plan: dict[str, Any], - open_legs: list[dict[str, Any]], - best: dict[str, Any], - *, - reason: str, - extra: Optional[dict[str, Any]] = None, -) -> dict[str, Any]: - """盈利腿已平后:残值平同轮尝试 / 到期平标记 hold_to_expiry.""" - rest_mode = resolve_oo_rest_close_mode(plan) - update_plan(conn, int(plan["id"]), close_reason=reason) - mid = dict(plan) - mid["close_reason"] = reason - mid["status"] = "active" - mid["oo_close_mode"] = rest_mode - notify_plan_end(cfg, conn, mid) - - out: dict[str, Any] = { - "plan_id": plan["id"], - "close_reason": reason, - "closed_leg": best.get("id"), - "oo_close_mode": rest_mode, - } - if extra: - out.update(extra) - - if rest_mode == "close_all": - legs2 = get_plan_legs(conn, int(plan["id"])) - rest = _tick_oo_close_rest(cfg, conn, mid, legs2) - if rest: - out["rest"] = rest - return out - - for leg in open_legs: - if int(leg.get("id") or 0) == int(best.get("id") or 0): - continue - conn.execute( - "UPDATE hedge_plan_legs SET status=? WHERE id=?", - ("hold_to_expiry", leg["id"]), - ) - return out - - -def _tick_oo_profit_rr( - cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, rr_target: float -) -> Optional[dict[str, Any]]: - """期期:任一开仓腿盈亏比(该腿盈利金额/总权利金)达目标 → 平盈利腿.""" - if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True): - return None - open_legs = _oo_option_legs(legs, statuses=("open",)) - if len(open_legs) < 2: - return None - total_prem = _oo_plan_premium_total(plan, legs) - if total_prem <= 0: - return None - - ranked: list[tuple[float, float, dict[str, Any]]] = [] - for leg in open_legs: - bid, _bid_sz = _oo_quote_bid(cfg, str(leg.get("inst_id") or "")) - rr = _oo_leg_profit_rr(leg, bid, total_premium=total_prem) - if rr is None: - continue - value = _oo_leg_mark_value(leg, bid) or 0.0 - premium = float(leg.get("premium") or 0) - pnl = value - premium - ranked.append((rr, pnl, leg)) - if not ranked: - return None - ranked.sort(key=lambda x: x[0], reverse=True) - best_rr, best_pnl, best = ranked[0] - if best_rr + 1e-12 < float(rr_target) or best_pnl <= 0: - return None - - close_r = _sell_option( - cfg, inst_id=str(best.get("inst_id") or ""), sheets=float(best.get("size") or 1) - ) - if not close_r.get("ok"): - notify_hedge( - cfg, - build_hedge_alert_message( - title="期期平盈利腿失败", - plan_id=plan.get("id"), - detail=str(close_r.get("msg") or close_r), - ), - ) - return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r} - - reason = "profit_rr_win_leg" - closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl)) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", reason, _now(), closed_pnl, best["id"]), - ) - idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) - return _after_oo_winner_closed( - cfg, - conn, - plan, - open_legs, - best, - reason=reason, - extra={ - "profit_rr": best_rr, - "rr_target": float(rr_target), - "total_premium": total_prem, - "index": idx, - }, - ) - - -def _tick_oo_target( - cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] -) -> Optional[dict[str, Any]]: - """期期:优先按盈亏比平盈利腿;旧单无 profit_rr 时回退上/下破目标价.""" - rr_target = _oo_resolve_profit_rr(plan) - if rr_target is not None: - return _tick_oo_profit_rr(cfg, conn, plan, legs, rr_target=rr_target) - - idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) - if idx is None: - return None - up = _sf(plan.get("target_price_up")) - down = _sf(plan.get("target_price_down")) - # 旧计划仅有单目标:两边都用它 - legacy = _sf(plan.get("target_price")) - if up is None and legacy is not None: - up = legacy - if down is None and legacy is not None: - down = legacy - if up is None and down is None: - return None - - hit_side: Optional[str] = None - if up is not None and idx >= up * 0.998: - hit_side = "up" - elif down is not None and idx <= down * 1.002: - hit_side = "down" - if not hit_side: - return None - if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True): - return None - open_legs = _oo_option_legs(legs, statuses=("open",)) - if len(open_legs) < 2: - return None - winners = [] - for leg in open_legs: - strike = _sf(leg.get("strike")) or 0 - o = (leg.get("opt_type") or "").upper() - intrinsic = max(0.0, idx - strike) if o == "C" else max(0.0, strike - idx) - premium = float(leg.get("premium") or 0) - pnl = intrinsic * float(leg.get("size") or 1) * float(leg.get("ct_mult") or 0.01) - premium - winners.append((pnl, leg)) - winners.sort(key=lambda x: x[0], reverse=True) - best_pnl, best = winners[0] - if best_pnl <= 0: - return None - close_r = _sell_option(cfg, inst_id=str(best.get("inst_id") or ""), sheets=float(best.get("size") or 1)) - if not close_r.get("ok"): - notify_hedge( - cfg, - build_hedge_alert_message( - title="期期平盈利腿失败", - plan_id=plan.get("id"), - detail=str(close_r.get("msg") or close_r), - ), - ) - return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r} - reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg" - closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl)) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", reason, _now(), closed_pnl, best["id"]), - ) - return _after_oo_winner_closed( - cfg, - conn, - plan, - open_legs, - best, - reason=reason, - extra={"hit_side": hit_side, "index": idx}, - ) - - -def _tick_oo_expiry( - cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] -) -> Optional[dict[str, Any]]: - """期期:剩余期权腿全部到期 → 结算合计并结束计划.""" - pending = [ - x - for x in legs - if str(x.get("leg_role") or "").startswith("option") - and str(x.get("status") or "") in ("open", "hold_to_expiry") - ] - if not pending: - # 若腿已全部 closed 但计划仍 active(异常残留)则用腿合计收口 - closed_opts = [ - x for x in legs if str(x.get("leg_role") or "").startswith("option") and x.get("status") == "closed" - ] - if len(closed_opts) < 1: - return None - total_opts = sum(float(x.get("realized_pnl") or 0) for x in closed_opts) - reason = "oo_expiry_loss" if total_opts <= 0 else "oo_expiry_win" - update_plan( - conn, - int(plan["id"]), - status="closed", - close_reason=reason, - realized_pnl_options=round(total_opts, 4), - realized_pnl_total=round(total_opts, 4), - stats_bucket=reason if reason == "oo_expiry_loss" else "oo_target", - closed_at=_now(), - ) - _notify_end_reload(cfg, conn, int(plan["id"])) - return {"plan_id": plan["id"], "close_reason": reason, "total": total_opts} - - if not all(leg_is_expired(x) for x in pending): - return None - - spot = _index_px(cfg, str(plan.get("underlying") or "ETH")) - if spot is None: - return None - - settled_sum = 0.0 - for leg in pending: - est = settle_option_leg_at_spot(leg, float(spot)) - pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est) - settled_sum += pnl - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", "expiry", _now(), round(pnl, 4), leg["id"]), - ) - - already = sum( - float(x.get("realized_pnl") or 0) - for x in legs - if str(x.get("leg_role") or "").startswith("option") and x.get("status") == "closed" - ) - total = already + settled_sum - reason = "oo_expiry_loss" if total <= 0 else "oo_expiry_win" - bucket = "oo_expiry_loss" if reason == "oo_expiry_loss" else "oo_target" - update_plan( - conn, - int(plan["id"]), - status="closed", - close_reason=reason, - realized_pnl_options=round(total, 4), - realized_pnl_total=round(total, 4), - stats_bucket=bucket, - closed_at=_now(), - ) - _notify_end_reload(cfg, conn, int(plan["id"])) - return {"plan_id": plan["id"], "close_reason": reason, "total": total, "spot": spot} - - -def _settle_orphaned_after_tp(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any]]: - """永期止盈后 hold_to_expiry 期权到期:只更新腿,不回写计划合计.""" - rows = conn.execute( - """ - SELECT l.id AS leg_id, l.plan_id, l.inst_id, l.opt_type, l.strike, l.size, l.premium, l.status, - p.underlying, p.status AS plan_status - FROM hedge_plan_legs l - JOIN hedge_plans p ON p.id = l.plan_id - WHERE l.status = 'hold_to_expiry' AND l.close_reason = 'orphaned_after_tp' - LIMIT 40 - """ - ).fetchall() - acted: list[dict[str, Any]] = [] - for row in rows: - leg = dict(row) - if not leg_is_expired(leg): - continue - spot = _index_px(cfg, str(leg.get("underlying") or "ETH")) - if spot is None: - continue - pnl_est = settle_option_leg_at_spot(leg, float(spot)) - # orphan row uses leg_id; map to id for resolver - leg_for_pnl = dict(leg) - leg_for_pnl["id"] = leg.get("leg_id") - pnl = _option_leg_pnl_after_close(cfg, leg_for_pnl, fallback=pnl_est) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", "expiry", _now(), round(pnl, 4), leg["leg_id"]), - ) - # 故意不 UPDATE hedge_plans.realized_pnl_* - acted.append( - { - "plan_id": leg["plan_id"], - "close_reason": "orphaned_option_expiry", - "leg_id": leg["leg_id"], - "leg_pnl": round(pnl, 4), - "note": "不回写计划合计", - } - ) - return acted diff --git a/lib/hedge_plan/hedge_plan_notify_lib.py b/lib/hedge_plan/hedge_plan_notify_lib.py deleted file mode 100644 index 03f8848..0000000 --- a/lib/hedge_plan/hedge_plan_notify_lib.py +++ /dev/null @@ -1,216 +0,0 @@ -"""对冲计划企业微信推送(起止必发,幂等落库标记).""" -from __future__ import annotations - -from typing import Any, Callable, Optional - -from lib.hedge_plan.hedge_plan_db import update_plan - - -def _fmt(v: Any, d: int = 2) -> str: - try: - if v is None or v == "": - return "—" - return f"{float(v):.{d}f}" - except (TypeError, ValueError): - return str(v) - - -def _type_label(plan_type: str) -> str: - return "永期对冲" if (plan_type or "") == "perp_options" else "期期对冲" - - -def _dir_label(direction: str) -> str: - d = (direction or "").lower() - if d == "long": - return "做多" - if d == "short": - return "做空" - return "—" - - -def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[str, Any]]] = None) -> str: - pt = plan.get("plan_type") or "" - lines = [ - f"🟢 对冲计划启动 #{plan.get('id')}", - f"📌 类型:{_type_label(pt)}", - f"🪙 标的:{plan.get('underlying') or '—'}", - ] - if pt == "perp_options": - lines.extend( - [ - f"📈 方向:{_dir_label(plan.get('direction') or '')}", - f"💵 开仓参考:{_fmt(plan.get('entry_mark'))}", - f"🎯 止盈:{_fmt(plan.get('tp'))}|止损:{_fmt(plan.get('sl'))}", - f"📦 永续张数:{_fmt(plan.get('perp_size'), 4)}|杠杆:{_fmt(plan.get('leverage'), 0)}x", - f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", - ] - ) - else: - rr = plan.get("profit_rr") - if rr not in (None, ""): - lines.extend( - [ - f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)", - f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", - ] - ) - else: - lines.extend( - [ - f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}" - f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}", - f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", - ] - ) - if legs: - for leg in legs: - role = leg.get("leg_role") or "" - if role == "perp": - lines.append(f"· 永续腿 {leg.get('symbol') or ''} ×{_fmt(leg.get('size'), 4)}") - else: - lines.append( - f"· {role} {(leg.get('opt_type') or '')} K{_fmt(leg.get('strike'), 0)} " - f"×{_fmt(leg.get('size'), 0)}张 {leg.get('inst_id') or ''}" - ) - lines.append("📎 独立模块推送,不进普通交易复盘") - return "\n".join(lines) - - -def build_hedge_end_message(plan: dict[str, Any]) -> str: - reason = plan.get("close_reason") or "—" - total = plan.get("realized_pnl_total") - try: - tv = float(total) if total is not None else None - except (TypeError, ValueError): - tv = None - head = "🔴" if (tv is not None and tv < 0) else "🟢" - reason_map = { - "perp_tp": "永续止盈(期权默认不平)", - "perp_sl": "永续止损(期权强制平)", - "target_win_leg": "期期已平盈利腿(中间态)", - "target_up_win_leg": "期期上破·已平盈利腿", - "target_down_win_leg": "期期下破·已平盈利腿", - "profit_rr_win_leg": "期期盈亏比达标·已平盈利腿", - "oo_rest_closing": "期期残值平·清亏损腿中", - "oo_rest_closed": "期期残值平·两腿已平", - "oo_expiry_loss": "期期到期无盈利·总亏损", - "oo_expiry_win": "期期到期仍盈利", - "expiry": "到期收口", - "manual": "人工结束", - "partial_fail": "半腿失败收尾", - "cancelled": "已取消", - } - lines = [ - f"{head} 对冲计划结束 #{plan.get('id')}", - f"📌 类型:{_type_label(plan.get('plan_type') or '')}", - f"🪙 标的:{plan.get('underlying') or '—'}", - f"📎 原因:{reason_map.get(reason, reason)}", - f"💰 合计≈U:{_fmt(total)}", - f"· 永续分项:{_fmt(plan.get('realized_pnl_perp'))} USDT", - f"· 期权分项:{_fmt(plan.get('realized_pnl_options'))} USDC(≈U 1:1)", - f"⏱ 开仓:{plan.get('opened_at') or '—'}|结束:{plan.get('closed_at') or '—'}", - ] - return "\n".join(lines) - - -def build_hedge_alert_message( - *, - title: str, - plan_id: Any = None, - detail: str = "", -) -> str: - lines = [f"⚠️ 对冲计划告警{(' #' + str(plan_id)) if plan_id else ''}", f"📌 {title}"] - if detail: - lines.append(str(detail)[:800]) - return "\n".join(lines) - - -def notify_hedge( - cfg: dict[str, Any], - content: str, -) -> bool: - send: Optional[Callable[[str], Any]] = cfg.get("send_wechat") - if not callable(send): - return False - try: - send(content) - return True - except Exception: - return False - - -def notify_plan_start( - cfg: dict[str, Any], - conn: Any, - plan: dict[str, Any], - legs: Optional[list[dict[str, Any]]] = None, -) -> bool: - if int(plan.get("wechat_start_sent") or 0): - return False - ok = notify_hedge(cfg, build_hedge_start_message(plan, legs=legs)) - if ok and plan.get("id") is not None: - update_plan(conn, int(plan["id"]), wechat_start_sent=1) - plan["wechat_start_sent"] = 1 - return ok - - -def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> bool: - if int(plan.get("wechat_end_sent") or 0): - return False - # 中间态 target_win_leg 不算正式结束推送(用告警) - if (plan.get("close_reason") or "") in ( - "target_win_leg", - "target_up_win_leg", - "target_down_win_leg", - "profit_rr_win_leg", - "oo_rest_closing", - ) and (plan.get("status") or "") != "closed": - cr = str(plan.get("close_reason") or "") - if "profit_rr" in cr: - side = "盈亏比达标" - elif "up" in cr: - side = "上破" - elif "down" in cr: - side = "下破" - else: - side = "目标" - mode = (plan.get("oo_close_mode") or "").strip().lower() - if mode in ("close_all", "全平", "残值平"): - rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)" - else: - rest_txt = "另一腿到期平(持有至到期结算)" - rr = plan.get("profit_rr") - if rr not in (None, ""): - detail = f"盈亏比 {_fmt(rr)} (盈利金额/总权利金)" - else: - detail = ( - f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}" - f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}" - ) - notify_hedge( - cfg, - build_hedge_alert_message( - title=f"期期{side}已平盈利腿 · {rest_txt}", - plan_id=plan.get("id"), - detail=detail, - ), - ) - return True - ok = notify_hedge(cfg, build_hedge_end_message(plan)) - if ok and plan.get("id") is not None: - update_plan(conn, int(plan["id"]), wechat_end_sent=1) - plan["wechat_end_sent"] = 1 - return ok - - -def notify_partial_fail(cfg: dict[str, Any], *, plan_type: str, msg: str, results: Any = None) -> bool: - detail = msg - if results: - try: - detail = f"{msg}\n路径结果:{results}"[:800] - except Exception: - pass - return notify_hedge( - cfg, - build_hedge_alert_message(title=f"{_type_label(plan_type)}半腿失败", detail=detail), - ) diff --git a/lib/hedge_plan/hedge_plan_option_primary_lib.py b/lib/hedge_plan/hedge_plan_option_primary_lib.py deleted file mode 100644 index ec82129..0000000 --- a/lib/hedge_plan/hedge_plan_option_primary_lib.py +++ /dev/null @@ -1,527 +0,0 @@ -"""永期「以期权为主」:定仓、方向映射、目标位与净利口径(纯函数为主).""" -from __future__ import annotations - -import math -import os -from typing import Any, Optional - -PREMIUM_EXEC_FACTOR = 0.95 -DEFAULT_MIN_HOURS = 36.0 -DEFAULT_STRIKE_INTERVAL = 15.0 -DEFAULT_PERP_LEVERAGE = 100 -DEFAULT_OPT_LEVERAGE_ITM_ATM = 100.0 -DEFAULT_OPT_LEVERAGE_OTM = 200.0 -DEFAULT_RATIO_ITM_ATM = 2.0 -DEFAULT_RATIO_OTM = 4.0 -OTM_LEV_FLOOR = 180.0 - - -def _sf(v: Any) -> Optional[float]: - if v is None or v == "": - return None - try: - return float(v) - except (TypeError, ValueError): - return None - - -def is_option_primary(body_or_plan: dict[str, Any] | None) -> bool: - if not body_or_plan: - return False - v = body_or_plan.get("option_primary") - if v in (True, 1, "1", "true", "yes", "on"): - return True - try: - return int(v or 0) == 1 - except (TypeError, ValueError): - return False - - -def fee_rate() -> float: - try: - return max(0.0, float(os.getenv("HEDGE_PLAN_FEE_RATE") or os.getenv("OKX_TAKER_FEE") or "0.0005")) - except (TypeError, ValueError): - return 0.0005 - - -def floor2(v: float) -> float: - """ETH 数量向下取两位小数.""" - if v <= 0: - return 0.0 - return math.floor(float(v) * 100.0 + 1e-12) / 100.0 - - -def opt_type_for_view(direction: str) -> str: - """看法做多→Call,做空→Put.""" - return "P" if str(direction or "").strip().lower() == "short" else "C" - - -def perp_direction_for_view(direction: str) -> str: - """看法做多→永续空,做空→永续多.""" - return "long" if str(direction or "").strip().lower() == "short" else "short" - - -def default_opt_leverage(moneyness: str) -> float: - m = (moneyness or "").strip().lower() - return DEFAULT_OPT_LEVERAGE_OTM if m == "otm" else DEFAULT_OPT_LEVERAGE_ITM_ATM - - -def default_ratio(moneyness: str) -> float: - m = (moneyness or "").strip().lower() - return DEFAULT_RATIO_OTM if m == "otm" else DEFAULT_RATIO_ITM_ATM - - -def effective_min_opt_leverage(moneyness: str, configured: Any) -> float: - cfg = _sf(configured) - base = cfg if cfg is not None and cfg > 0 else default_opt_leverage(moneyness) - if (moneyness or "").strip().lower() == "otm": - return max(base, OTM_LEV_FLOOR) - return base - - -def hours_to_expiry_from_ms(exp_ms: Any, *, now_ms: Optional[float] = None) -> Optional[float]: - exp = _sf(exp_ms) - if exp is None or exp <= 0: - return None - # OKX exp 多为毫秒 - if exp < 1e12: - exp *= 1000.0 - now = now_ms if now_ms is not None else __import__("time").time() * 1000.0 - return (exp - now) / 3600000.0 - - -def target_hit(*, view_side: str, index_px: float, strike: float, points: float) -> bool: - """相对 K 的点数目标:做多 index≥K+N;做空 index≤K−N.点数须 >0.""" - n = float(points or 0) - k = float(strike) - s = float(index_px) - if n <= 0 or k <= 0 or s <= 0: - return False - side = str(view_side or "").strip().lower() - if side == "short": - return s <= (k - n) - return s >= (k + n) - - -def option_bid_liquidity_ok(bid: Any, bid_sz: Any, *, need_sheets: float = 0) -> tuple[bool, str]: - b = _sf(bid) - if b is None or b <= 0: - return False, "暂无买一报价,无法平期权" - sz = _sf(bid_sz) - if sz is not None and sz <= 0: - return False, "买一深度为 0,无法平期权" - need = float(need_sheets or 0) - if need > 0 and sz is not None and sz + 1e-12 < need: - return False, f"买一深度不足(需 {need:g} 张,买一 {sz:g})" - return True, "" - - -def size_from_premium( - *, - premium_budget: float, - ask: float, - ct_mult: float, - ratio: float, - contract_size: float, - exec_factor: float = PREMIUM_EXEC_FACTOR, -) -> dict[str, Any]: - """权利金×0.95 → ETH 两位小数 → 期权张 → 永续跟比例.""" - budget = float(premium_budget or 0) - a = float(ask or 0) - ct = float(ct_mult or 0.01) - r = float(ratio or 0) - cs = float(contract_size or 0.01) - usable = budget * float(exec_factor or PREMIUM_EXEC_FACTOR) - if budget <= 0 or a <= 0 or ct <= 0 or r <= 0 or cs <= 0: - return { - "ok": False, - "msg": "定仓参数无效", - "usable_premium": round(usable, 4), - "eth_qty": 0.0, - "sheets": 0.0, - "perp_eth": 0.0, - "contracts": 0.0, - } - # ask 为每 1 币权利金;ETH 数量 = usable / ask - eth_qty = floor2(usable / a) - if eth_qty <= 0: - return { - "ok": False, - "msg": "权利金不足以买入 0.01 ETH 名义期权", - "usable_premium": round(usable, 4), - "eth_qty": 0.0, - "sheets": 0.0, - "perp_eth": 0.0, - "contracts": 0.0, - } - sheets = eth_qty / ct - # 张数向下取整到整数张(OKX 期权常见整张) - sheets_i = float(math.floor(sheets + 1e-12)) - if sheets_i <= 0: - return { - "ok": False, - "msg": "换算期权张数不足 1 张", - "usable_premium": round(usable, 4), - "eth_qty": eth_qty, - "sheets": 0.0, - "perp_eth": 0.0, - "contracts": 0.0, - } - # 用整张回写 ETH,保持与下单一致 - eth_qty = round(sheets_i * ct, 2) - perp_eth = eth_qty / r - contracts = perp_eth / cs - premium_est = a * sheets_i * ct - return { - "ok": True, - "msg": "", - "usable_premium": round(usable, 4), - "eth_qty": eth_qty, - "sheets": sheets_i, - "perp_eth": round(perp_eth, 6), - "contracts": contracts, - "premium_est": round(premium_est, 4), - "ratio": r, - "exec_factor": float(exec_factor or PREMIUM_EXEC_FACTOR), - } - - -def estimate_combo_net_pnl( - *, - view_side: str, - strike: float, - index_px: float, - ask_open: float, - bid: float, - sheets: float, - ct_mult: float, - perp_direction: str, - perp_entry: float, - perp_mark: float, - contracts: float, - contract_size: float, - fee: Optional[float] = None, -) -> dict[str, Any]: - """组合净利(扣费);平仓/卖出手续费按买入费率估算.""" - fr = fee if fee is not None else fee_rate() - ct = float(ct_mult or 0.01) - sh = float(sheets or 0) - a = float(ask_open or 0) - b = float(bid or 0) - premium = a * sh * ct - opt_proceeds = b * sh * ct - opt_open_fee = premium * fr - opt_close_fee = opt_proceeds * fr # 卖出费用按买入费率 - opt_net = opt_proceeds - premium - opt_open_fee - opt_close_fee - - coins = float(contracts or 0) * float(contract_size or 0.01) - entry = float(perp_entry or 0) - mark = float(perp_mark or 0) - pd = str(perp_direction or "").strip().lower() - if pd == "short": - perp_gross = (entry - mark) * coins - else: - perp_gross = (mark - entry) * coins - perp_notional_open = abs(entry * coins) - perp_notional_close = abs(mark * coins) - perp_open_fee = perp_notional_open * fr - perp_close_fee = perp_notional_close * fr - perp_net = perp_gross - perp_open_fee - perp_close_fee - total = opt_net + perp_net - return { - "opt_net": round(opt_net, 4), - "perp_net": round(perp_net, 4), - "net": round(total, 4), - "fee_rate": fr, - "premium": round(premium, 4), - "opt_proceeds": round(opt_proceeds, 4), - } - - -def validate_option_primary_moneyness( - *, - opt_type: str, - strike: Any, - index_px: Any, - ask: Any = None, - moneyness: str = "atm", - strike_interval: Any = DEFAULT_STRIKE_INTERVAL, - min_hours: Any = DEFAULT_MIN_HOURS, - hours_to_expiry: Any = None, - min_opt_leverage: Any = None, -) -> Optional[str]: - from lib.hedge_plan.hedge_plan_moneyness_lib import ( - classify_moneyness, - is_atm_or_otm, - is_itm_or_atm, - normalize_opt_type, - ) - - o = normalize_opt_type(opt_type) - k = _sf(strike) - s = _sf(index_px) - if o not in ("C", "P"): - return "期权类型无效" - if k is None or s is None or s <= 0: - return "行权价或指数无效" - m_want = (moneyness or "atm").strip().lower() - m_got = classify_moneyness(opt_type=o, strike=k, index_px=s) - if m_want == "itm": - if not is_itm_or_atm(opt_type=o, strike=k, index_px=s): - return "所选须为实值或平值" - elif m_want == "atm": - # 平值:距指数在间隔内即可(不强制 classify==atm) - pass - elif m_want == "otm": - if m_got == "itm": - return "虚值模式不可选实值" - if not is_atm_or_otm(opt_type=o, strike=k, index_px=s): - return "虚值模式须选虚值或平值档" - else: - return "期权类型(实/平/虚)无效" - - interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL) - if interval > 0 and abs(k - s) > interval + 1e-9: - return f"行权价偏离指数 {abs(k - s):.1f} > 间隔 {interval:.0f}" - - min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS) - h = _sf(hours_to_expiry) - if min_h > 0 and h is not None and h < min_h: - return f"剩余到期约 {h:.1f}h,低于最短 {min_h:.0f}h" - - a = _sf(ask) - min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else m_got or "atm", min_opt_leverage) - if min_lev > 0 and a is not None and a > 0: - lev = s / a - if lev < min_lev: - return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}" - return None - - -def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]: - """盯盘启动校验:只要参数,不要求已选具体合约.""" - need = ( - "direction", - "exchange_symbol", - "premium_budget", - "option_target_points", - "perp_target_points", - "option_perp_ratio", - "option_leverage", - ) - for k in need: - if body.get(k) in (None, ""): - return f"缺少字段: {k}" - try: - if float(body["premium_budget"]) <= 0: - return "权利金须大于 0" - if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0: - return "目标位点数须大于 0" - if float(body["option_perp_ratio"]) <= 0: - return "期权永续比例须大于 0" - if float(body["option_leverage"]) <= 0: - return "期权杠杆须大于 0" - lev_perp = _sf(body.get("leverage")) - if lev_perp is not None and lev_perp <= 0: - return "永续杠杆须大于 0" - except (TypeError, ValueError): - return "数值字段无效" - direction = str(body.get("direction") or "").strip().lower() - if direction not in ("long", "short"): - return "方向须为 long 或 short" - moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower() - if moneyness not in ("itm", "atm", "otm"): - return "期权类型(实/平/虚)无效" - return None - - -def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]: - need = ( - "direction", - "contracts", - "opt_inst_id", - "sheets", - "exchange_symbol", - "premium_budget", - "option_target_points", - "perp_target_points", - "option_perp_ratio", - ) - for k in need: - if body.get(k) in (None, ""): - return f"缺少字段: {k}" - try: - if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0: - return "张数必须大于 0" - if float(body["premium_budget"]) <= 0: - return "权利金须大于 0" - if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0: - return "目标位点数须大于 0" - if float(body["option_perp_ratio"]) <= 0: - return "期权永续比例须大于 0" - except (TypeError, ValueError): - return "数值字段无效" - direction = str(body.get("direction") or "").strip().lower() - if direction not in ("long", "short"): - return "方向须为 long 或 short" - opt_type = str(body.get("opt_type") or "").strip().upper() - if not opt_type: - inst = str(body.get("opt_inst_id") or "") - if inst.upper().endswith("-P"): - opt_type = "P" - elif inst.upper().endswith("-C"): - opt_type = "C" - want = opt_type_for_view(direction) - if opt_type != want: - return f"以期权为主时做{'多' if direction == 'long' else '空'}须用 {'Call' if want == 'C' else 'Put'}" - moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower() - from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst - - strike = body.get("strike") - if strike in (None, ""): - strike = parse_strike_from_inst(str(body.get("opt_inst_id") or "")) - index_px = body.get("index_px") or body.get("entry") - return validate_option_primary_moneyness( - opt_type=opt_type, - strike=strike, - index_px=index_px, - ask=body.get("ask"), - moneyness=moneyness, - strike_interval=body.get("strike_interval", DEFAULT_STRIKE_INTERVAL), - min_hours=body.get("min_option_hours", DEFAULT_MIN_HOURS), - hours_to_expiry=body.get("hours_to_expiry"), - min_opt_leverage=body.get("option_leverage") or body.get("min_opt_leverage"), - ) - - -def pick_option_primary_candidate( - chain: dict[str, Any], - *, - direction: str, - moneyness: str = "otm", - strike_interval: Any = DEFAULT_STRIKE_INTERVAL, - min_hours: Any = DEFAULT_MIN_HOURS, - min_opt_leverage: Any = None, -) -> Optional[dict[str, Any]]: - """从期权链挑最近达标合约(间隔+虚实值+杠杆门).""" - from lib.hedge_plan.hedge_plan_moneyness_lib import classify_moneyness - - want = opt_type_for_view(direction) - m_want = (moneyness or "otm").strip().lower() - interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL) - min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS) - try: - idx = float(chain.get("index_px") or 0) - except (TypeError, ValueError): - idx = 0.0 - if idx <= 0: - return None - - best: Optional[dict[str, Any]] = None - best_dist: Optional[float] = None - for exp in chain.get("expiries") or []: - h = hours_to_expiry_from_ms(exp.get("exp_time")) - if min_h > 0 and h is not None and h < min_h: - continue - for c in exp.get("contracts") or []: - if str(c.get("opt_type") or "").upper() != want: - continue - try: - k = float(c.get("strike") or 0) - ask = float(c.get("ask") or 0) - except (TypeError, ValueError): - continue - if k <= 0 or ask <= 0: - continue - if interval > 0 and abs(k - idx) > interval + 1e-9: - continue - m_got = classify_moneyness(opt_type=want, strike=k, index_px=idx) - if m_want == "itm" and m_got not in ("itm", "atm"): - continue - if m_want == "atm" and m_got != "atm": - continue - if m_want == "otm" and m_got == "itm": - continue - min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else (m_got or "atm"), min_opt_leverage) - if min_lev > 0 and idx / ask < min_lev - 1e-9: - continue - dist = abs(k - idx) - if best is None or best_dist is None or dist < best_dist: - best = { - **dict(c), - "hours_to_expiry": h, - "exp_time": exp.get("exp_time"), - "moneyness": m_got, - "index_px": idx, - "leverage": round(idx / ask, 1), - } - best_dist = dist - return best - - -def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]: - """情景:期权目标 / 永续目标粗估净利.""" - view = str(body.get("direction") or "long").lower() - strike = float(body["strike"]) - n = float(body.get("option_target_points") or 0) - m = float(body.get("perp_target_points") or 0) - ask = float(body.get("ask") or 0) - sheets = float(body.get("sheets") or 0) - ct = float(body.get("ct_mult") or 0.01) - contracts = float(body.get("contracts") or 0) - cs = float(body.get("contract_size") or 0.01) - entry = float(body.get("entry") or body.get("index_px") or 0) - perp_dir = perp_direction_for_view(view) - # 粗估到点时期权卖价:按内在价值近似(下限 0) - def intrinsic(spot: float) -> float: - o = opt_type_for_view(view) - if o == "C": - return max(0.0, spot - strike) - return max(0.0, strike - spot) - - scenarios = [] - for label, pts, reason in ( - ("期权目标", n, "opt_target_points"), - ("永续目标", m, "perp_target_points"), - ): - spot = strike + pts if view != "short" else strike - pts - bid_est = max(ask * 0.5, intrinsic(spot) * 0.85) # 保守估价 - net = estimate_combo_net_pnl( - view_side=view, - strike=strike, - index_px=spot, - ask_open=ask, - bid=bid_est, - sheets=sheets, - ct_mult=ct, - perp_direction=perp_dir, - perp_entry=entry, - perp_mark=spot, - contracts=contracts, - contract_size=cs, - ) - scenarios.append( - { - "label": label, - "reason": reason, - "index": spot, - "perp_pnl": net["perp_net"], - "options_pnl": net["opt_net"], - "total": net["net"], - "note": "扣费净利估价;平仓费按买入费率", - } - ) - premium = ask * sheets * ct - return { - "plan_type": "perp_options", - "option_primary": True, - "summary": { - "premium_paid": round(premium, 4), - "usable_premium": round(float(body.get("premium_budget") or 0) * PREMIUM_EXEC_FACTOR, 4), - "opt_target_total": scenarios[0]["total"] if scenarios else None, - "perp_target_total": scenarios[1]["total"] if len(scenarios) > 1 else None, - "perp_direction": perp_dir, - "opt_type": opt_type_for_view(view), - }, - "scenarios": scenarios, - } diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py deleted file mode 100644 index f25b590..0000000 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ /dev/null @@ -1,1346 +0,0 @@ -"""对冲计划开仓/平仓编排(可 dry_run 校验下单路径).""" -from __future__ import annotations - -import json -import os -from datetime import datetime, timezone -from typing import Any, Callable, Optional - - -def _now() -> str: - return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") - - -def _env_bool(key: str, default: bool = False) -> bool: - raw = (os.getenv(key) or "").strip().lower() - if not raw: - return default - return raw in ("1", "true", "yes", "on") - - -def open_order_mode() -> str: - v = (os.getenv("HEDGE_PLAN_OPEN_ORDER") or "options_first").strip().lower() - return v if v in ("options_first", "perp_first") else "options_first" - - -def manual_complete_on_partial() -> bool: - """半腿失败后挂 partial 并手动补开(默认 true).""" - return _env_bool("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", True) - - -def partial_auto_close_enabled() -> bool: - """手动补开开启时强制关闭自动平,避免吃买卖价差.""" - if manual_complete_on_partial(): - return False - return _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True) - - -def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: - """永期下单路径清单(不交易).""" - from lib.hedge_plan.hedge_plan_option_primary_lib import ( - is_option_primary, - perp_direction_for_view, - ) - - opt_primary = is_option_primary(body) - mode = "options_first" if opt_primary else open_order_mode() - view = str(body.get("direction") or "long") - perp_dir = perp_direction_for_view(view) if opt_primary else view - opt = { - "step": "options_buy_limit", - "account": "options", - "inst_id": body.get("opt_inst_id"), - "sheets": float(body.get("sheets") or 1), - "side": "buy", - "price_hint": "ask", - } - perp = { - "step": "perp_market_open", - "account": "swap", - "symbol": body.get("exchange_symbol"), - "direction": perp_dir, - "contracts": float(body.get("contracts") or 0), - "tp": None if opt_primary else body.get("tp"), - "sl": None if opt_primary else body.get("sl"), - "attach_tpsl": False if opt_primary else True, - "option_primary": opt_primary, - "view_side": view, - } - return [opt, perp] if mode == "options_first" else [perp, opt] - - -def build_oo_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: - return [ - { - "step": "options_buy_limit", - "account": "options", - "leg": "a", - "inst_id": (body.get("leg_a") or {}).get("inst_id"), - "sheets": float((body.get("leg_a") or {}).get("sheets") or 1), - "side": "buy", - "price_hint": "ask", - }, - { - "step": "options_buy_limit", - "account": "options", - "leg": "b", - "inst_id": (body.get("leg_b") or {}).get("inst_id"), - "sheets": float((body.get("leg_b") or {}).get("sheets") or 1), - "side": "buy", - "price_hint": "ask", - }, - ] - - -def _option_open_fill_timeout_sec() -> float: - try: - return max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12")) - except (TypeError, ValueError): - return 12.0 - - -def _buy_option( - cfg: dict[str, Any], - *, - inst_id: str, - sheets: float, - dry_run: bool, -) -> dict[str, Any]: - from lib.exchange.okx_options_lib import ( - cap_option_buy_sheets_to_ask_depth, - option_buy_liquidity_ok, - wait_option_order_full_fill, - ) - - ex = cfg.get("exchange_options") - quote_fn = cfg.get("quote_option_contract") - place_fn = cfg.get("place_option_limit_order") - td_buy = cfg.get("td_mode_for_option_buy") - if not inst_id: - return {"ok": False, "msg": "缺少期权合约"} - if not callable(quote_fn) or ex is None: - return {"ok": False, "msg": "期权报价能力未就绪"} - q = quote_fn(ex, inst_id) - if not q.get("ok"): - return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q} - ask = q.get("ask") - ask_sz = q.get("ask_sz") - can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) - if not can_open: - return { - "ok": False, - "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", - "quote": q, - "mark": q.get("mark"), - "ref_ask": q.get("ref_ask"), - "can_open": False, - } - from lib.options.options_position_limit_lib import option_position_limit_block_msg - - pos_limit_msg = option_position_limit_block_msg( - ex, - opening_inst_id=inst_id, - fetch_positions=cfg.get("fetch_option_positions"), - ) - if pos_limit_msg: - return {"ok": False, "msg": pos_limit_msg, "quote": q, "can_open": False} - sheets_i = max(1, int(round(float(sheets)))) - requested_sheets = sheets_i - capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets_i, ask_sz, min_sz=1) - if capped is None: - return {"ok": False, "msg": cap_msg or "卖一深度不足,无法买入", "quote": q} - if int(capped) < requested_sheets: - return { - "ok": False, - "msg": f"卖一深度仅 {int(capped)} 张,不足请求 {requested_sheets} 张,拒绝缩量成交", - "quote": q, - "can_open": False, - "requested_sheets": requested_sheets, - "ask_sz": ask_sz, - } - sheets_i = int(capped) - ct_mult = float(q.get("ct_mult") or 0.01) - premium = float(ask) * sheets_i * ct_mult - if dry_run: - return { - "ok": True, - "dry_run": True, - "inst_id": inst_id, - "sheets": sheets_i, - "ask": float(ask), - "ask_sz": float(ask_sz), - "premium": premium, - "ct_mult": ct_mult, - "tick_sz": q.get("tick_sz"), - "meta": q.get("meta") or {}, - "strike": q.get("strike"), - "exp_time": q.get("exp_time"), - "opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), - "can_open": True, - } - if not callable(place_fn): - return {"ok": False, "msg": "期权限价下单未注入"} - td = "isolated" - if callable(td_buy): - td = td_buy(cfg.get("options_td_mode") or "isolated") - # IOC:能成交多少成交多少,剩余立即撤销;再校验是否完全成交 - order = place_fn( - ex, - inst_id=inst_id, - side="buy", - sheets=sheets_i, - price=float(ask), - td_mode=td, - tick_sz=q.get("tick_sz"), - ord_type="ioc", - ) - if not order.get("ok"): - return order - ord_id = str((order.get("data") or {}).get("ordId") or "").strip() - if not ord_id: - return {"ok": False, "msg": "下单成功但未返回订单号", "order": order} - fill = wait_option_order_full_fill( - ex, - inst_id=inst_id, - ord_id=ord_id, - need_sheets=sheets_i, - timeout_sec=_option_open_fill_timeout_sec(), - cancel_on_timeout=True, - ) - if not fill.get("ok"): - filled_n = int(fill.get("filled_sheets") or 0) - orphan_close = None - if filled_n > 0 and not dry_run: - # 部分成交后撤单:尝试立刻平掉已成交,避免孤儿多头 - try: - orphan_close = _sell_option(cfg, inst_id=inst_id, sheets=float(filled_n)) - except Exception as e: - orphan_close = {"ok": False, "msg": str(e)} - return { - "ok": False, - "msg": fill.get("msg") or "未完全成交,开仓失败", - "inst_id": inst_id, - "sheets": sheets_i, - "ask": float(ask), - "exchange_ord_id": ord_id, - "filled_sheets": filled_n, - "orphan_close": orphan_close, - "order": order, - "fill": fill, - "can_open": False, - } - fill_px = float(fill.get("avg_px") or ask) - filled_n = int(fill.get("filled_sheets") or sheets_i) - premium = fill_px * filled_n * ct_mult - return { - "ok": True, - "inst_id": inst_id, - "sheets": filled_n, - "ask": fill_px, - "ask_sz": float(ask_sz), - "premium": premium, - "ct_mult": ct_mult, - "tick_sz": q.get("tick_sz"), - "meta": q.get("meta") or {}, - "strike": q.get("strike"), - "exp_time": q.get("exp_time"), - "opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), - "exchange_ord_id": ord_id, - "order": order, - "fill": fill, - "can_open": True, - } - - -def _open_perp( - cfg: dict[str, Any], - *, - symbol: str, - direction: str, - contracts: float, - leverage: int, - tp: Optional[float], - sl: Optional[float], - dry_run: bool, - attach_tpsl: bool = True, -) -> dict[str, Any]: - if not symbol or contracts <= 0: - return {"ok": False, "msg": "永续符号或张数无效"} - amount = float(contracts) - to_prec = cfg.get("amount_to_precision") - ex = cfg.get("exchange") - if callable(to_prec) and ex is not None: - try: - amount = float(to_prec(symbol, amount)) - except Exception: - pass - if amount <= 0: - return {"ok": False, "msg": "张数经精度舍入后为 0"} - use_tpsl = bool(attach_tpsl) and tp is not None and sl is not None - tp_v = float(tp) if use_tpsl else None - sl_v = float(sl) if use_tpsl else None - if dry_run: - return { - "ok": True, - "dry_run": True, - "symbol": symbol, - "direction": direction, - "contracts": amount, - "leverage": leverage, - "tp": tp_v, - "sl": sl_v, - "attach_tpsl": use_tpsl, - } - ensure = cfg.get("ensure_okx_live_ready") - if callable(ensure): - ok, msg = ensure() - if not ok: - return {"ok": False, "msg": msg or "实盘未就绪"} - place = cfg.get("place_exchange_order") - if not callable(place): - return {"ok": False, "msg": "永续下单函数未注入"} - try: - order = place( - symbol, - direction, - amount, - leverage, - stop_loss=sl_v, - take_profit=tp_v, - ) - except Exception as e: - return {"ok": False, "msg": f"永续开仓失败: {e}"} - return { - "ok": True, - "symbol": symbol, - "direction": direction, - "contracts": amount, - "leverage": leverage, - "tp": tp_v, - "sl": sl_v, - "attach_tpsl": use_tpsl, - "order": order, - "exchange_ord_id": str((order or {}).get("id") or (order or {}).get("info", {}).get("ordId") or ""), - } - - -def _close_perp( - cfg: dict[str, Any], - *, - symbol: str, - direction: str, - contracts: float, - dry_run: bool = False, -) -> dict[str, Any]: - """市价平永续(reduce-only);优先用注入的 close_exchange_order.""" - if not symbol: - return {"ok": False, "msg": "永续符号无效"} - if dry_run: - return { - "ok": True, - "dry_run": True, - "symbol": symbol, - "direction": direction, - "contracts": float(contracts or 0), - } - close_fn = cfg.get("close_exchange_order") - if callable(close_fn): - try: - order = close_fn( - { - "exchange_symbol": symbol, - "direction": direction, - "order_amount": float(contracts or 0), - "symbol": symbol, - } - ) - return {"ok": True, "symbol": symbol, "direction": direction, "order": order} - except Exception as e: - return {"ok": False, "msg": f"永续平仓失败: {e}"} - # 回退:对向市价 reduce-only(若注入了 place + 支持) - place = cfg.get("place_exchange_order") - if not callable(place): - return {"ok": False, "msg": "永续平仓函数未注入"} - try: - # 无 TP/SL 的对向单;依赖交易所 reduceOnly 由 place 实现不保证,优先 close_exchange_order - side_dir = "short" if str(direction).lower() == "long" else "long" - order = place(symbol, side_dir, float(contracts or 0), int(cfg.get("alt_leverage") or 5), None, None) - return {"ok": True, "symbol": symbol, "direction": direction, "order": order, "note": "fallback_place"} - except Exception as e: - return {"ok": False, "msg": f"永续平仓失败: {e}"} - - -def _sell_option( - cfg: dict[str, Any], - *, - inst_id: str, - sheets: float, - dry_run: bool = False, -) -> dict[str, Any]: - """平期权:走买一限价 + 验仓;仅 fully_closed/already_flat 视为成功. - - 对冲强平/目标平仓不启用 2× 回收门控(require_recycle_gate=False). - """ - from lib.exchange.okx_options_lib import fetch_option_book_depth, fetch_option_positions - from lib.options.options_close_exec_lib import close_option_by_bid1 - - ex = cfg.get("exchange_options") - quote_fn = cfg.get("quote_option_contract") - if not inst_id: - return {"ok": False, "msg": "缺少期权合约"} - if not callable(quote_fn) or ex is None: - return {"ok": False, "msg": "期权报价能力未就绪"} - q = quote_fn(ex, inst_id) - bid = q.get("bid") if q.get("ok") else None - if bid is None or float(bid) <= 0: - return {"ok": False, "msg": "暂无买一价,无法平期权"} - sheets_i = max(1, int(round(float(sheets)))) - if dry_run: - return { - "ok": True, - "dry_run": True, - "inst_id": inst_id, - "sheets": sheets_i, - "bid": float(bid), - "fully_closed": True, - } - if not callable(cfg.get("place_option_limit_order")): - return {"ok": False, "msg": "期权平仓未注入"} - close_cfg = dict(cfg) - if not callable(close_cfg.get("fetch_option_positions")): - close_cfg["fetch_option_positions"] = fetch_option_positions - if not callable(close_cfg.get("fetch_option_book_depth")): - close_cfg["fetch_option_book_depth"] = fetch_option_book_depth - if "td_mode" not in close_cfg: - close_cfg["td_mode"] = close_cfg.get("options_td_mode") or "isolated" - result = close_option_by_bid1( - close_cfg, - ex, - inst_id, - sheets=sheets_i, - require_recycle_gate=False, - ) - out = dict(result or {}) - if out.get("already_flat"): - # 二次验仓,避免一次空列表误判已平 - import time as _time - - _time.sleep(0.35) - try: - from lib.exchange.okx_options_lib import invalidate_option_positions_cache - - invalidate_option_positions_cache() - except Exception: - pass - rows2 = close_cfg["fetch_option_positions"](ex) - if rows2 is None: - return {"ok": False, "msg": "二次验仓失败,未确认是否已平", "fully_closed": False} - still = next((p for p in rows2 if str(p.get("instId")) == inst_id), None) - still_sz = 0.0 - if still is not None: - try: - still_sz = abs(float(still.get("availPos") or still.get("pos") or 0)) - except (TypeError, ValueError): - still_sz = 0.0 - if still is not None and still_sz >= 1: - return { - "ok": False, - "msg": "二次验仓仍有持仓,拒绝 already_flat", - "fully_closed": False, - } - out["ok"] = True - out["fully_closed"] = True - out.setdefault("bid", float(bid)) - return out - if not out.get("ok"): - out.setdefault("bid", float(bid)) - return out - if not out.get("fully_closed"): - return { - "ok": False, - "msg": out.get("msg") or "期权尚未完全平仓,将下轮重试", - "bid": out.get("locked_bid_px") or float(bid), - "fully_closed": False, - "partial": True, - "close": out, - } - out["bid"] = out.get("locked_bid_px") or float(bid) - out["fully_closed"] = True - return out - - -def _notify_partial(cfg: dict[str, Any], plan_type: str, msg: str, results: list[dict[str, Any]]) -> None: - try: - from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail - - notify_partial_fail(cfg, plan_type=plan_type, msg=msg, results=results) - except Exception: - pass - - -def _park_partial( - cfg: dict[str, Any], - *, - plan_type: str, - body: dict[str, Any], - missing_leg: str, - msg: str, - path: list[dict[str, Any]], - results: list[dict[str, Any]], - persist: Optional[Callable[..., Any]], - dry_run: bool, - **filled: Any, -) -> dict[str, Any]: - """半腿失败:保留已成腿,挂 partial 供手动补开.""" - if not dry_run: - _notify_partial(cfg, plan_type, msg, results) - out: dict[str, Any] = { - "ok": True, - "partial": True, - "status": "partial", - "dry_run": dry_run, - "plan_type": plan_type, - "missing_leg": missing_leg, - "msg": msg, - "path": path, - "results": results, - "opened_at": _now(), - **filled, - } - if persist and not dry_run: - out["plan_id"] = persist(out, body) - return out - - -def _hedge_budget_buffer(cfg: dict[str, Any] | None = None) -> float: - """对冲专用预算缓冲;默认 0.95.与 OKX_OPTIONS_BUDGET_BUFFER 独立.""" - raw = None - if cfg is not None: - raw = cfg.get("budget_buffer") - if raw is None or raw == "": - raw = os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95" - try: - buf = float(raw) - except (TypeError, ValueError): - buf = 0.95 - if buf <= 0: - buf = 0.95 - if buf > 1: - buf = 1.0 - return float(buf) - - -def _oo_bias_settings(cfg: dict[str, Any] | None = None) -> tuple[str, float]: - from lib.hedge_plan.hedge_plan_calc_lib import _clamp_oo_bias_ratio, _normalize_oo_bias_split_by - - split = None - ratio = None - if cfg is not None: - split = cfg.get("oo_bias_split_by") - ratio = cfg.get("oo_bias_ratio") - if split in (None, ""): - split = os.getenv("HEDGE_PLAN_OO_BIAS_SPLIT_BY") or "budget" - if ratio in (None, ""): - ratio = os.getenv("HEDGE_PLAN_OO_BIAS_RATIO") or "0.7" - return _normalize_oo_bias_split_by(split), _clamp_oo_bias_ratio(ratio) - - -def refresh_oo_sizing_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: - """启动前再拉两腿卖一,按对冲预算缓冲重算张数;就地写回 body.leg_*. - - 方案 A:成交价与张数均基于点击启动瞬间的最新卖一/余额. - """ - from lib.exchange.okx_options_lib import fetch_options_trading_usdc, option_buy_liquidity_ok - from lib.hedge_plan.hedge_plan_calc_lib import resolve_oo_budget_usdc, suggest_oo_sheets - - leg_a = dict(body.get("leg_a") or {}) - leg_b = dict(body.get("leg_b") or {}) - inst_a = str(leg_a.get("inst_id") or "").strip() - inst_b = str(leg_b.get("inst_id") or "").strip() - if not inst_a or not inst_b: - return {"ok": False, "msg": "缺少期权合约"} - quote_fn = cfg.get("quote_option_contract") - ex = cfg.get("exchange_options") - if not callable(quote_fn) or ex is None: - return {"ok": False, "msg": "期权报价能力未就绪"} - - qa = quote_fn(ex, inst_a) - if not qa.get("ok"): - return {"ok": False, "msg": qa.get("msg") or "腿A报价失败", "quote_a": qa} - qb = quote_fn(ex, inst_b) - if not qb.get("ok"): - return {"ok": False, "msg": qb.get("msg") or "腿B报价失败", "quote_b": qb} - - for tag, q in (("A", qa), ("B", qb)): - can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz")) - if not can_open: - return { - "ok": False, - "msg": f"腿{tag}: {block_msg or '暂无卖一深度,无法买入'}", - "quote_a": qa, - "quote_b": qb, - } - - trading = fetch_options_trading_usdc(ex) - buf = _hedge_budget_buffer(cfg) - budget_info = resolve_oo_budget_usdc( - trading_usdc=trading, - trade_budget_usdc=cfg.get("trade_budget_usdc"), - buffer_ratio=buf, - ) - if not budget_info.get("ok"): - return { - "ok": False, - "msg": budget_info.get("msg") or "可用预算不足", - "budget": budget_info, - "quote_a": qa, - "quote_b": qb, - } - - mode = str(body.get("oo_sheets_mode") or "same_sheets") - split_by, bias_ratio = _oo_bias_settings(cfg) - opt_a = str( - leg_a.get("opt_type") - or (qa.get("meta") or {}).get("optType") - or qa.get("opt_type") - or "" - ) - opt_b = str( - leg_b.get("opt_type") - or (qb.get("meta") or {}).get("optType") - or qb.get("opt_type") - or "" - ) - sug = suggest_oo_sheets( - mode=mode, - budget_usdc=float(budget_info["budget_usdc"]), - ask_a=float(qa["ask"]), - ct_mult_a=float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01), - ask_sz_a=qa.get("ask_sz"), - opt_type_a=opt_a, - ask_b=float(qb["ask"]), - ct_mult_b=float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01), - ask_sz_b=qb.get("ask_sz"), - opt_type_b=opt_b, - bias_split_by=split_by, - bias_ratio=bias_ratio, - ) - if not sug.get("ok"): - return { - "ok": False, - "msg": sug.get("msg") or "按最新卖一无法建议张数", - "sizing": sug, - "budget": budget_info, - "quote_a": qa, - "quote_b": qb, - } - - prev_a = leg_a.get("sheets") - prev_b = leg_b.get("sheets") - leg_a["sheets"] = int(sug["sheets_a"]) - leg_a["ask"] = float(qa["ask"]) - leg_a["ask_sz"] = qa.get("ask_sz") - leg_a["ct_mult"] = float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01) - if opt_a: - leg_a["opt_type"] = opt_a - leg_b["sheets"] = int(sug["sheets_b"]) - leg_b["ask"] = float(qb["ask"]) - leg_b["ask_sz"] = qb.get("ask_sz") - leg_b["ct_mult"] = float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01) - if opt_b: - leg_b["opt_type"] = opt_b - body["leg_a"] = leg_a - body["leg_b"] = leg_b - return { - "ok": True, - "buffer_ratio": buf, - "budget": budget_info, - "sizing": sug, - "quote_a": qa, - "quote_b": qb, - "prev_sheets_a": prev_a, - "prev_sheets_b": prev_b, - "sheets_a": int(sug["sheets_a"]), - "sheets_b": int(sug["sheets_b"]), - "ask_a": float(qa["ask"]), - "ask_b": float(qb["ask"]), - "premium_est": sug.get("premium_est"), - "msg": ( - f"已按最新卖一重算: A {sug['sheets_a']}张@{qa['ask']} + " - f"B {sug['sheets_b']}张@{qb['ask']} · 预估 {sug.get('premium_est')}U" - ), - } - - -def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: - """永期启动前再拉卖一;保险模式张数沿用页面;期权为主时按权利金×0.95重算定仓.""" - from lib.exchange.okx_options_lib import option_buy_liquidity_ok - from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary, size_from_premium - - inst = str(body.get("opt_inst_id") or "").strip() - if not inst: - return {"ok": False, "msg": "缺少期权合约"} - quote_fn = cfg.get("quote_option_contract") - ex = cfg.get("exchange_options") - if not callable(quote_fn) or ex is None: - return {"ok": False, "msg": "期权报价能力未就绪"} - q = quote_fn(ex, inst) - if not q.get("ok"): - return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q} - can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz")) - if not can_open: - return { - "ok": False, - "msg": block_msg or "暂无卖一深度,无法买入", - "quote": q, - } - body["ask"] = float(q["ask"]) - body["ask_sz"] = q.get("ask_sz") - if q.get("ct_mult") is not None: - body["ct_mult"] = float(q.get("ct_mult") or 0.01) - if is_option_primary(body): - cs = float(body.get("contract_size") or 0.01) - get_cs = cfg.get("get_contract_size") - sym = str(body.get("exchange_symbol") or "") - if callable(get_cs) and sym: - try: - cs = float(get_cs(sym) or cs) - except Exception: - pass - sized = size_from_premium( - premium_budget=float(body.get("premium_budget") or 0), - ask=float(body["ask"]), - ct_mult=float(body.get("ct_mult") or 0.01), - ratio=float(body.get("option_perp_ratio") or 2), - contract_size=cs, - ) - if not sized.get("ok"): - return {"ok": False, "msg": sized.get("msg") or "定仓失败", "quote": q, "sizing": sized} - body["sheets"] = sized["sheets"] - body["contracts"] = sized["contracts"] - body["eth_qty"] = sized["eth_qty"] - body["contract_size"] = cs - # 深度不足则缩量 - ask_sz = float(q.get("ask_sz") or 0) - if ask_sz > 0 and float(body["sheets"]) > ask_sz: - body["sheets"] = float(int(ask_sz)) - if body["sheets"] <= 0: - return {"ok": False, "msg": "卖一深度不足 1 张", "quote": q, "sizing": sized} - eth = round(float(body["sheets"]) * float(body.get("ct_mult") or 0.01), 2) - body["eth_qty"] = eth - body["contracts"] = (eth / float(body.get("option_perp_ratio") or 2)) / cs - return { - "ok": True, - "ask": float(q["ask"]), - "ask_sz": q.get("ask_sz"), - "sheets": body.get("sheets"), - "contracts": body.get("contracts"), - "eth_qty": body.get("eth_qty"), - "sizing": sized, - "quote": q, - "msg": ( - f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}ETH / " - f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}" - ), - } - return { - "ok": True, - "ask": float(q["ask"]), - "ask_sz": q.get("ask_sz"), - "sheets": body.get("sheets"), - "quote": q, - "msg": f"已按最新卖一: {body.get('sheets')}张@{q['ask']}", - } - - -def execute_perp_options_start( - cfg: dict[str, Any], - body: dict[str, Any], - *, - dry_run: bool = False, - persist: Optional[Callable[..., Any]] = None, -) -> dict[str, Any]: - refresh = refresh_po_option_quote_before_start(cfg, body) - if not refresh.get("ok"): - return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一失败", "refresh": refresh} - path = build_po_path_plan(body) - results: list[dict[str, Any]] = [] - opt_res: Optional[dict[str, Any]] = None - perp_res: Optional[dict[str, Any]] = None - for step in path: - if step["step"] == "options_buy_limit": - opt_res = _buy_option( - cfg, - inst_id=str(body.get("opt_inst_id") or ""), - sheets=float(body.get("sheets") or 1), - dry_run=dry_run, - ) - results.append({"step": step["step"], **opt_res}) - if not opt_res.get("ok"): - # 永续已成、期权失败 → 可挂 partial 等补开期权 - if ( - perp_res - and perp_res.get("ok") - and not dry_run - and manual_complete_on_partial() - and persist - ): - return _park_partial( - cfg, - plan_type="perp_options", - body=body, - missing_leg="option_hedge", - msg="永续已开、期权失败。计划已挂半腿待补,请在「进行中」补开期权", - path=path, - results=results, - persist=persist, - dry_run=dry_run, - option=None, - perp=perp_res, - ) - return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results} - else: - from lib.hedge_plan.hedge_plan_option_primary_lib import ( - is_option_primary, - perp_direction_for_view, - ) - - opt_primary = is_option_primary(body) - view = str(body.get("direction") or "long") - perp_dir = str(step.get("direction") or ( - perp_direction_for_view(view) if opt_primary else view - )) - attach = bool(step.get("attach_tpsl", not opt_primary)) - tp_v = None if not attach else body.get("tp") - sl_v = None if not attach else body.get("sl") - if attach: - tp_v = float(body["tp"]) - sl_v = float(body["sl"]) - perp_res = _open_perp( - cfg, - symbol=str(body.get("exchange_symbol") or ""), - direction=perp_dir, - contracts=float(body.get("contracts") or 0), - leverage=int(body.get("leverage") or (100 if opt_primary else 10)), - tp=tp_v, - sl=sl_v, - dry_run=dry_run, - attach_tpsl=attach, - ) - results.append({"step": step["step"], **perp_res}) - if not perp_res.get("ok"): - if opt_res and opt_res.get("ok") and not dry_run and partial_auto_close_enabled(): - close_r = _sell_option( - cfg, - inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""), - sheets=float(opt_res.get("sheets") or body.get("sheets") or 1), - ) - results.append({"step": "options_auto_close_on_perp_fail", **close_r}) - msg = perp_res.get("msg") or "永续开仓失败" - _notify_partial(cfg, "perp_options", msg, results) - return { - "ok": False, - "msg": msg, - "path": path, - "results": results, - "partial": True, - } - if ( - opt_res - and opt_res.get("ok") - and not dry_run - and manual_complete_on_partial() - and persist - ): - return _park_partial( - cfg, - plan_type="perp_options", - body=body, - missing_leg="perp", - msg="期权已开、永续失败。计划已挂半腿待补,请在「进行中」补开永续", - path=path, - results=results, - persist=persist, - dry_run=dry_run, - option=opt_res, - perp=None, - ) - msg = perp_res.get("msg") or "永续开仓失败" - if not dry_run: - _notify_partial(cfg, "perp_options", msg, results) - return { - "ok": False, - "msg": msg, - "path": path, - "results": results, - "partial": True, - } - - out = { - "ok": True, - "dry_run": dry_run, - "plan_type": "perp_options", - "path": path, - "results": results, - "option": opt_res, - "perp": perp_res, - "refresh": refresh, - "opened_at": _now(), - } - if persist and not dry_run: - out["plan_id"] = persist(out, body) - return out - - -def execute_options_options_start( - cfg: dict[str, Any], - body: dict[str, Any], - *, - dry_run: bool = False, - persist: Optional[Callable[..., Any]] = None, -) -> dict[str, Any]: - refresh = refresh_oo_sizing_before_start(cfg, body) - if not refresh.get("ok"): - return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一/张数失败", "refresh": refresh} - path = build_oo_path_plan(body) - results: list[dict[str, Any]] = [] - leg_a = body.get("leg_a") or {} - leg_b = body.get("leg_b") or {} - inst_a = str(leg_a.get("inst_id") or "") - inst_b = str(leg_b.get("inst_id") or "") - from lib.options.options_position_limit_lib import option_position_limit_block_msg - - pos_limit_msg = option_position_limit_block_msg( - cfg.get("exchange_options"), - opening_inst_ids=[inst_a, inst_b], - fetch_positions=cfg.get("fetch_option_positions"), - ) - if pos_limit_msg: - return { - "ok": False, - "msg": pos_limit_msg, - "path": path, - "results": [], - "refresh": refresh, - } - a_res = _buy_option(cfg, inst_id=inst_a, sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run) - results.append({"step": "options_buy_limit", "leg": "a", **a_res}) - if not a_res.get("ok"): - return { - "ok": False, - "msg": a_res.get("msg") or "腿A开仓失败", - "path": path, - "results": results, - "refresh": refresh, - } - b_res = _buy_option(cfg, inst_id=inst_b, sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run) - results.append({"step": "options_buy_limit", "leg": "b", **b_res}) - if not b_res.get("ok"): - if not dry_run and partial_auto_close_enabled(): - close_r = _sell_option(cfg, inst_id=str(a_res.get("inst_id") or ""), sheets=float(a_res.get("sheets") or 1)) - results.append({"step": "options_auto_close_leg_a", **close_r}) - msg = b_res.get("msg") or "腿B开仓失败" - _notify_partial(cfg, "options_options", msg, results) - return { - "ok": False, - "msg": msg, - "path": path, - "results": results, - "partial": True, - "refresh": refresh, - } - if not dry_run and manual_complete_on_partial() and persist: - out_p = _park_partial( - cfg, - plan_type="options_options", - body=body, - missing_leg="option_b", - msg="腿A已开、腿B失败。计划已挂半腿待补,请在「进行中」补开腿B", - path=path, - results=results, - persist=persist, - dry_run=dry_run, - leg_a=a_res, - leg_b=None, - ) - out_p["refresh"] = refresh - return out_p - msg = b_res.get("msg") or "腿B开仓失败" - if not dry_run: - _notify_partial(cfg, "options_options", msg, results) - return { - "ok": False, - "msg": msg, - "path": path, - "results": results, - "partial": True, - "refresh": refresh, - } - out = { - "ok": True, - "dry_run": dry_run, - "plan_type": "options_options", - "path": path, - "results": results, - "leg_a": a_res, - "leg_b": b_res, - "refresh": refresh, - "opened_at": _now(), - } - if persist and not dry_run: - out["plan_id"] = persist(out, body) - return out - - -def execute_complete_missing_leg( - cfg: dict[str, Any], - plan: dict[str, Any], - legs: list[dict[str, Any]], - start_body: dict[str, Any], - *, - dry_run: bool = False, -) -> dict[str, Any]: - """对 partial 计划补开缺失腿;成功后由调用方把计划升为 active.""" - missing = None - for leg in legs: - if str(leg.get("status") or "").lower() == "pending": - missing = leg - break - if not missing: - return {"ok": False, "msg": "没有待补开的腿"} - role = str(missing.get("leg_role") or "") - results: list[dict[str, Any]] = [] - if role == "perp": - from lib.hedge_plan.hedge_plan_option_primary_lib import ( - is_option_primary, - perp_direction_for_view, - ) - - opt_primary = is_option_primary(start_body) - view = str(start_body.get("direction") or "long") - perp_dir = perp_direction_for_view(view) if opt_primary else view - attach = not opt_primary - res = _open_perp( - cfg, - symbol=str(start_body.get("exchange_symbol") or missing.get("symbol") or ""), - direction=perp_dir, - contracts=float(start_body.get("contracts") or missing.get("size") or 0), - leverage=int(start_body.get("leverage") or (100 if opt_primary else 10)), - tp=None if not attach else float(start_body["tp"]), - sl=None if not attach else float(start_body["sl"]), - dry_run=dry_run, - attach_tpsl=attach, - ) - results.append({"step": "perp_market_open", "complete": True, **res}) - if not res.get("ok"): - return {"ok": False, "msg": res.get("msg") or "补开永续失败", "results": results, "leg_role": role} - return { - "ok": True, - "leg_role": role, - "leg_id": missing.get("id"), - "results": results, - "fill": res, - "opened_at": _now(), - } - if role in ("option_hedge", "option_b", "option_a"): - if role == "option_b": - src = start_body.get("leg_b") or {} - inst = str(src.get("inst_id") or missing.get("inst_id") or "") - sheets = float(src.get("sheets") or missing.get("size") or 1) - elif role == "option_a": - src = start_body.get("leg_a") or {} - inst = str(src.get("inst_id") or missing.get("inst_id") or "") - sheets = float(src.get("sheets") or missing.get("size") or 1) - else: - inst = str(start_body.get("opt_inst_id") or missing.get("inst_id") or "") - sheets = float(start_body.get("sheets") or missing.get("size") or 1) - res = _buy_option(cfg, inst_id=inst, sheets=sheets, dry_run=dry_run) - results.append({"step": "options_buy_limit", "complete": True, "leg_role": role, **res}) - if not res.get("ok"): - return {"ok": False, "msg": res.get("msg") or "补开期权失败", "results": results, "leg_role": role} - return { - "ok": True, - "leg_role": role, - "leg_id": missing.get("id"), - "results": results, - "fill": res, - "opened_at": _now(), - } - return {"ok": False, "msg": f"未知待补腿: {role}"} - - -def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: - pt = (plan_type or "").strip().lower() - if pt == "perp_options": - from lib.hedge_plan.hedge_plan_option_primary_lib import ( - is_option_primary, - validate_option_primary_start, - ) - - if is_option_primary(body): - from lib.hedge_plan.hedge_plan_option_primary_lib import validate_option_primary_watch - - # 以期权为主默认盯盘启动(非现场开仓);显式 watch_entry=0 才走即开校验 - watch = body.get("watch_entry") - if watch in (None, "", True, 1, "1", "true", "yes", "on"): - return validate_option_primary_watch(body) - return validate_option_primary_start(body) - need = ("direction", "entry", "tp", "sl", "contracts", "opt_inst_id", "sheets", "exchange_symbol") - for k in need: - if body.get(k) in (None, ""): - return f"缺少字段: {k}" - try: - if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0: - return "张数必须大于 0" - entry = float(body["entry"]) - tp = float(body["tp"]) - sl = float(body["sl"]) - if tp <= 0 or sl <= 0 or entry <= 0: - return "止盈/止损/入场无效" - except (TypeError, ValueError): - return "数值字段无效" - direction = str(body.get("direction") or "").strip().lower() - if direction not in ("long", "short"): - return "方向须为 long 或 short" - opt_type = str(body.get("opt_type") or "").strip().upper() - if not opt_type: - # 允许从合约名推断 ETH-USD-...-P / -C - inst = str(body.get("opt_inst_id") or "") - if inst.upper().endswith("-P"): - opt_type = "P" - elif inst.upper().endswith("-C"): - opt_type = "C" - if opt_type not in ("P", "C"): - return "缺少期权类型(Put/Call)" - if direction == "long" and opt_type != "P": - return "做多永期对冲须用 Put" - if direction == "short" and opt_type != "C": - return "做空永期对冲须用 Call" - if direction == "long" and not (sl < entry < tp): - return "做多须满足 止损 < 入场 < 止盈" - if direction == "short" and not (tp < entry < sl): - return "做空须满足 止盈 < 入场 < 止损" - from lib.hedge_plan.hedge_plan_moneyness_lib import ( - parse_strike_from_inst, - validate_po_option_moneyness, - ) - - strike = body.get("strike") - if strike in (None, ""): - strike = parse_strike_from_inst(str(body.get("opt_inst_id") or "")) - index_px = body.get("index_px") - if index_px in (None, ""): - index_px = entry - money_err = validate_po_option_moneyness( - opt_type=opt_type, - strike=strike, - index_px=index_px, - ask=body.get("ask"), - hours_to_expiry=body.get("hours_to_expiry"), - ) - if money_err: - return money_err - return None - if pt == "options_options": - a = body.get("leg_a") or {} - b = body.get("leg_b") or {} - if not a.get("inst_id") or not b.get("inst_id"): - return "请选用两条期权腿" - rr_raw = body.get("profit_rr") - if rr_raw not in (None, ""): - try: - rr = float(rr_raw) - except (TypeError, ValueError): - return "盈亏比无效" - if rr <= 0: - return "盈亏比须大于0" - else: - # 兼容旧上/下破 - up = body.get("target_price_up") - down = body.get("target_price_down") - legacy = body.get("target_price") - if up in (None, "") and legacy not in (None, ""): - up = legacy - if down in (None, "") and legacy not in (None, ""): - down = legacy - if up in (None, "") or down in (None, ""): - return "请填写盈亏比" - try: - if float(up) <= float(down): - return "上破目标价必须大于下破目标价" - except (TypeError, ValueError): - return "目标价无效" - from lib.hedge_plan.hedge_plan_moneyness_lib import ( - parse_strike_from_inst, - validate_oo_legs_moneyness, - ) - - def _leg_for_money(leg: dict) -> dict: - strike = leg.get("strike") - if strike in (None, ""): - strike = parse_strike_from_inst(str(leg.get("inst_id") or "")) - opt_type = leg.get("opt_type") - if not opt_type: - inst = str(leg.get("inst_id") or "").upper() - if inst.endswith("-C"): - opt_type = "C" - elif inst.endswith("-P"): - opt_type = "P" - return {"opt_type": opt_type, "strike": strike} - - index_px = body.get("index_px") - money_err = validate_oo_legs_moneyness( - _leg_for_money(a), - _leg_for_money(b), - index_px=index_px, - ) - if money_err: - return money_err - return None - return "未知计划类型" - - -def dump_preview(preview: Any) -> str: - try: - return json.dumps(preview, ensure_ascii=False)[:8000] - except Exception: - return "" - - -def _live_option_pos_sheets(ex: Any, inst_id: str) -> float: - from lib.exchange.okx_options_lib import fetch_option_positions - - inst_id = (inst_id or "").strip() - if not inst_id or ex is None: - return 0.0 - rows = fetch_option_positions(ex) - if rows is None: - return -1.0 # API 失败:未知 - for r in rows: - if str(r.get("instId") or "").strip() != inst_id: - continue - try: - return abs(float(r.get("pos") or 0)) - except (TypeError, ValueError): - return 0.0 - return 0.0 - - -def _sync_plan_status_after_leg_fix(conn: Any, plan_id: int) -> None: - """腿状态校正后:有 open + pending → partial.""" - from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan - - plan = get_plan(conn, int(plan_id)) - if not plan: - return - pst = str(plan.get("status") or "") - if pst not in ("opening", "active", "partial"): - return - legs = get_plan_legs(conn, int(plan_id)) - statuses = [str(l.get("status") or "").lower() for l in legs] - n_open = sum(1 for s in statuses if s == "open") - n_pending = sum(1 for s in statuses if s == "pending") - if n_pending and n_open: - update_plan(conn, int(plan_id), status="partial", close_reason="partial_fail") - - -def reconcile_unfilled_option_legs(cfg: dict[str, Any], conn: Any, plan_id: int) -> list[str]: - """未成交却标 open 的期权腿 → pending(可补开);不显示成持仓.""" - from lib.hedge_plan.hedge_plan_db import get_plan_legs, update_leg - from lib.exchange.okx_options_lib import fetch_option_order - - ex = cfg.get("exchange_options") - notes: list[str] = [] - legs = get_plan_legs(conn, int(plan_id)) - for leg in legs: - role = str(leg.get("leg_role") or "") - if not role.startswith("option"): - continue - st = str(leg.get("status") or "").lower() - if st != "open": - continue - inst = str(leg.get("inst_id") or "").strip() - oid = str(leg.get("exchange_ord_id") or "").strip() - leg_id = int(leg["id"]) - sheets = _live_option_pos_sheets(ex, inst) - if sheets < 0: - continue # 查仓失败不改 - if sheets >= 1: - continue - # 无实仓:再看订单是否已成交(仍挂单只改 pending,不撤单) - if ex is not None and inst and oid: - od = fetch_option_order(ex, inst_id=inst, ord_id=oid) - if od.get("ok"): - acc = float(od.get("acc_fill_sz") or 0) - ostate = str(od.get("state") or "") - if acc >= 1 or ostate == "filled": - continue # 有成交但仓位暂未同步,暂不改 - update_leg( - conn, - leg_id, - status="pending", - close_reason=None, - closed_at=None, - avg_open=None, - premium=0, - ) - notes.append(f"{inst} 无成交却标open→pending") - if notes: - _sync_plan_status_after_leg_fix(conn, int(plan_id)) - return notes - - -def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dict[str, Any]: - """人工结束进行中计划:不自动平仓;未成交腿标 cancelled.""" - from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_leg, update_plan - from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_end - from lib.exchange.okx_options_lib import cancel_option_order - - plan = get_plan(conn, int(plan_id)) - if not plan: - return {"ok": False, "msg": "计划不存在"} - st = str(plan.get("status") or "") - if st not in ("opening", "active", "partial", "watching"): - return {"ok": False, "msg": f"当前状态 {st or '—'} 不可结束"} - - notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id)) - ex = cfg.get("exchange_options") - legs = get_plan_legs(conn, int(plan_id)) - for leg in legs: - lst = str(leg.get("status") or "").lower() - inst = str(leg.get("inst_id") or "").strip() - oid = str(leg.get("exchange_ord_id") or "").strip() - if lst == "pending": - if ex is not None and inst and oid: - cancel_option_order(ex, inst_id=inst, ord_id=oid) - update_leg( - conn, - int(leg["id"]), - status="cancelled", - close_reason="manual_end", - closed_at=_now(), - avg_open=None, - premium=0, - ) - notes.append(f"{inst or leg.get('leg_role')} 待补→cancelled") - - update_plan( - conn, - int(plan_id), - status="closed", - close_reason="manual", - closed_at=_now(), - note=((plan.get("note") or "") + " · 人工结束(不平仓)").strip(" ·")[:500], - ) - plan2 = get_plan(conn, int(plan_id)) - if plan2: - try: - notify_plan_end(cfg, conn, plan2) - except Exception: - pass - return { - "ok": True, - "plan_id": int(plan_id), - "msg": "计划已结束(未自动平仓;有持仓请自行平掉)", - "notes": notes, - } diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py deleted file mode 100644 index 5dd66e8..0000000 --- a/lib/hedge_plan/hedge_plan_register.py +++ /dev/null @@ -1,1453 +0,0 @@ -"""OKX 对冲计划:P0 测算页与 API 注册.""" -from __future__ import annotations - -import os -from typing import Any, Optional - -from flask import Flask, jsonify, request -from jinja2 import ChoiceLoader, FileSystemLoader - -from lib.hedge_plan.hedge_plan_calc_lib import ( - build_options_options_preview, - build_perp_options_preview, - floor_contracts_to_precision, - gate_status, - option_premium_total, - suggest_contracts_from_notional, -) -from lib.market.market_precision_lib import amount_decimals_from_exchange -from lib.trade.position_sizing_lib import ( - compute_full_margin_sizing, - load_position_sizing_mode, -) - - -def _env_bool(key: str, default: bool = False) -> bool: - raw = (os.getenv(key) or "").strip().lower() - if not raw: - return default - return raw in ("1", "true", "yes", "on") - - -def attach_hedge_plan_templates(app: Flask, repo_root: str) -> None: - tpl_dir = os.path.join(repo_root, "lib", "hedge_plan", "templates") - if not os.path.isdir(tpl_dir): - return - existing = app.jinja_loader - loaders = [FileSystemLoader(tpl_dir)] - if existing is not None: - if isinstance(existing, ChoiceLoader): - loaders = list(existing.loaders) + loaders - else: - loaders.insert(0, existing) - app.jinja_loader = ChoiceLoader(loaders) - - -def install_hedge_plan(app: Flask, repo_root: str, app_module: Any) -> None: - attach_hedge_plan_templates(app, repo_root) - cfg = _build_cfg(app_module) - app.extensions["hedge_plan_cfg"] = cfg - register_hedge_plan_routes(app, cfg) - _maybe_start_monitor(cfg) - - -def _build_cfg(app_module: Any) -> dict[str, Any]: - from lib.exchange.okx_options_lib import ( - build_option_chain, - fetch_index_price, - options_header_balances, - place_option_limit_order, - quote_option_contract, - td_mode_for_option_buy, - ) - - def _amount_to_precision(sym: str, amt: float) -> float: - ex = getattr(app_module, "exchange", None) - if ex is None: - return float(amt) - return float(ex.amount_to_precision(sym, amt)) - - cfg = { - "get_db": app_module.get_db, - "login_required": app_module.login_required, - "render_main_page": app_module.render_main_page, - "exchange": getattr(app_module, "exchange", None), - "exchange_options": getattr(app_module, "exchange_options", None), - "get_available_trading_usdt": getattr(app_module, "get_available_trading_usdt", None), - "get_contract_size": getattr(app_module, "get_contract_size", None), - "normalize_exchange_symbol": getattr(app_module, "normalize_exchange_symbol", None), - "ensure_markets_loaded": getattr(app_module, "ensure_markets_loaded", None), - "ensure_okx_live_ready": getattr(app_module, "ensure_okx_live_ready", None), - "place_exchange_order": getattr(app_module, "place_exchange_order", None), - "close_exchange_order": getattr(app_module, "close_exchange_order", None), - "get_live_position_contracts": getattr(app_module, "get_live_position_contracts", None), - "amount_to_precision": _amount_to_precision, - "build_option_chain": build_option_chain, - "options_header_balances": options_header_balances, - "quote_option_contract": quote_option_contract, - "place_option_limit_order": place_option_limit_order, - "td_mode_for_option_buy": td_mode_for_option_buy, - "fetch_index_price": fetch_index_price, - "options_td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), - "btc_leverage": int(getattr(app_module, "BTC_LEVERAGE", 10) or 10), - "alt_leverage": int(getattr(app_module, "ALT_LEVERAGE", 5) or 5), - "full_margin_buffer": float(getattr(app_module, "FULL_MARGIN_BUFFER_RATIO", 0.98) or 0.98), - "funds_decimals": int(getattr(app_module, "FUNDS_DECIMALS", 2) or 2), - "options_enabled": _env_bool("OKX_OPTIONS_ENABLED", False), - "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), - "chain_max_dte": float(os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or "14"), - "perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(), - "options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(), - "trade_budget_usdc": float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC") or "10"), - # 对冲专用缓冲;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立 - "budget_buffer": float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"), - "oo_bias_split_by": _oo_bias_split_by(), - "oo_bias_ratio": _oo_bias_ratio(), - "live_trading": _env_bool("LIVE_TRADING_ENABLED", False), - "send_wechat": getattr(app_module, "send_wechat_msg", None), - } - try: - from lib.sim.hooks import patch_options_cfg - - return patch_options_cfg(cfg) - except Exception: - return cfg - - -def _hedge_enabled() -> bool: - from lib.hedge_plan.okx_trade_mode_lib import hedge_module_enabled - - return hedge_module_enabled() - - -def _show_perp_options() -> bool: - from lib.hedge_plan.okx_trade_mode_lib import show_perp_options - - return show_perp_options() - - -def _show_options_options() -> bool: - from lib.hedge_plan.okx_trade_mode_lib import show_options_options - - return show_options_options() - - -def _oo_close_mode_enabled() -> bool: - return _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True) - - -def _oo_bias_split_by() -> str: - from lib.hedge_plan.hedge_plan_calc_lib import _normalize_oo_bias_split_by - - return _normalize_oo_bias_split_by(os.getenv("HEDGE_PLAN_OO_BIAS_SPLIT_BY") or "budget") - - -def _oo_bias_ratio() -> float: - from lib.hedge_plan.hedge_plan_calc_lib import _clamp_oo_bias_ratio - - return _clamp_oo_bias_ratio(os.getenv("HEDGE_PLAN_OO_BIAS_RATIO") or "0.7") - - -def _normalize_oo_close_mode(raw: Any) -> str: - """方案C关闭时强制 hold_expiry;开启时默认 close_all.""" - if not _oo_close_mode_enabled(): - return "hold_expiry" - v = str(raw or "close_all").strip().lower() - if v in ("hold_expiry", "hold_to_expiry", "expiry", "到期平"): - return "hold_expiry" - return "close_all" - - -def _live_order() -> bool: - return _env_bool("HEDGE_PLAN_LIVE_ORDER", False) - - -def _max_active() -> int: - try: - return max(1, int(os.getenv("MAX_ACTIVE_HEDGE_PLANS") or "1")) - except ValueError: - return 1 - - -def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]: - active = 0 - has_standalone = False - mutual = True - try: - from lib.hedge_plan.hedge_options_exclusive_lib import ( - has_standalone_option_position, - mutual_exclusive_enabled, - ) - from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables - - mutual = mutual_exclusive_enabled() - conn = cfg["get_db"]() - try: - init_hedge_plan_tables(conn) - active = count_active_plans(conn) - if mutual: - try: - from lib.exchange.okx_options_lib import fetch_option_positions - - ex = cfg.get("exchange_options") or cfg.get("exchange") - raw = fetch_option_positions(ex) if ex is not None else [] - has_standalone = has_standalone_option_position(conn, raw or []) - except Exception: - has_standalone = True # fail-closed - conn.commit() - finally: - conn.close() - except Exception: - # fail-closed:探测失败视为不可开仓 - active = 10**9 - has_standalone = True - return gate_status( - hedge_enabled=_hedge_enabled(), - sizing_mode=load_position_sizing_mode(), - plan_type=plan_type, - options_enabled=bool(cfg.get("options_enabled")), - live_order=_live_order(), - live_trading=bool(cfg.get("live_trading")) or _env_bool("LIVE_TRADING_ENABLED", False), - active_count=active, - max_active=_max_active(), - show_perp_options=_show_perp_options(), - show_options_options=_show_options_options(), - mutual_exclusive=mutual, - has_standalone_option=has_standalone, - ) - - -def _gates_public(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]: - g = _gates_dict(cfg, plan_type) - g["oo_close_mode_enabled"] = _oo_close_mode_enabled() - g["oo_close_mode_default"] = "close_all" if _oo_close_mode_enabled() else "hold_expiry" - g["oo_bias_split_by"] = _oo_bias_split_by() - g["oo_bias_ratio"] = _oo_bias_ratio() - g["budget_buffer"] = float(cfg.get("budget_buffer") or 0.95) - return g - - -def _maybe_start_monitor(cfg: dict[str, Any]) -> None: - # 始终启动监控线程:单独期权模式下仍需收口遗留 active/partial 计划 - with _hedge_start_lock(): - if cfg.get("hedge_monitor_thread") is not None: - return - try: - secs = float(os.getenv("HEDGE_PLAN_MONITOR_POLL_SECONDS") or "15") - except ValueError: - secs = 15.0 - secs = max(5.0, secs) - - def _loop() -> None: - import time - - from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans - - while True: - try: - tick_active_plans(cfg) - except Exception: - pass - time.sleep(secs) - - import threading - - t = threading.Thread(target=_loop, name="hedge-plan-monitor", daemon=True) - t.start() - cfg["hedge_monitor_thread"] = t - - -_start_lock = None - - -def _hedge_start_lock(): - global _start_lock - if _start_lock is None: - import threading - - _start_lock = threading.Lock() - return _start_lock - - -def _start_body_json(body: dict[str, Any], missing_leg: Optional[str] = None) -> str: - import json - - try: - return json.dumps( - {"start_body": body, "missing_leg": missing_leg}, - ensure_ascii=False, - )[:8000] - except Exception: - return "" - - -def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: - from lib.hedge_plan.hedge_plan_db import ( - get_plan, - get_plan_legs, - init_hedge_plan_tables, - insert_leg, - insert_plan, - ) - from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start - - conn = cfg["get_db"]() - try: - init_hedge_plan_tables(conn) - is_partial = bool(result.get("partial")) - missing = str(result.get("missing_leg") or "") if is_partial else "" - opt = result.get("option") or {} - perp = result.get("perp") or {} - if is_partial: - opt_ok = missing != "option_hedge" and bool(result.get("option")) - perp_ok = missing != "perp" and bool(result.get("perp")) - else: - opt_ok = True - perp_ok = True - premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0 - from lib.hedge_plan.hedge_plan_option_primary_lib import ( - is_option_primary, - perp_direction_for_view, - ) - - opt_primary = is_option_primary(body) - view = str(body.get("direction") or "long") - perp_dir = ( - str((perp or {}).get("direction") or "") - or (perp_direction_for_view(view) if opt_primary else view) - ) - plan_row = { - "plan_type": "perp_options", - "status": "partial" if is_partial else "active", - "underlying": str(body.get("underlying") or "ETH").upper(), - "direction": view, - "entry_mark": float(body.get("entry") or 0), - "tp": float(body.get("tp") or 0) if not opt_primary else 0, - "sl": float(body.get("sl") or 0) if not opt_primary else 0, - "sizing_mode_at_open": load_position_sizing_mode(), - "perp_size": float((perp or {}).get("contracts") or body.get("contracts") or 0), - "margin": body.get("margin"), - "leverage": float(body.get("leverage") or (100 if opt_primary else 10)), - "premium_total": premium, - "preview_json": _start_body_json(body, missing or None), - "close_reason": "partial_fail" if is_partial else None, - "opened_at": result.get("opened_at"), - "note": (result.get("msg") or "")[:500] if is_partial else None, - "option_primary": 1 if opt_primary else 0, - "perp_direction": perp_dir, - } - if opt_primary: - plan_row.update( - { - "option_target_points": float(body.get("option_target_points") or 0), - "perp_target_points": float(body.get("perp_target_points") or 0), - "option_perp_ratio": float(body.get("option_perp_ratio") or 0), - "premium_budget": float(body.get("premium_budget") or 0), - "strike_interval": float(body.get("strike_interval") or 15), - "min_option_hours": float(body.get("min_option_hours") or 36), - "option_moneyness": str(body.get("moneyness") or body.get("option_moneyness") or ""), - } - ) - plan_id = insert_plan(conn, plan_row) - insert_leg( - conn, - { - "plan_id": plan_id, - "leg_role": "perp", - "symbol": str(body.get("exchange_symbol") or ""), - "side": perp_dir, - "size": float((perp or {}).get("contracts") or body.get("contracts") or 0), - "avg_open": float(body.get("entry") or 0) if perp_ok else None, - "status": "open" if perp_ok else "pending", - "exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""), - "opened_at": result.get("opened_at") if perp_ok else None, - }, - ) - insert_leg( - conn, - { - "plan_id": plan_id, - "leg_role": "option_hedge", - "inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""), - "opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""), - "strike": (opt or {}).get("strike") or body.get("strike"), - "side": "buy", - "size": float((opt or {}).get("sheets") or body.get("sheets") or 1), - "avg_open": float((opt or {}).get("ask") or body.get("ask") or 0) if opt_ok else None, - "premium": premium if opt_ok else 0, - "ct_mult": float(body.get("ct_mult") or (opt or {}).get("ct_mult") or 0.01), - "status": "open" if opt_ok else "pending", - "exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""), - "opened_at": result.get("opened_at") if opt_ok else None, - }, - ) - conn.commit() - if not is_partial: - plan = get_plan(conn, plan_id) - legs = get_plan_legs(conn, plan_id) - if plan: - notify_plan_start(cfg, conn, plan, legs) - conn.commit() - return plan_id - finally: - conn.close() - - -def _persist_po_watching(cfg: dict[str, Any], body: dict[str, Any]) -> int: - """以期权为主:只落库盯盘计划,不下单.""" - from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_plan - from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view - - conn = cfg["get_db"]() - try: - init_hedge_plan_tables(conn) - view = str(body.get("direction") or "long") - money = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower() - plan_id = insert_plan( - conn, - { - "plan_type": "perp_options", - "status": "watching", - "underlying": str(body.get("underlying") or "ETH").upper(), - "direction": view, - "entry_mark": float(body.get("index_px") or body.get("entry") or 0) or None, - "tp": 0, - "sl": 0, - "sizing_mode_at_open": None, - "perp_size": None, - "margin": None, - "leverage": float(body.get("leverage") or 100), - "premium_total": 0, - "preview_json": _start_body_json(body), - "close_reason": None, - "opened_at": None, - "note": "盯盘中:等待杠杆/间隔达标后自动开仓", - "option_primary": 1, - "perp_direction": perp_direction_for_view(view), - "option_target_points": float(body.get("option_target_points") or 0), - "perp_target_points": float(body.get("perp_target_points") or 0), - "option_perp_ratio": float(body.get("option_perp_ratio") or 0), - "premium_budget": float(body.get("premium_budget") or 0), - "strike_interval": float(body.get("strike_interval") or 15), - "min_option_hours": float(body.get("min_option_hours") or 36), - "option_moneyness": money, - "option_leverage": float(body.get("option_leverage") or 0), - }, - ) - conn.commit() - return plan_id - finally: - conn.close() - - -def _activate_watching_po( - cfg: dict[str, Any], - conn: Any, - plan_id: int, - result: dict[str, Any], - body: dict[str, Any], -) -> None: - """盯盘命中后:写入腿并把 watching → active/partial.""" - from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, insert_leg, update_plan - from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start - from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view - - is_partial = bool(result.get("partial")) - missing = str(result.get("missing_leg") or "") if is_partial else "" - opt = result.get("option") or {} - perp = result.get("perp") or {} - if is_partial: - opt_ok = missing != "option_hedge" and bool(result.get("option")) - perp_ok = missing != "perp" and bool(result.get("perp")) - else: - opt_ok = True - perp_ok = True - premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0 - view = str(body.get("direction") or "long") - perp_dir = ( - str((perp or {}).get("direction") or "") - or perp_direction_for_view(view) - ) - update_plan( - conn, - int(plan_id), - status="partial" if is_partial else "active", - entry_mark=float(body.get("entry") or body.get("index_px") or 0) or None, - perp_size=float((perp or {}).get("contracts") or body.get("contracts") or 0), - leverage=float(body.get("leverage") or 100), - premium_total=premium, - preview_json=_start_body_json(body, missing or None), - close_reason="partial_fail" if is_partial else None, - opened_at=result.get("opened_at"), - note=(result.get("msg") or "")[:500] if is_partial else "盯盘达标已开仓", - perp_direction=perp_dir, - ) - insert_leg( - conn, - { - "plan_id": int(plan_id), - "leg_role": "perp", - "symbol": str(body.get("exchange_symbol") or ""), - "side": perp_dir, - "size": float((perp or {}).get("contracts") or body.get("contracts") or 0), - "avg_open": float(body.get("entry") or 0) if perp_ok else None, - "status": "open" if perp_ok else "pending", - "exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""), - "opened_at": result.get("opened_at") if perp_ok else None, - }, - ) - insert_leg( - conn, - { - "plan_id": int(plan_id), - "leg_role": "option_hedge", - "inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""), - "opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""), - "strike": (opt or {}).get("strike") or body.get("strike"), - "side": "buy", - "size": float((opt or {}).get("sheets") or body.get("sheets") or 1), - "avg_open": float((opt or {}).get("ask") or body.get("ask") or 0) if opt_ok else None, - "premium": premium if opt_ok else 0, - "ct_mult": float(body.get("ct_mult") or (opt or {}).get("ct_mult") or 0.01), - "status": "open" if opt_ok else "pending", - "exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""), - "opened_at": result.get("opened_at") if opt_ok else None, - }, - ) - if not is_partial: - plan = get_plan(conn, int(plan_id)) - legs = get_plan_legs(conn, int(plan_id)) - if plan: - notify_plan_start(cfg, conn, plan, legs) - - -def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: - from lib.hedge_plan.hedge_plan_db import ( - get_plan, - get_plan_legs, - init_hedge_plan_tables, - insert_leg, - insert_plan, - ) - from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start - - conn = cfg["get_db"]() - try: - init_hedge_plan_tables(conn) - is_partial = bool(result.get("partial")) - missing = str(result.get("missing_leg") or "") if is_partial else "" - a = result.get("leg_a") or {} - b = result.get("leg_b") or {} - a_ok = True if not is_partial else bool(result.get("leg_a")) - b_ok = True if not is_partial else (missing != "option_b" and bool(result.get("leg_b"))) - premium = (float(a.get("premium") or 0) if a_ok else 0.0) + ( - float(b.get("premium") or 0) if b_ok else 0.0 - ) - rr_raw = body.get("profit_rr") - try: - profit_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0 - except (TypeError, ValueError): - profit_rr = 2.0 - if profit_rr <= 0: - profit_rr = 2.0 - # 旧字段兼容:不再要求上/下破;有传则原样落库 - def _opt_float(key: str, *alts: str) -> float | None: - for k in (key, *alts): - v = body.get(k) - if v not in (None, ""): - try: - return float(v) - except (TypeError, ValueError): - continue - return None - - up_f = _opt_float("target_price_up", "target_price") - down_f = _opt_float("target_price_down", "target_price") - plan_id = insert_plan( - conn, - { - "plan_type": "options_options", - "status": "partial" if is_partial else "active", - "underlying": str(body.get("underlying") or "ETH").upper(), - "target_price": up_f, - "target_price_up": up_f, - "target_price_down": down_f, - "profit_rr": profit_rr, - "sizing_mode_at_open": load_position_sizing_mode(), - "premium_total": premium, - "oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")), - "preview_json": _start_body_json(body, missing or None), - "close_reason": "partial_fail" if is_partial else None, - "opened_at": result.get("opened_at"), - "note": (result.get("msg") or "")[:500] if is_partial else None, - }, - ) - for role, res, src, ok in ( - ("option_a", a, body.get("leg_a") or {}, a_ok), - ("option_b", b, body.get("leg_b") or {}, b_ok), - ): - insert_leg( - conn, - { - "plan_id": plan_id, - "leg_role": role, - "inst_id": str((res or {}).get("inst_id") or src.get("inst_id") or ""), - "opt_type": str((res or {}).get("opt_type") or src.get("opt_type") or ""), - "strike": (res or {}).get("strike") or src.get("strike"), - "side": "buy", - "size": float((res or {}).get("sheets") or src.get("sheets") or 1), - "avg_open": float((res or {}).get("ask") or 0) if ok else None, - "premium": float((res or {}).get("premium") or 0) if ok else 0, - "status": "open" if ok else "pending", - "exchange_ord_id": str((res or {}).get("exchange_ord_id") or ""), - "opened_at": result.get("opened_at") if ok else None, - }, - ) - conn.commit() - if not is_partial: - plan = get_plan(conn, plan_id) - legs = get_plan_legs(conn, plan_id) - if plan: - notify_plan_start(cfg, conn, plan, legs) - conn.commit() - return plan_id - finally: - conn.close() - - -def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: - lr = cfg["login_required"] - - @app.route("/hedge-plan") - @lr - def page_hedge_plan(): - from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled - - redir = redirect_to_embed_shell_if_enabled("hedge_plan") - if redir is not None: - return redir - return cfg["render_main_page"]("hedge_plan") - - @app.route("/api/hedge-plan/gates") - @lr - def api_hedge_gates(): - plan_type = (request.args.get("plan_type") or "perp_options").strip() - return jsonify({"ok": True, **_gates_public(cfg, plan_type)}) - - @app.route("/api/hedge-plan/market") - @lr - def api_hedge_market(): - base = (request.args.get("base") or cfg.get("default_underly") or "ETH").strip().upper() - if base not in ("BTC", "ETH"): - return jsonify({"ok": False, "msg": "对冲计划仅支持 BTC/ETH"}), 400 - direction = (request.args.get("direction") or "long").strip().lower() - if direction not in ("long", "short"): - direction = "long" - option_primary = (request.args.get("option_primary") or "").strip().lower() in ( - "1", - "true", - "yes", - "on", - ) - data, err = _fetch_perp_market(cfg, base) - if err: - return jsonify({"ok": False, "msg": err}), 400 - sizing_mode = load_position_sizing_mode() - gates = _gates_dict(cfg, "perp_options") - if option_primary: - from lib.hedge_plan.hedge_plan_option_primary_lib import ( - opt_type_for_view, - perp_direction_for_view, - ) - - suggested = opt_type_for_view(direction) - perp_dir = perp_direction_for_view(direction) - acct_note = "以期权为主:看法腿买期权,永续反向对冲" - else: - suggested = "P" if direction == "long" else "C" - perp_dir = direction - acct_note = "永续腿使用合约(交易)账户可用 USDT" - out = { - "ok": True, - "base": base, - "direction": direction, - "option_primary": option_primary, - "suggested_opt_type": suggested, - "perp_direction": perp_dir, - **data, - "gates": gates, - "sizing_mode": sizing_mode, - "account_kind": "perp", - "account_label": cfg.get("perp_account_label") or "合约账户", - "account_note": acct_note, - } - return jsonify(out) - - @app.route("/api/hedge-plan/options-chain") - @lr - def api_hedge_options_chain(): - if not cfg.get("options_enabled"): - return jsonify({"ok": False, "msg": "期权模块未启用"}), 400 - ex = cfg.get("exchange_options") - if ex is None: - return jsonify({"ok": False, "msg": "期权交易所未初始化"}), 400 - u = (request.args.get("underlying") or cfg.get("default_underly") or "ETH").upper() - # 热更新:链展示天数每次读 env - chain_max_dte = float( - os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") - or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") - or cfg.get("chain_max_dte") - or 14 - ) - try: - chain = cfg["build_option_chain"]( - ex, - u, - max_dte_days=chain_max_dte, - itm_only=False, - itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"), - ) - except Exception as e: - return jsonify({"ok": False, "msg": f"拉取期权链失败: {e}"}), 500 - # 可选:永期以期权为主时按最低剩余小时/行权间隔过滤(仅当请求显式带 option_primary) - # 默认拉链不再带此过滤,避免期期看不到明天到期 - option_primary = (request.args.get("option_primary") or "").strip().lower() in ( - "1", - "true", - "yes", - "on", - ) - min_hours = None - strike_interval = None - try: - if request.args.get("min_hours") not in (None, ""): - min_hours = float(request.args.get("min_hours")) - except (TypeError, ValueError): - min_hours = 36.0 if option_primary else None - try: - if request.args.get("strike_interval") not in (None, ""): - strike_interval = float(request.args.get("strike_interval")) - except (TypeError, ValueError): - strike_interval = 15.0 if option_primary else None - if option_primary and min_hours is None: - min_hours = 36.0 - if option_primary and strike_interval is None: - strike_interval = 15.0 - if min_hours is not None or strike_interval is not None: - from lib.hedge_plan.hedge_plan_option_primary_lib import hours_to_expiry_from_ms - - idx = None - try: - idx = float(chain.get("index_px") or 0) or None - except (TypeError, ValueError): - idx = None - filtered = [] - for exp in chain.get("expiries") or []: - h = hours_to_expiry_from_ms(exp.get("exp_time")) - if min_hours is not None and h is not None and h < min_hours: - continue - contracts = [] - for c in exp.get("contracts") or []: - row = dict(c) - row["hours_to_expiry"] = h - if strike_interval is not None and idx and idx > 0: - try: - k = float(row.get("strike") or 0) - except (TypeError, ValueError): - k = 0.0 - if k > 0 and abs(k - idx) > strike_interval + 1e-9: - continue - contracts.append(row) - if contracts: - filtered.append({**exp, "contracts": contracts, "hours_to_expiry": h}) - chain = {**chain, "expiries": filtered} - opt_acct = _options_account_snapshot(cfg) - return jsonify( - { - "ok": True, - **chain, - "underlying": u, - "chain_max_dte_days": chain_max_dte, - "account_kind": "options", - "account_label": cfg.get("options_account_label") or "期权账户", - "account_note": "期权腿使用期权账户(交易 USDC)", - "options_account": opt_acct, - "trade_budget_usdc": cfg.get("trade_budget_usdc"), - "budget_buffer": cfg.get("budget_buffer"), - "option_primary": option_primary, - "min_hours": min_hours, - "strike_interval": strike_interval, - } - ) - - @app.route("/api/hedge-plan/preview", methods=["POST"]) - @lr - def api_hedge_preview(): - body = request.get_json(silent=True) or {} - plan_type = (body.get("plan_type") or "perp_options").strip().lower() - gates = _gates_dict(cfg, plan_type) - if not gates.get("can_preview"): - return jsonify({"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可测算"]), "gates": gates}), 400 - try: - if plan_type == "options_options": - data = _preview_oo(body) - else: - data = _preview_po(body) - except ValueError as e: - return jsonify({"ok": False, "msg": str(e)}), 400 - except Exception as e: - return jsonify({"ok": False, "msg": f"测算失败: {e}"}), 500 - return jsonify({"ok": True, "gates": gates, **data}) - - @app.route("/api/hedge-plan/validate-path", methods=["POST"]) - @lr - def api_hedge_validate_path(): - """只校验下单路径(强制 dry_run),不真实成交.""" - from lib.hedge_plan.hedge_plan_orders_lib import ( - execute_options_options_start, - execute_perp_options_start, - validate_start_body, - ) - - body = request.get_json(silent=True) or {} - plan_type = (body.get("plan_type") or "perp_options").strip().lower() - err = validate_start_body(plan_type, body) - if err: - return jsonify({"ok": False, "msg": err}), 400 - if plan_type == "options_options": - out = execute_options_options_start(cfg, body, dry_run=True) - else: - out = execute_perp_options_start(cfg, body, dry_run=True) - return jsonify(out), (200 if out.get("ok") else 400) - - @app.route("/api/hedge-plan/start", methods=["POST"]) - @lr - def api_hedge_start(): - from lib.hedge_plan.hedge_plan_orders_lib import ( - execute_options_options_start, - execute_perp_options_start, - validate_start_body, - ) - - body = request.get_json(silent=True) or {} - plan_type = (body.get("plan_type") or "perp_options").strip().lower() - dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) - try: - from lib.options.options_margin_mode_lib import is_coin_margin_mode - - if is_coin_margin_mode() and not dry_run: - return jsonify( - { - "ok": False, - "msg": "当前单笔期权为币本位模式,对冲计划仅支持 USDC 期权;请将 OKX_OPTIONS_MARGIN_MODE=usdc 并重启后再开对冲", - } - ), 400 - except Exception as e: - return jsonify( - {"ok": False, "msg": f"期权本位校验失败,已拒绝开对冲: {e}"} - ), 400 - with _hedge_start_lock(): - gates = _gates_dict(cfg, plan_type) - if not dry_run and not gates.get("can_start"): - return jsonify( - {"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可开仓"]), "gates": gates} - ), 400 - err = validate_start_body(plan_type, body) - if err: - return jsonify({"ok": False, "msg": err, "gates": gates}), 400 - # 补齐永续杠杆(以期权为主默认 100;保险模式 BTC/ETH 用 btc_leverage) - if plan_type == "perp_options" and not body.get("leverage"): - from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary - - if is_option_primary(body): - body["leverage"] = 100 - else: - base = str(body.get("underlying") or "ETH").upper() - if base in ("BTC", "ETH"): - body["leverage"] = int(cfg.get("btc_leverage") or 10) - else: - body["leverage"] = int(cfg.get("alt_leverage") or 5) - # 以期权为主:策略启动=盯盘,不现场开仓 - if plan_type == "perp_options": - from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary - - watch = body.get("watch_entry") - watch_on = watch in (None, "", True, 1, "1", "true", "yes", "on") - if is_option_primary(body) and watch_on: - if dry_run: - return jsonify( - { - "ok": True, - "dry_run": True, - "watching": True, - "msg": "dry_run:将创建盯盘计划(不落库)", - "gates": gates, - } - ) - plan_id = _persist_po_watching(cfg, body) - return jsonify( - { - "ok": True, - "watching": True, - "plan_id": plan_id, - "msg": "已启动盯盘,杠杆/间隔达标后自动开仓", - "gates": gates, - } - ) - if plan_type == "options_options": - out = execute_options_options_start( - cfg, - body, - dry_run=dry_run, - persist=(None if dry_run else (lambda r, b: _persist_oo(cfg, r, b))), - ) - else: - out = execute_perp_options_start( - cfg, - body, - dry_run=dry_run, - persist=(None if dry_run else (lambda r, b: _persist_po(cfg, r, b))), - ) - out["gates"] = gates - return jsonify(out), (200 if out.get("ok") else 400) - - @app.route("/api/hedge-plan//end", methods=["POST"]) - @lr - def api_hedge_end_plan(plan_id: int): - """人工结束进行中计划:不自动平仓;未成交腿改为 cancelled.""" - from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables - from lib.hedge_plan.hedge_plan_orders_lib import execute_manual_end_plan - - conn = cfg["get_db"]() - try: - init_hedge_plan_tables(conn) - out = execute_manual_end_plan(cfg, conn, plan_id) - if not out.get("ok"): - return jsonify(out), 400 - conn.commit() - finally: - conn.close() - return jsonify(out) - - @app.route("/api/hedge-plan//complete-leg", methods=["POST"]) - @lr - def api_hedge_complete_leg(plan_id: int): - """半腿待补:手动补开缺失腿,成功后升为 active.""" - import json - - from lib.hedge_plan.hedge_plan_db import ( - get_plan, - get_plan_legs, - init_hedge_plan_tables, - update_leg, - update_plan, - ) - from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start - from lib.hedge_plan.hedge_plan_orders_lib import execute_complete_missing_leg - - body = request.get_json(silent=True) or {} - dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) - if not dry_run and not _hedge_enabled(): - return jsonify({"ok": False, "msg": "当前交易模式为单独期权,不可补开对冲腿"}), 400 - conn = cfg["get_db"]() - try: - init_hedge_plan_tables(conn) - plan = get_plan(conn, plan_id) - if not plan: - return jsonify({"ok": False, "msg": "计划不存在"}), 404 - pt = str(plan.get("plan_type") or "") - if pt == "perp_options" and not _show_perp_options(): - return jsonify({"ok": False, "msg": "当前模式非永期对冲,不可补开"}), 400 - if pt == "options_options" and not _show_options_options(): - return jsonify({"ok": False, "msg": "当前模式非期期对冲,不可补开"}), 400 - if str(plan.get("status") or "") != "partial": - return jsonify({"ok": False, "msg": "仅半腿待补(partial)计划可补开"}), 400 - legs = get_plan_legs(conn, plan_id) - start_body: dict[str, Any] = {} - try: - meta = json.loads(plan.get("preview_json") or "{}") - if isinstance(meta, dict): - start_body = dict(meta.get("start_body") or {}) - except Exception: - start_body = {} - if not start_body: - return jsonify({"ok": False, "msg": "缺少开仓参数,无法补开"}), 400 - # 允许请求体覆盖少量字段 - for k in ("contracts", "leverage", "sheets", "tp", "sl"): - if body.get(k) not in (None, ""): - start_body[k] = body.get(k) - out = execute_complete_missing_leg( - cfg, plan, legs, start_body, dry_run=dry_run - ) - if not out.get("ok"): - return jsonify(out), 400 - if dry_run: - return jsonify(out) - fill = out.get("fill") or {} - leg_id = out.get("leg_id") - role = str(out.get("leg_role") or "") - opened_at = out.get("opened_at") - if leg_id: - if role == "perp": - update_leg( - conn, - int(leg_id), - status="open", - size=float(fill.get("contracts") or start_body.get("contracts") or 0), - avg_open=float(start_body.get("entry") or plan.get("entry_mark") or 0), - exchange_ord_id=str(fill.get("exchange_ord_id") or ""), - opened_at=opened_at, - ) - update_plan( - conn, - plan_id, - status="active", - close_reason=None, - note=None, - perp_size=float(fill.get("contracts") or start_body.get("contracts") or 0), - ) - else: - prem = float(fill.get("premium") or 0) - update_leg( - conn, - int(leg_id), - status="open", - size=float(fill.get("sheets") or start_body.get("sheets") or 1), - avg_open=float(fill.get("ask") or 0), - premium=prem, - exchange_ord_id=str(fill.get("exchange_ord_id") or ""), - opened_at=opened_at, - inst_id=str(fill.get("inst_id") or ""), - ) - old_prem = float(plan.get("premium_total") or 0) - update_plan( - conn, - plan_id, - status="active", - close_reason=None, - note=None, - premium_total=old_prem + prem, - ) - conn.commit() - plan2 = get_plan(conn, plan_id) - legs2 = get_plan_legs(conn, plan_id) - if plan2: - notify_plan_start(cfg, conn, plan2, legs2) - conn.commit() - out["plan_id"] = plan_id - out["status"] = "active" - out["plan"] = plan2 - out["legs"] = legs2 - return jsonify(out) - finally: - conn.close() - - @app.route("/api/hedge-plan/list") - @lr - def api_hedge_list(): - from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, list_plans - - status = (request.args.get("status") or "").strip() or None - plan_type = (request.args.get("plan_type") or "").strip() or None - underlying = (request.args.get("underlying") or "").strip() or None - conn = cfg["get_db"]() - try: - init_hedge_plan_tables(conn) - rows = list_plans( - conn, status=status, plan_type=plan_type, underlying=underlying, limit=80 - ) - conn.commit() - finally: - conn.close() - return jsonify({"ok": True, "plans": rows}) - - @app.route("/api/hedge-plan/history") - @lr - def api_hedge_history(): - from lib.hedge_plan.hedge_plan_db import ( - attach_legs_to_plans, - init_hedge_plan_tables, - list_plans, - ) - - conn = cfg["get_db"]() - try: - init_hedge_plan_tables(conn) - rows = list_plans(conn, status="closed", limit=100) - failed = list_plans(conn, status="failed", limit=50) - cancelled = list_plans(conn, status="cancelled", limit=50) - merged = attach_legs_to_plans(conn, rows + failed + cancelled) - conn.commit() - finally: - conn.close() - return jsonify({"ok": True, "plans": merged}) - - @app.route("/api/hedge-plan/active") - @lr - def api_hedge_active(): - from lib.hedge_plan.hedge_plan_db import ( - attach_legs_to_plans, - init_hedge_plan_tables, - list_plans, - ) - from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs - - conn = cfg["get_db"]() - try: - init_hedge_plan_tables(conn) - rows = [] - for status in ("watching", "opening", "active", "partial"): - rows.extend(list_plans(conn, status=status, limit=80)) - rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) - for row in rows: - if str(row.get("status") or "") == "watching": - continue - try: - reconcile_unfilled_option_legs(cfg, conn, int(row["id"])) - except Exception: - pass - # 校正后可能 status 变化,重新拉一遍 - rows = [] - for status in ("watching", "opening", "active", "partial"): - rows.extend(list_plans(conn, status=status, limit=80)) - rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) - plans = attach_legs_to_plans(conn, rows) - conn.commit() - finally: - conn.close() - return jsonify({"ok": True, "plans": plans}) - - @app.route("/api/hedge-plan/stats") - @lr - def api_hedge_stats(): - from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, stats_summary - - conn = cfg["get_db"]() - try: - init_hedge_plan_tables(conn) - s = stats_summary(conn) - conn.commit() - finally: - conn.close() - return jsonify({"ok": True, **s}) - - @app.route("/api/hedge-plan/") - @lr - def api_hedge_detail(plan_id: int): - from lib.hedge_plan.hedge_plan_db import ( - get_plan, - get_plan_legs, - init_hedge_plan_tables, - legs_contract_summary, - ) - from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs - - conn = cfg["get_db"]() - try: - init_hedge_plan_tables(conn) - plan = get_plan(conn, plan_id) - if not plan: - return jsonify({"ok": False, "msg": "计划不存在"}), 404 - # 打开细节时校正:无成交却标 open → cancelled - if str(plan.get("status") or "") in ("opening", "active", "partial"): - reconcile_unfilled_option_legs(cfg, conn, plan_id) - plan = get_plan(conn, plan_id) or plan - legs = get_plan_legs(conn, plan_id) - conn.commit() - finally: - conn.close() - return jsonify( - { - "ok": True, - "plan": plan, - "legs": legs, - "contracts_summary": legs_contract_summary(legs), - } - ) - - @app.route("/api/hedge-plan/", methods=["DELETE"]) - @lr - def api_hedge_delete(plan_id: int): - from lib.hedge_plan.hedge_plan_db import delete_plan, init_hedge_plan_tables - - conn = cfg["get_db"]() - try: - init_hedge_plan_tables(conn) - out = delete_plan(conn, plan_id) - if not out.get("ok"): - return jsonify(out), 400 - conn.commit() - finally: - conn.close() - return jsonify(out) - - @app.route("/api/hedge-plan/monitor-tick", methods=["POST"]) - @lr - def api_hedge_monitor_tick(): - from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans - - return jsonify(tick_active_plans(cfg)) - - -def _preview_po(body: dict[str, Any]) -> dict[str, Any]: - from lib.hedge_plan.hedge_plan_moneyness_lib import validate_po_option_moneyness - from lib.hedge_plan.hedge_plan_option_primary_lib import ( - build_option_primary_preview, - is_option_primary, - size_from_premium, - validate_option_primary_start, - ) - - if is_option_primary(body): - err = validate_option_primary_start(body) - if err: - raise ValueError(err) - sized = size_from_premium( - premium_budget=float(body.get("premium_budget") or 0), - ask=float(body.get("ask") or 0), - ct_mult=float(body.get("ct_mult") or 0.01), - ratio=float(body.get("option_perp_ratio") or 2), - contract_size=float(body.get("contract_size") or 0.01), - ) - if not sized.get("ok"): - raise ValueError(sized.get("msg") or "定仓失败") - body = dict(body) - body["sheets"] = sized["sheets"] - body["contracts"] = sized["contracts"] - body["eth_qty"] = sized["eth_qty"] - if not body.get("entry"): - body["entry"] = body.get("index_px") or 0 - out = build_option_primary_preview(body) - out["sizing"] = sized - return out - - direction = str(body.get("direction") or "long").lower() - entry = float(body["entry"]) - tp = float(body["tp"]) - sl = float(body["sl"]) - contracts = float(body["contracts"]) - contract_size = float(body.get("contract_size") or 0.01) - opt_type = str(body.get("opt_type") or ("P" if direction == "long" else "C")) - strike = float(body["strike"]) - sheets = float(body.get("sheets") or 1) - ct_mult = float(body.get("ct_mult") or 0.01) - ask = body.get("ask") - premium = body.get("premium_paid") - if premium is None: - if ask is None: - raise ValueError("缺少权利金或卖一价") - premium = option_premium_total(ask=float(ask), sheets=sheets, ct_mult=ct_mult) - index_px = body.get("index_px") - idx_for_money = float(index_px) if index_px is not None else entry - money_err = validate_po_option_moneyness( - opt_type=opt_type, - strike=strike, - index_px=idx_for_money, - ask=ask, - hours_to_expiry=body.get("hours_to_expiry"), - ) - if money_err: - raise ValueError(money_err) - return build_perp_options_preview( - direction=direction, - entry=entry, - tp=tp, - sl=sl, - contracts=contracts, - contract_size=contract_size, - opt_type=opt_type, - strike=strike, - sheets=sheets, - ct_mult=ct_mult, - premium_paid=float(premium), - index_px=float(index_px) if index_px is not None else None, - ) - - -def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: - from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness - - rr_raw = body.get("profit_rr") - profit_rr = None - if rr_raw not in (None, ""): - profit_rr = float(rr_raw) - if profit_rr <= 0: - raise ValueError("盈亏比须大于0") - up = body.get("target_price_up") - down = body.get("target_price_down") - legacy = body.get("target_price") - if up in (None, "") and legacy not in (None, ""): - up = legacy - if down in (None, "") and legacy not in (None, ""): - down = legacy - if profit_rr is None and (up in (None, "") or down in (None, "")): - raise ValueError("请填写盈亏比") - up_f = float(up) if up not in (None, "") else None - down_f = float(down) if down not in (None, "") else None - if profit_rr is None and up_f is not None and down_f is not None and up_f <= down_f: - raise ValueError("上破目标价必须大于下破目标价") - index_px = body.get("index_px") - if index_px in (None, ""): - if up_f is not None and down_f is not None: - index_px = (up_f + down_f) / 2 - else: - raise ValueError("缺少指数价格") - index_px = float(index_px) - leg_a = body.get("leg_a") or {} - leg_b = body.get("leg_b") or {} - for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)): - if not leg.get("strike"): - raise ValueError(f"缺少 {name} 行权价") - if leg.get("premium_paid") is None and leg.get("ask") is not None: - leg["premium_paid"] = option_premium_total( - ask=float(leg["ask"]), - sheets=float(leg.get("sheets") or 1), - ct_mult=float(leg.get("ct_mult") or 0.01), - ) - if leg.get("premium_paid") is None: - raise ValueError(f"缺少 {name} 权利金") - money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px) - if money_err: - raise ValueError(money_err) - return build_options_options_preview( - profit_rr=profit_rr, - target_price_up=up_f, - target_price_down=down_f, - index_px=index_px, - leg_a=leg_a, - leg_b=leg_b, - ) - - -def _fetch_perp_market(cfg: dict[str, Any], base: str) -> tuple[dict[str, Any], str | None]: - ex = cfg.get("exchange") - if ex is None: - return {}, "永续交易所未初始化" - ensure = cfg.get("ensure_markets_loaded") - if callable(ensure): - try: - ensure() - except Exception as e: - return {}, f"加载市场失败: {e}" - norm = cfg.get("normalize_exchange_symbol") - sym = f"{base}/USDT:USDT" - if callable(norm): - try: - sym = norm(f"{base}/USDT") - except Exception: - sym = f"{base}/USDT:USDT" - mark = bid = ask = last = None - try: - t = ex.fetch_ticker(sym) - last = _sf(t.get("last")) - mark = _sf(t.get("info", {}).get("markPx")) if isinstance(t.get("info"), dict) else None - if mark is None: - mark = _sf(t.get("mark")) or last - bid = _sf(t.get("bid")) - ask = _sf(t.get("ask")) - except Exception as e: - return {}, f"拉永续行情失败: {e}" - - cs = 0.01 - get_cs = cfg.get("get_contract_size") - if callable(get_cs): - try: - cs = float(get_cs(sym) or 0.01) - except Exception: - cs = 0.01 - - available = None - get_av = cfg.get("get_available_trading_usdt") - if callable(get_av): - try: - available = get_av() - except Exception: - available = None - - entry = float(mark or last or 0) - sizing = None - suggest_contracts = None - amount_precision = 4 - try: - amount_precision = int(amount_decimals_from_exchange(ex, sym)) - except Exception: - amount_precision = 4 - if available is not None and entry > 0: - sizing, _serr = compute_full_margin_sizing( - symbol=sym, - available_usdt=float(available), - capital_base=float(available), - buffer_ratio=float(cfg.get("full_margin_buffer") or 0.98), - btc_leverage=int(cfg.get("btc_leverage") or 10), - alt_leverage=int(cfg.get("alt_leverage") or 5), - funds_decimals=int(cfg.get("funds_decimals") or 2), - ) - if sizing: - raw_contracts = suggest_contracts_from_notional( - notional=float(sizing["notional_value"]), - entry=entry, - contract_size=cs, - ) - # 优先走交易所 amount_to_precision;失败则按精度位数向下取整 - suggest_contracts = None - try: - precise = float(ex.amount_to_precision(sym, raw_contracts)) - if precise > raw_contracts + 1e-12: - precise = floor_contracts_to_precision(raw_contracts, amount_precision) - suggest_contracts = precise - except Exception: - suggest_contracts = floor_contracts_to_precision(raw_contracts, amount_precision) - - return { - "exchange_symbol": sym, - "mark": mark, - "last": last, - "bid": bid, - "ask": ask, - "contract_size": cs, - "available_usdt": available, - "full_margin_sizing": sizing, - "suggest_contracts": suggest_contracts, - "amount_precision": amount_precision, - "unit_quote": "USDT", - "unit_contracts": "合约张", - "unit_note": "价格单位 USDT;张数=交易所永续合约张(与下单精度一致);名义≈张数×面值×价格", - "entry_ref": entry or None, - }, None - - -def _options_account_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: - """期权账户资金快照(与期权页同源: exchange_options).""" - out: dict[str, Any] = { - "label": cfg.get("options_account_label") or "期权账户", - "trading_usdc": None, - "funding_usdc": None, - "trading_usdt": None, - "funding_usdt": None, - } - ex = cfg.get("exchange_options") - hdr = cfg.get("options_header_balances") - if ex is None or not callable(hdr): - return out - try: - trading_usdc, funding_usdc, funding_usdt, trading_usdt = hdr(ex, force=False) - out.update( - { - "trading_usdc": trading_usdc, - "funding_usdc": funding_usdc, - "trading_usdt": trading_usdt, - "funding_usdt": funding_usdt, - } - ) - except Exception: - pass - return out - - -def _sf(v: Any) -> float | None: - if v is None or v == "": - return None - try: - return float(v) - except (TypeError, ValueError): - return None diff --git a/lib/hedge_plan/hedge_plan_settle_lib.py b/lib/hedge_plan/hedge_plan_settle_lib.py deleted file mode 100644 index b1d6803..0000000 --- a/lib/hedge_plan/hedge_plan_settle_lib.py +++ /dev/null @@ -1,217 +0,0 @@ -"""对冲计划结算辅助:到期内在价值与期权腿收口.""" -from __future__ import annotations - -import os -import time -from datetime import datetime -from typing import Any, Callable, Optional -from zoneinfo import ZoneInfo - -from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history -from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl - -_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai") - - -def _sf(v: Any) -> Optional[float]: - try: - if v is None or v == "": - return None - return float(v) - except (TypeError, ValueError): - return None - - -def leg_exp_ms(leg: dict[str, Any]) -> Optional[int]: - return normalize_option_exp_ms(leg.get("exp_time"), str(leg.get("inst_id") or "")) - - -def leg_is_expired(leg: dict[str, Any], *, now_ms: Optional[int] = None) -> bool: - exp = leg_exp_ms(leg) - if exp is None: - return False - now = int(now_ms if now_ms is not None else time.time() * 1000) - return now >= int(exp) - - -def settle_option_leg_at_spot(leg: dict[str, Any], spot: float) -> float: - """按到期结算口径估算腿盈亏(USDC).""" - premium = float(leg.get("premium") or 0) - strike = _sf(leg.get("strike")) - if strike is None: - return -premium - sheets = float(leg.get("size") or 1) - # ct_mult 未入库时默认 0.01 - ct = float(leg.get("ct_mult") or 0.01) - return float( - option_expiry_pnl( - opt_type=str(leg.get("opt_type") or "P"), - strike=float(strike), - spot=float(spot), - sheets=sheets, - ct_mult=ct, - premium_paid=premium, - ) - ) - - -def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int] = None) -> bool: - opts = [ - x - for x in legs - if str(x.get("leg_role") or "").startswith("option") - and str(x.get("status") or "") in ("open", "hold_to_expiry") - ] - if not opts: - return False - return all(leg_is_expired(x, now_ms=now_ms) for x in opts) - - -def _parse_opened_ms(raw: Any) -> Optional[int]: - """墙钟开仓时间 → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC.""" - if raw is None or raw == "": - return None - s = str(raw).strip() - if not s: - return None - for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)): - try: - dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ) - return int(dt.timestamp() * 1000) - except ValueError: - continue - return None - - -def resolve_option_leg_realized_pnl( - *, - ex: Any = None, - leg: dict[str, Any], - fallback: Optional[float] = None, - fetch_history_fn: Optional[Callable[[str], list[dict[str, Any]]]] = None, - hist_rows: Optional[list[dict[str, Any]]] = None, -) -> tuple[Optional[float], str]: - """ - 期权腿已实现盈亏:优先 OKX positions-history realizedPnl. - 返回 (pnl, source) source=exchange|fallback|none. - """ - inst_id = str(leg.get("inst_id") or "").strip() - open_ms = _parse_opened_ms(leg.get("opened_at")) - rows = hist_rows - if rows is None and inst_id: - try: - if callable(fetch_history_fn): - rows = fetch_history_fn(inst_id) - elif ex is not None: - from lib.exchange.okx_options_lib import fetch_option_position_history - - rows = fetch_option_position_history(ex, inst_id) - except Exception: - rows = None - if rows: - close_ms = _parse_opened_ms(leg.get("closed_at")) - sheets = _sf(leg.get("size")) or _sf(leg.get("sheets")) - info = resolve_option_close_from_history( - rows, open_ms=open_ms, close_ms=close_ms, sheets=sheets - ) - pnl = _sf((info or {}).get("realized_pnl")) if info else None - if pnl is not None: - return round(float(pnl), 4), "exchange" - if fallback is not None: - return round(float(fallback), 4), "fallback" - return None, "none" - - -def backfill_hedge_option_legs_realized_pnl( - conn: Any, - hist_rows: list[dict[str, Any]], - *, - update_plan_fn: Optional[Callable[..., Any]] = None, -) -> dict[str, int]: - """用交易所历史覆盖已平期权腿盈亏,并重算已结束计划合计.""" - from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan - - by_inst: dict[str, list[dict[str, Any]]] = {} - for raw in hist_rows or []: - if not isinstance(raw, dict): - continue - inst = str(raw.get("instId") or "").strip() - if inst: - by_inst.setdefault(inst, []).append(raw) - - legs = conn.execute( - """ - SELECT * FROM hedge_plan_legs - WHERE status = 'closed' - AND inst_id IS NOT NULL AND TRIM(inst_id) != '' - AND (leg_role LIKE 'option%' OR opt_type IS NOT NULL) - ORDER BY id DESC - LIMIT 400 - """ - ).fetchall() - updated_legs = 0 - touched_plans: set[int] = set() - for row in legs: - leg = dict(row) - inst = str(leg.get("inst_id") or "").strip() - if not inst or inst not in by_inst: - continue - pnl, src = resolve_option_leg_realized_pnl( - leg=leg, - hist_rows=by_inst[inst], - fallback=None, - ) - if src != "exchange" or pnl is None: - continue - local = _sf(leg.get("realized_pnl")) - if local is not None and abs(local - pnl) < 1e-6: - continue - conn.execute( - "UPDATE hedge_plan_legs SET realized_pnl=? WHERE id=?", - (pnl, int(leg["id"])), - ) - updated_legs += 1 - touched_plans.add(int(leg["plan_id"])) - - updated_plans = 0 - updater = update_plan_fn or update_plan - for pid in touched_plans: - plan = get_plan(conn, pid) - if not plan or str(plan.get("status") or "") != "closed": - continue - plan_legs = get_plan_legs(conn, pid) - opt_sum = 0.0 - for lg in plan_legs: - role = str(lg.get("leg_role") or "") - if not (role.startswith("option") or lg.get("opt_type")): - continue - if str(lg.get("status") or "") != "closed": - continue - opt_sum += float(_sf(lg.get("realized_pnl")) or 0.0) - perp = float(_sf(plan.get("realized_pnl_perp")) or 0.0) - ptype = str(plan.get("plan_type") or "") - if ptype == "options_options": - total = opt_sum - kwargs: dict[str, Any] = { - "realized_pnl_options": round(opt_sum, 4), - "realized_pnl_total": round(total, 4), - } - else: - total = perp + opt_sum - kwargs = { - "realized_pnl_perp": round(perp, 4), - "realized_pnl_options": round(opt_sum, 4), - "realized_pnl_total": round(total, 4), - } - old_total = _sf(plan.get("realized_pnl_total")) - old_opts = _sf(plan.get("realized_pnl_options")) - if ( - old_total is not None - and abs(old_total - total) < 1e-6 - and old_opts is not None - and abs(old_opts - opt_sum) < 1e-6 - ): - continue - updater(conn, pid, **kwargs) - updated_plans += 1 - return {"legs": updated_legs, "plans": updated_plans} diff --git a/lib/hedge_plan/okx_trade_mode_lib.py b/lib/hedge_plan/okx_trade_mode_lib.py deleted file mode 100644 index 0ce16c9..0000000 --- a/lib/hedge_plan/okx_trade_mode_lib.py +++ /dev/null @@ -1,96 +0,0 @@ -"""OKX 期权/对冲模式(env: OKX_TRADE_MODE). - -options → 仅单独期权(隐藏对冲导航与对冲 env 配置) -options_options → 仅期期对冲(不可单独开期权) -perp_options → 永期对冲(含合约腿);产品 UI 已隐藏,仅兼容旧 .env / 历史计划 -""" -from __future__ import annotations - -import os -from typing import Optional - -MODE_OPTIONS = "options" -MODE_PERP = "perp_options" -MODE_OO = "options_options" -VALID_MODES = frozenset({MODE_OPTIONS, MODE_PERP, MODE_OO}) -# 配置页可选模式(不含永期) -UI_MODES = frozenset({MODE_OPTIONS, MODE_OO}) - -_ALIASES = { - "option": MODE_OPTIONS, - "standalone": MODE_OPTIONS, - "期权": MODE_OPTIONS, - "单独期权": MODE_OPTIONS, - "po": MODE_PERP, - "perp": MODE_PERP, - "永期": MODE_PERP, - "永期对冲": MODE_PERP, - "oo": MODE_OO, - "期期": MODE_OO, - "期期对冲": MODE_OO, -} - - -def _env_bool(name: str, default: bool = False) -> bool: - raw = os.getenv(name) - if raw is None or str(raw).strip() == "": - return default - return str(raw).strip().lower() in ("1", "true", "yes", "on") - - -def normalize_okx_trade_mode(raw: Optional[str]) -> str: - s = str(raw or "").strip().lower() - if s in VALID_MODES: - return s - if s in _ALIASES: - return _ALIASES[s] - return "" - - -def legacy_infer_okx_trade_mode() -> str: - """未配置 OKX_TRADE_MODE 时默认单独期权(对冲关闭). - - 旧 HEDGE_PLAN_* 开关不再自动打开对冲,避免误开;需显式设 OKX_TRADE_MODE=options_options. - """ - return MODE_OPTIONS - - -def get_okx_trade_mode() -> str: - m = normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE")) - if m: - return m - return legacy_infer_okx_trade_mode() - - -def hedge_module_enabled() -> bool: - return get_okx_trade_mode() in (MODE_PERP, MODE_OO) - - -def show_perp_options() -> bool: - return get_okx_trade_mode() == MODE_PERP - - -def show_options_options() -> bool: - return get_okx_trade_mode() == MODE_OO - - -def standalone_options_open_allowed() -> bool: - return get_okx_trade_mode() == MODE_OPTIONS - - -def mode_label(mode: Optional[str] = None) -> str: - m = mode or get_okx_trade_mode() - return { - MODE_OPTIONS: "单独期权", - MODE_PERP: "永期对冲", - MODE_OO: "期期对冲", - }.get(m, m or "—") - - -def block_standalone_open_by_mode_msg() -> Optional[str]: - if standalone_options_open_allowed(): - return None - return ( - f"当前交易模式为「{mode_label()}」,不可单独开期权;" - "请在 env「交易模式」切换为「单独期权」" - ) diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html deleted file mode 100644 index 5c809ef..0000000 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ /dev/null @@ -1,408 +0,0 @@ -
- {% if not hedge_plan_enabled %} -
对冲计划未启用:请在 env配置 → 对冲计划 打开 HEDGE_PLAN_ENABLED(可热更).
- {% endif %} - {% if not options_enabled %} -
期权模块未启用,无法拉期权链.请先配置期权账户.
- {% endif %} - {% if hedge_plan_enabled and not hedge_plan_show_perp_options and not hedge_plan_show_options_options %} -
永期与期期 Tab 均已隐藏:请在 env「期权/对冲模式」切换交易模式;进行中/历史仍可查看.
- {% endif %} - -
-
-

对冲计划 测算 · 下单 - 期权开平仓与监控说明 -

- -
-
- {% if hedge_plan_show_perp_options %} - - {% endif %} - {% if hedge_plan_show_options_options %} - - {% endif %} - - - -
-

-

永续腿→合约账户 · 期权腿→期权账户

-
- -
-
-
-

- 以期权为主 - 执行参数 - · ETH - 合约账户 -

-
- 规则说明 -
-

账户:永续腿走合约账户(USDT);期权腿走期权账户(USDC)。两账户分开下单、资金不互通。

-

模式:在 env HEDGE_PLAN_OPTION_PRIMARY 切换(true=以期权为主 / false=保险模式);标题前标识当前模式。

-

保险模式:做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场。

-

以期权为主:填参后点「策略启动」进入盯盘(非现场开仓);杠杆/间隔达标后自动先开期权再市价永续。右侧列表仅展示达标候选。

-
-
-
- - -
-
-
- - -
-
- -
-
-

资金与杠杆配置

-
- - - -
-
-
-

选约条件

-
- - - - -
-
-
-

出场条件

-
- - -
-
-
-
-
-
-
-
-
-
-
-

永续行情 合约账户

-
- 标记 — -
-

加载中…

- -
-
-

期权 · Call 期权账户

-
- - - - - 指数 — -
-
- - - - - - - - - - - - - - -
行权价实虚值杠杆卖一/张买一/张操作
请刷新期权链
-
-
- - - -
-
-
-
-
- - -
-
-
-
- - - - - - - - - - - - -
- diff --git a/lib/instance/instance_dashboard_lib.py b/lib/instance/instance_dashboard_lib.py index 824065b..2437240 100644 --- a/lib/instance/instance_dashboard_lib.py +++ b/lib/instance/instance_dashboard_lib.py @@ -1,571 +1,481 @@ -"""实例数据看板:本户活跃监控 / 持仓只读聚合.""" -from __future__ import annotations - -from datetime import datetime, timezone -from typing import Any, Callable, Optional - - -def _row_dict(row: Any) -> dict[str, Any]: - if row is None: - return {} - if isinstance(row, dict): - return dict(row) - try: - return dict(row) - except Exception: - return {} - - -def _safe_float(v: Any) -> Optional[float]: - try: - if v is None or v == "": - return None - return float(v) - except (TypeError, ValueError): - return None - - -def _dir_label(direction: Any) -> str: - d = str(direction or "").strip().lower() - if d == "short": - return "做空" - if d == "long": - return "做多" - return str(direction or "-") - - -def _format_order_item(od: dict[str, Any]) -> dict[str, Any]: - try: - from lib.trade.trade_labels_lib import apply_order_monitor_source_labels - - od = apply_order_monitor_source_labels(od) - except Exception: - pass - try: - from lib.trade.entry_model_lib import enrich_entry_model_display - - enrich_entry_model_display(od) - except Exception: - pass - sym = od.get("exchange_symbol") or od.get("symbol") or "-" - direction = str(od.get("direction") or "long").lower() - mt = od.get("monitor_type_display") or od.get("monitor_type") or "" - kst = od.get("key_signal_type") or "" - title = f"{sym} {_dir_label(direction)}" - bits = [x for x in (mt, kst) if x] - subtitle = " · ".join(bits) if bits else "" - entry = _safe_float(od.get("trigger_price")) - sl = _safe_float(od.get("stop_loss")) - tp = _safe_float(od.get("take_profit")) - return { - "id": od.get("id"), - "kind": "order", - "tab": "trade", - "title": title, - "subtitle": subtitle, - "symbol": sym, - "price_symbol": od.get("symbol") or sym, - "direction": direction, - "direction_label": _dir_label(direction), - "entry": entry, - "mark_price": None, - "contracts": _safe_float(od.get("order_amount")), - "tp_profit": None, - "float_pnl": None, - "stop_loss": sl, - "take_profit": tp, - "status": od.get("status") or "active", - } - - -OPTIONS_SOURCE_LABELS = { - "option": "纯期权", - "perp_options": "永期对冲", - "options_options": "期期对冲", -} - -HEDGE_ACTIVE_STATUSES = frozenset({"opening", "active", "partial"}) - - -def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]: - """根据进行中对冲计划腿判定来源;默认纯期权. 返回 (source, label, plan_id).""" - default = ("option", OPTIONS_SOURCE_LABELS["option"], None) - if not inst_id or not _table_exists(conn, "hedge_plans") or not _table_exists(conn, "hedge_plan_legs"): - return default - try: - row = conn.execute( - """ - SELECT p.plan_type, p.id - FROM hedge_plans p - JOIN hedge_plan_legs l ON l.plan_id = p.id - WHERE p.status IN ('opening', 'active', 'partial') - AND l.status = 'open' - AND l.inst_id = ? - ORDER BY p.id DESC - LIMIT 1 - """, - (inst_id,), - ).fetchone() - except Exception: - return default - if not row: - return default - d = _row_dict(row) - pt = str(d.get("plan_type") or "").strip() - try: - plan_id = int(d["id"]) if d.get("id") is not None else None - except (TypeError, ValueError): - plan_id = None - if pt in OPTIONS_SOURCE_LABELS and pt != "option": - return pt, OPTIONS_SOURCE_LABELS[pt], plan_id - return default - - -def _format_profit_exit_mult(mult: Any) -> str: - try: - n = float(mult) - except (TypeError, ValueError): - return "1倍" - if n <= 0: - return "1倍" - if abs(n - round(n)) < 1e-9: - return f"{int(round(n))}倍" - return f"{n:g}倍" - - -def _format_options_target(p: dict[str, Any]) -> str: - hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None - opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() - if hedge: - rr = _safe_float(hedge.get("profit_rr")) - pid = hedge.get("plan_id") - if rr is not None and rr > 0: - return f"对冲#{pid} 盈亏比 {rr:g}" if pid is not None else f"盈亏比 {rr:g}" - ot = str(hedge.get("opt_type") or opt_type).upper() - side = "Put ≤" if ot == "P" else "Call ≥" - tgt = _safe_float(hedge.get("target_index")) - if tgt is not None: - return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}" - parts: list[str] = [] - tgt = _safe_float(p.get("target_index")) - if tgt is not None and tgt > 0: - side = "Put ≤" if opt_type == "P" else "Call ≥" - parts.append(f"{side} {tgt:g}") - if p.get("profit_exit_enabled"): - parts.append(_format_profit_exit_mult(p.get("profit_exit_mult"))) - if parts: - return " · ".join(parts) - return "—" - - -def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]: - inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" - opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() - label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") - # 看板期权列:统一折成 U(币本位 BTC/ETH 权利金盈亏 × 指数) - pnl = None - try: - from lib.options.options_positions_lib import display_pnl_usdt_from_option_row - - pnl = display_pnl_usdt_from_option_row(p) - except Exception: - pnl = None - pos = _safe_float(p.get("pos")) - exp_ms = p.get("exp_time_ms") - if exp_ms is None: - exp_ms = p.get("exp_time") - try: - exp_ms = int(float(exp_ms)) if exp_ms not in (None, "") else None - except (TypeError, ValueError): - exp_ms = None - if conn is not None: - source_key, source_label, source_plan_id = _resolve_options_source(conn, inst) - else: - source_key, source_label, source_plan_id = "option", OPTIONS_SOURCE_LABELS["option"], None - return { - "id": inst, - "kind": "options", - "tab": "options", - "title": f"{inst} {label}", - "subtitle": f"张数 {pos if pos is not None else '-'}", - "inst_id": inst, - "opt_type": opt_type, - "opt_type_label": label, - "source": source_key, - "source_label": source_label, - "source_plan_id": source_plan_id, - "pos": pos, - "exp_time_ms": exp_ms, - "target_monitor": _format_options_target(p), - "pnl": round(pnl, 4) if pnl is not None else None, - } - - -def _format_hedge_item(plan: dict[str, Any]) -> dict[str, Any]: - pid = plan.get("id") - underlying = plan.get("underlying") or "-" - plan_type = plan.get("plan_type") or "" - status = str(plan.get("status") or "") - summary = plan.get("contracts_summary") or "" - plan_type_label = OPTIONS_SOURCE_LABELS.get(plan_type, plan_type) - active = status in HEDGE_ACTIVE_STATUSES - status_label = "进行中" if active else (status or "—") - return { - "id": pid, - "kind": "hedge_plan", - "tab": "hedge_plan", - "title": f"对冲 #{pid} {underlying}", - "subtitle": " · ".join(x for x in (plan_type_label, status_label, summary) if x), - "underlying": underlying, - "plan_type": plan_type, - "plan_type_label": plan_type_label, - "status": status, - "status_label": status_label, - "status_active": active, - "contracts_summary": summary, - } - - -def _format_key_item(kd: dict[str, Any]) -> dict[str, Any]: - sym = kd.get("exchange_symbol") or kd.get("symbol") or "-" - direction = str(kd.get("direction") or "long").lower() - signal = kd.get("signal_type") or kd.get("key_signal_type") or kd.get("monitor_type") or "" - upper = _safe_float(kd.get("upper")) - lower = _safe_float(kd.get("lower")) - subtitle_parts = [] - if signal: - subtitle_parts.append(str(signal)) - if upper is not None or lower is not None: - subtitle_parts.append( - f"上{upper if upper is not None else '-'} / 下{lower if lower is not None else '-'}" - ) - return { - "id": kd.get("id"), - "kind": "key", - "tab": "key_monitor", - "title": f"{sym} {_dir_label(direction)}", - "subtitle": " · ".join(subtitle_parts), - "symbol": sym, - "direction": direction, - "direction_label": _dir_label(direction), - "upper": upper, - "lower": lower, - "status": kd.get("status") or "active", - } - - -def _format_trend_item(td: dict[str, Any]) -> dict[str, Any]: - sym = td.get("exchange_symbol") or td.get("symbol") or "-" - direction = str(td.get("direction") or "long").lower() - status = td.get("status") or "active" - entry = _safe_float(td.get("entry_price") or td.get("trigger_price")) - return { - "id": td.get("id"), - "kind": "trend", - "tab": "strategy", - "title": f"趋势回调 {sym} {_dir_label(direction)}", - "subtitle": f"状态 {status}", - "symbol": sym, - "direction": direction, - "direction_label": _dir_label(direction), - "entry": entry, - "status": status, - } - - -def _format_roll_item(rd: dict[str, Any]) -> dict[str, Any]: - sym = rd.get("exchange_symbol") or rd.get("symbol") or "-" - direction = str(rd.get("direction") or "long").lower() - status = rd.get("status") or "active" - return { - "id": rd.get("id"), - "kind": "roll", - "tab": "strategy", - "title": f"顺势加仓 {sym} {_dir_label(direction)}", - "subtitle": f"状态 {status}", - "symbol": sym, - "direction": direction, - "direction_label": _dir_label(direction), - "status": status, - } - - -def _table_exists(conn, name: str) -> bool: - try: - row = conn.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1", - (name,), - ).fetchone() - return bool(row) - except Exception: - return False - - -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] - - -def collect_keys(conn) -> list[dict[str, Any]]: - if not _table_exists(conn, "key_monitors"): - return [] - rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() - return [_format_key_item(_row_dict(r)) for r in rows] - - -def collect_trends(conn) -> list[dict[str, Any]]: - if not _table_exists(conn, "trend_pullback_plans"): - return [] - try: - rows = conn.execute( - "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" - ).fetchall() - except Exception: - return [] - return [_format_trend_item(_row_dict(r)) for r in rows] - - -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] - - -def collect_hedge_plans(conn) -> list[dict[str, Any]]: - if not _table_exists(conn, "hedge_plans"): - return [] - try: - from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans - - rows: list[dict[str, Any]] = [] - for status in ("opening", "active", "partial"): - rows.extend(list_plans(conn, status=status, limit=80)) - rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) - plans = attach_legs_to_plans(conn, rows) - return [_format_hedge_item(p) for p in plans] - except Exception: - return [] - - -def collect_options_items( - fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, - *, - conn=None, -) -> list[dict[str, Any]]: - if not callable(fetch_options_positions): - return [] - try: - raw = fetch_options_positions() or [] - except Exception: - return [] - pe_map: dict[str, dict[str, Any]] = {} - tgt_map: dict[str, dict[str, Any]] = {} - hedge_map: dict[str, dict[str, Any]] = {} - if conn is not None: - try: - from lib.options.options_profit_exit_lib import profit_exit_by_inst - from lib.options.options_target_lib import targets_by_inst - from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst - - pe_map = profit_exit_by_inst(conn) - tgt_map = targets_by_inst(conn) - hedge_map = active_options_targets_by_inst(conn) - except Exception: - pe_map, tgt_map, hedge_map = {}, {}, {} - out: list[dict[str, Any]] = [] - for p in raw: - if not isinstance(p, dict): - continue - row = dict(p) - inst = str(row.get("inst_id") or row.get("instId") or "").strip() - mon = tgt_map.get(inst) - if mon: - row["target_index"] = mon.get("target_index") - pe = pe_map.get(inst) - if pe: - row["profit_exit_enabled"] = pe.get("profit_exit_enabled") - row["profit_exit_mult"] = pe.get("profit_exit_mult") - hedge = hedge_map.get(inst) - if hedge: - row["hedge_plan_target"] = hedge - if not mon: - row["target_index"] = hedge.get("target_index") - out.append(_format_options_item(row, conn=conn)) - return out - - -def _swap_symbol_candidates(row: dict[str, Any]) -> list[str]: - """优先永续 symbol(含 settle),避免用现货 BTC/USDT 查到 contractSize=1.""" - raw: list[str] = [] - for key in ("symbol", "exchange_symbol", "price_symbol"): - s = str(row.get(key) or "").strip() - if s and s not in raw: - raw.append(s) - swapish: list[str] = [] - others: list[str] = [] - for s in raw: - if ":" in s: - swapish.append(s) - continue - others.append(s) - if "/" in s: - base, quote = s.split("/", 1) - q = quote.split(":")[0].strip() - if base and q: - swapish.append(f"{base}/{q}:{q}") - out: list[str] = [] - for s in swapish + others: - if s and s not in out: - out.append(s) - return out - - -def _resolve_contract_size( - row_or_sym: Any, - *, - get_contract_size: Optional[Callable[[str], Any]] = None, -) -> float: - if not callable(get_contract_size): - return 1.0 - if isinstance(row_or_sym, dict): - candidates = _swap_symbol_candidates(row_or_sym) - else: - sym = str(row_or_sym or "").strip() - candidates = _swap_symbol_candidates({"symbol": sym}) if sym else [] - for sym in candidates: - try: - cs = float(get_contract_size(sym) or 0) - if cs > 0: - return cs - except Exception: - continue - return 1.0 - - -def _fill_order_pnl_fields(row: dict[str, Any], *, mark: Optional[float], contract_size: float) -> None: - """按线性 U 本位补看板「盈利金额 / 浮盈」.""" - direction = str(row.get("direction") or "long").lower() - entry = _safe_float(row.get("entry")) - contracts = _safe_float(row.get("contracts")) - tp = _safe_float(row.get("take_profit")) - if entry is None or contracts is None or contracts <= 0: - return - cs = float(contract_size) if contract_size and contract_size > 0 else 1.0 - if mark is not None: - try: - from lib.market.position_metrics_lib import estimate_linear_swap_upnl_usdt - - upnl = estimate_linear_swap_upnl_usdt(direction, entry, mark, contracts, cs) - if upnl is not None: - row["float_pnl"] = upnl - except Exception: - pass - if tp is not None and tp > 0: - try: - try: - d = (direction or "long").lower() - e, t, c, cs_f = float(entry), float(tp), float(contracts), float(cs) - profit = (t - e) * c * cs_f if d == "long" else (e - t) * c * cs_f - except Exception: - profit = None - if profit is not None: - row["tp_profit"] = round(float(profit), 2) - except Exception: - pass - - -def enrich_order_items_with_marks( - items: list[dict[str, Any]], - *, - get_price: Optional[Callable[[str], Any]] = None, - get_contract_size: Optional[Callable[[str], Any]] = None, -) -> list[dict[str, Any]]: - """后台聚合时补标记价,并按张数×合约面值估算盈利金额/浮盈.""" - if not items: - return items - if not callable(get_price) and not callable(get_contract_size): - return items - out: list[dict[str, Any]] = [] - for it in items: - row = dict(it) - # 标记价:先试 price_symbol,再试永续候选 - mark = _safe_float(row.get("mark_price")) - if callable(get_price): - ordered: list[str] = [] - for s in [str(row.get("price_symbol") or "").strip()] + _swap_symbol_candidates(row): - if s and s not in ordered: - ordered.append(s) - for sym in ordered: - try: - px = get_price(sym) - except Exception: - px = None - mark = _safe_float(px) - if mark is not None: - row["mark_price"] = mark - break - cs = _resolve_contract_size(row, get_contract_size=get_contract_size) - _fill_order_pnl_fields(row, mark=mark, contract_size=cs) - out.append(row) - return out - - -def build_instance_dashboard_payload( - conn, - *, - fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, - hedge_enabled: bool = False, -) -> dict[str, Any]: - orders = collect_orders(conn) - keys = collect_keys(conn) - options_items = collect_options_items(fetch_options_positions, conn=conn) - hedge_items = collect_hedge_plans(conn) # 始终展示进行中计划,与当前交易模式无关 - # hedge_enabled 仅影响「新建」入口,不隐藏已有仓 - now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") - return { - "ok": True, - "updated_at": now, - "orders": { - "title": "实盘下单", - "count": 0, - "items": [], - "tab": "options", - "removed": True, - }, - "keys": {"title": "关键位监控", "count": len(keys), "items": keys, "tab": "key_monitor"}, - "strategy": { - "title": "策略交易", - "count": 0, - "items": [], - "trends": [], - "rolls": [], - "tab": "strategy", - "removed": True, - }, - "options": { - "title": "期权持仓", - "count": len(options_items), - "items": options_items, - "visible": len(options_items) > 0, - "tab": "options", - }, - "hedge_plan": { - "title": "对冲计划", - "count": len(hedge_items), - "items": hedge_items, - "visible": len(hedge_items) > 0, - "tab": "hedge_plan", - }, - } +"""实例数据看板:本户活跃监控 / 持仓只读聚合.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Callable, Optional + + +def _row_dict(row: Any) -> dict[str, Any]: + if row is None: + return {} + if isinstance(row, dict): + return dict(row) + try: + return dict(row) + except Exception: + return {} + + +def _safe_float(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _dir_label(direction: Any) -> str: + d = str(direction or "").strip().lower() + if d == "short": + return "做空" + if d == "long": + return "做多" + return str(direction or "-") + + +def _format_order_item(od: dict[str, Any]) -> dict[str, Any]: + try: + from lib.trade.trade_labels_lib import apply_order_monitor_source_labels + + od = apply_order_monitor_source_labels(od) + except Exception: + pass + try: + from lib.trade.entry_model_lib import enrich_entry_model_display + + enrich_entry_model_display(od) + except Exception: + pass + sym = od.get("exchange_symbol") or od.get("symbol") or "-" + direction = str(od.get("direction") or "long").lower() + mt = od.get("monitor_type_display") or od.get("monitor_type") or "" + kst = od.get("key_signal_type") or "" + title = f"{sym} {_dir_label(direction)}" + bits = [x for x in (mt, kst) if x] + subtitle = " · ".join(bits) if bits else "" + entry = _safe_float(od.get("trigger_price")) + sl = _safe_float(od.get("stop_loss")) + tp = _safe_float(od.get("take_profit")) + return { + "id": od.get("id"), + "kind": "order", + "tab": "trade", + "title": title, + "subtitle": subtitle, + "symbol": sym, + "price_symbol": od.get("symbol") or sym, + "direction": direction, + "direction_label": _dir_label(direction), + "entry": entry, + "mark_price": None, + "contracts": _safe_float(od.get("order_amount")), + "tp_profit": None, + "float_pnl": None, + "stop_loss": sl, + "take_profit": tp, + "status": od.get("status") or "active", + } + + +OPTIONS_SOURCE_LABELS = { + "option": "纯期权", + "perp_options": "永期对冲", + "options_options": "期期对冲", +} + +HEDGE_ACTIVE_STATUSES = frozenset({"opening", "active", "partial"}) + + +def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]: + """对冲已移除:持仓一律记为纯期权.""" + return ("option", OPTIONS_SOURCE_LABELS["option"], None) + + +def _format_profit_exit_mult(mult: Any) -> str: + try: + n = float(mult) + except (TypeError, ValueError): + return "1倍" + if n <= 0: + return "1倍" + if abs(n - round(n)) < 1e-9: + return f"{int(round(n))}倍" + return f"{n:g}倍" + + +def _format_options_target(p: dict[str, Any]) -> str: + opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() + parts: list[str] = [] + tgt = _safe_float(p.get("target_index")) + if tgt is not None and tgt > 0: + side = "Put ≤" if opt_type == "P" else "Call ≥" + parts.append(f"{side} {tgt:g}") + if p.get("profit_exit_enabled"): + parts.append(_format_profit_exit_mult(p.get("profit_exit_mult"))) + if parts: + return " · ".join(parts) + return "—" + + +def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]: + inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" + opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() + label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") + # 看板期权列:统一折成 U(币本位 BTC/ETH 权利金盈亏 × 指数) + pnl = None + try: + from lib.options.options_positions_lib import display_pnl_usdt_from_option_row + + pnl = display_pnl_usdt_from_option_row(p) + except Exception: + pnl = None + pos = _safe_float(p.get("pos")) + exp_ms = p.get("exp_time_ms") + if exp_ms is None: + exp_ms = p.get("exp_time") + try: + exp_ms = int(float(exp_ms)) if exp_ms not in (None, "") else None + except (TypeError, ValueError): + exp_ms = None + if conn is not None: + source_key, source_label, source_plan_id = _resolve_options_source(conn, inst) + else: + source_key, source_label, source_plan_id = "option", OPTIONS_SOURCE_LABELS["option"], None + return { + "id": inst, + "kind": "options", + "tab": "options", + "title": f"{inst} {label}", + "subtitle": f"张数 {pos if pos is not None else '-'}", + "inst_id": inst, + "opt_type": opt_type, + "opt_type_label": label, + "source": source_key, + "source_label": source_label, + "source_plan_id": source_plan_id, + "pos": pos, + "exp_time_ms": exp_ms, + "target_monitor": _format_options_target(p), + "pnl": round(pnl, 4) if pnl is not None else None, + } + + +def _format_key_item(kd: dict[str, Any]) -> dict[str, Any]: + sym = kd.get("exchange_symbol") or kd.get("symbol") or "-" + direction = str(kd.get("direction") or "long").lower() + signal = kd.get("signal_type") or kd.get("key_signal_type") or kd.get("monitor_type") or "" + upper = _safe_float(kd.get("upper")) + lower = _safe_float(kd.get("lower")) + subtitle_parts = [] + if signal: + subtitle_parts.append(str(signal)) + if upper is not None or lower is not None: + subtitle_parts.append( + f"上{upper if upper is not None else '-'} / 下{lower if lower is not None else '-'}" + ) + return { + "id": kd.get("id"), + "kind": "key", + "tab": "key_monitor", + "title": f"{sym} {_dir_label(direction)}", + "subtitle": " · ".join(subtitle_parts), + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "upper": upper, + "lower": lower, + "status": kd.get("status") or "active", + } + + +def _format_trend_item(td: dict[str, Any]) -> dict[str, Any]: + sym = td.get("exchange_symbol") or td.get("symbol") or "-" + direction = str(td.get("direction") or "long").lower() + status = td.get("status") or "active" + entry = _safe_float(td.get("entry_price") or td.get("trigger_price")) + return { + "id": td.get("id"), + "kind": "trend", + "tab": "strategy", + "title": f"趋势回调 {sym} {_dir_label(direction)}", + "subtitle": f"状态 {status}", + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "entry": entry, + "status": status, + } + + +def _format_roll_item(rd: dict[str, Any]) -> dict[str, Any]: + sym = rd.get("exchange_symbol") or rd.get("symbol") or "-" + direction = str(rd.get("direction") or "long").lower() + status = rd.get("status") or "active" + return { + "id": rd.get("id"), + "kind": "roll", + "tab": "strategy", + "title": f"顺势加仓 {sym} {_dir_label(direction)}", + "subtitle": f"状态 {status}", + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "status": status, + } + + +def _table_exists(conn, name: str) -> bool: + try: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1", + (name,), + ).fetchone() + return bool(row) + except Exception: + return False + + +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] + + +def collect_keys(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "key_monitors"): + return [] + rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + return [_format_key_item(_row_dict(r)) for r in rows] + + +def collect_trends(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "trend_pullback_plans"): + return [] + try: + rows = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall() + except Exception: + return [] + return [_format_trend_item(_row_dict(r)) for r in rows] + + +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] + + +def collect_options_items( + fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, + *, + conn=None, +) -> list[dict[str, Any]]: + if not callable(fetch_options_positions): + return [] + try: + raw = fetch_options_positions() or [] + except Exception: + return [] + pe_map: dict[str, dict[str, Any]] = {} + tgt_map: dict[str, dict[str, Any]] = {} + if conn is not None: + try: + from lib.options.options_profit_exit_lib import profit_exit_by_inst + from lib.options.options_target_lib import targets_by_inst + + pe_map = profit_exit_by_inst(conn) + tgt_map = targets_by_inst(conn) + except Exception: + pe_map, tgt_map = {}, {} + out: list[dict[str, Any]] = [] + for p in raw: + if not isinstance(p, dict): + continue + row = dict(p) + inst = str(row.get("inst_id") or row.get("instId") or "").strip() + mon = tgt_map.get(inst) + if mon: + row["target_index"] = mon.get("target_index") + pe = pe_map.get(inst) + if pe: + row["profit_exit_enabled"] = pe.get("profit_exit_enabled") + row["profit_exit_mult"] = pe.get("profit_exit_mult") + out.append(_format_options_item(row, conn=conn)) + return out + + +def _swap_symbol_candidates(row: dict[str, Any]) -> list[str]: + """优先永续 symbol(含 settle),避免用现货 BTC/USDT 查到 contractSize=1.""" + raw: list[str] = [] + for key in ("symbol", "exchange_symbol", "price_symbol"): + s = str(row.get(key) or "").strip() + if s and s not in raw: + raw.append(s) + swapish: list[str] = [] + others: list[str] = [] + for s in raw: + if ":" in s: + swapish.append(s) + continue + others.append(s) + if "/" in s: + base, quote = s.split("/", 1) + q = quote.split(":")[0].strip() + if base and q: + swapish.append(f"{base}/{q}:{q}") + out: list[str] = [] + for s in swapish + others: + if s and s not in out: + out.append(s) + return out + + +def _resolve_contract_size( + row_or_sym: Any, + *, + get_contract_size: Optional[Callable[[str], Any]] = None, +) -> float: + if not callable(get_contract_size): + return 1.0 + if isinstance(row_or_sym, dict): + candidates = _swap_symbol_candidates(row_or_sym) + else: + sym = str(row_or_sym or "").strip() + candidates = _swap_symbol_candidates({"symbol": sym}) if sym else [] + for sym in candidates: + try: + cs = float(get_contract_size(sym) or 0) + if cs > 0: + return cs + except Exception: + continue + return 1.0 + + +def _fill_order_pnl_fields(row: dict[str, Any], *, mark: Optional[float], contract_size: float) -> None: + """按线性 U 本位补看板「盈利金额 / 浮盈」.""" + direction = str(row.get("direction") or "long").lower() + entry = _safe_float(row.get("entry")) + contracts = _safe_float(row.get("contracts")) + tp = _safe_float(row.get("take_profit")) + if entry is None or contracts is None or contracts <= 0: + return + cs = float(contract_size) if contract_size and contract_size > 0 else 1.0 + if mark is not None: + try: + from lib.market.position_metrics_lib import estimate_linear_swap_upnl_usdt + + upnl = estimate_linear_swap_upnl_usdt(direction, entry, mark, contracts, cs) + if upnl is not None: + row["float_pnl"] = upnl + except Exception: + pass + if tp is not None and tp > 0: + try: + try: + d = (direction or "long").lower() + e, t, c, cs_f = float(entry), float(tp), float(contracts), float(cs) + profit = (t - e) * c * cs_f if d == "long" else (e - t) * c * cs_f + except Exception: + profit = None + if profit is not None: + row["tp_profit"] = round(float(profit), 2) + except Exception: + pass + + +def enrich_order_items_with_marks( + items: list[dict[str, Any]], + *, + get_price: Optional[Callable[[str], Any]] = None, + get_contract_size: Optional[Callable[[str], Any]] = None, +) -> list[dict[str, Any]]: + """后台聚合时补标记价,并按张数×合约面值估算盈利金额/浮盈.""" + if not items: + return items + if not callable(get_price) and not callable(get_contract_size): + return items + out: list[dict[str, Any]] = [] + for it in items: + row = dict(it) + # 标记价:先试 price_symbol,再试永续候选 + mark = _safe_float(row.get("mark_price")) + if callable(get_price): + ordered: list[str] = [] + for s in [str(row.get("price_symbol") or "").strip()] + _swap_symbol_candidates(row): + if s and s not in ordered: + ordered.append(s) + for sym in ordered: + try: + px = get_price(sym) + except Exception: + px = None + mark = _safe_float(px) + if mark is not None: + row["mark_price"] = mark + break + cs = _resolve_contract_size(row, get_contract_size=get_contract_size) + _fill_order_pnl_fields(row, mark=mark, contract_size=cs) + out.append(row) + return out + + +def build_instance_dashboard_payload( + conn, + *, + fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, + hedge_enabled: bool = False, +) -> dict[str, Any]: + orders = collect_orders(conn) + keys = collect_keys(conn) + options_items = collect_options_items(fetch_options_positions, conn=conn) + now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + return { + "ok": True, + "updated_at": now, + "orders": { + "title": "实盘下单", + "count": 0, + "items": [], + "tab": "options", + "removed": True, + }, + "keys": {"title": "关键位监控", "count": len(keys), "items": keys, "tab": "key_monitor"}, + "strategy": { + "title": "策略交易", + "count": 0, + "items": [], + "trends": [], + "rolls": [], + "tab": "strategy", + "removed": True, + }, + "options": { + "title": "期权持仓", + "count": len(options_items), + "items": options_items, + "visible": len(options_items) > 0, + "tab": "options", + }, + "hedge_plan": { + "title": "对冲计划", + "count": 0, + "items": [], + "visible": False, + "tab": "hedge_plan", + "removed": True, + }, + } diff --git a/lib/instance/instance_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py index 9d9afd0..c60cc29 100644 --- a/lib/instance/instance_display_prefs_lib.py +++ b/lib/instance/instance_display_prefs_lib.py @@ -1,144 +1,144 @@ -"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效).""" -from __future__ import annotations - -from typing import Any, Callable, Optional - -from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db - -DISPLAY_RUNTIME_PREFIX = "display." - -DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { - "show_nav_dashboard": False, - "show_nav_account_ledger": False, - "show_nav_key_monitor": True, - "show_nav_trade": False, # 实盘下单界面已移除 - "show_nav_strategy": False, - "show_nav_strategy_records": False, - "show_nav_records": False, # 永续交易记录与复盘已移除 - "show_nav_stats": False, - "show_nav_risk_policy": True, - "show_nav_system_guide": False, - "show_nav_env_config": True, - "show_nav_options": True, - "show_nav_options_review": True, - "show_nav_hedge_plan": True, - "show_settings_transfer": False, # 永续资金划转默认隐藏 - "show_settings_export": True, - "show_settings_password": True, - "show_settings_options_swap": True, - "show_settings_options_transfer": True, -} - -DISPLAY_LABELS: dict[str, str] = { - "show_nav_dashboard": "数据看板", - "show_nav_account_ledger": "账户流水", - "show_nav_key_monitor": "关键位监控", - "show_nav_trade": "实盘下单", - "show_nav_strategy": "策略交易", - "show_nav_strategy_records": "策略交易记录", - "show_nav_records": "交易记录与复盘", - "show_nav_stats": "统计分析", - "show_nav_risk_policy": "风控说明", - "show_nav_system_guide": "系统说明", - "show_nav_env_config": "env配置", - "show_nav_options": "期权", - "show_nav_options_review": "期权复盘", - "show_nav_hedge_plan": "对冲计划", - "show_settings_transfer": "永续资金划转", - "show_settings_export": "数据导出", - "show_settings_password": "账户密码修改", - "show_settings_options_swap": "期权币种兑换", - "show_settings_options_transfer": "期权资金划转", -} - -NAV_TAB_ALLOWED: dict[str, str] = { - "dashboard": "show_nav_dashboard", - "account_ledger": "show_nav_account_ledger", - "key_monitor": "show_nav_key_monitor", - "trade": "show_nav_trade", - "strategy": "show_nav_strategy", - "strategy_records": "show_nav_strategy_records", - "records": "show_nav_records", - "stats": "show_nav_stats", - "risk_policy": "show_nav_risk_policy", - "system_guide": "show_nav_system_guide", - "env_config": "show_nav_env_config", - "options": "show_nav_options", - "options_review": "show_nav_options_review", - "hedge_plan": "show_nav_hedge_plan", -} - - -def normalize_display_prefs(raw: dict | None) -> dict[str, bool]: - out = dict(DEFAULT_INSTANCE_DISPLAY) - if isinstance(raw, dict): - for key in DEFAULT_INSTANCE_DISPLAY: - if key in raw: - out[key] = bool(raw[key]) - return out - - -def _load_from_conn(conn) -> dict[str, bool]: - stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX) - merged: dict[str, Any] = {} - for key in DEFAULT_INSTANCE_DISPLAY: - sk = key - if sk in stored: - merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on") - return normalize_display_prefs(merged) - - -def get_display_prefs(get_db: Callable) -> dict[str, bool]: - return with_db(get_db, _load_from_conn) - - -def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]: - normalized = normalize_display_prefs(prefs) - - def _save(conn): - mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()} - runtime_set_many(conn, mapping) - return normalized - - return with_db(get_db, _save) - - -def display_prefs_template_context(get_db: Callable) -> dict[str, Any]: - prefs = get_display_prefs(get_db) - return {"display": prefs, "display_meta": display_meta_for_ui()} - - -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"): - return False # 实盘下单 / 交易记录复盘 / 统计分析 已移除 - key = NAV_TAB_ALLOWED.get(t) - if not key: - return True - return bool(prefs.get(key, True)) - - -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_system_guide", - "show_nav_env_config", - "show_nav_options", - "show_nav_options_review", - "show_nav_hedge_plan", - ] - settings_keys = [ - "show_settings_transfer", - "show_settings_export", - "show_settings_password", - "show_settings_options_swap", - "show_settings_options_transfer", - ] - return [ - {"group": "顶栏导航", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]}, - {"group": "系统设置区块", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]}, - ] +"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db + +DISPLAY_RUNTIME_PREFIX = "display." + +DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { + "show_nav_dashboard": False, + "show_nav_account_ledger": False, + "show_nav_key_monitor": True, + "show_nav_trade": False, # 实盘下单界面已移除 + "show_nav_strategy": False, + "show_nav_strategy_records": False, + "show_nav_records": False, # 永续交易记录与复盘已移除 + "show_nav_stats": False, + "show_nav_risk_policy": True, + "show_nav_system_guide": False, + "show_nav_env_config": True, + "show_nav_options": True, + "show_nav_options_review": True, + "show_nav_hedge_plan": False, + "show_settings_transfer": False, # 永续资金划转默认隐藏 + "show_settings_export": True, + "show_settings_password": True, + "show_settings_options_swap": True, + "show_settings_options_transfer": True, +} + +DISPLAY_LABELS: dict[str, str] = { + "show_nav_dashboard": "数据看板", + "show_nav_account_ledger": "账户流水", + "show_nav_key_monitor": "关键位监控", + "show_nav_trade": "实盘下单", + "show_nav_strategy": "策略交易", + "show_nav_strategy_records": "策略交易记录", + "show_nav_records": "交易记录与复盘", + "show_nav_stats": "统计分析", + "show_nav_risk_policy": "风控说明", + "show_nav_system_guide": "系统说明", + "show_nav_env_config": "env配置", + "show_nav_options": "期权", + "show_nav_options_review": "期权复盘", + "show_nav_hedge_plan": "对冲计划", + "show_settings_transfer": "永续资金划转", + "show_settings_export": "数据导出", + "show_settings_password": "账户密码修改", + "show_settings_options_swap": "期权币种兑换", + "show_settings_options_transfer": "期权资金划转", +} + +NAV_TAB_ALLOWED: dict[str, str] = { + "dashboard": "show_nav_dashboard", + "account_ledger": "show_nav_account_ledger", + "key_monitor": "show_nav_key_monitor", + "trade": "show_nav_trade", + "strategy": "show_nav_strategy", + "strategy_records": "show_nav_strategy_records", + "records": "show_nav_records", + "stats": "show_nav_stats", + "risk_policy": "show_nav_risk_policy", + "system_guide": "show_nav_system_guide", + "env_config": "show_nav_env_config", + "options": "show_nav_options", + "options_review": "show_nav_options_review", + "hedge_plan": "show_nav_hedge_plan", +} + + +def normalize_display_prefs(raw: dict | None) -> dict[str, bool]: + out = dict(DEFAULT_INSTANCE_DISPLAY) + if isinstance(raw, dict): + for key in DEFAULT_INSTANCE_DISPLAY: + if key in raw: + out[key] = bool(raw[key]) + return out + + +def _load_from_conn(conn) -> dict[str, bool]: + stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX) + merged: dict[str, Any] = {} + for key in DEFAULT_INSTANCE_DISPLAY: + sk = key + if sk in stored: + merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on") + return normalize_display_prefs(merged) + + +def get_display_prefs(get_db: Callable) -> dict[str, bool]: + return with_db(get_db, _load_from_conn) + + +def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]: + normalized = normalize_display_prefs(prefs) + + def _save(conn): + mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()} + runtime_set_many(conn, mapping) + return normalized + + return with_db(get_db, _save) + + +def display_prefs_template_context(get_db: Callable) -> dict[str, Any]: + prefs = get_display_prefs(get_db) + return {"display": prefs, "display_meta": display_meta_for_ui()} + + +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"): + return False # 实盘下单 / 交易记录复盘 / 统计分析 已移除 + key = NAV_TAB_ALLOWED.get(t) + if not key: + return True + return bool(prefs.get(key, True)) + + +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_system_guide", + "show_nav_env_config", + "show_nav_options", + "show_nav_options_review", + "show_nav_hedge_plan", + ] + settings_keys = [ + "show_settings_transfer", + "show_settings_export", + "show_settings_password", + "show_settings_options_swap", + "show_settings_options_transfer", + ] + return [ + {"group": "顶栏导航", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]}, + {"group": "系统设置区块", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]}, + ] diff --git a/lib/instance/instance_static_lib.py b/lib/instance/instance_static_lib.py index ea56dbc..769f348 100644 --- a/lib/instance/instance_static_lib.py +++ b/lib/instance/instance_static_lib.py @@ -40,7 +40,6 @@ _COMMON_STATIC_ASSETS: dict[str, str] = { "options_settings.js": "application/javascript; charset=utf-8", "options_position_cards.js": "application/javascript; charset=utf-8", "options_review.js": "application/javascript; charset=utf-8", - "hedge_plan.js": "application/javascript; charset=utf-8", "order_entry_model.js": "application/javascript; charset=utf-8", "focus_chart_page.js": "application/javascript; charset=utf-8", "focus_chart_page.css": "text/css; charset=utf-8", diff --git a/lib/options/okx_trade_mode_lib.py b/lib/options/okx_trade_mode_lib.py new file mode 100644 index 0000000..7173863 --- /dev/null +++ b/lib/options/okx_trade_mode_lib.py @@ -0,0 +1,74 @@ +"""OKX 交易模式(币本位期权专用机:仅单独期权). + +历史 .env 若仍为 perp_options / options_options,读取时归一为 options, +对冲模块已移除,不可再启用. +""" +from __future__ import annotations + +import os +from typing import Optional + +MODE_OPTIONS = "options" +# 兼容旧配置名,不再作为可选模式 +MODE_PERP = "perp_options" +MODE_OO = "options_options" +VALID_MODES = frozenset({MODE_OPTIONS}) +UI_MODES = frozenset({MODE_OPTIONS}) + +_ALIASES = { + "option": MODE_OPTIONS, + "standalone": MODE_OPTIONS, + "期权": MODE_OPTIONS, + "单独期权": MODE_OPTIONS, + "po": MODE_OPTIONS, + "perp": MODE_OPTIONS, + "perp_options": MODE_OPTIONS, + "永期": MODE_OPTIONS, + "永期对冲": MODE_OPTIONS, + "oo": MODE_OPTIONS, + "options_options": MODE_OPTIONS, + "期期": MODE_OPTIONS, + "期期对冲": MODE_OPTIONS, +} + + +def normalize_okx_trade_mode(raw: Optional[str] = None) -> str: + s = str(raw or "").strip().lower() + if s in VALID_MODES: + return s + if s in _ALIASES: + return _ALIASES[s] + # 未知或空 → 单独期权 + return MODE_OPTIONS + + +def legacy_infer_okx_trade_mode() -> str: + return MODE_OPTIONS + + +def get_okx_trade_mode() -> str: + return normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE")) + + +def hedge_module_enabled() -> bool: + return False + + +def show_perp_options() -> bool: + return False + + +def show_options_options() -> bool: + return False + + +def standalone_options_open_allowed() -> bool: + return True + + +def mode_label(mode: Optional[str] = None) -> str: + return "单独期权" + + +def block_standalone_open_by_mode_msg() -> Optional[str]: + return None diff --git a/lib/options/options_dashboard_lib.py b/lib/options/options_dashboard_lib.py index eb55c23..b9498e0 100644 --- a/lib/options/options_dashboard_lib.py +++ b/lib/options/options_dashboard_lib.py @@ -1,85 +1,77 @@ -"""实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏).""" -from __future__ import annotations - -from typing import Any - - -def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]: - """ - 拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合. - 不走 options dashboard snapshot(避免余额/历史). - """ - if not cfg.get("enabled"): - return [] - ex = cfg.get("exchange_options") - ready_fn = cfg.get("options_api_ready") - if not callable(ready_fn): - return [] - ok, _reason = ready_fn(ex) - if not ok: - return [] - fetch_fn = cfg.get("fetch_option_positions") - if not callable(fetch_fn): - return [] - raw = fetch_fn(ex) - if raw is None: - return [] - if not raw: - return [] - - from lib.options.options_db import init_options_tables, sum_open_premium_paid - from lib.options.options_history_lib import enrich_position_row_display - from lib.options.options_positions_lib import attach_close_preview - - meta_cache: dict[str, dict[str, Any] | None] = {} - rows: list[dict[str, Any]] = [] - get_db = cfg.get("get_db") - if not callable(get_db): - for p in raw: - if not isinstance(p, dict): - continue - row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache) - attach_close_preview(cfg, ex, row) - rows.append(row) - return rows - - conn = get_db() - try: - init_options_tables(conn) - try: - from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst - from lib.options.options_target_lib import targets_by_inst - - tgt_map = targets_by_inst(conn) - hedge_target_map = active_options_targets_by_inst(conn) - except Exception: - tgt_map = {} - hedge_target_map = {} - - for p in raw: - if not isinstance(p, dict): - continue - inst = str(p.get("instId") or "").strip() - premium_override = sum_open_premium_paid(conn, inst) if inst else None - row = enrich_position_row_display( - cfg, - ex, - p, - meta_cache=meta_cache, - premium_override=premium_override, - ) - attach_close_preview(cfg, ex, row, premium_paid=premium_override) - mon = tgt_map.get(str(row.get("inst_id") or "")) - if mon: - row["target_index"] = mon.get("target_index") - row["target_monitor_id"] = mon.get("id") - row["target_monitor"] = mon - hedge_target = hedge_target_map.get(str(row.get("inst_id") or "")) - if hedge_target: - row["hedge_plan_target"] = hedge_target - if not mon: - row["target_index"] = hedge_target.get("target_index") - rows.append(row) - finally: - conn.close() - return rows +"""实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏).""" +from __future__ import annotations + +from typing import Any + + +def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]: + """ + 拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合. + 不走 options dashboard snapshot(避免余额/历史). + """ + if not cfg.get("enabled"): + return [] + ex = cfg.get("exchange_options") + ready_fn = cfg.get("options_api_ready") + if not callable(ready_fn): + return [] + ok, _reason = ready_fn(ex) + if not ok: + return [] + fetch_fn = cfg.get("fetch_option_positions") + if not callable(fetch_fn): + return [] + raw = fetch_fn(ex) + if raw is None: + return [] + if not raw: + return [] + + from lib.options.options_db import init_options_tables, sum_open_premium_paid + from lib.options.options_history_lib import enrich_position_row_display + from lib.options.options_positions_lib import attach_close_preview + + meta_cache: dict[str, dict[str, Any] | None] = {} + rows: list[dict[str, Any]] = [] + get_db = cfg.get("get_db") + if not callable(get_db): + for p in raw: + if not isinstance(p, dict): + continue + row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache) + attach_close_preview(cfg, ex, row) + rows.append(row) + return rows + + conn = get_db() + try: + init_options_tables(conn) + try: + from lib.options.options_target_lib import targets_by_inst + + tgt_map = targets_by_inst(conn) + except Exception: + tgt_map = {} + + for p in raw: + if not isinstance(p, dict): + continue + inst = str(p.get("instId") or "").strip() + premium_override = sum_open_premium_paid(conn, inst) if inst else None + row = enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + attach_close_preview(cfg, ex, row, premium_paid=premium_override) + mon = tgt_map.get(str(row.get("inst_id") or "")) + if mon: + row["target_index"] = mon.get("target_index") + row["target_monitor_id"] = mon.get("id") + row["target_monitor"] = mon + rows.append(row) + finally: + conn.close() + return rows diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index 021778b..b40d5a7 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -28,15 +28,12 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: try: conn = cfg["get_db"]() try: - from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst from lib.options.options_profit_exit_lib import profit_exit_by_inst from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst target_monitors = list_active_targets(conn) + list_closing_targets(conn) tgt_map = targets_by_inst(conn) - hedge_target_map = active_options_targets_by_inst(conn) profit_exit_map = profit_exit_by_inst(conn) - target_monitors.extend(hedge_target_map.values()) for pe in profit_exit_map.values(): if pe.get("profit_exit_enabled"): target_monitors.append( @@ -59,12 +56,6 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: p["profit_exit_mult"] = pe.get("profit_exit_mult") p["profit_exit_state"] = pe.get("profit_exit_state") p["profit_exit_required_recycle"] = pe.get("required_recycle") - hedge_target = hedge_target_map.get(str(p.get("inst_id") or "")) - if hedge_target: - p["hedge_plan_target"] = hedge_target - if not mon: - # 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。 - p["target_index"] = hedge_target.get("target_index") try: from lib.instance.instance_dashboard_lib import ( _format_options_target, diff --git a/lib/options/options_position_limit_lib.py b/lib/options/options_position_limit_lib.py index 4ceac4c..23f029c 100644 --- a/lib/options/options_position_limit_lib.py +++ b/lib/options/options_position_limit_lib.py @@ -74,7 +74,7 @@ def option_position_limit_block_msg( - 拉持仓失败:拒绝开仓(避免绕过上限) """ try: - from lib.hedge_plan.okx_trade_mode_lib import standalone_options_open_allowed + from lib.options.okx_trade_mode_lib import standalone_options_open_allowed # 对冲模式用 MAX_ACTIVE_HEDGE_PLANS 管「组数」,不占用期权笔数上限 if max_active is None and not standalone_options_open_allowed(): diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py index a61de37..cc6d29a 100644 --- a/lib/options/options_pricing_lib.py +++ b/lib/options/options_pricing_lib.py @@ -28,6 +28,28 @@ def premium_per_sheet(quote_per_unit: float, ct_mult: float = 0.01) -> float: return float(quote_per_unit) * float(ct_mult) +def option_expiry_pnl( + *, + opt_type: str, + strike: float, + spot: float, + sheets: float, + ct_mult: float, + premium_paid: float, +) -> float: + """到期按实值估算盈亏(权利金币种口径);模拟盘到期结算等复用.""" + o = (opt_type or "").strip().upper() + intrinsic_per_coin = 0.0 + if o in ("C", "CALL"): + intrinsic_per_coin = max(0.0, float(spot) - float(strike)) + elif o in ("P", "PUT"): + intrinsic_per_coin = max(0.0, float(strike) - float(spot)) + else: + return -float(premium_paid) + value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01) + return value - float(premium_paid) + + def format_quote_liquidity(px: float | None, sz: float | None, *, px_decimals: int = 4) -> str | None: """盘口展示:价格/张数,如 17.2/150.""" if px is None: diff --git a/lib/options/options_profit_exit_lib.py b/lib/options/options_profit_exit_lib.py index 7d6b27c..75874f4 100644 --- a/lib/options/options_profit_exit_lib.py +++ b/lib/options/options_profit_exit_lib.py @@ -310,34 +310,12 @@ def run_options_profit_exits( """扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数.""" ensure_profit_exit_columns(conn) pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} - hedge_managed: set[str] = set() - try: - from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables - - init_hedge_plan_tables(conn) - hedge_managed = active_hedge_option_inst_ids(conn) - except Exception: - # 对冲表异常时不得整段跳过翻倍出场 - hedge_managed = set() - rules = profit_exit_by_inst(conn) triggered = 0 for inst_id, info in list(rules.items()): if not inst_id: continue - if inst_id in hedge_managed: - _mark_state(conn, inst_id, "idle") - conn.execute( - """ - UPDATE options_trades - SET profit_exit_enabled = 0, profit_exit_state = 'idle' - WHERE inst_id = ? AND status = 'open' - """, - (inst_id,), - ) - _commit(conn) - continue pos = pos_by_inst.get(inst_id) if not pos: # 本轮暂无持仓:不改状态(避免接口抖动误标 done);真正平完由 sync 收尾 diff --git a/lib/options/options_register.py b/lib/options/options_register.py index c379177..b3db227 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -1,2108 +1,1997 @@ -"""OKX 期权模块:Flask 路由注册.""" -from __future__ import annotations - -import os -import threading -import time -from typing import Any - -from flask import Flask, jsonify, redirect, request, url_for -from jinja2 import ChoiceLoader, FileSystemLoader - -from lib.options.options_db import init_options_tables, sum_open_premium_paid, sum_open_sheets -from lib.options.options_monitor_lib import options_monitor_loop -from lib.options.options_pricing_lib import ( - calc_order_size, - ct_mult_from_meta, - min_sz_from_meta, - premium_per_sheet, -) -from lib.exchange.okx_options_lib import ( - _safe_float, - cap_option_buy_sheets_to_ask_depth, - option_buy_liquidity_ok, - td_mode_for_option_buy, -) - - -def _env_bool(key: str, default: bool = False) -> bool: - raw = (os.getenv(key) or "").strip().lower() - if not raw: - return default - return raw in ("1", "true", "yes", "on") - - -def _env_float(key: str, default: float) -> float: - try: - return float(os.getenv(key) or default) - except (TypeError, ValueError): - return float(default) - - -def attach_options_templates(app: Flask, repo_root: str) -> None: - tpl_dir = os.path.join(repo_root, "lib", "options", "templates") - if not os.path.isdir(tpl_dir): - return - existing = app.jinja_loader - loaders = [FileSystemLoader(tpl_dir)] - if existing is not None: - if isinstance(existing, ChoiceLoader): - loaders = list(existing.loaders) + loaders - else: - loaders.insert(0, existing) - app.jinja_loader = ChoiceLoader(loaders) - - -def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None: - enabled = _env_bool("OKX_OPTIONS_ENABLED", False) - attach_options_templates(app, repo_root) - cfg = _build_cfg(app_module) - try: - from lib.sim.hooks import patch_options_cfg - - patch_options_cfg(cfg) - except Exception: - pass - app.extensions["options_cfg"] = cfg - register_options_routes(app, cfg) - _register_options_hub_bridge(app, cfg) - if enabled: - _start_monitor_thread(app, cfg) - - -def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None: - from lib.options.options_hub_lib import build_options_hub_snapshot - - def snapshot_fn(): - return build_options_hub_snapshot(cfg) - - hub_ctx = dict(app.config.get("HUB_CTX") or {}) - hub_ctx["options_snapshot_fn"] = snapshot_fn - app.config["HUB_CTX"] = hub_ctx - - -def _build_cfg(app_module: Any) -> dict[str, Any]: - from lib.exchange.okx_options_lib import ( - build_option_chain, - estimate_usdt_to_usdc, - execute_convert, - fetch_option_book_depth, - fetch_option_positions, - fetch_options_balances, - format_position_row, - options_api_ready, - cancel_option_order, - fetch_option_pending_orders, - place_option_limit_order, - place_option_market_order, - quote_option_contract, - spot_market_swap_usdt_usdc, - transfer_ccy, - ) - - return { - "enabled": _env_bool("OKX_OPTIONS_ENABLED", False), - "get_db": app_module.get_db, - "login_required": app_module.login_required, - "exchange_options": getattr(app_module, "exchange_options", None), - "send_wechat": app_module.send_wechat_msg, - "render_main_page": app_module.render_main_page, - "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0), - "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95), - "compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True), - "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), - "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), - "margin_mode": (os.getenv("OKX_OPTIONS_MARGIN_MODE") or "coin").strip().lower(), - "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), - "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), - "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0), - "chain_ask_liq_filter": _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True), - "itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0), - "td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), - # 市价平仓已硬关闭(忽略 env),仅买一限价 - "allow_market_close": False, - # 平仓限价挂单超时自动撤单(秒);默认 600=10 分钟,联调可设 60 - "pending_ttl_seconds": _env_float("OKX_OPTIONS_PENDING_TTL_SECONDS", 600.0), - "profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0), - "poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0), - "account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(), - "build_option_chain": build_option_chain, - "quote_option_contract": quote_option_contract, - "fetch_option_book_depth": fetch_option_book_depth, - "place_option_limit_order": place_option_limit_order, - "place_option_market_order": place_option_market_order, - "fetch_option_pending_orders": fetch_option_pending_orders, - "cancel_option_order": cancel_option_order, - "fetch_option_positions": fetch_option_positions, - "fetch_options_balances": fetch_options_balances, - "format_position_row": format_position_row, - "estimate_usdt_to_usdc": estimate_usdt_to_usdc, - "execute_convert": execute_convert, - "transfer_ccy": transfer_ccy, - "spot_market_swap_usdt_usdc": spot_market_swap_usdt_usdc, - "options_api_ready": options_api_ready, - "app_module": app_module, - } - - -def _mark_balances_stale(cfg: dict[str, Any]) -> None: - from lib.exchange.okx_options_lib import invalidate_options_balance_cache - from lib.instance.instance_live_push_lib import notify_instance_balance_changed - - invalidate_options_balance_cache() - app_mod = cfg.get("app_module") - if app_mod is not None and hasattr(app_mod, "invalidate_account_balance_cache"): - app_mod.invalidate_account_balance_cache() - try: - notify_instance_balance_changed() - except Exception: - pass - - -def _require_options_ex(cfg: dict[str, Any]): - if not cfg.get("enabled"): - return None, "期权模块未启用,请在 .env 设置 OKX_OPTIONS_ENABLED=true 并重启 PM2" - ex = cfg.get("exchange_options") - ok, reason = cfg["options_api_ready"](ex) - if not ok: - return None, reason or "期权 API 未配置" - return ex, "" - - -def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: - """打满可用额度 = min(交易户可用 USDC, 单笔预算);calc_order_size 再乘 budget_buffer.""" - from lib.exchange.okx_options_lib import fetch_options_trading_usdc - from lib.options.options_pricing_lib import resolve_budget_full_usdc - - raw = fetch_options_trading_usdc(ex) - if raw is None or float(raw) <= 0: - return None, "交易账户 USDC 可用余额不足" - trading = float(raw) - cap = _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10.0)) - if cap <= 0: - return None, "单笔预算无效(OKX_OPTIONS_TRADE_BUDGET_USDC)" - return resolve_budget_full_usdc(trading, float(cap)), "" - - -def _compound_full_enabled() -> bool: - return _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True) - - -def _budget_full_blocked_by_compound_msg() -> str | None: - if _compound_full_enabled(): - return "全仓复利已开启,不可使用单笔预算/打满;请关闭全仓复利或改用全仓复利模式" - return None - - -def _size_mode_budget_cap( - cfg: dict[str, Any], mode: str, budget_cap: float | None -) -> float | None: - """全仓复利开启时禁用单笔预算封顶(sheets/eth 也不再受 trade_budget 限制).""" - if mode in ("budget_full", "compound_full"): - return budget_cap - if mode in ("sheets", "eth_amount"): - if _compound_full_enabled(): - return None - return budget_cap - return None - - -def _normalize_size_mode(mode: str) -> tuple[str, str | None]: - """全仓复利关闭时强制离开 compound_full,避免前端残留选中导致无法开仓.""" - m = (mode or "sheets").strip() or "sheets" - if m == "compound_full" and not _compound_full_enabled(): - return "sheets", "全仓复利已关闭,已改用指定张数" - if m == "budget_full" and _compound_full_enabled(): - return "compound_full", None - return m, None - - -def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: - """全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer.""" - if not _compound_full_enabled(): - return None, "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)" - from lib.exchange.okx_options_lib import fetch_options_trading_usdc - from lib.options.options_pricing_lib import resolve_compound_full_usdc - - raw = fetch_options_trading_usdc(ex) - if raw is None or float(raw) <= 0: - return None, "交易账户 USDC 可用余额不足" - trading = float(raw) - # 额度热更读 env(与模板启动值无关) - cap_on = _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False) - cap_v = _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0) - if cap_on and cap_v <= 0: - return None, "全仓上限无效(OKX_OPTIONS_COMPOUND_FULL_CAP_USDC)" - return ( - resolve_compound_full_usdc( - trading, - cap_enabled=cap_on, - cap_usdc=cap_v, - ), - "", - ) - - -def _is_budget_mode(mode: str) -> bool: - return mode in ("budget_full", "compound_full") - - -def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: - conn = cfg["get_db"]() - try: - init_options_tables(conn) - return sum_open_premium_paid(conn, inst_id) - finally: - conn.close() - - -def _position_avail_sheets(pos: dict[str, Any]) -> int: - avail = _safe_float(pos.get("availPos")) - if avail is None or avail <= 0: - avail = abs(_safe_float(pos.get("pos")) or 0) - return max(0, int(avail or 0)) - - -def _find_position(rows: list[dict[str, Any]] | None, inst_id: str) -> dict[str, Any] | None: - return next((p for p in rows or [] if str(p.get("instId")) == inst_id), None) - - -def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int | None: - from lib.exchange.okx_options_lib import invalidate_option_positions_cache - - invalidate_option_positions_cache() - raw = cfg["fetch_option_positions"](ex) - if raw is None: - return None - pos = _find_position(raw, inst_id) - if not pos: - return 0 - return _position_avail_sheets(pos) - - -def _enrich_position_row_display( - cfg: dict[str, Any], - ex: Any, - raw_pos: dict[str, Any], - *, - meta_cache: dict[str, dict[str, Any] | None] | None = None, - premium_override: float | None = None, -) -> dict[str, Any]: - from lib.options.options_history_lib import enrich_position_row_display - - return enrich_position_row_display( - cfg, - ex, - raw_pos, - meta_cache=meta_cache, - premium_override=premium_override, - ) - - -def _attach_close_preview( - cfg: dict[str, Any], - ex: Any, - row: dict[str, Any], - *, - sheets: int | None = None, - premium_paid: float | None = None, -) -> dict[str, Any]: - from lib.options.options_positions_lib import attach_close_preview - - return attach_close_preview( - cfg, - ex, - row, - sheets=sheets, - premium_paid=premium_paid, - ) - - -_OPTIONS_SYNC_LOCK = threading.Lock() -_OPTIONS_SYNC_LAST_AT = 0.0 -_OPTIONS_SYNC_INTERVAL_SEC = 15.0 - - -def _sync_options_trades( - cfg: dict[str, Any], - *, - raw_positions: list[dict[str, Any]] | None = None, - force: bool = False, -) -> None: - global _OPTIONS_SYNC_LAST_AT - ex = cfg.get("exchange_options") - if ex is None: - return - now = time.time() - with _OPTIONS_SYNC_LOCK: - if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC: - return - _OPTIONS_SYNC_LAST_AT = now - from lib.exchange.okx_options_lib import fetch_all_option_positions_history, fetch_option_position_history - from lib.options.options_monitor_lib import ( - backfill_closed_options_realized_pnl_from_history, - reconcile_live_open_trades, - sync_open_options_trades, - ) - - if raw_positions is None: - raw = cfg["fetch_option_positions"](ex) - if raw is None: - return - else: - raw = raw_positions - live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} - - def _hist(inst_id: str): - return fetch_option_position_history(ex, inst_id) - - conn = cfg["get_db"]() - try: - init_options_tables(conn) - reconcile_live_open_trades(conn, live_inst_ids=live_ids) - sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist) - try: - hist_all = fetch_all_option_positions_history(ex, limit=200) - backfill_closed_options_realized_pnl_from_history(conn, hist_all) - except Exception: - pass - conn.commit() - finally: - conn.close() - - -def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: - lr = cfg["login_required"] - - @app.route("/options/guide") - @lr - def options_trade_guide(): - """期权开平仓与监控说明(独立页).""" - from pathlib import Path - - from flask import render_template_string - - from lib.hub.hub_strategy_lib import render_markdown_html - - md_path = Path(__file__).resolve().parents[2] / "docs" / "期权开平仓与监控说明.md" - try: - md_text = md_path.read_text(encoding="utf-8") - except OSError: - md_text = "# 说明文档缺失\n\n未找到 `docs/期权开平仓与监控说明.md`." - body = render_markdown_html(md_text) - return render_template_string( - """ - - - - - - 期权开平仓与监控说明 - - - -

← 返回期权 · 对冲计划

- {{ body|safe }} - - - """, - body=body, - ) - - @app.route("/api/options/balances") - @lr - def api_options_balances(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") - bal = cfg["fetch_options_balances"](ex, force=force, scope="main") - from lib.options.options_margin_mode_lib import is_coin_margin_mode, normalize_options_margin_mode - - margin_mode = normalize_options_margin_mode() - payload = { - "ok": True, - **bal, - "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)), - "compound_full_enabled": _compound_full_enabled(), - "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), - "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), - "options_margin_mode": margin_mode, - "options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC", - } - if is_coin_margin_mode(): - try: - from lib.options.options_coin_open_lib import coin_budget_preview - - payload["coin_budget"] = coin_budget_preview(cfg, ex) - except Exception as e: - payload["coin_budget"] = {"ok": False, "msg": str(e)} - return jsonify(payload) - - @app.route("/api/options/chain") - @lr - def api_options_chain(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - u = (request.args.get("underlying") or cfg["default_underly"]).upper() - # 热更新:链展示天数每次读 env,保存后刷新链即可 - chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14)) - try: - from lib.options.options_margin_mode_lib import normalize_options_margin_mode - - margin_mode = normalize_options_margin_mode() - chain = cfg["build_option_chain"]( - ex, - u, - max_dte_days=chain_max_dte, - itm_only=False, - itm_max_dist_usd=cfg["itm_max_dist"], - margin_mode=margin_mode, - ) - except Exception as e: - return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) - expiries = chain.get("expiries") or [] - chain_err = chain.get("chain_error") - # 热更新:每次读 env,保存配置后刷新链即可生效 - ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True) - budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95) - coin_budget = None - try: - from lib.options.options_margin_mode_lib import is_coin_margin_mode - from lib.options.options_coin_open_lib import coin_budget_preview - - if is_coin_margin_mode(): - coin_budget = coin_budget_preview(cfg, ex) - except Exception: - coin_budget = None - if not expiries: - return jsonify( - { - "ok": False, - "msg": chain_err or "暂无到期日,请稍后点「刷新链」", - **chain, - "chain_max_dte_days": chain_max_dte, - "ask_liq_filter_enabled": ask_liq_filter, - "budget_buffer": budget_buffer, - "trade_budget": cfg["trade_budget"], - "options_margin_mode": chain.get("margin_mode") or margin_mode, - "coin_budget": coin_budget, - } - ) - return jsonify( - { - "ok": True, - **chain, - "chain_max_dte_days": chain_max_dte, - "ask_liq_filter_enabled": ask_liq_filter, - "budget_buffer": budget_buffer, - "trade_budget": cfg["trade_budget"], - "options_margin_mode": chain.get("margin_mode") or margin_mode, - "coin_budget": coin_budget, - } - ) - - @app.route("/api/options/quote") - @lr - def api_options_quote(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - inst_id = (request.args.get("inst_id") or "").strip() - if not inst_id: - return jsonify({"ok": False, "msg": "缺少 inst_id"}) - q = cfg["quote_option_contract"](ex, inst_id) - if not q.get("ok"): - return jsonify(q) - ask = q.get("ask") - ct_mult = q.get("ct_mult") or 0.01 - min_sz = q.get("min_sz") or 1 - mode = (request.args.get("mode") or "sheets").strip() - sheet_count = None - try: - if request.args.get("sheets"): - sheet_count = int(request.args.get("sheets")) - except (TypeError, ValueError): - pass - if mode == "close_preview": - paid = _open_premium_paid(cfg, inst_id) - target = sheet_count if sheet_count is not None else 0 - from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode - - row_mode = margin_mode_from_inst_id(inst_id) - prem_ccy = premium_ccy_for_mode(row_mode, (inst_id.split("-")[0] if inst_id else "ETH")) - preview_row = { - **q, - "pos": target, - "premium_paid": paid, - "margin_mode": row_mode, - "premium_ccy": prem_ccy, - } - out = _attach_close_preview(cfg, ex, preview_row, sheets=target, premium_paid=paid) - out["options_margin_mode"] = row_mode - out["premium_ccy"] = prem_ccy - return jsonify(out) - mode, mode_note = _normalize_size_mode(mode) - - # 币本位:报价预览走 USDT 预算→估币→张数,禁止再查 USDC - try: - from lib.options.options_margin_mode_lib import ( - is_coin_margin_mode, - margin_mode_from_inst_id, - ) - from lib.options.options_coin_open_lib import coin_budget_preview - from lib.exchange.okx_options_lib import option_buy_liquidity_ok - - if is_coin_margin_mode(): - ask = q.get("ask") - ask_sz = q.get("ask_sz") - can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) - try: - from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg - - mode_block = block_standalone_open_by_mode_msg() - except Exception as e: - return jsonify({"ok": False, "can_open": False, "msg": f"交易模式校验失败: {e}"}) - if mode_block: - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": mode_block, - "options_margin_mode": "coin", - "sizing": {"ok": False, "msg": mode_block, "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}, - } - ) - if margin_mode_from_inst_id(inst_id) != "coin": - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": "当前为币本位模式,请选择 ETH-USD / BTC-USD 合约(非 USD_UM)", - "options_margin_mode": "coin", - "sizing": { - "ok": False, - "msg": "合约非币本位", - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - } - ) - budget_info = coin_budget_preview(cfg, ex) - if not can_open: - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", - "options_margin_mode": "coin", - "coin_budget": budget_info, - "sizing": { - "ok": False, - "msg": block_msg or "暂无卖一深度,无法买入", - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - } - ) - if not budget_info.get("ok"): - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": budget_info.get("msg") or "交易账户 USDT 不足", - "options_margin_mode": "coin", - "coin_budget": budget_info, - "sizing": { - "ok": False, - "msg": budget_info.get("msg") or "交易账户 USDT 不足", - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - } - ) - idx = _safe_float(q.get("index_px")) or _safe_float(q.get("idxPx")) - budget_usdt = float(budget_info["budget_usdt"]) - target_sheets = sheet_count if mode == "sheets" and sheet_count is not None else None - if mode == "eth" and request.args.get("eth"): - # 指定币量:按币量反推张数后再走统一规划 - try: - eth_want = float(request.args.get("eth")) - except (TypeError, ValueError): - eth_want = 0.0 - if eth_want > 0 and float(ct_mult) > 0: - import math - - target_sheets = max(int(min_sz), int(math.floor(eth_want / float(ct_mult) + 1e-12))) - from lib.options.options_margin_mode_lib import plan_coin_open_by_budget - - sizing = plan_coin_open_by_budget( - quote_per_unit=float(ask), - ct_mult=float(ct_mult), - min_sz=int(min_sz), - budget_usdt=budget_usdt, - index_px=float(idx or 0), - ask_sz=ask_sz, - target_sheets=target_sheets, - ) - if sizing.get("ok"): - sizing["premium_ccy"] = (inst_id.split("-")[0] if inst_id else "ETH").upper() - sizing["est_coin"] = sizing.get("buy_coin") - q = _attach_close_preview( - cfg, - ex, - q, - sheets=int(sizing.get("sheets") or 0), - premium_paid=_open_premium_paid(cfg, inst_id), - ) - return jsonify( - { - **q, - "can_open": bool(sizing.get("ok")), - "quote_per_unit": ask, - "premium_per_sheet": round(float(ask) * float(ct_mult), 8), - "sizing": sizing, - "mode": mode, - "mode_note": mode_note, - "options_margin_mode": "coin", - "coin_budget": budget_info, - "compound_full_enabled": _compound_full_enabled(), - } - ) - except Exception as e: - return jsonify({"ok": False, "msg": f"币本位报价失败: {e}"}) - - budget = cfg["trade_budget"] - budget_cap = cfg["trade_budget"] - available_usdc = None - if mode == "budget_full": - blocked = _budget_full_blocked_by_compound_msg() - if blocked: - return jsonify( - { - "ok": False, - "msg": blocked, - "compound_full_enabled": _compound_full_enabled(), - } - ) - budget, budget_err = _budget_full_usdc(cfg, ex) - if budget is None: - return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": _compound_full_enabled()}) - budget_cap = budget - from lib.exchange.okx_options_lib import fetch_options_trading_usdc - - available_usdc = fetch_options_trading_usdc(ex) - elif mode == "compound_full": - if not _compound_full_enabled(): - return jsonify( - { - "ok": False, - "msg": "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)", - "compound_full_enabled": False, - } - ) - budget, budget_err = _compound_full_usdc(cfg, ex) - if budget is None: - return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": True}) - budget_cap = budget - from lib.exchange.okx_options_lib import fetch_options_trading_usdc - - available_usdc = fetch_options_trading_usdc(ex) - elif mode in ("sheets", "eth_amount") and _compound_full_enabled(): - budget_cap = None - eth_amount = None - try: - if request.args.get("eth_amount"): - eth_amount = float(request.args.get("eth_amount")) - except (TypeError, ValueError): - pass - ask = q.get("ask") - ask_sz = q.get("ask_sz") - try: - from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg - - mode_block = block_standalone_open_by_mode_msg() - if mode_block: - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": mode_block, - "quote_per_unit": ask, - "premium_per_sheet": None, - "sizing": { - "ok": False, - "msg": mode_block, - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - "available_usdc": available_usdc, - "budget_full_usdc": budget if mode == "budget_full" else None, - "compound_full_usdc": budget if mode == "compound_full" else None, - } - ) - except Exception as e: - return jsonify( - { - "ok": False, - "can_open": False, - "msg": f"交易模式校验失败: {e}", - } - ) - try: - from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg - - conn_q = cfg["get_db"]() - try: - excl = block_standalone_option_open_msg(conn_q) - finally: - conn_q.close() - if excl: - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": excl, - "quote_per_unit": ask, - "premium_per_sheet": None, - "sizing": { - "ok": False, - "msg": excl, - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - "available_usdc": available_usdc, - "budget_full_usdc": budget if mode == "budget_full" else None, - "compound_full_usdc": budget if mode == "compound_full" else None, - } - ) - except Exception as e: - return jsonify({"ok": False, "can_open": False, "msg": f"互斥校验失败: {e}"}) - can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) - if not can_open: - # 合约可报价,但不可开仓:返回参考标记价供展示 - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", - "quote_per_unit": None, - "premium_per_sheet": None, - "sizing": { - "ok": False, - "msg": block_msg or "暂无卖一深度,无法买入", - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - "available_usdc": available_usdc, - "budget_full_usdc": budget if mode == "budget_full" else None, - "compound_full_usdc": budget if mode == "compound_full" else None, - } - ) - from lib.options.options_position_limit_lib import ( - compound_full_single_position_block_msg, - option_position_limit_block_msg, - ) - - if mode == "compound_full": - compound_block = compound_full_single_position_block_msg( - ex, fetch_positions=cfg.get("fetch_option_positions") - ) - if compound_block: - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": compound_block, - "quote_per_unit": ask, - "premium_per_sheet": None, - "sizing": { - "ok": False, - "msg": compound_block, - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - "available_usdc": available_usdc, - "budget_full_usdc": None, - "compound_full_usdc": budget, - } - ) - - pos_limit_msg = option_position_limit_block_msg( - ex, - opening_inst_id=inst_id, - fetch_positions=cfg.get("fetch_option_positions"), - ) - if pos_limit_msg: - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": pos_limit_msg, - "quote_per_unit": ask, - "premium_per_sheet": None, - "sizing": { - "ok": False, - "msg": pos_limit_msg, - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - "available_usdc": available_usdc, - "budget_full_usdc": budget if mode == "budget_full" else None, - "compound_full_usdc": budget if mode == "compound_full" else None, - } - ) - sizing = calc_order_size( - quote_per_unit=float(ask), - ct_mult=float(ct_mult), - min_sz=int(min_sz), - budget_usdc=budget if _is_budget_mode(mode) else None, - budget_buffer=cfg["budget_buffer"], - eth_amount=eth_amount if mode == "eth_amount" else None, - sheets=sheet_count if mode == "sheets" else None, - budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) - if mode in ("budget_full", "compound_full", "sheets", "eth_amount") - else None, - ) - if sizing.get("ok"): - capped, cap_msg = cap_option_buy_sheets_to_ask_depth( - int(sizing.get("sheets") or 0), - ask_sz, - min_sz=int(min_sz), - ) - if capped is None: - sizing = { - "ok": False, - "msg": cap_msg, - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - } - elif capped < int(sizing.get("sheets") or 0): - sizing = calc_order_size( - quote_per_unit=float(ask), - ct_mult=float(ct_mult), - min_sz=int(min_sz), - sheets=capped, - budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) - if mode in ("budget_full", "compound_full", "sheets", "eth_amount") - else None, - ) - if sizing.get("ok"): - sizing["ask_depth_capped"] = True - sizing["ask_sz"] = ask_sz - sizing["msg"] = f"已按卖一深度限制为 {capped} 张" - q = _attach_close_preview( - cfg, - ex, - q, - sheets=int(sizing.get("sheets") or sheet_count or 0), - premium_paid=_open_premium_paid(cfg, inst_id), - ) - return jsonify( - { - **q, - "can_open": True, - "quote_per_unit": ask, - "premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)), - "sizing": sizing, - "available_usdc": available_usdc, - "budget_full_usdc": budget if mode == "budget_full" else None, - "compound_full_usdc": budget if mode == "compound_full" else None, - "mode": mode, - "mode_note": mode_note, - "compound_full_enabled": _compound_full_enabled(), - } - ) - - @app.route("/api/options/open", methods=["POST"]) - @lr - def api_options_open(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - try: - from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg - - mode_block = block_standalone_open_by_mode_msg() - if mode_block: - return jsonify({"ok": False, "msg": mode_block, "can_open": False}) - except Exception as e: - return jsonify({"ok": False, "msg": f"交易模式校验失败: {e}", "can_open": False}) - try: - from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg - - conn_gate = cfg["get_db"]() - try: - block_msg = block_standalone_option_open_msg(conn_gate) - finally: - conn_gate.close() - if block_msg: - return jsonify({"ok": False, "msg": block_msg}) - except Exception as e: - return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"}) - data = request.get_json(silent=True) or {} - inst_id = (data.get("inst_id") or "").strip() - mode = (data.get("mode") or "sheets").strip() - mode, mode_note = _normalize_size_mode(mode) - signal_note = (data.get("signal_note") or "").strip() - if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full": - # 前端残留全仓复利选中时,已自动改指定张数;继续开仓 - pass - target_index = None - raw_target = data.get("target_index") - if raw_target is not None and str(raw_target).strip() != "": - try: - target_index = float(raw_target) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "目标位无效"}) - if target_index <= 0: - return jsonify({"ok": False, "msg": "目标位无效"}) - profit_exit_enabled = bool(data.get("profit_exit_enabled")) - profit_exit_mult = 1.0 - if profit_exit_enabled: - from lib.options.options_profit_exit_lib import normalize_profit_exit_mult - - profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0) - if not inst_id: - return jsonify({"ok": False, "msg": "缺少 inst_id"}) - try: - from lib.options.options_margin_mode_lib import is_coin_margin_mode - from lib.options.options_coin_open_lib import open_coin_option_buy_full - - if is_coin_margin_mode(): - want_sheets = None - if mode == "sheets": - try: - want_sheets = int(data.get("sheets") or 0) or None - except (TypeError, ValueError): - want_sheets = None - elif mode == "eth": - try: - eth_want = float(data.get("eth") or 0) - except (TypeError, ValueError): - eth_want = 0.0 - if eth_want > 0: - q0 = cfg["quote_option_contract"](ex, inst_id) - ct0 = float((q0 or {}).get("ct_mult") or 0.01) - min0 = int((q0 or {}).get("min_sz") or 1) - if ct0 > 0: - import math - - want_sheets = max(min0, int(math.floor(eth_want / ct0 + 1e-12))) - result = open_coin_option_buy_full( - cfg, - ex, - inst_id=inst_id, - signal_note=signal_note, - target_index=target_index, - profit_exit_enabled=profit_exit_enabled, - profit_exit_mult=profit_exit_mult, - target_sheets=want_sheets, - ) - if result.get("ok"): - from lib.exchange.okx_options_lib import invalidate_option_positions_cache - - invalidate_option_positions_cache() - _mark_balances_stale(cfg) - return jsonify(result) - except Exception as e: - return jsonify({"ok": False, "msg": f"币本位开仓失败: {e}"}) - q = cfg["quote_option_contract"](ex, inst_id) - if not q.get("ok"): - return jsonify(q) - ask = q.get("ask") - ask_sz = q.get("ask_sz") - can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) - if not can_open: - return jsonify( - { - "ok": False, - "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", - "can_open": False, - "mark": q.get("mark"), - "ref_ask": q.get("ref_ask"), - } - ) - from lib.options.options_position_limit_lib import ( - compound_full_single_position_block_msg, - option_position_limit_block_msg, - ) - - if mode == "compound_full": - compound_block = compound_full_single_position_block_msg( - ex, fetch_positions=cfg.get("fetch_option_positions") - ) - if compound_block: - return jsonify({"ok": False, "msg": compound_block, "can_open": False}) - - pos_limit_msg = option_position_limit_block_msg( - ex, - opening_inst_id=inst_id, - fetch_positions=cfg.get("fetch_option_positions"), - ) - if pos_limit_msg: - return jsonify({"ok": False, "msg": pos_limit_msg, "can_open": False}) - ct_mult = float(q.get("ct_mult") or 0.01) - min_sz = int(q.get("min_sz") or 1) - eth_amount = None - sheet_count = None - if mode == "eth_amount": - try: - eth_amount = float(data.get("eth_amount")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "ETH 数量无效"}) - elif mode == "sheets": - try: - sheet_count = int(data.get("sheets")) - except (TypeError, ValueError): - sheet_count = None - if sheet_count is None or int(sheet_count) < 1: - # 全仓复利关闭后前端可能仍带着旧 mode 过来,归一后缺张数则默认 1 - if (data.get("mode") or "").strip() == "compound_full": - sheet_count = 1 - else: - return jsonify({"ok": False, "msg": "张数无效"}) - budget = cfg["trade_budget"] - budget_cap = cfg["trade_budget"] - if mode == "budget_full": - blocked = _budget_full_blocked_by_compound_msg() - if blocked: - return jsonify({"ok": False, "msg": blocked, "compound_full_enabled": _compound_full_enabled()}) - budget, budget_err = _budget_full_usdc(cfg, ex) - if budget is None: - return jsonify({"ok": False, "msg": budget_err}) - budget_cap = budget - elif mode == "compound_full": - if not _compound_full_enabled(): - return jsonify( - { - "ok": False, - "msg": "全仓复利未开启,请改用指定张数或先开启全仓复利", - "compound_full_enabled": False, - } - ) - budget, budget_err = _compound_full_usdc(cfg, ex) - if budget is None: - return jsonify({"ok": False, "msg": budget_err}) - budget_cap = budget - elif mode in ("sheets", "eth_amount") and _compound_full_enabled(): - budget_cap = None - sizing = calc_order_size( - quote_per_unit=float(ask), - ct_mult=ct_mult, - min_sz=min_sz, - budget_usdc=budget if _is_budget_mode(mode) else None, - budget_buffer=cfg["budget_buffer"], - eth_amount=eth_amount, - sheets=sheet_count, - budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) - if mode in ("budget_full", "compound_full", "sheets", "eth_amount") - else None, - ) - if not sizing.get("ok"): - return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) - sheets = int(sizing["sheets"]) - capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz) - if capped is None: - return jsonify({"ok": False, "msg": cap_msg or "卖一深度不足,无法买入"}) - if capped < sheets: - return jsonify( - { - "ok": False, - "msg": f"卖一深度仅 {int(capped)} 张,不足请求 {int(sheets)} 张,拒绝缩量成交", - "requested_sheets": int(sheets), - "ask_sz": ask_sz, - } - ) - tick_sz = q.get("tick_sz") - order = cfg["place_option_limit_order"]( - ex, - inst_id=inst_id, - side="buy", - sheets=sheets, - price=float(ask), - td_mode=td_mode_for_option_buy(cfg["td_mode"]), - tick_sz=tick_sz, - ord_type="ioc", - ) - if not order.get("ok"): - return jsonify(order) - ord_id = str((order.get("data") or {}).get("ordId") or "").strip() - if not ord_id: - return jsonify({"ok": False, "msg": "下单成功但未返回订单号", "order": order}) - from lib.exchange.okx_options_lib import wait_option_order_full_fill - - try: - fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12")) - except (TypeError, ValueError): - fill_timeout = 12.0 - fill = wait_option_order_full_fill( - ex, - inst_id=inst_id, - ord_id=ord_id, - need_sheets=int(sheets), - timeout_sec=fill_timeout, - cancel_on_timeout=True, - ) - if not fill.get("ok"): - filled_n = int(fill.get("filled_sheets") or 0) - orphan_close = None - if filled_n > 0: - try: - from lib.options.options_close_exec_lib import close_option_by_bid1 - - orphan_close = close_option_by_bid1( - cfg, ex, inst_id, sheets=filled_n, require_recycle_gate=False - ) - except Exception as e: - orphan_close = {"ok": False, "msg": str(e)} - return jsonify( - { - "ok": False, - "msg": fill.get("msg") or "未完全成交,开仓失败", - "filled_sheets": filled_n, - "orphan_close": orphan_close, - "fill": fill, - "order": order, - } - ) - fill_px = float(fill.get("avg_px") or ask) - filled_n = int(fill.get("filled_sheets") or sheets) - sheets = filled_n - sizing = dict(sizing) - sizing["sheets"] = sheets - sizing["eth_amount"] = round(sheets * ct_mult, 8) - sizing["total_premium"] = round(fill_px * sheets * ct_mult, 4) - conn = cfg["get_db"]() - trade_id = None - target_mon = None - open_underlying = "" - open_opt_type = None - try: - init_options_tables(conn) - from lib.options.options_profit_exit_lib import ensure_profit_exit_columns - - ensure_profit_exit_columns(conn) - meta = q.get("meta") or {} - u = str(meta.get("uly") or inst_id).split("-")[0] - opt_type = meta.get("optType") - open_underlying = u - open_opt_type = opt_type - from lib.options.options_time_lib import options_app_now_str - - cur = conn.execute( - """ - INSERT INTO options_trades - (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, - open_quote, premium_paid, status, signal_note, exchange_ord_id, - profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?) - """, - ( - inst_id, - u, - opt_type, - q.get("strike"), - str(q.get("exp_time") or ""), - sheets, - sizing["eth_amount"], - fill_px, - sizing["total_premium"], - signal_note, - ord_id, - 1 if profit_exit_enabled else 0, - profit_exit_mult if profit_exit_enabled else 1.0, - "active" if profit_exit_enabled else "idle", - options_app_now_str(), - ), - ) - trade_id = int(cur.lastrowid) - if target_index is not None: - from lib.options.options_target_lib import upsert_target_monitor - - target_mon = upsert_target_monitor( - conn, - inst_id=inst_id, - target_index=target_index, - underlying=u, - opt_type=str(opt_type) if opt_type else None, - trade_id=trade_id, - sheets=sheets, - ) - if profit_exit_enabled: - pass # 列已由 init_options_tables / ensure 迁移 - conn.commit() - finally: - conn.close() - from lib.exchange.okx_options_lib import invalidate_option_positions_cache - from lib.options.options_notify_lib import notify_options_open - - invalidate_option_positions_cache() - _sync_options_trades(cfg, force=True) - try: - conn_n = cfg["get_db"]() - try: - notify_options_open( - cfg, - conn_n, - trade_id=trade_id, - inst_id=inst_id, - underlying=open_underlying, - opt_type=open_opt_type, - sheets=sheets, - premium_paid=sizing.get("total_premium"), - open_quote=fill_px, - target_index=target_index, - signal_note=signal_note, - premium_ccy=sizing.get("premium_ccy") or "USDC", - ) - finally: - conn_n.close() - except Exception: - pass - return jsonify( - { - "ok": True, - "order": order, - "sizing": sizing, - "trade_id": trade_id, - "target_monitor": target_mon, - } - ) - - @app.route("/api/options/orders/pending") - @lr - def api_options_orders_pending(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - inst_id = (request.args.get("inst_id") or "").strip() or None - try: - orders = cfg["fetch_option_pending_orders"](ex, inst_id) - except Exception as e: - return jsonify({"ok": False, "msg": f"获取委托失败: {e}"}) - from lib.options.options_pending_lib import enrich_pending_orders - - ttl = float(cfg.get("pending_ttl_seconds") or 600.0) - enriched = enrich_pending_orders(orders, ttl_seconds=ttl) - return jsonify( - { - "ok": True, - "orders": enriched, - "count": len(enriched), - "pending_ttl_seconds": ttl, - } - ) - - @app.route("/api/options/orders/cancel", methods=["POST"]) - @lr - def api_options_orders_cancel(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - inst_id = (data.get("inst_id") or "").strip() - ord_id = (data.get("ord_id") or "").strip() - if not inst_id or not ord_id: - return jsonify({"ok": False, "msg": "缺少 inst_id 或 ord_id"}) - out = cfg["cancel_option_order"](ex, inst_id=inst_id, ord_id=ord_id) - if out.get("ok"): - from lib.exchange.okx_options_lib import invalidate_option_positions_cache - - invalidate_option_positions_cache() - # 本地未成交开仓记录标记取消,避免假 open - try: - conn = cfg["get_db"]() - try: - from lib.options.options_time_lib import options_app_now_str - - init_options_tables(conn) - conn.execute( - """ - UPDATE options_trades - SET status = 'cancelled', - signal_note = CASE - WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销' - ELSE signal_note - END, - closed_at = ? - WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open' - """, - (options_app_now_str(), inst_id, ord_id), - ) - conn.commit() - finally: - conn.close() - except Exception: - pass - _sync_options_trades(cfg, force=True) - return jsonify(out), (200 if out.get("ok") else 400) - - @app.route("/api/options/positions") - @lr - def api_options_positions(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - raw = cfg["fetch_option_positions"](ex) - if raw is None: - return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - _sync_options_trades(cfg, raw_positions=raw) - meta_cache: dict[str, dict[str, Any] | None] = {} - conn = cfg["get_db"]() - try: - from lib.options.options_target_lib import targets_by_inst - from lib.options.options_profit_exit_lib import profit_exit_by_inst - from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst - - tgt_map = targets_by_inst(conn) - profit_exit_map = profit_exit_by_inst(conn) - hedge_target_map = active_options_targets_by_inst(conn) - rows = [] - for p in raw: - inst = str(p.get("instId") or "").strip() - premium_override = sum_open_premium_paid(conn, inst) if inst else None - row = _enrich_position_row_display( - cfg, - ex, - p, - meta_cache=meta_cache, - premium_override=premium_override, - ) - _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) - mon = tgt_map.get(inst) - if mon: - row["target_index"] = mon.get("target_index") - row["target_monitor_id"] = mon.get("id") - row["target_monitor"] = mon - pe = profit_exit_map.get(inst) - if pe: - row["profit_exit_enabled"] = pe.get("profit_exit_enabled") - row["profit_exit_mult"] = pe.get("profit_exit_mult") - row["profit_exit_state"] = pe.get("profit_exit_state") - row["profit_exit_required_recycle"] = pe.get("required_recycle") - hedge_target = hedge_target_map.get(inst) - if hedge_target: - row["hedge_plan_target"] = hedge_target - try: - from lib.instance.instance_dashboard_lib import _resolve_options_source - - source_key, source_label, source_plan_id = _resolve_options_source(conn, inst) - row["source"] = source_key - row["source_label"] = source_label - row["source_plan_id"] = source_plan_id - except Exception: - row.setdefault("source", "option") - row.setdefault("source_label", "纯期权") - row.setdefault("source_plan_id", None) - rows.append(row) - finally: - conn.close() - spot_sell_hint = None - try: - from lib.options.options_margin_mode_lib import is_coin_margin_mode - - if is_coin_margin_mode(): - from lib.options.options_spot_bridge_lib import list_open_bridges - - conn_b = cfg["get_db"]() - try: - from lib.options.options_db import init_options_tables - - init_options_tables(conn_b) - open_bridges = list_open_bridges(conn_b) - bal = cfg["fetch_options_balances"](ex, scope="main") - bridge = open_bridges[0] if open_bridges else None - spot_sell_hint = { - "bridge_status": str(bridge.get("status") or "") if bridge else None, - "bridge_underlying": str(bridge.get("underlying") or "").upper() if bridge else None, - "trading_eth": bal.get("trading_eth_avail") or bal.get("trading_eth"), - "trading_btc": bal.get("trading_btc_avail") or bal.get("trading_btc"), - "default_underly": (cfg.get("default_underly") or "ETH").strip().upper(), - } - finally: - conn_b.close() - except Exception: - spot_sell_hint = None - return jsonify({"ok": True, "positions": rows, "spot_sell": spot_sell_hint}) - - @app.route("/api/options/targets") - @lr - def api_options_targets(): - conn = cfg["get_db"]() - try: - from lib.options.options_target_lib import list_active_targets, list_closing_targets - - return jsonify({"ok": True, "targets": list_active_targets(conn) + list_closing_targets(conn)}) - finally: - conn.close() - - @app.route("/api/options/target", methods=["POST"]) - @lr - def api_options_target_set(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - inst_id = (data.get("inst_id") or "").strip() - if not inst_id: - return jsonify({"ok": False, "msg": "缺少 inst_id"}) - try: - from lib.hedge_plan.hedge_plan_db import ( - active_hedge_option_inst_ids, - init_hedge_plan_tables, - ) - - conn_h = cfg["get_db"]() - try: - init_hedge_plan_tables(conn_h) - if inst_id in active_hedge_option_inst_ids(conn_h): - return jsonify( - { - "ok": False, - "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置目标", - } - ) - finally: - conn_h.close() - except Exception as e: - return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) - try: - target_index = float(data.get("target_index")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "目标位无效"}) - if target_index <= 0: - return jsonify({"ok": False, "msg": "目标位无效"}) - raw = cfg["fetch_option_positions"](ex) - if raw is None: - return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - pos = _find_position(raw, inst_id) - if not pos: - return jsonify({"ok": False, "msg": "未找到持仓"}) - from lib.options.options_target_lib import upsert_target_monitor - - fmt = cfg["format_position_row"](pos) - conn = cfg["get_db"]() - try: - trade = conn.execute( - """ - SELECT id, opt_type, underlying FROM options_trades - WHERE inst_id = ? AND status = 'open' - ORDER BY id DESC LIMIT 1 - """, - (inst_id,), - ).fetchone() - trade_id = int(trade["id"]) if trade else None - sheets_sum = sum_open_sheets(conn, inst_id) - sheets = sheets_sum if sheets_sum is not None else int(fmt.get("pos") or 0) - opt_type = (trade["opt_type"] if trade else None) or fmt.get("opt_type") - underlying = (trade["underlying"] if trade else None) or fmt.get("underlying") - out = upsert_target_monitor( - conn, - inst_id=inst_id, - target_index=target_index, - underlying=str(underlying) if underlying else None, - opt_type=str(opt_type) if opt_type else None, - trade_id=trade_id, - sheets=sheets, - ) - conn.commit() - return jsonify(out) - finally: - conn.close() - - @app.route("/api/options/target/cancel", methods=["POST"]) - @lr - def api_options_target_cancel(): - data = request.get_json(silent=True) or {} - inst_id = (data.get("inst_id") or "").strip() or None - monitor_id = data.get("id") - try: - mid = int(monitor_id) if monitor_id is not None and str(monitor_id).strip() != "" else None - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "监控 id 无效"}) - if not inst_id and mid is None: - return jsonify({"ok": False, "msg": "缺少 inst_id 或 id"}) - from lib.options.options_target_lib import cancel_target_monitor - - conn = cfg["get_db"]() - try: - n = cancel_target_monitor(conn, inst_id=inst_id, monitor_id=mid) - conn.commit() - return jsonify({"ok": True, "cancelled": n}) - finally: - conn.close() - - @app.route("/api/options/profit-exit", methods=["POST"]) - @lr - def api_options_profit_exit_set(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - inst_id = (data.get("inst_id") or "").strip() - if not inst_id: - return jsonify({"ok": False, "msg": "缺少 inst_id"}) - try: - from lib.hedge_plan.hedge_plan_db import ( - active_hedge_option_inst_ids, - init_hedge_plan_tables, - ) - - conn_h = cfg["get_db"]() - try: - init_hedge_plan_tables(conn_h) - if inst_id in active_hedge_option_inst_ids(conn_h): - return jsonify( - { - "ok": False, - "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置翻倍出场", - } - ) - finally: - conn_h.close() - except Exception as e: - return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) - enabled_raw = data.get("enabled") - if enabled_raw is None: - enabled_raw = data.get("profit_exit_enabled") - enabled = bool(enabled_raw) and str(enabled_raw).strip().lower() not in ( - "0", - "false", - "off", - "no", - ) - from lib.options.options_profit_exit_lib import normalize_profit_exit_mult, set_profit_exit - - mult = normalize_profit_exit_mult(data.get("mult", data.get("profit_exit_mult")), default=1.0) - raw = cfg["fetch_option_positions"](ex) - if raw is None: - return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - if not _find_position(raw, inst_id): - return jsonify({"ok": False, "msg": "未找到持仓"}) - conn = cfg["get_db"]() - try: - out = set_profit_exit(conn, inst_id=inst_id, enabled=enabled, mult=mult) - if out.get("ok"): - conn.commit() - return jsonify(out) - finally: - conn.close() - - @app.route("/api/options/close", methods=["POST"]) - @lr - def api_options_close(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - inst_id = (data.get("inst_id") or "").strip() - if not inst_id: - return jsonify({"ok": False, "msg": "缺少 inst_id"}) - try: - from lib.hedge_plan.hedge_plan_db import ( - active_hedge_option_inst_ids, - init_hedge_plan_tables, - ) - - conn_h = cfg["get_db"]() - try: - init_hedge_plan_tables(conn_h) - if inst_id in active_hedge_option_inst_ids(conn_h): - return jsonify( - { - "ok": False, - "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页平仓", - } - ) - finally: - conn_h.close() - except Exception as e: - return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) - if data.get("market"): - return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价"}) - sheets = data.get("sheets") - try: - sheets_i = int(sheets) if sheets is not None and str(sheets).strip() != "" else None - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "张数无效"}) - from lib.options.options_close_exec_lib import close_option_by_bid1 - - # 手动买一平仓:只验有效流动性;2×门控仅用于自动/目标位平仓 - result = close_option_by_bid1( - cfg, - ex, - inst_id, - sheets=sheets_i, - require_recycle_gate=False, - ) - if result.get("ok"): - from lib.exchange.okx_options_lib import invalidate_option_positions_cache - - invalidate_option_positions_cache() - _sync_options_trades(cfg, force=True) - try: - from lib.options.options_target_lib import cancel_target_monitor - from lib.options.options_notify_lib import notify_options_close - - conn2 = cfg["get_db"]() - try: - if result.get("fully_closed"): - cancel_target_monitor(conn2, inst_id=inst_id) - conn2.commit() - reason = "手动平仓" if result.get("fully_closed") else "手动平仓(部分)" - notify_options_close( - cfg, - conn2, - inst_id=inst_id, - reason=reason, - sheets=result.get("submitted_sheets"), - premium_received=result.get("premium_received"), - close_quote=result.get("locked_bid_px") or result.get("bid"), - ) - finally: - conn2.close() - except Exception: - pass - if result.get("fully_closed"): - try: - from lib.options.options_coin_open_lib import maybe_sell_spot_after_close - - spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=result) - if spot_sell is not None: - result = dict(result) - result["spot_sell"] = spot_sell - if spot_sell.get("bridge_status") == "pending_sell_spot": - result["msg"] = ( - str(result.get("msg") or "平仓成功") - + ";但卖回 USDT 失败,请点「重试卖回」" - ) - except Exception as e: - result = dict(result) - result["spot_sell"] = {"ok": False, "msg": str(e)} - _mark_balances_stale(cfg) - return jsonify(result) - - @app.route("/api/options/spot-bridge/retry-sell", methods=["POST"]) - @lr - def api_options_spot_bridge_retry_sell(): - """币本位:重试把残留标的币市价卖回 USDT.""" - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - underlying = (data.get("underlying") or cfg.get("default_underly") or "ETH").strip().upper() - inst_id = (data.get("inst_id") or "").strip() or None - conn = cfg["get_db"]() - try: - init_options_tables(conn) - from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat - - out = sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id) - if out.get("ok"): - _mark_balances_stale(cfg) - return jsonify(out) - finally: - conn.close() - - @app.route("/api/options/convert/quote", methods=["POST"]) - @lr - def api_options_convert_quote(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - try: - amount = float(data.get("amount")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "数量无效"}) - return jsonify(cfg["estimate_usdt_to_usdc"](ex, amount)) - - @app.route("/api/options/convert/execute", methods=["POST"]) - @lr - def api_options_convert_execute(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - quote_id = (data.get("quote_id") or "").strip() - result = cfg["execute_convert"](ex, quote_id) - if result.get("ok"): - conn = cfg["get_db"]() - try: - init_options_tables(conn) - conn.execute( - """ - INSERT INTO options_convert_log (from_ccy, to_ccy, rfq_sz, received_sz, quote_id, status, message) - VALUES ('USDT', 'USDC', ?, ?, ?, 'ok', '') - """, - ( - data.get("rfq_sz"), - (result.get("data") or {}).get("baseSz"), - quote_id, - ), - ) - conn.commit() - finally: - conn.close() - return jsonify(result) - - @app.route("/api/options/transfer", methods=["POST"]) - @lr - def api_options_transfer(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - ccy = (data.get("ccy") or "USDC").upper() - from_acct = (data.get("from") or "funding").strip() - to_acct = (data.get("to") or "trading").strip() - try: - amount = float(data.get("amount")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "数量无效"}) - try: - import lib.exchange.okx_options_lib as _opt_lib - - xfer = getattr(_opt_lib, "transfer_ccy", None) - except Exception: - xfer = None - if not callable(xfer): - xfer = cfg.get("transfer_ccy") - result = xfer(ex, ccy, amount, from_acct, to_acct) if callable(xfer) else { - "ok": False, - "msg": "划转不可用", - } - if result.get("ok"): - conn = cfg["get_db"]() - try: - init_options_tables(conn) - conn.execute( - """ - INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message) - VALUES (?, ?, ?, ?, 'ok', '') - """, - (ccy, amount, from_acct, to_acct), - ) - conn.commit() - finally: - conn.close() - _mark_balances_stale(cfg) - return jsonify(result) - - @app.route("/api/options/spot/swap", methods=["POST"]) - @lr - def api_options_spot_swap(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - direction = (data.get("direction") or "usdt_to_usdc").strip() - try: - amount = float(data.get("amount")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "数量无效"}) - result = cfg["spot_market_swap_usdt_usdc"](ex, direction=direction, amount=amount) - if result.get("ok"): - _mark_balances_stale(cfg) - return jsonify(result) - - @app.route("/api/options/history") - @lr - def api_options_history(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - from lib.options.options_history_lib import load_options_history - - raw_live = cfg["fetch_option_positions"](ex) - if raw_live is None: - return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - history = load_options_history(ex, cfg) - live_ids = {str(x.get("inst_id") or "") for x in history if x.get("status") == "open"} - return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)}) - - @app.route("/api/options/stats") - @lr - def api_options_stats(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - from lib.options.options_history_lib import load_options_history - from lib.options.options_margin_mode_lib import is_coin_margin_mode - from lib.options.options_positions_lib import sum_options_net_pnl_usdc - from lib.options.options_stats_lib import ( - compute_options_stats_from_history, - history_pnl_to_usdt, - ) - - raw_live = cfg["fetch_option_positions"](ex) - if raw_live is None: - return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - history = load_options_history(ex, cfg) - # 币本位已平盈亏按指数折 U,与持仓浮盈/合计口径一致 - stats = compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) - open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live) - net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0 - total_pnl = None - if open_float is not None: - total_pnl = round(net_realized + float(open_float), 4) - elif stats.get("total_closed"): - total_pnl = round(net_realized, 4) - # 币本位统计统一标 U;USDC 模式仍标 USDC - pnl_unit = "U" if is_coin_margin_mode() else "USDC" - return jsonify( - { - "ok": True, - **stats, - "open_float_pnl": open_float, - "total_pnl": total_pnl, - "pnl_unit": pnl_unit, - } - ) - - @app.route("/api/options/history/", methods=["DELETE"]) - @lr - def api_options_history_delete(history_key: str): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - key = (history_key or "").strip() - if not key: - return jsonify({"ok": False, "msg": "缺少 history_key"}) - data = request.get_json(silent=True) or {} - inst_id = str(data.get("inst_id") or request.args.get("inst_id") or "").strip() or None - closed_at = str(data.get("closed_at") or request.args.get("closed_at") or "").strip() or None - conn = cfg["get_db"]() - try: - init_options_tables(conn) - conn.execute( - "INSERT OR IGNORE INTO options_history_hidden (history_key) VALUES (?)", - (key,), - ) - # 同步隐藏期权复盘,避免本地已平记录刷新后又出现 - try: - from lib.options.options_review_lib import hide_review_keys - - hide_review_keys( - conn, - history_key=key, - inst_id=inst_id, - closed_at=closed_at, - ) - if inst_id: - # 去掉已导入的复盘快照(按合约+平仓时间) - if closed_at: - rows = conn.execute( - """ - SELECT id, history_key FROM options_review_trades - WHERE inst_id = ? - AND substr(COALESCE(closed_at,''),1,16) = substr(?,1,16) - """, - (inst_id, closed_at), - ).fetchall() - else: - rows = conn.execute( - """ - SELECT id, history_key FROM options_review_trades - WHERE inst_id = ? - """, - (inst_id,), - ).fetchall() - for r in rows: - conn.execute( - "DELETE FROM options_review_entries WHERE trade_id=?", - (int(r["id"]),), - ) - conn.execute( - "DELETE FROM options_review_trades WHERE id=?", - (int(r["id"]),), - ) - conn.execute( - "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", - (str(r["history_key"]), inst_id, (closed_at or "")[:19] or None), - ) - fps = [] - if closed_at: - fps.append(f"inst_close:{inst_id}:{closed_at[:16]}") - fps.append(f"inst:{inst_id}") - for fp in fps: - conn.execute( - "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", - (fp, inst_id, (closed_at or "")[:19] or None), - ) - except Exception: - pass - conn.commit() - finally: - conn.close() - return jsonify({"ok": True}) - - -def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: - if app.extensions.get("options_monitor_started"): - return - app.extensions["options_monitor_started"] = True - - def _bid(inst_id: str) -> float | None: - ex = cfg.get("exchange_options") - if ex is None: - return None - try: - q = cfg["quote_option_contract"](ex, inst_id) - return q.get("bid") - except Exception: - return None - - def _positions(): - ex = cfg.get("exchange_options") - if ex is None: - return [] - raw = cfg["fetch_option_positions"](ex) - if raw is None: - return [] - return [cfg["format_position_row"](p) for p in raw] - - def _sync(conn): - from lib.exchange.okx_options_lib import fetch_option_position_history - from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades - - ex = cfg.get("exchange_options") - if ex is None: - return 0 - raw = cfg["fetch_option_positions"](ex) - if raw is None: - return 0 - live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} - reconcile_live_open_trades(conn, live_inst_ids=live_ids) - return sync_open_options_trades( - conn, - live_inst_ids=live_ids, - fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id), - notify_cfg=cfg, - ) - - def _target_close(inst_id: str) -> dict[str, Any]: - from lib.options.options_target_lib import close_option_by_bid_depth - - ex = cfg.get("exchange_options") - if ex is None: - return {"ok": False, "msg": "期权 exchange 未就绪"} - result = close_option_by_bid_depth(cfg, ex, inst_id) - if result.get("ok"): - try: - _sync_options_trades(cfg, force=True) - except Exception: - pass - try: - _mark_balances_stale(cfg) - except Exception: - pass - return result - - def _profit_exit_close(inst_id: str) -> dict[str, Any]: - from lib.options.options_profit_exit_lib import close_option_by_bid_profit_exit - - ex = cfg.get("exchange_options") - if ex is None: - return {"ok": False, "msg": "期权 exchange 未就绪"} - result = close_option_by_bid_profit_exit(cfg, ex, inst_id) - if result.get("ok"): - try: - _sync_options_trades(cfg, force=True) - except Exception: - pass - try: - _mark_balances_stale(cfg) - except Exception: - pass - return result - - def _stale_pending() -> dict[str, Any]: - from lib.exchange.okx_options_lib import invalidate_option_positions_cache - from lib.options.options_pending_lib import cancel_stale_close_pending_orders - - ex = cfg.get("exchange_options") - if ex is None: - return {"ok": False, "msg": "期权 exchange 未就绪"} - ttl = float(cfg.get("pending_ttl_seconds") or 600.0) - out = cancel_stale_close_pending_orders( - fetch_pending=lambda _ex: cfg["fetch_option_pending_orders"](_ex), - cancel_order=lambda _ex, inst_id, ord_id: cfg["cancel_option_order"]( - _ex, inst_id=inst_id, ord_id=ord_id - ), - ttl_seconds=ttl, - ex=ex, - ) - if out.get("cancelled"): - try: - invalidate_option_positions_cache() - except Exception: - pass - try: - send = cfg.get("send_wechat") - if callable(send): - parts = [ - "【OKX期权·挂单超时撤销】", - f"账户:{cfg.get('account_label') or 'OKX期权'}", - f"超时:{ttl:g}s", - f"撤销:{out.get('cancelled')} 笔", - ] - for o in out.get("orders") or []: - parts.append(f"- {o.get('inst_id')} #{o.get('ord_id')}") - send("\n".join(parts)) - except Exception: - pass - return out - - t = threading.Thread( - target=options_monitor_loop, - kwargs={ - "enabled": True, - "poll_seconds": cfg["poll_seconds"], - "get_db": cfg["get_db"], - "fetch_positions": _positions, - "ticker_bid_fn": _bid, - "send_wechat": cfg["send_wechat"], - "account_label": cfg["account_label"], - "profit_ratio": cfg["profit_ratio"], - "sync_trades_fn": _sync, - "target_close_fn": _target_close, - "profit_exit_close_fn": _profit_exit_close, - "profit_exit_cfg": cfg, - "stale_pending_fn": _stale_pending, - }, - daemon=True, - name="options-monitor", - ) - t.start() +"""OKX 期权模块:Flask 路由注册.""" +from __future__ import annotations + +import os +import threading +import time +from typing import Any + +from flask import Flask, jsonify, redirect, request, url_for +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.options.options_db import init_options_tables, sum_open_premium_paid, sum_open_sheets +from lib.options.options_monitor_lib import options_monitor_loop +from lib.options.options_pricing_lib import ( + calc_order_size, + ct_mult_from_meta, + min_sz_from_meta, + premium_per_sheet, +) +from lib.exchange.okx_options_lib import ( + _safe_float, + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, + td_mode_for_option_buy, +) + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _env_float(key: str, default: float) -> float: + try: + return float(os.getenv(key) or default) + except (TypeError, ValueError): + return float(default) + + +def attach_options_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "options", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None: + enabled = _env_bool("OKX_OPTIONS_ENABLED", False) + attach_options_templates(app, repo_root) + cfg = _build_cfg(app_module) + try: + from lib.sim.hooks import patch_options_cfg + + patch_options_cfg(cfg) + except Exception: + pass + app.extensions["options_cfg"] = cfg + register_options_routes(app, cfg) + _register_options_hub_bridge(app, cfg) + if enabled: + _start_monitor_thread(app, cfg) + + +def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None: + from lib.options.options_hub_lib import build_options_hub_snapshot + + def snapshot_fn(): + return build_options_hub_snapshot(cfg) + + hub_ctx = dict(app.config.get("HUB_CTX") or {}) + hub_ctx["options_snapshot_fn"] = snapshot_fn + app.config["HUB_CTX"] = hub_ctx + + +def _build_cfg(app_module: Any) -> dict[str, Any]: + from lib.exchange.okx_options_lib import ( + build_option_chain, + estimate_usdt_to_usdc, + execute_convert, + fetch_option_book_depth, + fetch_option_positions, + fetch_options_balances, + format_position_row, + options_api_ready, + cancel_option_order, + fetch_option_pending_orders, + place_option_limit_order, + place_option_market_order, + quote_option_contract, + spot_market_swap_usdt_usdc, + transfer_ccy, + ) + + return { + "enabled": _env_bool("OKX_OPTIONS_ENABLED", False), + "get_db": app_module.get_db, + "login_required": app_module.login_required, + "exchange_options": getattr(app_module, "exchange_options", None), + "send_wechat": app_module.send_wechat_msg, + "render_main_page": app_module.render_main_page, + "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0), + "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95), + "compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True), + "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), + "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), + "margin_mode": (os.getenv("OKX_OPTIONS_MARGIN_MODE") or "coin").strip().lower(), + "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), + "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), + "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0), + "chain_ask_liq_filter": _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True), + "itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0), + "td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), + # 市价平仓已硬关闭(忽略 env),仅买一限价 + "allow_market_close": False, + # 平仓限价挂单超时自动撤单(秒);默认 600=10 分钟,联调可设 60 + "pending_ttl_seconds": _env_float("OKX_OPTIONS_PENDING_TTL_SECONDS", 600.0), + "profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0), + "poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0), + "account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(), + "build_option_chain": build_option_chain, + "quote_option_contract": quote_option_contract, + "fetch_option_book_depth": fetch_option_book_depth, + "place_option_limit_order": place_option_limit_order, + "place_option_market_order": place_option_market_order, + "fetch_option_pending_orders": fetch_option_pending_orders, + "cancel_option_order": cancel_option_order, + "fetch_option_positions": fetch_option_positions, + "fetch_options_balances": fetch_options_balances, + "format_position_row": format_position_row, + "estimate_usdt_to_usdc": estimate_usdt_to_usdc, + "execute_convert": execute_convert, + "transfer_ccy": transfer_ccy, + "spot_market_swap_usdt_usdc": spot_market_swap_usdt_usdc, + "options_api_ready": options_api_ready, + "app_module": app_module, + } + + +def _mark_balances_stale(cfg: dict[str, Any]) -> None: + from lib.exchange.okx_options_lib import invalidate_options_balance_cache + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + invalidate_options_balance_cache() + app_mod = cfg.get("app_module") + if app_mod is not None and hasattr(app_mod, "invalidate_account_balance_cache"): + app_mod.invalidate_account_balance_cache() + try: + notify_instance_balance_changed() + except Exception: + pass + + +def _require_options_ex(cfg: dict[str, Any]): + if not cfg.get("enabled"): + return None, "期权模块未启用,请在 .env 设置 OKX_OPTIONS_ENABLED=true 并重启 PM2" + ex = cfg.get("exchange_options") + ok, reason = cfg["options_api_ready"](ex) + if not ok: + return None, reason or "期权 API 未配置" + return ex, "" + + +def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: + """打满可用额度 = min(交易户可用 USDC, 单笔预算);calc_order_size 再乘 budget_buffer.""" + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + from lib.options.options_pricing_lib import resolve_budget_full_usdc + + raw = fetch_options_trading_usdc(ex) + if raw is None or float(raw) <= 0: + return None, "交易账户 USDC 可用余额不足" + trading = float(raw) + cap = _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10.0)) + if cap <= 0: + return None, "单笔预算无效(OKX_OPTIONS_TRADE_BUDGET_USDC)" + return resolve_budget_full_usdc(trading, float(cap)), "" + + +def _compound_full_enabled() -> bool: + return _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True) + + +def _budget_full_blocked_by_compound_msg() -> str | None: + if _compound_full_enabled(): + return "全仓复利已开启,不可使用单笔预算/打满;请关闭全仓复利或改用全仓复利模式" + return None + + +def _size_mode_budget_cap( + cfg: dict[str, Any], mode: str, budget_cap: float | None +) -> float | None: + """全仓复利开启时禁用单笔预算封顶(sheets/eth 也不再受 trade_budget 限制).""" + if mode in ("budget_full", "compound_full"): + return budget_cap + if mode in ("sheets", "eth_amount"): + if _compound_full_enabled(): + return None + return budget_cap + return None + + +def _normalize_size_mode(mode: str) -> tuple[str, str | None]: + """全仓复利关闭时强制离开 compound_full,避免前端残留选中导致无法开仓.""" + m = (mode or "sheets").strip() or "sheets" + if m == "compound_full" and not _compound_full_enabled(): + return "sheets", "全仓复利已关闭,已改用指定张数" + if m == "budget_full" and _compound_full_enabled(): + return "compound_full", None + return m, None + + +def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: + """全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer.""" + if not _compound_full_enabled(): + return None, "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)" + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + from lib.options.options_pricing_lib import resolve_compound_full_usdc + + raw = fetch_options_trading_usdc(ex) + if raw is None or float(raw) <= 0: + return None, "交易账户 USDC 可用余额不足" + trading = float(raw) + # 额度热更读 env(与模板启动值无关) + cap_on = _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False) + cap_v = _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0) + if cap_on and cap_v <= 0: + return None, "全仓上限无效(OKX_OPTIONS_COMPOUND_FULL_CAP_USDC)" + return ( + resolve_compound_full_usdc( + trading, + cap_enabled=cap_on, + cap_usdc=cap_v, + ), + "", + ) + + +def _is_budget_mode(mode: str) -> bool: + return mode in ("budget_full", "compound_full") + + +def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: + conn = cfg["get_db"]() + try: + init_options_tables(conn) + return sum_open_premium_paid(conn, inst_id) + finally: + conn.close() + + +def _position_avail_sheets(pos: dict[str, Any]) -> int: + avail = _safe_float(pos.get("availPos")) + if avail is None or avail <= 0: + avail = abs(_safe_float(pos.get("pos")) or 0) + return max(0, int(avail or 0)) + + +def _find_position(rows: list[dict[str, Any]] | None, inst_id: str) -> dict[str, Any] | None: + return next((p for p in rows or [] if str(p.get("instId")) == inst_id), None) + + +def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int | None: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return None + pos = _find_position(raw, inst_id) + if not pos: + return 0 + return _position_avail_sheets(pos) + + +def _enrich_position_row_display( + cfg: dict[str, Any], + ex: Any, + raw_pos: dict[str, Any], + *, + meta_cache: dict[str, dict[str, Any] | None] | None = None, + premium_override: float | None = None, +) -> dict[str, Any]: + from lib.options.options_history_lib import enrich_position_row_display + + return enrich_position_row_display( + cfg, + ex, + raw_pos, + meta_cache=meta_cache, + premium_override=premium_override, + ) + + +def _attach_close_preview( + cfg: dict[str, Any], + ex: Any, + row: dict[str, Any], + *, + sheets: int | None = None, + premium_paid: float | None = None, +) -> dict[str, Any]: + from lib.options.options_positions_lib import attach_close_preview + + return attach_close_preview( + cfg, + ex, + row, + sheets=sheets, + premium_paid=premium_paid, + ) + + +_OPTIONS_SYNC_LOCK = threading.Lock() +_OPTIONS_SYNC_LAST_AT = 0.0 +_OPTIONS_SYNC_INTERVAL_SEC = 15.0 + + +def _sync_options_trades( + cfg: dict[str, Any], + *, + raw_positions: list[dict[str, Any]] | None = None, + force: bool = False, +) -> None: + global _OPTIONS_SYNC_LAST_AT + ex = cfg.get("exchange_options") + if ex is None: + return + now = time.time() + with _OPTIONS_SYNC_LOCK: + if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC: + return + _OPTIONS_SYNC_LAST_AT = now + from lib.exchange.okx_options_lib import fetch_all_option_positions_history, fetch_option_position_history + from lib.options.options_monitor_lib import ( + backfill_closed_options_realized_pnl_from_history, + reconcile_live_open_trades, + sync_open_options_trades, + ) + + if raw_positions is None: + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return + else: + raw = raw_positions + live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} + + def _hist(inst_id: str): + return fetch_option_position_history(ex, inst_id) + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + reconcile_live_open_trades(conn, live_inst_ids=live_ids) + sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist) + try: + hist_all = fetch_all_option_positions_history(ex, limit=200) + backfill_closed_options_realized_pnl_from_history(conn, hist_all) + except Exception: + pass + conn.commit() + finally: + conn.close() + + +def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: + lr = cfg["login_required"] + + @app.route("/options/guide") + @lr + def options_trade_guide(): + """期权开平仓与监控说明(独立页).""" + from pathlib import Path + + from flask import render_template_string + + from lib.hub.hub_strategy_lib import render_markdown_html + + md_path = Path(__file__).resolve().parents[2] / "docs" / "期权开平仓与监控说明.md" + try: + md_text = md_path.read_text(encoding="utf-8") + except OSError: + md_text = "# 说明文档缺失\n\n未找到 `docs/期权开平仓与监控说明.md`." + body = render_markdown_html(md_text) + return render_template_string( + """ + + + + + + 期权开平仓与监控说明 + + + +

← 返回期权

+ {{ body|safe }} + + + """, + body=body, + ) + + @app.route("/api/options/balances") + @lr + def api_options_balances(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") + bal = cfg["fetch_options_balances"](ex, force=force, scope="main") + from lib.options.options_margin_mode_lib import is_coin_margin_mode, normalize_options_margin_mode + + margin_mode = normalize_options_margin_mode() + payload = { + "ok": True, + **bal, + "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)), + "compound_full_enabled": _compound_full_enabled(), + "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), + "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), + "options_margin_mode": margin_mode, + "options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC", + } + if is_coin_margin_mode(): + try: + from lib.options.options_coin_open_lib import coin_budget_preview + + payload["coin_budget"] = coin_budget_preview(cfg, ex) + except Exception as e: + payload["coin_budget"] = {"ok": False, "msg": str(e)} + return jsonify(payload) + + @app.route("/api/options/chain") + @lr + def api_options_chain(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + u = (request.args.get("underlying") or cfg["default_underly"]).upper() + # 热更新:链展示天数每次读 env,保存后刷新链即可 + chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14)) + try: + from lib.options.options_margin_mode_lib import normalize_options_margin_mode + + margin_mode = normalize_options_margin_mode() + chain = cfg["build_option_chain"]( + ex, + u, + max_dte_days=chain_max_dte, + itm_only=False, + itm_max_dist_usd=cfg["itm_max_dist"], + margin_mode=margin_mode, + ) + except Exception as e: + return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) + expiries = chain.get("expiries") or [] + chain_err = chain.get("chain_error") + # 热更新:每次读 env,保存配置后刷新链即可生效 + ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True) + budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95) + coin_budget = None + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + from lib.options.options_coin_open_lib import coin_budget_preview + + if is_coin_margin_mode(): + coin_budget = coin_budget_preview(cfg, ex) + except Exception: + coin_budget = None + if not expiries: + return jsonify( + { + "ok": False, + "msg": chain_err or "暂无到期日,请稍后点「刷新链」", + **chain, + "chain_max_dte_days": chain_max_dte, + "ask_liq_filter_enabled": ask_liq_filter, + "budget_buffer": budget_buffer, + "trade_budget": cfg["trade_budget"], + "options_margin_mode": chain.get("margin_mode") or margin_mode, + "coin_budget": coin_budget, + } + ) + return jsonify( + { + "ok": True, + **chain, + "chain_max_dte_days": chain_max_dte, + "ask_liq_filter_enabled": ask_liq_filter, + "budget_buffer": budget_buffer, + "trade_budget": cfg["trade_budget"], + "options_margin_mode": chain.get("margin_mode") or margin_mode, + "coin_budget": coin_budget, + } + ) + + @app.route("/api/options/quote") + @lr + def api_options_quote(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + inst_id = (request.args.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return jsonify(q) + ask = q.get("ask") + ct_mult = q.get("ct_mult") or 0.01 + min_sz = q.get("min_sz") or 1 + mode = (request.args.get("mode") or "sheets").strip() + sheet_count = None + try: + if request.args.get("sheets"): + sheet_count = int(request.args.get("sheets")) + except (TypeError, ValueError): + pass + if mode == "close_preview": + paid = _open_premium_paid(cfg, inst_id) + target = sheet_count if sheet_count is not None else 0 + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + + row_mode = margin_mode_from_inst_id(inst_id) + prem_ccy = premium_ccy_for_mode(row_mode, (inst_id.split("-")[0] if inst_id else "ETH")) + preview_row = { + **q, + "pos": target, + "premium_paid": paid, + "margin_mode": row_mode, + "premium_ccy": prem_ccy, + } + out = _attach_close_preview(cfg, ex, preview_row, sheets=target, premium_paid=paid) + out["options_margin_mode"] = row_mode + out["premium_ccy"] = prem_ccy + return jsonify(out) + mode, mode_note = _normalize_size_mode(mode) + + # 币本位:报价预览走 USDT 预算→估币→张数,禁止再查 USDC + try: + from lib.options.options_margin_mode_lib import ( + is_coin_margin_mode, + margin_mode_from_inst_id, + ) + from lib.options.options_coin_open_lib import coin_budget_preview + from lib.exchange.okx_options_lib import option_buy_liquidity_ok + + if is_coin_margin_mode(): + ask = q.get("ask") + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + try: + from lib.options.okx_trade_mode_lib import block_standalone_open_by_mode_msg + + mode_block = block_standalone_open_by_mode_msg() + except Exception as e: + return jsonify({"ok": False, "can_open": False, "msg": f"交易模式校验失败: {e}"}) + if mode_block: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": mode_block, + "options_margin_mode": "coin", + "sizing": {"ok": False, "msg": mode_block, "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}, + } + ) + if margin_mode_from_inst_id(inst_id) != "coin": + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": "当前为币本位模式,请选择 ETH-USD / BTC-USD 合约(非 USD_UM)", + "options_margin_mode": "coin", + "sizing": { + "ok": False, + "msg": "合约非币本位", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + } + ) + budget_info = coin_budget_preview(cfg, ex) + if not can_open: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "options_margin_mode": "coin", + "coin_budget": budget_info, + "sizing": { + "ok": False, + "msg": block_msg or "暂无卖一深度,无法买入", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + } + ) + if not budget_info.get("ok"): + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": budget_info.get("msg") or "交易账户 USDT 不足", + "options_margin_mode": "coin", + "coin_budget": budget_info, + "sizing": { + "ok": False, + "msg": budget_info.get("msg") or "交易账户 USDT 不足", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + } + ) + idx = _safe_float(q.get("index_px")) or _safe_float(q.get("idxPx")) + budget_usdt = float(budget_info["budget_usdt"]) + target_sheets = sheet_count if mode == "sheets" and sheet_count is not None else None + if mode == "eth" and request.args.get("eth"): + # 指定币量:按币量反推张数后再走统一规划 + try: + eth_want = float(request.args.get("eth")) + except (TypeError, ValueError): + eth_want = 0.0 + if eth_want > 0 and float(ct_mult) > 0: + import math + + target_sheets = max(int(min_sz), int(math.floor(eth_want / float(ct_mult) + 1e-12))) + from lib.options.options_margin_mode_lib import plan_coin_open_by_budget + + sizing = plan_coin_open_by_budget( + quote_per_unit=float(ask), + ct_mult=float(ct_mult), + min_sz=int(min_sz), + budget_usdt=budget_usdt, + index_px=float(idx or 0), + ask_sz=ask_sz, + target_sheets=target_sheets, + ) + if sizing.get("ok"): + sizing["premium_ccy"] = (inst_id.split("-")[0] if inst_id else "ETH").upper() + sizing["est_coin"] = sizing.get("buy_coin") + q = _attach_close_preview( + cfg, + ex, + q, + sheets=int(sizing.get("sheets") or 0), + premium_paid=_open_premium_paid(cfg, inst_id), + ) + return jsonify( + { + **q, + "can_open": bool(sizing.get("ok")), + "quote_per_unit": ask, + "premium_per_sheet": round(float(ask) * float(ct_mult), 8), + "sizing": sizing, + "mode": mode, + "mode_note": mode_note, + "options_margin_mode": "coin", + "coin_budget": budget_info, + "compound_full_enabled": _compound_full_enabled(), + } + ) + except Exception as e: + return jsonify({"ok": False, "msg": f"币本位报价失败: {e}"}) + + budget = cfg["trade_budget"] + budget_cap = cfg["trade_budget"] + available_usdc = None + if mode == "budget_full": + blocked = _budget_full_blocked_by_compound_msg() + if blocked: + return jsonify( + { + "ok": False, + "msg": blocked, + "compound_full_enabled": _compound_full_enabled(), + } + ) + budget, budget_err = _budget_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": _compound_full_enabled()}) + budget_cap = budget + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + + available_usdc = fetch_options_trading_usdc(ex) + elif mode == "compound_full": + if not _compound_full_enabled(): + return jsonify( + { + "ok": False, + "msg": "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)", + "compound_full_enabled": False, + } + ) + budget, budget_err = _compound_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": True}) + budget_cap = budget + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + + available_usdc = fetch_options_trading_usdc(ex) + elif mode in ("sheets", "eth_amount") and _compound_full_enabled(): + budget_cap = None + eth_amount = None + try: + if request.args.get("eth_amount"): + eth_amount = float(request.args.get("eth_amount")) + except (TypeError, ValueError): + pass + ask = q.get("ask") + ask_sz = q.get("ask_sz") + try: + from lib.options.okx_trade_mode_lib import block_standalone_open_by_mode_msg + + mode_block = block_standalone_open_by_mode_msg() + if mode_block: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": mode_block, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": mode_block, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + } + ) + except Exception as e: + return jsonify( + { + "ok": False, + "can_open": False, + "msg": f"交易模式校验失败: {e}", + } + ) + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + # 合约可报价,但不可开仓:返回参考标记价供展示 + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "quote_per_unit": None, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": block_msg or "暂无卖一深度,无法买入", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + } + ) + from lib.options.options_position_limit_lib import ( + compound_full_single_position_block_msg, + option_position_limit_block_msg, + ) + + if mode == "compound_full": + compound_block = compound_full_single_position_block_msg( + ex, fetch_positions=cfg.get("fetch_option_positions") + ) + if compound_block: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": compound_block, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": compound_block, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": None, + "compound_full_usdc": budget, + } + ) + + pos_limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id=inst_id, + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": pos_limit_msg, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": pos_limit_msg, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + } + ) + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=float(ct_mult), + min_sz=int(min_sz), + budget_usdc=budget if _is_budget_mode(mode) else None, + budget_buffer=cfg["budget_buffer"], + eth_amount=eth_amount if mode == "eth_amount" else None, + sheets=sheet_count if mode == "sheets" else None, + budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) + if mode in ("budget_full", "compound_full", "sheets", "eth_amount") + else None, + ) + if sizing.get("ok"): + capped, cap_msg = cap_option_buy_sheets_to_ask_depth( + int(sizing.get("sheets") or 0), + ask_sz, + min_sz=int(min_sz), + ) + if capped is None: + sizing = { + "ok": False, + "msg": cap_msg, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + } + elif capped < int(sizing.get("sheets") or 0): + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=float(ct_mult), + min_sz=int(min_sz), + sheets=capped, + budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) + if mode in ("budget_full", "compound_full", "sheets", "eth_amount") + else None, + ) + if sizing.get("ok"): + sizing["ask_depth_capped"] = True + sizing["ask_sz"] = ask_sz + sizing["msg"] = f"已按卖一深度限制为 {capped} 张" + q = _attach_close_preview( + cfg, + ex, + q, + sheets=int(sizing.get("sheets") or sheet_count or 0), + premium_paid=_open_premium_paid(cfg, inst_id), + ) + return jsonify( + { + **q, + "can_open": True, + "quote_per_unit": ask, + "premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)), + "sizing": sizing, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + "mode": mode, + "mode_note": mode_note, + "compound_full_enabled": _compound_full_enabled(), + } + ) + + @app.route("/api/options/open", methods=["POST"]) + @lr + def api_options_open(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + try: + from lib.options.okx_trade_mode_lib import block_standalone_open_by_mode_msg + + mode_block = block_standalone_open_by_mode_msg() + if mode_block: + return jsonify({"ok": False, "msg": mode_block, "can_open": False}) + except Exception as e: + return jsonify({"ok": False, "msg": f"交易模式校验失败: {e}", "can_open": False}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + mode = (data.get("mode") or "sheets").strip() + mode, mode_note = _normalize_size_mode(mode) + signal_note = (data.get("signal_note") or "").strip() + if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full": + # 前端残留全仓复利选中时,已自动改指定张数;继续开仓 + pass + target_index = None + raw_target = data.get("target_index") + if raw_target is not None and str(raw_target).strip() != "": + try: + target_index = float(raw_target) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) + profit_exit_enabled = bool(data.get("profit_exit_enabled")) + profit_exit_mult = 1.0 + if profit_exit_enabled: + from lib.options.options_profit_exit_lib import normalize_profit_exit_mult + + profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0) + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + from lib.options.options_coin_open_lib import open_coin_option_buy_full + + if is_coin_margin_mode(): + want_sheets = None + if mode == "sheets": + try: + want_sheets = int(data.get("sheets") or 0) or None + except (TypeError, ValueError): + want_sheets = None + elif mode == "eth": + try: + eth_want = float(data.get("eth") or 0) + except (TypeError, ValueError): + eth_want = 0.0 + if eth_want > 0: + q0 = cfg["quote_option_contract"](ex, inst_id) + ct0 = float((q0 or {}).get("ct_mult") or 0.01) + min0 = int((q0 or {}).get("min_sz") or 1) + if ct0 > 0: + import math + + want_sheets = max(min0, int(math.floor(eth_want / ct0 + 1e-12))) + result = open_coin_option_buy_full( + cfg, + ex, + inst_id=inst_id, + signal_note=signal_note, + target_index=target_index, + profit_exit_enabled=profit_exit_enabled, + profit_exit_mult=profit_exit_mult, + target_sheets=want_sheets, + ) + if result.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + _mark_balances_stale(cfg) + return jsonify(result) + except Exception as e: + return jsonify({"ok": False, "msg": f"币本位开仓失败: {e}"}) + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return jsonify(q) + ask = q.get("ask") + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + return jsonify( + { + "ok": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "can_open": False, + "mark": q.get("mark"), + "ref_ask": q.get("ref_ask"), + } + ) + from lib.options.options_position_limit_lib import ( + compound_full_single_position_block_msg, + option_position_limit_block_msg, + ) + + if mode == "compound_full": + compound_block = compound_full_single_position_block_msg( + ex, fetch_positions=cfg.get("fetch_option_positions") + ) + if compound_block: + return jsonify({"ok": False, "msg": compound_block, "can_open": False}) + + pos_limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id=inst_id, + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return jsonify({"ok": False, "msg": pos_limit_msg, "can_open": False}) + ct_mult = float(q.get("ct_mult") or 0.01) + min_sz = int(q.get("min_sz") or 1) + eth_amount = None + sheet_count = None + if mode == "eth_amount": + try: + eth_amount = float(data.get("eth_amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "ETH 数量无效"}) + elif mode == "sheets": + try: + sheet_count = int(data.get("sheets")) + except (TypeError, ValueError): + sheet_count = None + if sheet_count is None or int(sheet_count) < 1: + # 全仓复利关闭后前端可能仍带着旧 mode 过来,归一后缺张数则默认 1 + if (data.get("mode") or "").strip() == "compound_full": + sheet_count = 1 + else: + return jsonify({"ok": False, "msg": "张数无效"}) + budget = cfg["trade_budget"] + budget_cap = cfg["trade_budget"] + if mode == "budget_full": + blocked = _budget_full_blocked_by_compound_msg() + if blocked: + return jsonify({"ok": False, "msg": blocked, "compound_full_enabled": _compound_full_enabled()}) + budget, budget_err = _budget_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err}) + budget_cap = budget + elif mode == "compound_full": + if not _compound_full_enabled(): + return jsonify( + { + "ok": False, + "msg": "全仓复利未开启,请改用指定张数或先开启全仓复利", + "compound_full_enabled": False, + } + ) + budget, budget_err = _compound_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err}) + budget_cap = budget + elif mode in ("sheets", "eth_amount") and _compound_full_enabled(): + budget_cap = None + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=ct_mult, + min_sz=min_sz, + budget_usdc=budget if _is_budget_mode(mode) else None, + budget_buffer=cfg["budget_buffer"], + eth_amount=eth_amount, + sheets=sheet_count, + budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) + if mode in ("budget_full", "compound_full", "sheets", "eth_amount") + else None, + ) + if not sizing.get("ok"): + return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) + sheets = int(sizing["sheets"]) + capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz) + if capped is None: + return jsonify({"ok": False, "msg": cap_msg or "卖一深度不足,无法买入"}) + if capped < sheets: + return jsonify( + { + "ok": False, + "msg": f"卖一深度仅 {int(capped)} 张,不足请求 {int(sheets)} 张,拒绝缩量成交", + "requested_sheets": int(sheets), + "ask_sz": ask_sz, + } + ) + tick_sz = q.get("tick_sz") + order = cfg["place_option_limit_order"]( + ex, + inst_id=inst_id, + side="buy", + sheets=sheets, + price=float(ask), + td_mode=td_mode_for_option_buy(cfg["td_mode"]), + tick_sz=tick_sz, + ord_type="ioc", + ) + if not order.get("ok"): + return jsonify(order) + ord_id = str((order.get("data") or {}).get("ordId") or "").strip() + if not ord_id: + return jsonify({"ok": False, "msg": "下单成功但未返回订单号", "order": order}) + from lib.exchange.okx_options_lib import wait_option_order_full_fill + + try: + fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12")) + except (TypeError, ValueError): + fill_timeout = 12.0 + fill = wait_option_order_full_fill( + ex, + inst_id=inst_id, + ord_id=ord_id, + need_sheets=int(sheets), + timeout_sec=fill_timeout, + cancel_on_timeout=True, + ) + if not fill.get("ok"): + filled_n = int(fill.get("filled_sheets") or 0) + orphan_close = None + if filled_n > 0: + try: + from lib.options.options_close_exec_lib import close_option_by_bid1 + + orphan_close = close_option_by_bid1( + cfg, ex, inst_id, sheets=filled_n, require_recycle_gate=False + ) + except Exception as e: + orphan_close = {"ok": False, "msg": str(e)} + return jsonify( + { + "ok": False, + "msg": fill.get("msg") or "未完全成交,开仓失败", + "filled_sheets": filled_n, + "orphan_close": orphan_close, + "fill": fill, + "order": order, + } + ) + fill_px = float(fill.get("avg_px") or ask) + filled_n = int(fill.get("filled_sheets") or sheets) + sheets = filled_n + sizing = dict(sizing) + sizing["sheets"] = sheets + sizing["eth_amount"] = round(sheets * ct_mult, 8) + sizing["total_premium"] = round(fill_px * sheets * ct_mult, 4) + conn = cfg["get_db"]() + trade_id = None + target_mon = None + open_underlying = "" + open_opt_type = None + try: + init_options_tables(conn) + from lib.options.options_profit_exit_lib import ensure_profit_exit_columns + + ensure_profit_exit_columns(conn) + meta = q.get("meta") or {} + u = str(meta.get("uly") or inst_id).split("-")[0] + opt_type = meta.get("optType") + open_underlying = u + open_opt_type = opt_type + from lib.options.options_time_lib import options_app_now_str + + cur = conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status, signal_note, exchange_ord_id, + profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?) + """, + ( + inst_id, + u, + opt_type, + q.get("strike"), + str(q.get("exp_time") or ""), + sheets, + sizing["eth_amount"], + fill_px, + sizing["total_premium"], + signal_note, + ord_id, + 1 if profit_exit_enabled else 0, + profit_exit_mult if profit_exit_enabled else 1.0, + "active" if profit_exit_enabled else "idle", + options_app_now_str(), + ), + ) + trade_id = int(cur.lastrowid) + if target_index is not None: + from lib.options.options_target_lib import upsert_target_monitor + + target_mon = upsert_target_monitor( + conn, + inst_id=inst_id, + target_index=target_index, + underlying=u, + opt_type=str(opt_type) if opt_type else None, + trade_id=trade_id, + sheets=sheets, + ) + if profit_exit_enabled: + pass # 列已由 init_options_tables / ensure 迁移 + conn.commit() + finally: + conn.close() + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + from lib.options.options_notify_lib import notify_options_open + + invalidate_option_positions_cache() + _sync_options_trades(cfg, force=True) + try: + conn_n = cfg["get_db"]() + try: + notify_options_open( + cfg, + conn_n, + trade_id=trade_id, + inst_id=inst_id, + underlying=open_underlying, + opt_type=open_opt_type, + sheets=sheets, + premium_paid=sizing.get("total_premium"), + open_quote=fill_px, + target_index=target_index, + signal_note=signal_note, + premium_ccy=sizing.get("premium_ccy") or "USDC", + ) + finally: + conn_n.close() + except Exception: + pass + return jsonify( + { + "ok": True, + "order": order, + "sizing": sizing, + "trade_id": trade_id, + "target_monitor": target_mon, + } + ) + + @app.route("/api/options/orders/pending") + @lr + def api_options_orders_pending(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + inst_id = (request.args.get("inst_id") or "").strip() or None + try: + orders = cfg["fetch_option_pending_orders"](ex, inst_id) + except Exception as e: + return jsonify({"ok": False, "msg": f"获取委托失败: {e}"}) + from lib.options.options_pending_lib import enrich_pending_orders + + ttl = float(cfg.get("pending_ttl_seconds") or 600.0) + enriched = enrich_pending_orders(orders, ttl_seconds=ttl) + return jsonify( + { + "ok": True, + "orders": enriched, + "count": len(enriched), + "pending_ttl_seconds": ttl, + } + ) + + @app.route("/api/options/orders/cancel", methods=["POST"]) + @lr + def api_options_orders_cancel(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + ord_id = (data.get("ord_id") or "").strip() + if not inst_id or not ord_id: + return jsonify({"ok": False, "msg": "缺少 inst_id 或 ord_id"}) + out = cfg["cancel_option_order"](ex, inst_id=inst_id, ord_id=ord_id) + if out.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + # 本地未成交开仓记录标记取消,避免假 open + try: + conn = cfg["get_db"]() + try: + from lib.options.options_time_lib import options_app_now_str + + init_options_tables(conn) + conn.execute( + """ + UPDATE options_trades + SET status = 'cancelled', + signal_note = CASE + WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销' + ELSE signal_note + END, + closed_at = ? + WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open' + """, + (options_app_now_str(), inst_id, ord_id), + ) + conn.commit() + finally: + conn.close() + except Exception: + pass + _sync_options_trades(cfg, force=True) + return jsonify(out), (200 if out.get("ok") else 400) + + @app.route("/api/options/positions") + @lr + def api_options_positions(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + _sync_options_trades(cfg, raw_positions=raw) + meta_cache: dict[str, dict[str, Any] | None] = {} + conn = cfg["get_db"]() + try: + from lib.options.options_target_lib import targets_by_inst + from lib.options.options_profit_exit_lib import profit_exit_by_inst + rows = [] + for p in raw: + inst = str(p.get("instId") or "").strip() + premium_override = sum_open_premium_paid(conn, inst) if inst else None + row = _enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) + mon = tgt_map.get(inst) + if mon: + row["target_index"] = mon.get("target_index") + row["target_monitor_id"] = mon.get("id") + row["target_monitor"] = mon + pe = profit_exit_map.get(inst) + if pe: + row["profit_exit_enabled"] = pe.get("profit_exit_enabled") + row["profit_exit_mult"] = pe.get("profit_exit_mult") + row["profit_exit_state"] = pe.get("profit_exit_state") + row["profit_exit_required_recycle"] = pe.get("required_recycle") + try: + from lib.instance.instance_dashboard_lib import _resolve_options_source + + source_key, source_label, source_plan_id = _resolve_options_source(conn, inst) + row["source"] = source_key + row["source_label"] = source_label + row["source_plan_id"] = source_plan_id + except Exception: + row.setdefault("source", "option") + row.setdefault("source_label", "纯期权") + row.setdefault("source_plan_id", None) + rows.append(row) + finally: + conn.close() + spot_sell_hint = None + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + + if is_coin_margin_mode(): + from lib.options.options_spot_bridge_lib import list_open_bridges + + conn_b = cfg["get_db"]() + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn_b) + open_bridges = list_open_bridges(conn_b) + bal = cfg["fetch_options_balances"](ex, scope="main") + bridge = open_bridges[0] if open_bridges else None + spot_sell_hint = { + "bridge_status": str(bridge.get("status") or "") if bridge else None, + "bridge_underlying": str(bridge.get("underlying") or "").upper() if bridge else None, + "trading_eth": bal.get("trading_eth_avail") or bal.get("trading_eth"), + "trading_btc": bal.get("trading_btc_avail") or bal.get("trading_btc"), + "default_underly": (cfg.get("default_underly") or "ETH").strip().upper(), + } + finally: + conn_b.close() + except Exception: + spot_sell_hint = None + return jsonify({"ok": True, "positions": rows, "spot_sell": spot_sell_hint}) + + @app.route("/api/options/targets") + @lr + def api_options_targets(): + conn = cfg["get_db"]() + try: + from lib.options.options_target_lib import list_active_targets, list_closing_targets + + return jsonify({"ok": True, "targets": list_active_targets(conn) + list_closing_targets(conn)}) + finally: + conn.close() + + @app.route("/api/options/target", methods=["POST"]) + @lr + def api_options_target_set(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + target_index = float(data.get("target_index")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + pos = _find_position(raw, inst_id) + if not pos: + return jsonify({"ok": False, "msg": "未找到持仓"}) + from lib.options.options_target_lib import upsert_target_monitor + + fmt = cfg["format_position_row"](pos) + conn = cfg["get_db"]() + try: + trade = conn.execute( + """ + SELECT id, opt_type, underlying FROM options_trades + WHERE inst_id = ? AND status = 'open' + ORDER BY id DESC LIMIT 1 + """, + (inst_id,), + ).fetchone() + trade_id = int(trade["id"]) if trade else None + sheets_sum = sum_open_sheets(conn, inst_id) + sheets = sheets_sum if sheets_sum is not None else int(fmt.get("pos") or 0) + opt_type = (trade["opt_type"] if trade else None) or fmt.get("opt_type") + underlying = (trade["underlying"] if trade else None) or fmt.get("underlying") + out = upsert_target_monitor( + conn, + inst_id=inst_id, + target_index=target_index, + underlying=str(underlying) if underlying else None, + opt_type=str(opt_type) if opt_type else None, + trade_id=trade_id, + sheets=sheets, + ) + conn.commit() + return jsonify(out) + finally: + conn.close() + + @app.route("/api/options/target/cancel", methods=["POST"]) + @lr + def api_options_target_cancel(): + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() or None + monitor_id = data.get("id") + try: + mid = int(monitor_id) if monitor_id is not None and str(monitor_id).strip() != "" else None + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "监控 id 无效"}) + if not inst_id and mid is None: + return jsonify({"ok": False, "msg": "缺少 inst_id 或 id"}) + from lib.options.options_target_lib import cancel_target_monitor + + conn = cfg["get_db"]() + try: + n = cancel_target_monitor(conn, inst_id=inst_id, monitor_id=mid) + conn.commit() + return jsonify({"ok": True, "cancelled": n}) + finally: + conn.close() + + @app.route("/api/options/profit-exit", methods=["POST"]) + @lr + def api_options_profit_exit_set(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + enabled_raw = data.get("enabled") + if enabled_raw is None: + enabled_raw = data.get("profit_exit_enabled") + enabled = bool(enabled_raw) and str(enabled_raw).strip().lower() not in ( + "0", + "false", + "off", + "no", + ) + from lib.options.options_profit_exit_lib import normalize_profit_exit_mult, set_profit_exit + + mult = normalize_profit_exit_mult(data.get("mult", data.get("profit_exit_mult")), default=1.0) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + if not _find_position(raw, inst_id): + return jsonify({"ok": False, "msg": "未找到持仓"}) + conn = cfg["get_db"]() + try: + out = set_profit_exit(conn, inst_id=inst_id, enabled=enabled, mult=mult) + if out.get("ok"): + conn.commit() + return jsonify(out) + finally: + conn.close() + + @app.route("/api/options/close", methods=["POST"]) + @lr + def api_options_close(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + if data.get("market"): + return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价"}) + sheets = data.get("sheets") + try: + sheets_i = int(sheets) if sheets is not None and str(sheets).strip() != "" else None + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "张数无效"}) + from lib.options.options_close_exec_lib import close_option_by_bid1 + + # 手动买一平仓:只验有效流动性;2×门控仅用于自动/目标位平仓 + result = close_option_by_bid1( + cfg, + ex, + inst_id, + sheets=sheets_i, + require_recycle_gate=False, + ) + if result.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + _sync_options_trades(cfg, force=True) + try: + from lib.options.options_target_lib import cancel_target_monitor + from lib.options.options_notify_lib import notify_options_close + + conn2 = cfg["get_db"]() + try: + if result.get("fully_closed"): + cancel_target_monitor(conn2, inst_id=inst_id) + conn2.commit() + reason = "手动平仓" if result.get("fully_closed") else "手动平仓(部分)" + notify_options_close( + cfg, + conn2, + inst_id=inst_id, + reason=reason, + sheets=result.get("submitted_sheets"), + premium_received=result.get("premium_received"), + close_quote=result.get("locked_bid_px") or result.get("bid"), + ) + finally: + conn2.close() + except Exception: + pass + if result.get("fully_closed"): + try: + from lib.options.options_coin_open_lib import maybe_sell_spot_after_close + + spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=result) + if spot_sell is not None: + result = dict(result) + result["spot_sell"] = spot_sell + if spot_sell.get("bridge_status") == "pending_sell_spot": + result["msg"] = ( + str(result.get("msg") or "平仓成功") + + ";但卖回 USDT 失败,请点「重试卖回」" + ) + except Exception as e: + result = dict(result) + result["spot_sell"] = {"ok": False, "msg": str(e)} + _mark_balances_stale(cfg) + return jsonify(result) + + @app.route("/api/options/spot-bridge/retry-sell", methods=["POST"]) + @lr + def api_options_spot_bridge_retry_sell(): + """币本位:重试把残留标的币市价卖回 USDT.""" + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + underlying = (data.get("underlying") or cfg.get("default_underly") or "ETH").strip().upper() + inst_id = (data.get("inst_id") or "").strip() or None + conn = cfg["get_db"]() + try: + init_options_tables(conn) + from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat + + out = sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id) + if out.get("ok"): + _mark_balances_stale(cfg) + return jsonify(out) + finally: + conn.close() + + @app.route("/api/options/convert/quote", methods=["POST"]) + @lr + def api_options_convert_quote(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + return jsonify(cfg["estimate_usdt_to_usdc"](ex, amount)) + + @app.route("/api/options/convert/execute", methods=["POST"]) + @lr + def api_options_convert_execute(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + quote_id = (data.get("quote_id") or "").strip() + result = cfg["execute_convert"](ex, quote_id) + if result.get("ok"): + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_convert_log (from_ccy, to_ccy, rfq_sz, received_sz, quote_id, status, message) + VALUES ('USDT', 'USDC', ?, ?, ?, 'ok', '') + """, + ( + data.get("rfq_sz"), + (result.get("data") or {}).get("baseSz"), + quote_id, + ), + ) + conn.commit() + finally: + conn.close() + return jsonify(result) + + @app.route("/api/options/transfer", methods=["POST"]) + @lr + def api_options_transfer(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + ccy = (data.get("ccy") or "USDC").upper() + from_acct = (data.get("from") or "funding").strip() + to_acct = (data.get("to") or "trading").strip() + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + try: + import lib.exchange.okx_options_lib as _opt_lib + + xfer = getattr(_opt_lib, "transfer_ccy", None) + except Exception: + xfer = None + if not callable(xfer): + xfer = cfg.get("transfer_ccy") + result = xfer(ex, ccy, amount, from_acct, to_acct) if callable(xfer) else { + "ok": False, + "msg": "划转不可用", + } + if result.get("ok"): + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message) + VALUES (?, ?, ?, ?, 'ok', '') + """, + (ccy, amount, from_acct, to_acct), + ) + conn.commit() + finally: + conn.close() + _mark_balances_stale(cfg) + return jsonify(result) + + @app.route("/api/options/spot/swap", methods=["POST"]) + @lr + def api_options_spot_swap(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + direction = (data.get("direction") or "usdt_to_usdc").strip() + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + result = cfg["spot_market_swap_usdt_usdc"](ex, direction=direction, amount=amount) + if result.get("ok"): + _mark_balances_stale(cfg) + return jsonify(result) + + @app.route("/api/options/history") + @lr + def api_options_history(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + from lib.options.options_history_lib import load_options_history + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + history = load_options_history(ex, cfg) + live_ids = {str(x.get("inst_id") or "") for x in history if x.get("status") == "open"} + return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)}) + + @app.route("/api/options/stats") + @lr + def api_options_stats(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + from lib.options.options_history_lib import load_options_history + from lib.options.options_margin_mode_lib import is_coin_margin_mode + from lib.options.options_positions_lib import sum_options_net_pnl_usdc + from lib.options.options_stats_lib import ( + compute_options_stats_from_history, + history_pnl_to_usdt, + ) + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + history = load_options_history(ex, cfg) + # 币本位已平盈亏按指数折 U,与持仓浮盈/合计口径一致 + stats = compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) + open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live) + net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0 + total_pnl = None + if open_float is not None: + total_pnl = round(net_realized + float(open_float), 4) + elif stats.get("total_closed"): + total_pnl = round(net_realized, 4) + # 币本位统计统一标 U;USDC 模式仍标 USDC + pnl_unit = "U" if is_coin_margin_mode() else "USDC" + return jsonify( + { + "ok": True, + **stats, + "open_float_pnl": open_float, + "total_pnl": total_pnl, + "pnl_unit": pnl_unit, + } + ) + + @app.route("/api/options/history/", methods=["DELETE"]) + @lr + def api_options_history_delete(history_key: str): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + key = (history_key or "").strip() + if not key: + return jsonify({"ok": False, "msg": "缺少 history_key"}) + data = request.get_json(silent=True) or {} + inst_id = str(data.get("inst_id") or request.args.get("inst_id") or "").strip() or None + closed_at = str(data.get("closed_at") or request.args.get("closed_at") or "").strip() or None + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + "INSERT OR IGNORE INTO options_history_hidden (history_key) VALUES (?)", + (key,), + ) + # 同步隐藏期权复盘,避免本地已平记录刷新后又出现 + try: + from lib.options.options_review_lib import hide_review_keys + + hide_review_keys( + conn, + history_key=key, + inst_id=inst_id, + closed_at=closed_at, + ) + if inst_id: + # 去掉已导入的复盘快照(按合约+平仓时间) + if closed_at: + rows = conn.execute( + """ + SELECT id, history_key FROM options_review_trades + WHERE inst_id = ? + AND substr(COALESCE(closed_at,''),1,16) = substr(?,1,16) + """, + (inst_id, closed_at), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, history_key FROM options_review_trades + WHERE inst_id = ? + """, + (inst_id,), + ).fetchall() + for r in rows: + conn.execute( + "DELETE FROM options_review_entries WHERE trade_id=?", + (int(r["id"]),), + ) + conn.execute( + "DELETE FROM options_review_trades WHERE id=?", + (int(r["id"]),), + ) + conn.execute( + "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", + (str(r["history_key"]), inst_id, (closed_at or "")[:19] or None), + ) + fps = [] + if closed_at: + fps.append(f"inst_close:{inst_id}:{closed_at[:16]}") + fps.append(f"inst:{inst_id}") + for fp in fps: + conn.execute( + "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", + (fp, inst_id, (closed_at or "")[:19] or None), + ) + except Exception: + pass + conn.commit() + finally: + conn.close() + return jsonify({"ok": True}) + + +def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: + if app.extensions.get("options_monitor_started"): + return + app.extensions["options_monitor_started"] = True + + def _bid(inst_id: str) -> float | None: + ex = cfg.get("exchange_options") + if ex is None: + return None + try: + q = cfg["quote_option_contract"](ex, inst_id) + return q.get("bid") + except Exception: + return None + + def _positions(): + ex = cfg.get("exchange_options") + if ex is None: + return [] + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return [] + return [cfg["format_position_row"](p) for p in raw] + + def _sync(conn): + from lib.exchange.okx_options_lib import fetch_option_position_history + from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades + + ex = cfg.get("exchange_options") + if ex is None: + return 0 + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return 0 + live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} + reconcile_live_open_trades(conn, live_inst_ids=live_ids) + return sync_open_options_trades( + conn, + live_inst_ids=live_ids, + fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id), + notify_cfg=cfg, + ) + + def _target_close(inst_id: str) -> dict[str, Any]: + from lib.options.options_target_lib import close_option_by_bid_depth + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + result = close_option_by_bid_depth(cfg, ex, inst_id) + if result.get("ok"): + try: + _sync_options_trades(cfg, force=True) + except Exception: + pass + try: + _mark_balances_stale(cfg) + except Exception: + pass + return result + + def _profit_exit_close(inst_id: str) -> dict[str, Any]: + from lib.options.options_profit_exit_lib import close_option_by_bid_profit_exit + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + result = close_option_by_bid_profit_exit(cfg, ex, inst_id) + if result.get("ok"): + try: + _sync_options_trades(cfg, force=True) + except Exception: + pass + try: + _mark_balances_stale(cfg) + except Exception: + pass + return result + + def _stale_pending() -> dict[str, Any]: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + from lib.options.options_pending_lib import cancel_stale_close_pending_orders + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + ttl = float(cfg.get("pending_ttl_seconds") or 600.0) + out = cancel_stale_close_pending_orders( + fetch_pending=lambda _ex: cfg["fetch_option_pending_orders"](_ex), + cancel_order=lambda _ex, inst_id, ord_id: cfg["cancel_option_order"]( + _ex, inst_id=inst_id, ord_id=ord_id + ), + ttl_seconds=ttl, + ex=ex, + ) + if out.get("cancelled"): + try: + invalidate_option_positions_cache() + except Exception: + pass + try: + send = cfg.get("send_wechat") + if callable(send): + parts = [ + "【OKX期权·挂单超时撤销】", + f"账户:{cfg.get('account_label') or 'OKX期权'}", + f"超时:{ttl:g}s", + f"撤销:{out.get('cancelled')} 笔", + ] + for o in out.get("orders") or []: + parts.append(f"- {o.get('inst_id')} #{o.get('ord_id')}") + send("\n".join(parts)) + except Exception: + pass + return out + + t = threading.Thread( + target=options_monitor_loop, + kwargs={ + "enabled": True, + "poll_seconds": cfg["poll_seconds"], + "get_db": cfg["get_db"], + "fetch_positions": _positions, + "ticker_bid_fn": _bid, + "send_wechat": cfg["send_wechat"], + "account_label": cfg["account_label"], + "profit_ratio": cfg["profit_ratio"], + "sync_trades_fn": _sync, + "target_close_fn": _target_close, + "profit_exit_close_fn": _profit_exit_close, + "profit_exit_cfg": cfg, + "stale_pending_fn": _stale_pending, + }, + daemon=True, + name="options-monitor", + ) + t.start() diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py index 335ed42..9072f14 100644 --- a/lib/options/options_review_lib.py +++ b/lib/options/options_review_lib.py @@ -586,32 +586,11 @@ def _mark_option_legs_excluded( def sync_hedge_plans_closed(conn: sqlite3.Connection) -> dict[str, Any]: - """从本地 hedge_plans 导入已结束计划(计划级).""" + """对冲模块已移除:不再导入对冲计划到复盘.""" init_options_review_tables(conn) - from lib.hedge_plan.hedge_plan_db import get_plan_legs, init_hedge_plan_tables, list_plans + return {"ok": True, "fetched": 0, "inserted": 0, "updated": 0, "skipped": 0, "removed": True} + - init_hedge_plan_tables(conn) - plans = list_plans(conn, status="closed", limit=500) - inserted = updated = skipped = 0 - for plan in plans: - legs = get_plan_legs(conn, int(plan["id"])) - action = upsert_hedge_plan_row(conn, plan, legs) - if action == "inserted": - inserted += 1 - elif action == "updated": - updated += 1 - else: - skipped += 1 - last_id = max((int(p["id"]) for p in plans), default=0) - set_sync_state(conn, "hedge_last_sync_at", _now_str()) - set_sync_state(conn, "hedge_last_plan_id", str(last_id)) - return { - "ok": True, - "fetched": len(plans), - "inserted": inserted, - "updated": updated, - "skipped": skipped, - } def sync_all_review_sources( @@ -653,16 +632,12 @@ def ensure_local_review_synced( if backfill_exchange_pnl and ex is not None: try: from lib.exchange.okx_options_lib import fetch_all_option_positions_history - from lib.hedge_plan.hedge_plan_settle_lib import ( - backfill_hedge_option_legs_realized_pnl, - ) from lib.options.options_monitor_lib import ( backfill_closed_options_realized_pnl_from_history, ) hist = fetch_all_option_positions_history(ex, limit=200) backfill_closed_options_realized_pnl_from_history(conn, hist) - backfill_hedge_option_legs_realized_pnl(conn, hist) except Exception: pass out = sync_all_review_sources(conn, ex=ex, from_exchange=False) diff --git a/lib/options/options_review_register.py b/lib/options/options_review_register.py index dd65e31..0c64e59 100644 --- a/lib/options/options_review_register.py +++ b/lib/options/options_review_register.py @@ -1,452 +1,452 @@ -"""OKX 期权复盘模块:Flask 路由注册(含对冲计划级复盘).""" -from __future__ import annotations - -import os -from typing import Any - -from flask import Flask, jsonify, request, send_file -from jinja2 import ChoiceLoader, FileSystemLoader -from werkzeug.utils import secure_filename - -from lib.options.options_review_db import SOURCE_TYPES, init_options_review_tables -from lib.options.options_review_images_lib import ( - OPTIONS_REVIEW_UPLOAD_TFS, - normalize_options_review_draft_id, - options_review_image_paths, - options_review_upload_dir, - save_options_review_slot_file, -) -from lib.options.options_review_lib import ( - SOURCE_LABELS, - compute_review_stats, - count_review_trades, - delete_review_entry, - ensure_local_review_synced, - get_review_trade, - hide_review_trade, - list_review_trades, - save_review_entry, -) - - -def _review_source_for_mode(requested: str | None) -> str | None: - """按当前交易模式钳制复盘 source_type;不允许跨模式窥探.""" - try: - from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode - - mode = get_okx_trade_mode() - except Exception: - mode = "options" - allowed = { - "options": "option_spot", - "perp_options": "perp_options", - "options_options": "options_options", - }.get(mode, "option_spot") - req = (requested or "").strip() - if not req: - return allowed - if req == allowed: - return allowed - # 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制 - return allowed - - -def attach_options_review_templates(app: Flask, repo_root: str) -> None: - tpl_dir = os.path.join(repo_root, "lib", "options", "templates") - if not os.path.isdir(tpl_dir): - return - existing = app.jinja_loader - loaders = [FileSystemLoader(tpl_dir)] - if existing is not None: - if isinstance(existing, ChoiceLoader): - loaders = list(existing.loaders) + loaders - else: - loaders.insert(0, existing) - app.jinja_loader = ChoiceLoader(loaders) - - -def install_options_review(app: Flask, repo_root: str, app_module: Any) -> None: - attach_options_review_templates(app, repo_root) - cfg = { - "get_db": app_module.get_db, - "login_required": app_module.login_required, - "exchange_options": getattr(app_module, "exchange_options", None), - "render_main_page": app_module.render_main_page, - "upload_folder": getattr(app_module, "UPLOAD_FOLDER", None) - or os.path.join(getattr(app_module, "BASE_DIR", repo_root), "static", "images"), - "options_enabled": bool(getattr(app_module, "OKX_OPTIONS_ENABLED", False)), - "app_module": app_module, - } - app.extensions["options_review_cfg"] = cfg - register_options_review_routes(app, cfg, repo_root) - - -def _require_ex(cfg: dict[str, Any]): - from lib.exchange.okx_options_lib import options_api_ready - - if not cfg.get("options_enabled"): - return None, "期权模块未启用" - ex = cfg.get("exchange_options") - ok, reason = options_api_ready(ex) - if not ok: - return None, reason or "期权 API 未配置" - return ex, "" - - -def _truthy(raw: Any) -> bool: - return str(raw or "").strip().lower() in ("1", "true", "on", "yes") - - -def _prepare_review_entry_images( - conn: Any, - trade_id: int, - data: dict[str, Any], -) -> dict[str, Any]: - """客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图.""" - from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF - from lib.options.options_review_images_lib import parse_options_review_images_json - - images = data.get("images") if isinstance(data.get("images"), list) else [] - cleaned: list[dict[str, str]] = [] - for item in images: - if not isinstance(item, dict): - continue - tf = str(item.get("tf") or "").strip() - file = str(item.get("file") or "").strip() - if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF: - continue - cleaned.append({"tf": tf, "file": file}) - - want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned - if not want_chart: - row = conn.execute( - "SELECT images_json FROM options_review_entries WHERE trade_id=?", - (int(trade_id),), - ).fetchone() - if row: - for item in parse_options_review_images_json(row["images_json"]): - if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF: - cleaned.insert(0, dict(item)) - break - - out = dict(data) - out["images"] = cleaned - return out - - -def _maybe_schedule_options_review_chart( - cfg: dict[str, Any], - trade: dict[str, Any], - data: dict[str, Any], -) -> bool: - """纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场).""" - if str(trade.get("source_type") or "").strip() != "option_spot": - return False - images = data.get("images") if isinstance(data.get("images"), list) else [] - has_manual = any(str((x or {}).get("file") or "").strip() for x in images) - if has_manual or not _truthy(data.get("journal_exchange_chart")): - return False - - app_module = cfg.get("app_module") - if app_module is None: - return False - if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)): - return False - generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None) - if not callable(generate_fn): - return False - - from lib.instance.journal_chart_lib import ( - parse_journal_chart_anchor, - parse_journal_chart_limit, - parse_journal_chart_timeframes, - ) - from lib.options.options_review_chart_async_lib import ( - schedule_options_review_exchange_chart, - underlying_to_okx_perp_symbol, - ) - from lib.options.options_review_images_lib import options_review_upload_dir - from lib.options.options_review_lib import _now_str - - fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2] - journal_tfs = parse_journal_chart_timeframes( - data.get("journal_chart_tf1"), - data.get("journal_chart_tf2"), - fallback_tfs or None, - ) - journal_limit = parse_journal_chart_limit( - data.get("journal_chart_limit"), - int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100), - ) - chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor")) - - underlying = str(trade.get("underlying") or data.get("underlying") or "").strip() - exchange_symbol = underlying_to_okx_perp_symbol( - underlying, - normalize_symbol_input_fn=app_module.normalize_symbol_input, - normalize_okx_symbol_fn=app_module.normalize_okx_symbol, - ) - if not exchange_symbol: - return False - - to_ms = getattr(app_module, "_local_input_datetime_to_ms", None) - if not callable(to_ms): - return False - entry_ts = to_ms(trade.get("opened_at")) - exit_ts = to_ms(trade.get("closed_at")) - if entry_ts is None and exit_ts is None: - return False - - now_ms = None - app_now = getattr(app_module, "app_now", None) - if callable(app_now): - try: - now_ms = int(app_now().timestamp() * 1000) - except Exception: - now_ms = None - - inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip() - upload_folder = options_review_upload_dir(cfg["upload_folder"]) - schedule_options_review_exchange_chart( - trade_id=int(trade["id"]), - exchange_symbol=exchange_symbol, - title_prefix=f"{underlying or 'OPT'} {inst} review", - journal_tfs=journal_tfs, - journal_limit=journal_limit, - marker_payload={ - "entry_ts_ms": entry_ts, - "exit_ts_ms": exit_ts, - # 只按时间钉进/出场,不传价格与止损 - "chart_anchor": chart_anchor, - "now_ts_ms": now_ms, - }, - upload_folder=upload_folder, - generate_chart_fn=generate_fn, - get_db_fn=cfg["get_db"], - now_str_fn=_now_str, - ) - return True - - -def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None: - lr = cfg["login_required"] - - @app.route("/options/review") - @lr - def options_review_page(): - from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled - - redir = redirect_to_embed_shell_if_enabled("options_review") - if redir is not None: - return redir - return cfg["render_main_page"]("options_review") - - @app.route("/static/options_review.js") - @lr - def static_options_review_js(): - path = os.path.join(repo_root, "lib", "common", "static", "options_review.js") - if not os.path.isfile(path): - return ("not found", 404) - return send_file(path, mimetype="application/javascript; charset=utf-8") - - @app.route("/static/images/options_journal/") - def static_options_review_image(filename: str): - """截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401).""" - folder = options_review_upload_dir(cfg["upload_folder"]) - safe = os.path.basename(filename or "") - path = os.path.join(folder, safe) - if not os.path.isfile(path): - # 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件 - root = os.path.abspath(cfg["upload_folder"] or "") - alt = os.path.join(root, safe) - if os.path.isfile(alt): - path = alt - else: - return ("not found", 404) - return send_file(path) - - @app.route("/api/options/review/sync", methods=["POST"]) - @lr - def api_options_review_sync(): - """刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算.""" - conn = cfg["get_db"]() - try: - init_options_review_tables(conn) - ex, _err = _require_ex(cfg) - result = ensure_local_review_synced(conn, ex=ex if ex is not None else None) - conn.commit() - return jsonify(result) - finally: - conn.close() - - @app.route("/api/options/review/trades") - @lr - def api_options_review_trades(): - conn = cfg["get_db"]() - try: - # 翻页可跳过同步,仅刷新当前卡片列表 - do_sync = (request.args.get("sync") or "1").strip().lower() not in ( - "0", - "false", - "no", - ) - if do_sync: - ex, _err = _require_ex(cfg) - ensure_local_review_synced(conn, ex=ex if ex is not None else None) - conn.commit() - filt = dict( - source_type=_review_source_for_mode(request.args.get("source_type")), - underlying=(request.args.get("underlying") or "").strip() or None, - opt_type=(request.args.get("opt_type") or "").strip() or None, - strategy_tag=(request.args.get("strategy_tag") or "").strip() or None, - q=(request.args.get("q") or "").strip() or None, - reviewed=(request.args.get("reviewed") or "").strip() or None, - include_hedge_legs=(request.args.get("include_hedge_legs") or "") - .strip() - .lower() - in ("1", "true", "yes"), - closed_from=(request.args.get("closed_from") or "").strip() or None, - closed_to=(request.args.get("closed_to") or "").strip() or None, - ) - limit = min(500, max(1, int(request.args.get("limit") or 200))) - offset = max(0, int(request.args.get("offset") or 0)) - total = count_review_trades(conn, **filt) - items = list_review_trades(conn, **filt, limit=limit, offset=offset) - pages = max(1, (total + limit - 1) // limit) if total else 1 - page = (offset // limit) + 1 if limit else 1 - return jsonify( - { - "ok": True, - "trades": items, - "source_labels": SOURCE_LABELS, - "total": total, - "limit": limit, - "offset": offset, - "page": page, - "pages": pages, - } - ) - finally: - conn.close() - - @app.route("/api/options/review/trades/") - @lr - def api_options_review_trade_detail(trade_id: int): - conn = cfg["get_db"]() - try: - item = get_review_trade(conn, trade_id) - if not item: - return jsonify({"ok": False, "msg": "未找到"}), 404 - return jsonify({"ok": True, "trade": item}) - finally: - conn.close() - - @app.route("/api/options/review/entry", methods=["POST"]) - @lr - def api_options_review_entry_save(): - data = request.get_json(silent=True) or {} - try: - trade_id = int(data.get("trade_id")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "trade_id 无效"}), 400 - conn = cfg["get_db"]() - try: - data = _prepare_review_entry_images(conn, trade_id, data) - out = save_review_entry(conn, trade_id, data) - if out.get("ok"): - conn.commit() - chart_queued = _maybe_schedule_options_review_chart( - cfg, out.get("trade") or {}, data - ) - if chart_queued: - out = dict(out) - out["chart_queued"] = True - return jsonify(out), (200 if out.get("ok") else 400) - finally: - conn.close() - - @app.route("/api/options/review/trades/", methods=["DELETE"]) - @lr - def api_options_review_trade_hide(trade_id: int): - """从复盘列表删除并持久隐藏(刷新本地源也不会再导入).""" - conn = cfg["get_db"]() - try: - out = hide_review_trade(conn, trade_id) - if out.get("ok"): - entry = out.get("entry") or {} - folder = options_review_upload_dir(cfg["upload_folder"]) - for path in options_review_image_paths(entry, folder): - try: - os.remove(path) - except OSError: - pass - conn.commit() - return jsonify(out), (200 if out.get("ok") else 400) - finally: - conn.close() - - @app.route("/api/options/review/entry/", methods=["DELETE"]) - @lr - def api_options_review_entry_delete(trade_id: int): - conn = cfg["get_db"]() - try: - out = delete_review_entry(conn, trade_id) - if out.get("ok"): - entry = out.get("entry") or {} - folder = options_review_upload_dir(cfg["upload_folder"]) - for path in options_review_image_paths(entry, folder): - try: - os.remove(path) - except OSError: - pass - conn.commit() - return jsonify(out), (200 if out.get("ok") else 400) - finally: - conn.close() - - @app.route("/api/options/review/upload_slot", methods=["POST"]) - @lr - def api_options_review_upload_slot(): - draft_id = normalize_options_review_draft_id( - request.form.get("draft_id") if request.form else None - ) - tf = str((request.form.get("tf") if request.form else None) or "").strip() - if not draft_id: - return jsonify({"ok": False, "error": "invalid draft_id"}), 400 - if tf not in OPTIONS_REVIEW_UPLOAD_TFS: - return jsonify({"ok": False, "error": "invalid tf"}), 400 - f = request.files.get("file") if request.files else None - if not f or not getattr(f, "filename", None): - return jsonify({"ok": False, "error": "no file"}), 400 - folder = options_review_upload_dir(cfg["upload_folder"]) - item = save_options_review_slot_file( - f, draft_id, tf, folder, secure_filename_fn=secure_filename - ) - if not item: - return jsonify({"ok": False, "error": "save failed"}), 500 - return jsonify({"ok": True, "tf": tf, "file": item["file"]}) - - @app.route("/api/options/review/stats") - @lr - def api_options_review_stats(): - conn = cfg["get_db"]() - try: - ex, _err = _require_ex(cfg) - ensure_local_review_synced(conn, ex=ex if ex is not None else None) - conn.commit() - stats = compute_review_stats( - conn, - source_type=_review_source_for_mode(request.args.get("source_type")), - underlying=(request.args.get("underlying") or "").strip() or None, - include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower() - in ("1", "true", "yes"), - closed_from=(request.args.get("closed_from") or "").strip() or None, - closed_to=(request.args.get("closed_to") or "").strip() or None, - require_strategy=(request.args.get("require_strategy") or "").strip().lower() - in ("1", "true", "yes"), - ) - stats["source_types"] = list(SOURCE_TYPES) - stats["source_labels"] = SOURCE_LABELS - return jsonify(stats) - finally: - conn.close() +"""OKX 期权复盘模块:Flask 路由注册(含对冲计划级复盘).""" +from __future__ import annotations + +import os +from typing import Any + +from flask import Flask, jsonify, request, send_file +from jinja2 import ChoiceLoader, FileSystemLoader +from werkzeug.utils import secure_filename + +from lib.options.options_review_db import SOURCE_TYPES, init_options_review_tables +from lib.options.options_review_images_lib import ( + OPTIONS_REVIEW_UPLOAD_TFS, + normalize_options_review_draft_id, + options_review_image_paths, + options_review_upload_dir, + save_options_review_slot_file, +) +from lib.options.options_review_lib import ( + SOURCE_LABELS, + compute_review_stats, + count_review_trades, + delete_review_entry, + ensure_local_review_synced, + get_review_trade, + hide_review_trade, + list_review_trades, + save_review_entry, +) + + +def _review_source_for_mode(requested: str | None) -> str | None: + """按当前交易模式钳制复盘 source_type;不允许跨模式窥探.""" + try: + from lib.options.okx_trade_mode_lib import get_okx_trade_mode + + mode = get_okx_trade_mode() + except Exception: + mode = "options" + allowed = { + "options": "option_spot", + "perp_options": "perp_options", + "options_options": "options_options", + }.get(mode, "option_spot") + req = (requested or "").strip() + if not req: + return allowed + if req == allowed: + return allowed + # 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制 + return allowed + + +def attach_options_review_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "options", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def install_options_review(app: Flask, repo_root: str, app_module: Any) -> None: + attach_options_review_templates(app, repo_root) + cfg = { + "get_db": app_module.get_db, + "login_required": app_module.login_required, + "exchange_options": getattr(app_module, "exchange_options", None), + "render_main_page": app_module.render_main_page, + "upload_folder": getattr(app_module, "UPLOAD_FOLDER", None) + or os.path.join(getattr(app_module, "BASE_DIR", repo_root), "static", "images"), + "options_enabled": bool(getattr(app_module, "OKX_OPTIONS_ENABLED", False)), + "app_module": app_module, + } + app.extensions["options_review_cfg"] = cfg + register_options_review_routes(app, cfg, repo_root) + + +def _require_ex(cfg: dict[str, Any]): + from lib.exchange.okx_options_lib import options_api_ready + + if not cfg.get("options_enabled"): + return None, "期权模块未启用" + ex = cfg.get("exchange_options") + ok, reason = options_api_ready(ex) + if not ok: + return None, reason or "期权 API 未配置" + return ex, "" + + +def _truthy(raw: Any) -> bool: + return str(raw or "").strip().lower() in ("1", "true", "on", "yes") + + +def _prepare_review_entry_images( + conn: Any, + trade_id: int, + data: dict[str, Any], +) -> dict[str, Any]: + """客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图.""" + from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF + from lib.options.options_review_images_lib import parse_options_review_images_json + + images = data.get("images") if isinstance(data.get("images"), list) else [] + cleaned: list[dict[str, str]] = [] + for item in images: + if not isinstance(item, dict): + continue + tf = str(item.get("tf") or "").strip() + file = str(item.get("file") or "").strip() + if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF: + continue + cleaned.append({"tf": tf, "file": file}) + + want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned + if not want_chart: + row = conn.execute( + "SELECT images_json FROM options_review_entries WHERE trade_id=?", + (int(trade_id),), + ).fetchone() + if row: + for item in parse_options_review_images_json(row["images_json"]): + if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF: + cleaned.insert(0, dict(item)) + break + + out = dict(data) + out["images"] = cleaned + return out + + +def _maybe_schedule_options_review_chart( + cfg: dict[str, Any], + trade: dict[str, Any], + data: dict[str, Any], +) -> bool: + """纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场).""" + if str(trade.get("source_type") or "").strip() != "option_spot": + return False + images = data.get("images") if isinstance(data.get("images"), list) else [] + has_manual = any(str((x or {}).get("file") or "").strip() for x in images) + if has_manual or not _truthy(data.get("journal_exchange_chart")): + return False + + app_module = cfg.get("app_module") + if app_module is None: + return False + if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)): + return False + generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None) + if not callable(generate_fn): + return False + + from lib.instance.journal_chart_lib import ( + parse_journal_chart_anchor, + parse_journal_chart_limit, + parse_journal_chart_timeframes, + ) + from lib.options.options_review_chart_async_lib import ( + schedule_options_review_exchange_chart, + underlying_to_okx_perp_symbol, + ) + from lib.options.options_review_images_lib import options_review_upload_dir + from lib.options.options_review_lib import _now_str + + fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2] + journal_tfs = parse_journal_chart_timeframes( + data.get("journal_chart_tf1"), + data.get("journal_chart_tf2"), + fallback_tfs or None, + ) + journal_limit = parse_journal_chart_limit( + data.get("journal_chart_limit"), + int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100), + ) + chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor")) + + underlying = str(trade.get("underlying") or data.get("underlying") or "").strip() + exchange_symbol = underlying_to_okx_perp_symbol( + underlying, + normalize_symbol_input_fn=app_module.normalize_symbol_input, + normalize_okx_symbol_fn=app_module.normalize_okx_symbol, + ) + if not exchange_symbol: + return False + + to_ms = getattr(app_module, "_local_input_datetime_to_ms", None) + if not callable(to_ms): + return False + entry_ts = to_ms(trade.get("opened_at")) + exit_ts = to_ms(trade.get("closed_at")) + if entry_ts is None and exit_ts is None: + return False + + now_ms = None + app_now = getattr(app_module, "app_now", None) + if callable(app_now): + try: + now_ms = int(app_now().timestamp() * 1000) + except Exception: + now_ms = None + + inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip() + upload_folder = options_review_upload_dir(cfg["upload_folder"]) + schedule_options_review_exchange_chart( + trade_id=int(trade["id"]), + exchange_symbol=exchange_symbol, + title_prefix=f"{underlying or 'OPT'} {inst} review", + journal_tfs=journal_tfs, + journal_limit=journal_limit, + marker_payload={ + "entry_ts_ms": entry_ts, + "exit_ts_ms": exit_ts, + # 只按时间钉进/出场,不传价格与止损 + "chart_anchor": chart_anchor, + "now_ts_ms": now_ms, + }, + upload_folder=upload_folder, + generate_chart_fn=generate_fn, + get_db_fn=cfg["get_db"], + now_str_fn=_now_str, + ) + return True + + +def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None: + lr = cfg["login_required"] + + @app.route("/options/review") + @lr + def options_review_page(): + from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled + + redir = redirect_to_embed_shell_if_enabled("options_review") + if redir is not None: + return redir + return cfg["render_main_page"]("options_review") + + @app.route("/static/options_review.js") + @lr + def static_options_review_js(): + path = os.path.join(repo_root, "lib", "common", "static", "options_review.js") + if not os.path.isfile(path): + return ("not found", 404) + return send_file(path, mimetype="application/javascript; charset=utf-8") + + @app.route("/static/images/options_journal/") + def static_options_review_image(filename: str): + """截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401).""" + folder = options_review_upload_dir(cfg["upload_folder"]) + safe = os.path.basename(filename or "") + path = os.path.join(folder, safe) + if not os.path.isfile(path): + # 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件 + root = os.path.abspath(cfg["upload_folder"] or "") + alt = os.path.join(root, safe) + if os.path.isfile(alt): + path = alt + else: + return ("not found", 404) + return send_file(path) + + @app.route("/api/options/review/sync", methods=["POST"]) + @lr + def api_options_review_sync(): + """刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算.""" + conn = cfg["get_db"]() + try: + init_options_review_tables(conn) + ex, _err = _require_ex(cfg) + result = ensure_local_review_synced(conn, ex=ex if ex is not None else None) + conn.commit() + return jsonify(result) + finally: + conn.close() + + @app.route("/api/options/review/trades") + @lr + def api_options_review_trades(): + conn = cfg["get_db"]() + try: + # 翻页可跳过同步,仅刷新当前卡片列表 + do_sync = (request.args.get("sync") or "1").strip().lower() not in ( + "0", + "false", + "no", + ) + if do_sync: + ex, _err = _require_ex(cfg) + ensure_local_review_synced(conn, ex=ex if ex is not None else None) + conn.commit() + filt = dict( + source_type=_review_source_for_mode(request.args.get("source_type")), + underlying=(request.args.get("underlying") or "").strip() or None, + opt_type=(request.args.get("opt_type") or "").strip() or None, + strategy_tag=(request.args.get("strategy_tag") or "").strip() or None, + q=(request.args.get("q") or "").strip() or None, + reviewed=(request.args.get("reviewed") or "").strip() or None, + include_hedge_legs=(request.args.get("include_hedge_legs") or "") + .strip() + .lower() + in ("1", "true", "yes"), + closed_from=(request.args.get("closed_from") or "").strip() or None, + closed_to=(request.args.get("closed_to") or "").strip() or None, + ) + limit = min(500, max(1, int(request.args.get("limit") or 200))) + offset = max(0, int(request.args.get("offset") or 0)) + total = count_review_trades(conn, **filt) + items = list_review_trades(conn, **filt, limit=limit, offset=offset) + pages = max(1, (total + limit - 1) // limit) if total else 1 + page = (offset // limit) + 1 if limit else 1 + return jsonify( + { + "ok": True, + "trades": items, + "source_labels": SOURCE_LABELS, + "total": total, + "limit": limit, + "offset": offset, + "page": page, + "pages": pages, + } + ) + finally: + conn.close() + + @app.route("/api/options/review/trades/") + @lr + def api_options_review_trade_detail(trade_id: int): + conn = cfg["get_db"]() + try: + item = get_review_trade(conn, trade_id) + if not item: + return jsonify({"ok": False, "msg": "未找到"}), 404 + return jsonify({"ok": True, "trade": item}) + finally: + conn.close() + + @app.route("/api/options/review/entry", methods=["POST"]) + @lr + def api_options_review_entry_save(): + data = request.get_json(silent=True) or {} + try: + trade_id = int(data.get("trade_id")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "trade_id 无效"}), 400 + conn = cfg["get_db"]() + try: + data = _prepare_review_entry_images(conn, trade_id, data) + out = save_review_entry(conn, trade_id, data) + if out.get("ok"): + conn.commit() + chart_queued = _maybe_schedule_options_review_chart( + cfg, out.get("trade") or {}, data + ) + if chart_queued: + out = dict(out) + out["chart_queued"] = True + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + + @app.route("/api/options/review/trades/", methods=["DELETE"]) + @lr + def api_options_review_trade_hide(trade_id: int): + """从复盘列表删除并持久隐藏(刷新本地源也不会再导入).""" + conn = cfg["get_db"]() + try: + out = hide_review_trade(conn, trade_id) + if out.get("ok"): + entry = out.get("entry") or {} + folder = options_review_upload_dir(cfg["upload_folder"]) + for path in options_review_image_paths(entry, folder): + try: + os.remove(path) + except OSError: + pass + conn.commit() + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + + @app.route("/api/options/review/entry/", methods=["DELETE"]) + @lr + def api_options_review_entry_delete(trade_id: int): + conn = cfg["get_db"]() + try: + out = delete_review_entry(conn, trade_id) + if out.get("ok"): + entry = out.get("entry") or {} + folder = options_review_upload_dir(cfg["upload_folder"]) + for path in options_review_image_paths(entry, folder): + try: + os.remove(path) + except OSError: + pass + conn.commit() + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + + @app.route("/api/options/review/upload_slot", methods=["POST"]) + @lr + def api_options_review_upload_slot(): + draft_id = normalize_options_review_draft_id( + request.form.get("draft_id") if request.form else None + ) + tf = str((request.form.get("tf") if request.form else None) or "").strip() + if not draft_id: + return jsonify({"ok": False, "error": "invalid draft_id"}), 400 + if tf not in OPTIONS_REVIEW_UPLOAD_TFS: + return jsonify({"ok": False, "error": "invalid tf"}), 400 + f = request.files.get("file") if request.files else None + if not f or not getattr(f, "filename", None): + return jsonify({"ok": False, "error": "no file"}), 400 + folder = options_review_upload_dir(cfg["upload_folder"]) + item = save_options_review_slot_file( + f, draft_id, tf, folder, secure_filename_fn=secure_filename + ) + if not item: + return jsonify({"ok": False, "error": "save failed"}), 500 + return jsonify({"ok": True, "tf": tf, "file": item["file"]}) + + @app.route("/api/options/review/stats") + @lr + def api_options_review_stats(): + conn = cfg["get_db"]() + try: + ex, _err = _require_ex(cfg) + ensure_local_review_synced(conn, ex=ex if ex is not None else None) + conn.commit() + stats = compute_review_stats( + conn, + source_type=_review_source_for_mode(request.args.get("source_type")), + underlying=(request.args.get("underlying") or "").strip() or None, + include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower() + in ("1", "true", "yes"), + closed_from=(request.args.get("closed_from") or "").strip() or None, + closed_to=(request.args.get("closed_to") or "").strip() or None, + require_strategy=(request.args.get("require_strategy") or "").strip().lower() + in ("1", "true", "yes"), + ) + stats["source_types"] = list(SOURCE_TYPES) + stats["source_labels"] = SOURCE_LABELS + return jsonify(stats) + finally: + conn.close() diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py index 4375e09..a404f06 100644 --- a/lib/options/options_target_lib.py +++ b/lib/options/options_target_lib.py @@ -1,501 +1,474 @@ -"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算).""" -from __future__ import annotations - -import sqlite3 -import time -from typing import Any, Callable - -from lib.options.options_db import init_options_tables -from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px - - -def _safe_float(v: Any) -> float | None: - if v is None or v == "": - return None - try: - return float(v) - except (TypeError, ValueError): - return None - - -def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]: - from lib.exchange.okx_options_lib import option_fields_from_inst_id - - inst_id = str(pos.get("instId") or pos.get("inst_id") or "") - mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark")) - if mark is None: - mark = fetch_option_mark_px(ex, inst_id) - opt_type = pos.get("optType") or (quote or {}).get("opt_type") - strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike")) - if not opt_type or strike is None: - pt, ps = option_fields_from_inst_id(inst_id) - opt_type = opt_type or pt - if strike is None: - strike = ps - idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px")) - return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx) - - -def ensure_target_tables(conn: sqlite3.Connection) -> None: - init_options_tables(conn) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS options_target_monitors ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - inst_id TEXT NOT NULL, - underlying TEXT, - opt_type TEXT, - target_index REAL NOT NULL, - trade_id INTEGER, - sheets INTEGER, - status TEXT DEFAULT 'active', - trigger_idx REAL, - close_ord_id TEXT, - message TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - triggered_at TIMESTAMP - ) - """ - ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status - ON options_target_monitors(status) - """ - ) - - -def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool: - """Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓.""" - ot = (opt_type or "").strip().upper() - if ot == "P": - return index_px <= target_index - return index_px >= target_index - - -def upsert_target_monitor( - conn: sqlite3.Connection, - *, - inst_id: str, - target_index: float, - underlying: str | None = None, - opt_type: str | None = None, - trade_id: int | None = None, - sheets: int | None = None, -) -> dict[str, Any]: - ensure_target_tables(conn) - inst_id = (inst_id or "").strip() - if not inst_id: - return {"ok": False, "msg": "缺少 inst_id"} - if target_index is None or float(target_index) <= 0: - return {"ok": False, "msg": "目标位无效"} - target_index = float(target_index) - row = conn.execute( - """ - SELECT id FROM options_target_monitors - WHERE inst_id = ? AND status IN ('active', 'closing') - ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC - LIMIT 1 - """, - (inst_id,), - ).fetchone() - if row: - conn.execute( - """ - UPDATE options_target_monitors - SET target_index = ?, - underlying = COALESCE(?, underlying), - opt_type = COALESCE(?, opt_type), - trade_id = COALESCE(?, trade_id), - sheets = COALESCE(?, sheets), - status = 'active', - trigger_idx = NULL, - close_ord_id = NULL, - message = NULL, - triggered_at = NULL - WHERE id = ? - """, - (target_index, underlying, opt_type, trade_id, sheets, int(row["id"])), - ) - mon_id = int(row["id"]) - # 同一合约其他进行中的委托取消,避免双轨触发重复推送 - conn.execute( - """ - UPDATE options_target_monitors - SET status = 'cancelled', message = '被新目标位覆盖' - WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing') - """, - (inst_id, mon_id), - ) - else: - cur = conn.execute( - """ - INSERT INTO options_target_monitors - (inst_id, underlying, opt_type, target_index, trade_id, sheets, status) - VALUES (?, ?, ?, ?, ?, ?, 'active') - """, - (inst_id, underlying, opt_type, target_index, trade_id, sheets), - ) - mon_id = int(cur.lastrowid) - return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index} - - -def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int: - ensure_target_tables(conn) - if monitor_id is not None: - cur = conn.execute( - """ - UPDATE options_target_monitors - SET status = 'cancelled', message = '手动取消' - WHERE id = ? AND status IN ('active', 'closing') - """, - (int(monitor_id),), - ) - return int(cur.rowcount or 0) - if inst_id: - cur = conn.execute( - """ - UPDATE options_target_monitors - SET status = 'cancelled', message = '手动取消' - WHERE inst_id = ? AND status IN ('active', 'closing') - """, - (inst_id.strip(),), - ) - return int(cur.rowcount or 0) - return 0 - - -def _row_to_target(r: sqlite3.Row) -> dict[str, Any]: - return { - "id": int(r["id"]), - "inst_id": r["inst_id"], - "underlying": r["underlying"], - "opt_type": r["opt_type"], - "target_index": _safe_float(r["target_index"]), - "trade_id": r["trade_id"], - "sheets": r["sheets"], - "status": r["status"], - "message": r["message"], - "created_at": r["created_at"], - } - - -def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: - ensure_target_tables(conn) - rows = conn.execute( - """ - SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, - status, message, created_at - FROM options_target_monitors - WHERE status = 'active' - ORDER BY id DESC - """ - ).fetchall() - return [_row_to_target(r) for r in rows] - - -def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: - """已挂出平仓单、等待成交的目标(不再重复推送微信).""" - ensure_target_tables(conn) - rows = conn.execute( - """ - SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, - status, message, created_at - FROM options_target_monitors - WHERE status = 'closing' - ORDER BY id DESC - """ - ).fetchall() - return [_row_to_target(r) for r in rows] - - -def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: - """UI/持仓挂载:active 与 closing 都算进行中.""" - out: dict[str, dict[str, Any]] = {} - for t in list_closing_targets(conn) + list_active_targets(conn): - inst = str(t.get("inst_id") or "") - if inst and inst not in out: - out[inst] = t - return out - - -def mark_monitor( - conn: sqlite3.Connection, - monitor_id: int, - *, - status: str, - trigger_idx: float | None = None, - close_ord_id: str | None = None, - message: str | None = None, -) -> None: - conn.execute( - """ - UPDATE options_target_monitors - SET status = ?, - trigger_idx = COALESCE(?, trigger_idx), - close_ord_id = COALESCE(?, close_ord_id), - message = COALESCE(?, message), - triggered_at = CASE - WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP) - ELSE triggered_at - END - WHERE id = ? - """, - (status, trigger_idx, close_ord_id, message, status, int(monitor_id)), - ) - - -def cancel_orphans_without_position( - conn: sqlite3.Connection, - *, - live_inst_ids: set[str], -) -> int: - """持仓已消失的目标委托标记为 expired(到期/已平),不挂止损.""" - ensure_target_tables(conn) - rows = list_active_targets(conn) + list_closing_targets(conn) - n = 0 - for t in rows: - inst = str(t.get("inst_id") or "") - if inst and inst not in live_inst_ids: - mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束") - n += 1 - return n - - -def _commit_monitor(conn: sqlite3.Connection) -> None: - """状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送.""" - try: - conn.commit() - except Exception: - pass - - -def close_option_by_bid_depth( - cfg: dict[str, Any], - ex: Any, - inst_id: str, - *, - sheets: int | None = None, -) -> dict[str, Any]: - """目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性).""" - from lib.options.options_close_exec_lib import close_option_by_bid1 - - return close_option_by_bid1( - cfg, - ex, - inst_id, - sheets=sheets, - require_recycle_gate=True, - signal_note="目标位平仓", - ) - - - -def _notify_target_close( - cfg: dict[str, Any] | None, - send_wechat: Callable[[str], None] | None, - *, - account_label: str, - inst_id: str, - target: float, - idx: float, - result: dict[str, Any], - conn: Any = None, -) -> None: - """目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" - from lib.options.options_notify_lib import resolve_premium_ccy - - ccy = resolve_premium_ccy(inst_id=inst_id) - if result.get("fully_closed") or result.get("already_flat"): - if cfg is not None: - try: - from lib.options.options_notify_lib import notify_options_close - - notify_options_close( - cfg, - conn, - inst_id=inst_id, - reason="目标位平仓", - sheets=result.get("submitted_sheets"), - premium_received=result.get("premium_received"), - close_quote=result.get("locked_bid_px") or result.get("bid"), - target_index=target, - trigger_idx=idx, - premium_ccy=ccy, - ) - return - except Exception: - pass - if not send_wechat: - return - try: - recv = result.get("premium_received") - send_wechat( - "\n".join( - [ - "【OKX期权·目标位平仓】", - f"账户:{account_label}", - f"合约:{inst_id}", - f"目标指数:{target:g}", - f"触发指数:{idx:g}", - f"提交张数:{result.get('submitted_sheets') or '—'}", - f"预估收回:{recv if recv is not None else '—'} {ccy}", - f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}", - ] - ) - ) - except Exception: - pass - - -def _result_fully_done(result: dict[str, Any]) -> bool: - if result.get("already_flat"): - return True - if result.get("fully_closed"): - return True - remaining = result.get("remaining_sheets") - if remaining is not None and int(remaining) <= 0 and result.get("ok"): - return True - return False - - -def run_options_target_closes( - conn: sqlite3.Connection, - positions: list[dict[str, Any]], - *, - close_fn: Callable[[str], dict[str, Any]], - index_fn: Callable[[dict[str, Any]], float | None] | None = None, - send_wechat: Callable[[str], None] | None = None, - account_label: str = "OKX期权", - cfg: dict[str, Any] | None = None, -) -> int: - """ - 扫描 active 目标委托;指数到位后限价平仓. - 状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送. - 未完全成交进入 closing,仅重试平仓不再推送. - 返回本次新触发(并推送)的条数. - """ - ensure_target_tables(conn) - pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} - live_ids = {k for k in pos_by_inst if k} - hedge_managed: set[str] = set() - try: - from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables - - init_hedge_plan_tables(conn) - hedge_managed = active_hedge_option_inst_ids(conn) - except Exception: - # fail-closed:本轮不执行任何单独目标平仓,避免误平对冲腿 - return 0 - cancel_orphans_without_position(conn, live_inst_ids=live_ids) - _commit_monitor(conn) - - # 先处理已挂单等待成交的,绝不再发微信 - for mon in list_closing_targets(conn): - inst_id = str(mon.get("inst_id") or "") - if not inst_id: - continue - if inst_id in hedge_managed: - mark_monitor( - conn, - int(mon["id"]), - status="expired", - message="已移交对冲计划托管,跳过单独目标平仓", - ) - _commit_monitor(conn) - continue - if inst_id not in pos_by_inst: - mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平") - _commit_monitor(conn) - continue - result = close_fn(inst_id) - idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx")) - if result.get("already_flat") or _result_fully_done(result): - mark_monitor( - conn, - int(mon["id"]), - status="triggered", - trigger_idx=idx, - close_ord_id=result.get("close_ord_id"), - message="目标位限价平仓完成", - ) - _commit_monitor(conn) - continue - mark_monitor( - conn, - int(mon["id"]), - status="closing", - trigger_idx=idx, - close_ord_id=result.get("close_ord_id"), - message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"), - ) - _commit_monitor(conn) - - triggered = 0 - for mon in list_active_targets(conn): - inst_id = str(mon.get("inst_id") or "") - target = _safe_float(mon.get("target_index")) - if not inst_id or target is None: - continue - if inst_id in hedge_managed: - mark_monitor( - conn, - int(mon["id"]), - status="expired", - message="已移交对冲计划托管,跳过单独目标平仓", - ) - _commit_monitor(conn) - continue - pos = pos_by_inst.get(inst_id) - if not pos: - continue - if index_fn is not None: - idx = index_fn(pos) - else: - idx = _safe_float(pos.get("idx_px") or pos.get("idxPx")) - if idx is None: - continue - opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType") - if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target): - continue - - result = close_fn(inst_id) - if result.get("already_flat"): - mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") - _commit_monitor(conn) - continue - if not result.get("ok"): - mark_monitor( - conn, - int(mon["id"]), - status="active", - trigger_idx=idx, - message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"), - ) - _commit_monitor(conn) - continue - - done = _result_fully_done(result) - status = "triggered" if done else "closing" - mark_monitor( - conn, - int(mon["id"]), - status=status, - trigger_idx=idx, - close_ord_id=result.get("close_ord_id"), - message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交", - ) - # 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信 - _commit_monitor(conn) - triggered += 1 - _notify_target_close( - cfg, - send_wechat, - account_label=account_label, - inst_id=inst_id, - target=target, - idx=idx, - result=result, - conn=conn, - ) - return triggered +"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算).""" +from __future__ import annotations + +import sqlite3 +import time +from typing import Any, Callable + +from lib.options.options_db import init_options_tables +from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]: + from lib.exchange.okx_options_lib import option_fields_from_inst_id + + inst_id = str(pos.get("instId") or pos.get("inst_id") or "") + mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark")) + if mark is None: + mark = fetch_option_mark_px(ex, inst_id) + opt_type = pos.get("optType") or (quote or {}).get("opt_type") + strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike")) + if not opt_type or strike is None: + pt, ps = option_fields_from_inst_id(inst_id) + opt_type = opt_type or pt + if strike is None: + strike = ps + idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px")) + return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx) + + +def ensure_target_tables(conn: sqlite3.Connection) -> None: + init_options_tables(conn) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_target_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id TEXT NOT NULL, + underlying TEXT, + opt_type TEXT, + target_index REAL NOT NULL, + trade_id INTEGER, + sheets INTEGER, + status TEXT DEFAULT 'active', + trigger_idx REAL, + close_ord_id TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + triggered_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status + ON options_target_monitors(status) + """ + ) + + +def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool: + """Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓.""" + ot = (opt_type or "").strip().upper() + if ot == "P": + return index_px <= target_index + return index_px >= target_index + + +def upsert_target_monitor( + conn: sqlite3.Connection, + *, + inst_id: str, + target_index: float, + underlying: str | None = None, + opt_type: str | None = None, + trade_id: int | None = None, + sheets: int | None = None, +) -> dict[str, Any]: + ensure_target_tables(conn) + inst_id = (inst_id or "").strip() + if not inst_id: + return {"ok": False, "msg": "缺少 inst_id"} + if target_index is None or float(target_index) <= 0: + return {"ok": False, "msg": "目标位无效"} + target_index = float(target_index) + row = conn.execute( + """ + SELECT id FROM options_target_monitors + WHERE inst_id = ? AND status IN ('active', 'closing') + ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC + LIMIT 1 + """, + (inst_id,), + ).fetchone() + if row: + conn.execute( + """ + UPDATE options_target_monitors + SET target_index = ?, + underlying = COALESCE(?, underlying), + opt_type = COALESCE(?, opt_type), + trade_id = COALESCE(?, trade_id), + sheets = COALESCE(?, sheets), + status = 'active', + trigger_idx = NULL, + close_ord_id = NULL, + message = NULL, + triggered_at = NULL + WHERE id = ? + """, + (target_index, underlying, opt_type, trade_id, sheets, int(row["id"])), + ) + mon_id = int(row["id"]) + # 同一合约其他进行中的委托取消,避免双轨触发重复推送 + conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '被新目标位覆盖' + WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing') + """, + (inst_id, mon_id), + ) + else: + cur = conn.execute( + """ + INSERT INTO options_target_monitors + (inst_id, underlying, opt_type, target_index, trade_id, sheets, status) + VALUES (?, ?, ?, ?, ?, ?, 'active') + """, + (inst_id, underlying, opt_type, target_index, trade_id, sheets), + ) + mon_id = int(cur.lastrowid) + return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index} + + +def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int: + ensure_target_tables(conn) + if monitor_id is not None: + cur = conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '手动取消' + WHERE id = ? AND status IN ('active', 'closing') + """, + (int(monitor_id),), + ) + return int(cur.rowcount or 0) + if inst_id: + cur = conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '手动取消' + WHERE inst_id = ? AND status IN ('active', 'closing') + """, + (inst_id.strip(),), + ) + return int(cur.rowcount or 0) + return 0 + + +def _row_to_target(r: sqlite3.Row) -> dict[str, Any]: + return { + "id": int(r["id"]), + "inst_id": r["inst_id"], + "underlying": r["underlying"], + "opt_type": r["opt_type"], + "target_index": _safe_float(r["target_index"]), + "trade_id": r["trade_id"], + "sheets": r["sheets"], + "status": r["status"], + "message": r["message"], + "created_at": r["created_at"], + } + + +def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: + ensure_target_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, + status, message, created_at + FROM options_target_monitors + WHERE status = 'active' + ORDER BY id DESC + """ + ).fetchall() + return [_row_to_target(r) for r in rows] + + +def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: + """已挂出平仓单、等待成交的目标(不再重复推送微信).""" + ensure_target_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, + status, message, created_at + FROM options_target_monitors + WHERE status = 'closing' + ORDER BY id DESC + """ + ).fetchall() + return [_row_to_target(r) for r in rows] + + +def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: + """UI/持仓挂载:active 与 closing 都算进行中.""" + out: dict[str, dict[str, Any]] = {} + for t in list_closing_targets(conn) + list_active_targets(conn): + inst = str(t.get("inst_id") or "") + if inst and inst not in out: + out[inst] = t + return out + + +def mark_monitor( + conn: sqlite3.Connection, + monitor_id: int, + *, + status: str, + trigger_idx: float | None = None, + close_ord_id: str | None = None, + message: str | None = None, +) -> None: + conn.execute( + """ + UPDATE options_target_monitors + SET status = ?, + trigger_idx = COALESCE(?, trigger_idx), + close_ord_id = COALESCE(?, close_ord_id), + message = COALESCE(?, message), + triggered_at = CASE + WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP) + ELSE triggered_at + END + WHERE id = ? + """, + (status, trigger_idx, close_ord_id, message, status, int(monitor_id)), + ) + + +def cancel_orphans_without_position( + conn: sqlite3.Connection, + *, + live_inst_ids: set[str], +) -> int: + """持仓已消失的目标委托标记为 expired(到期/已平),不挂止损.""" + ensure_target_tables(conn) + rows = list_active_targets(conn) + list_closing_targets(conn) + n = 0 + for t in rows: + inst = str(t.get("inst_id") or "") + if inst and inst not in live_inst_ids: + mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束") + n += 1 + return n + + +def _commit_monitor(conn: sqlite3.Connection) -> None: + """状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送.""" + try: + conn.commit() + except Exception: + pass + + +def close_option_by_bid_depth( + cfg: dict[str, Any], + ex: Any, + inst_id: str, + *, + sheets: int | None = None, +) -> dict[str, Any]: + """目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性).""" + from lib.options.options_close_exec_lib import close_option_by_bid1 + + return close_option_by_bid1( + cfg, + ex, + inst_id, + sheets=sheets, + require_recycle_gate=True, + signal_note="目标位平仓", + ) + + + +def _notify_target_close( + cfg: dict[str, Any] | None, + send_wechat: Callable[[str], None] | None, + *, + account_label: str, + inst_id: str, + target: float, + idx: float, + result: dict[str, Any], + conn: Any = None, +) -> None: + """目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" + from lib.options.options_notify_lib import resolve_premium_ccy + + ccy = resolve_premium_ccy(inst_id=inst_id) + if result.get("fully_closed") or result.get("already_flat"): + if cfg is not None: + try: + from lib.options.options_notify_lib import notify_options_close + + notify_options_close( + cfg, + conn, + inst_id=inst_id, + reason="目标位平仓", + sheets=result.get("submitted_sheets"), + premium_received=result.get("premium_received"), + close_quote=result.get("locked_bid_px") or result.get("bid"), + target_index=target, + trigger_idx=idx, + premium_ccy=ccy, + ) + return + except Exception: + pass + if not send_wechat: + return + try: + recv = result.get("premium_received") + send_wechat( + "\n".join( + [ + "【OKX期权·目标位平仓】", + f"账户:{account_label}", + f"合约:{inst_id}", + f"目标指数:{target:g}", + f"触发指数:{idx:g}", + f"提交张数:{result.get('submitted_sheets') or '—'}", + f"预估收回:{recv if recv is not None else '—'} {ccy}", + f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}", + ] + ) + ) + except Exception: + pass + + +def _result_fully_done(result: dict[str, Any]) -> bool: + if result.get("already_flat"): + return True + if result.get("fully_closed"): + return True + remaining = result.get("remaining_sheets") + if remaining is not None and int(remaining) <= 0 and result.get("ok"): + return True + return False + + +def run_options_target_closes( + conn: sqlite3.Connection, + positions: list[dict[str, Any]], + *, + close_fn: Callable[[str], dict[str, Any]], + index_fn: Callable[[dict[str, Any]], float | None] | None = None, + send_wechat: Callable[[str], None] | None = None, + account_label: str = "OKX期权", + cfg: dict[str, Any] | None = None, +) -> int: + """ + 扫描 active 目标委托;指数到位后限价平仓. + 状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送. + 未完全成交进入 closing,仅重试平仓不再推送. + 返回本次新触发(并推送)的条数. + """ + ensure_target_tables(conn) + pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} + live_ids = {k for k in pos_by_inst if k} + cancel_orphans_without_position(conn, live_inst_ids=live_ids) + _commit_monitor(conn) + + # 先处理已挂单等待成交的,绝不再发微信 + for mon in list_closing_targets(conn): + inst_id = str(mon.get("inst_id") or "") + if not inst_id: + continue + if inst_id not in pos_by_inst: + mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平") + _commit_monitor(conn) + continue + result = close_fn(inst_id) + idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx")) + if result.get("already_flat") or _result_fully_done(result): + mark_monitor( + conn, + int(mon["id"]), + status="triggered", + trigger_idx=idx, + close_ord_id=result.get("close_ord_id"), + message="目标位限价平仓完成", + ) + _commit_monitor(conn) + continue + mark_monitor( + conn, + int(mon["id"]), + status="closing", + trigger_idx=idx, + close_ord_id=result.get("close_ord_id"), + message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"), + ) + _commit_monitor(conn) + + triggered = 0 + for mon in list_active_targets(conn): + inst_id = str(mon.get("inst_id") or "") + target = _safe_float(mon.get("target_index")) + if not inst_id or target is None: + continue + pos = pos_by_inst.get(inst_id) + if not pos: + continue + if index_fn is not None: + idx = index_fn(pos) + else: + idx = _safe_float(pos.get("idx_px") or pos.get("idxPx")) + if idx is None: + continue + opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType") + if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target): + continue + + result = close_fn(inst_id) + if result.get("already_flat"): + mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") + _commit_monitor(conn) + continue + if not result.get("ok"): + mark_monitor( + conn, + int(mon["id"]), + status="active", + trigger_idx=idx, + message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"), + ) + _commit_monitor(conn) + continue + + done = _result_fully_done(result) + status = "triggered" if done else "closing" + mark_monitor( + conn, + int(mon["id"]), + status=status, + trigger_idx=idx, + close_ord_id=result.get("close_ord_id"), + message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交", + ) + # 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信 + _commit_monitor(conn) + triggered += 1 + _notify_target_close( + cfg, + send_wechat, + account_label=account_label, + inst_id=inst_id, + target=target, + idx=idx, + result=result, + conn=conn, + ) + return triggered diff --git a/lib/sim/broker_lib.py b/lib/sim/broker_lib.py index 1b109e2..5ee1e2d 100644 --- a/lib/sim/broker_lib.py +++ b/lib/sim/broker_lib.py @@ -704,7 +704,7 @@ class SimBroker: expiry_ms_from_inst_id, option_fields_from_inst_id, ) - from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl + from lib.options.options_pricing_lib import option_expiry_pnl now = int(now_ms if now_ms is not None else time.time() * 1000) settled: list[dict[str, Any]] = [] diff --git a/lib/sim/register.py b/lib/sim/register.py index 4e71898..9b3387d 100644 --- a/lib/sim/register.py +++ b/lib/sim/register.py @@ -51,7 +51,7 @@ def install_sim_trading(app: Flask, repo_root: str, app_module: Any = None) -> N apply_sim_hooks(app_module) # 若 options / hedge 已安装, 补丁其 cfg, 并刷新永续下单引用 - for key in ("options_cfg", "hedge_plan_cfg"): + for key in ("options_cfg",): cfg = app.extensions.get(key) if isinstance(cfg, dict): patch_options_cfg(cfg) diff --git a/tests/test_remove_hedge_smoke.py b/tests/test_remove_hedge_smoke.py new file mode 100644 index 0000000..f688efc --- /dev/null +++ b/tests/test_remove_hedge_smoke.py @@ -0,0 +1,52 @@ +"""对冲拆除后的冒烟:交易模式锁定单独期权;hedge_plan 包已移除.""" +from __future__ import annotations + +import importlib +import unittest + + +class RemoveHedgeSmokeTests(unittest.TestCase): + def test_trade_mode_always_options(self): + from lib.options.okx_trade_mode_lib import ( + get_okx_trade_mode, + hedge_module_enabled, + standalone_options_open_allowed, + block_standalone_open_by_mode_msg, + normalize_okx_trade_mode, + ) + + self.assertEqual(normalize_okx_trade_mode("options_options"), "options") + self.assertEqual(normalize_okx_trade_mode("perp_options"), "options") + self.assertEqual(get_okx_trade_mode(), "options") + self.assertFalse(hedge_module_enabled()) + self.assertTrue(standalone_options_open_allowed()) + self.assertIsNone(block_standalone_open_by_mode_msg()) + + def test_hedge_plan_package_gone(self): + with self.assertRaises(ModuleNotFoundError): + importlib.import_module("lib.hedge_plan") + + def test_option_expiry_pnl_available(self): + from lib.options.options_pricing_lib import option_expiry_pnl + + pnl = option_expiry_pnl( + opt_type="P", + strike=2700, + spot=2600, + sheets=10, + ct_mult=0.01, + premium_paid=1.0, + ) + # intrinsic = 100 * 10 * 0.01 = 10; pnl = 10 - 1 = 9 + self.assertAlmostEqual(pnl, 9.0, places=6) + + def test_profit_exit_no_hedge_import(self): + import inspect + from lib.options import options_profit_exit_lib as m + + src = inspect.getsource(m) + self.assertNotIn("hedge_plan", src) + + +if __name__ == "__main__": + unittest.main()