from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response, send_file
import sqlite3
import csv
from io import StringIO
import time
import threading
import requests
import os
import re
import base64
import json
import math
from datetime import datetime, timedelta, timezone

try:
    from zoneinfo import ZoneInfo
except ImportError:
    ZoneInfo = None  # type: ignore
from functools import wraps
import uuid
import ccxt
from werkzeug.utils import secure_filename

try:
    from PIL import Image, ImageDraw, ImageFont
except ImportError:
    Image = None  # type: ignore
    ImageDraw = None  # type: ignore
    ImageFont = None  # type: ignore

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
_REPO_ROOT = os.path.dirname(BASE_DIR)
import sys

if _REPO_ROOT not in sys.path:
    sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import (
    build_journal_ai_chart_path,
    collect_images_for_ai_review,
    journal_row_lines_for_ai,
)
from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from lib.key_monitor.fib_key_monitor_lib import (
    FIB_KEY_MONITOR_TYPES,
    backfill_missing_key_signal_types,
    calc_fib_plan,
    entry_reason_from_key_signal,
    fib_invalidate_by_mark,
    fib_ratio_from_type,
    is_fib_key_monitor_type,
    key_signal_type_for_trade_record,
    stored_key_signal_type,
)
from lib.key_monitor.false_breakout_key_monitor_lib import (
    FALSE_BREAKOUT_MONITOR_TYPE,
    FALSE_BREAKOUT_VALIDITY_HOURS,
    calc_false_breakout_plan,
    expires_at_text,
    false_breakout_gate_preview,
    is_false_breakout_expired,
    is_false_breakout_key_monitor_type,
    is_limit_key_monitor_type,
    key_price_from_row,
    normalize_false_breakout_symbol,
    storage_bounds_from_key_price,
)
from lib.strategy.strategy_trade_labels 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.key_monitor.trigger_entry_key_monitor_lib import (
    BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE,
    CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
    TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
    TRIGGER_ENTRY_CLOSE_EXPIRED,
    TRIGGER_ENTRY_CLOSE_FILLED,
    TRIGGER_ENTRY_CLOSE_SL_INVALIDATE,
    TRIGGER_ENTRY_CLOSE_TP_INVALIDATE,
    TRIGGER_ENTRY_MONITOR_TYPE,
    TRIGGER_ENTRY_MONITOR_TYPES,
    TRIGGER_ENTRY_VALIDITY_HOURS,
    check_trigger_entry_intent_limit,
    count_pending_trigger_entries,
    acquire_trigger_entry_exec_lock,
    is_trigger_entry_in_flight_row,
    release_trigger_entry_exec_lock,
    is_breakout_trigger_entry_key_monitor_type,
    is_trigger_entry_expired,
    is_trigger_entry_key_monitor_type,
    trigger_entry_expires_at_text,
    trigger_entry_gate_preview,
    trigger_entry_invalidate,
    trigger_should_fire,
    validate_trigger_entry_geometry,
    validate_trigger_entry_rr,
)
from lib.trade.position_sizing_lib import (
    OPEN_SOURCE_KEY_AUTO,
    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,
    hub_meta_entry_context,
    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.key_monitor.key_auto_order_lib import (
    check_monitor_type_add_allowed,
    effective_entry_reason_options,
    effective_stats_segment_defs,
    load_key_auto_order_enabled,
)
from lib.key_monitor.key_monitor_full_margin_lib import (
    monitor_type_disallowed_in_full_margin,
    purge_disallowed_key_monitors,
)
from lib.common.auto_transfer_daily_lib import run_auto_transfer_once_per_day
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,
    auto_amp_ok,
    auto_confirm_ok,
    box_breakout_invalidate_by_mark,
    box_breakout_invalidate_edge_label,
    claim_rs_level_notify,
    detect_rs_box_break,
    format_auto_amp_line,
    format_auto_confirm_line,
    key_monitor_rule_template_context,
    notify_interval_elapsed,
    resolve_rs_break_for_alert,
    rs_break_from_direction,
    run_rs_level_alert_tick,
)
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.hub.hub_auth import request_allowed as hub_request_allowed
from lib.instance.instance_nav_lib import request_is_hub_soft_nav
from lib.hub.hub_volume_rank_lib import resolve_daily_volume_rank
from lib.common.history_window_lib import (
    PRESET_ALL,
    PRESET_CUSTOM,
    PRESET_DEFAULT,
    PRESET_UTC_LAST24H,
    PRESET_UTC_LAST3M,
    PRESET_UTC_LAST6M,
    PRESET_UTC_LAST7D,
    PRESET_UTC_THIS_MONTH,
    PRESET_UTC_TODAY,
    list_window_redirect_query,
    normalize_bj_datetime_storage,
    resolve_list_window,
    resolve_window,
    sql_list_time_field,
    utc_window_to_bj_sql_strings,
    utc_window_to_utc_sql_strings,
)
from lib.trade.trade_result_lib import (
    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", "crypto_monitor_2026_secret_key")
from lib.instance.instance_embed_lib import attach_embed_templates, redirect_to_embed_shell_if_enabled

attach_embed_templates(app, _REPO_ROOT)

# ====================== 登录配置 ======================
USERNAME = os.getenv("APP_USERNAME", "dekun")
PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@")
AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on")

# 企业微信机器人Webhook
WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me")
SYSTEM_TYPE = "CRYPTO"
HOST = os.getenv("APP_HOST", "0.0.0.0")
PORT = int(os.getenv("APP_PORT", "5000"))
DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true"
DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db"))

# 训练参数(可由 .env 覆盖)
TOTAL_CAPITAL = float(os.getenv("TOTAL_CAPITAL", "100"))
DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30"))
DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20"))
DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50"))
BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10"))
ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5"))
# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8)
TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))
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"
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", "false").lower() in ("1", "true", "yes", "on")
OKX_OPTIONS_API_KEY = os.getenv("OKX_OPTIONS_API_KEY", "")
OKX_OPTIONS_API_SECRET = os.getenv("OKX_OPTIONS_API_SECRET", "")
OKX_OPTIONS_API_PASSPHRASE = os.getenv("OKX_OPTIONS_API_PASSPHRASE", "")
OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10"))
OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper()
OKX_SUB_ACCOUNT_NAME = (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip()
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 = int(os.getenv("BALANCE_REFRESH_SECONDS", "60"))
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3"))
KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5"))
KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5"))
AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true"
AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30"))
AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding")
AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap")
FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true"
FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0"))
# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日(与 OKX 日界一致便于对账)
AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8"))
POSITION_SIZING_MODE = load_position_sizing_mode()
KEY_AUTO_ORDER_ENABLED = load_key_auto_order_enabled()
TRADE_POLICY = load_trade_policy()
WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10"))
AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120"))
MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3"))
RECONCILE_STARTUP_GRACE_SEC = int(os.getenv("RECONCILE_STARTUP_GRACE_SEC", "90"))
RECONCILE_FLAT_CONFIRM_POLLS = max(1, int(os.getenv("RECONCILE_FLAT_CONFIRM_POLLS", "3")))
_APP_STARTED_AT = time.time()
_RECONCILE_FLAT_STREAK = {}
BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC = max(
    15, int(os.getenv("BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC", "60"))
)
_BREAKEVEN_LAST_EX_SYNC: dict[int, float] = {}
KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98"))
TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT")
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, int(os.getenv("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 = int(os.getenv("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 = float(os.getenv("RISK_PERCENT", "2"))
BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0"))
BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02"))
BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0"))
ORDER_MONITOR_TYPE_MANUAL = "下单监控"
ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控"
# KEY_MONITOR_AUTO_TYPES / KEY_MONITOR_ALERT_ONLY_TYPES:见 key_monitor_lib
KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5"))
KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5"))
KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1"))
KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30")))

MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20")))
KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3"))
KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03"))
KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5"))
KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2"))
KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1"))
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() 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 永续
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_OPTIONS_API_KEY and OKX_OPTIONS_API_SECRET and OKX_OPTIONS_API_PASSPHRASE:
    exchange_options.apiKey = OKX_OPTIONS_API_KEY
    exchange_options.secret = OKX_OPTIONS_API_SECRET
    exchange_options.password = OKX_OPTIONS_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 _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms):
    lim = max(2, int(limit or ORDER_CHART_LIMIT))
    try:
        if not end_ts_ms:
            ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim)
        else:
            period = _timeframe_period_ms(timeframe)
            since = int(end_ts_ms) - period * (lim + 10)
            ohlcv = exchange.fetch_ohlcv(
                exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20
            )
    except Exception:
        return []
    rows = _ohlcv_to_rows(ohlcv)
    if not rows:
        return []
    if not end_ts_ms:
        return _ohlcv_dict_rows_to_lists(rows, lim)
    filtered = [r for r in rows if int(r["ts"]) <= int(end_ts_ms)]
    if len(filtered) >= 2:
        return _ohlcv_dict_rows_to_lists(filtered, lim)
    return _ohlcv_dict_rows_to_lists(rows, lim)


def generate_multi_timeframe_chart_png(
    exchange_symbol,
    title_prefix,
    timeframes=None,
    limit=None,
    out_dir=None,
    filename=None,
    filename_prefix="chart",
    marker_payload=None,
    marker_timeframes=None,
    layout="grid",
):
    if not ORDER_CHART_ENABLED:
        return None
    if not Image:
        return None
    requested = list(timeframes or ORDER_CHART_TFS)
    limit = limit or ORDER_CHART_LIMIT
    if layout == "vertical":
        timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
    else:
        preferred_layout = ["5m", "15m", "1h", "4h"]
        requested_set = set(requested or [])
        ordered = [tf for tf in preferred_layout if tf in requested_set]
        for tf in requested:
            if tf not in ordered:
                ordered.append(tf)
        timeframes = ordered[:4] if ordered else preferred_layout

    ensure_markets_loaded()
    panels = []
    cell_w, cell_h = 980, 520
    end_ts_ms = None
    if marker_payload:
        try:
            end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None
        except (TypeError, ValueError):
            end_ts_ms = None
    default_marker_tfs = {str(t).strip().lower() for t in timeframes}
    price_levels = price_levels_from_marker_payload(marker_payload)
    for tf in timeframes:
        rows = []
        try:
            if layout == "vertical" and marker_payload:
                win = trade_review_fetch_window(
                    marker_payload.get("entry_ts_ms"),
                    marker_payload.get("exit_ts_ms"),
                    tf,
                    limit,
                    anchor=marker_payload.get("chart_anchor"),
                    now_ms=marker_payload.get("now_ts_ms"),
                )
                if win:
                    ohlcv = exchange.fetch_ohlcv(
                        exchange_symbol,
                        timeframe=tf,
                        since=max(0, int(win["since_ms"])),
                        limit=int(win["fetch_limit"]),
                    )
                    rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win)
            if not rows:
                ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
                if not ohlcv and end_ts_ms:
                    ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit)
                rows = _ohlcv_to_rows(ohlcv)[-limit:]
        except Exception:
            rows = []
        title = f"{title_prefix} | {tf} x{len(rows)}"
        tf_key = str(tf).strip().lower()
        if marker_payload:
            if marker_timeframes:
                marker_tfs = {str(x).strip().lower() for x in marker_timeframes if str(x).strip()}
            else:
                marker_tfs = default_marker_tfs
        else:
            marker_tfs = set()
        points = (
            marker_points_for_timeframe(rows, marker_payload)
            if marker_payload and tf_key in marker_tfs
            else []
        )
        panels.append(
            render_candles_subplot(
                rows,
                title,
                width=cell_w,
                height=cell_h,
                bg_rgb=(255, 255, 255),
                marker_points=points,
                price_levels=price_levels,
            )
        )

    if not panels:
        return None

    out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10)
    if out is None:
        return None

    target_dir = out_dir or ORDER_CHART_DIR
    os.makedirs(target_dir, exist_ok=True)
    fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png"
    out_path = os.path.join(target_dir, fname)
    out.save(out_path, format="PNG")
    return fname


def generate_order_open_chart(
    exchange_symbol,
    title_prefix,
    timeframes=None,
    limit=None,
    opened_at_ms=None,
    entry_price=None,
):
    marker_payload = None
    if opened_at_ms:
        marker_payload = {
            "entry_ts_ms": opened_at_ms,
            "exit_ts_ms": None,
            "entry_price": entry_price,
            "exit_price": None,
        }
    marker_tfs = (
        {x.strip().lower() for x in (timeframes or ORDER_CHART_TFS) if x and str(x).strip()}
        or {"5m", "15m", "1h", "4h"}
    )
    return generate_multi_timeframe_chart_png(
        exchange_symbol,
        title_prefix,
        timeframes=timeframes,
        limit=limit,
        out_dir=ORDER_CHART_DIR,
        filename=None,
        filename_prefix="order",
        marker_payload=marker_payload,
        marker_timeframes=marker_tfs,
    )


def journal_coin_from_symbol(symbol):
    sym = (symbol or "").strip().upper()
    if not sym:
        return ""
    if "/" in sym:
        return sym.split("/")[0].strip()
    if "-" in sym:
        return sym.split("-")[0].strip()
    if sym.endswith("USDT"):
        return sym[:-4].strip()
    return sym


EARLY_EXIT_TRIGGERS = (
    "",
    "止盈",
    "保本止盈",
    "移动止盈",
    TIME_CLOSE_RESULT,
    "强制清仓",
    "手动平仓",
    "止损",
    "其他",
)

# 趋势户:复盘开仓类型仅 entry model;策略/风格项已拆至下单类型
ENTRY_REASON_OPTIONS = build_journal_entry_reason_options()

STATS_SEGMENT_DEFS = (
    ("all", "全部交易", {"segment": "all"}),
    ("manual", "下单监控", {"segment": "manual"}),
    ("key_box", "关键位箱体突破", {"segment": "key_box"}),
    ("key_conv", "关键位收敛结构", {"segment": "key_conv"}),
    ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}),
    ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}),
    ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),
    ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),
)
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.strategy.strategy_db import init_strategy_tables
    from lib.options.options_db import init_options_tables
    from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables

    init_strategy_tables(conn)
    init_options_tables(conn)
    init_hedge_plan_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():
    if not is_full_margin_mode(POSITION_SIZING_MODE):
        return
    conn = get_db()
    try:
        purge_disallowed_key_monitors(
            conn,
            sizing_mode=POSITION_SIZING_MODE,
            select_rows=lambda c: c.execute("SELECT * FROM key_monitors").fetchall(),
            cancel_fib_limit=_cancel_fib_monitor_limit,
            delete_monitor=lambda c, kid: c.execute("DELETE FROM key_monitors WHERE id=?", (kid,)),
            send_wechat=send_wechat_msg,
        )
        conn.commit()
    except Exception as e:
        print(f"[full_margin] purge key monitors: {e}", flush=True)
    finally:
        conn.close()


