commit a1abe159fa3c962d52f3aefbb39a32960c35480c Author: dekun Date: Thu Aug 13 20:00:59 2026 +0800 Initial standalone crypto_okx with one-click deploy. Add deploy/manage.sh bootstrap for git.bz121.com/dekun/crypto_okx and point docs at this repo. Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..beacd56 --- /dev/null +++ b/.env.example @@ -0,0 +1,296 @@ +# ============================================================================= +# 环境配置模板(可提交 Git).程序运行时只读取同目录下的 .env. +# +# 首次部署 / 新机: +# cp .env.example .env +# nano .env # 填入真实密钥,端口,代理等 +# +# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖): +# cp .env .env.backup.$(date +%Y%m%d) +# +# 从备份恢复: +# cp .env.backup.YYYYMMDD .env +# ============================================================================= + +APP_ENV=production +# 服务监听地址(云服务器通常用 0.0.0.0) +APP_HOST=0.0.0.0 +# 服务端口 +APP_PORT=5004 +# 是否开启调试模式(生产建议 false) +APP_DEBUG=false + +# 登录账号 +APP_USERNAME=admin +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=CHANGE_ME_STRONG_PASSWORD +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=false +# Flask 会话密钥(必须替换为长随机字符串) +FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET + +# 企业微信机器人 Webhook(用于行情/风控推送) +WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY + +# 数据库文件路径(相对路径会自动按项目目录解析) +DB_PATH=crypto.db +# 交易截图上传目录 +UPLOAD_DIR=static/images + +# 自动备份(scripts/backup_data.sh + cron,可选;默认即可) +# BACKUP_ROOT=/root/backups +# BACKUP_RETENTION_DAYS=30 +# BACKUP_INSTANCE=crypto_okx + +# 训练总资金(U) +# TOTAL_CAPITAL=100 # 已弃用,资金展示读交易所 +# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) +POSITION_SIZING_MODE=risk +# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启) +# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向) +TRADE_DIRECTION_RESTRICT_ENABLED=false +TRADE_DIRECTION=both +# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择) +TRADE_SYMBOL_RESTRICT_ENABLED=false +TRADE_SYMBOL_WHITELIST=BTC,ETH +# 每天起始基数(U) +DAILY_START_CAPITAL=30 +# 日内回撤后基数(U) +DAILY_LOSS_CAPITAL=20 +# 日内盈利后基数(U) +DAILY_PROFIT_CAPITAL=50 +# BTC 默认杠杆倍数 +BTC_LEVERAGE=10 +# 山寨币默认杠杆倍数 +ALT_LEVERAGE=5 +# 交易日重置小时(北京时间) +TRADING_DAY_RESET_HOUR=8 +# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true + +# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单) +LIVE_TRADING_ENABLED=true + +# ============================================================================= +# 模拟资金(本地 SQLite 钱包; 系统设置可切换 模拟资金/实盘) +# 交易模式默认由系统设置(runtime trading.mode)切换; 也可启动时指定默认 +# ============================================================================= +SIM_DEFAULT_MODE=sim +SIM_INITIAL_EQUITY_USDT=10000 +SIM_INITIAL_USDC=0 +SIM_FEE_RATE=0.0005 +# ============================================================================= +# OKX 账户 API(永续 + 期权共用同一套密钥;修改后须重启 PM2) +# 旧键 OKX_OPTIONS_API_* 已废弃:若 OKX_API_* 为空,启动时会从 OPTIONS 键回填 +# ============================================================================= +OKX_API_KEY=REPLACE_WITH_OKX_API_KEY +OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET +OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE +# 保证金模式:cross=全仓,isolated=逐仓 +OKX_TD_MODE=cross +# 持仓模式:hedge=双向持仓,net=单向净持仓 +OKX_POS_MODE=hedge +# 仓位查询 instType(OKX) +OKX_POSITION_INST_TYPE=SWAP +# 从 OKX 历史仓位同步已实现盈亏(北京时间起点,空=近 90 天 0 点起) +# EXCHANGE_POSITION_SYNC_FROM_BJ=2026-01-01 +# 单次拉取历史仓位条数上限(OKX 每页最多 100,程序会分页) +# EXCHANGE_POSITION_HISTORY_LIMIT=200 +# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 OKX·测试网) +EXCHANGE_DISPLAY_NAME=OKX +# 企业微信推送里展示的账户备注 +# OKX_ACCOUNT_LABEL= +# 顶栏是否显示 USDT 资金/交易账户(热更);false 时总资金仅计期权 USDC 侧 +OKX_SHOW_PERP_FUNDS=true + +# ============================================================================= +# 期权模块(与上方 OKX_API_* 同源;修改启用开关后须重启 PM2) +# 详见 docs/期权方案.md 与 docs/期权用法.md +# ============================================================================= +OKX_OPTIONS_ENABLED=true +# 以下 OKX_OPTIONS_API_* 已废弃,请勿再配置(仅兼容旧部署回填) +# OKX_OPTIONS_API_KEY= +# OKX_OPTIONS_API_SECRET= +# OKX_OPTIONS_API_PASSPHRASE= +OKX_OPTIONS_ACCOUNT_LABEL=账户·期权 +OKX_OPTIONS_TRADE_BUDGET_USDC=10 +OKX_OPTIONS_BUDGET_BUFFER=0.95 +# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算 +OKX_OPTIONS_COMPOUND_FULL_ENABLED=true +OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false +OKX_OPTIONS_COMPOUND_FULL_CAP_USDC=300 +# 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲 +# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」 +OKX_TRADE_MODE=options +# 永期对冲改为: OKX_TRADE_MODE=perp_options +# 期期对冲改为: OKX_TRADE_MODE=options_options +# 仅单独期权模式:期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更 +OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0 +OKX_OPTIONS_DEFAULT_UNDERLY=ETH +# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部 +OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true +OKX_OPTIONS_MAX_DTE_DAYS=2 +OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14 +OKX_OPTIONS_ITM_MAX_DIST_USD=30 +OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0 +OKX_OPTIONS_POLL_SECONDS=15 +OKX_OPTIONS_TD_MODE=isolated +OKX_OPTIONS_ALLOW_MARKET_CLOSE=false +# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败 +OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12 + +# ============================================================================= +# 对冲计划(仅 OKX;由 OKX_TRADE_MODE 控制是否启用;详见 docs/对冲计划开发方案.md) +# ============================================================================= +# 以下三项已由 OKX_TRADE_MODE 取代,保留兼容旧部署(未配置 TRADE_MODE 时仍可读) +HEDGE_PLAN_ENABLED=false +HEDGE_PLAN_SHOW_PERP_OPTIONS=true +HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true +HEDGE_PLAN_LIVE_ORDER=false +# 永期子模式:true=以期权为主;false=保险模式(页面标题前标识,不可页内切换) +HEDGE_PLAN_OPTION_PRIMARY=true +HEDGE_PLAN_OPEN_ORDER=options_first +HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true +HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false +HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true +# 方案C:期期页面显示「平仓模式」(到期平/全平);关则固定到期平.默认开启,页面默认选全平 +HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true +# 期期「做多/做空」拆分口径:budget=按权利金预算(默认);sheets=先算同张数总张数(2n)再按比例拆 +HEDGE_PLAN_OO_BIAS_SPLIT_BY=budget +# 期期「做多/做空」主腿占比(0~1,默认 0.7=7:3);做多主腿=Call,做空主腿=Put +HEDGE_PLAN_OO_BIAS_RATIO=0.7 +# 对冲与单独期权互斥(默认 true):有对冲计划不可单独开期权;有单独期权不可启动对冲;false=可同时开 +HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true +# 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效 +HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true +# 对冲组数上限(默认 1;opening/active/partial 计入);仅永期/期期模式生效;热更 +MAX_ACTIVE_HEDGE_PLANS=1 +HEDGE_PLAN_MONITOR_POLL_SECONDS=15 +# 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则运行时强制无效(建议一并写成 false) +HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=false + +# ============================================================================= +# ============================================================================= +# +# POSITION_SIZING_MODE=risk(以损定仓) +# true → 允许关键位全套自动(含触价) +# +# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换) +# false → 不执行触价自动单 +# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止 +# +# 顺势加仓,趋势回调不受本开关控制;全仓模式下策略自动仍禁止. + +# ============================================================================= +# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) +# ============================================================================= +# 【周期】门控 K 线周期,如 5m,15m;仅影响关键位硬条件,不改变顶栏分区 +KLINE_TIMEFRAME=5m +# OKX 遗留:突破过滤百分比(与 KEY_BREAKOUT_AMP_* 并存,程序仍读取) +KEY_BREAKOUT_LIMIT_PCT=1.5 +# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2(倒数第2根),确认棒默认 -1(倒数第1根) +KEY_CONFIRM_BREAKOUT_BAR=-2 +KEY_CONFIRM_BAR=-1 +# 【量能】突破棒成交量 > 前 N 根均量 × 倍数(默认 N=20,倍数=1.3 即放大 30%) +KEY_VOLUME_MA_BARS=20 +KEY_VOLUME_RATIO_MIN=1.3 +# 【箱体/收敛】突破K收盘越过关键位(占该侧价格%)的下限;无上限(过猛由计划RR过滤) +KEY_BREAKOUT_AMP_MIN_PCT=0.03 +# 已不参与门控,可保留配置项兼容旧环境 +KEY_BREAKOUT_AMP_MAX_PCT=0.5 +# 【阻力/支撑】突破后微信提醒次数与间隔(分钟) +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=5 +# 【日成交量排名】品种须在该排名前 N 名(添加关键位与运行时门控均校验) +KEY_DAILY_VOLUME_RANK_MAX=30 +# 【关键位自动开仓盈亏比】按确认K收盘 E 计算,严格大于该值才市价开仓(如 1.5 表示须 >1.5:1) +# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%) +KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5 +# 趋势单方案:止损在突破 K 极值外侧的百分比(默认 1 即 1%) +KEY_TREND_STOP_OUTSIDE_PCT=1 + +# ============================================================================= +# 交易执行 / 人工风控(页面「实盘下单」) +# ============================================================================= +# 【最大同时持仓】active 订单数达到该值后禁止人工与关键位自动再加仓(默认 1=单仓) +MAX_ACTIVE_POSITIONS=1 +# 【人工下单最低盈亏比】按当前价与 SL/TP 计算,低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1) +MANUAL_MIN_PLANNED_RR=1.4 +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true +DAILY_OPEN_ALERT_THRESHOLD=5 +# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 +DAILY_OPEN_HARD_LIMIT=0 + +# ============================================================================= +# 详见 docs/account-risk-cooldown.md +# ============================================================================= +RISK_CONTROL_ENABLED=true +RISK_COOLING_HOURS_MANUAL=4 +RISK_COOLING_HOURS_MANUAL_JOURNAL=1 +RISK_MANUAL_CLOSE_DAILY_LIMIT=2 +# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用 +RISK_DAILY_LOSS_LIMIT=2 +RISK_MOOD_ISSUES_DAILY_FREEZE=true + +# 资金与仓位刷新周期(秒) +BALANCE_REFRESH_SECONDS=60 +# 前端价格快照轮询(秒) +PRICE_REFRESH_SECONDS=5 +# 后台监控轮询周期(秒) +MONITOR_POLL_SECONDS=3 +# 移动保本同步交易所止盈止损的最小间隔(秒),避免频繁撤挂叠单 +BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC=60 +# 重启后多少秒内不做「外部平仓」同步(避免 API 未就绪误判) +RECONCILE_STARTUP_GRACE_SEC=90 +# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) +RECONCILE_FLAT_CONFIRM_POLLS=3 +# 使用可用资金时的缓冲比例(如0.98代表用98%) +FULL_MARGIN_BUFFER_RATIO=0.98 + +# ============================================================================= +# ============================================================================= +AUTO_TRANSFER_ENABLED=false +# 交易账户(swap)目标余额 U:每日 8 点(北京)自动划入或划出至 funding;持仓中不划转 +AUTO_TRANSFER_AMOUNT=30 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +TRANSFER_CCY=USDT +# 北京时间该整点小时内尝试;账簿按 UTC 自然日去重 +AUTO_TRANSFER_BJ_HOUR=8 +# 强制清仓整点(北京时间,默认 0=凌晨00点) +FORCE_CLOSE_BJ_HOUR=0 +# 是否启用强制清仓(默认关闭,true 才会在整点执行) +FORCE_CLOSE_ENABLED=false +# 强制清仓执行窗口(分钟,默认 5;该窗口内禁止开仓) +FORCE_CLOSE_GRACE_MINUTES=5 + +WECHAT_TIMEOUT_SECONDS=10 + + +# OKX 代理(可选,仅本地开发网络受限时用;云服务器部署请留空,直连 OKX 即可) +# 1) 先在本机建立隧道(示例): +# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes +# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 +# +# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: +# OKX_HTTP_PROXY=http://127.0.0.1:3128 +# OKX_HTTPS_PROXY=http://127.0.0.1:3128 + +# 开仓多周期K线图(可选) +# ORDER_CHART_ENABLED=true +# ORDER_CHART_TFS=4h,1h,15m,5m +# ORDER_CHART_LIMIT=100 +# ORDER_CHART_DIR=static/images/order_charts +# 以损定仓(按交易账户资金的百分比) +# RISK_PERCENT=2 +# 移动保本触发(达到多少R触发)与偏移(百分比) +# BREAKEVEN_RR_TRIGGER=1.0 +# 移动保本阶梯(每多少R继续上移一次,默认1R) +# BREAKEVEN_STEP_R=1.0 +# BREAKEVEN_OFFSET_PCT=0.02 +# 开单风格默认值:trend / swing +# DEFAULT_TRADE_STYLE=trend + +APP_TIMEZONE=Asia/Shanghai +# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..77dda83 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Auto LF for shell scripts (avoid CRLF breaking bash on Linux) +*.sh text eol=lf +deploy/**/*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9da156 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ + +# Env & secrets +.env +*.pem +*.key + +# Runtime data +*.db +*.db-journal +data/ +static/images/ +!static/images/.gitkeep +static/images/order_charts/ +backups/ + +# OS / IDE +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp + +# Logs / PM2 +*.log +.pm2/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2150675 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# crypto_okx + +基于 **Flask** 的 **OKX 期权 / 对冲** 独立系统.仓库:[https://git.bz121.com/dekun/crypto_okx.git](https://git.bz121.com/dekun/crypto_okx.git) + +## 功能概要 + +- **期权**:`/options` +- **期权复盘**:`/options/review` +- **对冲计划**:`/hedge-plan` +- **模拟资金**:系统设置可切换模拟/实盘 +- **实盘(可选)**:`LIVE_TRADING_ENABLED=true` + OKX API + +与中控 / `manual_trading_hub` / 其它交易所实例无依赖. + +## 一键部署(Ubuntu) + +```bash +curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash +``` + +安装到 `/opt/crypto_okx`,PM2 进程名 `crypto_okx`,默认端口 **5004**. + +详见 **[部署文档.md](./部署文档.md)**、**[deploy/README.md](./deploy/README.md)**. + +## 环境要求 + +- Python 3.10+ +- 依赖:`requirements.txt`(SOCKS 需 **PySocks**) +- 生产建议 Ubuntu 22.04/24.04 + PM2 + +## 配置 + +完整模板见 **`.env.example`**.常用变量: + +| 变量 | 说明 | +|------|------| +| `OKX_API_KEY` / `OKX_API_SECRET` / `OKX_API_PASSPHRASE` | OKX API | +| `OKX_SOCKS_PROXY` | 如 `socks5h://127.0.0.1:1080` | +| `APP_PORT` | 默认 `5004` | +| `SIM_DEFAULT_MODE` | `sim` / `live` | + +## 本地运行 + +```bash +cd /opt/crypto_okx +cp -n .env.example .env +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python app.py +``` + +或: + +```bash +bash deploy/setup_env.sh +pm2 start ecosystem.config.cjs +``` + +## 自检 + +```bash +python scripts/verify_okx_funding.py +``` + +## 风险与合规 + +实盘风险自负;请确认 API 权限与 IP 白名单. diff --git a/app.py b/app.py new file mode 100644 index 0000000..4cb8d40 --- /dev/null +++ b/app.py @@ -0,0 +1,7235 @@ +from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response, send_file +import sqlite3 +import csv +from io import StringIO +import time +import threading +import requests +import os +import re +import base64 +import json +import math +from datetime import datetime, timedelta, timezone + +try: + from zoneinfo import ZoneInfo +except ImportError: + ZoneInfo = None # type: ignore +from functools import wraps +import uuid +import ccxt +from werkzeug.utils import secure_filename + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + Image = None # type: ignore + ImageDraw = None # type: ignore + ImageFont = None # type: ignore + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = BASE_DIR # 独立项目: lib/ 与 app.py 同级 +import sys + +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +from lib.paths import common_static_dir +from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order +from lib.key_monitor.key_monitor_lib import ( + KEY_DIRECTION_WATCH, + KEY_MONITOR_ALERT_ONLY_TYPES, + KEY_MONITOR_AUTO_TYPES, + KEY_MONITOR_RS_TYPE, + KEY_MONITOR_RS_TYPES, + backfill_missing_key_signal_types, + claim_rs_level_notify, + detect_rs_box_break, + entry_reason_from_key_signal, + is_fib_key_monitor_type, + is_false_breakout_key_monitor_type, + is_limit_key_monitor_type, + is_trigger_entry_key_monitor_type, + key_monitor_rule_template_context, + key_signal_type_for_trade_record, + notify_interval_elapsed, + resolve_rs_break_for_alert, + rs_break_from_direction, + run_rs_level_alert_tick, +) +from lib.trade.trade_labels_lib import ( + JOURNAL_ORDER_TYPE_OPTIONS, + apply_order_monitor_source_labels, + entry_reason_for_monitor_type, + handoff_trade_miss_reason, + normalize_journal_order_type, + order_monitor_source_type, + trade_record_monitor_type as resolve_trade_record_monitor_type, + trend_plan_id_from_monitor_row, +) +from lib.instance.journal_form_lib import normalize_journal_direction, normalize_journal_entry_reason +from lib.exchange.okx_orders_lib import cancel_okx_all_open_orders, fetch_okx_all_open_orders +from lib.instance.journal_images_lib import ( + collect_journal_slot_images, + enrich_journal_api_item, + images_json_dumps, + journal_image_paths, + normalize_journal_draft_id, + primary_journal_image, +) +from lib.instance.journal_upload_api_lib import handle_journal_upload_slot +from lib.instance.journal_chart_lib import ( + JOURNAL_CHART_DEFAULT_LIMIT, + JOURNAL_CHART_DEFAULT_TF1, + JOURNAL_CHART_DEFAULT_TF2, + JOURNAL_CHART_TF_CHOICES, + compose_chart_panels, + marker_points_for_timeframe, + parse_journal_chart_anchor, + parse_journal_chart_limit, + parse_journal_chart_timeframes, + JOURNAL_CHART_DEFAULT_ANCHOR, + price_levels_from_marker_payload, + render_candles_subplot, + trade_review_fetch_window, + trim_rows_for_trade_review, +) +from lib.key_monitor.key_sl_tp_lib import ( + breakeven_enabled_from_row, + normalize_sl_tp_mode, + parse_breakeven_enabled_form, + plan_key_sl_tp, + sl_tp_mode_from_row, + sl_tp_mode_label, + sl_tp_plan_summary_text, +) +from lib.trade.time_close_lib import ( + TIME_CLOSE_RESULT, + apply_time_close_to_payload, + ensure_time_close_schema, + parse_time_close_enabled_form, + parse_time_close_hours_form, + should_trigger_time_close, + time_close_insert_values, + time_close_label, + time_close_settings_from_row, +) +from lib.trade.force_close_lib import ( + apply_force_close_display_result, + apply_force_close_to_payload, + enrich_orders_force_close, + force_close_template_context, +) +from lib.trade.manual_sltp_lib import ( + normalize_open_sltp_mode, + resolve_entrust_sltp_prices, + resolve_open_sltp_prices, +) +from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema +from lib.trade.position_sizing_lib import ( + OPEN_SOURCE_MANUAL, + assert_open_source_allowed, + compute_full_margin_sizing, + format_risk_display_text, + full_margin_requires_flat_position, + is_full_margin_mode, + leverage_for_full_margin, + load_position_sizing_mode, + mode_label_zh, + risk_percent_for_storage, +) +from lib.trade.trade_policy_lib import load_trade_policy +from lib.trade.entry_model_lib import ( + build_intraday_entry_reason_options, + build_journal_entry_reason_options, + enrich_entry_model_display, + migrate_entry_model_columns, + order_entry_template_context, + open_position_button_label, + parse_manual_order_style_fields, + resolve_effective_trade_entry_reason, + format_entry_type_display, + resolve_trade_record_entry_reason, + trend_manual_entry_reason_count, +) +from lib.trade.trade_policy_app_lib import ( + check_direction_policy, + check_open_policy, + check_symbol_policy, + default_symbol_for_policy, + trade_policy_template_context, +) +from lib.common.auto_transfer_daily_lib import run_auto_transfer_once_per_day +from lib.trade.order_monitor_display_lib import ( + apply_order_price_display_fields, + enrich_order_display_fields, + order_monitor_tpsl_needs_sync, + stale_breakeven_armed, +) +from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook +from lib.instance.instance_nav_lib import request_is_hub_soft_nav +from lib.market.volume_rank_lib import resolve_daily_volume_rank +from lib.market.price_snapshot_lib import resolve_order_snapshot_price +from lib.common.history_window_lib import ( + PRESET_ALL, + PRESET_CUSTOM, + PRESET_DEFAULT, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST3M, + PRESET_UTC_LAST6M, + PRESET_UTC_LAST7D, + PRESET_UTC_THIS_MONTH, + PRESET_UTC_TODAY, + list_window_redirect_query, + normalize_bj_datetime_storage, + resolve_list_window, + resolve_window, + sql_list_time_field, + utc_window_to_bj_sql_strings, + utc_window_to_utc_sql_strings, +) +from lib.trade.trade_result_lib import ( + classify_exit_by_levels, + count_winning_trades, + filter_trade_records_excluding_miss, + normalize_result_with_pnl, +) +from lib.trade.trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills + + +def load_env_file(path): + if not os.path.exists(path): + return + raw_bytes = open(path, "rb").read() + text = "" + for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"): + try: + text = raw_bytes.decode(enc) + break + except Exception: + continue + if not text: + text = raw_bytes.decode("utf-8", errors="ignore") + text = text.replace("\x00", "") + for line in text.splitlines(): + raw = line.strip() + if not raw or raw.startswith("#") or "=" not in raw: + continue + key, value = raw.split("=", 1) + clean_key = key.strip().lstrip("\ufeff") + if not clean_key.replace("_", "").isalnum(): + continue + clean_value = value.strip().strip('"').strip("'") + os.environ[clean_key] = clean_value + +load_env_file(os.path.join(BASE_DIR, ".env")) + + +def resolve_path(path_value): + if os.path.isabs(path_value): + return path_value + return os.path.join(BASE_DIR, path_value) + +app = Flask(__name__) +app.secret_key = os.getenv("FLASK_SECRET_KEY") or os.urandom(32) +from lib.instance.instance_embed_lib import attach_embed_templates, redirect_to_embed_shell_if_enabled + +attach_embed_templates(app, _REPO_ROOT) + +# ====================== 登录配置 ====================== +# 独立部署须在 .env 配置账号;勿在源码写真实口令 +USERNAME = (os.getenv("APP_USERNAME") or "").strip() +PASSWORD = (os.getenv("APP_PASSWORD") or "").strip() +AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") +if not AUTH_DISABLED and (not USERNAME or not PASSWORD): + print("[auth] APP_USERNAME/APP_PASSWORD 未配置:请设置 .env,或临时 APP_AUTH_DISABLED=true") + +# 企业微信机器人Webhook +WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") +SYSTEM_TYPE = "CRYPTO" +HOST = os.getenv("APP_HOST", "0.0.0.0") +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" + + +def _promote_legacy_options_api_keys() -> None: + """1B: OKX_API_* 为空时,用废弃的 OKX_OPTIONS_API_* 回填到进程环境.""" + if (os.getenv("OKX_API_KEY") or "").strip(): + return + legacy_key = (os.getenv("OKX_OPTIONS_API_KEY") or "").strip() + legacy_secret = (os.getenv("OKX_OPTIONS_API_SECRET") or "").strip() + legacy_pass = (os.getenv("OKX_OPTIONS_API_PASSPHRASE") or "").strip() + if not (legacy_key and legacy_secret and legacy_pass): + return + os.environ["OKX_API_KEY"] = legacy_key + os.environ["OKX_API_SECRET"] = legacy_secret + os.environ["OKX_API_PASSPHRASE"] = legacy_pass + + +_promote_legacy_options_api_keys() +OKX_API_KEY = os.getenv("OKX_API_KEY", "") +OKX_API_SECRET = os.getenv("OKX_API_SECRET", "") +OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "") +OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "true").lower() in ("1", "true", "yes", "on") +OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10")) +OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() +OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross") +OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge") +EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX" +BALANCE_REFRESH_SECONDS = 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() +TRADE_POLICY = load_trade_policy() +WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) +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") or "USDT").strip().upper() or "USDT" +OKX_POSITION_INST_TYPE = os.getenv("OKX_POSITION_INST_TYPE", "SWAP") +EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip() +EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, 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_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_API_*:swap 客户端跑永续,option 客户端跑期权(身份相同,defaultType 不同) +exchange = ccxt.okx({ + "enableRateLimit": True, + "options": {"defaultType": "swap"}, # OKX 用 swap 表示永续 +}) +if OKX_CCXT_PROXIES: + exchange.proxies = OKX_CCXT_PROXIES +if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE: + exchange.apiKey = OKX_API_KEY + exchange.secret = OKX_API_SECRET + exchange.password = OKX_API_PASSPHRASE + +exchange_options = ccxt.okx( + { + "enableRateLimit": True, + "options": {"defaultType": "option"}, + } +) +if OKX_CCXT_PROXIES: + exchange_options.proxies = OKX_CCXT_PROXIES +if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE: + exchange_options.apiKey = OKX_API_KEY + exchange_options.secret = OKX_API_SECRET + exchange_options.password = OKX_API_PASSPHRASE + +MARKETS_LOADED = False +ACCOUNT_BALANCE_CACHE = { + "updated_at": 0.0, + "funding_usdt": None, + "trading_usdt": None +} +LIQUIDITY_RANK_CACHE = { + "updated_at": 0.0, + "version": 0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + send_wechat_webhook( + WECHAT_WEBHOOK, content, timeout=WECHAT_TIMEOUT_SECONDS + ) + + +_BREAKEVEN_EXCHANGE_WARNED_IDS = set() + + +def _send_breakeven_exchange_warn_once(order_id, message): + """移动保本同步交易所失败:同一笔监控单只推送一次,避免轮询刷屏.""" + oid = int(order_id) + if oid in _BREAKEVEN_EXCHANGE_WARNED_IDS: + return + _BREAKEVEN_EXCHANGE_WARNED_IDS.add(oid) + send_wechat_msg(message) + + +def _clear_breakeven_exchange_warn(order_id): + _BREAKEVEN_EXCHANGE_WARNED_IDS.discard(int(order_id)) + + +def _wechat_account_label(): + return (os.getenv("OKX_ACCOUNT_LABEL") or "okx实盘子账户").strip() + + +def _wechat_direction_text(direction): + d = (direction or "").lower() + return "多头(long)" if d == "long" else "空头(short)" + + +def _wechat_trading_capital_text(fallback=None): + try: + _, trading_capital = get_exchange_capitals(force=True) + except Exception: + trading_capital = None + if trading_capital is not None: + return f"{round(float(trading_capital), 2)}U" + if fallback is not None: + try: + return f"{round(float(fallback), 2)}U" + except Exception: + pass + return "-" + + +def format_wechat_scalar_2dp(value): + """企业微信推送:数值统一两位小数(与交易所 tick 无关).""" + if value in (None, ""): + return "-" + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return str(value) + + +def build_wechat_close_message( + symbol, + direction, + result, + pnl_amount, + hold_seconds=None, + trigger_price=None, + current_price=None, + stop_loss=None, + take_profit=None, + close_order_id=None, + extra_note=None, + session_capital_fallback=None, +): + hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-" + ep = format_price_for_symbol(symbol, trigger_price) + cp = format_price_for_symbol(symbol, current_price) + tp = format_price_for_symbol(symbol, take_profit) + sl = format_wechat_scalar_2dp(stop_loss) + cap_txt = _wechat_trading_capital_text(session_capital_fallback) + try: + if pnl_amount is not None: + pv = float(pnl_amount) + pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 2)} U" + else: + pnl_disp = "-" + except (TypeError, ValueError): + pnl_disp = "-" + + lines = [ + f"📉 {symbol} 平仓完成", + f"💼 账户:{_wechat_account_label()}", + "", + "🧾 平仓概要", + f"🔖 平仓单号:{close_order_id or '-'}", + f"📌 方向:{_wechat_direction_text(direction)}", + f"📌 平仓结果:{result or '-'}", + f"💰 本单盈亏:{pnl_disp}", + f"⏱ 持仓时长:{hold_txt}", + f"💵 交易账户资金:{cap_txt}", + "", + "🎯 价位(计划)", + f"开仓成交价:{ep}", + f"离场参考价:{cp}", + f"止盈价位:{tp}", + f"止损价位:{sl}", + ] + if extra_note: + lines.extend(["", "📎 备注", extra_note]) + return "\n".join(lines) + + +def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): + return "\n".join( + [ + f"# 🛡️ {symbol} 保护位更新", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 移动保本/止盈", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 类型:**{arm_txt}**", + f"- 当前RR:`{round(float(now_rr), 2)}R`", + f"- 锁定RR:`{round(float(locked_r), 2)}R`", + f"- 新保护位:`{format_wechat_scalar_2dp(new_sl)}`", + ] + ) + + +def build_wechat_monitor_error_message(symbol, direction, scene, error_text): + return "\n".join( + [ + f"# ⚠️ {symbol} 下单监控异常", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 异常信息", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", + ] + ) + + +def build_wechat_key_monitor_message( + symbol, + direction, + monitor_type, + trigger_time, + key_price, + confirm_close, + hard_lines, + btc8h_status, + coin4h_status, + swing4h_pct, + op_lines, + risk_tip=None, +): + lines = [ + f"# 🎯 {symbol} 关键位确认推送", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 交易对 / 触发时间", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", + "", + "### 方向与确认K", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", + "", + "### 关键价位", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", + "", + "### 硬条件校验结果", + ] + lines.extend([f"- {x}" for x in hard_lines]) + lines.extend( + [ + "", + "### 市场状态说明", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + "", + "### 操作提示", + ] + ) + lines.extend([f"- {x}" for x in op_lines]) + if risk_tip: + lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) + return "\n".join(lines) + + +def _read_image_base64(image_path): + try: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + except Exception: + return None + + +def _extract_json_object(text): + if not text: + return None + clean = text.strip() + if clean.startswith("```"): + clean = clean.replace("```json", "").replace("```", "").strip() + try: + return json.loads(clean) + except Exception: + pass + match = re.search(r"\{[\s\S]*\}", clean) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: + return None + + +def _load_font(size): + if not ImageFont: + return None + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\arial.ttf", + ] + for path in candidates: + if path and os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except Exception: + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def _ohlcv_to_rows(ohlcv): + rows = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + rows.append( + { + "ts": int(bar[0]), + "o": float(bar[1]), + "h": float(bar[2]), + "l": float(bar[3]), + "c": float(bar[4]), + "v": float(bar[5]), + } + ) + except Exception: + continue + return rows + + +def _local_input_datetime_to_ms(dt_text): + raw = str(dt_text or "").strip() + if not raw: + return None + raw = raw.replace("T", " ") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(raw, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + return None + + +def _marker_tag_label(tag): + t = str(tag or "").strip().upper() + if t == "ENTRY": + return "开仓" + if t == "EXIT": + return "平仓" + return str(tag or "") + + +def _pick_marker_point(rows, target_ts_ms, target_price=None): + if not rows or target_ts_ms is None: + return None, None + idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) + if target_price is not None: + try: + p = float(target_price) + if p > 0: + return idx, p + except Exception: + pass + return idx, float(rows[idx]["c"]) + + +def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): + if not Image or not ImageDraw: + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") + img = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(img) + font = _load_font(14) + small = _load_font(12) + + pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 + plot_w = max(10, width - pad_l - pad_r) + plot_h = max(10, height - pad_t - pad_b) + + header_bg = (245, 247, 250) + draw.rectangle((0, 0, width, pad_t), fill=header_bg) + if font: + draw.text((10, 6), title, fill=(25, 35, 60), font=font) + else: + draw.text((10, 6), title, fill=(25, 35, 60)) + + if not rows: + if small: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) + else: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) + return img + + lo = min(r["l"] for r in rows) + hi = max(r["h"] for r in rows) + if hi <= lo: + hi = lo + 1e-12 + + n = len(rows) + marker_by_idx = {} + for mp in marker_points or []: + try: + idx = int(mp.get("idx")) + except Exception: + continue + if idx < 0 or idx >= n: + continue + marker_by_idx.setdefault(idx, []).append(mp) + + x0 = pad_l + for i, r in enumerate(rows): + x1 = pad_l + int((i + 1) * plot_w / n) + x_mid = (x0 + x1) // 2 + wick_x = x_mid + y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) + y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) + y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) + y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) + top = min(y_open, y_close) + bot = max(y_open, y_close) + up = r["c"] >= r["o"] + wick_color = (120, 120, 120) + edge_color = (20, 20, 20) + draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) + body_w = max(1, (x1 - x0) - 2) + left = x0 + 1 + if bot - top < 2: + mid = (top + bot) // 2 + draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) + else: + if up: + draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) + else: + draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) + for j, mp in enumerate(marker_by_idx.get(i, [])): + tag = str(mp.get("tag") or "") + label = _marker_tag_label(tag) + m_price = float(mp.get("price") or r["c"]) + y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) + y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) + x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14 + x_draw = int(x_mid + x_off) + if tag == "ENTRY": + m_color = (0, 195, 95) + tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)] + text_y = y_m - 36 + else: + m_color = (235, 65, 65) + tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)] + text_y = y_m + 12 + draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) + draw.polygon(tri, fill=m_color) + draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) + if font: + draw.text((x_draw + 8, text_y), label, fill=m_color, font=font) + else: + draw.text((x_draw + 8, text_y), label, fill=m_color) + x0 = x1 + + if len(marker_points or []) >= 2: + try: + entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) + exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) + if entry is not None and exitp is not None: + ex_i, ex_p = int(entry["idx"]), float(entry["price"]) + xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) + x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) + x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) + y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) + y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) + draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) + except Exception: + pass + + # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 + if small: + draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) + return img + + +def _timeframe_period_ms(tf): + s = (tf or "").strip().lower() + if s.endswith("m"): + try: + return int(s[:-1]) * 60 * 1000 + except ValueError: + pass + if s.endswith("h"): + try: + return int(s[:-1]) * 3600 * 1000 + except ValueError: + pass + if s.endswith("d"): + try: + return int(s[:-1]) * 86400 * 1000 + except ValueError: + pass + return 300000 + + +def _ohlcv_dict_rows_to_lists(rows, lim): + if not rows: + return [] + pick = rows[-lim:] if len(rows) >= lim else rows + return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick] + + +def _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", "关键位监控", {"segment": "key"}), +) +def normalize_entry_reason(raw, custom_text=None): + del custom_text + return normalize_journal_entry_reason(raw, ENTRY_REASON_OPTIONS, allow_legacy=True) + + +def normalize_early_exit_trigger(raw): + v = str(raw or "").strip() + return v if v in EARLY_EXIT_TRIGGERS else "" + + +def compose_early_exit_reason_saved(trigger, note): + """Readable single-line string stored in early_exit_reason for legacy consumers.""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t and n: + return f"{t}|{n}" + return t or n + + +def journal_exit_reason_stored(trigger, note): + """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文.""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t == "手动平仓": + return n + return t + + +# 初始化数据库(支持多空方向) +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 关键位监控 + c.execute('''CREATE TABLE IF NOT EXISTS key_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", upper REAL, lower REAL, + notification_count INTEGER DEFAULT 0, last_notified_at TEXT, + max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5, + breakout_limit_pct REAL DEFAULT 1.5, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + # 订单监控(核心:加 direction 方向字段) + c.execute('''CREATE TABLE IF NOT EXISTS order_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", + exchange_symbol TEXT, + trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5, + trade_style TEXT DEFAULT "trend", + risk_percent REAL, risk_amount REAL, + breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL, + breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL, + notional_value REAL, position_ratio REAL, base_amount REAL, + order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT, + opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT, + status TEXT DEFAULT "active")''') + + # 交易记录(必须存多空) + c.execute('''CREATE TABLE IF NOT EXISTS trade_records + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0, + trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL, + hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER, + result TEXT, miss_reason TEXT, exchange_trade_id TEXT, + reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL, + reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER, + reviewed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions + (session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS journal_entries + (id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT, + coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT, + expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT, + early_exit_trigger TEXT, early_exit_note TEXT, + mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT, + new_trade_while_occupied TEXT, note TEXT, image TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews + (id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs + (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, + amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute( + """CREATE TABLE IF NOT EXISTS app_runtime_settings + (key TEXT PRIMARY KEY, value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""" + ) + c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') + c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique + ON transfer_logs(transfer_type, transfer_day) + WHERE transfer_type = 'auto_daily' ''') + + # 给旧表加 direction 字段(兼容老数据,不报错) + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT") + except: pass + try: + c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") + except: pass + for ddl in ( + "ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL", + "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL", + "ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL", + ): + try: + c.execute(ddl) + except Exception: + pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN images_json TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN order_type TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN direction TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '下单监控'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT") + except: pass + for ddl in ( + "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", + "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", + "ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'", + "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", + "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", + "ALTER TABLE key_monitors ADD COLUMN session_date TEXT", + ): + try: + c.execute(ddl) + except Exception: + pass + ensure_time_close_schema(c) + ensure_key_monitor_schema(c) + try: + c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") + except Exception: + pass + + c.execute( + """CREATE TABLE IF NOT EXISTS key_monitor_history + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, + close_reason TEXT, closed_at TEXT)""" + ) + + from lib.options.options_db import init_options_tables + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables + + init_options_tables(conn) + init_hedge_plan_tables(conn) + from lib.sim.db_lib import init_sim_tables + init_sim_tables(conn) + from lib.trade.account_risk_lib import ensure_account_risk_schema + + ensure_account_risk_schema(conn) + migrate_entry_model_columns(conn) + backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO) + conn.commit() + conn.close() + +init_db() + + +def _purge_key_monitors_if_full_margin(): + return + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def account_risk_status(conn): + from lib.trade.account_risk_lib import ( + apply_position_limit_risk, + compute_account_risk_status, + enrich_risk_status_countdown, + ensure_account_risk_schema, + ) + + ensure_account_risk_schema(conn) + now = app_now() + st = compute_account_risk_status( + conn, + trading_day=get_trading_day(), + now=now, + fmt_local_ms=ms_to_app_local_str, + ) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + from lib.trade.trade_labels_lib import count_position_limit_active_monitors + + return apply_position_limit_risk( + st, + count_position_limit_active_monitors(conn), + max_active_positions=MAX_ACTIVE_POSITIONS, + ) + + +def hub_user_initiated_close( + conn, + *, + source, + count=1, + trade_record_id=None, + closed_at_ms=None, +): + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close + + src = (source or "").strip() or CLOSE_SOURCE_USER_HUB + on_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=get_trading_day(), + now=app_now(), + count=count, + ) + + +def app_now(): + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较).""" + return datetime.now(APP_TZ).replace(tzinfo=None) + + +def app_now_str(): + return app_now().strftime("%Y-%m-%d %H:%M:%S") + + +def utc_now_dt(): + """当前时刻(UTC,aware).""" + return datetime.now(timezone.utc) + + +def utc_calendar_date_str(): + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算).""" + return utc_now_dt().strftime("%Y-%m-%d") + + +def get_trading_day(now=None): + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」.""" + now = now or app_now() + if getattr(now, "tzinfo", None): + now = now.astimezone(APP_TZ).replace(tzinfo=None) + if now.hour < TRADING_DAY_RESET_HOUR: + return (now - timedelta(days=1)).strftime("%Y-%m-%d") + return now.strftime("%Y-%m-%d") + + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", + TIME_CLOSE_RESULT, +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓", TIME_CLOSE_RESULT) + + +def parse_dt_for_trading_day(s): + if not s: + return None + s = str(s).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:ln], fmt) + except ValueError: + continue + return None + + +def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): + conn.execute( + """INSERT INTO key_monitor_history + (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + row["symbol"], + row["monitor_type"], + row["direction"] or "long", + row["upper"], + row["lower"], + int(notification_count or 0), + (last_msg or "")[:800] if last_msg else None, + close_reason, + app_now_str(), + ), + ) + + +def _session_week_bounds(trading_day_str): + end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() + start = end - timedelta(days=6) + return start.strftime("%Y-%m-%d"), trading_day_str + + +def _calendar_month_bounds(local_dt): + y, m = local_dt.year, local_dt.month + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end_d = datetime(y, 12, 31).date() + else: + end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() + return start, end_d.strftime("%Y-%m-%d") + + +def _count_opens_between(conn, start_td, end_td): + return _count_opens_for_segment(conn, start_td, end_td, "all") + + +def _list_window_from_request(): + return resolve_list_window(request.args, session, default_preset=PRESET_DEFAULT) + + +def _redirect_records(): + qs = list_window_redirect_query(session) + return redirect(f"/records?{qs}" if qs else "/records") + + +def _pnl_row_matches_segment(row, segment_key): + try: + mt = (row["monitor_type"] or "").strip() + kst = (row["key_signal_type"] or "").strip() + except Exception: + return False + if segment_key == "all": + return True + if segment_key == "manual": + return mt == ORDER_MONITOR_TYPE_MANUAL and not kst + if segment_key == "key": + return mt == ORDER_MONITOR_TYPE_KEY_AUTO or bool(kst) or "关键位" in mt + return False + + +def _count_opens_for_segment(conn, start_td, end_td, segment_key): + if segment_key == "manual": + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " + "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", + (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), + ).fetchone()[0] + if segment_key == "key": + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + "AND (monitor_type=? OR (key_signal_type IS NOT NULL AND TRIM(key_signal_type)!=''))", + (start_td, end_td, ORDER_MONITOR_TYPE_KEY_AUTO), + ).fetchone()[0] + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _load_completed_trade_pnls(conn): + q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, + result, reviewed_result, monitor_type, key_signal_type + FROM trade_records + ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" + rows = conn.execute(q).fetchall() + out = [] + for r in rows: + effective_result = (r["reviewed_result"] or r["result"] or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + continue + try: + p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + except (TypeError, ValueError): + p = 0.0 + t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) + td = get_trading_day(t) if t else None + out.append((p, t, td, r)) + return out + + +def _compute_period_metrics(trades): + """trades: list of (pnl, close_dt, close_trading_day)""" + trades = [(p, t, td) for p, t, td in trades if t is not None] + trades.sort(key=lambda x: x[1]) + closed = len(trades) + wins = sum(1 for p, _, _ in trades if p > 0) + losses = sum(1 for p, _, _ in trades if p < 0) + net = round(sum(p for p, _, _ in trades), 4) + loss_sum_raw = sum(p for p, _, _ in trades if p < 0) + loss_sum_u = round(abs(loss_sum_raw), 4) if loss_sum_raw < 0 else 0.0 + neg_pnls = [p for p, _, _ in trades if p < 0] + pos_pnls = [p for p, _, _ in trades if p > 0] + max_single_loss = round(min(neg_pnls), 4) if neg_pnls else None + max_single_profit = round(max(pos_pnls), 4) if pos_pnls else None + cum = peak = max_dd = 0.0 + for p, _, _ in trades: + cum += p + peak = max(peak, cum) + max_dd = max(max_dd, peak - cum) + max_dd = round(max_dd, 4) + streak = 0 + for p, _, _ in reversed(trades): + if p < 0: + streak += 1 + else: + break + daily = {} + for p, _, td in trades: + if td: + daily[td] = daily.get(td, 0.0) + p + max_loss_streak_days = 0 + worst_day = None + worst_day_pnl = None + if daily: + sorted_days = sorted(daily.keys()) + run = 0 + for d in sorted_days: + if daily[d] < 0: + run += 1 + max_loss_streak_days = max(max_loss_streak_days, run) + else: + run = 0 + worst_day = min(daily.keys(), key=lambda x: daily[x]) + worst_day_pnl = round(daily[worst_day], 4) + win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None + return { + "closed_count": closed, + "win_count": wins, + "loss_count": losses, + "win_rate_pct": win_rate_pct, + "net_pnl_u": net, + "loss_sum_u": loss_sum_u, + "max_single_loss": max_single_loss, + "max_single_profit": max_single_profit, + "max_drawdown_u": max_dd, + "consecutive_losses": streak, + "max_loss_streak_days": max_loss_streak_days, + "worst_day": worst_day, + "worst_day_pnl": worst_day_pnl, + "opens_count": 0, + "range_label": "", + } + + +def _bounds_for_month_key(ym): + """ym: YYYY-MM → 该自然月首末日(北京日历).""" + y, m = [int(x) for x in str(ym).split("-", 1)] + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end = f"{y:04d}-12-31" + else: + end = (datetime(y, m + 1, 1) - timedelta(days=1)).date().strftime("%Y-%m-%d") + return start, end + + +def _build_monthly_stats_rows(conn, all_tr, seg_key): + """按北京交易日所在自然月聚合;新月在前.""" + by_month = {} + for p, t, td in all_tr: + if not td or len(str(td)) < 7: + continue + mk = str(td)[:7] + by_month.setdefault(mk, []).append((p, t, td)) + rows = [] + for mk in sorted(by_month.keys(), reverse=True): + metrics = _compute_period_metrics(by_month[mk]) + ms, me = _bounds_for_month_key(mk) + metrics["opens_count"] = _count_opens_for_segment(conn, ms, me, seg_key) + metrics["range_label"] = f"{ms} ~ {me}" + metrics["month_key"] = mk + rows.append(metrics) + return rows + + +def compute_stats_bundle(conn, trading_day, now_dt=None): + """日 / 周 / 月 / 全部 统计:平仓按北京时间交易日(默认 8:00 切日)计入.""" + now_dt = now_dt or app_now() + pnls = _load_completed_trade_pnls(conn) + total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] + w_start, w_end = _session_week_bounds(trading_day) + m_start, m_end = _calendar_month_bounds(now_dt) + + def slice_metrics(seg_key): + seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)] + day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] + week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] + month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] + all_tr = [(p, t, td) for p, t, td, _r in seg_rows if t] + dm = _compute_period_metrics(day_tr) + wm = _compute_period_metrics(week_tr) + mm = _compute_period_metrics(month_tr) + am = _compute_period_metrics(all_tr) + dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) + wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) + mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) + am["opens_count"] = _count_opens_for_segment(conn, "1970-01-01", "9999-12-31", seg_key) + dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" + wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" + mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" + tds = [td for _, _, td in all_tr if td] + if tds: + am["range_label"] = f"全部历史 {min(tds)} ~ {max(tds)}(北京交易日)" + else: + am["range_label"] = "全部历史(暂无平仓)" + am["monthly_rows"] = _build_monthly_stats_rows(conn, all_tr, seg_key) + return dm, wm, mm, am + + segments = [] + seg_defs = STATS_SEGMENT_DEFS + for seg_key, seg_title, _meta in seg_defs: + dm, wm, mm, am = slice_metrics(seg_key) + segments.append( + {"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm, "all": am} + ) + + dm, wm, mm, am = slice_metrics("all") + + return { + "trading_day": trading_day, + "total_opens_all": total_opens_all, + "day": dm, + "week": wm, + "month": mm, + "all": am, + "segments": segments, + "stats_reset_hour": TRADING_DAY_RESET_HOUR, + } + + +def infer_leverage(symbol): + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return BTC_LEVERAGE + return ALT_LEVERAGE + + +def normalize_okx_symbol(symbol): + sym = symbol.strip().upper() + if ":" in sym: + return sym + if "/" in sym: + base, quote = sym.split("/", 1) + quote_clean = quote.split(":")[0] + return f"{base}/{quote_clean}:{quote_clean}" + return sym + + +def resolve_monitor_exchange_symbol(row): + raw = "" + try: + if row["exchange_symbol"]: + raw = str(row["exchange_symbol"]).strip() + except (KeyError, IndexError, TypeError): + raw = "" + if not raw: + try: + raw = str(row["symbol"] or "").strip() + except (KeyError, IndexError, TypeError): + raw = "" + return normalize_okx_symbol(raw) if raw else "" + + +def round_price_to_exchange(exchange_symbol, price): + if price in (None, ""): + return None + try: + v = float(price) + except (TypeError, ValueError): + return None + if not exchange_symbol: + return v + try: + ensure_markets_loaded() + return float(exchange.price_to_precision(exchange_symbol, v)) + except Exception: + return v + + +def normalize_symbol_input(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym + if ":" in sym: + sym = sym.split(":")[0] + return f"{sym}/USDT" + + +def validate_trade_policy_open(symbol, direction): + return check_open_policy( + TRADE_POLICY, symbol, direction, normalize_symbol_input + ) + + +def normalize_kline_limit(limit_raw, default=200): + try: + n = int(limit_raw) + except Exception: + return default + return 200 if n >= 200 else 100 + + +def get_recommended_capital(current_capital): + if current_capital <= DAILY_LOSS_CAPITAL: + return DAILY_LOSS_CAPITAL + if current_capital >= DAILY_PROFIT_CAPITAL: + return DAILY_PROFIT_CAPITAL + return DAILY_START_CAPITAL + + +def ensure_session(conn, session_date): + row = conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + if row: + return row + conn.execute( + "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", + (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) + ) + conn.commit() + return conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + + +def update_session_capital(conn, session_date, pnl_amount): + session_row = ensure_session(conn, session_date) + new_capital = float(session_row["current_capital"]) + float(pnl_amount) + conn.execute( + "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(new_capital, 4), session_date) + ) + conn.commit() + return round(new_capital, 4) + + +def calc_hold_seconds(opened_at_str, closed_at_dt): + try: + opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") + return int((closed_at_dt - opened_at).total_seconds()) + except Exception: + return 0 + + +def calc_hold_minutes(seconds): + if not seconds or seconds <= 0: + return 0 + return max(1, int(seconds // 60)) + + +def get_opened_at_value(row): + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "opened_at" in keys: + value = row["opened_at"] + if value: + return value + return app_now_str() + + +def get_effective_trade_field(row, reviewed_key, base_key, default=None): + try: + keys = row.keys() if hasattr(row, "keys") else row.keys() + except Exception: + keys = [] + if reviewed_key in keys: + v = row[reviewed_key] + if v is not None and str(v).strip() != "": + return v + if base_key in keys: + v = row[base_key] + if v is not None and str(v).strip() != "": + return v + return default + + +def to_effective_trade_dict(row): + item = row_to_dict(row) + from lib.trade.order_monitor_display_lib import snapshot_stop_loss + + open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss")) + item["display_open_stop_loss"] = open_stop + item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) + item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) + item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_stop) + item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) + item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) + item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason")) + item["effective_pnl_amount"] = get_effective_trade_field(row, "reviewed_pnl_amount", "pnl_amount", item.get("pnl_amount")) + item["effective_hold_minutes"] = get_effective_trade_field(row, "reviewed_hold_minutes", "hold_minutes", item.get("hold_minutes")) + item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds")) + try: + _er_keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + _er_keys = [] + reviewed_er = row["reviewed_entry_reason"] if "reviewed_entry_reason" in _er_keys else None + item["effective_entry_reason"] = resolve_effective_trade_entry_reason( + reviewed_entry_reason=reviewed_er, + entry_reason=item.get("entry_reason"), + entry_model=item.get("entry_model"), + key_signal_type=(item.get("key_signal_type") or "").strip() or None, + monitor_type=item.get("monitor_type"), + trade_style=item.get("trade_style"), + entry_reason_from_key_signal=entry_reason_from_key_signal, + entry_reason_for_monitor_type=entry_reason_for_monitor_type, + ) + try: + _keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + _keys = [] + _reviewed_pnl_raw = row["reviewed_pnl_amount"] if "reviewed_pnl_amount" in _keys else None + has_reviewed_pnl = _reviewed_pnl_raw is not None and str(_reviewed_pnl_raw).strip() != "" + ex_pnl = item.get("exchange_realized_pnl") + if not has_reviewed_pnl and ex_pnl is not None and str(ex_pnl).strip() != "": + try: + item["effective_pnl_amount"] = round(float(ex_pnl), FUNDS_DECIMALS) + item["display_pnl_source"] = "exchange" + ex_open = (str(item.get("exchange_opened_at") or "").strip() or None) + ex_close = (str(item.get("exchange_closed_at") or "").strip() or None) + if ex_open: + item["effective_opened_at"] = ex_open + if ex_close: + item["effective_closed_at"] = ex_close + except (TypeError, ValueError): + item["display_pnl_source"] = "local" + elif has_reviewed_pnl: + item["display_pnl_source"] = "reviewed" + else: + item["display_pnl_source"] = "local" + item["effective_result"] = normalize_result_with_pnl( + item.get("effective_result"), + item.get("effective_pnl_amount"), + ) + item["effective_result"] = apply_force_close_display_result( + item.get("effective_result"), + item.get("effective_closed_at"), + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) + return item + + +def format_price_for_symbol(symbol, value): + """价格展示:与交易所 price_to_precision 一致(与入库 round_price_to_exchange 对齐).""" + if value in (None, ""): + return "-" + try: + v = float(value) + except Exception: + return str(value) + if v == 0: + return "0" + try: + ex_sym = normalize_okx_symbol(str(symbol or "").strip()) if symbol else "" + if ex_sym: + ensure_markets_loaded() + return str(exchange.price_to_precision(ex_sym, v)) + except Exception: + pass + av = abs(v) + # 无法加载市场或无该合约时:按价格量级回退(尽量不阻断页面) + if av >= 10000: + d = 2 + elif av >= 100: + d = 3 + elif av >= 1: + d = 4 + elif av >= 0.01: + d = 6 + elif av >= 0.0001: + d = 8 + else: + d = 10 + text = f"{v:.{d}f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +FUNDS_DECIMALS = 2 + + +def format_funds_u(value): + if value in (None, ""): + return "-" + try: + return f"{float(value):.{FUNDS_DECIMALS}f}" + except (TypeError, ValueError): + return str(value) + + +def format_hold_minutes(minutes): + if not minutes: + return "0分钟" + total = int(minutes) + hours = total // 60 + mins = total % 60 + if hours: + return f"{hours}小时{mins}分钟" + return f"{mins}分钟" + + +def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage): + """估算净盈亏(USDT):价差毛利 − 双边 taker 费(默认各 0.05%).""" + try: + trigger = float(trigger_price) + exit_p = float(exit_price) + margin = float(margin_capital) + lev = float(leverage) + if trigger <= 0: + return 0.0 + if direction == "short": + pnl_ratio = (trigger - exit_p) / trigger + else: + pnl_ratio = (exit_p - trigger) / trigger + notional = margin * lev + gross = notional * pnl_ratio + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + net = net_pnl_after_fee(gross, trigger, exit_p, open_notional=notional) + return float(net) if net is not None else round(gross, 4) + except Exception: + return round(gross, 4) + except Exception: + return 0.0 + + +def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): + """ + 计划盈亏比 = 盈利空间 / 亏损空间(展示为 X:1,即 reward:risk). + 做多:止损须低于入场,止盈须高于入场;做空相反. + """ + try: + entry = float(entry_price) + sl = float(stop_loss) + tp = float(take_profit) + if entry <= 0 or sl <= 0 or tp <= 0: + return None + if direction == "short": + risk = sl - entry + reward = entry - tp + else: + risk = entry - sl + reward = tp - entry + if risk <= 0 or reward <= 0: + return None + return round(reward / risk, 4) + except Exception: + return None + + +def active_sl_tp_for_rr(stop_loss, initial_stop_loss, take_profit): + """展示/校验用:优先当前 stop_loss(委托改价后),否则回落 initial_stop_loss.""" + sl = stop_loss if stop_loss not in (None, "") else initial_stop_loss + return sl, take_profit + + +def calc_planned_rr_ratio(direction, entry_price, stop_loss, initial_stop_loss, take_profit): + sl, tp = active_sl_tp_for_rr(stop_loss, initial_stop_loss, take_profit) + return calc_rr_ratio(direction, entry_price, sl, tp) + + +def calc_risk_fraction(direction, entry_price, stop_loss): + try: + entry = float(entry_price) + sl = float(stop_loss) + if entry <= 0 or sl <= 0: + return None + if direction == "short": + risk = sl - entry + else: + risk = entry - sl + if risk <= 0: + return None + return risk / entry + except Exception: + return None + + +def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital, leverage): + rf = calc_risk_fraction(direction, entry_price, stop_loss) + if rf is None: + return None + try: + notional = float(margin_capital) * float(leverage) + if notional <= 0: + return None + return round(notional * rf, 6) + except Exception: + return None + + +def calc_actual_rr(pnl_amount, risk_amount): + try: + r = float(risk_amount or 0) + if r <= 0: + return None + return round(float(pnl_amount or 0) / r, 2) + except Exception: + return None + + +def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): + """ + 按“已锁定R”计算目标止损位: + - long: entry + locked_r * (entry*risk_fraction) + offset + - short: entry - locked_r * (entry*risk_fraction) - offset + """ + try: + entry = float(entry_price) + rf = float(risk_fraction) + lr = float(locked_r) + off = float(offset_pct) / 100.0 + if entry <= 0 or rf <= 0 or lr < 0: + return None + base_move = entry * rf * lr + offset_move = entry * off + if direction == "short": + return round(entry - base_move - offset_move, 8) + return round(entry + base_move + offset_move, 8) + except Exception: + return None + + +def insert_trade_record( + conn, + symbol, + monitor_type, + direction, + trigger_price, + stop_loss, + initial_stop_loss=None, + take_profit=None, + margin_capital=None, + leverage=None, + pnl_amount=0, + hold_seconds=0, + trade_style=None, + risk_amount=None, + planned_rr=None, + actual_rr=None, + result="", + miss_reason=None, + opened_at=None, + opened_at_ms=None, + closed_at=None, + closed_at_ms=None, + exchange_trade_id=None, + key_signal_type=None, + entry_reason=None, + entry_model=None, + trend_plan_id=None, + exchange_symbol=None, + attach_exchange_stats=True, +): + hold_minutes = calc_hold_minutes(hold_seconds) + open_ts = opened_at or app_now_str() + close_ts = closed_at or app_now_str() + open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) + close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) + kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) + from lib.trade.order_monitor_display_lib import snapshot_stop_loss + + snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss) + er = resolve_trade_record_entry_reason( + entry_reason=entry_reason, + entry_model=entry_model, + key_signal_type=kst, + monitor_type=monitor_type, + trade_style=trade_style, + entry_reason_from_key_signal=entry_reason_from_key_signal, + entry_reason_for_monitor_type=entry_reason_for_monitor_type, + ) + cur = conn.execute( + "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit, + margin_capital, leverage, pnl_amount, hold_seconds, + trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None, + trend_plan_id, + ) + ) + tid = int(cur.lastrowid or 0) + if attach_exchange_stats and tid: + ex_sym = (exchange_symbol or "").strip() or normalize_exchange_symbol(symbol) + _attach_okx_trade_exchange_stats( + conn, + tid, + exchange_symbol=ex_sym, + direction=direction, + opened_at_str=open_ts, + closed_at_str=close_ts, + opened_at_ms=open_ts_ms, + closed_at_ms=close_ts_ms, + ) + # 中控只拉 /api/trade_records,平仓当下也尝试回填交易所盈亏(内部 25s 节流) + try: + sync_trade_records_from_exchange(conn, force=False) + except Exception: + pass + try: + from lib.trade.account_risk_lib import on_closed_trade_pnl + + close_dt = parse_dt_for_trading_day(close_ts) + on_closed_trade_pnl( + conn, + pnl_amount=pnl_amount, + trading_day=get_trading_day(close_dt), + ) + except Exception: + pass + return tid + + +def calc_duration_text(open_str, close_str): + try: + fmt = "%Y-%m-%dT%H:%M" + o = datetime.strptime(open_str, fmt) + c = datetime.strptime(close_str, fmt) + delta = c - o + seconds = int(delta.total_seconds()) + if seconds <= 0: + return "0分钟" + d = seconds // 86400 + h = (seconds % 86400) // 3600 + m = (seconds % 3600) // 60 + parts = [] + if d: + parts.append(f"{d}天") + if h: + parts.append(f"{h}小时") + if m or not parts: + parts.append(f"{m}分钟") + return " ".join(parts) + except Exception: + return "计算失败" + + +def row_to_dict(row): + return {k: row[k] for k in row.keys()} + + +def enrich_order_item(raw_item, current_capital): + item = dict(raw_item or {}) + margin = float(item.get("margin_capital") or 0) + lev = float(item.get("leverage") or 0) + notional = item.get("notional_value") + ratio = item.get("position_ratio") + if notional is None: + notional = round(margin * lev, 4) if margin and lev else 0 + if ratio is None: + ratio = round(margin / current_capital * 100, 2) if current_capital else 0 + item["notional_value"] = notional + item["position_ratio"] = ratio + enrich_order_display_fields(item, calc_rr_ratio) + enrich_entry_model_display(item) + try: + be = item.get("breakeven_enabled") + item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 + except Exception: + item["breakeven_enabled"] = 1 + return apply_order_monitor_source_labels(item, default_manual=ORDER_MONITOR_TYPE_MANUAL) + + +def ensure_okx_live_ready(): + if not LIVE_TRADING_ENABLED: + return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return False, "缺少 OKX API 密钥配置" + return True, "" + + +def order_row_monitor_type(row): + return order_monitor_source_type(row, default_manual=ORDER_MONITOR_TYPE_MANUAL) + + +def trade_record_monitor_type(conn, row): + return resolve_trade_record_monitor_type( + conn, row, default_manual=ORDER_MONITOR_TYPE_MANUAL + ) + + +def order_row_key_signal_type(row): + if row is None: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "key_signal_type" not in keys: + return None + kst = (row["key_signal_type"] or "").strip() + if key_signal_type_for_trade_record(kst, KEY_MONITOR_AUTO_TYPES): + return key_signal_type_for_trade_record(kst, KEY_MONITOR_AUTO_TYPES) + return None + + +def _extract_usdt_total(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + total_map = balance.get("total", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + total = usdt_info.get("total") + if total is None: + total = total_map.get("USDT") + if total is None: + total = usdt_info.get("free") + if total is None: + total = free_map.get("USDT") + try: + return float(total) if total is not None else None + except Exception: + return None + + +def _extract_usdt_free(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + free = usdt_info.get("free") + if free is None: + free = free_map.get("USDT") + try: + return float(free) if free is not None else None + except Exception: + return None + + +def _fetch_usdt_by_types(type_candidates): + for t in type_candidates: + try: + bal = exchange.fetch_balance(params={"type": t}) + val = _extract_usdt_total(bal) + if val is not None: + return val + except Exception: + continue + return None + + +def get_available_trading_usdt(): + ok_live, _ = ensure_okx_live_ready() + if not ok_live: + return None + for t in ["swap", "trading", "spot"]: + try: + bal = exchange.fetch_balance(params={"type": t}) + free_val = _extract_usdt_free(bal) + if free_val is not None: + return free_val + except Exception: + continue + return None + + +def get_synced_leverage(exchange_symbol, direction): + ensure_markets_loaded() + # 1) 优先读取交易所杠杆配置 + try: + if hasattr(exchange, "fetch_leverage"): + lev = exchange.fetch_leverage(exchange_symbol, params={"mgnMode": OKX_TD_MODE}) + long_lev = lev.get("longLeverage") or lev.get("long") + short_lev = lev.get("shortLeverage") or lev.get("short") + base_lev = lev.get("leverage") + if direction == "long" and long_lev: + return int(float(long_lev)) + if direction == "short" and short_lev: + return int(float(short_lev)) + if base_lev: + return int(float(base_lev)) + except Exception: + pass + # 2) 从当前仓位里兜底读取 + try: + positions = exchange.fetch_positions([exchange_symbol], params={"instType": "SWAP"}) + for p in positions: + if p.get("symbol") != exchange_symbol: + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + if OKX_POS_MODE == "hedge" and side and side != direction: + continue + lev = p.get("leverage") or info.get("lever") + if lev: + return int(float(lev)) + except Exception: + pass + return None + + +def friendly_okx_error(err, available_usdt=None): + msg = str(err) + if "51008" in msg or "Insufficient USDT margin" in msg: + tail = f"(当前交易账户可用约 {round(available_usdt, 4)}U)" if available_usdt is not None else "" + return f"交易所下单失败:保证金不足 {tail}.请降低保证金/杠杆,或先划转USDT到交易账户." + clean = re.sub(r"\s+", " ", msg).strip() + return f"交易所下单失败:{clean}" + + +friendly_exchange_error = friendly_okx_error + + +def invalidate_account_balance_cache() -> None: + ACCOUNT_BALANCE_CACHE["updated_at"] = 0 + + +def get_exchange_capitals(force=False): + ok_live, _ = ensure_okx_live_ready() + if not ok_live: + return None, None + now_ts = time.time() + if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + try: + funding = _fetch_usdt_by_types(["funding"]) + trading = _fetch_usdt_by_types(["swap", "trading", "spot"]) + ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding + ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading + ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts + except Exception: + pass + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + + +def execute_transfer_usdt(amount, from_account, to_account): + if amount <= 0: + return False, "划转金额必须大于0", None + ok_live, reason = ensure_okx_live_ready() + if not ok_live: + return False, reason, None + try: + resp = exchange.transfer(TRANSFER_CCY, float(amount), from_account, to_account) + return True, "划转成功", resp + except Exception as e: + return False, str(e), None + + +def get_account_usdt_total(account_type): + try: + bal = exchange.fetch_balance(params={"type": account_type}) + return _extract_usdt_total(bal) + except Exception: + return None + + +def auto_transfer_once_per_day(): + run_auto_transfer_once_per_day( + enabled=AUTO_TRANSFER_ENABLED, + bj_hour=AUTO_TRANSFER_BJ_HOUR, + target_amount=AUTO_TRANSFER_AMOUNT, + from_account=AUTO_TRANSFER_FROM, + to_account=AUTO_TRANSFER_TO, + funds_decimals=FUNDS_DECIMALS, + get_db=get_db, + get_active_position_count=get_active_position_count, + get_account_usdt_total=get_account_usdt_total, + execute_transfer_usdt=execute_transfer_usdt, + send_wechat_msg=send_wechat_msg, + utc_now_dt=utc_now_dt, + app_tz=APP_TZ, + utc_calendar_date_str=utc_calendar_date_str, + app_now_str=app_now_str, + ) + + +def get_trading_day_reset_open_guard_enabled(conn=None): + """True=启用整点限制(默认 8:00 前禁止新开仓/登记监控).""" + owns = conn is None + if owns: + conn = get_db() + try: + row = conn.execute( + "SELECT value FROM app_runtime_settings WHERE key=?", + (RUNTIME_KEY_OPEN_GUARD,), + ).fetchone() + if row is not None: + return str(row[0]).lower() in ("1", "true", "yes", "on") + except Exception: + pass + finally: + if owns: + conn.close() + return TRADING_DAY_RESET_OPEN_GUARD_ENABLED + + +def set_trading_day_reset_open_guard_enabled(enabled: bool, conn=None): + owns = conn is None + if owns: + conn = get_db() + try: + conn.execute( + "INSERT INTO app_runtime_settings(key, value, updated_at) VALUES (?,?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", + (RUNTIME_KEY_OPEN_GUARD, "1" if enabled else "0", app_now_str()), + ) + if owns: + conn.commit() + finally: + if owns: + conn.close() + + +def trading_day_reset_allows_new_open(now, conn=None): + if not get_trading_day_reset_open_guard_enabled(conn): + return True + return now.hour >= TRADING_DAY_RESET_HOUR + + +def precheck_risk(conn, symbol, direction): + now = app_now() + from lib.trade.account_risk_lib import account_risk_blocks_trading + from lib.trade.force_close_lib import force_close_blocks_new_open + + ok_risk, risk_reason = account_risk_blocks_trading( + conn, + trading_day=get_trading_day(now), + now=now, + fmt_local_ms=ms_to_app_local_str, + ) + if not ok_risk: + return False, risk_reason + fc_block, fc_note = force_close_blocks_new_open( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + ) + if fc_block: + return False, fc_note or "强制清仓窗口内暂不可开仓" + if not trading_day_reset_allows_new_open(now): + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + from lib.trade.account_risk_lib import position_limit_reached + + reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) + if reached: + return False, f"已达最大持仓数({active_count}/{mx})" + ok_daily, daily_reason, _opens = check_daily_open_hard_limit( + conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR + ) + if not ok_daily: + return False, daily_reason + if direction not in ("long", "short"): + return False, "方向必须为 long 或 short" + if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): + expected = BTC_LEVERAGE + else: + expected = ALT_LEVERAGE + if expected <= 0: + return False, "杠杆配置异常" + return True, "" + + +def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): + ensure_markets_loaded() + notional = float(margin_capital) * float(leverage) + ticker = exchange.fetch_ticker(exchange_symbol) + price = float(ticker.get("last") or fallback_price) + if price <= 0: + raise ValueError("触发价必须大于 0") + market = exchange.market(exchange_symbol) + contract_size = float(market.get("contractSize") or 1) + if market.get("contract"): + # OKX 永续 amount 是“张数”,需要按合约面值换算 + amount = notional / (price * contract_size) + else: + amount = notional / price + min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") + if min_amount and amount < float(min_amount): + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") + amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) + if amount_precise <= 0: + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + return amount_precise, price + + +def _to_positive_float(value): + try: + n = float(value) + return n if n > 0 else None + except Exception: + return None + + +def _extract_order_price_value(order_obj): + if not isinstance(order_obj, dict): + return None + for key in ("average", "price"): + v = _to_positive_float(order_obj.get(key)) + if v is not None: + return v + cost = _to_positive_float(order_obj.get("cost")) + filled = _to_positive_float(order_obj.get("filled")) + if cost is not None and filled is not None and filled > 0: + return cost / filled + info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} + for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): + v = _to_positive_float(info.get(key)) + if v is not None: + return v + return None + + +def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): + price = _extract_order_price_value(order_resp) + if price is not None: + return round(price, 8) + order_id = (order_resp or {}).get("id") + if order_id: + try: + fetched = exchange.fetch_order(order_id, exchange_symbol) + fetched_price = _extract_order_price_value(fetched) + if fetched_price is not None: + return round(fetched_price, 8) + except Exception: + pass + fallback = _to_positive_float(fallback_price) + return round(fallback, 8) if fallback is not None else 0.0 + + +def get_contract_size(exchange_symbol): + try: + ensure_markets_loaded() + market = exchange.market(normalize_okx_symbol(exchange_symbol)) + return float(market.get("contractSize") or 1) + except Exception: + return 1.0 + + +def parse_positive_float(value): + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + num = float(raw) + if num <= 0: + raise ValueError("数值必须大于0") + return num + + +def build_okx_order_params(direction, reduce_only=False): + params = {"tdMode": OKX_TD_MODE} + if OKX_POS_MODE == "hedge": + params["posSide"] = "long" if direction == "long" else "short" + if reduce_only: + params["reduceOnly"] = True + return params + + +def ensure_markets_loaded(force=False): + global MARKETS_LOADED + if force or not MARKETS_LOADED: + exchange.load_markets(reload=force) + MARKETS_LOADED = True + + +def _okx_algo_trigger_price_str(exchange_symbol, price): + """OKX attachAlgoOrds 触发价须为按合约 tick 格式化的十进制字符串;直接用 str(float) 低价币会得到科学计数法(如 8.5e-06),会报 tpTriggerPx/slTriggerPx 参数错误.""" + ensure_markets_loaded() + return exchange.price_to_precision(exchange_symbol, float(price)) + + +def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): + ensure_markets_loaded() + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + params = build_okx_order_params(direction, reduce_only=False) + if stop_loss and take_profit: + params["attachAlgoOrds"] = [{ + "tpTriggerPx": _okx_algo_trigger_price_str(exchange_symbol, take_profit), + "tpOrdPx": "-1", + "slTriggerPx": _okx_algo_trigger_price_str(exchange_symbol, stop_loss), + "slOrdPx": "-1" + }] + try: + order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + order["tpsl_attached"] = bool(stop_loss and take_profit) + return order + except Exception as e: + if stop_loss and take_profit: + raise RuntimeError(f"交易所未接受止盈止损挂单参数,已拒绝开仓:{str(e)}") + raise + + +def close_exchange_order(order_row): + """ + 市价全平.数量优先取交易所当前持仓张数,避免仅用入库 order_amount 导致平不干净. + """ + ensure_markets_loaded() + exchange_symbol = order_row["exchange_symbol"] or normalize_okx_symbol(order_row["symbol"]) + direction = order_row["direction"] + db_amt = float(order_row["order_amount"] or 0) + side = "sell" if direction == "long" else "buy" + last_resp = None + for _ in range(3): + live = get_live_position_contracts(exchange_symbol, direction) + if live is not None and live > 0: + raw_amt = live + else: + raw_amt = db_amt + if raw_amt <= 0: + if last_resp is not None: + return last_resp + raise ValueError("平仓失败:缺少有效下单数量") + try: + amount = float(exchange.amount_to_precision(exchange_symbol, raw_amt)) + except Exception: + amount = float(raw_amt) + if amount <= 0: + if last_resp is not None: + return last_resp + raise ValueError("平仓失败:数量经精度舍入后为 0") + params = build_okx_order_params(direction, reduce_only=True) + last_resp = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + live_after = get_live_position_contracts(exchange_symbol, direction) + if live_after is None or live_after <= 0: + return last_resp + return last_resp + + +def cancel_okx_swap_open_orders(exchange_symbol): + ok, _ = ensure_okx_live_ready() + if not ok or not exchange_symbol: + return + ensure_markets_loaded() + try: + cancel_okx_all_open_orders(exchange, exchange_symbol) + except Exception: + pass + + +def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_profit): + """ + 为已有持仓挂条件止盈/止损(一笔 OCO 算法单). + 勿带 reduceOnly,勿分两笔 reduce-only 市价单,否则 OKX/ccxt 可能当成立即全平. + """ + ensure_markets_loaded() + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(amount))) + if amt <= 0: + raise RuntimeError("止盈止损:可平数量经精度舍入后为 0") + base = build_okx_order_params(direction, reduce_only=False) + sl_px = _okx_algo_trigger_price_str(exchange_symbol, stop_loss) + tp_px = _okx_algo_trigger_price_str(exchange_symbol, take_profit) + order_params = { + **base, + "stopLossPrice": float(sl_px), + "takeProfitPrice": float(tp_px), + "tpOrdPx": "-1", + "slOrdPx": "-1", + } + if OKX_POS_MODE == "hedge": + ps = "long" if direction == "long" else "short" + order_params["positionSide"] = ps + last_err = None + for attempt in range(6): + try: + exchange.create_order(exchange_symbol, "oco", close_side, amt, None, order_params) + return + except Exception as e: + last_err = e + cancel_okx_swap_open_orders(exchange_symbol) + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"OKX 未接受止盈/止损条件单:{last_err}") + + + +def exchange_private_api_configured(): + return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE) + + +def _position_row_effective_contracts(p): + """张数:OKX 以 info.pos 为准,再兜底 ccxt contracts 等(与 Binance/Gate 多字段一致).""" + from lib.market.position_metrics_lib import normalize_contracts_qty + + if not p: + return 0.0 + info = p.get("info", {}) or {} + for val in (info.get("pos"), p.get("contracts"), info.get("positionAmt"), info.get("size")): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return normalize_contracts_qty(x) + except (TypeError, ValueError): + continue + return 0.0 + + +def _position_matches_wanted_contract(exchange_symbol, position): + if not position: + return False + sym = position.get("symbol") + if sym == exchange_symbol: + return True + try: + if normalize_okx_symbol(sym or "") == normalize_okx_symbol(exchange_symbol or ""): + return True + except Exception: + pass + info = position.get("info") or {} + inst = (info.get("instId") or "").strip().upper() + if not inst: + return False + try: + ensure_markets_loaded() + want = exchange.market(exchange_symbol) + mid = (want.get("id") or "").strip().upper() + if mid and inst == mid: + return True + base = (want.get("base") or "").strip().upper() + quote = (want.get("quote") or "").strip().upper() + if base and quote and inst == f"{base}-{quote}-SWAP": + return True + except Exception: + pass + return False + + +def _okx_position_direction(position): + info = position.get("info") or {} + side = (position.get("side") or info.get("posSide") or "").strip().lower() + if side in ("long", "short"): + return side + try: + raw = float(info.get("pos") or position.get("contracts") or 0) + except (TypeError, ValueError): + raw = 0.0 + if raw > 0: + return "long" + if raw < 0: + return "short" + return "" + + +def _fetch_okx_swap_position_rows(): + """OKX 单合约 fetch_positions([sym]) 常返回空;与 /api/prices 一致拉全量 SWAP 再本地匹配.""" + ensure_markets_loaded() + rows = None + for fetcher in ( + lambda: exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}), + lambda: exchange.fetch_positions(), + ): + try: + rows = fetcher() or [] + break + except Exception: + continue + if rows is None: + return None + return rows + + +def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): + exchange_symbol = normalize_okx_symbol(exchange_symbol or "") + if not rows: + return None + candidates = [] + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + want_dir = (direction or "").lower() + if OKX_POS_MODE == "net" or side == "net": + pos_dir = _okx_position_direction(p) + if pos_dir and pos_dir != want_dir: + continue + elif (not relax_hedge) and OKX_POS_MODE == "hedge": + if side and side != want_dir: + continue + candidates.append((contracts, p)) + if not candidates and (not relax_hedge) and OKX_POS_MODE == "hedge": + return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) + if not candidates: + return None + candidates.sort(key=lambda x: x[0], reverse=True) + return candidates[0][1] + + +def parse_ccxt_position_metrics(position, order_leverage=None): + if not position: + return None + p = position + info = p.get("info", {}) or {} + initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin")) + if initial is None or initial <= 0: + initial = _coerce_float( + info.get("margin"), + info.get("imr"), + info.get("initial_margin"), + ) + notional = _coerce_float(p.get("notional"), p.get("notionalValue")) + if notional is None or notional <= 0: + notional = _coerce_float(info.get("notionalUsd"), info.get("notional")) + if notional is not None: + notional = abs(notional) + if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: + try: + lev = float(order_leverage) + if lev > 0: + approx = notional / lev + if approx > 0: + initial = approx + except (TypeError, ValueError): + pass + unrealized = _coerce_float_signed( + p.get("unrealizedPnl"), + info.get("upl"), + info.get("uplLast"), + info.get("unrealized_pnl"), + info.get("unrealisedPnl"), + ) + mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("markPx")) + out = {} + if initial is not None and initial > 0: + out["initial_margin"] = round(initial, FUNDS_DECIMALS) + if notional is not None and notional > 0: + out["notional"] = round(notional, FUNDS_DECIMALS) + if unrealized is not None: + out["unrealized_pnl"] = round(unrealized, FUNDS_DECIMALS) + if mark is not None and mark > 0: + out["mark_price"] = round(mark, 8) + if out: + sym = (p.get("symbol") or "").strip() + try: + cs = float(get_contract_size(sym)) if sym else 1.0 + except Exception: + cs = 1.0 + from lib.market.position_metrics_lib import enrich_ccxt_position_metrics_out + + enrich_ccxt_position_metrics_out( + p, out, contract_size=cs, funds_decimals=FUNDS_DECIMALS + ) + return out or None + + +def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): + return resolve_entrust_sltp_prices(direction, live_price, sltp_mode, data) + + +def _okx_tpsl_slot_build(exchange_symbol, order_id, trigger_price, order_type=""): + if trigger_price is None or order_id is None: + return None + sym = exchange_symbol.replace(":USDT", "").replace("/USDT:USDT", "") + return { + "order_id": str(order_id), + "trigger_price": float(trigger_price), + "trigger_display": format_price_for_symbol(sym, trigger_price), + "type": str(order_type or ""), + } + + +def _okx_tpsl_slots_from_order(order, exchange_symbol): + """从单笔 OKX 订单解析 SL/TP(算法单常同时带 slTriggerPx 与 tpTriggerPx).""" + if not isinstance(order, dict): + return None, None + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + oid = order.get("id") or info.get("algoId") or info.get("ordId") + if oid is None: + return None, None + ord_type = str(order.get("type") or info.get("ordType") or "") + sl_px = _coerce_float( + order.get("stopLossPrice"), + info.get("slTriggerPx"), + info.get("slOrdPx"), + ) + tp_px = _coerce_float( + order.get("takeProfitPrice"), + info.get("tpTriggerPx"), + info.get("tpOrdPx"), + ) + sl_slot = _okx_tpsl_slot_build(exchange_symbol, oid, sl_px, ord_type) if sl_px is not None else None + tp_slot = _okx_tpsl_slot_build(exchange_symbol, oid, tp_px, ord_type) if tp_px is not None else None + if sl_slot or tp_slot: + return sl_slot, tp_slot + trig = _coerce_float( + info.get("triggerPx"), + order.get("triggerPrice"), + order.get("stopPrice"), + ) + if trig is None: + return None, None + one = _okx_tpsl_slot_build(exchange_symbol, oid, trig, ord_type) + return one, None + + +def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None): + slots = {"sl": None, "tp": None} + if not exchange_symbol: + return slots + ok, _ = ensure_okx_live_ready() + if not ok: + return slots + try: + ensure_markets_loaded() + plan_sl_f = plan_tp_f = None + try: + if plan_sl is not None: + plan_sl_f = float(plan_sl) + if plan_tp is not None: + plan_tp_f = float(plan_tp) + except Exception: + plan_sl_f = plan_tp_f = None + + def assign_role(trig, slot): + if trig is None or slot is None: + return + if plan_sl_f is not None and plan_tp_f is not None: + role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp" + elif plan_sl_f is not None: + role = "sl" + elif plan_tp_f is not None: + role = "tp" + else: + return + if slots[role] is None: + slots[role] = slot + + for order in fetch_okx_all_open_orders(exchange, exchange_symbol): + sl_slot, tp_slot = _okx_tpsl_slots_from_order(order, exchange_symbol) + if sl_slot and slots["sl"] is None: + slots["sl"] = sl_slot + if tp_slot and slots["tp"] is None: + slots["tp"] = tp_slot + if sl_slot or tp_slot: + continue + info = order.get("info") or {} + oid = order.get("id") or info.get("algoId") + trig = _coerce_float(info.get("triggerPx"), order.get("triggerPrice")) + if oid is None or trig is None: + continue + slot = _okx_tpsl_slot_build( + exchange_symbol, + oid, + trig, + str(order.get("type") or info.get("ordType") or ""), + ) + assign_role(trig, slot) + except Exception: + pass + return slots + + +def cancel_okx_tpsl_slot(exchange_symbol, slot): + if not slot or not exchange_symbol: + return + oid = slot.get("order_id") + if not oid: + return + ensure_markets_loaded() + cancel_id = str(oid).split(":", 1)[0] + try: + exchange.cancel_order(cancel_id, exchange_symbol, {"stop": True}) + except Exception: + exchange.cancel_order(str(oid), exchange_symbol, {"stop": True}) + + +def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): + """先撤该合约挂单/条件单,再按新价重挂 TP/SL.""" + ok, reason = ensure_okx_live_ready() + if not ok: + raise RuntimeError(reason or "实盘未就绪") + ex_sym = resolve_monitor_exchange_symbol(order_row) + direction = order_row["direction"] + cancelled = cancel_okx_all_open_orders(exchange, ex_sym) + if cancelled > 0: + time.sleep(0.12) + pos_amt = get_live_position_contracts(ex_sym, direction) + if pos_amt is None or float(pos_amt) <= 0: + try: + pos_amt = float(order_row["order_amount"] or 0) + except (TypeError, ValueError): + pos_amt = 0 + if float(pos_amt or 0) <= 0: + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") + _okx_place_tp_sl_orders(ex_sym, direction, float(pos_amt), float(stop_loss), float(take_profit)) + + +def _okx_place_stop_loss_only(exchange_symbol, direction, stop_loss): + """OKX 永续:仅挂止损(趋势回调),止盈由程序监控. + + 须用 stopLossPrice 挂条件单;勿用 reduce-only 市价单 + params['stopLoss'], + 后者会当成立即市价平仓(开仓后约 1 秒内全平). + """ + ensure_markets_loaded() + pos_amt = get_live_position_contracts(exchange_symbol, direction) + if pos_amt is None or float(pos_amt) <= 0: + raise RuntimeError("交易所当前无持仓,无法挂止损") + cancel_okx_swap_open_orders(exchange_symbol) + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(pos_amt))) + if amt <= 0: + raise RuntimeError("止损:可平数量经精度舍入后为 0") + base = build_okx_order_params(direction, reduce_only=True) + sl_px = float(stop_loss) + last_err = None + for attempt in range(6): + try: + exchange.create_order( + exchange_symbol, + "market", + close_side, + amt, + None, + {**base, "stopLossPrice": sl_px}, + ) + return + except Exception as e: + last_err = e + cancel_okx_swap_open_orders(exchange_symbol) + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"OKX 未接受止损条件单:{last_err}") + + +def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): + try: + e = float(entry_price) + pct = float( + offset_pct + if offset_pct is not None + else float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) + ) + except (TypeError, ValueError): + return None + if e <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + return e * (1.0 - pct / 100.0) + return e * (1.0 + pct / 100.0) + + +def extract_trade_price_from_order(order): + if not order: + return None + for k in ("average", "avgPrice", "price"): + try: + v = float(order.get(k) or 0) + if v > 0: + return v + except Exception: + pass + try: + info = order.get("info") or {} + if isinstance(info, dict): + for k in ("fillPx", "avgPx", "fill_price"): + v = float(info.get(k) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def is_no_position_error(err_msg): + msg = (err_msg or "").lower() + keywords = [ + "no position", "position does not exist", "position not exist", + "pos size is 0", "nothing to close", "reduceonly", "51008", + "empty position", "increase_position", + ] + return any(k in msg for k in keywords) + + +def get_live_position_contracts(exchange_symbol, direction): + ex_sym = normalize_okx_symbol(exchange_symbol or "") + rows = _fetch_okx_swap_position_rows() + if rows is None: + return None + prow = _select_live_position_row(rows, ex_sym, direction) + if not prow: + return 0.0 + return _position_row_effective_contracts(prow) + + +def get_live_position_exchange_metrics(exchange_symbol, direction, order_leverage=None): + """趋势回调/下单监控:从交易所持仓读标记价与未实现盈亏.""" + if not exchange_private_api_configured() or not exchange_symbol: + return None + rows = _fetch_okx_swap_position_rows() + if rows is None: + return None + prow = _select_live_position_row(rows, exchange_symbol, direction) + return parse_ccxt_position_metrics(prow, order_leverage=order_leverage) + + +def opened_at_str_to_ms(opened_at_str): + if not opened_at_str: + return None + try: + dt = datetime.strptime(str(opened_at_str).strip()[:19], "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + try: + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + return None + + +def _to_ms_with_fallback(ms_value, dt_str): + try: + if ms_value is not None and str(ms_value).strip() != "": + v = int(float(ms_value)) + if v > 0: + return v + except Exception: + pass + return opened_at_str_to_ms(dt_str) + + +def ms_to_app_local_str(ms): + if ms is None: + return app_now_str() + try: + dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) + return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return app_now_str() + + +def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None.""" + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return None + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + + def pick_from_trades(trades, min_ts=None): + if not trades: + return None + candidates = [] + for t in trades: + if (t.get("side") or "").lower() != close_side: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if OKX_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + ts = t.get("timestamp") + if ts is None: + continue + try: + ts_i = int(ts) + except (TypeError, ValueError): + continue + if min_ts and ts_i < int(min_ts): + continue + candidates.append(t) + if not candidates: + return None + return max(candidates, key=lambda x: x.get("timestamp") or 0) + + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) + return pick_from_trades(trades, since_ms) + except Exception: + return None + + +def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): + """ + 拉取某条历史记录对应的减仓成交(用于按 id 回填). + 返回按时间排序的成交列表. + """ + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + # 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况 + if closed_ms is not None: + closed_ms += 6 * 60 * 60 * 1000 + candidates = [] + all_side_candidates = [] + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + for t in trades or []: + if (t.get("side") or "").lower() != close_side: + continue + ts = t.get("timestamp") + if ts is None: + continue + try: + ts = int(ts) + except Exception: + continue + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if OKX_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + all_side_candidates.append(t) + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + candidates.append(t) + candidates.sort(key=lambda x: x.get("timestamp") or 0) + if candidates: + return candidates + + # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败. + all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) + if not all_side_candidates: + return [] + if not closed_ms: + return all_side_candidates[-20:] + near = [] + for t in all_side_candidates: + ts = t.get("timestamp") + if ts is None: + continue + try: + delta = abs(int(ts) - int(closed_ms)) + except Exception: + continue + # 放宽到前后 7 天 + if delta <= 7 * 24 * 60 * 60 * 1000: + near.append((delta, t)) + if near: + near.sort(key=lambda x: x[0]) + picked = [x[1] for x in near[:20]] + picked.sort(key=lambda x: x.get("timestamp") or 0) + return picked + return all_side_candidates[-20:] + + +def fetch_all_position_fills_for_record( + exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None +): + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + if closed_ms is not None: + closed_ms += 6 * 60 * 60 * 1000 + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + return filter_position_lifecycle_fills( + trades or [], + direction, + since_ms, + closed_ms, + hedge_mode=(OKX_POS_MODE == "hedge"), + close_buffer_ms=0, + ) + + +def _attach_okx_trade_exchange_stats( + conn, trade_id, *, exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=None, closed_at_ms=None +): + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return + open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) + contract_size = 1.0 + try: + ensure_markets_loaded() + contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1) + except Exception: + pass + + def _fetch(): + return fetch_all_position_fills_for_record( + exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=open_ms, closed_at_ms=close_ms + ) + + try: + attach_exchange_stats_to_trade(conn, trade_id, fetch_fills=_fetch, contract_size=contract_size) + except Exception: + pass + + +def calc_weighted_exit_price(trades): + if not trades: + return None + total_amount = 0.0 + weighted_sum = 0.0 + for t in trades: + try: + price = float(t.get("price") or 0) + amount = float(t.get("amount") or 0) + except Exception: + continue + if price <= 0: + continue + if amount <= 0: + amount = 1.0 + weighted_sum += price * amount + total_amount += amount + if total_amount <= 0: + return None + return weighted_sum / total_amount + + +def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): + """ + 交易所已无仓,本地仍为 active 时,推断平仓类型/时间/盈亏. + 返回 (result, pnl_amount, closed_at_str, miss_reason). + """ + direction = row["direction"] + sym = row["symbol"] + trigger_price = row["trigger_price"] + stop_loss = row["stop_loss"] + take_profit = row["take_profit"] + margin_capital = row["margin_capital"] or DAILY_START_CAPITAL + leverage = row["leverage"] or infer_leverage(sym) + exchange_symbol = row["exchange_symbol"] or normalize_okx_symbol(sym) + + open_ms = _to_ms_with_fallback( + row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at_str + ) + trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) + exit_px = None + closed_at_str = app_now_str() + if trade: + try: + exit_px = float(trade.get("price") or 0) or None + except (TypeError, ValueError): + exit_px = None + ts = trade.get("timestamp") + if ts: + try: + ts_i = int(ts) + except (TypeError, ValueError): + ts_i = None + if ts_i is not None and open_ms and ts_i < int(open_ms): + exit_px = None + elif ts_i is not None: + closed_at_str = ms_to_app_local_str(ts_i) + + if exit_px is None or exit_px <= 0: + p = get_price(sym) + if p: + guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) + if guessed: + pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + return ( + normalize_result_with_pnl(guessed, pnl), + pnl, + closed_at_str, + "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", + ) + return ( + "外部平仓", + 0.0, + closed_at_str, + "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", + ) + + result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) + pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) + if result: + return ( + normalize_result_with_pnl(result, pnl), + pnl, + closed_at_str, + "按交易所成交记录同步为止盈/止损平仓", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def _finalize_hub_flat_monitor_okx(conn, r, *, result, pnl_amount, closed_at, miss_reason): + opened_at = get_opened_at_value(r) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=r["margin_capital"], + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio( + r["direction"], + r["trigger_price"], + r["initial_stop_loss"] or r["stop_loss"], + r["take_profit"], + ), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=handoff_trade_miss_reason(miss_reason, r), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + + +def reconcile_external_close(conn, symbol, direction): + from lib.market.reconcile_flat_lib import reconcile_external_close_impl + from lib.market.symbol_lib import symbols_match + + global _RECONCILE_FLAT_STREAK + + return reconcile_external_close_impl( + conn, + symbol, + direction, + exchange_configured=exchange_private_api_configured, + not_configured_msg="未配置 OKX_API_KEY / OKX_API_SECRET", + symbols_match=symbols_match, + get_opened_at_value=get_opened_at_value, + resolve_monitor_exchange_symbol=resolve_monitor_exchange_symbol, + get_live_position_contracts=get_live_position_contracts, + cancel_conditional_orders=cancel_okx_swap_open_orders, + resolve_synced_flat_close=resolve_synced_flat_close, + finalize_stopped_monitor=_finalize_hub_flat_monitor_okx, + sync_trade_records=sync_trade_records_from_exchange, + reconcile_flat_streak=_RECONCILE_FLAT_STREAK, + to_ms_with_fallback=_to_ms_with_fallback, + prefer_manual_resolve=False, + order_row_monitor_type=order_row_monitor_type, + ) + + +def reconcile_external_closes(conn, days=None): + global _RECONCILE_FLAT_STREAK + if not exchange_private_api_configured(): + return 0 + if time.time() - _APP_STARTED_AT < RECONCILE_STARTUP_GRACE_SEC: + return 0 + synced_count = 0 + cutoff_ms = None + if days is not None: + try: + d = int(days) + if d > 0: + cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) + except Exception: + cutoff_ms = None + rows = conn.execute( + "SELECT * FROM order_monitors WHERE status IN ('active', 'error')" + ).fetchall() + for r in rows: + if cutoff_ms is not None: + opened_at_v = get_opened_at_value(r) + opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 + if opened_ms is None or opened_ms < cutoff_ms: + continue + oid = int(r["id"]) + if r["status"] == "error": + opened_at_chk = get_opened_at_value(r) + existing = conn.execute( + "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", + (r["symbol"], opened_at_chk, order_row_monitor_type(r)), + ).fetchone() + if existing: + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) + synced_count += 1 + continue + exchange_symbol = r["exchange_symbol"] or normalize_okx_symbol(r["symbol"]) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + _RECONCILE_FLAT_STREAK.pop(oid, None) + continue + if live_contracts > 0: + _RECONCILE_FLAT_STREAK.pop(oid, None) + continue + if r["status"] != "error": + streak = int(_RECONCILE_FLAT_STREAK.get(oid, 0)) + 1 + _RECONCILE_FLAT_STREAK[oid] = streak + if streak < RECONCILE_FLAT_CONFIRM_POLLS: + continue + _RECONCILE_FLAT_STREAK.pop(oid, None) + print( + f"[reconcile_external_closes] {r['symbol']} id={oid} " + f"flat x{streak} polls -> sync close" + ) + else: + _RECONCILE_FLAT_STREAK.pop(oid, None) + print( + f"[reconcile_external_closes] error recovery {r['symbol']} id={oid} flat -> sync close" + ) + opened_at = get_opened_at_value(r) + opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=r["margin_capital"], + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=handoff_trade_miss_reason(miss_reason, r), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result=f"{result}(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + else: + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result="外部平仓(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + synced_count += 1 + return synced_count + + +def _coerce_ts_ms(val): + if val is None or val == "": + return None + try: + v = float(val) + except (TypeError, ValueError): + return None + if v > 1e12: + return int(v) + if v > 1e9: + return int(v * 1000.0) + return int(v * 1000.0) + + +def _unified_symbol_for_match(symbol_str): + """统一 ETH/USDT:USDT,ETH-USDT-SWAP 便于与 trade_records 比对.""" + s = (symbol_str or "").strip().upper() + if not s: + return "" + if ":" in s: + s = s.split(":")[0] + if "-" in s and "/" not in s: + parts = s.split("-") + if len(parts) >= 2 and parts[-1] in ("SWAP", "FUTURES", "FUTURE"): + s = f"{parts[0]}/{parts[1]}" + else: + s = s.replace("-", "/") + if "_" in s and "/" not in s: + s = s.replace("_", "/") + if s.endswith("USDT") and "/" not in s and len(s) > 4: + s = f"{s[:-4]}/USDT" + return s + + +def exchange_position_sync_since_ms(): + s = EXCHANGE_POSITION_SYNC_FROM_BJ + if s: + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)): + try: + chunk = s[:ln] if len(s) >= ln else s[:10] + dt = datetime.strptime(chunk, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + dt0 = app_now() - timedelta(days=90) + try: + aware0 = datetime(dt0.year, dt0.month, dt0.day, 0, 0, 0, tzinfo=APP_TZ) + except Exception: + aware0 = datetime.now(APP_TZ) + return int(aware0.timestamp() * 1000) + + +def _normalize_okx_position_history_entry(p): + if not p or not isinstance(p, dict): + return None + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + sym = p.get("symbol") or "" + if not sym: + inst = str(info.get("instId") or "").strip() + if inst: + try: + ensure_markets_loaded() + sym = exchange.market(inst).get("symbol") or "" + except Exception: + parts = inst.split("-") + if len(parts) >= 2: + sym = f"{parts[0]}/{parts[1]}" + side = (p.get("side") or info.get("direction") or info.get("posSide") or "").strip().lower() + if side not in ("long", "short"): + try: + pos_val = float(info.get("pos") or 0) + if pos_val > 0: + side = "long" + elif pos_val < 0: + side = "short" + except (TypeError, ValueError): + side = "" + rp = p.get("realizedPnl") + if rp is None: + rp = info.get("realizedPnl") + if rp is None: + rp = info.get("pnl") + try: + rp_f = float(rp) if rp is not None and str(rp).strip() != "" else None + except (TypeError, ValueError): + rp_f = None + close_ms = _coerce_ts_ms(p.get("lastUpdateTimestamp")) + if close_ms is None: + close_ms = _coerce_ts_ms(info.get("uTime")) + open_ms = _coerce_ts_ms(p.get("timestamp")) + if open_ms is None: + open_ms = _coerce_ts_ms(info.get("cTime")) + pos_id = str(info.get("posId") or "").strip() + inst_id = str(info.get("instId") or "").strip() + u_raw = info.get("uTime") + sync_key = pos_id or f"{inst_id}|{u_raw}|{side}" + return { + "symbol_u": _unified_symbol_for_match(sym), + "side": side, + "close_ms": close_ms, + "open_ms": open_ms, + "pnl": rp_f, + "sync_key": sync_key, + } + + +def fetch_okx_positions_close_history(): + if not exchange_private_api_configured(): + return [] + ensure_markets_loaded() + since_ms = exchange_position_sync_since_ms() + out = [] + page_limit = 100 + max_total = int(EXCHANGE_POSITION_HISTORY_LIMIT) + before = None + while len(out) < max_total: + params = {"instType": OKX_POSITION_INST_TYPE} + if before is not None: + params["before"] = str(before) + try: + rows = exchange.fetch_positions_history( + None, + since=int(since_ms), + limit=page_limit, + params=params, + ) + except Exception: + break + if not rows: + break + batch_min_u = None + for p in rows: + h = _normalize_okx_position_history_entry(p) + if h and h["close_ms"] and h["side"] in ("long", "short") and h["symbol_u"]: + out.append(h) + info = p.get("info") or {} + u = _coerce_ts_ms(info.get("uTime")) or _coerce_ts_ms(p.get("lastUpdateTimestamp")) + if u and (batch_min_u is None or u < batch_min_u): + batch_min_u = u + if len(rows) < page_limit or batch_min_u is None: + break + if before is not None and batch_min_u >= before: + break + before = batch_min_u + return out[:max_total] + + +def sync_trade_records_from_exchange(conn, force=False): + """为未同步的 trade_records 回填 OKX 历史仓位中的已实现盈亏.返回统计 dict.""" + global _LAST_EXCHANGE_PNL_SYNC_AT + stats = {"ok": False, "hist_count": 0, "matched": 0, "pending": 0, "skipped": False} + if not exchange_private_api_configured(): + stats["reason"] = "未配置 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE" + return stats + now = time.time() + if not force and now - _LAST_EXCHANGE_PNL_SYNC_AT < 25.0: + stats["ok"] = True + stats["skipped"] = True + return stats + try: + hist = fetch_okx_positions_close_history() + except Exception as e: + stats["reason"] = str(e) + return stats + stats["hist_count"] = len(hist) + if not hist: + stats["ok"] = True + stats["reason"] = "交易所平仓历史为空(请检查 API 权限或 EXCHANGE_POSITION_SYNC_FROM_BJ)" + return stats + candidates = conn.execute( + """ + SELECT id, symbol, direction, closed_at, closed_at_ms, opened_at, opened_at_ms + FROM trade_records + WHERE (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '') + OR exchange_realized_pnl IS NULL + ORDER BY id DESC + LIMIT 200 + """ + ).fetchall() + stats["pending"] = len(candidates) + if not candidates: + stats["ok"] = True + _LAST_EXCHANGE_PNL_SYNC_AT = now + return stats + used = set() + matched = 0 + for tr in candidates: + close_ms_trade = _to_ms_with_fallback( + tr["closed_at_ms"] if "closed_at_ms" in tr.keys() else None, tr["closed_at"] + ) or opened_at_str_to_ms(tr["closed_at"]) + open_ms_trade = _to_ms_with_fallback( + tr["opened_at_ms"] if "opened_at_ms" in tr.keys() else None, tr["opened_at"] + ) or opened_at_str_to_ms(tr["opened_at"]) + if close_ms_trade is None: + continue + best = None + best_d = None + for h in hist: + sk = h["sync_key"] + if not sk or sk in used: + continue + if h["symbol_u"] != _unified_symbol_for_match(tr["symbol"]): + continue + if h["side"] != (tr["direction"] or "long").strip().lower(): + continue + cm = h["close_ms"] + if cm is None: + continue + if open_ms_trade is not None: + if cm < open_ms_trade - 15 * 60 * 1000: + continue + if cm > open_ms_trade + 15 * 86400 * 1000: + continue + else: + if abs(cm - close_ms_trade) > 3 * 86400 * 1000: + continue + d = abs(cm - close_ms_trade) + if best_d is None or d < best_d: + best_d = d + best = h + if best is None or best_d is None or best_d > 90 * 60 * 1000: + continue + sk = best["sync_key"] + if sk in used: + continue + eo = ms_to_app_local_str(best["open_ms"]) if best.get("open_ms") else None + ec = ms_to_app_local_str(best["close_ms"]) if best.get("close_ms") else None + pnl_val = best.get("pnl") + if pnl_val is None: + pnl_val = 0.0 + conn.execute( + """ + UPDATE trade_records + SET exchange_realized_pnl = ?, exchange_opened_at = ?, exchange_closed_at = ?, exchange_sync_key = ? + WHERE id = ? + """, + (float(pnl_val), eo, ec, sk, int(tr["id"])), + ) + used.add(sk) + matched += 1 + stats["matched"] = matched + stats["ok"] = True + # 仍有未匹配且历史非空:缩短节流,避免平仓后历史稍晚入库时卡在「估」 + if matched < stats["pending"] and hist: + _LAST_EXCHANGE_PNL_SYNC_AT = now - 15.0 + else: + _LAST_EXCHANGE_PNL_SYNC_AT = now + try: + conn.commit() + except Exception: + pass + return stats + + +# 获取实时价格 +def get_price(symbol): + try: + ensure_markets_loaded() + return exchange.fetch_ticker(normalize_okx_symbol(symbol))["last"] + except: + return None + +# 获取5分钟K线收盘价 +def get_5m_close(symbol): + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(normalize_okx_symbol(symbol), KLINE_TIMEFRAME, limit=1) + return ohlcv[-1][4] if ohlcv else None + except: + return None + + +def _safe_float(v): + try: + return float(v) + except Exception: + return None + + +def _compute_ema(values, period=55): + arr = [float(x) for x in values if x is not None] + if len(arr) < period: + return None + k = 2.0 / (period + 1.0) + ema = arr[0] + for val in arr[1:]: + ema = val * k + ema * (1 - k) + return ema + + +def _status_by_ema55(symbol, timeframe): + try: + bars = exchange.fetch_ohlcv(normalize_okx_symbol(symbol), timeframe=timeframe, limit=80) + if not bars or len(bars) < 56: + return "横盘", None, None + closes = [float(x[4]) for x in bars if x and len(x) >= 5] + ema55 = _compute_ema(closes, 55) + last_close = closes[-1] + if ema55 is None or last_close <= 0: + return "横盘", last_close, ema55 + diff_pct = (last_close - ema55) / ema55 * 100.0 + if abs(diff_pct) < 0.1: + return "横盘", last_close, ema55 + return ("多头" if diff_pct > 0 else "空头"), last_close, ema55 + except Exception: + return "横盘", None, None + + +def _daily_volume_rank(symbol): + """ + 返回(symbol_rank, total_count):OKX USDT 永续 24h 成交额(USDT) 在全市场币种中的排名. + """ + sym_norm = normalize_symbol_input(symbol) + target_base = journal_coin_from_symbol(sym_norm) + return resolve_daily_volume_rank( + target_base, + LIQUIDITY_RANK_CACHE, + now_ts=time.time(), + ttl_sec=max(30, BALANCE_REFRESH_SECONDS), + exchange=exchange, + ensure_markets_loaded=ensure_markets_loaded, + ) + + +def _key_hard_checks(symbol, direction, upper, lower, monitor_type): + """ + 关键位门控:量能,突破幅度,第二根确认,日成交量前30. + 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根. + """ + out = {"ok": False} + ex_sym = normalize_okx_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=80) or [] + if len(bars) < 24: + out["reason"] = "5m K线数量不足" + return out + closed = bars[:-1] if len(bars) >= 3 else bars + min_closed = KEY_VOLUME_MA_BARS + 3 + if len(closed) < min_closed: + out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足" + return out + try: + breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] + confirm = closed[KEY_CONFIRM_BAR] + except IndexError: + out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" + return out + prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR] + avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1) + vol_break = float(breakout[5]) + vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN if avg20 > 0 else False + close_b = float(breakout[4]) + high_b = float(breakout[2]) + low_b = float(breakout[3]) + cfm_close = float(confirm[4]) + edge = float(upper) if direction == "long" else float(lower) + breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) + amp_ok, amp_pct = auto_amp_ok( + direction, close_b, float(upper), float(lower), KEY_BREAKOUT_AMP_MIN_PCT + ) + amp_ok = amp_ok and breakout_ok + confirm_ok_raw = auto_confirm_ok(direction, cfm_close, float(upper), float(lower)) + confirm_ok = confirm_ok_raw and breakout_ok + rank, total = _daily_volume_rank(symbol) + rank_ok = (rank is not None) and (rank <= KEY_DAILY_VOLUME_RANK_MAX) + swing4h_pct = 0.0 + try: + seg48 = closed[-48:] if len(closed) >= 48 else closed + hh = max(float(x[2]) for x in seg48) + ll = min(float(x[3]) for x in seg48) + swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0 + except Exception: + swing4h_pct = 0.0 + out.update( + { + "ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]), + "vol_ok": vol_ok, + "avg20": avg20, + "vol_break": vol_break, + "amp_ok": amp_ok, + "amp_pct": amp_pct, + "breakout_ok": breakout_ok, + "breakout_close": close_b, + "confirm_ok": confirm_ok, + "confirm_close": cfm_close, + "edge_price": edge, + "rank": rank, + "rank_total": total, + "rank_ok": rank_ok, + "breakout_high": high_b, + "breakout_low": low_b, + "breakout_ts": breakout[0], + "confirm_ts": confirm[0], + "swing4h_pct": swing4h_pct, + "monitor_type": monitor_type, + "direction": direction, + } + ) + return out + + +def _key_plan_sl_tp_for_row(row, direction, upper, lower, checks): + mode = sl_tp_mode_from_row(row, "standard") + manual_tp = _sqlite_row_val(row, "manual_take_profit") + return plan_key_sl_tp( + mode, + direction, + upper, + lower, + checks, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + manual_take_profit=manual_tp, + ), mode + + +def calc_price_diff_pct(current_price, target_price): + try: + if target_price is None: + return None, None + t = float(target_price) + if t == 0: + return None, None + c = float(current_price) + diff = c - t + pct = diff / t * 100 + return round(diff, 6), round(pct, 4) + except Exception: + return None, None + + + +def _coerce_float(*values): + """取第一个可解析且 > 0 的数(用于价格,保证金等).""" + for v in values: + if v is None: + continue + try: + f = float(v) + if f > 0: + return f + except (TypeError, ValueError): + continue + return None + + +def _coerce_float_signed(*values): + """取第一个有限浮点数(含 0 与负数),用于未实现盈亏等.""" + for v in values: + if v is None or v == "": + continue + try: + f = float(v) + if math.isfinite(f): + return f + except (TypeError, ValueError): + continue + return None + + +def _sqlite_row_val(row, key, default=None): + try: + v = row[key] + return default if v is None else v + except (KeyError, IndexError, TypeError): + return default + + +def get_active_position_count(conn): + return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) + + +def get_key_sizing_capital_snapshot(conn, session_date): + row = conn.execute( + "SELECT key_sizing_capital_snapshot FROM trading_sessions WHERE session_date=?", + (session_date,), + ).fetchone() + if not row: + return None + try: + v = row["key_sizing_capital_snapshot"] + return float(v) if v is not None else None + except (TypeError, ValueError, KeyError): + return None + + +def set_key_sizing_capital_snapshot(conn, session_date, capital): + ensure_session(conn, session_date) + conn.execute( + "UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(float(capital), 4), session_date), + ) + conn.commit() + + +def resolve_capital_base_for_key_open(conn, trading_day, live_capital): + live = float(live_capital) + active = get_active_position_count(conn) + if active <= 0: + set_key_sizing_capital_snapshot(conn, trading_day, live) + return live + if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT: + snap = get_key_sizing_capital_snapshot(conn, trading_day) + if snap is not None and snap > 0: + return snap + return live + + +def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): + n = int(row["notification_count"] or 0) + 1 + insert_key_monitor_history(conn, row, n, last_msg, close_reason) + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + + +def _fetch_last_closed_bar(symbol): + ex_sym = normalize_okx_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or [] + if len(bars) < 2: + return None + closed = bars[:-1] + return closed[-1] if closed else None + + +def _key_rs_gate_preview(symbol, upper, lower): + bar = _fetch_last_closed_bar(symbol) + if not bar: + return {"summary": "5m数据不足", "metrics": ""} + close = float(bar[4]) + br = detect_rs_box_break(close, upper, lower) + if br: + return { + "summary": f"已越线:{br['break_label']}", + "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", + } + return { + "summary": "待突破", + "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", + } + + +def _process_key_rs_level_alert(conn, row): + sym = row["symbol"] + typ = (row["monitor_type"] or "").strip() + up, low = float(row["upper"]), float(row["lower"]) + if up <= low: + return + bar = _fetch_last_closed_bar(sym) + if not bar: + return + close = float(bar[4]) + ts = bar[0] + now_dt = app_now() + tick = run_rs_level_alert_tick( + row, + close, + ts, + now_dt, + default_max_notify=KEY_ALERT_MAX_TIMES, + default_interval_min=KEY_ALERT_INTERVAL_MINUTES, + ) + if not tick: + return + + br = tick["break_info"] + notify_index = int(tick["notify_index"]) + max_n = int(tick["notify_max"]) + interval = int(tick["interval_min"]) + bar_ts = tick.get("bar_ts") + prior_count = int(tick.get("prior_count", notify_index - 1)) + + notified_at = app_now_str() + if not claim_rs_level_notify( + conn, + row["id"], + notify_index, + br["direction"], + notified_at, + bar_ts, + prior_count=prior_count, + ): + return + conn.commit() + + trigger_time = ms_to_app_local_str(int(ts)) if ts else app_now_str() + msg = build_wechat_rs_level_message( + symbol=sym, + monitor_type=typ, + account_label=_wechat_account_label(), + trigger_time=trigger_time, + upper_txt=format_price_for_symbol(sym, up), + lower_txt=format_price_for_symbol(sym, low), + close_txt=format_price_for_symbol(sym, close), + edge_txt=format_price_for_symbol(sym, br["edge_price"]), + break_label=br["break_label"], + direction=br["direction"], + notify_index=notify_index, + notify_max=max_n, + interval_min=interval, + ) + send_wechat_msg(msg) + conn.execute( + "UPDATE key_monitors SET last_alert_message=? WHERE id=?", + (msg, row["id"]), + ) + conn.commit() + if notify_index >= max_n: + hist_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (row["id"],)).fetchone() + if hist_row: + insert_key_monitor_history(conn, hist_row, notify_index, msg, "key_level_alert_done") + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + conn.commit() + + +def _key_hard_lines_from_checks(checks): + direction = (checks.get("direction") or "long").lower() + return [ + f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", + f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", + format_auto_amp_line(checks["amp_ok"], checks["amp_pct"], KEY_BREAKOUT_AMP_MIN_PCT), + format_auto_confirm_line( + checks["confirm_ok"], checks["confirm_close"], checks["edge_price"], direction + ), + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前{KEY_DAILY_VOLUME_RANK_MAX})", + ] + + +def get_symbol_mark_price(symbol): + """斐波失效判定用标记价.""" + ex_sym = normalize_okx_symbol(symbol) + try: + ensure_markets_loaded() + ticker = exchange.fetch_ticker(ex_sym) + m = _coerce_float(ticker.get("mark"), ticker.get("last")) + if m is None: + info = ticker.get("info") or {} + m = _coerce_float(info.get("markPx"), info.get("last")) + if m is not None: + return float(m) + except Exception: + pass + p = get_price(symbol) + return float(p) if p is not None else None + + +def can_notify_key_monitor(row, now_dt): + max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES) + if int(row["notification_count"] or 0) >= max_notify: + return False + last_at = row["last_notified_at"] + if not last_at: + return True + try: + last_dt = datetime.strptime(last_at, "%Y-%m-%d %H:%M:%S") + except Exception: + return True + interval_min = int(row["notify_interval_min"] or KEY_ALERT_INTERVAL_MINUTES) + return (now_dt - last_dt).total_seconds() >= interval_min * 60 + + +def breakout_too_far(p, edge_price, limit_pct): + try: + if edge_price is None or float(edge_price) <= 0: + return False + diff_pct = abs(float(p) - float(edge_price)) / float(edge_price) * 100 + return diff_pct > float(limit_pct) + except Exception: + return False + + +# 关键位监控:仅关键支撑阻力人工盯盘提醒(自动开仓已移除) +def check_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + sym, typ = r["symbol"], (r["monitor_type"] or "").strip() + if is_limit_key_monitor_type(typ): + continue + if typ in KEY_MONITOR_RS_TYPES: + try: + _process_key_rs_level_alert(conn, r) + except Exception as e: + print(f"[key_rs_level_alert] {sym} id={r['id']}: {e}") + conn.close() + + +def check_order_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(sym) + session_date = r["session_date"] or get_trading_day() + p = get_price(sym) + if not p: continue + + # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) + risk_amount = float(r["risk_amount"] or 0) + breakeven_armed = int(r["breakeven_armed"] or 0) + if stale_breakeven_armed(direction, trigger_price, stop_loss, breakeven_armed): + conn.execute( + "UPDATE order_monitors SET breakeven_armed=0, breakeven_price=NULL WHERE id=?", + (pid,), + ) + breakeven_armed = 0 + trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) + step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) + step_r = 1.0 if step_r <= 0 else step_r + breakeven_enabled = True + try: + if "breakeven_enabled" in r.keys(): + breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 + except Exception: + breakeven_enabled = True + if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: + now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + now_rr = now_pnl / risk_amount + if now_rr >= trigger_rr: + steps = int((now_rr - trigger_rr) // step_r) + locked_r = max(0.0, steps * step_r) + notional = float(margin_capital or 0) * float(leverage or 0) + risk_frac = (risk_amount / notional) if notional > 0 else None + if risk_frac and risk_frac > 0: + new_sl = calc_breakeven_stop( + direction, + trigger_price, + risk_frac, + locked_r=locked_r, + offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT), + ) + if new_sl is not None: + should_move = (direction == "short" and new_sl < float(stop_loss)) or ( + direction == "long" and new_sl > float(stop_loss) + ) + if should_move: + was_armed = breakeven_armed + ex_sym = resolve_monitor_exchange_symbol(r) + new_sl = round_price_to_exchange(ex_sym, new_sl) + tp_ex = float(take_profit or 0) + ok_live, _live_reason = ensure_okx_live_ready() + synced_ex = False + last_ex_sync = float(_BREAKEVEN_LAST_EX_SYNC.get(pid, 0)) + interval_ok = ( + time.time() - last_ex_sync + ) >= BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC + if ok_live and tp_ex > 0 and interval_ok: + try: + replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex) + synced_ex = True + _BREAKEVEN_LAST_EX_SYNC[pid] = time.time() + _clear_breakeven_exchange_warn(pid) + except Exception as e: + print( + f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}", + flush=True, + ) + _send_breakeven_exchange_warn_once( + pid, + f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_okx_error(e)}", + ) + elif ok_live: + print( + f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit", + flush=True, + ) + if synced_ex: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, pid), + ) + stop_loss = new_sl + breakeven_armed = 1 + if not was_armed: + arm_txt = "保本止盈" + be_msg = build_wechat_breakeven_message( + sym, + direction, + arm_txt, + now_rr, + locked_r, + new_sl, + ) + if ok_live: + be_msg += "\n- 交易所:已先撤后挂止盈止损" + send_wechat_msg(be_msg) + + res = None + if should_trigger_time_close(r): + res = TIME_CLOSE_RESULT + # 做多 + if not res and direction == "long": + if p >= take_profit: res = "止盈" + elif p <= stop_loss: res = "止损" + # 做空 + elif not res and direction == "short": + if p <= take_profit: res = "止盈" + elif p >= stop_loss: res = "止损" + + if res: + now = app_now() + opened_at = get_opened_at_value(r) + opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + if res == "止损" and float(pnl_amount or 0) > 0: + res = normalize_result_with_pnl("止损", pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + close_order_id = "" + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细. + exit_p = extract_trade_price_from_order(close_resp) + if exit_p and exit_p > 0: + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + else: + ex_sym = r["exchange_symbol"] or normalize_okx_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = normalize_result_with_pnl("止损", pnl_amount) + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + except Exception as e: + if is_no_position_error(str(e)): + ex_sym = r["exchange_symbol"] or normalize_okx_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判. + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = normalize_result_with_pnl("止损", pnl_amount) + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason=handoff_trade_miss_reason( + "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + r, + ), + opened_at=opened_at, + closed_at=closed_at, + ) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{res}(交易所已先行平仓)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + session_capital_fallback=session_capital, + ) + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) + conn.commit() + continue + ex_sym_fail = r["exchange_symbol"] or normalize_okx_symbol(sym) + live_contracts = get_live_position_contracts(ex_sym_fail, direction) + if live_contracts is not None and live_contracts <= 0: + record_res, record_pnl, record_closed, sync_miss = resolve_synced_flat_close( + r, opened_at, opened_at_ms=opened_at_ms + ) + record_miss = f"{sync_miss};本地触发{res}时平仓API失败:{e}" + monitor_status = "stopped" + else: + record_res, record_pnl, record_closed = res, pnl_amount, closed_at + record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}" + monitor_status = "error" + record_hold = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(record_closed) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=record_pnl, + hold_seconds=record_hold, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(record_pnl, r["risk_amount"]), + result=record_res, + miss_reason=handoff_trade_miss_reason(record_miss, r), + opened_at=opened_at, + closed_at=record_closed, + ) + session_capital = update_session_capital(conn, session_date, record_pnl) + conn.execute("UPDATE order_monitors SET status=? WHERE id=?", (monitor_status, pid)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + if monitor_status == "stopped": + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{record_res}(已补记入交易记录)", + pnl_amount=record_pnl, + hold_seconds=record_hold, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note=record_miss, + session_capital_fallback=session_capital, + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=res, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id=close_order_id or "-", + session_capital_fallback=session_capital, + ) + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason=handoff_trade_miss_reason(None, r), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid)) + conn.commit() + conn.close() + + +def force_close_before_reset(): + if not FORCE_CLOSE_ENABLED: + return + now = app_now() + # 每天北京时间指定整点起 FORCE_CLOSE_GRACE_MINUTES 分钟内执行兜底清仓 + from lib.trade.force_close_lib import is_force_close_executing + + if not is_force_close_executing(FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000)): + return + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + p = get_price(r["symbol"]) + if not p: + continue + direction = r["direction"] + trigger_price = r["trigger_price"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(r["symbol"]) + session_date = r["session_date"] or get_trading_day(now) + opened_at = get_opened_at_value(r) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + except Exception as e: + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=r["symbol"], + direction=direction, + scene="强制清仓失败", + error_text=str(e), + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result="强制清仓", + miss_reason=handoff_trade_miss_reason( + f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + r, + ), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"])) + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=direction, + result="强制清仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id=close_order_id or "-", + extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + session_capital_fallback=session_capital, + ) + ) + conn.commit() + conn.close() + +# 后台线程 +def background_task(): + while True: + try: + auto_transfer_once_per_day() + conn = get_db() + force_close_before_reset() + reconcile_external_closes(conn) + conn.commit() + conn.close() + check_key_monitors() + check_order_monitors() + except Exception as e: + print(f"[monitor_loop] {e}", flush=True) + time.sleep(MONITOR_POLL_SECONDS) + + +# ====================== 登录路由 ====================== +@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 AUTH_DISABLED or bool(session.get("logged_in")): + return f(*args, **kwargs) + return redirect("/login") + return decorated + + +@app.route("/sync_positions") +@login_required +def sync_positions(): + days_raw = (request.args.get("days") or "").strip() + sync_days = None + if days_raw: + try: + sync_days = max(1, min(365, int(days_raw))) + except Exception: + sync_days = None + conn = get_db() + synced = reconcile_external_closes(conn, days=sync_days) + conn.commit() + conn.close() + if sync_days is not None: + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + else: + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + return redirect("/") + + +@app.route("/api/sync_positions", methods=["POST"]) +@login_required +def api_sync_positions(): + payload = request.get_json(silent=True) or {} + days_raw = str(payload.get("days", "")).strip() + if not days_raw: + return jsonify({"ok": False, "msg": "请填写天数"}), 400 + try: + days = int(days_raw) + except Exception: + return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 + if days < 1 or days > 365: + return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 + conn = get_db() + synced = reconcile_external_closes(conn, days=days) + conn.commit() + conn.close() + return jsonify({"ok": True, "days": days, "synced": int(synced)}) + + +# ====================== 主页面 ====================== +def render_main_page(page="options", embed_mode=None): + now = app_now() + trading_day = get_trading_day(now) + list_window = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + from lib.instance.instance_embed_context_lib import ( + embed_render_plan, + minimal_stats_bundle, + options_funding_label, + profit_loss_ratio_from_trades, + show_perp_funds_enabled, + total_funds_usdt, + trade_records_summary, + ) + + 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 + _sim_mode_for_header = False + try: + from lib.sim.mode_lib import is_sim_mode as _is_sim_mode_fn + _sim_mode_for_header = bool(_is_sim_mode_fn(get_db)) + except Exception: + _sim_mode_for_header = False + if ( + OKX_OPTIONS_ENABLED + and embed_mode != "fragment" + and (getattr(exchange_options, "apiKey", None) or _sim_mode_for_header) + ): + try: + from lib.exchange.okx_options_lib import options_header_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.trade.trade_labels_lib import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) + open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR + opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = account_risk_status(conn) + from lib.trade.open_trade_gate_lib import resolve_manual_open_gate + + _open_gate = resolve_manual_open_gate( + time_allows=trading_day_reset_allows_new_open(now, conn), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + risk_status=risk_status, + force_close_enabled=FORCE_CLOSE_ENABLED, + force_close_bj_hour=FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + reset_hour=TRADING_DAY_RESET_HOUR, + ) + can_trade = _open_gate["can_trade"] + open_block_note = _open_gate["open_block_note"] + key_rule_ctx = {} + if page in ("key_monitor",): + key_rule_ctx = key_monitor_rule_template_context( + kline_timeframe=KLINE_TIMEFRAME, + key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT, + key_volume_ma_bars=KEY_VOLUME_MA_BARS, + key_volume_ratio_min=KEY_VOLUME_RATIO_MIN, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + key_daily_volume_rank_max=KEY_DAILY_VOLUME_RANK_MAX, + key_confirm_breakout_bar=KEY_CONFIRM_BREAKOUT_BAR, + key_confirm_bar=KEY_CONFIRM_BAR, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + key_alert_interval_minutes=KEY_ALERT_INTERVAL_MINUTES, + key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + ) + strategy_extra = {} + conn.close() + from lib.instance.instance_embed_lib import embed_context_extras + from lib.instance.instance_settings_lib import settings_page_context + from lib.instance.instance_display_prefs_lib import display_prefs_template_context + + _display_ctx = display_prefs_template_context(get_db) + from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode + + _okx_trade_mode = get_okx_trade_mode() + _hedge_mode_on = _okx_trade_mode in ("perp_options", "options_options") + _show_perp_funds = show_perp_funds_enabled(exchange_key="okx") + template_ctx = dict( + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + record=records, + total=total, + rate=rate, + profit_loss_ratio=profit_loss_ratio, + total_funds=total_funds_usdt( + funding_usdt if _show_perp_funds else None, + current_capital if _show_perp_funds else None, + options_trading_usdc, + options_funding_usdc, + None, + None, + ), + options_funding_usdc=options_funding_usdc, + options_funding_usdt=options_funding_usdt, + options_trading_usdc=options_trading_usdc, + options_trading_usdt=options_trading_usdt, + trading_day=trading_day, + daily_start_capital=DAILY_START_CAPITAL, + current_capital=current_capital, + recommended_capital=recommended_capital, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + reset_hour=TRADING_DAY_RESET_HOUR, + open_guard_enabled=open_guard_enabled, + open_guard_blocks_now=open_guard_blocks_now, + balance_refresh_seconds=BALANCE_REFRESH_SECONDS, + auto_transfer_enabled=AUTO_TRANSFER_ENABLED, + auto_transfer_amount=AUTO_TRANSFER_AMOUNT, + auto_transfer_from=AUTO_TRANSFER_FROM, + auto_transfer_to=AUTO_TRANSFER_TO, + auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, + full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + active_count=position_limit_count, + can_trade=can_trade, + open_block_note=open_block_note, + opens_today=opens_today, + daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, + daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, + focus_key_id=(key_list[0]["id"] if key_list else None), + focus_order_id=(order_list[0]["id"] if order_list else None), + data_export_version=3, + list_window=list_window, + list_window_presets={ + "utc_this_month": PRESET_UTC_THIS_MONTH, + "utc_last3m": PRESET_UTC_LAST3M, + "utc_last6m": PRESET_UTC_LAST6M, + "all": PRESET_ALL, + "utc_today": PRESET_UTC_TODAY, + "utc_last24h": PRESET_UTC_LAST24H, + "utc_last7d": PRESET_UTC_LAST7D, + "custom": PRESET_CUSTOM, + }, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + risk_percent=RISK_PERCENT, + position_sizing_mode=POSITION_SIZING_MODE, + position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE), + trade_policy=trade_policy_template_context(TRADE_POLICY), + **order_entry_template_context(TRADE_POLICY), + open_position_button_label=open_position_button_label(TRADE_POLICY, POSITION_SIZING_MODE), + breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, + breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, + price_fmt=format_price_for_symbol, + entry_reason_options=list(ENTRY_REASON_OPTIONS), + order_type_options=list(JOURNAL_ORDER_TYPE_OPTIONS), + journal_chart_tf_choices=JOURNAL_CHART_TF_CHOICES, + journal_chart_default_tf1=JOURNAL_CHART_DEFAULT_TF1, + journal_chart_default_tf2=JOURNAL_CHART_DEFAULT_TF2, + journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT, + journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, + key_rule_ctx=key_rule_ctx, + funds_fmt=format_funds_u, + options_funding_label=options_funding_label, + exchange_display=EXCHANGE_DISPLAY_NAME, + options_enabled=OKX_OPTIONS_ENABLED, + trading_mode=("sim" if _sim_mode_for_header else "live"), + is_sim_mode=_sim_mode_for_header, + show_perp_funds=_show_perp_funds, + options_nav_visible=True, + okx_trade_mode=_okx_trade_mode, + options_open_allowed=_okx_trade_mode == "options", + hedge_plan_enabled=_hedge_mode_on, + hedge_plan_nav_visible=_hedge_mode_on, + hedge_plan_show_perp_options=_okx_trade_mode == "perp_options", + hedge_plan_show_options_options=_okx_trade_mode == "options_options", + hedge_plan_oo_close_mode_enabled=os.getenv("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "true").lower() + in ("1", "true", "yes", "on"), + hedge_plan_option_primary=os.getenv("HEDGE_PLAN_OPTION_PRIMARY", "true").lower() + in ("1", "true", "yes", "on"), + hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"), + options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC, + options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"), + options_compound_full_enabled=os.getenv( + "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true" + ).lower() + in ("1", "true", "yes", "on"), + options_compound_full_cap_enabled=os.getenv( + "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false" + ).lower() + in ("1", "true", "yes", "on"), + options_compound_full_cap_usdc=float( + os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300" + ), + options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, + options_chain_ask_liq_filter=os.getenv( + "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true" + ).lower() + in ("1", "true", "yes", "on"), + risk_status=risk_status, + max_active_positions=MAX_ACTIVE_POSITIONS, + manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + kline_timeframe=KLINE_TIMEFRAME, + funding_usdt=funding_usdt, + exchange_pnl_sync=exchange_pnl_sync, + **strategy_extra, + **embed_context_extras("okx"), + **_display_ctx, + **settings_page_context( + page, + display=_display_ctx["display"], + instance_base_dir=BASE_DIR, + exchange_key="okx", + exchange_display=EXCHANGE_DISPLAY_NAME, + risk_status=risk_status, + trade_policy=TRADE_POLICY, + data_export_version=3, + open_guard_enabled=open_guard_enabled, + ), + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ), + ) + if embed_mode == "fragment": + return render_template("embed_page_fragment.html", **template_ctx) + if embed_mode == "shell": + return render_template("embed_shell.html", initial_tab=page, **template_ctx) + return render_template("index.html", **template_ctx) + + +@app.route("/api/sync_exchange_pnl") +@login_required +def api_sync_exchange_pnl(): + conn = get_db() + stats = sync_trade_records_from_exchange(conn, force=True) + try: + conn.commit() + except Exception: + pass + conn.close() + return jsonify(stats) + + +@app.route("/") +@login_required +def index(): + return redirect("/options") + + +@app.route("/key_monitor") +@login_required +def key_monitor_page(): + redir = redirect_to_embed_shell_if_enabled("key_monitor") + if redir is not None: + return redir + return render_main_page("key_monitor") + + +@app.route("/trade") +@login_required +def trade_page(): + # 实盘下单界面已移除;永续下单仍由对冲计划走 place_exchange_order + return redirect("/options") + + +@app.route("/records") +@login_required +def records_page(): + # 永续交易记录与复盘已移除;保留期权复盘 /options/review + return redirect("/options/review") + + +@app.route("/stats") +@login_required +def stats_page(): + return redirect("/options") + + +@app.route("/dashboard") +@login_required +def dashboard_page(): + redir = redirect_to_embed_shell_if_enabled("dashboard") + if redir is not None: + return redir + return render_main_page("dashboard") + + +@app.route("/risk_policy") +@login_required +def risk_policy_page(): + redir = redirect_to_embed_shell_if_enabled("risk_policy") + if redir is not None: + return redir + return render_main_page("risk_policy") + + +@app.route("/system_guide") +@login_required +def system_guide_page(): + redir = redirect_to_embed_shell_if_enabled("system_guide") + if redir is not None: + return redir + return render_main_page("system_guide") + + +@app.route("/env_config") +@login_required +def env_config_page(): + redir = redirect_to_embed_shell_if_enabled("env_config") + if redir is not None: + return redir + return render_main_page("env_config") + + +@app.route("/settings") +@login_required +def settings_page(): + redir = redirect_to_embed_shell_if_enabled("settings") + if redir is not None: + return redir + return render_main_page("settings") + + +@app.route("/options") +@login_required +def options_main_page(): + redir = redirect_to_embed_shell_if_enabled("options") + if redir is not None: + return redir + return render_main_page("options") + + +@app.route("/api/account_snapshot") +@login_required +def api_account_snapshot(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + force_refresh = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") + funding_capital, trading_capital = get_exchange_capitals(force=force_refresh) + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) + options_trading_usdc = None + options_funding_usdc = None + options_funding_usdt = None + options_trading_usdt = None + 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.trade.trade_labels_lib import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) + opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = account_risk_status(conn) + active_pnl_rows = conn.execute( + "SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'" + ).fetchall() + from lib.instance.instance_embed_context_lib import ( + header_trade_stats_for_window, + show_perp_funds_enabled, + total_funds_usdt, + ) + + header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ) + conn.close() + open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR + from lib.trade.open_trade_gate_lib import resolve_manual_open_gate + + _open_gate = resolve_manual_open_gate( + time_allows=trading_day_reset_allows_new_open(now), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + risk_status=risk_status, + force_close_enabled=FORCE_CLOSE_ENABLED, + force_close_bj_hour=FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + reset_hour=TRADING_DAY_RESET_HOUR, + ) + can_trade = _open_gate["can_trade"] + open_block_note = _open_gate["open_block_note"] + available_trading_usdt = get_available_trading_usdt() + + unrealized_pnl = None + if exchange_private_api_configured(): + from lib.instance.instance_live_pnl_lib import resolve_instance_unrealized_pnl + + def _okx_positions(): + ensure_markets_loaded() + try: + return exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or [] + except Exception: + return exchange.fetch_positions() or [] + + try: + unrealized_pnl = resolve_instance_unrealized_pnl( + _okx_positions, + active_pnl_rows, + get_live_position_exchange_metrics, + ) + except Exception: + unrealized_pnl = None + options_unrealized_pnl = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.instance.instance_live_pnl_lib import merge_unrealized_pnl_components + from lib.options.options_positions_lib import sum_options_net_pnl_usdc + + opt_cfg = app.extensions.get("options_cfg") + if opt_cfg: + # 与持仓卡「净盈亏」同口径(买一回收−权利金),不用交易所标记价 upl + options_unrealized_pnl = sum_options_net_pnl_usdc(opt_cfg, exchange_options) + else: + from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc + + options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) + unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl) + except Exception: + options_unrealized_pnl = None + _show_perp_funds = show_perp_funds_enabled(exchange_key="okx") + return jsonify({ + "funding_usdt": funding_usdt, + "current_capital": current_capital, + "show_perp_funds": _show_perp_funds, + "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 if _show_perp_funds else None, + current_capital if _show_perp_funds else None, + options_trading_usdc, + options_funding_usdc, + None, + None, + ), + "available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None, + "unrealized_pnl": unrealized_pnl, + "options_unrealized_pnl": options_unrealized_pnl, + "recommended_capital": recommended_capital, + "active_count": position_limit_count, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "can_trade": can_trade, + "open_block_note": open_block_note, + "opens_today": opens_today, + "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, + "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, + "open_guard_enabled": open_guard_enabled, + "open_guard_blocks_now": open_guard_blocks_now, + "reset_hour": TRADING_DAY_RESET_HOUR, + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "trading_day": trading_day, + "total": header_trade_stats["total"], + "rate": header_trade_stats["rate"], + "profit_loss_ratio": header_trade_stats.get("profit_loss_ratio"), + "risk_status": risk_status, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + ), + }) + + +@app.route("/api/settings/open_guard", methods=["POST"]) +@login_required +def api_settings_open_guard(): + data = request.get_json(silent=True) or {} + raw = data.get("enabled") + if raw is None: + raw = request.form.get("enabled") + if raw is None: + return jsonify({"ok": False, "msg": "缺少 enabled 参数"}), 400 + enabled = str(raw).lower() in ("1", "true", "yes", "on") + set_trading_day_reset_open_guard_enabled(enabled) + now = app_now() + conn = get_db() + trading_day = get_trading_day(now) + from lib.trade.trade_labels_lib import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + guard_on = get_trading_day_reset_open_guard_enabled(conn) + opens_today = count_opens_for_trading_day(conn, trading_day) + conn.close() + can_trade = can_trade_new_open( + time_allows=trading_day_reset_allows_new_open(now), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + ) + return jsonify( + { + "ok": True, + "open_guard_enabled": guard_on, + "can_trade": can_trade, + "opens_today": opens_today, + "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, + "reset_hour": TRADING_DAY_RESET_HOUR, + } + ) + + +@app.route("/api/price_snapshot") +@login_required +def api_price_snapshot(): + conn = get_db() + key_rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors" + ).fetchall() + order_rows = conn.execute( + "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,order_amount," + "time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status='active'" + ).fetchall() + + try: + ensure_markets_loaded() + except Exception: + pass + + symbol_set = set() + for r in key_rows: + symbol_set.add(r["symbol"]) + for r in order_rows: + symbol_set.add(r["symbol"]) + + prices = {} + for s in symbol_set: + p = get_price(s) + if p is not None: + prices[s] = float(p) + + all_swap_positions = [] + if exchange_private_api_configured(): + try: + ensure_markets_loaded() + # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 + all_swap_positions = exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or [] + except Exception: + try: + all_swap_positions = exchange.fetch_positions() or [] + except Exception: + all_swap_positions = [] + + key_prices = [] + for r in key_rows: + price = prices.get(r["symbol"]) + if price is None: + price = get_symbol_mark_price(r["symbol"]) + if price is None: + continue + upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) + lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) + typ = (r["monitor_type"] or "").strip() + is_rs = typ in KEY_MONITOR_RS_TYPES + gate_summary = "关键支撑阻力提醒" if is_rs else (typ or "-") + key_prices.append( + { + "id": r["id"], + "symbol": r["symbol"], + "monitor_type": typ, + "direction": r["direction"], + "price": price, + "upper": r["upper"], + "lower": r["lower"], + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + "gate_summary": gate_summary, + "gate_metrics": "", + "gate_ok": True, + "notification_count": r["notification_count"], + "max_notify": r["max_notify"], + } + ) + + for r in order_rows: + margin = float(r["margin_capital"] or 0) + leverage = float(r["leverage"] or 0) + entry = float(r["trigger_price"] or 0) + exchange_tpsl = {"sl": None, "tp": None} + ex_sym = resolve_monitor_exchange_symbol(r) + prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) + lev_row = r["leverage"] if "leverage" in r.keys() else None + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None + price = resolve_order_snapshot_price( + r["symbol"], + prices, + position_row=prow, + order_leverage=lev_row, + parse_position_metrics_fn=parse_ccxt_position_metrics, + get_mark_price_fn=get_symbol_mark_price, + fallback_entry=entry if entry > 0 else None, + ) + pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 and price else 0 + pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 + payload = { + "id": r["id"], + "symbol": r["symbol"], + "float_pnl": round(pnl, 2), + "float_pct": pnl_pct, + "plan_margin": round(margin, 2) if margin else None, + "order_amount": float(r["order_amount"]) if r["order_amount"] not in (None, "") else None, + "exchange_initial_margin": None, + "exchange_notional": None, + "exchange_mark_price": None, + "pnl_source": "plan", + } + if ex_metrics: + if ex_metrics.get("initial_margin") is not None: + payload["exchange_initial_margin"] = ex_metrics["initial_margin"] + if ex_metrics.get("notional") is not None: + payload["exchange_notional"] = ex_metrics["notional"] + if ex_metrics.get("mark_price") is not None: + payload["exchange_mark_price"] = ex_metrics["mark_price"] + if ex_metrics.get("unrealized_pnl") is not None: + payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 2) + payload["pnl_source"] = "exchange" + denom = ex_metrics.get("initial_margin") or margin + payload["float_pct"] = ( + round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct + ) + px_for_fmt = None + if price is not None: + try: + px_for_fmt = float(price) + except (TypeError, ValueError): + px_for_fmt = None + if ex_metrics and ex_metrics.get("mark_price") is not None: + try: + px_for_fmt = float(ex_metrics["mark_price"]) + except (TypeError, ValueError): + pass + if px_for_fmt is not None: + px_disp = format_price_for_symbol(r["symbol"], px_for_fmt) + try: + payload["price"] = float(px_disp) if px_disp != "-" else px_for_fmt + except Exception: + payload["price"] = px_for_fmt + payload["price_display"] = px_disp + else: + payload["price"] = None + payload["price_display"] = "-" + if exchange_private_api_configured(): + try: + exchange_tpsl = fetch_exchange_tpsl_slots( + ex_sym, + r["direction"], + plan_sl=r["stop_loss"], + plan_tp=r["take_profit"], + ) + except Exception: + exchange_tpsl = {"sl": None, "tp": None} + payload["exchange_tpsl"] = exchange_tpsl + avg_entry = None + if ex_metrics and ex_metrics.get("entry_price") is not None: + avg_entry = ex_metrics["entry_price"] + elif prow: + from lib.market.position_metrics_lib import parse_position_entry_price + + avg_entry = parse_position_entry_price(prow) + apply_order_price_display_fields( + payload, + direction=r["direction"], + entry_price=entry, + initial_stop_loss=r["initial_stop_loss"], + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + calc_rr_ratio_fn=calc_rr_ratio, + exchange_tpsl=exchange_tpsl, + format_price_fn=format_price_for_symbol, + symbol=r["symbol"], + margin_capital=margin, + leverage=leverage, + exchange_notional=ex_metrics.get("notional") if ex_metrics else None, + contracts=abs(_position_row_effective_contracts(prow)) if prow else None, + contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, + mark_price=ex_metrics.get("mark_price") if ex_metrics else price, + avg_entry_price=avg_entry, + funds_decimals=FUNDS_DECIMALS, + ) + apply_time_close_to_payload(payload, r) + apply_force_close_to_payload( + payload, + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) + payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None + open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None + payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None + new_sl, new_tp, changed = order_monitor_tpsl_needs_sync( + r["stop_loss"], r["take_profit"], exchange_tpsl + ) + if changed: + try: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (new_sl, new_tp, int(r["id"])), + ) + except Exception: + pass + order_prices.append(payload) + + try: + conn.commit() + except Exception: + pass + conn.close() + + from lib.market.position_metrics_lib import build_position_marks_list + + position_marks = build_position_marks_list( + all_swap_positions, + format_mark_display=lambda sym, px: format_price_for_symbol(sym, px), + ) + + options_unrealized_pnl = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.options.options_positions_lib import sum_options_net_pnl_usdc + + opt_cfg = app.extensions.get("options_cfg") + if opt_cfg: + options_unrealized_pnl = sum_options_net_pnl_usdc(opt_cfg, exchange_options) + else: + from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc + + options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) + except Exception: + options_unrealized_pnl = None + + return jsonify({ + "updated_at": app_now_str(), + "key_prices": key_prices, + "order_prices": order_prices, + "position_marks": position_marks, + "positions_raw_count": len(all_swap_positions), + "options_unrealized_pnl": options_unrealized_pnl, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + ), + }) + + +@app.route("/api/symbol_liquidity_rank") +@login_required +def api_symbol_liquidity_rank(): + symbol = normalize_symbol_input(request.args.get("symbol")) + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + rank, total = _daily_volume_rank(symbol) + base = journal_coin_from_symbol(symbol) + vol_24h = (LIQUIDITY_RANK_CACHE.get("volumes") or {}).get(base) + if total <= 0: + return jsonify({"ok": False, "msg": "24h成交额排名读取失败"}), 502 + if rank is None: + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": None, + "total": int(total), + "vol_usdt_24h": vol_24h, + "in_top30": False, + "rank_max": KEY_DAILY_VOLUME_RANK_MAX, + } + ) + in_top = bool(rank <= KEY_DAILY_VOLUME_RANK_MAX) + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": int(rank), + "total": int(total), + "vol_usdt_24h": vol_24h, + "in_top30": in_top, + "in_top": in_top, + "rank_max": KEY_DAILY_VOLUME_RANK_MAX, + } + ) + + +@app.route("/api/order_defaults") +@login_required +def api_order_defaults(): + symbol = normalize_symbol_input(request.args.get("symbol")) + direction = (request.args.get("direction") or "long").strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if direction not in ("long", "short"): + direction = "long" + exchange_symbol = normalize_okx_symbol(symbol) + leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + available = get_available_trading_usdt() + last_price = get_price(symbol) + return jsonify({ + "ok": True, + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "available_trading_usdt": round(available, 4) if available is not None else None, + "last_price": round(float(last_price), 8) if last_price is not None else None, + "price": round(float(last_price), 8) if last_price is not None else None, + }) + + +@app.route("/order_focus") +@login_required +def order_focus(): + return redirect("/options") + + +@app.route("/add_order", methods=["POST"]) +@login_required +def add_order(): + # 实盘下单界面已移除;永续开平仓请走对冲计划(place_exchange_order / close_exchange_order) + flash("实盘下单界面已移除,请使用对冲计划或期权页") + return redirect("/options") + + +@app.route("/api/order_kline") +@login_required +def api_order_kline(): + order_id_raw = (request.args.get("order_id") or "").strip() + if not order_id_raw.isdigit(): + return jsonify({"ok": False, "msg": "order_id 无效"}), 400 + order_id = int(order_id_raw) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"} + if timeframe not in allowed_tfs: + timeframe = KLINE_TIMEFRAME + limit = 100 + + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404 + + order_item = enrich_order_item(row_to_dict(row), current_capital) + exchange_symbol = order_item.get("exchange_symbol") or normalize_okx_symbol(order_item["symbol"]) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_okx_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + ts = int(bar[0] // 1000) + candles.append({ + "time": ts, + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + from lib.instance.focus_chart_lib import ( + build_order_kline_order_payload, + load_swap_positions_for_order_kline, + metrics_for_order_item, + ) + + current_price = get_price(order_item["symbol"]) + positions = load_swap_positions_for_order_kline( + exchange, + private_configured=exchange_private_api_configured(), + ensure_markets_fn=ensure_markets_loaded, + ) + ex_metrics = metrics_for_order_item( + order_item, + positions, + resolve_ex_sym_fn=resolve_monitor_exchange_symbol, + select_live_fn=_select_live_position_row, + parse_metrics_fn=parse_ccxt_position_metrics, + ) + order_payload = build_order_kline_order_payload( + order_item, + ticker_price=current_price, + format_price_fn=format_price_for_symbol, + calc_pnl_fn=calc_pnl, + calc_rr_ratio_fn=calc_rr_ratio, + ex_metrics=ex_metrics, + ) + + from lib.instance.focus_chart_lib import kline_api_price_fields + + price_fields = kline_api_price_fields( + exchange, + exchange_symbol, + candles, + ensure_markets_fn=ensure_markets_loaded, + ) + + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": order_payload, + "candles": candles, + "updated_at": app_now_str(), + **price_fields, + }) + + +@app.route("/key_focus") +@login_required +def key_focus(): + conn = get_db() + key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + conn.close() + key_list = [row_to_dict(r) for r in key_rows] + + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_query = normalize_symbol_input(request.args.get("symbol")) + selected_key = None + if key_id_raw.isdigit(): + selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None) + if selected_key is None and symbol_query: + selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None) + if selected_key is None and key_list: + selected_key = key_list[0] + default_symbol = default_symbol_for_policy( + TRADE_POLICY, + symbol_query or ((selected_key or {}).get("symbol")) or "BTC/USDT", + ) + return render_template( + "key_focus_v2.html", + key_list=key_list, + selected_key=selected_key, + default_symbol=default_symbol, + default_timeframe=KLINE_TIMEFRAME, + default_kline_limit=200, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + trade_policy=trade_policy_template_context(TRADE_POLICY), + ) + + +@app.route("/api/key_kline") +@login_required +def api_key_kline(): + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_input = normalize_symbol_input(request.args.get("symbol")) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + if timeframe not in {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}: + timeframe = KLINE_TIMEFRAME + limit = normalize_kline_limit(request.args.get("limit"), default=200) + + conn = get_db() + key_row = None + if key_id_raw.isdigit(): + key_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (int(key_id_raw),)).fetchone() + if key_row is None and symbol_input: + key_row = conn.execute( + "SELECT * FROM key_monitors WHERE upper(symbol)=? ORDER BY id DESC LIMIT 1", + (symbol_input,), + ).fetchone() + if key_row is not None: + symbol = (key_row["symbol"] or "").upper() + else: + symbol = symbol_input + conn.close() + if not symbol: + return jsonify({"ok": False, "msg": "请先输入币种或选择关键位"}), 400 + + exchange_symbol = normalize_okx_symbol(symbol) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_okx_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + candles.append({ + "time": int(bar[0] // 1000), + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(symbol) + key_info = None + if key_row is not None: + upper = float(key_row["upper"]) if key_row["upper"] is not None else None + lower = float(key_row["lower"]) if key_row["lower"] is not None else None + upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None) + lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None) + key_info = { + "id": key_row["id"], + "monitor_type": key_row["monitor_type"], + "direction": key_row["direction"] or "long", + "upper": upper, + "lower": lower, + "notification_count": int(key_row["notification_count"] or 0), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + } + + from lib.instance.focus_chart_lib import enrich_key_kline_response + + price_display, key_info = enrich_key_kline_response( + symbol=symbol, + current_price=current_price, + key_info=key_info, + format_price_fn=format_price_for_symbol, + ) + + from lib.instance.focus_chart_lib import kline_api_price_fields + + price_fields = kline_api_price_fields( + exchange, + exchange_symbol, + candles, + ensure_markets_fn=ensure_markets_loaded, + ) + + return jsonify({ + "ok": True, + "symbol": symbol, + "timeframe": timeframe, + "limit": limit, + "current_price": round(float(current_price), 8) if current_price is not None else None, + "current_price_display": price_display, + "key_monitor": key_info, + "candles": candles, + "updated_at": app_now_str(), + **price_fields, + }) + + +@app.route("/api/order//cancel_tpsl", methods=["POST"]) +@login_required +def api_order_cancel_tpsl(order_id): + from lib.trade.trade_policy_lib import is_intraday_trading_profile + + if is_intraday_trading_profile(TRADE_POLICY): + return jsonify({"ok": False, "msg": "日内纪律账户禁止撤销交易所止盈止损"}), 403 + data = request.get_json(silent=True) or {} + role = (data.get("role") or "").strip().lower() + if role not in ("sl", "tp"): + return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + ok, reason = ensure_okx_live_ready() + if not ok: + return jsonify({"ok": False, "msg": reason}), 400 + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"]) + slot = slots.get(role) + if not slot: + return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 + try: + cancel_okx_tpsl_slot(ex_sym, slot) + return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": fetch_exchange_tpsl_slots(ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"])}) + except Exception as e: + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + + +@app.route("/api/order//place_tpsl", methods=["POST"]) +@login_required +def api_order_place_tpsl(order_id): + data = request.get_json(silent=True) or {} + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + symbol = row["symbol"] + direction = row["direction"] + live_price = get_price(symbol) + if live_price is None: + conn.close() + return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400 + try: + sltp_mode = (data.get("sltp_mode") or "price").strip().lower() + stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": str(e)}), 400 + planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR: + conn.close() + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return jsonify( + { + "ok": False, + "msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1", + } + ), 400 + try: + replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (stop_loss, take_profit, order_id), + ) + conn.commit() + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit) + prow = None + ex_metrics = None + if exchange_private_api_configured(): + try: + rows = exchange.fetch_positions([ex_sym]) or exchange.fetch_positions() or [] + prow = _select_live_position_row(rows, ex_sym, direction) + if prow: + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=row["leverage"]) + except Exception: + pass + from lib.trade.order_monitor_display_lib import enrich_active_monitor_tpsl_json + + ex_sym = resolve_monitor_exchange_symbol(row) + display_extra = enrich_active_monitor_tpsl_json( + row, + stop_loss, + take_profit, + slots, + position_row=prow, + exchange_notional=ex_metrics.get("notional") if ex_metrics else None, + contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, + mark_price=live_price, + calc_rr_ratio_fn=calc_rr_ratio, + format_price_fn=format_price_for_symbol, + symbol=symbol, + funds_decimals=FUNDS_DECIMALS, + ) + conn.close() + return jsonify( + { + "ok": True, + "msg": "已先撤后挂止盈止损", + "stop_loss": stop_loss, + "take_profit": take_profit, + "planned_rr": planned_rr, + "exchange_tpsl": slots, + **display_extra, + } + ) + +@app.route("/add_key", methods=["POST"]) +@login_required +def add_key(): + d = request.form + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + flash("symbol 不能为空") + return redirect("/key_monitor") + ok_sym, sym_msg = check_symbol_policy( + TRADE_POLICY, symbol, normalize_symbol_input + ) + if not ok_sym: + flash(sym_msg) + return redirect("/key_monitor") + mt = (d.get("type") or "").strip() + direction_sel = (d.get("direction") or "").strip().lower() + dup_msg = check_duplicate_submit( + session, submit_scope_add_key(symbol, mt, direction_sel or "watch") + ) + if dup_msg: + flash(dup_msg) + return redirect("/key_monitor") + if mt not in KEY_MONITOR_RS_TYPES: + flash("仅支持「关键支撑阻力」提醒(关键位自动单已移除)") + return redirect("/key_monitor") + direction_sel = KEY_DIRECTION_WATCH + mt = KEY_MONITOR_RS_TYPE + ok_dir, dir_msg = check_direction_policy(TRADE_POLICY, direction_sel) + if not ok_dir: + flash(dir_msg) + return redirect("/key_monitor") + try: + upper = float(d.get("upper") or 0) + lower = float(d.get("lower") or 0) + except (TypeError, ValueError): + upper = lower = 0 + if upper <= 0 or lower <= 0 or upper <= lower: + flash("上沿须大于下沿,且均为正数") + return redirect("/key_monitor") + rank, total = _daily_volume_rank(symbol) + if rank is None: + flash("日成交量排名读取失败,请稍后重试") + return redirect("/key_monitor") + if rank > KEY_DAILY_VOLUME_RANK_MAX: + flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") + return redirect("/key_monitor") + conn = get_db() + max_notify = KEY_ALERT_MAX_TIMES + interval = KEY_ALERT_INTERVAL_MINUTES + try: + if d.get("max_notify"): + max_notify = max(1, int(d.get("max_notify"))) + if d.get("notify_interval_min"): + interval = max(1, int(d.get("notify_interval_min"))) + except (TypeError, ValueError): + pass + conn.execute( + """INSERT INTO key_monitors + (symbol, monitor_type, direction, upper, lower, notification_count, + max_notify, notify_interval_min, breakout_limit_pct) + VALUES (?,?,?,?,?,0,?,?,?)""", + ( + symbol, + mt, + direction_sel, + upper, + lower, + max_notify, + interval, + KEY_BREAKOUT_LIMIT_PCT, + ), + ) + conn.commit() + conn.close() + flash(f"已添加关键支撑阻力提醒:{symbol} H={upper} L={lower}|日成交量排名 {rank}/{total}") + return redirect("/key_monitor") + + +@app.route("/delete_key_monitor/", methods=["POST"]) +@login_required +def delete_key_monitor(kid): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "error": "not_found"}) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/delete_key_history/", methods=["POST"]) +@login_required +def delete_key_history(hid): + conn = get_db() + cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/del_key/") +@login_required +def del_key(id): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() + if row: + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) + conn.commit() + conn.close() + resp = redirect("/") + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + return resp + + +def _csv_response(filename, rows, header): + buf = StringIO() + w = csv.writer(buf) + w.writerow(header) + for row in rows: + w.writerow(row) + out = "\ufeff" + buf.getvalue() + return Response( + out, + mimetype="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +def _md_response(filename, content): + return Response( + content, + mimetype="text/markdown; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +@app.route("/export/trade_records") +@login_required +def export_trade_records(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," + "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," + "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason," + "exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at " + f"FROM trade_records WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? " + f"AND {sql_list_time_field('closed_at', 'created_at', 'opened_at')} <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", + "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", + "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", + "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", + "exchange_realized_pnl", "exchange_opened_at", "exchange_closed_at", "created_at", "开仓类型", + ] + data = [] + for r in rows: + er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" + er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" + kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" + eff = format_entry_type_display( + er1 or er0 or entry_reason_from_key_signal(kst) or "", + entry_model=r["entry_model"] if "entry_model" in r.keys() else None, + trade_style=r["trade_style"] if "trade_style" in r.keys() else None, + ) + snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] + data.append(( + r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], + snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], + r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], + r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], + r["exchange_realized_pnl"] if "exchange_realized_pnl" in r.keys() else None, + r["exchange_opened_at"] if "exchange_opened_at" in r.keys() else None, + r["exchange_closed_at"] if "exchange_closed_at" in r.keys() else None, + r["created_at"], eff, + )) + day = app_now().strftime("%Y%m%d") + return _csv_response(f"trade_records_v3_{day}.csv", data, head) + + +@app.route("/export/journal_entries") +@login_required +def export_journal_entries(): + conn = get_db() + rows = conn.execute( + "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," + "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," + "post_breakeven_stare,new_trade_while_occupied,note,image,images_json,created_at FROM journal_entries ORDER BY created_at ASC" + ).fetchall() + conn.close() + head = [ + "id", + "open_datetime", + "close_datetime", + "hold_duration", + "coin", + "tf", + "pnl", + "entry_reason", + "exit_reason", + "expect_rr", + "real_rr", + "early_exit", + "early_exit_trigger", + "early_exit_note", + "early_exit_reason", + "mood_issues", + "post_breakeven_stare", + "new_trade_while_occupied", + "note", + "image", + "images_json", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"journal_entries_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitors") +@login_required +def export_key_monitors(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify," + "notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC" + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_notified_at", + "max_notify", + "notify_interval_min", + "breakout_limit_pct", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitor_history") +@login_required +def export_key_monitor_history(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " + "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_alert_message", + "close_reason", + "closed_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) + +@app.route("/del_order/") +@login_required +def del_order(id): + conn = get_db() + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() + if not row: + conn.close() + flash("订单不存在") + return redirect("/") + if row["status"] == "active": + try: + p = get_price(row["symbol"]) or float(row["trigger_price"]) + opened_at = get_opened_at_value(row) + closed_at = app_now_str() + hold_seconds = calc_hold_seconds(opened_at, app_now()) + pnl_amount = calc_pnl( + row["direction"], + row["trigger_price"], + p, + row["margin_capital"] or DAILY_START_CAPITAL, + row["leverage"] or infer_leverage(row["symbol"]) + ) + close_resp = close_exchange_order(row) + close_order_id = close_resp.get("id", "") + session_date = row["session_date"] or get_trading_day() + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type=trade_record_monitor_type(conn, row), + trend_plan_id=trend_plan_id_from_monitor_row(row), + key_signal_type=order_row_key_signal_type(row), + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result="手动平仓", + miss_reason=handoff_trade_miss_reason("用户手动删除订单触发平仓", row), + opened_at=opened_at, + closed_at=closed_at, + ) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(None, closed_at), + trading_day=session_date, + now=app_now(), + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + conn.commit() + conn.close() + send_wechat_msg( + build_wechat_close_message( + symbol=row["symbol"], + direction=row["direction"], + result="手动平仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=row["trigger_price"], + current_price=p, + stop_loss=row["stop_loss"], + take_profit=row["take_profit"], + close_order_id=close_order_id or "-", + extra_note="用户在页面手动平仓", + session_capital_fallback=session_capital, + ) + ) + flash("已按实盘流程手动平仓") + return redirect("/") + except Exception as e: + if is_no_position_error(str(e)): + opened_at = get_opened_at_value(row) + opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) + miss_reason = f"手动删除时无持仓:{miss_reason}" + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = row["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type=trade_record_monitor_type(conn, row), + trend_plan_id=trend_plan_id_from_monitor_row(row), + key_signal_type=order_row_key_signal_type(row), + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result=result, + miss_reason=handoff_trade_miss_reason(miss_reason, row), + opened_at=opened_at, + closed_at=closed_at, + ) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(None, closed_at), + trading_day=session_date, + now=app_now(), + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + conn.commit() + conn.close() + flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") + return redirect("/") + conn.close() + flash(f"手动平仓失败:{str(e)}") + return redirect("/") + conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) + conn.commit() + conn.close() + return redirect("/") + + +@app.route("/add_journal", methods=["POST"]) +@login_required +def add_journal(): + from lib.instance.journal_chart_async_lib import journal_ajax_or_flash_error + + d = request.form + order_type_norm = normalize_journal_order_type(d.get("order_type")) + if not order_type_norm: + return journal_ajax_or_flash_error(request, "请选择下单类型", redirect_fn=_redirect_records) + direction_norm = normalize_journal_direction(d.get("direction") or d.get("direction_hint")) + if not direction_norm: + return journal_ajax_or_flash_error(request, "请选择方向", redirect_fn=_redirect_records) + entry_reason_norm = normalize_journal_entry_reason( + d.get("entry_reason"), ENTRY_REASON_OPTIONS, allow_legacy=False + ) + if not entry_reason_norm: + return journal_ajax_or_flash_error(request, "请选择开仓类型", redirect_fn=_redirect_records) + early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger")) + early_exit_note = str(d.get("early_exit_note") or "").strip() + if not early_exit_trigger: + return journal_ajax_or_flash_error(request, "请选择离场触发", redirect_fn=_redirect_records) + if early_exit_trigger == "手动平仓" and not early_exit_note: + return journal_ajax_or_flash_error( + request, "手工平仓必须填写补充说明", redirect_fn=_redirect_records + ) + if early_exit_trigger != "手动平仓": + early_exit_note = "" + # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」 + early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" + early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) + exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) + entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex + manual_images = collect_journal_slot_images( + d, + request.files, + entry_id, + app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + images_json_str = images_json_dumps(manual_images) + image_filename = primary_journal_image(manual_images) + has_manual_uploads = bool(manual_images) + + mood_issues = ",".join(request.form.getlist("mood_issues")) + hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) + real_rr_text = (d.get("real_rr") or "").strip() + try: + risk_amount_hint = float(d.get("risk_amount_hint") or 0) + pnl_hint = float(d.get("pnl") or 0) + # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 + if risk_amount_hint > 0: + real_rr_text = f"{(pnl_hint / risk_amount_hint):.2f}" + except Exception: + pass + + want_exchange_chart = ( + not has_manual_uploads + and ORDER_CHART_ENABLED + and d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + ) + chart_job = None + if want_exchange_chart: + coin = (d.get("coin") or "").strip().upper() + symbol_guess = normalize_symbol_input(coin) or coin + exchange_symbol = normalize_okx_symbol(symbol_guess) + journal_tfs = parse_journal_chart_timeframes( + d.get("journal_chart_tf1"), + d.get("journal_chart_tf2"), + ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None, + ) + journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT) + chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor")) + chart_job = { + "exchange_symbol": exchange_symbol, + "title_prefix": f"{symbol_guess} journal {entry_id[:8]}", + "journal_tfs": journal_tfs, + "journal_limit": journal_limit, + "marker_payload": { + "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")), + "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")), + "entry_price": d.get("entry_price_hint"), + "exit_price": d.get("exit_price_hint"), + "stop_loss_price": d.get("stop_loss_hint"), + "chart_anchor": chart_anchor, + "now_ts_ms": int(app_now().timestamp() * 1000), + }, + } + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, direction, pnl, order_type, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image, images_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, + normalize_bj_datetime_storage(d.get("open_datetime")), + normalize_bj_datetime_storage(d.get("close_datetime")), + hold_duration, + d.get("coin"), + d.get("tf"), + direction_norm, + d.get("pnl"), order_type_norm, entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, + early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, + None, None, None, mood_issues, + d.get("post_breakeven_stare"), None, d.get("note"), image_filename, + images_json_str, + ) + ) + from lib.trade.account_risk_lib import on_journal_saved + + on_journal_saved( + conn, + early_exit_trigger=early_exit_trigger, + early_exit_note=early_exit_note, + mood_issues_raw=mood_issues, + trading_day=get_trading_day(), + now=app_now(), + ) + conn.commit() + conn.close() + if chart_job: + from lib.instance.journal_chart_async_lib import schedule_journal_exchange_chart + + schedule_journal_exchange_chart( + entry_id=entry_id, + exchange_symbol=chart_job["exchange_symbol"], + title_prefix=chart_job["title_prefix"], + journal_tfs=chart_job["journal_tfs"], + journal_limit=chart_job["journal_limit"], + marker_payload=chart_job["marker_payload"], + upload_folder=app.config["UPLOAD_FOLDER"], + generate_chart_fn=generate_multi_timeframe_chart_png, + get_db_fn=get_db, + ) + msg = ( + "交易复盘记录已保存.K 线图后台生成中" + f"({'/'.join(chart_job['journal_tfs'])} 各{chart_job['journal_limit']}根),稍后刷新列表可见." + ) + else: + msg = "交易复盘记录已保存" + from lib.instance.journal_chart_async_lib import request_wants_journal_ajax + + if request_wants_journal_ajax(request): + return jsonify( + { + "ok": True, + "id": entry_id, + "msg": msg, + "chart_pending": bool(chart_job), + } + ) + flash(msg) + return _redirect_records() + + +@app.route("/api/journal_upload_slot", methods=["POST"]) +@login_required +def api_journal_upload_slot(): + payload, code = handle_journal_upload_slot( + request, + upload_folder=app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + return jsonify(payload), code + + +from lib.instance.records_api_register import register_trade_records_api + +register_trade_records_api( + app, + login_required=login_required, + get_db=get_db, + list_window_from_request=_list_window_from_request, + utc_window_to_bj_sql_strings=utc_window_to_bj_sql_strings, + sql_list_time_field=sql_list_time_field, + to_effective_trade_dict=to_effective_trade_dict, + filter_trade_records_excluding_miss=filter_trade_records_excluding_miss, + app_tz=APP_TZ, + format_price_fn=format_price_for_symbol, + sync_exchange_pnl_fn=lambda conn: sync_trade_records_from_exchange(conn, force=False), +) + + +def _dashboard_fetch_options_positions(): + if not OKX_OPTIONS_ENABLED: + return [] + cfg = app.extensions.get("options_cfg") + if not isinstance(cfg, dict) or not cfg.get("enabled"): + return [] + try: + from lib.options.options_dashboard_lib import fetch_light_option_positions_for_dashboard + + return fetch_light_option_positions_for_dashboard(cfg) + except Exception: + return [] + + +def _dashboard_enrich_orders(items): + from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks + + return enrich_order_items_with_marks( + items, get_price=get_price, get_contract_size=get_contract_size + ) + + +from lib.hedge_plan.okx_trade_mode_lib import hedge_module_enabled +from lib.instance.instance_dashboard_register import register_instance_dashboard_routes + +register_instance_dashboard_routes( + app, + login_required=login_required, + get_db=get_db, + fetch_options_positions=_dashboard_fetch_options_positions, + enrich_orders=_dashboard_enrich_orders, + hedge_enabled=hedge_module_enabled, +) + +from lib.account_ledger.account_ledger_register import install_account_ledger + +install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="okx") + + +@app.route("/api/journals") +@login_required +def api_journals(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + f"SELECT * FROM journal_entries WHERE {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} >= ? " + f"AND {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} <= ? ORDER BY created_at DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + conn.close() + result = [] + for r in rows: + item = enrich_journal_api_item(row_to_dict(r)) + item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] + result.append(item) + return jsonify(result) + + +@app.route("/delete_journal/", methods=["POST"]) +@login_required +def delete_journal(jid): + conn = get_db() + row = conn.execute( + "SELECT image, images_json FROM journal_entries WHERE id=?", + (jid,), + ).fetchone() + if row: + for img_path in journal_image_paths(row, app.config["UPLOAD_FOLDER"]): + try: + if os.path.exists(img_path): + os.remove(img_path) + except Exception: + pass + conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +_REPO_STATIC_DIR = common_static_dir(_REPO_ROOT) +_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js") +_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js") +_OPEN_SUBMIT_GATE_JS = os.path.join(_REPO_STATIC_DIR, "open_submit_gate.js") +_OPTIONS_PANEL_JS = os.path.join(_REPO_STATIC_DIR, "options_panel.js") +_OPTIONS_EXPIRY_COUNTDOWN_JS = os.path.join(_REPO_STATIC_DIR, "options_expiry_countdown.js") +_OPTIONS_SETTINGS_JS = os.path.join(_REPO_STATIC_DIR, "options_settings.js") +_HEDGE_PLAN_JS = os.path.join(_REPO_STATIC_DIR, "hedge_plan.js") + + +@app.route("/static/form_submit_guard.js") +def static_form_submit_guard_js(): + if not os.path.isfile(_FORM_SUBMIT_GUARD_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/manual_order_rr_preview.js") +def static_manual_order_rr_preview_js(): + if not os.path.isfile(_MANUAL_ORDER_RR_PREVIEW_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_MANUAL_ORDER_RR_PREVIEW_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/open_submit_gate.js") +def static_open_submit_gate_js(): + if not os.path.isfile(_OPEN_SUBMIT_GATE_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPEN_SUBMIT_GATE_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/options_panel.js") +def static_options_panel_js(): + if not os.path.isfile(_OPTIONS_PANEL_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_PANEL_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/options_expiry_countdown.js") +def static_options_expiry_countdown_js(): + if not os.path.isfile(_OPTIONS_EXPIRY_COUNTDOWN_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_EXPIRY_COUNTDOWN_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/options_settings.js") +def static_options_settings_js(): + if not os.path.isfile(_OPTIONS_SETTINGS_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_SETTINGS_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/hedge_plan.js") +def static_hedge_plan_js(): + if not os.path.isfile(_HEDGE_PLAN_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_HEDGE_PLAN_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/delete_trade_record/", methods=["POST"]) +@login_required +def delete_trade_record(rid): + conn = get_db() + cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) + + +@app.route("/api/trade_record_review_update", methods=["POST"]) +@login_required +def api_trade_record_review_update(): + payload = request.get_json(silent=True) or {} + rec_id = payload.get("id") + try: + rec_id = int(rec_id) + except Exception: + return jsonify({"ok": False, "msg": "记录ID无效"}), 400 + + reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() + reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() + reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") + reviewed_take_profit_raw = payload.get("reviewed_take_profit") + reviewed_result = str(payload.get("reviewed_result") or "").strip() + reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() + reviewed_pnl_raw = payload.get("reviewed_pnl_amount") + + if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: + return jsonify({"ok": False, "msg": "结果仅允许:" + "/".join(REVIEW_RESULT_OPTIONS)}), 400 + + try: + reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") + reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") + except Exception: + return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 + if reviewed_close_dt < reviewed_open_dt: + return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 + hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) + hold_minutes = calc_hold_minutes(hold_seconds) + + try: + reviewed_pnl_amount = float(reviewed_pnl_raw) + except Exception: + return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 + reviewed_stop_loss = None + if reviewed_stop_loss_raw not in (None, ""): + try: + reviewed_stop_loss = float(reviewed_stop_loss_raw) + except Exception: + return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 + reviewed_take_profit = None + if reviewed_take_profit_raw not in (None, ""): + try: + reviewed_take_profit = float(reviewed_take_profit_raw) + except Exception: + return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 + + _MISSING_ER = object() + reviewed_entry_reason_update = _MISSING_ER + if "reviewed_entry_reason" in payload: + s = str(payload.get("reviewed_entry_reason") or "").strip() + norm = normalize_entry_reason(s) if s else None + if s and not norm: + return jsonify({"ok": False, "msg": "开仓类型须为下拉选项之一或留空"}), 400 + reviewed_entry_reason_update = norm + + conn = get_db() + row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "记录不存在"}), 404 + risk_amount = row["risk_amount"] + actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) + base_params = [ + reviewed_opened_at, + reviewed_closed_at, + reviewed_stop_loss, + reviewed_take_profit, + round(reviewed_pnl_amount, 4), + reviewed_result or None, + reviewed_miss_reason or None, + hold_seconds, + hold_minutes, + app_now_str(), + actual_rr, + ] + if reviewed_entry_reason_update is not _MISSING_ER: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? + WHERE id=?""", + tuple(base_params + [reviewed_entry_reason_update, rec_id]), + ) + else: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr) + WHERE id=?""", + tuple(base_params + [rec_id]), + ) + if reviewed_result == "手动平仓" and reviewed_miss_reason: + from lib.trade.account_risk_lib import apply_manual_close_journal_cooloff + + apply_manual_close_journal_cooloff( + conn, + early_exit_note=reviewed_miss_reason, + trading_day=get_trading_day(), + now=app_now(), + ) + conn.commit() + conn.close() + return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) + + +@app.route("/manual_transfer", methods=["POST"]) +@login_required +def manual_transfer(): + try: + amount = float(request.form.get("amount", "0")) + except Exception: + flash("划转金额格式错误") + return redirect("/settings?settings_tab=transfer") + from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() + to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() + ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) + conn = get_db() + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + invalidate_account_balance_cache() + try: + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + notify_instance_balance_changed() + except Exception: + pass + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + else: + flash(f"手动划转失败:{msg}") + return redirect("/settings?settings_tab=transfer") + + + + +try: + from lib.instance.instance_settings_register import register_instance_settings_routes + + register_instance_settings_routes( + app, + get_db=get_db, + login_required_fn=login_required, + base_dir=BASE_DIR, + exchange_key="okx", + username=USERNAME, + password=PASSWORD, + ) +except Exception as _settings_err: + print(f"[instance_settings] okx: {_settings_err}") + + + +from lib.sim.register import install_sim_trading + +install_sim_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +from lib.options.options_register import install_options_trading + +install_options_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +from lib.options.options_review_register import install_options_review + +install_options_review(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +from lib.hedge_plan.hedge_plan_register import install_hedge_plan + +install_hedge_plan(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +_purge_key_monitors_if_full_margin() + + +# 启动 +if __name__ == "__main__": + from lib.common.flask_access_log_lib import silence_werkzeug_access_log + + silence_werkzeug_access_log() + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG, threaded=True) diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..88506dc --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,101 @@ +# crypto_okx 环境一键部署(Ubuntu / root /opt) + +在 **`/opt/crypto_okx`** 下以 **root** 部署独立 OKX 期权/对冲服务,使用 **PM2** 常驻. + +仓库: [https://git.bz121.com/dekun/crypto_okx.git](https://git.bz121.com/dekun/crypto_okx.git) + +完整步骤见根目录 **[部署文档.md](../部署文档.md)**. + +--- + +## 一键部署管理器(推荐) + +新服务器**无需先 clone**,一条命令进入菜单: + +```bash +curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash +``` + +已安装机器: + +```bash +bash /opt/crypto_okx/deploy/manage.sh +``` + +### 菜单 + +| 选项 | 功能 | +|------|------| +| **1) 一键部署** | 环境识别 + 系统依赖 + Node/PM2 + venv + 密钥 + 启动 `crypto_okx` | +| **2) 一键卸载** | 备份 `.env` → 停 PM2 → 移走目录 | +| **3) 更新** | 快速更新 / 依赖更新 | +| **4) 仅检测** | 识别 OS / Python / Node / PM2 / 项目状态(不安装) | +| **0) 退出** | | + +### 部署完成后 + +- 访问: `http://<服务器IP>:5004` (端口以 `.env` 的 `APP_PORT` 为准) +- 默认入口: `/options` +- 登录账号: **admin**(密码见 `.env` 的 `APP_PASSWORD`,首次脚本可写成 `admin123`) + +--- + +## 脚本结构 + +``` +deploy/ +├── manage.sh # 入口(自举 + 菜单) +├── lib/ +│ ├── common.sh # 公共函数 / 环境识别 / 验收 +│ ├── install.sh # 一键部署 +│ ├── uninstall.sh # 一键卸载 +│ ├── update.sh # 更新子菜单 +│ └── doctor.sh # 仅检测 +├── setup_env.sh # venv + pip(被 install 调用) +└── pull_and_restart.sh # 快速更新 +``` + +--- + +## 前置条件 + +- **Ubuntu 22.04 / 24.04**,用户 **root** +- 能 `git clone` [crypto_okx](https://git.bz121.com/dekun/crypto_okx.git) 到 `/opt/crypto_okx` + +--- + +## 分步安装(仍可用) + +```bash +cd /opt +git clone https://git.bz121.com/dekun/crypto_okx.git crypto_okx +cd /opt/crypto_okx +bash deploy/setup_env.sh --install-system-deps +pm2 start ecosystem.config.cjs +pm2 save && pm2 startup +``` + +`setup_env.sh` 常用参数: + +```bash +bash deploy/setup_env.sh --recreate-venv # 重建虚拟环境 +bash deploy/setup_env.sh --skip-pm2 # 不尝试安装 pm2 +bash deploy/setup_env.sh --skip-env-copy # 不复制 .env.example +``` + +若在 Windows 编辑过脚本后报 `pipefail` 错误,先转 LF: + +```bash +sed -i 's/\r$//' deploy/manage.sh deploy/lib/*.sh deploy/*.sh +``` + +--- + +## 环境变量(可选) + +```bash +INSTALL_ROOT=/opt/crypto_okx +GIT_URL=https://git.bz121.com/dekun/crypto_okx.git +GIT_BRANCH=main +BACKUP_ROOT=/root/backups +``` diff --git a/deploy/lib/common.sh b/deploy/lib/common.sh new file mode 100644 index 0000000..908bd71 --- /dev/null +++ b/deploy/lib/common.sh @@ -0,0 +1,451 @@ +#!/usr/bin/env bash +# deploy/lib/common.sh — crypto_okx 部署公共函数 +set -e +set -u +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +INSTALL_ROOT="${INSTALL_ROOT:-/opt/crypto_okx}" +GIT_URL="${GIT_URL:-https://git.bz121.com/dekun/crypto_okx.git}" +GIT_BRANCH="${GIT_BRANCH:-main}" +BACKUP_ROOT="${BACKUP_ROOT:-/root/backups}" +TZ_NAME="${CM_TZ:-Asia/Shanghai}" +NODE_MAJOR="${NODE_MAJOR:-20}" +APP_PORT="${APP_PORT:-5004}" +PM2_APP_NAME="${PM2_APP_NAME:-crypto_okx}" + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEPLOY_DIR="$(cd "${LIB_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)" + +PM2_APPS=( + crypto_okx +) + +# 历史进程名(仅 stop/delete 时兼容) +PM2_APPS_LEGACY=( + crypto-monitor-okx + crypto-monitor +) + +CONFIG_PATHS=( + .env +) + +log() { printf '[%s] %s\n' "$(TZ="${TZ_NAME}" date '+%Y-%m-%d %H:%M:%S')" "$*"; } +step() { echo ""; log "==> $*"; } + +die() { + echo "错误: $*" >&2 + exit 1 +} + +require_root() { + if [[ "$(id -u)" -ne 0 ]]; then + die "请使用 root 执行(推荐: sudo -i 后运行)" + fi +} + +require_ubuntu() { + if [[ ! -f /etc/os-release ]]; then + log "警告: 未检测到 /etc/os-release,跳过 Ubuntu 版本检查" + return 0 + fi + # shellcheck source=/dev/null + source /etc/os-release + if [[ "${ID:-}" != "ubuntu" ]]; then + log "警告: 当前系统为 ${ID:-unknown},官方仅测试 Ubuntu 22.04/24.04" + return 0 + fi + local ver="${VERSION_ID:-}" + if [[ "${ver}" != "22.04" && "${ver}" != "24.04" ]]; then + log "警告: Ubuntu ${ver} 未在文档中明确测试,继续执行" + fi +} + +detect_server_ip() { + local ip="" + if command -v hostname >/dev/null 2>&1; then + ip="$(hostname -I 2>/dev/null | awk '{print $1}')" + fi + if [[ -z "${ip}" ]]; then + ip="127.0.0.1" + fi + echo "${ip}" +} + +confirm_yes() { + local msg="$1" + local ans="" + cm_read ans "${msg} [y/N] " + [[ "${ans}" == [yY] || "${ans}" == [yY][eE][sS] ]] +} + +confirm_uninstall() { + local ans="" + echo "此操作将停止 crypto_okx PM2 进程并移走安装目录." + cm_read ans "输入 UNINSTALL 确认卸载: " + [[ "${ans}" == "UNINSTALL" ]] +} + +# curl | bash 时 stdin 是管道,须从 /dev/tty 读取用户输入 +cm_read() { + local __var="$1" + local __prompt="$2" + local __val="" + if [[ -r /dev/tty ]]; then + IFS= read -r -p "${__prompt}" __val /dev/null 2>&1; then + die "未检测到 apt-get,请手动安装 python3-venv git curl" + fi + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y python3 python3-pip python3-venv curl git ca-certificates + local pyver="" + if command -v python3 >/dev/null 2>&1; then + pyver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + apt-get install -y "python${pyver}-venv" 2>/dev/null || apt-get install -y python3-venv + fi +} + +install_node_pm2() { + step "检查 Node.js 与 PM2" + if command -v pm2 >/dev/null 2>&1; then + log "PM2 已安装: $(pm2 -v)" + return 0 + fi + if ! command -v node >/dev/null 2>&1; then + log "安装 Node.js ${NODE_MAJOR}.x ..." + curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - + apt-get install -y nodejs + fi + log "安装 PM2 ..." + npm install -g pm2 + log "PM2: $(pm2 -v)" +} + +pip_progress_bar_arg() { + echo "on" +} + +pip_upgrade_tools() { + local pip_bin="$1" + local bar + bar="$(pip_progress_bar_arg)" + echo " 升级 pip ..." + "${pip_bin}" install -U pip setuptools wheel \ + --disable-pip-version-check \ + --progress-bar "${bar}" +} + +pip_install_requirements() { + local pip_bin="$1" + local req_file="$2" + local label="${3:-依赖}" + local bar + bar="$(pip_progress_bar_arg)" + echo " 安装${label} (下方为 pip 进度) ..." + [[ -f "${req_file}" ]] || die "缺少依赖文件: ${req_file}" + "${pip_bin}" install -r "${req_file}" \ + --disable-pip-version-check \ + --retries 5 \ + --progress-bar "${bar}" + echo " ${label}安装完成" +} + +install_backup_cron() { + step "安装每日备份 cron" + local inst="${REPO_ROOT}/scripts/install_backup_cron.sh" + if [[ -f "${inst}" ]]; then + chmod +x "${inst}" + bash "${inst}" || log "警告: cron 安装失败" + else + log "未找到 scripts/install_backup_cron.sh,跳过" + fi +} + +remove_backup_cron() { + step "移除备份 cron" + local tmp removed=0 + tmp="$(mktemp)" + if ! crontab -l 2>/dev/null >"${tmp}"; then + rm -f "${tmp}" + return 0 + fi + local filtered + filtered="$(grep -vF "backup_data.sh" "${tmp}" || true)" + if [[ "${filtered}" != "$(cat "${tmp}")" ]]; then + printf '%s\n' "${filtered}" | awk ' + BEGIN { tz = 0 } + /^CRON_TZ=Asia\/Shanghai$/ { + if (tz++) next + } + { print } + ' | crontab - + removed=1 + fi + rm -f "${tmp}" + if [[ "${removed}" -eq 1 ]]; then + log "已移除 backup_data.sh 相关 cron" + fi +} + +pm2_app_exists() { + local name="$1" + pm2 pid "${name}" >/dev/null 2>&1 +} + +pm2_stop_project_apps() { + if ! command -v pm2 >/dev/null 2>&1; then + log "未安装 pm2,跳过" + return 0 + fi + local name stopped=0 + for name in "${PM2_APPS[@]}" "${PM2_APPS_LEGACY[@]}"; do + if pm2_app_exists "${name}"; then + log "pm2 stop ${name}" + pm2 stop "${name}" 2>/dev/null || true + stopped=$((stopped + 1)) + fi + done + if [[ "${stopped}" -eq 0 ]]; then + log "未发现本项目 PM2 进程(其它 PM2 不受影响)" + fi +} + +pm2_delete_project_apps() { + if ! command -v pm2 >/dev/null 2>&1; then + return 0 + fi + local name deleted=0 + for name in "${PM2_APPS[@]}" "${PM2_APPS_LEGACY[@]}"; do + if pm2_app_exists "${name}"; then + log "pm2 delete ${name}" + pm2 delete "${name}" 2>/dev/null || true + deleted=$((deleted + 1)) + fi + done + if [[ "${deleted}" -gt 0 ]]; then + pm2 save 2>/dev/null || true + fi +} + +pm2_start_app() { + step "启动 PM2: ${PM2_APP_NAME}" + cd "${REPO_ROOT}" + [[ -f "${REPO_ROOT}/ecosystem.config.cjs" ]] || die "缺少 ecosystem.config.cjs" + [[ -x "${REPO_ROOT}/.venv/bin/python" ]] || die "缺少 .venv,请先运行 setup_env.sh" + if pm2_app_exists "${PM2_APP_NAME}"; then + log "已存在,执行 restart --update-env" + pm2 restart "${PM2_APP_NAME}" --update-env + else + pm2 start ecosystem.config.cjs + fi +} + +pm2_save_startup() { + step "PM2 save & startup" + pm2 save 2>/dev/null || true + if pm2 startup systemd -u root --hp /root 2>/dev/null | grep -q "sudo"; then + pm2 startup systemd -u root --hp /root 2>/dev/null | grep "^sudo" | bash || true + else + pm2 startup 2>/dev/null || true + fi +} + +is_deployed() { + local root="$1" + [[ -x "${root}/.venv/bin/python" ]] +} + +read_app_port() { + local env_file="${REPO_ROOT}/.env" + local port="${APP_PORT}" + if [[ -f "${env_file}" ]]; then + local line + line="$(grep -E '^[[:space:]]*APP_PORT=' "${env_file}" | tail -n1 || true)" + if [[ -n "${line}" ]]; then + port="${line#*=}" + port="$(echo "${port}" | tr -d '"' | tr -d "'" | xargs)" + fi + fi + echo "${port:-5004}" +} + +check_http() { + local url="$1" + local code + code="$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 5 "${url}" 2>/dev/null || echo "000")" + [[ "${code}" == "200" || "${code}" == "302" || "${code}" == "301" ]] +} + +verify_deployment() { + local ip="${1:-$(detect_server_ip)}" + local port + local ok=1 + port="$(read_app_port)" + + step "部署验收" + if command -v pm2 >/dev/null 2>&1 && pm2_app_exists "${PM2_APP_NAME}"; then + echo " [✓] PM2 ${PM2_APP_NAME} 已注册" + else + echo " [✗] PM2 缺少进程 ${PM2_APP_NAME}" + ok=0 + pm2 list 2>/dev/null || true + fi + + if check_http "http://127.0.0.1:${port}/"; then + echo " [✓] 页面可访问 (http://127.0.0.1:${port}/)" + else + echo " [✗] 页面不可访问 (http://127.0.0.1:${port}/)" + ok=0 + fi + + if [[ "${ok}" -eq 1 ]]; then + echo "" + log "验收通过: 进程在线 + 页面可打开" + return 0 + fi + echo "" + log "验收未完全通过,可执行: pm2 logs ${PM2_APP_NAME} --lines 30" + return 1 +} + +print_post_install_guide() { + local ip="${1:-$(detect_server_ip)}" + local port + port="$(read_app_port)" + cat </dev/null || echo '?')" + echo " IP: $(detect_server_ip)" + echo " 安装根: ${INSTALL_ROOT}" + echo " 仓库: ${GIT_URL} (${GIT_BRANCH})" + + step "依赖检测" + local item cmd + for item in "python3:python3" "git:git" "curl:curl" "node:node" "npm:npm" "pm2:pm2"; do + cmd="${item#*:}" + if command -v "${cmd}" >/dev/null 2>&1; then + case "${cmd}" in + python3) echo " [✓] python3: $(python3 --version 2>&1)" ;; + node) echo " [✓] node: $(node -v 2>&1)" ;; + npm) echo " [✓] npm: $(npm -v 2>&1)" ;; + pm2) echo " [✓] pm2: $(pm2 -v 2>&1)" ;; + *) echo " [✓] ${cmd}: $(command -v "${cmd}")" ;; + esac + else + echo " [✗] ${cmd}: 未安装" + fi + done + + if command -v python3 >/dev/null 2>&1; then + local ver major minor + ver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + major="${ver%%.*}" + minor="${ver#*.}" + if [[ "${major}" -gt 3 ]] || [[ "${major}" -eq 3 && "${minor}" -ge 10 ]]; then + echo " [✓] Python 版本满足 3.10+ (${ver})" + else + echo " [✗] Python 需要 3.10+,当前 ${ver}" + fi + fi + + step "项目状态" + local root="" + if root="$(resolve_repo_root 2>/dev/null)"; then + echo " [✓] 仓库就绪: ${root}" + [[ -f "${root}/requirements.txt" ]] && echo " [✓] requirements.txt" || echo " [✗] requirements.txt" + [[ -f "${root}/ecosystem.config.cjs" ]] && echo " [✓] ecosystem.config.cjs" || echo " [✗] ecosystem.config.cjs" + [[ -f "${root}/.env" ]] && echo " [✓] .env 已存在" || echo " [!] .env 尚未生成" + if [[ -x "${root}/.venv/bin/python" ]]; then + echo " [✓] .venv: $("${root}/.venv/bin/python" --version 2>&1)" + else + echo " [!] .venv 未创建" + fi + else + echo " [!] 尚未安装到 ${INSTALL_ROOT}" + fi + + if command -v pm2 >/dev/null 2>&1; then + step "PM2" + pm2 list 2>/dev/null | head -n 40 || true + fi +} diff --git a/deploy/lib/doctor.sh b/deploy/lib/doctor.sh new file mode 100644 index 0000000..139b3b0 --- /dev/null +++ b/deploy/lib/doctor.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# deploy/lib/doctor.sh — 仅检测环境/依赖,不安装 +set -e +set -u +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "${LIB_DIR}/common.sh" + +main_doctor() { + if root="$(resolve_repo_root 2>/dev/null)"; then + REPO_ROOT="${root}" + fi + doctor_report + echo "" + log "检测完成(未修改系统).需要安装请返回菜单选 1." +} + +main_doctor "$@" diff --git a/deploy/lib/install.sh b/deploy/lib/install.sh new file mode 100644 index 0000000..dcce797 --- /dev/null +++ b/deploy/lib/install.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# deploy/lib/install.sh — crypto_okx 一键部署 +set -e +set -u +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "${LIB_DIR}/common.sh" + +install_fresh() { + step "克隆仓库" + if [[ -d "${INSTALL_ROOT}" ]]; then + die "目录已存在: ${INSTALL_ROOT},请选修复环境" + fi + mkdir -p "$(dirname "${INSTALL_ROOT}")" + git clone -b "${GIT_BRANCH}" "${GIT_URL}" "${INSTALL_ROOT}" +} + +install_repair() { + step "修复环境(保留数据与配置)" + bash "${REPO_ROOT}/deploy/setup_env.sh" --install-system-deps --recreate-venv +} + +run_install_pipeline() { + step "环境部署 setup_env.sh" + bash "${REPO_ROOT}/deploy/setup_env.sh" --install-system-deps + + pm2_start_app + pm2_save_startup + install_backup_cron + + verify_deployment "$(detect_server_ip)" || true + print_post_install_guide "$(detect_server_ip)" +} + +handle_existing_install() { + echo "" + echo "检测到已部署安装: ${INSTALL_ROOT}" + echo " a) 取消" + echo " b) 修复环境(重建 venv,保留 .env 与数据库)" + echo " c) 重新启动 PM2(不改依赖)" + local choice="" + cm_read choice "请选择 [a/b/c]: " + case "${choice}" in + b|B) + install_repair + if command -v pm2 >/dev/null 2>&1; then + pm2_start_app + pm2_save_startup + fi + verify_deployment "$(detect_server_ip)" || true + print_post_install_guide "$(detect_server_ip)" + ;; + c|C) + pm2_start_app + pm2_save_startup + verify_deployment "$(detect_server_ip)" || true + print_post_install_guide "$(detect_server_ip)" + ;; + *) + log "已取消" + ;; + esac +} + +ensure_repo_ready() { + if repo_ready "${INSTALL_ROOT}"; then + REPO_ROOT="${INSTALL_ROOT}" + elif [[ -n "${REPO_ROOT:-}" ]] && repo_ready "${REPO_ROOT}"; then + : + else + REPO_ROOT="" + fi + + if [[ -z "${REPO_ROOT}" ]]; then + install_system_packages + install_node_pm2 + install_fresh + REPO_ROOT="${INSTALL_ROOT}" + else + install_system_packages + install_node_pm2 + fi +} + +main_install() { + require_root + require_ubuntu + + if repo_ready "${INSTALL_ROOT}"; then + REPO_ROOT="${INSTALL_ROOT}" + elif repo_ready "${REPO_ROOT}"; then + : + else + REPO_ROOT="" + fi + + if [[ -n "${REPO_ROOT}" ]] && is_deployed "${REPO_ROOT}"; then + handle_existing_install + return 0 + fi + + ensure_repo_ready + run_install_pipeline +} + +main_install "$@" diff --git a/deploy/lib/uninstall.sh b/deploy/lib/uninstall.sh new file mode 100644 index 0000000..330ac02 --- /dev/null +++ b/deploy/lib/uninstall.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# deploy/lib/uninstall.sh — crypto_okx 一键卸载 +set -e +set -u +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "${LIB_DIR}/common.sh" + +main_uninstall() { + require_root + + local root="" + if ! root="$(resolve_repo_root)"; then + if [[ -d "${INSTALL_ROOT}" ]]; then + root="${INSTALL_ROOT}" + else + die "未找到安装目录 ${INSTALL_ROOT}" + fi + fi + REPO_ROOT="${root}" + + if ! confirm_uninstall; then + log "已取消卸载" + return 0 + fi + + local stamp backup_dir removed_dir + stamp="$(TZ="${TZ_NAME}" date +%Y%m%d-%H%M%S)" + backup_dir="${BACKUP_ROOT}/crypto_okx-pre-uninstall-${stamp}" + removed_dir="${INSTALL_ROOT}.removed.${stamp}" + + step "备份配置到 ${backup_dir}" + backup_configs_to "${REPO_ROOT}" "${backup_dir}" + { + echo "created_at=${stamp}" + echo "install_root=${INSTALL_ROOT}" + echo "removed_dir=${removed_dir}" + } >"${backup_dir}/uninstall.manifest" + + step "停止并移除本项目 PM2 进程(不影响其它 PM2)" + pm2_stop_project_apps + pm2_delete_project_apps + + remove_backup_cron + + step "移走安装目录" + if [[ -d "${INSTALL_ROOT}" ]]; then + mv "${INSTALL_ROOT}" "${removed_dir}" + log "已移动: ${INSTALL_ROOT} -> ${removed_dir}" + else + log "安装目录不存在,跳过" + fi + + echo "" + echo "卸载完成." + echo " 配置备份: ${backup_dir}" + echo " 旧目录: ${removed_dir} (确认无误后可手动删除)" + echo "" + echo "回滚示例:" + echo " mv ${removed_dir} ${INSTALL_ROOT}" + echo " bash ${INSTALL_ROOT}/deploy/manage.sh # 选 1 修复环境" +} + +main_uninstall "$@" diff --git a/deploy/lib/update.sh b/deploy/lib/update.sh new file mode 100644 index 0000000..de36deb --- /dev/null +++ b/deploy/lib/update.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# deploy/lib/update.sh — crypto_okx 更新 +set -e +set -u +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "${LIB_DIR}/common.sh" + +require_installed() { + if ! repo_ready "${REPO_ROOT}"; then + die "未找到安装,请先执行「1) 一键部署」" + fi +} + +update_quick() { + step "快速更新" + bash "${REPO_ROOT}/deploy/pull_and_restart.sh" + verify_deployment "$(detect_server_ip)" || true +} + +update_deps() { + step "依赖更新" + if [[ -x "${REPO_ROOT}/.venv/bin/pip" ]]; then + pip_install_requirements "${REPO_ROOT}/.venv/bin/pip" "${REPO_ROOT}/requirements.txt" "crypto_okx 依赖" + else + bash "${REPO_ROOT}/deploy/setup_env.sh" --install-system-deps + fi + update_quick +} + +show_update_menu() { + while true; do + echo "" + echo " 更新选项:" + echo " 3-1) 快速更新(git pull + pm2 restart)" + echo " 3-2) 依赖更新(含 pip install)" + echo " 0) 返回主菜单" + local choice="" + cm_read choice "请选择 [0/3-1/3-2]: " + case "${choice}" in + 3-1|31|1) update_quick; break ;; + 3-2|32|2) update_deps; break ;; + 0) break ;; + *) echo "无效选项" ;; + esac + done +} + +main_update() { + require_root + if ! REPO_ROOT="$(resolve_repo_root)"; then + die "未找到安装目录 ${INSTALL_ROOT},请先执行「1) 一键部署」" + fi + require_installed + show_update_menu +} + +main_update "$@" diff --git a/deploy/manage.sh b/deploy/manage.sh new file mode 100644 index 0000000..44311c7 --- /dev/null +++ b/deploy/manage.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# crypto_okx 部署管理器 — 一键部署 / 卸载 / 更新 +# +# 新服务器(免克隆): +# curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash +# +# 已安装: +# bash /opt/crypto_okx/deploy/manage.sh +# +set -e +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +INSTALL_ROOT="${INSTALL_ROOT:-/opt/crypto_okx}" +GIT_URL="${GIT_URL:-https://git.bz121.com/dekun/crypto_okx.git}" +GIT_BRANCH="${GIT_BRANCH:-main}" + +_script_src="${BASH_SOURCE[0]:-}" +if [[ -n "${_script_src}" && -f "${_script_src}" ]]; then + DEPLOY_DIR="$(cd "$(dirname "${_script_src}")" && pwd)" + REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)" + LIB_DIR="${DEPLOY_DIR}/lib" +else + DEPLOY_DIR="" + REPO_ROOT="" + LIB_DIR="" +fi +unset _script_src + +set -u + +repo_ready() { + [[ -f "${1}/deploy/setup_env.sh" && -f "${1}/deploy/manage.sh" && -f "${1}/app.py" ]] +} + +sync_repo_if_present() { + local root="$1" + if [[ -d "${root}/.git" ]] && command -v git >/dev/null 2>&1; then + git -C "${root}" pull -q --ff-only 2>/dev/null || true + fi +} + +bootstrap_repo() { + if repo_ready "${INSTALL_ROOT}"; then + REPO_ROOT="${INSTALL_ROOT}" + DEPLOY_DIR="${REPO_ROOT}/deploy" + LIB_DIR="${DEPLOY_DIR}/lib" + sync_repo_if_present "${REPO_ROOT}" + return 0 + fi + if [[ -n "${REPO_ROOT}" ]] && repo_ready "${REPO_ROOT}"; then + DEPLOY_DIR="${REPO_ROOT}/deploy" + LIB_DIR="${DEPLOY_DIR}/lib" + return 0 + fi + + echo "crypto_okx 部署管理器 — 首次自举" + echo "将克隆到: ${INSTALL_ROOT}" + if [[ "$(id -u)" -ne 0 ]]; then + echo "错误: 请使用 root 执行" >&2 + exit 1 + fi + if ! command -v git >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y git ca-certificates curl + else + echo "错误: 未找到 git" >&2 + exit 1 + fi + fi + if [[ -d "${INSTALL_ROOT}" ]]; then + echo "错误: ${INSTALL_ROOT} 已存在但不是有效仓库" >&2 + echo "请手动处理后再运行,或设置 INSTALL_ROOT 指向其它路径" >&2 + exit 1 + fi + mkdir -p "$(dirname "${INSTALL_ROOT}")" + git clone -b "${GIT_BRANCH}" "${GIT_URL}" "${INSTALL_ROOT}" + exec bash "${INSTALL_ROOT}/deploy/manage.sh" "$@" >> git pull" +git pull + +if [[ "${DRY}" -eq 1 ]]; then + echo "(dry-run, skip pm2 restart)" + exit 0 +fi + +echo ">>> pm2 restart ${PM2_APP_NAME} --update-env" +if command -v pm2 >/dev/null 2>&1; then + if pm2 pid "${PM2_APP_NAME}" >/dev/null 2>&1; then + pm2 restart "${PM2_APP_NAME}" --update-env + elif [[ -f ecosystem.config.cjs ]]; then + pm2 start ecosystem.config.cjs + else + echo "warn: 未找到 PM2 进程 ${PM2_APP_NAME}" >&2 + fi +else + echo "warn: 未安装 pm2" >&2 +fi + +echo "done" diff --git a/deploy/setup_env.sh b/deploy/setup_env.sh new file mode 100644 index 0000000..fc8eeeb --- /dev/null +++ b/deploy/setup_env.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# crypto_okx 一键环境部署(Ubuntu / root /opt/crypto_okx) +# +# 用法: +# bash deploy/setup_env.sh +# bash deploy/setup_env.sh --skip-pm2 +# bash deploy/setup_env.sh --recreate-venv +# bash deploy/setup_env.sh --install-system-deps +# +set -e +set -u +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)" +REQ_FILE="${REPO_ROOT}/requirements.txt" +# shellcheck source=lib/common.sh +source "${DEPLOY_DIR}/lib/common.sh" + +SKIP_PM2=0 +SKIP_ENV_COPY=0 +RECREATE_VENV=0 +INSTALL_APT_DEPS=0 +PY="" + +usage() { + sed -n '2,10p' "$0" | sed 's/^# \?//' + exit "${1:-0}" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-pm2) SKIP_PM2=1; shift ;; + --skip-env-copy) SKIP_ENV_COPY=1; shift ;; + --recreate-venv) RECREATE_VENV=1; shift ;; + --install-system-deps) INSTALL_APT_DEPS=1; shift ;; + -h|--help) usage 0 ;; + *) echo "未知参数: $1" >&2; usage 1 ;; + esac +done + +find_python() { + if command -v python3 >/dev/null 2>&1; then + echo python3 + return + fi + if command -v python >/dev/null 2>&1; then + echo python + return + fi + echo "未找到 python3/python,请先安装 Python 3.10+" >&2 + exit 1 +} + +check_python_version() { + local py="$1" + local ver + ver="$("${py}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + local major minor + major="${ver%%.*}" + minor="${ver#*.}" + if [[ "${major}" -lt 3 ]] || [[ "${major}" -eq 3 && "${minor}" -lt 10 ]]; then + echo "需要 Python 3.10+,当前: ${ver}" >&2 + exit 1 + fi + echo "Python: $("${py}" --version 2>&1)" +} + +python_minor_version() { + local py="$1" + "${py}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' +} + +check_venv_available() { + local py="$1" + local tmp + tmp="$(mktemp -d 2>/dev/null || mktemp -d -t cmvenv)" + if "${py}" -m venv "${tmp}" >/dev/null 2>&1 && [[ -x "${tmp}/bin/python" ]]; then + rm -rf "${tmp}" + return 0 + fi + rm -rf "${tmp}" 2>/dev/null || true + return 1 +} + +install_debian_venv_packages() { + local py="$1" + local ver + ver="$(python_minor_version "${py}")" + if ! command -v apt-get >/dev/null 2>&1; then + echo " 未检测到 apt-get,请手动安装 python${ver}-venv 与 python3-pip" >&2 + return 1 + fi + if [[ "$(id -u)" -ne 0 ]]; then + echo " 需要 root 安装系统包,请执行:" >&2 + echo " sudo apt update && sudo apt install -y python${ver}-venv python3-pip curl" >&2 + echo " 或: sudo bash deploy/setup_env.sh --install-system-deps" >&2 + return 1 + fi + step "安装系统依赖 (python${ver}-venv) ..." + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + if ! apt-get install -y "python${ver}-venv" python3-pip curl ca-certificates git; then + apt-get install -y python3-venv python3-pip curl ca-certificates git + fi +} + +ensure_venv_prereqs() { + local py="$1" + if check_venv_available "${py}"; then + return 0 + fi + echo " 当前 Python 无法创建 venv(缺少 ensurepip,常见于未安装 python*-venv)" >&2 + if [[ "${INSTALL_APT_DEPS}" -eq 1 ]] || [[ "$(id -u)" -eq 0 ]]; then + install_debian_venv_packages "${py}" || exit 1 + if check_venv_available "${py}"; then + return 0 + fi + fi + local ver + ver="$(python_minor_version "${py}")" + echo "请安装后重试:" >&2 + echo " apt update && apt install -y python${ver}-venv python3-pip" >&2 + echo " bash deploy/setup_env.sh" >&2 + exit 1 +} + +create_project_venv() { + local py="$1" + if [[ "${RECREATE_VENV}" -eq 1 && -d .venv ]]; then + echo " 删除旧 venv ..." + rm -rf .venv + fi + if [[ -d .venv && ! -x .venv/bin/python ]]; then + echo " 清理未完成的 venv ..." + rm -rf .venv + fi + if [[ -x .venv/bin/python ]]; then + return 0 + fi + echo " 创建 venv ..." + if ! "${py}" -m venv .venv; then + rm -rf .venv 2>/dev/null || true + echo " venv 创建失败" >&2 + exit 1 + fi +} + +setup_app() { + step "crypto_okx" + cd "${REPO_ROOT}" + create_project_venv "${PY}" + pip_upgrade_tools ".venv/bin/pip" + pip_install_requirements ".venv/bin/pip" "${REQ_FILE}" "crypto_okx 依赖" + if [[ "${SKIP_ENV_COPY}" -eq 0 ]]; then + if [[ -f .env.example && ! -f .env ]]; then + cp -n .env.example .env 2>/dev/null || cp .env.example .env + echo " 已复制 .env.example -> .env" + elif [[ -f .env ]]; then + echo " 保留已有 .env" + else + echo " 无 .env.example,请手动配置 .env" + fi + fi + mkdir -p static/images/order_charts + echo " 完成: ${REPO_ROOT}/.venv/bin/python" +} + +install_pm2_optional() { + if [[ "${SKIP_PM2}" -eq 1 ]]; then + return + fi + step "PM2(可选)" + if ! command -v node >/dev/null 2>&1; then + echo " 未检测到 Node.js,跳过.安装后执行: npm install -g pm2" + return + fi + if command -v pm2 >/dev/null 2>&1; then + echo " PM2 已安装: $(pm2 -v)" + return + fi + echo " 正在安装 pm2 ..." + npm install -g pm2 +} + +echo "crypto_okx 环境部署" +echo "仓库根目录: ${REPO_ROOT}" +echo "远端仓库: ${GIT_URL}" + +[[ -f "${REQ_FILE}" ]] || { echo "缺少 ${REQ_FILE}" >&2; exit 1; } + +PY="$(find_python)" +check_python_version "${PY}" +ensure_venv_prereqs "${PY}" + +setup_app +install_pm2_optional + +step "部署密钥(首次自动生成,不覆盖已有)" +if command -v python3 >/dev/null 2>&1; then + python3 "${REPO_ROOT}/scripts/bootstrap_deploy_secrets.py" || true +else + echo " 跳过 bootstrap_deploy_secrets(未找到 python3)" +fi + +echo "" +echo "部署完成.下一步:" +echo " 1. 编辑 ${REPO_ROOT}/.env (OKX API / 代理 / 登录密码)" +echo " 2. pm2 start ecosystem.config.cjs" +echo " 或: bash deploy/manage.sh # 选 1" +echo "" diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..6b20a4b --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,33 @@ +/** + * PM2 进程定义(Ubuntu / Linux). + * + * 仅托管 Flask 应用.**SSH SOCKS 隧道**用 `ssh -D` 常驻(可用 tmux / autossh),勿交给 PM2. + * 与 `.env` 里 `OKX_SOCKS_PROXY` 端口一致即可;不必交给 PM2. + * + * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks). + * + * 启动: + * pm2 start ecosystem.config.cjs + * 保存开机列表: + * pm2 save && pm2 startup + */ +const path = require("path"); + +const ROOT = __dirname; // 独立项目根: lib/ 与 app.py 同级 +const PY = path.join(ROOT, ".venv", "bin", "python"); + +module.exports = { + apps: [ + { + name: "crypto_okx", + cwd: ROOT, + script: path.join(ROOT, "app.py"), + interpreter: PY, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "800M", + env: { PYTHONPATH: ROOT }, + }, + ], +}; diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..54e157b --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1 @@ +"""crypto_monitor shared libraries.""" diff --git a/lib/account_ledger/__init__.py b/lib/account_ledger/__init__.py new file mode 100644 index 0000000..226daa3 --- /dev/null +++ b/lib/account_ledger/__init__.py @@ -0,0 +1 @@ +"""实例账户流水(交易所资金/交易账户账单).""" diff --git a/lib/account_ledger/account_ledger_db.py b/lib/account_ledger/account_ledger_db.py new file mode 100644 index 0000000..6d029a5 --- /dev/null +++ b/lib/account_ledger/account_ledger_db.py @@ -0,0 +1,186 @@ +"""账户流水 SQLite 缓存.""" +from __future__ import annotations + +import time +from typing import Any, Optional + +from lib.account_ledger.account_ledger_normalize import PAGE_SIZE, VALID_ACCOUNTS + + +def ensure_account_ledger_tables(conn) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS account_ledger_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT NOT NULL, + ccy TEXT NOT NULL, + amount REAL NOT NULL, + balance_after REAL, + kind TEXT, + raw_type TEXT, + symbol TEXT, + ref_id TEXT NOT NULL, + ts_ms INTEGER NOT NULL, + note TEXT, + synced_at REAL, + UNIQUE(account, ref_id, ccy, ts_ms) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_account_ledger_acc_ts " + "ON account_ledger_entries(account, ts_ms DESC)" + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS account_ledger_meta ( + key TEXT PRIMARY KEY, + value TEXT + ) + """ + ) + conn.commit() + + +def meta_get(conn, key: str, default: str = "") -> str: + row = conn.execute( + "SELECT value FROM account_ledger_meta WHERE key=?", (key,) + ).fetchone() + if not row: + return default + try: + return str(row[0] if not hasattr(row, "keys") else row["value"]) + except Exception: + return default + + +def meta_set(conn, key: str, value: str) -> None: + conn.execute( + "INSERT INTO account_ledger_meta(key, value) VALUES(?, ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, str(value)), + ) + + +def upsert_entries(conn, rows: list[dict[str, Any]]) -> int: + if not rows: + return 0 + now = time.time() + n = 0 + for r in rows: + try: + conn.execute( + """ + INSERT INTO account_ledger_entries( + account, ccy, amount, balance_after, kind, raw_type, + symbol, ref_id, ts_ms, note, synced_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(account, ref_id, ccy, ts_ms) DO UPDATE SET + amount=excluded.amount, + balance_after=excluded.balance_after, + kind=excluded.kind, + raw_type=excluded.raw_type, + symbol=excluded.symbol, + note=excluded.note, + synced_at=excluded.synced_at + """, + ( + r["account"], + r["ccy"], + float(r["amount"]), + r.get("balance_after"), + r.get("kind") or "other", + r.get("raw_type") or "", + r.get("symbol") or "", + r["ref_id"], + int(r["ts_ms"]), + r.get("note") or "", + now, + ), + ) + n += 1 + except Exception: + continue + conn.commit() + return n + + +def query_entries( + conn, + *, + account: str, + start_ms: int, + end_ms: int, + page: int = 1, + page_size: int = PAGE_SIZE, + currencies: Optional[list[str]] = None, +) -> dict[str, Any]: + acc = (account or "").strip().lower() + if acc not in VALID_ACCOUNTS: + return {"items": [], "total": 0, "page": 1, "page_size": page_size, "pages": 0} + page = max(1, int(page or 1)) + page_size = max(1, min(50, int(page_size or PAGE_SIZE))) + start_ms = int(start_ms) + end_ms = int(end_ms) + params: list[Any] = [acc, start_ms, end_ms] + ccy_sql = "" + if currencies: + ccy_list = [c.strip().upper() for c in currencies if c and str(c).strip()] + if ccy_list: + placeholders = ",".join("?" for _ in ccy_list) + ccy_sql = f" AND ccy IN ({placeholders})" + params.extend(ccy_list) + total = conn.execute( + f"SELECT COUNT(*) FROM account_ledger_entries " + f"WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql}", + params, + ).fetchone()[0] + total = int(total or 0) + pages = (total + page_size - 1) // page_size if total else 0 + if pages and page > pages: + page = pages + offset = (page - 1) * page_size + rows = conn.execute( + f""" + SELECT account, ccy, amount, balance_after, kind, raw_type, symbol, + ref_id, ts_ms, note + FROM account_ledger_entries + WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql} + ORDER BY ts_ms DESC, id DESC + LIMIT ? OFFSET ? + """, + params + [page_size, offset], + ).fetchall() + items = [] + for r in rows: + if hasattr(r, "keys"): + d = {k: r[k] for k in r.keys()} + else: + d = { + "account": r[0], + "ccy": r[1], + "amount": r[2], + "balance_after": r[3], + "kind": r[4], + "raw_type": r[5], + "symbol": r[6], + "ref_id": r[7], + "ts_ms": r[8], + "note": r[9], + } + from lib.account_ledger.account_ledger_normalize import kind_label_zh + + d["kind_label"] = kind_label_zh(d.get("kind") or "") + items.append(d) + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "pages": pages, + } + + +def prune_older_than(conn, min_ts_ms: int) -> None: + conn.execute("DELETE FROM account_ledger_entries WHERE ts_ms < ?", (int(min_ts_ms),)) + conn.commit() diff --git a/lib/account_ledger/account_ledger_normalize.py b/lib/account_ledger/account_ledger_normalize.py new file mode 100644 index 0000000..d08372c --- /dev/null +++ b/lib/account_ledger/account_ledger_normalize.py @@ -0,0 +1,192 @@ +"""账户流水:交易所原始记录 → 统一行模型.""" +from __future__ import annotations + +from typing import Any, Optional + + +ACCOUNT_FUNDING = "funding" +ACCOUNT_TRADING = "trading" +VALID_ACCOUNTS = frozenset({ACCOUNT_FUNDING, ACCOUNT_TRADING}) + +PAGE_SIZE = 10 + + +def _safe_float(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _safe_int(v: Any) -> Optional[int]: + if v is None or v == "": + return None + try: + n = float(v) + if n > 1e12: + return int(n) + if n > 1e9: + return int(n) + return int(n) + except (TypeError, ValueError): + return None + + +def _ts_ms(v: Any) -> Optional[int]: + if v is None or v == "": + return None + try: + n = float(v) + except (TypeError, ValueError): + return None + if n > 1e12: + return int(n) + if n > 1e10: + return int(n) + return int(n * 1000.0) + + +def kind_from_raw(raw_type: str, amount: Optional[float] = None) -> str: + t = (raw_type or "").strip().lower() + if not t: + return "other" + if "deposit" in t or t in ("1", "funding_deposit"): + return "deposit" + if "withdraw" in t or "withdrawal" in t: + return "withdraw" + if "transfer" in t or "dnw" in t or t in ("2", "18", "19"): + if amount is not None and amount < 0: + return "transfer_out" + if amount is not None and amount > 0: + return "transfer_in" + return "transfer" + if "funding" in t and "fee" in t: + return "funding_fee" + if t in ("funding_fee", "fundingfee", "8"): + return "funding_fee" + if "commission" in t or "fee" in t or t in ("commission", "5", "fee"): + return "commission" + if "realiz" in t or "pnl" in t or t in ("realized_pnl", "realizedpnl", "3"): + return "realized_pnl" + if "liqui" in t: + return "liquidate" + return "other" + + +def kind_label_zh(kind: str) -> str: + return { + "deposit": "充值", + "withdraw": "提现", + "transfer": "划转", + "transfer_in": "划入", + "transfer_out": "划出", + "realized_pnl": "已实现盈亏", + "funding_fee": "资金费", + "commission": "手续费", + "liquidate": "强平", + "other": "其他", + }.get((kind or "").strip().lower(), "其他") + + +def make_ref_id(*parts: Any) -> str: + bits = [] + for p in parts: + if p is None: + continue + s = str(p).strip() + if s: + bits.append(s) + return "|".join(bits) if bits else "" + + +def normalize_row( + *, + account: str, + ccy: str, + amount: Any, + ts_ms: Any, + ref_id: str, + raw_type: str = "", + balance_after: Any = None, + symbol: str = "", + note: str = "", + kind: str = "", +) -> Optional[dict[str, Any]]: + acc = (account or "").strip().lower() + if acc not in VALID_ACCOUNTS: + return None + ccy_u = (ccy or "").strip().upper() + if not ccy_u: + return None + amt = _safe_float(amount) + if amt is None: + return None + ts = _ts_ms(ts_ms) + if ts is None or ts <= 0: + return None + rid = (ref_id or "").strip() or make_ref_id(acc, ccy_u, ts, amt, raw_type) + k = (kind or "").strip().lower() or kind_from_raw(raw_type, amt) + bal = _safe_float(balance_after) + return { + "account": acc, + "ccy": ccy_u, + "amount": amt, + "balance_after": bal, + "kind": k, + "kind_label": kind_label_zh(k), + "raw_type": (raw_type or "").strip()[:120], + "symbol": (symbol or "").strip()[:80], + "ref_id": rid[:200], + "ts_ms": int(ts), + "note": (note or "").strip()[:240], + } + + +def from_ccxt_ledger_entry(entry: dict[str, Any], *, account: str) -> Optional[dict[str, Any]]: + if not isinstance(entry, dict): + return None + info = entry.get("info") if isinstance(entry.get("info"), dict) else {} + amount = entry.get("amount") + if amount is None: + amount = entry.get("change") + if amount is None: + amount = info.get("balChg") or info.get("change") or info.get("income") or info.get("amount") + ts = entry.get("timestamp") or entry.get("datetime") + if ts is None: + ts = info.get("time") or info.get("uTime") or info.get("ts") or info.get("create_time") or info.get("createDate") + ccy = entry.get("currency") or info.get("ccy") or info.get("asset") or info.get("currency") or "USDT" + raw_type = ( + entry.get("type") + or entry.get("status") + or info.get("type") + or info.get("incomeType") + or info.get("change_type") + or info.get("subType") + or "" + ) + if isinstance(raw_type, (int, float)): + raw_type = str(raw_type) + balance_after = entry.get("balance") or info.get("bal") or info.get("balance") + symbol = entry.get("symbol") or info.get("instId") or info.get("symbol") or info.get("contract") or "" + ref = ( + entry.get("id") + or info.get("billId") + or info.get("tranId") + or info.get("id") + or info.get("trade_id") + or "" + ) + note = entry.get("description") or info.get("info") or info.get("text") or "" + return normalize_row( + account=account, + ccy=str(ccy), + amount=amount, + ts_ms=ts, + ref_id=str(ref) if ref != "" else make_ref_id(account, ccy, ts, amount, raw_type), + raw_type=str(raw_type), + balance_after=balance_after, + symbol=str(symbol or ""), + note=str(note or ""), + ) diff --git a/lib/account_ledger/account_ledger_register.py b/lib/account_ledger/account_ledger_register.py new file mode 100644 index 0000000..997d336 --- /dev/null +++ b/lib/account_ledger/account_ledger_register.py @@ -0,0 +1,208 @@ +"""三所统一:账户流水路由 + 后台同步安装.""" +from __future__ import annotations + +import os +from typing import Any, Callable + +from flask import Flask, Response, jsonify, request, session, stream_with_context +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.account_ledger.account_ledger_db import ensure_account_ledger_tables, query_entries +from lib.account_ledger.account_ledger_normalize import ( + ACCOUNT_FUNDING, + ACCOUNT_TRADING, + PAGE_SIZE, + VALID_ACCOUNTS, +) +from lib.account_ledger.account_ledger_sync import account_ledger_store +from lib.common.history_window_lib import resolve_list_window + + +def attach_account_ledger_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "account_ledger", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def _build_fetch_fn(exchange_key: str, app_module: Any) -> Callable: + ex_key = (exchange_key or "").strip().lower() + exchange = getattr(app_module, "exchange", None) + ensure_markets = getattr(app_module, "ensure_markets_loaded", None) + + def _fetch(*, start_ms: int, end_ms: int): + if exchange is None: + return [], ["exchange missing"] + if ex_key == "okx": + from lib.exchange.okx_ledger_lib import fetch_okx_account_ledger + + return fetch_okx_account_ledger( + exchange, + start_ms=start_ms, + end_ms=end_ms, + ensure_markets=ensure_markets, + ) + if ex_key == "binance": + from lib.exchange.binance_ledger_lib import fetch_binance_account_ledger + + return fetch_binance_account_ledger( + exchange, + start_ms=start_ms, + end_ms=end_ms, + ensure_markets=ensure_markets, + ) + from lib.exchange.gate_ledger_lib import fetch_gate_account_ledger + + return fetch_gate_account_ledger( + exchange, + start_ms=start_ms, + end_ms=end_ms, + ensure_markets=ensure_markets, + ) + + return _fetch + + +def _currencies_for_exchange(exchange_key: str) -> list[str]: + if (exchange_key or "").strip().lower() == "okx": + return ["USDT", "USDC"] + return ["USDT"] + + +def install_account_ledger( + app: Flask, + repo_root: str, + app_module: Any, + *, + exchange_key: str = "", +) -> None: + ex = (exchange_key or "").strip().lower() + if not ex: + mod_name = getattr(app_module, "__name__", "") or "" + if "okx" in mod_name.lower(): + ex = "okx" + elif "binance" in mod_name.lower(): + ex = "binance" + else: + ex = "gate" + exchange_key = ex + + attach_account_ledger_templates(app, repo_root) + get_db = app_module.get_db + login_required = app_module.login_required + + # 初始化表 + try: + conn = get_db() + try: + ensure_account_ledger_tables(conn) + finally: + conn.close() + except Exception: + pass + + account_ledger_store.configure( + get_db=get_db, + fetch_fn=_build_fetch_fn(exchange_key, app_module), + exchange_key=str(exchange_key), + ) + account_ledger_store.start() + app.extensions["account_ledger_exchange"] = str(exchange_key).lower() + + def _list_window(): + resolve = getattr(app_module, "_list_window_from_request", None) + if callable(resolve): + return resolve() + return resolve_list_window(request.args, session) + + @app.route("/api/account_ledger") + @login_required + def api_account_ledger(): + account = (request.args.get("account") or ACCOUNT_FUNDING).strip().lower() + if account not in VALID_ACCOUNTS: + account = ACCOUNT_FUNDING + try: + page = int(request.args.get("page") or 1) + except Exception: + page = 1 + win = _list_window() + start_ms = int(win.get("start_ms") or 0) + end_ms = int(win.get("end_ms") or 0) + ccys = _currencies_for_exchange(app.extensions.get("account_ledger_exchange") or "") + conn = get_db() + try: + ensure_account_ledger_tables(conn) + data = query_entries( + conn, + account=account, + start_ms=start_ms, + end_ms=end_ms, + page=page, + page_size=PAGE_SIZE, + currencies=ccys, + ) + finally: + conn.close() + st = account_ledger_store.status_dict() + return jsonify( + { + "ok": True, + "account": account, + "window": { + "preset": win.get("preset"), + "label": win.get("label"), + "start_ms": start_ms, + "end_ms": end_ms, + }, + "currencies": ccys, + **data, + **st, + } + ) + + @app.route("/api/account_ledger/stream") + @login_required + def api_account_ledger_stream(): + return Response( + stream_with_context(account_ledger_store.iter_sse()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + @app.route("/api/account_ledger/refresh", methods=["POST"]) + @login_required + def api_account_ledger_refresh(): + win = _list_window() + body = request.get_json(silent=True) or {} + start_ms = body.get("start_ms", win.get("start_ms")) + end_ms = body.get("end_ms", win.get("end_ms")) + try: + start_i = int(start_ms) if start_ms is not None else None + end_i = int(end_ms) if end_ms is not None else None + except Exception: + start_i, end_i = None, None + result = account_ledger_store.sync_once( + reason="manual", start_ms=start_i, end_ms=end_i + ) + return jsonify(result) + + @app.route("/account_ledger") + @login_required + def account_ledger_page(): + from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled + + redir = redirect_to_embed_shell_if_enabled("account_ledger") + if redir is not None: + return redir + return app_module.render_main_page("account_ledger") diff --git a/lib/account_ledger/account_ledger_sync.py b/lib/account_ledger/account_ledger_sync.py new file mode 100644 index 0000000..75a1f75 --- /dev/null +++ b/lib/account_ledger/account_ledger_sync.py @@ -0,0 +1,252 @@ +"""账户流水:后台定时拉取交易所 + SSE 版本推送.""" +from __future__ import annotations + +import json +import os +import queue +import threading +import time +from collections.abc import Callable, Iterator +from datetime import datetime, timezone +from typing import Any, Optional + +from lib.account_ledger.account_ledger_db import ( + ensure_account_ledger_tables, + meta_get, + meta_set, + prune_older_than, + upsert_entries, +) + +ACCOUNT_LEDGER_POLL_SEC = float(os.getenv("ACCOUNT_LEDGER_POLL_SEC", "120")) +ACCOUNT_LEDGER_LOOKBACK_DAYS = int(os.getenv("ACCOUNT_LEDGER_LOOKBACK_DAYS", "90")) +ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC = float(os.getenv("ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC", "25")) + + +class AccountLedgerStore: + def __init__(self) -> None: + self._lock = threading.Lock() + self.version = 0 + self._subscribers: list[queue.Queue[str | None]] = [] + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._syncing = False + self._get_db: Optional[Callable] = None + self._fetch_fn: Optional[Callable[..., tuple[list[dict[str, Any]], list[str]]]] = None + self._exchange_key = "" + self.last_sync_at: Optional[float] = None + self.last_error: str = "" + self.last_upserted: int = 0 + self._last_manual_at: float = 0.0 + self._manual_cooldown_sec = float(os.getenv("ACCOUNT_LEDGER_MANUAL_COOLDOWN_SEC", "30")) + + def configure( + self, + *, + get_db: Callable, + fetch_fn: Callable[..., tuple[list[dict[str, Any]], list[str]]], + exchange_key: str, + ) -> None: + self._get_db = get_db + self._fetch_fn = fetch_fn + self._exchange_key = (exchange_key or "").strip().lower() + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + if not self._get_db or not self._fetch_fn: + return + self._stop.clear() + self._thread = threading.Thread( + target=self._loop, daemon=True, name=f"account-ledger-{self._exchange_key or 'x'}" + ) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + self._broadcast(close=True) + + def lookback_bounds_ms(self, start_ms: Optional[int] = None, end_ms: Optional[int] = None) -> tuple[int, int]: + now = datetime.now(timezone.utc) + end = int(end_ms) if end_ms is not None else int(now.timestamp() * 1000) + floor = int(end - ACCOUNT_LEDGER_LOOKBACK_DAYS * 86400 * 1000) + if start_ms is not None: + start = max(int(start_ms), floor) + else: + start = floor + if start > end: + start, end = end, start + return start, end + + def sync_once( + self, + *, + reason: str = "poll", + start_ms: Optional[int] = None, + end_ms: Optional[int] = None, + ) -> dict[str, Any]: + if not self._get_db or not self._fetch_fn: + return {"ok": False, "msg": "未配置"} + with self._lock: + if self._syncing: + return {"ok": True, "busy": True, "ledger_version": self.version} + if reason == "manual": + gap = time.time() - self._last_manual_at + if gap < self._manual_cooldown_sec: + wait = int(self._manual_cooldown_sec - gap) + 1 + return { + "ok": False, + "msg": f"同步过于频繁,请 {wait}s 后再试", + "ledger_version": self.version, + } + self._syncing = True + try: + start, end = self.lookback_bounds_ms(start_ms, end_ms) + rows, errors = self._fetch_fn(start_ms=start, end_ms=end) + conn = self._get_db() + try: + ensure_account_ledger_tables(conn) + n = upsert_entries(conn, rows or []) + # 保留略宽于 lookback 的缓存 + prune_ms = int( + (datetime.now(timezone.utc).timestamp() - (ACCOUNT_LEDGER_LOOKBACK_DAYS + 7) * 86400) + * 1000 + ) + prune_older_than(conn, prune_ms) + self.last_sync_at = time.time() + self.last_upserted = n + self.last_error = "; ".join(errors[:3]) if errors else "" + meta_set(conn, "last_sync_at", str(self.last_sync_at)) + meta_set(conn, "last_error", self.last_error) + meta_set(conn, "last_upserted", str(n)) + conn.commit() + finally: + try: + conn.close() + except Exception: + pass + if reason == "manual": + self._last_manual_at = time.time() + ver = self.bump(reason) + return { + "ok": True, + "ledger_version": ver, + "upserted": n, + "errors": errors, + "start_ms": start, + "end_ms": end, + } + except Exception as e: + self.last_error = str(e) + try: + conn = self._get_db() + try: + ensure_account_ledger_tables(conn) + meta_set(conn, "last_error", self.last_error) + conn.commit() + finally: + conn.close() + except Exception: + pass + return {"ok": False, "msg": str(e), "ledger_version": self.version} + finally: + with self._lock: + self._syncing = False + + def bump(self, reason: str = "poll") -> int: + with self._lock: + self.version += 1 + ver = self.version + payload = json.dumps( + {"ledger_version": ver, "reason": reason, "exchange": self._exchange_key}, + ensure_ascii=False, + ) + self._broadcast(payload) + return ver + + def status_dict(self) -> dict[str, Any]: + last_at = self.last_sync_at + if last_at is None and self._get_db: + try: + conn = self._get_db() + try: + ensure_account_ledger_tables(conn) + raw = meta_get(conn, "last_sync_at", "") + if raw: + last_at = float(raw) + self.last_error = meta_get(conn, "last_error", self.last_error) + finally: + conn.close() + except Exception: + pass + return { + "ledger_version": self.version, + "poll_sec": ACCOUNT_LEDGER_POLL_SEC, + "lookback_days": ACCOUNT_LEDGER_LOOKBACK_DAYS, + "last_sync_at": last_at, + "last_error": self.last_error, + "last_upserted": self.last_upserted, + "exchange": self._exchange_key, + } + + def _loop(self) -> None: + # 启动后稍等再拉,避免和启动高峰撞车 + if self._stop.wait(3): + return + while not self._stop.is_set(): + try: + self.sync_once(reason="poll") + except Exception: + pass + if self._stop.wait(ACCOUNT_LEDGER_POLL_SEC): + break + + def _broadcast(self, event: str | None = None, *, close: bool = False) -> None: + with self._lock: + subs = list(self._subscribers) + dead: list[queue.Queue[str | None]] = [] + for q in subs: + try: + q.put_nowait(None if close else event) + except Exception: + dead.append(q) + if dead: + with self._lock: + for q in dead: + if q in self._subscribers: + self._subscribers.remove(q) + + def _subscribe(self) -> queue.Queue[str | None]: + q: queue.Queue[str | None] = queue.Queue(maxsize=16) + with self._lock: + self._subscribers.append(q) + return q + + def _unsubscribe(self, q: queue.Queue[str | None]) -> None: + with self._lock: + if q in self._subscribers: + self._subscribers.remove(q) + + def iter_sse(self) -> Iterator[str]: + q = self._subscribe() + try: + yield f"event: ledger\ndata: {json.dumps({'ledger_version': self.version, 'reason': 'hello'}, ensure_ascii=False)}\n\n" + last_hb = time.time() + while not self._stop.is_set(): + try: + item = q.get(timeout=1.0) + except queue.Empty: + item = "timeout" + if item is None: + break + if item != "timeout": + yield f"event: ledger\ndata: {item}\n\n" + last_hb = time.time() + elif time.time() - last_hb >= ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC: + yield ": heartbeat\n\n" + last_hb = time.time() + finally: + self._unsubscribe(q) + + +account_ledger_store = AccountLedgerStore() diff --git a/lib/account_ledger/templates/account_ledger_panel.html b/lib/account_ledger/templates/account_ledger_panel.html new file mode 100644 index 0000000..3a5fba0 --- /dev/null +++ b/lib/account_ledger/templates/account_ledger_panel.html @@ -0,0 +1,72 @@ +{# 账户流水:资金/交易 Tab · 交易所账单 · SSE #} + + diff --git a/lib/common/__init__.py b/lib/common/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/common/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/common/auto_transfer_daily_lib.py b/lib/common/auto_transfer_daily_lib.py new file mode 100644 index 0000000..aaab5bf --- /dev/null +++ b/lib/common/auto_transfer_daily_lib.py @@ -0,0 +1,130 @@ +""" +每日自动划转:北京时间指定整点小时内,将交易账户(AUTO_TRANSFER_TO)余额调整至目标额. + +- 交易账户 < 目标:从资金账户划入差额 +- 交易账户 > 目标:将多余划回资金账户 +- 有 active 持仓:不划转,写账簿并企业微信说明 +""" +from __future__ import annotations + +from typing import Any, Callable + + +def run_auto_transfer_once_per_day( + *, + enabled: bool, + bj_hour: int, + target_amount: float, + from_account: str, + to_account: str, + funds_decimals: int, + get_db: Callable[[], Any], + get_active_position_count: Callable[[Any], int], + get_account_usdt_total: Callable[[str], float | None], + execute_transfer_usdt: Callable[[float, str, str], tuple[bool, str, Any]], + send_wechat_msg: Callable[[str], None], + utc_now_dt: Callable[[], Any], + app_tz: Any, + utc_calendar_date_str: Callable[[], str], + app_now_str: Callable[[], str], + min_transfer: float = 0.01, +) -> None: + if not enabled: + return + utc_dt = utc_now_dt() + bj = utc_dt.astimezone(app_tz) + if bj.hour != bj_hour: + return + + transfer_day = utc_calendar_date_str() + conn = get_db() + exists = conn.execute( + "SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?", + ("auto_daily", transfer_day), + ).fetchone() + if exists: + conn.close() + return + + def _log( + amount: float, + fr: str, + to: str, + status: str, + message: str, + *, + commit_close: bool = True, + ) -> None: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, amount, fr, to, status, message[:500]), + ) + conn.commit() + if commit_close: + conn.close() + + active = get_active_position_count(conn) + if active > 0: + msg = f"持仓中({active}笔),本次资金无划转" + _log(0, from_account, to_account, "skipped", msg) + send_wechat_msg( + f"自动划转:{msg}\n" + f"目标:{to_account} 调整至 {round(float(target_amount), funds_decimals)}U\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + return + + target = round(float(target_amount), funds_decimals) + trade_bal = get_account_usdt_total(to_account) + if trade_bal is None: + _log( + 0, + from_account, + to_account, + "failed", + f"读取{to_account}账户USDT失败", + ) + return + + trade = round(float(trade_bal), funds_decimals) + diff = round(target - trade, funds_decimals) + + if abs(diff) < min_transfer: + _log( + 0, + from_account, + to_account, + "skipped", + f"{to_account}账户已为{trade}U(目标{target}U)", + ) + return + + if diff > 0: + fr, to, amount = from_account, to_account, diff + action = "划入" + else: + fr, to, amount = to_account, from_account, round(abs(diff), funds_decimals) + action = "划出" + + from_bal = get_account_usdt_total(fr) + if from_bal is not None and round(float(from_bal), funds_decimals) < amount: + cur = round(float(from_bal), funds_decimals) + _log(amount, fr, to, "failed", f"{fr}账户USDT不足,需{amount}U,当前{cur}U") + send_wechat_msg( + f"自动划转失败:{fr}余额不足,需{amount}U,当前{cur}U({action}至{to_account}目标{target}U)\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + return + + ok, msg, _ = execute_transfer_usdt(amount, fr, to) + _log(amount, fr, to, "success" if ok else "failed", msg) + if ok: + send_wechat_msg( + f"自动划转成功:{to_account} {trade}U→目标{target}U,{action}{amount}U {fr}->{to}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + else: + send_wechat_msg( + f"自动划转失败:计划{action}{amount}U {fr}->{to}(目标{target}U)\n原因:{msg}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) diff --git a/lib/common/flask_access_log_lib.py b/lib/common/flask_access_log_lib.py new file mode 100644 index 0000000..68e7403 --- /dev/null +++ b/lib/common/flask_access_log_lib.py @@ -0,0 +1,16 @@ +"""关闭 Flask/Werkzeug 开发服务器 access log 刷屏(避免灌满 PM2 error 日志).""" +from __future__ import annotations + +import logging + + +def silence_werkzeug_access_log() -> None: + """仅抑制 request access 行;WARNING/ERROR 仍可读.""" + log = logging.getLogger("werkzeug") + log.setLevel(logging.WARNING) + # 部分环境会挂 StreamHandler 到 stderr;抬高阈值即可 + for h in list(log.handlers): + try: + h.setLevel(logging.WARNING) + except Exception: + pass diff --git a/lib/common/form_submit_lib.py b/lib/common/form_submit_lib.py new file mode 100644 index 0000000..687fccf --- /dev/null +++ b/lib/common/form_submit_lib.py @@ -0,0 +1,51 @@ +"""防重复提交:Flask session 短窗口去重(下单 / 关键位等).""" +from __future__ import annotations + +import time +from typing import Any, Optional + + +DEFAULT_SUBMIT_GUARD_TTL = 90.0 + + +def _prune_locks(locks: dict, now: float) -> dict: + return {k: float(v) for k, v in (locks or {}).items() if float(v) > now} + + +def check_duplicate_submit( + session: Any, + scope: str, + *, + ttl: float = DEFAULT_SUBMIT_GUARD_TTL, +) -> Optional[str]: + """ + 同一 scope 在 ttl 秒内仅允许通过一次. + 返回提示文案表示应拒绝;返回 None 表示可继续处理. + """ + scope = (scope or "").strip() + if not scope: + return None + now = time.time() + locks = _prune_locks(session.get("_form_submit_guard") or {}, now) + if scope in locks: + return "请求正在处理或刚提交过,请勿重复点击(请等待页面刷新后再试)" + locks[scope] = now + float(ttl) + session["_form_submit_guard"] = locks + try: + session.modified = True + except Exception: + pass + return None + + +def submit_scope_add_order(symbol: str, direction: str) -> str: + sym = (symbol or "").strip().upper() + d = (direction or "").strip().lower() + return f"add_order:{sym}:{d}" + + +def submit_scope_add_key(symbol: str, monitor_type: str, direction: str) -> str: + sym = (symbol or "").strip().upper() + mt = (monitor_type or "").strip() + d = (direction or "").strip().lower() or "watch" + return f"add_key:{sym}:{mt}:{d}" diff --git a/lib/common/history_window_lib.py b/lib/common/history_window_lib.py new file mode 100644 index 0000000..760f13a --- /dev/null +++ b/lib/common/history_window_lib.py @@ -0,0 +1,187 @@ +"""列表/导出用 UTC 时间窗(Gate / Binance 主站共用).""" + +from datetime import datetime, timedelta, timezone + +PRESET_UTC_TODAY = "utc_today" +PRESET_UTC_LAST24H = "utc_last24h" +PRESET_UTC_LAST7D = "utc_last7d" +PRESET_UTC_THIS_MONTH = "utc_this_month" +PRESET_UTC_LAST3M = "utc_last3m" +PRESET_UTC_LAST6M = "utc_last6m" +PRESET_ALL = "all" +PRESET_CUSTOM = "custom" +PRESET_DEFAULT = PRESET_UTC_THIS_MONTH + + +def utc_now(): + return datetime.now(timezone.utc) + + +def utc_today_bounds(now=None): + now = now or utc_now() + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + return start, now + + +def resolve_window(query_mapping, default_preset=PRESET_DEFAULT): + """ + 从 ?win_preset= & from_utc= & to_utc= 解析窗口. + 返回 dict: preset, start_utc, end_utc, label, start_ms, end_ms + """ + preset = (query_mapping.get("win_preset") or default_preset or PRESET_DEFAULT).strip().lower() + now = utc_now() + + if preset == PRESET_UTC_LAST24H: + start = now - timedelta(hours=24) + end = now + label = "近24小时(UTC)" + elif preset == PRESET_UTC_LAST7D: + start = now - timedelta(days=7) + end = now + label = "近7天(UTC)" + elif preset == PRESET_UTC_THIS_MONTH: + start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + end = now + label = f"本月 {start.strftime('%Y-%m')}" + elif preset == PRESET_UTC_LAST3M: + start = now - timedelta(days=90) + end = now + label = "近3月" + elif preset == PRESET_UTC_LAST6M: + start = now - timedelta(days=180) + end = now + label = "近6月" + elif preset == PRESET_ALL: + start = datetime(2000, 1, 1, tzinfo=timezone.utc) + end = now + label = "全部" + elif preset == PRESET_CUSTOM: + start = _parse_utc_input(query_mapping.get("from_utc")) or utc_today_bounds(now)[0] + end = _parse_utc_input(query_mapping.get("to_utc")) or now + if end < start: + start, end = end, start + label = f"{start.strftime('%Y-%m-%d %H:%M')} ~ {end.strftime('%Y-%m-%d %H:%M')} UTC" + elif preset == PRESET_UTC_TODAY: + start, end = utc_today_bounds(now) + label = f"UTC当日 {start.strftime('%Y-%m-%d')}" + else: + return resolve_window( + {**(query_mapping or {}), "win_preset": default_preset}, + default_preset=default_preset, + ) + + return { + "preset": preset, + "start_utc": start, + "end_utc": end, + "label": label, + "start_ms": int(start.timestamp() * 1000), + "end_ms": int(end.timestamp() * 1000), + } + + +def _parse_utc_input(raw): + s = (raw or "").strip().replace("T", " ").replace("Z", "").strip() + if not s: + return None + for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + dt = datetime.strptime(s[:n], fmt) + return dt.replace(tzinfo=timezone.utc) + except Exception: + continue + return None + + +def utc_window_to_bj_sql_strings(start_utc, end_utc, app_tz): + """DB 存北京时间字符串时,用于 SQLite 字符串范围比较.""" + start_bj = start_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S") + end_bj = end_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S") + return start_bj, end_bj + + +def utc_window_to_utc_sql_strings(start_utc, end_utc): + """SQLite CURRENT_TIMESTAMP 写入 UTC 时,用于 created_at 范围比较.""" + return ( + start_utc.strftime("%Y-%m-%d %H:%M:%S"), + end_utc.strftime("%Y-%m-%d %H:%M:%S"), + ) + + +def normalize_bj_datetime_storage(raw): + """表单 datetime-local(含 T)入库前统一为 YYYY-MM-DD HH:MM:SS(北京时间).""" + s = (raw or "").strip().replace("T", " ").replace("Z", "").strip() + if not s: + return "" + for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:n], fmt).strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + continue + return s + + +def sql_list_time_field(*columns): + """ + SQLite 列表时间窗比较表达式. + journal_entries 的 open/close 可能含 'T',直接与 bounds(空格格式)比会误判为超出上界. + 单列时不用 COALESCE(SQLite 要求 COALESCE 至少 2 个参数). + """ + cols = [c for c in columns if c] + if not cols: + raise ValueError("sql_list_time_field requires at least one column") + if len(cols) == 1: + return f"REPLACE({cols[0]}, 'T', ' ')" + return f"REPLACE(COALESCE({', '.join(cols)}), 'T', ' ')" + + +SESSION_KEY_LIST_WIN = "list_win_filter" + + +def query_mapping_from_session(session_store): + """从 Flask session 恢复 win_preset / from_utc / to_utc.""" + if not session_store: + return {} + block = session_store.get(SESSION_KEY_LIST_WIN) + if not isinstance(block, dict): + return {} + preset = (block.get("preset") or "").strip() + if not preset: + return {} + return { + "win_preset": preset, + "from_utc": (block.get("from_utc") or "").strip(), + "to_utc": (block.get("to_utc") or "").strip(), + } + + +def resolve_list_window(query_mapping, session_store=None, default_preset=PRESET_DEFAULT): + """ + URL 带 win_preset 时解析并写入 session;无参数时用 session 中上次「应用」的预设. + """ + qm = query_mapping or {} + preset_in_q = (qm.get("win_preset") or "").strip() + if preset_in_q: + win = resolve_window(qm, default_preset=default_preset) + if session_store is not None: + session_store[SESSION_KEY_LIST_WIN] = { + "preset": win["preset"], + "from_utc": (qm.get("from_utc") or "").strip(), + "to_utc": (qm.get("to_utc") or "").strip(), + } + return win + stored = query_mapping_from_session(session_store) + if stored.get("win_preset"): + return resolve_window(stored, default_preset=default_preset) + return resolve_window(qm, default_preset=default_preset) + + +def list_window_redirect_query(session_store): + """复盘/表单 POST 后重定向时附带列表筛选 query.""" + from urllib.parse import urlencode + + stored = query_mapping_from_session(session_store) + if not stored.get("win_preset"): + return "" + params = {k: v for k, v in stored.items() if v} + return urlencode(params) diff --git a/lib/common/markdown_html_lib.py b/lib/common/markdown_html_lib.py new file mode 100644 index 0000000..3e17ec9 --- /dev/null +++ b/lib/common/markdown_html_lib.py @@ -0,0 +1,128 @@ +"""Markdown → HTML for system guide / options docs.""" + +from __future__ import annotations + +import re + + +def render_markdown_html(md_text: str) -> str: + try: + import markdown # type: ignore + + return markdown.markdown( + md_text, + extensions=["tables", "fenced_code", "nl2br", "sane_lists"], + ) + except Exception: + return _simple_md_html(md_text) + + +def _simple_md_html(md_text: str) -> str: + from html import escape + + lines = md_text.replace("\r\n", "\n").replace("\r", "\n").splitlines() + out: list[str] = [] + i = 0 + in_code = False + code_buf: list[str] = [] + list_buf: list[str] = [] + list_ordered = False + + def flush_list() -> None: + nonlocal list_buf, list_ordered + if not list_buf: + return + tag = "ol" if list_ordered else "ul" + out.append(f"<{tag}>") + for item in list_buf: + out.append(f"
  • {_inline_md(item)}
  • ") + out.append(f"") + list_buf = [] + + def flush_code() -> None: + nonlocal code_buf, in_code + if not code_buf: + return + out.append(f"
    {escape(chr(10).join(code_buf))}
    ") + code_buf = [] + in_code = False + + while i < len(lines): + line = lines[i] + if line.strip().startswith("```"): + flush_list() + if in_code: + flush_code() + else: + in_code = True + i += 1 + continue + if in_code: + code_buf.append(line) + i += 1 + continue + if re.match(r"^\s*\|", line) and i + 1 < len(lines) and re.match(r"^\s*\|?\s*[-:| ]+\|", lines[i + 1]): + flush_list() + header = [c.strip() for c in line.strip().strip("|").split("|")] + i += 2 + rows: list[list[str]] = [] + while i < len(lines) and re.match(r"^\s*\|", lines[i]): + rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")]) + i += 1 + out.append("" + "".join(f"" for h in header) + "") + for row in rows: + out.append("" + "".join(f"" for c in row) + "") + out.append("
    {_inline_md(h)}
    {_inline_md(c)}
    ") + continue + if re.match(r"^#{1,3}\s+", line): + flush_list() + m = re.match(r"^(#{1,3})\s+(.*)$", line) + if m: + level = len(m.group(1)) + out.append(f"{_inline_md(m.group(2))}") + i += 1 + continue + if line.strip() == "---": + flush_list() + out.append("
    ") + i += 1 + continue + if line.startswith(">"): + flush_list() + out.append(f"
    {_inline_md(line.lstrip('>').strip())}
    ") + i += 1 + continue + m = re.match(r"^(\d+)\.\s+(.*)$", line.strip()) + if m: + if list_buf and not list_ordered: + flush_list() + list_ordered = True + list_buf.append(m.group(2)) + i += 1 + continue + if re.match(r"^[-*]\s+", line.strip()): + if list_buf and list_ordered: + flush_list() + list_ordered = False + list_buf.append(re.sub(r"^[-*]\s+", "", line.strip())) + i += 1 + continue + if not line.strip(): + flush_list() + i += 1 + continue + flush_list() + out.append(f"

    {_inline_md(line.strip())}

    ") + i += 1 + flush_list() + flush_code() + return "\n".join(out) + + +def _inline_md(text: str) -> str: + from html import escape + + s = escape(text) + s = re.sub(r"`([^`]+)`", r"\1", s) + s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) + return s diff --git a/lib/common/static/account_ledger.js b/lib/common/static/account_ledger.js new file mode 100644 index 0000000..a7ce569 --- /dev/null +++ b/lib/common/static/account_ledger.js @@ -0,0 +1,337 @@ +/** + * 账户流水:资金/交易 Tab · 分页 10 · SSE 自动刷新 · 时间窗跟随顶栏预设. + */ +(function (global) { + const PAGE_SIZE = 10; + let account = "funding"; + let page = 1; + let pages = 0; + let localVersion = 0; + let es = null; + let reconnectTimer = null; + let loading = false; + let booted = false; + + function root() { + const active = document.querySelector('.embed-tab-pane.is-active-pane [data-account-ledger="1"]'); + if (active) return active; + return document.getElementById("account-ledger-root"); + } + + function $(id) { + const r = root(); + return (r && r.querySelector("#" + id)) || document.getElementById(id); + } + + function escapeHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function listWindowQs() { + if (typeof global.listWindowQueryString === "function") { + const q = global.listWindowQueryString(); + return q ? (q.charAt(0) === "?" ? q.slice(1) : q) : ""; + } + try { + return new URLSearchParams(location.search).toString(); + } catch (_) { + return ""; + } + } + + function fmtBj(ms) { + const n = Number(ms); + if (!Number.isFinite(n) || n <= 0) return "—"; + try { + const d = new Date(n); + const parts = new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(d); + const get = (t) => (parts.find((p) => p.type === t) || {}).value || ""; + return ( + get("year") + + "-" + + get("month") + + "-" + + get("day") + + " " + + get("hour") + + ":" + + get("minute") + + ":" + + get("second") + ); + } catch (_) { + return "—"; + } + } + + function fmtAmt(v) { + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + const cls = n > 0 ? "account-ledger-amt-pos" : n < 0 ? "account-ledger-amt-neg" : ""; + const sign = n > 0 ? "+" : ""; + return '' + sign + n.toFixed(6).replace(/\.?0+$/, "") + ""; + } + + function fmtBal(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + return n.toFixed(6).replace(/\.?0+$/, ""); + } + + function setStatus(msg, isErr) { + const el = $("account-ledger-status"); + if (!el) return; + el.textContent = msg || ""; + el.style.color = isErr ? "#f07178" : ""; + } + + function setSyncLabel(data) { + const el = $("account-ledger-sync"); + if (!el) return; + const ts = data && data.last_sync_at; + if (!ts) { + el.textContent = "尚未同步"; + return; + } + el.textContent = "同步 " + fmtBj(Number(ts) * 1000); + } + + function renderRows(items) { + const tbody = $("account-ledger-tbody"); + if (!tbody) return; + if (!items || !items.length) { + tbody.innerHTML = '当前时间窗暂无流水'; + return; + } + tbody.innerHTML = items + .map(function (it) { + const note = [it.symbol, it.note, it.raw_type].filter(Boolean).join(" · "); + return ( + "" + + "" + + escapeHtml(fmtBj(it.ts_ms)) + + "" + + "" + + escapeHtml(it.ccy || "") + + "" + + "" + + escapeHtml(it.kind_label || it.kind || "") + + "" + + "" + + fmtAmt(it.amount) + + "" + + "" + + escapeHtml(fmtBal(it.balance_after)) + + "" + + "" + + escapeHtml(note || "—") + + "" + + "" + ); + }) + .join(""); + } + + function renderPager(data) { + pages = Number(data.pages || 0); + page = Number(data.page || 1); + const info = $("account-ledger-page-info"); + const prev = $("account-ledger-prev"); + const next = $("account-ledger-next"); + if (info) { + info.textContent = + "第 " + page + " / " + (pages || 1) + " 页 · 共 " + (data.total || 0) + " 条 · 每页 " + PAGE_SIZE; + } + if (prev) prev.disabled = page <= 1; + if (next) next.disabled = !pages || page >= pages; + } + + async function loadList(opts) { + const r = root(); + if (!r) return; + if (loading) return; + loading = true; + const force = opts && opts.force; + try { + if (!force) setStatus("加载中…"); + const qs = new URLSearchParams(listWindowQs()); + qs.set("account", account); + qs.set("page", String(page)); + const res = await fetch("/api/account_ledger?" + qs.toString(), { + credentials: "same-origin", + }); + const data = await res.json().catch(function () { + return {}; + }); + if (!res.ok || data.ok === false) { + throw new Error(data.msg || res.statusText || "加载失败"); + } + if (data.ledger_version != null) localVersion = Number(data.ledger_version) || localVersion; + renderRows(data.items || []); + renderPager(data); + setSyncLabel(data); + const winLabel = (data.window && data.window.label) || ""; + const err = data.last_error ? " · 同步提示: " + data.last_error : ""; + setStatus( + (winLabel ? "时间窗 " + winLabel + " · " : "") + + (account === "trading" ? "交易账户" : "资金账户") + + err, + !!data.last_error + ); + } catch (e) { + setStatus(e.message || String(e), true); + } finally { + loading = false; + } + } + + async function refreshNow() { + setStatus("正在从交易所同步…"); + try { + const res = await fetch("/api/account_ledger/refresh", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const data = await res.json().catch(function () { + return {}; + }); + if (!res.ok || data.ok === false) { + throw new Error(data.msg || "同步失败"); + } + await loadList({ force: true }); + } catch (e) { + setStatus(e.message || String(e), true); + } + } + + function bindUi() { + const r = root(); + if (!r || r.getAttribute("data-ledger-bound") === "1") return; + r.setAttribute("data-ledger-bound", "1"); + r.querySelectorAll(".account-ledger-tab").forEach(function (btn) { + btn.addEventListener("click", function () { + const acc = btn.getAttribute("data-ledger-account") || "funding"; + if (acc === account) return; + account = acc; + page = 1; + r.querySelectorAll(".account-ledger-tab").forEach(function (b) { + const on = b.getAttribute("data-ledger-account") === account; + b.classList.toggle("active", on); + b.setAttribute("aria-selected", on ? "true" : "false"); + }); + loadList(); + }); + }); + const prev = $("account-ledger-prev"); + const next = $("account-ledger-next"); + const ref = $("account-ledger-refresh"); + if (prev) + prev.addEventListener("click", function () { + if (page > 1) { + page -= 1; + loadList(); + } + }); + if (next) + next.addEventListener("click", function () { + if (!pages || page < pages) { + page += 1; + loadList(); + } + }); + if (ref) ref.addEventListener("click", refreshNow); + } + + function connectSse() { + if (es) { + try { + es.close(); + } catch (_) {} + es = null; + } + if (typeof EventSource === "undefined") return; + try { + es = new EventSource("/api/account_ledger/stream"); + es.addEventListener("ledger", function (ev) { + let data = {}; + try { + data = JSON.parse(ev.data || "{}"); + } catch (_) {} + const ver = Number(data.ledger_version || 0); + if (ver && ver !== localVersion) { + localVersion = ver; + loadList({ force: true }); + } + }); + es.onerror = function () { + try { + es.close(); + } catch (_) {} + es = null; + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(connectSse, 5000); + }; + } catch (_) {} + } + + function boot() { + const r = root(); + if (!r) return; + bindUi(); + if (!booted) { + booted = true; + connectSse(); + } + loadList(); + } + + function onTabActivated(tab) { + if (tab !== "account_ledger") return; + boot(); + } + + global.AccountLedgerPage = { + boot: boot, + onTabActivated: onTabActivated, + reload: function () { + page = 1; + loadList(); + }, + }; + + document.addEventListener("DOMContentLoaded", function () { + const page = + (document.body && document.body.getAttribute("data-page")) || + (document.body && document.body.getAttribute("data-initial-tab")) || + ""; + if (page === "account_ledger" || root()) { + // embed 延后到 tab 激活;独立页直接 boot + if (!document.body || document.body.getAttribute("data-embed-shell") !== "1") { + boot(); + } else if (page === "account_ledger") { + boot(); + } + } + }); + + document.addEventListener("instance-embed-tab-activated", function (ev) { + const tab = ev && ev.detail && ev.detail.tab; + onTabActivated(tab); + }); +})(window); diff --git a/lib/common/static/account_risk_badge.css b/lib/common/static/account_risk_badge.css new file mode 100644 index 0000000..bd47181 --- /dev/null +++ b/lib/common/static/account_risk_badge.css @@ -0,0 +1,150 @@ +/* 账户风控状态徽章 — 三所实例 + 中控共用;兼容 data-theme light/dark */ + +:root, +html[data-theme="dark"] { + --risk-normal-fg: #9cf0c4; + --risk-normal-bg: rgba(36, 140, 96, 0.16); + --risk-normal-border: rgba(72, 190, 130, 0.42); + --risk-normal-glow: rgba(72, 190, 130, 0.35); + + --risk-1h-fg: #ffd27a; + --risk-1h-bg: rgba(210, 150, 40, 0.16); + --risk-1h-border: rgba(230, 170, 60, 0.45); + --risk-1h-glow: rgba(230, 170, 60, 0.32); + + --risk-4h-fg: #ffab8a; + --risk-4h-bg: rgba(210, 90, 55, 0.16); + --risk-4h-border: rgba(230, 110, 70, 0.48); + --risk-4h-glow: rgba(230, 110, 70, 0.34); + + --risk-daily-fg: #ff9ec4; + --risk-daily-bg: rgba(190, 55, 100, 0.18); + --risk-daily-border: rgba(210, 75, 120, 0.5); + --risk-daily-glow: rgba(210, 75, 120, 0.36); + + --risk-position-fg: #8ec8ff; + --risk-position-bg: rgba(55, 120, 210, 0.18); + --risk-position-border: rgba(75, 145, 230, 0.48); + --risk-position-glow: rgba(75, 145, 230, 0.34); + + --risk-badge-shadow: 0 1px 2px rgba(0, 0, 0, 0.28); +} + +html[data-theme="light"] { + --risk-normal-fg: #056b44; + --risk-normal-bg: rgba(10, 143, 92, 0.14); + --risk-normal-border: rgba(8, 122, 80, 0.38); + --risk-normal-glow: rgba(10, 143, 92, 0.22); + + --risk-1h-fg: #8a5a00; + --risk-1h-bg: rgba(200, 140, 20, 0.14); + --risk-1h-border: rgba(170, 115, 10, 0.38); + --risk-1h-glow: rgba(200, 140, 20, 0.2); + + --risk-4h-fg: #a83812; + --risk-4h-bg: rgba(210, 85, 35, 0.12); + --risk-4h-border: rgba(180, 65, 25, 0.36); + --risk-4h-glow: rgba(210, 85, 35, 0.2); + + --risk-daily-fg: #9a1248; + --risk-daily-bg: rgba(180, 35, 80, 0.1); + --risk-daily-border: rgba(155, 28, 68, 0.34); + --risk-daily-glow: rgba(180, 35, 80, 0.18); + + --risk-position-fg: #0b5cab; + --risk-position-bg: rgba(20, 100, 190, 0.12); + --risk-position-border: rgba(15, 85, 165, 0.36); + --risk-position-glow: rgba(20, 100, 190, 0.2); + + --risk-badge-shadow: 0 1px 2px rgba(20, 50, 80, 0.1); +} + +.risk-status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.03em; + line-height: 1.15; + padding: 5px 12px 5px 10px; + border-radius: 999px; + border: 1px solid var(--risk-border, transparent); + background: var(--risk-bg, transparent); + color: var(--risk-fg, inherit); + box-shadow: var(--risk-badge-shadow); + white-space: nowrap; + vertical-align: middle; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +/* 中控 iframe 内切页:避免徽章过渡动画造成 header 闪动 */ +html[data-hub-linked="1"] .header-row .risk-status-badge { + transition: none; +} + +.risk-status-badge::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; + box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent), + 0 0 8px var(--risk-glow, currentColor); + opacity: 0.92; +} + +.risk-status-normal { + --risk-fg: var(--risk-normal-fg); + --risk-bg: var(--risk-normal-bg); + --risk-border: var(--risk-normal-border); + --risk-glow: var(--risk-normal-glow); +} + +.risk-status-freeze_1h { + --risk-fg: var(--risk-1h-fg); + --risk-bg: var(--risk-1h-bg); + --risk-border: var(--risk-1h-border); + --risk-glow: var(--risk-1h-glow); +} + +.risk-status-freeze_4h { + --risk-fg: var(--risk-4h-fg); + --risk-bg: var(--risk-4h-bg); + --risk-border: var(--risk-4h-border); + --risk-glow: var(--risk-4h-glow); +} + +.risk-status-freeze_daily { + --risk-fg: var(--risk-daily-fg); + --risk-bg: var(--risk-daily-bg); + --risk-border: var(--risk-daily-border); + --risk-glow: var(--risk-daily-glow); +} + +.risk-status-freeze_position { + --risk-fg: var(--risk-position-fg); + --risk-bg: var(--risk-position-bg); + --risk-border: var(--risk-position-border); + --risk-glow: var(--risk-position-glow); +} + +/* 实例页:与交易所标签并排 */ +.header-row .risk-status-badge { + min-height: 28px; +} + +/* 中控卡片标题内 */ +.card-title .risk-status-badge, +.hub-tile-name .risk-status-badge { + font-size: 0.7rem; + padding: 3px 10px 3px 8px; + vertical-align: middle; +} + +.card-title .risk-status-badge::before, +.hub-tile-name .risk-status-badge::before { + width: 6px; + height: 6px; +} diff --git a/lib/common/static/account_risk_badge.js b/lib/common/static/account_risk_badge.js new file mode 100644 index 0000000..68fe7dd --- /dev/null +++ b/lib/common/static/account_risk_badge.js @@ -0,0 +1,120 @@ +/** + * 账户风控徽章倒计时 — 三所实例 + 中控共用. + */ +(function (global) { + "use strict"; + + function formatRemaining(totalSec) { + const sec = Math.max(0, Math.floor(Number(totalSec) || 0)); + if (sec <= 0) return ""; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`; + if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`; + return `${s}s`; + } + + function baseLabel(riskStatus, el) { + if (riskStatus && riskStatus.status_label) return String(riskStatus.status_label); + if (el && el.dataset && el.dataset.statusLabel) return String(el.dataset.statusLabel); + return "正常"; + } + + function resolveFreezeUntilMs(riskStatus) { + if (!riskStatus) return null; + const sec = Number(riskStatus.freeze_remaining_sec); + if (Number.isFinite(sec) && sec > 0) { + return Date.now() + sec * 1000; + } + const until = Number(riskStatus.freeze_until_ms); + return Number.isFinite(until) && until > 0 ? until : null; + } + + function badgeText(riskStatus) { + const label = baseLabel(riskStatus, null); + const until = resolveFreezeUntilMs(riskStatus); + if (!until || until <= Date.now()) return label; + const cd = formatRemaining((until - Date.now()) / 1000); + return cd ? `${label} · ${cd}` : label; + } + + function setNormalBadge(el) { + el.className = "risk-status-badge risk-status-normal"; + el.dataset.statusLabel = "正常"; + el.textContent = "正常"; + el.title = ""; + if (el.dataset) delete el.dataset.freezeUntilMs; + } + + function refreshElement(el) { + if (!el) return; + const label = baseLabel(null, el); + const until = Number(el.dataset && el.dataset.freezeUntilMs); + if (!Number.isFinite(until) || until <= Date.now()) { + if (el.dataset && el.dataset.freezeUntilMs) { + setNormalBadge(el); + } else { + el.textContent = label; + } + return; + } + const cd = formatRemaining((until - Date.now()) / 1000); + el.textContent = cd ? `${label} · ${cd}` : label; + } + + function applyToElement(el, riskStatus) { + if (!el || !riskStatus) return; + const st = riskStatus.status || "normal"; + el.className = "risk-status-badge risk-status-" + st; + el.dataset.statusLabel = baseLabel(riskStatus, el); + const until = resolveFreezeUntilMs(riskStatus); + if (until) { + el.dataset.freezeUntilMs = String(until); + } else if (el.dataset) { + delete el.dataset.freezeUntilMs; + } + el.textContent = badgeText(riskStatus); + el.title = riskStatus.reason || ""; + } + + function formatBadgeHtml(riskStatus, esc) { + if (!riskStatus || typeof riskStatus !== "object") return ""; + const safe = typeof esc === "function" ? esc : (s) => String(s); + const st = riskStatus.status || "normal"; + const label = safe(riskStatus.status_label || "正常"); + const title = safe(riskStatus.reason || ""); + const text = safe(badgeText(riskStatus)); + const until = resolveFreezeUntilMs(riskStatus); + const untilAttr = + until != null + ? ` data-freeze-until-ms="${safe(String(Math.floor(until)))}"` + : ""; + return ( + `${text}` + ); + } + + function tickAll(root) { + const scope = root || document; + scope.querySelectorAll(".risk-status-badge[data-freeze-until-ms]").forEach(refreshElement); + } + + let timer = null; + function startTicker() { + if (timer) return; + tickAll(); + timer = setInterval(() => tickAll(), 1000); + } + + global.AccountRiskBadge = { + formatRemaining, + badgeText, + refreshElement, + applyToElement, + formatBadgeHtml, + tickAll, + startTicker, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/autofill_guard.js b/lib/common/static/autofill_guard.js new file mode 100644 index 0000000..b016d41 --- /dev/null +++ b/lib/common/static/autofill_guard.js @@ -0,0 +1,148 @@ +/** + * 全局防浏览器自动填充登录账号/密码进业务输入框. + * 跳过真正的登录/改密字段;对划转数量等易中招框用 readonly 到聚焦. + */ +(function () { + "use strict"; + + var GUARD_ATTRS = { + autocomplete: "off", + autocorrect: "off", + autocapitalize: "off", + spellcheck: "false", + "data-lpignore": "true", + "data-1p-ignore": "true", + "data-bwignore": "true", + "data-form-type": "other", + }; + + function looksLikeUsername(v) { + return /^[a-z][a-z0-9._-]{1,31}$/i.test(String(v || "").trim()); + } + + function isAuthField(el) { + if (!el || !el.getAttribute) return true; + var t = String(el.type || "").toLowerCase(); + if (t === "hidden" || t === "checkbox" || t === "radio" || t === "file" || t === "submit" || t === "button") { + return true; + } + if (el.getAttribute("aria-hidden") === "true") return true; + if (el.tabIndex === -1 && String(el.getAttribute("autocomplete") || "").toLowerCase() === "username") { + return true; // 诱饵账号框 + } + var idName = String(el.id || "") + " " + String(el.name || ""); + if (/^(pwd-|hub-pwd-|login-)/i.test(String(el.id || ""))) return true; + if (el.closest) { + if (el.closest(".login-form, #login-form, form.login-form, .password-settings, [data-password-settings]")) { + return true; + } + } + // env API Key 等 type=password 仍要防登录密码灌入,不在此跳过 + if (t === "password" && /^(username|password)$/i.test(String(el.name || ""))) { + if (el.closest && el.closest("form[method='post'], form[method='POST']")) return true; + } + return false; + } + + function isAmountLike(el) { + var key = String(el.id || "") + " " + String(el.name || "") + " " + String(el.placeholder || ""); + return /amount|xfer|transfer|划转|数量|金额/i.test(key); + } + + function wipeBad(el) { + if (!el || isAuthField(el)) return; + var v = String(el.value || "").trim(); + if (!looksLikeUsername(v)) return; + var t = String(el.type || "text").toLowerCase(); + if (t === "number" || isAmountLike(el) || /price|sheets|qty|sl|tp|target|entry|strike/i.test(String(el.id || "") + String(el.name || ""))) { + el.value = ""; + } + } + + function harden(el) { + if (!el || el.nodeType !== 1) return; + if (isAuthField(el)) return; + if (el.getAttribute("aria-hidden") === "true") return; + if (el.dataset && el.dataset.autofillGuarded === "1") { + wipeBad(el); + return; + } + if (el.dataset) el.dataset.autofillGuarded = "1"; + + Object.keys(GUARD_ATTRS).forEach(function (k) { + var cur = el.getAttribute(k); + if (k === "autocomplete" && cur && /^(username|current-password)/i.test(cur)) { + return; + } + // env 密钥框用 new-password 更抗登录密码灌入 + if (k === "autocomplete" && String(el.type || "").toLowerCase() === "password") { + el.setAttribute(k, "new-password"); + return; + } + if (!cur || cur === "on") el.setAttribute(k, GUARD_ATTRS[k]); + }); + + if (String(el.type || "").toLowerCase() === "password" || isAmountLike(el)) { + el.setAttribute("readonly", "readonly"); + el.addEventListener("focus", function () { + el.removeAttribute("readonly"); + }); + el.addEventListener("blur", function () { + if (!el.value) el.setAttribute("readonly", "readonly"); + }); + } + + wipeBad(el); + setTimeout(function () { + wipeBad(el); + }, 250); + setTimeout(function () { + wipeBad(el); + }, 900); + setTimeout(function () { + wipeBad(el); + }, 2000); + } + + function scan(root) { + var scope = root && root.querySelectorAll ? root : document; + var list = scope.querySelectorAll( + 'input[type="text"], input[type="number"], input[type="search"], input[type="url"], input[type="email"], input[type="tel"], input[type="password"], input:not([type]), textarea' + ); + for (var i = 0; i < list.length; i++) harden(list[i]); + } + + function boot() { + scan(document); + if (typeof MutationObserver === "undefined") return; + var obs = new MutationObserver(function (mutations) { + for (var i = 0; i < mutations.length; i++) { + var m = mutations[i]; + if (m.type === "childList") { + for (var j = 0; j < m.addedNodes.length; j++) { + var n = m.addedNodes[j]; + if (!n || n.nodeType !== 1) continue; + if (n.matches && n.matches("input, textarea")) harden(n); + else if (n.querySelectorAll) scan(n); + } + } else if (m.type === "attributes" && m.target) { + harden(m.target); + } + } + }); + obs.observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["value"], + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } + + window.cmAutofillGuardScan = scan; +})(); diff --git a/lib/common/static/focus_chart_page.css b/lib/common/static/focus_chart_page.css new file mode 100644 index 0000000..608b1e9 --- /dev/null +++ b/lib/common/static/focus_chart_page.css @@ -0,0 +1,221 @@ +/* 实盘/关键位放大页:与 instance_theme 联动,高对比 meta + 主题感知图表区 */ +body.focus-page { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + padding: 14px; + margin: 0; + background: var(--focus-bg, #0b0d14); + color: var(--focus-fg, #eaeaea); +} + +html[data-theme="light"] body.focus-page { + --focus-bg: #eef3f8; + --focus-fg: #142232; + --focus-card-bg: #fff; + --focus-card-border: #b8c8d8; + --focus-meta-bg: #fff; + --focus-meta-border: #9eb4c8; + --focus-meta-label: #2a4a66; + --focus-meta-value: #0a1628; + --focus-status: #4a6078; + --focus-chart-bg: #f0f4f9; + --focus-chart-border: #b8c8d8; + --focus-btn-bg: #fff; + --focus-btn-fg: #006e9a; + --focus-btn-border: rgba(0, 95, 140, 0.22); + --focus-input-bg: #fff; + --focus-input-fg: #142232; + --focus-input-border: #b8c8d8; + --focus-title: #0a1628; + --focus-pnl-up: #0a7a3d; + --focus-pnl-down: #c62828; + --focus-dir-short: #b71c1c; + --focus-dir-long: #0a7a3d; +} + +html[data-theme="dark"] body.focus-page { + --focus-bg: #0b0d14; + --focus-fg: #eaeaea; + --focus-card-bg: #121726; + --focus-card-border: #2a3150; + --focus-meta-bg: #141b2f; + --focus-meta-border: #3d4f72; + --focus-meta-label: #c8d8f0; + --focus-meta-value: #f0f4ff; + --focus-status: #95a2c2; + --focus-chart-bg: #0f1320; + --focus-chart-border: #2a3150; + --focus-btn-bg: #151a2a; + --focus-btn-fg: #8fc8ff; + --focus-btn-border: #304164; + --focus-input-bg: #1a1a29; + --focus-input-fg: #fff; + --focus-input-border: #2e2e45; + --focus-title: #dbe4ff; + --focus-pnl-up: #3ddc84; + --focus-pnl-down: #ff7070; + --focus-dir-short: #ff8a80; + --focus-dir-long: #69f0ae; +} + +body.focus-page * { + box-sizing: border-box; +} + +.focus-page .container { + width: min(98vw, 1900px); + margin: 0 auto; +} + +.focus-page .card { + background: var(--focus-card-bg); + border-radius: 10px; + padding: 12px; + border: 1px solid var(--focus-card-border); + margin-bottom: 12px; +} + +.focus-page .row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.focus-page .btn { + padding: 7px 10px; + border-radius: 8px; + text-decoration: none; + border: 1px solid var(--focus-btn-border); + background: var(--focus-btn-bg); + color: var(--focus-btn-fg); + cursor: pointer; +} + +.focus-page .btn:hover { + filter: brightness(1.06); +} + +.focus-page select, +.focus-page input, +.focus-page button { + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--focus-input-border); + background: var(--focus-input-bg); + color: var(--focus-input-fg); +} + +.focus-page .focus-title { + color: var(--focus-title); + font-weight: 700; +} + +.focus-page .meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.focus-page .meta-item { + background: var(--focus-meta-bg); + border: 1px solid var(--focus-meta-border); + border-radius: 8px; + padding: 10px 10px 9px; +} + +.focus-page .meta-item .k { + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--focus-meta-label); +} + +.focus-page .meta-item .v { + font-size: 1.02rem; + font-weight: 600; + margin-top: 5px; + word-break: break-all; + color: var(--focus-meta-value); +} + +.focus-page .meta-item--emph { + border-width: 2px; + border-color: var(--focus-meta-label); +} + +.focus-page .meta-item--emph .k { + font-size: 0.82rem; + font-weight: 700; +} + +.focus-page .meta-item--emph .v { + font-size: 1.12rem; + font-weight: 800; +} + +.focus-page .meta-item--pnl .v { + font-size: 1.14rem; + font-weight: 800; + letter-spacing: 0.01em; +} + +.focus-page .meta-pnl-up { + color: var(--focus-pnl-up) !important; +} + +.focus-page .meta-pnl-down { + color: var(--focus-pnl-down) !important; +} + +.focus-page .meta-dir-long { + color: var(--focus-dir-long) !important; +} + +.focus-page .meta-dir-short { + color: var(--focus-dir-short) !important; +} + +.focus-page .status { + font-size: 0.84rem; + color: var(--focus-status); +} + +.focus-page .status.err { + color: var(--focus-pnl-down); +} + +.focus-page #chart-wrap { + height: 560px; + background: var(--focus-chart-bg); + border: 1px solid var(--focus-chart-border); + border-radius: 10px; + padding: 8px; +} + +.focus-page #chart { + width: 100%; + height: 100%; +} + +.focus-page .empty { + padding: 18px; + color: var(--focus-status); +} + +.focus-page .exchange-tag { + font-size: 0.72rem; + font-weight: 600; + color: #b8f5d0; + background: #14241e; + border: 1px solid #2d6a4f; + padding: 4px 10px; + border-radius: 999px; + margin-left: 8px; +} + +html[data-theme="light"] .focus-page .exchange-tag { + color: #0a5c38; + background: #e8f5ee; + border-color: #7bc9a0; +} diff --git a/lib/common/static/focus_chart_page.js b/lib/common/static/focus_chart_page.js new file mode 100644 index 0000000..8d2fc5b --- /dev/null +++ b/lib/common/static/focus_chart_page.js @@ -0,0 +1,401 @@ +/** + * 实盘/关键位放大 K 线:交易所 tick 精度,主题感知图表,高对比 meta. + */ +(function (global) { + "use strict"; + + let activePriceTick = null; + + function currentTheme() { + return document.documentElement.getAttribute("data-theme") === "light" + ? "light" + : "dark"; + } + + function chartTheme(theme) { + if (theme === "light") { + return { + layout: { background: { color: "#f0f4f9" }, textColor: "#142232" }, + grid: { vertLines: { color: "#d0dae4" }, horzLines: { color: "#d0dae4" } }, + rightPriceScale: { borderColor: "#b8c8d8" }, + timeScale: { borderColor: "#b8c8d8" }, + candle: { + upColor: "#0a7a3d", + downColor: "#c62828", + wickUpColor: "#0a7a3d", + wickDownColor: "#c62828", + }, + }; + } + return { + layout: { background: { color: "#0f1320" }, textColor: "#d6deff" }, + grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } }, + rightPriceScale: { borderColor: "#2a3150" }, + timeScale: { borderColor: "#2a3150" }, + candle: { + upColor: "#4cd97f", + downColor: "#ff6666", + wickUpColor: "#4cd97f", + wickDownColor: "#ff6666", + }, + }; + } + + const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 }; + + function decimalsFromTick(tick) { + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null; + const minMove = Number(tick); + if (minMove >= 1) return 0; + const raw = String(minMove); + const sci = raw.match(/e-(\d+)/i); + if (sci) return Math.min(12, parseInt(sci[1], 10)); + const fixed = minMove.toFixed(12); + const frac = fixed.split(".")[1] || ""; + const trimmed = frac.replace(/0+$/, ""); + if (trimmed.length) return Math.min(12, trimmed.length); + return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove)))); + } + + function tickToPriceFormat(tick) { + try { + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) { + return { type: "price", precision: 2, minMove: 0.01 }; + } + const minMove = Number(tick); + let prec = decimalsFromTick(minMove); + if (prec == null || prec < 0) prec = 4; + prec = Math.min(12, Math.max(0, Math.floor(prec))); + return { type: "price", precision: prec, minMove: minMove }; + } catch (_) { + return SAFE_PRICE_FORMAT; + } + } + + function roundToTick(v, tick) { + if (v == null || Number.isNaN(Number(v))) return v; + const n = Number(v); + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n; + const t = Number(tick); + const rounded = Math.round(n / t) * t; + const dec = decimalsFromTick(t); + if (dec == null) return rounded; + return parseFloat(rounded.toFixed(dec)); + } + + function fmtPriceByTick(v, tick) { + if (v == null || Number.isNaN(Number(v))) return "-"; + const n = Number(roundToTick(v, tick)); + if (n === 0) return "0"; + const dec = decimalsFromTick(tick); + if (dec != null) return n.toFixed(dec); + const av = Math.abs(n); + let d = 8; + if (av >= 10000) d = 2; + else if (av >= 100) d = 3; + else if (av >= 1) d = 4; + else if (av >= 0.01) d = 6; + const text = n.toFixed(d); + return text.includes(".") ? text.replace(/\.?0+$/, "") : text; + } + + function setActivePriceTick(tick) { + activePriceTick = + tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0 + ? null + : Number(tick); + } + + function formatSigned(v, digits) { + digits = digits === undefined ? 2 : digits; + if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-"; + const n = Number(v); + const sign = n > 0 ? "+" : ""; + return sign + n.toFixed(digits); + } + + function formatSignedPrice(v) { + if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-"; + const n = Number(v); + const body = fmtPriceByTick(Math.abs(n), activePriceTick); + if (body === "-") return "-"; + return (n > 0 ? "+" : n < 0 ? "-" : "") + body; + } + + function formatRrRatio(rr) { + if (rr === null || typeof rr === "undefined") return "-:1"; + const n = Number(rr); + if (Number.isNaN(n)) return "-:1"; + const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2))); + return body + ":1"; + } + + function displayPrice(orderOrData, field, rawField) { + const dispKey = field + "_display"; + if (orderOrData && orderOrData[dispKey] && orderOrData[dispKey] !== "-") { + return String(orderOrData[dispKey]); + } + const raw = orderOrData ? orderOrData[rawField || field] : null; + if (raw === null || typeof raw === "undefined" || Number.isNaN(Number(raw))) return "-"; + return fmtPriceByTick(raw, activePriceTick); + } + + function lineTitle(label, display) { + const d = display && display !== "-" ? display : ""; + return d ? label + " " + d : label; + } + + function paintOrderMeta(order) { + const symEl = document.getElementById("m-symbol"); + const dirEl = document.getElementById("m-direction"); + const pnlEl = document.getElementById("m-pnl"); + if (symEl) symEl.textContent = order.symbol || "-"; + if (dirEl) { + const isShort = order.direction === "short"; + dirEl.textContent = isShort ? "做空" : "做多"; + dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long"); + } + const set = function (id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; + }; + set("m-entry", displayPrice(order, "trigger_price")); + set("m-sl", displayPrice(order, "stop_loss")); + set("m-tp", displayPrice(order, "take_profit")); + set("m-rr", formatRrRatio(order.rr_ratio)); + set( + "m-breakeven", + order.breakeven_enabled === false || order.breakeven_enabled === 0 ? "关闭" : "开启" + ); + set( + "m-price", + order.current_price_display || + order.price_display || + displayPrice(order, "current_price") + ); + if (pnlEl) { + pnlEl.textContent = + formatSigned(order.float_pnl, 2) + + "U (" + + formatSigned(order.float_pct, 2) + + "%)"; + pnlEl.className = "v"; + const pnl = Number(order.float_pnl || 0); + if (pnl > 0) pnlEl.classList.add("meta-pnl-up"); + else if (pnl < 0) pnlEl.classList.add("meta-pnl-down"); + } + } + + function paintKeyMeta(data) { + const key = data.key_monitor || null; + const symEl = document.getElementById("m-symbol"); + if (symEl) symEl.textContent = data.symbol || "-"; + const set = function (id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; + }; + set( + "m-price", + data.current_price_display || displayPrice(data, "current_price") + ); + const dirEl = document.getElementById("m-direction"); + if (!key) { + set("m-type", "未匹配到关键位"); + set("m-direction", "-"); + if (dirEl) dirEl.className = "v"; + set("m-upper", "-"); + set("m-lower", "-"); + set("m-updiff", "-"); + set("m-lowdiff", "-"); + return; + } + set("m-type", key.monitor_type || "-"); + if (dirEl) { + const isShort = key.direction === "short"; + dirEl.textContent = isShort ? "做空" : "做多"; + dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long"); + } + set("m-upper", key.upper_display || displayPrice(key, "upper")); + set("m-lower", key.lower_display || displayPrice(key, "lower")); + if (activePriceTick != null) { + set( + "m-updiff", + formatSignedPrice(key.upper_diff) + + " (" + + formatSigned(key.upper_pct, 2) + + "%)" + ); + set( + "m-lowdiff", + formatSignedPrice(key.lower_diff) + + " (" + + formatSigned(key.lower_pct, 2) + + "%)" + ); + } else { + set( + "m-updiff", + formatSigned(key.upper_diff, 4) + " (" + formatSigned(key.upper_pct, 2) + "%)" + ); + set( + "m-lowdiff", + formatSigned(key.lower_diff, 4) + " (" + formatSigned(key.lower_pct, 2) + "%)" + ); + } + } + + function applyPriceFormatToSeries(series, pf) { + if (!series || !series.applyOptions) return; + try { + series.applyOptions({ priceFormat: pf }); + } catch (_) { + try { + series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT }); + } catch (_2) {} + } + } + + function createFocusChart(host) { + if (!global.LightweightCharts) return null; + const th = chartTheme(currentTheme()); + const chart = global.LightweightCharts.createChart(host, { + layout: th.layout, + grid: th.grid, + rightPriceScale: th.rightPriceScale, + timeScale: Object.assign({ timeVisible: true, secondsVisible: false }, th.timeScale), + crosshair: { mode: 0 }, + localization: { + priceFormatter: function (p) { + return fmtPriceByTick(p, activePriceTick); + }, + }, + }); + let candleSeries = null; + + function applyChartPriceFormat() { + let pf = SAFE_PRICE_FORMAT; + try { + pf = tickToPriceFormat(activePriceTick); + } catch (_) { + pf = SAFE_PRICE_FORMAT; + } + applyPriceFormatToSeries(candleSeries, pf); + try { + chart.applyOptions({ + localization: { + priceFormatter: function (p) { + return fmtPriceByTick(p, activePriceTick); + }, + }, + }); + } catch (_) {} + } + + function setPriceTick(tick) { + setActivePriceTick(tick); + applyChartPriceFormat(); + } + + const opts = Object.assign({ borderVisible: false }, th.candle); + if (typeof chart.addCandlestickSeries === "function") { + candleSeries = chart.addCandlestickSeries(opts); + } else if ( + typeof chart.addSeries === "function" && + global.LightweightCharts.CandlestickSeries + ) { + candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, opts); + } + applyChartPriceFormat(); + + const priceLines = []; + function resetPriceLines() { + if (!candleSeries) return; + priceLines.forEach(function (line) { + try { + candleSeries.removePriceLine(line); + } catch (_) {} + }); + priceLines.length = 0; + } + function addLine(price, title, color) { + if (!candleSeries || price === null || typeof price === "undefined") return; + const p = Number(roundToTick(price, activePriceTick)); + if (Number.isNaN(p) || p <= 0) return; + priceLines.push( + candleSeries.createPriceLine({ + price: p, + color: color, + lineWidth: 1, + lineStyle: 0, + axisLabelVisible: true, + title: title, + }) + ); + } + function applyTheme() { + const t = chartTheme(currentTheme()); + chart.applyOptions({ + layout: t.layout, + grid: t.grid, + rightPriceScale: t.rightPriceScale, + timeScale: t.timeScale, + localization: { + priceFormatter: function (p) { + return fmtPriceByTick(p, activePriceTick); + }, + }, + }); + if (candleSeries && typeof candleSeries.applyOptions === "function") { + candleSeries.applyOptions(t.candle); + } + applyChartPriceFormat(); + } + function resize() { + chart.applyOptions({ width: host.clientWidth, height: host.clientHeight }); + } + global.addEventListener("resize", resize); + resize(); + const obs = new MutationObserver(applyTheme); + obs.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return { + chart: chart, + candleSeries: candleSeries, + resetPriceLines: resetPriceLines, + addLine: addLine, + applyTheme: applyTheme, + setPriceTick: setPriceTick, + ensureSeries: function () { + if (candleSeries) return true; + const t = chartTheme(currentTheme()); + const o = Object.assign({ borderVisible: false }, t.candle); + if (typeof chart.addCandlestickSeries === "function") { + candleSeries = chart.addCandlestickSeries(o); + } else if ( + typeof chart.addSeries === "function" && + global.LightweightCharts.CandlestickSeries + ) { + candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, o); + } + applyChartPriceFormat(); + return !!candleSeries; + }, + }; + } + + global.FocusChartPage = { + currentTheme: currentTheme, + chartTheme: chartTheme, + formatSigned: formatSigned, + formatRrRatio: formatRrRatio, + displayPrice: displayPrice, + lineTitle: lineTitle, + paintOrderMeta: paintOrderMeta, + paintKeyMeta: paintKeyMeta, + createFocusChart: createFocusChart, + setActivePriceTick: setActivePriceTick, + fmtPriceByTick: fmtPriceByTick, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/form_submit_guard.js b/lib/common/static/form_submit_guard.js new file mode 100644 index 0000000..e84807c --- /dev/null +++ b/lib/common/static/form_submit_guard.js @@ -0,0 +1,81 @@ +/** + * 表单提交防重复:网络慢时禁用按钮并显示「提交中」. + */ +(function (global) { + "use strict"; + + function submitButtons(form) { + if (!form) return []; + return Array.prototype.slice.call( + form.querySelectorAll('button[type="submit"], input[type="submit"]') + ); + } + + function lockForm(form, label) { + if (!form) return false; + if (form.dataset.submitGuard === "locked") return false; + form.dataset.submitGuard = "locked"; + form.classList.add("is-form-submitting"); + submitButtons(form).forEach(function (btn) { + if (btn.dataset.submitGuardOrig === undefined) { + btn.dataset.submitGuardOrig = + btn.tagName === "BUTTON" ? btn.textContent : btn.value; + } + btn.disabled = true; + if (label) { + if (btn.tagName === "BUTTON") btn.textContent = label; + else btn.value = label; + } + }); + return true; + } + + function unlockForm(form) { + if (!form) return; + delete form.dataset.submitGuard; + form.classList.remove("is-form-submitting"); + submitButtons(form).forEach(function (btn) { + // 风控灰显(开仓门禁)保持禁用 + btn.disabled = btn.classList.contains("is-blocked"); + var orig = btn.dataset.submitGuardOrig; + if (orig !== undefined) { + if (btn.tagName === "BUTTON") btn.textContent = orig; + else btn.value = orig; + delete btn.dataset.submitGuardOrig; + } + }); + } + + function isLocked(form) { + return !!(form && form.dataset.submitGuard === "locked"); + } + + /** 已锁定时仅更新按钮文案(校验通过 → 真正提交前) */ + function setSubmitLabel(form, label) { + if (!form || !label) return; + submitButtons(form).forEach(function (btn) { + if (btn.tagName === "BUTTON") btn.textContent = label; + else btn.value = label; + }); + } + + /** 已通过前端校验,发起最终 POST(页面将跳转) */ + function nativeSubmitOnce(form, label) { + if (!form) return; + var text = label || "提交中…"; + if (form.dataset.submitGuard === "locked") { + setSubmitLabel(form, text); + } else { + lockForm(form, text); + } + form.submit(); + } + + global.FormSubmitGuard = { + lock: lockForm, + unlock: unlockForm, + isLocked: isLocked, + setSubmitLabel: setSubmitLabel, + nativeSubmitOnce: nativeSubmitOnce, + }; +})(typeof window !== "undefined" ? window : this); diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js new file mode 100644 index 0000000..46847a0 --- /dev/null +++ b/lib/common/static/hedge_plan.js @@ -0,0 +1,2791 @@ +/** + * OKX 对冲计划 P0:行情 + 永期列表 / 期期 T + 情景测算 + 门禁. + */ +(function () { + const root = document.getElementById("hedge-plan-root"); + if (!root) return; + + function flagOn(attr) { + return root.getAttribute(attr) !== "0"; + } + + const showPerp = flagOn("data-show-perp"); + const showOo = flagOn("data-show-oo"); + + function pickDefaultTab() { + if (showPerp) return "perp_options"; + if (showOo) return "options_options"; + return "active"; + } + + const state = { + tab: pickDefaultTab(), + mode: showPerp ? "perp_options" : showOo ? "options_options" : "perp_options", + underlying: root.getAttribute("data-default-underly") || "ETH", + moneyFilter: root.getAttribute("data-option-primary") !== "0" ? "otm" : "itm", + ooMoneyFilter: "atm_otm", // 期期锁定:平值+虚值 + ooRecommend: null, // atm_straddle | double_otm | null + ooStrikeExpandAll: false, // 默认 Call/Put 各 3 档 + chain: null, + selected: null, + legA: null, + legB: null, + market: null, + ooSheetsMode: "same_sheets", + ooBiasSplitBy: "budget", + ooBiasRatio: 0.7, + ooCloseModeEnabled: root.getAttribute("data-oo-close-mode-enabled") !== "0", + ooCloseMode: "close_all", + direction: "long", + optionPrimary: root.getAttribute("data-option-primary") !== "0", + opLevTouched: false, + opRatioTouched: false, + tradingUsdc: null, + fundingUsdc: null, + tradeBudgetUsdc: null, + budgetBuffer: (function () { + const raw = root.getAttribute("data-budget-buffer"); + const n = raw != null && raw !== "" ? Number(raw) : NaN; + return !Number.isNaN(n) && n > 0 ? n : 0.95; + })(), + previewOk: false, + canStart: false, + previewPlanType: null, + }; + + function getDirection() { + return (state.direction || "long").toLowerCase() === "short" ? "short" : "long"; + } + + function syncPoDirUI() { + const dir = getDirection(); + document.querySelectorAll(".hp-po-dir").forEach(function (b) { + const on = (b.getAttribute("data-dir") || "") === dir; + b.classList.toggle("is-selected", on); + b.classList.toggle("active", on); + }); + } + + function setDirection(dir, forceReload) { + const next = (dir || "long").toLowerCase() === "short" ? "short" : "long"; + const changed = next !== getDirection(); + state.direction = next; + syncPoDirUI(); + if (!changed && !forceReload) return; + state.selected = null; + if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; + void loadMarket().then(function () { + renderListStrikes(); + }); + } + + function $(id) { + return document.getElementById(id); + } + + async function apiJson(url, opts) { + const res = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); + const data = await res.json().catch(function () { + return {}; + }); + if (!res.ok) throw new Error(data.msg || res.statusText || "请求失败"); + return data; + } + + function fmt(v, d) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(d == null ? 2 : d); + } + + function fmtOptionPx(v, tickSz) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const tick = Number(tickSz); + if (!tickSz || Number.isNaN(tick) || tick <= 0) { + return String(n).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, ""); + } + let decimals = 0; + if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick))); + else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length; + let s = n.toFixed(decimals); + // 仅裁小数尾零;整数 tick(BTC=5)时绝不能把 1370 裁成 137 + if (decimals > 0) s = s.replace(/\.?0+$/, ""); + return s || "0"; + } + + /** 价格/流动性(张),价格按 tick_sz 对齐交易所精度. */ + function fmtPxSz(px, sz, estimated, tickSz) { + if (px === null || px === undefined || Number.isNaN(Number(px))) return "—"; + let price = fmtOptionPx(px, tickSz); + if (price === "—") return "—"; + if (estimated) price += "~"; + if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price; + const s = Number(sz); + const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); + return price + "/" + size; + } + + function moneynessBadge(c) { + const m = (c && c.moneyness) || ""; + const label = (c && c.moneyness_label) || "—"; + return '' + label + ""; + } + + function pnlClass(v) { + if (v == null || v === "") return ""; + const n = Number(v); + if (Number.isNaN(n)) return ""; + return n >= 0 ? "hp-pnl-pos" : "hp-pnl-neg"; + } + + function fmtPnlHtml(v, digits) { + if (v == null || v === "" || Number.isNaN(Number(v))) return "—"; + const cls = pnlClass(v); + return '' + fmt(v, digits) + ""; + } + + function fmtRr(v) { + if (v == null || Number.isNaN(Number(v))) return "—"; + return fmt(v, 2) + ":1"; + } + + /** 列表/盯盘候选杠杆门槛(与后端 effective_min_opt_leverage 对齐). */ + function optionPrimaryMinLev() { + const minLev = numInput("hp-opt-leverage", opMoneyKind() === "otm" ? 200 : 100); + if (!(minLev > 0)) return 0; + if (opMoneyKind() === "otm") return Math.max(minLev, 180); + return minLev; + } + + function optionPrimaryLevOk(c) { + const idx = indexPx(); + const ask = Number(c && c.ask); + const floor = optionPrimaryMinLev(); + if (!(floor > 0)) return true; + if (!(idx > 0) || !(ask > 0)) return false; + return idx / ask >= floor - 1e-9; + } + + function matchesMoneyFilter(c) { + const f = state.moneyFilter || "itm"; + const m = (c.moneyness || "").toLowerCase(); + if (!isOptionPrimary() && f === "otm") return false; + // 列表:间隔+虚实值+杠杆门槛(达标才显示;启动盯盘后监控同样门槛) + if (isOptionPrimary()) { + const idx = indexPx(); + const interval = numInput("hp-strike-interval", 15); + if (idx && interval > 0 && Math.abs(Number(c.strike) - idx) > interval + 1e-9) { + return false; + } + if (!optionPrimaryLevOk(c)) return false; + } + if (f === "itm") return m === "itm" || m === "atm"; + if (f === "atm") return m === "atm"; + if (f === "otm") return m === "otm"; + return m === "itm" || m === "atm"; + } + + function matchesOoMoneyFilter(c) { + // 期期:仅平值/虚值(禁实值) + if (!c) return false; + const m = (c.moneyness || "").toLowerCase(); + const f = state.ooMoneyFilter || "atm_otm"; + if (f === "atm") return m === "atm"; + if (f === "otm") return m === "otm"; + return m === "atm" || m === "otm"; + } + + function indexPx() { + const fromChain = state.chain && Number(state.chain.index_px); + if (fromChain && !Number.isNaN(fromChain) && fromChain > 0) return fromChain; + const fromMkt = state.market && Number(state.market.index_px || state.market.mark); + if (fromMkt && !Number.isNaN(fromMkt) && fromMkt > 0) return fromMkt; + return null; + } + + function currentContracts(expSelectId) { + const exp = currentExp(expSelectId); + return (exp && exp.contracts) || []; + } + + function pickClosestItmAtm(contracts, want) { + const idx = indexPx(); + if (!idx) return null; + const list = (contracts || []).filter(function (c) { + return ( + String(c.opt_type || "").toUpperCase() === want && + matchesMoneyFilter(c) + ); + }); + if (!list.length) return null; + list.sort(function (a, b) { + return Math.abs(Number(a.strike) - idx) - Math.abs(Number(b.strike) - idx); + }); + return list[0]; + } + + function pickOoTemplate(template) { + const idx = indexPx(); + const contracts = currentContracts("hp-oo-exp-select"); + if (!idx || !contracts.length) return null; + const preferOtm = template === "double_otm"; + function pickSide(want) { + const list = contracts.filter(function (c) { + if (String(c.opt_type || "").toUpperCase() !== want) return false; + if (!matchesOoMoneyFilter(c)) return false; + const m = (c.moneyness || "").toLowerCase(); + if (preferOtm) return m === "otm"; + return m === "atm" || m === "otm"; + }); + if (!list.length) return null; + list.sort(function (a, b) { + const ma = (a.moneyness || "").toLowerCase(); + const mb = (b.moneyness || "").toLowerCase(); + if (!preferOtm) { + if (ma === "atm" && mb !== "atm") return -1; + if (mb === "atm" && ma !== "atm") return 1; + } + return Math.abs(Number(a.strike) - idx) - Math.abs(Number(b.strike) - idx); + }); + return list[0]; + } + const call = pickSide("C"); + const put = pickSide("P"); + if (!call || !put) return null; + return { call: call, put: put }; + } + + function isOptionPrimary() { + return !!state.optionPrimary; + } + + function optTypeForDirection(dir) { + if (isOptionPrimary()) { + return dir === "short" ? "P" : "C"; + } + return dir === "short" ? "C" : "P"; + } + + function opMoneyKind() { + const f = state.moneyFilter || "itm"; + if (f === "otm") return "otm"; + if (f === "atm") return "atm"; + return "itm"; + } + + function applyOpDefaultsFromMoney(force) { + const kind = opMoneyKind(); + const levEl = $("hp-opt-leverage"); + const ratioEl = $("hp-opt-perp-ratio"); + if (levEl && (force || !state.opLevTouched)) { + levEl.value = kind === "otm" ? "200" : "100"; + } + if (ratioEl && (force || !state.opRatioTouched)) { + ratioEl.value = kind === "otm" ? "4" : "2"; + } + } + + function syncOptionPrimaryUI() { + const on = isOptionPrimary(); + const ins = $("hp-po-fields-insurance"); + const op = $("hp-po-fields-option-primary"); + // 保险模式只显示开仓价/张数/止盈止损;以期权为主显示三组参数(env 切换,页内不可改) + if (ins) { + ins.classList.toggle("hidden", on); + if (on) ins.setAttribute("hidden", "hidden"); + else ins.removeAttribute("hidden"); + ins.style.display = on ? "none" : ""; + } + if (op) { + op.classList.toggle("hidden", !on); + if (!on) op.setAttribute("hidden", "hidden"); + else op.removeAttribute("hidden"); + op.style.display = on ? "" : "none"; + } + const insMoney = $("hp-po-ins-money"); + if (insMoney) { + if (on) { + insMoney.setAttribute("hidden", "hidden"); + insMoney.classList.add("hidden"); + } else { + insMoney.removeAttribute("hidden"); + insMoney.classList.remove("hidden"); + } + } + document.querySelectorAll(".hp-money-otm").forEach(function (otmBtn) { + otmBtn.classList.toggle("hidden", !on); + if (!on) otmBtn.setAttribute("hidden", "hidden"); + else otmBtn.removeAttribute("hidden"); + }); + if (!on && state.moneyFilter === "otm") { + state.moneyFilter = "itm"; + syncMoneyUI(); + } + if (on) applyOpDefaultsFromMoney(false); + const badge = $("hp-po-mode-badge"); + if (badge) { + badge.textContent = on ? "以期权为主" : "保险模式"; + badge.classList.toggle("is-insurance", !on); + } + const title = $("hp-po-card-title"); + if (title) title.textContent = "执行参数"; + const dirLong = document.querySelector('.hp-po-dir[data-dir="long"]'); + const dirShort = document.querySelector('.hp-po-dir[data-dir="short"]'); + if (dirLong) dirLong.title = on ? "做多=买Call+永续空" : "做多永续"; + if (dirShort) dirShort.title = on ? "做空=买Put+永续多" : "做空永续"; + syncPoActionBtn(); + } + + function hoursFromExpMs(expMs) { + const n = Number(expMs); + if (!n || Number.isNaN(n)) return null; + const ms = n < 1e12 ? n * 1000 : n; + return (ms - Date.now()) / 3600000; + } + + function numInput(id, fallback) { + const el = $(id); + const n = Number(el && el.value); + if (Number.isNaN(n)) return fallback; + return n; + } + + function computeOpSizing(ask, ctMult) { + const budget = numInput("hp-premium-budget", 0); + const ratio = numInput("hp-opt-perp-ratio", 2); + const cs = Number((state.market && state.market.contract_size) || 0.01); + const usable = budget * 0.95; + const a = Number(ask || 0); + const ct = Number(ctMult || 0.01); + if (!(budget > 0) || !(a > 0) || !(ct > 0) || !(ratio > 0) || !(cs > 0)) { + return null; + } + let eth = Math.floor((usable / a) * 100 + 1e-12) / 100; + if (!(eth > 0)) return null; + let sheets = Math.floor(eth / ct + 1e-12); + if (!(sheets > 0)) return null; + eth = Math.round(sheets * ct * 100) / 100; + const perpEth = eth / ratio; + const contracts = perpEth / cs; + return { + usable: usable, + eth_qty: eth, + sheets: sheets, + contracts: contracts, + premium_est: a * sheets * ct, + ratio: ratio, + }; + } + + function syncUnderlyingUI() { + const uly = state.underlying || "ETH"; + document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) { + const on = b.getAttribute("data-uly") === uly; + b.classList.toggle("active", on); + b.setAttribute("aria-pressed", on ? "true" : "false"); + }); + const lab = $("hp-perp-uly-label"); + if (lab) lab.textContent = uly; + const ooLab = $("hp-oo-uly-label"); + if (ooLab) ooLab.textContent = uly; + } + + function syncMoneyUI() { + const moneySel = $("hp-money-select"); + if (moneySel && isOptionPrimary()) { + moneySel.value = state.moneyFilter === "otm" ? "otm" : state.moneyFilter === "atm" ? "atm" : "itm"; + } + document.querySelectorAll(".hp-money-btn").forEach(function (b) { + const on = b.getAttribute("data-money") === state.moneyFilter; + b.classList.toggle("active", on); + b.classList.toggle("is-selected", on); + b.setAttribute("aria-pressed", on ? "true" : "false"); + }); + document.querySelectorAll(".hp-oo-money-btn").forEach(function (b) { + const on = b.getAttribute("data-oo-money") === state.ooMoneyFilter; + b.classList.toggle("active", on); + b.classList.toggle("is-selected", on); + b.setAttribute("aria-pressed", on ? "true" : "false"); + }); + } + + function syncPoActionBtn() { + const btn = $("hp-preview-btn"); + if (!btn) return; + if (isOptionPrimary()) { + btn.textContent = "策略启动"; + btn.title = "按参数启动盯盘;杠杆/间隔达标后自动开仓(非现场开)"; + } else { + btn.textContent = "计算"; + btn.title = "情景测算后再启动"; + } + } + + function isPoOptionPrimaryPlan(p) { + if (!p || p.plan_type !== "perp_options") return false; + return p.option_primary == 1 || p.option_primary === true || Number(p.option_primary) === 1; + } + + function setPoStrategyStatus(kind, planId) { + const el = $("hp-po-strategy-status"); + if (!el) return; + el.classList.remove("is-watching", "is-holding", "is-idle"); + if (!isOptionPrimary()) { + el.textContent = ""; + return; + } + const idPart = planId ? " #" + planId : ""; + if (kind === "watching") { + el.textContent = "盯盘中" + idPart; + el.classList.add("is-watching"); + } else if (kind === "holding") { + el.textContent = "持仓中" + idPart; + el.classList.add("is-holding"); + } else { + el.textContent = "未启动"; + el.classList.add("is-idle"); + } + } + + async function refreshPoStrategyStatus() { + const el = $("hp-po-strategy-status"); + if (!el) return; + if (!isOptionPrimary()) { + setPoStrategyStatus("", null); + return; + } + try { + const d = await apiJson("/api/hedge-plan/active"); + const uly = (state.underlying || "ETH").toUpperCase(); + const rows = (d.plans || []).filter(function (p) { + return isPoOptionPrimaryPlan(p) && String(p.underlying || "").toUpperCase() === uly; + }); + if (!rows.length) { + setPoStrategyStatus("idle", null); + return; + } + rows.sort(function (a, b) { + return Number(b.id || 0) - Number(a.id || 0); + }); + const p = rows[0]; + const st = String(p.status || ""); + if (st === "watching") setPoStrategyStatus("watching", p.id); + else if (st === "active" || st === "opening" || st === "partial") setPoStrategyStatus("holding", p.id); + else setPoStrategyStatus("idle", null); + } catch (e) { + /* ignore status refresh errors */ + } + } + + function syncOoRecommendUI() { + const cur = state.ooRecommend || ""; + document.querySelectorAll(".hp-oo-recommend-btn").forEach(function (b) { + const on = (b.getAttribute("data-oo-rec") || "") === cur; + b.classList.toggle("active", on); + b.classList.toggle("is-selected", on); + b.setAttribute("aria-pressed", on ? "true" : "false"); + }); + } + + function syncOoExpandUI() { + const btn = $("hp-oo-expand-all"); + if (!btn) return; + const on = !!state.ooStrikeExpandAll; + btn.classList.toggle("active", on); + btn.classList.toggle("is-selected", on); + btn.setAttribute("aria-pressed", on ? "true" : "false"); + } + + function calcStrikeAskLeverage(strike, ask) { + const k = Number(strike); + const a = Number(ask); + if (!Number.isFinite(k) || k <= 0 || !Number.isFinite(a) || a <= 0) return "—"; + return (Math.round((k / a) * 10) / 10).toFixed(1) + "×"; + } + + /** 永期期权行情杠杆:指数÷卖一(与选约杠杆门一致). */ + function calcIndexAskLeverage(ask) { + const idx = indexPx(); + const a = Number(ask); + if (!(idx > 0) || !(a > 0)) return "—"; + return (Math.round((idx / a) * 10) / 10).toFixed(1) + "×"; + } + + function clearPoSelection() { + state.selected = null; + if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; + updatePremiumLine(); + updatePerpPnlHint(); + } + + /** 以期权为主:按类型/间隔自动匹配最近合约. */ + function autoMatchPoOption(force) { + if (!isOptionPrimary()) return; + const want = optTypeForDirection(getDirection()); + const sel = state.selected; + const stillOk = + !force && + sel && + String(sel.opt_type || "").toUpperCase() === want && + matchesMoneyFilter(sel); + if (stillOk) return; + const c = pickClosestItmAtm(currentContracts("hp-exp-select"), want); + if (c) pickContract(c); + else clearPoSelection(); + } + + function findAtmStrikeFromRows(rows, idx) { + if (!rows.length) return null; + if (idx == null || Number.isNaN(Number(idx))) return rows[0].strike; + let best = rows[0].strike; + let bestDist = Math.abs(Number(rows[0].strike) - Number(idx)); + rows.forEach(function (row) { + const d = Math.abs(Number(row.strike) - Number(idx)); + if (d < bestDist || (d === bestDist && Number(row.strike) < Number(best))) { + bestDist = d; + best = row.strike; + } + }); + return best; + } + + function sliceOoTRows(prepared, idx) { + // prepared: [{strike, call, put, callOk, putOk}] + if (state.ooStrikeExpandAll || !prepared.length) return prepared; + const n = 3; + const anchor = Number(idx) || Number(findAtmStrikeFromRows(prepared, idx)) || 0; + function nearest(list, count) { + return list + .slice() + .sort(function (a, b) { + return Math.abs(Number(a.strike) - anchor) - Math.abs(Number(b.strike) - anchor); + }) + .slice(0, count); + } + const byStrike = {}; + nearest( + prepared.filter(function (r) { + return r.callOk; + }), + n + ).forEach(function (r) { + byStrike[String(r.strike)] = r; + }); + nearest( + prepared.filter(function (r) { + return r.putOk; + }), + n + ).forEach(function (r) { + byStrike[String(r.strike)] = r; + }); + // 已选用腿始终保留,避免折叠后看不到选中项 + [state.legA, state.legB].forEach(function (leg) { + if (!leg) return; + const hit = prepared.find(function (r) { + return ( + (r.call && r.call.inst_id === leg.inst_id) || + (r.put && r.put.inst_id === leg.inst_id) + ); + }); + if (hit) byStrike[String(hit.strike)] = hit; + }); + return Object.keys(byStrike) + .map(function (k) { + return byStrike[k]; + }) + .sort(function (a, b) { + return Number(a.strike) - Number(b.strike); + }); + } + + function syncTabUI() { + let tab = state.tab || pickDefaultTab(); + if (tab === "perp_options" && !showPerp) tab = pickDefaultTab(); + if (tab === "options_options" && !showOo) tab = pickDefaultTab(); + state.tab = tab; + document.querySelectorAll(".hp-tab").forEach(function (b) { + const on = b.getAttribute("data-tab") === tab; + b.classList.toggle("active", on); + b.setAttribute("aria-selected", on ? "true" : "false"); + }); + ["perp_options", "options_options", "active", "history", "stats"].forEach(function (id) { + const panel = $("hp-tab-" + id); + if (!panel) return; + const on = id === tab; + panel.classList.toggle("hidden", !on); + if (on) panel.removeAttribute("hidden"); + else panel.setAttribute("hidden", ""); + }); + if (tab === "perp_options" || tab === "options_options") { + state.mode = tab; + } + const hint = $("hp-acct-hint"); + if (hint) { + if (tab === "options_options") { + hint.textContent = "期期双腿→期权账户"; + } else if (tab === "perp_options") { + hint.textContent = "永续腿→合约账户 · 期权腿→期权账户"; + } else { + hint.textContent = "进行中/历史含永期与期期;显示开关只影响新建测算 Tab"; + } + } + } + + function setGateLine(gates) { + const el = $("hp-gate-line"); + if (!el) return; + if (!gates) { + el.textContent = ""; + return; + } + const parts = [ + "计仓:" + (gates.is_full_margin ? "全仓" : "非全仓"), + "测算:" + (gates.can_preview ? "可" : "否"), + "开仓:" + (gates.can_start ? "可" : "否"), + ]; + if (gates.reasons && gates.reasons.length) parts.push(gates.reasons.join("; ")); + el.textContent = parts.join(" · "); + state.canStart = !!gates.can_start; + syncPreviewStartBtn(); + } + + function syncPreviewStartBtn() { + const start = $("hp-preview-start"); + if (!start) return; + start.disabled = !(state.previewOk && state.canStart); + } + + function openPreviewModal() { + const modal = $("hp-preview-modal"); + if (modal) modal.hidden = false; + } + + function closePreviewModal() { + const modal = $("hp-preview-modal"); + if (modal) modal.hidden = true; + } + + function applyBudgetBuffer(raw) { + if (raw == null || raw === "") return; + const buf = Number(raw); + if (Number.isNaN(buf) || buf <= 0) return; + state.budgetBuffer = buf; + const el = $("hp-oo-buf-ratio"); + if (el) el.textContent = fmt(buf, 2); + } + + function setOptionsBalance(chain) { + const acct = (chain && chain.options_account) || {}; + const label = (chain && chain.account_label) || acct.label || "期权账户"; + const tag = $("hp-opt-acct-tag"); + if (tag) tag.textContent = label; + if (acct.trading_usdc != null && acct.trading_usdc !== "") { + state.tradingUsdc = Number(acct.trading_usdc); + } + if (acct.funding_usdc != null && acct.funding_usdc !== "") { + state.fundingUsdc = Number(acct.funding_usdc); + } + if (chain && chain.trade_budget_usdc != null && chain.trade_budget_usdc !== "") { + state.tradeBudgetUsdc = Number(chain.trade_budget_usdc); + } + if (chain && chain.budget_buffer != null && chain.budget_buffer !== "") { + applyBudgetBuffer(chain.budget_buffer); + } + const line = + label + + " · 交易 USDC " + + fmt(acct.trading_usdc, 2) + + " · 资金 USDC " + + fmt(acct.funding_usdc, 2); + const el = $("hp-opt-bal-line"); + if (el) el.textContent = line; + const fundEl = $("hp-oo-funding-usdc"); + const tradeEl = $("hp-oo-trading-usdc"); + if (fundEl) fundEl.textContent = fmt(acct.funding_usdc, 2); + if (tradeEl) tradeEl.textContent = fmt(acct.trading_usdc, 2); + autoFillOoSheets(); + } + + function setOoXferMsg(text, kind) { + const el = $("hp-oo-xfer-msg"); + if (!el) return; + el.textContent = text || ""; + el.classList.toggle("is-err", kind === "err"); + el.classList.toggle("is-ok", kind === "ok"); + el.classList.toggle("muted", !kind); + } + + function ooXferDir() { + const sel = $("hp-oo-xfer-dir"); + return (sel && sel.value) || "funding_to_trading"; + } + + async function submitOoUsdcTransfer(amount) { + const dir = ooXferDir(); + const from = dir === "trading_to_funding" ? "trading" : "funding"; + const to = dir === "trading_to_funding" ? "funding" : "trading"; + setOoXferMsg("划转中…", null); + try { + const d = await apiJson("/api/options/transfer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ccy: "USDC", from: from, to: to, amount: amount }), + }); + if (!d.ok) { + setOoXferMsg(d.msg || "划转失败", "err"); + return; + } + setOoXferMsg("划转成功", "ok"); + if ($("hp-oo-xfer-amount")) $("hp-oo-xfer-amount").value = ""; + await loadChain(); + } catch (e) { + setOoXferMsg(e.message || "划转失败", "err"); + } + } + + function resolveOoBudget() { + const buf = state.budgetBuffer > 0 ? state.budgetBuffer : 0.95; + const trading = state.tradingUsdc; + const cap = state.tradeBudgetUsdc; + let tradingCap = null; + let tradeCap = null; + if (trading != null && !Number.isNaN(Number(trading))) { + tradingCap = Math.max(0, Number(trading) * buf); + } + if (cap != null && !Number.isNaN(Number(cap))) { + tradeCap = Math.max(0, Number(cap)); + } + if (tradingCap == null && tradeCap == null) { + return { ok: false, budget: 0, tradingCap: null, tradeCap: null, buf: buf, msg: "缺少交易户余额与单笔预算" }; + } + let budget = 0; + if (tradingCap == null) budget = tradeCap; + else if (tradeCap == null) budget = tradingCap; + else budget = Math.min(tradingCap, tradeCap); + budget = Math.floor(budget * 1e6 + 1e-12) / 1e6; + return { + ok: budget > 0, + budget: budget, + tradingCap: tradingCap, + tradeCap: tradeCap, + buf: buf, + msg: budget > 0 ? "" : "可用预算为 0", + }; + } + + function unitCost(c) { + if (!c) return 0; + const ask = Number(c.ask || 0); + if (!(ask > 0)) return 0; + return ask * Number(c.ct_mult || 0.01); + } + + function capByDepth(n, askSz) { + let out = Math.max(0, Math.floor(Number(n) || 0)); + if (askSz == null || askSz === "") return out; + const d = Number(askSz); + if (Number.isNaN(d)) return out; + if (d <= 0) return 0; + return Math.min(out, Math.floor(d + 1e-12)); + } + + function normalizeOptCP(ot) { + const u = String(ot || "").toUpperCase(); + if (u.indexOf("C") === 0) return "C"; + if (u.indexOf("P") === 0) return "P"; + return ""; + } + + function ooSizeModeLabel(mode) { + if (mode === "long_bias") return "做多"; + if (mode === "short_bias") return "做空"; + if (mode === "split_budget") return "均分"; + return "同张数"; + } + + function suggestOoSheetsLocal(budget, mode) { + const costA = unitCost(state.legA); + const costB = unitCost(state.legB); + if (!(budget > 0)) { + return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "可用预算为 0" }; + } + if (!(costA > 0) || !(costB > 0)) { + return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "缺少有效卖一价,无法建议张数" }; + } + let ratio = Number(state.ooBiasRatio); + if (!(ratio > 0) || !(ratio < 1)) ratio = 0.7; + const splitBy = state.ooBiasSplitBy === "sheets" ? "sheets" : "budget"; + const pair = costA + costB; + const nPair = pair > 0 ? Math.floor(budget / pair + 1e-12) : 0; + const nSame = Math.min( + capByDepth(nPair, state.legA && state.legA.ask_sz), + capByDepth(nPair, state.legB && state.legB.ask_sz) + ); + + let nA = 0; + let nB = 0; + if (mode === "long_bias" || mode === "short_bias") { + const aCP = normalizeOptCP(state.legA && state.legA.opt_type); + const bCP = normalizeOptCP(state.legB && state.legB.opt_type); + if (!((aCP === "C" && bCP === "P") || (aCP === "P" && bCP === "C"))) { + return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "做多/做空需一腿 Call、一腿 Put" }; + } + const callIsA = aCP === "C"; + const majorIsCall = mode === "long_bias"; + let nCall = 0; + let nPut = 0; + if (splitBy === "sheets") { + // 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆 + const total = nSame * 2; + if (total < 2) { + return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "同张数总规模不足 2,无法按比例拆分" }; + } + let majorN = Math.round(total * ratio); + majorN = Math.max(1, Math.min(majorN, total - 1)); + const minorN = total - majorN; + nCall = majorIsCall ? majorN : minorN; + nPut = majorIsCall ? minorN : majorN; + } else { + const majBudget = budget * ratio; + const minBudget = budget * (1 - ratio); + const costCall = callIsA ? costA : costB; + const costPut = callIsA ? costB : costA; + if (majorIsCall) { + nCall = Math.floor(majBudget / costCall + 1e-12); + nPut = Math.floor(minBudget / costPut + 1e-12); + } else { + nPut = Math.floor(majBudget / costPut + 1e-12); + nCall = Math.floor(minBudget / costCall + 1e-12); + } + } + nA = callIsA ? nCall : nPut; + nB = callIsA ? nPut : nCall; + nA = capByDepth(nA, state.legA && state.legA.ask_sz); + nB = capByDepth(nB, state.legB && state.legB.ask_sz); + } else if (mode === "split_budget") { + const half = budget / 2; + nA = Math.floor(half / costA + 1e-12); + nB = Math.floor(half / costB + 1e-12); + nA = capByDepth(nA, state.legA && state.legA.ask_sz); + nB = capByDepth(nB, state.legB && state.legB.ask_sz); + } else { + nA = nSame; + nB = nSame; + } + const premium = costA * nA + costB * nB; + const ok = nA >= 1 && nB >= 1; + return { + sheetsA: nA, + sheetsB: nB, + premium: premium, + ok: ok, + msg: ok ? "" : "预算不够开 1+1(或卖一深度不足)", + }; + } + + function syncOoSizeModeUI() { + document.querySelectorAll(".hp-oo-size-mode").forEach(function (b) { + const on = b.getAttribute("data-oo-size") === state.ooSheetsMode; + b.classList.toggle("active", on); + b.classList.toggle("is-selected", on); + b.setAttribute("aria-pressed", on ? "true" : "false"); + }); + } + + function syncOoCloseModeUI() { + const enabled = !!state.ooCloseModeEnabled; + const row = $("hp-oo-close-mode-row"); + if (row) row.classList.toggle("hidden", !enabled); + if (!enabled) state.ooCloseMode = "hold_expiry"; + document.querySelectorAll(".hp-oo-close-mode").forEach(function (b) { + const on = b.getAttribute("data-oo-close") === state.ooCloseMode; + b.classList.toggle("active", on); + b.classList.toggle("is-selected", on); + b.setAttribute("aria-pressed", on ? "true" : "false"); + }); + } + + function updateOoBudgetLine(extra) { + const line = $("hp-oo-budget-line"); + if (!line) return; + const b = resolveOoBudget(); + const sizeLabel = ooSizeModeLabel(state.ooSheetsMode); + const closeLabel = !state.ooCloseModeEnabled + ? "" + : state.ooCloseMode === "hold_expiry" + ? "到期平" + : "全平"; + const parts = ["可用 " + fmt(b.budget, 2) + "U", sizeLabel]; + if (closeLabel) parts.push(closeLabel); + if (extra && extra.msg) parts.push(extra.msg); + else if (b.msg) parts.push(b.msg); + line.textContent = parts.join(" · "); + line.title = + "对冲预算=min(交易×" + + fmt(b.buf, 2) + + ", 单笔) · 缓冲 HEDGE_PLAN_BUDGET_BUFFER" + + (b.tradingCap != null ? " · 交易×缓冲 " + fmt(b.tradingCap, 2) : "") + + (b.tradeCap != null ? " · 单笔 " + fmt(b.tradeCap, 2) : ""); + line.classList.toggle("hp-oo-budget-warn", !!(extra && extra.msg) || !b.ok); + } + + function autoFillOoSheets() { + syncOoSizeModeUI(); + if (!state.legA || !state.legB) { + updateOoBudgetLine(null); + return; + } + const b = resolveOoBudget(); + const sug = suggestOoSheetsLocal(b.budget, state.ooSheetsMode); + const a = $("hp-oo-sheets-a"); + const bb = $("hp-oo-sheets-b"); + if (a && !a.disabled) a.value = String(sug.sheetsA); + if (bb && !bb.disabled) bb.value = String(sug.sheetsB); + updateOoBudgetLine(sug.ok ? null : sug); + updateOoPremiumLine(); + } + + async function loadGates() { + try { + const d = await apiJson("/api/hedge-plan/gates?plan_type=" + encodeURIComponent(state.mode)); + if (d.oo_close_mode_enabled != null) { + state.ooCloseModeEnabled = !!d.oo_close_mode_enabled; + } + if (!state.ooCloseModeEnabled) { + state.ooCloseMode = "hold_expiry"; + } else if (d.oo_close_mode_default && !state._ooCloseModeTouched) { + state.ooCloseMode = d.oo_close_mode_default === "hold_expiry" ? "hold_expiry" : "close_all"; + } + if (d.oo_bias_split_by != null) { + state.ooBiasSplitBy = d.oo_bias_split_by === "sheets" ? "sheets" : "budget"; + } + if (d.oo_bias_ratio != null && Number(d.oo_bias_ratio) > 0 && Number(d.oo_bias_ratio) < 1) { + state.ooBiasRatio = Number(d.oo_bias_ratio); + } + if (d.budget_buffer != null) applyBudgetBuffer(d.budget_buffer); + syncOoCloseModeUI(); + setGateLine(d); + if (state.mode === "options_options") autoFillOoSheets(); + } catch (e) { + setGateLine({ can_preview: false, can_start: false, reasons: [e.message], is_full_margin: false }); + } + } + + async function loadMarket() { + const dir = getDirection(); + const d = await apiJson( + "/api/hedge-plan/market?base=" + + encodeURIComponent(state.underlying) + + "&direction=" + + encodeURIComponent(dir) + + "&option_primary=" + + (isOptionPrimary() ? "1" : "0") + ); + state.market = d; + setGateLine(d.gates); + const acctLabel = d.account_label || "合约账户"; + const tag = $("hp-perp-acct-tag"); + if (tag) tag.textContent = acctLabel; + const amtPrec = d.amount_precision != null ? Number(d.amount_precision) : 4; + const markEl = $("hp-po-mark"); + if (markEl) markEl.textContent = "标记 " + fmt(d.mark, 2); + const quoteHtml = + "可用 " + + fmt(d.available_usdt, 2) + + " USDT · 卖一 " + + fmt(d.ask, 2) + + " · 买一 " + + fmt(d.bid, 2) + + " · 面值 " + + fmt(d.contract_size, 4) + + " · 精度 " + + amtPrec + + " 位" + + (d.perp_direction + ? " · 永续方向 " + (d.perp_direction === "short" ? "空" : "多") + : ""); + const q = $("hp-perp-quote"); + if (q) q.innerHTML = quoteHtml; + const contractsInput = $("hp-contracts"); + if (contractsInput) { + const step = amtPrec <= 0 ? "1" : String(Math.pow(10, -amtPrec)); + contractsInput.step = step; + } + const sz = $("hp-sizing-line"); + if (sz) { + if (isOptionPrimary()) { + const sized = + state.selected && + computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); + if (sized) { + sz.innerHTML = + "执行预算 " + + fmt(sized.usable, 2) + + " · 期权 ETH " + + fmt(sized.eth_qty, 2) + + " / " + + sized.sheets + + " 张 · 永续 " + + fmt(sized.contracts, amtPrec) + + " 张"; + } else { + sz.textContent = "填写权利金并选用期权后显示定仓(权利金×0.95,ETH两位小数)"; + } + } else if (d.full_margin_sizing) { + const s = d.full_margin_sizing; + sz.innerHTML = + "全仓建议 " + + fmt(d.suggest_contracts, amtPrec) + + " 张 · 保证金 " + + fmt(s.margin_capital, 2) + + " × " + + s.leverage + + "x · 名义 " + + fmt(s.notional_value, 2) + + " USDT"; + } else { + sz.textContent = "非全仓时请手动填张数;永期开仓需全仓"; + } + } + const entry = $("hp-entry"); + if (!isOptionPrimary() && entry && d.entry_ref && !entry.value) entry.value = d.entry_ref; + if (!isOptionPrimary() && contractsInput && d.suggest_contracts != null && !contractsInput.value) { + contractsInput.value = fmt(d.suggest_contracts, amtPrec); + } + const label = $("hp-opt-type-label"); + if (label) label.textContent = d.suggested_opt_type === "C" ? "Call" : "Put"; + updatePerpPnlHint(); + } + + function updatePerpPnlHint() { + const el = $("hp-perp-pnl-line"); + if (!el) return; + if (isOptionPrimary()) { + const n = numInput("hp-opt-target-pts", NaN); + const m = numInput("hp-perp-target-pts", NaN); + const k = state.selected && Number(state.selected.strike); + if (!(k > 0) || (!(n >= 0) && !(m >= 0))) { + el.innerHTML = '选用期权并填目标点数后显示 K±N 出场参考'; + return; + } + const dir = getDirection(); + const optT = n >= 0 ? (dir === "short" ? k - n : k + n) : null; + const perpT = m >= 0 ? (dir === "short" ? k - m : k + m) : null; + el.innerHTML = + "期权目标指数 " + + (optT != null ? fmt(optT, 2) : "—") + + " · 永续目标指数 " + + (perpT != null ? fmt(perpT, 2) : "—") + + ' (相对K;期权目标需买一且扣费净利>0)'; + return; + } + const entry = Number(($("hp-entry") && $("hp-entry").value) || NaN); + const tp = Number(($("hp-tp") && $("hp-tp").value) || NaN); + const sl = Number(($("hp-sl") && $("hp-sl").value) || NaN); + const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || NaN); + const cs = Number((state.market && state.market.contract_size) || 0.01); + const dir = getDirection(); + if (!(entry > 0) || !(contracts > 0) || !(cs > 0)) { + el.innerHTML = '填开仓价与张数后,输入止盈/止损可看盈亏'; + return; + } + function pnlAt(exitPx) { + const coins = contracts * cs; + if (dir === "short") return (entry - exitPx) * coins; + return (exitPx - entry) * coins; + } + function chip(lab, px) { + if (!(px > 0)) { + return '' + lab + " —"; + } + const p = pnlAt(px); + const cls = p >= 0 ? "hp-pnl-pos" : "hp-pnl-neg"; + const sign = p >= 0 ? "+" : ""; + return ( + '' + + lab + + ' ' + + sign + + fmt(p, 2) + + "" + ); + } + el.innerHTML = chip("止盈", tp) + chip("止损", sl); + } + + function fillExpSelect(sel, chain) { + if (!sel) return; + const prev = sel.value; + const isPoSel = sel.id === "hp-exp-select"; + // 永期以期权为主:到期下拉按「最低剩余小时」过滤;期期下拉不过滤,保证明天到期可见 + const minH = isPoSel && isOptionPrimary() ? numInput("hp-min-hours", 36) : 0; + sel.innerHTML = ''; + let firstOk = null; + let skippedNear = 0; + (chain.expiries || []).forEach(function (e) { + const h = hoursFromExpMs(e.exp_time); + if (minH > 0 && h != null && h < minH) { + skippedNear += 1; + return; + } + const opt = document.createElement("option"); + opt.value = String(e.exp_time); + const dt = new Date(Number(e.exp_time) < 1e12 ? Number(e.exp_time) * 1000 : Number(e.exp_time)); + opt.textContent = dt.toLocaleString() + (h != null ? " · " + fmt(h, 1) + "h" : ""); + sel.appendChild(opt); + if (!firstOk) firstOk = e; + }); + if (prev) sel.value = prev; + if (!sel.value && firstOk) { + sel.value = String(firstOk.exp_time); + } + if (isPoSel && skippedNear > 0 && minH > 0) { + const tip = document.createElement("option"); + tip.disabled = true; + tip.textContent = "(已隐藏 " + skippedNear + " 个不足 " + minH + "h 的到期 · 可改左侧最低剩余小时)"; + sel.appendChild(tip); + } + } + + async function loadChain() { + // 拉完整链(按 CHAIN_MAX_DTE);永期「最低剩余小时」只在左侧到期下拉里过滤,不影响期期看到明天到期 + const url = + "/api/hedge-plan/options-chain?underlying=" + encodeURIComponent(state.underlying); + const d = await apiJson(url); + state.chain = d; + const idx = $("hp-index-line"); + if (idx) idx.textContent = "指数 " + fmt(d.index_px, 2); + const ooIdx = $("hp-oo-index"); + if (ooIdx) ooIdx.textContent = "指数 " + fmt(d.index_px, 2); + setOptionsBalance(d); + fillExpSelect($("hp-exp-select"), d); + fillExpSelect($("hp-oo-exp-select"), d); + renderListStrikes(); + renderTStrikes(); + if (d.index_px) { + // 盈亏比默认2,不随指数自动改写 + if ($("hp-profit-rr") && !$("hp-profit-rr").value) { + $("hp-profit-rr").value = "2"; + } + } + } + + function currentExp(selectId) { + const sel = $(selectId); + const expMs = sel && sel.value; + if (!expMs || !state.chain) return null; + return (state.chain.expiries || []).find(function (e) { + return String(e.exp_time) === String(expMs); + }); + } + + function pickContract(c) { + if (!c) return; + const m = (c.moneyness || "").toLowerCase(); + if (!isOptionPrimary() && m === "otm") { + alert("永期保险腿须为实值或平值,不可选虚值"); + return; + } + if (isOptionPrimary() && !matchesMoneyFilter(c)) { + alert("不符合当前间隔/虚实值/杠杆门槛"); + return; + } + state.selected = c; + const el = $("hp-sel-inst"); + if (el) el.textContent = c.inst_id; + const tbody = $("hp-strike-tbody"); + if (tbody) { + tbody.querySelectorAll(".opt-strike-row").forEach(function (r) { + r.classList.toggle("opt-row-selected", r.getAttribute("data-inst") === c.inst_id); + }); + tbody.querySelectorAll(".hp-pick").forEach(function (b) { + b.classList.toggle("active", b.getAttribute("data-inst") === c.inst_id); + }); + } + updatePremiumLine(); + if (isOptionPrimary()) { + const sized = computeOpSizing(c.ask, c.ct_mult || 0.01); + if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets); + void loadMarket(); + updatePerpPnlHint(); + } + } + + function renderListStrikes() { + const tbody = $("hp-strike-tbody"); + if (!tbody) return; + const want = optTypeForDirection(getDirection()); + const exp = currentExp("hp-exp-select"); + const prevInst = state.selected && state.selected.inst_id; + tbody.innerHTML = ""; + if (!exp) { + tbody.innerHTML = '请选择到期日'; + if (isOptionPrimary()) clearPoSelection(); + return; + } + const sameType = (exp.contracts || []).filter(function (c) { + return String(c.opt_type || "").toUpperCase() === want; + }); + const list = sameType.filter(matchesMoneyFilter); + if (!list.length) { + const idx = indexPx(); + const interval = isOptionPrimary() ? numInput("hp-strike-interval", 15) : null; + let hint = "无匹配合约"; + if (isOptionPrimary() && sameType.length) { + hint = + "无匹配" + + (want === "C" ? "Call" : "Put") + + "(已滤 " + + sameType.length + + " 档)·检查间隔" + + (interval != null ? "≤" + interval : "") + + "点/虚实值/杠杆≥" + + optionPrimaryMinLev(); + } else if (!sameType.length) { + hint = + "该到期无 " + + (want === "C" ? "Call" : "Put") + + (idx ? " · 指数 " + fmt(idx, 2) : "") + + " · 可换到期或点刷新链"; + } + tbody.innerHTML = '' + hint + ""; + if (isOptionPrimary()) clearPoSelection(); + return; + } + list.forEach(function (c) { + const tr = document.createElement("tr"); + tr.className = "opt-strike-row" + (c.moneyness ? " opt-row-" + c.moneyness : ""); + if (prevInst && c.inst_id === prevInst) tr.classList.add("opt-row-selected"); + tr.setAttribute("data-inst", c.inst_id); + tr.innerHTML = + "" + + c.strike + + "" + + moneynessBadge(c) + + '' + + calcIndexAskLeverage(c.ask) + + '' + + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated, c.tick_sz) + + '' + + fmtPxSz(c.bid, c.bid_sz, false, c.tick_sz) + + ''; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".hp-pick").forEach(function (btn) { + btn.addEventListener("click", function () { + const inst = btn.getAttribute("data-inst"); + const c = list.find(function (x) { + return x.inst_id === inst; + }); + pickContract(c); + }); + }); + autoMatchPoOption(false); + } + + function updatePremiumLine() { + const line = $("hp-premium-line"); + if (!line || !state.selected) { + if (line) line.textContent = ""; + return; + } + const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); + const ct = Number(state.selected.ct_mult || 0.01); + const ask = Number(state.selected.ask || 0); + const prem = ask * sheets * ct; + line.textContent = "预估权利金 ≈ " + fmt(prem, 4) + " USDC(期权账户)"; + } + + function buildStraddleRows(contracts) { + const map = {}; + (contracts || []).forEach(function (c) { + const key = String(c.strike); + if (!map[key]) map[key] = { strike: c.strike, call: null, put: null }; + const o = (c.opt_type || "").toUpperCase(); + if (o === "C") map[key].call = c; + else if (o === "P") map[key].put = c; + }); + return Object.keys(map) + .map(function (k) { + return map[k]; + }) + .sort(function (a, b) { + return Number(a.strike) - Number(b.strike); + }); + } + + function selectedOoLegMap() { + const map = {}; + if (state.legA && state.legA.inst_id) map[String(state.legA.inst_id)] = "A"; + if (state.legB && state.legB.inst_id) map[String(state.legB.inst_id)] = "B"; + return map; + } + + function syncOoPickHighlight() { + const tbody = $("hp-oo-tbody"); + if (!tbody) return; + const map = selectedOoLegMap(); + tbody.querySelectorAll(".hp-oo-pick").forEach(function (btn) { + const inst = String(btn.getAttribute("data-inst") || ""); + const leg = map[inst]; + const on = !!leg; + btn.classList.toggle("is-selected", on); + btn.classList.toggle("active", on); + btn.setAttribute("aria-pressed", on ? "true" : "false"); + const side = String(btn.getAttribute("data-side") || "").toUpperCase(); + const base = side === "P" ? "Put" : "Call"; + btn.textContent = on ? "腿" + leg + " · " + base : base; + btn.title = on ? "已选用为腿" + leg + "(再点其他合约可改选)" : "选用此 " + base; + }); + tbody.querySelectorAll("tr").forEach(function (tr) { + const callBtn = tr.querySelector('.hp-oo-pick[data-side="C"]'); + const putBtn = tr.querySelector('.hp-oo-pick[data-side="P"]'); + const callOn = !!(callBtn && map[String(callBtn.getAttribute("data-inst") || "")]); + const putOn = !!(putBtn && map[String(putBtn.getAttribute("data-inst") || "")]); + tr.classList.toggle("hp-oo-row-selected", callOn || putOn); + tr.querySelectorAll("td.opt-t-call").forEach(function (td) { + td.classList.toggle("hp-oo-side-selected", callOn); + }); + if (callBtn && callBtn.parentElement) { + callBtn.parentElement.classList.toggle("hp-oo-side-selected", callOn); + } + tr.querySelectorAll("td.opt-t-put").forEach(function (td) { + td.classList.toggle("hp-oo-side-selected", putOn); + }); + if (putBtn && putBtn.parentElement) { + putBtn.parentElement.classList.toggle("hp-oo-side-selected", putOn); + } + }); + } + + function renderTStrikes() { + const tbody = $("hp-oo-tbody"); + if (!tbody) return; + const exp = currentExp("hp-oo-exp-select"); + const cols = 9; + tbody.innerHTML = ""; + if (!exp) { + tbody.innerHTML = '请选择到期日'; + return; + } + const prepared = []; + buildStraddleRows(exp.contracts).forEach(function (row) { + const callOk = !!(row.call && matchesOoMoneyFilter(row.call)); + const putOk = !!(row.put && matchesOoMoneyFilter(row.put)); + if (!callOk && !putOk) return; + prepared.push({ + strike: row.strike, + call: row.call, + put: row.put, + callOk: callOk, + putOk: putOk, + }); + }); + const rows = sliceOoTRows(prepared, indexPx()); + const selected = selectedOoLegMap(); + if (!rows.length) { + tbody.innerHTML = '该筛选下暂无平值/虚值合约'; + return; + } + rows.forEach(function (row) { + const tr = document.createElement("tr"); + const call = row.call; + const put = row.put; + const callOk = row.callOk; + const putOk = row.putOk; + const callAsk = callOk ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated, call.tick_sz) : "—"; + const putAsk = putOk ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated, put.tick_sz) : "—"; + const callLev = callOk ? calcStrikeAskLeverage(row.strike, call.ask) : "—"; + const putLev = putOk ? calcStrikeAskLeverage(row.strike, put.ask) : "—"; + const callLeg = callOk ? selected[String(call.inst_id)] : ""; + const putLeg = putOk ? selected[String(put.inst_id)] : ""; + tr.innerHTML = + '' + + callAsk + + '' + + callLev + + '' + + (callOk ? moneynessBadge(call) : "—") + + "" + + (callOk + ? '" + : "—") + + '' + + row.strike + + '' + + (putOk ? moneynessBadge(put) : "—") + + '' + + putAsk + + '' + + putLev + + "" + + (putOk + ? '" + : "—") + + ""; + tbody.appendChild(tr); + }); + if (!state.ooStrikeExpandAll && prepared.length > rows.length) { + const hint = document.createElement("tr"); + hint.className = "opt-strike-hint-row"; + hint.innerHTML = + '默认显示 Call/Put 各最近 3 档 · 点「显示全部」查看该到期更多行权价(若当前为「仅平值」会自动切到「平/虚」)'; + tbody.appendChild(hint); + } else if (state.ooStrikeExpandAll && state.ooMoneyFilter === "otm") { + const hint = document.createElement("tr"); + hint.className = "opt-strike-hint-row"; + hint.innerHTML = + '当前为「仅虚值」筛选 · 切到「平/虚」可看平值档'; + tbody.appendChild(hint); + } + tbody.querySelectorAll(".hp-oo-pick").forEach(function (btn) { + btn.addEventListener("click", function () { + const inst = btn.getAttribute("data-inst"); + const exp2 = currentExp("hp-oo-exp-select"); + const c = (exp2.contracts || []).find(function (x) { + return x.inst_id === inst; + }); + if (!c || !matchesOoMoneyFilter(c)) { + alert("期期仅可选平值或虚值,不可选实值"); + return; + } + if (!state.legA) state.legA = c; + else if (!state.legB || state.legB.inst_id === state.legA.inst_id) state.legB = c; + else { + state.legA = c; + state.legB = null; + } + state.ooRecommend = null; + syncOoRecommendUI(); + renderOoLegs(); + }); + }); + syncOoPickHighlight(); + } + + function renderOoLegs() { + function fill(tag, c, infoId, sheetsId) { + const info = $(infoId); + const sheets = $(sheetsId); + if (!info) return; + if (!c) { + info.textContent = tag + ": 尚未选用"; + if (sheets) { + sheets.disabled = true; + sheets.value = "1"; + } + return; + } + info.innerHTML = + tag + + ": " + + c.opt_type + + " K" + + c.strike + + " " + + (c.moneyness_label || "") + + " · 卖一 " + + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated, c.tick_sz) + + " " + + c.inst_id + + ""; + if (sheets) sheets.disabled = false; + } + fill("腿A", state.legA, "hp-oo-leg-a-info", "hp-oo-sheets-a"); + fill("腿B", state.legB, "hp-oo-leg-b-info", "hp-oo-sheets-b"); + autoFillOoSheets(); + syncOoPickHighlight(); + } + + function ooSheets(id) { + const n = Number(($(id) && $(id).value) || 1); + return n > 0 ? n : 1; + } + + function updateOoPremiumLine() { + const line = $("hp-oo-prem-line"); + if (!line) return; + if (!state.legA && !state.legB) { + line.textContent = ""; + return; + } + function prem(c, sheets) { + if (!c) return 0; + return Number(c.ask || 0) * sheets * Number(c.ct_mult || 0.01); + } + const a = prem(state.legA, ooSheets("hp-oo-sheets-a")); + const b = prem(state.legB, ooSheets("hp-oo-sheets-b")); + line.textContent = + "预估权利金 A " + + fmt(a, 4) + + " + B " + + fmt(b, 4) + + " ≈ " + + fmt(a + b, 4) + + " USDC"; + } + + function legPayload(c, sheets) { + return { + opt_type: c.opt_type, + strike: c.strike, + sheets: sheets, + ct_mult: c.ct_mult || 0.01, + ask: c.ask, + inst_id: c.inst_id, + }; + } + + function setUnderlying(uly, forceReload) { + const next = (uly || "ETH").toUpperCase(); + const changed = next !== state.underlying; + state.underlying = next; + syncUnderlyingUI(); + if (!changed && !forceReload) return; + state.selected = null; + state.legA = null; + state.legB = null; + state.ooRecommend = null; + syncOoRecommendUI(); + if ($("hp-entry")) $("hp-entry").value = ""; + if ($("hp-contracts")) $("hp-contracts").value = ""; + if ($("hp-tp")) $("hp-tp").value = ""; + if ($("hp-sl")) $("hp-sl").value = ""; + if ($("hp-profit-rr")) $("hp-profit-rr").value = "2"; + if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; + if ($("hp-premium-line")) $("hp-premium-line").textContent = ""; + if ($("hp-oo-sheets-a")) { + $("hp-oo-sheets-a").value = "1"; + $("hp-oo-sheets-a").disabled = true; + } + if ($("hp-oo-sheets-b")) { + $("hp-oo-sheets-b").value = "1"; + $("hp-oo-sheets-b").disabled = true; + } + renderOoLegs(); + void refreshAll(); + } + + async function runPreview() { + const isOo = state.mode === "options_options"; + const tbody = $("hp-result-tbody"); + const summary = $("hp-preview-summary"); + const midTh = $("hp-preview-mid-th"); + const title = $("hp-preview-title"); + state.previewOk = false; + state.previewPlanType = isOo ? "options_options" : "perp_options"; + syncPreviewStartBtn(); + if (midTh) midTh.textContent = isOo ? "腿盈亏" : "永续/腿盈亏"; + if (title) title.textContent = isOo ? "情景测算 · 期期" : "情景测算 · 永期"; + if (summary) summary.textContent = ""; + if (tbody) tbody.innerHTML = '计算中…'; + openPreviewModal(); + try { + let body; + if (isOo) { + if (!state.legA || !state.legB) throw new Error("请选用两条期权腿"); + if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { + throw new Error("期期两腿须为平值或虚值,不可选实值"); + } + const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0); + if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)"); + body = { + plan_type: "options_options", + profit_rr: rr, + index_px: indexPx() || 0, + leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), + leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), + }; + } else { + if (!state.selected) throw new Error("请选用期权腿"); + if (isOptionPrimary()) { + const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); + if (!sized) throw new Error("请填写权利金并确认卖一有效"); + const optPts = numInput("hp-opt-target-pts", NaN); + const perpPts = numInput("hp-perp-target-pts", NaN); + if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)"); + const exp = currentExp("hp-exp-select"); + body = { + plan_type: "perp_options", + option_primary: true, + direction: getDirection(), + entry: indexPx() || Number((state.market && state.market.mark) || 0), + contracts: sized.contracts, + sheets: sized.sheets, + contract_size: (state.market && state.market.contract_size) || 0.01, + opt_type: state.selected.opt_type, + strike: state.selected.strike, + ct_mult: state.selected.ct_mult || 0.01, + ask: state.selected.ask, + index_px: indexPx() || 0, + premium_budget: numInput("hp-premium-budget", 0), + option_perp_ratio: numInput("hp-opt-perp-ratio", 2), + option_target_points: optPts, + perp_target_points: perpPts, + strike_interval: numInput("hp-strike-interval", 15), + min_option_hours: numInput("hp-min-hours", 36), + option_leverage: numInput("hp-opt-leverage", 100), + leverage: numInput("hp-perp-leverage", 100), + moneyness: opMoneyKind(), + hours_to_expiry: exp ? hoursFromExpMs(exp.exp_time) : null, + }; + } else { + const mSel = (state.selected.moneyness || "").toLowerCase(); + if (mSel === "otm") throw new Error("永期保险腿须为实值或平值,不可选虚值"); + const entry = Number(($("hp-entry") && $("hp-entry").value) || 0); + const tp = Number(($("hp-tp") && $("hp-tp").value) || 0); + const sl = Number(($("hp-sl") && $("hp-sl").value) || 0); + const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0); + const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); + if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数"); + body = { + plan_type: "perp_options", + direction: getDirection(), + entry: entry, + tp: tp, + sl: sl, + contracts: contracts, + contract_size: (state.market && state.market.contract_size) || 0.01, + opt_type: state.selected.opt_type, + strike: state.selected.strike, + sheets: sheets, + ct_mult: state.selected.ct_mult || 0.01, + ask: state.selected.ask, + index_px: indexPx() || entry, + }; + } + } + const d = await apiJson("/api/hedge-plan/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + setGateLine(d.gates); + const s = d.summary || {}; + if (summary) { + if (d.plan_type === "perp_options" && (d.option_primary || s.opt_target_total != null)) { + const sz = d.sizing || {}; + summary.innerHTML = + "期权目标净利 " + + fmtPnlHtml(s.opt_target_total) + + " · 永续目标净利 " + + fmtPnlHtml(s.perp_target_total) + + " · 保费 " + + fmt(s.premium_paid) + + (sz.eth_qty != null ? " · ETH " + fmt(sz.eth_qty, 2) : "") + + (s.perp_direction ? " · 永续" + (s.perp_direction === "short" ? "空" : "多") : ""); + } else if (d.plan_type === "perp_options") { + summary.innerHTML = + "止盈合计 " + + fmtPnlHtml(s.tp_total) + + " · 止损合计 " + + fmtPnlHtml(s.sl_total) + + " · 保费 " + + fmt(s.premium_paid) + + (s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : ""); + } else { + const rrTarget = s.profit_rr != null ? s.profit_rr : null; + let rrLine = ""; + if (rrTarget != null) { + rrLine = + " · 目标盈亏比 " + + fmt(rrTarget, 2) + + '(盈利金额/总权利金)'; + } else if (s.rr_at_up != null || s.rr_at_down != null) { + rrLine = + " · 盈亏比 上破 " + + fmtRr(s.rr_at_up) + + (s.at_target_down_total != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") + + '(亏=全额保费 ' + + fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) + + ")"; + } + const aTot = s.at_rr_a_full_total != null ? s.at_rr_a_full_total : s.at_target_up_total; + const bTot = s.at_rr_b_full_total != null ? s.at_rr_b_full_total : s.at_target_down_total; + summary.innerHTML = + (rrTarget != null ? "腿A达标 " : "上破 ") + + fmtPnlHtml(aTot) + + (bTot != null ? (rrTarget != null ? " · 腿B达标 " : " · 下破 ") + fmtPnlHtml(bTot) : "") + + " · 到期现价 " + + fmtPnlHtml(s.expiry_flat_total) + + " · 保费 " + + fmt(s.premium_paid) + + rrLine + + (s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : ""); + } + } + if (!tbody) return; + tbody.innerHTML = ""; + (d.scenarios || []).forEach(function (sc) { + const tr = document.createElement("tr"); + let mid; + if (sc.perp_pnl != null) { + mid = "永续 " + fmtPnlHtml(sc.perp_pnl); + } else { + mid = "A " + fmtPnlHtml(sc.leg_a_pnl) + " / B " + fmtPnlHtml(sc.leg_b_pnl); + } + const optCol = sc.options_pnl != null ? fmtPnlHtml(sc.options_pnl) : "—"; + tr.innerHTML = + "" + + (sc.label || sc.id) + + "" + + fmt(sc.spot) + + "" + + mid + + "" + + optCol + + "" + + fmtPnlHtml(sc.total) + + "" + + (sc.note || "") + + ""; + tbody.appendChild(tr); + }); + state.previewOk = true; + syncPreviewStartBtn(); + } catch (e) { + state.previewOk = false; + syncPreviewStartBtn(); + if (tbody) tbody.innerHTML = '' + (e.message || e) + ""; + if (summary) summary.textContent = ""; + } + } + + function hardenAmountAutofill(ids) { + (ids || []).forEach(function (id) { + const el = $(id); + if (!el) return; + function wipe() { + const v = String(el.value || "").trim(); + // 浏览器常把登录用户名(如 dekun)灌进数量框 + if (/^[a-z][a-z0-9._-]{1,31}$/i.test(v)) el.value = ""; + } + wipe(); + const lockRo = id.indexOf("xfer") >= 0 || el.hasAttribute("readonly"); + if (lockRo) { + el.setAttribute("readonly", "readonly"); + el.addEventListener("focus", function () { + el.removeAttribute("readonly"); + }); + el.addEventListener("blur", function () { + if (!el.value) el.setAttribute("readonly", "readonly"); + }); + } + setTimeout(wipe, 200); + setTimeout(wipe, 800); + setTimeout(wipe, 2000); + }); + } + + function bind() { + document.querySelectorAll(".hp-tab").forEach(function (b) { + b.addEventListener("click", function () { + const next = b.getAttribute("data-tab") || pickDefaultTab(); + if (next === "perp_options" && !showPerp) return; + if (next === "options_options" && !showOo) return; + state.tab = next; + syncTabUI(); + if (state.tab === "perp_options" || state.tab === "options_options") { + void loadGates(); + if (state.tab === "perp_options") void refreshPoStrategyStatus(); + } else if (state.tab === "active") { + void loadActivePlans(); + } else if (state.tab === "history") { + void loadHistory(); + } else if (state.tab === "stats") { + void loadStats(); + } else { + const el = $("hp-gate-line"); + if (el) el.textContent = ""; + } + }); + }); + document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) { + b.addEventListener("click", function () { + setUnderlying(b.getAttribute("data-uly") || "ETH", true); + }); + }); + document.querySelectorAll(".hp-money-btn").forEach(function (b) { + b.addEventListener("click", function () { + const m = b.getAttribute("data-money") || "itm"; + if (m === "otm" && !isOptionPrimary()) { + alert("永期保险腿仅允许实值或平值;请在 env 将 HEDGE_PLAN_OPTION_PRIMARY=true"); + return; + } + state.moneyFilter = m === "otm" ? "otm" : m === "atm" ? "atm" : "itm"; + if (isOptionPrimary()) applyOpDefaultsFromMoney(false); + syncMoneyUI(); + renderListStrikes(); + }); + }); + if ($("hp-money-select")) { + $("hp-money-select").addEventListener("change", function () { + const m = $("hp-money-select").value || "otm"; + state.moneyFilter = m === "otm" ? "otm" : m === "atm" ? "atm" : "itm"; + state.opLevTouched = false; + state.opRatioTouched = false; + applyOpDefaultsFromMoney(true); + syncMoneyUI(); + renderListStrikes(); + }); + } + if ($("hp-opt-leverage")) { + $("hp-opt-leverage").addEventListener("input", function () { + state.opLevTouched = true; + if (isOptionPrimary()) renderListStrikes(); + }); + } + if ($("hp-opt-perp-ratio")) { + $("hp-opt-perp-ratio").addEventListener("input", function () { + state.opRatioTouched = true; + if (state.selected) { + const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); + if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets); + } + void loadMarket(); + }); + } + let _poChainReloadTimer = null; + function schedulePoChainReload() { + if (_poChainReloadTimer) clearTimeout(_poChainReloadTimer); + _poChainReloadTimer = setTimeout(function () { + _poChainReloadTimer = null; + void loadChain(); + }, 350); + } + ["hp-premium-budget", "hp-strike-interval", "hp-min-hours", "hp-opt-target-pts", "hp-perp-target-pts"].forEach( + function (id) { + const el = $(id); + if (!el) return; + el.addEventListener("input", function () { + if (id === "hp-min-hours" || id === "hp-strike-interval") { + schedulePoChainReload(); + } + if (id === "hp-premium-budget") renderListStrikes(); + if (state.selected && id === "hp-premium-budget") { + const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); + if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets); + void loadMarket(); + } + updatePerpPnlHint(); + }); + } + ); + document.querySelectorAll(".hp-oo-money-btn").forEach(function (b) { + b.addEventListener("click", function () { + const m = b.getAttribute("data-oo-money") || "atm_otm"; + if (m === "itm") { + alert("期期两腿仅允许平值或虚值"); + return; + } + state.ooMoneyFilter = m; + syncMoneyUI(); + renderTStrikes(); + }); + }); + if ($("hp-recommend-opt")) { + $("hp-recommend-opt").addEventListener("click", function () { + if (isOptionPrimary()) { + autoMatchPoOption(true); + if (!state.selected) { + alert("当前选约条件下无匹配合约,请调整类型/间隔或换到期"); + } + return; + } + const want = optTypeForDirection(getDirection()); + const c = pickClosestItmAtm(currentContracts("hp-exp-select"), want); + if (!c) { + alert("当前到期日无可用实值/平值合约,请换到期或刷新链"); + return; + } + pickContract(c); + }); + } + if ($("hp-oo-recommend-atm")) { + $("hp-oo-recommend-atm").addEventListener("click", function () { + const pair = pickOoTemplate("atm_straddle"); + if (!pair) { + alert("无法推荐平值跨式,请确认到期日与链数据"); + return; + } + state.legA = pair.call; + state.legB = pair.put; + state.ooRecommend = "atm_straddle"; + syncOoRecommendUI(); + renderOoLegs(); + autoFillOoSheets(); + updateOoPremiumLine(); + }); + } + if ($("hp-oo-recommend-otm")) { + $("hp-oo-recommend-otm").addEventListener("click", function () { + const pair = pickOoTemplate("double_otm"); + if (!pair) { + alert("无法推荐双虚值,请确认到期日与链数据"); + return; + } + state.legA = pair.call; + state.legB = pair.put; + state.ooRecommend = "double_otm"; + syncOoRecommendUI(); + renderOoLegs(); + autoFillOoSheets(); + updateOoPremiumLine(); + }); + } + document.querySelectorAll(".hp-po-dir").forEach(function (b) { + b.addEventListener("click", function () { + setDirection(b.getAttribute("data-dir") || "long", true); + }); + }); + ["hp-entry", "hp-tp", "hp-sl", "hp-contracts"].forEach(function (id) { + const el = $(id); + if (el) el.addEventListener("input", updatePerpPnlHint); + }); + syncOptionPrimaryUI(); + if ($("hp-refresh")) + $("hp-refresh").addEventListener("click", function () { + void refreshAll(); + }); + if ($("hp-load-chain")) + $("hp-load-chain").addEventListener("click", function () { + void loadChain(); + }); + if ($("hp-oo-load-chain")) + $("hp-oo-load-chain").addEventListener("click", function () { + void loadChain(); + }); + if ($("hp-oo-expand-all")) { + $("hp-oo-expand-all").addEventListener("click", function () { + state.ooStrikeExpandAll = !state.ooStrikeExpandAll; + // 「仅平值」时本来就只有 1~3 档,展开几乎无变化;展开时自动切到平/虚以便看到全部可选档 + if (state.ooStrikeExpandAll && state.ooMoneyFilter === "atm") { + state.ooMoneyFilter = "atm_otm"; + syncMoneyUI(); + } + syncOoExpandUI(); + renderTStrikes(); + }); + } + if ($("hp-exp-select")) $("hp-exp-select").addEventListener("change", renderListStrikes); + if ($("hp-oo-exp-select")) $("hp-oo-exp-select").addEventListener("change", renderTStrikes); + if ($("hp-sheets")) $("hp-sheets").addEventListener("input", updatePremiumLine); + ["hp-oo-sheets-a", "hp-oo-sheets-b"].forEach(function (id) { + const el = $(id); + if (el) el.addEventListener("input", updateOoPremiumLine); + }); + document.querySelectorAll(".hp-oo-size-mode").forEach(function (b) { + b.addEventListener("click", function () { + state.ooSheetsMode = b.getAttribute("data-oo-size") || "same_sheets"; + syncOoSizeModeUI(); + autoFillOoSheets(); + updateOoBudgetLine(null); + }); + }); + document.querySelectorAll(".hp-oo-close-mode").forEach(function (b) { + b.addEventListener("click", function () { + if (!state.ooCloseModeEnabled) return; + state.ooCloseMode = b.getAttribute("data-oo-close") || "close_all"; + state._ooCloseModeTouched = true; + syncOoCloseModeUI(); + updateOoBudgetLine(null); + }); + }); + if ($("hp-oo-xfer-btn")) { + $("hp-oo-xfer-btn").addEventListener("click", function () { + const amount = Number(($("hp-oo-xfer-amount") && $("hp-oo-xfer-amount").value) || 0); + if (!(amount > 0)) { + setOoXferMsg("请输入有效数量", "err"); + return; + } + void submitOoUsdcTransfer(amount); + }); + } + if ($("hp-oo-xfer-all")) { + $("hp-oo-xfer-all").addEventListener("click", function () { + const dir = ooXferDir(); + const max = + dir === "trading_to_funding" ? Number(state.tradingUsdc || 0) : Number(state.fundingUsdc || 0); + if (!(max > 0)) { + setOoXferMsg("划出账户可用余额不足", "err"); + return; + } + const amt = Math.floor(max * 100) / 100; + const label = dir === "trading_to_funding" ? "交易 → 资金" : "资金 → 交易"; + if (!window.confirm("确认全部划转?\n方向:" + label + "\n数量:" + amt + " USDC")) return; + const amtEl = $("hp-oo-xfer-amount"); + if (amtEl) { + amtEl.removeAttribute("readonly"); + amtEl.value = String(amt); + } + void submitOoUsdcTransfer(amt); + }); + } + hardenAmountAutofill([ + "hp-oo-xfer-amount", + "hp-entry", + "hp-contracts", + "hp-tp", + "hp-sl", + "hp-sheets", + "hp-profit-rr", + ]); + if ($("hp-preview-btn")) + $("hp-preview-btn").addEventListener("click", function () { + state.mode = "perp_options"; + if (isOptionPrimary()) void startOptionPrimaryWatch(); + else void runPreview(); + }); + if ($("hp-preview-btn-oo")) + $("hp-preview-btn-oo").addEventListener("click", function () { + state.mode = "options_options"; + void runPreview(); + }); + if ($("hp-preview-cancel")) $("hp-preview-cancel").addEventListener("click", closePreviewModal); + if ($("hp-preview-cancel-x")) $("hp-preview-cancel-x").addEventListener("click", closePreviewModal); + if ($("hp-preview-start")) + $("hp-preview-start").addEventListener("click", function () { + const planType = state.previewPlanType || state.mode; + void startPlan(planType, true); + }); + const previewModal = $("hp-preview-modal"); + if (previewModal) { + previewModal.addEventListener("click", function (ev) { + if (ev.target === previewModal) closePreviewModal(); + }); + } + if ($("hp-detail-close")) $("hp-detail-close").addEventListener("click", closeModal); + const modal = $("hp-detail-modal"); + if (modal) { + modal.addEventListener("click", function (ev) { + if (ev.target === modal) closeModal(); + }); + } + } + + async function loadHistory() { + const tbody = $("hp-history-tbody"); + if (!tbody) return; + try { + const d = await apiJson("/api/hedge-plan/history"); + const rows = d.plans || []; + if (!rows.length) { + tbody.innerHTML = '暂无已结束计划'; + return; + } + tbody.innerHTML = ""; + rows.forEach(function (p) { + const tr = document.createElement("tr"); + const typeLabel = p.plan_type === "perp_options" ? "永期" : "期期"; + const contracts = p.contracts_summary || "—"; + const pnl = p.realized_pnl_total; + const pnlCls = + pnl == null || Number.isNaN(Number(pnl)) ? "" : Number(pnl) >= 0 ? "hp-pnl-pos" : "hp-pnl-neg"; + tr.innerHTML = + "#" + + p.id + + "" + + typeLabel + + "" + + (p.underlying || "") + + "" + + contracts + + "" + + (p.status || "") + + '' + + fmt(pnl) + + "" + + reasonLabel(p.close_reason) + + "" + + (p.opened_at || "—") + + "" + + (p.closed_at || "—") + + '' + + ' ' + + '' + + ""; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".hp-btn-detail").forEach(function (btn) { + btn.addEventListener("click", function () { + void showPlanDetail(Number(btn.getAttribute("data-id"))); + }); + }); + tbody.querySelectorAll(".hp-btn-del").forEach(function (btn) { + btn.addEventListener("click", function () { + void deletePlan(Number(btn.getAttribute("data-id"))); + }); + }); + } catch (e) { + tbody.innerHTML = '' + (e.message || e) + ""; + } + } + + function activeTargetLabel(p) { + if (p.plan_type === "perp_options" && (p.option_primary == 1 || p.option_primary === true || Number(p.option_primary) === 1)) { + return ( + "期权K±" + + fmt(p.option_target_points, 0) + + " · 永续K±" + + fmt(p.perp_target_points, 0) + ); + } + if (p.plan_type === "perp_options") { + return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl); + } + if (p.profit_rr != null && Number(p.profit_rr) > 0) { + return "盈亏比 " + fmt(p.profit_rr, 2); + } + return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price); + } + + function activeStatusLabel(p) { + if ((p.status || "") === "watching") { + return '盯盘中'; + } + if ((p.status || "") === "partial") { + return '半腿待补'; + } + if ((p.status || "") === "opening") { + return '开仓中'; + } + return '进行中'; + } + + function completeLegButtonHtml(p) { + if ((p.status || "") !== "partial") return ""; + const role = p.missing_leg || ""; + let label = ""; + if (role === "perp") label = "补开永续"; + else if (role === "option_b") label = "补开腿B"; + else if (role === "option_hedge" || role === "option_a") label = "补开期权"; + else return ""; + return ( + ' " + ); + } + + async function completeMissingLeg(planId) { + if (!window.confirm("确认补开缺失腿并真实下单?")) return; + try { + const d = await apiJson("/api/hedge-plan/" + planId + "/complete-leg", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + alert("补开成功 #" + (d.plan_id || planId) + " · 已完全成交并进入进行中"); + void loadActivePlans(); + void loadGates(); + } catch (e) { + alert(e.message || String(e)); + } + } + + async function endActivePlan(planId) { + if ( + !window.confirm( + "确认结束计划 #" + + planId + + "?\n不会自动平仓;未成交/待补腿将标为未成交取消。\n已有持仓请自行平掉。" + ) + ) { + return; + } + try { + const d = await apiJson("/api/hedge-plan/" + planId + "/end", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + alert(d.msg || "计划已结束 #" + (d.plan_id || planId)); + void loadActivePlans(); + void loadHistory(); + void loadGates(); + void refreshPoStrategyStatus(); + } catch (e) { + alert(e.message || String(e)); + } + } + + function legStatusLabel(st) { + const map = { + open: "持仓中", + pending: "待补/未成交", + cancelled: "未成交取消", + canceled: "未成交取消", + closed: "已平仓", + }; + return map[st] || st || "—"; + } + + async function loadActivePlans() { + const tbody = $("hp-active-tbody"); + if (!tbody) return; + try { + const d = await apiJson("/api/hedge-plan/active"); + const rows = d.plans || []; + if (!rows.length) { + tbody.innerHTML = '暂无进行中的计划'; + return; + } + tbody.innerHTML = ""; + rows.forEach(function (p) { + const tr = document.createElement("tr"); + const typeLabel = p.plan_type === "perp_options" ? "永期" : "期期"; + const contracts = p.contracts_summary || "—"; + tr.innerHTML = + "#" + + p.id + + "" + + typeLabel + + "" + + (p.underlying || "") + + "" + + contracts + + "" + + activeStatusLabel(p) + + "" + + activeTargetLabel(p) + + "" + + (p.opened_at || "—") + + '' + + completeLegButtonHtml(p) + + ' ' + + ''; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".hp-btn-detail").forEach(function (btn) { + btn.addEventListener("click", function () { + void showPlanDetail(Number(btn.getAttribute("data-id"))); + }); + }); + tbody.querySelectorAll(".hp-btn-complete").forEach(function (btn) { + btn.addEventListener("click", function () { + void completeMissingLeg(Number(btn.getAttribute("data-id"))); + }); + }); + tbody.querySelectorAll(".hp-btn-end").forEach(function (btn) { + btn.addEventListener("click", function () { + void endActivePlan(Number(btn.getAttribute("data-id"))); + }); + }); + } catch (e) { + tbody.innerHTML = '' + (e.message || e) + ""; + } + } + + function reasonLabel(r) { + const map = { + perp_tp: "永续止盈", + perp_sl: "永续止损", + oo_expiry_loss: "期期到期亏损", + oo_expiry_win: "期期到期盈利", + target_win_leg: "期期平盈利腿", + target_up_win_leg: "期期上破·平盈利腿", + target_down_win_leg: "期期下破·平盈利腿", + profit_rr_win_leg: "期期盈亏比达标·平盈利腿", + oo_rest_closing: "期期残值平·清亏损腿中", + oo_rest_closed: "期期残值平·两腿已平", + orphaned_after_tp: "止盈后持有至到期", + orphaned_option_expiry: "残腿到期", + hold_to_expiry: "持有至到期", + expiry: "到期", + manual: "人工结束", + manual_end: "人工结束", + partial_fail: "半腿失败", + cancelled: "已取消", + unfilled: "未成交", + }; + return map[r] || r || "—"; + } + + function roleLabel(role) { + const map = { + perp: "永续腿", + option_hedge: "保险期权", + option_a: "期期腿A", + option_b: "期期腿B", + }; + return map[role] || role || "—"; + } + + function closeModal() { + const m = $("hp-detail-modal"); + if (m) m.hidden = true; + } + + async function showPlanDetail(planId) { + const modal = $("hp-detail-modal"); + const body = $("hp-detail-body"); + const title = $("hp-detail-title"); + if (!modal || !body) return; + modal.hidden = false; + body.innerHTML = '

    加载中…

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

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

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

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

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

    ' + + title + + '

    暂无已结束样本

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

    ' + + title + + "

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

    总览

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

    "; + html += metricCard("永期对冲", by.perp_options); + html += metricCard("期期对冲", by.options_options); + html += "
    "; + const poB = (by.perp_options && by.perp_options.buckets) || {}; + const ooB = (by.options_options && by.options_options.buckets) || {}; + if ((poB.tp && poB.tp.count) || (poB.sl && poB.sl.count) || (ooB.expiry_loss && ooB.expiry_loss.count)) { + html += '
    '; + if (poB.tp && poB.tp.count) html += metricCard("永期·止盈桶", poB.tp); + if (poB.sl && poB.sl.count) html += metricCard("永期·止损桶", poB.sl); + if (ooB.expiry_loss && ooB.expiry_loss.count) html += metricCard("期期·到期亏损", ooB.expiry_loss); + if (ooB.expiry_win && ooB.expiry_win.count) html += metricCard("期期·到期盈利", ooB.expiry_win); + html += "
    "; + } + box.innerHTML = html; + } catch (e) { + box.textContent = e.message || String(e); + } + } + + async function startOptionPrimaryWatch() { + try { + const optPts = numInput("hp-opt-target-pts", NaN); + const perpPts = numInput("hp-perp-target-pts", NaN); + const prem = numInput("hp-premium-budget", 0); + const optLev = numInput("hp-opt-leverage", 200); + if (!(prem > 0)) throw new Error("请填写权利金预算"); + if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)"); + if (!(optLev > 0)) throw new Error("请填写期权杠杆门槛"); + if (!(state.market && state.market.exchange_symbol)) throw new Error("永续行情未就绪,请先刷新"); + if (!state.canStart) { + throw new Error("当前不可启动(门禁未满足),请查看上方提示"); + } + const msg = + "确认启动盯盘?\n" + + "类型 " + + (opMoneyKind() === "otm" ? "虚值" : opMoneyKind() === "atm" ? "平值" : "实/平") + + " · 间隔 " + + numInput("hp-strike-interval", 15) + + " · 杠杆≥" + + optionPrimaryMinLev() + + "\n达标后自动开仓(非现场立即开)"; + if (!window.confirm(msg)) return; + const body = { + plan_type: "perp_options", + option_primary: true, + watch_entry: 1, + underlying: state.underlying, + direction: getDirection(), + exchange_symbol: state.market.exchange_symbol, + contract_size: state.market.contract_size || 0.01, + index_px: indexPx() || Number(state.market.mark || 0), + entry: indexPx() || Number(state.market.mark || 0), + premium_budget: prem, + option_perp_ratio: numInput("hp-opt-perp-ratio", 4), + option_target_points: optPts, + perp_target_points: perpPts, + strike_interval: numInput("hp-strike-interval", 15), + min_option_hours: numInput("hp-min-hours", 36), + option_leverage: optLev, + leverage: numInput("hp-perp-leverage", 100), + moneyness: opMoneyKind(), + }; + const d = await apiJson("/api/hedge-plan/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + setGateLine(d.gates); + setPoStrategyStatus("watching", d.plan_id || null); + alert((d.msg || "已启动盯盘") + (d.plan_id ? "\n计划 #" + d.plan_id : "")); + void refreshPoStrategyStatus(); + void loadGates(); + } catch (e) { + alert(e.message || String(e)); + } + } + + async function startPlan(planType, fromPreviewModal) { + const isOo = planType === "options_options"; + const startBtn = $("hp-preview-start"); + try { + let body; + if (isOo) { + if (!state.legA || !state.legB) throw new Error("请选用两条期权腿"); + if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { + throw new Error("期期两腿须为平值或虚值,不可选实值"); + } + const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0); + if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)"); + body = { + plan_type: "options_options", + underlying: state.underlying, + profit_rr: rr, + index_px: indexPx() || 0, + oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry", + oo_sheets_mode: state.ooSheetsMode || "same_sheets", + leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), + leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), + }; + } else { + if (!state.selected) throw new Error("请选用期权腿"); + if (isOptionPrimary()) { + const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01); + if (!sized) throw new Error("请填写权利金并确认卖一有效"); + const optPts = numInput("hp-opt-target-pts", NaN); + const perpPts = numInput("hp-perp-target-pts", NaN); + if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)"); + const exp = currentExp("hp-exp-select"); + const entry = indexPx() || Number((state.market && state.market.mark) || 0); + body = { + plan_type: "perp_options", + option_primary: true, + underlying: state.underlying, + direction: getDirection(), + entry: entry, + contracts: sized.contracts, + sheets: sized.sheets, + contract_size: (state.market && state.market.contract_size) || 0.01, + ct_mult: state.selected.ct_mult || 0.01, + opt_inst_id: state.selected.inst_id, + opt_type: state.selected.opt_type, + strike: state.selected.strike, + ask: state.selected.ask, + index_px: entry, + exchange_symbol: (state.market && state.market.exchange_symbol) || "", + leverage: numInput("hp-perp-leverage", 100), + option_leverage: numInput("hp-opt-leverage", 100), + premium_budget: numInput("hp-premium-budget", 0), + option_perp_ratio: numInput("hp-opt-perp-ratio", 2), + option_target_points: optPts, + perp_target_points: perpPts, + strike_interval: numInput("hp-strike-interval", 15), + min_option_hours: numInput("hp-min-hours", 36), + moneyness: opMoneyKind(), + hours_to_expiry: exp ? hoursFromExpMs(exp.exp_time) : null, + }; + } else { + const m = (state.selected.moneyness || "").toLowerCase(); + if (m === "otm") throw new Error("永期保险腿须为实值或平值,不可选虚值"); + const entry = Number(($("hp-entry") && $("hp-entry").value) || 0); + const tp = Number(($("hp-tp") && $("hp-tp").value) || 0); + const sl = Number(($("hp-sl") && $("hp-sl").value) || 0); + const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0); + const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); + if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数"); + body = { + plan_type: "perp_options", + underlying: state.underlying, + direction: getDirection(), + entry: entry, + tp: tp, + sl: sl, + contracts: contracts, + sheets: sheets, + opt_inst_id: state.selected.inst_id, + opt_type: state.selected.opt_type, + strike: state.selected.strike, + ask: state.selected.ask, + index_px: indexPx() || entry, + exchange_symbol: (state.market && state.market.exchange_symbol) || "", + leverage: 10, + margin: state.market && state.market.full_margin_sizing && state.market.full_margin_sizing.margin_capital, + }; + } + } + if (!fromPreviewModal) { + if (!window.confirm("确认启动对冲计划并真实下单?\n(将按期权账户/合约账户分别下单)")) return; + } + if (startBtn) startBtn.disabled = true; + const d = await apiJson("/api/hedge-plan/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + setGateLine(d.gates); + closePreviewModal(); + const refreshHint = + d.refresh && d.refresh.msg ? "\n" + String(d.refresh.msg) : ""; + if (d.partial) { + alert( + (d.msg || "半腿失败,已挂待补") + + (d.plan_id ? "\n计划 #" + d.plan_id : "") + + refreshHint + + "\n请到「进行中的计划」补开缺失腿" + ); + state.tab = "active"; + syncTabUI(); + void loadActivePlans(); + } else { + alert( + "计划已启动 #" + + (d.plan_id || "") + + (d.dry_run ? " (dry_run)" : "") + + refreshHint + ); + } + void loadGates(); + if (isOo) void loadChain(); + } catch (e) { + alert(e.message || String(e)); + syncPreviewStartBtn(); + } + } + + async function refreshAll() { + if (state.tab === "perp_options" || state.tab === "options_options") { + await loadGates(); + } + try { + await loadMarket(); + } catch (e) { + const q = $("hp-perp-quote"); + if (q) q.textContent = e.message || String(e); + } + try { + await loadChain(); + } catch (e) { + const tbody = $("hp-strike-tbody"); + if (tbody) tbody.innerHTML = '' + (e.message || e) + ""; + } + if (state.tab === "perp_options") void refreshPoStrategyStatus(); + } + + syncTabUI(); + syncUnderlyingUI(); + syncMoneyUI(); + syncOoRecommendUI(); + syncOoExpandUI(); + syncPoDirUI(); + syncOoSizeModeUI(); + syncOoCloseModeUI(); + applyBudgetBuffer(state.budgetBuffer); + updateOoBudgetLine(null); + bind(); + syncOptionPrimaryUI(); + void refreshAll().then(function () { + void refreshPoStrategyStatus(); + }); + setInterval(function () { + if (state.tab === "perp_options" && isOptionPrimary()) void refreshPoStrategyStatus(); + }, 15000); +})(); diff --git a/lib/common/static/instance_dashboard.js b/lib/common/static/instance_dashboard.js new file mode 100644 index 0000000..b3e7e69 --- /dev/null +++ b/lib/common/static/instance_dashboard.js @@ -0,0 +1,508 @@ +/** + * 实例数据看板:拉 /api/instance/dashboard 渲染只读表格. + * 各区块无数据时不展示;有数据按表格展示. + */ +(function (global) { + const SECTION_ORDER = ["orders", "keys", "strategy", "options", "hedge_plan"]; + let loading = false; + let localDashVersion = 0; + let dashEventSource = null; + let dashReconnectTimer = null; + let booted = false; + + function root() { + const active = document.querySelector('.embed-tab-pane.is-active-pane [data-inst-dashboard="1"]'); + if (active) return active; + return document.getElementById("instance-dashboard"); + } + + function escapeHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function fmtNum(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return escapeHtml(v); + return String(n); + } + + function fmtPnl(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : ""; + const sign = n > 0 ? "+" : ""; + return '' + sign + n.toFixed(2) + "U"; + } + + function fmtPnlPlain(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : ""; + return '' + n.toFixed(2) + ""; + } + + function dirCell(it) { + const d = String(it.direction || "").toLowerCase(); + const label = it.direction_label || (d === "short" ? "做空" : d === "long" ? "做多" : "-"); + const cls = d === "short" ? "inst-dash-dir-short" : d === "long" ? "inst-dash-dir-long" : ""; + return '' + escapeHtml(label) + ""; + } + + function fmtExpiry(ms) { + const n = Number(ms); + if (!Number.isFinite(n) || n <= 0) return "—"; + let fallback = "—"; + try { + const d = new Date(n); + if (!Number.isNaN(d.getTime())) { + const pad = function (x) { + return String(x).padStart(2, "0"); + }; + fallback = + d.getFullYear() + + "-" + + pad(d.getMonth() + 1) + + "-" + + pad(d.getDate()) + + " " + + pad(d.getHours()) + + ":" + + pad(d.getMinutes()); + } + } catch (_) {} + return ( + '' + + escapeHtml(fallback) + + "" + ); + } + + function goTab(tab) { + if (!tab) return; + if (global.InstanceEmbed && typeof global.InstanceEmbed.loadTab === "function") { + global.InstanceEmbed.loadTab(tab); + return; + } + const pathMap = { + trade: "/trade", + key_monitor: "/key_monitor", + strategy: "/strategy", + options: "/options", + hedge_plan: "/hedge-plan", + }; + const path = pathMap[tab] || "/" + tab; + location.href = path; + } + + function tableWrap(headers, rowsHtml) { + return ( + '
    ' + + '' + + "" + + headers + .map(function (h) { + return ""; + }) + .join("") + + "" + + "" + + rowsHtml + + "
    " + escapeHtml(h) + "
    " + ); + } + + function rowClickAttrs(tab) { + return ' class="inst-dash-row" data-dash-tab="' + escapeHtml(tab || "") + '" role="link" tabindex="0"'; + } + + function renderOrdersTable(items) { + const rows = items + .map(function (it) { + const sym = it.symbol || "-"; + const mark = + it.mark_display != null && it.mark_display !== "" + ? escapeHtml(it.mark_display) + : fmtNum(it.mark_price); + const tpProfit = + it.tp_profit != null && Number.isFinite(Number(it.tp_profit)) + ? '' + Number(it.tp_profit).toFixed(2) + "U" + : "—"; + return ( + "" + + '' + + escapeHtml(sym) + + "" + + dirCell(it) + + "" + + fmtNum(it.entry) + + "" + + "" + + mark + + "" + + "" + + fmtNum(it.contracts) + + "" + + "" + + tpProfit + + "" + + "" + + fmtPnlPlain(it.float_pnl) + + "" + + "—" + + "" + ); + }) + .join(""); + return tableWrap( + ["合约", "方向", "开仓价", "标记价", "张数", "盈利金额", "浮盈", "操作"], + rows + ); + } + + function renderKeysTable(items) { + const rows = items + .map(function (it) { + return ( + "" + + "" + + escapeHtml(it.symbol || "-") + + "" + + dirCell(it) + + "" + + escapeHtml(it.subtitle || "—") + + "" + + "" + + fmtNum(it.upper) + + "" + + "" + + fmtNum(it.lower) + + "" + + "" + ); + }) + .join(""); + return tableWrap(["合约", "方向", "信号", "上沿", "下沿"], rows); + } + + function renderStrategyTable(items) { + const rows = items + .map(function (it) { + const kindLabel = it.kind === "roll" ? "顺势加仓" : it.kind === "trend" ? "趋势回调" : "策略"; + return ( + "" + + "" + + escapeHtml(kindLabel) + + "" + + "" + + escapeHtml(it.symbol || "-") + + "" + + dirCell(it) + + "" + + escapeHtml(it.status || "—") + + "" + + "" + + fmtNum(it.entry) + + "" + + "" + ); + }) + .join(""); + return tableWrap(["类型", "合约", "方向", "状态", "入场"], rows); + } + + function renderOptionsTable(items) { + const rows = items + .map(function (it) { + const opt = it.opt_type_label || + (String(it.opt_type || "").toUpperCase() === "C" + ? "Call" + : String(it.opt_type || "").toUpperCase() === "P" + ? "Put" + : it.opt_type || "—"); + return ( + "" + + "" + + escapeHtml(it.inst_id || it.title || "-") + + "" + + "" + + escapeHtml(it.source_label || "纯期权") + + "" + + "" + + escapeHtml(opt) + + "" + + "" + + fmtNum(it.pos) + + "" + + "" + + fmtExpiry(it.exp_time_ms) + + "" + + "" + + escapeHtml(it.target_monitor || "—") + + "" + + "" + + fmtPnl(it.pnl) + + "" + + "" + ); + }) + .join(""); + return tableWrap(["合约", "来源", "类型", "张数", "到期时间", "目标监控", "净盈亏"], rows); + } + + function renderHedgeTable(items) { + const rows = items + .map(function (it) { + const stCls = it.status_active ? "inst-dash-status-active" : ""; + const stText = it.status_label || (it.status_active ? "进行中" : it.status || "—"); + return ( + "" + + "#" + + escapeHtml(it.id != null ? it.id : "—") + + "" + + "" + + escapeHtml(it.underlying || "-") + + "" + + "" + + escapeHtml(it.plan_type_label || it.plan_type || "—") + + "" + + '' + + escapeHtml(stText) + + "" + + "" + + escapeHtml(it.contracts_summary || it.subtitle || "—") + + "" + + "" + ); + }) + .join(""); + return tableWrap(["ID", "标的", "计划类型", "状态", "说明"], rows); + } + + function renderTable(key, items) { + if (key === "orders") return renderOrdersTable(items); + if (key === "keys") return renderKeysTable(items); + if (key === "strategy") return renderStrategyTable(items); + if (key === "options") return renderOptionsTable(items); + if (key === "hedge_plan") return renderHedgeTable(items); + return ""; + } + + function sectionHasData(sec) { + if (!sec) return false; + const count = Number(sec.count); + if (Number.isFinite(count) && count > 0) return true; + return Array.isArray(sec.items) && sec.items.length > 0; + } + + function renderSection(key, sec) { + if (!sectionHasData(sec)) return ""; + const items = sec.items || []; + const count = Number(sec.count) || items.length; + return ( + '
    ' + + '
    ' + + "

    " + + escapeHtml(sec.title || key) + + ' ' + + count + + "

    " + + '' + + "
    " + + renderTable(key, items) + + "
    " + ); + } + + function bindClicks(el) { + if (!el) return; + el.querySelectorAll("[data-dash-tab]").forEach(function (node) { + const handler = function () { + goTab(node.getAttribute("data-dash-tab")); + }; + node.addEventListener("click", handler); + node.addEventListener("keydown", function (ev) { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + handler(); + } + }); + }); + } + + async function load(opts) { + const el = root(); + if (!el) return; + const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status"); + const sections = el.querySelector("#inst-dash-sections") || document.getElementById("inst-dash-sections"); + const updated = el.querySelector("#inst-dash-updated") || document.getElementById("inst-dash-updated"); + const options = opts || {}; + if (loading && !options.force) return; + loading = true; + if (status && !options.silent) status.textContent = "同步中…"; + try { + const dashRes = await fetch("/api/instance/dashboard", { credentials: "same-origin" }); + if (dashRes.status === 401) { + location.href = "/login?next=" + encodeURIComponent(location.pathname); + return; + } + const data = await dashRes.json().catch(function () { + return {}; + }); + if (!dashRes.ok || !data.ok) { + if ( + data.aggregating || + (data.msg && String(data.msg).indexOf("尚未就绪") >= 0) + ) { + if (status) status.textContent = "后台聚合中…"; + return; + } + throw new Error(data.msg || data.error || dashRes.statusText || "加载失败"); + } + const ver = Number(data.dashboard_version) || 0; + if (ver) localDashVersion = ver; + if (data.orders && Array.isArray(data.orders.items)) { + data.orders.count = data.orders.items.length; + } + if (updated) updated.textContent = "更新 " + (data.updated_at || "—"); + if (sections) { + const html = SECTION_ORDER.map(function (k) { + return renderSection(k, data[k]); + }).join(""); + sections.innerHTML = + html || '

    当前无活跃监控与持仓

    '; + bindClicks(sections); + if (global.OptionsExpiryCountdown) { + if (typeof global.OptionsExpiryCountdown.tick === "function") { + global.OptionsExpiryCountdown.tick(sections); + } + if (typeof global.OptionsExpiryCountdown.ensureTimer === "function") { + global.OptionsExpiryCountdown.ensureTimer(); + } + } + } + const sec = Number(data.poll_interval_sec) || 5; + if (status) { + status.textContent = options.silent + ? "SSE 已连接 · 后台每 " + sec + "s 聚合" + : "已更新 · 后台每 " + sec + "s 聚合"; + } + } catch (e) { + if (status) status.textContent = e.message || "加载失败"; + } finally { + loading = false; + } + } + + function closeDashboardStream() { + if (dashEventSource) { + dashEventSource.close(); + dashEventSource = null; + } + if (dashReconnectTimer) { + clearTimeout(dashReconnectTimer); + dashReconnectTimer = null; + } + } + + function connectDashboardStream() { + const el = root(); + if (!el) return; + closeDashboardStream(); + dashEventSource = new EventSource("/api/instance/dashboard/stream"); + dashEventSource.addEventListener("dashboard", function (ev) { + try { + const st = JSON.parse(ev.data || "{}"); + const ver = Number(st.dashboard_version) || 0; + if (ver && ver !== localDashVersion) { + load({ silent: true }); + } else if (st.aggregating) { + const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status"); + if (status) status.textContent = "后台聚合中…"; + } + } catch (_) {} + }); + dashEventSource.onerror = function () { + closeDashboardStream(); + const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status"); + if (status) status.textContent = "SSE 断开,8s 后重连…"; + dashReconnectTimer = setTimeout(function () { + if (booted) { + connectDashboardStream(); + load({ silent: true }); + } + }, 8000); + }; + } + + async function requestDashboardRefresh() { + try { + await fetch("/api/instance/dashboard/refresh", { + method: "POST", + credentials: "same-origin", + }); + } catch (_) {} + load({ force: true }); + } + + function stopAuto() { + closeDashboardStream(); + } + + function init(force) { + const el = root(); + if (!el) return; + if (!force && el.getAttribute("data-dash-booted") === "1") { + booted = true; + load({ silent: true }); + connectDashboardStream(); + return; + } + el.setAttribute("data-dash-booted", "1"); + booted = true; + const btn = el.querySelector("#inst-dash-refresh") || document.getElementById("inst-dash-refresh"); + if (btn && !btn.getAttribute("data-bound")) { + btn.setAttribute("data-bound", "1"); + btn.addEventListener("click", function () { + requestDashboardRefresh(); + }); + } + load({}); + connectDashboardStream(); + } + + function refreshSoft(opts) { + load(Object.assign({ silent: true }, opts || {})); + } + + global.InstanceDashboard = { + init: init, + refreshSoft: refreshSoft, + load: load, + stopAuto: stopAuto, + }; +})(window); diff --git a/lib/common/static/instance_embed.js b/lib/common/static/instance_embed.js new file mode 100644 index 0000000..be8b93b --- /dev/null +++ b/lib/common/static/instance_embed.js @@ -0,0 +1,595 @@ +/** + * 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/. + * 各 tab 面板常驻 DOM,切换时 show/hide;脚本延后到首次激活,回访零请求. + */ +(function (global) { + const TAB_PATH = { + dashboard: "/dashboard", + account_ledger: "/account_ledger", + key_monitor: "/key_monitor", + trade: "/trade", + strategy: "/strategy", + strategy_records: "/strategy/records", + options: "/options", + options_review: "/options/review", + hedge_plan: "/hedge-plan", + records: "/records", + stats: "/stats", + risk_policy: "/risk_policy", + system_guide: "/system_guide", + env_config: "/env_config", + settings: "/settings", + }; + + let navToken = 0; + let loadingTab = false; + let pendingTabLoad = null; + const tabPanes = new Map(); + const tabBooted = new Set(); + + /** 自带 AJAX/校验提交的表单,勿在捕获阶段再 fetch+reloadCurrentTab(会卡在「加载中…」) */ + const CUSTOM_SUBMIT_FORM_IDS = new Set([ + "add-order-form", + "key-form", + "roll-form", + "journal-form", + ]); + + function isEmbedShell() { + return document.body && document.body.getAttribute("data-embed-shell") === "1"; + } + + function getTab() { + try { + const t = new URLSearchParams(location.search).get("tab"); + if (t) return t; + } catch (_) {} + return document.body.getAttribute("data-page") || "trade"; + } + + function listWindowQueryString() { + if (typeof global.listWindowQueryString === "function") { + return global.listWindowQueryString(); + } + return ""; + } + + function pageRoot() { + return document.getElementById("embed-page-root"); + } + + function setNavActive(tab) { + document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => { + a.classList.toggle("active", a.getAttribute("data-embed-tab") === tab); + }); + if (global.InstanceMobileNav && typeof global.InstanceMobileNav.onTabChange === "function") { + global.InstanceMobileNav.onTabChange(tab); + } else if (global.InstanceMobileNav && typeof global.InstanceMobileNav.syncTabActive === "function") { + global.InstanceMobileNav.syncTabActive(tab); + } + } + + function pageNavAllowed(tab) { + if (global.InstanceSettingsPrefs && typeof global.InstanceSettingsPrefs.pageNavAllowed === "function") { + return global.InstanceSettingsPrefs.pageNavAllowed(tab); + } + return true; + } + + function syncUrl(tab, replace) { + const q = new URLSearchParams(location.search); + q.set("tab", tab); + q.set("embed", "1"); + const qs = q.toString(); + const url = "/embed?" + qs; + if (replace) history.replaceState({ embedTab: tab }, "", url); + else history.pushState({ embedTab: tab }, "", url); + } + + function notifyParentTabSwitch(tab) { + try { + window.parent.postMessage({ type: "instance-frame-navigating", embedShellTab: true, tab: tab }, "*"); + } catch (_) {} + } + + function runPageInit(tab, opts) { + const options = opts || {}; + const revisit = !!options.revisit; + document.body.setAttribute("data-page", tab); + if (!revisit && typeof global.attachListWindowToExports === "function") { + global.attachListWindowToExports(); + } + if (tab === "trade") { + if (!revisit && typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults(); + if (!revisit && typeof global.initOrderEntryModelSelect === "function") { + const root = pageRoot() || document; + global.initOrderEntryModelSelect(root); + } + if (!revisit && global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") { + global.ManualOrderRrPreview.wire(); + } + } + if (!revisit && tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") { + global.KeyMonitorForm.init(); + } + if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.init === "function") { + global.InstanceDashboard.init(!!revisit); + } + if (tab === "account_ledger" && global.AccountLedgerPage && typeof global.AccountLedgerPage.boot === "function") { + global.AccountLedgerPage.boot(); + } + if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") { + global.initStrategyRollForm(); + } + if (tab === "records") { + if (global.RecordsReviewPage && typeof global.RecordsReviewPage.init === "function") { + global.RecordsReviewPage.init({ refresh: !!revisit }); + } else { + if (!revisit && typeof global.loadJournals === "function") global.loadJournals(); + if (!revisit && typeof global.loadReviews === "function") global.loadReviews(); + } + if (global.InstanceTheme && typeof global.InstanceTheme.initReviewEditModeSync === "function") { + global.InstanceTheme.initReviewEditModeSync(); + } else if (typeof global.toggleReviewMode === "function") { + global.toggleReviewMode(); + } + } + if (tab === "stats") { + if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl(); + } + if (tab === "settings" || tab === "env_config") { + if (global.InstanceSettingsPrefs) { + if (typeof global.InstanceSettingsPrefs.bindEvents === "function") { + global.InstanceSettingsPrefs.bindEvents(); + } + if (tab === "settings" && typeof global.InstanceSettingsPrefs.loadDisplayPrefsForm === "function") { + global.InstanceSettingsPrefs.loadDisplayPrefsForm(); + } + if (tab === "env_config") { + if (typeof global.InstanceSettingsPrefs.loadEnvConfig === "function") { + global.InstanceSettingsPrefs.loadEnvConfig(); + } + if (typeof global.InstanceSettingsPrefs.bindEnvTabs === "function") { + global.InstanceSettingsPrefs.bindEnvTabs(); + } + } + } + } + if (!revisit) { + if (typeof global.refreshAccountSnapshot === "function") { + global.refreshAccountSnapshot({ silent: true }); + } + if (typeof global.refreshPriceSnapshotConditional === "function") { + global.refreshPriceSnapshotConditional(); + } + if (global.SymbolLivePrice && typeof global.SymbolLivePrice.init === "function") { + const root = pageRoot() || document; + global.SymbolLivePrice.init(root); + } + if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") { + const root = pageRoot() || document; + global.JournalUploadSlots.init(root); + } + if (global.JournalFormSave && typeof global.JournalFormSave.init === "function") { + global.JournalFormSave.init(); + } + } + } + + function runScripts(container) { + container.querySelectorAll("script").forEach((old) => { + const s = document.createElement("script"); + if (old.src) s.src = old.src; + else s.textContent = old.textContent; + old.replaceWith(s); + }); + } + + function showPane(tab) { + tabPanes.forEach((pane, name) => { + const on = name === tab; + pane.hidden = !on; + pane.classList.toggle("is-active-pane", on); + }); + } + + function bootPaneScripts(tab) { + if (tabBooted.has(tab)) return; + const pane = tabPanes.get(tab); + if (!pane) return; + runScripts(pane); + tabBooted.add(tab); + } + + function mountPane(tab, html) { + const root = pageRoot(); + if (!root) return null; + const existing = tabPanes.get(tab); + if (existing) existing.remove(); + + const pane = document.createElement("div"); + pane.className = "embed-tab-pane"; + pane.setAttribute("data-embed-pane", tab); + pane.hidden = true; + + const holder = document.createElement("div"); + holder.innerHTML = html; + while (holder.firstChild) pane.appendChild(holder.firstChild); + + root.appendChild(pane); + tabPanes.set(tab, pane); + return pane; + } + + function initBootPane() { + const root = pageRoot(); + if (!root || tabPanes.size > 0) return; + const tab = getTab(); + if (root.querySelector("[data-embed-pane]")) return; + if (!root.childNodes.length) return; + + const pane = document.createElement("div"); + pane.className = "embed-tab-pane is-active-pane"; + pane.setAttribute("data-embed-pane", tab); + Array.from(root.childNodes).forEach((node) => pane.appendChild(node)); + root.appendChild(pane); + tabPanes.set(tab, pane); + tabBooted.add(tab); + showPane(tab); + } + + function embedPageUrl(tab) { + const qs = listWindowQueryString(); + let url = "/api/embed/page/" + encodeURIComponent(tab); + const parts = []; + if (qs) parts.push(qs); + parts.push("embed=1"); + if (tab === "settings") { + try { + const st = new URLSearchParams(location.search).get("settings_tab"); + if (st) parts.push("settings_tab=" + encodeURIComponent(st)); + } catch (_) {} + } + return url + "?" + parts.join("&"); + } + + function setSettingsSubTabInUrl(key) { + if (!key) return; + try { + const q = new URLSearchParams(location.search); + q.set("tab", "settings"); + q.set("settings_tab", key); + q.set("embed", "1"); + history.replaceState(null, "", "/embed?" + q.toString()); + } catch (_) {} + } + + function activateSettingsSubTab(key) { + if (!key) return; + setSettingsSubTabInUrl(key); + const pane = tabPanes.get("settings") || document; + const radio = pane.querySelector( + 'input.env-tab-radio[data-settings-tab="' + key + '"]' + ); + if (radio) radio.checked = true; + } + + function formActionPath(form) { + try { + return new URL(form.action || "", location.href).pathname.replace(/\/$/, "") || "/"; + } catch (_) { + return ""; + } + } + + function maybeKeepSettingsSubTabAfterForm(form) { + const path = formActionPath(form); + if (path === "/manual_transfer") { + setSettingsSubTabInUrl("transfer"); + return "transfer"; + } + if (path.indexOf("/api/options/transfer") >= 0) { + setSettingsSubTabInUrl("options_transfer"); + return "options_transfer"; + } + return ""; + } + + async function fetchTabHtml(tab) { + const r = await fetch(embedPageUrl(tab), { + credentials: "same-origin", + cache: "no-store", + headers: { "X-Instance-Soft-Nav": "1" }, + }); + const ct = (r.headers.get("content-type") || "").toLowerCase(); + if (!ct.includes("application/json")) { + throw new Error("加载失败(HTTP " + r.status + ")"); + } + const j = await r.json(); + if (!j.ok || !j.html) throw new Error(j.msg || "加载失败"); + return j.html; + } + + function warmTabCache(tab) { + if (!tab || tabPanes.has(tab) || loadingTab) return; + fetchTabHtml(tab) + .then((html) => { + if (!tabPanes.has(tab)) mountPane(tab, html); + }) + .catch(() => {}); + } + + function preloadAllTabs() { + const tabs = Object.keys(TAB_PATH); + const current = getTab(); + const heavyLast = new Set(["options", "records", "stats"]); + const ordered = tabs.filter((t) => t !== current && !heavyLast.has(t)) + .concat(tabs.filter((t) => heavyLast.has(t) && t !== current)); + let idx = 0; + function step() { + if (idx >= ordered.length) return; + const tab = ordered[idx++]; + if (tabPanes.has(tab)) { + step(); + return; + } + fetchTabHtml(tab) + .then((html) => { + if (!tabPanes.has(tab)) mountPane(tab, html); + }) + .catch(() => {}) + .finally(() => { + setTimeout(step, heavyLast.has(tab) ? 400 : 180); + }); + } + const ric = global.requestIdleCallback || function (fn) { + setTimeout(fn, 2000); + }; + ric(step); + } + + function clearTabCache() { + tabPanes.forEach((pane) => pane.remove()); + tabPanes.clear(); + tabBooted.clear(); + } + + function syncShellChrome(tab) { + const hideTopBar = + tab === "settings" || tab === "risk_policy" || tab === "system_guide" || tab === "env_config"; + document.querySelectorAll(".instance-top-bar").forEach((el) => { + el.hidden = hideTopBar; + }); + } + + function initPaneThemeToggle(tab) { + if (tab !== "settings") return; + const pane = tabPanes.get(tab); + if (!pane || !global.InstanceTheme) return; + if (typeof global.InstanceTheme.initToggleUI === "function") { + global.InstanceTheme.initToggleUI(pane); + } + if (typeof global.InstanceTheme.syncToggleUI === "function") { + global.InstanceTheme.syncToggleUI(pane); + } + } + + function activateTab(tab, opts) { + const options = opts || {}; + const revisit = !!options.revisit; + const firstBoot = !tabBooted.has(tab); + syncShellChrome(tab); + showPane(tab); + setNavActive(tab); + if (!options.skipUrl) syncUrl(tab, !!options.replace); + notifyParentTabSwitch(tab); + if (firstBoot) { + bootPaneScripts(tab); + initPaneThemeToggle(tab); + runPageInit(tab, { revisit: false }); + return; + } + if (revisit) { + document.body.setAttribute("data-page", tab); + runPageInit(tab, { revisit: true }); + return; + } + runPageInit(tab, { revisit: false }); + } + + async function loadTab(tab, opts) { + const options = opts || {}; + if (!tab) return; + if (!pageNavAllowed(tab)) { + void loadTab("trade", { replace: true }); + return; + } + + if (tabPanes.has(tab) && !options.force) { + activateTab(tab, Object.assign({}, options, { revisit: true })); + return; + } + + if (loadingTab) { + pendingTabLoad = { tab: tab, opts: options }; + return; + } + const token = ++navToken; + loadingTab = true; + try { + const html = await fetchTabHtml(tab); + if (token !== navToken) return; + mountPane(tab, html); + activateTab(tab, options); + } catch (e) { + if (token === navToken) { + const flash = document.getElementById("embed-flash"); + if (flash) { + flash.style.display = ""; + flash.textContent = String(e && e.message ? e.message : e); + } + } + } finally { + if (token === navToken) loadingTab = false; + if (pendingTabLoad) { + const pending = pendingTabLoad; + pendingTabLoad = null; + if (pending.tab !== tab) void loadTab(pending.tab, pending.opts); + } + } + } + + function reloadCurrentTab() { + const tab = getTab(); + const pane = tabPanes.get(tab); + if (pane) pane.remove(); + tabPanes.delete(tab); + tabBooted.delete(tab); + return loadTab(tab, { replace: true, skipUrl: true, force: true }); + } + + function postFormAndReload(form, label) { + if (!form) return Promise.resolve(); + if (global.FormSubmitGuard) { + if (global.FormSubmitGuard.isLocked(form)) { + global.FormSubmitGuard.setSubmitLabel(form, label || "提交中…"); + } else { + global.FormSubmitGuard.lock(form, label || "提交中…"); + } + } + const fd = new FormData(form); + const keepSub = maybeKeepSettingsSubTabAfterForm(form); + return fetch(form.action, { + method: form.method || "POST", + body: fd, + credentials: "same-origin", + redirect: "manual", + }) + .then(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub))) + .catch(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub))); + } + + function patchApplyListWindow() { + if (typeof global.applyListWindow !== "function") return; + global.applyListWindow = function embedApplyListWindow() { + clearTabCache(); + const qs = listWindowQueryString(); + const tab = getTab(); + const q = new URLSearchParams(qs); + q.set("tab", tab); + q.set("embed", "1"); + window.location.href = "/embed?" + q.toString(); + }; + } + + function patchHardNavigations() { + const resubmitPaths = + /^\/(del_|delete_|add_|stop_|strategy\/|trend_|roll_|cancel_|place_)/; + + document.addEventListener( + "click", + (ev) => { + if (!isEmbedShell()) return; + const a = ev.target.closest("a[href]"); + if (!a || ev.defaultPrevented) return; + if (a.closest(".embed-top-nav")) return; + if (a.hasAttribute("download") || a.target === "_blank") return; + const raw = a.getAttribute("href"); + if (!raw || raw.startsWith("#") || raw.startsWith("javascript:")) return; + let url; + try { + url = new URL(raw, location.href); + } catch (_) { + return; + } + if (url.origin !== location.origin) return; + if (url.pathname.startsWith("/export/") || url.pathname.startsWith("/order_focus") || url.pathname.startsWith("/key_focus")) { + return; + } + if (!resubmitPaths.test(url.pathname)) return; + ev.preventDefault(); + fetch(url.pathname + url.search, { credentials: "same-origin", redirect: "manual" }) + .then(() => reloadCurrentTab()) + .catch(() => reloadCurrentTab()); + }, + false + ); + + document.addEventListener( + "submit", + (ev) => { + if (!isEmbedShell()) return; + const form = ev.target; + if (!(form instanceof HTMLFormElement)) return; + if (form.method && form.method.toUpperCase() === "GET") return; + if (CUSTOM_SUBMIT_FORM_IDS.has(form.id)) return; + ev.preventDefault(); + const fd = new FormData(form); + const keepSub = maybeKeepSettingsSubTabAfterForm(form); + fetch(form.action, { + method: form.method || "POST", + body: fd, + credentials: "same-origin", + redirect: "manual", + }) + .then(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub))) + .catch(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub))); + }, + true + ); + } + + function bindNav() { + document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => { + a.addEventListener("mouseenter", () => { + warmTabCache(a.getAttribute("data-embed-tab")); + }); + a.addEventListener("click", (ev) => { + ev.preventDefault(); + const tab = a.getAttribute("data-embed-tab"); + if (!tab || tab === getTab()) return; + void loadTab(tab); + }); + }); + window.addEventListener("popstate", () => { + const tab = getTab(); + void loadTab(tab, { replace: true, skipUrl: true }); + }); + } + + function boot() { + if (!isEmbedShell()) return; + patchApplyListWindow(); + patchHardNavigations(); + initBootPane(); + const bootTab = getTab(); + if (!pageNavAllowed(bootTab)) { + void loadTab("trade", { replace: true }); + return; + } + if (bootTab === "settings") { + initPaneThemeToggle("settings"); + } + bindNav(); + syncShellChrome(getTab()); + runPageInit(getTab()); + preloadAllTabs(); + try { + window.parent.postMessage({ type: "instance-frame-ready" }, "*"); + } catch (_) {} + } + + global.InstanceEmbed = { + loadTab, + reloadCurrentTab, + getTab, + postFormAndReload, + clearTabCache, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/instance_live.js b/lib/common/static/instance_live.js new file mode 100644 index 0000000..f51b3be --- /dev/null +++ b/lib/common/static/instance_live.js @@ -0,0 +1,111 @@ +/** + * embed 壳:SSE 收到后台 tick 后拉 JSON 快照更新 DOM,切换 tab 不再重复请求 HTML. + */ +(function (global) { + let liveEventSource = null; + let liveReconnectTimer = null; + let localLiveVersion = -1; + let sseConnected = false; + let refreshTimer = null; + + function isEmbedShell() { + return document.body && document.body.getAttribute("data-embed-shell") === "1"; + } + + function currentTab() { + if (global.InstanceEmbed && typeof global.InstanceEmbed.getTab === "function") { + return global.InstanceEmbed.getTab(); + } + return document.body.getAttribute("data-page") || "trade"; + } + + function refreshTabData(tab, opts) { + const options = opts || {}; + if (typeof global.refreshAccountSnapshot === "function") { + global.refreshAccountSnapshot(options); + } + if (typeof global.refreshPriceSnapshotConditional === "function") { + global.refreshPriceSnapshotConditional(); + } + if (tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") { + global.OptionsPanelLive.refreshSoft(options); + } + // 数据看板自有 SSE + 快照,不跟 embed live tick 重拉. + } + + function scheduleRefresh(opts) { + if (refreshTimer) return; + const options = opts || {}; + refreshTimer = setTimeout(function () { + refreshTimer = null; + if (document.hidden) return; + refreshTabData(currentTab(), { silent: true, force: !!options.force }); + }, 80); + } + + function onLiveEvent(data) { + const reason = data && data.reason; + const ver = Number(data && data.live_version) || 0; + if (!ver) return; + if (reason === "connect") { + localLiveVersion = ver; + scheduleRefresh(); + return; + } + if (ver === localLiveVersion) return; + localLiveVersion = ver; + scheduleRefresh({ force: reason === "balance" }); + } + + function closeLiveStream() { + if (liveEventSource) { + liveEventSource.close(); + liveEventSource = null; + } + if (liveReconnectTimer) { + clearTimeout(liveReconnectTimer); + liveReconnectTimer = null; + } + sseConnected = false; + } + + function connectLiveStream() { + if (!isEmbedShell()) return; + closeLiveStream(); + liveEventSource = new EventSource("/api/instance/live/stream"); + liveEventSource.addEventListener("live", function (ev) { + try { + onLiveEvent(JSON.parse(ev.data || "{}")); + } catch (_) {} + }); + liveEventSource.onopen = function () { + sseConnected = true; + }; + liveEventSource.onerror = function () { + sseConnected = false; + closeLiveStream(); + liveReconnectTimer = setTimeout(function () { + connectLiveStream(); + }, 8000); + }; + } + + function startLive() { + if (!isEmbedShell()) return; + connectLiveStream(); + } + + global.InstanceLive = { + start: startLive, + refreshTabData: refreshTabData, + isConnected: function () { + return sseConnected; + }, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", startLive); + } else { + startLive(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/instance_mobile_nav.js b/lib/common/static/instance_mobile_nav.js new file mode 100644 index 0000000..4402a89 --- /dev/null +++ b/lib/common/static/instance_mobile_nav.js @@ -0,0 +1,177 @@ +/** + * 实例手机壳: ≤720px 底栏 +「更多」,与 embed soft-nav 同步. + */ +(function (global) { + const PRIMARY = { trade: 1, key_monitor: 1, options: 1 }; + const MQ = "(max-width: 720px)"; + + function isEmbedShell() { + return document.body && document.body.getAttribute("data-embed-shell") === "1"; + } + + function isMobileLayout() { + return window.matchMedia(MQ).matches; + } + + function syncPhoneClass() { + if (!document.body) return; + document.body.classList.toggle("inst-phone", isMobileLayout()); + } + + function currentTab() { + if (global.InstanceEmbed && typeof global.InstanceEmbed.getTab === "function") { + return global.InstanceEmbed.getTab(); + } + try { + const t = new URLSearchParams(location.search).get("tab"); + if (t) return t; + } catch (_) {} + return (document.body && document.body.getAttribute("data-page")) || "trade"; + } + + function closeMore() { + document.body.classList.remove("inst-mobile-more-open"); + const more = document.getElementById("inst-mobile-more"); + const btn = document.getElementById("inst-m-tab-more"); + if (more) more.setAttribute("aria-hidden", "true"); + if (btn) btn.setAttribute("aria-expanded", "false"); + syncTabActive(currentTab()); + } + + function openMore() { + if (!isMobileLayout()) return; + document.body.classList.add("inst-mobile-more-open"); + const more = document.getElementById("inst-mobile-more"); + const btn = document.getElementById("inst-m-tab-more"); + if (more) more.setAttribute("aria-hidden", "false"); + if (btn) btn.setAttribute("aria-expanded", "true"); + syncTabActive(currentTab()); + } + + function toggleMore() { + if (document.body.classList.contains("inst-mobile-more-open")) closeMore(); + else openMore(); + } + + function syncTabActive(tab) { + const page = tab || currentTab(); + const primary = !!PRIMARY[page]; + const moreOpen = document.body.classList.contains("inst-mobile-more-open"); + document.querySelectorAll("#inst-mobile-tabbar .inst-m-tab").forEach((el) => { + const t = el.getAttribute("data-embed-tab") || ""; + let on = false; + if (t === "more") on = moreOpen || !primary; + else on = !moreOpen && t === page; + el.classList.toggle("active", on); + }); + document.querySelectorAll("#inst-mobile-more .inst-mobile-more-nav [data-embed-tab]").forEach((a) => { + a.classList.toggle("active", a.getAttribute("data-embed-tab") === page); + }); + } + + /** embed 切页时关闭「更多」并同步高亮 */ + function onTabChange(tab) { + document.body.classList.remove("inst-mobile-more-open"); + const more = document.getElementById("inst-mobile-more"); + const btn = document.getElementById("inst-m-tab-more"); + if (more) more.setAttribute("aria-hidden", "true"); + if (btn) btn.setAttribute("aria-expanded", "false"); + syncTabActive(tab); + } + + function goTab(tab) { + if (!tab || tab === "more") return; + closeMore(); + if (global.InstanceEmbed && typeof global.InstanceEmbed.loadTab === "function") { + if (tab === currentTab()) { + syncTabActive(tab); + return; + } + void global.InstanceEmbed.loadTab(tab); + return; + } + const pathMap = { + dashboard: "/dashboard", + key_monitor: "/key_monitor", + trade: "/trade", + strategy: "/strategy", + strategy_records: "/strategy/records", + options: "/options", + options_review: "/options/review", + hedge_plan: "/hedge-plan", + records: "/records", + stats: "/stats", + risk_policy: "/risk_policy", + system_guide: "/system_guide", + env_config: "/env_config", + settings: "/settings", + }; + location.href = pathMap[tab] || "/trade"; + } + + function bindChrome() { + const moreBtn = document.getElementById("inst-m-tab-more"); + const backdrop = document.getElementById("inst-mobile-more-backdrop"); + const closeBtn = document.getElementById("inst-mobile-more-close"); + if (moreBtn) { + moreBtn.addEventListener("click", (ev) => { + ev.preventDefault(); + toggleMore(); + }); + } + if (backdrop) backdrop.addEventListener("click", closeMore); + if (closeBtn) closeBtn.addEventListener("click", closeMore); + document.addEventListener("keydown", (ev) => { + if (ev.key === "Escape" && document.body.classList.contains("inst-mobile-more-open")) { + closeMore(); + } + }); + + document.querySelectorAll("#inst-mobile-tabbar .inst-m-tab[data-embed-tab]").forEach((el) => { + if (el.getAttribute("data-embed-tab") === "more") return; + el.addEventListener("click", (ev) => { + if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return; + ev.preventDefault(); + goTab(el.getAttribute("data-embed-tab")); + }); + }); + document.querySelectorAll("#inst-mobile-more .inst-mobile-more-nav [data-embed-tab]").forEach((a) => { + a.addEventListener("click", (ev) => { + if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return; + ev.preventDefault(); + goTab(a.getAttribute("data-embed-tab")); + }); + }); + } + + function boot() { + if (!isEmbedShell()) return; + if (!document.getElementById("inst-mobile-tabbar")) return; + syncPhoneClass(); + bindChrome(); + syncTabActive(currentTab()); + let resizeTimer = null; + window.addEventListener("resize", () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + const was = document.body.classList.contains("inst-phone"); + syncPhoneClass(); + if (!isMobileLayout()) closeMore(); + else if (!was) syncTabActive(currentTab()); + }, 120); + }); + } + + global.InstanceMobileNav = { + syncTabActive, + onTabChange, + closeMore, + isMobileLayout, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/instance_page.css b/lib/common/static/instance_page.css new file mode 100644 index 0000000..c0db29f --- /dev/null +++ b/lib/common/static/instance_page.css @@ -0,0 +1,332 @@ +.order-trade-style-hint{font-size:.78rem;color:#8fc8ff;margin-left:4px;white-space:nowrap} +.order-entry-model-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px} +.order-entry-model-row select.order-entry-category{min-width:4.8em;max-width:6.5em} +.order-entry-model-row select.order-entry-model-sub{min-width:7em;max-width:10rem} +.order-leverage-hint{font-size:.78rem;color:#cfd3ef;white-space:nowrap;align-self:center} + body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 20px} + .container{width:100%;max-width:min(1440px,94vw);margin:0 auto;padding:0 clamp(8px,1.5vw,20px)} + .header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px} + .header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25} + .exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em} + .header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center} + .top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px} + .top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none} + .top-nav a.active{background:#2a3f6c;color:#dbe4ff} + .stat-box{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin-bottom:16px;align-items:stretch} + .stat-item{min-width:0;min-height:76px;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:6px;background:#151a2a;padding:12px 10px;border-radius:10px;text-align:center;border:1px solid #2a3152} + .stat-item .label{font-size:.8rem;color:#aaa;line-height:1.25;max-width:100%} + .stat-item .value{font-size:1.25rem;font-weight:600;color:#fff;line-height:1.3;min-height:1.35em;display:flex;align-items:center;justify-content:center} + .grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} + .card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150} + .full{grid-column:1/-1} + .card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff} + .form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center} + .form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem} + /* 实盘下单监控:分层布局 */ + .order-monitor-form{display:flex;flex-direction:column;gap:10px;margin-bottom:4px} + .order-monitor-form .om-row{display:flex;flex-wrap:wrap;align-items:flex-end;gap:8px} + .order-monitor-form .om-row-policy > input:not([type=checkbox]):not([type=radio]), + .order-monitor-form .om-row-policy > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem} + .order-monitor-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto} + .order-monitor-form .om-field{display:flex;flex-direction:column;gap:4px;min-width:7.5rem} + .order-monitor-form .om-field-lab{font-size:.72rem;color:#9aa3c7;line-height:1;letter-spacing:.02em} + .order-monitor-form .om-field input{width:9.5rem;max-width:160px;box-sizing:border-box} + .order-monitor-form .om-live-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding-bottom:2px;margin-left:auto} + .order-monitor-form .om-row-opts{align-items:center;gap:12px;padding-top:2px} + .order-monitor-form .om-check{display:inline-flex;align-items:center;gap:5px;font-size:.82rem;color:#cfd3ef;cursor:pointer;user-select:none} + .order-monitor-form .om-time-close{display:inline-flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef} + .order-monitor-form .om-time-close select{width:auto;min-width:4.2rem;max-width:5.5rem;padding:6px 8px} + .order-monitor-form .om-row-action{padding-top:2px;display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px} + .order-monitor-form .om-submit{min-width:11rem;padding:10px 18px;font-weight:600} + .order-monitor-form .om-submit.is-blocked, + .order-monitor-form .om-submit:disabled{ + opacity:.45; + cursor:not-allowed; + filter:grayscale(.35); + pointer-events:none; + } + .order-monitor-form .om-open-block-note{ + color:var(--danger,#ff7b7b); + font-size:13px; + line-height:1.4; + max-width:min(28rem,100%); + } + .order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem} + #add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto} + .order-preview-risk{color:#ff6b6b} + .order-preview-risk strong{color:#ff8f8f;font-weight:600} + .order-preview-profit{color:#4cd97f} + .order-preview-profit strong{color:#6ee7a0;font-weight:600} + .order-preview-rr{color:#cfd3ef} + .order-preview-rr strong{font-weight:600;color:#dbe4ff} + .order-preview-rr.order-preview-rr-low strong{color:#ff8f8f} + .order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff} + .form-row > button,.form-row > label{flex:0 0 auto} + .form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px} + /* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */ + .journal-card .form-grid{gap:10px} + .journal-card .form-grid > input, + .journal-card .form-grid > select{ + min-width:0; + width:100%; + max-width:100%; + box-sizing:border-box; + } + .journal-card #journal-form textarea[name="note"]{ + display:block;width:100%;max-width:100%;box-sizing:border-box;margin-top:8px; + } + input,select,button,textarea{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff;font-size:.88rem;outline:none} + button{background:linear-gradient(90deg,#4285f4,#7b42ff);border:none;cursor:pointer} + .list{display:flex;flex-direction:column;gap:8px;margin-top:8px;max-height:240px;overflow:auto} + .list-item{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:9px;background:#1a2034;border:1px solid #2a3150;border-radius:8px} + .btn-del{padding:5px 9px;background:#2f2134;color:#ff7b7b;border-radius:8px;text-decoration:none;font-size:.8rem} + .rule-tip{font-size:.8rem;color:#95a2c2;margin-bottom:8px} + table{width:100%;border-collapse:collapse} + th,td{padding:8px;text-align:left;border-bottom:1px solid #25253b;font-size:.85rem} + th{color:#a9a9ff} + .badge{padding:2px 6px;border-radius:6px;font-size:.72rem} + .profit{background:#1e332f;color:#4cd97f} + .loss{background:#331e24;color:#ff6666} + .miss{background:#29241e;color:#eac147} + .direction{background:#1e2533;color:#4cc2ff} + .direction-long{background:#1e332f;color:#4cd97f} + .direction-short{background:#331e24;color:#ff6666} + .pnl-profit{color:#4cd97f;font-weight:600} + .pnl-loss{color:#ff6666;font-weight:600} + .flash{padding:10px;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:12px;text-align:center;border:1px solid #304164} + form.is-form-submitting{opacity:.88;pointer-events:none} + form.is-form-submitting button[type=submit],form.is-form-submitting input[type=submit]{cursor:wait} + .ai-result{background:#1a1a29;border:1px solid #2e2e45;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:220px;overflow:auto;font-size:.84rem;line-height:1.45;margin-top:8px} + .ai-result.ai-result-md,.detail-modal .panel-body.md-review{white-space:normal} + .ai-result-md p,.detail-modal .panel-body.md-review p{margin:6px 0;color:#dde2ff} + .ai-result-md ul,.ai-result-md ol,.detail-modal .panel-body.md-review ul,.detail-modal .panel-body.md-review ol{margin:6px 0 8px 1.25em;padding:0} + .ai-result-md li,.detail-modal .panel-body.md-review li{margin:5px 0;line-height:1.5} + .ai-result-md strong,.detail-modal .panel-body.md-review strong{color:#f0f3ff;font-weight:600} + .ai-result-md h2,.detail-modal .panel-body.md-review h2{font-size:1.02rem;color:#b8c8ff;margin:14px 0 8px;padding-bottom:4px;border-bottom:1px solid #2e2e45} + .ai-result-md h3,.detail-modal .panel-body.md-review h3{font-size:.92rem;color:#c9d4ff;margin:10px 0 6px} + .ai-result-md code,.detail-modal .panel-body.md-review code{background:#252538;padding:1px 4px;border-radius:4px;font-size:.82em} + .ai-result-md .md-raw-block-title,.detail-modal .panel-body.md-review .md-raw-block-title{margin-top:14px;padding-top:10px;border-top:1px dashed #3a3a55;color:#a8b0d8;font-weight:600} + .price-up{color:#4cd97f} + .price-down{color:#ff6666} + .price-flat{color:#cfd3ef} + .panel-list{display:grid;grid-template-columns:1fr 1fr;gap:12px} + .panel-item{background:#141423;border:1px solid #24243b;border-radius:10px;padding:10px;max-height:260px;overflow:auto} + .entry{border-bottom:1px solid #2b2b43;padding:8px 0} + .entry:last-child{border-bottom:none} + .table-del{padding:4px 8px;background:#2f2134;color:#ff7b7b;border:none;border-radius:6px;cursor:pointer;font-size:.78rem} + .mood-grid{display:flex;gap:10px;flex-wrap:wrap;font-size:.82rem;color:#d7d7ea} + .mood-grid label{display:flex;align-items:center;gap:3px} + .screenshot{width:100px;border-radius:6px;cursor:pointer;margin-top:6px} + .modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:2100} + .modal img{max-width:90%;max-height:90%;border-radius:8px} + .detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1200;padding:20px} + .detail-modal .panel{width:min(92vw,980px);max-height:88vh;overflow:auto;background:#121726;border:1px solid #2a3150;border-radius:10px;padding:14px} + .detail-modal .panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px} + .detail-modal .panel-title{font-size:1rem;color:#dbe4ff} + .detail-modal .panel-close{padding:6px 10px;background:#2f2134;color:#ffb2b2;border:none;border-radius:8px;cursor:pointer} + .detail-modal .panel-body{white-space:pre-wrap;line-height:1.5;font-size:.86rem;color:#e5e9ff} + .detail-modal .panel-image{margin-top:10px;max-width:min(100%,680px);border-radius:8px;cursor:pointer;border:1px solid #2a3150} + .detail-modal .panel-actions{display:flex;gap:8px;align-items:center;flex-shrink:0} + .detail-modal .panel-fs{padding:6px 10px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem} + .detail-modal.fullscreen{padding:10px} + .detail-modal.fullscreen .panel{width:100%;height:100%;max-width:none;max-height:none;display:flex;flex-direction:column;overflow:hidden} + .detail-modal.fullscreen .panel-body{flex:1;overflow:auto;min-height:0;font-size:.9rem} + .ai-result-wrap{margin-top:8px} + .ai-result-toolbar{display:flex;gap:8px;margin-top:6px} + .ai-result-toolbar .btn-fs{padding:4px 10px;font-size:.78rem;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:6px;cursor:pointer} + .table-wrap{overflow-x:auto} + .dual-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch} + .dual-panel-grid .card{height:100%;display:flex;flex-direction:column} + .panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto} + .records-card{grid-column:1/-1} + .review-card{grid-column:1/-1} + .review-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap} + .review-card-head h2{margin:0} + .review-card-fs-btn{padding:6px 12px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;white-space:nowrap} + .review-card-fs-btn:hover{filter:brightness(1.08)} + body.review-card-fullscreen-open{overflow:hidden} + .review-card.is-fullscreen{ + position:fixed;inset:12px;z-index:1100;margin:0; + width:auto !important;max-width:none;height:auto; + overflow:auto;display:flex;flex-direction:column; + box-shadow:0 12px 48px rgba(0,0,0,.55); + } + .review-card.is-fullscreen .panel-list{flex:1;min-height:320px} + .review-card.is-fullscreen .panel-item{max-height:none;height:auto;min-height:280px} + .review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)} + @media (max-width: 1200px){ + .stat-box{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))} + } + @media (min-width: 1440px){ + .panel-scroll,.pos-list{max-height:420px} + .records-card .table-wrap{max-height:620px;overflow:auto} + } + @media (min-width: 2200px){ + .container{max-width:min(1720px,90vw)} + } + @media (min-width: 2560px){ + .container{max-width:min(1860px,88vw)} + .dual-panel-grid{gap:18px} + } + @media (min-width: 3000px){ + .container{max-width:min(1980px,86vw)} + .pos-grid{grid-template-columns:repeat(4,minmax(0,1fr))} + } + @media (max-width: 1100px){ + .grid{grid-template-columns:1fr} + .dual-panel-grid{grid-template-columns:1fr} + .records-card,.review-card{grid-column:auto} + .panel-list{grid-template-columns:1fr} + } + @media (max-width: 960px){ + body{padding:10px} + .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))} + .stat-box{grid-template-columns:repeat(2,minmax(0,1fr))} + } + .stats-detail{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:10px} + .stats-detail .stat-item{min-width:0;min-height:0;display:block;text-align:left;padding:10px 12px;align-items:stretch;gap:4px} + .stats-detail .stat-item .value{min-height:0;display:block;font-size:1.05rem} + .stats-detail .stat-item .label{font-size:.75rem} + .stats-detail .stat-item .value{font-size:1.05rem;word-break:break-all} + .export-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;font-size:.85rem} + .export-bar a{color:#8fc8ff;text-decoration:none;padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a} + .export-bar a:hover{background:#1f2740} + .list-window-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;padding:10px 12px;background:#151a2a;border:1px solid #304164;border-radius:10px;font-size:.82rem} + .list-window-bar label{color:#9aa;display:flex;align-items:center;gap:6px} + .stats-segment-block{margin-top:20px;padding-top:14px;border-top:1px solid #3a4468} + .stats-segment-block h2{font-size:1.05rem;color:#dbe4ff;margin-bottom:8px} + .stats-period-tabs{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;position:relative;z-index:2} + .stats-period-tab{background:#151a2a;color:#9aa3bf;border:1px solid #304164;border-radius:8px;padding:7px 14px;font-size:.84rem;cursor:pointer;transition:background .15s,border-color .15s,color .15s} + .stats-period-tab:hover{background:#1c2438;color:#cfd3ef} + .stats-period-tab.active{background:#1f3a5a;color:#8fc8ff;border-color:#3d5f8a;font-weight:600} + .stats-period-pane[hidden]{display:none!important} + .stats-period-range{font-size:.78rem;color:#8892b0;margin-bottom:12px;line-height:1.45} + .inst-stats-viz{display:flex;flex-direction:column;gap:14px;margin-bottom:14px} + .inst-stats-kpis{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px} + .inst-stats-kpi{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:12px 10px;background:#151a2a;border:1px solid #2a3152;border-radius:10px;text-align:center;min-height:88px} + .inst-stats-kpi-val{font-size:1.15rem;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.2} + .inst-stats-kpi-lbl{font-size:.72rem;color:#8892b0;line-height:1.3} + .inst-stats-ring{--win-pct:0;width:56px;height:56px;border-radius:50%;background:conic-gradient(#4cd97f 0 calc(var(--win-pct) * 1%),#ff6b6b calc(var(--win-pct) * 1%) 100%);display:flex;align-items:center;justify-content:center;position:relative} + .inst-stats-ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:#151a2a} + .inst-stats-ring-label{position:relative;z-index:1;font-size:.78rem;font-weight:700;font-variant-numeric:tabular-nums} + .inst-stats-block{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px} + .inst-stats-block-title{font-size:.72rem;color:#8892b0;margin-bottom:8px} + .inst-stats-stacked-bar{display:flex;height:10px;border-radius:6px;overflow:hidden;background:#1e2438} + .inst-stats-stacked-fill{height:100%;min-width:0;transition:width .2s ease} + .inst-stats-stacked-fill--profit{background:#4cd97f} + .inst-stats-stacked-fill--loss{background:#ff6b6b} + .inst-stats-bar-labels{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:.76rem;font-variant-numeric:tabular-nums} + .inst-stats-risk-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px} + .inst-stats-risk-item{display:flex;flex-direction:column;gap:3px;min-width:0} + .inst-stats-risk-item .k{font-size:.7rem;color:#8892b0} + .inst-stats-risk-item .v{font-size:.84rem;font-weight:600;font-variant-numeric:tabular-nums;color:#e8ecf4;word-break:break-word} + .inst-stats-empty{margin:0;padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px} + .inst-stats-details{margin-top:4px} + .inst-stats-details>summary{cursor:pointer;font-size:.84rem;color:#9aa3bf;padding:8px 0;user-select:none;list-style-position:inside} + .inst-stats-details>summary::-webkit-details-marker{color:#6d7689} + .inst-stats-details[open]>summary{margin-bottom:6px;color:#cfd3ef} + .inst-stats-month-table-wrap{overflow:auto;-webkit-overflow-scrolling:touch} + .inst-stats-month-table{width:100%;border-collapse:collapse;font-size:.8rem;font-variant-numeric:tabular-nums} + .inst-stats-month-table th,.inst-stats-month-table td{padding:8px 10px;text-align:right;border-bottom:1px solid #2a3348;white-space:nowrap} + .inst-stats-month-table th:first-child,.inst-stats-month-table td:first-child{text-align:left} + .inst-stats-month-table th{color:#8892b0;font-weight:600;font-size:.72rem} + .inst-stats-month-table td{color:#e8ecf4} + .inst-stats-month-table tbody tr:last-child td{border-bottom:none} + @media (max-width:640px){.inst-stats-kpis{grid-template-columns:1fr}.inst-stats-risk-grid{grid-template-columns:1fr}} + .key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150} + .key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px} + .key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px} + .key-history .list{max-height:200px} + .pos-section{margin-top:12px} + .pos-section-title{font-size:.82rem;color:#8892b0;margin-bottom:8px;font-weight:500} + .pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto} + .dual-panel-grid .pos-list-live{max-height:none;overflow:visible;flex:1 1 auto} + .dual-panel-grid .panel-scroll.pos-list-live{max-height:none;overflow:visible} + .pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px} + .pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px} + .pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0} + .pos-meta-item{display:inline-flex;align-items:center} + .pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659} + .pos-meta-on{color:#6eb5ff} + .pos-meta-off{color:#7d8799} + .pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f} + .pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0} + .pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600} + .pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2} + .pos-side-long{background:#253a6e;color:#6eb5ff} + .pos-side-short{background:#4a2230;color:#ff8a8a} + .pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0} + .pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap} + .pos-entrust-btn:hover{background:#355d96} + .pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap;border:none;cursor:pointer;display:inline-block} + .pos-close-btn:hover{background:#d66565;color:#fff} + .pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348} + .pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px} + .pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px} + .pos-ex-order-main{flex:1;min-width:0;line-height:1.35} + .pos-ex-cancel-btn{padding:3px 10px;background:#3a3048;color:#d4b8ff;border:none;border-radius:6px;font-size:.74rem;cursor:pointer;flex-shrink:0} + .pos-ex-cancel-btn:disabled{opacity:.4;cursor:not-allowed} + .tpsl-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:center;justify-content:center;padding:16px} + .tpsl-modal-backdrop.open{display:flex} + .tpsl-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(440px,100%);max-height:90vh;overflow:auto} + .tpsl-modal h3{margin:0 0 12px;font-size:1rem;color:#fff} + .tpsl-modal .form-row{margin-bottom:10px} + .tpsl-modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px} + .tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem} + .tpsl-modal-submit{background:#2d6a4f;color:#fff} + .tpsl-modal-cancel{background:#3a3f52;color:#ddd} + .review-entry-reason-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9100;align-items:center;justify-content:center;padding:16px} + .review-entry-reason-backdrop.open{display:flex} + .review-entry-reason-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(480px,100%);max-height:90vh;overflow:auto} + .review-entry-reason-modal h3{margin:0 0 8px;font-size:1rem;color:#fff} + .review-entry-reason-hint{margin:0 0 12px;font-size:.82rem;color:#9aa3c7;line-height:1.45} + .review-entry-reason-select{width:100%;padding:8px 10px;border-radius:8px;border:1px solid #3a4a66;background:#121726;color:#e8ecff;font-size:.9rem} + .review-entry-reason-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px} + .review-entry-reason-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem} + .review-entry-reason-ok{background:#2d6a4f;color:#fff} + .review-entry-reason-cancel{background:#3a3f52;color:#ddd} + .pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px} + .pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0} + .pos-label{font-size:.72rem;color:#7d8799} + .pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25} + .pos-val-dash{opacity:.75;color:#8b95a8} + .pos-value.price-up{color:#4cd97f} + .pos-value.price-down{color:#ff6666} + .pos-value.price-flat{color:#e8ecf4} + .pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689} + .pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px} + @media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}} + .stats-card{grid-column:1/-1;margin-top:14px} + .stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer} + .stats-card.collapsed .stats-content{display:none} + .stats-period-block{margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid #2a3150} + .stats-period-block:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0} + .stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px} + .stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4} +#embed-page-root{min-height:120px;position:relative} +.embed-tab-pane[hidden]{display:none!important} +.inst-dash-card{grid-column:1/-1} +.inst-dash-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:6px} +.inst-dash-card > .inst-dash-head h2{font-size:.88rem;margin:0 0 2px;font-weight:600} +.inst-dash-desc{margin:0;font-size:.72rem;line-height:1.35} +.inst-dash-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.inst-dash-updated{font-size:.72rem} +.inst-dash-status{min-height:1.1em;margin:0 0 8px;font-size:.72rem} +.inst-dash-sections{display:flex;flex-direction:column;gap:14px} +.inst-dash-section{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px} +.inst-dash-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px} +.inst-dash-section-head h3{margin:0;font-size:.95rem;color:#dbe4ff} +.inst-dash-count{display:inline-block;min-width:1.4em;padding:1px 7px;margin-left:4px;border-radius:999px;background:#1f3a5a;color:#8fc8ff;font-size:.75rem;font-weight:600} +.inst-dash-empty{margin:0;padding:14px;text-align:center;border:1px dashed #2a3348;border-radius:8px;font-size:.84rem} +.inst-dash-table-wrap{overflow:auto;border:1px solid #2a3150;border-radius:8px} +.inst-dash-table{width:100%;border-collapse:collapse;font-size:.84rem} +.inst-dash-table th,.inst-dash-table td{padding:8px 10px;text-align:left;border-bottom:1px solid #25253b;white-space:nowrap} +.inst-dash-table th{color:#a9a9ff;background:#151a2a;font-weight:600} +.inst-dash-table tbody tr:last-child td{border-bottom:none} +.inst-dash-table tbody tr.inst-dash-row{cursor:pointer} +.inst-dash-table tbody tr.inst-dash-row:hover{background:#1e2740} +.inst-dash-sym-link{color:#8fc8ff;text-decoration:underline} +.inst-dash-dir-long{color:#4cd97f;font-weight:600} +.inst-dash-dir-short{color:#ff6666;font-weight:600} +.inst-dash-status-active{color:#4cd97f;font-weight:600} +.inst-dash-table .pos-tp-profit{color:#cfd3ef} diff --git a/lib/common/static/instance_records_mobile.js b/lib/common/static/instance_records_mobile.js new file mode 100644 index 0000000..8f165d2 --- /dev/null +++ b/lib/common/static/instance_records_mobile.js @@ -0,0 +1,74 @@ +/** + * 手机端:交易记录 / 复盘记录紧凑列表(币种 · 方向 · 盈亏),点击展开详情. + */ +(function (global) { + "use strict"; + + var resizeTimer = null; + + function refreshTradeRecords() { + var UI = global.InstanceUI; + if (!UI) return; + var card = document.querySelector(".records-card"); + if (!card) return; + var tableWrap = card.querySelector(".table-wrap"); + var table = tableWrap && tableWrap.querySelector("table"); + if (!table) return; + + var listEl = card.querySelector(".mobile-record-list"); + var mobile = UI.isMobileCompactRecords(); + + if (!mobile) { + if (listEl) listEl.remove(); + return; + } + + if (!listEl) { + listEl = document.createElement("div"); + listEl.className = "mobile-record-list"; + tableWrap.parentNode.insertBefore(listEl, tableWrap); + } + + var rows = table.querySelectorAll('tr[id^="trade-row-"]'); + listEl.innerHTML = rows.length + ? Array.prototype.map + .call(rows, function (tr) { + return UI.renderMobileTradeRow(tr); + }) + .join("") + : '
    暂无交易记录
    '; + + listEl.querySelectorAll(".mobile-record-row").forEach(function (btn) { + btn.addEventListener("click", function () { + var rowId = btn.getAttribute("data-row-id"); + var tr = rowId && document.getElementById(rowId); + if (tr) UI.openTradeRecordDetailModal(tr); + }); + }); + } + + function onResize() { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(function () { + refreshTradeRecords(); + if (typeof global.loadJournals === "function" && document.getElementById("journal-list")) { + global.loadJournals(); + } + }, 180); + } + + function init() { + refreshTradeRecords(); + global.addEventListener("resize", onResize); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } + + global.InstanceRecordsMobile = { + refresh: refreshTradeRecords, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js new file mode 100644 index 0000000..4565900 --- /dev/null +++ b/lib/common/static/instance_settings_prefs.js @@ -0,0 +1,604 @@ +/** + * 实例:导航显示,env 配置,改密,PM2 重启. + */ +(function (global) { + const DISPLAY = () => global.__INSTANCE_DISPLAY__ || {}; + + function setStatus(el, text, isErr) { + if (!el) return; + el.textContent = text || ""; + el.classList.toggle("err", !!isErr); + } + + async function fetchJson(url, opts) { + const res = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.msg || res.statusText || "请求失败"); + } + return data; + } + + /** 默认关闭的导航开关:缺失时按 false,不能用 !== false */ + const NAV_DEFAULT_OFF = { + show_nav_dashboard: true, + show_nav_account_ledger: true, + show_nav_system_guide: true, + }; + + function navPrefShow(display, key) { + if (!key) return true; + if (NAV_DEFAULT_OFF[key]) return display[key] === true; + return display[key] !== false; + } + + function applyDisplayToNav(display) { + const map = { + dashboard: "show_nav_dashboard", + account_ledger: "show_nav_account_ledger", + key_monitor: "show_nav_key_monitor", + trade: "show_nav_trade", + strategy: "show_nav_strategy", + strategy_records: "show_nav_strategy_records", + records: "show_nav_records", + stats: "show_nav_stats", + options: "show_nav_options", + "options-review": "show_nav_options_review", + options_review: "show_nav_options_review", + "hedge-plan": "show_nav_hedge_plan", + hedge_plan: "show_nav_hedge_plan", + risk_policy: "show_nav_risk_policy", + system_guide: "show_nav_system_guide", + env_config: "show_nav_env_config", + }; + document + .querySelectorAll( + ".embed-top-nav [data-embed-tab], .top-nav a[href^='/'], #inst-mobile-tabbar [data-embed-tab], #inst-mobile-more [data-embed-tab]" + ) + .forEach((a) => { + const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0]; + if (tab === "more") return; + const key = map[tab]; + if (!key) return; + const show = navPrefShow(display, key); + a.classList.toggle("nav-hidden", !show); + a.style.display = show ? "" : "none"; + }); + global.__INSTANCE_DISPLAY__ = display; + } + + function pageNavAllowed(tab) { + const d = DISPLAY(); + const map = { + dashboard: "show_nav_dashboard", + account_ledger: "show_nav_account_ledger", + key_monitor: "show_nav_key_monitor", + trade: "show_nav_trade", + strategy: "show_nav_strategy", + strategy_records: "show_nav_strategy_records", + records: "show_nav_records", + stats: "show_nav_stats", + options: "show_nav_options", + "options-review": "show_nav_options_review", + options_review: "show_nav_options_review", + "hedge-plan": "show_nav_hedge_plan", + hedge_plan: "show_nav_hedge_plan", + risk_policy: "show_nav_risk_policy", + system_guide: "show_nav_system_guide", + env_config: "show_nav_env_config", + }; + const key = map[tab]; + if (!key) return true; + return navPrefShow(d, key); + } + + function displayPrefsRoot() { + const settingsPane = document.querySelector('.embed-tab-pane[data-embed-pane="settings"]'); + if (settingsPane) { + const inSettings = settingsPane.querySelector("#display-prefs-form"); + if (inSettings) return inSettings; + } + const pane = document.querySelector(".embed-tab-pane.is-active-pane"); + if (pane) { + const inPane = pane.querySelector("#display-prefs-form"); + if (inPane) return inPane; + } + return document.getElementById("display-prefs-form"); + } + + function displayPrefsStatusEl() { + const card = document.getElementById("display-prefs-card"); + if (card) { + const el = card.querySelector("#display-prefs-status"); + if (el) return el; + } + return document.getElementById("display-prefs-status"); + } + + function envConfigRoot() { + const activePane = document.querySelector(".embed-tab-pane.is-active-pane"); + if (activePane) { + return activePane.querySelector(".env-config-page"); + } + return document.querySelector(".env-config-page"); + } + + function bindEnvTabs() { + /* Tab 切换由 CSS radio+label 实现 */ + } + + async function loadDisplayPrefsForm(force) { + const root = displayPrefsRoot(); + if (!root) return; + if (!force && root.getAttribute("data-prefs-ssr") === "1" && root.querySelector("[data-pref-key]")) { + return; + } + return loadDisplayPrefsFormIn(root); + } + + async function loadDisplayPrefsFormIn(root) { + try { + const data = await fetchJson("/api/settings/display"); + const display = data.display || {}; + const meta = data.meta || []; + root.innerHTML = ""; + meta.forEach((group) => { + const section = document.createElement("div"); + section.className = "display-prefs-group"; + const title = document.createElement("h3"); + title.className = "settings-subcard-title"; + title.textContent = group.group; + section.appendChild(title); + const grid = document.createElement("div"); + grid.className = "display-prefs-checks"; + (group.entries || []).forEach((item) => { + const label = document.createElement("label"); + label.className = "chk-label"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.dataset.prefKey = item.key; + cb.checked = NAV_DEFAULT_OFF[item.key] + ? display[item.key] === true + : display[item.key] !== false; + label.appendChild(cb); + label.appendChild(document.createTextNode(" " + item.label)); + grid.appendChild(label); + }); + section.appendChild(grid); + root.appendChild(section); + }); + root.setAttribute("data-prefs-ssr", "1"); + } catch (e) { + root.innerHTML = '' + (e.message || "加载失败") + ""; + } + } + + async function saveDisplayPrefs() { + const status = displayPrefsStatusEl(); + const root = displayPrefsRoot(); + if (!root) { + setStatus(status, "未找到导航设置表单", true); + return; + } + const display = {}; + root.querySelectorAll("input[data-pref-key]").forEach((cb) => { + display[cb.dataset.prefKey] = !!cb.checked; + }); + try { + const data = await fetchJson("/api/settings/display", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ display }), + }); + applyDisplayToNav(data.display || display); + setStatus(status, "已保存,导航已更新"); + } catch (e) { + setStatus(status, e.message || "保存失败", true); + } + } + + let envSchemaGroups = []; + + function renderEnvFieldRow(field) { + const row = document.createElement("div"); + row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : ""); + row.dataset.envKey = field.key; + const label = document.createElement("label"); + label.className = "env-field-label"; + label.htmlFor = "env-f-" + field.key; + label.textContent = field.label || field.key; + if (field.restart_required) { + const mark = document.createElement("span"); + mark.className = "env-restart-mark"; + mark.title = "需重启"; + mark.textContent = "*"; + label.appendChild(mark); + } + row.appendChild(label); + if (field.note) { + const note = document.createElement("div"); + note.className = "env-field-note muted"; + note.textContent = field.note; + row.appendChild(note); + } + let input; + if (field.type === "bool") { + input = document.createElement("select"); + input.id = "env-f-" + field.key; + [["true", "开启"], ["false", "关闭"]].forEach(([v, text]) => { + const o = document.createElement("option"); + o.value = v; + o.textContent = text; + input.appendChild(o); + }); + const cur = (field.current || field.default || "false").toLowerCase(); + input.value = cur === "true" || cur === "1" ? "true" : "false"; + } else if (field.type === "select" && Array.isArray(field.options) && field.options.length) { + input = document.createElement("select"); + input.id = "env-f-" + field.key; + const cur = String(field.current || field.default || ""); + const seen = new Set(); + field.options.forEach((opt) => { + const v = String(opt.value != null ? opt.value : ""); + if (seen.has(v)) return; + seen.add(v); + const o = document.createElement("option"); + o.value = v; + o.textContent = opt.label || v; + input.appendChild(o); + }); + if (cur && !seen.has(cur)) { + const o = document.createElement("option"); + o.value = cur; + o.textContent = cur; + input.insertBefore(o, input.firstChild); + } + input.value = cur || (field.options[0] && field.options[0].value) || ""; + } else { + input = document.createElement("input"); + input.id = "env-f-" + field.key; + input.type = "password"; + // 防止浏览器把登录密码自动填进 API Key/Secret(保存对冲开关时曾误写入密钥) + input.autocomplete = "new-password"; + input.setAttribute("data-lpignore", "true"); + input.setAttribute("data-1p-ignore", "true"); + input.setAttribute("data-form-type", "other"); + input.readOnly = true; + input.addEventListener("focus", function () { + input.readOnly = false; + }); + if (field.sensitive) { + input.dataset.envSensitive = "1"; + input.dataset.envDirty = "0"; + input.addEventListener("input", function () { + input.dataset.envDirty = "1"; + }); + if (field.has_value) { + const cur = document.createElement("div"); + cur.className = "env-sensitive-current muted"; + const labelSpan = document.createElement("span"); + labelSpan.textContent = "已配置 "; + const masked = document.createElement("span"); + masked.className = "env-masked-value"; + masked.textContent = field.masked || ""; + cur.appendChild(labelSpan); + cur.appendChild(masked); + row.appendChild(cur); + } + input.placeholder = field.has_value ? "修改时填写新值,留空不修改" : "请输入"; + } else { + input.type = "text"; + input.autocomplete = "off"; + input.value = field.current || field.default || ""; + } + } + input.dataset.envKey = field.key; + input.className = "env-field-input"; + row.appendChild(input); + if (field.hidden) { + row.hidden = true; + row.style.display = "none"; + } + return row; + } + + function renderEnvConfigBody(groups) { + const body = document.createElement("div"); + body.className = "env-config-body card"; + body.id = "env-config-body"; + body.setAttribute("data-env-ssr", "1"); + groups.forEach((_group, idx) => { + const radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "env-section"; + radio.id = "env-sec-" + idx; + radio.className = "env-tab-radio"; + if (idx === 0) radio.checked = true; + body.appendChild(radio); + }); + const tabBar = document.createElement("div"); + tabBar.className = "env-config-tabs"; + tabBar.setAttribute("role", "tablist"); + const panelsWrap = document.createElement("div"); + panelsWrap.className = "env-config-panels"; + panelsWrap.id = "env-config-grid"; + let modeSectionIdx = 0; + groups.forEach((group, idx) => { + if ((group.title || "").indexOf("期权/对冲模式") >= 0) modeSectionIdx = idx; + const label = document.createElement("label"); + label.className = "env-tab-btn"; + label.htmlFor = "env-sec-" + idx; + label.setAttribute("role", "tab"); + label.textContent = group.title || "其他"; + tabBar.appendChild(label); + const panel = document.createElement("section"); + panel.className = "env-panel env-panel--" + idx; + panel.setAttribute("role", "tabpanel"); + if (group.has_restart) { + const hint = document.createElement("p"); + hint.className = "env-panel-hint"; + hint.textContent = "本组含需重启项,修改后请点「保存并重启」."; + panel.appendChild(hint); + } + const grid = document.createElement("div"); + grid.className = "env-form-grid"; + (group.fields || []).forEach((field) => grid.appendChild(renderEnvFieldRow(field))); + panel.appendChild(grid); + panelsWrap.appendChild(panel); + }); + body.appendChild(tabBar); + body.appendChild(panelsWrap); + body.dataset.envModeSectionIdx = String(modeSectionIdx); + bindTradeModeAutoRefresh(body); + bindCompoundBudgetVisibility(body); + return body; + } + + function envFieldRowByKey(body, key) { + if (!body || !key) return null; + const byRow = body.querySelector('.env-field-row[data-env-key="' + key + '"]'); + if (byRow) return byRow; + const input = body.querySelector('.env-field-input[data-env-key="' + key + '"]'); + return input ? input.closest(".env-field-row") : null; + } + + function syncCompoundBudgetVisibility(body) { + if (!body) return; + const compoundSel = body.querySelector( + '.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]' + ); + const budgetRow = envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC"); + if (!budgetRow) return; + const compoundOn = !compoundSel || String(compoundSel.value || "").toLowerCase() === "true"; + budgetRow.hidden = compoundOn; + budgetRow.style.display = compoundOn ? "none" : ""; + } + + function bindCompoundBudgetVisibility(body) { + if (!body) return; + syncCompoundBudgetVisibility(body); + const compoundSel = body.querySelector( + '.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]' + ); + if (!compoundSel || compoundSel.dataset.compoundBudgetBound === "1") return; + compoundSel.dataset.compoundBudgetBound = "1"; + compoundSel.addEventListener("change", () => syncCompoundBudgetVisibility(body)); + } + + function bindTradeModeAutoRefresh(body) { + const modeSel = body.querySelector('.env-field-input[data-env-key="OKX_TRADE_MODE"]'); + if (!modeSel || modeSel.dataset.modeRefreshBound === "1") return; + modeSel.dataset.modeRefreshBound = "1"; + modeSel.addEventListener("change", async () => { + const status = document.getElementById("env-config-status"); + const nextMode = modeSel.value; + setStatus(status, "切换交易模式并刷新配置…"); + try { + await fetchJson("/api/settings/env", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values: { OKX_TRADE_MODE: nextMode } }), + }); + await loadEnvConfig(true); + const page = envConfigRoot() || document.querySelector(".env-config-page"); + const newBody = page && page.querySelector("#env-config-body"); + const idx = newBody && newBody.dataset.envModeSectionIdx; + if (idx != null) { + const radio = document.getElementById("env-sec-" + idx); + if (radio) radio.checked = true; + } + setStatus(status, "交易模式已切换为当前选项,配置区已刷新"); + } catch (e) { + setStatus(status, e.message || "切换失败", true); + } + }); + } + + async function loadEnvConfig(force) { + const root = envConfigRoot(); + const body = root && root.querySelector("#env-config-body"); + if (!force && body && body.getAttribute("data-env-ssr") === "1" && body.querySelector("[data-env-key]")) { + return; + } + return loadEnvConfigIn(root); + } + + async function loadEnvConfigIn(root) { + const page = root || envConfigRoot() || document.querySelector(".env-config-page"); + if (!page) return; + const loading = document.createElement("div"); + loading.className = "env-config-loading-wrap card"; + loading.id = "env-config-body"; + loading.innerHTML = '
    加载配置中…
    '; + const oldBody = page.querySelector("#env-config-body"); + const oldGrid = page.querySelector("#env-config-grid.env-config-loading-wrap"); + if (oldBody) oldBody.replaceWith(loading); + else if (oldGrid) oldGrid.replaceWith(loading); + try { + const data = await fetchJson("/api/settings/env"); + envSchemaGroups = data.groups || []; + loading.replaceWith(renderEnvConfigBody(envSchemaGroups)); + } catch (e) { + loading.innerHTML = '' + (e.message || "加载失败") + ""; + } + } + + function collectEnvValues() { + const root = envConfigRoot(); + const values = {}; + const scope = root || document; + scope.querySelectorAll(".env-field-input[data-env-key]").forEach((el) => { + if (el.dataset.envSensitive === "1" && el.dataset.envDirty !== "1") { + // 未改动过的敏感项不提交,避免浏览器自动填充覆盖已有密钥 + return; + } + values[el.dataset.envKey] = el.value; + }); + return values; + } + + async function saveEnvConfig(restartAfter) { + const status = document.getElementById("env-config-status"); + setStatus(status, "保存中…"); + try { + const data = await fetchJson("/api/settings/env", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values: collectEnvValues() }), + }); + const needRestart = restartAfter || data.restart_required; + if (needRestart) { + setStatus(status, "已保存,正在重启实例…"); + await restartInstance(); + setStatus(status, "保存并重启完成"); + await loadEnvConfig(true); + } else { + setStatus(status, "已保存(即时生效项已应用)"); + await loadEnvConfig(true); + } + } catch (e) { + setStatus(status, e.message || "保存失败", true); + } + } + + async function restartInstance() { + try { + await fetchJson("/api/admin/restart", { method: "POST" }); + } catch (_) { + // 重启会中断当前 HTTP 连接;只要后续 health 恢复即视为成功. + } + const deadline = Date.now() + 90000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2000)); + try { + const h = await fetch("/api/admin/health", { credentials: "same-origin" }); + if (h.ok) return; + } catch (_) {} + } + throw new Error("重启后服务未在预期时间内恢复"); + } + + async function savePassword() { + const status = document.getElementById("pwd-save-status"); + const body = { + old_password: (document.getElementById("pwd-old") || {}).value || "", + new_username: (document.getElementById("pwd-new-username") || {}).value || "", + new_password: (document.getElementById("pwd-new") || {}).value || "", + confirm_password: (document.getElementById("pwd-confirm") || {}).value || "", + }; + try { + const data = await fetchJson("/api/settings/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (data.restart_required) { + setStatus(status, "密码已保存,正在重启…"); + await restartInstance(); + setStatus(status, "密码已更新,请用新密码登录"); + } else { + setStatus(status, "密码已更新"); + } + } catch (e) { + setStatus(status, e.message || "保存失败", true); + } + } + + function installDelegatedHandlers() { + if (document.documentElement.dataset.prefsDelegateBound === "1") return; + document.documentElement.dataset.prefsDelegateBound = "1"; + document.addEventListener("click", (ev) => { + const target = ev.target; + if (!(target instanceof Element)) return; + if (target.closest("#display-prefs-save")) { + ev.preventDefault(); + void saveDisplayPrefs(); + return; + } + if (target.closest("#env-config-save")) { + ev.preventDefault(); + void saveEnvConfig(false); + return; + } + if (target.closest("#env-config-save-restart")) { + ev.preventDefault(); + void saveEnvConfig(true); + return; + } + if (target.closest("#env-config-reload")) { + ev.preventDefault(); + void loadEnvConfig(true); + return; + } + if (target.closest("#pwd-save-btn")) { + ev.preventDefault(); + void savePassword(); + } + }); + } + + function bindClickOnce(id, handler) { + const el = document.getElementById(id); + if (!el || el.dataset.bound === "1") return; + el.dataset.bound = "1"; + el.addEventListener("click", handler); + } + + function bindEvents() { + bindClickOnce("display-prefs-save", saveDisplayPrefs); + bindClickOnce("env-config-save", () => saveEnvConfig(false)); + bindClickOnce("env-config-save-restart", () => saveEnvConfig(true)); + bindClickOnce("env-config-reload", () => loadEnvConfig(true)); + bindClickOnce("pwd-save-btn", savePassword); + } + + function initPage() { + installDelegatedHandlers(); + bindEvents(); + loadDisplayPrefsForm(false); + loadEnvConfig(false); + const root = envConfigRoot(); + const body = root && root.querySelector("#env-config-body"); + if (body) { + bindTradeModeAutoRefresh(body); + bindCompoundBudgetVisibility(body); + } + if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__); + } + + global.InstanceSettingsPrefs = { + pageNavAllowed, + applyDisplayToNav, + loadDisplayPrefsForm, + loadEnvConfig, + bindEnvTabs, + bindEvents, + restartInstance, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initPage); + } else { + initPage(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/instance_stats.js b/lib/common/static/instance_stats.js new file mode 100644 index 0000000..c0eecdf --- /dev/null +++ b/lib/common/static/instance_stats.js @@ -0,0 +1,117 @@ +(function (global) { + "use strict"; + + var PERIODS = ["day", "week", "month", "all"]; + + function statsSegmentSelect() { + return document.getElementById("stats-segment-select"); + } + + function panelFromTrigger(triggerEl) { + if (triggerEl && triggerEl.closest) { + var fromBtn = triggerEl.closest(".stats-segment-panel"); + if (fromBtn) return fromBtn; + } + return null; + } + + function activeSegmentPanel(triggerEl) { + var panel = panelFromTrigger(triggerEl); + if (panel) return panel; + var sel = statsSegmentSelect(); + if (!sel) return null; + var key = sel.value; + return document.querySelector( + '.stats-segment-panel[data-stats-segment="' + key + '"]' + ); + } + + function replaceStatsUrl(params) { + var q = new URLSearchParams(global.location.search); + Object.keys(params).forEach(function (k) { + if (params[k] == null || params[k] === "") q.delete(k); + else q.set(k, params[k]); + }); + var qs = q.toString(); + global.history.replaceState( + null, + "", + qs ? global.location.pathname + "?" + qs : global.location.pathname + ); + } + + function switchStatsPeriod(periodKey, triggerEl) { + var panel = activeSegmentPanel(triggerEl); + if (!panel) return; + var key = PERIODS.indexOf(periodKey) >= 0 ? periodKey : "day"; + panel.querySelectorAll(".stats-period-pane").forEach(function (pane) { + var match = pane.getAttribute("data-stats-period") === key; + if (match) pane.removeAttribute("hidden"); + else pane.setAttribute("hidden", ""); + }); + panel.querySelectorAll(".stats-period-tab").forEach(function (btn) { + var on = btn.getAttribute("data-stats-period") === key; + btn.classList.toggle("active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + replaceStatsUrl({ stats_period: key }); + } + + function switchStatsSegment() { + var sel = statsSegmentSelect(); + if (!sel) return; + var key = sel.value; + document.querySelectorAll(".stats-segment-panel").forEach(function (p) { + p.style.display = + p.getAttribute("data-stats-segment") === key ? "block" : "none"; + }); + replaceStatsUrl({ stats_segment: key }); + var period = + new URLSearchParams(global.location.search).get("stats_period") || "day"; + switchStatsPeriod(period); + } + + function ensurePeriodTabDelegation() { + if (global.__instanceStatsTabsDelegated) return; + global.__instanceStatsTabsDelegated = true; + document.addEventListener( + "click", + function (e) { + var btn = + e.target && e.target.closest + ? e.target.closest(".stats-period-tab") + : null; + if (!btn) return; + var card = document.getElementById("stats-card"); + if (!card || !card.contains(btn)) return; + switchStatsPeriod(btn.getAttribute("data-stats-period") || "day", btn); + }, + true + ); + } + + function initStatsFromUrl() { + var sel = statsSegmentSelect(); + if (!sel) return; + ensurePeriodTabDelegation(); + var url = new URLSearchParams(global.location.search); + var segKey = url.get("stats_segment"); + if ( + segKey && + sel.querySelector('option[value="' + segKey.replace(/"/g, "") + '"]') + ) { + sel.value = segKey; + } + switchStatsSegment(); + var period = url.get("stats_period") || "day"; + if (PERIODS.indexOf(period) < 0) period = "day"; + switchStatsPeriod(period); + } + + ensurePeriodTabDelegation(); + + global.switchStatsSegment = switchStatsSegment; + global.switchStatsPeriod = switchStatsPeriod; + global.initStatsFromUrl = initStatsFromUrl; + global.initStatsSegmentFromUrl = initStatsFromUrl; +})(window); diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css new file mode 100644 index 0000000..59d0413 --- /dev/null +++ b/lib/common/static/instance_theme.css @@ -0,0 +1,6270 @@ +/* 实例页手机端:与中控一致,桌面专属区块隐藏;下载仅电脑端 */ +:root, +html[data-theme="dark"] { + --inst-label: #8892b0; + --inst-muted: #9aa3bf; + --inst-text: #e8ecff; + --inst-nav-idle: #8fc8ff; + --inst-nav-active-fg: #dbe4ff; +} + +html[data-theme="light"] { + --inst-label: #4a6078; + --inst-muted: #5a6f85; + --inst-text: #142232; + --inst-nav-idle: #006e9a; + --inst-nav-active-fg: #004d6e; +} + +@media (max-width: 720px) { + .instance-desktop-only { + display: none !important; + } + + a[href^="/export/"] { + display: none !important; + } + + button[onclick*="exportDailyBundleMd"], + button[onclick*="exportWeeklyBundleMd"] { + display: none !important; + } + + body { + padding: 8px 10px !important; + } + + .header h1 { + font-size: 1rem !important; + line-height: 1.35; + } + + .header-row { + flex-wrap: wrap; + gap: 8px; + } + + .container { + max-width: 100% !important; + width: 100% !important; + padding-left: 0 !important; + padding-right: 0 !important; + overflow: visible !important; + } + + .top-nav { + display: flex !important; + flex-wrap: nowrap !important; + justify-content: flex-start !important; + align-items: stretch; + overflow-x: auto !important; + overflow-y: hidden; + width: 100%; + max-width: 100%; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + scrollbar-width: none; + gap: 6px !important; + margin-bottom: 12px !important; + padding: 2px 2px 6px; + scroll-padding-inline: 10px; + touch-action: pan-x; + } + + .top-nav::-webkit-scrollbar { + display: none; + } + + .top-nav a { + flex: 0 0 auto; + white-space: nowrap; + padding: 8px 12px; + font-size: 0.78rem; + } + + .list-window-bar { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .instance-toolbar-row { + flex-direction: column; + align-items: stretch; + } + + .instance-header-toolbar { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .instance-header-toolbar-filter { + flex-wrap: wrap; + } + + .instance-header-toolbar-end { + width: 100%; + justify-content: flex-end; + } + + .instance-header-theme { + align-self: auto; + } + + .instance-header-stats { + flex-wrap: nowrap; + } + + .stat-strip-inner { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .grid { + gap: 10px; + } + + .card { + padding: 12px; + } + + .form-grid:not(.journal-form-row1):not(.journal-form-row2) { + grid-template-columns: minmax(0, 1fr) !important; + } + + .pos-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + } + + .stat-box { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + } + + .dual-panel-grid { + grid-template-columns: minmax(0, 1fr) !important; + } + + .grid { + grid-template-columns: minmax(0, 1fr) !important; + } + + .records-card .table-wrap { + display: none !important; + } + + .mobile-record-list { + display: flex !important; + flex-direction: column; + gap: 6px; + } + + .mobile-record-row-wrap { + display: flex; + align-items: stretch; + gap: 6px; + } + + .mobile-record-row { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1.2fr) auto minmax(0, 0.9fr); + align-items: center; + gap: 8px; + width: 100%; + margin: 0; + padding: 10px 12px; + border: 1px solid rgba(120, 140, 200, 0.28); + border-radius: 8px; + background: rgba(18, 24, 42, 0.65); + color: #e8ecff; + font-size: 0.82rem; + text-align: left; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + + .mobile-record-row:active { + background: rgba(30, 42, 72, 0.85); + } + + .mrr-symbol { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mrr-dir { + justify-self: center; + } + + .mrr-dir .badge { + font-size: 0.72rem; + padding: 2px 8px; + } + + .mrr-pnl { + justify-self: end; + font-weight: 600; + white-space: nowrap; + } + + .mrr-muted { + color: #8892b0; + font-size: 0.78rem; + } + + .mobile-record-del { + flex: 0 0 36px; + width: 36px; + border: 1px solid rgba(200, 80, 80, 0.35); + border-radius: 8px; + background: rgba(80, 24, 24, 0.35); + color: #ff9a9a; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + } + + #journal-list .entry { + display: none; + } + + #journal-list .journal-empty-msg { + color: #8892b0; + font-size: 0.82rem; + padding: 8px 4px; + } + + #detailActions.detail-actions, + .detail-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 14px 14px; + border-top: 1px solid rgba(120, 140, 200, 0.2); + } + + .detail-actions-inner { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: 100%; + } + + .detail-actions .table-del, + .detail-actions button { + font-size: 0.78rem !important; + padding: 6px 10px !important; + } + + .detail-modal .panel-body.trade-record-detail-wrap { + white-space: normal; + } + + .trd-row { + grid-template-columns: 76px minmax(0, 1fr); + } +} + +@media (min-width: 721px) { + .mobile-record-list { + display: none !important; + } +} + +.detail-modal .panel-body.trade-record-detail-wrap { + white-space: normal; +} + +.trade-record-detail { + display: flex; + flex-direction: column; + gap: 8px; +} + +.trd-row { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 8px 12px; + align-items: center; + line-height: 1.45; +} + +.trd-label { + color: #8892b0; + font-size: 0.82rem; +} + +.trd-value { + color: #e5e9ff; + font-size: 0.86rem; + text-align: left; + min-width: 0; +} + +.trd-value .badge { + display: inline-block; + vertical-align: middle; +} + +/* 手机竖屏(含大屏手机) */ +@media (max-width: 900px) and (orientation: portrait) { + .grid { + grid-template-columns: minmax(0, 1fr) !important; + } + + .dual-panel-grid { + grid-template-columns: minmax(0, 1fr) !important; + } + + .form-grid:not(.journal-form-row1):not(.journal-form-row2) { + grid-template-columns: minmax(0, 1fr) !important; + } +} + +/* 平板横屏:双列布局,充分利用宽屏 */ +@media (min-width: 721px) and (max-width: 1200px) and (orientation: landscape) { + body { + padding: 10px 14px !important; + } + + .grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 12px; + } + + .dual-panel-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + } + + .form-grid:not(.journal-form-row1):not(.journal-form-row2) { + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; + } + + .pos-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; + } + + .stat-box { + grid-template-columns: repeat(4, minmax(0, 1fr)) !important; + } + + .records-card, + .review-card { + grid-column: 1 / -1; + } +} + +html[data-theme="light"] { + background: #c8d4de; + color-scheme: light; +} + +html[data-theme="light"] body { + background: #c8d4de !important; + color: #142232 !important; +} + +html[data-theme="light"] .header h1 { + color: #142232 !important; +} + +html[data-theme="light"] .exchange-tag { + color: #087a50 !important; + background: rgba(10, 143, 92, 0.12) !important; + border-color: rgba(10, 143, 92, 0.35) !important; +} + +html[data-theme="light"] .top-nav a { + background: #fff !important; + color: var(--inst-nav-idle) !important; + border-color: rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] .top-nav a:hover, +html[data-theme="light"] .embed-top-nav a:hover, +html[data-theme="light"] .strategy-subnav a:hover { + background: rgba(0, 110, 154, 0.1) !important; + color: var(--inst-nav-active-fg) !important; +} + +html[data-theme="light"] .top-nav a.active, +html[data-theme="light"] .embed-top-nav a.active { + background: rgba(0, 110, 154, 0.12) !important; + color: var(--inst-nav-active-fg) !important; + border: 1px solid rgba(0, 95, 140, 0.28) !important; + font-weight: 600; +} + +html[data-theme="light"] .stat-item, +html[data-theme="light"] .card, +html[data-theme="light"] .meta-item, +html[data-theme="light"] .list-item, +html[data-theme="light"] .journal-card { + background: #fff !important; + border-color: #9eb0c4 !important; + box-shadow: 0 1px 3px rgba(20, 34, 50, 0.06); +} + +html[data-theme="light"] .stat-item .label, +html[data-theme="light"] .status, +html[data-theme="light"] .rule-tip, +html[data-theme="light"] .muted { + color: #3a5068 !important; +} + +html[data-theme="light"] .stat-item .value, +html[data-theme="light"] .card h2 { + color: #142232 !important; +} + +html[data-theme="light"] input:not([type="checkbox"]):not([type="radio"]), +html[data-theme="light"] select, +html[data-theme="light"] textarea { + background: #fff !important; + color: #142232 !important; + border-color: #9eb0c4 !important; +} + +html[data-theme="light"] input[type="checkbox"], +html[data-theme="light"] input[type="radio"] { + accent-color: #007aa8; + background: transparent !important; + border: none !important; + width: 1rem; + height: 1rem; + cursor: pointer; +} + +html[data-theme="light"] .mood-grid { + color: #1a2838 !important; +} + +html[data-theme="light"] .mood-grid label { + color: #1a2838 !important; +} + +/* 复盘区次要按钮(内联 #1f3a5a):浅底深字,避免白字看不见 */ +html[data-theme="light"] .journal-card .form-row button[type="button"], +html[data-theme="light"] .review-card .form-row button[type="button"][onclick*="export"], +html[data-theme="light"] .review-card-fs-btn, +html[data-theme="light"] .ai-result-toolbar .btn-fs { + background: #e8eef5 !important; + background-image: none !important; + color: #006e9a !important; + border: 1px solid rgba(0, 95, 140, 0.28) !important; +} + +html[data-theme="light"] .journal-card button[type="submit"], +html[data-theme="light"] .review-card .form-row button[onclick="genDaily()"], +html[data-theme="light"] .review-card .form-row button[onclick="genWeekly()"] { + background: linear-gradient(90deg, #007aa8, #5b4fc7) !important; + color: #fff !important; + border: none !important; +} + +html[data-theme="light"] .flash { + background: rgba(0, 110, 154, 0.1) !important; + color: #006e9a !important; + border-color: rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] th { + color: #334155 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] td { + color: #142232 !important; + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .ai-result, +html[data-theme="light"] .login-box { + background: #fff !important; + border-color: #b8c8d8 !important; + color: #142232 !important; +} + +html[data-theme="light"] #chart-wrap { + background: #f0f4f9 !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .btn { + background: #fff !important; + color: #006e9a !important; + border-color: rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] .btn:hover { + background: #eef3f8 !important; +} + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border-radius: 8px; + border: 1px solid #304164; + background: #151a2a; +} + +html[data-theme="light"] .theme-toggle { + background: #fff; + border-color: #b8c8d8; +} + +.theme-toggle.is-hub-linked { + display: none !important; +} + +.theme-toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 30px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #8fc8ff; + cursor: pointer; +} + +html[data-theme="light"] .theme-toggle-btn { + color: #334155; +} + +.theme-toggle-btn.is-active { + color: #dbe4ff; + background: rgba(79, 121, 255, 0.2); + box-shadow: inset 0 0 0 1px #304164; +} + +html[data-theme="light"] .theme-toggle-btn.is-active { + color: #004d6e; + background: rgba(0, 110, 154, 0.16); + box-shadow: inset 0 0 0 1px #9eb0c4; +} + +.header-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 6px; +} + +/* ── 统一顶栏面板:状态/筛选 + 统计条 ── */ +.instance-header-panel { + margin-top: 18px; + margin-bottom: 12px; + padding: 10px 14px; +} + +.instance-header-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 14px; +} + +.instance-toolbar-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + flex: 0 1 auto; +} + +.instance-header-toolbar-filter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + flex: 1 1 200px; + min-width: 0; + font-size: 0.82rem; +} + +.instance-header-toolbar-end { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex: 0 1 auto; + margin-left: auto; +} + +.instance-header-toolbar-filter label { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--inst-label); + margin: 0; +} + +.instance-header-theme { + flex: 0 0 auto; +} + +.list-window-label { + color: var(--inst-text); + font-size: 0.82rem; + white-space: nowrap; +} + +.list-window-hint { + color: var(--inst-muted); + font-size: 0.72rem; + white-space: nowrap; +} + +.list-window-apply { + padding: 5px 12px; + font-size: 0.82rem; +} + +.instance-header-stats-wrap { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-soft, #2a3150); +} + +.instance-header-stats { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + gap: 0; + padding: 2px 0 0; + min-height: 56px; + align-items: stretch; + scrollbar-width: thin; +} + +.instance-header-stats--options .stat-strip-item { + min-width: 72px; +} + +.stat-strip-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1 1 0; + min-width: 64px; + padding: 4px 8px; + border-right: 1px solid var(--border-soft, #2a3150); + text-align: center; +} + +.stat-strip-item:first-child { + padding-left: 4px; +} + +.stat-strip-item:last-child { + border-right: none; + padding-right: 4px; +} + +.stat-strip-item .label { + font-size: 0.72rem; + color: var(--inst-label); + margin-bottom: 6px; + white-space: nowrap; +} + +.stat-strip-item .value { + font-size: 0.88rem; + font-weight: 600; + color: var(--inst-text); + line-height: 1.3; + white-space: nowrap; +} + +.stat-strip-item--primary .label { + font-size: 0.76rem; +} + +.stat-strip-item--primary .value { + font-size: 1.02rem; + font-weight: 700; +} + +.stat-strip-item--pnl .value.pnl-pos { + color: #3dd68c; +} + +.stat-strip-item--pnl .value.pnl-neg { + color: #ff6b7a; +} + +@media (max-width: 1100px) { + .instance-header-stats { + flex-wrap: nowrap; + } + + .stat-strip-item { + flex: 0 0 auto; + min-width: 72px; + border-right: none; + padding: 6px 8px; + border-right: 1px solid var(--border-soft, #2a3150); + } + + .stat-strip-item:first-child { + padding-left: 6px; + } +} + +/* 旧片段兼容(若仍被引用) */ +.instance-toolbar-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 14px; + margin-bottom: 12px; +} + +.instance-toolbar-filter.list-window-bar { + flex: 1 1 320px; + margin-bottom: 0; +} + +.stat-strip { + margin-bottom: 12px; + padding: 10px 14px; +} + +.stat-strip-inner { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0; + align-items: stretch; +} + +html[data-theme="light"] .stat-strip-item .value { + color: var(--inst-text); +} + +html[data-theme="light"] .stat-strip-item .label, +html[data-theme="light"] .instance-header-toolbar-filter label { + color: var(--inst-label) !important; +} + +html[data-theme="light"] .list-window-hint { + color: var(--inst-muted) !important; +} + +html[data-theme="light"] .instance-header-stats { + border-top-color: #c8d4e0; +} + +html[data-theme="light"] .stat-strip-item { + border-right-color: #d8e2ec; +} + +html[data-theme="light"] .list-window-label { + color: #1a2838; +} + +.login-theme-bar { + display: flex; + justify-content: flex-end; + width: 100%; + max-width: 400px; + margin: 0 0 10px; + flex-shrink: 0; +} + +/* ── 交易执行 / 复盘 / 统计(index 内联样式覆盖)── */ +html[data-theme="light"] .list-window-bar, +html[data-theme="light"] .export-bar a { + background: #fff !important; + border-color: #b8c8d8 !important; + color: #1a2838 !important; +} + +html[data-theme="light"] .list-window-bar label, +html[data-theme="light"] .export-bar { + color: #4a6078 !important; +} + +html[data-theme="light"] .stats-segment-block { + border-top-color: #c8d4e0 !important; +} + +html[data-theme="light"] .stats-segment-block h2, +html[data-theme="light"] .stats-period-block h3, +html[data-theme="light"] .key-history h3 { + color: #142232 !important; +} + +html[data-theme="light"] .stats-period-block .sub, +html[data-theme="light"] .key-history .sub, +html[data-theme="light"] .pos-section-title, +html[data-theme="light"] .pos-empty { + color: #4a6078 !important; +} + +html[data-theme="light"] .stats-period-block { + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .stats-period-tab { + background: #f4f7fb !important; + color: #4a6078 !important; + border-color: #c8d4e0 !important; +} +html[data-theme="light"] .stats-period-tab:hover { + background: #e8eef5 !important; + color: #142232 !important; +} +html[data-theme="light"] .stats-period-tab.active { + background: #dce8f5 !important; + color: #0d4a7a !important; + border-color: #7eb0d8 !important; +} +html[data-theme="light"] .stats-period-range, +html[data-theme="light"] .inst-stats-kpi-lbl, +html[data-theme="light"] .inst-stats-block-title, +html[data-theme="light"] .inst-stats-risk-item .k, +html[data-theme="light"] .inst-stats-empty { + color: #4a6078 !important; +} +html[data-theme="light"] .inst-stats-kpi, +html[data-theme="light"] .inst-stats-block { + background: #f8fafc !important; + border-color: #c8d4e0 !important; +} +html[data-theme="light"] .inst-stats-ring::before { + background: #f8fafc !important; +} +html[data-theme="light"] .inst-stats-stacked-bar { + background: #e2e8f0 !important; +} +html[data-theme="light"] .inst-stats-risk-item .v, +html[data-theme="light"] .inst-stats-details > summary { + color: #142232 !important; +} +html[data-theme="light"] .inst-stats-details[open] > summary { + color: #0d4a7a !important; +} +html[data-theme="light"] .inst-stats-month-table th { + color: #4a6078 !important; +} +html[data-theme="light"] .inst-stats-month-table td { + color: #142232 !important; + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .key-history { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .pos-card, +html[data-theme="light"] .pos-empty { + background: #fff !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .pos-card-symbol strong, +html[data-theme="light"] .pos-value, +html[data-theme="light"] .pos-value.price-flat { + color: #142232 !important; +} + +html[data-theme="light"] .pos-label, +html[data-theme="light"] .pos-meta, +html[data-theme="light"] .pos-footer, +html[data-theme="light"] .pos-ex-orders-title, +html[data-theme="light"] .pos-ex-order-row { + color: #1e293b !important; +} + +html[data-theme="light"] .pos-meta-item::after { + color: #94a3b8 !important; +} + +.pos-time-close-meta { + color: #8fc8ff; +} +.pos-time-close-meta .pos-time-close-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} +.pos-symbol-time-close { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 500; + color: #8fc8ff; + padding: 1px 6px; + border-radius: 4px; + background: rgba(143, 200, 255, 0.1); + white-space: nowrap; +} +.pos-symbol-time-close .pos-time-close-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; +} +.force-close-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.78rem; + font-weight: 600; + color: #ffc870; + background: #2a2218; + border: 1px solid #6a5020; + padding: 4px 12px; + border-radius: 999px; + letter-spacing: 0.02em; + white-space: nowrap; +} +.force-close-badge .force-close-header-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; +} +.pos-force-close-meta { + color: #ffc870; +} +.pos-symbol-force-close { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 500; + color: #ffc870; + padding: 1px 6px; + border-radius: 4px; + background: rgba(255, 200, 112, 0.12); + white-space: nowrap; +} +.pos-symbol-force-close .pos-force-close-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; +} +html[data-theme="light"] .force-close-badge { + color: #9a6200; + background: #fff6e8; + border-color: #d4a84a; +} +html[data-theme="light"] .pos-symbol-force-close, +html[data-theme="light"] .pos-force-close-meta { + color: #9a6200; + background: rgba(212, 168, 74, 0.14); +} +.key-time-close-wrap.is-disabled > label, +.order-time-close-wrap.is-disabled > label { + opacity: 0.72; +} +.key-time-close-wrap select, +.order-time-close-wrap select { + cursor: pointer; +} +html[data-theme="light"] .pos-meta-on { + color: #006e9a !important; +} + +html[data-theme="light"] .pos-side-long { + background: #006e9a !important; + color: #fff !important; + border: 1px solid #005a82 !important; +} + +html[data-theme="light"] .pos-side-short { + background: #b03030 !important; + color: #fff !important; + border: 1px solid #8a2424 !important; +} + +html[data-theme="light"] .pos-entrust-btn, +html[data-theme="light"] .stats-card .stats-toggle, +html[data-theme="light"] .btn-del[style*="1f3a5a"], +html[data-theme="light"] a.btn-del[style*="1f3a5a"], +html[data-theme="light"] .detail-modal .panel-fs, +html[data-theme="light"] .review-card-fs-btn { + background: #e8eef5 !important; + color: #006e9a !important; +} + +html[data-theme="light"] .pos-ex-orders { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .pos-ex-cancel-btn { + background: #eef3f8 !important; + color: #5b4fc7 !important; +} + +html[data-theme="light"] .tpsl-modal { + background: #fff !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .tpsl-modal h3 { + color: #142232 !important; +} + +html[data-theme="light"] .tpsl-modal-cancel { + background: #eef3f8 !important; + color: #4a6078 !important; +} + +html[data-theme="light"] .list-item { + background: #f6f9fc !important; + border-color: #d0dae4 !important; +} + +html[data-theme="light"] .price-flat { + color: #4a6078 !important; +} + +html[data-theme="light"] .detail-modal .panel, +html[data-theme="light"] .ai-result { + background: #fff !important; +} + +html[data-theme="light"] .detail-modal .panel-title { + color: #142232 !important; +} + +/* 交易复盘详情:上方元数据(非 Markdown 区)浅色主题对比度 */ +html[data-theme="light"] .detail-modal .panel-body:not(.md-review) { + color: #1a2838 !important; +} + +html[data-theme="light"] .detail-modal .panel { + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .detail-modal .panel-image { + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .detail-modal .panel-close { + background: #f6f9fc !important; + color: #4a6078 !important; + border: 1px solid #b8c8d8 !important; +} + +/* ── 交易记录:方向 / 结果徽章(浅底描边,避免黑底块)── */ +html[data-theme="light"] .badge.direction-long, +html[data-theme="light"] .direction-long { + background: rgba(8, 122, 80, 0.1) !important; + color: #087a50 !important; + border: 1px solid rgba(8, 122, 80, 0.28) !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .badge.direction-short, +html[data-theme="light"] .direction-short { + background: rgba(192, 48, 48, 0.08) !important; + color: #b03030 !important; + border: 1px solid rgba(192, 48, 48, 0.25) !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .badge.profit { + background: rgba(8, 122, 80, 0.1) !important; + color: #087a50 !important; + border: 1px solid rgba(8, 122, 80, 0.28) !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .badge.loss { + background: rgba(192, 48, 48, 0.08) !important; + color: #b03030 !important; + border: 1px solid rgba(192, 48, 48, 0.25) !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .badge.miss { + background: rgba(180, 130, 20, 0.1) !important; + color: #8a6200 !important; + border: 1px solid rgba(180, 130, 20, 0.28) !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .badge.direction { + background: rgba(0, 110, 154, 0.08) !important; + color: #006e9a !important; + border: 1px solid rgba(0, 110, 154, 0.22) !important; +} + +html[data-theme="light"] .table-del, +html[data-theme="light"] button.table-del { + background: #fff5f5 !important; + color: #b03030 !important; + border: 1px solid rgba(176, 48, 48, 0.28) !important; +} + +html[data-theme="light"] .pos-breakeven-badge { + background: rgba(8, 122, 80, 0.1) !important; + color: #087a50 !important; + border: 1px solid rgba(8, 122, 80, 0.25) !important; +} + +/* ── 实时持仓 / 行情:浮盈亏涨跌色 ── */ +html[data-theme="light"] .price-up, +html[data-theme="light"] .pos-value.price-up { + color: #087a50 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .price-down, +html[data-theme="light"] .pos-value.price-down { + color: #c03030 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .journal-detail-meta { + color: #1a2838 !important; + line-height: 1.65 !important; +} + +html[data-theme="light"] .journal-card .form-grid label, +html[data-theme="light"] .journal-card .sub { + color: #4a6078 !important; +} + +html[data-theme="light"] .btn-del:not([style*="1f3a5a"]) { + background: #fff5f5 !important; + color: #b03030 !important; + border: 1px solid rgba(176, 48, 48, 0.25) !important; +} + +html[data-theme="light"] table th { + background: #eef3f8 !important; +} + +html[data-theme="light"] .strategy-subnav { + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .strategy-subnav a { + background: #fff !important; + color: var(--inst-nav-idle) !important; + border-color: rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] .strategy-subnav a.active { + background: rgba(0, 110, 154, 0.12) !important; + color: var(--inst-nav-active-fg) !important; + border: 1px solid rgba(0, 95, 140, 0.28) !important; + font-weight: 600; +} + +/* ── 策略交易 / 策略记录(strategy_templates 内联)── */ +html[data-theme="dark"] .strategy-records-page .sr-summary, +html[data-theme="dark"] .strategy-records-page .sr-detail { + color: #cfd3ef !important; +} +html[data-theme="dark"] .strategy-records-page .sr-summary .sr-sym, +html[data-theme="dark"] .strategy-records-page .sr-detail-grid .val { + color: #f0f2ff !important; +} +html[data-theme="dark"] .strategy-records-page .sr-summary .sr-dca-tag { + color: #8892b0 !important; +} +html[data-theme="dark"] .strategy-records-page .sr-summary .sr-pnl.pos, +html[data-theme="dark"] .strategy-records-page .sr-pnl.pos { + color: #4cd97f !important; +} +html[data-theme="dark"] .strategy-records-page .sr-summary .sr-pnl.neg, +html[data-theme="dark"] .strategy-records-page .sr-pnl.neg { + color: #ff6666 !important; +} + +html[data-theme="light"] .strategy-records-page h2, +html[data-theme="light"] .plan-card-title, +html[data-theme="light"] .sr-panel-title, +html[data-theme="light"] .sr-summary .sr-sym, +html[data-theme="light"] .sr-detail-grid .val, +html[data-theme="light"] .plan-cell .val:not(.pnl-profit):not(.pnl-loss) { + color: #142232 !important; +} + +html[data-theme="light"] .plan-cell .val.pnl-profit, +html[data-theme="light"] .pnl-profit { + color: #087a50 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .plan-cell .val.pnl-loss, +html[data-theme="light"] .pnl-loss { + color: #c03030 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .plan-dca-table td.st-done, +html[data-theme="light"] .plan-dca-table .st-done, +html[data-theme="light"] .sr-dca-table .st-done { + color: #087a50 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .plan-dca-table .st-pending, +html[data-theme="light"] .sr-dca-table .st-pending { + color: #6a7588 !important; +} + +html[data-theme="light"] .strategy-records-tip, +html[data-theme="light"] .plan-card-meta, +html[data-theme="light"] .plan-cell .lbl, +html[data-theme="light"] .sr-panel-count, +html[data-theme="light"] .sr-empty, +html[data-theme="light"] .plan-dca-title { + color: #4a6078 !important; +} + +html[data-theme="light"] .plan-position-card, +html[data-theme="light"] .sr-filters, +html[data-theme="light"] .sr-panel { + background: #fff !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .sr-filters select, +html[data-theme="light"] .sr-filters input[type="datetime-local"] { + background: #f6f9fc !important; + color: #142232 !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .sr-chip { + background: #fff !important; + color: #4a6078 !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .sr-chip.active { + background: rgba(0, 110, 154, 0.12) !important; + color: #006e9a !important; + border-color: rgba(0, 95, 140, 0.35) !important; +} + +html[data-theme="light"] .sr-item { + background: #f6f9fc !important; + border-color: #d0dae4 !important; +} + +html[data-theme="light"] .sr-summary, +html[data-theme="light"] .sr-detail, +html[data-theme="light"] .plan-cell .val.pnl-neutral { + color: #1a2838 !important; +} + +html[data-theme="light"] .sr-summary:hover { + background: rgba(0, 110, 154, 0.06) !important; +} + +html[data-theme="light"] .sr-detail { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .plan-dca-block { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .plan-dca-table th, +html[data-theme="light"] .plan-dca-table td, +html[data-theme="light"] .sr-dca-table th, +html[data-theme="light"] .sr-dca-table td { + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .plan-dca-table th, +html[data-theme="light"] .sr-dca-table th { + color: #4a6078 !important; +} + +html[data-theme="light"] .trend-running-plans { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .plan-card-meta .accent, +html[data-theme="light"] .sr-panel-title.trend, +html[data-theme="light"] .sr-summary::before { + color: #006e9a !important; +} + +html[data-theme="light"] .sr-panel-title.roll { + color: #a06010 !important; +} + +html[data-theme="light"] .btn-close-plan { + background: #fff5f5 !important; + color: #b03030 !important; +} + +html[data-theme="light"] .running-plans-stack .plan-position-card[style*="8892b0"] { + color: #4a6078 !important; + background: #f6f9fc !important; +} + +html[data-theme="light"] button[style*="1f4a3a"] { + background: #e8f5ef !important; + color: #087a50 !important; +} + +html[data-theme="light"] .strategy-trading-grid .card, +html[data-theme="light"] .dual-panel-grid .card { + background: #fff !important; +} + +/* ── AI 复盘(panel-list / ai-result)── */ +html[data-theme="light"] .panel-item { + background: #fff !important; + border-color: #b8c8d8 !important; + color: #1a2838 !important; +} + +html[data-theme="light"] .panel-item strong { + color: #142232 !important; +} + +html[data-theme="light"] .panel-item .entry { + border-bottom-color: #d0dae4 !important; + color: #1a2838 !important; +} + +html[data-theme="light"] .panel-item .entry div { + color: #4a6078 !important; +} + +html[data-theme="light"] .ai-result { + background: #f6f9fc !important; + border-color: #b8c8d8 !important; + color: #1a2838 !important; +} + +.ai-result.is-loading { + color: #8fc8ff; + font-style: italic; + animation: ai-review-pulse 1.2s ease-in-out infinite; +} + +html[data-theme="light"] .ai-result.is-loading { + color: #006e9a !important; +} + +@keyframes ai-review-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +/* AI 日复盘 / 周复盘 Markdown(弹窗 + 内联结果区,三所共用) */ +html[data-theme="light"] .ai-result-md, +html[data-theme="light"] .detail-modal .panel-body.md-review { + color: #1a2838 !important; +} + +html[data-theme="light"] .ai-result-md p, +html[data-theme="light"] .detail-modal .panel-body.md-review p, +html[data-theme="light"] .ai-result-md li, +html[data-theme="light"] .detail-modal .panel-body.md-review li, +html[data-theme="light"] .ai-result-md ol, +html[data-theme="light"] .ai-result-md ul, +html[data-theme="light"] .detail-modal .panel-body.md-review ol, +html[data-theme="light"] .detail-modal .panel-body.md-review ul { + color: #1a2838 !important; +} + +html[data-theme="light"] .ai-result-md strong, +html[data-theme="light"] .detail-modal .panel-body.md-review strong { + color: #142232 !important; +} + +html[data-theme="light"] .ai-result-md h2, +html[data-theme="light"] .detail-modal .panel-body.md-review h2, +html[data-theme="light"] .ai-result-md h3, +html[data-theme="light"] .detail-modal .panel-body.md-review h3, +html[data-theme="light"] .ai-result-md h4, +html[data-theme="light"] .detail-modal .panel-body.md-review h4 { + color: #142232 !important; +} + +html[data-theme="light"] .ai-result-md h2, +html[data-theme="light"] .detail-modal .panel-body.md-review h2 { + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .ai-result-md h3, +html[data-theme="light"] .detail-modal .panel-body.md-review h3 { + color: #006e9a !important; +} + +html[data-theme="light"] .ai-result-md code, +html[data-theme="light"] .detail-modal .panel-body.md-review code { + background: #eef3f8 !important; + color: #142232 !important; +} + +html[data-theme="light"] .ai-result-md .md-raw-block-title, +html[data-theme="light"] .detail-modal .panel-body.md-review .md-raw-block-title { + color: #4a6078 !important; + border-top-color: #d0dae4 !important; +} + +/* ── 统计分栏(机器人 / 趋势回调)── */ +html[data-theme="light"] .stats-split-col { + background: #fff !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .stats-split-head { + color: #142232 !important; + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .stats-split-col .stat-item { + background: #f6f9fc !important; + border-color: #d0dae4 !important; +} + +html[data-theme="light"] .stats-split-col .stat-item .label { + color: #4a6078 !important; +} + +html[data-theme="light"] .stats-split-col .stat-item .value { + color: #142232 !important; +} + +/* ── 可折叠说明(规则 / 划转 / 价格)── */ +.tip-collapse { + margin-bottom: 8px; + border: 1px solid #2a3348; + border-radius: 8px; + background: rgba(20, 25, 35, 0.45); + overflow: hidden; +} + +.tip-collapse-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 8px; + padding: 8px 12px; + cursor: pointer; + list-style: none; + font-size: 0.8rem; + color: #95a2c2; + line-height: 1.45; +} + +.tip-collapse-summary::-webkit-details-marker { + display: none; +} + +.tip-collapse-summary::before { + content: "▸"; + flex: 0 0 auto; + color: #6d7a99; + transition: transform 0.15s ease; +} + +.tip-collapse[open] > .tip-collapse-summary::before { + transform: rotate(90deg); +} + +.tip-collapse-hint { + color: #6d7a99; + font-size: 0.74rem; +} + +.tip-collapse-body { + padding: 0 12px 10px; + border-top: 1px solid #232b3d; +} + +.tip-collapse-body.rule-tip { + margin-bottom: 0; + padding-top: 8px; +} + +html[data-theme="light"] .tip-collapse { + background: #f6f9fc !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .tip-collapse-summary { + color: #4a6078 !important; +} + +html[data-theme="light"] .tip-collapse-summary::before { + color: #6a7588 !important; +} + +html[data-theme="light"] .tip-collapse-hint { + color: #6a7588 !important; +} + +html[data-theme="light"] .tip-collapse-body { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .tip-collapse-body.rule-tip { + color: #4a6078 !important; +} + +html[data-theme="light"] .key-rule-table th, +html[data-theme="light"] .key-rule-table td { + border-color: #d0dae4 !important; +} + +html[data-theme="light"] .key-rule-table th { + background: #eef3f8 !important; + color: #4a6078 !important; +} + +html[data-theme="light"] .key-rule-table td { + color: #142232 !important; +} + +html[data-theme="light"] .key-rule-table .key-rule-type { + color: #142232 !important; +} + +html[data-theme="light"] .key-rule-table .key-rule-sub { + color: #006e9a !important; +} + +html[data-theme="light"] .key-rule-foot { + color: #6a7588 !important; +} + +html[data-theme="light"] .key-rule-foot code { + color: #006e9a !important; +} + +/* ── 关键位折叠行(亮色)── */ +html[data-theme="light"] .key-row-collapse { + background: #f6f9fc !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .key-row-collapse-summary { + color: #1a2838 !important; +} + +html[data-theme="light"] .key-row-collapse-summary::before { + color: #6a7588 !important; +} + +html[data-theme="light"] .key-row-summary-title strong { + color: #142232 !important; +} + +html[data-theme="light"] .key-row-summary-line, +html[data-theme="light"] .key-history-brief { + color: #4a6078 !important; +} + +html[data-theme="light"] .key-row-summary-live { + color: #006e9a !important; +} + +html[data-theme="light"] .key-row-summary-live.key-row-summary-pending { + color: #087a50 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .key-row-collapse-body { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .key-history-alert { + color: #4a6078 !important; +} + +html[data-theme="light"] .key-row-collapse .pos-side-badge[style*="2a3152"] { + background: rgba(0, 110, 154, 0.1) !important; + color: #006e9a !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-success { + background: rgba(8, 122, 80, 0.08) !important; + border-color: rgba(8, 122, 80, 0.35) !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-success .key-row-collapse-summary, +html[data-theme="light"] .key-row-collapse.key-history-success .key-row-summary-title strong { + color: #142232 !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-success .key-history-brief, +html[data-theme="light"] .key-row-collapse.key-history-success .key-history-outcome-badge { + color: #087a50 !important; + background: rgba(8, 122, 80, 0.1) !important; + border-color: rgba(8, 122, 80, 0.28) !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-manual { + background: #f0f2f6 !important; + border-color: #b8c0cc !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-manual .key-history-brief, +html[data-theme="light"] .key-row-collapse.key-history-manual .key-history-outcome-badge { + color: #5a6478 !important; + background: rgba(90, 100, 120, 0.1) !important; + border-color: rgba(90, 100, 120, 0.22) !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-failed { + background: rgba(192, 48, 48, 0.06) !important; + border-color: rgba(192, 48, 48, 0.28) !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-failed .key-row-collapse-summary { + color: #1a2838 !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-failed .key-history-brief, +html[data-theme="light"] .key-row-collapse.key-history-failed .key-history-outcome-badge { + color: #b04040 !important; + background: rgba(192, 48, 48, 0.08) !important; + border-color: rgba(192, 48, 48, 0.22) !important; +} + +html[data-theme="light"] .trd-label { + color: #6a7588 !important; +} + +html[data-theme="light"] .trd-value { + color: #142232 !important; +} + +html[data-theme="light"] .mobile-record-row { + background: #fff !important; + border-color: #b8c8d8 !important; + color: #142232 !important; +} + +html[data-theme="light"] .mobile-record-row:active { + background: #eef3f8 !important; +} + +html[data-theme="light"] .mrr-muted { + color: #6a7588 !important; +} + +html[data-theme="light"] .mobile-record-del { + background: rgba(192, 48, 48, 0.08) !important; + border-color: rgba(192, 48, 48, 0.28) !important; + color: #b04040 !important; +} + +html[data-theme="light"] .detail-actions { + border-top-color: #d0dae4 !important; +} + +/* ── 顺势加仓:表单字段按模式显隐(CSS 兜底,不依赖 JS)── */ +#roll-form[data-add-mode="market"] .roll-field-fib, +#roll-form[data-add-mode="market"] .roll-field-breakout { + display: none !important; +} + +#roll-form[data-add-mode="fib_618"] .roll-field-breakout, +#roll-form[data-add-mode="fib_786"] .roll-field-breakout { + display: none !important; +} + +#roll-form[data-add-mode="breakout"] .roll-field-fib { + display: none !important; +} + +#roll-form[data-add-mode="fib_618"] .roll-field-fib, +#roll-form[data-add-mode="fib_786"] .roll-field-fib, +#roll-form[data-add-mode="breakout"] .roll-field-breakout { + display: inline-flex !important; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +#roll-form[data-add-mode="fib_618"] #roll-preview-btn, +#roll-form[data-add-mode="fib_786"] #roll-preview-btn, +#roll-form[data-add-mode="breakout"] #roll-preview-btn { + display: none !important; +} + +#strategy-roll-panel .roll-risk-banner { + margin-bottom: 8px; + color: #8fc8ff; +} + +html[data-theme="light"] #strategy-roll-panel .roll-risk-banner { + color: #006e9a !important; +} + +#strategy-roll-panel .roll-doc-link { + color: #8fc8ff; +} + +html[data-theme="light"] #strategy-roll-panel .roll-doc-link { + color: #006e9a !important; +} + +#strategy-roll-panel .roll-section-title { + margin: 14px 0 8px; + font-size: 0.95rem; + color: #b8c4ff; +} + +html[data-theme="light"] #strategy-roll-panel .roll-section-title { + color: #006e9a !important; +} + +#strategy-roll-panel .roll-active-groups-table .roll-tp-profit, +#strategy-roll-panel .roll-active-groups-table .roll-status-active { + color: #4cd97f; + font-weight: 600; +} + +.pos-tp-profit { + color: #4cd97f; + font-weight: 600; +} + +html[data-theme="light"] .pos-tp-profit { + color: #1a8f4a !important; +} + +html[data-theme="light"] #strategy-roll-panel .roll-active-groups-table .roll-tp-profit, +html[data-theme="light"] #strategy-roll-panel .roll-active-groups-table .roll-status-active { + color: #1a8f4a !important; +} + +#roll-preview-box.roll-preview-box { + margin: 8px 0; + padding: 10px; + border: 1px solid #3a5a8a; + border-radius: 8px; + background: #141a28; + color: #dde2ff; +} + +#roll-preview-box.roll-preview-box.is-error { + border-color: #8a3a4a; + background: #1a1218; + color: #ffb4b4; +} + +#roll-preview-box.roll-preview-box.is-preview { + border-color: #3a5a8a; + background: #141a28; + color: #dde2ff; +} + +html[data-theme="light"] #roll-preview-box.roll-preview-box { + background: #f6f9fc !important; + border-color: #b8c8d8 !important; + color: #1a2838 !important; +} + +html[data-theme="light"] #roll-preview-box.roll-preview-box.is-error { + background: #fff5f5 !important; + border-color: #d8a0a8 !important; + color: #8a2030 !important; +} + +#roll-countdown.roll-countdown { + margin-top: 6px; + color: #ffb347; +} + +html[data-theme="light"] #roll-countdown.roll-countdown { + color: #a06010 !important; +} + +/* ── 顺势加仓说明页 ── */ +body.roll-doc-page { + font-family: system-ui, sans-serif; + margin: 0; + padding: 16px; + background: #0f1117; + color: #e6e8ef; +} + +html[data-theme="light"] body.roll-doc-page { + background: #eef3f8 !important; + color: #142232 !important; +} + +.roll-doc-container { + max-width: 920px; + margin: 0 auto; +} + +.roll-doc-nav { + margin-bottom: 14px; +} + +.roll-doc-nav a { + color: #8fc8ff; + text-decoration: none; +} + +html[data-theme="light"] .roll-doc-nav a { + color: #006e9a !important; +} + +.roll-doc-body { + background: #151a2a; + border: 1px solid #2a3150; + border-radius: 10px; + padding: 18px 20px; + line-height: 1.65; + font-size: 0.92rem; +} + +html[data-theme="light"] .roll-doc-body { + background: #fff !important; + border-color: #b8c8d8 !important; + color: #1a2838 !important; +} + +.roll-doc-body h1 { + font-size: 1.35rem; + margin: 0 0 12px; + color: #f0f2ff; +} + +html[data-theme="light"] .roll-doc-body h1 { + color: #142232 !important; +} + +.roll-doc-body h2 { + font-size: 1.08rem; + margin: 22px 0 10px; + color: #b8c4ff; + border-bottom: 1px solid #2a3150; + padding-bottom: 6px; +} + +html[data-theme="light"] .roll-doc-body h2 { + color: #006e9a !important; + border-bottom-color: #d0dae4 !important; +} + +.roll-doc-body h3 { + font-size: 0.98rem; + margin: 16px 0 8px; + color: #c9d4ff; +} + +html[data-theme="light"] .roll-doc-body h3 { + color: #142232 !important; +} + +.roll-doc-body p, +.roll-doc-body li { + color: #dde2ff; +} + +html[data-theme="light"] .roll-doc-body p, +html[data-theme="light"] .roll-doc-body li { + color: #1a2838 !important; +} + +.roll-doc-body ul, +.roll-doc-body ol { + margin: 8px 0 12px 1.25em; +} + +.roll-doc-body code { + background: #252538; + padding: 1px 5px; + border-radius: 4px; + font-size: 0.88em; +} + +html[data-theme="light"] .roll-doc-body code { + background: #e8eef5 !important; + color: #142232 !important; +} + +.roll-doc-body pre { + background: #0f1420; + border: 1px solid #2a3150; + border-radius: 8px; + padding: 12px; + overflow: auto; + font-size: 0.84rem; + line-height: 1.5; + color: #dde2ff; +} + +html[data-theme="light"] .roll-doc-body pre { + background: #f6f9fc !important; + border-color: #b8c8d8 !important; + color: #142232 !important; +} + +.roll-doc-body pre code { + background: transparent; + padding: 0; +} + +.roll-doc-body table { + width: 100%; + border-collapse: collapse; + margin: 10px 0; + font-size: 0.86rem; +} + +.roll-doc-body th, +.roll-doc-body td { + border: 1px solid #2a3150; + padding: 6px 8px; + text-align: left; + color: #dde2ff; +} + +html[data-theme="light"] .roll-doc-body th, +html[data-theme="light"] .roll-doc-body td { + border-color: #b8c8d8 !important; + color: #1a2838 !important; +} + +.roll-doc-body th { + background: #1a2030; + color: #b8c4ff; +} + +html[data-theme="light"] .roll-doc-body th { + background: #e8eef5 !important; + color: #142232 !important; +} + +.roll-doc-body hr { + border: none; + border-top: 1px solid #2a3150; + margin: 20px 0; +} + +html[data-theme="light"] .roll-doc-body hr { + border-top-color: #d0dae4 !important; +} + +/* ── 实盘下单:预估风险/盈利/盈亏比条 ── */ +html[data-theme="light"] .order-plan-preview { + background: #f6f9fc !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .order-monitor-form .om-field-lab { + color: #5a6a82; +} + +html[data-theme="light"] .order-monitor-form .om-check, +html[data-theme="light"] .order-monitor-form .om-time-close { + color: #3a4a62; +} + +html[data-theme="light"] .order-preview-rr { + color: #4a6078 !important; +} + +html[data-theme="light"] .order-preview-rr strong { + color: #142232 !important; +} + +html[data-theme="light"] .order-preview-risk strong { + color: #b03030 !important; +} + +html[data-theme="light"] .order-preview-profit strong { + color: #087a50 !important; +} + +/* ── 账户交易限制(方向 / 币种白名单)── */ +.trade-policy-badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + color: #8fc8ff; + background: rgba(31, 58, 90, 0.55); + border: 1px solid rgba(143, 200, 255, 0.35); + line-height: 1.4; +} + +.trade-policy-dir-lock { + display: inline-flex; + align-items: center; + padding: 6px 12px; + border-radius: 8px; + font-size: 0.82rem; + font-weight: 600; + color: #4cd97f; + background: rgba(76, 217, 127, 0.1); + border: 1px solid rgba(76, 217, 127, 0.28); + white-space: nowrap; +} + +html[data-theme="light"] .trade-policy-badge { + color: #1a4a7a; + background: #e8f2fb; + border-color: #9ec5e8; +} + +html[data-theme="light"] .trade-policy-dir-lock { + color: #087a50; + background: #e8f8f0; + border-color: #9ed4b8; +} + +/* ── 币种输入实时现价 ── */ +.symbol-live-price { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 8px; + font-size: 0.8rem; + font-weight: 600; + color: #8fc8ff; + background: rgba(31, 58, 90, 0.35); + border: 1px solid rgba(143, 200, 255, 0.22); + white-space: nowrap; + line-height: 1.35; +} + +.symbol-live-price--ok { + color: #4cd97f; + border-color: rgba(76, 217, 127, 0.35); + background: rgba(76, 217, 127, 0.08); +} + +.symbol-live-price--loading { + opacity: 0.75; +} + +.symbol-live-price--err { + color: #e8a090; + border-color: rgba(232, 160, 144, 0.35); +} + +.symbol-live-price-note { + font-size: 0.72rem; + color: #8892b0; + white-space: nowrap; +} + +html[data-theme="light"] .symbol-live-price { + color: #1a4a7a; + background: #eef4fb; + border-color: #b8cfe8; +} + +html[data-theme="light"] .symbol-live-price--ok { + color: #087a50; + background: #e8f8f0; + border-color: #9ed4b8; +} + +/* ── 复盘:字段按内容宽度;开仓类型与离场触发同一行 ── */ +.journal-card #journal-form { + min-width: 0; + max-width: 100%; +} + +.journal-card .form-grid { + gap: 10px; +} + +.journal-card .form-grid > input, +.journal-card .form-grid > select { + box-sizing: border-box; +} + +.journal-card .journal-form-row1 { + grid-template-columns: + minmax(11rem, 1.55fr) + minmax(11rem, 1.55fr) + minmax(4.2rem, 0.62fr) + minmax(3.2rem, 0.48fr) + minmax(4.8rem, 0.72fr) + minmax(4rem, 0.55fr) + minmax(4rem, 0.55fr); + margin-bottom: 10px; +} + +.journal-card .journal-form-row2 { + grid-template-columns: + minmax(6.5rem, 0.85fr) + minmax(7.5rem, 1.15fr) + minmax(7rem, 1fr) + minmax(0, 1.35fr) + minmax(6.5rem, 0.75fr); + margin-bottom: 8px; +} + +.journal-card .journal-form-row2 select[name="order_type"], +.journal-card .journal-form-row2 select[name="entry_reason"] { + font-size: 0.8rem; + line-height: 1.35; +} + +.journal-card #journal-form textarea[name="note"] { + display: block; + width: 100%; + max-width: 100%; + box-sizing: border-box; + margin-top: 8px; +} + +.journal-upload-slots { + display: flex; + flex-wrap: nowrap; + gap: 10px; + align-items: flex-start; + margin-top: 8px; +} + +.journal-upload-row { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + flex: 1 1 0; + min-width: 0; +} + +.journal-upload-slot-label { + color: #9aa3c7; + font-weight: 600; + font-size: 0.78rem; + letter-spacing: 0.02em; +} + +.journal-upload-slot-input { + width: 100%; + min-width: 0; + font-size: 0.72rem; + padding: 3px 4px; + line-height: 1.2; +} + +.journal-upload-status { + font-size: 0.68rem; + color: #8892b0; + min-height: 1.1em; + line-height: 1.25; + word-break: break-all; +} + +.journal-upload-status--pending { + color: #c9b458; +} + +.journal-upload-status--ok { + color: #6bc98a; +} + +.journal-upload-status--err { + color: #ff7b7b; +} + +.journal-upload-hint { + margin-top: 4px; + margin-bottom: 0; + font-size: 0.72rem; + color: #8892b0; +} + +.journal-card .journal-upload-slots { + margin-bottom: 2px; +} + +.journal-card .form-row.journal-chart-options { + margin-top: 6px; + margin-bottom: 6px; + gap: 6px; +} + +.journal-card .mood-grid { + margin-top: 6px; + gap: 8px; +} + +@media (max-width: 960px) { + .journal-card .journal-form-row1 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .journal-card .journal-form-row1 .journal-field-datetime { + grid-column: span 2; + } + + .journal-card .journal-form-row2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .journal-card .journal-form-row2 input[name="early_exit_note"] { + grid-column: 1 / -1; + } + + .journal-upload-slots { + flex-wrap: wrap; + } + + .journal-upload-row { + flex: 1 1 calc(50% - 8px); + } +} + +@media (max-width: 560px) { + .journal-card .journal-form-row1 { + grid-template-columns: minmax(0, 1fr); + } + + .journal-card .journal-form-row1 .journal-field-datetime { + grid-column: auto; + } + + .journal-card .journal-form-row2 { + grid-template-columns: minmax(0, 1fr); + } + + .journal-card .journal-form-row2 input[name="early_exit_note"] { + grid-column: auto; + } +} + +@media (max-width: 560px) { + .journal-upload-row { + flex: 1 1 100%; + } +} + +.journal-detail-images { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 10px 14px 14px; + border-top: 1px solid rgba(130, 145, 190, 0.25); +} + +.journal-detail-img-cell { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.journal-detail-img-label { + font-size: 0.75rem; + color: #9aa3c7; + font-weight: 600; +} + +.journal-detail-img-thumb { + width: 100%; + max-height: 220px; + object-fit: contain; + background: rgba(0, 0, 0, 0.25); + border-radius: 6px; + cursor: zoom-in; +} + +html[data-theme="light"] .journal-detail-images { + border-top-color: #d0dae4; +} + +html[data-theme="light"] .journal-detail-img-thumb { + background: #eef2f7; +} + +.nav-hidden { + display: none !important; +} + +/* ── env 配置页(Tab + 双列表单) ── */ +.env-config-page { + margin-top: 12px; + width: 100%; + min-width: 0; + grid-column: 1 / -1; +} + +.env-config-head { + padding: 14px 16px; + margin-bottom: 12px; +} + +.env-config-head-row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px 16px; +} + +.env-config-head h2 { + margin: 0 0 4px; + font-size: 1rem; +} + +.env-config-head-hint { + margin: 0; + font-size: 0.78rem; + max-width: 42rem; +} + +.env-config-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.env-config-body { + padding: 0; + overflow: hidden; + position: relative; +} + +.env-tab-radio { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.env-config-tabs { + display: flex; + flex-wrap: nowrap; + gap: 0; + overflow-x: auto; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding: 0 8px; + scrollbar-width: thin; +} + +.env-tab-btn { + flex: 0 0 auto; + display: inline-block; + border: none; + background: transparent; + color: var(--muted, #8892b0); + font-size: 0.8rem; + padding: 10px 14px; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; + user-select: none; +} + +.env-tab-btn:hover { + color: #c5cae0; +} + +.env-config-panels .env-panel { + display: none; +} + +#env-sec-0:checked ~ .env-config-tabs label[for="env-sec-0"], +#env-sec-1:checked ~ .env-config-tabs label[for="env-sec-1"], +#env-sec-2:checked ~ .env-config-tabs label[for="env-sec-2"], +#env-sec-3:checked ~ .env-config-tabs label[for="env-sec-3"], +#env-sec-4:checked ~ .env-config-tabs label[for="env-sec-4"], +#env-sec-5:checked ~ .env-config-tabs label[for="env-sec-5"], +#env-sec-6:checked ~ .env-config-tabs label[for="env-sec-6"], +#env-sec-7:checked ~ .env-config-tabs label[for="env-sec-7"], +#env-sec-8:checked ~ .env-config-tabs label[for="env-sec-8"], +#env-sec-9:checked ~ .env-config-tabs label[for="env-sec-9"], +#env-sec-10:checked ~ .env-config-tabs label[for="env-sec-10"], +#env-sec-11:checked ~ .env-config-tabs label[for="env-sec-11"] { + color: #e8ecff; + border-bottom-color: var(--accent, #7c6cf0); + font-weight: 600; +} + +#env-sec-0:checked ~ .env-config-panels .env-panel--0, +#env-sec-1:checked ~ .env-config-panels .env-panel--1, +#env-sec-2:checked ~ .env-config-panels .env-panel--2, +#env-sec-3:checked ~ .env-config-panels .env-panel--3, +#env-sec-4:checked ~ .env-config-panels .env-panel--4, +#env-sec-5:checked ~ .env-config-panels .env-panel--5, +#env-sec-6:checked ~ .env-config-panels .env-panel--6, +#env-sec-7:checked ~ .env-config-panels .env-panel--7, +#env-sec-8:checked ~ .env-config-panels .env-panel--8, +#env-sec-9:checked ~ .env-config-panels .env-panel--9, +#env-sec-10:checked ~ .env-config-panels .env-panel--10, +#env-sec-11:checked ~ .env-config-panels .env-panel--11 { + display: block; +} + +#settings-sec-0:checked ~ .env-config-tabs label[for="settings-sec-0"], +#settings-sec-1:checked ~ .env-config-tabs label[for="settings-sec-1"], +#settings-sec-2:checked ~ .env-config-tabs label[for="settings-sec-2"], +#settings-sec-3:checked ~ .env-config-tabs label[for="settings-sec-3"], +#settings-sec-4:checked ~ .env-config-tabs label[for="settings-sec-4"], +#settings-sec-5:checked ~ .env-config-tabs label[for="settings-sec-5"], +#settings-sec-6:checked ~ .env-config-tabs label[for="settings-sec-6"] { + color: #e8ecff; + border-bottom-color: var(--accent, #7c6cf0); + font-weight: 600; +} + +#settings-sec-0:checked ~ .env-config-panels .env-panel--0, +#settings-sec-1:checked ~ .env-config-panels .env-panel--1, +#settings-sec-2:checked ~ .env-config-panels .env-panel--2, +#settings-sec-3:checked ~ .env-config-panels .env-panel--3, +#settings-sec-4:checked ~ .env-config-panels .env-panel--4, +#settings-sec-5:checked ~ .env-config-panels .env-panel--5, +#settings-sec-6:checked ~ .env-config-panels .env-panel--6 { + display: block; +} + +.env-sensitive-current { + margin: 0 0 6px; + font-size: 0.75rem; +} + +.env-masked-value { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.04em; + color: #c5cae0; +} + +.settings-tab-panel h2 { + margin: 0 0 8px; + font-size: 1rem; +} + +.settings-tab-inner h2 { + margin: 0 0 8px; + font-size: 1rem; +} + +.settings-config-body { + margin-top: 10px; +} + +.env-config-panels { + padding: 14px 16px 16px; +} + +.env-panel-hint { + margin: 0 0 12px; + padding: 8px 10px; + font-size: 0.75rem; + border-radius: 6px; + background: rgba(251, 191, 36, 0.08); + color: #e8c468; + border: 1px solid rgba(251, 191, 36, 0.15); +} + +.env-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 20px; + align-items: start; +} + +.env-field-row { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +/* display:flex 会盖掉 UA [hidden];全仓复利开时隐藏单笔预算等依赖此规则 */ +.env-field-row[hidden] { + display: none !important; +} + +.env-field-row--restart .env-field-label { + color: #d4c4a0; +} + +.env-field-label { + font-size: 0.8rem; + font-weight: 600; + color: #c5cae0; + line-height: 1.3; +} + +.env-restart-mark { + color: #fbbf24; + font-weight: 700; + margin-left: 2px; +} + +.env-field-note { + font-size: 0.72rem; + line-height: 1.35; + margin-top: -2px; +} + +.env-field-input { + width: 100%; + font-size: 0.82rem; + padding: 7px 10px; + border-radius: 6px; + box-sizing: border-box; +} + +.env-config-loading-wrap { + padding: 24px; + text-align: center; +} + +/* 兼容旧结构 */ +.env-config-grid { + display: block; +} + +.env-group-card, +.env-field-card, +.env-field-badge, +.env-badge-hot, +.env-badge-restart { + display: none; +} + +@media (max-width: 900px) { + .env-form-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 720px) { + .env-config-head-row { + flex-direction: column; + } + .env-config-toolbar { + width: 100%; + } +} + +.display-prefs-form { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.display-prefs-checks { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} + +.display-prefs-checks .chk-label { + font-size: 0.82rem; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.settings-password-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; + margin: 8px 0; +} + +.settings-password-form label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.78rem; + color: var(--muted, #8892b0); +} + +.settings-actions-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 8px; +} + +.settings-status-line.err { + color: var(--danger, #f87171); +} + +@media (max-width: 1100px) { + .env-form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .env-form-grid { + grid-template-columns: minmax(0, 1fr); + } + .settings-password-form { + grid-template-columns: minmax(0, 1fr); + } +} + +/* ── 风控说明页 ── */ +.risk-policy-page { + margin-top: 12px; + width: 100%; + min-width: 0; + grid-column: 1 / -1; +} + +.risk-policy-page .settings-card--risk { + width: 100%; + min-width: 0; + box-sizing: border-box; + height: auto; +} + +.settings-risk-sections { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: auto; + gap: 10px; + margin-top: 10px; + align-items: stretch; +} + +.risk-policy-page .settings-subcard { + height: 100%; + display: flex; + flex-direction: column; + padding: 10px 12px; + min-width: 0; + margin: 0; + width: 100%; + box-sizing: border-box; +} + +.settings-page--grid { + margin-top: 12px; +} + +.settings-grid-2col { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + align-items: start; +} + +.settings-grid-cell { + min-width: 0; +} + +.settings-grid-cell--full { + grid-column: 1 / -1; +} + +.settings-card--standalone { + padding: 12px 14px; + height: 100%; +} + +.settings-card--standalone h2 { + margin: 0 0 8px; + font-size: 1.05rem; +} + +.settings-card--export .settings-export-links-block { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-top: 8px; +} + +.settings-page--ops { + max-width: 720px; +} + +.settings-page--ops .settings-card--side-panel { + height: auto; +} + +/* ── 系统设置页 ── */ +.settings-page { + margin-top: 12px; + width: 100%; + min-width: 0; + grid-column: 1 / -1; +} + +.settings-account-summary { + margin-bottom: 16px; + padding: 12px 14px 10px; +} + +.settings-account-summary .instance-header-stats { + border-top: none; + margin-top: 0; + padding-top: 4px; +} + +.settings-account-summary-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.settings-account-summary-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.settings-cards-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + align-items: stretch; +} + +.settings-cards-grid > .settings-card--risk, +.settings-cards-grid > .settings-side-col { + min-height: 0; +} + +.settings-side-col { + display: flex; + flex-direction: column; + align-self: stretch; +} + +.settings-card--side-panel { + height: 100%; + display: flex; + flex-direction: column; + padding: 10px 12px; +} + +.settings-side-subcards { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1 1 auto; +} + +.settings-side-subcards > .settings-subcard { + flex: 0 0 auto; + padding: 8px 10px; + margin: 0; +} + +.settings-subcard-desc { + font-size: 0.7rem; + margin: 0 0 6px; + line-height: 1.4; + color: var(--muted, #8892b0); +} + +.settings-card--risk { + min-width: 0; + height: 100%; + display: flex; + flex-direction: column; +} + +.settings-side-export { + flex: 0 0 auto; + margin-top: auto; + padding-top: 8px; + border-top: 1px solid var(--border-soft, #2a3150); +} + +.settings-side-export-head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 4px; +} + +.settings-side-export-label { + font-size: 0.72rem; + font-weight: 600; + color: #a8b0cc; +} + +.settings-side-export-meta { + font-size: 0.68rem; +} + +.settings-export-links-inline { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; +} + +.settings-export-links-inline a { + font-size: 0.72rem; + color: #8fc8ff; + text-decoration: none; + white-space: nowrap; +} + +.settings-export-links-inline a:hover { + text-decoration: underline; +} + +.settings-card--compact { + padding: 10px 12px; +} + +.settings-card--compact h2 { + margin: 0 0 6px; + font-size: 0.88rem; + font-weight: 600; +} + +.settings-card--compact .settings-card-desc { + font-size: 0.72rem; + margin: 0 0 8px; + line-height: 1.45; +} + +.settings-card--compact .settings-transfer-auto, +.settings-card--compact .settings-transfer-form { + font-size: 0.72rem; +} + +.settings-card--compact .settings-transfer-form input, +.settings-card--compact .settings-transfer-form select, +.settings-card--compact .settings-transfer-form button { + font-size: 0.75rem; + padding: 4px 8px; +} + +.settings-card--compact .settings-export-link { + font-size: 0.75rem; + padding: 5px 10px; +} + +@media (max-width: 900px) { + .settings-grid-2col { + grid-template-columns: minmax(0, 1fr); + } + .settings-cards-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.settings-card h2 { + margin: 0 0 10px; + font-size: 1.05rem; +} + +.settings-card-desc, +.settings-env-hint, +.settings-transfer-auto { + color: var(--muted, #8892b0); + font-size: 0.82rem; + line-height: 1.5; + margin: 0 0 12px; +} + +.settings-live-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 0.88rem; + margin: 0 0 10px; +} + +.settings-status-reason, +.settings-policy-note { + color: var(--muted, #8892b0); + font-size: 0.8rem; +} + +.settings-subcard-title { + margin: 0 0 8px; + font-size: 0.82rem; + font-weight: 600; + color: #cfd3ef; +} + +.settings-kv--compact .settings-kv-row { + grid-template-columns: minmax(7em, auto) minmax(0, 1fr); + gap: 6px 12px; + padding: 4px 0; + font-size: 0.75rem; + align-items: start; +} + +@media (max-width: 720px) { + .settings-risk-sections { + grid-template-columns: minmax(0, 1fr); + } + + .settings-kv--compact .settings-kv-row { + grid-template-columns: minmax(0, 1fr); + gap: 2px; + } + + .settings-kv--compact .settings-kv-row dd { + margin-bottom: 6px; + } +} + +.settings-section { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--border-soft, #2a3150); +} + +.settings-section h3 { + margin: 0 0 8px; + font-size: 0.92rem; + color: #cfd3ef; +} + +.settings-kv { + margin: 0; +} + +.settings-kv-row { + display: grid; + grid-template-columns: 9.5em 1fr; + gap: 8px 12px; + padding: 6px 0; + font-size: 0.82rem; + border-bottom: 1px dashed rgba(136, 146, 176, 0.15); +} + +.settings-kv-row:last-child { + border-bottom: none; +} + +.settings-kv-row dt { + margin: 0; + color: #9aa3c7; +} + +.settings-kv-row dd { + margin: 0; +} + +.settings-kv-value { + color: #e8ecff; + font-weight: 600; +} + +.settings-kv-note { + display: block; + margin-top: 2px; + color: #8892b0; + font-size: 0.75rem; + font-weight: 400; +} + +.settings-link-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.settings-export-link { + display: block; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft, #2a3150); + background: var(--inset-surface, #12151f); + color: #8fc8ff; + text-decoration: none; + font-size: 0.88rem; +} + +.settings-export-link:hover { + border-color: #3d4f7a; + background: #1a2030; +} + +.settings-transfer-form { + margin-top: 10px; +} + +html[data-theme="light"] .settings-section { + border-top-color: #d0dae4; +} + +html[data-theme="light"] .settings-subcard-title, +html[data-theme="light"] .settings-section h3, +html[data-theme="light"] .settings-kv-value { + color: #142232; +} + +html[data-theme="light"] .settings-export-link, +html[data-theme="light"] .settings-export-links-inline a { + background: transparent; + border-color: transparent; + color: #1d4f8c; +} + +html[data-theme="light"] .settings-side-export { + border-top-color: #d0dae4; +} + +html[data-theme="light"] .settings-side-export-label { + color: #142232; +} + +/* OKX 期权页 */ +.options-page-wrap { + font-size: 0.8rem; +} + +.options-page-wrap .card { + padding: 12px 14px; +} + +.options-page-wrap .card h2, +.options-page-wrap .options-order-card h2, +.options-page-wrap .options-pos-card-wrap h2, +.options-page-wrap .options-pos-head h2 { + font-size: 0.9rem; + margin: 0 0 8px; + font-weight: 600; +} + +.options-page-wrap .options-hint, +.options-page-wrap #opt-index-line { + font-size: 0.72rem; + line-height: 1.45; + margin-bottom: 6px; +} + +.options-page-wrap .options-chain-toolbar .btn-secondary, +.options-page-wrap .options-chain-toolbar select { + font-size: 0.72rem; + padding: 4px 8px; + min-height: 28px; +} + +.options-page-wrap .options-pos-head .btn-secondary { + font-size: 0.72rem; + padding: 3px 10px; + min-height: 26px; +} + +.options-funds-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin: 12px 0 16px; +} +.options-funds-col { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + padding: 12px; +} +.options-fund-row { + display: flex; + justify-content: space-between; + gap: 8px; + margin: 6px 0; + font-size: 0.9rem; +} +.options-section { + margin: 16px 0; +} +.options-section.card-nested { + padding: 12px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.15); +} +.options-strike-table-wrap { + overflow-x: auto; + overflow-y: auto; + max-height: 352px; + margin-top: 8px; +} +.options-strike-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: rgba(18, 24, 38, 0.98); +} +/* 列表 / T 型表头互斥:类名 hidden 需显式隐藏(实例页无全局 .hidden) */ +.options-strike-table thead tr.hidden { + display: none !important; +} +.options-strike-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} +.options-page-wrap .options-strike-table { + font-size: 0.74rem; +} +.options-strike-table th, +.options-strike-table td { + padding: 6px 5px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + text-align: left; +} +.options-page-wrap .options-strike-table th, +.options-page-wrap .options-strike-table td { + padding: 5px 4px; +} +.options-page-wrap .options-strike-table code { + font-size: 0.66rem; +} +.opt-px-sz { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.opt-chain-lev { + font-variant-numeric: tabular-nums; + white-space: nowrap; + font-weight: 600; + color: #b8c8ff; +} +html[data-theme="light"] .opt-chain-lev { + color: #1a4a8a; +} +.opt-be-dist-up { + color: #5ee89a; +} +.opt-be-dist-down { + color: #ff8a8a; +} +html[data-theme="light"] .opt-be-dist-up { + color: #0d7a45; +} +html[data-theme="light"] .opt-be-dist-down { + color: #c62828; +} +.options-chain-toolbar .btn-secondary.active, +.opt-uly-btn.active, +.opt-type-btn.active, +.opt-money-btn.active, +.opt-view-btn.active, +.opt-pos-tab.active { + border-color: #5b8cff; + color: #cfe0ff; + background: rgba(74, 124, 255, 0.28); + box-shadow: inset 0 0 0 1px rgba(120, 160, 255, 0.45); +} +.opt-pick-btn.active { + border-color: #5b8cff; + color: #fff; + background: rgba(74, 124, 255, 0.45); + box-shadow: inset 0 0 0 1px rgba(140, 175, 255, 0.6); +} +.opt-strike-row.opt-row-selected td { + background: rgba(74, 124, 255, 0.1); +} +.opt-strike-row.opt-row-selected td:first-child { + box-shadow: inset 3px 0 0 #5b8cff; +} +.opt-chain-view-group { + display: inline-flex; + gap: 4px; +} +.opt-type-btn-group { + display: inline-flex; + gap: 4px; +} +.opt-strike-expand-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.78rem; + color: var(--text-soft, #9aa4b2); + white-space: nowrap; + cursor: pointer; + user-select: none; +} +.opt-strike-expand-label input { + margin: 0; +} +.options-strike-table-wrap--t { + max-height: 380px; +} + +/* 对冲计划 Tab:高对比选中态 */ +.hedge-plan-page-wrap { + font-size: 0.8rem; +} +.hedge-plan-page-wrap .card { + padding: 12px 14px; +} +.hedge-plan-page-wrap .card h2, +.hedge-plan-page-wrap .hp-title { + font-size: 0.9rem; + margin: 0 0 8px; + font-weight: 600; +} +.hedge-plan-page-wrap .hp-head-card { + margin-bottom: 12px; +} +.hedge-plan-page-wrap .hp-rule-collapse { + margin: 0 0 10px; + border: none; + background: transparent; + box-shadow: none; +} +.hedge-plan-page-wrap .hp-rule-collapse > .tip-collapse-summary { + padding: 4px 0; + font-size: 0.78rem; +} +.hedge-plan-page-wrap .hp-rule-collapse .tip-collapse-body { + padding: 6px 0 2px; +} +.hedge-plan-page-wrap .hp-rule-collapse .tip-collapse-body.rule-tip { + margin: 0; + border: none; + background: transparent; + padding: 0; +} +.hedge-plan-page-wrap .hp-rule-collapse .tip-collapse-body p { + margin: 0 0 6px; + font-size: 0.74rem; + line-height: 1.45; +} +.hedge-plan-page-wrap .hp-rule-collapse .tip-collapse-body p:last-child { + margin-bottom: 0; +} +.hedge-plan-page-wrap .hp-head-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} +.hedge-plan-page-wrap .hp-title { + margin: 0; +} +.hedge-plan-page-wrap .hp-title-sub { + font-size: 0.75rem; + font-weight: 400; +} +.hedge-plan-page-wrap .hp-quote-line, +.hedge-plan-page-wrap .hp-acct-hint, +.hedge-plan-page-wrap #hp-sizing-line, +.hedge-plan-page-wrap #hp-gate-line { + font-size: 0.72rem; + line-height: 1.45; + margin: 4px 0 6px; +} +.hedge-plan-page-wrap .hp-acct-tag { + font-size: 0.68rem; + font-weight: 500; + margin-left: 4px; +} +.hedge-plan-page-wrap .hp-unit { + font-size: 0.66rem; + color: #8892b0; + font-weight: 500; + margin-right: 2px; +} +.hedge-plan-page-wrap .hp-unit-hint { + font-size: 0.68rem; + margin: 2px 0 6px; + line-height: 1.4; +} +.hedge-plan-page-wrap #hp-perp-pnl-line { + font-size: 0.74rem; + margin: 4px 0 6px; + line-height: 1.45; +} +.hedge-plan-page-wrap .hp-pnl-pos { + color: #7ee787; +} +.hedge-plan-page-wrap .hp-plan-active { + color: #7ee787; + font-weight: 700; +} +.hedge-plan-page-wrap .hp-plan-partial { + color: #ffb454; + font-weight: 700; +} +.hedge-plan-page-wrap .hp-hist-actions .hp-btn-complete { + margin-right: 6px; + padding: 3px 8px; + font-size: 0.72rem; + min-height: 26px; +} +.hedge-plan-page-wrap .hp-pnl-neg { + color: #ff8a8a; +} +.hedge-plan-page-wrap .hp-oo-legs { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} +.hedge-plan-page-wrap .hp-oo-target-row { + align-items: flex-end; + flex-wrap: wrap; + gap: 10px 14px; +} +.hedge-plan-page-wrap .hp-oo-index { + display: inline-flex; + align-items: center; + margin: 0 0 2px; + padding: 4px 0; + font-size: 0.9rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #3dd68c; + white-space: nowrap; +} +.hedge-plan-page-wrap .hp-oo-transfer { + margin: 10px 0 4px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.hedge-plan-page-wrap .hp-oo-transfer--compact { + margin: 12px 0 8px; + padding: 8px 10px; +} +.hedge-plan-page-wrap .hp-oo-transfer-bals { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 8px; + margin-bottom: 6px; + font-size: 0.76rem; +} +.hedge-plan-page-wrap .hp-oo-transfer-bals strong { + color: var(--text, #e6edf3); + font-variant-numeric: tabular-nums; +} +.hedge-plan-page-wrap .hp-oo-transfer-unit { + opacity: 0.75; +} +.hedge-plan-page-wrap .hp-oo-transfer-form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 0; +} +.hedge-plan-page-wrap .hp-oo-transfer-form select, +.hedge-plan-page-wrap .hp-oo-transfer-form input[type="number"] { + font-size: 0.74rem; +} +.hedge-plan-page-wrap .hp-oo-transfer-form input[type="number"] { + width: 96px; + max-width: 30vw; +} +.hedge-plan-page-wrap #hp-oo-xfer-msg { + margin-left: auto; + font-size: 0.72rem; + min-height: 1.1em; +} +.hedge-plan-page-wrap #hp-oo-xfer-msg.is-err { + color: #ff8a8a; +} +.hedge-plan-page-wrap #hp-oo-xfer-msg.is-ok { + color: #3dd68c; +} +.hedge-plan-page-wrap .hp-oo-controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 14px; + margin: 10px 0 4px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.hedge-plan-page-wrap .hp-oo-ctrl { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.hedge-plan-page-wrap .hp-oo-ctrl-lab { + font-size: 0.72rem; + color: var(--muted, #8b949e); + letter-spacing: 0.02em; +} +.hedge-plan-page-wrap .hp-oo-seg { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.hedge-plan-page-wrap .hp-oo-seg .btn-secondary { + flex: 1 1 auto; + min-width: 4.5em; + justify-content: center; + padding: 5px 8px; + position: relative; +} +.hedge-plan-page-wrap .hp-oo-check { + display: none; + margin-right: 4px; + font-weight: 700; +} +.hedge-plan-page-wrap .hp-oo-size-mode.is-selected, +.hedge-plan-page-wrap .hp-oo-close-mode.is-selected, +.hedge-plan-page-wrap .hp-po-dir.is-selected, +.hedge-plan-page-wrap .hp-oo-size-mode.active, +.hedge-plan-page-wrap .hp-oo-close-mode.active, +.hedge-plan-page-wrap .hp-po-dir.active { + border-color: var(--accent, #00d4ff); + color: var(--text, #fff); + background: rgba(0, 212, 255, 0.16); + box-shadow: inset 0 0 0 1px rgba(0, 212, 255, 0.35); + font-weight: 700; +} +.hedge-plan-page-wrap .hp-oo-size-mode.is-selected .hp-oo-check, +.hedge-plan-page-wrap .hp-oo-close-mode.is-selected .hp-oo-check, +.hedge-plan-page-wrap .hp-po-dir.is-selected .hp-oo-check, +.hedge-plan-page-wrap .hp-oo-size-mode.active .hp-oo-check, +.hedge-plan-page-wrap .hp-oo-close-mode.active .hp-oo-check, +.hedge-plan-page-wrap .hp-po-dir.active .hp-oo-check { + display: inline; + color: var(--accent, #00d4ff); +} +.hedge-plan-page-wrap .hp-po-top { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 16px; + margin: 8px 0 4px; +} +.hedge-plan-page-wrap .hp-po-dir-seg { + flex: 1 1 180px; + max-width: 220px; +} +.hedge-plan-page-wrap .hp-po-mark { + font-size: 0.95rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #3dd68c; + white-space: nowrap; +} +.hedge-plan-page-wrap .hp-po-meta { + margin: 2px 0 8px; + font-size: 0.74rem; + line-height: 1.4; +} +.hedge-plan-page-wrap .hp-po-mode-badge { + display: inline-block; + margin-right: 6px; + padding: 2px 8px; + border-radius: 6px; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.02em; + color: #c4b5fd; + background: rgba(139, 92, 246, 0.22); + border: 1px solid rgba(167, 139, 250, 0.35); + vertical-align: middle; +} +.hedge-plan-page-wrap .hp-po-mode-badge.is-insurance { + color: #93c5fd; + background: rgba(59, 130, 246, 0.18); + border-color: rgba(96, 165, 250, 0.35); +} +.hedge-plan-page-wrap .hp-po-section { + margin: 8px 0 10px; +} +.hedge-plan-page-wrap .hp-po-section-title { + margin: 0 0 6px; + font-size: 0.8rem; + font-weight: 650; + color: #c5cdd9; + letter-spacing: 0.02em; +} +.hedge-plan-page-wrap .hp-po-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 12px; + margin: 8px 0 6px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.hedge-plan-page-wrap .hp-po-fields--section { + margin: 0; +} +.hedge-plan-page-wrap .hp-po-fields--capital { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.hedge-plan-page-wrap .hp-po-fields--select { + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: end; +} +.hedge-plan-page-wrap .hp-po-field--type { + min-width: 0; +} +.hedge-plan-page-wrap .hp-po-field--type select { + width: 100%; + min-width: 0; + box-sizing: border-box; +} +.hedge-plan-page-wrap .hp-plan-watching { + color: #fbbf24; + font-weight: 650; +} +@media (max-width: 720px) { + .hedge-plan-page-wrap .hp-po-fields--capital, + .hedge-plan-page-wrap .hp-po-fields--select { + grid-template-columns: 1fr 1fr; + } + .hedge-plan-page-wrap .hp-po-field--type { + grid-column: 1 / -1; + } + .hedge-plan-page-wrap .hp-po-type-seg { + flex-wrap: wrap; + } +} +.hedge-plan-page-wrap .hp-po-right-card { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; +} +.hedge-plan-page-wrap .hp-po-right-stack { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + min-height: 0; + flex: 1 1 auto; +} +.hedge-plan-page-wrap .hp-po-inner-card { + margin: 0; + padding: 10px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + min-width: 0; + min-height: 0; +} +.hedge-plan-page-wrap .hp-po-inner-card > h2 { + margin: 0 0 8px; + font-size: 0.95rem; +} +.hedge-plan-page-wrap .hp-po-action-row { + margin-top: auto; + padding-top: 10px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; +} +.hedge-plan-page-wrap .hp-po-strategy-status { + font-size: 0.86rem; + font-weight: 700; + letter-spacing: 0.02em; + min-height: 1.2em; +} +.hedge-plan-page-wrap .hp-po-strategy-status.is-watching { + color: #fbbf24; +} +.hedge-plan-page-wrap .hp-po-strategy-status.is-holding { + color: #3dd68c; +} +.hedge-plan-page-wrap .hp-po-strategy-status.is-idle { + color: #6b7388; + font-weight: 500; +} +.hedge-plan-page-wrap .hp-po-quote-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 4px; +} +.hedge-plan-page-wrap .hp-po-ins-money { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; +} +.hedge-plan-page-wrap .hp-po-ins-money[hidden], +.hedge-plan-page-wrap .hp-po-ins-money.hidden { + display: none !important; +} +/* display:grid 会盖掉 [hidden];模式切换必须显式 none */ +.hedge-plan-page-wrap .hp-po-fields.hidden, +.hedge-plan-page-wrap .hp-po-fields[hidden], +.hedge-plan-page-wrap #hp-po-fields-option-primary.hidden, +.hedge-plan-page-wrap #hp-po-fields-option-primary[hidden] { + display: none !important; +} +.hedge-plan-page-wrap .hp-strike-table-wrap--6 { + max-height: 280px; + min-height: 200px; + overflow-y: auto; + margin-top: 4px; + flex: 1 1 auto; +} +.hedge-plan-page-wrap .hp-po-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + margin: 0; + font-size: 0.78rem; +} +.hedge-plan-page-wrap .hp-po-field-lab { + color: #9aa4b2; + font-size: 0.72rem; +} +.hedge-plan-page-wrap .hp-po-field-lab em { + font-style: normal; + color: #6b7388; + margin-left: 2px; +} +.hedge-plan-page-wrap .hp-po-field input { + width: 100%; + min-width: 0; + box-sizing: border-box; +} +.hedge-plan-page-wrap .hp-po-field--tp input { + border-color: rgba(61, 214, 140, 0.45); +} +.hedge-plan-page-wrap .hp-po-field--sl input { + border-color: rgba(255, 138, 138, 0.45); +} +.hedge-plan-page-wrap .hp-po-summary { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} +.hedge-plan-page-wrap .hp-po-pnl { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.hedge-plan-page-wrap .hp-po-chip { + display: inline-flex; + align-items: baseline; + gap: 4px; + padding: 4px 8px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 0.76rem; +} +.hedge-plan-page-wrap .hp-po-sizing { + font-size: 0.74rem; + line-height: 1.4; +} +.hedge-plan-page-wrap .hp-po-opt-toolbar { + align-items: center; +} +.hedge-plan-page-wrap .hp-po-index { + margin-left: auto; + font-size: 0.9rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #3dd68c; + white-space: nowrap; +} +@media (max-width: 720px) { + .hedge-plan-page-wrap .hp-po-fields { + grid-template-columns: 1fr; + } + .hedge-plan-page-wrap .hp-po-index { + margin-left: 0; + } +} +.hedge-plan-page-wrap #hp-oo-close-mode-row.hidden { + display: none !important; +} +.hedge-plan-page-wrap .hp-oo-controls:has(#hp-oo-close-mode-row.hidden) { + grid-template-columns: 1fr; +} +.hedge-plan-page-wrap .hp-oo-meta { + margin: 4px 0 0; + font-size: 0.75rem; + line-height: 1.4; +} +.hedge-plan-page-wrap #hp-oo-budget-line.hp-oo-budget-warn { + color: #ff8a8a; +} +@media (max-width: 720px) { + .hedge-plan-page-wrap .hp-oo-controls { + grid-template-columns: 1fr; + } +} +.hedge-plan-page-wrap .hp-oo-leg-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.hedge-plan-page-wrap .hp-oo-leg-row input[type="number"] { + width: 72px; +} +.hedge-plan-page-wrap .form-row label, +.hedge-plan-page-wrap .form-row select, +.hedge-plan-page-wrap .form-row input, +.hedge-plan-page-wrap .form-row .btn-secondary, +.hedge-plan-page-wrap .form-row .primary { + font-size: 0.74rem; +} +.hedge-plan-page-wrap .form-row input[type="number"] { + max-width: 110px; + padding: 4px 6px; + min-height: 28px; +} +.hedge-plan-page-wrap .options-strike-table { + font-size: 0.74rem; +} +.hedge-plan-page-wrap .options-strike-table th, +.hedge-plan-page-wrap .options-strike-table td { + padding: 5px 4px; +} +.hedge-plan-page-wrap .options-strike-table code { + font-size: 0.66rem; +} +.hedge-plan-page-wrap .hp-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 8px; + padding: 6px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.08); +} +.hedge-plan-page-wrap .hp-tab { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: #aeb6c5; + font-size: 0.82rem; + font-weight: 600; + padding: 7px 14px; + border-radius: 8px; + cursor: pointer; + line-height: 1.2; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} +.hedge-plan-page-wrap .hp-tab:hover { + color: #eef3ff; + border-color: rgba(120, 170, 255, 0.45); + background: rgba(74, 124, 255, 0.16); +} +.hedge-plan-page-wrap .hp-tab.active { + color: #0b1220; + background: linear-gradient(180deg, #d7e6ff 0%, #8eb6ff 100%); + border-color: #fff; + box-shadow: 0 0 0 2px rgba(142, 182, 255, 0.55), 0 6px 16px rgba(0, 0, 0, 0.35); +} +.hedge-plan-page-wrap .hp-uly-btn, +.hedge-plan-page-wrap .hp-uly-btn-oo, +.hedge-plan-page-wrap .hp-money-btn, +.hedge-plan-page-wrap .hp-oo-money-btn, +.hedge-plan-page-wrap .hp-oo-recommend-btn, +.hedge-plan-page-wrap .hp-oo-expand-btn { + min-width: 52px; + font-weight: 600; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: #9aa4b2; +} +.hedge-plan-page-wrap .hp-uly-btn.active, +.hedge-plan-page-wrap .hp-uly-btn-oo.active, +.hedge-plan-page-wrap .hp-money-btn.active, +.hedge-plan-page-wrap .hp-oo-money-btn.is-selected, +.hedge-plan-page-wrap .hp-oo-money-btn.active, +.hedge-plan-page-wrap .hp-oo-recommend-btn.is-selected, +.hedge-plan-page-wrap .hp-oo-recommend-btn.active, +.hedge-plan-page-wrap .hp-oo-expand-btn.is-selected, +.hedge-plan-page-wrap .hp-oo-expand-btn.active { + border-color: var(--accent, #00d4ff); + color: var(--text, #fff); + background: rgba(0, 212, 255, 0.16); + box-shadow: inset 0 0 0 1px rgba(0, 212, 255, 0.35); + font-weight: 700; +} +.hedge-plan-page-wrap .hp-oo-money-btn .hp-oo-check, +.hedge-plan-page-wrap .hp-oo-recommend-btn .hp-oo-check, +.hedge-plan-page-wrap .hp-oo-expand-btn .hp-oo-check { + display: none; + margin-right: 4px; + font-weight: 700; +} +.hedge-plan-page-wrap .hp-oo-money-btn.is-selected .hp-oo-check, +.hedge-plan-page-wrap .hp-oo-money-btn.active .hp-oo-check, +.hedge-plan-page-wrap .hp-oo-recommend-btn.is-selected .hp-oo-check, +.hedge-plan-page-wrap .hp-oo-recommend-btn.active .hp-oo-check, +.hedge-plan-page-wrap .hp-oo-expand-btn.is-selected .hp-oo-check, +.hedge-plan-page-wrap .hp-oo-expand-btn.active .hp-oo-check { + display: inline; + color: var(--accent, #00d4ff); +} +.hedge-plan-page-wrap .hp-money-hint { + font-size: 0.68rem; + margin-left: 4px; +} +.hedge-plan-page-wrap .hp-opt-toolbar, +.hedge-plan-page-wrap .hp-pick-row { + flex-wrap: wrap; + gap: 6px; + margin: 4px 0; + align-items: center; +} +.hedge-plan-page-wrap .hp-opt-toolbar select { + max-width: 168px; + font-size: 0.72rem; + padding: 3px 6px; + min-height: 26px; +} +.hedge-plan-page-wrap .hp-opt-toolbar .btn-secondary, +.hedge-plan-page-wrap .hp-opt-toolbar .hp-money-btn, +.hedge-plan-page-wrap .hp-oo-money-btn, +.hedge-plan-page-wrap .hp-oo-recommend-btn, +.hedge-plan-page-wrap .hp-oo-expand-btn { + padding: 3px 8px; + min-height: 26px; + font-size: 0.72rem; +} +/* 永期期权列表:视口约 5 行 + 表头,超出滚动 */ +.hedge-plan-page-wrap .hp-strike-table-wrap--5 { + max-height: 248px; + min-height: 248px; + overflow-y: auto; + margin-top: 4px; + flex: 1 1 auto; +} +/* 期期 T 型:默认 3+3 一页展示,无下滑框 */ +.hedge-plan-page-wrap .hp-oo-table-wrap { + max-height: none; + min-height: 0; + overflow: visible; + margin-top: 4px; + flex: 0 0 auto; +} +.hedge-plan-page-wrap .opt-chain-lev, +.hedge-plan-page-wrap .hp-oo-table-wrap .opt-chain-lev { + text-align: center; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.hedge-plan-page-wrap .hp-opt-bal-line { + margin-top: 4px; +} +.hedge-plan-page-wrap .hp-acct-hint { + display: none; +} +.hedge-plan-page-wrap .options-dual-grid { + grid-template-columns: 1fr 1fr; + align-items: stretch; + margin-bottom: 28px; +} +.hedge-plan-page-wrap .options-dual-grid > .card { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} +.hedge-plan-page-wrap #hp-po-layout { + align-items: stretch; +} +.hedge-plan-page-wrap #hp-po-layout > .card { + align-self: stretch; +} +.hedge-plan-page-wrap .hp-po-right-stack > .hp-po-perp-quote-card { + height: auto; + flex: 0 0 auto; +} +.hedge-plan-page-wrap .hp-po-right-stack > .hp-opt-card { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; +} +.hedge-plan-page-wrap .hp-po-right-stack > .hp-opt-card .hp-strike-table-wrap--6 { + flex: 1 1 auto; +} +.hedge-plan-page-wrap .hp-action-row { + margin-top: 10px; + gap: 8px; + justify-content: flex-end; +} +.hedge-plan-page-wrap .hp-preview-modal { + width: min(96vw, 920px); +} +.hedge-plan-page-wrap .hp-preview-summary { + margin: 0 0 10px; + font-size: 0.84rem; + line-height: 1.45; +} +.hedge-plan-page-wrap .hp-preview-actions { + margin-top: 14px; + justify-content: flex-end; + gap: 10px; +} +.hedge-plan-page-wrap .hp-pick.active, +.hedge-plan-page-wrap .opt-row-selected td { + background: rgba(90, 140, 255, 0.18); +} +.hedge-plan-page-wrap .hp-oo-pick.is-selected, +.hedge-plan-page-wrap .hp-oo-pick.active { + border-color: var(--accent, #00d4ff); + color: var(--text, #fff); + background: rgba(0, 212, 255, 0.22); + box-shadow: inset 0 0 0 1px rgba(0, 212, 255, 0.45), 0 0 0 2px rgba(0, 212, 255, 0.2); + font-weight: 700; +} +.hedge-plan-page-wrap .hp-oo-side-selected { + background: rgba(0, 212, 255, 0.12); +} +.hedge-plan-page-wrap tr.hp-oo-row-selected td.opt-t-strike { + color: var(--accent, #00d4ff); +} +.hedge-plan-page-wrap .hp-tab-panel.hidden, +.hedge-plan-page-wrap .hp-tab-panel[hidden] { + display: none !important; +} +.hedge-plan-page-wrap .hp-placeholder { + margin: 16px 0 4px; + padding: 18px; + text-align: center; + border-radius: 8px; + border: 1px dashed rgba(255, 255, 255, 0.16); + color: #9aa4b2; + background: rgba(255, 255, 255, 0.03); + font-size: 0.78rem; +} +.hedge-plan-page-wrap .hp-target-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin: 6px 0; +} +.hedge-plan-page-wrap .hp-target-row label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; +} +.hedge-plan-page-wrap .hp-target-row input { + width: 110px; +} +.hedge-plan-page-wrap .hp-contracts-cell { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.hedge-plan-page-wrap .hp-hist-actions { + white-space: nowrap; +} +.hedge-plan-page-wrap .hp-hist-actions .btn-secondary { + padding: 3px 8px; + font-size: 0.72rem; + min-height: 26px; +} +.hedge-plan-page-wrap .hp-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin-top: 8px; +} +.hedge-plan-page-wrap .hp-stats-grid--sub { + margin-top: 14px; +} +.hedge-plan-page-wrap .hp-stats-card { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 12px 14px; + background: rgba(0, 0, 0, 0.22); +} +.hedge-plan-page-wrap .hp-stats-card h3 { + margin: 0 0 8px; + font-size: 0.92rem; + color: #e8eefc; +} +.hedge-plan-page-wrap .hp-stats-list { + list-style: none; + margin: 0; + padding: 0; +} +.hedge-plan-page-wrap .hp-stats-list li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + margin: 5px 0; + font-size: 0.8rem; +} +.hedge-plan-page-wrap .hp-stats-list li > span:first-child { + color: #9aa4b2; + min-width: 4.5em; +} +.hedge-plan-page-wrap .hp-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1300; + background: rgba(0, 0, 0, 0.72); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} +.hedge-plan-page-wrap .hp-modal-backdrop[hidden] { + display: none !important; +} +.hedge-plan-page-wrap .hp-modal { + width: min(96vw, 980px); + max-height: 88vh; + overflow: auto; + background: #121726; + border: 1px solid #2a3150; + border-radius: 12px; + padding: 14px 16px; +} +.hedge-plan-page-wrap .hp-modal-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} +.hedge-plan-page-wrap .hp-modal-head h3 { + margin: 0; + font-size: 1rem; + color: #dbe4ff; +} +.hedge-plan-page-wrap .hp-detail-summary { + display: grid; + gap: 6px; + margin-bottom: 12px; + font-size: 0.82rem; + color: #e5e9ff; +} +.hedge-plan-page-wrap .hp-detail-legs { + margin-top: 4px; +} +.hedge-plan-page-wrap .hp-ord { + font-size: 0.62rem; + word-break: break-all; +} +.hedge-plan-page-wrap .hp-detail-note { + margin-top: 10px; + font-size: 0.82rem; +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-modal { + background: #f7f8fc; + border-color: #c9d2e8; +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-modal-head h3 { + color: #1a2438; +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-stats-card { + background: rgba(255, 255, 255, 0.7); + border-color: rgba(0, 0, 0, 0.08); +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-stats-card h3 { + color: #1a2438; +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-tabs { + background: rgba(15, 23, 42, 0.06); + border-color: rgba(15, 23, 42, 0.1); +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-tab { + background: #fff; + color: #5b6472; + border-color: rgba(15, 23, 42, 0.14); +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-tab.active { + color: #0b1220; + background: linear-gradient(180deg, #ffffff 0%, #b9d2ff 100%); + border-color: #2f6fed; + box-shadow: 0 0 0 2px rgba(47, 111, 237, 0.25); +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-uly-btn.active, +html[data-theme="light"] .hedge-plan-page-wrap .hp-uly-btn-oo.active, +html[data-theme="light"] .hedge-plan-page-wrap .hp-money-btn.active, +html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-money-btn.is-selected, +html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-money-btn.active, +html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-recommend-btn.is-selected, +html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-recommend-btn.active, +html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-expand-btn.is-selected, +html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-expand-btn.active { + color: #0b1220; + background: linear-gradient(180deg, #ffffff 0%, #b9d2ff 100%); + border-color: #2f6fed; + box-shadow: 0 0 0 2px rgba(47, 111, 237, 0.25); +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-placeholder { + border-color: rgba(15, 23, 42, 0.18); + background: rgba(15, 23, 42, 0.03); +} +.options-strike-table--t thead th { + text-align: center; +} +.options-strike-table--t .opt-t-head-call { + text-align: center; + color: #8ec5ff; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.options-strike-table--t .opt-t-head-mid { + text-align: center; + color: #ffd48a; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.options-strike-table--t .opt-t-head-put { + text-align: center; + color: #ff9f9f; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.options-strike-table--t .opt-t-strike { + text-align: center; + font-variant-numeric: tabular-nums; + background: rgba(255, 255, 255, 0.03); +} +.options-strike-table--t .opt-t-mid { + text-align: center; + background: rgba(255, 212, 138, 0.04); +} +.options-strike-table--t .opt-t-straddle-prem { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.options-strike-table--t .opt-t-straddle-band { + font-variant-numeric: tabular-nums; + white-space: nowrap; + font-size: 0.72rem; + color: var(--text-soft, #9aa4b2); +} +.options-strike-table--t .opt-t-call, +.options-strike-table--t .opt-t-put { + text-align: center; +} +.options-strike-table--t .opt-strike-row-atm td { + background: rgba(255, 212, 138, 0.08); +} +.options-strike-table--t .opt-strike-row-atm .opt-t-strike strong { + color: #ffd48a; +} +.options-strike-table--t .opt-strike-row.opt-row-selected td { + background: rgba(74, 124, 255, 0.12); +} +.opt-strike-hint-row td { + text-align: center; + font-size: 0.72rem; + padding: 8px 4px; + border-bottom: none; +} +html[data-theme="light"] .options-strike-table--t .opt-t-head-call { + color: #1565c0; +} +html[data-theme="light"] .options-strike-table--t .opt-t-head-mid { + color: #e65100; +} +html[data-theme="light"] .options-strike-table--t .opt-t-head-put { + color: #c62828; +} +html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td { + background: rgba(255, 152, 0, 0.08); +} +.opt-order-inline-row td { + padding: 0 !important; + border: none !important; + background: transparent !important; +} +.opt-order-backdrop { + position: fixed; + inset: 0; + z-index: 2100; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(0, 0, 0, 0.72); +} +.opt-order-backdrop[hidden] { + display: none !important; +} +.opt-order-dialog { + width: min(96vw, 760px); + max-height: 92vh; + overflow: auto; + background: var(--card-bg, #121726); + color: inherit; + border: 1px solid rgba(127, 127, 127, 0.35); + border-radius: 14px; + padding: 18px 20px 20px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); +} +.opt-order-layout { + display: block; +} +.opt-order-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; +} +.opt-order-dialog #opt-order-inst, +.opt-order-dialog .options-order-inst { + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1.4; + word-break: break-all; + margin: 0; +} +.opt-order-pending-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.opt-order-pending-title { + margin: 0; + font-size: 0.82rem; + color: #9ec0ff; + font-weight: 600; +} +.opt-pending-ttl-hint { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.4; +} +.opt-order-pending-head .btn-secondary { + font-size: 0.68rem; + padding: 2px 8px; +} +.opt-pending-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 220px; + overflow: auto; +} +.opt-pending-list--tab { + max-height: min(52vh, 420px); +} +.opt-pos-pending-pane { + padding: 4px 2px 8px; +} +.opt-pending-empty { + font-size: 0.72rem; +} +.opt-pending-item { + padding: 8px 9px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.opt-pending-item-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} +.opt-pending-side { + font-size: 0.72rem; + font-weight: 600; +} +.opt-pending-side.is-buy { color: #3dd68c; } +.opt-pending-side.is-sell { color: #ff6b7a; } +.opt-pending-inst { + font-size: 0.68rem; + color: #c5d0ee; + word-break: break-all; + margin-bottom: 4px; +} +.opt-pending-meta { + font-size: 0.68rem; + color: #8892b0; + line-height: 1.35; +} +.opt-pending-item .opt-pending-cancel { + font-size: 0.68rem; + padding: 2px 8px; +} +html[data-theme="light"] .opt-pending-item { + background: #fff; + border-color: rgba(0, 0, 0, 0.08); +} +@media (max-width: 900px) { + .opt-pending-list--tab { + max-height: min(46vh, 360px); + } +} +.opt-order-dialog-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.opt-order-dialog-head .opt-order-title { + margin: 0; + margin-right: auto; + font-size: 1.02rem; + color: #9ec0ff; + font-weight: 650; +} +.opt-order-dialog-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 4px; +} +.opt-order-dialog-actions .btn-primary, +.opt-order-dialog-actions .btn-secondary { + flex: 1 1 140px; + min-height: 38px; + font-size: 0.86rem; + padding: 8px 14px; +} +.opt-order-dialog #opt-order-msg { + margin-top: 2px; + min-height: 1.2em; + line-height: 1.4; +} +.options-order-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); + gap: 12px 14px; + margin: 0; + padding: 12px 12px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.options-order-grid .k { + display: block; + font-size: 0.7rem; + color: #8892b0; + margin-bottom: 3px; +} +.options-order-grid .v, +.options-page-wrap .options-order-grid .v { + font-size: 0.86rem; + font-weight: 600; + line-height: 1.35; +} +.options-estimate-row { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + margin: 0; + padding: 12px 14px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px dashed rgba(255, 255, 255, 0.12); + font-size: 0.78rem; +} +.opt-est-main { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 12px; +} +.opt-order-chip { + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0; + cursor: pointer; + user-select: none; + font-size: 0.76rem; + padding: 6px 12px; + min-height: 32px; + line-height: 1.2; + white-space: nowrap; + border: 1px solid rgba(140, 160, 200, 0.35); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + color: inherit; + box-sizing: border-box; +} +.opt-order-chip:hover { + border-color: rgba(140, 170, 230, 0.55); + background: rgba(255, 255, 255, 0.07); +} +.opt-size-mode-chip { + position: relative; +} +.opt-size-mode-chip[hidden] { + display: none !important; +} +.opt-size-mode-chip input[type="radio"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + margin: 0; + pointer-events: none; +} +.opt-size-mode-chip:has(input:checked), +.opt-size-mode-chip.is-selected, +.opt-size-mode-chip.active { + border-color: #5b8cff; + color: #cfe0ff; + background: rgba(74, 124, 255, 0.28); + box-shadow: inset 0 0 0 1px rgba(120, 160, 255, 0.45); +} +.options-estimate-row .opt-target-idx { + width: 140px; + font-size: 0.8rem; + padding: 6px 8px; + min-height: 32px; + box-sizing: border-box; +} +.options-estimate-row .opt-profit-exit-mult, +.options-page-wrap .opt-pos-profit-exit-mult { + width: 4.5rem; + min-width: 0; + font-size: 0.8rem; + padding: 6px 8px; + min-height: 32px; + box-sizing: border-box; +} +.options-page-wrap .opt-profit-exit-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.78rem; + white-space: nowrap; +} +.options-estimate-row .k { + color: #8892b0; +} +.options-estimate-row .v { + font-size: 0.86rem; + font-weight: 600; +} +.options-estimate-row .opt-est-note { + display: block; + font-size: 0.7rem; + line-height: 1.45; + opacity: 0.85; +} +html[data-theme="light"] .options-estimate-row, +html[data-theme="light"] .options-order-grid { + background: rgba(0, 0, 0, 0.02); + border-color: rgba(0, 0, 0, 0.1); +} +html[data-theme="light"] .opt-order-chip { + border-color: rgba(0, 0, 0, 0.16); + background: #fff; +} +.options-hint { + font-size: 0.75rem; + margin-bottom: 6px; +} +.options-page-wrap .options-order-mode-row { + font-size: 0.78rem; + gap: 10px; +} +.options-page-wrap .options-order-mode-row input[type="number"], +.options-page-wrap .options-order-mode-row input[type="text"] { + font-size: 0.8rem; + padding: 6px 8px; + min-height: 32px; + box-sizing: border-box; +} +.options-page-wrap .options-order-mode-row .btn-primary { + font-size: 0.8rem; + padding: 6px 12px; +} +.options-page-wrap .opt-row-actions .btn-primary, +.options-page-wrap .opt-row-actions .btn-secondary { + font-size: 0.7rem; + padding: 3px 7px; + min-height: 24px; +} +#opt-order-msg.opt-error, +.opt-error { + color: #ff6b6b; +} +.opt-success { + color: #3ecf8e; +} +.opt-row-actions { + white-space: nowrap; +} +.opt-row-actions .btn-primary, +.opt-row-actions .btn-secondary { + margin-right: 4px; +} +.opt-moneyness { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + font-size: 0.68rem; + font-weight: 600; +} +.opt-moneyness-itm { + color: #7ee787; + background: rgba(46, 160, 67, 0.15); +} +.opt-moneyness-otm { + color: #a8b3cf; + background: rgba(136, 146, 176, 0.12); +} +.opt-moneyness-atm { + color: #ffd166; + background: rgba(255, 209, 102, 0.12); +} +.options-order-mode-row { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + margin: 0; +} +.opt-size-mode-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} +.options-order-mode-row input[type="number"] { + width: 96px; +} +.options-order-mode-row .opt-signal-note, +.options-order-mode-row #opt-signal-note { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} +.options-order-mode-row .opt-order-chip { + flex: 0 0 auto; +} +.options-dual-grid { + display: grid; + grid-template-columns: 1.15fr 0.85fr; + gap: 16px; + align-items: stretch; +} +.options-order-card, +.options-pos-card-wrap { + display: flex; + flex-direction: column; + min-height: 0; +} +.options-pos-stack, +.options-pos-tab-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; +} +.options-pos-tabs { + display: flex; + gap: 6px; + margin-bottom: 8px; +} +.opt-pos-tab { + flex: 1; + font-size: 0.78rem; + padding: 6px 8px; + min-height: 32px; + white-space: nowrap; +} +.opt-pos-tab.active { + border-color: #5b8cff; + color: #cfe0ff; + background: rgba(74, 124, 255, 0.28); +} +.options-pos-pane { + display: none; + flex: 1; + flex-direction: column; + min-height: 0; +} +.options-pos-pane.is-active { + display: flex; +} +.options-pos-live-pane { + flex: 1; + min-height: 180px; + max-height: 420px; + overflow-y: auto; +} +.options-pos-live-pane.options-pos-live-pane--accordion { + max-height: 480px; +} +.options-pos-subcard { + display: flex; + flex-direction: column; + min-height: 0; + padding: 8px 10px; +} +.options-pos-subcard h3 { + margin: 0 0 6px; + font-size: 0.8rem; + font-weight: 600; +} +.options-page-wrap .options-pos-subcard h3 { + font-size: 0.78rem; + color: #b8c0dc; +} +.options-pos-stats-card { + flex-shrink: 0; +} +.options-stats-pnl-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} +.options-stats-pnl-summary .options-stat-item { + padding: 10px 12px; + border-radius: 8px; + background: rgba(127, 127, 127, 0.12); +} +.options-stats-pnl-summary .opt-stats-net-item .v { + font-size: 1.15em; + font-weight: 650; +} +html[data-theme="light"] .options-stats-pnl-summary .options-stat-item { + background: rgba(0, 0, 0, 0.04); +} +.options-stats-panel { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; +} +.options-stats-charts { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px 12px; + align-items: center; +} +.opt-stats-chart--ring { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} +.opt-stats-ring { + --win-pct: 0; + width: 68px; + height: 68px; + border-radius: 50%; + background: conic-gradient( + #4cd97f 0 calc(var(--win-pct) * 1%), + #ff6b6b calc(var(--win-pct) * 1%) 100% + ); + display: flex; + align-items: center; + justify-content: center; + position: relative; +} +.opt-stats-ring::before { + content: ""; + position: absolute; + inset: 8px; + border-radius: 50%; + background: #141923; +} +.opt-stats-ring-label { + position: relative; + z-index: 1; + font-size: 0.82rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.opt-stats-chart-caption, +.opt-stats-chart-title { + font-size: 0.66rem; + color: #9aa3bf; + text-align: center; +} +.opt-stats-chart-title { + margin-bottom: 4px; + text-align: left; +} +.opt-stats-chart--pnl, +.opt-stats-chart--hold { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.opt-stats-bar-row { + display: grid; + grid-template-columns: 2.2em 1fr auto; + gap: 6px; + align-items: center; + font-size: 0.72rem; +} +.opt-stats-bar-row .k { + opacity: 0.8; +} +.opt-stats-bar-row .v { + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; +} +.opt-stats-bar-track { + height: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} +.opt-stats-bar-fill { + height: 100%; + width: 0; + border-radius: 999px; + transition: width 0.25s ease; +} +.opt-stats-bar-fill--profit { + background: linear-gradient(90deg, #2f9f62, #4cd97f); +} +.opt-stats-bar-fill--loss { + background: linear-gradient(90deg, #c44a4a, #ff6b6b); +} +.options-stats-grid { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + padding: 4px 2px 8px; +} +.options-stat-item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 52px; +} +.options-stat-item .k { + font-size: 0.66rem; + opacity: 0.75; +} +.options-stat-item .v { + font-size: 0.82rem; + font-weight: 600; + line-height: 1.25; +} +.options-page-wrap .options-stat-item .v { + font-size: 0.8rem; +} +.options-history-table-wrap .opt-history-del { + font-size: 0.68rem; + padding: 2px 7px; + min-height: 22px; +} +.options-page-wrap .opt-pos-card { + font-size: 0.76rem; + margin-bottom: 8px; +} +.opt-pos-cards--accordion { + display: flex; + flex-direction: column; + gap: 6px; +} +.opt-pos-accordion-item { + display: flex; + flex-direction: column; +} +.opt-pos-bar { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + background: #141923; + border: 1px solid #2a3348; + border-radius: 8px; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + transition: border-color 0.15s, background 0.15s; +} +.opt-pos-bar:hover { + border-color: #3d4d6e; + background: #171d2a; +} +.opt-pos-accordion-item.is-expanded .opt-pos-bar { + border-color: #4a6fd8; + border-radius: 8px 8px 0 0; + border-bottom-color: transparent; + background: #171d2a; +} +.opt-pos-accordion-body { + overflow: visible; +} +.opt-pos-card--inline { + margin-bottom: 0 !important; + border-top: none !important; + border-radius: 0 0 8px 8px !important; +} +.opt-pos-bar-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; + overflow: hidden; +} +.opt-pos-bar-id-group { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-shrink: 1; + overflow: hidden; +} +.opt-pos-bar .pos-side-badge { + display: inline-flex; + align-items: center; + white-space: nowrap; + flex-shrink: 0; + padding: 2px 6px; + font-size: 0.62rem; + line-height: 1; +} +.opt-source-badge { + display: inline-flex; + align-items: center; + white-space: nowrap; + flex-shrink: 0; + padding: 2px 7px; + border-radius: 6px; + font-size: 0.68rem; + font-weight: 600; + line-height: 1.2; + border: 1px solid transparent; +} +.opt-source-badge--plain { + background: rgba(255, 255, 255, 0.06); + color: #9aa4b2; + border-color: rgba(255, 255, 255, 0.1); +} +.opt-source-badge--po { + background: rgba(100, 160, 255, 0.16); + color: #8ec0ff; + border-color: rgba(100, 160, 255, 0.35); +} +.opt-source-badge--oo { + background: rgba(0, 212, 255, 0.14); + color: #5ee4ff; + border-color: rgba(0, 212, 255, 0.35); +} +.opt-pos-bar .opt-source-badge { + padding: 2px 6px; + font-size: 0.6rem; +} +html[data-theme="light"] .opt-source-badge--plain { + background: rgba(15, 23, 42, 0.06); + color: #5a6578; + border-color: rgba(15, 23, 42, 0.12); +} +html[data-theme="light"] .opt-source-badge--po { + background: rgba(37, 99, 235, 0.1); + color: #1d4ed8; + border-color: rgba(37, 99, 235, 0.25); +} +html[data-theme="light"] .opt-source-badge--oo { + background: rgba(8, 145, 178, 0.1); + color: #0e7490; + border-color: rgba(8, 145, 178, 0.28); +} +.opt-pos-bar-meta { + font-size: 0.66rem; + color: #8b95b0; + white-space: nowrap; + flex-shrink: 0; +} +.opt-pos-bar-side { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + flex: 0 0 auto; + margin-left: 8px; + font-variant-numeric: tabular-nums; +} +.opt-pos-bar-title { + font-size: 0.68rem; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 0 1 auto; + min-width: 0; +} +.opt-pos-bar-cd { + font-size: 0.66rem; + color: #8b95b0; + white-space: nowrap; +} +.opt-pos-bar-pnl, +.opt-pos-bar-roi { + font-size: 0.68rem; + font-weight: 600; + white-space: nowrap; + line-height: 1.15; +} +.opt-pos-bar-chevron { + display: inline-block; + font-size: 0.58rem; + color: #8b95b0; + transition: transform 0.15s ease; + flex-shrink: 0; +} +.opt-pos-accordion-item.is-expanded .opt-pos-bar-chevron { + transform: rotate(90deg); +} +.options-page-wrap .opt-pos-card .pos-card-symbol strong { + font-size: 0.78rem; +} +.options-page-wrap .opt-pos-card .pos-label, +.options-page-wrap .opt-pos-card .pos-meta-item { + font-size: 0.7rem; +} +.options-page-wrap .opt-pos-card .pos-value { + font-size: 0.78rem; +} +.options-page-wrap .opt-pos-cell--depth { + grid-column: span 2; +} +.options-page-wrap .opt-target-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(67, 82, 118, 0.45); +} +.options-page-wrap .opt-target-row-label { + font-size: 0.72rem; + color: #9aa8c7; + min-width: 2.5em; +} +.options-page-wrap .opt-pos-target-input { + width: 110px; + max-width: 36vw; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid #3a4660; + background: #0f1420; + color: #e8eefc; + font-size: 0.82rem; +} +.options-page-wrap .opt-target-row .btn-secondary { + padding: 4px 10px; + font-size: 0.75rem; +} +.options-page-wrap .opt-target-row-hint { + font-size: 0.7rem; +} +.options-page-wrap .opt-target-armed { + font-size: 0.78rem; + color: #9ad0ff; + font-variant-numeric: tabular-nums; +} +.options-page-wrap .opt-target-row--managed { + border-color: rgba(126, 231, 135, 0.38); + background: rgba(46, 160, 67, 0.08); +} +.options-page-wrap .opt-target-row--managed .opt-target-armed, +.options-page-wrap .opt-target-mon-managed { + color: #7ee787; + font-weight: 600; +} +.options-page-wrap .opt-target-est { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + font-size: 0.78rem; +} +.options-page-wrap .opt-target-est--idle:empty { + display: none; +} +.options-page-wrap .opt-target-est-item { + display: inline-flex; + align-items: baseline; + gap: 4px; +} +.options-page-wrap .opt-target-est-item .k { + color: #9aa8c7; + font-size: 0.7rem; +} +.options-page-wrap .opt-target-est-item .v { + font-variant-numeric: tabular-nums; + font-weight: 600; +} +.opt-target-monitors { + margin: 0 0 10px; + padding: 10px 12px; + border: 1px solid rgba(99, 118, 168, 0.45); + border-radius: 10px; + background: rgba(18, 28, 48, 0.75); +} +.opt-target-monitors-head { + font-size: 0.78rem; + font-weight: 600; + color: #c9d6f5; + margin-bottom: 8px; +} +.opt-target-mon-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 6px 0; + border-top: 1px solid rgba(67, 82, 118, 0.35); +} +.opt-target-mon-item:first-child { + border-top: 0; + padding-top: 0; +} +.opt-target-mon-inst { + font-size: 0.72rem; + color: #dbe6ff; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} +.opt-target-mon-rule { + font-size: 0.78rem; + color: #9ad0ff; + font-variant-numeric: tabular-nums; +} +.opt-target-mon-item .btn-secondary { + margin-left: auto; + padding: 2px 8px; + font-size: 0.7rem; +} +.opt-target-mon-item--managed { + border-color: rgba(126, 231, 135, 0.28); +} +.opt-target-mon-managed { + margin-left: auto; + font-size: 0.72rem; +} +.options-page-wrap .opt-bid-plain { + color: #dbe6ff; + font-variant-numeric: tabular-nums; + line-height: 1.35; + white-space: normal; +} +.options-page-wrap .opt-close-value { + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.options-page-wrap .opt-close-rule, +.options-page-wrap .opt-open-rule { + margin-top: 8px; + margin-bottom: 10px; + padding: 0; + border: 1px solid rgba(67, 82, 118, 0.55); + border-radius: 10px; + background: rgba(14, 19, 30, 0.58); + overflow: hidden; +} +.options-page-wrap .opt-open-rule { + margin-top: 4px; +} +.options-page-wrap .opt-close-rule summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + color: #c9d6f2; + font-size: 0.72rem; + font-weight: 650; + cursor: pointer; + user-select: none; + list-style: none; +} +.options-page-wrap .opt-close-rule summary::-webkit-details-marker { + display: none; +} +.options-page-wrap .opt-close-rule summary::after { + content: "展开"; + padding: 2px 7px; + border-radius: 999px; + background: rgba(82, 101, 143, 0.25); + color: #91a4cc; + font-size: 0.62rem; + font-weight: 600; +} +.options-page-wrap .opt-close-rule[open] summary { + border-bottom: 1px solid rgba(67, 82, 118, 0.45); + background: rgba(28, 38, 60, 0.55); +} +.options-page-wrap .opt-close-rule[open] summary::after { + content: "收起"; +} +.options-page-wrap .opt-close-rule-body { + padding: 8px 10px 10px; + color: #96a4bf; + font-size: 0.7rem; + line-height: 1.55; +} +.options-page-wrap .opt-close-rule-body p { + margin: 0 0 6px; + color: #b6c2dc; +} +.options-page-wrap .opt-close-rule-body ul { + margin: 0; + padding-left: 16px; +} +.options-page-wrap .opt-close-rule-body li + li { + margin-top: 3px; +} +.options-page-wrap .opt-close-rule-body code { + color: #dbe6ff; + background: rgba(82, 101, 143, 0.22); + border-radius: 4px; + padding: 1px 4px; +} +.options-page-wrap .pos-empty { + padding: 10px; + font-size: 0.72rem; +} +.options-page-wrap #opt-order-msg { + font-size: 0.72rem; +} +.options-pos-history-card { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.options-history-table-wrap { + flex: 1; + overflow-y: auto; + overflow-x: auto; + min-height: 180px; + max-height: 420px; +} +.opt-history-table { + table-layout: fixed; + width: 100%; +} +.opt-history-table .opt-hist-inst code { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.opt-history-table th:nth-child(1), +.opt-history-table td:nth-child(1) { + width: 34%; +} +.opt-history-table th:nth-child(2), +.opt-history-table td:nth-child(2) { + width: 7%; +} +.opt-history-table th:nth-child(3), +.opt-history-table td:nth-child(3) { + width: 11%; +} +.opt-history-table th:nth-child(4), +.opt-history-table td:nth-child(4) { + width: 9%; +} +.opt-history-table th:nth-child(5), +.opt-history-table td:nth-child(5) { + width: 11%; +} +.opt-history-table th:nth-child(6), +.opt-history-table td:nth-child(6) { + width: 20%; +} +.opt-history-table th:nth-child(7), +.opt-history-table td:nth-child(7) { + width: 8%; + text-align: center; +} +.opt-hist-time { + font-size: 0.64rem; + white-space: nowrap; +} +.opt-hist-status { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 4px; + font-size: 0.62rem; + font-weight: 600; + line-height: 1.3; + white-space: nowrap; +} +.opt-hist-status--closed { + background: rgba(74, 124, 255, 0.2); + color: #9ec0ff; +} +.opt-hist-status--expired { + background: rgba(255, 179, 71, 0.15); + color: #ffb347; +} +.opt-hist-status--open { + background: rgba(94, 232, 154, 0.12); + color: #5ee89a; +} +@media (max-width: 1100px) { + .options-dual-grid { + grid-template-columns: 1fr; + } +} +.options-order-card h2, +.options-pos-card-wrap h2 { + margin: 0 0 8px; +} +.options-pos-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.options-pos-head h2 { + margin: 0; +} +.opt-pos-card { + margin-bottom: 10px; +} +.opt-expiry-cd { + font-variant-numeric: tabular-nums; + font-weight: 600; +} +.opt-expiry-cd--urgent { + color: #ffb347; +} +.opt-expiry-cd--expired { + color: var(--muted); +} +.settings-card--compact .options-settings-section { + margin-bottom: 8px; +} + +.settings-card--compact .options-settings-section:last-child { + margin-bottom: 0; +} + +.options-settings-block { + margin-bottom: 14px; +} +.options-settings-section { + margin-bottom: 10px; +} +.options-settings-section:last-child { + margin-bottom: 0; +} +.options-settings-subtitle { + font-size: 0.72rem; + font-weight: 600; + color: #a8b0cc; + margin: 0 0 6px; +} +.options-settings-arrow { + font-size: 0.75rem; + color: #8892b0; + align-self: center; +} +.options-settings-row { + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.options-settings-row select, +.options-settings-row input[type="number"] { + font-size: 0.75rem; + padding: 4px 7px; + min-height: 28px; +} +.options-settings-row .btn-sm { + font-size: 0.72rem; + padding: 4px 10px; + min-height: 28px; +} +.options-settings-hint { + font-size: 0.72rem; + margin: 0 0 6px; + line-height: 1.45; +} +.options-settings-msg { + font-size: 0.68rem; + margin-top: 2px; + min-height: 1em; + line-height: 1.35; +} +html[data-theme="light"] .stat-strip-item--pnl .value.pnl-pos { + color: #087a50 !important; + font-weight: 700 !important; +} + +html[data-theme="light"] .stat-strip-item--pnl .value.pnl-neg { + color: #c03030 !important; + font-weight: 700 !important; +} + +html[data-theme="light"] .btn-secondary { + background: #fff !important; + color: #004d6e !important; + border: 1px solid rgba(0, 95, 140, 0.32) !important; +} + +html[data-theme="light"] .btn-secondary:hover { + background: #eef3f8 !important; +} + +html[data-theme="light"] .btn-primary { + background: linear-gradient(90deg, #007aa8, #5b4fc7) !important; + color: #fff !important; + border: none !important; +} + +html[data-theme="light"] code { + background: #eef3f8; + color: #142232; + border: 1px solid #c8d4e0; + padding: 1px 4px; + border-radius: 4px; + font-size: 0.92em; +} + +html[data-theme="light"] .card-nested, +html[data-theme="light"] .options-section.card-nested, +html[data-theme="light"] .options-pos-subcard { + background: #f6f9fc !important; + border: 1px solid #c8d4e0 !important; +} + +html[data-theme="light"] .options-strike-table thead th { + background: #eef3f8 !important; + color: #334155 !important; + border-bottom: 1px solid #c8d4e0 !important; +} + +html[data-theme="light"] .options-strike-table th, +html[data-theme="light"] .options-strike-table td { + border-bottom-color: #d0dae4 !important; + color: #142232 !important; +} + +html[data-theme="light"] .options-page-wrap .options-pos-subcard h3, +html[data-theme="light"] .options-settings-subtitle { + color: #142232 !important; +} + +html[data-theme="light"] .opt-pos-bar { + background: #f6f9fc; + border-color: #c8d4e0; + color: #142232; +} +html[data-theme="light"] .opt-pos-bar:hover, +html[data-theme="light"] .opt-pos-accordion-item.is-expanded .opt-pos-bar { + background: #eef3f8; + border-color: #9eb0c4; +} +html[data-theme="light"] .opt-pos-bar-title { + color: #142232; +} +html[data-theme="light"] .opt-pos-bar-meta, +html[data-theme="light"] .opt-pos-bar-cd, +html[data-theme="light"] .opt-pos-bar-chevron { + color: #5a6d82; +} +html[data-theme="light"] .opt-pos-accordion-body { + background: #f6f9fc; + border-color: #c8d4e0; +} + +html[data-theme="light"] .options-page-wrap .options-hint, +html[data-theme="light"] .options-page-wrap #opt-index-line, +html[data-theme="light"] .options-order-grid .k, +html[data-theme="light"] .options-stat-item .k { + color: #3a5068 !important; + opacity: 1 !important; +} + +html[data-theme="light"] .options-page-wrap .options-order-grid .v, +html[data-theme="light"] .options-page-wrap .options-stat-item .v { + color: #142232 !important; +} + +html[data-theme="light"] .options-chain-toolbar .btn-secondary.active, +html[data-theme="light"] .opt-uly-btn.active, +html[data-theme="light"] .opt-type-btn.active, +html[data-theme="light"] .opt-money-btn.active, +html[data-theme="light"] .opt-pos-tab.active, +html[data-theme="light"] .opt-size-mode-chip.is-selected, +html[data-theme="light"] .opt-size-mode-chip.active, +html[data-theme="light"] .opt-size-mode-chip:has(input:checked) { + border-color: rgba(0, 95, 140, 0.45) !important; + color: #004d6e !important; + background: rgba(0, 110, 154, 0.14) !important; + box-shadow: inset 0 0 0 1px rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] .opt-stats-ring::before { + background: #f4f7fb; +} +html[data-theme="light"] .opt-stats-bar-track { + background: rgba(20, 34, 50, 0.08); +} +html[data-theme="light"] .opt-stats-chart-caption, +html[data-theme="light"] .opt-stats-chart-title { + color: #5a6a80 !important; +} +html[data-theme="light"] .opt-moneyness-itm { + color: #087a50 !important; + background: rgba(8, 122, 80, 0.12) !important; +} + +html[data-theme="light"] .opt-moneyness-otm { + color: #4a6078 !important; + background: rgba(74, 96, 120, 0.1) !important; +} + +html[data-theme="light"] .opt-moneyness-atm { + color: #8a6200 !important; + background: rgba(180, 130, 20, 0.12) !important; +} + +html[data-theme="light"] .opt-strike-row.opt-row-selected td { + background: rgba(0, 110, 154, 0.08) !important; +} + +html[data-theme="light"] .pos-pnl-profit { + color: #087a50 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .pos-pnl-loss { + color: #c03030 !important; + font-weight: 600 !important; +} + +/* 期权持仓 · 亮色主题对比度 */ +html[data-theme="light"] .options-page-wrap .opt-pos-card { + background: #fff !important; + border-color: #94a3b8 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-card .pos-label, +html[data-theme="light"] .options-page-wrap .opt-pos-card .pos-meta, +html[data-theme="light"] .options-page-wrap .opt-pos-card .pos-meta-item { + color: #0f172a !important; + font-weight: 500 !important; + opacity: 1 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-card .pos-meta-item::after { + color: #64748b !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-card .pos-value { + color: #020617 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-bid-plain { + color: #0f172a !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-value { + color: #9f1239 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-bid-invalid-hint, +html[data-theme="light"] .options-page-wrap .opt-pos-card .muted { + color: #334155 !important; + opacity: 1 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-row { + border-top-color: #94a3b8 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-row-label { + color: #0f172a !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-target-input { + background: #fff !important; + color: #0f172a !important; + border-color: #64748b !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-target-input::placeholder { + color: #64748b !important; + opacity: 1 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-row .btn-secondary { + color: #004d6e !important; + border-color: #007aa8 !important; + background: #e8f4fa !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-armed { + color: #004d6e !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-est-item .k { + color: #334155 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-est-item .v { + color: #0f172a !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-row-hint { + color: #334155 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-expiry-cd { + color: #004d6e !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-expiry-cd--urgent { + color: #9a6200 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-expiry-cd--expired { + color: #475569 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-tab:not(.active) { + color: #0f172a !important; + border-color: #64748b !important; + background: #fff !important; + font-weight: 500 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-tab.active { + color: #003d57 !important; + border-color: #006e9a !important; + background: rgba(0, 110, 154, 0.16) !important; + font-weight: 700 !important; +} + +html[data-theme="light"] .options-page-wrap .options-pos-head h2, +html[data-theme="light"] .options-page-wrap .options-pos-card-wrap h2 { + color: #020617 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule { + border: 1px solid #64748b !important; + background: #e2e8f0 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule summary { + color: #0f172a !important; + background: #cbd5e1 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule summary::after { + background: rgba(0, 95, 140, 0.18) !important; + color: #003d57 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule[open] summary { + background: #b8c8d8 !important; + border-bottom-color: #64748b !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule-body { + color: #1e293b !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule-body p { + color: #0f172a !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule-body li { + color: #1e293b !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule-body code { + color: #0f172a !important; + background: #fff !important; + border: 1px solid #94a3b8 !important; +} + +html[data-theme="light"] .options-page-wrap .pos-empty { + color: #334155 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-monitors { + background: #eef4fa !important; + border: 1px solid #94a3b8 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-monitors-head { + color: #0f172a !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-mon-item { + border-top-color: #cbd5e1 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-mon-inst { + color: #1e293b !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-mon-rule { + color: #004d6e !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-mon-item .btn-secondary { + color: #004d6e !important; + border-color: #007aa8 !important; + background: #fff !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .order-preview-profit { + color: #087a50 !important; +} + +html[data-theme="light"] .order-preview-risk { + color: #c03030 !important; +} + +html[data-theme="light"] .instance-header-panel { + background: #fff !important; + border: 1px solid #9eb0c4 !important; + box-shadow: 0 1px 3px rgba(20, 34, 50, 0.06); +} + +html[data-theme="light"] .settings-account-summary { + background: #fff !important; + border: 1px solid #9eb0c4 !important; + box-shadow: 0 1px 3px rgba(20, 34, 50, 0.06); +} + +/* 期权复盘 · 亮色主题(覆盖面板内暗色默认变量) */ +html[data-theme="light"] .options-review-wrap { + --or-section-bg: #fff; + --or-section-shadow: 0 1px 3px rgba(20, 34, 50, 0.06); + --or-border: #9eb0c4; + --or-border-soft: #c8d4e0; + --or-border-faint: #dce4ec; + --or-text: #142232; + --or-title: #142232; + --or-muted: #3a5068; + --or-filters-bg: #eef3f8; + --or-tile-bg: #f6f9fc; + --or-badge-bg: rgba(0, 110, 154, 0.1); + --or-accent-bg: rgba(0, 110, 154, 0.12); + --or-accent-fg: #004d6e; + --or-accent-border: rgba(0, 95, 140, 0.28); + --or-row-active-bg: rgba(0, 110, 154, 0.08); + --or-row-hover-bg: rgba(0, 110, 154, 0.06); + --or-modal-bg: #fff; + --or-modal-shadow: 0 12px 40px rgba(20, 34, 50, 0.18); + --or-backdrop: rgba(20, 34, 50, 0.45); + --or-img-bg: #eef3f8; + color: #142232; +} + +html[data-theme="light"] .options-review-wrap .or-section { + background: #fff !important; + border-color: #9eb0c4 !important; + box-shadow: 0 1px 3px rgba(20, 34, 50, 0.06); + color: #142232 !important; +} + +html[data-theme="light"] .options-review-wrap .or-section-title, +html[data-theme="light"] .options-review-wrap .or-detail-modal-head h3, +html[data-theme="light"] .options-review-wrap .or-page-head h2 { + color: #142232 !important; +} + +html[data-theme="light"] .options-review-wrap .or-section-desc, +html[data-theme="light"] .options-review-wrap .muted, +html[data-theme="light"] .options-review-wrap .sub { + color: #3a5068 !important; +} + +html[data-theme="light"] .options-review-wrap .or-filters { + background: #eef3f8 !important; + border-color: #c8d4e0 !important; +} + +html[data-theme="light"] .options-review-wrap .or-kpi-tile, +html[data-theme="light"] .options-review-wrap .or-stat-card, +html[data-theme="light"] .options-review-wrap .or-detail-img-cell { + background: #f6f9fc !important; + border-color: #c8d4e0 !important; +} + +html[data-theme="light"] .options-review-wrap .or-tab { + background: #fff !important; + color: #006e9a !important; + border-color: rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] .options-review-wrap .or-tab.active { + background: rgba(0, 110, 154, 0.12) !important; + color: #004d6e !important; + border-color: rgba(0, 95, 140, 0.28) !important; +} + +html[data-theme="light"] .options-review-wrap .or-step { + background: rgba(0, 110, 154, 0.12) !important; + color: #004d6e !important; + border-color: rgba(0, 95, 140, 0.28) !important; +} + +html[data-theme="light"] .options-review-wrap .or-badge { + background: rgba(0, 110, 154, 0.1) !important; + color: #004d6e !important; +} + +html[data-theme="light"] .options-review-wrap .or-detail-modal { + background: #fff !important; + color: #142232 !important; + border-color: #9eb0c4 !important; + box-shadow: 0 12px 40px rgba(20, 34, 50, 0.18); +} + +html[data-theme="light"] .options-review-wrap .or-detail-backdrop { + background: rgba(20, 34, 50, 0.45) !important; +} + +html[data-theme="light"] .options-review-wrap .options-strike-table thead th { + background: #eef3f8 !important; + color: #334155 !important; + border-bottom: 1px solid #c8d4e0 !important; +} + +html[data-theme="light"] .options-review-wrap .options-strike-table th, +html[data-theme="light"] .options-review-wrap .options-strike-table td { + color: #142232 !important; + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .options-review-wrap .or-trades-table tr.or-row-active, +html[data-theme="light"] .options-review-wrap .or-reviewed-table tbody tr:hover { + background: rgba(0, 110, 154, 0.08) !important; +} + +.pos-pnl-profit { + color: #7ee787; +} +.pos-pnl-loss { + color: #ff8b8b; +} + +/* —— 实例手机壳:底栏四件套(仅 ≤720px + body.inst-phone) —— */ +.inst-mobile-tabbar, +.inst-mobile-more, +.instance-phone-only { + display: none; +} + +@media (max-width: 720px) { + :root { + --inst-m-tabbar-h: 56px; + --inst-m-page-pad: calc(var(--inst-m-tabbar-h) + max(16px, env(safe-area-inset-bottom)) + 12px); + } + + /* 仅手机壳:不碰 >720 桌面/平板。overflow 放 container,避免裁切 fixed 下单弹窗 */ + body.inst-phone { + padding-bottom: var(--inst-m-page-pad) !important; + box-sizing: border-box; + } + + body.inst-phone .embed-top-nav.top-nav { + display: none !important; + } + + body.inst-phone .header { + margin-bottom: 4px; + } + + body.inst-phone .header h1 { + display: none !important; + } + + body.inst-phone .container { + padding-bottom: 8px !important; + max-width: 100% !important; + min-width: 0 !important; + overflow-x: hidden !important; + } + + body.inst-phone #embed-page-root, + body.inst-phone .embed-tab-pane, + body.inst-phone .embed-tab-pane.is-active-pane { + max-width: 100%; + min-width: 0; + overflow-x: hidden; + } + + body.inst-phone .card { + max-width: 100%; + min-width: 0; + box-sizing: border-box; + } + + body.inst-phone .instance-header-toolbar { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + body.inst-phone .instance-header-toolbar-end { + width: auto; + margin-left: auto; + } + + body.inst-phone .instance-phone-only { + display: flex; + } + + body.inst-phone .instance-header-phone-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + width: 100%; + } + + html[data-theme="light"] body.inst-phone .instance-header-phone-strip { + border-top-color: rgba(0, 0, 0, 0.08); + } + + body.inst-phone .inst-phone-chip { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 6px; + min-height: 32px; + min-width: 0; + padding: 6px 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + font-size: 12px; + color: var(--inst-text); + } + + html[data-theme="light"] body.inst-phone .inst-phone-chip { + background: rgba(0, 0, 0, 0.03); + border-color: rgba(0, 0, 0, 0.08); + } + + body.inst-phone .inst-phone-chip em { + font-style: normal; + color: var(--inst-muted); + font-size: 11px; + flex: 0 0 auto; + } + + body.inst-phone .inst-phone-chip b { + font-weight: 600; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; + } + + body.inst-phone .inst-phone-chip--pnl b { + color: var(--inst-nav-idle); + } + + /* 实盘/关键位等表单:窄屏拉满,避免挤成一行裁切 */ + body.inst-phone #add-order-form.form-row, + body.inst-phone form.form-row { + align-items: stretch; + } + + body.inst-phone #add-order-form.form-row > input:not([type="checkbox"]):not([type="radio"]), + body.inst-phone #add-order-form.form-row > select, + body.inst-phone #add-order-form #sltp-mode, + body.inst-phone form.form-row > input:not([type="checkbox"]):not([type="radio"]), + body.inst-phone form.form-row > select { + flex: 1 1 100% !important; + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + box-sizing: border-box; + } + + body.inst-phone #add-order-form .order-entry-model-row, + body.inst-phone #add-order-form .order-time-close-wrap, + body.inst-phone #add-order-form > label, + body.inst-phone #add-order-form > button { + flex: 1 1 100%; + max-width: 100%; + } + + body.inst-phone #add-order-form .order-entry-model-row select { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + } + + body.inst-phone .order-monitor-form .om-row { + align-items: stretch; + } + + body.inst-phone .order-monitor-form .om-row-policy > input:not([type="checkbox"]):not([type="radio"]), + body.inst-phone .order-monitor-form .om-row-policy > select, + body.inst-phone .order-monitor-form #sltp-mode, + body.inst-phone .order-monitor-form .om-field, + body.inst-phone .order-monitor-form .om-live-meta, + body.inst-phone .order-monitor-form .om-check, + body.inst-phone .order-monitor-form .om-time-close, + body.inst-phone .order-monitor-form .order-entry-model-row, + body.inst-phone .order-monitor-form .om-submit { + flex: 1 1 100% !important; + width: 100% !important; + max-width: 100% !important; + min-width: 0; + box-sizing: border-box; + margin-left: 0; + } + + body.inst-phone .order-monitor-form .om-field input { + width: 100% !important; + max-width: 100% !important; + } + + body.inst-phone .order-monitor-form .om-live-meta { + padding-bottom: 0; + } + + body.inst-phone .order-plan-preview { + flex-direction: column; + align-items: flex-start; + gap: 6px; + } + + /* 期权:防止宽表撑破页面;表内横向滑看「操作」 */ + body.inst-phone .options-page-wrap, + body.inst-phone .options-dual-grid, + body.inst-phone .options-order-card, + body.inst-phone .options-pos-card-wrap { + max-width: 100%; + min-width: 0; + } + + body.inst-phone .options-dual-grid { + grid-template-columns: minmax(0, 1fr) !important; + } + + body.inst-phone .options-chain-toolbar.form-row { + flex-wrap: wrap; + gap: 6px; + } + + body.inst-phone .options-chain-toolbar .btn-secondary, + body.inst-phone .options-chain-toolbar select, + body.inst-phone .options-chain-toolbar .opt-chain-view-group, + body.inst-phone .options-chain-toolbar .opt-type-btn-group { + flex: 0 1 auto; + max-width: 100%; + } + + body.inst-phone .options-strike-table-wrap, + body.inst-phone .options-strike-table-wrap--t, + body.inst-phone .options-history-table-wrap, + body.inst-phone .table-wrap { + display: block; + width: 100%; + max-width: 100%; + overflow-x: auto !important; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + touch-action: pan-x pan-y; + } + + body.inst-phone .options-strike-table { + width: max-content; + min-width: 100%; + } + + body.inst-phone .options-strike-table th, + body.inst-phone .options-strike-table td { + white-space: nowrap; + } + + /* 列表视图:隐藏合约 / 买一 / 到期平衡 / 距平衡(保留行权价·类型·卖一·操作) */ + body.inst-phone #opt-strike-head-list th:nth-child(3), + body.inst-phone #opt-strike-head-list th:nth-child(5), + body.inst-phone #opt-strike-head-list th:nth-child(6), + body.inst-phone #opt-strike-head-list th:nth-child(7), + body.inst-phone .opt-strike-row:not(.opt-strike-row-t) > td:nth-child(3), + body.inst-phone .opt-strike-row:not(.opt-strike-row-t) > td:nth-child(5), + body.inst-phone .opt-strike-row:not(.opt-strike-row-t) > td:nth-child(6), + body.inst-phone .opt-strike-row:not(.opt-strike-row-t) > td:nth-child(7) { + display: none !important; + } + + /* 选择后下单弹窗:盖过底栏,可滚动完整显示 */ + body.inst-phone .opt-order-backdrop { + z-index: 2400; + align-items: flex-end; + justify-content: center; + padding: 0; + padding-bottom: env(safe-area-inset-bottom); + background: rgba(0, 0, 0, 0.72); + } + + body.inst-phone .opt-order-backdrop:not([hidden]) { + display: flex !important; + } + + body.inst-phone .opt-order-dialog { + width: 100%; + max-width: 100%; + max-height: min(88vh, 720px); + margin: 0; + border-radius: 16px 16px 0 0; + padding: 14px 14px calc(14px + env(safe-area-inset-bottom)); + overflow: auto; + -webkit-overflow-scrolling: touch; + } + + body.inst-phone .opt-order-dialog .options-order-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + } + + body.inst-phone .options-estimate-row .opt-est-main { + flex-wrap: wrap; + } + + body.inst-phone .opt-size-mode-bar { + flex-wrap: wrap; + width: 100%; + } + + body.inst-phone .inst-mobile-tabbar { + display: flex; + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 80; + height: calc(var(--inst-m-tabbar-h) + env(safe-area-inset-bottom)); + padding: 0 max(8px, env(safe-area-inset-right)) env(safe-area-inset-bottom) + max(8px, env(safe-area-inset-left)); + align-items: stretch; + justify-content: space-around; + gap: 2px; + background: color-mix(in srgb, #12161f 92%, transparent); + border-top: 1px solid rgba(255, 255, 255, 0.08); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + box-sizing: border-box; + } + + html[data-theme="light"] body.inst-phone .inst-mobile-tabbar { + background: color-mix(in srgb, #f4f7fb 94%, transparent); + border-top-color: rgba(0, 0, 0, 0.1); + } + + body.inst-phone .inst-mobile-tabbar .inst-m-tab.nav-hidden { + display: none !important; + } + + body.inst-phone .inst-m-tab { + flex: 1 1 0; + min-width: 0; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 6px 2px; + padding: 0 4px; + border: none; + border-radius: 10px; + background: transparent; + color: var(--inst-muted); + font: inherit; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-decoration: none; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + + body.inst-phone .inst-m-tab:hover, + body.inst-phone .inst-m-tab:focus-visible { + color: var(--inst-text); + background: rgba(255, 255, 255, 0.04); + outline: none; + } + + body.inst-phone .inst-m-tab.active { + color: var(--inst-nav-idle); + background: rgba(143, 200, 255, 0.12); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--inst-nav-idle) 35%, transparent); + } + + body.inst-phone.inst-mobile-more-open .inst-mobile-more { + display: block; + } + + body.inst-phone .inst-mobile-more { + position: fixed; + inset: 0; + z-index: 90; + } + + body.inst-phone .inst-mobile-more-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.45); + } + + body.inst-phone .inst-mobile-more-sheet { + position: absolute; + left: 0; + right: 0; + bottom: 0; + max-height: min(78vh, 560px); + overflow: auto; + padding: 10px 16px calc(16px + env(safe-area-inset-bottom)); + border-radius: 16px 16px 0 0; + background: #12161f; + border: 1px solid rgba(255, 255, 255, 0.1); + border-bottom: none; + box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.35); + } + + html[data-theme="light"] body.inst-phone .inst-mobile-more-sheet { + background: #f4f7fb; + border-color: rgba(0, 0, 0, 0.1); + } + + body.inst-phone .inst-mobile-more-handle { + width: 36px; + height: 4px; + margin: 2px auto 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.2); + } + + html[data-theme="light"] body.inst-phone .inst-mobile-more-handle { + background: rgba(0, 0, 0, 0.15); + } + + body.inst-phone .inst-mobile-more-title { + margin: 0 0 4px; + font-size: 1rem; + color: var(--inst-text); + } + + body.inst-phone .inst-mobile-more-hint { + margin: 0 0 14px; + font-size: 11px; + color: var(--inst-muted); + } + + body.inst-phone .inst-mobile-more-nav { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + body.inst-phone .inst-mobile-more-nav a { + display: flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 10px 8px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); + color: var(--inst-text); + text-decoration: none; + font-size: 13px; + font-weight: 500; + } + + html[data-theme="light"] body.inst-phone .inst-mobile-more-nav a { + border-color: rgba(0, 0, 0, 0.1); + background: rgba(0, 0, 0, 0.03); + } + + body.inst-phone .inst-mobile-more-nav a.nav-hidden { + display: none !important; + } + + body.inst-phone .inst-mobile-more-nav a.active { + border-color: color-mix(in srgb, var(--inst-nav-idle) 45%, transparent); + background: rgba(143, 200, 255, 0.12); + color: var(--inst-nav-idle); + } + + body.inst-phone .inst-mobile-more-close { + width: 100%; + margin-top: 14px; + min-height: 44px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: transparent; + color: var(--inst-text); + font: inherit; + font-size: 14px; + cursor: pointer; + } + + html[data-theme="light"] body.inst-phone .inst-mobile-more-close { + border-color: rgba(0, 0, 0, 0.12); + } +} + diff --git a/lib/common/static/instance_theme.js b/lib/common/static/instance_theme.js new file mode 100644 index 0000000..c4b70af --- /dev/null +++ b/lib/common/static/instance_theme.js @@ -0,0 +1,570 @@ +/** + * 三所实例主题:默认暗色;单独登录用 instance-theme;中控 iframe/SSO 随 hub-theme 联动. + */ +(function (global) { + const STANDALONE_KEY = "instance-theme"; + const HUB_LINKED_THEME_KEY = "hub-linked-theme"; + const META = { dark: "#0b0d14", light: "#c8d4de" }; + + function normalize(theme) { + return theme === "light" ? "light" : "dark"; + } + + function isHubLinked() { + try { + if (window.self !== window.top) return true; + } catch (_) { + return true; + } + return false; + } + + function themeFromUrl() { + try { + const t = new URLSearchParams(location.search).get("hub_theme"); + if (t === "light" || t === "dark") return t; + } catch (_) {} + return null; + } + + function readLinkedThemeStorage() { + try { + const t = sessionStorage.getItem(HUB_LINKED_THEME_KEY); + if (t === "light" || t === "dark") return t; + } catch (_) {} + return null; + } + + function writeLinkedThemeStorage(theme) { + if (!isHubLinked()) return; + try { + sessionStorage.setItem(HUB_LINKED_THEME_KEY, normalize(theme)); + } catch (_) {} + } + + function getStandalone() { + try { + return normalize(localStorage.getItem(STANDALONE_KEY)); + } catch (_) { + return "dark"; + } + } + + function setStandalone(theme) { + try { + localStorage.setItem(STANDALONE_KEY, normalize(theme)); + } catch (_) {} + } + + let _linkedTheme = null; + let _appliedTheme = null; + + function get() { + if (isHubLinked()) { + return themeFromUrl() || _linkedTheme || readLinkedThemeStorage() || "dark"; + } + return getStandalone(); + } + + /** 模板内联暗色 → 亮色(切换时重写 style 属性) */ + const INLINE_HEX_LIGHT = { + "#cfd3ef": "#1a2838", + "#8892b0": "#4a6078", + "#9aa3c4": "#4a6078", + "#8b95a8": "#4a6078", + "#8b95b8": "#4a6078", + "#6a7598": "#4a6078", + "#7d8799": "#4a6078", + "#6d7689": "#4a6078", + "#dbe4ff": "#142232", + "#f0f2ff": "#142232", + "#e8ecf4": "#142232", + "#c5cce0": "#4a6078", + "#b8c4ff": "#142232", + "#8fc8ff": "#006e9a", + "#6ab8ff": "#006e9a", + "#6eb5ff": "#006e9a", + "#101522": "#ffffff", + "#121726": "#ffffff", + "#141423": "#ffffff", + "#24243b": "#b8c8d8", + "#252a45": "#b8c8d8", + "#252538": "#eef3f8", + "#1a1a29": "#f6f9fc", + "#2e2e45": "#b8c8d8", + "#2b2b43": "#d0dae4", + "#151a2a": "#eef3f8", + "#141a2a": "#ffffff", + "#141923": "#ffffff", + "#141a2e": "#ffffff", + "#0f1424": "#f6f9fc", + "#0f1420": "#f6f9fc", + "#0f1117": "#d8e2ec", + "#1a2034": "#eef3f8", + "#1a2030": "#ffffff", + "#1f3a5a": "#e8eef5", + "#2f2f44": "#dde5ec", + "#2a3f6c": "rgba(0,110,154,0.14)", + "#304164": "rgba(0,95,140,0.22)", + "#2a3150": "#b8c8d8", + "#2a3152": "#b8c8d8", + "#3a5a8a": "rgba(0,95,140,0.35)", + "#2a3348": "#b8c8d8", + "#243050": "rgba(0,75,115,0.16)", + "#2a3558": "#d0dae4", + "#3a4468": "#c8d4e0", + "#3a4a66": "#b8c8d8", + "#3a3f52": "#dde5ec", + "#3d4659": "#b8c8d8", + "#1f2740": "#eef3f8", + "#1f2a44": "rgba(0,110,154,0.1)", + "#1f4a3a": "#e8f5ef", + "#2a4a7a": "#e8eef5", + "#3a3048": "#eef3f8", + "#d4b8ff": "#5b4fc7", + "#e6e8ef": "#1a2838", + }; + + function remapInlineStyle(style, theme) { + if (!style) return style; + if (theme !== "light") return style; + const hadSecondaryBtnBg = /#1f3a5a/i.test(style); + let out = style; + for (const [from, to] of Object.entries(INLINE_HEX_LIGHT)) { + out = out.replace(new RegExp(from.replace("#", "\\#"), "gi"), to); + } + if (hadSecondaryBtnBg && !/color\s*:/i.test(style)) { + out = `${out.replace(/;+\s*$/, "")};color:#006e9a`; + } + return out; + } + + function syncInlineStyles(theme, root) { + const scope = root || document; + scope.querySelectorAll("[style]").forEach((el) => { + const raw = el.getAttribute("style"); + if (!raw) return; + if (!el.dataset.instStyleBase) { + el.dataset.instStyleBase = raw; + } + const base = el.dataset.instStyleBase; + el.setAttribute("style", theme === "light" ? remapInlineStyle(base, "light") : base); + }); + } + + function mergeHubQueryIntoHref(href, theme) { + if (!href || href.startsWith("#") || href.startsWith("javascript:")) return href; + try { + const u = new URL(href, location.origin); + if (u.origin !== location.origin) return href; + if (isHubLinked()) { + u.searchParams.set("embed", "1"); + if (theme === "light" || theme === "dark") { + u.searchParams.set("hub_theme", theme); + } + } + return u.pathname + u.search + u.hash; + } catch (_) { + return href; + } + } + + function patchHubNavLinks(theme) { + if (!isHubLinked()) return; + const t = normalize(theme || get()); + document + .querySelectorAll(".top-nav a[href], .strategy-subnav a[href]") + .forEach((a) => { + const href = a.getAttribute("href"); + if (!href) return; + const next = mergeHubQueryIntoHref(href, t); + if (next !== href) a.setAttribute("href", next); + }); + } + + function apply(theme, opts) { + const options = opts || {}; + const linked = isHubLinked(); + const t = normalize(theme); + const root = document.documentElement; + const unchanged = + !options.force && + _appliedTheme === t && + root.getAttribute("data-theme") === t; + if (unchanged) { + return t; + } + _appliedTheme = t; + if (linked) { + _linkedTheme = t; + writeLinkedThemeStorage(t); + root.setAttribute("data-hub-linked", "1"); + } else { + root.removeAttribute("data-hub-linked"); + } + if (!linked && !options.skipStore) { + setStandalone(t); + } + root.setAttribute("data-theme", t); + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) meta.setAttribute("content", META[t]); + root.style.colorScheme = t; + if (document.body) { + syncInlineStyles(t); + patchHubNavLinks(t); + } else { + document.addEventListener( + "DOMContentLoaded", + function onDom() { + syncInlineStyles(t); + patchHubNavLinks(t); + }, + { once: true } + ); + } + syncToggleUI(); + document.dispatchEvent( + new CustomEvent("instance-theme-change", { detail: { theme: t, hubLinked: linked } }) + ); + return t; + } + + function syncToggleUI(root) { + const scope = root || document; + const linked = isHubLinked(); + const toggle = scope.querySelector(".instance-theme-toggle"); + if (toggle) { + toggle.classList.toggle("is-hub-linked", linked); + toggle.setAttribute("aria-hidden", linked ? "true" : "false"); + } + if (linked) return; + scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => { + const on = btn.getAttribute("data-theme-value") === getStandalone(); + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-pressed", on ? "true" : "false"); + }); + } + + function initToggleUI(root) { + const scope = root || document; + syncToggleUI(scope); + scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => { + if (btn.dataset.themeBound === "1") return; + btn.dataset.themeBound = "1"; + btn.addEventListener("click", () => { + if (isHubLinked()) return; + apply(btn.getAttribute("data-theme-value")); + }); + }); + } + + function initMobileTopNav() { + const mq = window.matchMedia("(max-width: 720px)"); + + function scrollActiveTab(nav) { + const active = nav.querySelector("a.active"); + if (!active) return; + requestAnimationFrame(() => { + try { + active.scrollIntoView({ inline: "center", block: "nearest", behavior: "instant" }); + } catch (_) { + active.scrollIntoView(false); + } + }); + } + + function apply() { + if (!mq.matches) return; + document.querySelectorAll(".top-nav").forEach(scrollActiveTab); + } + + apply(); + mq.addEventListener("change", apply); + window.addEventListener("resize", apply); + window.addEventListener("orientationchange", apply); + } + + function initFromHubMessage(data) { + if (!data || data.type !== "hub-theme-sync") return; + if (!isHubLinked()) return; + apply(data.theme, { skipStore: true }); + } + + /** 交易记录页:核对开关与按钮 disabled 保持同步(含 iframe 软导航后动态挂载的 toggle) */ + function syncReviewEditButtons() { + const toggle = document.getElementById("review-mode-toggle"); + if (!toggle) return; + const on = !!toggle.checked; + document.querySelectorAll(".review-edit-btn").forEach((btn) => { + btn.disabled = !on; + }); + } + + function initReviewEditModeSync() { + if (!global.__instReviewModeBound) { + global.__instReviewModeBound = true; + const onToggle = () => { + if (typeof global.toggleReviewMode === "function") global.toggleReviewMode(); + else syncReviewEditButtons(); + }; + document.addEventListener("change", (ev) => { + if (ev.target && ev.target.id === "review-mode-toggle") onToggle(); + }); + document.addEventListener("input", (ev) => { + if (ev.target && ev.target.id === "review-mode-toggle") onToggle(); + }); + } + const run = () => { + if (typeof global.toggleReviewMode === "function") global.toggleReviewMode(); + else syncReviewEditButtons(); + }; + run(); + requestAnimationFrame(run); + setTimeout(run, 0); + if (!global.__instReviewModePageshowBound) { + global.__instReviewModePageshowBound = true; + window.addEventListener("pageshow", run); + } + } + + function notifyParentFrameNavStart() { + if (!isHubLinked()) return; + try { + window.parent.postMessage({ type: "instance-frame-navigating", theme: get() }, "*"); + } catch (_) {} + } + + function notifyParentFrameReady() { + if (!isHubLinked()) return; + dismissNavOverlay(); + try { + window.parent.postMessage({ type: "instance-frame-ready", theme: get() }, "*"); + } catch (_) {} + } + + function ensureNavOverlay() { + const t = normalize(get()); + const bg = META[t]; + let el = document.getElementById("inst-nav-overlay"); + if (!el) { + el = document.createElement("div"); + el.id = "inst-nav-overlay"; + el.setAttribute("aria-hidden", "true"); + (document.body || document.documentElement).appendChild(el); + } + el.style.cssText = + "position:fixed;inset:0;z-index:2147483646;background:" + + bg + + ";opacity:1;pointer-events:auto;transition:opacity 80ms ease;"; + return el; + } + + function dismissNavOverlay() { + const el = document.getElementById("inst-nav-overlay"); + if (!el) return; + el.style.opacity = "0"; + window.setTimeout(() => { + try { + el.remove(); + } catch (_) {} + }, 90); + } + + function injectNavOverlayIntoHtml(html, theme) { + const t = normalize(theme || get()); + const bg = META[t]; + let out = html || ""; + const guard = + ''; + if (out.includes("")) { + out = out.replace("", guard + ""); + } else { + out = guard + out; + } + out = out.replace(/]*)>/i, (m, attrs) => { + if (/data-theme=/i.test(attrs)) { + return m.replace(/data-theme="[^"]*"/i, 'data-theme="' + t + '"'); + } + return "'; + }); + const overlay = + ''; + if (/]*>/i.test(out)) { + out = out.replace(/]*)>/i, "" + overlay); + } + return out; + } + + /** 中控 iframe:fetch 换页 + 页内遮罩,避免整页卸载与中控侧长时间空白. */ + function initHubEmbedInFrameNav() { + if (!isHubLinked()) return; + if (document.body && document.body.getAttribute("data-embed-shell") === "1") return; + + let navToken = 0; + + function isSoftNavLink(a) { + if (!a || !a.getAttribute) return false; + if (a.hasAttribute("download") || a.target === "_blank") return false; + return !!a.closest(".top-nav, .strategy-subnav"); + } + + function softNavFetch(href) { + return fetch(href, { + credentials: "same-origin", + headers: { "X-Instance-Soft-Nav": "1" }, + }); + } + + async function navigateInFrame(href, opts) { + const token = ++navToken; + notifyParentFrameNavStart(); + ensureNavOverlay(); + try { + const r = await softNavFetch(href); + if (token !== navToken) return; + if (!r.ok) { + location.assign(href); + return; + } + let html = await r.text(); + if (token !== navToken) return; + html = injectNavOverlayIntoHtml(html, get()); + let path = href; + try { + const u = new URL(href, location.href); + path = u.pathname + u.search + u.hash; + } catch (_) {} + if (opts && opts.replace) history.replaceState(null, "", path); + else history.pushState(null, "", path); + document.open(); + document.write(html); + document.close(); + } catch (_) { + if (token === navToken) location.assign(href); + } + } + + document.addEventListener( + "click", + (ev) => { + const a = ev.target.closest("a[href]"); + if (!a || !isSoftNavLink(a) || ev.defaultPrevented) return; + if (ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return; + const rawHref = a.getAttribute("href"); + if (!rawHref || rawHref.startsWith("#") || rawHref.startsWith("javascript:")) return; + let target; + try { + target = new URL(rawHref, location.href); + } catch (_) { + return; + } + if (target.origin !== location.origin) return; + const nextHref = target.pathname + target.search + target.hash; + if (target.pathname === location.pathname && target.search === location.search) return; + ev.preventDefault(); + void navigateInFrame(nextHref); + }, + true + ); + + window.addEventListener("popstate", () => { + void navigateInFrame(location.pathname + location.search + location.hash, { replace: true }); + }); + } + + function purgeLegacySoftNavCache() { + try { + for (let i = localStorage.length - 1; i >= 0; i -= 1) { + const key = localStorage.key(i); + if (!key) continue; + if ( + key.startsWith("inst-pc:") || + key === "inst-page-cache-index" || + key === "inst-page-cache-days" + ) { + localStorage.removeItem(key); + } + } + sessionStorage.removeItem("inst-soft-nav"); + sessionStorage.removeItem("inst-cache-revalidate"); + } catch (_) {} + } + + function boot() { + purgeLegacySoftNavCache(); + if (isHubLinked()) { + apply(get(), { skipStore: true }); + window.addEventListener("message", (ev) => initFromHubMessage(ev.data)); + initHubEmbedInFrameNav(); + try { + window.parent.postMessage({ type: "instance-theme-ready" }, "*"); + } catch (_) {} + } else { + apply(getStandalone()); + } + + function observeDynamicLists() { + ["journal-list", "review-list"].forEach((id) => { + const el = document.getElementById(id); + if (!el || el.dataset.instThemeObserved === "1") return; + el.dataset.instThemeObserved = "1"; + new MutationObserver(() => { + syncInlineStyles(get()); + patchHubNavLinks(get()); + }).observe(el, { + childList: true, + subtree: true, + }); + }); + } + + const onReady = () => { + initToggleUI(); + initMobileTopNav(); + initReviewEditModeSync(); + syncInlineStyles(get()); + patchHubNavLinks(get()); + observeDynamicLists(); + if (isHubLinked()) { + requestAnimationFrame(() => { + requestAnimationFrame(() => notifyParentFrameReady()); + }); + } + }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", onReady); + } else { + onReady(); + } + document.addEventListener("instance-theme-change", (ev) => { + const t = ev.detail && ev.detail.theme; + if (t) { + syncInlineStyles(t); + patchHubNavLinks(t); + } + }); + } + + boot(); + + global.InstanceTheme = { + STANDALONE_KEY, + HUB_LINKED_THEME_KEY, + isHubLinked, + get, + apply, + initToggleUI, + syncToggleUI, + syncInlineStyles, + patchHubNavLinks, + mergeHubQueryIntoHref, + syncReviewEditButtons, + initReviewEditModeSync, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/instance_theme_early.css b/lib/common/static/instance_theme_early.css new file mode 100644 index 0000000..872f586 --- /dev/null +++ b/lib/common/static/instance_theme_early.css @@ -0,0 +1,54 @@ +/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */ +html { + background: #0b0d14; + color-scheme: dark; +} + +html[data-theme="light"] { + background: #c8d4de; + color-scheme: light; +} + +html[data-theme="light"] body { + background: #c8d4de !important; + color: #142232 !important; +} + +.review-edit-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +html[data-theme="light"] .header h1 { + color: #142232 !important; +} + +html[data-theme="light"] .top-nav a, +html[data-theme="light"] .embed-top-nav a, +html[data-theme="light"] .strategy-subnav a { + background: #fff !important; + color: #006e9a !important; + border-color: rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] .top-nav a:hover, +html[data-theme="light"] .embed-top-nav a:hover, +html[data-theme="light"] .strategy-subnav a:hover { + background: rgba(0, 110, 154, 0.1) !important; + color: #004d6e !important; +} + +html[data-theme="light"] .top-nav a.active, +html[data-theme="light"] .embed-top-nav a.active, +html[data-theme="light"] .strategy-subnav a.active { + background: rgba(0, 110, 154, 0.12) !important; + color: #004d6e !important; + border: 1px solid rgba(0, 95, 140, 0.28) !important; + font-weight: 600; +} + +html[data-theme="light"] .card, +html[data-theme="light"] .stat-item { + background: #fff !important; + border-color: #b8c8d8 !important; +} diff --git a/lib/common/static/instance_ui.js b/lib/common/static/instance_ui.js new file mode 100644 index 0000000..158078e --- /dev/null +++ b/lib/common/static/instance_ui.js @@ -0,0 +1,456 @@ +/** + * 三所实例共用 UI:复盘详情,盈亏着色等. + */ +(function (global) { + "use strict"; + + function escapeHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function pnlClassFromValue(val) { + const n = Number(String(val == null ? "" : val).replace(/[^\d.-]/g, "")); + if (!Number.isFinite(n) || n === 0) return ""; + return n > 0 ? "pnl-profit" : "pnl-loss"; + } + + function formatPnlSpan(val, suffix) { + const sfx = suffix == null ? "U" : suffix; + const cls = pnlClassFromValue(val); + const text = escapeHtml(val == null || val === "" ? "-" : val) + sfx; + return cls ? `${text}` : text; + } + + function buildJournalDetailHtml(o, formatExitLine) { + const moodTags = + Array.isArray(o.mood_issues) && o.mood_issues.length + ? o.mood_issues.join(",") + : o.mood_issues || "无"; + const exitText = + typeof formatExitLine === "function" ? formatExitLine(o) : o.exit_reason || "无"; + const lines = [ + `币种/周期:${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}`, + `方向:${escapeHtml((function(){ const d = inferJournalDirection(o); return d ? d.text : "-"; })())}`, + `开仓时间:${escapeHtml(o.open_datetime || "-")}`, + `平仓时间:${escapeHtml(o.close_datetime || "-")}`, + `持仓时长:${escapeHtml(o.hold_duration || "-")}`, + `盈亏:${formatPnlSpan(o.pnl)}`, + `下单类型:${escapeHtml(o.order_type || "无")}`, + `开仓类型:${escapeHtml(o.entry_reason || "无")}`, + `平仓/离场:${escapeHtml(exitText)}`, + `预期RR:${escapeHtml(o.expect_rr || "-")}`, + `实际RR:${escapeHtml(o.real_rr || "-")}`, + `保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`, + `心态标签:${escapeHtml(moodTags)}`, + `备注:${escapeHtml(o.note || "无")}`, + ]; + return lines.join("
    "); + } + + function resolveJournalImages(o) { + if (Array.isArray(o.images) && o.images.length) return o.images; + if (o.image) return [{ tf: "", file: o.image }]; + return []; + } + + function setJournalDetailImages(o) { + const grid = document.getElementById("detailImages"); + const legacyImg = document.getElementById("detailImage"); + const images = resolveJournalImages(o || {}); + + if (grid) { + if (!images.length) { + grid.innerHTML = ""; + grid.style.display = "none"; + } else { + grid.innerHTML = images + .map(function (img) { + const tf = String(img.tf || "").trim(); + const file = String(img.file || "").trim(); + if (!file) return ""; + const label = tf ? escapeHtml(tf) : "截图"; + const src = "/static/images/" + encodeURIComponent(file).replace(/%2F/g, "/"); + return ( + '
    ' + + '' + + label + + "" + + '' +
+              label +
+              '' + + "
    " + ); + }) + .join(""); + grid.style.display = "grid"; + } + if (legacyImg) { + legacyImg.src = ""; + legacyImg.style.display = "none"; + } + return; + } + + if (legacyImg) { + if (images.length === 1) { + legacyImg.src = "/static/images/" + images[0].file; + legacyImg.style.display = "block"; + } else { + legacyImg.src = ""; + legacyImg.style.display = "none"; + } + } + } + + function clearJournalDetailImages() { + const grid = document.getElementById("detailImages"); + if (grid) { + grid.innerHTML = ""; + grid.style.display = "none"; + } + const legacyImg = document.getElementById("detailImage"); + if (legacyImg) { + legacyImg.src = ""; + legacyImg.style.display = "none"; + } + } + + function setJournalDetailBody(o, formatExitLine) { + const body = document.getElementById("detailBody"); + if (!body) return; + body.classList.remove("md-review", "trade-record-detail-wrap"); + body.classList.add("journal-detail-meta"); + body.innerHTML = buildJournalDetailHtml(o, formatExitLine); + } + + function openJournalDetailModal(id, journalCache, formatExitLine) { + const o = journalCache && journalCache[id]; + if (!o) return; + const titleEl = document.getElementById("detailTitle"); + if (titleEl) { + titleEl.innerText = `交易复盘详情|${o.coin || "-"} ${o.tf || "-"}`; + } + setJournalDetailBody(o, formatExitLine); + clearDetailActions(); + setJournalDetailImages(o); + if (typeof setDetailModalFullscreen === "function") { + setDetailModalFullscreen(false); + } + const modal = document.getElementById("detailModal"); + if (modal) modal.style.display = "flex"; + } + + function isMobileCompactRecords() { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia("(max-width: 720px)").matches; + } + + function inferJournalDirection(o) { + const hint = String((o && (o.direction_hint || o.direction)) || "").toLowerCase(); + if (hint === "long" || hint === "buy" || hint === "多") { + return { text: "做多", cls: "direction-long" }; + } + if (hint === "short" || hint === "sell" || hint === "空") { + return { text: "做空", cls: "direction-short" }; + } + const text = String((o && (o.entry_reason || o.note)) || ""); + if (/做空|空头|short/i.test(text)) { + return { text: "做空", cls: "direction-short" }; + } + if (/做多|多头|long/i.test(text)) { + return { text: "做多", cls: "direction-long" }; + } + return null; + } + + function renderJournalListHtml(data) { + if (!data || !data.length) return ""; + const mobile = isMobileCompactRecords(); + if (mobile) { + return data + .map(function (o) { + const dir = inferJournalDirection(o); + const pnlCls = pnlClassFromValue(o.pnl); + const dirHtml = dir + ? `${escapeHtml(dir.text)}` + : `-`; + const id = escapeHtml(o.id); + return `
    + + +
    `; + }) + .join(""); + } + const rows = data + .map(function (o) { + const moodTags = Array.isArray(o.mood_issues) + ? o.mood_issues.join(",") + : o.mood_issues || ""; + const mood = moodTags || "无"; + const id = escapeHtml(o.id); + const pnlCls = pnlClassFromValue(o.pnl); + const pnlTxt = + o.pnl == null || o.pnl === "" ? "-" : String(o.pnl); + const dir = inferJournalDirection(o); + const dirHtml = dir + ? `${escapeHtml(dir.text)}` + : "-"; + return ` + ${escapeHtml(o.coin || "-")} + ${escapeHtml(o.tf || "-")} + ${dirHtml} + ${escapeHtml(o.order_type || "-")} + ${escapeHtml(o.entry_reason || "-")} + ${escapeHtml(pnlTxt)} + ${escapeHtml((o.open_datetime || "-").toString().slice(0, 16))} + ${escapeHtml((o.close_datetime || "-").toString().slice(0, 16))} + ${escapeHtml(o.hold_duration || "-")} + ${escapeHtml(mood)} + + + + + `; + }) + .join(""); + return `
    + + + + + ${rows} +
    品种周期方向下单类型开仓类型盈亏U开仓时间平仓时间持仓心态标签操作
    `; + } + + function parseTradeRecordRow(tr) { + const cells = tr.querySelectorAll("td"); + if (cells.length < 15) return null; + const dirBadge = cells[3].querySelector(".badge"); + return { + rowId: tr.id, + symbol: cells[0].textContent.trim(), + type: cells[1].textContent.trim(), + entryReason: cells[2].textContent.trim(), + directionHtml: (dirBadge ? dirBadge.outerHTML : cells[3].innerHTML).trim(), + directionText: cells[3].textContent.trim(), + trigger: cells[4].textContent.trim(), + stopLoss: cells[5].textContent.trim(), + takeProfit: cells[6].textContent.trim(), + margin: cells[7].textContent.trim(), + leverage: cells[8].textContent.trim(), + holdMinutes: cells[9].textContent.trim(), + openedAt: cells[10].textContent.trim(), + closedAt: cells[11].textContent.trim(), + pnlHtml: cells[12].innerHTML.trim(), + pnlText: cells[12].textContent.trim(), + resultHtml: cells[13].innerHTML.trim(), + resultText: cells[13].textContent.trim(), + actionsHtml: cells[14].innerHTML, + }; + } + + function renderMobileTradeRow(tr) { + const row = parseTradeRecordRow(tr); + if (!row) return ""; + const pnlCls = pnlClassFromValue(row.pnlText); + return ``; + } + + function tradeDetailRow(label, valueHtml) { + return `
    ${escapeHtml(label)}${valueHtml}
    `; + } + + function buildTradeRecordDetailHtml(row) { + return `
    ${ + tradeDetailRow("品种", escapeHtml(row.symbol)) + + tradeDetailRow("下单类型", escapeHtml(row.type)) + + tradeDetailRow("开仓类型", escapeHtml(row.entryReason || "-")) + + tradeDetailRow("方向", row.directionHtml) + + tradeDetailRow("成交价", escapeHtml(row.trigger)) + + tradeDetailRow("止损(开仓)", escapeHtml(row.stopLoss)) + + tradeDetailRow("止盈", escapeHtml(row.takeProfit)) + + tradeDetailRow("基数", escapeHtml(row.margin)) + + tradeDetailRow("杠杆", escapeHtml(row.leverage)) + + tradeDetailRow("持仓分钟", escapeHtml(row.holdMinutes)) + + tradeDetailRow("开仓时间", escapeHtml(row.openedAt)) + + tradeDetailRow("平仓时间", escapeHtml(row.closedAt)) + + tradeDetailRow("盈亏U", row.pnlHtml) + + tradeDetailRow("结果", row.resultHtml) + }
    `; + } + + function clearDetailActions() { + const el = document.getElementById("detailActions"); + if (el) { + el.innerHTML = ""; + el.style.display = "none"; + } + } + + function setDetailActionsHtml(html) { + let el = document.getElementById("detailActions"); + if (!el) { + const panel = document.querySelector("#detailModal .panel"); + if (!panel) return; + el = document.createElement("div"); + el.id = "detailActions"; + el.className = "detail-actions"; + const body = document.getElementById("detailBody"); + if (body && body.parentNode === panel) { + panel.insertBefore(el, body.nextSibling); + } else { + panel.appendChild(el); + } + } + el.innerHTML = html || ""; + el.style.display = html ? "flex" : "none"; + } + + function promptReviewEntryReason(options, currentValue) { + const opts = Array.isArray(options) ? options : []; + const cur = String(currentValue == null ? "" : currentValue).trim(); + return new Promise(function (resolve) { + const backdrop = document.createElement("div"); + backdrop.className = "review-entry-reason-backdrop open"; + const modal = document.createElement("div"); + modal.className = "review-entry-reason-modal"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + + const title = document.createElement("h3"); + title.textContent = "开仓类型"; + modal.appendChild(title); + + const hint = document.createElement("p"); + hint.className = "review-entry-reason-hint"; + hint.textContent = "请选择下拉选项之一;选「不改该项」则保留原值."; + modal.appendChild(hint); + + const select = document.createElement("select"); + select.className = "review-entry-reason-select"; + const emptyOpt = document.createElement("option"); + emptyOpt.value = ""; + emptyOpt.textContent = "(不改该项)"; + select.appendChild(emptyOpt); + + const seen = new Set([""]); + if (cur && opts.indexOf(cur) < 0) { + const curOpt = document.createElement("option"); + curOpt.value = cur; + curOpt.textContent = cur + "(当前)"; + select.appendChild(curOpt); + seen.add(cur); + } + opts.forEach(function (opt) { + const v = String(opt || "").trim(); + if (!v || seen.has(v)) return; + const o = document.createElement("option"); + o.value = v; + o.textContent = v; + select.appendChild(o); + seen.add(v); + }); + if (cur) select.value = cur; + modal.appendChild(select); + + const actions = document.createElement("div"); + actions.className = "review-entry-reason-actions"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "review-entry-reason-cancel"; + cancelBtn.textContent = "取消"; + const okBtn = document.createElement("button"); + okBtn.type = "button"; + okBtn.className = "review-entry-reason-ok"; + okBtn.textContent = "确定"; + actions.appendChild(cancelBtn); + actions.appendChild(okBtn); + modal.appendChild(actions); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + function cleanup(result) { + document.removeEventListener("keydown", onKey); + backdrop.remove(); + resolve(result); + } + function onKey(ev) { + if (ev.key === "Escape") cleanup(null); + } + cancelBtn.addEventListener("click", function () { + cleanup(null); + }); + backdrop.addEventListener("click", function (ev) { + if (ev.target === backdrop) cleanup(null); + }); + okBtn.addEventListener("click", function () { + cleanup(select.value); + }); + document.addEventListener("keydown", onKey); + select.focus(); + }); + } + + function openTradeRecordDetailModal(tr) { + const row = parseTradeRecordRow(tr); + if (!row) return; + const titleEl = document.getElementById("detailTitle"); + if (titleEl) { + titleEl.innerText = `交易记录|${row.symbol}`; + } + const body = document.getElementById("detailBody"); + if (body) { + body.classList.remove("md-review", "journal-detail-meta"); + body.classList.add("trade-record-detail-wrap"); + body.innerHTML = buildTradeRecordDetailHtml(row); + } + setDetailActionsHtml( + `
    ${row.actionsHtml}
    ` + ); + const imgEl = document.getElementById("detailImage"); + if (imgEl) { + imgEl.src = ""; + imgEl.style.display = "none"; + } + if (typeof setDetailModalFullscreen === "function") { + setDetailModalFullscreen(false); + } + const modal = document.getElementById("detailModal"); + if (modal) modal.style.display = "flex"; + } + + global.InstanceUI = { + escapeHtml: escapeHtml, + pnlClassFromValue: pnlClassFromValue, + formatPnlSpan: formatPnlSpan, + buildJournalDetailHtml: buildJournalDetailHtml, + setJournalDetailBody: setJournalDetailBody, + openJournalDetailModal: openJournalDetailModal, + isMobileCompactRecords: isMobileCompactRecords, + inferJournalDirection: inferJournalDirection, + renderJournalListHtml: renderJournalListHtml, + parseTradeRecordRow: parseTradeRecordRow, + renderMobileTradeRow: renderMobileTradeRow, + buildTradeRecordDetailHtml: buildTradeRecordDetailHtml, + openTradeRecordDetailModal: openTradeRecordDetailModal, + clearDetailActions: clearDetailActions, + clearJournalDetailImages: clearJournalDetailImages, + setJournalDetailImages: setJournalDetailImages, + promptReviewEntryReason: promptReviewEntryReason, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/journal_form_save.js b/lib/common/static/journal_form_save.js new file mode 100644 index 0000000..0ccbd73 --- /dev/null +++ b/lib/common/static/journal_form_save.js @@ -0,0 +1,175 @@ +/** + * 复盘表单 AJAX 保存:避免整页刷新卡顿;配合 FormSubmitGuard 即时反馈. + * 使用 document 委托,兼容中控 embed 后插入的 #journal-form. + */ +(function (global) { + "use strict"; + + function $(id) { + return document.getElementById(id); + } + + function toast(msg) { + if (!msg) return; + try { + if (global.InstanceTheme && typeof InstanceTheme.toast === "function") { + InstanceTheme.toast(msg); + return; + } + } catch (_) {} + try { + alert(msg); + } catch (_) {} + } + + function refreshLists() { + if (global.RecordsReviewPage && typeof RecordsReviewPage.loadJournals === "function") { + try { + RecordsReviewPage.loadJournals(); + } catch (_) {} + } else if (typeof global.loadJournals === "function") { + try { + global.loadJournals(); + } catch (_) {} + } + // 交易记录保持可见:soft 刷新,勿「加载中…」占位 + if (global.RecordsReviewPage && typeof RecordsReviewPage.loadTradeRecords === "function") { + try { + RecordsReviewPage.loadTradeRecords({ soft: true }); + } catch (_) {} + } else if (typeof global.loadTradeRecords === "function") { + try { + global.loadTradeRecords({ soft: true }); + } catch (_) {} + } + } + + function resetJournalForm(form) { + if (!form) return; + form.reset(); + ["risk-amount-hint", "entry-price-hint", "stop-loss-hint", "exit-price-hint", "direction-hint"].forEach( + function (id) { + var el = $(id); + if (el) el.value = ""; + } + ); + if (global.JournalUploadSlots && typeof JournalUploadSlots.reset === "function") { + JournalUploadSlots.reset(form); + } + if (typeof global.syncEarlyExitNoteRequired === "function") { + try { + global.syncEarlyExitNoteRequired(); + } catch (_) {} + } + if (global.RecordsReviewPage && typeof RecordsReviewPage.hideJournalCard === "function") { + try { + RecordsReviewPage.hideJournalCard(); + } catch (_) {} + } + } + + function onSuccess(form, data) { + var msg = (data && data.msg) || "交易复盘记录已保存"; + toast(msg); + resetJournalForm(form); + refreshLists(); + if (data && data.chart_pending) { + setTimeout(refreshLists, 4000); + setTimeout(refreshLists, 12000); + } + } + + function parseJsonSafe(res) { + var ct = (res.headers.get("content-type") || "").toLowerCase(); + if (ct.indexOf("application/json") >= 0) { + return res.json().then(function (data) { + return { kind: "json", ok: res.ok, data: data }; + }); + } + return res.text().then(function (text) { + return { kind: "html", ok: res.ok, redirected: !!res.redirected, text: text }; + }); + } + + function submitAjax(form) { + if (global.FormSubmitGuard && FormSubmitGuard.isLocked(form)) return; + if (global.FormSubmitGuard) FormSubmitGuard.lock(form, "保存中…"); + + var fd = new FormData(form); + fd.set("ajax", "1"); + var action = form.getAttribute("action") || "/add_journal"; + + fetch(action, { + method: "POST", + body: fd, + credentials: "same-origin", + redirect: "manual", + headers: { + "X-Requested-With": "XMLHttpRequest", + Accept: "application/json", + }, + }) + .then(function (res) { + // opaque redirect:后端未认 AJAX,仍走了 302 + if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) { + throw new Error("保存接口未返回 JSON,请硬刷新页面后重试"); + } + return parseJsonSafe(res); + }) + .then(function (result) { + if (result.kind !== "json") { + throw new Error("保存接口未返回 JSON,请硬刷新页面后重试"); + } + if (!result.ok || !result.data || result.data.ok === false) { + var err = + (result.data && (result.data.msg || result.data.error)) || "保存失败"; + throw new Error(err); + } + onSuccess(form, result.data); + }) + .catch(function (err) { + toast((err && err.message) || "保存失败,请稍后重试"); + }) + .finally(function () { + if (global.FormSubmitGuard) FormSubmitGuard.unlock(form); + }); + } + + function onSubmit(ev) { + var form = ev.target; + if (!form || form.id !== "journal-form") return; + if (typeof global.validateJournalEntryReason === "function") { + if (!global.validateJournalEntryReason()) { + ev.preventDefault(); + if (typeof ev.stopImmediatePropagation === "function") ev.stopImmediatePropagation(); + return; + } + } + ev.preventDefault(); + if (typeof ev.stopImmediatePropagation === "function") ev.stopImmediatePropagation(); + submitAjax(form); + } + + function bind(form) { + // 保留显式 bind 入口(embed 切 tab 时可再调);真正拦截靠 document 委托 + if (form) form.dataset.journalAjaxBound = "1"; + } + + function init() { + if (document.documentElement.dataset.journalFormSaveDelegated === "1") { + bind($("journal-form")); + return; + } + document.documentElement.dataset.journalFormSaveDelegated = "1"; + document.addEventListener("submit", onSubmit, true); + bind($("journal-form")); + } + + global.JournalFormSave = { init: init, bind: bind }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/journal_upload_slots.js b/lib/common/static/journal_upload_slots.js new file mode 100644 index 0000000..5153985 --- /dev/null +++ b/lib/common/static/journal_upload_slots.js @@ -0,0 +1,153 @@ +/** + * 复盘表单:四周期截图即时上传与状态展示. + */ +(function (global) { + "use strict"; + + function newDraftId() { + if (global.crypto && typeof global.crypto.randomUUID === "function") { + return global.crypto.randomUUID().replace(/-/g, ""); + } + var s = ""; + for (var i = 0; i < 32; i++) { + s += Math.floor(Math.random() * 16).toString(16); + } + return s; + } + + function ensureDraftId(root) { + var scope = root || document; + var el = scope.querySelector("#journal-draft-id"); + if (!el) return ""; + if (!el.value) { + el.value = newDraftId(); + } + return el.value; + } + + function rowParts(input) { + var row = input.closest(".journal-upload-row"); + if (!row) return {}; + return { + row: row, + status: row.querySelector(".journal-upload-status"), + hidden: row.querySelector(".journal-upload-hidden-file"), + }; + } + + function setStatus(statusEl, text, kind) { + if (!statusEl) return; + statusEl.textContent = text || ""; + statusEl.classList.remove( + "journal-upload-status--pending", + "journal-upload-status--ok", + "journal-upload-status--err" + ); + if (kind) { + statusEl.classList.add("journal-upload-status--" + kind); + } + } + + function uploadSlotFile(input, file) { + var parts = rowParts(input); + var draftId = ensureDraftId(input.form || document); + if (!draftId || !file) { + setStatus(parts.status, "上传失败", "err"); + return; + } + + setStatus(parts.status, "上传中…", "pending"); + if (parts.hidden) { + parts.hidden.value = ""; + } + + var fd = new FormData(); + fd.append("journal_draft_id", draftId); + fd.append("tf", input.getAttribute("data-tf") || ""); + fd.append("file", file); + + fetch("/api/journal_upload_slot", { method: "POST", body: fd, credentials: "same-origin" }) + .then(function (res) { + return res.json().then(function (data) { + return { ok: res.ok, data: data }; + }); + }) + .then(function (result) { + if (!result.ok || !result.data || !result.data.ok) { + throw new Error( + (result.data && result.data.error) || "upload failed" + ); + } + var fname = String(result.data.file || "").trim(); + if (parts.hidden) { + parts.hidden.value = fname; + } + input.value = ""; + setStatus(parts.status, "上传成功 " + fname, "ok"); + }) + .catch(function () { + if (parts.hidden) { + parts.hidden.value = ""; + } + setStatus(parts.status, "上传失败", "err"); + }); + } + + function isOptionsReviewSlot(input) { + if (!input) return false; + if (input.classList && input.classList.contains("or-upload-input")) return true; + return !!(input.closest && input.closest("#or-upload-slots, #options-review-root")); + } + + function bindInput(input) { + if (!input || input.dataset.journalSlotBound === "1") return; + // 期权复盘槽位由 options_review.js 处理,勿被合约复盘上传抢走 + if (isOptionsReviewSlot(input)) return; + input.dataset.journalSlotBound = "1"; + input.addEventListener("change", function () { + var file = input.files && input.files[0]; + if (!file) { + var parts = rowParts(input); + if (parts.hidden) { + parts.hidden.value = ""; + } + setStatus(parts.status, "", ""); + return; + } + uploadSlotFile(input, file); + }); + } + + function resetSlots(root) { + var scope = root || document; + var draftEl = scope.querySelector("#journal-draft-id"); + if (draftEl) { + draftEl.value = newDraftId(); + } + scope.querySelectorAll(".journal-upload-hidden-file").forEach(function (el) { + el.value = ""; + }); + scope.querySelectorAll(".journal-upload-slot-input").forEach(function (el) { + el.value = ""; + }); + scope.querySelectorAll(".journal-upload-status").forEach(function (el) { + setStatus(el, "", ""); + }); + } + + function init(root) { + var scope = root || document; + ensureDraftId(scope); + scope.querySelectorAll(".journal-upload-slot-input").forEach(bindInput); + } + + global.JournalUploadSlots = { init: init, reset: resetSlots }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + init(document); + }); + } else { + init(document); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/key_monitor_form.js b/lib/common/static/key_monitor_form.js new file mode 100644 index 0000000..43f143e --- /dev/null +++ b/lib/common/static/key_monitor_form.js @@ -0,0 +1,160 @@ +/** + * 关键位监控添加表单:类型切换显隐,成交量排名校验(三所实例共用). + */ +(function (global) { + const RS_TYPES = new Set([ + "关键支撑阻力", + "关键阻力位", + "关键支撑位", + ]); + + function syncKeyMonitorFormFields() { + const typeEl = document.querySelector('#key-form [name="type"]'); + const dirEl = document.getElementById("key-direction"); + const modeEl = document.getElementById("key-sl-tp-mode"); + const manualTp = document.getElementById("key-manual-tp"); + const beWrap = document.getElementById("key-breakeven-wrap"); + if (!typeEl) return; + const t = (typeEl.value || "").trim(); + const autoTypes = new Set(["箱体突破", "收敛突破"]); + const fibTypes = new Set(["斐波回调0.618", "斐波回调0.786"]); + const fbTypes = new Set(["假突破"]); + const teTypes = new Set(["回调触价开仓", "突破触价开仓", "触价开仓"]); + const showAuto = autoTypes.has(t); + const showFb = fbTypes.has(t); + const showTe = teTypes.has(t); + const showBe = showAuto || fibTypes.has(t) || showFb || showTe; + const showDir = !RS_TYPES.has(t); + const upperEl = document.getElementById("key-upper"); + const lowerEl = document.getElementById("key-lower"); + const fbPriceEl = document.getElementById("key-fb-price"); + const teEntryEl = document.getElementById("key-trigger-entry"); + const teSlEl = document.getElementById("key-trigger-sl"); + const teTpEl = document.getElementById("key-trigger-tp"); + if (dirEl) { + dirEl.style.display = showDir ? "" : "none"; + dirEl.required = showDir; + if (!showDir) dirEl.value = ""; + } + if (modeEl) modeEl.style.display = showAuto ? "" : "none"; + if (manualTp) { + const trend = showAuto && modeEl && modeEl.value === "trend_manual"; + manualTp.style.display = trend ? "" : "none"; + manualTp.required = !!trend; + } + if (beWrap) beWrap.style.display = showBe ? "inline-flex" : "none"; + if (global.TimeCloseUI) global.TimeCloseUI.syncKeyTimeCloseVisibility(showBe); + const hideBounds = showFb || showTe; + if (upperEl) { + upperEl.style.display = hideBounds ? "none" : ""; + upperEl.required = !hideBounds; + if (hideBounds) upperEl.value = ""; + } + if (lowerEl) { + lowerEl.style.display = hideBounds ? "none" : ""; + lowerEl.required = !hideBounds; + if (hideBounds) lowerEl.value = ""; + } + if (fbPriceEl) { + fbPriceEl.style.display = showFb ? "" : "none"; + fbPriceEl.required = showFb; + if (!showFb) fbPriceEl.value = ""; + fbPriceEl.placeholder = + dirEl && dirEl.value === "short" + ? "高点(阻力)" + : dirEl && dirEl.value === "long" + ? "低点(支撑)" + : "做空填高点/做多填低点"; + } + [teEntryEl, teSlEl, teTpEl].forEach((el) => { + if (!el) return; + el.style.display = showTe ? "" : "none"; + el.required = showTe; + if (!showTe) el.value = ""; + }); + } + + function submitKeyForm(keyForm, label) { + if ( + document.body && + document.body.getAttribute("data-embed-shell") === "1" && + global.InstanceEmbed && + typeof global.InstanceEmbed.postFormAndReload === "function" + ) { + global.InstanceEmbed.postFormAndReload(keyForm, label || "提交中…"); + return; + } + if (global.FormSubmitGuard) global.FormSubmitGuard.nativeSubmitOnce(keyForm, label || "提交中…"); + else keyForm.submit(); + } + + function bindKeyMonitorForm() { + const keyForm = document.getElementById("key-form"); + const keyTypeSel = document.querySelector('#key-form [name="type"]'); + const keyModeSel = document.getElementById("key-sl-tp-mode"); + const keyDirSel = document.getElementById("key-direction"); + if (keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields); + if (keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields); + if (keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields); + syncKeyMonitorFormFields(); + if (global.TimeCloseUI) { + global.TimeCloseUI.bindTimeCloseForm( + "key-time-close-cb", + "key-time-close-hours", + "key-time-close-wrap" + ); + } + if (!keyForm || keyForm.dataset.keyFormBound === "1") return; + keyForm.dataset.keyFormBound = "1"; + keyForm.addEventListener("submit", (e) => { + e.preventDefault(); + if (global.FormSubmitGuard && global.FormSubmitGuard.isLocked(keyForm)) return; + const symbolEl = keyForm.querySelector('[name="symbol"]'); + const symbol = (symbolEl ? symbolEl.value : "").trim(); + if (!symbol) { + alert("请先输入交易对"); + return; + } + const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || ""; + if (typeVal === "假突破") { + submitKeyForm(keyForm, "提交中…"); + return; + } + if (global.FormSubmitGuard) global.FormSubmitGuard.lock(keyForm, "校验排名中…"); + fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`) + .then((r) => r.json().then((d) => ({ status: r.status, data: d }))) + .then(({ status, data }) => { + if (status >= 400 || !data.ok) { + alert((data && data.msg) || "日成交量排名读取失败"); + if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm); + return; + } + const rankMax = data.rank_max || 30; + const inTop = data.in_top != null ? data.in_top : data.in_top30; + if (data.rank == null || !inTop) { + alert( + `${data.symbol} 当前日成交量排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截.` + ); + if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm); + return; + } + submitKeyForm(keyForm, "提交中…"); + }) + .catch(() => { + alert("日成交量排名检查失败,请稍后重试"); + if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm); + }); + }); + } + + global.KeyMonitorForm = { + syncFields: syncKeyMonitorFormFields, + init: bindKeyMonitorForm, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bindKeyMonitorForm); + } else { + bindKeyMonitorForm(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/manual_order_rr_preview.js b/lib/common/static/manual_order_rr_preview.js new file mode 100644 index 0000000..eee1856 --- /dev/null +++ b/lib/common/static/manual_order_rr_preview.js @@ -0,0 +1,340 @@ +/** + * 实盘下单:填完币种与止盈止损后,在表单下方显示预估风险 / 预估盈利 / 预估盈亏比. + * 以损定仓:风险 = 当前交易基数 × risk%. + * 全仓杠杆:风险 = 可用保证金×缓冲 × 杠杆 × |SL-入场|/入场(与开仓 calc_risk_amount_from_plan 一致). + */ +(function (global) { + "use strict"; + + let debounceMs = 400; + let minRr = 1.5; + let debounceTimer = null; + let fetchSeq = 0; + + function $(id) { + return document.getElementById(id); + } + + function num(v) { + const n = Number(v); + return Number.isFinite(n) ? n : null; + } + + function formatRr(rr) { + if (rr === null || typeof rr === "undefined") return "—"; + const n = Number(rr); + if (!Number.isFinite(n)) return "—"; + const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2))); + return body + ":1"; + } + + function formatU(v) { + if (v === null || typeof v === "undefined" || !Number.isFinite(Number(v))) return "—"; + return Number(v).toFixed(2) + "U"; + } + + function setMetric(el, label, valueText) { + if (!el) return; + el.innerHTML = label + ":" + valueText + ""; + } + + function sizingMode() { + return (document.body && document.body.getAttribute("data-position-sizing-mode")) || "risk"; + } + + function isFullMarginMode() { + return sizingMode() === "full_margin"; + } + + function fullMarginBuffer() { + const n = Number(document.body && document.body.getAttribute("data-full-margin-buffer")); + return Number.isFinite(n) && n > 0 ? n : 0.9; + } + + function leverageForSymbol(sym) { + const u = (sym || "").trim().toUpperCase(); + const btc = Number(document.body && document.body.getAttribute("data-btc-leverage")); + const alt = Number(document.body && document.body.getAttribute("data-alt-leverage")); + if (u.startsWith("BTC") || u.startsWith("ETH")) { + return Number.isFinite(btc) && btc > 0 ? btc : 10; + } + return Number.isFinite(alt) && alt > 0 ? alt : 5; + } + + function riskPercent() { + const form = $("add-order-form"); + const raw = + (form && form.getAttribute("data-risk-percent")) || + (document.body && document.body.getAttribute("data-risk-percent")) || + ""; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : 1; + } + + function calcRiskFraction(direction, entry, sl) { + const e = num(entry); + const s = num(sl); + if (e === null || s === null || e <= 0 || s <= 0) return null; + let risk = 0; + if (direction === "short") { + risk = s - e; + } else { + risk = e - s; + } + if (risk <= 0) return null; + return risk / e; + } + + function calcRr(direction, entry, sl, tp) { + const e = num(entry); + const s = num(sl); + const t = num(tp); + if (e === null || s === null || t === null) return null; + if (direction === "short") { + if (s <= e || t >= e) return null; + return (e - t) / (s - e); + } + if (s >= e || t <= e) return null; + return (t - e) / (e - s); + } + + function calcRrFromPct(slPct, tpPct) { + const sl = num(slPct); + const tp = num(tpPct); + if (sl === null || tp === null || sl <= 0 || tp <= 0) return null; + return tp / sl; + } + + function calcTpFromFixedRr(direction, entry, sl, rr) { + const e = num(entry); + const s = num(sl); + const r = num(rr); + if (e === null || s === null || r === null || r <= 0) return null; + if (direction === "short") { + if (s <= e) return null; + return e - (s - e) * r; + } + if (s >= e) return null; + return e + (e - s) * r; + } + + function resolveSlPrice(mode, direction, entry) { + if (mode === "pct") { + const slPct = num($("order-sl-pct") && $("order-sl-pct").value); + if (slPct === null || slPct <= 0) return null; + if (direction === "short") return entry * (1 + slPct / 100); + return entry * (1 - slPct / 100); + } + return num($("order-sl") && $("order-sl").value); + } + + function currentMode() { + return ($("sltp-mode") && $("sltp-mode").value) || "fixed_rr"; + } + + function currentDirection() { + return ($("order-direction") && $("order-direction").value) || "long"; + } + + function currentSymbol() { + return (($("order-symbol") && $("order-symbol").value) || "").trim(); + } + + function inputsComplete(m) { + const dir = currentDirection(); + if (!currentSymbol() || !dir) return false; + if (m === "pct") { + const sl = num($("order-sl-pct") && $("order-sl-pct").value); + const tp = num($("order-tp-pct") && $("order-tp-pct").value); + return sl !== null && tp !== null && sl > 0 && tp > 0; + } + if (m === "fixed_rr") { + const sl = num($("order-sl") && $("order-sl").value); + const rr = num($("order-fixed-rr") && $("order-fixed-rr").value); + return sl !== null && rr !== null && sl > 0 && rr > 0; + } + const sl = num($("order-sl") && $("order-sl").value); + const tp = num($("order-tp") && $("order-tp").value); + return sl !== null && tp !== null && sl > 0 && tp > 0; + } + + function paintEmpty() { + setMetric($("order-risk-preview"), "预估风险", "—"); + setMetric($("order-profit-preview"), "预估盈利", "—"); + setMetric($("order-rr-preview"), "预估盈亏比", "—"); + } + + function paintLoading() { + setMetric($("order-risk-preview"), "预估风险", "计算中…"); + setMetric($("order-profit-preview"), "预估盈利", "计算中…"); + setMetric($("order-rr-preview"), "预估盈亏比", "计算中…"); + } + + function paintFail(kind) { + const msg = kind === "fetch_fail" ? "取价失败" : "无效"; + setMetric($("order-risk-preview"), "预估风险", msg); + setMetric($("order-profit-preview"), "预估盈利", msg); + setMetric($("order-rr-preview"), "预估盈亏比", msg); + } + + function paintOk(riskU, profitU, rr) { + setMetric($("order-risk-preview"), "预估风险", formatU(riskU)); + setMetric($("order-profit-preview"), "预估盈利", formatU(profitU)); + const rrEl = $("order-rr-preview"); + const rrText = formatRr(rr); + setMetric(rrEl, "预估盈亏比", rrText); + if (rrEl && rr !== null && Number.isFinite(Number(rr))) { + rrEl.classList.toggle("order-preview-rr-low", Number(rr) < minRr); + rrEl.classList.toggle("order-preview-rr-ok", Number(rr) >= minRr); + } + } + + function plannedRiskFromRiskMode(capital) { + const cap = num(capital); + if (cap === null || cap <= 0) return null; + return Math.round((cap * riskPercent()) / 100 * 100) / 100; + } + + function plannedRiskFromFullMargin(availableUsdt, symbol, direction, entry, sl) { + const avail = num(availableUsdt); + if (avail === null || avail <= 0) return null; + const slPx = num(sl); + const entryPx = num(entry); + if (slPx === null || entryPx === null) return null; + const rf = calcRiskFraction(direction, entryPx, slPx); + if (rf === null) return null; + const margin = Math.round(avail * fullMarginBuffer() * 100) / 100; + const lev = leverageForSymbol(symbol); + return Math.round(margin * lev * rf * 100) / 100; + } + + function resolvePreviewRr(m, dir, entry) { + if (m === "pct") { + return calcRrFromPct( + $("order-sl-pct") && $("order-sl-pct").value, + $("order-tp-pct") && $("order-tp-pct").value + ); + } + const sl = num($("order-sl") && $("order-sl").value); + if (m === "fixed_rr") { + const fixed = num($("order-fixed-rr") && $("order-fixed-rr").value); + if (fixed !== null && fixed > 0) return fixed; + const tp = calcTpFromFixedRr(dir, entry, sl, fixed); + return calcRr(dir, entry, sl, tp); + } + const tp = num($("order-tp") && $("order-tp").value); + return calcRr(dir, entry, sl, tp); + } + + function refreshNow() { + if (!$("order-plan-preview")) return; + const m = currentMode(); + if (!inputsComplete(m)) { + paintEmpty(); + return; + } + + const sym = currentSymbol(); + const dir = currentDirection(); + const seq = ++fetchSeq; + paintLoading(); + + const defaultsP = fetch( + "/api/order_defaults?symbol=" + + encodeURIComponent(sym) + + "&direction=" + + encodeURIComponent(dir) + ).then(function (r) { + return r.json(); + }); + + const capitalP = fetch("/api/account_snapshot").then(function (r) { + return r.json(); + }); + + Promise.all([defaultsP, capitalP]) + .then(function (results) { + if (seq !== fetchSeq) return; + const data = results[0]; + const account = results[1] || {}; + if (!data.ok) { + paintFail("fetch_fail"); + return; + } + const entry = num(data.last_price != null ? data.last_price : data.price); + if (entry === null) { + paintFail("fetch_fail"); + return; + } + const rr = resolvePreviewRr(m, dir, entry); + if (rr === null) { + paintFail("invalid"); + return; + } + let riskU = null; + if (isFullMarginMode()) { + const slPx = resolveSlPrice(m, dir, entry); + const avail = + data.available_trading_usdt != null + ? data.available_trading_usdt + : account.available_trading_usdt; + riskU = plannedRiskFromFullMargin(avail, sym, dir, entry, slPx); + } else { + riskU = plannedRiskFromRiskMode(account.current_capital); + } + if (riskU === null) { + paintFail("fetch_fail"); + return; + } + const profitU = Math.round(riskU * rr * 100) / 100; + paintOk(riskU, profitU, rr); + }) + .catch(function () { + if (seq !== fetchSeq) return; + paintFail("fetch_fail"); + }); + } + + function schedule() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(refreshNow, debounceMs); + } + + function wire(opts) { + opts = opts || {}; + if (opts.minRr != null && Number.isFinite(Number(opts.minRr))) { + minRr = Number(opts.minRr); + } + if (opts.debounceMs != null && Number.isFinite(Number(opts.debounceMs))) { + debounceMs = Number(opts.debounceMs); + } + [ + "order-symbol", + "order-direction", + "sltp-mode", + "order-sl", + "order-tp", + "order-sl-pct", + "order-tp-pct", + "order-fixed-rr", + "order-leverage", + ].forEach(function (id) { + const el = $(id); + if (!el || el._rrPreviewBound) return; + el._rrPreviewBound = true; + el.addEventListener("input", schedule); + el.addEventListener("change", schedule); + }); + schedule(); + } + + global.ManualOrderRrPreview = { + wire: wire, + schedule: schedule, + refresh: refreshNow, + calcRr: calcRr, + calcRrFromPct: calcRrFromPct, + calcRiskFraction: calcRiskFraction, + formatRr: formatRr, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/open_submit_gate.js b/lib/common/static/open_submit_gate.js new file mode 100644 index 0000000..aaf7bdf --- /dev/null +++ b/lib/common/static/open_submit_gate.js @@ -0,0 +1,55 @@ +/** + * 实盘下单监控:开仓按钮灰显 + 旁注(强制清仓/冷静期/日冻结等). + */ +(function (global) { + function apply(data) { + const d = data || {}; + const btn = + document.getElementById("om-submit-btn") || + document.querySelector("#add-order-form button.om-submit"); + const noteEl = document.getElementById("om-open-block-note"); + if (!btn && !noteEl) return; + + const canTrade = d.can_trade !== false; + let note = (d.open_block_note || "").trim(); + const fc = d.force_close || {}; + const rs = d.risk_status || {}; + if (!note && fc.enabled && fc.executing) { + const grace = fc.grace_minutes != null ? fc.grace_minutes : 5; + note = + "强制清仓窗口内(北京时间 " + + (fc.hour_label || "--:--") + + " 起 " + + grace + + " 分钟),暂不可开仓"; + } + if (!note && rs.can_trade === false && rs.reason) { + note = String(rs.reason); + } + if (!note && !canTrade) { + note = "当前不可开仓"; + } + + if (btn) { + btn.disabled = !canTrade; + btn.classList.toggle("is-blocked", !canTrade); + btn.setAttribute("aria-disabled", canTrade ? "false" : "true"); + if (!canTrade) { + btn.title = note || "当前不可开仓"; + } else { + btn.removeAttribute("title"); + } + } + if (noteEl) { + if (!canTrade && note) { + noteEl.hidden = false; + noteEl.textContent = note; + } else { + noteEl.hidden = true; + noteEl.textContent = ""; + } + } + } + + global.OpenSubmitGate = { apply: apply }; +})(window); diff --git a/lib/common/static/options_expiry_countdown.js b/lib/common/static/options_expiry_countdown.js new file mode 100644 index 0000000..db33172 --- /dev/null +++ b/lib/common/static/options_expiry_countdown.js @@ -0,0 +1,58 @@ +/** + * 期权到期倒计时(实例期权页 + 中控监控/看板共用) + */ +(function (global) { + function normalizeExpMs(v) { + if (v == null || v === "") return null; + var n = Number(v); + if (!Number.isFinite(n) || n <= 0) return null; + if (n < 1e12) n *= 1000; + return n; + } + + function formatCountdown(expMs, nowMs) { + var ms = normalizeExpMs(expMs); + if (ms == null) return "—"; + var now = nowMs != null ? nowMs : Date.now(); + var rem = Math.max(0, Math.floor((ms - now) / 1000)); + if (rem <= 0) return "已到期"; + var d = Math.floor(rem / 86400); + var h = Math.floor((rem % 86400) / 3600); + var m = Math.floor((rem % 3600) / 60); + var s = rem % 60; + var pad = function (x) { + return String(x).padStart(2, "0"); + }; + if (d > 0) return d + "天 " + pad(h) + ":" + pad(m) + ":" + pad(s); + return pad(h) + ":" + pad(m) + ":" + pad(s); + } + + function tick(root) { + var scope = root && root.querySelectorAll ? root : document; + var now = Date.now(); + scope.querySelectorAll("[data-opt-exp-ms]").forEach(function (el) { + var exp = el.getAttribute("data-opt-exp-ms"); + var text = formatCountdown(exp, now); + el.textContent = text; + var expMs = normalizeExpMs(exp); + el.classList.toggle("opt-expiry-cd--urgent", expMs != null && expMs - now > 0 && expMs - now < 3600000); + el.classList.toggle("opt-expiry-cd--expired", text === "已到期"); + }); + } + + var timer = null; + function ensureTimer() { + tick(); + if (timer) return; + timer = setInterval(function () { + tick(); + }, 1000); + } + + global.OptionsExpiryCountdown = { + normalizeExpMs: normalizeExpMs, + format: formatCountdown, + tick: tick, + ensureTimer: ensureTimer, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js new file mode 100644 index 0000000..99ed92b --- /dev/null +++ b/lib/common/static/options_panel.js @@ -0,0 +1,2614 @@ +(function () { + "use strict"; + + const root = document.getElementById("options-root"); + if (!root) return; + if (root.getAttribute("data-options-booted") === "1") return; + root.setAttribute("data-options-booted", "1"); + + const panelCache = (window.__optionsPanelCache = window.__optionsPanelCache || {}); + + const state = { + underlying: root.dataset.defaultUnderly || "ETH", + optType: "C", + moneyFilter: "all", + chainView: "list", + strikeExpandAll: false, + /** 环境 OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED;链接口可热更新 */ + askLiqFilter: root.dataset.askLiqFilter !== "0", + budgetBuffer: (function () { + const raw = root.dataset.budgetBuffer; + const n = raw != null && raw !== "" ? Number(raw) : NaN; + return !Number.isNaN(n) && n > 0 ? n : 0.95; + })(), + chain: panelCache.chain || null, + selectedInst: null, + orderQuote: null, + expandedPosInst: null, + posTab: "live", + /** 未点设定前的目标输入草稿,避免持仓轮询重绘清空 */ + targetDraftByInst: {}, + /** 翻倍倍数草稿,避免轮询重绘把正在输入的值刷回 1 */ + profitExitDraftByInst: {}, + }; + + let lastGoodPositions = null; + let lastGoodPositionsAt = 0; + let positionsRefreshSeq = 0; + let chainLoadSeq = 0; + let selectSeq = 0; + let refreshAllTimer = null; + let pendingRefreshTimer = null; + let pendingTtlSeconds = 600; + const POSITIONS_STALE_MS = 45000; + const PENDING_POLL_MS = 8000; + const orderPanelHome = (function () { + const host = document.getElementById("opt-order-panel-host"); + return host ? host.parentElement : null; + })(); + + function fmt(v, d) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(d == null ? 2 : d); + } + + function fmtDisplay(v, fallback) { + if (v !== null && v !== undefined && String(v).trim() !== "") return String(v); + if (fallback !== undefined) return fmtDisplay(fallback); + return "—"; + } + + function fmtOptionPx(v, tickSz) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const tick = Number(tickSz); + if (!tickSz || Number.isNaN(tick) || tick <= 0) { + // 无 tick 时裁掉浮点毛刺,勿 482.4881990066513 + let s = n.toFixed(4).replace(/\.?0+$/, ""); + return s || "0"; + } + let decimals = 0; + if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick))); + else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length; + let s = n.toFixed(decimals); + // 仅裁小数尾零;整数 tick(BTC=5)时绝不能把 1370 裁成 137 + if (decimals > 0) s = s.replace(/\.?0+$/, ""); + return s || "0"; + } + + async function apiJson(url, opts) { + const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); + return r.json(); + } + + function orderPanel() { + return document.getElementById("opt-order-panel"); + } + + function orderPanelHost() { + return document.getElementById("opt-order-panel-host"); + } + + function syncPickButtons(instId) { + document.querySelectorAll(".opt-pick-btn").forEach(function (btn) { + const on = !!instId && btn.getAttribute("data-inst") === instId; + btn.classList.toggle("active", on); + btn.disabled = false; + if (!btn.dataset.origText) btn.dataset.origText = "选择"; + btn.textContent = on ? "已选" : btn.dataset.origText; + }); + } + + function parkOrderPanel() { + const panel = orderPanel(); + const host = orderPanelHost(); + // 弹窗挂到 body;关闭后收回原位,绝不插入期权链表格 + if (panel && host && panel.parentElement !== host) host.appendChild(panel); + if (host) { + host.hidden = true; + host.setAttribute("aria-hidden", "true"); + if (orderPanelHome && host.parentElement !== orderPanelHome) { + orderPanelHome.appendChild(host); + } + } + if (panel) panel.style.display = "none"; + const inline = document.querySelector(".opt-order-inline-row"); + if (inline) inline.remove(); + document.querySelectorAll(".opt-strike-row").forEach(function (r) { + r.classList.remove("opt-row-selected"); + }); + syncPickButtons(null); + } + + function placeOrderPanelAfter(instId) { + const panel = orderPanel(); + const host = orderPanelHost(); + if (!panel || !host || !instId) { + syncPickButtons(instId || null); + return false; + } + const row = + document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-inst="' + CSS.escape(instId) + '"]') || + document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-call-inst="' + CSS.escape(instId) + '"]') || + document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-put-inst="' + CSS.escape(instId) + '"]'); + document.querySelectorAll(".opt-strike-row").forEach(function (r) { + r.classList.toggle("opt-row-selected", !!row && r === row); + }); + syncPickButtons(instId); + const oldInline = document.querySelector(".opt-order-inline-row"); + if (oldInline) oldInline.remove(); + if (panel.parentElement !== host) host.appendChild(panel); + // 挂到 body,避免被卡片 overflow 裁成「行内展开」 + if (host.parentElement !== document.body) document.body.appendChild(host); + host.hidden = false; + host.setAttribute("aria-hidden", "false"); + panel.style.display = ""; + return true; + } + + function closeOrderDialog() { + state.selectedInst = null; + state.orderQuote = null; + parkOrderPanel(); + } + + function fmtPendingAge(sec) { + if (sec == null || Number.isNaN(Number(sec))) return "—"; + let s = Math.max(0, Math.round(Number(sec))); + if (s < 60) return s + "秒"; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return rs ? m + "分" + rs + "秒" : m + "分"; + const h = Math.floor(m / 60); + const rm = m % 60; + return rm ? h + "时" + rm + "分" : h + "时"; + } + + function paintPendingOrders(orders, ttlSec) { + const host = document.getElementById("opt-pending-list"); + const hint = document.getElementById("opt-pending-ttl-hint"); + if (ttlSec != null && !Number.isNaN(Number(ttlSec))) { + pendingTtlSeconds = Number(ttlSec); + } + if (hint) { + const ttl = pendingTtlSeconds; + hint.textContent = ttl > 0 + ? ("平仓限价超 " + fmtPendingAge(ttl) + " 未成交将自动撤销") + : "平仓超时自动撤单已关闭"; + } + if (!host) return; + const rows = Array.isArray(orders) ? orders : []; + if (!rows.length) { + host.innerHTML = '
    暂无未成交委托
    '; + return; + } + host.innerHTML = rows.map(function (o) { + const side = String(o.side || "").toLowerCase(); + const sideCls = side === "buy" ? "is-buy" : side === "sell" ? "is-sell" : ""; + const remain = (o.sz != null && o.fill_sz != null) ? Math.max(0, Number(o.sz) - Number(o.fill_sz)) : o.sz; + const pxTxt = o.px != null ? fmtOptionPx(o.px, null) : "—"; + const kind = o.is_close_order ? "平仓" : "开仓"; + let ttlTxt = ""; + if (o.auto_cancel_enabled) { + if (o.stale) ttlTxt = " · 超时待撤"; + else if (o.expire_in_sec != null) ttlTxt = " · 剩 " + fmtPendingAge(o.expire_in_sec) + " 自动撤"; + } + const ageTxt = o.age_sec != null ? ("已挂 " + fmtPendingAge(o.age_sec)) : ""; + return ( + '
    ' + + '
    ' + + '' + kind + " · " + (o.side_label || side || "—") + "" + + '' + + "
    " + + '
    ' + (o.inst_id || "—") + "
    " + + '
    价 ' + pxTxt + + " · 张数 " + (o.sz != null ? o.sz : "—") + + (o.fill_sz != null && Number(o.fill_sz) > 0 ? " · 已成 " + o.fill_sz : "") + + (remain != null && o.fill_sz != null && Number(o.fill_sz) > 0 ? " · 剩余 " + remain : "") + + (ageTxt ? " · " + ageTxt : "") + + ttlTxt + + "
    " + ); + }).join(""); + host.querySelectorAll(".opt-pending-cancel").forEach(function (btn) { + btn.addEventListener("click", function () { + cancelPendingOrder(btn.getAttribute("data-inst"), btn.getAttribute("data-ord"), btn); + }); + }); + } + + async function refreshPendingOrders() { + const host = document.getElementById("opt-pending-list"); + if (!host) return; + try { + const d = await apiJson("/api/options/orders/pending"); + if (!d.ok) { + host.innerHTML = '
    ' + (d.msg || "获取委托失败") + "
    "; + return; + } + paintPendingOrders(d.orders || [], d.pending_ttl_seconds); + } catch (e) { + host.innerHTML = '
    获取委托失败
    '; + } + } + + function startPendingOrdersPoll() { + stopPendingOrdersPoll(); + pendingRefreshTimer = setInterval(function () { + if (!document.getElementById("options-root")) { + stopPendingOrdersPoll(); + return; + } + refreshPendingOrders(); + }, PENDING_POLL_MS); + } + + function stopPendingOrdersPoll() { + if (pendingRefreshTimer) { + clearInterval(pendingRefreshTimer); + pendingRefreshTimer = null; + } + } + + async function cancelPendingOrder(inst, ordId, btn) { + if (!inst || !ordId) return; + if (!confirm("撤销该委托?\n合约: " + inst + "\n订单: " + ordId)) return; + if (btn) btn.disabled = true; + try { + const d = await apiJson("/api/options/orders/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inst_id: inst, ord_id: ordId }), + }); + if (!d.ok) { + alert(d.msg || "撤销失败"); + return; + } + await refreshPendingOrders(); + refreshAllPositions(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); + } finally { + if (btn) btn.disabled = false; + } + } + + function compoundFullEnabled() { + // 缺省按关闭,避免热更关闭后仍误用全仓复利 + return !!(root && String(root.dataset.compoundFullEnabled || "0") === "1"); + } + + function currentSizeMode() { + const el = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)'); + if (el) return el.value; + const any = document.querySelector('input[name="opt-size-mode"]:checked'); + if (any && any.value === "compound_full" && !compoundFullEnabled()) return "sheets"; + if (any && any.value === "budget_full" && compoundFullEnabled()) return "compound_full"; + return "sheets"; + } + + function applyCompoundModeUi(compoundOn) { + if (root) root.dataset.compoundFullEnabled = compoundOn ? "1" : "0"; + updateSizeInputs(); + } + + function syncCompoundFlagsFromPayload(d) { + if (!d || typeof d !== "object") return; + if (d.compound_full_enabled != null) { + applyCompoundModeUi(!!d.compound_full_enabled); + } else if (d.cfg && d.cfg.compound_full_enabled != null) { + applyCompoundModeUi(!!d.cfg.compound_full_enabled); + } + } + + function updateSizeInputs() { + const mode = currentSizeMode(); + const sheetsEl = document.getElementById("opt-sheets-amount"); + const ethEl = document.getElementById("opt-eth-amount"); + const hint = document.getElementById("opt-budget-full-hint"); + const compoundHint = document.getElementById("opt-compound-full-hint"); + const budgetWrap = document.getElementById("opt-size-mode-budget-wrap"); + const compoundWrap = document.getElementById("opt-size-mode-compound-wrap"); + const capEl = document.getElementById("opt-budget-full-cap"); + const compoundCapLine = document.getElementById("opt-compound-cap-line"); + const compoundOn = compoundFullEnabled(); + if (budgetWrap) { + budgetWrap.hidden = !!compoundOn; + budgetWrap.style.display = compoundOn ? "none" : ""; + const radio = budgetWrap.querySelector('input[name="opt-size-mode"]'); + if (radio) radio.disabled = !!compoundOn; + } + if (compoundWrap) { + compoundWrap.hidden = !compoundOn; + compoundWrap.style.display = compoundOn ? "" : "none"; + const radio = compoundWrap.querySelector('input[name="opt-size-mode"]'); + if (radio) radio.disabled = !compoundOn; + } + if (compoundOn && (mode === "budget_full" || mode === "compound_full")) { + const compoundRadio = document.querySelector('input[name="opt-size-mode"][value="compound_full"]'); + if (compoundRadio) { + compoundRadio.disabled = false; + compoundRadio.checked = true; + } + } else if (!compoundOn) { + const compoundRadio = document.querySelector('input[name="opt-size-mode"][value="compound_full"]'); + if (compoundRadio) { + compoundRadio.checked = false; + compoundRadio.disabled = true; + } + // currentSizeMode 会把残留 compound 映射成 sheets,须实际勾选,避免无选中无法开仓 + const checkedOk = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)'); + if (!checkedOk) { + const sheetsRadio = document.querySelector('input[name="opt-size-mode"][value="sheets"]'); + if (sheetsRadio) { + sheetsRadio.disabled = false; + sheetsRadio.checked = true; + } + } + } + const modeNow = currentSizeMode(); + if (sheetsEl) sheetsEl.style.display = modeNow === "sheets" ? "" : "none"; + if (ethEl) ethEl.style.display = modeNow === "eth_amount" ? "" : "none"; + if (hint) hint.style.display = modeNow === "budget_full" && !compoundOn ? "" : "none"; + if (compoundHint) compoundHint.style.display = modeNow === "compound_full" && compoundOn ? "" : "none"; + if (capEl && root && root.dataset.tradeBudget) { + const n = Number(root.dataset.tradeBudget); + if (Number.isFinite(n) && n > 0) capEl.textContent = n.toFixed(2); + } + if (compoundCapLine && root) { + const on = String(root.dataset.compoundCapEnabled || "") === "1"; + const cap = Number(root.dataset.compoundCapUsdc); + if (on && Number.isFinite(cap) && cap > 0) { + compoundCapLine.textContent = "全仓上限已开启:" + cap.toFixed(2) + "U"; + } else { + compoundCapLine.textContent = "全仓上限关闭(env可开)"; + } + } + document.querySelectorAll(".opt-size-mode-chip").forEach(function (chip) { + const radio = chip.querySelector('input[name="opt-size-mode"]'); + const selected = !!(radio && radio.checked && !radio.disabled); + chip.classList.toggle("is-selected", selected); + chip.classList.toggle("active", selected); + }); + } + + function hardenOrderAutofill() { + function looksLikeUsername(v) { + return /^[a-z][a-z0-9._-]{1,31}$/i.test(String(v || "").trim()); + } + function harden(el) { + if (!el) return; + function wipe() { + if (looksLikeUsername(el.value)) el.value = ""; + } + wipe(); + el.addEventListener("focus", function () { + el.removeAttribute("readonly"); + }); + el.addEventListener("blur", function () { + if (!el.value) el.setAttribute("readonly", "readonly"); + }); + setTimeout(wipe, 200); + setTimeout(wipe, 800); + setTimeout(wipe, 2000); + } + const note = document.getElementById("opt-signal-note"); + harden(note); + [ + "opt-sheets-amount", + "opt-eth-amount", + "opt-target-idx", + ].forEach(function (id) { + harden(document.getElementById(id)); + }); + } + + function quoteUrl(instId) { + updateSizeInputs(); + const mode = currentSizeMode(); + let url = "/api/options/quote?inst_id=" + encodeURIComponent(instId) + "&mode=" + mode; + if (mode === "eth_amount") { + const eth = document.getElementById("opt-eth-amount").value; + if (eth) url += "ð_amount=" + encodeURIComponent(eth); + } else if (mode === "sheets") { + const sheets = document.getElementById("opt-sheets-amount").value; + if (sheets) url += "&sheets=" + encodeURIComponent(sheets); + } + return url; + } + + function strikeTableColspan() { + return 9; + } + + function syncChainViewUI() { + const isT = state.chainView === "t"; + document.querySelectorAll(".opt-view-btn").forEach(function (b) { + b.classList.toggle("active", (b.getAttribute("data-view") || "") === state.chainView); + }); + const typeGroup = document.getElementById("opt-type-btn-group"); + if (typeGroup) typeGroup.hidden = isT; + const expandWrap = document.getElementById("opt-strike-expand-wrap"); + if (expandWrap) expandWrap.hidden = false; + const headList = document.getElementById("opt-strike-head-list"); + const headT = document.getElementById("opt-strike-head-t"); + const headTCols = document.getElementById("opt-strike-head-t-cols"); + if (headList) { + headList.classList.toggle("hidden", isT); + headList.hidden = isT; + } + if (headT) { + headT.classList.toggle("hidden", !isT); + headT.hidden = !isT; + } + if (headTCols) { + headTCols.classList.toggle("hidden", !isT); + headTCols.hidden = !isT; + } + const wrap = document.getElementById("opt-strike-table-wrap"); + if (wrap) wrap.classList.toggle("options-strike-table-wrap--t", isT); + const table = document.getElementById("opt-strike-table"); + if (table) table.classList.toggle("options-strike-table--t", isT); + } + + function matchesMoneyFilter(moneyness) { + const m = (moneyness || "").toLowerCase(); + if (state.moneyFilter === "all") return true; + if (state.moneyFilter === "otm") return m === "otm"; + return m === "itm" || m === "atm"; + } + + function moneyFilterLabel() { + if (state.moneyFilter === "otm") return "虚值"; + if (state.moneyFilter === "itm") return "实值"; + return ""; + } + + function askLiqFilterOn() { + return !!state.askLiqFilter; + } + + function hasAskLiquidity(c) { + if (!c) return false; + if (c.ask_estimated) return false; + const a = Number(c.ask); + const s = Number(c.ask_sz); + return Number.isFinite(a) && a > 0 && Number.isFinite(s) && s >= 1; + } + + function syncAskLiqFilterFromChain(d) { + if (!d || d.ask_liq_filter_enabled == null) return; + state.askLiqFilter = !!d.ask_liq_filter_enabled; + root.dataset.askLiqFilter = state.askLiqFilter ? "1" : "0"; + } + + function countContractsForType(contracts) { + if (state.chainView === "t") { + return countStraddleStrikes(contracts); + } + return (contracts || []).filter(function (c) { + if (c.opt_type !== state.optType) return false; + if (askLiqFilterOn() && !hasAskLiquidity(c)) return false; + return true; + }).length; + } + + function countStraddleStrikes(contracts) { + const rows = buildStraddleRows(contracts).filter(function (row) { + if (!askLiqFilterOn()) return true; + return hasAskLiquidity(row.call) || hasAskLiquidity(row.put); + }); + return rows.length; + } + + function buildStraddleRows(contracts) { + const map = {}; + (contracts || []).forEach(function (c) { + const key = String(c.strike); + if (!map[key]) map[key] = { strike: c.strike, call: null, put: null }; + const o = (c.opt_type || "").toUpperCase(); + if (o === "C") map[key].call = c; + else if (o === "P") map[key].put = c; + }); + return Object.keys(map) + .map(function (k) { return map[k]; }) + .sort(function (a, b) { return Number(a.strike) - Number(b.strike); }); + } + + function findAtmStrike(rows, indexPx) { + if (!rows.length || indexPx == null || Number.isNaN(Number(indexPx))) return null; + let best = rows[0].strike; + let bestDist = Math.abs(Number(rows[0].strike) - Number(indexPx)); + rows.forEach(function (row) { + const d = Math.abs(Number(row.strike) - Number(indexPx)); + if (d < bestDist || (d === bestDist && Number(row.strike) < Number(best))) { + bestDist = d; + best = row.strike; + } + }); + return best; + } + + function matchesStrikeRowFilter(strike, indexPx, atmStrike) { + if (state.moneyFilter === "all") return true; + if (atmStrike != null && Number(strike) === Number(atmStrike)) return true; + if (indexPx == null || Number.isNaN(Number(indexPx))) return true; + if (state.moneyFilter === "itm") return Number(strike) <= Number(indexPx); + if (state.moneyFilter === "otm") return Number(strike) >= Number(indexPx); + return true; + } + + function filterStraddleRows(rows, indexPx) { + const atmStrike = findAtmStrike(rows, indexPx); + return rows.filter(function (row) { + if (!matchesStrikeRowFilter(row.strike, indexPx, atmStrike)) return false; + if (askLiqFilterOn() && !hasAskLiquidity(row.call) && !hasAskLiquidity(row.put)) { + return false; + } + return true; + }); + } + + // 默认窗口:平值 + 实值 3 档 + 虚值 3 档(T 型按行权价 ATM±3) + const DEFAULT_ATM_SIDE = 3; + + function sliceAtmWindow(rows, indexPx) { + if (state.strikeExpandAll || !rows.length) return rows; + const atmStrike = findAtmStrike(rows, indexPx); + const idx = rows.findIndex(function (r) { return Number(r.strike) === Number(atmStrike); }); + const side = DEFAULT_ATM_SIDE; + if (idx < 0) return rows.slice(0, Math.min(rows.length, side * 2 + 1)); + const start = Math.max(0, idx - side); + const end = Math.min(rows.length, idx + side + 1); + return rows.slice(start, end); + } + + function sliceListByMoneyness(list) { + if (state.strikeExpandAll || !list.length) return list; + const sorted = list.slice().sort(function (a, b) { + return Number(a.strike) - Number(b.strike); + }); + const itm = []; + const atm = []; + const otm = []; + sorted.forEach(function (c) { + const m = String(c.moneyness || "").toLowerCase(); + if (m === "atm") atm.push(c); + else if (m === "itm") itm.push(c); + else if (m === "otm") otm.push(c); + }); + if (!atm.length && !itm.length && !otm.length) { + const indexPx = state.chain && state.chain.index_px; + return sliceAtmWindow( + sorted.map(function (c) { return { strike: c.strike, _c: c }; }), + indexPx + ).map(function (r) { return r._c; }); + } + const n = DEFAULT_ATM_SIDE; + const isPut = String(state.optType || "").toUpperCase() === "P"; + // Call: ITM 在下方取靠近 ATM 的末 N;OTM 取前 N。Put 相反。 + const pickedItm = isPut ? itm.slice(0, n) : itm.slice(-n); + const pickedOtm = isPut ? otm.slice(-n) : otm.slice(0, n); + return pickedItm.concat(atm, pickedOtm).sort(function (a, b) { + return Number(a.strike) - Number(b.strike); + }); + } + + function strikeWindowHintHtml(cols) { + return ( + '默认显示平值 + 实值3档 + 虚值3档 · 勾选「展开全部」查看该到期全部行权价' + ); + } + + function straddleAskPerUnit(callAsk, putAsk) { + const c = Number(callAsk); + const p = Number(putAsk); + if (!Number.isFinite(c) || !Number.isFinite(p) || c <= 0 || p <= 0) return null; + return Math.round((c + p) * 10000) / 10000; + } + + function formatStraddleBand(strike, combinedAsk) { + const per = combinedAsk; + if (strike == null || per == null) return "—"; + const k = Number(strike); + const d = Number(per); + if (!Number.isFinite(k) || !Number.isFinite(d)) return "—"; + const lo = Math.round((k - d) * 10) / 10; + const hi = Math.round((k + d) * 10) / 10; + return lo.toFixed(0) + " ~ " + hi.toFixed(0); + } + + function formatStraddlePremiumCell(callAsk, putAsk) { + const per = straddleAskPerUnit(callAsk, putAsk); + if (per == null) return '不可双买'; + return fmtUsdc(per) + " USDC"; + } + + function pickBtnHtml(instId) { + if (!instId) return "—"; + return ''; + } + + function syncMoneyFilterButtons() { + document.querySelectorAll(".opt-money-btn").forEach(function (b) { + b.classList.toggle("active", (b.getAttribute("data-money") || "") === state.moneyFilter); + }); + } + + function resetMoneyFilterToAll() { + state.moneyFilter = "all"; + syncMoneyFilterButtons(); + } + + function updateUnderlyingLabel() { + const el = document.getElementById("opt-order-eth-label"); + if (el) el.textContent = state.underlying + " 数量"; + } + + function filterChainContracts(contracts) { + return (contracts || []).filter(function (c) { + if (c.opt_type !== state.optType) return false; + if (!matchesMoneyFilter(c.moneyness)) return false; + if (askLiqFilterOn() && !hasAskLiquidity(c)) return false; + return true; + }); + } + + function moneynessBadge(c) { + const m = (c && c.moneyness) || ""; + const label = (c && c.moneyness_label) || "—"; + return '' + label + ""; + } + + function optTypeLabel(t) { + return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call"; + } + + function sourceText(p) { + const lab = (p && p.source_label) || "纯期权"; + const src = (p && p.source) || "option"; + let pid = p && p.source_plan_id; + if (pid == null && p && p.hedge_plan_target && p.hedge_plan_target.plan_id != null) { + pid = p.hedge_plan_target.plan_id; + } + if (src !== "option" && pid != null && pid !== "") return lab + " #" + pid; + return lab; + } + + function sourceBadgeHtml(p) { + const src = (p && p.source) || "option"; + const cls = + src === "options_options" + ? "opt-source-badge opt-source-badge--oo" + : src === "perp_options" + ? "opt-source-badge opt-source-badge--po" + : "opt-source-badge opt-source-badge--plain"; + return '' + sourceText(p) + ""; + } + + function expLabel(ms) { + try { + const dt = new Date(Number(ms)); + const now = Date.now(); + const dte = Math.max(0, Math.ceil((Number(ms) - now) / 86400000)); + const base = dt.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); + return base + " · " + dte + "D"; + } catch (e) { + return String(ms); + } + } + + function applyBudgetBuffer(raw) { + if (raw == null || raw === "") return; + const buf = Number(raw); + if (Number.isNaN(buf) || buf <= 0) return; + state.budgetBuffer = buf; + const el = document.getElementById("opt-budget-buf"); + if (el) el.textContent = fmt(buf, 2); + } + + function renderIndexLine() { + const idx = state.chain && state.chain.index_px; + const dte = state.chain && state.chain.chain_max_dte_days; + if (dte != null) { + const el = document.getElementById("opt-chain-dte"); + if (el) el.textContent = String(Math.round(dte)); + } + if (state.chain && state.chain.budget_buffer != null) { + applyBudgetBuffer(state.chain.budget_buffer); + } + const line = document.getElementById("opt-index-line"); + if (line) { + const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)"; + line.textContent = + "指数 " + state.underlying + " ≈ " + fmt(idx, 2) + + " · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外"; + } + } + + function pickNearestExpiry(exps) { + if (!exps || !exps.length) return ""; + const now = Date.now(); + let best = null; + let bestDelta = Infinity; + exps.forEach(function (e) { + const t = Number(e.exp_time); + if (!Number.isFinite(t)) return; + const delta = t - now; + if (delta < -60000) return; + if (delta < bestDelta) { + bestDelta = delta; + best = e; + } + }); + if (best) return String(best.exp_time); + return String(exps[0].exp_time); + } + + function renderExpiryOptions(preserveSelection) { + const sel = document.getElementById("opt-exp-select"); + if (!sel) return; + const prev = preserveSelection !== false ? sel.value : ""; + const exps = (state.chain && state.chain.expiries) || []; + sel.innerHTML = ''; + exps.forEach(function (e) { + const o = document.createElement("option"); + o.value = String(e.exp_time); + o.textContent = expLabel(e.exp_time) + " (" + countContractsForType(e.contracts) + ")"; + sel.appendChild(o); + }); + if (prev && exps.some(function (e) { return String(e.exp_time) === String(prev); })) { + sel.value = prev; + } else if (exps.length) { + sel.value = pickNearestExpiry(exps); + } + } + + function setExpirySelectStatus(text) { + const sel = document.getElementById("opt-exp-select"); + if (!sel) return; + sel.innerHTML = ""; + const o = document.createElement("option"); + o.value = ""; + o.textContent = text || "选择到期日"; + sel.appendChild(o); + } + + function chainHasExpiries(chain) { + return !!(chain && Array.isArray(chain.expiries) && chain.expiries.length > 0); + } + + function renderExpiries() { + renderExpiryOptions(true); + renderIndexLine(); + } + + function fmtPxSz(px, sz, estimated) { + if (px === null || px === undefined || Number.isNaN(Number(px))) return "—"; + let price = Number(px).toFixed(4).replace(/\.?0+$/, ""); + if (estimated) price += "~"; + if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price; + const s = Number(sz); + const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); + return price + "/" + size; + } + + /** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */ + function fmtCloseLevels(preview, tickSz) { + if (preview && preview.bid_invalid) { + return "暂无有效买盘"; + } + const levels = ((preview && preview.levels) || []).slice(0, 5); + if (!levels.length) return "—"; + return levels.map(function (x, idx) { + const levelNo = x.level != null ? x.level : idx + 1; + const liq = x.available_sheets != null ? x.available_sheets : x.sz; + const pxTxt = fmtOptionPx(x.px, tickSz); + if (liq === null || liq === undefined || liq === "" || Number.isNaN(Number(liq))) { + return "买" + levelNo + " " + pxTxt; + } + const s = Number(liq); + const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); + return "买" + levelNo + " " + pxTxt + "/" + size; + }).join(" · "); + } + + function closeGateHint(preview) { + if (!preview) return ""; + if (preview.bid_invalid || preview.manual_close_blocked) { + return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓"; + } + const gate = preview.close_gate || {}; + // 2× 只是目标平仓门控,本身不会自动平;手动买一平不拦截 + if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { + return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); + } + return ""; + } + + function netPnlFromPos(p) { + const preview = (p && p.close_preview) || {}; + if (preview.bid_invalid) { + const upl = p && p.upl != null ? Number(p.upl) : NaN; + return Number.isFinite(upl) ? upl : null; + } + if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) { + return Number(preview.estimated_pnl); + } + const covered = Number(preview.covered_sheets); + const recv = Number(preview.total_received); + const prem = Number(p && p.premium_paid); + if ( + preview.total_received != null && + Number.isFinite(covered) && + covered > 0 && + !Number.isNaN(recv) && + !Number.isNaN(prem) + ) { + return recv - prem; + } + const upl = p && p.upl != null ? Number(p.upl) : NaN; + return Number.isFinite(upl) ? upl : null; + } + + function netRoiFromPos(p, net) { + const preview = (p && p.close_preview) || {}; + if (preview.estimated_pnl_ratio_pct != null && !Number.isNaN(Number(preview.estimated_pnl_ratio_pct))) { + return Number(preview.estimated_pnl_ratio_pct); + } + const prem = Number(p && p.premium_paid); + if (net == null || Number.isNaN(prem) || prem <= 0) return null; + return (net / prem) * 100; + } + + function fmtUsdc(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(2); + } + + function fmtClosePreview(preview, premiumPaid) { + if (!preview || preview.total_received == null) return "—"; + const recvTxt = fmtUsdc(preview.total_received); + let cls = ""; + const prem = Number(premiumPaid); + const recv = Number(preview.total_received); + if (!Number.isNaN(prem) && !Number.isNaN(recv)) { + if (recv > prem) cls = " pos-pnl-profit"; + else if (recv < prem) cls = " pos-pnl-loss"; + } + return '' + recvTxt + " USDC"; + } + + function fmtClosePreviewText(preview) { + if (!preview || preview.total_received == null) return "—"; + let text = fmt(preview.total_received, 4) + " USDC"; + if (preview.covered_sheets != null) { + text += " · 覆盖 " + preview.covered_sheets + "张"; + } + if (preview.uncovered_sheets > 0) { + text += " · 缺 " + preview.uncovered_sheets + "张"; + } + return text; + } + + function fmtPreviewLevels(preview) { + const levels = (preview && preview.levels) || []; + if (!levels.length) return "暂无可用买盘深度"; + return levels.map(function (x) { + return "买" + x.level + " " + fmt(x.px, 4) + " × " + x.sheets + "张 ≈ " + fmt(x.received, 4) + " USDC"; + }).join("\n"); + } + + function pnlCls(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return ""; + const n = Number(v); + if (n > 0) return "pos-pnl-profit"; + if (n < 0) return "pos-pnl-loss"; + return ""; + } + + function expiryIntrinsicPerUnit(optType, strike, targetIdx) { + const tgt = Number(targetIdx); + const k = Number(strike); + if (!Number.isFinite(tgt) || !Number.isFinite(k)) return null; + const o = (optType || "").toUpperCase(); + if (o === "C") return Math.max(0, tgt - k); + if (o === "P") return Math.max(0, k - tgt); + return null; + } + + function estimateExpiryValue(optType, strike, targetIdx, ethAmount) { + const amt = Number(ethAmount); + const intrinsic = expiryIntrinsicPerUnit(optType, strike, targetIdx); + if (intrinsic == null || !Number.isFinite(amt) || amt <= 0) return null; + return Math.round(intrinsic * amt * 100) / 100; + } + + function estimateExpiryProfit(optType, strike, targetIdx, ethAmount, totalPremium) { + const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); + const prem = Number(totalPremium); + if (value == null || !Number.isFinite(prem)) return null; + return Math.round((value - prem) * 100) / 100; + } + + /** 盈亏比 = 盈利金额 / 本合约权利金(目标位仅作到期实值参考). */ + function estimateProfitRr(profit, totalPremium) { + const pnl = Number(profit); + const prem = Number(totalPremium); + if (!Number.isFinite(pnl) || !Number.isFinite(prem) || prem <= 0) return null; + return Math.round((pnl / prem) * 100) / 100; + } + + function fmtProfitRr(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(2); + } + + function calcContractLeverage(indexPx, ethAmount, totalPremium) { + if (indexPx == null || ethAmount == null || totalPremium == null) return null; + const idx = Number(indexPx); + const amt = Number(ethAmount); + const prem = Number(totalPremium); + if (!Number.isFinite(idx) || !Number.isFinite(amt) || !Number.isFinite(prem) || amt <= 0 || prem <= 0) { + return null; + } + return Math.round((idx * amt) / prem * 10) / 10; + } + + function fmtLeverage(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return "约 " + Number(v).toFixed(1) + "×"; + } + + /** 链上展示:指数 ÷ 卖一(每1币). */ + function calcAskLeverage(indexPx, askPx) { + if (indexPx == null || askPx == null) return null; + const idx = Number(indexPx); + const ask = Number(askPx); + if (!Number.isFinite(idx) || !Number.isFinite(ask) || ask <= 0) return null; + return Math.round((idx / ask) * 10) / 10; + } + + function fmtChainLeverage(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(1) + "×"; + } + + function fmtUsdcSigned(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const sign = n > 0 ? "+" : ""; + return sign + fmtUsdc(n) + " USDC"; + } + + function updateOrderEstimates() { + const levEl = document.getElementById("opt-order-leverage"); + const valueEl = document.getElementById("opt-est-value"); + const profitEl = document.getElementById("opt-est-profit"); + const rrEl = document.getElementById("opt-est-rr") || document.getElementById("opt-est-leverage"); + const targetEl = document.getElementById("opt-target-idx"); + const q = state.orderQuote; + if (!q || !q.ok || !q.can_open) { + if (levEl) levEl.textContent = "—"; + if (valueEl) valueEl.textContent = "—"; + if (profitEl) { + profitEl.textContent = "—"; + profitEl.className = "v"; + } + if (rrEl) { + rrEl.textContent = "—"; + rrEl.className = "v"; + } + return; + } + const sz = q.sizing || {}; + const ethAmount = sz.eth_amount; + const premium = sz.total_premium; + const lev = calcContractLeverage(q.index_px, ethAmount, premium); + if (levEl) levEl.textContent = fmtLeverage(lev); + + if (valueEl && profitEl && targetEl) { + const targetRaw = targetEl.value; + if (targetRaw === "" || targetRaw == null) { + valueEl.textContent = "—"; + profitEl.textContent = "—"; + profitEl.className = "v"; + if (rrEl) { + rrEl.textContent = "—"; + rrEl.className = "v"; + } + } else { + const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount); + const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium); + const rr = estimateProfitRr(profit, premium); + if (value == null || Number.isNaN(value)) { + valueEl.textContent = "—"; + } else { + valueEl.textContent = fmtUsdc(value) + " USDC"; + } + if (profit == null || Number.isNaN(profit)) { + profitEl.textContent = "—"; + profitEl.className = "v"; + } else { + profitEl.textContent = fmtUsdcSigned(profit); + profitEl.className = "v " + pnlCls(profit); + } + if (rrEl) { + if (rr == null || Number.isNaN(rr)) { + rrEl.textContent = "—"; + rrEl.className = "v"; + } else { + rrEl.textContent = fmtProfitRr(rr); + rrEl.className = "v " + pnlCls(rr); + } + } + } + } + } + + function updateEstimatedProfit() { + updateOrderEstimates(); + } + + function fmtDist(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const sign = n > 0 ? "+" : ""; + return sign + n.toFixed(1); + } + + function distBeClass(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return ""; + const n = Number(v); + if (n > 0) return "opt-be-dist-up"; + if (n < 0) return "opt-be-dist-down"; + return ""; + } + + function bindStrikePickButtons(tbody) { + tbody.querySelectorAll(".opt-pick-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + selectContract(btn.getAttribute("data-inst"), btn); + }); + }); + } + + function finishStrikeRender(tbody, prevSelected, matchedSelected) { + bindStrikePickButtons(tbody); + if (matchedSelected && prevSelected) { + selectContract(prevSelected, null, true); + } else if (!matchedSelected) { + state.selectedInst = null; + } + } + + function renderStrikes() { + syncChainViewUI(); + if (state.chainView === "t") renderStrikesT(); + else renderStrikesList(); + } + + function renderStrikesList() { + const tbody = document.getElementById("opt-strike-tbody"); + const expMs = document.getElementById("opt-exp-select").value; + const prevSelected = state.selectedInst; + const cols = strikeTableColspan(); + parkOrderPanel(); + tbody.innerHTML = ""; + if (!expMs || !state.chain) { + tbody.innerHTML = '请选择到期日'; + state.selectedInst = null; + return; + } + const exp = (state.chain.expiries || []).find(function (e) { + return String(e.exp_time) === String(expMs); + }); + if (!exp) { + state.selectedInst = null; + return; + } + let list = filterChainContracts(exp.contracts); + list = sliceListByMoneyness(list); + if (!list.length) { + const label = moneyFilterLabel(); + const suffix = label ? label : optTypeLabel(state.optType); + const liqTip = askLiqFilterOn() ? "(卖一深度≥1 时才显示,可在环境配置关闭筛选)" : ""; + tbody.innerHTML = '该到期日暂无' + suffix + "合约" + liqTip + ""; + state.selectedInst = null; + return; + } + let matchedSelected = false; + const indexPx = state.chain && state.chain.index_px; + const atmStrike = findAtmStrike( + list.map(function (c) { return { strike: c.strike }; }), + indexPx + ); + list.forEach(function (c) { + const tr = document.createElement("tr"); + tr.className = "opt-strike-row"; + tr.setAttribute("data-inst", c.inst_id); + if (c.moneyness) tr.classList.add("opt-row-" + c.moneyness); + if (atmStrike != null && Number(c.strike) === Number(atmStrike)) { + tr.classList.add("opt-strike-row-atm"); + } + const chainLev = calcAskLeverage(indexPx, c.ask); + tr.innerHTML = + "" + c.strike + "" + + "" + moneynessBadge(c) + "" + + "" + c.inst_id + "" + + "" + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated) + "" + + '' + fmtChainLeverage(chainLev) + "" + + "" + fmtPxSz(c.bid, c.bid_sz) + "" + + "" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "" + + '' + fmtDist(c.dist_expiry_be) + "" + + '' + + pickBtnHtml(c.inst_id) + + ""; + tbody.appendChild(tr); + if (c.inst_id === prevSelected) matchedSelected = true; + }); + if (!state.strikeExpandAll && list.length >= 1) { + const hint = document.createElement("tr"); + hint.className = "opt-strike-hint-row"; + hint.innerHTML = strikeWindowHintHtml(cols); + tbody.appendChild(hint); + } + finishStrikeRender(tbody, prevSelected, matchedSelected); + } + + function renderStrikesT() { + const tbody = document.getElementById("opt-strike-tbody"); + const expMs = document.getElementById("opt-exp-select").value; + const prevSelected = state.selectedInst; + const cols = strikeTableColspan(); + const indexPx = state.chain && state.chain.index_px; + parkOrderPanel(); + tbody.innerHTML = ""; + if (!expMs || !state.chain) { + tbody.innerHTML = '请选择到期日'; + state.selectedInst = null; + return; + } + const exp = (state.chain.expiries || []).find(function (e) { + return String(e.exp_time) === String(expMs); + }); + if (!exp) { + state.selectedInst = null; + return; + } + let rows = filterStraddleRows(buildStraddleRows(exp.contracts), indexPx); + rows = sliceAtmWindow(rows, indexPx); + if (!rows.length) { + const label = moneyFilterLabel(); + const suffix = label ? label + "区" : "匹配"; + tbody.innerHTML = '该到期日暂无' + suffix + "行权价"; + state.selectedInst = null; + return; + } + const atmStrike = findAtmStrike(rows, indexPx); + let matchedSelected = false; + rows.forEach(function (row) { + const callRaw = row.call; + const putRaw = row.put; + const call = callRaw && (!askLiqFilterOn() || hasAskLiquidity(callRaw)) ? callRaw : null; + const put = putRaw && (!askLiqFilterOn() || hasAskLiquidity(putRaw)) ? putRaw : null; + const combined = straddleAskPerUnit(call && call.ask, put && put.ask); + const tr = document.createElement("tr"); + tr.className = "opt-strike-row opt-strike-row-t"; + tr.setAttribute("data-strike", String(row.strike)); + if (Number(row.strike) === Number(atmStrike)) tr.classList.add("opt-strike-row-atm"); + if (call && call.inst_id) tr.setAttribute("data-call-inst", call.inst_id); + if (put && put.inst_id) tr.setAttribute("data-put-inst", put.inst_id); + tr.innerHTML = + '' + (call ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated) : "—") + "" + + '' + (call ? moneynessBadge(call) : "—") + "" + + '' + pickBtnHtml(call && call.inst_id) + "" + + '' + row.strike + "" + + '' + formatStraddlePremiumCell(call && call.ask, put && put.ask) + "" + + '' + formatStraddleBand(row.strike, combined) + "" + + '' + (put ? moneynessBadge(put) : "—") + "" + + '' + (put ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated) : "—") + "" + + '' + pickBtnHtml(put && put.inst_id) + ""; + tbody.appendChild(tr); + if (prevSelected && ((call && call.inst_id === prevSelected) || (put && put.inst_id === prevSelected))) { + matchedSelected = true; + } + }); + if (!state.strikeExpandAll && rows.length >= 1) { + const hint = document.createElement("tr"); + hint.className = "opt-strike-hint-row"; + hint.innerHTML = strikeWindowHintHtml(cols); + tbody.appendChild(hint); + } + finishStrikeRender(tbody, prevSelected, matchedSelected); + } + + function fillOrderPanel(d) { + syncCompoundFlagsFromPayload(d); + state.orderQuote = d && d.ok ? d : null; + const sz = d.sizing || {}; + const canOpen = !!(d && d.ok && d.can_open); + document.getElementById("opt-order-inst").textContent = d.inst_id || state.selectedInst || ""; + const askEl = document.getElementById("opt-order-ask"); + if (askEl) { + askEl.textContent = canOpen ? fmtPxSz(d.ask, d.ask_sz) : "—"; + } + const bidEl = document.getElementById("opt-order-bid"); + if (bidEl) bidEl.textContent = fmtPxSz(d.bid, d.bid_sz); + const refEl = document.getElementById("opt-order-ref-ask"); + if (refEl) { + if (canOpen) { + refEl.textContent = "—"; + } else if (d.ref_ask != null && !Number.isNaN(Number(d.ref_ask))) { + refEl.textContent = fmtPxSz(d.ref_ask, null, true) + " (不可开仓)"; + } else if (d.mark != null && !Number.isNaN(Number(d.mark))) { + refEl.textContent = fmtPxSz(d.mark, null, true) + " (不可开仓)"; + } else { + refEl.textContent = "—"; + } + } + document.getElementById("opt-order-sheets").textContent = canOpen && sz.sheets != null ? sz.sheets : "—"; + document.getElementById("opt-order-eth").textContent = canOpen && sz.eth_amount != null ? sz.eth_amount : "—"; + updateUnderlyingLabel(); + document.getElementById("opt-order-premium").textContent = + canOpen && sz.total_premium != null ? fmtUsdc(sz.total_premium) + " USDC" : "—"; + const beEl = document.getElementById("opt-order-expiry-be"); + const distEl = document.getElementById("opt-order-dist-be"); + if (beEl) { + beEl.textContent = d.expiry_be_px != null ? fmt(d.expiry_be_px, 0) : "—"; + } + if (distEl) { + distEl.textContent = fmtDist(d.dist_expiry_be); + distEl.className = "v " + distBeClass(d.dist_expiry_be); + } + const openBtn = document.getElementById("opt-open-btn"); + if (openBtn) { + openBtn.disabled = !canOpen || sz.ok === false; + openBtn.textContent = canOpen ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓"; + } + const msgEl = document.getElementById("opt-order-msg"); + if (!d.ok) { + msgEl.textContent = d.msg || "报价失败"; + msgEl.classList.add("opt-error"); + } else if (!canOpen) { + const ref = d.ref_ask != null ? d.ref_ask : d.mark; + let tip = d.msg || d.open_block_msg || "当前无卖一深度,无法按卖一限价买入"; + if (ref != null && !Number.isNaN(Number(ref))) { + tip += "。参考标记价 ~" + Number(ref).toFixed(4).replace(/\.?0+$/, "") + "(仅供参考,不可用于开仓)"; + } else { + tip += "。无可用参考标记价"; + } + msgEl.textContent = tip; + msgEl.classList.add("opt-error"); + } else if (sz.ok === false) { + msgEl.textContent = sz.msg || ""; + msgEl.classList.add("opt-error"); + } else if (sz.ask_depth_capped) { + msgEl.textContent = sz.msg || "已按卖一深度限制张数"; + msgEl.classList.remove("opt-error"); + } else { + msgEl.textContent = ""; + msgEl.classList.remove("opt-error"); + } + updateEstimatedProfit(); + } + + async function selectContract(instId, pickBtn, silent) { + const seq = ++selectSeq; + state.selectedInst = instId; + placeOrderPanelAfter(instId); + if (pickBtn) { + pickBtn.disabled = true; + if (!pickBtn.dataset.origText) pickBtn.dataset.origText = "选择"; + pickBtn.textContent = "加载…"; + } + try { + const d = await apiJson(quoteUrl(instId)); + if (seq !== selectSeq) return d; + fillOrderPanel(d); + return d; + } finally { + if (seq === selectSeq) { + syncPickButtons(instId); + if (!silent) placeOrderPanelAfter(instId); + } + } + } + + async function loadChain(opts) { + const soft = !!(opts && opts.soft); + const uly = state.underlying; + const seq = ++chainLoadSeq; + const btn = document.getElementById("opt-load-chain"); + if (btn && !soft) btn.disabled = true; + if (!soft) { + setExpirySelectStatus("加载到期日中…"); + const tbody = document.getElementById("opt-strike-tbody"); + if (tbody) { + tbody.innerHTML = + '加载期权链…'; + } + } + try { + let d = null; + let lastMsg = ""; + for (let attempt = 0; attempt < 2; attempt++) { + if (seq !== chainLoadSeq) return; + d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly)); + if (seq !== chainLoadSeq) return; + if (d && d.ok && chainHasExpiries(d)) break; + lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日"; + const rateLimited = + /50011|Too Many Requests|RateLimit/i.test(String(lastMsg || "")); + d = null; + if (attempt === 0 && !rateLimited) { + if (!soft) setExpirySelectStatus("重试加载到期日…"); + await new Promise(function (resolve) { setTimeout(resolve, 400); }); + } else { + break; + } + } + if (seq !== chainLoadSeq) return; + if (!d || !d.ok || !chainHasExpiries(d)) { + if (chainHasExpiries(state.chain) && state.chain.underlying === uly) { + if (!soft) { + renderExpiries(); + renderStrikes(); + } + return; + } + if (soft) return; + setExpirySelectStatus("选择到期日"); + const tbody = document.getElementById("opt-strike-tbody"); + if (tbody) { + tbody.innerHTML = + '' + + (lastMsg || "暂无到期日,请点「刷新链」") + + ""; + } + alert(lastMsg || "加载到期日失败,请点「刷新链」重试"); + return; + } + const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : ""; + state.chain = d; + panelCache.chain = d; + panelCache.underlying = uly; + panelCache.optType = state.optType; + syncAskLiqFilterFromChain(d); + if (!soft) { + state.selectedInst = null; + resetMoneyFilterToAll(); + state.strikeExpandAll = false; + const expandCb = document.getElementById("opt-strike-expand-all"); + if (expandCb) expandCb.checked = false; + parkOrderPanel(); + } + updateUnderlyingLabel(); + renderExpiries(); + if (soft && keepExp) { + const sel = document.getElementById("opt-exp-select"); + if (sel && Array.from(sel.options).some(function (o) { return o.value === keepExp; })) { + sel.value = keepExp; + } + } + // soft 时保留 selectedInst;renderStrikes 会先 park 再按 prevSelected 静默重挂下单面板 + renderStrikes(); + } catch (e) { + if (seq !== chainLoadSeq || soft) return; + setExpirySelectStatus("选择到期日"); + const tbody = document.getElementById("opt-strike-tbody"); + if (tbody) { + tbody.innerHTML = + '加载失败: ' + + String((e && e.message) || e) + + ""; + } + } finally { + if (seq === chainLoadSeq && btn) btn.disabled = false; + } + } + + async function openPosition() { + if (!state.selectedInst) { + alert("请先选择合约"); + return false; + } + const q = state.orderQuote; + if (!q || !q.ok || !q.can_open) { + alert((q && (q.msg || q.open_block_msg)) || "暂无卖一深度,无法按卖一开仓"); + return false; + } + if (q.sizing && q.sizing.ok === false) { + alert(q.sizing.msg || "张数无效"); + return false; + } + const btn = document.getElementById("opt-open-btn"); + btn.disabled = true; + try { + updateSizeInputs(); + const mode = currentSizeMode(); + const body = { + inst_id: state.selectedInst, + mode: mode, + signal_note: document.getElementById("opt-signal-note").value || "", + }; + if (mode === "eth_amount") { + body.eth_amount = parseFloat(document.getElementById("opt-eth-amount").value); + } else if (mode === "sheets") { + body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10) || 1; + } else if (mode === "compound_full" && !compoundFullEnabled()) { + body.mode = "sheets"; + body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10) || 1; + } + const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim(); + if (tgtRaw !== "") { + const tgt = parseFloat(tgtRaw); + if (!Number.isFinite(tgt) || tgt <= 0) { + alert("目标位无效"); + return false; + } + body.target_index = tgt; + } + const peEnabled = !!(document.getElementById("opt-profit-exit-enabled") || {}).checked; + if (peEnabled) { + const multRaw = (document.getElementById("opt-profit-exit-mult") || {}).value; + const mult = parseFloat(multRaw); + if (!Number.isFinite(mult) || mult <= 0) { + alert("翻倍倍数无效"); + return false; + } + body.profit_exit_enabled = true; + body.profit_exit_mult = mult; + } + const d = await apiJson("/api/options/open", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const msgEl = document.getElementById("opt-order-msg"); + msgEl.textContent = d.ok ? "下单已提交,可在「当前委托」查看/撤销" : (d.msg || "失败"); + msgEl.classList.toggle("opt-error", !d.ok); + if (d.ok) { + refreshPendingOrders(); + startPendingOrdersPoll(); + refreshAllPositions(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); + closeOrderDialog(); + setOptionsPosTab("pending"); + return true; + } + alert(d.msg || "下单失败"); + return false; + } finally { + const latest = state.orderQuote; + btn.disabled = !(latest && latest.ok && latest.can_open && !(latest.sizing && latest.sizing.ok === false)); + btn.textContent = (latest && latest.can_open) ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓"; + } + } + + function renderPositionCardInner(p) { + const net = netPnlFromPos(p); + const roi = netRoiFromPos(p, net); + const uplCls = pnlCls(net); + const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; + const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; + const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; + const closePreview = p.close_preview || {}; + const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos; + const tickSz = p.tick_sz; + const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null); + // 优先用数值+tick 现算,避免接口侧 mark_px_fmt 带着浮点毛刺直出 + const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt); + const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt); + return ( + '
    ' + + '
    ' + (p.inst_id || "") + '' + + '' + optTypeLabel(p.opt_type) + "" + + sourceBadgeHtml(p) + + "
    " + + '
    ' + + '' + + "
    " + + '
    ' + + '持仓来源: ' + sourceText(p) + "" + + '行权价: ' + fmt(p.strike, 0) + "" + + '张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "" + + (expAttr + ? '到期倒计时: ' + : "") + + "
    " + + '
    ' + + '
    权利金' + premTxt + " USDC
    " + + '
    开仓均价' + avgTxt + "
    " + + '
    标记价' + markTxt + "
    " + + '
    指数价' + fmt(p.idx_px, 0) + "
    " + + '
    到期平衡' + fmt(p.expiry_be_px, 0) + "
    " + + '
    平掉回本' + fmt(p.close_be_px, 0) + "
    " + + '
    净盈亏' + + (closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "
    " + + '
    收益率' + + (closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "
    " + + '
    买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
    " + + '
    按买盘回收' + + (closePreview.bid_invalid + ? '暂无有效买盘' + : fmtClosePreview(closePreview, p.premium_paid)) + "
    " + + "
    " + + (function () { + const hint = closeGateHint(closePreview); + return hint ? '
    ' + hint + "
    " : ""; + })() + + renderTargetDelegateRow(p) + + renderProfitExitRow(p) + ); + } + + function formatProfitExitMultLabel(mult) { + const n = Number(mult); + if (!Number.isFinite(n) || n <= 0) return "1倍"; + if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n)) + "倍"; + return fmt(n, 2) + "倍"; + } + + function renderProfitExitRow(p) { + const inst = p.inst_id || ""; + if (p.hedge_plan_target) { + return ""; + } + const enabled = !!p.profit_exit_enabled; + const serverMult = p.profit_exit_mult != null && Number(p.profit_exit_mult) > 0 + ? Number(p.profit_exit_mult) + : 1; + const draft = state.profitExitDraftByInst[inst]; + const multDisp = draft != null && String(draft).trim() !== "" + ? String(draft) + : String(serverMult); + const multNum = Number(multDisp); + const multLabel = formatProfitExitMultLabel( + Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult + ); + const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle")); + const req = p.profit_exit_required_recycle; + let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启"; + if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel; + return ( + '
    ' + + '翻倍' + + '" + + '' + + '" + + '' + statusTxt + "" + + '' + + (enabled + ? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtUsdc(req)) : "")) + : "开启后自选倍数;达标按买一限价平;可随时关闭") + + "" + + "
    " + ); + } + + function posEthAmount(p) { + if (p.eth_amount != null && Number(p.eth_amount) > 0) return Number(p.eth_amount); + const sheets = Number(p.avail_pos != null ? p.avail_pos : p.pos); + const ct = Number(p.ct_mult != null ? p.ct_mult : 0.01); + if (Number.isFinite(sheets) && sheets > 0 && Number.isFinite(ct) && ct > 0) return sheets * ct; + return null; + } + + function formatTargetEstimateHtml(optType, strike, targetIdx, ethAmount, premiumPaid) { + const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); + const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid); + const rr = estimateProfitRr(profit, premiumPaid); + if (value == null && profit == null && rr == null) return ""; + let html = ''; + html += '价值' + + (value == null ? "—" : fmtUsdc(value) + " USDC") + ""; + html += '预估盈利' + + (profit == null ? "—" : fmtUsdcSigned(profit)) + ""; + html += '盈亏比' + + (rr == null ? "—" : fmtProfitRr(rr)) + ""; + html += ""; + return html; + } + + function renderTargetDelegateRow(p) { + const inst = p.inst_id || ""; + const hedgeTarget = p.hedge_plan_target || null; + if (hedgeTarget) { + const rr = hedgeTarget.profit_rr != null ? Number(hedgeTarget.profit_rr) : null; + if (rr != null && rr > 0) { + return ( + '
    ' + + '对冲计划' + + '计划 #' + + hedgeTarget.plan_id + + " · 盈亏比 " + + fmt(rr, 2) + + "" + + '进行中 · 盈利达总权利金×盈亏比仅平盈利腿;亏损腿按本合约残值平或到期平' + + "
    " + ); + } + if (Number(hedgeTarget.target_index) > 0) { + const side = (p.opt_type || hedgeTarget.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; + return ( + '
    ' + + '对冲计划' + + '计划 #' + + hedgeTarget.plan_id + + " · " + + side + + " " + + fmt(hedgeTarget.target_index, 1) + + "" + + '进行中 · 由对冲计划监控,到位后仅平盈利腿' + + "
    " + ); + } + } + const tgt = p.target_index != null && p.target_index !== "" ? Number(p.target_index) : null; + const armed = tgt != null && Number.isFinite(tgt) && tgt > 0; + const ethAmt = posEthAmount(p); + const prem = p.premium_paid; + const estHtml = armed + ? formatTargetEstimateHtml(p.opt_type, p.strike, tgt, ethAmt, prem) + : ''; + return ( + '
    ' + + '委托' + + '' + + '' + + '" + + (armed + ? '目标 ' + fmt(tgt, 1) + "" + : "") + + estHtml + + '' + + (armed ? "监控中 · 目标位参考 · 到位按买一限价平" : "目标位参考(到期实值估盈亏比) · 到位按买一限价平 · 到期即止损") + + "" + + "
    " + ); + } + + function updatePosTargetEstimate(row) { + if (!row) return; + const est = row.querySelector(".opt-target-est"); + if (!est) return; + const inp = row.querySelector(".opt-pos-target-input"); + const typed = inp ? String(inp.value || "").trim() : ""; + const armed = row.getAttribute("data-armed-target") || ""; + const targetRaw = typed !== "" ? typed : armed; + if (targetRaw === "") { + est.className = "opt-target-est opt-target-est--idle"; + est.innerHTML = ""; + return; + } + const html = formatTargetEstimateHtml( + row.getAttribute("data-opt-type"), + row.getAttribute("data-strike"), + targetRaw, + row.getAttribute("data-eth"), + row.getAttribute("data-prem") + ); + if (!html) { + est.className = "opt-target-est opt-target-est--idle"; + est.innerHTML = ""; + return; + } + const tmp = document.createElement("div"); + tmp.innerHTML = html; + const node = tmp.firstChild; + est.className = "opt-target-est"; + est.innerHTML = node ? node.innerHTML : ""; + } + + function renderPositionCard(p) { + return ( + '
    ' + + renderPositionCardInner(p) + + "
    " + ); + } + + function renderPositionAccordionItem(p, expanded) { + const net = netPnlFromPos(p); + const roi = netRoiFromPos(p, net); + const uplCls = pnlCls(net); + const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; + const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; + const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; + const inst = p.inst_id || ""; + return ( + '
    ' + + '" + + '
    ' + + '
    ' + + renderPositionCardInner(p) + + "
    " + ); + } + + function applyAccordionState() { + const wrap = document.getElementById("opt-pos-cards"); + if (!wrap) return; + wrap.querySelectorAll(".opt-pos-accordion-item").forEach(function (el) { + const open = el.getAttribute("data-inst") === state.expandedPosInst; + el.classList.toggle("is-expanded", open); + const btn = el.querySelector(".opt-pos-bar"); + const body = el.querySelector(".opt-pos-accordion-body"); + if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false"); + if (body) body.hidden = !open; + }); + } + + function bindPositionActions(container) { + if (!container) return; + container.querySelectorAll(".opt-close-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + closePosition(btn.getAttribute("data-inst"), btn); + }); + }); + container.querySelectorAll(".opt-target-set-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + setPositionTarget(btn.getAttribute("data-inst"), btn); + }); + }); + container.querySelectorAll(".opt-target-cancel-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + cancelPositionTarget(btn.getAttribute("data-inst"), btn); + }); + }); + container.querySelectorAll(".opt-profit-exit-save-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + savePositionProfitExit(btn.getAttribute("data-inst"), btn); + }); + }); + container.querySelectorAll(".opt-pos-profit-exit-enabled").forEach(function (cb) { + cb.addEventListener("click", function (e) { e.stopPropagation(); }); + }); + container.querySelectorAll(".opt-pos-profit-exit-mult").forEach(function (inp) { + // 倍数随时可改;「开启/应用」只控制是否监控,不再因未勾选而 disabled + inp.disabled = false; + inp.removeAttribute("readonly"); + inp.addEventListener("click", function (e) { e.stopPropagation(); }); + inp.addEventListener("mousedown", function (e) { e.stopPropagation(); }); + inp.addEventListener("focus", function (e) { e.stopPropagation(); }); + inp.addEventListener("input", function () { + const id = inp.getAttribute("data-inst") || ""; + if (!id) return; + const draft = String(inp.value || ""); + if (draft.trim() === "") delete state.profitExitDraftByInst[id]; + else state.profitExitDraftByInst[id] = draft; + }); + inp.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + savePositionProfitExit(inp.getAttribute("data-inst"), null); + } + }); + }); + container.querySelectorAll(".opt-pos-target-input").forEach(function (inp) { + inp.addEventListener("click", function (e) { e.stopPropagation(); }); + inp.addEventListener("input", function () { + const instId = inp.getAttribute("data-inst") || ""; + const draft = String(inp.value || ""); + if (instId) { + if (draft.trim() === "") delete state.targetDraftByInst[instId]; + else state.targetDraftByInst[instId] = draft; + } + updatePosTargetEstimate(inp.closest(".opt-target-row")); + }); + inp.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + setPositionTarget(inp.getAttribute("data-inst"), null); + } + }); + // 重绘后恢复预估展示(草稿或已设定目标) + updatePosTargetEstimate(inp.closest(".opt-target-row")); + }); + container.querySelectorAll(".opt-pos-bar").forEach(function (bar) { + bar.addEventListener("click", function () { + const item = bar.closest(".opt-pos-accordion-item"); + if (!item) return; + const inst = item.getAttribute("data-inst"); + state.expandedPosInst = state.expandedPosInst === inst ? null : inst; + applyAccordionState(); + if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } + }); + }); + } + + async function setPositionTarget(inst, btn) { + if (!inst) return; + const card = document.querySelector('.opt-pos-card[data-inst="' + inst + '"]') || + document.querySelector('.opt-pos-accordion-item[data-inst="' + inst + '"]'); + const row = card ? card.querySelector(".opt-target-row") : null; + const inp = card ? card.querySelector(".opt-pos-target-input") : null; + const raw = inp ? String(inp.value || "").trim() : ""; + const tgt = parseFloat(raw); + if (!Number.isFinite(tgt) || tgt <= 0) { + alert("请输入有效目标指数价"); + return; + } + if (btn) btn.disabled = true; + try { + const d = await apiJson("/api/options/target", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inst_id: inst, target_index: tgt }), + }); + if (!d.ok) { + alert(d.msg || "设定失败"); + return; + } + delete state.targetDraftByInst[inst]; + if (inp) inp.value = ""; + if (row) { + row.setAttribute("data-armed-target", String(tgt)); + updatePosTargetEstimate(row); + } + await refreshAllPositions(); + } finally { + if (btn) btn.disabled = false; + } + } + + async function cancelPositionTarget(inst, btn) { + if (!inst) return; + if (btn) btn.disabled = true; + try { + const d = await apiJson("/api/options/target/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inst_id: inst }), + }); + if (!d.ok) { + alert(d.msg || "取消失败"); + return; + } + delete state.targetDraftByInst[inst]; + await refreshAllPositions(); + } finally { + if (btn) btn.disabled = false; + } + } + + async function savePositionProfitExit(inst, btn) { + if (!inst) return; + const card = document.querySelector('.opt-pos-card[data-inst="' + inst + '"]') || + document.querySelector('.opt-pos-accordion-item[data-inst="' + inst + '"]'); + const row = card ? card.querySelector(".opt-profit-exit-pos-row") : null; + const enabledEl = row ? row.querySelector(".opt-pos-profit-exit-enabled") : null; + const multEl = row ? row.querySelector(".opt-pos-profit-exit-mult") : null; + const mode = btn && btn.getAttribute("data-mode"); + let enabled = !!(enabledEl && enabledEl.checked); + if (mode === "cancel") enabled = false; + if (mode === "apply") { + enabled = true; + if (enabledEl) enabledEl.checked = true; + } + let mult = 1; + if (enabled) { + mult = parseFloat(multEl ? multEl.value : "1"); + if (!Number.isFinite(mult) || mult <= 0) { + alert("翻倍倍数无效"); + return; + } + } + if (btn) btn.disabled = true; + try { + const d = await apiJson("/api/options/profit-exit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inst_id: inst, enabled: enabled, mult: mult }), + }); + if (!d.ok) { + alert(d.msg || "保存失败"); + return; + } + delete state.profitExitDraftByInst[inst]; + await refreshAllPositions(); + } finally { + if (btn) btn.disabled = false; + } + } + + function paintTargetMonitors(list) { + const box = document.getElementById("opt-target-monitors"); + const host = document.getElementById("opt-target-monitors-list"); + if (!box || !host) return; + const rows = Array.isArray(list) ? list.filter(function (t) { return t && t.inst_id; }) : []; + if (!rows.length) { + box.hidden = true; + host.innerHTML = ""; + return; + } + box.hidden = false; + host.innerHTML = rows.map(function (t) { + const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; + const managed = t.managed_by === "hedge_plan"; + return ( + '
    ' + + '' + (t.inst_id || "") + "" + + '' + side + " " + fmt(t.target_index, 1) + "" + + (managed + ? '对冲计划 #' + (t.plan_id || "") + " · 进行中" + : '') + + "
    " + ); + }).join(""); + host.querySelectorAll(".opt-target-mon-cancel").forEach(function (btn) { + btn.addEventListener("click", function () { + cancelPositionTarget(btn.getAttribute("data-inst"), btn); + }); + }); + } + + async function closePosition(inst, btn) { + const sheets = btn && btn.getAttribute("data-sheets") ? parseInt(btn.getAttribute("data-sheets"), 10) : null; + let url = "/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=close_preview"; + if (sheets && sheets > 0) url += "&sheets=" + encodeURIComponent(sheets); + const q = await apiJson(url); + if (!q.ok) { + alert(q.msg || "获取买一价失败"); + return; + } + const preview = q.close_preview || {}; + if (preview.bid_invalid || preview.manual_close_blocked) { + alert(preview.bid_invalid_reason || "当前买一为无效残档,禁止买一平仓。"); + return; + } + if (!preview.covered_sheets || preview.covered_sheets <= 0) { + alert("暂无有效买一深度,请稍后重试或到 OKX App 挂限价"); + return; + } + const lv = (preview.levels && preview.levels[0]) || {}; + const msg = [ + "按买一限价卖出本轮可平张数?", + "合约: " + inst, + "锁定买一: " + (lv.px != null ? lv.px : "—") + " × " + (lv.sheets != null ? lv.sheets : preview.covered_sheets) + " 张", + "预计收回: " + fmtClosePreviewText(preview), + preview.estimated_pnl != null ? "预估盈亏: " + fmt(preview.estimated_pnl, 4) + " USDC" : "", + preview.uncovered_sheets > 0 ? "\n注意: 买一深度不足,预计仍剩 " + preview.uncovered_sheets + " 张,需下次再平。" : "" + ].filter(function (x) { return x !== ""; }).join("\n"); + if (!confirm(msg)) return; + if (btn) btn.disabled = true; + try { + const r = await apiJson("/api/options/close", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inst_id: inst, mode: "bid1", sheets: sheets }), + }); + if (r.ok) { + let okMsg = "买一平仓已提交 " + (r.submitted_sheets || 0) + " 张"; + if (r.locked_bid_px != null) okMsg += "\n锁定买一: " + r.locked_bid_px; + if (r.premium_received != null) okMsg += "\n预估收回: " + fmt(r.premium_received, 4) + " USDC"; + if (r.remaining_sheets > 0) okMsg += "\n剩余: " + r.remaining_sheets + " 张(下次再平)"; + if (r.stopped_reason) okMsg += "\n状态: " + r.stopped_reason; + alert(okMsg); + } else { + alert(r.msg || "平仓失败"); + } + refreshAllPositions(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); + } finally { + if (btn) btn.disabled = false; + } + } + + function setOptionsPosTab(tabId) { + const tab = tabId || "live"; + state.posTab = tab; + document.querySelectorAll(".opt-pos-tab").forEach(function (btn) { + const on = btn.getAttribute("data-opt-pos-tab") === tab; + btn.classList.toggle("active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + document.querySelectorAll("[data-opt-pos-pane]").forEach(function (pane) { + const on = pane.getAttribute("data-opt-pos-pane") === tab; + pane.classList.toggle("is-active", on); + pane.hidden = !on; + }); + if (tab === "live" && window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } + if (tab === "pending") { + refreshPendingOrders(); + startPendingOrdersPoll(); + } + } + + function bindOptionsPosTabs() { + document.querySelectorAll(".opt-pos-tab").forEach(function (btn) { + btn.addEventListener("click", function () { + setOptionsPosTab(btn.getAttribute("data-opt-pos-tab")); + }); + }); + setOptionsPosTab(state.posTab); + } + + function resolvePositionsList(d) { + const now = Date.now(); + const list = (d && d.ok && d.positions) ? d.positions : []; + if (d && d.ok) { + if (list.length) { + lastGoodPositions = list; + lastGoodPositionsAt = now; + return list; + } + lastGoodPositions = null; + lastGoodPositionsAt = 0; + return list; + } + if (lastGoodPositions && lastGoodPositions.length && now - lastGoodPositionsAt < POSITIONS_STALE_MS) { + return lastGoodPositions; + } + return []; + } + + function paintPositions(list) { + const wrap = document.getElementById("opt-pos-cards"); + const empty = document.getElementById("opt-pos-empty"); + const livePane = document.getElementById("opt-pos-live"); + if (!wrap) return; + const active = document.activeElement; + // 正在输入目标指数:先落到草稿,本轮不重绘整卡,避免数字往回退 + if (active && active.classList && active.classList.contains("opt-pos-target-input")) { + const focusInst = active.getAttribute("data-inst") || ""; + if (focusInst) { + state.targetDraftByInst[focusInst] = String(active.value || ""); + } + return; + } + // 正在输入翻倍倍数:同样跳过重绘,避免被默认 1 冲掉 + if (active && active.classList && active.classList.contains("opt-pos-profit-exit-mult")) { + const focusInst = active.getAttribute("data-inst") || ""; + if (focusInst) { + state.profitExitDraftByInst[focusInst] = String(active.value || ""); + } + return; + } + // 未聚焦时也同步可见输入,防止漏掉 input 事件 + wrap.querySelectorAll(".opt-pos-target-input").forEach(function (inp) { + const id = inp.getAttribute("data-inst") || ""; + if (!id) return; + const v = String(inp.value || ""); + if (v.trim() === "") delete state.targetDraftByInst[id]; + else state.targetDraftByInst[id] = v; + }); + wrap.querySelectorAll(".opt-pos-profit-exit-mult").forEach(function (inp) { + const id = inp.getAttribute("data-inst") || ""; + if (!id) return; + const v = String(inp.value || ""); + if (v.trim() === "") delete state.profitExitDraftByInst[id]; + else state.profitExitDraftByInst[id] = v; + }); + wrap.innerHTML = ""; + if (!list.length) { + if (empty) empty.style.display = ""; + state.expandedPosInst = null; + if (livePane) livePane.classList.remove("options-pos-live-pane--accordion"); + return; + } + if (empty) empty.style.display = "none"; + const multi = list.length >= 2; + wrap.classList.toggle("opt-pos-cards--accordion", multi); + if (livePane) livePane.classList.toggle("options-pos-live-pane--accordion", multi); + if (multi) { + const ids = list.map(function (p) { return p.inst_id; }); + if (state.expandedPosInst && ids.indexOf(state.expandedPosInst) < 0) { + state.expandedPosInst = null; + } + list.forEach(function (p) { + const div = document.createElement("div"); + div.innerHTML = renderPositionAccordionItem(p, p.inst_id === state.expandedPosInst); + wrap.appendChild(div.firstChild); + }); + } else { + state.expandedPosInst = null; + list.forEach(function (p) { + const div = document.createElement("div"); + div.innerHTML = renderPositionCard(p); + wrap.appendChild(div.firstChild); + }); + } + bindPositionActions(wrap); + if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } + } + + async function refreshPositions() { + const seq = ++positionsRefreshSeq; + const d = await apiJson("/api/options/positions"); + if (seq !== positionsRefreshSeq) return; + const list = resolvePositionsList(d); + paintPositions(list); + const fromPos = list.reduce(function (targets, p) { + if (!p) return targets; + if (p.target_index != null) { + targets.push({ + id: p.target_monitor_id, + inst_id: p.inst_id, + opt_type: p.opt_type, + target_index: p.target_index, + }); + } + const hedgeTarget = p.hedge_plan_target; + if (hedgeTarget && (hedgeTarget.target_index != null || hedgeTarget.profit_rr != null)) { + targets.push({ + inst_id: p.inst_id, + opt_type: p.opt_type || hedgeTarget.opt_type, + target_index: hedgeTarget.target_index, + profit_rr: hedgeTarget.profit_rr, + plan_id: hedgeTarget.plan_id, + managed_by: hedgeTarget.managed_by, + exit_mode: hedgeTarget.exit_mode, + }); + } + return targets; + }, []); + if (fromPos.length) { + paintTargetMonitors(fromPos); + } else { + const t = await apiJson("/api/options/targets"); + if (seq !== positionsRefreshSeq) return; + paintTargetMonitors((t && t.ok && t.targets) ? t.targets : []); + } + } + + function paintPnlStat(el, value) { + if (!el) return; + if (value == null || value === "" || Number.isNaN(Number(value))) { + el.textContent = "—"; + el.classList.remove("pos-pnl-profit", "pos-pnl-loss"); + return; + } + const n = Number(value); + el.textContent = (n > 0 ? "+" : "") + fmt(n, 2) + " USDC"; + el.classList.toggle("pos-pnl-profit", n > 0); + el.classList.toggle("pos-pnl-loss", n < 0); + } + + async function refreshStats() { + const d = await apiJson("/api/options/stats"); + const winEl = document.getElementById("opt-stats-winrate"); + const plrEl = document.getElementById("opt-stats-plr"); + const closedEl = document.getElementById("opt-stats-closed"); + const profitEl = document.getElementById("opt-stats-profit"); + const lossEl = document.getElementById("opt-stats-loss"); + const avgHoldEl = document.getElementById("opt-stats-avg-hold"); + const winHoldEl = document.getElementById("opt-stats-win-hold"); + const lossHoldEl = document.getElementById("opt-stats-loss-hold"); + const openHoldEl = document.getElementById("opt-stats-open-hold"); + const totalPnlEl = document.getElementById("opt-stats-total-pnl"); + const netRealizedEl = document.getElementById("opt-stats-net-realized"); + const openFloatEl = document.getElementById("opt-stats-open-float"); + const statEls = [winEl, plrEl, closedEl, profitEl, lossEl, avgHoldEl, winHoldEl, lossHoldEl, openHoldEl]; + if (!d.ok) { + statEls.forEach(function (el) { + if (el) el.textContent = "—"; + }); + paintPnlStat(totalPnlEl, null); + paintPnlStat(netRealizedEl, null); + paintPnlStat(openFloatEl, null); + paintStatsCharts(null); + return; + } + paintPnlStat(totalPnlEl, d.total_pnl); + paintPnlStat(netRealizedEl, d.net_realized_pnl); + paintPnlStat(openFloatEl, d.open_float_pnl); + if (winEl) winEl.textContent = d.total_closed ? d.win_rate + "%" : "0%"; + if (plrEl) { + plrEl.textContent = d.profit_loss_ratio != null ? String(d.profit_loss_ratio) : "—"; + } + if (closedEl) closedEl.textContent = String(d.total_closed || 0); + if (profitEl) { + profitEl.textContent = d.avg_win != null && d.avg_win > 0 + ? fmt(d.avg_win, 2) + " USDC" : (d.win_count ? "0 USDC" : "—"); + } + if (lossEl) { + lossEl.textContent = d.avg_loss != null && d.avg_loss > 0 + ? fmt(d.avg_loss, 2) + " USDC" : (d.loss_count ? "0 USDC" : "—"); + } + if (avgHoldEl) avgHoldEl.textContent = fmtDuration(d.avg_hold_sec); + if (winHoldEl) winHoldEl.textContent = fmtDuration(d.avg_win_hold_sec); + if (lossHoldEl) lossHoldEl.textContent = fmtDuration(d.avg_loss_hold_sec); + if (openHoldEl) { + const cnt = Number(d.open_count) || 0; + if (!cnt) { + openHoldEl.textContent = "0 笔"; + } else { + openHoldEl.textContent = cnt + " 笔 · " + fmtDuration(d.avg_open_hold_sec); + } + } + paintStatsCharts(d); + } + + function fmtDuration(sec) { + if (sec == null || sec === "" || Number.isNaN(Number(sec))) return "—"; + let s = Math.max(0, Math.round(Number(sec))); + if (s < 60) return s + "秒"; + const m = Math.floor(s / 60); + if (m < 60) { + const rs = s % 60; + return rs ? m + "分" + rs + "秒" : m + "分"; + } + const h = Math.floor(m / 60); + const rm = m % 60; + if (h < 24) return rm ? h + "时" + rm + "分" : h + "时"; + const d = Math.floor(h / 24); + const rh = h % 24; + return rh ? d + "天" + rh + "时" : d + "天"; + } + + function setBarFill(el, pct) { + if (!el) return; + const n = Math.max(0, Math.min(100, Number(pct) || 0)); + el.style.width = n + "%"; + } + + function paintStatsCharts(d) { + const ring = document.getElementById("opt-stats-ring"); + const ringLabel = document.getElementById("opt-stats-ring-label"); + const profitBar = document.getElementById("opt-stats-bar-profit"); + const lossBar = document.getElementById("opt-stats-bar-loss"); + const profitBarLabel = document.getElementById("opt-stats-bar-profit-label"); + const lossBarLabel = document.getElementById("opt-stats-bar-loss-label"); + const winHoldBar = document.getElementById("opt-stats-bar-win-hold"); + const lossHoldBar = document.getElementById("opt-stats-bar-loss-hold"); + const winHoldBarLabel = document.getElementById("opt-stats-win-hold-label"); + const lossHoldBarLabel = document.getElementById("opt-stats-loss-hold-label"); + if (!d || !d.ok) { + if (ring) ring.style.setProperty("--win-pct", "0"); + if (ringLabel) ringLabel.textContent = "—"; + [profitBar, lossBar, winHoldBar, lossHoldBar].forEach(function (el) { setBarFill(el, 0); }); + [profitBarLabel, lossBarLabel, winHoldBarLabel, lossHoldBarLabel].forEach(function (el) { + if (el) el.textContent = "—"; + }); + return; + } + const winRate = d.total_closed ? Number(d.win_rate) || 0 : 0; + if (ring) ring.style.setProperty("--win-pct", String(winRate)); + if (ringLabel) ringLabel.textContent = d.total_closed ? winRate.toFixed(0) + "%" : "0%"; + + const profit = Math.max(0, Number(d.avg_win) || 0); + const loss = Math.max(0, Number(d.avg_loss) || 0); + const pnlTotal = profit + loss; + if (pnlTotal > 0) { + setBarFill(profitBar, (profit / pnlTotal) * 100); + setBarFill(lossBar, (loss / pnlTotal) * 100); + if (profitBarLabel) profitBarLabel.textContent = fmt(profit, 2) + " USDC"; + if (lossBarLabel) lossBarLabel.textContent = fmt(loss, 2) + " USDC"; + } else { + setBarFill(profitBar, 0); + setBarFill(lossBar, 0); + if (profitBarLabel) profitBarLabel.textContent = d.win_count ? "0 USDC" : "—"; + if (lossBarLabel) lossBarLabel.textContent = d.loss_count ? "0 USDC" : "—"; + } + + const winHold = Number(d.avg_win_hold_sec) || 0; + const lossHold = Number(d.avg_loss_hold_sec) || 0; + const holdMax = Math.max(winHold, lossHold); + if (holdMax > 0) { + setBarFill(winHoldBar, (winHold / holdMax) * 100); + setBarFill(lossHoldBar, (lossHold / holdMax) * 100); + if (winHoldBarLabel) winHoldBarLabel.textContent = fmtDuration(d.avg_win_hold_sec); + if (lossHoldBarLabel) lossHoldBarLabel.textContent = fmtDuration(d.avg_loss_hold_sec); + } else { + setBarFill(winHoldBar, 0); + setBarFill(lossHoldBar, 0); + if (winHoldBarLabel) winHoldBarLabel.textContent = "—"; + if (lossHoldBarLabel) lossHoldBarLabel.textContent = "—"; + } + } + + async function deleteHistoryRow(key, status, instId, closedAt) { + const warn = status === "open" + ? "该记录仍为持仓中,仅从列表隐藏,不影响交易所持仓.确认删除?" + : "确认从列表隐藏该条历史记录?(期权复盘页也会同步隐藏)"; + if (!confirm(warn)) return; + const body = {}; + if (instId) body.inst_id = instId; + if (closedAt) body.closed_at = closedAt; + const r = await apiJson("/api/options/history/" + encodeURIComponent(key), { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) { + alert(r.msg || "删除失败"); + return; + } + refreshAllPositions(); + } + + function optHistoryStatus(h) { + if (h.status_label) return h.status_label; + if (h.status === "open") return "持仓中"; + if (h.status !== "closed") return "持仓中"; + return "已平"; + } + + function optHistoryStatusHtml(h) { + const s = optHistoryStatus(h); + let cls = "opt-hist-status"; + if (s === "已平") cls += " opt-hist-status--closed"; + else if (s === "到期" || s === "强平") cls += " opt-hist-status--expired"; + else cls += " opt-hist-status--open"; + return '' + s + ""; + } + + async function refreshHistory() { + const d = await apiJson("/api/options/history"); + const tbody = document.getElementById("opt-history-tbody"); + tbody.innerHTML = ""; + const list = (d.ok && d.history) || []; + if (!list.length) { + tbody.innerHTML = '暂无历史记录'; + return; + } + list.forEach(function (h) { + const tr = document.createElement("tr"); + const premTxt = fmtDisplay(h.premium_paid_fmt, h.premium_paid != null ? fmtUsdc(h.premium_paid) : null); + const isOpen = h.status === "open"; + const pnl = isOpen ? null : h.realized_pnl; + const pnlTxt = pnl != null ? fmt(pnl, 2) : "—"; + const pnlCls = pnl > 0 ? "pos-pnl-profit" : pnl < 0 ? "pos-pnl-loss" : ""; + const timeTxt = (h.closed_at || h.created_at || "—").replace("T", " ").slice(0, 19); + const histKey = h.history_key || ""; + tr.innerHTML = + '' + (h.inst_id || "") + "" + + "" + fmt(h.sheets, 0) + "" + + "" + premTxt + "" + + "" + optHistoryStatusHtml(h) + "" + + '' + pnlTxt + "" + + '' + timeTxt + "" + + ''; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".opt-history-del").forEach(function (btn) { + btn.addEventListener("click", function () { + deleteHistoryRow( + btn.getAttribute("data-key"), + btn.getAttribute("data-status"), + btn.getAttribute("data-inst"), + btn.getAttribute("data-closed") + ); + }); + }); + } + + function refreshAllPositions() { + if (refreshAllTimer) clearTimeout(refreshAllTimer); + refreshAllTimer = setTimeout(function () { + refreshAllTimer = null; + refreshPositions(); + refreshStats(); + refreshHistory(); + }, 120); + } + + function onExpiryChange() { + resetMoneyFilterToAll(); + state.strikeExpandAll = false; + const expandCb = document.getElementById("opt-strike-expand-all"); + if (expandCb) expandCb.checked = false; + renderStrikes(); + } + + function bootOptionsPanel() { + applyBudgetBuffer(state.budgetBuffer); + updateSizeInputs(); + void (async function syncLiveCompoundFlag() { + try { + const d = await apiJson("/api/options/balances"); + syncCompoundFlagsFromPayload(d); + if (d && d.trade_budget != null && root) { + root.dataset.tradeBudget = String(d.trade_budget); + } + } catch (_) {} + })(); + syncMoneyFilterButtons(); + syncChainViewUI(); + updateUnderlyingLabel(); + refreshPendingOrders(); + startPendingOrdersPoll(); + const hasCache = + chainHasExpiries(panelCache.chain) && + panelCache.underlying === state.underlying && + panelCache.optType === state.optType; + if (hasCache) { + state.chain = panelCache.chain; + renderExpiries(); + renderStrikes(); + refreshAllPositions(); + // 后台静默刷新,避免缓存过期后到期日变空 + loadChain({ soft: true }); + return; + } + requestAnimationFrame(function () { + loadChain(); + refreshAllPositions(); + }); + } + + document.querySelectorAll(".opt-uly-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + document.querySelectorAll(".opt-uly-btn").forEach(function (b) { b.classList.remove("active"); }); + btn.classList.add("active"); + state.underlying = btn.getAttribute("data-uly"); + loadChain(); + }); + }); + + document.querySelectorAll(".opt-view-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + const view = btn.getAttribute("data-view") || "list"; + if (view === state.chainView) return; + state.chainView = view; + if (view === "t") { + state.strikeExpandAll = false; + const expandCb = document.getElementById("opt-strike-expand-all"); + if (expandCb) expandCb.checked = false; + } + syncChainViewUI(); + renderStrikes(); + }); + }); + + const expandAllCb = document.getElementById("opt-strike-expand-all"); + if (expandAllCb) { + expandAllCb.addEventListener("change", function () { + state.strikeExpandAll = !!expandAllCb.checked; + // 实值/虚值筛选下档位本来就少,展开几乎不变;勾选时切回「全部」才有意义 + if (state.strikeExpandAll && state.moneyFilter !== "all") { + state.moneyFilter = "all"; + syncMoneyFilterButtons(); + } + renderStrikes(); + }); + } + + document.querySelectorAll(".opt-type-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + document.querySelectorAll(".opt-type-btn").forEach(function (b) { b.classList.remove("active"); }); + btn.classList.add("active"); + state.optType = btn.getAttribute("data-type"); + resetMoneyFilterToAll(); + renderExpiryOptions(true); + renderStrikes(); + }); + }); + + document.querySelectorAll(".opt-money-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + state.moneyFilter = btn.getAttribute("data-money") || "all"; + syncMoneyFilterButtons(); + renderStrikes(); + }); + }); + + document.getElementById("opt-exp-select").addEventListener("change", onExpiryChange); + document.getElementById("opt-load-chain").addEventListener("click", loadChain); + document.getElementById("opt-refresh-positions").addEventListener("click", refreshAllPositions); + document.getElementById("opt-open-btn").addEventListener("click", openPosition); + const pendingRefreshBtn = document.getElementById("opt-pending-refresh"); + if (pendingRefreshBtn) { + pendingRefreshBtn.addEventListener("click", function () { + refreshPendingOrders(); + }); + } + bindOptionsPosTabs(); + hardenOrderAutofill(); + + (function bindProfitExitOpenControls() { + const peCb = document.getElementById("opt-profit-exit-enabled"); + const peMult = document.getElementById("opt-profit-exit-mult"); + if (!peCb || !peMult) return; + // 倍数始终可手输;勾选只决定开仓是否带上翻倍出场 + peMult.disabled = false; + peMult.removeAttribute("readonly"); + peCb.addEventListener("change", function () { + if (peCb.checked && (!peMult.value || Number(peMult.value) <= 0)) peMult.value = "1"; + }); + })(); + + document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) { + r.addEventListener("change", function () { + updateSizeInputs(); + if (state.selectedInst) selectContract(state.selectedInst, null, true); + }); + }); + + function bindOrderDialogChrome() { + const host = orderPanelHost(); + const closeBtn = document.getElementById("opt-order-close-btn"); + const cancelBtn = document.getElementById("opt-order-cancel-btn"); + if (closeBtn) closeBtn.addEventListener("click", closeOrderDialog); + if (cancelBtn) cancelBtn.addEventListener("click", closeOrderDialog); + if (host) { + host.addEventListener("click", function (ev) { + if (ev.target === host) closeOrderDialog(); + }); + } + document.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape") return; + const h = orderPanelHost(); + if (h && !h.hidden) closeOrderDialog(); + }); + } + bindOrderDialogChrome(); + + ["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) { + const el = document.getElementById(id); + if (!el) return; + el.addEventListener("change", function () { + if (id === "opt-target-idx") { + updateEstimatedProfit(); + return; + } + if (state.selectedInst) selectContract(state.selectedInst, null, true); + }); + if (id === "opt-target-idx") { + el.addEventListener("input", updateEstimatedProfit); + } + }); + + bootOptionsPanel(); + + window.OptionsPanelLive = { + refreshSoft: function () { + refreshAllPositions(); + }, + refreshChain: loadChain, + }; +})(); diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js new file mode 100644 index 0000000..d2010d2 --- /dev/null +++ b/lib/common/static/options_position_cards.js @@ -0,0 +1,276 @@ +(function (global) { + "use strict"; + + function fmt(v, d) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(d == null ? 2 : d); + } + + function fmtDisplay(v, fallback) { + if (v !== null && v !== undefined && String(v).trim() !== "") return String(v); + if (fallback !== undefined) return fmtDisplay(fallback); + return "—"; + } + + function fmtOptionPx(v, tickSz) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const tick = Number(tickSz); + if (!tickSz || Number.isNaN(tick) || tick <= 0) { + let s = n.toFixed(4).replace(/\.?0+$/, ""); + return s || "0"; + } + let decimals = 0; + if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick))); + else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length; + let s = n.toFixed(decimals); + // 仅裁小数尾零;整数 tick(BTC=5)时绝不能把 1370 裁成 137 + if (decimals > 0) s = s.replace(/\.?0+$/, ""); + return s || "0"; + } + + function fmtUsdc(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(2); + } + + function optTypeLabel(t) { + return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call"; + } + + function sourceText(p) { + const lab = (p && p.source_label) || "纯期权"; + const src = (p && p.source) || "option"; + let pid = p && p.source_plan_id; + if (pid == null && p && p.hedge_plan_target && p.hedge_plan_target.plan_id != null) { + pid = p.hedge_plan_target.plan_id; + } + if (src !== "option" && pid != null && pid !== "") return lab + " #" + pid; + return lab; + } + + function sourceBadgeHtml(p) { + const src = (p && p.source) || "option"; + const cls = + src === "options_options" + ? "opt-source-badge opt-source-badge--oo" + : src === "perp_options" + ? "opt-source-badge opt-source-badge--po" + : "opt-source-badge opt-source-badge--plain"; + return '' + sourceText(p) + ""; + } + + function pnlCls(upl, hub) { + if (upl > 0) return hub ? "pnl-pos" : "pos-pnl-profit"; + if (upl < 0) return hub ? "pnl-neg" : "pos-pnl-loss"; + return ""; + } + + function fmtPxSz(px, sz, tickSz) { + if (px === null || px === undefined || Number.isNaN(Number(px))) return "—"; + let price = fmtOptionPx(px, tickSz); + if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price; + const s = Number(sz); + const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); + return price + "/" + size; + } + + /** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */ + function fmtCloseLevels(preview, tickSz) { + if (preview && preview.bid_invalid) { + return "暂无有效买盘"; + } + const levels = ((preview && preview.levels) || []).slice(0, 5); + if (!levels.length) return "—"; + return levels.map(function (x, idx) { + const levelNo = x.level != null ? x.level : idx + 1; + const liq = x.available_sheets != null ? x.available_sheets : x.sz; + return "买" + levelNo + " " + fmtPxSz(x.px, liq, tickSz); + }).join(" · "); + } + + function closeGateHint(preview) { + if (!preview) return ""; + if (preview.bid_invalid || preview.manual_close_blocked) { + return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓"; + } + const gate = preview.close_gate || {}; + if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { + return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); + } + return ""; + } + + function netPnlFromPos(p) { + const preview = (p && p.close_preview) || {}; + if (preview.bid_invalid) { + const upl = p && p.upl != null ? Number(p.upl) : NaN; + return Number.isFinite(upl) ? upl : null; + } + if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) { + return Number(preview.estimated_pnl); + } + const covered = Number(preview.covered_sheets); + const recv = Number(preview.total_received); + const prem = Number(p && p.premium_paid); + if ( + preview.total_received != null && + Number.isFinite(covered) && + covered > 0 && + !Number.isNaN(recv) && + !Number.isNaN(prem) + ) { + return recv - prem; + } + const upl = p && p.upl != null ? Number(p.upl) : NaN; + return Number.isFinite(upl) ? upl : null; + } + + function netRoiFromPos(p, net) { + const preview = (p && p.close_preview) || {}; + if (preview.estimated_pnl_ratio_pct != null && !Number.isNaN(Number(preview.estimated_pnl_ratio_pct))) { + return Number(preview.estimated_pnl_ratio_pct); + } + const prem = Number(p && p.premium_paid); + if (net == null || Number.isNaN(prem) || prem <= 0) return null; + return (net / prem) * 100; + } + + function fmtClosePreview(preview, premiumPaid, hub) { + if (!preview || preview.total_received == null) return "—"; + const recvTxt = fmtUsdc(preview.total_received); + let cls = ""; + const prem = Number(premiumPaid); + const recv = Number(preview.total_received); + if (!Number.isNaN(prem) && !Number.isNaN(recv)) { + if (recv > prem) cls = " " + pnlCls(1, hub); + else if (recv < prem) cls = " " + pnlCls(-1, hub); + } + return '' + recvTxt + " USDC"; + } + + function expiryCdHtml(expMs) { + const ms = expMs != null && expMs !== "" ? String(expMs) : ""; + if (!ms) return "—"; + return ''; + } + + function renderCardInner(p, opts) { + opts = opts || {}; + const hub = !!opts.hub; + const readOnly = !!opts.readOnly; + const hidePnl = !!opts.hidePnl; + const net = hidePnl ? null : netPnlFromPos(p); + const roi = hidePnl ? null : netRoiFromPos(p, net); + const uplCls = hidePnl ? "" : pnlCls(net, hub); + const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; + const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; + const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; + const closePreview = p.close_preview || {}; + const tickSz = p.tick_sz; + const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null); + const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt); + const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt); + let headActions = ""; + if (!readOnly) { + const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos; + headActions = + '
    ' + + '' + + "
    "; + } + const pnlCells = hidePnl + ? "" + : '
    净盈亏' + + (net == null ? "—" : fmt(net, 2)) + "
    " + + '
    收益率' + + (roi == null ? "—" : fmt(roi, 2) + "%") + "
    "; + return ( + '
    ' + + '
    ' + (p.inst_id || "") + "" + + '' + optTypeLabel(p.opt_type) + "" + + sourceBadgeHtml(p) + + "
    " + + headActions + + "
    " + + '
    ' + + '持仓来源: ' + sourceText(p) + "" + + '行权价: ' + fmt(p.strike, 0) + "" + + '张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "" + + (expAttr + ? '到期倒计时: ' + expiryCdHtml(expAttr) + "" + : "") + + "
    " + + '
    ' + + '
    权利金' + premTxt + " USDC
    " + + '
    开仓均价' + avgTxt + "
    " + + '
    标记价' + markTxt + "
    " + + '
    指数价' + fmt(p.idx_px, 0) + "
    " + + '
    到期平衡' + fmt(p.expiry_be_px, 0) + "
    " + + '
    平掉回本' + fmt(p.close_be_px, 0) + "
    " + + pnlCells + + '
    买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
    " + + '
    按买盘回收' + + (closePreview.bid_invalid + ? '暂无有效买盘' + : fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub)) + "
    " + + "
    " + + (function () { + const hint = closeGateHint(closePreview); + return hint ? '
    ' + hint + "
    " : ""; + })() + + (p.target_index != null + ? (function () { + const eth = p.eth_amount != null ? Number(p.eth_amount) + : (Number(p.pos) > 0 ? Number(p.pos) * Number(p.ct_mult || 0.01) : null); + const strike = Number(p.strike); + const tgt = Number(p.target_index); + const prem = Number(p.premium_paid); + let profit = null; + let value = null; + if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) { + const o = String(p.opt_type || "").toUpperCase(); + const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null; + if (intrinsic != null) { + value = Math.round(intrinsic * eth * 100) / 100; + if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100; + } + } + const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC"); + const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : ""; + const hedgeTarget = p.hedge_plan_target || null; + const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan"; + const profitSpan = hidePnl + ? "" + : '预估盈利 ' + profitTxt + ""; + return ( + '
    ' + + '' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "" + + '目标 ' + fmt(p.target_index, 1) + "" + + '价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "" + + profitSpan + + '' + + (managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") + + "
    " + ); + })() + : "") + ); + } + + function renderCard(p, opts) { + opts = opts || {}; + const hub = !!opts.hub; + const extraCls = hub ? " hub-pos-card hub-opt-pos-card" : " opt-pos-card"; + return ( + '
    ' + + renderCardInner(p, opts) + + "
    " + ); + } + + global.OptionsPositionCards = { + renderCardInner: renderCardInner, + renderCard: renderCard, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js new file mode 100644 index 0000000..88d2540 --- /dev/null +++ b/lib/common/static/options_review.js @@ -0,0 +1,1362 @@ +/** + * OKX 期权复盘:待复盘交易(5行) → 点复盘出表单 → 复盘记录详情 → 统计. + */ +(function (global) { + "use strict"; + + var PAGE_SIZE = 5; + var TAB_LABELS = { + option_spot: "期权交易记录", + options_options: "期期对冲记录", + perp_options: "永期对冲记录", + }; + var FORM_PRESETS = { + option_spot: { + strategy: ["顺势", "反转"], + direction: ["多", "空"], + entry: ["假突破", "结构突破"], + }, + hedge: { + strategy: ["横盘", "趋势"], + direction: ["多", "空"], + entry: ["横盘博弈方向", "趋势对冲止损"], + }, + }; + var RESULT_OPTIONS = ["盈利", "亏损", "持平"]; + var activeSource = "option_spot"; + var currentTradeId = null; + var draftId = ""; + var tradesCache = {}; + var reviewedCache = {}; + var tradesPage = 0; + var tradesPages = 1; + var reviewedPage = 0; + var reviewedPages = 1; + + function $(id) { + return document.getElementById(id); + } + + function escapeHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function fmtPnl(v) { + if (v == null || v === "") return "—"; + var n = Number(v); + if (Number.isNaN(n)) return "—"; + return (n >= 0 ? "+" : "") + n.toFixed(2); + } + + function fmtHold(sec) { + if (sec == null) return "—"; + var s = Math.max(0, Number(sec) || 0); + if (s < 3600) return Math.round(s / 60) + "m"; + if (s < 86400) return (s / 3600).toFixed(1) + "h"; + return (s / 86400).toFixed(1) + "d"; + } + + function toLocalInput(ts) { + if (!ts) return ""; + var s = String(ts).trim().replace(" ", "T"); + if (s.length >= 16) return s.slice(0, 16); + return s; + } + + function closeReasonLabel(r) { + var map = { + perp_tp: "永续止盈", + perp_sl: "永续止损", + oo_expiry_loss: "期期到期亏损", + oo_expiry_win: "期期到期盈利", + target_win_leg: "期期平盈利腿", + target_up_win_leg: "期期上破·平盈利腿", + target_down_win_leg: "期期下破·平盈利腿", + profit_rr_win_leg: "期期盈亏比达标·平盈利腿", + oo_rest_closing: "期期残值平·清亏损腿中", + oo_rest_closed: "期期残值平·两腿已平", + orphaned_after_tp: "止盈后持有至到期", + orphaned_option_expiry: "残腿到期", + hold_to_expiry: "持有至到期", + expiry: "到期", + manual: "人工结束", + partial_fail: "半腿失败", + cancelled: "已取消", + tp: "止盈", + sl: "止损", + }; + var key = String(r || "").trim(); + if (!key) return "—"; + return map[key] || key; + } + + function legRoleLabel(role) { + var map = { + perp: "永续腿", + option_hedge: "保险期权", + option_a: "期期腿A", + option_b: "期期腿B", + }; + var key = String(role || "").trim(); + if (!key) return "—"; + return map[key] || key; + } + + function tradeTitle(t) { + if (!t) return "—"; + if (t.source_type === "option_spot") return t.inst_id || "—"; + return ( + (t.underlying || "") + + (t.direction ? " " + t.direction : "") + + (t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : "") + ); + } + + function pnlClass(v) { + var n = Number(v); + if (n > 0) return "pos-pnl-profit"; + if (n < 0) return "pos-pnl-loss"; + return ""; + } + + function resultClass(tag) { + var t = String(tag || "").trim(); + if (t === "盈利") return "pos-pnl-profit"; + if (t === "亏损") return "pos-pnl-loss"; + return ""; + } + + function tradeContractLabel(t) { + if (!t) return "—"; + if (t.source_type === "option_spot") return t.inst_id || t.underlying || "—"; + return t.underlying || "—"; + } + + function newDraftId() { + if (global.crypto && typeof global.crypto.randomUUID === "function") { + return global.crypto.randomUUID().replace(/-/g, ""); + } + var s = ""; + for (var i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16); + return s; + } + + function baseQs() { + var p = new URLSearchParams(); + p.set("source_type", activeSource); + var uly = ($("or-filter-uly") || {}).value || ""; + var opt = ($("or-filter-opt") || {}).value || ""; + var q = (($("or-filter-q") || $("or-filter-strategy") || {}).value || "").trim(); + var from = ($("or-filter-from") || {}).value || ""; + var to = ($("or-filter-to") || {}).value || ""; + if (uly) p.set("underlying", uly); + if (opt) p.set("opt_type", opt); + if (q) p.set("q", q); + if (from) p.set("closed_from", from.replace("T", " ") + ":00"); + if (to) p.set("closed_to", to.replace("T", " ") + ":00"); + if (($("or-include-hedge-legs") || {}).checked) p.set("include_hedge_legs", "1"); + return p; + } + + function setSyncStatus(text) { + var el = $("or-sync-status"); + if (el) el.textContent = text || ""; + } + + function reloadAll() { + setSyncStatus("读取本地记录…"); + loadTrades({ sync: true }); + loadReviewed({ sync: false }); + loadStats(); + } + + function isHedgeSource(sourceType) { + return sourceType === "options_options" || sourceType === "perp_options"; + } + + function fillSelect(el, options, placeholder) { + if (!el) return; + var keep = el.value; + el.innerHTML = ""; + var first = document.createElement("option"); + first.value = ""; + first.textContent = placeholder || ""; + el.appendChild(first); + (options || []).forEach(function (v) { + var opt = document.createElement("option"); + opt.value = v; + opt.textContent = v; + el.appendChild(opt); + }); + if (keep) setSelectValue(el, keep); + } + + function setSelectValue(el, value) { + if (!el) return; + var v = value == null ? "" : String(value); + if (!v) { + el.value = ""; + return; + } + var found = false; + for (var i = 0; i < el.options.length; i++) { + if (el.options[i].value === v) { + found = true; + break; + } + } + if (!found) { + var opt = document.createElement("option"); + opt.value = v; + opt.textContent = v; + el.appendChild(opt); + } + el.value = v; + } + + function applyFormPresets(sourceType) { + var preset = isHedgeSource(sourceType) ? FORM_PRESETS.hedge : FORM_PRESETS.option_spot; + fillSelect($("or-f-strategy"), preset.strategy, "策略标签"); + fillSelect($("or-f-direction"), preset.direction, "方向判断"); + fillSelect($("or-f-entry"), preset.entry, "入场逻辑"); + fillSelect($("or-f-result"), RESULT_OPTIONS, "结果标签"); + } + + function autoDirection(t) { + if (!t) return ""; + if (isHedgeSource(t.source_type)) { + var d = String(t.direction || "").trim().toLowerCase(); + if (d === "long" || d === "buy" || d === "多") return "多"; + if (d === "short" || d === "sell" || d === "空") return "空"; + return ""; + } + var ot = String(t.opt_type || "").trim().toUpperCase(); + if (ot === "C" || ot === "CALL") return "多"; + if (ot === "P" || ot === "PUT") return "空"; + return ""; + } + + function autoResultTag(pnl) { + if (pnl == null || pnl === "") return ""; + var n = Number(pnl); + if (Number.isNaN(n)) return ""; + if (n > 0) return "盈利"; + if (n < 0) return "亏损"; + return "持平"; + } + + function tradeModeFromDom() { + var tabs = document.querySelector(".or-tabs"); + return (tabs && tabs.getAttribute("data-okx-trade-mode")) || "options"; + } + + function defaultSourceForMode(mode) { + if (mode === "options_options") return "options_options"; + if (mode === "perp_options") return "perp_options"; + return "option_spot"; + } + + function setActiveTab(source) { + var mode = tradeModeFromDom(); + var allowed = defaultSourceForMode(mode); + activeSource = source || allowed; + if (activeSource !== allowed) activeSource = allowed; + tradesPage = 0; + reviewedPage = 0; + document.querySelectorAll(".or-tab").forEach(function (btn) { + btn.classList.toggle("active", btn.getAttribute("data-source") === activeSource); + }); + var title = $("or-list-title"); + if (title) title.textContent = TAB_LABELS[activeSource] || "记录"; + applyFormPresets(activeSource); + hideJournalForm(); + hideDetail(); + reloadAll(); + } + + function updateTradesPager() { + var label = $("or-trades-page-label"); + var prev = $("or-trades-prev"); + var next = $("or-trades-next"); + if (label) { + label.textContent = "第 " + (tradesPage + 1) + " / " + tradesPages + " 页"; + } + if (prev) prev.disabled = tradesPage <= 0; + if (next) next.disabled = tradesPage + 1 >= tradesPages; + } + + function updateReviewedPager() { + var label = $("or-reviewed-page-label"); + var prev = $("or-reviewed-prev"); + var next = $("or-reviewed-next"); + if (label) { + label.textContent = "第 " + (reviewedPage + 1) + " / " + reviewedPages + " 页"; + } + if (prev) prev.disabled = reviewedPage <= 0; + if (next) next.disabled = reviewedPage + 1 >= reviewedPages; + } + + function applyPagerMeta(data, kind) { + var pages = Number(data.pages || 1); + if (!pages || pages < 1) pages = 1; + var clamped = false; + if (kind === "trades") { + tradesPages = pages; + if (tradesPage >= tradesPages) { + tradesPage = Math.max(0, tradesPages - 1); + clamped = true; + } + updateTradesPager(); + } else { + reviewedPages = pages; + if (reviewedPage >= reviewedPages) { + reviewedPage = Math.max(0, reviewedPages - 1); + clamped = true; + } + updateReviewedPager(); + } + return clamped; + } + + function beginListLoad(wrapId, soft) { + var wrap = $(wrapId); + if (!wrap) return null; + if (soft) { + if (!wrap.style.minHeight) { + wrap.style.minHeight = Math.max(wrap.offsetHeight, 1) + "px"; + } + wrap.classList.add("or-list-loading"); + } else { + wrap.classList.remove("or-list-loading"); + wrap.style.minHeight = ""; + } + return wrap; + } + + function endListLoad(wrap) { + if (!wrap) return; + wrap.classList.remove("or-list-loading"); + wrap.style.minHeight = ""; + } + + function loadTrades(opts) { + opts = opts || {}; + var doSync = opts.sync !== false; + var soft = !!opts.soft; + var tbody = $("or-trades-tbody"); + if (!tbody) return; + var wrap = beginListLoad("or-trades-wrap", soft); + if (!soft) { + tbody.innerHTML = '加载中…'; + } + var p = baseQs(); + // 交易记录保留已复盘条目,不再只显示待复盘 + p.set("limit", String(PAGE_SIZE)); + p.set("offset", String(tradesPage * PAGE_SIZE)); + if (!doSync) p.set("sync", "0"); + fetch("/api/options/review/trades?" + p.toString(), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (doSync) setSyncStatus("本地记录已加载"); + if (!data.ok) { + tbody.innerHTML = '加载失败'; + endListLoad(wrap); + return; + } + if (applyPagerMeta(data, "trades") && Number(data.total || 0) > 0) { + loadTrades(opts); + return; + } + var rows = data.trades || []; + tradesCache = {}; + if (!rows.length) { + tbody.innerHTML = + '暂无交易记录'; + endListLoad(wrap); + return; + } + tbody.innerHTML = rows + .map(function (t) { + tradesCache[t.id] = t; + var active = currentTradeId === t.id ? " or-row-active" : ""; + var reviewed = !!t.reviewed; + var actionBtn = reviewed + ? '' + : ''; + var badgeExtra = reviewed + ? ' 已复盘' + : ""; + return ( + '' + + "" + + escapeHtml(t.source_label || t.source_type) + + "" + + badgeExtra + + "" + + "" + + escapeHtml(tradeTitle(t)) + + "" + + '' + + fmtPnl(t.realized_pnl_total) + + "" + + '' + + escapeHtml(t.opened_at || "—") + + "" + + '' + + escapeHtml(t.closed_at || "—") + + "" + + "" + + fmtHold(t.hold_seconds) + + "" + + "" + + actionBtn + + " " + + '' + + "" + ); + }) + .join(""); + tbody.querySelectorAll(".or-review-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + openJournalForm(Number(btn.getAttribute("data-id"))); + }); + }); + tbody.querySelectorAll(".or-hide-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + hideTrade(Number(btn.getAttribute("data-id"))); + }); + }); + endListLoad(wrap); + }) + .catch(function () { + tbody.innerHTML = '加载失败'; + endListLoad(wrap); + }); + } + + function loadReviewed(opts) { + opts = opts || {}; + var doSync = opts.sync === true; + var soft = !!opts.soft; + var tbody = $("or-reviewed-tbody"); + if (!tbody) return; + var wrap = beginListLoad("or-reviewed-wrap", soft); + if (!soft) { + tbody.innerHTML = '加载中…'; + } + var p = baseQs(); + p.set("reviewed", "1"); + p.set("limit", String(PAGE_SIZE)); + p.set("offset", String(reviewedPage * PAGE_SIZE)); + if (!doSync) p.set("sync", "0"); + fetch("/api/options/review/trades?" + p.toString(), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) { + tbody.innerHTML = '加载失败'; + endListLoad(wrap); + return; + } + if (applyPagerMeta(data, "reviewed") && Number(data.total || 0) > 0) { + loadReviewed(opts); + return; + } + var rows = data.trades || []; + reviewedCache = {}; + if (!rows.length) { + tbody.innerHTML = '暂无复盘记录'; + endListLoad(wrap); + return; + } + tbody.innerHTML = rows + .map(function (t) { + reviewedCache[t.id] = t; + var entry = t.entry || {}; + var direction = t.direction_view || entry.direction_view || ""; + var entryLogic = t.entry_logic || entry.entry_logic || ""; + return ( + '' + + "" + + escapeHtml(t.source_label || t.source_type) + + "" + + "" + + escapeHtml(tradeContractLabel(t)) + + "" + + "" + + escapeHtml(direction || "—") + + "" + + '' + + fmtPnl(t.realized_pnl_total) + + "" + + '' + + escapeHtml(t.opened_at || "—") + + "" + + '' + + escapeHtml(t.closed_at || "—") + + "" + + "" + + escapeHtml(fmtHold(t.hold_seconds)) + + "" + + "" + + escapeHtml(t.strategy_tag || "—") + + "" + + "" + + escapeHtml(entryLogic || "—") + + "" + + '' + + escapeHtml(t.result_tag || "—") + + "" + + '' + + escapeHtml(t.reviewed_at || "—") + + "" + + "" + ); + }) + .join(""); + tbody.querySelectorAll(".or-reviewed-row").forEach(function (tr) { + tr.addEventListener("click", function () { + openDetail(Number(tr.getAttribute("data-id"))); + }); + }); + endListLoad(wrap); + }) + .catch(function () { + tbody.innerHTML = '加载失败'; + endListLoad(wrap); + }); + } + + function hideLightbox() { + var box = $("or-img-lightbox"); + if (box) box.hidden = true; + var img = $("or-img-lightbox-img"); + if (img) img.src = ""; + } + + function showLightbox(src) { + var url = String(src || "").trim(); + if (!url) return; + var box = $("or-img-lightbox"); + var img = $("or-img-lightbox-img"); + if (box && img) { + img.src = url; + box.hidden = false; + return; + } + if (typeof global.showImage === "function") { + global.showImage(url); + } else if (typeof window.showImage === "function") { + window.showImage(url); + } else { + global.open(url, "_blank"); + } + } + + function hideDetail() { + hideLightbox(); + var backdrop = $("or-detail-backdrop"); + if (backdrop) backdrop.hidden = true; + } + + function openDetail(tradeId) { + var backdrop = $("or-detail-backdrop"); + if (!backdrop) return; + backdrop.hidden = false; + ($("or-detail-title") || {}).textContent = "加载中…"; + ($("or-detail-meta") || {}).innerHTML = ""; + ($("or-detail-text") || {}).innerHTML = ""; + ($("or-detail-images") || {}).innerHTML = ""; + + fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok || !data.trade) { + ($("or-detail-title") || {}).textContent = "加载失败"; + return; + } + renderDetail(data.trade); + }) + .catch(function () { + ($("or-detail-title") || {}).textContent = "加载失败"; + }); + } + + function optionsJournalImgSrc(file) { + var name = String(file || "").trim().replace(/\\/g, "/"); + var slash = name.lastIndexOf("/"); + if (slash >= 0) name = name.slice(slash + 1); + if (!name) return ""; + // options_journal_* 在子目录;误走合约上传的 journal_* 在 static/images 根目录 + var base = + name.toLowerCase().indexOf("options_journal_") === 0 + ? "/static/images/options_journal/" + : "/static/images/"; + return base + encodeURIComponent(name); + } + + function renderDetailImages(images) { + var imagesHost = $("or-detail-images"); + if (!imagesHost) return; + var byTf = {}; + (images || []).forEach(function (img) { + var tf = String((img && img.tf) || "").trim(); + var file = String((img && img.file) || "").trim(); + if (!file) return; + var key = tf || "_"; + byTf[key] = file; + }); + var order = ["5m", "15m", "1h", "4h"]; + var keys = order.slice(); + Object.keys(byTf).forEach(function (k) { + if (keys.indexOf(k) < 0) keys.push(k); + }); + var cells = keys + .map(function (tf) { + var file = byTf[tf]; + if (!file) { + if (order.indexOf(tf) < 0) return ""; + return ( + '
    ' + + '' + + escapeHtml(tf) + + "" + + '
    未上传
    ' + + "
    " + ); + } + var src = optionsJournalImgSrc(file); + var label = escapeHtml(tf === "_" ? "截图" : tf); + return ( + '
    ' + + '' + + label + + "" + + '' +
+          label +
+          '' + + "
    " + ); + }) + .filter(Boolean); + if (!cells.length) { + imagesHost.innerHTML = '
    无截图
    '; + return; + } + imagesHost.innerHTML = cells.join(""); + imagesHost.querySelectorAll("img").forEach(function (img) { + img.addEventListener("error", function () { + var cell = img.closest(".or-detail-img-cell"); + if (!cell) return; + var label = cell.querySelector(".or-detail-img-label"); + var tf = label ? label.textContent : "截图"; + cell.innerHTML = + '' + + escapeHtml(tf) + + "" + + '
    文件缺失或无法加载
    '; + }); + img.addEventListener("click", function () { + var src = img.getAttribute("data-src") || img.src; + showLightbox(src); + }); + }); + } + + function renderDetail(t) { + var e = t.entry || {}; + reviewedCache[t.id] = t; + ($("or-detail-title") || {}).textContent = + "复盘详情 · " + (t.source_label || "") + " · " + tradeTitle(t); + var editBtn = $("or-detail-edit-btn"); + if (editBtn) editBtn.setAttribute("data-id", String(t.id)); + + var meta = $("or-detail-meta"); + if (meta) { + var cells = [ + ["标的", t.underlying || "—"], + ["合约/计划", tradeTitle(t)], + ["盈亏", fmtPnl(t.realized_pnl_total)], + ["持有", fmtHold(t.hold_seconds)], + ["开仓时间", t.opened_at || "—"], + ["平仓时间", t.closed_at || "—"], + ["策略", e.strategy_tag || "—"], + ["方向", e.direction_view || "—"], + ["结果", e.result_tag || "—"], + ["离场", e.exit_reason || "—"], + ["按计划", e.followed_plan || "—"], + ["入场逻辑", e.entry_logic || "—"], + ]; + if (t.is_hedge) { + cells.push(["永续盈亏", fmtPnl(t.realized_pnl_perp)]); + cells.push(["期权盈亏", fmtPnl(t.realized_pnl_options)]); + } + meta.innerHTML = cells + .map(function (pair) { + var cls = ""; + if (pair[0] === "盈亏" || pair[0] === "永续盈亏" || pair[0] === "期权盈亏") { + cls = pnlClass(t.realized_pnl_total); + if (pair[0] === "永续盈亏") cls = pnlClass(t.realized_pnl_perp); + if (pair[0] === "期权盈亏") cls = pnlClass(t.realized_pnl_options); + } else if (pair[0] === "结果") { + cls = resultClass(e.result_tag); + } + return ( + "
    " + + escapeHtml(pair[0]) + + '
    ' + + escapeHtml(pair[1]) + + "
    " + ); + }) + .join(""); + } + + var text = $("or-detail-text"); + if (text) { + var lines = []; + if (e.mistake_tags) lines.push("
    心理标签:" + escapeHtml(e.mistake_tags) + "
    "); + if (e.note) lines.push("
    备注:" + escapeHtml(e.note).replace(/\n/g, "
    ") + "
    "); + if (t.legs && t.legs.length) { + lines.push( + "
    计划腿
    " + + t.legs + .map(function (leg) { + return ( + "" + ); + }) + .join("") + + "
    合约盈亏原因
    " + + escapeHtml(legRoleLabel(leg.leg_role)) + + "" + + escapeHtml(leg.inst_id || leg.symbol || "") + + "" + + fmtPnl(leg.realized_pnl) + + "" + + escapeHtml(closeReasonLabel(leg.close_reason)) + + "
    " + ); + } + text.innerHTML = lines.join("") || '
    无额外备注
    '; + } + + var imagesHost = $("or-detail-images"); + if (imagesHost) { + renderDetailImages(e.images || []); + } + } + + function renderGroup(title, items) { + if (!items || !items.length) return ""; + var lines = items + .slice(0, 8) + .map(function (g) { + var keyLabel = + title === "对冲结束原因" ? closeReasonLabel(g.key) : String(g.key || ""); + return ( + '
    ' + + '' + + escapeHtml(keyLabel) + + " · " + + g.count + + "笔" + + '' + + fmtPnl(g.pnl_sum) + + " / 胜" + + (g.win_rate || 0) + + "%" + + "
    " + ); + }) + .join(""); + return ( + '
    ' + + title + + "
    " + + lines + + "
    " + ); + } + + function loadStats() { + var kpi = $("or-kpi"); + var groups = $("or-stats-groups"); + if (!kpi || !groups) return; + fetch("/api/options/review/stats?" + baseQs().toString(), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) return; + var k = data.kpi || {}; + kpi.innerHTML = [ + ["笔数", k.total], + ["已复盘率", (k.review_rate || 0) + "%"], + ["胜率", (k.win_rate || 0) + "%"], + ["累计盈亏", fmtPnl(k.pnl_sum)], + ["平均盈亏", fmtPnl(k.avg_pnl)], + ["平均持有", fmtHold(k.avg_hold_sec)], + ] + .map(function (pair) { + var cls = ""; + if (pair[0] === "累计盈亏") cls = pnlClass(k.pnl_sum); + if (pair[0] === "平均盈亏") cls = pnlClass(k.avg_pnl); + return ( + '
    ' + + pair[0] + + '
    ' + + pair[1] + + "
    " + ); + }) + .join(""); + var html = [ + renderGroup("按类型", data.by_source_type), + renderGroup("按标的", data.by_underlying), + renderGroup("按策略", data.by_strategy), + renderGroup("对冲结束原因", data.by_close_reason), + renderGroup("持有周期", data.by_hold_bucket), + renderGroup("Call/Put", data.by_opt_type), + ] + .filter(Boolean) + .join(""); + groups.innerHTML = html || '
    暂无分组数据
    '; + }) + .catch(function () {}); + } + + function resetUploadSlots() { + draftId = newDraftId(); + var draftEl = $("or-draft-id"); + if (draftEl) draftEl.value = draftId; + document.querySelectorAll("#or-upload-slots .or-upload-hidden").forEach(function (el) { + el.value = ""; + }); + document.querySelectorAll("#or-upload-slots .or-upload-input").forEach(function (el) { + el.value = ""; + }); + document.querySelectorAll("#or-upload-slots .or-upload-status").forEach(function (el) { + el.textContent = ""; + }); + } + + function bindUploadSlots() { + document.querySelectorAll("#or-upload-slots .or-upload-input").forEach(function (input) { + if (input.dataset.orBound === "1") return; + input.dataset.orBound = "1"; + input.addEventListener("change", function () { + var file = input.files && input.files[0]; + var row = input.closest(".journal-upload-row"); + var status = row && row.querySelector(".or-upload-status"); + var hidden = row && row.querySelector(".or-upload-hidden"); + if (!file) { + if (hidden) hidden.value = ""; + if (status) { + status.textContent = ""; + status.className = "journal-upload-status or-upload-status"; + } + return; + } + if (!draftId) draftId = newDraftId(); + if (status) { + status.textContent = "上传中…"; + status.className = "journal-upload-status or-upload-status journal-upload-status--pending"; + } + var fd = new FormData(); + fd.append("draft_id", draftId); + fd.append("tf", input.getAttribute("data-tf") || ""); + fd.append("file", file); + fetch("/api/options/review/upload_slot", { + method: "POST", + body: fd, + credentials: "same-origin", + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) throw new Error(data.error || "fail"); + if (hidden) hidden.value = data.file; + if (status) { + status.textContent = "上传成功 " + data.file; + status.className = "journal-upload-status or-upload-status journal-upload-status--ok"; + } + input.value = ""; + }) + .catch(function () { + if (hidden) hidden.value = ""; + if (status) { + status.textContent = "上传失败"; + status.className = "journal-upload-status or-upload-status journal-upload-status--err"; + } + }); + }); + }); + } + + function setMoodTags(raw) { + var set = {}; + String(raw || "") + .split(/[,,]/) + .map(function (x) { + return x.trim(); + }) + .filter(Boolean) + .forEach(function (x) { + set[x] = true; + }); + document.querySelectorAll(".or-mood").forEach(function (cb) { + cb.checked = !!set[cb.value]; + }); + } + + function collectMoodTags() { + var out = []; + document.querySelectorAll(".or-mood:checked").forEach(function (cb) { + out.push(cb.value); + }); + return out.join(","); + } + + function collectImages() { + var out = []; + document.querySelectorAll("#or-upload-slots .or-upload-hidden").forEach(function (el) { + var file = (el.value || "").trim(); + if (file) out.push({ tf: el.getAttribute("data-tf") || "", file: file }); + }); + return out; + } + + function hideJournalForm() { + currentTradeId = null; + var card = $("or-journal-card"); + if (card) card.classList.add("hidden"); + document.querySelectorAll(".or-trade-row").forEach(function (tr) { + tr.classList.remove("or-row-active"); + }); + ($("or-trade-id") || {}).value = ""; + ($("or-f-open") || {}).value = ""; + ($("or-f-close") || {}).value = ""; + ($("or-f-coin") || {}).value = ""; + ($("or-f-inst") || {}).value = ""; + ($("or-f-pnl") || {}).value = ""; + ($("or-f-hold") || {}).value = ""; + ($("or-f-strategy") || {}).value = ""; + ($("or-f-direction") || {}).value = ""; + ($("or-f-exit") || {}).value = ""; + ($("or-f-followed") || {}).value = ""; + ($("or-f-result") || {}).value = ""; + ($("or-f-entry") || {}).value = ""; + ($("or-f-note") || {}).value = ""; + setMoodTags(""); + resetUploadSlots(); + var summary = $("or-journal-summary"); + if (summary) { + summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h)."; + } + var legsHost = $("or-legs-host"); + if (legsHost) legsHost.innerHTML = ""; + ($("or-save-status") || {}).textContent = ""; + } + + function openJournalForm(tradeId) { + currentTradeId = tradeId; + var card = $("or-journal-card"); + if (!card) return; + card.classList.remove("hidden"); + card.scrollIntoView({ behavior: "smooth", block: "start" }); + document.querySelectorAll(".or-trade-row").forEach(function (tr) { + tr.classList.toggle("or-row-active", Number(tr.getAttribute("data-id")) === tradeId); + }); + resetUploadSlots(); + bindUploadSlots(); + ($("or-save-status") || {}).textContent = "加载中…"; + + fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok || !data.trade) { + ($("or-save-status") || {}).textContent = "加载失败"; + return; + } + fillForm(data.trade); + ($("or-save-status") || {}).textContent = "已选中 #" + tradeId; + }) + .catch(function () { + ($("or-save-status") || {}).textContent = "加载失败"; + }); + } + + function fillForm(t) { + var e = t.entry || {}; + applyFormPresets(t.source_type || activeSource); + ($("or-trade-id") || {}).value = String(t.id || ""); + ($("or-f-open") || {}).value = toLocalInput(t.opened_at); + ($("or-f-close") || {}).value = toLocalInput(t.closed_at); + ($("or-f-coin") || {}).value = t.underlying || ""; + ($("or-f-inst") || {}).value = + t.source_type === "option_spot" + ? t.inst_id || "" + : (t.source_label || "") + + (t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : ""); + ($("or-f-pnl") || {}).value = fmtPnl(t.realized_pnl_total); + ($("or-f-hold") || {}).value = fmtHold(t.hold_seconds); + setSelectValue($("or-f-strategy"), e.strategy_tag || ""); + setSelectValue($("or-f-direction"), e.direction_view || autoDirection(t)); + ($("or-f-exit") || {}).value = e.exit_reason || closeReasonLabel(t.plan_close_reason) || ""; + ($("or-f-followed") || {}).value = e.followed_plan || ""; + setSelectValue($("or-f-result"), e.result_tag || autoResultTag(t.realized_pnl_total)); + setSelectValue($("or-f-entry"), e.entry_logic || ""); + ($("or-f-note") || {}).value = e.note || ""; + setMoodTags(e.mistake_tags); + + var summary = $("or-journal-summary"); + if (summary) { + summary.textContent = + (t.source_label || "") + + " · " + + (t.inst_id || t.underlying || "#" + t.id) + + " · 盈亏 " + + fmtPnl(t.realized_pnl_total) + + (t.is_hedge + ? " (永续 " + fmtPnl(t.realized_pnl_perp) + " / 期权 " + fmtPnl(t.realized_pnl_options) + ")" + : ""); + } + + (e.images || []).forEach(function (img) { + var hidden = document.querySelector( + '#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]' + ); + var status = document.querySelector( + '#or-upload-slots .or-upload-status[data-tf="' + img.tf + '"]' + ); + if (hidden && img.file) { + hidden.value = img.file; + if (status) { + var src = optionsJournalImgSrc(img.file); + status.innerHTML = + '已有 ' + + escapeHtml(img.file) + + '
    ' +
+            escapeHtml(img.tf || '; + status.className = + "journal-upload-status or-upload-status journal-upload-status--ok"; + } + } + }); + + var legsHost = $("or-legs-host"); + if (legsHost) { + if (t.legs && t.legs.length) { + legsHost.innerHTML = + "

    计划腿

    " + + t.legs + .map(function (leg) { + return ( + "" + ); + }) + .join("") + + "
    合约盈亏原因
    " + + escapeHtml(legRoleLabel(leg.leg_role)) + + "" + + escapeHtml(leg.inst_id || leg.symbol || "") + + "" + + fmtPnl(leg.realized_pnl) + + "" + + escapeHtml(closeReasonLabel(leg.close_reason)) + + "
    "; + } else { + legsHost.innerHTML = ""; + } + } + } + + function hideTrade(tradeId) { + if (!tradeId) return; + if (!confirm("从待复盘列表删除并隐藏?刷新后也不会再出现.")) return; + fetch("/api/options/review/trades/" + tradeId, { + method: "DELETE", + credentials: "same-origin", + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) { + alert(data.msg || "删除失败"); + return; + } + if (currentTradeId === tradeId) hideJournalForm(); + reloadAll(); + }) + .catch(function () { + alert("删除失败"); + }); + } + + function saveEntry() { + var tradeId = Number(($("or-trade-id") || {}).value || 0); + if (!tradeId) { + alert("请先点击交易记录中的「复盘」"); + return; + } + var strategy = (($("or-f-strategy") || {}).value || "").trim(); + if (!strategy) { + alert("请选择策略标签"); + return; + } + var payload = { + trade_id: tradeId, + strategy_tag: strategy, + direction_view: ($("or-f-direction") || {}).value || "", + exit_reason: ($("or-f-exit") || {}).value || "", + followed_plan: ($("or-f-followed") || {}).value || "", + result_tag: ($("or-f-result") || {}).value || "", + mistake_tags: collectMoodTags(), + entry_logic: ($("or-f-entry") || {}).value || "", + note: ($("or-f-note") || {}).value || "", + images: collectImages(), + }; + ($("or-save-status") || {}).textContent = "保存中…"; + fetch("/api/options/review/entry", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) { + ($("or-save-status") || {}).textContent = data.msg || "保存失败"; + return; + } + ($("or-save-status") || {}).textContent = "已保存"; + hideJournalForm(); + reloadAll(); + openDetail(tradeId); + }) + .catch(function () { + ($("or-save-status") || {}).textContent = "保存失败"; + }); + } + + function deleteEntry() { + var tradeId = Number(($("or-trade-id") || {}).value || 0); + if (!tradeId) return; + if (!confirm("删除该条复盘内容与图片?交易记录会回到待复盘列表.")) return; + fetch("/api/options/review/entry/" + tradeId, { + method: "DELETE", + credentials: "same-origin", + }) + .then(function (r) { + return r.json(); + }) + .then(function () { + hideJournalForm(); + hideDetail(); + reloadAll(); + }); + } + + function init() { + if (!$("options-review-root")) return; + document.querySelectorAll(".or-tab").forEach(function (btn) { + btn.addEventListener("click", function () { + setActiveTab(btn.getAttribute("data-source")); + }); + }); + var reloadBtn = $("or-reload-btn"); + var saveBtn = $("or-save-btn"); + var clearBtn = $("or-clear-btn"); + var delBtn = $("or-del-btn"); + var prevBtn = $("or-trades-prev"); + var nextBtn = $("or-trades-next"); + var reviewedPrev = $("or-reviewed-prev"); + var reviewedNext = $("or-reviewed-next"); + var detailClose = $("or-detail-close-btn"); + var detailEdit = $("or-detail-edit-btn"); + if (reloadBtn) reloadBtn.addEventListener("click", reloadAll); + if (saveBtn) saveBtn.addEventListener("click", saveEntry); + if (clearBtn) clearBtn.addEventListener("click", hideJournalForm); + if (delBtn) delBtn.addEventListener("click", deleteEntry); + if (prevBtn) { + prevBtn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (tradesPage <= 0) return; + tradesPage -= 1; + updateTradesPager(); + loadTrades({ sync: false, soft: true }); + }); + } + if (nextBtn) { + nextBtn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (tradesPage + 1 >= tradesPages) return; + tradesPage += 1; + updateTradesPager(); + loadTrades({ sync: false, soft: true }); + }); + } + if (reviewedPrev) { + reviewedPrev.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (reviewedPage <= 0) return; + reviewedPage -= 1; + updateReviewedPager(); + loadReviewed({ sync: false, soft: true }); + }); + } + if (reviewedNext) { + reviewedNext.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (reviewedPage + 1 >= reviewedPages) return; + reviewedPage += 1; + updateReviewedPager(); + loadReviewed({ sync: false, soft: true }); + }); + } + if (detailClose) detailClose.addEventListener("click", hideDetail); + if (detailEdit) { + detailEdit.addEventListener("click", function () { + var id = Number(detailEdit.getAttribute("data-id") || 0); + if (id) { + hideDetail(); + openJournalForm(id); + } + }); + } + var detailBackdrop = $("or-detail-backdrop"); + if (detailBackdrop) { + detailBackdrop.addEventListener("click", function (ev) { + if (ev.target === detailBackdrop) hideDetail(); + }); + } + var lightbox = $("or-img-lightbox"); + if (lightbox) { + lightbox.addEventListener("click", function () { + hideLightbox(); + }); + } + document.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape") return; + var lb = $("or-img-lightbox"); + if (lb && !lb.hidden) { + hideLightbox(); + return; + } + var bd = $("or-detail-backdrop"); + if (bd && !bd.hidden) hideDetail(); + }); + ["or-filter-uly", "or-filter-opt", "or-include-hedge-legs"].forEach(function (id) { + var el = $(id); + if (el) { + el.addEventListener("change", function () { + tradesPage = 0; + reviewedPage = 0; + reloadAll(); + }); + } + }); + ["or-filter-q", "or-filter-strategy", "or-filter-from", "or-filter-to"].forEach(function (id) { + var el = $(id); + if (el) { + el.addEventListener("change", function () { + tradesPage = 0; + reviewedPage = 0; + reloadAll(); + }); + } + }); + bindUploadSlots(); + hideJournalForm(); + hideDetail(); + hardenSearchAutofill(); + setActiveTab(defaultSourceForMode(tradeModeFromDom())); + } + + function hardenSearchAutofill() { + var qEl = $("or-filter-q"); + if (!qEl) return; + function wipe() { + qEl.value = ""; + } + wipe(); + qEl.addEventListener("focus", function () { + qEl.removeAttribute("readonly"); + }); + qEl.addEventListener("blur", function () { + if (!qEl.value) qEl.setAttribute("readonly", "readonly"); + }); + // 密码管理器常延后写入用户名,加载后再清两次 + setTimeout(wipe, 200); + setTimeout(wipe, 800); + } + + global.OptionsReview = { + init: init, + openJournalForm: openJournalForm, + hideJournalForm: hideJournalForm, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/options_settings.js b/lib/common/static/options_settings.js new file mode 100644 index 0000000..6a0895a --- /dev/null +++ b/lib/common/static/options_settings.js @@ -0,0 +1,312 @@ +(function () { + "use strict"; + + const root = document.getElementById("options-settings-root"); + if (!root) return; + + const SWAP_BTNS = ["opt-set-swap-btn", "opt-set-swap-all-btn"]; + const INT_BTNS = ["opt-set-int-btn", "opt-set-int-all-btn"]; + + async function apiJson(url, opts) { + const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); + return r.json(); + } + + function refreshFundsAfterMutation() { + if (typeof refreshAccountSnapshot !== "function") return; + refreshAccountSnapshot({ force: true }); + setTimeout(function () { + refreshAccountSnapshot({ force: true, silent: true }); + }, 1500); + } + + function setMsg(id, text, isErr) { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text || ""; + el.classList.toggle("opt-error", !!isErr); + el.classList.toggle("opt-success", !!text && !isErr); + } + + function fmtAmt(amount, ccy) { + return `${Number(amount).toFixed(2)} ${ccy}`; + } + + function accountLabel(acct) { + return acct === "trading" ? "交易账户" : "资金账户"; + } + + function swapDirLabel(dir) { + return dir === "usdc_to_usdt" ? "USDC → USDT" : "USDT → USDC"; + } + + function confirmOk(message) { + return window.confirm(message); + } + + function setButtonsBusy(btnIds, busy, busyText) { + btnIds.forEach(function (id) { + const btn = document.getElementById(id); + if (!btn) return; + if (busy) { + if (!btn.dataset.origText) btn.dataset.origText = btn.textContent; + btn.disabled = true; + if (busyText) btn.textContent = busyText; + } else { + btn.disabled = false; + if (btn.dataset.origText) { + btn.textContent = btn.dataset.origText; + delete btn.dataset.origText; + } + } + }); + const amountIds = { + "opt-set-swap-btn": "opt-set-swap-amount", + "opt-set-swap-all-btn": "opt-set-swap-amount", + "opt-set-int-btn": "opt-set-int-amount", + "opt-set-int-all-btn": "opt-set-int-amount", + "opt-set-cross-btn": "opt-set-cross-amount", + "opt-set-cross-all-btn": "opt-set-cross-amount", + }; + btnIds.forEach(function (id) { + const input = document.getElementById(amountIds[id]); + if (input) input.disabled = busy; + }); + } + + function roundAvail(v) { + const n = Number(v); + if (!Number.isFinite(n) || n <= 0) return null; + return Math.round(n * 100) / 100; + } + + async function loadBalances(force, scope) { + const parts = []; + if (force) parts.push("force=1"); + if (scope && scope !== "main") parts.push("scope=" + encodeURIComponent(scope)); + const q = parts.length ? "?" + parts.join("&") : ""; + const d = await apiJson("/api/options/balances" + q); + if (!d.ok) throw new Error(d.msg || "余额拉取失败"); + return d; + } + + function pickBalance(bal, account, ccy) { + const acct = account === "trading" ? "trading" : "funding"; + const c = String(ccy || "").toLowerCase(); + const availKey = acct + "_" + c + "_avail"; + const totalKey = acct + "_" + c; + return roundAvail(bal[availKey] != null ? bal[availKey] : bal[totalKey]); + } + + async function resolveSwapMaxAmount(dir) { + const bal = await loadBalances(true, "main"); + const ccy = dir === "usdc_to_usdt" ? "USDC" : "USDT"; + // 币种兑换走资金账户现货;统一账户下 USDT 有时在交易户,市价单仍可能成交 + let amount = pickBalance(bal, "funding", ccy); + let source = "funding"; + if (!amount && ccy === "USDT") { + const tradingAmt = pickBalance(bal, "trading", ccy); + if (tradingAmt) { + amount = tradingAmt; + source = "trading"; + } + } + return { amount, bal, ccy, source }; + } + + async function resolveMaxAmount(account, ccy, scope) { + const bal = await loadBalances(true, scope || "main"); + return pickBalance(bal, account, ccy); + } + + async function submitSwap(amount) { + setButtonsBusy(SWAP_BTNS, true, "兑换中…"); + setMsg("opt-set-swap-msg", "兑换中,市价成交可能有延时…", false); + try { + const d = await apiJson("/api/options/spot/swap", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + direction: document.getElementById("opt-set-swap-dir").value, + amount: amount, + }), + }); + if (d.ok) { + setMsg("opt-set-swap-msg", "兑换成功", false); + refreshFundsAfterMutation(); + } else { + setMsg("opt-set-swap-msg", "兑换失败:" + (d.msg || "未知错误"), true); + } + return d; + } catch (e) { + setMsg("opt-set-swap-msg", "兑换失败:" + (e.message || "网络错误"), true); + return { ok: false }; + } finally { + setButtonsBusy(SWAP_BTNS, false); + } + } + + const swapBtn = document.getElementById("opt-set-swap-btn"); + if (swapBtn) { + swapBtn.addEventListener("click", async function () { + const amount = parseFloat(document.getElementById("opt-set-swap-amount").value); + if (!amount || amount <= 0) { + setMsg("opt-set-swap-msg", "请输入有效数量", true); + return; + } + await submitSwap(amount); + }); + } + + const swapAllBtn = document.getElementById("opt-set-swap-all-btn"); + if (swapAllBtn) { + swapAllBtn.addEventListener("click", async function () { + try { + const dir = document.getElementById("opt-set-swap-dir").value; + const { amount, bal, ccy, source } = await resolveSwapMaxAmount(dir); + if (!amount) { + const fu = bal.funding_usdt_avail != null ? bal.funding_usdt_avail : bal.funding_usdt; + const tu = bal.trading_usdt_avail != null ? bal.trading_usdt_avail : bal.trading_usdt; + const fc = bal.funding_usdc_avail != null ? bal.funding_usdc_avail : bal.funding_usdc; + setMsg( + "opt-set-swap-msg", + "资金账户可用 " + + ccy + + " 不足(资金户 USDT:" + + (fu != null ? fu : "—") + + " USDC:" + + (fc != null ? fc : "—") + + "; 交易户 USDT:" + + (tu != null ? tu : "—") + + ")", + true + ); + return; + } + const srcLabel = source === "trading" ? "交易账户" : "资金账户"; + const msg = + "确认全部兑换?\n\n" + + "方向:" + swapDirLabel(dir) + "\n" + + "金额:" + fmtAmt(amount, ccy) + "\n" + + "来源:" + srcLabel + "\n\n" + + "将按该账户可用余额发起市价兑换(可能有延时)。请确认。"; + if (!confirmOk(msg)) return; + document.getElementById("opt-set-swap-amount").value = String(amount); + await submitSwap(amount); + } catch (e) { + setMsg("opt-set-swap-msg", "兑换失败:" + (e.message || "余额拉取失败"), true); + } + }); + } + + async function submitInternalTransfer(amount) { + setButtonsBusy(INT_BTNS, true, "划转中…"); + setMsg("opt-set-int-msg", "划转中…", false); + try { + const d = await apiJson("/api/options/transfer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ccy: document.getElementById("opt-set-int-ccy").value, + from: document.getElementById("opt-set-int-from").value, + to: document.getElementById("opt-set-int-to").value, + amount: amount, + }), + }); + if (d.ok) { + setMsg("opt-set-int-msg", "划转成功", false); + refreshFundsAfterMutation(); + } else { + setMsg("opt-set-int-msg", "划转失败:" + (d.msg || "未知错误"), true); + } + return d; + } catch (e) { + setMsg("opt-set-int-msg", "划转失败:" + (e.message || "网络错误"), true); + return { ok: false }; + } finally { + setButtonsBusy(INT_BTNS, false); + } + } + + const intBtn = document.getElementById("opt-set-int-btn"); + if (intBtn) { + intBtn.addEventListener("click", async function () { + const amount = parseFloat(document.getElementById("opt-set-int-amount").value); + if (!amount || amount <= 0) { + setMsg("opt-set-int-msg", "请输入有效数量", true); + return; + } + await submitInternalTransfer(amount); + }); + } + + const intAllBtn = document.getElementById("opt-set-int-all-btn"); + if (intAllBtn) { + intAllBtn.addEventListener("click", async function () { + try { + const ccy = document.getElementById("opt-set-int-ccy").value; + const from = document.getElementById("opt-set-int-from").value; + const to = document.getElementById("opt-set-int-to").value; + const amount = await resolveMaxAmount(from, ccy, "main"); + if (!amount) { + setMsg("opt-set-int-msg", "划出账户可用余额不足", true); + return; + } + const msg = + "确认全部划转?\n\n" + + "币种:" + ccy + "\n" + + "划出:" + accountLabel(from) + "\n" + + "划入:" + accountLabel(to) + "\n" + + "金额:" + fmtAmt(amount, ccy) + "\n\n" + + "将划转该账户全部可用余额。"; + if (!confirmOk(msg)) return; + document.getElementById("opt-set-int-amount").value = String(amount); + await submitInternalTransfer(amount); + } catch (e) { + setMsg("opt-set-int-msg", "划转失败:" + (e.message || "余额拉取失败"), true); + } + }); + } + + function hardenAmountAutofill(ids) { + ids.forEach(function (id) { + const el = document.getElementById(id); + if (!el) return; + function wipe() { + const v = String(el.value || "").trim(); + if (/^[a-z][a-z0-9._-]{1,31}$/i.test(v)) el.value = ""; + } + wipe(); + el.setAttribute("readonly", "readonly"); + el.addEventListener("focus", function () { + el.removeAttribute("readonly"); + }); + el.addEventListener("blur", function () { + if (!el.value) el.setAttribute("readonly", "readonly"); + }); + setTimeout(wipe, 200); + setTimeout(wipe, 800); + setTimeout(wipe, 2000); + }); + } + + // 全部划转/兑换前去掉 readonly,避免写不进数量 + ["opt-set-swap-all-btn", "opt-set-int-all-btn"].forEach(function (btnId) { + const btn = document.getElementById(btnId); + if (!btn) return; + btn.addEventListener( + "click", + function () { + const map = { + "opt-set-swap-all-btn": "opt-set-swap-amount", + "opt-set-int-all-btn": "opt-set-int-amount", + }; + const input = document.getElementById(map[btnId]); + if (input) input.removeAttribute("readonly"); + }, + true + ); + }); + + hardenAmountAutofill(["opt-set-swap-amount", "opt-set-int-amount"]); +})(); diff --git a/lib/common/static/order_entry_model.js b/lib/common/static/order_entry_model.js new file mode 100644 index 0000000..e2ca41d --- /dev/null +++ b/lib/common/static/order_entry_model.js @@ -0,0 +1,214 @@ +(function (global) { + var delegated = false; + + function queryInScope(scope, id) { + if (scope && scope.querySelector) return scope.querySelector("#" + id); + return document.getElementById(id); + } + + function categoriesData() { + return global.ORDER_ENTRY_MODEL_CATEGORIES || []; + } + + function codeToCategoryMap() { + return global.ORDER_ENTRY_MODEL_CODE_TO_CATEGORY || {}; + } + + function tradeStyleForCode(code, modelSel) { + if (modelSel && code) { + var opt = modelSel.querySelector('option[value="' + code.replace(/"/g, '\\"') + '"]'); + if (opt) { + var ds = opt.getAttribute("data-trade-style"); + if (ds === "swing" || ds === "trend") return ds; + } + } + var map = global.ORDER_ENTRY_MODEL_TRADE_STYLE || {}; + return map[code] || "trend"; + } + + function findOption(catKey, code) { + var cats = categoriesData(); + for (var i = 0; i < cats.length; i++) { + if (cats[i].key !== catKey) continue; + var opts = cats[i].options || []; + for (var j = 0; j < opts.length; j++) { + if (opts[j].code === code) return opts[j]; + } + } + return null; + } + + function filterDomSubOptions(modelSel, catKey, preserveCode) { + var tagged = modelSel.querySelectorAll("option[data-entry-category]"); + if (!tagged.length) return false; + + var any = false; + for (var i = 0; i < tagged.length; i++) { + var opt = tagged[i]; + var show = !!catKey && opt.getAttribute("data-entry-category") === catKey; + opt.hidden = !show; + opt.disabled = !show; + if (show) any = true; + } + + modelSel.disabled = !any; + if (!any) { + modelSel.value = ""; + return true; + } + + var pick = preserveCode || ""; + if (pick) { + var picked = modelSel.querySelector('option[value="' + pick.replace(/"/g, '\\"') + '"]:not([disabled])'); + if (picked) { + modelSel.value = pick; + return true; + } + } + + var visible = []; + for (var k = 0; k < tagged.length; k++) { + if (!tagged[k].disabled) visible.push(tagged[k]); + } + if (visible.length === 1) modelSel.value = visible[0].value; + else modelSel.value = ""; + return true; + } + + function rebuildFromCategories(modelSel, catKey, preserveCode) { + var cats = categoriesData(); + var cat = null; + for (var i = 0; i < cats.length; i++) { + if (cats[i].key === catKey) { + cat = cats[i]; + break; + } + } + + modelSel.innerHTML = ""; + var placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "类型"; + modelSel.appendChild(placeholder); + + if (!cat || !cat.options || !cat.options.length) { + modelSel.disabled = true; + modelSel.value = ""; + return; + } + + modelSel.disabled = false; + var pick = preserveCode || ""; + for (var k = 0; k < cat.options.length; k++) { + var o = cat.options[k]; + var opt = document.createElement("option"); + opt.value = o.code; + opt.textContent = o.label; + if (o.trade_style) opt.setAttribute("data-trade-style", o.trade_style); + if (o.help) opt.title = o.help; + modelSel.appendChild(opt); + } + + if (pick && findOption(catKey, pick)) { + modelSel.value = pick; + } else if (cat.options.length === 1) { + modelSel.value = cat.options[0].code; + } else { + modelSel.value = ""; + } + } + + function rebuildEntryModelSubSelect(preserveCode, scope) { + var root = scope && scope.querySelector ? scope : document; + var catSel = queryInScope(root, "order-entry-category"); + var modelSel = queryInScope(root, "order-entry-model"); + if (!catSel || !modelSel) return; + + var catKey = catSel.value; + if (filterDomSubOptions(modelSel, catKey, preserveCode)) { + syncOrderEntryModelTradeStyle(modelSel); + return; + } + rebuildFromCategories(modelSel, catKey, preserveCode); + syncOrderEntryModelTradeStyle(modelSel); + } + + function syncOrderEntryModelTradeStyle(modelSel) { + if (!modelSel) modelSel = document.getElementById("order-entry-model"); + var hidden = document.getElementById("order-trade-style-hidden"); + var hint = document.getElementById("order-trade-style-hint"); + if (!modelSel || !hidden) return; + var labels = { trend: "趋势单", swing: "波段单" }; + var code = modelSel.value || ""; + var ts = tradeStyleForCode(code, modelSel); + hidden.value = ts; + if (hint) hint.textContent = labels[ts] || ts; + } + + function wireDelegation() { + if (delegated) return; + delegated = true; + document.addEventListener( + "change", + function (ev) { + var t = ev.target; + if (!t || !t.id) return; + if (t.id === "order-entry-category") { + rebuildEntryModelSubSelect(""); + return; + } + if (t.id === "order-entry-model") { + syncOrderEntryModelTradeStyle(t); + } + }, + false + ); + } + + function initOrderEntryModelSelect(root) { + wireDelegation(); + var scope = root && root.querySelector ? root : document; + var catSel = queryInScope(scope, "order-entry-category"); + var modelSel = queryInScope(scope, "order-entry-model"); + if (!catSel || !modelSel) return; + + var presetCode = modelSel.getAttribute("data-preset-code") || modelSel.value || ""; + if (presetCode) { + var catMap = codeToCategoryMap(); + var catKey = catMap[presetCode]; + if (catKey) { + catSel.value = catKey; + rebuildEntryModelSubSelect(presetCode, scope); + return; + } + } + rebuildEntryModelSubSelect("", scope); + } + + global.paintOrderLeverageHint = function (leverage) { + var hidden = document.getElementById("order-leverage"); + var hint = document.getElementById("order-leverage-hint"); + if (!hidden && !hint) return; + var lev = parseInt(leverage, 10); + if (!Number.isFinite(lev) || lev <= 0) { + if (hint) hint.textContent = "杠杆 —"; + if (hidden) hidden.value = ""; + return; + } + if (hidden) hidden.value = String(lev); + if (hint) hint.textContent = "杠杆 " + lev + "x"; + }; + + global.initOrderEntryModelSelect = initOrderEntryModelSelect; + global.syncOrderEntryModelTradeStyle = syncOrderEntryModelTradeStyle; + global.rebuildEntryModelSubSelect = rebuildEntryModelSubSelect; + + wireDelegation(); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + initOrderEntryModelSelect(); + }); + } else { + initOrderEntryModelSelect(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/records_review_page.js b/lib/common/static/records_review_page.js new file mode 100644 index 0000000..43b853c --- /dev/null +++ b/lib/common/static/records_review_page.js @@ -0,0 +1,641 @@ +/** + * 三所 /records:交易记录分页 + 复盘表单显隐 + 复盘/AI 列表分页(soft,每页5). + */ +(function (global) { + "use strict"; + + var PAGE_SIZE = 5; + var tradesPage = 0; + var tradesPages = 1; + var journalsAll = []; + var journalsPage = 0; + var journalsPages = 1; + var reviewsAll = []; + var reviewsPage = 0; + var reviewsPages = 1; + var tradesCache = {}; + var booted = false; + + function $(id) { + return document.getElementById(id); + } + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function listQs() { + if (typeof global.listWindowQueryString === "function") { + return global.listWindowQueryString() || ""; + } + return ""; + } + + function fmtNum(v, digits) { + if (v == null || v === "") return "—"; + var n = Number(v); + if (!Number.isFinite(n)) return esc(v); + return n.toFixed(digits == null ? 2 : digits); + } + + /** 优先用后端交易所精度字符串;否则回退量级格式(与 formatPriceForInput 一致). */ + function fmtPx(display, raw) { + if (display != null && display !== "") return esc(display); + if (raw == null || raw === "") return "—"; + var n = Number(raw); + if (!Number.isFinite(n)) return esc(raw); + var av = Math.abs(n); + var d; + if (av >= 10000) d = 2; + else if (av >= 100) d = 3; + else if (av >= 1) d = 4; + else if (av >= 0.01) d = 6; + else if (av >= 0.0001) d = 8; + else d = 10; + var text = n.toFixed(d); + if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, ""); + return text; + } + + function fmtTime(s) { + if (!s) return "—"; + return esc(String(s).slice(0, 16)); + } + + function resultBadge(result) { + var er = String(result || "").trim(); + if (["止盈", "保本止盈", "移动止盈"].indexOf(er) >= 0) { + return '' + esc(er) + ""; + } + if (["止损", "强制清仓", "手动平仓"].indexOf(er) >= 0) { + return '' + esc(er) + ""; + } + if (er === "时间平仓") return '' + esc(er) + ""; + return '' + esc(er || "-") + ""; + } + + function pnlClass(v) { + var n = Number(v); + if (!Number.isFinite(n) || n === 0) return ""; + return n > 0 ? "pnl-profit" : "pnl-loss"; + } + + function beginSoft(wrapId, soft) { + var wrap = $(wrapId); + if (!wrap) return null; + if (soft) { + if (!wrap.style.minHeight) { + wrap.style.minHeight = Math.max(wrap.offsetHeight, 1) + "px"; + } + wrap.classList.add("rr-list-loading"); + } else { + wrap.classList.remove("rr-list-loading"); + wrap.style.minHeight = ""; + } + return wrap; + } + + function endSoft(wrap) { + if (!wrap) return; + wrap.classList.remove("rr-list-loading"); + wrap.style.minHeight = ""; + } + + function updatePager(kind) { + var map = { + trades: { + page: tradesPage, + pages: tradesPages, + label: "rr-trades-page-label", + prev: "rr-trades-prev", + next: "rr-trades-next", + }, + journals: { + page: journalsPage, + pages: journalsPages, + label: "rr-journals-page-label", + prev: "rr-journals-prev", + next: "rr-journals-next", + }, + reviews: { + page: reviewsPage, + pages: reviewsPages, + label: "rr-reviews-page-label", + prev: "rr-reviews-prev", + next: "rr-reviews-next", + }, + }; + var m = map[kind]; + if (!m) return; + var label = $(m.label); + var prev = $(m.prev); + var next = $(m.next); + if (label) label.textContent = "第 " + (m.page + 1) + " / " + m.pages + " 页"; + if (prev) prev.disabled = m.page <= 0; + if (next) next.disabled = m.page + 1 >= m.pages; + } + + function fillPayload(t) { + return { + symbol: t.symbol, + monitor_type: t.monitor_type, + key_signal_type: t.key_signal_type || "", + direction: t.direction, + trigger_price: t.trigger_price, + stop_loss: t.display_open_stop_loss || t.initial_stop_loss || t.stop_loss, + take_profit: t.effective_take_profit || t.take_profit, + opened_at: t.effective_opened_at, + closed_at: t.effective_closed_at, + pnl_amount: t.effective_pnl_amount, + result: t.effective_result, + risk_amount: t.risk_amount, + effective_entry_reason: t.effective_entry_reason || "", + }; + } + + function editPayload(t) { + return { + id: t.id, + opened_at: t.effective_opened_at, + closed_at: t.effective_closed_at, + stop_loss: t.effective_stop_loss || t.initial_stop_loss || t.stop_loss, + take_profit: t.effective_take_profit || t.take_profit, + pnl_amount: t.effective_pnl_amount, + result: t.effective_result, + miss_reason: t.effective_miss_reason, + effective_entry_reason: t.effective_entry_reason || "", + }; + } + + function renderTradesRows(rows) { + var tbody = $("rr-trades-tbody"); + if (!tbody) return; + tradesCache = {}; + if (!rows || !rows.length) { + tbody.innerHTML = '暂无交易记录'; + return; + } + tbody.innerHTML = rows + .map(function (t) { + tradesCache[t.id] = t; + var mon = esc(t.monitor_type || ""); + if (t.key_signal_type) mon += " · " + esc(t.key_signal_type); + var stopShow = t.display_open_stop_loss || t.initial_stop_loss || t.stop_loss; + var tpShow = t.effective_take_profit || t.take_profit; + var pnl = t.effective_pnl_amount; + var pnlSrc = ""; + if (t.display_pnl_source === "exchange") { + pnlSrc = ''; + } else if (t.display_pnl_source !== "reviewed") { + pnlSrc = ''; + } + var dirCls = t.direction === "long" ? "direction-long" : "direction-short"; + var dirTxt = t.direction === "long" ? "做多" : "做空"; + var margin = + t.margin_capital != null && t.margin_capital !== "" + ? fmtNum(t.margin_capital, 2) + : "-"; + return ( + '' + + "" + + esc(t.symbol) + + "" + + "" + + mon + + "" + + "" + + esc(t.effective_entry_reason || "-") + + "" + + '' + + dirTxt + + "" + + "" + + fmtPx(t.trigger_price_display, t.trigger_price) + + "" + + "" + + fmtPx(t.stop_loss_display, stopShow) + + "" + + "" + + fmtPx(t.take_profit_display, tpShow) + + "" + + "" + + margin + + "" + + "" + + esc(t.leverage != null ? t.leverage : "-") + + "" + + "" + + esc(t.effective_hold_minutes || 0) + + "" + + "" + + fmtTime(t.effective_opened_at) + + "" + + "" + + fmtTime(t.effective_closed_at || t.created_at) + + "" + + '' + + fmtNum(pnl, 2) + + "" + + pnlSrc + + "" + + "" + + resultBadge(t.effective_result) + + "" + + "" + + ' ' + + ' ' + + '' + + "" + + "" + ); + }) + .join(""); + + tbody.querySelectorAll(".rr-fill-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + var id = btn.getAttribute("data-id"); + var t = tradesCache[id]; + if (!t) return; + showJournalCard(); + if (typeof global.fillJournalFromTrade === "function") { + global.fillJournalFromTrade(fillPayload(t)); + } + }); + }); + tbody.querySelectorAll(".review-edit-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + var id = btn.getAttribute("data-id"); + var t = tradesCache[id]; + if (!t) return; + if (typeof global.editTradeRecordReview === "function") { + global.editTradeRecordReview(editPayload(t)); + } + }); + }); + if (typeof global.toggleReviewMode === "function") { + global.toggleReviewMode(); + } + } + + function loadTradeRecords(opts) { + opts = opts || {}; + var soft = !!opts.soft; + var tbody = $("rr-trades-tbody"); + if (!tbody) return; + var wrap = beginSoft("rr-trades-wrap", soft); + if (!soft) { + tbody.innerHTML = '加载中…'; + } + var qs = listQs(); + var p = new URLSearchParams(qs || ""); + p.set("limit", String(PAGE_SIZE)); + p.set("offset", String(tradesPage * PAGE_SIZE)); + fetch("/api/trade_records?" + p.toString(), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data || !data.ok) { + tbody.innerHTML = '加载失败'; + endSoft(wrap); + return; + } + tradesPages = Math.max(1, Number(data.pages) || 1); + if (tradesPage >= tradesPages) { + tradesPage = Math.max(0, tradesPages - 1); + updatePager("trades"); + if (Number(data.total || 0) > 0) { + loadTradeRecords(opts); + return; + } + } + updatePager("trades"); + renderTradesRows(data.items || []); + endSoft(wrap); + }) + .catch(function () { + tbody.innerHTML = '加载失败'; + endSoft(wrap); + }); + } + + function renderJournalsPage(soft) { + var box = $("journal-list"); + if (!box) return; + var wrap = beginSoft("journal-list-wrap", soft); + var total = journalsAll.length; + journalsPages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1); + if (journalsPage >= journalsPages) journalsPage = Math.max(0, journalsPages - 1); + updatePager("journals"); + var hint = $("rr-journals-hint"); + if (hint) { + hint.textContent = + total > 0 + ? "已保存的复盘(共" + total + "条,每页5条)." + : "已保存的复盘(每页5条)."; + } + var slice = journalsAll.slice( + journalsPage * PAGE_SIZE, + journalsPage * PAGE_SIZE + PAGE_SIZE + ); + if (global.InstanceUI && typeof InstanceUI.renderJournalListHtml === "function") { + var html = InstanceUI.renderJournalListHtml(slice); + box.innerHTML = html || "
    暂无数据
    "; + } else { + box.innerHTML = "
    暂无数据
    "; + } + endSoft(wrap); + } + + function renderReviewsPage(soft) { + var box = $("review-list"); + if (!box) return; + var wrap = beginSoft("review-list-wrap", soft); + var total = reviewsAll.length; + reviewsPages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1); + if (reviewsPage >= reviewsPages) reviewsPage = Math.max(0, reviewsPages - 1); + updatePager("reviews"); + var slice = reviewsAll.slice( + reviewsPage * PAGE_SIZE, + reviewsPage * PAGE_SIZE + PAGE_SIZE + ); + if (!slice.length) { + box.innerHTML = "
    暂无数据
    "; + endSoft(wrap); + return; + } + var html = ""; + slice.forEach(function (r) { + if (global.reviewCache) global.reviewCache[r.id] = r; + var preview = (r.content || "").replace(/\s+/g, " ").trim(); + var shortText = preview.length > 90 ? preview.slice(0, 90) + "..." : preview; + html += + '
    ' + + "
    " + + (r.review_type === "daily" ? "日复盘" : "周复盘") + + " | " + + esc(r.target_date) + + "
    " + + '
    ' + + esc(r.created_at || "") + + "
    " + + '
    ' + + esc(shortText || "(空)") + + "
    " + + '
    ' + + '" + + '" + + '导出MD' + + '" + + "
    "; + }); + box.innerHTML = html; + endSoft(wrap); + } + + function loadJournalsPaged() { + var qs = listQs(); + fetch("/api/journals" + (qs ? "?" + qs : ""), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + journalsAll = Array.isArray(data) ? data : []; + if (global.journalCache) { + Object.keys(global.journalCache).forEach(function (k) { + delete global.journalCache[k]; + }); + journalsAll.forEach(function (o) { + global.journalCache[o.id] = o; + }); + } + journalsPage = 0; + renderJournalsPage(false); + }); + } + + function loadReviewsPaged() { + var qs = listQs(); + fetch("/api/reviews" + (qs ? "?" + qs : ""), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + reviewsAll = Array.isArray(data) ? data : []; + if (global.reviewCache) { + Object.keys(global.reviewCache).forEach(function (k) { + delete global.reviewCache[k]; + }); + } else { + global.reviewCache = {}; + } + reviewsAll.forEach(function (r) { + global.reviewCache[r.id] = r; + }); + reviewsPage = 0; + renderReviewsPage(false); + }); + } + + function showJournalCard() { + var card = $("journal-card"); + if (card) card.classList.remove("hidden"); + var hint = $("rr-journal-fill-hint"); + if (hint) hint.style.display = ""; + } + + function hideJournalCard() { + var card = $("journal-card"); + if (card) card.classList.add("hidden"); + var hint = $("rr-journal-fill-hint"); + if (hint) hint.style.display = "none"; + } + + function patchFillJournalFromTrade() { + var prev = global.fillJournalFromTrade; + if (typeof prev !== "function") return; + if (prev.__rrPatched) return; + global.fillJournalFromTrade = function (t) { + showJournalCard(); + prev(t); + var hint = $("rr-journal-fill-hint"); + if (hint) hint.style.display = ""; + }; + global.fillJournalFromTrade.__rrPatched = true; + } + + function patchDeleteTradeRecord() { + var prev = global.deleteTradeRecord; + if (typeof prev !== "function") return; + if (prev.__rrPatched) return; + global.deleteTradeRecord = function (id) { + if (!confirm("确定删除这条交易记录?")) return; + fetch("/delete_trade_record/" + id, { method: "POST", credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (data && data.ok) { + loadTradeRecords({ soft: true }); + return; + } + if (typeof prev === "function") { + /* fallthrough reload */ + } + global.location.href = + (global.location.pathname || "/records") + "?_ts=" + Date.now(); + }) + .catch(function () { + global.location.href = + (global.location.pathname || "/records") + "?_ts=" + Date.now(); + }); + }; + global.deleteTradeRecord.__rrPatched = true; + } + + function bindPagers() { + var tp = $("rr-trades-prev"); + var tn = $("rr-trades-next"); + var jp = $("rr-journals-prev"); + var jn = $("rr-journals-next"); + var rp = $("rr-reviews-prev"); + var rn = $("rr-reviews-next"); + var hideBtn = $("rr-journal-hide-btn"); + if (tp) { + tp.addEventListener("click", function (ev) { + ev.preventDefault(); + if (tradesPage <= 0) return; + tradesPage -= 1; + updatePager("trades"); + loadTradeRecords({ soft: true }); + }); + } + if (tn) { + tn.addEventListener("click", function (ev) { + ev.preventDefault(); + if (tradesPage + 1 >= tradesPages) return; + tradesPage += 1; + updatePager("trades"); + loadTradeRecords({ soft: true }); + }); + } + if (jp) { + jp.addEventListener("click", function (ev) { + ev.preventDefault(); + if (journalsPage <= 0) return; + journalsPage -= 1; + renderJournalsPage(true); + }); + } + if (jn) { + jn.addEventListener("click", function (ev) { + ev.preventDefault(); + if (journalsPage + 1 >= journalsPages) return; + journalsPage += 1; + renderJournalsPage(true); + }); + } + if (rp) { + rp.addEventListener("click", function (ev) { + ev.preventDefault(); + if (reviewsPage <= 0) return; + reviewsPage -= 1; + renderReviewsPage(true); + }); + } + if (rn) { + rn.addEventListener("click", function (ev) { + ev.preventDefault(); + if (reviewsPage + 1 >= reviewsPages) return; + reviewsPage += 1; + renderReviewsPage(true); + }); + } + if (hideBtn) { + hideBtn.addEventListener("click", function (ev) { + ev.preventDefault(); + hideJournalCard(); + }); + } + } + + function init(opts) { + opts = opts || {}; + if (!$("records-panel-root")) return; + var tbody = $("rr-trades-tbody"); + var stuckLoading = + !!tbody && + tbody.querySelectorAll("tr").length <= 1 && + /加载中/.test(String(tbody.textContent || "")); + if (booted) { + if (opts.refresh || stuckLoading) { + loadTradeRecords({ soft: !stuckLoading }); + loadJournalsPaged(); + loadReviewsPaged(); + } + patchFillJournalFromTrade(); + patchDeleteTradeRecord(); + if (global.JournalFormSave && typeof global.JournalFormSave.init === "function") { + global.JournalFormSave.init(); + } + return; + } + booted = true; + if (!global.journalCache) global.journalCache = {}; + if (!global.reviewCache) global.reviewCache = {}; + global.loadJournals = loadJournalsPaged; + global.loadReviews = loadReviewsPaged; + global.loadTradeRecords = loadTradeRecords; + patchFillJournalFromTrade(); + patchDeleteTradeRecord(); + bindPagers(); + updatePager("trades"); + updatePager("journals"); + updatePager("reviews"); + if (global.JournalFormSave && typeof global.JournalFormSave.init === "function") { + global.JournalFormSave.init(); + } + loadTradeRecords({ soft: false }); + loadJournalsPaged(); + loadReviewsPaged(); + } + + global.RecordsReviewPage = { + init: init, + loadTradeRecords: loadTradeRecords, + loadJournals: loadJournalsPaged, + loadReviews: loadReviewsPaged, + showJournalCard: showJournalCard, + hideJournalCard: hideJournalCard, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/symbol_live_price.js b/lib/common/static/symbol_live_price.js new file mode 100644 index 0000000..07db9c1 --- /dev/null +++ b/lib/common/static/symbol_live_price.js @@ -0,0 +1,169 @@ +/** + * 表单币种输入:防抖 + 定时刷新,展示交易所最新价(/api/order_defaults). + */ +(function (global) { + "use strict"; + + const DEFAULT_DEBOUNCE_MS = 350; + const DEFAULT_POLL_MS = 5000; + const bound = new WeakSet(); + + function $(id) { + return id ? document.getElementById(id) : null; + } + + function symbolValue(el) { + if (!el) return ""; + return (el.value || "").trim(); + } + + function directionValue(dirId) { + const el = dirId ? $(dirId) : null; + const v = (el && el.value ? el.value : "long").trim().toLowerCase(); + return v === "short" ? "short" : "long"; + } + + function formatPrice(px, sym) { + const n = Number(px); + if (!Number.isFinite(n)) return "—"; + const u = (sym || "").trim().toUpperCase(); + let digits = 4; + if (u.startsWith("BTC") || u.startsWith("ETH") || n >= 1000) digits = 2; + else if (n >= 10) digits = 3; + else if (n >= 1) digits = 4; + else if (n >= 0.01) digits = 5; + else digits = 6; + return n.toFixed(digits); + } + + function pollMs() { + const raw = + (document.body && document.body.getAttribute("data-price-refresh-ms")) || ""; + const n = Number(raw); + return Number.isFinite(n) && n >= 2000 ? n : DEFAULT_POLL_MS; + } + + function paint(el, sym, px, err) { + if (!el) return; + if (err) { + el.textContent = "现价:—"; + el.classList.add("symbol-live-price--err"); + el.classList.remove("symbol-live-price--ok"); + el.title = err; + return; + } + if (px === null || typeof px === "undefined") { + el.textContent = "现价:—"; + el.classList.remove("symbol-live-price--ok", "symbol-live-price--err"); + el.title = sym ? "无法读取交易所价格" : ""; + return; + } + const label = sym ? sym.toUpperCase().replace(/\/USDT.*/, "") : ""; + el.textContent = label ? label + " 现价 " + formatPrice(px, sym) : "现价 " + formatPrice(px, sym); + el.classList.add("symbol-live-price--ok"); + el.classList.remove("symbol-live-price--err"); + el.title = "交易所最新价(约 " + pollMs() / 1000 + "s 刷新)"; + } + + function bindOne(el) { + if (!el || bound.has(el)) return; + bound.add(el); + + const symId = el.getAttribute("data-symbol-input"); + const dirId = el.getAttribute("data-direction-input") || ""; + let debounceTimer = null; + let pollTimer = null; + let fetchSeq = 0; + + function clearPoll() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + function startPoll() { + clearPoll(); + pollTimer = setInterval(refresh, pollMs()); + } + + function refresh() { + const symEl = $(symId); + const sym = symbolValue(symEl); + if (!sym) { + paint(el, "", null, ""); + clearPoll(); + return; + } + const dir = directionValue(dirId); + const seq = ++fetchSeq; + el.classList.add("symbol-live-price--loading"); + fetch( + "/api/order_defaults?symbol=" + + encodeURIComponent(sym) + + "&direction=" + + encodeURIComponent(dir) + ) + .then(function (r) { + return r.json().then(function (d) { + return { status: r.status, data: d }; + }).catch(function () { + return { status: r.status, data: null }; + }); + }) + .then(function (res) { + if (seq !== fetchSeq) return; + el.classList.remove("symbol-live-price--loading"); + const data = res.data || {}; + if (res.status >= 400 || !data || !data.ok) { + paint(el, sym, null, (data && data.msg) || "读取失败"); + return; + } + const px = data.last_price != null ? data.last_price : data.price; + if (px === null || typeof px === "undefined") { + paint(el, data.symbol || sym, null, "无法读取交易所价格"); + return; + } + paint(el, data.symbol || sym, px, ""); + if (!pollTimer) startPoll(); + }) + .catch(function () { + if (seq !== fetchSeq) return; + el.classList.remove("symbol-live-price--loading"); + paint(el, sym, null, "网络错误"); + }); + } + + function schedule() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(refresh, DEFAULT_DEBOUNCE_MS); + } + + const symEl = $(symId); + if (symEl) { + symEl.addEventListener("input", schedule); + symEl.addEventListener("change", schedule); + } + const dirEl = dirId ? $(dirId) : null; + if (dirEl) { + dirEl.addEventListener("change", schedule); + } + + schedule(); + } + + function init(root) { + const scope = root || document; + scope.querySelectorAll(".symbol-live-price").forEach(bindOne); + } + + global.SymbolLivePrice = { init: init, bind: bindOne }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + init(document); + }); + } else { + init(document); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/time_close_ui.js b/lib/common/static/time_close_ui.js new file mode 100644 index 0000000..7d4933e --- /dev/null +++ b/lib/common/static/time_close_ui.js @@ -0,0 +1,194 @@ +/** + * 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时. + */ +(function (global) { + "use strict"; + + function pad2(n) { + return n < 10 ? "0" + n : String(n); + } + + function formatCountdown(sec) { + const s = Math.max(0, parseInt(sec, 10) || 0); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const r = s % 60; + return pad2(h) + ":" + pad2(m) + ":" + pad2(r); + } + + function isForceCloseActive(wrap) { + if (!wrap) return false; + const raw = + wrap.dataset.forceCloseActive || + wrap.getAttribute("data-force-close-active") || + ""; + return raw === "1" || raw === "true"; + } + + function bindTimeCloseForm(checkboxId, selectId, wrapId) { + const cb = document.getElementById(checkboxId); + const sel = document.getElementById(selectId); + const wrap = wrapId ? document.getElementById(wrapId) : null; + if (!cb || !sel) return; + function sync() { + const on = !!cb.checked; + sel.disabled = false; + sel.tabIndex = 0; + if (wrap) wrap.classList.toggle("is-disabled", !on); + } + sel.addEventListener("mousedown", function (ev) { + ev.stopPropagation(); + }); + sel.addEventListener("click", function (ev) { + ev.stopPropagation(); + }); + cb.addEventListener("change", sync); + sync(); + } + + function paintCountdownEl(cd, rem, active) { + if (!cd) return; + if (active) { + cd.textContent = "执行中"; + return; + } + cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--"; + } + + function paintOrderTimeClose(order) { + if (!order || order.id == null) return; + const wrap = document.getElementById("order-time-close-wrap-" + order.id); + const cd = document.getElementById("order-time-close-cd-" + order.id); + if (!wrap || !cd) return; + const enabled = !!(order.time_close_enabled || order.time_close_at_ms); + if (!enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const hours = order.time_close_hours; + const label = order.time_close_label || (hours ? "时间平仓 " + hours + "h" : "时间平仓"); + const labelEl = wrap.querySelector(".pos-time-close-label"); + if (labelEl) labelEl.textContent = label; + let rem = + order.time_close_remaining_sec != null + ? Number(order.time_close_remaining_sec) + : null; + if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) { + rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000)); + } + paintCountdownEl(cd, rem, false); + wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : ""; + } + + function paintOrderForceClose(order) { + if (!order || order.id == null) return; + const wrap = document.getElementById("order-force-close-wrap-" + order.id); + const cd = document.getElementById("order-force-close-cd-" + order.id); + if (!wrap || !cd) return; + const enabled = !!order.force_close_enabled; + if (!enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const label = order.force_close_label || "强制清仓"; + const labelEl = wrap.querySelector(".pos-force-close-label"); + if (labelEl) labelEl.textContent = label; + let rem = + order.force_close_remaining_sec != null + ? Number(order.force_close_remaining_sec) + : null; + const atMs = order.force_close_at_ms; + if ((rem == null || !Number.isFinite(rem)) && atMs) { + rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000)); + } + const active = !!order.force_close_active; + paintCountdownEl(cd, rem, active); + wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : ""; + wrap.dataset.forceCloseActive = active ? "1" : "0"; + } + + function paintForceCloseHeader(state) { + const wrap = document.getElementById("force-close-header-badge"); + if (!wrap) return; + if (!state || !state.enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const label = state.label || "强制清仓"; + const labelPrefix = label + " 已开启 · "; + let prefixNode = wrap.querySelector(".force-close-header-prefix"); + if (!prefixNode) { + wrap.textContent = ""; + prefixNode = document.createElement("span"); + prefixNode.className = "force-close-header-prefix"; + prefixNode.textContent = labelPrefix; + wrap.appendChild(prefixNode); + const cd = document.createElement("span"); + cd.className = "force-close-header-cd"; + wrap.appendChild(cd); + } else { + prefixNode.textContent = labelPrefix; + } + const cd = wrap.querySelector(".force-close-header-cd"); + let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null; + if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) { + rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000)); + } + paintCountdownEl(cd, rem, !!state.active); + wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : ""; + wrap.dataset.forceCloseActive = state.active ? "1" : "0"; + } + + function tickLocalCountdowns() { + document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) { + const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || ""; + const cd = wrap.querySelector(".pos-time-close-cd"); + if (!cd) return; + const closeAt = Number(closeAtRaw); + if (!closeAt) return; + const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000)); + cd.textContent = formatCountdown(rem); + }); + document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) { + const closeAtRaw = + wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || ""; + const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd"); + if (!cd) return; + const closeAt = Number(closeAtRaw); + if (!closeAt) return; + const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000)); + paintCountdownEl(cd, rem, isForceCloseActive(wrap)); + }); + } + + function paintOrders(orders) { + (orders || []).forEach(function (order) { + paintOrderTimeClose(order); + paintOrderForceClose(order); + }); + } + + function syncKeyTimeCloseVisibility(show) { + const wrap = document.getElementById("key-time-close-wrap"); + if (!wrap) return; + wrap.style.display = show ? "inline-flex" : "none"; + } + + global.TimeCloseUI = { + bindTimeCloseForm: bindTimeCloseForm, + paintOrderTimeClose: paintOrderTimeClose, + paintOrderForceClose: paintOrderForceClose, + paintForceCloseHeader: paintForceCloseHeader, + paintOrders: paintOrders, + tickLocalCountdowns: tickLocalCountdowns, + syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility, + formatCountdown: formatCountdown, + }; + + if (!global.__timeCloseCountdownTimer) { + global.__timeCloseCountdownTimer = setInterval(tickLocalCountdowns, 1000); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/trade_stats_calendar.css b/lib/common/static/trade_stats_calendar.css new file mode 100644 index 0000000..1eb1f05 --- /dev/null +++ b/lib/common/static/trade_stats_calendar.css @@ -0,0 +1,171 @@ +/* 交易日历:内照明心 + 三所统计分析共用,随 data-theme 浅/深切换 */ +.trade-cal-wrap { + --trade-cal-wrap-bg: var(--inset-surface, rgba(0, 0, 0, 0.22)); + --trade-cal-cell-bg: var(--section-surface, var(--inset-surface, rgba(0, 0, 0, 0.32))); + --trade-cal-cell-border: rgba(255, 255, 255, 0.14); + --trade-cal-cell-shadow: 0 1px 3px rgba(0, 0, 0, 0.22); + --trade-cal-cell-empty-bg: color-mix(in srgb, var(--trade-cal-cell-bg) 72%, transparent); + --trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #6366f1) 12%, var(--trade-cal-cell-bg)); + --trade-cal-cell-hover-border: color-mix(in srgb, var(--accent, #6366f1) 45%, transparent); + --trade-cal-selected-border: rgba(59, 130, 246, 0.85); + --trade-cal-selected-bg: color-mix(in srgb, #3b82f6 16%, var(--trade-cal-cell-bg)); + --trade-cal-selected-shadow: rgba(59, 130, 246, 0.45); + --trade-cal-sick-bg: color-mix(in srgb, var(--red, #ef4444) 14%, var(--trade-cal-cell-bg)); + --trade-cal-sick-border: color-mix(in srgb, var(--red, #ef4444) 55%, transparent); + --trade-cal-sick-shadow: color-mix(in srgb, var(--red, #ef4444) 45%, transparent); + --trade-cal-sick-tag-bg: color-mix(in srgb, var(--red, #ef4444) 25%, transparent); + --trade-cal-sick-tag-fg: color-mix(in srgb, var(--red, #ef4444) 70%, #fff); + --trade-cal-pos: var(--green, #22c55e); + --trade-cal-neg: var(--red, #ef4444); + margin-top: 4px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border-soft, rgba(120, 140, 200, 0.28)); + background: var(--trade-cal-wrap-bg); +} +.stats-calendar-wrap { + margin-bottom: 14px; +} +.trade-cal-wrap button.trade-cal-cell { + background: var(--trade-cal-cell-bg) !important; + background-image: none !important; + border: 1px solid var(--trade-cal-cell-border); + box-shadow: var(--trade-cal-cell-shadow); + padding: 6px 4px; + min-height: 72px; + width: 100%; + line-height: 1.15; + font-size: inherit; + text-align: center; +} +.trade-cal-wrap button.trade-cal-cell:not(.has-trade) { + background: var(--trade-cal-cell-empty-bg) !important; + cursor: default; +} +.trade-cal-wrap button.trade-cal-cell:disabled { + opacity: 1; + cursor: default; +} +.trade-cal-wrap .trade-cal-head .btn, +.trade-cal-wrap .trade-cal-head button { + min-height: 0; + min-width: 34px; + padding: 4px 12px; + line-height: 1.2; +} +.trade-cal-head { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-bottom: 8px; +} +.trade-cal-title { + font-size: 0.95rem; + font-weight: 600; + min-width: 120px; + text-align: center; + color: var(--text, #e8ecff); +} +.trade-cal-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + margin-bottom: 4px; +} +.trade-cal-wd { + text-align: center; + font-size: 0.72rem; + color: var(--muted, #8892b0); +} +.trade-cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 6px; +} +.trade-cal-cell { + min-height: 72px; + padding: 6px 4px; + border-radius: 8px; + border: 1px solid var(--trade-cal-cell-border); + box-shadow: var(--trade-cal-cell-shadow); + background: var(--trade-cal-cell-bg); + color: inherit; + font: inherit; + cursor: default; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 2px; +} +.trade-cal-cell.has-trade { + cursor: pointer; +} +.trade-cal-wrap button.trade-cal-cell.has-trade:hover { + background: var(--trade-cal-cell-hover-bg) !important; + background-image: none !important; + border-color: var(--trade-cal-cell-hover-border); +} +.trade-cal-cell.is-selected { + border-color: var(--trade-cal-selected-border); + background: var(--trade-cal-selected-bg); + box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow); +} +.trade-cal-cell.is-sick-day { + border-color: var(--trade-cal-sick-border); + background: var(--trade-cal-sick-bg); +} +.trade-cal-cell.is-sick-day.is-selected { + border-color: var(--trade-cal-selected-border); + background: color-mix(in srgb, #3b82f6 14%, var(--trade-cal-sick-bg)); + box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow); +} +.trade-cal-day-num { + font-size: 0.78rem; + font-weight: 600; + color: var(--text, #e8ecff); +} +.trade-cal-pnl { + font-size: 0.72rem; + font-weight: 600; + line-height: 1.1; + color: var(--text, #e8ecff); +} +.trade-cal-cell.pnl-pos .trade-cal-pnl { + color: var(--trade-cal-pos); +} +.trade-cal-cell.pnl-neg .trade-cal-pnl { + color: var(--trade-cal-neg); +} +.trade-cal-cnt { + font-size: 0.65rem; + color: var(--muted, #8892b0); + font-weight: 500; +} +.trade-cal-sick-tag { + font-size: 0.62rem; + padding: 1px 4px; + border-radius: 4px; + background: var(--trade-cal-sick-tag-bg); + color: var(--trade-cal-sick-tag-fg); + font-weight: 600; +} +.trade-cal-pad { + background: transparent; + border: none; + min-height: 0; +} + +html[data-theme="light"] .trade-cal-wrap { + --trade-cal-wrap-bg: var(--inset-surface, #eef3f8); + --trade-cal-cell-bg: #ffffff; + --trade-cal-cell-empty-bg: #f6f9fc; + --trade-cal-cell-border: rgba(0, 75, 115, 0.18); + --trade-cal-cell-shadow: 0 1px 4px rgba(30, 60, 100, 0.08); + --trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #ffffff); + --trade-cal-selected-border: rgba(37, 99, 235, 0.75); + --trade-cal-selected-bg: color-mix(in srgb, #2563eb 12%, #ffffff); + --trade-cal-selected-shadow: rgba(37, 99, 235, 0.35); + --trade-cal-sick-tag-fg: #b91c1c; +} diff --git a/lib/common/static/trade_stats_calendar.js b/lib/common/static/trade_stats_calendar.js new file mode 100644 index 0000000..73da916 --- /dev/null +++ b/lib/common/static/trade_stats_calendar.js @@ -0,0 +1,314 @@ +/** + * 交易日历组件:内照明心档案 + 三所统计分析共用. + */ +(function (global) { + "use strict"; + + var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]; + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function monthLabel(y, m) { + return y + "年" + m + "月"; + } + + function formatCalPnl(pnl) { + var n = Number(pnl); + if (!Number.isFinite(n)) n = 0; + return (n >= 0 ? "+" : "") + n.toFixed(1) + "U"; + } + + function dayHasTrade(info) { + if (!info) return false; + var cnt = Number(info.open_count); + if (Number.isFinite(cnt) && cnt > 0) return true; + var pnl = Number(info.pnl_total); + return Number.isFinite(pnl) && Math.abs(pnl) > 0.0001; + } + + function dayOpenCount(info) { + var cnt = Number(info && info.open_count); + return Number.isFinite(cnt) && cnt > 0 ? cnt : 0; + } + + function dayPnl(info) { + return Number(info && info.pnl_total) || 0; + } + + function TradeStatsCalendar(config) { + this.gridEl = config.gridEl; + this.titleEl = config.titleEl; + this.prevBtn = config.prevBtn || null; + this.nextBtn = config.nextBtn || null; + this.apiUrl = config.apiUrl || "/api/stats/calendar"; + this.buildQuery = + config.buildQuery || + function (year, month) { + var q = new URLSearchParams(); + q.set("year", String(year)); + q.set("month", String(month)); + return q; + }; + this.parseResponse = + config.parseResponse || + function (data) { + if (data && data.ok === false) return {}; + return (data && data.days) || {}; + }; + this.fetchFn = config.fetchFn || null; + this.showSick = config.showSick !== false; + this.selectedDay = config.selectedDay || ""; + this.onDayClick = config.onDayClick || null; + this.onMonthChange = config.onMonthChange || null; + this.year = config.year || 0; + this.month = config.month || 0; + this.days = {}; + this.monthPnlTotal = 0; + this.monthOpenCount = 0; + this._navBound = false; + this._bindNav(); + } + + TradeStatsCalendar.prototype.ensureMonth = function (ref) { + if (this.year > 0 && this.month > 0) return; + var d; + if (ref instanceof Date) d = ref; + else if (typeof ref === "string" && ref.length >= 7) { + var p = ref.slice(0, 10).split("-"); + this.year = parseInt(p[0], 10) || new Date().getFullYear(); + this.month = parseInt(p[1], 10) || new Date().getMonth() + 1; + return; + } else d = new Date(); + this.year = d.getFullYear(); + this.month = d.getMonth() + 1; + }; + + TradeStatsCalendar.prototype.applyPayload = function (data) { + if (!data) return; + var y = Number(data.year); + var m = Number(data.month); + if (Number.isFinite(y) && y > 0) this.year = y; + if (Number.isFinite(m) && m > 0) this.month = m; + this.days = this.parseResponse(data) || {}; + this.monthPnlTotal = Number(data.month_pnl_total) || 0; + this.monthOpenCount = Number(data.month_open_count) || 0; + if (!this.monthOpenCount) { + var self = this; + Object.keys(this.days).forEach(function (k) { + if (dayHasTrade(self.days[k])) { + self.monthOpenCount += dayOpenCount(self.days[k]); + self.monthPnlTotal += dayPnl(self.days[k]); + } + }); + this.monthPnlTotal = Math.round(this.monthPnlTotal * 10000) / 10000; + } + }; + + function readStatsCalendarBootstrap() { + var el = document.getElementById("stats-calendar-bootstrap"); + if (!el || !el.textContent) return null; + try { + return JSON.parse(el.textContent); + } catch (e) { + console.warn("[trade calendar] bootstrap parse", e); + return null; + } + } + + TradeStatsCalendar.prototype.setSelectedDay = function (day) { + this.selectedDay = day || ""; + this.render(); + }; + + TradeStatsCalendar.prototype.render = function () { + if (!this.gridEl || !this.titleEl) return; + if (this.year <= 0 || this.month <= 0) this.ensureMonth(new Date()); + var title = monthLabel(this.year, this.month); + if (this.monthOpenCount > 0) { + title += + " · " + formatCalPnl(this.monthPnlTotal) + " · " + this.monthOpenCount + "笔"; + } + this.titleEl.textContent = title; + var first = new Date(this.year, this.month - 1, 1); + var lastDay = new Date(this.year, this.month, 0).getDate(); + var startWd = first.getDay(); + var html = + '
    ' + + WEEKDAYS.map(function (w) { + return '' + w + ""; + }).join("") + + '
    '; + var i; + for (i = 0; i < startWd; i++) { + html += ''; + } + for (var d = 1; d <= lastDay; d++) { + var dayStr = + this.year + + "-" + + String(this.month).padStart(2, "0") + + "-" + + String(d).padStart(2, "0"); + var info = this.days[dayStr]; + var hasTrade = dayHasTrade(info); + var sick = this.showSick && info && info.has_sick; + var pnl = hasTrade ? dayPnl(info) : null; + var cnt = hasTrade ? dayOpenCount(info) : 0; + var cls = + "trade-cal-cell" + + (hasTrade ? " has-trade" : "") + + (sick ? " is-sick-day" : "") + + (this.selectedDay === dayStr ? " is-selected" : "") + + (pnl != null && pnl > 0.0001 + ? " pnl-pos" + : pnl != null && pnl < -0.0001 + ? " pnl-neg" + : ""); + var body = '' + d + ""; + if (hasTrade) { + body += + '' + + esc(formatCalPnl(pnl)) + + "" + + '' + + cnt + + "笔"; + if (sick) body += '犯病'; + } + html += + '"; + } + html += "
    "; + this.gridEl.innerHTML = html; + var self = this; + this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) { + btn.addEventListener("click", function () { + var day = btn.getAttribute("data-day"); + if (!day || !self.onDayClick) return; + self.selectedDay = day; + self.render(); + self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null); + }); + }); + }; + + TradeStatsCalendar.prototype.load = async function () { + this.ensureMonth(new Date()); + this.render(); + var q = this.buildQuery(this.year, this.month); + if (!q.has("year")) q.set("year", String(this.year)); + if (!q.has("month")) q.set("month", String(this.month)); + try { + var data; + if (this.fetchFn) { + data = await this.fetchFn(q); + } else { + var resp = await fetch(this.apiUrl + "?" + q.toString(), { + credentials: "same-origin", + }); + if (!resp.ok) { + console.warn("[trade calendar] api", resp.status); + this.render(); + return; + } + data = await resp.json(); + } + this.applyPayload(data); + this.render(); + if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days); + } catch (e) { + console.warn("[trade calendar]", e); + this.render(); + } + }; + + TradeStatsCalendar.prototype.shiftMonth = function (delta) { + this.ensureMonth(new Date()); + this.month += delta; + if (this.month > 12) { + this.month = 1; + this.year += 1; + } else if (this.month < 1) { + this.month = 12; + this.year -= 1; + } + void this.load(); + }; + + TradeStatsCalendar.prototype._bindNav = function () { + if (this._navBound) return; + var self = this; + if (this.prevBtn) { + this.prevBtn.addEventListener("click", function () { + self.shiftMonth(-1); + }); + } + if (this.nextBtn) { + this.nextBtn.addEventListener("click", function () { + self.shiftMonth(1); + }); + } + this._navBound = true; + }; + + global.TradeStatsCalendar = TradeStatsCalendar; + + global.statsCalendarWidget = null; + + global.initInstanceStatsCalendar = function () { + var grid = document.getElementById("stats-calendar"); + if (!grid || !global.TradeStatsCalendar) return null; + var bootstrap = readStatsCalendarBootstrap(); + if ( + global.statsCalendarWidget && + global.statsCalendarWidget.gridEl === grid + ) { + if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap); + global.statsCalendarWidget.render(); + void global.statsCalendarWidget.load(); + return global.statsCalendarWidget; + } + global.statsCalendarWidget = new TradeStatsCalendar({ + gridEl: grid, + titleEl: document.getElementById("stats-cal-title"), + prevBtn: document.getElementById("stats-cal-prev"), + nextBtn: document.getElementById("stats-cal-next"), + apiUrl: "/api/stats/calendar", + showSick: false, + buildQuery: function (year, month) { + var q = new URLSearchParams(); + q.set("year", String(year)); + q.set("month", String(month)); + var sel = document.getElementById("stats-segment-select"); + if (sel) q.set("segment", sel.value || "all"); + return q; + }, + parseResponse: function (data) { + if (data && data.ok === false) return {}; + return (data && data.days) || {}; + }, + }); + if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap); + global.statsCalendarWidget.render(); + void global.statsCalendarWidget.load(); + return global.statsCalendarWidget; + }; + + global.initStatsCalendarWidget = global.initInstanceStatsCalendar; +})(window); diff --git a/lib/common/wechat_notify_lib.py b/lib/common/wechat_notify_lib.py new file mode 100644 index 0000000..d24be62 --- /dev/null +++ b/lib/common/wechat_notify_lib.py @@ -0,0 +1,117 @@ +"""企业微信机器人 Webhook 推送(多实例共用).""" +from __future__ import annotations + +import re +from typing import Optional + +import requests + + +def strip_markdown_for_text(content: str) -> str: + s = str(content or "") + s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) + s = re.sub(r"`([^`]+)`", r"\1", s) + s = re.sub(r"^#+\s*", "", s, flags=re.MULTILINE) + s = re.sub(r"^---\s*$", "", s, flags=re.MULTILINE) + return s.strip() + + +def looks_like_wechat_markdown(content: str) -> bool: + if not content: + return False + if re.search(r"^#+\s", content, re.MULTILINE): + return True + return "**" in content or "`" in content + + +def send_wechat_webhook( + webhook_url: str, + content: str, + *, + timeout: int = 10, + prefix: str = "【加密货币】", +) -> bool: + url = (webhook_url or "").strip() + if not url or "replace-me" in url: + return False + body = str(content or "").strip() + if prefix: + full = f"{prefix}\n{body}" if body else prefix + else: + full = body + if not full.strip(): + return False + + payloads = [] + if looks_like_wechat_markdown(full): + payloads.append({"msgtype": "markdown", "markdown": {"content": full}}) + plain = strip_markdown_for_text(full) if looks_like_wechat_markdown(full) else full + payloads.append({"msgtype": "text", "text": {"content": plain}}) + + seen = set() + for payload in payloads: + key = payload["msgtype"] + if key in seen: + continue + seen.add(key) + try: + resp = requests.post(url, json=payload, timeout=timeout) + if resp.status_code != 200: + continue + data = resp.json() + if int(data.get("errcode", -1)) == 0: + return True + except Exception: + continue + return False + + +def wechat_direction_label(direction: str) -> str: + d = (direction or "").strip().lower() + if d == "long": + return "多头(long)" + if d == "short": + return "空头(short)" + return "双向(watch)" + + +def build_wechat_rs_level_message( + *, + symbol: str, + monitor_type: str, + account_label: str, + trigger_time: str, + upper_txt: str, + lower_txt: str, + close_txt: str, + edge_txt: str, + break_label: str, + direction: str, + notify_index: int, + notify_max: int, + interval_min: int, + extra_note: Optional[str] = None, +) -> str: + """阻力/支撑突破提醒(与开平仓推送一致的 emoji 纯文本风格).""" + head = "📈" if (direction or "").strip().lower() == "long" else "📉" + dir_txt = wechat_direction_label(direction) + lines = [ + f"{head} {symbol} 关键位突破提醒({notify_index}/{notify_max})", + f"💼 账户:{account_label}", + "", + "🧾 突破概要", + f"📌 类型:{monitor_type}", + f"⏱ 触发时间:{trigger_time}", + f"📊 上沿:{upper_txt}|下沿:{lower_txt}", + f"💹 触发收盘:{close_txt}", + f"🎯 {break_label}({dir_txt})", + f"📍 突破价位:{edge_txt}", + "", + "📎 说明", + f"· 人工盯盘,共推送 {notify_max} 次(间隔约 {interval_min} 分钟)", + "· 推送完毕后本条监控自动结案", + "· 不参与自动开仓", + ] + if extra_note: + lines.append(f"· {extra_note}") + return "\n".join(lines) diff --git a/lib/env/env_file_lib.py b/lib/env/env_file_lib.py new file mode 100644 index 0000000..e16a49c --- /dev/null +++ b/lib/env/env_file_lib.py @@ -0,0 +1,121 @@ +"""读写实例目录 .env(行级 upsert,原子落盘).""" +from __future__ import annotations + +import os +import re +import tempfile +from typing import Optional + +_KEY_LINE = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$") + + +def parse_env_lines(text: str) -> list[str]: + return text.replace("\r\n", "\n").replace("\r", "\n").splitlines() + + +def read_env_lines(path: str) -> list[str]: + if not os.path.isfile(path): + return [] + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return parse_env_lines(f.read()) + + +def env_get(lines: list[str], key: str) -> Optional[str]: + for line in lines: + m = _KEY_LINE.match(line) + if m and m.group(2) == key: + raw = m.group(3).strip() + if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")): + return raw[1:-1] + return raw + return None + + +def env_get_all(lines: list[str]) -> dict[str, str]: + out: dict[str, str] = {} + for line in lines: + m = _KEY_LINE.match(line) + if m: + key = m.group(2) + raw = m.group(3).strip() + if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")): + out[key] = raw[1:-1] + else: + out[key] = raw + return out + + +def upsert_env_line(lines: list[str], key: str, value: str) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=") + out: list[str] = [] + replaced = False + safe = value if value is not None else "" + if any(c in safe for c in (' ', '#', '"', "'")): + safe = '"' + safe.replace("\\", "\\\\").replace('"', '\\"') + '"' + new_line = f"{key}={safe}" + for line in lines: + if pat.match(line): + if not replaced: + out.append(new_line) + replaced = True + continue + out.append(line) + if not replaced: + if out and out[-1].strip(): + out.append("") + out.append(new_line) + return out + + +def write_env_lines_atomic(path: str, lines: list[str]) -> None: + directory = os.path.dirname(os.path.abspath(path)) or "." + os.makedirs(directory, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=".env.", dir=directory, text=True) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: + f.write("\n".join(lines)) + if lines: + f.write("\n") + os.replace(tmp, path) + finally: + if os.path.exists(tmp): + try: + os.remove(tmp) + except OSError: + pass + + +def apply_env_updates(path: str, updates: dict[str, str]) -> list[str]: + lines = read_env_lines(path) + changed: list[str] = [] + for key, value in updates.items(): + if value is None: + continue + old = env_get(lines, key) + if old == value: + continue + lines = upsert_env_line(lines, key, value) + changed.append(key) + if changed: + write_env_lines_atomic(path, lines) + return changed + + +def load_env_file_into_environ(path: str) -> None: + if not os.path.exists(path): + return + with open(path, "r", encoding="utf-8", errors="ignore") as f: + text = f.read() + if text.startswith("\ufeff"): + text = text[1:] + for line in parse_env_lines(text): + s = line.strip() + if not s or s.startswith("#"): + continue + if "=" not in s: + continue + k, _, v = s.partition("=") + clean_key = k.strip() + clean_val = v.strip().strip('"').strip("'") + if clean_key: + os.environ[clean_key] = clean_val diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py new file mode 100644 index 0000000..8882ca3 --- /dev/null +++ b/lib/env/env_schema.py @@ -0,0 +1,418 @@ +"""从 .env.example 构建 env 配置 schema(分组,敏感,重启标注).""" +from __future__ import annotations + +import os +import re +from typing import Any, Optional + +from lib.env.env_file_lib import env_get, env_get_all, read_env_lines + +_GROUP_RE = re.compile(r"^#\s*=+\s*(.+?)\s*=+\s*$") +_SEPARATOR_RE = re.compile(r"^#\s*=+\s*$") +_SECTION_DASH_RE = re.compile(r"^#\s*---\s*(.+?)\s*---\s*$") +_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=") + +RESTART_REQUIRED_EXACT = frozenset({ + "APP_HOST", + "APP_PORT", + "APP_DEBUG", + "DB_PATH", + "UPLOAD_DIR", + "FLASK_SECRET_KEY", + "POSITION_SIZING_MODE", + "LIVE_TRADING_ENABLED", + "OKX_TD_MODE", + "OKX_POS_MODE", + "OKX_POSITION_INST_TYPE", + "BINANCE_MARGIN_MODE", + "BINANCE_POSITION_MODE", + "GATE_TD_MODE", + "GATE_POS_MODE", + "PM2_APP_NAME", +}) + +RESTART_REQUIRED_PREFIXES = ( + "OKX_API_", + "OKX_OPTIONS_API_", + "BINANCE_API_", + "GATE_API_", + "OKX_SOCKS_", + "OKX_HTTP_", + "OKX_HTTPS_", + "BINANCE_HTTP_", + "BINANCE_HTTPS_", + "GATE_HTTP_", + "GATE_HTTPS_", +) + +HOT_RELOAD_EXACT = frozenset({ + "RISK_PERCENT", + "MAX_ACTIVE_POSITIONS", + "MANUAL_MIN_PLANNED_RR", + "DAILY_OPEN_ALERT_THRESHOLD", + "DAILY_OPEN_HARD_LIMIT", + "TRADING_DAY_RESET_HOUR", + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", + "RISK_CONTROL_ENABLED", + "RISK_COOLING_HOURS_MANUAL", + "RISK_COOLING_HOURS_MANUAL_JOURNAL", + "RISK_MANUAL_CLOSE_DAILY_LIMIT", + "RISK_DAILY_LOSS_LIMIT", + "RISK_MOOD_ISSUES_DAILY_FREEZE", + "TRADE_DIRECTION_RESTRICT_ENABLED", + "TRADE_DIRECTION", + "TRADE_SYMBOL_RESTRICT_ENABLED", + "TRADE_SYMBOL_WHITELIST", + "BALANCE_REFRESH_SECONDS", + "PRICE_REFRESH_SECONDS", + "MONITOR_POLL_SECONDS", + "AUTO_TRANSFER_ENABLED", + "AUTO_TRANSFER_AMOUNT", + "AUTO_TRANSFER_FROM", + "AUTO_TRANSFER_TO", + "AUTO_TRANSFER_BJ_HOUR", + "TRANSFER_CCY", + "FORCE_CLOSE_ENABLED", + "FORCE_CLOSE_BJ_HOUR", + "FORCE_CLOSE_GRACE_MINUTES", + "BTC_LEVERAGE", + "ALT_LEVERAGE", + "DAILY_START_CAPITAL", + "DAILY_LOSS_CAPITAL", + "DAILY_PROFIT_CAPITAL", + "FULL_MARGIN_BUFFER_RATIO", + "APP_USERNAME", + "APP_PASSWORD", + "APP_AUTH_DISABLED", + "WECHAT_WEBHOOK", + "HEDGE_PLAN_ENABLED", + "HEDGE_PLAN_SHOW_PERP_OPTIONS", + "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", + "OKX_SHOW_PERP_FUNDS", + "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", + "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", + "OKX_OPTIONS_MAX_DTE_DAYS", + "OKX_OPTIONS_MAX_ACTIVE_POSITIONS", + "OKX_OPTIONS_COMPOUND_FULL_ENABLED", + "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", + "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", + "OKX_OPTIONS_TRADE_BUDGET_USDC", + "OKX_OPTIONS_BUDGET_BUFFER", + "OKX_TRADE_MODE", + "MAX_ACTIVE_HEDGE_PLANS", + "HEDGE_PLAN_LIVE_ORDER", + "HEDGE_PLAN_OPTION_PRIMARY", + "HEDGE_PLAN_OPEN_ORDER", + "HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", + "HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", + "HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", + "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", + "HEDGE_PLAN_OO_BIAS_SPLIT_BY", + "HEDGE_PLAN_OO_BIAS_RATIO", + "HEDGE_PLAN_BUDGET_BUFFER", + "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", + "MAX_ACTIVE_HEDGE_PLANS", + "HEDGE_PLAN_MONITOR_POLL_SECONDS", + "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", +}) + +SENSITIVE_EXACT = frozenset({ + "APP_PASSWORD", + "FLASK_SECRET_KEY", + "OPENAI_API_KEY", +}) + +SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD") + +# env 配置页下拉:value → 中文标签 +SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = { + "OKX_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")), + "OKX_POS_MODE": (("hedge", "双向"), ("net", "单向净持仓")), + "BINANCE_MARGIN_MODE": (("cross", "全仓"), ("isolated", "逐仓")), + "BINANCE_POSITION_MODE": (("hedge", "双向"), ("one_way", "单向")), + "GATE_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")), + "GATE_POS_MODE": (("hedge", "双向"), ("single", "单向")), + "POSITION_SIZING_MODE": (("risk", "以损定仓"), ("full_margin", "全仓杠杆")), + "TRADE_DIRECTION": ( + ("both", "双向均可"), + ("long_only", "仅做多"), + ("short_only", "仅做空"), + ), + "AUTO_TRANSFER_FROM": ( + ("funding", "funding 资金账户"), + ("swap", "swap 交易账户"), + ("spot", "spot 现货"), + ), + "AUTO_TRANSFER_TO": ( + ("swap", "swap 交易账户"), + ("funding", "funding 资金账户"), + ("spot", "spot 现货"), + ), + "TRANSFER_CCY": (("USDT", "USDT"),), + "HEDGE_PLAN_OO_BIAS_SPLIT_BY": ( + ("budget", "预算金额"), + ("sheets", "张数"), + ), + "OKX_TRADE_MODE": ( + ("options", "单独期权"), + ("perp_options", "永期对冲"), + ("options_options", "期期对冲"), + ), + "HEDGE_PLAN_OPTION_PRIMARY": ( + ("true", "以期权为主"), + ("false", "保险模式"), + ), +} + +_SELECT_ALIASES: dict[str, dict[str, str]] = { + "OKX_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"}, + "BINANCE_MARGIN_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"}, + "GATE_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"}, + "TRANSFER_CCY": {"usdt": "USDT"}, +} + + +def _is_sensitive(key: str) -> bool: + if key in SENSITIVE_EXACT: + return True + return any(s in key for s in SENSITIVE_SUBSTR) + + +def select_options_for(key: str) -> list[dict[str, str]]: + opts = SELECT_OPTIONS.get(key) or () + return [{"value": v, "label": lab} for v, lab in opts] + + +def normalize_select_value(key: str, value: Optional[str]) -> str: + raw = (value or "").strip() + if not raw: + return "" + low = raw.lower() + aliases = _SELECT_ALIASES.get(key) or {} + if low in aliases: + return aliases[low] + allowed = {v for v, _ in (SELECT_OPTIONS.get(key) or ())} + allowed_by_lower = {v.lower(): v for v in allowed} + if low in allowed: + return low + if raw in allowed: + return raw + if low in allowed_by_lower: + return allowed_by_lower[low] + return raw + + +def _restart_required(key: str) -> bool: + if key in HOT_RELOAD_EXACT: + return False + if key in RESTART_REQUIRED_EXACT: + return True + return any(key.startswith(p) for p in RESTART_REQUIRED_PREFIXES) + + +def _hot_reload(key: str) -> bool: + if key in HOT_RELOAD_EXACT: + return True + if _restart_required(key): + return False + return key.startswith(("KEY_", "KLINE_", "BREAKEVEN_", "RECONCILE_", "ORDER_CHART_")) + + +def _field_type(key: str, value: str) -> str: + if key in SELECT_OPTIONS: + return "select" + low = (value or "").strip().lower() + if low in ("true", "false"): + return "bool" + if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in ( + "OKX_SHOW_PERP_FUNDS", + "HEDGE_PLAN_SHOW_PERP_OPTIONS", + "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", + ): + return "bool" + try: + if "." in low: + float(low) + return "float" + int(low) + return "int" + except ValueError: + pass + return "text" + + +def _mask_value(key: str, value: Optional[str]) -> dict[str, Any]: + if value is None or value == "": + return {"value": "", "masked": "", "tail": "", "has_value": False} + if not _is_sensitive(key): + return {"value": value, "masked": value, "tail": "", "has_value": True} + tail = value[-4:] if len(value) >= 4 else value + return {"value": "", "masked": f"****{tail}", "tail": tail, "has_value": True} + + +def parse_env_example_schema(example_path: str) -> list[dict[str, Any]]: + if not os.path.isfile(example_path): + return [] + lines = read_env_lines(example_path) + groups: list[dict[str, Any]] = [] + group_map: dict[str, dict[str, Any]] = {} + current_group = "基础配置" + pending_note: list[str] = [] + in_section_block = False + section_title_set = False + allow_section_blocks = False + + def _ensure_group(title: str) -> dict[str, Any]: + title = (title or "").strip() or "其他" + if title not in group_map: + group_map[title] = {"title": title, "fields": []} + groups.append(group_map[title]) + return group_map[title] + + for raw in lines: + line = raw.rstrip() + stripped = line.strip() + if not stripped: + pending_note = [] + continue + if _SEPARATOR_RE.match(stripped): + if not allow_section_blocks: + continue + if not in_section_block: + in_section_block = True + section_title_set = False + else: + in_section_block = False + continue + if in_section_block and stripped.startswith("#"): + note = stripped.lstrip("#").strip() + if note and not section_title_set: + current_group = note + _ensure_group(current_group) + section_title_set = True + elif note: + pending_note.append(note) + continue + gm = _GROUP_RE.match(stripped) + if gm: + title = gm.group(1).strip() + if title and title != "=": + current_group = title + _ensure_group(current_group) + in_section_block = False + section_title_set = False + pending_note = [] + continue + dash = _SECTION_DASH_RE.match(stripped) + if dash: + allow_section_blocks = True + current_group = dash.group(1).strip() + _ensure_group(current_group) + in_section_block = False + section_title_set = False + pending_note = [] + continue + if stripped.startswith("#"): + note = stripped.lstrip("#").strip() + if note and not note.startswith("="): + pending_note.append(note) + continue + km = _KEY_LINE.match(stripped) + if not km: + continue + key = km.group(1) + allow_section_blocks = True + default_val = env_get(lines, key) or "" + grp = _ensure_group(current_group) + note = " ".join(pending_note).strip() + grp["fields"].append( + { + "key": key, + "label": key, + "note": note, + "default": default_val, + "type": _field_type(key, default_val), + "sensitive": _is_sensitive(key), + "restart_required": _restart_required(key), + "hot_reload": _hot_reload(key), + } + ) + pending_note = [] + return [g for g in groups if g.get("fields")] + + +def build_env_payload(example_path: str, env_path: str) -> dict[str, Any]: + groups = parse_env_example_schema(example_path) + env_lines = read_env_lines(env_path) + values = env_get_all(env_lines) + for group in groups: + for field in group.get("fields") or []: + key = field["key"] + val = values.get(key) + if val is None: + val = field.get("default") or "" + masked = _mask_value(key, val) + field["current"] = masked["value"] if not field["sensitive"] else "" + field["masked"] = masked["masked"] + field["has_value"] = masked["has_value"] + return {"groups": groups} + + +def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[dict[str, str], list[str]]: + allowed = {} + for group in groups: + for field in group.get("fields") or []: + allowed[field["key"]] = field + clean: dict[str, str] = {} + errors: list[str] = [] + for key, value in (updates or {}).items(): + if key not in allowed: + errors.append(f"未知配置项: {key}") + continue + if value is None: + continue + val = str(value).strip() + if allowed[key].get("sensitive") and (val == "" or (val.startswith("****") and len(val) <= 8)): + continue + # API Key 被密码管理器/自动填充成登录密码时通常很短;OKX Key 一般为 36 位 + if key.endswith("_API_KEY") and 0 < len(val) < 16: + errors.append(f"{key} 长度异常,疑似自动填充;留空则不修改已有密钥") + continue + ftype = allowed[key].get("type") + if ftype == "bool": + low = val.lower() + if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"): + errors.append(f"{key} 须为 true/false") + continue + val = "true" if low in ("true", "1", "yes", "on") else "false" + elif ftype == "select" or key in SELECT_OPTIONS: + allowed_vals = { + str(o.get("value") if isinstance(o, dict) else o[0]).lower() + for o in (allowed[key].get("options") or select_options_for(key)) + } + norm = normalize_select_value(key, val) + if allowed_vals and norm.lower() not in allowed_vals: + labels = " / ".join( + f"{o['value']}({o['label']})" if isinstance(o, dict) else f"{o[0]}({o[1]})" + for o in (allowed[key].get("options") or select_options_for(key)) + ) + errors.append(f"{key} 须为: {labels}") + continue + val = norm + clean[key] = val + return clean, errors + + +def updates_need_restart(groups: list[dict], changed_keys: list[str]) -> bool: + field_map = {} + for group in groups: + for field in group.get("fields") or []: + field_map[field["key"]] = field + for key in changed_keys: + meta = field_map.get(key) or {} + if meta.get("restart_required"): + return True + if not meta.get("hot_reload"): + return True + return False diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py new file mode 100644 index 0000000..5bde533 --- /dev/null +++ b/lib/env/env_ui_manifest.py @@ -0,0 +1,561 @@ +"""env 配置页 UI 白名单:中文标签,按交易所过滤.""" +from __future__ import annotations + +import os +from typing import Any, Optional + +from lib.env.env_file_lib import env_get_all, read_env_lines +from lib.env.env_schema import ( + _field_type, + _hot_reload, + _is_sensitive, + _mask_value, + _restart_required, + normalize_select_value, + parse_env_example_schema, + select_options_for, +) + +# 各所「交易所与实盘」字段(顺序即页面顺序) +_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = { + "okx": [ + ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), + ("OKX_API_KEY", "API Key", "账户 API(永续+期权共用)"), + ("OKX_API_SECRET", "API Secret", "账户 API(永续+期权共用)"), + ("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"), + ("OKX_TD_MODE", "保证金模式", ""), + ("OKX_POS_MODE", "持仓模式", ""), + ("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"), + ("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), + ( + "OKX_SHOW_PERP_FUNDS", + "显示永续资金", + "默认开启;关闭后顶栏隐藏 USDT 资金账户与交易账户,总资金仅计期权 USDC 侧", + ), + ], + "binance": [ + ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), + ("BINANCE_API_KEY", "API Key", "永续子账户"), + ("BINANCE_API_SECRET", "API Secret", "永续子账户"), + ("BINANCE_MARGIN_MODE", "保证金模式", ""), + ("BINANCE_POSITION_MODE", "持仓模式", ""), + ("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), + ], + "gate": [ + ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), + ("GATE_API_KEY", "API Key", "永续子账户"), + ("GATE_API_SECRET", "API Secret", "永续子账户"), + ("GATE_TD_MODE", "保证金模式", ""), + ("GATE_POS_MODE", "持仓模式", ""), + ("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), + ], +} + +_SHARED_SECTIONS: list[dict[str, Any]] = [ + { + "title": "企业微信", + "fields": [ + ("WECHAT_WEBHOOK", "机器人 Webhook", "行情与风控推送地址"), + ("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"), + ], + }, + { + "title": "交易执行", + "fields": [ + ("POSITION_SIZING_MODE", "计仓模式", "切换须无仓后重启"), + ("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"), + ("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"), + ("BTC_LEVERAGE", "BTC 默认杠杆", ""), + ("ALT_LEVERAGE", "山寨默认杠杆", ""), + ("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", ""), + ("TRADE_DIRECTION", "允许方向", "需同时开启「方向限制开关」才生效"), + ("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""), + ("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"), + ("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"), + ( + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", + "切点前禁止新开仓", + "默认 true;开启则北京时间切点前禁止斐波登记与人工开仓;说明见风控说明·交易执行", + ), + ("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""), + ("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"), ("FORCE_CLOSE_ENABLED", "强制清仓开关", ""), + ("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""), + ("FORCE_CLOSE_GRACE_MINUTES", "强制清仓窗口(分钟)", "默认 5;整点起该分钟内执行并禁止开仓"), + ], + }, + { + "title": "交易风控", + "fields": [ + ("DAILY_OPEN_ALERT_THRESHOLD", "单日开仓提醒阈值", "达次数后 AI 提醒,不拦单"), + ("DAILY_OPEN_HARD_LIMIT", "单日开仓硬上限", "0=不启用"), + ], + }, + { + "title": "账户冷静期", + "fields": [ + ("RISK_CONTROL_ENABLED", "冷静期总开关", ""), + ("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""), + ("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""), + ("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""), + ("RISK_DAILY_LOSS_LIMIT", "日亏损次数上限", "默认2;达限当日冻结开仓;0=不因亏损次数冻结"), + ("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""), + ], + }, + { + "title": "自动划转", + "fields": [ + ("AUTO_TRANSFER_ENABLED", "启用自动划转", ""), + ("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"), + ("AUTO_TRANSFER_FROM", "划出账户", "余额不足时从此账户划入交易账户"), + ("AUTO_TRANSFER_TO", "划入账户", "目标余额所在账户,一般为 swap"), + ("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""), + ("TRANSFER_CCY", "划转币种", ""), + ], + }, + { + "title": "当日资金", + "fields": [ + ("DAILY_START_CAPITAL", "日起始基数(U)", ""), + ("DAILY_LOSS_CAPITAL", "回撤后基数(U)", ""), + ("DAILY_PROFIT_CAPITAL", "盈利后基数(U)", ""), + ], + }, +] + +_MODE_SECTION: dict[str, Any] = { + "title": "期权/对冲模式", + "exchanges": frozenset({"okx"}), + "fields": [ + ( + "OKX_TRADE_MODE", + "交易模式", + "三选一:单独期权 / 永期对冲 / 期期对冲.选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权", + ), + ], +} + +_OPTIONS_SECTION: dict[str, Any] = { + "title": "期权账户", + "exchanges": frozenset({"okx"}), + "fields": [ + ("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"), + ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""), + ( + "OKX_OPTIONS_TRADE_BUDGET_USDC", + "单笔预算(USDC)", + "仅全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限", + ), + ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;打满/全仓复利共用"), + ( + "OKX_OPTIONS_COMPOUND_FULL_ENABLED", + "全仓复利开关", + "默认 true;开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利", + ), + ( + "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", + "全仓复利上限开关", + "仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶", + ), + ( + "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", + "全仓复利上限(USDC)", + "仅「全仓复利」且「上限开关」都开启时生效;例如 300", + ), + ( + "OKX_OPTIONS_MAX_ACTIVE_POSITIONS", + "期权持仓上限(笔)", + "仅「单独期权」模式生效;默认 0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数", + ), + ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"), + ( + "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", + "期权链展示天数", + "默认 14;下拉到期日只出现该天数内的合约(含明天)", + ), + ( + "OKX_OPTIONS_MAX_DTE_DAYS", + "开仓最大剩余天数", + "默认 2;单独开期权时拒绝更远到期(与链展示天数独立)", + ), + ( + "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", + "链上仅显示有卖一", + "默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)", + ), + ], +} + +# 对冲公共字段(不含已由 OKX_TRADE_MODE 取代的 ENABLED/SHOW/MUTUAL) +_HEDGE_COMMON_FIELDS: list[tuple[str, str, str]] = [ + ("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动"), + ( + "MAX_ACTIVE_HEDGE_PLANS", + "对冲组数上限", + "默认 1;同时进行中的对冲计划组数(opening/active/partial),可改", + ), + ("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"), + ( + "HEDGE_PLAN_BUDGET_BUFFER", + "对冲预算缓冲比例", + "默认 0.95;仅对冲计划;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立", + ), + ( + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", + "半腿失败改手动补开", + "默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开;并强制关闭下方自动平", + ), + ( + "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", + "半腿失败时自动平期权", + "默认 true;若上方「半腿失败改手动补开」开启则本项强制无效", + ), +] + +_HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [ + ( + "HEDGE_PLAN_OPTION_PRIMARY", + "永期模式(以期权为主/保险)", + "默认 true=以期权为主;false=保险模式;页面标题前显示标识,不可在页内切换", + ), + ("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"), + ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"), + ("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"), + ( + "HEDGE_PLAN_ITM_MAX_DIST_USD", + "永期实值最大深度(U)", + "默认空=沿用 OKX_OPTIONS_ITM_MAX_DIST_USD(常 30);0=不限制", + ), + ( + "HEDGE_PLAN_MIN_OPTION_HOURS", + "对冲期权最低剩余小时", + "默认 8;测算/启动时若传 hours_to_expiry 则校验", + ), + ( + "HEDGE_PLAN_MIN_OPTION_LEVERAGE", + "对冲期权最低杠杆(S/ask)", + "默认 0=不启用;>0 时拒绝杠杆过低的保险腿", + ), +] + +_HEDGE_OO_FIELDS: list[tuple[str, str, str]] = [ + ("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"), + ( + "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", + "期期平仓模式(方案C)", + "默认 true;开启后页面可选「到期平/全平」;关闭则固定到期平", + ), + ( + "HEDGE_PLAN_OO_BIAS_SPLIT_BY", + "期期做多做空拆分口径", + "默认预算金额;budget=按权利金预算分两腿;sheets=先算同张数再按比例拆", + ), + ( + "HEDGE_PLAN_OO_BIAS_RATIO", + "期期做多做空主腿占比", + "默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间", + ), +] + +# 兼容旧测试/全量字段列表(写 env 时仍允许这些键,但 UI 按模式过滤) +_HEDGE_PLAN_SECTION: dict[str, Any] = { + "title": "对冲计划", + "exchanges": frozenset({"okx"}), + "fields": [ + ("HEDGE_PLAN_ENABLED", "启用对冲计划", "已由「交易模式」取代,一般无需再改"), + ("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "已由「交易模式」取代"), + ("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "已由「交易模式」取代"), + ("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "对冲与期权互斥门控", "已由「交易模式」三选一取代"), + *_HEDGE_COMMON_FIELDS, + *_HEDGE_PO_FIELDS, + *_HEDGE_OO_FIELDS, + ], +} + + +# 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页) +_RUNTIME_ENV_DEFAULTS: dict[str, str] = { + "RISK_CONTROL_ENABLED": "true", + "RISK_COOLING_HOURS_MANUAL": "4", + "RISK_COOLING_HOURS_MANUAL_JOURNAL": "1", + "RISK_MANUAL_CLOSE_DAILY_LIMIT": "2", + "RISK_DAILY_LOSS_LIMIT": "2", + "RISK_MOOD_ISSUES_DAILY_FREEZE": "true", + "AUTO_TRANSFER_FROM": "funding", + "AUTO_TRANSFER_TO": "swap", + "TRANSFER_CCY": "USDT", + "HEDGE_PLAN_SHOW_PERP_OPTIONS": "true", + "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true", + "OKX_SHOW_PERP_FUNDS": "true", + "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true", + "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS": "14", + "OKX_OPTIONS_MAX_DTE_DAYS": "2", + "OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0", + "OKX_TRADE_MODE": "options", + "MAX_ACTIVE_HEDGE_PLANS": "1", + "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true", + "HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget", + "HEDGE_PLAN_OO_BIAS_RATIO": "0.7", + "HEDGE_PLAN_BUDGET_BUFFER": "0.95", + "HEDGE_PLAN_OPTION_PRIMARY": "true", + "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true", + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true", +} + + +def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str: + if key == "OKX_TRADE_MODE": + # 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式 + file_val = str(file_values.get(key) or "").strip() if key in file_values else "" + if file_val: + from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode + + return normalize_okx_trade_mode(file_val) or file_val + try: + from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode + + return get_okx_trade_mode() + except Exception: + pass + if key in file_values: + file_val = str(file_values.get(key) or "").strip() + if file_val: + return file_val + runtime = os.getenv(key) + if runtime is not None and str(runtime).strip() != "": + return str(runtime).strip() + if schema_default: + return schema_default + return _RUNTIME_ENV_DEFAULTS.get(key, "") + + +def _env_truthy(raw: str) -> bool: + return str(raw or "").strip().lower() in ("1", "true", "yes", "on") + + +def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + for group in parse_env_example_schema(example_path): + for field in group.get("fields") or []: + out[field["key"]] = dict(field) + return out + + +def _build_field( + key: str, + label: str, + note: str, + schema: dict[str, dict[str, Any]], + values: dict[str, str], +) -> dict[str, Any]: + meta = schema.get(key) or {} + schema_default = meta.get("default") or "" + val = _effective_env_value(key, values, schema_default) + # 与运行时一致:手动补开开启时,「自动平期权」展示为关闭(实际也不会执行) + if key == "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION": + manual = _effective_env_value( + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", values, "true" + ) + if _env_truthy(manual): + val = "false" + masked = _mask_value(key, val) + ftype = meta.get("type") or _field_type(key, val or schema_default) + options = select_options_for(key) + if options: + ftype = "select" + val = normalize_select_value(key, val) or val + masked = _mask_value(key, val) + out: dict[str, Any] = { + "key": key, + "label": label, + "note": note or meta.get("note") or "", + "default": val, + "type": ftype, + "sensitive": meta.get("sensitive", _is_sensitive(key)), + "restart_required": meta.get("restart_required", _restart_required(key)), + "hot_reload": meta.get("hot_reload", _hot_reload(key)), + "current": masked["value"] if not _is_sensitive(key) else "", + "masked": masked["masked"], + "tail": masked.get("tail") or "", + "has_value": masked["has_value"], + } + if options: + cur = (out["current"] or out["default"] or "").strip() + opt_vals = {o["value"] for o in options} + if cur and cur not in opt_vals: + options = [{"value": cur, "label": cur}] + options + out["options"] = options + return out + + +def _okx_mode_for_env_ui() -> str: + try: + from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode + + return get_okx_trade_mode() + except Exception: + return "options" + + +def _options_fields_for_mode(mode: str) -> list[tuple[str, str, str]]: + fields = list(_OPTIONS_SECTION["fields"]) + if mode != "options": + fields = [f for f in fields if f[0] != "OKX_OPTIONS_MAX_ACTIVE_POSITIONS"] + return fields + + +def _hedge_fields_for_mode(mode: str) -> list[tuple[str, str, str]]: + if mode == "perp_options": + return [*_HEDGE_COMMON_FIELDS, *_HEDGE_PO_FIELDS] + if mode == "options_options": + return [*_HEDGE_COMMON_FIELDS, *_HEDGE_OO_FIELDS] + return [] + + +def ui_sections_for_exchange( + exchange_key: str, + *, + mode: str | None = None, +) -> list[dict[str, Any]]: + ex = (exchange_key or "").strip().lower() + sections: list[dict[str, Any]] = [] + live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"]) + sections.append({"title": "交易所与实盘", "fields": live_fields}) + sections.extend(_SHARED_SECTIONS) + if ex in _MODE_SECTION.get("exchanges", frozenset()): + from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode + + m = normalize_okx_trade_mode(mode) if mode else "" + if not m: + m = _okx_mode_for_env_ui() + sections.append(_MODE_SECTION) + sections.append({"title": "期权账户", "fields": _options_fields_for_mode(m)}) + hedge_fields = _hedge_fields_for_mode(m) + if hedge_fields: + title = "对冲计划·永期" if m == "perp_options" else "对冲计划·期期" + sections.append({"title": title, "fields": hedge_fields}) + return sections + + +def ui_allowed_keys(exchange_key: str) -> frozenset[str]: + """可写键=当前模式可见字段 + 模式切换键 + 遗留对冲开关(兼容旧脚本写入).""" + keys: set[str] = set() + for sec in ui_sections_for_exchange(exchange_key): + for item in sec["fields"]: + keys.add(item[0]) + ex = (exchange_key or "").strip().lower() + if ex == "okx": + keys.add("OKX_TRADE_MODE") + # 允许写入遗留键,避免旧自动化/手改失败;页面不再展示 + for item in _HEDGE_PLAN_SECTION["fields"]: + keys.add(item[0]) + for item in _OPTIONS_SECTION["fields"]: + keys.add(item[0]) + return frozenset(keys) + + +def build_env_ui_payload( + exchange_key: str, + example_path: str, + env_path: str, +) -> list[dict[str, Any]]: + schema = _schema_field_map(example_path) + env_lines = read_env_lines(env_path) + values = env_get_all(env_lines) + groups: list[dict[str, Any]] = [] + for sec in ui_sections_for_exchange( + exchange_key, mode=values.get("OKX_TRADE_MODE") or "" + ): + fields = [ + _build_field(key, label, note, schema, values) + for key, label, note in sec["fields"] + ] + fields = _mark_compound_budget_hidden(fields) + groups.append({ + "title": sec["title"], + "fields": fields, + "has_restart": any(f.get("restart_required") for f in fields), + }) + return groups + + +def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]: + """全仓复利开启时标记单笔预算为 hidden(供 SSR/前端隐藏;切换开关仍可再显示).""" + compound_on = True + for f in fields: + if f.get("key") == "OKX_OPTIONS_COMPOUND_FULL_ENABLED": + compound_on = _env_truthy(str(f.get("current") or f.get("default") or "true")) + break + if not compound_on: + return fields + out: list[dict[str, Any]] = [] + for f in fields: + if f.get("key") == "OKX_OPTIONS_TRADE_BUDGET_USDC": + item = dict(f) + item["hidden"] = True + out.append(item) + else: + out.append(f) + return out + + +def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]: + allowed = ui_allowed_keys(exchange_key) + return {k: v for k, v in (updates or {}).items() if k in allowed} + + +def validate_env_ui_updates( + exchange_key: str, + example_path: str, + updates: dict[str, str], +) -> tuple[dict[str, str], list[str]]: + from lib.env.env_schema import validate_env_updates + + schema = _schema_field_map(example_path) + groups: list[dict[str, Any]] = [] + for sec in ui_sections_for_exchange(exchange_key): + fields: list[dict[str, Any]] = [] + for key, _label, _note in sec["fields"]: + if key in schema: + field = dict(schema[key]) + opts = select_options_for(key) + if opts: + field["type"] = "select" + field["options"] = opts + fields.append(field) + else: + default = "" + fields.append( + { + "key": key, + "type": _field_type(key, default), + "sensitive": _is_sensitive(key), + "restart_required": _restart_required(key), + "hot_reload": _hot_reload(key), + "options": select_options_for(key), + } + ) + groups.append({"title": sec["title"], "fields": fields}) + return validate_env_updates(groups, updates) + + +def coerce_hedge_partial_close_with_manual( + clean: dict[str, str], + *, + env_path: str = "", +) -> dict[str, str]: + """手动补开为开启时,强制把自动平写成 false(与运行时一致).""" + out = dict(clean or {}) + manual = out.get("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL") + if manual is None and env_path: + try: + from lib.env.env_file_lib import env_get_all, read_env_lines + + file_vals = env_get_all(read_env_lines(env_path)) + manual = _effective_env_value( + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", file_vals, "true" + ) + except Exception: + manual = "true" + if _env_truthy(str(manual or "")): + out["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "false" + return out diff --git a/lib/env/shared_env_lib.py b/lib/env/shared_env_lib.py new file mode 100644 index 0000000..e4e1419 --- /dev/null +++ b/lib/env/shared_env_lib.py @@ -0,0 +1,190 @@ +"""Local AI env helpers (standalone project).""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +from lib.env.env_file_lib import apply_env_updates, env_get_all, load_env_file_into_environ, read_env_lines +from lib.env.env_schema import ( + _field_type, + _hot_reload, + _is_sensitive, + _mask_value, + _restart_required, + parse_env_example_schema, + validate_env_updates, +) +from lib.paths import REPO_ROOT + +AI_ENV_FIELDS: list[tuple[str, str, str]] = [ + ("AI_PROVIDER", "AI 提供方", "openai 或 ollama"), + ("OPENAI_API_BASE", "API 地址", "OpenAI 兼容接口"), + ("OPENAI_API_KEY", "API 密钥", "留空表示不修改"), + ("OPENAI_MODEL", "云端模型", ""), + ("OLLAMA_API", "Ollama 地址", "本地服务 URL"), + ("AI_MODEL", "Ollama 模型", ""), + ("AI_TIMEOUT_SECONDS", "请求超时(秒)", "默认 120"), +] + +AI_ENV_KEYS = frozenset(k for k, _l, _n in AI_ENV_FIELDS) + +def local_env_path() -> str: + return str(REPO_ROOT / ".env") + + +def local_example_path() -> str: + return str(REPO_ROOT / ".env.example") + + +def instance_example_path(exchange_key: str = "okx") -> str: + return local_example_path() + + +def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + for group in parse_env_example_schema(example_path): + for field in group.get("fields") or []: + out[field["key"]] = dict(field) + return out + + +def _build_field( + key: str, + label: str, + note: str, + schema: dict[str, dict[str, Any]], + values: dict[str, str], +) -> dict[str, Any]: + meta = schema.get(key) or {} + schema_default = meta.get("default") or "" + val = values.get(key, "") + if val == "" and schema_default: + val = schema_default + masked = _mask_value(key, val) + ftype = meta.get("type") or _field_type(key, val or schema_default) + return { + "key": key, + "label": label, + "note": note or meta.get("note") or "", + "default": val, + "type": ftype, + "sensitive": meta.get("sensitive", _is_sensitive(key)), + "restart_required": meta.get("restart_required", _restart_required(key)), + "hot_reload": meta.get("hot_reload", _hot_reload(key)), + "current": masked["value"] if not _is_sensitive(key) else "", + "masked": masked["masked"], + "tail": masked.get("tail") or "", + "has_value": masked["has_value"], + } + + +def build_ai_env_payload(env_path: str | None = None, example_path: str | None = None) -> dict[str, Any]: + env_path = env_path or local_env_path() + example_path = example_path or local_example_path() + schema = _schema_field_map(example_path) + values = env_get_all(read_env_lines(env_path)) + fields = [ + _build_field(key, label, note, schema, values) + for key, label, note in AI_ENV_FIELDS + ] + sync_status = ai_sync_status() + return { + "title": "AI 复盘", + "fields": fields, + "sync_status": sync_status, + } + + +def ai_sync_status() -> dict[str, Any]: + """Standalone project: no multi-instance hub sync.""" + path = local_env_path() + if not os.path.isfile(path): + return {"all_synced": False, "instances": {"local": {"ok": False, "msg": "缺少 .env"}}} + return {"all_synced": True, "instances": {"local": {"ok": True, "mismatched_keys": []}}} + + + +def _ai_validate_groups(example_path: str) -> list[dict[str, Any]]: + schema = _schema_field_map(example_path) + fields: list[dict[str, Any]] = [] + for key, _label, _note in AI_ENV_FIELDS: + if key in schema: + fields.append(schema[key]) + else: + fields.append( + { + "key": key, + "type": _field_type(key, ""), + "sensitive": _is_sensitive(key), + "restart_required": _restart_required(key), + "hot_reload": _hot_reload(key), + } + ) + return [{"title": "AI 复盘", "fields": fields}] + + +def validate_ai_env_updates(updates: dict[str, str], example_path: str | None = None) -> tuple[dict[str, str], list[str]]: + example_path = example_path or local_example_path() + groups = _ai_validate_groups(example_path) + filtered = {k: v for k, v in (updates or {}).items() if k in AI_ENV_KEYS} + unknown = [k for k in (updates or {}) if k not in AI_ENV_KEYS] + errors = [f"未知配置项: {k}" for k in unknown] + clean, val_errors = validate_env_updates(groups, filtered) + errors.extend(val_errors) + return clean, errors + + +def apply_ai_env_to_all(updates: dict[str, str]) -> dict[str, Any]: + """Write AI keys to local .env only.""" + clean, errors = validate_ai_env_updates(updates) + if errors: + return {"ok": False, "errors": errors, "changed": {}} + if not clean: + return {"ok": True, "changed": {}, "restart_required": False} + + path = local_env_path() + changed_keys = apply_env_updates(path, clean) + if changed_keys: + load_env_file_into_environ(path) + restart_required = any( + (not _hot_reload(k)) or _restart_required(k) for k in changed_keys + ) + return { + "ok": True, + "changed": {"local": list(changed_keys)}, + "restart_required": restart_required, + } + + + + +def restart_local_pm2() -> dict[str, Any]: + """Restart this app via PM2 if configured.""" + return _restart_pm2_app("crypto_okx") + + + + + +def _restart_pm2_app(app_name: str) -> dict[str, Any]: + try: + proc = subprocess.run( + ["pm2", "restart", app_name, "--update-env"], + capture_output=True, + text=True, + timeout=120, + ) + return { + "ok": proc.returncode == 0, + "msg": (proc.stdout or proc.stderr or "").strip()[:500], + "returncode": proc.returncode, + } + except FileNotFoundError: + return {"ok": False, "msg": "未找到 pm2 命令"} + except subprocess.TimeoutExpired: + return {"ok": False, "msg": "pm2 restart 超时"} + except Exception as e: + return {"ok": False, "msg": str(e)} diff --git a/lib/exchange/__init__.py b/lib/exchange/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/exchange/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/exchange/binance_ledger_lib.py b/lib/exchange/binance_ledger_lib.py new file mode 100644 index 0000000..795b53a --- /dev/null +++ b/lib/exchange/binance_ledger_lib.py @@ -0,0 +1,187 @@ +"""Binance:交易账户 futures income;资金账户 deposits/withdrawals/transfers.USDT.""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.account_ledger.account_ledger_normalize import ( + ACCOUNT_FUNDING, + ACCOUNT_TRADING, + from_ccxt_ledger_entry, + kind_from_raw, + make_ref_id, + normalize_row, +) + + +def _paginate_income(exchange, *, start_ms: int, end_ms: int, max_pages: int = 15) -> list[dict]: + out: list[dict] = [] + cursor = int(start_ms) + end = int(end_ms) + for _ in range(max_pages): + try: + if hasattr(exchange, "fapiPrivateGetIncome"): + batch = exchange.fapiPrivateGetIncome( + {"startTime": cursor, "endTime": end, "limit": 1000} + ) + else: + batch = exchange.fetch_ledger( + "USDT", cursor, 1000, {"type": "swap", "until": end} + ) + # already unified + return batch or [] + except Exception: + break + if not batch: + break + out.extend(batch) + if len(batch) < 1000: + break + last_t = batch[-1].get("time") or batch[-1].get("timestamp") + try: + last_i = int(float(last_t)) + except Exception: + break + if last_i >= end: + break + cursor = last_i + 1 + return out + + +def _income_to_row(raw: dict) -> Optional[dict[str, Any]]: + if not isinstance(raw, dict): + return None + # raw fapi income + if "income" in raw or "incomeType" in raw: + amt = raw.get("income") + ts = raw.get("time") + ccy = raw.get("asset") or "USDT" + raw_type = str(raw.get("incomeType") or "") + ref = str(raw.get("tranId") or raw.get("tradeId") or "") + return normalize_row( + account=ACCOUNT_TRADING, + ccy=str(ccy), + amount=amt, + ts_ms=ts, + ref_id=ref or make_ref_id("trading", ccy, ts, amt, raw_type), + raw_type=raw_type, + symbol=str(raw.get("symbol") or ""), + note=str(raw.get("info") or ""), + kind=kind_from_raw(raw_type, float(amt) if amt is not None else None), + ) + return from_ccxt_ledger_entry(raw, account=ACCOUNT_TRADING) + + +def _dep_wd_to_row(entry: dict, *, kind: str) -> Optional[dict[str, Any]]: + if not isinstance(entry, dict): + return None + info = entry.get("info") if isinstance(entry.get("info"), dict) else {} + amount = entry.get("amount") + ts = entry.get("timestamp") or info.get("insertTime") or info.get("applyTime") + ccy = entry.get("currency") or info.get("coin") or "USDT" + status = entry.get("status") or info.get("status") or "" + ref = str(entry.get("id") or info.get("txId") or info.get("id") or "") + amt = amount + try: + af = float(amount) + if kind == "withdraw" and af > 0: + af = -af + amt = af + except Exception: + pass + return normalize_row( + account=ACCOUNT_FUNDING, + ccy=str(ccy), + amount=amt, + ts_ms=ts, + ref_id=ref or make_ref_id("funding", kind, ccy, ts, amount), + raw_type=kind, + note=str(status), + kind=kind, + ) + + +def _transfer_to_row(entry: dict) -> Optional[dict[str, Any]]: + if not isinstance(entry, dict): + return None + info = entry.get("info") if isinstance(entry.get("info"), dict) else {} + amount = entry.get("amount") + ts = entry.get("timestamp") or info.get("timestamp") + ccy = entry.get("currency") or info.get("asset") or "USDT" + ref = str(entry.get("id") or info.get("tranId") or info.get("id") or "") + frm = str(entry.get("fromAccount") or info.get("from") or "") + to = str(entry.get("toAccount") or info.get("to") or "") + try: + amt = float(amount) + except Exception: + return None + # 资金侧视角:从资金转出为负,转入为正(粗分) + note = f"{frm}->{to}".strip("->") + raw_type = "transfer" + return normalize_row( + account=ACCOUNT_FUNDING, + ccy=str(ccy), + amount=amt, + ts_ms=ts, + ref_id=ref or make_ref_id("funding", "transfer", ccy, ts, amt), + raw_type=raw_type, + note=note, + kind=kind_from_raw("transfer", amt), + ) + + +def fetch_binance_account_ledger( + exchange, + *, + start_ms: int, + end_ms: int, + ensure_markets: Optional[Callable[[], None]] = None, +) -> tuple[list[dict[str, Any]], list[str]]: + errors: list[str] = [] + rows: list[dict[str, Any]] = [] + if ensure_markets: + try: + ensure_markets() + except Exception as e: + errors.append(f"markets:{e}") + + # 交易账户 + try: + raw = _paginate_income(exchange, start_ms=start_ms, end_ms=end_ms) + for e in raw: + n = _income_to_row(e) + if n and n["ccy"] == "USDT": + rows.append(n) + except Exception as e: + errors.append(f"trading:{e}") + + # 资金账户:充提 + 划转 + for label, fn, kind in ( + ("deposits", "fetch_deposits", "deposit"), + ("withdrawals", "fetch_withdrawals", "withdraw"), + ): + try: + meth = getattr(exchange, fn, None) + if not callable(meth): + continue + batch = meth("USDT", int(start_ms), 1000, {"until": int(end_ms)}) or [] + for e in batch: + n = _dep_wd_to_row(e, kind=kind) + if n: + rows.append(n) + except Exception as e: + errors.append(f"{label}:{e}") + + try: + if hasattr(exchange, "fetch_transfers"): + batch = ( + exchange.fetch_transfers("USDT", int(start_ms), 1000, {"until": int(end_ms)}) + or [] + ) + for e in batch: + n = _transfer_to_row(e) + if n: + rows.append(n) + except Exception as e: + errors.append(f"transfers:{e}") + + return rows, errors diff --git a/lib/exchange/gate_ccxt_lib.py b/lib/exchange/gate_ccxt_lib.py new file mode 100644 index 0000000..0f143f6 --- /dev/null +++ b/lib/exchange/gate_ccxt_lib.py @@ -0,0 +1,9 @@ +"""Gate.io ccxt 构造(ccxt 4.x 起类名由 gateio 改为 gate).""" +from __future__ import annotations + +import ccxt + + +def gate_ccxt_class(): + """返回 ccxt Gate 交易所类(兼容旧版 gateio 名称).""" + return getattr(ccxt, "gate", None) or ccxt.gateio diff --git a/lib/exchange/gate_ledger_lib.py b/lib/exchange/gate_ledger_lib.py new file mode 100644 index 0000000..9df63a9 --- /dev/null +++ b/lib/exchange/gate_ledger_lib.py @@ -0,0 +1,212 @@ +"""Gate:资金账户(spot account_book) + 交易账户(futures account_book),USDT.""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.account_ledger.account_ledger_normalize import ( + ACCOUNT_FUNDING, + ACCOUNT_TRADING, + from_ccxt_ledger_entry, + kind_from_raw, + make_ref_id, + normalize_row, +) + + +def _sec(ms: int) -> int: + return max(0, int(int(ms) // 1000)) + + +def _paginate_spot_book(exchange, *, start_ms: int, end_ms: int, max_pages: int = 10) -> list[dict]: + out: list[dict] = [] + # Gate spot account_book: from/to 为秒 + cursor = _sec(start_ms) + end = _sec(end_ms) + for _ in range(max_pages): + try: + batch = exchange.privateSpotGetAccountBook( + { + "currency": "USDT", + "from": cursor, + "to": end, + "limit": 100, + } + ) + except Exception: + break + if not batch: + break + if isinstance(batch, dict): + batch = batch.get("data") or batch.get("result") or [] + if not isinstance(batch, list) or not batch: + break + out.extend(batch) + if len(batch) < 100: + break + last_t = batch[-1].get("time") or batch[-1].get("create_time") + try: + last_i = int(float(last_t)) + except Exception: + break + # spot 返回秒 + if last_i > 1e12: + last_i = last_i // 1000 + if last_i >= end: + break + cursor = last_i + 1 + return out + + +def _paginate_swap_book(exchange, *, start_ms: int, end_ms: int, max_pages: int = 10) -> list[dict]: + out: list[dict] = [] + cursor = _sec(start_ms) + end = _sec(end_ms) + for _ in range(max_pages): + try: + batch = exchange.privateFuturesGetSettleAccountBook( + { + "settle": "usdt", + "from": cursor, + "to": end, + "limit": 100, + } + ) + except Exception: + break + if not batch: + break + if isinstance(batch, dict): + batch = batch.get("data") or batch.get("result") or [] + if not isinstance(batch, list) or not batch: + break + out.extend(batch) + if len(batch) < 100: + break + last_t = batch[-1].get("time") + try: + last_i = int(float(last_t)) + except Exception: + break + if last_i > 1e12: + last_i = last_i // 1000 + if last_i >= end: + break + cursor = last_i + 1 + return out + + +def _spot_row(raw: dict) -> Optional[dict[str, Any]]: + if not isinstance(raw, dict): + return None + amt = raw.get("change") + ts = raw.get("time") or raw.get("create_time") + # 秒 → 毫秒 + try: + t = float(ts) + if t < 1e12: + t = t * 1000.0 + ts = t + except Exception: + pass + raw_type = str(raw.get("type") or raw.get("change_type") or "") + bal = raw.get("balance") + ref = str(raw.get("id") or raw.get("txid") or "") + return normalize_row( + account=ACCOUNT_FUNDING, + ccy="USDT", + amount=amt, + ts_ms=ts, + ref_id=ref or make_ref_id("funding", raw_type, ts, amt), + raw_type=raw_type, + balance_after=bal, + note=str(raw.get("text") or ""), + kind=kind_from_raw(raw_type, float(amt) if amt is not None else None), + ) + + +def _swap_row(raw: dict) -> Optional[dict[str, Any]]: + if not isinstance(raw, dict): + return None + # futures account_book: change, balance, type, text, time, contract... + amt = raw.get("change") + ts = raw.get("time") + try: + t = float(ts) + if t < 1e12: + t = t * 1000.0 + ts = t + except Exception: + pass + raw_type = str(raw.get("type") or "") + bal = raw.get("balance") + ref = str(raw.get("id") or "") + return normalize_row( + account=ACCOUNT_TRADING, + ccy="USDT", + amount=amt, + ts_ms=ts, + ref_id=ref or make_ref_id("trading", raw_type, ts, amt, raw.get("contract")), + raw_type=raw_type, + balance_after=bal, + symbol=str(raw.get("contract") or ""), + note=str(raw.get("text") or ""), + kind=kind_from_raw(raw_type, float(amt) if amt is not None else None), + ) + + +def fetch_gate_account_ledger( + exchange, + *, + start_ms: int, + end_ms: int, + ensure_markets: Optional[Callable[[], None]] = None, +) -> tuple[list[dict[str, Any]], list[str]]: + errors: list[str] = [] + rows: list[dict[str, Any]] = [] + if ensure_markets: + try: + ensure_markets() + except Exception as e: + errors.append(f"markets:{e}") + + try: + for e in _paginate_spot_book(exchange, start_ms=start_ms, end_ms=end_ms): + n = _spot_row(e) + if n: + rows.append(n) + except Exception as e: + errors.append(f"funding:{e}") + # 回退 ccxt fetch_ledger + try: + batch = exchange.fetch_ledger( + "USDT", int(start_ms), 100, {"type": "spot", "until": int(end_ms)} + ) or [] + for e in batch: + n = from_ccxt_ledger_entry(e, account=ACCOUNT_FUNDING) + if n: + rows.append(n) + except Exception as e2: + errors.append(f"funding_fallback:{e2}") + + try: + for e in _paginate_swap_book(exchange, start_ms=start_ms, end_ms=end_ms): + n = _swap_row(e) + if n: + rows.append(n) + except Exception as e: + errors.append(f"trading:{e}") + try: + batch = exchange.fetch_ledger( + "USDT", + int(start_ms), + 100, + {"type": "swap", "settle": "usdt", "until": int(end_ms)}, + ) or [] + for e in batch: + n = from_ccxt_ledger_entry(e, account=ACCOUNT_TRADING) + if n: + rows.append(n) + except Exception as e2: + errors.append(f"trading_fallback:{e2}") + + return rows, errors diff --git a/lib/exchange/gate_position_history_lib.py b/lib/exchange/gate_position_history_lib.py new file mode 100644 index 0000000..6fc37ab --- /dev/null +++ b/lib/exchange/gate_position_history_lib.py @@ -0,0 +1,66 @@ +"""Gate 平仓历史匹配(fetch_positions_history),供 reconcile / 中控全平同步共用.""" + +from __future__ import annotations + + +def unified_symbol_for_match(symbol_str: str) -> str: + x = (symbol_str or "").strip().upper() + if ":" in x: + x = x.split(":")[0] + return x + + +def pick_gate_position_close( + hist: list[dict], + symbol: str, + direction: str, + *, + opened_at_ms: int | None = None, + closed_at_ms: int | None = None, + used_keys: set[str] | None = None, + max_close_delta_ms: int = 25 * 60 * 1000, +) -> dict | None: + """ + 从 Gate 平仓历史列表中选取与 symbol/direction/开仓时间最匹配的一条. + 返回 normalize 后的 dict(含 close_ms,pnl,sync_key 等),无匹配则 None. + """ + if not hist: + return None + sym_u = unified_symbol_for_match(symbol) + dir_l = (direction or "long").strip().lower() + if dir_l not in ("long", "short"): + return None + used = used_keys or set() + ref_ms = closed_at_ms or opened_at_ms + best = None + best_d = None + for h in hist: + if not isinstance(h, dict): + continue + sk = h.get("sync_key") + if not sk or sk in used: + continue + if h.get("symbol_u") != sym_u: + continue + if (h.get("side") or "").strip().lower() != dir_l: + continue + cm = h.get("close_ms") + if cm is None: + continue + if opened_at_ms is not None: + if cm < opened_at_ms - 15 * 60 * 1000: + continue + if cm > opened_at_ms + 15 * 86400 * 1000: + continue + if ref_ms is not None: + d = abs(int(cm) - int(ref_ms)) + else: + d = 0 + if best_d is None or d < best_d: + best_d = d + best = h + if best is None or best_d is None: + return None + if ref_ms is not None and best_d > max_close_delta_ms: + return None + return best diff --git a/lib/exchange/gate_transfer_lib.py b/lib/exchange/gate_transfer_lib.py new file mode 100644 index 0000000..fd0b257 --- /dev/null +++ b/lib/exchange/gate_transfer_lib.py @@ -0,0 +1,56 @@ +"""Gate.io 资金划转(crypto_monitor_gate 共用).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +INVALID_KEY_HINT = ( + ".常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" + "③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);" + "④ Key 已重置或权限变更.你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单." +) + + +def execute_transfer_usdt( + exchange, + amount: float, + from_account: str, + to_account: str, + *, + transfer_ccy: str = "USDT", + ensure_live_ready: Callable[[], tuple[bool, str]], + ensure_markets_loaded: Optional[Callable[[], None]] = None, +) -> tuple[bool, str, Any]: + if amount <= 0: + return False, "划转金额必须大于0", None + ccy = (transfer_ccy or "USDT").strip().upper() or "USDT" + ok_live, reason = ensure_live_ready() + if not ok_live: + return False, reason, None + if ensure_markets_loaded: + try: + ensure_markets_loaded() + except Exception: + pass + try: + resp = exchange.transfer(ccy, float(amount), from_account, to_account) + return True, "划转成功", resp + except Exception as e: + msg = str(e) + if "INVALID_KEY" in msg or "Invalid key" in msg: + msg += INVALID_KEY_HINT + return False, msg, None + + +def count_auto_transfer_blockers(conn, *, count_order_monitors: Callable[[Any], int]) -> int: + """自动划转持仓守卫:order_monitors active + 趋势回调已开仓计划.""" + n = int(count_order_monitors(conn) or 0) + if n > 0: + return n + try: + row = conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans " + "WHERE status='active' AND COALESCE(first_order_done, 0) != 0" + ).fetchone() + return int(row[0] or 0) if row else 0 + except Exception: + return n diff --git a/lib/exchange/okx_ledger_lib.py b/lib/exchange/okx_ledger_lib.py new file mode 100644 index 0000000..e4f7cb8 --- /dev/null +++ b/lib/exchange/okx_ledger_lib.py @@ -0,0 +1,99 @@ +"""OKX:资金账户 asset bills + 交易账户 account bills;USDT + USDC.""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.account_ledger.account_ledger_normalize import ( + ACCOUNT_FUNDING, + ACCOUNT_TRADING, + from_ccxt_ledger_entry, +) + +OKX_LEDGER_CCYS = ("USDT", "USDC") + + +def _fetch_one( + exchange, + *, + code: str, + since: int, + until: int, + method: str, + max_pages: int = 10, +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + after = None + for _ in range(max_pages): + params: dict[str, Any] = {"method": method, "until": int(until)} + if after: + params["after"] = after + try: + batch = exchange.fetch_ledger(code, int(since), 100, params) or [] + except Exception: + # archive / bills 窗口差异:失败则停 + break + if not batch: + break + out.extend(batch) + if len(batch) < 100: + break + # OKX 翻页用 billId + last = batch[-1] + info = last.get("info") if isinstance(last.get("info"), dict) else {} + bid = last.get("id") or info.get("billId") + if not bid: + break + after = str(bid) + return out + + +def fetch_okx_account_ledger( + exchange, + *, + start_ms: int, + end_ms: int, + ensure_markets: Optional[Callable[[], None]] = None, +) -> tuple[list[dict[str, Any]], list[str]]: + errors: list[str] = [] + rows: list[dict[str, Any]] = [] + if ensure_markets: + try: + ensure_markets() + except Exception as e: + errors.append(f"markets:{e}") + + for ccy in OKX_LEDGER_CCYS: + # 资金账户 + try: + raw = _fetch_one( + exchange, + code=ccy, + since=start_ms, + until=end_ms, + method="privateGetAssetBills", + ) + for e in raw: + n = from_ccxt_ledger_entry(e, account=ACCOUNT_FUNDING) + if n: + rows.append(n) + except Exception as e: + errors.append(f"funding:{ccy}:{e}") + + # 交易账户:近 3 月 archive + 近 7 日 bills(去重靠 upsert) + for method in ("privateGetAccountBillsArchive", "privateGetAccountBills"): + try: + raw = _fetch_one( + exchange, + code=ccy, + since=start_ms, + until=end_ms, + method=method, + ) + for e in raw: + n = from_ccxt_ledger_entry(e, account=ACCOUNT_TRADING) + if n: + rows.append(n) + except Exception as e: + errors.append(f"trading:{ccy}:{method}:{e}") + + return rows, errors diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py new file mode 100644 index 0000000..094b298 --- /dev/null +++ b/lib/exchange/okx_options_lib.py @@ -0,0 +1,1763 @@ +"""OKX USDⓈ 期权 API 封装(主账户 exchange_options 专用).""" +from __future__ import annotations + +import json +import math +import re +import threading +import time +from typing import Any, Callable + +import ccxt + +from lib.options.options_pricing_lib import ( + expiry_breakeven_from_ask, + idx_distance_to_be, + is_shallow_itm, + option_moneyness, + option_moneyness_label, +) + +_OKX_OPTION_ERR_ZH: dict[str, str] = { + "51008": "可用余额或保证金不足(期权买入请确认交易账户 USDC 足够)", + "51018": "期权账户不能持有净空头头寸", + "51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)", +} + + +def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str: + row: dict[str, Any] | None = None + if isinstance(resp, dict): + data = resp.get("data") or [] + if data and isinstance(data[0], dict): + row = data[0] + if row is None and exc is not None: + text = str(exc) + match = re.search(r"\{.*\}", text, re.DOTALL) + if match: + try: + payload = json.loads(match.group(0)) + data = payload.get("data") or [] + if data and isinstance(data[0], dict): + row = data[0] + except json.JSONDecodeError: + pass + if row: + code = str(row.get("sCode") or "") + msg = str(row.get("sMsg") or "").strip() + low = msg.lower() + if code == "51008": + # 勿写死「资金账户 USDT」:期权开仓常因交易户 USDC 不足 + if "usdc" in low: + return "交易账户 USDC 可用余额不足" + if "usdt" in low: + return "USDT 可用余额不足(期权请先兑成 USDC 并划入交易账户)" + return _OKX_OPTION_ERR_ZH["51008"] + zh = _OKX_OPTION_ERR_ZH.get(code) + if zh: + return zh + if msg: + return msg + if exc is not None: + text = str(exc).strip() + if text.lower().startswith("okx "): + text = text[4:].strip() + return text or "下单失败" + return "下单失败" + + +_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None} + +# public/instruments 全族缓存:合约列表变化慢,限频时用旧数据保活 +_OPTION_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {} +_OPTION_INSTRUMENTS_CACHE_LOCK = threading.Lock() +_OPTION_INSTRUMENTS_CACHE_TTL = 90.0 +_OPTION_INSTRUMENTS_STALE_MAX = 600.0 + + +def invalidate_options_balance_cache() -> None: + _OPTIONS_BALANCE_CACHE["updated_at"] = 0.0 + _OPTIONS_BALANCE_CACHE["data"] = None + + +def invalidate_option_instruments_cache(inst_family: str | None = None) -> None: + with _OPTION_INSTRUMENTS_CACHE_LOCK: + if inst_family: + _OPTION_INSTRUMENTS_CACHE.pop(str(inst_family), None) + else: + _OPTION_INSTRUMENTS_CACHE.clear() + + +def td_mode_for_option_buy(configured: str | None = None) -> str: + """OKX 买入期权(多头)必须使用逐仓.""" + mode = (configured or "isolated").strip().lower() + return "isolated" if mode == "cross" else mode or "isolated" + + +def create_options_exchange( + api_key: str = "", + api_secret: str = "", + passphrase: str = "", + proxies: dict[str, str] | None = None, +) -> ccxt.okx: + """创建 option 客户端.未传密钥时读 OKX_API_*(与永续同源).""" + import os + + key = (api_key or os.getenv("OKX_API_KEY") or "").strip() + secret = (api_secret or os.getenv("OKX_API_SECRET") or "").strip() + password = (passphrase or os.getenv("OKX_API_PASSPHRASE") or "").strip() + ex = ccxt.okx( + { + "apiKey": key, + "secret": secret, + "password": password, + "enableRateLimit": True, + "options": {"defaultType": "option"}, + } + ) + if proxies: + ex.proxies = proxies + return ex + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def round_option_px(px: float, tick_sz: Any, side: str) -> float: + """按 OKX tickSz 对齐:买入向上取整,卖出向下取整.""" + tick = _safe_float(tick_sz) + if tick is None or tick <= 0 or px <= 0: + return px + steps = px / tick + side_l = (side or "").lower() + if side_l == "buy": + return math.ceil(steps - 1e-12) * tick + return math.floor(steps + 1e-12) * tick + + +def format_option_px(px: float, tick_sz: Any) -> str: + tick = _safe_float(tick_sz) + if tick is None or tick <= 0: + # 无 tick 时裁到 4 位并去尾零,避免 482.4881990066513 这类浮点毛刺 + s = f"{float(px):.4f}".rstrip("0").rstrip(".") + return s or "0" + if tick < 1: + decimals = max(0, -int(round(math.log10(tick)))) + return f"{px:.{decimals}f}".rstrip("0").rstrip(".") or "0" + # tick>=1(如 BTC 期权 tickSz=5):只按整数展示,禁止 rstrip('0') 把 1370 变成 137 + if "." in str(tick): + decimals = len(str(tick).split(".")[-1]) + return f"{px:.{decimals}f}".rstrip("0").rstrip(".") or "0" + return str(int(round(float(px)))) + + +def format_usdc_amount(v: float | None) -> str | None: + """USDC 金额展示(权利金/回收等,固定 2 位小数).""" + if v is None: + return None + return f"{float(v):.2f}" + + +def is_option_full_close_history(raw: dict[str, Any]) -> bool: + """仅保留 OKX 历史仓位中的「全部平仓/强平/ADL 全平」记录,排除部分平仓.""" + close_type = str(raw.get("type") or "").strip() + return close_type in ("2", "3", "6") + + +def option_history_row_key( + *, + source: str, + inst_id: str = "", + pos_id: str | None = None, + close_ms: int | None = None, +) -> str: + inst_id = (inst_id or "").strip() + pos_id = (pos_id or "").strip() + if source == "live": + return f"live:{inst_id}:{pos_id or close_ms or '0'}" + # OKX 可能对同合约多次开平复用 posId,必须带上平仓时间区分 + if pos_id: + if close_ms: + return f"ex:{pos_id}:{int(close_ms)}" + return f"ex:{pos_id}" + return f"ex:{inst_id}:{close_ms or 0}" + + +def _ms_to_iso(ms: Any) -> str | None: + val = _safe_float(ms) + if val is None or val <= 0: + return None + try: + from datetime import datetime, timezone + + dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone() + return dt.strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError, OSError): + return None + + +def option_instrument_meta_cached( + ex: ccxt.okx, + inst_id: str, + cache: dict[str, dict[str, Any] | None] | None = None, +) -> dict[str, Any] | None: + inst_id = (inst_id or "").strip() + if not inst_id: + return None + if cache is not None and inst_id in cache: + return cache[inst_id] + meta = fetch_option_instrument_meta(ex, inst_id) + if cache is not None: + cache[inst_id] = meta + return meta + + +def tick_sz_and_ct_mult( + ex: ccxt.okx, + inst_id: str, + cache: dict[str, dict[str, Any] | None] | None = None, +) -> tuple[Any, float]: + meta = option_instrument_meta_cached(ex, inst_id, cache) + tick_sz = meta.get("tickSz") if meta else None + ct_mult = _safe_float(meta.get("ctMult")) if meta else None + return tick_sz, ct_mult or 0.01 + + +def _intrinsic_px_per_unit(opt_type: str, strike: float, index_px: float) -> float | None: + o = (opt_type or "").upper() + if o == "C" and index_px > strike: + return float(index_px) - float(strike) + if o == "P" and index_px < strike: + return float(strike) - float(index_px) + return None + + +def _resolve_chain_quote( + *, + ticker: dict[str, Any], + meta: dict[str, Any], + opt_type: str, + strike: float, + index_px: float, +) -> dict[str, Any]: + """链列表报价:卖一缺失时用标记价/内在价值估算(深度实值常见无卖一).""" + tick_sz = meta.get("tickSz") + ask = _safe_float(ticker.get("askPx")) + bid = _safe_float(ticker.get("bidPx")) + mark = _safe_float(ticker.get("markPx")) + ask_sz = _safe_float(ticker.get("askSz")) + bid_sz = _safe_float(ticker.get("bidSz")) + ask_estimated = False + + if ask is None and mark is not None and mark > 0: + ask = round_option_px(mark, tick_sz, "buy") + ask_estimated = True + if ask is None: + intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px) + if intrinsic is not None and intrinsic > 0: + ask = round_option_px(intrinsic, tick_sz, "buy") + ask_estimated = True + + if bid is None and mark is not None and mark > 0: + bid = round_option_px(mark, tick_sz, "sell") + if bid is None: + intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px) + if intrinsic is not None and intrinsic > 0: + bid = round_option_px(intrinsic, tick_sz, "sell") + + if ask_estimated: + ask_sz = None + + return { + "ask": ask, + "bid": bid, + "ask_sz": ask_sz, + "bid_sz": bid_sz, + "mark_px": mark, + "ask_estimated": ask_estimated, + } + + +def _fetch_book_bid_ask(ex: ccxt.okx, inst_id: str) -> tuple[float | None, float | None]: + bid, ask, _, _ = _fetch_book_top(ex, inst_id) + return bid, ask + + +def _normalize_book_levels(rows: list[Any], depth: int) -> list[dict[str, float]]: + levels: list[dict[str, float]] = [] + for row in rows[: max(0, int(depth))]: + if not isinstance(row, (list, tuple)) or len(row) < 2: + continue + px = _safe_float(row[0]) + sz = _safe_float(row[1]) + if px is None or sz is None or px <= 0 or sz <= 0: + continue + levels.append({"px": px, "sz": sz}) + return levels + + +def fetch_option_book_depth(ex: ccxt.okx, inst_id: str, depth: int = 5) -> dict[str, list[dict[str, float]]]: + """获取期权盘口深度,sz 为 OKX 返回的张数口径.""" + inst_id = (inst_id or "").strip() + if not inst_id: + return {"bids": [], "asks": []} + try: + sz = str(max(1, min(int(depth), 10))) + rows = ex.public_get_market_books({"instId": inst_id, "sz": sz}).get("data") or [] + if not rows: + return {"bids": [], "asks": []} + row = rows[0] + return { + "bids": _normalize_book_levels(row.get("bids") or [], int(depth)), + "asks": _normalize_book_levels(row.get("asks") or [], int(depth)), + } + except Exception: + return {"bids": [], "asks": []} + + +def _fetch_book_top( + ex: ccxt.okx, inst_id: str +) -> tuple[float | None, float | None, float | None, float | None]: + try: + rows = ex.public_get_market_books({"instId": inst_id, "sz": "1"}).get("data") or [] + if not rows: + return None, None, None, None + row = rows[0] + asks = row.get("asks") or [] + bids = row.get("bids") or [] + ask = _safe_float(asks[0][0]) if asks else None + bid = _safe_float(bids[0][0]) if bids else None + ask_sz = _safe_float(asks[0][1]) if asks and len(asks[0]) > 1 else None + bid_sz = _safe_float(bids[0][1]) if bids and len(bids[0]) > 1 else None + return bid, ask, bid_sz, ask_sz + except Exception: + return None, None, None, None + + +def _pos_side_from_position(pos: dict[str, Any] | None) -> str | None: + if not pos: + return None + ps = str(pos.get("posSide") or "").strip().lower() + if ps in ("long", "short", "net"): + return ps + sheets = _safe_float(pos.get("pos")) or 0.0 + if sheets > 0: + return "long" + if sheets < 0: + return "short" + return "net" + + +def inst_family_from_inst_id(inst_id: str) -> str | None: + """从 instId 解析 instFamily,如 ETH-USD_UM-260707-1790-C → ETH-USD_UM.""" + parts = (inst_id or "").strip().split("-") + if len(parts) < 4: + return None + return "-".join(parts[:-3]) + + +def option_fields_from_inst_id(inst_id: str) -> tuple[str | None, float | None]: + """从 instId 解析 optType 与 strike,如 ETH-USD_UM-260709-1700-P.""" + parts = (inst_id or "").strip().split("-") + if len(parts) < 2: + return None, None + tail = parts[-1].upper() + opt_type = tail if tail in ("C", "P") else None + strike = _safe_float(parts[-2]) if len(parts) >= 2 else None + return opt_type, strike + + +def expiry_ms_from_inst_id(inst_id: str) -> int | None: + """从 instId 日期段解析到期时刻(OKX 期权默认 08:00 UTC).""" + parts = (inst_id or "").strip().split("-") + if len(parts) < 3: + return None + date_part = parts[-3] + if not re.fullmatch(r"\d{6}", date_part): + return None + try: + from datetime import datetime, timezone + + yy, mm, dd = int(date_part[0:2]), int(date_part[2:4]), int(date_part[4:6]) + dt = datetime(2000 + yy, mm, dd, 8, 0, 0, tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + except (ValueError, OSError): + return None + + +def normalize_option_exp_ms(exp_time: Any, inst_id: str = "") -> int | None: + """统一期权到期毫秒时间戳(优先 API expTime,否则从 instId 推算).""" + raw = _safe_float(exp_time) + if raw is not None and raw > 0: + ms = int(raw) + if ms < 10_000_000_000: + ms *= 1000 + return ms + return expiry_ms_from_inst_id(inst_id) + + +def _is_okx_rate_limit(err: BaseException) -> bool: + text = str(err) or "" + name = err.__class__.__name__ + return "50011" in text or "Too Many Requests" in text or "RateLimit" in name + + +def _meta_from_inst_id_fallback(inst_id: str) -> dict[str, Any]: + """行情在但 instruments 限频时,用合约 ID 拼最小 meta,避免误报「合约不存在」.""" + family = inst_family_from_inst_id(inst_id) or "" + opt_type, strike = option_fields_from_inst_id(inst_id) + uly = family.replace("_UM", "") if family else "" + return { + "instId": inst_id, + "instFamily": family, + "uly": uly, + "optType": opt_type, + "stk": strike, + "ctMult": 0.01, + "minSz": "1", + "tickSz": "0.0001", + "state": "live", + } + + +def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] | None: + family = inst_family_from_inst_id(inst_id) + if not family: + return None + # 优先从全族缓存取,避免每选一腿再打 instruments + try: + cached_rows = fetch_option_instruments(ex, family, allow_stale=True) + for r in cached_rows: + if isinstance(r, dict) and str(r.get("instId")) == inst_id: + return r + except Exception: + pass + last_err: BaseException | None = None + for attempt in range(2): + try: + rows = ex.public_get_public_instruments( + {"instType": "OPTION", "instFamily": family, "instId": inst_id} + ).get("data") or [] + if rows and isinstance(rows[0], dict): + return rows[0] + rows = fetch_option_instruments(ex, family, allow_stale=True) + for r in rows: + if isinstance(r, dict) and str(r.get("instId")) == inst_id: + return r + return None + except Exception as e: + last_err = e + if _is_okx_rate_limit(e) and attempt < 1: + time.sleep(1.2) + continue + break + if last_err is not None and _is_okx_rate_limit(last_err): + try: + t_rows = ex.public_get_market_ticker({"instId": inst_id}).get("data") or [] + if t_rows: + return _meta_from_inst_id_fallback(inst_id) + except Exception: + pass + return None + + +def _extract_ccy_free(balance: dict[str, Any], ccy: str) -> float | None: + ccy = (ccy or "").upper() + if not isinstance(balance, dict): + return None + info = balance.get(ccy) + if isinstance(info, dict): + v = _safe_float(info.get("free")) + if v is not None: + return v + free_map = balance.get("free") or {} + if isinstance(free_map, dict): + return _safe_float(free_map.get(ccy)) + return None + + +def _extract_ccy_balance(balance: dict[str, Any], ccy: str) -> float | None: + ccy = (ccy or "").upper() + if not isinstance(balance, dict): + return None + info = balance.get(ccy) + if isinstance(info, dict): + for k in ("free", "total", "eq"): + v = _safe_float(info.get(k)) + if v is not None: + return v + total_map = balance.get("total") or {} + if isinstance(total_map, dict): + v = _safe_float(total_map.get(ccy)) + if v is not None: + return v + free_map = balance.get("free") or {} + if isinstance(free_map, dict): + v = _safe_float(free_map.get(ccy)) + if v is not None: + return v + return None + + +def fetch_account_balances_by_type( + ex: ccxt.okx, + account_type: str, +) -> tuple[dict[str, float | None], dict[str, float | None]]: + out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} + avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} + try: + bal = ex.fetch_balance(params={"type": account_type}) + for c in out: + out[c] = _extract_ccy_balance(bal, c) + avail[c] = _extract_ccy_free(bal, c) + except Exception: + pass + return out, avail + + +def fetch_funding_balances_via_asset_api( + ex: ccxt.okx, +) -> tuple[dict[str, float | None], dict[str, float | None]]: + """OKX 资金账户余额(GET /api/v5/asset/balances),比 ccxt fetch_balance 更准确.""" + out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} + avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} + try: + resp = ex.private_get_asset_balances({}) + for row in (resp or {}).get("data") or []: + if not isinstance(row, dict): + continue + ccy = str(row.get("ccy") or "").upper() + if ccy not in out: + continue + a = _safe_float(row.get("availBal")) + b = _safe_float(row.get("bal")) or _safe_float(row.get("eq")) + avail[ccy] = a + out[ccy] = b if b is not None else a + except Exception: + pass + return out, avail + + +def _merge_balance_maps( + primary: dict[str, float | None], + secondary: dict[str, float | None], +) -> dict[str, float | None]: + merged = dict(primary) + for ccy, val in secondary.items(): + if merged.get(ccy) is None and val is not None: + merged[ccy] = val + return merged + + +def fetch_subaccount_asset_balances(ex: ccxt.okx, sub_acct: str) -> dict[str, float | None]: + """子账户各币种可用余额(主/子划转「全部」用).""" + sub = (sub_acct or "").strip() + out: dict[str, float | None] = {"USDT": None, "USDC": None} + if not sub: + return out + try: + resp = ex.private_get_asset_subaccount_balances({"subAcct": sub}) + for row in (resp or {}).get("data") or []: + if not isinstance(row, dict): + continue + ccy = str(row.get("ccy") or "").upper() + if ccy not in out: + continue + out[ccy] = _safe_float(row.get("availBal")) or _safe_float(row.get("bal")) + except Exception: + pass + return out + + +def fetch_options_balances( + ex: ccxt.okx, + *, + force: bool = False, + scope: str = "main", + sub_acct: str = "", +) -> dict[str, Any]: + import os + + if (scope or "").strip().lower() == "sub": + sub_bal = fetch_subaccount_asset_balances(ex, sub_acct) + return { + "scope": "sub", + "funding_usdt": sub_bal.get("USDT"), + "funding_usdc": sub_bal.get("USDC"), + "funding_usdt_avail": sub_bal.get("USDT"), + "funding_usdc_avail": sub_bal.get("USDC"), + "trading_usdt": sub_bal.get("USDT"), + "trading_usdc": sub_bal.get("USDC"), + "trading_usdt_avail": sub_bal.get("USDT"), + "trading_usdc_avail": sub_bal.get("USDC"), + } + + ttl = float(os.getenv("OKX_OPTIONS_BALANCE_REFRESH_SEC", "30")) + now = time.time() + cached = _OPTIONS_BALANCE_CACHE.get("data") + if not force and cached is not None and now - float(_OPTIONS_BALANCE_CACHE.get("updated_at") or 0) < ttl: + return dict(cached) + + funding, funding_avail = fetch_account_balances_by_type(ex, "funding") + asset_funding, asset_funding_avail = fetch_funding_balances_via_asset_api(ex) + funding = _merge_balance_maps(funding, asset_funding) + funding_avail = _merge_balance_maps(funding_avail, asset_funding_avail) + trading, trading_avail = fetch_account_balances_by_type(ex, "trading") + if trading.get("USDC") is None: + swap_bal, swap_avail = fetch_account_balances_by_type(ex, "swap") + if swap_bal.get("USDC") is not None: + trading["USDC"] = swap_bal["USDC"] + if trading_avail.get("USDC") is None and swap_avail.get("USDC") is not None: + trading_avail["USDC"] = swap_avail["USDC"] + result = { + "scope": "main", + "funding_usdt": funding.get("USDT"), + "funding_usdc": funding.get("USDC"), + "funding_usdg": funding.get("USDG"), + "funding_usdt_avail": funding_avail.get("USDT"), + "funding_usdc_avail": funding_avail.get("USDC"), + "trading_usdt": trading.get("USDT"), + "trading_usdc": trading.get("USDC"), + "trading_usdg": trading.get("USDG"), + "trading_usdt_avail": trading_avail.get("USDT"), + "trading_usdc_avail": trading_avail.get("USDC"), + } + _OPTIONS_BALANCE_CACHE["updated_at"] = now + _OPTIONS_BALANCE_CACHE["data"] = result + return result + + +def options_header_balances( + ex: ccxt.okx, + *, + force: bool = False, +) -> tuple[float | None, float | None, float | None, float | None]: + """顶栏期权两格用 USDC;顺带返回同账户 USDT(调用方勿再计入总资金,避免与永续栏重复). + + 返回:(trading_usdc, funding_usdc, funding_usdt, trading_usdt) + """ + bal = fetch_options_balances(ex, force=force) + + def _round(v: Any) -> float | None: + if v is None: + return None + try: + return round(float(v), 2) + except (TypeError, ValueError): + return None + + return ( + _round(bal.get("trading_usdc")), + _round(bal.get("funding_usdc")), + _round(bal.get("funding_usdt")), + _round(bal.get("trading_usdt")), + ) + + +def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None: + inst = f"{uly}" if "-" in uly else f"{uly}-USD" + try: + rows = ex.public_get_market_index_tickers({"instId": inst}).get("data") or [] + if rows: + return _safe_float(rows[0].get("idxPx")) + except Exception: + pass + return None + + +def fetch_option_instruments( + ex: ccxt.okx, + inst_family: str, + *, + force: bool = False, + allow_stale: bool = True, +) -> list[dict[str, Any]]: + """拉取 OPTION instruments;进程内缓存,50011 时回退旧列表.""" + family = str(inst_family or "").strip() + if not family: + return [] + now = time.time() + with _OPTION_INSTRUMENTS_CACHE_LOCK: + entry = _OPTION_INSTRUMENTS_CACHE.get(family) + if ( + not force + and entry is not None + and entry.get("rows") is not None + and now - float(entry.get("updated_at") or 0) < _OPTION_INSTRUMENTS_CACHE_TTL + ): + return list(entry["rows"]) + + try: + rows = ex.public_get_public_instruments( + {"instType": "OPTION", "instFamily": family} + ).get("data") or [] + live = [r for r in rows if isinstance(r, dict) and r.get("state") == "live"] + with _OPTION_INSTRUMENTS_CACHE_LOCK: + _OPTION_INSTRUMENTS_CACHE[family] = {"updated_at": now, "rows": live} + return list(live) + except Exception as e: + if allow_stale: + with _OPTION_INSTRUMENTS_CACHE_LOCK: + entry = _OPTION_INSTRUMENTS_CACHE.get(family) + if entry is not None and entry.get("rows") is not None: + age = now - float(entry.get("updated_at") or 0) + if age <= _OPTION_INSTRUMENTS_STALE_MAX: + return list(entry["rows"]) + raise + + +def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + try: + rows = ex.public_get_market_tickers( + {"instType": "OPTION", "instFamily": inst_family} + ).get("data") or [] + for r in rows: + if isinstance(r, dict) and r.get("instId"): + out[str(r["instId"])] = r + except Exception: + pass + return out + + +def build_option_chain( + ex: ccxt.okx, + underlying: str, + *, + max_dte_days: float = 2.0, + itm_only: bool = True, + itm_max_dist_usd: float = 30.0, + index_px: float | None = None, +) -> dict[str, Any]: + u = (underlying or "ETH").upper() + family = f"{u}-USD_UM" + uly = f"{u}-USD" + idx = index_px if index_px is not None else fetch_index_price(ex, uly) + now_ms = time.time() * 1000 + max_ms = now_ms + max_dte_days * 86400 * 1000 + instruments_err = "" + instruments: list[dict[str, Any]] = [] + try: + instruments = fetch_option_instruments(ex, family) + if not instruments: + # 空列表可能是瞬时空;短退避后强制再拉一次(非 50011) + time.sleep(0.5) + instruments = fetch_option_instruments(ex, family, force=True) + if not instruments: + instruments_err = "期权合约列表为空" + except Exception as e: + instruments = [] + instruments_err = str(e) or e.__class__.__name__ + # 限频:再等一下用 stale/缓存,不要连打 + if _is_okx_rate_limit(e): + time.sleep(1.5) + try: + instruments = fetch_option_instruments(ex, family, allow_stale=True) + if instruments: + instruments_err = "" + except Exception as e2: + instruments_err = str(e2) or e2.__class__.__name__ + tickers = fetch_option_tickers(ex, family) + expiries: dict[str, list[dict[str, Any]]] = {} + skipped_no_index = 0 + for meta in instruments: + try: + exp_ms = int(meta.get("expTime") or 0) + except (TypeError, ValueError): + continue + if exp_ms <= now_ms or exp_ms > max_ms: + continue + opt_type = str(meta.get("optType") or "") + strike = _safe_float(meta.get("stk")) + if strike is None: + continue + if idx is None: + skipped_no_index += 1 + continue + if itm_only and not is_shallow_itm( + opt_type=opt_type, + strike=strike, + index_px=idx, + max_dist_usd=itm_max_dist_usd, + ): + continue + inst_id = str(meta.get("instId") or "") + t = tickers.get(inst_id) or {} + q = _resolve_chain_quote( + ticker=t, + meta=meta, + opt_type=opt_type, + strike=strike, + index_px=idx, + ) + ask = q["ask"] + bid = q["bid"] + mark = q["mark_px"] + ask_sz = q["ask_sz"] + bid_sz = q["bid_sz"] + expiry_be = expiry_breakeven_from_ask( + opt_type=opt_type, + strike=strike, + ask_px=ask, + mark_px=mark, + ) + mny = option_moneyness(opt_type=opt_type, strike=strike, index_px=idx) + exp_key = str(exp_ms) + expiries.setdefault(exp_key, []).append( + { + "inst_id": inst_id, + "strike": strike, + "opt_type": opt_type, + "exp_time": exp_ms, + "ask": ask, + "bid": bid, + "ask_sz": ask_sz, + "bid_sz": bid_sz, + "mark_px": mark, + "ask_estimated": q["ask_estimated"], + "expiry_be_px": expiry_be, + "dist_expiry_be": idx_distance_to_be(idx, expiry_be), + "moneyness": mny, + "moneyness_label": option_moneyness_label(mny), + "ct_mult": _safe_float(meta.get("ctMult")) or 0.01, + "tick_sz": meta.get("tickSz"), + "min_sz": int(_safe_float(meta.get("minSz")) or 1), + } + ) + exp_list = [] + for exp_ms_str, contracts in sorted(expiries.items(), key=lambda x: int(x[0])): + contracts.sort(key=lambda c: (c["opt_type"], c["strike"])) + exp_list.append({"exp_time": int(exp_ms_str), "contracts": contracts}) + out: dict[str, Any] = { + "underlying": u, + "index_px": idx, + "inst_family": family, + "expiries": exp_list, + "instruments_count": len(instruments), + } + if not exp_list: + if instruments_err: + out["chain_error"] = f"拉取期权合约失败: {instruments_err}" + elif idx is None: + out["chain_error"] = "指数价获取失败,无法构建期权链" + elif skipped_no_index: + out["chain_error"] = "指数价缺失,合约已跳过" + elif instruments: + out["chain_error"] = f"近 {max_dte_days:g} 日内无可用到期(已过滤 {len(instruments)} 个合约)" + else: + out["chain_error"] = "期权合约列表为空,请稍后刷新" + return out + + +def option_buy_liquidity_ok(ask: Any, ask_sz: Any) -> tuple[bool, str]: + """开仓仅认真实卖一价+卖一深度;不接受标记价/内在价值顶包.""" + a = _safe_float(ask) + s = _safe_float(ask_sz) + if a is None or a <= 0: + return False, "暂无卖一价,无法买入" + if s is None or s <= 0: + return False, "暂无卖一深度,无法买入" + return True, "" + + +def cap_option_buy_sheets_to_ask_depth( + sheets: int, + ask_sz: Any, + *, + min_sz: int = 1, +) -> tuple[int | None, str]: + """将买入张数限制在卖一深度内(向下取整).""" + depth = _safe_float(ask_sz) + if depth is None or depth <= 0: + return None, "暂无卖一深度,无法买入" + max_sheets = int(math.floor(depth + 1e-12)) + need = max(1, int(min_sz or 1)) + if max_sheets < need: + return None, f"卖一深度不足 {need} 张(当前 {depth:g})" + want = max(0, int(sheets)) + capped = min(want, max_sheets) + if capped < need: + return None, f"卖一深度不足 {need} 张(当前 {depth:g})" + return capped, "" + + +def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]: + inst_id = (inst_id or "").strip() + if not inst_id: + return {"ok": False, "msg": "缺少 inst_id"} + try: + meta = fetch_option_instrument_meta(ex, inst_id) + t_rows: list[Any] = [] + ticker_err: BaseException | None = None + for attempt in range(3): + try: + t_rows = ex.public_get_market_ticker({"instId": inst_id}).get("data") or [] + ticker_err = None + break + except Exception as e: + ticker_err = e + if _is_okx_rate_limit(e) and attempt < 2: + time.sleep(0.45 * (attempt + 1)) + continue + break + if not meta and t_rows: + meta = _meta_from_inst_id_fallback(inst_id) + if not meta: + if ticker_err is not None and _is_okx_rate_limit(ticker_err): + return {"ok": False, "msg": "行情限频,请稍后重试"} + return {"ok": False, "msg": "合约不存在"} + t = t_rows[0] if t_rows else {} + # 开仓用真实盘口卖一;绝不把标记价写入 ask + ask = _safe_float(t.get("askPx")) + bid = _safe_float(t.get("bidPx")) + ask_sz = _safe_float(t.get("askSz")) + bid_sz = _safe_float(t.get("bidSz")) + if ask is None or bid is None or ask_sz is None or bid_sz is None: + book_bid, book_ask, book_bid_sz, book_ask_sz = _fetch_book_top(ex, inst_id) + if ask is None: + ask = book_ask + if bid is None: + bid = book_bid + if ask_sz is None: + ask_sz = book_ask_sz + if bid_sz is None: + bid_sz = book_bid_sz + mark = _safe_float(t.get("markPx")) + tick_sz = meta.get("tickSz") + # 买一缺失时仍可用标记价补展示(平仓路径读 bid);开仓 ask 不顶包 + if bid is None and mark is not None: + bid = round_option_px(mark, tick_sz, "sell") + ref_ask = None + if ask is None and mark is not None and mark > 0: + ref_ask = round_option_px(mark, tick_sz, "buy") + can_open, open_block_msg = option_buy_liquidity_ok(ask, ask_sz) + book_ask = ask + book_ask_sz = ask_sz + uly = str(meta.get("uly") or "") + idx = fetch_index_price(ex, uly) + opt_type = meta.get("optType") + strike = _safe_float(meta.get("stk")) + expiry_be = expiry_breakeven_from_ask( + opt_type=str(opt_type or ""), + strike=strike, + ask_px=book_ask if can_open else None, + mark_px=mark, + ) + return { + "ok": True, + "inst_id": inst_id, + "meta": meta, + "ask": book_ask if can_open else None, + "bid": bid, + "ask_sz": book_ask_sz if can_open else None, + "bid_sz": bid_sz, + "mark": mark, + "ref_ask": ref_ask, + "book_ask": book_ask, + "book_ask_sz": book_ask_sz, + "can_open": can_open, + "ask_source": "book" if can_open else "none", + "open_block_msg": "" if can_open else open_block_msg, + "index_px": idx, + "expiry_be_px": expiry_be, + "dist_expiry_be": idx_distance_to_be(idx, expiry_be), + "ct_mult": _safe_float(meta.get("ctMult")) or 0.01, + "min_sz": int(_safe_float(meta.get("minSz")) or 1), + "tick_sz": tick_sz, + "strike": strike, + "opt_type": opt_type, + "exp_time": meta.get("expTime"), + } + except Exception as e: + return {"ok": False, "msg": str(e)} + + +def fetch_option_pending_orders(ex: ccxt.okx, inst_id: str | None = None) -> list[dict[str, Any]]: + """未成交期权委托(限价挂单).""" + params: dict[str, Any] = {"instType": "OPTION"} + inst = (inst_id or "").strip() + if inst: + params["instId"] = inst + try: + rows = ex.private_get_trade_orders_pending(params).get("data") or [] + except Exception: + return [] + out: list[dict[str, Any]] = [] + for o in rows: + if not isinstance(o, dict): + continue + oid = str(o.get("ordId") or "").strip() + iid = str(o.get("instId") or "").strip() + if not oid or not iid: + continue + side = str(o.get("side") or "").lower() + px = _safe_float(o.get("px")) + sz = _safe_float(o.get("sz")) + fill_sz = _safe_float(o.get("fillSz")) or 0.0 + acc_fill = _safe_float(o.get("accFillSz")) + if acc_fill is not None: + fill_sz = acc_fill + out.append( + { + "ord_id": oid, + "inst_id": iid, + "side": side, + "side_label": "买入" if side == "buy" else ("卖出" if side == "sell" else side or "—"), + "px": px, + "sz": int(sz) if sz is not None else None, + "fill_sz": int(fill_sz) if fill_sz is not None else 0, + "state": str(o.get("state") or ""), + "ord_type": str(o.get("ordType") or ""), + "c_time": o.get("cTime"), + "u_time": o.get("uTime"), + "reduce_only": str(o.get("reduceOnly") or "").lower() in ("true", "1", "yes"), + } + ) + out.sort(key=lambda x: int(float(x.get("c_time") or 0)), reverse=True) + return out + + +def cancel_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str, Any]: + inst_id = (inst_id or "").strip() + ord_id = (ord_id or "").strip() + if not inst_id or not ord_id: + return {"ok": False, "msg": "缺少 inst_id 或 ord_id"} + try: + resp = ex.private_post_trade_cancel_order({"instId": inst_id, "ordId": ord_id}) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode")) == "0": + return {"ok": True, "data": data[0], "raw": resp} + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +def fetch_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str, Any]: + """查询单笔期权订单状态.""" + inst_id = (inst_id or "").strip() + ord_id = (ord_id or "").strip() + if not inst_id or not ord_id: + return {"ok": False, "msg": "缺少 inst_id 或 ord_id"} + try: + resp = ex.private_get_trade_order({"instId": inst_id, "ordId": ord_id}) + data = (resp or {}).get("data") or [] + if not data or not isinstance(data[0], dict): + return {"ok": False, "msg": "订单不存在或暂不可查", "raw": resp} + o = data[0] + sz = _safe_float(o.get("sz")) + acc = _safe_float(o.get("accFillSz")) + if acc is None: + acc = _safe_float(o.get("fillSz")) or 0.0 + avg = _safe_float(o.get("avgPx")) + fill_px = _safe_float(o.get("fillPx")) + if avg is None or avg <= 0: + avg = fill_px + state = str(o.get("state") or "").strip().lower() + return { + "ok": True, + "ord_id": str(o.get("ordId") or ord_id), + "inst_id": str(o.get("instId") or inst_id), + "state": state, + "sz": int(sz) if sz is not None else None, + "acc_fill_sz": float(acc or 0), + "avg_px": avg, + "side": str(o.get("side") or "").lower(), + "ord_type": str(o.get("ordType") or ""), + "raw": o, + } + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +def wait_option_order_full_fill( + ex: ccxt.okx, + *, + inst_id: str, + ord_id: str, + need_sheets: int, + timeout_sec: float = 12.0, + poll_sec: float = 0.35, + cancel_on_timeout: bool = True, +) -> dict[str, Any]: + """轮询至完全成交;超时则撤单.未完全成交返回 ok=False.""" + need = max(1, int(need_sheets)) + deadline = time.time() + max(0.5, float(timeout_sec)) + last: dict[str, Any] = {} + while time.time() < deadline: + last = fetch_option_order(ex, inst_id=inst_id, ord_id=ord_id) + if not last.get("ok"): + time.sleep(max(0.15, float(poll_sec))) + continue + acc = float(last.get("acc_fill_sz") or 0) + state = str(last.get("state") or "") + if acc + 1e-9 >= need or state == "filled": + if acc + 1e-9 < need: + return { + "ok": False, + "msg": f"订单已结束但成交不足 {need} 张(已成 {acc:g})", + "filled_sheets": acc, + "order": last, + } + return { + "ok": True, + "filled_sheets": int(round(acc)), + "avg_px": last.get("avg_px"), + "state": state, + "order": last, + } + if state in ("canceled", "cancelled", "mmp_canceled"): + if acc + 1e-9 >= need: + return { + "ok": True, + "filled_sheets": int(round(acc)), + "avg_px": last.get("avg_px"), + "state": state, + "order": last, + } + return { + "ok": False, + "msg": f"订单已撤销且未完全成交(已成 {acc:g}/{need})", + "filled_sheets": acc, + "order": last, + } + time.sleep(max(0.15, float(poll_sec))) + + if cancel_on_timeout: + cancel_option_order(ex, inst_id=inst_id, ord_id=ord_id) + time.sleep(0.25) + last = fetch_option_order(ex, inst_id=inst_id, ord_id=ord_id) + acc = float((last or {}).get("acc_fill_sz") or 0) if (last or {}).get("ok") else 0.0 + if acc + 1e-9 >= need: + return { + "ok": True, + "filled_sheets": int(round(acc)), + "avg_px": (last or {}).get("avg_px"), + "state": (last or {}).get("state"), + "order": last, + "timed_out": True, + } + return { + "ok": False, + "msg": f"等待成交超时({float(timeout_sec):g}s),已撤未成交部分;已成 {acc:g}/{need}", + "filled_sheets": acc, + "order": last, + "timed_out": True, + } + + +def place_option_limit_order( + ex: ccxt.okx, + *, + inst_id: str, + side: str, + sheets: int, + price: float, + td_mode: str = "isolated", + tick_sz: Any = None, + reduce_only: bool = False, + pos_side: str | None = None, + ord_type: str = "limit", +) -> dict[str, Any]: + side_l = (side or "").lower() + if side_l not in ("buy", "sell"): + return {"ok": False, "msg": "side 必须为 buy 或 sell"} + if sheets < 1: + return {"ok": False, "msg": "张数至少为 1"} + ot = (ord_type or "limit").strip().lower() + if ot not in ("limit", "ioc", "fok", "post_only"): + return {"ok": False, "msg": f"不支持的 ordType: {ord_type}"} + px = round_option_px(float(price), tick_sz, side_l) + if px <= 0: + return {"ok": False, "msg": "价格无效"} + body: dict[str, Any] = { + "instId": inst_id, + "tdMode": td_mode, + "side": side_l, + "ordType": ot, + "px": format_option_px(px, tick_sz), + "sz": str(int(sheets)), + } + if pos_side: + body["posSide"] = pos_side + if reduce_only: + body["reduceOnly"] = "true" + try: + resp = ex.private_post_trade_order(body) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode")) == "0": + return {"ok": True, "data": data[0], "raw": resp, "px": px, "ord_type": ot} + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp, "px": px} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e), "px": px} + + +def place_option_market_order( + ex: ccxt.okx, + *, + inst_id: str, + side: str, + sheets: int, + td_mode: str = "isolated", + reduce_only: bool = False, + pos_side: str | None = None, +) -> dict[str, Any]: + side_l = (side or "").lower() + if side_l not in ("buy", "sell"): + return {"ok": False, "msg": "side 必须为 buy 或 sell"} + if sheets < 1: + return {"ok": False, "msg": "张数至少为 1"} + body: dict[str, Any] = { + "instId": inst_id, + "tdMode": td_mode, + "side": side_l, + "ordType": "market", + "sz": str(int(sheets)), + } + if pos_side: + body["posSide"] = pos_side + if reduce_only: + body["reduceOnly"] = "true" + try: + resp = ex.private_post_trade_order(body) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode")) == "0": + return {"ok": True, "data": data[0], "raw": resp} + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +_OPTION_POSITIONS_CACHE: dict[str, Any] = {"updated_at": 0.0, "rows": None, "failed": False} +_OPTION_POSITIONS_CACHE_LOCK = threading.Lock() +_OPTION_POSITIONS_CACHE_TTL = 4.0 +_OPTION_POSITIONS_STALE_OK_SEC = 30.0 + + +def invalidate_option_positions_cache() -> None: + with _OPTION_POSITIONS_CACHE_LOCK: + _OPTION_POSITIONS_CACHE["updated_at"] = 0.0 + _OPTION_POSITIONS_CACHE["failed"] = False + + +def fetch_option_positions(ex: ccxt.okx) -> list[dict[str, Any]] | None: + """期权持仓:有仓返回列表,无仓返回 [],API 失败返回 None(短时回退缓存).""" + now = time.time() + with _OPTION_POSITIONS_CACHE_LOCK: + age = now - float(_OPTION_POSITIONS_CACHE["updated_at"] or 0.0) + cached = _OPTION_POSITIONS_CACHE["rows"] + if age < _OPTION_POSITIONS_CACHE_TTL and cached is not None and not _OPTION_POSITIONS_CACHE["failed"]: + return list(cached) + try: + rows = ex.private_get_account_positions({"instType": "OPTION"}).get("data") or [] + out = [] + for r in rows: + if not isinstance(r, dict): + continue + pos = _safe_float(r.get("pos")) + if pos is None or abs(pos) < 1e-12: + continue + out.append(r) + with _OPTION_POSITIONS_CACHE_LOCK: + _OPTION_POSITIONS_CACHE["updated_at"] = now + _OPTION_POSITIONS_CACHE["rows"] = out + _OPTION_POSITIONS_CACHE["failed"] = False + return out + except Exception: + with _OPTION_POSITIONS_CACHE_LOCK: + cached = _OPTION_POSITIONS_CACHE["rows"] + age = now - float(_OPTION_POSITIONS_CACHE["updated_at"] or 0.0) + if cached is not None and age < _OPTION_POSITIONS_STALE_OK_SEC: + return list(cached) + _OPTION_POSITIONS_CACHE["updated_at"] = now + _OPTION_POSITIONS_CACHE["rows"] = None + _OPTION_POSITIONS_CACHE["failed"] = True + return None + + +def fetch_option_position_history( + ex: ccxt.okx, + inst_id: str, + *, + limit: int = 20, +) -> list[dict[str, Any]]: + """OKX 期权历史仓位(含到期结算/平仓).""" + inst_id = (inst_id or "").strip() + if not inst_id: + return [] + try: + resp = ex.private_get_account_positions_history( + { + "instType": "OPTION", + "instId": inst_id, + "limit": str(max(1, min(int(limit), 100))), + } + ) + rows = (resp or {}).get("data") or [] + return [r for r in rows if isinstance(r, dict)] + except Exception: + return [] + + +def fetch_all_option_positions_history( + ex: ccxt.okx, + *, + limit: int = 200, +) -> list[dict[str, Any]]: + """拉取 OKX 期权全部历史仓位(分页,按平仓时间倒序).""" + cap = max(1, min(int(limit), 500)) + out: list[dict[str, Any]] = [] + after: str | None = None + while len(out) < cap: + page_limit = min(100, cap - len(out)) + params: dict[str, Any] = { + "instType": "OPTION", + "limit": str(page_limit), + } + if after is not None: + params["after"] = after + try: + resp = ex.private_get_account_positions_history(params) + except Exception: + break + rows = (resp or {}).get("data") or [] + batch = [r for r in rows if isinstance(r, dict)] + if not batch: + break + out.extend(batch) + if len(batch) < page_limit: + break + utimes = [_safe_float(r.get("uTime")) for r in batch] + utimes = [int(u) for u in utimes if u is not None and u > 0] + if not utimes: + break + oldest = min(utimes) + if after is not None and str(oldest) == after: + break + after = str(oldest) + out = [r for r in out if is_option_full_close_history(r)] + out.sort(key=lambda r: int(_safe_float(r.get("uTime")) or 0), reverse=True) + return out[:cap] + + +def format_option_history_row( + raw: dict[str, Any], + *, + tick_sz: Any = None, + ct_mult: float = 0.01, +) -> dict[str, Any]: + """标准化 OKX positions-history 单条记录供前端展示.""" + from lib.options.options_pricing_lib import total_premium + + inst_id = str(raw.get("instId") or "").strip() + open_avg = _safe_float(raw.get("openAvgPx")) + close_avg = _safe_float(raw.get("closeAvgPx")) + sheets = _safe_float(raw.get("closeTotalPos")) + if sheets is None or sheets <= 0: + sheets = _safe_float(raw.get("openMaxPos")) + sheets_i = int(abs(sheets or 0)) + eth_amount = round(abs(sheets or 0) * ct_mult, 8) if sheets else 0.0 + premium_paid = ( + round(total_premium(open_avg, eth_amount), 8) + if open_avg is not None and eth_amount > 0 + else None + ) + realized = _safe_float(raw.get("realizedPnl")) + if realized is None: + realized = _safe_float(raw.get("pnl")) + pnl_ratio = _safe_float(raw.get("pnlRatio")) + close_type = str(raw.get("type") or "").strip() + utime = _safe_float(raw.get("uTime")) + ctime = _safe_float(raw.get("cTime")) + opt_type, strike = option_fields_from_inst_id(inst_id) + uly = str(raw.get("uly") or inst_id.split("-")[0] or "").replace("-USD_UM", "").replace("-USD", "") + if close_type in ("3", "4"): + status_label = "强平" + else: + status_label = "已平" + pos_id = str(raw.get("posId") or "").strip() or None + close_ms = int(utime) if utime is not None else None + return { + "source": "exchange", + "history_key": option_history_row_key( + source="exchange", + inst_id=inst_id, + pos_id=pos_id, + close_ms=close_ms, + ), + "pos_id": pos_id, + "inst_id": inst_id, + "underlying": uly, + "opt_type": opt_type, + "strike": strike, + "sheets": sheets_i, + "eth_amount": eth_amount, + "open_avg_px": open_avg, + "open_avg_px_fmt": format_option_px(open_avg, tick_sz) if open_avg is not None else None, + "close_avg_px": close_avg, + "close_avg_px_fmt": format_option_px(close_avg, tick_sz) if close_avg is not None else None, + "premium_paid": premium_paid, + "premium_paid_fmt": format_usdc_amount(premium_paid), + "realized_pnl": realized, + "pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None, + "status": "closed", + "status_label": status_label, + "close_type": close_type, + "created_at": _ms_to_iso(ctime), + "closed_at": _ms_to_iso(utime), + "close_ms": close_ms, + "tick_sz": tick_sz, + "raw": raw, + } + + +def format_live_option_history_row( + row: dict[str, Any], + *, + open_ms: int | None = None, +) -> dict[str, Any]: + """将当前持仓格式化为历史列表中的「持仓中」行.""" + inst_id = str(row.get("inst_id") or "").strip() + pos_id = str((row.get("raw") or {}).get("posId") or "").strip() or None + close_ms = open_ms + return { + "source": "live", + "history_key": option_history_row_key( + source="live", + inst_id=inst_id, + pos_id=pos_id, + close_ms=close_ms, + ), + "pos_id": pos_id, + "inst_id": inst_id, + "underlying": str(row.get("underlying") or inst_id.split("-")[0] or ""), + "opt_type": row.get("opt_type"), + "strike": row.get("strike"), + "sheets": int(abs(_safe_float(row.get("pos")) or 0)), + "eth_amount": row.get("eth_amount"), + "open_avg_px": row.get("avg_px"), + "open_avg_px_fmt": row.get("avg_px_fmt"), + "close_avg_px": None, + "close_avg_px_fmt": None, + "premium_paid": row.get("premium_paid"), + "premium_paid_fmt": row.get("premium_paid_fmt"), + "realized_pnl": row.get("upl"), + "pnl_ratio_pct": row.get("upl_ratio_pct"), + "status": "open", + "status_label": "持仓中", + "close_type": None, + "created_at": _ms_to_iso(open_ms), + "closed_at": None, + "close_ms": open_ms, + "tick_sz": row.get("tick_sz"), + "raw": row.get("raw"), + } + + +def resolve_option_close_from_history( + hist_rows: list[dict[str, Any]], + *, + open_ms: int | None = None, + close_ms: int | None = None, + sheets: float | int | None = None, +) -> dict[str, Any] | None: + """从 positions-history 选取匹配的平仓记录. + + 同合约多次开平时,优先按开仓时间(cTime≈open_ms)对齐,再按平仓时间/张数; + 无锚点时取开仓后最晚一条(供刚平掉的持仓同步)。 + """ + candidates: list[tuple[int, dict[str, Any]]] = [] + for row in hist_rows: + u_ms = _safe_float(row.get("uTime")) + if u_ms is None or u_ms <= 0: + continue + u_i = int(u_ms) + # 本地时间偶发与交易所差整时区时,放宽到 12h,主要靠 cTime/张数精配 + if open_ms is not None and u_i < int(open_ms) - 12 * 3600_000: + continue + candidates.append((u_i, row)) + if not candidates: + return None + + has_ctime = any(_safe_float(row.get("cTime")) is not None for _, row in candidates) + want_sheets = _safe_float(sheets) + + def _score(item: tuple[int, dict[str, Any]]) -> tuple: + u_i, row = item + c_ms = _safe_float(row.get("cTime")) + parts: list[float] = [] + # 张数优先:同合约多笔时最稳,且不受本地/交易所时区偏差影响 + if want_sheets is not None: + hist_sheets = _safe_float(row.get("closeTotalPos")) + if hist_sheets is None: + hist_sheets = _safe_float(row.get("openMaxPos")) + parts.append( + abs(float(hist_sheets) - float(want_sheets)) + if hist_sheets is not None + else 1e12 + ) + if open_ms is not None and c_ms is not None: + parts.append(float(abs(int(c_ms) - int(open_ms)))) + if close_ms is not None: + parts.append(float(abs(u_i - int(close_ms)))) + if not parts: + parts.append(float(-u_i)) + # 同距时偏向更晚平仓 + parts.append(float(-u_i)) + return tuple(parts) + + if open_ms is None and close_ms is None and want_sheets is None: + u_i, best = max(candidates, key=lambda item: item[0]) + elif open_ms is not None and close_ms is None and want_sheets is None and not has_ctime: + # 兼容旧调用:只有 open_ms 时仍取最晚一条 + u_i, best = max(candidates, key=lambda item: item[0]) + else: + u_i, best = min(candidates, key=_score) + + realized = _safe_float(best.get("realizedPnl")) + if realized is None: + realized = _safe_float(best.get("pnl")) + return { + "close_quote": _safe_float(best.get("closeAvgPx")), + "realized_pnl": realized, + "close_ms": u_i, + "pos_id": str(best.get("posId") or "").strip() or None, + } + + +def fetch_options_unrealized_pnl_usdc(ex: ccxt.okx) -> float | None: + """ + 期权浮盈合计(USDC≈U). + 优先返回交易所标记价 upl;实例顶栏应改用 + `options_positions_lib.sum_options_net_pnl_usdc`(买一净盈亏)以与持仓卡一致. + """ + positions = fetch_option_positions(ex) + if positions is None: + return None + total = 0.0 + found = False + for pos in positions: + upl = _safe_float(pos.get("upl")) + if upl is None: + continue + found = True + total += upl + return round(total, 4) if found else None + + +def estimate_usdt_to_usdc(ex: ccxt.okx, usdt_amount: float) -> dict[str, Any]: + if usdt_amount <= 0: + return {"ok": False, "msg": "兑换数量须大于 0"} + try: + resp = ex.private_post_asset_convert_estimate_quote( + { + "baseCcy": "USDC", + "quoteCcy": "USDT", + "side": "buy", + "rfqSz": str(usdt_amount), + "rfqSzCcy": "USDT", + } + ) + data = (resp or {}).get("data") or [] + if not data: + return {"ok": False, "msg": "询价失败", "raw": resp} + row = data[0] + return { + "ok": True, + "quote_id": row.get("quoteId"), + "base_ccy": row.get("baseCcy"), + "quote_ccy": row.get("quoteCcy"), + "cnvt_px": _safe_float(row.get("cnvtPx")), + "base_sz": _safe_float(row.get("baseSz")), + "quote_sz": _safe_float(row.get("quoteSz")), + "rfq_sz": usdt_amount, + "raw": row, + } + except Exception as e: + return {"ok": False, "msg": str(e)} + + +def execute_convert(ex: ccxt.okx, quote_id: str) -> dict[str, Any]: + if not quote_id: + return {"ok": False, "msg": "缺少 quoteId"} + try: + resp = ex.private_post_asset_convert_trade({"quoteId": str(quote_id)}) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode", "0")) == "0": + return {"ok": True, "data": data[0], "raw": resp} + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +def transfer_ccy( + ex: ccxt.okx, + ccy: str, + amount: float, + from_account: str, + to_account: str, +) -> dict[str, Any]: + if amount <= 0: + return {"ok": False, "msg": "划转金额须大于 0"} + try: + resp = ex.transfer(str(ccy).upper(), float(amount), from_account, to_account) + return {"ok": True, "data": resp} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +_OKX_ACCT_CODE = {"funding": "6", "trading": "18", "spot": "18"} + + +def fetch_options_trading_usdc(ex: ccxt.okx, *, force: bool = False) -> float | None: + bal = fetch_options_balances(ex, force=force) + v = bal.get("trading_usdc") + if v is None: + return None + return round(float(v), 2) + + +def fetch_options_funding_usdc(ex: ccxt.okx, *, force: bool = False) -> float | None: + bal = fetch_options_balances(ex, force=force) + v = bal.get("funding_usdc") + if v is None: + return None + return round(float(v), 2) + + +def fetch_options_funding_usdt(ex: ccxt.okx, *, force: bool = False) -> float | None: + bal = fetch_options_balances(ex, force=force) + v = bal.get("funding_usdt") + if v is None: + return None + return round(float(v), 2) + + +def spot_market_swap_usdt_usdc( + ex: ccxt.okx, + *, + direction: str, + amount: float, +) -> dict[str, Any]: + """现货市价兑换 USDC-USDT.direction: usdt_to_usdc | usdc_to_usdt.""" + if amount <= 0: + return {"ok": False, "msg": "数量须大于 0"} + d = (direction or "").lower() + inst_id = "USDC-USDT" + try: + if d == "usdt_to_usdc": + body = { + "instId": inst_id, + "tdMode": "cash", + "side": "buy", + "ordType": "market", + "sz": str(amount), + "tgtCcy": "quote_ccy", + } + elif d == "usdc_to_usdt": + body = { + "instId": inst_id, + "tdMode": "cash", + "side": "sell", + "ordType": "market", + "sz": str(amount), + "tgtCcy": "base_ccy", + } + else: + return {"ok": False, "msg": "direction 须为 usdt_to_usdc 或 usdc_to_usdt"} + resp = ex.private_post_trade_order(body) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode")) == "0": + return {"ok": True, "data": data[0], "raw": resp} + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +def format_position_row( + pos: dict[str, Any], + ct_mult: float = 0.01, + *, + tick_sz: Any = None, +) -> dict[str, Any]: + from lib.options.options_pricing_lib import ( + close_breakeven_idx, + expiry_breakeven_px, + idx_distance_to_be, + total_premium, + ) + + sheets = _safe_float(pos.get("pos")) or 0.0 + avg = _safe_float(pos.get("avgPx")) + mark = _safe_float(pos.get("markPx")) + upl = _safe_float(pos.get("upl")) + upl_ratio = _safe_float(pos.get("uplRatio")) + idx_px = _safe_float(pos.get("idxPx")) + inst_id = str(pos.get("instId") or "") + opt_type = pos.get("optType") + strike = _safe_float(pos.get("stk")) + parsed_type, parsed_strike = option_fields_from_inst_id(inst_id) + if not opt_type: + opt_type = parsed_type + if strike is None: + strike = parsed_strike + eth_amount = round(abs(sheets) * ct_mult, 8) + premium_paid = ( + round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None + ) + delta_pa = _safe_float(pos.get("deltaPA")) + expiry_be = expiry_breakeven_px( + opt_type=str(opt_type or ""), + strike=strike, + avg_px=avg, + be_px_api=_safe_float(pos.get("bePx")), + ) + close_be = close_breakeven_idx( + opt_type=str(opt_type or ""), + idx_px=idx_px, + mark_px=mark, + avg_px=avg, + delta_pa=delta_pa, + pos=sheets, + ct_mult=ct_mult, + ) + exp_time_ms = normalize_option_exp_ms(pos.get("expTime"), inst_id) + return { + "inst_id": inst_id or pos.get("instId"), + "pos": sheets, + "eth_amount": eth_amount, + "avg_px": avg, + "mark_px": mark, + "avg_px_fmt": format_option_px(avg, tick_sz) if avg is not None else None, + "mark_px_fmt": format_option_px(mark, tick_sz) if mark is not None else None, + "premium_paid_fmt": format_usdc_amount(premium_paid), + "tick_sz": tick_sz, + "ct_mult": ct_mult, + "idx_px": idx_px, + "premium_paid": premium_paid, + "upl": upl, + "upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None, + "exp_time": exp_time_ms, + "exp_time_ms": exp_time_ms, + "opt_type": opt_type, + "strike": strike, + "avail_pos": _safe_float(pos.get("availPos")), + "expiry_be_px": expiry_be, + "close_be_px": close_be, + "dist_expiry_be": idx_distance_to_be(idx_px, expiry_be), + "dist_close_be": idx_distance_to_be(idx_px, close_be), + "raw": pos, + } + + +def options_api_ready(ex: ccxt.okx | None) -> tuple[bool, str]: + if ex is None: + return False, "期权 API 未配置" + if not ex.apiKey or not ex.secret or not ex.password: + return False, "期权 API Key 不完整" + return True, "" diff --git a/lib/exchange/okx_orders_lib.py b/lib/exchange/okx_orders_lib.py new file mode 100644 index 0000000..c112128 --- /dev/null +++ b/lib/exchange/okx_orders_lib.py @@ -0,0 +1,116 @@ +""" +OKX 挂单聚合:普通委托 + 算法单(conditional / oco / trigger). +交易所 App「止盈止损」页多为 orders-algo-pending,仅 fetch_open_orders 默认拿不到. +""" +from __future__ import annotations + +from typing import Any + + +def _order_dedupe_key(order: dict) -> str: + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + return str(order.get("id") or info.get("algoId") or info.get("ordId") or "") + + +def _okx_algo_cancel_id(order_id: str) -> str: + oid = str(order_id or "") + if ":" in oid: + return oid.split(":", 1)[0] + return oid + + +def _okx_order_needs_stop_cancel_param(order: dict) -> bool: + """OKX 条件/算法单撤单须 params.stop=True,否则 cancel_order 走普通单接口会静默失败.""" + if not isinstance(order, dict): + return False + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + if order.get("stopLossPrice") is not None or order.get("takeProfitPrice") is not None: + return True + if info.get("algoId") or info.get("slTriggerPx") or info.get("tpTriggerPx"): + return True + typ = str(order.get("type") or info.get("ordType") or "").lower() + for token in ("conditional", "oco", "trigger", "move_order_stop", "iceberg"): + if token in typ: + return True + return False + + +def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]: + """合并 OKX 普通挂单与算法挂单(去重).""" + if not exchange_symbol: + return [] + ex.load_markets() + sym = exchange_symbol + try: + sym = ex.market(exchange_symbol)["symbol"] + except Exception: + pass + seen: set[str] = set() + out: list[dict] = [] + + def add_batch(batch: list | None) -> None: + for o in batch or []: + if not isinstance(o, dict): + continue + k = _order_dedupe_key(o) + if not k or k in seen: + continue + seen.add(k) + out.append(o) + + try: + add_batch(ex.fetch_open_orders(sym)) + except Exception: + pass + for params in ( + {"ordType": "conditional"}, + {"ordType": "oco"}, + {"trigger": True}, + ): + try: + add_batch(ex.fetch_open_orders(sym, params=dict(params))) + except Exception: + pass + return out + + +def cancel_okx_all_open_orders(ex, exchange_symbol: str) -> int: + """ + 撤销某合约全部挂单(普通 + 条件/算法). + OKX 止盈止损在 orders-algo-pending,必须用 stop=True 才能撤掉. + """ + if not exchange_symbol: + return 0 + ex.load_markets() + sym = exchange_symbol + try: + sym = ex.market(exchange_symbol)["symbol"] + except Exception: + pass + n = 0 + for o in fetch_okx_all_open_orders(ex, sym): + oid = _order_dedupe_key(o) + if not oid: + continue + cancel_id = _okx_algo_cancel_id(oid) + params = {"stop": True} if _okx_order_needs_stop_cancel_param(o) else None + try: + ex.cancel_order(cancel_id, sym, params) + n += 1 + continue + except Exception: + pass + try: + ex.cancel_order(oid, sym, params) + n += 1 + except Exception: + pass + try: + ex.cancel_all_orders(sym) + except Exception: + pass + return n diff --git a/lib/hedge_plan/__init__.py b/lib/hedge_plan/__init__.py new file mode 100644 index 0000000..300c2bd --- /dev/null +++ b/lib/hedge_plan/__init__.py @@ -0,0 +1 @@ +# hedge_plan package diff --git a/lib/hedge_plan/hedge_options_exclusive_lib.py b/lib/hedge_plan/hedge_options_exclusive_lib.py new file mode 100644 index 0000000..162af36 --- /dev/null +++ b/lib/hedge_plan/hedge_options_exclusive_lib.py @@ -0,0 +1,86 @@ +"""对冲计划与单独期权开仓互斥门控. + +默认开启:有进行中对冲计划时禁止单独开期权;有纯期权持仓时禁止启动对冲计划. +关闭 HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE 后两边可同时开. +""" +from __future__ import annotations + +import os +from typing import Any, Callable, Optional + + +def _env_bool(key: str, default: bool = False) -> bool: + v = (os.getenv(key) or "").strip().lower() + if not v: + return default + return v in ("1", "true", "yes", "on") + + +def mutual_exclusive_enabled() -> bool: + return _env_bool("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", True) + + +def block_standalone_option_open_msg(conn: Any) -> Optional[str]: + """若应拦截单独开期权,返回中文原因;否则 None.""" + if not mutual_exclusive_enabled(): + return None + try: + from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables + + init_hedge_plan_tables(conn) + if count_active_plans(conn) > 0: + return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)" + except Exception: + return "互斥门控校验失败,暂禁止单独开期权" + return None + + +def _pos_nonzero(raw: dict[str, Any]) -> bool: + try: + return abs(float(raw.get("pos") or 0)) > 1e-12 + except (TypeError, ValueError): + return False + + +def has_standalone_option_position(conn: Any, raw_positions: list[dict[str, Any]] | None) -> bool: + """交易所期权持仓中,是否存在未挂在进行中对冲计划腿上的仓位.""" + if not raw_positions: + return False + from lib.instance.instance_dashboard_lib import _resolve_options_source + + for p in raw_positions: + if not isinstance(p, dict) or not _pos_nonzero(p): + continue + inst = str(p.get("instId") or p.get("inst_id") or "").strip() + if not inst: + continue + source, _, _ = _resolve_options_source(conn, inst) + if source == "option": + return True + return False + + +def block_hedge_plan_start_msg( + conn: Any, + *, + fetch_positions: Optional[Callable[[Any], Any]] = None, + exchange: Any = None, + raw_positions: list[dict[str, Any]] | None = None, +) -> Optional[str]: + """若应拦截启动对冲计划,返回中文原因;否则 None.""" + if not mutual_exclusive_enabled(): + return None + rows = raw_positions + if rows is None: + if fetch_positions is None or exchange is None: + return None + try: + rows = fetch_positions(exchange) or [] + except Exception: + return "获取期权持仓失败,暂禁止启动对冲计划" + try: + if has_standalone_option_position(conn, rows): + return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)" + except Exception: + return "互斥门控校验失败,暂禁止启动对冲计划" + return None diff --git a/lib/hedge_plan/hedge_plan_calc_lib.py b/lib/hedge_plan/hedge_plan_calc_lib.py new file mode 100644 index 0000000..b44cc53 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_calc_lib.py @@ -0,0 +1,782 @@ +"""对冲计划:情景测算与全仓建议仓(纯函数,无 IO).""" +from __future__ import annotations + +from typing import Any, Optional + + +def _f(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def perp_coin_amount(*, contracts: float, contract_size: float) -> float: + return float(contracts) * float(contract_size or 1.0) + + +def perp_pnl( + *, + direction: str, + entry: float, + exit_px: float, + contracts: float, + contract_size: float, +) -> float: + coins = perp_coin_amount(contracts=contracts, contract_size=contract_size) + d = (direction or "long").strip().lower() + if d == "short": + return (float(entry) - float(exit_px)) * coins + return (float(exit_px) - float(entry)) * coins + + +def option_premium_total(*, ask: float, sheets: float, ct_mult: float) -> float: + """卖一报价为每 1 币;权利金 = ask × 张数 × ct_mult.""" + return float(ask) * float(sheets) * float(ct_mult or 0.01) + + +def option_expiry_pnl( + *, + opt_type: str, + strike: float, + spot: float, + sheets: float, + ct_mult: float, + premium_paid: float, +) -> float: + o = (opt_type or "").strip().upper() + intrinsic_per_coin = 0.0 + if o in ("C", "CALL"): + intrinsic_per_coin = max(0.0, float(spot) - float(strike)) + elif o in ("P", "PUT"): + intrinsic_per_coin = max(0.0, float(strike) - float(spot)) + else: + return -float(premium_paid) + value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01) + return value - float(premium_paid) + + +def spot_from_expiry_intrinsic_profit( + *, + opt_type: str, + strike: float, + sheets: float, + ct_mult: float, + premium_paid: float, + profit: float, +) -> float | None: + """按到期实值反推现货价:使该腿到期盈亏 ≈ profit. + + 到期价值=实值×张数×乘数;盈亏=价值−权利金 → 实值/币=(profit+权利金)/(张数×乘数). + Call: spot=K+实值/币; Put: spot=K−实值/币. + """ + try: + k = float(strike) + n = float(sheets or 0) + ct = float(ct_mult or 0.01) + prem = float(premium_paid or 0) + pnl = float(profit) + except (TypeError, ValueError): + return None + denom = n * ct + if denom <= 0: + return None + need = (pnl + prem) / denom + if need < 0: + need = 0.0 + o = (opt_type or "").strip().upper() + if o in ("C", "CALL"): + return round(k + need, 2) + if o in ("P", "PUT"): + return round(k - need, 2) + return None + + +def suggest_contracts_from_notional( + *, + notional: float, + entry: float, + contract_size: float, +) -> float: + if entry <= 0 or contract_size <= 0 or notional <= 0: + return 0.0 + return float(notional) / (float(entry) * float(contract_size)) + + +def floor_contracts_to_precision(contracts: float, decimals: int) -> float: + """按交易所张数精度向下取整,避免建议张数超过可用保证金.""" + import math + + raw = float(contracts or 0.0) + if raw <= 0: + return 0.0 + try: + d = int(decimals) + except (TypeError, ValueError): + d = 0 + if d <= 0: + return float(math.floor(raw + 1e-12)) + scale = 10**d + return math.floor(raw * scale + 1e-12) / scale + + +def option_unit_cost_usdc(*, ask: float, ct_mult: float) -> float: + """单张权利金(USDC) = 卖一价 × ct_mult.""" + a = _f(ask) + if a is None or a <= 0: + return 0.0 + return float(a) * float(ct_mult or 0.01) + + +def resolve_oo_budget_usdc( + *, + trading_usdc: Any, + trade_budget_usdc: Any, + buffer_ratio: Any = 0.95, +) -> dict[str, Any]: + """期期可用预算 = min(交易户×buffer, 单笔预算).""" + import math + + trading = _f(trading_usdc) + cap = _f(trade_budget_usdc) + buf = _f(buffer_ratio) + if buf is None or buf <= 0: + buf = 0.95 + if buf > 1: + buf = 1.0 + trading_cap = None if trading is None else max(0.0, float(trading) * float(buf)) + trade_cap = None if cap is None else max(0.0, float(cap)) + if trading_cap is None and trade_cap is None: + return { + "ok": False, + "budget_usdc": 0.0, + "trading_cap": None, + "trade_budget_cap": None, + "buffer_ratio": float(buf), + "msg": "缺少交易户余额与单笔预算", + } + if trading_cap is None: + budget = float(trade_cap or 0.0) + elif trade_cap is None: + budget = float(trading_cap) + else: + budget = min(float(trading_cap), float(trade_cap)) + budget = float(math.floor(budget * 1e6 + 1e-12) / 1e6) + return { + "ok": budget > 0, + "budget_usdc": budget, + "trading_cap": None if trading_cap is None else round(float(trading_cap), 6), + "trade_budget_cap": None if trade_cap is None else round(float(trade_cap), 6), + "buffer_ratio": float(buf), + "msg": "" if budget > 0 else "可用预算为 0", + } + + +def _cap_sheets_by_ask_depth(sheets: int, ask_sz: Any) -> int: + import math + + n = max(0, int(sheets)) + depth = _f(ask_sz) + if depth is None: + return n + if depth <= 0: + return 0 + return min(n, int(math.floor(float(depth) + 1e-12))) + + +def _normalize_oo_sheets_mode(mode: str) -> str: + m = (mode or "same_sheets").strip().lower() + if m in ("long_bias", "bias_long", "long", "做多"): + return "long_bias" + if m in ("short_bias", "bias_short", "short", "做空"): + return "short_bias" + # 旧「均分」兼容:按预算 50/50(页面已移除) + if m in ("split", "equal_budget", "split_budget", "均分"): + return "split_budget" + return "same_sheets" + + +def _normalize_oo_bias_split_by(raw: Any) -> str: + v = str(raw or "budget").strip().lower() + if v in ("sheets", "qty", "quantity", "张数"): + return "sheets" + return "budget" + + +def _clamp_oo_bias_ratio(raw: Any, default: float = 0.7) -> float: + try: + r = float(raw) + except (TypeError, ValueError): + r = float(default) + if r <= 0 or r >= 1: + r = float(default) + return r + + +def _oo_call_put_leg_index(opt_type_a: str, opt_type_b: str) -> tuple[Optional[str], Optional[str], str]: + """返回 (call_side, put_side, err);side 为 'a'/'b'.""" + a = (opt_type_a or "").strip().upper() + b = (opt_type_b or "").strip().upper() + if a.startswith("C"): + a = "C" + elif a.startswith("P"): + a = "P" + if b.startswith("C"): + b = "C" + elif b.startswith("P"): + b = "P" + if {a, b} != {"C", "P"}: + return None, None, "做多/做空需一腿 Call、一腿 Put" + call_side = "a" if a == "C" else "b" + put_side = "b" if call_side == "a" else "a" + return call_side, put_side, "" + + +def suggest_oo_sheets( + *, + mode: str, + budget_usdc: float, + ask_a: float, + ct_mult_a: float = 0.01, + ask_sz_a: Any = None, + opt_type_a: str = "", + ask_b: float, + ct_mult_b: float = 0.01, + ask_sz_b: Any = None, + opt_type_b: str = "", + bias_split_by: str = "budget", + bias_ratio: float = 0.7, +) -> dict[str, Any]: + """期期建议张数:same_sheets / long_bias / short_bias(及旧 split_budget).""" + import math + + m = _normalize_oo_sheets_mode(mode) + split_by = _normalize_oo_bias_split_by(bias_split_by) + ratio = _clamp_oo_bias_ratio(bias_ratio) + budget = max(0.0, float(budget_usdc or 0.0)) + cost_a = option_unit_cost_usdc(ask=ask_a, ct_mult=ct_mult_a) + cost_b = option_unit_cost_usdc(ask=ask_b, ct_mult=ct_mult_b) + + def _fail(msg: str, n_a: int = 0, n_b: int = 0) -> dict[str, Any]: + return { + "mode": m, + "sheets_a": n_a, + "sheets_b": n_b, + "cost_a": round(cost_a, 8), + "cost_b": round(cost_b, 8), + "premium_est": round(cost_a * n_a + cost_b * n_b, 6), + "ok": False, + "msg": msg, + "bias_split_by": split_by, + "bias_ratio": ratio, + } + + if budget <= 0: + return _fail("可用预算为 0") + if cost_a <= 0 or cost_b <= 0: + return _fail("缺少有效卖一价,无法建议张数") + + pair = cost_a + cost_b + n_pair = int(math.floor(budget / pair + 1e-12)) if pair > 0 else 0 + # 与同张数一致:先按预算得 n,再各自深度封顶后取 min + n_same = min( + _cap_sheets_by_ask_depth(n_pair, ask_sz_a), + _cap_sheets_by_ask_depth(n_pair, ask_sz_b), + ) + + if m == "same_sheets": + n_a = n_same + n_b = n_same + elif m == "split_budget": + half = budget / 2.0 + n_a = int(math.floor(half / cost_a + 1e-12)) + n_b = int(math.floor(half / cost_b + 1e-12)) + n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a) + n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b) + else: + call_side, put_side, err = _oo_call_put_leg_index(opt_type_a, opt_type_b) + if err: + return _fail(err) + major_is_call = m == "long_bias" + if split_by == "sheets": + # 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆到 Call/Put + total = int(n_same) * 2 + if total < 2: + return _fail("同张数总规模不足 2,无法按比例拆分") + major_n = int(round(total * ratio)) + major_n = max(1, min(major_n, total - 1)) + minor_n = total - major_n + n_call = major_n if major_is_call else minor_n + n_put = minor_n if major_is_call else major_n + else: + maj_budget = budget * ratio + min_budget = budget * (1.0 - ratio) + cost_call = cost_a if call_side == "a" else cost_b + cost_put = cost_b if call_side == "a" else cost_a + if major_is_call: + n_call = int(math.floor(maj_budget / cost_call + 1e-12)) if cost_call > 0 else 0 + n_put = int(math.floor(min_budget / cost_put + 1e-12)) if cost_put > 0 else 0 + else: + n_put = int(math.floor(maj_budget / cost_put + 1e-12)) if cost_put > 0 else 0 + n_call = int(math.floor(min_budget / cost_call + 1e-12)) if cost_call > 0 else 0 + n_a = n_call if call_side == "a" else n_put + n_b = n_put if call_side == "a" else n_call + n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a) + n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b) + + prem = cost_a * n_a + cost_b * n_b + ok = n_a >= 1 and n_b >= 1 + msg = "" if ok else "预算不够开 1+1(或卖一深度不足)" + return { + "mode": m, + "sheets_a": n_a, + "sheets_b": n_b, + "cost_a": round(cost_a, 8), + "cost_b": round(cost_b, 8), + "premium_est": round(prem, 6), + "ok": ok, + "msg": msg, + "bias_split_by": split_by, + "bias_ratio": ratio, + } + + +def build_perp_options_preview( + *, + direction: str, + entry: float, + tp: float, + sl: float, + contracts: float, + contract_size: float, + opt_type: str, + strike: float, + sheets: float, + ct_mult: float, + premium_paid: float, + index_px: Optional[float] = None, +) -> dict[str, Any]: + """ + 永期情景. + 止盈账:永续止盈盈利 - 权利金. + 止损账:期权到期内在(按 SL 价) - 永续止损亏损额. + """ + d = (direction or "long").strip().lower() + pnl_tp_perp = perp_pnl( + direction=d, entry=entry, exit_px=tp, contracts=contracts, contract_size=contract_size + ) + pnl_sl_perp = perp_pnl( + direction=d, entry=entry, exit_px=sl, contracts=contracts, contract_size=contract_size + ) + # 止盈统计口径 + tp_total = float(pnl_tp_perp) - float(premium_paid) + # 止损:期权按 SL 价结算内在 - |永续亏损| + opt_at_sl = option_expiry_pnl( + opt_type=opt_type, + strike=strike, + spot=sl, + sheets=sheets, + ct_mult=ct_mult, + premium_paid=premium_paid, + ) + sl_total = float(opt_at_sl) - abs(float(pnl_sl_perp)) if pnl_sl_perp < 0 else float(opt_at_sl) + float( + pnl_sl_perp + ) + # 有符号相加更稳:期权盈亏 + 永续盈亏 + sl_total_signed = float(opt_at_sl) + float(pnl_sl_perp) + + spot = float(index_px) if index_px is not None else float(entry) + opt_flat = option_expiry_pnl( + opt_type=opt_type, + strike=strike, + spot=spot, + sheets=sheets, + ct_mult=ct_mult, + premium_paid=premium_paid, + ) + flat_total = 0.0 + float(opt_flat) + + opt_at_tp = option_expiry_pnl( + opt_type=opt_type, + strike=strike, + spot=tp, + sheets=sheets, + ct_mult=ct_mult, + premium_paid=premium_paid, + ) + + return { + "plan_type": "perp_options", + "direction": d, + "contracts": contracts, + "coin_amount": perp_coin_amount(contracts=contracts, contract_size=contract_size), + "premium_paid": round(float(premium_paid), 6), + "scenarios": [ + { + "id": "tp", + "label": "止盈(计划结束口径)", + "spot": tp, + "perp_pnl": round(pnl_tp_perp, 4), + "options_pnl": round(-float(premium_paid), 4), + "total": round(tp_total, 4), + "note": "止盈盈利 − 权利金;期权可不强平", + }, + { + "id": "sl", + "label": "止损(计划结束口径)", + "spot": sl, + "perp_pnl": round(pnl_sl_perp, 4), + "options_pnl": round(opt_at_sl, 4), + "total": round(sl_total_signed, 4), + "note": "期权盈利 − 永续亏损(有符号相加);期权须强平", + }, + { + "id": "flat", + "label": "到期·现价附近", + "spot": spot, + "perp_pnl": 0.0, + "options_pnl": round(opt_flat, 4), + "total": round(flat_total, 4), + "note": "示意:永续未动,期权按到期内在", + }, + { + "id": "expiry_tp", + "label": "到期·止盈价", + "spot": tp, + "perp_pnl": round(pnl_tp_perp, 4), + "options_pnl": round(opt_at_tp, 4), + "total": round(pnl_tp_perp + opt_at_tp, 4), + "note": "若期权拿到 TP 价到期(参考)", + }, + { + "id": "expiry_sl", + "label": "到期·止损价", + "spot": sl, + "perp_pnl": round(pnl_sl_perp, 4), + "options_pnl": round(opt_at_sl, 4), + "total": round(pnl_sl_perp + opt_at_sl, 4), + "note": "与止损口径相近(期权用内在)", + }, + ], + "summary": { + "tp_total": round(tp_total, 4), + "sl_total": round(sl_total_signed, 4), + "premium_paid": round(float(premium_paid), 4), + "hedge_ratio_at_sl": _hedge_ratio(opt_at_sl, pnl_sl_perp), + }, + } + + +def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]: + loss = abs(float(perp_pnl)) if float(perp_pnl) < 0 else 0.0 + if loss <= 1e-12: + return None + if float(opt_pnl) <= 0: + return 0.0 + return round(float(opt_pnl) / loss * 100.0, 2) + + +def build_options_options_preview( + *, + target_price: float | None = None, + target_price_up: float | None = None, + target_price_down: float | None = None, + profit_rr: float | None = None, + index_px: float, + leg_a: dict[str, Any], + leg_b: dict[str, Any], +) -> dict[str, Any]: + """期期情景:盈亏比达标 / 到期现价 / 最大保费损耗. + + 新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价. + 残值按亏损腿本合约权利金的 20% 计. + """ + + def _leg_pnl(leg: dict[str, Any], spot: float) -> float: + return option_expiry_pnl( + opt_type=str(leg.get("opt_type") or ""), + strike=float(leg["strike"]), + spot=spot, + sheets=float(leg.get("sheets") or 0), + ct_mult=float(leg.get("ct_mult") or 0.01), + premium_paid=float(leg.get("premium_paid") or 0), + ) + + prem_a = float(leg_a.get("premium_paid") or 0) + prem_b = float(leg_b.get("premium_paid") or 0) + prem = prem_a + prem_b + rr = float(profit_rr) if profit_rr is not None else None + + # 新:盈亏比情景(不依赖指数上下破价) + if rr is not None and rr > 0: + # 盈利腿达 RR:盈利金额 = rr × 总权利金;亏损腿按全亏 / 本合约残值20%回收 + win_profit = rr * prem + a_at_a = win_profit + b_at_a_full = -prem_b + b_at_a_res = -prem_b * 0.8 # 本合约回收 20% + b_at_b = win_profit + a_at_b_full = -prem_a + a_at_b_res = -prem_a * 0.8 + + spot_a = spot_from_expiry_intrinsic_profit( + opt_type=str(leg_a.get("opt_type") or ""), + strike=float(leg_a["strike"]), + sheets=float(leg_a.get("sheets") or 0), + ct_mult=float(leg_a.get("ct_mult") or 0.01), + premium_paid=prem_a, + profit=win_profit, + ) + spot_b = spot_from_expiry_intrinsic_profit( + opt_type=str(leg_b.get("opt_type") or ""), + strike=float(leg_b["strike"]), + sheets=float(leg_b.get("sheets") or 0), + ct_mult=float(leg_b.get("ct_mult") or 0.01), + premium_paid=prem_b, + profit=win_profit, + ) + + a_flat = _leg_pnl(leg_a, index_px) + b_flat = _leg_pnl(leg_b, index_px) + flat_total = a_flat + b_flat + + return { + "plan_type": "options_options", + "premium_paid": round(prem, 6), + "profit_rr": rr, + "target_price": None, + "target_price_up": None, + "target_price_down": None, + "winner_at_up": "a", + "winner_at_down": "b", + "winner_at_target": "a", + "scenarios": [ + { + "id": "rr_leg_a_full", + "label": f"腿A达盈亏比{rr:g}(亏腿全损)", + "spot": spot_a, + "leg_a_pnl": round(a_at_a, 4), + "leg_b_pnl": round(b_at_a_full, 4), + "total": round(a_at_a + b_at_a_full, 4), + "note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏", + }, + { + "id": "rr_leg_b_full", + "label": f"腿B达盈亏比{rr:g}(亏腿全损)", + "spot": spot_b, + "leg_a_pnl": round(a_at_b_full, 4), + "leg_b_pnl": round(b_at_b, 4), + "total": round(a_at_b_full + b_at_b, 4), + "note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏", + }, + { + "id": "rr_leg_a_residual", + "label": f"腿A达盈亏比{rr:g}(亏腿残值20%)", + "spot": spot_a, + "leg_a_pnl": round(a_at_a, 4), + "leg_b_pnl": round(b_at_a_res, 4), + "total": round(a_at_a + b_at_a_res, 4), + "note": "现货同腿A达标反推;亏腿买一回收约本合约权利金20%", + }, + { + "id": "expiry_flat", + "label": "到期·现价", + "spot": index_px, + "leg_a_pnl": round(a_flat, 4), + "leg_b_pnl": round(b_flat, 4), + "total": round(flat_total, 4), + "note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值", + }, + { + "id": "max_premium_loss", + "label": "最大保费损耗", + "spot": None, + "leg_a_pnl": round(-prem_a, 4), + "leg_b_pnl": round(-prem_b, 4), + "total": round(-prem, 4), + "note": "双腿权利金全部损失", + }, + ], + "summary": { + "profit_rr": rr, + "spot_at_rr_a": spot_a, + "spot_at_rr_b": spot_b, + "at_rr_a_full_total": round(a_at_a + b_at_a_full, 4), + "at_rr_b_full_total": round(a_at_b_full + b_at_b, 4), + "at_rr_a_residual_total": round(a_at_a + b_at_a_res, 4), + "at_target_up_total": round(a_at_a + b_at_a_full, 4), + "at_target_down_total": round(a_at_b_full + b_at_b, 4), + "at_target_total": round(a_at_a + b_at_a_full, 4), + "expiry_flat_total": round(flat_total, 4), + "premium_paid": round(prem, 6), + "expiry_is_loss": flat_total <= 0, + "rr_risk_premium": round(prem, 6), + "rr_at_up": round((a_at_a + b_at_a_full) / prem, 4) if prem > 0 else None, + "rr_at_down": round((a_at_b_full + b_at_b) / prem, 4) if prem > 0 else None, + }, + } + + # 兼容旧单目标:若未传上下目标则用 target_price 填两边 + up = target_price_up if target_price_up is not None else target_price + down = target_price_down if target_price_down is not None else target_price + if up is None or down is None: + raise ValueError("缺少盈亏比或上破/下破目标价") + up_f = float(up) + down_f = float(down) + + a_up = _leg_pnl(leg_a, up_f) + b_up = _leg_pnl(leg_b, up_f) + at_up = a_up + b_up + win_up = "a" if a_up >= b_up else "b" + + a_dn = _leg_pnl(leg_a, down_f) + b_dn = _leg_pnl(leg_b, down_f) + at_dn = a_dn + b_dn + win_dn = "a" if a_dn >= b_dn else "b" + + a_flat = _leg_pnl(leg_a, index_px) + b_flat = _leg_pnl(leg_b, index_px) + flat_total = a_flat + b_flat + expiry_loss = flat_total if flat_total <= 0 else flat_total + + return { + "plan_type": "options_options", + "premium_paid": round(prem, 6), + "target_price": up_f, # 兼容旧字段,取上破 + "target_price_up": up_f, + "target_price_down": down_f, + "winner_at_up": win_up, + "winner_at_down": win_dn, + "winner_at_target": win_up, + "scenarios": [ + { + "id": "target_up", + "label": "上破目标", + "spot": up_f, + "leg_a_pnl": round(a_up, 4), + "leg_b_pnl": round(b_up, 4), + "total": round(at_up, 4), + "note": f"盈利方≈腿{win_up.upper()}(可平);亏损方默认到期", + }, + { + "id": "target_down", + "label": "下破目标", + "spot": down_f, + "leg_a_pnl": round(a_dn, 4), + "leg_b_pnl": round(b_dn, 4), + "total": round(at_dn, 4), + "note": f"盈利方≈腿{win_dn.upper()}(可平);亏损方默认到期", + }, + { + "id": "expiry_flat", + "label": "到期·现价(无突破)", + "spot": index_px, + "leg_a_pnl": round(a_flat, 4), + "leg_b_pnl": round(b_flat, 4), + "total": round(flat_total, 4), + "note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值", + }, + { + "id": "max_premium_loss", + "label": "最大保费损耗", + "spot": None, + "leg_a_pnl": round(-prem_a, 4), + "leg_b_pnl": round(-prem_b, 4), + "total": round(-prem, 4), + "note": "双腿权利金全部损失", + }, + ], + "summary": { + "at_target_up_total": round(at_up, 4), + "at_target_down_total": round(at_dn, 4), + "at_target_total": round(at_up, 4), + "expiry_flat_total": round(expiry_loss, 4), + "premium_paid": round(prem, 6), + "expiry_is_loss": flat_total <= 0, + # 盈亏比:盈利/全亏保费(风险=权利金全损) + "rr_risk_premium": round(prem, 6), + "rr_at_up": round(at_up / prem, 4) if prem > 0 else None, + "rr_at_down": round(at_dn / prem, 4) if prem > 0 else None, + }, + } + + +def gate_status( + *, + hedge_enabled: bool, + sizing_mode: str, + plan_type: str, + options_enabled: bool, + live_order: bool = False, + live_trading: bool = False, + active_count: int = 0, + max_active: int = 1, + show_perp_options: bool = True, + show_options_options: bool = True, + mutual_exclusive: bool = True, + has_standalone_option: bool = False, +) -> dict[str, Any]: + from lib.trade.position_sizing_lib import is_full_margin_mode + + full = is_full_margin_mode(sizing_mode) + pt = (plan_type or "").strip().lower() + can_preview = True + can_start = True + reasons: list[str] = [] + if not hedge_enabled: + can_start = False + reasons.append("对冲计划未启用(HEDGE_PLAN_ENABLED)") + if not options_enabled: + can_preview = False + can_start = False + reasons.append("期权模块未启用") + if pt == "perp_options" and not show_perp_options: + can_preview = False + can_start = False + reasons.append("永期对冲已隐藏(HEDGE_PLAN_SHOW_PERP_OPTIONS)") + if pt == "options_options" and not show_options_options: + can_preview = False + can_start = False + reasons.append("期期对冲已隐藏(HEDGE_PLAN_SHOW_OPTIONS_OPTIONS)") + if not live_order: + can_start = False + reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)") + if active_count >= max(1, int(max_active or 1)): + can_start = False + reasons.append(f"活跃计划已达上限({max_active})") + if mutual_exclusive and has_standalone_option: + can_start = False + reasons.append("存在单独期权持仓,禁止启动对冲计划(互斥门控)") + if pt == "perp_options": + if not full: + can_start = False + reasons.append("永期开仓仅全仓模式可用(当前可测算)") + if not live_trading: + can_start = False + reasons.append("未开启实盘(LIVE_TRADING_ENABLED)") + elif pt == "options_options": + pass + else: + can_start = False + reasons.append("未知计划类型") + if can_start: + reasons = [] + return { + "hedge_enabled": hedge_enabled, + "options_enabled": options_enabled, + "sizing_mode": sizing_mode, + "is_full_margin": full, + "plan_type": pt, + "live_order": live_order, + "live_trading": live_trading, + "active_count": active_count, + "max_active": max_active, + "show_perp_options": bool(show_perp_options), + "show_options_options": bool(show_options_options), + "mutual_exclusive": bool(mutual_exclusive), + "has_standalone_option": bool(has_standalone_option), + "can_preview": can_preview, + "can_start": can_start, + "reasons": reasons, + } diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py new file mode 100644 index 0000000..319452f --- /dev/null +++ b/lib/hedge_plan/hedge_plan_db.py @@ -0,0 +1,468 @@ +"""对冲计划 SQLite 表.""" +from __future__ import annotations + +import sqlite3 +from typing import Any, Optional + + +def init_hedge_plan_tables(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS hedge_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_type TEXT NOT NULL, + status TEXT NOT NULL, + underlying TEXT NOT NULL, + direction TEXT, + entry_mark REAL, + tp REAL, + sl REAL, + target_price REAL, + sizing_mode_at_open TEXT, + perp_size REAL, + margin REAL, + leverage REAL, + premium_total REAL, + realized_pnl_perp REAL, + realized_pnl_options REAL, + realized_pnl_total REAL, + stats_bucket TEXT, + close_reason TEXT, + wechat_start_sent INTEGER DEFAULT 0, + wechat_end_sent INTEGER DEFAULT 0, + note TEXT, + preview_json TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + opened_at TIMESTAMP, + closed_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS hedge_plan_legs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL, + leg_role TEXT NOT NULL, + symbol TEXT, + inst_id TEXT, + opt_type TEXT, + strike REAL, + side TEXT, + size REAL, + avg_open REAL, + premium REAL, + status TEXT, + linked_monitor_id INTEGER, + options_trade_id INTEGER, + exchange_ord_id TEXT, + realized_pnl REAL, + close_reason TEXT, + opened_at TIMESTAMP, + closed_at TIMESTAMP, + FOREIGN KEY(plan_id) REFERENCES hedge_plans(id) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_hedge_plans_status ON hedge_plans(status)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_hedge_plan_legs_plan ON hedge_plan_legs(plan_id)" + ) + _ensure_column(conn, "hedge_plans", "target_price_up", "REAL") + _ensure_column(conn, "hedge_plans", "target_price_down", "REAL") + # 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价 + _ensure_column(conn, "hedge_plans", "profit_rr", "REAL") + # close_all=残值平(本合约权利金≤20%且有买一);hold_expiry=残腿持有至到期 + _ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT") + # 永期「以期权为主」 + _ensure_column(conn, "hedge_plans", "option_primary", "INTEGER") + _ensure_column(conn, "hedge_plans", "option_target_points", "REAL") + _ensure_column(conn, "hedge_plans", "perp_target_points", "REAL") + _ensure_column(conn, "hedge_plans", "option_perp_ratio", "REAL") + _ensure_column(conn, "hedge_plans", "premium_budget", "REAL") + _ensure_column(conn, "hedge_plans", "strike_interval", "REAL") + _ensure_column(conn, "hedge_plans", "min_option_hours", "REAL") + _ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT") + _ensure_column(conn, "hedge_plans", "option_leverage", "REAL") + _ensure_column(conn, "hedge_plans", "perp_direction", "TEXT") + _ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL") + + +def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + names: set[str] = set() + for r in rows: + try: + names.add(str(r["name"])) + except (TypeError, KeyError, IndexError): + names.add(str(r[1])) + if col not in names: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") + + +_ACTIVE_STATUSES = ("opening", "active", "partial", "watching") + + +def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int: + statuses = ",".join(f"'{s}'" for s in _ACTIVE_STATUSES) + if plan_type: + row = conn.execute( + f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses}) AND plan_type=?", + (plan_type,), + ).fetchone() + else: + row = conn.execute( + f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses})" + ).fetchone() + return int((row["c"] if row else 0) or 0) + + +def insert_plan(conn: sqlite3.Connection, row: dict[str, Any]) -> int: + cols = list(row.keys()) + placeholders = ",".join(["?"] * len(cols)) + conn.execute( + f"INSERT INTO hedge_plans ({','.join(cols)}) VALUES ({placeholders})", + [row[c] for c in cols], + ) + return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + + +def insert_leg(conn: sqlite3.Connection, row: dict[str, Any]) -> int: + cols = list(row.keys()) + placeholders = ",".join(["?"] * len(cols)) + conn.execute( + f"INSERT INTO hedge_plan_legs ({','.join(cols)}) VALUES ({placeholders})", + [row[c] for c in cols], + ) + return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + + +def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None: + if not fields: + return + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id]) + + +def update_leg(conn: sqlite3.Connection, leg_id: int, **fields: Any) -> None: + if not fields: + return + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute(f"UPDATE hedge_plan_legs SET {sets} WHERE id=?", [*fields.values(), int(leg_id)]) + + +def missing_leg_role(legs: list[dict[str, Any]]) -> Optional[str]: + for leg in legs or []: + if str(leg.get("status") or "").strip().lower() == "pending": + role = str(leg.get("leg_role") or "").strip() + if role: + return role + return None + + +def list_plans( + conn: sqlite3.Connection, + *, + status: Optional[str] = None, + plan_type: Optional[str] = None, + underlying: Optional[str] = None, + limit: int = 50, +) -> list[dict[str, Any]]: + wheres: list[str] = [] + args: list[Any] = [] + if status: + wheres.append("status=?") + args.append(status) + if plan_type: + wheres.append("plan_type=?") + args.append(plan_type) + if underlying: + wheres.append("underlying=?") + args.append(underlying) + where = (" WHERE " + " AND ".join(wheres)) if wheres else "" + rows = conn.execute( + f"SELECT * FROM hedge_plans{where} ORDER BY id DESC LIMIT ?", + [*args, int(limit)], + ).fetchall() + return [dict(r) for r in rows] + + +def get_plan(conn: sqlite3.Connection, plan_id: int) -> Optional[dict[str, Any]]: + row = conn.execute("SELECT * FROM hedge_plans WHERE id=?", (plan_id,)).fetchone() + return dict(row) if row else None + + +def get_plan_legs(conn: sqlite3.Connection, plan_id: int) -> list[dict[str, Any]]: + rows = conn.execute( + "SELECT * FROM hedge_plan_legs WHERE plan_id=? ORDER BY id", (plan_id,) + ).fetchall() + return [dict(r) for r in rows] + + +def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]: + """删除已结束/失败/取消的计划及其腿;活跃计划拒绝删除.""" + plan = get_plan(conn, int(plan_id)) + if not plan: + return {"ok": False, "msg": "计划不存在"} + st = str(plan.get("status") or "") + if st in ("opening", "active", "partial", "watching"): + return {"ok": False, "msg": "进行中的计划不可删除,请先结束"} + conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),)) + conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),)) + return {"ok": True, "deleted_id": int(plan_id)} + + +def legs_contract_summary(legs: list[dict[str, Any]]) -> str: + parts: list[str] = [] + for leg in legs: + role = str(leg.get("leg_role") or "") + st = str(leg.get("status") or "").strip().lower() + if st == "pending": + suffix = "(待补)" + elif st in ("cancelled", "canceled"): + suffix = "(未成交)" + else: + suffix = "" + if role == "perp": + name = str(leg.get("symbol") or "永续") + parts.append(f"永续 {name}{suffix}") + else: + inst = str(leg.get("inst_id") or "") + ot = str(leg.get("opt_type") or "").upper() + strike = leg.get("strike") + label = inst or (f"{ot}{strike}" if ot or strike is not None else role) + parts.append(f"{label}{suffix}") + return " · ".join(parts) if parts else "—" + + +def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for p in plans: + legs = get_plan_legs(conn, int(p["id"])) + row = dict(p) + row["legs"] = legs + summary = legs_contract_summary(legs) + if str(p.get("status") or "") == "watching" and (not legs or summary == "—"): + money = str(p.get("option_moneyness") or "otm") + money_lab = {"itm": "实/平", "atm": "平值", "otm": "虚值"}.get(money, money) + parts = [f"盯盘·{money_lab}"] + try: + if p.get("strike_interval") not in (None, ""): + parts.append(f"间隔{float(p.get('strike_interval')):g}") + except (TypeError, ValueError): + pass + try: + if p.get("option_leverage") not in (None, ""): + parts.append(f"杠杆≥{float(p.get('option_leverage')):g}") + except (TypeError, ValueError): + pass + summary = "·".join(parts) + row["contracts_summary"] = summary + row["missing_leg"] = missing_leg_role(legs) + out.append(row) + return out + + +def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: + """返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。 + + 这些目标由 hedge_plan_monitor_lib 执行,绝不能写入 options_target_monitors, + 否则两套监控会同时尝试平掉同一条期权腿。 + """ + rows = conn.execute( + """ + SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down, + p.profit_rr, l.inst_id, l.opt_type + FROM hedge_plans p + JOIN hedge_plan_legs l ON l.plan_id = p.id + WHERE p.plan_type = 'options_options' + AND p.status IN ('opening', 'active', 'partial') + AND l.status = 'open' + AND l.inst_id IS NOT NULL + AND l.inst_id != '' + ORDER BY p.id DESC, l.id DESC + """ + ).fetchall() + out: dict[str, dict[str, Any]] = {} + for raw in rows: + row = dict(raw) + inst_id = str(row.get("inst_id") or "") + opt_type = str(row.get("opt_type") or "").upper() + if not inst_id or inst_id in out: + continue + profit_rr = _sf(row.get("profit_rr")) + if profit_rr is not None and profit_rr > 0: + out[inst_id] = { + "plan_id": int(row["plan_id"]), + "inst_id": inst_id, + "underlying": row.get("underlying"), + "opt_type": opt_type, + "profit_rr": profit_rr, + "target_index": None, + "exit_mode": "profit_rr", + "managed_by": "hedge_plan", + } + continue + target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down") + target_f = _sf(target) + if target_f is None or target_f <= 0: + continue + out[inst_id] = { + "plan_id": int(row["plan_id"]), + "inst_id": inst_id, + "underlying": row.get("underlying"), + "opt_type": opt_type, + "target_index": target_f, + "plan_type": "options_options", + "managed_by": "hedge_plan", + } + return out + + +def active_hedge_option_inst_ids(conn: sqlite3.Connection) -> set[str]: + """进行中对冲计划托管的期权合约,禁止单独期权页 close/target 拆组.""" + rows = conn.execute( + """ + SELECT DISTINCT l.inst_id + FROM hedge_plan_legs l + JOIN hedge_plans p ON p.id = l.plan_id + WHERE p.status IN ('opening', 'active', 'partial') + AND l.status IN ('open', 'hold_to_expiry') + AND l.inst_id IS NOT NULL + AND TRIM(l.inst_id) != '' + AND ( + l.leg_role LIKE 'option%' + OR (l.opt_type IS NOT NULL AND TRIM(l.opt_type) != '') + ) + """ + ).fetchall() + return {str(r[0]).strip() for r in rows if r and r[0]} + + +def _sf(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _metrics_from_pnls(rows: list[dict[str, Any]]) -> dict[str, Any]: + """对一组已结束计划计算胜率/盈亏比/最大盈亏/最大回撤.""" + pnls: list[float] = [] + timed: list[tuple[str, float]] = [] + for r in rows: + pnl = _sf(r.get("realized_pnl_total")) + if pnl is None: + continue + pnls.append(pnl) + t = str(r.get("closed_at") or r.get("opened_at") or r.get("created_at") or "") + timed.append((t, pnl)) + n = len(pnls) + if n == 0: + return { + "count": 0, + "wins": 0, + "losses": 0, + "win_rate": None, + "net_pnl": 0.0, + "avg_pnl": None, + "avg_premium": None, + "profit_factor": None, + "max_profit": None, + "max_loss": None, + "max_drawdown": None, + } + wins = [x for x in pnls if x > 0] + losses = [x for x in pnls if x < 0] + gross_win = sum(wins) + gross_loss = abs(sum(losses)) + if gross_loss > 0: + profit_factor = round(gross_win / gross_loss, 4) + elif gross_win > 0: + profit_factor = None # 全胜,标无限 + else: + profit_factor = 0.0 + + timed.sort(key=lambda x: x[0] or "") + cum = 0.0 + peak = 0.0 + mdd = 0.0 + for _, p in timed: + cum += p + if cum > peak: + peak = cum + dd = peak - cum + if dd > mdd: + mdd = dd + + premiums = [_sf(r.get("premium_total")) for r in rows] + premiums_f = [x for x in premiums if x is not None] + return { + "count": n, + "wins": len(wins), + "losses": len(losses), + "win_rate": round(len(wins) / n, 4), + "net_pnl": round(sum(pnls), 4), + "avg_pnl": round(sum(pnls) / n, 4), + "avg_premium": round(sum(premiums_f) / len(premiums_f), 4) if premiums_f else None, + "profit_factor": profit_factor, + "profit_factor_infinite": bool(gross_loss <= 0 and gross_win > 0), + "max_profit": round(max(pnls), 4), + "max_loss": round(min(pnls), 4), + "max_drawdown": round(mdd, 4), + } + + +def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]: + reason_rows = conn.execute( + """ + SELECT plan_type, close_reason, COUNT(1) AS n, + COALESCE(SUM(realized_pnl_total), 0) AS pnl + FROM hedge_plans + WHERE status='closed' + GROUP BY plan_type, close_reason + """ + ).fetchall() + closed_rows = [ + dict(r) + for r in conn.execute( + "SELECT * FROM hedge_plans WHERE status='closed' ORDER BY COALESCE(closed_at, opened_at, created_at), id" + ).fetchall() + ] + active = count_active_plans(conn) + overall = _metrics_from_pnls(closed_rows) + by_type = { + "perp_options": _metrics_from_pnls( + [r for r in closed_rows if r.get("plan_type") == "perp_options"] + ), + "options_options": _metrics_from_pnls( + [r for r in closed_rows if r.get("plan_type") == "options_options"] + ), + } + # 永期止盈/止损分桶 + po = [r for r in closed_rows if r.get("plan_type") == "perp_options"] + by_type["perp_options"]["buckets"] = { + "tp": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_tp"]), + "sl": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_sl"]), + } + oo = [r for r in closed_rows if r.get("plan_type") == "options_options"] + by_type["options_options"]["buckets"] = { + "expiry_loss": _metrics_from_pnls( + [r for r in oo if r.get("close_reason") == "oo_expiry_loss"] + ), + "expiry_win": _metrics_from_pnls( + [r for r in oo if r.get("close_reason") == "oo_expiry_win"] + ), + } + return { + "active": active, + "closed_count": overall["count"], + "closed_pnl_total": overall["net_pnl"], + "overall": overall, + "by_type": by_type, + "by_reason": [dict(r) for r in reason_rows], + } diff --git a/lib/hedge_plan/hedge_plan_moneyness_lib.py b/lib/hedge_plan/hedge_plan_moneyness_lib.py new file mode 100644 index 0000000..94856b9 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_moneyness_lib.py @@ -0,0 +1,285 @@ +"""对冲计划虚实值选约与校验. + +永期(perp_options):期权腿仅允许实值或平值(禁虚值). +期期(options_options):两腿仅允许平值或虚值(禁实值). +""" +from __future__ import annotations + +import os +from typing import Any, Optional + + +def _env_float(name: str, default: float) -> float: + try: + return float(os.getenv(name) or default) + except (TypeError, ValueError): + return float(default) + + +def itm_max_dist_usd() -> float: + """过深实值上限(USD).优先对冲专用,否则回退期权页.""" + raw = (os.getenv("HEDGE_PLAN_ITM_MAX_DIST_USD") or "").strip() + if raw: + try: + return max(0.0, float(raw)) + except ValueError: + pass + return max(0.0, _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0)) + + +def min_option_hours() -> float: + return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_HOURS", 8.0)) + + +def min_option_leverage() -> float: + """指数/卖一 最低杠杆门槛;0=不启用.""" + return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_LEVERAGE", 0.0)) + + +def _sf(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def normalize_opt_type(opt_type: Any, inst_id: str = "") -> str: + o = str(opt_type or "").strip().upper() + if o in ("C", "CALL"): + return "C" + if o in ("P", "PUT"): + return "P" + inst = str(inst_id or "").upper() + if inst.endswith("-C") or inst.endswith("-CALL"): + return "C" + if inst.endswith("-P") or inst.endswith("-PUT"): + return "P" + return "" + + +def classify_moneyness(*, opt_type: str, strike: float, index_px: float) -> str: + """itm / atm / otm / unknown.与 options_pricing_lib.option_moneyness 同口径.""" + from lib.options.options_pricing_lib import option_moneyness + + return option_moneyness(opt_type=opt_type, strike=strike, index_px=index_px) + + +def is_itm_or_atm(*, opt_type: str, strike: float, index_px: float) -> bool: + """Call: K<=S(+atm 带);Put: K>=S.用 classify 结果含 atm/itm.""" + m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px) + if m in ("itm", "atm"): + return True + # 几何兜底(与 eth_hedge_sim 一致),避免 atm 带边界漏判 + o = normalize_opt_type(opt_type) + k = float(strike) + s = float(index_px) + if o == "C": + return k <= s + 1e-9 + if o == "P": + return k >= s - 1e-9 + return False + + +def is_atm_or_otm(*, opt_type: str, strike: float, index_px: float) -> bool: + m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px) + if m in ("atm", "otm"): + return True + o = normalize_opt_type(opt_type) + k = float(strike) + s = float(index_px) + if o == "C": + return k >= s - 1e-9 # 平值带内或虚值 + if o == "P": + return k <= s + 1e-9 + return False + + +def itm_depth_usd(*, opt_type: str, strike: float, index_px: float) -> float: + o = normalize_opt_type(opt_type) + k = float(strike) + s = float(index_px) + if o == "C" and k < s: + return s - k + if o == "P" and k > s: + return k - s + return 0.0 + + +def parse_strike_from_inst(inst_id: str) -> Optional[float]: + """从 OKX 合约名解析行权价: ETH-USD-260731-1800-P.""" + parts = str(inst_id or "").strip().upper().split("-") + if len(parts) < 5: + return None + return _sf(parts[-2]) + + +def pick_itm_or_atm_contract( + contracts: list[dict[str, Any]], + *, + opt_type: str, + index_px: float, + itm_max_dist: Optional[float] = None, +) -> Optional[dict[str, Any]]: + """在合约列表中选距标的最近的实值/平值腿.""" + want = normalize_opt_type(opt_type) + if not want or index_px <= 0: + return None + max_dist = itm_max_dist if itm_max_dist is not None else itm_max_dist_usd() + cands: list[tuple[float, float, dict[str, Any]]] = [] + for c in contracts or []: + if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want: + continue + k = _sf(c.get("strike")) + if k is None: + continue + if not is_itm_or_atm(opt_type=want, strike=k, index_px=index_px): + continue + depth = itm_depth_usd(opt_type=want, strike=k, index_px=index_px) + if max_dist > 0 and depth > max_dist: + continue + cands.append((abs(k - index_px), k, c)) + if not cands: + return None + cands.sort(key=lambda x: (x[0], x[1])) + return cands[0][2] + + +def pick_atm_or_otm_contract( + contracts: list[dict[str, Any]], + *, + opt_type: str, + index_px: float, + prefer: str = "atm", +) -> Optional[dict[str, Any]]: + """选平值或虚值腿.prefer=atm 取距标的最近;prefer=otm 取最近虚值(不含实值).""" + want = normalize_opt_type(opt_type) + if not want or index_px <= 0: + return None + prefer_l = (prefer or "atm").strip().lower() + cands: list[tuple[float, float, dict[str, Any]]] = [] + for c in contracts or []: + if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want: + continue + k = _sf(c.get("strike")) + if k is None: + continue + if not is_atm_or_otm(opt_type=want, strike=k, index_px=index_px): + continue + m = classify_moneyness(opt_type=want, strike=k, index_px=index_px) + if prefer_l == "otm" and m != "otm": + continue + if prefer_l == "atm" and m == "otm": + # 仍可入选,但排序靠后(先 atm) + cands.append((1_000_000 + abs(k - index_px), k, c)) + else: + cands.append((abs(k - index_px), k, c)) + if not cands: + return None + cands.sort(key=lambda x: (x[0], x[1])) + return cands[0][2] + + +def recommend_oo_legs( + contracts: list[dict[str, Any]], + *, + index_px: float, + template: str = "atm_straddle", +) -> Optional[tuple[dict[str, Any], dict[str, Any]]]: + """期期推荐两腿.atm_straddle=最近平值 Call+Put;double_otm=最近虚值 Call+Put.""" + tpl = (template or "atm_straddle").strip().lower() + prefer = "otm" if tpl in ("double_otm", "otm_otm", "otm") else "atm" + call = pick_atm_or_otm_contract( + contracts, opt_type="C", index_px=index_px, prefer=prefer + ) + put = pick_atm_or_otm_contract( + contracts, opt_type="P", index_px=index_px, prefer=prefer + ) + if not call or not put: + return None + if str(call.get("inst_id") or "") == str(put.get("inst_id") or ""): + return None + return call, put + + +def validate_po_option_moneyness( + *, + opt_type: str, + strike: Any, + index_px: Any, + ask: Any = None, + hours_to_expiry: Any = None, +) -> Optional[str]: + """永期保险腿校验;返回错误文案或 None.""" + o = normalize_opt_type(opt_type) + k = _sf(strike) + s = _sf(index_px) + if o not in ("C", "P"): + return "期权类型无效" + if k is None or s is None or s <= 0: + return "行权价或指数无效,无法校验虚实值" + if not is_itm_or_atm(opt_type=o, strike=k, index_px=s): + return "永期保险腿须为实值或平值,不可选虚值" + max_dist = itm_max_dist_usd() + depth = itm_depth_usd(opt_type=o, strike=k, index_px=s) + if max_dist > 0 and depth > max_dist: + return f"实值过深(距现价 {depth:.1f}U > {max_dist:.0f}U),请换更接近平值的档" + min_h = min_option_hours() + h = _sf(hours_to_expiry) + if min_h > 0 and h is not None and h < min_h: + return f"剩余到期约 {h:.1f}h,低于最低 {min_h:.0f}h" + min_lev = min_option_leverage() + a = _sf(ask) + if min_lev > 0 and a is not None and a > 0: + lev = s / a + if lev < min_lev: + return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}" + return None + + +def validate_oo_leg_moneyness( + *, + opt_type: str, + strike: Any, + index_px: Any, + role: str = "腿", +) -> Optional[str]: + o = normalize_opt_type(opt_type) + k = _sf(strike) + s = _sf(index_px) + if o not in ("C", "P"): + return f"{role}期权类型无效" + if k is None or s is None or s <= 0: + return f"{role}行权价或指数无效,无法校验虚实值" + m = classify_moneyness(opt_type=o, strike=k, index_px=s) + if m == "itm": + return f"{role}须为平值或虚值,不可选实值" + if not is_atm_or_otm(opt_type=o, strike=k, index_px=s): + return f"{role}须为平值或虚值" + return None + + +def validate_oo_legs_moneyness( + leg_a: dict[str, Any], + leg_b: dict[str, Any], + *, + index_px: Any, +) -> Optional[str]: + err = validate_oo_leg_moneyness( + opt_type=leg_a.get("opt_type"), + strike=leg_a.get("strike"), + index_px=index_px, + role="腿A", + ) + if err: + return err + err = validate_oo_leg_moneyness( + opt_type=leg_b.get("opt_type"), + strike=leg_b.get("strike"), + index_px=index_px, + role="腿B", + ) + if err: + return err + return None diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py new file mode 100644 index 0000000..9dddff5 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -0,0 +1,1439 @@ +"""对冲计划监控:永期 TP/SL、期期目标价、到期结算与微信收口推送.""" +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any, Optional + +from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, list_plans, update_plan +from lib.hedge_plan.hedge_plan_notify_lib import notify_hedge, notify_plan_end, build_hedge_alert_message +from lib.hedge_plan.hedge_plan_orders_lib import _sell_option +from lib.hedge_plan.hedge_plan_settle_lib import ( + leg_is_expired, + resolve_option_leg_realized_pnl, + settle_option_leg_at_spot, +) + + +def _now() -> str: + return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _sf(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _perp_live_contracts(cfg: dict[str, Any], symbol: str, direction: str) -> Optional[float]: + fn = cfg.get("get_live_position_contracts") + if not callable(fn): + return None + try: + return fn(symbol, direction) + except Exception: + return None + + +def _index_px(cfg: dict[str, Any], underlying: str) -> Optional[float]: + ex = cfg.get("exchange_options") + fn = cfg.get("fetch_index_price") + if callable(fn) and ex is not None: + try: + return fn(ex, underlying) + except Exception: + return None + # 无期权账户时回退永续 ticker + ex_perp = cfg.get("exchange") + if ex_perp is not None: + try: + base = (underlying or "ETH").upper() + sym = f"{base}/USDT:USDT" + t = ex_perp.fetch_ticker(sym) + return _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) + except Exception: + return None + return None + + +def _plan_open_grace_sec() -> float: + try: + return max(0.0, float(os.getenv("HEDGE_PLAN_OPEN_GRACE_SEC") or "90")) + except (TypeError, ValueError): + return 90.0 + + +def _within_open_grace(plan: dict[str, Any]) -> bool: + """开仓后宽限期:仓位尚未同步到交易所时禁止按「已平」收口.""" + grace = _plan_open_grace_sec() + if grace <= 0: + return False + raw = str(plan.get("opened_at") or plan.get("created_at") or "").strip() + if not raw: + return True + try: + # "YYYY-MM-DD HH:MM:SS" 本地墙钟 + opened = datetime.strptime(raw[:19], "%Y-%m-%d %H:%M:%S") + age = (datetime.now() - opened).total_seconds() + return age < grace + except Exception: + return True + + +def _classify_po_flat_reason( + *, + direction: str, + entry: float, + mark: Optional[float], + tp: Optional[float], + sl: Optional[float], +) -> str: + """永续已平时分类 TP/SL.歧义时偏 SL(触发强平期权),避免误判 TP 跳过强平.""" + d = (direction or "long").lower() + if mark is None or not entry: + return "perp_flat_unknown" + if sl is not None: + if d == "long" and mark <= sl: + return "perp_sl" + if d == "short" and mark >= sl: + return "perp_sl" + if tp is not None: + if d == "long" and mark >= tp: + return "perp_tp" + if d == "short" and mark <= tp: + return "perp_tp" + if sl is not None and tp is not None: + return "perp_sl" if abs(mark - sl) <= abs(mark - tp) else "perp_tp" + if sl is not None: + return "perp_sl" + if tp is not None: + return "perp_tp" + return "perp_flat_unknown" + + +def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]: + """扫描 active/partial 计划 + 止盈后遗留期权到期收口.返回处理摘要.""" + get_db = cfg.get("get_db") + if not callable(get_db): + return {"ok": False, "msg": "get_db missing"} + conn = get_db() + acted: list[dict[str, Any]] = [] + backfill_stats: dict[str, int] = {} + try: + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables + + init_hedge_plan_tables(conn) + plans = list_plans(conn, status="watching", limit=20) + plans.extend(list_plans(conn, status="active", limit=40)) + # partial:裸永续/半腿也需侦测永续 TP/SL + plans.extend(list_plans(conn, status="partial", limit=20)) + seen: set[int] = set() + for plan in plans: + pid = int(plan.get("id") or 0) + if pid in seen: + continue + seen.add(pid) + r = _tick_one(cfg, conn, plan) + if r: + acted.append(r) + orphaned = _settle_orphaned_after_tp(cfg, conn) + acted.extend(orphaned) + try: + ex = cfg.get("exchange_options") + if ex is not None: + from lib.exchange.okx_options_lib import fetch_all_option_positions_history + from lib.hedge_plan.hedge_plan_settle_lib import ( + backfill_hedge_option_legs_realized_pnl, + ) + + hist = fetch_all_option_positions_history(ex, limit=200) + backfill_stats = backfill_hedge_option_legs_realized_pnl(conn, hist) + except Exception: + pass + conn.commit() + finally: + conn.close() + return {"ok": True, "acted": acted, "pnl_backfill": backfill_stats} + + +def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None: + plan = get_plan(conn, int(plan_id)) + if plan: + notify_plan_end(cfg, conn, plan) + + +def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str: + """盈利腿平后另一腿:close_all(残值平) / hold_expiry(到期平). + + - 方案C关闭 → 强制到期平 + - 计划未写 oo_close_mode(旧单) → 到期平,避免误清残腿 + - 新开仓默认写入 close_all(残值平:权利金≤初始20%且有买一) + """ + if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True): + return "hold_expiry" + raw = plan.get("oo_close_mode") + if raw is None or str(raw).strip() == "": + return "hold_expiry" + v = str(raw).strip().lower() + if v in ("hold_expiry", "hold_to_expiry", "expiry", "到期平"): + return "hold_expiry" + return "close_all" + + +# 期期亏损腿残值平:当前买一回收 ≤ 本合约初始权利金 × 该比例 +OO_LOSS_LEG_RESIDUAL_RATIO = 0.20 +# 期期默认盈亏比:盈利金额 / 总权利金 +OO_DEFAULT_PROFIT_RR = 2.0 + + +def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) -> list[dict[str, Any]]: + out = [] + for x in legs: + if not str(x.get("leg_role") or "").startswith("option"): + continue + if str(x.get("status") or "") in statuses: + out.append(x) + return out + + +def _oo_quote_bid(cfg: dict[str, Any], inst_id: str) -> tuple[Optional[float], Optional[float]]: + quote_fn = cfg.get("quote_option_contract") + ex_opt = cfg.get("exchange_options") + if not callable(quote_fn) or ex_opt is None or not inst_id: + return None, None + try: + q = quote_fn(ex_opt, inst_id) + if not q.get("ok"): + return None, None + return _sf(q.get("bid")), _sf(q.get("bid_sz")) + except Exception: + return None, None + + +def _oo_leg_mark_value(leg: dict[str, Any], bid: Optional[float]) -> Optional[float]: + """买一可回收金额(USDC)= bid × 张数 × ct_mult.""" + b = _sf(bid) + if b is None or b < 0: + return None + sheets = float(leg.get("size") or 1) + ct = float(leg.get("ct_mult") or 0.01) + return float(b) * sheets * ct + + +def _oo_plan_premium_total(plan: dict[str, Any], legs: list[dict[str, Any]]) -> float: + """双腿总权利金:优先计划字段,否则对期权腿 premium 求和.""" + total = _sf(plan.get("premium_total")) + if total is not None and total > 0: + return float(total) + s = 0.0 + for leg in legs: + if not str(leg.get("leg_role") or "").startswith("option"): + continue + s += float(leg.get("premium") or 0) + return s + + +def _oo_leg_profit_rr( + leg: dict[str, Any], bid: Optional[float], *, total_premium: float +) -> Optional[float]: + """盈亏比 = 该腿盈利金额 / 总权利金;盈利金额 = 买一回收 − 本腿权利金.""" + if total_premium <= 0: + return None + leg_prem = float(leg.get("premium") or 0) + value = _oo_leg_mark_value(leg, bid) + if value is None: + return None + return (value - leg_prem) / total_premium + + +def _oo_resolve_profit_rr(plan: dict[str, Any]) -> Optional[float]: + rr = _sf(plan.get("profit_rr")) + if rr is not None and rr > 0: + return rr + return None + + +def _finalize_oo_all_closed( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str +) -> dict[str, Any]: + closed_opts = _oo_option_legs(legs, statuses=("closed",)) + total_opts = sum(float(x.get("realized_pnl") or 0) for x in closed_opts) + close_reason = reason or "oo_rest_closed" + bucket = "oo_target" if total_opts > 0 else "oo_expiry_loss" + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason=close_reason, + realized_pnl_options=round(total_opts, 4), + realized_pnl_total=round(total_opts, 4), + stats_bucket=bucket, + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": close_reason, "total": total_opts} + + +def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[dict[str, Any]]: + pt = plan.get("plan_type") + legs = get_plan_legs(conn, int(plan["id"])) + if pt == "perp_options": + from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary + + if is_option_primary(plan): + if str(plan.get("status") or "") == "watching": + return _tick_po_option_primary_watching(cfg, conn, plan) + # 期权为主:半平重试 → 到期 → 目标位分叉 + r = _tick_po_option_primary_pending(cfg, conn, plan, legs) + if r: + return r + r = _tick_po_option_primary_expiry(cfg, conn, plan, legs) + if r: + return r + r = _tick_po_option_primary_both_expired(cfg, conn, plan, legs) + if r: + return r + return _tick_po_option_primary(cfg, conn, plan, legs) + r = _tick_po(cfg, conn, plan, legs) + return r + if pt == "options_options": + r = _tick_oo_expiry(cfg, conn, plan, legs) + if r: + return r + r = _tick_oo_close_rest(cfg, conn, plan, legs) + if r: + return r + return _tick_oo_target(cfg, conn, plan, legs) + return None + + +def _tick_po_option_primary_watching( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any] +) -> Optional[dict[str, Any]]: + """盯盘:链上出现杠杆/间隔达标合约后自动开仓.""" + import json + import os + + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + pick_option_primary_candidate, + size_from_premium, + ) + from lib.hedge_plan.hedge_plan_orders_lib import execute_perp_options_start + from lib.hedge_plan.hedge_plan_register import _activate_watching_po + + build_chain = cfg.get("build_option_chain") + ex = cfg.get("exchange_options") + if not callable(build_chain) or ex is None: + return None + body0: dict[str, Any] = {} + try: + raw = plan.get("preview_json") or "" + blob = json.loads(raw) if raw else {} + body0 = dict(blob.get("start_body") or blob or {}) + except Exception: + body0 = {} + uly = str(plan.get("underlying") or body0.get("underlying") or "ETH").upper() + direction = str(plan.get("direction") or body0.get("direction") or "long").lower() + money = str(plan.get("option_moneyness") or body0.get("moneyness") or "otm").lower() + interval = plan.get("strike_interval") + if interval in (None, ""): + interval = body0.get("strike_interval", 15) + min_h = plan.get("min_option_hours") + if min_h in (None, ""): + min_h = body0.get("min_option_hours", 36) + opt_lev = plan.get("option_leverage") + if opt_lev in (None, ""): + opt_lev = body0.get("option_leverage") + try: + chain = build_chain( + ex, + uly, + max_dte_days=float(cfg.get("chain_max_dte") or 14), + itm_only=False, + itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"), + ) + except Exception as e: + update_plan(conn, int(plan["id"]), note=f"盯盘拉链失败: {e}"[:500]) + return None + cand = pick_option_primary_candidate( + chain, + direction=direction, + moneyness=money, + strike_interval=interval, + min_hours=min_h, + min_opt_leverage=opt_lev, + ) + if not cand: + return None + ask = float(cand.get("ask") or 0) + ct = float(cand.get("ct_mult") or body0.get("ct_mult") or 0.01) + sized = size_from_premium( + premium_budget=float(plan.get("premium_budget") or body0.get("premium_budget") or 0), + ask=ask, + ct_mult=ct, + ratio=float(plan.get("option_perp_ratio") or body0.get("option_perp_ratio") or 2), + contract_size=float(body0.get("contract_size") or 0.01), + ) + if not sized.get("ok"): + update_plan(conn, int(plan["id"]), note=f"盯盘定仓失败: {sized.get('msg')}"[:500]) + return None + idx = float(cand.get("index_px") or chain.get("index_px") or 0) + body = dict(body0) + body.update( + { + "plan_type": "perp_options", + "option_primary": True, + "watch_entry": 0, + "underlying": uly, + "direction": direction, + "moneyness": money, + "opt_inst_id": cand.get("inst_id"), + "opt_type": cand.get("opt_type"), + "strike": cand.get("strike"), + "ask": ask, + "ct_mult": ct, + "sheets": sized["sheets"], + "contracts": sized["contracts"], + "eth_qty": sized.get("eth_qty"), + "index_px": idx, + "entry": idx, + "hours_to_expiry": cand.get("hours_to_expiry"), + "strike_interval": interval, + "min_option_hours": min_h, + "option_leverage": opt_lev, + "option_perp_ratio": plan.get("option_perp_ratio") or body0.get("option_perp_ratio"), + "option_target_points": plan.get("option_target_points") or body0.get("option_target_points"), + "perp_target_points": plan.get("perp_target_points") or body0.get("perp_target_points"), + "premium_budget": plan.get("premium_budget") or body0.get("premium_budget"), + "leverage": plan.get("leverage") or body0.get("leverage") or 100, + "exchange_symbol": body0.get("exchange_symbol") or f"{uly}-USDT-SWAP", + "contract_size": body0.get("contract_size") or 0.01, + } + ) + dry = str(os.getenv("HEDGE_PLAN_DRY_RUN") or "").strip().lower() in ("1", "true", "yes", "on") + out = execute_perp_options_start(cfg, body, dry_run=dry, persist=None) + if not out.get("ok"): + update_plan(conn, int(plan["id"]), note=f"盯盘开仓未成: {out.get('msg')}"[:500]) + return {"plan_id": plan["id"], "watching_open": False, "msg": out.get("msg")} + if dry: + update_plan(conn, int(plan["id"]), note=f"dry_run命中 {cand.get('inst_id')}"[:500]) + return {"plan_id": plan["id"], "watching_open": True, "dry_run": True, "inst_id": cand.get("inst_id")} + _activate_watching_po(cfg, conn, int(plan["id"]), out, body) + return { + "plan_id": plan["id"], + "watching_open": True, + "inst_id": cand.get("inst_id"), + "leverage": cand.get("leverage"), + } + + +def _tick_po_option_primary_pending( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """期权已平、永续待平(opt_target_perp_pending)时只重试平永续.""" + from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view + from lib.hedge_plan.hedge_plan_orders_lib import _close_perp + + pending = str(plan.get("close_reason") or "") + if pending not in ("opt_target_perp_pending", "opt_target_pending"): + return None + perp = next((x for x in legs if x.get("leg_role") == "perp"), None) + opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) + if not perp or str(perp.get("status") or "") != "open": + return None + view = str(plan.get("direction") or "long").lower() + perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower() + symbol = str(perp.get("symbol") or "") + contracts = float(perp.get("size") or plan.get("perp_size") or 0) + + # 期权仍 open:继续走主路径,不在此强平 + if pending == "opt_target_pending" and opt and str(opt.get("status") or "") == "open": + return None + + # 期权已平或 already flat:只补平永续 + if opt and str(opt.get("status") or "") == "open": + return None + + perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False) + if not perp_close.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期权已平·永续平仓重试失败", + plan_id=plan.get("id"), + detail=str(perp_close.get("msg") or perp_close), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="opt_target_perp_pending") + return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} + + entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0 + mark = entry + ex = cfg.get("exchange") + if ex is not None and symbol: + try: + t = ex.fetch_ticker(symbol) + mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) or entry + except Exception: + pass + cs = float(cfg.get("default_contract_size") or 0.01) + get_cs = cfg.get("get_contract_size") + if callable(get_cs) and symbol: + try: + cs = float(get_cs(symbol) or cs) + except Exception: + pass + coins = contracts * cs + if perp_dir == "short": + perp_pnl = (float(entry or 0) - float(mark or 0)) * coins + else: + perp_pnl = (float(mark or 0) - float(entry or 0)) * coins + opt_pnl = float(opt.get("realized_pnl") or 0) if opt else float(plan.get("realized_pnl_options") or 0) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "opt_target_points", _now(), round(perp_pnl, 4), perp["id"]), + ) + total = opt_pnl + perp_pnl + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason="opt_target_points", + realized_pnl_perp=round(perp_pnl, 4), + realized_pnl_options=round(opt_pnl, 4), + realized_pnl_total=round(total, 4), + stats_bucket="opt_primary", + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": "opt_target_points", "total": total, "recovered": True} + + +def _tick_po_option_primary_both_expired( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """两腿仍 open 但期权已到期:结算期权并市价平永续,避免裸奔.""" + from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view + from lib.hedge_plan.hedge_plan_orders_lib import _close_perp + + perp = next((x for x in legs if x.get("leg_role") == "perp"), None) + opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) + if not perp or str(perp.get("status") or "") != "open": + return None + if not opt or str(opt.get("status") or "") != "open": + return None + if not leg_is_expired(opt): + return None + spot = _index_px(cfg, str(plan.get("underlying") or "ETH")) + if spot is None: + return None + est = settle_option_leg_at_spot(opt, float(spot)) + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "expiry", _now(), round(opt_pnl, 4), opt["id"]), + ) + view = str(plan.get("direction") or "long").lower() + perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower() + symbol = str(perp.get("symbol") or "") + contracts = float(perp.get("size") or plan.get("perp_size") or 0) + perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False) + entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or float(spot) + cs = float(cfg.get("default_contract_size") or 0.01) + get_cs = cfg.get("get_contract_size") + if callable(get_cs) and symbol: + try: + cs = float(get_cs(symbol) or cs) + except Exception: + pass + coins = contracts * cs + if perp_dir == "short": + perp_pnl = (float(entry) - float(spot)) * coins + else: + perp_pnl = (float(spot) - float(entry)) * coins + if not perp_close.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期权到期后永续平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(perp_close.get("msg") or perp_close), + ), + ) + update_plan( + conn, + int(plan["id"]), + close_reason="opt_target_perp_pending", + realized_pnl_options=round(opt_pnl, 4), + note="期权已到期结算,永续待平", + ) + return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "option_expired", _now(), round(perp_pnl, 4), perp["id"]), + ) + total = opt_pnl + perp_pnl + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason="option_expired", + realized_pnl_perp=round(perp_pnl, 4), + realized_pnl_options=round(opt_pnl, 4), + realized_pnl_total=round(total, 4), + stats_bucket="opt_primary", + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": "option_expired", "total": total} + + +def _tick_po_option_primary_expiry( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """期权为主且永续已平、期权 hold_to_expiry → 到期结算后收口计划.""" + perp = next((x for x in legs if x.get("leg_role") == "perp"), None) + opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) + if not opt or str(opt.get("status") or "") != "hold_to_expiry": + return None + if perp and str(perp.get("status") or "") == "open": + return None + if not leg_is_expired(opt): + return None + spot = _index_px(cfg, str(plan.get("underlying") or "ETH")) + if spot is None: + return None + est = settle_option_leg_at_spot(opt, float(spot)) + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "expiry", _now(), round(opt_pnl, 4), opt["id"]), + ) + perp_pnl = float(perp.get("realized_pnl") or 0) if perp else float(plan.get("realized_pnl_perp") or 0) + total = perp_pnl + opt_pnl + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason="perp_target_points_expiry", + realized_pnl_perp=round(perp_pnl, 4), + realized_pnl_options=round(opt_pnl, 4), + realized_pnl_total=round(total, 4), + stats_bucket="opt_primary", + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": "perp_target_points_expiry", "total": total} + + +def _tick_po_option_primary( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """以期权为主:触达目标位立即执行分叉平仓规则.""" + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + estimate_combo_net_pnl, + option_bid_liquidity_ok, + perp_direction_for_view, + target_hit, + ) + from lib.hedge_plan.hedge_plan_orders_lib import _close_perp + + perp = next((x for x in legs if x.get("leg_role") == "perp"), None) + opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) + if not perp or str(perp.get("status") or "") != "open": + return None + if not opt or str(opt.get("status") or "") != "open": + return None + if _within_open_grace(plan): + return None + + view = str(plan.get("direction") or "long").lower() + perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower() + strike = _sf(opt.get("strike")) + n = _sf(plan.get("option_target_points")) + m = _sf(plan.get("perp_target_points")) + if strike is None or strike <= 0: + return None + idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) + if idx is None: + return None + + hit_opt = bool(n is not None and target_hit(view_side=view, index_px=idx, strike=strike, points=float(n))) + hit_perp = bool(m is not None and target_hit(view_side=view, index_px=idx, strike=strike, points=float(m))) + if not hit_opt and not hit_perp: + return None + + symbol = str(perp.get("symbol") or "") + mark = None + ex = cfg.get("exchange") + if ex is not None and symbol: + try: + t = ex.fetch_ticker(symbol) + mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) + except Exception: + mark = None + mark = mark or idx + entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or mark + cs = float(cfg.get("default_contract_size") or 0.01) + get_cs = cfg.get("get_contract_size") + if callable(get_cs) and symbol: + try: + cs = float(get_cs(symbol) or cs) + except Exception: + pass + + quote_fn = cfg.get("quote_option_contract") + ex_opt = cfg.get("exchange_options") + bid = None + bid_sz = None + if callable(quote_fn) and ex_opt is not None: + try: + q = quote_fn(ex_opt, str(opt.get("inst_id") or "")) + if q.get("ok"): + bid = _sf(q.get("bid")) + bid_sz = _sf(q.get("bid_sz")) + except Exception: + bid = None + + ask_open = _sf(opt.get("avg_open")) or 0.0 + sheets = float(opt.get("size") or 1) + ct = float(opt.get("ct_mult") or 0.01) + contracts = float(perp.get("size") or plan.get("perp_size") or 0) + + # 优先期权目标;买一不足或净利≤0 时若永续目标已触达则改走永续目标 + if hit_opt: + liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets) + net = None + if liq_ok: + net = estimate_combo_net_pnl( + view_side=view, + strike=float(strike), + index_px=float(idx), + ask_open=float(ask_open), + bid=float(bid or 0), + sheets=sheets, + ct_mult=ct, + perp_direction=perp_dir, + perp_entry=float(entry or 0), + perp_mark=float(mark or 0), + contracts=contracts, + contract_size=cs, + ) + can_opt_exit = bool(liq_ok and net is not None and float(net.get("net") or 0) > 0) + if can_opt_exit: + reason = "opt_target_points" + close_r = _sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=sheets) + if close_r.get("already_flat"): + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=net["opt_net"]) + elif not close_r.get("ok") or not close_r.get("fully_closed", True): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期权目标平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(close_r.get("msg") or close_r), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="opt_target_pending") + return {"plan_id": plan["id"], "retry": True, "close": close_r} + else: + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=net["opt_net"]) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), round(opt_pnl, 4), opt["id"]), + ) + perp_close = _close_perp( + cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False + ) + if not perp_close.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期权已平但永续平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(perp_close.get("msg") or perp_close), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="opt_target_perp_pending") + return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} + perp_pnl = float(net.get("perp_net") or 0) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), round(perp_pnl, 4), perp["id"]), + ) + total = float(opt_pnl) + float(perp_pnl) + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason=reason, + realized_pnl_perp=round(perp_pnl, 4), + realized_pnl_options=round(opt_pnl, 4), + realized_pnl_total=round(total, 4), + stats_bucket="opt_primary", + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": reason, "total": total, "net": net} + if not hit_perp: + return { + "plan_id": plan["id"], + "skip": True, + "msg": (liq_msg if not liq_ok else "净利≤0,继续持有"), + "net": net, + } + + if not hit_perp: + return None + + reason = "perp_target_points" + # 永续目标:平永续,期权持有至到期 + perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False) + if not perp_close.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="永续目标平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(perp_close.get("msg") or perp_close), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="perp_target_pending") + return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} + # 估永续已实现 + coins = contracts * cs + if perp_dir == "short": + perp_pnl = (float(entry or 0) - float(mark or 0)) * coins + else: + perp_pnl = (float(mark or 0) - float(entry or 0)) * coins + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), round(perp_pnl, 4), perp["id"]), + ) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?", + ("hold_to_expiry", "hold_expiry_after_perp_target", opt["id"]), + ) + update_plan( + conn, + int(plan["id"]), + # 计划保持 active,等期权到期收口 + close_reason="perp_target_points", + realized_pnl_perp=round(perp_pnl, 4), + note="永续已按目标平仓,期权持有至到期", + ) + notify_hedge( + cfg, + build_hedge_alert_message( + title="永续目标已平·期权持有至到期", + plan_id=plan.get("id"), + detail=f"指数 {idx:.2f} · 永续盈亏约 {perp_pnl:.2f}", + ), + ) + return {"plan_id": plan["id"], "close_reason": reason, "perp_pnl": perp_pnl, "opt_hold": True} + + +def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]) -> Optional[dict[str, Any]]: + perp = next((x for x in legs if x.get("leg_role") == "perp"), None) + opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) + if not perp or perp.get("status") != "open": + return None + symbol = perp.get("symbol") or "" + direction = (plan.get("perp_direction") or plan.get("direction") or "long").lower() + live = _perp_live_contracts(cfg, symbol, direction) + # API 失败 / 未注入 → 本轮跳过,绝不当「已平」 + if live is None: + return None + # 仍有仓 → 未触达交易所 TP/SL + if live > 0: + return None + # 开仓后宽限期:仓位同步延迟可误读为 0 + if _within_open_grace(plan): + return None + + entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0 + tp = _sf(plan.get("tp")) + sl = _sf(plan.get("sl")) + mark = None + ex = cfg.get("exchange") + if ex is not None and symbol: + try: + t = ex.fetch_ticker(symbol) + mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) + except Exception: + mark = None + reason = _classify_po_flat_reason( + direction=direction, entry=float(entry or 0), mark=mark, tp=tp, sl=sl + ) + # 上一轮止损强平未完成:粘滞为 SL,避免 mark 反弹误判 TP 跳过强平 + pending_reason = str(plan.get("close_reason") or "") + if pending_reason == "perp_sl_pending_opt": + reason = "perp_sl" + elif pending_reason == "perp_tp_pending_opt": + reason = "perp_tp" + # 未明确 TP/SL 时不收口,下轮再判 + if reason == "perp_flat_unknown": + return None + + premium = float(plan.get("premium_total") or 0) + cs = float(cfg.get("default_contract_size") or 0.01) + get_cs = cfg.get("get_contract_size") + if callable(get_cs) and symbol: + try: + cs = float(get_cs(symbol) or cs) + except Exception: + pass + size = float(perp.get("size") or 0) + exit_px = mark or (tp if reason == "perp_tp" else sl) or entry + coins = size * cs + if direction == "short": + perp_pnl = (entry - exit_px) * coins + else: + perp_pnl = (exit_px - entry) * coins + + opt_pnl = -premium + if reason == "perp_sl" and opt and str(opt.get("status") or "") == "open": + if _env_bool("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", True): + close_r = _sell_option( + cfg, + inst_id=str(opt.get("inst_id") or ""), + sheets=float(opt.get("size") or 1), + ) + if close_r.get("already_flat"): + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=-premium) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), opt_pnl, opt["id"]), + ) + elif not close_r.get("ok") or not close_r.get("fully_closed", True): + notify_hedge( + cfg, + build_hedge_alert_message( + title="永续止损后期权强制平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(close_r.get("msg") or close_r), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="perp_sl_pending_opt") + return { + "plan_id": plan["id"], + "msg": "止损后期权未平完", + "close": close_r, + "retry": True, + } + else: + bid = _sf(close_r.get("bid") or close_r.get("locked_bid_px")) + ask_open = _sf(opt.get("avg_open")) + if bid is not None and ask_open is not None: + ct = float(opt.get("ct_mult") or 0.01) + est = (bid - ask_open) * float(opt.get("size") or 1) * ct + else: + est = -premium + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), opt_pnl, opt["id"]), + ) + else: + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?", + ("hold_to_expiry", "orphaned_after_sl", opt["id"]), + ) + opt_pnl = -premium + elif reason == "perp_tp" and opt and str(opt.get("status") or "") == "open": + if _env_bool("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", False): + close_r = _sell_option( + cfg, inst_id=str(opt.get("inst_id") or ""), sheets=float(opt.get("size") or 1) + ) + if close_r.get("already_flat"): + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=-premium) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), opt_pnl, opt["id"]), + ) + elif not close_r.get("ok") or not close_r.get("fully_closed", True): + notify_hedge( + cfg, + build_hedge_alert_message( + title="永续止盈后期权平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(close_r.get("msg") or close_r), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="perp_tp_pending_opt") + return { + "plan_id": plan["id"], + "msg": "止盈后期权未平完", + "close": close_r, + "retry": True, + } + else: + bid = _sf(close_r.get("bid") or close_r.get("locked_bid_px")) + ask_open = _sf(opt.get("avg_open")) + if bid is not None and ask_open is not None: + ct = float(opt.get("ct_mult") or 0.01) + est = (bid - ask_open) * float(opt.get("size") or 1) * ct + else: + est = -premium + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), opt_pnl, opt["id"]), + ) + else: + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?", + ("hold_to_expiry", "orphaned_after_tp", opt["id"]), + ) + opt_pnl = -premium + + total = perp_pnl + opt_pnl + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), perp_pnl, perp["id"]), + ) + # partial → closed 也走同一收口 + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason=reason, + realized_pnl_perp=round(perp_pnl, 4), + realized_pnl_options=round(opt_pnl, 4), + realized_pnl_total=round(total, 4), + stats_bucket="tp" if reason == "perp_tp" else "sl", + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": reason, "total": total} + + +def _option_leg_pnl_after_close( + cfg: dict[str, Any], + leg: dict[str, Any], + *, + fallback: float, +) -> float: + """平仓后写腿盈亏:优先交易所历史,否则用估算.""" + ex = cfg.get("exchange_options") + pnl, _src = resolve_option_leg_realized_pnl(ex=ex, leg=leg, fallback=fallback) + return float(pnl if pnl is not None else fallback) + + +def _estimate_leg_close_pnl(leg: dict[str, Any], idx: Optional[float], bid: Optional[float]) -> float: + """残腿平仓盈亏估算:优先买一回收 − 权利金;无买一则用内在价值.""" + premium = float(leg.get("premium") or 0) + sheets = float(leg.get("size") or 1) + ct = float(leg.get("ct_mult") or 0.01) + if bid is not None and float(bid) > 0: + return float(bid) * sheets * ct - premium + if idx is None: + return -premium + strike = _sf(leg.get("strike")) or 0 + o = (leg.get("opt_type") or "").upper() + intrinsic = max(0.0, idx - strike) if o == "C" else max(0.0, strike - idx) + return intrinsic * sheets * ct - premium + + +def _tick_oo_close_rest( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """盈利腿已平后:残值平模式清亏损腿. + + 条件:买一回收 ≤ 本合约初始权利金×20%,且买一有流动性;失败或未达条件则下轮重试. + """ + from lib.hedge_plan.hedge_plan_option_primary_lib import option_bid_liquidity_ok + + if resolve_oo_rest_close_mode(plan) != "close_all": + return None + open_legs = _oo_option_legs(legs, statuses=("open",)) + closed_legs = _oo_option_legs(legs, statuses=("closed",)) + # 至少已平一条,且仍有残腿;避免双腿都还 open 时误清 + if len(closed_legs) < 1 or len(open_legs) < 1: + return None + reason0 = str(plan.get("close_reason") or "") + allowed_reasons = ( + "target_win_leg", + "target_up_win_leg", + "target_down_win_leg", + "profit_rr_win_leg", + "oo_rest_closing", + "", + ) + if reason0 not in allowed_reasons and not ( + len(closed_legs) >= 1 and len(open_legs) == 1 + ): + return None + + idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) + acted = False + waiting = False + for leg in list(open_legs): + inst_id = str(leg.get("inst_id") or "") + sheets = float(leg.get("size") or 1) + premium = float(leg.get("premium") or 0) + bid, bid_sz = _oo_quote_bid(cfg, inst_id) + value = _oo_leg_mark_value(leg, bid) + # 残值门槛:相对本合约初始权利金,买一回收须 ≤ 20% + if premium > 0: + if value is None: + waiting = True + continue + if value > premium * OO_LOSS_LEG_RESIDUAL_RATIO + 1e-12: + waiting = True + continue + liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets) + if not liq_ok: + waiting = True + update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing") + return { + "plan_id": plan["id"], + "msg": "残值平等待买一流动性", + "detail": liq_msg, + "waiting": True, + } + close_r = _sell_option(cfg, inst_id=inst_id, sheets=sheets) + if not close_r.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期期残值平·亏损腿平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(close_r.get("msg") or close_r), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing") + return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True} + bid_fill = _sf(close_r.get("bid")) or bid + est = _estimate_leg_close_pnl(leg, idx, bid_fill) + pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "oo_rest_close", _now(), round(pnl, 4), leg["id"]), + ) + leg["status"] = "closed" + leg["realized_pnl"] = round(pnl, 4) + acted = True + + if not acted: + if waiting: + update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing") + return {"plan_id": plan["id"], "msg": "残值平等待本合约权利金≤20%", "waiting": True} + return None + legs2 = get_plan_legs(conn, int(plan["id"])) + still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry")) + if still_open: + update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing") + return {"plan_id": plan["id"], "msg": "残腿部分已平,继续重试", "remaining": len(still_open)} + return _finalize_oo_all_closed( + cfg, conn, plan, legs2, reason="oo_rest_closed" + ) + + +def _after_oo_winner_closed( + cfg: dict[str, Any], + conn: Any, + plan: dict[str, Any], + open_legs: list[dict[str, Any]], + best: dict[str, Any], + *, + reason: str, + extra: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """盈利腿已平后:残值平同轮尝试 / 到期平标记 hold_to_expiry.""" + rest_mode = resolve_oo_rest_close_mode(plan) + update_plan(conn, int(plan["id"]), close_reason=reason) + mid = dict(plan) + mid["close_reason"] = reason + mid["status"] = "active" + mid["oo_close_mode"] = rest_mode + notify_plan_end(cfg, conn, mid) + + out: dict[str, Any] = { + "plan_id": plan["id"], + "close_reason": reason, + "closed_leg": best.get("id"), + "oo_close_mode": rest_mode, + } + if extra: + out.update(extra) + + if rest_mode == "close_all": + legs2 = get_plan_legs(conn, int(plan["id"])) + rest = _tick_oo_close_rest(cfg, conn, mid, legs2) + if rest: + out["rest"] = rest + return out + + for leg in open_legs: + if int(leg.get("id") or 0) == int(best.get("id") or 0): + continue + conn.execute( + "UPDATE hedge_plan_legs SET status=? WHERE id=?", + ("hold_to_expiry", leg["id"]), + ) + return out + + +def _tick_oo_profit_rr( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, rr_target: float +) -> Optional[dict[str, Any]]: + """期期:任一开仓腿盈亏比(该腿盈利金额/总权利金)达目标 → 平盈利腿.""" + if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True): + return None + open_legs = _oo_option_legs(legs, statuses=("open",)) + if len(open_legs) < 2: + return None + total_prem = _oo_plan_premium_total(plan, legs) + if total_prem <= 0: + return None + + ranked: list[tuple[float, float, dict[str, Any]]] = [] + for leg in open_legs: + bid, _bid_sz = _oo_quote_bid(cfg, str(leg.get("inst_id") or "")) + rr = _oo_leg_profit_rr(leg, bid, total_premium=total_prem) + if rr is None: + continue + value = _oo_leg_mark_value(leg, bid) or 0.0 + premium = float(leg.get("premium") or 0) + pnl = value - premium + ranked.append((rr, pnl, leg)) + if not ranked: + return None + ranked.sort(key=lambda x: x[0], reverse=True) + best_rr, best_pnl, best = ranked[0] + if best_rr + 1e-12 < float(rr_target) or best_pnl <= 0: + return None + + close_r = _sell_option( + cfg, inst_id=str(best.get("inst_id") or ""), sheets=float(best.get("size") or 1) + ) + if not close_r.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期期平盈利腿失败", + plan_id=plan.get("id"), + detail=str(close_r.get("msg") or close_r), + ), + ) + return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r} + + reason = "profit_rr_win_leg" + closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl)) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), closed_pnl, best["id"]), + ) + idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) + return _after_oo_winner_closed( + cfg, + conn, + plan, + open_legs, + best, + reason=reason, + extra={ + "profit_rr": best_rr, + "rr_target": float(rr_target), + "total_premium": total_prem, + "index": idx, + }, + ) + + +def _tick_oo_target( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """期期:优先按盈亏比平盈利腿;旧单无 profit_rr 时回退上/下破目标价.""" + rr_target = _oo_resolve_profit_rr(plan) + if rr_target is not None: + return _tick_oo_profit_rr(cfg, conn, plan, legs, rr_target=rr_target) + + idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) + if idx is None: + return None + up = _sf(plan.get("target_price_up")) + down = _sf(plan.get("target_price_down")) + # 旧计划仅有单目标:两边都用它 + legacy = _sf(plan.get("target_price")) + if up is None and legacy is not None: + up = legacy + if down is None and legacy is not None: + down = legacy + if up is None and down is None: + return None + + hit_side: Optional[str] = None + if up is not None and idx >= up * 0.998: + hit_side = "up" + elif down is not None and idx <= down * 1.002: + hit_side = "down" + if not hit_side: + return None + if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True): + return None + open_legs = _oo_option_legs(legs, statuses=("open",)) + if len(open_legs) < 2: + return None + winners = [] + for leg in open_legs: + strike = _sf(leg.get("strike")) or 0 + o = (leg.get("opt_type") or "").upper() + intrinsic = max(0.0, idx - strike) if o == "C" else max(0.0, strike - idx) + premium = float(leg.get("premium") or 0) + pnl = intrinsic * float(leg.get("size") or 1) * float(leg.get("ct_mult") or 0.01) - premium + winners.append((pnl, leg)) + winners.sort(key=lambda x: x[0], reverse=True) + best_pnl, best = winners[0] + if best_pnl <= 0: + return None + close_r = _sell_option(cfg, inst_id=str(best.get("inst_id") or ""), sheets=float(best.get("size") or 1)) + if not close_r.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期期平盈利腿失败", + plan_id=plan.get("id"), + detail=str(close_r.get("msg") or close_r), + ), + ) + return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r} + reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg" + closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl)) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), closed_pnl, best["id"]), + ) + return _after_oo_winner_closed( + cfg, + conn, + plan, + open_legs, + best, + reason=reason, + extra={"hit_side": hit_side, "index": idx}, + ) + + +def _tick_oo_expiry( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """期期:剩余期权腿全部到期 → 结算合计并结束计划.""" + pending = [ + x + for x in legs + if str(x.get("leg_role") or "").startswith("option") + and str(x.get("status") or "") in ("open", "hold_to_expiry") + ] + if not pending: + # 若腿已全部 closed 但计划仍 active(异常残留)则用腿合计收口 + closed_opts = [ + x for x in legs if str(x.get("leg_role") or "").startswith("option") and x.get("status") == "closed" + ] + if len(closed_opts) < 1: + return None + total_opts = sum(float(x.get("realized_pnl") or 0) for x in closed_opts) + reason = "oo_expiry_loss" if total_opts <= 0 else "oo_expiry_win" + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason=reason, + realized_pnl_options=round(total_opts, 4), + realized_pnl_total=round(total_opts, 4), + stats_bucket=reason if reason == "oo_expiry_loss" else "oo_target", + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": reason, "total": total_opts} + + if not all(leg_is_expired(x) for x in pending): + return None + + spot = _index_px(cfg, str(plan.get("underlying") or "ETH")) + if spot is None: + return None + + settled_sum = 0.0 + for leg in pending: + est = settle_option_leg_at_spot(leg, float(spot)) + pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est) + settled_sum += pnl + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "expiry", _now(), round(pnl, 4), leg["id"]), + ) + + already = sum( + float(x.get("realized_pnl") or 0) + for x in legs + if str(x.get("leg_role") or "").startswith("option") and x.get("status") == "closed" + ) + total = already + settled_sum + reason = "oo_expiry_loss" if total <= 0 else "oo_expiry_win" + bucket = "oo_expiry_loss" if reason == "oo_expiry_loss" else "oo_target" + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason=reason, + realized_pnl_options=round(total, 4), + realized_pnl_total=round(total, 4), + stats_bucket=bucket, + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": reason, "total": total, "spot": spot} + + +def _settle_orphaned_after_tp(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any]]: + """永期止盈后 hold_to_expiry 期权到期:只更新腿,不回写计划合计.""" + rows = conn.execute( + """ + SELECT l.id AS leg_id, l.plan_id, l.inst_id, l.opt_type, l.strike, l.size, l.premium, l.status, + p.underlying, p.status AS plan_status + FROM hedge_plan_legs l + JOIN hedge_plans p ON p.id = l.plan_id + WHERE l.status = 'hold_to_expiry' AND l.close_reason = 'orphaned_after_tp' + LIMIT 40 + """ + ).fetchall() + acted: list[dict[str, Any]] = [] + for row in rows: + leg = dict(row) + if not leg_is_expired(leg): + continue + spot = _index_px(cfg, str(leg.get("underlying") or "ETH")) + if spot is None: + continue + pnl_est = settle_option_leg_at_spot(leg, float(spot)) + # orphan row uses leg_id; map to id for resolver + leg_for_pnl = dict(leg) + leg_for_pnl["id"] = leg.get("leg_id") + pnl = _option_leg_pnl_after_close(cfg, leg_for_pnl, fallback=pnl_est) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "expiry", _now(), round(pnl, 4), leg["leg_id"]), + ) + # 故意不 UPDATE hedge_plans.realized_pnl_* + acted.append( + { + "plan_id": leg["plan_id"], + "close_reason": "orphaned_option_expiry", + "leg_id": leg["leg_id"], + "leg_pnl": round(pnl, 4), + "note": "不回写计划合计", + } + ) + return acted diff --git a/lib/hedge_plan/hedge_plan_notify_lib.py b/lib/hedge_plan/hedge_plan_notify_lib.py new file mode 100644 index 0000000..03f8848 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_notify_lib.py @@ -0,0 +1,216 @@ +"""对冲计划企业微信推送(起止必发,幂等落库标记).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.hedge_plan.hedge_plan_db import update_plan + + +def _fmt(v: Any, d: int = 2) -> str: + try: + if v is None or v == "": + return "—" + return f"{float(v):.{d}f}" + except (TypeError, ValueError): + return str(v) + + +def _type_label(plan_type: str) -> str: + return "永期对冲" if (plan_type or "") == "perp_options" else "期期对冲" + + +def _dir_label(direction: str) -> str: + d = (direction or "").lower() + if d == "long": + return "做多" + if d == "short": + return "做空" + return "—" + + +def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[str, Any]]] = None) -> str: + pt = plan.get("plan_type") or "" + lines = [ + f"🟢 对冲计划启动 #{plan.get('id')}", + f"📌 类型:{_type_label(pt)}", + f"🪙 标的:{plan.get('underlying') or '—'}", + ] + if pt == "perp_options": + lines.extend( + [ + f"📈 方向:{_dir_label(plan.get('direction') or '')}", + f"💵 开仓参考:{_fmt(plan.get('entry_mark'))}", + f"🎯 止盈:{_fmt(plan.get('tp'))}|止损:{_fmt(plan.get('sl'))}", + f"📦 永续张数:{_fmt(plan.get('perp_size'), 4)}|杠杆:{_fmt(plan.get('leverage'), 0)}x", + f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", + ] + ) + else: + rr = plan.get("profit_rr") + if rr not in (None, ""): + lines.extend( + [ + f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)", + f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", + ] + ) + else: + lines.extend( + [ + f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}" + f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}", + f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", + ] + ) + if legs: + for leg in legs: + role = leg.get("leg_role") or "" + if role == "perp": + lines.append(f"· 永续腿 {leg.get('symbol') or ''} ×{_fmt(leg.get('size'), 4)}") + else: + lines.append( + f"· {role} {(leg.get('opt_type') or '')} K{_fmt(leg.get('strike'), 0)} " + f"×{_fmt(leg.get('size'), 0)}张 {leg.get('inst_id') or ''}" + ) + lines.append("📎 独立模块推送,不进普通交易复盘") + return "\n".join(lines) + + +def build_hedge_end_message(plan: dict[str, Any]) -> str: + reason = plan.get("close_reason") or "—" + total = plan.get("realized_pnl_total") + try: + tv = float(total) if total is not None else None + except (TypeError, ValueError): + tv = None + head = "🔴" if (tv is not None and tv < 0) else "🟢" + reason_map = { + "perp_tp": "永续止盈(期权默认不平)", + "perp_sl": "永续止损(期权强制平)", + "target_win_leg": "期期已平盈利腿(中间态)", + "target_up_win_leg": "期期上破·已平盈利腿", + "target_down_win_leg": "期期下破·已平盈利腿", + "profit_rr_win_leg": "期期盈亏比达标·已平盈利腿", + "oo_rest_closing": "期期残值平·清亏损腿中", + "oo_rest_closed": "期期残值平·两腿已平", + "oo_expiry_loss": "期期到期无盈利·总亏损", + "oo_expiry_win": "期期到期仍盈利", + "expiry": "到期收口", + "manual": "人工结束", + "partial_fail": "半腿失败收尾", + "cancelled": "已取消", + } + lines = [ + f"{head} 对冲计划结束 #{plan.get('id')}", + f"📌 类型:{_type_label(plan.get('plan_type') or '')}", + f"🪙 标的:{plan.get('underlying') or '—'}", + f"📎 原因:{reason_map.get(reason, reason)}", + f"💰 合计≈U:{_fmt(total)}", + f"· 永续分项:{_fmt(plan.get('realized_pnl_perp'))} USDT", + f"· 期权分项:{_fmt(plan.get('realized_pnl_options'))} USDC(≈U 1:1)", + f"⏱ 开仓:{plan.get('opened_at') or '—'}|结束:{plan.get('closed_at') or '—'}", + ] + return "\n".join(lines) + + +def build_hedge_alert_message( + *, + title: str, + plan_id: Any = None, + detail: str = "", +) -> str: + lines = [f"⚠️ 对冲计划告警{(' #' + str(plan_id)) if plan_id else ''}", f"📌 {title}"] + if detail: + lines.append(str(detail)[:800]) + return "\n".join(lines) + + +def notify_hedge( + cfg: dict[str, Any], + content: str, +) -> bool: + send: Optional[Callable[[str], Any]] = cfg.get("send_wechat") + if not callable(send): + return False + try: + send(content) + return True + except Exception: + return False + + +def notify_plan_start( + cfg: dict[str, Any], + conn: Any, + plan: dict[str, Any], + legs: Optional[list[dict[str, Any]]] = None, +) -> bool: + if int(plan.get("wechat_start_sent") or 0): + return False + ok = notify_hedge(cfg, build_hedge_start_message(plan, legs=legs)) + if ok and plan.get("id") is not None: + update_plan(conn, int(plan["id"]), wechat_start_sent=1) + plan["wechat_start_sent"] = 1 + return ok + + +def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> bool: + if int(plan.get("wechat_end_sent") or 0): + return False + # 中间态 target_win_leg 不算正式结束推送(用告警) + if (plan.get("close_reason") or "") in ( + "target_win_leg", + "target_up_win_leg", + "target_down_win_leg", + "profit_rr_win_leg", + "oo_rest_closing", + ) and (plan.get("status") or "") != "closed": + cr = str(plan.get("close_reason") or "") + if "profit_rr" in cr: + side = "盈亏比达标" + elif "up" in cr: + side = "上破" + elif "down" in cr: + side = "下破" + else: + side = "目标" + mode = (plan.get("oo_close_mode") or "").strip().lower() + if mode in ("close_all", "全平", "残值平"): + rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)" + else: + rest_txt = "另一腿到期平(持有至到期结算)" + rr = plan.get("profit_rr") + if rr not in (None, ""): + detail = f"盈亏比 {_fmt(rr)} (盈利金额/总权利金)" + else: + detail = ( + f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}" + f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}" + ) + notify_hedge( + cfg, + build_hedge_alert_message( + title=f"期期{side}已平盈利腿 · {rest_txt}", + plan_id=plan.get("id"), + detail=detail, + ), + ) + return True + ok = notify_hedge(cfg, build_hedge_end_message(plan)) + if ok and plan.get("id") is not None: + update_plan(conn, int(plan["id"]), wechat_end_sent=1) + plan["wechat_end_sent"] = 1 + return ok + + +def notify_partial_fail(cfg: dict[str, Any], *, plan_type: str, msg: str, results: Any = None) -> bool: + detail = msg + if results: + try: + detail = f"{msg}\n路径结果:{results}"[:800] + except Exception: + pass + return notify_hedge( + cfg, + build_hedge_alert_message(title=f"{_type_label(plan_type)}半腿失败", detail=detail), + ) diff --git a/lib/hedge_plan/hedge_plan_option_primary_lib.py b/lib/hedge_plan/hedge_plan_option_primary_lib.py new file mode 100644 index 0000000..ec82129 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_option_primary_lib.py @@ -0,0 +1,527 @@ +"""永期「以期权为主」:定仓、方向映射、目标位与净利口径(纯函数为主).""" +from __future__ import annotations + +import math +import os +from typing import Any, Optional + +PREMIUM_EXEC_FACTOR = 0.95 +DEFAULT_MIN_HOURS = 36.0 +DEFAULT_STRIKE_INTERVAL = 15.0 +DEFAULT_PERP_LEVERAGE = 100 +DEFAULT_OPT_LEVERAGE_ITM_ATM = 100.0 +DEFAULT_OPT_LEVERAGE_OTM = 200.0 +DEFAULT_RATIO_ITM_ATM = 2.0 +DEFAULT_RATIO_OTM = 4.0 +OTM_LEV_FLOOR = 180.0 + + +def _sf(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def is_option_primary(body_or_plan: dict[str, Any] | None) -> bool: + if not body_or_plan: + return False + v = body_or_plan.get("option_primary") + if v in (True, 1, "1", "true", "yes", "on"): + return True + try: + return int(v or 0) == 1 + except (TypeError, ValueError): + return False + + +def fee_rate() -> float: + try: + return max(0.0, float(os.getenv("HEDGE_PLAN_FEE_RATE") or os.getenv("OKX_TAKER_FEE") or "0.0005")) + except (TypeError, ValueError): + return 0.0005 + + +def floor2(v: float) -> float: + """ETH 数量向下取两位小数.""" + if v <= 0: + return 0.0 + return math.floor(float(v) * 100.0 + 1e-12) / 100.0 + + +def opt_type_for_view(direction: str) -> str: + """看法做多→Call,做空→Put.""" + return "P" if str(direction or "").strip().lower() == "short" else "C" + + +def perp_direction_for_view(direction: str) -> str: + """看法做多→永续空,做空→永续多.""" + return "long" if str(direction or "").strip().lower() == "short" else "short" + + +def default_opt_leverage(moneyness: str) -> float: + m = (moneyness or "").strip().lower() + return DEFAULT_OPT_LEVERAGE_OTM if m == "otm" else DEFAULT_OPT_LEVERAGE_ITM_ATM + + +def default_ratio(moneyness: str) -> float: + m = (moneyness or "").strip().lower() + return DEFAULT_RATIO_OTM if m == "otm" else DEFAULT_RATIO_ITM_ATM + + +def effective_min_opt_leverage(moneyness: str, configured: Any) -> float: + cfg = _sf(configured) + base = cfg if cfg is not None and cfg > 0 else default_opt_leverage(moneyness) + if (moneyness or "").strip().lower() == "otm": + return max(base, OTM_LEV_FLOOR) + return base + + +def hours_to_expiry_from_ms(exp_ms: Any, *, now_ms: Optional[float] = None) -> Optional[float]: + exp = _sf(exp_ms) + if exp is None or exp <= 0: + return None + # OKX exp 多为毫秒 + if exp < 1e12: + exp *= 1000.0 + now = now_ms if now_ms is not None else __import__("time").time() * 1000.0 + return (exp - now) / 3600000.0 + + +def target_hit(*, view_side: str, index_px: float, strike: float, points: float) -> bool: + """相对 K 的点数目标:做多 index≥K+N;做空 index≤K−N.点数须 >0.""" + n = float(points or 0) + k = float(strike) + s = float(index_px) + if n <= 0 or k <= 0 or s <= 0: + return False + side = str(view_side or "").strip().lower() + if side == "short": + return s <= (k - n) + return s >= (k + n) + + +def option_bid_liquidity_ok(bid: Any, bid_sz: Any, *, need_sheets: float = 0) -> tuple[bool, str]: + b = _sf(bid) + if b is None or b <= 0: + return False, "暂无买一报价,无法平期权" + sz = _sf(bid_sz) + if sz is not None and sz <= 0: + return False, "买一深度为 0,无法平期权" + need = float(need_sheets or 0) + if need > 0 and sz is not None and sz + 1e-12 < need: + return False, f"买一深度不足(需 {need:g} 张,买一 {sz:g})" + return True, "" + + +def size_from_premium( + *, + premium_budget: float, + ask: float, + ct_mult: float, + ratio: float, + contract_size: float, + exec_factor: float = PREMIUM_EXEC_FACTOR, +) -> dict[str, Any]: + """权利金×0.95 → ETH 两位小数 → 期权张 → 永续跟比例.""" + budget = float(premium_budget or 0) + a = float(ask or 0) + ct = float(ct_mult or 0.01) + r = float(ratio or 0) + cs = float(contract_size or 0.01) + usable = budget * float(exec_factor or PREMIUM_EXEC_FACTOR) + if budget <= 0 or a <= 0 or ct <= 0 or r <= 0 or cs <= 0: + return { + "ok": False, + "msg": "定仓参数无效", + "usable_premium": round(usable, 4), + "eth_qty": 0.0, + "sheets": 0.0, + "perp_eth": 0.0, + "contracts": 0.0, + } + # ask 为每 1 币权利金;ETH 数量 = usable / ask + eth_qty = floor2(usable / a) + if eth_qty <= 0: + return { + "ok": False, + "msg": "权利金不足以买入 0.01 ETH 名义期权", + "usable_premium": round(usable, 4), + "eth_qty": 0.0, + "sheets": 0.0, + "perp_eth": 0.0, + "contracts": 0.0, + } + sheets = eth_qty / ct + # 张数向下取整到整数张(OKX 期权常见整张) + sheets_i = float(math.floor(sheets + 1e-12)) + if sheets_i <= 0: + return { + "ok": False, + "msg": "换算期权张数不足 1 张", + "usable_premium": round(usable, 4), + "eth_qty": eth_qty, + "sheets": 0.0, + "perp_eth": 0.0, + "contracts": 0.0, + } + # 用整张回写 ETH,保持与下单一致 + eth_qty = round(sheets_i * ct, 2) + perp_eth = eth_qty / r + contracts = perp_eth / cs + premium_est = a * sheets_i * ct + return { + "ok": True, + "msg": "", + "usable_premium": round(usable, 4), + "eth_qty": eth_qty, + "sheets": sheets_i, + "perp_eth": round(perp_eth, 6), + "contracts": contracts, + "premium_est": round(premium_est, 4), + "ratio": r, + "exec_factor": float(exec_factor or PREMIUM_EXEC_FACTOR), + } + + +def estimate_combo_net_pnl( + *, + view_side: str, + strike: float, + index_px: float, + ask_open: float, + bid: float, + sheets: float, + ct_mult: float, + perp_direction: str, + perp_entry: float, + perp_mark: float, + contracts: float, + contract_size: float, + fee: Optional[float] = None, +) -> dict[str, Any]: + """组合净利(扣费);平仓/卖出手续费按买入费率估算.""" + fr = fee if fee is not None else fee_rate() + ct = float(ct_mult or 0.01) + sh = float(sheets or 0) + a = float(ask_open or 0) + b = float(bid or 0) + premium = a * sh * ct + opt_proceeds = b * sh * ct + opt_open_fee = premium * fr + opt_close_fee = opt_proceeds * fr # 卖出费用按买入费率 + opt_net = opt_proceeds - premium - opt_open_fee - opt_close_fee + + coins = float(contracts or 0) * float(contract_size or 0.01) + entry = float(perp_entry or 0) + mark = float(perp_mark or 0) + pd = str(perp_direction or "").strip().lower() + if pd == "short": + perp_gross = (entry - mark) * coins + else: + perp_gross = (mark - entry) * coins + perp_notional_open = abs(entry * coins) + perp_notional_close = abs(mark * coins) + perp_open_fee = perp_notional_open * fr + perp_close_fee = perp_notional_close * fr + perp_net = perp_gross - perp_open_fee - perp_close_fee + total = opt_net + perp_net + return { + "opt_net": round(opt_net, 4), + "perp_net": round(perp_net, 4), + "net": round(total, 4), + "fee_rate": fr, + "premium": round(premium, 4), + "opt_proceeds": round(opt_proceeds, 4), + } + + +def validate_option_primary_moneyness( + *, + opt_type: str, + strike: Any, + index_px: Any, + ask: Any = None, + moneyness: str = "atm", + strike_interval: Any = DEFAULT_STRIKE_INTERVAL, + min_hours: Any = DEFAULT_MIN_HOURS, + hours_to_expiry: Any = None, + min_opt_leverage: Any = None, +) -> Optional[str]: + from lib.hedge_plan.hedge_plan_moneyness_lib import ( + classify_moneyness, + is_atm_or_otm, + is_itm_or_atm, + normalize_opt_type, + ) + + o = normalize_opt_type(opt_type) + k = _sf(strike) + s = _sf(index_px) + if o not in ("C", "P"): + return "期权类型无效" + if k is None or s is None or s <= 0: + return "行权价或指数无效" + m_want = (moneyness or "atm").strip().lower() + m_got = classify_moneyness(opt_type=o, strike=k, index_px=s) + if m_want == "itm": + if not is_itm_or_atm(opt_type=o, strike=k, index_px=s): + return "所选须为实值或平值" + elif m_want == "atm": + # 平值:距指数在间隔内即可(不强制 classify==atm) + pass + elif m_want == "otm": + if m_got == "itm": + return "虚值模式不可选实值" + if not is_atm_or_otm(opt_type=o, strike=k, index_px=s): + return "虚值模式须选虚值或平值档" + else: + return "期权类型(实/平/虚)无效" + + interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL) + if interval > 0 and abs(k - s) > interval + 1e-9: + return f"行权价偏离指数 {abs(k - s):.1f} > 间隔 {interval:.0f}" + + min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS) + h = _sf(hours_to_expiry) + if min_h > 0 and h is not None and h < min_h: + return f"剩余到期约 {h:.1f}h,低于最短 {min_h:.0f}h" + + a = _sf(ask) + min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else m_got or "atm", min_opt_leverage) + if min_lev > 0 and a is not None and a > 0: + lev = s / a + if lev < min_lev: + return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}" + return None + + +def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]: + """盯盘启动校验:只要参数,不要求已选具体合约.""" + need = ( + "direction", + "exchange_symbol", + "premium_budget", + "option_target_points", + "perp_target_points", + "option_perp_ratio", + "option_leverage", + ) + for k in need: + if body.get(k) in (None, ""): + return f"缺少字段: {k}" + try: + if float(body["premium_budget"]) <= 0: + return "权利金须大于 0" + if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0: + return "目标位点数须大于 0" + if float(body["option_perp_ratio"]) <= 0: + return "期权永续比例须大于 0" + if float(body["option_leverage"]) <= 0: + return "期权杠杆须大于 0" + lev_perp = _sf(body.get("leverage")) + if lev_perp is not None and lev_perp <= 0: + return "永续杠杆须大于 0" + except (TypeError, ValueError): + return "数值字段无效" + direction = str(body.get("direction") or "").strip().lower() + if direction not in ("long", "short"): + return "方向须为 long 或 short" + moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower() + if moneyness not in ("itm", "atm", "otm"): + return "期权类型(实/平/虚)无效" + return None + + +def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]: + need = ( + "direction", + "contracts", + "opt_inst_id", + "sheets", + "exchange_symbol", + "premium_budget", + "option_target_points", + "perp_target_points", + "option_perp_ratio", + ) + for k in need: + if body.get(k) in (None, ""): + return f"缺少字段: {k}" + try: + if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0: + return "张数必须大于 0" + if float(body["premium_budget"]) <= 0: + return "权利金须大于 0" + if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0: + return "目标位点数须大于 0" + if float(body["option_perp_ratio"]) <= 0: + return "期权永续比例须大于 0" + except (TypeError, ValueError): + return "数值字段无效" + direction = str(body.get("direction") or "").strip().lower() + if direction not in ("long", "short"): + return "方向须为 long 或 short" + opt_type = str(body.get("opt_type") or "").strip().upper() + if not opt_type: + inst = str(body.get("opt_inst_id") or "") + if inst.upper().endswith("-P"): + opt_type = "P" + elif inst.upper().endswith("-C"): + opt_type = "C" + want = opt_type_for_view(direction) + if opt_type != want: + return f"以期权为主时做{'多' if direction == 'long' else '空'}须用 {'Call' if want == 'C' else 'Put'}" + moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower() + from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst + + strike = body.get("strike") + if strike in (None, ""): + strike = parse_strike_from_inst(str(body.get("opt_inst_id") or "")) + index_px = body.get("index_px") or body.get("entry") + return validate_option_primary_moneyness( + opt_type=opt_type, + strike=strike, + index_px=index_px, + ask=body.get("ask"), + moneyness=moneyness, + strike_interval=body.get("strike_interval", DEFAULT_STRIKE_INTERVAL), + min_hours=body.get("min_option_hours", DEFAULT_MIN_HOURS), + hours_to_expiry=body.get("hours_to_expiry"), + min_opt_leverage=body.get("option_leverage") or body.get("min_opt_leverage"), + ) + + +def pick_option_primary_candidate( + chain: dict[str, Any], + *, + direction: str, + moneyness: str = "otm", + strike_interval: Any = DEFAULT_STRIKE_INTERVAL, + min_hours: Any = DEFAULT_MIN_HOURS, + min_opt_leverage: Any = None, +) -> Optional[dict[str, Any]]: + """从期权链挑最近达标合约(间隔+虚实值+杠杆门).""" + from lib.hedge_plan.hedge_plan_moneyness_lib import classify_moneyness + + want = opt_type_for_view(direction) + m_want = (moneyness or "otm").strip().lower() + interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL) + min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS) + try: + idx = float(chain.get("index_px") or 0) + except (TypeError, ValueError): + idx = 0.0 + if idx <= 0: + return None + + best: Optional[dict[str, Any]] = None + best_dist: Optional[float] = None + for exp in chain.get("expiries") or []: + h = hours_to_expiry_from_ms(exp.get("exp_time")) + if min_h > 0 and h is not None and h < min_h: + continue + for c in exp.get("contracts") or []: + if str(c.get("opt_type") or "").upper() != want: + continue + try: + k = float(c.get("strike") or 0) + ask = float(c.get("ask") or 0) + except (TypeError, ValueError): + continue + if k <= 0 or ask <= 0: + continue + if interval > 0 and abs(k - idx) > interval + 1e-9: + continue + m_got = classify_moneyness(opt_type=want, strike=k, index_px=idx) + if m_want == "itm" and m_got not in ("itm", "atm"): + continue + if m_want == "atm" and m_got != "atm": + continue + if m_want == "otm" and m_got == "itm": + continue + min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else (m_got or "atm"), min_opt_leverage) + if min_lev > 0 and idx / ask < min_lev - 1e-9: + continue + dist = abs(k - idx) + if best is None or best_dist is None or dist < best_dist: + best = { + **dict(c), + "hours_to_expiry": h, + "exp_time": exp.get("exp_time"), + "moneyness": m_got, + "index_px": idx, + "leverage": round(idx / ask, 1), + } + best_dist = dist + return best + + +def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]: + """情景:期权目标 / 永续目标粗估净利.""" + view = str(body.get("direction") or "long").lower() + strike = float(body["strike"]) + n = float(body.get("option_target_points") or 0) + m = float(body.get("perp_target_points") or 0) + ask = float(body.get("ask") or 0) + sheets = float(body.get("sheets") or 0) + ct = float(body.get("ct_mult") or 0.01) + contracts = float(body.get("contracts") or 0) + cs = float(body.get("contract_size") or 0.01) + entry = float(body.get("entry") or body.get("index_px") or 0) + perp_dir = perp_direction_for_view(view) + # 粗估到点时期权卖价:按内在价值近似(下限 0) + def intrinsic(spot: float) -> float: + o = opt_type_for_view(view) + if o == "C": + return max(0.0, spot - strike) + return max(0.0, strike - spot) + + scenarios = [] + for label, pts, reason in ( + ("期权目标", n, "opt_target_points"), + ("永续目标", m, "perp_target_points"), + ): + spot = strike + pts if view != "short" else strike - pts + bid_est = max(ask * 0.5, intrinsic(spot) * 0.85) # 保守估价 + net = estimate_combo_net_pnl( + view_side=view, + strike=strike, + index_px=spot, + ask_open=ask, + bid=bid_est, + sheets=sheets, + ct_mult=ct, + perp_direction=perp_dir, + perp_entry=entry, + perp_mark=spot, + contracts=contracts, + contract_size=cs, + ) + scenarios.append( + { + "label": label, + "reason": reason, + "index": spot, + "perp_pnl": net["perp_net"], + "options_pnl": net["opt_net"], + "total": net["net"], + "note": "扣费净利估价;平仓费按买入费率", + } + ) + premium = ask * sheets * ct + return { + "plan_type": "perp_options", + "option_primary": True, + "summary": { + "premium_paid": round(premium, 4), + "usable_premium": round(float(body.get("premium_budget") or 0) * PREMIUM_EXEC_FACTOR, 4), + "opt_target_total": scenarios[0]["total"] if scenarios else None, + "perp_target_total": scenarios[1]["total"] if len(scenarios) > 1 else None, + "perp_direction": perp_dir, + "opt_type": opt_type_for_view(view), + }, + "scenarios": scenarios, + } diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py new file mode 100644 index 0000000..f25b590 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -0,0 +1,1346 @@ +"""对冲计划开仓/平仓编排(可 dry_run 校验下单路径).""" +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from typing import Any, Callable, Optional + + +def _now() -> str: + return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def open_order_mode() -> str: + v = (os.getenv("HEDGE_PLAN_OPEN_ORDER") or "options_first").strip().lower() + return v if v in ("options_first", "perp_first") else "options_first" + + +def manual_complete_on_partial() -> bool: + """半腿失败后挂 partial 并手动补开(默认 true).""" + return _env_bool("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", True) + + +def partial_auto_close_enabled() -> bool: + """手动补开开启时强制关闭自动平,避免吃买卖价差.""" + if manual_complete_on_partial(): + return False + return _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True) + + +def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: + """永期下单路径清单(不交易).""" + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + is_option_primary, + perp_direction_for_view, + ) + + opt_primary = is_option_primary(body) + mode = "options_first" if opt_primary else open_order_mode() + view = str(body.get("direction") or "long") + perp_dir = perp_direction_for_view(view) if opt_primary else view + opt = { + "step": "options_buy_limit", + "account": "options", + "inst_id": body.get("opt_inst_id"), + "sheets": float(body.get("sheets") or 1), + "side": "buy", + "price_hint": "ask", + } + perp = { + "step": "perp_market_open", + "account": "swap", + "symbol": body.get("exchange_symbol"), + "direction": perp_dir, + "contracts": float(body.get("contracts") or 0), + "tp": None if opt_primary else body.get("tp"), + "sl": None if opt_primary else body.get("sl"), + "attach_tpsl": False if opt_primary else True, + "option_primary": opt_primary, + "view_side": view, + } + return [opt, perp] if mode == "options_first" else [perp, opt] + + +def build_oo_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "step": "options_buy_limit", + "account": "options", + "leg": "a", + "inst_id": (body.get("leg_a") or {}).get("inst_id"), + "sheets": float((body.get("leg_a") or {}).get("sheets") or 1), + "side": "buy", + "price_hint": "ask", + }, + { + "step": "options_buy_limit", + "account": "options", + "leg": "b", + "inst_id": (body.get("leg_b") or {}).get("inst_id"), + "sheets": float((body.get("leg_b") or {}).get("sheets") or 1), + "side": "buy", + "price_hint": "ask", + }, + ] + + +def _option_open_fill_timeout_sec() -> float: + try: + return max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12")) + except (TypeError, ValueError): + return 12.0 + + +def _buy_option( + cfg: dict[str, Any], + *, + inst_id: str, + sheets: float, + dry_run: bool, +) -> dict[str, Any]: + from lib.exchange.okx_options_lib import ( + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, + wait_option_order_full_fill, + ) + + ex = cfg.get("exchange_options") + quote_fn = cfg.get("quote_option_contract") + place_fn = cfg.get("place_option_limit_order") + td_buy = cfg.get("td_mode_for_option_buy") + if not inst_id: + return {"ok": False, "msg": "缺少期权合约"} + if not callable(quote_fn) or ex is None: + return {"ok": False, "msg": "期权报价能力未就绪"} + q = quote_fn(ex, inst_id) + if not q.get("ok"): + return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q} + ask = q.get("ask") + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + return { + "ok": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "quote": q, + "mark": q.get("mark"), + "ref_ask": q.get("ref_ask"), + "can_open": False, + } + from lib.options.options_position_limit_lib import option_position_limit_block_msg + + pos_limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id=inst_id, + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return {"ok": False, "msg": pos_limit_msg, "quote": q, "can_open": False} + sheets_i = max(1, int(round(float(sheets)))) + requested_sheets = sheets_i + capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets_i, ask_sz, min_sz=1) + if capped is None: + return {"ok": False, "msg": cap_msg or "卖一深度不足,无法买入", "quote": q} + if int(capped) < requested_sheets: + return { + "ok": False, + "msg": f"卖一深度仅 {int(capped)} 张,不足请求 {requested_sheets} 张,拒绝缩量成交", + "quote": q, + "can_open": False, + "requested_sheets": requested_sheets, + "ask_sz": ask_sz, + } + sheets_i = int(capped) + ct_mult = float(q.get("ct_mult") or 0.01) + premium = float(ask) * sheets_i * ct_mult + if dry_run: + return { + "ok": True, + "dry_run": True, + "inst_id": inst_id, + "sheets": sheets_i, + "ask": float(ask), + "ask_sz": float(ask_sz), + "premium": premium, + "ct_mult": ct_mult, + "tick_sz": q.get("tick_sz"), + "meta": q.get("meta") or {}, + "strike": q.get("strike"), + "exp_time": q.get("exp_time"), + "opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), + "can_open": True, + } + if not callable(place_fn): + return {"ok": False, "msg": "期权限价下单未注入"} + td = "isolated" + if callable(td_buy): + td = td_buy(cfg.get("options_td_mode") or "isolated") + # IOC:能成交多少成交多少,剩余立即撤销;再校验是否完全成交 + order = place_fn( + ex, + inst_id=inst_id, + side="buy", + sheets=sheets_i, + price=float(ask), + td_mode=td, + tick_sz=q.get("tick_sz"), + ord_type="ioc", + ) + if not order.get("ok"): + return order + ord_id = str((order.get("data") or {}).get("ordId") or "").strip() + if not ord_id: + return {"ok": False, "msg": "下单成功但未返回订单号", "order": order} + fill = wait_option_order_full_fill( + ex, + inst_id=inst_id, + ord_id=ord_id, + need_sheets=sheets_i, + timeout_sec=_option_open_fill_timeout_sec(), + cancel_on_timeout=True, + ) + if not fill.get("ok"): + filled_n = int(fill.get("filled_sheets") or 0) + orphan_close = None + if filled_n > 0 and not dry_run: + # 部分成交后撤单:尝试立刻平掉已成交,避免孤儿多头 + try: + orphan_close = _sell_option(cfg, inst_id=inst_id, sheets=float(filled_n)) + except Exception as e: + orphan_close = {"ok": False, "msg": str(e)} + return { + "ok": False, + "msg": fill.get("msg") or "未完全成交,开仓失败", + "inst_id": inst_id, + "sheets": sheets_i, + "ask": float(ask), + "exchange_ord_id": ord_id, + "filled_sheets": filled_n, + "orphan_close": orphan_close, + "order": order, + "fill": fill, + "can_open": False, + } + fill_px = float(fill.get("avg_px") or ask) + filled_n = int(fill.get("filled_sheets") or sheets_i) + premium = fill_px * filled_n * ct_mult + return { + "ok": True, + "inst_id": inst_id, + "sheets": filled_n, + "ask": fill_px, + "ask_sz": float(ask_sz), + "premium": premium, + "ct_mult": ct_mult, + "tick_sz": q.get("tick_sz"), + "meta": q.get("meta") or {}, + "strike": q.get("strike"), + "exp_time": q.get("exp_time"), + "opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), + "exchange_ord_id": ord_id, + "order": order, + "fill": fill, + "can_open": True, + } + + +def _open_perp( + cfg: dict[str, Any], + *, + symbol: str, + direction: str, + contracts: float, + leverage: int, + tp: Optional[float], + sl: Optional[float], + dry_run: bool, + attach_tpsl: bool = True, +) -> dict[str, Any]: + if not symbol or contracts <= 0: + return {"ok": False, "msg": "永续符号或张数无效"} + amount = float(contracts) + to_prec = cfg.get("amount_to_precision") + ex = cfg.get("exchange") + if callable(to_prec) and ex is not None: + try: + amount = float(to_prec(symbol, amount)) + except Exception: + pass + if amount <= 0: + return {"ok": False, "msg": "张数经精度舍入后为 0"} + use_tpsl = bool(attach_tpsl) and tp is not None and sl is not None + tp_v = float(tp) if use_tpsl else None + sl_v = float(sl) if use_tpsl else None + if dry_run: + return { + "ok": True, + "dry_run": True, + "symbol": symbol, + "direction": direction, + "contracts": amount, + "leverage": leverage, + "tp": tp_v, + "sl": sl_v, + "attach_tpsl": use_tpsl, + } + ensure = cfg.get("ensure_okx_live_ready") + if callable(ensure): + ok, msg = ensure() + if not ok: + return {"ok": False, "msg": msg or "实盘未就绪"} + place = cfg.get("place_exchange_order") + if not callable(place): + return {"ok": False, "msg": "永续下单函数未注入"} + try: + order = place( + symbol, + direction, + amount, + leverage, + stop_loss=sl_v, + take_profit=tp_v, + ) + except Exception as e: + return {"ok": False, "msg": f"永续开仓失败: {e}"} + return { + "ok": True, + "symbol": symbol, + "direction": direction, + "contracts": amount, + "leverage": leverage, + "tp": tp_v, + "sl": sl_v, + "attach_tpsl": use_tpsl, + "order": order, + "exchange_ord_id": str((order or {}).get("id") or (order or {}).get("info", {}).get("ordId") or ""), + } + + +def _close_perp( + cfg: dict[str, Any], + *, + symbol: str, + direction: str, + contracts: float, + dry_run: bool = False, +) -> dict[str, Any]: + """市价平永续(reduce-only);优先用注入的 close_exchange_order.""" + if not symbol: + return {"ok": False, "msg": "永续符号无效"} + if dry_run: + return { + "ok": True, + "dry_run": True, + "symbol": symbol, + "direction": direction, + "contracts": float(contracts or 0), + } + close_fn = cfg.get("close_exchange_order") + if callable(close_fn): + try: + order = close_fn( + { + "exchange_symbol": symbol, + "direction": direction, + "order_amount": float(contracts or 0), + "symbol": symbol, + } + ) + return {"ok": True, "symbol": symbol, "direction": direction, "order": order} + except Exception as e: + return {"ok": False, "msg": f"永续平仓失败: {e}"} + # 回退:对向市价 reduce-only(若注入了 place + 支持) + place = cfg.get("place_exchange_order") + if not callable(place): + return {"ok": False, "msg": "永续平仓函数未注入"} + try: + # 无 TP/SL 的对向单;依赖交易所 reduceOnly 由 place 实现不保证,优先 close_exchange_order + side_dir = "short" if str(direction).lower() == "long" else "long" + order = place(symbol, side_dir, float(contracts or 0), int(cfg.get("alt_leverage") or 5), None, None) + return {"ok": True, "symbol": symbol, "direction": direction, "order": order, "note": "fallback_place"} + except Exception as e: + return {"ok": False, "msg": f"永续平仓失败: {e}"} + + +def _sell_option( + cfg: dict[str, Any], + *, + inst_id: str, + sheets: float, + dry_run: bool = False, +) -> dict[str, Any]: + """平期权:走买一限价 + 验仓;仅 fully_closed/already_flat 视为成功. + + 对冲强平/目标平仓不启用 2× 回收门控(require_recycle_gate=False). + """ + from lib.exchange.okx_options_lib import fetch_option_book_depth, fetch_option_positions + from lib.options.options_close_exec_lib import close_option_by_bid1 + + ex = cfg.get("exchange_options") + quote_fn = cfg.get("quote_option_contract") + if not inst_id: + return {"ok": False, "msg": "缺少期权合约"} + if not callable(quote_fn) or ex is None: + return {"ok": False, "msg": "期权报价能力未就绪"} + q = quote_fn(ex, inst_id) + bid = q.get("bid") if q.get("ok") else None + if bid is None or float(bid) <= 0: + return {"ok": False, "msg": "暂无买一价,无法平期权"} + sheets_i = max(1, int(round(float(sheets)))) + if dry_run: + return { + "ok": True, + "dry_run": True, + "inst_id": inst_id, + "sheets": sheets_i, + "bid": float(bid), + "fully_closed": True, + } + if not callable(cfg.get("place_option_limit_order")): + return {"ok": False, "msg": "期权平仓未注入"} + close_cfg = dict(cfg) + if not callable(close_cfg.get("fetch_option_positions")): + close_cfg["fetch_option_positions"] = fetch_option_positions + if not callable(close_cfg.get("fetch_option_book_depth")): + close_cfg["fetch_option_book_depth"] = fetch_option_book_depth + if "td_mode" not in close_cfg: + close_cfg["td_mode"] = close_cfg.get("options_td_mode") or "isolated" + result = close_option_by_bid1( + close_cfg, + ex, + inst_id, + sheets=sheets_i, + require_recycle_gate=False, + ) + out = dict(result or {}) + if out.get("already_flat"): + # 二次验仓,避免一次空列表误判已平 + import time as _time + + _time.sleep(0.35) + try: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + except Exception: + pass + rows2 = close_cfg["fetch_option_positions"](ex) + if rows2 is None: + return {"ok": False, "msg": "二次验仓失败,未确认是否已平", "fully_closed": False} + still = next((p for p in rows2 if str(p.get("instId")) == inst_id), None) + still_sz = 0.0 + if still is not None: + try: + still_sz = abs(float(still.get("availPos") or still.get("pos") or 0)) + except (TypeError, ValueError): + still_sz = 0.0 + if still is not None and still_sz >= 1: + return { + "ok": False, + "msg": "二次验仓仍有持仓,拒绝 already_flat", + "fully_closed": False, + } + out["ok"] = True + out["fully_closed"] = True + out.setdefault("bid", float(bid)) + return out + if not out.get("ok"): + out.setdefault("bid", float(bid)) + return out + if not out.get("fully_closed"): + return { + "ok": False, + "msg": out.get("msg") or "期权尚未完全平仓,将下轮重试", + "bid": out.get("locked_bid_px") or float(bid), + "fully_closed": False, + "partial": True, + "close": out, + } + out["bid"] = out.get("locked_bid_px") or float(bid) + out["fully_closed"] = True + return out + + +def _notify_partial(cfg: dict[str, Any], plan_type: str, msg: str, results: list[dict[str, Any]]) -> None: + try: + from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail + + notify_partial_fail(cfg, plan_type=plan_type, msg=msg, results=results) + except Exception: + pass + + +def _park_partial( + cfg: dict[str, Any], + *, + plan_type: str, + body: dict[str, Any], + missing_leg: str, + msg: str, + path: list[dict[str, Any]], + results: list[dict[str, Any]], + persist: Optional[Callable[..., Any]], + dry_run: bool, + **filled: Any, +) -> dict[str, Any]: + """半腿失败:保留已成腿,挂 partial 供手动补开.""" + if not dry_run: + _notify_partial(cfg, plan_type, msg, results) + out: dict[str, Any] = { + "ok": True, + "partial": True, + "status": "partial", + "dry_run": dry_run, + "plan_type": plan_type, + "missing_leg": missing_leg, + "msg": msg, + "path": path, + "results": results, + "opened_at": _now(), + **filled, + } + if persist and not dry_run: + out["plan_id"] = persist(out, body) + return out + + +def _hedge_budget_buffer(cfg: dict[str, Any] | None = None) -> float: + """对冲专用预算缓冲;默认 0.95.与 OKX_OPTIONS_BUDGET_BUFFER 独立.""" + raw = None + if cfg is not None: + raw = cfg.get("budget_buffer") + if raw is None or raw == "": + raw = os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95" + try: + buf = float(raw) + except (TypeError, ValueError): + buf = 0.95 + if buf <= 0: + buf = 0.95 + if buf > 1: + buf = 1.0 + return float(buf) + + +def _oo_bias_settings(cfg: dict[str, Any] | None = None) -> tuple[str, float]: + from lib.hedge_plan.hedge_plan_calc_lib import _clamp_oo_bias_ratio, _normalize_oo_bias_split_by + + split = None + ratio = None + if cfg is not None: + split = cfg.get("oo_bias_split_by") + ratio = cfg.get("oo_bias_ratio") + if split in (None, ""): + split = os.getenv("HEDGE_PLAN_OO_BIAS_SPLIT_BY") or "budget" + if ratio in (None, ""): + ratio = os.getenv("HEDGE_PLAN_OO_BIAS_RATIO") or "0.7" + return _normalize_oo_bias_split_by(split), _clamp_oo_bias_ratio(ratio) + + +def refresh_oo_sizing_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + """启动前再拉两腿卖一,按对冲预算缓冲重算张数;就地写回 body.leg_*. + + 方案 A:成交价与张数均基于点击启动瞬间的最新卖一/余额. + """ + from lib.exchange.okx_options_lib import fetch_options_trading_usdc, option_buy_liquidity_ok + from lib.hedge_plan.hedge_plan_calc_lib import resolve_oo_budget_usdc, suggest_oo_sheets + + leg_a = dict(body.get("leg_a") or {}) + leg_b = dict(body.get("leg_b") or {}) + inst_a = str(leg_a.get("inst_id") or "").strip() + inst_b = str(leg_b.get("inst_id") or "").strip() + if not inst_a or not inst_b: + return {"ok": False, "msg": "缺少期权合约"} + quote_fn = cfg.get("quote_option_contract") + ex = cfg.get("exchange_options") + if not callable(quote_fn) or ex is None: + return {"ok": False, "msg": "期权报价能力未就绪"} + + qa = quote_fn(ex, inst_a) + if not qa.get("ok"): + return {"ok": False, "msg": qa.get("msg") or "腿A报价失败", "quote_a": qa} + qb = quote_fn(ex, inst_b) + if not qb.get("ok"): + return {"ok": False, "msg": qb.get("msg") or "腿B报价失败", "quote_b": qb} + + for tag, q in (("A", qa), ("B", qb)): + can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz")) + if not can_open: + return { + "ok": False, + "msg": f"腿{tag}: {block_msg or '暂无卖一深度,无法买入'}", + "quote_a": qa, + "quote_b": qb, + } + + trading = fetch_options_trading_usdc(ex) + buf = _hedge_budget_buffer(cfg) + budget_info = resolve_oo_budget_usdc( + trading_usdc=trading, + trade_budget_usdc=cfg.get("trade_budget_usdc"), + buffer_ratio=buf, + ) + if not budget_info.get("ok"): + return { + "ok": False, + "msg": budget_info.get("msg") or "可用预算不足", + "budget": budget_info, + "quote_a": qa, + "quote_b": qb, + } + + mode = str(body.get("oo_sheets_mode") or "same_sheets") + split_by, bias_ratio = _oo_bias_settings(cfg) + opt_a = str( + leg_a.get("opt_type") + or (qa.get("meta") or {}).get("optType") + or qa.get("opt_type") + or "" + ) + opt_b = str( + leg_b.get("opt_type") + or (qb.get("meta") or {}).get("optType") + or qb.get("opt_type") + or "" + ) + sug = suggest_oo_sheets( + mode=mode, + budget_usdc=float(budget_info["budget_usdc"]), + ask_a=float(qa["ask"]), + ct_mult_a=float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01), + ask_sz_a=qa.get("ask_sz"), + opt_type_a=opt_a, + ask_b=float(qb["ask"]), + ct_mult_b=float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01), + ask_sz_b=qb.get("ask_sz"), + opt_type_b=opt_b, + bias_split_by=split_by, + bias_ratio=bias_ratio, + ) + if not sug.get("ok"): + return { + "ok": False, + "msg": sug.get("msg") or "按最新卖一无法建议张数", + "sizing": sug, + "budget": budget_info, + "quote_a": qa, + "quote_b": qb, + } + + prev_a = leg_a.get("sheets") + prev_b = leg_b.get("sheets") + leg_a["sheets"] = int(sug["sheets_a"]) + leg_a["ask"] = float(qa["ask"]) + leg_a["ask_sz"] = qa.get("ask_sz") + leg_a["ct_mult"] = float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01) + if opt_a: + leg_a["opt_type"] = opt_a + leg_b["sheets"] = int(sug["sheets_b"]) + leg_b["ask"] = float(qb["ask"]) + leg_b["ask_sz"] = qb.get("ask_sz") + leg_b["ct_mult"] = float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01) + if opt_b: + leg_b["opt_type"] = opt_b + body["leg_a"] = leg_a + body["leg_b"] = leg_b + return { + "ok": True, + "buffer_ratio": buf, + "budget": budget_info, + "sizing": sug, + "quote_a": qa, + "quote_b": qb, + "prev_sheets_a": prev_a, + "prev_sheets_b": prev_b, + "sheets_a": int(sug["sheets_a"]), + "sheets_b": int(sug["sheets_b"]), + "ask_a": float(qa["ask"]), + "ask_b": float(qb["ask"]), + "premium_est": sug.get("premium_est"), + "msg": ( + f"已按最新卖一重算: A {sug['sheets_a']}张@{qa['ask']} + " + f"B {sug['sheets_b']}张@{qb['ask']} · 预估 {sug.get('premium_est')}U" + ), + } + + +def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + """永期启动前再拉卖一;保险模式张数沿用页面;期权为主时按权利金×0.95重算定仓.""" + from lib.exchange.okx_options_lib import option_buy_liquidity_ok + from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary, size_from_premium + + inst = str(body.get("opt_inst_id") or "").strip() + if not inst: + return {"ok": False, "msg": "缺少期权合约"} + quote_fn = cfg.get("quote_option_contract") + ex = cfg.get("exchange_options") + if not callable(quote_fn) or ex is None: + return {"ok": False, "msg": "期权报价能力未就绪"} + q = quote_fn(ex, inst) + if not q.get("ok"): + return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q} + can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz")) + if not can_open: + return { + "ok": False, + "msg": block_msg or "暂无卖一深度,无法买入", + "quote": q, + } + body["ask"] = float(q["ask"]) + body["ask_sz"] = q.get("ask_sz") + if q.get("ct_mult") is not None: + body["ct_mult"] = float(q.get("ct_mult") or 0.01) + if is_option_primary(body): + cs = float(body.get("contract_size") or 0.01) + get_cs = cfg.get("get_contract_size") + sym = str(body.get("exchange_symbol") or "") + if callable(get_cs) and sym: + try: + cs = float(get_cs(sym) or cs) + except Exception: + pass + sized = size_from_premium( + premium_budget=float(body.get("premium_budget") or 0), + ask=float(body["ask"]), + ct_mult=float(body.get("ct_mult") or 0.01), + ratio=float(body.get("option_perp_ratio") or 2), + contract_size=cs, + ) + if not sized.get("ok"): + return {"ok": False, "msg": sized.get("msg") or "定仓失败", "quote": q, "sizing": sized} + body["sheets"] = sized["sheets"] + body["contracts"] = sized["contracts"] + body["eth_qty"] = sized["eth_qty"] + body["contract_size"] = cs + # 深度不足则缩量 + ask_sz = float(q.get("ask_sz") or 0) + if ask_sz > 0 and float(body["sheets"]) > ask_sz: + body["sheets"] = float(int(ask_sz)) + if body["sheets"] <= 0: + return {"ok": False, "msg": "卖一深度不足 1 张", "quote": q, "sizing": sized} + eth = round(float(body["sheets"]) * float(body.get("ct_mult") or 0.01), 2) + body["eth_qty"] = eth + body["contracts"] = (eth / float(body.get("option_perp_ratio") or 2)) / cs + return { + "ok": True, + "ask": float(q["ask"]), + "ask_sz": q.get("ask_sz"), + "sheets": body.get("sheets"), + "contracts": body.get("contracts"), + "eth_qty": body.get("eth_qty"), + "sizing": sized, + "quote": q, + "msg": ( + f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}ETH / " + f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}" + ), + } + return { + "ok": True, + "ask": float(q["ask"]), + "ask_sz": q.get("ask_sz"), + "sheets": body.get("sheets"), + "quote": q, + "msg": f"已按最新卖一: {body.get('sheets')}张@{q['ask']}", + } + + +def execute_perp_options_start( + cfg: dict[str, Any], + body: dict[str, Any], + *, + dry_run: bool = False, + persist: Optional[Callable[..., Any]] = None, +) -> dict[str, Any]: + refresh = refresh_po_option_quote_before_start(cfg, body) + if not refresh.get("ok"): + return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一失败", "refresh": refresh} + path = build_po_path_plan(body) + results: list[dict[str, Any]] = [] + opt_res: Optional[dict[str, Any]] = None + perp_res: Optional[dict[str, Any]] = None + for step in path: + if step["step"] == "options_buy_limit": + opt_res = _buy_option( + cfg, + inst_id=str(body.get("opt_inst_id") or ""), + sheets=float(body.get("sheets") or 1), + dry_run=dry_run, + ) + results.append({"step": step["step"], **opt_res}) + if not opt_res.get("ok"): + # 永续已成、期权失败 → 可挂 partial 等补开期权 + if ( + perp_res + and perp_res.get("ok") + and not dry_run + and manual_complete_on_partial() + and persist + ): + return _park_partial( + cfg, + plan_type="perp_options", + body=body, + missing_leg="option_hedge", + msg="永续已开、期权失败。计划已挂半腿待补,请在「进行中」补开期权", + path=path, + results=results, + persist=persist, + dry_run=dry_run, + option=None, + perp=perp_res, + ) + return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results} + else: + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + is_option_primary, + perp_direction_for_view, + ) + + opt_primary = is_option_primary(body) + view = str(body.get("direction") or "long") + perp_dir = str(step.get("direction") or ( + perp_direction_for_view(view) if opt_primary else view + )) + attach = bool(step.get("attach_tpsl", not opt_primary)) + tp_v = None if not attach else body.get("tp") + sl_v = None if not attach else body.get("sl") + if attach: + tp_v = float(body["tp"]) + sl_v = float(body["sl"]) + perp_res = _open_perp( + cfg, + symbol=str(body.get("exchange_symbol") or ""), + direction=perp_dir, + contracts=float(body.get("contracts") or 0), + leverage=int(body.get("leverage") or (100 if opt_primary else 10)), + tp=tp_v, + sl=sl_v, + dry_run=dry_run, + attach_tpsl=attach, + ) + results.append({"step": step["step"], **perp_res}) + if not perp_res.get("ok"): + if opt_res and opt_res.get("ok") and not dry_run and partial_auto_close_enabled(): + close_r = _sell_option( + cfg, + inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""), + sheets=float(opt_res.get("sheets") or body.get("sheets") or 1), + ) + results.append({"step": "options_auto_close_on_perp_fail", **close_r}) + msg = perp_res.get("msg") or "永续开仓失败" + _notify_partial(cfg, "perp_options", msg, results) + return { + "ok": False, + "msg": msg, + "path": path, + "results": results, + "partial": True, + } + if ( + opt_res + and opt_res.get("ok") + and not dry_run + and manual_complete_on_partial() + and persist + ): + return _park_partial( + cfg, + plan_type="perp_options", + body=body, + missing_leg="perp", + msg="期权已开、永续失败。计划已挂半腿待补,请在「进行中」补开永续", + path=path, + results=results, + persist=persist, + dry_run=dry_run, + option=opt_res, + perp=None, + ) + msg = perp_res.get("msg") or "永续开仓失败" + if not dry_run: + _notify_partial(cfg, "perp_options", msg, results) + return { + "ok": False, + "msg": msg, + "path": path, + "results": results, + "partial": True, + } + + out = { + "ok": True, + "dry_run": dry_run, + "plan_type": "perp_options", + "path": path, + "results": results, + "option": opt_res, + "perp": perp_res, + "refresh": refresh, + "opened_at": _now(), + } + if persist and not dry_run: + out["plan_id"] = persist(out, body) + return out + + +def execute_options_options_start( + cfg: dict[str, Any], + body: dict[str, Any], + *, + dry_run: bool = False, + persist: Optional[Callable[..., Any]] = None, +) -> dict[str, Any]: + refresh = refresh_oo_sizing_before_start(cfg, body) + if not refresh.get("ok"): + return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一/张数失败", "refresh": refresh} + path = build_oo_path_plan(body) + results: list[dict[str, Any]] = [] + leg_a = body.get("leg_a") or {} + leg_b = body.get("leg_b") or {} + inst_a = str(leg_a.get("inst_id") or "") + inst_b = str(leg_b.get("inst_id") or "") + from lib.options.options_position_limit_lib import option_position_limit_block_msg + + pos_limit_msg = option_position_limit_block_msg( + cfg.get("exchange_options"), + opening_inst_ids=[inst_a, inst_b], + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return { + "ok": False, + "msg": pos_limit_msg, + "path": path, + "results": [], + "refresh": refresh, + } + a_res = _buy_option(cfg, inst_id=inst_a, sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run) + results.append({"step": "options_buy_limit", "leg": "a", **a_res}) + if not a_res.get("ok"): + return { + "ok": False, + "msg": a_res.get("msg") or "腿A开仓失败", + "path": path, + "results": results, + "refresh": refresh, + } + b_res = _buy_option(cfg, inst_id=inst_b, sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run) + results.append({"step": "options_buy_limit", "leg": "b", **b_res}) + if not b_res.get("ok"): + if not dry_run and partial_auto_close_enabled(): + close_r = _sell_option(cfg, inst_id=str(a_res.get("inst_id") or ""), sheets=float(a_res.get("sheets") or 1)) + results.append({"step": "options_auto_close_leg_a", **close_r}) + msg = b_res.get("msg") or "腿B开仓失败" + _notify_partial(cfg, "options_options", msg, results) + return { + "ok": False, + "msg": msg, + "path": path, + "results": results, + "partial": True, + "refresh": refresh, + } + if not dry_run and manual_complete_on_partial() and persist: + out_p = _park_partial( + cfg, + plan_type="options_options", + body=body, + missing_leg="option_b", + msg="腿A已开、腿B失败。计划已挂半腿待补,请在「进行中」补开腿B", + path=path, + results=results, + persist=persist, + dry_run=dry_run, + leg_a=a_res, + leg_b=None, + ) + out_p["refresh"] = refresh + return out_p + msg = b_res.get("msg") or "腿B开仓失败" + if not dry_run: + _notify_partial(cfg, "options_options", msg, results) + return { + "ok": False, + "msg": msg, + "path": path, + "results": results, + "partial": True, + "refresh": refresh, + } + out = { + "ok": True, + "dry_run": dry_run, + "plan_type": "options_options", + "path": path, + "results": results, + "leg_a": a_res, + "leg_b": b_res, + "refresh": refresh, + "opened_at": _now(), + } + if persist and not dry_run: + out["plan_id"] = persist(out, body) + return out + + +def execute_complete_missing_leg( + cfg: dict[str, Any], + plan: dict[str, Any], + legs: list[dict[str, Any]], + start_body: dict[str, Any], + *, + dry_run: bool = False, +) -> dict[str, Any]: + """对 partial 计划补开缺失腿;成功后由调用方把计划升为 active.""" + missing = None + for leg in legs: + if str(leg.get("status") or "").lower() == "pending": + missing = leg + break + if not missing: + return {"ok": False, "msg": "没有待补开的腿"} + role = str(missing.get("leg_role") or "") + results: list[dict[str, Any]] = [] + if role == "perp": + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + is_option_primary, + perp_direction_for_view, + ) + + opt_primary = is_option_primary(start_body) + view = str(start_body.get("direction") or "long") + perp_dir = perp_direction_for_view(view) if opt_primary else view + attach = not opt_primary + res = _open_perp( + cfg, + symbol=str(start_body.get("exchange_symbol") or missing.get("symbol") or ""), + direction=perp_dir, + contracts=float(start_body.get("contracts") or missing.get("size") or 0), + leverage=int(start_body.get("leverage") or (100 if opt_primary else 10)), + tp=None if not attach else float(start_body["tp"]), + sl=None if not attach else float(start_body["sl"]), + dry_run=dry_run, + attach_tpsl=attach, + ) + results.append({"step": "perp_market_open", "complete": True, **res}) + if not res.get("ok"): + return {"ok": False, "msg": res.get("msg") or "补开永续失败", "results": results, "leg_role": role} + return { + "ok": True, + "leg_role": role, + "leg_id": missing.get("id"), + "results": results, + "fill": res, + "opened_at": _now(), + } + if role in ("option_hedge", "option_b", "option_a"): + if role == "option_b": + src = start_body.get("leg_b") or {} + inst = str(src.get("inst_id") or missing.get("inst_id") or "") + sheets = float(src.get("sheets") or missing.get("size") or 1) + elif role == "option_a": + src = start_body.get("leg_a") or {} + inst = str(src.get("inst_id") or missing.get("inst_id") or "") + sheets = float(src.get("sheets") or missing.get("size") or 1) + else: + inst = str(start_body.get("opt_inst_id") or missing.get("inst_id") or "") + sheets = float(start_body.get("sheets") or missing.get("size") or 1) + res = _buy_option(cfg, inst_id=inst, sheets=sheets, dry_run=dry_run) + results.append({"step": "options_buy_limit", "complete": True, "leg_role": role, **res}) + if not res.get("ok"): + return {"ok": False, "msg": res.get("msg") or "补开期权失败", "results": results, "leg_role": role} + return { + "ok": True, + "leg_role": role, + "leg_id": missing.get("id"), + "results": results, + "fill": res, + "opened_at": _now(), + } + return {"ok": False, "msg": f"未知待补腿: {role}"} + + +def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: + pt = (plan_type or "").strip().lower() + if pt == "perp_options": + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + is_option_primary, + validate_option_primary_start, + ) + + if is_option_primary(body): + from lib.hedge_plan.hedge_plan_option_primary_lib import validate_option_primary_watch + + # 以期权为主默认盯盘启动(非现场开仓);显式 watch_entry=0 才走即开校验 + watch = body.get("watch_entry") + if watch in (None, "", True, 1, "1", "true", "yes", "on"): + return validate_option_primary_watch(body) + return validate_option_primary_start(body) + need = ("direction", "entry", "tp", "sl", "contracts", "opt_inst_id", "sheets", "exchange_symbol") + for k in need: + if body.get(k) in (None, ""): + return f"缺少字段: {k}" + try: + if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0: + return "张数必须大于 0" + entry = float(body["entry"]) + tp = float(body["tp"]) + sl = float(body["sl"]) + if tp <= 0 or sl <= 0 or entry <= 0: + return "止盈/止损/入场无效" + except (TypeError, ValueError): + return "数值字段无效" + direction = str(body.get("direction") or "").strip().lower() + if direction not in ("long", "short"): + return "方向须为 long 或 short" + opt_type = str(body.get("opt_type") or "").strip().upper() + if not opt_type: + # 允许从合约名推断 ETH-USD-...-P / -C + inst = str(body.get("opt_inst_id") or "") + if inst.upper().endswith("-P"): + opt_type = "P" + elif inst.upper().endswith("-C"): + opt_type = "C" + if opt_type not in ("P", "C"): + return "缺少期权类型(Put/Call)" + if direction == "long" and opt_type != "P": + return "做多永期对冲须用 Put" + if direction == "short" and opt_type != "C": + return "做空永期对冲须用 Call" + if direction == "long" and not (sl < entry < tp): + return "做多须满足 止损 < 入场 < 止盈" + if direction == "short" and not (tp < entry < sl): + return "做空须满足 止盈 < 入场 < 止损" + from lib.hedge_plan.hedge_plan_moneyness_lib import ( + parse_strike_from_inst, + validate_po_option_moneyness, + ) + + strike = body.get("strike") + if strike in (None, ""): + strike = parse_strike_from_inst(str(body.get("opt_inst_id") or "")) + index_px = body.get("index_px") + if index_px in (None, ""): + index_px = entry + money_err = validate_po_option_moneyness( + opt_type=opt_type, + strike=strike, + index_px=index_px, + ask=body.get("ask"), + hours_to_expiry=body.get("hours_to_expiry"), + ) + if money_err: + return money_err + return None + if pt == "options_options": + a = body.get("leg_a") or {} + b = body.get("leg_b") or {} + if not a.get("inst_id") or not b.get("inst_id"): + return "请选用两条期权腿" + rr_raw = body.get("profit_rr") + if rr_raw not in (None, ""): + try: + rr = float(rr_raw) + except (TypeError, ValueError): + return "盈亏比无效" + if rr <= 0: + return "盈亏比须大于0" + else: + # 兼容旧上/下破 + up = body.get("target_price_up") + down = body.get("target_price_down") + legacy = body.get("target_price") + if up in (None, "") and legacy not in (None, ""): + up = legacy + if down in (None, "") and legacy not in (None, ""): + down = legacy + if up in (None, "") or down in (None, ""): + return "请填写盈亏比" + try: + if float(up) <= float(down): + return "上破目标价必须大于下破目标价" + except (TypeError, ValueError): + return "目标价无效" + from lib.hedge_plan.hedge_plan_moneyness_lib import ( + parse_strike_from_inst, + validate_oo_legs_moneyness, + ) + + def _leg_for_money(leg: dict) -> dict: + strike = leg.get("strike") + if strike in (None, ""): + strike = parse_strike_from_inst(str(leg.get("inst_id") or "")) + opt_type = leg.get("opt_type") + if not opt_type: + inst = str(leg.get("inst_id") or "").upper() + if inst.endswith("-C"): + opt_type = "C" + elif inst.endswith("-P"): + opt_type = "P" + return {"opt_type": opt_type, "strike": strike} + + index_px = body.get("index_px") + money_err = validate_oo_legs_moneyness( + _leg_for_money(a), + _leg_for_money(b), + index_px=index_px, + ) + if money_err: + return money_err + return None + return "未知计划类型" + + +def dump_preview(preview: Any) -> str: + try: + return json.dumps(preview, ensure_ascii=False)[:8000] + except Exception: + return "" + + +def _live_option_pos_sheets(ex: Any, inst_id: str) -> float: + from lib.exchange.okx_options_lib import fetch_option_positions + + inst_id = (inst_id or "").strip() + if not inst_id or ex is None: + return 0.0 + rows = fetch_option_positions(ex) + if rows is None: + return -1.0 # API 失败:未知 + for r in rows: + if str(r.get("instId") or "").strip() != inst_id: + continue + try: + return abs(float(r.get("pos") or 0)) + except (TypeError, ValueError): + return 0.0 + return 0.0 + + +def _sync_plan_status_after_leg_fix(conn: Any, plan_id: int) -> None: + """腿状态校正后:有 open + pending → partial.""" + from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan + + plan = get_plan(conn, int(plan_id)) + if not plan: + return + pst = str(plan.get("status") or "") + if pst not in ("opening", "active", "partial"): + return + legs = get_plan_legs(conn, int(plan_id)) + statuses = [str(l.get("status") or "").lower() for l in legs] + n_open = sum(1 for s in statuses if s == "open") + n_pending = sum(1 for s in statuses if s == "pending") + if n_pending and n_open: + update_plan(conn, int(plan_id), status="partial", close_reason="partial_fail") + + +def reconcile_unfilled_option_legs(cfg: dict[str, Any], conn: Any, plan_id: int) -> list[str]: + """未成交却标 open 的期权腿 → pending(可补开);不显示成持仓.""" + from lib.hedge_plan.hedge_plan_db import get_plan_legs, update_leg + from lib.exchange.okx_options_lib import fetch_option_order + + ex = cfg.get("exchange_options") + notes: list[str] = [] + legs = get_plan_legs(conn, int(plan_id)) + for leg in legs: + role = str(leg.get("leg_role") or "") + if not role.startswith("option"): + continue + st = str(leg.get("status") or "").lower() + if st != "open": + continue + inst = str(leg.get("inst_id") or "").strip() + oid = str(leg.get("exchange_ord_id") or "").strip() + leg_id = int(leg["id"]) + sheets = _live_option_pos_sheets(ex, inst) + if sheets < 0: + continue # 查仓失败不改 + if sheets >= 1: + continue + # 无实仓:再看订单是否已成交(仍挂单只改 pending,不撤单) + if ex is not None and inst and oid: + od = fetch_option_order(ex, inst_id=inst, ord_id=oid) + if od.get("ok"): + acc = float(od.get("acc_fill_sz") or 0) + ostate = str(od.get("state") or "") + if acc >= 1 or ostate == "filled": + continue # 有成交但仓位暂未同步,暂不改 + update_leg( + conn, + leg_id, + status="pending", + close_reason=None, + closed_at=None, + avg_open=None, + premium=0, + ) + notes.append(f"{inst} 无成交却标open→pending") + if notes: + _sync_plan_status_after_leg_fix(conn, int(plan_id)) + return notes + + +def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dict[str, Any]: + """人工结束进行中计划:不自动平仓;未成交腿标 cancelled.""" + from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_leg, update_plan + from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_end + from lib.exchange.okx_options_lib import cancel_option_order + + plan = get_plan(conn, int(plan_id)) + if not plan: + return {"ok": False, "msg": "计划不存在"} + st = str(plan.get("status") or "") + if st not in ("opening", "active", "partial", "watching"): + return {"ok": False, "msg": f"当前状态 {st or '—'} 不可结束"} + + notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id)) + ex = cfg.get("exchange_options") + legs = get_plan_legs(conn, int(plan_id)) + for leg in legs: + lst = str(leg.get("status") or "").lower() + inst = str(leg.get("inst_id") or "").strip() + oid = str(leg.get("exchange_ord_id") or "").strip() + if lst == "pending": + if ex is not None and inst and oid: + cancel_option_order(ex, inst_id=inst, ord_id=oid) + update_leg( + conn, + int(leg["id"]), + status="cancelled", + close_reason="manual_end", + closed_at=_now(), + avg_open=None, + premium=0, + ) + notes.append(f"{inst or leg.get('leg_role')} 待补→cancelled") + + update_plan( + conn, + int(plan_id), + status="closed", + close_reason="manual", + closed_at=_now(), + note=((plan.get("note") or "") + " · 人工结束(不平仓)").strip(" ·")[:500], + ) + plan2 = get_plan(conn, int(plan_id)) + if plan2: + try: + notify_plan_end(cfg, conn, plan2) + except Exception: + pass + return { + "ok": True, + "plan_id": int(plan_id), + "msg": "计划已结束(未自动平仓;有持仓请自行平掉)", + "notes": notes, + } diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py new file mode 100644 index 0000000..89ca8c5 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_register.py @@ -0,0 +1,1439 @@ +"""OKX 对冲计划:P0 测算页与 API 注册.""" +from __future__ import annotations + +import os +from typing import Any, Optional + +from flask import Flask, jsonify, request +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.hedge_plan.hedge_plan_calc_lib import ( + build_options_options_preview, + build_perp_options_preview, + floor_contracts_to_precision, + gate_status, + option_premium_total, + suggest_contracts_from_notional, +) +from lib.market.market_precision_lib import amount_decimals_from_exchange +from lib.trade.position_sizing_lib import ( + compute_full_margin_sizing, + load_position_sizing_mode, +) + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def attach_hedge_plan_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "hedge_plan", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def install_hedge_plan(app: Flask, repo_root: str, app_module: Any) -> None: + attach_hedge_plan_templates(app, repo_root) + cfg = _build_cfg(app_module) + app.extensions["hedge_plan_cfg"] = cfg + register_hedge_plan_routes(app, cfg) + _maybe_start_monitor(cfg) + + +def _build_cfg(app_module: Any) -> dict[str, Any]: + from lib.exchange.okx_options_lib import ( + build_option_chain, + fetch_index_price, + options_header_balances, + place_option_limit_order, + quote_option_contract, + td_mode_for_option_buy, + ) + + def _amount_to_precision(sym: str, amt: float) -> float: + ex = getattr(app_module, "exchange", None) + if ex is None: + return float(amt) + return float(ex.amount_to_precision(sym, amt)) + + cfg = { + "get_db": app_module.get_db, + "login_required": app_module.login_required, + "render_main_page": app_module.render_main_page, + "exchange": getattr(app_module, "exchange", None), + "exchange_options": getattr(app_module, "exchange_options", None), + "get_available_trading_usdt": getattr(app_module, "get_available_trading_usdt", None), + "get_contract_size": getattr(app_module, "get_contract_size", None), + "normalize_exchange_symbol": getattr(app_module, "normalize_exchange_symbol", None), + "ensure_markets_loaded": getattr(app_module, "ensure_markets_loaded", None), + "ensure_okx_live_ready": getattr(app_module, "ensure_okx_live_ready", None), + "place_exchange_order": getattr(app_module, "place_exchange_order", None), + "close_exchange_order": getattr(app_module, "close_exchange_order", None), + "get_live_position_contracts": getattr(app_module, "get_live_position_contracts", None), + "amount_to_precision": _amount_to_precision, + "build_option_chain": build_option_chain, + "options_header_balances": options_header_balances, + "quote_option_contract": quote_option_contract, + "place_option_limit_order": place_option_limit_order, + "td_mode_for_option_buy": td_mode_for_option_buy, + "fetch_index_price": fetch_index_price, + "options_td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), + "btc_leverage": int(getattr(app_module, "BTC_LEVERAGE", 10) or 10), + "alt_leverage": int(getattr(app_module, "ALT_LEVERAGE", 5) or 5), + "full_margin_buffer": float(getattr(app_module, "FULL_MARGIN_BUFFER_RATIO", 0.98) or 0.98), + "funds_decimals": int(getattr(app_module, "FUNDS_DECIMALS", 2) or 2), + "options_enabled": _env_bool("OKX_OPTIONS_ENABLED", False), + "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), + "chain_max_dte": float(os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or "14"), + "perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(), + "options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(), + "trade_budget_usdc": float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC") or "10"), + # 对冲专用缓冲;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立 + "budget_buffer": float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"), + "oo_bias_split_by": _oo_bias_split_by(), + "oo_bias_ratio": _oo_bias_ratio(), + "live_trading": _env_bool("LIVE_TRADING_ENABLED", False), + "send_wechat": getattr(app_module, "send_wechat_msg", None), + } + try: + from lib.sim.hooks import patch_options_cfg + + return patch_options_cfg(cfg) + except Exception: + return cfg + + +def _hedge_enabled() -> bool: + from lib.hedge_plan.okx_trade_mode_lib import hedge_module_enabled + + return hedge_module_enabled() + + +def _show_perp_options() -> bool: + from lib.hedge_plan.okx_trade_mode_lib import show_perp_options + + return show_perp_options() + + +def _show_options_options() -> bool: + from lib.hedge_plan.okx_trade_mode_lib import show_options_options + + return show_options_options() + + +def _oo_close_mode_enabled() -> bool: + return _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True) + + +def _oo_bias_split_by() -> str: + from lib.hedge_plan.hedge_plan_calc_lib import _normalize_oo_bias_split_by + + return _normalize_oo_bias_split_by(os.getenv("HEDGE_PLAN_OO_BIAS_SPLIT_BY") or "budget") + + +def _oo_bias_ratio() -> float: + from lib.hedge_plan.hedge_plan_calc_lib import _clamp_oo_bias_ratio + + return _clamp_oo_bias_ratio(os.getenv("HEDGE_PLAN_OO_BIAS_RATIO") or "0.7") + + +def _normalize_oo_close_mode(raw: Any) -> str: + """方案C关闭时强制 hold_expiry;开启时默认 close_all.""" + if not _oo_close_mode_enabled(): + return "hold_expiry" + v = str(raw or "close_all").strip().lower() + if v in ("hold_expiry", "hold_to_expiry", "expiry", "到期平"): + return "hold_expiry" + return "close_all" + + +def _live_order() -> bool: + return _env_bool("HEDGE_PLAN_LIVE_ORDER", False) + + +def _max_active() -> int: + try: + return max(1, int(os.getenv("MAX_ACTIVE_HEDGE_PLANS") or "1")) + except ValueError: + return 1 + + +def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]: + active = 0 + has_standalone = False + mutual = True + try: + from lib.hedge_plan.hedge_options_exclusive_lib import ( + has_standalone_option_position, + mutual_exclusive_enabled, + ) + from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables + + mutual = mutual_exclusive_enabled() + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + active = count_active_plans(conn) + if mutual: + try: + from lib.exchange.okx_options_lib import fetch_option_positions + + ex = cfg.get("exchange_options") or cfg.get("exchange") + raw = fetch_option_positions(ex) if ex is not None else [] + has_standalone = has_standalone_option_position(conn, raw or []) + except Exception: + has_standalone = True # fail-closed + conn.commit() + finally: + conn.close() + except Exception: + # fail-closed:探测失败视为不可开仓 + active = 10**9 + has_standalone = True + return gate_status( + hedge_enabled=_hedge_enabled(), + sizing_mode=load_position_sizing_mode(), + plan_type=plan_type, + options_enabled=bool(cfg.get("options_enabled")), + live_order=_live_order(), + live_trading=bool(cfg.get("live_trading")) or _env_bool("LIVE_TRADING_ENABLED", False), + active_count=active, + max_active=_max_active(), + show_perp_options=_show_perp_options(), + show_options_options=_show_options_options(), + mutual_exclusive=mutual, + has_standalone_option=has_standalone, + ) + + +def _gates_public(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]: + g = _gates_dict(cfg, plan_type) + g["oo_close_mode_enabled"] = _oo_close_mode_enabled() + g["oo_close_mode_default"] = "close_all" if _oo_close_mode_enabled() else "hold_expiry" + g["oo_bias_split_by"] = _oo_bias_split_by() + g["oo_bias_ratio"] = _oo_bias_ratio() + g["budget_buffer"] = float(cfg.get("budget_buffer") or 0.95) + return g + + +def _maybe_start_monitor(cfg: dict[str, Any]) -> None: + # 始终启动监控线程:单独期权模式下仍需收口遗留 active/partial 计划 + with _hedge_start_lock(): + if cfg.get("hedge_monitor_thread") is not None: + return + try: + secs = float(os.getenv("HEDGE_PLAN_MONITOR_POLL_SECONDS") or "15") + except ValueError: + secs = 15.0 + secs = max(5.0, secs) + + def _loop() -> None: + import time + + from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans + + while True: + try: + tick_active_plans(cfg) + except Exception: + pass + time.sleep(secs) + + import threading + + t = threading.Thread(target=_loop, name="hedge-plan-monitor", daemon=True) + t.start() + cfg["hedge_monitor_thread"] = t + + +_start_lock = None + + +def _hedge_start_lock(): + global _start_lock + if _start_lock is None: + import threading + + _start_lock = threading.Lock() + return _start_lock + + +def _start_body_json(body: dict[str, Any], missing_leg: Optional[str] = None) -> str: + import json + + try: + return json.dumps( + {"start_body": body, "missing_leg": missing_leg}, + ensure_ascii=False, + )[:8000] + except Exception: + return "" + + +def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: + from lib.hedge_plan.hedge_plan_db import ( + get_plan, + get_plan_legs, + init_hedge_plan_tables, + insert_leg, + insert_plan, + ) + from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + is_partial = bool(result.get("partial")) + missing = str(result.get("missing_leg") or "") if is_partial else "" + opt = result.get("option") or {} + perp = result.get("perp") or {} + if is_partial: + opt_ok = missing != "option_hedge" and bool(result.get("option")) + perp_ok = missing != "perp" and bool(result.get("perp")) + else: + opt_ok = True + perp_ok = True + premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0 + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + is_option_primary, + perp_direction_for_view, + ) + + opt_primary = is_option_primary(body) + view = str(body.get("direction") or "long") + perp_dir = ( + str((perp or {}).get("direction") or "") + or (perp_direction_for_view(view) if opt_primary else view) + ) + plan_row = { + "plan_type": "perp_options", + "status": "partial" if is_partial else "active", + "underlying": str(body.get("underlying") or "ETH").upper(), + "direction": view, + "entry_mark": float(body.get("entry") or 0), + "tp": float(body.get("tp") or 0) if not opt_primary else 0, + "sl": float(body.get("sl") or 0) if not opt_primary else 0, + "sizing_mode_at_open": load_position_sizing_mode(), + "perp_size": float((perp or {}).get("contracts") or body.get("contracts") or 0), + "margin": body.get("margin"), + "leverage": float(body.get("leverage") or (100 if opt_primary else 10)), + "premium_total": premium, + "preview_json": _start_body_json(body, missing or None), + "close_reason": "partial_fail" if is_partial else None, + "opened_at": result.get("opened_at"), + "note": (result.get("msg") or "")[:500] if is_partial else None, + "option_primary": 1 if opt_primary else 0, + "perp_direction": perp_dir, + } + if opt_primary: + plan_row.update( + { + "option_target_points": float(body.get("option_target_points") or 0), + "perp_target_points": float(body.get("perp_target_points") or 0), + "option_perp_ratio": float(body.get("option_perp_ratio") or 0), + "premium_budget": float(body.get("premium_budget") or 0), + "strike_interval": float(body.get("strike_interval") or 15), + "min_option_hours": float(body.get("min_option_hours") or 36), + "option_moneyness": str(body.get("moneyness") or body.get("option_moneyness") or ""), + } + ) + plan_id = insert_plan(conn, plan_row) + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": "perp", + "symbol": str(body.get("exchange_symbol") or ""), + "side": perp_dir, + "size": float((perp or {}).get("contracts") or body.get("contracts") or 0), + "avg_open": float(body.get("entry") or 0) if perp_ok else None, + "status": "open" if perp_ok else "pending", + "exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at") if perp_ok else None, + }, + ) + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": "option_hedge", + "inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""), + "opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""), + "strike": (opt or {}).get("strike") or body.get("strike"), + "side": "buy", + "size": float((opt or {}).get("sheets") or body.get("sheets") or 1), + "avg_open": float((opt or {}).get("ask") or body.get("ask") or 0) if opt_ok else None, + "premium": premium if opt_ok else 0, + "ct_mult": float(body.get("ct_mult") or (opt or {}).get("ct_mult") or 0.01), + "status": "open" if opt_ok else "pending", + "exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at") if opt_ok else None, + }, + ) + conn.commit() + if not is_partial: + plan = get_plan(conn, plan_id) + legs = get_plan_legs(conn, plan_id) + if plan: + notify_plan_start(cfg, conn, plan, legs) + conn.commit() + return plan_id + finally: + conn.close() + + +def _persist_po_watching(cfg: dict[str, Any], body: dict[str, Any]) -> int: + """以期权为主:只落库盯盘计划,不下单.""" + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_plan + from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + view = str(body.get("direction") or "long") + money = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower() + plan_id = insert_plan( + conn, + { + "plan_type": "perp_options", + "status": "watching", + "underlying": str(body.get("underlying") or "ETH").upper(), + "direction": view, + "entry_mark": float(body.get("index_px") or body.get("entry") or 0) or None, + "tp": 0, + "sl": 0, + "sizing_mode_at_open": None, + "perp_size": None, + "margin": None, + "leverage": float(body.get("leverage") or 100), + "premium_total": 0, + "preview_json": _start_body_json(body), + "close_reason": None, + "opened_at": None, + "note": "盯盘中:等待杠杆/间隔达标后自动开仓", + "option_primary": 1, + "perp_direction": perp_direction_for_view(view), + "option_target_points": float(body.get("option_target_points") or 0), + "perp_target_points": float(body.get("perp_target_points") or 0), + "option_perp_ratio": float(body.get("option_perp_ratio") or 0), + "premium_budget": float(body.get("premium_budget") or 0), + "strike_interval": float(body.get("strike_interval") or 15), + "min_option_hours": float(body.get("min_option_hours") or 36), + "option_moneyness": money, + "option_leverage": float(body.get("option_leverage") or 0), + }, + ) + conn.commit() + return plan_id + finally: + conn.close() + + +def _activate_watching_po( + cfg: dict[str, Any], + conn: Any, + plan_id: int, + result: dict[str, Any], + body: dict[str, Any], +) -> None: + """盯盘命中后:写入腿并把 watching → active/partial.""" + from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, insert_leg, update_plan + from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start + from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view + + is_partial = bool(result.get("partial")) + missing = str(result.get("missing_leg") or "") if is_partial else "" + opt = result.get("option") or {} + perp = result.get("perp") or {} + if is_partial: + opt_ok = missing != "option_hedge" and bool(result.get("option")) + perp_ok = missing != "perp" and bool(result.get("perp")) + else: + opt_ok = True + perp_ok = True + premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0 + view = str(body.get("direction") or "long") + perp_dir = ( + str((perp or {}).get("direction") or "") + or perp_direction_for_view(view) + ) + update_plan( + conn, + int(plan_id), + status="partial" if is_partial else "active", + entry_mark=float(body.get("entry") or body.get("index_px") or 0) or None, + perp_size=float((perp or {}).get("contracts") or body.get("contracts") or 0), + leverage=float(body.get("leverage") or 100), + premium_total=premium, + preview_json=_start_body_json(body, missing or None), + close_reason="partial_fail" if is_partial else None, + opened_at=result.get("opened_at"), + note=(result.get("msg") or "")[:500] if is_partial else "盯盘达标已开仓", + perp_direction=perp_dir, + ) + insert_leg( + conn, + { + "plan_id": int(plan_id), + "leg_role": "perp", + "symbol": str(body.get("exchange_symbol") or ""), + "side": perp_dir, + "size": float((perp or {}).get("contracts") or body.get("contracts") or 0), + "avg_open": float(body.get("entry") or 0) if perp_ok else None, + "status": "open" if perp_ok else "pending", + "exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at") if perp_ok else None, + }, + ) + insert_leg( + conn, + { + "plan_id": int(plan_id), + "leg_role": "option_hedge", + "inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""), + "opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""), + "strike": (opt or {}).get("strike") or body.get("strike"), + "side": "buy", + "size": float((opt or {}).get("sheets") or body.get("sheets") or 1), + "avg_open": float((opt or {}).get("ask") or body.get("ask") or 0) if opt_ok else None, + "premium": premium if opt_ok else 0, + "ct_mult": float(body.get("ct_mult") or (opt or {}).get("ct_mult") or 0.01), + "status": "open" if opt_ok else "pending", + "exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at") if opt_ok else None, + }, + ) + if not is_partial: + plan = get_plan(conn, int(plan_id)) + legs = get_plan_legs(conn, int(plan_id)) + if plan: + notify_plan_start(cfg, conn, plan, legs) + + +def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: + from lib.hedge_plan.hedge_plan_db import ( + get_plan, + get_plan_legs, + init_hedge_plan_tables, + insert_leg, + insert_plan, + ) + from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + is_partial = bool(result.get("partial")) + missing = str(result.get("missing_leg") or "") if is_partial else "" + a = result.get("leg_a") or {} + b = result.get("leg_b") or {} + a_ok = True if not is_partial else bool(result.get("leg_a")) + b_ok = True if not is_partial else (missing != "option_b" and bool(result.get("leg_b"))) + premium = (float(a.get("premium") or 0) if a_ok else 0.0) + ( + float(b.get("premium") or 0) if b_ok else 0.0 + ) + rr_raw = body.get("profit_rr") + try: + profit_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0 + except (TypeError, ValueError): + profit_rr = 2.0 + if profit_rr <= 0: + profit_rr = 2.0 + # 旧字段兼容:不再要求上/下破;有传则原样落库 + def _opt_float(key: str, *alts: str) -> float | None: + for k in (key, *alts): + v = body.get(k) + if v not in (None, ""): + try: + return float(v) + except (TypeError, ValueError): + continue + return None + + up_f = _opt_float("target_price_up", "target_price") + down_f = _opt_float("target_price_down", "target_price") + plan_id = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "partial" if is_partial else "active", + "underlying": str(body.get("underlying") or "ETH").upper(), + "target_price": up_f, + "target_price_up": up_f, + "target_price_down": down_f, + "profit_rr": profit_rr, + "sizing_mode_at_open": load_position_sizing_mode(), + "premium_total": premium, + "oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")), + "preview_json": _start_body_json(body, missing or None), + "close_reason": "partial_fail" if is_partial else None, + "opened_at": result.get("opened_at"), + "note": (result.get("msg") or "")[:500] if is_partial else None, + }, + ) + for role, res, src, ok in ( + ("option_a", a, body.get("leg_a") or {}, a_ok), + ("option_b", b, body.get("leg_b") or {}, b_ok), + ): + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": role, + "inst_id": str((res or {}).get("inst_id") or src.get("inst_id") or ""), + "opt_type": str((res or {}).get("opt_type") or src.get("opt_type") or ""), + "strike": (res or {}).get("strike") or src.get("strike"), + "side": "buy", + "size": float((res or {}).get("sheets") or src.get("sheets") or 1), + "avg_open": float((res or {}).get("ask") or 0) if ok else None, + "premium": float((res or {}).get("premium") or 0) if ok else 0, + "status": "open" if ok else "pending", + "exchange_ord_id": str((res or {}).get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at") if ok else None, + }, + ) + conn.commit() + if not is_partial: + plan = get_plan(conn, plan_id) + legs = get_plan_legs(conn, plan_id) + if plan: + notify_plan_start(cfg, conn, plan, legs) + conn.commit() + return plan_id + finally: + conn.close() + + +def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: + lr = cfg["login_required"] + + @app.route("/hedge-plan") + @lr + def page_hedge_plan(): + from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled + + redir = redirect_to_embed_shell_if_enabled("hedge_plan") + if redir is not None: + return redir + return cfg["render_main_page"]("hedge_plan") + + @app.route("/api/hedge-plan/gates") + @lr + def api_hedge_gates(): + plan_type = (request.args.get("plan_type") or "perp_options").strip() + return jsonify({"ok": True, **_gates_public(cfg, plan_type)}) + + @app.route("/api/hedge-plan/market") + @lr + def api_hedge_market(): + base = (request.args.get("base") or cfg.get("default_underly") or "ETH").strip().upper() + if base not in ("BTC", "ETH"): + return jsonify({"ok": False, "msg": "对冲计划仅支持 BTC/ETH"}), 400 + direction = (request.args.get("direction") or "long").strip().lower() + if direction not in ("long", "short"): + direction = "long" + option_primary = (request.args.get("option_primary") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + data, err = _fetch_perp_market(cfg, base) + if err: + return jsonify({"ok": False, "msg": err}), 400 + sizing_mode = load_position_sizing_mode() + gates = _gates_dict(cfg, "perp_options") + if option_primary: + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + opt_type_for_view, + perp_direction_for_view, + ) + + suggested = opt_type_for_view(direction) + perp_dir = perp_direction_for_view(direction) + acct_note = "以期权为主:看法腿买期权,永续反向对冲" + else: + suggested = "P" if direction == "long" else "C" + perp_dir = direction + acct_note = "永续腿使用合约(交易)账户可用 USDT" + out = { + "ok": True, + "base": base, + "direction": direction, + "option_primary": option_primary, + "suggested_opt_type": suggested, + "perp_direction": perp_dir, + **data, + "gates": gates, + "sizing_mode": sizing_mode, + "account_kind": "perp", + "account_label": cfg.get("perp_account_label") or "合约账户", + "account_note": acct_note, + } + return jsonify(out) + + @app.route("/api/hedge-plan/options-chain") + @lr + def api_hedge_options_chain(): + if not cfg.get("options_enabled"): + return jsonify({"ok": False, "msg": "期权模块未启用"}), 400 + ex = cfg.get("exchange_options") + if ex is None: + return jsonify({"ok": False, "msg": "期权交易所未初始化"}), 400 + u = (request.args.get("underlying") or cfg.get("default_underly") or "ETH").upper() + # 热更新:链展示天数每次读 env + chain_max_dte = float( + os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") + or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") + or cfg.get("chain_max_dte") + or 14 + ) + try: + chain = cfg["build_option_chain"]( + ex, + u, + max_dte_days=chain_max_dte, + itm_only=False, + itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"), + ) + except Exception as e: + return jsonify({"ok": False, "msg": f"拉取期权链失败: {e}"}), 500 + # 可选:永期以期权为主时按最低剩余小时/行权间隔过滤(仅当请求显式带 option_primary) + # 默认拉链不再带此过滤,避免期期看不到明天到期 + option_primary = (request.args.get("option_primary") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + min_hours = None + strike_interval = None + try: + if request.args.get("min_hours") not in (None, ""): + min_hours = float(request.args.get("min_hours")) + except (TypeError, ValueError): + min_hours = 36.0 if option_primary else None + try: + if request.args.get("strike_interval") not in (None, ""): + strike_interval = float(request.args.get("strike_interval")) + except (TypeError, ValueError): + strike_interval = 15.0 if option_primary else None + if option_primary and min_hours is None: + min_hours = 36.0 + if option_primary and strike_interval is None: + strike_interval = 15.0 + if min_hours is not None or strike_interval is not None: + from lib.hedge_plan.hedge_plan_option_primary_lib import hours_to_expiry_from_ms + + idx = None + try: + idx = float(chain.get("index_px") or 0) or None + except (TypeError, ValueError): + idx = None + filtered = [] + for exp in chain.get("expiries") or []: + h = hours_to_expiry_from_ms(exp.get("exp_time")) + if min_hours is not None and h is not None and h < min_hours: + continue + contracts = [] + for c in exp.get("contracts") or []: + row = dict(c) + row["hours_to_expiry"] = h + if strike_interval is not None and idx and idx > 0: + try: + k = float(row.get("strike") or 0) + except (TypeError, ValueError): + k = 0.0 + if k > 0 and abs(k - idx) > strike_interval + 1e-9: + continue + contracts.append(row) + if contracts: + filtered.append({**exp, "contracts": contracts, "hours_to_expiry": h}) + chain = {**chain, "expiries": filtered} + opt_acct = _options_account_snapshot(cfg) + return jsonify( + { + "ok": True, + **chain, + "underlying": u, + "chain_max_dte_days": chain_max_dte, + "account_kind": "options", + "account_label": cfg.get("options_account_label") or "期权账户", + "account_note": "期权腿使用期权账户(交易 USDC)", + "options_account": opt_acct, + "trade_budget_usdc": cfg.get("trade_budget_usdc"), + "budget_buffer": cfg.get("budget_buffer"), + "option_primary": option_primary, + "min_hours": min_hours, + "strike_interval": strike_interval, + } + ) + + @app.route("/api/hedge-plan/preview", methods=["POST"]) + @lr + def api_hedge_preview(): + body = request.get_json(silent=True) or {} + plan_type = (body.get("plan_type") or "perp_options").strip().lower() + gates = _gates_dict(cfg, plan_type) + if not gates.get("can_preview"): + return jsonify({"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可测算"]), "gates": gates}), 400 + try: + if plan_type == "options_options": + data = _preview_oo(body) + else: + data = _preview_po(body) + except ValueError as e: + return jsonify({"ok": False, "msg": str(e)}), 400 + except Exception as e: + return jsonify({"ok": False, "msg": f"测算失败: {e}"}), 500 + return jsonify({"ok": True, "gates": gates, **data}) + + @app.route("/api/hedge-plan/validate-path", methods=["POST"]) + @lr + def api_hedge_validate_path(): + """只校验下单路径(强制 dry_run),不真实成交.""" + from lib.hedge_plan.hedge_plan_orders_lib import ( + execute_options_options_start, + execute_perp_options_start, + validate_start_body, + ) + + body = request.get_json(silent=True) or {} + plan_type = (body.get("plan_type") or "perp_options").strip().lower() + err = validate_start_body(plan_type, body) + if err: + return jsonify({"ok": False, "msg": err}), 400 + if plan_type == "options_options": + out = execute_options_options_start(cfg, body, dry_run=True) + else: + out = execute_perp_options_start(cfg, body, dry_run=True) + return jsonify(out), (200 if out.get("ok") else 400) + + @app.route("/api/hedge-plan/start", methods=["POST"]) + @lr + def api_hedge_start(): + from lib.hedge_plan.hedge_plan_orders_lib import ( + execute_options_options_start, + execute_perp_options_start, + validate_start_body, + ) + + body = request.get_json(silent=True) or {} + plan_type = (body.get("plan_type") or "perp_options").strip().lower() + dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) + with _hedge_start_lock(): + gates = _gates_dict(cfg, plan_type) + if not dry_run and not gates.get("can_start"): + return jsonify( + {"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可开仓"]), "gates": gates} + ), 400 + err = validate_start_body(plan_type, body) + if err: + return jsonify({"ok": False, "msg": err, "gates": gates}), 400 + # 补齐永续杠杆(以期权为主默认 100;保险模式 BTC/ETH 用 btc_leverage) + if plan_type == "perp_options" and not body.get("leverage"): + from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary + + if is_option_primary(body): + body["leverage"] = 100 + else: + base = str(body.get("underlying") or "ETH").upper() + if base in ("BTC", "ETH"): + body["leverage"] = int(cfg.get("btc_leverage") or 10) + else: + body["leverage"] = int(cfg.get("alt_leverage") or 5) + # 以期权为主:策略启动=盯盘,不现场开仓 + if plan_type == "perp_options": + from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary + + watch = body.get("watch_entry") + watch_on = watch in (None, "", True, 1, "1", "true", "yes", "on") + if is_option_primary(body) and watch_on: + if dry_run: + return jsonify( + { + "ok": True, + "dry_run": True, + "watching": True, + "msg": "dry_run:将创建盯盘计划(不落库)", + "gates": gates, + } + ) + plan_id = _persist_po_watching(cfg, body) + return jsonify( + { + "ok": True, + "watching": True, + "plan_id": plan_id, + "msg": "已启动盯盘,杠杆/间隔达标后自动开仓", + "gates": gates, + } + ) + if plan_type == "options_options": + out = execute_options_options_start( + cfg, + body, + dry_run=dry_run, + persist=(None if dry_run else (lambda r, b: _persist_oo(cfg, r, b))), + ) + else: + out = execute_perp_options_start( + cfg, + body, + dry_run=dry_run, + persist=(None if dry_run else (lambda r, b: _persist_po(cfg, r, b))), + ) + out["gates"] = gates + return jsonify(out), (200 if out.get("ok") else 400) + + @app.route("/api/hedge-plan//end", methods=["POST"]) + @lr + def api_hedge_end_plan(plan_id: int): + """人工结束进行中计划:不自动平仓;未成交腿改为 cancelled.""" + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables + from lib.hedge_plan.hedge_plan_orders_lib import execute_manual_end_plan + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + out = execute_manual_end_plan(cfg, conn, plan_id) + if not out.get("ok"): + return jsonify(out), 400 + conn.commit() + finally: + conn.close() + return jsonify(out) + + @app.route("/api/hedge-plan//complete-leg", methods=["POST"]) + @lr + def api_hedge_complete_leg(plan_id: int): + """半腿待补:手动补开缺失腿,成功后升为 active.""" + import json + + from lib.hedge_plan.hedge_plan_db import ( + get_plan, + get_plan_legs, + init_hedge_plan_tables, + update_leg, + update_plan, + ) + from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start + from lib.hedge_plan.hedge_plan_orders_lib import execute_complete_missing_leg + + body = request.get_json(silent=True) or {} + dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) + if not dry_run and not _hedge_enabled(): + return jsonify({"ok": False, "msg": "当前交易模式为单独期权,不可补开对冲腿"}), 400 + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + plan = get_plan(conn, plan_id) + if not plan: + return jsonify({"ok": False, "msg": "计划不存在"}), 404 + pt = str(plan.get("plan_type") or "") + if pt == "perp_options" and not _show_perp_options(): + return jsonify({"ok": False, "msg": "当前模式非永期对冲,不可补开"}), 400 + if pt == "options_options" and not _show_options_options(): + return jsonify({"ok": False, "msg": "当前模式非期期对冲,不可补开"}), 400 + if str(plan.get("status") or "") != "partial": + return jsonify({"ok": False, "msg": "仅半腿待补(partial)计划可补开"}), 400 + legs = get_plan_legs(conn, plan_id) + start_body: dict[str, Any] = {} + try: + meta = json.loads(plan.get("preview_json") or "{}") + if isinstance(meta, dict): + start_body = dict(meta.get("start_body") or {}) + except Exception: + start_body = {} + if not start_body: + return jsonify({"ok": False, "msg": "缺少开仓参数,无法补开"}), 400 + # 允许请求体覆盖少量字段 + for k in ("contracts", "leverage", "sheets", "tp", "sl"): + if body.get(k) not in (None, ""): + start_body[k] = body.get(k) + out = execute_complete_missing_leg( + cfg, plan, legs, start_body, dry_run=dry_run + ) + if not out.get("ok"): + return jsonify(out), 400 + if dry_run: + return jsonify(out) + fill = out.get("fill") or {} + leg_id = out.get("leg_id") + role = str(out.get("leg_role") or "") + opened_at = out.get("opened_at") + if leg_id: + if role == "perp": + update_leg( + conn, + int(leg_id), + status="open", + size=float(fill.get("contracts") or start_body.get("contracts") or 0), + avg_open=float(start_body.get("entry") or plan.get("entry_mark") or 0), + exchange_ord_id=str(fill.get("exchange_ord_id") or ""), + opened_at=opened_at, + ) + update_plan( + conn, + plan_id, + status="active", + close_reason=None, + note=None, + perp_size=float(fill.get("contracts") or start_body.get("contracts") or 0), + ) + else: + prem = float(fill.get("premium") or 0) + update_leg( + conn, + int(leg_id), + status="open", + size=float(fill.get("sheets") or start_body.get("sheets") or 1), + avg_open=float(fill.get("ask") or 0), + premium=prem, + exchange_ord_id=str(fill.get("exchange_ord_id") or ""), + opened_at=opened_at, + inst_id=str(fill.get("inst_id") or ""), + ) + old_prem = float(plan.get("premium_total") or 0) + update_plan( + conn, + plan_id, + status="active", + close_reason=None, + note=None, + premium_total=old_prem + prem, + ) + conn.commit() + plan2 = get_plan(conn, plan_id) + legs2 = get_plan_legs(conn, plan_id) + if plan2: + notify_plan_start(cfg, conn, plan2, legs2) + conn.commit() + out["plan_id"] = plan_id + out["status"] = "active" + out["plan"] = plan2 + out["legs"] = legs2 + return jsonify(out) + finally: + conn.close() + + @app.route("/api/hedge-plan/list") + @lr + def api_hedge_list(): + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, list_plans + + status = (request.args.get("status") or "").strip() or None + plan_type = (request.args.get("plan_type") or "").strip() or None + underlying = (request.args.get("underlying") or "").strip() or None + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + rows = list_plans( + conn, status=status, plan_type=plan_type, underlying=underlying, limit=80 + ) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, "plans": rows}) + + @app.route("/api/hedge-plan/history") + @lr + def api_hedge_history(): + from lib.hedge_plan.hedge_plan_db import ( + attach_legs_to_plans, + init_hedge_plan_tables, + list_plans, + ) + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + rows = list_plans(conn, status="closed", limit=100) + failed = list_plans(conn, status="failed", limit=50) + cancelled = list_plans(conn, status="cancelled", limit=50) + merged = attach_legs_to_plans(conn, rows + failed + cancelled) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, "plans": merged}) + + @app.route("/api/hedge-plan/active") + @lr + def api_hedge_active(): + from lib.hedge_plan.hedge_plan_db import ( + attach_legs_to_plans, + init_hedge_plan_tables, + list_plans, + ) + from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + rows = [] + for status in ("watching", "opening", "active", "partial"): + rows.extend(list_plans(conn, status=status, limit=80)) + rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) + for row in rows: + if str(row.get("status") or "") == "watching": + continue + try: + reconcile_unfilled_option_legs(cfg, conn, int(row["id"])) + except Exception: + pass + # 校正后可能 status 变化,重新拉一遍 + rows = [] + for status in ("watching", "opening", "active", "partial"): + rows.extend(list_plans(conn, status=status, limit=80)) + rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) + plans = attach_legs_to_plans(conn, rows) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, "plans": plans}) + + @app.route("/api/hedge-plan/stats") + @lr + def api_hedge_stats(): + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, stats_summary + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + s = stats_summary(conn) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, **s}) + + @app.route("/api/hedge-plan/") + @lr + def api_hedge_detail(plan_id: int): + from lib.hedge_plan.hedge_plan_db import ( + get_plan, + get_plan_legs, + init_hedge_plan_tables, + legs_contract_summary, + ) + from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + plan = get_plan(conn, plan_id) + if not plan: + return jsonify({"ok": False, "msg": "计划不存在"}), 404 + # 打开细节时校正:无成交却标 open → cancelled + if str(plan.get("status") or "") in ("opening", "active", "partial"): + reconcile_unfilled_option_legs(cfg, conn, plan_id) + plan = get_plan(conn, plan_id) or plan + legs = get_plan_legs(conn, plan_id) + conn.commit() + finally: + conn.close() + return jsonify( + { + "ok": True, + "plan": plan, + "legs": legs, + "contracts_summary": legs_contract_summary(legs), + } + ) + + @app.route("/api/hedge-plan/", methods=["DELETE"]) + @lr + def api_hedge_delete(plan_id: int): + from lib.hedge_plan.hedge_plan_db import delete_plan, init_hedge_plan_tables + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + out = delete_plan(conn, plan_id) + if not out.get("ok"): + return jsonify(out), 400 + conn.commit() + finally: + conn.close() + return jsonify(out) + + @app.route("/api/hedge-plan/monitor-tick", methods=["POST"]) + @lr + def api_hedge_monitor_tick(): + from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans + + return jsonify(tick_active_plans(cfg)) + + +def _preview_po(body: dict[str, Any]) -> dict[str, Any]: + from lib.hedge_plan.hedge_plan_moneyness_lib import validate_po_option_moneyness + from lib.hedge_plan.hedge_plan_option_primary_lib import ( + build_option_primary_preview, + is_option_primary, + size_from_premium, + validate_option_primary_start, + ) + + if is_option_primary(body): + err = validate_option_primary_start(body) + if err: + raise ValueError(err) + sized = size_from_premium( + premium_budget=float(body.get("premium_budget") or 0), + ask=float(body.get("ask") or 0), + ct_mult=float(body.get("ct_mult") or 0.01), + ratio=float(body.get("option_perp_ratio") or 2), + contract_size=float(body.get("contract_size") or 0.01), + ) + if not sized.get("ok"): + raise ValueError(sized.get("msg") or "定仓失败") + body = dict(body) + body["sheets"] = sized["sheets"] + body["contracts"] = sized["contracts"] + body["eth_qty"] = sized["eth_qty"] + if not body.get("entry"): + body["entry"] = body.get("index_px") or 0 + out = build_option_primary_preview(body) + out["sizing"] = sized + return out + + direction = str(body.get("direction") or "long").lower() + entry = float(body["entry"]) + tp = float(body["tp"]) + sl = float(body["sl"]) + contracts = float(body["contracts"]) + contract_size = float(body.get("contract_size") or 0.01) + opt_type = str(body.get("opt_type") or ("P" if direction == "long" else "C")) + strike = float(body["strike"]) + sheets = float(body.get("sheets") or 1) + ct_mult = float(body.get("ct_mult") or 0.01) + ask = body.get("ask") + premium = body.get("premium_paid") + if premium is None: + if ask is None: + raise ValueError("缺少权利金或卖一价") + premium = option_premium_total(ask=float(ask), sheets=sheets, ct_mult=ct_mult) + index_px = body.get("index_px") + idx_for_money = float(index_px) if index_px is not None else entry + money_err = validate_po_option_moneyness( + opt_type=opt_type, + strike=strike, + index_px=idx_for_money, + ask=ask, + hours_to_expiry=body.get("hours_to_expiry"), + ) + if money_err: + raise ValueError(money_err) + return build_perp_options_preview( + direction=direction, + entry=entry, + tp=tp, + sl=sl, + contracts=contracts, + contract_size=contract_size, + opt_type=opt_type, + strike=strike, + sheets=sheets, + ct_mult=ct_mult, + premium_paid=float(premium), + index_px=float(index_px) if index_px is not None else None, + ) + + +def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: + from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness + + rr_raw = body.get("profit_rr") + profit_rr = None + if rr_raw not in (None, ""): + profit_rr = float(rr_raw) + if profit_rr <= 0: + raise ValueError("盈亏比须大于0") + up = body.get("target_price_up") + down = body.get("target_price_down") + legacy = body.get("target_price") + if up in (None, "") and legacy not in (None, ""): + up = legacy + if down in (None, "") and legacy not in (None, ""): + down = legacy + if profit_rr is None and (up in (None, "") or down in (None, "")): + raise ValueError("请填写盈亏比") + up_f = float(up) if up not in (None, "") else None + down_f = float(down) if down not in (None, "") else None + if profit_rr is None and up_f is not None and down_f is not None and up_f <= down_f: + raise ValueError("上破目标价必须大于下破目标价") + index_px = body.get("index_px") + if index_px in (None, ""): + if up_f is not None and down_f is not None: + index_px = (up_f + down_f) / 2 + else: + raise ValueError("缺少指数价格") + index_px = float(index_px) + leg_a = body.get("leg_a") or {} + leg_b = body.get("leg_b") or {} + for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)): + if not leg.get("strike"): + raise ValueError(f"缺少 {name} 行权价") + if leg.get("premium_paid") is None and leg.get("ask") is not None: + leg["premium_paid"] = option_premium_total( + ask=float(leg["ask"]), + sheets=float(leg.get("sheets") or 1), + ct_mult=float(leg.get("ct_mult") or 0.01), + ) + if leg.get("premium_paid") is None: + raise ValueError(f"缺少 {name} 权利金") + money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px) + if money_err: + raise ValueError(money_err) + return build_options_options_preview( + profit_rr=profit_rr, + target_price_up=up_f, + target_price_down=down_f, + index_px=index_px, + leg_a=leg_a, + leg_b=leg_b, + ) + + +def _fetch_perp_market(cfg: dict[str, Any], base: str) -> tuple[dict[str, Any], str | None]: + ex = cfg.get("exchange") + if ex is None: + return {}, "永续交易所未初始化" + ensure = cfg.get("ensure_markets_loaded") + if callable(ensure): + try: + ensure() + except Exception as e: + return {}, f"加载市场失败: {e}" + norm = cfg.get("normalize_exchange_symbol") + sym = f"{base}/USDT:USDT" + if callable(norm): + try: + sym = norm(f"{base}/USDT") + except Exception: + sym = f"{base}/USDT:USDT" + mark = bid = ask = last = None + try: + t = ex.fetch_ticker(sym) + last = _sf(t.get("last")) + mark = _sf(t.get("info", {}).get("markPx")) if isinstance(t.get("info"), dict) else None + if mark is None: + mark = _sf(t.get("mark")) or last + bid = _sf(t.get("bid")) + ask = _sf(t.get("ask")) + except Exception as e: + return {}, f"拉永续行情失败: {e}" + + cs = 0.01 + get_cs = cfg.get("get_contract_size") + if callable(get_cs): + try: + cs = float(get_cs(sym) or 0.01) + except Exception: + cs = 0.01 + + available = None + get_av = cfg.get("get_available_trading_usdt") + if callable(get_av): + try: + available = get_av() + except Exception: + available = None + + entry = float(mark or last or 0) + sizing = None + suggest_contracts = None + amount_precision = 4 + try: + amount_precision = int(amount_decimals_from_exchange(ex, sym)) + except Exception: + amount_precision = 4 + if available is not None and entry > 0: + sizing, _serr = compute_full_margin_sizing( + symbol=sym, + available_usdt=float(available), + capital_base=float(available), + buffer_ratio=float(cfg.get("full_margin_buffer") or 0.98), + btc_leverage=int(cfg.get("btc_leverage") or 10), + alt_leverage=int(cfg.get("alt_leverage") or 5), + funds_decimals=int(cfg.get("funds_decimals") or 2), + ) + if sizing: + raw_contracts = suggest_contracts_from_notional( + notional=float(sizing["notional_value"]), + entry=entry, + contract_size=cs, + ) + # 优先走交易所 amount_to_precision;失败则按精度位数向下取整 + suggest_contracts = None + try: + precise = float(ex.amount_to_precision(sym, raw_contracts)) + if precise > raw_contracts + 1e-12: + precise = floor_contracts_to_precision(raw_contracts, amount_precision) + suggest_contracts = precise + except Exception: + suggest_contracts = floor_contracts_to_precision(raw_contracts, amount_precision) + + return { + "exchange_symbol": sym, + "mark": mark, + "last": last, + "bid": bid, + "ask": ask, + "contract_size": cs, + "available_usdt": available, + "full_margin_sizing": sizing, + "suggest_contracts": suggest_contracts, + "amount_precision": amount_precision, + "unit_quote": "USDT", + "unit_contracts": "合约张", + "unit_note": "价格单位 USDT;张数=交易所永续合约张(与下单精度一致);名义≈张数×面值×价格", + "entry_ref": entry or None, + }, None + + +def _options_account_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: + """期权账户资金快照(与期权页同源: exchange_options).""" + out: dict[str, Any] = { + "label": cfg.get("options_account_label") or "期权账户", + "trading_usdc": None, + "funding_usdc": None, + "trading_usdt": None, + "funding_usdt": None, + } + ex = cfg.get("exchange_options") + hdr = cfg.get("options_header_balances") + if ex is None or not callable(hdr): + return out + try: + trading_usdc, funding_usdc, funding_usdt, trading_usdt = hdr(ex, force=False) + out.update( + { + "trading_usdc": trading_usdc, + "funding_usdc": funding_usdc, + "trading_usdt": trading_usdt, + "funding_usdt": funding_usdt, + } + ) + except Exception: + pass + return out + + +def _sf(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None diff --git a/lib/hedge_plan/hedge_plan_settle_lib.py b/lib/hedge_plan/hedge_plan_settle_lib.py new file mode 100644 index 0000000..b1d6803 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_settle_lib.py @@ -0,0 +1,217 @@ +"""对冲计划结算辅助:到期内在价值与期权腿收口.""" +from __future__ import annotations + +import os +import time +from datetime import datetime +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history +from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl + +_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai") + + +def _sf(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def leg_exp_ms(leg: dict[str, Any]) -> Optional[int]: + return normalize_option_exp_ms(leg.get("exp_time"), str(leg.get("inst_id") or "")) + + +def leg_is_expired(leg: dict[str, Any], *, now_ms: Optional[int] = None) -> bool: + exp = leg_exp_ms(leg) + if exp is None: + return False + now = int(now_ms if now_ms is not None else time.time() * 1000) + return now >= int(exp) + + +def settle_option_leg_at_spot(leg: dict[str, Any], spot: float) -> float: + """按到期结算口径估算腿盈亏(USDC).""" + premium = float(leg.get("premium") or 0) + strike = _sf(leg.get("strike")) + if strike is None: + return -premium + sheets = float(leg.get("size") or 1) + # ct_mult 未入库时默认 0.01 + ct = float(leg.get("ct_mult") or 0.01) + return float( + option_expiry_pnl( + opt_type=str(leg.get("opt_type") or "P"), + strike=float(strike), + spot=float(spot), + sheets=sheets, + ct_mult=ct, + premium_paid=premium, + ) + ) + + +def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int] = None) -> bool: + opts = [ + x + for x in legs + if str(x.get("leg_role") or "").startswith("option") + and str(x.get("status") or "") in ("open", "hold_to_expiry") + ] + if not opts: + return False + return all(leg_is_expired(x, now_ms=now_ms) for x in opts) + + +def _parse_opened_ms(raw: Any) -> Optional[int]: + """墙钟开仓时间 → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC.""" + if raw is None or raw == "": + return None + s = str(raw).strip() + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)): + try: + dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ) + return int(dt.timestamp() * 1000) + except ValueError: + continue + return None + + +def resolve_option_leg_realized_pnl( + *, + ex: Any = None, + leg: dict[str, Any], + fallback: Optional[float] = None, + fetch_history_fn: Optional[Callable[[str], list[dict[str, Any]]]] = None, + hist_rows: Optional[list[dict[str, Any]]] = None, +) -> tuple[Optional[float], str]: + """ + 期权腿已实现盈亏:优先 OKX positions-history realizedPnl. + 返回 (pnl, source) source=exchange|fallback|none. + """ + inst_id = str(leg.get("inst_id") or "").strip() + open_ms = _parse_opened_ms(leg.get("opened_at")) + rows = hist_rows + if rows is None and inst_id: + try: + if callable(fetch_history_fn): + rows = fetch_history_fn(inst_id) + elif ex is not None: + from lib.exchange.okx_options_lib import fetch_option_position_history + + rows = fetch_option_position_history(ex, inst_id) + except Exception: + rows = None + if rows: + close_ms = _parse_opened_ms(leg.get("closed_at")) + sheets = _sf(leg.get("size")) or _sf(leg.get("sheets")) + info = resolve_option_close_from_history( + rows, open_ms=open_ms, close_ms=close_ms, sheets=sheets + ) + pnl = _sf((info or {}).get("realized_pnl")) if info else None + if pnl is not None: + return round(float(pnl), 4), "exchange" + if fallback is not None: + return round(float(fallback), 4), "fallback" + return None, "none" + + +def backfill_hedge_option_legs_realized_pnl( + conn: Any, + hist_rows: list[dict[str, Any]], + *, + update_plan_fn: Optional[Callable[..., Any]] = None, +) -> dict[str, int]: + """用交易所历史覆盖已平期权腿盈亏,并重算已结束计划合计.""" + from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan + + by_inst: dict[str, list[dict[str, Any]]] = {} + for raw in hist_rows or []: + if not isinstance(raw, dict): + continue + inst = str(raw.get("instId") or "").strip() + if inst: + by_inst.setdefault(inst, []).append(raw) + + legs = conn.execute( + """ + SELECT * FROM hedge_plan_legs + WHERE status = 'closed' + AND inst_id IS NOT NULL AND TRIM(inst_id) != '' + AND (leg_role LIKE 'option%' OR opt_type IS NOT NULL) + ORDER BY id DESC + LIMIT 400 + """ + ).fetchall() + updated_legs = 0 + touched_plans: set[int] = set() + for row in legs: + leg = dict(row) + inst = str(leg.get("inst_id") or "").strip() + if not inst or inst not in by_inst: + continue + pnl, src = resolve_option_leg_realized_pnl( + leg=leg, + hist_rows=by_inst[inst], + fallback=None, + ) + if src != "exchange" or pnl is None: + continue + local = _sf(leg.get("realized_pnl")) + if local is not None and abs(local - pnl) < 1e-6: + continue + conn.execute( + "UPDATE hedge_plan_legs SET realized_pnl=? WHERE id=?", + (pnl, int(leg["id"])), + ) + updated_legs += 1 + touched_plans.add(int(leg["plan_id"])) + + updated_plans = 0 + updater = update_plan_fn or update_plan + for pid in touched_plans: + plan = get_plan(conn, pid) + if not plan or str(plan.get("status") or "") != "closed": + continue + plan_legs = get_plan_legs(conn, pid) + opt_sum = 0.0 + for lg in plan_legs: + role = str(lg.get("leg_role") or "") + if not (role.startswith("option") or lg.get("opt_type")): + continue + if str(lg.get("status") or "") != "closed": + continue + opt_sum += float(_sf(lg.get("realized_pnl")) or 0.0) + perp = float(_sf(plan.get("realized_pnl_perp")) or 0.0) + ptype = str(plan.get("plan_type") or "") + if ptype == "options_options": + total = opt_sum + kwargs: dict[str, Any] = { + "realized_pnl_options": round(opt_sum, 4), + "realized_pnl_total": round(total, 4), + } + else: + total = perp + opt_sum + kwargs = { + "realized_pnl_perp": round(perp, 4), + "realized_pnl_options": round(opt_sum, 4), + "realized_pnl_total": round(total, 4), + } + old_total = _sf(plan.get("realized_pnl_total")) + old_opts = _sf(plan.get("realized_pnl_options")) + if ( + old_total is not None + and abs(old_total - total) < 1e-6 + and old_opts is not None + and abs(old_opts - opt_sum) < 1e-6 + ): + continue + updater(conn, pid, **kwargs) + updated_plans += 1 + return {"legs": updated_legs, "plans": updated_plans} diff --git a/lib/hedge_plan/okx_trade_mode_lib.py b/lib/hedge_plan/okx_trade_mode_lib.py new file mode 100644 index 0000000..2da692c --- /dev/null +++ b/lib/hedge_plan/okx_trade_mode_lib.py @@ -0,0 +1,103 @@ +"""OKX 期权/对冲三选一模式(env: OKX_TRADE_MODE). + +options → 仅单独期权(隐藏对冲导航与对冲 env 配置) +perp_options → 仅永期对冲(不可单独开期权;对冲组数上限 MAX_ACTIVE_HEDGE_PLANS) +options_options → 仅期期对冲(同上) +""" +from __future__ import annotations + +import os +from typing import Optional + +MODE_OPTIONS = "options" +MODE_PERP = "perp_options" +MODE_OO = "options_options" +VALID_MODES = frozenset({MODE_OPTIONS, MODE_PERP, MODE_OO}) + +_ALIASES = { + "option": MODE_OPTIONS, + "standalone": MODE_OPTIONS, + "期权": MODE_OPTIONS, + "单独期权": MODE_OPTIONS, + "po": MODE_PERP, + "perp": MODE_PERP, + "永期": MODE_PERP, + "永期对冲": MODE_PERP, + "oo": MODE_OO, + "期期": MODE_OO, + "期期对冲": MODE_OO, +} + + +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None or str(raw).strip() == "": + return default + return str(raw).strip().lower() in ("1", "true", "yes", "on") + + +def normalize_okx_trade_mode(raw: Optional[str]) -> str: + s = str(raw or "").strip().lower() + if s in VALID_MODES: + return s + if s in _ALIASES: + return _ALIASES[s] + return "" + + +def legacy_infer_okx_trade_mode() -> str: + """未配置 OKX_TRADE_MODE 时,按旧开关推断,避免已有部署行为突变.""" + if not _env_bool("HEDGE_PLAN_ENABLED", False): + return MODE_OPTIONS + show_po = _env_bool("HEDGE_PLAN_SHOW_PERP_OPTIONS", True) + show_oo = _env_bool("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", True) + if show_po and not show_oo: + return MODE_PERP + if show_oo and not show_po: + return MODE_OO + if show_po: + return MODE_PERP + if show_oo: + return MODE_OO + return MODE_OPTIONS + + +def get_okx_trade_mode() -> str: + m = normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE")) + if m: + return m + return legacy_infer_okx_trade_mode() + + +def hedge_module_enabled() -> bool: + return get_okx_trade_mode() in (MODE_PERP, MODE_OO) + + +def show_perp_options() -> bool: + return get_okx_trade_mode() == MODE_PERP + + +def show_options_options() -> bool: + return get_okx_trade_mode() == MODE_OO + + +def standalone_options_open_allowed() -> bool: + return get_okx_trade_mode() == MODE_OPTIONS + + +def mode_label(mode: Optional[str] = None) -> str: + m = mode or get_okx_trade_mode() + return { + MODE_OPTIONS: "单独期权", + MODE_PERP: "永期对冲", + MODE_OO: "期期对冲", + }.get(m, m or "—") + + +def block_standalone_open_by_mode_msg() -> Optional[str]: + if standalone_options_open_allowed(): + return None + return ( + f"当前交易模式为「{mode_label()}」,不可单独开期权;" + "请在 env「交易模式」切换为「单独期权」" + ) diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html new file mode 100644 index 0000000..5c809ef --- /dev/null +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -0,0 +1,408 @@ +
    + {% if not hedge_plan_enabled %} +
    对冲计划未启用:请在 env配置 → 对冲计划 打开 HEDGE_PLAN_ENABLED(可热更).
    + {% endif %} + {% if not options_enabled %} +
    期权模块未启用,无法拉期权链.请先配置期权账户.
    + {% endif %} + {% if hedge_plan_enabled and not hedge_plan_show_perp_options and not hedge_plan_show_options_options %} +
    永期与期期 Tab 均已隐藏:请在 env「期权/对冲模式」切换交易模式;进行中/历史仍可查看.
    + {% endif %} + +
    +
    +

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

    + +
    +
    + {% if hedge_plan_show_perp_options %} + + {% endif %} + {% if hedge_plan_show_options_options %} + + {% endif %} + + + +
    +

    +

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

    +
    + +
    +
    +
    +

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

    +
    + 规则说明 +
    +

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

    +

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

    +

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

    +

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

    +
    +
    +
    + + +
    +
    +
    + + +
    +
    + +
    +
    +

    资金与杠杆配置

    +
    + + + +
    +
    +
    +

    选约条件

    +
    + + + + +
    +
    +
    +

    出场条件

    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    永续行情 合约账户

    +
    + 标记 — +
    +

    加载中…

    + +
    +
    +

    期权 · Call 期权账户

    +
    + + + + + 指数 — +
    +
    + + + + + + + + + + + + + + +
    行权价实虚值杠杆卖一/张买一/张操作
    请刷新期权链
    +
    +
    + + + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + + + + + + + + + + + +
    + diff --git a/lib/instance/__init__.py b/lib/instance/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/instance/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/instance/focus_chart_lib.py b/lib/instance/focus_chart_lib.py new file mode 100644 index 0000000..3dadc63 --- /dev/null +++ b/lib/instance/focus_chart_lib.py @@ -0,0 +1,187 @@ +"""实盘/关键位放大 K 线:订单元数据与交易所浮盈,价格展示精度.""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.market.ohlcv_lib import ( + normalize_price_tick, + price_tick_from_market, + round_ohlcv_bars_to_tick, +) +from lib.trade.order_monitor_display_lib import ( + apply_order_live_price_display, + apply_order_price_display_fields, +) + + +def resolve_kline_price_tick( + exchange: Any, + exchange_symbol: str, + *, + ensure_markets_fn: Callable[[], None], +) -> Optional[float]: + """交易所最小价格变动单位,供 lightweight-charts 右侧刻度与标记线对齐.""" + if not exchange_symbol: + return None + try: + ensure_markets_fn() + return normalize_price_tick(price_tick_from_market(exchange, exchange_symbol)) + except Exception: + return None + + +def align_candles_to_price_tick( + candles: list[dict[str, Any]], + price_tick: Optional[float], +) -> None: + if price_tick is not None and candles: + round_ohlcv_bars_to_tick(candles, price_tick) + + +def kline_api_price_fields( + exchange: Any, + exchange_symbol: str, + candles: list[dict[str, Any]], + *, + ensure_markets_fn: Callable[[], None], +) -> dict[str, Any]: + tick = resolve_kline_price_tick( + exchange, exchange_symbol, ensure_markets_fn=ensure_markets_fn + ) + align_candles_to_price_tick(candles, tick) + return {"price_tick": tick} + + +def load_swap_positions_for_order_kline( + exchange: Any, + *, + private_configured: bool, + ensure_markets_fn: Callable[[], None], + settle: str = "usdt", +) -> list: + if not private_configured: + return [] + try: + ensure_markets_fn() + try: + return exchange.fetch_positions(None, {"settle": settle}) or [] + except Exception: + return exchange.fetch_positions() or [] + except Exception: + return [] + + +def metrics_for_order_item( + order_item: dict[str, Any], + positions: list, + *, + resolve_ex_sym_fn: Callable[[Any], str], + select_live_fn: Callable[[list, str, str], Any], + parse_metrics_fn: Callable[..., Optional[dict]], +) -> Optional[dict]: + if not positions: + return None + ex_sym = resolve_ex_sym_fn(order_item) + direction = order_item.get("direction") or "long" + prow = select_live_fn(positions, ex_sym, direction) + if not prow: + return None + lev = order_item.get("leverage") + return parse_metrics_fn(prow, order_leverage=lev) + + +def build_order_kline_order_payload( + order_item: dict[str, Any], + *, + ticker_price: Any, + format_price_fn: Callable[[Any, Any], str], + calc_pnl_fn: Callable[..., float], + calc_rr_ratio_fn: Callable[..., Optional[float]], + ex_metrics: Optional[dict] = None, +) -> dict[str, Any]: + sym = order_item.get("symbol") or "" + direction = order_item.get("direction") or "long" + margin = float(order_item.get("margin_capital") or 0) + leverage = float(order_item.get("leverage") or 0) + entry = float(order_item.get("trigger_price") or 0) + + float_pnl = 0.0 + float_pct = 0.0 + if ticker_price and entry > 0: + float_pnl = float( + calc_pnl_fn(direction, entry, ticker_price, margin, leverage) + ) + float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0.0 + + px_for_fmt = ticker_price + mark_raw = None + if ex_metrics and ex_metrics.get("mark_price") is not None: + mark_raw = ex_metrics["mark_price"] + try: + px_for_fmt = float(mark_raw) + except (TypeError, ValueError): + pass + + if ex_metrics and ex_metrics.get("unrealized_pnl") is not None: + float_pnl = round(float(ex_metrics["unrealized_pnl"]), 2) + denom = ex_metrics.get("initial_margin") or margin + float_pct = ( + round((float_pnl / float(denom)) * 100, 4) + if denom and float(denom) > 0 + else float_pct + ) + + payload: dict[str, Any] = { + "id": order_item["id"], + "symbol": sym, + "direction": direction, + "trigger_price": order_item.get("trigger_price"), + "stop_loss": order_item.get("stop_loss"), + "take_profit": order_item.get("take_profit"), + "trigger_price_display": format_price_fn(sym, order_item.get("trigger_price")), + "stop_loss_display": format_price_fn(sym, order_item.get("stop_loss")), + "take_profit_display": format_price_fn(sym, order_item.get("take_profit")), + "margin_capital": order_item.get("margin_capital"), + "leverage": order_item.get("leverage"), + "position_ratio": order_item.get("position_ratio"), + "breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)), + "current_price": round(float(px_for_fmt), 8) if px_for_fmt is not None else None, + "float_pnl": round(float(float_pnl), 2), + "float_pct": float_pct, + } + apply_order_price_display_fields( + payload, + direction=direction, + entry_price=order_item.get("trigger_price"), + initial_stop_loss=order_item.get("initial_stop_loss"), + stop_loss=order_item.get("stop_loss"), + take_profit=order_item.get("take_profit"), + calc_rr_ratio_fn=calc_rr_ratio_fn, + ) + apply_order_live_price_display( + payload, + sym, + ticker_price, + mark_raw, + format_price_fn, + ) + payload["current_price_display"] = payload.get("price_display") or ( + format_price_fn(sym, px_for_fmt) if px_for_fmt is not None else None + ) + return payload + + +def enrich_key_kline_response( + *, + symbol: str, + current_price: Any, + key_info: Optional[dict[str, Any]], + format_price_fn: Callable[[Any, Any], str], +) -> tuple[Any, Optional[dict[str, Any]]]: + price_display = format_price_fn(symbol, current_price) if current_price is not None else None + if key_info is None: + return price_display, None + enriched = dict(key_info) + enriched["upper_display"] = format_price_fn(symbol, key_info.get("upper")) + enriched["lower_display"] = format_price_fn(symbol, key_info.get("lower")) + return price_display, enriched diff --git a/lib/instance/instance_dashboard_cache.py b/lib/instance/instance_dashboard_cache.py new file mode 100644 index 0000000..bcb9faf --- /dev/null +++ b/lib/instance/instance_dashboard_cache.py @@ -0,0 +1,175 @@ +"""实例数据看板:后台定时聚合,内存快照,SSE 版本通知(对齐中控 dashboard_store).""" +from __future__ import annotations + +import json +import os +import queue +import threading +from collections.abc import Callable, Iterator +from typing import Any + +INSTANCE_DASHBOARD_POLL_SEC = float(os.getenv("INSTANCE_DASHBOARD_POLL_SEC", "5")) +INSTANCE_DASHBOARD_SSE_HEARTBEAT_SEC = float(os.getenv("INSTANCE_DASHBOARD_SSE_HEARTBEAT_SEC", "25")) + +BuildFn = Callable[[], dict[str, Any]] + + +class InstanceDashboardStore: + def __init__(self) -> None: + self._lock = threading.RLock() + self.version = 0 + self.payload: dict[str, Any] | None = None + self.aggregating = False + self.last_error: str | None = None + self._subscribers: list[queue.Queue[str | None]] = [] + self._stop = threading.Event() + self._refresh = threading.Event() + self._thread: threading.Thread | None = None + self._build_fn: BuildFn | None = None + + def start(self, build_fn: BuildFn) -> None: + self._build_fn = build_fn + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread( + target=self._loop, + daemon=True, + name="instance-dashboard-poll", + ) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + self._refresh.set() + self._broadcast(close=True) + + def request_refresh(self) -> None: + self._refresh.set() + + def snapshot_dict(self) -> dict[str, Any]: + with self._lock: + p = dict(self.payload or {}) + ver = self.version + aggregating = self.aggregating + err = self.last_error + if not p: + return { + "ok": False, + "dashboard_version": ver, + "aggregating": aggregating, + "error": err, + "msg": err or "看板快照尚未就绪", + "poll_interval_sec": INSTANCE_DASHBOARD_POLL_SEC, + } + return { + **p, + "dashboard_version": ver, + "aggregating": aggregating, + "error": err or p.get("error"), + "poll_interval_sec": INSTANCE_DASHBOARD_POLL_SEC, + } + + def event_dict(self) -> dict[str, Any]: + with self._lock: + p = self.payload or {} + return { + "dashboard_version": self.version, + "updated_at": p.get("updated_at"), + "aggregating": self.aggregating, + "ok": p.get("ok", True) if self.payload else False, + "error": self.last_error or p.get("error"), + } + + def _loop(self) -> None: + assert self._build_fn is not None + while not self._stop.is_set(): + self._aggregate_once(self._build_fn) + if self._stop.is_set(): + break + self._refresh.clear() + # 周期等待,可被 request_refresh 提前唤醒 + self._refresh.wait(timeout=INSTANCE_DASHBOARD_POLL_SEC) + + def _aggregate_once(self, build_fn: BuildFn) -> None: + with self._lock: + self.aggregating = True + self._broadcast() + try: + result = build_fn() + if not isinstance(result, dict): + result = {"ok": False, "msg": "聚合返回无效"} + except Exception as e: + result = {"ok": False, "msg": str(e), "error": "aggregate_failed"} + with self._lock: + self.version += 1 + prev = self.payload if isinstance(self.payload, dict) else None + if result.get("ok") is False and prev and prev.get("ok"): + self.payload = prev + self.last_error = str(result.get("msg") or result.get("error") or "aggregate_failed") + else: + self.payload = result + self.last_error = ( + None + if result.get("ok") is not False + else str(result.get("msg") or result.get("error") or "aggregate_failed") + ) + self.aggregating = False + self._broadcast() + + def _broadcast(self, *, close: bool = False) -> None: + with self._lock: + subs = list(self._subscribers) + event = None if close else json.dumps(self.event_dict(), ensure_ascii=False) + dead: list[queue.Queue[str | None]] = [] + for q in subs: + try: + q.put_nowait(None if close else event) + except queue.Full: + try: + q.get_nowait() + except queue.Empty: + pass + try: + q.put_nowait(event) + except queue.Full: + dead.append(q) + except Exception: + dead.append(q) + if dead: + with self._lock: + for q in dead: + if q in self._subscribers: + self._subscribers.remove(q) + + def iter_sse(self) -> Iterator[str]: + q: queue.Queue[str | None] = queue.Queue(maxsize=32) + with self._lock: + self._subscribers.append(q) + try: + yield _sse_frame(self.event_dict()) + while True: + try: + raw = q.get(timeout=INSTANCE_DASHBOARD_SSE_HEARTBEAT_SEC) + except queue.Empty: + yield ": heartbeat\n\n" + continue + if raw is None: + break + try: + data = json.loads(raw) + except Exception: + data = self.event_dict() + yield _sse_frame(data) + finally: + with self._lock: + if q in self._subscribers: + self._subscribers.remove(q) + + +def _sse_frame(data: dict[str, Any]) -> str: + body = json.dumps(data, ensure_ascii=False) + return f"event: dashboard\ndata: {body}\n\n" + + +instance_dashboard_store = InstanceDashboardStore() diff --git a/lib/instance/instance_dashboard_lib.py b/lib/instance/instance_dashboard_lib.py new file mode 100644 index 0000000..70df9c2 --- /dev/null +++ b/lib/instance/instance_dashboard_lib.py @@ -0,0 +1,571 @@ +"""实例数据看板:本户活跃监控 / 持仓只读聚合.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Callable, Optional + + +def _row_dict(row: Any) -> dict[str, Any]: + if row is None: + return {} + if isinstance(row, dict): + return dict(row) + try: + return dict(row) + except Exception: + return {} + + +def _safe_float(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _dir_label(direction: Any) -> str: + d = str(direction or "").strip().lower() + if d == "short": + return "做空" + if d == "long": + return "做多" + return str(direction or "-") + + +def _format_order_item(od: dict[str, Any]) -> dict[str, Any]: + try: + from lib.trade.trade_labels_lib import apply_order_monitor_source_labels + + od = apply_order_monitor_source_labels(od) + except Exception: + pass + try: + from lib.trade.entry_model_lib import enrich_entry_model_display + + enrich_entry_model_display(od) + except Exception: + pass + sym = od.get("exchange_symbol") or od.get("symbol") or "-" + direction = str(od.get("direction") or "long").lower() + mt = od.get("monitor_type_display") or od.get("monitor_type") or "" + kst = od.get("key_signal_type") or "" + title = f"{sym} {_dir_label(direction)}" + bits = [x for x in (mt, kst) if x] + subtitle = " · ".join(bits) if bits else "" + entry = _safe_float(od.get("trigger_price")) + sl = _safe_float(od.get("stop_loss")) + tp = _safe_float(od.get("take_profit")) + return { + "id": od.get("id"), + "kind": "order", + "tab": "trade", + "title": title, + "subtitle": subtitle, + "symbol": sym, + "price_symbol": od.get("symbol") or sym, + "direction": direction, + "direction_label": _dir_label(direction), + "entry": entry, + "mark_price": None, + "contracts": _safe_float(od.get("order_amount")), + "tp_profit": None, + "float_pnl": None, + "stop_loss": sl, + "take_profit": tp, + "status": od.get("status") or "active", + } + + +OPTIONS_SOURCE_LABELS = { + "option": "纯期权", + "perp_options": "永期对冲", + "options_options": "期期对冲", +} + +HEDGE_ACTIVE_STATUSES = frozenset({"opening", "active", "partial"}) + + +def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]: + """根据进行中对冲计划腿判定来源;默认纯期权. 返回 (source, label, plan_id).""" + default = ("option", OPTIONS_SOURCE_LABELS["option"], None) + if not inst_id or not _table_exists(conn, "hedge_plans") or not _table_exists(conn, "hedge_plan_legs"): + return default + try: + row = conn.execute( + """ + SELECT p.plan_type, p.id + FROM hedge_plans p + JOIN hedge_plan_legs l ON l.plan_id = p.id + WHERE p.status IN ('opening', 'active', 'partial') + AND l.status = 'open' + AND l.inst_id = ? + ORDER BY p.id DESC + LIMIT 1 + """, + (inst_id,), + ).fetchone() + except Exception: + return default + if not row: + return default + d = _row_dict(row) + pt = str(d.get("plan_type") or "").strip() + try: + plan_id = int(d["id"]) if d.get("id") is not None else None + except (TypeError, ValueError): + plan_id = None + if pt in OPTIONS_SOURCE_LABELS and pt != "option": + return pt, OPTIONS_SOURCE_LABELS[pt], plan_id + return default + + +def _format_profit_exit_mult(mult: Any) -> str: + try: + n = float(mult) + except (TypeError, ValueError): + return "1倍" + if n <= 0: + return "1倍" + if abs(n - round(n)) < 1e-9: + return f"{int(round(n))}倍" + return f"{n:g}倍" + + +def _format_options_target(p: dict[str, Any]) -> str: + hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None + opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() + if hedge: + rr = _safe_float(hedge.get("profit_rr")) + pid = hedge.get("plan_id") + if rr is not None and rr > 0: + return f"对冲#{pid} 盈亏比 {rr:g}" if pid is not None else f"盈亏比 {rr:g}" + ot = str(hedge.get("opt_type") or opt_type).upper() + side = "Put ≤" if ot == "P" else "Call ≥" + tgt = _safe_float(hedge.get("target_index")) + if tgt is not None: + return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}" + parts: list[str] = [] + tgt = _safe_float(p.get("target_index")) + if tgt is not None and tgt > 0: + side = "Put ≤" if opt_type == "P" else "Call ≥" + parts.append(f"{side} {tgt:g}") + if p.get("profit_exit_enabled"): + parts.append(_format_profit_exit_mult(p.get("profit_exit_mult"))) + if parts: + return " · ".join(parts) + return "—" + + +def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]: + inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" + opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() + label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") + # 看板期权列:优先买一净盈亏,残档回退交易所 upl + pnl = None + try: + from lib.options.options_positions_lib import display_pnl_from_option_row + + pnl = display_pnl_from_option_row(p) + except Exception: + pnl = None + pos = _safe_float(p.get("pos")) + exp_ms = p.get("exp_time_ms") + if exp_ms is None: + exp_ms = p.get("exp_time") + try: + exp_ms = int(float(exp_ms)) if exp_ms not in (None, "") else None + except (TypeError, ValueError): + exp_ms = None + if conn is not None: + source_key, source_label, source_plan_id = _resolve_options_source(conn, inst) + else: + source_key, source_label, source_plan_id = "option", OPTIONS_SOURCE_LABELS["option"], None + return { + "id": inst, + "kind": "options", + "tab": "options", + "title": f"{inst} {label}", + "subtitle": f"张数 {pos if pos is not None else '-'}", + "inst_id": inst, + "opt_type": opt_type, + "opt_type_label": label, + "source": source_key, + "source_label": source_label, + "source_plan_id": source_plan_id, + "pos": pos, + "exp_time_ms": exp_ms, + "target_monitor": _format_options_target(p), + "pnl": round(pnl, 4) if pnl is not None else None, + } + + +def _format_hedge_item(plan: dict[str, Any]) -> dict[str, Any]: + pid = plan.get("id") + underlying = plan.get("underlying") or "-" + plan_type = plan.get("plan_type") or "" + status = str(plan.get("status") or "") + summary = plan.get("contracts_summary") or "" + plan_type_label = OPTIONS_SOURCE_LABELS.get(plan_type, plan_type) + active = status in HEDGE_ACTIVE_STATUSES + status_label = "进行中" if active else (status or "—") + return { + "id": pid, + "kind": "hedge_plan", + "tab": "hedge_plan", + "title": f"对冲 #{pid} {underlying}", + "subtitle": " · ".join(x for x in (plan_type_label, status_label, summary) if x), + "underlying": underlying, + "plan_type": plan_type, + "plan_type_label": plan_type_label, + "status": status, + "status_label": status_label, + "status_active": active, + "contracts_summary": summary, + } + + +def _format_key_item(kd: dict[str, Any]) -> dict[str, Any]: + sym = kd.get("exchange_symbol") or kd.get("symbol") or "-" + direction = str(kd.get("direction") or "long").lower() + signal = kd.get("signal_type") or kd.get("key_signal_type") or kd.get("monitor_type") or "" + upper = _safe_float(kd.get("upper")) + lower = _safe_float(kd.get("lower")) + subtitle_parts = [] + if signal: + subtitle_parts.append(str(signal)) + if upper is not None or lower is not None: + subtitle_parts.append( + f"上{upper if upper is not None else '-'} / 下{lower if lower is not None else '-'}" + ) + return { + "id": kd.get("id"), + "kind": "key", + "tab": "key_monitor", + "title": f"{sym} {_dir_label(direction)}", + "subtitle": " · ".join(subtitle_parts), + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "upper": upper, + "lower": lower, + "status": kd.get("status") or "active", + } + + +def _format_trend_item(td: dict[str, Any]) -> dict[str, Any]: + sym = td.get("exchange_symbol") or td.get("symbol") or "-" + direction = str(td.get("direction") or "long").lower() + status = td.get("status") or "active" + entry = _safe_float(td.get("entry_price") or td.get("trigger_price")) + return { + "id": td.get("id"), + "kind": "trend", + "tab": "strategy", + "title": f"趋势回调 {sym} {_dir_label(direction)}", + "subtitle": f"状态 {status}", + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "entry": entry, + "status": status, + } + + +def _format_roll_item(rd: dict[str, Any]) -> dict[str, Any]: + sym = rd.get("exchange_symbol") or rd.get("symbol") or "-" + direction = str(rd.get("direction") or "long").lower() + status = rd.get("status") or "active" + return { + "id": rd.get("id"), + "kind": "roll", + "tab": "strategy", + "title": f"顺势加仓 {sym} {_dir_label(direction)}", + "subtitle": f"状态 {status}", + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "status": status, + } + + +def _table_exists(conn, name: str) -> bool: + try: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1", + (name,), + ).fetchone() + return bool(row) + except Exception: + return False + + +def collect_orders(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "order_monitors"): + return [] + rows = conn.execute( + "SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall() + return [_format_order_item(_row_dict(r)) for r in rows] + + +def collect_keys(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "key_monitors"): + return [] + rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + return [_format_key_item(_row_dict(r)) for r in rows] + + +def collect_trends(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "trend_pullback_plans"): + return [] + try: + rows = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall() + except Exception: + return [] + return [_format_trend_item(_row_dict(r)) for r in rows] + + +def collect_rolls(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "roll_groups") or not _table_exists(conn, "order_monitors"): + return [] + try: + rows = conn.execute( + """SELECT g.* FROM roll_groups g + INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active' + WHERE g.status='active' ORDER BY g.id DESC""" + ).fetchall() + except Exception: + return [] + return [_format_roll_item(_row_dict(r)) for r in rows] + + +def collect_hedge_plans(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "hedge_plans"): + return [] + try: + from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans + + rows: list[dict[str, Any]] = [] + for status in ("opening", "active", "partial"): + rows.extend(list_plans(conn, status=status, limit=80)) + rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) + plans = attach_legs_to_plans(conn, rows) + return [_format_hedge_item(p) for p in plans] + except Exception: + return [] + + +def collect_options_items( + fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, + *, + conn=None, +) -> list[dict[str, Any]]: + if not callable(fetch_options_positions): + return [] + try: + raw = fetch_options_positions() or [] + except Exception: + return [] + pe_map: dict[str, dict[str, Any]] = {} + tgt_map: dict[str, dict[str, Any]] = {} + hedge_map: dict[str, dict[str, Any]] = {} + if conn is not None: + try: + from lib.options.options_profit_exit_lib import profit_exit_by_inst + from lib.options.options_target_lib import targets_by_inst + from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst + + pe_map = profit_exit_by_inst(conn) + tgt_map = targets_by_inst(conn) + hedge_map = active_options_targets_by_inst(conn) + except Exception: + pe_map, tgt_map, hedge_map = {}, {}, {} + out: list[dict[str, Any]] = [] + for p in raw: + if not isinstance(p, dict): + continue + row = dict(p) + inst = str(row.get("inst_id") or row.get("instId") or "").strip() + mon = tgt_map.get(inst) + if mon: + row["target_index"] = mon.get("target_index") + pe = pe_map.get(inst) + if pe: + row["profit_exit_enabled"] = pe.get("profit_exit_enabled") + row["profit_exit_mult"] = pe.get("profit_exit_mult") + hedge = hedge_map.get(inst) + if hedge: + row["hedge_plan_target"] = hedge + if not mon: + row["target_index"] = hedge.get("target_index") + out.append(_format_options_item(row, conn=conn)) + return out + + +def _swap_symbol_candidates(row: dict[str, Any]) -> list[str]: + """优先永续 symbol(含 settle),避免用现货 BTC/USDT 查到 contractSize=1.""" + raw: list[str] = [] + for key in ("symbol", "exchange_symbol", "price_symbol"): + s = str(row.get(key) or "").strip() + if s and s not in raw: + raw.append(s) + swapish: list[str] = [] + others: list[str] = [] + for s in raw: + if ":" in s: + swapish.append(s) + continue + others.append(s) + if "/" in s: + base, quote = s.split("/", 1) + q = quote.split(":")[0].strip() + if base and q: + swapish.append(f"{base}/{q}:{q}") + out: list[str] = [] + for s in swapish + others: + if s and s not in out: + out.append(s) + return out + + +def _resolve_contract_size( + row_or_sym: Any, + *, + get_contract_size: Optional[Callable[[str], Any]] = None, +) -> float: + if not callable(get_contract_size): + return 1.0 + if isinstance(row_or_sym, dict): + candidates = _swap_symbol_candidates(row_or_sym) + else: + sym = str(row_or_sym or "").strip() + candidates = _swap_symbol_candidates({"symbol": sym}) if sym else [] + for sym in candidates: + try: + cs = float(get_contract_size(sym) or 0) + if cs > 0: + return cs + except Exception: + continue + return 1.0 + + +def _fill_order_pnl_fields(row: dict[str, Any], *, mark: Optional[float], contract_size: float) -> None: + """按线性 U 本位补看板「盈利金额 / 浮盈」.""" + direction = str(row.get("direction") or "long").lower() + entry = _safe_float(row.get("entry")) + contracts = _safe_float(row.get("contracts")) + tp = _safe_float(row.get("take_profit")) + if entry is None or contracts is None or contracts <= 0: + return + cs = float(contract_size) if contract_size and contract_size > 0 else 1.0 + if mark is not None: + try: + from lib.market.position_metrics_lib import estimate_linear_swap_upnl_usdt + + upnl = estimate_linear_swap_upnl_usdt(direction, entry, mark, contracts, cs) + if upnl is not None: + row["float_pnl"] = upnl + except Exception: + pass + if tp is not None and tp > 0: + try: + try: + d = (direction or "long").lower() + e, t, c, cs_f = float(entry), float(tp), float(contracts), float(cs) + profit = (t - e) * c * cs_f if d == "long" else (e - t) * c * cs_f + except Exception: + profit = None + if profit is not None: + row["tp_profit"] = round(float(profit), 2) + except Exception: + pass + + +def enrich_order_items_with_marks( + items: list[dict[str, Any]], + *, + get_price: Optional[Callable[[str], Any]] = None, + get_contract_size: Optional[Callable[[str], Any]] = None, +) -> list[dict[str, Any]]: + """后台聚合时补标记价,并按张数×合约面值估算盈利金额/浮盈.""" + if not items: + return items + if not callable(get_price) and not callable(get_contract_size): + return items + out: list[dict[str, Any]] = [] + for it in items: + row = dict(it) + # 标记价:先试 price_symbol,再试永续候选 + mark = _safe_float(row.get("mark_price")) + if callable(get_price): + ordered: list[str] = [] + for s in [str(row.get("price_symbol") or "").strip()] + _swap_symbol_candidates(row): + if s and s not in ordered: + ordered.append(s) + for sym in ordered: + try: + px = get_price(sym) + except Exception: + px = None + mark = _safe_float(px) + if mark is not None: + row["mark_price"] = mark + break + cs = _resolve_contract_size(row, get_contract_size=get_contract_size) + _fill_order_pnl_fields(row, mark=mark, contract_size=cs) + out.append(row) + return out + + +def build_instance_dashboard_payload( + conn, + *, + fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, + hedge_enabled: bool = False, +) -> dict[str, Any]: + orders = collect_orders(conn) + keys = collect_keys(conn) + options_items = collect_options_items(fetch_options_positions, conn=conn) + hedge_items = collect_hedge_plans(conn) # 始终展示进行中计划,与当前交易模式无关 + # hedge_enabled 仅影响「新建」入口,不隐藏已有仓 + now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + return { + "ok": True, + "updated_at": now, + "orders": { + "title": "实盘下单", + "count": 0, + "items": [], + "tab": "options", + "removed": True, + }, + "keys": {"title": "关键位监控", "count": len(keys), "items": keys, "tab": "key_monitor"}, + "strategy": { + "title": "策略交易", + "count": 0, + "items": [], + "trends": [], + "rolls": [], + "tab": "strategy", + "removed": True, + }, + "options": { + "title": "期权持仓", + "count": len(options_items), + "items": options_items, + "visible": len(options_items) > 0, + "tab": "options", + }, + "hedge_plan": { + "title": "对冲计划", + "count": len(hedge_items), + "items": hedge_items, + "visible": len(hedge_items) > 0, + "tab": "hedge_plan", + }, + } diff --git a/lib/instance/instance_dashboard_register.py b/lib/instance/instance_dashboard_register.py new file mode 100644 index 0000000..0520a66 --- /dev/null +++ b/lib/instance/instance_dashboard_register.py @@ -0,0 +1,78 @@ +"""注册实例数据看板:快照 GET + SSE + 手动刷新(对齐中控).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from flask import Flask, Response, jsonify, stream_with_context + + +def register_instance_dashboard_routes( + app: Flask, + *, + login_required: Callable, + get_db: Callable, + fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, + hedge_enabled: bool | Callable[[], bool] = False, + enrich_orders: Optional[Callable[[list[dict[str, Any]]], list[dict[str, Any]]]] = None, +) -> None: + from lib.instance.instance_dashboard_cache import instance_dashboard_store + from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload + + def _hedge_on() -> bool: + if callable(hedge_enabled): + try: + return bool(hedge_enabled()) + except Exception: + return False + return bool(hedge_enabled) + + def _build() -> dict[str, Any]: + conn = get_db() + try: + payload = build_instance_dashboard_payload( + conn, + fetch_options_positions=fetch_options_positions, + hedge_enabled=_hedge_on(), + ) + if callable(enrich_orders) and payload.get("ok") and isinstance(payload.get("orders"), dict): + items = list(payload["orders"].get("items") or []) + try: + enriched = enrich_orders(items) or items + except Exception: + enriched = items + payload["orders"]["items"] = enriched + payload["orders"]["count"] = len(enriched) + return payload + finally: + conn.close() + + instance_dashboard_store.start(_build) + + @app.route("/api/instance/dashboard") + @login_required + def api_instance_dashboard(): + return jsonify(instance_dashboard_store.snapshot_dict()) + + @app.route("/api/instance/dashboard/stream") + @login_required + def api_instance_dashboard_stream(): + return Response( + stream_with_context(instance_dashboard_store.iter_sse()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + @app.route("/api/instance/dashboard/refresh", methods=["POST"]) + @login_required + def api_instance_dashboard_refresh(): + instance_dashboard_store.request_refresh() + return jsonify( + { + "ok": True, + "dashboard_version": instance_dashboard_store.version, + } + ) diff --git a/lib/instance/instance_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py new file mode 100644 index 0000000..734e246 --- /dev/null +++ b/lib/instance/instance_display_prefs_lib.py @@ -0,0 +1,144 @@ +"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db + +DISPLAY_RUNTIME_PREFIX = "display." + +DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { + "show_nav_dashboard": False, + "show_nav_account_ledger": False, + "show_nav_key_monitor": True, + "show_nav_trade": False, # 实盘下单界面已移除 + "show_nav_strategy": False, + "show_nav_strategy_records": False, + "show_nav_records": False, # 永续交易记录与复盘已移除 + "show_nav_stats": False, + "show_nav_risk_policy": True, + "show_nav_system_guide": False, + "show_nav_env_config": True, + "show_nav_options": True, + "show_nav_options_review": True, + "show_nav_hedge_plan": True, + "show_settings_transfer": True, + "show_settings_export": True, + "show_settings_password": True, + "show_settings_options_swap": True, + "show_settings_options_transfer": True, +} + +DISPLAY_LABELS: dict[str, str] = { + "show_nav_dashboard": "数据看板", + "show_nav_account_ledger": "账户流水", + "show_nav_key_monitor": "关键位监控", + "show_nav_trade": "实盘下单", + "show_nav_strategy": "策略交易", + "show_nav_strategy_records": "策略交易记录", + "show_nav_records": "交易记录与复盘", + "show_nav_stats": "统计分析", + "show_nav_risk_policy": "风控说明", + "show_nav_system_guide": "系统说明", + "show_nav_env_config": "env配置", + "show_nav_options": "期权", + "show_nav_options_review": "期权复盘", + "show_nav_hedge_plan": "对冲计划", + "show_settings_transfer": "资金划转", + "show_settings_export": "数据导出", + "show_settings_password": "账户密码修改", + "show_settings_options_swap": "期权币种兑换", + "show_settings_options_transfer": "期权资金划转", +} + +NAV_TAB_ALLOWED: dict[str, str] = { + "dashboard": "show_nav_dashboard", + "account_ledger": "show_nav_account_ledger", + "key_monitor": "show_nav_key_monitor", + "trade": "show_nav_trade", + "strategy": "show_nav_strategy", + "strategy_records": "show_nav_strategy_records", + "records": "show_nav_records", + "stats": "show_nav_stats", + "risk_policy": "show_nav_risk_policy", + "system_guide": "show_nav_system_guide", + "env_config": "show_nav_env_config", + "options": "show_nav_options", + "options_review": "show_nav_options_review", + "hedge_plan": "show_nav_hedge_plan", +} + + +def normalize_display_prefs(raw: dict | None) -> dict[str, bool]: + out = dict(DEFAULT_INSTANCE_DISPLAY) + if isinstance(raw, dict): + for key in DEFAULT_INSTANCE_DISPLAY: + if key in raw: + out[key] = bool(raw[key]) + return out + + +def _load_from_conn(conn) -> dict[str, bool]: + stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX) + merged: dict[str, Any] = {} + for key in DEFAULT_INSTANCE_DISPLAY: + sk = key + if sk in stored: + merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on") + return normalize_display_prefs(merged) + + +def get_display_prefs(get_db: Callable) -> dict[str, bool]: + return with_db(get_db, _load_from_conn) + + +def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]: + normalized = normalize_display_prefs(prefs) + + def _save(conn): + mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()} + runtime_set_many(conn, mapping) + return normalized + + return with_db(get_db, _save) + + +def display_prefs_template_context(get_db: Callable) -> dict[str, Any]: + prefs = get_display_prefs(get_db) + return {"display": prefs, "display_meta": display_meta_for_ui()} + + +def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool: + prefs = normalize_display_prefs(display or {}) + t = (tab or "").strip() + if t in ("trade", "records", "stats"): + return False # 实盘下单 / 交易记录复盘 / 统计分析 已移除 + key = NAV_TAB_ALLOWED.get(t) + if not key: + return True + return bool(prefs.get(key, True)) + + +def display_meta_for_ui() -> list[dict[str, Any]]: + nav_keys = [ + "show_nav_dashboard", + "show_nav_account_ledger", + "show_nav_key_monitor", + "show_nav_risk_policy", + "show_nav_system_guide", + "show_nav_env_config", + "show_nav_options", + "show_nav_options_review", + "show_nav_hedge_plan", + ] + settings_keys = [ + "show_settings_transfer", + "show_settings_export", + "show_settings_password", + "show_settings_options_swap", + "show_settings_options_transfer", + ] + return [ + {"group": "顶栏导航", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]}, + {"group": "系统设置区块", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]}, + ] diff --git a/lib/instance/instance_embed_context_lib.py b/lib/instance/instance_embed_context_lib.py new file mode 100644 index 0000000..fd86ed6 --- /dev/null +++ b/lib/instance/instance_embed_context_lib.py @@ -0,0 +1,194 @@ +"""embed 壳/片段:按 tab 裁剪 render_main_page 的数据加载,降内存与 API 压力.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + +EMBED_STRATEGY_PAGES = frozenset() + +_WIN_EPS = 1e-9 + + +def env_truthy(raw: str | None, default: bool = False) -> bool: + if raw is None or str(raw).strip() == "": + return default + return str(raw).strip().lower() in ("1", "true", "yes", "on") + + +def show_perp_funds_enabled(*, exchange_key: str | None = None) -> bool: + """OKX:是否在顶栏显示永续资金账户/交易账户.其他所恒为 True.""" + ex = (exchange_key or "").strip().lower() + if ex and ex != "okx": + return True + return env_truthy(os.getenv("OKX_SHOW_PERP_FUNDS"), default=True) + + +@dataclass(frozen=True) +class EmbedRenderPlan: + exchange_capitals: bool + records_rows: bool + records_summary: bool + key_history: bool + key_list: bool + orders: bool + stats_bundle: bool + strategy: bool + orphan_live: bool + + +def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan: + if embed_mode not in ("fragment", "shell"): + return EmbedRenderPlan( + exchange_capitals=True, + records_rows=True, + records_summary=False, + key_history=True, + key_list=True, + orders=True, + stats_bundle=True, + strategy=True, + orphan_live=True, + ) + is_shell = embed_mode == "shell" + is_strategy = page in EMBED_STRATEGY_PAGES + return EmbedRenderPlan( + exchange_capitals=is_shell, + records_rows=False, # 永续交易记录页已移除 + # 顶栏常驻:设置/风控/env 也要统计,否则首屏 SSR 为 0 后软切 tab 不会重绘顶栏 + records_summary=False, + key_history=page == "key_monitor", + key_list=page == "key_monitor" or is_strategy, + orders=False, # 实盘下单界面已移除;对冲永续下单不依赖本页数据 + stats_bundle=False, + strategy=is_strategy, + orphan_live=False, + ) + + +def profit_loss_ratio_from_averages(avg_win: float | None, avg_loss: float | None) -> float | None: + """盈亏比 = 平均盈利 / |平均亏损|.""" + if avg_win is None or avg_loss is None: + return None + try: + aw = float(avg_win) + al = float(avg_loss) + except (TypeError, ValueError): + return None + if al == 0: + return None + return round(aw / abs(al), 2) + + +def profit_loss_ratio_from_trades(trades: list[dict[str, Any]] | None) -> float | None: + wins: list[float] = [] + losses: list[float] = [] + for row in trades or []: + if not isinstance(row, dict): + continue + try: + pnl = float(row.get("effective_pnl_amount") or row.get("pnl_amount") or 0) + except (TypeError, ValueError): + continue + if pnl > _WIN_EPS: + wins.append(pnl) + elif pnl < -_WIN_EPS: + losses.append(pnl) + avg_win = sum(wins) / len(wins) if wins else None + avg_loss = sum(losses) / len(losses) if losses else None + return profit_loss_ratio_from_averages(avg_win, avg_loss) + + +def options_funding_label( + funding_usdc: float | None, + funding_usdt: float | None = None, +) -> str: + """期权侧顶栏仅展示 USDC(USDT 归永续资金/交易账户).funding_usdt 参数保留兼容,忽略.""" + _ = funding_usdt + if funding_usdc is None: + return "—" + try: + return f"{float(funding_usdc):.2f} USDC" + except (TypeError, ValueError): + return "—" + + +def total_funds_usdt( + funding_usdt: float | None, + trading_usdt: float | None, + options_trading_usdc: float | None = None, + options_funding_usdc: float | None = None, + options_funding_usdt: float | None = None, + options_trading_usdt: float | None = None, +) -> float | None: + parts = [ + funding_usdt, + trading_usdt, + options_funding_usdc, + options_funding_usdt, + options_trading_usdc, + options_trading_usdt, + ] + if all(v is None for v in parts): + return None + try: + total = 0.0 + for v in parts: + if v is not None: + total += float(v) + return round(total, 2) + except (TypeError, ValueError): + return None + + +def trade_records_summary(conn, start_bj: str, end_bj: str, tr_ts: str) -> dict[str, Any]: + """顶栏统计用 COUNT,避免 embed 壳拉 1000 行交易记录.""" + from lib.trade.trade_result_lib import sql_effective_pnl_expr + + pnl_sql = sql_effective_pnl_expr() + row = conn.execute( + f""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN {pnl_sql} > 0 THEN 1 ELSE 0 END) AS wins, + AVG(CASE WHEN {pnl_sql} > 0 THEN {pnl_sql} END) AS avg_win, + AVG(CASE WHEN {pnl_sql} < 0 THEN {pnl_sql} END) AS avg_loss + FROM trade_records + WHERE {tr_ts} >= ? AND {tr_ts} <= ? + AND COALESCE(result, '') != '错过' + AND COALESCE(reviewed_result, '') != '错过' + """, + (start_bj, end_bj), + ).fetchone() + total = int(row["total"] or 0) if row else 0 + wins = int(row["wins"] or 0) if row else 0 + rate = round(wins / total * 100, 2) if total else 0 + avg_win = float(row["avg_win"]) if row and row["avg_win"] is not None else None + avg_loss = float(row["avg_loss"]) if row and row["avg_loss"] is not None else None + return { + "records": [], + "total": total, + "rate": rate, + "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), + } + + +def header_trade_stats_for_window(conn, list_window: dict[str, Any], app_tz) -> dict[str, Any]: + """account_snapshot / 顶栏刷新:按当前列表窗返回总交易/胜率/盈亏比.""" + from lib.common.history_window_lib import sql_list_time_field, utc_window_to_bj_sql_strings + + start_bj, end_bj = utc_window_to_bj_sql_strings( + list_window["start_utc"], list_window["end_utc"], app_tz + ) + tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") + summary = trade_records_summary(conn, start_bj, end_bj, tr_ts) + return { + "total": summary["total"], + "rate": summary["rate"], + "profit_loss_ratio": summary.get("profit_loss_ratio"), + } + + +def minimal_stats_bundle(reset_hour: int) -> dict[str, Any]: + return {"stats_reset_hour": reset_hour, "segments": []} diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py new file mode 100644 index 0000000..fa666e3 --- /dev/null +++ b/lib/instance/instance_embed_lib.py @@ -0,0 +1,221 @@ +"""Embed shell: persistent chrome + tab content API (/embed, /api/embed/page/).""" +from __future__ import annotations + +from lib.paths import embed_templates_dir + +import os +from typing import Callable +from urllib.parse import parse_qsl, urlencode, urlsplit + +from flask import Flask, Response, jsonify, make_response, redirect, request, session +from jinja2 import ChoiceLoader, FileSystemLoader + +EMBED_TABS: tuple[str, ...] = ( + "dashboard", + "account_ledger", + "key_monitor", + "options", + "options_review", + "hedge_plan", + "risk_policy", + "system_guide", + "env_config", + "settings", +) + +PATH_TO_EMBED_TAB: dict[str, str] = { + "/": "options", + "/trade": "options", # 实盘下单界面已移除 + "/records": "options_review", # 永续交易记录与复盘已移除 + "/stats": "options", + "/dashboard": "dashboard", + "/account_ledger": "account_ledger", + "/key_monitor": "key_monitor", + "/options": "options", + "/options/review": "options_review", + "/hedge-plan": "hedge_plan", + "/risk_policy": "risk_policy", + "/system_guide": "system_guide", + "/env_config": "env_config", + "/settings": "settings", +} + +ORDER_RULE_TIPS_BY_EXCHANGE: dict[str, str] = { + "gate": "order_monitor_rule_tips_gate.html", + "binance": "order_monitor_rule_tips_binance.html", + "okx": "order_monitor_rule_tips_okx.html", +} + + +def order_rule_tips_template(exchange_key: str) -> str: + ex = (exchange_key or "").strip().lower() + return ORDER_RULE_TIPS_BY_EXCHANGE.get(ex, "order_monitor_rule_tips_gate.html") + + +def include_transfer_block(exchange_key: str) -> bool: + """三所 standalone / embed 壳均在顶栏展示划转区块.""" + return (exchange_key or "").strip().lower() in ORDER_RULE_TIPS_BY_EXCHANGE + + +def ui_open_guard_enabled(exchange_key: str) -> bool: + return (exchange_key or "").strip().lower() == "okx" + + +def ui_orphan_recovery_enabled(exchange_key: str) -> bool: + return (exchange_key or "").strip().lower() == "binance" + + +def path_to_embed_tab(path: str) -> str | None: + p = (path or "/").strip() + if not p.startswith("/"): + p = "/" + p + base = urlsplit(p).path.rstrip("/") or "/" + return PATH_TO_EMBED_TAB.get(base) + + +def embed_shell_enabled() -> bool: + raw = (os.getenv("EMBED_SHELL") or os.getenv("HUB_EMBED_SHELL") or "1").strip().lower() + return raw in ("1", "true", "yes", "on") + + +_SETTINGS_SUB_TABS = frozenset( + {"nav", "password", "transfer", "export", "options_swap", "options_transfer"} +) + + +def redirect_to_embed_shell_if_enabled(page: str): + """直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换).""" + if not embed_shell_enabled(): + return None + if (request.args.get("embed") or "").strip() == "1": + return None + if (request.path or "").rstrip("/") == "/embed": + return None + q = {k: v for k, v in request.args.items()} + # embed 的 tab=页面名;系统设置内页签用 settings_tab,避免 /settings?tab=transfer 被覆盖成 tab=settings + if (page or "").strip() == "settings": + sub = (q.get("settings_tab") or "").strip() + legacy = (q.get("tab") or "").strip() + if not sub and legacy in _SETTINGS_SUB_TABS: + q["settings_tab"] = legacy + q["tab"] = page + q["embed"] = "1" + return redirect("/embed?" + urlencode(q)) + + +def rewrite_embed_dest(path: str, hub_theme: str | None = None) -> str: + """embed=1 打开时:/trade → /embed?tab=trade&embed=1""" + if not embed_shell_enabled(): + split = urlsplit(path or "/") + q = dict(parse_qsl(split.query, keep_blank_values=True)) + q["embed"] = "1" + ht = (hub_theme or q.get("hub_theme") or "").strip().lower() + if ht in ("light", "dark"): + q["hub_theme"] = ht + dest = split.path or "/" + if q: + return f"{dest}?{urlencode(q)}" + return dest + "?embed=1" + split = urlsplit(path or "/") + tab = path_to_embed_tab(split.path) + q = dict(parse_qsl(split.query, keep_blank_values=True)) + if tab: + if tab == "settings": + sub = (q.get("settings_tab") or "").strip() + legacy = (q.get("tab") or "").strip() + if not sub and legacy in _SETTINGS_SUB_TABS: + q["settings_tab"] = legacy + q["tab"] = tab + q["embed"] = "1" + ht = (hub_theme or q.get("hub_theme") or "").strip().lower() + if ht in ("light", "dark"): + q["hub_theme"] = ht + return f"/embed?{urlencode(q)}" + q["embed"] = "1" + ht = (hub_theme or q.get("hub_theme") or "").strip().lower() + if ht in ("light", "dark"): + q["hub_theme"] = ht + dest = split.path or "/" + if split.query: + dest += "?" + split.query + if "embed=1" not in dest: + sep = "&" if "?" in dest else "?" + dest += f"{sep}embed=1" + if ht in ("light", "dark") and "hub_theme=" not in dest: + sep = "&" if "?" in dest else "?" + dest += f"{sep}hub_theme={ht}" + return dest + + +def attach_embed_templates(app: Flask, repo_root: str) -> None: + embed_dir = embed_templates_dir(repo_root) + if not os.path.isdir(embed_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(embed_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def register_embed_routes( + app: Flask, + login_required: Callable, + render_main_page_fn: Callable, +) -> None: + from lib.instance.instance_live_push_lib import register_instance_live_routes + + app.config["RENDER_MAIN_PAGE_FN"] = render_main_page_fn + register_instance_live_routes(app, login_required) + + @login_required + @app.route("/embed") + def embed_shell_page(): + tab = (request.args.get("tab") or "options").strip() + if tab not in EMBED_TABS: + tab = "options" + session["hub_embed_shell"] = True + resp = make_response(render_main_page_fn(tab, embed_mode="shell")) + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + return resp + + @login_required + @app.route("/api/embed/page/") + def api_embed_page(tab: str): + tab = (tab or "").strip() + if tab not in EMBED_TABS: + return jsonify({"ok": False, "msg": "unknown tab"}), 404 + allowed_fn = app.config.get("INSTANCE_TAB_ALLOWED_FN") + if callable(allowed_fn) and not allowed_fn(tab): + return jsonify({"ok": False, "msg": "tab disabled"}), 403 + html = render_main_page_fn(tab, embed_mode="fragment") + if isinstance(html, Response): + html = html.get_data(as_text=True) + resp = jsonify({"ok": True, "page": tab, "html": html}) + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + return resp + + +def pwa_app_name(exchange_key: str) -> str: + """安装 App / 主屏幕显示名(各所独立标识).""" + ex = (exchange_key or "").strip().lower() + return { + "binance": "Binance 交易系统", + "okx": "OKX 交易系统", + "gate": "Gate 交易系统", + }.get(ex, "交易系统") + + +def embed_context_extras(exchange_key: str) -> dict: + return { + "order_rule_tips_tpl": order_rule_tips_template(exchange_key), + "include_transfer_block": include_transfer_block(exchange_key), + "ui_open_guard_enabled": ui_open_guard_enabled(exchange_key), + "ui_orphan_recovery_enabled": ui_orphan_recovery_enabled(exchange_key), + "pwa_app_name": pwa_app_name(exchange_key), + } diff --git a/lib/instance/instance_live_pnl_lib.py b/lib/instance/instance_live_pnl_lib.py new file mode 100644 index 0000000..f61a315 --- /dev/null +++ b/lib/instance/instance_live_pnl_lib.py @@ -0,0 +1,127 @@ +"""实例页:持仓未实现盈亏(实时盈亏)汇总.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from lib.market.position_metrics_lib import parse_position_unrealized_pnl + + +def position_row_contracts(pos: dict[str, Any]) -> float: + """持仓张数:与三所 app 内 _position_row_effective_contracts 规则一致.""" + if not isinstance(pos, dict): + return 0.0 + info = pos.get("info") or {} + if not isinstance(info, dict): + info = {} + for val in ( + pos.get("contracts"), + info.get("positionAmt"), + info.get("size"), + info.get("pos"), + info.get("availPos"), + ): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return x + except (TypeError, ValueError): + continue + return 0.0 + + +def sum_unrealized_pnl_from_positions(positions: list[dict[str, Any]] | None) -> float | None: + total = 0.0 + found = False + for p in positions or []: + if not isinstance(p, dict): + continue + if position_row_contracts(p) <= 1e-12: + continue + upnl = parse_position_unrealized_pnl(p) + if upnl is None: + continue + found = True + total += float(upnl) + return round(total, 2) if found else None + + +def _row_field(row: Any, key: str, default: str = "") -> str: + if row is None: + return default + try: + if hasattr(row, "keys") and key in row.keys(): + val = row[key] + elif isinstance(row, dict): + val = row.get(key) + else: + val = None + except Exception: + val = None + return str(val or default).strip() + + +def sum_unrealized_pnl_from_metrics( + rows: list[dict[str, Any]] | list[Any], + get_metrics_fn: Callable[[str, str], dict[str, Any] | None], +) -> float | None: + """按活跃监控单逐笔拉交易所 metrics 汇总(与持仓卡浮盈亏一致).""" + total = 0.0 + found = False + for row in rows or []: + ex_sym = _row_field(row, "exchange_symbol") + sym = _row_field(row, "symbol") + direction = _row_field(row, "direction", "long").lower() or "long" + target = ex_sym or sym + if not target: + continue + metrics = get_metrics_fn(target, direction) + if not isinstance(metrics, dict): + continue + upnl = metrics.get("unrealized_pnl") + if upnl is None: + continue + try: + total += float(upnl) + found = True + except (TypeError, ValueError): + continue + return round(total, 2) if found else None + + +def fetch_unrealized_pnl(fetch_positions_fn: Callable[[], list[dict[str, Any]] | None]) -> float | None: + try: + return sum_unrealized_pnl_from_positions(fetch_positions_fn() or []) + except Exception: + return None + + +def resolve_instance_unrealized_pnl( + fetch_positions_fn: Callable[[], list[dict[str, Any]] | None], + active_rows: list[Any] | None, + get_metrics_fn: Callable[[str, str], dict[str, Any] | None] | None, +) -> float | None: + """先全量持仓汇总,失败或无数据时回退到活跃监控单 metrics.""" + total = fetch_unrealized_pnl(fetch_positions_fn) + if total is not None: + return total + if active_rows and get_metrics_fn: + return sum_unrealized_pnl_from_metrics(active_rows, get_metrics_fn) + return None + + +def merge_unrealized_pnl_components(*parts: float | None) -> float | None: + """合并永续与期权等多路未实现盈亏(任一路有值即参与合计).""" + total = 0.0 + found = False + for part in parts: + if part is None: + continue + try: + total += float(part) + found = True + except (TypeError, ValueError): + continue + return round(total, 2) if found else None diff --git a/lib/instance/instance_live_push_lib.py b/lib/instance/instance_live_push_lib.py new file mode 100644 index 0000000..ca93a67 --- /dev/null +++ b/lib/instance/instance_live_push_lib.py @@ -0,0 +1,122 @@ +"""实例 embed 壳:后台定时 tick + SSE 通知前端拉 JSON 快照(对齐中控 dashboard).""" +from __future__ import annotations + +import json +import os +import queue +import threading +from collections.abc import Iterator +from typing import Any, Callable + +from flask import Flask, Response, stream_with_context + +INSTANCE_LIVE_TICK_SEC = float(os.getenv("INSTANCE_LIVE_TICK_SEC", "5")) +INSTANCE_SSE_HEARTBEAT_SEC = float(os.getenv("INSTANCE_SSE_HEARTBEAT_SEC", "25")) + + +class InstanceLivePush: + def __init__(self) -> None: + self._lock = threading.Lock() + self.version = 0 + self._subscribers: list[queue.Queue[str | None]] = [] + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._loop, daemon=True, name="instance-live-push") + self._thread.start() + + def stop(self) -> None: + self._stop.set() + self._broadcast(close=True) + + def tick(self, reason: str = "poll") -> int: + with self._lock: + self.version += 1 + ver = self.version + payload = json.dumps({"live_version": ver, "reason": reason}, ensure_ascii=False) + self._broadcast(payload) + return ver + + def event_dict(self) -> dict[str, Any]: + return {"live_version": self.version, "tick_sec": INSTANCE_LIVE_TICK_SEC} + + def _loop(self) -> None: + while not self._stop.is_set(): + self.tick("poll") + if self._stop.wait(INSTANCE_LIVE_TICK_SEC): + break + + def _broadcast(self, event: str | None = None, *, close: bool = False) -> None: + with self._lock: + subs = list(self._subscribers) + dead: list[queue.Queue[str | None]] = [] + for q in subs: + try: + q.put_nowait(None if close else event) + except Exception: + dead.append(q) + if dead: + with self._lock: + for q in dead: + if q in self._subscribers: + self._subscribers.remove(q) + + def _subscribe(self) -> queue.Queue[str | None]: + q: queue.Queue[str | None] = queue.Queue(maxsize=16) + with self._lock: + self._subscribers.append(q) + return q + + def _unsubscribe(self, q: queue.Queue[str | None]) -> None: + with self._lock: + if q in self._subscribers: + self._subscribers.remove(q) + + def iter_sse(self) -> Iterator[str]: + q = self._subscribe() + try: + yield self._format_event(self.event_dict() | {"reason": "connect"}) + while True: + try: + raw = q.get(timeout=INSTANCE_SSE_HEARTBEAT_SEC) + except queue.Empty: + yield ": heartbeat\n\n" + continue + if raw is None: + break + yield f"event: live\ndata: {raw}\n\n" + finally: + self._unsubscribe(q) + + @staticmethod + def _format_event(data: dict[str, Any]) -> str: + return "event: live\ndata: " + json.dumps(data, ensure_ascii=False) + "\n\n" + + +instance_live_push = InstanceLivePush() + + +def notify_instance_balance_changed() -> int: + """划转/兑换后通知 embed 壳拉最新资金快照.""" + return instance_live_push.tick("balance") + + +def register_instance_live_routes(app: Flask, login_required: Callable) -> None: + instance_live_push.start() + + @login_required + @app.route("/api/instance/live/stream") + def api_instance_live_stream(): + return Response( + stream_with_context(instance_live_push.iter_sse()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/lib/instance/instance_nav_lib.py b/lib/instance/instance_nav_lib.py new file mode 100644 index 0000000..cce52ba --- /dev/null +++ b/lib/instance/instance_nav_lib.py @@ -0,0 +1,9 @@ +"""Soft-nav helper (standalone: always False).""" + +from __future__ import annotations + +from flask import Request + + +def request_is_hub_soft_nav(req: Request | None = None) -> bool: + return False diff --git a/lib/instance/instance_pm2_lib.py b/lib/instance/instance_pm2_lib.py new file mode 100644 index 0000000..215eec2 --- /dev/null +++ b/lib/instance/instance_pm2_lib.py @@ -0,0 +1,74 @@ +"""PM2 重启当前实例(仅 Linux 部署环境).""" +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +from typing import Any + + +def default_pm2_app_name(exchange_key: str) -> str: + mapping = { + "okx": "crypto_okx", + "binance": "crypto_binance", + "gate": "crypto_gate", + } + return mapping.get((exchange_key or "").strip().lower(), "crypto_okx") + + +def resolve_pm2_app_name(exchange_key: str) -> str: + explicit = (os.getenv("PM2_APP_NAME") or "").strip() + if explicit: + return explicit + return default_pm2_app_name(exchange_key) + + +def schedule_pm2_restart(app_name: str, *, delay_seconds: float = 1.0) -> dict[str, Any]: + """延迟触发 PM2 重启,便于 HTTP 响应先返回(避免重启当前进程导致请求中断).""" + if not sys.platform.startswith("linux"): + return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "app": app_name} + if not (app_name or "").strip(): + return {"ok": False, "msg": "未指定 PM2 应用名", "app": app_name} + app_name = app_name.strip() + try: + cmd = f"sleep {delay_seconds} && exec pm2 restart {shlex.quote(app_name)} --update-env" + subprocess.Popen( + ["bash", "-c", cmd], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return {"ok": True, "app": app_name, "msg": "重启已触发", "deferred": True} + except FileNotFoundError: + return {"ok": False, "msg": "未找到 bash 或 pm2 命令", "app": app_name} + except Exception as e: + return {"ok": False, "msg": str(e), "app": app_name} + + +def restart_instance_pm2(exchange_key: str, *, defer: bool = False) -> dict[str, Any]: + if not sys.platform.startswith("linux"): + return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "app": None} + app_name = resolve_pm2_app_name(exchange_key) + if defer: + return schedule_pm2_restart(app_name) + try: + proc = subprocess.run( + ["pm2", "restart", app_name, "--update-env"], + capture_output=True, + text=True, + timeout=120, + ) + ok = proc.returncode == 0 + return { + "ok": ok, + "app": app_name, + "msg": (proc.stdout or proc.stderr or "").strip()[:500], + "returncode": proc.returncode, + } + except FileNotFoundError: + return {"ok": False, "msg": "未找到 pm2 命令", "app": app_name} + except subprocess.TimeoutExpired: + return {"ok": False, "msg": "pm2 restart 超时", "app": app_name} + except Exception as e: + return {"ok": False, "msg": str(e), "app": app_name} diff --git a/lib/instance/instance_settings_lib.py b/lib/instance/instance_settings_lib.py new file mode 100644 index 0000000..c760d18 --- /dev/null +++ b/lib/instance/instance_settings_lib.py @@ -0,0 +1,236 @@ +"""实例「系统设置」页:从 .env 汇总风控说明(三所共用).""" +from __future__ import annotations + +import os +from typing import Any, Optional + +from lib.trade.account_risk_lib import ( + cooling_hours_manual, + cooling_hours_manual_journal, + daily_loss_limit, + manual_close_daily_limit, + max_active_positions_from_env, + mood_issues_daily_freeze_enabled, + risk_control_enabled, +) +from lib.trade.position_sizing_lib import is_full_margin_mode, load_position_sizing_mode, mode_label_zh +from lib.trade.trade_policy_lib import TradePolicy + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _env_float(key: str, default: float) -> float: + try: + return float(os.getenv(key, str(default))) + except (TypeError, ValueError): + return default + + +def _env_int(key: str, default: int) -> int: + try: + return int(os.getenv(key, str(default))) + except (TypeError, ValueError): + return default + + +def _row(label: str, value: str, note: str = "") -> dict[str, str]: + return {"label": label, "value": value, "note": note} + + +def _on_off(enabled: bool) -> str: + return "开启" if enabled else "关闭" + + +def build_instance_settings_view( + *, + exchange_key: str, + exchange_display: str, + risk_status: Optional[dict[str, Any]] = None, + trade_policy: Optional[TradePolicy] = None, + data_export_version: int = 3, + open_guard_enabled: Optional[bool] = None, +) -> dict[str, Any]: + rs = risk_status or {} + sizing_mode = load_position_sizing_mode() + reset_hour = _env_int("TRADING_DAY_RESET_HOUR", 8) + hard_limit = _env_int("DAILY_OPEN_HARD_LIMIT", 0) + alert_threshold = _env_int("DAILY_OPEN_ALERT_THRESHOLD", 5) + force_close_on = _env_bool("FORCE_CLOSE_ENABLED", False) + force_close_hour = _env_int("FORCE_CLOSE_BJ_HOUR", 0) + auto_transfer_on = _env_bool("AUTO_TRANSFER_ENABLED", False) + guard_on = ( + bool(open_guard_enabled) + if open_guard_enabled is not None + else _env_bool("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", True) + ) + + sections: list[dict[str, Any]] = [] + + sections.append( + { + "title": "交易执行", + "rows": [ + _row("最大同时持仓", str(max_active_positions_from_env())), + _row("计仓模式", mode_label_zh(sizing_mode)), + _row("以损定仓风险%", f"{_env_float('RISK_PERCENT', 2):g}%"), + _row("人工最低盈亏比", f">= {_env_float('MANUAL_MIN_PLANNED_RR', 1.4):g}:1"), + _row( + "交易日切点", + f"北京时间 {reset_hour}:00", + "新交易日统计与部分开仓限制以此为准", + ), + _row( + "允许北京时间切点前开仓", + "已放开(允许开仓)" if not guard_on else "已限制(禁止开仓)", + f"关闭限制后,{reset_hour}:00 前也可斐波成交登记与人工下单;" + "环境配置「切点前禁止新开仓」(TRADING_DAY_RESET_OPEN_GUARD_ENABLED)", + ), + _row( + "单日开仓提醒", + f"第 {alert_threshold} 次", + "达次数推送企业微信,不拦单", + ), + _row( + "单日开仓硬上限", + str(hard_limit) if hard_limit > 0 else "未启用", + "达上限后禁止一切新开仓直至下一交易日" if hard_limit > 0 else "", + ), + ], + } + ) + + sections.append( + { + "title": "账户冷静期", + "rows": [ + _row("风控总开关", _on_off(risk_control_enabled())), + _row("手动平仓冷静", f"{cooling_hours_manual():g} 小时"), + _row("复盘后冷静", f"{cooling_hours_manual_journal():g} 小时", "手动平仓且填写说明后可缩短"), + _row("日手动平仓上限", f"{manual_close_daily_limit()} 次", "超限当日冻结"), + _row( + "日亏损次数上限", + ( + f"{daily_loss_limit()} 次" + if daily_loss_limit() > 0 + else "未启用" + ), + "平仓亏损达限后当日冻结开仓;0=不启用" if daily_loss_limit() > 0 else "RISK_DAILY_LOSS_LIMIT=0", + ), + _row( + "复盘情绪日冻结", + _on_off(mood_issues_daily_freeze_enabled()), + "复盘勾选心态标签可触发当日冻结", + ), + ], + } + ) + + sections.append( + { + "title": "关键位监控", + "rows": [ + _row("模式", "仅关键支撑阻力提醒", "箱体/斐波/触价等程序自动单已移除"), + ], + } + ) + + if force_close_on or (exchange_key or "").strip().lower() == "gate": + sections.append( + { + "title": "整点强制清仓", + "rows": [ + _row("强制清仓", _on_off(force_close_on)), + _row( + "执行时刻", + f"北京时间 {force_close_hour}:00 起 {_env_int('FORCE_CLOSE_GRACE_MINUTES', 5)} 分钟内", + ), + ], + } + ) + + if (exchange_key or "").strip().lower() == "okx" and _env_bool("OKX_OPTIONS_ENABLED", False): + api_key = (os.getenv("OKX_API_KEY") or "").strip() + sections.append( + { + "title": "期权设置", + "rows": [ + _row("期权模块", "已启用"), + _row( + "账户 API", + f"已配置(…{api_key[-4:]})" if len(api_key) >= 4 else "未配置 OKX_API_*", + "永续与期权共用 OKX_API_*", + ), + _row( + "说明", + "币种兑换与账户内划转到右侧「期权设置」卡片操作", + ), + ], + } + ) + + policy_note = "" + if trade_policy and getattr(trade_policy, "badge_text", ""): + policy_note = str(trade_policy.badge_text) + + return { + "exchange_display": exchange_display, + "risk_status_label": str(rs.get("status_label") or "正常"), + "risk_status_reason": str(rs.get("reason") or "").strip(), + "can_trade": bool(rs.get("can_trade", True)), + "trade_policy_note": policy_note, + "sections": sections, + "data_export_version": int(data_export_version), + "show_transfer": (exchange_key or "").strip().lower() in ("gate", "binance", "okx"), + "options_settings_enabled": (exchange_key or "").strip().lower() == "okx" + and _env_bool("OKX_OPTIONS_ENABLED", False), + "auto_transfer_enabled": auto_transfer_on, + "auto_transfer_bj_hour": _env_int("AUTO_TRANSFER_BJ_HOUR", 8), + "auto_transfer_amount": _env_float("AUTO_TRANSFER_AMOUNT", 30), + "auto_transfer_from": (os.getenv("AUTO_TRANSFER_FROM") or "funding").strip(), + "auto_transfer_to": (os.getenv("AUTO_TRANSFER_TO") or "swap").strip(), + } + + +def build_settings_tabs(display: dict[str, Any] | None, instance_settings: dict[str, Any]) -> list[dict[str, str]]: + disp = display or {} + inst = instance_settings or {} + tabs: list[dict[str, str]] = [{"key": "nav", "title": "导航显示"}] + tabs.append({"key": "sim_funds", "title": "模拟资金"}) + if disp.get("show_settings_password", True): + tabs.append({"key": "password", "title": "账户密码"}) + if inst.get("show_transfer") and disp.get("show_settings_transfer", True): + tabs.append({"key": "transfer", "title": "永续划转"}) + if disp.get("show_settings_export", True): + tabs.append({"key": "export", "title": "数据导出"}) + if inst.get("options_settings_enabled") and disp.get("show_settings_options_swap", True): + tabs.append({"key": "options_swap", "title": "币种兑换"}) + if inst.get("options_settings_enabled") and disp.get("show_settings_options_transfer", True): + tabs.append({"key": "options_transfer", "title": "期权划转"}) + return tabs + + +def settings_page_context(page: str, *, instance_base_dir: str | None = None, **kwargs: Any) -> dict[str, Any]: + p = (page or "").strip() + if p == "system_guide": + from lib.instance.instance_system_guide_lib import system_guide_template_context + + return system_guide_template_context() + if p not in ("settings", "risk_policy", "env_config"): + return {} + display = kwargs.pop("display", None) + ctx: dict[str, Any] = {"instance_settings": build_instance_settings_view(**kwargs)} + if p == "settings": + ctx["settings_tabs"] = build_settings_tabs(display, ctx["instance_settings"]) + if p == "env_config" and instance_base_dir: + from lib.env.env_ui_manifest import build_env_ui_payload + + exchange_key = str(kwargs.get("exchange_key") or "") + env_path = os.path.join(instance_base_dir, ".env") + example_path = os.path.join(instance_base_dir, ".env.example") + ctx["env_config_groups"] = build_env_ui_payload(exchange_key, example_path, env_path) + return ctx diff --git a/lib/instance/instance_settings_register.py b/lib/instance/instance_settings_register.py new file mode 100644 index 0000000..d0b5644 --- /dev/null +++ b/lib/instance/instance_settings_register.py @@ -0,0 +1,170 @@ +"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启.""" +from __future__ import annotations + +import os +from functools import wraps +from typing import Any, Callable + +from flask import jsonify, request, session + +from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines +from lib.env.env_ui_manifest import ( + build_env_ui_payload, + filter_updates_for_ui, + coerce_hedge_partial_close_with_manual, + validate_env_ui_updates, +) +from lib.env.env_schema import parse_env_example_schema +from lib.instance.instance_display_prefs_lib import ( + display_meta_for_ui, + get_display_prefs, + normalize_display_prefs, + save_display_prefs, + tab_allowed, +) +from lib.instance.instance_pm2_lib import restart_instance_pm2 +from lib.instance.runtime_config_lib import apply_env_reload + + +def _api_login_required(): + def decorator(f): + @wraps(f) + def wrapped(*args, **kwargs): + logged_in = bool(session.get("logged_in")) + auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + if auth_disabled or logged_in: + return f(*args, **kwargs) + return jsonify({"ok": False, "msg": "未登录"}), 401 + + return wrapped + + return decorator + + +def register_instance_settings_routes( + app, + *, + get_db: Callable, + login_required_fn: Callable, + base_dir: str, + exchange_key: str, + username: str, + password: str, +) -> None: + env_path = os.path.join(base_dir, ".env") + example_path = os.path.join(base_dir, ".env.example") + api_auth = _api_login_required() + + @app.route("/api/settings/display", methods=["GET", "POST"]) + @api_auth + def api_settings_display(): + if request.method == "GET": + prefs = get_display_prefs(get_db) + return jsonify( + { + "ok": True, + "display": prefs, + "meta": display_meta_for_ui(), + } + ) + body = request.get_json(silent=True) or {} + raw = body.get("display") if isinstance(body.get("display"), dict) else body + saved = save_display_prefs(get_db, raw) + return jsonify({"ok": True, "display": saved}) + + @app.route("/api/settings/env/meta", methods=["GET"]) + @api_auth + def api_env_meta(): + groups = build_env_ui_payload(exchange_key, example_path, env_path) + return jsonify({"ok": True, "groups": groups}) + + @app.route("/api/settings/env", methods=["GET", "POST"]) + @api_auth + def api_settings_env(): + if request.method == "GET": + groups = build_env_ui_payload(exchange_key, example_path, env_path) + return jsonify({"ok": True, "groups": groups}) + body = request.get_json(silent=True) or {} + updates = body.get("values") if isinstance(body.get("values"), dict) else body + if not isinstance(updates, dict): + return jsonify({"ok": False, "msg": "无效请求体"}), 400 + updates = filter_updates_for_ui(exchange_key, updates) + clean, errors = validate_env_ui_updates(exchange_key, example_path, updates) + if errors: + return jsonify({"ok": False, "msg": "; ".join(errors)}), 400 + clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path) + if not clean: + return jsonify({"ok": True, "changed_keys": [], "restart_required": False}) + changed = apply_env_updates(env_path, clean) + groups = parse_env_example_schema(example_path) + reload_info = apply_env_reload(env_path, get_db, changed, groups) + return jsonify( + { + "ok": True, + "changed_keys": changed, + "restart_required": reload_info.get("restart_required", False), + } + ) + + @app.route("/api/settings/password", methods=["POST"]) + @api_auth + def api_change_password(): + body = request.get_json(silent=True) or {} + old_password = str(body.get("old_password") or "") + new_username = str(body.get("new_username") or "").strip() + new_password = str(body.get("new_password") or "") + confirm = str(body.get("confirm_password") or "") + if not old_password or old_password != password: + return jsonify({"ok": False, "msg": "当前密码错误"}), 400 + if len(new_password) < 6: + return jsonify({"ok": False, "msg": "新密码至少 6 位"}), 400 + if new_password != confirm: + return jsonify({"ok": False, "msg": "两次输入的新密码不一致"}), 400 + updates: dict[str, str] = {"APP_PASSWORD": new_password} + if new_username: + updates["APP_USERNAME"] = new_username + changed = apply_env_updates(env_path, updates) + groups = parse_env_example_schema(example_path) + apply_env_reload(env_path, get_db, changed, groups) + return jsonify({"ok": True, "restart_required": True, "changed_keys": changed}) + + @app.route("/api/admin/restart", methods=["POST"]) + @api_auth + def api_admin_restart(): + result = restart_instance_pm2(exchange_key, defer=True) + code = 200 if result.get("ok") else 500 + return jsonify({"ok": bool(result.get("ok")), **result}), code + + @app.route("/api/admin/health", methods=["GET"]) + def api_admin_health(): + return jsonify({"ok": True, "status": "up"}) + + def tab_allowed_fn(tab: str) -> bool: + prefs = get_display_prefs(get_db) + return tab_allowed(tab, prefs) + + app.config["INSTANCE_GET_DB"] = get_db + app.config["INSTANCE_TAB_ALLOWED_FN"] = tab_allowed_fn + + @app.route("/api/embed/tab_allowed/", methods=["GET"]) + @api_auth + def api_tab_allowed(tab: str): + prefs = get_display_prefs(get_db) + return jsonify({"ok": True, "tab": tab, "allowed": tab_allowed(tab, prefs)}) + + +def merge_ui_template_context(page: str, get_db: Callable, **settings_kwargs: Any) -> dict[str, Any]: + from lib.instance.instance_settings_lib import settings_page_context + + prefs = get_display_prefs(get_db) + ctx = { + "display": prefs, + "display_meta": display_meta_for_ui(), + **settings_page_context(page, display=prefs, **settings_kwargs), + } + return ctx diff --git a/lib/instance/instance_system_guide_lib.py b/lib/instance/instance_system_guide_lib.py new file mode 100644 index 0000000..e02f921 --- /dev/null +++ b/lib/instance/instance_system_guide_lib.py @@ -0,0 +1,69 @@ +"""实例「系统说明」:加载 Markdown,生成 h2 目录与带锚点正文.""" +from __future__ import annotations + +import re +from functools import lru_cache +from html import escape +from pathlib import Path +from typing import Any + +from lib.common.markdown_html_lib import render_markdown_html +from lib.paths import REPO_ROOT + + +def system_guide_md_path() -> Path: + return REPO_ROOT / "docs" / "系统说明.md" + + +def _slugify(text: str) -> str: + raw = re.sub(r"<[^>]+>", "", text or "") + raw = re.sub(r"\s+", "-", raw.strip()) + raw = re.sub(r"[^\w\u4e00-\u9fff\-]+", "", raw) + return raw[:80] or "section" + + +def _inject_h2_ids(html: str) -> tuple[str, list[dict[str, str]]]: + """为 h2 注入 id,并收集目录(仅 h2).""" + toc: list[dict[str, str]] = [] + used: dict[str, int] = {} + + def repl(m: re.Match[str]) -> str: + inner = m.group(1) + base = _slugify(inner) + n = used.get(base, 0) + 1 + used[base] = n + hid = base if n == 1 else f"{base}-{n}" + toc.append({"id": hid, "title": re.sub(r"<[^>]+>", "", inner).strip()}) + return f'

    {inner}

    ' + + out = re.sub(r"

    (.*?)

    ", repl, html, flags=re.I | re.S) + return out, toc + + +@lru_cache(maxsize=4) +def _load_payload_cached(mtime_ns: int, path_str: str) -> dict[str, Any]: + path = Path(path_str) + try: + md_text = path.read_text(encoding="utf-8") + except OSError: + md_text = "# 系统说明缺失\n\n未找到 `docs/系统说明.md`。" + body = render_markdown_html(md_text) + body, toc = _inject_h2_ids(body) + return {"html": body, "toc": toc, "mtime_ns": mtime_ns} + + +def load_system_guide_payload() -> dict[str, Any]: + path = system_guide_md_path() + try: + mtime_ns = path.stat().st_mtime_ns + except OSError: + mtime_ns = 0 + return dict(_load_payload_cached(mtime_ns, str(path))) + + +def system_guide_template_context() -> dict[str, Any]: + payload = load_system_guide_payload() + return { + "system_guide_html": payload.get("html") or "", + "system_guide_toc": payload.get("toc") or [], + } diff --git a/lib/instance/journal_chart_async_lib.py b/lib/instance/journal_chart_async_lib.py new file mode 100644 index 0000000..bdd01d1 --- /dev/null +++ b/lib/instance/journal_chart_async_lib.py @@ -0,0 +1,93 @@ +"""复盘自动 K 线:后台线程生成,避免 /add_journal 同步卡住.""" + +from __future__ import annotations + +import logging +import threading +from typing import Any, Callable + +logger = logging.getLogger(__name__) + + +def schedule_journal_exchange_chart( + *, + entry_id: str, + exchange_symbol: str, + title_prefix: str, + journal_tfs: list[str], + journal_limit: int, + marker_payload: dict[str, Any], + upload_folder: str, + generate_chart_fn: Callable[..., str | None], + get_db_fn: Callable[[], Any], +) -> None: + """提交后立即返回;线程内画图并写回 journal_entries.image.""" + entry_id = str(entry_id or "").strip() + if not entry_id or not callable(generate_chart_fn) or not callable(get_db_fn): + return + + tfs = [str(x).strip() for x in (journal_tfs or []) if str(x).strip()] + if not tfs: + return + + def _run() -> None: + try: + chart_fname = f"journal_{entry_id}.png" + saved = generate_chart_fn( + exchange_symbol, + title_prefix, + timeframes=tfs, + limit=journal_limit, + out_dir=upload_folder, + filename=chart_fname, + filename_prefix="journal", + marker_payload=marker_payload, + marker_timeframes={x.lower() for x in tfs}, + layout="vertical", + ) + if not saved: + logger.warning("journal chart async empty entry_id=%s", entry_id) + return + conn = get_db_fn() + try: + conn.execute( + "UPDATE journal_entries SET image=? WHERE id=?", + (saved, entry_id), + ) + conn.commit() + finally: + conn.close() + except Exception: + logger.exception("journal chart async failed entry_id=%s", entry_id) + + threading.Thread( + target=_run, + name=f"journal-chart-{entry_id[:8]}", + daemon=True, + ).start() + + +def request_wants_journal_ajax(request: Any) -> bool: + """XHR / Accept:json / form ajax=1 → 返回 JSON,避免整页刷新.""" + xrw = str(getattr(request, "headers", {}).get("X-Requested-With") or "").lower() + if xrw == "xmlhttprequest": + return True + form = getattr(request, "form", None) + if form is not None: + raw = str(form.get("ajax") or "").strip().lower() + if raw in ("1", "true", "yes", "on"): + return True + accept = str(getattr(request, "headers", {}).get("Accept") or "").lower() + if "application/json" in accept and accept.strip().startswith("application/json"): + return True + return False + + +def journal_ajax_or_flash_error(request: Any, msg: str, *, redirect_fn: Callable[[], Any]): + """校验失败:AJAX 返回 JSON,否则 flash + 跳转.""" + from flask import flash, jsonify + + if request_wants_journal_ajax(request): + return jsonify({"ok": False, "msg": msg}), 400 + flash(msg) + return redirect_fn() diff --git a/lib/instance/journal_chart_lib.py b/lib/instance/journal_chart_lib.py new file mode 100644 index 0000000..18bd9f5 --- /dev/null +++ b/lib/instance/journal_chart_lib.py @@ -0,0 +1,452 @@ +"""交易复盘 / 订单 K 线拼图(Binance / Gate / OKX 共用).""" + +import math + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + Image = None # type: ignore + ImageDraw = None # type: ignore + ImageFont = None # type: ignore + +JOURNAL_CHART_TF_CHOICES = ("1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "12h", "1d") +JOURNAL_CHART_DEFAULT_TF1 = "15m" +JOURNAL_CHART_DEFAULT_TF2 = "1h" +JOURNAL_CHART_DEFAULT_LIMIT = 300 +JOURNAL_CHART_LIMIT_MIN = 50 +JOURNAL_CHART_LIMIT_MAX = 500 +JOURNAL_CHART_ANCHOR_CLOSE = "close" +JOURNAL_CHART_ANCHOR_NOW = "now" +JOURNAL_CHART_DEFAULT_ANCHOR = JOURNAL_CHART_ANCHOR_CLOSE + + +def _load_font(size): + if not ImageFont: + return None + for name in ("msyh.ttc", "Microsoft YaHei.ttf", "arial.ttf", "Arial.ttf"): + try: + return ImageFont.truetype(name, 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 marker_tag_label(tag): + t = str(tag or "").strip().upper() + if t == "ENTRY": + return "开仓" + if t == "EXIT": + return "平仓" + if t == "STOP": + 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 parse_positive_price(raw): + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + try: + p = float(s) + return p if p > 0 else None + except (TypeError, ValueError): + return None + + +def parse_journal_chart_anchor(raw): + s = str(raw or "").strip().lower() + if s in (JOURNAL_CHART_ANCHOR_NOW, "current", "当前", "当前时间"): + return JOURNAL_CHART_ANCHOR_NOW + return JOURNAL_CHART_ANCHOR_CLOSE + + +def parse_journal_chart_limit(raw, fallback=None): + fb = int(fallback if fallback is not None else JOURNAL_CHART_DEFAULT_LIMIT) + try: + n = int(str(raw or "").strip() or fb) + except (TypeError, ValueError): + n = fb + return max(JOURNAL_CHART_LIMIT_MIN, min(JOURNAL_CHART_LIMIT_MAX, n)) + + +def normalize_chart_timeframe(raw): + tf = str(raw or "").strip().lower() + if tf in JOURNAL_CHART_TF_CHOICES: + return tf + return "" + + +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 _to_int_ms(value): + if value is None: + return None + try: + v = int(value) + return v if v > 0 else None + except (TypeError, ValueError): + return None + + +def trade_review_fetch_window(entry_ts_ms, exit_ts_ms, timeframe, limit, anchor=None, now_ms=None): + """ + 复盘 K 线窗口(anchor=close): + - 有开/平仓:从开仓前若干根起,到平仓 K 线止(覆盖整笔交易 + 入场前背景) + - 仅开仓:以开仓时间为终点向前 limit 根 + - 仅平仓:以平仓时间为终点向前 limit 根 + anchor=now:以当前时间为终点向前 limit 根(可看平仓后走势) + """ + period = timeframe_period_ms(timeframe) + lim = max(2, int(limit)) + entry_ms = _to_int_ms(entry_ts_ms) + exit_ms = _to_int_ms(exit_ts_ms) + anch = (anchor or JOURNAL_CHART_DEFAULT_ANCHOR).strip().lower() + + if anch == JOURNAL_CHART_ANCHOR_NOW: + end_ms = _to_int_ms(now_ms) + if not end_ms: + return None + since_ms = end_ms - period * (lim + 10) + return { + "since_ms": since_ms, + "end_ms": end_ms, + "window_start_ms": since_ms, + "fetch_limit": lim + 20, + "display_limit": lim, + } + + if entry_ms and exit_ms: + if exit_ms < entry_ms: + entry_ms, exit_ms = exit_ms, entry_ms + span_bars = max(1, (exit_ms - entry_ms) // period + 1) + pre_bars = max(40, min(120, lim // 3)) + need = span_bars + pre_bars + fetch_limit = min(JOURNAL_CHART_LIMIT_MAX, max(lim, need + 15)) + since_ms = entry_ms - period * pre_bars + return { + "since_ms": since_ms, + "end_ms": exit_ms, + "window_start_ms": since_ms, + "fetch_limit": fetch_limit, + "display_limit": lim, + } + if entry_ms: + end_ms = entry_ms + since_ms = end_ms - period * (lim + 10) + return { + "since_ms": since_ms, + "end_ms": end_ms, + "window_start_ms": since_ms, + "fetch_limit": lim + 20, + "display_limit": lim, + } + if exit_ms: + end_ms = exit_ms + since_ms = end_ms - period * (lim + 10) + return { + "since_ms": since_ms, + "end_ms": end_ms, + "window_start_ms": since_ms, + "fetch_limit": lim + 20, + "display_limit": lim, + } + return None + + +def trim_rows_for_trade_review(rows, window): + if not window: + return list(rows or []) + start_ms = int(window["window_start_ms"]) + end_ms = int(window["end_ms"]) + lim = int(window["display_limit"]) + filt = [r for r in (rows or []) if start_ms <= int(r["ts"]) <= end_ms] + if len(filt) > lim: + filt = filt[-lim:] + return filt + + +def parse_journal_chart_timeframes(tf1, tf2, fallback_tfs=None): + """复盘表单:最多两个周期,去重保序.""" + out = [] + for raw in (tf1, tf2): + tf = normalize_chart_timeframe(raw) + if tf and tf not in out: + out.append(tf) + if out: + return out[:2] + fb = [normalize_chart_timeframe(x) for x in (fallback_tfs or (JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2))] + fb = [x for x in fb if x] + return fb[:2] if fb else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2] + + +def marker_points_for_timeframe(rows, marker_payload): + points = [] + if not marker_payload or not rows: + return points + entry_idx, entry_price = pick_marker_point( + rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price") + ) + exit_idx, exit_price = pick_marker_point( + rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price") + ) + if entry_idx is not None and entry_price is not None: + points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"}) + if exit_idx is not None and exit_price is not None: + points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"}) + return points + + +def price_levels_from_marker_payload(marker_payload): + levels = [] + if not marker_payload: + return levels + sl = parse_positive_price(marker_payload.get("stop_loss_price")) + if sl is not None: + levels.append({"price": sl, "label": "止损", "color": (255, 152, 0)}) + return levels + + +def render_candles_subplot( + rows, + title, + width, + height, + bg_rgb=(255, 255, 255), + marker_points=None, + price_levels=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) + for pl in price_levels or []: + try: + p = float(pl.get("price")) + if p > 0: + lo = min(lo, p) + hi = max(hi, p) + except (TypeError, ValueError): + pass + 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 + + x_right = pad_l + plot_w + for pl in price_levels or []: + try: + p = float(pl.get("price")) + except (TypeError, ValueError): + continue + if p <= 0: + continue + y_sl = pad_t + int((hi - p) / (hi - lo) * plot_h) + color = tuple(pl.get("color") or (255, 152, 0)) + label = str(pl.get("label") or "止损") + for xx in range(pad_l, x_right, 10): + draw.line((xx, y_sl, min(xx + 6, x_right), y_sl), fill=color, width=2) + if font: + draw.text((x_right - 72, y_sl - 18), label, fill=color, font=small or font) + else: + draw.text((x_right - 72, y_sl - 18), label, fill=color) + + 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 compose_chart_panels(panels, layout="grid", cell_w=980, cell_h=520, gap=10): + if not panels or not Image: + return None + if layout == "vertical": + cols = 1 + rows_n = len(panels) + else: + cols = 2 + rows_n = int(math.ceil(len(panels) / cols)) + w = cols * cell_w + (cols - 1) * gap + h = rows_n * cell_h + (rows_n - 1) * gap + out = Image.new("RGB", (w, h), (255, 255, 255)) + idx = 0 + for r in range(rows_n): + for c in range(cols): + if idx >= len(panels): + break + x = c * (cell_w + gap) + y = r * (cell_h + gap) + out.paste(panels[idx], (x, y)) + idx += 1 + + if ImageDraw and layout != "vertical" and rows_n >= 1: + draw_out = ImageDraw.Draw(out) + line_col = (220, 225, 232) + x_mid = cell_w + gap // 2 + if w > x_mid >= 0: + draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2) + for rr in range(1, rows_n): + y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 + if 0 <= y_mid <= h: + draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) + elif ImageDraw and layout == "vertical" and rows_n >= 2: + draw_out = ImageDraw.Draw(out) + line_col = (220, 225, 232) + for rr in range(1, rows_n): + y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 + if 0 <= y_mid <= h: + draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) + return out diff --git a/lib/instance/journal_form_lib.py b/lib/instance/journal_form_lib.py new file mode 100644 index 0000000..54e21c6 --- /dev/null +++ b/lib/instance/journal_form_lib.py @@ -0,0 +1,53 @@ +"""复盘表单:下单类型与开仓类型校验(三所共用).""" +from __future__ import annotations + +from typing import Optional, Sequence, Tuple + +from lib.trade.trade_labels_lib import ( + JOURNAL_ORDER_TYPE_OPTIONS, + STRATEGY_ENTRY_REASON_OPTIONS, + normalize_journal_order_type, +) +from lib.trade.entry_model_lib import ( + TRADE_STYLE_FALLBACK_ENTRY_REASONS, + normalize_review_entry_reason, +) + +_LEGACY_JOURNAL_ENTRY_REASONS: Tuple[str, ...] = ( + *TRADE_STYLE_FALLBACK_ENTRY_REASONS, + *STRATEGY_ENTRY_REASON_OPTIONS, +) + + +def normalize_journal_entry_reason( + raw: Optional[str], + allowed: Sequence[str], + *, + allow_legacy: bool = False, +) -> str: + s = normalize_review_entry_reason(raw, allowed) + if s: + return s + if not allow_legacy: + return "" + legacy = (raw or "").strip() + if legacy in _LEGACY_JOURNAL_ENTRY_REASONS: + return legacy + return "" + + +def journal_entry_reason_valid(raw: Optional[str], allowed: Sequence[str]) -> bool: + return bool(normalize_journal_entry_reason(raw, allowed, allow_legacy=False)) + + +def journal_order_type_valid(raw: Optional[str]) -> bool: + return bool(normalize_journal_order_type(raw)) + + +def normalize_journal_direction(raw: Optional[str]) -> str: + s = (raw or "").strip().lower() + if s in ("long", "buy", "多", "做多"): + return "long" + if s in ("short", "sell", "空", "做空"): + return "short" + return "" diff --git a/lib/instance/journal_images_lib.py b/lib/instance/journal_images_lib.py new file mode 100644 index 0000000..dbe420e --- /dev/null +++ b/lib/instance/journal_images_lib.py @@ -0,0 +1,208 @@ +"""复盘记录:多周期截图上传,存储与读取(三所共用).""" +from __future__ import annotations + +import json +import os +import re +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence + +JOURNAL_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h") +JOURNAL_UPLOAD_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}) +_JOURNAL_DRAFT_ID_RE = re.compile(r"^[a-f0-9]{32}$") +_JOURNAL_SLOT_FILE_RE = re.compile( + r"^journal_([a-f0-9]{32})_(5m|15m|1h|4h)\.(png|jpg|jpeg|webp|gif|bmp)$", + re.I, +) + + +def journal_upload_field_name(tf: str) -> str: + return f"screenshot_{tf}" + + +def uploaded_screenshot_field_name(tf: str) -> str: + return f"uploaded_screenshot_{tf}" + + +def normalize_journal_draft_id(raw: Any) -> Optional[str]: + s = str(raw or "").strip().lower() + if _JOURNAL_DRAFT_ID_RE.match(s): + return s + return None + + +def _safe_ext(filename: str) -> str: + ext = os.path.splitext(str(filename or ""))[1].lower() + return ext if ext in JOURNAL_UPLOAD_ALLOWED_EXT else ".png" + + +def build_journal_slot_filename( + entry_id: str, + tf: str, + ext: str, + *, + secure_filename_fn: Callable[[str], str], +) -> str: + ext = ext if ext.startswith(".") else f".{ext}" + ext = _safe_ext(f"x{ext}") + fname = secure_filename_fn(f"journal_{entry_id}_{tf}{ext}") + return fname or "" + + +def is_valid_preuploaded_journal_file(filename: str, entry_id: str, tf: str) -> bool: + fn = os.path.basename(str(filename or "").strip()) + if not fn or fn != str(filename or "").strip(): + return False + m = _JOURNAL_SLOT_FILE_RE.match(fn) + if not m: + return False + return m.group(1) == entry_id.lower() and m.group(2) == tf + + +def save_journal_slot_file( + file, + entry_id: str, + tf: str, + upload_folder: str, + *, + secure_filename_fn: Callable[[str], str], +) -> Optional[Dict[str, str]]: + if tf not in JOURNAL_UPLOAD_TFS or not entry_id or not upload_folder: + return None + if not file or not getattr(file, "filename", None): + return None + ext = _safe_ext(file.filename) + fname = build_journal_slot_filename( + entry_id, tf, ext, secure_filename_fn=secure_filename_fn + ) + if not fname: + return None + os.makedirs(upload_folder, exist_ok=True) + path = os.path.join(upload_folder, fname) + file.save(path) + return {"tf": tf, "file": fname} + + +def collect_journal_slot_images( + form, + files, + entry_id: str, + upload_folder: str, + *, + secure_filename_fn: Callable[[str], str], +) -> List[Dict[str, str]]: + """优先使用即时上传 hidden 字段;否则回退到表单 multipart.""" + saved: List[Dict[str, str]] = [] + if not entry_id or not upload_folder: + return saved + for tf in JOURNAL_UPLOAD_TFS: + pre = "" + if form is not None: + pre = str(form.get(uploaded_screenshot_field_name(tf)) or "").strip() + if pre and is_valid_preuploaded_journal_file(pre, entry_id, tf): + path = os.path.join(upload_folder, os.path.basename(pre)) + if os.path.isfile(path): + saved.append({"tf": tf, "file": os.path.basename(pre)}) + continue + f = files.get(journal_upload_field_name(tf)) if files else None + item = save_journal_slot_file( + f, + entry_id, + tf, + upload_folder, + secure_filename_fn=secure_filename_fn, + ) + if item: + saved.append(item) + return saved + + +def save_journal_slot_uploads( + files, + entry_id: str, + upload_folder: str, + *, + secure_filename_fn: Callable[[str], str], +) -> List[Dict[str, str]]: + """保存四槽位手动截图,返回 [{"tf":"5m","file":"journal_xxx_5m.png"}, ...].""" + return collect_journal_slot_images( + None, + files, + entry_id, + upload_folder, + secure_filename_fn=secure_filename_fn, + ) + + +def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]: + if not items: + return None + return json.dumps(list(items), ensure_ascii=False, separators=(",", ":")) + + +def parse_images_json(raw: Any) -> List[Dict[str, str]]: + if not raw: + return [] + if isinstance(raw, list): + data = raw + else: + try: + data = json.loads(str(raw)) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + if not isinstance(data, list): + return [] + out: List[Dict[str, str]] = [] + for item in data: + if not isinstance(item, dict): + continue + tf = str(item.get("tf") or "").strip() + file = str(item.get("file") or "").strip() + if file: + out.append({"tf": tf, "file": file}) + return out + + +def primary_journal_image( + manual_images: Sequence[Mapping[str, str]], + *, + fallback: Optional[str] = None, +) -> Optional[str]: + if manual_images: + return str(manual_images[0].get("file") or "").strip() or None + return fallback + + +def enrich_journal_api_item(item: Dict[str, Any]) -> Dict[str, Any]: + """API 输出:解析 images_json,兼容旧单图 image 字段.""" + images = parse_images_json(item.get("images_json")) + if not images and item.get("image"): + images = [{"tf": "", "file": str(item["image"]).strip()}] + item["images"] = images + return item + + +def journal_image_paths(row: Any, upload_folder: str) -> List[str]: + """删除 / AI 附图:收集本条复盘所有本地图片路径(去重).""" + upload_folder = os.path.abspath(upload_folder or "") + paths: List[str] = [] + seen = set() + + def _add(name: Optional[str]) -> None: + if not name: + return + p = os.path.abspath(os.path.join(upload_folder, str(name).strip())) + if os.path.isfile(p) and p not in seen: + seen.add(p) + paths.append(p) + + try: + keys = row.keys() if hasattr(row, "keys") else () + except Exception: + keys = () + + if "images_json" in keys and row["images_json"]: + for img in parse_images_json(row["images_json"]): + _add(img.get("file")) + if "image" in keys: + _add(row["image"]) + return paths diff --git a/lib/instance/journal_upload_api_lib.py b/lib/instance/journal_upload_api_lib.py new file mode 100644 index 0000000..cb6a94c --- /dev/null +++ b/lib/instance/journal_upload_api_lib.py @@ -0,0 +1,43 @@ +"""复盘截图即时上传 API(三所共用).""" +from __future__ import annotations + +from typing import Any, Callable, Dict, Tuple + +from lib.instance.journal_images_lib import ( + JOURNAL_UPLOAD_TFS, + normalize_journal_draft_id, + save_journal_slot_file, +) + + +def handle_journal_upload_slot( + request: Any, + *, + upload_folder: str, + secure_filename_fn: Callable[[str], str], +) -> Tuple[Dict[str, Any], int]: + """POST multipart: journal_draft_id, tf, file → {ok, file}.""" + draft_id = normalize_journal_draft_id( + request.form.get("journal_draft_id") if request.form else None + ) + tf = str((request.form.get("tf") if request.form else None) or "").strip() + if not draft_id: + return {"ok": False, "error": "invalid draft_id"}, 400 + if tf not in JOURNAL_UPLOAD_TFS: + return {"ok": False, "error": "invalid tf"}, 400 + + f = request.files.get("file") if request.files else None + if not f or not getattr(f, "filename", None): + return {"ok": False, "error": "no file"}, 400 + + item = save_journal_slot_file( + f, + draft_id, + tf, + upload_folder, + secure_filename_fn=secure_filename_fn, + ) + if not item: + return {"ok": False, "error": "save failed"}, 500 + + return {"ok": True, "tf": tf, "file": item["file"]}, 200 diff --git a/lib/instance/records_api_register.py b/lib/instance/records_api_register.py new file mode 100644 index 0000000..ee05ae3 --- /dev/null +++ b/lib/instance/records_api_register.py @@ -0,0 +1,66 @@ +"""注册 /api/trade_records(三所共用).""" + +from __future__ import annotations + +from typing import Any, Callable + +from flask import Flask, jsonify, request + + +def register_trade_records_api( + app: Flask, + *, + login_required: Callable, + get_db: Callable, + list_window_from_request: Callable[[], dict[str, Any]], + utc_window_to_bj_sql_strings: Callable[..., tuple[str, str]], + sql_list_time_field: Callable[..., str], + to_effective_trade_dict: Callable[[Any], dict[str, Any]], + filter_trade_records_excluding_miss: Callable[[list], list], + app_tz: Any, + format_price_fn: Callable[[Any, Any], str] | None = None, + sync_exchange_pnl_fn: Callable[[Any], Any] | None = None, +) -> None: + """ + sync_exchange_pnl_fn(conn): 可选,列表前节流回填交易所已实现盈亏. + 中控只走本 API,不经实例整页渲染,必须在此触发,否则盈亏U会一直显示「估」. + """ + from lib.instance.records_list_lib import list_trade_records_page + + @app.route("/api/trade_records") + @login_required + def api_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 + ) + tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") + try: + limit = int(request.args.get("limit") or 5) + except (TypeError, ValueError): + limit = 5 + try: + offset = int(request.args.get("offset") or 0) + except (TypeError, ValueError): + offset = 0 + conn = get_db() + try: + if sync_exchange_pnl_fn is not None: + try: + sync_exchange_pnl_fn(conn) + except Exception: + pass + payload = list_trade_records_page( + conn, + start_bj, + end_bj, + tr_ts=tr_ts, + to_effective_fn=to_effective_trade_dict, + filter_fn=filter_trade_records_excluding_miss, + limit=limit, + offset=offset, + format_price_fn=format_price_fn, + ) + return jsonify(payload) + finally: + conn.close() diff --git a/lib/instance/records_list_lib.py b/lib/instance/records_list_lib.py new file mode 100644 index 0000000..8b8062b --- /dev/null +++ b/lib/instance/records_list_lib.py @@ -0,0 +1,72 @@ +"""交易记录列表分页(三所 /records 共用).""" + +from __future__ import annotations + +from typing import Any, Callable, Optional + + +def enrich_trade_price_displays( + item: dict[str, Any], + format_price_fn: Optional[Callable[[Any, Any], str]] = None, +) -> dict[str, Any]: + """为成交/止损/止盈补交易所精度展示字段(供交易记录表直接渲染).""" + if not format_price_fn or not isinstance(item, dict): + return item + sym = item.get("symbol") + stop_show = item.get("display_open_stop_loss") + if stop_show in (None, ""): + stop_show = item.get("initial_stop_loss") + if stop_show in (None, ""): + stop_show = item.get("stop_loss") + tp_show = item.get("effective_take_profit") + if tp_show in (None, ""): + tp_show = item.get("take_profit") + try: + item["trigger_price_display"] = format_price_fn(sym, item.get("trigger_price")) + item["stop_loss_display"] = format_price_fn(sym, stop_show) + item["take_profit_display"] = format_price_fn(sym, tp_show) + except Exception: + pass + return item + + +def list_trade_records_page( + conn: Any, + start_bj: str, + end_bj: str, + *, + tr_ts: str, + to_effective_fn: Callable[[Any], dict[str, Any]], + filter_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]], + limit: int = 5, + offset: int = 0, + fetch_cap: int = 1000, + format_price_fn: Optional[Callable[[Any, Any], str]] = None, +) -> dict[str, Any]: + """按列表窗拉取、enrich、过滤「错过」后分页.""" + limit = max(1, min(100, int(limit or 5))) + offset = max(0, int(offset or 0)) + raw_records = conn.execute( + f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? " + f"ORDER BY id DESC LIMIT ?", + (start_bj, end_bj, int(fetch_cap)), + ).fetchall() + records = filter_fn([to_effective_fn(r) for r in raw_records]) + total = len(records) + pages = max(1, (total + limit - 1) // limit) if total else 1 + page = (offset // limit) + 1 if limit else 1 + if page > pages: + page = pages + offset = (page - 1) * limit + items = records[offset : offset + limit] + if format_price_fn is not None: + items = [enrich_trade_price_displays(dict(it), format_price_fn) for it in items] + return { + "ok": True, + "items": items, + "total": total, + "limit": limit, + "offset": offset, + "page": page, + "pages": pages, + } diff --git a/lib/instance/runtime_config_lib.py b/lib/instance/runtime_config_lib.py new file mode 100644 index 0000000..43ea963 --- /dev/null +++ b/lib/instance/runtime_config_lib.py @@ -0,0 +1,62 @@ +"""env 运行时覆盖:热生效项优先读 SQLite,再回退 os.environ.""" +from __future__ import annotations + +import os +from typing import Callable, Optional + +from lib.env.env_file_lib import load_env_file_into_environ +from lib.instance.runtime_settings_lib import runtime_get, with_db + +ENV_OVERRIDE_PREFIX = "env." + + +def runtime_env_key(name: str) -> str: + return ENV_OVERRIDE_PREFIX + name + + +def get_config(key: str, get_db: Callable, default: Optional[str] = None) -> Optional[str]: + def _read(conn): + v = runtime_get(conn, runtime_env_key(key)) + return v + + try: + v = with_db(get_db, _read) + if v is not None: + return v + except Exception: + pass + raw = os.getenv(key) + if raw is None or raw == "": + return default + return raw + + +def set_config_overrides(get_db: Callable, mapping: dict[str, str]) -> None: + from lib.instance.runtime_settings_lib import runtime_set_many + + def _write(conn): + payload = {runtime_env_key(k): str(v) for k, v in mapping.items()} + runtime_set_many(conn, payload) + + with_db(get_db, _write) + + +def apply_env_reload(env_path: str, get_db: Callable, changed_keys: list[str], groups: list[dict]) -> dict[str, bool]: + """写盘后同步 os.environ,并将可热生效项写入 runtime 覆盖.""" + load_env_file_into_environ(env_path) + hot: dict[str, str] = {} + field_map = {} + for group in groups: + for field in group.get("fields") or []: + field_map[field["key"]] = field + for key in changed_keys: + meta = field_map.get(key) or {} + if meta.get("hot_reload") and not meta.get("restart_required"): + val = os.getenv(key) + if val is not None: + hot[key] = val + if hot: + set_config_overrides(get_db, hot) + from lib.env.env_schema import updates_need_restart + + return {"restart_required": updates_need_restart(groups, changed_keys)} diff --git a/lib/instance/runtime_settings_lib.py b/lib/instance/runtime_settings_lib.py new file mode 100644 index 0000000..36933c6 --- /dev/null +++ b/lib/instance/runtime_settings_lib.py @@ -0,0 +1,71 @@ +"""实例 SQLite 运行时配置(导航开关,env 热覆盖等).""" +from __future__ import annotations + +import sqlite3 +from datetime import datetime +from typing import Any, Callable, Optional + +RUNTIME_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS app_runtime_settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) +""" + + +def ensure_runtime_settings_table(conn: sqlite3.Connection) -> None: + conn.execute(RUNTIME_TABLE_SQL) + conn.commit() + + +def runtime_get(conn: sqlite3.Connection, key: str) -> Optional[str]: + row = conn.execute( + "SELECT value FROM app_runtime_settings WHERE key=?", + (key,), + ).fetchone() + if not row: + return None + val = row["value"] if isinstance(row, sqlite3.Row) else row[0] + return None if val is None else str(val) + + +def runtime_set(conn: sqlite3.Connection, key: str, value: str) -> None: + now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + 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", + (key, value, now), + ) + conn.commit() + + +def runtime_get_prefix(conn: sqlite3.Connection, prefix: str) -> dict[str, str]: + rows = conn.execute( + "SELECT key, value FROM app_runtime_settings WHERE key LIKE ?", + (prefix + "%",), + ).fetchall() + out: dict[str, str] = {} + for row in rows: + k = row["key"] if isinstance(row, sqlite3.Row) else row[0] + v = row["value"] if isinstance(row, sqlite3.Row) else row[1] + if k.startswith(prefix): + out[k[len(prefix) :]] = v if v is not None else "" + return out + + +def runtime_set_many(conn: sqlite3.Connection, mapping: dict[str, str]) -> None: + for key, value in mapping.items(): + runtime_set(conn, key, value) + + +def with_db( + get_db: Callable[[], sqlite3.Connection], + fn: Callable[[sqlite3.Connection], Any], +) -> Any: + conn = get_db() + try: + ensure_runtime_settings_table(conn) + return fn(conn) + finally: + conn.close() diff --git a/lib/instance/templates/dashboard_panel.html b/lib/instance/templates/dashboard_panel.html new file mode 100644 index 0000000..6b7342b --- /dev/null +++ b/lib/instance/templates/dashboard_panel.html @@ -0,0 +1,15 @@ +{# 实例数据看板:只读活跃监控总览 #} +
    +
    +
    +

    数据看板

    +

    本户活跃监控总览 · 只读 · 后台快照 + SSE · 无数据的区块不显示

    +
    +
    + + +
    +
    +

    +
    +
    diff --git a/lib/instance/templates/display_prefs_panel.html b/lib/instance/templates/display_prefs_panel.html new file mode 100644 index 0000000..edcbecc --- /dev/null +++ b/lib/instance/templates/display_prefs_panel.html @@ -0,0 +1,28 @@ +{# 系统设置 · 导航显示开关(SSR 预渲染,保存仍走 API) #} +
    +

    导航显示

    +

    以下开关控制顶栏导航与系统设置内区块是否显示,保存后立即生效.系统设置为固定项.

    +
    + {% if display_meta %} + {% for group in display_meta %} +
    +

    {{ group.group }}

    +
    + {% for item in group.entries %} + + {% endfor %} +
    +
    + {% endfor %} + {% else %} +
    加载中…
    + {% endif %} +
    +
    + + +
    +
    diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html new file mode 100644 index 0000000..ac79c2e --- /dev/null +++ b/lib/instance/templates/embed_boot_scripts.html @@ -0,0 +1,1521 @@ + diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html new file mode 100644 index 0000000..cafc65f --- /dev/null +++ b/lib/instance/templates/embed_page_fragment.html @@ -0,0 +1,152 @@ +{# Hub iframe tab fragment — shared via embed_templates #} +{% macro period_stats_pane(period_key, s) %} +{% set win_pct = s.win_rate_pct if s.win_rate_pct is not none else 0 %} +{% set profit_sum = (s.net_pnl_u + s.loss_sum_u) if s.closed_count else 0 %} +{% set loss_sum = s.loss_sum_u %} +{% set pnl_total = profit_sum + loss_sum %} +{% set profit_bar_w = (profit_sum / pnl_total * 100) if pnl_total > 0 else 0 %} +{% set loss_bar_w = (loss_sum / pnl_total * 100) if pnl_total > 0 else 0 %} +{% set net_cls = 'pos-pnl-profit' if s.net_pnl_u > 0 else ('pos-pnl-loss' if s.net_pnl_u < 0 else '') %} +
    +
    {{ s.range_label }}
    +
    + {% if s.closed_count %} +
    +
    + {% if s.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(s.net_pnl_u) }}U + 净盈亏 +
    +
    +
    + {% if s.win_rate_pct is not none %}{{ win_pct|round(0)|int }}%{% else %}—{% endif %} +
    + {{ s.win_count }}胜 {{ s.loss_count }}负 +
    +
    + {{ s.opens_count }} / {{ s.closed_count }} + 开单 / 平仓 +
    +
    +
    +
    盈亏构成
    +
    +
    +
    +
    +
    + 盈利 {{ funds_fmt(profit_sum) }}U + 亏损 {{ funds_fmt(loss_sum) }}U +
    +
    +
    +
    +
    + 最大回撤 + {{ funds_fmt(s.max_drawdown_u) }}U +
    +
    + 连续亏损 + {{ s.consecutive_losses }} 笔 +
    +
    + 最长连亏日 + {{ s.max_loss_streak_days }} 天 +
    +
    + 最大亏损日 + {% if s.worst_day %}{{ s.worst_day }} ({{ funds_fmt(s.worst_day_pnl) }}U){% else %}—{% endif %} +
    +
    +
    + {% else %} +

    当前区间暂无平仓数据

    + {% endif %} +
    +
    + 详细指标 +
    +
    开单次数
    {{ s.opens_count }}
    +
    平仓笔数
    {{ s.closed_count }}
    +
    胜率
    {% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
    +
    净盈亏(U)
    {{ funds_fmt(s.net_pnl_u) }}
    +
    亏损额合计(U)
    {{ funds_fmt(s.loss_sum_u) }}
    +
    单笔最大亏损(U)
    {% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}
    +
    单笔最大盈利(U)
    {% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}
    +
    最大回撤(U)
    {{ funds_fmt(s.max_drawdown_u) }}
    +
    当前连续亏损笔数
    {{ s.consecutive_losses }}
    +
    最长连续亏损(交易日)
    {{ s.max_loss_streak_days }} 天
    +
    期内最大亏损日
    {% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
    +
    +
    + {% if period_key == 'all' %} +
    +
    按月统计
    + {% if s.monthly_rows %} +
    + + + + + + + + + + + + + {% for m in s.monthly_rows %} + {% set m_net_cls = 'pos-pnl-profit' if m.net_pnl_u > 0 else ('pos-pnl-loss' if m.net_pnl_u < 0 else '') %} + + + + + + + + + {% endfor %} + +
    月份开单平仓胜率净盈亏最大回撤
    {{ m.month_key }}{{ m.opens_count }}{{ m.closed_count }}{% if m.win_rate_pct is not none %}{{ m.win_rate_pct }}%{% else %}—{% endif %}{% if m.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(m.net_pnl_u) }}{{ funds_fmt(m.max_drawdown_u) }}
    +
    + {% else %} +

    暂无按月平仓数据

    + {% endif %} +
    + {% endif %} +
    +{% endmacro %} +
    + {% if page == 'dashboard' %} + {% include 'dashboard_panel.html' %} + {% elif page == 'account_ledger' %} + {% include 'account_ledger_panel.html' %} + {% elif page == 'key_monitor' %} + {% include 'key_monitor_panel.html' %} + {% elif page == 'options' %} + {% include 'options_panel.html' %} + {% elif page == 'options_review' %} + {% include 'options_review_panel.html' %} + {% elif page == 'hedge_plan' %} + {% include 'hedge_plan_panel.html' %} + {% endif %} + + + + {% if page == 'env_config' %} + {% include 'env_config_panel.html' %} + {% endif %} + + {% if page == 'risk_policy' %} + {% include 'risk_policy_panel.html' %} + {% endif %} + + {% if page == 'system_guide' %} + {% include 'system_guide_panel.html' %} + {% endif %} + + {% if page == 'settings' %} + {% include 'settings_panel.html' %} + {% endif %} + + diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html new file mode 100644 index 0000000..ce432b6 --- /dev/null +++ b/lib/instance/templates/embed_shell.html @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + {{ pwa_app_name }} + + +
    +
    +

    加密货币|OKX 期权与对冲

    +
    + + + + {% include 'instance_header_panel.html' %} + {% if initial_tab not in ('settings', 'risk_policy', 'system_guide', 'env_config') and include_transfer_block %} + {% include 'instance_top_bar.html' %} + {% endif %} + +
    + {% include 'embed_page_fragment.html' %} +
    +
    + + +
    +
    +
    +
    详情
    +
    + + +
    +
    +
    + + +
    +
    + + + + + + + + + + + + + + + + + +{% include 'embed_boot_scripts.html' %} + + + + + + + + + + diff --git a/lib/instance/templates/env_config_panel.html b/lib/instance/templates/env_config_panel.html new file mode 100644 index 0000000..6568d47 --- /dev/null +++ b/lib/instance/templates/env_config_panel.html @@ -0,0 +1,108 @@ +{# env配置:CSS Tab(无需 JS)+ 双列表单 #} +
    +
    +
    +
    +

    env 配置

    +

    按分类修改,改完点保存.含「需重启」的项请用「保存并重启」.AI 配置请在中控 → 系统设置 → AI 配置统一维护.

    +
    +
    + + + +
    +
    + +
    + + {% if env_config_groups %} + {% set ns = namespace(mode_idx=0) %} + {% for group in env_config_groups %} + {% if '期权/对冲模式' in (group.title or '') %}{% set ns.mode_idx = loop.index0 %}{% endif %} + {% endfor %} +
    + {% for group in env_config_groups %} + + {% endfor %} +
    + {% for group in env_config_groups %} + + {% endfor %} +
    +
    + {% for group in env_config_groups %} +
    + {% if group.has_restart %} +

    本组含需重启项,修改后请点「保存并重启」.

    + {% endif %} +
    + {% for field in group.fields %} + + {% endfor %} +
    +
    + {% endfor %} +
    +
    + {% else %} +
    +
    加载配置中…
    +
    + {% endif %} +
    diff --git a/lib/instance/templates/force_close_header_badge.html b/lib/instance/templates/force_close_header_badge.html new file mode 100644 index 0000000..3442516 --- /dev/null +++ b/lib/instance/templates/force_close_header_badge.html @@ -0,0 +1,8 @@ +{% if force_close.enabled %} + + {{ force_close.label }} 已开启 · {{ force_close.countdown or '--:--:--' }} + +{% endif %} diff --git a/lib/instance/templates/force_close_order_badge.html b/lib/instance/templates/force_close_order_badge.html new file mode 100644 index 0000000..e82f63f --- /dev/null +++ b/lib/instance/templates/force_close_order_badge.html @@ -0,0 +1,8 @@ +{% if force_close.enabled %} + + {{ o.force_close_label or force_close.label }} + · {{ o.force_close_countdown or force_close.countdown or '--:--:--' }} + +{% endif %} diff --git a/lib/instance/templates/gate_transfer_block.html b/lib/instance/templates/gate_transfer_block.html new file mode 100644 index 0000000..bedceb9 --- /dev/null +++ b/lib/instance/templates/gate_transfer_block.html @@ -0,0 +1,26 @@ +
    + + 实时价格更新:--(北京时间 UTC+8) + · 划转规则 + +
    + 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U:不足从 {{ auto_transfer_from }} 划入,超出划回 {{ auto_transfer_from }};持仓中不划转并微信通知) +
    +
    +
    + + + + + +
    diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html new file mode 100644 index 0000000..7cca29a --- /dev/null +++ b/lib/instance/templates/index.html @@ -0,0 +1,1834 @@ +{# 三所共用 standalone 主页 — 由 scripts/build_unified_index.py 生成,勿手改三所副本 #} + + + + + + + + + + + + + + + + + + + {{ pwa_app_name }} + + + + + +{% macro period_stats_pane(period_key, s) %} +{% set win_pct = s.win_rate_pct if s.win_rate_pct is not none else 0 %} +{% set profit_sum = (s.net_pnl_u + s.loss_sum_u) if s.closed_count else 0 %} +{% set loss_sum = s.loss_sum_u %} +{% set pnl_total = profit_sum + loss_sum %} +{% set profit_bar_w = (profit_sum / pnl_total * 100) if pnl_total > 0 else 0 %} +{% set loss_bar_w = (loss_sum / pnl_total * 100) if pnl_total > 0 else 0 %} +{% set net_cls = 'pos-pnl-profit' if s.net_pnl_u > 0 else ('pos-pnl-loss' if s.net_pnl_u < 0 else '') %} +
    +
    {{ s.range_label }}
    +
    + {% if s.closed_count %} +
    +
    + {% if s.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(s.net_pnl_u) }}U + 净盈亏 +
    +
    +
    + {% if s.win_rate_pct is not none %}{{ win_pct|round(0)|int }}%{% else %}—{% endif %} +
    + {{ s.win_count }}胜 {{ s.loss_count }}负 +
    +
    + {{ s.opens_count }} / {{ s.closed_count }} + 开单 / 平仓 +
    +
    +
    +
    盈亏构成
    +
    +
    +
    +
    +
    + 盈利 {{ funds_fmt(profit_sum) }}U + 亏损 {{ funds_fmt(loss_sum) }}U +
    +
    +
    +
    +
    + 最大回撤 + {{ funds_fmt(s.max_drawdown_u) }}U +
    +
    + 连续亏损 + {{ s.consecutive_losses }} 笔 +
    +
    + 最长连亏日 + {{ s.max_loss_streak_days }} 天 +
    +
    + 最大亏损日 + {% if s.worst_day %}{{ s.worst_day }} ({{ funds_fmt(s.worst_day_pnl) }}U){% else %}—{% endif %} +
    +
    +
    + {% else %} +

    当前区间暂无平仓数据

    + {% endif %} +
    +
    + 详细指标 +
    +
    开单次数
    {{ s.opens_count }}
    +
    平仓笔数
    {{ s.closed_count }}
    +
    胜率
    {% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
    +
    净盈亏(U)
    {{ funds_fmt(s.net_pnl_u) }}
    +
    亏损额合计(U)
    {{ funds_fmt(s.loss_sum_u) }}
    +
    单笔最大亏损(U)
    {% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}
    +
    单笔最大盈利(U)
    {% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}
    +
    最大回撤(U)
    {{ funds_fmt(s.max_drawdown_u) }}
    +
    当前连续亏损笔数
    {{ s.consecutive_losses }}
    +
    最长连续亏损(交易日)
    {{ s.max_loss_streak_days }} 天
    +
    期内最大亏损日
    {% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
    +
    +
    + {% if period_key == 'all' %} +
    +
    按月统计
    + {% if s.monthly_rows %} +
    + + + + + + + + + + + + + {% for m in s.monthly_rows %} + {% set m_net_cls = 'pos-pnl-profit' if m.net_pnl_u > 0 else ('pos-pnl-loss' if m.net_pnl_u < 0 else '') %} + + + + + + + + + {% endfor %} + +
    月份开单平仓胜率净盈亏最大回撤
    {{ m.month_key }}{{ m.opens_count }}{{ m.closed_count }}{% if m.win_rate_pct is not none %}{{ m.win_rate_pct }}%{% else %}—{% endif %}{% if m.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(m.net_pnl_u) }}{{ funds_fmt(m.max_drawdown_u) }}
    +
    + {% else %} +

    暂无按月平仓数据

    + {% endif %} +
    + {% endif %} +
    +{% endmacro %} +
    +
    +

    加密货币|OKX 期权与对冲

    +
    +
    + + + + {% if options_nav_visible and display.show_nav_options %} + 期权 + {% endif %} + {% if options_nav_visible and display.show_nav_options_review %} + 期权复盘 + {% endif %} + {% if hedge_plan_nav_visible and display.show_nav_hedge_plan %} + 对冲计划 + {% endif %} + {% if display.show_nav_risk_policy %} + 风控说明 + {% endif %} + + {% if display.show_nav_env_config %} + env配置 + {% endif %} + 系统设置 +
    + {% with msg=get_flashed_messages() %}{% if msg %}
    {{ msg[0] }}
    {% endif %}{% endwith %} + + {% include 'instance_header_panel.html' %} + {% if page not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options', 'options_review', 'hedge_plan') %} + {% include 'instance_top_bar.html' %} + {% endif %} + +
    + {% if page == 'dashboard' %} + {% include 'dashboard_panel.html' %} + {% elif page == 'account_ledger' %} + {% include 'account_ledger_panel.html' %} + {% elif page == 'key_monitor' %} + {% include 'key_monitor_panel.html' %} + {% elif page == 'options' %} + {% include 'options_panel.html' %} + {% elif page == 'options_review' %} + {% include 'options_review_panel.html' %} + {% elif page == 'hedge_plan' %} + {% include 'hedge_plan_panel.html' %} + {% endif %} + + + + {% if page == 'env_config' %} + {% include 'env_config_panel.html' %} + {% endif %} + + {% if page == 'risk_policy' %} + {% include 'risk_policy_panel.html' %} + {% endif %} + + {% if page == 'system_guide' %} + {% include 'system_guide_panel.html' %} + {% endif %} + + {% if page == 'settings' %} + {% include 'settings_panel.html' %} + {% endif %} + + +
    + + +
    +
    +
    +
    详情
    +
    + + +
    +
    +
    + + +
    +
    + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/instance/templates/instance_header_panel.html b/lib/instance/templates/instance_header_panel.html new file mode 100644 index 0000000..f2cb7a4 --- /dev/null +++ b/lib/instance/templates/instance_header_panel.html @@ -0,0 +1,54 @@ +{# 统一顶栏:状态 + 筛选(上)· 统计条(下) #} +
    +
    +
    + UTC {{ list_window.label }} + + + + + + + 统计切日 {{ stats_bundle.stats_reset_hour|default(reset_hour) }}:00 +
    +
    +
    +
    {{ exchange_display }}
    + {% if trade_policy.badge_text %} + {{ trade_policy.badge_text }} + {% endif %} + {% include 'force_close_header_badge.html' %} + {{ risk_status.status_label|default('正常') }} +
    + {% include 'instance_theme_toggle.html' %} +
    +
    +
    + {% include 'instance_header_stats.html' %} +
    +
    + + + + 总资 + {% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %} + +
    +
    diff --git a/lib/instance/templates/instance_header_stats.html b/lib/instance/templates/instance_header_stats.html new file mode 100644 index 0000000..642255b --- /dev/null +++ b/lib/instance/templates/instance_header_stats.html @@ -0,0 +1,49 @@ +{# 资金与统计条(顶栏 / 系统设置共用,单行展示) #} +
    +
    +
    交易所
    +
    {{ exchange_display }}{% if is_sim_mode|default(false) %} 模拟{% endif %}
    +
    +
    +
    交易日
    +
    {{ trading_day }}
    +
    +
    +
    总交易
    +
    {{ total }}
    +
    +
    +
    胜率
    +
    {{ rate }}%
    +
    +
    +
    盈亏比
    +
    {% if profit_loss_ratio is not none %}{{ profit_loss_ratio }}{% else %}—{% endif %}
    +
    +
    +
    总资金
    +
    {% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}
    +
    + + + {% if options_enabled %} +
    +
    期权资金账户
    +
    {{ options_funding_label(options_funding_usdc) }}
    +
    +
    +
    期权交易账户
    +
    {{ options_funding_label(options_trading_usdc) }}
    +
    + {% endif %} +
    +
    实时盈亏
    +
    +
    +
    diff --git a/lib/instance/templates/instance_theme_toggle.html b/lib/instance/templates/instance_theme_toggle.html new file mode 100644 index 0000000..5ed8615 --- /dev/null +++ b/lib/instance/templates/instance_theme_toggle.html @@ -0,0 +1,12 @@ +
    + + +
    diff --git a/lib/instance/templates/instance_top_bar.html b/lib/instance/templates/instance_top_bar.html new file mode 100644 index 0000000..c6af1f0 --- /dev/null +++ b/lib/instance/templates/instance_top_bar.html @@ -0,0 +1,4 @@ +{# 三所统一顶栏:实时价(划转已移至系统设置;切点前开仓说明见风控说明·交易执行) #} +
    + 实时价格更新:--(北京时间 UTC+8) +
    diff --git a/lib/instance/templates/instance_transfer_panel.html b/lib/instance/templates/instance_transfer_panel.html new file mode 100644 index 0000000..1a7e89e --- /dev/null +++ b/lib/instance/templates/instance_transfer_panel.html @@ -0,0 +1,28 @@ +{# 系统设置 · 资金划转(三所共用) #} +
    +

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

    +
    + + + + + +
    +
    diff --git a/lib/instance/templates/journal_form_fields.html b/lib/instance/templates/journal_form_fields.html new file mode 100644 index 0000000..3ce3126 --- /dev/null +++ b/lib/instance/templates/journal_form_fields.html @@ -0,0 +1,44 @@ +{# 复盘表单:首行按字段宽度比例;下单类型/开仓类型与离场触发同一行 #} +{% macro journal_form_fields(entry_reason_options, order_type_options) -%} +
    + + + + + + + +
    +
    + + + + + + +
    +{%- endmacro %} diff --git a/lib/instance/templates/journal_upload_slots.html b/lib/instance/templates/journal_upload_slots.html new file mode 100644 index 0000000..b99e470 --- /dev/null +++ b/lib/instance/templates/journal_upload_slots.html @@ -0,0 +1,20 @@ +{# 复盘四周期截图槽位(须加载 journal_upload_slots.js) #} +{% macro journal_upload_slots() -%} + +
    + {% for tf in ['5m', '15m', '1h', '4h'] %} +
    + {{ tf }} + + + +
    + {% endfor %} +
    +

    可只传部分周期;选文件后即时上传,保存后详情页四宫格查看

    +{%- endmacro %} diff --git a/lib/instance/templates/key_focus_v2.html b/lib/instance/templates/key_focus_v2.html new file mode 100644 index 0000000..3110112 --- /dev/null +++ b/lib/instance/templates/key_focus_v2.html @@ -0,0 +1,182 @@ + + + + + + {{ exchange_display }} | 关键位放大 + + + + + +{% if trade_policy is not defined %} +{% set trade_policy = {'symbol_restrict_enabled': false, 'direction_restrict_enabled': false, 'symbol_whitelist': [], 'allows_long': true, 'allows_short': true, 'badge_text': ''} %} +{% endif %} +
    +
    +
    +
    + + +
    +
    + 返回首页 + 关键位放大{% if trade_policy.symbol_restrict_enabled %}(选择币种){% else %}(可输入币种){% endif %}{{ exchange_display }} +
    +
    最近刷新:--
    +
    +
    + + {% from 'trade_policy_fields.html' import trade_policy_symbol with context %} + {{ trade_policy_symbol('symbol', 'symbol-input', default_symbol, placeholder='BTC/USDT') }} + {% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %} + {{ symbol_live_price_hint('key-focus-symbol-live-price', 'symbol-input') }} + + + + + + + + +
    +
    + +
    +
    +
    交易对
    -
    +
    监控类型
    -
    +
    方向
    -
    +
    上沿/阻力
    -
    +
    下沿/支撑
    -
    +
    现价
    -
    +
    距上沿
    -
    +
    距下沿
    -
    +
    +
    + +
    +
    + + + + + + diff --git a/lib/instance/templates/key_monitor_panel.html b/lib/instance/templates/key_monitor_panel.html new file mode 100644 index 0000000..523fad1 --- /dev/null +++ b/lib/instance/templates/key_monitor_panel.html @@ -0,0 +1,296 @@ + + +{% macro key_monitor_type_label(k) -%} +{%- if k.monitor_type in ['关键阻力位','关键支撑位','关键支撑阻力'] -%}关键支撑阻力{%- else -%}{{ k.monitor_type }}{%- endif -%} +{%- endmacro %} + +{% macro key_direction_label(k) -%} +{% if k.direction == 'watch' %}双向{% elif k.direction == 'long' %}做多{% else %}做空{% endif %} +{%- endmacro %} + +{% macro key_sl_tp_mode_label(k) -%} +{% if (k.sl_tp_mode or 'standard') == 'standard' %}标准突破{% elif k.sl_tp_mode == 'box_1p5' %}箱体1R·止盈1.5H{% else %}趋势单{% endif %} +{%- endmacro %} + +{% macro key_monitor_brief(k) -%} +上{{ k.upper }} / 下{{ k.lower }} · 提醒 {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} +{%- if k.monitor_type in ['箱体突破','收敛突破'] %} · {{ key_sl_tp_mode_label(k) }}{% endif %} +{%- if k.breakeven_enabled %} · 保本开{% else %} · 保本关{% endif %} +{%- endmacro %} + +{% macro key_history_outcome_kind(h) -%} +{%- set r = (h.close_reason or '')|trim -%} +{%- if r in ['fib_filled', 'false_breakout_filled', 'trigger_entry_filled', 'key_level_alert_done', 'alerts_complete', 'auto_opened'] -%}success +{%- elif r == 'manual' -%}manual +{%- elif r -%}failed +{%- else -%}neutral +{%- endif -%} +{%- endmacro %} + +{% macro key_history_outcome_label(h) -%} +{%- set r = (h.close_reason or '')|trim -%} +{%- if r == 'fib_filled' -%}斐波成交 +{%- elif r == 'false_breakout_filled' -%}假突破成交 +{%- elif r == 'trigger_entry_filled' -%}触价成交 +{%- elif r == 'key_level_alert_done' -%}提醒完成 +{%- elif r == 'alerts_complete' -%}提醒已满 +{%- elif r == 'auto_opened' -%}自动开仓 +{%- elif r == 'manual' -%}手动删除 +{%- elif r == 'fib_invalidate' -%}斐波失效 +{%- elif r == 'box_opposite_break' -%}反向突破失效 +{%- elif r == 'trigger_tp_invalidate' -%}触价止盈失效 +{%- elif r == 'trigger_sl_invalidate' -%}触价止损失效 +{%- elif r == 'trigger_entry_expired' -%}触价过期 +{%- elif r == 'trigger_exchange_failed' -%}触价下单失败 +{%- elif r == 'false_breakout_expired' -%}假突破过期 +{%- elif r == 'fib_plan_invalid' -%}计划无效 +{%- elif r == 'rr_insufficient' -%}盈亏比不足 +{%- elif r == 'exchange_failed' -%}下单失败 +{%- else -%}{{ r or '—' }} +{%- endif -%} +{%- endmacro %} + +{% macro key_history_brief(h) -%} +{{ key_history_outcome_label(h) }} · {{ (h.closed_at or '-')[:16] }} · 上{{ h.upper }} / 下{{ h.lower }} · 提醒 {{ h.notification_count or 0 }} +{%- endmacro %} + +
    +
    +
    +

    关键位监控

    + {% if focus_key_id %} + 放大查看K线(默认200根) + {% else %} + 输入币种查看K线 + {% endif %} +
    +
    + {% from 'trade_policy_fields.html' import trade_policy_symbol with context %} + {{ trade_policy_symbol('symbol', 'key-symbol') }} + + + {% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %} + {{ symbol_live_price_hint('key-symbol-live-price', 'key-symbol', '') }} + + + +
    +
    + 关键位监控规则说明 +
    + {% include 'key_monitor_rule_tips.html' %} +
    +
    +
    + {% for k in key %} +
    + + + + {{ k.symbol }} + {% if k.time_close_enabled and k.time_close_hours %} + 时间平仓 {{ k.time_close_hours }}h + {% endif %} + {% if k.direction == 'watch' %} + 双向 + {% else %} + {{ key_direction_label(k) }} + {% endif %} + {{ key_monitor_type_label(k) }} + + 现价 — · 门控 — + + + + + +
    +
    {{ key_monitor_brief(k) }}
    +
    + 上沿: {{ k.upper }} + 下沿: {{ k.lower }} + {% if k.fib_entry_price and k.monitor_type in ['回调触价开仓','突破触价开仓','触价开仓'] %}E: {{ k.fib_entry_price }} / SL: {{ k.fib_stop_loss }} / TP: {{ k.fib_take_profit }}{% elif k.fib_entry_price %}挂E: {{ k.fib_entry_price }}{% endif %} + {% if k.monitor_type == '假突破' and k.fib_stop_loss %}SL: {{ k.fib_stop_loss }} / TP: {{ k.fib_take_profit }}{% endif %} + 已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} + {% if k.monitor_type in ['箱体突破','收敛突破'] %} + 方案: {{ key_sl_tp_mode_label(k) }} + {% endif %} + 保本: {{ '开' if k.breakeven_enabled else '关' }} +
    +
    +
    现价-
    +
    距上沿-
    +
    距下沿-
    +
    门控-
    +
    +
    +
    +
    + {% else %} +
    暂无监控中的关键位
    + {% endfor %} +
    +
    +
    +

    关键位历史

    +
    失效或已结案的关键位 · 点击展开详情
    +
    + {% for h in key_history %} +
    + + + + {{ h.symbol }} + {{ key_direction_label(h) }} + {{ key_monitor_type_label(h) }} + {{ key_history_outcome_label(h) }} + + + + + + +
    +
    {{ key_history_brief(h) }}
    +
    + 类型: {{ key_monitor_type_label(h) }} + 结案: {{ key_history_outcome_label(h) }}{% if h.close_reason %} ({{ h.close_reason }}){% endif %} + 时间: {{ h.closed_at or '—' }} +
    +
    + 上沿: {{ h.upper }} + 下沿: {{ h.lower }} + 提醒次数: {{ h.notification_count or 0 }} +
    + {% if h.last_alert_message %} +
    {{ h.last_alert_message }}
    + {% endif %} +
    +
    + {% else %} +
    暂无历史
    + {% endfor %} +
    +
    +
    + + diff --git a/lib/instance/templates/key_monitor_rule_tips.html b/lib/instance/templates/key_monitor_rule_tips.html new file mode 100644 index 0000000..b652431 --- /dev/null +++ b/lib/instance/templates/key_monitor_rule_tips.html @@ -0,0 +1,59 @@ +{% set r = key_rule_ctx %} +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    类型填写门控止盈止损执行
    箱体突破
    收敛突破
    方向必选;填 H/L
    方案:标准 / 1R·1.5H / 趋势
    可勾移动保本
    {{ r.tf }} 两根闭合 K({{ r.breakout_bar }}/{{ r.confirm_bar }})
    突破 >{{ r.amp_min_pct }}%;确认在箱外
    量 >前{{ r.vol_ma_bars }}均×{{ r.vol_ratio_min }}
    成交 Top{{ r.vol_rank_max }};RR >{{ r.min_rr }}
    标记价先破反向边界→失效
    标准:SL 极值外{{ r.stop_outside_pct }}%,TP=E±H
    1R:SL=E∓H,TP=E∓1.5H
    趋势:SL 极值外{{ r.trend_stop_outside_pct }}%,TP 自填
    门控过→市价开仓→下单监控
    满仓不可再加
    斐波回调
    0.618 / 0.786
    方向 + H/L 波段
    系统算 E/SL/TP
    多:E=H−rΔ,SL=L,TP=H
    空:E=L+rΔ,SL=H,TP=L
    RR >{{ r.min_rr }};先触 TP 侧失效
    公式固定 SL/TP
    成交后挂所
    挂限价等成交
    成交→下单监控
    假突破
    BTC / ETH
    空填高点 / 多填低点
    同币仅 1 条
    外侧 {{ r.fb_offset_pct }}% 限价
    SL {{ r.fb_sl_pct }}%;RR {{ r.fb_rr }}
    有效 {{ r.fb_valid_hours }}h
    自动 E/SL/TP
    可保本
    即挂限价
    成交/过期→历史
    回调触价开仓方向 + 入场 E / 止损 SL / 止盈 TP
    可勾移动保本,时间平仓
    RR >{{ r.min_rr }};做多 SL<E<TP
    标记价回调触 E(多≤E / 空≥E)后下一轮询市价开
    先触 TP 侧失效;有效 {{ r.trigger_entry_validity_hours }}h
    程序盯价,无交易所挂单
    成交后挂所 TP/SL → 下单监控
    占当日开仓意图
    全仓模式可用
    突破触价开仓方向 + 突破价 E / 止损 SL / 止盈 TP
    可勾移动保本,时间平仓
    RR >{{ r.min_rr }};做多 SL<E<TP
    标记价穿越 E 立即市价开(多向上 / 空向下)
    先触 TP 或 SL 侧失效;有效 {{ r.trigger_entry_validity_hours }}h
    程序盯价,无交易所挂单
    成交后挂所 TP/SL → 下单监控
    占当日开仓意图
    全仓模式可用
    关键支撑阻力双向;填上/下沿{{ r.tf }} 收盘破上沿或下沿
    上沿优先
    无(仅提醒)微信 ≤{{ r.alert_max }} 次
    间隔 ≥{{ r.alert_interval_min }} 分
    +
    +

    阈值来自 .env,修改后重启实例.

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

    账户密码修改

    +

    修改网页登录账号密码,写入 .env 后需重启实例生效.

    +
    + + + + +
    +
    + + +
    diff --git a/lib/instance/templates/records_panel.html b/lib/instance/templates/records_panel.html new file mode 100644 index 0000000..cf8a6f3 --- /dev/null +++ b/lib/instance/templates/records_panel.html @@ -0,0 +1,153 @@ +{# 三所共用:交易记录(5/页) → 填入复盘出表单 → 交易复盘记录 / AI历史复盘 #} + +
    +
    +

    交易记录

    +

    每页5条.点「填入复盘」打开下方复盘表单.

    +
    + +
    +
    + + + + + + + + + + + +
    品种下单类型开仓类型方向成交止损(开仓)止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
    加载中…
    +
    +
    + + 第 1 / 1 页 + +
    +
    + + + +
    +
    +

    AI复盘(按交易记录)

    + +
    +
    + + + + + + + +
    + + +
    + +
    +

    交易复盘记录

    +

    已保存的复盘(每页5条).

    +
    +
    +
    +
    + + 第 1 / 1 页 + +
    +
    + +
    +

    AI历史复盘

    +

    日/周 AI 复盘历史(每页5条).

    +
    +
    +
    +
    + + 第 1 / 1 页 + +
    +
    +
    diff --git a/lib/instance/templates/risk_policy_panel.html b/lib/instance/templates/risk_policy_panel.html new file mode 100644 index 0000000..5f5d415 --- /dev/null +++ b/lib/instance/templates/risk_policy_panel.html @@ -0,0 +1,39 @@ +{# 风控说明:只读展示 .env 风控参数与当前账户状态 #} +
    +
    +

    风控说明

    +

    + 当前账户状态: + + {{ instance_settings.risk_status_label }} + + {% if instance_settings.risk_status_reason %} + {{ instance_settings.risk_status_reason }} + {% endif %} +

    + {% if instance_settings.trade_policy_note %} +

    账户限制:{{ instance_settings.trade_policy_note }}

    + {% endif %} +

    以下参数读取自本实例 .env,修改后需重启进程生效.

    +
    + {% for section in instance_settings.sections %} +
    +

    {{ section.title }}

    +
    + {% for row in section.rows %} +
    +
    {{ row.label }}
    +
    + {{ row.value }} + {% if row.note %} + {{ row.note }} + {% endif %} +
    +
    + {% endfor %} +
    +
    + {% endfor %} +
    +
    +
    diff --git a/lib/instance/templates/settings_panel.html b/lib/instance/templates/settings_panel.html new file mode 100644 index 0000000..5bb0974 --- /dev/null +++ b/lib/instance/templates/settings_panel.html @@ -0,0 +1,77 @@ +{# 系统设置:CSS Tab(与 env 配置同方案) #} +
    +
    +

    系统设置

    +

    各区块说明见 docs/系统设置说明.md.

    +
    + + {% if settings_tabs %} + {% set _sub = (request.args.get('settings_tab') or '').strip() %} + {% set _legacy_tab = (request.args.get('tab') or '').strip() %} + {% set ns = namespace(active_idx=0, active_key='') %} + {% for tab in settings_tabs %} + {% if _sub and tab.key == _sub %} + {% set ns.active_idx = loop.index0 %} + {% set ns.active_key = tab.key %} + {% elif (not _sub) and _legacy_tab and tab.key == _legacy_tab %} + {% set ns.active_idx = loop.index0 %} + {% set ns.active_key = tab.key %} + {% endif %} + {% endfor %} +
    + {% for tab in settings_tabs %} + + {% endfor %} +
    + {% for tab in settings_tabs %} + + {% endfor %} +
    +
    + {% for tab in settings_tabs %} +
    + {% if tab.key == 'nav' %} + {% include 'display_prefs_panel.html' %} + {% elif tab.key == 'sim_funds' %} + {% include 'sim_funds_panel.html' %} + {% elif tab.key == 'password' %} + {% include 'password_settings_panel.html' %} + {% elif tab.key == 'transfer' %} +

    永续资金划转

    +

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

    + {% include 'instance_transfer_panel.html' %} + {% elif tab.key == 'export' %} +

    数据导出

    +

    CSV · v{{ instance_settings.data_export_version }}

    + + {% elif tab.key == 'options_swap' %} +

    币种兑换

    + {% include 'options_settings_swap.html' %} + {% elif tab.key == 'options_transfer' %} +

    期权资金划转

    + {% include 'options_settings_transfer.html' %} + {% endif %} +
    + {% endfor %} +
    +
    + {% endif %} + + {% if instance_settings.options_settings_enabled %} + {% include 'options_settings_panel.html' %} + {% endif %} +
    + diff --git a/lib/instance/templates/symbol_live_price_snippet.html b/lib/instance/templates/symbol_live_price_snippet.html new file mode 100644 index 0000000..c746897 --- /dev/null +++ b/lib/instance/templates/symbol_live_price_snippet.html @@ -0,0 +1,10 @@ +{# 币种输入旁实时现价(须加载 symbol_live_price.js) #} +{% macro symbol_live_price_hint(price_id, symbol_input_id, direction_input_id='') -%} +现价:— +{%- endmacro %} diff --git a/lib/instance/templates/system_guide_panel.html b/lib/instance/templates/system_guide_panel.html new file mode 100644 index 0000000..fbb07be --- /dev/null +++ b/lib/instance/templates/system_guide_panel.html @@ -0,0 +1,87 @@ +{# 系统说明: docs/系统说明.md + h2 目录 #} +
    +
    +
    +

    系统说明

    +

    操作与逻辑按章节混排。默认不在顶栏显示;可在系统设置 → 导航显示中打开。

    +
    +
    + {% if system_guide_toc %} + + {% endif %} +
    + {{ system_guide_html|safe }} +
    +
    +
    +
    + diff --git a/lib/instance/templates/trade_policy_fields.html b/lib/instance/templates/trade_policy_fields.html new file mode 100644 index 0000000..449c1ec --- /dev/null +++ b/lib/instance/templates/trade_policy_fields.html @@ -0,0 +1,35 @@ +{# 方向 / 币种:env 账户级限制(三所共用宏);调用方须 with context #} +{% if trade_policy is not defined %} +{% set trade_policy = {'symbol_restrict_enabled': false, 'direction_restrict_enabled': false, 'symbol_whitelist': [], 'allows_long': true, 'allows_short': true, 'direction_mode': 'both', 'badge_text': ''} %} +{% endif %} +{% macro trade_policy_symbol(name, id, value='', required=true, placeholder='BTC 或 BTC/USDT') -%} +{% if trade_policy.symbol_restrict_enabled and trade_policy.symbol_whitelist %} +{% set wl = trade_policy.symbol_whitelist %} +{% set sole_sym = wl[0] if (wl|length) == 1 else '' %} +{% set effective = value if value else sole_sym %} + +{% else %} + +{% endif %} +{%- endmacro %} + +{% macro trade_policy_direction(name, id, required=true, include_empty=true) -%} +{% if trade_policy.direction_restrict_enabled and trade_policy.direction_mode == 'long_only' %} +做多 + +{% elif trade_policy.direction_restrict_enabled and trade_policy.direction_mode == 'short_only' %} +做空 + +{% else %} + +{% endif %} +{%- endmacro %} diff --git a/lib/key_monitor/__init__.py b/lib/key_monitor/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/key_monitor/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/key_monitor/key_monitor_lib.py b/lib/key_monitor/key_monitor_lib.py new file mode 100644 index 0000000..7427979 --- /dev/null +++ b/lib/key_monitor/key_monitor_lib.py @@ -0,0 +1,462 @@ +""" +关键位监控:阻力/支撑双向提醒与箱体/收敛自动门控的共享逻辑. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) +KEY_MONITOR_RS_TYPE = "关键支撑阻力" +KEY_MONITOR_RS_LEGACY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) +KEY_MONITOR_RS_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES +KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES +KEY_DIRECTION_WATCH = "watch" + + +def is_rs_key_monitor_type(monitor_type: str) -> bool: + return (monitor_type or "").strip() in KEY_MONITOR_RS_TYPES + + +def rs_monitor_type_label(monitor_type: str) -> str: + """展示用:旧库里的阻力/支撑合并为「关键支撑阻力」.""" + if is_rs_key_monitor_type(monitor_type): + return KEY_MONITOR_RS_TYPE + return (monitor_type or "").strip() + + +def rs_monitor_type_for_storage(monitor_type: str) -> str: + if is_rs_key_monitor_type(monitor_type): + return KEY_MONITOR_RS_TYPE + return (monitor_type or "").strip() + + +def calc_breakout_breach_pct(direction: str, close: float, upper: float, lower: float) -> float: + """突破 K 收盘相对关键位的越过幅度(%).未越过对应边界时返回 0.""" + direction = (direction or "long").strip().lower() + c = float(close) + if direction == "long": + u = float(upper) + if u <= 0 or c <= u: + return 0.0 + return (c - u) / u * 100.0 + lo = float(lower) + if lo <= 0 or c >= lo: + return 0.0 + return (lo - c) / lo * 100.0 + + +def auto_amp_ok( + direction: str, + close_b: float, + upper: float, + lower: float, + min_pct: float, +) -> tuple[bool, float]: + breach = calc_breakout_breach_pct(direction, close_b, upper, lower) + return breach > float(min_pct), breach + + +def auto_confirm_ok(direction: str, cfm_close: float, upper: float, lower: float) -> bool: + """确认 K 收盘须在箱体外(不得回到 [lower, upper] 内).""" + direction = (direction or "long").strip().lower() + c = float(cfm_close) + if direction == "long": + return c > float(upper) + return c < float(lower) + + +BOX_BREAKOUT_CLOSE_OPPOSITE = "box_opposite_break" + + +def box_breakout_invalidate_by_mark( + direction: str, mark_price: float, upper: float, lower: float +) -> bool: + """箱体/收敛:标记价先突破反向边界则失效.多:mark<=L;空:mark>=H.""" + try: + m = float(mark_price) + h = float(upper) + lo = float(lower) + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "short": + return m >= h + return m <= lo + + +def box_breakout_invalidate_edge_label(direction: str) -> str: + direction = (direction or "long").strip().lower() + return "下沿" if direction == "long" else "上沿" + + +def detect_rs_box_break(close: float, upper: float, lower: float) -> Optional[dict[str, Any]]: + """ + 阻力/支撑人工盯盘:最近 5m 收盘突破上沿或下沿(严格 > / <). + 上沿优先:同一根 K 不可能同时满足两者. + """ + u, lo, c = float(upper), float(lower), float(close) + if c > u: + return { + "break_side": "upper", + "direction": "long", + "edge_price": u, + "key_price": u, + "break_label": "向上突破上沿", + } + if c < lo: + return { + "break_side": "lower", + "direction": "short", + "edge_price": lo, + "key_price": lo, + "break_label": "向下突破下沿", + } + return None + + +def rs_break_from_direction(direction: str, upper: float, lower: float) -> Optional[dict[str, Any]]: + """已触发后根据入库方向还原突破边(long=上沿,short=下沿).""" + d = (direction or "").strip().lower() + if d == "long": + return { + "break_side": "upper", + "direction": "long", + "edge_price": float(upper), + "key_price": float(upper), + "break_label": "向上突破上沿", + } + if d == "short": + return { + "break_side": "lower", + "direction": "short", + "edge_price": float(lower), + "key_price": float(lower), + "break_label": "向下突破下沿", + } + return None + + +def rs_break_infer_from_close(close: float, upper: float, lower: float) -> dict[str, Any]: + """ + 续发提醒时价格已回到箱体内:按收盘价相对箱体中线推断首次突破边, + 保证第 2/3 次企业微信提醒仍能发出. + """ + mid = (float(upper) + float(lower)) / 2.0 + if float(close) >= mid: + br = rs_break_from_direction("long", upper, lower) + else: + br = rs_break_from_direction("short", upper, lower) + if br: + return br + return { + "break_side": "upper", + "direction": "long", + "edge_price": float(upper), + "key_price": float(upper), + "break_label": "向上突破上沿", + } + + +def _parse_notify_datetime(raw: Optional[str]) -> Optional[datetime]: + s = str(raw or "").strip() + if not s: + return None + try: + dt = datetime.fromisoformat(s.replace("Z", "+00:00")) + if dt.tzinfo is not None: + dt = dt.replace(tzinfo=None) + return dt + except Exception: + pass + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(s[:19], fmt) + except Exception: + continue + return None + + +def claim_rs_level_notify( + conn: Any, + monitor_id: int, + notify_index: int, + direction: str, + notified_at: str, + bar_ts: Optional[int], + *, + prior_count: Optional[int] = None, +) -> bool: + """ + 原子占位:仅在 notification_count 仍为 prior_count 时推进到 notify_index. + 须在发送企业微信之前调用并 commit,避免 (2/3) 重复刷屏. + """ + prior = int(prior_count if prior_count is not None else notify_index - 1) + if prior < 0 or notify_index != prior + 1: + return False + bar_val: Optional[int] = None + if bar_ts is not None: + try: + bar_val = int(bar_ts) + except (TypeError, ValueError): + bar_val = None + cur = conn.execute( + "UPDATE key_monitors SET notification_count=?, direction=?, last_notified_at=?, last_rs_bar_ts=? " + "WHERE id=? AND COALESCE(notification_count,0)=?", + (notify_index, direction, notified_at, bar_val, int(monitor_id), prior), + ) + return int(cur.rowcount or 0) > 0 + + +def parse_last_rs_bar_ts(row: Any) -> Optional[int]: + if row is None: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + raw = row["last_rs_bar_ts"] if "last_rs_bar_ts" in keys else None + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def run_rs_level_alert_tick( + row: Any, + close: float, + bar_ts: Optional[int], + now_dt: datetime, + *, + default_max_notify: int, + default_interval_min: int, +) -> Optional[dict[str, Any]]: + """ + 判定本轮回合是否应推送阻力/支撑提醒. + 首条:仅在新闭合 K 越线时触发;发送前须 claim_rs_level_notify 占位防轮询/多进程重复. + """ + up, lo = float(row["upper"]), float(row["lower"]) + if up <= lo: + return None + count = int(row["notification_count"] or 0) + max_n = max(1, int(row["max_notify"] or default_max_notify)) + interval = max(1, int(row["notify_interval_min"] or default_interval_min)) + if count >= max_n: + return None + + bar_ts_i: Optional[int] = None + if bar_ts is not None: + try: + bar_ts_i = int(bar_ts) + except (TypeError, ValueError): + bar_ts_i = None + last_bar_i = parse_last_rs_bar_ts(row) + + if count == 0: + br = detect_rs_box_break(close, up, lo) + if not br: + return None + if bar_ts_i is not None and last_bar_i is not None and bar_ts_i == last_bar_i: + return None + return { + "break_info": br, + "notify_index": 1, + "prior_count": 0, + "notify_max": max_n, + "interval_min": interval, + "bar_ts": bar_ts_i, + } + + if not notify_interval_elapsed(row["last_notified_at"], interval, now_dt): + return None + br = resolve_rs_break_for_alert(count, row["direction"], close, up, lo) + if not br: + return None + return { + "break_info": br, + "notify_index": count + 1, + "prior_count": count, + "notify_max": max_n, + "interval_min": interval, + "bar_ts": bar_ts_i, + } + + +def resolve_rs_break_for_alert( + notification_count: int, + direction: Optional[str], + close: float, + upper: float, + lower: float, +) -> Optional[dict[str, Any]]: + """ + 阻力/支撑提醒:首次用 5m 收盘越线判定;后续用已存方向,兼容 direction=watch. + """ + count = int(notification_count or 0) + up, lo, c = float(upper), float(lower), float(close) + if count <= 0: + return detect_rs_box_break(c, up, lo) + br = rs_break_from_direction(direction, up, lo) + if br: + return br + d = (direction or "").strip().lower() + if d not in ("", KEY_DIRECTION_WATCH): + return None + br = detect_rs_box_break(c, up, lo) + if br: + return br + return rs_break_infer_from_close(c, up, lo) + + +def notify_interval_elapsed( + last_notified_at: Optional[str], + interval_min: int, + now_dt: datetime, +) -> bool: + if not last_notified_at: + return False + last_dt = _parse_notify_datetime(last_notified_at) + if last_dt is None: + return False + return (now_dt - last_dt).total_seconds() >= max(1, int(interval_min)) * 60 + + +def format_auto_amp_line(amp_ok: bool, amp_pct: float, min_pct: float) -> str: + return ( + f"突破越过幅度:{'通过' if amp_ok else '不通过'}" + f"({round(float(amp_pct), 4)}%,要求 > {min_pct}%)" + ) + + +def format_auto_confirm_line(confirm_ok: bool, cfm_close, edge_price, direction: str) -> str: + side = "箱外上方" if (direction or "").lower() == "long" else "箱外下方" + return ( + f"第二根确认:{'通过' if confirm_ok else '不通过'}" + f"(确认收盘 {cfm_close},须收于{side},关键位 {edge_price})" + ) + + +def key_monitor_rule_template_context( + *, + kline_timeframe: str, + key_breakout_amp_min_pct: float, + key_volume_ma_bars: int, + key_volume_ratio_min: float, + key_auto_min_planned_rr: float, + key_daily_volume_rank_max: int, + key_confirm_breakout_bar: int, + key_confirm_bar: int, + key_alert_max_times: int, + key_alert_interval_minutes: int, + key_stop_outside_breakout_pct: float, + key_trend_stop_outside_pct: float, + false_breakout_validity_hours: int = 0, + trigger_entry_validity_hours: int | None = None, +) -> dict[str, Any]: + """关键位监控页规则说明表格(Jinja key_rule_ctx);自动单已移除,保留字段兼容模板.""" + del false_breakout_validity_hours, trigger_entry_validity_hours + return { + "tf": (kline_timeframe or "5m").strip(), + "amp_min_pct": key_breakout_amp_min_pct, + "vol_ma_bars": key_volume_ma_bars, + "vol_ratio_min": key_volume_ratio_min, + "min_rr": key_auto_min_planned_rr, + "vol_rank_max": key_daily_volume_rank_max, + "breakout_bar": key_confirm_breakout_bar, + "confirm_bar": key_confirm_bar, + "alert_max": key_alert_max_times, + "alert_interval": key_alert_interval_minutes, + "stop_outside_pct": key_stop_outside_breakout_pct, + "trend_stop_outside_pct": key_trend_stop_outside_pct, + "false_breakout_hours": 0, + "false_breakout_offset_pct": 0, + "false_breakout_sl_pct": 0, + "false_breakout_rr": 0, + "trigger_entry_hours": 0, + } + + +# ---- 历史 key_signal / entry_reason 兼容(自动单已移除,仅读旧数据) ---- +KEY_MONITOR_TRADE_TYPE = "关键位监控" +FIB_KEY_MONITOR_TYPES = frozenset({"斐波回调0.618", "斐波回调0.786"}) +KEY_ENTRY_REASON_BY_SIGNAL = { + "箱体突破": "关键位箱体突破", + "收敛突破": "关键位收敛突破", + "斐波回调0.618": "关键位斐波0.618", + "斐波回调0.786": "关键位斐波0.786", + "假突破": "关键位假突破", + "回调触价开仓": "关键位回调触价开仓", + "突破触价开仓": "关键位突破触价开仓", + "触价开仓": "关键位触价开仓", + "趋势回调": "趋势回调", +} + + +def entry_reason_from_key_signal(key_signal_type): + return KEY_ENTRY_REASON_BY_SIGNAL.get((key_signal_type or "").strip()) + + +def key_signal_type_for_trade_record(key_signal_type, box_auto_types=None): + kst = (key_signal_type or "").strip() + if not kst: + return None + if kst in FIB_KEY_MONITOR_TYPES: + return kst + if kst in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"): + return kst if kst != "触价开仓" else "回调触价开仓" + if box_auto_types and kst in box_auto_types: + return kst + if kst in KEY_MONITOR_AUTO_TYPES: + return kst + return kst or None + + +def stored_key_signal_type(monitor_type): + mt = (monitor_type or "").strip() + if mt in FIB_KEY_MONITOR_TYPES: + return mt + if mt in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"): + return mt if mt != "触价开仓" else "回调触价开仓" + if mt in KEY_MONITOR_AUTO_TYPES: + return mt + return None + + +def backfill_missing_key_signal_types(conn, *, monitor_type: str = KEY_MONITOR_TRADE_TYPE) -> int: + mt = (monitor_type or KEY_MONITOR_TRADE_TYPE).strip() + updated = 0 + for signal in KEY_MONITOR_AUTO_TYPES: + entry_reason = KEY_ENTRY_REASON_BY_SIGNAL.get(signal) + if not entry_reason: + continue + cur = conn.execute( + """UPDATE trade_records SET key_signal_type=? + WHERE monitor_type=? AND (key_signal_type IS NULL OR TRIM(key_signal_type)='') + AND TRIM(COALESCE(entry_reason, ''))=?""", + (signal, mt, entry_reason), + ) + updated += int(cur.rowcount or 0) + return updated + + +def is_fib_key_monitor_type(monitor_type): + return (monitor_type or "").strip() in FIB_KEY_MONITOR_TYPES + + +def is_false_breakout_key_monitor_type(monitor_type): + return (monitor_type or "").strip() == "假突破" + + +def is_trigger_entry_key_monitor_type(monitor_type): + return (monitor_type or "").strip() in ( + "触价开仓", + "回调触价开仓", + "突破触价开仓", + ) + + +def is_limit_key_monitor_type(monitor_type): + mt = (monitor_type or "").strip() + return is_fib_key_monitor_type(mt) or is_false_breakout_key_monitor_type(mt) diff --git a/lib/key_monitor/key_monitor_schema_lib.py b/lib/key_monitor/key_monitor_schema_lib.py new file mode 100644 index 0000000..9e64637 --- /dev/null +++ b/lib/key_monitor/key_monitor_schema_lib.py @@ -0,0 +1,15 @@ +"""关键位监控表结构迁移(三所共用).""" +from __future__ import annotations + +from typing import Any + + +def ensure_key_monitor_schema(conn: Any) -> None: + for sql in ( + "ALTER TABLE key_monitors ADD COLUMN last_mark_price REAL", + "ALTER TABLE key_monitors ADD COLUMN last_alert_message TEXT", + ): + try: + conn.execute(sql) + except Exception: + pass diff --git a/lib/key_monitor/key_sl_tp_lib.py b/lib/key_monitor/key_sl_tp_lib.py new file mode 100644 index 0000000..0704694 --- /dev/null +++ b/lib/key_monitor/key_sl_tp_lib.py @@ -0,0 +1,139 @@ +"""关键位箱体/收敛:止盈止损方案(Binance / Gate / OKX 共用).""" + +KEY_SL_TP_MODES = frozenset({"standard", "box_1p5", "trend_manual"}) + +KEY_SL_TP_MODE_LABELS = { + "standard": "标准突破", + "box_1p5": "箱体1R·止盈1.5H", + "trend_manual": "趋势单·自填止盈", +} + +KEY_MONITOR_AUTO_TYPES_FOR_FORM = frozenset({"箱体突破", "收敛突破"}) + + +def normalize_sl_tp_mode(raw): + m = (raw or "standard").strip().lower() + if m in ("box_1p5", "box15", "box-1.5", "box_1.5"): + return "box_1p5" + if m in ("trend_manual", "trend", "manual"): + return "trend_manual" + if m in KEY_SL_TP_MODES: + return m + return "standard" + + +def sl_tp_mode_label(mode): + return KEY_SL_TP_MODE_LABELS.get(normalize_sl_tp_mode(mode), normalize_sl_tp_mode(mode)) + + +def sl_tp_mode_from_row(row, default="standard"): + try: + if hasattr(row, "keys") and "sl_tp_mode" in row.keys(): + raw = row["sl_tp_mode"] + else: + raw = row.get("sl_tp_mode") if isinstance(row, dict) else None + except Exception: + raw = None + return normalize_sl_tp_mode(raw if raw not in (None, "") else default) + + +def breakeven_enabled_from_row(row, default=0): + try: + if hasattr(row, "keys") and "breakeven_enabled" in row.keys(): + v = row["breakeven_enabled"] + else: + v = row.get("breakeven_enabled") if isinstance(row, dict) else None + except Exception: + v = None + if v is None: + return int(default) != 0 + return int(v) != 0 + + +def parse_breakeven_enabled_form(form_value): + return 1 if (form_value or "").strip().lower() in ("1", "true", "on", "yes") else 0 + + +def plan_key_sl_tp( + mode, + direction, + upper, + lower, + checks, + *, + outside_pct, + trend_outside_pct, + manual_take_profit=None, +): + """ + 以确认 K 收盘 E 为「当前价」计算计划 SL/TP. + 返回 (E, sl_raw, tp_raw, box_h) 或 None(几何无效 / 模式3缺止盈). + """ + try: + E = float(checks["confirm_close"]) + H = abs(float(upper) - float(lower)) + except (TypeError, ValueError, KeyError): + return None + if H <= 0: + return None + direction = (direction or "long").strip().lower() + mode = normalize_sl_tp_mode(mode) + + if mode == "box_1p5": + if direction == "long": + sl_raw = E - H + tp_raw = E + 1.5 * H + else: + sl_raw = E + H + tp_raw = E - 1.5 * H + return E, sl_raw, tp_raw, H + + if mode == "trend_manual": + try: + br_hi = float(checks["breakout_high"]) + br_lo = float(checks["breakout_low"]) + tp_raw = float(manual_take_profit) + except (TypeError, ValueError, KeyError): + return None + m = float(trend_outside_pct) / 100.0 + if direction == "long": + sl_raw = br_lo * (1.0 - m) if br_lo > 0 else 0.0 + if tp_raw <= E or sl_raw <= 0: + return None + else: + sl_raw = br_hi * (1.0 + m) if br_hi > 0 else 0.0 + if tp_raw >= E or sl_raw <= 0: + return None + return E, sl_raw, tp_raw, H + + # standard:突破 K 极值外侧 + 止盈 E±1×H + try: + br_hi = float(checks["breakout_high"]) + br_lo = float(checks["breakout_low"]) + except (TypeError, ValueError, KeyError): + return None + om = float(outside_pct) / 100.0 + if direction == "long": + sl_raw = br_lo * (1.0 - om) if br_lo > 0 else 0.0 + tp_raw = E + H + else: + sl_raw = br_hi * (1.0 + om) if br_hi > 0 else 0.0 + tp_raw = E - H + return E, sl_raw, tp_raw, H + + +def sl_tp_plan_summary_text(mode, direction, E, sl_raw, tp_raw, box_h, *, outside_pct, trend_outside_pct): + """微信/页面用一行计划 SL/TP 说明.""" + mode = normalize_sl_tp_mode(mode) + direction = (direction or "long").strip().lower() + if mode == "box_1p5": + return ( + f"方案:{sl_tp_mode_label(mode)}|E={E}|SL=E∓1×H({box_h})|TP=E∓1.5×H" + ) + if mode == "trend_manual": + return ( + f"方案:{sl_tp_mode_label(mode)}|E={E}|SL=突破K极值外{trend_outside_pct}%|TP={tp_raw}(录入)" + ) + return ( + f"方案:{sl_tp_mode_label(mode)}|E={E}|SL=突破K外{outside_pct}%|TP=E±1×H({box_h})" + ) diff --git a/lib/market/__init__.py b/lib/market/__init__.py new file mode 100644 index 0000000..2fcbc19 --- /dev/null +++ b/lib/market/__init__.py @@ -0,0 +1 @@ +"""Standalone market helpers (formerly lib.hub).""" diff --git a/lib/market/market_precision_lib.py b/lib/market/market_precision_lib.py new file mode 100644 index 0000000..f554d17 --- /dev/null +++ b/lib/market/market_precision_lib.py @@ -0,0 +1,41 @@ +"""Exchange amount/price precision helpers for hedge and local trading.""" + +from __future__ import annotations + +from typing import Any, Optional + + +def _decimals_from_precision_value(value: Any) -> Optional[int]: + if value in (None, ""): + return None + try: + p = float(value) + except (TypeError, ValueError): + return None + if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12: + return int(round(p)) + if 0 < p < 1: + s = f"{p:.12f}".rstrip("0") + if "." in s: + return min(12, len(s.split(".", 1)[1])) + return None + + +def _decimals_from_ccxt_str(text: str) -> int: + s = str(text or "").strip() + if not s or "." not in s: + return 0 + frac = s.split(".", 1)[1] + if not frac: + return 0 + return min(12, len(frac.rstrip("0") or frac)) + + +def amount_decimals_from_exchange(exchange: Any, exchange_symbol: str) -> int: + try: + return _decimals_from_ccxt_str(exchange.amount_to_precision(exchange_symbol, 1.23456789)) + except Exception: + market = exchange.market(exchange_symbol) + prec = (market.get("precision") or {}).get("amount") + d = _decimals_from_precision_value(prec) + return d if d is not None else 4 diff --git a/lib/market/ohlcv_lib.py b/lib/market/ohlcv_lib.py new file mode 100644 index 0000000..a28b0c8 --- /dev/null +++ b/lib/market/ohlcv_lib.py @@ -0,0 +1,693 @@ +"""ccxt OHLCV helpers for charts and market data.""" + + +from __future__ import annotations + +import math +import os +import time +from typing import Any, Callable, Optional + +CHART_TIMEFRAMES = frozenset( + { + "1m", + "5m", + "15m", + "1h", + "2h", + "4h", + "1d", + "1w", + } +) +CHART_TIMEFRAME_ORDER = ( + "1m", + "5m", + "15m", + "1h", + "2h", + "4h", + "1d", + "1w", +) +DAILY_PLUS_TIMEFRAMES = frozenset({"1d", "1w"}) + +# 入库 / 同步真源(各周期直拉交易所,不做本地聚合) +STORED_TIMEFRAMES = frozenset(CHART_TIMEFRAMES) +PERMANENT_STORED_TIMEFRAMES = frozenset({"1d", "1w"}) +YEAR_ROLLING_STORED = frozenset({"5m", "15m", "1h", "2h", "4h"}) + +# 行情区不做展示周期聚合;保留空映射供兼容读取 +CHART_DISPLAY_AGGREGATE_FROM: dict[str, str] = {} + +SMALL_DISPLAY_TFS = frozenset({"1m", "5m", "15m"}) +MID_DISPLAY_TFS = frozenset({"1h", "2h", "4h"}) + +HUB_KLINE_1M_MAX_BARS = max(1000, int(os.getenv("HUB_KLINE_1M_MAX_BARS", "10000"))) +HUB_KLINE_5M_1H_RETENTION_DAYS = max(30, int(os.getenv("HUB_KLINE_5M_1H_RETENTION_DAYS", "365"))) +HUB_KLINE_SEED_BARS = max(100, int(os.getenv("HUB_KLINE_SEED_BARS", "500"))) + +# 交易所无原生周期时的远程拉取 fallback(行情区当前无映射) +OHLCV_AGGREGATE_FROM: dict[str, str] = {} + +TIMEFRAME_MS: dict[str, int] = { + "1m": 60_000, + "5m": 5 * 60_000, + "15m": 15 * 60_000, + "1h": 60 * 60_000, + "2h": 2 * 60 * 60_000, + "4h": 4 * 60 * 60_000, + "12h": 12 * 60 * 60_000, + "1d": 24 * 60 * 60_000, + "1w": 7 * 24 * 60 * 60_000, +} + + +def normalize_chart_timeframe(raw: str | None, default: str = "5m") -> str: + tf = (raw or default).strip().lower() + return tf if tf in CHART_TIMEFRAMES else default + + +def normalize_perpetual_symbol(symbol: str) -> str: + """BTC/USDT → BTC/USDT:USDT(与三所 ccxt swap 行情一致).""" + sym = (symbol or "").strip().upper() + if not sym: + return "" + 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 sync_timeframe_for_display(timeframe: str) -> str: + """展示周期对应的入库 / 同步周期.""" + tf = normalize_chart_timeframe(timeframe) + return CHART_DISPLAY_AGGREGATE_FROM.get(tf, tf) + + +def aggregation_source_for_display(timeframe: str) -> str | None: + tf = normalize_chart_timeframe(timeframe) + return CHART_DISPLAY_AGGREGATE_FROM.get(tf) + + +def aggregate_ratio(display_tf: str, source_tf: str) -> int: + d = normalize_chart_timeframe(display_tf) + s = normalize_chart_timeframe(source_tf) + return max(1, int(TIMEFRAME_MS[d] // TIMEFRAME_MS[s])) + + +def chart_initial_limit(timeframe: str) -> int: + tf = normalize_chart_timeframe(timeframe) + if tf in SMALL_DISPLAY_TFS: + return 2000 + if tf in MID_DISPLAY_TFS: + return 1000 + if tf in DAILY_PLUS_TIMEFRAMES: + return 500 + return 500 + + +def chart_chunk_limit(timeframe: str) -> int: + tf = normalize_chart_timeframe(timeframe) + if tf in SMALL_DISPLAY_TFS: + return 500 + if tf == "1w": + return 150 + if tf in MID_DISPLAY_TFS: + return 300 + return 200 + + +def chart_memory_cap(timeframe: str) -> int: + tf = normalize_chart_timeframe(timeframe) + if tf in SMALL_DISPLAY_TFS: + return 5000 + if tf == "1w": + return 500 + return 1000 + + +def bar_limit_for_timeframe(timeframe: str) -> int: + return chart_memory_cap(timeframe) + + +def storage_retention_days(storage_tf: str) -> int | None: + """None 表示不按天截断(1m 按根数;1d/1w 永久).""" + tf = normalize_chart_timeframe(storage_tf) + if tf in YEAR_ROLLING_STORED: + return HUB_KLINE_5M_1H_RETENTION_DAYS + return None + + +def history_cutoff_ms_for_storage(storage_tf: str, now_ms: int | None = None) -> int: + days = storage_retention_days(storage_tf) + if days is None: + return 0 + now = int(now_ms if now_ms is not None else time.time() * 1000) + return max(0, now - int(days) * 86400000) + + +def seed_bar_target(storage_tf: str) -> int: + tf = normalize_chart_timeframe(storage_tf) + if tf == "1m": + return HUB_KLINE_1M_MAX_BARS + if tf in YEAR_ROLLING_STORED: + period = TIMEFRAME_MS[tf] + return min( + int(86400000 * HUB_KLINE_5M_1H_RETENTION_DAYS / period) + 20, + 150000, + ) + return HUB_KLINE_SEED_BARS + + +def retention_policy_meta() -> dict[str, Any]: + year = {"mode": "days", "days": HUB_KLINE_5M_1H_RETENTION_DAYS} + return { + "1m": {"mode": "bars", "max_bars": HUB_KLINE_1M_MAX_BARS}, + "5m": dict(year), + "15m": dict(year), + "1h": dict(year), + "2h": dict(year), + "4h": dict(year), + "1d": {"mode": "permanent"}, + "1w": {"mode": "permanent"}, + "aggregate_from": {}, + } + + +def last_closed_bar_open_ms(timeframe: str, now_ms: int | None = None) -> int: + """上一根已收盘 K 的 open_time(毫秒 UTC).""" + tf = normalize_chart_timeframe(timeframe) + period = TIMEFRAME_MS[tf] + now = int(now_ms if now_ms is not None else time.time() * 1000) + current_open = (now // period) * period + return int(current_open - period) + + +def window_start_ms(timeframe: str, need: int, retention_days: int, now_ms: int | None = None) -> int: + """本地库清理/读库窗口:不超过 retention_days.""" + now = int(now_ms if now_ms is not None else time.time() * 1000) + period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)] + retention_cutoff = now - max(1, int(retention_days)) * 86400000 + want = now - max(1, int(need)) * period + return max(retention_cutoff, want) + + +def chart_fetch_start_ms(timeframe: str, need: int, now_ms: int | None = None) -> int: + """行情展示拉取起点:按 need 根回看(日线 500 / 日内 1000),不受 DB 保留天数限制.""" + now = int(now_ms if now_ms is not None else time.time() * 1000) + period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)] + return max(0, now - max(1, int(need)) * period) + + +def _positive_float(value: Any) -> Optional[float]: + if value in (None, ""): + return None + try: + v = float(value) + except (TypeError, ValueError): + return None + return v if v > 0 else None + + +def _price_tick_from_market_info(info: dict) -> Optional[float]: + """从 market.info 解析 tick(含币安 PRICE_FILTER.filters).""" + for key in ("tickSize", "tickSz", "price_increment", "order_price_round", "quote_increment"): + v = _positive_float(info.get(key)) + if v is not None: + return v + + for key in ("pricePrecision", "price_precision"): + raw = info.get(key) + if raw in (None, ""): + continue + try: + p = float(raw) + except (TypeError, ValueError): + continue + if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12: + return 10 ** (-int(p)) + if 0 < p < 1: + return p + + filters = info.get("filters") + if isinstance(filters, list): + for f in filters: + if not isinstance(f, dict): + continue + if str(f.get("filterType") or "").upper() != "PRICE_FILTER": + continue + v = _positive_float(f.get("tickSize")) + if v is not None: + return v + return None + + +def round_price_to_tick(value: Any, tick: Optional[float]) -> Optional[float]: + """按交易所 tick 对齐价格(K 线/标记线与坐标轴一致).""" + t = normalize_price_tick(tick) + if t is None: + return None + try: + v = float(value) + except (TypeError, ValueError): + return None + n = round(v / t) * t + d = _decimals_from_tick(t) + return float(f"{n:.{d}f}") + + +def round_ohlcv_bars_to_tick(bars: list[dict[str, Any]], tick: Optional[float]) -> None: + t = normalize_price_tick(tick) + if t is None: + return + for b in bars: + for key in ("open", "high", "low", "close"): + if key in b: + rounded = round_price_to_tick(b.get(key), t) + if rounded is not None: + b[key] = rounded + + +def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]: + """最小价格变动单位(与交易所 tick / price_to_precision 一致).""" + try: + if not getattr(exchange, "markets", None): + exchange.load_markets() + market = exchange.market(exchange_symbol) + except Exception: + return None + + info = market.get("info") or {} + if isinstance(info, dict): + tick = _price_tick_from_market_info(info) + if tick is not None: + return tick + + limits = market.get("limits") or {} + price_limits = limits.get("price") or {} + if price_limits.get("min") not in (None, ""): + try: + v = float(price_limits["min"]) + if v > 0: + return v + except (TypeError, ValueError): + pass + + try: + sample = exchange.price_to_precision(exchange_symbol, 12345.678901234) + s = str(sample).strip() + if "." in s: + frac = s.split(".", 1)[1] + if frac: + return 10 ** (-len(frac)) + return 1.0 + except Exception: + pass + + prec = (market.get("precision") or {}).get("price") + if prec is not None: + try: + p = float(prec) + if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12: + return 10 ** (-int(p)) + if 0 < p < 1: + return p + except (TypeError, ValueError): + pass + return None + + +def normalize_price_tick(tick: Optional[float]) -> Optional[float]: + """将 tick 对齐为 10^-n,避免浮点噪声导致前端 lightweight-charts unexpected base.""" + if tick is None: + return None + try: + t = float(tick) + except (TypeError, ValueError): + return None + if t <= 0: + return None + if t >= 1: + return t + try: + exp = int(round(-math.log10(t))) + except (ValueError, OverflowError): + return None + exp = max(0, min(12, exp)) + return 10 ** (-exp) + + +def _decimals_from_tick(tick: float) -> int: + if tick >= 1: + return 0 + s = f"{tick:.12f}".rstrip("0") + if "." in s: + frac = s.split(".", 1)[1] + if frac: + return min(12, len(frac)) + return max(0, min(12, int(round(-math.log10(tick))))) + + +def format_price_by_tick(value: Any, tick: Optional[float]) -> str: + if value in (None, ""): + return "-" + try: + v = float(value) + except (TypeError, ValueError): + return str(value) + if v == 0: + return "0" + if tick and tick > 0: + return f"{v:.{_decimals_from_tick(float(tick))}f}" + av = abs(v) + if av >= 10000: + d = 2 + elif av >= 100: + d = 3 + elif av >= 1: + d = 4 + elif av >= 0.01: + d = 6 + else: + d = 8 + text = f"{v:.{d}f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +def exchange_supports_timeframe(exchange, timeframe: str) -> bool: + tf = normalize_chart_timeframe(timeframe) + tfs = getattr(exchange, "timeframes", None) or {} + if not tfs: + return True + return tf in tfs + + +def _median_bar_step_ms(bars: list[dict[str, Any]]) -> Optional[int]: + if len(bars) < 2: + return None + steps: list[int] = [] + for i in range(1, min(len(bars), 64)): + step = int(bars[i]["open_time_ms"]) - int(bars[i - 1]["open_time_ms"]) + if step > 0: + steps.append(step) + if not steps: + return None + steps.sort() + return steps[len(steps) // 2] + + +def bars_spacing_matches_timeframe( + bars: list[dict[str, Any]], timeframe: str, *, tolerance: float = 0.08 +) -> bool: + if len(bars) < 2: + return True + period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)] + step = _median_bar_step_ms(bars) + if step is None: + return False + return abs(step - period) <= period * tolerance + + +def align_bar_open_ms(open_time_ms: int, period_ms: int) -> int: + return (int(open_time_ms) // period_ms) * period_ms + + +def snap_to_bar_grid(ts_ms: int, origin_ms: int, step_ms: int) -> int: + step = max(1, int(step_ms)) + origin = int(origin_ms) + if ts_ms <= origin: + return origin + idx = (int(ts_ms) - origin + step - 1) // step + return origin + idx * step + + +def fill_missing_ohlcv_bars( + bars: list[dict[str, Any]], + period_ms: int, + start_ms: int | None = None, + end_ms: int | None = None, +) -> list[dict[str, Any]]: + """细周期缺口用上一根收盘价填平,保证聚合后 K 线时间轴连续.""" + by_ts: dict[int, dict[str, Any]] = {} + for b in bars or []: + try: + by_ts[int(b["open_time_ms"])] = b + except (KeyError, TypeError, ValueError): + continue + if not by_ts: + return [] + keys = sorted(by_ts.keys()) + step_ms = max(1, int(period_ms)) + origin = keys[0] + aligned_start = snap_to_bar_grid( + int(start_ms if start_ms is not None else keys[0]), origin, step_ms + ) + aligned_end = max( + int(end_ms if end_ms is not None else keys[-1]), + keys[-1], + ) + out: list[dict[str, Any]] = [] + last: dict[str, Any] | None = None + for ts_key in keys: + if ts_key <= aligned_start: + last = by_ts[ts_key] + ts = aligned_start + while ts <= aligned_end: + cur = by_ts.get(ts) + if cur is not None: + last = cur + out.append(cur) + elif last is not None: + c = float(last["close"]) + out.append( + { + "open_time_ms": ts, + "open": c, + "high": c, + "low": c, + "close": c, + "volume": 0.0, + "filled": True, + } + ) + ts += step_ms + return out + + +def aggregate_ohlcv_bars( + bars: list[dict[str, Any]], target_timeframe: str +) -> list[dict[str, Any]]: + """将细周期 OHLCV 聚合为目标周期(UTC 对齐 bucket).""" + tf = normalize_chart_timeframe(target_timeframe) + period = TIMEFRAME_MS[tf] + buckets: dict[int, dict[str, Any]] = {} + for b in bars or []: + try: + key = align_bar_open_ms(int(b["open_time_ms"]), period) + o = float(b["open"]) + h = float(b["high"]) + l = float(b["low"]) + c = float(b["close"]) + v = float(b.get("volume") or 0) + except (KeyError, TypeError, ValueError): + continue + cur = buckets.get(key) + if cur is None: + buckets[key] = { + "open_time_ms": key, + "open": o, + "high": h, + "low": l, + "close": c, + "volume": v, + } + continue + cur["high"] = max(float(cur["high"]), h) + cur["low"] = min(float(cur["low"]), l) + cur["close"] = c + cur["volume"] = float(cur.get("volume") or 0) + v + return [buckets[k] for k in sorted(buckets.keys())] + + +def _next_since_from_batch(batch: list, period_ms: int) -> int: + last_ts = int(batch[-1][0]) + if len(batch) >= 2: + step = int(batch[-1][0]) - int(batch[-2][0]) + if step > 0: + return last_ts + step + return last_ts + period_ms + + +def _paginate_fetch_ohlcv( + exchange, + ex_sym: str, + timeframe: str, + *, + want: int, + since_ms: int | None, + period_ms: int, + chunk_max: int = 300, +) -> list[dict[str, Any]]: + tf = normalize_chart_timeframe(timeframe) + collected: list = [] + if since_ms is not None and int(since_ms) > 0: + since = int(since_ms) + else: + since = max(0, int(time.time() * 1000) - want * period_ms) + + now_ms = int(time.time() * 1000) + guard = 0 + prev_since = None + while len(collected) < want and guard < 80: + guard += 1 + if since >= now_ms: + break + req_limit = min(chunk_max, want - len(collected)) + try: + batch = exchange.fetch_ohlcv( + ex_sym, timeframe=tf, since=since, limit=req_limit + ) + except Exception as e: + err = str(e).lower() + if collected and ( + "from" in err + and "to" in err + or "invalid request parameter" in err + ): + break + raise + if not batch: + break + collected.extend(batch) + next_since = _next_since_from_batch(batch, period_ms) + if next_since >= now_ms: + break + if prev_since is not None and next_since <= prev_since: + break + prev_since = since + since = next_since + + bars = _bars_to_dicts(collected) + uniq: dict[int, dict[str, Any]] = {} + for b in bars: + uniq[int(b["open_time_ms"])] = b + merged = [uniq[k] for k in sorted(uniq.keys())] + if len(merged) > want: + merged = merged[-want:] + return merged + + +def _bars_to_dicts(ohlcv: list) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + out.append( + { + "open_time_ms": int(bar[0]), + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + } + ) + except (TypeError, ValueError): + continue + return out + + +def fetch_ohlcv_for_hub( + *, + symbol: str, + timeframe: str, + since_ms: int | None = None, + limit: int = 500, + normalize_symbol_input: Callable[[Any], str], + normalize_exchange_symbol: Callable[[str], str], + ensure_markets_loaded: Callable[[], None], + exchange, + friendly_error: Callable[[Exception], str] | None = None, +) -> dict[str, Any]: + """从 ccxt 拉 OHLCV,供本地图表与行情接口使用.""" + tf = normalize_chart_timeframe(timeframe) + sym = normalize_symbol_input(symbol) + if not sym: + return {"ok": False, "msg": "symbol 不能为空"} + try: + ensure_markets_loaded() + ex_sym = normalize_exchange_symbol(sym) + want = max(1, min(int(limit or bar_limit_for_timeframe(tf)), 1500)) + period = TIMEFRAME_MS[tf] + merged: list[dict[str, Any]] = [] + src_tf = OHLCV_AGGREGATE_FROM.get(tf) + + if exchange_supports_timeframe(exchange, tf): + candidate = _paginate_fetch_ohlcv( + exchange, + ex_sym, + tf, + want=want, + since_ms=since_ms, + period_ms=period, + ) + if candidate and bars_spacing_matches_timeframe(candidate, tf): + merged = candidate + + if ( + not merged + and src_tf + and exchange_supports_timeframe(exchange, src_tf) + ): + src_period = TIMEFRAME_MS[normalize_chart_timeframe(src_tf)] + ratio = max(1, int(math.ceil(period / src_period))) + src_want = min(1500, want * ratio + ratio * 4) + src_bars = _paginate_fetch_ohlcv( + exchange, + ex_sym, + src_tf, + want=src_want, + since_ms=since_ms, + period_ms=src_period, + ) + if not src_bars or not bars_spacing_matches_timeframe(src_bars, src_tf): + return { + "ok": False, + "msg": f"无法获取 {tf} K 线(细周期 {src_tf} 数据异常)", + } + merged = aggregate_ohlcv_bars(src_bars, tf) + if len(merged) > want: + merged = merged[-want:] + + if not merged: + try: + tail = exchange.fetch_ohlcv( + ex_sym, timeframe=tf, limit=min(want, 300) + ) + merged = _bars_to_dicts(tail or []) + if len(merged) > want: + merged = merged[-want:] + except Exception: + pass + if not merged: + return {"ok": False, "msg": "交易所未返回 K 线"} + + tick = normalize_price_tick(price_tick_from_market(exchange, ex_sym)) + round_ohlcv_bars_to_tick(merged, tick) + + return { + "ok": True, + "symbol": sym, + "exchange_symbol": ex_sym, + "timeframe": tf, + "price_tick": tick, + "bars": merged, + } + except Exception as e: + msg = friendly_error(e) if friendly_error else str(e) + return {"ok": False, "msg": f"K线加载失败:{msg}"} diff --git a/lib/market/position_metrics_lib.py b/lib/market/position_metrics_lib.py new file mode 100644 index 0000000..17b7ccb --- /dev/null +++ b/lib/market/position_metrics_lib.py @@ -0,0 +1,311 @@ +"""ccxt position mark/metrics helpers.""" + +from __future__ import annotations + +import math +import re +from typing import Any, Callable + + +def _finite_or_none(x: Any) -> float | None: + try: + f = float(x) + return f if math.isfinite(f) else None + except (TypeError, ValueError): + return None + + +def _coerce_float(*values: Any) -> float | None: + for v in values: + if v is None or v == "": + continue + px = _finite_or_none(v) + if px is not None and px > 0: + return px + return None + + +# OKX ccxt: ETH/USD:USD-260806-1875-C ; instId: ETH-USD-260806-1875-C +_OPTION_SYM_RE = re.compile( + r"(?:^|[/:])[A-Z0-9]+(?:-USD)?(?::USD)?-\d{6}-\d+-(?:C|P|CALL|PUT)$", + re.IGNORECASE, +) + + +def is_option_like_position(pos: dict[str, Any] | None) -> bool: + """识别期权仓(子代理/中控浮盈合计须排除,避免按永续线性公式误算).""" + if not isinstance(pos, dict): + return False + info = pos.get("info") if isinstance(pos.get("info"), dict) else {} + inst_type = str( + info.get("instType") + or info.get("inst_type") + or pos.get("type") + or "" + ).upper() + if inst_type in ("OPTION", "OPT"): + return True + sym = str( + pos.get("symbol") + or info.get("instId") + or info.get("instrument_name") + or info.get("contract") + or "" + ).strip() + if not sym: + return False + if _OPTION_SYM_RE.search(sym.replace(" ", "")): + return True + su = sym.upper() + if su.endswith("-C") or su.endswith("-P") or su.endswith("-CALL") or su.endswith("-PUT"): + # 永续多为 BTC/USDT:USDT;期权常带到期日段 + if re.search(r"-\d{6}-\d+-(?:C|P|CALL|PUT)$", su): + return True + return False + + +CONTRACTS_QTY_DECIMALS = 2 + + +def normalize_contracts_qty(qty: Any, *, decimals: int = CONTRACTS_QTY_DECIMALS) -> float: + """张数统一精度(OKX 等线性永续默认两位小数).""" + try: + q = float(qty) + except (TypeError, ValueError): + return 0.0 + if not math.isfinite(q): + return 0.0 + return round(abs(q), decimals) + + +def contracts_qty_is_open(qty: Any, *, decimals: int = CONTRACTS_QTY_DECIMALS) -> bool: + return normalize_contracts_qty(qty, decimals=decimals) > 0 + + +def position_contracts(p: dict[str, Any]) -> float: + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + # OKX 等:info.pos 为交易所张数,优先于 ccxt contracts(加仓后后者可能滞后) + for k in ("pos", "positionAmt", "positionamt", "size"): + if k in info: + try: + v = float(info[k]) + if v != 0: + return normalize_contracts_qty(v) + except (TypeError, ValueError): + pass + raw = p.get("contracts") + if raw is not None: + try: + v = float(raw) + if v != 0: + return normalize_contracts_qty(v) + except (TypeError, ValueError): + pass + return 0.0 + + +def position_side_from_ccxt(p: dict[str, Any], contracts: float | None = None) -> str: + s = (p.get("side") or "").lower() + if s in ("long", "short"): + return s + c = contracts if contracts is not None else position_contracts(p) + if c > 0: + return "long" + if c < 0: + return "short" + return "long" + + +def parse_position_entry_price(p: dict[str, Any]) -> float | None: + """三所 ccxt 持仓开仓均价.""" + if not isinstance(p, dict): + return None + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + return _coerce_float( + p.get("entryPrice"), + p.get("entry_price"), + p.get("average"), + info.get("entryPrice"), + info.get("entry_price"), + info.get("avgPx"), + info.get("avgEntryPrice"), + info.get("avg_entry_price"), + info.get("avgPrice"), + info.get("openAvgPx"), + ) + + +def estimate_linear_swap_upnl_usdt( + side: str, + entry: float | None, + mark: float | None, + contracts: float | None, + contract_size: float | None = None, +) -> float | None: + """U 本位线性永续:浮盈 = (标记价 - 开仓价) × 张数 × contractSize(空头取反).""" + e = _finite_or_none(entry) + m = _finite_or_none(mark) + c = _finite_or_none(contracts) + if e is None or m is None or c is None or c <= 0: + return None + mult = _finite_or_none(contract_size) + if mult is None or mult <= 0: + mult = 1.0 + diff = (m - e) if (side or "long").strip().lower() == "long" else (e - m) + return round(diff * abs(c) * mult, 2) + + +def resolve_position_display_upnl( + side: str, + entry: float | None, + mark: float | None, + contracts: float | None, + contract_size: float | None, + exchange_upnl: float | None, +) -> float | None: + """展示用浮盈:优先与标记价/张数一致的推算;与交易所值偏差过大时用推算值.""" + computed = estimate_linear_swap_upnl_usdt( + side, entry, mark, contracts, contract_size + ) + if computed is None: + return exchange_upnl + if exchange_upnl is None: + return computed + ref = max(abs(computed), 1.0) + if abs(exchange_upnl - computed) / ref > 0.2: + return computed + return exchange_upnl + + +def _coerce_signed(*values: Any) -> float | None: + """解析可正可负的数值(未实现盈亏等).""" + for v in values: + if v is None or v == "": + continue + f = _finite_or_none(v) + if f is not None: + return f + return None + + +def parse_position_unrealized_pnl(p: dict[str, Any]) -> float | None: + """三所 ccxt 持仓统一解析未实现盈亏(Gate/OKX/Binance 字段名不一致).""" + if not isinstance(p, dict): + return None + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + return _coerce_signed( + p.get("unrealizedPnl"), + p.get("unrealisedPnl"), + p.get("unrealized_pnl"), + p.get("unrealised_pnl"), + info.get("unrealised_pnl"), + info.get("unrealized_pnl"), + info.get("unrealisedPnl"), + info.get("unrealizedPnl"), + info.get("upl"), + info.get("uplLast"), + ) + + +def enrich_ccxt_position_metrics_out( + position: dict[str, Any], + out: dict[str, Any], + *, + contract_size: float = 1.0, + funds_decimals: int = 2, +) -> dict[str, Any]: + """ + 三所 parse_ccxt_position_metrics 产出后统一: + - 标记价用 hub 兜底 + - 未实现盈亏 = resolve(交易所值, entry/mark/张数/contractSize 推算) + """ + if not isinstance(position, dict) or not isinstance(out, dict): + return out + mark = _finite_or_none(out.get("mark_price")) + if mark is None or mark <= 0: + mp = parse_position_mark_price(position) + if mp is not None and mp > 0: + out["mark_price"] = round(mp, 8) + mark = mp + exchange_upnl = parse_position_unrealized_pnl(position) + if exchange_upnl is None: + exchange_upnl = _coerce_signed(out.get("unrealized_pnl")) + c = position_contracts(position) + if abs(c) < 1e-12: + return out + side = position_side_from_ccxt(position, c) + entry = parse_position_entry_price(position) + if entry is not None and entry > 0: + out["entry_price"] = round(entry, 8) + cs = contract_size if contract_size and contract_size > 0 else 1.0 + upnl = resolve_position_display_upnl( + side, entry, mark, abs(c), cs, exchange_upnl + ) + if upnl is not None: + out["unrealized_pnl"] = round(upnl, funds_decimals) + return out + + +def parse_position_mark_price(p: dict[str, Any]) -> float | None: + """三所 ccxt 持仓统一解析标记价(与 crypto_monitor_* parse_ccxt_position_metrics 口径一致).""" + if not isinstance(p, dict): + return None + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + mark = _coerce_float( + p.get("markPrice"), + p.get("mark_price"), + p.get("mark"), + info.get("markPx"), + info.get("mark_price"), + info.get("markPrice"), + ) + if mark is not None: + return mark + contracts = position_contracts(p) + if abs(contracts) >= 1e-12: + notional = _finite_or_none(p.get("notional")) + if notional is not None and abs(notional) > 0: + return abs(notional) / abs(contracts) + return None + + +def build_position_marks_list( + positions: list, + *, + format_mark_display: Callable[[str, float], str] | None = None, +) -> list[dict[str, Any]]: + """从 fetch_positions 结果生成 position_marks,供 price_snapshot / 中控合并.""" + out: list[dict[str, Any]] = [] + for p in positions or []: + if not isinstance(p, dict): + continue + c = position_contracts(p) + if abs(c) < 1e-12: + continue + mark = parse_position_mark_price(p) + if mark is None or mark <= 0: + continue + sym = (p.get("symbol") or "").strip() + side = position_side_from_ccxt(p, c) + row: dict[str, Any] = { + "symbol": sym, + "side": side, + "mark_price": mark, + } + if format_mark_display and sym: + try: + row["mark_price_display"] = format_mark_display(sym, mark) + except Exception: + row["mark_price_display"] = f"{mark:g}" + else: + row["mark_price_display"] = f"{mark:g}" + out.append(row) + return out diff --git a/lib/market/price_snapshot_lib.py b/lib/market/price_snapshot_lib.py new file mode 100644 index 0000000..48b213b --- /dev/null +++ b/lib/market/price_snapshot_lib.py @@ -0,0 +1,124 @@ +"""price_snapshot 共用:订单行情价兜底,避免 get_price 失败时整单不入 order_prices.""" +from __future__ import annotations + +from typing import Any, Callable, Mapping, Optional, Sequence + +from lib.market.position_metrics_lib import parse_position_mark_price + + +def resolve_order_snapshot_price( + symbol: str, + prices: Mapping[str, float], + *, + position_row: Optional[dict[str, Any]] = None, + order_leverage=None, + parse_position_metrics_fn: Callable[..., dict[str, Any] | None] | None = None, + get_mark_price_fn: Callable[[str], float | None] | None = None, + fallback_entry: float | None = None, +) -> float | None: + """ + 解析下单监控轮询用的现价/标记价,优先级: + 1. 已批量拉取的 ticker last + 2. get_symbol_mark_price(含 mark) + 3. 交易所持仓 mark(parse_ccxt_position_metrics / parse_position_mark_price) + 4. 计划成交价 trigger_price + """ + sym = (symbol or "").strip() + if not sym: + return None + + cached = prices.get(sym) + if cached is not None: + try: + v = float(cached) + if v > 0: + return v + except (TypeError, ValueError): + pass + + if get_mark_price_fn is not None: + try: + mp = get_mark_price_fn(sym) + if mp is not None and float(mp) > 0: + return float(mp) + except Exception: + pass + + if position_row: + mark = None + if parse_position_metrics_fn is not None: + try: + metrics = parse_position_metrics_fn( + position_row, order_leverage=order_leverage + ) + if isinstance(metrics, dict) and metrics.get("mark_price") is not None: + mark = float(metrics["mark_price"]) + except Exception: + mark = None + if mark is None or mark <= 0: + try: + mp = parse_position_mark_price(position_row) + if mp is not None and mp > 0: + mark = float(mp) + except Exception: + mark = None + if mark is not None and mark > 0: + return mark + + if fallback_entry is not None: + try: + entry = float(fallback_entry) + if entry > 0: + return entry + except (TypeError, ValueError): + pass + return None + + +def seed_prices_from_positions( + prices: dict[str, float], + order_rows: Sequence[Any], + all_positions: Sequence[dict[str, Any]], + *, + resolve_ex_sym_fn: Callable[[Any], str], +) -> None: + """用持仓标记价补全 prices 字典(symbol 与 order_monitors 行对齐).""" + if not all_positions or not order_rows: + return + try: + from lib.market.symbol_lib import symbols_match + except Exception: + symbols_match = None + for r in order_rows: + try: + sym = str(r["symbol"] or "").strip() + except (KeyError, TypeError, IndexError): + sym = "" + if not sym or sym in prices: + continue + try: + ex_sym = resolve_ex_sym_fn(r) + except Exception: + ex_sym = sym + try: + direction = str(r["direction"] or "long").lower() + except (KeyError, TypeError, IndexError): + direction = "long" + for p in all_positions: + if not isinstance(p, dict): + continue + ps = p.get("symbol") or "" + if not ps: + continue + matched = ps == sym or ps == ex_sym + if not matched and symbols_match is not None: + matched = symbols_match(sym, ps) or symbols_match(ex_sym, ps) + if not matched: + continue + side = (p.get("side") or "").lower() + if side and side != direction: + continue + mp = parse_position_mark_price(p) + if mp is not None and mp > 0: + prices[sym] = float(mp) + break diff --git a/lib/market/reconcile_flat_lib.py b/lib/market/reconcile_flat_lib.py new file mode 100644 index 0000000..69c40d6 --- /dev/null +++ b/lib/market/reconcile_flat_lib.py @@ -0,0 +1,98 @@ +"""Sync order_monitors after an external/market flat.""" +from __future__ import annotations + +import time +from typing import Any, Callable + + +def reconcile_hub_external_close_impl( + conn, + symbol: str, + direction: str, + *, + exchange_configured: Callable[[], bool], + not_configured_msg: str, + symbols_match: Callable[[str, str], bool], + get_opened_at_value: Callable[[Any], str], + resolve_monitor_exchange_symbol: Callable[[Any], str], + get_live_position_contracts: Callable[[str, str], float | None], + cancel_conditional_orders: Callable[[str], None], + resolve_synced_flat_close: Callable[..., tuple], + finalize_stopped_monitor: Callable[..., None], + sync_trade_records: Callable[..., None] | None = None, + reconcile_flat_streak: dict | None = None, + to_ms_with_fallback: Callable[..., int | None] | None = None, + prefer_manual_resolve: bool = False, + order_row_monitor_type: Callable[[Any], str] | None = None, +) -> dict[str, Any]: + if not exchange_configured(): + return {"ok": False, "msg": not_configured_msg, "synced": 0} + sym_req = (symbol or "").strip() + dir_l = (direction or "").strip().lower() + if dir_l not in ("long", "short"): + return {"ok": False, "msg": "side 须为 long 或 short", "synced": 0} + synced = 0 + streak = reconcile_flat_streak if reconcile_flat_streak is not None else {} + rows = conn.execute( + "SELECT * FROM order_monitors WHERE status IN ('active', 'error')" + ).fetchall() + for r in rows: + if not symbols_match(str(r["symbol"] or ""), sym_req): + continue + if (r["direction"] or "").strip().lower() != dir_l: + continue + oid = int(r["id"]) + if r["status"] == "error": + opened_at_chk = get_opened_at_value(r) + mtype = order_row_monitor_type(r) if order_row_monitor_type else r["monitor_type"] + existing = conn.execute( + "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", + (r["symbol"], opened_at_chk, mtype), + ).fetchone() + if existing: + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) + synced += 1 + continue + exchange_symbol = resolve_monitor_exchange_symbol(r) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + continue + if live_contracts > 0: + time.sleep(0.6) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None or live_contracts > 0: + continue + streak.pop(oid, None) + cancel_conditional_orders(exchange_symbol) + opened_at = get_opened_at_value(r) + opened_at_ms = None + if to_ms_with_fallback is not None: + keys = r.keys() if hasattr(r, "keys") else () + opened_at_ms = to_ms_with_fallback( + r["opened_at_ms"] if "opened_at_ms" in keys else None, + opened_at, + ) + resolve_kw = {"opened_at_ms": opened_at_ms} + if prefer_manual_resolve: + resolve_kw["prefer_manual"] = True + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close( + r, opened_at, **resolve_kw + ) + finalize_stopped_monitor( + conn, + r, + result=result, + pnl_amount=pnl_amount, + closed_at=closed_at, + miss_reason=miss_reason, + ) + synced += 1 + if sync_trade_records is not None: + try: + sync_trade_records(conn, force=True) + except Exception: + pass + return {"ok": True, "synced": synced} + + +reconcile_external_close_impl = reconcile_hub_external_close_impl diff --git a/lib/market/symbol_lib.py b/lib/market/symbol_lib.py new file mode 100644 index 0000000..2ec4aa6 --- /dev/null +++ b/lib/market/symbol_lib.py @@ -0,0 +1,38 @@ +"""合约 symbol 匹配(持仓 vs 监控/挂单).""" + + +def _symbol_base_coin(symbol: str) -> str: + s = (symbol or "").strip().upper() + if not s: + return "" + if "-SWAP" in s: + s = s.replace("-SWAP", "") + if "-" in s: + return s.split("-", 1)[0] + if "/" in s: + return s.split("/", 1)[0] + if ":" in s: + return s.split(":", 1)[0] + return s + + +def symbols_match(position_symbol: str, order_symbol: str) -> bool: + a = (position_symbol or "").strip().upper() + b = (order_symbol or "").strip().upper() + if not a or not b: + return False + if a == b: + return True + ba, bb = _symbol_base_coin(a), _symbol_base_coin(b) + if ba and bb and ba == bb: + return True + for suf in (":USDT", "/USDT:USDT", "/USDT"): + a2 = a.replace(suf, "") + b2 = b.replace(suf, "") + if f"{a2}/USDT" == b or f"{a2}/USDT:USDT" == b: + return True + if f"{b2}/USDT" == a or f"{b2}/USDT:USDT" == a: + return True + if a2 == b2: + return True + return False diff --git a/lib/market/volume_rank_lib.py b/lib/market/volume_rank_lib.py new file mode 100644 index 0000000..d24842b --- /dev/null +++ b/lib/market/volume_rank_lib.py @@ -0,0 +1,599 @@ +"""行情区:各交易所 USDT 永续昨日成交额 Top N(每日 8:00 快照).""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Callable +from zoneinfo import ZoneInfo + + +def trading_day_from_dt(dt: datetime, reset_hour: int = 8) -> str: + """Hours < reset_hour belong to the previous calendar day.""" + if dt.hour < reset_hour: + dt = dt - timedelta(days=1) + return dt.strftime("%Y-%m-%d") + + +TOP_N_DEFAULT = 20 +CACHE_VERSION = 3 +LIQUIDITY_RANK_CACHE_VERSION = 1 + + +def volume_rank_reset_hour() -> int: + try: + return max(0, min(23, int(os.getenv("HUB_VOLUME_RANK_RESET_HOUR", "8")))) + except ValueError: + return 8 + + +def volume_rank_timezone() -> ZoneInfo: + name = (os.getenv("HUB_VOLUME_RANK_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai" + try: + return ZoneInfo(name) + except Exception: + return ZoneInfo("Asia/Shanghai") + + +def rank_date_label(*, now: datetime | None = None, reset_hour: int | None = None) -> str: + """8 点更新后展示的「昨日」交易日(与 TRADING_DAY_RESET_HOUR 口径一致).""" + rh = volume_rank_reset_hour() if reset_hour is None else reset_hour + tz = volume_rank_timezone() + dt = now.astimezone(tz) if now else datetime.now(tz) + cur_td = trading_day_from_dt(dt.replace(tzinfo=None), rh) + cur = datetime.strptime(cur_td, "%Y-%m-%d").date() + return (cur - timedelta(days=1)).isoformat() + + +def seconds_until_next_reset( + *, + now: datetime | None = None, + reset_hour: int | None = None, +) -> float: + rh = volume_rank_reset_hour() if reset_hour is None else reset_hour + tz = volume_rank_timezone() + dt = now.astimezone(tz) if now else datetime.now(tz) + nxt = dt.replace(hour=rh, minute=0, second=0, microsecond=0) + if dt >= nxt: + nxt += timedelta(days=1) + return max(1.0, (nxt - dt).total_seconds()) + + +def default_cache_path() -> Path: + raw = (os.getenv("HUB_VOLUME_RANK_CACHE_PATH") or os.getenv("VOLUME_RANK_CACHE_PATH") or "").strip() + if raw: + return Path(raw) + return Path(__file__).resolve().parents[2] / "data" / "volume_rank.json" + + +def _safe_float(v: Any) -> float | None: + try: + n = float(v) + return n if n == n else None + except (TypeError, ValueError): + return None + + +def _ticker_base(sym_text: str) -> str: + s = str(sym_text or "").upper().strip() + if ":" in s: + s = s.split(":", 1)[0] + if "/" in s: + return s.split("/", 1)[0].strip() + if "-" in s: + return s.split("-", 1)[0].strip() + if s.endswith("USDT"): + return s[:-4].strip() + return s + + +def _hub_symbol_from_base(base: str, quote: str = "USDT") -> str: + b = str(base or "").strip().upper() + q = str(quote or "USDT").strip().upper() + return f"{b}/{q}" if b else "" + + +def _hub_symbol_from_market(market: dict | None, fallback_symbol: str) -> str: + if market: + base = str(market.get("base") or "").strip().upper() + quote = str(market.get("quote") or "USDT").strip().upper() + if base: + return f"{base}/{quote}" + fb = str(fallback_symbol or "").upper().strip() + if ":" in fb: + fb = fb.split(":", 1)[0] + if "/" in fb: + return fb + base = _ticker_base(fb) + return f"{base}/USDT" if base else fb + + +def _okx_turnover_usdt(row: dict | None) -> float | None: + """OKX SWAP:成交额(USDT) ≈ volCcy24h(基础币) × last.""" + if not isinstance(row, dict): + return None + base_vol = _safe_float(row.get("volCcy24h")) + if base_vol is None or base_vol <= 0: + return None + last = _safe_float(row.get("last") or row.get("lastPx")) + if last is None or last <= 0: + return None + return float(base_vol * last) + + +def _quote_volume_from_ticker( + ticker: dict | None, + market: dict | None, + *, + exchange_id: str = "", +) -> float | None: + ex_id = str(exchange_id or "").lower() + t = ticker or {} + info = t.get("info") if isinstance(t.get("info"), dict) else {} + + if ex_id == "okx": + row = dict(info) + if row.get("last") is None: + row["last"] = t.get("last") + qv = _okx_turnover_usdt(row) + if qv is not None and qv > 0: + return qv + + qv = _safe_float(t.get("quoteVolume")) + if qv is not None and qv > 0: + return qv + + if ex_id in ("gateio", "gate"): + for key in ( + "volume_24h_quote", + "volume_24h_settle", + "quote_volume", + "vol_24h", + "turnover", + ): + qv = _safe_float(info.get(key)) + if qv is not None and qv > 0: + return qv + + for key in ("quoteVolume", "volCcy24h", "vol24h", "turnover24h", "amount24", "turnover"): + qv = _safe_float(info.get(key)) + if qv is not None and qv > 0: + if key == "volCcy24h" and ex_id == "okx": + last = _safe_float(info.get("last") or info.get("lastPx") or t.get("last")) + if last: + return qv * last + return qv + + bv = _safe_float(t.get("baseVolume")) + lp = _safe_float(t.get("last")) or _safe_float(t.get("close")) + if bv is not None and lp is not None and bv > 0 and lp > 0: + return bv * lp + + if info: + bv = _safe_float(info.get("volCcy24h") or info.get("vol24h") or info.get("volume")) + lp = _safe_float(info.get("last") or info.get("lastPx") or info.get("markPrice")) + if bv is not None and lp is not None and bv > 0 and lp > 0: + return bv * lp + + return None + + +def _is_usdt_linear_swap(market: dict | None, symbol: str) -> bool: + if not market: + su = str(symbol or "").upper() + return "USDT" in su and (":USDT" in su or "/USDT" in su or su.endswith("USDT")) + if not market.get("swap") and market.get("type") not in ("swap", "future"): + return False + if str(market.get("quote") or "").upper() != "USDT": + return False + if market.get("linear") is False: + return False + if market.get("active") is False: + return False + settle = str(market.get("settle") or "").upper() + if settle and settle != "USDT": + return False + return True + + +def _lookup_ticker(tickers: dict, sym: str, market: dict | None) -> dict | None: + if not tickers: + return None + t = tickers.get(sym) + if t: + return t + if not market: + return None + base = market.get("base") + quote = market.get("quote") or "USDT" + settle = market.get("settle") or quote + candidates = [ + sym, + f"{base}/{quote}:{settle}", + f"{base}/{quote}", + f"{base}{quote}", + market.get("id"), + ] + for key in candidates: + if not key: + continue + t = tickers.get(key) + if t: + return t + return None + + +def _merge_scores(scored: dict[str, tuple[str, float]]) -> list[tuple[str, str, float]]: + rows = [(sym, base, vol) for base, (sym, vol) in scored.items() if sym and base and vol > 0] + rows.sort(key=lambda x: x[2], reverse=True) + return rows + + +def _scores_from_okx(exchange) -> list[tuple[str, str, float]]: + by_base: dict[str, tuple[str, float]] = {} + if hasattr(exchange, "publicGetMarketTickers"): + try: + resp = exchange.publicGetMarketTickers({"instType": "SWAP"}) + for row in (resp or {}).get("data") or []: + if not isinstance(row, dict): + continue + inst = str(row.get("instId") or "").upper() + parts = inst.split("-") + if len(parts) < 3 or parts[-1] != "SWAP" or parts[1] != "USDT": + continue + base = parts[0].strip() + if not base: + continue + qv = _okx_turnover_usdt(row) + if qv is None or qv <= 0: + continue + sym = _hub_symbol_from_base(base) + prev = by_base.get(base) + if prev is None or qv > prev[1]: + by_base[base] = (sym, float(qv)) + if by_base: + return _merge_scores(by_base) + except Exception: + pass + + try: + tickers = exchange.fetch_tickers(params={"instType": "SWAP"}) + except Exception: + tickers = exchange.fetch_tickers() + return _scores_from_markets(exchange, tickers or {}, "okx") + + +def _scores_from_binance(exchange) -> list[tuple[str, str, float]]: + by_base: dict[str, tuple[str, float]] = {} + if hasattr(exchange, "fapiPublicGetTicker24hr"): + try: + rows = exchange.fapiPublicGetTicker24hr() + if isinstance(rows, list): + for row in rows: + if not isinstance(row, dict): + continue + raw = str(row.get("symbol") or "").upper() + if not raw.endswith("USDT"): + continue + base = raw[:-4] + if not base: + continue + qv = _safe_float(row.get("quoteVolume")) + if qv is None or qv <= 0: + bv = _safe_float(row.get("volume")) + lp = _safe_float(row.get("lastPrice") or row.get("weightedAvgPrice")) + if bv and lp: + qv = bv * lp + if qv is None or qv <= 0: + continue + sym = _hub_symbol_from_base(base) + prev = by_base.get(base) + if prev is None or qv > prev[1]: + by_base[base] = (sym, float(qv)) + if by_base: + return _merge_scores(by_base) + except Exception: + pass + return [] + + +def _scores_from_gate(exchange) -> list[tuple[str, str, float]]: + by_base: dict[str, tuple[str, float]] = {} + for method_name in ("publicFuturesGetSettleTickers", "publicFuturesGetUsdtTickers"): + fn = getattr(exchange, method_name, None) + if not callable(fn): + continue + try: + rows = fn({"settle": "usdt"}) + if isinstance(rows, list): + for row in rows: + if not isinstance(row, dict): + continue + contract = str(row.get("contract") or row.get("name") or "").upper() + if not contract: + continue + base = contract.replace("_USDT", "").replace("USDT", "").strip("_") + if not base: + continue + qv = _safe_float(row.get("volume_24h_quote") or row.get("volume_24h_settle")) + if qv is None or qv <= 0: + bv = _safe_float(row.get("volume_24h_base")) + lp = _safe_float(row.get("last") or row.get("mark_price")) + if bv and lp: + qv = bv * lp + if qv is None or qv <= 0: + continue + sym = _hub_symbol_from_base(base) + prev = by_base.get(base) + if prev is None or qv > prev[1]: + by_base[base] = (sym, float(qv)) + if by_base: + return _merge_scores(by_base) + except Exception: + continue + return [] + + +def _scores_from_markets( + exchange, + tickers: dict, + exchange_id: str, +) -> list[tuple[str, str, float]]: + by_base: dict[str, tuple[str, float]] = {} + markets = getattr(exchange, "markets", None) or {} + for sym, mk in markets.items(): + try: + if not _is_usdt_linear_swap(mk, sym): + continue + ticker = _lookup_ticker(tickers, sym, mk) + qv = _quote_volume_from_ticker(ticker, mk, exchange_id=exchange_id) + if qv is None or qv <= 0: + continue + hub_sym = _hub_symbol_from_market(mk, sym) + base = _ticker_base(hub_sym) + if not base: + continue + prev = by_base.get(base) + if prev is None or qv > prev[1]: + by_base[base] = (hub_sym, float(qv)) + except Exception: + continue + return _merge_scores(by_base) + + +def _collect_scores(exchange, exchange_id: str) -> list[tuple[str, str, float]]: + ex_id = str(exchange_id or "").lower() + if ex_id == "okx": + return _scores_from_okx(exchange) + if ex_id == "binance": + return _scores_from_binance(exchange) + if ex_id in ("gateio", "gate"): + return _scores_from_gate(exchange) + tickers = exchange.fetch_tickers() + return _scores_from_markets(exchange, tickers or {}, ex_id) + + +def _uses_lightweight_volume_scores(exchange_id: str) -> bool: + ex_id = str(exchange_id or "").lower() + return ex_id in ("okx", "binance", "gateio", "gate") + + +def build_usdt_swap_volume_ranks( + exchange, + ensure_markets_loaded: Callable[[], None], + *, + exchange_id: str | None = None, +) -> tuple[dict[str, int], int]: + """ + 全市场 USDT 永续 24h 成交额排名(base -> rank). + 优先各所轻量 ticker API,避免 fetch_tickers() 拉全市场(Gate/Binance 内存优化). + """ + ex_id = str(exchange_id or getattr(exchange, "id", "") or "").lower() + if not _uses_lightweight_volume_scores(ex_id): + ensure_markets_loaded() + scored = _collect_scores(exchange, ex_id) + ranks: dict[str, int] = {} + for idx, (_sym, base, _qv) in enumerate(scored, 1): + if base and base not in ranks: + ranks[base] = idx + return ranks, len(scored) + + +def resolve_daily_volume_rank( + target_base: str, + cache: dict[str, Any], + *, + now_ts: float, + ttl_sec: float, + exchange, + ensure_markets_loaded: Callable[[], None], + exchange_id: str | None = None, + cache_version: int = LIQUIDITY_RANK_CACHE_VERSION, +) -> tuple[int | None, int]: + """关键位门控:按 base 查 24h 成交额全市场排名;cache 带 TTL.""" + cached_ok = ( + cache.get("version") == cache_version + and cache.get("updated_at") + and now_ts - float(cache["updated_at"]) < ttl_sec + ) + if not cached_ok: + try: + ranks, total = build_usdt_swap_volume_ranks( + exchange, + ensure_markets_loaded, + exchange_id=exchange_id, + ) + if total > 0 and ranks: + cache["ranks"] = ranks + cache["total"] = total + cache["version"] = cache_version + cache["updated_at"] = now_ts + except Exception: + pass + ranks = cache.get("ranks") or {} + total = int(cache.get("total") or 0) + base = str(target_base or "").strip().upper() + return ranks.get(base), total + + +def fetch_usdt_swap_volume_rank( + exchange, + ensure_markets_loaded: Callable[[], None], + *, + top_n: int = TOP_N_DEFAULT, + rank_date: str | None = None, + exchange_id: str | None = None, +) -> dict[str, Any]: + """从 ccxt 拉全市场 USDT 永续 ticker,按 24h 成交额(USDT) 取 Top N.""" + top_n = max(1, min(int(top_n or TOP_N_DEFAULT), 100)) + ensure_markets_loaded() + ex_id = str(exchange_id or getattr(exchange, "id", "") or "").lower() + + try: + scored = _collect_scores(exchange, ex_id) + except Exception as e: + return {"ok": False, "msg": str(e)} + + items = [] + for idx, (hub_sym, base, qv) in enumerate(scored[:top_n], 1): + items.append( + { + "rank": idx, + "symbol": hub_sym, + "base": base, + "volume_quote": round(qv, 4), + } + ) + return { + "ok": True, + "rank_date": rank_date or rank_date_label(), + "items": items, + "total_symbols": len(scored), + "exchange_id": ex_id, + "fetched_at": datetime.now(volume_rank_timezone()).isoformat(timespec="seconds"), + } + + +def format_volume_quote(value: float | None) -> str: + n = _safe_float(value) + if n is None or n <= 0: + return "—" + if n >= 1e9: + return f"{n / 1e9:.2f}B" + if n >= 1e6: + return f"{n / 1e6:.2f}M" + if n >= 1e3: + return f"{n / 1e3:.2f}K" + return f"{n:.0f}" + + +def load_volume_rank_cache(path: Path | None = None) -> dict[str, Any]: + p = path or default_cache_path() + if not p.is_file(): + return {"version": CACHE_VERSION, "exchanges": {}} + try: + data = json.loads(p.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return {"version": CACHE_VERSION, "exchanges": {}} + if int(data.get("version") or 0) < CACHE_VERSION: + return {"version": CACHE_VERSION, "exchanges": {}} + data.setdefault("version", CACHE_VERSION) + data.setdefault("exchanges", {}) + return data + except Exception: + return {"version": CACHE_VERSION, "exchanges": {}} + + +def save_volume_rank_cache(data: dict[str, Any], path: Path | None = None) -> None: + p = path or default_cache_path() + p.parent.mkdir(parents=True, exist_ok=True) + payload = dict(data) + payload["version"] = CACHE_VERSION + payload["updated_at"] = datetime.now(volume_rank_timezone()).isoformat(timespec="seconds") + p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def merge_exchange_rank( + cache: dict[str, Any], + exchange_key: str, + payload: dict[str, Any], +) -> dict[str, Any]: + ex_k = str(exchange_key or "").strip().lower() + if not ex_k or not payload.get("ok"): + return cache + exchanges = dict(cache.get("exchanges") or {}) + exchanges[ex_k] = { + "rank_date": payload.get("rank_date"), + "items": payload.get("items") or [], + "total_symbols": int(payload.get("total_symbols") or 0), + "fetched_at": payload.get("fetched_at"), + "error": None, + } + out = dict(cache) + out["exchanges"] = exchanges + out["rank_date"] = payload.get("rank_date") or cache.get("rank_date") + return out + + +def _exchange_rank_row_stale(row: dict[str, Any] | None) -> bool: + if not row: + return True + items = row.get("items") or [] + if len(items) < TOP_N_DEFAULT: + return True + total = int(row.get("total_symbols") or 0) + if total > 0 and total < TOP_N_DEFAULT: + return True + return False + + +def cache_needs_refresh( + cache: dict[str, Any], + *, + expected_rank_date: str | None = None, + required_keys: list[str] | None = None, +) -> bool: + expected = expected_rank_date or rank_date_label() + if int(cache.get("version") or 0) < CACHE_VERSION: + return True + exchanges = cache.get("exchanges") or {} + if not exchanges: + return True + if str(cache.get("rank_date") or "") != expected: + return True + keys = required_keys or list(exchanges.keys()) + if not keys: + return True + for key in keys: + ex_k = str(key or "").strip().lower() + if not ex_k: + continue + if _exchange_rank_row_stale(exchanges.get(ex_k)): + return True + return False + + +def get_cached_rank( + cache: dict[str, Any], + exchange_key: str, + *, + top_n: int = TOP_N_DEFAULT, +) -> dict[str, Any]: + ex_k = str(exchange_key or "").strip().lower() + ex_data = (cache.get("exchanges") or {}).get(ex_k) or {} + items = list(ex_data.get("items") or [])[: max(1, int(top_n))] + stale = _exchange_rank_row_stale(ex_data) + return { + "ok": True, + "exchange_key": ex_k, + "rank_date": ex_data.get("rank_date") or cache.get("rank_date"), + "updated_at": cache.get("updated_at"), + "items": items, + "item_count": len(items), + "expected_count": int(top_n), + "total_symbols": int(ex_data.get("total_symbols") or 0), + "stale": stale, + "error": ex_data.get("error"), + } diff --git a/lib/options/__init__.py b/lib/options/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/options/options_close_exec_lib.py b/lib/options/options_close_exec_lib.py new file mode 100644 index 0000000..7fbde32 --- /dev/null +++ b/lib/options/options_close_exec_lib.py @@ -0,0 +1,389 @@ +"""期权平仓执行:只锁买一限价卖出;永不市价.""" +from __future__ import annotations + +import time +from typing import Any + +from lib.options.options_close_gate_lib import ( + clear_close_gate, + is_close_gate_passed, + mark_close_gate_passed, + update_close_gate, +) +from lib.options.options_pricing_lib import ( + estimate_close_by_bids, + fetch_option_mark_px, + is_stub_bid_px, + total_premium, +) + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: + try: + conn = cfg["get_db"]() + try: + from lib.options.options_db import init_options_tables, sum_open_premium_paid + + init_options_tables(conn) + return sum_open_premium_paid(conn, inst_id) + finally: + conn.close() + except Exception: + pass + return None + + +def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]: + from lib.exchange.okx_options_lib import option_fields_from_inst_id + from lib.options.options_pricing_lib import close_ref_prices + + inst_id = str(pos.get("instId") or pos.get("inst_id") or "") + mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark")) + if mark is None: + mark = fetch_option_mark_px(ex, inst_id) + opt_type = pos.get("optType") or (quote or {}).get("opt_type") + strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike")) + if not opt_type or strike is None: + pt, ps = option_fields_from_inst_id(inst_id) + opt_type = opt_type or pt + if strike is None: + strike = ps + idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px")) + return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx) + + +def _avail_sheets(pos: dict[str, Any]) -> int: + avail = _safe_float(pos.get("availPos")) + if avail is None or avail <= 0: + avail = abs(_safe_float(pos.get("pos")) or 0) + return max(0, int(avail or 0)) + + +def _cancel_sell_pending(ex: Any, inst_id: str) -> None: + try: + pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {} + for o in pending.get("data") or []: + if str(o.get("side") or "").lower() != "sell": + continue + oid = o.get("ordId") + if not oid: + continue + try: + ex.private_post_trade_cancel_order({"instId": inst_id, "ordId": oid}) + except Exception: + pass + except Exception: + pass + + +def close_option_by_bid1( + cfg: dict[str, Any], + ex: Any, + inst_id: str, + *, + sheets: int | None = None, + require_recycle_gate: bool = False, + signal_note: str | None = None, +) -> dict[str, Any]: + """ + 本轮只吃买一深度: + - 本批张数 = min(请求张数, 持仓, 买一深度) + - 限价 = 校验通过时锁定的买一价 + - 永不市价 + - 始终校验有效流动性(残档买一禁止) + - require_recycle_gate=True 时:首次还需可回收≥2×权利金并持续 hold 秒; + 一旦通过后对同仓续批只验流动性 + """ + from lib.exchange.okx_options_lib import ( + _pos_side_from_position, + invalidate_option_positions_cache, + ) + + inst_id = (inst_id or "").strip() + if not inst_id: + return {"ok": False, "msg": "缺少 inst_id"} + + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return {"ok": False, "msg": q.get("msg") or "报价失败"} + tick_sz = q.get("tick_sz") + ct_mult = float(q.get("ct_mult") or 0.01) + + raw_positions = cfg["fetch_option_positions"](ex) + if raw_positions is None: + return {"ok": False, "msg": "获取期权持仓失败"} + pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None) + if not pos: + clear_close_gate(inst_id) + return {"ok": False, "msg": "未找到持仓", "already_flat": True} + + avail = _avail_sheets(pos) + want = int(sheets) if sheets else avail + want = min(want, avail) + if want < 1: + clear_close_gate(inst_id) + return {"ok": False, "msg": "可平张数不足", "already_flat": True} + + td_mode = str(pos.get("mgnMode") or cfg.get("td_mode") or "isolated") + pos_side = _pos_side_from_position(pos) or "net" + mark_px, intrinsic_px = _pos_close_refs(ex, pos, q) + premium_paid = _open_premium_paid(cfg, inst_id) + if premium_paid is None: + premium_paid = _safe_float(pos.get("premium_paid")) + + # 已有未成交卖平单:等成交,不撤不重挂 + try: + pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {} + sell_pending = [ + o + for o in (pending.get("data") or []) + if str(o.get("side") or "").lower() == "sell" and o.get("ordId") + ] + if sell_pending: + time.sleep(0.5) + invalidate_option_positions_cache() + raw_positions = cfg["fetch_option_positions"](ex) + if raw_positions is None: + return {"ok": False, "msg": "获取期权持仓失败"} + pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None) + if not pos or _avail_sheets(pos) < 1: + clear_close_gate(inst_id) + return { + "ok": True, + "already_flat": True, + "msg": "已有限价卖单成交", + "close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending), + "fully_closed": True, + "submitted_sheets": want, + "remaining_sheets": 0, + "mode": "bid1", + } + return { + "ok": False, + "msg": "等待已有买一限价卖单成交", + "stopped_reason": "pending_close_order", + "close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending), + } + except Exception: + pass + + book = cfg["fetch_option_book_depth"](ex, inst_id, 1) + preview = estimate_close_by_bids( + book.get("bids") or [], + want, + ct_mult=ct_mult, + premium_paid=premium_paid, + mark_px=mark_px, + intrinsic_px=intrinsic_px, + max_levels=1, + ) + if preview.get("bid_invalid") or preview.get("auto_close_blocked"): + # 不撤他人挂单:仅拒绝本轮下单 + update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) + return { + "ok": False, + "msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + "liquidity_blocked": True, + } + + levels = preview.get("levels") or [] + if not levels: + bid_px = _safe_float(q.get("bid")) + stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px) + if stub or bid_px is None or bid_px <= 0: + update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) + return { + "ok": False, + "msg": stub_reason or "暂无买一,无法限价平仓", + "stopped_reason": "stub_bid" if stub else "no_bid", + "auto_close_blocked": True, + "liquidity_blocked": True, + } + return { + "ok": False, + "msg": "暂无买一深度,无法平仓", + "stopped_reason": "no_bid_depth", + "liquidity_blocked": True, + } + + level = levels[0] + level_sheets = int(level.get("sheets") or 0) + level_px = float(level.get("px") or 0) + if level_sheets <= 0 or level_px <= 0: + return {"ok": False, "msg": "买一深度无效", "stopped_reason": "invalid_bid_depth"} + + stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px) + if stub_lv: + update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) + return { + "ok": False, + "msg": stub_lv_reason or "暂无有效买盘,禁止平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + "liquidity_blocked": True, + } + + # 自动平仓:2×权利金门控(首次);通过后同仓续批只验流动性 + gate = update_close_gate( + inst_id, + recycle_usdc=_safe_float(preview.get("total_received")), + premium_paid=premium_paid, + ) + if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"): + return { + "ok": False, + "msg": gate.get("msg") or "平仓门控未就绪(需可回收≥2×权利金并持续一段时间)", + "stopped_reason": "close_gate", + "auto_close_blocked": True, + "close_gate": gate, + } + + locked_bid_px = level_px + before_avail = avail + order = cfg["place_option_limit_order"]( + ex, + inst_id=inst_id, + side="sell", + sheets=level_sheets, + price=locked_bid_px, + td_mode=td_mode, + tick_sz=tick_sz, + reduce_only=True, + pos_side=pos_side, + ) + if not order.get("ok"): + return { + "ok": False, + "msg": order.get("msg") or "买一限价平仓失败", + "stopped_reason": "order_failed", + "locked_bid_px": locked_bid_px, + "batch_sheets": level_sheets, + } + # 仅下单被接受后才记门控已通过,避免下单失败却跳过后续 2× 等待 + if require_recycle_gate and gate.get("ready"): + mark_close_gate_passed(inst_id) + + px = float(order.get("px", locked_bid_px)) + oid = str((order.get("data") or {}).get("ordId") or "") + prem_recv = round(total_premium(px, level_sheets * ct_mult), 4) + time.sleep(0.6) + invalidate_option_positions_cache() + raw2 = cfg["fetch_option_positions"](ex) + if raw2 is None: + return { + "ok": False, + "msg": "下单后获取持仓失败,未确认是否成交", + "stopped_reason": "position_fetch_failed", + "locked_bid_px": locked_bid_px, + "batch_sheets": level_sheets, + "close_ord_id": oid or None, + "fully_closed": False, + } + after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None) + after_avail = _avail_sheets(after_pos) if after_pos else 0 + reduced = max(0, before_avail - after_avail) + remaining_pos = after_avail + fully_closed = remaining_pos < 1 + + if fully_closed: + clear_close_gate(inst_id) + conn = cfg["get_db"]() + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + open_rows = conn.execute( + """ + SELECT id, premium_paid FROM options_trades + WHERE inst_id = ? AND status = 'open' + ORDER BY id ASC + """, + (inst_id,), + ).fetchall() + total_paid = sum(float(r["premium_paid"] or 0) for r in open_rows) + allocated = 0.0 + for i, row in enumerate(open_rows): + paid = float(row["premium_paid"] or 0) + if i == len(open_rows) - 1: + recv = round(prem_recv - allocated, 4) + elif total_paid > 0: + recv = round(prem_recv * (paid / total_paid), 4) + allocated += recv + else: + recv = round(prem_recv / len(open_rows), 4) + allocated += recv + pnl = round(recv - paid, 4) + note_sql = "" + params: list[Any] = [px, recv, pnl, oid or None] + if signal_note and i == len(open_rows) - 1: + note_sql = """, + signal_note = CASE + WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN ? + ELSE signal_note + END""" + params.append(signal_note) + params.append(int(row["id"])) + conn.execute( + f""" + UPDATE options_trades + SET status = 'closed', close_quote = ?, premium_received = ?, + realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP + {note_sql} + WHERE id = ? + """, + tuple(params), + ) + conn.commit() + finally: + conn.close() + elif require_recycle_gate: + # 自动平已挂过单:同仓续批只验流动性 + mark_close_gate_passed(inst_id) + + return { + "ok": True, + "mode": "bid1", + "orders": [{"order": order, "px": px, "sheets": level_sheets}], + "bid": px, + "locked_bid_px": locked_bid_px, + "submitted_sheets": level_sheets, + "filled_or_reduced_sheets": min(reduced, level_sheets) if reduced else 0, + "remaining_sheets": remaining_pos, + "premium_received": prem_recv, + "stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"), + "close_ord_id": oid or None, + "fully_closed": fully_closed, + "msg": ( + f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张" + + ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓") + ), + } + + +# 兼容旧名 +def close_option_by_bid_depth( + cfg: dict[str, Any], + ex: Any, + inst_id: str, + *, + sheets: int | None = None, +) -> dict[str, Any]: + return close_option_by_bid1( + cfg, + ex, + inst_id, + sheets=sheets, + require_recycle_gate=True, + signal_note="目标位平仓", + ) diff --git a/lib/options/options_close_gate_lib.py b/lib/options/options_close_gate_lib.py new file mode 100644 index 0000000..d263db2 --- /dev/null +++ b/lib/options/options_close_gate_lib.py @@ -0,0 +1,184 @@ +"""期权按买盘平仓门控:可回收需 ≥ N×权利金,并持续持有一段时间后才允许平仓.""" +from __future__ import annotations + +import os +import threading +import time +from typing import Any + + +def _env_float(key: str, default: float) -> float: + try: + return float(os.getenv(key, str(default))) + except (TypeError, ValueError): + return default + + +# 可回收 ≥ 权利金 × 倍数,且该状态持续满 hold_seconds 才允许按买盘平仓 +CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0) +CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0) + +_lock = threading.Lock() +# inst_id -> {"ok_since": float|None, "recycle": float, "premium": float, "updated": float} +_gates: dict[str, dict[str, Any]] = {} + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def clear_close_gate(inst_id: str | None = None) -> None: + with _lock: + if inst_id: + _gates.pop(str(inst_id).strip(), None) + else: + _gates.clear() + + +def mark_close_gate_passed(inst_id: str) -> None: + """标记同仓已通过 2× 门控,续批平仓只验流动性.""" + inst = (inst_id or "").strip() + if not inst: + return + with _lock: + st = _gates.get(inst) or {} + st["passed"] = True + st["updated"] = time.time() + _gates[inst] = st + + +def is_close_gate_passed(inst_id: str) -> bool: + inst = (inst_id or "").strip() + if not inst: + return False + with _lock: + return bool((_gates.get(inst) or {}).get("passed")) + + +def update_close_gate( + inst_id: str, + *, + recycle_usdc: float | None, + premium_paid: float | None, + now: float | None = None, + min_mult: float | None = None, + hold_seconds: float | None = None, +) -> dict[str, Any]: + """ + 根据当前买盘可回收金额刷新门控. + 条件不满足时重置计时;满足时从首次满足起累计持续时间. + """ + inst = (inst_id or "").strip() + if not inst: + return { + "ok": False, + "ready": False, + "recycle_ok": False, + "msg": "缺少合约", + } + ts = float(now if now is not None else time.time()) + mult = float(min_mult if min_mult is not None else CLOSE_RECYCLE_MIN_MULT) + hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS) + if mult <= 0: + mult = 2.0 + if hold < 0: + hold = 0.0 + + prem = _safe_float(premium_paid) + recv = _safe_float(recycle_usdc) + need = round(prem * mult, 4) if prem is not None and prem > 0 else None + recycle_ok = bool( + prem is not None and prem > 0 and recv is not None and need is not None and recv + 1e-12 >= need + ) + + with _lock: + prev = _gates.get(inst) or {} + ok_since = prev.get("ok_since") + if recycle_ok: + if ok_since is None: + ok_since = ts + else: + ok_since = None + held = (ts - float(ok_since)) if ok_since is not None else 0.0 + ready = bool(recycle_ok and held + 1e-9 >= hold) + prev_passed = bool(prev.get("passed")) + passed = prev_passed or ready + state = { + "ok_since": ok_since, + "recycle": recv, + "premium": prem, + "need": need, + "updated": ts, + "min_mult": mult, + "hold_seconds": hold, + "passed": passed, + } + _gates[inst] = state + + remain = max(0.0, hold - held) if recycle_ok and not ready else None + if prem is None or prem <= 0: + msg = "缺少权利金,无法校验平仓门控" + elif recv is None: + msg = "暂无有效买盘可回收金额" + elif not recycle_ok: + msg = f"可回收 {recv:.4f} USDC < 权利金×{mult:g}({need:.4f}),目标平仓门控未过" + elif not ready: + msg = ( + f"可回收已达×{mult:g}({recv:.4f}/{need:.4f})," + f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过" + ) + else: + msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,目标触达后可按买一平仓" + + auto_blocked = not (ready or passed) + return { + "ok": True, + "ready": ready, + "passed": passed, + "recycle_ok": recycle_ok, + "recycle_usdc": recv, + "premium_paid": prem, + "need_recycle_usdc": need, + "min_mult": mult, + "hold_seconds": hold, + "held_seconds": round(held, 1) if recycle_ok else 0.0, + "remain_seconds": round(remain, 1) if remain is not None else None, + "ok_since": ok_since, + "msg": msg, + "auto_close_blocked": auto_blocked, + "close_gate_blocked": auto_blocked, + } + + +def check_close_gate( + inst_id: str, + *, + recycle_usdc: float | None = None, + premium_paid: float | None = None, + refresh: bool = True, +) -> dict[str, Any]: + """检查是否允许平仓;默认先用最新回收/权利金刷新.""" + inst = (inst_id or "").strip() + if refresh: + if recycle_usdc is None or premium_paid is None: + with _lock: + prev = _gates.get(inst) or {} + if recycle_usdc is None: + recycle_usdc = prev.get("recycle") + if premium_paid is None: + premium_paid = prev.get("premium") + return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid) + with _lock: + prev = _gates.get(inst) + if not prev: + return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid) + return update_close_gate( + inst, + recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"), + premium_paid=premium_paid if premium_paid is not None else prev.get("premium"), + ) diff --git a/lib/options/options_dashboard_lib.py b/lib/options/options_dashboard_lib.py new file mode 100644 index 0000000..eb55c23 --- /dev/null +++ b/lib/options/options_dashboard_lib.py @@ -0,0 +1,85 @@ +"""实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏).""" +from __future__ import annotations + +from typing import Any + + +def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]: + """ + 拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合. + 不走 options dashboard snapshot(避免余额/历史). + """ + if not cfg.get("enabled"): + return [] + ex = cfg.get("exchange_options") + ready_fn = cfg.get("options_api_ready") + if not callable(ready_fn): + return [] + ok, _reason = ready_fn(ex) + if not ok: + return [] + fetch_fn = cfg.get("fetch_option_positions") + if not callable(fetch_fn): + return [] + raw = fetch_fn(ex) + if raw is None: + return [] + if not raw: + return [] + + from lib.options.options_db import init_options_tables, sum_open_premium_paid + from lib.options.options_history_lib import enrich_position_row_display + from lib.options.options_positions_lib import attach_close_preview + + meta_cache: dict[str, dict[str, Any] | None] = {} + rows: list[dict[str, Any]] = [] + get_db = cfg.get("get_db") + if not callable(get_db): + for p in raw: + if not isinstance(p, dict): + continue + row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache) + attach_close_preview(cfg, ex, row) + rows.append(row) + return rows + + conn = get_db() + try: + init_options_tables(conn) + try: + from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst + from lib.options.options_target_lib import targets_by_inst + + tgt_map = targets_by_inst(conn) + hedge_target_map = active_options_targets_by_inst(conn) + except Exception: + tgt_map = {} + hedge_target_map = {} + + for p in raw: + if not isinstance(p, dict): + continue + inst = str(p.get("instId") or "").strip() + premium_override = sum_open_premium_paid(conn, inst) if inst else None + row = enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + attach_close_preview(cfg, ex, row, premium_paid=premium_override) + mon = tgt_map.get(str(row.get("inst_id") or "")) + if mon: + row["target_index"] = mon.get("target_index") + row["target_monitor_id"] = mon.get("id") + row["target_monitor"] = mon + hedge_target = hedge_target_map.get(str(row.get("inst_id") or "")) + if hedge_target: + row["hedge_plan_target"] = hedge_target + if not mon: + row["target_index"] = hedge_target.get("target_index") + rows.append(row) + finally: + conn.close() + return rows diff --git a/lib/options/options_db.py b/lib/options/options_db.py new file mode 100644 index 0000000..f4e5f22 --- /dev/null +++ b/lib/options/options_db.py @@ -0,0 +1,145 @@ +"""期权模块 SQLite 表.""" +from __future__ import annotations + +import sqlite3 + + +def init_options_tables(conn: sqlite3.Connection) -> None: + from lib.options.options_review_db import init_options_review_tables + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id TEXT NOT NULL, + underlying TEXT NOT NULL, + opt_type TEXT NOT NULL, + strike REAL, + exp_time TEXT, + sheets INTEGER NOT NULL, + eth_amount REAL NOT NULL, + open_quote REAL, + premium_paid REAL, + status TEXT DEFAULT 'open', + close_quote REAL, + premium_received REAL, + realized_pnl REAL, + profit_alert_sent INTEGER DEFAULT 0, + signal_note TEXT, + exchange_ord_id TEXT, + close_ord_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_convert_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_ccy TEXT, + to_ccy TEXT, + rfq_sz REAL, + received_sz REAL, + quote_id TEXT, + status TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_history_hidden ( + history_key TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_transfer_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ccy TEXT, + amount REAL, + from_account TEXT, + to_account TEXT, + status TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_target_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id TEXT NOT NULL, + underlying TEXT, + opt_type TEXT, + target_index REAL NOT NULL, + trade_id INTEGER, + sheets INTEGER, + status TEXT DEFAULT 'active', + trigger_idx REAL, + close_ord_id TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + triggered_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status + ON options_target_monitors(status) + """ + ) + for ddl in ( + "ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0", + "ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0", + "ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0", + "ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0", + "ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'", + ): + try: + conn.execute(ddl) + except Exception: + pass + init_options_review_tables(conn) + + +def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | None: + """同合约所有 open 腿权利金合计(加仓后显示/门控用).""" + inst = (inst_id or "").strip() + if not inst: + return None + row = conn.execute( + """ + SELECT SUM(premium_paid) AS total, COUNT(*) AS n + FROM options_trades + WHERE inst_id = ? AND status = 'open' AND premium_paid IS NOT NULL + """, + (inst,), + ).fetchone() + if not row or int(row["n"] or 0) < 1: + return None + return round(float(row["total"] or 0), 4) + + +def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None: + """同合约所有 open 腿张数合计.""" + inst = (inst_id or "").strip() + if not inst: + return None + row = conn.execute( + """ + SELECT SUM(sheets) AS total, COUNT(*) AS n + FROM options_trades + WHERE inst_id = ? AND status = 'open' + """, + (inst,), + ).fetchone() + if not row or int(row["n"] or 0) < 1: + return None + return int(row["total"] or 0) diff --git a/lib/options/options_history_lib.py b/lib/options/options_history_lib.py new file mode 100644 index 0000000..e54d868 --- /dev/null +++ b/lib/options/options_history_lib.py @@ -0,0 +1,86 @@ +"""期权历史列表(交易所 positions-history + 当前持仓).""" +from __future__ import annotations + +from typing import Any + +from lib.options.options_db import init_options_tables, sum_open_premium_paid + + +def enrich_position_row_display( + cfg: dict[str, Any], + ex: Any, + raw_pos: dict[str, Any], + *, + meta_cache: dict[str, dict[str, Any] | None] | None = None, + premium_override: float | None = None, +) -> dict[str, Any]: + from lib.exchange.okx_options_lib import format_position_row, format_usdc_amount, tick_sz_and_ct_mult + + inst_id = str(raw_pos.get("instId") or "").strip() + tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) + row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz) + if premium_override is not None: + row["premium_paid"] = premium_override + row["premium_paid_fmt"] = format_usdc_amount(premium_override) + return row + + +def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]: + """与期权历史页相同的数据源:交易所全平记录 + 当前持仓,排除本地隐藏项.""" + from lib.exchange.okx_options_lib import ( + fetch_all_option_positions_history, + format_live_option_history_row, + format_option_history_row, + tick_sz_and_ct_mult, + ) + + meta_cache: dict[str, dict[str, Any] | None] = {} + items: list[dict[str, Any]] = [] + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return [] + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + hidden_keys = { + str(r["history_key"]) + for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall() + } + for p in raw_live: + inst = str(p.get("instId") or "").strip() + premium_override = sum_open_premium_paid(conn, inst) if inst else None + row = enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + open_ms = None + ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime") + try: + if ctime is not None and str(ctime).strip(): + open_ms = int(float(ctime)) + except (TypeError, ValueError): + open_ms = None + items.append(format_live_option_history_row(row, open_ms=open_ms)) + finally: + conn.close() + + hist_raw = fetch_all_option_positions_history(ex, limit=200) + for raw in hist_raw: + inst_id = str(raw.get("instId") or "").strip() + tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) + items.append(format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult)) + + open_rows = [x for x in items if x.get("status") == "open"] + closed = [x for x in items if x.get("status") != "open"] + closed.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True) + open_rows.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True) + return [ + x + for x in (open_rows + closed) + if str(x.get("history_key") or "") not in hidden_keys + ] diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py new file mode 100644 index 0000000..e6e5093 --- /dev/null +++ b/lib/options/options_monitor_lib.py @@ -0,0 +1,492 @@ +"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步.""" +from __future__ import annotations + +import os +import sqlite3 +import time +from datetime import datetime, timezone +from typing import Any, Callable +from zoneinfo import ZoneInfo + +from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history + +_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai") + + +def _safe_float(v: Any) -> float | None: + if v is None: + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def build_profit_alert_message( + *, + account_label: str, + inst_id: str, + premium_paid: float, + upl: float, + upl_ratio: float | None, + bid: float | None, +) -> str: + pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else "—" + bid_txt = f"{bid:.4f}" if bid is not None else "—" + return "\n".join( + [ + "【OKX期权·翻倍提醒】", + f"账户:{account_label}", + f"合约:{inst_id}", + f"已付权利金:{premium_paid:.4f} USDC", + f"未实现盈亏:{upl:+.4f} USDC({pct})", + f"当前买一:{bid_txt}(可考虑限价平仓锁利)", + ] + ) + + +def run_options_profit_alerts( + conn: sqlite3.Connection, + positions: list[dict[str, Any]], + *, + profit_ratio: float, + send_wechat: Callable[[str], None], + account_label: str, + ticker_bid_fn: Callable[[str], float | None], +) -> int: + """ + 对比 DB 中 open 记录与交易所持仓;达到阈值发微信. + 返回发送条数. + """ + sent = 0 + pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} + rows = conn.execute( + """ + SELECT id, inst_id, premium_paid, profit_alert_sent + FROM options_trades + WHERE status = 'open' + ORDER BY id ASC + """ + ).fetchall() + # 同合约多腿加仓:按合约汇总权利金,整仓只告警一次 + by_inst: dict[str, dict[str, Any]] = {} + for row in rows: + inst_id = str(row["inst_id"] or "") + if not inst_id: + continue + bucket = by_inst.setdefault( + inst_id, + {"ids": [], "premium": 0.0, "all_sent": True, "has_prem": False}, + ) + bucket["ids"].append(int(row["id"])) + prem = _safe_float(row["premium_paid"]) + if prem is not None: + bucket["premium"] += float(prem) + bucket["has_prem"] = True + if not int(row["profit_alert_sent"] or 0): + bucket["all_sent"] = False + + for inst_id, bucket in by_inst.items(): + if bucket["all_sent"] or not bucket["has_prem"] or bucket["premium"] <= 0: + continue + pos = pos_by_inst.get(inst_id) + if not pos: + continue + prem = float(bucket["premium"]) + upl = _safe_float(pos.get("upl")) + upl_ratio = _safe_float(pos.get("upl_ratio_pct")) + if upl_ratio is not None: + ratio = upl_ratio / 100.0 + elif upl is not None: + ratio = upl / prem + else: + continue + if ratio < float(profit_ratio): + continue + bid = ticker_bid_fn(inst_id) + msg = build_profit_alert_message( + account_label=account_label, + inst_id=inst_id, + premium_paid=prem, + upl=upl or 0.0, + upl_ratio=ratio, + bid=bid, + ) + try: + send_wechat(msg) + conn.execute( + f"UPDATE options_trades SET profit_alert_sent = 1 WHERE id IN ({','.join('?' * len(bucket['ids']))})", + tuple(bucket["ids"]), + ) + sent += 1 + except Exception: + pass + return sent + + +def _created_at_ms(created_at: Any) -> int | None: + """墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC.""" + if not created_at: + return None + raw = str(created_at).strip() + if not raw: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)): + try: + dt = datetime.strptime(raw[:ln], fmt).replace(tzinfo=_APP_TZ) + return int(dt.timestamp() * 1000) + except ValueError: + continue + return None + + +def _group_key_for_closed_trade(row: Any) -> str: + inst = str(row["inst_id"] or "").strip() + closed = str(row["closed_at"] or "").strip() + close_prefix = closed[:16] if closed else "" + ord_id = str(row["close_ord_id"] or "").strip() if "close_ord_id" in row.keys() else "" + # 即使 close_ord_id/posId 相同,也要按平仓时间拆开(OKX 可能复用 posId) + if ord_id: + return f"{inst}|ord:{ord_id}|close:{close_prefix}" + return f"{inst}|close:{close_prefix}" + + +def backfill_closed_options_realized_pnl_from_history( + conn: sqlite3.Connection, + hist_rows: list[dict[str, Any]], + *, + trade_limit: int = 200, +) -> int: + """ + 用 OKX positions-history 的 realizedPnl 覆盖本地已平记录. + 同一次平仓多笔本地 open(加仓)按权利金占比分摊交易所总盈亏. + """ + by_inst: dict[str, list[dict[str, Any]]] = {} + for raw in hist_rows or []: + if not isinstance(raw, dict): + continue + inst = str(raw.get("instId") or "").strip() + if not inst: + continue + by_inst.setdefault(inst, []).append(raw) + + rows = conn.execute( + """ + SELECT id, inst_id, sheets, premium_paid, realized_pnl, close_quote, + created_at, closed_at, close_ord_id + FROM options_trades + WHERE status = 'closed' + ORDER BY id DESC + LIMIT ? + """, + (int(trade_limit),), + ).fetchall() + if not rows: + return 0 + + groups: dict[str, list[Any]] = {} + for row in rows: + inst = str(row["inst_id"] or "").strip() + if not inst or inst not in by_inst: + continue + groups.setdefault(_group_key_for_closed_trade(row), []).append(row) + + updated = 0 + for group in groups.values(): + inst = str(group[0]["inst_id"] or "").strip() + open_candidates = [_created_at_ms(r["created_at"]) for r in group] + open_ms = min((x for x in open_candidates if x is not None), default=None) + close_candidates = [_created_at_ms(r["closed_at"]) for r in group] + close_ms = max((x for x in close_candidates if x is not None), default=None) + sheets_hint = None + try: + sheets_hint = sum(float(_safe_float(r["sheets"]) or 0.0) for r in group) or None + except (TypeError, ValueError): + sheets_hint = None + close_info = resolve_option_close_from_history( + by_inst.get(inst) or [], + open_ms=open_ms, + close_ms=close_ms, + sheets=sheets_hint, + ) + if not close_info: + continue + ex_pnl = _safe_float(close_info.get("realized_pnl")) + if ex_pnl is None: + continue + close_quote = _safe_float(close_info.get("close_quote")) + matched_pos = str(close_info.get("pos_id") or "").strip() or None + total_paid = 0.0 + for r in group: + total_paid += float(_safe_float(r["premium_paid"]) or 0.0) + allocated = 0.0 + for i, r in enumerate(group): + paid = float(_safe_float(r["premium_paid"]) or 0.0) + if i == len(group) - 1: + share = round(float(ex_pnl) - allocated, 4) + elif total_paid > 0: + share = round(float(ex_pnl) * (paid / total_paid), 4) + allocated += share + else: + share = round(float(ex_pnl) / len(group), 4) + allocated += share + local = _safe_float(r["realized_pnl"]) + local_close = _safe_float(r["close_quote"]) + local_ord = str(r["close_ord_id"] or "").strip() + pnl_ok = local is not None and abs(local - share) < 1e-6 + quote_ok = close_quote is None or ( + local_close is not None and abs(local_close - float(close_quote)) < 1e-6 + ) + ord_ok = (not matched_pos) or (local_ord == matched_pos) + if pnl_ok and quote_ok and ord_ok: + continue + prem_recv = round(paid + share, 4) + conn.execute( + """ + UPDATE options_trades + SET realized_pnl = ?, + premium_received = ?, + close_quote = COALESCE(?, close_quote), + close_ord_id = COALESCE(?, close_ord_id) + WHERE id = ? + """, + (share, prem_recv, close_quote, matched_pos, int(r["id"])), + ) + updated += 1 + return updated + + +def sync_open_options_trades( + conn: sqlite3.Connection, + *, + live_inst_ids: set[str], + fetch_history_fn: Callable[[str], list[dict[str, Any]]], + notify_cfg: dict[str, Any] | None = None, +) -> int: + """ + 交易所已无持仓时,将本地 open 记录同步为 closed. + 优先用 positions-history 回填盈亏;否则到期后按归零处理. + """ + rows = conn.execute( + """ + SELECT id, inst_id, premium_paid, exp_time, created_at + FROM options_trades + WHERE status = 'open' + """ + ).fetchall() + updated = 0 + now_ms = int(time.time() * 1000) + for row in rows: + inst_id = str(row["inst_id"] or "") + if not inst_id or inst_id in live_inst_ids: + continue + paid = _safe_float(row["premium_paid"]) or 0.0 + open_ms = _created_at_ms(row["created_at"]) + exp_ms = normalize_option_exp_ms(row["exp_time"], inst_id) + close_quote: float | None = None + prem_recv: float | None = None + realized_pnl: float | None = None + close_ord_id: str | None = None + closed_at: str | None = None + close_reason = "exchange" + + close_info = resolve_option_close_from_history( + fetch_history_fn(inst_id), + open_ms=open_ms, + ) + if close_info: + close_quote = close_info.get("close_quote") + realized_pnl = close_info.get("realized_pnl") + close_ord_id = close_info.get("pos_id") + if realized_pnl is not None: + prem_recv = round(paid + float(realized_pnl), 4) + close_ms = close_info.get("close_ms") + if close_ms: + closed_at = datetime.fromtimestamp(int(close_ms) / 1000, tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S" + ) + elif exp_ms is not None and now_ms >= int(exp_ms): + close_reason = "expired" + close_quote = 0.0 + prem_recv = 0.0 + realized_pnl = round(-paid, 4) + if exp_ms: + closed_at = datetime.fromtimestamp(int(exp_ms) / 1000, tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S" + ) + else: + continue + + conn.execute( + """ + UPDATE options_trades + SET status = 'closed', + close_quote = ?, + premium_received = ?, + realized_pnl = ?, + close_ord_id = COALESCE(?, close_ord_id), + closed_at = COALESCE(?, closed_at, CURRENT_TIMESTAMP), + signal_note = CASE + WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '') + THEN '到期结算' + ELSE signal_note + END + WHERE id = ? + """, + ( + close_quote, + prem_recv, + realized_pnl, + close_ord_id, + closed_at, + close_reason, + int(row["id"]), + ), + ) + updated += 1 + if notify_cfg is not None: + try: + from lib.options.options_notify_lib import notify_options_close + + reason = "到期结算" if close_reason == "expired" else "交易所平仓" + notify_options_close( + notify_cfg, + conn, + inst_id=inst_id, + reason=reason, + trade_id=int(row["id"]), + premium_paid=paid, + premium_received=prem_recv, + realized_pnl=realized_pnl, + close_quote=close_quote, + ) + except Exception: + pass + return updated + + +def reconcile_live_open_trades( + conn: sqlite3.Connection, + *, + live_inst_ids: set[str], +) -> int: + """交易所有持仓但本地误标 closed 时恢复为 open.""" + fixed = 0 + for inst_id in live_inst_ids: + if not inst_id: + continue + open_row = conn.execute( + "SELECT id FROM options_trades WHERE inst_id = ? AND status = 'open' LIMIT 1", + (inst_id,), + ).fetchone() + if open_row: + continue + row = conn.execute( + """ + SELECT id, close_ord_id, realized_pnl + FROM options_trades + WHERE inst_id = ? AND status = 'closed' + ORDER BY id DESC LIMIT 1 + """, + (inst_id,), + ).fetchone() + if not row: + continue + if row["close_ord_id"]: + continue + if row["realized_pnl"] is not None: + continue + conn.execute( + """ + UPDATE options_trades + SET status = 'open', + close_quote = NULL, + premium_received = NULL, + realized_pnl = NULL, + closed_at = NULL, + signal_note = CASE + WHEN signal_note = '到期结算' THEN NULL + ELSE signal_note + END + WHERE id = ? + """, + (int(row["id"]),), + ) + fixed += 1 + return fixed + + +def options_monitor_loop( + *, + enabled: bool, + poll_seconds: float, + get_db: Callable[[], sqlite3.Connection], + fetch_positions: Callable[[], list[dict[str, Any]]], + ticker_bid_fn: Callable[[str], float | None], + send_wechat: Callable[[str], None], + account_label: str, + profit_ratio: float, + sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None, + target_close_fn: Callable[[str], dict[str, Any]] | None = None, + profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None, + profit_exit_cfg: dict[str, Any] | None = None, + stale_pending_fn: Callable[[], dict[str, Any]] | None = None, + stop_event: Any = None, +) -> None: + if not enabled: + return + while True: + if stop_event is not None and getattr(stop_event, "is_set", lambda: False)(): + break + try: + conn = get_db() + try: + positions = fetch_positions() + run_options_profit_alerts( + conn, + positions, + profit_ratio=profit_ratio, + send_wechat=send_wechat, + account_label=account_label, + ticker_bid_fn=ticker_bid_fn, + ) + if target_close_fn is not None: + from lib.options.options_target_lib import run_options_target_closes + + run_options_target_closes( + conn, + positions, + close_fn=target_close_fn, + send_wechat=send_wechat, + account_label=account_label, + cfg={"send_wechat": send_wechat, "account_label": account_label}, + ) + if profit_exit_close_fn is not None: + from lib.options.options_profit_exit_lib import run_options_profit_exits + + pe_cfg = dict(profit_exit_cfg or {}) + pe_cfg.setdefault("send_wechat", send_wechat) + pe_cfg.setdefault("account_label", account_label) + run_options_profit_exits( + conn, + positions, + close_fn=profit_exit_close_fn, + send_wechat=send_wechat, + account_label=account_label, + cfg=pe_cfg, + ex=pe_cfg.get("exchange_options"), + ) + if sync_trades_fn is not None: + sync_trades_fn(conn) + conn.commit() + finally: + conn.close() + # 平仓限价挂单超时撤单(独立于 DB 事务) + if stale_pending_fn is not None: + try: + stale_pending_fn() + except Exception: + pass + except Exception: + pass + time.sleep(max(5.0, float(poll_seconds))) diff --git a/lib/options/options_notify_lib.py b/lib/options/options_notify_lib.py new file mode 100644 index 0000000..7b32721 --- /dev/null +++ b/lib/options/options_notify_lib.py @@ -0,0 +1,330 @@ +"""OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记).""" +from __future__ import annotations + +import sqlite3 +from typing import Any, Callable, Optional + + +def _fmt(v: Any, d: int = 4) -> str: + try: + if v is None or v == "": + return "—" + return f"{float(v):.{d}f}" + except (TypeError, ValueError): + return str(v) + + +def _opt_type_label(opt_type: Any) -> str: + t = str(opt_type or "").strip().upper() + if t in ("C", "CALL"): + return "Call" + if t in ("P", "PUT"): + return "Put" + return t or "—" + + +def ensure_options_notify_columns(conn: sqlite3.Connection) -> None: + for ddl in ( + "ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0", + "ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0", + ): + try: + conn.execute(ddl) + except Exception: + pass + + +def notify_options_send(cfg: dict[str, Any], content: str) -> bool: + send: Optional[Callable[[str], Any]] = cfg.get("send_wechat") + if not callable(send): + return False + try: + send(content) + return True + except Exception: + return False + + +def build_options_open_message( + *, + account_label: str, + inst_id: str, + underlying: str = "", + opt_type: Any = None, + sheets: Any = None, + premium_paid: Any = None, + open_quote: Any = None, + target_index: Any = None, + signal_note: str = "", + trade_id: Any = None, +) -> str: + lines = [ + "【OKX期权·开仓】", + f"账户:{account_label or 'OKX期权'}", + ] + if trade_id is not None: + lines.append(f"本地单号:#{trade_id}") + lines.extend( + [ + f"合约:{inst_id}", + f"标的:{(underlying or '—')} · {_opt_type_label(opt_type)}", + f"张数:{sheets if sheets is not None else '—'}", + f"开仓报价:{_fmt(open_quote)} USDC", + f"权利金:{_fmt(premium_paid)} USDC", + ] + ) + if target_index is not None and str(target_index).strip() != "": + try: + lines.append(f"目标指数:{float(target_index):g}") + except (TypeError, ValueError): + lines.append(f"目标指数:{target_index}") + if signal_note: + lines.append(f"备注:{signal_note[:200]}") + return "\n".join(lines) + + +def build_options_close_message( + *, + account_label: str, + inst_id: str, + reason: str = "", + underlying: str = "", + opt_type: Any = None, + sheets: Any = None, + premium_paid: Any = None, + premium_received: Any = None, + realized_pnl: Any = None, + close_quote: Any = None, + target_index: Any = None, + trigger_idx: Any = None, + trade_id: Any = None, +) -> str: + lines = [ + "【OKX期权·平仓】", + f"账户:{account_label or 'OKX期权'}", + ] + if trade_id is not None: + lines.append(f"本地单号:#{trade_id}") + lines.extend( + [ + f"合约:{inst_id}", + f"标的:{(underlying or '—')} · {_opt_type_label(opt_type)}", + f"原因:{(reason or '平仓').strip()}", + f"张数:{sheets if sheets is not None else '—'}", + f"平仓报价:{_fmt(close_quote)} USDC", + f"已付/收回:{_fmt(premium_paid)} / {_fmt(premium_received)} USDC", + f"实现盈亏:{_fmt(realized_pnl, 4)} USDC", + ] + ) + if target_index is not None and str(target_index).strip() != "": + try: + lines.append(f"目标指数:{float(target_index):g}") + except (TypeError, ValueError): + lines.append(f"目标指数:{target_index}") + if trigger_idx is not None and str(trigger_idx).strip() != "": + try: + lines.append(f"触发指数:{float(trigger_idx):g}") + except (TypeError, ValueError): + lines.append(f"触发指数:{trigger_idx}") + return "\n".join(lines) + + +def notify_options_open( + cfg: dict[str, Any], + conn: sqlite3.Connection | None, + *, + trade_id: int | None, + inst_id: str, + underlying: str = "", + opt_type: Any = None, + sheets: Any = None, + premium_paid: Any = None, + open_quote: Any = None, + target_index: Any = None, + signal_note: str = "", +) -> bool: + ensure_options_notify_columns(conn) if conn is not None else None + if conn is not None and trade_id is not None: + row = conn.execute( + "SELECT wechat_open_sent FROM options_trades WHERE id=?", + (int(trade_id),), + ).fetchone() + if row and int(row["wechat_open_sent"] or 0): + return False + msg = build_options_open_message( + account_label=str(cfg.get("account_label") or "OKX期权"), + inst_id=inst_id, + underlying=underlying, + opt_type=opt_type, + sheets=sheets, + premium_paid=premium_paid, + open_quote=open_quote, + target_index=target_index, + signal_note=signal_note, + trade_id=trade_id, + ) + ok = notify_options_send(cfg, msg) + if ok and conn is not None and trade_id is not None: + conn.execute( + "UPDATE options_trades SET wechat_open_sent=1 WHERE id=?", + (int(trade_id),), + ) + try: + conn.commit() + except Exception: + pass + return ok + + +def _load_trade_row(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] | None: + row = conn.execute("SELECT * FROM options_trades WHERE id=?", (int(trade_id),)).fetchone() + return dict(row) if row else None + + +def notify_options_close( + cfg: dict[str, Any], + conn: sqlite3.Connection | None, + *, + inst_id: str, + reason: str = "平仓", + trade_id: int | None = None, + underlying: str = "", + opt_type: Any = None, + sheets: Any = None, + premium_paid: Any = None, + premium_received: Any = None, + realized_pnl: Any = None, + close_quote: Any = None, + target_index: Any = None, + trigger_idx: Any = None, + force: bool = False, +) -> bool: + """平仓必发.默认按 trade_id / 同合约未标记行幂等.""" + if conn is not None: + ensure_options_notify_columns(conn) + rows: list[dict[str, Any]] = [] + if conn is not None and trade_id is not None: + r = _load_trade_row(conn, int(trade_id)) + if r: + rows = [r] + elif conn is not None and inst_id: + q = conn.execute( + """ + SELECT * FROM options_trades + WHERE inst_id=? AND status='closed' + AND COALESCE(wechat_close_sent,0)=0 + ORDER BY id DESC + LIMIT 20 + """, + (inst_id,), + ).fetchall() + rows = [dict(x) for x in q] + if not rows and force: + q2 = conn.execute( + """ + SELECT * FROM options_trades + WHERE inst_id=? AND status='closed' + ORDER BY id DESC LIMIT 1 + """, + (inst_id,), + ).fetchone() + if q2: + rows = [dict(q2)] + + if rows: + # 同次平仓可能多腿:合并一条推送,逐条标记 + total_paid = sum(float(r.get("premium_paid") or 0) for r in rows) + total_recv = sum(float(r.get("premium_received") or 0) for r in rows if r.get("premium_received") is not None) + pnls = [float(r["realized_pnl"]) for r in rows if r.get("realized_pnl") is not None] + total_pnl = sum(pnls) if pnls else None + if total_pnl is None and (premium_received is not None or realized_pnl is not None): + total_pnl = realized_pnl + total_recv = premium_received if premium_received is not None else total_recv + total_paid = premium_paid if premium_paid is not None else total_paid + head = rows[0] + pending = [r for r in rows if not int(r.get("wechat_close_sent") or 0)] + if not pending and not force: + return False + msg = build_options_close_message( + account_label=str(cfg.get("account_label") or "OKX期权"), + inst_id=inst_id or str(head.get("inst_id") or ""), + reason=reason, + underlying=underlying or str(head.get("underlying") or ""), + opt_type=opt_type or head.get("opt_type"), + sheets=sheets if sheets is not None else sum(int(r.get("sheets") or 0) for r in rows), + premium_paid=total_paid, + premium_received=total_recv if rows else premium_received, + realized_pnl=total_pnl, + close_quote=close_quote if close_quote is not None else head.get("close_quote"), + target_index=target_index, + trigger_idx=trigger_idx, + trade_id=head.get("id") if len(rows) == 1 else None, + ) + ok = notify_options_send(cfg, msg) + if ok and conn is not None: + for r in pending or rows: + conn.execute( + "UPDATE options_trades SET wechat_close_sent=1 WHERE id=?", + (int(r["id"]),), + ) + try: + conn.commit() + except Exception: + pass + return ok + + # 无库行时仍发一条(尽量不丢提醒) + msg = build_options_close_message( + account_label=str(cfg.get("account_label") or "OKX期权"), + inst_id=inst_id, + reason=reason, + underlying=underlying, + opt_type=opt_type, + sheets=sheets, + premium_paid=premium_paid, + premium_received=premium_received, + realized_pnl=realized_pnl, + close_quote=close_quote, + target_index=target_index, + trigger_idx=trigger_idx, + trade_id=trade_id, + ) + return notify_options_send(cfg, msg) + + +def notify_options_close_trade_ids( + cfg: dict[str, Any], + conn: sqlite3.Connection, + trade_ids: list[int], + *, + reason: str, +) -> bool: + ids = [int(x) for x in trade_ids if x is not None] + if not ids: + return False + ensure_options_notify_columns(conn) + placeholders = ",".join("?" for _ in ids) + rows = conn.execute( + f""" + SELECT * FROM options_trades + WHERE id IN ({placeholders}) AND COALESCE(wechat_close_sent,0)=0 + """, + ids, + ).fetchall() + if not rows: + return False + first = dict(rows[0]) + return notify_options_close( + cfg, + conn, + inst_id=str(first.get("inst_id") or ""), + reason=reason, + trade_id=int(first["id"]) if len(rows) == 1 else None, + underlying=str(first.get("underlying") or ""), + opt_type=first.get("opt_type"), + sheets=sum(int(r["sheets"] or 0) for r in rows), + premium_paid=sum(float(r["premium_paid"] or 0) for r in rows), + premium_received=sum(float(r["premium_received"] or 0) for r in rows if r["premium_received"] is not None), + realized_pnl=sum(float(r["realized_pnl"]) for r in rows if r["realized_pnl"] is not None), + close_quote=first.get("close_quote"), + ) diff --git a/lib/options/options_pending_lib.py b/lib/options/options_pending_lib.py new file mode 100644 index 0000000..392df02 --- /dev/null +++ b/lib/options/options_pending_lib.py @@ -0,0 +1,124 @@ +"""期权限价挂单:展示 enrichment + 超时自动撤单.""" +from __future__ import annotations + +import time +from typing import Any + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def order_age_seconds(order: dict[str, Any], *, now_ms: float | None = None) -> float | None: + """根据交易所 cTime(ms) 估算挂单时长(秒).""" + ct = _safe_float(order.get("c_time") or order.get("cTime")) + if ct is None or ct <= 0: + return None + # OKX 一般为毫秒时间戳 + if ct < 1e12: + ct *= 1000.0 + now = float(now_ms if now_ms is not None else time.time() * 1000.0) + age = (now - ct) / 1000.0 + return age if age >= 0 else 0.0 + + +def is_close_pending_order(order: dict[str, Any]) -> bool: + """平仓向限价挂单:卖出 / reduceOnly.""" + side = str(order.get("side") or "").lower() + if side == "sell": + return True + return bool(order.get("reduce_only")) + + +def enrich_pending_orders( + orders: list[dict[str, Any]] | None, + *, + ttl_seconds: float = 600.0, + now_ms: float | None = None, +) -> list[dict[str, Any]]: + """为 UI 附加挂单时长与自动撤倒计时.""" + ttl = max(0.0, float(ttl_seconds or 0)) + now = float(now_ms if now_ms is not None else time.time() * 1000.0) + out: list[dict[str, Any]] = [] + for raw in orders or []: + o = dict(raw) + age = order_age_seconds(o, now_ms=now) + is_close = is_close_pending_order(o) + o["age_sec"] = round(age, 1) if age is not None else None + o["is_close_order"] = is_close + o["auto_cancel_enabled"] = bool(is_close and ttl > 0) + if age is not None and is_close and ttl > 0: + remain = max(0.0, ttl - age) + o["ttl_seconds"] = ttl + o["expire_in_sec"] = round(remain, 1) + o["stale"] = remain <= 0 + else: + o["ttl_seconds"] = ttl if is_close else None + o["expire_in_sec"] = None + o["stale"] = False + out.append(o) + return out + + +def cancel_stale_close_pending_orders( + *, + fetch_pending: Any, + cancel_order: Any, + ttl_seconds: float = 600.0, + now_ms: float | None = None, + ex: Any = None, +) -> dict[str, Any]: + """ + 平仓限价挂单超过 ttl 自动撤销. + fetch_pending(ex) -> list; cancel_order(ex, inst_id=..., ord_id=...). + """ + ttl = float(ttl_seconds or 0) + if ttl <= 0: + return {"ok": True, "cancelled": 0, "checked": 0, "skipped": "ttl_disabled"} + try: + orders = fetch_pending(ex) if ex is not None else fetch_pending() + except TypeError: + orders = fetch_pending(ex) + except Exception as e: + return {"ok": False, "msg": str(e), "cancelled": 0, "checked": 0} + enriched = enrich_pending_orders(orders or [], ttl_seconds=ttl, now_ms=now_ms) + cancelled: list[dict[str, Any]] = [] + errors: list[str] = [] + checked = 0 + for o in enriched: + if not o.get("is_close_order"): + continue + checked += 1 + if not o.get("stale"): + continue + inst = str(o.get("inst_id") or "").strip() + oid = str(o.get("ord_id") or "").strip() + if not inst or not oid: + continue + try: + if ex is not None: + res = cancel_order(ex, inst_id=inst, ord_id=oid) + else: + res = cancel_order(inst_id=inst, ord_id=oid) + except TypeError: + res = cancel_order(ex, inst_id=inst, ord_id=oid) + except Exception as e: + errors.append(f"{oid}:{e}") + continue + if res.get("ok"): + cancelled.append({"inst_id": inst, "ord_id": oid, "age_sec": o.get("age_sec")}) + else: + errors.append(f"{oid}:{res.get('msg') or 'cancel_failed'}") + return { + "ok": True, + "cancelled": len(cancelled), + "checked": checked, + "orders": cancelled, + "errors": errors, + "ttl_seconds": ttl, + } diff --git a/lib/options/options_position_limit_lib.py b/lib/options/options_position_limit_lib.py new file mode 100644 index 0000000..4ceac4c --- /dev/null +++ b/lib/options/options_position_limit_lib.py @@ -0,0 +1,144 @@ +"""OKX 期权持仓笔数上限(env: OKX_OPTIONS_MAX_ACTIVE_POSITIONS).""" +from __future__ import annotations + +import os +from typing import Any, Optional, Sequence + + +def options_max_active_positions() -> int: + """同时持有的期权合约笔数上限;0=不限制.热更读 env.""" + raw = os.getenv("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "0") + try: + v = int(float(str(raw).strip())) + except (TypeError, ValueError): + return 0 + return max(0, v) + + +def count_live_option_positions(rows: Optional[list[dict[str, Any]]]) -> int: + if not rows: + return 0 + n = 0 + for r in rows: + if not isinstance(r, dict): + continue + try: + pos = float(r.get("pos") or 0) + except (TypeError, ValueError): + continue + if abs(pos) >= 1e-12: + n += 1 + return n + + +def _inst_already_open(rows: list[dict[str, Any]], inst_id: str) -> bool: + want = (inst_id or "").strip() + if not want: + return False + for r in rows: + if str(r.get("instId") or r.get("inst_id") or "").strip() == want: + return True + return False + + +def _normalize_inst_ids( + opening_inst_id: str = "", + opening_inst_ids: Optional[Sequence[str]] = None, +) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for raw in list(opening_inst_ids or []) + ([opening_inst_id] if opening_inst_id else []): + iid = str(raw or "").strip() + if not iid or iid in seen: + continue + seen.add(iid) + out.append(iid) + return out + + +def option_position_limit_block_msg( + ex: Any, + *, + opening_inst_id: str = "", + opening_inst_ids: Optional[Sequence[str]] = None, + new_positions: Optional[int] = None, + max_active: Optional[int] = None, + fetch_positions=None, +) -> Optional[str]: + """若禁止新开买期权则返回中文原因,否则 None. + + - max_active<=0:不限制 + - opening_inst_ids:本次要开的合约;已在持仓中的不占新笔数 + - new_positions:显式指定还需新占几笔(默认按 opening_inst_ids 推算) + - 期期两腿应一次传入两个 inst_id,在开仓前预检,避免上限=1 时开出半边仓 + - 拉持仓失败:拒绝开仓(避免绕过上限) + """ + try: + from lib.hedge_plan.okx_trade_mode_lib import standalone_options_open_allowed + + # 对冲模式用 MAX_ACTIVE_HEDGE_PLANS 管「组数」,不占用期权笔数上限 + if max_active is None and not standalone_options_open_allowed(): + return None + except Exception: + pass + mx = options_max_active_positions() if max_active is None else int(max_active) + if mx <= 0: + return None + fetch = fetch_positions + if fetch is None: + from lib.exchange.okx_options_lib import fetch_option_positions + + fetch = fetch_option_positions + try: + rows = fetch(ex) + except Exception: + rows = None + if rows is None: + return f"无法获取期权持仓,暂不可开仓(上限 {mx} 笔)" + active = count_live_option_positions(rows) + ids = _normalize_inst_ids(opening_inst_id, opening_inst_ids) + + if new_positions is None: + if ids: + already = sum(1 for i in ids if _inst_already_open(rows, i)) + need = max(0, len(ids) - already) + else: + need = 1 + else: + need = max(0, int(new_positions)) + if need <= 1 and len(ids) == 1 and _inst_already_open(rows, ids[0]): + return None + + if need <= 0: + return None + if active + need <= mx: + return None + if need >= 2: + return ( + f"期期对冲需新开 {need} 笔期权,当前已有 {active} 笔、上限 {mx};" + f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓" + ) + return f"期权持仓已达上限({active}/{mx}),请先平仓后再开" + + +def compound_full_single_position_block_msg( + ex: Any, + *, + fetch_positions=None, +) -> Optional[str]: + """全仓复利:账户内已有任意期权持仓则禁止再开(仅允许 1 笔).""" + fetch = fetch_positions + if fetch is None: + from lib.exchange.okx_options_lib import fetch_option_positions + + fetch = fetch_option_positions + try: + rows = fetch(ex) + except Exception: + rows = None + if rows is None: + return "无法获取期权持仓,全仓复利模式暂不可开仓" + active = count_live_option_positions(rows) + if active >= 1: + return f"全仓复利模式仅允许同时持有 1 笔仓位(当前 {active} 笔),请先平仓" + return None diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py new file mode 100644 index 0000000..a917f2c --- /dev/null +++ b/lib/options/options_positions_lib.py @@ -0,0 +1,169 @@ +"""期权持仓展示(实例页 / 中控快照共用).""" +from __future__ import annotations + +from typing import Any + +from lib.options.options_db import init_options_tables, sum_open_premium_paid +from lib.options.options_history_lib import enrich_position_row_display +from lib.options.options_close_gate_lib import clear_close_gate, is_close_gate_passed, update_close_gate +from lib.options.options_pricing_lib import estimate_close_by_bids, intrinsic_px_per_unit + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def attach_close_preview( + cfg: dict[str, Any], + ex: Any, + row: dict[str, Any], + *, + sheets: int | None = None, + premium_paid: float | None = None, +) -> dict[str, Any]: + inst_id = str(row.get("inst_id") or row.get("instId") or "").strip() + if not inst_id: + return row + ct_mult = float(row.get("ct_mult") or 0.01) + target_sheets = int(sheets) if sheets is not None else int(abs(_safe_float(row.get("pos")) or 0)) + paid = premium_paid if premium_paid is not None else _safe_float(row.get("premium_paid")) + book = cfg["fetch_option_book_depth"](ex, inst_id, 5) + row["bid_depth"] = book.get("bids") or [] + row["ask_depth"] = book.get("asks") or [] + mark_px = _safe_float(row.get("mark_px") or row.get("markPx")) + intrinsic = intrinsic_px_per_unit( + row.get("opt_type") or row.get("optType"), + _safe_float(row.get("strike") or row.get("stk")), + _safe_float(row.get("idx_px") or row.get("idxPx")), + ) + # 与实盘一致:只按买一估算本轮可平 + preview = estimate_close_by_bids( + row["bid_depth"], + target_sheets, + ct_mult=ct_mult, + premium_paid=paid, + mark_px=mark_px, + intrinsic_px=intrinsic, + max_levels=1, + ) + # 残档时不累计 2×门控;有效买一时刷新计时(仅自动平仓需要) + if preview.get("bid_invalid") or preview.get("auto_close_blocked"): + gate = update_close_gate(inst_id, recycle_usdc=None, premium_paid=paid) + preview["close_gate"] = gate + preview["close_gate_blocked"] = True + preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg") + preview["manual_close_blocked"] = True + preview["liquidity_ok"] = False + else: + gate = update_close_gate( + inst_id, + recycle_usdc=_safe_float(preview.get("total_received")), + premium_paid=paid, + ) + passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready")) + preview["close_gate"] = gate + preview["close_gate_blocked"] = not passed + preview["close_gate_msg"] = gate.get("msg") + preview["manual_close_blocked"] = False + preview["liquidity_ok"] = True + if not passed: + preview["auto_close_blocked"] = True + row["close_preview"] = preview + return row + + +def forget_close_gate_for_inst(inst_id: str) -> None: + clear_close_gate(inst_id) + + +def net_pnl_from_display_row(row: dict[str, Any]) -> float | None: + """与持仓卡「净盈亏」同口径:买一可回收 − 权利金;残档买一则无净值.""" + preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {} + if preview.get("bid_invalid"): + return None + net = preview.get("estimated_pnl") + if net is not None: + try: + return float(net) + except (TypeError, ValueError): + pass + # 仅当实际吃到买盘张数时,才用 total_received − 权利金(避免 bid 无效时 total_received=0 算出 −权利金假亏) + try: + covered = float(preview.get("covered_sheets") or 0) + except (TypeError, ValueError): + covered = 0.0 + recv = _safe_float(preview.get("total_received")) + paid = _safe_float(row.get("premium_paid")) + if covered > 0 and recv is not None and paid is not None: + return round(recv - paid, 4) + return None + + +def display_pnl_from_option_row(row: dict[str, Any]) -> float | None: + """展示用盈亏:优先买一净盈亏;残档/无买一时回退交易所标记浮盈 upl.""" + net = net_pnl_from_display_row(row) + if net is not None: + return net + return _safe_float(row.get("upl")) + + +def sum_options_net_pnl_usdc( + cfg: dict[str, Any], + ex: Any, + raw_positions: list[dict[str, Any]] | None = None, +) -> float | None: + """ + 期权浮盈合计(USDC),与顶栏实时盈亏/中控口径对齐为「净盈亏」: + 各仓买一可回收 − 权利金之和;残档则回退该仓交易所 upl. + 获取失败返回 None;无持仓返回 0. + """ + raw = raw_positions + if raw is None: + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return None + if not raw: + return 0.0 + positions = build_display_option_positions(cfg, ex, raw) + total = 0.0 + found = False + for p in positions: + pnl = display_pnl_from_option_row(p) + if pnl is None: + continue + found = True + total += float(pnl) + return round(total, 4) if found else (0.0 if not positions else None) + + +def build_display_option_positions( + cfg: dict[str, Any], + ex: Any, + raw_positions: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """与实例 /api/options/positions 相同 enrichment + close_preview.""" + meta_cache: dict[str, dict[str, Any] | None] = {} + rows: list[dict[str, Any]] = [] + conn = cfg["get_db"]() + try: + init_options_tables(conn) + for p in raw_positions: + inst = str(p.get("instId") or "").strip() + premium_override = sum_open_premium_paid(conn, inst) if inst else None + row = enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) + rows.append(row) + finally: + conn.close() + return rows diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py new file mode 100644 index 0000000..d049e2a --- /dev/null +++ b/lib/options/options_pricing_lib.py @@ -0,0 +1,567 @@ +"""OKX USDⓈ 期权:张数与权利金计算.""" +from __future__ import annotations + +import math +from typing import Any + + +def ct_mult_from_meta(meta: dict[str, Any] | None) -> float: + if not meta: + return 0.01 + try: + return float(meta.get("ctMult") or 0.01) + except (TypeError, ValueError): + return 0.01 + + +def min_sz_from_meta(meta: dict[str, Any] | None) -> int: + if not meta: + return 1 + try: + return max(1, int(float(meta.get("minSz") or 1))) + except (TypeError, ValueError): + return 1 + + +def premium_per_sheet(quote_per_unit: float, ct_mult: float = 0.01) -> float: + """报价为每 1 ETH/BTC;每张权利金 = 报价 × ctMult.""" + return float(quote_per_unit) * float(ct_mult) + + +def format_quote_liquidity(px: float | None, sz: float | None, *, px_decimals: int = 4) -> str | None: + """盘口展示:价格/张数,如 17.2/150.""" + if px is None: + return None + try: + price = f"{float(px):.{px_decimals}f}".rstrip("0").rstrip(".") + except (TypeError, ValueError): + return None + if sz is None: + return price + try: + s = float(sz) + size = str(int(s)) if abs(s - int(s)) < 1e-9 else str(s).rstrip("0").rstrip(".") + except (TypeError, ValueError): + return price + return f"{price}/{size}" + + +def total_premium(quote_per_unit: float, eth_amount: float, ct_mult: float = 0.01) -> float: + return float(quote_per_unit) * float(eth_amount) + + +# 买一相对标记价/内在价值低于该比例 → 视为残档,禁止按买盘自动/多档平仓 +BID_CLOSE_MIN_RATIO = 0.3 + + +def _safe_px(v: Any) -> float | None: + if v is None or v == "": + return None + try: + x = float(v) + except (TypeError, ValueError): + return None + return x if x > 0 else None + + +def intrinsic_px_per_unit(opt_type: str | None, strike: float | None, index_px: float | None) -> float | None: + o = (opt_type or "").strip().upper() + if strike is None or index_px is None: + return None + try: + k = float(strike) + idx = float(index_px) + except (TypeError, ValueError): + return None + if o == "C" and idx > k: + return idx - k + if o == "P" and idx < k: + return k - idx + return None + + +def is_stub_bid_px( + bid_px: float | None, + *, + mark_px: float | None = None, + intrinsic_px: float | None = None, + min_ratio: float = BID_CLOSE_MIN_RATIO, +) -> tuple[bool, str]: + """ + 判断买一是否为无效残档(如标记 42、买一 0.2). + 返回 (is_stub, reason). + """ + bid = _safe_px(bid_px) + if bid is None: + return True, "无买一" + ref = _safe_px(mark_px) + ref_name = "标记价" + intrinsic = _safe_px(intrinsic_px) + if intrinsic is not None and (ref is None or intrinsic > ref): + ref = intrinsic + ref_name = "内在价值" + if ref is None: + return False, "" + ratio = float(min_ratio) if min_ratio and min_ratio > 0 else BID_CLOSE_MIN_RATIO + if bid < ref * ratio: + return True, f"买一{bid:g}远低于{ref_name}{ref:g},属无效残档,禁止按买盘自动平仓" + return False, "" + + +def fetch_option_mark_px(ex: Any, inst_id: str) -> float | None: + """优先 mark-price 接口,失败则 None.""" + inst_id = (inst_id or "").strip() + if not inst_id or ex is None: + return None + try: + rows = ex.public_get_public_mark_price({"instType": "OPTION", "instId": inst_id}).get("data") or [] + if rows: + return _safe_px(rows[0].get("markPx")) + except Exception: + pass + return None + + +def close_ref_prices( + *, + mark_px: float | None = None, + opt_type: str | None = None, + strike: float | None = None, + index_px: float | None = None, +) -> tuple[float | None, float | None]: + """返回 (mark_px, intrinsic_px) 供残档判断.""" + return _safe_px(mark_px), intrinsic_px_per_unit(opt_type, strike, index_px) + + +def filter_bids_for_close( + bids: list[dict[str, Any]] | None, + *, + mark_px: float | None = None, + intrinsic_px: float | None = None, + min_ratio: float = BID_CLOSE_MIN_RATIO, +) -> tuple[list[dict[str, Any]], bool, str]: + """过滤不可用于平仓的残档买盘.返回 (usable_bids, had_stub_only, reason).""" + raw = list(bids or []) + usable: list[dict[str, Any]] = [] + stub_reason = "" + for level in raw: + px = _safe_px(level.get("px") if isinstance(level, dict) else None) + stub, reason = is_stub_bid_px(px, mark_px=mark_px, intrinsic_px=intrinsic_px, min_ratio=min_ratio) + if stub: + if not stub_reason: + stub_reason = reason or "买一无效" + continue + usable.append(level) + if raw and not usable: + return [], True, stub_reason or "暂无有效买盘" + return usable, False, "" + + +def estimate_close_by_bids( + bids: list[dict[str, Any]] | None, + sheets: int | float, + *, + ct_mult: float = 0.01, + premium_paid: float | None = None, + mark_px: float | None = None, + intrinsic_px: float | None = None, + min_bid_ratio: float = BID_CLOSE_MIN_RATIO, + max_levels: int = 1, +) -> dict[str, Any]: + """按买盘估算限价卖出可收回金额;默认只估算买一(与实盘平仓一致);残档不参与.""" + target = max(0, int(float(sheets or 0))) + remaining = target + total_received = 0.0 + levels: list[dict[str, Any]] = [] + max_lv = max(1, int(max_levels or 1)) + empty = { + "levels": [], + "covered_sheets": 0, + "uncovered_sheets": target, + "total_received": 0.0, + "avg_px": None, + "estimated_pnl": None, + "estimated_pnl_ratio_pct": None, + "bid_invalid": False, + "bid_invalid_reason": None, + "auto_close_blocked": False, + "max_levels": max_lv, + } + if target <= 0 or ct_mult <= 0: + return empty + usable, stub_only, stub_reason = filter_bids_for_close( + bids, mark_px=mark_px, intrinsic_px=intrinsic_px, min_ratio=min_bid_ratio + ) + if stub_only: + out = dict(empty) + out["bid_invalid"] = True + out["bid_invalid_reason"] = stub_reason + out["auto_close_blocked"] = True + out["raw_bid_px"] = _safe_px((bids or [{}])[0].get("px")) if bids else None + return out + for i, level in enumerate(usable[:max_lv], start=1): + if remaining <= 0: + break + try: + px = float(level.get("px")) + sz = int(float(level.get("sz"))) + except (AttributeError, TypeError, ValueError): + continue + if px <= 0 or sz <= 0: + continue + take = min(remaining, sz) + eth_amount = eth_amount_from_sheets(take, ct_mult) + received = total_premium(px, eth_amount) + levels.append( + { + "level": i, + "px": px, + "available_sheets": sz, + "sheets": take, + "eth_amount": eth_amount, + "received": round(received, 4), + } + ) + total_received += received + remaining -= take + covered = target - remaining + avg_px = (total_received / eth_amount_from_sheets(covered, ct_mult)) if covered > 0 else None + # 净盈亏 = 本轮买盘可回收 − 全部权利金(买一不够时剩余张数计入 uncovered) + estimated_pnl = None + estimated_pnl_ratio_pct = None + if premium_paid is not None and covered > 0: + paid = float(premium_paid) + estimated_pnl = round(total_received - paid, 4) + if paid > 0: + estimated_pnl_ratio_pct = round(estimated_pnl / paid * 100.0, 2) + return { + "levels": levels, + "covered_sheets": covered, + "uncovered_sheets": remaining, + "total_received": round(total_received, 4), + "avg_px": round(avg_px, 4) if avg_px is not None else None, + "estimated_pnl": estimated_pnl, + "estimated_pnl_ratio_pct": estimated_pnl_ratio_pct, + "bid_invalid": False, + "bid_invalid_reason": None, + "auto_close_blocked": False, + "max_levels": max_lv, + } + + +def sheets_from_eth_amount(eth_amount: float, ct_mult: float = 0.01) -> int: + if eth_amount <= 0 or ct_mult <= 0: + return 0 + return int(math.floor(eth_amount / ct_mult + 1e-12)) + + +def eth_amount_from_sheets(sheets: int, ct_mult: float = 0.01) -> float: + return round(int(sheets) * float(ct_mult), 8) + + +def resolve_budget_full_usdc(trading_usdc: float, trade_budget_usdc: float) -> float: + """按可用余额打满:余额大于预算用预算,否则用余额.""" + return min(float(trading_usdc), float(trade_budget_usdc)) + + +def resolve_compound_full_usdc( + trading_usdc: float, + *, + cap_enabled: bool = False, + cap_usdc: float | None = None, +) -> float: + """全仓复利:默认用期权交易户全部可用;上限开关开启时再封顶.""" + bal = max(0.0, float(trading_usdc or 0)) + if not cap_enabled: + return bal + try: + cap = float(cap_usdc) if cap_usdc is not None else 0.0 + except (TypeError, ValueError): + cap = 0.0 + if cap <= 0: + return bal + return min(bal, cap) + + +def calc_order_size( + *, + quote_per_unit: float, + ct_mult: float, + min_sz: int, + budget_usdc: float | None = None, + budget_buffer: float = 0.95, + eth_amount: float | None = None, + sheets: int | None = None, + budget_cap: float | None = None, +) -> dict[str, Any]: + """ + 返回 sheets, eth_amount, total_premium. + mode: budget_full / eth_amount / sheets. + """ + if quote_per_unit <= 0: + return {"ok": False, "msg": "卖一价无效", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0} + + if sheets is not None and int(sheets) > 0: + sheets = int(sheets) + elif eth_amount is not None and eth_amount > 0: + sheets = sheets_from_eth_amount(eth_amount, ct_mult) + elif budget_usdc is not None and budget_usdc > 0: + eff = float(budget_usdc) * float(budget_buffer) + per_sheet = premium_per_sheet(quote_per_unit, ct_mult) + if per_sheet <= 0: + return {"ok": False, "msg": "无法计算单张权利金", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0} + sheets = int(math.floor(eff / per_sheet)) + else: + return {"ok": False, "msg": "请指定预算,币数量或张数", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0} + + if sheets < min_sz: + per = premium_per_sheet(quote_per_unit, ct_mult) + return { + "ok": False, + "msg": f"预算不足,无法买入 {min_sz} 张(单张约 {per:.4f} USDC)", + "sheets": sheets, + "eth_amount": eth_amount_from_sheets(sheets, ct_mult), + "total_premium": total_premium(quote_per_unit, eth_amount_from_sheets(sheets, ct_mult)), + } + + eth = eth_amount_from_sheets(sheets, ct_mult) + prem = total_premium(quote_per_unit, eth) + if budget_cap is not None and prem > float(budget_cap) + 1e-9: + return { + "ok": False, + "msg": f"权利金 {prem:.4f} 超过单笔上限 {budget_cap} USDC", + "sheets": sheets, + "eth_amount": eth, + "total_premium": prem, + } + return {"ok": True, "msg": "", "sheets": sheets, "eth_amount": eth, "total_premium": prem} + + +def is_shallow_itm( + *, + opt_type: str, + strike: float, + index_px: float, + max_dist_usd: float, +) -> bool: + o = (opt_type or "").upper() + if o == "C": + if strike >= index_px: + return False + return (index_px - strike) <= max_dist_usd + if o == "P": + if strike <= index_px: + return False + return (strike - index_px) <= max_dist_usd + return False + + +def option_moneyness(*, opt_type: str, strike: float, index_px: float) -> str: + """返回 itm / otm / atm.""" + o = (opt_type or "").upper() + if strike is None or index_px is None or index_px <= 0: + return "unknown" + atm_band = max(index_px * 0.002, 2.0) + if abs(strike - index_px) <= atm_band: + return "atm" + if o == "C": + return "itm" if strike < index_px else "otm" + if o == "P": + return "itm" if strike > index_px else "otm" + return "unknown" + + +def option_moneyness_label(moneyness: str) -> str: + return {"itm": "实值", "otm": "虚值", "atm": "平值"}.get((moneyness or "").lower(), "") + + +def expiry_breakeven_from_ask( + *, + opt_type: str, + strike: float | None, + ask_px: float | None, + mark_px: float | None = None, +) -> float | None: + """买入前预估到期平衡:权利金按卖一;无卖一时回退标记价.""" + prem = ask_px if ask_px is not None and ask_px > 0 else mark_px + return expiry_breakeven_px(opt_type=opt_type, strike=strike, avg_px=prem) + + +def expiry_breakeven_px( + *, + opt_type: str, + strike: float | None, + avg_px: float | None, + be_px_api: float | None = None, +) -> float | None: + """到期平衡点:持有至到期时标的指数盈亏为 0 的价格.优先 OKX bePx.""" + if be_px_api is not None and be_px_api > 0: + return round(float(be_px_api), 2) + if strike is None or avg_px is None: + return None + o = (opt_type or "").upper() + if o == "C": + return round(strike + avg_px, 2) + if o == "P": + return round(strike - avg_px, 2) + return None + + +def close_breakeven_idx( + *, + opt_type: str, + idx_px: float | None, + mark_px: float | None, + avg_px: float | None, + delta_pa: float | None = None, + pos: float = 0, + ct_mult: float = 0.01, +) -> float | None: + """ + 平掉回本:标的指数达到该价位时,按标记价平仓近似盈亏为 0. + 优先用 deltaPA 线性外推,否则用时间价值近似(适合短期轻度实值). + """ + if idx_px is None or mark_px is None or avg_px is None: + return None + eth_amt = abs(float(pos)) * float(ct_mult) + if eth_amt > 1e-12 and delta_pa is not None and abs(float(delta_pa)) > 1e-12: + slope = float(delta_pa) / eth_amt + return round(float(idx_px) + (float(avg_px) - float(mark_px)) / slope, 2) + o = (opt_type or "").upper() + if o == "C": + return round(float(idx_px) + float(avg_px) - float(mark_px), 2) + if o == "P": + return round(float(idx_px) + float(mark_px) - float(avg_px), 2) + return None + + +def idx_distance_to_be(idx_px: float | None, be_px: float | None) -> float | None: + """指数距平衡点(正=指数需上涨才到平衡点).""" + if idx_px is None or be_px is None: + return None + return round(float(be_px) - float(idx_px), 2) + + +def format_options_breakeven_line( + *, + expiry_be_px: float | None, + close_be_px: float | None, + idx_px: float | None = None, +) -> str: + """持仓摘要行:到期平衡 / 平掉回本.""" + parts: list[str] = [] + if expiry_be_px is not None: + parts.append(f"到期平衡{expiry_be_px:.0f}") + if close_be_px is not None: + parts.append(f"平掉回本{close_be_px:.0f}") + if idx_px is not None and parts: + return " ".join(parts) + f"(指数{idx_px:.0f})" + return " ".join(parts) + + +def estimate_expiry_value_at_index( + *, + opt_type: str, + strike: float | None, + target_idx: float | None, + eth_amount: float | None, +) -> float | None: + """到期测算:目标指数价下期权内在价值总额(不含已付权利金).""" + if strike is None or target_idx is None or eth_amount is None: + return None + if eth_amount <= 0: + return None + o = (opt_type or "").upper() + if o == "C": + intrinsic = max(0.0, float(target_idx) - float(strike)) + elif o == "P": + intrinsic = max(0.0, float(strike) - float(target_idx)) + else: + return None + return round(intrinsic * float(eth_amount), 2) + + +def estimate_expiry_profit_at_index( + *, + opt_type: str, + strike: float | None, + target_idx: float | None, + entry_px: float | None, + eth_amount: float | None, + total_premium: float | None = None, +) -> float | None: + """到期测算:目标指数价下净盈利 = 预计价值 − 权利金.""" + value = estimate_expiry_value_at_index( + opt_type=opt_type, + strike=strike, + target_idx=target_idx, + eth_amount=eth_amount, + ) + if value is None: + return None + prem = total_premium + if prem is None and entry_px is not None and eth_amount is not None: + prem = float(entry_px) * float(eth_amount) + if prem is None: + return None + return round(float(value) - float(prem), 2) + + +def equivalent_contract_leverage( + *, + index_px: float | None, + eth_amount: float | None, + total_premium: float | None, +) -> float | None: + """名义价值 / 权利金,近似相当于永续合约杠杆倍数(测算用).""" + if index_px is None or eth_amount is None or total_premium is None: + return None + if eth_amount <= 0 or total_premium <= 0: + return None + return round(float(index_px) * float(eth_amount) / float(total_premium), 1) + + +def straddle_ask_per_unit( + call_ask: float | None, + put_ask: float | None, +) -> float | None: + """跨式双买:每 1 标的币的卖一报价之和.""" + if call_ask is None or put_ask is None: + return None + if float(call_ask) <= 0 or float(put_ask) <= 0: + return None + return round(float(call_ask) + float(put_ask), 4) + + +def straddle_premium_total( + call_ask: float | None, + put_ask: float | None, + eth_amount: float | None, +) -> float | None: + """跨式双买权利金总额(USDC).""" + per = straddle_ask_per_unit(call_ask, put_ask) + if per is None or eth_amount is None or float(eth_amount) <= 0: + return None + return round(per * float(eth_amount), 2) + + +def straddle_breakeven_band( + strike: float | None, + combined_ask_per_unit: float | None, +) -> tuple[float | None, float | None]: + """跨式到期平衡带:下平衡 ~ 上平衡(按双卖一报价和).""" + if strike is None or combined_ask_per_unit is None: + return None, None + k = float(strike) + d = float(combined_ask_per_unit) + return round(k - d, 2), round(k + d, 2) + + +def format_straddle_band( + strike: float | None, + combined_ask_per_unit: float | None, +) -> str: + lo, hi = straddle_breakeven_band(strike, combined_ask_per_unit) + if lo is None or hi is None: + return "" + return f"{lo:.0f} ~ {hi:.0f}" diff --git a/lib/options/options_profit_exit_lib.py b/lib/options/options_profit_exit_lib.py new file mode 100644 index 0000000..c71c986 --- /dev/null +++ b/lib/options/options_profit_exit_lib.py @@ -0,0 +1,377 @@ +"""单独期权翻倍出场:盈利达权利金×倍数后按买一限价平仓. + +1 倍 = 盈利金额等于初始权利金 ⇒ 买一可回收 ≥ 权利金 × (1 + 倍数). +与「目标位」并行;与仅微信提醒的 OKX_OPTIONS_PROFIT_ALERT_RATIO 独立. +""" +from __future__ import annotations + +import sqlite3 +from typing import Any, Callable + +from lib.options.options_db import init_options_tables, sum_open_premium_paid + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def ensure_profit_exit_columns(conn: sqlite3.Connection) -> None: + init_options_tables(conn) + for ddl in ( + "ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0", + "ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0", + "ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'", + ): + try: + conn.execute(ddl) + except Exception: + pass + + +def normalize_profit_exit_mult(raw: Any, *, default: float = 1.0) -> float: + try: + mult = float(raw) + except (TypeError, ValueError): + mult = float(default) + if mult <= 0: + mult = float(default) + return round(mult, 4) + + +def profit_exit_hit( + *, + premium_paid: float, + recycle_usdc: float, + mult: float, +) -> bool: + """1倍:盈利=权利金 ⇒ recycle ≥ premium×(1+mult).""" + prem = float(premium_paid or 0) + recv = float(recycle_usdc or 0) + m = float(mult or 0) + if prem <= 0 or m <= 0 or recv <= 0: + return False + return recv + 1e-9 >= prem * (1.0 + m) + + +def required_recycle_usdc(premium_paid: float, mult: float) -> float | None: + prem = float(premium_paid or 0) + m = float(mult or 0) + if prem <= 0 or m <= 0: + return None + return round(prem * (1.0 + m), 4) + + +def set_profit_exit( + conn: sqlite3.Connection, + *, + inst_id: str, + enabled: bool, + mult: float | None = None, +) -> dict[str, Any]: + ensure_profit_exit_columns(conn) + inst = (inst_id or "").strip() + if not inst: + return {"ok": False, "msg": "缺少 inst_id"} + m = normalize_profit_exit_mult(mult if mult is not None else 1.0) + rows = conn.execute( + """ + SELECT id FROM options_trades + WHERE inst_id = ? AND status = 'open' + """, + (inst,), + ).fetchall() + if not rows: + return {"ok": False, "msg": "未找到该合约的本地开仓记录"} + if enabled: + conn.execute( + """ + UPDATE options_trades + SET profit_exit_enabled = 1, + profit_exit_mult = ?, + profit_exit_state = 'active' + WHERE inst_id = ? AND status = 'open' + """, + (m, inst), + ) + else: + conn.execute( + """ + UPDATE options_trades + SET profit_exit_enabled = 0, + profit_exit_state = 'idle' + WHERE inst_id = ? AND status = 'open' + """, + (inst,), + ) + return { + "ok": True, + "inst_id": inst, + "profit_exit_enabled": bool(enabled), + "profit_exit_mult": m if enabled else None, + "updated": len(rows), + } + + +def profit_exit_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: + """进行中(active/closing)的翻倍出场,按合约取最新一条规则.""" + ensure_profit_exit_columns(conn) + rows = conn.execute( + """ + SELECT inst_id, profit_exit_enabled, profit_exit_mult, profit_exit_state + FROM options_trades + WHERE status = 'open' + AND ( + CAST(COALESCE(profit_exit_enabled, 0) AS INTEGER) = 1 + OR COALESCE(profit_exit_state, 'idle') IN ('active', 'closing') + ) + ORDER BY id DESC + """ + ).fetchall() + out: dict[str, dict[str, Any]] = {} + for r in rows: + inst = str(r["inst_id"] or "").strip() + if not inst or inst in out: + continue + enabled = int(r["profit_exit_enabled"] or 0) == 1 + state = str(r["profit_exit_state"] or "idle") + if not enabled and state not in ("active", "closing"): + continue + mult = normalize_profit_exit_mult(r["profit_exit_mult"], default=1.0) + out[inst] = { + "inst_id": inst, + "profit_exit_enabled": enabled or state in ("active", "closing"), + "profit_exit_mult": mult, + "profit_exit_state": state if state in ("active", "closing") else ("active" if enabled else "idle"), + "required_recycle": None, + } + for inst, info in out.items(): + prem = sum_open_premium_paid(conn, inst) + if prem is not None: + info["premium_paid"] = prem + info["required_recycle"] = required_recycle_usdc(prem, float(info["profit_exit_mult"])) + return out + + +def _mark_state(conn: sqlite3.Connection, inst_id: str, state: str) -> None: + conn.execute( + """ + UPDATE options_trades + SET profit_exit_state = ? + WHERE inst_id = ? AND status = 'open' + """, + (state, inst_id), + ) + + +def _commit(conn: sqlite3.Connection) -> None: + try: + conn.commit() + except Exception: + pass + + +def _result_fully_done(result: dict[str, Any]) -> bool: + if result.get("already_flat"): + return True + if result.get("fully_closed"): + return True + remaining = result.get("remaining_sheets") + if remaining is not None and int(remaining) <= 0 and result.get("ok"): + return True + return False + + +def close_option_by_bid_profit_exit( + cfg: dict[str, Any], + ex: Any, + inst_id: str, + *, + sheets: int | None = None, +) -> dict[str, Any]: + from lib.options.options_close_exec_lib import close_option_by_bid1 + + return close_option_by_bid1( + cfg, + ex, + inst_id, + sheets=sheets, + require_recycle_gate=False, + signal_note="翻倍出场", + ) + + +def _estimate_recycle( + cfg: dict[str, Any], + ex: Any, + pos: dict[str, Any], + premium_paid: float | None, +) -> float | None: + from lib.options.options_positions_lib import attach_close_preview + + row = dict(pos) + attach_close_preview(cfg, ex, row, premium_paid=premium_paid) + preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {} + if preview.get("bid_invalid"): + return None + return _safe_float(preview.get("total_received")) + + +def _notify_profit_exit_close( + cfg: dict[str, Any] | None, + send_wechat: Callable[[str], None] | None, + *, + account_label: str, + inst_id: str, + mult: float, + premium_paid: float | None, + recycle: float | None, + result: dict[str, Any], + conn: Any = None, +) -> None: + if result.get("fully_closed") or result.get("already_flat"): + if cfg is not None: + try: + from lib.options.options_notify_lib import notify_options_close + + notify_options_close( + cfg, + conn, + inst_id=inst_id, + reason=f"翻倍出场({mult:g}倍)", + sheets=result.get("submitted_sheets"), + premium_received=result.get("premium_received"), + close_quote=result.get("locked_bid_px") or result.get("bid"), + ) + return + except Exception: + pass + if not send_wechat: + return + try: + send_wechat( + "\n".join( + [ + "【OKX期权·翻倍出场】", + f"账户:{account_label}", + f"合约:{inst_id}", + f"倍数:{mult:g}(1倍=盈利=权利金)", + f"权利金:{premium_paid if premium_paid is not None else '—'}", + f"可回收:{recycle if recycle is not None else '—'}", + f"提交张数:{result.get('submitted_sheets') or '—'}", + f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}", + ] + ) + ) + except Exception: + pass + + +def run_options_profit_exits( + conn: sqlite3.Connection, + positions: list[dict[str, Any]], + *, + close_fn: Callable[[str], dict[str, Any]], + recycle_fn: Callable[[dict[str, Any], float | None], float | None] | None = None, + send_wechat: Callable[[str], None] | None = None, + account_label: str = "OKX期权", + cfg: dict[str, Any] | None = None, + ex: Any = None, +) -> int: + """扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数.""" + ensure_profit_exit_columns(conn) + pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} + hedge_managed: set[str] = set() + try: + from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables + + init_hedge_plan_tables(conn) + hedge_managed = active_hedge_option_inst_ids(conn) + except Exception: + return 0 + + rules = profit_exit_by_inst(conn) + triggered = 0 + + for inst_id, info in list(rules.items()): + if not inst_id: + continue + if inst_id in hedge_managed: + _mark_state(conn, inst_id, "idle") + conn.execute( + """ + UPDATE options_trades + SET profit_exit_enabled = 0, profit_exit_state = 'idle' + WHERE inst_id = ? AND status = 'open' + """, + (inst_id,), + ) + _commit(conn) + continue + pos = pos_by_inst.get(inst_id) + if not pos: + # 持仓已平:收尾 + _mark_state(conn, inst_id, "done") + _commit(conn) + continue + + state = str(info.get("profit_exit_state") or "active") + mult = normalize_profit_exit_mult(info.get("profit_exit_mult"), default=1.0) + prem = sum_open_premium_paid(conn, inst_id) + if prem is None or prem <= 0: + continue + + if state == "closing": + result = close_fn(inst_id) + if result.get("already_flat") or _result_fully_done(result): + _mark_state(conn, inst_id, "done") + _commit(conn) + else: + _mark_state(conn, inst_id, "closing") + _commit(conn) + continue + + if not info.get("profit_exit_enabled"): + continue + + if recycle_fn is not None: + recycle = recycle_fn(pos, prem) + elif cfg is not None and ex is not None: + recycle = _estimate_recycle(cfg, ex, pos, prem) + else: + continue + if recycle is None: + continue + if not profit_exit_hit(premium_paid=prem, recycle_usdc=recycle, mult=mult): + continue + + result = close_fn(inst_id) + if result.get("already_flat"): + _mark_state(conn, inst_id, "done") + _commit(conn) + continue + if not result.get("ok"): + _mark_state(conn, inst_id, "active") + _commit(conn) + continue + + done = _result_fully_done(result) + _mark_state(conn, inst_id, "done" if done else "closing") + _commit(conn) + triggered += 1 + _notify_profit_exit_close( + cfg, + send_wechat, + account_label=account_label, + inst_id=inst_id, + mult=mult, + premium_paid=prem, + recycle=recycle, + result=result, + conn=conn, + ) + return triggered diff --git a/lib/options/options_register.py b/lib/options/options_register.py new file mode 100644 index 0000000..38571ab --- /dev/null +++ b/lib/options/options_register.py @@ -0,0 +1,1787 @@ +"""OKX 期权模块:Flask 路由注册.""" +from __future__ import annotations + +import os +import threading +import time +from typing import Any + +from flask import Flask, jsonify, redirect, request, url_for +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.options.options_db import init_options_tables, sum_open_premium_paid, sum_open_sheets +from lib.options.options_monitor_lib import options_monitor_loop +from lib.options.options_pricing_lib import ( + calc_order_size, + ct_mult_from_meta, + min_sz_from_meta, + premium_per_sheet, +) +from lib.exchange.okx_options_lib import ( + _safe_float, + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, + td_mode_for_option_buy, +) + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _env_float(key: str, default: float) -> float: + try: + return float(os.getenv(key, str(default))) + except (TypeError, ValueError): + return default + + +def attach_options_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "options", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None: + enabled = _env_bool("OKX_OPTIONS_ENABLED", False) + attach_options_templates(app, repo_root) + cfg = _build_cfg(app_module) + app.extensions["options_cfg"] = cfg + register_options_routes(app, cfg) + if enabled: + _start_monitor_thread(app, cfg) + + + +def _build_cfg(app_module: Any) -> dict[str, Any]: + from lib.exchange.okx_options_lib import ( + build_option_chain, + estimate_usdt_to_usdc, + execute_convert, + fetch_option_book_depth, + fetch_option_positions, + fetch_options_balances, + format_position_row, + options_api_ready, + cancel_option_order, + fetch_option_pending_orders, + place_option_limit_order, + place_option_market_order, + quote_option_contract, + spot_market_swap_usdt_usdc, + transfer_ccy, + ) + + cfg = { + "enabled": _env_bool("OKX_OPTIONS_ENABLED", False), + "get_db": app_module.get_db, + "login_required": app_module.login_required, + "exchange_options": getattr(app_module, "exchange_options", None), + "send_wechat": app_module.send_wechat_msg, + "render_main_page": app_module.render_main_page, + "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0), + "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95), + "compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True), + "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), + "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), + "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), + "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), + "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0), + "chain_ask_liq_filter": _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True), + "itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0), + "td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), + # 市价平仓已硬关闭(忽略 env),仅买一限价 + "allow_market_close": False, + # 平仓限价挂单超时自动撤单(秒);默认 600=10 分钟,联调可设 60 + "pending_ttl_seconds": _env_float("OKX_OPTIONS_PENDING_TTL_SECONDS", 600.0), + "profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0), + "poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0), + "account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(), + "build_option_chain": build_option_chain, + "quote_option_contract": quote_option_contract, + "fetch_option_book_depth": fetch_option_book_depth, + "place_option_limit_order": place_option_limit_order, + "place_option_market_order": place_option_market_order, + "fetch_option_pending_orders": fetch_option_pending_orders, + "cancel_option_order": cancel_option_order, + "fetch_option_positions": fetch_option_positions, + "fetch_options_balances": fetch_options_balances, + "format_position_row": format_position_row, + "estimate_usdt_to_usdc": estimate_usdt_to_usdc, + "execute_convert": execute_convert, + "transfer_ccy": transfer_ccy, + "spot_market_swap_usdt_usdc": spot_market_swap_usdt_usdc, + "options_api_ready": options_api_ready, + "app_module": app_module, + } + try: + from lib.sim.hooks import patch_options_cfg + + return patch_options_cfg(cfg) + except Exception: + return cfg + + +def _mark_balances_stale(cfg: dict[str, Any]) -> None: + from lib.exchange.okx_options_lib import invalidate_options_balance_cache + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + invalidate_options_balance_cache() + app_mod = cfg.get("app_module") + if app_mod is not None and hasattr(app_mod, "invalidate_account_balance_cache"): + app_mod.invalidate_account_balance_cache() + try: + notify_instance_balance_changed() + except Exception: + pass + + +def _require_options_ex(cfg: dict[str, Any]): + if not cfg.get("enabled"): + return None, "期权模块未启用,请在 .env 设置 OKX_OPTIONS_ENABLED=true 并重启 PM2" + ex = cfg.get("exchange_options") + ok, reason = cfg["options_api_ready"](ex) + if not ok: + return None, reason or "期权 API 未配置" + return ex, "" + + +def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: + """打满可用额度 = min(交易户可用 USDC, 单笔预算);calc_order_size 再乘 budget_buffer.""" + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + from lib.options.options_pricing_lib import resolve_budget_full_usdc + + raw = fetch_options_trading_usdc(ex) + if raw is None or float(raw) <= 0: + return None, "交易账户 USDC 可用余额不足" + trading = float(raw) + cap = _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10.0)) + if cap <= 0: + return None, "单笔预算无效(OKX_OPTIONS_TRADE_BUDGET_USDC)" + return resolve_budget_full_usdc(trading, float(cap)), "" + + +def _compound_full_enabled() -> bool: + return _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True) + + +def _budget_full_blocked_by_compound_msg() -> str | None: + if _compound_full_enabled(): + return "全仓复利已开启,不可使用单笔预算/打满;请关闭全仓复利或改用全仓复利模式" + return None + + +def _size_mode_budget_cap( + cfg: dict[str, Any], mode: str, budget_cap: float | None +) -> float | None: + """全仓复利开启时禁用单笔预算封顶(sheets/eth 也不再受 trade_budget 限制).""" + if mode in ("budget_full", "compound_full"): + return budget_cap + if mode in ("sheets", "eth_amount"): + if _compound_full_enabled(): + return None + return budget_cap + return None + + +def _normalize_size_mode(mode: str) -> tuple[str, str | None]: + """全仓复利关闭时强制离开 compound_full,避免前端残留选中导致无法开仓.""" + m = (mode or "sheets").strip() or "sheets" + if m == "compound_full" and not _compound_full_enabled(): + return "sheets", "全仓复利已关闭,已改用指定张数" + if m == "budget_full" and _compound_full_enabled(): + return "compound_full", None + return m, None + + +def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: + """全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer.""" + if not _compound_full_enabled(): + return None, "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)" + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + from lib.options.options_pricing_lib import resolve_compound_full_usdc + + raw = fetch_options_trading_usdc(ex) + if raw is None or float(raw) <= 0: + return None, "交易账户 USDC 可用余额不足" + trading = float(raw) + # 额度热更读 env(与模板启动值无关) + cap_on = _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False) + cap_v = _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0) + if cap_on and cap_v <= 0: + return None, "全仓上限无效(OKX_OPTIONS_COMPOUND_FULL_CAP_USDC)" + return ( + resolve_compound_full_usdc( + trading, + cap_enabled=cap_on, + cap_usdc=cap_v, + ), + "", + ) + + +def _is_budget_mode(mode: str) -> bool: + return mode in ("budget_full", "compound_full") + + +def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: + conn = cfg["get_db"]() + try: + init_options_tables(conn) + return sum_open_premium_paid(conn, inst_id) + finally: + conn.close() + + +def _position_avail_sheets(pos: dict[str, Any]) -> int: + avail = _safe_float(pos.get("availPos")) + if avail is None or avail <= 0: + avail = abs(_safe_float(pos.get("pos")) or 0) + return max(0, int(avail or 0)) + + +def _find_position(rows: list[dict[str, Any]] | None, inst_id: str) -> dict[str, Any] | None: + return next((p for p in rows or [] if str(p.get("instId")) == inst_id), None) + + +def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int | None: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return None + pos = _find_position(raw, inst_id) + if not pos: + return 0 + return _position_avail_sheets(pos) + + +def _enrich_position_row_display( + cfg: dict[str, Any], + ex: Any, + raw_pos: dict[str, Any], + *, + meta_cache: dict[str, dict[str, Any] | None] | None = None, + premium_override: float | None = None, +) -> dict[str, Any]: + from lib.options.options_history_lib import enrich_position_row_display + + return enrich_position_row_display( + cfg, + ex, + raw_pos, + meta_cache=meta_cache, + premium_override=premium_override, + ) + + +def _attach_close_preview( + cfg: dict[str, Any], + ex: Any, + row: dict[str, Any], + *, + sheets: int | None = None, + premium_paid: float | None = None, +) -> dict[str, Any]: + from lib.options.options_positions_lib import attach_close_preview + + return attach_close_preview( + cfg, + ex, + row, + sheets=sheets, + premium_paid=premium_paid, + ) + + +_OPTIONS_SYNC_LOCK = threading.Lock() +_OPTIONS_SYNC_LAST_AT = 0.0 +_OPTIONS_SYNC_INTERVAL_SEC = 15.0 + + +def _sync_options_trades( + cfg: dict[str, Any], + *, + raw_positions: list[dict[str, Any]] | None = None, + force: bool = False, +) -> None: + global _OPTIONS_SYNC_LAST_AT + ex = cfg.get("exchange_options") + if ex is None: + return + now = time.time() + with _OPTIONS_SYNC_LOCK: + if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC: + return + _OPTIONS_SYNC_LAST_AT = now + from lib.exchange.okx_options_lib import fetch_all_option_positions_history, fetch_option_position_history + from lib.options.options_monitor_lib import ( + backfill_closed_options_realized_pnl_from_history, + reconcile_live_open_trades, + sync_open_options_trades, + ) + + if raw_positions is None: + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return + else: + raw = raw_positions + live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} + + def _hist(inst_id: str): + return fetch_option_position_history(ex, inst_id) + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + reconcile_live_open_trades(conn, live_inst_ids=live_ids) + sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist) + try: + hist_all = fetch_all_option_positions_history(ex, limit=200) + backfill_closed_options_realized_pnl_from_history(conn, hist_all) + except Exception: + pass + conn.commit() + finally: + conn.close() + + +def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: + lr = cfg["login_required"] + + @app.route("/options/guide") + @lr + def options_trade_guide(): + """期权开平仓与监控说明(独立页).""" + from pathlib import Path + + from flask import render_template_string + + from lib.common.markdown_html_lib import render_markdown_html + from lib.paths import REPO_ROOT + + md_path = REPO_ROOT / "docs" / "期权开平仓与监控说明.md" + try: + md_text = md_path.read_text(encoding="utf-8") + except OSError: + md_text = "# 说明文档缺失\n\n未找到 `docs/期权开平仓与监控说明.md`." + body = render_markdown_html(md_text) + return render_template_string( + """ + + + + + + 期权开平仓与监控说明 + + + +

    ← 返回期权 · 对冲计划

    + {{ body|safe }} + + + """, + body=body, + ) + + @app.route("/api/options/balances") + @lr + def api_options_balances(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") + bal = cfg["fetch_options_balances"](ex, force=force, scope="main") + return jsonify( + { + "ok": True, + **bal, + "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)), + "compound_full_enabled": _compound_full_enabled(), + "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), + "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), + } + ) + + @app.route("/api/options/chain") + @lr + def api_options_chain(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + u = (request.args.get("underlying") or cfg["default_underly"]).upper() + # 热更新:链展示天数每次读 env,保存后刷新链即可 + chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14)) + try: + chain = cfg["build_option_chain"]( + ex, + u, + max_dte_days=chain_max_dte, + itm_only=False, + itm_max_dist_usd=cfg["itm_max_dist"], + ) + except Exception as e: + return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) + expiries = chain.get("expiries") or [] + chain_err = chain.get("chain_error") + # 热更新:每次读 env,保存配置后刷新链即可生效 + ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True) + budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95) + if not expiries: + return jsonify( + { + "ok": False, + "msg": chain_err or "暂无到期日,请稍后点「刷新链」", + **chain, + "chain_max_dte_days": chain_max_dte, + "ask_liq_filter_enabled": ask_liq_filter, + "budget_buffer": budget_buffer, + "trade_budget": cfg["trade_budget"], + } + ) + return jsonify( + { + "ok": True, + **chain, + "chain_max_dte_days": chain_max_dte, + "ask_liq_filter_enabled": ask_liq_filter, + "budget_buffer": budget_buffer, + "trade_budget": cfg["trade_budget"], + } + ) + + @app.route("/api/options/quote") + @lr + def api_options_quote(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + inst_id = (request.args.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return jsonify(q) + ask = q.get("ask") + ct_mult = q.get("ct_mult") or 0.01 + min_sz = q.get("min_sz") or 1 + mode = (request.args.get("mode") or "sheets").strip() + sheet_count = None + try: + if request.args.get("sheets"): + sheet_count = int(request.args.get("sheets")) + except (TypeError, ValueError): + pass + if mode == "close_preview": + paid = _open_premium_paid(cfg, inst_id) + target = sheet_count if sheet_count is not None else 0 + return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid)) + mode, mode_note = _normalize_size_mode(mode) + budget = cfg["trade_budget"] + budget_cap = cfg["trade_budget"] + available_usdc = None + if mode == "budget_full": + blocked = _budget_full_blocked_by_compound_msg() + if blocked: + return jsonify( + { + "ok": False, + "msg": blocked, + "compound_full_enabled": _compound_full_enabled(), + } + ) + budget, budget_err = _budget_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": _compound_full_enabled()}) + budget_cap = budget + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + + available_usdc = fetch_options_trading_usdc(ex) + elif mode == "compound_full": + if not _compound_full_enabled(): + return jsonify( + { + "ok": False, + "msg": "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)", + "compound_full_enabled": False, + } + ) + budget, budget_err = _compound_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": True}) + budget_cap = budget + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + + available_usdc = fetch_options_trading_usdc(ex) + elif mode in ("sheets", "eth_amount") and _compound_full_enabled(): + budget_cap = None + eth_amount = None + try: + if request.args.get("eth_amount"): + eth_amount = float(request.args.get("eth_amount")) + except (TypeError, ValueError): + pass + ask = q.get("ask") + ask_sz = q.get("ask_sz") + try: + from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg + + mode_block = block_standalone_open_by_mode_msg() + if mode_block: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": mode_block, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": mode_block, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + } + ) + except Exception as e: + return jsonify( + { + "ok": False, + "can_open": False, + "msg": f"交易模式校验失败: {e}", + } + ) + try: + from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg + + conn_q = cfg["get_db"]() + try: + excl = block_standalone_option_open_msg(conn_q) + finally: + conn_q.close() + if excl: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": excl, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": excl, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + } + ) + except Exception as e: + return jsonify({"ok": False, "can_open": False, "msg": f"互斥校验失败: {e}"}) + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + # 合约可报价,但不可开仓:返回参考标记价供展示 + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "quote_per_unit": None, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": block_msg or "暂无卖一深度,无法买入", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + } + ) + from lib.options.options_position_limit_lib import ( + compound_full_single_position_block_msg, + option_position_limit_block_msg, + ) + + if mode == "compound_full": + compound_block = compound_full_single_position_block_msg( + ex, fetch_positions=cfg.get("fetch_option_positions") + ) + if compound_block: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": compound_block, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": compound_block, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": None, + "compound_full_usdc": budget, + } + ) + + pos_limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id=inst_id, + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": pos_limit_msg, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": pos_limit_msg, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + } + ) + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=float(ct_mult), + min_sz=int(min_sz), + budget_usdc=budget if _is_budget_mode(mode) else None, + budget_buffer=cfg["budget_buffer"], + eth_amount=eth_amount if mode == "eth_amount" else None, + sheets=sheet_count if mode == "sheets" else None, + budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) + if mode in ("budget_full", "compound_full", "sheets", "eth_amount") + else None, + ) + if sizing.get("ok"): + capped, cap_msg = cap_option_buy_sheets_to_ask_depth( + int(sizing.get("sheets") or 0), + ask_sz, + min_sz=int(min_sz), + ) + if capped is None: + sizing = { + "ok": False, + "msg": cap_msg, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + } + elif capped < int(sizing.get("sheets") or 0): + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=float(ct_mult), + min_sz=int(min_sz), + sheets=capped, + budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) + if mode in ("budget_full", "compound_full", "sheets", "eth_amount") + else None, + ) + if sizing.get("ok"): + sizing["ask_depth_capped"] = True + sizing["ask_sz"] = ask_sz + sizing["msg"] = f"已按卖一深度限制为 {capped} 张" + q = _attach_close_preview( + cfg, + ex, + q, + sheets=int(sizing.get("sheets") or sheet_count or 0), + premium_paid=_open_premium_paid(cfg, inst_id), + ) + return jsonify( + { + **q, + "can_open": True, + "quote_per_unit": ask, + "premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)), + "sizing": sizing, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + "mode": mode, + "mode_note": mode_note, + "compound_full_enabled": _compound_full_enabled(), + } + ) + + @app.route("/api/options/open", methods=["POST"]) + @lr + def api_options_open(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + try: + from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg + + mode_block = block_standalone_open_by_mode_msg() + if mode_block: + return jsonify({"ok": False, "msg": mode_block, "can_open": False}) + except Exception as e: + return jsonify({"ok": False, "msg": f"交易模式校验失败: {e}", "can_open": False}) + try: + from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg + + conn_gate = cfg["get_db"]() + try: + block_msg = block_standalone_option_open_msg(conn_gate) + finally: + conn_gate.close() + if block_msg: + return jsonify({"ok": False, "msg": block_msg}) + except Exception as e: + return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + mode = (data.get("mode") or "sheets").strip() + mode, mode_note = _normalize_size_mode(mode) + signal_note = (data.get("signal_note") or "").strip() + if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full": + # 前端残留全仓复利选中时,已自动改指定张数;继续开仓 + pass + target_index = None + raw_target = data.get("target_index") + if raw_target is not None and str(raw_target).strip() != "": + try: + target_index = float(raw_target) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) + profit_exit_enabled = bool(data.get("profit_exit_enabled")) + profit_exit_mult = 1.0 + if profit_exit_enabled: + from lib.options.options_profit_exit_lib import normalize_profit_exit_mult + + profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0) + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return jsonify(q) + ask = q.get("ask") + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + return jsonify( + { + "ok": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "can_open": False, + "mark": q.get("mark"), + "ref_ask": q.get("ref_ask"), + } + ) + from lib.options.options_position_limit_lib import ( + compound_full_single_position_block_msg, + option_position_limit_block_msg, + ) + + if mode == "compound_full": + compound_block = compound_full_single_position_block_msg( + ex, fetch_positions=cfg.get("fetch_option_positions") + ) + if compound_block: + return jsonify({"ok": False, "msg": compound_block, "can_open": False}) + + pos_limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id=inst_id, + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return jsonify({"ok": False, "msg": pos_limit_msg, "can_open": False}) + ct_mult = float(q.get("ct_mult") or 0.01) + min_sz = int(q.get("min_sz") or 1) + eth_amount = None + sheet_count = None + if mode == "eth_amount": + try: + eth_amount = float(data.get("eth_amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "ETH 数量无效"}) + elif mode == "sheets": + try: + sheet_count = int(data.get("sheets")) + except (TypeError, ValueError): + sheet_count = None + if sheet_count is None or int(sheet_count) < 1: + # 全仓复利关闭后前端可能仍带着旧 mode 过来,归一后缺张数则默认 1 + if (data.get("mode") or "").strip() == "compound_full": + sheet_count = 1 + else: + return jsonify({"ok": False, "msg": "张数无效"}) + budget = cfg["trade_budget"] + budget_cap = cfg["trade_budget"] + if mode == "budget_full": + blocked = _budget_full_blocked_by_compound_msg() + if blocked: + return jsonify({"ok": False, "msg": blocked, "compound_full_enabled": _compound_full_enabled()}) + budget, budget_err = _budget_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err}) + budget_cap = budget + elif mode == "compound_full": + if not _compound_full_enabled(): + return jsonify( + { + "ok": False, + "msg": "全仓复利未开启,请改用指定张数或先开启全仓复利", + "compound_full_enabled": False, + } + ) + budget, budget_err = _compound_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err}) + budget_cap = budget + elif mode in ("sheets", "eth_amount") and _compound_full_enabled(): + budget_cap = None + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=ct_mult, + min_sz=min_sz, + budget_usdc=budget if _is_budget_mode(mode) else None, + budget_buffer=cfg["budget_buffer"], + eth_amount=eth_amount, + sheets=sheet_count, + budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) + if mode in ("budget_full", "compound_full", "sheets", "eth_amount") + else None, + ) + if not sizing.get("ok"): + return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) + sheets = int(sizing["sheets"]) + capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz) + if capped is None: + return jsonify({"ok": False, "msg": cap_msg or "卖一深度不足,无法买入"}) + if capped < sheets: + return jsonify( + { + "ok": False, + "msg": f"卖一深度仅 {int(capped)} 张,不足请求 {int(sheets)} 张,拒绝缩量成交", + "requested_sheets": int(sheets), + "ask_sz": ask_sz, + } + ) + tick_sz = q.get("tick_sz") + order = cfg["place_option_limit_order"]( + ex, + inst_id=inst_id, + side="buy", + sheets=sheets, + price=float(ask), + td_mode=td_mode_for_option_buy(cfg["td_mode"]), + tick_sz=tick_sz, + ord_type="ioc", + ) + if not order.get("ok"): + return jsonify(order) + ord_id = str((order.get("data") or {}).get("ordId") or "").strip() + if not ord_id: + return jsonify({"ok": False, "msg": "下单成功但未返回订单号", "order": order}) + from lib.exchange.okx_options_lib import wait_option_order_full_fill + + try: + fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12")) + except (TypeError, ValueError): + fill_timeout = 12.0 + fill = wait_option_order_full_fill( + ex, + inst_id=inst_id, + ord_id=ord_id, + need_sheets=int(sheets), + timeout_sec=fill_timeout, + cancel_on_timeout=True, + ) + if not fill.get("ok"): + filled_n = int(fill.get("filled_sheets") or 0) + orphan_close = None + if filled_n > 0: + try: + from lib.options.options_close_exec_lib import close_option_by_bid1 + + orphan_close = close_option_by_bid1( + cfg, ex, inst_id, sheets=filled_n, require_recycle_gate=False + ) + except Exception as e: + orphan_close = {"ok": False, "msg": str(e)} + return jsonify( + { + "ok": False, + "msg": fill.get("msg") or "未完全成交,开仓失败", + "filled_sheets": filled_n, + "orphan_close": orphan_close, + "fill": fill, + "order": order, + } + ) + fill_px = float(fill.get("avg_px") or ask) + filled_n = int(fill.get("filled_sheets") or sheets) + sheets = filled_n + sizing = dict(sizing) + sizing["sheets"] = sheets + sizing["eth_amount"] = round(sheets * ct_mult, 8) + sizing["total_premium"] = round(fill_px * sheets * ct_mult, 4) + conn = cfg["get_db"]() + trade_id = None + target_mon = None + open_underlying = "" + open_opt_type = None + try: + init_options_tables(conn) + from lib.options.options_profit_exit_lib import ensure_profit_exit_columns + + ensure_profit_exit_columns(conn) + meta = q.get("meta") or {} + u = str(meta.get("uly") or inst_id).split("-")[0] + opt_type = meta.get("optType") + open_underlying = u + open_opt_type = opt_type + cur = conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status, signal_note, exchange_ord_id, + profit_exit_enabled, profit_exit_mult, profit_exit_state) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?) + """, + ( + inst_id, + u, + opt_type, + q.get("strike"), + str(q.get("exp_time") or ""), + sheets, + sizing["eth_amount"], + fill_px, + sizing["total_premium"], + signal_note, + ord_id, + 1 if profit_exit_enabled else 0, + profit_exit_mult if profit_exit_enabled else 1.0, + "active" if profit_exit_enabled else "idle", + ), + ) + trade_id = int(cur.lastrowid) + if target_index is not None: + from lib.options.options_target_lib import upsert_target_monitor + + target_mon = upsert_target_monitor( + conn, + inst_id=inst_id, + target_index=target_index, + underlying=u, + opt_type=str(opt_type) if opt_type else None, + trade_id=trade_id, + sheets=sheets, + ) + if profit_exit_enabled: + pass # 列已由 init_options_tables / ensure 迁移 + conn.commit() + finally: + conn.close() + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + from lib.options.options_notify_lib import notify_options_open + + invalidate_option_positions_cache() + _sync_options_trades(cfg, force=True) + try: + conn_n = cfg["get_db"]() + try: + notify_options_open( + cfg, + conn_n, + trade_id=trade_id, + inst_id=inst_id, + underlying=open_underlying, + opt_type=open_opt_type, + sheets=sheets, + premium_paid=sizing.get("total_premium"), + open_quote=fill_px, + target_index=target_index, + signal_note=signal_note, + ) + finally: + conn_n.close() + except Exception: + pass + return jsonify( + { + "ok": True, + "order": order, + "sizing": sizing, + "trade_id": trade_id, + "target_monitor": target_mon, + } + ) + + @app.route("/api/options/orders/pending") + @lr + def api_options_orders_pending(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + inst_id = (request.args.get("inst_id") or "").strip() or None + try: + orders = cfg["fetch_option_pending_orders"](ex, inst_id) + except Exception as e: + return jsonify({"ok": False, "msg": f"获取委托失败: {e}"}) + from lib.options.options_pending_lib import enrich_pending_orders + + ttl = float(cfg.get("pending_ttl_seconds") or 600.0) + enriched = enrich_pending_orders(orders, ttl_seconds=ttl) + return jsonify( + { + "ok": True, + "orders": enriched, + "count": len(enriched), + "pending_ttl_seconds": ttl, + } + ) + + @app.route("/api/options/orders/cancel", methods=["POST"]) + @lr + def api_options_orders_cancel(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + ord_id = (data.get("ord_id") or "").strip() + if not inst_id or not ord_id: + return jsonify({"ok": False, "msg": "缺少 inst_id 或 ord_id"}) + out = cfg["cancel_option_order"](ex, inst_id=inst_id, ord_id=ord_id) + if out.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + # 本地未成交开仓记录标记取消,避免假 open + try: + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + UPDATE options_trades + SET status = 'cancelled', + signal_note = CASE + WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销' + ELSE signal_note + END, + closed_at = CURRENT_TIMESTAMP + WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open' + """, + (inst_id, ord_id), + ) + conn.commit() + finally: + conn.close() + except Exception: + pass + _sync_options_trades(cfg, force=True) + return jsonify(out), (200 if out.get("ok") else 400) + + @app.route("/api/options/positions") + @lr + def api_options_positions(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + _sync_options_trades(cfg, raw_positions=raw) + meta_cache: dict[str, dict[str, Any] | None] = {} + conn = cfg["get_db"]() + try: + from lib.options.options_target_lib import targets_by_inst + from lib.options.options_profit_exit_lib import profit_exit_by_inst + from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst + + tgt_map = targets_by_inst(conn) + profit_exit_map = profit_exit_by_inst(conn) + hedge_target_map = active_options_targets_by_inst(conn) + rows = [] + for p in raw: + inst = str(p.get("instId") or "").strip() + premium_override = sum_open_premium_paid(conn, inst) if inst else None + row = _enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) + mon = tgt_map.get(inst) + if mon: + row["target_index"] = mon.get("target_index") + row["target_monitor_id"] = mon.get("id") + row["target_monitor"] = mon + pe = profit_exit_map.get(inst) + if pe: + row["profit_exit_enabled"] = pe.get("profit_exit_enabled") + row["profit_exit_mult"] = pe.get("profit_exit_mult") + row["profit_exit_state"] = pe.get("profit_exit_state") + row["profit_exit_required_recycle"] = pe.get("required_recycle") + hedge_target = hedge_target_map.get(inst) + if hedge_target: + row["hedge_plan_target"] = hedge_target + try: + from lib.instance.instance_dashboard_lib import _resolve_options_source + + source_key, source_label, source_plan_id = _resolve_options_source(conn, inst) + row["source"] = source_key + row["source_label"] = source_label + row["source_plan_id"] = source_plan_id + except Exception: + row.setdefault("source", "option") + row.setdefault("source_label", "纯期权") + row.setdefault("source_plan_id", None) + rows.append(row) + finally: + conn.close() + return jsonify({"ok": True, "positions": rows}) + + @app.route("/api/options/targets") + @lr + def api_options_targets(): + conn = cfg["get_db"]() + try: + from lib.options.options_target_lib import list_active_targets, list_closing_targets + + return jsonify({"ok": True, "targets": list_active_targets(conn) + list_closing_targets(conn)}) + finally: + conn.close() + + @app.route("/api/options/target", methods=["POST"]) + @lr + def api_options_target_set(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + from lib.hedge_plan.hedge_plan_db import ( + active_hedge_option_inst_ids, + init_hedge_plan_tables, + ) + + conn_h = cfg["get_db"]() + try: + init_hedge_plan_tables(conn_h) + if inst_id in active_hedge_option_inst_ids(conn_h): + return jsonify( + { + "ok": False, + "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置目标", + } + ) + finally: + conn_h.close() + except Exception as e: + return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) + try: + target_index = float(data.get("target_index")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + pos = _find_position(raw, inst_id) + if not pos: + return jsonify({"ok": False, "msg": "未找到持仓"}) + from lib.options.options_target_lib import upsert_target_monitor + + fmt = cfg["format_position_row"](pos) + conn = cfg["get_db"]() + try: + trade = conn.execute( + """ + SELECT id, opt_type, underlying FROM options_trades + WHERE inst_id = ? AND status = 'open' + ORDER BY id DESC LIMIT 1 + """, + (inst_id,), + ).fetchone() + trade_id = int(trade["id"]) if trade else None + sheets_sum = sum_open_sheets(conn, inst_id) + sheets = sheets_sum if sheets_sum is not None else int(fmt.get("pos") or 0) + opt_type = (trade["opt_type"] if trade else None) or fmt.get("opt_type") + underlying = (trade["underlying"] if trade else None) or fmt.get("underlying") + out = upsert_target_monitor( + conn, + inst_id=inst_id, + target_index=target_index, + underlying=str(underlying) if underlying else None, + opt_type=str(opt_type) if opt_type else None, + trade_id=trade_id, + sheets=sheets, + ) + conn.commit() + return jsonify(out) + finally: + conn.close() + + @app.route("/api/options/target/cancel", methods=["POST"]) + @lr + def api_options_target_cancel(): + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() or None + monitor_id = data.get("id") + try: + mid = int(monitor_id) if monitor_id is not None and str(monitor_id).strip() != "" else None + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "监控 id 无效"}) + if not inst_id and mid is None: + return jsonify({"ok": False, "msg": "缺少 inst_id 或 id"}) + from lib.options.options_target_lib import cancel_target_monitor + + conn = cfg["get_db"]() + try: + n = cancel_target_monitor(conn, inst_id=inst_id, monitor_id=mid) + conn.commit() + return jsonify({"ok": True, "cancelled": n}) + finally: + conn.close() + + @app.route("/api/options/profit-exit", methods=["POST"]) + @lr + def api_options_profit_exit_set(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + from lib.hedge_plan.hedge_plan_db import ( + active_hedge_option_inst_ids, + init_hedge_plan_tables, + ) + + conn_h = cfg["get_db"]() + try: + init_hedge_plan_tables(conn_h) + if inst_id in active_hedge_option_inst_ids(conn_h): + return jsonify( + { + "ok": False, + "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置翻倍出场", + } + ) + finally: + conn_h.close() + except Exception as e: + return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) + enabled_raw = data.get("enabled") + if enabled_raw is None: + enabled_raw = data.get("profit_exit_enabled") + enabled = bool(enabled_raw) and str(enabled_raw).strip().lower() not in ( + "0", + "false", + "off", + "no", + ) + from lib.options.options_profit_exit_lib import normalize_profit_exit_mult, set_profit_exit + + mult = normalize_profit_exit_mult(data.get("mult", data.get("profit_exit_mult")), default=1.0) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + if not _find_position(raw, inst_id): + return jsonify({"ok": False, "msg": "未找到持仓"}) + conn = cfg["get_db"]() + try: + out = set_profit_exit(conn, inst_id=inst_id, enabled=enabled, mult=mult) + if out.get("ok"): + conn.commit() + return jsonify(out) + finally: + conn.close() + + @app.route("/api/options/close", methods=["POST"]) + @lr + def api_options_close(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + from lib.hedge_plan.hedge_plan_db import ( + active_hedge_option_inst_ids, + init_hedge_plan_tables, + ) + + conn_h = cfg["get_db"]() + try: + init_hedge_plan_tables(conn_h) + if inst_id in active_hedge_option_inst_ids(conn_h): + return jsonify( + { + "ok": False, + "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页平仓", + } + ) + finally: + conn_h.close() + except Exception as e: + return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) + if data.get("market"): + return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价"}) + sheets = data.get("sheets") + try: + sheets_i = int(sheets) if sheets is not None and str(sheets).strip() != "" else None + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "张数无效"}) + from lib.options.options_close_exec_lib import close_option_by_bid1 + + # 手动买一平仓:只验有效流动性;2×门控仅用于自动/目标位平仓 + result = close_option_by_bid1( + cfg, + ex, + inst_id, + sheets=sheets_i, + require_recycle_gate=False, + ) + if result.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + _sync_options_trades(cfg, force=True) + if result.get("fully_closed"): + try: + from lib.options.options_target_lib import cancel_target_monitor + from lib.options.options_notify_lib import notify_options_close + + conn2 = cfg["get_db"]() + try: + cancel_target_monitor(conn2, inst_id=inst_id) + conn2.commit() + notify_options_close( + cfg, + conn2, + inst_id=inst_id, + reason="手动平仓", + sheets=result.get("submitted_sheets"), + premium_received=result.get("premium_received"), + close_quote=result.get("locked_bid_px") or result.get("bid"), + ) + finally: + conn2.close() + except Exception: + pass + _mark_balances_stale(cfg) + return jsonify(result) + + @app.route("/api/options/convert/quote", methods=["POST"]) + @lr + def api_options_convert_quote(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + return jsonify(cfg["estimate_usdt_to_usdc"](ex, amount)) + + @app.route("/api/options/convert/execute", methods=["POST"]) + @lr + def api_options_convert_execute(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + quote_id = (data.get("quote_id") or "").strip() + result = cfg["execute_convert"](ex, quote_id) + if result.get("ok"): + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_convert_log (from_ccy, to_ccy, rfq_sz, received_sz, quote_id, status, message) + VALUES ('USDT', 'USDC', ?, ?, ?, 'ok', '') + """, + ( + data.get("rfq_sz"), + (result.get("data") or {}).get("baseSz"), + quote_id, + ), + ) + conn.commit() + finally: + conn.close() + return jsonify(result) + + @app.route("/api/options/transfer", methods=["POST"]) + @lr + def api_options_transfer(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + ccy = (data.get("ccy") or "USDC").upper() + from_acct = (data.get("from") or "funding").strip() + to_acct = (data.get("to") or "trading").strip() + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + result = cfg["transfer_ccy"](ex, ccy, amount, from_acct, to_acct) + if result.get("ok"): + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message) + VALUES (?, ?, ?, ?, 'ok', '') + """, + (ccy, amount, from_acct, to_acct), + ) + conn.commit() + finally: + conn.close() + _mark_balances_stale(cfg) + return jsonify(result) + + @app.route("/api/options/spot/swap", methods=["POST"]) + @lr + def api_options_spot_swap(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + direction = (data.get("direction") or "usdt_to_usdc").strip() + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + result = cfg["spot_market_swap_usdt_usdc"](ex, direction=direction, amount=amount) + if result.get("ok"): + _mark_balances_stale(cfg) + return jsonify(result) + + @app.route("/api/options/history") + @lr + def api_options_history(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + from lib.options.options_history_lib import load_options_history + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + history = load_options_history(ex, cfg) + live_ids = {str(x.get("inst_id") or "") for x in history if x.get("status") == "open"} + return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)}) + + @app.route("/api/options/stats") + @lr + def api_options_stats(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + from lib.options.options_history_lib import load_options_history + from lib.options.options_positions_lib import sum_options_net_pnl_usdc + from lib.options.options_stats_lib import compute_options_stats_from_history + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + history = load_options_history(ex, cfg) + stats = compute_options_stats_from_history(history) + open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live) + net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0 + total_pnl = None + if open_float is not None: + total_pnl = round(net_realized + float(open_float), 4) + elif stats.get("total_closed"): + total_pnl = round(net_realized, 4) + return jsonify( + { + "ok": True, + **stats, + "open_float_pnl": open_float, + "total_pnl": total_pnl, + } + ) + + @app.route("/api/options/history/", methods=["DELETE"]) + @lr + def api_options_history_delete(history_key: str): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + key = (history_key or "").strip() + if not key: + return jsonify({"ok": False, "msg": "缺少 history_key"}) + data = request.get_json(silent=True) or {} + inst_id = str(data.get("inst_id") or request.args.get("inst_id") or "").strip() or None + closed_at = str(data.get("closed_at") or request.args.get("closed_at") or "").strip() or None + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + "INSERT OR IGNORE INTO options_history_hidden (history_key) VALUES (?)", + (key,), + ) + # 同步隐藏期权复盘,避免本地已平记录刷新后又出现 + try: + from lib.options.options_review_lib import hide_review_keys + + hide_review_keys( + conn, + history_key=key, + inst_id=inst_id, + closed_at=closed_at, + ) + if inst_id: + # 去掉已导入的复盘快照(按合约+平仓时间) + if closed_at: + rows = conn.execute( + """ + SELECT id, history_key FROM options_review_trades + WHERE inst_id = ? + AND substr(COALESCE(closed_at,''),1,16) = substr(?,1,16) + """, + (inst_id, closed_at), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, history_key FROM options_review_trades + WHERE inst_id = ? + """, + (inst_id,), + ).fetchall() + for r in rows: + conn.execute( + "DELETE FROM options_review_entries WHERE trade_id=?", + (int(r["id"]),), + ) + conn.execute( + "DELETE FROM options_review_trades WHERE id=?", + (int(r["id"]),), + ) + conn.execute( + "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", + (str(r["history_key"]), inst_id, (closed_at or "")[:19] or None), + ) + fps = [] + if closed_at: + fps.append(f"inst_close:{inst_id}:{closed_at[:16]}") + fps.append(f"inst:{inst_id}") + for fp in fps: + conn.execute( + "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", + (fp, inst_id, (closed_at or "")[:19] or None), + ) + except Exception: + pass + conn.commit() + finally: + conn.close() + return jsonify({"ok": True}) + + +def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: + if app.extensions.get("options_monitor_started"): + return + app.extensions["options_monitor_started"] = True + + def _bid(inst_id: str) -> float | None: + ex = cfg.get("exchange_options") + if ex is None: + return None + try: + q = cfg["quote_option_contract"](ex, inst_id) + return q.get("bid") + except Exception: + return None + + def _positions(): + ex = cfg.get("exchange_options") + if ex is None: + return [] + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return [] + return [cfg["format_position_row"](p) for p in raw] + + def _sync(conn): + from lib.exchange.okx_options_lib import fetch_option_position_history + from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades + + ex = cfg.get("exchange_options") + if ex is None: + return 0 + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return 0 + live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} + reconcile_live_open_trades(conn, live_inst_ids=live_ids) + return sync_open_options_trades( + conn, + live_inst_ids=live_ids, + fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id), + notify_cfg=cfg, + ) + + def _target_close(inst_id: str) -> dict[str, Any]: + from lib.options.options_target_lib import close_option_by_bid_depth + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + result = close_option_by_bid_depth(cfg, ex, inst_id) + if result.get("ok"): + try: + _sync_options_trades(cfg, force=True) + except Exception: + pass + try: + _mark_balances_stale(cfg) + except Exception: + pass + return result + + def _profit_exit_close(inst_id: str) -> dict[str, Any]: + from lib.options.options_profit_exit_lib import close_option_by_bid_profit_exit + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + result = close_option_by_bid_profit_exit(cfg, ex, inst_id) + if result.get("ok"): + try: + _sync_options_trades(cfg, force=True) + except Exception: + pass + try: + _mark_balances_stale(cfg) + except Exception: + pass + return result + + def _stale_pending() -> dict[str, Any]: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + from lib.options.options_pending_lib import cancel_stale_close_pending_orders + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + ttl = float(cfg.get("pending_ttl_seconds") or 600.0) + out = cancel_stale_close_pending_orders( + fetch_pending=lambda _ex: cfg["fetch_option_pending_orders"](_ex), + cancel_order=lambda _ex, inst_id, ord_id: cfg["cancel_option_order"]( + _ex, inst_id=inst_id, ord_id=ord_id + ), + ttl_seconds=ttl, + ex=ex, + ) + if out.get("cancelled"): + try: + invalidate_option_positions_cache() + except Exception: + pass + try: + send = cfg.get("send_wechat") + if callable(send): + parts = [ + "【OKX期权·挂单超时撤销】", + f"账户:{cfg.get('account_label') or 'OKX期权'}", + f"超时:{ttl:g}s", + f"撤销:{out.get('cancelled')} 笔", + ] + for o in out.get("orders") or []: + parts.append(f"- {o.get('inst_id')} #{o.get('ord_id')}") + send("\n".join(parts)) + except Exception: + pass + return out + + t = threading.Thread( + target=options_monitor_loop, + kwargs={ + "enabled": True, + "poll_seconds": cfg["poll_seconds"], + "get_db": cfg["get_db"], + "fetch_positions": _positions, + "ticker_bid_fn": _bid, + "send_wechat": cfg["send_wechat"], + "account_label": cfg["account_label"], + "profit_ratio": cfg["profit_ratio"], + "sync_trades_fn": _sync, + "target_close_fn": _target_close, + "profit_exit_close_fn": _profit_exit_close, + "profit_exit_cfg": cfg, + "stale_pending_fn": _stale_pending, + }, + daemon=True, + name="options-monitor", + ) + t.start() diff --git a/lib/options/options_review_db.py b/lib/options/options_review_db.py new file mode 100644 index 0000000..f0db635 --- /dev/null +++ b/lib/options/options_review_db.py @@ -0,0 +1,144 @@ +"""期权复盘(含对冲) SQLite 表.""" +from __future__ import annotations + +import sqlite3 + + +SOURCE_OPTION = "option_spot" +SOURCE_PERP_OPTIONS = "perp_options" +SOURCE_OPTIONS_OPTIONS = "options_options" +SOURCE_TYPES = (SOURCE_OPTION, SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS) + + +def init_options_review_tables(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_review_trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_type TEXT NOT NULL, + history_key TEXT NOT NULL UNIQUE, + underlying TEXT, + opened_at TEXT, + closed_at TEXT, + hold_seconds INTEGER, + realized_pnl_total REAL, + status_raw TEXT, + synced_at TEXT, + -- 纯期权 + pos_id TEXT, + inst_id TEXT, + opt_type TEXT, + strike REAL, + exp_time TEXT, + sheets INTEGER, + open_avg REAL, + close_avg REAL, + premium_paid REAL, + realized_pnl REAL, + -- 对冲计划 + hedge_plan_id INTEGER, + plan_close_reason TEXT, + realized_pnl_perp REAL, + realized_pnl_options REAL, + premium_total REAL, + direction TEXT, + tp REAL, + sl REAL, + target_price REAL, + target_price_up REAL, + target_price_down REAL, + legs_json TEXT, + -- 双计防护:纯期权腿已归属对冲计划 + linked_hedge_plan_id INTEGER, + excluded_as_hedge_leg INTEGER DEFAULT 0 + ) + """ + ) + conn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_options_review_trades_history_key + ON options_review_trades(history_key) + """ + ) + conn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_options_review_trades_hedge_plan + ON options_review_trades(hedge_plan_id) + WHERE hedge_plan_id IS NOT NULL + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_review_trades_closed + ON options_review_trades(closed_at) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_review_trades_source + ON options_review_trades(source_type) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_review_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_id INTEGER NOT NULL UNIQUE, + strategy_tag TEXT, + direction_view TEXT, + entry_logic TEXT, + exit_reason TEXT, + followed_plan TEXT, + mistake_tags TEXT, + result_tag TEXT, + note TEXT, + images_json TEXT, + image TEXT, + reviewed_at TEXT, + updated_at TEXT, + FOREIGN KEY(trade_id) REFERENCES options_review_trades(id) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_review_sync_state ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_review_hidden ( + history_key TEXT PRIMARY KEY, + inst_id TEXT, + closed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_review_hidden_inst + ON options_review_hidden(inst_id, closed_at) + """ + ) + _ensure_column(conn, "options_review_trades", "linked_hedge_plan_id", "INTEGER") + _ensure_column(conn, "options_review_trades", "excluded_as_hedge_leg", "INTEGER DEFAULT 0") + _ensure_column(conn, "options_review_trades", "target_price_up", "REAL") + _ensure_column(conn, "options_review_trades", "target_price_down", "REAL") + _ensure_column(conn, "options_review_trades", "profit_rr", "REAL") + + +def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + names: set[str] = set() + for r in rows: + try: + names.add(str(r["name"])) + except (TypeError, KeyError, IndexError): + names.add(str(r[1])) + if col not in names: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") diff --git a/lib/options/options_review_images_lib.py b/lib/options/options_review_images_lib.py new file mode 100644 index 0000000..a0d0545 --- /dev/null +++ b/lib/options/options_review_images_lib.py @@ -0,0 +1,144 @@ +"""期权复盘截图:独立命名空间,与合约同款四周期 5m/15m/1h/4h.""" +from __future__ import annotations + +import json +import os +import re +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence + +OPTIONS_REVIEW_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h") +OPTIONS_REVIEW_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}) +_DRAFT_ID_RE = re.compile(r"^[a-f0-9]{32}$") +_SLOT_FILE_RE = re.compile( + r"^options_journal_([a-f0-9]{32})_(5m|15m|1h|4h)\.(png|jpg|jpeg|webp|gif|bmp)$", + re.I, +) + + +def normalize_options_review_draft_id(raw: Any) -> Optional[str]: + s = str(raw or "").strip().lower() + if _DRAFT_ID_RE.match(s): + return s + return None + + +def _safe_ext(filename: str) -> str: + ext = os.path.splitext(str(filename or ""))[1].lower() + return ext if ext in OPTIONS_REVIEW_ALLOWED_EXT else ".png" + + +def options_review_upload_dir(base_upload_folder: str) -> str: + """独立子目录 static/images/options_journal.""" + base = os.path.abspath(base_upload_folder or "") + path = os.path.join(base, "options_journal") + os.makedirs(path, exist_ok=True) + return path + + +def build_options_review_slot_filename( + draft_id: str, + tf: str, + ext: str, + *, + secure_filename_fn: Callable[[str], str], +) -> str: + ext = ext if ext.startswith(".") else f".{ext}" + ext = _safe_ext(f"x{ext}") + fname = secure_filename_fn(f"options_journal_{draft_id}_{tf}{ext}") + return fname or "" + + +def is_valid_options_review_file(filename: str, draft_id: str, tf: str) -> bool: + fn = os.path.basename(str(filename or "").strip()) + if not fn or fn != str(filename or "").strip(): + return False + m = _SLOT_FILE_RE.match(fn) + if not m: + return False + return m.group(1) == draft_id.lower() and m.group(2) == tf + + +def save_options_review_slot_file( + file, + draft_id: str, + tf: str, + upload_folder: str, + *, + secure_filename_fn: Callable[[str], str], +) -> Optional[Dict[str, str]]: + if tf not in OPTIONS_REVIEW_UPLOAD_TFS or not draft_id or not upload_folder: + return None + if not file or not getattr(file, "filename", None): + return None + ext = _safe_ext(file.filename) + fname = build_options_review_slot_filename( + draft_id, tf, ext, secure_filename_fn=secure_filename_fn + ) + if not fname: + return None + os.makedirs(upload_folder, exist_ok=True) + path = os.path.join(upload_folder, fname) + file.save(path) + return {"tf": tf, "file": fname} + + +def parse_options_review_images_json(raw: Any) -> List[Dict[str, str]]: + if not raw: + return [] + if isinstance(raw, list): + data = raw + else: + try: + data = json.loads(str(raw)) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + if not isinstance(data, list): + return [] + out: List[Dict[str, str]] = [] + for item in data: + if not isinstance(item, dict): + continue + tf = str(item.get("tf") or "").strip() + file = str(item.get("file") or "").strip() + if file: + out.append({"tf": tf, "file": file}) + return out + + +def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]: + if not items: + return None + return json.dumps(list(items), ensure_ascii=False, separators=(",", ":")) + + +def options_review_image_paths(row: Any, upload_folder: str) -> List[str]: + upload_root = os.path.abspath(upload_folder or "") + options_dir = options_review_upload_dir(upload_root) + paths: List[str] = [] + seen: set[str] = set() + + def _add(name: Optional[str]) -> None: + if not name: + return + base = os.path.basename(str(name).strip()) + if not base: + return + for folder in (options_dir, upload_root): + p = os.path.abspath(os.path.join(folder, base)) + if os.path.isfile(p) and p not in seen: + seen.add(p) + paths.append(p) + return + + try: + keys = row.keys() if hasattr(row, "keys") else () + except Exception: + keys = () + images = parse_options_review_images_json( + row["images_json"] if "images_json" in keys else getattr(row, "images_json", None) + ) + for item in images: + _add(item.get("file")) + if "image" in keys or hasattr(row, "image"): + _add(row["image"] if "image" in keys else getattr(row, "image", None)) + return paths diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py new file mode 100644 index 0000000..a57d88a --- /dev/null +++ b/lib/options/options_review_lib.py @@ -0,0 +1,1034 @@ +"""期权复盘业务:OKX 已平期权导入 + 已结束对冲计划导入 + 复盘 CRUD + 统计.""" +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from typing import Any, Callable, Optional + +from lib.options.options_review_db import ( + SOURCE_OPTION, + SOURCE_OPTIONS_OPTIONS, + SOURCE_PERP_OPTIONS, + SOURCE_TYPES, + init_options_review_tables, +) +from lib.options.options_review_images_lib import ( + images_json_dumps, + parse_options_review_images_json, +) + +SOURCE_LABELS = { + SOURCE_OPTION: "纯期权", + SOURCE_PERP_OPTIONS: "永期对冲", + SOURCE_OPTIONS_OPTIONS: "期期对冲", +} + +HOLD_BUCKETS = ( + ("0-1h", 0, 3600), + ("1-6h", 3600, 6 * 3600), + ("6-24h", 6 * 3600, 24 * 3600), + ("1-3d", 24 * 3600, 3 * 24 * 3600), + (">3d", 3 * 24 * 3600, None), +) + + +def _now_str() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _parse_ts(raw: Any) -> Optional[datetime]: + if raw is None or raw == "": + return None + s = str(raw).strip().replace(" ", "T", 1) + try: + return datetime.fromisoformat(s) + except (TypeError, ValueError): + return None + + +def _hold_seconds(opened_at: Any, closed_at: Any) -> Optional[int]: + start = _parse_ts(opened_at) + end = _parse_ts(closed_at) + if start is None or end is None: + return None + sec = int((end - start).total_seconds()) + return sec if sec >= 0 else None + + +def _safe_float(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def get_sync_state(conn: sqlite3.Connection, key: str) -> Optional[str]: + row = conn.execute( + "SELECT value FROM options_review_sync_state WHERE key=?", (key,) + ).fetchone() + return str(row["value"]) if row and row["value"] is not None else None + + +def set_sync_state(conn: sqlite3.Connection, key: str, value: str) -> None: + conn.execute( + """ + INSERT INTO options_review_sync_state(key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at + """, + (key, value, _now_str()), + ) + + +def _purge_review_trade_by_key(conn: sqlite3.Connection, history_key: str) -> bool: + """删除已导入的复盘快照(含复盘内容).""" + key = str(history_key or "").strip() + if not key: + return False + existing = conn.execute( + "SELECT id FROM options_review_trades WHERE history_key=?", (key,) + ).fetchone() + if not existing: + return False + tid = int(existing["id"]) + conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (tid,)) + conn.execute("DELETE FROM options_review_trades WHERE id=?", (tid,)) + return True + + +def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> str: + """幂等写入纯期权快照;不触碰 options_review_entries;已隐藏的不再导入.""" + history_key = str(row.get("history_key") or "").strip() + if not history_key: + return "skip" + if is_review_hidden( + conn, + history_key, + inst_id=str(row.get("inst_id") or "").strip() or None, + closed_at=row.get("closed_at") or row.get("created_at"), + ): + # 若此前已导入,清掉,避免列表残留 + return "purged" if _purge_review_trade_by_key(conn, history_key) else "hidden" + opened_at = row.get("created_at") or row.get("opened_at") + closed_at = row.get("closed_at") + pnl = _safe_float(row.get("realized_pnl")) + hold = _hold_seconds(opened_at, closed_at) + existing = conn.execute( + "SELECT id FROM options_review_trades WHERE history_key=?", (history_key,) + ).fetchone() + fields = { + "source_type": SOURCE_OPTION, + "history_key": history_key, + "underlying": str(row.get("underlying") or "").strip() or None, + "opened_at": opened_at, + "closed_at": closed_at, + "hold_seconds": hold, + "realized_pnl_total": pnl, + "status_raw": str(row.get("status_label") or row.get("status") or "closed"), + "synced_at": _now_str(), + "pos_id": str(row.get("pos_id") or "").strip() or None, + "inst_id": str(row.get("inst_id") or "").strip() or None, + "opt_type": str(row.get("opt_type") or "").strip() or None, + "strike": _safe_float(row.get("strike")), + "exp_time": str(row.get("exp_time") or "").strip() or None, + "sheets": int(row.get("sheets") or 0) or None, + "open_avg": _safe_float(row.get("open_avg_px") if row.get("open_avg_px") is not None else row.get("open_avg")), + "close_avg": _safe_float(row.get("close_avg_px") if row.get("close_avg_px") is not None else row.get("close_avg")), + "premium_paid": _safe_float(row.get("premium_paid")), + "realized_pnl": pnl, + } + cols = list(fields.keys()) + if existing: + sets = ", ".join(f"{c}=?" for c in cols if c != "history_key") + vals = [fields[c] for c in cols if c != "history_key"] + conn.execute( + f"UPDATE options_review_trades SET {sets} WHERE history_key=?", + [*vals, history_key], + ) + return "updated" + placeholders = ",".join(["?"] * len(cols)) + conn.execute( + f"INSERT INTO options_review_trades ({','.join(cols)}) VALUES ({placeholders})", + [fields[c] for c in cols], + ) + return "inserted" + + +def _close_fingerprint(inst_id: Any, closed_at: Any) -> str | None: + inst = str(inst_id or "").strip() + if not inst: + return None + closed = str(closed_at or "").strip() + if not closed: + return f"inst:{inst}" + # 精确到分钟,避免秒差导致漏匹配 + return f"inst_close:{inst}:{closed[:16]}" + + +def is_review_hidden( + conn: sqlite3.Connection, + history_key: str, + *, + inst_id: str | None = None, + closed_at: Any = None, +) -> bool: + init_options_review_tables(conn) + key = str(history_key or "").strip() + if key and conn.execute( + "SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", (key,) + ).fetchone(): + return True + fp = _close_fingerprint(inst_id, closed_at) + if fp and conn.execute( + "SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", (fp,) + ).fetchone(): + return True + # 期权历史页删除:options_history_hidden,按合约指纹或原 key + try: + if key and conn.execute( + "SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", (key,) + ).fetchone(): + return True + if fp and conn.execute( + "SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", (fp,) + ).fetchone(): + return True + # 仅隐藏了 ex:posId 时,用合约+平仓时间在历史隐藏表无直接命中; + # 若指纹已写入 options_review_hidden(新删除路径)上面已覆盖. + # 兼容:inst 级隐藏 + if inst_id: + inst_fp = f"inst:{str(inst_id).strip()}" + if conn.execute( + "SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", + (inst_fp,), + ).fetchone(): + return True + if conn.execute( + "SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", + (inst_fp,), + ).fetchone(): + return True + except Exception: + pass + return False + + +def hide_review_keys( + conn: sqlite3.Connection, + *, + history_key: str, + inst_id: str | None = None, + closed_at: Any = None, +) -> None: + init_options_review_tables(conn) + keys = [str(history_key or "").strip()] + fp = _close_fingerprint(inst_id, closed_at) + if fp: + keys.append(fp) + for k in keys: + if not k: + continue + conn.execute( + """ + INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) + VALUES (?, ?, ?) + """, + (k, (inst_id or None), str(closed_at or "")[:19] or None), + ) + try: + conn.execute( + "INSERT OR IGNORE INTO options_history_hidden(history_key) VALUES (?)", + (k,), + ) + except Exception: + pass + + +def hide_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]: + """从复盘列表删除并持久隐藏,刷新本地源也不会再回来.""" + init_options_review_tables(conn) + row = conn.execute( + "SELECT * FROM options_review_trades WHERE id=?", (int(trade_id),) + ).fetchone() + if not row: + return {"ok": False, "msg": "记录不存在"} + d = _row_to_dict(row) + hide_review_keys( + conn, + history_key=str(d.get("history_key") or ""), + inst_id=str(d.get("inst_id") or "").strip() or None, + closed_at=d.get("closed_at") or d.get("opened_at"), + ) + entry = conn.execute( + "SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),) + ).fetchone() + conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (int(trade_id),)) + conn.execute("DELETE FROM options_review_trades WHERE id=?", (int(trade_id),)) + return {"ok": True, "entry": _row_to_dict(entry) if entry else None, "history_key": d.get("history_key")} + + + +def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]: + """从本地 options_trades 已平仓记录导入复盘快照(不访问交易所).""" + init_options_review_tables(conn) + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets, + open_quote, close_quote, premium_paid, realized_pnl, + created_at, closed_at, signal_note, status + FROM options_trades + WHERE status = 'closed' + ORDER BY id DESC + LIMIT 500 + """ + ).fetchall() + inserted = updated = skipped = 0 + for r in rows: + trade_id = int(r["id"]) + history_key = f"local_opt:{trade_id}" + pnl = _safe_float(r["realized_pnl"]) + opened_at = r["created_at"] + closed_at = r["closed_at"] + action = upsert_option_history_row( + conn, + { + "history_key": history_key, + "pos_id": f"local:{trade_id}", + "inst_id": r["inst_id"], + "underlying": r["underlying"], + "opt_type": r["opt_type"], + "strike": r["strike"], + "exp_time": r["exp_time"], + "sheets": r["sheets"], + "open_avg_px": r["open_quote"], + "close_avg_px": r["close_quote"], + "premium_paid": r["premium_paid"], + "realized_pnl": pnl, + "created_at": opened_at, + "closed_at": closed_at, + "status_label": "已平", + }, + ) + if action == "inserted": + inserted += 1 + elif action == "updated": + updated += 1 + else: + skipped += 1 + set_sync_state(conn, "options_last_sync_at", _now_str()) + set_sync_state(conn, "options_last_count", str(len(rows))) + set_sync_state(conn, "options_sync_source", "local") + return { + "ok": True, + "source": "local", + "fetched": len(rows), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + } + + +def sync_options_from_exchange( + conn: sqlite3.Connection, + ex: Any, + *, + limit: int = 500, + fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, + format_fn: Optional[Callable[..., dict[str, Any]]] = None, +) -> dict[str, Any]: + """从 OKX positions-history 导入已全平期权仓位(可选,默认不用).""" + init_options_review_tables(conn) + from lib.exchange.okx_options_lib import ( + fetch_all_option_positions_history, + format_option_history_row, + tick_sz_and_ct_mult, + ) + + fetch = fetch_fn or fetch_all_option_positions_history + fmt = format_fn or format_option_history_row + raw_rows = fetch(ex, limit=limit) + meta_cache: dict[str, dict[str, Any] | None] = {} + inserted = updated = skipped = 0 + for raw in raw_rows: + inst_id = str(raw.get("instId") or "").strip() + tick_sz, ct_mult = None, 0.01 + try: + tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) + except Exception: + pass + formatted = fmt(raw, tick_sz=tick_sz, ct_mult=ct_mult) + action = upsert_option_history_row(conn, formatted) + if action == "inserted": + inserted += 1 + elif action == "updated": + updated += 1 + else: + skipped += 1 + set_sync_state(conn, "options_last_sync_at", _now_str()) + set_sync_state(conn, "options_last_count", str(len(raw_rows))) + set_sync_state(conn, "options_sync_source", "exchange") + return { + "ok": True, + "source": "exchange", + "fetched": len(raw_rows), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + } + + +def _legs_json_from_plan(legs: list[dict[str, Any]]) -> str: + slim = [] + for leg in legs: + slim.append( + { + "id": leg.get("id"), + "leg_role": leg.get("leg_role"), + "symbol": leg.get("symbol"), + "inst_id": leg.get("inst_id"), + "opt_type": leg.get("opt_type"), + "strike": leg.get("strike"), + "side": leg.get("side"), + "size": leg.get("size"), + "avg_open": leg.get("avg_open"), + "premium": leg.get("premium"), + "status": leg.get("status"), + "realized_pnl": leg.get("realized_pnl"), + "close_reason": leg.get("close_reason"), + "opened_at": leg.get("opened_at"), + "closed_at": leg.get("closed_at"), + } + ) + return json.dumps(slim, ensure_ascii=False, separators=(",", ":")) + + +def upsert_hedge_plan_row( + conn: sqlite3.Connection, + plan: dict[str, Any], + legs: list[dict[str, Any]], +) -> str: + plan_id = int(plan["id"]) + history_key = f"hedge:{plan_id}" + plan_type = str(plan.get("plan_type") or "").strip() + if plan_type not in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS): + return "skip" + opened_at = plan.get("opened_at") or plan.get("created_at") + closed_at = plan.get("closed_at") + if is_review_hidden( + conn, + history_key, + inst_id=None, + closed_at=closed_at, + ): + return "purged" if _purge_review_trade_by_key(conn, history_key) else "hidden" + total = _safe_float(plan.get("realized_pnl_total")) + hold = _hold_seconds(opened_at, closed_at) + fields = { + "source_type": plan_type, + "history_key": history_key, + "underlying": str(plan.get("underlying") or "").strip() or None, + "opened_at": opened_at, + "closed_at": closed_at, + "hold_seconds": hold, + "realized_pnl_total": total, + "status_raw": str(plan.get("status") or "closed"), + "synced_at": _now_str(), + "hedge_plan_id": plan_id, + "plan_close_reason": str(plan.get("close_reason") or "").strip() or None, + "realized_pnl_perp": _safe_float(plan.get("realized_pnl_perp")), + "realized_pnl_options": _safe_float(plan.get("realized_pnl_options")), + "premium_total": _safe_float(plan.get("premium_total")), + "direction": str(plan.get("direction") or "").strip() or None, + "tp": _safe_float(plan.get("tp")), + "sl": _safe_float(plan.get("sl")), + "target_price": _safe_float(plan.get("target_price")), + "target_price_up": _safe_float(plan.get("target_price_up")), + "target_price_down": _safe_float(plan.get("target_price_down")), + "profit_rr": _safe_float(plan.get("profit_rr")), + "legs_json": _legs_json_from_plan(legs), + } + existing = conn.execute( + "SELECT id FROM options_review_trades WHERE history_key=?", (history_key,) + ).fetchone() + cols = list(fields.keys()) + if existing: + sets = ", ".join(f"{c}=?" for c in cols if c != "history_key") + vals = [fields[c] for c in cols if c != "history_key"] + conn.execute( + f"UPDATE options_review_trades SET {sets} WHERE history_key=?", + [*vals, history_key], + ) + trade_id = int(existing["id"]) + action = "updated" + else: + placeholders = ",".join(["?"] * len(cols)) + cur = conn.execute( + f"INSERT INTO options_review_trades ({','.join(cols)}) VALUES ({placeholders})", + [fields[c] for c in cols], + ) + trade_id = int(cur.lastrowid) + action = "inserted" + _mark_option_legs_excluded(conn, plan_id, legs) + del trade_id + return action + + +def _mark_option_legs_excluded( + conn: sqlite3.Connection, + plan_id: int, + legs: list[dict[str, Any]], +) -> int: + """纯期权记录若 inst_id 出现在对冲腿中,标记排除以免双计.""" + inst_ids = { + str(leg.get("inst_id") or "").strip() + for leg in legs + if str(leg.get("leg_role") or "").startswith("option") and str(leg.get("inst_id") or "").strip() + } + if not inst_ids: + return 0 + n = 0 + for inst_id in inst_ids: + cur = conn.execute( + """ + UPDATE options_review_trades + SET excluded_as_hedge_leg = 1, linked_hedge_plan_id = ? + WHERE source_type = ? AND inst_id = ? AND excluded_as_hedge_leg = 0 + """, + (plan_id, SOURCE_OPTION, inst_id), + ) + n += int(cur.rowcount or 0) + return n + + +def sync_hedge_plans_closed(conn: sqlite3.Connection) -> dict[str, Any]: + """从本地 hedge_plans 导入已结束计划(计划级).""" + init_options_review_tables(conn) + from lib.hedge_plan.hedge_plan_db import get_plan_legs, init_hedge_plan_tables, list_plans + + init_hedge_plan_tables(conn) + plans = list_plans(conn, status="closed", limit=500) + inserted = updated = skipped = 0 + for plan in plans: + legs = get_plan_legs(conn, int(plan["id"])) + action = upsert_hedge_plan_row(conn, plan, legs) + if action == "inserted": + inserted += 1 + elif action == "updated": + updated += 1 + else: + skipped += 1 + last_id = max((int(p["id"]) for p in plans), default=0) + set_sync_state(conn, "hedge_last_sync_at", _now_str()) + set_sync_state(conn, "hedge_last_plan_id", str(last_id)) + return { + "ok": True, + "fetched": len(plans), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + } + + +def sync_all_review_sources( + conn: sqlite3.Connection, + ex: Any | None = None, + *, + options_limit: int = 500, + from_exchange: bool = False, + fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, + format_fn: Optional[Callable[..., dict[str, Any]]] = None, +) -> dict[str, Any]: + """默认只读本地 options_trades + 已结束对冲计划;不访问交易所.""" + init_options_review_tables(conn) + out: dict[str, Any] = {"ok": True, "options": None, "hedge": None} + if from_exchange and ex is not None: + out["options"] = sync_options_from_exchange( + conn, ex, limit=options_limit, fetch_fn=fetch_fn, format_fn=format_fn + ) + else: + out["options"] = sync_options_from_local_trades(conn) + out["hedge"] = sync_hedge_plans_closed(conn) + return out + + +def ensure_local_review_synced( + conn: sqlite3.Connection, + *, + ex: Any | None = None, + backfill_exchange_pnl: bool = True, +) -> dict[str, Any]: + """列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘.""" + if backfill_exchange_pnl and ex is not None: + try: + from lib.exchange.okx_options_lib import fetch_all_option_positions_history + from lib.hedge_plan.hedge_plan_settle_lib import ( + backfill_hedge_option_legs_realized_pnl, + ) + from lib.options.options_monitor_lib import ( + backfill_closed_options_realized_pnl_from_history, + ) + + hist = fetch_all_option_positions_history(ex, limit=200) + backfill_closed_options_realized_pnl_from_history(conn, hist) + backfill_hedge_option_legs_realized_pnl(conn, hist) + except Exception: + pass + return sync_all_review_sources(conn, from_exchange=False) + + +def _row_to_dict(row: Any) -> dict[str, Any]: + return dict(row) if row is not None else {} + + +def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -> dict[str, Any]: + out = dict(row) + out["source_label"] = SOURCE_LABELS.get(str(out.get("source_type") or ""), out.get("source_type")) + out["is_hedge"] = str(out.get("source_type") or "") in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS) + legs = [] + if out.get("legs_json"): + try: + legs = json.loads(str(out["legs_json"])) + except (TypeError, ValueError, json.JSONDecodeError): + legs = [] + out["legs"] = legs if isinstance(legs, list) else [] + out["reviewed"] = bool(entry) + if entry: + out["entry"] = dict(entry) + out["entry"]["images"] = parse_options_review_images_json(entry.get("images_json")) + out["strategy_tag"] = entry.get("strategy_tag") + out["direction_view"] = entry.get("direction_view") + out["entry_logic"] = entry.get("entry_logic") + out["result_tag"] = entry.get("result_tag") + out["reviewed_at"] = entry.get("reviewed_at") or entry.get("updated_at") + else: + out["entry"] = None + out["strategy_tag"] = None + out["direction_view"] = None + out["entry_logic"] = None + out["result_tag"] = None + out["reviewed_at"] = None + return out + + +def _review_search_tokens(q: str) -> list[str]: + """自由搜索词:BTCUSDT 同时匹配 BTC / BTCUSDT.""" + raw = str(q or "").strip() + if not raw: + return [] + tokens = [raw] + u = raw.upper() + for suf in ("-USDT", "-USD", "-USDC", "USDT", "USD", "USDC"): + if u.endswith(suf) and len(u) > len(suf): + base = u[: -len(suf)].rstrip("-_") + if base and base not in {t.upper() for t in tokens}: + tokens.append(base) + break + return tokens + + +def _review_trades_filters( + *, + source_type: str | None = None, + underlying: str | None = None, + opt_type: str | None = None, + strategy_tag: str | None = None, + q: str | None = None, + reviewed: str | None = None, + include_hedge_legs: bool = False, + closed_from: str | None = None, + closed_to: str | None = None, +) -> tuple[str, list[Any]]: + wheres: list[str] = [] + args: list[Any] = [] + if source_type and source_type in SOURCE_TYPES: + wheres.append("t.source_type=?") + args.append(source_type) + if underlying: + wheres.append("UPPER(COALESCE(t.underlying,''))=?") + args.append(underlying.strip().upper()) + if opt_type: + ot = opt_type.strip().upper() + if ot in ("C", "P", "CALL", "PUT"): + if ot.startswith("C"): + ot = "C" + elif ot.startswith("P"): + ot = "P" + wheres.append( + """( + UPPER(COALESCE(t.opt_type,''))=? + OR ( + t.legs_json IS NOT NULL + AND t.legs_json LIKE '%' || '"opt_type":"' || ? || '%' + ) + )""" + ) + args.extend([ot, ot]) + if not include_hedge_legs: + wheres.append("COALESCE(t.excluded_as_hedge_leg,0)=0") + if closed_from: + wheres.append("COALESCE(t.closed_at,'')>=?") + args.append(closed_from) + if closed_to: + wheres.append("COALESCE(t.closed_at,'')<=?") + args.append(closed_to) + # 兼容旧参数:精确策略标签;前端已改用 q 模糊搜索 + if strategy_tag and not q: + wheres.append("UPPER(COALESCE(e.strategy_tag,''))=UPPER(?)") + args.append(strategy_tag) + search_tokens = _review_search_tokens(q or "") + if search_tokens: + token_ors: list[str] = [] + for tok in search_tokens: + like = f"%{tok}%" + token_ors.append( + """( + UPPER(COALESCE(t.underlying,'')) LIKE UPPER(?) + OR UPPER(COALESCE(t.inst_id,'')) LIKE UPPER(?) + OR UPPER(COALESCE(t.legs_json,'')) LIKE UPPER(?) + OR UPPER(COALESCE(e.strategy_tag,'')) LIKE UPPER(?) + OR UPPER(COALESCE(e.result_tag,'')) LIKE UPPER(?) + )""" + ) + args.extend([like, like, like, like, like]) + wheres.append("(" + " OR ".join(token_ors) + ")") + if reviewed == "1" or reviewed == "yes": + wheres.append("e.id IS NOT NULL") + elif reviewed == "0" or reviewed == "no": + wheres.append("e.id IS NULL") + where = (" WHERE " + " AND ".join(wheres)) if wheres else "" + return where, args + + +def count_review_trades( + conn: sqlite3.Connection, + *, + source_type: str | None = None, + underlying: str | None = None, + opt_type: str | None = None, + strategy_tag: str | None = None, + q: str | None = None, + reviewed: str | None = None, + include_hedge_legs: bool = False, + closed_from: str | None = None, + closed_to: str | None = None, +) -> int: + init_options_review_tables(conn) + where, args = _review_trades_filters( + source_type=source_type, + underlying=underlying, + opt_type=opt_type, + strategy_tag=strategy_tag, + q=q, + reviewed=reviewed, + include_hedge_legs=include_hedge_legs, + closed_from=closed_from, + closed_to=closed_to, + ) + row = conn.execute( + f""" + SELECT COUNT(*) AS c + FROM options_review_trades t + LEFT JOIN options_review_entries e ON e.trade_id = t.id + {where} + """, + args, + ).fetchone() + return int(row["c"] if row else 0) + + +def list_review_trades( + conn: sqlite3.Connection, + *, + source_type: str | None = None, + underlying: str | None = None, + opt_type: str | None = None, + strategy_tag: str | None = None, + q: str | None = None, + reviewed: str | None = None, + include_hedge_legs: bool = False, + closed_from: str | None = None, + closed_to: str | None = None, + limit: int = 200, + offset: int = 0, +) -> list[dict[str, Any]]: + init_options_review_tables(conn) + where, args = _review_trades_filters( + source_type=source_type, + underlying=underlying, + opt_type=opt_type, + strategy_tag=strategy_tag, + q=q, + reviewed=reviewed, + include_hedge_legs=include_hedge_legs, + closed_from=closed_from, + closed_to=closed_to, + ) + rows = conn.execute( + f""" + SELECT t.*, e.id AS entry_id, e.strategy_tag AS e_strategy_tag, + e.direction_view, e.entry_logic, e.exit_reason, e.followed_plan, + e.mistake_tags, e.result_tag, e.note, e.images_json, e.image, + e.reviewed_at, e.updated_at + FROM options_review_trades t + LEFT JOIN options_review_entries e ON e.trade_id = t.id + {where} + ORDER BY COALESCE(t.closed_at, t.opened_at, '') DESC, t.id DESC + LIMIT ? OFFSET ? + """, + [*args, int(limit), int(offset)], + ).fetchall() + out: list[dict[str, Any]] = [] + for r in rows: + d = _row_to_dict(r) + entry = None + if d.get("entry_id"): + entry = { + "id": d.pop("entry_id", None), + "strategy_tag": d.pop("e_strategy_tag", None), + "direction_view": d.pop("direction_view", None), + "entry_logic": d.pop("entry_logic", None), + "exit_reason": d.pop("exit_reason", None), + "followed_plan": d.pop("followed_plan", None), + "mistake_tags": d.pop("mistake_tags", None), + "result_tag": d.pop("result_tag", None), + "note": d.pop("note", None), + "images_json": d.pop("images_json", None), + "image": d.pop("image", None), + "reviewed_at": d.pop("reviewed_at", None), + "updated_at": d.pop("updated_at", None), + } + else: + for k in ( + "entry_id", + "e_strategy_tag", + "direction_view", + "entry_logic", + "exit_reason", + "followed_plan", + "mistake_tags", + "result_tag", + "note", + "images_json", + "image", + "reviewed_at", + "updated_at", + ): + d.pop(k, None) + out.append(enrich_trade_row(d, entry)) + return out + + +def get_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] | None: + init_options_review_tables(conn) + row = conn.execute( + "SELECT * FROM options_review_trades WHERE id=?", (int(trade_id),) + ).fetchone() + if not row: + return None + entry_row = conn.execute( + "SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),) + ).fetchone() + entry = _row_to_dict(entry_row) if entry_row else None + return enrich_trade_row(_row_to_dict(row), entry) + + +def save_review_entry( + conn: sqlite3.Connection, + trade_id: int, + payload: dict[str, Any], +) -> dict[str, Any]: + """保存/更新人工复盘;不影响 trades 快照字段.""" + init_options_review_tables(conn) + trade = conn.execute( + "SELECT id FROM options_review_trades WHERE id=?", (int(trade_id),) + ).fetchone() + if not trade: + return {"ok": False, "msg": "交易不存在"} + images = payload.get("images") + if images is None and payload.get("images_json") is not None: + images = parse_options_review_images_json(payload.get("images_json")) + if not isinstance(images, list): + images = [] + images_json = images_json_dumps(images) + primary = None + if images: + primary = str(images[0].get("file") or "").strip() or None + fields = { + "strategy_tag": str(payload.get("strategy_tag") or "").strip() or None, + "direction_view": str(payload.get("direction_view") or "").strip() or None, + "entry_logic": str(payload.get("entry_logic") or "").strip() or None, + "exit_reason": str(payload.get("exit_reason") or "").strip() or None, + "followed_plan": str(payload.get("followed_plan") or "").strip() or None, + "mistake_tags": str(payload.get("mistake_tags") or "").strip() or None, + "result_tag": str(payload.get("result_tag") or "").strip() or None, + "note": str(payload.get("note") or "").strip() or None, + "images_json": images_json, + "image": primary or (str(payload.get("image") or "").strip() or None), + "updated_at": _now_str(), + } + existing = conn.execute( + "SELECT id, reviewed_at FROM options_review_entries WHERE trade_id=?", + (int(trade_id),), + ).fetchone() + if existing: + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute( + f"UPDATE options_review_entries SET {sets} WHERE trade_id=?", + [*fields.values(), int(trade_id)], + ) + else: + fields["trade_id"] = int(trade_id) + fields["reviewed_at"] = _now_str() + cols = list(fields.keys()) + conn.execute( + f"INSERT INTO options_review_entries ({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})", + [fields[c] for c in cols], + ) + return {"ok": True, "trade": get_review_trade(conn, int(trade_id))} + + +def delete_review_entry(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]: + init_options_review_tables(conn) + entry = conn.execute( + "SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),) + ).fetchone() + if not entry: + return {"ok": False, "msg": "无复盘记录"} + conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (int(trade_id),)) + return {"ok": True, "entry": _row_to_dict(entry)} + + +def _hold_bucket(sec: Optional[int]) -> str: + if sec is None: + return "未知" + for label, lo, hi in HOLD_BUCKETS: + if sec >= lo and (hi is None or sec < hi): + return label + return "未知" + + +def _group_stats(rows: list[dict[str, Any]], key_fn) -> list[dict[str, Any]]: + buckets: dict[str, dict[str, Any]] = {} + for row in rows: + key = str(key_fn(row) or "未填") + b = buckets.setdefault( + key, + {"key": key, "count": 0, "wins": 0, "losses": 0, "pnl_sum": 0.0, "hold_sum": 0.0, "hold_n": 0}, + ) + pnl = _safe_float(row.get("realized_pnl_total")) + if pnl is None: + continue + b["count"] += 1 + b["pnl_sum"] = round(b["pnl_sum"] + pnl, 4) + if pnl > 0: + b["wins"] += 1 + elif pnl < 0: + b["losses"] += 1 + hs = row.get("hold_seconds") + if hs is not None: + try: + b["hold_sum"] += float(hs) + b["hold_n"] += 1 + except (TypeError, ValueError): + pass + out = [] + for b in buckets.values(): + c = b["count"] + out.append( + { + "key": b["key"], + "count": c, + "wins": b["wins"], + "losses": b["losses"], + "win_rate": round(b["wins"] / c * 100, 2) if c else 0, + "pnl_sum": round(b["pnl_sum"], 4), + "avg_pnl": round(b["pnl_sum"] / c, 4) if c else None, + "avg_hold_sec": round(b["hold_sum"] / b["hold_n"], 1) if b["hold_n"] else None, + } + ) + out.sort(key=lambda x: abs(float(x.get("pnl_sum") or 0)), reverse=True) + return out + + +def compute_review_stats( + conn: sqlite3.Connection, + *, + source_type: str | None = None, + underlying: str | None = None, + include_hedge_legs: bool = False, + closed_from: str | None = None, + closed_to: str | None = None, + require_strategy: bool = False, +) -> dict[str, Any]: + rows = list_review_trades( + conn, + source_type=source_type, + underlying=underlying, + include_hedge_legs=include_hedge_legs, + closed_from=closed_from, + closed_to=closed_to, + limit=5000, + offset=0, + ) + if require_strategy: + rows = [r for r in rows if str(r.get("strategy_tag") or "").strip()] + + wins = losses = reviewed = 0 + pnl_sum = 0.0 + hold_vals: list[float] = [] + for r in rows: + if r.get("reviewed"): + reviewed += 1 + pnl = _safe_float(r.get("realized_pnl_total")) + if pnl is None: + continue + pnl_sum += pnl + if pnl > 0: + wins += 1 + elif pnl < 0: + losses += 1 + if r.get("hold_seconds") is not None: + hold_vals.append(float(r["hold_seconds"])) + + total = wins + losses + kpi = { + "total": len(rows), + "pnl_count": total, + "reviewed": reviewed, + "review_rate": round(reviewed / len(rows) * 100, 2) if rows else 0, + "wins": wins, + "losses": losses, + "win_rate": round(wins / total * 100, 2) if total else 0, + "pnl_sum": round(pnl_sum, 4), + "avg_pnl": round(pnl_sum / total, 4) if total else None, + "avg_hold_sec": round(sum(hold_vals) / len(hold_vals), 1) if hold_vals else None, + } + + strategy_rows = [r for r in rows if str(r.get("strategy_tag") or "").strip()] + return { + "ok": True, + "kpi": kpi, + "by_source_type": _group_stats(rows, lambda r: SOURCE_LABELS.get(str(r.get("source_type") or ""), r.get("source_type"))), + "by_underlying": _group_stats(rows, lambda r: r.get("underlying") or "未填"), + "by_opt_type": _group_stats( + [r for r in rows if r.get("source_type") == SOURCE_OPTION], + lambda r: r.get("opt_type") or "未填", + ), + "by_strategy": _group_stats(strategy_rows, lambda r: r.get("strategy_tag")), + "by_close_reason": _group_stats( + [r for r in rows if r.get("is_hedge")], + lambda r: r.get("plan_close_reason") or "未填", + ), + "by_hold_bucket": _group_stats(rows, lambda r: _hold_bucket(r.get("hold_seconds"))), + "sync": { + "options_last_sync_at": get_sync_state(conn, "options_last_sync_at"), + "hedge_last_sync_at": get_sync_state(conn, "hedge_last_sync_at"), + "hedge_last_plan_id": get_sync_state(conn, "hedge_last_plan_id"), + }, + } diff --git a/lib/options/options_review_register.py b/lib/options/options_review_register.py new file mode 100644 index 0000000..3a387a3 --- /dev/null +++ b/lib/options/options_review_register.py @@ -0,0 +1,310 @@ +"""OKX 期权复盘模块:Flask 路由注册(含对冲计划级复盘).""" +from __future__ import annotations + +import os +from typing import Any + +from flask import Flask, jsonify, request, send_file +from jinja2 import ChoiceLoader, FileSystemLoader +from werkzeug.utils import secure_filename + +from lib.options.options_review_db import SOURCE_TYPES, init_options_review_tables +from lib.options.options_review_images_lib import ( + OPTIONS_REVIEW_UPLOAD_TFS, + normalize_options_review_draft_id, + options_review_image_paths, + options_review_upload_dir, + save_options_review_slot_file, +) +from lib.options.options_review_lib import ( + SOURCE_LABELS, + compute_review_stats, + count_review_trades, + delete_review_entry, + ensure_local_review_synced, + get_review_trade, + hide_review_trade, + list_review_trades, + save_review_entry, +) + + +def _review_source_for_mode(requested: str | None) -> str | None: + """按当前交易模式钳制复盘 source_type;不允许跨模式窥探.""" + try: + from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode + + mode = get_okx_trade_mode() + except Exception: + mode = "options" + allowed = { + "options": "option_spot", + "perp_options": "perp_options", + "options_options": "options_options", + }.get(mode, "option_spot") + req = (requested or "").strip() + if not req: + return allowed + if req == allowed: + return allowed + # 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制 + return allowed + + +def attach_options_review_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "options", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def install_options_review(app: Flask, repo_root: str, app_module: Any) -> None: + attach_options_review_templates(app, repo_root) + cfg = { + "get_db": app_module.get_db, + "login_required": app_module.login_required, + "exchange_options": getattr(app_module, "exchange_options", None), + "render_main_page": app_module.render_main_page, + "upload_folder": getattr(app_module, "UPLOAD_FOLDER", None) + or os.path.join(getattr(app_module, "BASE_DIR", repo_root), "static", "images"), + "options_enabled": bool(getattr(app_module, "OKX_OPTIONS_ENABLED", False)), + "app_module": app_module, + } + app.extensions["options_review_cfg"] = cfg + register_options_review_routes(app, cfg, repo_root) + + +def _require_ex(cfg: dict[str, Any]): + from lib.exchange.okx_options_lib import options_api_ready + + if not cfg.get("options_enabled"): + return None, "期权模块未启用" + ex = cfg.get("exchange_options") + ok, reason = options_api_ready(ex) + if not ok: + return None, reason or "期权 API 未配置" + return ex, "" + + +def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None: + lr = cfg["login_required"] + + @app.route("/options/review") + @lr + def options_review_page(): + from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled + + redir = redirect_to_embed_shell_if_enabled("options_review") + if redir is not None: + return redir + return cfg["render_main_page"]("options_review") + + @app.route("/static/options_review.js") + @lr + def static_options_review_js(): + path = os.path.join(repo_root, "lib", "common", "static", "options_review.js") + if not os.path.isfile(path): + return ("not found", 404) + return send_file(path, mimetype="application/javascript; charset=utf-8") + + @app.route("/static/images/options_journal/") + def static_options_review_image(filename: str): + """截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401).""" + folder = options_review_upload_dir(cfg["upload_folder"]) + safe = os.path.basename(filename or "") + path = os.path.join(folder, safe) + if not os.path.isfile(path): + # 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件 + root = os.path.abspath(cfg["upload_folder"] or "") + alt = os.path.join(root, safe) + if os.path.isfile(alt): + path = alt + else: + return ("not found", 404) + return send_file(path) + + @app.route("/api/options/review/sync", methods=["POST"]) + @lr + def api_options_review_sync(): + """刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算.""" + conn = cfg["get_db"]() + try: + init_options_review_tables(conn) + ex, _err = _require_ex(cfg) + result = ensure_local_review_synced(conn, ex=ex if ex is not None else None) + conn.commit() + return jsonify(result) + finally: + conn.close() + + @app.route("/api/options/review/trades") + @lr + def api_options_review_trades(): + conn = cfg["get_db"]() + try: + # 翻页可跳过同步,仅刷新当前卡片列表 + do_sync = (request.args.get("sync") or "1").strip().lower() not in ( + "0", + "false", + "no", + ) + if do_sync: + ex, _err = _require_ex(cfg) + ensure_local_review_synced(conn, ex=ex if ex is not None else None) + conn.commit() + filt = dict( + source_type=_review_source_for_mode(request.args.get("source_type")), + underlying=(request.args.get("underlying") or "").strip() or None, + opt_type=(request.args.get("opt_type") or "").strip() or None, + strategy_tag=(request.args.get("strategy_tag") or "").strip() or None, + q=(request.args.get("q") or "").strip() or None, + reviewed=(request.args.get("reviewed") or "").strip() or None, + include_hedge_legs=(request.args.get("include_hedge_legs") or "") + .strip() + .lower() + in ("1", "true", "yes"), + closed_from=(request.args.get("closed_from") or "").strip() or None, + closed_to=(request.args.get("closed_to") or "").strip() or None, + ) + limit = min(500, max(1, int(request.args.get("limit") or 200))) + offset = max(0, int(request.args.get("offset") or 0)) + total = count_review_trades(conn, **filt) + items = list_review_trades(conn, **filt, limit=limit, offset=offset) + pages = max(1, (total + limit - 1) // limit) if total else 1 + page = (offset // limit) + 1 if limit else 1 + return jsonify( + { + "ok": True, + "trades": items, + "source_labels": SOURCE_LABELS, + "total": total, + "limit": limit, + "offset": offset, + "page": page, + "pages": pages, + } + ) + finally: + conn.close() + + @app.route("/api/options/review/trades/") + @lr + def api_options_review_trade_detail(trade_id: int): + conn = cfg["get_db"]() + try: + item = get_review_trade(conn, trade_id) + if not item: + return jsonify({"ok": False, "msg": "未找到"}), 404 + return jsonify({"ok": True, "trade": item}) + finally: + conn.close() + + @app.route("/api/options/review/entry", methods=["POST"]) + @lr + def api_options_review_entry_save(): + data = request.get_json(silent=True) or {} + try: + trade_id = int(data.get("trade_id")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "trade_id 无效"}), 400 + conn = cfg["get_db"]() + try: + out = save_review_entry(conn, trade_id, data) + if out.get("ok"): + conn.commit() + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + + @app.route("/api/options/review/trades/", methods=["DELETE"]) + @lr + def api_options_review_trade_hide(trade_id: int): + """从复盘列表删除并持久隐藏(刷新本地源也不会再导入).""" + conn = cfg["get_db"]() + try: + out = hide_review_trade(conn, trade_id) + if out.get("ok"): + entry = out.get("entry") or {} + folder = options_review_upload_dir(cfg["upload_folder"]) + for path in options_review_image_paths(entry, folder): + try: + os.remove(path) + except OSError: + pass + conn.commit() + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + + @app.route("/api/options/review/entry/", methods=["DELETE"]) + @lr + def api_options_review_entry_delete(trade_id: int): + conn = cfg["get_db"]() + try: + out = delete_review_entry(conn, trade_id) + if out.get("ok"): + entry = out.get("entry") or {} + folder = options_review_upload_dir(cfg["upload_folder"]) + for path in options_review_image_paths(entry, folder): + try: + os.remove(path) + except OSError: + pass + conn.commit() + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + + @app.route("/api/options/review/upload_slot", methods=["POST"]) + @lr + def api_options_review_upload_slot(): + draft_id = normalize_options_review_draft_id( + request.form.get("draft_id") if request.form else None + ) + tf = str((request.form.get("tf") if request.form else None) or "").strip() + if not draft_id: + return jsonify({"ok": False, "error": "invalid draft_id"}), 400 + if tf not in OPTIONS_REVIEW_UPLOAD_TFS: + return jsonify({"ok": False, "error": "invalid tf"}), 400 + f = request.files.get("file") if request.files else None + if not f or not getattr(f, "filename", None): + return jsonify({"ok": False, "error": "no file"}), 400 + folder = options_review_upload_dir(cfg["upload_folder"]) + item = save_options_review_slot_file( + f, draft_id, tf, folder, secure_filename_fn=secure_filename + ) + if not item: + return jsonify({"ok": False, "error": "save failed"}), 500 + return jsonify({"ok": True, "tf": tf, "file": item["file"]}) + + @app.route("/api/options/review/stats") + @lr + def api_options_review_stats(): + conn = cfg["get_db"]() + try: + ex, _err = _require_ex(cfg) + ensure_local_review_synced(conn, ex=ex if ex is not None else None) + conn.commit() + stats = compute_review_stats( + conn, + source_type=_review_source_for_mode(request.args.get("source_type")), + underlying=(request.args.get("underlying") or "").strip() or None, + include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower() + in ("1", "true", "yes"), + closed_from=(request.args.get("closed_from") or "").strip() or None, + closed_to=(request.args.get("closed_to") or "").strip() or None, + require_strategy=(request.args.get("require_strategy") or "").strip().lower() + in ("1", "true", "yes"), + ) + stats["source_types"] = list(SOURCE_TYPES) + stats["source_labels"] = SOURCE_LABELS + return jsonify(stats) + finally: + conn.close() diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py new file mode 100644 index 0000000..052c43e --- /dev/null +++ b/lib/options/options_stats_lib.py @@ -0,0 +1,173 @@ +"""期权本地交易统计(胜率 / 盈亏 / 持仓时长).""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages +from lib.options.options_db import init_options_tables + + +def _parse_ts(raw: Any) -> datetime | None: + if raw is None or raw == "": + return None + s = str(raw).strip().replace(" ", "T", 1) + try: + return datetime.fromisoformat(s) + except (TypeError, ValueError): + return None + + +def _hold_seconds(created_at: Any, closed_at: Any) -> float | None: + start = _parse_ts(created_at) + end = _parse_ts(closed_at) + if start is None or end is None: + return None + sec = (end - start).total_seconds() + return sec if sec >= 0 else None + + +def _avg_seconds(values: list[float]) -> float | None: + if not values: + return None + return round(sum(values) / len(values), 1) + + +def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[str, Any]: + """基于期权历史列表(交易所)计算统计.""" + wins: list[float] = [] + losses: list[float] = [] + win_holds: list[float] = [] + loss_holds: list[float] = [] + all_holds: list[float] = [] + open_holds: list[float] = [] + now = datetime.now() + + for row in history: + if row.get("status") == "open": + start = _parse_ts(row.get("created_at")) + if start is not None: + sec = (now - start).total_seconds() + if sec >= 0: + open_holds.append(sec) + continue + pnl_raw = row.get("realized_pnl") + if pnl_raw is None: + continue + try: + pnl = float(pnl_raw) + except (TypeError, ValueError): + continue + hold = _hold_seconds(row.get("created_at"), row.get("closed_at")) + if hold is not None: + all_holds.append(hold) + if pnl > 0: + wins.append(pnl) + if hold is not None: + win_holds.append(hold) + elif pnl < 0: + losses.append(pnl) + if hold is not None: + loss_holds.append(hold) + + total_closed = len(wins) + len(losses) + win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0 + avg_win = sum(wins) / len(wins) if wins else None + avg_loss = sum(losses) / len(losses) if losses else None + + total_profit = round(sum(wins), 4) if wins else 0.0 + total_loss = round(abs(sum(losses)), 4) if losses else 0.0 + net_realized = round(sum(wins) + sum(losses), 4) + return { + "total_closed": total_closed, + "win_count": len(wins), + "loss_count": len(losses), + "win_rate": win_rate, + "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), + "avg_win": round(avg_win, 4) if avg_win is not None else None, + "avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None, + "total_profit": total_profit, + "total_loss": total_loss, + "net_realized_pnl": net_realized, + "avg_hold_sec": _avg_seconds(all_holds), + "avg_win_hold_sec": _avg_seconds(win_holds), + "avg_loss_hold_sec": _avg_seconds(loss_holds), + "open_count": len(open_holds), + "avg_open_hold_sec": _avg_seconds(open_holds), + } + + +def compute_options_stats(get_db) -> dict[str, Any]: + conn = get_db() + try: + init_options_tables(conn) + closed_rows = conn.execute( + """ + SELECT realized_pnl, created_at, closed_at + FROM options_trades + WHERE status = 'closed' AND realized_pnl IS NOT NULL + """ + ).fetchall() + open_rows = conn.execute( + """ + SELECT created_at FROM options_trades WHERE status = 'open' + """ + ).fetchall() + finally: + conn.close() + + wins: list[float] = [] + losses: list[float] = [] + win_holds: list[float] = [] + loss_holds: list[float] = [] + all_holds: list[float] = [] + now = datetime.now() + + for row in closed_rows: + pnl = float(row["realized_pnl"]) + hold = _hold_seconds(row["created_at"], row["closed_at"]) + if hold is not None: + all_holds.append(hold) + if pnl > 0: + wins.append(pnl) + if hold is not None: + win_holds.append(hold) + elif pnl < 0: + losses.append(pnl) + if hold is not None: + loss_holds.append(hold) + + open_holds: list[float] = [] + for row in open_rows: + start = _parse_ts(row["created_at"]) + if start is None: + continue + sec = (now - start).total_seconds() + if sec >= 0: + open_holds.append(sec) + + total_closed = len(wins) + len(losses) + win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0 + avg_win = sum(wins) / len(wins) if wins else None + avg_loss = sum(losses) / len(losses) if losses else None + + total_profit = round(sum(wins), 4) if wins else 0.0 + total_loss = round(abs(sum(losses)), 4) if losses else 0.0 + net_realized = round(sum(wins) + sum(losses), 4) + return { + "total_closed": total_closed, + "win_count": len(wins), + "loss_count": len(losses), + "win_rate": win_rate, + "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), + "avg_win": round(avg_win, 4) if avg_win is not None else None, + "avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None, + "total_profit": total_profit, + "total_loss": total_loss, + "net_realized_pnl": net_realized, + "avg_hold_sec": _avg_seconds(all_holds), + "avg_win_hold_sec": _avg_seconds(win_holds), + "avg_loss_hold_sec": _avg_seconds(loss_holds), + "open_count": len(open_holds), + "avg_open_hold_sec": _avg_seconds(open_holds), + } diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py new file mode 100644 index 0000000..ecbb8c6 --- /dev/null +++ b/lib/options/options_target_lib.py @@ -0,0 +1,496 @@ +"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算).""" +from __future__ import annotations + +import sqlite3 +import time +from typing import Any, Callable + +from lib.options.options_db import init_options_tables +from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]: + from lib.exchange.okx_options_lib import option_fields_from_inst_id + + inst_id = str(pos.get("instId") or pos.get("inst_id") or "") + mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark")) + if mark is None: + mark = fetch_option_mark_px(ex, inst_id) + opt_type = pos.get("optType") or (quote or {}).get("opt_type") + strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike")) + if not opt_type or strike is None: + pt, ps = option_fields_from_inst_id(inst_id) + opt_type = opt_type or pt + if strike is None: + strike = ps + idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px")) + return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx) + + +def ensure_target_tables(conn: sqlite3.Connection) -> None: + init_options_tables(conn) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_target_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id TEXT NOT NULL, + underlying TEXT, + opt_type TEXT, + target_index REAL NOT NULL, + trade_id INTEGER, + sheets INTEGER, + status TEXT DEFAULT 'active', + trigger_idx REAL, + close_ord_id TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + triggered_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status + ON options_target_monitors(status) + """ + ) + + +def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool: + """Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓.""" + ot = (opt_type or "").strip().upper() + if ot == "P": + return index_px <= target_index + return index_px >= target_index + + +def upsert_target_monitor( + conn: sqlite3.Connection, + *, + inst_id: str, + target_index: float, + underlying: str | None = None, + opt_type: str | None = None, + trade_id: int | None = None, + sheets: int | None = None, +) -> dict[str, Any]: + ensure_target_tables(conn) + inst_id = (inst_id or "").strip() + if not inst_id: + return {"ok": False, "msg": "缺少 inst_id"} + if target_index is None or float(target_index) <= 0: + return {"ok": False, "msg": "目标位无效"} + target_index = float(target_index) + row = conn.execute( + """ + SELECT id FROM options_target_monitors + WHERE inst_id = ? AND status IN ('active', 'closing') + ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC + LIMIT 1 + """, + (inst_id,), + ).fetchone() + if row: + conn.execute( + """ + UPDATE options_target_monitors + SET target_index = ?, + underlying = COALESCE(?, underlying), + opt_type = COALESCE(?, opt_type), + trade_id = COALESCE(?, trade_id), + sheets = COALESCE(?, sheets), + status = 'active', + trigger_idx = NULL, + close_ord_id = NULL, + message = NULL, + triggered_at = NULL + WHERE id = ? + """, + (target_index, underlying, opt_type, trade_id, sheets, int(row["id"])), + ) + mon_id = int(row["id"]) + # 同一合约其他进行中的委托取消,避免双轨触发重复推送 + conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '被新目标位覆盖' + WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing') + """, + (inst_id, mon_id), + ) + else: + cur = conn.execute( + """ + INSERT INTO options_target_monitors + (inst_id, underlying, opt_type, target_index, trade_id, sheets, status) + VALUES (?, ?, ?, ?, ?, ?, 'active') + """, + (inst_id, underlying, opt_type, target_index, trade_id, sheets), + ) + mon_id = int(cur.lastrowid) + return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index} + + +def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int: + ensure_target_tables(conn) + if monitor_id is not None: + cur = conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '手动取消' + WHERE id = ? AND status IN ('active', 'closing') + """, + (int(monitor_id),), + ) + return int(cur.rowcount or 0) + if inst_id: + cur = conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '手动取消' + WHERE inst_id = ? AND status IN ('active', 'closing') + """, + (inst_id.strip(),), + ) + return int(cur.rowcount or 0) + return 0 + + +def _row_to_target(r: sqlite3.Row) -> dict[str, Any]: + return { + "id": int(r["id"]), + "inst_id": r["inst_id"], + "underlying": r["underlying"], + "opt_type": r["opt_type"], + "target_index": _safe_float(r["target_index"]), + "trade_id": r["trade_id"], + "sheets": r["sheets"], + "status": r["status"], + "message": r["message"], + "created_at": r["created_at"], + } + + +def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: + ensure_target_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, + status, message, created_at + FROM options_target_monitors + WHERE status = 'active' + ORDER BY id DESC + """ + ).fetchall() + return [_row_to_target(r) for r in rows] + + +def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: + """已挂出平仓单、等待成交的目标(不再重复推送微信).""" + ensure_target_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, + status, message, created_at + FROM options_target_monitors + WHERE status = 'closing' + ORDER BY id DESC + """ + ).fetchall() + return [_row_to_target(r) for r in rows] + + +def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: + """UI/持仓挂载:active 与 closing 都算进行中.""" + out: dict[str, dict[str, Any]] = {} + for t in list_closing_targets(conn) + list_active_targets(conn): + inst = str(t.get("inst_id") or "") + if inst and inst not in out: + out[inst] = t + return out + + +def mark_monitor( + conn: sqlite3.Connection, + monitor_id: int, + *, + status: str, + trigger_idx: float | None = None, + close_ord_id: str | None = None, + message: str | None = None, +) -> None: + conn.execute( + """ + UPDATE options_target_monitors + SET status = ?, + trigger_idx = COALESCE(?, trigger_idx), + close_ord_id = COALESCE(?, close_ord_id), + message = COALESCE(?, message), + triggered_at = CASE + WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP) + ELSE triggered_at + END + WHERE id = ? + """, + (status, trigger_idx, close_ord_id, message, status, int(monitor_id)), + ) + + +def cancel_orphans_without_position( + conn: sqlite3.Connection, + *, + live_inst_ids: set[str], +) -> int: + """持仓已消失的目标委托标记为 expired(到期/已平),不挂止损.""" + ensure_target_tables(conn) + rows = list_active_targets(conn) + list_closing_targets(conn) + n = 0 + for t in rows: + inst = str(t.get("inst_id") or "") + if inst and inst not in live_inst_ids: + mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束") + n += 1 + return n + + +def _commit_monitor(conn: sqlite3.Connection) -> None: + """状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送.""" + try: + conn.commit() + except Exception: + pass + + +def close_option_by_bid_depth( + cfg: dict[str, Any], + ex: Any, + inst_id: str, + *, + sheets: int | None = None, +) -> dict[str, Any]: + """目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性).""" + from lib.options.options_close_exec_lib import close_option_by_bid1 + + return close_option_by_bid1( + cfg, + ex, + inst_id, + sheets=sheets, + require_recycle_gate=True, + signal_note="目标位平仓", + ) + + + +def _notify_target_close( + cfg: dict[str, Any] | None, + send_wechat: Callable[[str], None] | None, + *, + account_label: str, + inst_id: str, + target: float, + idx: float, + result: dict[str, Any], + conn: Any = None, +) -> None: + """目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" + if result.get("fully_closed") or result.get("already_flat"): + if cfg is not None: + try: + from lib.options.options_notify_lib import notify_options_close + + notify_options_close( + cfg, + conn, + inst_id=inst_id, + reason="目标位平仓", + sheets=result.get("submitted_sheets"), + premium_received=result.get("premium_received"), + close_quote=result.get("locked_bid_px") or result.get("bid"), + target_index=target, + trigger_idx=idx, + ) + return + except Exception: + pass + if not send_wechat: + return + try: + send_wechat( + "\n".join( + [ + "【OKX期权·目标位平仓】", + f"账户:{account_label}", + f"合约:{inst_id}", + f"目标指数:{target:g}", + f"触发指数:{idx:g}", + f"提交张数:{result.get('submitted_sheets') or '—'}", + f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC", + f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}", + ] + ) + ) + except Exception: + pass + + +def _result_fully_done(result: dict[str, Any]) -> bool: + if result.get("already_flat"): + return True + if result.get("fully_closed"): + return True + remaining = result.get("remaining_sheets") + if remaining is not None and int(remaining) <= 0 and result.get("ok"): + return True + return False + + +def run_options_target_closes( + conn: sqlite3.Connection, + positions: list[dict[str, Any]], + *, + close_fn: Callable[[str], dict[str, Any]], + index_fn: Callable[[dict[str, Any]], float | None] | None = None, + send_wechat: Callable[[str], None] | None = None, + account_label: str = "OKX期权", + cfg: dict[str, Any] | None = None, +) -> int: + """ + 扫描 active 目标委托;指数到位后限价平仓. + 状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送. + 未完全成交进入 closing,仅重试平仓不再推送. + 返回本次新触发(并推送)的条数. + """ + ensure_target_tables(conn) + pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} + live_ids = {k for k in pos_by_inst if k} + hedge_managed: set[str] = set() + try: + from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables + + init_hedge_plan_tables(conn) + hedge_managed = active_hedge_option_inst_ids(conn) + except Exception: + # fail-closed:本轮不执行任何单独目标平仓,避免误平对冲腿 + return 0 + cancel_orphans_without_position(conn, live_inst_ids=live_ids) + _commit_monitor(conn) + + # 先处理已挂单等待成交的,绝不再发微信 + for mon in list_closing_targets(conn): + inst_id = str(mon.get("inst_id") or "") + if not inst_id: + continue + if inst_id in hedge_managed: + mark_monitor( + conn, + int(mon["id"]), + status="expired", + message="已移交对冲计划托管,跳过单独目标平仓", + ) + _commit_monitor(conn) + continue + if inst_id not in pos_by_inst: + mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平") + _commit_monitor(conn) + continue + result = close_fn(inst_id) + idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx")) + if result.get("already_flat") or _result_fully_done(result): + mark_monitor( + conn, + int(mon["id"]), + status="triggered", + trigger_idx=idx, + close_ord_id=result.get("close_ord_id"), + message="目标位限价平仓完成", + ) + _commit_monitor(conn) + continue + mark_monitor( + conn, + int(mon["id"]), + status="closing", + trigger_idx=idx, + close_ord_id=result.get("close_ord_id"), + message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"), + ) + _commit_monitor(conn) + + triggered = 0 + for mon in list_active_targets(conn): + inst_id = str(mon.get("inst_id") or "") + target = _safe_float(mon.get("target_index")) + if not inst_id or target is None: + continue + if inst_id in hedge_managed: + mark_monitor( + conn, + int(mon["id"]), + status="expired", + message="已移交对冲计划托管,跳过单独目标平仓", + ) + _commit_monitor(conn) + continue + pos = pos_by_inst.get(inst_id) + if not pos: + continue + if index_fn is not None: + idx = index_fn(pos) + else: + idx = _safe_float(pos.get("idx_px") or pos.get("idxPx")) + if idx is None: + continue + opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType") + if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target): + continue + + result = close_fn(inst_id) + if result.get("already_flat"): + mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") + _commit_monitor(conn) + continue + if not result.get("ok"): + mark_monitor( + conn, + int(mon["id"]), + status="active", + trigger_idx=idx, + message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"), + ) + _commit_monitor(conn) + continue + + done = _result_fully_done(result) + status = "triggered" if done else "closing" + mark_monitor( + conn, + int(mon["id"]), + status=status, + trigger_idx=idx, + close_ord_id=result.get("close_ord_id"), + message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交", + ) + # 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信 + _commit_monitor(conn) + triggered += 1 + _notify_target_close( + cfg, + send_wechat, + account_label=account_label, + inst_id=inst_id, + target=target, + idx=idx, + result=result, + conn=conn, + ) + return triggered diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html new file mode 100644 index 0000000..8ff17f9 --- /dev/null +++ b/lib/options/templates/options_panel.html @@ -0,0 +1,353 @@ +
    + {% set compound_on = options_compound_full_enabled if options_compound_full_enabled is defined else true %} + {% if not options_enabled %} +
    期权未启用:请在 crypto_monitor_okx/.env 设置 OKX_OPTIONS_ENABLED=trueOKX_API_*(永续与期权共用),然后 pm2 restart crypto_okx --update-env.
    + {% endif %} + {% if options_enabled and options_open_allowed is defined and not options_open_allowed %} +
    当前交易模式为对冲(永期/期期),不可单独开期权;持仓可在此查看/平仓.切换请到 env「交易模式」.
    + {% endif %} + +
    +
    +

    期权下单{% if options_open_allowed is defined and not options_open_allowed %} (对冲模式已禁用开仓){% endif %}

    +
    + 开仓规则说明 +
    +

    报价单位为每 1 ETH/BTC;1 张 = 0.01。默认选中最近一期到期,可手动改。

    +
      +
    • 列表含卖一/买一;T 型仅卖一(买方开仓),中间为跨式双买测算。
    • +
    • 环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 ~ 亦不显示)。
    • +
    • 开仓只认真实卖一价且卖一深度≥1;无深度时面板显示参考标记价并禁用买入。
    • +
    • 链展示近 14 日到期;列表与 T 型默认平值 + 实值3档 + 虚值3档,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。
    • +
    • 「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 {{ '%.2f'|format(options_trade_budget|default(10)|float) }}),再 × 预算缓冲 {{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }} 算张数(env 可改)。
    • +
    • 「全仓复利」用期权交易户全部可用×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。
    • +
    • 翻倍出场:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。
    • +
    • 平仓仅买一限价,详见说明文档。
    • +
    +

    打开《期权开平仓与监控说明》

    +
    +
    +
    + + + + + + + + + + + + + + + + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    行权价类型合约卖一/张杠杆买一/张到期平衡距平衡操作
    请选择到期日
    +
    + +
    + +
    +
    +

    持仓

    + +
    +
    + + + + +
    +
    +
    + +
    +
    暂无持仓
    +
    +
    +
    + 买一平仓规则说明 +
    +

    平仓前重新读盘口并校验有效流动性;市价平仓已禁用。

    +
      +
    • 本轮只锁买一:张数 = min(持仓, 买一深度),限价 = 当场买一。
    • +
    • 买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。
    • +
    • 手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。
    • +
    • 翻倍出场:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。
    • +
    • 全程 reduceOnly 限价卖,不吃买二及以下、不走市价。
    • +
    +

    打开《期权开平仓与监控说明》

    +
    +
    +
    + + + +
    +
    +
    +
    + + diff --git a/lib/options/templates/options_review_panel.html b/lib/options/templates/options_review_panel.html new file mode 100644 index 0000000..f67311b --- /dev/null +++ b/lib/options/templates/options_review_panel.html @@ -0,0 +1,419 @@ +{# OKX 期权复盘:交易记录 → 复盘表单 → 复盘记录 → 统计 #} +
    + {% if not options_enabled %} +
    期权未启用:请设置 OKX_OPTIONS_ENABLED=true 后重启.
    + {% endif %} + + + +
    +

    期权复盘

    + + +
    + + {# Tab + 筛选:放在各内容卡片上方,全局作用于下方列表/统计 #} +
    +
    + {% if okx_trade_mode|default('options') == 'options' %} + + {% elif okx_trade_mode == 'options_options' %} + + {% elif okx_trade_mode == 'perp_options' %} + + {% else %} + + {% endif %} +
    +
    + + + + + + + {% if okx_trade_mode|default('options') == 'options' %} + + {% endif %} +
    +
    + + {# 1. 交易记录 #} +
    +
    + +
    +
    期权交易记录
    +

    点「复盘」填写表单;已复盘仍保留在此,也可在下方查看详情。

    +
    +
    +
    + + + + + + + + + + + + + + + +
    类型标的/合约盈亏开仓时间平仓时间持有操作
    加载中…
    +
    +
    + + 第 1 / 1 页 + +
    +
    + + {# 2. 复盘上传(默认隐藏) #} + + + {# 3. 已复盘记录 #} +
    +
    + +
    +
    复盘记录
    +

    已保存的复盘内容,点一行查看详情与截图。

    +
    +
    +
    + + + + + + + + + + + + + + + + + + + +
    类型标的/合约方向盈亏开仓时间平仓时间持仓时长策略入场逻辑结果复盘时间
    加载中…
    +
    +
    + + 第 1 / 1 页 + +
    +
    + + {# 详情 / 放大 #} + + + + {# 4. 统计 #} +
    +
    + +
    +
    统计
    +

    跟随上方 Tab 与筛选条件汇总。

    +
    +
    +
    +
    +
    +
    + + diff --git a/lib/options/templates/options_settings_panel.html b/lib/options/templates/options_settings_panel.html new file mode 100644 index 0000000..709408a --- /dev/null +++ b/lib/options/templates/options_settings_panel.html @@ -0,0 +1,3 @@ +{# 期权设置脚本挂载点(卡片在 settings_panel 中拆分) #} + + diff --git a/lib/options/templates/options_settings_swap.html b/lib/options/templates/options_settings_swap.html new file mode 100644 index 0000000..418144b --- /dev/null +++ b/lib/options/templates/options_settings_swap.html @@ -0,0 +1,16 @@ +
    +

    账户资金账户:USDT ↔ USDC 现货市价单.

    +
    + + + + + +
    +
    +
    diff --git a/lib/options/templates/options_settings_transfer.html b/lib/options/templates/options_settings_transfer.html new file mode 100644 index 0000000..48d6990 --- /dev/null +++ b/lib/options/templates/options_settings_transfer.html @@ -0,0 +1,24 @@ +
    +
    账户内划转
    +
    + + + + + + + +
    +
    +
    diff --git a/lib/paths.py b/lib/paths.py new file mode 100644 index 0000000..7a8c0cd --- /dev/null +++ b/lib/paths.py @@ -0,0 +1,23 @@ +"""Repository path helpers for lib/ assets.""" +from __future__ import annotations + +from pathlib import Path + +LIB_DIR = Path(__file__).resolve().parent +REPO_ROOT = LIB_DIR.parent + + +def strategy_templates_dir(repo_root: str | Path | None = None) -> str: + root = Path(repo_root) if repo_root is not None else REPO_ROOT + return str(root / "lib" / "strategy" / "templates") + + +def embed_templates_dir(repo_root: str | Path | None = None) -> str: + root = Path(repo_root) if repo_root is not None else REPO_ROOT + return str(root / "lib" / "instance" / "templates") + + +def common_static_dir(repo_root: str | Path | None = None) -> str: + root = Path(repo_root) if repo_root is not None else REPO_ROOT + return str(root / "lib" / "common" / "static") + diff --git a/lib/sim/__init__.py b/lib/sim/__init__.py new file mode 100644 index 0000000..3ccd57e --- /dev/null +++ b/lib/sim/__init__.py @@ -0,0 +1,11 @@ +"""本地模拟资金撮合(公开行情 + SQLite 钱包).""" + +from __future__ import annotations + +__all__ = ["install_sim_trading"] + + +def install_sim_trading(*args, **kwargs): + from lib.sim.register import install_sim_trading as _install + + return _install(*args, **kwargs) diff --git a/lib/sim/broker_lib.py b/lib/sim/broker_lib.py new file mode 100644 index 0000000..6beb577 --- /dev/null +++ b/lib/sim/broker_lib.py @@ -0,0 +1,528 @@ +"""模拟撮合: 用公开行情 bid/ask 成交, 结算到本地钱包.""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any, Callable + +from lib.sim.pricing_lib import option_fill, perp_fill, sim_fee_rate +from lib.sim.wallets_lib import InsufficientFunds, SimWallets + + +def _now() -> str: + return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + + +def _ticker_bid_ask(exchange: Any, symbol: str) -> tuple[float, float]: + t = exchange.fetch_ticker(symbol) + last = t.get("last") + bid = t.get("bid") + ask = t.get("ask") + if bid is None or float(bid) <= 0: + bid = last + if ask is None or float(ask) <= 0: + ask = last + if bid is None or ask is None: + raise RuntimeError(f"无法获取 {symbol} 行情 bid/ask") + return float(bid), float(ask) + + +def _option_bid_ask(exchange_options: Any, inst_id: str) -> tuple[float, float, float]: + """返回 bid, ask, ct_mult.""" + ct_mult = 0.01 + bid = ask = None + try: + from lib.exchange.okx_options_lib import quote_option_contract + + q = quote_option_contract(exchange_options, inst_id) + if q.get("ok"): + bid = q.get("bid") or q.get("mark") + ask = q.get("ask") or q.get("book_ask") or q.get("ref_ask") or q.get("mark") + ct_mult = float(q.get("ct_mult") or 0.01) + except Exception: + pass + if bid is None or ask is None: + rows = exchange_options.public_get_market_ticker({"instId": inst_id}).get("data") or [] + if not rows: + raise RuntimeError(f"无法获取期权行情 {inst_id}") + t = rows[0] + bid = bid or t.get("bidPx") or t.get("markPx") or t.get("last") + ask = ask or t.get("askPx") or t.get("markPx") or t.get("last") + if bid is None or ask is None: + raise RuntimeError(f"期权 {inst_id} 缺少 bid/ask") + return float(bid), float(ask), float(ct_mult) + + +def _contract_size(exchange: Any, symbol: str) -> float: + try: + if hasattr(exchange, "market"): + m = exchange.market(symbol) + return float(m.get("contractSize") or 1) + except Exception: + pass + return 1.0 + + +class SimBroker: + def __init__(self, get_db: Callable) -> None: + self.get_db = get_db + self.wallets = SimWallets(get_db) + + def balances_header(self) -> dict[str, float]: + return self.wallets.view() + + def list_perp_positions(self) -> list[dict[str, Any]]: + conn = self.get_db() + try: + rows = conn.execute( + "SELECT * FROM sim_perp_positions WHERE contracts > 1e-12 ORDER BY id" + ).fetchall() + out = [] + for r in rows: + out.append( + { + "symbol": r["symbol"], + "direction": r["direction"], + "contracts": float(r["contracts"]), + "entry_px": float(r["entry_px"]), + "leverage": int(r["leverage"] or 1), + "margin_usdt": float(r["margin_usdt"] or 0), + "contract_size": float(r["contract_size"] or 1), + } + ) + return out + finally: + conn.close() + + def list_option_positions(self) -> list[dict[str, Any]]: + conn = self.get_db() + try: + rows = conn.execute( + "SELECT * FROM sim_option_positions WHERE sheets > 1e-12 ORDER BY id" + ).fetchall() + out = [] + for r in rows: + out.append( + { + "inst_id": r["inst_id"], + "side": r["side"], + "sheets": float(r["sheets"]), + "entry_px": float(r["entry_px"]), + "ct_mult": float(r["ct_mult"] or 0.01), + "premium_paid_usdc": float(r["premium_paid_usdc"] or 0), + } + ) + return out + finally: + conn.close() + + def get_perp_contracts(self, symbol: str, direction: str) -> float: + conn = self.get_db() + try: + row = conn.execute( + """ + SELECT contracts FROM sim_perp_positions + WHERE symbol=? AND direction=? AND contracts > 1e-12 + """, + (symbol, direction), + ).fetchone() + if not row: + return 0.0 + return float(row["contracts"] if hasattr(row, "keys") else row[0]) + finally: + conn.close() + + def place_perp_market( + self, + exchange: Any, + *, + symbol: str, + direction: str, + contracts: float, + leverage: int, + fee_rate: float | None = None, + stop_loss: Any = None, + take_profit: Any = None, + ) -> dict[str, Any]: + _ = (stop_loss, take_profit) # sim: ignore attachAlgoOrds / tpsl + side = (direction or "").lower().strip() + if side not in ("long", "short"): + raise ValueError("direction 须为 long 或 short") + qty_c = float(contracts) + if qty_c <= 0: + raise ValueError("张数须大于 0") + lev = max(1, int(leverage or 1)) + fr = sim_fee_rate(fee_rate) + bid, ask = _ticker_bid_ask(exchange, symbol) + ct_sz = _contract_size(exchange, symbol) + qty = qty_c * ct_sz + pr = perp_fill(side=side, action="open", bid=bid, ask=ask, qty=qty, fee_rate=fr) + margin = pr.notional / lev + need = margin + pr.fee + try: + self.wallets.debit_trading( + "USDT", + need, + kind="perp_open", + note=f"open {side} {symbol} {qty_c}@{pr.fill_px:.4f}", + ) + except InsufficientFunds as e: + raise RuntimeError(str(e)) from e + + conn = self.get_db() + try: + existing = conn.execute( + "SELECT * FROM sim_perp_positions WHERE symbol=? AND direction=?", + (symbol, side), + ).fetchone() + if existing and float(existing["contracts"] or 0) > 1e-12: + old_c = float(existing["contracts"]) + old_px = float(existing["entry_px"]) + old_m = float(existing["margin_usdt"] or 0) + new_c = old_c + qty_c + entry = (old_px * old_c + pr.fill_px * qty_c) / new_c + conn.execute( + """ + UPDATE sim_perp_positions + SET contracts=?, entry_px=?, leverage=?, margin_usdt=?, contract_size=?, updated_at=? + WHERE symbol=? AND direction=? + """, + (new_c, entry, lev, old_m + margin, ct_sz, _now(), symbol, side), + ) + else: + conn.execute( + """ + INSERT INTO sim_perp_positions( + symbol, direction, contracts, entry_px, leverage, margin_usdt, contract_size, updated_at + ) VALUES (?,?,?,?,?,?,?,?) + ON CONFLICT(symbol, direction) DO UPDATE SET + contracts=excluded.contracts, + entry_px=excluded.entry_px, + leverage=excluded.leverage, + margin_usdt=excluded.margin_usdt, + contract_size=excluded.contract_size, + updated_at=excluded.updated_at + """, + (symbol, side, qty_c, pr.fill_px, lev, margin, ct_sz, _now()), + ) + conn.commit() + finally: + conn.close() + + oid = f"sim-perp-{uuid.uuid4().hex[:16]}" + return { + "id": oid, + "symbol": symbol, + "side": "buy" if side == "long" else "sell", + "amount": qty_c, + "average": pr.fill_px, + "status": "closed", + "info": {"sim": True, "fee": pr.fee, "margin": margin, "fill": pr.to_dict()}, + "tpsl_attached": False, + } + + def close_perp_market( + self, + exchange: Any, + *, + symbol: str, + direction: str, + contracts: float | None = None, + fee_rate: float | None = None, + ) -> dict[str, Any]: + side = (direction or "").lower().strip() + if side not in ("long", "short"): + raise ValueError("direction 须为 long 或 short") + conn = self.get_db() + try: + row = conn.execute( + "SELECT * FROM sim_perp_positions WHERE symbol=? AND direction=?", + (symbol, side), + ).fetchone() + if not row or float(row["contracts"] or 0) <= 1e-12: + raise ValueError("模拟永续无对应持仓") + pos_c = float(row["contracts"]) + entry = float(row["entry_px"]) + margin_all = float(row["margin_usdt"] or 0) + ct_sz = float(row["contract_size"] or 1) + close_c = pos_c if contracts is None else min(pos_c, float(contracts)) + if close_c <= 0: + raise ValueError("平仓张数无效") + fr = sim_fee_rate(fee_rate) + bid, ask = _ticker_bid_ask(exchange, symbol) + qty = close_c * ct_sz + pr = perp_fill(side=side, action="close", bid=bid, ask=ask, qty=qty, fee_rate=fr) + if side == "long": + pnl = (pr.fill_px - entry) * qty + else: + pnl = (entry - pr.fill_px) * qty + release = margin_all * (close_c / pos_c) + credit = release + pnl - pr.fee + remain = pos_c - close_c + if remain <= 1e-12: + conn.execute( + "DELETE FROM sim_perp_positions WHERE symbol=? AND direction=?", + (symbol, side), + ) + else: + conn.execute( + """ + UPDATE sim_perp_positions + SET contracts=?, margin_usdt=?, updated_at=? + WHERE symbol=? AND direction=? + """, + (remain, margin_all - release, _now(), symbol, side), + ) + conn.commit() + finally: + conn.close() + + if credit >= 0: + self.wallets.credit_trading( + "USDT", + credit, + kind="perp_close", + note=f"close {side} {symbol} pnl={pnl:.4f}", + ) + else: + self.wallets.debit_trading( + "USDT", + abs(credit), + kind="perp_close", + note=f"close {side} {symbol} pnl={pnl:.4f}", + ) + + oid = f"sim-perp-close-{uuid.uuid4().hex[:16]}" + return { + "id": oid, + "symbol": symbol, + "side": "sell" if side == "long" else "buy", + "amount": close_c, + "average": pr.fill_px, + "status": "closed", + "info": { + "sim": True, + "fee": pr.fee, + "pnl": pnl, + "released_margin": release, + "fill": pr.to_dict(), + }, + "tpsl_attached": False, + } + + def _store_option_order( + self, + *, + ord_id: str, + inst_id: str, + side: str, + sheets: float, + avg_px: float, + ) -> None: + conn = self.get_db() + try: + conn.execute( + """ + INSERT OR REPLACE INTO sim_option_orders( + ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at + ) VALUES (?,?,?,?,?,'filled',?,?) + """, + (ord_id, inst_id, side, float(sheets), float(avg_px), float(sheets), _now()), + ) + conn.commit() + finally: + conn.close() + + def get_option_order(self, ord_id: str) -> dict[str, Any] | None: + conn = self.get_db() + try: + row = conn.execute( + "SELECT * FROM sim_option_orders WHERE ord_id=?", + (ord_id,), + ).fetchone() + if not row: + return None + return { + "ok": True, + "ord_id": row["ord_id"], + "inst_id": row["inst_id"], + "side": row["side"], + "state": row["state"], + "acc_fill_sz": float(row["acc_fill_sz"]), + "avg_px": float(row["avg_px"]), + "sim": True, + } + finally: + conn.close() + + def place_option_buy( + self, + exchange_options: Any, + *, + inst_id: str, + sheets: int, + price: float | None = None, + fee_rate: float | None = None, + **_kwargs: Any, + ) -> dict[str, Any]: + n = int(sheets) + if n < 1: + return {"ok": False, "msg": "张数至少为 1"} + fr = sim_fee_rate(fee_rate) + bid, ask, ct_mult = _option_bid_ask(exchange_options, inst_id) + if price is not None and float(price) > 0: + # 限价: 用 min(limit, ask) 作为基准卖一近似, 仍走 option_fill 滑点 + ask = min(float(ask), float(price)) if float(price) > 0 else float(ask) + qty = n * ct_mult + pr = option_fill(action="open", bid=bid, ask=ask, qty=qty, fee_rate=fr) + cost = pr.notional + pr.fee + try: + self.wallets.debit_trading( + "USDC", + cost, + kind="option_open", + note=f"buy {inst_id} x{n}@{pr.fill_px}", + ) + except InsufficientFunds as e: + return {"ok": False, "msg": str(e)} + + conn = self.get_db() + try: + existing = conn.execute( + "SELECT * FROM sim_option_positions WHERE inst_id=?", + (inst_id,), + ).fetchone() + if existing and float(existing["sheets"] or 0) > 1e-12: + old_s = float(existing["sheets"]) + old_px = float(existing["entry_px"]) + old_prem = float(existing["premium_paid_usdc"] or 0) + new_s = old_s + n + entry = (old_px * old_s + pr.fill_px * n) / new_s + conn.execute( + """ + UPDATE sim_option_positions + SET sheets=?, entry_px=?, ct_mult=?, premium_paid_usdc=?, updated_at=? + WHERE inst_id=? + """, + (new_s, entry, ct_mult, old_prem + pr.notional, _now(), inst_id), + ) + else: + conn.execute( + """ + INSERT INTO sim_option_positions( + inst_id, side, sheets, entry_px, ct_mult, premium_paid_usdc, updated_at + ) VALUES (?,?,?,?,?,?,?) + ON CONFLICT(inst_id) DO UPDATE SET + sheets=excluded.sheets, + entry_px=excluded.entry_px, + ct_mult=excluded.ct_mult, + premium_paid_usdc=excluded.premium_paid_usdc, + updated_at=excluded.updated_at + """, + (inst_id, "buy", float(n), pr.fill_px, ct_mult, pr.notional, _now()), + ) + conn.commit() + finally: + conn.close() + + ord_id = f"sim-opt-{uuid.uuid4().hex[:16]}" + self._store_option_order( + ord_id=ord_id, inst_id=inst_id, side="buy", sheets=n, avg_px=pr.fill_px + ) + return { + "ok": True, + "data": {"ordId": ord_id, "sCode": "0", "sMsg": "sim filled"}, + "raw": {"sim": True}, + "px": pr.fill_px, + "ord_type": "ioc", + "info": {"sim": True, "fee": pr.fee, "fill": pr.to_dict()}, + } + + def sell_option_close( + self, + exchange_options: Any, + *, + inst_id: str, + sheets: int, + price: float | None = None, + fee_rate: float | None = None, + **_kwargs: Any, + ) -> dict[str, Any]: + n = int(sheets) + if n < 1: + return {"ok": False, "msg": "张数至少为 1"} + conn = self.get_db() + try: + row = conn.execute( + "SELECT * FROM sim_option_positions WHERE inst_id=?", + (inst_id,), + ).fetchone() + if not row or float(row["sheets"] or 0) <= 1e-12: + return {"ok": False, "msg": "模拟期权无对应持仓"} + pos_s = float(row["sheets"]) + ct_mult = float(row["ct_mult"] or 0.01) + close_n = min(pos_s, float(n)) + if close_n <= 0: + return {"ok": False, "msg": "平仓张数无效"} + fr = sim_fee_rate(fee_rate) + bid, ask, _ = _option_bid_ask(exchange_options, inst_id) + if price is not None and float(price) > 0: + bid = max(float(bid), float(price)) if float(price) > 0 else float(bid) + qty = close_n * ct_mult + pr = option_fill(action="close", bid=bid, ask=ask, qty=qty, fee_rate=fr) + credit = pr.notional - pr.fee + remain = pos_s - close_n + if remain <= 1e-12: + conn.execute("DELETE FROM sim_option_positions WHERE inst_id=?", (inst_id,)) + else: + prem = float(row["premium_paid_usdc"] or 0) * (remain / pos_s) + conn.execute( + """ + UPDATE sim_option_positions + SET sheets=?, premium_paid_usdc=?, updated_at=? + WHERE inst_id=? + """, + (remain, prem, _now(), inst_id), + ) + conn.commit() + finally: + conn.close() + + if credit > 0: + self.wallets.credit_trading( + "USDC", + credit, + kind="option_close", + note=f"sell {inst_id} x{close_n}@{pr.fill_px}", + ) + ord_id = f"sim-opt-close-{uuid.uuid4().hex[:16]}" + self._store_option_order( + ord_id=ord_id, inst_id=inst_id, side="sell", sheets=close_n, avg_px=pr.fill_px + ) + return { + "ok": True, + "data": {"ordId": ord_id, "sCode": "0", "sMsg": "sim filled"}, + "raw": {"sim": True}, + "px": pr.fill_px, + "ord_type": "ioc", + "info": {"sim": True, "fee": pr.fee, "fill": pr.to_dict()}, + } + + def option_positions_okx_rows(self) -> list[dict[str, Any]]: + """对齐 OKX positions 行字段, 供 format_position_row 使用.""" + rows = [] + for p in self.list_option_positions(): + rows.append( + { + "instId": p["inst_id"], + "pos": str(p["sheets"]), + "avgPx": str(p["entry_px"]), + "markPx": str(p["entry_px"]), + "upl": "0", + "uplRatio": "0", + "posSide": "long", + "mgnMode": "isolated", + } + ) + return rows diff --git a/lib/sim/db_lib.py b/lib/sim/db_lib.py new file mode 100644 index 0000000..6548d65 --- /dev/null +++ b/lib/sim/db_lib.py @@ -0,0 +1,116 @@ +"""模拟资金 SQLite 表初始化与种子余额.""" + +from __future__ import annotations + +import os +import sqlite3 +from datetime import datetime + + +def _env_float(key: str, default: float) -> float: + try: + return float(os.getenv(key) if os.getenv(key) not in (None, "") else default) + except (TypeError, ValueError): + return float(default) + + +def init_sim_tables(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sim_wallets ( + id INTEGER PRIMARY KEY CHECK (id = 1), + funding_usdt REAL NOT NULL DEFAULT 0, + trading_usdt REAL NOT NULL DEFAULT 0, + funding_usdc REAL NOT NULL DEFAULT 0, + trading_usdc REAL NOT NULL DEFAULT 0, + updated_at TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sim_perp_positions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + direction TEXT NOT NULL, + contracts REAL NOT NULL, + entry_px REAL NOT NULL, + leverage INTEGER NOT NULL DEFAULT 1, + margin_usdt REAL NOT NULL DEFAULT 0, + contract_size REAL NOT NULL DEFAULT 1, + updated_at TEXT, + UNIQUE(symbol, direction) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sim_option_positions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id TEXT NOT NULL UNIQUE, + side TEXT NOT NULL DEFAULT 'buy', + sheets REAL NOT NULL, + entry_px REAL NOT NULL, + ct_mult REAL NOT NULL DEFAULT 0.01, + premium_paid_usdc REAL NOT NULL DEFAULT 0, + updated_at TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sim_ledger_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + amount REAL NOT NULL, + ccy TEXT NOT NULL, + account TEXT NOT NULL, + balance_after REAL, + note TEXT, + ts TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sim_option_orders ( + ord_id TEXT PRIMARY KEY, + inst_id TEXT NOT NULL, + side TEXT NOT NULL, + sheets REAL NOT NULL, + avg_px REAL NOT NULL, + state TEXT NOT NULL DEFAULT 'filled', + acc_fill_sz REAL NOT NULL, + created_at TEXT + ) + """ + ) + row = conn.execute("SELECT id FROM sim_wallets WHERE id=1").fetchone() + if row is None: + equity = max(0.0, _env_float("SIM_INITIAL_EQUITY_USDT", 10000.0)) + usdc = max(0.0, _env_float("SIM_INITIAL_USDC", 0.0)) + now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + """ + INSERT INTO sim_wallets( + id, funding_usdt, trading_usdt, funding_usdc, trading_usdc, updated_at + ) VALUES (1, ?, 0, ?, 0, ?) + """, + (equity, usdc, now), + ) + conn.execute( + """ + INSERT INTO sim_ledger_entries(kind, amount, ccy, account, balance_after, note, ts) + VALUES ('seed', ?, 'USDT', 'funding', ?, 'initial equity', ?) + """, + (equity, equity, now), + ) + if usdc > 0: + conn.execute( + """ + INSERT INTO sim_ledger_entries(kind, amount, ccy, account, balance_after, note, ts) + VALUES ('seed', ?, 'USDC', 'funding', ?, 'initial usdc', ?) + """, + (usdc, usdc, now), + ) + conn.commit() diff --git a/lib/sim/hooks.py b/lib/sim/hooks.py new file mode 100644 index 0000000..90d8e69 --- /dev/null +++ b/lib/sim/hooks.py @@ -0,0 +1,321 @@ +"""运行时挂钩: 将 app / options cfg 在 sim 模式下切到本地撮合.""" + +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.sim.broker_lib import SimBroker +from lib.sim.mode_lib import is_sim_mode +from lib.sim.pricing_lib import sim_fee_rate + + +_GET_DB: Optional[Callable] = None +_APP_MODULE: Any = None + + +def set_get_db(get_db: Callable) -> None: + global _GET_DB + _GET_DB = get_db + + +def get_db_fn() -> Callable: + if _GET_DB is None: + raise RuntimeError("sim get_db 未安装") + return _GET_DB + + +def broker() -> SimBroker: + return SimBroker(get_db_fn()) + + +def apply_sim_hooks(app_module: Any) -> None: + """包装 app 上的永续下单/余额/就绪检查; 幂等.""" + global _APP_MODULE + _APP_MODULE = app_module + set_get_db(app_module.get_db) + + if getattr(app_module, "_sim_hooks_applied", False): + return + + _orig_ensure = app_module.ensure_okx_live_ready + _orig_capitals = app_module.get_exchange_capitals + _orig_avail = app_module.get_available_trading_usdt + _orig_place = app_module.place_exchange_order + _orig_close = app_module.close_exchange_order + _orig_live_contracts = app_module.get_live_position_contracts + + def ensure_okx_live_ready(): + if is_sim_mode(app_module.get_db): + return True, "sim" + return _orig_ensure() + + def get_exchange_capitals(force=False): + if is_sim_mode(app_module.get_db): + w = broker().balances_header() + return float(w["funding_usdt"]), float(w["trading_usdt"]) + return _orig_capitals(force=force) + + def get_available_trading_usdt(): + if is_sim_mode(app_module.get_db): + return float(broker().balances_header()["trading_usdt"]) + return _orig_avail() + + def place_exchange_order( + exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None + ): + if is_sim_mode(app_module.get_db): + ex = getattr(app_module, "exchange", None) + if ex is None: + raise RuntimeError("sim: exchange 未就绪(公开行情)") + ensure = getattr(app_module, "ensure_markets_loaded", None) + if callable(ensure): + try: + ensure() + except Exception: + pass + return broker().place_perp_market( + ex, + symbol=exchange_symbol, + direction=direction, + contracts=float(amount), + leverage=int(leverage or 1), + fee_rate=sim_fee_rate(), + stop_loss=stop_loss, + take_profit=take_profit, + ) + return _orig_place( + exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit + ) + + def close_exchange_order(order_row): + if is_sim_mode(app_module.get_db): + ex = getattr(app_module, "exchange", None) + if ex is None: + raise RuntimeError("sim: exchange 未就绪") + ensure = getattr(app_module, "ensure_markets_loaded", None) + if callable(ensure): + try: + ensure() + except Exception: + pass + normalize = getattr(app_module, "normalize_okx_symbol", None) or getattr( + app_module, "normalize_exchange_symbol", None + ) + try: + symbol = order_row["exchange_symbol"] or None + except Exception: + symbol = None + if not symbol: + try: + symbol = order_row["symbol"] + except Exception: + symbol = None + if callable(normalize): + symbol = normalize(symbol) + direction = order_row["direction"] + db_amt = float(order_row["order_amount"] or 0) + live = broker().get_perp_contracts(symbol, direction) + amt = live if live and live > 0 else db_amt + return broker().close_perp_market( + ex, symbol=symbol, direction=direction, contracts=amt, fee_rate=sim_fee_rate() + ) + return _orig_close(order_row) + + def get_live_position_contracts(exchange_symbol, direction): + if is_sim_mode(app_module.get_db): + normalize = getattr(app_module, "normalize_okx_symbol", None) + sym = exchange_symbol + if callable(normalize): + sym = normalize(exchange_symbol or "") + return broker().get_perp_contracts(sym, direction) + return _orig_live_contracts(exchange_symbol, direction) + + app_module.ensure_okx_live_ready = ensure_okx_live_ready + app_module.get_exchange_capitals = get_exchange_capitals + app_module.get_available_trading_usdt = get_available_trading_usdt + app_module.place_exchange_order = place_exchange_order + app_module.close_exchange_order = close_exchange_order + app_module.get_live_position_contracts = get_live_position_contracts + app_module._sim_hooks_applied = True + + _patch_okx_options_lib(app_module) + + +def _patch_okx_options_lib(app_module: Any) -> None: + """期权余额 / 成交等待: 对 sim-* 订单与 sim 模式短路.""" + import lib.exchange.okx_options_lib as opt_lib + + if getattr(opt_lib, "_sim_hooks_applied", False): + return + + _orig_header = opt_lib.options_header_balances + _orig_wait = opt_lib.wait_option_order_full_fill + _orig_fetch_order = opt_lib.fetch_option_order + _orig_fetch_pos = opt_lib.fetch_option_positions + _orig_ready = opt_lib.options_api_ready + _orig_fetch_bal = opt_lib.fetch_options_balances + + def options_header_balances(ex, *, force: bool = False): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + w = broker().balances_header() + return ( + round(float(w["trading_usdc"]), 2), + round(float(w["funding_usdc"]), 2), + round(float(w["funding_usdt"]), 2), + round(float(w["trading_usdt"]), 2), + ) + except Exception: + pass + return _orig_header(ex, force=force) + + def fetch_options_balances(ex, *, force: bool = False): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + w = broker().balances_header() + return { + "trading_usdc": float(w["trading_usdc"]), + "funding_usdc": float(w["funding_usdc"]), + "funding_usdt": float(w["funding_usdt"]), + "trading_usdt": float(w["trading_usdt"]), + } + except Exception: + pass + return _orig_fetch_bal(ex, force=force) + + def options_api_ready(ex): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + return True, "sim" + except Exception: + pass + return _orig_ready(ex) + + def fetch_option_order(ex, *, inst_id: str, ord_id: str): + oid = str(ord_id or "") + if oid.startswith("sim-"): + info = broker().get_option_order(oid) + if info: + return info + return {"ok": False, "msg": "sim order not found"} + return _orig_fetch_order(ex, inst_id=inst_id, ord_id=ord_id) + + def wait_option_order_full_fill( + ex, + *, + inst_id: str, + ord_id: str, + need_sheets: int, + timeout_sec: float = 12.0, + poll_sec: float = 0.35, + cancel_on_timeout: bool = True, + ): + oid = str(ord_id or "") + if oid.startswith("sim-"): + info = broker().get_option_order(oid) + if not info: + return {"ok": False, "msg": "sim order not found", "filled_sheets": 0} + return { + "ok": True, + "filled_sheets": int(round(float(info.get("acc_fill_sz") or need_sheets))), + "avg_px": info.get("avg_px"), + "state": "filled", + "order": info, + } + return _orig_wait( + ex, + inst_id=inst_id, + ord_id=ord_id, + need_sheets=need_sheets, + timeout_sec=timeout_sec, + poll_sec=poll_sec, + cancel_on_timeout=cancel_on_timeout, + ) + + def fetch_option_positions(ex): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + return broker().option_positions_okx_rows() + except Exception: + pass + return _orig_fetch_pos(ex) + + opt_lib.options_header_balances = options_header_balances + opt_lib.fetch_options_balances = fetch_options_balances + opt_lib.options_api_ready = options_api_ready + opt_lib.fetch_option_order = fetch_option_order + opt_lib.wait_option_order_full_fill = wait_option_order_full_fill + opt_lib.fetch_option_positions = fetch_option_positions + opt_lib._sim_hooks_applied = True + + +def wrap_option_place_fns(get_db: Callable, live_place_limit, live_place_market): + """返回按模式分流的 place_option_limit/market.""" + + def place_option_limit_order(ex, **kwargs): + if is_sim_mode(get_db): + side = (kwargs.get("side") or "").lower() + b = broker() + if side == "sell" or kwargs.get("reduce_only"): + return b.sell_option_close(ex, **kwargs) + return b.place_option_buy(ex, **kwargs) + return live_place_limit(ex, **kwargs) + + def place_option_market_order(ex, **kwargs): + if is_sim_mode(get_db): + side = (kwargs.get("side") or "").lower() + b = broker() + if side == "sell" or kwargs.get("reduce_only"): + return b.sell_option_close(ex, **kwargs) + return b.place_option_buy(ex, **kwargs) + return live_place_market(ex, **kwargs) + + return place_option_limit_order, place_option_market_order + + +def patch_options_cfg(cfg: dict[str, Any]) -> dict[str, Any]: + """就地替换 options/hedge cfg 中的下单函数为模式感知包装.""" + get_db = cfg.get("get_db") + if not callable(get_db): + return cfg + set_get_db(get_db) + live_limit = cfg.get("place_option_limit_order") + live_market = cfg.get("place_option_market_order") + if callable(live_limit) and callable(live_market): + wrapped_l, wrapped_m = wrap_option_place_fns(get_db, live_limit, live_market) + cfg["place_option_limit_order"] = wrapped_l + cfg["place_option_market_order"] = wrapped_m + elif callable(live_limit): + wrapped_l, _ = wrap_option_place_fns( + get_db, + live_limit, + live_limit, + ) + cfg["place_option_limit_order"] = wrapped_l + + live_cancel = cfg.get("cancel_option_order") + + def cancel_option_order(ex, **kwargs): + oid = str(kwargs.get("ord_id") or "") + if oid.startswith("sim-") or (callable(get_db) and is_sim_mode(get_db)): + return {"ok": True, "msg": "sim cancel noop", "sim": True} + if callable(live_cancel): + return live_cancel(ex, **kwargs) + return {"ok": False, "msg": "cancel unavailable"} + + if "cancel_option_order" in cfg: + cfg["cancel_option_order"] = cancel_option_order + + live_pending = cfg.get("fetch_option_pending_orders") + + def fetch_option_pending_orders(ex, **kwargs): + if is_sim_mode(get_db): + return [] + if callable(live_pending): + return live_pending(ex, **kwargs) + return [] + + if "fetch_option_pending_orders" in cfg: + cfg["fetch_option_pending_orders"] = fetch_option_pending_orders + + return cfg diff --git a/lib/sim/mode_lib.py b/lib/sim/mode_lib.py new file mode 100644 index 0000000..46056a1 --- /dev/null +++ b/lib/sim/mode_lib.py @@ -0,0 +1,52 @@ +"""交易模式: sim | live, 持久化到 app_runtime_settings.""" + +from __future__ import annotations + +import os +from typing import Callable + +from lib.instance.runtime_settings_lib import runtime_get, runtime_set, with_db + +TRADING_MODE_KEY = "trading.mode" +MODE_SIM = "sim" +MODE_LIVE = "live" +VALID_MODES = (MODE_SIM, MODE_LIVE) + + +def default_trading_mode() -> str: + raw = (os.getenv("SIM_DEFAULT_MODE") or "").strip().lower() + if raw in VALID_MODES: + return raw + return MODE_SIM + + +def normalize_mode(mode: str | None) -> str: + m = (mode or "").strip().lower() + if m in VALID_MODES: + return m + raise ValueError("mode 须为 sim 或 live") + + +def get_trading_mode(get_db: Callable) -> str: + def _read(conn): + v = runtime_get(conn, TRADING_MODE_KEY) + if v is None or str(v).strip() == "": + return default_trading_mode() + m = str(v).strip().lower() + return m if m in VALID_MODES else default_trading_mode() + + return with_db(get_db, _read) + + +def set_trading_mode(get_db: Callable, mode: str) -> str: + m = normalize_mode(mode) + + def _write(conn): + runtime_set(conn, TRADING_MODE_KEY, m) + return m + + return with_db(get_db, _write) + + +def is_sim_mode(get_db: Callable) -> bool: + return get_trading_mode(get_db) == MODE_SIM diff --git a/lib/sim/pricing_lib.py b/lib/sim/pricing_lib.py new file mode 100644 index 0000000..dfdc8a9 --- /dev/null +++ b/lib/sim/pricing_lib.py @@ -0,0 +1,79 @@ +"""成交价与手续费: 滑点 = 1×fee_rate.""" + +from __future__ import annotations + +import os +from dataclasses import asdict, dataclass + + +@dataclass(slots=True) +class PriceResult: + base_px: float + fill_px: float + fee: float + slip: float + notional: float + + def to_dict(self) -> dict[str, float]: + return asdict(self) + + +def sim_fee_rate(override: float | None = None) -> float: + if override is not None: + return float(override) + try: + return float(os.getenv("SIM_FEE_RATE") or "0.0005") + except (TypeError, ValueError): + return 0.0005 + + +def perp_fill( + *, + side: str, + action: str, + bid: float, + ask: float, + qty: float, + fee_rate: float, +) -> PriceResult: + """ + side: long|short + action: open|close + 开多/平空: 吃卖一 ×(1+f) + 开空/平多: 吃买一 ×(1-f) + qty: 标的数量(合约张数 × 合约面值) + """ + f = float(fee_rate) + buying = (action == "open" and side == "long") or (action == "close" and side == "short") + if buying: + base = float(ask) + fill = base * (1.0 + f) + else: + base = float(bid) + fill = base * (1.0 - f) + notional = abs(fill * float(qty)) + fee = notional * f + slip = abs(fill - base) * float(qty) + return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=slip, notional=notional) + + +def option_fill( + *, + action: str, + bid: float, + ask: float, + qty: float, + fee_rate: float, +) -> PriceResult: + """开仓买入吃卖一; 平仓卖出吃买一. qty = sheets × ct_mult.""" + f = float(fee_rate) + if action == "open": + base = float(ask) + fill = base * (1.0 + f) + else: + base = float(bid) + fill = base * (1.0 - f) + notional = abs(fill * float(qty)) + fee = notional * f + slip = abs(fill - base) * float(qty) + return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=slip, notional=notional) diff --git a/lib/sim/register.py b/lib/sim/register.py new file mode 100644 index 0000000..b64b3af --- /dev/null +++ b/lib/sim/register.py @@ -0,0 +1,166 @@ +"""安装模拟资金路由与模板.""" + +from __future__ import annotations + +import os +from typing import Any + +from flask import Flask, jsonify, request +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.sim.broker_lib import SimBroker +from lib.sim.db_lib import init_sim_tables +from lib.sim.hooks import apply_sim_hooks, patch_options_cfg, set_get_db +from lib.sim.mode_lib import get_trading_mode, is_sim_mode, set_trading_mode +from lib.sim.pricing_lib import sim_fee_rate +from lib.sim.wallets_lib import SimWallets + + +def attach_sim_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "sim", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def install_sim_trading(app: Flask, repo_root: str, app_module: Any = None) -> None: + if app_module is None: + raise ValueError("install_sim_trading 需要 app_module") + + attach_sim_templates(app, repo_root) + get_db = app_module.get_db + login_required = app_module.login_required + set_get_db(get_db) + + try: + conn = get_db() + try: + init_sim_tables(conn) + finally: + conn.close() + except Exception as e: + print(f"[sim] init tables: {e}") + + apply_sim_hooks(app_module) + + # 若 options / hedge 已安装, 补丁其 cfg, 并刷新永续下单引用 + for key in ("options_cfg", "hedge_plan_cfg"): + cfg = app.extensions.get(key) + if isinstance(cfg, dict): + patch_options_cfg(cfg) + for fn_name in ( + "place_exchange_order", + "close_exchange_order", + "get_exchange_capitals", + "get_available_trading_usdt", + "ensure_okx_live_ready", + "get_live_position_contracts", + ): + if fn_name in cfg and hasattr(app_module, fn_name): + cfg[fn_name] = getattr(app_module, fn_name) + + app.extensions["sim_installed"] = True + + def _status_payload(): + mode = get_trading_mode(get_db) + wallets = SimWallets(get_db).view() if mode == "sim" else None + return { + "mode": mode, + "is_sim": mode == "sim", + "wallets": wallets, + "fee_rate": sim_fee_rate(), + } + + @app.route("/api/sim/status") + @login_required + def api_sim_status(): + return jsonify({"ok": True, **_status_payload()}) + + @app.route("/api/sim/mode", methods=["POST"]) + @login_required + def api_sim_mode(): + body = request.get_json(silent=True) or {} + mode = body.get("mode") + try: + saved = set_trading_mode(get_db, mode) + except ValueError as e: + return jsonify({"ok": False, "msg": str(e)}), 400 + return jsonify({"ok": True, "mode": saved, **_status_payload()}) + + @app.route("/api/sim/reset", methods=["POST"]) + @login_required + def api_sim_reset(): + if not is_sim_mode(get_db): + return jsonify({"ok": False, "msg": "仅模拟模式可重置"}), 400 + body = request.get_json(silent=True) or {} + try: + equity = float(body.get("equity_usdt") if body.get("equity_usdt") is not None else 10000) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "equity_usdt 无效"}), 400 + force = bool(body.get("force")) + result = SimWallets(get_db).reset_equity(equity, force=force) + if not result.get("ok"): + return jsonify(result), 400 + return jsonify({"ok": True, **result, **_status_payload()}) + + @app.route("/api/sim/transfer", methods=["POST"]) + @login_required + def api_sim_transfer(): + if not is_sim_mode(get_db): + return jsonify({"ok": False, "msg": "仅模拟模式可划转"}), 400 + body = request.get_json(silent=True) or {} + try: + amount = float(body.get("amount") or 0) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "amount 无效"}), 400 + result = SimWallets(get_db).transfer( + ccy=str(body.get("ccy") or "USDT"), + amount=amount, + from_account=str(body.get("from") or ""), + to_account=str(body.get("to") or ""), + ) + if not result.get("ok"): + return jsonify(result), 400 + return jsonify({**result, **_status_payload()}) + + @app.route("/api/sim/convert", methods=["POST"]) + @login_required + def api_sim_convert(): + if not is_sim_mode(get_db): + return jsonify({"ok": False, "msg": "仅模拟模式可兑换"}), 400 + body = request.get_json(silent=True) or {} + try: + amount = float(body.get("amount") or 0) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "amount 无效"}), 400 + result = SimWallets(get_db).convert( + from_ccy=str(body.get("from_ccy") or ""), + to_ccy=str(body.get("to_ccy") or ""), + amount=amount, + account=str(body.get("account") or "funding"), + ) + if not result.get("ok"): + return jsonify(result), 400 + return jsonify({**result, **_status_payload()}) + + @app.route("/api/sim/positions") + @login_required + def api_sim_positions(): + if not is_sim_mode(get_db): + return jsonify({"ok": True, "perp": [], "options": [], "is_sim": False}) + b = SimBroker(get_db) + return jsonify( + { + "ok": True, + "is_sim": True, + "perp": b.list_perp_positions(), + "options": b.list_option_positions(), + } + ) diff --git a/lib/sim/templates/sim_funds_panel.html b/lib/sim/templates/sim_funds_panel.html new file mode 100644 index 0000000..7d7cd38 --- /dev/null +++ b/lib/sim/templates/sim_funds_panel.html @@ -0,0 +1,167 @@ +{# 系统设置 · 模拟资金 #} +
    +

    模拟资金

    +

    + 在本地 SQLite 钱包中模拟撮合: 用 OKX 公开行情 吃买卖一结算盈亏, + 不会向交易所下真实订单. +

    +

    + 当前模式: + + +

    + +
    + + +
    + + + +

    +
    + diff --git a/lib/sim/wallets_lib.py b/lib/sim/wallets_lib.py new file mode 100644 index 0000000..d5f3436 --- /dev/null +++ b/lib/sim/wallets_lib.py @@ -0,0 +1,335 @@ +"""模拟资金钱包: funding/trading × USDT/USDC.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Callable + + +WALLET_KEYS = ( + "funding_usdt", + "trading_usdt", + "funding_usdc", + "trading_usdc", +) + +_ACCT_MAP = { + ("funding", "usdt"): "funding_usdt", + ("trading", "usdt"): "trading_usdt", + ("funding", "usdc"): "funding_usdc", + ("trading", "usdc"): "trading_usdc", +} + + +class InsufficientFunds(RuntimeError): + pass + + +class SimWallets: + def __init__(self, get_db: Callable) -> None: + self.get_db = get_db + + def _now(self) -> str: + return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + + def snapshot(self) -> dict[str, float]: + conn = self.get_db() + try: + row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() + if row is None: + return {k: 0.0 for k in WALLET_KEYS} + return {k: float(row[k] or 0) for k in WALLET_KEYS} + finally: + conn.close() + + def view(self) -> dict[str, float]: + return self.snapshot() + + def total_usdt_equiv(self, snap: dict[str, float] | None = None) -> float: + v = snap or self.view() + return round( + float(v.get("funding_usdt") or 0) + + float(v.get("trading_usdt") or 0) + + float(v.get("funding_usdc") or 0) + + float(v.get("trading_usdc") or 0), + 8, + ) + + def _write(self, snap: dict[str, float], conn=None) -> dict[str, float]: + owns = conn is None + if owns: + conn = self.get_db() + try: + now = self._now() + conn.execute( + """ + UPDATE sim_wallets SET + funding_usdt=?, trading_usdt=?, funding_usdc=?, trading_usdc=?, updated_at=? + WHERE id=1 + """, + ( + float(snap["funding_usdt"]), + float(snap["trading_usdt"]), + float(snap["funding_usdc"]), + float(snap["trading_usdc"]), + now, + ), + ) + if owns: + conn.commit() + return {k: float(snap[k]) for k in WALLET_KEYS} + finally: + if owns: + conn.close() + + def _ledger( + self, + conn, + *, + kind: str, + amount: float, + ccy: str, + account: str, + balance_after: float, + note: str = "", + ) -> None: + conn.execute( + """ + INSERT INTO sim_ledger_entries(kind, amount, ccy, account, balance_after, note, ts) + VALUES (?,?,?,?,?,?,?) + """, + (kind, float(amount), ccy.upper(), account, float(balance_after), note or "", self._now()), + ) + + def debit_trading(self, ccy: str, amount: float, *, kind: str = "debit", note: str = "") -> dict[str, float]: + amt = float(amount) + if amt <= 0: + raise ValueError("扣款金额须大于 0") + key = _ACCT_MAP.get(("trading", (ccy or "").lower())) + if not key: + raise ValueError("币种须为 USDT 或 USDC") + conn = self.get_db() + try: + row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() + snap = {k: float(row[k] or 0) for k in WALLET_KEYS} + bal = float(snap[key]) + if amt > bal + 1e-9: + raise InsufficientFunds(f"交易账户 {ccy.upper()} 不足(可用 {bal:.4f})") + snap[key] = bal - amt + self._write(snap, conn=conn) + self._ledger( + conn, + kind=kind, + amount=-amt, + ccy=ccy, + account="trading", + balance_after=snap[key], + note=note, + ) + conn.commit() + return snap + finally: + conn.close() + + def credit_trading(self, ccy: str, amount: float, *, kind: str = "credit", note: str = "") -> dict[str, float]: + amt = float(amount) + if amt < 0: + raise ValueError("入账金额不能为负") + if amt < 1e-12: + return self.snapshot() + key = _ACCT_MAP.get(("trading", (ccy or "").lower())) + if not key: + raise ValueError("币种须为 USDT 或 USDC") + conn = self.get_db() + try: + row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() + snap = {k: float(row[k] or 0) for k in WALLET_KEYS} + snap[key] = float(snap[key]) + amt + self._write(snap, conn=conn) + self._ledger( + conn, + kind=kind, + amount=amt, + ccy=ccy, + account="trading", + balance_after=snap[key], + note=note, + ) + conn.commit() + return snap + finally: + conn.close() + + def transfer( + self, + *, + ccy: str, + amount: float, + from_account: str, + to_account: str, + ) -> dict[str, Any]: + amt = float(amount) + if amt <= 0: + return {"ok": False, "detail": "划转金额须大于 0"} + ccy_l = (ccy or "USDT").strip().lower() + fa = (from_account or "").strip().lower() + ta = (to_account or "").strip().lower() + if fa not in ("funding", "trading") or ta not in ("funding", "trading"): + return {"ok": False, "detail": "账户仅支持 funding / trading"} + if fa == ta: + return {"ok": False, "detail": "来源与目标账户不能相同"} + src_key = _ACCT_MAP.get((fa, ccy_l)) + dst_key = _ACCT_MAP.get((ta, ccy_l)) + if not src_key or not dst_key: + return {"ok": False, "detail": "币种须为 USDT 或 USDC"} + conn = self.get_db() + try: + row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() + snap = {k: float(row[k] or 0) for k in WALLET_KEYS} + src_bal = float(snap[src_key]) + if amt > src_bal + 1e-9: + return {"ok": False, "detail": f"余额不足(可用 {src_bal:.4f})"} + snap[src_key] = src_bal - amt + snap[dst_key] = float(snap[dst_key]) + amt + self._write(snap, conn=conn) + self._ledger( + conn, + kind="transfer", + amount=-amt, + ccy=ccy_l, + account=fa, + balance_after=snap[src_key], + note=f"to {ta}", + ) + self._ledger( + conn, + kind="transfer", + amount=amt, + ccy=ccy_l, + account=ta, + balance_after=snap[dst_key], + note=f"from {fa}", + ) + conn.commit() + return { + "ok": True, + "detail": "transferred", + "ccy": ccy_l.upper(), + "amount": amt, + "from": fa, + "to": ta, + "wallets": {k: float(snap[k]) for k in WALLET_KEYS}, + "total_usdt_equiv": self.total_usdt_equiv(snap), + } + finally: + conn.close() + + def convert( + self, + *, + from_ccy: str, + to_ccy: str, + amount: float, + account: str = "funding", + ) -> dict[str, Any]: + amt = float(amount) + if amt <= 0: + return {"ok": False, "detail": "数量须大于 0"} + fa = (from_ccy or "").strip().lower() + ta = (to_ccy or "").strip().lower() + acct = (account or "funding").strip().lower() + if acct not in ("funding", "trading"): + return {"ok": False, "detail": "account 须为 funding / trading"} + if {fa, ta} != {"usdt", "usdc"}: + return {"ok": False, "detail": "仅支持 USDT↔USDC 1:1"} + src_key = _ACCT_MAP[(acct, fa)] + dst_key = _ACCT_MAP[(acct, ta)] + conn = self.get_db() + try: + row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() + snap = {k: float(row[k] or 0) for k in WALLET_KEYS} + src = float(snap[src_key]) + if amt > src + 1e-9: + return {"ok": False, "detail": f"{acct} {fa.upper()} 不足(可用 {src:.4f})"} + snap[src_key] = src - amt + snap[dst_key] = float(snap[dst_key]) + amt + self._write(snap, conn=conn) + self._ledger( + conn, + kind="convert", + amount=-amt, + ccy=fa, + account=acct, + balance_after=snap[src_key], + note=f"to {ta}", + ) + self._ledger( + conn, + kind="convert", + amount=amt, + ccy=ta, + account=acct, + balance_after=snap[dst_key], + note=f"from {fa}", + ) + conn.commit() + return { + "ok": True, + "detail": "converted", + "from_ccy": fa.upper(), + "to_ccy": ta.upper(), + "amount": amt, + "rate": 1.0, + "account": acct, + "wallets": {k: float(snap[k]) for k in WALLET_KEYS}, + "total_usdt_equiv": self.total_usdt_equiv(snap), + } + finally: + conn.close() + + def has_open_positions(self) -> bool: + conn = self.get_db() + try: + p = conn.execute( + "SELECT COUNT(*) AS n FROM sim_perp_positions WHERE contracts > 1e-12" + ).fetchone() + o = conn.execute( + "SELECT COUNT(*) AS n FROM sim_option_positions WHERE sheets > 1e-12" + ).fetchone() + pn = int(p["n"] if hasattr(p, "keys") else p[0]) + on = int(o["n"] if hasattr(o, "keys") else o[0]) + return pn > 0 or on > 0 + finally: + conn.close() + + def reset_equity(self, amount: float, *, force: bool = False) -> dict[str, Any]: + if self.has_open_positions() and not force: + return {"ok": False, "detail": "仍有模拟持仓,请先平仓或传 force=true"} + amt = max(0.0, float(amount)) + conn = self.get_db() + try: + if force: + conn.execute("DELETE FROM sim_perp_positions") + conn.execute("DELETE FROM sim_option_positions") + conn.execute("DELETE FROM sim_option_orders") + now = self._now() + snap = { + "funding_usdt": amt, + "trading_usdt": 0.0, + "funding_usdc": 0.0, + "trading_usdc": 0.0, + } + self._write(snap, conn=conn) + self._ledger( + conn, + kind="reset", + amount=amt, + ccy="USDT", + account="funding", + balance_after=amt, + note="reset equity", + ) + conn.commit() + return {"ok": True, "wallets": snap, "total_usdt_equiv": amt} + finally: + conn.close() diff --git a/lib/strategy/__init__.py b/lib/strategy/__init__.py new file mode 100644 index 0000000..5007e3d --- /dev/null +++ b/lib/strategy/__init__.py @@ -0,0 +1 @@ +"""策略交易已移除;标签工具见 lib.trade.trade_labels_lib.""" diff --git a/lib/strategy/strategy_trade_labels.py b/lib/strategy/strategy_trade_labels.py new file mode 100644 index 0000000..4cb4404 --- /dev/null +++ b/lib/strategy/strategy_trade_labels.py @@ -0,0 +1,2 @@ +"""兼容薄封装:请改用 lib.trade.trade_labels_lib.""" +from lib.trade.trade_labels_lib import * # noqa: F401,F403 diff --git a/lib/trade/__init__.py b/lib/trade/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/trade/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/trade/account_risk_lib.py b/lib/trade/account_risk_lib.py new file mode 100644 index 0000000..dd24098 --- /dev/null +++ b/lib/trade/account_risk_lib.py @@ -0,0 +1,912 @@ +"""账户冷静期 / 日冻结风控(三所实例共用).""" +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any, Callable, Optional + +STATUS_NORMAL = "normal" +STATUS_FREEZE_1H = "freeze_1h" +STATUS_FREEZE_4H = "freeze_4h" +STATUS_DAILY = "freeze_daily" +STATUS_FREEZE_POSITION = "freeze_position" + +STATUS_LABELS = { + STATUS_NORMAL: "正常", + STATUS_FREEZE_1H: "1h冻结", + STATUS_FREEZE_4H: "4h冻结", + STATUS_DAILY: "日冻结", + STATUS_FREEZE_POSITION: "仓位上限冻结", +} + +MOOD_ISSUE_OPTIONS = ( + "怕踏空", + "报复开仓", + "盈利飘了", + "拿不住单", + "扛单", + "重仓违规", +) + +# 仅以下来源计入「手动平仓」风控(用户主动点平仓/结束计划) +CLOSE_SOURCE_USER_INSTANCE = "user_instance" +CLOSE_SOURCE_USER_HUB = "user_hub" +CLOSE_SOURCE_USER_TREND_STOP = "user_trend_stop" + +USER_INITIATED_CLOSE_SOURCES = frozenset( + { + CLOSE_SOURCE_USER_INSTANCE, + CLOSE_SOURCE_USER_HUB, + CLOSE_SOURCE_USER_TREND_STOP, + } +) + + +def _env_bool(key: str, default: bool = True) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _env_hours(key: str, default: float) -> float: + try: + v = float(os.getenv(key, str(default))) + except (TypeError, ValueError): + v = default + return max(0.0, v) + + +def _app_tz(): + from zoneinfo import ZoneInfo + + name = (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() + try: + return ZoneInfo(name) + except Exception: + return ZoneInfo("Asia/Shanghai") + + +def risk_control_enabled() -> bool: + return _env_bool("RISK_CONTROL_ENABLED", True) + + +def cooling_hours_manual() -> float: + return _env_hours("RISK_COOLING_HOURS_MANUAL", 4.0) + + +def cooling_hours_manual_journal() -> float: + return _env_hours("RISK_COOLING_HOURS_MANUAL_JOURNAL", 1.0) + + +def manual_close_daily_limit() -> int: + try: + return max(1, int(os.getenv("RISK_MANUAL_CLOSE_DAILY_LIMIT", "2"))) + except (TypeError, ValueError): + return 2 + + +def daily_loss_limit() -> int: + """日亏损次数上限:达限当日冻结开仓;0=不因亏损次数冻结.""" + try: + return max(0, int(os.getenv("RISK_DAILY_LOSS_LIMIT", "2"))) + except (TypeError, ValueError): + return 2 + + +def max_active_positions_from_env(default: int = 1) -> int: + try: + return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", str(default)))) + except (TypeError, ValueError): + return max(1, default) + + +def position_limit_reached( + conn, + *, + max_active_positions: Optional[int] = None, +) -> tuple[bool, int, int]: + """(已达上限, 计入上限的活跃数, 上限值).""" + from lib.trade.trade_labels_lib import count_position_limit_active_monitors + + mx = max(1, int(max_active_positions if max_active_positions is not None else max_active_positions_from_env())) + ac = count_position_limit_active_monitors(conn) + return ac >= mx, ac, mx + + +def mood_issues_daily_freeze_enabled() -> bool: + return _env_bool("RISK_MOOD_ISSUES_DAILY_FREEZE", True) + + +def ensure_account_risk_schema(conn) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS account_risk_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + trading_day TEXT, + manual_close_count INTEGER DEFAULT 0, + daily_loss_count INTEGER DEFAULT 0, + cooloff_until_ms INTEGER, + cooloff_hours INTEGER, + daily_frozen INTEGER DEFAULT 0, + pending_journal_trade_id INTEGER, + last_close_at_ms INTEGER, + updated_at TEXT + )""" + ) + cols = { + str(r[1]) + for r in conn.execute("PRAGMA table_info(account_risk_state)").fetchall() + } + if "daily_loss_count" not in cols: + conn.execute( + "ALTER TABLE account_risk_state ADD COLUMN daily_loss_count INTEGER DEFAULT 0" + ) + row = conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone() + if not row: + conn.execute( + "INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_loss_count, daily_frozen) VALUES (1, '', 0, 0, 0)" + ) + + +def _row_get(row, key, default=None): + if row is None: + return default + try: + return row[key] + except (KeyError, IndexError, TypeError): + return default + + +def _now_ms(now: Optional[datetime] = None) -> int: + dt = now or datetime.now() + if dt.tzinfo is None: + dt = dt.replace(tzinfo=_app_tz()) + return int(dt.timestamp() * 1000) + + +def _normalize_epoch_ms(ms: int, ref_now_ms: Optional[int] = None) -> int: + """修正旧版把北京时间 naive 当作 UTC 写入的 epoch 毫秒.""" + tz = _app_tz() + off = datetime.now(tz).utcoffset() + if not off: + return int(ms) + offset_ms = int(off.total_seconds() * 1000) + if offset_ms == 0: + return int(ms) + ref = int(ref_now_ms) if ref_now_ms is not None else _now_ms(datetime.now(tz)) + corrected = int(ms) - offset_ms + if abs(int(ms) - ref) <= abs(corrected - ref): + return int(ms) + return corrected + + +def _sanitize_last_close_ms(last_ms: int, now_ms: int) -> Optional[int]: + """平仓时刻须不晚于当前(允许 1 分钟时钟偏差);显著未来视为无效锚点.""" + slack_ms = 60 * 1000 + if last_ms > now_ms + slack_ms: + return None + return last_ms + + +def _cooloff_duration_ms(hours: float) -> int: + return int(max(0.0, float(hours)) * 3600 * 1000) + + +def _cooloff_hours_value(row) -> float: + return float(_row_get(row, "cooloff_hours") or cooling_hours_manual()) + + +def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]: + """冷静期结束 = last_close + cooloff_hours;无效/已过期锚点不再重启计时.""" + hours = _cooloff_hours_value(row) + journal_h = cooling_hours_manual_journal() + duration_ms = _cooloff_duration_ms(hours) + last_raw = _row_get(row, "last_close_at_ms") + stored_raw = _cooloff_until_ms(row) + + if last_raw is not None: + try: + last_ms = _sanitize_last_close_ms( + _normalize_epoch_ms(int(last_raw), now_ms), now_ms + ) + except (TypeError, ValueError): + last_ms = None + if last_ms is not None: + end_ms = last_ms + duration_ms + if end_ms > now_ms: + return end_ms + if hours <= journal_h + 1e-6: + return None + + if stored_raw is None: + return None + stored_ms = _normalize_epoch_ms(int(stored_raw), now_ms) + return stored_ms if stored_ms > now_ms else None + + +def _clear_inactive_cooloff( + conn, + *, + now: Optional[datetime] = None, +) -> None: + """冷静期已结束或锚点无效时清库,避免重启后误读旧冻结.""" + conn.execute( + """UPDATE account_risk_state SET + cooloff_until_ms=NULL, + cooloff_hours=NULL, + last_close_at_ms=NULL, + updated_at=? + WHERE id=1""", + ((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),), + ) + + +def _freeze_tier_from_remaining_ms(remaining_ms: int, hours: float) -> str: + journal_h = cooling_hours_manual_journal() + rh = remaining_ms / 3600000.0 + if rh <= journal_h + (5 / 60): + return STATUS_FREEZE_1H + return STATUS_FREEZE_4H + + +def _freeze_status_label(hours: float, status: str) -> str: + if status == STATUS_FREEZE_1H: + return STATUS_LABELS[STATUS_FREEZE_1H] + if status == STATUS_FREEZE_4H: + h = int(hours) if float(hours) == int(hours) else round(float(hours), 1) + if abs(float(hours) - 4.0) < 1e-6: + return STATUS_LABELS[STATUS_FREEZE_4H] + return f"{h}h冻结" + return STATUS_LABELS.get(status, STATUS_LABELS[STATUS_NORMAL]) + + +def _ms_to_local_str(ms: Optional[int], fmt_local: Callable[[int], str]) -> Optional[str]: + if ms is None: + return None + try: + return fmt_local(int(ms)) + except Exception: + return None + + +def _load_state(conn): + ensure_account_risk_schema(conn) + return conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() + + +def _sync_trading_day(conn, trading_day: str, now: Optional[datetime] = None) -> Any: + row = _load_state(conn) + td = (trading_day or "").strip() + stored = str(_row_get(row, "trading_day") or "").strip() + if stored != td: + now_ms = _now_ms(now) + cooloff_active = _resolved_cooloff_until_ms(row, now_ms) + conn.execute( + """UPDATE account_risk_state SET + trading_day=?, + manual_close_count=0, + daily_loss_count=0, + daily_frozen=0, + cooloff_until_ms=?, + cooloff_hours=?, + last_close_at_ms=?, + pending_journal_trade_id=NULL, + updated_at=? + WHERE id=1""", + ( + td, + cooloff_active, + _row_get(row, "cooloff_hours") if cooloff_active else None, + _row_get(row, "last_close_at_ms") if cooloff_active else None, + (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + row = _load_state(conn) + return row + + +def _set_cooloff( + conn, + *, + trading_day: str, + close_at_ms: int, + hours: float, + now: Optional[datetime] = None, +) -> None: + _sync_trading_day(conn, trading_day, now=now) + h = max(0.0, float(hours)) + until_ms = int(close_at_ms + h * 3600 * 1000) + conn.execute( + """UPDATE account_risk_state SET + cooloff_until_ms=?, + cooloff_hours=?, + last_close_at_ms=?, + updated_at=? + WHERE id=1""", + ( + until_ms, + int(h) if h == int(h) else int(round(h)), + int(close_at_ms), + (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + + +def _set_cooloff_until( + conn, + *, + trading_day: str, + until_ms: int, + hours: float, + now: Optional[datetime] = None, +) -> None: + _sync_trading_day(conn, trading_day, now=now) + h = max(0.0, float(hours)) + conn.execute( + """UPDATE account_risk_state SET + cooloff_until_ms=?, + cooloff_hours=?, + updated_at=? + WHERE id=1""", + ( + int(until_ms), + int(h) if h == int(h) else int(round(h)), + (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + + +def _ms_trading_day_label(ms: int) -> str: + dt = datetime.fromtimestamp(ms / 1000, tz=_app_tz()) + return dt.strftime("%Y-%m-%d") + + +def _parse_journal_close_ms(raw: Any) -> Optional[int]: + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(s[:19] if len(s) > 16 else s, fmt) + return _now_ms(dt) + except ValueError: + continue + return None + + +def _latest_journaled_manual_close_ms(conn, trading_day: str) -> Optional[int]: + """当日最近一条已复盘的手动平仓时刻(journal 有说明).""" + try: + rows = conn.execute( + """SELECT close_datetime FROM journal_entries + WHERE early_exit_trigger='手动平仓' + AND early_exit_note IS NOT NULL AND TRIM(early_exit_note) <> '' + ORDER BY close_datetime DESC""" + ).fetchall() + except Exception: + return None + td = (trading_day or "").strip() + best: Optional[int] = None + for row in rows: + ms = _parse_journal_close_ms(_row_get(row, "close_datetime")) + if ms is None: + continue + if td and _ms_trading_day_label(ms) != td: + continue + if best is None or ms > best: + best = ms + return best + + +def _journaled_manual_cooloff_expired( + conn, *, trading_day: str, now_ms: int, pending: Any +) -> bool: + """当日手动平仓已复盘且 1h 冷静期结束,且无待复盘的新平仓.""" + if pending is not None: + try: + if int(pending) != 0: + return False + except (TypeError, ValueError): + return False + close_ms = _latest_journaled_manual_close_ms(conn, trading_day) + if close_ms is None: + return False + journal_ms = _cooloff_duration_ms(cooling_hours_manual_journal()) + return close_ms + journal_ms <= now_ms + + +def _cooloff_until_ms(row) -> Optional[int]: + raw = _row_get(row, "cooloff_until_ms") + try: + return int(raw) if raw is not None else None + except (TypeError, ValueError): + return None + + +def _repair_stale_cooloff_row( + conn, + row, + *, + now_ms: int, + resolved_until_ms: Optional[int], + now: Optional[datetime] = None, +) -> None: + """脏数据读时写回:过期/无效则清库,否则对齐 until / last_close.""" + last_raw = _row_get(row, "last_close_at_ms") + stored_raw = _cooloff_until_ms(row) + if last_raw is None and stored_raw is None: + return + if resolved_until_ms is None: + if last_raw is not None or stored_raw is not None: + _clear_inactive_cooloff(conn, now=now) + return + dirty = False + new_last: Optional[int] = None + if last_raw is not None: + try: + norm = _normalize_epoch_ms(int(last_raw), now_ms) + sanitized = _sanitize_last_close_ms(norm, now_ms) + if sanitized is None: + dirty = True + else: + new_last = sanitized + if sanitized != int(last_raw): + dirty = True + except (TypeError, ValueError): + dirty = True + if stored_raw is not None: + stored_norm = _normalize_epoch_ms(int(stored_raw), now_ms) + if abs(stored_norm - int(resolved_until_ms)) > 60 * 1000: + dirty = True + if not dirty: + return + conn.execute( + """UPDATE account_risk_state SET + cooloff_until_ms=?, + cooloff_hours=?, + last_close_at_ms=?, + updated_at=? + WHERE id=1""", + ( + resolved_until_ms, + _row_get(row, "cooloff_hours"), + new_last, + (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + + +def _journal_can_reduce_cooloff(row, pending, now_ms: int) -> bool: + if int(_row_get(row, "daily_frozen") or 0) == 1: + return False + if _resolved_cooloff_until_ms(row, now_ms) is None: + return False + journal_h = cooling_hours_manual_journal() + cooloff_h = float(_row_get(row, "cooloff_hours") or cooling_hours_manual()) + if cooloff_h <= journal_h + 1e-6: + return False + if pending is not None: + try: + if int(pending) != 0: + return True + except (TypeError, ValueError): + return True + return True + + +def _journal_cooloff_until_ms(row, now_ms: int, journal_hours: float) -> int: + journal_ms = int(max(0.0, float(journal_hours)) * 3600 * 1000) + last_close_ms = _row_get(row, "last_close_at_ms") + if last_close_ms: + try: + base_ms = _sanitize_last_close_ms( + _normalize_epoch_ms(int(last_close_ms), now_ms), now_ms + ) + except (TypeError, ValueError): + base_ms = None + if base_ms is None: + base_ms = now_ms + else: + base_ms = now_ms + until_from_close = base_ms + journal_ms + if until_from_close > now_ms: + return until_from_close + return now_ms + journal_ms + + +def _set_daily_frozen(conn, *, trading_day: str, now: Optional[datetime] = None) -> None: + _sync_trading_day(conn, trading_day, now=now) + conn.execute( + """UPDATE account_risk_state SET daily_frozen=1, updated_at=? WHERE id=1""", + ((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),), + ) + + +def parse_mood_issues(raw: Any) -> list[str]: + if raw is None: + return [] + if isinstance(raw, (list, tuple)): + parts = [str(x).strip() for x in raw if str(x).strip()] + else: + parts = [x.strip() for x in str(raw).split(",") if x.strip()] + return [p for p in parts if p in MOOD_ISSUE_OPTIONS] + + +def _record_one_user_initiated_close( + conn, + *, + source: str, + trade_record_id: Optional[int], + closed_at_ms: Optional[int], + trading_day: str, + now: Optional[datetime] = None, +) -> None: + row = _sync_trading_day(conn, trading_day, now=now) + count = int(_row_get(row, "manual_close_count") or 0) + 1 + close_ms = int(closed_at_ms) if closed_at_ms else _now_ms(now) + pending = int(trade_record_id) if trade_record_id else None + conn.execute( + """UPDATE account_risk_state SET + manual_close_count=?, + pending_journal_trade_id=?, + updated_at=? + WHERE id=1""", + (count, pending, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")), + ) + if count >= manual_close_daily_limit(): + _set_daily_frozen(conn, trading_day=trading_day, now=now) + return + _set_cooloff( + conn, + trading_day=trading_day, + close_at_ms=close_ms, + hours=cooling_hours_manual(), + now=now, + ) + + +def on_user_initiated_close( + conn, + *, + source: str, + trade_record_id: Optional[int] = None, + closed_at_ms: Optional[int] = None, + trading_day: str, + now: Optional[datetime] = None, + count: int = 1, +) -> None: + """用户主动平仓/结束趋势计划:计入手动平仓次数与冷静期.""" + if not risk_control_enabled(): + return + src = (source or "").strip() + if src not in USER_INITIATED_CLOSE_SOURCES: + return + n = max(1, int(count or 1)) + for i in range(n): + _record_one_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id if i == 0 else None, + closed_at_ms=closed_at_ms, + trading_day=trading_day, + now=now, + ) + row = _load_state(conn) + if int(_row_get(row, "daily_frozen") or 0) == 1: + break + + +def on_manual_close( + conn, + *, + trade_record_id: int, + closed_at_ms: Optional[int], + trading_day: str, + now: Optional[datetime] = None, +) -> None: + """兼容旧调用:等同实例页用户平仓.""" + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=trading_day, + now=now, + count=1, + ) + + +def on_closed_trade_pnl( + conn, + *, + pnl_amount: Any, + trading_day: str, + now: Optional[datetime] = None, +) -> None: + """ + 已平仓交易记盈亏后调用:亏损笔数达 RISK_DAILY_LOSS_LIMIT 则当日冻结开仓. + 上限为 0 时不启用本规则. + """ + if not risk_control_enabled(): + return + limit = daily_loss_limit() + if limit <= 0: + return + try: + pnl = float(pnl_amount) + except (TypeError, ValueError): + return + if pnl >= 0: + return + row = _sync_trading_day(conn, trading_day, now=now) + if int(_row_get(row, "daily_frozen") or 0) == 1: + return + count = int(_row_get(row, "daily_loss_count") or 0) + 1 + conn.execute( + """UPDATE account_risk_state SET + daily_loss_count=?, + updated_at=? + WHERE id=1""", + (count, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")), + ) + if count >= limit: + _set_daily_frozen(conn, trading_day=trading_day, now=now) + + +def on_journal_saved( + conn, + *, + early_exit_trigger: str, + early_exit_note: str, + mood_issues_raw: Any, + trading_day: str, + now: Optional[datetime] = None, +) -> None: + if not risk_control_enabled(): + return + row = _sync_trading_day(conn, trading_day, now=now) + mood_list = parse_mood_issues(mood_issues_raw) + if mood_issues_daily_freeze_enabled() and mood_list: + _set_daily_frozen(conn, trading_day=trading_day, now=now) + conn.execute( + "UPDATE account_risk_state SET pending_journal_trade_id=NULL, updated_at=? WHERE id=1", + ((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),), + ) + return + pending = _row_get(row, "pending_journal_trade_id") + trigger = (early_exit_trigger or "").strip() + note = (early_exit_note or "").strip() + now_ms = _now_ms(now) + if ( + trigger == "手动平仓" + and note + and int(_row_get(row, "daily_frozen") or 0) != 1 + and _journal_can_reduce_cooloff(row, pending, now_ms) + ): + journal_h = cooling_hours_manual_journal() + until_ms = _journal_cooloff_until_ms(row, now_ms, journal_h) + _set_cooloff_until( + conn, + trading_day=trading_day, + until_ms=until_ms, + hours=journal_h, + now=now, + ) + anchor_ms = until_ms - int(journal_h * 3600 * 1000) + conn.execute( + """UPDATE account_risk_state SET + pending_journal_trade_id=NULL, + last_close_at_ms=?, + updated_at=? + WHERE id=1""", + (int(anchor_ms), (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")), + ) + return + + +def apply_manual_close_journal_cooloff( + conn, + *, + early_exit_note: str, + trading_day: str, + now: Optional[datetime] = None, +) -> None: + """核对修改或复盘:手动平仓 + 说明后尝试将 4h 冷静期降为 1h.""" + note = (early_exit_note or "").strip() + if not note: + return + on_journal_saved( + conn, + early_exit_trigger="手动平仓", + early_exit_note=note, + mood_issues_raw="", + trading_day=trading_day, + now=now, + ) + + +def _next_trading_day_reset_ms(now: datetime, reset_hour: int) -> int: + from datetime import timedelta + + h = max(0, min(23, int(reset_hour))) + candidate = now.replace(hour=h, minute=0, second=0, microsecond=0) + if now >= candidate: + candidate = candidate + timedelta(days=1) + return _now_ms(candidate) + + +def enrich_risk_status_countdown( + st: dict[str, Any], + *, + now: Optional[datetime] = None, + daily_reset_hour: int = 8, +) -> dict[str, Any]: + """补充 freeze_until_ms / freeze_remaining_sec,供前端倒计时展示.""" + if not st.get("enabled", True): + return st + dt = now or datetime.now() + now_ms = _now_ms(dt) + until_ms: Optional[int] = None + if st.get("daily_frozen"): + until_ms = _next_trading_day_reset_ms(dt, daily_reset_hour) + elif st.get("cooloff_until_ms"): + try: + until_ms = int(st["cooloff_until_ms"]) + except (TypeError, ValueError): + until_ms = None + if until_ms is not None and until_ms > now_ms: + st["freeze_until_ms"] = until_ms + st["freeze_remaining_sec"] = max(0, (until_ms - now_ms) // 1000) + else: + st["freeze_until_ms"] = None + st["freeze_remaining_sec"] = 0 + return st + + +def apply_position_limit_risk( + st: dict[str, Any], + active_count: int, + *, + max_active_positions: Optional[int] = None, +) -> dict[str, Any]: + """持仓达 env MAX_ACTIVE_POSITIONS 时叠加「仓位上限冻结」(时间冻结优先展示).""" + out = dict(st or {}) + try: + mx = max(1, int(max_active_positions if max_active_positions is not None else max_active_positions_from_env())) + except (TypeError, ValueError): + mx = max_active_positions_from_env() + try: + ac = max(0, int(active_count)) + except (TypeError, ValueError): + ac = 0 + out["max_active_positions"] = mx + out["active_count"] = ac + if out.get("status") != STATUS_NORMAL: + return out + if ac >= mx: + out["status"] = STATUS_FREEZE_POSITION + out["status_label"] = STATUS_LABELS[STATUS_FREEZE_POSITION] + out["can_trade"] = False + out["can_roll"] = True + out["reason"] = f"已达最大持仓数({ac}/{mx}),新开仓已冻结,顺势加仓仍可用" + out["position_limit_frozen"] = True + out["freeze_until_ms"] = None + out["freeze_remaining_sec"] = 0 + else: + out["position_limit_frozen"] = False + out["can_roll"] = True + return out + + +def compute_account_risk_status( + conn, + *, + trading_day: str, + now: Optional[datetime] = None, + fmt_local_ms: Optional[Callable[[int], str]] = None, +) -> dict[str, Any]: + if not risk_control_enabled(): + return { + "enabled": False, + "status": STATUS_NORMAL, + "status_label": STATUS_LABELS[STATUS_NORMAL], + "can_trade": True, + "reason": "", + "cooloff_until_ms": None, + "cooloff_until": None, + "manual_close_count": 0, + "daily_loss_count": 0, + "daily_frozen": False, + } + row = _sync_trading_day(conn, trading_day, now=now) + now_ms = _now_ms(now) + daily_frozen = int(_row_get(row, "daily_frozen") or 0) == 1 + pending = _row_get(row, "pending_journal_trade_id") + cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms) + if ( + not daily_frozen + and cooloff_until_ms is not None + and _journaled_manual_cooloff_expired( + conn, trading_day=trading_day, now_ms=now_ms, pending=pending + ) + ): + cooloff_until_ms = None + if not daily_frozen: + _repair_stale_cooloff_row( + conn, row, now_ms=now_ms, resolved_until_ms=cooloff_until_ms, now=now + ) + row = _load_state(conn) + cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms) + manual_close_count = int(_row_get(row, "manual_close_count") or 0) + daily_loss_count = int(_row_get(row, "daily_loss_count") or 0) + loss_limit = daily_loss_limit() + + status = STATUS_NORMAL + reason = "" + if daily_frozen: + status = STATUS_DAILY + parts = [] + if loss_limit > 0 and daily_loss_count >= loss_limit: + parts.append(f"日亏损 {daily_loss_count}/{loss_limit} 次") + if manual_close_count >= manual_close_daily_limit(): + parts.append(f"手动平仓 {manual_close_count} 次") + if not parts: + parts.append("手动平仓/日亏损达限或复盘情绪标签") + reason = "账户今日已冻结(" + "、".join(parts) + ")" + elif cooloff_until_ms is not None: + remaining_ms = cooloff_until_ms - now_ms + hours = _cooloff_hours_value(row) + status = _freeze_tier_from_remaining_ms(remaining_ms, hours) + status_label = _freeze_status_label(hours, status) + until_str = _ms_to_local_str(cooloff_until_ms, fmt_local_ms) if fmt_local_ms else None + label = status_label + reason = f"账户{label}中" + if until_str: + reason += f",至 {until_str}" + + can_trade = status == STATUS_NORMAL + freeze_remaining_sec = ( + max(0, (cooloff_until_ms - now_ms) // 1000) if cooloff_until_ms is not None else 0 + ) + return { + "enabled": True, + "status": status, + "status_label": _freeze_status_label(_cooloff_hours_value(row), status) + if status in (STATUS_FREEZE_1H, STATUS_FREEZE_4H) + else STATUS_LABELS[status], + "can_trade": can_trade, + "reason": reason, + "cooloff_until_ms": cooloff_until_ms, + "cooloff_until": _ms_to_local_str(cooloff_until_ms, fmt_local_ms) + if fmt_local_ms and cooloff_until_ms + else None, + "manual_close_count": manual_close_count, + "daily_loss_count": daily_loss_count, + "daily_loss_limit": loss_limit, + "daily_frozen": daily_frozen, + "pending_journal_trade_id": pending, + "freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0, + } + + +def account_risk_blocks_trading( + conn, + *, + trading_day: str, + now: Optional[datetime] = None, + fmt_local_ms: Optional[Callable[[int], str]] = None, +) -> tuple[bool, str]: + """返回 (允许交易, 拒绝原因).""" + st = compute_account_risk_status( + conn, trading_day=trading_day, now=now, fmt_local_ms=fmt_local_ms + ) + if st.get("can_trade"): + return True, "" + return False, str(st.get("reason") or STATUS_LABELS.get(st.get("status"), "账户冻结")) + + +def insert_trade_record_id(conn) -> int: + row = conn.execute("SELECT last_insert_rowid()").fetchone() + return int(row[0] if row else 0) diff --git a/lib/trade/compensating_close_lib.py b/lib/trade/compensating_close_lib.py new file mode 100644 index 0000000..47fbebd --- /dev/null +++ b/lib/trade/compensating_close_lib.py @@ -0,0 +1,16 @@ +"""开仓后挂 TP/SL 失败时的补偿平仓(避免裸仓).""" +from __future__ import annotations + +from typing import Callable + + +def log_compensating_close_error(prefix: str, exc: BaseException) -> None: + print(f"[{prefix}] {exc}", flush=True) + + +def run_compensating_close(close_fn: Callable[[], None], *, log_prefix: str = "compensating_close") -> None: + """执行补偿平仓;二次失败只打日志,不掩盖原始异常.""" + try: + close_fn() + except Exception as e: + log_compensating_close_error(log_prefix, e) diff --git a/lib/trade/daily_open_limit_lib.py b/lib/trade/daily_open_limit_lib.py new file mode 100644 index 0000000..2769e57 --- /dev/null +++ b/lib/trade/daily_open_limit_lib.py @@ -0,0 +1,140 @@ +"""单日开仓次数:软提醒阈值 + 硬上限(三所实例共用).""" +from __future__ import annotations + +import os +from typing import Any, Optional + + +def parse_daily_open_alert_threshold(raw: Any = None, *, default: int = 5) -> int: + """AI 克制提醒阈值;至少 1.""" + try: + v = int(raw if raw is not None and str(raw).strip() != "" else default) + except (TypeError, ValueError): + v = default + return max(1, v) + + +def parse_daily_open_hard_limit(raw: Any = None, *, default: int = 0) -> int: + """硬上限;0 表示不启用.至少 0.""" + try: + v = int(raw if raw is not None and str(raw).strip() != "" else default) + except (TypeError, ValueError): + v = default + return max(0, v) + + +def load_daily_open_limits_from_env( + env: Optional[dict[str, str]] = None, +) -> tuple[int, int]: + """从环境变量读取 (alert_threshold, hard_limit).""" + src = env if env is not None else os.environ + alert = parse_daily_open_alert_threshold(src.get("DAILY_OPEN_ALERT_THRESHOLD")) + hard = parse_daily_open_hard_limit(src.get("DAILY_OPEN_HARD_LIMIT")) + return alert, hard + + +def count_opens_for_trading_day(conn, trading_day: str) -> int: + """本交易日已成功写入 order_monitors 的开仓次数.""" + td = (trading_day or "").strip() + if not td: + return 0 + row = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (td,), + ).fetchone() + return int(row[0] if row else 0) + + +def daily_open_hard_limit_blocks(opens_today: int, hard_limit: int) -> bool: + return int(hard_limit) > 0 and int(opens_today) >= int(hard_limit) + + +def hard_limit_block_reason(opens_today: int, hard_limit: int, reset_hour: int) -> str: + return ( + f"本交易日开仓次数已达上限({int(opens_today)}/{int(hard_limit)})," + f"次日北京时间 {int(reset_hour)}:00 后恢复" + ) + + +def check_daily_open_hard_limit( + conn, + trading_day: str, + hard_limit: int, + reset_hour: int, +) -> tuple[bool, str, int]: + """返回 (允许继续开仓, 拒绝原因, 当日已开次数).""" + opens_today = count_opens_for_trading_day(conn, trading_day) + if daily_open_hard_limit_blocks(opens_today, hard_limit): + return False, hard_limit_block_reason(opens_today, hard_limit, reset_hour), opens_today + return True, "", opens_today + + +def can_trade_new_open( + *, + time_allows: bool, + active_count: int, + max_active_positions: int, + opens_today: int, + hard_limit: int, + extra_blocks: bool = False, +) -> bool: + if extra_blocks: + return False + if not time_allows: + return False + if int(active_count) >= int(max_active_positions): + return False + if daily_open_hard_limit_blocks(opens_today, hard_limit): + return False + return True + + +def should_send_daily_open_alert(before: int, after: int, alert_threshold: int) -> bool: + return int(before) < int(alert_threshold) <= int(after) + + +def build_daily_open_alert_prompt( + trading_day: str, + opens_after: int, + alert_threshold: int, + *, + hard_limit: int = 0, + detail_line: str = "", +) -> str: + hard_txt = ( + f"硬上限 {hard_limit} 次(已达后将禁止新开仓直至下一交易日)." + if int(hard_limit) > 0 + else "未配置单日硬上限." + ) + extra = f" {detail_line}" if detail_line else "" + return ( + f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_after} 次" + f"(AI 提醒阈值 {alert_threshold};{hard_txt})" + f"{extra}" + f"用户自述“上头了”.请给克制提醒." + ) + + +def format_daily_open_counter_line( + opens_today: int, + alert_threshold: int, + hard_limit: int, +) -> str: + if int(hard_limit) > 0: + return ( + f"📅 当日开仓次数:{int(opens_today)} / 硬上限 {int(hard_limit)} 次" + f"(AI 提醒阈值 {int(alert_threshold)})" + ) + return ( + f"📅 当日开仓次数:{int(opens_today)} / AI 提醒阈值 {int(alert_threshold)} 次" + ) + + +def format_daily_open_summary_short( + opens_today: int, + alert_threshold: int, + hard_limit: int, +) -> str: + if int(hard_limit) > 0: + return f"本交易日累计开仓:{int(opens_today)}(硬上限 {int(hard_limit)},提醒 {int(alert_threshold)})" + return f"本交易日累计开仓:{int(opens_today)}(提醒阈值 {int(alert_threshold)})" diff --git a/lib/trade/entry_model_lib.py b/lib/trade/entry_model_lib.py new file mode 100644 index 0000000..e73a882 --- /dev/null +++ b/lib/trade/entry_model_lib.py @@ -0,0 +1,503 @@ +"""趋势户开仓类型:反转·启动 / 顺势·大分歧 / 波段·小分歧;日内户单独 profile.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Optional, Sequence, Tuple + +from lib.trade.trade_policy_lib import TradePolicy + +PROFILE_TREND_DIV = "trend_div" +PROFILE_INTRADAY = "intraday" + +ENTRY_CATEGORY_REVERSAL = "reversal" +ENTRY_CATEGORY_TREND = "trend" +ENTRY_CATEGORY_SWING = "swing" + +ENTRY_MODEL_LAUNCH_A = "launch_a" +ENTRY_MODEL_LAUNCH_B = "launch_b" +ENTRY_MODEL_BIG_DIV_A = "big_div_a" +ENTRY_MODEL_BIG_DIV_B = "big_div_b" +ENTRY_MODEL_SMALL_DIV = "small_div" + +ENTRY_CATEGORY_INTRADAY = "intraday" +ENTRY_MODEL_LIQUIDITY_FALSE_BREAK = "liquidity_false_break" +ENTRY_MODEL_STRUCTURE_BREAKOUT = "structure_breakout" + +VALID_ENTRY_MODEL_CODES = frozenset( + { + ENTRY_MODEL_LAUNCH_A, + ENTRY_MODEL_LAUNCH_B, + ENTRY_MODEL_BIG_DIV_A, + ENTRY_MODEL_BIG_DIV_B, + ENTRY_MODEL_SMALL_DIV, + } +) + +INTRADAY_ENTRY_MODEL_CODES = frozenset( + { + ENTRY_MODEL_LIQUIDITY_FALSE_BREAK, + ENTRY_MODEL_STRUCTURE_BREAKOUT, + } +) + +ALL_ENTRY_MODEL_CODES = VALID_ENTRY_MODEL_CODES | INTRADAY_ENTRY_MODEL_CODES + +ENTRY_CATEGORY_LABELS: dict[str, str] = { + ENTRY_CATEGORY_REVERSAL: "反转", + ENTRY_CATEGORY_TREND: "顺势", + ENTRY_CATEGORY_SWING: "波段", +} + +TRADE_STYLE_FALLBACK_ENTRY_REASONS: Tuple[str, ...] = ("趋势单", "波段单") + +INTRADAY_LEGACY_TREND_ENTRY_REASONS: Tuple[str, ...] = ( + "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", + "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", + "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", + "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", + "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", +) + +# code, label, category, trade_style, help +_ENTRY_SPECS: Tuple[Tuple[str, str, str, str, str], ...] = ( + ( + ENTRY_MODEL_LAUNCH_A, + "启动A", + ENTRY_CATEGORY_REVERSAL, + "trend", + "反转链结构内:摸参考极值前小收敛,或 B 失败后 5m N 字试仓(不单列)", + ), + ( + ENTRY_MODEL_LAUNCH_B, + "启动B", + ENTRY_CATEGORY_REVERSAL, + "trend", + "第二次到参考极值附近,无小收敛时的实体突破", + ), + ( + ENTRY_MODEL_BIG_DIV_A, + "大分歧A", + ENTRY_CATEGORY_TREND, + "trend", + "主升已确立:突破前收敛,不创新低企稳(空:不创新高)", + ), + ( + ENTRY_MODEL_BIG_DIV_B, + "大分歧B", + ENTRY_CATEGORY_TREND, + "trend", + "主升已确立:结构实体突破确认后入场", + ), + ( + ENTRY_MODEL_SMALL_DIV, + "小分歧", + ENTRY_CATEGORY_SWING, + "swing", + "主升已确立:二次探底 N 字或 5m 三均线重新多头(空:二次探顶 / 空头均线)", + ), +) + +_INTRADAY_ENTRY_SPECS: Tuple[Tuple[str, str, str, str, str], ...] = ( + ( + ENTRY_MODEL_LIQUIDITY_FALSE_BREAK, + "假破", + ENTRY_CATEGORY_INTRADAY, + "trend", + "流动性扫单 → 假突破验证 → 5m N 字 → 15m 顶/底分型", + ), + ( + ENTRY_MODEL_STRUCTURE_BREAKOUT, + "结构突破", + ENTRY_CATEGORY_INTRADAY, + "trend", + "15m 结构有效突破(收盘确认)", + ), +) + +_CODE_TO_LABEL = {code: label for code, label, _, _, _ in _ENTRY_SPECS} +_CODE_TO_LABEL.update({code: label for code, label, _, _, _ in _INTRADAY_ENTRY_SPECS}) +_CODE_TO_STYLE = {code: style for code, _, _, style, _ in _ENTRY_SPECS} +_CODE_TO_STYLE.update({code: style for code, _, _, style, _ in _INTRADAY_ENTRY_SPECS}) +_CODE_TO_CATEGORY = {code: cat for code, _, cat, _, _ in _ENTRY_SPECS} +_CODE_TO_CATEGORY.update({code: cat for code, _, cat, _, _ in _INTRADAY_ENTRY_SPECS}) +_LABEL_TO_CODE = {label: code for code, label, _, _, _ in _ENTRY_SPECS} +_LABEL_TO_CODE.update({label: code for code, label, _, _, _ in _INTRADAY_ENTRY_SPECS}) +_CODE_TO_HELP = {code: help for code, _, _, _, help in _ENTRY_SPECS} +_CODE_TO_HELP.update({code: help for code, _, _, _, help in _INTRADAY_ENTRY_SPECS}) + +_CATEGORY_ORDER: Tuple[str, ...] = ( + ENTRY_CATEGORY_REVERSAL, + ENTRY_CATEGORY_TREND, + ENTRY_CATEGORY_SWING, +) + +_INTRADAY_WHITELIST = frozenset({"BTC", "ETH"}) + + +@dataclass(frozen=True) +class EntryModelOption: + code: str + label: str + category: str + trade_style: str + help: str + + +def is_intraday_trading_profile(policy: TradePolicy) -> bool: + """日内户:启用 BTC/ETH 白名单(env 中 TRADE_SYMBOL_WHITELIST).""" + if not policy.symbol_restrict_enabled: + return False + if not policy.symbol_whitelist: + return False + return all(s in _INTRADAY_WHITELIST for s in policy.symbol_whitelist) + + +def order_entry_profile(policy: TradePolicy) -> str: + return PROFILE_INTRADAY if is_intraday_trading_profile(policy) else PROFILE_TREND_DIV + + +def entry_model_options() -> Tuple[EntryModelOption, ...]: + return tuple( + EntryModelOption(code=code, label=label, category=cat, trade_style=style, help=help) + for code, label, cat, style, help in _ENTRY_SPECS + ) + + +def intraday_entry_model_options() -> Tuple[EntryModelOption, ...]: + return tuple( + EntryModelOption(code=code, label=label, category=cat, trade_style=style, help=help) + for code, label, cat, style, help in _INTRADAY_ENTRY_SPECS + ) + + +def entry_model_categories() -> list[dict[str, Any]]: + """两级 UI:反转 / 顺势 / 波段 → 子选项.""" + opts = entry_model_options() + out: list[dict[str, Any]] = [] + for cat_key in _CATEGORY_ORDER: + children = [ + { + "code": o.code, + "label": o.label, + "trade_style": o.trade_style, + "help": o.help, + } + for o in opts + if o.category == cat_key + ] + if not children: + continue + out.append( + { + "key": cat_key, + "label": ENTRY_CATEGORY_LABELS.get(cat_key, cat_key), + "options": children, + } + ) + return out + + +def entry_model_category(code: Optional[str]) -> str: + c = normalize_entry_model_code(code) + return _CODE_TO_CATEGORY.get(c, "") + + +def normalize_entry_model_code(raw: Optional[str]) -> str: + v = (raw or "").strip().lower() + if v in ALL_ENTRY_MODEL_CODES: + return v + label = (raw or "").strip() + if label in _LABEL_TO_CODE: + return _LABEL_TO_CODE[label] + return "" + + +def entry_model_label(code: Optional[str]) -> str: + c = normalize_entry_model_code(code) + return _CODE_TO_LABEL.get(c, "") + + +def entry_category_display_prefix(category: str) -> str: + """两级展示用的一级前缀:反转 / 顺势 / 波段单(含日内).""" + cat = (category or "").strip() + if cat in (ENTRY_CATEGORY_SWING, ENTRY_CATEGORY_INTRADAY): + return "波段单" + return ENTRY_CATEGORY_LABELS.get(cat, "") + + +def entry_model_display_label(code: Optional[str]) -> str: + """两级展示:反转/启动A,顺势/大分歧A,波段单/小分歧,波段单/假破.""" + c = normalize_entry_model_code(code) + if not c: + return "" + label = entry_model_label(c) + if not label: + return "" + prefix = entry_category_display_prefix(entry_model_category(c)) + if prefix: + return f"{prefix}/{label}" + return label + + +def format_entry_type_display( + text: Optional[str] = None, + *, + entry_model: Optional[str] = None, + trade_style: Optional[str] = None, +) -> str: + """交易记录/持仓展示:已知 entry_model 或短标签 → 两级文案.""" + if entry_model: + disp = entry_model_display_label(entry_model) + if disp: + return disp + raw = (text or "").strip() + if not raw: + ts = (trade_style or "").strip().lower() + if ts in ("trend", "swing"): + return trade_style_label_zh(ts) + return "" + if "/" in raw: + return raw + code = normalize_entry_model_code(raw) + if code: + disp = entry_model_display_label(code) + if disp: + return disp + return raw + + +def trade_style_for_entry_model(code: Optional[str]) -> str: + c = normalize_entry_model_code(code) + return _CODE_TO_STYLE.get(c, "trend") + + +def trade_style_label_zh(trade_style: str) -> str: + return "波段单" if (trade_style or "").strip().lower() == "swing" else "趋势单" + + +def trend_div_entry_reason_display_options() -> Tuple[str, ...]: + return tuple(entry_model_display_label(code) for code, _, _, _, _ in _ENTRY_SPECS) + + +def intraday_entry_reason_display_options() -> Tuple[str, ...]: + return tuple(entry_model_display_label(code) for code, _, _, _, _ in _INTRADAY_ENTRY_SPECS) + + +def normalize_review_entry_reason(raw: Optional[str], allowed: Sequence[str]) -> str: + """复盘/核对开仓类型:允许两级展示名,兼容旧短标签.""" + s = (raw or "").strip() + if not s: + return "" + allowed_set = frozenset(allowed) + if s in allowed_set: + return s + disp = format_entry_type_display(s) + if disp in allowed_set: + return disp + code = normalize_entry_model_code(s) + if code: + disp2 = entry_model_display_label(code) + if disp2 in allowed_set: + return disp2 + return "" + + +def trend_manual_entry_reason_count(policy: TradePolicy) -> int: + if is_intraday_trading_profile(policy): + return len(intraday_entry_reason_display_options()) + return len(trend_div_entry_reason_display_options()) + + +def build_journal_entry_reason_options() -> Tuple[str, ...]: + """复盘开仓类型:仅 entry model,不含 trade_style 兜底与策略/关键位自动类型.""" + return trend_div_entry_reason_display_options() + + +def build_trend_div_entry_reason_options( + strategy_options: Sequence[str], +) -> Tuple[str, ...]: + del strategy_options + return trend_div_entry_reason_display_options() + TRADE_STYLE_FALLBACK_ENTRY_REASONS + + +def build_intraday_entry_reason_options( + key_options: Sequence[str], + strategy_options: Sequence[str], +) -> Tuple[str, ...]: + del strategy_options, key_options + return intraday_entry_reason_display_options() + + +def entry_reason_options_for_policy( + policy: TradePolicy, + key_options: Sequence[str], + strategy_options: Sequence[str], +) -> Tuple[str, ...]: + del key_options, strategy_options + if is_intraday_trading_profile(policy): + return build_intraday_entry_reason_options((), ()) + return build_trend_div_entry_reason_options(()) + + +def parse_manual_order_style_fields( + policy: TradePolicy, + form: Mapping[str, Any], + *, + default_trade_style: str = "trend", +) -> Tuple[str, Optional[str], Optional[str]]: + """返回 (trade_style, entry_model_code|None, error_message|None).""" + if is_intraday_trading_profile(policy): + entry_model = normalize_entry_model_code(form.get("entry_model")) + if entry_model in INTRADAY_ENTRY_MODEL_CODES: + return "trend", entry_model, None + raw_style = (form.get("trade_style") or "").strip().lower() + if raw_style in ("trend", "swing"): + return raw_style, None, None + return "", None, "请选择开仓类型(假破 / 结构突破)" + + entry_model = normalize_entry_model_code(form.get("entry_model")) + if not entry_model: + return "", None, "请选择开仓类型(反转 / 顺势 / 波段)" + trade_style = trade_style_for_entry_model(entry_model) + return trade_style, entry_model, None + + +def resolve_trade_record_entry_reason( + *, + entry_reason: Optional[str] = None, + entry_model: Optional[str] = None, + key_signal_type: Optional[str] = None, + monitor_type: Optional[str] = None, + trade_style: Optional[str] = None, + entry_reason_from_key_signal=None, + entry_reason_for_monitor_type=None, +) -> str: + er = (entry_reason or "").strip() + if er: + return er + label = entry_model_display_label(entry_model) + if label: + return label + kst = (key_signal_type or "").strip() + if kst and entry_reason_from_key_signal is not None: + from_key = (entry_reason_from_key_signal(kst) or "").strip() + if from_key: + return from_key + if entry_reason_for_monitor_type is not None: + from_mt = (entry_reason_for_monitor_type(monitor_type) or "").strip() + if from_mt: + return from_mt + ts = (trade_style or "").strip().lower() + if ts in ("trend", "swing"): + return trade_style_label_zh(ts) + return "" + + +def resolve_effective_trade_entry_reason( + *, + reviewed_entry_reason: Optional[str] = None, + entry_reason: Optional[str] = None, + entry_model: Optional[str] = None, + key_signal_type: Optional[str] = None, + monitor_type: Optional[str] = None, + trade_style: Optional[str] = None, + entry_reason_from_key_signal=None, + entry_reason_for_monitor_type=None, +) -> str: + """交易记录展示/导出用:复盘优先,再回落 entry_model / 关键位 / 策略 / trade_style.""" + for raw in (reviewed_entry_reason, entry_reason): + er = (raw or "").strip() + if er: + return format_entry_type_display( + er, + entry_model=entry_model, + trade_style=trade_style, + ) + return format_entry_type_display( + resolve_trade_record_entry_reason( + entry_model=entry_model, + key_signal_type=key_signal_type, + 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, + ), + entry_model=entry_model, + trade_style=trade_style, + ) + + +def enrich_entry_model_display(item: dict) -> dict: + code = normalize_entry_model_code(item.get("entry_model")) + if code: + item["entry_model"] = code + item["entry_model_label"] = entry_model_display_label(code) + cat = entry_model_category(code) + if cat: + item["entry_model_category"] = cat + item["entry_model_category_label"] = ENTRY_CATEGORY_LABELS.get(cat, "") + else: + item.setdefault("entry_model_label", "") + return item + + +def open_position_button_label(policy: TradePolicy, sizing_mode: str) -> str: + from lib.trade.position_sizing_lib import mode_label_zh + + mode_txt = mode_label_zh(sizing_mode) + if is_intraday_trading_profile(policy): + return f"开仓(日内·{mode_txt})" + return f"开仓({mode_txt})" + + +def order_entry_template_context(policy: TradePolicy) -> dict: + profile = order_entry_profile(policy) + opts = entry_model_options() + intraday_opts = intraday_entry_model_options() + return { + "order_entry_profile": profile, + "intraday_discipline": profile == PROFILE_INTRADAY, + "intraday_entry_model_options": [ + { + "code": o.code, + "label": o.label, + "trade_style": o.trade_style, + "help": o.help, + } + for o in intraday_opts + ], + "entry_model_options": [ + { + "code": o.code, + "label": o.label, + "category": o.category, + "trade_style": o.trade_style, + "help": o.help, + } + for o in opts + ], + "entry_model_categories": entry_model_categories(), + "entry_model_trade_style_map": {o.code: o.trade_style for o in opts}, + "entry_model_code_to_category": {o.code: o.category for o in opts}, + } + + +def meta_entry_context(policy: TradePolicy) -> dict: + """开仓入口展示上下文(风格/是否隐藏委托等).""" + profile = order_entry_profile(policy) + return { + "order_entry_profile": profile, + "intraday_discipline": profile == PROFILE_INTRADAY, + } + + +def migrate_entry_model_columns(conn) -> None: + for table in ("order_monitors", "trade_records"): + try: + conn.execute(f"ALTER TABLE {table} ADD COLUMN entry_model TEXT") + except Exception: + pass + + +# 兼容旧引用:现为两级展示文案 +TREND_DIV_ENTRY_REASON_LABELS = trend_div_entry_reason_display_options() diff --git a/lib/trade/force_close_lib.py b/lib/trade/force_close_lib.py new file mode 100644 index 0000000..ef7c1db --- /dev/null +++ b/lib/trade/force_close_lib.py @@ -0,0 +1,366 @@ +"""整点强制清仓(FORCE_CLOSE_*):UI 标识与持仓倒计时.""" +from __future__ import annotations + +import os +import time +from datetime import datetime, timedelta +from typing import Any, Optional +from zoneinfo import ZoneInfo + +FORCE_CLOSE_RESULT = "强制清仓" +# 默认宽限(分钟);运行时优先读 FORCE_CLOSE_GRACE_MINUTES +FORCE_CLOSE_GRACE_MINUTES = 5 + + +def force_close_grace_minutes(override: Any = None) -> int: + """整点强制清仓执行窗口长度(分钟).""" + if override is not None and str(override).strip() != "": + raw = override + else: + raw = os.getenv("FORCE_CLOSE_GRACE_MINUTES") + if raw is None or str(raw).strip() == "": + raw = FORCE_CLOSE_GRACE_MINUTES + try: + return max(1, int(raw)) + except (TypeError, ValueError): + return max(1, int(FORCE_CLOSE_GRACE_MINUTES)) + + +def app_timezone_name() -> str: + return (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai" + + +def normalize_force_close_bj_hour(value: Any) -> int: + try: + h = int(value) + except (TypeError, ValueError): + return 0 + return max(0, min(23, h)) + + +def _now_dt(*, now_ms: Optional[int] = None, tz_name: Optional[str] = None) -> datetime: + tz = ZoneInfo(tz_name or app_timezone_name()) + if now_ms is None: + return datetime.now(tz) + return datetime.fromtimestamp(int(now_ms) / 1000, tz=tz) + + +def force_close_hour_label(bj_hour: Any) -> str: + return f"{normalize_force_close_bj_hour(bj_hour):02d}:00" + + +def force_close_label(bj_hour: Any) -> str: + return f"强制清仓 {force_close_hour_label(bj_hour)}" + + +def is_force_close_active_hour( + bj_hour: Any, + *, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, + grace_minutes: Optional[int] = None, +) -> bool: + """当前是否处于整点强制清仓执行窗口(整点起 grace 分钟内).""" + return is_force_close_executing( + bj_hour, + now_ms=now_ms, + tz_name=tz_name, + grace_minutes=grace_minutes, + ) + + +def is_force_close_executing( + bj_hour: Any, + *, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, + grace_minutes: Optional[int] = None, +) -> bool: + hour = normalize_force_close_bj_hour(bj_hour) + now = _now_dt(now_ms=now_ms, tz_name=tz_name) + target = now.replace(hour=hour, minute=0, second=0, microsecond=0) + if now < target: + return False + grace = force_close_grace_minutes(grace_minutes) + end = target + timedelta(minutes=grace) + return now < end + + +def force_close_blocks_new_open( + enabled: bool, + bj_hour: Any, + *, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, + grace_minutes: Optional[int] = None, +) -> tuple[bool, str]: + """强制清仓执行窗口内禁止新开仓.返回 (是否拦截, 说明文案).""" + if not enabled: + return False, "" + grace = force_close_grace_minutes(grace_minutes) + if not is_force_close_executing( + bj_hour, now_ms=now_ms, tz_name=tz_name, grace_minutes=grace + ): + return False, "" + label = force_close_hour_label(bj_hour) + return ( + True, + f"强制清仓窗口内(北京时间 {label} 起 {grace} 分钟),暂不可开仓", + ) + + +def parse_closed_at_dt( + closed_at: Any, + *, + tz_name: Optional[str] = None, +) -> Optional[datetime]: + if closed_at is None: + return None + text = str(closed_at).strip() + if not text: + return None + tz = ZoneInfo(tz_name or app_timezone_name()) + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): + try: + return datetime.strptime(text, fmt).replace(tzinfo=tz) + except ValueError: + continue + return None + + +def is_close_at_force_close_window( + closed_at: Any, + bj_hour: Any, + *, + grace_minutes: Optional[int] = None, + tz_name: Optional[str] = None, +) -> bool: + """平仓时刻是否落在北京时间整点强制清仓窗口内.""" + dt = parse_closed_at_dt(closed_at, tz_name=tz_name) + if dt is None: + return False + hour = normalize_force_close_bj_hour(bj_hour) + if dt.hour != hour: + return False + return dt.minute < force_close_grace_minutes(grace_minutes) + + +def infer_force_close_result( + closed_at: Any, + *, + enabled: bool, + bj_hour: Any, + grace_minutes: Optional[int] = None, + tz_name: Optional[str] = None, +) -> Optional[str]: + if not enabled: + return None + if is_close_at_force_close_window( + closed_at, bj_hour, grace_minutes=grace_minutes, tz_name=tz_name + ): + return FORCE_CLOSE_RESULT + return None + + +def coerce_force_close_result( + result: Optional[str], + closed_at: Any, + *, + enabled: bool, + bj_hour: Any, + miss_reason: Optional[str] = None, + grace_minutes: Optional[int] = None, + tz_name: Optional[str] = None, +) -> tuple[str, str]: + """同步平仓归类:整点窗口内优先记为强制清仓.""" + res = (result or "").strip() + note = (miss_reason or "").strip() + if res == FORCE_CLOSE_RESULT: + return res, note + fc = infer_force_close_result( + closed_at, + enabled=enabled, + bj_hour=bj_hour, + grace_minutes=grace_minutes, + tz_name=tz_name, + ) + if not fc: + return res, note + if not note: + note = f"北京时间 {force_close_hour_label(bj_hour)} 整点风控清仓" + return fc, note + + +def apply_force_close_display_result( + result: Optional[str], + closed_at: Any, + *, + enabled: bool, + bj_hour: Any, + grace_minutes: Optional[int] = None, + tz_name: Optional[str] = None, +) -> str: + """展示层:外部平仓/手动平仓若落在整点窗口,显示为强制清仓.""" + res = (result or "").strip() + if res == FORCE_CLOSE_RESULT: + return res + fc = infer_force_close_result( + closed_at, + enabled=enabled, + bj_hour=bj_hour, + grace_minutes=grace_minutes, + tz_name=tz_name, + ) + if fc and (res in ("", "外部平仓", "手动平仓") or res.startswith("外部平仓")): + return fc + return res + + +def compute_next_force_close_at_ms( + *, + bj_hour: Any, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, +) -> Optional[int]: + """下一次强制清仓时刻(北京时间整点)的 epoch 毫秒.""" + hour = normalize_force_close_bj_hour(bj_hour) + now = _now_dt(now_ms=now_ms, tz_name=tz_name) + target = now.replace(hour=hour, minute=0, second=0, microsecond=0) + if now >= target: + target += timedelta(days=1) + return int(target.timestamp() * 1000) + + +def force_close_remaining_seconds( + close_at_ms: Any, + *, + now_ms: Optional[int] = None, +) -> Optional[int]: + try: + close_at = int(close_at_ms) + except (TypeError, ValueError): + return None + now = int(now_ms if now_ms is not None else time.time() * 1000) + return max(0, int((close_at - now) / 1000)) + + +def format_force_close_countdown(seconds: Any, *, active: bool = False) -> str: + if active: + return "执行中" + try: + sec = max(0, int(seconds)) + except (TypeError, ValueError): + return "--:--:--" + h = sec // 3600 + m = (sec % 3600) // 60 + s = sec % 60 + return f"{h:02d}:{m:02d}:{s:02d}" + + +def build_force_close_state( + enabled: bool, + bj_hour: Any, + *, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, + has_active_positions: Optional[bool] = None, +) -> dict[str, Any]: + """实例级强制清仓状态(模板 / API 共用).""" + grace = force_close_grace_minutes() + if not enabled: + return { + "enabled": False, + "bj_hour": normalize_force_close_bj_hour(bj_hour), + "hour_label": force_close_hour_label(bj_hour), + "label": force_close_label(bj_hour), + "grace_minutes": grace, + "next_at_ms": None, + "remaining_sec": None, + "countdown": "", + "active": False, + "executing": False, + } + hour = normalize_force_close_bj_hour(bj_hour) + executing = is_force_close_executing( + hour, now_ms=now_ms, tz_name=tz_name, grace_minutes=grace + ) + active = executing and (has_active_positions is not False) + next_at_ms = compute_next_force_close_at_ms(bj_hour=hour, now_ms=now_ms, tz_name=tz_name) + rem = force_close_remaining_seconds(next_at_ms, now_ms=now_ms) if next_at_ms else None + return { + "enabled": True, + "bj_hour": hour, + "hour_label": force_close_hour_label(hour), + "label": force_close_label(hour), + "grace_minutes": grace, + "next_at_ms": next_at_ms, + "remaining_sec": rem, + "countdown": format_force_close_countdown(rem, active=active), + "active": active, + "executing": executing, + } + + +def force_close_template_context( + enabled: bool, + bj_hour: Any, + *, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, + has_active_positions: Optional[bool] = None, +) -> dict[str, dict[str, Any]]: + return { + "force_close": build_force_close_state( + enabled, + bj_hour, + now_ms=now_ms, + tz_name=tz_name, + has_active_positions=has_active_positions, + ) + } + + +def apply_force_close_to_payload( + payload: dict[str, Any], + *, + enabled: bool, + bj_hour: Any, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, +) -> None: + """为 active 持仓 JSON 附加整点强制清仓倒计时.""" + state = build_force_close_state( + enabled, + bj_hour, + now_ms=now_ms, + tz_name=tz_name, + has_active_positions=True, + ) + payload["force_close_enabled"] = bool(state["enabled"]) + payload["force_close_bj_hour"] = state["bj_hour"] + payload["force_close_at_ms"] = state["next_at_ms"] + payload["force_close_label"] = state["label"] if state["enabled"] else "" + payload["force_close_remaining_sec"] = state["remaining_sec"] + payload["force_close_countdown"] = state["countdown"] + payload["force_close_active"] = bool(state["active"]) + + +def enrich_orders_force_close( + orders: list[dict[str, Any]], + enabled: bool, + bj_hour: Any, + *, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, +) -> None: + if not enabled or not orders: + return + for item in orders: + if isinstance(item, dict): + apply_force_close_to_payload( + item, + enabled=enabled, + bj_hour=bj_hour, + now_ms=now_ms, + tz_name=tz_name, + ) diff --git a/lib/trade/manual_sltp_lib.py b/lib/trade/manual_sltp_lib.py new file mode 100644 index 0000000..41a5783 --- /dev/null +++ b/lib/trade/manual_sltp_lib.py @@ -0,0 +1,136 @@ +"""实盘人工下单:止盈止损模式(价格 / 百分比 / 固定盈亏比).""" +from __future__ import annotations + +from typing import Any, Optional, Tuple + +MANUAL_FIXED_RR_DEFAULT = 1.5 + +SLTP_MODE_PRICE = "price" +SLTP_MODE_PCT = "pct" +SLTP_MODE_FIXED_RR = "fixed_rr" + +OPEN_SLTP_MODES = frozenset({SLTP_MODE_PRICE, SLTP_MODE_PCT, SLTP_MODE_FIXED_RR}) +ENTRUST_SLTP_MODES = frozenset({SLTP_MODE_PRICE, SLTP_MODE_PCT}) + + +def normalize_open_sltp_mode(raw: Optional[str]) -> str: + mode = (raw or SLTP_MODE_FIXED_RR).strip().lower() + if mode in OPEN_SLTP_MODES: + return mode + return SLTP_MODE_PRICE + + +def normalize_entrust_sltp_mode(raw: Optional[str]) -> str: + mode = (raw or SLTP_MODE_PRICE).strip().lower() + if mode in ENTRUST_SLTP_MODES: + return mode + return SLTP_MODE_PRICE + + +def parse_fixed_rr(raw: Any, *, default: float = MANUAL_FIXED_RR_DEFAULT) -> float: + try: + v = float(raw) + if v > 0: + return v + except (TypeError, ValueError): + pass + return float(default) + + +def calc_tp_from_fixed_rr( + direction: str, + entry_price: float, + stop_loss: float, + rr_ratio: float, +) -> float: + entry = float(entry_price) + sl = float(stop_loss) + rr = float(rr_ratio) + if entry <= 0 or sl <= 0 or rr <= 0: + raise ValueError("固定盈亏比参数无效") + side = (direction or "long").strip().lower() + if side == "short": + risk = sl - entry + if risk <= 0: + raise ValueError("止损方向不合法:做空时止损须高于入场价") + return entry - risk * rr + risk = entry - sl + if risk <= 0: + raise ValueError("止损方向不合法:做多时止损须低于入场价") + return entry + risk * rr + + +def _resolve_pct_sltp(direction: str, live_price: float, data: dict[str, Any]) -> Tuple[float, float]: + sl_pct = float(data.get("sl_pct") or 0) + tp_pct = float(data.get("tp_pct") or 0) + if sl_pct <= 0 or tp_pct <= 0: + raise ValueError("百分比止盈止损须为正数") + sl_ratio = sl_pct / 100.0 + tp_ratio = tp_pct / 100.0 + entry = float(live_price) + if (direction or "long").strip().lower() == "short": + stop_loss = entry * (1 + sl_ratio) + take_profit = entry * (1 - tp_ratio) + else: + stop_loss = entry * (1 - sl_ratio) + take_profit = entry * (1 + tp_ratio) + return stop_loss, take_profit + + +def _resolve_price_sltp( + data: dict[str, Any], + *, + fallback_sl: Optional[float] = None, + fallback_tp: Optional[float] = None, + require_tp: bool = True, +) -> Tuple[float, float]: + stop_loss = float(data.get("sl") or data.get("stop_loss") or 0) + take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0) + if stop_loss <= 0 and fallback_sl is not None: + stop_loss = float(fallback_sl) + if take_profit <= 0 and fallback_tp is not None: + take_profit = float(fallback_tp) + if stop_loss <= 0: + raise ValueError("止损价格须大于 0" if require_tp else "请填写止损价格") + if require_tp and take_profit <= 0: + raise ValueError("止盈止损价格须大于 0" if fallback_tp is None else "请填写止盈价格,或保留原计划止盈") + return stop_loss, take_profit + + +def resolve_open_sltp_prices( + direction: str, + live_price: float, + sltp_mode: Optional[str], + data: dict[str, Any], +) -> Tuple[float, float]: + """新开仓 /add_order:支持 price,pct,fixed_rr.""" + mode = normalize_open_sltp_mode(sltp_mode) + if mode == SLTP_MODE_PCT: + return _resolve_pct_sltp(direction, live_price, data) + if mode == SLTP_MODE_FIXED_RR: + stop_loss, _ = _resolve_price_sltp(data, require_tp=False) + rr = parse_fixed_rr(data.get("fixed_rr")) + take_profit = calc_tp_from_fixed_rr(direction, live_price, stop_loss, rr) + return stop_loss, take_profit + return _resolve_price_sltp(data, require_tp=True) + + +def resolve_entrust_sltp_prices( + direction: str, + live_price: float, + sltp_mode: Optional[str], + data: dict[str, Any], + *, + fallback_sl: Optional[float] = None, + fallback_tp: Optional[float] = None, +) -> Tuple[float, float]: + """持仓委托弹窗:仅 price / pct,不校验盈亏比.""" + mode = normalize_entrust_sltp_mode(sltp_mode) + if mode == SLTP_MODE_PCT: + return _resolve_pct_sltp(direction, live_price, data) + return _resolve_price_sltp( + data, + fallback_sl=fallback_sl, + fallback_tp=fallback_tp, + require_tp=True, + ) diff --git a/lib/trade/open_trade_gate_lib.py b/lib/trade/open_trade_gate_lib.py new file mode 100644 index 0000000..be298d5 --- /dev/null +++ b/lib/trade/open_trade_gate_lib.py @@ -0,0 +1,57 @@ +"""三所开仓门禁:账户风控 + 强制清仓窗口 + 仓位/日开仓上限.""" +from __future__ import annotations + +from typing import Any, Optional + +from lib.trade.daily_open_limit_lib import can_trade_new_open +from lib.trade.force_close_lib import force_close_blocks_new_open + + +def resolve_manual_open_gate( + *, + time_allows: bool, + active_count: int, + max_active_positions: int, + opens_today: int, + hard_limit: int, + risk_status: Optional[dict[str, Any]], + force_close_enabled: bool, + force_close_bj_hour: Any, + now_ms: Optional[int] = None, + reset_hour: int = 8, +) -> dict[str, Any]: + """汇总是否可开仓及按钮旁说明文案.""" + rs = risk_status if isinstance(risk_status, dict) else {} + risk_can = bool(rs.get("can_trade", True)) + fc_block, fc_note = force_close_blocks_new_open( + bool(force_close_enabled), + force_close_bj_hour, + now_ms=now_ms, + ) + can_trade = can_trade_new_open( + time_allows=time_allows, + active_count=active_count, + max_active_positions=max_active_positions, + opens_today=opens_today, + hard_limit=hard_limit, + extra_blocks=(not risk_can) or fc_block, + ) + note = "" + if fc_block and fc_note: + note = fc_note + elif not risk_can: + note = str(rs.get("reason") or "账户冷静期/日冻结中,暂不可开仓") + elif not time_allows: + note = f"未到北京时间 {int(reset_hour)}:00,暂不可开仓" + elif int(active_count) >= int(max_active_positions): + note = f"已达最大持仓数({int(active_count)}/{int(max_active_positions)}),暂不可开仓" + elif int(hard_limit) > 0 and int(opens_today) >= int(hard_limit): + note = ( + f"本交易日开仓已达上限({int(opens_today)}/{int(hard_limit)})," + f"次日北京时间 {int(reset_hour)}:00 后恢复" + ) + return { + "can_trade": can_trade, + "open_block_note": note if not can_trade else "", + "force_close_blocks": fc_block, + } diff --git a/lib/trade/order_monitor_display_lib.py b/lib/trade/order_monitor_display_lib.py new file mode 100644 index 0000000..6351fc1 --- /dev/null +++ b/lib/trade/order_monitor_display_lib.py @@ -0,0 +1,461 @@ +"""实时持仓展示:开仓快照盈亏比,交易所止损是否已保本.""" +from __future__ import annotations + +from typing import Any, Callable, Optional + + +def _positive_float(value: Any) -> Optional[float]: + try: + v = float(value) + return v if v > 0 else None + except (TypeError, ValueError): + return None + + +def snapshot_stop_loss(initial_stop_loss: Any, stop_loss: Any) -> Optional[float]: + """展示盈亏比 / 交易记录时优先用开仓时止损快照,不用后续改单后的止损.""" + sl = _positive_float(initial_stop_loss) + if sl is not None: + return sl + return _positive_float(stop_loss) + + +def monitor_open_stop_loss(row: Any) -> Optional[float]: + """从 order_monitors 行取开仓止损快照.""" + try: + keys = row.keys() if hasattr(row, "keys") else () + except Exception: + keys = () + init = row["initial_stop_loss"] if "initial_stop_loss" in keys else None + cur = row["stop_loss"] if "stop_loss" in keys else None + if init is None and isinstance(row, dict): + init = row.get("initial_stop_loss") + cur = row.get("stop_loss") + return snapshot_stop_loss(init, cur) + + +def snapshot_rr( + calc_rr_ratio_fn: Callable[..., Optional[float]], + direction: str, + trigger_price: Any, + initial_stop_loss: Any, + stop_loss: Any, + take_profit: Any, +) -> Optional[float]: + entry = _positive_float(trigger_price) + sl = snapshot_stop_loss(initial_stop_loss, stop_loss) + tp = _positive_float(take_profit) + if entry is None or sl is None or tp is None: + return None + return calc_rr_ratio_fn(direction or "long", entry, sl, tp) + + +def tpsl_slot_trigger_price(slot: Any) -> Optional[float]: + if not isinstance(slot, dict): + return None + for key in ("trigger_price", "trigger_display"): + v = _positive_float(slot.get(key)) + if v is not None: + return v + return None + + +def stop_is_profit_protecting(direction: str, entry_price: Any, stop_loss: Any) -> bool: + """ + 止损是否已在盈利侧(保本/锁盈),不再适用「开仓盈亏比」风控. + 做空:止损 < 成交价;做多:止损 > 成交价. + """ + entry = _positive_float(entry_price) + sl = _positive_float(stop_loss) + if entry is None or sl is None: + return False + d = (direction or "long").strip().lower() + if d == "short": + return sl < entry + return sl > entry + + +def tpsl_update_passes_rr_gate( + direction: str, + entry_price: Any, + stop_loss: Any, + take_profit: Any, + min_rr: float, + calc_rr_ratio_fn: Callable[..., Optional[float]], +) -> tuple[bool, Optional[str]]: + """持仓委托改价:盈利侧止损跳过最低盈亏比;否则按开仓价几何校验.""" + if stop_is_profit_protecting(direction, entry_price, stop_loss): + return True, None + rr = calc_rr_ratio_fn(direction or "long", entry_price, stop_loss, take_profit) + if rr is not None and rr >= float(min_rr): + return True, None + rr_txt = f"{rr:.4f}" if rr is not None else "无法计算" + return False, f"计划盈亏比 {rr_txt}:1 低于最低要求 {min_rr}:1(盈利侧保本止损不受此限)" + + +def resolve_breakeven_entry_price(entry_price: Any, avg_entry_price: Any = None) -> Optional[float]: + """保本判断基准价:有持仓加权均价时优先(滚仓后),否则用首仓成交价.""" + avg = _positive_float(avg_entry_price) + if avg is not None: + return avg + return _positive_float(entry_price) + + +def stale_breakeven_armed(direction: str, entry_price: Any, stop_loss: Any, breakeven_armed: Any) -> bool: + """止损已回到亏损侧时 breakeven_armed 视为过期(如滚仓下移止损).""" + try: + armed = int(breakeven_armed or 0) != 0 + except (TypeError, ValueError): + return False + if not armed: + return False + return not stop_is_profit_protecting(direction, entry_price, stop_loss) + + +def is_sl_breakeven_secured(direction: str, entry_price: Any, exchange_sl_price: Any) -> bool: + """ + 交易所当前止损相对开仓成交价是否已保本. + 做多:止损 >= 成交价;做空:止损 <= 成交价. + """ + entry = _positive_float(entry_price) + sl = _positive_float(exchange_sl_price) + if entry is None or sl is None: + return False + d = (direction or "long").strip().lower() + if d == "short": + return sl <= entry + return sl >= entry + + +def sl_breakeven_from_exchange_tpsl( + direction: str, + entry_price: Any, + exchange_tpsl: Any, +) -> bool: + if not isinstance(exchange_tpsl, dict): + return False + sl_px = tpsl_slot_trigger_price(exchange_tpsl.get("sl")) + if sl_px is None: + return False + return is_sl_breakeven_secured(direction, entry_price, sl_px) + + +def enrich_order_display_fields(item: dict[str, Any], calc_rr_ratio_fn: Callable[..., Optional[float]]) -> dict[str, Any]: + item["rr_ratio"] = snapshot_rr( + calc_rr_ratio_fn, + item.get("direction") or "long", + item.get("trigger_price"), + item.get("initial_stop_loss"), + item.get("stop_loss"), + item.get("take_profit"), + ) + return item + + +def apply_order_live_price_display( + payload: dict[str, Any], + symbol: Any, + ticker_price: Any, + exchange_mark_price: Any, + format_price_fn: Callable[[Any, Any], str], +) -> dict[str, Any]: + """标记价/现价展示:与交易所 price_to_precision 对齐,避免前端 toFixed(8).""" + px_for_fmt = ticker_price + mark_raw = exchange_mark_price + if mark_raw is not None: + try: + px_for_fmt = float(mark_raw) + except (TypeError, ValueError): + pass + px_disp = format_price_fn(symbol, px_for_fmt) + payload["price_display"] = px_disp + if mark_raw is not None: + try: + payload["exchange_mark_price_display"] = format_price_fn(symbol, float(mark_raw)) + except (TypeError, ValueError): + payload["exchange_mark_price_display"] = px_disp + else: + payload["exchange_mark_price_display"] = None + return payload + + +def resolve_live_tpsl_prices( + plan_sl: Any, + plan_tp: Any, + exchange_tpsl: Any, +) -> tuple[Optional[float], Optional[float], Optional[float], Optional[float]]: + """返回 (展示用止损, 展示用止盈, 交易所止损, 交易所止盈).""" + ex_sl = ex_tp = None + if isinstance(exchange_tpsl, dict): + ex_sl = tpsl_slot_trigger_price(exchange_tpsl.get("sl")) + ex_tp = tpsl_slot_trigger_price(exchange_tpsl.get("tp")) + disp_sl = ex_sl if ex_sl is not None else _positive_float(plan_sl) + disp_tp = ex_tp if ex_tp is not None else _positive_float(plan_tp) + return disp_sl, disp_tp, ex_sl, ex_tp + + +def calc_risk_fraction(direction: str, entry_price: Any, stop_loss: Any) -> Optional[float]: + """|入场-止损|/入场;盈利侧止损返回 0.""" + entry = _positive_float(entry_price) + sl = _positive_float(stop_loss) + if entry is None or sl is None: + return None + d = (direction or "long").strip().lower() + if d == "short": + risk = sl - entry + else: + risk = entry - sl + if risk <= 0: + return 0.0 + return risk / entry + + +def calc_latest_risk_amount( + direction: str, + entry_price: Any, + stop_loss: Any, + *, + margin_capital: Any = None, + leverage: Any = None, + exchange_notional: Any = None, + contracts: Any = None, + contract_size: Any = None, + mark_price: Any = None, + funds_decimals: int = 2, +) -> Optional[float]: + """按当前止损与持仓名义价值估算最新风险(U).""" + rf = calc_risk_fraction(direction, entry_price, stop_loss) + if rf is None: + return None + if rf <= 0: + return 0.0 + notional = _positive_float(exchange_notional) + if notional is None: + try: + mc = float(margin_capital or 0) + lev = float(leverage or 0) + if mc > 0 and lev > 0: + notional = mc * lev + except (TypeError, ValueError): + pass + if notional is None: + try: + c = abs(float(contracts or 0)) + cs = float(contract_size or 1) + if cs <= 0: + cs = 1.0 + px = _positive_float(mark_price) or _positive_float(entry_price) + if c > 0 and px is not None: + notional = c * cs * px + except (TypeError, ValueError): + pass + if notional is None or notional <= 0: + return None + return round(notional * rf, funds_decimals) + + +def order_monitor_tpsl_needs_sync( + plan_sl: Any, + plan_tp: Any, + exchange_tpsl: Any, + *, + eps: float = 1e-12, +) -> tuple[Optional[float], Optional[float], bool]: + """若交易所 TP/SL 与库中不一致,返回应写回的 (sl, tp) 及是否需更新.""" + _, _, ex_sl, ex_tp = resolve_live_tpsl_prices(plan_sl, plan_tp, exchange_tpsl) + try: + cur_sl = float(plan_sl or 0) + cur_tp = float(plan_tp or 0) + except (TypeError, ValueError): + cur_sl, cur_tp = 0.0, 0.0 + new_sl = ex_sl if ex_sl is not None else cur_sl + new_tp = ex_tp if ex_tp is not None else cur_tp + changed = ( + (ex_sl is not None and abs(new_sl - cur_sl) > eps) + or (ex_tp is not None and abs(new_tp - cur_tp) > eps) + ) + return new_sl, new_tp, changed + + +def apply_order_price_display_fields( + payload: dict[str, Any], + *, + direction: str, + entry_price: Any, + initial_stop_loss: Any, + stop_loss: Any, + take_profit: Any, + calc_rr_ratio_fn: Callable[..., Optional[float]], + exchange_tpsl: Any = None, + format_price_fn: Optional[Callable[[Any, Any], str]] = None, + symbol: Any = None, + margin_capital: Any = None, + leverage: Any = None, + exchange_notional: Any = None, + contracts: Any = None, + contract_size: Any = None, + mark_price: Any = None, + avg_entry_price: Any = None, + funds_decimals: int = 2, +) -> dict[str, Any]: + disp_sl, disp_tp, _, _ = resolve_live_tpsl_prices(stop_loss, take_profit, exchange_tpsl) + payload["stop_loss_raw"] = _positive_float(stop_loss) + payload["take_profit_raw"] = _positive_float(take_profit) + payload["rr_ratio"] = snapshot_rr( + calc_rr_ratio_fn, + direction, + entry_price, + initial_stop_loss, + stop_loss, + take_profit, + ) + risk_entry = resolve_breakeven_entry_price(entry_price, avg_entry_price) + payload["avg_entry_price"] = risk_entry + payload["sl_breakeven_secured"] = sl_breakeven_from_exchange_tpsl( + direction, risk_entry, exchange_tpsl + ) + payload["stop_loss"] = disp_sl + payload["take_profit"] = disp_tp + if disp_sl is not None and disp_tp is not None: + payload["display_rr_ratio"] = calc_rr_ratio_fn( + direction or "long", entry_price, disp_sl, disp_tp + ) + else: + payload["display_rr_ratio"] = None + if contracts is not None: + try: + from lib.market.position_metrics_lib import normalize_contracts_qty + + c = normalize_contracts_qty(contracts) + if c > 0: + payload["contracts"] = c + except (TypeError, ValueError): + pass + payload["latest_risk_amount"] = calc_latest_risk_amount( + direction, + risk_entry, + disp_sl if disp_sl is not None else stop_loss, + margin_capital=margin_capital, + leverage=leverage, + exchange_notional=exchange_notional, + contracts=payload.get("contracts") if payload.get("contracts") is not None else contracts, + contract_size=contract_size, + mark_price=mark_price, + funds_decimals=funds_decimals, + ) + tp_for_reward = disp_tp if disp_tp is not None else _positive_float(take_profit) + qty_for_reward = payload.get("contracts") + if qty_for_reward is None and contracts is not None: + try: + qty_for_reward = abs(float(contracts)) + except (TypeError, ValueError): + qty_for_reward = None + if risk_entry is not None and tp_for_reward is not None and qty_for_reward: + try: + def reward_at_tp_usdt(direction, entry, tp, contracts, contract_size, fee_rate=0.0): + try: + d = (direction or "long").lower() + e, t, c, cs = float(entry), float(tp), float(contracts), float(contract_size or 1) + if e <= 0 or t <= 0 or c <= 0: + return None + raw = (t - e) * c * cs if d == "long" else (e - t) * c * cs + return round(raw, 2) + except Exception: + return None + + reward = reward_at_tp_usdt( + direction, + risk_entry, + tp_for_reward, + float(qty_for_reward), + contract_size=float(contract_size or 1.0), + ) + payload["reward_at_tp_usdt"] = ( + round(reward, funds_decimals) if reward is not None else None + ) + except Exception: + payload["reward_at_tp_usdt"] = None + else: + payload["reward_at_tp_usdt"] = None + if format_price_fn is not None and symbol is not None: + payload["stop_loss_display"] = ( + format_price_fn(symbol, disp_sl) if disp_sl is not None else "—" + ) + payload["take_profit_display"] = ( + format_price_fn(symbol, disp_tp) if disp_tp is not None else "—" + ) + mark_raw = mark_price if mark_price is not None else None + if mark_raw is not None and format_price_fn is not None and symbol is not None: + try: + payload["exchange_mark_price_display"] = format_price_fn(symbol, float(mark_raw)) + except (TypeError, ValueError): + payload["exchange_mark_price_display"] = None + return payload + + +def enrich_active_monitor_tpsl_json( + row: Any, + stop_loss: Any, + take_profit: Any, + exchange_tpsl: Any, + *, + position_row: Any = None, + exchange_notional: Any = None, + contracts: Any = None, + contract_size: float = 1.0, + mark_price: Any = None, + calc_rr_ratio_fn: Callable[..., Optional[float]], + format_price_fn: Optional[Callable[[Any, Any], str]] = None, + symbol: Any = None, + funds_decimals: int = 2, +) -> dict[str, Any]: + """place_tpsl 响应:展示用 TP/SL,最新风险,当前盈亏比.""" + def _row_val(key: str, default=None): + try: + if hasattr(row, "keys") and key in row.keys(): + return row[key] + except Exception: + pass + if isinstance(row, dict): + return row.get(key, default) + return default + + direction = _row_val("direction") or "long" + entry = _row_val("trigger_price") + init_sl = _row_val("initial_stop_loss") + margin = _row_val("margin_capital") + leverage = _row_val("leverage") + if position_row is not None: + from lib.market.position_metrics_lib import parse_position_entry_price, position_contracts + + live_c = position_contracts(position_row) + if abs(live_c) >= 1e-12: + contracts = abs(live_c) + avg_entry = parse_position_entry_price(position_row) + else: + avg_entry = None + payload: dict[str, Any] = { + "stop_loss": stop_loss, + "take_profit": take_profit, + } + apply_order_price_display_fields( + payload, + direction=direction, + entry_price=entry, + initial_stop_loss=init_sl, + stop_loss=stop_loss, + take_profit=take_profit, + calc_rr_ratio_fn=calc_rr_ratio_fn, + exchange_tpsl=exchange_tpsl, + format_price_fn=format_price_fn, + symbol=symbol or _row_val("symbol"), + margin_capital=margin, + leverage=leverage, + exchange_notional=exchange_notional, + contracts=contracts, + contract_size=contract_size, + mark_price=mark_price, + avg_entry_price=avg_entry, + funds_decimals=funds_decimals, + ) + return payload diff --git a/lib/trade/position_sizing_lib.py b/lib/trade/position_sizing_lib.py new file mode 100644 index 0000000..e6252af --- /dev/null +++ b/lib/trade/position_sizing_lib.py @@ -0,0 +1,135 @@ +""" +三所共用:计仓模式 risk(以损定仓)| full_margin(全仓杠杆). +仅 env POSITION_SIZING_MODE 切换;须无持仓(由部署流程保证). +""" +from __future__ import annotations + +import os +from typing import Any, Optional, Tuple + +MODE_RISK = "risk" +MODE_FULL_MARGIN = "full_margin" +VALID_MODES = frozenset({MODE_RISK, MODE_FULL_MARGIN}) + +OPEN_SOURCE_MANUAL = "manual" +# 历史兼容常量(策略/关键位自动单已移除,assert 仍可引用) +OPEN_SOURCE_KEY_AUTO = "key_auto" +OPEN_SOURCE_KEY_FIB = "key_fib" +OPEN_SOURCE_KEY_TRIGGER = "key_trigger" +OPEN_SOURCE_TREND = "trend" +OPEN_SOURCE_ROLL = "roll" + +FULL_MARGIN_BLOCKED_SOURCES = frozenset() + + +def normalize_position_sizing_mode(raw: Optional[str]) -> str: + v = (raw or MODE_RISK).strip().lower() + if v in ("full", "full_margin", "fullmargin", "全仓", "全仓杠杆"): + return MODE_FULL_MARGIN + return MODE_RISK if v in ("risk", "r", "以损定仓", "") else MODE_RISK + + +def load_position_sizing_mode(env: Optional[dict] = None) -> str: + e = env if env is not None else os.environ + return normalize_position_sizing_mode(e.get("POSITION_SIZING_MODE")) + + +def is_full_margin_mode(mode: str) -> bool: + return normalize_position_sizing_mode(mode) == MODE_FULL_MARGIN + + +def mode_label_zh(mode: str) -> str: + return "全仓杠杆" if is_full_margin_mode(mode) else "以损定仓" + + +def leverage_for_full_margin(symbol: str, btc_leverage: int, alt_leverage: int) -> int: + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return max(1, int(btc_leverage or 10)) + return max(1, int(alt_leverage or 5)) + + +def round_funds(value: float, decimals: int = 2) -> float: + return round(float(value), int(decimals)) + + +def risk_percent_for_storage(mode: str, risk_percent: float) -> Optional[float]: + """全仓杠杆:库内不写风险百分比(仅 risk_amount U).""" + if is_full_margin_mode(mode): + return None + return risk_percent + + +def format_risk_display_text( + mode: str, + risk_percent: Optional[float], + risk_amount: Optional[float], + *, + decimals: int = 2, +) -> str: + """持仓/通知「风险」文案:全仓仅 U;以损定仓为 %≈U.""" + amt: Optional[float] = None + if risk_amount is not None and risk_amount != "": + try: + amt = float(risk_amount) + except (TypeError, ValueError): + amt = None + if is_full_margin_mode(mode): + if amt is None: + return "—" + return f"{round_funds(amt, decimals)}U" + pct: Optional[float] = None + if risk_percent is not None and risk_percent != "": + try: + pct = float(risk_percent) + except (TypeError, ValueError): + pct = None + pct_txt = f"{pct:g}" if pct is not None else "—" + amt_txt = round_funds(amt, decimals) if amt is not None else "—" + return f"{pct_txt}%≈{amt_txt}U" + + +def assert_open_source_allowed(mode: str, source: str) -> Tuple[bool, str]: + if not is_full_margin_mode(mode): + return True, "" + src = (source or "").strip().lower() + if src in FULL_MARGIN_BLOCKED_SOURCES: + return False, ( + "当前为全仓杠杆模式(POSITION_SIZING_MODE=full_margin)," + "不允许关键位突破/斐波自动开仓,趋势回调与顺势加仓;" + "仅支持实盘人工下单与阻力/支撑提醒." + ) + return True, "" + + +def full_margin_requires_flat_position(active_count: int) -> Tuple[bool, str]: + if active_count > 0: + return False, "全仓杠杆模式仅允许单仓且无其它持仓,请先平仓后再开仓" + return True, "" + + +def compute_full_margin_sizing( + *, + symbol: str, + available_usdt: float, + capital_base: float, + buffer_ratio: float, + btc_leverage: int, + alt_leverage: int, + funds_decimals: int = 2, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + if available_usdt is None or float(available_usdt) <= 0: + return None, "全仓杠杆:无法读取合约账户可用保证金" + lev = leverage_for_full_margin(symbol, btc_leverage, alt_leverage) + margin = round_funds(float(available_usdt) * float(buffer_ratio), funds_decimals) + if margin <= 0: + return None, "全仓杠杆:可用保证金不足" + notional = round_funds(margin * lev, funds_decimals) + ratio = round(margin / float(capital_base) * 100, 2) if capital_base else 0.0 + return { + "margin_capital": margin, + "leverage": lev, + "notional_value": notional, + "position_ratio": ratio, + "mode": MODE_FULL_MARGIN, + }, None diff --git a/lib/trade/time_close_lib.py b/lib/trade/time_close_lib.py new file mode 100644 index 0000000..96968a3 --- /dev/null +++ b/lib/trade/time_close_lib.py @@ -0,0 +1,150 @@ +"""持仓时间平仓:开仓后按 1h/2h/4h 定时市价平仓.""" +from __future__ import annotations + +import time +from typing import Any, Optional + +ALLOWED_TIME_CLOSE_HOURS = (1, 2, 4) +TIME_CLOSE_RESULT = "时间平仓" + + +def parse_time_close_enabled_form(form_value: Any) -> int: + return 1 if str(form_value or "").strip().lower() in ("1", "true", "on", "yes") else 0 + + +def parse_time_close_hours_form(form_value: Any, *, default: int = 4) -> Optional[int]: + raw = str(form_value or "").strip().lower().rstrip("h") + if not raw: + return None + try: + h = int(float(raw)) + except (TypeError, ValueError): + return None + if h in ALLOWED_TIME_CLOSE_HOURS: + return h + return None + + +def normalize_time_close_hours(value: Any) -> Optional[int]: + try: + h = int(value) + except (TypeError, ValueError): + return None + return h if h in ALLOWED_TIME_CLOSE_HOURS else None + + +def _row_val(row: Any, key: str, default=None): + if row is None: + return default + try: + if hasattr(row, "keys") and key in row.keys(): + return row[key] + except Exception: + pass + if isinstance(row, dict): + return row.get(key, default) + return default + + +def time_close_settings_from_row(row: Any) -> tuple[int, Optional[int], Optional[int]]: + """返回 (enabled, hours, close_at_ms).""" + enabled = int(_row_val(row, "time_close_enabled", 0) or 0) != 0 + hours = normalize_time_close_hours(_row_val(row, "time_close_hours")) + close_at = _row_val(row, "time_close_at_ms") + try: + close_at_ms = int(close_at) if close_at not in (None, "") else None + except (TypeError, ValueError): + close_at_ms = None + if enabled and hours and not close_at_ms: + opened_ms = _row_val(row, "opened_at_ms") + try: + opened_ms = int(opened_ms) if opened_ms not in (None, "") else None + except (TypeError, ValueError): + opened_ms = None + close_at_ms = compute_close_at_ms(opened_ms, hours) + return (1 if enabled and hours else 0, hours, close_at_ms) + + +def compute_close_at_ms(opened_at_ms: Any, hours: Any) -> Optional[int]: + h = normalize_time_close_hours(hours) + try: + opened = int(opened_at_ms) + except (TypeError, ValueError): + return None + if not h or opened <= 0: + return None + return opened + h * 3600 * 1000 + + +def should_trigger_time_close(row: Any, *, now_ms: Optional[int] = None) -> bool: + enabled, hours, close_at_ms = time_close_settings_from_row(row) + if not enabled or not close_at_ms: + return False + now = int(now_ms if now_ms is not None else time.time() * 1000) + return now >= int(close_at_ms) + + +def time_close_remaining_seconds(close_at_ms: Any, *, now_ms: Optional[int] = None) -> Optional[int]: + try: + close_at = int(close_at_ms) + except (TypeError, ValueError): + return None + now = int(now_ms if now_ms is not None else time.time() * 1000) + return max(0, int((close_at - now) / 1000)) + + +def format_time_close_countdown(seconds: Any) -> str: + try: + sec = max(0, int(seconds)) + except (TypeError, ValueError): + return "--:--:--" + h = sec // 3600 + m = (sec % 3600) // 60 + s = sec % 60 + return f"{h:02d}:{m:02d}:{s:02d}" + + +def time_close_label(hours: Any) -> str: + h = normalize_time_close_hours(hours) + return f"时间平仓 {h}h" if h else "时间平仓" + + +def apply_time_close_to_payload(payload: dict[str, Any], row: Any, *, now_ms: Optional[int] = None) -> None: + enabled, hours, close_at_ms = time_close_settings_from_row(row) + payload["time_close_enabled"] = bool(enabled) + payload["time_close_hours"] = hours + payload["time_close_at_ms"] = close_at_ms + payload["time_close_label"] = time_close_label(hours) if enabled else "" + if enabled and close_at_ms: + rem = time_close_remaining_seconds(close_at_ms, now_ms=now_ms) + payload["time_close_remaining_sec"] = rem + payload["time_close_countdown"] = format_time_close_countdown(rem) + else: + payload["time_close_remaining_sec"] = None + payload["time_close_countdown"] = "" + + +def ensure_time_close_schema(cursor) -> None: + ddl_list = ( + "ALTER TABLE order_monitors ADD COLUMN time_close_enabled INTEGER DEFAULT 0", + "ALTER TABLE order_monitors ADD COLUMN time_close_hours INTEGER", + "ALTER TABLE order_monitors ADD COLUMN time_close_at_ms INTEGER", + "ALTER TABLE key_monitors ADD COLUMN time_close_enabled INTEGER DEFAULT 0", + "ALTER TABLE key_monitors ADD COLUMN time_close_hours INTEGER", + ) + for ddl in ddl_list: + try: + cursor.execute(ddl) + except Exception: + pass + + +def time_close_insert_values( + enabled: int, + hours: Optional[int], + opened_at_ms: Optional[int], +) -> tuple[int, Optional[int], Optional[int]]: + en = 1 if int(enabled or 0) != 0 and hours else 0 + h = normalize_time_close_hours(hours) if en else None + close_at = compute_close_at_ms(opened_at_ms, h) if en else None + return en, h, close_at diff --git a/lib/trade/trade_exchange_stats_lib.py b/lib/trade/trade_exchange_stats_lib.py new file mode 100644 index 0000000..7158f14 --- /dev/null +++ b/lib/trade/trade_exchange_stats_lib.py @@ -0,0 +1,229 @@ +"""平仓交易:交易所口径双边成交额与手续费(三所共用聚合逻辑).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + + +def _coerce_ts_ms(raw: Any) -> int | None: + if raw in (None, ""): + return None + try: + v = int(raw) + return v if v > 1_000_000_000_000 else v * 1000 + except (TypeError, ValueError): + return None + + +def quote_turnover_usdt_from_fill(trade: dict, *, contract_size: float = 1.0) -> float: + """单笔成交的报价币成交额(USDT 口径).""" + info = trade.get("info") or {} + if not isinstance(info, dict): + info = {} + for key in ("quoteQty", "quote_qty", "fillNotionalUsd", "notional"): + try: + v = float(info.get(key) or 0) + if v > 0: + return abs(v) + except (TypeError, ValueError): + continue + try: + cost = float(trade.get("cost") or 0) + if cost > 0: + return abs(cost) + except (TypeError, ValueError): + pass + try: + price = float(trade.get("price") or 0) + amount = float(trade.get("amount") or 0) * float(contract_size or 1.0) + if price > 0 and amount > 0: + return abs(price * amount) + except (TypeError, ValueError): + pass + return 0.0 + + +def commission_usdt_from_fill(trade: dict) -> float: + """单笔成交手续费(正数表示成本).""" + fee = trade.get("fee") + if isinstance(fee, dict): + try: + cost = float(fee.get("cost") or 0) + except (TypeError, ValueError): + cost = 0.0 + if cost != 0: + cur = str(fee.get("currency") or "USDT").upper() + if cur in ("USDT", "USD", "BUSD", "USDC"): + return abs(cost) + return abs(cost) + info = trade.get("info") or {} + if isinstance(info, dict): + for key in ("fee", "commission", "fillFee"): + try: + v = float(info.get(key) or 0) + if v != 0: + return abs(v) + except (TypeError, ValueError): + continue + return 0.0 + + +def aggregate_bilateral_stats( + fills: list[dict], + *, + contract_size: float = 1.0, +) -> dict[str, float] | None: + """双边成交额 = 开+平所有相关 fill 的报价币成交额之和;手续费 = fill fee 之和.""" + if not fills: + return None + turnover = 0.0 + commission = 0.0 + for t in fills: + turnover += quote_turnover_usdt_from_fill(t, contract_size=contract_size) + commission += commission_usdt_from_fill(t) + if turnover <= 0 and commission <= 0: + return None + return { + "exchange_turnover_usdt": round(turnover, 4), + "exchange_commission_usdt": round(commission, 4), + } + + +def filter_position_lifecycle_fills( + trades: list[dict], + direction: str, + open_ms: int | None, + close_ms: int | None, + *, + hedge_mode: bool = False, + close_buffer_ms: int = 15 * 60 * 1000, +) -> list[dict]: + """ + 持仓生命周期内 fill:多=开买+平卖;空=开卖+平买. + hedge_mode 时按 posSide 与 direction 过滤. + """ + direction = (direction or "long").strip().lower() + open_side = "buy" if direction == "long" else "sell" + close_side = "sell" if direction == "long" else "buy" + allowed_sides = {open_side, close_side} + upper = int(close_ms) + int(close_buffer_ms) if close_ms else None + out: list[dict] = [] + for t in trades or []: + side = (t.get("side") or "").lower() + if side not in allowed_sides: + continue + ts = _coerce_ts_ms(t.get("timestamp")) + if ts is None: + continue + if open_ms and ts < int(open_ms) - 60_000: + continue + if upper and ts > upper: + continue + if hedge_mode: + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if pos_side in ("long", "short") and pos_side != direction: + continue + out.append(t) + out.sort(key=lambda x: x.get("timestamp") or 0) + return out + + +def sum_binance_commission_income(entries: list[dict], trade_ids: set[str] | None) -> float | None: + """Binance income 流水中 COMMISSION 合计(负值取绝对值为成本).""" + if not entries: + return None + total = 0.0 + found = False + for e in entries: + it = (e.get("incomeType") or e.get("income_type") or "").strip() + if it != "COMMISSION": + continue + if trade_ids: + tid = str(e.get("tradeId") or e.get("trade_id") or "").strip() + if tid and tid not in trade_ids: + continue + try: + total += float(e.get("income") or 0) + found = True + except (TypeError, ValueError): + continue + if not found: + return None + return round(abs(total), 4) + + +def trade_ids_from_fills(fills: list[dict]) -> set[str]: + out: set[str] = set() + for t in fills or []: + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + for key in ("id", "tradeId", "trade_id"): + raw = t.get(key) if key in t else info.get(key) + if raw is not None and str(raw).strip(): + out.add(str(raw).strip()) + break + return out + + +def merge_commission_prefer_income( + fill_commission: float, + income_commission: float | None, +) -> float: + if income_commission is not None and income_commission > 0: + return round(income_commission, 4) + return round(max(fill_commission, 0.0), 4) + + +def update_trade_record_stats_columns( + conn: Any, + trade_id: int, + turnover_usdt: float | None, + commission_usdt: float | None, +) -> None: + if turnover_usdt is None and commission_usdt is None: + return + conn.execute( + """ + UPDATE trade_records + SET exchange_turnover_usdt = COALESCE(?, exchange_turnover_usdt), + exchange_commission_usdt = COALESCE(?, exchange_commission_usdt) + WHERE id = ? + """, + (turnover_usdt, commission_usdt, int(trade_id)), + ) + + +def attach_exchange_stats_to_trade( + conn: Any, + trade_id: int, + *, + fetch_fills: Callable[[], list[dict]], + contract_size: float = 1.0, + income_commission: float | None = None, +) -> dict[str, float] | None: + """拉 fill 并写库;仅在新单平仓路径调用.""" + try: + fills = fetch_fills() or [] + except Exception: + fills = [] + stats = aggregate_bilateral_stats(fills, contract_size=contract_size) + if not stats and income_commission is None: + return None + turnover = stats.get("exchange_turnover_usdt") if stats else None + fill_comm = float(stats.get("exchange_commission_usdt") or 0) if stats else 0.0 + commission = merge_commission_prefer_income(fill_comm, income_commission) + update_trade_record_stats_columns( + conn, + trade_id, + turnover, + commission if commission > 0 else None, + ) + out = {} + if turnover is not None: + out["exchange_turnover_usdt"] = turnover + if commission > 0: + out["exchange_commission_usdt"] = commission + return out or None diff --git a/lib/trade/trade_fee_lib.py b/lib/trade/trade_fee_lib.py new file mode 100644 index 0000000..ed27312 --- /dev/null +++ b/lib/trade/trade_fee_lib.py @@ -0,0 +1,94 @@ +"""永续估算盈亏:固定 taker 手续费(默认单边 0.05%,开+平双边). + +浮盈亏仍读交易所;本模块只服务「盈利金额 / 止盈盈利 / 推送 / 记账 pnl_amount」等估算口径. +""" +from __future__ import annotations + +import math +import os +from typing import Optional + + +def _finite(v) -> Optional[float]: + try: + f = float(v) + return f if math.isfinite(f) else None + except (TypeError, ValueError): + return None + + +def taker_fee_rate() -> float: + """单边 taker 费率,默认 0.0005(=0.05%).""" + raw = os.getenv("PERP_TAKER_FEE_RATE", "0.0005") + rate = _finite(raw) + if rate is None or rate < 0: + return 0.0005 + return rate + + +def notional_usdt(price, qty, contract_size: float = 1.0) -> Optional[float]: + """名义价值 U = 价格 × 张数 × 合约面值.""" + p = _finite(price) + q = _finite(qty) + cs = _finite(contract_size) + if p is None or q is None or p <= 0 or q <= 0: + return None + if cs is None or cs <= 0: + cs = 1.0 + return abs(q) * p * cs + + +def estimate_roundtrip_fee_usdt( + entry_price, + exit_price, + qty=None, + contract_size: float = 1.0, + *, + open_notional: float | None = None, + rate: float | None = None, +) -> float: + """开+平双边手续费(各单边 rate). + + 优先用 价×张×面值;若无张数则用 open_notional 估开仓名义, + 平仓名义按 exit/entry 缩放. + """ + fee_rate = taker_fee_rate() if rate is None else float(rate) + if fee_rate <= 0: + return 0.0 + entry = _finite(entry_price) + exit_p = _finite(exit_price) + open_n = notional_usdt(entry, qty, contract_size) if qty is not None else None + if open_n is None: + open_n = _finite(open_notional) + if open_n is None or open_n <= 0: + return 0.0 + if entry is not None and entry > 0 and exit_p is not None and exit_p > 0: + close_n = open_n * (exit_p / entry) + else: + close_n = open_n + return round(open_n * fee_rate + close_n * fee_rate, 8) + + +def net_pnl_after_fee( + gross_pnl, + entry_price, + exit_price, + qty=None, + contract_size: float = 1.0, + *, + open_notional: float | None = None, + rate: float | None = None, +) -> Optional[float]: + """毛利扣双边手续费后的净盈亏;gross 无效则返回 None.""" + gross = _finite(gross_pnl) + if gross is None: + return None + fee = estimate_roundtrip_fee_usdt( + entry_price, + exit_price, + qty, + contract_size, + open_notional=open_notional, + rate=rate, + ) + return round(gross - fee, 4) diff --git a/lib/trade/trade_labels_lib.py b/lib/trade/trade_labels_lib.py new file mode 100644 index 0000000..12b36cf --- /dev/null +++ b/lib/trade/trade_labels_lib.py @@ -0,0 +1,169 @@ +"""交易记录 / 下单监控标签工具(原 lib.strategy.strategy_trade_labels 瘦身后).""" +from __future__ import annotations + +from typing import Optional + +ORDER_TYPE_MANUAL = "下单监控" +ORDER_TYPE_KEY = "关键位监控" + +# 历史数据兼容可读;新复盘下拉不再提供策略开仓类型 +MONITOR_TYPE_TREND_PULLBACK = "趋势回调" +MONITOR_TYPE_ROLL = "顺势加仓" +ENTRY_REASON_TREND_PULLBACK = "趋势回调" +ENTRY_REASON_ROLL = "顺势加仓" + +JOURNAL_ORDER_TYPE_OPTIONS = ( + ORDER_TYPE_MANUAL, + ORDER_TYPE_KEY, +) + +# 旧库兼容(normalize / 读历史);不进 JOURNAL 下拉 +STRATEGY_ENTRY_REASON_OPTIONS = ( + ENTRY_REASON_TREND_PULLBACK, + ENTRY_REASON_ROLL, +) + +TREND_HANDOFF_KEY_SIGNAL = ENTRY_REASON_TREND_PULLBACK +TREND_HANDOFF_TRADE_NOTE = "趋势回调计划" + + +def normalize_journal_order_type(raw: Optional[str]) -> str: + s = (raw or "").strip() + if s in JOURNAL_ORDER_TYPE_OPTIONS: + return s + # 历史策略类型仍可读,映射到相近展示 + if s in (MONITOR_TYPE_TREND_PULLBACK, MONITOR_TYPE_ROLL): + return s + if "关键位" in s: + return ORDER_TYPE_KEY + return "" + + +def order_type_from_monitor_type( + monitor_type: Optional[str], + key_signal_type: Optional[str] = None, +) -> str: + del key_signal_type + mt = (monitor_type or "").strip() + if mt == MONITOR_TYPE_TREND_PULLBACK: + return MONITOR_TYPE_TREND_PULLBACK + if mt == MONITOR_TYPE_ROLL: + return MONITOR_TYPE_ROLL + if mt == ORDER_TYPE_KEY or "关键位" in mt: + return ORDER_TYPE_KEY + return ORDER_TYPE_MANUAL + + +def handoff_trade_miss_reason(miss_reason, row) -> Optional[str]: + """历史趋势保本移交监控单平仓备注兼容.""" + if trend_plan_id_from_monitor_row(row) is None: + return miss_reason + base = (miss_reason or "").strip() + if TREND_HANDOFF_TRADE_NOTE in base: + return base or TREND_HANDOFF_TRADE_NOTE + if base: + return f"{TREND_HANDOFF_TRADE_NOTE};{base}" + return TREND_HANDOFF_TRADE_NOTE + + +def trend_plan_id_from_monitor_row(row) -> Optional[int]: + if row is None: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "trend_plan_id" not in keys or row["trend_plan_id"] in (None, ""): + return None + try: + tid = int(row["trend_plan_id"]) + return tid if tid > 0 else None + except (TypeError, ValueError): + return None + + +def order_had_roll_fills(conn, order_monitor_id) -> bool: + """策略已移除:始终 False(roll_legs 表可能仍在旧库).""" + del conn, order_monitor_id + return False + + +def _row_monitor_type(row, default_manual: str) -> str: + if row is None: + return default_manual + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "monitor_type" in keys: + mt = (row["monitor_type"] or "").strip() + if mt: + return mt + return default_manual + + +def _row_key_signal_type(row) -> str: + if row is None: + return "" + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "key_signal_type" not in keys: + return "" + return (row["key_signal_type"] or "").strip() + + +def order_monitor_source_type(row, *, default_manual: str = "下单监控") -> str: + """展示/平仓记录:历史趋势移交单仍标「趋势回调」.""" + if trend_plan_id_from_monitor_row(row) is not None: + return MONITOR_TYPE_TREND_PULLBACK + mt = _row_monitor_type(row, default_manual) + if mt != default_manual: + return mt + kst = _row_key_signal_type(row) + if kst in ( + MONITOR_TYPE_TREND_PULLBACK, + TREND_HANDOFF_KEY_SIGNAL, + TREND_HANDOFF_TRADE_NOTE, + ENTRY_REASON_TREND_PULLBACK, + ): + return MONITOR_TYPE_TREND_PULLBACK + return mt + + +def apply_order_monitor_source_labels(item: dict, *, default_manual: str = "下单监控") -> dict: + out = dict(item or {}) + out["monitor_type"] = order_monitor_source_type(out, default_manual=default_manual) + return out + + +def trade_record_monitor_type(conn, order_row, *, default_manual: str = "下单监控") -> str: + del conn + return order_monitor_source_type(order_row, default_manual=default_manual) + + +def entry_reason_for_monitor_type(monitor_type: str | None) -> str: + mt = (monitor_type or "").strip() + if mt == MONITOR_TYPE_TREND_PULLBACK: + return ENTRY_REASON_TREND_PULLBACK + if mt == MONITOR_TYPE_ROLL: + return ENTRY_REASON_ROLL + return "" + + +def order_monitor_excluded_from_position_limit(conn, row) -> bool: + del conn + return order_monitor_source_type(row) == MONITOR_TYPE_TREND_PULLBACK + + +def count_position_limit_active_monitors(conn) -> int: + try: + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + except Exception: + return 0 + n = 0 + for row in rows: + if not order_monitor_excluded_from_position_limit(conn, row): + n += 1 + return n diff --git a/lib/trade/trade_policy_app_lib.py b/lib/trade/trade_policy_app_lib.py new file mode 100644 index 0000000..b35ea3f --- /dev/null +++ b/lib/trade/trade_policy_app_lib.py @@ -0,0 +1,57 @@ +"""Flask 实例接入 trade policy(三所 app.py 共用).""" +from __future__ import annotations + +from typing import Callable, Tuple + +from lib.trade.trade_policy_lib import ( + TradePolicy, + assert_direction_allowed, + assert_symbol_allowed, + assert_trade_policy_open, + trade_policy_to_dict, +) + + +def trade_policy_template_context(policy: TradePolicy) -> dict: + return trade_policy_to_dict(policy) + + +def default_symbol_for_policy(policy: TradePolicy, raw_default: str) -> str: + d = (raw_default or "").strip() + if policy.symbol_restrict_enabled and policy.symbol_whitelist: + # 白名单仅一币时直接用 env 币种,表单下拉同步默认选中 + if len(policy.symbol_whitelist) == 1: + return f"{policy.symbol_whitelist[0]}/USDT" + from lib.trade.trade_policy_lib import symbol_base_coin + + base = symbol_base_coin(d or "BTC/USDT") + if base not in policy.symbol_whitelist: + return f"{policy.symbol_whitelist[0]}/USDT" + if d: + return d if "/" in d else f"{base}/USDT" + return f"{policy.symbol_whitelist[0]}/USDT" + return d or "BTC/USDT" + +def check_symbol_policy( + policy: TradePolicy, + symbol: str, + normalize_symbol_fn: Callable[[str], str], +) -> Tuple[bool, str]: + return assert_symbol_allowed( + policy, symbol, normalize_symbol_fn=normalize_symbol_fn + ) + + +def check_direction_policy(policy: TradePolicy, direction: str) -> Tuple[bool, str]: + return assert_direction_allowed(policy, direction) + + +def check_open_policy( + policy: TradePolicy, + symbol: str, + direction: str, + normalize_symbol_fn: Callable[[str], str], +) -> Tuple[bool, str]: + return assert_trade_policy_open( + policy, symbol, direction, normalize_symbol_fn=normalize_symbol_fn + ) diff --git a/lib/trade/trade_policy_lib.py b/lib/trade/trade_policy_lib.py new file mode 100644 index 0000000..f7d57b9 --- /dev/null +++ b/lib/trade/trade_policy_lib.py @@ -0,0 +1,205 @@ +""" +三所共用:账户级方向 / 币种白名单(.env 开关,默认关闭=不限制). +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Callable, FrozenSet, Optional, Sequence, Tuple + +DIR_BOTH = "both" +DIR_LONG_ONLY = "long_only" +DIR_SHORT_ONLY = "short_only" +VALID_DIRECTION_MODES = frozenset({DIR_BOTH, DIR_LONG_ONLY, DIR_SHORT_ONLY}) + +_DIR_ALIASES = { + "both": DIR_BOTH, + "双向": DIR_BOTH, + "long": DIR_LONG_ONLY, + "long_only": DIR_LONG_ONLY, + "多": DIR_LONG_ONLY, + "仅多": DIR_LONG_ONLY, + "做多": DIR_LONG_ONLY, + "short": DIR_SHORT_ONLY, + "short_only": DIR_SHORT_ONLY, + "空": DIR_SHORT_ONLY, + "仅空": DIR_SHORT_ONLY, + "做空": DIR_SHORT_ONLY, +} + + +def _env_bool(raw: Optional[str], default: bool = False) -> bool: + if raw is None: + return default + return (raw or "").strip().lower() in ("1", "true", "yes", "on") + + +def normalize_direction_mode(raw: Optional[str]) -> str: + v = (raw or DIR_BOTH).strip().lower() + return _DIR_ALIASES.get(v, v if v in VALID_DIRECTION_MODES else DIR_BOTH) + + +def symbol_base_coin(symbol: str) -> str: + """BTC/USDT:USDT,BTC/USDT,BTC,btc -> BTC""" + s = (symbol or "").strip().upper() + if not s: + return "" + if ":" in s: + s = s.split(":", 1)[0] + if "/" in s: + return s.split("/", 1)[0].strip() + if s.endswith("USDT") and len(s) > 4: + return s[:-4] + return s + + +def parse_symbol_whitelist(raw: Optional[str]) -> Tuple[str, ...]: + if not raw or not str(raw).strip(): + return () + parts = [] + for piece in str(raw).replace(";", ",").split(","): + base = symbol_base_coin(piece.strip()) + if base and base not in parts: + parts.append(base) + return tuple(parts) + + +@dataclass(frozen=True) +class TradePolicy: + direction_restrict_enabled: bool + direction_mode: str + symbol_restrict_enabled: bool + symbol_whitelist: Tuple[str, ...] + + @property + def allows_long(self) -> bool: + if not self.direction_restrict_enabled: + return True + return self.direction_mode in (DIR_BOTH, DIR_LONG_ONLY) + + @property + def allows_short(self) -> bool: + if not self.direction_restrict_enabled: + return True + return self.direction_mode in (DIR_BOTH, DIR_SHORT_ONLY) + + +def load_trade_policy(env: Optional[dict] = None) -> TradePolicy: + e = env if env is not None else os.environ + direction_restrict = _env_bool(e.get("TRADE_DIRECTION_RESTRICT_ENABLED"), False) + symbol_restrict = _env_bool(e.get("TRADE_SYMBOL_RESTRICT_ENABLED"), False) + direction_mode = normalize_direction_mode(e.get("TRADE_DIRECTION")) + whitelist = parse_symbol_whitelist(e.get("TRADE_SYMBOL_WHITELIST")) + if symbol_restrict and not whitelist: + symbol_restrict = False + return TradePolicy( + direction_restrict_enabled=direction_restrict, + direction_mode=direction_mode, + symbol_restrict_enabled=symbol_restrict, + symbol_whitelist=whitelist, + ) + + +def direction_mode_label_zh(mode: str) -> str: + m = normalize_direction_mode(mode) + if m == DIR_LONG_ONLY: + return "仅多" + if m == DIR_SHORT_ONLY: + return "仅空" + return "双向" + + +def trade_policy_badge_parts(policy: TradePolicy) -> Tuple[str, ...]: + parts: list[str] = [] + if policy.direction_restrict_enabled: + if policy.direction_mode == DIR_LONG_ONLY: + parts.append("仅多") + elif policy.direction_mode == DIR_SHORT_ONLY: + parts.append("仅空") + if policy.symbol_restrict_enabled and policy.symbol_whitelist: + parts.append("/".join(policy.symbol_whitelist)) + return tuple(parts) + + +def trade_policy_to_dict(policy: TradePolicy) -> dict: + badges = trade_policy_badge_parts(policy) + return { + "direction_restrict_enabled": policy.direction_restrict_enabled, + "direction_mode": policy.direction_mode, + "direction_label_zh": ( + direction_mode_label_zh(policy.direction_mode) + if policy.direction_restrict_enabled + else "双向" + ), + "allows_long": policy.allows_long, + "allows_short": policy.allows_short, + "symbol_restrict_enabled": policy.symbol_restrict_enabled, + "symbol_whitelist": list(policy.symbol_whitelist), + "badge_parts": list(badges), + "badge_text": " · ".join(badges), + } + + +def normalize_open_direction(policy: TradePolicy, direction: str) -> str: + d = (direction or "long").strip().lower() + if d not in ("long", "short"): + d = "long" + if policy.direction_restrict_enabled: + if policy.direction_mode == DIR_LONG_ONLY: + return "long" + if policy.direction_mode == DIR_SHORT_ONLY: + return "short" + return d + + +def assert_direction_allowed(policy: TradePolicy, direction: str) -> Tuple[bool, str]: + d = (direction or "").strip().lower() + if d not in ("long", "short"): + if d in ("watch", ""): + return True, "" + return False, "方向无效,请选择做多或做空" + if d == "long" and not policy.allows_long: + return False, "当前账户配置为仅做空,不允许做多" + if d == "short" and not policy.allows_short: + return False, "当前账户配置为仅做多,不允许做空" + return True, "" + + +def assert_symbol_allowed( + policy: TradePolicy, + symbol: str, + *, + normalize_symbol_fn: Optional[Callable[[str], str]] = None, +) -> Tuple[bool, str]: + if not policy.symbol_restrict_enabled: + return True, "" + sym = (symbol or "").strip() + if not sym: + return False, "请选择币种" + if normalize_symbol_fn is not None: + sym_norm = (normalize_symbol_fn(sym) or "").strip() + else: + sym_norm = sym + base = symbol_base_coin(sym_norm or sym) + allowed: FrozenSet[str] = frozenset(policy.symbol_whitelist) + if base not in allowed: + allowed_txt = ",".join(policy.symbol_whitelist) + return False, f"当前账户仅允许 {allowed_txt},不允许 {base or sym}" + return True, "" + + +def assert_trade_policy_open( + policy: TradePolicy, + symbol: str, + direction: str, + normalize_symbol_fn: Optional[Callable[[str], str]] = None, +) -> Tuple[bool, str]: + ok_sym, msg_sym = assert_symbol_allowed( + policy, symbol, normalize_symbol_fn=normalize_symbol_fn + ) + if not ok_sym: + return False, msg_sym + ok_dir, msg_dir = assert_direction_allowed(policy, direction) + if not ok_dir: + return False, msg_dir + return True, "" diff --git a/lib/trade/trade_result_lib.py b/lib/trade/trade_result_lib.py new file mode 100644 index 0000000..156f953 --- /dev/null +++ b/lib/trade/trade_result_lib.py @@ -0,0 +1,120 @@ +"""交易结果展示与入库时的语义归一化.""" + +from __future__ import annotations + +from typing import Optional + +_WIN_EPS = 1e-9 + + +def classify_exit_by_levels( + direction, + trigger_price, + stop_loss, + take_profit, + exit_price, +) -> Optional[str]: + """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None. + + 交易所条件止盈常按标记价触发、市价成交,成交价可能偏离计划止盈数个 tick. + 因此先用窄带,失败后再用宽带;仍失败则看是否落在入场→止盈/止损的「盈利/亏损侧」。 + """ + try: + tp = float(take_profit) + sl = float(stop_loss) + ex = float(exit_price) + trig = float(trigger_price) + except (TypeError, ValueError): + return None + d = (direction or "").strip().lower() + if d not in ("long", "short"): + return None + band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12) + # 宽带:覆盖 BTC 等高价币种条件单滑点(实测 Gate 止盈成交可偏出窄带 ~100U) + band_loose = max(abs(trig) * 0.003, abs(tp - sl) * 0.05, band * 4.0, 1e-12) + + def _is_tp(b: float) -> bool: + return ex >= tp - b if d == "long" else ex <= tp + b + + def _is_sl(b: float) -> bool: + return ex <= sl + b if d == "long" else ex >= sl - b + + if _is_tp(band): + return "止盈" + if _is_sl(band): + return "止损" + if _is_tp(band_loose): + return "止盈" + if _is_sl(band_loose): + return "止损" + + # 盈利侧且更靠近止盈 → 止盈; 亏损侧且更靠近止损 → 止损 + if d == "long": + if ex > trig and abs(ex - tp) <= abs(ex - trig): + return "止盈" + if ex < trig and abs(ex - sl) <= abs(ex - trig): + return "止损" + else: + if ex < trig and abs(ex - tp) <= abs(ex - trig): + return "止盈" + if ex > trig and abs(ex - sl) <= abs(ex - trig): + return "止损" + return None + + +def normalize_display_result(result): + """展示用:外部平仓一律视为手动平仓.""" + res = (result or "").strip() + if res == "外部平仓" or res.startswith("外部平仓"): + return "手动平仓" + return res + + +def is_winning_pnl(pnl_amount) -> bool: + """胜率统计:盈亏为正即计为盈利单.""" + try: + return float(pnl_amount or 0) > _WIN_EPS + except (TypeError, ValueError): + return False + + +def sql_effective_pnl_expr() -> str: + """与 to_effective_trade_dict / hub_trades_lib 一致的盈亏 SQL 表达式.""" + return "COALESCE(reviewed_pnl_amount, exchange_realized_pnl, pnl_amount, 0)" + + +def count_winning_trades(trades) -> int: + return sum(1 for r in trades or [] if is_winning_pnl(r.get("effective_pnl_amount"))) + + +MISS_TRADE_RESULT = "错过" + + +def is_miss_trade_result(result) -> bool: + return (result or "").strip() == MISS_TRADE_RESULT + + +def filter_trade_records_excluding_miss(records): + """列表/统计:不展示,不计入「错过」类交易记录.""" + return [ + r + for r in (records or []) + if not is_miss_trade_result(r.get("effective_result") or r.get("result")) + ] + + +def normalize_result_with_pnl(result, pnl_amount): + """ + 非手动平仓且实际盈利时,不应记为「止损」. + 程序触发的止损类平仓若盈亏为正,归类为「移动止盈」. + """ + res = normalize_display_result(result) + if res == "手动平仓": + return res + if res == "止损": + try: + if float(pnl_amount or 0) > 0: + return "移动止盈" + except (TypeError, ValueError): + pass + return res diff --git a/lib/trade/trade_stats_calendar_lib.py b/lib/trade/trade_stats_calendar_lib.py new file mode 100644 index 0000000..18d5361 --- /dev/null +++ b/lib/trade/trade_stats_calendar_lib.py @@ -0,0 +1,115 @@ +"""按交易日聚合实例 trade_records 盈亏,供统计分析页日历 API 使用.""" +from __future__ import annotations + +import json +from datetime import datetime, timedelta +from typing import Any, Callable + + +def build_trade_stats_calendar( + pnls: list[tuple], + year: int, + month: int, + segment_key: str, + row_matches_fn: Callable[[Any, str], bool], + *, + reset_hour: int = 8, +) -> dict[str, Any]: + """pnls: _load_completed_trade_pnls 返回值 (pnl, close_dt, trading_day, row).""" + y = int(year) + m = int(month) + if m < 1 or m > 12: + raise ValueError("month 无效") + first = f"{y:04d}-{m:02d}-01" + if m == 12: + next_first = datetime(y + 1, 1, 1) + else: + next_first = datetime(y, m + 1, 1) + last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d") + seg = (segment_key or "all").strip() or "all" + days: dict[str, dict[str, Any]] = {} + for pnl, _close_dt, td, row in pnls: + if not td or td < first or td > last: + continue + if not row_matches_fn(row, seg): + continue + bucket = days.setdefault( + td, + { + "trading_day": td, + "open_count": 0, + "pnl_total": 0.0, + "turnover_total": 0.0, + "commission_total": 0.0, + "has_sick": False, + "sick_count": 0, + }, + ) + bucket["open_count"] += 1 + bucket["pnl_total"] += float(pnl or 0) + try: + bucket["turnover_total"] += float(row["exchange_turnover_usdt"] or 0) + except (TypeError, ValueError, KeyError): + pass + try: + bucket["commission_total"] += float(row["exchange_commission_usdt"] or 0) + except (TypeError, ValueError, KeyError): + pass + for d in days.values(): + d["pnl_total"] = round(float(d["pnl_total"]), 4) + d["turnover_total"] = round(float(d["turnover_total"]), 4) + d["commission_total"] = round(float(d["commission_total"]), 4) + month_pnl = sum(float(d["pnl_total"]) for d in days.values()) + month_count = sum(int(d["open_count"]) for d in days.values()) + return { + "year": y, + "month": m, + "date_from": first, + "date_to": last, + "segment": seg, + "reset_hour": int(reset_hour), + "days": days, + "month_pnl_total": round(month_pnl, 4), + "month_open_count": month_count, + } + + +def build_initial_stats_calendar( + pnls: list[tuple], + now_dt: datetime, + row_matches_fn: Callable[[Any, str], bool], + *, + reset_hour: int = 8, + segment_key: str = "all", +) -> dict[str, Any]: + """统计页首屏内嵌日历(当前自然月,默认品类).""" + return build_trade_stats_calendar( + pnls, + now_dt.year, + now_dt.month, + segment_key, + row_matches_fn, + reset_hour=reset_hour, + ) + + +def build_stats_calendar_bootstrap( + pnls: list[tuple], + now_dt: datetime, + row_matches_fn: Callable[[Any, str], bool], + *, + reset_hour: int = 8, + segment_key: str = "all", +) -> tuple[dict[str, Any] | None, str | None]: + """返回 (payload, json_str);失败时 (None, None),供模板安全内嵌.""" + try: + payload = build_initial_stats_calendar( + pnls, + now_dt, + row_matches_fn, + reset_hour=reset_hour, + segment_key=segment_key, + ) + return payload, json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + except Exception: + return None, None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c88f68a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# crypto_monitor 三个 Flask 子项目共用依赖(Binance / Gate / OKX) +# 安装:在各子目录 venv 内执行 pip install -r ../requirements.txt +# 共用 Python 库位于 ../lib/,启动时需将仓库根加入 PYTHONPATH(各 app.py / PM2 已配置) +flask>=3.0,<4 +requests>=2.31,<3 +ccxt>=4.2,<5 +werkzeug>=3.0,<4 +PySocks>=1.7,<2 +Pillow>=10.0,<12 diff --git a/scripts/backup_data.sh b/scripts/backup_data.sh new file mode 100644 index 0000000..9a25287 --- /dev/null +++ b/scripts/backup_data.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Daily backup: SQLite DB + static/images → /root/backups/// +# Prune backup folders older than RETENTION_DAYS (default 30). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_DIR" + +BACKUP_ROOT="${BACKUP_ROOT:-/root/backups}" +RETENTION_DAYS="${RETENTION_DAYS:-30}" +INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}" +TZ_NAME="${BACKUP_TZ:-Asia/Shanghai}" + +log() { + printf '[%s] %s\n' "$(TZ="$TZ_NAME" date '+%Y-%m-%d %H:%M:%S %Z')" "$*" +} + +read_env_var() { + local key="$1" + local default="$2" + local line + if [[ ! -f .env ]]; then + printf '%s' "$default" + return + fi + line="$(grep -E "^${key}=" .env 2>/dev/null | tail -1 || true)" + if [[ -z "$line" ]]; then + printf '%s' "$default" + return + fi + printf '%s' "${line#*=}" | tr -d '\r' +} + +resolve_project_path() { + local p="$1" + if [[ "$p" == /* ]]; then + printf '%s' "$p" + else + printf '%s' "$PROJECT_DIR/$p" + fi +} + +prune_old_backups() { + local base="$BACKUP_ROOT/$INSTANCE_NAME" + [[ -d "$base" ]] || return 0 + local cutoff + cutoff="$(TZ="$TZ_NAME" date -d "-${RETENTION_DAYS} days" +%Y-%m-%d 2>/dev/null || true)" + if [[ -z "$cutoff" ]]; then + find "$base" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETENTION_DAYS" -print0 | + xargs -r -0 rm -rf + return 0 + fi + local dir name + for dir in "$base"/*/; do + [[ -d "$dir" ]] || continue + name="$(basename "$dir")" + [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || continue + if [[ "$name" < "$cutoff" ]]; then + log "prune: remove $dir (older than ${RETENTION_DAYS} days)" + rm -rf "$dir" + fi + done +} + +DB_REL="$(read_env_var DB_PATH crypto.db)" +UPLOAD_REL="$(read_env_var UPLOAD_DIR static/images)" +BACKUP_ROOT="$(read_env_var BACKUP_ROOT "$BACKUP_ROOT")" +RETENTION_DAYS="$(read_env_var BACKUP_RETENTION_DAYS "$RETENTION_DAYS")" +INSTANCE_NAME="$(read_env_var BACKUP_INSTANCE "$INSTANCE_NAME")" + +DB_PATH="$(resolve_project_path "$DB_REL")" +UPLOAD_DIR="$(resolve_project_path "$UPLOAD_REL")" +DATE_TAG="$(TZ="$TZ_NAME" date +%Y-%m-%d)" +DEST="$BACKUP_ROOT/$INSTANCE_NAME/$DATE_TAG" + +if [[ ! -f "$DB_PATH" ]]; then + log "error: database not found: $DB_PATH" + exit 1 +fi + +mkdir -p "$DEST" +log "start backup instance=$INSTANCE_NAME dest=$DEST" + +if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$DB_PATH" ".backup '$DEST/crypto.db'" + log "db: sqlite3 backup -> $DEST/crypto.db" +else + cp -a "$DB_PATH" "$DEST/crypto.db" + log "db: cp -> $DEST/crypto.db (sqlite3 not installed)" +fi + +if [[ -d "$UPLOAD_DIR" ]]; then + tar -czf "$DEST/static_images.tar.gz" -C "$(dirname "$UPLOAD_DIR")" "$(basename "$UPLOAD_DIR")" + log "images: $UPLOAD_DIR -> $DEST/static_images.tar.gz" +else + log "warn: upload dir missing, skip images: $UPLOAD_DIR" +fi + +{ + echo "instance=$INSTANCE_NAME" + echo "project_dir=$PROJECT_DIR" + echo "backup_date=$DATE_TAG" + echo "db_path=$DB_PATH" + echo "upload_dir=$UPLOAD_DIR" +} >"$DEST/manifest.txt" + +prune_old_backups +log "done" diff --git a/scripts/bootstrap_deploy_secrets.py b/scripts/bootstrap_deploy_secrets.py new file mode 100644 index 0000000..2a8417a --- /dev/null +++ b/scripts/bootstrap_deploy_secrets.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""首次部署:自动生成登录会话密钥与初始账号(仅空/占位时写入,不覆盖已有). + +- FLASK_SECRET_KEY +- APP_USERNAME=admin / APP_PASSWORD=admin123(仅空时) +""" +from __future__ import annotations + +import argparse +import os +import secrets +import sys + +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _REPO not in sys.path: + sys.path.insert(0, _REPO) + +from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines + +FLASK_PLACEHOLDERS = frozenset( + {"", "CHANGE_TO_LONG_RANDOM_SECRET", "crypto_monitor_2026_secret_key"} +) +PASSWORD_PLACEHOLDERS = frozenset({"", "CHANGE_ME_STRONG_PASSWORD"}) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Bootstrap deploy secrets for crypto_okx") + parser.add_argument("--dry-run", action="store_true", help="只打印将写入的项,不改文件") + args = parser.parse_args() + + env_path = os.path.join(_REPO, ".env") + if not os.path.isfile(env_path): + print(f" 跳过: 未找到 {env_path}") + return 0 + + lines = read_env_lines(env_path) + updates: dict[str, str] = {} + + cur_secret = env_get(lines, "FLASK_SECRET_KEY") + if (cur_secret or "").strip() in FLASK_PLACEHOLDERS: + updates["FLASK_SECRET_KEY"] = secrets.token_urlsafe(48) + + if not (env_get(lines, "APP_USERNAME") or "").strip(): + updates["APP_USERNAME"] = "admin" + + cur_pass = env_get(lines, "APP_PASSWORD") + if (cur_pass or "").strip() in PASSWORD_PLACEHOLDERS: + updates["APP_PASSWORD"] = "admin123" + + if not updates: + print(" 密钥已存在,无需写入") + return 0 + + print(f" 将更新 {env_path}: {', '.join(updates.keys())}") + if args.dry_run: + for k, v in updates.items(): + shown = v if k != "APP_PASSWORD" and "SECRET" not in k else "***" + print(f" {k}={shown}") + return 0 + + apply_env_updates(env_path, updates) + print(" 已写入初始密钥/账号(请尽快修改 APP_PASSWORD)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/fix_breakeven_labels.py b/scripts/fix_breakeven_labels.py new file mode 100644 index 0000000..97a910a --- /dev/null +++ b/scripts/fix_breakeven_labels.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +一次性修复历史交易记录标签: +将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”. + +默认条件(可通过参数修改): +- monitor_type = 下单监控 +- result = 止损 +- pnl_amount > 0 + +用法示例: +1) 仅预览(不落库): + python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run + +2) 执行修复: + python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +""" + +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.") + parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db") + parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)") + parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)") + parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)") + parser.add_argument("--dry-run", action="store_true", help="Preview only, no write") + parser.add_argument("--apply", action="store_true", help="Execute update") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + db_path = Path(args.db).expanduser().resolve() + if not db_path.exists(): + print(f"[ERR] DB not found: {db_path}") + return 1 + + if args.dry_run and args.apply: + print("[ERR] --dry-run and --apply are mutually exclusive.") + return 1 + if not args.dry_run and not args.apply: + print("[INFO] No mode provided, defaulting to --dry-run.") + args.dry_run = True + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + where_sql = """ + monitor_type = ? + AND result = ? + AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0 + """ + params = (args.monitor_type, args.from_result) + + cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params) + will_change = int(cur.fetchone()["c"]) + print(f"[INFO] Candidate rows: {will_change}") + + if will_change == 0: + print("[INFO] Nothing to update.") + conn.close() + return 0 + + cur.execute( + f""" + SELECT id, symbol, result, pnl_amount, closed_at + FROM trade_records + WHERE {where_sql} + ORDER BY id DESC + LIMIT 10 + """, + params, + ) + sample = cur.fetchall() + print("[INFO] Sample (latest 10):") + for r in sample: + print( + f" id={r['id']} symbol={r['symbol']} result={r['result']} " + f"pnl={r['pnl_amount']} closed_at={r['closed_at']}" + ) + + if args.dry_run: + print("[DRY-RUN] No write executed.") + conn.close() + return 0 + + cur.execute( + f"UPDATE trade_records SET result=? WHERE {where_sql}", + (args.to_result, *params), + ) + changed = int(cur.rowcount) + conn.commit() + conn.close() + print(f"[DONE] Updated rows: {changed}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/scripts/install_backup_cron.sh b/scripts/install_backup_cron.sh new file mode 100644 index 0000000..96053f4 --- /dev/null +++ b/scripts/install_backup_cron.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Install daily backup cron: Beijing 00:00 (CRON_TZ=Asia/Shanghai). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BACKUP_SCRIPT="$SCRIPT_DIR/backup_data.sh" +INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}" +LOG_FILE="${BACKUP_CRON_LOG:-/var/log/crypto-monitor-backup-${INSTANCE_NAME}.log}" +if [[ ! -x "$BACKUP_SCRIPT" ]]; then + chmod +x "$BACKUP_SCRIPT" +fi + +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT + +{ + crontab -l 2>/dev/null | grep -vF "$BACKUP_SCRIPT" || true + echo "CRON_TZ=Asia/Shanghai" + echo "0 0 * * * $BACKUP_SCRIPT >> $LOG_FILE 2>&1" +} >"$TMP" + +# Keep a single CRON_TZ line at top. +awk ' + BEGIN { tz = 0 } + /^CRON_TZ=Asia\/Shanghai$/ { + if (tz++) next + } + { print } +' "$TMP" >"${TMP}.2" +mv "${TMP}.2" "$TMP" + +crontab "$TMP" +echo "Installed cron for $INSTANCE_NAME" +echo " Schedule : daily 00:00 Asia/Shanghai" +echo " Script : $BACKUP_SCRIPT" +echo " Log : $LOG_FILE" +crontab -l | grep -F "$BACKUP_SCRIPT" || true diff --git a/scripts/verify_okx_funding.py b/scripts/verify_okx_funding.py new file mode 100644 index 0000000..1550dc7 --- /dev/null +++ b/scripts/verify_okx_funding.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" + python scripts/verify_okx_funding.py + +打印 OKX_API_KEY 前 8 位便于与 Binance 控制台核对(不含 Secret).用于服务器自检. +""" +import os +import sys + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, BASE) + + +def load_env(path): + if not os.path.exists(path): + return + for line in open(path, "r", encoding="utf-8", errors="ignore"): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip().lstrip("\ufeff") + if k.replace("_", "").isalnum(): + os.environ[k] = v.strip().strip('"').strip("'") + + +def main(): + load_env(os.path.join(BASE, ".env")) + k = (os.getenv("OKX_API_KEY") or "").strip() + s = (os.getenv("OKX_API_SECRET") or "").strip() + if not k or "REPLACE" in k.upper(): + print("WARN: OKX_API_KEY 为空或仍像占位符,请核对 .env") + if not s or "REPLACE" in s.upper(): + print("WARN: OKX_API_SECRET 为空或仍像占位符,请核对 .env") + print("OKX_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)") + + import app as mod # noqa: E402 + + mod.ensure_markets_loaded() + fu = mod._fetch_okx_funding_usdt() + print(">>> _fetch_okx_funding_usdt() =", fu) + try: + sw = mod._fetch_okx_swap_usdt_total() + print(">>> _fetch_okx_swap_usdt_total() (合约账户) =", sw) + sf = mod._fetch_okx_swap_usdt_free() + print(">>> _fetch_okx_swap_usdt_free() (合约可用) =", sf) + except Exception as e: + print(">>> swap balance fetch error:", e) + + +if __name__ == "__main__": + main() diff --git a/static/icons/apple-touch-icon.png b/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..82d1e82 Binary files /dev/null and b/static/icons/apple-touch-icon.png differ diff --git a/static/icons/favicon.ico b/static/icons/favicon.ico new file mode 100644 index 0000000..2dbb0dd Binary files /dev/null and b/static/icons/favicon.ico differ diff --git a/static/icons/icon-16.png b/static/icons/icon-16.png new file mode 100644 index 0000000..5ea0f54 Binary files /dev/null and b/static/icons/icon-16.png differ diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png new file mode 100644 index 0000000..55ed1de Binary files /dev/null and b/static/icons/icon-192.png differ diff --git a/static/icons/icon-32.png b/static/icons/icon-32.png new file mode 100644 index 0000000..ea8a9a2 Binary files /dev/null and b/static/icons/icon-32.png differ diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png new file mode 100644 index 0000000..e526b77 Binary files /dev/null and b/static/icons/icon-512.png differ diff --git a/static/icons/icon.svg b/static/icons/icon.svg new file mode 100644 index 0000000..b7eaa46 --- /dev/null +++ b/static/icons/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/icons/manifest.webmanifest b/static/icons/manifest.webmanifest new file mode 100644 index 0000000..7d82187 --- /dev/null +++ b/static/icons/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "OKX 交易系统", + "short_name": "OKX 交易系统", + "description": "OKX 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#FFFFFF", + "icons": [ + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/templates/key_focus.html b/templates/key_focus.html new file mode 100644 index 0000000..41a633a --- /dev/null +++ b/templates/key_focus.html @@ -0,0 +1 @@ +ok2 \ No newline at end of file diff --git a/templates/order_focus.html b/templates/order_focus.html new file mode 100644 index 0000000..3dc7ce3 --- /dev/null +++ b/templates/order_focus.html @@ -0,0 +1,195 @@ + + + + + 实盘下单放大 | 100根K线 + + + +
    +
    +
    +
    + 返回首页 + 实盘下单放大(100根K线) +
    +
    最近刷新:--
    +
    + {% if orders %} +
    + + + + + + +
    + {% else %} +
    当前没有激活订单,无法展示放大K线.
    + {% endif %} +
    + + {% if orders %} +
    +
    +
    交易对
    -
    +
    方向
    -
    +
    成交价
    -
    +
    止损
    -
    +
    止盈
    -
    +
    盈亏比
    -
    +
    现价
    -
    +
    浮盈亏
    -
    +
    +
    + +
    +
    +
    + {% endif %} +
    + +{% if orders %} + + +{% endif %} + + diff --git a/使用说明.md b/使用说明.md new file mode 100644 index 0000000..31cf03b --- /dev/null +++ b/使用说明.md @@ -0,0 +1,142 @@ +# 使用说明 + +**本文件对应独立仓库:** [https://git.bz121.com/dekun/crypto_okx.git](https://git.bz121.com/dekun/crypto_okx.git)(`crypto_okx`,OKX 期权 / 对冲). + +**部署,代理,PM2**见本目录 **[部署文档.md](./部署文档.md)** 或一键: + +```bash +curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash +``` + +当前主功能为期权、期权复盘、对冲计划与模拟资金;策略交易 / 关键位自动单 / AI 复盘 / 中控已从此独立项目移除.下文部分历史模块说明仅供对照,以实际页面为准. + +--- + +## 1. 它能做什么 + +面向个人盘面的 **Web 控制台**,主要能力包括: + +| 模块 | 说明 | +|------|------| +| **关键位监控** | 录入上/下沿与类型,按 **5m 收线** 做硬条件过滤;符合条件后 **企业微信** 提醒,部分类型可 **自动市价开仓**(见第 4 节与专门文档). | +| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏,保本逻辑等. | +| **交易记录 / 复盘** | 平仓结果,盈亏,错过的单等归档与导出;可选 **AI 复盘**(见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)). | +| **策略交易** | 顶栏 `/strategy`:**趋势回调**(左)与 **顺势加仓**(右)左右并列;细则见 [策略交易说明.md](../策略交易说明.md). | + +后台按 **`MONITOR_POLL_SECONDS`**(默认几秒)轮询行情与监控逻辑.**切勿**在未理解规则时同时运行两套程序共用一个实盘账户. + +--- + +## 2. 运行前必须配置(`.env`) + +首次在本目录执行 **`cp .env.example .env`**,再编辑 `.env`(`.env` 勿提交 Git;`git pull` 不会改你的 `.env`,升级前建议 `cp .env .env.backup.$(date +%Y%m%d)`). + +至少检查以下项(具体键名以 **`.env.example`** 为准): + +| 类别 | 说明 | +|------|------| +| **登录网页** | `APP_PASSWORD`:打开站点后的登录口令.`FLASK_SECRET_KEY`:Session 密钥,请勿使用默认值. | +| **企业微信** | `WECHAT_WEBHOOK`:告警与关键位推送机器人的 Webhook. | +| **是否真下单** | `LIVE_TRADING_ENABLED=false`:**不会**向交易所发送开仓指令(适合测试流程).改为 `true` 且密钥正确才会实盘. | +| **交易所 API** | **本仓库:** `OKX_API_KEY`,`OKX_API_SECRET`;永续相关见 `OKX_TD_MODE`,`OKX_POS_MODE`,`OKX_TRIGGER_WORKING_TYPE` 等.**勿**把 `.env` 提交到 Git. | +| **关键位 RR / 止损外扩** | `KEY_AUTO_MIN_PLANNED_RR`,`KEY_STOP_OUTSIDE_BREAKOUT_PCT`(详见 `关键位自动下单说明.md`). | +| **AI 复盘** | 默认 `AI_PROVIDER=openai`,`OPENAI_API_BASE=https://op.bz121.com/v1`,`OPENAI_API_KEY`,`OPENAI_MODEL=gemma4:e4b`;或 `AI_PROVIDER=ollama` + `OLLAMA_API` / `AI_MODEL`.详见 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md). | + +网络需要代理时可配置 **`OKX_SOCKS_PROXY` / `OKX_HTTP_PROXY`**(与 Gate 版 `GATE_*_PROXY` 用法类似). + +--- + +## 3. 如何启动与登录 + +1. 准备 Python 虚拟环境并安装依赖(如 `flask`,`requests`,`ccxt`,按需 `Pillow`,`PySocks` 等),配置好 `.env`. +2. 启动 Flask 应用(可用 **`ecosystem.config.cjs`** 交给 PM2,或本地 `python app.py` / `flask run`,以你当前脚本为准). +3. 浏览器访问站点,打开 **`/login`**,使用 **`.env` 里的 `APP_PASSWORD`** 登录. + +登录后顶栏:**关键位监控** | **实盘下单**(默认首页)| **策略交易**(`/strategy`,趋势回调 + 顺势加仓双栏)| **策略交易记录**(`/strategy/records`)| **交易记录与复盘** | **统计分析**. + +--- + +## 4. 关键位监控(顶栏「关键位监控」→ `/key_monitor`) + +### 4.1 添加一条关键位 + +1. **币种**:如 `BTC` 或 `BTC/USDT`(会规范成内部符号). +2. **类型**(必选其一): + + | 类型 | 行为摘要 | + |------|----------| + | **箱体突破** | 通过门控且计划 RR 达标 → **自动市价开仓**(需 `LIVE_TRADING_ENABLED=true` 且无其他持仓占位).结案后本条从列表消失并记入历史. | + | **收敛突破** | 同上(自动开仓类). | + | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**. | + | **关键支撑位** | 同上(仅提醒). | + | **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**(RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** | + | **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** | + +3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选). +4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**. + +**限制:** +活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」. +若 **4h EMA55** 与你的方向逆势,页面会 **额外 Flash 提示**,**不阻挡**提交. + +### 4.2 触发后会发生什么(简版) + +- **箱体 / 收敛**:门控通过后算计划 SL/TP 与 RR;不达标 → 微信说明 + **`rr_insufficient`** 结案;达标 → **市价开仓**,成功 **`auto_opened`** / 失败 **`exchange_failed`**,均不重试同一关键位. +- **阻力 / 支撑**:仅 **单次推送** → **`key_level_alert_only`** 结案. + +详细公式与字段见 **`关键位自动下单说明.md`**. + +### 4.3 列表与历史 + +当前条目与历史记录的用法与 Gate 版相同;结案后可在历史区查阅 **`close_reason`**. + +--- + +## 5. 实盘下单(顶栏「实盘下单」→ `/trade`) + +- 持仓上限由 **`MAX_ACTIVE_POSITIONS`** 控制(默认 1). +- **人工开仓**计划盈亏比不得低于 **`MANUAL_MIN_PLANNED_RR`**(默认 1.4:1). +- 填写币种,方向,杠杆(可选),止损/止盈(价格或百分比按表单). +- 移动保本等选项按页面与 `.env` 默认. + +开仓成功后卡片 **「来源」**:手工一般为 **下单监控**;关键位自动为 **关键位监控**. + +--- + +## 6. 企业微信 + +推送逻辑与 Gate 版一致;未配置 **`WECHAT_WEBHOOK`** 时可能没有消息,请以 **交易所端** 核对持仓与挂单. + +--- + +## 7. 强烈建议的风险与运维习惯 + +1. **先用 `LIVE_TRADING_ENABLED=false`** 熟悉流程再实盘. +2. **API 权限**最小化,密钥勿泄露. +3. **同一账户避免多程序重复开仓**. +4. **自动备份**:服务器上执行 `bash scripts/install_backup_cron.sh`(每天北京时间 0:00 → `/root/backups`,保留 30 天);升级前也可 `bash scripts/backup_data.sh` 手动跑一次. +5. 升级代码后留意 **首轮启动**有无数据库迁移报错. + +--- + +## 8. 常见问题(简要) + +| 现象 | 可自查 | +|------|--------| +| 关键位永远不触发 | 门控五项,日成交量排名,`KLINE_TIMEFRAME`. | +| 有信号但不自动开仓 | `LIVE_TRADING_ENABLED`,RR 阈值,是否已有持仓,API/保证金错误信息. | +| 加不了箱体/收敛 | 是否已有持仓. | +| 推送收不到 | Webhook,网络. | + +--- + +## 9. 与币安版(`crypto_monitor_binance`)差异速查 + +| 项目 | OKX 本仓库 | 币安版 | +|------|------------|--------| +| API 变量 | `OKX_API_KEY`,`OKX_API_SECRET`,`OKX_API_PASSPHRASE` | `BINANCE_API_KEY`,`BINANCE_API_SECRET` | +| 代理 | `OKX_SOCKS_PROXY` | `BINANCE_SOCKS_PROXY` | +| 默认端口 | 常为 `5004` | 常为 `5001` | +| TP/SL 实现 | `_okx_place_tp_sl_orders`,页面 `/api/order/.../cancel_tpsl` | `_binance_place_tp_sl_orders` | + +业务流程,顶栏分栏,策略交易,风控参数名已与币安版对齐;仅需更换目录与 `.env`. diff --git a/更新文档.md b/更新文档.md new file mode 100644 index 0000000..df5af60 --- /dev/null +++ b/更新文档.md @@ -0,0 +1,104 @@ +# 界面与风控更新说明(OKX 实例) + +> 仓库已独立为 [crypto_okx](https://git.bz121.com/dekun/crypto_okx.git).一键部署见 [部署文档.md](./部署文档.md): +> +> \\ash +> curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash +> \\n + +与 Gate / Binance 主站对齐的列表窗,统计分品类,交易记录展示,复盘与移动保本交易所同步;OKX 仍为 **三页导航**(交易执行 / 记录复盘 / 统计),关键位监控合并在 **交易执行** 页,**无** Gate 独立「关键位监控」页与斐波限价监控. + +## 顶栏导航(3 项) + +| 顺序 | 名称 | 路由 | 说明 | +|------|------|------|------| +| 1 | 交易执行 | `/trade` | 关键位监控 + 实盘下单(**默认首页** `/` → `/trade`) | +| 2 | 交易记录与复盘 | `/records` | 交易记录,复盘表单,AI 历史(受顶栏 UTC 时间窗筛选) | +| 3 | 统计分析 | `/stats` | 按北京时间交易日切日 + 分品类统计块 | + +## 列表时间窗(UTC,全站顶栏) + +共用模块:仓库根目录 `history_window_lib.py`(与 Gate / Binance 一致). + +| 项 | 说明 | +|----|------| +| 默认 | **UTC 当日**(`win_preset=utc_today`) | +| 可选 | 近 24 小时,近 7 天,自定义起止(UTC) | +| 作用范围 | 关键位历史,交易记录列表,复盘 API,AI 历史 API,导出「交易记录」「关键位历史」 | +| 与统计 | **仅影响列表/导出**;统计页仍按北京时间 `TRADING_DAY_RESET_HOUR`(默认 8:00)切日 | +| 切换 | 顶栏「列表筛选(UTC)」→ 应用(保留当前路由 query) | + +## 交易记录与复盘 + +- 列表 **止损(开仓)**:展示 `initial_stop_loss` 快照(`display_open_stop_loss`). +- 类型列显示 `monitor_type` 与 `key_signal_type`(若有). +- 平仓入库:`stop_loss` / `initial_stop_loss` 为开仓止损快照;机器单 `entry_reason` 可按 `key_signal_type` 自动映射(箱体突破 / 收敛突破 → 四条固定关键位开仓类型文案). +- 复盘:开仓类型下拉含四条关键位固定文案 +「其他」;离场触发含 **「止盈」**;从交易记录填入时按结果与信号预填. +- 复盘 K 线图:以 **平仓时间** 为锚点向前约 `ORDER_CHART_LIMIT`(默认 100)根(`_fetch_ohlcv_ending_at`). +- `/api/journals`,`/api/reviews` 与顶栏 UTC 窗一致. + +### 导出(交易记录 v3) + +- 文件名:`trade_records_v3_YYYYMMDD.csv` +- 含 `key_signal_type`,`initial_stop_loss`,计划/实际 RR,`risk_amount` 等;末列「开仓类型」为有效展示文案. +- 受 UTC 列表窗限制;关键位历史导出同理. + +## 实盘下单(交易执行页) + +- **移动保本**:表单可勾选「启用移动保本」;触发阶梯上移后 **先撤后挂** 交易所 TP/SL(`replace_active_monitor_tpsl_on_exchange`),仅成功后才写库;企业微信提示含「交易所:已先撤后挂止盈止损」.未配置实盘 API 时仅更新本地止损. +- 开仓 TP/SL 仍通过 OKX `attachAlgoOrds`(与原有逻辑一致);重挂使用 ccxt `stopLoss` / `takeProfit` 参数,触发价经 `_okx_algo_trigger_price_str` 格式化. + +## 统计分析页(`/stats`) + +| 项 | 说明 | +|----|------| +| 切日 | 北京时间;边界 = `TRADING_DAY_RESET_HOUR:00`(默认 8) | +| 品类下拉 | 全部交易,下单监控,关键位箱体突破,关键位收敛结构,关键位斐波0.618,关键位斐波0.786 | +| URL | `stats_segment=`(`all` / `manual` / `key_box` / `key_conv` / `key_fib618` / `key_fib786`) | +| 与 UTC 窗 | 统计 **不** 随顶栏列表窗变化 | + +## 斐波关键位监控(与 Gate / Binance 对齐) + +| 项 | 说明 | +|----|------| +| 类型 | **斐波回调0.618**,**斐波回调0.786**(交易执行页关键位表单) | +| 同币互斥 | 每币仅一条斐波监控 | +| 挂单价 E | 做多 `E = H − ratio×(H−L)`;做空 `E = L + ratio×(H−L)`;SL/TP 为 L/H | +| 添加后 | 立即在 OKX 挂限价单;卡片显示 **挂E**,限价单 ID | +| 失效 | 标记价触达止盈侧且限价未成交 → 仅撤本条限价单(`cancel_fib_limit_order`) | +| 成交后 | 挂交易所 TP/SL → 写入 `order_monitors`(`monitor_type=关键位监控`,`key_signal_type=斐波回调…`)→ 从关键位表移除 | +| 轮询 | `check_fib_key_monitors()`(与箱体/收敛 `check_key_monitors()` 分离) | +| 盈亏比 | 计划 RR 须 > `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5) | +| 日成交量 | 排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30) | + +计算逻辑见仓库根目录 `fib_key_monitor_lib.py`. + +## 与 Gate 的差异(其余) + +- 无独立「关键位监控」导航页(斐波在 **交易执行** 页添加). +- 箱体/收敛与 Gate/Binance 相同:**门控 + RR 达标后自动市价开仓**(须 `LIVE_TRADING_ENABLED=true`). + +## 交易所已实现盈亏(与 Gate 一致) + +- 打开 **交易执行 / 交易记录** 等主页面时,若已配置 `OKX_API_KEY` / `OKX_API_SECRET` / `OKX_API_PASSPHRASE`(只读即可),同进程约 **25 秒**内最多调用一次 OKX **历史仓位**(`fetch_positions_history`),为未写入 `exchange_sync_key` 的记录匹配并回填 `exchange_realized_pnl`. +- 复盘列表盈亏优先展示交易所 U(旁标 **所**);本地公式估算标 **估**;人工复核优先. +- 手动强制同步:`GET /api/sync_exchange_pnl`(需登录). +- 可选 `.env`:`EXCHANGE_POSITION_SYNC_FROM_BJ`(北京时间起点),`EXCHANGE_POSITION_HISTORY_LIMIT`(默认 200). + +## 企业微信推送(与 Gate 对齐) + +- 平仓:`📉 … 平仓完成` 模板(盈亏 ±X.XX U,价位两位/按币价精度,账户资金 2 位小数). +- 开仓成功:与 Gate 相同的 emoji 分段(条件委托状态文案,RR/张数/名义 2 位小数). +- 移动保本:仅首次触发推送;交易所同步失败同一监控单只告警一次. +- 斐波/关键位/划转等推送数值格式与 Gate 一致(`format_wechat_scalar_2dp`). + +## 配置与部署 + +- 详见 `.env.example` 中 OKX(`OKX_*`)与通用风控项. +- 代码更新后请 **重启 OKX 监控进程**;旧库行不做批量回填,展示字段有则用之,无则回退. + +--- + +## 共享更新记录 + +自 2026-07-16 起,期权/对冲等共享逻辑的变更统一记在仓库根目录 **[docs/更新文档.md](../docs/更新文档.md)**(含原因、改动文件、目标、验收)。最新一条:期权/对冲开仓仅认真实卖一深度。 diff --git a/部署文档.md b/部署文档.md new file mode 100644 index 0000000..f633a08 --- /dev/null +++ b/部署文档.md @@ -0,0 +1,196 @@ +# crypto_okx 部署文档(Ubuntu) + +独立仓库:[https://git.bz121.com/dekun/crypto_okx.git](https://git.bz121.com/dekun/crypto_okx.git) + +**功能与页面操作**见同目录 **[使用说明.md](./使用说明.md)**.脚本细节见 **[deploy/README.md](./deploy/README.md)**. + +--- + +## 一键部署(推荐) + +新服务器**无需先 clone**: + +```bash +curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash +``` + +菜单: + +1. **一键部署** — 环境识别、系统依赖、Node/PM2、venv、密钥、启动 `crypto_okx` +2. **一键卸载** — 备份 `.env` 后移走 `/opt/crypto_okx` +3. **更新** — `git pull` + 重启 / 依赖更新 +4. **仅检测** — 识别环境与依赖,不安装 +0. 退出 + +已安装后再次进入: + +```bash +bash /opt/crypto_okx/deploy/manage.sh +``` + +默认安装路径:`/opt/crypto_okx` +默认端口:`5004`(`.env` 中 `APP_PORT`) +访问:`http://<服务器IP>:5004` → 默认进入期权页 `/options` + +--- + +## 本地部署 + SSH SOCKS 转发 + PM2(可选) + +本机直连 OKX 若被 TLS/SNI reset,可用本机 `ssh -D` 把 SOCKS 出口放到可达 OKX 的 VPS,并配置: + +```env +OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 +``` + +> 安全提醒:不要把 `.env`、私钥、OKX API Key 提交到 Git. + +### 0. 准备 + +- Ubuntu 本机或云主机 +- 能访问 OKX 的 VPS(SSH) +- Python 3.10+、`python3-venv`、`git`、`curl`、`node`/`npm`(装 PM2) + +### 1. 克隆本仓库 + +```bash +cd /opt +git clone https://git.bz121.com/dekun/crypto_okx.git crypto_okx +cd /opt/crypto_okx +cp -n .env.example .env +``` + +或用一键脚本自动克隆到 `/opt/crypto_okx`. + +### 2. SSH SOCKS(可选) + +`~/.ssh/config` 示例: + +```sshconfig +Host okx-vps + HostName YOUR_VPS_IP + User root + IdentityFile ~/.ssh/vps1.pem + IdentitiesOnly yes + ServerAliveInterval 30 + ServerAliveCountMax 3 + ExitOnForwardFailure yes + BatchMode yes +``` + +开隧道并验证: + +```bash +ssh -N -D 127.0.0.1:1080 okx-vps +curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time +``` + +### 3. 环境与依赖 + +推荐: + +```bash +cd /opt/crypto_okx +bash deploy/setup_env.sh --install-system-deps +``` + +手动等价: + +```bash +cd /opt/crypto_okx +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt +``` + +走 SOCKS 须已安装 **PySocks**(含在 `requirements.txt`). + +### 4. 配置 `.env` + +| 文件 | 是否进 Git | 说明 | +|------|------------|------| +| `.env.example` | 是 | 模板 | +| `.env` | 否 | 真实配置;`app.py` 只读此文件 | + +首次: + +```bash +cp -n .env.example .env +nano .env +``` + +至少确认: + +```env +APP_HOST=0.0.0.0 +APP_PORT=5004 +APP_USERNAME=admin +APP_PASSWORD=你的强密码 +FLASK_SECRET_KEY=长随机串 + +LIVE_TRADING_ENABLED=false +OKX_API_KEY=... +OKX_API_SECRET=... +OKX_API_PASSPHRASE=... +OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 +``` + +`git pull` **不会**覆盖 `.env`.升级前可备份:`cp .env .env.backup.$(date +%Y%m%d)`. + +### 5. 手工启动验证 + +```bash +cd /opt/crypto_okx +source .venv/bin/activate +python app.py +``` + +浏览器:`http://127.0.0.1:5004` + +### 6. PM2 常驻 + +```bash +cd /opt/crypto_okx +pm2 start ecosystem.config.cjs +pm2 save +pm2 startup +``` + +进程名:`crypto_okx`.日志:`pm2 logs crypto_okx --lines 100` + +SOCKS 隧道建议用 `tmux`/`autossh`/`ssh -D` 常驻,**不要**交给本项目的 PM2 配置(见 `ecosystem.config.cjs` 注释). + +### 7. 更新 / 卸载 + +```bash +# 更新 +bash /opt/crypto_okx/deploy/manage.sh # 选 3 +# 或 +bash /opt/crypto_okx/deploy/pull_and_restart.sh + +# 卸载 +bash /opt/crypto_okx/deploy/manage.sh # 选 2 +``` + +### 8. 常见问题 + +- **页面打不开**:`pm2 status` / `pm2 logs crypto_okx --lines 50` +- **OKX 失败**:先确认 SOCKS `ss -lntp | grep 1080`,再 `curl --proxy socks5h://...` +- **脚本 `pipefail` 报错**(Windows 编辑过):`sed -i 's/\r$//' deploy/*.sh deploy/lib/*.sh` + +--- + +## 环境变量覆盖(可选) + +```bash +INSTALL_ROOT=/opt/crypto_okx +GIT_URL=https://git.bz121.com/dekun/crypto_okx.git +GIT_BRANCH=main +BACKUP_ROOT=/root/backups +``` + +--- + +## 免责声明 + +请遵守当地法律法规与交易所条款.实盘风险自负.