def get_db():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn


def hub_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.strategy.strategy_trade_labels 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_box":
        return kst == "箱体突破"
    if segment_key == "key_conv":
        return kst == "收敛突破"
    if segment_key == "key_fib618":
        return kst == "斐波回调0.618"
    if segment_key == "key_fib786":
        return kst == "斐波回调0.786"
    if segment_key == "key_false_breakout":
        return kst == FALSE_BREAKOUT_MONITOR_TYPE
    if segment_key == "key_trigger":
        return kst in TRIGGER_ENTRY_MONITOR_TYPES
    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]
    kst_map = {
        "key_box": "箱体突破",
        "key_conv": "收敛突破",
        "key_fib618": "斐波回调0.618",
        "key_fib786": "斐波回调0.786",
        "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,
        "key_trigger": None,  # 见 _count_opens_for_segment 多类型
    }
    if segment_key == "key_trigger":
        placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES))
        return conn.execute(
            f"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? "
            f"AND key_signal_type IN ({placeholders})",
            (start_td, end_td, *TRIGGER_ENTRY_MONITOR_TYPES),
        ).fetchone()[0]
    kst = kst_map.get(segment_key)
    if kst:
        return conn.execute(
            "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?",
            (start_td, end_td, kst),
        ).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 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]
        dm = _compute_period_metrics(day_tr)
        wm = _compute_period_metrics(week_tr)
        mm = _compute_period_metrics(month_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)
        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}(北京自然月)"
        return dm, wm, mm

    segments = []
    seg_defs = effective_stats_segment_defs(
        STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED
    )
    for seg_key, seg_title, _meta in seg_defs:
        dm, wm, mm = slice_metrics(seg_key)
        segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm})

    dm, wm, mm = slice_metrics("all")

    return {
        "trading_day": trading_day,
        "total_opens_all": total_opens_all,
        "day": dm,
        "week": wm,
        "month": mm,
        "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):
    if value in (None, ""):
        return "-"
    try:
        v = float(value)
    except Exception:
        return str(value)
    if v == 0:
        return "0"
    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):
    try:
        trigger = float(trigger_price)
        exit_p = float(exit_price)
        margin = float(margin_capital)
        lev = float(leverage)
        if trigger <= 0:
            return 0.0
        if direction == "short":
            pnl_ratio = (trigger - exit_p) / trigger
        else:
            pnl_ratio = (exit_p - trigger) / trigger
        return round(margin * lev * pnl_ratio, 4)
    except Exception:
        return 0.0


def calc_rr_ratio(direction, entry_price, stop_loss, take_profit):
    """
    计划盈亏比 = 盈利空间 / 亏损空间(展示为 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,
        )
    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 kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst) or is_false_breakout_key_monitor_type(kst):
        return kst
    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

    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
    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(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.hub.hub_position_metrics 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.hub.hub_position_metrics 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 classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price):
    """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None."""
    try:
        tp = float(take_profit)
        sl = float(stop_loss)
        ex = float(exit_price)
        trig = float(trigger_price)
    except (TypeError, ValueError):
        return None
    band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12)
    if direction == "long":
        if ex >= tp - band:
            return "止盈"
        if ex <= sl + band:
            return "止损"
    else:
        if ex <= tp + band:
            return "止盈"
        if ex >= sl - band:
            return "止损"
    return None


def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None):
    """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None."""
    if not (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_hub_external_close(conn, symbol, direction):
    from lib.hub.hub_reconcile_flat_lib import reconcile_hub_external_close_impl
    from lib.hub.hub_symbol_lib import symbols_match

    global _RECONCILE_FLAT_STREAK

    return reconcile_hub_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
    _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 cancel_fib_limit_order(exchange_symbol, order_id):
    if not order_id:
        return False
    ok_live, _ = ensure_okx_live_ready()
    if not ok_live:
        return False
    ensure_markets_loaded()
    oid = str(order_id)
    try:
        exchange.cancel_order(oid, exchange_symbol)
        return True
    except Exception:
        pass
    try:
        for o in exchange.fetch_open_orders(exchange_symbol) or []:
            if str(o.get("id")) == oid:
                exchange.cancel_order(oid, exchange_symbol)
                return True
    except Exception:
        pass
    return False


def fib_limit_order_status(exchange_symbol, order_id):
    if not order_id:
        return "missing"
    ensure_markets_loaded()
    oid = str(order_id)
    try:
        o = exchange.fetch_order(oid, exchange_symbol)
        st = (o.get("status") or "").lower()
        if st in ("closed", "filled"):
            filled = float(o.get("filled") or 0)
            if filled > 0 or st == "filled":
                return "filled"
        if st in ("canceled", "cancelled", "expired", "rejected"):
            return "canceled"
        if st in ("open", "new", "partially_filled", "live"):
            return "open"
    except Exception:
        pass
    try:
        for o in fetch_okx_all_open_orders(exchange, exchange_symbol):
            if str(o.get("id")) == oid:
                return "open"
    except Exception:
        pass
    return "unknown"


def place_fib_limit_order(
    exchange_symbol,
    direction,
    amount,
    leverage,
    limit_price,
    stop_loss=None,
    take_profit=None,
):
    ensure_markets_loaded()
    exchange.set_leverage(leverage, exchange_symbol)
    side = "buy" if direction == "long" else "sell"
    price = round_price_to_exchange(exchange_symbol, float(limit_price))
    if price is None or price <= 0:
        raise ValueError("挂单价无效")
    params = build_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",
            }
        ]
    return exchange.create_order(exchange_symbol, "limit", side, amount, price, params)


def _fib_key_exists_for_symbol(conn, symbol):
    ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES))
    row = conn.execute(
        f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})",
        (symbol, *tuple(FIB_KEY_MONITOR_TYPES)),
    ).fetchone()
    return row is not None


def _fib_plan_for_row(row):
    typ = (row["monitor_type"] or "").strip()
    ratio = fib_ratio_from_type(typ)
    if ratio is None:
        return None
    return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio)


def _limit_key_plan_for_row(row):
    typ = (row["monitor_type"] or "").strip()
    if is_fib_key_monitor_type(typ):
        return _fib_plan_for_row(row)
    if is_false_breakout_key_monitor_type(typ):
        direction = (row["direction"] or "long").lower()
        key_px = key_price_from_row(direction, row["upper"], row["lower"])
        if key_px is None:
            return None
        return calc_false_breakout_plan(direction, key_px)
    return None


def _cancel_fib_monitor_limit(row):
    ex_sym = normalize_okx_symbol(row["symbol"])
    oid = _sqlite_row_val(row, "fib_limit_order_id")
    if oid:
        cancel_fib_limit_order(ex_sym, oid)


def _fib_has_live_position(exchange_symbol, direction):
    live = get_live_position_contracts(exchange_symbol, direction)
    return live is not None and float(live) > 0


def _insert_order_monitor_from_fib_fill(
    conn,
    row,
    trigger_price,
    stop_loss,
    take_profit,
    amount,
    leverage,
    margin_capital,
    notional_value,
    position_ratio,
    base_amount,
    exchange_order_id,
):
    symbol = row["symbol"]
    direction = (row["direction"] or "long").lower()
    exchange_symbol = normalize_okx_symbol(symbol)
    typ = (row["monitor_type"] or "").strip()
    now = app_now()
    trading_day = get_trading_day(now)
    trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower()
    if trade_style not in ("trend", "swing"):
        trade_style = "trend"
    risk_percent = max(0.01, float(RISK_PERCENT))
    risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage)
    if risk_amount_final is None:
        risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4)
    breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
    breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
    breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
    if direction == "short":
        breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0)
    else:
        breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0)
    breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
    opened_at_bj = app_now_str()
    opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
    tc_en, tc_h, _ = time_close_settings_from_row(row)
    tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms)
    conn.execute(
        "INSERT INTO order_monitors "
        "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, "
        "margin_capital, leverage, trade_style, risk_percent, risk_amount, "
        "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, "
        "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, "
        "time_close_enabled, time_close_hours, time_close_at_ms) "
        "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (
            symbol,
            exchange_symbol,
            direction,
            trigger_price,
            stop_loss,
            stop_loss,
            take_profit,
            margin_capital,
            leverage,
            trade_style,
            risk_percent,
            risk_amount_final,
            breakeven_rr_trigger,
            breakeven_offset_pct,
            breakeven_step_r,
            0,
            breakeven_price,
            1 if breakeven_enabled_from_row(row, 0) else 0,
            notional_value,
            position_ratio,
            base_amount,
            amount,
            exchange_order_id or "",
            opened_at_bj,
            opened_at_ms,
            trading_day,
            ORDER_MONITOR_TYPE_KEY_AUTO,
            stored_key_signal_type(typ),
            tc_en,
            tc_h,
            tc_at,
        ),
    )
    return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])


def _finalize_fib_key_fill(conn, row):
    symbol = row["symbol"]
    direction = (row["direction"] or "long").lower()
    typ = (row["monitor_type"] or "").strip()
    kind = "假突破" if is_false_breakout_key_monitor_type(typ) else "斐波"
    ex_sym = normalize_okx_symbol(symbol)
    plan = _limit_key_plan_for_row(row)
    if not plan:
        _finalize_key_monitor_one_shot(conn, row, f"{kind}计划无效", "fib_plan_invalid")
        return
    entry_plan, sl_plan, tp_plan = plan
    sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan)
    tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan)
    sl_adj = round_price_to_exchange(ex_sym, sl)
    tp_adj = round_price_to_exchange(ex_sym, tp)
    if sl_adj is not None:
        sl = float(sl_adj)
    if tp_adj is not None:
        tp = float(tp_adj)
    amount = float(_sqlite_row_val(row, "fib_order_amount") or 0)
    leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5)
    margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0)
    oid = _sqlite_row_val(row, "fib_limit_order_id")
    entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan)
    trigger_price = entry_px
    if oid:
        try:
            o = exchange.fetch_order(str(oid), ex_sym)
            trigger_price = resolve_order_entry_price(o, ex_sym, entry_px)
        except Exception:
            pass
    tr_adj = round_price_to_exchange(ex_sym, trigger_price)
    if tr_adj is not None:
        trigger_price = float(tr_adj)
    if amount <= 0:
        live_amt = get_live_position_contracts(ex_sym, direction)
        amount = float(live_amt or 0)
    if amount <= 0:
        msg = (
            f"# ❌ {symbol} {kind}成交后处理失败\n"
            f"**账户:{_wechat_account_label()}**\n"
            f"- 无法取得持仓/下单数量,未挂 TP/SL\n"
        )
        send_wechat_msg(msg)
        _finalize_key_monitor_one_shot(conn, row, msg, "fib_fill_no_amount")
        return
    ok, reason = precheck_risk(conn, symbol, direction)
    if not ok:
        msg = (
            f"# ❌ {symbol} {kind}成交后风控拒绝\n"
            f"**账户:{_wechat_account_label()}**\n"
            f"- 类型:{typ}\n"
            f"- 原因:{reason}\n"
            f"- 请手动处理仓位与挂单\n"
        )
        send_wechat_msg(msg)
        _finalize_key_monitor_one_shot(conn, row, msg, "fib_risk_rejected")
        return
    tpsl_attached = False
    try:
        slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=sl, plan_tp=tp)
        if slots.get("sl") and slots.get("tp"):
            tpsl_attached = True
        else:
            _okx_place_tp_sl_orders(ex_sym, direction, amount, sl, tp)
            slots2 = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=sl, plan_tp=tp)
            tpsl_attached = bool(slots2.get("sl") and slots2.get("tp"))
    except Exception as e:
        msg = (
            f"# ❌ {symbol} {kind}成交后挂 TP/SL 失败\n"
            f"**账户:{_wechat_account_label()}**\n"
            f"- 错误:{friendly_okx_error(e)}\n"
            f"- 请手动补挂止盈止损\n"
        )
        send_wechat_msg(msg)
        _finalize_key_monitor_one_shot(conn, row, msg, "fib_tpsl_failed")
        return
    contract_size = get_contract_size(ex_sym)
    base_amount = round(float(amount) * contract_size, 8)
    notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0
    session_row = ensure_session(conn, get_trading_day(app_now()))
    capital_base = float(session_row["current_capital"] or 0)
    position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0
    planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp)
    new_order_id = _insert_order_monitor_from_fib_fill(
        conn,
        row,
        trigger_price,
        sl,
        tp,
        amount,
        leverage,
        margin_capital,
        notional_value,
        position_ratio,
        base_amount,
        oid,
    )
    rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-"
    close_reason = "false_breakout_filled" if is_false_breakout_key_monitor_type(typ) else "fib_filled"
    succ = (
        f"# ✅ {symbol} {kind}限价成交\n"
        f"**账户:{_wechat_account_label()}**\n"
        f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n"
        f"- 类型:{typ}｜{_wechat_direction_text(direction)}\n"
        f"- 订单 ID:**{new_order_id}**\n"
        f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n"
        f"- 止损:{format_wechat_scalar_2dp(sl)}｜止盈:{format_price_for_symbol(symbol, tp)}\n"
        f"- 计划 RR:{rr_txt}:1\n"
        f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n"
    )
    send_wechat_msg(succ)
    _finalize_key_monitor_one_shot(conn, row, succ, close_reason)


def _trigger_entry_exists_for_symbol(conn, symbol):
    placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES))
    row = conn.execute(
        f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({placeholders})",
        (symbol, *TRIGGER_ENTRY_MONITOR_TYPES),
    ).fetchone()
    return row is not None


def _add_trigger_entry_key_monitor(
    conn,
    symbol,
    direction_sel,
    entry,
    sl,
    tp,
    monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
    breakeven_enabled=0,
    time_close_enabled=0,
    time_close_hours=None,
):
    mt = (monitor_type or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip()
    if mt not in TRIGGER_ENTRY_MONITOR_TYPES:
        mt = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
    if _trigger_entry_exists_for_symbol(conn, symbol):
        return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)"
    ex_sym = normalize_exchange_symbol(symbol)
    mark = get_symbol_mark_price(symbol)
    geom_err = validate_trigger_entry_geometry(
        direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt
    )
    if geom_err:
        return False, geom_err
    rr_err = validate_trigger_entry_rr(
        direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio
    )
    if rr_err:
        return False, rr_err
    entry = float(round_price_to_exchange(ex_sym, entry) or entry)
    sl = float(round_price_to_exchange(ex_sym, sl) or sl)
    tp = float(round_price_to_exchange(ex_sym, tp) or tp)
    geom_err = validate_trigger_entry_geometry(
        direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt
    )
    if geom_err:
        return False, geom_err
    rr_err = validate_trigger_entry_rr(
        direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio
    )
    if rr_err:
        return False, rr_err
    ok_live, reason_live = ensure_exchange_live_ready()
    if not ok_live:
        return False, reason_live
    now = app_now()
    trading_day = get_trading_day(now)
    opens_today = count_opens_for_trading_day(conn, trading_day)
    ok_intent, intent_msg = check_trigger_entry_intent_limit(
        conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT
    )
    if not ok_intent:
        return False, intent_msg
    if is_full_margin_mode(POSITION_SIZING_MODE):
        ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
        if not ok_flat:
            return False, flat_msg
        if count_pending_trigger_entries(conn, trading_day) > 0:
            return False, "全仓杠杆模式下仅允许一条待触发触价监控"
    session_row = ensure_session(conn, trading_day)
    _, trading_capital_live = get_exchange_capitals(force=True)
    live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
    capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)
    available_usdt = get_available_trading_usdt()
    if is_full_margin_mode(POSITION_SIZING_MODE):
        leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
        sizing, sizing_err = compute_full_margin_sizing(
            symbol=symbol,
            available_usdt=available_usdt if available_usdt is not None else 0.0,
            capital_base=capital_base,
            buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
            btc_leverage=BTC_LEVERAGE,
            alt_leverage=ALT_LEVERAGE,
            funds_decimals=2,
        )
        if sizing_err:
            return False, sizing_err
        margin_capital = float(sizing["margin_capital"])
        amount_plan = None
    else:
        default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol)
        leverage = int(default_leverage) if default_leverage else 5
        if leverage <= 0:
            leverage = 5
        risk_fraction = calc_risk_fraction(direction_sel, entry, sl)
        if risk_fraction is None:
            return False, "止损方向不合法(相对计划入场价)"
        risk_percent = max(0.01, float(RISK_PERCENT))
        risk_amount = round(capital_base * risk_percent / 100.0, 4)
        notional_value = round(risk_amount / risk_fraction, 4)
        margin_capital = round(notional_value / leverage, 4)
        if capital_base and margin_capital > capital_base:
            return False, "以损定仓后保证金超过当前交易资金"
        if available_usdt is not None:
            max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
            if margin_capital > max_margin:
                return (
                    False,
                    f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
                )
        try:
            amount_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry)
        except Exception as e:
            return False, friendly_exchange_error(e, available_usdt=available_usdt)
    upper_px = round_price_to_exchange(ex_sym, max(entry, tp))
    lower_px = round_price_to_exchange(ex_sym, min(entry, sl))
    if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px):
        upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl))
        if upper_px <= lower_px:
            lower_px = upper_px * 0.9999
    be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0
    tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None)
    conn.execute(
        "INSERT INTO key_monitors "
        "(symbol, monitor_type, direction, upper, lower, "
        "fib_entry_price, fib_stop_loss, fib_take_profit, "
        "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, "
        "time_close_enabled, time_close_hours, session_date) "
        "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (
            symbol,
            mt,
            direction_sel,
            float(upper_px),
            float(lower_px),
            entry,
            sl,
            tp,
            float(amount_plan) if amount_plan is not None else None,
            margin_capital,
            leverage,
            be_flag,
            tc_en,
            tc_h,
            trading_day,
        ),
    )
    return True, None


def _market_open_for_trigger_entry(
    conn,
    symbol,
    direction,
    exchange_symbol,
    entry_price,
    stop_loss,
    take_profit,
    monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
    breakeven_enabled=0,
    time_close_enabled=0,
    time_close_hours=None,
):
    """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致."""
    ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER)
    if not ok_src:
        return False, src_msg, None
    now = app_now()
    ok, reason = precheck_risk(conn, symbol, direction)
    if not ok:
        return False, f"风控拒绝下单:{reason}", None
    ok_live, reason_live = ensure_exchange_live_ready()
    if not ok_live:
        return False, reason_live, None

    trading_day = get_trading_day(now)
    opens_today_before = count_opens_for_trading_day(conn, trading_day)
    session_row = ensure_session(conn, trading_day)
    _, trading_capital_live = get_exchange_capitals(force=True)
    live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
    capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)

    trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower()
    if trade_style not in ("trend", "swing"):
        trade_style = "trend"

    available_usdt = get_available_trading_usdt()
    live_price = get_symbol_mark_price(symbol) or get_price(symbol)
    if live_price is None:
        return False, "获取标记价/实时价失败", None
    try:
        ensure_markets_loaded()
    except Exception:
        pass
    lp_r = round_price_to_exchange(exchange_symbol, live_price)
    if lp_r is not None:
        live_price = float(lp_r)

    entry_price = float(entry_price)
    sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss))
    tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit))
    if sl_adj is not None:
        stop_loss = float(sl_adj)
    if tp_adj is not None:
        take_profit = float(tp_adj)

    planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit)
    if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR:
        rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算"
        return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)", None

    risk_percent = max(0.01, float(RISK_PERCENT))
    if is_full_margin_mode(POSITION_SIZING_MODE):
        ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
        if not ok_flat:
            return False, flat_msg, None
        leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
        sizing, sizing_err = compute_full_margin_sizing(
            symbol=symbol,
            available_usdt=available_usdt if available_usdt is not None else 0.0,
            capital_base=capital_base,
            buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
            btc_leverage=BTC_LEVERAGE,
            alt_leverage=ALT_LEVERAGE,
            funds_decimals=2,
        )
        if sizing_err:
            return False, sizing_err, None
        margin_capital = float(sizing["margin_capital"])
        notional_value = float(sizing["notional_value"])
        position_ratio = float(sizing["position_ratio"])
        risk_amount = margin_capital
    else:
        default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
        leverage = int(default_leverage) if default_leverage else 5
        if leverage <= 0:
            leverage = 5
        risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss)
        if risk_fraction is None:
            return False, "止损方向不合法(相对计划入场价)", None
        risk_amount = round(capital_base * risk_percent / 100.0, 4)
        notional_value = round(risk_amount / risk_fraction, 4)
        margin_capital = round(notional_value / leverage, 4)
        if capital_base and margin_capital > capital_base:
            return False, "以损定仓后保证金超过当前交易资金", None
        if available_usdt is not None:
            max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
            if margin_capital > max_margin:
                return (
                    False,
                    f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
                    None,
                )
        position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0

    try:
        amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
        contract_size = get_contract_size(exchange_symbol)
        base_amount = round(float(amount) * contract_size, 8)
        order_resp = place_exchange_order(
            exchange_symbol, direction, amount, leverage,
            stop_loss=stop_loss, take_profit=take_profit,
        )
        open_order_id = order_resp.get("id", "")
        tpsl_attached = bool(order_resp.get("tpsl_attached"))
        trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price)
    except Exception as e:
        return False, friendly_exchange_error(e, available_usdt=available_usdt), None

    trigger_price = round_price_to_exchange(exchange_symbol, trigger_price)
    stop_loss = round_price_to_exchange(exchange_symbol, stop_loss)
    take_profit = round_price_to_exchange(exchange_symbol, take_profit)

    opened_at_bj = app_now_str()
    opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
    planned_rr_fill = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit)
    breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
    breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
    breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
    risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage)
    if risk_amount_final is None:
        risk_amount_final = risk_amount
    else:
        try:
            risk_amount_final = round(float(risk_amount_final), 4)
        except (TypeError, ValueError):
            risk_amount_final = risk_amount

    if direction == "short":
        breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0)
    else:
        breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0)
    breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
    be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0
    tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms)
    risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent)

    conn.execute(
        "INSERT INTO order_monitors "
        "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, "
        "margin_capital, leverage, trade_style, risk_percent, risk_amount, "
        "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, "
        "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, "
        "time_close_enabled, time_close_hours, time_close_at_ms) "
        "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (
            symbol,
            exchange_symbol,
            direction,
            trigger_price,
            stop_loss,
            stop_loss,
            take_profit,
            margin_capital,
            leverage,
            trade_style,
            risk_percent_db,
            risk_amount_final,
            breakeven_rr_trigger,
            breakeven_offset_pct,
            breakeven_step_r,
            0,
            breakeven_price,
            be_enabled,
            notional_value,
            position_ratio,
            base_amount,
            amount,
            open_order_id,
            opened_at_bj,
            opened_at_ms,
            trading_day,
            ORDER_MONITOR_TYPE_KEY_AUTO,
            stored_key_signal_type(monitor_type),
            tc_en,
            tc_h,
            tc_at,
        ),
    )
    new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
    try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage)
    opens_today_after = count_opens_for_trading_day(conn, trading_day)

    return True, None, {
        "new_order_id": new_order_id,
        "open_order_id": open_order_id,
        "trigger_price": trigger_price,
        "planned_rr_fill": planned_rr_fill,
        "risk_amount_final": risk_amount_final,
        "margin_capital": margin_capital,
        "leverage": leverage,
        "amount": amount,
        "tpsl_attached": tpsl_attached,
        "opens_today_before": opens_today_before,
        "opens_today_after": opens_today_after,
        "trading_day": trading_day,
        "stop_loss": stop_loss,
        "take_profit": take_profit,
    }


def _execute_trigger_entry_cross(conn, row):
    """标记价触达计划入场:加锁防重复触发,成交成功后再删监控行."""
    symbol = row["symbol"]
    direction = (row["direction"] or "long").lower()
    ex_sym = normalize_exchange_symbol(symbol)
    entry = float(_sqlite_row_val(row, "fib_entry_price") or 0)
    sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0)
    tp = float(_sqlite_row_val(row, "fib_take_profit") or 0)
    be_en = breakeven_enabled_from_row(row, 0)
    tc_en, tc_h, _ = time_close_settings_from_row(row)

    kid = int(row["id"])
    if not acquire_trigger_entry_exec_lock(conn, kid):
        return False, "触价开仓进行中"
    conn.commit()

    try:
        ok, err, det = _market_open_for_trigger_entry(
            conn,
            symbol,
            direction,
            ex_sym,
            entry,
            sl,
            tp,
            monitor_type=(row["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE),
            breakeven_enabled=be_en,
            time_close_enabled=tc_en,
            time_close_hours=tc_h,
        )
    except Exception as e:
        release_trigger_entry_exec_lock(conn, kid)
        conn.commit()
        fail_msg = friendly_exchange_error(e)
        send_wechat_msg(
            f"# ❌ {symbol} 触价开仓异常\n"
            f"**账户:{_wechat_account_label()}**\n"
            f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
            f"- 原因:{fail_msg}\n"
        )
        insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED)
        return False, fail_msg

    if ok and det:
        conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,))
        conn.commit()
        rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-"
        msg = (
            f"# ✅ {symbol} 触价开仓成交\n"
            f"**账户:{_wechat_account_label()}**\n"
            f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E)\n"
            f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}｜{_wechat_direction_text(direction)}\n"
            f"- 订单 ID:**{det.get('new_order_id')}**\n"
            f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
            f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n"
            f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}｜止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n"
            f"- 计划 RR:{rr_txt}:1\n"
            f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n"
        )
        send_wechat_msg(msg)
        insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED)
        return True, None
    release_trigger_entry_exec_lock(conn, kid)
    conn.commit()
    fail_msg = err or "触价触发后开仓失败"
    send_wechat_msg(
        f"# ❌ {symbol} 触价开仓失败\n"
        f"**账户:{_wechat_account_label()}**\n"
        f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
        f"- 原因:{fail_msg}\n"
    )
    insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED)
    return False, fail_msg


def check_trigger_entry_key_monitors():
    if not KEY_AUTO_ORDER_ENABLED:
        return
    conn = get_db()
    placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES))
    rows = conn.execute(
        f"SELECT * FROM key_monitors WHERE monitor_type IN ({placeholders})",
        tuple(TRIGGER_ENTRY_MONITOR_TYPES),
    ).fetchall()
    now_dt = app_now()
    for r in rows:
        symbol = r["symbol"]
        direction = (r["direction"] or "long").lower()
        mt = (r["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip()
        entry = float(_sqlite_row_val(r, "fib_entry_price") or 0)
        sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0)
        tp = float(_sqlite_row_val(r, "fib_take_profit") or 0)
        kid = int(r["id"])
        if is_trigger_entry_in_flight_row(r):
            continue
        if entry <= 0 or sl <= 0 or tp <= 0:
            _finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid")
            continue
        mark = get_symbol_mark_price(symbol)
        if mark is None:
            continue
        prev_mark = _sqlite_row_val(r, "last_mark_price")
        prev_mark_f = float(prev_mark) if prev_mark not in (None, "") else None
        if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS):
            exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS)
            msg = (
                f"# ⚠️ {symbol} 触价开仓已过期\n"
                f"**账户:{_wechat_account_label()}**\n"
                f"- 类型:{mt}｜{_wechat_direction_text(direction)}\n"
                f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n"
            )
            send_wechat_msg(msg)
            _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED)
            continue
        inv = trigger_entry_invalidate(mt, direction, mark, sl, tp)
        if inv == "tp":
            msg = (
                f"# ⚠️ {symbol} 触价开仓失效\n"
                f"**账户:{_wechat_account_label()}**\n"
                f"- 类型:{mt}｜标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n"
            )
            send_wechat_msg(msg)
            _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE)
            continue
        if inv == "sl":
            msg = (
                f"# ⚠️ {symbol} 触价开仓失效\n"
                f"**账户:{_wechat_account_label()}**\n"
                f"- 类型:{mt}｜标记价 {format_price_for_symbol(symbol, mark)} 已触达止损侧(未突破)\n"
            )
            send_wechat_msg(msg)
            _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_SL_INVALIDATE)
            continue
        if trigger_should_fire(mt, direction, mark, entry, prev_mark_f):
            _execute_trigger_entry_cross(conn, r)
            continue
        conn.execute("UPDATE key_monitors SET last_mark_price=? WHERE id=?", (float(mark), kid))
    conn.commit()
    conn.close()


def check_fib_key_monitors():
    if not KEY_AUTO_ORDER_ENABLED:
        return
    conn = get_db()
    rows = conn.execute("SELECT * FROM key_monitors").fetchall()
    for r in rows:
        typ = (r["monitor_type"] or "").strip()
        if not is_limit_key_monitor_type(typ):
            continue
        symbol = r["symbol"]
        direction = (r["direction"] or "long").lower()
        ex_sym = normalize_okx_symbol(symbol)
        up, low = float(r["upper"]), float(r["lower"])
        oid = _sqlite_row_val(r, "fib_limit_order_id")
        if is_false_breakout_key_monitor_type(typ):
            now_dt = app_now()
            if is_false_breakout_expired(r["created_at"], now_dt):
                _cancel_fib_monitor_limit(r)
                exp_txt = expires_at_text(r["created_at"])
                msg = (
                    f"# ⚠️ {symbol} 假突破监控已过期\n"
                    f"**账户:{_wechat_account_label()}**\n"
                    f"- 类型:{typ}｜{_wechat_direction_text(direction)}\n"
                    f"- 有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h(应于 {exp_txt} 前成交)\n"
                    f"- 已撤销限价单\n"
                )
                send_wechat_msg(msg)
                _finalize_key_monitor_one_shot(conn, r, msg, "false_breakout_expired")
                continue
        mark = get_symbol_mark_price(symbol)
        if mark is None:
            continue
        status = fib_limit_order_status(ex_sym, oid) if oid else "missing"
        if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)):
            _finalize_fib_key_fill(conn, r)
            continue
        if is_fib_key_monitor_type(typ) and status == "open":
            if fib_invalidate_by_mark(direction, mark, up, low):
                _cancel_fib_monitor_limit(r)
                msg = (
                    f"# ⚠️ {symbol} 斐波监控失效\n"
                    f"**账户:{_wechat_account_label()}**\n"
                    f"- 类型:{typ}｜{_wechat_direction_text(direction)}\n"
                    f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n"
                )
                send_wechat_msg(msg)
                _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate")
            continue
        if is_fib_key_monitor_type(typ) and status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low):
            msg = (
                f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n"
                f"**账户:{_wechat_account_label()}**\n"
                f"- 标记价触达止盈侧,本条已结案\n"
            )
            send_wechat_msg(msg)
            _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate")
    conn.commit()
    conn.close()


def _false_breakout_exists_for_symbol(conn, symbol):
    row = conn.execute(
        "SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?",
        (symbol, FALSE_BREAKOUT_MONITOR_TYPE),
    ).fetchone()
    return row is not None


def _add_false_breakout_key_monitor(
    conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0,
    time_close_enabled=0, time_close_hours=None,
):
    if _false_breakout_exists_for_symbol(conn, symbol):
        return False, f"{symbol} 已有假突破监控(同币仅允许一条)"
    plan = calc_false_breakout_plan(direction_sel, key_px)
    if not plan:
        return False, "假突破价位无效,请核对方向与关键价位"
    entry, sl, tp = plan
    ex_sym = normalize_okx_symbol(symbol)
    entry = round_price_to_exchange(ex_sym, entry)
    sl = round_price_to_exchange(ex_sym, sl)
    tp = round_price_to_exchange(ex_sym, tp)
    if entry is None or sl is None or tp is None:
        return False, "假突破价位经交易所精度舍入后无效"
    entry, sl, tp = float(entry), float(sl), float(tp)
    ok, reason = precheck_risk(conn, symbol, direction_sel)
    if not ok:
        return False, reason
    ok_live, reason_live = ensure_exchange_live_ready()
    if not ok_live:
        return False, reason_live
    now = app_now()
    trading_day = get_trading_day(now)
    session_row = ensure_session(conn, trading_day)
    _, trading_capital_live = get_exchange_capitals(force=True)
    live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
    capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)
    default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol)
    leverage = int(default_leverage) if default_leverage else 5
    if leverage <= 0:
        leverage = 5
    available_usdt = get_available_trading_usdt()
    risk_fraction = calc_risk_fraction(direction_sel, entry, sl)
    if risk_fraction is None:
        return False, "止损方向不合法(相对挂单价);请核对方向与关键价位"
    risk_percent = max(0.01, float(RISK_PERCENT))
    risk_amount = round(capital_base * risk_percent / 100.0, 4)
    notional_value = round(risk_amount / risk_fraction, 4)
    margin_capital = round(notional_value / leverage, 4)
    if capital_base and margin_capital > capital_base:
        return False, "以损定仓后保证金超过当前交易资金"
    if available_usdt is not None:
        max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
        if margin_capital > max_margin:
            return (
                False,
                f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
            )
    try:
        amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry)
        order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry)
        oid = str(order_resp.get("id") or "")
        if not oid:
            return False, "交易所未返回限价单 ID"
    except Exception as e:
        return False, friendly_okx_error(e, available_usdt=available_usdt)
    be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0
    tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None)
    conn.execute(
        "INSERT INTO key_monitors "
        "(symbol, monitor_type, direction, upper, lower, "
        "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, "
        "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, time_close_enabled, time_close_hours) "
        "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (
            symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px,
            oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h,
        ),
    )
    return True, None


def _add_fib_key_monitor(
    conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0,
    time_close_enabled=0, time_close_hours=None,
):
    if _fib_key_exists_for_symbol(conn, symbol):
        return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)"
    ratio = fib_ratio_from_type(mt)
    plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio)
    if not plan:
        return False, "斐波上下沿无效(需上沿 H > 下沿 L)"
    entry, sl, tp = plan
    ex_sym = normalize_okx_symbol(symbol)
    entry = round_price_to_exchange(ex_sym, entry)
    sl = round_price_to_exchange(ex_sym, sl)
    tp = round_price_to_exchange(ex_sym, tp)
    if entry is None or sl is None or tp is None:
        return False, "斐波价位经交易所精度舍入后无效"
    entry, sl, tp = float(entry), float(sl), float(tp)
    planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp)
    if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR:
        fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算"
        return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)"
    ok, reason = precheck_risk(conn, symbol, direction_sel)
    if not ok:
        return False, reason
    ok_live, reason_live = ensure_okx_live_ready()
    if not ok_live:
        return False, reason_live
    now = app_now()
    trading_day = get_trading_day(now)
    session_row = ensure_session(conn, trading_day)
    _, trading_capital_live = get_exchange_capitals(force=True)
    live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
    capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)
    default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol)
    leverage = int(default_leverage) if default_leverage else 5
    if leverage <= 0:
        leverage = 5
    available_usdt = get_available_trading_usdt()
    risk_fraction = calc_risk_fraction(direction_sel, entry, sl)
    if risk_fraction is None:
        return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向"
    risk_percent = max(0.01, float(RISK_PERCENT))
    risk_amount = round(capital_base * risk_percent / 100.0, 4)
    notional_value = round(risk_amount / risk_fraction, 4)
    margin_capital = round(notional_value / leverage, 4)
    if capital_base and margin_capital > capital_base:
        return False, "以损定仓后保证金超过当前交易资金"
    if available_usdt is not None:
        max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
        if margin_capital > max_margin:
            return (
                False,
                f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
            )
    try:
        amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry)
        order_resp = place_fib_limit_order(
            ex_sym, direction_sel, amount, leverage, entry, stop_loss=sl, take_profit=tp
        )
        oid = str(order_resp.get("id") or "")
        if not oid:
            return False, "交易所未返回限价单 ID"
    except Exception as e:
        return False, friendly_okx_error(e, available_usdt=available_usdt)
    be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0
    conn.execute(
        "INSERT INTO key_monitors "
        "(symbol, monitor_type, direction, upper, lower, "
        "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, "
        "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) "
        "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (
            symbol,
            mt,
            direction_sel,
            upper_px,
            lower_px,
            oid,
            entry,
            sl,
            tp,
            float(amount),
            margin_capital,
            leverage,
            be_flag,
        ),
    )
    return True, None


def _market_open_for_key_monitor(
    conn,
    symbol,
    direction,
    exchange_symbol,
    stop_loss,
    take_profit,
    key_signal_type=None,
    breakeven_enabled=0,
    time_close_enabled=0,
    time_close_hours=None,
):
    """
    与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(OKX 永续).
    返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict])
    """
    ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_AUTO)
    if not ok_src:
        return False, src_msg, None
    now = app_now()
    ok, reason = precheck_risk(conn, symbol, direction)
    if not ok:
        return False, f"风控拒绝下单:{reason}", None
    ok_live, reason_live = ensure_exchange_live_ready()
    if not ok_live:
        return False, reason_live, None

    default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
    leverage = int(default_leverage) if default_leverage else 5
    if leverage <= 0:
        leverage = 5

    trading_day = get_trading_day(now)
    opens_today_before = conn.execute(
        "SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
        (trading_day,),
    ).fetchone()[0]
    session_row = ensure_session(conn, trading_day)
    _, trading_capital_live = get_exchange_capitals(force=True)
    live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
    capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)

    trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower()
    if trade_style not in ("trend", "swing"):
        trade_style = "trend"

    available_usdt = get_available_trading_usdt()
    live_price = get_price(symbol)
    if live_price is None:
        return False, "获取交易所实时价格失败(以损定仓需要当前价)", None
    try:
        ensure_markets_loaded()
    except Exception:
        pass
    lp_r = round_price_to_exchange(exchange_symbol, live_price)
    if lp_r is not None:
        live_price = lp_r

    sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss))
    tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit))
    if sl_adj is not None:
        stop_loss = float(sl_adj)
    if tp_adj is not None:
        take_profit = float(tp_adj)

    risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
    if risk_fraction is None:
        return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None
    risk_percent = max(0.01, float(RISK_PERCENT))
    risk_amount = round(capital_base * risk_percent / 100.0, 4)
    notional_value = round(risk_amount / risk_fraction, 4)
    margin_capital = round(notional_value / leverage, 4)

    if capital_base and margin_capital > capital_base:
        return False, "以损定仓后保证金超过当前交易资金", None

    if available_usdt is not None:
        max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
        if margin_capital > max_margin:
            return (
                False,
                f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
                None,
            )

    position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0

    try:
        amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
        contract_size = get_contract_size(exchange_symbol)
        base_amount = round(float(amount) * contract_size, 8)
        order_resp = place_exchange_order(
            exchange_symbol, direction, amount, leverage,
            stop_loss=stop_loss, take_profit=take_profit,
        )
        open_order_id = order_resp.get("id", "")
        tpsl_attached = bool(order_resp.get("tpsl_attached"))
        trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price)
    except Exception as e:
        return False, friendly_okx_error(e, available_usdt=available_usdt), None

    trigger_price = round_price_to_exchange(exchange_symbol, trigger_price)
    stop_loss = round_price_to_exchange(exchange_symbol, stop_loss)
    take_profit = round_price_to_exchange(exchange_symbol, take_profit)

    opened_at_bj = app_now_str()
    opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)

    planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit)
    breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
    breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
    breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
    risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage)
    if risk_amount_final is None:
        risk_amount_final = risk_amount
    else:
        try:
            risk_amount_final = round(float(risk_amount_final), 4)
        except (TypeError, ValueError):
            risk_amount_final = risk_amount

    if direction == "short":
        breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0)
    else:
        breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0)
    breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
    be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0
    tc_en, tc_h, tc_at = time_close_insert_values(
        time_close_enabled, time_close_hours, opened_at_ms
    )

    conn.execute(
        "INSERT INTO order_monitors "
        "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, "
        "margin_capital, leverage, trade_style, risk_percent, risk_amount, "
        "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, "
        "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, "
        "time_close_enabled, time_close_hours, time_close_at_ms) "
        "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (
            symbol,
            exchange_symbol,
            direction,
            trigger_price,
            stop_loss,
            stop_loss,
            take_profit,
            margin_capital,
            leverage,
            trade_style,
            risk_percent,
            risk_amount_final,
            breakeven_rr_trigger,
            breakeven_offset_pct,
            breakeven_step_r,
            0,
            breakeven_price,
            be_enabled,
            notional_value,
            position_ratio,
            base_amount,
            amount,
            open_order_id,
            opened_at_bj,
            opened_at_ms,
            trading_day,
            ORDER_MONITOR_TYPE_KEY_AUTO,
            stored_key_signal_type(key_signal_type),
            tc_en,
            tc_h,
            tc_at,
        ),
    )
    new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
    opens_today_after = conn.execute(
        "SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
        (trading_day,),
    ).fetchone()[0]

    return True, None, {
        "new_order_id": new_order_id,
        "open_order_id": open_order_id,
        "trigger_price": trigger_price,
        "planned_rr_fill": planned_rr,
        "risk_amount_final": risk_amount_final,
        "margin_capital": margin_capital,
        "leverage": leverage,
        "amount": amount,
        "base_amount": base_amount,
        "notional_value": notional_value,
        "position_ratio": position_ratio,
        "tpsl_attached": tpsl_attached,
        "opens_today_before": opens_today_before,
        "opens_today_after": opens_today_after,
        "trading_day": trading_day,
        "risk_percent": risk_percent,
        "breakeven_rr_trigger": breakeven_rr_trigger,
        "breakeven_price": breakeven_price,
        "capital_base_at_open": capital_base,
    }


def 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


# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒)
def check_key_monitors():
    conn = get_db()
    rows = conn.execute("SELECT * FROM key_monitors").fetchall()
    for r in rows:
        sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"]
        typ = (typ_raw or "").strip()
        if is_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}")
            continue

        if not KEY_AUTO_ORDER_ENABLED:
            continue

        direction = (r["direction"] or "long").lower()
        if direction == KEY_DIRECTION_WATCH:
            continue
        if typ in KEY_MONITOR_AUTO_TYPES:
            mark = get_symbol_mark_price(sym)
            if mark is not None and box_breakout_invalidate_by_mark(direction, mark, up, low):
                edge = float(low) if direction == "long" else float(up)
                edge_label = box_breakout_invalidate_edge_label(direction)
                msg = (
                    f"# ⚠️ {sym} 关键位监控失效\n"
                    f"**账户:{_wechat_account_label()}**\n"
                    f"- 类型:{typ}｜{_wechat_direction_text(direction)}\n"
                    f"- 标记价 {format_price_for_symbol(sym, mark)} 已突破反向{edge_label} "
                    f"{format_price_for_symbol(sym, edge)}(设置失效)\n"
                )
                send_wechat_msg(msg)
                _finalize_key_monitor_one_shot(conn, r, msg, "box_opposite_break")
                continue
        try:
            checks = _key_hard_checks(sym, direction, up, low, typ)
        except Exception:
            checks = {"ok": False}
        if not checks.get("ok"):
            continue

        btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h")
        coin4h_status, _, _ = _status_by_ema55(sym, "4h")
        risk_tip = None
        if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"):
            risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损."

        key_price = float(low) if direction == "long" else float(up)
        hard_lines = _key_hard_lines_from_checks(checks)
        trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str()

        if typ not in KEY_MONITOR_AUTO_TYPES:
            continue

        plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks)
        if not plan_tuple:
            fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)"
            rr_msg = (
                f"# ⚠️ {sym} 关键位自动单:计划无效\n"
                f"**账户:{_wechat_account_label()}**\n"
                f"- 类型:{typ}｜方案:{sl_tp_mode_label(sl_tp_mode)}\n"
                f"- 方向:**{_wechat_direction_text(direction)}**\n"
                f"- 触发时间:`{trigger_time}`\n"
                f"- 确认K收盘(E):`{format_price_for_symbol(sym, checks.get('confirm_close'))}`\n"
                f"- **{fmt_rr}**(未开仓)\n"
                "---\n"
                "### 硬条件\n"
                + "\n".join(f"- {x}" for x in hard_lines)
            )
            if risk_tip:
                rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}"
            send_wechat_msg(rr_msg)
            _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient")
            continue
        E, sl_raw, tp_raw, box_h = plan_tuple
        exchange_symbol = normalize_okx_symbol(sym)
        try:
            ensure_markets_loaded()
        except Exception:
            pass
        sl_px = round_price_to_exchange(exchange_symbol, sl_raw)
        tp_px = round_price_to_exchange(exchange_symbol, tp_raw)
        if sl_px is not None:
            sl_raw = float(sl_px)
        if tp_px is not None:
            tp_raw = float(tp_px)

        planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw)
        rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR

        if not rr_ok:
            fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)"
            plan_line = sl_tp_plan_summary_text(
                sl_tp_mode, direction, E, sl_raw, tp_raw, box_h,
                outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT,
                trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
            )
            rr_msg = (
                f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n"
                f"**账户:{_wechat_account_label()}**\n"
                f"- 类型:{typ}｜{plan_line}\n"
                f"- 方向:**{_wechat_direction_text(direction)}**\n"
                f"- 触发时间:`{trigger_time}`\n"
                f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n"
                f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n"
                f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n"
                f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n"
                f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n"
                "---\n"
                "### 硬条件\n"
                + "\n".join(f"- {x}" for x in hard_lines)
            )
            if risk_tip:
                rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}"
            send_wechat_msg(rr_msg)
            _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient")
            continue

        key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None
        be_on = breakeven_enabled_from_row(r, 0)
        tc_en, tc_h, _ = time_close_settings_from_row(r)
        ok_trade, trade_err, det = _market_open_for_key_monitor(
            conn,
            sym,
            direction,
            exchange_symbol,
            sl_raw,
            tp_raw,
            key_signal_type=key_sig,
            breakeven_enabled=1 if be_on else 0,
            time_close_enabled=tc_en,
            time_close_hours=tc_h,
        )
        planned_rr_txt = (
            format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-"
        )
        if not ok_trade:
            fail_msg = (
                f"# ❌ {sym} 关键位自动单失败\n"
                f"**账户:{_wechat_account_label()}**\n"
                f"- 类型:{typ}\n"
                f"- 方向:**{_wechat_direction_text(direction)}**\n"
                f"- 触发时间:`{trigger_time}`\n"
                f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n"
                f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n"
                f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n"
                f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n"
                f"- **失败原因:{trade_err}**\n"
                "---\n"
                "### 硬条件\n"
                + "\n".join(f"- {x}" for x in hard_lines)
            )
            if risk_tip:
                fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}"
            send_wechat_msg(fail_msg)
            _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed")
            continue

        tpsl_txt = (
            "已在交易所挂止盈/止损触发单(OKX 条件单)"
            if det.get("tpsl_attached")
            else "⚠️ 条件单挂接状态异常或未挂上"
        )
        rr_fill = det.get("planned_rr_fill")
        rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-"

        succ_msg_lines = [
            f"# ✅ {sym} 关键位自动开仓成功",
            f"**账户:{_wechat_account_label()}**",
            f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)",
            f"- 页面订单 ID:**{det['new_order_id']}**",
            f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`",
            f"- 类型:{typ}｜方案:{sl_tp_mode_label(sl_tp_mode)}｜移动保本:{'开' if be_on else '关'}",
            f"- 方向:**{_wechat_direction_text(direction)}**",
            f"- 触发时间:`{trigger_time}`",
            f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)",
            f"- **计划 RR(E):{planned_rr_txt}:1**",
            f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**",
            f"- **成交价侧计划 RR:**{rr_fill_txt}:1",
            f"- 止损:{format_wechat_scalar_2dp(sl_raw)}",
            f"- 止盈:{format_price_for_symbol(sym, tp_raw)}",
            f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U｜基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U｜杠杆 {det.get('leverage')}x",
            f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U｜张数 {format_wechat_scalar_2dp(det.get('amount'))}｜折算标的 {det.get('base_amount')}",
            f"- **{tpsl_txt}**",
            f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}",
            f"- {format_daily_open_summary_short(det.get('opens_today_after'), DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT)}",
        ]
        succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines])
        if risk_tip:
            succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"])
        succ_msg = "\n".join(succ_msg_lines)
        send_wechat_msg(succ_msg)
        _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened")

        if should_send_daily_open_alert(
            det.get("opens_today_before", 0),
            det.get("opens_today_after", 0),
            DAILY_OPEN_ALERT_THRESHOLD,
        ):
            advice = ai_short_advice(
                build_daily_open_alert_prompt(
                    det["trading_day"],
                    det.get("opens_today_after", 0),
                    DAILY_OPEN_ALERT_THRESHOLD,
                    hard_limit=DAILY_OPEN_HARD_LIMIT,
                    detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x.",
                )
            )
            if advice:
                send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}")
    conn.commit()
    conn.close()

# 止盈止损监控(已修复:严格区分多空,无默认做多)
def check_order_monitors():
    conn = get_db()
    rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
    for r in rows:
        pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"]
        margin_capital = r["margin_capital"] or DAILY_START_CAPITAL
        leverage = r["leverage"] or infer_leverage(sym)
        session_date = r["session_date"] or get_trading_day()
        p = get_price(sym)
        if not p: continue

        # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层)
        risk_amount = float(r["risk_amount"] or 0)
        breakeven_armed = int(r["breakeven_armed"] or 0)
        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()
    # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx)
    if now.hour != FORCE_CLOSE_BJ_HOUR:
        return
    conn = get_db()
    rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
    for r in rows:
        p = get_price(r["symbol"])
        if not p:
            continue
        direction = r["direction"]
        trigger_price = r["trigger_price"]
        margin_capital = r["margin_capital"] or DAILY_START_CAPITAL
        leverage = r["leverage"] or infer_leverage(r["symbol"])
        session_date = r["session_date"] or get_trading_day(now)
        opened_at = get_opened_at_value(r)
        closed_at = now.strftime("%Y-%m-%d %H:%M:%S")
        hold_seconds = calc_hold_seconds(opened_at, now)
        pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage)
        try:
            close_resp = close_exchange_order(r)
            close_order_id = close_resp.get("id", "")
        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_fib_key_monitors()
            check_trigger_entry_key_monitors()
            _roll_cfg = app.extensions.get("strategy_roll_cfg")
            if _roll_cfg:
                from lib.strategy.strategy_roll_monitor_lib import check_roll_monitors

                check_roll_monitors(_roll_cfg)
            check_key_monitors()
            check_order_monitors()
            cfg = app.extensions.get("strategy_trend_cfg")
            if cfg:
                from lib.strategy.strategy_trend_register import check_trend_pullback_plans

                check_trend_pullback_plans(cfg)
        except Exception as e:
            print(f"[monitor_loop] {e}", flush=True)
        time.sleep(MONITOR_POLL_SECONDS)


# ====================== 登录路由 ======================
@app.route("/login", methods=["GET", "POST"])
def login():
    if AUTH_DISABLED:
        session["logged_in"] = True
        return redirect("/")
    if request.method == "POST":
        username = request.form.get("username")
        password = request.form.get("password")
        if username == USERNAME and password == PASSWORD:
            session["logged_in"] = True
            return redirect("/")
        else:
            flash("账号或密码错误")
    return render_template(
        "login.html",
        exchange_display=EXCHANGE_DISPLAY_NAME,
        pwa_app_name="OKX 交易系统",
    )

@app.route("/logout")
def logout():
    session.clear()
    return redirect("/" if AUTH_DISABLED else "/login")

# 登录校验装饰器
def login_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED):
            return f(*args, **kwargs)
        return redirect("/login")
    return decorated


@app.route("/sync_positions")
@login_required
def sync_positions():
    days_raw = (request.args.get("days") or "").strip()
    sync_days = None
    if days_raw:
        try:
            sync_days = max(1, min(365, int(days_raw)))
        except Exception:
            sync_days = None
    conn = get_db()
    synced = reconcile_external_closes(conn, days=sync_days)
    conn.commit()
    conn.close()
    if sync_days is not None:
        flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新")
    else:
        flash(f"同步完成:{synced} 笔持仓已按交易所状态更新")
    return redirect("/")


@app.route("/api/sync_positions", methods=["POST"])
@login_required
def api_sync_positions():
    payload = request.get_json(silent=True) or {}
    days_raw = str(payload.get("days", "")).strip()
    if not days_raw:
        return jsonify({"ok": False, "msg": "请填写天数"}), 400
    try:
        days = int(days_raw)
    except Exception:
        return jsonify({"ok": False, "msg": "天数必须是整数"}), 400
    if days < 1 or days > 365:
        return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400
    conn = get_db()
    synced = reconcile_external_closes(conn, days=days)
    conn.commit()
    conn.close()
    return jsonify({"ok": True, "days": days, "synced": int(synced)})


# ====================== 主页面 ======================
def render_main_page(page="trade", 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,
        total_funds_usdt,
        trade_records_summary,
    )

    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
    if (
        OKX_OPTIONS_ENABLED
        and exchange_options.apiKey
        and embed_mode != "fragment"
    ):
        try:
            from lib.exchange.okx_options_lib import options_header_balances

            options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances(
                exchange_options
            )
        except Exception:
            options_trading_usdc = None
            options_funding_usdc = None
            options_funding_usdt = None
            options_trading_usdt = None
    recommended_capital = get_recommended_capital(current_capital)
    key_list = (
        conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else []
    )
    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
    active_count = len(order_list)
    from lib.strategy.strategy_trade_labels 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 = hub_account_risk_status(conn)
    can_trade = can_trade_new_open(
        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,
        extra_blocks=not risk_status.get("can_trade", True),
    )
    key_rule_ctx = {}
    if page in ("key_monitor", "trade") or page in (
        "strategy",
        "strategy_trend",
        "strategy_roll",
        "strategy_records",
    ):
        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,
            false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS,
            trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS,
        )
    strategy_extra = {}
    if plan.strategy:
        from lib.strategy.strategy_ui import strategy_render_extras

        strategy_extra = strategy_render_extras(
            conn,
            page,
            default_risk_percent=float(RISK_PERCENT),
            request_obj=request,
            trend_cfg=app.extensions.get("strategy_trend_cfg"),
        )
    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)
    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,
            current_capital,
            options_trading_usdc,
            options_funding_usdc,
            options_funding_usdt,
            options_trading_usdt,
        ),
        options_funding_usdc=options_funding_usdc,
        options_funding_usdt=options_funding_usdt,
        options_trading_usdc=options_trading_usdc,
        options_trading_usdt=options_trading_usdt,
        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,
        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(
            effective_entry_reason_options(
                ENTRY_REASON_OPTIONS,
                POSITION_SIZING_MODE,
                KEY_AUTO_ORDER_ENABLED,
                trend_manual_count=trend_manual_entry_reason_count(TRADE_POLICY),
            )
        ),
        order_type_options=list(JOURNAL_ORDER_TYPE_OPTIONS),
        key_auto_order_enabled=KEY_AUTO_ORDER_ENABLED,
        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,
        exchange_display=EXCHANGE_DISPLAY_NAME,
        options_enabled=OKX_OPTIONS_ENABLED,
        options_nav_visible=True,
        hedge_plan_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"),
        hedge_plan_nav_visible=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"),
        options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC,
        options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY,
        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"),
        **_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,
        ),
        **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("/trade")


@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():
    redir = redirect_to_embed_shell_if_enabled("trade")
    if redir is not None:
        return redir
    return render_main_page("trade")


@app.route("/records")
@login_required
def records_page():
    redir = redirect_to_embed_shell_if_enabled("records")
    if redir is not None:
        return redir
    return render_main_page("records")


@app.route("/stats")
@login_required
def stats_page():
    redir = redirect_to_embed_shell_if_enabled("stats")
    if redir is not None:
        return redir
    return render_main_page("stats")


@app.route("/dashboard")
@login_required
def dashboard_page():
    redir = redirect_to_embed_shell_if_enabled("dashboard")
    if redir is not None:
        return redir
    return render_main_page("dashboard")


@app.route("/risk_policy")
@login_required
def risk_policy_page():
    redir = redirect_to_embed_shell_if_enabled("risk_policy")
    if redir is not None:
        return redir
    return render_main_page("risk_policy")


@app.route("/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
    if OKX_OPTIONS_ENABLED and exchange_options.apiKey:
        try:
            from lib.exchange.okx_options_lib import options_header_balances

            options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances(
                exchange_options,
                force=force_refresh,
            )
        except Exception:
            options_trading_usdc = None
            options_funding_usdc = None
            options_funding_usdt = None
            options_trading_usdt = None
    recommended_capital = get_recommended_capital(current_capital)
    from lib.strategy.strategy_trade_labels 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 = hub_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, total_funds_usdt

    header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ)
    conn.close()
    open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR
    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,
        extra_blocks=not risk_status.get("can_trade", True),
    )
    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
    return jsonify({
        "funding_usdt": funding_usdt,
        "current_capital": current_capital,
        "options_funding_usdc": options_funding_usdc,
        "options_funding_usdt": options_funding_usdt,
        "options_trading_usdc": options_trading_usdc,
        "options_trading_usdt": options_trading_usdt,
        "total_funds": total_funds_usdt(
            funding_usdt,
            current_capital,
            options_trading_usdc,
            options_funding_usdc,
            options_funding_usdt,
            options_trading_usdt,
        ),
        "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,
        "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"),
        "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.strategy.strategy_trade_labels 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/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:
        is_fib = is_fib_key_monitor_type(r["monitor_type"])
        is_fb = is_false_breakout_key_monitor_type(r["monitor_type"])
        is_te = is_trigger_entry_key_monitor_type(r["monitor_type"])
        if is_fib or is_fb or is_te:
            price = get_symbol_mark_price(r["symbol"])
        else:
            price = prices.get(r["symbol"])
        if price is None:
            continue
        upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"])
        lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"])
        gate = None
        gate_summary = "-"
        gate_metrics = ""
        fib_gate_ok = True
        fb_gate_ok = True
        te_gate_ok = True
        box_gate_ok = True
        if is_fib:
            direction = (r["direction"] or "long").lower()
            inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"])
            fib_gate_ok = not inval
            entry = _sqlite_row_val(r, "fib_entry_price")
            entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
            gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}"
            if _sqlite_row_val(r, "fib_limit_order_id"):
                gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}"
        elif is_fb:
            entry = _sqlite_row_val(r, "fib_entry_price")
            entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
            prev = false_breakout_gate_preview(
                entry_display=entry_txt,
                limit_order_id=_sqlite_row_val(r, "fib_limit_order_id"),
                created_at=_sqlite_row_val(r, "created_at"),
                now=app_now(),
            )
            gate_summary = prev.get("summary") or "-"
            gate_metrics = prev.get("metrics") or ""
            fb_gate_ok = bool(prev.get("gate_ok"))
        elif is_te:
            direction = (r["direction"] or "long").lower()
            entry = _sqlite_row_val(r, "fib_entry_price")
            tp_v = _sqlite_row_val(r, "fib_take_profit")
            entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
            tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-"
            sl_v = _sqlite_row_val(r, "fib_stop_loss")
            inv = (
                trigger_entry_invalidate(
                    r["monitor_type"], direction, price, float(sl_v or 0), float(tp_v or 0)
                )
                if tp_v
                else None
            )
            prev = trigger_entry_gate_preview(
                monitor_type=r["monitor_type"],
                entry_display=entry_txt,
                take_profit_display=tp_txt,
                created_at=_sqlite_row_val(r, "created_at"),
                now=app_now(),
                tp_invalidated=inv == "tp",
                sl_invalidated=inv == "sl",
                hours=TRIGGER_ENTRY_VALIDITY_HOURS,
            )
            gate_summary = prev.get("summary") or "-"
            gate_metrics = prev.get("metrics") or ""
            te_gate_ok = bool(prev.get("gate_ok"))
        elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:
            try:
                prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"])
                gate_summary = prev.get("summary") or "-"
                gate_metrics = prev.get("metrics") or ""
            except Exception:
                gate_summary = "-"
        elif (r["monitor_type"] or "").strip() in KEY_MONITOR_AUTO_TYPES:
            direction = (r["direction"] or "long").lower()
            if box_breakout_invalidate_by_mark(direction, price, r["upper"], r["lower"]):
                edge_label = box_breakout_invalidate_edge_label(direction)
                gate_summary = f"反向突破{edge_label}·将撤销"
                box_gate_ok = False
            else:
                try:
                    gate = _key_hard_checks(
                        r["symbol"],
                        direction,
                        r["upper"],
                        r["lower"],
                        r["monitor_type"],
                    )
                except Exception:
                    gate = None
        if gate:
            rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}"
            gate_summary = (
                f"量:{'Y' if gate.get('vol_ok') else 'N'} "
                f"破:{'Y' if gate.get('breakout_ok') else 'N'} "
                f"幅:{'Y' if gate.get('amp_ok') else 'N'} "
                f"二确:{'Y' if gate.get('confirm_ok') else 'N'} "
                f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})"
            )
            if gate.get("breakout_ok"):
                try:
                    vol_now = round(float(gate.get("vol_break") or 0), 4)
                    vol_avg = round(float(gate.get("avg20") or 0), 4)
                    amp_pct = round(float(gate.get("amp_pct") or 0), 4)
                    cfm_close = round(float(gate.get("confirm_close") or 0), 8)
                    edge = round(float(gate.get("edge_price") or 0), 8)
                    gate_metrics = (
                        f"量值:{vol_now}/{vol_avg} "
                        f"幅值:{amp_pct}% "
                        f"二确值:{cfm_close}@{edge}"
                    )
                except Exception:
                    gate_metrics = ""
        px_disp = format_price_for_symbol(r["symbol"], price)
        try:
            price_num = float(px_disp) if px_disp != "-" else float(price)
        except Exception:
            price_num = float(price)
        key_prices.append({
            "id": r["id"],
            "symbol": r["symbol"],
            "price": price_num,
            "price_display": px_disp,
            "upper_diff": upper_diff,
            "upper_pct": upper_pct,
            "lower_diff": lower_diff,
            "lower_pct": lower_pct,
            "gate_summary": gate_summary,
            "gate_ok": (
                fib_gate_ok if is_fib
                else fb_gate_ok if is_fb
                else te_gate_ok if is_te
                else box_gate_ok and bool(gate and gate.get("ok"))
            ),
            "gate_metrics": gate_metrics,
        })

    order_prices = []
    from lib.hub.price_snapshot_lib import resolve_order_snapshot_price

    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.hub.hub_position_metrics 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.hub.hub_position_metrics import build_position_marks_list

    position_marks = build_position_marks_list(
        all_swap_positions,
        format_mark_display=lambda sym, px: format_price_for_symbol(sym, px),
    )

    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():
    now = app_now()
    trading_day = get_trading_day(now)
    conn = get_db()
    session_row = ensure_session(conn, trading_day)
    local_current_capital = float(session_row["current_capital"])
    _, trading_capital_live = get_exchange_capitals()
    current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4)
    raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall()
    conn.close()
    orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders]
    picked_id = request.args.get("order_id", "").strip()
    selected = None
    if picked_id.isdigit():
        selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None)
    if selected is None and orders:
        selected = orders[0]
    return render_template(
        "order_focus_v2.html",
        orders=orders,
        selected_order=selected,
        default_timeframe=KLINE_TIMEFRAME,
        price_refresh_seconds=PRICE_REFRESH_SECONDS,
        exchange_display=EXCHANGE_DISPLAY_NAME,
    )


@app.route("/api/order_kline")
@login_required
def api_order_kline():
    order_id_raw = (request.args.get("order_id") or "").strip()
    if not order_id_raw.isdigit():
        return jsonify({"ok": False, "msg": "order_id 无效"}), 400
    order_id = int(order_id_raw)
    timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip()
    allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}
    if timeframe not in allowed_tfs:
        timeframe = KLINE_TIMEFRAME
    limit = 100

    now = app_now()
    trading_day = get_trading_day(now)
    conn = get_db()
    session_row = ensure_session(conn, trading_day)
    local_current_capital = float(session_row["current_capital"])
    _, trading_capital_live = get_exchange_capitals()
    current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4)
    row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone()
    conn.close()
    if not row:
        return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404

    order_item = enrich_order_item(row_to_dict(row), current_capital)
    exchange_symbol = order_item.get("exchange_symbol") or normalize_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/<int:order_id>/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/<int:order_id>/place_tpsl", methods=["POST"])
@login_required
def api_order_place_tpsl(order_id):
    data = request.get_json(silent=True) or {}
    conn = get_db()
    row = conn.execute(
        "SELECT * FROM order_monitors WHERE id=? AND status='active'",
        (order_id,),
    ).fetchone()
    if not row:
        conn.close()
        return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404
    symbol = row["symbol"]
    direction = row["direction"]
    live_price = get_price(symbol)
    if live_price is None:
        conn.close()
        return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400
    try:
        sltp_mode = (data.get("sltp_mode") or "price").strip().lower()
        stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data)
    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 in KEY_MONITOR_RS_TYPES:
        direction_sel = KEY_DIRECTION_WATCH
        mt = KEY_MONITOR_RS_TYPE
    elif direction_sel not in ("long", "short"):
        flash("箱体/收敛突破请选择做多或做空")
        return redirect("/key_monitor")
    ok_dir, dir_msg = check_direction_policy(TRADE_POLICY, direction_sel)
    if not ok_dir:
        flash(dir_msg)
        return redirect("/key_monitor")
    allowed_types = (
        tuple(KEY_MONITOR_AUTO_TYPES)
        + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
        + tuple(FIB_KEY_MONITOR_TYPES)
        + (FALSE_BREAKOUT_MONITOR_TYPE,)
        + tuple(TRIGGER_ENTRY_MONITOR_TYPES)
    )
    if mt not in allowed_types:
        flash("监控类型无效")
        return redirect("/key_monitor")
    ok_mt, mt_msg = check_monitor_type_add_allowed(
        mt, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED
    )
    if not ok_mt:
        flash(mt_msg)
        return redirect("/key_monitor")
    skip_volume_rank = is_false_breakout_key_monitor_type(mt)
    rank, total = None, None
    if not skip_volume_rank:
        rank, total = _daily_volume_rank(symbol)
        if rank is None:
            flash("日成交量排名读取失败,请稍后重试")
            return redirect("/key_monitor")
        if rank > KEY_DAILY_VOLUME_RANK_MAX:
            flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位")
            return redirect("/key_monitor")
    conn = get_db()
    if mt in KEY_MONITOR_AUTO_TYPES:
        occupied = get_active_position_count(conn)
        if occupied >= MAX_ACTIVE_POSITIONS:
            conn.close()
            flash(
                f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」."
                "请平仓后再试,或使用「关键支撑阻力」(仅提醒)."
            )
            return redirect("/key_monitor")
    ex_sym_key = normalize_okx_symbol(symbol)
    try:
        ensure_markets_loaded()
    except Exception:
        pass
    be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled"))
    tc_en = parse_time_close_enabled_form(d.get("time_close_enabled"))
    tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None
    if tc_en and not tc_h:
        tc_en = 0
    if is_trigger_entry_key_monitor_type(mt):
        if direction_sel not in ("long", "short"):
            conn.close()
            flash("触价请选择做多或做空")
            return redirect("/key_monitor")
        try:
            entry_px = float(d.get("trigger_entry") or 0)
            sl_px = float(d.get("trigger_sl") or 0)
            tp_px = float(d.get("trigger_tp") or 0)
        except (TypeError, ValueError):
            entry_px = sl_px = tp_px = 0
        if entry_px <= 0 or sl_px <= 0 or tp_px <= 0:
            conn.close()
            flash("触价须填写有效的入场价,止损价,止盈价")
            return redirect("/key_monitor")
        ok_te, err_te = _add_trigger_entry_key_monitor(
            conn,
            symbol,
            direction_sel,
            entry_px,
            sl_px,
            tp_px,
            monitor_type=mt,
            breakeven_enabled=be_flag,
            time_close_enabled=tc_en,
            time_close_hours=tc_h,
        )
        conn.commit()
        conn.close()
        if not ok_te:
            flash(err_te or "触价开仓监控添加失败")
            return redirect("/key_monitor")
        trigger_hint = (
            "标记价穿越入场价后立即市价开仓"
            if is_breakout_trigger_entry_key_monitor_type(mt)
            else "标记价回调触达入场价后下一轮询市价开仓"
        )
        flash(
            f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})"
            f"｜有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h"
            f"｜{trigger_hint}"
            f"｜移动保本:{'开' if be_flag else '关'}"
            + (f"｜{time_close_label(tc_h)}" if tc_en else "")
        )
        return redirect("/key_monitor")
    if is_false_breakout_key_monitor_type(mt):
        fb_sym = normalize_false_breakout_symbol(symbol)
        if not fb_sym:
            conn.close()
            flash("假突破仅支持 BTC / ETH")
            return redirect("/key_monitor")
        symbol = fb_sym
        if direction_sel not in ("long", "short"):
            conn.close()
            flash("假突破请选择做多或做空")
            return redirect("/key_monitor")
        try:
            key_px = float(d.get("key_price") or 0)
        except (TypeError, ValueError):
            key_px = 0
        if key_px <= 0:
            conn.close()
            flash("请填写关键价位(做空填高点,做多填低点)")
            return redirect("/key_monitor")
        ex_sym_key = normalize_okx_symbol(symbol)
        key_adj = round_price_to_exchange(ex_sym_key, key_px)
        key_px = float(key_adj) if key_adj is not None else float(key_px)
        try:
            upper_px, lower_px = storage_bounds_from_key_price(direction_sel, key_px)
        except ValueError as e:
            conn.close()
            flash(str(e))
            return redirect("/key_monitor")
        ok_fb, err_fb = _add_false_breakout_key_monitor(
            conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag,
            time_close_enabled=tc_en, time_close_hours=tc_h,
        )
        conn.commit()
        conn.close()
        if not ok_fb:
            flash(err_fb or "假突破监控添加失败")
            return redirect("/key_monitor")
        flash(
            f"假突破监控已添加,限价单已挂出({symbol})"
            f"｜有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h｜移动保本:{'开' if be_flag else '关'}"
            + (f"｜{time_close_label(tc_h)}" if tc_en else "")
        )
        return redirect("/key_monitor")
    uh = round_price_to_exchange(ex_sym_key, float(d["upper"]))
    lw = round_price_to_exchange(ex_sym_key, float(d["lower"]))
    upper_px = float(uh) if uh is not None else float(d["upper"])
    lower_px = float(lw) if lw is not None else float(d["lower"])
    if upper_px <= lower_px:
        conn.close()
        flash("上沿必须大于下沿")
        return redirect("/key_monitor")
    if is_fib_key_monitor_type(mt):
        ok_fib, err_fib = _add_fib_key_monitor(
            conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag,
            time_close_enabled=tc_en, time_close_hours=tc_h,
        )
        conn.commit()
        conn.close()
        if not ok_fib:
            flash(err_fib or "斐波监控添加失败")
            return redirect("/key_monitor")
        flash(
            f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})"
            f"｜移动保本:{'开' if be_flag else '关'}"
            + (f"｜{time_close_label(tc_h)}" if tc_en else "")
        )
        return redirect("/key_monitor")
    sl_tp_mode = "standard"
    manual_tp = None
    if mt in KEY_MONITOR_AUTO_TYPES:
        sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode"))
        if sl_tp_mode == "trend_manual":
            try:
                manual_tp = float(d.get("manual_take_profit") or 0)
            except (TypeError, ValueError):
                manual_tp = 0
            if manual_tp <= 0:
                conn.close()
                flash("趋势单方案须填写有效止盈价")
                return redirect("/key_monitor")
            if direction_sel == "long" and manual_tp <= upper_px:
                conn.close()
                flash("做多趋势单:止盈价应高于上沿(阻力)")
                return redirect("/key_monitor")
            if direction_sel == "short" and manual_tp >= lower_px:
                conn.close()
                flash("做空趋势单:止盈价应低于下沿(支撑)")
                return redirect("/key_monitor")
            mtpx = round_price_to_exchange(ex_sym_key, manual_tp)
            if mtpx is not None:
                manual_tp = float(mtpx)
    if mt in KEY_MONITOR_RS_TYPES:
        conn.execute(
            "INSERT INTO key_monitors "
            "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled,"
            "max_notify,notify_interval_min,time_close_enabled,time_close_hours) "
            "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
            (
                symbol,
                mt,
                direction_sel,
                upper_px,
                lower_px,
                sl_tp_mode,
                manual_tp,
                be_flag,
                KEY_ALERT_MAX_TIMES,
                KEY_ALERT_INTERVAL_MINUTES,
                tc_en,
                tc_h,
            ),
        )
    else:
        conn.execute(
            "INSERT INTO key_monitors "
            "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled,"
            "time_close_enabled,time_close_hours) "
            "VALUES (?,?,?,?,?,?,?,?,?,?)",
            (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag, tc_en, tc_h),
        )
    conn.commit()
    conn.close()
    extra = ""
    if mt in KEY_MONITOR_AUTO_TYPES:
        extra = f"｜方案:{sl_tp_mode_label(sl_tp_mode)}｜移动保本:{'开' if be_flag else '关'}"
        if tc_en:
            extra += f"｜{time_close_label(tc_h)}"
    ctr = False
    try:
        coin4h_status, _, _ = _status_by_ema55(symbol, "4h")
        ctr = (direction_sel == "long" and coin4h_status == "空头") or (
            direction_sel == "short" and coin4h_status == "多头"
        )
    except Exception:
        pass
    if mt in KEY_MONITOR_RS_TYPES:
        flash(
            f"添加成功({symbol} 日成交量排名 {rank}/{total})｜关键支撑阻力:双向监控上/下沿,"
            f"5m 收盘突破后微信提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分钟)"
        )
    else:
        flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}")
    if ctr and mt in KEY_MONITOR_AUTO_TYPES:
        flash(
            "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位."
        )
    return redirect("/key_monitor")

@app.route("/add_order", methods=["POST"])
@login_required
def add_order():
    d = request.form
    now = app_now()
    conn = get_db()
    direction = d.get("direction", "long")
    symbol = normalize_symbol_input(d.get("symbol"))
    if not symbol:
        conn.close()
        flash("symbol 不能为空")
        return redirect("/trade")
    ok_pol, pol_msg = validate_trade_policy_open(symbol, direction)
    if not ok_pol:
        conn.close()
        flash(f"账户限制:{pol_msg}")
        return redirect("/trade")
    dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction))
    if dup_msg:
        conn.close()
        flash(dup_msg)
        return redirect("/trade")
    ok, reason = precheck_risk(conn, symbol, direction)
    if not ok:
        conn.close()
        flash(f"风控拒绝下单:{reason}")
        return redirect("/trade")
    ok_live, reason_live = ensure_okx_live_ready()
    if not ok_live:
        conn.close()
        flash(f"风控拒绝下单:{reason_live}")
        return redirect("/trade")
    exchange_symbol = normalize_okx_symbol(symbol)
    trading_day = get_trading_day(now)
    opens_today_before = conn.execute(
        "SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
        (trading_day,),
    ).fetchone()[0]
    session_row = ensure_session(conn, trading_day)
    _, trading_capital_live = get_exchange_capitals(force=True)
    capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
    trade_style, entry_model, style_err = parse_manual_order_style_fields(
        TRADE_POLICY, d, default_trade_style=DEFAULT_TRADE_STYLE or "trend"
    )
    if style_err:
        conn.close()
        flash(style_err)
        return redirect("/trade")
    available_usdt = get_available_trading_usdt()
    live_price = get_price(symbol)
    if live_price is None:
        conn.close()
        flash("获取交易所实时价格失败,请稍后重试")
        return redirect("/trade")
    sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode"))
    try:
        stop_loss, take_profit = resolve_open_sltp_prices(
            direction, live_price, sltp_mode, d
        )
    except ValueError as e:
        conn.close()
        flash(str(e) or "止盈止损参数错误")
        return redirect("/trade")
    if stop_loss <= 0 or take_profit <= 0:
        conn.close()
        flash("价格参数必须大于0")
        return redirect("/trade")
    planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit)
    if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR:
        conn.close()
        rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算"
        flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1")
        return redirect("/trade")
    risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
    if risk_fraction is None:
        conn.close()
        flash("止损方向不合法:请检查入场方向与止损价格关系")
        return redirect("/trade")
    risk_percent = max(0.01, float(RISK_PERCENT))
    risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS)
    if is_full_margin_mode(POSITION_SIZING_MODE):
        ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
        if not ok_flat:
            conn.close()
            flash(flat_msg)
            return redirect("/trade")
        leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
        sizing, sizing_err = compute_full_margin_sizing(
            symbol=symbol,
            available_usdt=available_usdt if available_usdt is not None else 0.0,
            capital_base=capital_base,
            buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
            btc_leverage=BTC_LEVERAGE,
            alt_leverage=ALT_LEVERAGE,
            funds_decimals=FUNDS_DECIMALS,
        )
        if sizing_err:
            conn.close()
            flash(sizing_err)
            return redirect("/trade")
        margin_capital = sizing["margin_capital"]
        notional_value = sizing["notional_value"]
        position_ratio = sizing["position_ratio"]
    else:
        default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
        try:
            leverage_input = parse_positive_float(d.get("leverage"))
            leverage = int(leverage_input) if leverage_input is not None else default_leverage
        except Exception:
            conn.close()
            flash("杠杆参数格式错误")
            return redirect("/trade")
        if leverage <= 0:
            conn.close()
            flash("杠杆必须大于0")
            return redirect("/trade")
        notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS)
        margin_capital = round(notional_value / leverage, FUNDS_DECIMALS)
        if capital_base and margin_capital > capital_base:
            conn.close()
            flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例")
            return redirect("/trade")
        if available_usdt is not None:
            max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS)
            if margin_capital > max_margin:
                conn.close()
                flash(f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U")
                return redirect("/trade")
        position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0
    try:
        amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
        contract_size = get_contract_size(exchange_symbol)
        base_amount = round(float(amount) * contract_size, 8)
        order_resp = place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit)
        open_order_id = order_resp.get("id", "")
        tpsl_attached = bool(order_resp.get("tpsl_attached"))
        trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price)
    except Exception as e:
        conn.close()
        flash(friendly_okx_error(e, available_usdt=available_usdt))
        return redirect("/trade")

    make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes")
    opened_at_bj = app_now_str()
    opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
    planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit)
    breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
    breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
    breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
    risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount
    risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent)
    risk_display = format_risk_display_text(
        POSITION_SIZING_MODE, risk_percent, risk_amount_final, decimals=FUNDS_DECIMALS
    )
    if direction == "short":
        breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8)
    else:
        breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8)
    breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0
    tc_en = parse_time_close_enabled_form(d.get("time_close_enabled"))
    tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None
    if tc_en and not tc_h:
        tc_en = 0
    tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms)
    conn.execute(
        "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, entry_model, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (
            symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit,
            margin_capital, leverage, trade_style, entry_model, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price,
            breakeven_enabled,
            notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, "下单监控",
            tc_en, tc_h, tc_at,
        )
    )
    conn.commit()
    new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
    opens_today_after = conn.execute(
        "SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
        (trading_day,),
    ).fetchone()[0]
    conn.close()

    chart_name = None
    chart_url = None
    if make_order_chart and ORDER_CHART_ENABLED:
        try:
            title_prefix = f"{symbol} {direction} #{new_order_id}"
            chart_name = generate_order_open_chart(
                exchange_symbol,
                title_prefix,
                opened_at_ms=opened_at_ms,
                entry_price=trigger_price,
            )
            if chart_name:
                chart_url = f"/static/images/order_charts/{chart_name}"
        except Exception:
            chart_name = None
            chart_url = None

    if chart_name:
        try:
            journal_id = f"order_{new_order_id}"
            coin = journal_coin_from_symbol(symbol)
            open_local = (opened_at_bj or "")[:16].replace(" ", "T")
            if len(open_local) < 16:
                open_local = app_now().strftime("%Y-%m-%dT%H:%M")
            close_local = open_local
            hold_duration = calc_duration_text(open_local, close_local)
            note = (
                f"auto_from_open_order id={new_order_id} oid={open_order_id} "
                f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}"
            )
            conn = get_db()
            conn.execute(
                """INSERT OR REPLACE INTO journal_entries
                   (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason,
                    expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note,
                    mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare,
                    new_trade_while_occupied, note, image)
                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    journal_id,
                    open_local,
                    close_local,
                    hold_duration,
                    coin,
                    "multi",
                    "0",
                    "auto:open",
                    "待平仓",
                    "",
                    "",
                    "否",
                    "",
                    "",
                    "",
                    None,
                    None,
                    None,
                    "",
                    "否",
                    "否",
                    note,
                    chart_name,
                ),
            )
            conn.commit()
            conn.close()
        except Exception:
            try:
                conn.close()
            except Exception:
                pass

    _, trading_capital_after = get_exchange_capitals(force=True)
    account_base_display = (
        round(float(trading_capital_after), 2)
        if trading_capital_after is not None
        else round(float(capital_base), 2)
    )
    dir_text = "多头(long)" if direction == "long" else "空头(short)"
    order_state_text = (
        "已在交易所挂条件委托(止盈,止损各一张触发单)"
        if tpsl_attached
        else "条件委托未挂上(已拦截)"
    )
    rr_show = planned_rr if planned_rr is not None else "-"
    try:
        rr_show_fmt = f"{float(planned_rr):.2f}" if planned_rr is not None else None
    except (TypeError, ValueError):
        rr_show_fmt = None
    rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1"
    ep_wx = format_price_for_symbol(symbol, trigger_price)
    sl_wx = format_wechat_scalar_2dp(stop_loss)
    tp_wx = format_price_for_symbol(symbol, take_profit)
    be_wx = format_price_for_symbol(symbol, breakeven_price)
    style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势"
    wx_lines = [
        f"📈 {symbol} 开仓成功",
        f"💼 交易类型:{dir_text}",
        "🧾 订单基础信息",
        f"🔖 交易所订单 ID:{open_order_id}",
        f"📈 交易风格:{style_zh}",
        f"⚠️ 单笔风控风险:{risk_display}",
        "📊 仓位配置详情",
        f"账户基数:{account_base_display} USDT",
        f"合约杠杆:{leverage} 倍",
        f"名义仓位:{format_wechat_scalar_2dp(notional_value)} USDT",
        f"仓位占比:{position_ratio}%",
        f"合约张数:{format_wechat_scalar_2dp(amount)} 张",
        f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}",
        "🎯 价位 & 盈亏比",
        f"开仓成交价:{ep_wx}",
        f"止损价位:{sl_wx}",
        f"止盈价位:{tp_wx}",
        f"计划盈亏比:{rr_line}",
        f"移动保本位:{breakeven_rr_trigger}R → {be_wx}",
        "📌 状态统计",
        f"✅ 条件委托:{order_state_text}",
        format_daily_open_counter_line(
            opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT
        ),
    ]
    if chart_url:
        wx_lines.append(f"多周期K线图:{chart_url}")
    send_wechat_msg("\n".join(wx_lines))

    flash_lines = [
        f"实盘开单成功:风格 {trade_style};风险 {risk_display};基数 {round(float(margin_capital), 2)}U,杠杆 {leverage}x,名义仓位 {format_wechat_scalar_2dp(notional_value)}U,仓位占比 {position_ratio}%,合约张数 {format_wechat_scalar_2dp(amount)}(折算标的 {base_amount}),"
        f"计划RR {format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)",
        format_daily_open_summary_short(
            opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT
        ),
    ]
    if chart_url:
        flash_lines.append(f"已生成多周期K线图:{chart_url}")
    flash(" ".join(flash_lines))

    if should_send_daily_open_alert(
        opens_today_before, opens_today_after, DAILY_OPEN_ALERT_THRESHOLD
    ):
        advice = ai_short_advice(
            build_daily_open_alert_prompt(
                trading_day,
                opens_today_after,
                DAILY_OPEN_ALERT_THRESHOLD,
                hard_limit=DAILY_OPEN_HARD_LIMIT,
                detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U.",
            )
        )
        if advice:
            send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}")
            flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}")
    return redirect("/trade")

@app.route("/delete_key_monitor/<int:kid>", methods=["POST"])
@login_required
def delete_key_monitor(kid):
    conn = get_db()
    row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone()
    if not row:
        conn.close()
        return jsonify({"ok": False, "error": "not_found"})
    if is_limit_key_monitor_type((row["monitor_type"] or "").strip()):
        _cancel_fib_monitor_limit(row)
    insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual")
    cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,))
    conn.commit()
    conn.close()
    return jsonify({"ok": cur.rowcount > 0})


@app.route("/delete_key_history/<int:hid>", methods=["POST"])
@login_required
def delete_key_history(hid):
    conn = get_db()
    cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,))
    conn.commit()
    conn.close()
    return jsonify({"ok": cur.rowcount > 0})


@app.route("/del_key/<int:id>")
@login_required
def del_key(id):
    conn = get_db()
    row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone()
    if row:
        if is_limit_key_monitor_type((row["monitor_type"] or "").strip()):
            _cancel_fib_monitor_limit(row)
        insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual")
    conn.execute("DELETE FROM key_monitors WHERE id=?", (id,))
    conn.commit()
    conn.close()
    resp = redirect("/")
    resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
    resp.headers["Pragma"] = "no-cache"
    return resp


def _csv_response(filename, rows, header):
    buf = StringIO()
    w = csv.writer(buf)
    w.writerow(header)
    for row in rows:
        w.writerow(row)
    out = "\ufeff" + buf.getvalue()
    return Response(
        out,
        mimetype="text/csv; charset=utf-8",
        headers={
            "Content-Disposition": f'attachment; filename="{filename}"',
            "Cache-Control": "no-store",
        },
    )


def _md_response(filename, content):
    return Response(
        content,
        mimetype="text/markdown; charset=utf-8",
        headers={
            "Content-Disposition": f'attachment; filename="{filename}"',
            "Cache-Control": "no-store",
        },
    )


@app.route("/export/trade_records")
@login_required
def export_trade_records():
    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/<int:id>")
@login_required
def del_order(id):
    conn = get_db()
    row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone()
    if not row:
        conn.close()
        flash("订单不存在")
        return redirect("/")
    if row["status"] == "active":
        try:
            p = get_price(row["symbol"]) or float(row["trigger_price"])
            opened_at = get_opened_at_value(row)
            closed_at = app_now_str()
            hold_seconds = calc_hold_seconds(opened_at, app_now())
            pnl_amount = calc_pnl(
                row["direction"],
                row["trigger_price"],
                p,
                row["margin_capital"] or DAILY_START_CAPITAL,
                row["leverage"] or infer_leverage(row["symbol"])
            )
            close_resp = close_exchange_order(row)
            close_order_id = close_resp.get("id", "")
            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))
            try:
                _rcfg = app.extensions.get("strategy_roll_cfg")
                if isinstance(_rcfg, dict):
                    from lib.strategy.strategy_register import roll_sync_after_external_close

                    roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
            except Exception:
                pass
            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,))
                try:
                    _rcfg = app.extensions.get("strategy_roll_cfg")
                    if isinstance(_rcfg, dict):
                        from lib.strategy.strategy_register import roll_sync_after_external_close

                        roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
                except Exception:
                    pass
                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():
    d = request.form
    order_type_norm = normalize_journal_order_type(d.get("order_type"))
    if not order_type_norm:
        flash("请选择下单类型")
        return _redirect_records()
    direction_norm = normalize_journal_direction(d.get("direction") or d.get("direction_hint"))
    if not direction_norm:
        flash("请选择方向")
        return _redirect_records()
    entry_reason_norm = normalize_journal_entry_reason(
        d.get("entry_reason"), ENTRY_REASON_OPTIONS, allow_legacy=False
    )
    if not entry_reason_norm:
        flash("请选择开仓类型")
        return _redirect_records()
    early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger"))
    early_exit_note = str(d.get("early_exit_note") or "").strip()
    if not early_exit_trigger:
        flash("请选择离场触发")
        return _redirect_records()
    if early_exit_trigger == "手动平仓" and not early_exit_note:
        flash("手工平仓必须填写补充说明")
        return _redirect_records()
    if early_exit_trigger != "手动平仓":
        early_exit_note = ""
    # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」
    early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否"
    early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note)
    exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note)
    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 d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes")
    )
    chart_msg = None
    if want_exchange_chart and ORDER_CHART_ENABLED:
        coin = (d.get("coin") or "").strip().upper()
        symbol_guess = normalize_symbol_input(coin) or coin
        exchange_symbol = normalize_okx_symbol(symbol_guess)
        title_prefix = f"{symbol_guess} journal {entry_id[:8]}"
        journal_tfs = parse_journal_chart_timeframes(
            d.get("journal_chart_tf1"),
            d.get("journal_chart_tf2"),
            ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None,
        )
        journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT)
        chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor"))
        marker_payload = {
            "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
            "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
            "entry_price": d.get("entry_price_hint"),
            "exit_price": d.get("exit_price_hint"),
            "stop_loss_price": d.get("stop_loss_hint"),
            "chart_anchor": chart_anchor,
            "now_ts_ms": int(app_now().timestamp() * 1000),
        }
        try:
            chart_fname = f"journal_{entry_id}.png"
            saved = generate_multi_timeframe_chart_png(
                exchange_symbol,
                title_prefix,
                timeframes=journal_tfs,
                limit=journal_limit,
                out_dir=app.config["UPLOAD_FOLDER"],
                filename=chart_fname,
                filename_prefix="journal",
                marker_payload=marker_payload,
                marker_timeframes={x.strip().lower() for x in journal_tfs},
                layout="vertical",
            )
            if saved:
                image_filename = saved
                chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}"
            else:
                chart_msg = "已勾选自动生成K线图,但生成失败(返回空).请检查 Pillow 是否安装,OKX 网络/代理是否正常."
        except Exception as e:
            chart_msg = f"自动生成K线图失败:{str(e)}"

    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_msg:
        flash(f"交易复盘记录已保存.{chart_msg}")
    else:
        flash("交易复盘记录已保存")
    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,
)


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_hub_lib import build_options_hub_snapshot

        snap = build_options_hub_snapshot(cfg)
    except Exception:
        return []
    if not snap.get("ok"):
        return []
    return list(snap.get("positions") or [])


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,
    hedge_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"),
)


@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/<jid>", 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("/api/reviews")
@login_required
def api_reviews():
    win = _list_window_from_request()
    start_sql, end_sql = utc_window_to_utc_sql_strings(win["start_utc"], win["end_utc"])
    conn = get_db()
    rows = conn.execute(
        "SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200",
        (start_sql, end_sql),
    ).fetchall()
    conn.close()
    return jsonify([row_to_dict(r) for r in rows])


_REPO_STATIC_DIR = common_static_dir(os.path.dirname(BASE_DIR))
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.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/ai_review_render.js")
def static_ai_review_render_js():
    if not os.path.isfile(_AI_REVIEW_RENDER_JS):
        return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
    return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8")


@app.route("/static/form_submit_guard.js")
def static_form_submit_guard_js():
    if not os.path.isfile(_FORM_SUBMIT_GUARD_JS):
        return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
    return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8")


@app.route("/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/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("/export/review_md/<rid>")
@login_required
def export_review_md(rid):
    conn = get_db()
    row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone()
    conn.close()
    if not row:
        return Response("review not found", status=404, mimetype="text/plain; charset=utf-8")

    review_type = "日复盘" if row["review_type"] == "daily" else "周复盘"
    target_date = row["target_date"] or "-"
    created_at = row["created_at"] or app_now_str()
    content = (row["content"] or "").strip()
    if not content:
        content = "(无内容)"

    md = (
        f"# {review_type}报告\n\n"
        f"- 目标日期: {target_date}\n"
        f"- 生成时间: {created_at}\n"
        f"- 报告ID: {row['id']}\n\n"
        f"---\n\n"
        f"{content}\n"
    )

    safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date"
    safe_type = "daily" if row["review_type"] == "daily" else "weekly"
    filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md"
    return _md_response(filename, md)


@app.route("/export/reviews_md_bundle")
@login_required
def export_reviews_md_bundle():
    review_type = (request.args.get("review_type") or "").strip().lower()
    target_date = (request.args.get("target_date") or "").strip()
    if review_type not in ("daily", "weekly"):
        return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8")
    if not target_date:
        return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8")

    conn = get_db()
    rows = conn.execute(
        "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC",
        (review_type, target_date),
    ).fetchall()
    conn.close()
    if not rows:
        return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8")

    title = "日复盘" if review_type == "daily" else "周复盘"
    lines = [
        f"# {title}汇总报告",
        "",
        f"- 目标日期: {target_date}",
        f"- 条目数量: {len(rows)}",
        f"- 导出时间: {app_now_str()}",
        "",
        "---",
        "",
    ]
    for idx, row in enumerate(rows, 1):
        created_at = row["created_at"] or "-"
        content = (row["content"] or "").strip() or "(无内容)"
        lines.extend(
            [
                f"## 第{idx}条",
                "",
                f"- 报告ID: {row['id']}",
                f"- 生成时间: {created_at}",
                "",
                content,
                "",
                "---",
                "",
            ]
        )
    md = "\n".join(lines)
    safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date"
    filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md"
    return _md_response(filename, md)


@app.route("/delete_review/<rid>", methods=["POST"])
@login_required
def delete_review(rid):
    conn = get_db()
    conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,))
    conn.commit()
    conn.close()
    return jsonify({"ok": True})


@app.route("/delete_trade_record/<int:rid>", methods=["POST"])
@login_required
def delete_trade_record(rid):
    conn = get_db()
    cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,))
    conn.commit()
    conn.close()
    return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount})


@app.route("/api/trade_record_review_update", methods=["POST"])
@login_required
def api_trade_record_review_update():
    payload = request.get_json(silent=True) or {}
    rec_id = payload.get("id")
    try:
        rec_id = int(rec_id)
    except Exception:
        return jsonify({"ok": False, "msg": "记录ID无效"}), 400

    reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip()
    reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip()
    reviewed_stop_loss_raw = payload.get("reviewed_stop_loss")
    reviewed_take_profit_raw = payload.get("reviewed_take_profit")
    reviewed_result = str(payload.get("reviewed_result") or "").strip()
    reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip()
    reviewed_pnl_raw = payload.get("reviewed_pnl_amount")

    if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS:
        return jsonify({"ok": False, "msg": "结果仅允许:" + "/".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")
    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")


def _journal_ai_chart_builder(row):
    return build_journal_ai_chart_path(
        row,
        app.config["UPLOAD_FOLDER"],
        order_chart_enabled=ORDER_CHART_ENABLED,
        normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)),
        generate_chart_fn=generate_multi_timeframe_chart_png,
        local_datetime_to_ms_fn=_local_input_datetime_to_ms,
        now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000),
    )


@app.route("/ai_daily_review", methods=["POST"])
@login_required
def ai_daily_review():
    date = request.form.get("date", "")
    conn = get_db()
    rows = conn.execute(
        "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC",
        (date,)
    ).fetchall()
    conn.close()
    if not rows:
        return jsonify({"result": "该日无交易记录"})

    text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n"
    for idx, row in enumerate(rows, 1):
        text += journal_row_lines_for_ai(idx, row)
    text += "\n"

    image_paths = collect_images_for_ai_review(
        rows,
        app.config["UPLOAD_FOLDER"],
        build_chart_if_missing=_journal_ai_chart_builder,
    )
    ai_result = ai_review(text, "每日", image_paths=image_paths)
    full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}"
    conn = get_db()
    conn.execute(
        "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)",
        (uuid.uuid4().hex, "daily", date, full)
    )
    conn.commit()
    conn.close()
    return jsonify({"result": full})


@app.route("/ai_weekly_review", methods=["POST"])
@login_required
def ai_weekly_review():
    start_date = request.form.get("start_date", "")
    end_date = request.form.get("end_date", "")
    conn = get_db()
    rows = conn.execute(
        "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC",
        (start_date, end_date)
    ).fetchall()
    conn.close()
    if not rows:
        return jsonify({"result": "该时间段无交易记录"})

    text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n"
    for idx, row in enumerate(rows, 1):
        text += journal_row_lines_for_ai(idx, row)
    text += "\n"

    image_paths = collect_images_for_ai_review(
        rows,
        app.config["UPLOAD_FOLDER"],
        build_chart_if_missing=_journal_ai_chart_builder,
    )
    ai_result = ai_review(text, "周度", image_paths=image_paths)
    full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}"
    conn = get_db()
    conn.execute(
        "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)",
        (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full)
    )
    conn.commit()
    conn.close()
    return jsonify({"result": full})

def _hub_meta_bundle():
    return {
        "exchange_display": EXCHANGE_DISPLAY_NAME,
        "key_gate_rule_text": (
            f"周期 {KLINE_TIMEFRAME}｜量能/突破/二确门控见箱体与收敛规则｜"
            f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1｜日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}｜"
            f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)｜移动保本默认关｜"
        f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本｜趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%"
        ),
        "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
        "max_active_positions": MAX_ACTIVE_POSITIONS,
        "btc_leverage": BTC_LEVERAGE,
        "alt_leverage": ALT_LEVERAGE,
        "trade_policy": trade_policy_template_context(TRADE_POLICY),
        **hub_meta_entry_context(TRADE_POLICY),
        "options_enabled": OKX_OPTIONS_ENABLED,
    }


def _hub_account_bundle():
    funding_capital, trading_capital = get_exchange_capitals(force=True)
    funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
    trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None
    available = get_available_trading_usdt()
    return {
        "funding_usdt": funding_usdt,
        "trading_usdt": trading_usdt,
        "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None,
        "trading_day": get_trading_day(app_now()),
    }


def _hub_fetch_market(base=""):
    from lib.hub.hub_market_info_lib import fetch_usdt_swap_market_info

    return fetch_usdt_swap_market_info(
        base_or_symbol=base,
        normalize_symbol_input=normalize_symbol_input,
        normalize_exchange_symbol=normalize_okx_symbol,
        ensure_markets_loaded=ensure_markets_loaded,
        exchange=exchange,
        exchange_id="okx",
    )


def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
    from lib.hub.hub_ohlcv_lib import fetch_ohlcv_for_hub

    return fetch_ohlcv_for_hub(
        symbol=symbol,
        timeframe=timeframe,
        since_ms=since_ms,
        limit=limit,
        normalize_symbol_input=normalize_symbol_input,
        normalize_exchange_symbol=normalize_okx_symbol,
        ensure_markets_loaded=ensure_markets_loaded,
        exchange=exchange,
        friendly_error=friendly_okx_error,
    )


def _hub_fetch_volume_rank(top_n=20):
    from lib.hub.hub_volume_rank_lib import fetch_usdt_swap_volume_rank

    return fetch_usdt_swap_volume_rank(
        exchange=exchange,
        ensure_markets_loaded=ensure_markets_loaded,
        top_n=top_n,
        exchange_id="okx",
    )


try:
    import sys
    from pathlib import Path

    _repo_root = Path(__file__).resolve().parent.parent
    if str(_repo_root) not in sys.path:
        sys.path.insert(0, str(_repo_root))
    from lib.hub.hub_bridge import install_on_app

    install_on_app(
        app,
        exchange="okx",
        capabilities=["order", "key"],
        has_trend=True,
        get_db=get_db,
        row_to_dict=row_to_dict,
        meta_fn=_hub_meta_bundle,
        account_fn=_hub_account_bundle,
        views={"add_order": add_order, "add_key": add_key},
        ohlcv_fn=_hub_fetch_ohlcv,
        volume_rank_fn=_hub_fetch_volume_rank,
        market_fn=_hub_fetch_market,
        reconcile_hub_flat_fn=reconcile_hub_external_close,
        risk_status_fn=hub_account_risk_status,
        user_close_fn=hub_user_initiated_close,
        render_main_page_fn=render_main_page,
        login_required_fn=login_required,
    )
except Exception as _hub_err:
    print(f"[hub_bridge] okx: {_hub_err}")

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}")


@app.route("/strategy")
@login_required
def strategy_trading_page():
    return render_main_page("strategy")


@app.route("/strategy/trend")
@login_required
def strategy_trend_page():
    qs = request.query_string.decode()
    return redirect(f"/strategy?{qs}" if qs else "/strategy")


@app.route("/strategy/roll")
@login_required
def strategy_roll_page():
    return redirect("/strategy")


# 根目录 strategy_* 与币安/Gate 共用同一套属性名(OKX 内部仍用 normalize_okx_symbol / ensure_okx_live_ready)
normalize_exchange_symbol = normalize_okx_symbol
ensure_exchange_live_ready = ensure_okx_live_ready

from lib.strategy.strategy_register import install_strategy_trading
from lib.strategy.strategy_trend_register import install_strategy_trend

install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
install_strategy_trend(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__])

_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)
